From a08544db7daea25016b4a2a1756f1c03c3d479b5 Mon Sep 17 00:00:00 2001 From: marcuspaico Date: Mon, 17 Aug 2026 15:23:34 -0700 Subject: [PATCH] feat(labs): unit conversion, analyte registry, marker normalization --- server/src/lib/analytes.ts | 508 ++++++++++++++++++++++++++++++++++ server/src/lib/normalize.ts | 62 +++++ server/src/lib/units.ts | 49 ++++ server/test/normalize.test.ts | 55 ++++ 4 files changed, 674 insertions(+) create mode 100644 server/src/lib/analytes.ts create mode 100644 server/src/lib/normalize.ts create mode 100644 server/src/lib/units.ts create mode 100644 server/test/normalize.test.ts diff --git a/server/src/lib/analytes.ts b/server/src/lib/analytes.ts new file mode 100644 index 0000000..75f8b68 --- /dev/null +++ b/server/src/lib/analytes.ts @@ -0,0 +1,508 @@ +// Analyte registry: canonical units, molar masses, and panel groupings for +// lab markers. `molarMass` is only set when a mass<->mole bridge is actually +// used in practice for that analyte (see units.ts); it stays null for +// analytes reported purely by mass, by count, as a percentage/ratio, in +// enzyme-activity units (U/L, IU/L), or in units the converter treats as +// "other" (e.g. eGFR's mL/min/1.73m2, MCV's fL) — those never convert. +export interface Analyte { + key: string; + display: string; + canonicalUnit: string; + molarMass: number | null; + panel: string; + aliases: string[]; +} + +export const ANALYTES: Analyte[] = [ + // --- iron --- + { + key: "iron", + display: "Iron", + canonicalUnit: "µmol/L", + molarMass: 55.845, + panel: "iron", + aliases: ["iron", "serum iron", "fe"], + }, + { + key: "ferritin", + display: "Ferritin", + canonicalUnit: "µg/L", + molarMass: null, + panel: "iron", + aliases: ["ferritin", "serum ferritin"], + }, + { + key: "transferrin", + display: "Transferrin", + canonicalUnit: "g/L", + molarMass: null, + panel: "iron", + aliases: ["transferrin"], + }, + { + key: "tibc", + display: "Total Iron Binding Capacity", + canonicalUnit: "µmol/L", + molarMass: 55.845, + panel: "iron", + aliases: ["tibc", "total iron binding capacity", "total iron-binding capacity"], + }, + { + key: "transferrin_saturation", + display: "Transferrin Saturation", + canonicalUnit: "%", + molarMass: null, + panel: "iron", + aliases: ["transferrin saturation", "iron saturation", "tsat", "% saturation", "percent saturation"], + }, + + // --- hormones --- + { + key: "testosterone_total", + display: "Total Testosterone", + canonicalUnit: "ng/dL", + molarMass: 288.42, + panel: "hormones", + aliases: ["testosterone", "total testosterone", "testosterone total", "testosterone, total"], + }, + { + key: "testosterone_free", + display: "Free Testosterone", + canonicalUnit: "pg/mL", + molarMass: 288.42, + panel: "hormones", + aliases: ["free testosterone", "testosterone free", "testosterone, free"], + }, + { + key: "shbg", + display: "SHBG", + canonicalUnit: "nmol/L", + molarMass: null, + panel: "hormones", + aliases: ["shbg", "sex hormone binding globulin", "sex hormone-binding globulin"], + }, + { + key: "dhea_s", + display: "DHEA-S", + canonicalUnit: "µmol/L", + molarMass: 384.5, + panel: "hormones", + aliases: ["dhea-s", "dhea s", "dheas", "dehydroepiandrosterone sulfate"], + }, + { + key: "cortisol", + display: "Cortisol", + canonicalUnit: "nmol/L", + molarMass: 362.46, + panel: "hormones", + aliases: ["cortisol", "cortisol am", "cortisol, am", "morning cortisol"], + }, + { + key: "estradiol", + display: "Estradiol", + canonicalUnit: "pmol/L", + molarMass: 272.38, + panel: "hormones", + aliases: ["estradiol", "oestradiol", "e2"], + }, + { + key: "prolactin", + display: "Prolactin", + canonicalUnit: "µg/L", + molarMass: null, + panel: "hormones", + aliases: ["prolactin", "prl"], + }, + { + key: "fsh", + display: "FSH", + canonicalUnit: "IU/L", + molarMass: null, + panel: "hormones", + aliases: ["fsh", "follicle stimulating hormone", "follicle-stimulating hormone"], + }, + { + key: "lh", + display: "LH", + canonicalUnit: "IU/L", + molarMass: null, + panel: "hormones", + aliases: ["lh", "luteinizing hormone"], + }, + { + key: "insulin", + display: "Insulin", + canonicalUnit: "µIU/mL", + molarMass: null, + panel: "hormones", + aliases: ["insulin", "fasting insulin", "insulin fasting", "insulin, fasting"], + }, + { + key: "igf1", + display: "IGF-1", + canonicalUnit: "µg/L", + molarMass: null, + panel: "hormones", + aliases: ["igf-1", "igf1", "igf 1", "insulin-like growth factor 1", "insulin like growth factor 1"], + }, + + // --- thyroid --- + { + key: "tsh", + display: "TSH", + canonicalUnit: "mIU/L", + molarMass: null, + panel: "thyroid", + aliases: ["tsh", "thyroid stimulating hormone", "thyroid-stimulating hormone"], + }, + { + key: "free_t4", + display: "Free T4", + canonicalUnit: "pmol/L", + molarMass: null, + panel: "thyroid", + aliases: ["free t4", "ft4", "free thyroxine", "t4 free", "thyroxine free", "thyroxine, free"], + }, + { + key: "free_t3", + display: "Free T3", + canonicalUnit: "pmol/L", + molarMass: null, + panel: "thyroid", + aliases: ["free t3", "ft3", "free triiodothyronine", "t3 free", "triiodothyronine free", "triiodothyronine, free"], + }, + + // --- lipids --- + { + key: "cholesterol_total", + display: "Total Cholesterol", + canonicalUnit: "mmol/L", + molarMass: 386.65, + panel: "lipids", + aliases: ["cholesterol", "total cholesterol", "cholesterol total", "cholesterol, total"], + }, + { + key: "hdl", + display: "HDL Cholesterol", + canonicalUnit: "mmol/L", + molarMass: 386.65, + panel: "lipids", + aliases: ["hdl", "hdl cholesterol", "hdl-c", "cholesterol hdl"], + }, + { + key: "ldl", + display: "LDL Cholesterol", + canonicalUnit: "mmol/L", + molarMass: 386.65, + panel: "lipids", + aliases: ["ldl", "ldl cholesterol", "ldl-c", "cholesterol ldl", "ldl calculated", "ldl, calculated"], + }, + { + key: "triglycerides", + display: "Triglycerides", + canonicalUnit: "mmol/L", + molarMass: 885.4, + panel: "lipids", + aliases: ["triglycerides", "trig", "trigs", "tg"], + }, + { + key: "non_hdl", + display: "Non-HDL Cholesterol", + canonicalUnit: "mmol/L", + molarMass: 386.65, + panel: "lipids", + aliases: ["non-hdl", "non hdl", "non-hdl cholesterol", "non hdl cholesterol"], + }, + { + key: "apob", + display: "Apolipoprotein B", + canonicalUnit: "g/L", + molarMass: null, + panel: "lipids", + aliases: ["apob", "apo b", "apolipoprotein b"], + }, + { + key: "lipoprotein_a", + display: "Lipoprotein(a)", + canonicalUnit: "nmol/L", + molarMass: null, + panel: "lipids", + aliases: ["lipoprotein a", "lipoprotein(a)", "lp(a)", "lpa"], + }, + + // --- metabolic --- + { + key: "glucose", + display: "Glucose", + canonicalUnit: "mmol/L", + molarMass: 180.16, + panel: "metabolic", + aliases: ["glucose", "glucose fasting", "fasting glucose", "blood glucose"], + }, + { + key: "hba1c", + display: "HbA1c", + canonicalUnit: "%", + molarMass: null, + panel: "metabolic", + aliases: ["hba1c", "hemoglobin a1c", "haemoglobin a1c", "a1c", "glycated hemoglobin", "glycated haemoglobin"], + }, + { + key: "uric_acid", + display: "Uric Acid", + canonicalUnit: "mmol/L", + molarMass: 168.11, + panel: "metabolic", + aliases: ["uric acid", "urate"], + }, + + // --- inflammation --- + { + key: "hs_crp", + display: "hs-CRP", + canonicalUnit: "mg/L", + molarMass: null, + panel: "inflammation", + aliases: ["hs-crp", "hscrp", "hs crp", "high sensitivity crp", "high-sensitivity crp", "c-reactive protein", "crp"], + }, + + // --- liver --- + { + key: "alt", + display: "ALT", + canonicalUnit: "U/L", + molarMass: null, + panel: "liver", + aliases: ["alt", "sgpt", "alanine aminotransferase", "alanine transaminase"], + }, + { + key: "ast", + display: "AST", + canonicalUnit: "U/L", + molarMass: null, + panel: "liver", + aliases: ["ast", "sgot", "aspartate aminotransferase", "aspartate transaminase"], + }, + { + key: "ggt", + display: "GGT", + canonicalUnit: "U/L", + molarMass: null, + panel: "liver", + aliases: ["ggt", "gamma gt", "gamma-glutamyl transferase", "gamma glutamyl transferase"], + }, + { + key: "alp", + display: "ALP", + canonicalUnit: "U/L", + molarMass: null, + panel: "liver", + aliases: ["alp", "alkaline phosphatase"], + }, + { + key: "bilirubin_total", + display: "Total Bilirubin", + canonicalUnit: "µmol/L", + molarMass: 584.66, + panel: "liver", + aliases: ["bilirubin", "total bilirubin", "bilirubin total", "bilirubin, total"], + }, + { + key: "albumin", + display: "Albumin", + canonicalUnit: "g/L", + molarMass: null, + panel: "liver", + aliases: ["albumin"], + }, + { + key: "total_protein", + display: "Total Protein", + canonicalUnit: "g/L", + molarMass: null, + panel: "liver", + aliases: ["total protein", "protein total", "protein, total"], + }, + + // --- kidney --- + { + key: "creatinine", + display: "Creatinine", + canonicalUnit: "µmol/L", + molarMass: 113.12, + panel: "kidney", + aliases: ["creatinine", "serum creatinine"], + }, + { + key: "egfr", + display: "eGFR", + canonicalUnit: "mL/min/1.73m2", + molarMass: null, + panel: "kidney", + aliases: ["egfr", "estimated gfr", "estimated glomerular filtration rate", "gfr estimated", "gfr, estimated"], + }, + { + key: "urea", + display: "Urea", + canonicalUnit: "mmol/L", + molarMass: 60.06, + panel: "kidney", + aliases: ["urea", "bun", "blood urea nitrogen"], + }, + + // --- cbc --- + { + key: "hemoglobin", + display: "Hemoglobin", + canonicalUnit: "g/L", + molarMass: null, + panel: "cbc", + aliases: ["hemoglobin", "haemoglobin", "hgb", "hb"], + }, + { + key: "hematocrit", + display: "Hematocrit", + canonicalUnit: "%", + molarMass: null, + panel: "cbc", + aliases: ["hematocrit", "haematocrit", "hct", "pcv", "packed cell volume"], + }, + { + key: "wbc", + display: "White Blood Cells", + canonicalUnit: "x10^9/L", + molarMass: null, + panel: "cbc", + aliases: ["wbc", "white blood cells", "white blood cell count", "white cell count", "leukocytes", "leucocytes"], + }, + { + key: "rbc", + display: "Red Blood Cells", + canonicalUnit: "x10^12/L", + molarMass: null, + panel: "cbc", + aliases: ["rbc", "red blood cells", "red blood cell count", "red cell count", "erythrocytes"], + }, + { + key: "platelets", + display: "Platelets", + canonicalUnit: "x10^9/L", + molarMass: null, + panel: "cbc", + aliases: ["platelets", "platelet count", "plt"], + }, + { + key: "mcv", + display: "MCV", + canonicalUnit: "fL", + molarMass: null, + panel: "cbc", + aliases: ["mcv", "mean corpuscular volume", "mean cell volume"], + }, + { + key: "neutrophils", + display: "Neutrophils", + canonicalUnit: "x10^9/L", + molarMass: null, + panel: "cbc", + aliases: ["neutrophils", "neutrophil count", "neutrophils absolute", "absolute neutrophils", "anc"], + }, + { + key: "lymphocytes", + display: "Lymphocytes", + canonicalUnit: "x10^9/L", + molarMass: null, + panel: "cbc", + aliases: ["lymphocytes", "lymphocyte count", "lymphocytes absolute", "absolute lymphocytes", "alc"], + }, + + // --- vitamins --- + { + key: "vitamin_d", + display: "Vitamin D", + canonicalUnit: "nmol/L", + molarMass: 400.64, + panel: "vitamins", + aliases: [ + "vitamin d", + "vitamin d3", + "25-oh vitamin d", + "vitamin d 25-hydroxy", + "25-hydroxyvitamin d", + "25-hydroxy vitamin d", + "25 oh vitamin d", + ], + }, + { + key: "vitamin_b12", + display: "Vitamin B12", + canonicalUnit: "pmol/L", + molarMass: 1355.4, + panel: "vitamins", + aliases: ["vitamin b12", "b12", "cobalamin"], + }, + { + key: "folate", + display: "Folate", + canonicalUnit: "nmol/L", + molarMass: 441.4, + panel: "vitamins", + aliases: ["folate", "folic acid", "vitamin b9"], + }, + + // --- electrolytes --- + { + key: "sodium", + display: "Sodium", + canonicalUnit: "mmol/L", + molarMass: null, + panel: "electrolytes", + aliases: ["sodium", "na"], + }, + { + key: "potassium", + display: "Potassium", + canonicalUnit: "mmol/L", + molarMass: null, + panel: "electrolytes", + aliases: ["potassium", "k"], + }, + { + key: "calcium", + display: "Calcium", + canonicalUnit: "mmol/L", + molarMass: 40.08, + panel: "electrolytes", + aliases: ["calcium", "calcium total", "total calcium", "ca"], + }, + { + key: "magnesium", + display: "Magnesium", + canonicalUnit: "mmol/L", + molarMass: 24.31, + panel: "electrolytes", + aliases: ["magnesium", "mg"], + }, + { + key: "zinc", + display: "Zinc", + canonicalUnit: "µmol/L", + molarMass: 65.38, + panel: "electrolytes", + aliases: ["zinc", "zn"], + }, +]; + +// Resolution is alias/display lookup only — normalized to lowercase with +// punctuation collapsed to spaces so "Total Testosterone", "total-testosterone", +// and "TOTAL TESTOSTERONE" all hit the same registry entry. +const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9%]+/g, " ").trim(); +const INDEX = new Map(); +for (const a of ANALYTES) { + INDEX.set(norm(a.display), a); + for (const al of a.aliases) INDEX.set(norm(al), a); +} + +export function resolveAnalyte(name: string): Analyte | null { + return INDEX.get(norm(name)) ?? null; +} diff --git a/server/src/lib/normalize.ts b/server/src/lib/normalize.ts new file mode 100644 index 0000000..fc7c8db --- /dev/null +++ b/server/src/lib/normalize.ts @@ -0,0 +1,62 @@ +import { resolveAnalyte } from "./analytes"; +import { convert } from "./units"; + +export interface RawMarker { + panel?: string | null; + name: string; + value: string; + unit?: string | null; + referenceRange?: string | null; + flagged?: boolean; +} + +export interface NormMarker { + panel: string; + name: string; + marker: string; + analyteKey: string | null; + value: string; + valueNum: number | null; + unit: string | null; + referenceRange: string | null; + flagged: boolean; + valueCanonical: number | null; + canonicalUnit: string | null; +} + +const num = (v: string): number | null => { + const n = parseFloat(v.replace(/[<>≤≥]/g, "").trim()); + return Number.isFinite(n) ? n : null; +}; + +export function normalizeMarker(raw: RawMarker): NormMarker { + const analyte = resolveAnalyte(raw.name); + const valueNum = num(raw.value); + const unit = raw.unit ?? null; + + let valueCanonical: number | null = null; + let canonicalUnit: string | null = null; + if (analyte && valueNum !== null && unit) { + canonicalUnit = analyte.canonicalUnit; + valueCanonical = convert(valueNum, unit, analyte.canonicalUnit, analyte.molarMass); + if (valueCanonical === null && unit.toLowerCase().replace(/\s/g, "") === analyte.canonicalUnit.toLowerCase().replace(/\s/g, "")) { + valueCanonical = valueNum; + } + } + + return { + // Registry panel wins for mapped analytes so a marker groups the same way + // no matter which section a particular lab filed it under. + panel: analyte?.panel ?? raw.panel ?? "other", + name: raw.name, + marker: analyte?.key ?? raw.name, + analyteKey: analyte?.key ?? null, + value: raw.value, + valueNum, + unit, + referenceRange: raw.referenceRange ?? null, + flagged: raw.flagged === true, + valueCanonical, + canonicalUnit, + }; +} diff --git a/server/src/lib/units.ts b/server/src/lib/units.ts new file mode 100644 index 0000000..7a1e5a1 --- /dev/null +++ b/server/src/lib/units.ts @@ -0,0 +1,49 @@ +// Lab unit conversion. Two transform kinds: dimensional scaling (prefix/volume, +// analyte-independent) and the mass<->mole bridge, which requires the analyte's +// molar mass. %, ratios, IU, and cell counts never bridge to mass or mole. +type Kind = "mass" | "mole" | "count" | "iu" | "percent" | "ratio" | "other"; + +const AMOUNT: Record = { + g: ["mass", 1], mg: ["mass", 1e-3], ug: ["mass", 1e-6], mcg: ["mass", 1e-6], + "µg": ["mass", 1e-6], ng: ["mass", 1e-9], pg: ["mass", 1e-12], + mol: ["mole", 1], mmol: ["mole", 1e-3], umol: ["mole", 1e-6], "µmol": ["mole", 1e-6], + nmol: ["mole", 1e-9], pmol: ["mole", 1e-12], + cells: ["count", 1], k: ["count", 1e3], thousand: ["count", 1e3], + million: ["count", 1e6], "x10^9": ["count", 1e9], "x10^12": ["count", 1e12], + iu: ["iu", 1], miu: ["iu", 1e-3], uiu: ["iu", 1e-6], "µiu": ["iu", 1e-6], u: ["iu", 1], +}; +const VOLUME: Record = { l: 1, dl: 0.1, ml: 1e-3, ul: 1e-6, "µl": 1e-6 }; + +const clean = (u: string) => u.toLowerCase().replace(/\s+/g, "").replace("μ", "µ"); + +interface Parsed { kind: Kind; amountFactor: number; volumeFactor: number } + +export function parseUnit(unit: string): Parsed | null { + const u = clean(unit); + if (u === "%") return { kind: "percent", amountFactor: 1, volumeFactor: 1 }; + if (u === "" || u === "ratio" || u === "index") return { kind: "ratio", amountFactor: 1, volumeFactor: 1 }; + const m = u.match(/^([a-zµ0-9^]+)\/([a-zµ0-9.]+)$/); + if (m) { + const amtEntry = AMOUNT[m[1]]; + const volFactor = VOLUME[m[2]]; + if (amtEntry && volFactor) return { kind: amtEntry[0], amountFactor: amtEntry[1], volumeFactor: volFactor }; + } + return { kind: "other", amountFactor: 1, volumeFactor: 1 }; +} + +export function convert(value: number, fromUnit: string, toUnit: string, molarMass?: number | null): number | null { + const from = parseUnit(fromUnit); + const to = parseUnit(toUnit); + if (!from || !to) return null; + if (clean(fromUnit) === clean(toUnit)) return value; + if (from.kind === "other" || to.kind === "other") return null; + + const fromBasePerL = (value * from.amountFactor) / from.volumeFactor; + let toBasePerL = fromBasePerL; + if (from.kind !== to.kind) { + const bridge = (from.kind === "mass" && to.kind === "mole") || (from.kind === "mole" && to.kind === "mass"); + if (!bridge || !molarMass) return null; + toBasePerL = from.kind === "mass" ? fromBasePerL / molarMass : fromBasePerL * molarMass; + } + return (toBasePerL * to.volumeFactor) / to.amountFactor; +} diff --git a/server/test/normalize.test.ts b/server/test/normalize.test.ts new file mode 100644 index 0000000..34589b4 --- /dev/null +++ b/server/test/normalize.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "bun:test"; +import { resolveAnalyte } from "../src/lib/analytes"; +import { normalizeMarker } from "../src/lib/normalize"; +import { convert } from "../src/lib/units"; + +describe("units", () => { + test("dimensional scaling: ng/mL → µg/L is 1:1", () => { + expect(convert(30, "ng/mL", "µg/L")).toBeCloseTo(30); + }); + test("mass→mole bridge needs molar mass", () => { + expect(convert(100, "mg/dL", "mmol/L")).toBeNull(); + expect(convert(100, "mg/dL", "mmol/L", 180.16)).toBeCloseTo(5.551, 2); // glucose + }); + test("mole→mass: testosterone 20 nmol/L → ng/dL", () => { + expect(convert(20, "nmol/L", "ng/dL", 288.42)).toBeCloseTo(576.8, 0); + }); + test("percent only converts to percent", () => { + expect(convert(42, "%", "%")).toBe(42); + expect(convert(42, "%", "mg/dL")).toBeNull(); + }); + test("count units: x10^9/L → thousand/uL is 1:1", () => { + expect(convert(6.1, "x10^9/L", "thousand/uL")).toBeCloseTo(6.1); + }); +}); + +describe("analytes", () => { + test("alias resolution is case/space-insensitive", () => { + expect(resolveAnalyte("Total Testosterone")?.key).toBe("testosterone_total"); + expect(resolveAnalyte("HbA1c")?.key).toBe("hba1c"); + expect(resolveAnalyte("definitely not a marker")).toBeNull(); + }); +}); + +describe("normalizeMarker", () => { + test("known analyte converts to canonical unit and registry panel", () => { + const n = normalizeMarker({ panel: "chemistry", name: "Glucose", value: "100", unit: "mg/dL", referenceRange: "70-99", flagged: true }); + expect(n.analyteKey).toBe("glucose"); + expect(n.panel).toBe("metabolic"); // registry panel wins for consistent grouping + expect(n.valueCanonical).toBeCloseTo(5.551, 2); + expect(n.canonicalUnit).toBe("mmol/L"); + expect(n.value).toBe("100"); // raw preserved + expect(n.flagged).toBe(true); + }); + test("unknown marker keeps raw fields, no canonical value", () => { + const n = normalizeMarker({ name: "Exotic Marker X", value: "1.2", unit: "u/L" }); + expect(n.analyteKey).toBeNull(); + expect(n.marker).toBe("Exotic Marker X"); + expect(n.valueCanonical).toBeNull(); + }); + test("comparator values parse numerically", () => { + const n = normalizeMarker({ name: "hs-CRP", value: "<0.3", unit: "mg/L" }); + expect(n.valueNum).toBeCloseTo(0.3); + expect(n.value).toBe("<0.3"); + }); +});