diff --git a/src/lib/fhir/__tests__/build-bundle.test.ts b/src/lib/fhir/__tests__/build-bundle.test.ts index a7d350c4e..b454d2002 100644 --- a/src/lib/fhir/__tests__/build-bundle.test.ts +++ b/src/lib/fhir/__tests__/build-bundle.test.ts @@ -493,11 +493,11 @@ describe("buildFhirDocumentBundle", () => { const coding = stmt?.medicationCodeableConcept.coding; expect(stmt?.medicationCodeableConcept.text).toBe("Mounjaro"); expect(coding).toHaveLength(2); - // ATC is primary (first), with the name as display. + // ATC is primary (first). No display: the user's name is not the ATC + // name for the code, and the validator rejects it; the name is `.text`. expect(coding?.[0]).toEqual({ system: "http://www.whocc.no/atc", code: "A10BX10", - display: "Mounjaro", }); // RxNorm is secondary, no display. expect(coding?.[1]).toEqual({ @@ -578,7 +578,7 @@ describe("buildFhirDocumentBundle", () => { expect(a.dosage?.site?.coding?.[0]).toEqual({ system: "http://snomed.info/sct", code: "818983003", - display: "Abdomen structure", + display: "Abdomen", }); }); @@ -712,14 +712,14 @@ describe("buildFhirDocumentBundle", () => { it("maps every InjectionSite enum value to its body-region SNOMED concept, preserving laterality in `.text`", () => { const cases: Array<[string, string, string]> = [ - ["ABDOMEN_LEFT", "818983003", "Abdomen structure"], - ["ABDOMEN_RIGHT", "818983003", "Abdomen structure"], - ["ABDOMEN_UPPER_LEFT", "818983003", "Abdomen structure"], - ["ABDOMEN_UPPER_RIGHT", "818983003", "Abdomen structure"], + ["ABDOMEN_LEFT", "818983003", "Abdomen"], + ["ABDOMEN_RIGHT", "818983003", "Abdomen"], + ["ABDOMEN_UPPER_LEFT", "818983003", "Abdomen"], + ["ABDOMEN_UPPER_RIGHT", "818983003", "Abdomen"], ["THIGH_LEFT", "68367000", "Thigh structure"], ["THIGH_RIGHT", "68367000", "Thigh structure"], - ["UPPER_ARM_LEFT", "40983000", "Structure of upper arm"], - ["UPPER_ARM_RIGHT", "40983000", "Structure of upper arm"], + ["UPPER_ARM_LEFT", "40983000", "Upper arm structure"], + ["UPPER_ARM_RIGHT", "40983000", "Upper arm structure"], ]; for (const [site, code, display] of cases) { const bundle = buildFhirDocumentBundle( @@ -846,13 +846,12 @@ describe("buildFhirDocumentBundle", () => { expect(stmtCoding?.[0]).toEqual({ system: "http://www.whocc.no/atc", code: "A10BK03", - display: "Empagliflozin", }); expect(stmtCoding?.[1]).toEqual({ system: "http://fhir.de/CodeSystem/bfarm/atc", code: "A10BK03", - display: "Empagliflozin", }); + expect(stmt?.medicationCodeableConcept.text).toBe("Empagliflozin"); // The administration concept reflects the flag identically; RxNorm follows. const adminCoding = @@ -909,8 +908,12 @@ describe("buildFhirDocumentBundle", () => { const moodObs = bundle.entry .map((e) => e.resource) .filter((r): r is FhirObservation => r.resourceType === "Observation") - .find((o) => o.code.coding?.some((c) => c.code === "76542-6")); + .find((o) => o.code.text === "Mood (average over period)"); expect(moodObs?.valueQuantity?.value).toBe(3.6); + // 76542-6 does not exist in LOINC: text-only, and a survey finding, not + // a vital sign (so the vital-signs panel does not claim it). + expect(moodObs?.code.coding).toBeUndefined(); + expect(moodObs?.category?.[0]?.coding?.[0]?.code).toBe("survey"); }); it("emits no Observation for a type with no readings (empty domain)", () => { @@ -953,7 +956,7 @@ describe("buildFhirDocumentBundle", () => { expect(spo2?.valueQuantity?.code).toBe("%"); }); - it("maps VO2max to 96402-2 with the iOS display", () => { + it("maps VO2max to the HealthKit identifier, not the nonexistent LOINC 96402-2", () => { const bundle = buildFhirDocumentBundle( makeData({ measurements: { @@ -963,10 +966,15 @@ describe("buildFhirDocumentBundle", () => { { insuranceNumber: null }, FIXED_NOW, ); - const coding = observationsOf(bundle) - .flatMap((o) => o.code.coding ?? []) - .find((c) => c.code === "96402-2"); - expect(coding?.display).toBe("Oxygen consumption maximum during exercise"); + const codings = observationsOf(bundle).flatMap((o) => o.code.coding ?? []); + expect(codings.some((c) => c.code === "96402-2")).toBe(false); + const coding = codings.find( + (c) => c.code === "HKQuantityTypeIdentifierVO2Max", + ); + expect(coding?.system).toBe( + "https://healthlog.dev/fhir/CodeSystem/healthkit", + ); + expect(coding?.display).toBe("VO2 max (estimated)"); }); it("emits steps with UCUM {steps}", () => { @@ -1008,7 +1016,7 @@ describe("buildFhirDocumentBundle", () => { expect(sleep?.valueQuantity?.value).toBe(7.5); }); - it("maps body water + bone mass to the iOS-locked LOINC codes", () => { + it("maps body water + bone mass to the LOINC body-composition terms", () => { const bundle = buildFhirDocumentBundle( makeData({ measurements: { @@ -1022,10 +1030,14 @@ describe("buildFhirDocumentBundle", () => { FIXED_NOW, ); const water = observationsOf(bundle).find((o) => - o.code.coding?.some((c) => c.code === "73704-9"), + o.code.coding?.some( + (c) => c.code === "101683-1" && c.display === "Body water mass", + ), ); const bone = observationsOf(bundle).find((o) => - o.code.coding?.some((c) => c.code === "73708-0"), + o.code.coding?.some( + (c) => c.code === "101685-6" && c.display === "Body bone mass", + ), ); expect(water?.valueQuantity?.code).toBe("kg"); expect(bone?.valueQuantity?.code).toBe("kg"); @@ -1049,8 +1061,11 @@ describe("buildFhirDocumentBundle", () => { const energy = observationsOf(bundle).find((o) => o.code.coding?.some((c) => c.code === "41981-2"), ); + // Not 41957-2 (a 24-hour mean): a HealthKit walking-speed reading. const speed = observationsOf(bundle).find((o) => - o.code.coding?.some((c) => c.code === "41957-2"), + o.code.coding?.some( + (c) => c.code === "HKQuantityTypeIdentifierWalkingSpeed", + ), ); expect(energy?.valueQuantity?.code).toBe("kcal"); // Walking speed FHIR value stays m/s (no km/h conversion). diff --git a/src/lib/fhir/__tests__/cycle-observations.test.ts b/src/lib/fhir/__tests__/cycle-observations.test.ts index bff847b86..330a83d63 100644 --- a/src/lib/fhir/__tests__/cycle-observations.test.ts +++ b/src/lib/fhir/__tests__/cycle-observations.test.ts @@ -4,11 +4,7 @@ import type { DoctorReportData } from "@/lib/doctor-report-data"; import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics"; import { cycleObservationsFromReportData } from "@/lib/fhir/resources"; import { buildFhirDocumentBundle } from "@/lib/fhir/build-bundle"; -import { - LMP_LOINC, - CYCLE_LENGTH_LOINC, - PERIOD_LENGTH_LOINC, -} from "@/lib/fhir/loinc-map"; +import { LMP_LOINC } from "@/lib/fhir/loinc-map"; function baseData(overrides?: Partial): DoctorReportData { return { @@ -68,9 +64,17 @@ describe("fhir/cycle observations", () => { const codes = obs.flatMap((o) => (o.code.coding ?? []).map((c) => c.code).filter(Boolean), ); - expect(codes).toContain(LMP_LOINC); - expect(codes).toContain(CYCLE_LENGTH_LOINC); - expect(codes).toContain(PERIOD_LENGTH_LOINC); + // LMP is the only coded one. The averages are text-only: 64700-8 and + // 64698-4, used before, are PhenX questionnaire items, not these values. + expect(codes).toEqual([LMP_LOINC]); + const cycleLength = obs.find( + (o) => o.code.text === "Average menstrual cycle length", + ); + const periodLength = obs.find( + (o) => o.code.text === "Average period length", + ); + expect(cycleLength?.code.coding).toBeUndefined(); + expect(periodLength?.code.coding).toBeUndefined(); const lmp = obs.find((o) => (o.code.coding ?? []).some((c) => c.code === LMP_LOINC), diff --git a/src/lib/fhir/__tests__/glucose-clinical-observations.test.ts b/src/lib/fhir/__tests__/glucose-clinical-observations.test.ts index 15b065a4c..1274631e9 100644 --- a/src/lib/fhir/__tests__/glucose-clinical-observations.test.ts +++ b/src/lib/fhir/__tests__/glucose-clinical-observations.test.ts @@ -6,7 +6,6 @@ import { GLUCOSE_TIR_LOINC, GLUCOSE_GMI_LOINC, GLUCOSE_MEAN_LOINC, - GLUCOSE_EA1C_LOINC, } from "@/lib/fhir/loinc-map"; import { buildFhirDocumentBundle } from "../build-bundle"; @@ -89,7 +88,28 @@ describe("clinical glucose-panel FHIR Observations", () => { expect(codes).toContain(GLUCOSE_TIR_LOINC); expect(codes).toContain(GLUCOSE_GMI_LOINC); expect(codes).toContain(GLUCOSE_MEAN_LOINC); - expect(codes).toContain(GLUCOSE_EA1C_LOINC); + + // Each coded term carries the display LOINC publishes for it. + const displayOf = (code: string) => + observationsOf(bundle) + .flatMap((o) => o.code.coding ?? []) + .find((c) => c.code === code)?.display; + expect(displayOf(GLUCOSE_TIR_LOINC)).toBe( + "Glucose measurements in range out of Total glucose measurements during reporting period", + ); + expect(displayOf(GLUCOSE_GMI_LOINC)).toBe("Glucose management indicator"); + expect(displayOf(GLUCOSE_MEAN_LOINC)).toBe( + "Average glucose [Mass/volume] in Interstitial fluid during Reporting Period", + ); + + // Estimated A1C is text-only: 41995-2, used before, is a measured + // "Hemoglobin A1c [Mass/volume] in Blood". + const ea1c = observationsOf(bundle).find( + (o) => o.code.text === "Estimated A1C", + ); + expect(ea1c).toBeDefined(); + expect(ea1c?.code.coding).toBeUndefined(); + expect(codes).not.toContain("41995-2"); }); it("emits a percent-valued, UCUM-coded TIR Observation", () => { @@ -122,6 +142,8 @@ describe("clinical glucose-panel FHIR Observations", () => { expect(codes).not.toContain(GLUCOSE_TIR_LOINC); expect(codes).not.toContain(GLUCOSE_GMI_LOINC); expect(codes).not.toContain(GLUCOSE_MEAN_LOINC); - expect(codes).not.toContain(GLUCOSE_EA1C_LOINC); + expect( + observationsOf(bundle).some((o) => o.code.text === "Estimated A1C"), + ).toBe(false); }); }); diff --git a/src/lib/fhir/__tests__/lab-loinc-locales.test.ts b/src/lib/fhir/__tests__/lab-loinc-locales.test.ts index 2e4363e45..bbe104493 100644 --- a/src/lib/fhir/__tests__/lab-loinc-locales.test.ts +++ b/src/lib/fhir/__tests__/lab-loinc-locales.test.ts @@ -35,7 +35,7 @@ describe("resolveLabCoding — the analyte name in every shipped locale", () => ["Ferrytyna", "2276-4"], ["Hémoglobine", "718-7"], ["Emoglobina", "718-7"], - ["Cholestérol LDL", "18262-6"], + ["Cholestérol LDL", "2089-1"], ["Colesterol HDL", "2085-9"], ["Triglycérides", "2571-8"], ["Trójglicerydy", "2571-8"], @@ -80,8 +80,8 @@ describe("resolveLabCoding — the analyte name in every shipped locale", () => describe("resolveLabCoding — the existing English and German behaviour", () => { it.each([ ["HbA1c", "4548-4"], - ["LDL-C", "18262-6"], - ["LDL Cholesterol", "18262-6"], + ["LDL-C", "2089-1"], + ["LDL Cholesterol", "2089-1"], ["Gesamtcholesterin", "2093-3"], ["Kreatinin", "2160-0"], ["GPT", "1742-6"], diff --git a/src/lib/fhir/__tests__/lab-observations.test.ts b/src/lib/fhir/__tests__/lab-observations.test.ts index 0feb2cc46..8ad312ffc 100644 --- a/src/lib/fhir/__tests__/lab-observations.test.ts +++ b/src/lib/fhir/__tests__/lab-observations.test.ts @@ -224,17 +224,17 @@ describe("lab-result LOINC + canonical UCUM coding (v1.18.8)", () => { it("stamps the canonical UCUM code when the unit matches", () => { const o = labOf("LDL", "mg/dL", 110); - expect(o?.code.coding?.[0].code).toBe("18262-6"); + expect(o?.code.coding?.[0].code).toBe("2089-1"); expect(o?.valueQuantity?.system).toBe("http://unitsofmeasure.org"); expect(o?.valueQuantity?.code).toBe("mg/dL"); expect(o?.valueQuantity?.unit).toBe("mg/dL"); }); it("resolves analyte aliases to the same canonical LOINC", () => { - // "LDL", "LDL-C", "LDL Cholesterol" all fold to 18262-6. + // "LDL", "LDL-C", "LDL Cholesterol" all fold to 2089-1. for (const name of ["LDL-C", "LDL Cholesterol", "ldl_c"]) { const o = labOf(name, "mg/dL", 110); - expect(o?.code.coding?.[0].code).toBe("18262-6"); + expect(o?.code.coding?.[0].code).toBe("2089-1"); } // German alias for total cholesterol. const chol = labOf("Gesamtcholesterin", "mg/dL", 180); diff --git a/src/lib/fhir/__tests__/terminology-displays.test.ts b/src/lib/fhir/__tests__/terminology-displays.test.ts new file mode 100644 index 000000000..f712fb2e0 --- /dev/null +++ b/src/lib/fhir/__tests__/terminology-displays.test.ts @@ -0,0 +1,175 @@ +/** + * Codes and displays the official HL7 FHIR validator (R4, tx.fhir.org) + * rejected before v1.39.3, pinned at the values it accepts. A display must be + * one LOINC or SNOMED CT publishes for the code; a code must be active and + * mean what the Observation says. + */ +import { describe, expect, it } from "vitest"; + +import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics"; +import type { DoctorReportData } from "@/lib/doctor-report-data"; +import { buildFhirDocumentBundle } from "@/lib/fhir/build-bundle"; +import { resolveLabCoding } from "@/lib/fhir/lab-loinc"; +import type { FhirCoding, FhirObservation } from "@/lib/fhir/types"; + +const FIXED_NOW = new Date("2026-05-03T12:00:00.000Z"); + +function codings(overrides: Partial): FhirCoding[] { + const data = { + period: { + days: 90, + since: "2026-02-02T00:00:00.000Z", + start: "2026-02-02T00:00:00.000Z", + end: "2026-05-03T12:00:00.000Z", + }, + patient: { username: "sample-user", dateOfBirth: null, gender: null }, + practiceName: null, + measurements: {}, + stats: {}, + glucoseStats: {}, + glucoseRanges: {}, + glucoseClinical: computeGlucoseClinicalMetrics([], { now: FIXED_NOW }), + glucoseUnit: "mg/dL", + bmi: null, + compliance: {}, + medications: [], + mood: null, + glp1: null, + ...overrides, + } as DoctorReportData; + const bundle = buildFhirDocumentBundle( + data, + { insuranceNumber: null }, + FIXED_NOW, + ); + return bundle.entry + .map((e) => e.resource) + .filter((r): r is FhirObservation => r.resourceType === "Observation") + .flatMap((o) => [ + ...(o.code.coding ?? []), + ...(o.component ?? []).flatMap((c) => c.code.coding ?? []), + ]); +} + +describe("LOINC displays accepted by the validator", () => { + it("names the blood-pressure panel and its components as LOINC does", () => { + const all = codings({ + measurements: { + BLOOD_PRESSURE_SYS: [ + { value: 120, measuredAt: "2026-04-30T08:00:00.000Z" }, + ], + BLOOD_PRESSURE_DIA: [ + { value: 78, measuredAt: "2026-04-30T08:00:00.000Z" }, + ], + }, + }); + const display = (code: string) => all.find((c) => c.code === code)?.display; + expect(display("85354-9")).toBe( + "Blood pressure panel with all children optional", + ); + expect(display("8480-6")).toBe("Systolic blood pressure"); + expect(display("8462-4")).toBe("Diastolic blood pressure"); + }); + + it("names medication adherence as LOINC does", () => { + const all = codings({ + compliance: { + "Example Drug": { total: 10, taken: 9, skipped: 1, missed: 0 }, + }, + }); + expect(all.find((c) => c.code === "71799-1")?.display).toBe( + "Adherence to prescribed medication instructions [Reported]", + ); + }); +}); + +describe("R4 vital-signs profile codes", () => { + it("adds the heart-rate and oxygen-saturation magic codes beside the specific ones", () => { + const all = codings({ + measurements: { + RESTING_HEART_RATE: [ + { value: 54, measuredAt: "2026-04-30T08:00:00.000Z" }, + ], + OXYGEN_SATURATION: [ + { value: 97, measuredAt: "2026-04-30T08:00:00.000Z" }, + ], + }, + }); + const codes = all.map((c) => c.code); + expect(codes).toEqual( + expect.arrayContaining(["40443-4", "8867-4", "59408-5", "2708-6"]), + ); + }); +}); + +describe("medication codings", () => { + it("carries no display on an ATC coding; the user's name stays on text", () => { + const bundle = buildFhirDocumentBundle( + { + period: { + days: 90, + since: "2026-02-02T00:00:00.000Z", + start: "2026-02-02T00:00:00.000Z", + end: "2026-05-03T12:00:00.000Z", + }, + patient: { username: "sample-user", dateOfBirth: null, gender: null }, + practiceName: null, + measurements: {}, + stats: {}, + glucoseStats: {}, + glucoseRanges: {}, + glucoseClinical: computeGlucoseClinicalMetrics([], { now: FIXED_NOW }), + glucoseUnit: "mg/dL", + bmi: null, + compliance: {}, + medications: [ + { name: "Delix 5", dose: "5mg", schedules: [], atcCode: "C09AA05" }, + ], + mood: null, + glp1: null, + } as unknown as DoctorReportData, + { insuranceNumber: null }, + FIXED_NOW, + { germanAtc: true }, + ); + const stmt = bundle.entry + .map((e) => e.resource) + .find((r) => r.resourceType === "MedicationStatement"); + if (stmt?.resourceType !== "MedicationStatement") throw new Error("none"); + expect(stmt.medicationCodeableConcept.text).toBe("Delix 5"); + expect(stmt.medicationCodeableConcept.coding).toEqual([ + { system: "http://www.whocc.no/atc", code: "C09AA05" }, + { system: "http://fhir.de/CodeSystem/bfarm/atc", code: "C09AA05" }, + ]); + }); +}); + +describe("lab LOINC terms", () => { + it.each([ + // Method-less LDL; 18262-6 is the direct-assay term. + [ + "LDL-C", + "mg/dL", + "2089-1", + "Cholesterol in LDL [Mass/volume] in Serum or Plasma", + ], + // Total 25-OH vitamin D; 1989-3 is D3 alone. + [ + "Vitamin D", + "ng/mL", + "62292-8", + "25-Hydroxyvitamin D3+25-Hydroxyvitamin D2 [Mass/volume] in Serum or Plasma", + ], + // Formula-less eGFR; 33914-3 (MDRD) is DISCOURAGED. + [ + "eGFR", + "mL/min/1.73m2", + "69405-9", + "Glomerular filtration rate [Volume Rate/Area] in Serum, Plasma or Blood by based on 1.73 sq M", + ], + ])("codes %s as %s", (analyte, unit, code, display) => { + const coding = resolveLabCoding(analyte, unit); + expect(coding?.loinc).toBe(code); + expect(coding?.display).toBe(display); + }); +}); diff --git a/src/lib/fhir/lab-loinc.ts b/src/lib/fhir/lab-loinc.ts index 1d6562b56..8b2a51158 100644 --- a/src/lib/fhir/lab-loinc.ts +++ b/src/lib/fhir/lab-loinc.ts @@ -79,8 +79,10 @@ const LAB_LOINC_BY_KEY: Record = { display: "Hemoglobin A1c/Hemoglobin.total in Blood", ucum: "%", }, + // Method-less LDL: a recorded value does not say whether it was measured + // directly (18262-6) or calculated (13457-7). ldlc: { - loinc: "18262-6", + loinc: "2089-1", display: "Cholesterol in LDL [Mass/volume] in Serum or Plasma", ucum: "mg/dL", }, @@ -109,10 +111,11 @@ const LAB_LOINC_BY_KEY: Record = { display: "Thyrotropin [Units/volume] in Serum or Plasma", ucum: "m[IU]/L", }, + // Total 25-OH vitamin D (D2+D3), the value labs report; 1989-3 is D3 alone. vitamind: { - loinc: "1989-3", + loinc: "62292-8", display: - "25-hydroxyvitamin D2+25-hydroxyvitamin D3 [Mass/volume] in Serum or Plasma", + "25-Hydroxyvitamin D3+25-Hydroxyvitamin D2 [Mass/volume] in Serum or Plasma", ucum: "ng/mL", }, creatinine: { @@ -120,10 +123,12 @@ const LAB_LOINC_BY_KEY: Record = { display: "Creatinine [Mass/volume] in Serum or Plasma", ucum: "mg/dL", }, + // Formula-less eGFR per 1.73 m2. A recorded value does not name its + // formula, and 33914-3 (MDRD) is DISCOURAGED in LOINC. egfr: { - loinc: "33914-3", + loinc: "69405-9", display: - "Glomerular filtration rate/1.73 sq M.predicted by Creatinine-based formula (MDRD)", + "Glomerular filtration rate [Volume Rate/Area] in Serum, Plasma or Blood by based on 1.73 sq M", ucum: "mL/min/{1.73_m2}", }, alt: { diff --git a/src/lib/fhir/loinc-map.ts b/src/lib/fhir/loinc-map.ts index 4920d04fa..aab8101d3 100644 --- a/src/lib/fhir/loinc-map.ts +++ b/src/lib/fhir/loinc-map.ts @@ -95,18 +95,21 @@ export const GLUCOSE_TIR_LOINC = "97510-2"; export const GLUCOSE_GMI_LOINC = "97506-0"; /** Mean glucose over the reporting period. */ export const GLUCOSE_MEAN_LOINC = "97507-8"; -/** Estimated A1C (ADAG, derived from mean glucose). */ -export const GLUCOSE_EA1C_LOINC = "41995-2"; +/* + * Estimated A1C (ADAG, derived from mean glucose) has no LOINC term of its + * own: 41995-2, used here before, is a measured "Hemoglobin A1c + * [Mass/volume] in Blood". The builder emits it text-only. + */ /** Medication-adherence Observation LOINC. */ export const MEDICATION_ADHERENCE_LOINC = "71799-1"; -/** Mood Observation LOINC (opt-in only). */ -export const MOOD_LOINC = "76542-6"; /* ── Cycle / reproductive-health LOINCs (v1.15.0, opt-in only) ──────── */ /** Last menstrual period (LMP) start date. */ export const LMP_LOINC = "8665-2"; -/** Menstrual cycle length [time]. */ -export const CYCLE_LENGTH_LOINC = "64700-8"; -/** Length of menses (period / bleeding duration). */ -export const PERIOD_LENGTH_LOINC = "64698-4"; +/* + * Average cycle length and average period length have no LOINC term for a + * value computed over many cycles. 64700-8 and 64698-4, used before, are + * PhenX questionnaire items (a remembered typical cycle; a breastfeeding + * question), so both Observations are text-only. + */ diff --git a/src/lib/fhir/resources/common.ts b/src/lib/fhir/resources/common.ts index 97fa59619..920ef0592 100644 --- a/src/lib/fhir/resources/common.ts +++ b/src/lib/fhir/resources/common.ts @@ -84,6 +84,24 @@ export function categoryConcept(category: string): FhirCodeableConcept { }; } +/** + * R4 vital-signs profiles require a "magic" LOINC code on `Observation.code` + * and allow a more specific code beside it + * (https://hl7.org/fhir/R4/observation-vitalsigns.html). A resting heart rate + * is a heart rate (8867-4) and a pulse-oximetry SpO2 is an oxygen saturation + * (2708-6); without the magic code the validator rejects both Observations. + */ +const VITAL_SIGNS_MAGIC_CODE: Record< + string, + { code: string; display: string } +> = { + "40443-4": { code: "8867-4", display: "Heart rate" }, + "59408-5": { + code: "2708-6", + display: "Oxygen saturation in Arterial blood", + }, +}; + export function codeableFromMapping(m: LoincMapping): FhirCodeableConcept { if (m.loinc) { // HealthKit placeholder codes have no published LOINC term; they must not @@ -93,8 +111,15 @@ export function codeableFromMapping(m: LoincMapping): FhirCodeableConcept { const system = m.loinc.startsWith("HKQuantityTypeIdentifier") ? HEALTHKIT_CODESYSTEM : LOINC_SYSTEM; + const magic = + system === LOINC_SYSTEM ? VITAL_SIGNS_MAGIC_CODE[m.loinc] : undefined; return { - coding: [{ system, code: m.loinc, display: m.display }], + coding: [ + { system, code: m.loinc, display: m.display }, + ...(magic + ? [{ system: LOINC_SYSTEM, code: magic.code, display: magic.display }] + : []), + ], text: m.display, }; } diff --git a/src/lib/fhir/resources/cycle.ts b/src/lib/fhir/resources/cycle.ts index a954db26d..71d48e289 100644 --- a/src/lib/fhir/resources/cycle.ts +++ b/src/lib/fhir/resources/cycle.ts @@ -1,18 +1,13 @@ /** * v1.15.0 — the cycle / reproductive-health Observations from the opt-in cycle - * summary: LMP (LOINC 8665-2, a date value), average cycle length (64700-8, - * days), average period length (64698-4, days), and the current phase + * summary: LMP (LOINC 8665-2, a date value), average cycle length and average + * period length (text-only, days; see `loinc-map.ts`), and the current phase * (text-only survey finding). Absent when the aggregator carried no cycle * summary (toggle off or no observed cycle). Ids run `obs-cycle-N` so they * never collide with the main `obs-N` sequence. */ import type { DoctorReportData } from "@/lib/doctor-report-data"; -import { - LOINC_SYSTEM, - LMP_LOINC, - CYCLE_LENGTH_LOINC, - PERIOD_LENGTH_LOINC, -} from "@/lib/fhir/loinc-map"; +import { LOINC_SYSTEM, LMP_LOINC } from "@/lib/fhir/loinc-map"; import { ucumQuantity } from "@/lib/fhir/ucum"; import type { FhirObservation } from "@/lib/fhir/types"; import { @@ -76,16 +71,9 @@ export function cycleObservationsFromReportData( id: next(), status: "final", category: [categoryConcept("survey")], - code: { - coding: [ - { - system: LOINC_SYSTEM, - code: CYCLE_LENGTH_LOINC, - display: "Menstrual cycle length", - }, - ], - text: "Average menstrual cycle length", - }, + // Text-only: no LOINC term for a cycle length averaged over the + // observed cycles (see `loinc-map.ts`). + code: { text: "Average menstrual cycle length" }, subject: patientRef, effectivePeriod, valueQuantity: ucumQuantity(cycle.averageCycleLengthDays, "d"), @@ -107,16 +95,8 @@ export function cycleObservationsFromReportData( id: next(), status: "final", category: [categoryConcept("survey")], - code: { - coding: [ - { - system: LOINC_SYSTEM, - code: PERIOD_LENGTH_LOINC, - display: "Length of menses", - }, - ], - text: "Average period length", - }, + // Text-only, like the cycle length above. + code: { text: "Average period length" }, subject: patientRef, effectivePeriod, valueQuantity: ucumQuantity(cycle.averagePeriodLengthDays, "d"), diff --git a/src/lib/fhir/resources/glucose.ts b/src/lib/fhir/resources/glucose.ts index 6bb30e618..0ec140729 100644 --- a/src/lib/fhir/resources/glucose.ts +++ b/src/lib/fhir/resources/glucose.ts @@ -15,7 +15,6 @@ import { GLUCOSE_TIR_LOINC, GLUCOSE_GMI_LOINC, GLUCOSE_MEAN_LOINC, - GLUCOSE_EA1C_LOINC, } from "@/lib/fhir/loinc-map"; import { ucumQuantity } from "@/lib/fhir/ucum"; import type { FhirObservation } from "@/lib/fhir/types"; @@ -92,7 +91,8 @@ export function glucoseClinicalObservations( const observations: FhirObservation[] = []; const push = (opts: { - loinc?: string; + /** LOINC code and its LOINC display; absent for a text-only concept. */ + loinc?: { code: string; display: string }; text: string; value: number; unit: string; @@ -106,7 +106,11 @@ export function glucoseClinicalObservations( code: opts.loinc ? { coding: [ - { system: LOINC_SYSTEM, code: opts.loinc, display: opts.text }, + { + system: LOINC_SYSTEM, + code: opts.loinc.code, + display: opts.loinc.display, + }, ], text: opts.text, } @@ -128,7 +132,11 @@ export function glucoseClinicalObservations( if (clinical.distribution) { push({ - loinc: GLUCOSE_TIR_LOINC, + loinc: { + code: GLUCOSE_TIR_LOINC, + display: + "Glucose measurements in range out of Total glucose measurements during reporting period", + }, text: timeInRangeDisplay(glucoseUnit), value: Math.round(clinical.distribution.tir * 1000) / 10, unit: "%", @@ -136,7 +144,11 @@ export function glucoseClinicalObservations( } if (clinical.meanMgdl !== null) { push({ - loinc: GLUCOSE_MEAN_LOINC, + loinc: { + code: GLUCOSE_MEAN_LOINC, + display: + "Average glucose [Mass/volume] in Interstitial fluid during Reporting Period", + }, text: "Mean glucose", value: convertGlucose(clinical.meanMgdl, glucoseUnit), unit: glucoseUnit, @@ -144,7 +156,10 @@ export function glucoseClinicalObservations( } if (clinical.gmi !== null) { push({ - loinc: GLUCOSE_GMI_LOINC, + loinc: { + code: GLUCOSE_GMI_LOINC, + display: "Glucose management indicator", + }, text: "Glucose Management Indicator (GMI)", value: Math.round(clinical.gmi * 10) / 10, unit: "%", @@ -152,7 +167,8 @@ export function glucoseClinicalObservations( } if (clinical.estimatedA1c !== null) { push({ - loinc: GLUCOSE_EA1C_LOINC, + // Text-only: no LOINC term exists for an A1C estimated from mean + // glucose (see `loinc-map.ts`). text: "Estimated A1C", value: Math.round(clinical.estimatedA1c * 10) / 10, unit: "%", diff --git a/src/lib/fhir/resources/medications.ts b/src/lib/fhir/resources/medications.ts index 233caee61..ea14d2be7 100644 --- a/src/lib/fhir/resources/medications.ts +++ b/src/lib/fhir/resources/medications.ts @@ -35,10 +35,13 @@ function medicationConcept( germanAtc: boolean, ): FhirCodeableConcept { const coding: NonNullable = []; + // No `display` on the ATC codings: a coding's display must be the code + // system's own name for the code ("ramipril"), and the user's name for the + // medication ("Delix 5") is not that. It rides `.text`. if (atcCode) { - coding.push({ system: ATC_SYSTEM, code: atcCode, display: name }); + coding.push({ system: ATC_SYSTEM, code: atcCode }); if (germanAtc) { - coding.push({ system: ATC_BFARM_SYSTEM, code: atcCode, display: name }); + coding.push({ system: ATC_BFARM_SYSTEM, code: atcCode }); } } if (rxNormCode) { @@ -117,14 +120,14 @@ function routeConcept( * no information is lost. */ const SITE_SNOMED: Record = { - ABDOMEN_LEFT: { code: "818983003", display: "Abdomen structure" }, - ABDOMEN_RIGHT: { code: "818983003", display: "Abdomen structure" }, - ABDOMEN_UPPER_LEFT: { code: "818983003", display: "Abdomen structure" }, - ABDOMEN_UPPER_RIGHT: { code: "818983003", display: "Abdomen structure" }, + ABDOMEN_LEFT: { code: "818983003", display: "Abdomen" }, + ABDOMEN_RIGHT: { code: "818983003", display: "Abdomen" }, + ABDOMEN_UPPER_LEFT: { code: "818983003", display: "Abdomen" }, + ABDOMEN_UPPER_RIGHT: { code: "818983003", display: "Abdomen" }, THIGH_LEFT: { code: "68367000", display: "Thigh structure" }, THIGH_RIGHT: { code: "68367000", display: "Thigh structure" }, - UPPER_ARM_LEFT: { code: "40983000", display: "Structure of upper arm" }, - UPPER_ARM_RIGHT: { code: "40983000", display: "Structure of upper arm" }, + UPPER_ARM_LEFT: { code: "40983000", display: "Upper arm structure" }, + UPPER_ARM_RIGHT: { code: "40983000", display: "Upper arm structure" }, }; function siteConcept(injectionSite: string): FhirCodeableConcept { diff --git a/src/lib/fhir/resources/observation.ts b/src/lib/fhir/resources/observation.ts index 45a8bc4a8..72fc8d3af 100644 --- a/src/lib/fhir/resources/observation.ts +++ b/src/lib/fhir/resources/observation.ts @@ -22,7 +22,6 @@ import { BP_DIA_LOINC, BP_UNIT, MEDICATION_ADHERENCE_LOINC, - MOOD_LOINC, } from "@/lib/fhir/loinc-map"; import { ucumQuantity } from "@/lib/fhir/ucum"; import type { FhirObservation } from "@/lib/fhir/types"; @@ -104,7 +103,7 @@ function bloodPressureObservation( { system: LOINC_SYSTEM, code: BP_PANEL_LOINC, - display: "Blood pressure panel", + display: "Blood pressure panel with all children optional", }, ], text: "Blood pressure", @@ -116,7 +115,11 @@ function bloodPressureObservation( { code: { coding: [ - { system: LOINC_SYSTEM, code: BP_SYS_LOINC, display: "Systolic" }, + { + system: LOINC_SYSTEM, + code: BP_SYS_LOINC, + display: "Systolic blood pressure", + }, ], }, valueQuantity: ucumQuantity(sys.value, BP_UNIT), @@ -127,7 +130,7 @@ function bloodPressureObservation( { system: LOINC_SYSTEM, code: BP_DIA_LOINC, - display: "Diastolic", + display: "Diastolic blood pressure", }, ], }, @@ -194,7 +197,8 @@ function adherenceObservations( { system: LOINC_SYSTEM, code: MEDICATION_ADHERENCE_LOINC, - display: "Medication adherence", + display: + "Adherence to prescribed medication instructions [Reported]", }, ], text: `Medication adherence — ${name}`, @@ -207,7 +211,13 @@ function adherenceObservations( return observations; } -/** The opt-in mood average; absent when the toggle is off. */ +/** + * The opt-in mood average; absent when the toggle is off. + * + * Text-only, under `survey`: LOINC has no term for a self-rated mood score + * averaged over a period (76542-6, used before, does not exist in LOINC), and + * a mood score is not a vital sign, so it must not ride the vital-signs panel. + */ function moodObservation( data: DoctorReportData, nextId: () => string, @@ -218,11 +228,8 @@ function moodObservation( resourceType: "Observation", id: nextId(), status: "final", - category: [categoryConcept("vital-signs")], - code: { - coding: [{ system: LOINC_SYSTEM, code: MOOD_LOINC, display: "Mood" }], - text: "Mood (average over period)", - }, + category: [categoryConcept("survey")], + code: { text: "Mood (average over period)" }, subject: patientRef, effectivePeriod: reportingPeriod(data), valueQuantity: ucumQuantity( diff --git a/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json b/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json index 716887d9b..ef14ef361 100644 --- a/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json +++ b/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json @@ -48,8 +48,8 @@ "category": "vital-signs" }, "VO2_MAX": { - "loinc": "96402-2", - "display": "Oxygen consumption maximum during exercise", + "loinc": "HKQuantityTypeIdentifierVO2Max", + "display": "VO2 max (estimated)", "unit": "mL/min/kg", "category": "vital-signs" }, @@ -78,32 +78,32 @@ "category": "activity" }, "WALKING_SPEED": { - "loinc": "41957-2", - "display": "Gait speed [Velocity] Measured", + "loinc": "HKQuantityTypeIdentifierWalkingSpeed", + "display": "Walking speed", "unit": "m/s", "category": "vital-signs" }, "WALKING_ASYMMETRY": { - "loinc": "91557-1", + "loinc": "HKQuantityTypeIdentifierWalkingAsymmetryPercentage", "display": "Walking asymmetry percentage", "unit": "%", "category": "vital-signs" }, "WALKING_STEP_LENGTH": { - "loinc": "41955-6", - "display": "Step length Measured", + "loinc": "HKQuantityTypeIdentifierWalkingStepLength", + "display": "Walking step length", "unit": "m", "category": "vital-signs" }, "TOTAL_BODY_WATER": { - "loinc": "73704-9", - "display": "Body water by Bioelectrical impedance analysis", + "loinc": "101683-1", + "display": "Body water mass", "unit": "kg", "category": "vital-signs" }, "BONE_MASS": { - "loinc": "73708-0", - "display": "Bone mineral content by DXA", + "loinc": "101685-6", + "display": "Body bone mass", "unit": "kg", "category": "vital-signs" }, diff --git a/src/lib/signals/registry.ts b/src/lib/signals/registry.ts index afb003c53..17ca5d025 100644 --- a/src/lib/signals/registry.ts +++ b/src/lib/signals/registry.ts @@ -574,8 +574,11 @@ export const SIGNALS: Record = { mcp: true, }, fhir: { - loinc: "96402-2", - display: "Oxygen consumption maximum during exercise", + // 96402-2, used before, does not exist in LOINC. The watch estimate is + // not a measured exercise-test VO2 peak (94122-9), so the HealthKit + // identifier rides the custom system. + loinc: "HKQuantityTypeIdentifierVO2Max", + display: "VO2 max (estimated)", unit: "mL/min/kg", category: "vital-signs", }, @@ -656,8 +659,9 @@ export const SIGNALS: Record = { mcp: true, }, fhir: { - loinc: "73704-9", - display: "Body water by Bioelectrical impedance analysis", + // 73704-9, used before, is a nasogastric fluid output. + loinc: "101683-1", + display: "Body water mass", unit: "kg", category: "vital-signs", }, @@ -677,8 +681,9 @@ export const SIGNALS: Record = { mcp: true, }, fhir: { - loinc: "73708-0", - display: "Bone mineral content by DXA", + // 73708-0, used before, is "Body fat [Mass] Calculated". + loinc: "101685-6", + display: "Body bone mass", unit: "kg", category: "vital-signs", }, @@ -821,7 +826,8 @@ export const SIGNALS: Record = { mcp: true, }, fhir: { - loinc: "91557-1", + // 91557-1, used before, does not exist in LOINC. + loinc: "HKQuantityTypeIdentifierWalkingAsymmetryPercentage", display: "Walking asymmetry percentage", unit: "%", category: "vital-signs", @@ -864,8 +870,10 @@ export const SIGNALS: Record = { mcp: true, }, fhir: { - loinc: "41955-6", - display: "Step length Measured", + // 41955-6, used before, is "Distance walked in 1 week"; LOINC has no + // step-length term, so the HealthKit identifier rides the custom system. + loinc: "HKQuantityTypeIdentifierWalkingStepLength", + display: "Walking step length", unit: "m", category: "vital-signs", }, @@ -886,8 +894,10 @@ export const SIGNALS: Record = { mcp: true, }, fhir: { - loinc: "41957-2", - display: "Gait speed [Velocity] Measured", + // 41957-2, used before, is a 24-hour mean walking speed; the value + // here is a HealthKit walking-speed reading. + loinc: "HKQuantityTypeIdentifierWalkingSpeed", + display: "Walking speed", unit: "m/s", category: "vital-signs", },