Ships the AAP 2022 phototherapy/exchange nomograms, the Bhutani 1999
risk zones, and the Fenton 2013 preterm weight-for-GA chart as real
React calculators. These were the high-risk "table-driven" ports the
migration checkpoint flagged as needing captured vectors before
landing — the whole vector-capture workflow now exists and can be
reused for BP / BMI / growth-beyond-Fenton next.
Workflow, so future calculator ports have a template:
scripts/capture-calc-vectors.js
Standalone Node script. Data tables + math inlined VERBATIM from
public/js/calculators.js (no rewriting, no reformatting). Picks 83
carefully chosen test cases — edge-of-domain, table-key-exact,
interpolated-between-keys, and clinical-decision-boundary values —
and emits e2e/fixtures/calc-vectors.json with
{ inputs, output } pairs produced by the authoritative math.
e2e/fixtures/calc-vectors.json
12 Bhutani cases, 58 AAP 2022 cases, 13 Fenton cases. Re-run
capture-calc-vectors.js whenever the vanilla file changes.
shared/clinical/bilirubin.ts
• 17 HourTable constants ported byte-for-byte from calculators.js
lines 1489-1512: photo 35w/36w/37w/38w/39w/40+ (low risk) +
35w/36w/37w/38+ (medium risk), same 8 for exchange.
• Bhutani zones (p95/p75/p40) from lines 1644-1651.
• interpolateThreshold helper (lines 1514-1525).
• classifyBhutani + classifyAapBili functions mirror the vanilla
click-handler logic.
shared/clinical/fenton.ts
• 15-week × 2-sex LMS table from lines 1168-1183 preserved entry-
for-entry.
• interpolateLMS + calcZ + zToPercentile (Abramowitz & Stegun
normal CDF, lines 2299-2326) ported byte-for-byte.
• fentonWeightForAge returns { L, M, S, z, percentile };
classifySizeForAge labels SGA/<10 / AGA / LGA/>90.
shared/clinical/bilirubin.test.ts + fenton.test.ts
Vitest suites that import e2e/fixtures/calc-vectors.json and assert
classifyBhutani / classifyAapBili / fentonWeightForAge match
every captured vector to 10 decimal places for threshold values
and 6 decimals for the Fenton M (grams, so 6 is >= 1e-3 g).
All 102 tests pass locally (19 prior + 70 bili + 13 Fenton).
client/src/pages/Calculators.tsx
• BiliPanel with AAP 2022 / Bhutani mode switch, GA + risk-factor
dropdowns, color-coded status / zone badges, both threshold
pairs surfaced in the result grid.
• GrowthPanel runs Fenton with sex + GA + weight inputs, emits
Z-score, percentile, L/M/S reference, and SGA/AGA/LGA label.
• PILLS flags bili + growth as ported: true; ActivePanel routes to
the new components. BP / BMI / vitals / resus / equipment
remain legacy-linked until their own vectors land.
e2e/tests/calculators-react.spec.js
Three new parity tests covering above-phototherapy/above-exchange
transitions (38w 72h TSB 20 → phototherapy; TSB 26 → exchange),
Bhutani high-risk classification at 36h TSB 13, and Fenton 32w
male 1795g landing exactly at 50th percentile AGA.
Backend tsc + client tsc + vite build + vitest (102/102) all green.
Bundle 619 kB / 178 kB gz (+10 kB for the bili tables).
93 lines
3.9 KiB
TypeScript
93 lines
3.9 KiB
TypeScript
// ============================================================
|
||
// FENTON 2013 — preterm growth LMS for weight-for-GA. Table
|
||
// ported VERBATIM from public/js/calculators.js:1168-1183
|
||
// (15 GA weeks × 2 sexes × {L,M,S}). Parity against the vanilla
|
||
// implementation pinned by e2e/fixtures/calc-vectors.json +
|
||
// fenton.test.ts.
|
||
//
|
||
// Reference:
|
||
// Fenton TR, Kim JH. A systematic review and meta-analysis
|
||
// to revise the Fenton growth chart for preterm infants.
|
||
// BMC Pediatr 2013;13:59.
|
||
// ============================================================
|
||
|
||
export type Sex = 'male' | 'female';
|
||
export interface Lms { L: number; M: number; S: number }
|
||
|
||
export interface FentonResult {
|
||
L: number;
|
||
M: number;
|
||
S: number;
|
||
z: number;
|
||
percentile: number;
|
||
}
|
||
|
||
// Verbatim from calculators.js:1168-1183.
|
||
export const fentonLMS: Record<Sex, Record<number, Lms>> = {
|
||
male: {
|
||
22: { L: 0.21, M: 496, S: 0.17 }, 24: { L: 0.21, M: 660, S: 0.17 }, 26: { L: 0.21, M: 870, S: 0.16 },
|
||
28: { L: 0.20, M: 1124, S: 0.15 }, 30: { L: 0.18, M: 1430, S: 0.14 }, 32: { L: 0.15, M: 1795, S: 0.14 },
|
||
34: { L: 0.12, M: 2230, S: 0.13 }, 36: { L: 0.08, M: 2710, S: 0.13 }, 38: { L: 0.04, M: 3195, S: 0.12 },
|
||
40: { L: 0.01, M: 3530, S: 0.12 }, 42: { L: -0.02, M: 3820, S: 0.12 }, 44: { L: -0.04, M: 4200, S: 0.12 },
|
||
46: { L: -0.06, M: 4680, S: 0.12 }, 48: { L: -0.07, M: 5200, S: 0.12 }, 50: { L: -0.08, M: 5760, S: 0.12 },
|
||
},
|
||
female: {
|
||
22: { L: 0.23, M: 474, S: 0.17 }, 24: { L: 0.22, M: 610, S: 0.17 }, 26: { L: 0.22, M: 810, S: 0.16 },
|
||
28: { L: 0.21, M: 1040, S: 0.15 }, 30: { L: 0.19, M: 1330, S: 0.14 }, 32: { L: 0.16, M: 1680, S: 0.14 },
|
||
34: { L: 0.12, M: 2090, S: 0.13 }, 36: { L: 0.08, M: 2540, S: 0.13 }, 38: { L: 0.04, M: 3000, S: 0.12 },
|
||
40: { L: 0.01, M: 3340, S: 0.12 }, 42: { L: -0.02, M: 3630, S: 0.12 }, 44: { L: -0.04, M: 4010, S: 0.12 },
|
||
46: { L: -0.06, M: 4470, S: 0.12 }, 48: { L: -0.07, M: 4970, S: 0.12 }, 50: { L: -0.08, M: 5510, S: 0.12 },
|
||
},
|
||
};
|
||
|
||
// Verbatim from calculators.js:2299-2311.
|
||
export function interpolateLMS(table: Record<number, Lms>, val: number): Lms {
|
||
const keys = Object.keys(table).map(Number).sort((a, b) => a - b);
|
||
if (val <= keys[0]) return table[keys[0]];
|
||
if (val >= keys[keys.length - 1]) return table[keys[keys.length - 1]];
|
||
for (let i = 0; i < keys.length - 1; i++) {
|
||
if (val >= keys[i] && val <= keys[i + 1]) {
|
||
const t = (val - keys[i]) / (keys[i + 1] - keys[i]);
|
||
const lms1 = table[keys[i]];
|
||
const lms2 = table[keys[i + 1]];
|
||
return {
|
||
L: lms1.L + t * (lms2.L - lms1.L),
|
||
M: lms1.M + t * (lms2.M - lms1.M),
|
||
S: lms1.S + t * (lms2.S - lms1.S),
|
||
};
|
||
}
|
||
}
|
||
return table[keys[0]];
|
||
}
|
||
|
||
// Verbatim from calculators.js:2313-2316.
|
||
export function calcZ(value: number, L: number, M: number, S: number): number {
|
||
if (L === 0) return Math.log(value / M) / S;
|
||
return (Math.pow(value / M, L) - 1) / (L * S);
|
||
}
|
||
|
||
// Abramowitz & Stegun normal CDF approximation, verbatim from
|
||
// calculators.js:2318-2326.
|
||
export function zToPercentile(z: number): number {
|
||
const t = 1 / (1 + 0.2316419 * Math.abs(z));
|
||
const d = 0.3989423 * Math.exp(-z * z / 2);
|
||
let p = d * t * (0.3193815 + t * (-0.3565638 + t * (1.781478 + t * (-1.821256 + t * 1.330274))));
|
||
if (z > 0) p = 1 - p;
|
||
return p * 100;
|
||
}
|
||
|
||
export function fentonWeightForAge(gaWeeks: number, weightGrams: number, sex: Sex): FentonResult {
|
||
const lms = interpolateLMS(fentonLMS[sex], gaWeeks);
|
||
const z = calcZ(weightGrams, lms.L, lms.M, lms.S);
|
||
const percentile = zToPercentile(z);
|
||
return { L: lms.L, M: lms.M, S: lms.S, z, percentile };
|
||
}
|
||
|
||
// Clinical classification — per AAP 2017 / Fenton 2013:
|
||
// SGA: <10th percentile, LGA: >90th, AGA: between.
|
||
export type SizeForAge = 'SGA' | 'AGA' | 'LGA';
|
||
export function classifySizeForAge(percentile: number): SizeForAge {
|
||
if (percentile < 10) return 'SGA';
|
||
if (percentile > 90) return 'LGA';
|
||
return 'AGA';
|
||
}
|