Compare commits
No commits in common. "4f365e9a693001558ce73cc4f2fc16ffc39362c3" and "56933431f6edbe08d21888378b2d438fc0d9d4fa" have entirely different histories.
4f365e9a69
...
56933431f6
|
|
@ -1,48 +0,0 @@
|
||||||
"""
|
|
||||||
Calculation Engine for Phase 0b - Goal-Aware Placeholders
|
|
||||||
|
|
||||||
This package contains all metric calculation functions for:
|
|
||||||
- Body metrics (K1-K5 from visualization concept)
|
|
||||||
- Nutrition metrics (E1-E5)
|
|
||||||
- Activity metrics (A1-A8)
|
|
||||||
- Recovery metrics (S1)
|
|
||||||
- Correlations (C1-C7)
|
|
||||||
- Scores (Goal Progress Score with Dynamic Focus Areas)
|
|
||||||
|
|
||||||
All calculations are designed to work with Dynamic Focus Areas v2.0.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from .body_metrics import *
|
|
||||||
from .nutrition_metrics import *
|
|
||||||
from .activity_metrics import *
|
|
||||||
from .recovery_metrics import *
|
|
||||||
from .correlation_metrics import *
|
|
||||||
from .scores import *
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
# Body
|
|
||||||
'calculate_weight_7d_median',
|
|
||||||
'calculate_weight_28d_slope',
|
|
||||||
'calculate_fm_28d_change',
|
|
||||||
'calculate_lbm_28d_change',
|
|
||||||
'calculate_body_progress_score',
|
|
||||||
|
|
||||||
# Nutrition
|
|
||||||
'calculate_energy_balance_7d',
|
|
||||||
'calculate_protein_g_per_kg',
|
|
||||||
'calculate_nutrition_score',
|
|
||||||
|
|
||||||
# Activity
|
|
||||||
'calculate_training_minutes_week',
|
|
||||||
'calculate_activity_score',
|
|
||||||
|
|
||||||
# Recovery
|
|
||||||
'calculate_recovery_score_v2',
|
|
||||||
|
|
||||||
# Correlations
|
|
||||||
'calculate_lag_correlation',
|
|
||||||
|
|
||||||
# Meta Scores
|
|
||||||
'calculate_goal_progress_score',
|
|
||||||
'calculate_data_quality_score',
|
|
||||||
]
|
|
||||||
|
|
@ -1,624 +0,0 @@
|
||||||
"""
|
|
||||||
Activity Metrics Calculation Engine
|
|
||||||
|
|
||||||
Implements A1-A8 from visualization concept:
|
|
||||||
- A1: Training volume per week
|
|
||||||
- A2: Intensity distribution
|
|
||||||
- A3: Training quality matrix
|
|
||||||
- A4: Ability balance radar
|
|
||||||
- A5: Load monitoring (proxy-based)
|
|
||||||
- A6: Activity goal alignment score
|
|
||||||
- A7: Rest day compliance
|
|
||||||
- A8: VO2max development
|
|
||||||
|
|
||||||
All calculations work with training_types abilities system.
|
|
||||||
"""
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from typing import Optional, Dict, List
|
|
||||||
import statistics
|
|
||||||
|
|
||||||
from db import get_db, get_cursor
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# A1: Training Volume Calculations
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_training_minutes_week(profile_id: str) -> Optional[int]:
|
|
||||||
"""Calculate total training minutes last 7 days"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT SUM(duration) as total_minutes
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '7 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
row = cur.fetchone()
|
|
||||||
return int(row['total_minutes']) if row and row['total_minutes'] else None
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_training_frequency_7d(profile_id: str) -> Optional[int]:
|
|
||||||
"""Calculate number of training sessions last 7 days"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COUNT(*) as session_count
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '7 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
row = cur.fetchone()
|
|
||||||
return int(row['session_count']) if row else None
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_quality_sessions_pct(profile_id: str) -> Optional[int]:
|
|
||||||
"""Calculate percentage of quality sessions (good or better) last 28 days"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT
|
|
||||||
COUNT(*) as total,
|
|
||||||
COUNT(*) FILTER (WHERE quality_label IN ('excellent', 'very_good', 'good')) as quality_count
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row or row['total'] == 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
pct = (row['quality_count'] / row['total']) * 100
|
|
||||||
return int(pct)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# A2: Intensity Distribution (Proxy-based)
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_intensity_proxy_distribution(profile_id: str) -> Optional[Dict]:
|
|
||||||
"""
|
|
||||||
Calculate intensity distribution (proxy until HR zones available)
|
|
||||||
Returns dict: {'low': X, 'moderate': Y, 'high': Z} in minutes
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT duration, avg_heart_rate, max_heart_rate
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
activities = cur.fetchall()
|
|
||||||
|
|
||||||
if not activities:
|
|
||||||
return None
|
|
||||||
|
|
||||||
low_min = 0
|
|
||||||
moderate_min = 0
|
|
||||||
high_min = 0
|
|
||||||
|
|
||||||
for activity in activities:
|
|
||||||
duration = activity['duration']
|
|
||||||
avg_hr = activity['avg_heart_rate']
|
|
||||||
max_hr = activity['max_heart_rate']
|
|
||||||
|
|
||||||
# Simple proxy classification
|
|
||||||
if avg_hr:
|
|
||||||
# Rough HR-based classification (assumes max HR ~190)
|
|
||||||
if avg_hr < 120:
|
|
||||||
low_min += duration
|
|
||||||
elif avg_hr < 150:
|
|
||||||
moderate_min += duration
|
|
||||||
else:
|
|
||||||
high_min += duration
|
|
||||||
else:
|
|
||||||
# Fallback: assume moderate
|
|
||||||
moderate_min += duration
|
|
||||||
|
|
||||||
return {
|
|
||||||
'low': low_min,
|
|
||||||
'moderate': moderate_min,
|
|
||||||
'high': high_min
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# A4: Ability Balance Calculations
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_ability_balance(profile_id: str) -> Optional[Dict]:
|
|
||||||
"""
|
|
||||||
Calculate ability balance from training_types.abilities
|
|
||||||
Returns dict with scores per ability dimension (0-100)
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT a.duration, tt.abilities
|
|
||||||
FROM activity_log a
|
|
||||||
JOIN training_types tt ON a.training_category = tt.category
|
|
||||||
WHERE a.profile_id = %s
|
|
||||||
AND a.date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
AND tt.abilities IS NOT NULL
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
activities = cur.fetchall()
|
|
||||||
|
|
||||||
if not activities:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Accumulate ability load (duration × ability weight)
|
|
||||||
ability_loads = {
|
|
||||||
'strength': 0,
|
|
||||||
'endurance': 0,
|
|
||||||
'mental': 0,
|
|
||||||
'coordination': 0,
|
|
||||||
'mobility': 0
|
|
||||||
}
|
|
||||||
|
|
||||||
for activity in activities:
|
|
||||||
duration = activity['duration']
|
|
||||||
abilities = activity['abilities'] # JSONB
|
|
||||||
|
|
||||||
if not abilities:
|
|
||||||
continue
|
|
||||||
|
|
||||||
for ability, weight in abilities.items():
|
|
||||||
if ability in ability_loads:
|
|
||||||
ability_loads[ability] += duration * weight
|
|
||||||
|
|
||||||
# Normalize to 0-100 scale
|
|
||||||
max_load = max(ability_loads.values()) if ability_loads else 1
|
|
||||||
if max_load == 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
normalized = {
|
|
||||||
ability: int((load / max_load) * 100)
|
|
||||||
for ability, load in ability_loads.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_ability_balance_strength(profile_id: str) -> Optional[int]:
|
|
||||||
"""Get strength ability score"""
|
|
||||||
balance = calculate_ability_balance(profile_id)
|
|
||||||
return balance['strength'] if balance else None
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_ability_balance_endurance(profile_id: str) -> Optional[int]:
|
|
||||||
"""Get endurance ability score"""
|
|
||||||
balance = calculate_ability_balance(profile_id)
|
|
||||||
return balance['endurance'] if balance else None
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_ability_balance_mental(profile_id: str) -> Optional[int]:
|
|
||||||
"""Get mental ability score"""
|
|
||||||
balance = calculate_ability_balance(profile_id)
|
|
||||||
return balance['mental'] if balance else None
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_ability_balance_coordination(profile_id: str) -> Optional[int]:
|
|
||||||
"""Get coordination ability score"""
|
|
||||||
balance = calculate_ability_balance(profile_id)
|
|
||||||
return balance['coordination'] if balance else None
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_ability_balance_mobility(profile_id: str) -> Optional[int]:
|
|
||||||
"""Get mobility ability score"""
|
|
||||||
balance = calculate_ability_balance(profile_id)
|
|
||||||
return balance['mobility'] if balance else None
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# A5: Load Monitoring (Proxy-based)
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_proxy_internal_load_7d(profile_id: str) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
Calculate proxy internal load (last 7 days)
|
|
||||||
Formula: duration × intensity_factor × quality_factor
|
|
||||||
"""
|
|
||||||
intensity_factors = {'low': 1.0, 'moderate': 1.5, 'high': 2.0}
|
|
||||||
quality_factors = {
|
|
||||||
'excellent': 1.15,
|
|
||||||
'very_good': 1.05,
|
|
||||||
'good': 1.0,
|
|
||||||
'acceptable': 0.9,
|
|
||||||
'poor': 0.75,
|
|
||||||
'excluded': 0.0
|
|
||||||
}
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT duration, avg_heart_rate, quality_label
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '7 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
activities = cur.fetchall()
|
|
||||||
|
|
||||||
if not activities:
|
|
||||||
return None
|
|
||||||
|
|
||||||
total_load = 0
|
|
||||||
|
|
||||||
for activity in activities:
|
|
||||||
duration = activity['duration']
|
|
||||||
avg_hr = activity['avg_heart_rate']
|
|
||||||
quality = activity['quality_label'] or 'good'
|
|
||||||
|
|
||||||
# Determine intensity
|
|
||||||
if avg_hr:
|
|
||||||
if avg_hr < 120:
|
|
||||||
intensity = 'low'
|
|
||||||
elif avg_hr < 150:
|
|
||||||
intensity = 'moderate'
|
|
||||||
else:
|
|
||||||
intensity = 'high'
|
|
||||||
else:
|
|
||||||
intensity = 'moderate'
|
|
||||||
|
|
||||||
load = duration * intensity_factors[intensity] * quality_factors.get(quality, 1.0)
|
|
||||||
total_load += load
|
|
||||||
|
|
||||||
return int(total_load)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_monotony_score(profile_id: str) -> Optional[float]:
|
|
||||||
"""
|
|
||||||
Calculate training monotony (last 7 days)
|
|
||||||
Monotony = mean daily load / std dev daily load
|
|
||||||
Higher = more monotonous
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT date, SUM(duration) as daily_duration
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '7 days'
|
|
||||||
GROUP BY date
|
|
||||||
ORDER BY date
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
daily_loads = [row['daily_duration'] for row in cur.fetchall()]
|
|
||||||
|
|
||||||
if len(daily_loads) < 4:
|
|
||||||
return None
|
|
||||||
|
|
||||||
mean_load = sum(daily_loads) / len(daily_loads)
|
|
||||||
std_dev = statistics.stdev(daily_loads)
|
|
||||||
|
|
||||||
if std_dev == 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
monotony = mean_load / std_dev
|
|
||||||
return round(monotony, 2)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_strain_score(profile_id: str) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
Calculate training strain (last 7 days)
|
|
||||||
Strain = weekly load × monotony
|
|
||||||
"""
|
|
||||||
weekly_load = calculate_proxy_internal_load_7d(profile_id)
|
|
||||||
monotony = calculate_monotony_score(profile_id)
|
|
||||||
|
|
||||||
if weekly_load is None or monotony is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
strain = weekly_load * monotony
|
|
||||||
return int(strain)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# A6: Activity Goal Alignment Score (Dynamic Focus Areas)
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_activity_score(profile_id: str, focus_weights: Optional[Dict] = None) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
Activity goal alignment score 0-100
|
|
||||||
Weighted by user's activity-related focus areas
|
|
||||||
"""
|
|
||||||
if focus_weights is None:
|
|
||||||
from calculations.scores import get_user_focus_weights
|
|
||||||
focus_weights = get_user_focus_weights(profile_id)
|
|
||||||
|
|
||||||
# Activity-related focus areas
|
|
||||||
activity_focus = {
|
|
||||||
'kraftaufbau': focus_weights.get('kraftaufbau', 0),
|
|
||||||
'cardio': focus_weights.get('cardio', 0),
|
|
||||||
'bewegungsumfang': focus_weights.get('bewegungsumfang', 0),
|
|
||||||
'trainingsqualität': focus_weights.get('trainingsqualität', 0),
|
|
||||||
'ability_balance': focus_weights.get('ability_balance', 0),
|
|
||||||
}
|
|
||||||
|
|
||||||
total_activity_weight = sum(activity_focus.values())
|
|
||||||
|
|
||||||
if total_activity_weight == 0:
|
|
||||||
return None # No activity goals
|
|
||||||
|
|
||||||
components = []
|
|
||||||
|
|
||||||
# 1. Weekly minutes (if bewegungsumfang goal)
|
|
||||||
if activity_focus['bewegungsumfang'] > 0:
|
|
||||||
minutes = calculate_training_minutes_week(profile_id)
|
|
||||||
if minutes is not None:
|
|
||||||
# WHO: 150-300 min/week
|
|
||||||
if 150 <= minutes <= 300:
|
|
||||||
minutes_score = 100
|
|
||||||
elif minutes < 150:
|
|
||||||
minutes_score = max(40, (minutes / 150) * 100)
|
|
||||||
else:
|
|
||||||
minutes_score = max(80, 100 - ((minutes - 300) / 10))
|
|
||||||
|
|
||||||
components.append(('minutes', minutes_score, activity_focus['bewegungsumfang']))
|
|
||||||
|
|
||||||
# 2. Quality sessions (if trainingsqualität goal)
|
|
||||||
if activity_focus['trainingsqualität'] > 0:
|
|
||||||
quality_pct = calculate_quality_sessions_pct(profile_id)
|
|
||||||
if quality_pct is not None:
|
|
||||||
components.append(('quality', quality_pct, activity_focus['trainingsqualität']))
|
|
||||||
|
|
||||||
# 3. Strength presence (if kraftaufbau goal)
|
|
||||||
if activity_focus['kraftaufbau'] > 0:
|
|
||||||
strength_score = _score_strength_presence(profile_id)
|
|
||||||
if strength_score is not None:
|
|
||||||
components.append(('strength', strength_score, activity_focus['kraftaufbau']))
|
|
||||||
|
|
||||||
# 4. Cardio presence (if cardio goal)
|
|
||||||
if activity_focus['cardio'] > 0:
|
|
||||||
cardio_score = _score_cardio_presence(profile_id)
|
|
||||||
if cardio_score is not None:
|
|
||||||
components.append(('cardio', cardio_score, activity_focus['cardio']))
|
|
||||||
|
|
||||||
# 5. Ability balance (if ability_balance goal)
|
|
||||||
if activity_focus['ability_balance'] > 0:
|
|
||||||
balance_score = _score_ability_balance(profile_id)
|
|
||||||
if balance_score is not None:
|
|
||||||
components.append(('balance', balance_score, activity_focus['ability_balance']))
|
|
||||||
|
|
||||||
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_strength_presence(profile_id: str) -> Optional[int]:
|
|
||||||
"""Score strength training presence (0-100)"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COUNT(DISTINCT date) as strength_days
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '7 days'
|
|
||||||
AND training_category = 'strength'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
return None
|
|
||||||
|
|
||||||
strength_days = row['strength_days']
|
|
||||||
|
|
||||||
# Target: 2-4 days/week
|
|
||||||
if 2 <= strength_days <= 4:
|
|
||||||
return 100
|
|
||||||
elif strength_days == 1:
|
|
||||||
return 60
|
|
||||||
elif strength_days == 5:
|
|
||||||
return 85
|
|
||||||
elif strength_days == 0:
|
|
||||||
return 0
|
|
||||||
else:
|
|
||||||
return 70
|
|
||||||
|
|
||||||
|
|
||||||
def _score_cardio_presence(profile_id: str) -> Optional[int]:
|
|
||||||
"""Score cardio training presence (0-100)"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COUNT(DISTINCT date) as cardio_days, SUM(duration) as cardio_minutes
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '7 days'
|
|
||||||
AND training_category = 'cardio'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
return None
|
|
||||||
|
|
||||||
cardio_days = row['cardio_days']
|
|
||||||
cardio_minutes = row['cardio_minutes'] or 0
|
|
||||||
|
|
||||||
# Target: 3-5 days/week, 150+ minutes
|
|
||||||
day_score = min(100, (cardio_days / 4) * 100)
|
|
||||||
minute_score = min(100, (cardio_minutes / 150) * 100)
|
|
||||||
|
|
||||||
return int((day_score + minute_score) / 2)
|
|
||||||
|
|
||||||
|
|
||||||
def _score_ability_balance(profile_id: str) -> Optional[int]:
|
|
||||||
"""Score ability balance (0-100)"""
|
|
||||||
balance = calculate_ability_balance(profile_id)
|
|
||||||
|
|
||||||
if not balance:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Good balance = all abilities > 40, std_dev < 30
|
|
||||||
values = list(balance.values())
|
|
||||||
min_value = min(values)
|
|
||||||
std_dev = statistics.stdev(values) if len(values) > 1 else 0
|
|
||||||
|
|
||||||
# Score based on minimum coverage and balance
|
|
||||||
min_score = min(100, min_value * 2) # Want all > 50
|
|
||||||
balance_score = max(0, 100 - (std_dev * 2)) # Want low std_dev
|
|
||||||
|
|
||||||
return int((min_score + balance_score) / 2)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# A7: Rest Day Compliance
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_rest_day_compliance(profile_id: str) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
Calculate rest day compliance percentage (last 28 days)
|
|
||||||
Returns percentage of planned rest days that were respected
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Get planned rest days
|
|
||||||
cur.execute("""
|
|
||||||
SELECT date, rest_type
|
|
||||||
FROM rest_days
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
rest_days = {row['date']: row['rest_type'] for row in cur.fetchall()}
|
|
||||||
|
|
||||||
if not rest_days:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Check if training occurred on rest days
|
|
||||||
cur.execute("""
|
|
||||||
SELECT date, training_category
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
training_days = {}
|
|
||||||
for row in cur.fetchall():
|
|
||||||
if row['date'] not in training_days:
|
|
||||||
training_days[row['date']] = []
|
|
||||||
training_days[row['date']].append(row['training_category'])
|
|
||||||
|
|
||||||
# Count compliance
|
|
||||||
compliant = 0
|
|
||||||
total = len(rest_days)
|
|
||||||
|
|
||||||
for rest_date, rest_type in rest_days.items():
|
|
||||||
if rest_date not in training_days:
|
|
||||||
# Full rest = compliant
|
|
||||||
compliant += 1
|
|
||||||
else:
|
|
||||||
# Check if training violates rest type
|
|
||||||
categories = training_days[rest_date]
|
|
||||||
if rest_type == 'strength_rest' and 'strength' not in categories:
|
|
||||||
compliant += 1
|
|
||||||
elif rest_type == 'cardio_rest' and 'cardio' not in categories:
|
|
||||||
compliant += 1
|
|
||||||
# If rest_type == 'recovery', any training = non-compliant
|
|
||||||
|
|
||||||
compliance_pct = (compliant / total) * 100
|
|
||||||
return int(compliance_pct)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# A8: VO2max Development
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_vo2max_trend_28d(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate VO2max trend (change over 28 days)"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT vo2_max, date
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND vo2_max IS NOT NULL
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
ORDER BY date DESC
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
measurements = cur.fetchall()
|
|
||||||
|
|
||||||
if len(measurements) < 2:
|
|
||||||
return None
|
|
||||||
|
|
||||||
recent = measurements[0]['vo2_max']
|
|
||||||
oldest = measurements[-1]['vo2_max']
|
|
||||||
|
|
||||||
change = recent - oldest
|
|
||||||
return round(change, 1)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Data Quality Assessment
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_activity_data_quality(profile_id: str) -> Dict[str, any]:
|
|
||||||
"""
|
|
||||||
Assess data quality for activity metrics
|
|
||||||
Returns dict with quality score and details
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Activity entries last 28 days
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COUNT(*) as total,
|
|
||||||
COUNT(avg_heart_rate) as with_hr,
|
|
||||||
COUNT(quality_label) as with_quality
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
counts = cur.fetchone()
|
|
||||||
|
|
||||||
total_entries = counts['total']
|
|
||||||
hr_coverage = counts['with_hr'] / total_entries if total_entries > 0 else 0
|
|
||||||
quality_coverage = counts['with_quality'] / total_entries if total_entries > 0 else 0
|
|
||||||
|
|
||||||
# Score components
|
|
||||||
frequency_score = min(100, (total_entries / 15) * 100) # 15 = ~4 sessions/week
|
|
||||||
hr_score = hr_coverage * 100
|
|
||||||
quality_score = quality_coverage * 100
|
|
||||||
|
|
||||||
# Overall score
|
|
||||||
overall_score = int(
|
|
||||||
frequency_score * 0.5 +
|
|
||||||
hr_score * 0.25 +
|
|
||||||
quality_score * 0.25
|
|
||||||
)
|
|
||||||
|
|
||||||
if overall_score >= 80:
|
|
||||||
confidence = "high"
|
|
||||||
elif overall_score >= 60:
|
|
||||||
confidence = "medium"
|
|
||||||
else:
|
|
||||||
confidence = "low"
|
|
||||||
|
|
||||||
return {
|
|
||||||
"overall_score": overall_score,
|
|
||||||
"confidence": confidence,
|
|
||||||
"measurements": {
|
|
||||||
"activities_28d": total_entries,
|
|
||||||
"hr_coverage_pct": int(hr_coverage * 100),
|
|
||||||
"quality_coverage_pct": int(quality_coverage * 100)
|
|
||||||
},
|
|
||||||
"component_scores": {
|
|
||||||
"frequency": int(frequency_score),
|
|
||||||
"hr": int(hr_score),
|
|
||||||
"quality": int(quality_score)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,554 +0,0 @@
|
||||||
"""
|
|
||||||
Body Metrics Calculation Engine
|
|
||||||
|
|
||||||
Implements K1-K5 from visualization concept:
|
|
||||||
- K1: Weight trend + goal projection
|
|
||||||
- K2: Weight/FM/LBM multi-line chart
|
|
||||||
- K3: Circumference panel
|
|
||||||
- K4: Recomposition detector
|
|
||||||
- K5: Body progress score (goal-mode dependent)
|
|
||||||
|
|
||||||
All calculations include data quality/confidence assessment.
|
|
||||||
"""
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from typing import Optional, Dict, Tuple
|
|
||||||
import statistics
|
|
||||||
|
|
||||||
from db import get_db, get_cursor
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# K1: Weight Trend Calculations
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_weight_7d_median(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate 7-day median weight (reduces daily noise)"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT weight_kg
|
|
||||||
FROM weight_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '7 days'
|
|
||||||
ORDER BY date DESC
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
weights = [row['weight_kg'] for row in cur.fetchall()]
|
|
||||||
|
|
||||||
if len(weights) < 4: # Need at least 4 measurements
|
|
||||||
return None
|
|
||||||
|
|
||||||
return round(statistics.median(weights), 1)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_weight_28d_slope(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate 28-day weight slope (kg/day)"""
|
|
||||||
return _calculate_weight_slope(profile_id, days=28)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_weight_90d_slope(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate 90-day weight slope (kg/day)"""
|
|
||||||
return _calculate_weight_slope(profile_id, days=90)
|
|
||||||
|
|
||||||
|
|
||||||
def _calculate_weight_slope(profile_id: str, days: int) -> Optional[float]:
|
|
||||||
"""
|
|
||||||
Calculate weight slope using linear regression
|
|
||||||
Returns kg/day (negative = weight loss)
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT date, weight_kg
|
|
||||||
FROM weight_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '%s days'
|
|
||||||
ORDER BY date
|
|
||||||
""", (profile_id, days))
|
|
||||||
|
|
||||||
data = [(row['date'], row['weight_kg']) for row in cur.fetchall()]
|
|
||||||
|
|
||||||
# Need minimum data points based on period
|
|
||||||
min_points = max(18, int(days * 0.6)) # 60% coverage
|
|
||||||
if len(data) < min_points:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Convert dates to days since start
|
|
||||||
start_date = data[0][0]
|
|
||||||
x_values = [(date - start_date).days for date, _ in data]
|
|
||||||
y_values = [weight for _, weight in data]
|
|
||||||
|
|
||||||
# Linear regression
|
|
||||||
n = len(data)
|
|
||||||
x_mean = sum(x_values) / n
|
|
||||||
y_mean = sum(y_values) / n
|
|
||||||
|
|
||||||
numerator = sum((x - x_mean) * (y - y_mean) for x, y in zip(x_values, y_values))
|
|
||||||
denominator = sum((x - x_mean) ** 2 for x in x_values)
|
|
||||||
|
|
||||||
if denominator == 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
slope = numerator / denominator
|
|
||||||
return round(slope, 4) # kg/day
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_goal_projection_date(profile_id: str, goal_id: str) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Calculate projected date to reach goal based on 28d trend
|
|
||||||
Returns ISO date string or None if unrealistic
|
|
||||||
"""
|
|
||||||
from goal_utils import get_goal_by_id
|
|
||||||
|
|
||||||
goal = get_goal_by_id(goal_id)
|
|
||||||
if not goal or goal['goal_type'] != 'weight':
|
|
||||||
return None
|
|
||||||
|
|
||||||
slope = calculate_weight_28d_slope(profile_id)
|
|
||||||
if not slope or slope == 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
current = goal['current_value']
|
|
||||||
target = goal['target_value']
|
|
||||||
remaining = target - current
|
|
||||||
|
|
||||||
days_needed = remaining / slope
|
|
||||||
|
|
||||||
# Unrealistic if >2 years or negative
|
|
||||||
if days_needed < 0 or days_needed > 730:
|
|
||||||
return None
|
|
||||||
|
|
||||||
projection_date = datetime.now().date() + timedelta(days=int(days_needed))
|
|
||||||
return projection_date.isoformat()
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_goal_progress_pct(current: float, target: float, start: float) -> int:
|
|
||||||
"""
|
|
||||||
Calculate goal progress percentage
|
|
||||||
Returns 0-100 (can exceed 100 if target surpassed)
|
|
||||||
"""
|
|
||||||
if start == target:
|
|
||||||
return 100 if current == target else 0
|
|
||||||
|
|
||||||
progress = ((current - start) / (target - start)) * 100
|
|
||||||
return max(0, min(100, int(progress)))
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# K2: Fat Mass / Lean Mass Calculations
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_fm_28d_change(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate 28-day fat mass change (kg)"""
|
|
||||||
return _calculate_body_composition_change(profile_id, 'fm', 28)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_lbm_28d_change(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate 28-day lean body mass change (kg)"""
|
|
||||||
return _calculate_body_composition_change(profile_id, 'lbm', 28)
|
|
||||||
|
|
||||||
|
|
||||||
def _calculate_body_composition_change(profile_id: str, metric: str, days: int) -> Optional[float]:
|
|
||||||
"""
|
|
||||||
Calculate change in body composition over period
|
|
||||||
metric: 'fm' (fat mass) or 'lbm' (lean mass)
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Get weight and caliper measurements
|
|
||||||
cur.execute("""
|
|
||||||
SELECT w.date, w.weight_kg, c.body_fat_pct
|
|
||||||
FROM weight_log w
|
|
||||||
LEFT JOIN caliper_log c ON w.profile_id = c.profile_id
|
|
||||||
AND w.date = c.date
|
|
||||||
WHERE w.profile_id = %s
|
|
||||||
AND w.date >= CURRENT_DATE - INTERVAL '%s days'
|
|
||||||
ORDER BY w.date DESC
|
|
||||||
""", (profile_id, days))
|
|
||||||
|
|
||||||
data = [
|
|
||||||
{
|
|
||||||
'date': row['date'],
|
|
||||||
'weight': row['weight_kg'],
|
|
||||||
'bf_pct': row['body_fat_pct']
|
|
||||||
}
|
|
||||||
for row in cur.fetchall()
|
|
||||||
if row['body_fat_pct'] is not None # Need BF% for composition
|
|
||||||
]
|
|
||||||
|
|
||||||
if len(data) < 2:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Most recent and oldest measurement
|
|
||||||
recent = data[0]
|
|
||||||
oldest = data[-1]
|
|
||||||
|
|
||||||
# Calculate FM and LBM
|
|
||||||
recent_fm = recent['weight'] * (recent['bf_pct'] / 100)
|
|
||||||
recent_lbm = recent['weight'] - recent_fm
|
|
||||||
|
|
||||||
oldest_fm = oldest['weight'] * (oldest['bf_pct'] / 100)
|
|
||||||
oldest_lbm = oldest['weight'] - oldest_fm
|
|
||||||
|
|
||||||
if metric == 'fm':
|
|
||||||
change = recent_fm - oldest_fm
|
|
||||||
else: # lbm
|
|
||||||
change = recent_lbm - oldest_lbm
|
|
||||||
|
|
||||||
return round(change, 2)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# K3: Circumference Calculations
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_waist_28d_delta(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate 28-day waist circumference change (cm)"""
|
|
||||||
return _calculate_circumference_delta(profile_id, 'c_waist', 28)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_hip_28d_delta(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate 28-day hip circumference change (cm)"""
|
|
||||||
return _calculate_circumference_delta(profile_id, 'c_hip', 28)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_chest_28d_delta(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate 28-day chest circumference change (cm)"""
|
|
||||||
return _calculate_circumference_delta(profile_id, 'c_chest', 28)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_arm_28d_delta(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate 28-day arm circumference change (cm)"""
|
|
||||||
return _calculate_circumference_delta(profile_id, 'c_arm', 28)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_thigh_28d_delta(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate 28-day thigh circumference change (cm, average of L/R)"""
|
|
||||||
left = _calculate_circumference_delta(profile_id, 'c_thigh_l', 28)
|
|
||||||
right = _calculate_circumference_delta(profile_id, 'c_thigh_r', 28)
|
|
||||||
|
|
||||||
if left is None or right is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return round((left + right) / 2, 1)
|
|
||||||
|
|
||||||
|
|
||||||
def _calculate_circumference_delta(profile_id: str, column: str, days: int) -> Optional[float]:
|
|
||||||
"""Calculate change in circumference measurement"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(f"""
|
|
||||||
SELECT {column}
|
|
||||||
FROM circumference_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '%s days'
|
|
||||||
AND {column} IS NOT NULL
|
|
||||||
ORDER BY date DESC
|
|
||||||
LIMIT 1
|
|
||||||
""", (profile_id, days))
|
|
||||||
|
|
||||||
recent = cur.fetchone()
|
|
||||||
if not recent:
|
|
||||||
return None
|
|
||||||
|
|
||||||
cur.execute(f"""
|
|
||||||
SELECT {column}
|
|
||||||
FROM circumference_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date < CURRENT_DATE - INTERVAL '%s days'
|
|
||||||
AND {column} IS NOT NULL
|
|
||||||
ORDER BY date DESC
|
|
||||||
LIMIT 1
|
|
||||||
""", (profile_id, days))
|
|
||||||
|
|
||||||
oldest = cur.fetchone()
|
|
||||||
if not oldest:
|
|
||||||
return None
|
|
||||||
|
|
||||||
change = recent[column] - oldest[column]
|
|
||||||
return round(change, 1)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_waist_hip_ratio(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate current waist-to-hip ratio"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT c_waist, c_hip
|
|
||||||
FROM circumference_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND c_waist IS NOT NULL
|
|
||||||
AND c_hip IS NOT NULL
|
|
||||||
ORDER BY date DESC
|
|
||||||
LIMIT 1
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
return None
|
|
||||||
|
|
||||||
ratio = row['c_waist'] / row['c_hip']
|
|
||||||
return round(ratio, 3)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# K4: Recomposition Detector
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_recomposition_quadrant(profile_id: str) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Determine recomposition quadrant based on 28d changes:
|
|
||||||
- optimal: FM down, LBM up
|
|
||||||
- cut_with_risk: FM down, LBM down
|
|
||||||
- bulk: FM up, LBM up
|
|
||||||
- unfavorable: FM up, LBM down
|
|
||||||
"""
|
|
||||||
fm_change = calculate_fm_28d_change(profile_id)
|
|
||||||
lbm_change = calculate_lbm_28d_change(profile_id)
|
|
||||||
|
|
||||||
if fm_change is None or lbm_change is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if fm_change < 0 and lbm_change > 0:
|
|
||||||
return "optimal"
|
|
||||||
elif fm_change < 0 and lbm_change < 0:
|
|
||||||
return "cut_with_risk"
|
|
||||||
elif fm_change > 0 and lbm_change > 0:
|
|
||||||
return "bulk"
|
|
||||||
else: # fm_change > 0 and lbm_change < 0
|
|
||||||
return "unfavorable"
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# K5: Body Progress Score (Dynamic Focus Areas)
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_body_progress_score(profile_id: str, focus_weights: Optional[Dict] = None) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
Calculate body progress score (0-100) weighted by user's focus areas
|
|
||||||
|
|
||||||
Components:
|
|
||||||
- Weight trend alignment with goals
|
|
||||||
- FM/LBM changes (recomposition quality)
|
|
||||||
- Circumference changes (especially waist)
|
|
||||||
- Goal progress percentage
|
|
||||||
|
|
||||||
Weighted dynamically based on user's focus area priorities
|
|
||||||
"""
|
|
||||||
if focus_weights is None:
|
|
||||||
from calculations.scores import get_user_focus_weights
|
|
||||||
focus_weights = get_user_focus_weights(profile_id)
|
|
||||||
|
|
||||||
# Get all body-related focus area weights
|
|
||||||
body_weight = focus_weights.get('körpergewicht', 0)
|
|
||||||
body_fat_weight = focus_weights.get('körperfett', 0)
|
|
||||||
muscle_weight = focus_weights.get('muskelmasse', 0)
|
|
||||||
|
|
||||||
total_body_weight = body_weight + body_fat_weight + muscle_weight
|
|
||||||
|
|
||||||
if total_body_weight == 0:
|
|
||||||
return None # No body-related goals
|
|
||||||
|
|
||||||
# Calculate component scores (0-100)
|
|
||||||
components = []
|
|
||||||
|
|
||||||
# Weight trend component (if weight goal active)
|
|
||||||
if body_weight > 0:
|
|
||||||
weight_score = _score_weight_trend(profile_id)
|
|
||||||
if weight_score is not None:
|
|
||||||
components.append(('weight', weight_score, body_weight))
|
|
||||||
|
|
||||||
# Body composition component (if BF% or LBM goal active)
|
|
||||||
if body_fat_weight > 0 or muscle_weight > 0:
|
|
||||||
comp_score = _score_body_composition(profile_id)
|
|
||||||
if comp_score is not None:
|
|
||||||
components.append(('composition', comp_score, body_fat_weight + muscle_weight))
|
|
||||||
|
|
||||||
# Waist circumference component (proxy for health)
|
|
||||||
waist_score = _score_waist_trend(profile_id)
|
|
||||||
if waist_score is not None:
|
|
||||||
# Waist gets 20% base weight + bonus from BF% goals
|
|
||||||
waist_weight = 20 + (body_fat_weight * 0.3)
|
|
||||||
components.append(('waist', waist_score, waist_weight))
|
|
||||||
|
|
||||||
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_weight_trend(profile_id: str) -> Optional[int]:
|
|
||||||
"""Score weight trend alignment with goals (0-100)"""
|
|
||||||
from goal_utils import get_goals_by_type
|
|
||||||
|
|
||||||
goals = get_goals_by_type(profile_id, 'weight')
|
|
||||||
if not goals:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Use primary or first active goal
|
|
||||||
goal = next((g for g in goals if g.get('is_primary')), goals[0])
|
|
||||||
|
|
||||||
current = goal.get('current_value')
|
|
||||||
target = goal.get('target_value')
|
|
||||||
start = goal.get('start_value', current)
|
|
||||||
|
|
||||||
if None in [current, target, start]:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Progress percentage
|
|
||||||
progress_pct = calculate_goal_progress_pct(current, target, start)
|
|
||||||
|
|
||||||
# Bonus/penalty based on trend
|
|
||||||
slope = calculate_weight_28d_slope(profile_id)
|
|
||||||
if slope is not None:
|
|
||||||
desired_direction = -1 if target < start else 1
|
|
||||||
actual_direction = -1 if slope < 0 else 1
|
|
||||||
|
|
||||||
if desired_direction == actual_direction:
|
|
||||||
# Moving in right direction
|
|
||||||
score = min(100, progress_pct + 10)
|
|
||||||
else:
|
|
||||||
# Moving in wrong direction
|
|
||||||
score = max(0, progress_pct - 20)
|
|
||||||
else:
|
|
||||||
score = progress_pct
|
|
||||||
|
|
||||||
return int(score)
|
|
||||||
|
|
||||||
|
|
||||||
def _score_body_composition(profile_id: str) -> Optional[int]:
|
|
||||||
"""Score body composition changes (0-100)"""
|
|
||||||
fm_change = calculate_fm_28d_change(profile_id)
|
|
||||||
lbm_change = calculate_lbm_28d_change(profile_id)
|
|
||||||
|
|
||||||
if fm_change is None or lbm_change is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
quadrant = calculate_recomposition_quadrant(profile_id)
|
|
||||||
|
|
||||||
# Scoring by quadrant
|
|
||||||
if quadrant == "optimal":
|
|
||||||
return 100
|
|
||||||
elif quadrant == "cut_with_risk":
|
|
||||||
# Penalty proportional to LBM loss
|
|
||||||
penalty = min(30, abs(lbm_change) * 15)
|
|
||||||
return max(50, 80 - int(penalty))
|
|
||||||
elif quadrant == "bulk":
|
|
||||||
# Score based on FM/LBM ratio
|
|
||||||
if lbm_change > 0 and fm_change > 0:
|
|
||||||
ratio = lbm_change / fm_change
|
|
||||||
if ratio >= 3: # 3:1 LBM:FM = excellent bulk
|
|
||||||
return 90
|
|
||||||
elif ratio >= 2:
|
|
||||||
return 75
|
|
||||||
elif ratio >= 1:
|
|
||||||
return 60
|
|
||||||
else:
|
|
||||||
return 45
|
|
||||||
return 60
|
|
||||||
else: # unfavorable
|
|
||||||
return 20
|
|
||||||
|
|
||||||
|
|
||||||
def _score_waist_trend(profile_id: str) -> Optional[int]:
|
|
||||||
"""Score waist circumference trend (0-100)"""
|
|
||||||
delta = calculate_waist_28d_delta(profile_id)
|
|
||||||
|
|
||||||
if delta is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Waist reduction is almost always positive
|
|
||||||
if delta <= -3: # >3cm reduction
|
|
||||||
return 100
|
|
||||||
elif delta <= -2:
|
|
||||||
return 90
|
|
||||||
elif delta <= -1:
|
|
||||||
return 80
|
|
||||||
elif delta <= 0:
|
|
||||||
return 70
|
|
||||||
elif delta <= 1:
|
|
||||||
return 55
|
|
||||||
elif delta <= 2:
|
|
||||||
return 40
|
|
||||||
else: # >2cm increase
|
|
||||||
return 20
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Data Quality Assessment
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_body_data_quality(profile_id: str) -> Dict[str, any]:
|
|
||||||
"""
|
|
||||||
Assess data quality for body metrics
|
|
||||||
Returns dict with quality score and details
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Weight measurement frequency (last 28 days)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COUNT(*) as count
|
|
||||||
FROM weight_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
weight_count = cur.fetchone()['count']
|
|
||||||
|
|
||||||
# Caliper measurement frequency (last 28 days)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COUNT(*) as count
|
|
||||||
FROM caliper_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
caliper_count = cur.fetchone()['count']
|
|
||||||
|
|
||||||
# Circumference measurement frequency (last 28 days)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COUNT(*) as count
|
|
||||||
FROM circumference_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
circ_count = cur.fetchone()['count']
|
|
||||||
|
|
||||||
# Score components
|
|
||||||
weight_score = min(100, (weight_count / 18) * 100) # 18 = ~65% of 28 days
|
|
||||||
caliper_score = min(100, (caliper_count / 4) * 100) # 4 = weekly
|
|
||||||
circ_score = min(100, (circ_count / 4) * 100)
|
|
||||||
|
|
||||||
# Overall score (weight 50%, caliper 30%, circ 20%)
|
|
||||||
overall_score = int(
|
|
||||||
weight_score * 0.5 +
|
|
||||||
caliper_score * 0.3 +
|
|
||||||
circ_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": {
|
|
||||||
"weight_28d": weight_count,
|
|
||||||
"caliper_28d": caliper_count,
|
|
||||||
"circumference_28d": circ_count
|
|
||||||
},
|
|
||||||
"component_scores": {
|
|
||||||
"weight": int(weight_score),
|
|
||||||
"caliper": int(caliper_score),
|
|
||||||
"circumference": int(circ_score)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,508 +0,0 @@
|
||||||
"""
|
|
||||||
Correlation Metrics Calculation Engine
|
|
||||||
|
|
||||||
Implements C1-C7 from visualization concept:
|
|
||||||
- C1: Energy balance vs. weight change (lagged)
|
|
||||||
- C2: Protein adequacy vs. LBM trend
|
|
||||||
- C3: Training load vs. HRV/RHR (1-3 days delayed)
|
|
||||||
- C4: Sleep duration + regularity vs. recovery
|
|
||||||
- C5: Blood pressure context matrix
|
|
||||||
- C6: Plateau detector
|
|
||||||
- C7: Multi-factor driver panel
|
|
||||||
|
|
||||||
All correlations are clearly marked as exploratory and include:
|
|
||||||
- Effect size
|
|
||||||
- Best lag window
|
|
||||||
- Data point count
|
|
||||||
- Confidence level
|
|
||||||
"""
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from typing import Optional, Dict, List, Tuple
|
|
||||||
import statistics
|
|
||||||
|
|
||||||
from db import get_db, get_cursor
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# C1: Energy Balance vs. Weight Change (Lagged)
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_lag_correlation(profile_id: str, var1: str, var2: str, max_lag_days: int = 14) -> Optional[Dict]:
|
|
||||||
"""
|
|
||||||
Calculate lagged correlation between two variables
|
|
||||||
|
|
||||||
Args:
|
|
||||||
var1: 'energy', 'protein', 'training_load'
|
|
||||||
var2: 'weight', 'lbm', 'hrv', 'rhr'
|
|
||||||
max_lag_days: Maximum lag to test
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{
|
|
||||||
'best_lag': X, # days
|
|
||||||
'correlation': 0.XX, # -1 to 1
|
|
||||||
'direction': 'positive'/'negative'/'none',
|
|
||||||
'confidence': 'high'/'medium'/'low',
|
|
||||||
'data_points': N
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
if var1 == 'energy' and var2 == 'weight':
|
|
||||||
return _correlate_energy_weight(profile_id, max_lag_days)
|
|
||||||
elif var1 == 'protein' and var2 == 'lbm':
|
|
||||||
return _correlate_protein_lbm(profile_id, max_lag_days)
|
|
||||||
elif var1 == 'training_load' and var2 in ['hrv', 'rhr']:
|
|
||||||
return _correlate_load_vitals(profile_id, var2, max_lag_days)
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _correlate_energy_weight(profile_id: str, max_lag: int) -> Optional[Dict]:
|
|
||||||
"""
|
|
||||||
Correlate energy balance with weight change
|
|
||||||
Test lags: 0, 3, 7, 10, 14 days
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Get energy balance data (daily calories - estimated TDEE)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT n.date, n.calories, w.weight_kg
|
|
||||||
FROM nutrition_log n
|
|
||||||
LEFT JOIN weight_log w ON w.profile_id = n.profile_id
|
|
||||||
AND w.date = n.date
|
|
||||||
WHERE n.profile_id = %s
|
|
||||||
AND n.date >= CURRENT_DATE - INTERVAL '90 days'
|
|
||||||
ORDER BY n.date
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
data = cur.fetchall()
|
|
||||||
|
|
||||||
if len(data) < 30:
|
|
||||||
return {
|
|
||||||
'best_lag': None,
|
|
||||||
'correlation': None,
|
|
||||||
'direction': 'none',
|
|
||||||
'confidence': 'low',
|
|
||||||
'data_points': len(data),
|
|
||||||
'reason': 'Insufficient data (<30 days)'
|
|
||||||
}
|
|
||||||
|
|
||||||
# Calculate 7d rolling energy balance
|
|
||||||
# (Simplified - actual implementation would need TDEE estimation)
|
|
||||||
|
|
||||||
# For now, return placeholder
|
|
||||||
return {
|
|
||||||
'best_lag': 7,
|
|
||||||
'correlation': -0.45, # Placeholder
|
|
||||||
'direction': 'negative', # Higher deficit = lower weight (expected)
|
|
||||||
'confidence': 'medium',
|
|
||||||
'data_points': len(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _correlate_protein_lbm(profile_id: str, max_lag: int) -> Optional[Dict]:
|
|
||||||
"""Correlate protein intake with LBM trend"""
|
|
||||||
# TODO: Implement full correlation calculation
|
|
||||||
return {
|
|
||||||
'best_lag': 0,
|
|
||||||
'correlation': 0.32, # Placeholder
|
|
||||||
'direction': 'positive',
|
|
||||||
'confidence': 'medium',
|
|
||||||
'data_points': 28
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _correlate_load_vitals(profile_id: str, vital: str, max_lag: int) -> Optional[Dict]:
|
|
||||||
"""
|
|
||||||
Correlate training load with HRV or RHR
|
|
||||||
Test lags: 1, 2, 3 days
|
|
||||||
"""
|
|
||||||
# TODO: Implement full correlation calculation
|
|
||||||
if vital == 'hrv':
|
|
||||||
return {
|
|
||||||
'best_lag': 1,
|
|
||||||
'correlation': -0.38, # Negative = high load reduces HRV (expected)
|
|
||||||
'direction': 'negative',
|
|
||||||
'confidence': 'medium',
|
|
||||||
'data_points': 25
|
|
||||||
}
|
|
||||||
else: # rhr
|
|
||||||
return {
|
|
||||||
'best_lag': 1,
|
|
||||||
'correlation': 0.42, # Positive = high load increases RHR (expected)
|
|
||||||
'direction': 'positive',
|
|
||||||
'confidence': 'medium',
|
|
||||||
'data_points': 25
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# C4: Sleep vs. Recovery Correlation
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_correlation_sleep_recovery(profile_id: str) -> Optional[Dict]:
|
|
||||||
"""
|
|
||||||
Correlate sleep quality/duration with recovery score
|
|
||||||
"""
|
|
||||||
# TODO: Implement full correlation
|
|
||||||
return {
|
|
||||||
'correlation': 0.65, # Strong positive (expected)
|
|
||||||
'direction': 'positive',
|
|
||||||
'confidence': 'high',
|
|
||||||
'data_points': 28
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# C6: Plateau Detector
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_plateau_detected(profile_id: str) -> Optional[Dict]:
|
|
||||||
"""
|
|
||||||
Detect if user is in a plateau based on goal mode
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{
|
|
||||||
'plateau_detected': True/False,
|
|
||||||
'plateau_type': 'weight_loss'/'strength'/'endurance'/None,
|
|
||||||
'confidence': 'high'/'medium'/'low',
|
|
||||||
'duration_days': X,
|
|
||||||
'top_factors': [list of potential causes]
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
from calculations.scores import get_user_focus_weights
|
|
||||||
|
|
||||||
focus_weights = get_user_focus_weights(profile_id)
|
|
||||||
|
|
||||||
if not focus_weights:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Determine primary focus area
|
|
||||||
top_focus = max(focus_weights, key=focus_weights.get)
|
|
||||||
|
|
||||||
# Check for plateau based on focus area
|
|
||||||
if top_focus in ['körpergewicht', 'körperfett']:
|
|
||||||
return _detect_weight_plateau(profile_id)
|
|
||||||
elif top_focus == 'kraftaufbau':
|
|
||||||
return _detect_strength_plateau(profile_id)
|
|
||||||
elif top_focus == 'cardio':
|
|
||||||
return _detect_endurance_plateau(profile_id)
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _detect_weight_plateau(profile_id: str) -> Dict:
|
|
||||||
"""Detect weight loss plateau"""
|
|
||||||
from calculations.body_metrics import calculate_weight_28d_slope
|
|
||||||
from calculations.nutrition_metrics import calculate_nutrition_score
|
|
||||||
|
|
||||||
slope = calculate_weight_28d_slope(profile_id)
|
|
||||||
nutrition_score = calculate_nutrition_score(profile_id)
|
|
||||||
|
|
||||||
if slope is None:
|
|
||||||
return {'plateau_detected': False, 'reason': 'Insufficient data'}
|
|
||||||
|
|
||||||
# Plateau = flat weight for 28 days despite adherence
|
|
||||||
is_plateau = abs(slope) < 0.02 and nutrition_score and nutrition_score > 70
|
|
||||||
|
|
||||||
if is_plateau:
|
|
||||||
factors = []
|
|
||||||
|
|
||||||
# Check potential factors
|
|
||||||
if nutrition_score > 85:
|
|
||||||
factors.append('Hohe Adhärenz trotz Stagnation → mögliche Anpassung des Stoffwechsels')
|
|
||||||
|
|
||||||
# Check if deficit is too small
|
|
||||||
from calculations.nutrition_metrics import calculate_energy_balance_7d
|
|
||||||
balance = calculate_energy_balance_7d(profile_id)
|
|
||||||
if balance and balance > -200:
|
|
||||||
factors.append('Energiedefizit zu gering (<200 kcal/Tag)')
|
|
||||||
|
|
||||||
# Check water retention (if waist is shrinking but weight stable)
|
|
||||||
from calculations.body_metrics import calculate_waist_28d_delta
|
|
||||||
waist_delta = calculate_waist_28d_delta(profile_id)
|
|
||||||
if waist_delta and waist_delta < -1:
|
|
||||||
factors.append('Taillenumfang sinkt → mögliche Wasserretention maskiert Fettabbau')
|
|
||||||
|
|
||||||
return {
|
|
||||||
'plateau_detected': True,
|
|
||||||
'plateau_type': 'weight_loss',
|
|
||||||
'confidence': 'high' if len(factors) >= 2 else 'medium',
|
|
||||||
'duration_days': 28,
|
|
||||||
'top_factors': factors[:3]
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
return {'plateau_detected': False}
|
|
||||||
|
|
||||||
|
|
||||||
def _detect_strength_plateau(profile_id: str) -> Dict:
|
|
||||||
"""Detect strength training plateau"""
|
|
||||||
from calculations.body_metrics import calculate_lbm_28d_change
|
|
||||||
from calculations.activity_metrics import calculate_activity_score
|
|
||||||
from calculations.recovery_metrics import calculate_recovery_score_v2
|
|
||||||
|
|
||||||
lbm_change = calculate_lbm_28d_change(profile_id)
|
|
||||||
activity_score = calculate_activity_score(profile_id)
|
|
||||||
recovery_score = calculate_recovery_score_v2(profile_id)
|
|
||||||
|
|
||||||
if lbm_change is None:
|
|
||||||
return {'plateau_detected': False, 'reason': 'Insufficient data'}
|
|
||||||
|
|
||||||
# Plateau = flat LBM despite high activity score
|
|
||||||
is_plateau = abs(lbm_change) < 0.3 and activity_score and activity_score > 75
|
|
||||||
|
|
||||||
if is_plateau:
|
|
||||||
factors = []
|
|
||||||
|
|
||||||
if recovery_score and recovery_score < 60:
|
|
||||||
factors.append('Recovery Score niedrig → möglicherweise Übertraining')
|
|
||||||
|
|
||||||
from calculations.nutrition_metrics import calculate_protein_adequacy_28d
|
|
||||||
protein_score = calculate_protein_adequacy_28d(profile_id)
|
|
||||||
if protein_score and protein_score < 70:
|
|
||||||
factors.append('Proteinzufuhr unter Zielbereich')
|
|
||||||
|
|
||||||
from calculations.activity_metrics import calculate_monotony_score
|
|
||||||
monotony = calculate_monotony_score(profile_id)
|
|
||||||
if monotony and monotony > 2.0:
|
|
||||||
factors.append('Hohe Trainingsmonotonie → Stimulus-Anpassung')
|
|
||||||
|
|
||||||
return {
|
|
||||||
'plateau_detected': True,
|
|
||||||
'plateau_type': 'strength',
|
|
||||||
'confidence': 'medium',
|
|
||||||
'duration_days': 28,
|
|
||||||
'top_factors': factors[:3]
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
return {'plateau_detected': False}
|
|
||||||
|
|
||||||
|
|
||||||
def _detect_endurance_plateau(profile_id: str) -> Dict:
|
|
||||||
"""Detect endurance plateau"""
|
|
||||||
from calculations.activity_metrics import calculate_training_minutes_week, calculate_monotony_score
|
|
||||||
from calculations.recovery_metrics import calculate_vo2max_trend_28d
|
|
||||||
|
|
||||||
# TODO: Implement when vitals_baseline.vo2_max is populated
|
|
||||||
return {'plateau_detected': False, 'reason': 'VO2max tracking not yet implemented'}
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# C7: Multi-Factor Driver Panel
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_top_drivers(profile_id: str) -> Optional[List[Dict]]:
|
|
||||||
"""
|
|
||||||
Calculate top influencing factors for goal progress
|
|
||||||
|
|
||||||
Returns list of drivers:
|
|
||||||
[
|
|
||||||
{
|
|
||||||
'factor': 'Energiebilanz',
|
|
||||||
'status': 'förderlich'/'neutral'/'hinderlich',
|
|
||||||
'evidence': 'hoch'/'mittel'/'niedrig',
|
|
||||||
'reason': '1-sentence explanation'
|
|
||||||
},
|
|
||||||
...
|
|
||||||
]
|
|
||||||
"""
|
|
||||||
drivers = []
|
|
||||||
|
|
||||||
# 1. Energy balance
|
|
||||||
from calculations.nutrition_metrics import calculate_energy_balance_7d
|
|
||||||
balance = calculate_energy_balance_7d(profile_id)
|
|
||||||
if balance is not None:
|
|
||||||
if -500 <= balance <= -200:
|
|
||||||
status = 'förderlich'
|
|
||||||
reason = f'Moderates Defizit ({int(balance)} kcal/Tag) unterstützt Fettabbau'
|
|
||||||
elif balance < -800:
|
|
||||||
status = 'hinderlich'
|
|
||||||
reason = f'Sehr großes Defizit ({int(balance)} kcal/Tag) → Risiko für Magermasseverlust'
|
|
||||||
elif -200 < balance < 200:
|
|
||||||
status = 'neutral'
|
|
||||||
reason = 'Energiebilanz ausgeglichen'
|
|
||||||
else:
|
|
||||||
status = 'neutral'
|
|
||||||
reason = f'Energieüberschuss ({int(balance)} kcal/Tag)'
|
|
||||||
|
|
||||||
drivers.append({
|
|
||||||
'factor': 'Energiebilanz',
|
|
||||||
'status': status,
|
|
||||||
'evidence': 'hoch',
|
|
||||||
'reason': reason
|
|
||||||
})
|
|
||||||
|
|
||||||
# 2. Protein adequacy
|
|
||||||
from calculations.nutrition_metrics import calculate_protein_adequacy_28d
|
|
||||||
protein_score = calculate_protein_adequacy_28d(profile_id)
|
|
||||||
if protein_score is not None:
|
|
||||||
if protein_score >= 80:
|
|
||||||
status = 'förderlich'
|
|
||||||
reason = f'Proteinzufuhr konstant im Zielbereich (Score: {protein_score})'
|
|
||||||
elif protein_score >= 60:
|
|
||||||
status = 'neutral'
|
|
||||||
reason = f'Proteinzufuhr teilweise im Zielbereich (Score: {protein_score})'
|
|
||||||
else:
|
|
||||||
status = 'hinderlich'
|
|
||||||
reason = f'Proteinzufuhr häufig unter Zielbereich (Score: {protein_score})'
|
|
||||||
|
|
||||||
drivers.append({
|
|
||||||
'factor': 'Proteinzufuhr',
|
|
||||||
'status': status,
|
|
||||||
'evidence': 'hoch',
|
|
||||||
'reason': reason
|
|
||||||
})
|
|
||||||
|
|
||||||
# 3. Sleep duration
|
|
||||||
from calculations.recovery_metrics import calculate_sleep_avg_duration_7d
|
|
||||||
sleep_hours = calculate_sleep_avg_duration_7d(profile_id)
|
|
||||||
if sleep_hours is not None:
|
|
||||||
if sleep_hours >= 7:
|
|
||||||
status = 'förderlich'
|
|
||||||
reason = f'Schlafdauer ausreichend ({sleep_hours:.1f}h/Nacht)'
|
|
||||||
elif sleep_hours >= 6.5:
|
|
||||||
status = 'neutral'
|
|
||||||
reason = f'Schlafdauer knapp ausreichend ({sleep_hours:.1f}h/Nacht)'
|
|
||||||
else:
|
|
||||||
status = 'hinderlich'
|
|
||||||
reason = f'Schlafdauer zu gering ({sleep_hours:.1f}h/Nacht < 7h Empfehlung)'
|
|
||||||
|
|
||||||
drivers.append({
|
|
||||||
'factor': 'Schlafdauer',
|
|
||||||
'status': status,
|
|
||||||
'evidence': 'hoch',
|
|
||||||
'reason': reason
|
|
||||||
})
|
|
||||||
|
|
||||||
# 4. Sleep regularity
|
|
||||||
from calculations.recovery_metrics import calculate_sleep_regularity_proxy
|
|
||||||
regularity = calculate_sleep_regularity_proxy(profile_id)
|
|
||||||
if regularity is not None:
|
|
||||||
if regularity <= 45:
|
|
||||||
status = 'förderlich'
|
|
||||||
reason = f'Schlafrhythmus regelmäßig (Abweichung: {int(regularity)} min)'
|
|
||||||
elif regularity <= 75:
|
|
||||||
status = 'neutral'
|
|
||||||
reason = f'Schlafrhythmus moderat variabel (Abweichung: {int(regularity)} min)'
|
|
||||||
else:
|
|
||||||
status = 'hinderlich'
|
|
||||||
reason = f'Schlafrhythmus stark variabel (Abweichung: {int(regularity)} min)'
|
|
||||||
|
|
||||||
drivers.append({
|
|
||||||
'factor': 'Schlafregelmäßigkeit',
|
|
||||||
'status': status,
|
|
||||||
'evidence': 'mittel',
|
|
||||||
'reason': reason
|
|
||||||
})
|
|
||||||
|
|
||||||
# 5. Training consistency
|
|
||||||
from calculations.activity_metrics import calculate_training_frequency_7d
|
|
||||||
frequency = calculate_training_frequency_7d(profile_id)
|
|
||||||
if frequency is not None:
|
|
||||||
if 3 <= frequency <= 6:
|
|
||||||
status = 'förderlich'
|
|
||||||
reason = f'Trainingsfrequenz im Zielbereich ({frequency}× pro Woche)'
|
|
||||||
elif frequency <= 2:
|
|
||||||
status = 'hinderlich'
|
|
||||||
reason = f'Trainingsfrequenz zu niedrig ({frequency}× pro Woche)'
|
|
||||||
else:
|
|
||||||
status = 'neutral'
|
|
||||||
reason = f'Trainingsfrequenz sehr hoch ({frequency}× pro Woche) → Recovery beachten'
|
|
||||||
|
|
||||||
drivers.append({
|
|
||||||
'factor': 'Trainingskonsistenz',
|
|
||||||
'status': status,
|
|
||||||
'evidence': 'hoch',
|
|
||||||
'reason': reason
|
|
||||||
})
|
|
||||||
|
|
||||||
# 6. Quality sessions
|
|
||||||
from calculations.activity_metrics import calculate_quality_sessions_pct
|
|
||||||
quality_pct = calculate_quality_sessions_pct(profile_id)
|
|
||||||
if quality_pct is not None:
|
|
||||||
if quality_pct >= 75:
|
|
||||||
status = 'förderlich'
|
|
||||||
reason = f'{quality_pct}% der Trainings mit guter Qualität'
|
|
||||||
elif quality_pct >= 50:
|
|
||||||
status = 'neutral'
|
|
||||||
reason = f'{quality_pct}% der Trainings mit guter Qualität'
|
|
||||||
else:
|
|
||||||
status = 'hinderlich'
|
|
||||||
reason = f'Nur {quality_pct}% der Trainings mit guter Qualität'
|
|
||||||
|
|
||||||
drivers.append({
|
|
||||||
'factor': 'Trainingsqualität',
|
|
||||||
'status': status,
|
|
||||||
'evidence': 'mittel',
|
|
||||||
'reason': reason
|
|
||||||
})
|
|
||||||
|
|
||||||
# 7. Recovery score
|
|
||||||
from calculations.recovery_metrics import calculate_recovery_score_v2
|
|
||||||
recovery = calculate_recovery_score_v2(profile_id)
|
|
||||||
if recovery is not None:
|
|
||||||
if recovery >= 70:
|
|
||||||
status = 'förderlich'
|
|
||||||
reason = f'Recovery Score gut ({recovery}/100)'
|
|
||||||
elif recovery >= 50:
|
|
||||||
status = 'neutral'
|
|
||||||
reason = f'Recovery Score moderat ({recovery}/100)'
|
|
||||||
else:
|
|
||||||
status = 'hinderlich'
|
|
||||||
reason = f'Recovery Score niedrig ({recovery}/100) → mehr Erholung nötig'
|
|
||||||
|
|
||||||
drivers.append({
|
|
||||||
'factor': 'Recovery',
|
|
||||||
'status': status,
|
|
||||||
'evidence': 'hoch',
|
|
||||||
'reason': reason
|
|
||||||
})
|
|
||||||
|
|
||||||
# 8. Rest day compliance
|
|
||||||
from calculations.activity_metrics import calculate_rest_day_compliance
|
|
||||||
compliance = calculate_rest_day_compliance(profile_id)
|
|
||||||
if compliance is not None:
|
|
||||||
if compliance >= 80:
|
|
||||||
status = 'förderlich'
|
|
||||||
reason = f'Ruhetage gut eingehalten ({compliance}%)'
|
|
||||||
elif compliance >= 60:
|
|
||||||
status = 'neutral'
|
|
||||||
reason = f'Ruhetage teilweise eingehalten ({compliance}%)'
|
|
||||||
else:
|
|
||||||
status = 'hinderlich'
|
|
||||||
reason = f'Ruhetage häufig ignoriert ({compliance}%) → Übertrainingsrisiko'
|
|
||||||
|
|
||||||
drivers.append({
|
|
||||||
'factor': 'Ruhetagsrespekt',
|
|
||||||
'status': status,
|
|
||||||
'evidence': 'mittel',
|
|
||||||
'reason': reason
|
|
||||||
})
|
|
||||||
|
|
||||||
# Sort by importance: hinderlich first, then förderlich, then neutral
|
|
||||||
priority = {'hinderlich': 0, 'förderlich': 1, 'neutral': 2}
|
|
||||||
drivers.sort(key=lambda d: priority[d['status']])
|
|
||||||
|
|
||||||
return drivers[:8] # Top 8 drivers
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Confidence/Evidence Levels
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_correlation_confidence(data_points: int, correlation: float) -> str:
|
|
||||||
"""
|
|
||||||
Determine confidence level for correlation
|
|
||||||
|
|
||||||
Returns: 'high', 'medium', or 'low'
|
|
||||||
"""
|
|
||||||
# Need sufficient data points
|
|
||||||
if data_points < 20:
|
|
||||||
return 'low'
|
|
||||||
|
|
||||||
# Strong correlation with good data
|
|
||||||
if data_points >= 40 and abs(correlation) >= 0.5:
|
|
||||||
return 'high'
|
|
||||||
elif data_points >= 30 and abs(correlation) >= 0.4:
|
|
||||||
return 'medium'
|
|
||||||
else:
|
|
||||||
return 'low'
|
|
||||||
|
|
@ -1,645 +0,0 @@
|
||||||
"""
|
|
||||||
Nutrition Metrics Calculation Engine
|
|
||||||
|
|
||||||
Implements E1-E5 from visualization concept:
|
|
||||||
- E1: Energy balance vs. weight trend
|
|
||||||
- E2: Protein adequacy (g/kg)
|
|
||||||
- E3: Macro distribution & consistency
|
|
||||||
- E4: Nutrition adherence score
|
|
||||||
- E5: Energy availability warning (heuristic)
|
|
||||||
|
|
||||||
All calculations include data quality assessment.
|
|
||||||
"""
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from typing import Optional, Dict, List
|
|
||||||
import statistics
|
|
||||||
|
|
||||||
from db import get_db, get_cursor
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# E1: Energy Balance Calculations
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_energy_balance_7d(profile_id: str) -> Optional[float]:
|
|
||||||
"""
|
|
||||||
Calculate 7-day average energy balance (kcal/day)
|
|
||||||
Positive = surplus, Negative = deficit
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT calories
|
|
||||||
FROM nutrition_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '7 days'
|
|
||||||
ORDER BY date DESC
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
calories = [row['calories'] for row in cur.fetchall()]
|
|
||||||
|
|
||||||
if len(calories) < 4: # Need at least 4 days
|
|
||||||
return None
|
|
||||||
|
|
||||||
avg_intake = sum(calories) / len(calories)
|
|
||||||
|
|
||||||
# Get estimated TDEE (simplified - could use Harris-Benedict)
|
|
||||||
# For now, use weight-based estimate
|
|
||||||
cur.execute("""
|
|
||||||
SELECT weight_kg
|
|
||||||
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 = weight_row['weight_kg'] * 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'
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# E2: Protein Adequacy Calculations
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
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_kg
|
|
||||||
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_kg = weight_row['weight_kg']
|
|
||||||
|
|
||||||
# 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 = sum(protein_values) / len(protein_values)
|
|
||||||
protein_per_kg = avg_protein / weight_kg
|
|
||||||
|
|
||||||
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_kg
|
|
||||||
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_kg = weight_row['weight_kg']
|
|
||||||
|
|
||||||
# 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 = row['protein_g'] / weight_kg
|
|
||||||
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
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Get average weight (28d)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT AVG(weight_kg) 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_kg = 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 = [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_kg 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)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# E3: Macro Distribution & Consistency
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_macro_consistency_score(profile_id: str) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
Macro consistency score 0-100 (last 28 days)
|
|
||||||
Lower variability = higher score
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT calories, protein_g, fat_g, carbs_g
|
|
||||||
FROM nutrition_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
AND calories 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['calories'] 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'
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# E4: Nutrition Adherence Score (Dynamic Focus Areas)
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
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:
|
|
||||||
from calculations.scores import get_user_focus_weights
|
|
||||||
focus_weights = get_user_focus_weights(profile_id)
|
|
||||||
|
|
||||||
# Nutrition-related focus areas
|
|
||||||
nutrition_focus = {
|
|
||||||
'ernährung_basis': focus_weights.get('ernährung_basis', 0),
|
|
||||||
'ernährung_makros': focus_weights.get('ernährung_makros', 0),
|
|
||||||
'proteinzufuhr': focus_weights.get('proteinzufuhr', 0),
|
|
||||||
'kalorienbilanz': focus_weights.get('kalorienbilanz', 0),
|
|
||||||
}
|
|
||||||
|
|
||||||
total_nutrition_weight = sum(nutrition_focus.values())
|
|
||||||
|
|
||||||
if total_nutrition_weight == 0:
|
|
||||||
return None # No nutrition goals
|
|
||||||
|
|
||||||
components = []
|
|
||||||
|
|
||||||
# 1. Calorie target adherence (if kalorienbilanz goal active)
|
|
||||||
if nutrition_focus['kalorienbilanz'] > 0:
|
|
||||||
calorie_score = _score_calorie_adherence(profile_id)
|
|
||||||
if calorie_score is not None:
|
|
||||||
components.append(('calories', calorie_score, nutrition_focus['kalorienbilanz']))
|
|
||||||
|
|
||||||
# 2. Protein target adherence (always important if any nutrition goal)
|
|
||||||
protein_score = calculate_protein_adequacy_28d(profile_id)
|
|
||||||
if protein_score is not None:
|
|
||||||
# Higher weight if protein-specific goal
|
|
||||||
protein_weight = nutrition_focus['proteinzufuhr'] or (total_nutrition_weight * 0.3)
|
|
||||||
components.append(('protein', protein_score, protein_weight))
|
|
||||||
|
|
||||||
# 3. Intake consistency (always relevant)
|
|
||||||
consistency_score = calculate_macro_consistency_score(profile_id)
|
|
||||||
if consistency_score is not None:
|
|
||||||
consistency_weight = total_nutrition_weight * 0.2
|
|
||||||
components.append(('consistency', consistency_score, consistency_weight))
|
|
||||||
|
|
||||||
# 4. Macro balance (if makros goal active)
|
|
||||||
if nutrition_focus['ernährung_makros'] > 0:
|
|
||||||
macro_score = _score_macro_balance(profile_id)
|
|
||||||
if macro_score is not None:
|
|
||||||
components.append(('macros', macro_score, nutrition_focus['ernährung_makros']))
|
|
||||||
|
|
||||||
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)"""
|
|
||||||
# Get goal (if exists)
|
|
||||||
from goal_utils import get_goals_by_type
|
|
||||||
|
|
||||||
# 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, calories
|
|
||||||
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)))
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# E5: Energy Availability Warning (Heuristic)
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
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 calculations.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 calculations.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 calculations.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.")
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Additional Helper Metrics
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Data Quality Assessment
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,604 +0,0 @@
|
||||||
"""
|
|
||||||
Recovery Metrics Calculation Engine
|
|
||||||
|
|
||||||
Implements improved Recovery Score (S1 from visualization concept):
|
|
||||||
- HRV vs. baseline
|
|
||||||
- RHR vs. baseline
|
|
||||||
- Sleep duration vs. target
|
|
||||||
- Sleep debt calculation
|
|
||||||
- Sleep regularity
|
|
||||||
- Recent load balance
|
|
||||||
- Data quality assessment
|
|
||||||
|
|
||||||
All metrics designed for robust scoring.
|
|
||||||
"""
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from typing import Optional, Dict
|
|
||||||
import statistics
|
|
||||||
|
|
||||||
from db import get_db, get_cursor
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Recovery Score v2 (Improved from v9d)
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_recovery_score_v2(profile_id: str) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
Improved recovery/readiness score (0-100)
|
|
||||||
|
|
||||||
Components:
|
|
||||||
- HRV status (25%)
|
|
||||||
- RHR status (20%)
|
|
||||||
- Sleep duration (20%)
|
|
||||||
- Sleep debt (10%)
|
|
||||||
- Sleep regularity (10%)
|
|
||||||
- Recent load balance (10%)
|
|
||||||
- Data quality (5%)
|
|
||||||
"""
|
|
||||||
components = []
|
|
||||||
|
|
||||||
# 1. HRV status (25%)
|
|
||||||
hrv_score = _score_hrv_vs_baseline(profile_id)
|
|
||||||
if hrv_score is not None:
|
|
||||||
components.append(('hrv', hrv_score, 25))
|
|
||||||
|
|
||||||
# 2. RHR status (20%)
|
|
||||||
rhr_score = _score_rhr_vs_baseline(profile_id)
|
|
||||||
if rhr_score is not None:
|
|
||||||
components.append(('rhr', rhr_score, 20))
|
|
||||||
|
|
||||||
# 3. Sleep duration (20%)
|
|
||||||
sleep_duration_score = _score_sleep_duration(profile_id)
|
|
||||||
if sleep_duration_score is not None:
|
|
||||||
components.append(('sleep_duration', sleep_duration_score, 20))
|
|
||||||
|
|
||||||
# 4. Sleep debt (10%)
|
|
||||||
sleep_debt_score = _score_sleep_debt(profile_id)
|
|
||||||
if sleep_debt_score is not None:
|
|
||||||
components.append(('sleep_debt', sleep_debt_score, 10))
|
|
||||||
|
|
||||||
# 5. Sleep regularity (10%)
|
|
||||||
regularity_score = _score_sleep_regularity(profile_id)
|
|
||||||
if regularity_score is not None:
|
|
||||||
components.append(('regularity', regularity_score, 10))
|
|
||||||
|
|
||||||
# 6. Recent load balance (10%)
|
|
||||||
load_score = _score_recent_load_balance(profile_id)
|
|
||||||
if load_score is not None:
|
|
||||||
components.append(('load', load_score, 10))
|
|
||||||
|
|
||||||
# 7. Data quality (5%)
|
|
||||||
quality_score = _score_recovery_data_quality(profile_id)
|
|
||||||
if quality_score is not None:
|
|
||||||
components.append(('data_quality', quality_score, 5))
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
final_score = int(total_score / total_weight)
|
|
||||||
|
|
||||||
return final_score
|
|
||||||
|
|
||||||
|
|
||||||
def _score_hrv_vs_baseline(profile_id: str) -> Optional[int]:
|
|
||||||
"""Score HRV relative to 28d baseline (0-100)"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Get recent HRV (last 3 days average)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT AVG(hrv) as recent_hrv
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND hrv IS NOT NULL
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '3 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
recent_row = cur.fetchone()
|
|
||||||
if not recent_row or not recent_row['recent_hrv']:
|
|
||||||
return None
|
|
||||||
|
|
||||||
recent_hrv = recent_row['recent_hrv']
|
|
||||||
|
|
||||||
# Get baseline (28d average, excluding last 3 days)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT AVG(hrv) as baseline_hrv
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND hrv IS NOT NULL
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
AND date < CURRENT_DATE - INTERVAL '3 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
baseline_row = cur.fetchone()
|
|
||||||
if not baseline_row or not baseline_row['baseline_hrv']:
|
|
||||||
return None
|
|
||||||
|
|
||||||
baseline_hrv = baseline_row['baseline_hrv']
|
|
||||||
|
|
||||||
# Calculate percentage deviation
|
|
||||||
deviation_pct = ((recent_hrv - baseline_hrv) / baseline_hrv) * 100
|
|
||||||
|
|
||||||
# Score: higher HRV = better recovery
|
|
||||||
if deviation_pct >= 10:
|
|
||||||
return 100
|
|
||||||
elif deviation_pct >= 5:
|
|
||||||
return 90
|
|
||||||
elif deviation_pct >= 0:
|
|
||||||
return 75
|
|
||||||
elif deviation_pct >= -5:
|
|
||||||
return 60
|
|
||||||
elif deviation_pct >= -10:
|
|
||||||
return 45
|
|
||||||
else:
|
|
||||||
return max(20, 45 + int(deviation_pct * 2))
|
|
||||||
|
|
||||||
|
|
||||||
def _score_rhr_vs_baseline(profile_id: str) -> Optional[int]:
|
|
||||||
"""Score RHR relative to 28d baseline (0-100)"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Get recent RHR (last 3 days average)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT AVG(resting_heart_rate) as recent_rhr
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND resting_heart_rate IS NOT NULL
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '3 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
recent_row = cur.fetchone()
|
|
||||||
if not recent_row or not recent_row['recent_rhr']:
|
|
||||||
return None
|
|
||||||
|
|
||||||
recent_rhr = recent_row['recent_rhr']
|
|
||||||
|
|
||||||
# Get baseline (28d average, excluding last 3 days)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT AVG(resting_heart_rate) as baseline_rhr
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND resting_heart_rate IS NOT NULL
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
AND date < CURRENT_DATE - INTERVAL '3 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
baseline_row = cur.fetchone()
|
|
||||||
if not baseline_row or not baseline_row['baseline_rhr']:
|
|
||||||
return None
|
|
||||||
|
|
||||||
baseline_rhr = baseline_row['baseline_rhr']
|
|
||||||
|
|
||||||
# Calculate difference (bpm)
|
|
||||||
difference = recent_rhr - baseline_rhr
|
|
||||||
|
|
||||||
# Score: lower RHR = better recovery
|
|
||||||
if difference <= -3:
|
|
||||||
return 100
|
|
||||||
elif difference <= -1:
|
|
||||||
return 90
|
|
||||||
elif difference <= 1:
|
|
||||||
return 75
|
|
||||||
elif difference <= 3:
|
|
||||||
return 60
|
|
||||||
elif difference <= 5:
|
|
||||||
return 45
|
|
||||||
else:
|
|
||||||
return max(20, 45 - (difference * 5))
|
|
||||||
|
|
||||||
|
|
||||||
def _score_sleep_duration(profile_id: str) -> Optional[int]:
|
|
||||||
"""Score recent sleep duration (0-100)"""
|
|
||||||
avg_sleep_hours = calculate_sleep_avg_duration_7d(profile_id)
|
|
||||||
|
|
||||||
if avg_sleep_hours is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Target: 7-9 hours
|
|
||||||
if 7 <= avg_sleep_hours <= 9:
|
|
||||||
return 100
|
|
||||||
elif 6.5 <= avg_sleep_hours < 7:
|
|
||||||
return 85
|
|
||||||
elif 6 <= avg_sleep_hours < 6.5:
|
|
||||||
return 70
|
|
||||||
elif avg_sleep_hours >= 9.5:
|
|
||||||
return 85 # Too much sleep can indicate fatigue
|
|
||||||
else:
|
|
||||||
return max(40, int(avg_sleep_hours * 10))
|
|
||||||
|
|
||||||
|
|
||||||
def _score_sleep_debt(profile_id: str) -> Optional[int]:
|
|
||||||
"""Score sleep debt (0-100)"""
|
|
||||||
debt_hours = calculate_sleep_debt_hours(profile_id)
|
|
||||||
|
|
||||||
if debt_hours is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Score based on accumulated debt
|
|
||||||
if debt_hours <= 1:
|
|
||||||
return 100
|
|
||||||
elif debt_hours <= 3:
|
|
||||||
return 85
|
|
||||||
elif debt_hours <= 5:
|
|
||||||
return 70
|
|
||||||
elif debt_hours <= 8:
|
|
||||||
return 55
|
|
||||||
else:
|
|
||||||
return max(30, 100 - (debt_hours * 8))
|
|
||||||
|
|
||||||
|
|
||||||
def _score_sleep_regularity(profile_id: str) -> Optional[int]:
|
|
||||||
"""Score sleep regularity (0-100)"""
|
|
||||||
regularity_proxy = calculate_sleep_regularity_proxy(profile_id)
|
|
||||||
|
|
||||||
if regularity_proxy is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# regularity_proxy = mean absolute shift in minutes
|
|
||||||
# Lower = better
|
|
||||||
if regularity_proxy <= 30:
|
|
||||||
return 100
|
|
||||||
elif regularity_proxy <= 45:
|
|
||||||
return 85
|
|
||||||
elif regularity_proxy <= 60:
|
|
||||||
return 70
|
|
||||||
elif regularity_proxy <= 90:
|
|
||||||
return 55
|
|
||||||
else:
|
|
||||||
return max(30, 100 - int(regularity_proxy / 2))
|
|
||||||
|
|
||||||
|
|
||||||
def _score_recent_load_balance(profile_id: str) -> Optional[int]:
|
|
||||||
"""Score recent training load balance (0-100)"""
|
|
||||||
load_3d = calculate_recent_load_balance_3d(profile_id)
|
|
||||||
|
|
||||||
if load_3d is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Proxy load: 0-300 = low, 300-600 = moderate, >600 = high
|
|
||||||
if load_3d < 300:
|
|
||||||
# Under-loading
|
|
||||||
return 90
|
|
||||||
elif load_3d <= 600:
|
|
||||||
# Optimal
|
|
||||||
return 100
|
|
||||||
elif load_3d <= 900:
|
|
||||||
# High but manageable
|
|
||||||
return 75
|
|
||||||
elif load_3d <= 1200:
|
|
||||||
# Very high
|
|
||||||
return 55
|
|
||||||
else:
|
|
||||||
# Excessive
|
|
||||||
return max(30, 100 - (load_3d / 20))
|
|
||||||
|
|
||||||
|
|
||||||
def _score_recovery_data_quality(profile_id: str) -> Optional[int]:
|
|
||||||
"""Score data quality for recovery metrics (0-100)"""
|
|
||||||
quality = calculate_recovery_data_quality(profile_id)
|
|
||||||
return quality['overall_score']
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Individual Recovery Metrics
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_hrv_vs_baseline_pct(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate HRV deviation from baseline (percentage)"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Recent HRV (3d avg)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT AVG(hrv) as recent_hrv
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND hrv IS NOT NULL
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '3 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
recent_row = cur.fetchone()
|
|
||||||
if not recent_row or not recent_row['recent_hrv']:
|
|
||||||
return None
|
|
||||||
|
|
||||||
recent = recent_row['recent_hrv']
|
|
||||||
|
|
||||||
# Baseline (28d avg, excluding last 3d)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT AVG(hrv) as baseline_hrv
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND hrv IS NOT NULL
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
AND date < CURRENT_DATE - INTERVAL '3 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
baseline_row = cur.fetchone()
|
|
||||||
if not baseline_row or not baseline_row['baseline_hrv']:
|
|
||||||
return None
|
|
||||||
|
|
||||||
baseline = baseline_row['baseline_hrv']
|
|
||||||
|
|
||||||
deviation_pct = ((recent - baseline) / baseline) * 100
|
|
||||||
return round(deviation_pct, 1)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_rhr_vs_baseline_pct(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate RHR deviation from baseline (percentage)"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Recent RHR (3d avg)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT AVG(resting_heart_rate) as recent_rhr
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND resting_heart_rate IS NOT NULL
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '3 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
recent_row = cur.fetchone()
|
|
||||||
if not recent_row or not recent_row['recent_rhr']:
|
|
||||||
return None
|
|
||||||
|
|
||||||
recent = recent_row['recent_rhr']
|
|
||||||
|
|
||||||
# Baseline
|
|
||||||
cur.execute("""
|
|
||||||
SELECT AVG(resting_heart_rate) as baseline_rhr
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND resting_heart_rate IS NOT NULL
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
AND date < CURRENT_DATE - INTERVAL '3 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
baseline_row = cur.fetchone()
|
|
||||||
if not baseline_row or not baseline_row['baseline_rhr']:
|
|
||||||
return None
|
|
||||||
|
|
||||||
baseline = baseline_row['baseline_rhr']
|
|
||||||
|
|
||||||
deviation_pct = ((recent - baseline) / baseline) * 100
|
|
||||||
return round(deviation_pct, 1)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_sleep_avg_duration_7d(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate average sleep duration (hours) last 7 days"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT AVG(total_sleep_min) as avg_sleep_min
|
|
||||||
FROM sleep_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '7 days'
|
|
||||||
AND total_sleep_min IS NOT NULL
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row or not row['avg_sleep_min']:
|
|
||||||
return None
|
|
||||||
|
|
||||||
avg_hours = row['avg_sleep_min'] / 60
|
|
||||||
return round(avg_hours, 1)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_sleep_debt_hours(profile_id: str) -> Optional[float]:
|
|
||||||
"""
|
|
||||||
Calculate accumulated sleep debt (hours) last 14 days
|
|
||||||
Assumes 7.5h target per night
|
|
||||||
"""
|
|
||||||
target_hours = 7.5
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT total_sleep_min
|
|
||||||
FROM sleep_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '14 days'
|
|
||||||
AND total_sleep_min IS NOT NULL
|
|
||||||
ORDER BY date DESC
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
sleep_data = [row['total_sleep_min'] for row in cur.fetchall()]
|
|
||||||
|
|
||||||
if len(sleep_data) < 10: # Need at least 10 days
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Calculate cumulative debt
|
|
||||||
total_debt_min = sum(max(0, (target_hours * 60) - sleep_min) for sleep_min in sleep_data)
|
|
||||||
debt_hours = total_debt_min / 60
|
|
||||||
|
|
||||||
return round(debt_hours, 1)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_sleep_regularity_proxy(profile_id: str) -> Optional[float]:
|
|
||||||
"""
|
|
||||||
Sleep regularity proxy: mean absolute shift from previous day (minutes)
|
|
||||||
Lower = more regular
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT bedtime, waketime, date
|
|
||||||
FROM sleep_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '14 days'
|
|
||||||
AND bedtime IS NOT NULL
|
|
||||||
AND waketime IS NOT NULL
|
|
||||||
ORDER BY date
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
sleep_data = cur.fetchall()
|
|
||||||
|
|
||||||
if len(sleep_data) < 7:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Calculate day-to-day shifts
|
|
||||||
shifts = []
|
|
||||||
for i in range(1, len(sleep_data)):
|
|
||||||
prev = sleep_data[i-1]
|
|
||||||
curr = sleep_data[i]
|
|
||||||
|
|
||||||
# Bedtime shift (minutes)
|
|
||||||
prev_bedtime = prev['bedtime']
|
|
||||||
curr_bedtime = curr['bedtime']
|
|
||||||
|
|
||||||
# Convert to minutes since midnight
|
|
||||||
prev_bed_min = prev_bedtime.hour * 60 + prev_bedtime.minute
|
|
||||||
curr_bed_min = curr_bedtime.hour * 60 + curr_bedtime.minute
|
|
||||||
|
|
||||||
# Handle cross-midnight (e.g., 23:00 to 01:00)
|
|
||||||
bed_shift = abs(curr_bed_min - prev_bed_min)
|
|
||||||
if bed_shift > 720: # More than 12 hours = wrapped around
|
|
||||||
bed_shift = 1440 - bed_shift
|
|
||||||
|
|
||||||
shifts.append(bed_shift)
|
|
||||||
|
|
||||||
mean_shift = sum(shifts) / len(shifts)
|
|
||||||
return round(mean_shift, 1)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_recent_load_balance_3d(profile_id: str) -> Optional[int]:
|
|
||||||
"""Calculate proxy internal load last 3 days"""
|
|
||||||
from calculations.activity_metrics import calculate_proxy_internal_load_7d
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT SUM(duration) as total_duration
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '3 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Simplified 3d load (duration-based)
|
|
||||||
return int(row['total_duration'] or 0)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_sleep_quality_7d(profile_id: str) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
Calculate sleep quality score (0-100) based on deep+REM percentage
|
|
||||||
Last 7 days
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT total_sleep_min, deep_min, rem_min
|
|
||||||
FROM sleep_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '7 days'
|
|
||||||
AND total_sleep_min IS NOT NULL
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
sleep_data = cur.fetchall()
|
|
||||||
|
|
||||||
if len(sleep_data) < 4:
|
|
||||||
return None
|
|
||||||
|
|
||||||
quality_scores = []
|
|
||||||
for s in sleep_data:
|
|
||||||
if s['deep_min'] and s['rem_min']:
|
|
||||||
quality_pct = ((s['deep_min'] + s['rem_min']) / s['total_sleep_min']) * 100
|
|
||||||
# 40-60% deep+REM is good
|
|
||||||
if quality_pct >= 45:
|
|
||||||
quality_scores.append(100)
|
|
||||||
elif quality_pct >= 35:
|
|
||||||
quality_scores.append(75)
|
|
||||||
elif quality_pct >= 25:
|
|
||||||
quality_scores.append(50)
|
|
||||||
else:
|
|
||||||
quality_scores.append(30)
|
|
||||||
|
|
||||||
if not quality_scores:
|
|
||||||
return None
|
|
||||||
|
|
||||||
avg_quality = sum(quality_scores) / len(quality_scores)
|
|
||||||
return int(avg_quality)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Data Quality Assessment
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_recovery_data_quality(profile_id: str) -> Dict[str, any]:
|
|
||||||
"""
|
|
||||||
Assess data quality for recovery metrics
|
|
||||||
Returns dict with quality score and details
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# HRV measurements (28d)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COUNT(*) as hrv_count
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND hrv IS NOT NULL
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
hrv_count = cur.fetchone()['hrv_count']
|
|
||||||
|
|
||||||
# RHR measurements (28d)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COUNT(*) as rhr_count
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND resting_heart_rate IS NOT NULL
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
rhr_count = cur.fetchone()['rhr_count']
|
|
||||||
|
|
||||||
# Sleep measurements (28d)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COUNT(*) as sleep_count
|
|
||||||
FROM sleep_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
sleep_count = cur.fetchone()['sleep_count']
|
|
||||||
|
|
||||||
# Score components
|
|
||||||
hrv_score = min(100, (hrv_count / 21) * 100) # 21 = 75% coverage
|
|
||||||
rhr_score = min(100, (rhr_count / 21) * 100)
|
|
||||||
sleep_score = min(100, (sleep_count / 21) * 100)
|
|
||||||
|
|
||||||
# Overall score
|
|
||||||
overall_score = int(
|
|
||||||
hrv_score * 0.3 +
|
|
||||||
rhr_score * 0.3 +
|
|
||||||
sleep_score * 0.4
|
|
||||||
)
|
|
||||||
|
|
||||||
if overall_score >= 80:
|
|
||||||
confidence = "high"
|
|
||||||
elif overall_score >= 60:
|
|
||||||
confidence = "medium"
|
|
||||||
else:
|
|
||||||
confidence = "low"
|
|
||||||
|
|
||||||
return {
|
|
||||||
"overall_score": overall_score,
|
|
||||||
"confidence": confidence,
|
|
||||||
"measurements": {
|
|
||||||
"hrv_28d": hrv_count,
|
|
||||||
"rhr_28d": rhr_count,
|
|
||||||
"sleep_28d": sleep_count
|
|
||||||
},
|
|
||||||
"component_scores": {
|
|
||||||
"hrv": int(hrv_score),
|
|
||||||
"rhr": int(rhr_score),
|
|
||||||
"sleep": int(sleep_score)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,497 +0,0 @@
|
||||||
"""
|
|
||||||
Score Calculation Engine
|
|
||||||
|
|
||||||
Implements meta-scores with Dynamic Focus Areas v2.0 integration:
|
|
||||||
- Goal Progress Score (weighted by user's focus areas)
|
|
||||||
- Data Quality Score
|
|
||||||
- Helper functions for focus area weighting
|
|
||||||
|
|
||||||
All scores are 0-100 with confidence levels.
|
|
||||||
"""
|
|
||||||
from typing import Dict, Optional, List
|
|
||||||
import json
|
|
||||||
|
|
||||||
from db import get_db, get_cursor
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Focus Area Weighting System
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def get_user_focus_weights(profile_id: str) -> Dict[str, float]:
|
|
||||||
"""
|
|
||||||
Get user's focus area weights as dictionary
|
|
||||||
Returns: {'körpergewicht': 30.0, 'kraftaufbau': 25.0, ...}
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT fa.focus_area_id, ufw.weight_pct
|
|
||||||
FROM user_focus_area_weights ufw
|
|
||||||
JOIN focus_area_definitions fa ON ufw.focus_area_id = fa.id
|
|
||||||
WHERE ufw.profile_id = %s
|
|
||||||
AND ufw.weight_pct > 0
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
return {
|
|
||||||
row['focus_area_id']: float(row['weight_pct'])
|
|
||||||
for row in cur.fetchall()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_focus_area_category(focus_area_id: str) -> Optional[str]:
|
|
||||||
"""Get category for a focus area"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT category
|
|
||||||
FROM focus_area_definitions
|
|
||||||
WHERE focus_area_id = %s
|
|
||||||
""", (focus_area_id,))
|
|
||||||
|
|
||||||
row = cur.fetchone()
|
|
||||||
return row['category'] if row else None
|
|
||||||
|
|
||||||
|
|
||||||
def map_focus_to_score_components() -> Dict[str, str]:
|
|
||||||
"""
|
|
||||||
Map focus areas to score components
|
|
||||||
Returns: {'körpergewicht': 'body', 'proteinzufuhr': 'nutrition', ...}
|
|
||||||
"""
|
|
||||||
return {
|
|
||||||
# Körper-Kategorie → body_progress_score
|
|
||||||
'körpergewicht': 'body',
|
|
||||||
'körperfett': 'body',
|
|
||||||
'muskelmasse': 'body',
|
|
||||||
'umfänge': 'body',
|
|
||||||
|
|
||||||
# Ernährung-Kategorie → nutrition_score
|
|
||||||
'ernährung_basis': 'nutrition',
|
|
||||||
'ernährung_makros': 'nutrition',
|
|
||||||
'proteinzufuhr': 'nutrition',
|
|
||||||
'kalorienbilanz': 'nutrition',
|
|
||||||
|
|
||||||
# Aktivität-Kategorie → activity_score
|
|
||||||
'kraftaufbau': 'activity',
|
|
||||||
'cardio': 'activity',
|
|
||||||
'bewegungsumfang': 'activity',
|
|
||||||
'trainingsqualität': 'activity',
|
|
||||||
'ability_balance': 'activity',
|
|
||||||
|
|
||||||
# Recovery-Kategorie → recovery_score
|
|
||||||
'schlaf': 'recovery',
|
|
||||||
'erholung': 'recovery',
|
|
||||||
'ruhetage': 'recovery',
|
|
||||||
|
|
||||||
# Vitalwerte-Kategorie → health_risk_score
|
|
||||||
'herzgesundheit': 'health',
|
|
||||||
'blutdruck': 'health',
|
|
||||||
'vo2max': 'health',
|
|
||||||
|
|
||||||
# Mental-Kategorie → recovery_score (teilweise)
|
|
||||||
'meditation_mindfulness': 'recovery',
|
|
||||||
'stress_management': 'recovery',
|
|
||||||
|
|
||||||
# Lebensstil-Kategorie → mixed
|
|
||||||
'hydration': 'nutrition',
|
|
||||||
'alkohol_moderation': 'nutrition',
|
|
||||||
'supplements': 'nutrition',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_category_weight(profile_id: str, category: str) -> float:
|
|
||||||
"""
|
|
||||||
Calculate total weight for a category
|
|
||||||
Returns sum of all focus area weights in this category
|
|
||||||
"""
|
|
||||||
focus_weights = get_user_focus_weights(profile_id)
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT focus_area_id
|
|
||||||
FROM focus_area_definitions
|
|
||||||
WHERE category = %s
|
|
||||||
""", (category,))
|
|
||||||
|
|
||||||
focus_areas = [row['focus_area_id'] for row in cur.fetchall()]
|
|
||||||
|
|
||||||
total_weight = sum(
|
|
||||||
focus_weights.get(fa, 0)
|
|
||||||
for fa in focus_areas
|
|
||||||
)
|
|
||||||
|
|
||||||
return total_weight
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Goal Progress Score (Meta-Score with Dynamic Weighting)
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_goal_progress_score(profile_id: str) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
Calculate overall goal progress score (0-100)
|
|
||||||
Weighted dynamically based on user's focus area priorities
|
|
||||||
|
|
||||||
This is the main meta-score that combines all sub-scores
|
|
||||||
"""
|
|
||||||
focus_weights = get_user_focus_weights(profile_id)
|
|
||||||
|
|
||||||
if not focus_weights:
|
|
||||||
return None # No goals/focus areas configured
|
|
||||||
|
|
||||||
# Calculate sub-scores
|
|
||||||
from calculations.body_metrics import calculate_body_progress_score
|
|
||||||
from calculations.nutrition_metrics import calculate_nutrition_score
|
|
||||||
from calculations.activity_metrics import calculate_activity_score
|
|
||||||
from calculations.recovery_metrics import calculate_recovery_score_v2
|
|
||||||
|
|
||||||
body_score = calculate_body_progress_score(profile_id, focus_weights)
|
|
||||||
nutrition_score = calculate_nutrition_score(profile_id, focus_weights)
|
|
||||||
activity_score = calculate_activity_score(profile_id, focus_weights)
|
|
||||||
recovery_score = calculate_recovery_score_v2(profile_id)
|
|
||||||
health_risk_score = calculate_health_stability_score(profile_id)
|
|
||||||
|
|
||||||
# Map focus areas to score components
|
|
||||||
focus_to_component = map_focus_to_score_components()
|
|
||||||
|
|
||||||
# Calculate weighted sum
|
|
||||||
total_score = 0.0
|
|
||||||
total_weight = 0.0
|
|
||||||
|
|
||||||
for focus_area_id, weight in focus_weights.items():
|
|
||||||
component = focus_to_component.get(focus_area_id)
|
|
||||||
|
|
||||||
if component == 'body' and body_score is not None:
|
|
||||||
total_score += body_score * weight
|
|
||||||
total_weight += weight
|
|
||||||
elif component == 'nutrition' and nutrition_score is not None:
|
|
||||||
total_score += nutrition_score * weight
|
|
||||||
total_weight += weight
|
|
||||||
elif component == 'activity' and activity_score is not None:
|
|
||||||
total_score += activity_score * weight
|
|
||||||
total_weight += weight
|
|
||||||
elif component == 'recovery' and recovery_score is not None:
|
|
||||||
total_score += recovery_score * weight
|
|
||||||
total_weight += weight
|
|
||||||
elif component == 'health' and health_risk_score is not None:
|
|
||||||
total_score += health_risk_score * weight
|
|
||||||
total_weight += weight
|
|
||||||
|
|
||||||
if total_weight == 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Normalize to 0-100
|
|
||||||
final_score = total_score / total_weight
|
|
||||||
|
|
||||||
return int(final_score)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_health_stability_score(profile_id: str) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
Health stability score (0-100)
|
|
||||||
Components:
|
|
||||||
- Blood pressure status
|
|
||||||
- Sleep quality
|
|
||||||
- Movement baseline
|
|
||||||
- Weight/circumference risk factors
|
|
||||||
- Regularity
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
components = []
|
|
||||||
|
|
||||||
# 1. Blood pressure status (30%)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT systolic, diastolic
|
|
||||||
FROM blood_pressure_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
ORDER BY date DESC
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
bp_readings = cur.fetchall()
|
|
||||||
if bp_readings:
|
|
||||||
bp_score = _score_blood_pressure(bp_readings)
|
|
||||||
components.append(('bp', bp_score, 30))
|
|
||||||
|
|
||||||
# 2. Sleep quality (25%)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT total_sleep_min, deep_min, rem_min
|
|
||||||
FROM sleep_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
ORDER BY date DESC
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
sleep_data = cur.fetchall()
|
|
||||||
if sleep_data:
|
|
||||||
sleep_score = _score_sleep_quality(sleep_data)
|
|
||||||
components.append(('sleep', sleep_score, 25))
|
|
||||||
|
|
||||||
# 3. Movement baseline (20%)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT duration
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '7 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
activities = cur.fetchall()
|
|
||||||
if activities:
|
|
||||||
total_minutes = sum(a['duration'] for a in activities)
|
|
||||||
# WHO recommends 150-300 min/week moderate activity
|
|
||||||
movement_score = min(100, (total_minutes / 150) * 100)
|
|
||||||
components.append(('movement', movement_score, 20))
|
|
||||||
|
|
||||||
# 4. Waist circumference risk (15%)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT c_waist
|
|
||||||
FROM circumference_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND c_waist IS NOT NULL
|
|
||||||
ORDER BY date DESC
|
|
||||||
LIMIT 1
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
waist = cur.fetchone()
|
|
||||||
if waist:
|
|
||||||
# Gender-specific thresholds (simplified - should use profile gender)
|
|
||||||
# Men: <94cm good, 94-102 elevated, >102 high risk
|
|
||||||
# Women: <80cm good, 80-88 elevated, >88 high risk
|
|
||||||
# Using conservative thresholds
|
|
||||||
waist_cm = waist['c_waist']
|
|
||||||
if waist_cm < 88:
|
|
||||||
waist_score = 100
|
|
||||||
elif waist_cm < 94:
|
|
||||||
waist_score = 75
|
|
||||||
elif waist_cm < 102:
|
|
||||||
waist_score = 50
|
|
||||||
else:
|
|
||||||
waist_score = 25
|
|
||||||
components.append(('waist', waist_score, 15))
|
|
||||||
|
|
||||||
# 5. Regularity (10%) - sleep timing consistency
|
|
||||||
if len(sleep_data) >= 7:
|
|
||||||
sleep_times = [s['total_sleep_min'] for s in sleep_data]
|
|
||||||
avg = sum(sleep_times) / len(sleep_times)
|
|
||||||
variance = sum((x - avg) ** 2 for x in sleep_times) / len(sleep_times)
|
|
||||||
std_dev = variance ** 0.5
|
|
||||||
# Lower std_dev = better consistency
|
|
||||||
regularity_score = max(0, 100 - (std_dev * 2))
|
|
||||||
components.append(('regularity', regularity_score, 10))
|
|
||||||
|
|
||||||
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_blood_pressure(readings: List) -> int:
|
|
||||||
"""Score blood pressure readings (0-100)"""
|
|
||||||
# Average last 28 days
|
|
||||||
avg_systolic = sum(r['systolic'] for r in readings) / len(readings)
|
|
||||||
avg_diastolic = sum(r['diastolic'] for r in readings) / len(readings)
|
|
||||||
|
|
||||||
# ESC 2024 Guidelines:
|
|
||||||
# Optimal: <120/80
|
|
||||||
# Normal: 120-129 / 80-84
|
|
||||||
# Elevated: 130-139 / 85-89
|
|
||||||
# Hypertension: ≥140/90
|
|
||||||
|
|
||||||
if avg_systolic < 120 and avg_diastolic < 80:
|
|
||||||
return 100
|
|
||||||
elif avg_systolic < 130 and avg_diastolic < 85:
|
|
||||||
return 85
|
|
||||||
elif avg_systolic < 140 and avg_diastolic < 90:
|
|
||||||
return 65
|
|
||||||
else:
|
|
||||||
return 40
|
|
||||||
|
|
||||||
|
|
||||||
def _score_sleep_quality(sleep_data: List) -> int:
|
|
||||||
"""Score sleep quality (0-100)"""
|
|
||||||
# Average sleep duration and quality
|
|
||||||
avg_total = sum(s['total_sleep_min'] for s in sleep_data) / len(sleep_data)
|
|
||||||
avg_total_hours = avg_total / 60
|
|
||||||
|
|
||||||
# Duration score (7+ hours = good)
|
|
||||||
if avg_total_hours >= 8:
|
|
||||||
duration_score = 100
|
|
||||||
elif avg_total_hours >= 7:
|
|
||||||
duration_score = 85
|
|
||||||
elif avg_total_hours >= 6:
|
|
||||||
duration_score = 65
|
|
||||||
else:
|
|
||||||
duration_score = 40
|
|
||||||
|
|
||||||
# Quality score (deep + REM percentage)
|
|
||||||
quality_scores = []
|
|
||||||
for s in sleep_data:
|
|
||||||
if s['deep_min'] and s['rem_min']:
|
|
||||||
quality_pct = ((s['deep_min'] + s['rem_min']) / s['total_sleep_min']) * 100
|
|
||||||
# 40-60% deep+REM is good
|
|
||||||
if quality_pct >= 45:
|
|
||||||
quality_scores.append(100)
|
|
||||||
elif quality_pct >= 35:
|
|
||||||
quality_scores.append(75)
|
|
||||||
elif quality_pct >= 25:
|
|
||||||
quality_scores.append(50)
|
|
||||||
else:
|
|
||||||
quality_scores.append(30)
|
|
||||||
|
|
||||||
if quality_scores:
|
|
||||||
avg_quality = sum(quality_scores) / len(quality_scores)
|
|
||||||
# Weighted: 60% duration, 40% quality
|
|
||||||
return int(duration_score * 0.6 + avg_quality * 0.4)
|
|
||||||
else:
|
|
||||||
return duration_score
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Data Quality Score
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_data_quality_score(profile_id: str) -> int:
|
|
||||||
"""
|
|
||||||
Overall data quality score (0-100)
|
|
||||||
Combines quality from all modules
|
|
||||||
"""
|
|
||||||
from calculations.body_metrics import calculate_body_data_quality
|
|
||||||
from calculations.nutrition_metrics import calculate_nutrition_data_quality
|
|
||||||
from calculations.activity_metrics import calculate_activity_data_quality
|
|
||||||
from calculations.recovery_metrics import calculate_recovery_data_quality
|
|
||||||
|
|
||||||
body_quality = calculate_body_data_quality(profile_id)
|
|
||||||
nutrition_quality = calculate_nutrition_data_quality(profile_id)
|
|
||||||
activity_quality = calculate_activity_data_quality(profile_id)
|
|
||||||
recovery_quality = calculate_recovery_data_quality(profile_id)
|
|
||||||
|
|
||||||
# Weighted average (all equal weight)
|
|
||||||
total_score = (
|
|
||||||
body_quality['overall_score'] * 0.25 +
|
|
||||||
nutrition_quality['overall_score'] * 0.25 +
|
|
||||||
activity_quality['overall_score'] * 0.25 +
|
|
||||||
recovery_quality['overall_score'] * 0.25
|
|
||||||
)
|
|
||||||
|
|
||||||
return int(total_score)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Top-Weighted Helpers (instead of "primary goal")
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def get_top_priority_goal(profile_id: str) -> Optional[Dict]:
|
|
||||||
"""
|
|
||||||
Get highest priority goal based on:
|
|
||||||
- Progress gap (distance to target)
|
|
||||||
- Focus area weight
|
|
||||||
Returns goal dict or None
|
|
||||||
"""
|
|
||||||
from goal_utils import get_active_goals
|
|
||||||
|
|
||||||
goals = get_active_goals(profile_id)
|
|
||||||
if not goals:
|
|
||||||
return None
|
|
||||||
|
|
||||||
focus_weights = get_user_focus_weights(profile_id)
|
|
||||||
|
|
||||||
for goal in goals:
|
|
||||||
# Progress gap (0-100, higher = further from target)
|
|
||||||
goal['progress_gap'] = 100 - goal.get('progress_pct', 0)
|
|
||||||
|
|
||||||
# Get focus areas for this goal
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT fa.focus_area_id
|
|
||||||
FROM goal_focus_contributions gfc
|
|
||||||
JOIN focus_area_definitions fa ON gfc.focus_area_id = fa.id
|
|
||||||
WHERE gfc.goal_id = %s
|
|
||||||
""", (goal['id'],))
|
|
||||||
|
|
||||||
goal_focus_areas = [row['focus_area_id'] for row in cur.fetchall()]
|
|
||||||
|
|
||||||
# Sum focus weights
|
|
||||||
goal['total_focus_weight'] = sum(
|
|
||||||
focus_weights.get(fa, 0)
|
|
||||||
for fa in goal_focus_areas
|
|
||||||
)
|
|
||||||
|
|
||||||
# Priority score
|
|
||||||
goal['priority_score'] = goal['progress_gap'] * (goal['total_focus_weight'] / 100)
|
|
||||||
|
|
||||||
# Return goal with highest priority score
|
|
||||||
return max(goals, key=lambda g: g.get('priority_score', 0))
|
|
||||||
|
|
||||||
|
|
||||||
def get_top_focus_area(profile_id: str) -> Optional[Dict]:
|
|
||||||
"""
|
|
||||||
Get focus area with highest user weight
|
|
||||||
Returns dict with focus_area_id, label, weight, progress
|
|
||||||
"""
|
|
||||||
focus_weights = get_user_focus_weights(profile_id)
|
|
||||||
|
|
||||||
if not focus_weights:
|
|
||||||
return None
|
|
||||||
|
|
||||||
top_fa_id = max(focus_weights, key=focus_weights.get)
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT focus_area_id, label_de, category
|
|
||||||
FROM focus_area_definitions
|
|
||||||
WHERE focus_area_id = %s
|
|
||||||
""", (top_fa_id,))
|
|
||||||
|
|
||||||
fa_def = cur.fetchone()
|
|
||||||
if not fa_def:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Calculate progress for this focus area
|
|
||||||
progress = calculate_focus_area_progress(profile_id, top_fa_id)
|
|
||||||
|
|
||||||
return {
|
|
||||||
'focus_area_id': top_fa_id,
|
|
||||||
'label': fa_def['label_de'],
|
|
||||||
'category': fa_def['category'],
|
|
||||||
'weight': focus_weights[top_fa_id],
|
|
||||||
'progress': progress
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_focus_area_progress(profile_id: str, focus_area_id: str) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
Calculate progress for a specific focus area (0-100)
|
|
||||||
Average progress of all goals contributing to this focus area
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT g.id, g.progress_pct, gfc.contribution_weight
|
|
||||||
FROM goals g
|
|
||||||
JOIN goal_focus_contributions gfc ON g.id = gfc.goal_id
|
|
||||||
WHERE g.profile_id = %s
|
|
||||||
AND gfc.focus_area_id = (
|
|
||||||
SELECT id FROM focus_area_definitions WHERE focus_area_id = %s
|
|
||||||
)
|
|
||||||
AND g.status = 'active'
|
|
||||||
""", (profile_id, focus_area_id))
|
|
||||||
|
|
||||||
goals = cur.fetchall()
|
|
||||||
|
|
||||||
if not goals:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Weighted average by contribution_weight
|
|
||||||
total_progress = sum(g['progress_pct'] * g['contribution_weight'] for g in goals)
|
|
||||||
total_weight = sum(g['contribution_weight'] for g in goals)
|
|
||||||
|
|
||||||
return int(total_progress / total_weight) if total_weight > 0 else None
|
|
||||||
|
|
@ -464,242 +464,6 @@ def get_vitals_vo2_max(profile_id: str) -> str:
|
||||||
return "nicht verfügbar"
|
return "nicht verfügbar"
|
||||||
|
|
||||||
|
|
||||||
# ── Phase 0b Calculation Engine Integration ──────────────────────────────────
|
|
||||||
|
|
||||||
def _safe_int(func_name: str, profile_id: str) -> str:
|
|
||||||
"""
|
|
||||||
Safely call calculation function and return integer value or fallback.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
func_name: Name of the calculation function (e.g., 'goal_progress_score')
|
|
||||||
profile_id: Profile ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
String representation of integer value or 'nicht verfügbar'
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Import calculations dynamically to avoid circular imports
|
|
||||||
from calculations import scores, body_metrics, nutrition_metrics, activity_metrics, recovery_metrics, correlation_metrics
|
|
||||||
|
|
||||||
# Map function names to actual functions
|
|
||||||
func_map = {
|
|
||||||
'goal_progress_score': scores.calculate_goal_progress_score,
|
|
||||||
'body_progress_score': body_metrics.calculate_body_progress_score,
|
|
||||||
'nutrition_score': nutrition_metrics.calculate_nutrition_score,
|
|
||||||
'activity_score': activity_metrics.calculate_activity_score,
|
|
||||||
'recovery_score_v2': recovery_metrics.calculate_recovery_score_v2,
|
|
||||||
'data_quality_score': scores.calculate_data_quality_score,
|
|
||||||
'top_goal_progress_pct': lambda pid: scores.get_top_priority_goal(pid)['progress_pct'] if scores.get_top_priority_goal(pid) else None,
|
|
||||||
'top_focus_area_progress': lambda pid: scores.get_top_focus_area(pid)['progress'] if scores.get_top_focus_area(pid) else None,
|
|
||||||
'focus_cat_körper_progress': lambda pid: scores.calculate_category_progress(pid, 'körper'),
|
|
||||||
'focus_cat_ernährung_progress': lambda pid: scores.calculate_category_progress(pid, 'ernährung'),
|
|
||||||
'focus_cat_aktivität_progress': lambda pid: scores.calculate_category_progress(pid, 'aktivität'),
|
|
||||||
'focus_cat_recovery_progress': lambda pid: scores.calculate_category_progress(pid, 'recovery'),
|
|
||||||
'focus_cat_vitalwerte_progress': lambda pid: scores.calculate_category_progress(pid, 'vitalwerte'),
|
|
||||||
'focus_cat_mental_progress': lambda pid: scores.calculate_category_progress(pid, 'mental'),
|
|
||||||
'focus_cat_lebensstil_progress': lambda pid: scores.calculate_category_progress(pid, 'lebensstil'),
|
|
||||||
'training_minutes_week': activity_metrics.calculate_training_minutes_week,
|
|
||||||
'training_frequency_7d': activity_metrics.calculate_training_frequency_7d,
|
|
||||||
'quality_sessions_pct': activity_metrics.calculate_quality_sessions_pct,
|
|
||||||
'ability_balance_strength': activity_metrics.calculate_ability_balance_strength,
|
|
||||||
'ability_balance_endurance': activity_metrics.calculate_ability_balance_endurance,
|
|
||||||
'ability_balance_mental': activity_metrics.calculate_ability_balance_mental,
|
|
||||||
'ability_balance_coordination': activity_metrics.calculate_ability_balance_coordination,
|
|
||||||
'ability_balance_mobility': activity_metrics.calculate_ability_balance_mobility,
|
|
||||||
'proxy_internal_load_7d': activity_metrics.calculate_proxy_internal_load_7d,
|
|
||||||
'strain_score': activity_metrics.calculate_strain_score,
|
|
||||||
'rest_day_compliance': activity_metrics.calculate_rest_day_compliance,
|
|
||||||
'protein_adequacy_28d': nutrition_metrics.calculate_protein_adequacy_28d,
|
|
||||||
'macro_consistency_score': nutrition_metrics.calculate_macro_consistency_score,
|
|
||||||
'recent_load_balance_3d': recovery_metrics.calculate_recent_load_balance_3d,
|
|
||||||
'sleep_quality_7d': recovery_metrics.calculate_sleep_quality_7d,
|
|
||||||
}
|
|
||||||
|
|
||||||
func = func_map.get(func_name)
|
|
||||||
if not func:
|
|
||||||
return 'nicht verfügbar'
|
|
||||||
|
|
||||||
result = func(profile_id)
|
|
||||||
return str(int(result)) if result is not None else 'nicht verfügbar'
|
|
||||||
except Exception as e:
|
|
||||||
return 'nicht verfügbar'
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_float(func_name: str, profile_id: str, decimals: int = 1) -> str:
|
|
||||||
"""
|
|
||||||
Safely call calculation function and return float value or fallback.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
func_name: Name of the calculation function
|
|
||||||
profile_id: Profile ID
|
|
||||||
decimals: Number of decimal places
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
String representation of float value or 'nicht verfügbar'
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from calculations import body_metrics, nutrition_metrics, activity_metrics, recovery_metrics, scores
|
|
||||||
|
|
||||||
func_map = {
|
|
||||||
'weight_7d_median': body_metrics.calculate_weight_7d_median,
|
|
||||||
'weight_28d_slope': body_metrics.calculate_weight_28d_slope,
|
|
||||||
'weight_90d_slope': body_metrics.calculate_weight_90d_slope,
|
|
||||||
'fm_28d_change': body_metrics.calculate_fm_28d_change,
|
|
||||||
'lbm_28d_change': body_metrics.calculate_lbm_28d_change,
|
|
||||||
'waist_28d_delta': body_metrics.calculate_waist_28d_delta,
|
|
||||||
'hip_28d_delta': body_metrics.calculate_hip_28d_delta,
|
|
||||||
'chest_28d_delta': body_metrics.calculate_chest_28d_delta,
|
|
||||||
'arm_28d_delta': body_metrics.calculate_arm_28d_delta,
|
|
||||||
'thigh_28d_delta': body_metrics.calculate_thigh_28d_delta,
|
|
||||||
'waist_hip_ratio': body_metrics.calculate_waist_hip_ratio,
|
|
||||||
'energy_balance_7d': nutrition_metrics.calculate_energy_balance_7d,
|
|
||||||
'protein_g_per_kg': nutrition_metrics.calculate_protein_g_per_kg,
|
|
||||||
'monotony_score': activity_metrics.calculate_monotony_score,
|
|
||||||
'vo2max_trend_28d': activity_metrics.calculate_vo2max_trend_28d,
|
|
||||||
'hrv_vs_baseline_pct': recovery_metrics.calculate_hrv_vs_baseline_pct,
|
|
||||||
'rhr_vs_baseline_pct': recovery_metrics.calculate_rhr_vs_baseline_pct,
|
|
||||||
'sleep_avg_duration_7d': recovery_metrics.calculate_sleep_avg_duration_7d,
|
|
||||||
'sleep_debt_hours': recovery_metrics.calculate_sleep_debt_hours,
|
|
||||||
'sleep_regularity_proxy': recovery_metrics.calculate_sleep_regularity_proxy,
|
|
||||||
'focus_cat_körper_weight': lambda pid: scores.calculate_category_weight(pid, 'körper'),
|
|
||||||
'focus_cat_ernährung_weight': lambda pid: scores.calculate_category_weight(pid, 'ernährung'),
|
|
||||||
'focus_cat_aktivität_weight': lambda pid: scores.calculate_category_weight(pid, 'aktivität'),
|
|
||||||
'focus_cat_recovery_weight': lambda pid: scores.calculate_category_weight(pid, 'recovery'),
|
|
||||||
'focus_cat_vitalwerte_weight': lambda pid: scores.calculate_category_weight(pid, 'vitalwerte'),
|
|
||||||
'focus_cat_mental_weight': lambda pid: scores.calculate_category_weight(pid, 'mental'),
|
|
||||||
'focus_cat_lebensstil_weight': lambda pid: scores.calculate_category_weight(pid, 'lebensstil'),
|
|
||||||
}
|
|
||||||
|
|
||||||
func = func_map.get(func_name)
|
|
||||||
if not func:
|
|
||||||
return 'nicht verfügbar'
|
|
||||||
|
|
||||||
result = func(profile_id)
|
|
||||||
return f"{result:.{decimals}f}" if result is not None else 'nicht verfügbar'
|
|
||||||
except Exception as e:
|
|
||||||
return 'nicht verfügbar'
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_str(func_name: str, profile_id: str) -> str:
|
|
||||||
"""
|
|
||||||
Safely call calculation function and return string value or fallback.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from calculations import body_metrics, nutrition_metrics, activity_metrics, scores, correlation_metrics
|
|
||||||
|
|
||||||
func_map = {
|
|
||||||
'top_goal_name': lambda pid: scores.get_top_priority_goal(pid)['name'] if scores.get_top_priority_goal(pid) else None,
|
|
||||||
'top_goal_status': lambda pid: scores.get_top_priority_goal(pid)['status'] if scores.get_top_priority_goal(pid) else None,
|
|
||||||
'top_focus_area_name': lambda pid: scores.get_top_focus_area(pid)['label'] if scores.get_top_focus_area(pid) else None,
|
|
||||||
'recomposition_quadrant': body_metrics.calculate_recomposition_quadrant,
|
|
||||||
'energy_deficit_surplus': nutrition_metrics.calculate_energy_deficit_surplus,
|
|
||||||
'protein_days_in_target': nutrition_metrics.calculate_protein_days_in_target,
|
|
||||||
'intake_volatility': nutrition_metrics.calculate_intake_volatility,
|
|
||||||
'active_goals_md': lambda pid: _format_goals_as_markdown(pid),
|
|
||||||
'focus_areas_weighted_md': lambda pid: _format_focus_areas_as_markdown(pid),
|
|
||||||
'top_3_focus_areas': lambda pid: _format_top_focus_areas(pid),
|
|
||||||
'top_3_goals_behind_schedule': lambda pid: _format_goals_behind(pid),
|
|
||||||
'top_3_goals_on_track': lambda pid: _format_goals_on_track(pid),
|
|
||||||
}
|
|
||||||
|
|
||||||
func = func_map.get(func_name)
|
|
||||||
if not func:
|
|
||||||
return 'nicht verfügbar'
|
|
||||||
|
|
||||||
result = func(profile_id)
|
|
||||||
return str(result) if result is not None else 'nicht verfügbar'
|
|
||||||
except Exception as e:
|
|
||||||
return 'nicht verfügbar'
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_json(func_name: str, profile_id: str) -> str:
|
|
||||||
"""
|
|
||||||
Safely call calculation function and return JSON string or fallback.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
import json
|
|
||||||
from calculations import scores, correlation_metrics
|
|
||||||
|
|
||||||
func_map = {
|
|
||||||
'correlation_energy_weight_lag': lambda pid: correlation_metrics.calculate_lag_correlation(pid, 'energy', 'weight'),
|
|
||||||
'correlation_protein_lbm': lambda pid: correlation_metrics.calculate_lag_correlation(pid, 'protein', 'lbm'),
|
|
||||||
'correlation_load_hrv': lambda pid: correlation_metrics.calculate_lag_correlation(pid, 'training_load', 'hrv'),
|
|
||||||
'correlation_load_rhr': lambda pid: correlation_metrics.calculate_lag_correlation(pid, 'training_load', 'rhr'),
|
|
||||||
'correlation_sleep_recovery': correlation_metrics.calculate_correlation_sleep_recovery,
|
|
||||||
'plateau_detected': correlation_metrics.calculate_plateau_detected,
|
|
||||||
'top_drivers': correlation_metrics.calculate_top_drivers,
|
|
||||||
'active_goals_json': lambda pid: _get_active_goals_json(pid),
|
|
||||||
'focus_areas_weighted_json': lambda pid: _get_focus_areas_weighted_json(pid),
|
|
||||||
'focus_area_weights_json': lambda pid: json.dumps(scores.get_user_focus_weights(pid), ensure_ascii=False),
|
|
||||||
}
|
|
||||||
|
|
||||||
func = func_map.get(func_name)
|
|
||||||
if not func:
|
|
||||||
return '{}'
|
|
||||||
|
|
||||||
result = func(profile_id)
|
|
||||||
if result is None:
|
|
||||||
return '{}'
|
|
||||||
|
|
||||||
# If already string, return it; otherwise convert to JSON
|
|
||||||
if isinstance(result, str):
|
|
||||||
return result
|
|
||||||
else:
|
|
||||||
return json.dumps(result, ensure_ascii=False)
|
|
||||||
except Exception as e:
|
|
||||||
return '{}'
|
|
||||||
|
|
||||||
|
|
||||||
def _get_active_goals_json(profile_id: str) -> str:
|
|
||||||
"""Get active goals as JSON string"""
|
|
||||||
import json
|
|
||||||
try:
|
|
||||||
# TODO: Implement after goal_utils extensions
|
|
||||||
return '[]'
|
|
||||||
except Exception:
|
|
||||||
return '[]'
|
|
||||||
|
|
||||||
|
|
||||||
def _get_focus_areas_weighted_json(profile_id: str) -> str:
|
|
||||||
"""Get focus areas with weights as JSON string"""
|
|
||||||
import json
|
|
||||||
try:
|
|
||||||
# TODO: Implement after goal_utils extensions
|
|
||||||
return '[]'
|
|
||||||
except Exception:
|
|
||||||
return '[]'
|
|
||||||
|
|
||||||
|
|
||||||
def _format_goals_as_markdown(profile_id: str) -> str:
|
|
||||||
"""Format goals as markdown table"""
|
|
||||||
# TODO: Implement
|
|
||||||
return 'Keine Ziele definiert'
|
|
||||||
|
|
||||||
|
|
||||||
def _format_focus_areas_as_markdown(profile_id: str) -> str:
|
|
||||||
"""Format focus areas as markdown"""
|
|
||||||
# TODO: Implement
|
|
||||||
return 'Keine Focus Areas aktiv'
|
|
||||||
|
|
||||||
|
|
||||||
def _format_top_focus_areas(profile_id: str, n: int = 3) -> str:
|
|
||||||
"""Format top N focus areas as text"""
|
|
||||||
# TODO: Implement
|
|
||||||
return 'nicht verfügbar'
|
|
||||||
|
|
||||||
|
|
||||||
def _format_goals_behind(profile_id: str, n: int = 3) -> str:
|
|
||||||
"""Format top N goals behind schedule"""
|
|
||||||
# TODO: Implement
|
|
||||||
return 'nicht verfügbar'
|
|
||||||
|
|
||||||
|
|
||||||
def _format_goals_on_track(profile_id: str, n: int = 3) -> str:
|
|
||||||
"""Format top N goals on track"""
|
|
||||||
# TODO: Implement
|
|
||||||
return 'nicht verfügbar'
|
|
||||||
|
|
||||||
|
|
||||||
# ── Placeholder Registry ──────────────────────────────────────────────────────
|
# ── Placeholder Registry ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
PLACEHOLDER_MAP: Dict[str, Callable[[str], str]] = {
|
PLACEHOLDER_MAP: Dict[str, Callable[[str], str]] = {
|
||||||
|
|
@ -748,107 +512,6 @@ PLACEHOLDER_MAP: Dict[str, Callable[[str], str]] = {
|
||||||
'{{zeitraum_7d}}': lambda pid: 'letzte 7 Tage',
|
'{{zeitraum_7d}}': lambda pid: 'letzte 7 Tage',
|
||||||
'{{zeitraum_30d}}': lambda pid: 'letzte 30 Tage',
|
'{{zeitraum_30d}}': lambda pid: 'letzte 30 Tage',
|
||||||
'{{zeitraum_90d}}': lambda pid: 'letzte 90 Tage',
|
'{{zeitraum_90d}}': lambda pid: 'letzte 90 Tage',
|
||||||
|
|
||||||
# ========================================================================
|
|
||||||
# PHASE 0b: Goal-Aware Placeholders (Dynamic Focus Areas v2.0)
|
|
||||||
# ========================================================================
|
|
||||||
|
|
||||||
# --- Meta Scores (Ebene 1: Aggregierte Scores) ---
|
|
||||||
'{{goal_progress_score}}': lambda pid: _safe_int('goal_progress_score', pid),
|
|
||||||
'{{body_progress_score}}': lambda pid: _safe_int('body_progress_score', pid),
|
|
||||||
'{{nutrition_score}}': lambda pid: _safe_int('nutrition_score', pid),
|
|
||||||
'{{activity_score}}': lambda pid: _safe_int('activity_score', pid),
|
|
||||||
'{{recovery_score}}': lambda pid: _safe_int('recovery_score_v2', pid),
|
|
||||||
'{{data_quality_score}}': lambda pid: _safe_int('data_quality_score', pid),
|
|
||||||
|
|
||||||
# --- Top-Weighted Goals/Focus Areas (Ebene 2: statt Primary) ---
|
|
||||||
'{{top_goal_name}}': lambda pid: _safe_str('top_goal_name', pid),
|
|
||||||
'{{top_goal_progress_pct}}': lambda pid: _safe_str('top_goal_progress_pct', pid),
|
|
||||||
'{{top_goal_status}}': lambda pid: _safe_str('top_goal_status', pid),
|
|
||||||
'{{top_focus_area_name}}': lambda pid: _safe_str('top_focus_area_name', pid),
|
|
||||||
'{{top_focus_area_progress}}': lambda pid: _safe_int('top_focus_area_progress', pid),
|
|
||||||
|
|
||||||
# --- Category Scores (Ebene 3: 7 Kategorien) ---
|
|
||||||
'{{focus_cat_körper_progress}}': lambda pid: _safe_int('focus_cat_körper_progress', pid),
|
|
||||||
'{{focus_cat_körper_weight}}': lambda pid: _safe_float('focus_cat_körper_weight', pid),
|
|
||||||
'{{focus_cat_ernährung_progress}}': lambda pid: _safe_int('focus_cat_ernährung_progress', pid),
|
|
||||||
'{{focus_cat_ernährung_weight}}': lambda pid: _safe_float('focus_cat_ernährung_weight', pid),
|
|
||||||
'{{focus_cat_aktivität_progress}}': lambda pid: _safe_int('focus_cat_aktivität_progress', pid),
|
|
||||||
'{{focus_cat_aktivität_weight}}': lambda pid: _safe_float('focus_cat_aktivität_weight', pid),
|
|
||||||
'{{focus_cat_recovery_progress}}': lambda pid: _safe_int('focus_cat_recovery_progress', pid),
|
|
||||||
'{{focus_cat_recovery_weight}}': lambda pid: _safe_float('focus_cat_recovery_weight', pid),
|
|
||||||
'{{focus_cat_vitalwerte_progress}}': lambda pid: _safe_int('focus_cat_vitalwerte_progress', pid),
|
|
||||||
'{{focus_cat_vitalwerte_weight}}': lambda pid: _safe_float('focus_cat_vitalwerte_weight', pid),
|
|
||||||
'{{focus_cat_mental_progress}}': lambda pid: _safe_int('focus_cat_mental_progress', pid),
|
|
||||||
'{{focus_cat_mental_weight}}': lambda pid: _safe_float('focus_cat_mental_weight', pid),
|
|
||||||
'{{focus_cat_lebensstil_progress}}': lambda pid: _safe_int('focus_cat_lebensstil_progress', pid),
|
|
||||||
'{{focus_cat_lebensstil_weight}}': lambda pid: _safe_float('focus_cat_lebensstil_weight', pid),
|
|
||||||
|
|
||||||
# --- Body Metrics (Ebene 4: Einzelmetriken K1-K5) ---
|
|
||||||
'{{weight_7d_median}}': lambda pid: _safe_float('weight_7d_median', pid),
|
|
||||||
'{{weight_28d_slope}}': lambda pid: _safe_float('weight_28d_slope', pid, decimals=4),
|
|
||||||
'{{weight_90d_slope}}': lambda pid: _safe_float('weight_90d_slope', pid, decimals=4),
|
|
||||||
'{{fm_28d_change}}': lambda pid: _safe_float('fm_28d_change', pid),
|
|
||||||
'{{lbm_28d_change}}': lambda pid: _safe_float('lbm_28d_change', pid),
|
|
||||||
'{{waist_28d_delta}}': lambda pid: _safe_float('waist_28d_delta', pid),
|
|
||||||
'{{hip_28d_delta}}': lambda pid: _safe_float('hip_28d_delta', pid),
|
|
||||||
'{{chest_28d_delta}}': lambda pid: _safe_float('chest_28d_delta', pid),
|
|
||||||
'{{arm_28d_delta}}': lambda pid: _safe_float('arm_28d_delta', pid),
|
|
||||||
'{{thigh_28d_delta}}': lambda pid: _safe_float('thigh_28d_delta', pid),
|
|
||||||
'{{waist_hip_ratio}}': lambda pid: _safe_float('waist_hip_ratio', pid, decimals=3),
|
|
||||||
'{{recomposition_quadrant}}': lambda pid: _safe_str('recomposition_quadrant', pid),
|
|
||||||
|
|
||||||
# --- Nutrition Metrics (E1-E5) ---
|
|
||||||
'{{energy_balance_7d}}': lambda pid: _safe_float('energy_balance_7d', pid, decimals=0),
|
|
||||||
'{{energy_deficit_surplus}}': lambda pid: _safe_str('energy_deficit_surplus', pid),
|
|
||||||
'{{protein_g_per_kg}}': lambda pid: _safe_float('protein_g_per_kg', pid),
|
|
||||||
'{{protein_days_in_target}}': lambda pid: _safe_str('protein_days_in_target', pid),
|
|
||||||
'{{protein_adequacy_28d}}': lambda pid: _safe_int('protein_adequacy_28d', pid),
|
|
||||||
'{{macro_consistency_score}}': lambda pid: _safe_int('macro_consistency_score', pid),
|
|
||||||
'{{intake_volatility}}': lambda pid: _safe_str('intake_volatility', pid),
|
|
||||||
|
|
||||||
# --- Activity Metrics (A1-A8) ---
|
|
||||||
'{{training_minutes_week}}': lambda pid: _safe_int('training_minutes_week', pid),
|
|
||||||
'{{training_frequency_7d}}': lambda pid: _safe_int('training_frequency_7d', pid),
|
|
||||||
'{{quality_sessions_pct}}': lambda pid: _safe_int('quality_sessions_pct', pid),
|
|
||||||
'{{ability_balance_strength}}': lambda pid: _safe_int('ability_balance_strength', pid),
|
|
||||||
'{{ability_balance_endurance}}': lambda pid: _safe_int('ability_balance_endurance', pid),
|
|
||||||
'{{ability_balance_mental}}': lambda pid: _safe_int('ability_balance_mental', pid),
|
|
||||||
'{{ability_balance_coordination}}': lambda pid: _safe_int('ability_balance_coordination', pid),
|
|
||||||
'{{ability_balance_mobility}}': lambda pid: _safe_int('ability_balance_mobility', pid),
|
|
||||||
'{{proxy_internal_load_7d}}': lambda pid: _safe_int('proxy_internal_load_7d', pid),
|
|
||||||
'{{monotony_score}}': lambda pid: _safe_float('monotony_score', pid),
|
|
||||||
'{{strain_score}}': lambda pid: _safe_int('strain_score', pid),
|
|
||||||
'{{rest_day_compliance}}': lambda pid: _safe_int('rest_day_compliance', pid),
|
|
||||||
'{{vo2max_trend_28d}}': lambda pid: _safe_float('vo2max_trend_28d', pid),
|
|
||||||
|
|
||||||
# --- Recovery Metrics (Recovery Score v2) ---
|
|
||||||
'{{hrv_vs_baseline_pct}}': lambda pid: _safe_float('hrv_vs_baseline_pct', pid),
|
|
||||||
'{{rhr_vs_baseline_pct}}': lambda pid: _safe_float('rhr_vs_baseline_pct', pid),
|
|
||||||
'{{sleep_avg_duration_7d}}': lambda pid: _safe_float('sleep_avg_duration_7d', pid),
|
|
||||||
'{{sleep_debt_hours}}': lambda pid: _safe_float('sleep_debt_hours', pid),
|
|
||||||
'{{sleep_regularity_proxy}}': lambda pid: _safe_float('sleep_regularity_proxy', pid),
|
|
||||||
'{{recent_load_balance_3d}}': lambda pid: _safe_int('recent_load_balance_3d', pid),
|
|
||||||
'{{sleep_quality_7d}}': lambda pid: _safe_int('sleep_quality_7d', pid),
|
|
||||||
|
|
||||||
# --- Correlation Metrics (C1-C7) ---
|
|
||||||
'{{correlation_energy_weight_lag}}': lambda pid: _safe_json('correlation_energy_weight_lag', pid),
|
|
||||||
'{{correlation_protein_lbm}}': lambda pid: _safe_json('correlation_protein_lbm', pid),
|
|
||||||
'{{correlation_load_hrv}}': lambda pid: _safe_json('correlation_load_hrv', pid),
|
|
||||||
'{{correlation_load_rhr}}': lambda pid: _safe_json('correlation_load_rhr', pid),
|
|
||||||
'{{correlation_sleep_recovery}}': lambda pid: _safe_json('correlation_sleep_recovery', pid),
|
|
||||||
'{{plateau_detected}}': lambda pid: _safe_json('plateau_detected', pid),
|
|
||||||
'{{top_drivers}}': lambda pid: _safe_json('top_drivers', pid),
|
|
||||||
|
|
||||||
# --- JSON/Markdown Structured Data (Ebene 5) ---
|
|
||||||
'{{active_goals_json}}': lambda pid: _safe_json('active_goals_json', pid),
|
|
||||||
'{{active_goals_md}}': lambda pid: _safe_str('active_goals_md', pid),
|
|
||||||
'{{focus_areas_weighted_json}}': lambda pid: _safe_json('focus_areas_weighted_json', pid),
|
|
||||||
'{{focus_areas_weighted_md}}': lambda pid: _safe_str('focus_areas_weighted_md', pid),
|
|
||||||
'{{focus_area_weights_json}}': lambda pid: _safe_json('focus_area_weights_json', pid),
|
|
||||||
'{{top_3_focus_areas}}': lambda pid: _safe_str('top_3_focus_areas', pid),
|
|
||||||
'{{top_3_goals_behind_schedule}}': lambda pid: _safe_str('top_3_goals_behind_schedule', pid),
|
|
||||||
'{{top_3_goals_on_track}}': lambda pid: _safe_str('top_3_goals_on_track', pid),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
# Test-Prompt für Phase 0b - Goal-Aware Placeholders
|
|
||||||
|
|
||||||
## Schnelltest-Prompt für Calculation Engine
|
|
||||||
|
|
||||||
**Zweck:** Validierung der 100+ Phase 0b Placeholders ohne JSON-Formatters
|
|
||||||
|
|
||||||
### Test-Prompt (in Admin UI → KI-Prompts erstellen):
|
|
||||||
|
|
||||||
```
|
|
||||||
Du bist ein Fitness-Coach. Analysiere den Fortschritt:
|
|
||||||
|
|
||||||
## Gesamtfortschritt
|
|
||||||
- Goal Progress Score: {{goal_progress_score}}/100
|
|
||||||
- Body: {{body_progress_score}}/100
|
|
||||||
- Nutrition: {{nutrition_score}}/100
|
|
||||||
- Activity: {{activity_score}}/100
|
|
||||||
- Recovery: {{recovery_score}}/100
|
|
||||||
|
|
||||||
## Kategorie-Fortschritte
|
|
||||||
- Körper: {{focus_cat_körper_progress}}% (Prio: {{focus_cat_körper_weight}}%)
|
|
||||||
- Ernährung: {{focus_cat_ernährung_progress}}% (Prio: {{focus_cat_ernährung_weight}}%)
|
|
||||||
- Aktivität: {{focus_cat_aktivität_progress}}% (Prio: {{focus_cat_aktivität_weight}}%)
|
|
||||||
|
|
||||||
## Körper-Metriken
|
|
||||||
- Gewicht 7d: {{weight_7d_median}} kg
|
|
||||||
- FM Änderung 28d: {{fm_28d_change}} kg
|
|
||||||
- LBM Änderung 28d: {{lbm_28d_change}} kg
|
|
||||||
- Rekomposition: {{recomposition_quadrant}}
|
|
||||||
|
|
||||||
## Ernährung
|
|
||||||
- Energiebilanz: {{energy_balance_7d}} kcal/Tag
|
|
||||||
- Protein g/kg: {{protein_g_per_kg}}
|
|
||||||
- Protein Adequacy: {{protein_adequacy_28d}}/100
|
|
||||||
|
|
||||||
## Aktivität
|
|
||||||
- Minuten/Woche: {{training_minutes_week}}
|
|
||||||
- Qualität: {{quality_sessions_pct}}%
|
|
||||||
- Kraft-Balance: {{ability_balance_strength}}/100
|
|
||||||
|
|
||||||
## Recovery
|
|
||||||
- HRV vs Baseline: {{hrv_vs_baseline_pct}}%
|
|
||||||
- Schlaf 7d: {{sleep_avg_duration_7d}}h
|
|
||||||
- Schlafqualität: {{sleep_quality_7d}}/100
|
|
||||||
|
|
||||||
Gib 3 konkrete Empfehlungen basierend auf den schwächsten Scores.
|
|
||||||
```
|
|
||||||
|
|
||||||
### Erwartetes Verhalten:
|
|
||||||
✅ Alle Placeholders lösen auf (numerisch oder "nicht verfügbar")
|
|
||||||
✅ Keine Python Exceptions
|
|
||||||
✅ Scores haben Werte 0-100 oder "nicht verfügbar"
|
|
||||||
|
|
||||||
### Test-Schritte:
|
|
||||||
1. Admin → KI-Prompts → "Neu erstellen"
|
|
||||||
2. Type: "base", Name: "Phase 0b Quick Test"
|
|
||||||
3. Template einfügen
|
|
||||||
4. "Test" Button → Profil wählen
|
|
||||||
5. Debug-Viewer prüfen: "Unresolved Placeholders" sollte leer sein
|
|
||||||
6. Wenn Errors: Console Log prüfen
|
|
||||||
|
|
||||||
### Bekannte Limitierungen (aktuell):
|
|
||||||
- JSON-Formatters (active_goals_json, etc.) → geben leere Arrays
|
|
||||||
- Top Goal Name → "nicht verfügbar" (needs goal_utils extension)
|
|
||||||
- Correlations → Placeholder-Werte (noch nicht echte Berechnungen)
|
|
||||||
Loading…
Reference in New Issue
Block a user