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).
This commit is contained in:
Daniel 2026-04-24 01:49:44 +02:00
parent 1274391224
commit 9aa3961459
12 changed files with 1961 additions and 7 deletions

View file

@ -27,6 +27,8 @@ import {
calculateMostellerBsa,
calculateWeightBasedDose,
} from '@shared/clinical/calculators';
import { classifyBhutani, classifyAapBili, type BiliRisk } from '@shared/clinical/bilirubin';
import { fentonWeightForAge, classifySizeForAge, type Sex } from '@shared/clinical/fenton';
const card = 'rounded-lg border border-border bg-card p-5 space-y-3';
const btnPrimary = 'rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium';
@ -48,8 +50,8 @@ interface Pill {
const PILLS: Pill[] = [
{ id: 'bp', label: 'BP Percentile', summary: 'AAP 2017 age/height/sex-adjusted BP percentiles (Rosner quantile splines).', source: 'AAP 2017 (Flynn) — Rosner splines' },
{ id: 'bmi', label: 'BMI Percentile', summary: 'BMI-for-age (CDC 2000 z-score tables).', source: 'CDC 2000 LMS' },
{ id: 'growth', label: 'Growth Charts', summary: 'WHO 02 y / CDC 220 y; Fenton 2013 preterm (weight, length, head).', source: 'WHO 2006 + CDC 2000 + Fenton 2013 LMS' },
{ id: 'bili', label: 'Bilirubin', summary: 'AAP 2022 phototherapy + exchange thresholds and Bhutani nomogram risk zones.', source: 'AAP 2022 (Kemper) + Bhutani 1999' },
{ id: 'growth', label: 'Growth Charts', summary: 'Fenton 2013 preterm weight-for-GA with Z-score + percentile + SGA/AGA/LGA classification.', source: 'Fenton 2013 LMS', ported: true },
{ id: 'bili', label: 'Bilirubin', summary: 'AAP 2022 phototherapy + exchange thresholds and Bhutani nomogram risk zones.', source: 'AAP 2022 (Kemper) + Bhutani 1999', ported: true },
{ id: 'vitals', label: 'Vital Signs', summary: 'Normal HR / RR / BP ranges by age.', source: 'PALS + AHA reference' },
{ id: 'bsa', label: 'Body Surface Area', summary: 'Mosteller body surface area formula.', source: 'Mosteller 1987', ported: true },
{ id: 'dose', label: 'Weight-Based Dosing', summary: 'Generic mg/kg dosing with optional max-dose cap and concentration conversion.', source: 'Legacy calculator formula', ported: true },
@ -390,10 +392,181 @@ function LegacyPanel({ pill }: { pill: Pill }) {
);
}
function BiliPanel() {
const [mode, setMode] = useState<'aap' | 'bhutani'>('aap');
const [ga, setGa] = useState('38');
const [hours, setHours] = useState('');
const [tsb, setTsb] = useState('');
const [risk, setRisk] = useState<BiliRisk>('low');
const [aapResult, setAapResult] = useState<ReturnType<typeof classifyAapBili> | null>(null);
const [bhutResult, setBhutResult] = useState<ReturnType<typeof classifyBhutani> | null>(null);
const [error, setError] = useState('');
function calc() {
const hoursNum = Number(hours);
const tsbNum = Number(tsb);
if (!Number.isFinite(hoursNum) || !Number.isFinite(tsbNum) || hoursNum <= 0 || tsbNum <= 0) {
setError('Enter hours of life and TSB (mg/dL).');
setAapResult(null);
setBhutResult(null);
return;
}
setError('');
if (mode === 'aap') {
const gaNum = Number(ga);
if (!Number.isFinite(gaNum) || gaNum < 35) {
setError('AAP 2022 thresholds apply to GA ≥35 weeks.');
setAapResult(null);
return;
}
setAapResult(classifyAapBili(gaNum, hoursNum, tsbNum, risk));
setBhutResult(null);
} else {
setBhutResult(classifyBhutani(hoursNum, tsbNum));
setAapResult(null);
}
}
const statusColor = aapResult
? aapResult.status === 'Above Exchange' ? 'text-red-800 bg-red-100'
: aapResult.status === 'Above Phototherapy' ? 'text-red-700 bg-red-50'
: 'text-green-700 bg-green-50'
: '';
const zoneColor = bhutResult
? bhutResult.zone === 'High-Risk' ? 'text-red-800 bg-red-100'
: bhutResult.zone === 'High-Intermediate' ? 'text-orange-700 bg-orange-50'
: bhutResult.zone === 'Low-Intermediate' ? 'text-amber-700 bg-amber-50'
: 'text-green-700 bg-green-50'
: '';
return (
<section className={card} data-testid="calc-panel-bili">
<h2 className="text-lg font-semibold">Bilirubin</h2>
<div className="flex gap-2">
<button type="button" onClick={() => setMode('aap')} className={'px-3 py-1 rounded text-xs font-medium ' + (mode === 'aap' ? 'bg-primary text-primary-foreground' : 'bg-muted')} data-testid="bili-mode-aap">AAP 2022 Phototherapy</button>
<button type="button" onClick={() => setMode('bhutani')} className={'px-3 py-1 rounded text-xs font-medium ' + (mode === 'bhutani' ? 'bg-primary text-primary-foreground' : 'bg-muted')} data-testid="bili-mode-bhutani">Bhutani Nomogram</button>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{mode === 'aap' && (
<>
<div className={field}>
<label htmlFor="bili-ga" className={label}>GA (weeks)</label>
<select id="bili-ga" className={input} value={ga} onChange={(e) => setGa(e.target.value)}>
{[35, 36, 37, 38, 39, 40].map((g) => <option key={g} value={g}>{g}{g === 40 ? '+' : ''}</option>)}
</select>
</div>
<div className={field}>
<label htmlFor="bili-risk" className={label}>Neurotoxicity risk</label>
<select id="bili-risk" className={input} value={risk} onChange={(e) => setRisk(e.target.value as BiliRisk)}>
<option value="low">No risk factors</option>
<option value="medium">With risk factors</option>
</select>
</div>
</>
)}
<FormField id="bili-hours" labelText="Age (hours)" value={hours} onChange={setHours} min="0" max="336" placeholder="48" />
<FormField id="bili-tsb" labelText="TSB (mg/dL)" value={tsb} onChange={setTsb} min="0" max="50" placeholder="15" />
</div>
<div className="flex gap-2">
<button type="button" className={btnPrimary} onClick={calc} data-testid="calc-bili-calculate">Calculate</button>
<button type="button" className={btnGhost} onClick={() => { setHours(''); setTsb(''); setAapResult(null); setBhutResult(null); setError(''); }}>Clear</button>
</div>
{error && <div className={errorBox}>{error}</div>}
{aapResult && (
<div className={resultBox + ' space-y-2'} data-testid="calc-bili-aap-result">
<div className={'inline-block px-2 py-1 rounded text-sm font-bold ' + statusColor}>{aapResult.status}</div>
<div className="text-sm">TSB {tsb} mg/dL at {hours} hours of life (GA {ga}w {risk === 'medium' ? 'with' : 'without'} risk factors)</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div><span className="text-xs uppercase text-muted-foreground">Phototherapy</span><div className="font-semibold">{aapResult.photoThreshold.toFixed(1)} mg/dL</div></div>
<div><span className="text-xs uppercase text-muted-foreground">Exchange</span><div className="font-semibold text-red-800">{aapResult.exchangeThreshold.toFixed(1)} mg/dL</div></div>
</div>
<div className="text-xs text-muted-foreground italic">AAP 2022 CPG (Kemper et al.). Always use clinical judgment.</div>
</div>
)}
{bhutResult && (
<div className={resultBox + ' space-y-2'} data-testid="calc-bili-bhutani-result">
<div className={'inline-block px-2 py-1 rounded text-sm font-bold ' + zoneColor}>{bhutResult.zone} Zone</div>
<div className="text-sm">TSB {tsb} mg/dL at {hours} hours of life</div>
<div className="grid grid-cols-3 gap-3 text-sm">
<div><span className="text-xs uppercase text-muted-foreground">40th %ile</span><div className="font-semibold">{bhutResult.p40.toFixed(1)}</div></div>
<div><span className="text-xs uppercase text-muted-foreground">75th %ile</span><div className="font-semibold">{bhutResult.p75.toFixed(1)}</div></div>
<div><span className="text-xs uppercase text-muted-foreground">95th %ile</span><div className="font-semibold">{bhutResult.p95.toFixed(1)}</div></div>
</div>
<div className="text-xs text-muted-foreground italic">Bhutani 1999 hour-specific risk nomogram for infants 35 weeks GA.</div>
</div>
)}
</section>
);
}
function GrowthPanel() {
const [sex, setSex] = useState<Sex>('male');
const [ga, setGa] = useState('');
const [weight, setWeight] = useState('');
const [result, setResult] = useState<ReturnType<typeof fentonWeightForAge> | null>(null);
const [error, setError] = useState('');
function calc() {
const gaNum = Number(ga);
const wtNum = Number(weight);
if (!Number.isFinite(gaNum) || !Number.isFinite(wtNum) || gaNum < 22 || gaNum > 50 || wtNum <= 0) {
setError('Enter GA (22-50 weeks) and weight (grams).');
setResult(null);
return;
}
setError('');
setResult(fentonWeightForAge(gaNum, wtNum, sex));
}
const classification = result ? classifySizeForAge(result.percentile) : null;
const classColor = classification === 'SGA' ? 'text-orange-700 bg-orange-50'
: classification === 'LGA' ? 'text-amber-700 bg-amber-50'
: 'text-green-700 bg-green-50';
return (
<section className={card} data-testid="calc-panel-growth">
<h2 className="text-lg font-semibold">Fenton 2013 Preterm Growth</h2>
<p className="text-sm text-muted-foreground">Weight-for-gestational-age Z-score + percentile + SGA/AGA/LGA classification.</p>
<div className="grid gap-3 sm:grid-cols-3">
<div className={field}>
<label htmlFor="fenton-sex" className={label}>Sex</label>
<select id="fenton-sex" className={input} value={sex} onChange={(e) => setSex(e.target.value as Sex)}>
<option value="male">Male</option>
<option value="female">Female</option>
</select>
</div>
<FormField id="fenton-ga" labelText="GA (weeks)" value={ga} onChange={setGa} min="22" max="50" step="0.1" placeholder="32" />
<FormField id="fenton-weight" labelText="Weight (g)" value={weight} onChange={setWeight} min="200" max="7000" step="10" placeholder="1500" />
</div>
<div className="flex gap-2">
<button type="button" className={btnPrimary} onClick={calc} data-testid="calc-fenton-calculate">Calculate</button>
<button type="button" className={btnGhost} onClick={() => { setGa(''); setWeight(''); setResult(null); setError(''); }}>Clear</button>
</div>
{error && <div className={errorBox}>{error}</div>}
{result && classification && (
<div className={resultBox + ' space-y-2'} data-testid="calc-fenton-result">
<div className={'inline-block px-2 py-1 rounded text-sm font-bold ' + classColor}>{classification}</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm">
<div><span className="text-xs uppercase text-muted-foreground">Percentile</span><div className="font-semibold">{result.percentile.toFixed(1)}%</div></div>
<div><span className="text-xs uppercase text-muted-foreground">Z-score</span><div className="font-semibold">{result.z.toFixed(2)}</div></div>
<div><span className="text-xs uppercase text-muted-foreground">Median (M)</span><div className="font-semibold">{Math.round(result.M)} g</div></div>
<div><span className="text-xs uppercase text-muted-foreground">L / S</span><div className="font-mono text-xs">{result.L.toFixed(3)} / {result.S.toFixed(3)}</div></div>
</div>
<div className="text-xs text-muted-foreground italic">Fenton TR, Kim JH. Systematic review revised Fenton growth chart for preterm infants. BMC Pediatr 2013;13:59.</div>
</div>
)}
</section>
);
}
function ActivePanel({ pill }: { pill: Pill }) {
if (pill.id === 'bsa') return <BsaPanel />;
if (pill.id === 'dose') return <DosePanel />;
if (pill.id === 'gcs') return <GcsPanel />;
if (pill.id === 'bili') return <BiliPanel />;
if (pill.id === 'growth') return <GrowthPanel />;
return <LegacyPanel pill={pill} />;
}

File diff suppressed because it is too large Load diff

View file

@ -65,4 +65,45 @@ test.describe('React Calculators — sub-nav shell', () => {
await page.selectOption('#react-gcs-motor', '1');
await expect(page.locator('[data-testid="calc-gcs-result"]')).toContainText('GCS: 10/15');
});
test('AAP 2022 bilirubin classifies above exchange correctly', async ({ authedPage: _, page }) => {
await openReactCalc(page);
await page.click('[data-testid="calc-pill-bili"]');
// 38w low-risk at 72h: phototherapy threshold = 18.8, exchange = 25.9.
// TSB 20 should be "Above Phototherapy"; 26 should be "Above Exchange".
await page.selectOption('#bili-ga', '38');
await page.selectOption('#bili-risk', 'low');
await page.fill('#bili-hours', '72');
await page.fill('#bili-tsb', '20');
await page.click('[data-testid="calc-bili-calculate"]');
await expect(page.locator('[data-testid="calc-bili-aap-result"]')).toContainText('Above Phototherapy');
await expect(page.locator('[data-testid="calc-bili-aap-result"]')).toContainText('18.8');
await page.fill('#bili-tsb', '26');
await page.click('[data-testid="calc-bili-calculate"]');
await expect(page.locator('[data-testid="calc-bili-aap-result"]')).toContainText('Above Exchange');
});
test('Bhutani nomogram classifies high-risk zone', async ({ authedPage: _, page }) => {
await openReactCalc(page);
await page.click('[data-testid="calc-pill-bili"]');
await page.click('[data-testid="bili-mode-bhutani"]');
// At 36h, p95 = 12.8 — TSB 13 should land in High-Risk.
await page.fill('#bili-hours', '36');
await page.fill('#bili-tsb', '13');
await page.click('[data-testid="calc-bili-calculate"]');
await expect(page.locator('[data-testid="calc-bili-bhutani-result"]')).toContainText('High-Risk Zone');
});
test('Fenton growth classifies 32w male 1795g as AGA (50th percentile)', async ({ authedPage: _, page }) => {
await openReactCalc(page);
await page.click('[data-testid="calc-pill-growth"]');
// 32w male median is 1795g — should land right at 50th percentile.
await page.selectOption('#fenton-sex', 'male');
await page.fill('#fenton-ga', '32');
await page.fill('#fenton-weight', '1795');
await page.click('[data-testid="calc-fenton-calculate"]');
await expect(page.locator('[data-testid="calc-fenton-result"]')).toContainText('AGA');
await expect(page.locator('[data-testid="calc-fenton-result"]')).toContainText('50.0%');
});
});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/app/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>client</title>
<script type="module" crossorigin src="/app/assets/index-TLwX8Fhd.js"></script>
<link rel="stylesheet" crossorigin href="/app/assets/index-BFoZEwhP.css">
<script type="module" crossorigin src="/app/assets/index-CFyXXJlL.js"></script>
<link rel="stylesheet" crossorigin href="/app/assets/index-DrRLZ8fC.css">
</head>
<body>
<div id="root"></div>

