2026-04-19 17:20:24 +02:00
|
|
|
|
"""
|
|
|
|
|
|
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"),
|
2026-04-19 17:36:45 +02:00
|
|
|
|
"hint": "Ohne aktuelles Körpergewicht lässt sich das Protein-Ziel (g/kg) nicht bewerten.",
|
2026-04-19 17:20:24 +02:00
|
|
|
|
"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"),
|
2026-04-19 17:36:45 +02:00
|
|
|
|
"hint": (
|
2026-04-19 17:43:29 +02:00
|
|
|
|
f"~{miss} g Protein/Tag fehlen – bei Defizit Muskelerhalt gefährdet."
|
2026-04-19 17:36:45 +02:00
|
|
|
|
),
|
2026-04-19 17:20:24 +02:00
|
|
|
|
"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"),
|
2026-04-19 17:36:45 +02:00
|
|
|
|
"hint": (
|
2026-04-19 17:43:29 +02:00
|
|
|
|
f"Protein-Kalorienanteil niedrig (P {prot_pct} % / KH {kh_pct} % / F {fat_pct} %); "
|
|
|
|
|
|
"Ziel oft 25–35 %."
|
2026-04-19 17:36:45 +02:00
|
|
|
|
),
|
2026-04-19 17:20:24 +02:00
|
|
|
|
"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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-19 17:43:29 +02:00
|
|
|
|
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"],
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-19 17:20:24 +02:00
|
|
|
|
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 [
|
2026-04-19 17:28:41 +02:00
|
|
|
|
{"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)},
|
2026-04-19 17:20:24 +02:00
|
|
|
|
]
|
2026-04-20 13:45:28 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|