pediatric-ai-scribe-v3/shared/clinical/bmi.ts
Daniel 7371cbf13a feat(client): port BMI + Vitals + Resus Meds + Equipment calculators
Nine of the ten Calculator pills now run in React. Only BP Percentile
(Rosner quantile splines, ~3,000 hand-transcribed coefficients across
6 LMS arrays and 4 spline matrices) remains legacy-linked; that port
deserves its own dedicated session with extra care.

shared/clinical/bmi.ts — CDC 2000 BMI-for-age
  • bmiLMS table ported byte-for-byte from calculators.js:739
    (74 LMS triples = 37 age points × 2 sexes, 24-240 months).
  • normalCDF (Abramowitz & Stegun), calcBmiPercentile, classifyBMI
    (including the %-of-95th severe-obesity split), and a top-level
    computeBmi helper — all verbatim translations of the vanilla math.
  • classification labels preserved exactly so existing e2e screenshots
    or reporting continue to read the same text ('Class 2 Severe
    Obesity', 'Healthy Weight', etc.).

shared/clinical/bmi.test.ts — 12 captured vectors covering:
  both sexes, edges (2y + 20y), interpolated-between-keys (13m),
  each classification cliff (underweight / healthy / overweight /
  obese / severe class 2 / severe class 3), and the age-clamping
  branches (<24 mo and >240 mo). All 12 pass at 10-decimal precision
  (percentile to 6 places since the vanilla rounds to 2).

scripts/capture-calc-vectors.js — BMI section added
  Same pattern as bilirubin / Fenton: the vanilla data + math are
  inlined verbatim, the script runs 12 chosen cases, and writes to
  e2e/fixtures/calc-vectors.json. Re-run after any upstream change.

client/src/pages/CalculatorPanels.tsx (new)
  • BmiPanel — age/sex/weight/height inputs, calls computeBmi,
    renders color-coded classification badge with BMI, percentile,
    Z, and % of 95th when percentile ≥85.
  • VitalsPanel — 8-band age selector (premie → >12 yr).
    VITALS_DATA ported verbatim from calculators.js:1703-1831 with
    every HR/RR/SBP/DBP/temp/weight/SpO₂ range and clinical notes
    preserved entry-for-entry.
  • ResusPanel — weight input drives all 14 drugs (Adenosine,
    Amiodarone, Atropine, CaCl, Ca-gluconate, Dextrose, Epi,
    Hydrocortisone, Insulin, Lidocaine, Mg, Naloxone, NaHCO₃) with
    calc() closures ported verbatim from RESUS_MEDS lines 1873-2050.
    Category colors + labels preserved.
  • EquipmentPanel — 9-band age selector (premie → 16+ yr).
    EQUIP_DATA ported verbatim from calculators.js:2173-2228 with
    12 equipment sizes per band (BVM, NPA, OPA, blade, ETT, LMA,
    Glidescope, IV, CVL, NGT, chest tube, Foley).

client/src/pages/Calculators.tsx
  Dispatch wires bmi → BmiPanel, vitals → VitalsPanel, resus →
  ResusPanel, equipment → EquipmentPanel. PILLS flags all four as
  ported: true. Only bp remains on LegacyPanel.

Backend tsc + client tsc + vite build + 122/122 vitest (19 calc +
70 bili + 21 fenton/neonatal + 12 bmi) all green.
2026-04-24 02:08:05 +02:00

108 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ============================================================
// BMI-FOR-AGE — CDC 2000 LMS. Table + math + classification
// ported VERBATIM from public/js/calculators.js:739-1017.
// Vitest parity driven by calc-vectors.json.
//
// CDC reference:
// Kuczmarski RJ, Ogden CL, Guo SS, et al. 2000 CDC Growth
// Charts for the United States: methods and development.
// Vital Health Stat 11. 2002;(246):1-190.
// ============================================================
import type { Sex, Lms } from './fenton';
import { interpolateLMS } from './fenton';
// 37 age points (24-240 months, 6-mo steps) × 2 sexes. Verbatim from
// calculators.js:739. Values are floating-point to 6+ decimals; the
// test vectors pin every one.
export const bmiLMS: Record<Sex, Record<number, Lms>> = {
male: {
24: { L: -1.982374, M: 16.5478, S: 0.080127 }, 30: { L: -1.642107, M: 16.2497, S: 0.075499 }, 36: { L: -1.419991, M: 16.0003, S: 0.072634 }, 42: { L: -1.438165, M: 15.7941, S: 0.071495 },
48: { L: -1.714869, M: 15.6282, S: 0.071889 }, 54: { L: -2.155348, M: 15.5026, S: 0.073491 }, 60: { L: -2.615166, M: 15.4191, S: 0.075992 }, 66: { L: -2.981797, M: 15.3795, S: 0.079211 },
72: { L: -3.211705, M: 15.3835, S: 0.083048 }, 78: { L: -3.314769, M: 15.429, S: 0.0874 }, 84: { L: -3.323189, M: 15.5129, S: 0.092131 }, 90: { L: -3.270455, M: 15.6317, S: 0.097082 },
96: { L: -3.183058, M: 15.7823, S: 0.102091 }, 102: { L: -3.079383, M: 15.9617, S: 0.107013 }, 108: { L: -2.971148, M: 16.1671, S: 0.111721 }, 114: { L: -2.865311, M: 16.3961, S: 0.116113 },
120: { L: -2.765648, M: 16.6461, S: 0.120112 }, 126: { L: -2.673903, M: 16.9151, S: 0.123664 }, 132: { L: -2.59056, M: 17.2009, S: 0.126735 }, 138: { L: -2.51532, M: 17.5014, S: 0.129309 },
144: { L: -2.447426, M: 17.8146, S: 0.131389 }, 150: { L: -2.385858, M: 18.1387, S: 0.132991 }, 156: { L: -2.329457, M: 18.4718, S: 0.134141 }, 162: { L: -2.277017, M: 18.812, S: 0.13488 },
168: { L: -2.227362, M: 19.1576, S: 0.135251 }, 174: { L: -2.179426, M: 19.5067, S: 0.135309 }, 180: { L: -2.132345, M: 19.8577, S: 0.13511 }, 186: { L: -2.085574, M: 20.2086, S: 0.134718 },
192: { L: -2.039015, M: 20.5576, S: 0.134198 }, 198: { L: -1.99315, M: 20.9029, S: 0.13362 }, 204: { L: -1.949135, M: 21.2425, S: 0.133057 }, 210: { L: -1.908831, M: 21.5742, S: 0.132585 },
216: { L: -1.87467, M: 21.8959, S: 0.132286 }, 222: { L: -1.849323, M: 22.2054, S: 0.132249 }, 228: { L: -1.835138, M: 22.5007, S: 0.132566 }, 234: { L: -1.833401, M: 22.7799, S: 0.133339 },
240: { L: -1.843581, M: 23.0414, S: 0.134675 },
},
female: {
24: { L: -1.024497, M: 16.388, S: 0.085026 }, 30: { L: -1.534542, M: 16.0059, S: 0.080932 }, 36: { L: -2.096829, M: 15.6992, S: 0.078605 }, 42: { L: -2.618733, M: 15.4647, S: 0.077904 },
48: { L: -3.018522, M: 15.2985, S: 0.078713 }, 54: { L: -3.2593, M: 15.1961, S: 0.080904 }, 60: { L: -3.350078, M: 15.1519, S: 0.0843 }, 66: { L: -3.325522, M: 15.1606, S: 0.08868 },
72: { L: -3.225607, M: 15.2169, S: 0.093803 }, 78: { L: -3.084291, M: 15.3161, S: 0.099427 }, 84: { L: -2.926187, M: 15.4536, S: 0.105325 }, 90: { L: -2.76731, M: 15.6252, S: 0.111295 },
96: { L: -2.617192, M: 15.827, S: 0.117159 }, 102: { L: -2.480952, M: 16.0552, S: 0.122771 }, 108: { L: -2.360921, M: 16.3061, S: 0.128014 }, 114: { L: -2.257782, M: 16.5763, S: 0.132797 },
120: { L: -2.171296, M: 16.8623, S: 0.137057 }, 126: { L: -2.100749, M: 17.161, S: 0.140754 }, 132: { L: -2.045235, M: 17.4691, S: 0.143868 }, 138: { L: -2.003802, M: 17.7836, S: 0.146399 },
144: { L: -1.975521, M: 18.1015, S: 0.148361 }, 150: { L: -1.95952, M: 18.42, S: 0.149783 }, 156: { L: -1.954978, M: 18.7364, S: 0.150705 }, 162: { L: -1.9611, M: 19.0481, S: 0.151176 },
168: { L: -1.977074, M: 19.3526, S: 0.151256 }, 174: { L: -2.002014, M: 19.6475, S: 0.15101 }, 180: { L: -2.034893, M: 19.9306, S: 0.150512 }, 186: { L: -2.07446, M: 20.1998, S: 0.149843 },
192: { L: -2.119157, M: 20.4533, S: 0.14909 }, 198: { L: -2.167045, M: 20.6891, S: 0.148349 }, 204: { L: -2.215738, M: 20.9058, S: 0.147723 }, 210: { L: -2.262382, M: 21.1016, S: 0.147323 },
216: { L: -2.303688, M: 21.2753, S: 0.147269 }, 222: { L: -2.336038, M: 21.4255, S: 0.147689 }, 228: { L: -2.355678, M: 21.5508, S: 0.148724 }, 234: { L: -2.35898, M: 21.6501, S: 0.150521 },
240: { L: -2.342797, M: 21.7219, S: 0.153241 },
},
};
// Abramowitz & Stegun erf-form normal CDF (0-1 range). Verbatim from
// calculators.js:754-761.
export function normalCDF(z: number): number {
const a1 = 0.254829592, a2 = -0.284496736, a3 = 1.421413741;
const a4 = -1.453152027, a5 = 1.061405429, p = 0.3275911;
const sign = z < 0 ? -1 : 1;
const x = Math.abs(z) / Math.sqrt(2);
const t = 1 / (1 + p * x);
const y = 1 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * Math.exp(-x * x);
return 0.5 * (1 + sign * y);
}
// Verbatim from calculators.js:741-752.
export interface BmiZ { z: number; percentile: number }
export function calcBmiPercentile(bmi: number, L: number, M: number, S: number): BmiZ {
let z: number;
if (Math.abs(L) < 0.001) z = Math.log(bmi / M) / S;
else z = (Math.pow(bmi / M, L) - 1) / (L * S);
const p = normalCDF(z);
return { z, percentile: Math.round(p * 10000) / 100 };
}
// Verbatim from calculators.js:1000-1017.
export type BmiClassification =
| 'Underweight' | 'Healthy Weight' | 'Overweight'
| 'Obese (Class 1)' | 'Class 2 Severe Obesity' | 'Class 3 Severe Obesity';
export interface BmiClass {
label: BmiClassification;
color: string;
bg: string;
pctOf95: number;
bmi95: number;
}
export function classifyBMI(percentile: number, bmi: number, lms: Lms): BmiClass {
const p95z = 1.645;
const bmi95 = Math.abs(lms.L) < 0.001
? lms.M * Math.exp(lms.S * p95z)
: lms.M * Math.pow(1 + lms.L * lms.S * p95z, 1 / lms.L);
const pctOf95 = (bmi / bmi95) * 100;
if (percentile >= 95) {
if (pctOf95 >= 140) return { label: 'Class 3 Severe Obesity', color: '#7f1d1d', bg: '#fecaca', pctOf95, bmi95 };
if (pctOf95 >= 120) return { label: 'Class 2 Severe Obesity', color: '#dc2626', bg: '#fee2e2', pctOf95, bmi95 };
return { label: 'Obese (Class 1)', color: '#ef4444', bg: '#fee2e2', pctOf95, bmi95 };
}
if (percentile >= 85) return { label: 'Overweight', color: '#f97316', bg: '#ffedd5', pctOf95, bmi95 };
if (percentile >= 5) return { label: 'Healthy Weight', color: '#10b981', bg: '#d1fae5', pctOf95, bmi95 };
return { label: 'Underweight', color: '#f59e0b', bg: '#fef3c7', pctOf95, bmi95 };
}
// Top-level helper combining interpolate + math + classification.
// Mirrors the click-handler flow in calculators.js:1019-1038.
export interface BmiResult extends BmiZ {
bmi: number;
L: number; M: number; S: number;
classification: BmiClass;
}
export function computeBmi(weightKg: number, heightCm: number, ageMonths: number, sex: Sex): BmiResult {
const bmi = weightKg / Math.pow(heightCm / 100, 2);
const clamped = Math.max(24, Math.min(240, ageMonths));
const lms = interpolateLMS(bmiLMS[sex], clamped);
const { z, percentile } = calcBmiPercentile(bmi, lms.L, lms.M, lms.S);
const classification = classifyBMI(percentile, bmi, lms);
return { bmi, z, percentile, L: lms.L, M: lms.M, S: lms.S, classification };
}