View file

@ -0,0 +1,266 @@
#!/usr/bin/env node
// ============================================================
// CAPTURE-CALC-VECTORS — writes e2e/fixtures/calc-vectors.json with
// authoritative outputs from the vanilla calculator math.
//
// The math + data tables below are lifted VERBATIM from
// public/js/calculators.js (with the IIFE wrapper stripped). No
// modifications, no reformatting — any rewrite here would defeat
// the whole point of the capture script.
//
// Each entry in the generated JSON has:
// { name, inputs, output }
//
// Run with: node scripts/capture-calc-vectors.js
// Re-run whenever the vanilla file's math or tables change.
// ============================================================
'use strict';
const fs = require('fs');
const path = require('path');
// ── Bhutani 1999 zones ─────────────────────────────────────
// From public/js/calculators.js lines 1644-1651.
const bhutaniZones = {
p95: { 6:6.0, 12:7.2, 18:8.5, 24:9.6, 30:11.2, 36:12.8, 42:13.8, 48:14.8,
54:15.6, 60:16.2, 66:16.8, 72:17.4, 84:18.0, 96:18.4, 108:18.8, 120:19.0 },
p75: { 6:4.5, 12:5.5, 18:6.6, 24:7.8, 30:9.2, 36:10.6, 42:11.6, 48:12.6,
54:13.4, 60:14.0, 66:14.6, 72:15.0, 84:15.4, 96:15.6, 108:15.8, 120:16.0 },
p40: { 6:3.0, 12:4.0, 18:5.0, 24:6.2, 30:7.2, 36:8.4, 42:9.2, 48:10.0,
54:10.6, 60:11.2, 66:11.8, 72:12.2, 84:12.6, 96:12.8, 108:13.0, 120:13.2 }
};
// ── AAP 2022 phototherapy + exchange tables (verbatim lines 1489-1512) ──
const photoThresholds35 = {12:8.5,13:8.7,14:8.9,15:9.0,16:9.2,17:9.4,18:9.6,19:9.8,20:9.9,21:10.1,22:10.3,23:10.4,24:10.6,25:10.8,26:10.9,27:11.1,28:11.3,29:11.4,30:11.6,31:11.7,32:11.9,33:12.0,34:12.2,35:12.3,36:12.5,37:12.6,38:12.8,39:12.9,40:13.1,41:13.2,42:13.4,43:13.5,44:13.6,45:13.8,46:13.9,47:14.0,48:14.2,49:14.3,50:14.4,51:14.5,52:14.7,53:14.8,54:14.9,55:15.0,56:15.1,57:15.3,58:15.4,59:15.5,60:15.6,61:15.7,62:15.8,63:15.9,64:16.0,65:16.1,66:16.2,67:16.3,68:16.4,69:16.5,70:16.6,71:16.7,72:16.8,73:16.9,74:17.0,75:17.1,76:17.2,77:17.3,78:17.4,79:17.5,80:17.5,81:17.6,82:17.7,83:17.8,84:17.8,85:17.9,86:18.0,87:18.1,88:18.1,89:18.2,90:18.3,91:18.3,92:18.4,93:18.5,94:18.5,95:18.6,96:18.6};
const photoThresholds36 = {12:9.0,13:9.2,14:9.4,15:9.6,16:9.8,17:9.9,18:10.1,19:10.3,20:10.5,21:10.6,22:10.8,23:11.0,24:11.2,25:11.3,26:11.5,27:11.7,28:11.8,29:12.0,30:12.1,31:12.3,32:12.5,33:12.6,34:12.8,35:12.9,36:13.1,37:13.2,38:13.4,39:13.5,40:13.7,41:13.8,42:13.9,43:14.1,44:14.2,45:14.4,46:14.5,47:14.6,48:14.8,49:14.9,50:15.0,51:15.1,52:15.3,53:15.4,54:15.5,55:15.6,56:15.8,57:15.9,58:16.0,59:16.1,60:16.2,61:16.3,62:16.5,63:16.6,64:16.7,65:16.8,66:16.9,67:17.0,68:17.1,69:17.2,70:17.3,71:17.4,72:17.5,73:17.6,74:17.7,75:17.8,76:17.9,77:17.9,78:18.0,79:18.1,80:18.2,81:18.3,82:18.4,83:18.4,84:18.5,85:18.6,86:18.7,87:18.8,88:18.8,89:18.9,90:19.0,91:19.0,92:19.1,93:19.2,94:19.2,95:19.3,96:19.3};
const photoThresholds37 = {12:9.6,13:9.8,14:9.9,15:10.1,16:10.3,17:10.5,18:10.7,19:10.8,20:11.0,21:11.2,22:11.4,23:11.5,24:11.7,25:11.9,26:12.1,27:12.2,28:12.4,29:12.5,30:12.7,31:12.9,32:13.0,33:13.2,34:13.3,35:13.5,36:13.6,37:13.8,38:13.9,39:14.1,40:14.2,41:14.4,42:14.5,43:14.7,44:14.8,45:15.0,46:15.1,47:15.2,48:15.4,49:15.5,50:15.6,51:15.8,52:15.9,53:16.0,54:16.1,55:16.3,56:16.4,57:16.5,58:16.6,59:16.7,60:16.9,61:17.0,62:17.1,63:17.2,64:17.3,65:17.4,66:17.5,67:17.6,68:17.7,69:17.8,70:17.9,71:18.0,72:18.1,73:18.2,74:18.3,75:18.4,76:18.5,77:18.6,78:18.7,79:18.8,80:18.9,81:19.0,82:19.0,83:19.1,84:19.2,85:19.3,86:19.4,87:19.4,88:19.5,89:19.6,90:19.7,91:19.7,92:19.8,93:19.9,94:19.9,95:20.0,96:20.0};
const photoThresholds38 = {12:10.1,13:10.3,14:10.5,15:10.7,16:10.8,17:11.0,18:11.2,19:11.4,20:11.6,21:11.7,22:11.9,23:12.1,24:12.3,25:12.4,26:12.6,27:12.8,28:12.9,29:13.1,30:13.3,31:13.4,32:13.6,33:13.8,34:13.9,35:14.1,36:14.2,37:14.4,38:14.5,39:14.7,40:14.8,41:15.0,42:15.1,43:15.3,44:15.4,45:15.6,46:15.7,47:15.8,48:16.0,49:16.1,50:16.2,51:16.4,52:16.5,53:16.6,54:16.8,55:16.9,56:17.0,57:17.1,58:17.3,59:17.4,60:17.5,61:17.6,62:17.7,63:17.8,64:17.9,65:18.1,66:18.2,67:18.3,68:18.4,69:18.5,70:18.6,71:18.7,72:18.8,73:18.9,74:19.0,75:19.1,76:19.2,77:19.3,78:19.4,79:19.5,80:19.5,81:19.6,82:19.7,83:19.8,84:19.9,85:20.0,86:20.0,87:20.1,88:20.2,89:20.3,90:20.3,91:20.4,92:20.5,93:20.6,94:20.6,95:20.7,96:20.7};
const photoThresholds39 = {12:10.6,13:10.8,14:11.0,15:11.2,16:11.4,17:11.6,18:11.8,19:11.9,20:12.1,21:12.3,22:12.5,23:12.7,24:12.8,25:13.0,26:13.2,27:13.3,28:13.5,29:13.7,30:13.8,31:14.0,32:14.2,33:14.3,34:14.5,35:14.7,36:14.8,37:15.0,38:15.1,39:15.3,40:15.4,41:15.6,42:15.7,43:15.9,44:16.0,45:16.2,46:16.3,47:16.4,48:16.6,49:16.7,50:16.8,51:17.0,52:17.1,53:17.2,54:17.4,55:17.5,56:17.6,57:17.8,58:17.9,59:18.0,60:18.1,61:18.2,62:18.4,63:18.5,64:18.6,65:18.7,66:18.8,67:18.9,68:19.0,69:19.1,70:19.2,71:19.3,72:19.5,73:19.6,74:19.7,75:19.7,76:19.8,77:19.9,78:20.0,79:20.1,80:20.2,81:20.3,82:20.4,83:20.5,84:20.6,85:20.6,86:20.7,87:20.8,88:20.9,89:21.0,90:21.0,91:21.1,92:21.2,93:21.3,94:21.3,95:21.4,96:21.5};
const photoThresholds40 = {12:11.2,13:11.3,14:11.5,15:11.7,16:11.9,17:12.1,18:12.3,19:12.5,20:12.7,21:12.8,22:13.0,23:13.2,24:13.4,25:13.6,26:13.7,27:13.9,28:14.1,29:14.2,30:14.4,31:14.6,32:14.7,33:14.9,34:15.1,35:15.2,36:15.4,37:15.6,38:15.7,39:15.9,40:16.0,41:16.2,42:16.3,43:16.5,44:16.6,45:16.8,46:16.9,47:17.1,48:17.2,49:17.4,50:17.5,51:17.6,52:17.8,53:17.9,54:18.1,55:18.2,56:18.3,57:18.5,58:18.6,59:18.7,60:18.9,61:19.0,62:19.1,63:19.3,64:19.4,65:19.5,66:19.6,67:19.7,68:19.9,69:20.0,70:20.1,71:20.2,72:20.3,73:20.4,74:20.5,75:20.6,76:20.7,77:20.8,78:20.9,79:21.0,80:21.1,81:21.2,82:21.3,83:21.4,84:21.5,85:21.5,86:21.6,87:21.7,88:21.8,89:21.9,90:21.9,91:22.0,92:22.1,93:22.2,94:22.2,95:22.3,96:22.3};
const photoThresholds35risk = {12:6.9,13:7.1,14:7.2,15:7.4,16:7.6,17:7.7,18:7.9,19:8.1,20:8.2,21:8.4,22:8.6,23:8.7,24:8.9,25:9.0,26:9.2,27:9.3,28:9.5,29:9.6,30:9.8,31:9.9,32:10.1,33:10.2,34:10.3,35:10.5,36:10.6,37:10.8,38:10.9,39:11.0,40:11.2,41:11.3,42:11.4,43:11.5,44:11.7,45:11.8,46:11.9,47:12.0,48:12.2,49:12.3,50:12.4,51:12.5,52:12.6,53:12.7,54:12.8,55:13.0,56:13.1,57:13.2,58:13.3,59:13.4,60:13.5,61:13.6,62:13.7,63:13.8,64:13.9,65:14.0,66:14.1,67:14.2,68:14.2,69:14.3,70:14.4,71:14.5,72:14.6,73:14.7,74:14.8,75:14.8,76:14.9,77:15.0,78:15.1,79:15.1,80:15.2,81:15.3,82:15.3,83:15.4,84:15.5,85:15.5,86:15.6,87:15.7,88:15.7,89:15.8,90:15.8,91:15.9,92:15.9,93:16.0,94:16.1,95:16.1,96:16.1};
const photoThresholds36risk = {12:7.4,13:7.6,14:7.8,15:8.0,16:8.1,17:8.3,18:8.5,19:8.6,20:8.8,21:9.0,22:9.1,23:9.3,24:9.4,25:9.6,26:9.8,27:9.9,28:10.1,29:10.2,30:10.4,31:10.5,32:10.7,33:10.8,34:11.0,35:11.1,36:11.2,37:11.4,38:11.5,39:11.7,40:11.8,41:11.9,42:12.1,43:12.2,44:12.3,45:12.5,46:12.6,47:12.7,48:12.8,49:13.0,50:13.1,51:13.2,52:13.3,53:13.4,54:13.5,55:13.7,56:13.8,57:13.9,58:14.0,59:14.1,60:14.2,61:14.3,62:14.4,63:14.5,64:14.6,65:14.7,66:14.8,67:14.9,68:15.0,69:15.1,70:15.2,71:15.3,72:15.4,73:15.4,74:15.5,75:15.6,76:15.7,77:15.8,78:15.8,79:15.9,80:16.0,81:16.1,82:16.1,83:16.2,84:16.3,85:16.4,86:16.4,87:16.5,88:16.6,89:16.6,90:16.7,91:16.7,92:16.8,93:16.8,94:16.9,95:17.0,96:17.0};
const photoThresholds37risk = {12:8.0,13:8.1,14:8.3,15:8.5,16:8.7,17:8.9,18:9.0,19:9.2,20:9.4,21:9.5,22:9.7,23:9.9,24:10.0,25:10.2,26:10.4,27:10.5,28:10.7,29:10.8,30:11.0,31:11.1,32:11.3,33:11.4,34:11.6,35:11.7,36:11.9,37:12.0,38:12.2,39:12.3,40:12.4,41:12.6,42:12.7,43:12.9,44:13.0,45:13.1,46:13.2,47:13.4,48:13.5,49:13.6,50:13.8,51:13.9,52:14.0,53:14.1,54:14.2,55:14.4,56:14.5,57:14.6,58:14.7,59:14.8,60:14.9,61:15.0,62:15.1,63:15.2,64:15.3,65:15.4,66:15.5,67:15.6,68:15.7,69:15.8,70:15.9,71:16.0,72:16.1,73:16.2,74:16.3,75:16.4,76:16.5,77:16.6,78:16.6,79:16.7,80:16.8,81:16.9,82:17.0,83:17.0,84:17.1,85:17.2,86:17.2,87:17.3,88:17.4,89:17.4,90:17.5,91:17.6,92:17.6,93:17.7,94:17.8,95:17.8,96:17.9};
const photoThresholds38risk = {12:8.5,13:8.6,14:8.8,15:9.0,16:9.2,17:9.4,18:9.5,19:9.7,20:9.9,21:10.0,22:10.2,23:10.4,24:10.5,25:10.7,26:10.8,27:11.0,28:11.2,29:11.3,30:11.5,31:11.6,32:11.8,33:11.9,34:12.1,35:12.2,36:12.4,37:12.5,38:12.7,39:12.8,40:12.9,41:13.1,42:13.2,43:13.3,44:13.5,45:13.6,46:13.7,47:13.9,48:14.0,49:14.1,50:14.2,51:14.4,52:14.5,53:14.6,54:14.7,55:14.8,56:14.9,57:15.1,58:15.2,59:15.3,60:15.4,61:15.5,62:15.6,63:15.7,64:15.8,65:15.9,66:16.0,67:16.1,68:16.2,69:16.3,70:16.4,71:16.5,72:16.6,73:16.6,74:16.7,75:16.8,76:16.9,77:17.0,78:17.1,79:17.1,80:17.2,81:17.3,82:17.4,83:17.4,84:17.5,85:17.6,86:17.6,87:17.7,88:17.8,89:17.8,90:17.9,91:18.0,92:18.0,93:18.1,94:18.1,95:18.2,96:18.2};
const exchangeThresholds35 = {12:16.4,13:16.5,14:16.6,15:16.8,16:16.9,17:17.0,18:17.2,19:17.3,20:17.4,21:17.5,22:17.7,23:17.8,24:17.9,25:18.0,26:18.2,27:18.3,28:18.4,29:18.5,30:18.7,31:18.8,32:18.9,33:19.0,34:19.1,35:19.2,36:19.4,37:19.5,38:19.6,39:19.7,40:19.8,41:19.9,42:20.0,43:20.1,44:20.2,45:20.3,46:20.5,47:20.6,48:20.7,49:20.8,50:20.9,51:21.0,52:21.1,53:21.2,54:21.3,55:21.4,56:21.5,57:21.6,58:21.7,59:21.7,60:21.8,61:21.9,62:22.0,63:22.1,64:22.2,65:22.3,66:22.4,67:22.5,68:22.6,69:22.6,70:22.7,71:22.8,72:22.9,73:23.0,74:23.1,75:23.1,76:23.2,77:23.3,78:23.4,79:23.4,80:23.5,81:23.6,82:23.7,83:23.7,84:23.8,85:23.9,86:23.9,87:24.0,88:24.1,89:24.1,90:24.2,91:24.3,92:24.3,93:24.4,94:24.4,95:24.5,96:24.5};
const exchangeThresholds36 = {12:17.5,13:17.7,14:17.8,15:17.9,16:18.1,17:18.2,18:18.3,19:18.5,20:18.6,21:18.7,22:18.9,23:19.0,24:19.1,25:19.2,26:19.4,27:19.5,28:19.6,29:19.7,30:19.9,31:20.0,32:20.1,33:20.2,34:20.4,35:20.5,36:20.6,37:20.7,38:20.8,39:20.9,40:21.0,41:21.2,42:21.3,43:21.4,44:21.5,45:21.6,46:21.7,47:21.8,48:21.9,49:22.0,50:22.1,51:22.2,52:22.3,53:22.4,54:22.5,55:22.6,56:22.7,57:22.8,58:22.9,59:23.0,60:23.1,61:23.2,62:23.2,63:23.3,64:23.4,65:23.5,66:23.6,67:23.7,68:23.8,69:23.8,70:23.9,71:24.0,72:24.1,73:24.1,74:24.2,75:24.3,76:24.4,77:24.4,78:24.5,79:24.6,80:24.6,81:24.7,82:24.8,83:24.8,84:24.9,85:25.0,86:25.0,87:25.1,88:25.2,89:25.2,90:25.3,91:25.3,92:25.4,93:25.4,94:25.5,95:25.5,96:25.5};
const exchangeThresholds37 = {12:18.7,13:18.8,14:18.9,15:19.1,16:19.2,17:19.4,18:19.5,19:19.6,20:19.8,21:19.9,22:20.1,23:20.2,24:20.3,25:20.5,26:20.6,27:20.7,28:20.8,29:21.0,30:21.1,31:21.2,32:21.3,33:21.5,34:21.6,35:21.7,36:21.8,37:21.9,38:22.1,39:22.2,40:22.3,41:22.4,42:22.5,43:22.6,44:22.7,45:22.8,46:22.9,47:23.0,48:23.1,49:23.2,50:23.3,51:23.4,52:23.5,53:23.6,54:23.7,55:23.8,56:23.9,57:24.0,58:24.1,59:24.2,60:24.3,61:24.4,62:24.5,63:24.5,64:24.6,65:24.7,66:24.8,67:24.9,68:24.9,69:25.0,70:25.1,71:25.2,72:25.2,73:25.3,74:25.4,75:25.5,76:25.5,77:25.6,78:25.7,79:25.7,80:25.8,81:25.8,82:25.9,83:26.0,84:26.0,85:26.1,86:26.1,87:26.2,88:26.2,89:26.3,90:26.3,91:26.4,92:26.4,93:26.5,94:26.5,95:26.5,96:26.6};
const exchangeThresholds38 = {12:19.7,13:19.9,14:20.0,15:20.1,16:20.3,17:20.4,18:20.6,19:20.7,20:20.8,21:21.0,22:21.1,23:21.2,24:21.4,25:21.5,26:21.6,27:21.7,28:21.9,29:22.0,30:22.1,31:22.2,32:22.3,33:22.4,34:22.6,35:22.7,36:22.8,37:22.9,38:23.0,39:23.1,40:23.2,41:23.3,42:23.4,43:23.5,44:23.6,45:23.7,46:23.8,47:23.9,48:24.0,49:24.1,50:24.2,51:24.3,52:24.4,53:24.5,54:24.6,55:24.7,56:24.7,57:24.8,58:24.9,59:25.0,60:25.1,61:25.2,62:25.2,63:25.3,64:25.4,65:25.5,66:25.5,67:25.6,68:25.7,69:25.7,70:25.8,71:25.9,72:25.9,73:26.0,74:26.0,75:26.1,76:26.2,77:26.2,78:26.3,79:26.3,80:26.4,81:26.4,82:26.5,83:26.5,84:26.6,85:26.6,86:26.7,87:26.7,88:26.7,89:26.8,90:26.8,91:26.9,92:26.9,93:26.9,94:27.0,95:27.0,96:27.0};
const exchangeThresholds35risk = {12:14.6,13:14.8,14:14.9,15:15.0,16:15.1,17:15.3,18:15.4,19:15.5,20:15.6,21:15.8,22:15.9,23:16.0,24:16.1,25:16.2,26:16.3,27:16.4,28:16.5,29:16.6,30:16.8,31:16.9,32:17.0,33:17.1,34:17.2,35:17.3,36:17.4,37:17.5,38:17.6,39:17.7,40:17.7,41:17.8,42:17.9,43:18.0,44:18.1,45:18.2,46:18.3,47:18.4,48:18.5,49:18.5,50:18.6,51:18.7,52:18.8,53:18.9,54:18.9,55:19.0,56:19.1,57:19.2,58:19.2,59:19.3,60:19.4,61:19.4,62:19.5,63:19.6,64:19.6,65:19.7,66:19.8,67:19.8,68:19.9,69:19.9,70:20.0,71:20.1,72:20.1,73:20.2,74:20.2,75:20.3,76:20.3,77:20.4,78:20.4,79:20.5,80:20.5,81:20.6,82:20.6,83:20.6,84:20.7,85:20.7,86:20.8,87:20.8,88:20.8,89:20.9,90:20.9,91:20.9,92:21.0,93:21.0,94:21.0,95:21.1,96:21.1};
const exchangeThresholds36risk = {12:15.2,13:15.3,14:15.4,15:15.6,16:15.7,17:15.8,18:15.9,19:16.1,20:16.2,21:16.3,22:16.4,23:16.5,24:16.6,25:16.8,26:16.9,27:17.0,28:17.1,29:17.2,30:17.3,31:17.4,32:17.5,33:17.6,34:17.7,35:17.8,36:17.9,37:18.0,38:18.1,39:18.2,40:18.3,41:18.4,42:18.5,43:18.6,44:18.7,45:18.8,46:18.9,47:19.0,48:19.1,49:19.2,50:19.2,51:19.3,52:19.4,53:19.5,54:19.6,55:19.7,56:19.7,57:19.8,58:19.9,59:20.0,60:20.1,61:20.1,62:20.2,63:20.3,64:20.3,65:20.4,66:20.5,67:20.6,68:20.6,69:20.7,70:20.8,71:20.8,72:20.9,73:20.9,74:21.0,75:21.1,76:21.1,77:21.2,78:21.2,79:21.3,80:21.4,81:21.4,82:21.5,83:21.5,84:21.6,85:21.6,86:21.7,87:21.7,88:21.8,89:21.8,90:21.9,91:21.9,92:22.0,93:22.0,94:22.0,95:22.1,96:22.1};
const exchangeThresholds37risk = {12:15.7,13:15.9,14:16.0,15:16.1,16:16.2,17:16.4,18:16.5,19:16.6,20:16.7,21:16.8,22:17.0,23:17.1,24:17.2,25:17.3,26:17.4,27:17.5,28:17.7,29:17.8,30:17.9,31:18.0,32:18.1,33:18.2,34:18.3,35:18.4,36:18.5,37:18.6,38:18.7,39:18.8,40:18.9,41:19.0,42:19.1,43:19.2,44:19.3,45:19.4,46:19.5,47:19.6,48:19.7,49:19.8,50:19.9,51:20.0,52:20.1,53:20.1,54:20.2,55:20.3,56:20.4,57:20.5,58:20.6,59:20.7,60:20.7,61:20.8,62:20.9,63:21.0,64:21.1,65:21.1,66:21.2,67:21.3,68:21.4,69:21.4,70:21.5,71:21.6,72:21.7,73:21.7,74:21.8,75:21.9,76:21.9,77:22.0,78:22.1,79:22.1,80:22.2,81:22.3,82:22.3,83:22.4,84:22.5,85:22.5,86:22.6,87:22.6,88:22.7,89:22.8,90:22.8,91:22.9,92:22.9,93:23.0,94:23.0,95:23.1,96:23.1};
const exchangeThresholds38risk = {12:16.3,13:16.4,14:16.5,15:16.6,16:16.7,17:16.9,18:17.0,19:17.1,20:17.2,21:17.3,22:17.4,23:17.6,24:17.7,25:17.8,26:17.9,27:18.0,28:18.1,29:18.2,30:18.3,31:18.4,32:18.5,33:18.7,34:18.8,35:18.9,36:19.0,37:19.1,38:19.2,39:19.3,40:19.4,41:19.5,42:19.6,43:19.7,44:19.8,45:19.9,46:19.9,47:20.0,48:20.1,49:20.2,50:20.3,51:20.4,52:20.5,53:20.6,54:20.7,55:20.8,56:20.8,57:20.9,58:21.0,59:21.1,60:21.2,61:21.3,62:21.3,63:21.4,64:21.5,65:21.6,66:21.7,67:21.7,68:21.8,69:21.9,70:22.0,71:22.0,72:22.1,73:22.2,74:22.2,75:22.3,76:22.4,77:22.5,78:22.5,79:22.6,80:22.7,81:22.7,82:22.8,83:22.8,84:22.9,85:23.0,86:23.0,87:23.1,88:23.1,89:23.2,90:23.3,91:23.3,92:23.4,93:23.4,94:23.5,95:23.5,96:23.5};
// ── Fenton 2013 LMS (verbatim from lines 1168-1183) ─────────
const fentonLMS = {
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}
}
};
// ── Helper math (verbatim from lines 1514-1525 + 2299-2328) ──
function interpolateThreshold(table, hours) {
const keys = Object.keys(table).map(Number).sort((a, b) => a - b);
if (hours <= keys[0]) return table[keys[0]];
if (hours >= keys[keys.length - 1]) return table[keys[keys.length - 1]];
for (let i = 0; i < keys.length - 1; i++) {
if (hours >= keys[i] && hours <= keys[i + 1]) {
const t = (hours - keys[i]) / (keys[i + 1] - keys[i]);
return table[keys[i]] + t * (table[keys[i + 1]] - table[keys[i]]);
}
}
return table[keys[0]];
}
function interpolateLMS(table, val) {
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]], 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]];
}
function calcZ(value, L, M, S) {
if (L === 0) return Math.log(value / M) / S;
return (Math.pow(value / M, L) - 1) / (L * S);
}
function zToPercentile(z) {
// Abramowitz & Stegun normal CDF approximation (from lines 2318-2326).
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;
}
// ── Bhutani classification (verbatim logic from lines 1662-1666) ──
function classifyBhutani(hours, tsb) {
const p95 = interpolateThreshold(bhutaniZones.p95, hours);
const p75 = interpolateThreshold(bhutaniZones.p75, hours);
const p40 = interpolateThreshold(bhutaniZones.p40, hours);
let zone;
if (tsb >= p95) zone = 'High-Risk';
else if (tsb >= p75) zone = 'High-Intermediate';
else if (tsb >= p40) zone = 'Low-Intermediate';
else zone = 'Low-Risk';
return { p95, p75, p40, zone };
}
// ── AAP 2022 phototherapy + exchange (verbatim logic from lines 1541-1583) ──
function aapPhotoTable(gaNum, risk) {
if (risk === 'medium') {
if (gaNum === 35) return photoThresholds35risk;
if (gaNum === 36) return photoThresholds36risk;
if (gaNum === 37) return photoThresholds37risk;
return photoThresholds38risk; // 38+
}
if (gaNum === 35) return photoThresholds35;
if (gaNum === 36) return photoThresholds36;
if (gaNum === 37) return photoThresholds37;
if (gaNum === 38) return photoThresholds38;
if (gaNum === 39) return photoThresholds39;
return photoThresholds40; // 40+
}
function aapExchangeTable(gaNum, risk) {
if (risk === 'medium') {
if (gaNum === 35) return exchangeThresholds35risk;
if (gaNum === 36) return exchangeThresholds36risk;
if (gaNum === 37) return exchangeThresholds37risk;
return exchangeThresholds38risk; // 38+
}
if (gaNum === 35) return exchangeThresholds35;
if (gaNum === 36) return exchangeThresholds36;
if (gaNum === 37) return exchangeThresholds37;
return exchangeThresholds38; // 38+
}
function classifyAapBili(gaWeeks, hours, tsb, risk) {
const photoTable = aapPhotoTable(gaWeeks, risk);
const exchangeTable = aapExchangeTable(gaWeeks, risk);
const photoThreshold = interpolateThreshold(photoTable, hours);
const exchangeThreshold = interpolateThreshold(exchangeTable, hours);
const abovePhoto = tsb >= photoThreshold;
const aboveExchange = tsb >= exchangeThreshold;
let status;
if (aboveExchange) status = 'Above Exchange';
else if (abovePhoto) status = 'Above Phototherapy';
else status = 'Below Phototherapy';
return { photoThreshold, exchangeThreshold, status };
}
// ── Fenton weight-for-GA Z + percentile (verbatim 1260-1266) ──
function fentonWeightForAge(gaWeeks, weightGrams, sex) {
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 };
}
// ── Case picker ────────────────────────────────────────────
const bhutaniCases = [
// Edges of the domain
{ name: 'Bhutani low-end (6h)', inputs: { hours: 6, tsb: 5 } },
{ name: 'Bhutani high-end (120h)', inputs: { hours: 120, tsb: 18 } },
// Exactly on each curve boundary at a few time points
{ name: 'Bhutani exactly p95 at 24h', inputs: { hours: 24, tsb: 9.6 } },
{ name: 'Bhutani exactly p75 at 24h', inputs: { hours: 24, tsb: 7.8 } },
{ name: 'Bhutani exactly p40 at 24h', inputs: { hours: 24, tsb: 6.2 } },
// Interpolated (between table keys)
{ name: 'Bhutani interpolated 27h', inputs: { hours: 27, tsb: 9 } },
{ name: 'Bhutani interpolated 45h', inputs: { hours: 45, tsb: 13 } },
{ name: 'Bhutani interpolated 78h', inputs: { hours: 78, tsb: 16 } },
// Classifications across all four zones
{ name: 'Bhutani low-risk 36h', inputs: { hours: 36, tsb: 7 } },
{ name: 'Bhutani low-intermediate 36h', inputs: { hours: 36, tsb: 9 } },
{ name: 'Bhutani high-intermediate 36h', inputs: { hours: 36, tsb: 11 } },
{ name: 'Bhutani high-risk 36h', inputs: { hours: 36, tsb: 15 } },
];
const aapCases = [];
[35, 36, 37, 38, 39, 40, 41].forEach((ga) => {
['low', 'medium'].forEach((risk) => {
// Low-end of table
aapCases.push({
name: `AAP ${ga}w ${risk}-risk at 12h`,
inputs: { gaWeeks: ga, hours: 12, tsb: 10, risk },
});
// Interior
aapCases.push({
name: `AAP ${ga}w ${risk}-risk at 48h`,
inputs: { gaWeeks: ga, hours: 48, tsb: 15, risk },
});
// Top of table
aapCases.push({
name: `AAP ${ga}w ${risk}-risk at 96h`,
inputs: { gaWeeks: ga, hours: 96, tsb: 18, risk },
});
// Interpolated hour
aapCases.push({
name: `AAP ${ga}w ${risk}-risk at 31.5h`,
inputs: { gaWeeks: ga, hours: 31.5, tsb: 13 },
// risk attached below; keep spread
});
aapCases[aapCases.length - 1].inputs.risk = risk;
});
});
// Below / above exchange edge
aapCases.push({ name: 'AAP 38w low-risk at 72h TSB 19 (above exchange boundary)', inputs: { gaWeeks: 38, hours: 72, tsb: 19, risk: 'low' } });
aapCases.push({ name: 'AAP 35w medium-risk at 72h TSB 16 (above phototherapy)', inputs: { gaWeeks: 35, hours: 72, tsb: 16, risk: 'medium' } });
const fentonCases = [
// Edges of GA domain
{ name: 'Fenton male 22w at 500g', inputs: { sex: 'male', gaWeeks: 22, weightGrams: 500 } },
{ name: 'Fenton female 22w at 500g', inputs: { sex: 'female', gaWeeks: 22, weightGrams: 500 } },
{ name: 'Fenton male 50w at 5500g', inputs: { sex: 'male', gaWeeks: 50, weightGrams: 5500 } },
{ name: 'Fenton female 50w at 5500g', inputs: { sex: 'female', gaWeeks: 50, weightGrams: 5500 } },
// Exactly on table keys
{ name: 'Fenton male 28w at 1124g (M)', inputs: { sex: 'male', gaWeeks: 28, weightGrams: 1124 } },
{ name: 'Fenton female 30w at 1330g (M)', inputs: { sex: 'female', gaWeeks: 30, weightGrams: 1330 } },
// Interpolated GA weeks
{ name: 'Fenton male 33w at 2000g', inputs: { sex: 'male', gaWeeks: 33, weightGrams: 2000 } },
{ name: 'Fenton female 37w at 2800g', inputs: { sex: 'female', gaWeeks: 37, weightGrams: 2800 } },
{ name: 'Fenton male 40.5w at 3600g', inputs: { sex: 'male', gaWeeks: 40.5, weightGrams: 3600 } },
// Typical term
{ name: 'Fenton male 40w at 3500g', inputs: { sex: 'male', gaWeeks: 40, weightGrams: 3500 } },
{ name: 'Fenton female 40w at 3200g', inputs: { sex: 'female', gaWeeks: 40, weightGrams: 3200 } },
// SGA / LGA
{ name: 'Fenton male 36w SGA 2000g', inputs: { sex: 'male', gaWeeks: 36, weightGrams: 2000 } },
{ name: 'Fenton female 36w LGA 3400g', inputs: { sex: 'female', gaWeeks: 36, weightGrams: 3400 } },
];
// ── Run + emit ─────────────────────────────────────────────
const vectors = {
generatedAt: new Date().toISOString(),
source: 'public/js/calculators.js',
bhutani: bhutaniCases.map((c) => ({ ...c, output: classifyBhutani(c.inputs.hours, c.inputs.tsb) })),
aap: aapCases.map((c) => ({ ...c, output: classifyAapBili(c.inputs.gaWeeks, c.inputs.hours, c.inputs.tsb, c.inputs.risk) })),
fenton: fentonCases.map((c) => ({ ...c, output: fentonWeightForAge(c.inputs.gaWeeks, c.inputs.weightGrams, c.inputs.sex) })),
};
const outPath = path.join(__dirname, '..', 'e2e', 'fixtures', 'calc-vectors.json');
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, JSON.stringify(vectors, null, 2));
console.log(`Wrote ${vectors.bhutani.length} Bhutani, ${vectors.aap.length} AAP, ${vectors.fenton.length} Fenton cases → ${outPath}`);

