diff --git a/skills/datastore/SKILL.md b/skills/datastore/SKILL.md index 94bb644..3a4f97a 100644 --- a/skills/datastore/SKILL.md +++ b/skills/datastore/SKILL.md @@ -26,6 +26,9 @@ The `datastore` skill is the source of truth for all structured, repetitive, and - Searching for beer gardens, playgrounds, stores, or farm stands within a given radius (e.g. 500m or 1km around Grabengasse 7) using native DuckDB spatial distance. 5. **Catalogs & Entities (`foods`, `recipes`, `places`, `people`):** - Upserting and reading reference data. +6. **Baby Growth & WHO Curves (`growth.measurement`):** + - Logging weight (`weight_kg`) and height/length (`height_cm`). + - Deterministic WHO child growth standards evaluation (P3, P50 Median, P97) based on exact age in months. --- @@ -139,6 +142,18 @@ uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/datastore.py pl # Nightly Garmin mirror sync uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/garmin_sync.py --days 2 +# Log Mila's weight and height measurement +uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/datastore.py add growth.measurement \ + --subject mila \ + --actor agent:hebamme \ + --payload '{"weight_kg": 7.4, "height_cm": 67.5}' + +# Retroactively log measurement with date +uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/datastore.py add growth.measurement \ + --subject mila \ + --date 2026-06-15 \ + --payload '{"weight_kg": 5.8, "height_cm": 60.0}' + # Render Markdown views & HTML dashboards PYTHONPATH=/home/node/mop-skills/mop-skills/skills/datastore/scripts \ uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/render_views.py diff --git a/skills/datastore/assets/favicon.ico b/skills/datastore/assets/favicon.ico index 004e97f..b5ca0ad 100644 Binary files a/skills/datastore/assets/favicon.ico and b/skills/datastore/assets/favicon.ico differ diff --git a/skills/datastore/assets/mila-apple-touch-icon.png b/skills/datastore/assets/mila-apple-touch-icon.png index cf8ee48..9671ff8 100644 Binary files a/skills/datastore/assets/mila-apple-touch-icon.png and b/skills/datastore/assets/mila-apple-touch-icon.png differ diff --git a/skills/datastore/assets/mila-icon-16.png b/skills/datastore/assets/mila-icon-16.png index 93c292e..4bf8af7 100644 Binary files a/skills/datastore/assets/mila-icon-16.png and b/skills/datastore/assets/mila-icon-16.png differ diff --git a/skills/datastore/assets/mila-icon-192.png b/skills/datastore/assets/mila-icon-192.png index a3393c4..9b1971b 100644 Binary files a/skills/datastore/assets/mila-icon-192.png and b/skills/datastore/assets/mila-icon-192.png differ diff --git a/skills/datastore/assets/mila-icon-32.png b/skills/datastore/assets/mila-icon-32.png index 71a1e56..9d47cf3 100644 Binary files a/skills/datastore/assets/mila-icon-32.png and b/skills/datastore/assets/mila-icon-32.png differ diff --git a/skills/datastore/assets/mila-icon-512.png b/skills/datastore/assets/mila-icon-512.png index 237a460..23a18c5 100644 Binary files a/skills/datastore/assets/mila-icon-512.png and b/skills/datastore/assets/mila-icon-512.png differ diff --git a/skills/datastore/assets/mila-icon.svg b/skills/datastore/assets/mila-icon.svg index 6723d26..a7fc212 100644 --- a/skills/datastore/assets/mila-icon.svg +++ b/skills/datastore/assets/mila-icon.svg @@ -1,3 +1,3 @@ - + diff --git a/skills/datastore/scripts/datastore.py b/skills/datastore/scripts/datastore.py index 9eef8e5..a99ad7f 100755 --- a/skills/datastore/scripts/datastore.py +++ b/skills/datastore/scripts/datastore.py @@ -30,6 +30,10 @@ from zoneinfo import ZoneInfo import duckdb +SCRIPT_DIR = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + logger = logging.getLogger("datastore") def generate_ulid() -> str: @@ -225,6 +229,34 @@ class DatastoreEngine: restingHeartRate INTEGER, raw JSON ); + + CREATE OR REPLACE VIEW v_growth AS + SELECT + id, + subject, + actor, + start, + start AT TIME ZONE 'Europe/Berlin' AS local_time, + CAST(start AT TIME ZONE 'Europe/Berlin' AS DATE) AS log_date, + CAST(json_extract(payload, '$.weight_kg') AS DOUBLE) AS weight_kg, + CAST(json_extract(payload, '$.height_cm') AS DOUBLE) AS height_cm, + CAST(json_extract(payload, '$.head_circ_cm') AS DOUBLE) AS head_circ_cm, + note, + created_at + FROM events + WHERE kind = 'growth.measurement' AND deleted_at IS NULL; + + CREATE OR REPLACE VIEW v_growth_daily AS + SELECT + subject, + log_date, + ROUND(AVG(weight_kg), 3) AS weight_kg, + ROUND(AVG(height_cm), 1) AS height_cm, + ROUND(AVG(head_circ_cm), 1) AS head_circ_cm, + COUNT(*) AS measurement_count + FROM v_growth + GROUP BY subject, log_date + ORDER BY log_date DESC; """) def _load_all_data(self): @@ -387,6 +419,13 @@ class DatastoreEngine: elif kind.startswith("sleep."): if p1.get("type") == p2.get("type"): return True + elif kind.startswith("growth."): + w1, w2 = p1.get("weight_kg"), p2.get("weight_kg") + h1, h2 = p1.get("height_cm"), p2.get("height_cm") + match_w = (w1 is not None and w2 is not None and abs(float(w1) - float(w2)) < 0.001) + match_h = (h1 is not None and h2 is not None and abs(float(h1) - float(h2)) < 0.001) + if match_w or match_h: + return True return False def _is_duplicate_event( @@ -870,6 +909,11 @@ class DatastoreEngine: if fee: res["feeding"] = fee[0] + # 6. Growth latest measurement + grw = self.query(f"SELECT * FROM v_growth WHERE subject = '{subject}' ORDER BY start DESC LIMIT 1") + if grw: + res["growth_latest"] = grw[0] + return res def find_places_near( @@ -952,6 +996,44 @@ class DatastoreEngine: "bottle_count": daily[0], "total_consumed_ml": daily[1], } + elif kind.startswith("growth"): + row = self.con.execute( + "SELECT weight_kg, height_cm, head_circ_cm FROM v_growth WHERE subject = ? AND log_date = ? ORDER BY start DESC LIMIT 1", + [subject, date_str], + ).fetchone() + if row: + w_kg, h_cm, hc_cm = row[0], row[1], row[2] + p_row = self.con.execute("SELECT birthdate FROM people WHERE id = ?", [subject]).fetchone() + bdate = p_row[0] if p_row and p_row[0] else datetime.date(2026, 3, 14) + if isinstance(bdate, str): + bdate = datetime.date.fromisoformat(bdate) + age_days = (target_date - bdate).days + age_months = max(0.0, age_days / 30.4375) + try: + from who_growth import format_growth_feedback, calculate_percentile + fb_text = format_growth_feedback(w_kg, h_cm, age_months, date_str) + fb_res: Dict[str, Any] = { + "day_total_formatted": fb_text, + "age_months": round(age_months, 2), + } + if w_kg is not None: + pw = calculate_percentile("weight", w_kg, age_months) + fb_res["weight_kg"] = w_kg + fb_res["weight_percentile"] = pw["percentile"] + fb_res["weight_percentile_str"] = pw["percentile_str"] + if h_cm is not None: + ph = calculate_percentile("height", h_cm, age_months) + fb_res["height_cm"] = h_cm + fb_res["height_percentile"] = ph["percentile"] + fb_res["height_percentile_str"] = ph["percentile_str"] + return fb_res + except Exception as e: + logger.warning(f"Error computing growth feedback: {e}") + return { + "day_total_formatted": f"Wachstum ({date_str}): Gewicht {w_kg} kg, Größe {h_cm} cm", + "weight_kg": w_kg, + "height_cm": h_cm, + } return {} # -------------------------------------------------------------------------- @@ -1013,11 +1095,11 @@ def run_mcp_server(): TOOLS = [ { "name": "datastore_add_event", - "description": "Log an event into the structured datastore (e.g. nutrition.intake, sleep.session, feeding.bottle). Computes all macros and totals deterministically.", + "description": "Log an event into the structured datastore (e.g. nutrition.intake, sleep.session, feeding.bottle, growth.measurement). Computes all macros, totals, and WHO percentiles deterministically.", "inputSchema": { "type": "object", "properties": { - "kind": {"type": "string", "description": "e.g. 'nutrition.intake', 'sleep.session', 'feeding.bottle'"}, + "kind": {"type": "string", "description": "e.g. 'nutrition.intake', 'sleep.session', 'feeding.bottle', 'growth.measurement'"}, "subject": {"type": "string", "description": "'moritz', 'amelie', or 'mila'"}, "actor": {"type": "string", "description": "'agent:koch', 'agent:hebamme', 'moritz', 'amelie'"}, "payload": {"type": "object", "description": "Detailed payload matching the kind schema"}, diff --git a/skills/datastore/scripts/who_growth.py b/skills/datastore/scripts/who_growth.py new file mode 100644 index 0000000..d9b1142 --- /dev/null +++ b/skills/datastore/scripts/who_growth.py @@ -0,0 +1,170 @@ +#!/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]" diff --git a/skills/datastore/tests/test_datastore.py b/skills/datastore/tests/test_datastore.py index b28bfdd..7ea239e 100644 --- a/skills/datastore/tests/test_datastore.py +++ b/skills/datastore/tests/test_datastore.py @@ -737,5 +737,51 @@ def test_feeding_logging(temp_datastore: DatastoreEngine): assert daily[0]["total_consumed_ml"] == 160.0 +def test_growth_logging(temp_datastore: DatastoreEngine): + """Verifies baby growth measurements, WHO percentile feedback, and DuckDB views.""" + # Ensure Mila is in people table with birthdate + temp_datastore.put_entity( + collection="people", + record={"id": "mila", "name": "Mila Graf", "birthdate": "2026-03-14", "role": "child"}, + ) + + # Log measurement at approx 6.9 months (2026-10-12) + res = temp_datastore.add_event( + kind="growth.measurement", + subject="mila", + actor="agent:hebamme", + start="2026-10-12T10:00:00+02:00", + payload={"weight_kg": 7.4, "height_cm": 67.5}, + note="U-Untersuchung Vorbereitung", + ) + assert res["status"] == "success" + fb = res["feedback"] + assert "weight_kg" in fb + assert fb["weight_kg"] == 7.4 + assert fb["height_cm"] == 67.5 + assert "weight_percentile" in fb + assert 30 <= fb["weight_percentile"] <= 60 # around P41 + assert 45 <= fb["height_percentile"] <= 70 # around P56 + assert "Normbereich" in fb["day_total_formatted"] + + # Verify DuckDB views + growth_rows = temp_datastore.query("SELECT * FROM v_growth WHERE subject = 'mila'") + assert len(growth_rows) == 1 + assert growth_rows[0]["weight_kg"] == 7.4 + assert growth_rows[0]["height_cm"] == 67.5 + assert growth_rows[0]["note"] == "U-Untersuchung Vorbereitung" + + daily_rows = temp_datastore.query("SELECT * FROM v_growth_daily WHERE subject = 'mila'") + assert len(daily_rows) == 1 + assert daily_rows[0]["measurement_count"] == 1 + + # Verify get_summary returns growth_latest + summary = temp_datastore.get_summary(subject="mila", date_str="2026-10-12") + assert "growth_latest" in summary + assert summary["growth_latest"]["weight_kg"] == 7.4 + assert summary["growth_latest"]["height_cm"] == 67.5 + + +