331 lines
13 KiB
Python
331 lines
13 KiB
Python
|
|
"""
|
|||
|
|
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
|