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 .body_metrics import *
|
||||||
from .nutrition_metrics import *
|
from .nutrition_metrics import *
|
||||||
from .activity_metrics import *
|
from .activity_metrics import *
|
||||||
|
from .recovery_metrics import *
|
||||||
|
|
||||||
# Future imports (will be added as modules are created):
|
# Future imports (will be added as modules are created):
|
||||||
# from .recovery_metrics import *
|
|
||||||
# from .health_metrics import *
|
# from .health_metrics import *
|
||||||
# from .goals import *
|
# from .goals import *
|
||||||
# from .correlations import *
|
# from .correlations import *
|
||||||
|
|
@ -61,4 +61,9 @@ __all__ = [
|
||||||
'get_activity_summary_data',
|
'get_activity_summary_data',
|
||||||
'get_activity_detail_data',
|
'get_activity_detail_data',
|
||||||
'get_training_type_distribution_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_activity_detail_data,
|
||||||
get_training_type_distribution_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 ──────────────────────────────────────────────────────────
|
# ── 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:
|
def get_sleep_avg_duration(profile_id: str, days: int = 7) -> str:
|
||||||
"""Calculate average sleep duration in hours."""
|
"""
|
||||||
with get_db() as conn:
|
Calculate average sleep duration in hours.
|
||||||
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:
|
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)
|
||||||
|
|
||||||
|
if data['confidence'] == 'insufficient':
|
||||||
return "nicht verfügbar"
|
return "nicht verfügbar"
|
||||||
|
|
||||||
total_minutes = 0
|
return f"{data['avg_duration_hours']:.1f}h"
|
||||||
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 total_minutes == 0:
|
|
||||||
return "nicht verfügbar"
|
|
||||||
|
|
||||||
avg_hours = total_minutes / len(rows) / 60
|
|
||||||
return f"{avg_hours:.1f}h"
|
|
||||||
|
|
||||||
|
|
||||||
def get_sleep_avg_quality(profile_id: str, days: int = 7) -> str:
|
def get_sleep_avg_quality(profile_id: str, days: int = 7) -> str:
|
||||||
"""Calculate average sleep quality (Deep+REM %)."""
|
"""
|
||||||
with get_db() as conn:
|
Calculate average sleep quality (Deep+REM %).
|
||||||
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:
|
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)
|
||||||
|
|
||||||
|
if data['confidence'] == 'insufficient':
|
||||||
return "nicht verfügbar"
|
return "nicht verfügbar"
|
||||||
|
|
||||||
total_quality = 0
|
return f"{data['quality_score']:.0f}% (Deep+REM)"
|
||||||
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 count == 0:
|
|
||||||
return "nicht verfügbar"
|
|
||||||
|
|
||||||
avg_quality = total_quality / count
|
|
||||||
return f"{avg_quality:.0f}% (Deep+REM)"
|
|
||||||
|
|
||||||
|
|
||||||
def get_rest_days_count(profile_id: str, days: int = 30) -> str:
|
def get_rest_days_count(profile_id: str, days: int = 30) -> str:
|
||||||
"""Count rest days in the given period."""
|
"""
|
||||||
with get_db() as conn:
|
Count rest days in the given period.
|
||||||
cur = get_cursor(conn)
|
|
||||||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
Phase 0c: Refactored to use data_layer.recovery_metrics.get_rest_days_data()
|
||||||
cur.execute(
|
This function now only FORMATS the data for AI consumption.
|
||||||
"""SELECT COUNT(DISTINCT date) as count FROM rest_days
|
"""
|
||||||
WHERE profile_id=%s AND date >= %s""",
|
data = get_rest_days_data(profile_id, days)
|
||||||
(profile_id, cutoff)
|
return f"{data['total_rest_days']} Ruhetage"
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
count = row['count'] if row else 0
|
|
||||||
return f"{count} Ruhetage"
|
|
||||||
|
|
||||||
|
|
||||||
def get_vitals_avg_hr(profile_id: str, days: int = 7) -> str:
|
def get_vitals_avg_hr(profile_id: str, days: int = 7) -> str:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user