feat: Refactor sleep metrics calculations and improve error handling
All checks were successful
Deploy Development / deploy (push) Successful in 54s
Build Test / pytest-backend (push) Successful in 3s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s

- 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:
Lars 2026-04-11 21:27:49 +02:00
parent 04e23d8115
commit 41bf593d4c
7 changed files with 174 additions and 74 deletions

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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)"