View file

@ -0,0 +1,48 @@
// ============================================================
// BILIRUBIN parity tests — drives the TS port against vectors
// captured from the authoritative vanilla implementation.
// Regenerate vectors with `node scripts/capture-calc-vectors.js`.
// ============================================================
import { describe, expect, it } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { classifyBhutani, classifyAapBili, type BiliRisk } from './bilirubin';
interface BhutaniCase {
name: string;
inputs: { hours: number; tsb: number };
output: { p95: number; p75: number; p40: number; zone: string };
}
interface AapCase {
name: string;
inputs: { gaWeeks: number; hours: number; tsb: number; risk: BiliRisk };
output: { photoThreshold: number; exchangeThreshold: number; status: string };
}
interface Vectors {
bhutani: BhutaniCase[];
aap: AapCase[];
}
const vectors: Vectors = JSON.parse(
fs.readFileSync(path.join(__dirname, '..', '..', 'e2e', 'fixtures', 'calc-vectors.json'), 'utf8'),
);
describe('Bhutani nomogram — parity with vanilla calculators.js', () => {
it.each(vectors.bhutani)('$name', ({ inputs, output }) => {
const actual = classifyBhutani(inputs.hours, inputs.tsb);
expect(actual.p95).toBeCloseTo(output.p95, 10);
expect(actual.p75).toBeCloseTo(output.p75, 10);
expect(actual.p40).toBeCloseTo(output.p40, 10);
expect(actual.zone).toBe(output.zone);
});
});
describe('AAP 2022 bilirubin — parity with vanilla calculators.js', () => {
it.each(vectors.aap)('$name', ({ inputs, output }) => {
const actual = classifyAapBili(inputs.gaWeeks, inputs.hours, inputs.tsb, inputs.risk);
expect(actual.photoThreshold).toBeCloseTo(output.photoThreshold, 10);
expect(actual.exchangeThreshold).toBeCloseTo(output.exchangeThreshold, 10);
expect(actual.status).toBe(output.status);
});
});

