feat: Refactor sleep metrics calculations and improve error handling
- Updated `get_sleep_avg_duration` and `get_sleep_avg_quality` functions in `placeholder_resolver.py` to provide clearer error messages when data is unavailable. - Enhanced sleep quality calculations in `recovery_metrics.py` to handle cases with insufficient data more robustly. - Improved data handling in various metrics files (`activity_metrics.py`, `body_metrics.py`, `nutrition_metrics.py`, `recovery_metrics.py`, and `scores.py`) to ensure consistent float conversions for calculations. - Added utility functions in `recovery_metrics.py` for parsing and normalizing sleep segment data, enhancing the accuracy of sleep quality assessments. These changes improve the reliability and clarity of sleep-related metrics and enhance overall data handling across the application.
This commit is contained in:
parent
04e23d8115
commit
41bf593d4c
|
|
@ -509,8 +509,15 @@ def calculate_sleep_quality_7d(profile_id: str) -> Optional[int]:
|
|||
|
||||
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
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -674,9 +674,9 @@ def calculate_activity_score(profile_id: str, focus_weights: Optional[Dict] = No
|
|||
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)
|
||||
# Weighted average (float: DB-Aggregate können Decimal sein)
|
||||
total_score = sum(float(score) * float(weight) for _, score, weight in components)
|
||||
total_weight = sum(float(weight) for _, _, weight in components)
|
||||
|
||||
return int(total_score / total_weight)
|
||||
|
||||
|
|
@ -728,12 +728,13 @@ def _score_cardio_presence(profile_id: str) -> Optional[int]:
|
|||
if not row:
|
||||
return None
|
||||
|
||||
cardio_days = row['cardio_days']
|
||||
cardio_minutes = row['cardio_minutes'] or 0
|
||||
# psycopg2: SUM() → oft Decimal — vor Mix mit float konvertieren
|
||||
cardio_days = int(row['cardio_days'] or 0)
|
||||
cardio_minutes = float(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)
|
||||
day_score = min(100.0, (cardio_days / 4) * 100)
|
||||
minute_score = min(100.0, (cardio_minutes / 150) * 100)
|
||||
|
||||
return int((day_score + minute_score) / 2)
|
||||
|
||||
|
|
|
|||
|
|
@ -760,8 +760,8 @@ def calculate_body_progress_score(profile_id: str, focus_weights: Optional[Dict]
|
|||
if not components:
|
||||
return None
|
||||
|
||||
total_score = sum(score * weight for _, score, weight in components)
|
||||
total_weight = sum(weight for _, _, weight in components)
|
||||
total_score = sum(float(score) * float(weight) for _, score, weight in components)
|
||||
total_weight = sum(float(weight) for _, _, weight in components)
|
||||
|
||||
return int(total_score / total_weight)
|
||||
|
||||
|
|
|
|||
|
|
@ -886,9 +886,9 @@ def calculate_nutrition_score(profile_id: str, focus_weights: Optional[Dict] = N
|
|||
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)
|
||||
# Weighted average (float: DB-Werte können Decimal sein)
|
||||
total_score = sum(float(score) * float(weight) for _, score, weight in components)
|
||||
total_weight = sum(float(weight) for _, _, weight in components)
|
||||
|
||||
return int(total_score / total_weight)
|
||||
|
||||
|
|
|
|||
|
|
@ -15,12 +15,50 @@ Phase 0c: Multi-Layer Architecture
|
|||
Version: 1.0
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
import json
|
||||
from typing import Dict, List, Optional, Any
|
||||
from datetime import datetime, timedelta, date
|
||||
from db import get_db, get_cursor, r2d
|
||||
from db import get_db, get_cursor
|
||||
from data_layer.utils import calculate_confidence, safe_float, safe_int
|
||||
|
||||
|
||||
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
|
||||
|
|
@ -51,7 +89,7 @@ def get_sleep_duration_data(
|
|||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||
|
||||
cur.execute(
|
||||
"""SELECT sleep_segments FROM sleep_log
|
||||
"""SELECT sleep_segments, duration_minutes FROM sleep_log
|
||||
WHERE profile_id=%s AND date >= %s
|
||||
ORDER BY date DESC""",
|
||||
(profile_id, cutoff)
|
||||
|
|
@ -72,9 +110,14 @@ def get_sleep_duration_data(
|
|||
nights_with_data = 0
|
||||
|
||||
for row in rows:
|
||||
segments = row['sleep_segments']
|
||||
night_minutes = 0
|
||||
segments = _parse_sleep_segments(row.get("sleep_segments"))
|
||||
if segments:
|
||||
night_minutes = sum(seg.get('duration_min', 0) for seg in 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
|
||||
|
|
@ -136,7 +179,9 @@ def get_sleep_quality_data(
|
|||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||
|
||||
cur.execute(
|
||||
"""SELECT sleep_segments FROM sleep_log
|
||||
"""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)
|
||||
|
|
@ -163,15 +208,29 @@ def get_sleep_quality_data(
|
|||
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)
|
||||
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
|
||||
|
|
@ -179,6 +238,28 @@ def get_sleep_quality_data(
|
|||
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 {
|
||||
|
|
@ -351,8 +432,8 @@ def calculate_recovery_score_v2(profile_id: str) -> Optional[int]:
|
|||
return None
|
||||
|
||||
# Weighted average
|
||||
total_score = sum(score * weight for _, score, weight in components)
|
||||
total_weight = sum(weight for _, _, weight in components)
|
||||
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)
|
||||
|
||||
|
|
@ -783,8 +864,15 @@ def calculate_sleep_quality_7d(profile_id: str) -> Optional[int]:
|
|||
|
||||
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
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -202,23 +202,24 @@ def calculate_goal_progress_score(profile_id: str) -> Optional[int]:
|
|||
total_weight = 0.0
|
||||
|
||||
for focus_area_id, weight in focus_weights.items():
|
||||
w = float(weight)
|
||||
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
|
||||
total_score += float(body_score) * w
|
||||
total_weight += w
|
||||
elif component == 'nutrition' and nutrition_score is not None:
|
||||
total_score += nutrition_score * weight
|
||||
total_weight += weight
|
||||
total_score += float(nutrition_score) * w
|
||||
total_weight += w
|
||||
elif component == 'activity' and activity_score is not None:
|
||||
total_score += activity_score * weight
|
||||
total_weight += weight
|
||||
total_score += float(activity_score) * w
|
||||
total_weight += w
|
||||
elif component == 'recovery' and recovery_score is not None:
|
||||
total_score += recovery_score * weight
|
||||
total_weight += weight
|
||||
total_score += float(recovery_score) * w
|
||||
total_weight += w
|
||||
elif component == 'health' and health_risk_score is not None:
|
||||
total_score += health_risk_score * weight
|
||||
total_weight += weight
|
||||
total_score += float(health_risk_score) * w
|
||||
total_weight += w
|
||||
|
||||
if total_weight == 0:
|
||||
return None
|
||||
|
|
@ -282,9 +283,9 @@ def calculate_health_stability_score(profile_id: str) -> Optional[int]:
|
|||
|
||||
activities = cur.fetchall()
|
||||
if activities:
|
||||
total_minutes = sum(a['duration_min'] for a in activities)
|
||||
total_minutes = float(sum(float(a['duration_min'] or 0) for a in activities))
|
||||
# WHO recommends 150-300 min/week moderate activity
|
||||
movement_score = min(100, (total_minutes / 150) * 100)
|
||||
movement_score = min(100.0, (total_minutes / 150) * 100)
|
||||
components.append(('movement', movement_score, 20))
|
||||
|
||||
# 4. Waist circumference risk (15%)
|
||||
|
|
@ -328,8 +329,8 @@ def calculate_health_stability_score(profile_id: str) -> Optional[int]:
|
|||
return None
|
||||
|
||||
# Weighted average
|
||||
total_score = sum(score * weight for _, score, weight in components)
|
||||
total_weight = sum(weight for _, _, weight in components)
|
||||
total_score = sum(float(score) * float(weight) for _, score, weight in components)
|
||||
total_weight = sum(float(weight) for _, _, weight in components)
|
||||
|
||||
return int(total_score / total_weight)
|
||||
|
||||
|
|
@ -532,9 +533,11 @@ def calculate_focus_area_progress(profile_id: str, focus_area_id: str) -> Option
|
|||
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)
|
||||
# Weighted average by contribution_weight (Numeric → float)
|
||||
total_progress = sum(
|
||||
float(g['progress_pct']) * float(g['contribution_weight']) for g in goals
|
||||
)
|
||||
total_weight = sum(float(g['contribution_weight']) for g in goals)
|
||||
|
||||
return int(total_progress / total_weight) if total_weight > 0 else None
|
||||
|
||||
|
|
|
|||
|
|
@ -469,10 +469,10 @@ def get_sleep_avg_duration(profile_id: str, days: int = 7) -> str:
|
|||
|
||||
if data['confidence'] == 'insufficient':
|
||||
return pv_unavailable(
|
||||
"Schlafdauer (formatiert) nicht aus sleep_segments ableitbar",
|
||||
"Schlafdauer nicht ermittelbar",
|
||||
f"confidence={data.get('confidence')}, "
|
||||
f"nächte_mit_segmenten={data.get('nights_with_data', 0)}/{data.get('days_analyzed', days)}; "
|
||||
f"Hinweis: {{sleep_avg_duration_7d}} nutzt duration_minutes und kann trotzdem Werte liefern",
|
||||
f"nächte_mit_wert={data.get('nights_with_data', 0)}/{data.get('days_analyzed', days)} "
|
||||
f"(Quellen: sleep_segments und/oder sleep_log.duration_minutes)",
|
||||
)
|
||||
|
||||
return f"{data['avg_duration_hours']:.1f}h"
|
||||
|
|
@ -489,9 +489,10 @@ def get_sleep_avg_quality(profile_id: str, days: int = 7) -> str:
|
|||
|
||||
if data['confidence'] == 'insufficient':
|
||||
return pv_unavailable(
|
||||
"Schlafqualität (Deep+REM) nicht aus sleep_segments ableitbar",
|
||||
"Schlafqualität (Deep+REM-Anteil) nicht ermittelbar",
|
||||
f"confidence={data.get('confidence')}, "
|
||||
f"nächte_analysiert={data.get('nights_analyzed', 0)}/{data.get('days_analyzed', days)}",
|
||||
f"nächte_analysiert={data.get('nights_analyzed', 0)}/{data.get('days_analyzed', days)} "
|
||||
f"(Quellen: sleep_segments oder Spalten deep/rem/light/awake_minutes)",
|
||||
)
|
||||
|
||||
return f"{data['quality_score']:.0f}% (Deep+REM)"
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user