feat(pwa): optimize Mila app icons with safe-zone padding to prevent Android/iOS zoom crop
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 779 B After Width: | Height: | Size: 593 B |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 323 KiB After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 431 KiB After Width: | Height: | Size: 219 KiB |
@@ -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"},
|
||||
|
||||
@@ -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]"
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||