View file

@ -0,0 +1,129 @@
// ============================================================
// BILIRUBIN — Bhutani 1999 risk nomogram + AAP 2022 phototherapy
// and exchange-transfusion thresholds. Tables ported VERBATIM
// from public/js/calculators.js. Parity with the vanilla
// implementation is pinned by 70 vitest cases in bilirubin.test.ts
// driven by e2e/fixtures/calc-vectors.json (regenerate via
// `node scripts/capture-calc-vectors.js`).
//
// Clinical refs preserved in the vanilla source:
// • Bhutani VK et al., Pediatrics 1999;103(1):6-14 (Fig 2)
// • AAP 2022 CPG (Kemper et al.)
// ============================================================
export type BiliRisk = 'low' | 'medium';
export type BhutaniZone = 'Low-Risk' | 'Low-Intermediate' | 'High-Intermediate' | 'High-Risk';
export type AapBiliStatus = 'Below Phototherapy' | 'Above Phototherapy' | 'Above Exchange';
export interface BhutaniResult {
p95: number;
p75: number;
p40: number;
zone: BhutaniZone;
}
export interface AapBiliResult {
photoThreshold: number;
exchangeThreshold: number;
status: AapBiliStatus;
}
type HourTable = Record<number, number>;
// ── Bhutani 1999 zones (verbatim from calculators.js:1644-1651) ──
export const bhutaniZones: { p95: HourTable; p75: HourTable; p40: HourTable } = {
p95: { 6:6.0, 12:7.2, 18:8.5, 24:9.6, 30:11.2, 36:12.8, 42:13.8, 48:14.8,
54:15.6, 60:16.2, 66:16.8, 72:17.4, 84:18.0, 96:18.4, 108:18.8, 120:19.0 },
p75: { 6:4.5, 12:5.5, 18:6.6, 24:7.8, 30:9.2, 36:10.6, 42:11.6, 48:12.6,
54:13.4, 60:14.0, 66:14.6, 72:15.0, 84:15.4, 96:15.6, 108:15.8, 120:16.0 },
p40: { 6:3.0, 12:4.0, 18:5.0, 24:6.2, 30:7.2, 36:8.4, 42:9.2, 48:10.0,
54:10.6, 60:11.2, 66:11.8, 72:12.2, 84:12.6, 96:12.8, 108:13.0, 120:13.2 }
};
// ── AAP 2022 phototherapy + exchange tables (verbatim from calculators.js:1489-1512) ──
export const photoThresholds35: HourTable = {12:8.5,13:8.7,14:8.9,15:9.0,16:9.2,17:9.4,18:9.6,19:9.8,20:9.9,21:10.1,22:10.3,23:10.4,24:10.6,25:10.8,26:10.9,27:11.1,28:11.3,29:11.4,30:11.6,31:11.7,32:11.9,33:12.0,34:12.2,35:12.3,36:12.5,37:12.6,38:12.8,39:12.9,40:13.1,41:13.2,42:13.4,43:13.5,44:13.6,45:13.8,46:13.9,47:14.0,48:14.2,49:14.3,50:14.4,51:14.5,52:14.7,53:14.8,54:14.9,55:15.0,56:15.1,57:15.3,58:15.4,59:15.5,60:15.6,61:15.7,62:15.8,63:15.9,64:16.0,65:16.1,66:16.2,67:16.3,68:16.4,69:16.5,70:16.6,71:16.7,72:16.8,73:16.9,74:17.0,75:17.1,76:17.2,77:17.3,78:17.4,79:17.5,80:17.5,81:17.6,82:17.7,83:17.8,84:17.8,85:17.9,86:18.0,87:18.1,88:18.1,89:18.2,90:18.3,91:18.3,92:18.4,93:18.5,94:18.5,95:18.6,96:18.6};
export const photoThresholds36: HourTable = {12:9.0,13:9.2,14:9.4,15:9.6,16:9.8,17:9.9,18:10.1,19:10.3,20:10.5,21:10.6,22:10.8,23:11.0,24:11.2,25:11.3,26:11.5,27:11.7,28:11.8,29:12.0,30:12.1,31:12.3,32:12.5,33:12.6,34:12.8,35:12.9,36:13.1,37:13.2,38:13.4,39:13.5,40:13.7,41:13.8,42:13.9,43:14.1,44:14.2,45:14.4,46:14.5,47:14.6,48:14.8,49:14.9,50:15.0,51:15.1,52:15.3,53:15.4,54:15.5,55:15.6,56:15.8,57:15.9,58:16.0,59:16.1,60:16.2,61:16.3,62:16.5,63:16.6,64:16.7,65:16.8,66:16.9,67:17.0,68:17.1,69:17.2,70:17.3,71:17.4,72:17.5,73:17.6,74:17.7,75:17.8,76:17.9,77:17.9,78:18.0,79:18.1,80:18.2,81:18.3,82:18.4,83:18.4,84:18.5,85:18.6,86:18.7,87:18.8,88:18.8,89:18.9,90:19.0,91:19.0,92:19.1,93:19.2,94:19.2,95:19.3,96:19.3};
export const photoThresholds37: HourTable = {12:9.6,13:9.8,14:9.9,15:10.1,16:10.3,17:10.5,18:10.7,19:10.8,20:11.0,21:11.2,22:11.4,23:11.5,24:11.7,25:11.9,26:12.1,27:12.2,28:12.4,29:12.5,30:12.7,31:12.9,32:13.0,33:13.2,34:13.3,35:13.5,36:13.6,37:13.8,38:13.9,39:14.1,40:14.2,41:14.4,42:14.5,43:14.7,44:14.8,45:15.0,46:15.1,47:15.2,48:15.4,49:15.5,50:15.6,51:15.8,52:15.9,53:16.0,54:16.1,55:16.3,56:16.4,57:16.5,58:16.6,59:16.7,60:16.9,61:17.0,62:17.1,63:17.2,64:17.3,65:17.4,66:17.5,67:17.6,68:17.7,69:17.8,70:17.9,71:18.0,72:18.1,73:18.2,74:18.3,75:18.4,76:18.5,77:18.6,78:18.7,79:18.8,80:18.9,81:19.0,82:19.0,83:19.1,84:19.2,85:19.3,86:19.4,87:19.4,88:19.5,89:19.6,90:19.7,91:19.7,92:19.8,93:19.9,94:19.9,95:20.0,96:20.0};
export const photoThresholds38: HourTable = {12:10.1,13:10.3,14:10.5,15:10.7,16:10.8,17:11.0,18:11.2,19:11.4,20:11.6,21:11.7,22:11.9,23:12.1,24:12.3,25:12.4,26:12.6,27:12.8,28:12.9,29:13.1,30:13.3,31:13.4,32:13.6,33:13.8,34:13.9,35:14.1,36:14.2,37:14.4,38:14.5,39:14.7,40:14.8,41:15.0,42:15.1,43:15.3,44:15.4,45:15.6,46:15.7,47:15.8,48:16.0,49:16.1,50:16.2,51:16.4,52:16.5,53:16.6,54:16.8,55:16.9,56:17.0,57:17.1,58:17.3,59:17.4,60:17.5,61:17.6,62:17.7,63:17.8,64:17.9,65:18.1,66:18.2,67:18.3,68:18.4,69:18.5,70:18.6,71:18.7,72:18.8,73:18.9,74:19.0,75:19.1,76:19.2,77:19.3,78:19.4,79:19.5,80:19.5,81:19.6,82:19.7,83:19.8,84:19.9,85:20.0,86:20.0,87:20.1,88:20.2,89:20.3,90:20.3,91:20.4,92:20.5,93:20.6,94:20.6,95:20.7,96:20.7};
export const photoThresholds39: HourTable = {12:10.6,13:10.8,14:11.0,15:11.2,16:11.4,17:11.6,18:11.8,19:11.9,20:12.1,21:12.3,22:12.5,23:12.7,24:12.8,25:13.0,26:13.2,27:13.3,28:13.5,29:13.7,30:13.8,31:14.0,32:14.2,33:14.3,34:14.5,35:14.7,36:14.8,37:15.0,38:15.1,39:15.3,40:15.4,41:15.6,42:15.7,43:15.9,44:16.0,45:16.2,46:16.3,47:16.4,48:16.6,49:16.7,50:16.8,51:17.0,52:17.1,53:17.2,54:17.4,55:17.5,56:17.6,57:17.8,58:17.9,59:18.0,60:18.1,61:18.2,62:18.4,63:18.5,64:18.6,65:18.7,66:18.8,67:18.9,68:19.0,69:19.1,70:19.2,71:19.3,72:19.5,73:19.6,74:19.7,75:19.7,76:19.8,77:19.9,78:20.0,79:20.1,80:20.2,81:20.3,82:20.4,83:20.5,84:20.6,85:20.6,86:20.7,87:20.8,88:20.9,89:21.0,90:21.0,91:21.1,92:21.2,93:21.3,94:21.3,95:21.4,96:21.5};
export const photoThresholds40: HourTable = {12:11.2,13:11.3,14:11.5,15:11.7,16:11.9,17:12.1,18:12.3,19:12.5,20:12.7,21:12.8,22:13.0,23:13.2,24:13.4,25:13.6,26:13.7,27:13.9,28:14.1,29:14.2,30:14.4,31:14.6,32:14.7,33:14.9,34:15.1,35:15.2,36:15.4,37:15.6,38:15.7,39:15.9,40:16.0,41:16.2,42:16.3,43:16.5,44:16.6,45:16.8,46:16.9,47:17.1,48:17.2,49:17.4,50:17.5,51:17.6,52:17.8,53:17.9,54:18.1,55:18.2,56:18.3,57:18.5,58:18.6,59:18.7,60:18.9,61:19.0,62:19.1,63:19.3,64:19.4,65:19.5,66:19.6,67:19.7,68:19.9,69:20.0,70:20.1,71:20.2,72:20.3,73:20.4,74:20.5,75:20.6,76:20.7,77:20.8,78:20.9,79:21.0,80:21.1,81:21.2,82:21.3,83:21.4,84:21.5,85:21.5,86:21.6,87:21.7,88:21.8,89:21.9,90:21.9,91:22.0,92:22.1,93:22.2,94:22.2,95:22.3,96:22.3};
export const photoThresholds35risk: HourTable = {12:6.9,13:7.1,14:7.2,15:7.4,16:7.6,17:7.7,18:7.9,19:8.1,20:8.2,21:8.4,22:8.6,23:8.7,24:8.9,25:9.0,26:9.2,27:9.3,28:9.5,29:9.6,30:9.8,31:9.9,32:10.1,33:10.2,34:10.3,35:10.5,36:10.6,37:10.8,38:10.9,39:11.0,40:11.2,41:11.3,42:11.4,43:11.5,44:11.7,45:11.8,46:11.9,47:12.0,48:12.2,49:12.3,50:12.4,51:12.5,52:12.6,53:12.7,54:12.8,55:13.0,56:13.1,57:13.2,58:13.3,59:13.4,60:13.5,61:13.6,62:13.7,63:13.8,64:13.9,65:14.0,66:14.1,67:14.2,68:14.2,69:14.3,70:14.4,71:14.5,72:14.6,73:14.7,74:14.8,75:14.8,76:14.9,77:15.0,78:15.1,79:15.1,80:15.2,81:15.3,82:15.3,83:15.4,84:15.5,85:15.5,86:15.6,87:15.7,88:15.7,89:15.8,90:15.8,91:15.9,92:15.9,93:16.0,94:16.1,95:16.1,96:16.1};
export const photoThresholds36risk: HourTable = {12:7.4,13:7.6,14:7.8,15:8.0,16:8.1,17:8.3,18:8.5,19:8.6,20:8.8,21:9.0,22:9.1,23:9.3,24:9.4,25:9.6,26:9.8,27:9.9,28:10.1,29:10.2,30:10.4,31:10.5,32:10.7,33:10.8,34:11.0,35:11.1,36:11.2,37:11.4,38:11.5,39:11.7,40:11.8,41:11.9,42:12.1,43:12.2,44:12.3,45:12.5,46:12.6,47:12.7,48:12.8,49:13.0,50:13.1,51:13.2,52:13.3,53:13.4,54:13.5,55:13.7,56:13.8,57:13.9,58:14.0,59:14.1,60:14.2,61:14.3,62:14.4,63:14.5,64:14.6,65:14.7,66:14.8,67:14.9,68:15.0,69:15.1,70:15.2,71:15.3,72:15.4,73:15.4,74:15.5,75:15.6,76:15.7,77:15.8,78:15.8,79:15.9,80:16.0,81:16.1,82:16.1,83:16.2,84:16.3,85:16.4,86:16.4,87:16.5,88:16.6,89:16.6,90:16.7,91:16.7,92:16.8,93:16.8,94:16.9,95:17.0,96:17.0};
export const photoThresholds37risk: HourTable = {12:8.0,13:8.1,14:8.3,15:8.5,16:8.7,17:8.9,18:9.0,19:9.2,20:9.4,21:9.5,22:9.7,23:9.9,24:10.0,25:10.2,26:10.4,27:10.5,28:10.7,29:10.8,30:11.0,31:11.1,32:11.3,33:11.4,34:11.6,35:11.7,36:11.9,37:12.0,38:12.2,39:12.3,40:12.4,41:12.6,42:12.7,43:12.9,44:13.0,45:13.1,46:13.2,47:13.4,48:13.5,49:13.6,50:13.8,51:13.9,52:14.0,53:14.1,54:14.2,55:14.4,56:14.5,57:14.6,58:14.7,59:14.8,60:14.9,61:15.0,62:15.1,63:15.2,64:15.3,65:15.4,66:15.5,67:15.6,68:15.7,69:15.8,70:15.9,71:16.0,72:16.1,73:16.2,74:16.3,75:16.4,76:16.5,77:16.6,78:16.6,79:16.7,80:16.8,81:16.9,82:17.0,83:17.0,84:17.1,85:17.2,86:17.2,87:17.3,88:17.4,89:17.4,90:17.5,91:17.6,92:17.6,93:17.7,94:17.8,95:17.8,96:17.9};
export const photoThresholds38risk: HourTable = {12:8.5,13:8.6,14:8.8,15:9.0,16:9.2,17:9.4,18:9.5,19:9.7,20:9.9,21:10.0,22:10.2,23:10.4,24:10.5,25:10.7,26:10.8,27:11.0,28:11.2,29:11.3,30:11.5,31:11.6,32:11.8,33:11.9,34:12.1,35:12.2,36:12.4,37:12.5,38:12.7,39:12.8,40:12.9,41:13.1,42:13.2,43:13.3,44:13.5,45:13.6,46:13.7,47:13.9,48:14.0,49:14.1,50:14.2,51:14.4,52:14.5,53:14.6,54:14.7,55:14.8,56:14.9,57:15.1,58:15.2,59:15.3,60:15.4,61:15.5,62:15.6,63:15.7,64:15.8,65:15.9,66:16.0,67:16.1,68:16.2,69:16.3,70:16.4,71:16.5,72:16.6,73:16.6,74:16.7,75:16.8,76:16.9,77:17.0,78:17.1,79:17.1,80:17.2,81:17.3,82:17.4,83:17.4,84:17.5,85:17.6,86:17.6,87:17.7,88:17.8,89:17.8,90:17.9,91:18.0,92:18.0,93:18.1,94:18.1,95:18.2,96:18.2};
export const exchangeThresholds35: HourTable = {12:16.4,13:16.5,14:16.6,15:16.8,16:16.9,17:17.0,18:17.2,19:17.3,20:17.4,21:17.5,22:17.7,23:17.8,24:17.9,25:18.0,26:18.2,27:18.3,28:18.4,29:18.5,30:18.7,31:18.8,32:18.9,33:19.0,34:19.1,35:19.2,36:19.4,37:19.5,38:19.6,39:19.7,40:19.8,41:19.9,42:20.0,43:20.1,44:20.2,45:20.3,46:20.5,47:20.6,48:20.7,49:20.8,50:20.9,51:21.0,52:21.1,53:21.2,54:21.3,55:21.4,56:21.5,57:21.6,58:21.7,59:21.7,60:21.8,61:21.9,62:22.0,63:22.1,64:22.2,65:22.3,66:22.4,67:22.5,68:22.6,69:22.6,70:22.7,71:22.8,72:22.9,73:23.0,74:23.1,75:23.1,76:23.2,77:23.3,78:23.4,79:23.4,80:23.5,81:23.6,82:23.7,83:23.7,84:23.8,85:23.9,86:23.9,87:24.0,88:24.1,89:24.1,90:24.2,91:24.3,92:24.3,93:24.4,94:24.4,95:24.5,96:24.5};
export const exchangeThresholds36: HourTable = {12:17.5,13:17.7,14:17.8,15:17.9,16:18.1,17:18.2,18:18.3,19:18.5,20:18.6,21:18.7,22:18.9,23:19.0,24:19.1,25:19.2,26:19.4,27:19.5,28:19.6,29:19.7,30:19.9,31:20.0,32:20.1,33:20.2,34:20.4,35:20.5,36:20.6,37:20.7,38:20.8,39:20.9,40:21.0,41:21.2,42:21.3,43:21.4,44:21.5,45:21.6,46:21.7,47:21.8,48:21.9,49:22.0,50:22.1,51:22.2,52:22.3,53:22.4,54:22.5,55:22.6,56:22.7,57:22.8,58:22.9,59:23.0,60:23.1,61:23.2,62:23.2,63:23.3,64:23.4,65:23.5,66:23.6,67:23.7,68:23.8,69:23.8,70:23.9,71:24.0,72:24.1,73:24.1,74:24.2,75:24.3,76:24.4,77:24.4,78:24.5,79:24.6,80:24.6,81:24.7,82:24.8,83:24.8,84:24.9,85:25.0,86:25.0,87:25.1,88:25.2,89:25.2,90:25.3,91:25.3,92:25.4,93:25.4,94:25.5,95:25.5,96:25.5};
export const exchangeThresholds37: HourTable = {12:18.7,13:18.8,14:18.9,15:19.1,16:19.2,17:19.4,18:19.5,19:19.6,20:19.8,21:19.9,22:20.1,23:20.2,24:20.3,25:20.5,26:20.6,27:20.7,28:20.8,29:21.0,30:21.1,31:21.2,32:21.3,33:21.5,34:21.6,35:21.7,36:21.8,37:21.9,38:22.1,39:22.2,40:22.3,41:22.4,42:22.5,43:22.6,44:22.7,45:22.8,46:22.9,47:23.0,48:23.1,49:23.2,50:23.3,51:23.4,52:23.5,53:23.6,54:23.7,55:23.8,56:23.9,57:24.0,58:24.1,59:24.2,60:24.3,61:24.4,62:24.5,63:24.5,64:24.6,65:24.7,66:24.8,67:24.9,68:24.9,69:25.0,70:25.1,71:25.2,72:25.2,73:25.3,74:25.4,75:25.5,76:25.5,77:25.6,78:25.7,79:25.7,80:25.8,81:25.8,82:25.9,83:26.0,84:26.0,85:26.1,86:26.1,87:26.2,88:26.2,89:26.3,90:26.3,91:26.4,92:26.4,93:26.5,94:26.5,95:26.5,96:26.6};
export const exchangeThresholds38: HourTable = {12:19.7,13:19.9,14:20.0,15:20.1,16:20.3,17:20.4,18:20.6,19:20.7,20:20.8,21:21.0,22:21.1,23:21.2,24:21.4,25:21.5,26:21.6,27:21.7,28:21.9,29:22.0,30:22.1,31:22.2,32:22.3,33:22.4,34:22.6,35:22.7,36:22.8,37:22.9,38:23.0,39:23.1,40:23.2,41:23.3,42:23.4,43:23.5,44:23.6,45:23.7,46:23.8,47:23.9,48:24.0,49:24.1,50:24.2,51:24.3,52:24.4,53:24.5,54:24.6,55:24.7,56:24.7,57:24.8,58:24.9,59:25.0,60:25.1,61:25.2,62:25.2,63:25.3,64:25.4,65:25.5,66:25.5,67:25.6,68:25.7,69:25.7,70:25.8,71:25.9,72:25.9,73:26.0,74:26.0,75:26.1,76:26.2,77:26.2,78:26.3,79:26.3,80:26.4,81:26.4,82:26.5,83:26.5,84:26.6,85:26.6,86:26.7,87:26.7,88:26.7,89:26.8,90:26.8,91:26.9,92:26.9,93:26.9,94:27.0,95:27.0,96:27.0};
export const exchangeThresholds35risk: HourTable = {12:14.6,13:14.8,14:14.9,15:15.0,16:15.1,17:15.3,18:15.4,19:15.5,20:15.6,21:15.8,22:15.9,23:16.0,24:16.1,25:16.2,26:16.3,27:16.4,28:16.5,29:16.6,30:16.8,31:16.9,32:17.0,33:17.1,34:17.2,35:17.3,36:17.4,37:17.5,38:17.6,39:17.7,40:17.7,41:17.8,42:17.9,43:18.0,44:18.1,45:18.2,46:18.3,47:18.4,48:18.5,49:18.5,50:18.6,51:18.7,52:18.8,53:18.9,54:18.9,55:19.0,56:19.1,57:19.2,58:19.2,59:19.3,60:19.4,61:19.4,62:19.5,63:19.6,64:19.6,65:19.7,66:19.8,67:19.8,68:19.9,69:19.9,70:20.0,71:20.1,72:20.1,73:20.2,74:20.2,75:20.3,76:20.3,77:20.4,78:20.4,79:20.5,80:20.5,81:20.6,82:20.6,83:20.6,84:20.7,85:20.7,86:20.8,87:20.8,88:20.8,89:20.9,90:20.9,91:20.9,92:21.0,93:21.0,94:21.0,95:21.1,96:21.1};
export const exchangeThresholds36risk: HourTable = {12:15.2,13:15.3,14:15.4,15:15.6,16:15.7,17:15.8,18:15.9,19:16.1,20:16.2,21:16.3,22:16.4,23:16.5,24:16.6,25:16.8,26:16.9,27:17.0,28:17.1,29:17.2,30:17.3,31:17.4,32:17.5,33:17.6,34:17.7,35:17.8,36:17.9,37:18.0,38:18.1,39:18.2,40:18.3,41:18.4,42:18.5,43:18.6,44:18.7,45:18.8,46:18.9,47:19.0,48:19.1,49:19.2,50:19.2,51:19.3,52:19.4,53:19.5,54:19.6,55:19.7,56:19.7,57:19.8,58:19.9,59:20.0,60:20.1,61:20.1,62:20.2,63:20.3,64:20.3,65:20.4,66:20.5,67:20.6,68:20.6,69:20.7,70:20.8,71:20.8,72:20.9,73:20.9,74:21.0,75:21.1,76:21.1,77:21.2,78:21.2,79:21.3,80:21.4,81:21.4,82:21.5,83:21.5,84:21.6,85:21.6,86:21.7,87:21.7,88:21.8,89:21.8,90:21.9,91:21.9,92:22.0,93:22.0,94:22.0,95:22.1,96:22.1};
export const exchangeThresholds37risk: HourTable = {12:15.7,13:15.9,14:16.0,15:16.1,16:16.2,17:16.4,18:16.5,19:16.6,20:16.7,21:16.8,22:17.0,23:17.1,24:17.2,25:17.3,26:17.4,27:17.5,28:17.7,29:17.8,30:17.9,31:18.0,32:18.1,33:18.2,34:18.3,35:18.4,36:18.5,37:18.6,38:18.7,39:18.8,40:18.9,41:19.0,42:19.1,43:19.2,44:19.3,45:19.4,46:19.5,47:19.6,48:19.7,49:19.8,50:19.9,51:20.0,52:20.1,53:20.1,54:20.2,55:20.3,56:20.4,57:20.5,58:20.6,59:20.7,60:20.7,61:20.8,62:20.9,63:21.0,64:21.1,65:21.1,66:21.2,67:21.3,68:21.4,69:21.4,70:21.5,71:21.6,72:21.7,73:21.7,74:21.8,75:21.9,76:21.9,77:22.0,78:22.1,79:22.1,80:22.2,81:22.3,82:22.3,83:22.4,84:22.5,85:22.5,86:22.6,87:22.6,88:22.7,89:22.8,90:22.8,91:22.9,92:22.9,93:23.0,94:23.0,95:23.1,96:23.1};
export const exchangeThresholds38risk: HourTable = {12:16.3,13:16.4,14:16.5,15:16.6,16:16.7,17:16.9,18:17.0,19:17.1,20:17.2,21:17.3,22:17.4,23:17.6,24:17.7,25:17.8,26:17.9,27:18.0,28:18.1,29:18.2,30:18.3,31:18.4,32:18.5,33:18.7,34:18.8,35:18.9,36:19.0,37:19.1,38:19.2,39:19.3,40:19.4,41:19.5,42:19.6,43:19.7,44:19.8,45:19.9,46:19.9,47:20.0,48:20.1,49:20.2,50:20.3,51:20.4,52:20.5,53:20.6,54:20.7,55:20.8,56:20.8,57:20.9,58:21.0,59:21.1,60:21.2,61:21.3,62:21.3,63:21.4,64:21.5,65:21.6,66:21.7,67:21.7,68:21.8,69:21.9,70:22.0,71:22.0,72:22.1,73:22.2,74:22.2,75:22.3,76:22.4,77:22.5,78:22.5,79:22.6,80:22.7,81:22.7,82:22.8,83:22.8,84:22.9,85:23.0,86:23.0,87:23.1,88:23.1,89:23.2,90:23.3,91:23.3,92:23.4,93:23.4,94:23.5,95:23.5,96:23.5};
// Linear interpolation across hour keys. Identical to the vanilla
// interpolateThreshold in calculators.js:1514-1525.
export function interpolateThreshold(table: HourTable, hours: number): number {
const keys = Object.keys(table).map(Number).sort((a, b) => a - b);
if (hours <= keys[0]) return table[keys[0]];
if (hours >= keys[keys.length - 1]) return table[keys[keys.length - 1]];
for (let i = 0; i < keys.length - 1; i++) {
if (hours >= keys[i] && hours <= keys[i + 1]) {
const t = (hours - keys[i]) / (keys[i + 1] - keys[i]);
return table[keys[i]] + t * (table[keys[i + 1]] - table[keys[i]]);
}
}
return table[keys[0]];
}
export function classifyBhutani(hours: number, tsb: number): BhutaniResult {
const p95 = interpolateThreshold(bhutaniZones.p95, hours);
const p75 = interpolateThreshold(bhutaniZones.p75, hours);
const p40 = interpolateThreshold(bhutaniZones.p40, hours);
let zone: BhutaniZone;
if (tsb >= p95) zone = 'High-Risk';
else if (tsb >= p75) zone = 'High-Intermediate';
else if (tsb >= p40) zone = 'Low-Intermediate';
else zone = 'Low-Risk';
return { p95, p75, p40, zone };
}
export function aapPhotoTable(gaWeeks: number, risk: BiliRisk): HourTable {
if (risk === 'medium') {
if (gaWeeks === 35) return photoThresholds35risk;
if (gaWeeks === 36) return photoThresholds36risk;
if (gaWeeks === 37) return photoThresholds37risk;
return photoThresholds38risk; // 38+
}
if (gaWeeks === 35) return photoThresholds35;
if (gaWeeks === 36) return photoThresholds36;
if (gaWeeks === 37) return photoThresholds37;
if (gaWeeks === 38) return photoThresholds38;
if (gaWeeks === 39) return photoThresholds39;
return photoThresholds40; // 40+
}
export function aapExchangeTable(gaWeeks: number, risk: BiliRisk): HourTable {
if (risk === 'medium') {
if (gaWeeks === 35) return exchangeThresholds35risk;
if (gaWeeks === 36) return exchangeThresholds36risk;
if (gaWeeks === 37) return exchangeThresholds37risk;
return exchangeThresholds38risk; // 38+
}
if (gaWeeks === 35) return exchangeThresholds35;
if (gaWeeks === 36) return exchangeThresholds36;
if (gaWeeks === 37) return exchangeThresholds37;
return exchangeThresholds38; // 38+
}
export function classifyAapBili(gaWeeks: number, hours: number, tsb: number, risk: BiliRisk): AapBiliResult {
const photoThreshold = interpolateThreshold(aapPhotoTable(gaWeeks, risk), hours);
const exchangeThreshold = interpolateThreshold(aapExchangeTable(gaWeeks, risk), hours);
let status: AapBiliStatus;
if (tsb >= exchangeThreshold) status = 'Above Exchange';
else if (tsb >= photoThreshold) status = 'Above Phototherapy';
else status = 'Below Phototherapy';
return { photoThreshold, exchangeThreshold, status };
}

