- Refactored the `calculate_lag_correlation` function to normalize lag payloads and improve correlation calculations for various nutrition metrics. - Introduced a new function `build_nutrition_correlation_heuristic_items` to generate heuristic insights based on merged nutrition data, enhancing user understanding of dietary impacts on weight and body composition. - Updated the `get_nutrition_history_viz_bundle` function to include daily calorie balance and protein vs. lean mass data, providing a comprehensive view of nutrition trends. - Enhanced the frontend to visualize calorie balance and protein vs. lean mass insights, improving the user experience with clear graphical representations of dietary correlations.
324 lines
12 KiB
Python
324 lines
12 KiB
Python
"""
|
||
Interpretation + KPI-Kacheln für Layer 2b Ernährungs-Verlauf.
|
||
|
||
Gleiche Schwellen wie zuvor im Frontend (History.jsx); Ausgabe strukturiert
|
||
für KpiTilesOverview (keys = related_placeholder_keys).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
|
||
def _verdict(status: str) -> str:
|
||
if status == "good":
|
||
return "Gut"
|
||
if status == "warn":
|
||
return "Hinweis"
|
||
return "Achtung"
|
||
|
||
|
||
def build_nutrition_history_kpi_tiles(
|
||
navg: Dict[str, Any],
|
||
targets: Dict[str, Any],
|
||
date_span_label: str,
|
||
n_days_with_entries: int,
|
||
) -> List[Dict[str, Any]]:
|
||
"""
|
||
KPI-Kacheln wie buildNutritionKpiTiles im Frontend (Kalorien/KH/Fett + Regeln).
|
||
"""
|
||
kcal_avg = round(float(navg.get("kcal_avg") or 0))
|
||
avg_carbs = round(float(navg.get("carbs_avg") or 0) * 10) / 10
|
||
avg_fat = round(float(navg.get("fat_avg") or 0) * 10) / 10
|
||
avg_protein = round(float(navg.get("protein_avg") or 0) * 10) / 10
|
||
|
||
pt_low = round(float(targets.get("protein_target_low") or 0))
|
||
pt_high = round(float(targets.get("protein_target_high") or 0))
|
||
targets_ok = targets.get("confidence") != "insufficient" and pt_low > 0
|
||
protein_ok = targets_ok and avg_protein >= pt_low
|
||
|
||
total_macro_kcal = avg_protein * 4 + avg_carbs * 4 + avg_fat * 9
|
||
prot_pct = (
|
||
round(avg_protein * 4 / total_macro_kcal * 100)
|
||
if total_macro_kcal > 0
|
||
else 0
|
||
)
|
||
kh_pct = (
|
||
round(avg_carbs * 4 / total_macro_kcal * 100)
|
||
if total_macro_kcal > 0
|
||
else 0
|
||
)
|
||
fat_pct = (
|
||
round(avg_fat * 9 / total_macro_kcal * 100)
|
||
if total_macro_kcal > 0
|
||
else 0
|
||
)
|
||
|
||
tiles: List[Dict[str, Any]] = [
|
||
{
|
||
"key": "kcal",
|
||
"category": "Kalorien (Ø)",
|
||
"icon": "🔥",
|
||
"value": f"{kcal_avg} kcal",
|
||
"sublabel": date_span_label,
|
||
"status": "good",
|
||
"verdict": "Gut",
|
||
"hoverTop": "Durchschnittliche tägliche Energie",
|
||
"hoverBody": f"Mittel über {n_days_with_entries} Tage mit Ernährungseinträgen im gewählten Zeitraum.",
|
||
"keys": ["nutrition_score"],
|
||
},
|
||
{
|
||
"key": "carbs",
|
||
"category": "KH (Ø)",
|
||
"icon": "🌾",
|
||
"value": f"{avg_carbs} g",
|
||
"sublabel": "Kohlenhydrate / Tag",
|
||
"status": "good",
|
||
"verdict": "Gut",
|
||
"hoverTop": "Durchschnittliche Kohlenhydrate",
|
||
"hoverBody": "Summe der täglichen Werte im Zeitraum, gemittelt.",
|
||
"keys": ["nutrition_summary"],
|
||
},
|
||
{
|
||
"key": "fat",
|
||
"category": "Fett (Ø)",
|
||
"icon": "🧈",
|
||
"value": f"{avg_fat} g",
|
||
"sublabel": "Fett / Tag",
|
||
"status": "good",
|
||
"verdict": "Gut",
|
||
"hoverTop": "Durchschnittliches Fett",
|
||
"hoverBody": "Summe der täglichen Werte im Zeitraum, gemittelt.",
|
||
"keys": ["nutrition_summary"],
|
||
},
|
||
]
|
||
|
||
if not targets_ok:
|
||
tiles.append(
|
||
{
|
||
"key": "eval-protein",
|
||
"category": "Protein",
|
||
"icon": "🥩",
|
||
"value": f"{avg_protein}g",
|
||
"sublabel": "Referenzgewicht fehlt",
|
||
"status": "warn",
|
||
"verdict": _verdict("warn"),
|
||
"hint": "Ohne aktuelles Körpergewicht lässt sich das Protein-Ziel (g/kg) nicht bewerten.",
|
||
"hoverTop": "Protein-Ziel nicht berechenbar",
|
||
"hoverBody": "Für 1,6–2,2 g/kg wird ein aktuelles Körpergewicht benötigt.",
|
||
"keys": ["protein_adequacy"],
|
||
}
|
||
)
|
||
elif not protein_ok:
|
||
miss = max(0, pt_low - round(avg_protein))
|
||
tiles.append(
|
||
{
|
||
"key": "eval-protein",
|
||
"category": "Protein",
|
||
"icon": "🥩",
|
||
"value": f"{avg_protein}g",
|
||
"sublabel": f"Unterversorgung: {avg_protein}g/Tag (Ziel {pt_low}–{pt_high}g)",
|
||
"status": "bad",
|
||
"verdict": _verdict("bad"),
|
||
"hint": (
|
||
f"~{miss} g Protein/Tag fehlen – bei Defizit Muskelerhalt gefährdet."
|
||
),
|
||
"hoverTop": f"Unterversorgung: {avg_protein}g/Tag (Ziel {pt_low}–{pt_high}g)",
|
||
"hoverBody": (
|
||
f"1,6–2,2g/kg KG. Fehlend: ~{miss}g täglich. "
|
||
"Konsequenz: Muskelverlust bei Defizit."
|
||
),
|
||
"keys": ["protein_adequacy", "nutrition_score"],
|
||
}
|
||
)
|
||
else:
|
||
tiles.append(
|
||
{
|
||
"key": "eval-protein",
|
||
"category": "Protein",
|
||
"icon": "🥩",
|
||
"value": f"{avg_protein}g",
|
||
"sublabel": f"Gut: {avg_protein}g/Tag (Ziel {pt_low}–{pt_high}g)",
|
||
"status": "good",
|
||
"verdict": _verdict("good"),
|
||
"hoverTop": f"Gut: {avg_protein}g/Tag (Ziel {pt_low}–{pt_high}g)",
|
||
"hoverBody": "Ausreichend für Muskelerhalt und -aufbau.",
|
||
"keys": ["protein_adequacy", "nutrition_score"],
|
||
}
|
||
)
|
||
|
||
if prot_pct < 20 and total_macro_kcal > 0:
|
||
tiles.append(
|
||
{
|
||
"key": "eval-macro-pct",
|
||
"category": "Makro-Anteil",
|
||
"icon": "📊",
|
||
"value": f"{prot_pct}%",
|
||
"sublabel": f"Protein-Anteil niedrig: {prot_pct}% der Kalorien",
|
||
"status": "warn",
|
||
"verdict": _verdict("warn"),
|
||
"hint": (
|
||
f"Protein-Kalorienanteil niedrig (P {prot_pct} % / KH {kh_pct} % / F {fat_pct} %); "
|
||
"Ziel oft 25–35 %."
|
||
),
|
||
"hoverTop": f"Protein-Anteil niedrig: {prot_pct}% der Kalorien",
|
||
"hoverBody": (
|
||
f"Empfehlung oft 25–35%. Aktuell: {prot_pct}% P / {kh_pct}% KH / {fat_pct}% F"
|
||
),
|
||
"keys": ["nutrition_summary"],
|
||
}
|
||
)
|
||
|
||
return tiles
|
||
|
||
|
||
def build_energy_availability_kpi_tile(ea: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||
"""E5: nur bei caution/warning — gleiche Daten wie /charts/energy-availability-warning."""
|
||
level = str(ea.get("warning_level") or "none").strip().lower()
|
||
if level == "none":
|
||
return None
|
||
triggers: List[str] = list(ea.get("triggers") or [])
|
||
msg = str(ea.get("message") or "").strip()
|
||
st = "bad" if level == "warning" else "warn"
|
||
first = triggers[0] if triggers else msg
|
||
if len(first) > 90:
|
||
first = first[:87] + "…"
|
||
meta = ea.get("metadata") if isinstance(ea.get("metadata"), dict) else {}
|
||
note = str(meta.get("note") or "")
|
||
hover_lines = [msg] + [f"• {t}" for t in triggers]
|
||
if note:
|
||
hover_lines.append(note)
|
||
return {
|
||
"key": "energy-availability-e5",
|
||
"category": "Energieverfügbarkeit",
|
||
"icon": "⚡",
|
||
"value": "Achtung" if level == "warning" else "Hinweis",
|
||
"sublabel": first or "Signale prüfen",
|
||
"status": st,
|
||
"verdict": _verdict(st),
|
||
"hint": msg,
|
||
"hoverTop": "Energieverfügbarkeit (Heuristik)",
|
||
"hoverBody": "\n".join(hover_lines),
|
||
"keys": ["nutrition_score"],
|
||
}
|
||
|
||
|
||
def build_macro_donut_from_averages(navg: Dict[str, Any]) -> Optional[List[Dict[str, Any]]]:
|
||
"""Anteile in % der Makro-kcal + Gramm für Legende."""
|
||
p = float(navg.get("protein_avg") or 0)
|
||
c = float(navg.get("carbs_avg") or 0)
|
||
f = float(navg.get("fat_avg") or 0)
|
||
pkcal, ckcal, fkcal = p * 4, c * 4, f * 9
|
||
tot = pkcal + ckcal + fkcal
|
||
if tot <= 0:
|
||
return None
|
||
return [
|
||
{"name": "Protein", "value": round(pkcal / tot * 100), "color": "#4a8f72", "grams": round(p, 1)},
|
||
{"name": "KH", "value": round(ckcal / tot * 100), "color": "#c17d45", "grams": round(c, 1)},
|
||
{"name": "Fett", "value": round(fkcal / tot * 100), "color": "#6e8eb8", "grams": round(f, 1)},
|
||
]
|
||
|
||
|
||
def build_nutrition_correlation_heuristic_items(
|
||
merged_rows: List[Dict[str, Any]],
|
||
tdee_kcal: float,
|
||
protein_target_low_g: float,
|
||
) -> List[Dict[str, Any]]:
|
||
"""
|
||
Heuristische Kurz-Aussagen (vormals Reiter «Korrelation») — gleiche Logik wie History.jsx,
|
||
TDEE aber aus Data-Layer (nutrition_metrics / estimate_tdee), nicht ×1,4 im Frontend.
|
||
"""
|
||
filtered = [
|
||
r
|
||
for r in merged_rows
|
||
if r.get("kcal") is not None and r.get("weight") is not None
|
||
]
|
||
if len(filtered) < 5:
|
||
return []
|
||
|
||
td = float(tdee_kcal)
|
||
latest_w = float(filtered[-1].get("weight") or 0) or 80.0
|
||
pt_low = round(float(protein_target_low_g or 0)) or max(1, round(latest_w * 1.6))
|
||
|
||
items: List[Dict[str, Any]] = []
|
||
|
||
if len(filtered) >= 14:
|
||
high_k = [d for d in filtered if float(d.get("kcal") or 0) > td + 200]
|
||
low_k = [d for d in filtered if float(d.get("kcal") or 0) < td - 200]
|
||
if len(high_k) >= 3 and len(low_k) >= 3:
|
||
avg_wh = sum(float(d["weight"]) for d in high_k) / len(high_k)
|
||
avg_wl = sum(float(d["weight"]) for d in low_k) / len(low_k)
|
||
avg_wh_r = round(avg_wh * 10) / 10
|
||
avg_wl_r = round(avg_wl * 10) / 10
|
||
items.append(
|
||
{
|
||
"icon": "📊",
|
||
"status": "good" if avg_wl < avg_wh else "warn",
|
||
"title": (
|
||
f"Kalorienreduktion wirkt: Ø {avg_wl_r} kg bei Defizit vs. {avg_wh_r} kg bei Überschuss"
|
||
if avg_wl < avg_wh
|
||
else "Kein klarer Kalorieneffekt auf Gewicht erkennbar"
|
||
),
|
||
"detail": (
|
||
f"Tage mit Überschuss (>{int(td + 200)} kcal): Ø {avg_wh_r} kg · "
|
||
f"Tage mit Defizit (<{int(td - 200)} kcal): Ø {avg_wl_r} kg"
|
||
),
|
||
}
|
||
)
|
||
|
||
prot_vs_lean = [
|
||
d
|
||
for d in filtered
|
||
if d.get("protein_g") is not None and d.get("lean_mass") is not None
|
||
]
|
||
if len(prot_vs_lean) >= 3:
|
||
high_p = [d for d in prot_vs_lean if float(d.get("protein_g") or 0) >= pt_low]
|
||
low_p = [d for d in prot_vs_lean if float(d.get("protein_g") or 0) < pt_low]
|
||
if len(high_p) >= 2 and len(low_p) >= 2:
|
||
avg_lh = sum(float(d["lean_mass"]) for d in high_p) / len(high_p)
|
||
avg_ll = sum(float(d["lean_mass"]) for d in low_p) / len(low_p)
|
||
avg_lh_r = round(avg_lh * 10) / 10
|
||
avg_ll_r = round(avg_ll * 10) / 10
|
||
items.append(
|
||
{
|
||
"icon": "🥩",
|
||
"status": "good" if avg_lh >= avg_ll else "warn",
|
||
"title": (
|
||
f"Hohe Proteinzufuhr (≥{pt_low} g): Ø {avg_lh_r} kg Mager · Niedrig: Ø {avg_ll_r} kg"
|
||
),
|
||
"detail": (
|
||
f"{len(high_p)} Messpunkte mit hoher vs. {len(low_p)} mit niedriger Proteinzufuhr verglichen."
|
||
),
|
||
}
|
||
)
|
||
|
||
balances = [float(d["kcal"]) - td for d in filtered if d.get("kcal") is not None]
|
||
avg_balance = int(round(sum(balances) / len(balances))) if balances else 0
|
||
ab_s = f"{avg_balance:+d}" if avg_balance > 0 else str(avg_balance)
|
||
if avg_balance < -100:
|
||
ic, st = "✅", "good"
|
||
elif avg_balance > 200:
|
||
ic, st = "⬆️", "warn" if avg_balance > 300 else "good"
|
||
else:
|
||
ic, st = "➡️", "good"
|
||
|
||
if avg_balance < -500:
|
||
bal_detail = "Starkes Defizit – Muskelerhalt durch ausreichend Protein sicherstellen."
|
||
elif avg_balance < -100:
|
||
bal_detail = "Moderates Defizit – ideal für Fettabbau bei Muskelerhalt."
|
||
elif avg_balance > 300:
|
||
bal_detail = "Kalorienüberschuss – günstig für Muskelaufbau, Fettzunahme möglich."
|
||
else:
|
||
bal_detail = "Nahezu ausgeglichen – Gewicht sollte stabil bleiben."
|
||
|
||
items.append(
|
||
{
|
||
"icon": ic,
|
||
"status": st,
|
||
"title": f"Ø Kalorienbilanz: {ab_s} kcal/Tag",
|
||
"detail": f"Geschätzter TDEE: {int(round(td))} kcal (Data-Layer, konsistent mit Verlauf). {bal_detail}",
|
||
}
|
||
)
|
||
|
||
return items
|