import type { ZScoreInput, ZScoreResult, AssessmentInput, AssessmentResult, Indicator, } from './types.js'; import { lookupLms, resolveTable } from './lms.js'; import { calculateAge } from './age.js'; import { classifyAll } from './classify.js'; import { getStrings } from './i18n/pt-BR.js'; import type { LmsRow } from './types.js'; /** * Standard normal CDF approximation (Abramowitz & Stegun, formula 26.2.17). * Maximum error: 7.5e-8. */ export function normalCdf(z: number): number { if (z < -12) return 0; if (z > 12) return 1; const absZ = Math.abs(z); const t = 1 / (1 + 0.2316419 * absZ); const d = 0.3989422804014327; // 1/sqrt(2*PI) const p = d * Math.exp((-absZ * absZ) / 2) * (t * (0.31938153 + t * (-0.356563782 + t * (1.781477937 + t * (-1.821255978 + t * 1.330274429))))); return z > 0 ? 1 - p : p; } /** * Calculate z-score from measurement and LMS parameters. * * Standard formula: Z = ((measurement/M)^L - 1) / (L * S) * When L = 0: Z = ln(measurement/M) / S * * WHO extrapolation for |Z| > 3: * SD3pos = M * (1 + L*S*3)^(1/L) * SD23pos = SD3pos - SD2pos * If Z > 3: Z = 3 + (measurement - SD3pos) / SD23pos * If Z < -3: Z = -3 + (measurement - SD3neg) / SD23neg */ export function computeZScore(measurement: number, lms: LmsRow): number { const { L, M, S } = lms; if (measurement <= 0 || M <= 0 || S <= 0) return NaN; let z: number; if (Math.abs(L) < 1e-10) { // L ≈ 0: use log formula z = Math.log(measurement / M) / S; } else { z = (Math.pow(measurement / M, L) - 1) / (L * S); } // WHO extrapolation for |Z| > 3 if (Math.abs(z) > 3) { const sd3pos = M * Math.pow(1 + L * S * 3, 1 / L); const sd2pos = M * Math.pow(1 + L * S * 2, 1 / L); const sd3neg = M * Math.pow(1 + L * S * -3, 1 / L); const sd2neg = M * Math.pow(1 + L * S * -2, 1 / L); const sd23pos = sd3pos - sd2pos; const sd23neg = sd2neg - sd3neg; if (z > 3) { z = 3 + (measurement - sd3pos) / sd23pos; } else { z = -3 + (measurement - sd3neg) / sd23neg; } } return Math.round(z * 100) / 100; } /** * Calculate z-score and percentile for a single indicator. */ export async function calculateZScore(input: ZScoreInput): Promise { const resolved = await resolveTable({ indicator: input.indicator, sex: input.sex, ageInDays: input.ageInDays, lengthHeight: input.lengthHeight, chartSet: input.chartSet, gestationalAgeWeeks: input.gestationalAgeWeeks, }); if (!resolved) return null; const lms = lookupLms(resolved.table, resolved.index); if (!lms) return null; const zScore = computeZScore(input.measurement, lms); if (isNaN(zScore)) return null; const percentile = Math.round(normalCdf(zScore) * 1000) / 10; return { indicator: input.indicator, zScore, percentile, }; } /** * Calculate all applicable indicators for a patient. */ export async function calculateAll(input: AssessmentInput): Promise { const strings = getStrings(); const chartSet = input.chartSet ?? 'who-standard'; const age = calculateAge( input.dateOfBirth, input.dateOfMeasurement, input.gestationalAgeWeeks, strings.age ); const effectiveAgeDays = age.correctedDays; const isUnder2 = effectiveAgeDays < 731; const isUnder5 = effectiveAgeDays < 1857; // Determine if we need length/height adjustment // Under 2: measured lying down (length). If measured standing, add 0.7 cm. // Over 2: measured standing (height). If measured lying down, subtract 0.7 cm. // For simplicity in V1, we assume correct measurement position. const adjustedLengthHeight = input.lengthHeight; const results: ZScoreResult[] = []; const indicatorsToCalculate: Array<{ indicator: Indicator; measurement: number; ageInDays?: number; lengthHeight?: number; }> = []; // Weight-for-age (WHO: 0-10 years; DS: 0-18 years) const wfaMaxDays = chartSet === 'down-syndrome' ? 6574 : 3650; if (input.weight != null && effectiveAgeDays <= wfaMaxDays) { indicatorsToCalculate.push({ indicator: 'weight-for-age', measurement: input.weight, ageInDays: effectiveAgeDays, }); } // Length/Height-for-age (0-19 years) if (adjustedLengthHeight != null) { indicatorsToCalculate.push({ indicator: 'length-height-for-age', measurement: adjustedLengthHeight, ageInDays: effectiveAgeDays, }); } // BMI-for-age (0-19 years, needs both weight and height) if (input.weight != null && adjustedLengthHeight != null && adjustedLengthHeight > 0) { const heightM = adjustedLengthHeight / 100; const bmi = input.weight / (heightM * heightM); indicatorsToCalculate.push({ indicator: 'bmi-for-age', measurement: Math.round(bmi * 100) / 100, ageInDays: effectiveAgeDays, }); } // Head circumference-for-age (WHO: 0-5 years; DS: 0-20 years) const hcfaMaxDays = chartSet === 'down-syndrome' ? 7305 : 1856; if (input.headCircumference != null && effectiveAgeDays <= hcfaMaxDays) { indicatorsToCalculate.push({ indicator: 'head-circumference-for-age', measurement: input.headCircumference, ageInDays: effectiveAgeDays, }); } // Weight-for-length (45-110 cm, typically < 2 years) — not available for Down syndrome if ( chartSet !== 'down-syndrome' && input.weight != null && adjustedLengthHeight != null && isUnder2 ) { if (adjustedLengthHeight >= 45 && adjustedLengthHeight <= 110) { indicatorsToCalculate.push({ indicator: 'weight-for-length', measurement: input.weight, lengthHeight: adjustedLengthHeight, }); } } // Weight-for-height (65-120 cm, typically 2-5 years) — not available for Down syndrome if ( chartSet !== 'down-syndrome' && input.weight != null && adjustedLengthHeight != null && !isUnder2 && isUnder5 ) { if (adjustedLengthHeight >= 65 && adjustedLengthHeight <= 120) { indicatorsToCalculate.push({ indicator: 'weight-for-height', measurement: input.weight, lengthHeight: adjustedLengthHeight, }); } } // Calculate all in parallel const calcResults = await Promise.all( indicatorsToCalculate.map((calc) => calculateZScore({ indicator: calc.indicator, sex: input.sex, ageInDays: calc.ageInDays, lengthHeight: calc.lengthHeight, measurement: calc.measurement, chartSet, gestationalAgeWeeks: input.gestationalAgeWeeks, }) ) ); for (const result of calcResults) { if (result) results.push(result); } const classifications = classifyAll(results, effectiveAgeDays, strings); return { age, results, classifications }; }