pediatric-ai-scribe-v3/shared/clinical/fenton.ts
Daniel ff4b3de988 feat(client): port Bilirubin + Fenton calculators with captured vectors
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).
2026-04-24 01:49:45 +02:00

93 lines
3.9 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.

// ============================================================
// 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';
}