- 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.
1363 lines
43 KiB
Python
1363 lines
43 KiB
Python
"""
|
||
Nutrition Metrics Data Layer
|
||
|
||
Provides structured data for nutrition tracking and analysis.
|
||
|
||
Functions:
|
||
- get_nutrition_average_data(): Average calor
|
||
|
||
ies, protein, carbs, fat
|
||
- get_nutrition_days_data(): Number of days with nutrition data
|
||
- get_protein_targets_data(): Protein targets based on weight
|
||
- get_energy_balance_data(): Energy balance calculation
|
||
- get_protein_adequacy_data(): Protein adequacy score
|
||
- get_macro_consistency_data(): Macro consistency analysis
|
||
|
||
All functions return structured data (dict) without formatting.
|
||
Use placeholder_resolver.py for formatted strings for AI.
|
||
|
||
Phase 0c: Multi-Layer Architecture
|
||
Version: 1.0
|
||
"""
|
||
|
||
import statistics
|
||
from typing import Dict, List, Optional
|
||
from datetime import datetime, timedelta, date
|
||
from db import get_db, get_cursor, r2d
|
||
from data_layer.utils import calculate_confidence, safe_float, safe_int
|
||
|
||
# Fallback TDEE (kcal/day) when demographics for Mifflin–St Jeor are incomplete.
|
||
TDEE_KCAL_PER_KG_BODYWEIGHT = 32.5
|
||
# PAL applied to MSJ BMR when height, sex, dob and weight are available (moderate activity).
|
||
TDEE_PAL_MODERATE = 1.55
|
||
|
||
|
||
def _age_years_from_dob(dob) -> Optional[int]:
|
||
if dob is None:
|
||
return None
|
||
try:
|
||
if isinstance(dob, str):
|
||
birth = datetime.strptime(dob[:10], "%Y-%m-%d").date()
|
||
else:
|
||
birth = dob
|
||
today = date.today()
|
||
return today.year - birth.year - ((today.month, today.day) < (birth.month, birth.day))
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _mifflin_st_jeor_bmr_kcal(
|
||
weight_kg: float, height_cm: float, age_years: int, sex_is_male: bool
|
||
) -> float:
|
||
if sex_is_male:
|
||
return 10.0 * weight_kg + 6.25 * height_cm - 5.0 * age_years + 5.0
|
||
return 10.0 * weight_kg + 6.25 * height_cm - 5.0 * age_years - 161.0
|
||
|
||
|
||
def estimate_tdee_kcal_from_latest_weight(profile_id: str) -> Optional[float]:
|
||
"""
|
||
Estimated TDEE (kcal/day).
|
||
|
||
Primary: Mifflin–St Jeor BMR × TDEE_PAL_MODERATE when latest weight plus
|
||
profiles.height, profiles.sex, profiles.dob are usable.
|
||
|
||
Fallback: latest weight (kg) × TDEE_KCAL_PER_KG_BODYWEIGHT (legacy heuristic).
|
||
|
||
Returns None if no weight on record.
|
||
"""
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
cur.execute(
|
||
"""SELECT weight FROM weight_log
|
||
WHERE profile_id=%s ORDER BY date DESC LIMIT 1""",
|
||
(profile_id,),
|
||
)
|
||
wrow = cur.fetchone()
|
||
if not wrow or wrow["weight"] is None:
|
||
return None
|
||
weight_kg = float(wrow["weight"])
|
||
|
||
cur.execute(
|
||
"SELECT height, sex, dob FROM profiles WHERE id=%s",
|
||
(profile_id,),
|
||
)
|
||
prow = cur.fetchone()
|
||
|
||
if prow and prow.get("height") and prow.get("sex") is not None and prow.get("dob"):
|
||
height_cm = float(prow["height"])
|
||
age = _age_years_from_dob(prow["dob"])
|
||
if age is not None and 10 < age < 120 and height_cm > 50:
|
||
sex_raw = str(prow["sex"]).strip().lower()
|
||
sex_is_male = sex_raw in ("m", "male", "männlich", "mann")
|
||
bmr = _mifflin_st_jeor_bmr_kcal(weight_kg, height_cm, age, sex_is_male)
|
||
if bmr > 400:
|
||
return bmr * TDEE_PAL_MODERATE
|
||
|
||
return weight_kg * TDEE_KCAL_PER_KG_BODYWEIGHT
|
||
|
||
|
||
def _get_profile_goal_mode(profile_id: str) -> str:
|
||
"""Strategic goal_mode from profiles (Phase 0a); defaults to health."""
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
cur.execute("SELECT goal_mode FROM profiles WHERE id=%s", (profile_id,))
|
||
row = cur.fetchone()
|
||
if row and row.get("goal_mode"):
|
||
g = str(row["goal_mode"]).strip().lower()
|
||
if g:
|
||
return g
|
||
return "health"
|
||
|
||
|
||
def get_nutrition_average_data(
|
||
profile_id: str,
|
||
days: int = 30,
|
||
*,
|
||
all_history: bool = False,
|
||
) -> Dict:
|
||
"""
|
||
Get average nutrition values for all macros.
|
||
|
||
Args:
|
||
profile_id: User profile ID
|
||
days: Analysis window (default 30)
|
||
|
||
Returns:
|
||
{
|
||
"kcal_avg": float,
|
||
"protein_avg": float,
|
||
"carbs_avg": float,
|
||
"fat_avg": float,
|
||
"data_points": int,
|
||
"confidence": str,
|
||
"days_analyzed": int
|
||
}
|
||
|
||
Migration from Phase 0b:
|
||
OLD: get_nutrition_avg(pid, field, days) per field
|
||
NEW: All macros in one call
|
||
"""
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
cutoff = None if all_history else (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||
|
||
# Mean over calendar days (per-day sums), not over raw log rows.
|
||
if cutoff:
|
||
inner_where = "WHERE profile_id=%s AND date >= %s"
|
||
params = (profile_id, cutoff)
|
||
else:
|
||
inner_where = "WHERE profile_id=%s"
|
||
params = (profile_id,)
|
||
|
||
cur.execute(
|
||
f"""SELECT
|
||
AVG(daily_kcal) AS kcal_avg,
|
||
AVG(daily_protein) AS protein_avg,
|
||
AVG(daily_carbs) AS carbs_avg,
|
||
AVG(daily_fat) AS fat_avg,
|
||
COUNT(*)::int AS day_count
|
||
FROM (
|
||
SELECT date,
|
||
COALESCE(SUM(kcal), 0)::float AS daily_kcal,
|
||
COALESCE(SUM(protein_g), 0)::float AS daily_protein,
|
||
COALESCE(SUM(carbs_g), 0)::float AS daily_carbs,
|
||
COALESCE(SUM(fat_g), 0)::float AS daily_fat
|
||
FROM nutrition_log
|
||
{inner_where}
|
||
GROUP BY date
|
||
) AS daily""",
|
||
params,
|
||
)
|
||
row = cur.fetchone()
|
||
|
||
if not row or row["day_count"] == 0:
|
||
return {
|
||
"kcal_avg": 0.0,
|
||
"protein_avg": 0.0,
|
||
"carbs_avg": 0.0,
|
||
"fat_avg": 0.0,
|
||
"data_points": 0,
|
||
"confidence": "insufficient",
|
||
"days_analyzed": days
|
||
}
|
||
|
||
data_points = row["day_count"]
|
||
confidence = calculate_confidence(data_points, days, "general")
|
||
|
||
return {
|
||
"kcal_avg": safe_float(row['kcal_avg']),
|
||
"protein_avg": safe_float(row['protein_avg']),
|
||
"carbs_avg": safe_float(row['carbs_avg']),
|
||
"fat_avg": safe_float(row['fat_avg']),
|
||
"data_points": data_points,
|
||
"confidence": confidence,
|
||
"days_analyzed": days
|
||
}
|
||
|
||
|
||
def get_nutrition_days_data(
|
||
profile_id: str,
|
||
days: int = 30
|
||
) -> Dict:
|
||
"""
|
||
Count days with nutrition data.
|
||
|
||
Args:
|
||
profile_id: User profile ID
|
||
days: Analysis window (default 30)
|
||
|
||
Returns:
|
||
{
|
||
"days_with_data": int,
|
||
"days_analyzed": int,
|
||
"coverage_pct": float,
|
||
"confidence": str
|
||
}
|
||
"""
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||
|
||
cur.execute(
|
||
"""SELECT COUNT(DISTINCT date) as days
|
||
FROM nutrition_log
|
||
WHERE profile_id=%s AND date >= %s""",
|
||
(profile_id, cutoff)
|
||
)
|
||
row = cur.fetchone()
|
||
days_with_data = row['days'] if row else 0
|
||
|
||
coverage_pct = (days_with_data / days * 100) if days > 0 else 0
|
||
confidence = calculate_confidence(days_with_data, days, "general")
|
||
|
||
return {
|
||
"days_with_data": days_with_data,
|
||
"days_analyzed": days,
|
||
"coverage_pct": coverage_pct,
|
||
"confidence": confidence
|
||
}
|
||
|
||
|
||
def get_protein_targets_data(
|
||
profile_id: str
|
||
) -> Dict:
|
||
"""
|
||
Calculate protein targets based on current weight.
|
||
|
||
Targets:
|
||
- Low: 1.6 g/kg (maintenance)
|
||
- High: 2.2 g/kg (muscle building)
|
||
|
||
Args:
|
||
profile_id: User profile ID
|
||
|
||
Returns:
|
||
{
|
||
"current_weight": float,
|
||
"protein_target_low": float, # 1.6 g/kg
|
||
"protein_target_high": float, # 2.2 g/kg
|
||
"confidence": str
|
||
}
|
||
"""
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
cur.execute(
|
||
"""SELECT weight FROM weight_log
|
||
WHERE profile_id=%s ORDER BY date DESC LIMIT 1""",
|
||
(profile_id,)
|
||
)
|
||
row = cur.fetchone()
|
||
|
||
if not row:
|
||
return {
|
||
"current_weight": 0.0,
|
||
"protein_target_low": 0.0,
|
||
"protein_target_high": 0.0,
|
||
"confidence": "insufficient"
|
||
}
|
||
|
||
weight = safe_float(row['weight'])
|
||
|
||
return {
|
||
"current_weight": weight,
|
||
"protein_target_low": weight * 1.6,
|
||
"protein_target_high": weight * 2.2,
|
||
"confidence": "high"
|
||
}
|
||
|
||
|
||
def get_energy_balance_data(
|
||
profile_id: str,
|
||
days: int = 7
|
||
) -> Dict:
|
||
"""
|
||
Energy balance (intake - estimated expenditure), kcal/day.
|
||
|
||
Intake: mean of daily total kcal (sum per calendar day).
|
||
TDEE: estimate_tdee_kcal_from_latest_weight (MSJ × PAL oder kg-Fallback).
|
||
"""
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||
|
||
cur.execute(
|
||
"""SELECT date, SUM(kcal)::float AS daily_kcal
|
||
FROM nutrition_log
|
||
WHERE profile_id=%s AND date >= %s AND kcal IS NOT NULL
|
||
GROUP BY date
|
||
ORDER BY date""",
|
||
(profile_id, cutoff),
|
||
)
|
||
daily_rows = cur.fetchall()
|
||
|
||
if not daily_rows:
|
||
return {
|
||
"energy_balance": 0.0,
|
||
"avg_intake": 0.0,
|
||
"estimated_tdee": 0.0,
|
||
"status": "unknown",
|
||
"confidence": "insufficient",
|
||
"days_analyzed": days,
|
||
"data_points": 0,
|
||
}
|
||
|
||
daily_totals = [safe_float(r["daily_kcal"]) for r in daily_rows]
|
||
avg_intake = sum(daily_totals) / len(daily_totals)
|
||
data_points = len(daily_totals)
|
||
|
||
estimated_tdee = estimate_tdee_kcal_from_latest_weight(profile_id)
|
||
if estimated_tdee is None:
|
||
return {
|
||
"energy_balance": 0.0,
|
||
"avg_intake": avg_intake,
|
||
"estimated_tdee": 0.0,
|
||
"status": "unknown",
|
||
"confidence": "insufficient",
|
||
"days_analyzed": days,
|
||
"data_points": data_points
|
||
}
|
||
|
||
energy_balance = avg_intake - estimated_tdee
|
||
|
||
if energy_balance < -200:
|
||
status = "deficit"
|
||
elif energy_balance > 200:
|
||
status = "surplus"
|
||
else:
|
||
status = "maintenance"
|
||
|
||
confidence = calculate_confidence(data_points, days, "general")
|
||
|
||
return {
|
||
"energy_balance": energy_balance,
|
||
"avg_intake": avg_intake,
|
||
"estimated_tdee": estimated_tdee,
|
||
"status": status,
|
||
"confidence": confidence,
|
||
"days_analyzed": days,
|
||
"data_points": data_points
|
||
}
|
||
|
||
|
||
def get_protein_adequacy_data(
|
||
profile_id: str,
|
||
days: int = 28
|
||
) -> Dict:
|
||
"""
|
||
Calculate protein adequacy score (0-100).
|
||
|
||
Score based on:
|
||
- Daily protein intake vs. target (1.6-2.2 g/kg)
|
||
- Consistency across days
|
||
|
||
Args:
|
||
profile_id: User profile ID
|
||
days: Analysis window (default 28)
|
||
|
||
Returns:
|
||
{
|
||
"adequacy_score": int, # 0-100
|
||
"avg_protein_g": float,
|
||
"target_protein_low": float,
|
||
"target_protein_high": float,
|
||
"protein_g_per_kg": float,
|
||
"days_in_target": int,
|
||
"days_with_data": int,
|
||
"confidence": str
|
||
}
|
||
"""
|
||
targets = get_protein_targets_data(profile_id)
|
||
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||
|
||
cur.execute(
|
||
"""SELECT COALESCE(SUM(protein_g), 0)::float AS daily_protein
|
||
FROM nutrition_log
|
||
WHERE profile_id=%s AND date >= %s
|
||
GROUP BY date""",
|
||
(profile_id, cutoff),
|
||
)
|
||
rows = cur.fetchall()
|
||
|
||
if not rows or targets.get("confidence") == "insufficient" or targets["current_weight"] <= 0:
|
||
return {
|
||
"adequacy_score": 0,
|
||
"avg_protein_g": 0.0,
|
||
"target_protein_low": targets['protein_target_low'],
|
||
"target_protein_high": targets['protein_target_high'],
|
||
"protein_g_per_kg": 0.0,
|
||
"days_in_target": 0,
|
||
"days_with_data": 0,
|
||
"confidence": "insufficient"
|
||
}
|
||
|
||
daily_totals = [safe_float(r["daily_protein"]) for r in rows]
|
||
days_with_data = len(daily_totals)
|
||
low = targets["protein_target_low"]
|
||
high = targets["protein_target_high"]
|
||
days_in_target = sum(1 for d in daily_totals if low <= d <= high)
|
||
|
||
avg_protein = sum(daily_totals) / days_with_data
|
||
protein_g_per_kg = avg_protein / targets["current_weight"] if targets["current_weight"] > 0 else 0.0
|
||
|
||
target_pct = (days_in_target / days_with_data * 100) if days_with_data > 0 else 0
|
||
target_mid = (low + high) / 2
|
||
avg_vs_target = (avg_protein / target_mid) if target_mid > 0 else 0
|
||
|
||
adequacy_score = int(target_pct * 0.7 + min(avg_vs_target * 100, 100) * 0.3)
|
||
adequacy_score = max(0, min(100, adequacy_score))
|
||
|
||
confidence = calculate_confidence(days_with_data, days, "general")
|
||
|
||
return {
|
||
"adequacy_score": adequacy_score,
|
||
"avg_protein_g": avg_protein,
|
||
"target_protein_low": targets['protein_target_low'],
|
||
"target_protein_high": targets['protein_target_high'],
|
||
"protein_g_per_kg": protein_g_per_kg,
|
||
"days_in_target": days_in_target,
|
||
"days_with_data": days_with_data,
|
||
"confidence": confidence
|
||
}
|
||
|
||
|
||
def get_macro_consistency_data(
|
||
profile_id: str,
|
||
days: int = 28
|
||
) -> Dict:
|
||
"""
|
||
Calculate macro consistency score (0-100).
|
||
|
||
Measures how consistent macronutrient ratios are across days.
|
||
High consistency = predictable nutrition, easier to track progress.
|
||
|
||
Args:
|
||
profile_id: User profile ID
|
||
days: Analysis window (default 28)
|
||
|
||
Returns:
|
||
{
|
||
"consistency_score": int, # 0-100 (100 = very consistent)
|
||
"avg_protein_pct": float,
|
||
"avg_carbs_pct": float,
|
||
"avg_fat_pct": float,
|
||
"std_dev_protein": float, # Standard deviation
|
||
"std_dev_carbs": float,
|
||
"std_dev_fat": float,
|
||
"confidence": str,
|
||
"data_points": int
|
||
}
|
||
"""
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||
|
||
cur.execute(
|
||
"""SELECT
|
||
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
|
||
HAVING COALESCE(SUM(kcal), 0) > 0
|
||
AND COALESCE(SUM(protein_g), 0) > 0
|
||
AND COALESCE(SUM(carbs_g), 0) > 0
|
||
AND COALESCE(SUM(fat_g), 0) > 0""",
|
||
(profile_id, cutoff),
|
||
)
|
||
rows = cur.fetchall()
|
||
|
||
if len(rows) < 3:
|
||
return {
|
||
"consistency_score": 0,
|
||
"avg_protein_pct": 0.0,
|
||
"avg_carbs_pct": 0.0,
|
||
"avg_fat_pct": 0.0,
|
||
"std_dev_protein": 0.0,
|
||
"std_dev_carbs": 0.0,
|
||
"std_dev_fat": 0.0,
|
||
"confidence": "insufficient",
|
||
"data_points": len(rows)
|
||
}
|
||
|
||
protein_pcts = []
|
||
carbs_pcts = []
|
||
fat_pcts = []
|
||
|
||
for row in rows:
|
||
total_kcal = safe_float(row['kcal'])
|
||
if total_kcal == 0:
|
||
continue
|
||
|
||
protein_kcal = safe_float(row['protein_g']) * 4
|
||
carbs_kcal = safe_float(row['carbs_g']) * 4
|
||
fat_kcal = safe_float(row['fat_g']) * 9
|
||
|
||
macro_kcal_total = protein_kcal + carbs_kcal + fat_kcal
|
||
|
||
if macro_kcal_total > 0:
|
||
protein_pcts.append(protein_kcal / macro_kcal_total * 100)
|
||
carbs_pcts.append(carbs_kcal / macro_kcal_total * 100)
|
||
fat_pcts.append(fat_kcal / macro_kcal_total * 100)
|
||
|
||
if len(protein_pcts) < 3:
|
||
return {
|
||
"consistency_score": 0,
|
||
"avg_protein_pct": 0.0,
|
||
"avg_carbs_pct": 0.0,
|
||
"avg_fat_pct": 0.0,
|
||
"std_dev_protein": 0.0,
|
||
"std_dev_carbs": 0.0,
|
||
"std_dev_fat": 0.0,
|
||
"confidence": "insufficient",
|
||
"data_points": len(protein_pcts)
|
||
}
|
||
|
||
# Calculate averages and standard deviations
|
||
avg_protein_pct = statistics.mean(protein_pcts)
|
||
avg_carbs_pct = statistics.mean(carbs_pcts)
|
||
avg_fat_pct = statistics.mean(fat_pcts)
|
||
|
||
std_protein = statistics.stdev(protein_pcts) if len(protein_pcts) > 1 else 0.0
|
||
std_carbs = statistics.stdev(carbs_pcts) if len(carbs_pcts) > 1 else 0.0
|
||
std_fat = statistics.stdev(fat_pcts) if len(fat_pcts) > 1 else 0.0
|
||
|
||
# Consistency score: inverse of average standard deviation
|
||
# Lower std_dev = higher consistency
|
||
avg_std = (std_protein + std_carbs + std_fat) / 3
|
||
|
||
# Score: 100 - (avg_std * scale_factor)
|
||
# avg_std of 5% = score 75, avg_std of 10% = score 50, avg_std of 20% = score 0
|
||
consistency_score = max(0, min(100, int(100 - (avg_std * 5))))
|
||
|
||
confidence = calculate_confidence(len(protein_pcts), days, "general")
|
||
|
||
return {
|
||
"consistency_score": consistency_score,
|
||
"avg_protein_pct": avg_protein_pct,
|
||
"avg_carbs_pct": avg_carbs_pct,
|
||
"avg_fat_pct": avg_fat_pct,
|
||
"std_dev_protein": std_protein,
|
||
"std_dev_carbs": std_carbs,
|
||
"std_dev_fat": std_fat,
|
||
"confidence": confidence,
|
||
"data_points": len(protein_pcts)
|
||
}
|
||
|
||
|
||
def get_weekly_macro_distribution_chart_data(profile_id: str, weeks: int) -> Dict:
|
||
"""
|
||
Chart E3: gestapelte Wochenbalken (Makro-%), gleiche Logik wie /charts/weekly-macro-distribution.
|
||
"""
|
||
cutoff = (datetime.now() - timedelta(weeks=weeks)).strftime("%Y-%m-%d")
|
||
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
cur.execute(
|
||
"""SELECT date, protein_g, carbs_g, fat_g, kcal
|
||
FROM nutrition_log
|
||
WHERE profile_id=%s AND date >= %s
|
||
AND protein_g IS NOT NULL AND carbs_g IS NOT NULL
|
||
AND fat_g IS NOT NULL AND kcal > 0
|
||
ORDER BY date""",
|
||
(profile_id, cutoff),
|
||
)
|
||
rows = cur.fetchall()
|
||
|
||
if not rows or len(rows) < 7:
|
||
return {
|
||
"chart_type": "bar",
|
||
"data": {
|
||
"labels": [],
|
||
"datasets": [],
|
||
},
|
||
"metadata": {
|
||
"confidence": "insufficient",
|
||
"data_points": len(rows) if rows else 0,
|
||
"message": "Nicht genug Daten für Wochen-Analyse (min. 7 Tage)",
|
||
},
|
||
}
|
||
|
||
weekly_data: Dict[str, Dict[str, List[float]]] = {}
|
||
for row in rows:
|
||
date_obj = row["date"] if isinstance(row["date"], datetime) else datetime.fromisoformat(str(row["date"]))
|
||
iso_week = date_obj.strftime("%Y-W%V")
|
||
|
||
if iso_week not in weekly_data:
|
||
weekly_data[iso_week] = {
|
||
"protein": [],
|
||
"carbs": [],
|
||
"fat": [],
|
||
"kcal": [],
|
||
}
|
||
|
||
weekly_data[iso_week]["protein"].append(safe_float(row["protein_g"]))
|
||
weekly_data[iso_week]["carbs"].append(safe_float(row["carbs_g"]))
|
||
weekly_data[iso_week]["fat"].append(safe_float(row["fat_g"]))
|
||
weekly_data[iso_week]["kcal"].append(safe_float(row["kcal"]))
|
||
|
||
labels: List[str] = []
|
||
protein_pcts: List[float] = []
|
||
carbs_pcts: List[float] = []
|
||
fat_pcts: List[float] = []
|
||
|
||
for iso_week in sorted(weekly_data.keys())[-weeks:]:
|
||
data = weekly_data[iso_week]
|
||
|
||
avg_protein = sum(data["protein"]) / len(data["protein"]) if data["protein"] else 0
|
||
avg_carbs = sum(data["carbs"]) / len(data["carbs"]) if data["carbs"] else 0
|
||
avg_fat = sum(data["fat"]) / len(data["fat"]) if data["fat"] else 0
|
||
|
||
protein_kcal = avg_protein * 4
|
||
carbs_kcal = avg_carbs * 4
|
||
fat_kcal = avg_fat * 9
|
||
|
||
total_kcal = protein_kcal + carbs_kcal + fat_kcal
|
||
|
||
if total_kcal > 0:
|
||
labels.append(f"KW {iso_week[-2:]}")
|
||
protein_pcts.append(round((protein_kcal / total_kcal) * 100, 1))
|
||
carbs_pcts.append(round((carbs_kcal / total_kcal) * 100, 1))
|
||
fat_pcts.append(round((fat_kcal / total_kcal) * 100, 1))
|
||
|
||
protein_cv = (
|
||
statistics.stdev(protein_pcts) / statistics.mean(protein_pcts) * 100
|
||
if len(protein_pcts) > 1 and statistics.mean(protein_pcts) > 0
|
||
else 0
|
||
)
|
||
carbs_cv = (
|
||
statistics.stdev(carbs_pcts) / statistics.mean(carbs_pcts) * 100
|
||
if len(carbs_pcts) > 1 and statistics.mean(carbs_pcts) > 0
|
||
else 0
|
||
)
|
||
fat_cv = (
|
||
statistics.stdev(fat_pcts) / statistics.mean(fat_pcts) * 100
|
||
if len(fat_pcts) > 1 and statistics.mean(fat_pcts) > 0
|
||
else 0
|
||
)
|
||
|
||
return {
|
||
"chart_type": "bar",
|
||
"data": {
|
||
"labels": labels,
|
||
"datasets": [
|
||
{
|
||
"label": "Protein (%)",
|
||
"data": protein_pcts,
|
||
"backgroundColor": "#4a8f72",
|
||
"stack": "macro",
|
||
},
|
||
{
|
||
"label": "Kohlenhydrate (%)",
|
||
"data": carbs_pcts,
|
||
"backgroundColor": "#c17d45",
|
||
"stack": "macro",
|
||
},
|
||
{
|
||
"label": "Fett (%)",
|
||
"data": fat_pcts,
|
||
"backgroundColor": "#6e8eb8",
|
||
"stack": "macro",
|
||
},
|
||
],
|
||
},
|
||
"metadata": {
|
||
"confidence": calculate_confidence(len(rows), weeks * 7, "general"),
|
||
"data_points": len(rows),
|
||
"weeks_analyzed": len(labels),
|
||
"avg_protein_pct": round(statistics.mean(protein_pcts), 1) if protein_pcts else 0,
|
||
"avg_carbs_pct": round(statistics.mean(carbs_pcts), 1) if carbs_pcts else 0,
|
||
"avg_fat_pct": round(statistics.mean(fat_pcts), 1) if fat_pcts else 0,
|
||
"protein_cv": round(protein_cv, 1),
|
||
"carbs_cv": round(carbs_cv, 1),
|
||
"fat_cv": round(fat_cv, 1),
|
||
},
|
||
}
|
||
|
||
|
||
def get_energy_availability_warning_payload(profile_id: str, days: int = 14) -> Dict:
|
||
"""
|
||
E5 Energieverfügbarkeit — gleiche Heuristik wie GET /charts/energy-availability-warning.
|
||
"""
|
||
from data_layer.recovery_metrics import calculate_recovery_score_v2, calculate_sleep_quality_7d
|
||
from data_layer.body_metrics import calculate_lbm_28d_change
|
||
|
||
triggers: List[str] = []
|
||
warning_level = "none"
|
||
|
||
energy_data = get_energy_balance_data(profile_id, days)
|
||
if energy_data.get("energy_balance", 0) < -500:
|
||
triggers.append("Großes Energiedefizit (>500 kcal/Tag)")
|
||
|
||
try:
|
||
recovery_score = calculate_recovery_score_v2(profile_id)
|
||
if recovery_score and recovery_score < 50:
|
||
triggers.append("Recovery Score niedrig (<50)")
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
sleep_quality = calculate_sleep_quality_7d(profile_id)
|
||
if sleep_quality and sleep_quality < 60:
|
||
triggers.append("Schlafqualität reduziert (<60%)")
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
lbm_change = calculate_lbm_28d_change(profile_id)
|
||
if lbm_change and lbm_change < -1.0:
|
||
triggers.append("Magermasse sinkt (-{:.1f} kg)".format(abs(lbm_change)))
|
||
except Exception:
|
||
pass
|
||
|
||
if len(triggers) >= 3:
|
||
warning_level = "warning"
|
||
message = (
|
||
"⚠️ Hinweis auf mögliche Unterversorgung. Mehrere Indikatoren auffällig. "
|
||
"Erwäge Defizit-Anpassung oder Regenerationswoche."
|
||
)
|
||
elif len(triggers) >= 2:
|
||
warning_level = "caution"
|
||
message = (
|
||
"⚡ Beobachte folgende Signale genau. Aktuell noch kein Handlungsbedarf, aber Trend beachten."
|
||
)
|
||
elif len(triggers) >= 1:
|
||
warning_level = "caution"
|
||
message = "💡 Ein Indikator auffällig. Weiter beobachten."
|
||
else:
|
||
message = "✅ Energieverfügbarkeit unauffällig."
|
||
|
||
return {
|
||
"warning_level": warning_level,
|
||
"triggers": triggers,
|
||
"message": message,
|
||
"metadata": {
|
||
"days_analyzed": days,
|
||
"trigger_count": len(triggers),
|
||
"note": "Heuristische Einschätzung, keine medizinische Diagnose",
|
||
},
|
||
}
|
||
|
||
|
||
# ============================================================================
|
||
# Calculated Metrics (migrated from calculations/nutrition_metrics.py)
|
||
# ============================================================================
|
||
# These functions return simple values for placeholders.
|
||
# Use get_*_data() functions above for structured chart data.
|
||
|
||
|
||
def calculate_energy_balance_7d(profile_id: str) -> Optional[float]:
|
||
"""
|
||
7-day mean energy balance (kcal/day), same rules as get_energy_balance_data(..., 7).
|
||
"""
|
||
data = get_energy_balance_data(profile_id, 7)
|
||
if data["data_points"] < 4:
|
||
return None
|
||
tdee = data.get("estimated_tdee") or 0
|
||
if tdee <= 0:
|
||
return None
|
||
return round(float(data["energy_balance"]), 0)
|
||
|
||
|
||
def calculate_energy_deficit_surplus(profile_id: str, days: int = 7) -> Optional[str]:
|
||
"""
|
||
Classify energy balance as deficit/maintenance/surplus
|
||
Returns: 'deficit', 'maintenance', 'surplus', or None
|
||
"""
|
||
balance = calculate_energy_balance_7d(profile_id)
|
||
|
||
if balance is None:
|
||
return None
|
||
|
||
if balance < -200:
|
||
return 'deficit'
|
||
elif balance > 200:
|
||
return 'surplus'
|
||
else:
|
||
return 'maintenance'
|
||
|
||
|
||
def calculate_protein_g_per_kg(profile_id: str) -> Optional[float]:
|
||
"""Calculate average protein intake in g/kg bodyweight (last 7 days)"""
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
|
||
# Get recent weight
|
||
cur.execute("""
|
||
SELECT weight
|
||
FROM weight_log
|
||
WHERE profile_id = %s
|
||
ORDER BY date DESC
|
||
LIMIT 1
|
||
""", (profile_id,))
|
||
|
||
weight_row = cur.fetchone()
|
||
if not weight_row:
|
||
return None
|
||
|
||
weight = float(weight_row['weight'])
|
||
|
||
# Get protein intake aggregated by day (SUM per day)
|
||
cur.execute("""
|
||
SELECT date, SUM(protein_g) as daily_protein
|
||
FROM nutrition_log
|
||
WHERE profile_id = %s
|
||
AND date >= CURRENT_DATE - INTERVAL '7 days'
|
||
AND protein_g IS NOT NULL
|
||
GROUP BY date
|
||
ORDER BY date DESC
|
||
""", (profile_id,))
|
||
|
||
daily_protein = [float(row['daily_protein']) for row in cur.fetchall()]
|
||
|
||
if len(daily_protein) < 4: # At least 4 days with data
|
||
return None
|
||
|
||
avg_protein = sum(daily_protein) / len(daily_protein)
|
||
protein_per_kg = avg_protein / weight
|
||
|
||
return round(protein_per_kg, 2)
|
||
|
||
|
||
def calculate_protein_days_in_target(profile_id: str, target_low: float = 1.6, target_high: float = 2.2) -> Optional[str]:
|
||
"""
|
||
Calculate how many days in last 7 were within protein target
|
||
Returns: "5/7" format or None
|
||
"""
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
|
||
# Get recent weight
|
||
cur.execute("""
|
||
SELECT weight
|
||
FROM weight_log
|
||
WHERE profile_id = %s
|
||
ORDER BY date DESC
|
||
LIMIT 1
|
||
""", (profile_id,))
|
||
|
||
weight_row = cur.fetchone()
|
||
if not weight_row:
|
||
return None
|
||
|
||
weight = float(weight_row['weight'])
|
||
|
||
# Calculate protein target range (absolute values)
|
||
target_low_g = target_low * weight
|
||
target_high_g = target_high * weight
|
||
|
||
# Get protein intake aggregated by day (SUM per day)
|
||
cur.execute("""
|
||
SELECT date, SUM(protein_g) as daily_protein
|
||
FROM nutrition_log
|
||
WHERE profile_id = %s
|
||
AND date >= CURRENT_DATE - INTERVAL '7 days'
|
||
AND protein_g IS NOT NULL
|
||
GROUP BY date
|
||
ORDER BY date DESC
|
||
""", (profile_id,))
|
||
|
||
daily_data = cur.fetchall()
|
||
|
||
if len(daily_data) < 4: # At least 4 days with data
|
||
return None
|
||
|
||
# Count days in target range
|
||
days_in_target = 0
|
||
total_days = len(daily_data)
|
||
|
||
for row in daily_data:
|
||
daily_protein = float(row['daily_protein'])
|
||
if target_low_g <= daily_protein <= target_high_g:
|
||
days_in_target += 1
|
||
|
||
return f"{days_in_target}/{total_days}"
|
||
|
||
|
||
def calculate_protein_adequacy_28d(profile_id: str) -> Optional[int]:
|
||
"""
|
||
Protein adequacy score 0-100 (last 28 days).
|
||
Uses per-calendar-day total protein vs. average weight in the window (g/kg per day).
|
||
"""
|
||
import statistics
|
||
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
|
||
cur.execute("""
|
||
SELECT AVG(weight) as avg_weight
|
||
FROM weight_log
|
||
WHERE profile_id = %s
|
||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
||
""", (profile_id,))
|
||
|
||
weight_row = cur.fetchone()
|
||
if not weight_row or not weight_row['avg_weight']:
|
||
return None
|
||
|
||
weight = float(weight_row['avg_weight'])
|
||
|
||
cur.execute("""
|
||
SELECT COALESCE(SUM(protein_g), 0)::float AS daily_protein
|
||
FROM nutrition_log
|
||
WHERE profile_id = %s
|
||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
||
GROUP BY date
|
||
""", (profile_id,))
|
||
|
||
daily_totals = [float(row['daily_protein']) for row in cur.fetchall()]
|
||
|
||
if len(daily_totals) < 18:
|
||
return None
|
||
|
||
protein_per_kg_values = [p / weight for p in daily_totals]
|
||
avg_protein_per_kg = sum(protein_per_kg_values) / len(protein_per_kg_values)
|
||
|
||
if 1.6 <= avg_protein_per_kg <= 2.2:
|
||
base_score = 100
|
||
elif avg_protein_per_kg < 1.6:
|
||
base_score = max(40, 100 - ((1.6 - avg_protein_per_kg) * 40))
|
||
else:
|
||
base_score = max(80, 100 - ((avg_protein_per_kg - 2.2) * 10))
|
||
|
||
std_dev = statistics.stdev(protein_per_kg_values)
|
||
if std_dev < 0.3:
|
||
consistency_bonus = 10
|
||
elif std_dev < 0.5:
|
||
consistency_bonus = 0
|
||
else:
|
||
consistency_bonus = -10
|
||
|
||
final_score = min(100, max(0, base_score + consistency_bonus))
|
||
|
||
return int(final_score)
|
||
|
||
|
||
def calculate_macro_consistency_score(profile_id: str) -> Optional[int]:
|
||
"""
|
||
Macro consistency score 0-100 (last 28 days).
|
||
CV of daily totals (kcal and macros), not raw log rows.
|
||
"""
|
||
import statistics
|
||
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
cur.execute("""
|
||
SELECT
|
||
COALESCE(SUM(kcal), 0)::float AS dk,
|
||
COALESCE(SUM(protein_g), 0)::float AS dp,
|
||
COALESCE(SUM(fat_g), 0)::float AS df,
|
||
COALESCE(SUM(carbs_g), 0)::float AS dc
|
||
FROM nutrition_log
|
||
WHERE profile_id = %s
|
||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
||
GROUP BY date
|
||
HAVING COALESCE(SUM(kcal), 0) > 0
|
||
""", (profile_id,))
|
||
|
||
data = cur.fetchall()
|
||
|
||
if len(data) < 18:
|
||
return None
|
||
|
||
def cv(values):
|
||
if not values or len(values) < 2:
|
||
return None
|
||
mean = sum(values) / len(values)
|
||
if mean == 0:
|
||
return None
|
||
std_dev = statistics.stdev(values)
|
||
return std_dev / mean
|
||
|
||
calories_cv = cv([d['dk'] for d in data])
|
||
protein_cv = cv([d['dp'] for d in data if d['dp']])
|
||
fat_cv = cv([d['df'] for d in data if d['df']])
|
||
carbs_cv = cv([d['dc'] for d in data if d['dc']])
|
||
|
||
cv_values = [v for v in [calories_cv, protein_cv, fat_cv, carbs_cv] if v is not None]
|
||
|
||
if not cv_values:
|
||
return None
|
||
|
||
avg_cv = sum(cv_values) / len(cv_values)
|
||
|
||
if avg_cv < 0.2:
|
||
score = 100
|
||
elif avg_cv < 0.3:
|
||
score = 85
|
||
elif avg_cv < 0.4:
|
||
score = 70
|
||
elif avg_cv < 0.5:
|
||
score = 55
|
||
else:
|
||
score = max(30, 100 - (avg_cv * 100))
|
||
|
||
return int(score)
|
||
|
||
|
||
def calculate_intake_volatility(profile_id: str) -> Optional[str]:
|
||
"""
|
||
Classify intake volatility: 'stable', 'moderate', 'high'
|
||
"""
|
||
consistency = calculate_macro_consistency_score(profile_id)
|
||
|
||
if consistency is None:
|
||
return None
|
||
|
||
if consistency >= 80:
|
||
return 'stable'
|
||
elif consistency >= 60:
|
||
return 'moderate'
|
||
else:
|
||
return 'high'
|
||
|
||
|
||
def calculate_nutrition_score(profile_id: str, focus_weights: Optional[Dict] = None) -> Optional[int]:
|
||
"""
|
||
Nutrition adherence score 0-100
|
||
Weighted by user's nutrition-related focus areas
|
||
"""
|
||
if focus_weights is None:
|
||
# Import here to avoid circular dependency
|
||
from data_layer.scores import get_user_focus_weights
|
||
focus_weights = get_user_focus_weights(profile_id)
|
||
|
||
# Nutrition-related focus areas (English keys from DB; Gewichte immer float)
|
||
protein_intake = float(focus_weights.get('protein_intake', 0) or 0)
|
||
calorie_balance = float(focus_weights.get('calorie_balance', 0) or 0)
|
||
macro_consistency = float(focus_weights.get('macro_consistency', 0) or 0)
|
||
meal_timing = float(focus_weights.get('meal_timing', 0) or 0)
|
||
hydration = float(focus_weights.get('hydration', 0) or 0)
|
||
|
||
total_nutrition_weight = (
|
||
protein_intake + calorie_balance + macro_consistency + meal_timing + hydration
|
||
)
|
||
|
||
if total_nutrition_weight == 0:
|
||
return None # No nutrition goals
|
||
|
||
components = []
|
||
|
||
# 1. Calorie target adherence (if calorie_balance goal active)
|
||
if calorie_balance > 0:
|
||
calorie_score = _score_calorie_adherence(profile_id)
|
||
if calorie_score is not None:
|
||
components.append(('calories', calorie_score, calorie_balance))
|
||
|
||
# 2. Protein target adherence (if protein_intake goal active)
|
||
if protein_intake > 0:
|
||
protein_score = calculate_protein_adequacy_28d(profile_id)
|
||
if protein_score is not None:
|
||
components.append(('protein', protein_score, protein_intake))
|
||
|
||
# 3. Intake consistency (if macro_consistency goal active)
|
||
if macro_consistency > 0:
|
||
consistency_score = calculate_macro_consistency_score(profile_id)
|
||
if consistency_score is not None:
|
||
components.append(('consistency', consistency_score, macro_consistency))
|
||
|
||
# 4. Macro balance (always relevant if any nutrition goal)
|
||
if total_nutrition_weight > 0:
|
||
macro_score = _score_macro_balance(profile_id)
|
||
if macro_score is not None:
|
||
# Use 20% of total weight for macro balance
|
||
components.append(('macros', macro_score, total_nutrition_weight * 0.2))
|
||
|
||
if not components:
|
||
return None
|
||
|
||
# Weighted average (float: DB-Werte können Decimal sein)
|
||
total_score = sum(float(score) * float(weight) for _, score, weight in components)
|
||
total_weight = sum(float(weight) for _, _, weight in components)
|
||
|
||
return int(total_score / total_weight)
|
||
|
||
|
||
def _score_calorie_adherence(profile_id: str) -> Optional[int]:
|
||
"""Score calorie target adherence (0–100) using 7d balance vs profiles.goal_mode."""
|
||
balance = calculate_energy_balance_7d(profile_id)
|
||
if balance is None:
|
||
return None
|
||
|
||
mode = _get_profile_goal_mode(profile_id)
|
||
b = float(balance)
|
||
|
||
def _weight_loss(x: float) -> int:
|
||
if -550 <= x <= -250:
|
||
return 100
|
||
if x > 450:
|
||
return 38
|
||
if -750 <= x < -550 or -250 < x <= 120:
|
||
return 82
|
||
if x < -1200:
|
||
return 52
|
||
if -950 <= x < -750 or 120 < x <= 350:
|
||
return 68
|
||
return 58
|
||
|
||
def _surplus_friendly(x: float) -> int:
|
||
if 80 <= x <= 480:
|
||
return 100
|
||
if -120 <= x < 80 or 480 < x <= 700:
|
||
return 86
|
||
if -380 <= x < -120:
|
||
return 68
|
||
if x > 850:
|
||
return 54
|
||
if x < -650:
|
||
return 44
|
||
return 72
|
||
|
||
def _maintenance(x: float) -> int:
|
||
a = abs(x)
|
||
if a <= 200:
|
||
return 100
|
||
if a <= 400:
|
||
return 84
|
||
if a <= 650:
|
||
return 70
|
||
if a <= 900:
|
||
return 55
|
||
return 40
|
||
|
||
if mode == "weight_loss":
|
||
return _weight_loss(b)
|
||
if mode in ("strength", "recomposition"):
|
||
return _surplus_friendly(b)
|
||
return _maintenance(b)
|
||
|
||
|
||
def _score_macro_balance(profile_id: str) -> Optional[int]:
|
||
"""Score macro balance (0-100)"""
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
cur.execute("""
|
||
SELECT protein_g, fat_g, carbs_g, kcal
|
||
FROM nutrition_log
|
||
WHERE profile_id = %s
|
||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
||
AND protein_g IS NOT NULL
|
||
AND fat_g IS NOT NULL
|
||
AND carbs_g IS NOT NULL
|
||
ORDER BY date DESC
|
||
""", (profile_id,))
|
||
|
||
data = cur.fetchall()
|
||
|
||
if len(data) < 18:
|
||
return None
|
||
|
||
# Calculate average macro percentages
|
||
macro_pcts = []
|
||
for row in data:
|
||
total_kcal = (row['protein_g'] * 4) + (row['fat_g'] * 9) + (row['carbs_g'] * 4)
|
||
if total_kcal == 0:
|
||
continue
|
||
|
||
protein_pct = (row['protein_g'] * 4 / total_kcal) * 100
|
||
fat_pct = (row['fat_g'] * 9 / total_kcal) * 100
|
||
carbs_pct = (row['carbs_g'] * 4 / total_kcal) * 100
|
||
|
||
macro_pcts.append((protein_pct, fat_pct, carbs_pct))
|
||
|
||
if not macro_pcts:
|
||
return None
|
||
|
||
avg_protein_pct = sum(p for p, _, _ in macro_pcts) / len(macro_pcts)
|
||
avg_fat_pct = sum(f for _, f, _ in macro_pcts) / len(macro_pcts)
|
||
avg_carbs_pct = sum(c for _, _, c in macro_pcts) / len(macro_pcts)
|
||
|
||
# Reasonable ranges:
|
||
# Protein: 20-35%
|
||
# Fat: 20-35%
|
||
# Carbs: 30-55%
|
||
|
||
score = 100
|
||
|
||
# Protein score
|
||
if not (20 <= avg_protein_pct <= 35):
|
||
if avg_protein_pct < 20:
|
||
score -= (20 - avg_protein_pct) * 2
|
||
else:
|
||
score -= (avg_protein_pct - 35) * 1
|
||
|
||
# Fat score
|
||
if not (20 <= avg_fat_pct <= 35):
|
||
if avg_fat_pct < 20:
|
||
score -= (20 - avg_fat_pct) * 2
|
||
else:
|
||
score -= (avg_fat_pct - 35) * 2
|
||
|
||
# Carbs score
|
||
if not (30 <= avg_carbs_pct <= 55):
|
||
if avg_carbs_pct < 30:
|
||
score -= (30 - avg_carbs_pct) * 1.5
|
||
else:
|
||
score -= (avg_carbs_pct - 55) * 1.5
|
||
|
||
return max(40, min(100, int(score)))
|
||
|
||
|
||
def calculate_energy_availability_warning(profile_id: str) -> Optional[Dict]:
|
||
"""
|
||
Heuristic energy availability warning
|
||
Returns dict with warning level and reasons
|
||
"""
|
||
warnings = []
|
||
severity = 'none' # none, low, medium, high
|
||
|
||
# 1. Check for sustained large deficit
|
||
balance = calculate_energy_balance_7d(profile_id)
|
||
if balance and balance < -800:
|
||
warnings.append('Anhaltend großes Energiedefizit (>800 kcal/Tag)')
|
||
severity = 'medium'
|
||
|
||
if balance < -1200:
|
||
warnings.append('Sehr großes Energiedefizit (>1200 kcal/Tag)')
|
||
severity = 'high'
|
||
|
||
# 2. Check recovery score
|
||
from data_layer.recovery_metrics import calculate_recovery_score_v2
|
||
recovery = calculate_recovery_score_v2(profile_id)
|
||
if recovery and recovery < 50:
|
||
warnings.append('Recovery Score niedrig (<50)')
|
||
if severity == 'none':
|
||
severity = 'low'
|
||
elif severity == 'medium':
|
||
severity = 'high'
|
||
|
||
# 3. Check LBM trend
|
||
from data_layer.body_metrics import calculate_lbm_28d_change
|
||
lbm_change = calculate_lbm_28d_change(profile_id)
|
||
if lbm_change and lbm_change < -1.0:
|
||
warnings.append('Magermasse sinkt (>1kg in 28 Tagen)')
|
||
if severity == 'none':
|
||
severity = 'low'
|
||
elif severity in ['low', 'medium']:
|
||
severity = 'high'
|
||
|
||
# 4. Check sleep quality
|
||
from data_layer.recovery_metrics import calculate_sleep_quality_7d
|
||
sleep_quality = calculate_sleep_quality_7d(profile_id)
|
||
if sleep_quality and sleep_quality < 60:
|
||
warnings.append('Schlafqualität verschlechtert')
|
||
if severity == 'none':
|
||
severity = 'low'
|
||
|
||
if not warnings:
|
||
return None
|
||
|
||
return {
|
||
'severity': severity,
|
||
'warnings': warnings,
|
||
'recommendation': _get_energy_warning_recommendation(severity)
|
||
}
|
||
|
||
|
||
def _get_energy_warning_recommendation(severity: str) -> str:
|
||
"""Get recommendation text based on severity"""
|
||
if severity == 'high':
|
||
return ("Mögliche Unterversorgung erkannt. Erwäge eine Reduktion des Energiedefizits, "
|
||
"Erhöhung der Proteinzufuhr und mehr Erholung. Dies ist keine medizinische Diagnose.")
|
||
elif severity == 'medium':
|
||
return ("Hinweise auf aggressives Defizit. Beobachte Recovery, Schlaf und Magermasse genau.")
|
||
else:
|
||
return ("Leichte Hinweise auf Belastung. Monitoring empfohlen.")
|
||
|
||
|
||
def calculate_fiber_avg_7d(profile_id: str) -> Optional[float]:
|
||
"""Calculate average fiber intake (g/day) last 7 days"""
|
||
# TODO: Implement when fiber column added to nutrition_log
|
||
return None
|
||
|
||
|
||
def calculate_sugar_avg_7d(profile_id: str) -> Optional[float]:
|
||
"""Calculate average sugar intake (g/day) last 7 days"""
|
||
# TODO: Implement when sugar column added to nutrition_log
|
||
return None
|
||
|
||
|
||
def calculate_nutrition_data_quality(profile_id: str) -> Dict[str, any]:
|
||
"""
|
||
Assess data quality for nutrition metrics
|
||
Returns dict with quality score and details
|
||
"""
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
|
||
# Nutrition entries last 28 days
|
||
cur.execute("""
|
||
SELECT COUNT(*) as total,
|
||
COUNT(protein_g) as with_protein,
|
||
COUNT(fat_g) as with_fat,
|
||
COUNT(carbs_g) as with_carbs
|
||
FROM nutrition_log
|
||
WHERE profile_id = %s
|
||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
||
""", (profile_id,))
|
||
|
||
counts = cur.fetchone()
|
||
|
||
total_entries = counts['total']
|
||
protein_coverage = counts['with_protein'] / total_entries if total_entries > 0 else 0
|
||
macro_coverage = min(counts['with_fat'], counts['with_carbs']) / total_entries if total_entries > 0 else 0
|
||
|
||
# Score components
|
||
frequency_score = min(100, (total_entries / 21) * 100) # 21 = 75% of 28 days
|
||
protein_score = protein_coverage * 100
|
||
macro_score = macro_coverage * 100
|
||
|
||
# Overall score (frequency 50%, protein 30%, macros 20%)
|
||
overall_score = int(
|
||
frequency_score * 0.5 +
|
||
protein_score * 0.3 +
|
||
macro_score * 0.2
|
||
)
|
||
|
||
# Confidence level
|
||
if overall_score >= 80:
|
||
confidence = "high"
|
||
elif overall_score >= 60:
|
||
confidence = "medium"
|
||
else:
|
||
confidence = "low"
|
||
|
||
return {
|
||
"overall_score": overall_score,
|
||
"confidence": confidence,
|
||
"measurements": {
|
||
"entries_28d": total_entries,
|
||
"protein_coverage_pct": int(protein_coverage * 100),
|
||
"macro_coverage_pct": int(macro_coverage * 100)
|
||
},
|
||
"component_scores": {
|
||
"frequency": int(frequency_score),
|
||
"protein": int(protein_score),
|
||
"macros": int(macro_score)
|
||
}
|
||
}
|