feat: Phase 0c - recovery_metrics.py module complete
Data Layer: - get_sleep_duration_data() - avg duration with hours/minutes breakdown - get_sleep_quality_data() - Deep+REM percentage with phase breakdown - get_rest_days_data() - total count + breakdown by rest type Placeholder Layer: - get_sleep_avg_duration() - refactored to use data layer - get_sleep_avg_quality() - refactored to use data layer - get_rest_days_count() - refactored to use data layer All 3 recovery data functions + 3 placeholder refactors complete. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6b2ad9fa1c
commit
432f7ba49f
|
|
@ -32,9 +32,9 @@ from .utils import *
|
|||
from .body_metrics import *
|
||||
from .nutrition_metrics import *
|
||||
from .activity_metrics import *
|
||||
from .recovery_metrics import *
|
||||
|
||||
# Future imports (will be added as modules are created):
|
||||
# from .recovery_metrics import *
|
||||
# from .health_metrics import *
|
||||
# from .goals import *
|
||||
# from .correlations import *
|
||||
|
|
@ -61,4 +61,9 @@ __all__ = [
|
|||
'get_activity_summary_data',
|
||||
'get_activity_detail_data',
|
||||
'get_training_type_distribution_data',
|
||||
|
||||
# Recovery Metrics
|
||||
'get_sleep_duration_data',
|
||||
'get_sleep_quality_data',
|
||||
'get_rest_days_data',
|
||||
]
|
||||
|
|
|
|||
287
backend/data_layer/recovery_metrics.py
Normal file
287
backend/data_layer/recovery_metrics.py
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
"""
|
||||
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
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
from datetime import datetime, timedelta, date
|
||||
from db import get_db, get_cursor, r2d
|
||||
from data_layer.utils import calculate_confidence, safe_float, safe_int
|
||||
|
||||
|
||||
def get_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 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:
|
||||
segments = row['sleep_segments']
|
||||
if segments:
|
||||
night_minutes = sum(seg.get('duration_min', 0) for seg in segments)
|
||||
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 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:
|
||||
segments = row['sleep_segments']
|
||||
if segments:
|
||||
# Note: segments use 'phase' key, stored lowercase (deep, rem, light, awake)
|
||||
deep_rem_min = sum(s.get('duration_min', 0) for s in segments if s.get('phase') in ['deep', 'rem'])
|
||||
light_min = sum(s.get('duration_min', 0) for s in segments if s.get('phase') == 'light')
|
||||
awake_min = sum(s.get('duration_min', 0) for s in segments if s.get('phase') == 'awake')
|
||||
total_min = sum(s.get('duration_min', 0) for s in segments)
|
||||
|
||||
if total_min > 0:
|
||||
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
|
||||
|
||||
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": {
|
||||
"strength": int,
|
||||
"cardio": int,
|
||||
"relaxation": 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 rest type
|
||||
cur.execute(
|
||||
"""SELECT rest_type, COUNT(*) as count FROM rest_days
|
||||
WHERE profile_id=%s AND date >= %s
|
||||
GROUP BY rest_type""",
|
||||
(profile_id, cutoff)
|
||||
)
|
||||
type_rows = cur.fetchall()
|
||||
|
||||
rest_types = {
|
||||
"strength": 0,
|
||||
"cardio": 0,
|
||||
"relaxation": 0
|
||||
}
|
||||
|
||||
for row in type_rows:
|
||||
rest_type = row['rest_type']
|
||||
if rest_type in rest_types:
|
||||
rest_types[rest_type] = 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
|
||||
}
|
||||
|
|
@ -28,6 +28,11 @@ from data_layer.activity_metrics import (
|
|||
get_activity_detail_data,
|
||||
get_training_type_distribution_data
|
||||
)
|
||||
from data_layer.recovery_metrics import (
|
||||
get_sleep_duration_data,
|
||||
get_sleep_quality_data,
|
||||
get_rest_days_data
|
||||
)
|
||||
|
||||
|
||||
# ── Helper Functions ──────────────────────────────────────────────────────────
|
||||
|
|
@ -305,85 +310,44 @@ def get_trainingstyp_verteilung(profile_id: str, days: int = 14) -> str:
|
|||
|
||||
|
||||
def get_sleep_avg_duration(profile_id: str, days: int = 7) -> str:
|
||||
"""Calculate average sleep duration in hours."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||
cur.execute(
|
||||
"""SELECT sleep_segments FROM sleep_log
|
||||
WHERE profile_id=%s AND date >= %s
|
||||
ORDER BY date DESC""",
|
||||
(profile_id, cutoff)
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
"""
|
||||
Calculate average sleep duration in hours.
|
||||
|
||||
if not rows:
|
||||
return "nicht verfügbar"
|
||||
Phase 0c: Refactored to use data_layer.recovery_metrics.get_sleep_duration_data()
|
||||
This function now only FORMATS the data for AI consumption.
|
||||
"""
|
||||
data = get_sleep_duration_data(profile_id, days)
|
||||
|
||||
total_minutes = 0
|
||||
for row in rows:
|
||||
segments = row['sleep_segments']
|
||||
if segments:
|
||||
# Sum duration_min from all segments
|
||||
for seg in segments:
|
||||
total_minutes += seg.get('duration_min', 0)
|
||||
if data['confidence'] == 'insufficient':
|
||||
return "nicht verfügbar"
|
||||
|
||||
if total_minutes == 0:
|
||||
return "nicht verfügbar"
|
||||
|
||||
avg_hours = total_minutes / len(rows) / 60
|
||||
return f"{avg_hours:.1f}h"
|
||||
return f"{data['avg_duration_hours']:.1f}h"
|
||||
|
||||
|
||||
def get_sleep_avg_quality(profile_id: str, days: int = 7) -> str:
|
||||
"""Calculate average sleep quality (Deep+REM %)."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||
cur.execute(
|
||||
"""SELECT sleep_segments FROM sleep_log
|
||||
WHERE profile_id=%s AND date >= %s
|
||||
ORDER BY date DESC""",
|
||||
(profile_id, cutoff)
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
"""
|
||||
Calculate average sleep quality (Deep+REM %).
|
||||
|
||||
if not rows:
|
||||
return "nicht verfügbar"
|
||||
Phase 0c: Refactored to use data_layer.recovery_metrics.get_sleep_quality_data()
|
||||
This function now only FORMATS the data for AI consumption.
|
||||
"""
|
||||
data = get_sleep_quality_data(profile_id, days)
|
||||
|
||||
total_quality = 0
|
||||
count = 0
|
||||
for row in rows:
|
||||
segments = row['sleep_segments']
|
||||
if segments:
|
||||
# Note: segments use 'phase' key (not 'stage'), stored lowercase (deep, rem, light, awake)
|
||||
deep_rem_min = sum(s.get('duration_min', 0) for s in segments if s.get('phase') in ['deep', 'rem'])
|
||||
total_min = sum(s.get('duration_min', 0) for s in segments)
|
||||
if total_min > 0:
|
||||
quality_pct = (deep_rem_min / total_min) * 100
|
||||
total_quality += quality_pct
|
||||
count += 1
|
||||
if data['confidence'] == 'insufficient':
|
||||
return "nicht verfügbar"
|
||||
|
||||
if count == 0:
|
||||
return "nicht verfügbar"
|
||||
|
||||
avg_quality = total_quality / count
|
||||
return f"{avg_quality:.0f}% (Deep+REM)"
|
||||
return f"{data['quality_score']:.0f}% (Deep+REM)"
|
||||
|
||||
|
||||
def get_rest_days_count(profile_id: str, days: int = 30) -> str:
|
||||
"""Count rest days in the given period."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||
cur.execute(
|
||||
"""SELECT COUNT(DISTINCT date) as count FROM rest_days
|
||||
WHERE profile_id=%s AND date >= %s""",
|
||||
(profile_id, cutoff)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
count = row['count'] if row else 0
|
||||
return f"{count} Ruhetage"
|
||||
"""
|
||||
Count rest days in the given period.
|
||||
|
||||
Phase 0c: Refactored to use data_layer.recovery_metrics.get_rest_days_data()
|
||||
This function now only FORMATS the data for AI consumption.
|
||||
"""
|
||||
data = get_rest_days_data(profile_id, days)
|
||||
return f"{data['total_rest_days']} Ruhetage"
|
||||
|
||||
|
||||
def get_vitals_avg_hr(profile_id: str, days: int = 7) -> str:
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user