mitai-jinkendo/backend/data_layer/recovery_metrics.py
Lars 6c962bf6e5
All checks were successful
Deploy Development / deploy (push) Successful in 1m2s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
feat: enhance recovery metrics and dashboard with sleep debt calculations and improved visualizations
- Introduced new constants for sleep debt calculations, including target hours and rolling window days.
- Added a function to calculate sleep debt over a specified window, aligning with KPI logic.
- Updated SQL queries in recovery chart payloads to ensure accurate data retrieval for sleep metrics.
- Enhanced the RecoveryDashboardOverview component to reflect changes in sleep debt visualization and descriptions, improving user understanding of metrics.
- Refined chart labels and notes for clarity, ensuring users can easily interpret recovery and sleep data.
2026-04-20 12:47:03 +02:00

1008 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Recovery Metrics Data Layer
Provides structured data for recovery tracking and analysis.
Functions:
- get_sleep_duration_data(): Average sleep duration
- get_sleep_quality_data(): Sleep quality score (Deep+REM %)
- get_rest_days_data(): Rest day count and types
All functions return structured data (dict) without formatting.
Use placeholder_resolver.py for formatted strings for AI.
Phase 0c: Multi-Layer Architecture
Version: 1.0
"""
import json
from typing import Dict, List, Optional, Any
from datetime import datetime, timedelta, date
from db import get_db, get_cursor
from data_layer.utils import calculate_confidence, safe_float, safe_int
# ── Schlafschuld (KPI + Charts): eine Zielschlafdauer, bis ein Profil-Feld existiert
SLEEP_DEBT_TARGET_HOURS_DEFAULT = 7.5
SLEEP_DEBT_ROLLING_WINDOW_DAYS = 14
SLEEP_DEBT_MIN_NIGHTS_FOR_KPI = 10
def _parse_sleep_segments(raw: Any) -> Optional[List[dict]]:
"""JSONB kann dict/list/str sein; ungültig → None."""
if raw is None:
return None
if isinstance(raw, str):
try:
raw = json.loads(raw)
except (json.JSONDecodeError, TypeError):
return None
if not isinstance(raw, list):
return None
return raw
def _segment_minutes(seg: Any) -> int:
if not isinstance(seg, dict):
return 0
for key in ("duration_min", "duration_minutes", "minutes"):
v = seg.get(key)
if v is not None:
return max(0, safe_int(v))
return 0
def _normalize_sleep_phase(seg: dict) -> str:
"""Kleinbuchstaben; Apple „Core“-Schlaf wird wie light gewertet."""
if not isinstance(seg, dict):
return ""
p = seg.get("phase")
if p is None:
return ""
s = str(p).strip().lower()
if s in ("core", "asleep"):
return "light"
return s
def get_sleep_duration_data(
profile_id: str,
days: int = 7
) -> Dict:
"""
Calculate average sleep duration.
Args:
profile_id: User profile ID
days: Analysis window (default 7)
Returns:
{
"avg_duration_hours": float,
"avg_duration_minutes": int,
"total_nights": int,
"nights_with_data": int,
"confidence": str,
"days_analyzed": int
}
Migration from Phase 0b:
OLD: get_sleep_avg_duration(pid, days) formatted string
NEW: Structured data with hours and minutes
"""
with get_db() as conn:
cur = get_cursor(conn)
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
cur.execute(
"""SELECT sleep_segments, duration_minutes FROM sleep_log
WHERE profile_id=%s AND date >= %s
ORDER BY date DESC""",
(profile_id, cutoff)
)
rows = cur.fetchall()
if not rows:
return {
"avg_duration_hours": 0.0,
"avg_duration_minutes": 0,
"total_nights": 0,
"nights_with_data": 0,
"confidence": "insufficient",
"days_analyzed": days
}
total_minutes = 0
nights_with_data = 0
for row in rows:
night_minutes = 0
segments = _parse_sleep_segments(row.get("sleep_segments"))
if segments:
night_minutes = sum(_segment_minutes(seg) for seg in segments)
if night_minutes <= 0:
dm = row.get("duration_minutes")
if dm is not None:
night_minutes = max(0, safe_int(dm))
if night_minutes > 0:
total_minutes += night_minutes
nights_with_data += 1
if nights_with_data == 0:
return {
"avg_duration_hours": 0.0,
"avg_duration_minutes": 0,
"total_nights": len(rows),
"nights_with_data": 0,
"confidence": "insufficient",
"days_analyzed": days
}
avg_minutes = int(total_minutes / nights_with_data)
avg_hours = avg_minutes / 60
confidence = calculate_confidence(nights_with_data, days, "general")
return {
"avg_duration_hours": round(avg_hours, 1),
"avg_duration_minutes": avg_minutes,
"total_nights": len(rows),
"nights_with_data": nights_with_data,
"confidence": confidence,
"days_analyzed": days
}
def get_sleep_quality_data(
profile_id: str,
days: int = 7
) -> Dict:
"""
Calculate sleep quality score (Deep+REM percentage).
Args:
profile_id: User profile ID
days: Analysis window (default 7)
Returns:
{
"quality_score": float, # 0-100, Deep+REM percentage
"avg_deep_rem_minutes": int,
"avg_total_minutes": int,
"avg_light_minutes": int,
"avg_awake_minutes": int,
"nights_analyzed": int,
"confidence": str,
"days_analyzed": int
}
Migration from Phase 0b:
OLD: get_sleep_avg_quality(pid, days) formatted string
NEW: Complete sleep phase breakdown
"""
with get_db() as conn:
cur = get_cursor(conn)
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
cur.execute(
"""SELECT sleep_segments, duration_minutes, deep_minutes, rem_minutes,
light_minutes, awake_minutes
FROM sleep_log
WHERE profile_id=%s AND date >= %s
ORDER BY date DESC""",
(profile_id, cutoff)
)
rows = cur.fetchall()
if not rows:
return {
"quality_score": 0.0,
"avg_deep_rem_minutes": 0,
"avg_total_minutes": 0,
"avg_light_minutes": 0,
"avg_awake_minutes": 0,
"nights_analyzed": 0,
"confidence": "insufficient",
"days_analyzed": days
}
total_quality = 0
total_deep_rem = 0
total_light = 0
total_awake = 0
total_all = 0
count = 0
for row in rows:
deep_rem_min = light_min = awake_min = 0
total_min = 0
used_segments = False
segments = _parse_sleep_segments(row.get("sleep_segments"))
if segments:
total_min = sum(_segment_minutes(s) for s in segments)
if total_min > 0:
deep_rem_min = sum(
_segment_minutes(s)
for s in segments
if _normalize_sleep_phase(s) in ("deep", "rem")
)
light_min = sum(
_segment_minutes(s)
for s in segments
if _normalize_sleep_phase(s) == "light"
)
awake_min = sum(
_segment_minutes(s)
for s in segments
if _normalize_sleep_phase(s) == "awake"
)
quality_pct = (deep_rem_min / total_min) * 100
total_quality += quality_pct
total_deep_rem += deep_rem_min
total_light += light_min
total_awake += awake_min
total_all += total_min
count += 1
used_segments = True
if not used_segments:
d, r, l, a = (
row.get("deep_minutes"),
row.get("rem_minutes"),
row.get("light_minutes"),
row.get("awake_minutes"),
)
if d is not None or r is not None or l is not None:
di, ri, li = (d or 0), (r or 0), (l or 0)
phase_sum = di + ri + li
ai = (a or 0) if a is not None else 0
total_min = phase_sum + ai
if total_min > 0 and phase_sum > 0:
quality_pct = ((di + ri) / total_min) * 100
total_quality += quality_pct
total_deep_rem += di + ri
total_light += li
total_awake += ai
total_all += total_min
count += 1
if count == 0:
return {
"quality_score": 0.0,
"avg_deep_rem_minutes": 0,
"avg_total_minutes": 0,
"avg_light_minutes": 0,
"avg_awake_minutes": 0,
"nights_analyzed": 0,
"confidence": "insufficient",
"days_analyzed": days
}
avg_quality = total_quality / count
avg_deep_rem = int(total_deep_rem / count)
avg_total = int(total_all / count)
avg_light = int(total_light / count)
avg_awake = int(total_awake / count)
confidence = calculate_confidence(count, days, "general")
return {
"quality_score": round(avg_quality, 1),
"avg_deep_rem_minutes": avg_deep_rem,
"avg_total_minutes": avg_total,
"avg_light_minutes": avg_light,
"avg_awake_minutes": avg_awake,
"nights_analyzed": count,
"confidence": confidence,
"days_analyzed": days
}
def get_rest_days_data(
profile_id: str,
days: int = 30
) -> Dict:
"""
Get rest days count and breakdown by type.
Args:
profile_id: User profile ID
days: Analysis window (default 30)
Returns:
{
"total_rest_days": int,
"rest_types": {
"muscle_recovery": int,
"cardio_recovery": int,
"mental_rest": int,
"deload": int,
"injury": int
},
"rest_frequency": float, # days per week
"confidence": str,
"days_analyzed": int
}
Migration from Phase 0b:
OLD: get_rest_days_count(pid, days) formatted string
NEW: Complete breakdown by rest type
"""
with get_db() as conn:
cur = get_cursor(conn)
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
# Get total distinct rest days
cur.execute(
"""SELECT COUNT(DISTINCT date) as count FROM rest_days
WHERE profile_id=%s AND date >= %s""",
(profile_id, cutoff)
)
total_row = cur.fetchone()
total_count = total_row['count'] if total_row else 0
# Get breakdown by focus type
cur.execute(
"""SELECT focus, COUNT(*) as count FROM rest_days
WHERE profile_id=%s AND date >= %s
GROUP BY focus""",
(profile_id, cutoff)
)
type_rows = cur.fetchall()
rest_types = {
"muscle_recovery": 0,
"cardio_recovery": 0,
"mental_rest": 0,
"deload": 0,
"injury": 0
}
for row in type_rows:
focus = row['focus']
if focus in rest_types:
rest_types[focus] = row['count']
# Calculate frequency (rest days per week)
rest_frequency = (total_count / days * 7) if days > 0 else 0.0
confidence = calculate_confidence(total_count, days, "general")
return {
"total_rest_days": total_count,
"rest_types": rest_types,
"rest_frequency": round(rest_frequency, 1),
"confidence": confidence,
"days_analyzed": days
}
# ============================================================================
# Calculated Metrics (migrated from calculations/recovery_metrics.py)
# ============================================================================
# These functions return simple values for placeholders and scoring.
# Use get_*_data() functions above for structured chart data.
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(float(score) * float(weight) for _, score, weight in components)
total_weight = sum(float(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_hr) 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_hr) 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 _row_date_as_date(d: Any) -> Optional[date]:
if d is None:
return None
if isinstance(d, datetime):
return d.date()
if isinstance(d, date):
return d
return None
def sleep_debt_sum_hours_in_window(
night_rows: List[Dict[str, Any]],
window_end: date,
*,
target_hours: float = SLEEP_DEBT_TARGET_HOURS_DEFAULT,
window_days: int = SLEEP_DEBT_ROLLING_WINDOW_DAYS,
min_nights: int = SLEEP_DEBT_MIN_NIGHTS_FOR_KPI,
) -> Optional[float]:
"""
Summe der nächtlichen Defizite (nur Unter-Ziel, kein „Überschuss-Guthaben“) im Fenster
(window_end window_days … window_end], Kalendertage).
Gleiche Logik wie KPI calculate_sleep_debt_hours für window_end = heute.
"""
start = window_end - timedelta(days=window_days)
tmin = target_hours * 60.0
total_min = 0.0
nights = 0
for row in night_rows:
rd = _row_date_as_date(row.get("date"))
if rd is None or rd < start or rd > window_end:
continue
dm = row.get("duration_minutes")
if dm is None:
continue
nights += 1
total_min += max(0.0, tmin - float(dm))
if nights < min_nights:
return None
return round(total_min / 60.0, 1)
def calculate_sleep_debt_hours(profile_id: str) -> Optional[float]:
"""
Aufsummierte Schlafschuld (h) der letzten 14 Kalendertage bis heute —
Ziel pro Nacht: SLEEP_DEBT_TARGET_HOURS_DEFAULT (aktuell nicht profilkonfigurierbar).
"""
today = datetime.now().date()
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT date, duration_minutes
FROM sleep_log
WHERE profile_id = %s
AND date >= %s::date - INTERVAL '14 days'
AND date <= %s::date
AND duration_minutes IS NOT NULL
ORDER BY date DESC
""",
(profile_id, today, today),
)
rows = [dict(r) for r in cur.fetchall()]
return sleep_debt_sum_hours_in_window(rows, today)
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 data_layer.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:
dur = s["duration_minutes"]
if not dur or dur <= 0:
continue
d = s["deep_minutes"]
r = s["rem_minutes"]
if d is None and r is None:
continue
di, ri = (d or 0), (r or 0)
quality_pct = ((di + ri) / dur) * 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)
}
}