feat(labs): add the lab unit conversion registry and its pure functions

Phase 2 of #1095: new code only, no behaviour change. Nothing imports it yet.

An analyte lists its units as affine maps to a hub unit (hub = a * value + b),
so any pair goes unit -> hub -> unit. Molar-mass factors are derived from the
formula and a cited atomic-weight table instead of being typed; glucose reuses
MGDL_PER_MMOL. HbA1c is the IFCC-NGSP master equation. Units that differ only
by a power of ten convert for any analyte, inside one family and never across
mass and amount. An entry with no source, a hub that is not one of its units, or
a non-positive factor makes the registry fail to load.

convertLabValue, convertLabRange and conversionsFor refuse rather than guess:
unknown unit, unknown analyte, a mass to amount pair without an analyte key,
a value that is not finite. Triglycerides, haemoglobin in mmol/L, ferritin and
B12 in pmol/L and enzymes in ukat/L stay refusals.

Refs #1095
This commit is contained in:
BernhardBuckel committed 2026-10-04 11:05:36 +02:00
1 parent 808b2672cd
commit 503f0f183a
7 files changed
+1238

No files matched your search

@@ -0,0 +1,380 @@
import { describe, expect, it } from "vitest";
import { allAnalyteConversions } from "../registry";
import {
convertLabRange,
convertLabValue,
conversionsFor,
type ConvertedValue,
} from "../convert";
const ok = (result: ConvertedValue) => {
if (!result.ok) throw new Error(`refused: ${result.reason}`);
return result;
};
const convert = (
analyteKey: string | null | undefined,
value: number,
from: string,
to: string,
) => convertLabValue({ analyteKey, value, from, to });
describe("reference pairs, checked by hand", () => {
it("5.6 mmol/L glucose is 100.9 mg/dL", () => {
const r = ok(convert("fasting-glucose", 5.6, "mmol/L", "mg/dL"));
expect(r.value).toBeCloseTo(100.9, 1);
expect(r.via).toBe("analyte");
});
it("1.0 mg/dL creatinine is 88.4 µmol/L", () => {
expect(ok(convert("creatinine", 1, "mg/dL", "µmol/L")).value).toBeCloseTo(
88.4,
1,
);
});
it("6.5 % HbA1c is 47.5 mmol/mol, and back", () => {
const ifcc = ok(convert("hba1c", 6.5, "%", "mmol/mol")).value;
expect(ifcc).toBeCloseTo(47.5, 1);
expect(ok(convert("hba1c", ifcc, "mmol/mol", "%")).value).toBeCloseTo(
6.5,
9,
);
});
it("30 ng/mL vitamin D is 74.9 nmol/L", () => {
expect(ok(convert("vitamin-d", 30, "ng/mL", "nmol/L")).value).toBeCloseTo(
74.9,
1,
);
});
it("5.2 mmol/L total cholesterol is 201 mg/dL, and LDL goes the same way", () => {
expect(
ok(convert("total-cholesterol", 5.2, "mmol/L", "mg/dL")).value,
).toBeCloseTo(201.1, 1);
expect(ok(convert("ldl", 3, "mmol/L", "mg/dL")).value).toBeCloseTo(116, 0);
});
it("5 mmol/L urea is 30.0 mg/dL, and 5 mmol/L is 14.0 mg/dL as BUN", () => {
expect(ok(convert("urea", 5, "mmol/L", "mg/dL")).value).toBeCloseTo(
30.03,
2,
);
expect(
ok(convert("blood-urea-nitrogen", 5, "mmol/L", "mg/dL")).value,
).toBeCloseTo(14.007, 2);
});
it("2.4 mmol/L total calcium is 9.6 mg/dL", () => {
expect(
ok(convert("calcium-total", 2.4, "mmol/L", "mg/dL")).value,
).toBeCloseTo(9.62, 1);
});
it("300 µmol/L uric acid is 5.0 mg/dL and 17.1 µmol/L bilirubin is 1.0 mg/dL", () => {
expect(ok(convert("uric-acid", 300, "µmol/L", "mg/dL")).value).toBeCloseTo(
5.04,
2,
);
expect(
ok(convert("bilirubin-total", 17.1, "µmol/L", "mg/dL")).value,
).toBeCloseTo(1.0, 2);
});
});
describe("the same unit written two ways", () => {
it("returns the value untouched", () => {
expect(ok(convert(null, 5.6, "mmol/l", "mmol/L")).value).toBe(5.6);
expect(ok(convert(null, 5.6, "MG/DL", "mg/dl")).value).toBe(5.6);
expect(ok(convert("fasting-glucose", 5.6, "mg/dl", "mg/dL")).value).toBe(
5.6,
);
});
it("reads a spelling of the unit through the normaliser", () => {
const a = ok(convert("creatinine", 88.4, "umol/l", "mg/dl"));
const b = ok(convert("creatinine", 88.4, "µmol/L", "mg/dL"));
expect(a.value).toBe(b.value);
});
});
describe("a pair of scale units needs no analyte", () => {
it.each([
[1, "g/L", "mg/L", 1000],
[1, "mg/dL", "mg/L", 10],
[1, "g/dL", "g/L", 10],
[1, "ng/mL", "µg/L", 1],
[1, "ng/mL", "pg/mL", 1000],
[1, "ng/dL", "ng/L", 10],
[1, "mmol/L", "µmol/L", 1000],
[1, "mol/L", "pmol/L", 1e12],
[1, "mIU/L", "µIU/mL", 1],
[1, "IU/L", "mIU/L", 1000],
[4.5, "10³/µL", "10⁹/L", 4.5],
[1, "10⁶/µL", "10¹²/L", 1],
[1, "10⁶/µL", "10³/µL", 1000],
[1, "G/L", "10³/µL", 1],
])("%s %s is %s %s", (value, from, to, expected) => {
const r = ok(convert(null, value, from, to));
expect(r.via).toBe("scale");
expect(r.value).toBeCloseTo(expected, 9);
});
it("is exact where a power of ten is exactly representable", () => {
expect(ok(convert(null, 5, "g/L", "mg/L")).value).toBe(5000);
expect(ok(convert(null, 5, "mg/L", "g/L")).value).toBe(0.005);
});
it("works for an analyte's own mass units too: glucose in g/L", () => {
expect(ok(convert("fasting-glucose", 1, "g/L", "mg/dL")).value).toBe(100);
});
});
describe("an analyte's units reach each other across a scale", () => {
it("glucose in g/L to mmol/L", () => {
// 1 g/L = 100 mg/dL = 100 / 18.0182 mmol/L
expect(
ok(convert("fasting-glucose", 1, "g/L", "mmol/L")).value,
).toBeCloseTo(100 / 18.0182, 9);
});
it("creatinine in mmol/L to mg/dL (the entry lists µmol/L)", () => {
expect(
ok(convert("creatinine", 0.0884, "mmol/L", "mg/dL")).value,
).toBeCloseTo(1.0, 3);
});
it("vitamin D in µg/L to nmol/L (the entry lists ng/mL)", () => {
expect(ok(convert("vitamin-d", 30, "µg/L", "nmol/L")).value).toBeCloseTo(
74.9,
1,
);
});
});
describe("a round trip returns the start, for every unit of every analyte", () => {
for (const entry of allAnalyteConversions()) {
const units = Object.keys(entry.units);
for (const from of units) {
for (const to of units) {
it(`${entry.key}: ${from} -> ${to} -> ${from}`, () => {
for (const start of [0.5, 1, 7.3, 120.45]) {
const there = ok(convert(entry.key, start, from, to)).value;
const back = ok(convert(entry.key, there, to, from)).value;
expect(Math.abs(back - start) / start).toBeLessThan(1e-12);
}
});
}
}
}
it("holds for the scale pairs", () => {
const units = ["g/L", "mg/dL", "µg/L", "ng/mL", "pg/mL", "ng/dL", "g/dL"];
for (const from of units) {
for (const to of units) {
const there = ok(convert(null, 7.3, from, to)).value;
const back = ok(convert(null, there, to, from)).value;
expect(Math.abs(back - 7.3) / 7.3).toBeLessThan(1e-12);
}
}
});
});
describe("a refusal, never a guess", () => {
it("refuses a unit nobody knows", () => {
expect(convert("fasting-glucose", 5, "mmol/L", "furlongs")).toEqual({
ok: false,
reason: "unknown_unit",
});
expect(convert(null, 5, "foo", "bar")).toEqual({
ok: false,
reason: "unknown_unit",
});
});
it("refuses an analyte it has no entry for", () => {
expect(convert("sodium", 140, "mmol/L", "mg/dL")).toEqual({
ok: false,
reason: "unknown_analyte",
});
expect(convert("triglycerides", 1.7, "mmol/L", "mg/dL")).toEqual({
ok: false,
reason: "unknown_analyte",
});
});
it("refuses mass to amount when no analyte key is given", () => {
expect(convert(null, 100, "mg/dL", "mmol/L")).toEqual({
ok: false,
reason: "unknown_analyte",
});
expect(convert(undefined, 100, "mg/dL", "mmol/L")).toEqual({
ok: false,
reason: "unknown_analyte",
});
});
it("refuses a unit the entry does not reach", () => {
// HbA1c knows % and mmol/mol; g/L is neither, and not a scale of either.
expect(convert("hba1c", 6.5, "%", "g/L")).toEqual({
ok: false,
reason: "unknown_unit",
});
});
it.each([Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY])(
"refuses %s",
(value) => {
expect(convert("fasting-glucose", value, "mmol/L", "mg/dL")).toEqual({
ok: false,
reason: "not_finite",
});
expect(convert(null, value, "g/L", "mg/L")).toEqual({
ok: false,
reason: "not_finite",
});
},
);
it("never turns the lookalike pairs into a conversion", () => {
// Different measurements, not different units of one.
expect(convert("urea", 5, "mmol/L", "mg/dL").ok).toBe(true);
expect(convert("calcium-total", 2.4, "mmol/L", "mg/dL").ok).toBe(true);
expect(convert("calcium-ionised", 1.2, "mmol/L", "mg/dL").ok).toBe(false);
});
});
describe("a reference range", () => {
it("converts both bounds together and keeps them in order", () => {
const r = convertLabRange({
analyteKey: "fasting-glucose",
low: 3.9,
high: 5.6,
from: "mmol/L",
to: "mg/dL",
});
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.low).toBeCloseTo(70.3, 1);
expect(r.high).toBeCloseTo(100.9, 1);
expect(r.low!).toBeLessThan(r.high!);
expect(r.via).toBe("analyte");
});
it("keeps the order for HbA1c, whose map has an offset", () => {
const r = convertLabRange({
analyteKey: "hba1c",
low: 4,
high: 5.6,
from: "%",
to: "mmol/mol",
});
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.low!).toBeLessThan(r.high!);
expect(r.low).toBeCloseTo(20.2, 1);
expect(r.high).toBeCloseTo(37.7, 1);
});
it("leaves an open bound open", () => {
expect(
convertLabRange({
analyteKey: "fasting-glucose",
low: null,
high: 5.6,
from: "mmol/L",
to: "mg/dL",
}),
).toMatchObject({ ok: true, low: null });
expect(
convertLabRange({
analyteKey: "fasting-glucose",
low: 3.9,
high: null,
from: "mmol/L",
to: "mg/dL",
}),
).toMatchObject({ ok: true, high: null });
});
it("refuses the whole range when the units do not convert", () => {
expect(
convertLabRange({
analyteKey: null,
low: 3.9,
high: 5.6,
from: "mmol/L",
to: "mg/dL",
}),
).toEqual({ ok: false, reason: "unknown_analyte" });
});
it("refuses a range of two open bounds when the units do not convert either", () => {
expect(
convertLabRange({
analyteKey: null,
low: null,
high: null,
from: "mmol/L",
to: "mg/dL",
}),
).toEqual({ ok: false, reason: "unknown_analyte" });
});
it("is the unit spelled another way: no conversion, bounds as they are", () => {
expect(
convertLabRange({
analyteKey: null,
low: 3.9,
high: 5.6,
from: "mmol/l",
to: "mmol/L",
}),
).toEqual({ ok: true, low: 3.9, high: 5.6, via: "scale" });
});
});
describe("conversionsFor", () => {
it("lists what glucose in mg/dL can arrive as, without mg/dL itself", () => {
const units = conversionsFor({
analyteKey: "fasting-glucose",
to: "mg/dL",
});
expect(units).toContain("mmol/L");
expect(units).toContain("g/L");
expect(units).toContain("µmol/L"); // the amount family, scaled
expect(units).not.toContain("mg/dL");
expect(units).not.toContain("mg/dl");
});
it("lists only the scale family for a marker with no entry", () => {
const units = conversionsFor({ analyteKey: null, to: "mg/dL" });
expect(units).toContain("g/L");
expect(units).not.toContain("mmol/L");
});
it("lists nothing for a unit that has no family and no entry", () => {
expect(conversionsFor({ analyteKey: null, to: "%" })).toEqual([]);
});
it("lists HbA1c's two units", () => {
expect(conversionsFor({ analyteKey: "hba1c", to: "%" })).toEqual([
"mmol/mol",
]);
});
it("every listed unit really converts", () => {
for (const entry of allAnalyteConversions()) {
for (const to of Object.keys(entry.units)) {
for (const from of conversionsFor({ analyteKey: entry.key, to })) {
expect(
convert(entry.key, 1, from, to).ok,
`${entry.key}: ${from} -> ${to}`,
).toBe(true);
}
}
}
});
});
@@ -0,0 +1,191 @@
import { describe, expect, it } from "vitest";
import { MGDL_PER_MMOL } from "@/lib/glucose";
import { molarMass } from "../atomic-weights";
import {
allAnalyteConversions,
analyteConversionFor,
defineAnalyteConversion,
type AnalyteConversion,
} from "../registry";
/**
* The guard over the registry. Every entry is checked for what makes a
* conversion safe to ship: a named source, a hub that is one of its units, a
* finite positive slope, a key nobody else claims, and, for the molar-mass
* entries, a factor that equals one recomputed here from the molar mass written
* out by hand, not by the code that built the entry.
*/
/** g/mol, summed by hand from H 1.008, C 12.011, N 14.007, O 15.999, Ca 40.078. */
const MOLAR_MASS: Record<string, number> = {
"fasting-glucose": 180.156, // C6H12O6
"total-cholesterol": 386.664, // C27H46O
creatinine: 113.12, // C4H7N3O
urea: 60.056, // CH4N2O
"blood-urea-nitrogen": 28.014, // N2
"uric-acid": 168.112, // C5H4N4O3
"calcium-total": 40.078, // Ca
"bilirubin-total": 584.673, // C33H36N4O6
"vitamin-d": 400.647, // C27H44O2
};
/** Of the entry's amount unit: how many of its mass unit one of it is, per g/mol. */
const POWER_OF_TEN: Record<string, number> = {
"fasting-glucose": 1 / 10, // mmol/L -> mg/dL
"total-cholesterol": 1 / 10,
creatinine: 1 / 10_000, // µmol/L -> mg/dL
urea: 1 / 10,
"blood-urea-nitrogen": 1 / 10,
"uric-acid": 1 / 10_000,
"calcium-total": 1 / 10,
"bilirubin-total": 1 / 10_000,
"vitamin-d": 1 / 1_000, // nmol/L -> ng/mL
};
describe("the registry's entries", () => {
const entries = allAnalyteConversions();
it("holds the starter set", () => {
expect(entries.map((entry) => entry.key).sort()).toEqual(
[
"blood-urea-nitrogen",
"bilirubin-total",
"calcium-total",
"creatinine",
"fasting-glucose",
"hba1c",
"total-cholesterol",
"uric-acid",
"urea",
"vitamin-d",
].sort(),
);
});
it.each(entries.map((entry) => [entry.key, entry] as const))(
"%s: names its source, has a hub among its units, and a finite positive slope",
(_key, entry) => {
expect(entry.source.trim().length).toBeGreaterThan(10);
expect(Object.keys(entry.units)).toContain(entry.hub);
for (const { a, b } of Object.values(entry.units)) {
expect(Number.isFinite(a) && a > 0).toBe(true);
expect(Number.isFinite(b)).toBe(true);
}
expect(entry.units[entry.hub]).toEqual({ a: 1, b: 0 });
},
);
it("never lists a key or an alias twice, across entries", () => {
const keys = entries.flatMap((entry) => [entry.key, ...entry.aliases]);
expect(new Set(keys).size).toBe(keys.length);
});
it("finds an analyte by its alias: LDL and HDL are cholesterol", () => {
expect(analyteConversionFor("ldl")?.key).toBe("total-cholesterol");
expect(analyteConversionFor("hdl")?.key).toBe("total-cholesterol");
expect(analyteConversionFor("nope")).toBeNull();
expect(analyteConversionFor(null)).toBeNull();
});
it("keeps urea and blood urea nitrogen, and the two calciums, apart", () => {
expect(analyteConversionFor("urea")).not.toBe(
analyteConversionFor("blood-urea-nitrogen"),
);
expect(analyteConversionFor("calcium-ionised")).toBeNull();
});
describe("the molar-mass entries", () => {
const molar = entries.filter((entry) => entry.kind === "molar-mass");
it("are exactly the ones this table knows", () => {
expect(molar.map((entry) => entry.key).sort()).toEqual(
Object.keys(MOLAR_MASS).sort(),
);
});
it.each(molar.map((entry) => [entry.key, entry] as const))(
"%s: the factor is the molar mass moved by its powers of ten",
(key, entry) => {
const [amountUnit] = Object.keys(entry.units).filter(
(unit) => unit !== entry.hub,
);
const { a } = entry.units[amountUnit];
const expected = MOLAR_MASS[key] * POWER_OF_TEN[key];
if (key === "fasting-glucose") {
// The constant the rest of the app uses.
expect(a).toBe(MGDL_PER_MMOL);
} else {
expect(a).toBeCloseTo(expected, 9);
}
},
);
it("has glucose agree with the derived factor to within 0.02 %", () => {
const derived = molarMass({ C: 6, H: 12, O: 6 }) / 10;
expect(derived).toBeCloseTo(18.0156, 4);
expect(Math.abs(MGDL_PER_MMOL - derived) / derived).toBeLessThan(0.0002);
});
});
it("has HbA1c as the IFCC-NGSP master equation, affine and not a factor", () => {
const hba1c = analyteConversionFor("hba1c")!;
expect(hba1c.kind).toBe("standard");
expect(hba1c.units["mmol/mol"]).toEqual({ a: 0.09148, b: 2.152 });
expect(hba1c.source).toContain("Hoelzel");
});
it("has no convention-based entry until its source is cited (triglycerides)", () => {
expect(entries.filter((entry) => entry.kind === "convention")).toEqual([]);
expect(analyteConversionFor("triglycerides")).toBeNull();
});
});
describe("an entry that is not safe cannot be defined", () => {
const valid: AnalyteConversion = {
key: "x",
aliases: [],
hub: "mg/dL",
units: { "mg/dL": { a: 1, b: 0 }, "mmol/L": { a: 10, b: 0 } },
kind: "molar-mass",
source: "A cited source.",
};
it("accepts a valid entry", () => {
expect(defineAnalyteConversion(valid).key).toBe("x");
});
it("refuses an entry with no source", () => {
expect(() => defineAnalyteConversion({ ...valid, source: " " })).toThrow(
/no source/,
);
});
it("refuses a hub that is not one of its units", () => {
expect(() => defineAnalyteConversion({ ...valid, hub: "g/L" })).toThrow(
/hub/,
);
});
it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY])(
"refuses a slope of %s",
(a) => {
expect(() =>
defineAnalyteConversion({
...valid,
units: { ...valid.units, "mmol/L": { a, b: 0 } },
}),
).toThrow(/finite positive/);
},
);
it("refuses a unit that is not in its normalised spelling", () => {
expect(() =>
defineAnalyteConversion({
...valid,
units: { ...valid.units, "mmol/l": { a: 10, b: 0 } },
}),
).toThrow(/normalised/);
});
});
@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import { molarMass, formulaLabel } from "../atomic-weights";
import {
convertByScale,
scaleUnitOf,
scaleUnitsInFamilyOf,
shiftPowerOfTen,
} from "../scale";
describe("scale families", () => {
it("puts each unit in the family of its quantity", () => {
expect(scaleUnitOf("mg/dL")?.family).toBe("mass");
expect(scaleUnitOf("pmol/L")?.family).toBe("amount");
expect(scaleUnitOf("mIU/L")?.family).toBe("activity");
expect(scaleUnitOf("10³/µL")?.family).toBe("count");
});
it("derives the exponent from the prefix and the volume", () => {
// Relative to g/L.
expect(scaleUnitOf("g/L")?.exponent).toBe(0);
expect(scaleUnitOf("g/dL")?.exponent).toBe(1);
expect(scaleUnitOf("mg/dL")?.exponent).toBe(-2);
expect(scaleUnitOf("ng/mL")?.exponent).toBe(-6);
expect(scaleUnitOf("ng/dL")?.exponent).toBe(-8);
expect(scaleUnitOf("pg/mL")?.exponent).toBe(-9);
});
it("knows a unit by any spelling the normaliser resolves", () => {
expect(scaleUnitOf("mg/dl")).toEqual(scaleUnitOf("mg/dL"));
expect(scaleUnitOf("ug/L")).toEqual(scaleUnitOf("µg/L"));
expect(scaleUnitOf("umol/l")).toEqual(scaleUnitOf("µmol/L"));
});
it("knows no family for a unit that is not a plain scale", () => {
expect(scaleUnitOf("%")).toBeNull();
expect(scaleUnitOf("mmol/mol")).toBeNull();
expect(scaleUnitOf("furlongs")).toBeNull();
// A lone capital M is mega, not milli: not read as a scale unit at all.
expect(scaleUnitOf("MIU/L")).toBeNull();
});
it("does not make mass to amount a scale pair", () => {
expect(convertByScale(1, "mg/dL", "mmol/L")).toBeNull();
expect(convertByScale(1, "g/L", "mol/L")).toBeNull();
});
it("keeps the 10^9/L of a blood count apart from g/L", () => {
expect(scaleUnitOf("G/L")?.family).toBe("count");
expect(scaleUnitOf("g/L")?.family).toBe("mass");
expect(convertByScale(1, "G/L", "g/L")).toBeNull();
});
it("lists every unit of a family, in its own spelling", () => {
const amount = scaleUnitsInFamilyOf("mmol/L");
expect(amount).toEqual(
expect.arrayContaining(["mol/L", "mmol/L", "µmol/L", "nmol/L", "pmol/L"]),
);
expect(amount).not.toContain("mg/dL");
expect(scaleUnitsInFamilyOf("%")).toEqual([]);
});
});
describe("shiftPowerOfTen", () => {
it("multiplies for a positive power and divides for a negative one", () => {
expect(shiftPowerOfTen(5, 3)).toBe(5000);
expect(shiftPowerOfTen(5, -3)).toBe(0.005);
expect(shiftPowerOfTen(5, 0)).toBe(5);
});
it("is exact where multiplying by 10^-n is not", () => {
// 3 * 1e-3 is 0.003 with a stray last digit; 3 / 1000 is exact.
expect(shiftPowerOfTen(3, -3)).toBe(0.003);
});
});
describe("atomic weights", () => {
it("sums a molar mass from the table, without float noise", () => {
expect(molarMass({ C: 6, H: 12, O: 6 })).toBe(180.156);
expect(molarMass({ Ca: 1 })).toBe(40.078);
expect(molarMass({ N: 2 })).toBe(28.014);
});
it("writes a formula in a stable order", () => {
expect(formulaLabel({ O: 6, H: 12, C: 6 })).toBe("C6H12O6");
expect(formulaLabel({ Ca: 1 })).toBe("Ca");
expect(formulaLabel({ C: 1, H: 4, N: 2, O: 1 })).toBe("CH4N2O");
});
});
@@ -0,0 +1,50 @@
/**
* The atomic weights the molar-mass conversions are derived from.
*
* Conventional atomic weights as published by the IUPAC Commission on Isotopic
* Abundances and Atomic Weights (CIAAW), "Standard atomic weights". Only the
* elements a conversion in the registry needs are listed; one more element is
* one more line here, with a conversion that uses it.
*
* A molar mass is summed from these, never typed, so a long decimal cannot be
* copied wrong into a factor that then touches every reading of a marker.
*/
export const ATOMIC_WEIGHTS_SOURCE =
"IUPAC CIAAW, Standard atomic weights (conventional values)";
export const ATOMIC_WEIGHTS = {
H: 1.008,
C: 12.011,
N: 14.007,
O: 15.999,
Ca: 40.078,
} as const;
export type Element = keyof typeof ATOMIC_WEIGHTS;
/** Atoms per molecule, by element: urea is `{ C: 1, H: 4, N: 2, O: 1 }`. */
export type Formula = Readonly<Partial<Record<Element, number>>>;
/** g/mol of a formula, summed from `ATOMIC_WEIGHTS`. */
export function molarMass(formula: Formula): number {
let total = 0;
for (const [element, count] of Object.entries(formula) as Array<
[Element, number]
>) {
total += ATOMIC_WEIGHTS[element] * count;
}
// Summing decimals leaves float noise past the sixth place (180.15600000000001).
return Math.round(total * 1e6) / 1e6;
}
/** `C6H12O6` for `{ C: 6, H: 12, O: 6 }`: for a citation, in a stable order. */
export function formulaLabel(formula: Formula): string {
const order: Element[] = ["C", "H", "N", "O", "Ca"];
return order
.filter((element) => (formula[element] ?? 0) > 0)
.map((element) => {
const count = formula[element]!;
return count === 1 ? element : `${element}${count}`;
})
.join("");
}
+167
View File
@@ -0,0 +1,167 @@
/**
* Convert a lab value, or a reference range, between units. Pure, no I/O.
*
* The rules, in order:
*
* 1. The same unit written two ways (`mmol/l`, `mmol/L`) is the same unit and
* the value is returned as it is.
* 2. Two units in one scale family (`mg/L`, `g/dL`) convert by their power of
* ten, for any analyte, with no key. This is `via: "scale"`.
* 3. Anything else needs the analyte's entry in the registry (`via:
* "analyte"`): each unit is moved to a unit the entry lists by scale where
* needed, then unit -> hub -> unit.
*
* Everything else is a refusal, never a guess: an unknown unit, an analyte with
* no entry, a value that is not finite. A mass to amount pair asked for without
* an analyte key is an `unknown_analyte` refusal: it is never a scale pair.
*/
import { normaliseLabUnit } from "../unit-normalise";
import {
analyteConversionFor,
type Affine,
type AnalyteConversion,
} from "./registry";
import { scaleUnitOf, scaleUnitsInFamilyOf, shiftPowerOfTen } from "./scale";
export type ConversionRefusal =
"unknown_analyte" | "unknown_unit" | "not_finite";
export type ConvertedValue =
| { ok: true; value: number; via: "analyte" | "scale" }
| { ok: false; reason: ConversionRefusal };
export interface ConvertLabValueInput {
/** Catalog slug or registry key of the marker's analyte; null when it has none. */
analyteKey: string | null | undefined;
value: number;
from: string;
to: string;
}
/** `unit` written as one of the entry's own units, by scale where it is not one. */
function intoEntryUnit(
entry: AnalyteConversion,
unit: string,
): { entryUnit: string; affine: Affine; shift: number } | null {
const norm = normaliseLabUnit(unit);
const direct = entry.units[norm];
if (direct) return { entryUnit: norm, affine: direct, shift: 0 };
const own = scaleUnitOf(norm);
if (!own) return null;
for (const [entryUnit, affine] of Object.entries(entry.units)) {
const candidate = scaleUnitOf(entryUnit);
if (candidate && candidate.family === own.family) {
return { entryUnit, affine, shift: own.exponent - candidate.exponent };
}
}
return null;
}
export function convertLabValue(input: ConvertLabValueInput): ConvertedValue {
const { analyteKey, value, from, to } = input;
if (!Number.isFinite(value)) return { ok: false, reason: "not_finite" };
const fromNorm = normaliseLabUnit(from);
const toNorm = normaliseLabUnit(to);
if (fromNorm === toNorm) return { ok: true, value, via: "scale" };
const a = scaleUnitOf(fromNorm);
const b = scaleUnitOf(toNorm);
if (a && b && a.family === b.family) {
return {
ok: true,
value: shiftPowerOfTen(value, a.exponent - b.exponent),
via: "scale",
};
}
const entry = analyteConversionFor(analyteKey);
if (!entry) {
// Both units are real but of different families: it needs an analyte.
return {
ok: false,
reason: a && b ? "unknown_analyte" : "unknown_unit",
};
}
const source = intoEntryUnit(entry, fromNorm);
const target = intoEntryUnit(entry, toNorm);
if (!source || !target) return { ok: false, reason: "unknown_unit" };
const inEntryUnit = shiftPowerOfTen(value, source.shift);
const hub = source.affine.a * inEntryUnit + source.affine.b;
const inTargetEntryUnit = (hub - target.affine.b) / target.affine.a;
// `target.shift` moves the entry's unit to the one asked for: the reverse of
// the way `from` was moved in.
const result = shiftPowerOfTen(inTargetEntryUnit, -target.shift);
return Number.isFinite(result)
? { ok: true, value: result, via: "analyte" }
: { ok: false, reason: "not_finite" };
}
export type ConvertedRange =
| {
ok: true;
low: number | null;
high: number | null;
via: "analyte" | "scale";
}
| { ok: false; reason: ConversionRefusal };
/**
* Both bounds of a reference range, or the refusal. An open bound (null) stays
* open. Every conversion has a positive slope, so the bounds keep their order.
*/
export function convertLabRange(input: {
analyteKey: string | null | undefined;
low: number | null;
high: number | null;
from: string;
to: string;
}): ConvertedRange {
const { analyteKey, from, to } = input;
const bounds: Array<number | null> = [input.low, input.high];
const converted: Array<number | null> = [];
let via: "analyte" | "scale" = "scale";
for (const bound of bounds) {
if (bound === null) {
converted.push(null);
continue;
}
const result = convertLabValue({ analyteKey, value: bound, from, to });
if (!result.ok) return result;
if (result.via === "analyte") via = "analyte";
converted.push(result.value);
}
// A range of nothing but open bounds still has to name a way: the units must
// convert at all.
if (input.low === null && input.high === null) {
const probe = convertLabValue({ analyteKey, value: 1, from, to });
if (!probe.ok) return probe;
via = probe.via;
}
return { ok: true, low: converted[0], high: converted[1], via };
}
/**
* The units a reading can arrive in and be converted to `to` for this marker:
* every unit in the scale family of `to`, and, when the analyte has an entry,
* every unit of that entry along with their scale families. `to` itself is not
* listed.
*/
export function conversionsFor(input: {
analyteKey: string | null | undefined;
to: string;
}): string[] {
const to = normaliseLabUnit(input.to);
const found = new Set<string>(scaleUnitsInFamilyOf(to));
const entry = analyteConversionFor(input.analyteKey);
if (entry && (to in entry.units || intoEntryUnit(entry, to))) {
for (const unit of Object.keys(entry.units)) {
found.add(unit);
for (const sibling of scaleUnitsInFamilyOf(unit)) found.add(sibling);
}
}
found.delete(to);
return [...found].filter((unit) => normaliseLabUnit(unit) !== to);
}
+231
View File
@@ -0,0 +1,231 @@
/**
* Which analytes can be moved between units, and how.
*
* Each analyte lists the units it knows as an affine map to one hub unit:
* `hub = a * value + b`. Any pair then goes unit -> hub -> unit, so adding a
* unit to an analyte is one line and nothing lists pairs.
*
* Three kinds of entry, by where the factor comes from:
*
* - `molar-mass`: mass concentration to amount concentration, which needs the
* analyte's molar mass. The factor is DERIVED from the formula and the
* atomic weights, never typed.
* - `standard`: an equation a standards body publishes (HbA1c, IFCC to NGSP).
* - `convention`: a factor that rests on an assumed value (triglycerides take
* an average molecular weight). None is in the registry yet: a conversion
* that cannot cite a primary source stays a refusal.
*
* Every entry names its source, and an entry that does not, or whose hub is not
* one of its units, or whose factor is not a finite positive number, makes this
* module throw when it loads. A wrong factor is worse than no conversion, so an
* uncited one cannot be added quietly.
*
* Left out on purpose, so the refusal stays: haemoglobin in mmol/L (the figure
* depends on whether the monomer or the tetramer is meant, a factor of four),
* ferritin and vitamin B12 in pmol/L (large-protein molecular weights that vary
* by source), enzymes in µkat/L, and anything without a source. Urea and blood
* urea nitrogen are separate analytes (about 2.14 apart), and so are total and
* ionised calcium; only the total is here.
*/
import { MGDL_PER_MMOL } from "@/lib/glucose";
import { normaliseLabUnit } from "../unit-normalise";
import {
ATOMIC_WEIGHTS_SOURCE,
formulaLabel,
molarMass,
type Formula,
} from "./atomic-weights";
import { scaleUnitOf, shiftPowerOfTen } from "./scale";
export interface Affine {
a: number;
b: number;
}
export type ConversionKind = "molar-mass" | "standard" | "convention";
export interface AnalyteConversion {
/** Stable identity: the catalog slug where one exists, a registry-only key otherwise. */
key: string;
/** Other catalog slugs that are this same analyte (LDL and HDL are cholesterol). */
aliases: readonly string[];
/** The unit every other unit of this analyte maps to. */
hub: string;
/** Units by their normalised spelling, each as an affine map to the hub. */
units: Readonly<Record<string, Affine>>;
kind: ConversionKind;
/** Where the factor comes from; required. */
source: string;
}
export function defineAnalyteConversion(
entry: AnalyteConversion,
): AnalyteConversion {
const where = `unit-conversion registry entry "${entry.key}"`;
if (!entry.source.trim()) throw new Error(`${where} has no source`);
const hub = normaliseLabUnit(entry.hub);
if (!(hub in entry.units)) {
throw new Error(`${where}: hub "${entry.hub}" is not one of its units`);
}
for (const [unit, { a, b }] of Object.entries(entry.units)) {
if (normaliseLabUnit(unit) !== unit) {
throw new Error(`${where}: unit "${unit}" is not in its normalised form`);
}
if (!Number.isFinite(a) || a <= 0 || !Number.isFinite(b)) {
throw new Error(`${where}: unit "${unit}" has no finite positive factor`);
}
}
return { ...entry, hub };
}
/**
* A mass concentration (the hub) and one amount concentration of the same
* analyte. The factor is the molar mass moved by the powers of ten between the
* two units: 1 mmol/L of glucose is 180.156 mg/L, which is 18.0156 mg/dL.
*/
function molarMassEntry(spec: {
key: string;
aliases?: readonly string[];
formula: Formula;
/** The mass concentration the analyte is tracked in; the hub. */
mass: string;
/** The amount concentration it is also printed in. */
amount: string;
/** Overrides the derived mmol/L factor with a constant the app already has. */
factor?: number;
note?: string;
}): AnalyteConversion {
const mass = scaleUnitOf(spec.mass);
const amount = scaleUnitOf(spec.amount);
if (mass?.family !== "mass" || amount?.family !== "amount") {
throw new Error(
`molar-mass entry "${spec.key}" needs a mass and an amount unit`,
);
}
const grams = molarMass(spec.formula);
// mol/L -> g/L is x molar mass; g/L -> the mass unit and the amount unit's
// own prefix are powers of ten.
const derived = shiftPowerOfTen(grams, amount.exponent - mass.exponent);
return defineAnalyteConversion({
key: spec.key,
aliases: spec.aliases ?? [],
hub: spec.mass,
units: {
[normaliseLabUnit(spec.mass)]: { a: 1, b: 0 },
[normaliseLabUnit(spec.amount)]: { a: spec.factor ?? derived, b: 0 },
},
kind: "molar-mass",
source:
`Molar mass of ${formulaLabel(spec.formula)} (${grams} g/mol), summed from ` +
`${ATOMIC_WEIGHTS_SOURCE}.` +
(spec.note ? ` ${spec.note}` : ""),
});
}
const ENTRIES: readonly AnalyteConversion[] = [
molarMassEntry({
key: "fasting-glucose",
formula: { C: 6, H: 12, O: 6 },
mass: "mg/dL",
amount: "mmol/L",
// The factor the glucose screens and the CGM import already use, so the two
// can never disagree; the guard test checks it against the derived one.
factor: MGDL_PER_MMOL,
note: "The factor is MGDL_PER_MMOL (src/lib/glucose.ts).",
}),
molarMassEntry({
key: "total-cholesterol",
aliases: ["ldl", "hdl"],
formula: { C: 27, H: 46, O: 1 },
mass: "mg/dL",
amount: "mmol/L",
note: "LDL and HDL cholesterol are the same molecule.",
}),
molarMassEntry({
key: "creatinine",
formula: { C: 4, H: 7, N: 3, O: 1 },
mass: "mg/dL",
amount: "µmol/L",
}),
molarMassEntry({
key: "urea",
formula: { C: 1, H: 4, N: 2, O: 1 },
mass: "mg/dL",
amount: "mmol/L",
note: "Urea itself, not urea nitrogen.",
}),
molarMassEntry({
key: "blood-urea-nitrogen",
// Only the two nitrogen atoms of urea count: BUN is mg of nitrogen per dL.
formula: { N: 2 },
mass: "mg/dL",
amount: "mmol/L",
note: "BUN in mg/dL against urea in mmol/L; two nitrogen atoms per urea molecule.",
}),
molarMassEntry({
key: "uric-acid",
formula: { C: 5, H: 4, N: 4, O: 3 },
mass: "mg/dL",
amount: "µmol/L",
}),
molarMassEntry({
key: "calcium-total",
formula: { Ca: 1 },
mass: "mg/dL",
amount: "mmol/L",
note: "Total calcium; ionised calcium is a different measurement.",
}),
molarMassEntry({
key: "bilirubin-total",
formula: { C: 33, H: 36, N: 4, O: 6 },
mass: "mg/dL",
amount: "µmol/L",
}),
molarMassEntry({
key: "vitamin-d",
formula: { C: 27, H: 44, O: 2 },
mass: "ng/mL",
amount: "nmol/L",
note: "25-hydroxyvitamin D, taken as cholecalciferol (C27H44O2).",
}),
defineAnalyteConversion({
key: "hba1c",
aliases: [],
hub: "%",
units: {
"%": { a: 1, b: 0 },
// NGSP(%) = 0.09148 x IFCC(mmol/mol) + 2.152. Affine, not a factor.
"mmol/mol": { a: 0.09148, b: 2.152 },
},
kind: "standard",
source:
"IFCC-NGSP master equation, Hoelzel W et al., Clin Chem 2004;50(1):166-174, " +
"as published by the NGSP: NGSP (%) = 0.09148 x IFCC (mmol/mol) + 2.152.",
}),
];
const BY_KEY: ReadonlyMap<string, AnalyteConversion> = (() => {
const map = new Map<string, AnalyteConversion>();
for (const entry of ENTRIES) {
for (const key of [entry.key, ...entry.aliases]) {
if (map.has(key)) {
throw new Error(`unit-conversion registry: "${key}" appears twice`);
}
map.set(key, entry);
}
}
return map;
})();
/** The conversion entry for an analyte key (or one of its aliases), if any. */
export function analyteConversionFor(
analyteKey: string | null | undefined,
): AnalyteConversion | null {
return analyteKey ? (BY_KEY.get(analyteKey) ?? null) : null;
}
/** Every entry, once each, for the guard test and for phase 3's key matching. */
export function allAnalyteConversions(): readonly AnalyteConversion[] {
return ENTRIES;
}
+130
View File
@@ -0,0 +1,130 @@
/**
* Units that differ only by a power of ten.
*
* A pair of units inside one family cannot change the quantity, only the
* prefix, so it needs no analyte: `mg/L` to `g/dL` is the same for every
* marker. A pair across families (mass to amount, `mg/dL` to `mmol/L`) is NOT a
* scale pair, because it needs the analyte's molar mass; those live only in the
* registry, per analyte.
*
* Every unit is stored as its power of ten relative to its family's base unit,
* computed from the prefix and the volume, not typed.
*/
import { normaliseLabUnit } from "../unit-normalise";
type FamilyName = "mass" | "amount" | "activity" | "count";
const MASS_PREFIX: ReadonlyArray<[string, number]> = [
["g", 0],
["mg", -3],
["µg", -6],
["ng", -9],
["pg", -12],
];
const AMOUNT_PREFIX: ReadonlyArray<[string, number]> = [
["mol", 0],
["mmol", -3],
["µmol", -6],
["nmol", -9],
["pmol", -12],
];
/** Litres per volume unit, as a power of ten. */
const VOLUME: Readonly<Record<string, number>> = {
L: 0,
dL: -1,
mL: -3,
µL: -6,
};
/** The mass-concentration units a lab prints, with the volumes they print on. */
const MASS_UNITS: ReadonlyArray<[string, string]> = [
["g", "L"],
["g", "dL"],
["mg", "L"],
["mg", "dL"],
["µg", "L"],
["µg", "dL"],
["ng", "L"],
["ng", "mL"],
["ng", "dL"],
["pg", "mL"],
];
const exponent = (table: ReadonlyArray<[string, number]>, prefix: string) =>
table.find(([name]) => name === prefix)![1];
/** unit -> [family, power of ten relative to the family's base unit]. */
const SCALE_UNITS: ReadonlyMap<string, readonly [FamilyName, number]> = new Map<
string,
readonly [FamilyName, number]
>([
// Base: g/L.
...MASS_UNITS.map(
([prefix, volume]) =>
[
`${prefix}/${volume}`,
["mass", exponent(MASS_PREFIX, prefix) - VOLUME[volume]],
] as const,
),
// Base: mol/L.
...AMOUNT_PREFIX.map(
([prefix, power]) => [`${prefix}/L`, ["amount", power - VOLUME.L]] as const,
),
// Base: IU/L. `µIU/mL` is `mIU/L`.
["IU/L", ["activity", 0]] as const,
["mIU/L", ["activity", -3]] as const,
["µIU/mL", ["activity", -6 - VOLUME.mL]] as const,
// Base: 10^9/L. A blood count is printed either way.
["10⁹/L", ["count", 9]] as const,
["10^9/L", ["count", 9]] as const,
["G/L", ["count", 9]] as const,
["10³/µL", ["count", 3 - VOLUME.µL]] as const,
["10^3/µL", ["count", 3 - VOLUME.µL]] as const,
["10¹²/L", ["count", 12]] as const,
["10^12/L", ["count", 12]] as const,
["T/L", ["count", 12]] as const,
["10⁶/µL", ["count", 6 - VOLUME.µL]] as const,
["10^6/µL", ["count", 6 - VOLUME.µL]] as const,
]);
export interface ScaleUnit {
family: FamilyName;
/** Power of ten of one of this unit, in the family's base unit. */
exponent: number;
}
/** The family a unit belongs to, or null for a unit that has none. */
export function scaleUnitOf(unit: string): ScaleUnit | null {
const found = SCALE_UNITS.get(normaliseLabUnit(unit));
return found ? { family: found[0], exponent: found[1] } : null;
}
/** Multiply by `10^power`, dividing for a negative power: `10^-3` is not exact. */
export function shiftPowerOfTen(value: number, power: number): number {
if (power === 0) return value;
return power > 0 ? value * 10 ** power : value / 10 ** -power;
}
/**
* `value` in `from`, written in `to`, when both are in the same family;
* null when either is in none or they are in different families.
*/
export function convertByScale(
value: number,
from: string,
to: string,
): number | null {
const a = scaleUnitOf(from);
const b = scaleUnitOf(to);
if (!a || !b || a.family !== b.family) return null;
return shiftPowerOfTen(value, a.exponent - b.exponent);
}
/** Every unit that has a family, in its spelling here. */
export function scaleUnitsInFamilyOf(unit: string): string[] {
const own = scaleUnitOf(unit);
if (!own) return [];
return [...SCALE_UNITS.entries()]
.filter(([, [family]]) => family === own.family)
.map(([spelling]) => spelling);
}