- Introduced a new API endpoint `/body-history-viz` to retrieve body history visualization data. - Updated the frontend to fetch and display body history data in the `BodySection` component. - Enhanced the `EvaluationTileGrid` to include related placeholder keys for improved data interpretation. - Refactored existing logic to streamline data handling and improve user experience.
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
|