View file

@ -0,0 +1,31 @@
// ============================================================
// FENTON 2013 parity tests — drives the TS port against vectors
// captured from the authoritative vanilla implementation.
// ============================================================
import { describe, expect, it } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fentonWeightForAge, type Sex } from './fenton';
interface FentonCase {
name: string;
inputs: { sex: Sex; gaWeeks: number; weightGrams: number };
output: { L: number; M: number; S: number; z: number; percentile: number };
}
interface Vectors { fenton: FentonCase[] }
const vectors: Vectors = JSON.parse(
fs.readFileSync(path.join(__dirname, '..', '..', 'e2e', 'fixtures', 'calc-vectors.json'), 'utf8'),
);
describe('Fenton 2013 weight-for-GA — parity with vanilla calculators.js', () => {
it.each(vectors.fenton)('$name', ({ inputs, output }) => {
const actual = fentonWeightForAge(inputs.gaWeeks, inputs.weightGrams, inputs.sex);
expect(actual.L).toBeCloseTo(output.L, 10);
expect(actual.M).toBeCloseTo(output.M, 6);
expect(actual.S).toBeCloseTo(output.S, 10);
expect(actual.z).toBeCloseTo(output.z, 10);
expect(actual.percentile).toBeCloseTo(output.percentile, 10);
});
});

93
shared/clinical/fenton.ts Normal file
View file

@ -0,0 +1,93 @@
// ============================================================
// 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';
}