Files
mop-skills/skills/datastore/scripts/who_growth.py
T

171 lines
8.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
who_growth.py — WHO Child Growth Standards (Girls 0–24 Months)
Deterministic percentile and z-score calculations via LMS method
and reference curve data for Mila's growth tracking.
"""
from __future__ import annotations
import math
from typing import Any, Dict, List, Optional, Tuple
# WHO Child Growth Standards (Girls: Birth to 24 Months)
# Source: World Health Organization (WHO) Multi-centre Growth Reference Study (MGRS)
# https://www.who.int/tools/child-growth-standards/standards
WHO_GIRLS_WEIGHT = [
{"month": 0, "L": 0.3809, "M": 3.2322, "S": 0.14171, "p3": 2.4, "p50": 3.2, "p97": 4.2},
{"month": 1, "L": 0.1714, "M": 4.1873, "S": 0.13724, "p3": 3.2, "p50": 4.2, "p97": 5.4},
{"month": 2, "L": 0.0962, "M": 5.1282, "S": 0.13000, "p3": 4.0, "p50": 5.1, "p97": 6.5},
{"month": 3, "L": 0.0402, "M": 5.8458, "S": 0.12619, "p3": 4.6, "p50": 5.8, "p97": 7.4},
{"month": 4, "L": -0.0050, "M": 6.4237, "S": 0.12402, "p3": 5.1, "p50": 6.4, "p97": 8.1},
{"month": 5, "L": -0.0430, "M": 6.8985, "S": 0.12274, "p3": 5.5, "p50": 6.9, "p97": 8.7},
{"month": 6, "L": -0.0756, "M": 7.2970, "S": 0.12204, "p3": 5.8, "p50": 7.3, "p97": 9.2},
{"month": 7, "L": -0.1039, "M": 7.6422, "S": 0.12178, "p3": 6.1, "p50": 7.6, "p97": 9.6},
{"month": 8, "L": -0.1288, "M": 7.9487, "S": 0.12181, "p3": 6.3, "p50": 7.9, "p97": 10.0},
{"month": 9, "L": -0.1507, "M": 8.2254, "S": 0.12199, "p3": 6.6, "p50": 8.2, "p97": 10.4},
{"month": 10, "L": -0.1700, "M": 8.4800, "S": 0.12223, "p3": 6.8, "p50": 8.5, "p97": 10.7},
{"month": 11, "L": -0.1872, "M": 8.7192, "S": 0.12247, "p3": 7.0, "p50": 8.7, "p97": 11.0},
{"month": 12, "L": -0.2024, "M": 8.9481, "S": 0.12268, "p3": 7.1, "p50": 8.9, "p97": 11.3},
{"month": 13, "L": -0.2158, "M": 9.1699, "S": 0.12283, "p3": 7.3, "p50": 9.2, "p97": 11.6},
{"month": 14, "L": -0.2278, "M": 9.3870, "S": 0.12294, "p3": 7.5, "p50": 9.4, "p97": 11.9},
{"month": 15, "L": -0.2384, "M": 9.6008, "S": 0.12299, "p3": 7.7, "p50": 9.6, "p97": 12.2},
{"month": 16, "L": -0.2478, "M": 9.8124, "S": 0.12303, "p3": 7.8, "p50": 9.8, "p97": 12.5},
{"month": 17, "L": -0.2562, "M": 10.0226, "S": 0.12306, "p3": 8.0, "p50": 10.0, "p97": 12.7},
{"month": 18, "L": -0.2637, "M": 10.2315, "S": 0.12309, "p3": 8.2, "p50": 10.2, "p97": 13.0},
{"month": 19, "L": -0.2703, "M": 10.4393, "S": 0.12315, "p3": 8.3, "p50": 10.4, "p97": 13.3},
{"month": 20, "L": -0.2762, "M": 10.6464, "S": 0.12323, "p3": 8.5, "p50": 10.6, "p97": 13.5},
{"month": 21, "L": -0.2815, "M": 10.8534, "S": 0.12335, "p3": 8.7, "p50": 10.9, "p97": 13.8},
{"month": 22, "L": -0.2862, "M": 11.0608, "S": 0.12350, "p3": 8.8, "p50": 11.1, "p97": 14.1},
{"month": 23, "L": -0.2903, "M": 11.2688, "S": 0.12369, "p3": 9.0, "p50": 11.3, "p97": 14.3},
{"month": 24, "L": -0.2941, "M": 11.4775, "S": 0.12390, "p3": 9.2, "p50": 11.5, "p97": 14.6},
]
WHO_GIRLS_HEIGHT = [
{"month": 0, "L": 1.0, "M": 49.1477, "S": 0.03790, "p3": 45.6, "p50": 49.1, "p97": 52.7},
{"month": 1, "L": 1.0, "M": 53.6872, "S": 0.03640, "p3": 50.0, "p50": 53.7, "p97": 57.4},
{"month": 2, "L": 1.0, "M": 57.0673, "S": 0.03568, "p3": 53.2, "p50": 57.1, "p97": 60.9},
{"month": 3, "L": 1.0, "M": 59.8029, "S": 0.03520, "p3": 55.8, "p50": 59.8, "p97": 63.8},
{"month": 4, "L": 1.0, "M": 62.0899, "S": 0.03486, "p3": 58.0, "p50": 62.1, "p97": 66.2},
{"month": 5, "L": 1.0, "M": 64.0301, "S": 0.03463, "p3": 59.9, "p50": 64.0, "p97": 68.2},
{"month": 6, "L": 1.0, "M": 65.7311, "S": 0.03448, "p3": 61.5, "p50": 65.7, "p97": 70.0},
{"month": 7, "L": 1.0, "M": 67.2873, "S": 0.03441, "p3": 62.9, "p50": 67.3, "p97": 71.6},
{"month": 8, "L": 1.0, "M": 68.7498, "S": 0.03440, "p3": 64.3, "p50": 68.7, "p97": 73.2},
{"month": 9, "L": 1.0, "M": 70.1435, "S": 0.03444, "p3": 65.6, "p50": 70.1, "p97": 74.7},
{"month": 10, "L": 1.0, "M": 71.4818, "S": 0.03452, "p3": 66.8, "p50": 71.5, "p97": 76.1},
{"month": 11, "L": 1.0, "M": 72.7710, "S": 0.03464, "p3": 68.0, "p50": 72.8, "p97": 77.5},
{"month": 12, "L": 1.0, "M": 74.0150, "S": 0.03479, "p3": 69.2, "p50": 74.0, "p97": 78.9},
{"month": 13, "L": 1.0, "M": 75.2176, "S": 0.03496, "p3": 70.3, "p50": 75.2, "p97": 80.2},
{"month": 14, "L": 1.0, "M": 76.3817, "S": 0.03514, "p3": 71.3, "p50": 76.4, "p97": 81.4},
{"month": 15, "L": 1.0, "M": 77.5099, "S": 0.03534, "p3": 72.4, "p50": 77.5, "p97": 82.7},
{"month": 16, "L": 1.0, "M": 78.6055, "S": 0.03555, "p3": 73.3, "p50": 78.6, "p97": 83.9},
{"month": 17, "L": 1.0, "M": 79.6710, "S": 0.03576, "p3": 74.3, "p50": 79.7, "p97": 85.0},
{"month": 18, "L": 1.0, "M": 80.7079, "S": 0.03598, "p3": 75.2, "p50": 80.7, "p97": 86.2},
{"month": 19, "L": 1.0, "M": 81.7182, "S": 0.03620, "p3": 76.2, "p50": 81.7, "p97": 87.3},
{"month": 20, "L": 1.0, "M": 82.7036, "S": 0.03643, "p3": 77.0, "p50": 82.7, "p97": 88.4},
{"month": 21, "L": 1.0, "M": 83.6654, "S": 0.03666, "p3": 77.9, "p50": 83.7, "p97": 89.4},
{"month": 22, "L": 1.0, "M": 84.6040, "S": 0.03688, "p3": 78.7, "p50": 84.6, "p97": 90.5},
{"month": 23, "L": 1.0, "M": 85.5202, "S": 0.03711, "p3": 79.6, "p50": 85.5, "p97": 91.5},
{"month": 24, "L": 1.0, "M": 86.4153, "S": 0.03734, "p3": 80.3, "p50": 86.4, "p97": 92.5},
]
def _interpolate_lms(table: List[Dict[str, float]], age_months: float) -> Tuple[float, float, float, float, float, float]:
"""Linearly interpolates L, M, S, p3, p50, p97 for a given age in months."""
m_clamped = max(0.0, min(float(table[-1]["month"]), float(age_months)))
m_low = int(math.floor(m_clamped))
m_high = int(math.ceil(m_clamped))
r_low = table[m_low]
if m_low == m_high:
return r_low["L"], r_low["M"], r_low["S"], r_low["p3"], r_low["p50"], r_low["p97"]
r_high = table[m_high]
frac = m_clamped - m_low
L = r_low["L"] + frac * (r_high["L"] - r_low["L"])
M = r_low["M"] + frac * (r_high["M"] - r_low["M"])
S = r_low["S"] + frac * (r_high["S"] - r_low["S"])
p3 = r_low["p3"] + frac * (r_high["p3"] - r_low["p3"])
p50 = r_low["p50"] + frac * (r_high["p50"] - r_low["p50"])
p97 = r_low["p97"] + frac * (r_high["p97"] - r_low["p97"])
return L, M, S, p3, p50, p97
def calculate_percentile(metric: str, value: float, age_months: float) -> Dict[str, Any]:
"""
Computes exact WHO z-score and percentile using the LMS formula.
metric: 'weight' (in kg) or 'height' (in cm)
"""
table = WHO_GIRLS_WEIGHT if metric == "weight" else WHO_GIRLS_HEIGHT
L, M, S, p3, p50, p97 = _interpolate_lms(table, age_months)
if value <= 0 or M <= 0 or S <= 0:
return {
"value": value,
"age_months": round(age_months, 2),
"z_score": 0.0,
"percentile": 50.0,
"percentile_str": "P50",
"p3": round(p3, 2),
"p50": round(p50, 2),
"p97": round(p97, 2),
"in_normal_range": True,
"status": "normal",
}
# Box-Cox power exponential LMS formula
if abs(L) > 1e-6:
z = ((value / M) ** L - 1.0) / (L * S)
else:
z = math.log(value / M) / S
# Cumulative standard normal distribution
p = 0.5 * (1.0 + math.erf(z / math.sqrt(2.0))) * 100.0
p_clamped = max(0.1, min(99.9, p))
in_normal = 3.0 <= p_clamped <= 97.0
if p_clamped < 3.0:
status = "unter P3"
elif p_clamped > 97.0:
status = "über P97"
else:
status = "im WHO-Normbereich"
return {
"value": value,
"age_months": round(age_months, 2),
"z_score": round(z, 2),
"percentile": round(p_clamped, 1),
"percentile_str": f"P{int(round(p_clamped))}",
"p3": round(p3, 2),
"p50": round(p50, 2),
"p97": round(p97, 2),
"in_normal_range": in_normal,
"status": status,
}
def format_growth_feedback(
weight_kg: Optional[float],
height_cm: Optional[float],
age_months: float,
date_str: str,
) -> str:
"""Formats clear, comforting feedback for CLI and Hebamme agent responses."""
parts = []
if weight_kg is not None:
pw = calculate_percentile("weight", weight_kg, age_months)
parts.append(f"{weight_kg:.2f} kg ({pw['percentile_str']} · WHO Median: {pw['p50']:.1f} kg)")
if height_cm is not None:
ph = calculate_percentile("height", height_cm, age_months)
parts.append(f"{height_cm:.1f} cm ({ph['percentile_str']} · WHO Median: {ph['p50']:.1f} cm)")
if not parts:
return f"📏 Messung ({date_str}) ohne Werte geloggt."
details = " · ".join(parts)
return f"📏 Wachstum ({date_str}, ~{age_months:.1f} Mon): {details} [Normbereich: P3–P97]"