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.
108 lines
7.2 KiB
TypeScript
108 lines
7.2 KiB
TypeScript
// ============================================================
|
||
// 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 };
|
||
}
|