mitai-jinkendo/backend/calculations/recovery_metrics.py
Lars dd3a4111fc
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
fix: Phase 0b - fix remaining calculation errors
Fixes applied:
1. WHERE clause column names (total_sleep_min → duration_minutes, resting_heart_rate → resting_hr)
2. COUNT() column names (avg_heart_rate → hr_avg, quality_label → rpe)
3. Type errors (Decimal * float) - convert to float before multiplication
4. rest_days table (type column removed in migration 010, now uses rest_config JSONB)
5. c_thigh_l → c_thigh (no separate left/right columns)
6. focus_area_definitions queries (focus_area_id → key, label_de → name_de)

Missing functions implemented:
- goal_utils.get_active_goals() - queries goals table for active goals
- goal_utils.get_goal_by_id() - gets single goal
- calculations.scores.calculate_category_progress() - maps categories to score functions

Changes:
- activity_metrics.py: Fixed Decimal/float type errors, rest_config JSONB, data quality query
- recovery_metrics.py: Fixed all WHERE clause column names
- body_metrics.py: Fixed c_thigh column reference
- scores.py: Fixed focus_area queries, added calculate_category_progress()
- goal_utils.py: Added get_active_goals(), get_goal_by_id()

All calculation functions should now work with correct schema.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 08:39:31 +01:00

605 lines
18 KiB
Python

"""
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_hr) as recent_rhr
FROM vitals_baseline
WHERE profile_id = %s
AND resting_hr 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_hr 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_hr) as recent_rhr
FROM vitals_baseline
WHERE profile_id = %s
AND resting_hr 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_hr 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(duration_minutes) as avg_sleep_min
FROM sleep_log
WHERE profile_id = %s
AND date >= CURRENT_DATE - INTERVAL '7 days'
AND duration_minutes 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 duration_minutes
FROM sleep_log
WHERE profile_id = %s
AND date >= CURRENT_DATE - INTERVAL '14 days'
AND duration_minutes 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, wake_time, date
FROM sleep_log
WHERE profile_id = %s
AND date >= CURRENT_DATE - INTERVAL '14 days'
AND bedtime IS NOT NULL
AND wake_time 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_min) 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 duration_minutes, deep_minutes, rem_minutes
FROM sleep_log
WHERE profile_id = %s
AND date >= CURRENT_DATE - INTERVAL '7 days'
AND duration_minutes 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_minutes'] and s['rem_minutes']:
quality_pct = ((s['deep_minutes'] + s['rem_minutes']) / s['duration_minutes']) * 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_hr 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)
}
}