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:
parent
2a95bcc961
commit
ff4b3de988
12 changed files with 1961 additions and 7 deletions
|
|
@ -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 0–2 y / CDC 2–20 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} />;
|
||||
}
|
||||
|
||||
|
|
|
|||
1173
e2e/fixtures/calc-vectors.json
Normal file
1173
e2e/fixtures/calc-vectors.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
2
public/app/assets/index-DrRLZ8fC.css
Normal file
2
public/app/assets/index-DrRLZ8fC.css
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||
|
|
|
|||
266
scripts/capture-calc-vectors.js
Normal file
266
scripts/capture-calc-vectors.js
Normal 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}`);
|
||||
48
shared/clinical/bilirubin.test.ts
Normal file
48
shared/clinical/bilirubin.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
129
shared/clinical/bilirubin.ts
Normal file
129
shared/clinical/bilirubin.ts
Normal 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 };
|
||||
}
|
||||
31
shared/clinical/fenton.test.ts
Normal file
31
shared/clinical/fenton.test.ts
Normal 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
93
shared/clinical/fenton.ts
Normal 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';
|
||||
}
|
||||
Loading…
Reference in a new issue