mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
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("");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user