mitai-jinkendo/backend/data_layer/nutrition_interpretation.py
Lars d7304c1a44
All checks were successful
Deploy Development / deploy (push) Successful in 55s
Build Test / pytest-backend (push) Successful in 9s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
feat: implement energy availability warning and enhance nutrition visualization
- Added `get_energy_availability_warning_payload` function to assess energy availability and provide contextual warnings based on multiple health indicators.
- Integrated energy availability KPI tile into the nutrition history visualization, enhancing user insights on energy balance.
- Updated frontend components to conditionally display the energy availability warning, improving user experience and data interpretation.
- Refactored existing logic in `charts.py` to utilize the new energy availability functionality, streamlining data handling.
2026-04-19 17:43:29 +02:00

220 lines
8.0 KiB
Python
Raw 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.

"""
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,62,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,62,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 2535 %."
),
"hoverTop": f"Protein-Anteil niedrig: {prot_pct}% der Kalorien",
"hoverBody": (
f"Empfehlung oft 2535%. 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)},
]