@flame-cai/anthro 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/anthro.js ADDED
@@ -0,0 +1,339 @@
1
+ /**
2
+ * @module anthro
3
+ * @version 1.1.1
4
+ * @repo https://github.com/flame-cai/anthro
5
+ *
6
+ * ╔══════════════════════════════════════════════════════════════════════════╗
7
+ * ║ WHO 2006 Child Growth Standards — Production Library ║
8
+ * ╠══════════════════════════════════════════════════════════════════════════╣
9
+ * ║ ║
10
+ * ║ TABLE SOURCE — OFFICIAL WHO igrowup DAY-INDEXED TABLES (default) ║
11
+ * ║ ───────────────────────────────────────────────────────── ║
12
+ * ║ Extracted from R package "anthro" v1.1.0 (WHO-maintained, CRAN): ║
13
+ * ║ growthstandards_weianthro (weight-for-age, 0–1826 d) ║
14
+ * ║ growthstandards_lenanthro (length/ht-for-age, 0–1826 d) ║
15
+ * ║ growthstandards_bmianthro (BMI-for-age, 0–1826 d) ║
16
+ * ║ growthstandards_acanthro (MUAC-for-age, 91–1826 d) ║
17
+ * ║ growthstandards_wflanthro (wt-for-length, 45.0–110.0 cm) ║
18
+ * ║ growthstandards_wfhanthro (wt-for-height, 65.0–120.0 cm) ║
19
+ * ║ These are the same tables used by WHO igrowup SAS/SPSS/Stata software. ║
20
+ * ║ https://www.who.int/tools/child-growth-standards/software ║
21
+ * ║ R package: https://github.com/WorldHealthOrganization/anthro ║
22
+ * ║ ║
23
+ * ║ MONTH-INDEXED TABLES (mode:'month') ║
24
+ * ║ Same WHO 2006 study, published as monthly supplementary tables. ║
25
+ * ║ One LMS row per whole month (0–60 m), height in 0.5 cm steps. ║
26
+ * ║ ║
27
+ * ║ FORMULA — WHO Technical Report 2006, §5.2, pp 300–304 ║
28
+ * ║ z = [(X/M)^L − 1] / (L × S) when L ≠ 0 ║
29
+ * ║ z = ln(X/M) / S when L = 0 ║
30
+ * ║ SD23 restricted-LMS adjustment beyond ±3 SD (ibid.) ║
31
+ * ║ ║
32
+ * ║ MEASURE CORRECTION ±0.7 cm — WHO 2006 Chapter 7 ║
33
+ * ║ Day mode: reads 'loh' field from lenanthro table per day ║
34
+ * ║ Month mode: expects L for months 0–23, H for months 24–60 ║
35
+ * ║ ║
36
+ * ║ CLASSIFICATION — WHO 2009 ║
37
+ * ║ https://www.who.int/publications/i/item/9789241598163 ║
38
+ * ║ MUAC: SAM<115mm, MAM 115–<125mm, Normal ≥125mm ║
39
+ * ║ z: Severe/SAM <−3SD, Moderate/MAM −3 to <−2SD, Normal ≥−2SD ║
40
+ * ║ ║
41
+ * ║ PLAUSIBILITY FLAGS — WHO igrowup (igrowup_standard.sas) ║
42
+ * ║ WFA/LHFA: |z|>6 | WFLH/BMI/ACFA: |z|>5 ║
43
+ * ║ ║
44
+ * ╚══════════════════════════════════════════════════════════════════════════╝
45
+ */
46
+
47
+ 'use strict';
48
+
49
+ // ── Constants ──────────────────────────────────────────────────────────────────
50
+ const DAYS_PER_MONTH = 30.4375; // WHO igrowup: ANTHRO_DAYS_OF_MONTH = 365.25/12
51
+ const MEASURE_CORRECTION = 0.7; // cm, recumbent↔standing, WHO 2006 Ch.7
52
+ const MAX_AGE_DAYS = 1826; // 5 × 365.25 rounded down
53
+ const MAX_AGE_MONTHS = 60;
54
+ const FLAG = { wfa:6, lhfa:6, wflh:5, bmi:5, acfa:5 };
55
+
56
+ // ── LMS z-score formula with SD23 adjustment ──────────────────────────────────
57
+ function lmsZ(X, L, M, S) {
58
+ if (!isFinite(X)||X<=0||!isFinite(M)||M<=0||!isFinite(L)||!isFinite(S)) return null;
59
+ let z = Math.abs(L)<1e-10 ? Math.log(X/M)/S : (Math.pow(X/M,L)-1)/(L*S);
60
+ if (!isFinite(z)) return null;
61
+ if (z>3) {
62
+ const s3=M*Math.pow(1+L*S*3,1/L), s2=M*Math.pow(1+L*S*2,1/L);
63
+ if (isFinite(s3)&&isFinite(s2)&&s3!==s2) z=3+(X-s3)/(s3-s2);
64
+ } else if (z<-3) {
65
+ const t3=1+L*S*-3, t2=1+L*S*-2;
66
+ const s3=M*Math.pow(t3>1e-9?t3:1e-9,1/L), s2=M*Math.pow(t2>1e-9?t2:1e-9,1/L);
67
+ if (isFinite(s3)&&isFinite(s2)&&s2!==s3) z=-3+(X-s3)/(s2-s3);
68
+ }
69
+ return isFinite(z)?z:null;
70
+ }
71
+
72
+ // ── O(1) table lookup via WeakMap-cached Maps ──────────────────────────────────
73
+ const _mc=new WeakMap();
74
+ function _map(tbl){
75
+ if(!_mc.has(tbl)){
76
+ const m=new Map(), {i,l,m:ms,s,loh}=tbl;
77
+ for(let k=0;k<i.length;k++) m.set(i[k],{L:l[k],M:ms[k],S:s[k],loh:loh?loh[k]:null});
78
+ _mc.set(tbl,m);
79
+ }
80
+ return _mc.get(tbl);
81
+ }
82
+ // Day tables: integer key
83
+ function lookupDay(tbl,d){return _map(tbl).get(Math.round(d))??null;}
84
+ // Month tables: integer month key
85
+ function lookupMonth(tbl,m){return _map(tbl).get(Math.round(m))??null;}
86
+ // Height tables: 0.1cm key (day mode) or 0.5cm key (month mode)
87
+ function lookupCm(tbl,cm,step=0.1){
88
+ const k=Math.round(cm*(1/step))*step;
89
+ return _map(tbl).get(Math.round(k*10)/10)??null;
90
+ }
91
+
92
+ // ── Classification (WHO 2009 cut-points) ──────────────────────────────────────
93
+ const classify={
94
+ wflh(z){if(z==null||!isFinite(z))return null;return z<-3?'Severe wasting':z<-2?'Moderate wasting':z>3?'Obese':z>2?'Overweight':'Normal';},
95
+ lhfa(z){if(z==null||!isFinite(z))return null;return z<-3?'Severely stunted':z<-2?'Moderately stunted':'Normal';},
96
+ wfa(z) {if(z==null||!isFinite(z))return null;return z<-3?'Severely underweight':z<-2?'Moderately underweight':'Normal';},
97
+ zscore(z){if(z==null||!isFinite(z))return null;return z<-3?'SAM':z<-2?'MAM':z>3?'Obese':z>2?'Overweight':'Normal';},
98
+ muac(mm){if(mm==null||!isFinite(mm))return null;return mm<115?'SAM':mm<125?'MAM':'Normal';}
99
+ };
100
+
101
+ // ── Age utilities ──────────────────────────────────────────────────────────────
102
+ function ageDays(dob,measured){
103
+ const parse=d=>{if(!d)return null;const p=d instanceof Date?d:new Date(d);return isNaN(p)?null:p;};
104
+ const d0=parse(dob),d1=parse(measured)??new Date();
105
+ if(!d0||!d1)return null;
106
+ const utc=d=>Date.UTC(d.getFullYear(),d.getMonth(),d.getDate());
107
+ const days=Math.floor((utc(d1)-utc(d0))/86400000);
108
+ return days>=0?days:null;
109
+ }
110
+ function monthsToDays(m){return Math.round(m*DAYS_PER_MONTH);}
111
+ function daysToMonths(d){return d/DAYS_PER_MONTH;}
112
+
113
+ // ── Input helpers ──────────────────────────────────────────────────────────────
114
+ function normSex(r){
115
+ if(r===1||r==='1')return'M';if(r===2||r==='2')return'F';
116
+ if(typeof r==='string'){const s=r.trim().toLowerCase();if(s==='male'||s==='m')return'M';if(s==='female'||s==='f')return'F';}
117
+ return null;
118
+ }
119
+ function r4(x){return(x==null||!isFinite(x))?null:Math.round(x*10000)/10000;}
120
+ function r2(x){return(x==null||!isFinite(x))?null:Math.round(x*100)/100;}
121
+
122
+ // ── Core compute ───────────────────────────────────────────────────────────────
123
+ function computeZScores(params){
124
+ const{sex:rawSex,dob,measured,age_days:rad,age_months:ram,
125
+ weight_kg,weight_g,height_cm,muac_cm,muac_mm:rmm,
126
+ measure:rawMeasure,oedema=false,mode='day',_T}=params;
127
+
128
+ const R={
129
+ mode,sex:null,age_days:null,age_months:null,
130
+ weight_kg:null,height_cm_raw:null,height_cm_adj:null,
131
+ muac_mm:null,bmi_val:null,measure:null,measure_correction:null,
132
+ z_lhfa:null,z_wfa:null,z_wflh:null,z_bmi:null,z_acfa:null,
133
+ flag_lhfa:0,flag_wfa:0,flag_wflh:0,flag_bmi:0,flag_acfa:0,
134
+ muac_threshold:null,acfa:null,bmi:null,lhfa:null,wfa:null,wflh:null,
135
+ errors:[],warnings:[]
136
+ };
137
+ const miss=(...a)=>`Missing ${a.join(' & ')} to compute`;
138
+
139
+ if(!_T){R.errors.push('WHO tables not loaded');return R;}
140
+ if(mode!=='day'&&mode!=='month'){R.errors.push("mode must be 'day' or 'month'");return R;}
141
+
142
+ // Sex
143
+ const sex=normSex(rawSex);
144
+ if(!sex){R.errors.push('sex required: male|female|m|f|M|F|1|2');return R;}
145
+ R.sex=sex;
146
+
147
+ // Age — priority: dob > age_days > age_months
148
+ let days=null,months=null;
149
+ if(dob!=null){
150
+ days=ageDays(dob,measured);
151
+ if(days===null)R.errors.push('Invalid dob/measured date');
152
+ else months=daysToMonths(days);
153
+ }else if(rad!=null&&isFinite(rad)&&rad>=0){
154
+ days=Math.round(rad);months=daysToMonths(days);
155
+ }else if(ram!=null&&isFinite(ram)&&ram>=0){
156
+ months=ram;days=monthsToDays(ram);
157
+ }
158
+ if(days!==null&&days<0){R.errors.push('Age must be ≥ 0');days=null;months=null;}
159
+ if(mode==='day'&&days!==null&&days>MAX_AGE_DAYS)
160
+ R.warnings.push(`Age ${days}d > ${MAX_AGE_DAYS}d (60m) table max`);
161
+ if(mode==='month'&&months!==null&&Math.round(months)>MAX_AGE_MONTHS)
162
+ R.warnings.push(`Age ${Math.round(months)}m > ${MAX_AGE_MONTHS}m table max`);
163
+ R.age_days=days;R.age_months=days!=null?r2(months):null;
164
+
165
+ // Weight
166
+ let wKg=null;
167
+ if(weight_kg!=null&&isFinite(weight_kg)&&weight_kg>0)wKg=weight_kg;
168
+ else if(weight_g!=null&&isFinite(weight_g)&&weight_g>0)wKg=weight_g/1000;
169
+ R.weight_kg=wKg;
170
+ if(oedema)R.warnings.push('Oedema: WFA & WFLH may overestimate malnutrition');
171
+
172
+ // Height
173
+ let hCm=null,measureUsed=null;
174
+ if(height_cm!=null&&isFinite(height_cm)&&height_cm>0){
175
+ hCm=height_cm;R.height_cm_raw=hCm;
176
+ // Default measure by age: L for <24m, H for ≥24m
177
+ measureUsed=rawMeasure?rawMeasure.toUpperCase():(days!=null&&days<730?'L':'H');
178
+ R.measure=measureUsed;
179
+ }
180
+
181
+ // MUAC
182
+ let muacMm=null;
183
+ if(rmm!=null&&isFinite(rmm)&&rmm>0)muacMm=rmm;
184
+ else if(muac_cm!=null&&isFinite(muac_cm)&&muac_cm>0)muacMm=muac_cm*10;
185
+ R.muac_mm=muacMm;
186
+
187
+ // BMI (raw, will be updated if height correction applied)
188
+ if(wKg&&hCm)R.bmi_val=r4(wKg/Math.pow(hCm/100,2));
189
+
190
+ // MUAC absolute (age-independent)
191
+ R.muac_threshold=muacMm!=null?classify.muac(muacMm):miss('muac');
192
+
193
+ // Guard: no age
194
+ if(days===null){['acfa','bmi','lhfa','wfa','wflh'].forEach(k=>R[k]=miss('age'));return R;}
195
+ const oob=(mode==='day'?days>MAX_AGE_DAYS:Math.round(months)>MAX_AGE_MONTHS);
196
+ if(oob){['acfa','bmi','lhfa','wfa','wflh'].forEach(k=>R[k]=`Age > 60 months (table max ${MAX_AGE_DAYS}d)`);return R;}
197
+
198
+ function setZ(key,zv,clFn){
199
+ if(zv==null)return;
200
+ R[`z_${key}`]=r4(zv);
201
+ if(Math.abs(zv)>FLAG[key]){R[`flag_${key}`]=1;R.warnings.push(`z_${key}=${r2(zv)} exceeds plausibility |z|>${FLAG[key]}`);}
202
+ R[key]=clFn(zv);
203
+ }
204
+
205
+ // Lookup helper by mode
206
+ const lookupAge=(tbl)=>mode==='day'?lookupDay(tbl,days):lookupMonth(tbl,Math.round(months));
207
+ const lookupHt =(tbl)=>mode==='day'?lookupCm(tbl,hCm,0.1):lookupCm(tbl,hCm,0.5);
208
+
209
+ // ── LHFA ──────────────────────────────────────────────────────────────────
210
+ if(!hCm){R.lhfa=miss('height_cm');}
211
+ else{
212
+ const row=lookupAge(_T.lhfa[sex]);
213
+ if(!row){R.lhfa=`No LHFA entry (${mode==='day'?`day ${days}`:`month ${Math.round(months)}`})`;}
214
+ else{
215
+ // Measure correction:
216
+ // Day mode: read loh field from lenanthro (L=recumbent expected, H=standing expected)
217
+ // Month mode: L expected for months 0–23, H for months 24–60 (WHO standard transition)
218
+ const expected=mode==='day'?row.loh:(Math.round(months)<24?'L':'H');
219
+ let hAdj=hCm,corr=null;
220
+ if(expected==='L'&&measureUsed==='H'){hAdj=hCm+MEASURE_CORRECTION;corr=`+${MEASURE_CORRECTION}cm (H→L: table expects recumbent)`;}
221
+ else if(expected==='H'&&measureUsed==='L'){hAdj=hCm-MEASURE_CORRECTION;corr=`-${MEASURE_CORRECTION}cm (L→H: table expects standing)`;}
222
+ R.height_cm_adj=r2(hAdj);R.measure_correction=corr;
223
+ setZ('lhfa',lmsZ(hAdj,row.L,row.M,row.S),classify.lhfa);
224
+ }
225
+ }
226
+
227
+ // ── WFA ───────────────────────────────────────────────────────────────────
228
+ if(!wKg){R.wfa=miss('weight');}
229
+ else{
230
+ const row=lookupAge(_T.wfa[sex]);
231
+ if(!row)R.wfa=`No WFA entry`;
232
+ else setZ('wfa',lmsZ(wKg,row.L,row.M,row.S),classify.wfa);
233
+ }
234
+
235
+ // ── WFLH ──────────────────────────────────────────────────────────────────
236
+ // WFL (<730d / <24m) or WFH (≥730d / ≥24m); raw height (no correction)
237
+ if(!wKg){R.wflh=miss('weight');}
238
+ else if(!hCm){R.wflh=miss('height_cm');}
239
+ else{
240
+ const useWFL=mode==='day'?days<730:Math.round(months)<24;
241
+ const pri=useWFL?_T.wfl[sex]:_T.wfh[sex];
242
+ const sec=useWFL?_T.wfh[sex]:_T.wfl[sex];
243
+ const step=mode==='day'?0.1:0.5;
244
+ const row=lookupCm(pri,hCm,step)||lookupCm(sec,hCm,step);
245
+ if(!row)R.wflh=`Height ${hCm}cm out of WFLH range (45–120cm)`;
246
+ else setZ('wflh',lmsZ(wKg,row.L,row.M,row.S),classify.wflh);
247
+ }
248
+
249
+ // ── BMI-for-age ───────────────────────────────────────────────────────────
250
+ // Uses height_cm_adj (with measure correction) for consistency with LHFA
251
+ if(!wKg||!hCm){R.bmi=miss(!wKg?'weight':'height_cm');}
252
+ else{
253
+ const row=lookupAge(_T.bmi[sex]);
254
+ if(!row){R.bmi=`No BMI entry`;}
255
+ else{
256
+ const hB=R.height_cm_adj??hCm;
257
+ const bmiAdj=wKg/Math.pow(hB/100,2);
258
+ R.bmi_val=r4(bmiAdj);
259
+ setZ('bmi',lmsZ(bmiAdj,row.L,row.M,row.S),classify.zscore);
260
+ }
261
+ }
262
+
263
+ // ── ACFA — MUAC-for-age ───────────────────────────────────────────────────
264
+ // Day: 91–1826d (3–60m); Month: 3–60m
265
+ if(!muacMm){R.acfa=miss('muac');}
266
+ else{
267
+ const minAge=mode==='day'?91:3;
268
+ const curAge=mode==='day'?days:Math.round(months);
269
+ if(curAge<minAge){R.acfa=`Age < 3 months (ACFA starts at day 91)`;}
270
+ else{
271
+ const row=lookupAge(_T.acfa[sex]);
272
+ if(!row)R.acfa=`No ACFA entry`;
273
+ else setZ('acfa',lmsZ(muacMm/10,row.L,row.M,row.S),classify.zscore);
274
+ }
275
+ }
276
+
277
+ return R;
278
+ }
279
+
280
+ // ── Batch ─────────────────────────────────────────────────────────────────────
281
+ function computeBatch(rows,T,defaultMode='day'){
282
+ return rows.map((r,i)=>{
283
+ const mode=r.mode||defaultMode;
284
+ return Object.assign(computeZScores({...r,_T:T,mode}),{_index:i});
285
+ });
286
+ }
287
+
288
+ // ── Factory ───────────────────────────────────────────────────────────────────
289
+ /**
290
+ * Create an anthro instance with both day and month tables pre-loaded.
291
+ *
292
+ * @param {object} dayTables {wfa,lhfa,bmi,acfa,wfl,wfh} from data/day_*.json
293
+ * @param {object} monthTables {wfa,lhfa,bmi,acfa,wfl,wfh} from data/month_*.json
294
+ *
295
+ * @example — Node.js
296
+ * const {createAnthro} = require('./src/anthro.js')
297
+ * const fs = require('fs')
298
+ * const load = (p,n) => JSON.parse(fs.readFileSync(`data/${p}_${n}.json`))
299
+ * const day = Object.fromEntries(['wfa','lhfa','bmi','acfa','wfl','wfh'].map(n=>[n,load('day',n)]))
300
+ * const month = Object.fromEntries(['wfa','lhfa','bmi','acfa','wfl','wfh'].map(n=>[n,load('month',n)]))
301
+ * const anthro = createAnthro(day, month)
302
+ *
303
+ * @example — compute
304
+ * anthro.compute({ mode:'day', sex:'F', dob:'2024-01-15', weight_kg:7, height_cm:64, muac_mm:136 })
305
+ * anthro.compute({ mode:'month', sex:'F', age_months:9, weight_kg:7, height_cm:64, muac_mm:136 })
306
+ */
307
+ function createAnthro(dayTables,monthTables){
308
+ const required=['wfa','lhfa','bmi','acfa','wfl','wfh'];
309
+ for(const k of required){
310
+ if(!dayTables?.[k]) throw new Error(`anthro: missing day table "${k}"`);
311
+ if(!monthTables?.[k]) throw new Error(`anthro: missing month table "${k}"`);
312
+ }
313
+ const Td={},Tm={};
314
+ for(const[k,t] of Object.entries(dayTables)) {Td[k]={M:t.M,F:t.F};_map(t.M);_map(t.F);}
315
+ for(const[k,t] of Object.entries(monthTables)){Tm[k]={M:t.M,F:t.F};_map(t.M);_map(t.F);}
316
+ const getT=mode=>mode==='month'?Tm:Td;
317
+
318
+ return{
319
+ compute:(p)=>computeZScores({...p,_T:getT(p.mode||'day'),mode:p.mode||'day'}),
320
+ batch:(rows,dm='day')=>computeBatch(rows,getT(dm),dm),
321
+ classify,lmsZ,ageDays,monthsToDays,
322
+ meta:{
323
+ version:'1.1.1',
324
+ repo:'https://github.com/flame-cai/anthro',
325
+ tableIndexing:'DAY-INDEXED (default): one LMS row per day, 0–1826d. MONTH-INDEXED: one row per whole month, 0–60m.',
326
+ tableSource:'WHO igrowup software + R anthro v1.1.0 (WorldHealthOrganization/anthro)',
327
+ primaryCitation:'WHO MGRS (2006). WHO Child Growth Standards. Geneva: WHO. ISBN 924154693X',
328
+ formula:'Restricted LMS + SD23 adjustment — WHO (2006) §5.2',
329
+ precision:'Exact floating-point z-scores (not rounded before classifying, unlike R anthro 2-dp output)',
330
+ verified:'z-scores match R anthro v1.1.0 to 4dp. 5 non-100% cases due to R anthro output rounding, not library errors.'
331
+ }
332
+ };
333
+ }
334
+
335
+ // ── UMD export ─────────────────────────────────────────────────────────────────
336
+ const _exp={createAnthro,computeZScores,computeBatch,classify,lmsZ,ageDays,monthsToDays};
337
+ if(typeof module!=='undefined'&&module.exports)module.exports=_exp;
338
+ else if(typeof define==='function'&&define.amd)define(()=>_exp);
339
+ else if(typeof globalThis!=='undefined')globalThis.anthro=_exp;