mitai-jinkendo/backend/data_layer/body_interpretation.py
Lars 461c358dc2
All checks were successful
Deploy Development / deploy (push) Successful in 51s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
feat: add body history visualization endpoint and frontend integration
- 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.
2026-04-19 16:06:07 +02:00

331 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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,524,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