pediatric-ai-scribe-v3/scripts/capture-calc-vectors.js
Daniel 21facd4e1b feat(client): port BP Percentile — all 10 Calculator pills now run in React
Closes the Calculators migration. AAP 2017 BP percentile (Rosner
quantile splines) was the biggest table-driven calculator in the
codebase: 6 height-LMS arrays × 218 entries + 4 spline coefficient
matrices × 99 rows × 13 terms = ~3,500 numeric constants. Every one
ported verbatim. 14 parity tests prove the TS port returns identical
percentile and classification outputs to the vanilla calculators.js.

shared/clinical/bp.ts — 571 lines
  Generated from public/js/calculators.js via awk-extracted lines
  89-94 (LMS) and 97-503 (coefficients), wrapped in TS export
  declarations. No rewriting, no reformatting, no reordering — the
  data bytes are identical to the vanilla source.
  Math (calcHeightPercentile, computeBpPercentile,
  classifyBpFromPercentiles) ported verbatim from calculators.js:
  505-608. Exports a top-level computeBp() helper returning
  { sysPercentile, diaPercentile, heightPercentile, sysClass,
  diaClass, classification }.

scripts/capture-calc-vectors.js — BP cases added
  Uses new Function() to evaluate the raw LMS + coefficient blocks
  from calculators.js directly, then runs the vanilla math against
  14 carefully chosen test cases:
    • Typical pediatric ages (3, 5, 8, 10, 12 years, both sexes)
    • Adult-threshold cross-over (age 13 — uses absolute mmHg cutoffs)
    • Stage 1 / Stage 2 hypertension boundaries
    • Edge-of-domain (age 1, age 17)
    • Tall / short height-percentile outliers
  Fixture regenerated to 14 Bhutani + 58 AAP + 13 Fenton + 8 neonatal
  + 12 BMI + 14 BP = 117 total vectors.

shared/clinical/bp.test.ts — exact-match parity
  sysPercentile / diaPercentile are integer selections from 99
  candidate predicted values, so tests use .toBe() for exact match.
  heightPercentile uses toBeCloseTo(6) (double-precision float).
  Classification strings must match exactly. 14/14 pass.

client/src/pages/CalculatorPanels.tsx — BpPanel added
  Age/sex/height/SBP/DBP inputs, validation (age 1-17, height 50-200
  cm), color-coded overall classification + per-measurement
  percentile and tier (Normal / Elevated / Stage 1 / Stage 2) in a
  4-column result grid with the height percentile for context.

client/src/pages/Calculators.tsx
  Dispatch wires bp → BpPanel. PILLS['bp'].ported = true.
  LegacyPanel is now dead code — every pill has a real implementation.

Final test suite: 5 files, 136 tests green
  (19 calculators + 70 bilirubin + 21 fenton/neonatal + 12 BMI + 14 BP)
2026-04-24 02:15:17 +02:00

540 lines
43 KiB
JavaScript

