feat(labs): unit conversion, analyte registry, marker normalization

This commit is contained in:
marcuspaico
2026-08-17 15:23:34 -07:00
parent bd4817cb77
commit a08544db7d
4 changed files with 674 additions and 0 deletions

508
server/src/lib/analytes.ts Normal file
View File

@@ -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<string, Analyte>();
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;
}

View File

@@ -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,
};
}

49
server/src/lib/units.ts Normal file
View File

@@ -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<string, [Kind, number]> = {
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<string, number> = { 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;
}

View File

@@ -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");
});
});