feat(labs): unit conversion, analyte registry, marker normalization
This commit is contained in:
508
server/src/lib/analytes.ts
Normal file
508
server/src/lib/analytes.ts
Normal 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;
|
||||||
|
}
|
||||||
62
server/src/lib/normalize.ts
Normal file
62
server/src/lib/normalize.ts
Normal 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
49
server/src/lib/units.ts
Normal 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;
|
||||||
|
}
|
||||||
55
server/test/normalize.test.ts
Normal file
55
server/test/normalize.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user