#!/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};
// ── Peditools-derived Fenton LMS (verbatim from bedside/neonatal.js:20-37) ──
const fentonLmsPeditools = {
male: {
22:{L:0.5885,M:496,S:0.12802},23:{L:0.7565,M:571,S:0.14547},24:{L:0.9128,M:651,S:0.16235},25:{L:1.0544,M:741,S:0.17765},
26:{L:1.1862,M:841,S:0.19029},27:{L:1.3051,M:953,S:0.19989},28:{L:1.3699,M:1079,S:0.20777},29:{L:1.4165,M:1223,S:0.21163},
30:{L:1.4172,M:1388,S:0.21185},31:{L:1.3755,M:1578,S:0.20785},32:{L:1.2952,M:1790,S:0.20112},33:{L:1.1974,M:2018,S:0.19143},
34:{L:1.0743,M:2255,S:0.18119},35:{L:0.9583,M:2493,S:0.16992},36:{L:0.8460,M:2726,S:0.16001},37:{L:0.7543,M:2947,S:0.15072},
38:{L:0.6650,M:3156,S:0.14304},39:{L:0.5881,M:3360,S:0.13641},40:{L:0.5237,M:3568,S:0.13173},41:{L:0.4691,M:3785,S:0.12863},
42:{L:0.4216,M:4014,S:0.12735}
},
female: {
22:{L:-0.0868,M:481,S:0.13605},23:{L:0.2119,M:537,S:0.14635},24:{L:0.5281,M:606,S:0.16134},25:{L:0.8258,M:694,S:0.18077},
26:{L:1.0501,M:792,S:0.19889},27:{L:1.2084,M:899,S:0.21323},28:{L:1.2599,M:1017,S:0.22437},29:{L:1.2539,M:1152,S:0.22982},
30:{L:1.2262,M:1306,S:0.23082},31:{L:1.1223,M:1482,S:0.22733},32:{L:1.0122,M:1681,S:0.21846},33:{L:0.8746,M:1897,S:0.20681},
34:{L:0.7299,M:2126,S:0.19407},35:{L:0.5929,M:2362,S:0.18059},36:{L:0.4534,M:2602,S:0.17028},37:{L:0.3462,M:2835,S:0.16139},
38:{L:0.2636,M:3050,S:0.15513},39:{L:0.2069,M:3239,S:0.15004},40:{L:0.1670,M:3415,S:0.14649},41:{L:0.1517,M:3596,S:0.14359},
42:{L:0.1308,M:3787,S:0.14127}
}
};
// ── 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);
}
// Neonatal variant uses |L|<0.001 threshold; verbatim from
// bedside/neonatal.js:53-56.
function calcZNeonatal(value, L, M, S) {
if (Math.abs(L) < 0.001) return Math.log(value / M) / S;
return (Math.pow(value / M, L) - 1) / (L * S);
}
// Abramowitz & Stegun erf form, verbatim from bedside/neonatal.js:58-66.
function zToPercentileNeonatal(z) {
const a1 = 0.254829592, a2 = -0.284496736, a3 = 1.421413741;
const a4 = -1.453152027, a5 = 1.061405429, p = 0.3275911;
const sign = z < 0 ? -1 : 1;
const x = Math.abs(z) / Math.sqrt(2);
const t = 1 / (1 + p * x);
const y = 1 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * Math.exp(-x * x);
return Math.round(((1 + sign * y) / 2) * 1000) / 10;
}
function neonatalAssess(weeks, days, weightGrams, sex) {
const gaDecimal = weeks + days / 7;
const lms = interpolateLMS(fentonLmsPeditools[sex], gaDecimal);
const z = calcZNeonatal(weightGrams, lms.L, lms.M, lms.S);
const percentile = zToPercentileNeonatal(z);
return { gaDecimal, L: lms.L, M: lms.M, S: lms.S, z, percentile, expectedWeight: Math.round(lms.M) };
}
// ── CDC 2000 BMI LMS (verbatim from calculators.js:739) ──
const bmiLMS = {male:{24:{L:-1.982374,M:16.5478,S:0.080127},30:{L:-1.642107,M:16.2497,S:0.075499},36:{L:-1.419991,M:16.0003,S:0.072634},42:{L:-1.438165,M:15.7941,S:0.071495},48:{L:-1.714869,M:15.6282,S:0.071889},54:{L:-2.155348,M:15.5026,S:0.073491},60:{L:-2.615166,M:15.4191,S:0.075992},66:{L:-2.981797,M:15.3795,S:0.079211},72:{L:-3.211705,M:15.3835,S:0.083048},78:{L:-3.314769,M:15.429,S:0.0874},84:{L:-3.323189,M:15.5129,S:0.092131},90:{L:-3.270455,M:15.6317,S:0.097082},96:{L:-3.183058,M:15.7823,S:0.102091},102:{L:-3.079383,M:15.9617,S:0.107013},108:{L:-2.971148,M:16.1671,S:0.111721},114:{L:-2.865311,M:16.3961,S:0.116113},120:{L:-2.765648,M:16.6461,S:0.120112},126:{L:-2.673903,M:16.9151,S:0.123664},132:{L:-2.59056,M:17.2009,S:0.126735},138:{L:-2.51532,M:17.5014,S:0.129309},144:{L:-2.447426,M:17.8146,S:0.131389},150:{L:-2.385858,M:18.1387,S:0.132991},156:{L:-2.329457,M:18.4718,S:0.134141},162:{L:-2.277017,M:18.812,S:0.13488},168:{L:-2.227362,M:19.1576,S:0.135251},174:{L:-2.179426,M:19.5067,S:0.135309},180:{L:-2.132345,M:19.8577,S:0.13511},186:{L:-2.085574,M:20.2086,S:0.134718},192:{L:-2.039015,M:20.5576,S:0.134198},198:{L:-1.99315,M:20.9029,S:0.13362},204:{L:-1.949135,M:21.2425,S:0.133057},210:{L:-1.908831,M:21.5742,S:0.132585},216:{L:-1.87467,M:21.8959,S:0.132286},222:{L:-1.849323,M:22.2054,S:0.132249},228:{L:-1.835138,M:22.5007,S:0.132566},234:{L:-1.833401,M:22.7799,S:0.133339},240:{L:-1.843581,M:23.0414,S:0.134675}},female:{24:{L:-1.024497,M:16.388,S:0.085026},30:{L:-1.534542,M:16.0059,S:0.080932},36:{L:-2.096829,M:15.6992,S:0.078605},42:{L:-2.618733,M:15.4647,S:0.077904},48:{L:-3.018522,M:15.2985,S:0.078713},54:{L:-3.2593,M:15.1961,S:0.080904},60:{L:-3.350078,M:15.1519,S:0.0843},66:{L:-3.325522,M:15.1606,S:0.08868},72:{L:-3.225607,M:15.2169,S:0.093803},78:{L:-3.084291,M:15.3161,S:0.099427},84:{L:-2.926187,M:15.4536,S:0.105325},90:{L:-2.76731,M:15.6252,S:0.111295},96:{L:-2.617192,M:15.827,S:0.117159},102:{L:-2.480952,M:16.0552,S:0.122771},108:{L:-2.360921,M:16.3061,S:0.128014},114:{L:-2.257782,M:16.5763,S:0.132797},120:{L:-2.171296,M:16.8623,S:0.137057},126:{L:-2.100749,M:17.161,S:0.140754},132:{L:-2.045235,M:17.4691,S:0.143868},138:{L:-2.003802,M:17.7836,S:0.146399},144:{L:-1.975521,M:18.1015,S:0.148361},150:{L:-1.95952,M:18.42,S:0.149783},156:{L:-1.954978,M:18.7364,S:0.150705},162:{L:-1.9611,M:19.0481,S:0.151176},168:{L:-1.977074,M:19.3526,S:0.151256},174:{L:-2.002014,M:19.6475,S:0.15101},180:{L:-2.034893,M:19.9306,S:0.150512},186:{L:-2.07446,M:20.1998,S:0.149843},192:{L:-2.119157,M:20.4533,S:0.14909},198:{L:-2.167045,M:20.6891,S:0.148349},204:{L:-2.215738,M:20.9058,S:0.147723},210:{L:-2.262382,M:21.1016,S:0.147323},216:{L:-2.303688,M:21.2753,S:0.147269},222:{L:-2.336038,M:21.4255,S:0.147689},228:{L:-2.355678,M:21.5508,S:0.148724},234:{L:-2.35898,M:21.6501,S:0.150521},240:{L:-2.342797,M:21.7219,S:0.153241}}};
function calcBmiPercentile(bmi, L, M, S) {
let z;
if (Math.abs(L) < 0.001) z = Math.log(bmi / M) / S;
else z = (Math.pow(bmi / M, L) - 1) / (L * S);
const a1 = 0.254829592, a2 = -0.284496736, a3 = 1.421413741;
const a4 = -1.453152027, a5 = 1.061405429, p = 0.3275911;
const sign = z < 0 ? -1 : 1;
const x = Math.abs(z) / Math.sqrt(2);
const t = 1 / (1 + p * x);
const y = 1 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * Math.exp(-x * x);
const prob = 0.5 * (1 + sign * y);
return { z, percentile: Math.round(prob * 10000) / 100 };
}
function classifyBMI(percentile, bmi, lms) {
const p95z = 1.645;
const bmi95 = Math.abs(lms.L) < 0.001
? lms.M * Math.exp(lms.S * p95z)
: lms.M * Math.pow(1 + lms.L * lms.S * p95z, 1 / lms.L);
const pctOf95 = (bmi / bmi95) * 100;
if (percentile >= 95) {
if (pctOf95 >= 140) return { label: 'Class 3 Severe Obesity', pctOf95, bmi95 };
if (pctOf95 >= 120) return { label: 'Class 2 Severe Obesity', pctOf95, bmi95 };
return { label: 'Obese (Class 1)', pctOf95, bmi95 };
}
if (percentile >= 85) return { label: 'Overweight', pctOf95, bmi95 };
if (percentile >= 5) return { label: 'Healthy Weight', pctOf95, bmi95 };
return { label: 'Underweight', pctOf95, bmi95 };
}
// ── BP capture helpers — import the generated bp.ts's authoritative
// data + math by requiring the transpiled source. Since Node can't
// execute TS directly, we instead read the JSON-shaped arrays back
// from the vanilla calculators.js via eval(). This keeps the
// authoritative source as calculators.js and the vectors are direct
// outputs of that source. If calculators.js ever gets reformatted,
// regenerate and re-run the vitest.
const bpSourceLines = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'calculators.js'), 'utf8');
// Extract lines 89-503 (LMS + BP coefficients + declarations).
const bpDataSnippet = bpSourceLines.split('\n').slice(88, 503).join('\n');
// The data uses `var _htLMS_F_L` etc. — evaluate in a scope so the
// subsequent math can reference them.
const bpScope = {};
const bpDataWrap = '(function() { ' + bpDataSnippet
.replace(/var _htLMS_F_L=/g, 'bpScope._htLMS_F_L = ')
.replace(/var _htLMS_F_M=/g, 'bpScope._htLMS_F_M = ')
.replace(/var _htLMS_F_S=/g, 'bpScope._htLMS_F_S = ')
.replace(/var _htLMS_M_L=/g, 'bpScope._htLMS_M_L = ')
.replace(/var _htLMS_M_M=/g, 'bpScope._htLMS_M_M = ')
.replace(/var _htLMS_M_S=/g, 'bpScope._htLMS_M_S = ')
.replace(/var _bpCoeff_F_SYS=/g, 'bpScope._bpCoeff_F_SYS = ')
.replace(/var _bpCoeff_F_DIA=/g, 'bpScope._bpCoeff_F_DIA = ')
.replace(/var _bpCoeff_M_SYS=/g, 'bpScope._bpCoeff_M_SYS = ')
.replace(/var _bpCoeff_M_DIA=/g, 'bpScope._bpCoeff_M_DIA = ')
+ ' })()';
new Function('bpScope', bpDataWrap.replace('(function() {', '').replace('})()', ''))(bpScope);
function calcHeightPercentile(ageYears, sex, heightCm) {
const idx = Math.max(0, Math.min(217, Math.round(ageYears * 12) - 24));
let L, M, S;
if (sex === 'female') { L = bpScope._htLMS_F_L[idx]; M = bpScope._htLMS_F_M[idx]; S = bpScope._htLMS_F_S[idx]; }
else { L = bpScope._htLMS_M_L[idx]; M = bpScope._htLMS_M_M[idx]; S = bpScope._htLMS_M_S[idx]; }
const z = (Math.pow(heightCm / M, L) - 1) / (L * S);
// Reuse normalCDF-like approximation matching calc-math Abramowitz form
const a1 = 0.254829592, a2 = -0.284496736, a3 = 1.421413741;
const a4 = -1.453152027, a5 = 1.061405429, p = 0.3275911;
const sign = z < 0 ? -1 : 1;
const x = Math.abs(z) / Math.sqrt(2);
const t = 1 / (1 + p * x);
const y = 1 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * Math.exp(-x * x);
return { z, percentile: 0.5 * (1 + sign * y) * 100 };
}
function computeBpPercentile(ageYears, sex, heightCm, bpType, bpValue) {
let t1, t2, t3, t4, t5, ta1, ta2, ta3, ta4, ta5, tb1, tb2, tb3, tb4, tb5, w;
if (sex === 'female') {
t1=106.7; t2=140.7; t3=154.0; t4=160.5; t5=168.9;
ta1=5.00; ta2=10.70; ta3=13.16; ta4=14.51; ta5=17.33;
tb1=6.701; tb2=16.438; tb3=46.80; tb4=84.46; tb5=203.608;
w = (ageYears - 10) * (heightCm - 147);
} else {
t1=107.8; t2=140.0; t3=154.5; t4=166.4; t5=179.1;
ta1=5.06; ta2=10.79; ta3=13.22; ta4=14.51; ta5=17.30;
tb1=-15; tb2=8.9; tb3=50.375; tb4=112.684; tb5=250.04;
w = (ageYears - 10) * (heightCm - 150);
}
const x = heightCm, y = ageYears;
const x2a = Math.max(0, x - t1), x2b = Math.max(0, x - t4), x2c = Math.max(0, x - t5);
const x2 = Math.pow(x2a,3) - Math.pow(x2b,3)*(t5-t1)/(t5-t4) + Math.pow(x2c,3)*(t4-t1)/(t5-t4);
const x3a = Math.max(0, x - t2);
const x3 = Math.pow(x3a,3) - Math.pow(x2b,3)*(t5-t2)/(t5-t4) + Math.pow(x2c,3)*(t4-t2)/(t5-t4);
const x4a = Math.max(0, x - t3);
const x4 = Math.pow(x4a,3) - Math.pow(x2b,3)*(t5-t3)/(t5-t4) + Math.pow(x2c,3)*(t4-t3)/(t5-t4);
const x2s = x2/100, x3s = x3/100, x4s = x4/100;
const y2a = Math.max(0, y - ta1), y2b = Math.max(0, y - ta4), y2c = Math.max(0, y - ta5);
const y2 = Math.pow(y2a,3) - Math.pow(y2b,3)*(ta5-ta1)/(ta5-ta4) + Math.pow(y2c,3)*(ta4-ta1)/(ta5-ta4);
const y3a = Math.max(0, y - ta2);
const y3 = Math.pow(y3a,3) - Math.pow(y2b,3)*(ta5-ta2)/(ta5-ta4) + Math.pow(y2c,3)*(ta4-ta2)/(ta5-ta4);
const y4a = Math.max(0, y - ta3);
const y4 = Math.pow(y4a,3) - Math.pow(y2b,3)*(ta5-ta3)/(ta5-ta4) + Math.pow(y2c,3)*(ta4-ta3)/(ta5-ta4);
const y2s = y2/100, y3s = y3/100, y4s = y4/100;
const w2a = Math.max(0, w - tb1), w2b = Math.max(0, w - tb4), w2c = Math.max(0, w - tb5);
const w2 = Math.pow(w2a,3) - Math.pow(w2b,3)*(tb5-tb1)/(tb5-tb4) + Math.pow(w2c,3)*(tb4-tb1)/(tb5-tb4);
const w3a = Math.max(0, w - tb2);
const w3 = Math.pow(w3a,3) - Math.pow(w2b,3)*(tb5-tb2)/(tb5-tb4) + Math.pow(w2c,3)*(tb4-tb2)/(tb5-tb4);
const w4a = Math.max(0, w - tb3);
const w4 = Math.pow(w4a,3) - Math.pow(w2b,3)*(tb5-tb3)/(tb5-tb4) + Math.pow(w2c,3)*(tb4-tb3)/(tb5-tb4);
const w2s = w2/10000, w3s = w3/10000, w4s = w4/10000;
let coeff;
if (sex === 'female') coeff = bpType === 'sys' ? bpScope._bpCoeff_F_SYS : bpScope._bpCoeff_F_DIA;
else coeff = bpType === 'sys' ? bpScope._bpCoeff_M_SYS : bpScope._bpCoeff_M_DIA;
const fxsys = new Array(99);
for (let i = 0; i < 99; i++) {
fxsys[i] = coeff[i][0] + coeff[i][5]*x + coeff[i][6]*x2s + coeff[i][7]*x3s + coeff[i][8]*x4s
+ coeff[i][1]*y + coeff[i][2]*y2s + coeff[i][3]*y3s + coeff[i][4]*y4s
+ coeff[i][9]*w + coeff[i][10]*w2s + coeff[i][11]*w3s + coeff[i][12]*w4s;
}
let minDiff = Infinity, percentile = 50;
for (let i = 0; i < 99; i++) {
const diff = Math.abs(bpValue - fxsys[i]);
if (diff < minDiff) { minDiff = diff; percentile = i + 1; }
}
return { percentile };
}
function classifyBpFromPercentiles(ageYears, sysPctile, diaPctile, sys, dia) {
let sysClass, diaClass;
if (ageYears >= 13) {
if (sys >= 140) sysClass = 'stage2';
else if (sys >= 130) sysClass = 'stage1';
else if (sys >= 120) sysClass = 'elevated';
else sysClass = 'normal';
if (dia >= 90) diaClass = 'stage2';
else if (dia >= 80) diaClass = 'stage1';
else diaClass = 'normal';
} else {
if (sysPctile >= 95 + 12 || sys >= 140) sysClass = 'stage2';
else if (sysPctile >= 95 || sys >= 130) sysClass = 'stage1';
else if (sysPctile >= 90 || sys >= 120) sysClass = 'elevated';
else sysClass = 'normal';
if (diaPctile >= 95 + 12 || dia >= 90) diaClass = 'stage2';
else if (diaPctile >= 95 || dia >= 80) diaClass = 'stage1';
else if (diaPctile >= 90) diaClass = 'elevated';
else diaClass = 'normal';
}
const levels = { normal: 0, elevated: 1, stage1: 2, stage2: 3 };
const overall = levels[sysClass] >= levels[diaClass] ? sysClass : diaClass;
return { sysClass, diaClass, classification: overall };
}
function computeBp(ageYears, sex, heightCm, sbp, dbp) {
const ht = calcHeightPercentile(ageYears, sex, heightCm);
const sysP = computeBpPercentile(ageYears, sex, heightCm, 'sys', sbp);
const diaP = computeBpPercentile(ageYears, sex, heightCm, 'dia', dbp);
const klass = classifyBpFromPercentiles(ageYears, sysP.percentile, diaP.percentile, sbp, dbp);
return {
sysPercentile: sysP.percentile,
diaPercentile: diaP.percentile,
heightPercentile: ht.percentile,
sysClass: klass.sysClass,
diaClass: klass.diaClass,
classification: klass.classification,
};
}
function computeBmi(weightKg, heightCm, ageMonths, sex) {
const bmi = weightKg / Math.pow(heightCm / 100, 2);
const clamped = Math.max(24, Math.min(240, ageMonths));
const lms = interpolateLMS(bmiLMS[sex], clamped);
const { z, percentile } = calcBmiPercentile(bmi, lms.L, lms.M, lms.S);
const classification = classifyBMI(percentile, bmi, lms);
return { bmi, z, percentile, L: lms.L, M: lms.M, S: lms.S, classification };
}
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 } },
];
const bpCases = [
// AAP 2017 BP calculator — age/sex/height/SBP/DBP
// Pick cases across the full age 1-17 range, both sexes, both hypertension tiers.
{ name: 'BP 3y female normal', inputs: { ageYears: 3, sex: 'female', heightCm: 95, sbp: 95, dbp: 55 } },
{ name: 'BP 5y male normal', inputs: { ageYears: 5, sex: 'male', heightCm: 110, sbp: 100, dbp: 60 } },
{ name: 'BP 8y female elevated', inputs: { ageYears: 8, sex: 'female', heightCm: 128, sbp: 115, dbp: 75 } },
{ name: 'BP 8y male stage 1', inputs: { ageYears: 8, sex: 'male', heightCm: 128, sbp: 125, dbp: 82 } },
{ name: 'BP 10y female stage 2', inputs: { ageYears: 10, sex: 'female', heightCm: 140, sbp: 135, dbp: 90 } },
{ name: 'BP 12y male normal', inputs: { ageYears: 12, sex: 'male', heightCm: 150, sbp: 112, dbp: 70 } },
{ name: 'BP 13y female adult normal', inputs: { ageYears: 13, sex: 'female', heightCm: 158, sbp: 115, dbp: 70 } },
{ name: 'BP 14y male elevated (adult)', inputs: { ageYears: 14, sex: 'male', heightCm: 165, sbp: 122, dbp: 78 } },
{ name: 'BP 15y female stage 1 (adult)', inputs: { ageYears: 15, sex: 'female', heightCm: 162, sbp: 132, dbp: 85 } },
{ name: 'BP 16y male stage 2 (adult)', inputs: { ageYears: 16, sex: 'male', heightCm: 175, sbp: 145, dbp: 92 } },
// Edges
{ name: 'BP 1y male (lowest age)', inputs: { ageYears: 1, sex: 'male', heightCm: 75, sbp: 95, dbp: 55 } },
{ name: 'BP 17y female (highest age)', inputs: { ageYears: 17, sex: 'female', heightCm: 165, sbp: 110, dbp: 70 } },
// Tall / short kids (height percentile outliers)
{ name: 'BP 10y male very tall', inputs: { ageYears: 10, sex: 'male', heightCm: 160, sbp: 110, dbp: 68 } },
{ name: 'BP 10y male very short', inputs: { ageYears: 10, sex: 'male', heightCm: 125, sbp: 110, dbp: 68 } },
];
const bmiCases = [
// Edges + interior, both sexes. Cover underweight / healthy / overweight / obese / severe obesity cliffs.
{ name: 'BMI 2y male healthy', inputs: { weightKg: 12, heightCm: 85, ageMonths: 24, sex: 'male' } },
{ name: 'BMI 2y female underweight', inputs: { weightKg: 10, heightCm: 85, ageMonths: 24, sex: 'female' } },
{ name: 'BMI 5y male overweight', inputs: { weightKg: 22, heightCm: 110, ageMonths: 60, sex: 'male' } },
{ name: 'BMI 5y female obese', inputs: { weightKg: 27, heightCm: 110, ageMonths: 60, sex: 'female' } },
{ name: 'BMI 8y male healthy interior', inputs: { weightKg: 26, heightCm: 128, ageMonths: 96, sex: 'male' } },
{ name: 'BMI 10y female severe obesity', inputs: { weightKg: 55, heightCm: 138, ageMonths: 120, sex: 'female' } },
{ name: 'BMI 12y male interpolated 13m', inputs: { weightKg: 45, heightCm: 150, ageMonths: 155, sex: 'male' } },
{ name: 'BMI 15y female adolescent', inputs: { weightKg: 55, heightCm: 162, ageMonths: 180, sex: 'female' } },
{ name: 'BMI 18y male 20', inputs: { weightKg: 75, heightCm: 178, ageMonths: 216, sex: 'male' } },
{ name: 'BMI 20y female (upper boundary)', inputs: { weightKg: 60, heightCm: 165, ageMonths: 240, sex: 'female' } },
// Boundaries — clamping
{ name: 'BMI clamped lower (1y)', inputs: { weightKg: 10, heightCm: 75, ageMonths: 12, sex: 'male' } },
{ name: 'BMI clamped upper (25y)', inputs: { weightKg: 70, heightCm: 175, ageMonths: 300, sex: 'male' } },
];
const neonatalCases = [
// Validated test case from bedside/neonatal.js comment (40 5/7 wk male 3070g → z = -1.42)
{ name: 'Neonatal 40w5d male 3070g (validated against peditools/Epic)',
inputs: { weeks: 40, days: 5, weightGrams: 3070, sex: 'male' } },
{ name: 'Neonatal 28w male 1079g (M)', inputs: { weeks: 28, days: 0, weightGrams: 1079, sex: 'male' } },
{ name: 'Neonatal 32w female 1681g (M)', inputs: { weeks: 32, days: 0, weightGrams: 1681, sex: 'female' } },
{ name: 'Neonatal 24w SGA', inputs: { weeks: 24, days: 0, weightGrams: 450, sex: 'male' } },
{ name: 'Neonatal 40w LGA 4500g', inputs: { weeks: 40, days: 0, weightGrams: 4500, sex: 'female' } },
{ name: 'Neonatal 34w3d female 2000g', inputs: { weeks: 34, days: 3, weightGrams: 2000, sex: 'female' } },
{ name: 'Neonatal 39w male 3400g', inputs: { weeks: 39, days: 0, weightGrams: 3400, sex: 'male' } },
{ name: 'Neonatal 22w SGA', inputs: { weeks: 22, days: 0, weightGrams: 400, sex: 'male' } },
];
// ── Run + emit ─────────────────────────────────────────────
const vectors = {
generatedAt: new Date().toISOString(),
source: 'public/js/calculators.js + public/js/bedside/neonatal.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) })),
neonatal: neonatalCases.map((c) => ({ ...c, output: neonatalAssess(c.inputs.weeks, c.inputs.days, c.inputs.weightGrams, c.inputs.sex) })),
bmi: bmiCases.map((c) => ({ ...c, output: computeBmi(c.inputs.weightKg, c.inputs.heightCm, c.inputs.ageMonths, c.inputs.sex) })),
bp: bpCases.map((c) => ({ ...c, output: computeBp(c.inputs.ageYears, c.inputs.sex, c.inputs.heightCm, c.inputs.sbp, c.inputs.dbp) })),
};
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, ${vectors.neonatal.length} Neonatal, ${vectors.bmi.length} BMI, ${vectors.bp.length} BP cases → ${outPath}`);