// Lab unit conversion. Two transform kinds: dimensional scaling (prefix/volume, // analyte-independent) and the mass<->mole bridge, which requires the analyte's // molar mass. %, ratios, IU, and cell counts never bridge to mass or mole. type Kind = "mass" | "mole" | "count" | "iu" | "percent" | "ratio" | "other"; const AMOUNT: Record = { g: ["mass", 1], mg: ["mass", 1e-3], ug: ["mass", 1e-6], mcg: ["mass", 1e-6], "µg": ["mass", 1e-6], ng: ["mass", 1e-9], pg: ["mass", 1e-12], mol: ["mole", 1], mmol: ["mole", 1e-3], umol: ["mole", 1e-6], "µmol": ["mole", 1e-6], nmol: ["mole", 1e-9], pmol: ["mole", 1e-12], cells: ["count", 1], k: ["count", 1e3], thousand: ["count", 1e3], million: ["count", 1e6], "x10^9": ["count", 1e9], "x10^12": ["count", 1e12], iu: ["iu", 1], miu: ["iu", 1e-3], uiu: ["iu", 1e-6], "µiu": ["iu", 1e-6], u: ["iu", 1], }; const VOLUME: Record = { l: 1, dl: 0.1, ml: 1e-3, ul: 1e-6, "µl": 1e-6 }; const clean = (u: string) => u.toLowerCase().replace(/\s+/g, "").replace("μ", "µ"); interface Parsed { kind: Kind; amountFactor: number; volumeFactor: number } export function parseUnit(unit: string): Parsed | null { const u = clean(unit); if (u === "%") return { kind: "percent", amountFactor: 1, volumeFactor: 1 }; if (u === "" || u === "ratio" || u === "index") return { kind: "ratio", amountFactor: 1, volumeFactor: 1 }; const m = u.match(/^([a-zµ0-9^]+)\/([a-zµ0-9.]+)$/); if (m) { const amtEntry = AMOUNT[m[1]]; const volFactor = VOLUME[m[2]]; if (amtEntry && volFactor) return { kind: amtEntry[0], amountFactor: amtEntry[1], volumeFactor: volFactor }; } return { kind: "other", amountFactor: 1, volumeFactor: 1 }; } export function convert(value: number, fromUnit: string, toUnit: string, molarMass?: number | null): number | null { const from = parseUnit(fromUnit); const to = parseUnit(toUnit); if (!from || !to) return null; if (clean(fromUnit) === clean(toUnit)) return value; if (from.kind === "other" || to.kind === "other") return null; const fromBasePerL = (value * from.amountFactor) / from.volumeFactor; let toBasePerL = fromBasePerL; if (from.kind !== to.kind) { const bridge = (from.kind === "mass" && to.kind === "mole") || (from.kind === "mole" && to.kind === "mass"); if (!bridge || !molarMass) return null; toBasePerL = from.kind === "mass" ? fromBasePerL / molarMass : fromBasePerL * molarMass; } return (toBasePerL * to.volumeFactor) / to.amountFactor; }