diff --git a/src/lib/labs/unit-conversion/__tests__/convert.test.ts b/src/lib/labs/unit-conversion/__tests__/convert.test.ts new file mode 100644 index 000000000..762b60ba5 --- /dev/null +++ b/src/lib/labs/unit-conversion/__tests__/convert.test.ts @@ -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); + } + } + } + }); +}); diff --git a/src/lib/labs/unit-conversion/__tests__/registry.test.ts b/src/lib/labs/unit-conversion/__tests__/registry.test.ts new file mode 100644 index 000000000..724a7f8b4 --- /dev/null +++ b/src/lib/labs/unit-conversion/__tests__/registry.test.ts @@ -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 = { + "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 = { + "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/); + }); +}); diff --git a/src/lib/labs/unit-conversion/__tests__/scale.test.ts b/src/lib/labs/unit-conversion/__tests__/scale.test.ts new file mode 100644 index 000000000..8fe7e1ac9 --- /dev/null +++ b/src/lib/labs/unit-conversion/__tests__/scale.test.ts @@ -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"); + }); +}); diff --git a/src/lib/labs/unit-conversion/atomic-weights.ts b/src/lib/labs/unit-conversion/atomic-weights.ts new file mode 100644 index 000000000..9c0fe807d --- /dev/null +++ b/src/lib/labs/unit-conversion/atomic-weights.ts @@ -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>>; + +/** 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(""); +} diff --git a/src/lib/labs/unit-conversion/convert.ts b/src/lib/labs/unit-conversion/convert.ts new file mode 100644 index 000000000..9e108489b --- /dev/null +++ b/src/lib/labs/unit-conversion/convert.ts @@ -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 = [input.low, input.high]; + const converted: Array = []; + 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(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); +} diff --git a/src/lib/labs/unit-conversion/registry.ts b/src/lib/labs/unit-conversion/registry.ts new file mode 100644 index 000000000..2ec1e2ee1 --- /dev/null +++ b/src/lib/labs/unit-conversion/registry.ts @@ -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>; + 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 = (() => { + const map = new Map(); + 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; +} diff --git a/src/lib/labs/unit-conversion/scale.ts b/src/lib/labs/unit-conversion/scale.ts new file mode 100644 index 000000000..987f07b36 --- /dev/null +++ b/src/lib/labs/unit-conversion/scale.ts @@ -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> = { + 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 = 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); +}