""" Body interpretation tiles for Layer 2b (Verlauf UI). Logic aligned with frontend/src/utils/interpret.js (Körper-Kontext). Uses the same thresholds; outputs structured tiles + related_placeholder_keys for alignment with Layer 2a registry keys. No formatting for KI — structured dicts only. """ from __future__ import annotations from datetime import date, datetime from typing import Any, Dict, List, Optional def _safe_float(v: Any) -> Optional[float]: if v is None: return None try: return round(float(v), 4) except (TypeError, ValueError): return None def _calc_derived(m: Dict, height_cm: float) -> Dict[str, float]: out: Dict[str, float] = {} w = _safe_float(m.get("c_waist")) h = _safe_float(m.get("c_hip")) lean = _safe_float(m.get("lean_mass")) if w and h: out["whr"] = round(w / h, 2) if w and height_cm: out["whtr"] = round(w / height_cm, 2) if lean and height_cm: hm = height_cm / 100.0 out["ffmi"] = round(lean / (hm ** 2), 1) return out def _bf_status_ranges(sex: str) -> Dict[str, float]: if sex == "f": return {"essential": 14, "athletic": 21, "fit": 25, "avg": 32} return {"essential": 6, "athletic": 14, "fit": 18, "avg": 25} def get_body_interpretation_tiles( measurement: Dict[str, Any], profile: Dict[str, Any], prev_measurement: Optional[Dict[str, Any]] = None, ) -> List[Dict[str, Any]]: """ Returns interpretation tiles. Each tile includes related_placeholder_keys pointing to Layer 2a registry keys fed by the same Layer-1 metrics. """ results: List[Dict[str, Any]] = [] sex = profile.get("sex") or "m" height = _safe_float(profile.get("height")) or 178.0 m = measurement derived = _calc_derived(m, height) # ── Körperfett ────────────────────────────────────────────────────────── bf = _safe_float(m.get("body_fat_pct")) if bf is not None: ranges = _bf_status_ranges(sex) if bf <= ranges["essential"]: msg = "Sehr niedriger Körperfettanteil" detail = ( "Essenzielle Fettwerte – nur für Leistungssportler geeignet, " "auf Dauer nicht empfehlenswert." ) status = "warn" elif bf <= ranges["athletic"]: msg = "Athletischer Körperfettanteil" detail = "Ausgezeichnet. Typisch für aktive Sportler mit hohem Trainingsvolumen." status = "good" elif bf <= ranges["fit"]: msg = "Guter Körperfettanteil" detail = "Sehr gute Fitness-Kategorie. Gesund und gut in Form." status = "good" elif bf <= ranges["avg"]: msg = "Durchschnittlicher Körperfettanteil" detail = ( "Im normalen Bereich. Verbesserung durch Kombination aus Kraft- " "und Ausdauertraining möglich." ) status = "warn" else: msg = "Erhöhter Körperfettanteil" detail = ( "Über dem empfohlenen Bereich. Ernährungsumstellung und " "regelmäßiges Training empfohlen." ) status = "bad" results.append( { "category": "Körperfett", "icon": "🫧", "status": status, "title": msg, "detail": detail, "value": f"{bf}%", "related_placeholder_keys": ["caliper_summary", "fm_28d_change"], } ) # ── WHR ───────────────────────────────────────────────────────────────── whr = derived.get("whr") if whr is not None: limit = 0.90 if sex == "m" else 0.85 limit_high = 1.0 if sex == "m" else 0.95 if whr < limit: status = "good" title = "Günstige Fettverteilung" detail = ( f"Dein WHR von {whr} liegt unter dem Grenzwert ({limit}). " "Birnenförmige Fettverteilung – metabolisch günstig." ) elif whr < limit_high: status = "warn" title = "Grenzwertiger WHR" detail = ( f"Dein WHR von {whr} liegt leicht über dem Zielwert ({limit}). " "Apfelförmige Tendenz – Bauchfett reduzieren empfohlen." ) else: status = "bad" title = "Erhöhtes Risiko durch Fettverteilung" detail = ( f"WHR von {whr} deutlich über dem Grenzwert. Erhöhtes " "kardiovaskuläres Risiko durch viszerales Fett." ) results.append( { "category": "Fettverteilung", "icon": "📐", "status": status, "title": title, "detail": detail, "value": str(whr), "related_placeholder_keys": ["waist_hip_ratio", "circ_summary"], } ) # ── WHtR ──────────────────────────────────────────────────────────────── whtr = derived.get("whtr") if whtr is not None: if whtr < 0.40: status = "warn" title = "Sehr schlanke Taille" detail = f"WHtR {whtr} – möglicherweise zu wenig Körpermasse." elif whtr < 0.50: status = "good" title = "Optimale Taillen-Größen-Relation" detail = ( f"WHtR {whtr} – im optimalen Bereich. Geringstes kardiovaskuläres Risiko." ) elif whtr < 0.60: status = "warn" title = "Leicht erhöhter WHtR" detail = f"WHtR {whtr} – Ziel ist unter 0,50. Moderat erhöhtes Risiko." else: status = "bad" title = "Stark erhöhter WHtR" detail = ( f"WHtR {whtr} – deutlich erhöhtes Risiko. Taille sollte weniger " "als die Hälfte der Körpergröße betragen." ) results.append( { "category": "Taille/Größe", "icon": "📏", "status": status, "title": title, "detail": detail, "value": str(whtr), "related_placeholder_keys": ["circ_summary", "waist_28d_delta"], } ) # ── FFMI ───────────────────────────────────────────────────────────────── ffmi = derived.get("ffmi") if ffmi is not None: natural_limit = 25.0 if sex == "m" else 22.0 if ffmi < (18.0 if sex == "m" else 15.0): status = "warn" title = "Unterdurchschnittliche Muskelmasse" detail = ( f"FFMI {ffmi} – Krafttraining kann die Muskelmasse und den " "Grundumsatz deutlich verbessern." ) elif ffmi < (22.0 if sex == "m" else 19.0): status = "good" title = "Durchschnittliche Muskelmasse" detail = f"FFMI {ffmi} – gute Basis. Mit regelmäßigem Krafttraining weiter ausbaubar." elif ffmi <= natural_limit: status = "good" title = "Überdurchschnittliche Muskelmasse" detail = f"FFMI {ffmi} – sehr gut. Oberes natürliches Spektrum für Kraftsportler." else: status = "warn" title = "Außergewöhnlich hohe Muskelmasse" detail = ( f"FFMI {ffmi} – oberhalb der natürlichen Grenze (~{natural_limit}). " "Selten ohne unterstützende Mittel erreichbar." ) results.append( { "category": "Muskelmasse", "icon": "💪", "status": status, "title": title, "detail": detail, "value": str(ffmi), "related_placeholder_keys": ["lbm_28d_change", "caliper_summary"], } ) # ── BMI ─────────────────────────────────────────────────────────────────── w_kg = _safe_float(m.get("weight")) if w_kg is not None and height > 0: bmi = round(w_kg / ((height / 100.0) ** 2), 1) if bmi < 18.5: status = "warn" title = "Untergewicht (BMI)" detail = f"BMI {bmi} – unter 18,5. Auf ausreichende Kalorienzufuhr und Nährstoffversorgung achten." elif bmi < 25: status = "good" title = "Normalgewicht (BMI)" detail = f"BMI {bmi} – im optimalen Bereich (18,5–24,9)." elif bmi < 30: status = "warn" title = "Übergewicht (BMI)" detail = ( f"BMI {bmi} – leichtes Übergewicht. BMI allein ist wenig aussagekräftig " "bei Muskelmasse – Körperfett-% beachten." ) else: status = "bad" title = "Adipositas (BMI)" detail = f"BMI {bmi} – deutliches Übergewicht. Ärztliche Beratung empfohlen." results.append( { "category": "BMI", "icon": "⚖️", "status": status, "title": title, "detail": detail, "value": str(bmi), "related_placeholder_keys": ["bmi", "weight_aktuell"], } ) # ── Vergleich zur letzten Messung (Caliper) ─────────────────────────────── if prev_measurement: p = prev_measurement m_date = m.get("date") p_date = p.get("date") days = 0 if m_date and p_date: if isinstance(m_date, str): m_date = datetime.fromisoformat(m_date[:10]).date() if isinstance(p_date, str): p_date = datetime.fromisoformat(p_date[:10]).date() if isinstance(m_date, date) and isinstance(p_date, date): days = (m_date - p_date).days changes: List[Dict[str, Any]] = [] if m.get("body_fat_pct") is not None and p.get("body_fat_pct") is not None: diff = round(float(m["body_fat_pct"]) - float(p["body_fat_pct"]), 1) if abs(diff) >= 0.3: changes.append({"label": "Körperfett", "diff": diff, "unit": "%", "invert": True}) if m.get("weight") is not None and p.get("weight") is not None: diff = round(float(m["weight"]) - float(p["weight"]), 1) if abs(diff) >= 0.2: changes.append({"label": "Gewicht", "diff": diff, "unit": "kg", "invert": True}) if m.get("lean_mass") is not None and p.get("lean_mass") is not None: diff = round(float(m["lean_mass"]) - float(p["lean_mass"]), 1) if abs(diff) >= 0.2: changes.append({"label": "Magermasse", "diff": diff, "unit": "kg", "invert": False}) if m.get("c_waist") is not None and p.get("c_waist") is not None: diff = round(float(m["c_waist"]) - float(p["c_waist"]), 1) if abs(diff) >= 0.5: changes.append({"label": "Taille", "diff": diff, "unit": "cm", "invert": True}) if m.get("c_belly") is not None and p.get("c_belly") is not None: diff = round(float(m["c_belly"]) - float(p["c_belly"]), 1) if abs(diff) >= 0.5: changes.append({"label": "Bauch", "diff": diff, "unit": "cm", "invert": True}) if changes: positive = [c for c in changes if (c["diff"] < 0 if c["invert"] else c["diff"] > 0)] negative = [c for c in changes if (c["diff"] > 0 if c["invert"] else c["diff"] < 0)] detail_parts = [] for c in changes: sign = "+" if c["diff"] > 0 else "" good = (c["diff"] < 0) if c["invert"] else (c["diff"] > 0) detail_parts.append( f"{c['label']}: {sign}{c['diff']} {c['unit']} {'✓' if good else '↑'}" ) detail = " · ".join(detail_parts) if len(positive) > len(negative): st = "good" title = "Positive Entwicklung seit letzter Messung" elif len(negative) > len(positive): st = "warn" title = "Verschlechterung seit letzter Messung" else: st = "warn" title = "Gemischte Entwicklung seit letzter Messung" results.append( { "category": f"Seit letzter Messung ({days} Tage)", "icon": "📊", "status": st, "title": title, "detail": detail, "value": f"{days}d", "related_placeholder_keys": [ "caliper_summary", "weight_trend", "lbm_28d_change", "waist_28d_delta", ], } ) return results