mitai-jinkendo/backend/data_layer/nutrition_metrics.py

1094 lines
34 KiB
Python
Raw Normal View History

"""
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
"""
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
def get_nutrition_average_data(
profile_id: str,
days: int = 30
) -> 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 = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
cur.execute(
"""SELECT
AVG(kcal) as kcal_avg,
AVG(protein_g) as protein_avg,
AVG(carbs_g) as carbs_avg,
AVG(fat_g) as fat_avg,
COUNT(*) as data_points
FROM nutrition_log
WHERE profile_id=%s AND date >= %s""",
(profile_id, cutoff)
)
row = cur.fetchone()
if not row or row['data_points'] == 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['data_points']
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:
"""
Calculate energy balance (intake - estimated expenditure).
Note: This is a simplified calculation.
For accurate TDEE, use profile-based calculations.
Args:
profile_id: User profile ID
days: Analysis window (default 7)
Returns:
{
"energy_balance": float, # kcal/day (negative = deficit)
"avg_intake": float,
"estimated_tdee": float,
"status": str, # "deficit" | "surplus" | "maintenance"
"confidence": str,
"days_analyzed": int,
"data_points": int
}
"""
with get_db() as conn:
cur = get_cursor(conn)
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
# Get average intake
cur.execute(
"""SELECT AVG(kcal) as avg_kcal, COUNT(*) as cnt
FROM nutrition_log
WHERE profile_id=%s AND date >= %s AND kcal IS NOT NULL""",
(profile_id, cutoff)
)
row = cur.fetchone()
if not row or row['cnt'] == 0:
return {
"energy_balance": 0.0,
"avg_intake": 0.0,
"estimated_tdee": 0.0,
"status": "unknown",
"confidence": "insufficient",
"days_analyzed": days,
"data_points": 0
}
avg_intake = safe_float(row['avg_kcal'])
data_points = row['cnt']
# Simple TDEE estimation (this should be improved with profile data)
# For now, use a rough estimate: 2500 kcal for average adult
estimated_tdee = 2500.0 # TODO: Calculate from profile (weight, height, age, activity)
energy_balance = avg_intake - estimated_tdee
# Determine status
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
}
"""
# Get protein targets
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
AVG(protein_g) as avg_protein,
COUNT(*) as cnt,
SUM(CASE WHEN protein_g >= %s AND protein_g <= %s THEN 1 ELSE 0 END) as days_in_target
FROM nutrition_log
WHERE profile_id=%s AND date >= %s AND protein_g IS NOT NULL""",
(targets['protein_target_low'], targets['protein_target_high'], profile_id, cutoff)
)
row = cur.fetchone()
if not row or row['cnt'] == 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"
}
avg_protein = safe_float(row['avg_protein'])
days_with_data = row['cnt']
days_in_target = row['days_in_target']
protein_g_per_kg = avg_protein / targets['current_weight'] if targets['current_weight'] > 0 else 0.0
# Calculate adequacy score
# 100 = always in target range
# Scale based on percentage of days in target + average relative to target
target_pct = (days_in_target / days_with_data * 100) if days_with_data > 0 else 0
# Bonus/penalty for average protein level
target_mid = (targets['protein_target_low'] + targets['protein_target_high']) / 2
avg_vs_target = (avg_protein / target_mid) if target_mid > 0 else 0
# Weighted score: 70% target days, 30% average level
adequacy_score = int(target_pct * 0.7 + min(avg_vs_target * 100, 100) * 0.3)
adequacy_score = max(0, min(100, adequacy_score)) # Clamp to 0-100
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
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 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)
}
# Calculate macro percentages for each day
import statistics
protein_pcts = []
carbs_pcts = []
fat_pcts = []
for row in rows:
total_kcal = safe_float(row['kcal'])
if total_kcal == 0:
continue
# Convert grams to kcal (protein=4, carbs=4, fat=9)
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)
}
# ============================================================================
# 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]:
"""
Calculate 7-day average energy balance (kcal/day)
Positive = surplus, Negative = deficit
Migration from Phase 0b:
Used by placeholders that need single balance value
"""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""
SELECT kcal
FROM nutrition_log
WHERE profile_id = %s
AND date >= CURRENT_DATE - INTERVAL '7 days'
ORDER BY date DESC
""", (profile_id,))
calories = [row['kcal'] for row in cur.fetchall()]
if len(calories) < 4: # Need at least 4 days
return None
avg_intake = float(sum(calories) / len(calories))
# Get estimated TDEE (simplified - could use Harris-Benedict)
# For now, use weight-based estimate
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
# Simple TDEE estimate: bodyweight (kg) × 30-35
# TODO: Improve with activity level, age, gender
estimated_tdee = float(weight_row['weight']) * 32.5
balance = avg_intake - estimated_tdee
return round(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
cur.execute("""
SELECT protein_g
FROM nutrition_log
WHERE profile_id = %s
AND date >= CURRENT_DATE - INTERVAL '7 days'
AND protein_g IS NOT NULL
ORDER BY date DESC
""", (profile_id,))
protein_values = [row['protein_g'] for row in cur.fetchall()]
if len(protein_values) < 4:
return None
avg_protein = float(sum(protein_values) / len(protein_values))
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'])
# Get protein intake last 7 days
cur.execute("""
SELECT protein_g, date
FROM nutrition_log
WHERE profile_id = %s
AND date >= CURRENT_DATE - INTERVAL '7 days'
AND protein_g IS NOT NULL
ORDER BY date DESC
""", (profile_id,))
protein_data = cur.fetchall()
if len(protein_data) < 4:
return None
# Count days in target range
days_in_target = 0
total_days = len(protein_data)
for row in protein_data:
protein_per_kg = float(row['protein_g']) / weight
if target_low <= protein_per_kg <= target_high:
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)
Based on consistency and target achievement
"""
import statistics
with get_db() as conn:
cur = get_cursor(conn)
# Get average weight (28d)
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'])
# Get protein intake (28d)
cur.execute("""
SELECT protein_g
FROM nutrition_log
WHERE profile_id = %s
AND date >= CURRENT_DATE - INTERVAL '28 days'
AND protein_g IS NOT NULL
""", (profile_id,))
protein_values = [float(row['protein_g']) for row in cur.fetchall()]
if len(protein_values) < 18: # 60% coverage
return None
# Calculate metrics
protein_per_kg_values = [p / weight for p in protein_values]
avg_protein_per_kg = sum(protein_per_kg_values) / len(protein_per_kg_values)
# Target range: 1.6-2.2 g/kg for active individuals
target_mid = 1.9
# Score based on distance from target
if 1.6 <= avg_protein_per_kg <= 2.2:
base_score = 100
elif avg_protein_per_kg < 1.6:
# Below target
base_score = max(40, 100 - ((1.6 - avg_protein_per_kg) * 40))
else:
# Above target (less penalty)
base_score = max(80, 100 - ((avg_protein_per_kg - 2.2) * 10))
# Consistency bonus/penalty
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)
Lower variability = higher score
"""
import statistics
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""
SELECT kcal, protein_g, fat_g, carbs_g
FROM nutrition_log
WHERE profile_id = %s
AND date >= CURRENT_DATE - INTERVAL '28 days'
AND kcal IS NOT NULL
ORDER BY date DESC
""", (profile_id,))
data = cur.fetchall()
if len(data) < 18:
return None
# Calculate coefficient of variation for each macro
def cv(values):
"""Coefficient of variation (std_dev / mean)"""
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['kcal'] for d in data])
protein_cv = cv([d['protein_g'] for d in data if d['protein_g']])
fat_cv = cv([d['fat_g'] for d in data if d['fat_g']])
carbs_cv = cv([d['carbs_g'] for d in data if d['carbs_g']])
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)
# Score: lower CV = higher score
# CV < 0.2 = excellent consistency
# CV > 0.5 = poor consistency
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)
protein_intake = focus_weights.get('protein_intake', 0)
calorie_balance = focus_weights.get('calorie_balance', 0)
macro_consistency = focus_weights.get('macro_consistency', 0)
meal_timing = focus_weights.get('meal_timing', 0)
hydration = focus_weights.get('hydration', 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
total_score = sum(score * weight for _, score, weight in components)
total_weight = sum(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)"""
# Check for energy balance goal
# For now, use energy balance calculation
balance = calculate_energy_balance_7d(profile_id)
if balance is None:
return None
# Score based on whether deficit/surplus aligns with goal
# Simplified: assume weight loss goal = deficit is good
# TODO: Check actual goal type
abs_balance = abs(balance)
# Moderate deficit/surplus = good
if 200 <= abs_balance <= 500:
return 100
elif 100 <= abs_balance <= 700:
return 85
elif abs_balance <= 900:
return 70
elif abs_balance <= 1200:
return 55
else:
return 40
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)
}
}