- 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.
371 lines
13 KiB
Python
371 lines
13 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_body_merge import build_merged_daily_nutrition_body_rows
|
||
from data_layer.nutrition_interpretation import (
|
||
build_energy_availability_kpi_tile,
|
||
build_macro_donut_from_averages,
|
||
build_nutrition_correlation_heuristic_items,
|
||
build_nutrition_history_kpi_tiles,
|
||
)
|
||
from data_layer.nutrition_metrics import (
|
||
estimate_tdee_kcal_from_latest_weight,
|
||
get_energy_availability_warning_payload,
|
||
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 _filter_merged_rows_by_cutoff(
|
||
merged: List[Dict[str, Any]], cutoff: Optional[str]
|
||
) -> List[Dict[str, Any]]:
|
||
if not cutoff:
|
||
return list(merged)
|
||
return [r for r in merged if str(r.get("date"))[:10] >= cutoff]
|
||
|
||
|
||
def _calorie_balance_daily_series(
|
||
merged_filtered: List[Dict[str, Any]], tdee: float
|
||
) -> List[Dict[str, Any]]:
|
||
"""Tagesbilanz (Aufnahme − TDEE) + 7-Tage-Mittel der Bilanz — gleiche TDEE-Quelle wie kcal_vs_weight."""
|
||
rows: List[Dict[str, Any]] = []
|
||
for r in merged_filtered:
|
||
if r.get("kcal") is None:
|
||
continue
|
||
ds = _iso(r.get("date"))
|
||
if not ds:
|
||
continue
|
||
bal = round(float(r["kcal"]) - float(tdee))
|
||
rows.append({"date": ds, "balance_kcal": bal})
|
||
rolled = _rolling_avg([dict(x) for x in rows], "balance_kcal", 7)
|
||
out: List[Dict[str, Any]] = []
|
||
for x in rolled:
|
||
out.append(
|
||
{
|
||
"date": x["date"],
|
||
"balance_kcal": x.get("balance_kcal"),
|
||
"balance_kcal_avg": x.get("balance_kcal_avg"),
|
||
}
|
||
)
|
||
return out
|
||
|
||
|
||
def _protein_lean_mass_points(merged_filtered: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||
out: List[Dict[str, Any]] = []
|
||
for r in merged_filtered:
|
||
if r.get("protein_g") is None or r.get("lean_mass") is None:
|
||
continue
|
||
ds = _iso(r.get("date"))
|
||
if not ds:
|
||
continue
|
||
out.append(
|
||
{
|
||
"date": ds,
|
||
"protein_g": round(safe_float(r.get("protein_g")) or 0, 1),
|
||
"lean_mass_kg": round(safe_float(r.get("lean_mass")) or 0, 2),
|
||
}
|
||
)
|
||
return out
|
||
|
||
|
||
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": [],
|
||
"energy_availability_warning": None,
|
||
"calorie_balance_daily": [],
|
||
"protein_vs_lean_mass": {"points": [], "protein_target_low_g": None},
|
||
"nutrition_correlation_heuristics": [],
|
||
"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)
|
||
chart_days_for_pipeline = 90 if all_history else max(7, min(eff_days, 365))
|
||
|
||
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)
|
||
)
|
||
|
||
ea_days = min(28, max(7, chart_days_for_pipeline))
|
||
ea_payload = get_energy_availability_warning_payload(profile_id, ea_days)
|
||
ea_tile = build_energy_availability_kpi_tile(ea_payload)
|
||
kpi_tiles_out: List[Dict[str, Any]] = list(kpi_tiles)
|
||
if ea_tile:
|
||
kpi_tiles_out.append(ea_tile)
|
||
|
||
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))
|
||
|
||
merged_all = build_merged_daily_nutrition_body_rows(profile_id)
|
||
merged_win = _filter_merged_rows_by_cutoff(merged_all, cutoff)
|
||
tdee_eff = float(tdee) if tdee is not None else float(safe_float(energy_meta.get("estimated_tdee")) or 0)
|
||
calorie_balance_daily: List[Dict[str, Any]] = (
|
||
_calorie_balance_daily_series(merged_win, tdee_eff) if tdee_eff > 0 else []
|
||
)
|
||
pl_points = _protein_lean_mass_points(merged_win)
|
||
nutrition_correlation_heuristics = (
|
||
build_nutrition_correlation_heuristic_items(merged_win, tdee_eff, float(pt_low))
|
||
if tdee_eff > 0
|
||
else []
|
||
)
|
||
|
||
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_out,
|
||
"interpretation_tiles": [],
|
||
"energy_availability_warning": ea_payload,
|
||
"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"),
|
||
},
|
||
"calorie_balance_daily": calorie_balance_daily,
|
||
"protein_vs_lean_mass": {
|
||
"points": pl_points,
|
||
"protein_target_low_g": pt_low if pt_low > 0 else None,
|
||
},
|
||
"nutrition_correlation_heuristics": nutrition_correlation_heuristics,
|
||
"meta": {
|
||
"layer_1": "nutrition_metrics",
|
||
"layer_2b": "nutrition_viz",
|
||
"issue": "53-phase-0c",
|
||
},
|
||
}
|