284 lines
9.9 KiB
Python
284 lines
9.9 KiB
Python
|
|
"""
|
|||
|
|
Layer 2b: Ernährungs-Verlauf — ein Bundle für die UI (Issue #53).
|
|||
|
|
|
|||
|
|
Single Source: nutrition_metrics + dieselben Tabellen wie Ernährungs-Platzhalter.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
from datetime import date, datetime, timedelta
|
|||
|
|
from typing import Any, Dict, List, Optional
|
|||
|
|
|
|||
|
|
from db import get_db, get_cursor, r2d
|
|||
|
|
from data_layer.nutrition_interpretation import (
|
|||
|
|
build_macro_donut_from_averages,
|
|||
|
|
build_nutrition_history_kpi_tiles,
|
|||
|
|
)
|
|||
|
|
from data_layer.nutrition_metrics import (
|
|||
|
|
estimate_tdee_kcal_from_latest_weight,
|
|||
|
|
get_energy_balance_data,
|
|||
|
|
get_nutrition_average_data,
|
|||
|
|
get_protein_targets_data,
|
|||
|
|
get_weekly_macro_distribution_chart_data,
|
|||
|
|
)
|
|||
|
|
from data_layer.utils import safe_float
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _cutoff_sql(days: int) -> Optional[str]:
|
|||
|
|
if days >= 9999:
|
|||
|
|
return None
|
|||
|
|
return (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _iso(d: Any) -> Optional[str]:
|
|||
|
|
if d is None:
|
|||
|
|
return None
|
|||
|
|
if hasattr(d, "isoformat"):
|
|||
|
|
return d.isoformat()[:10]
|
|||
|
|
return str(d)[:10]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _rolling_avg(rows: List[Dict[str, Any]], key: str, window: int) -> List[Dict[str, Any]]:
|
|||
|
|
out: List[Dict[str, Any]] = []
|
|||
|
|
for i, d in enumerate(rows):
|
|||
|
|
sl = rows[max(0, i - window + 1) : i + 1]
|
|||
|
|
vals: List[float] = []
|
|||
|
|
for x in sl:
|
|||
|
|
v = safe_float(x.get(key))
|
|||
|
|
if v is not None:
|
|||
|
|
vals.append(v)
|
|||
|
|
if not vals:
|
|||
|
|
out.append({**d, f"{key}_avg": None})
|
|||
|
|
continue
|
|||
|
|
avg = round(sum(vals) / len(vals), 1)
|
|||
|
|
out.append({**d, f"{key}_avg": avg})
|
|||
|
|
return out
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _has_nutrition_entries(profile_id: str) -> bool:
|
|||
|
|
with get_db() as conn:
|
|||
|
|
cur = get_cursor(conn)
|
|||
|
|
cur.execute(
|
|||
|
|
"SELECT 1 FROM nutrition_log WHERE profile_id=%s LIMIT 1",
|
|||
|
|
(profile_id,),
|
|||
|
|
)
|
|||
|
|
return cur.fetchone() is not None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _last_nutrition_date(profile_id: str) -> Optional[str]:
|
|||
|
|
with get_db() as conn:
|
|||
|
|
cur = get_cursor(conn)
|
|||
|
|
cur.execute(
|
|||
|
|
"SELECT MAX(date) AS d FROM nutrition_log WHERE profile_id=%s",
|
|||
|
|
(profile_id,),
|
|||
|
|
)
|
|||
|
|
row = cur.fetchone()
|
|||
|
|
if not row or row["d"] is None:
|
|||
|
|
return None
|
|||
|
|
return _iso(row["d"])
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _fetch_daily_macro_totals(profile_id: str, cutoff: Optional[str]) -> List[Dict[str, Any]]:
|
|||
|
|
with get_db() as conn:
|
|||
|
|
cur = get_cursor(conn)
|
|||
|
|
if cutoff:
|
|||
|
|
cur.execute(
|
|||
|
|
"""SELECT date,
|
|||
|
|
COALESCE(SUM(kcal), 0)::float AS kcal,
|
|||
|
|
COALESCE(SUM(protein_g), 0)::float AS protein_g,
|
|||
|
|
COALESCE(SUM(carbs_g), 0)::float AS carbs_g,
|
|||
|
|
COALESCE(SUM(fat_g), 0)::float AS fat_g
|
|||
|
|
FROM nutrition_log
|
|||
|
|
WHERE profile_id=%s AND date >= %s
|
|||
|
|
GROUP BY date
|
|||
|
|
ORDER BY date ASC""",
|
|||
|
|
(profile_id, cutoff),
|
|||
|
|
)
|
|||
|
|
else:
|
|||
|
|
cur.execute(
|
|||
|
|
"""SELECT date,
|
|||
|
|
COALESCE(SUM(kcal), 0)::float AS kcal,
|
|||
|
|
COALESCE(SUM(protein_g), 0)::float AS protein_g,
|
|||
|
|
COALESCE(SUM(carbs_g), 0)::float AS carbs_g,
|
|||
|
|
COALESCE(SUM(fat_g), 0)::float AS fat_g
|
|||
|
|
FROM nutrition_log
|
|||
|
|
WHERE profile_id=%s
|
|||
|
|
GROUP BY date
|
|||
|
|
ORDER BY date ASC""",
|
|||
|
|
(profile_id,),
|
|||
|
|
)
|
|||
|
|
return [r2d(r) for r in cur.fetchall()]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _kcal_weight_points_for_window(
|
|||
|
|
profile_id: str, cutoff: Optional[str]
|
|||
|
|
) -> List[Dict[str, Any]]:
|
|||
|
|
"""Gemeinsame Tage: Tages-kcal vs. Gewicht; gleiche Idee wie /nutrition/correlations, gefiltert."""
|
|||
|
|
with get_db() as conn:
|
|||
|
|
cur = get_cursor(conn)
|
|||
|
|
if cutoff:
|
|||
|
|
cur.execute(
|
|||
|
|
"""SELECT date, SUM(kcal)::float AS kcal
|
|||
|
|
FROM nutrition_log
|
|||
|
|
WHERE profile_id=%s AND date >= %s AND kcal IS NOT NULL
|
|||
|
|
GROUP BY date""",
|
|||
|
|
(profile_id, cutoff),
|
|||
|
|
)
|
|||
|
|
else:
|
|||
|
|
cur.execute(
|
|||
|
|
"""SELECT date, SUM(kcal)::float AS kcal
|
|||
|
|
FROM nutrition_log
|
|||
|
|
WHERE profile_id=%s AND kcal IS NOT NULL
|
|||
|
|
GROUP BY date""",
|
|||
|
|
(profile_id,),
|
|||
|
|
)
|
|||
|
|
nk = { _iso(r["date"]): safe_float(r["kcal"]) for r in cur.fetchall() }
|
|||
|
|
|
|||
|
|
if cutoff:
|
|||
|
|
cur.execute(
|
|||
|
|
"SELECT date, weight FROM weight_log WHERE profile_id=%s AND date >= %s ORDER BY date",
|
|||
|
|
(profile_id, cutoff),
|
|||
|
|
)
|
|||
|
|
else:
|
|||
|
|
cur.execute(
|
|||
|
|
"SELECT date, weight FROM weight_log WHERE profile_id=%s ORDER BY date",
|
|||
|
|
(profile_id,),
|
|||
|
|
)
|
|||
|
|
wk = { _iso(r["date"]): safe_float(r["weight"]) for r in cur.fetchall() if r.get("weight") is not None }
|
|||
|
|
|
|||
|
|
common = sorted(set(nk) & set(wk))
|
|||
|
|
raw: List[Dict[str, Any]] = []
|
|||
|
|
for ds in common:
|
|||
|
|
raw.append({"date": ds, "kcal": nk[ds], "weight": wk[ds]})
|
|||
|
|
rolled = _rolling_avg(raw, "kcal", 7)
|
|||
|
|
out: List[Dict[str, Any]] = []
|
|||
|
|
for r in rolled:
|
|||
|
|
out.append(
|
|||
|
|
{
|
|||
|
|
"date": r["date"],
|
|||
|
|
"kcal": r.get("kcal"),
|
|||
|
|
"weight": r.get("weight"),
|
|||
|
|
"kcal_avg": r.get("kcal_avg"),
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
return out
|
|||
|
|
|
|||
|
|
|
|||
|
|
def get_nutrition_history_viz_bundle(profile_id: str, days: int) -> Dict[str, Any]:
|
|||
|
|
"""
|
|||
|
|
Layer 2b Bundle für Verlauf «Ernährung».
|
|||
|
|
|
|||
|
|
days: Analysefenster (>=9999 = gesamte Historie für Mittelwerte / Reihen).
|
|||
|
|
"""
|
|||
|
|
if not _has_nutrition_entries(profile_id):
|
|||
|
|
return {
|
|||
|
|
"confidence": "insufficient",
|
|||
|
|
"has_nutrition_entries": False,
|
|||
|
|
"message": "Noch keine Ernährungsdaten",
|
|||
|
|
"kpi_tiles": [],
|
|||
|
|
"summary": {},
|
|||
|
|
"daily_macros": [],
|
|||
|
|
"donut_avg_pct": None,
|
|||
|
|
"kcal_vs_weight": {"points": [], "tdee_reference_kcal": None, "common_days_count": 0},
|
|||
|
|
"weekly_macro_chart": {},
|
|||
|
|
"tdee_reference_kcal": None,
|
|||
|
|
"energy_balance_meta": {},
|
|||
|
|
"interpretation_tiles": [],
|
|||
|
|
"meta": {"layer_1": "nutrition_metrics", "layer_2b": "nutrition_viz"},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
all_history = days >= 9999
|
|||
|
|
eff_days = 3650 if all_history else max(7, min(int(days), 3650))
|
|||
|
|
cutoff = _cutoff_sql(days)
|
|||
|
|
|
|||
|
|
navg = get_nutrition_average_data(profile_id, eff_days, all_history=all_history)
|
|||
|
|
targets = get_protein_targets_data(profile_id)
|
|||
|
|
energy_days = eff_days if not all_history else min(9999, 3650)
|
|||
|
|
energy_meta = get_energy_balance_data(profile_id, energy_days)
|
|||
|
|
tdee = estimate_tdee_kcal_from_latest_weight(profile_id)
|
|||
|
|
if tdee is None:
|
|||
|
|
tdee = safe_float(energy_meta.get("estimated_tdee")) or None
|
|||
|
|
else:
|
|||
|
|
tdee = float(tdee)
|
|||
|
|
|
|||
|
|
daily_rows = _fetch_daily_macro_totals(profile_id, cutoff)
|
|||
|
|
daily_macros: List[Dict[str, Any]] = []
|
|||
|
|
for r in daily_rows:
|
|||
|
|
daily_macros.append(
|
|||
|
|
{
|
|||
|
|
"date": _iso(r["date"]),
|
|||
|
|
"kcal": round(safe_float(r.get("kcal")) or 0),
|
|||
|
|
"Protein": round(safe_float(r.get("protein_g")) or 0),
|
|||
|
|
"KH": round(safe_float(r.get("carbs_g")) or 0),
|
|||
|
|
"Fett": round(safe_float(r.get("fat_g")) or 0),
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
date_span_label = ""
|
|||
|
|
if daily_macros:
|
|||
|
|
date_span_label = f"{daily_macros[0]['date']} – {daily_macros[-1]['date']}"
|
|||
|
|
|
|||
|
|
n_days = int(navg.get("data_points") or 0)
|
|||
|
|
kpi_tiles = build_nutrition_history_kpi_tiles(
|
|||
|
|
navg, targets, date_span_label or "—", max(1, n_days)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
donut = build_macro_donut_from_averages(navg)
|
|||
|
|
|
|||
|
|
kw_points = _kcal_weight_points_for_window(profile_id, cutoff)
|
|||
|
|
pt_low = round(float(targets.get("protein_target_low") or 0))
|
|||
|
|
|
|||
|
|
chart_days_for_pipeline = 90 if all_history else max(7, min(eff_days, 365))
|
|||
|
|
weeks_for_weekly = max(4, min(52, (chart_days_for_pipeline + 6) // 7))
|
|||
|
|
weekly_chart = get_weekly_macro_distribution_chart_data(profile_id, weeks_for_weekly)
|
|||
|
|
|
|||
|
|
conf = navg.get("confidence") or "medium"
|
|||
|
|
if targets.get("confidence") == "insufficient":
|
|||
|
|
conf = "insufficient"
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"confidence": conf,
|
|||
|
|
"has_nutrition_entries": True,
|
|||
|
|
"days_requested": days,
|
|||
|
|
"effective_window_days": eff_days,
|
|||
|
|
"nutrition_charts_days": chart_days_for_pipeline,
|
|||
|
|
"weekly_macro_weeks_used": weeks_for_weekly,
|
|||
|
|
"last_updated": _last_nutrition_date(profile_id),
|
|||
|
|
"summary": {
|
|||
|
|
"kcal_avg": navg.get("kcal_avg"),
|
|||
|
|
"protein_avg": navg.get("protein_avg"),
|
|||
|
|
"carbs_avg": navg.get("carbs_avg"),
|
|||
|
|
"fat_avg": navg.get("fat_avg"),
|
|||
|
|
"data_points": navg.get("data_points"),
|
|||
|
|
"days_analyzed": navg.get("days_analyzed"),
|
|||
|
|
"protein_target_low": targets.get("protein_target_low"),
|
|||
|
|
"protein_target_high": targets.get("protein_target_high"),
|
|||
|
|
"reference_weight_kg": targets.get("current_weight"),
|
|||
|
|
},
|
|||
|
|
"kpi_tiles": kpi_tiles,
|
|||
|
|
"interpretation_tiles": [],
|
|||
|
|
"daily_macros": daily_macros,
|
|||
|
|
"donut_avg_pct": donut,
|
|||
|
|
"protein_reference_line_g": pt_low,
|
|||
|
|
"kcal_vs_weight": {
|
|||
|
|
"points": kw_points,
|
|||
|
|
"tdee_reference_kcal": tdee,
|
|||
|
|
"common_days_count": len(kw_points),
|
|||
|
|
},
|
|||
|
|
"weekly_macro_chart": weekly_chart,
|
|||
|
|
"tdee_reference_kcal": tdee,
|
|||
|
|
"energy_balance_meta": {
|
|||
|
|
"energy_balance": energy_meta.get("energy_balance"),
|
|||
|
|
"avg_intake": energy_meta.get("avg_intake"),
|
|||
|
|
"estimated_tdee": energy_meta.get("estimated_tdee"),
|
|||
|
|
"status": energy_meta.get("status"),
|
|||
|
|
"confidence": energy_meta.get("confidence"),
|
|||
|
|
"data_points": energy_meta.get("data_points"),
|
|||
|
|
},
|
|||
|
|
"meta": {
|
|||
|
|
"layer_1": "nutrition_metrics",
|
|||
|
|
"layer_2b": "nutrition_viz",
|
|||
|
|
"issue": "53-phase-0c",
|
|||
|
|
},
|
|||
|
|
}
|