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 = []
|
quality_scores = []
|
||||||
for s in sleep_data:
|
for s in sleep_data:
|
||||||
if s['deep_minutes'] and s['rem_minutes']:
|
dur = s["duration_minutes"]
|
||||||
quality_pct = ((s['deep_minutes'] + s['rem_minutes']) / s['duration_minutes']) * 100
|
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
|
# 40-60% deep+REM is good
|
||||||
if quality_pct >= 45:
|
if quality_pct >= 45:
|
||||||
quality_scores.append(100)
|
quality_scores.append(100)
|
||||||
|
|
|
||||||
|
|
@ -674,9 +674,9 @@ def calculate_activity_score(profile_id: str, focus_weights: Optional[Dict] = No
|
||||||
if not components:
|
if not components:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Weighted average
|
# Weighted average (float: DB-Aggregate können Decimal sein)
|
||||||
total_score = sum(score * weight for _, score, weight in components)
|
total_score = sum(float(score) * float(weight) for _, score, weight in components)
|
||||||
total_weight = sum(weight for _, _, weight in components)
|
total_weight = sum(float(weight) for _, _, weight in components)
|
||||||
|
|
||||||
return int(total_score / total_weight)
|
return int(total_score / total_weight)
|
||||||
|
|
||||||
|
|
@ -728,12 +728,13 @@ def _score_cardio_presence(profile_id: str) -> Optional[int]:
|
||||||
if not row:
|
if not row:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
cardio_days = row['cardio_days']
|
# psycopg2: SUM() → oft Decimal — vor Mix mit float konvertieren
|
||||||
cardio_minutes = row['cardio_minutes'] or 0
|
cardio_days = int(row['cardio_days'] or 0)
|
||||||
|
cardio_minutes = float(row['cardio_minutes'] or 0)
|
||||||
|
|
||||||
# Target: 3-5 days/week, 150+ minutes
|
# Target: 3-5 days/week, 150+ minutes
|
||||||
day_score = min(100, (cardio_days / 4) * 100)
|
day_score = min(100.0, (cardio_days / 4) * 100)
|
||||||
minute_score = min(100, (cardio_minutes / 150) * 100)
|
minute_score = min(100.0, (cardio_minutes / 150) * 100)
|
||||||
|
|
||||||
return int((day_score + minute_score) / 2)
|
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:
|
if not components:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
total_score = sum(score * weight for _, score, weight in components)
|
total_score = sum(float(score) * float(weight) for _, score, weight in components)
|
||||||
total_weight = sum(weight for _, _, weight in components)
|
total_weight = sum(float(weight) for _, _, weight in components)
|
||||||
|
|
||||||
return int(total_score / total_weight)
|
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:
|
if not components:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Weighted average
|
# Weighted average (float: DB-Werte können Decimal sein)
|
||||||
total_score = sum(score * weight for _, score, weight in components)
|
total_score = sum(float(score) * float(weight) for _, score, weight in components)
|
||||||
total_weight = sum(weight for _, _, weight in components)
|
total_weight = sum(float(weight) for _, _, weight in components)
|
||||||
|
|
||||||
return int(total_score / total_weight)
|
return int(total_score / total_weight)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,50 @@ Phase 0c: Multi-Layer Architecture
|
||||||
Version: 1.0
|
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 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
|
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(
|
def get_sleep_duration_data(
|
||||||
profile_id: str,
|
profile_id: str,
|
||||||
days: int = 7
|
days: int = 7
|
||||||
|
|
@ -51,7 +89,7 @@ def get_sleep_duration_data(
|
||||||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||||
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""SELECT sleep_segments FROM sleep_log
|
"""SELECT sleep_segments, duration_minutes FROM sleep_log
|
||||||
WHERE profile_id=%s AND date >= %s
|
WHERE profile_id=%s AND date >= %s
|
||||||
ORDER BY date DESC""",
|
ORDER BY date DESC""",
|
||||||
(profile_id, cutoff)
|
(profile_id, cutoff)
|
||||||
|
|
@ -72,9 +110,14 @@ def get_sleep_duration_data(
|
||||||
nights_with_data = 0
|
nights_with_data = 0
|
||||||
|
|
||||||
for row in rows:
|
for row in rows:
|
||||||
segments = row['sleep_segments']
|
night_minutes = 0
|
||||||
|
segments = _parse_sleep_segments(row.get("sleep_segments"))
|
||||||
if 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:
|
if night_minutes > 0:
|
||||||
total_minutes += night_minutes
|
total_minutes += night_minutes
|
||||||
nights_with_data += 1
|
nights_with_data += 1
|
||||||
|
|
@ -136,7 +179,9 @@ def get_sleep_quality_data(
|
||||||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||||
|
|
||||||
cur.execute(
|
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
|
WHERE profile_id=%s AND date >= %s
|
||||||
ORDER BY date DESC""",
|
ORDER BY date DESC""",
|
||||||
(profile_id, cutoff)
|
(profile_id, cutoff)
|
||||||
|
|
@ -163,15 +208,29 @@ def get_sleep_quality_data(
|
||||||
count = 0
|
count = 0
|
||||||
|
|
||||||
for row in rows:
|
for row in rows:
|
||||||
segments = row['sleep_segments']
|
deep_rem_min = light_min = awake_min = 0
|
||||||
if segments:
|
total_min = 0
|
||||||
# Note: segments use 'phase' key, stored lowercase (deep, rem, light, awake)
|
used_segments = False
|
||||||
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)
|
|
||||||
|
|
||||||
|
segments = _parse_sleep_segments(row.get("sleep_segments"))
|
||||||
|
if segments:
|
||||||
|
total_min = sum(_segment_minutes(s) for s in segments)
|
||||||
if total_min > 0:
|
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
|
quality_pct = (deep_rem_min / total_min) * 100
|
||||||
total_quality += quality_pct
|
total_quality += quality_pct
|
||||||
total_deep_rem += deep_rem_min
|
total_deep_rem += deep_rem_min
|
||||||
|
|
@ -179,6 +238,28 @@ def get_sleep_quality_data(
|
||||||
total_awake += awake_min
|
total_awake += awake_min
|
||||||
total_all += total_min
|
total_all += total_min
|
||||||
count += 1
|
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:
|
if count == 0:
|
||||||
return {
|
return {
|
||||||
|
|
@ -351,8 +432,8 @@ def calculate_recovery_score_v2(profile_id: str) -> Optional[int]:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Weighted average
|
# Weighted average
|
||||||
total_score = sum(score * weight for _, score, weight in components)
|
total_score = sum(float(score) * float(weight) for _, score, weight in components)
|
||||||
total_weight = sum(weight for _, _, weight in components)
|
total_weight = sum(float(weight) for _, _, weight in components)
|
||||||
|
|
||||||
final_score = int(total_score / total_weight)
|
final_score = int(total_score / total_weight)
|
||||||
|
|
||||||
|
|
@ -783,8 +864,15 @@ def calculate_sleep_quality_7d(profile_id: str) -> Optional[int]:
|
||||||
|
|
||||||
quality_scores = []
|
quality_scores = []
|
||||||
for s in sleep_data:
|
for s in sleep_data:
|
||||||
if s['deep_minutes'] and s['rem_minutes']:
|
dur = s["duration_minutes"]
|
||||||
quality_pct = ((s['deep_minutes'] + s['rem_minutes']) / s['duration_minutes']) * 100
|
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
|
# 40-60% deep+REM is good
|
||||||
if quality_pct >= 45:
|
if quality_pct >= 45:
|
||||||
quality_scores.append(100)
|
quality_scores.append(100)
|
||||||
|
|
|
||||||
|
|
@ -202,23 +202,24 @@ def calculate_goal_progress_score(profile_id: str) -> Optional[int]:
|
||||||
total_weight = 0.0
|
total_weight = 0.0
|
||||||
|
|
||||||
for focus_area_id, weight in focus_weights.items():
|
for focus_area_id, weight in focus_weights.items():
|
||||||
|
w = float(weight)
|
||||||
component = focus_to_component.get(focus_area_id)
|
component = focus_to_component.get(focus_area_id)
|
||||||
|
|
||||||
if component == 'body' and body_score is not None:
|
if component == 'body' and body_score is not None:
|
||||||
total_score += body_score * weight
|
total_score += float(body_score) * w
|
||||||
total_weight += weight
|
total_weight += w
|
||||||
elif component == 'nutrition' and nutrition_score is not None:
|
elif component == 'nutrition' and nutrition_score is not None:
|
||||||
total_score += nutrition_score * weight
|
total_score += float(nutrition_score) * w
|
||||||
total_weight += weight
|
total_weight += w
|
||||||
elif component == 'activity' and activity_score is not None:
|
elif component == 'activity' and activity_score is not None:
|
||||||
total_score += activity_score * weight
|
total_score += float(activity_score) * w
|
||||||
total_weight += weight
|
total_weight += w
|
||||||
elif component == 'recovery' and recovery_score is not None:
|
elif component == 'recovery' and recovery_score is not None:
|
||||||
total_score += recovery_score * weight
|
total_score += float(recovery_score) * w
|
||||||
total_weight += weight
|
total_weight += w
|
||||||
elif component == 'health' and health_risk_score is not None:
|
elif component == 'health' and health_risk_score is not None:
|
||||||
total_score += health_risk_score * weight
|
total_score += float(health_risk_score) * w
|
||||||
total_weight += weight
|
total_weight += w
|
||||||
|
|
||||||
if total_weight == 0:
|
if total_weight == 0:
|
||||||
return None
|
return None
|
||||||
|
|
@ -282,9 +283,9 @@ def calculate_health_stability_score(profile_id: str) -> Optional[int]:
|
||||||
|
|
||||||
activities = cur.fetchall()
|
activities = cur.fetchall()
|
||||||
if activities:
|
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
|
# 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))
|
components.append(('movement', movement_score, 20))
|
||||||
|
|
||||||
# 4. Waist circumference risk (15%)
|
# 4. Waist circumference risk (15%)
|
||||||
|
|
@ -328,8 +329,8 @@ def calculate_health_stability_score(profile_id: str) -> Optional[int]:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Weighted average
|
# Weighted average
|
||||||
total_score = sum(score * weight for _, score, weight in components)
|
total_score = sum(float(score) * float(weight) for _, score, weight in components)
|
||||||
total_weight = sum(weight for _, _, weight in components)
|
total_weight = sum(float(weight) for _, _, weight in components)
|
||||||
|
|
||||||
return int(total_score / total_weight)
|
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:
|
if not goals:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Weighted average by contribution_weight
|
# Weighted average by contribution_weight (Numeric → float)
|
||||||
total_progress = sum(g['progress_pct'] * g['contribution_weight'] for g in goals)
|
total_progress = sum(
|
||||||
total_weight = sum(g['contribution_weight'] for g in goals)
|
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
|
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':
|
if data['confidence'] == 'insufficient':
|
||||||
return pv_unavailable(
|
return pv_unavailable(
|
||||||
"Schlafdauer (formatiert) nicht aus sleep_segments ableitbar",
|
"Schlafdauer nicht ermittelbar",
|
||||||
f"confidence={data.get('confidence')}, "
|
f"confidence={data.get('confidence')}, "
|
||||||
f"nächte_mit_segmenten={data.get('nights_with_data', 0)}/{data.get('days_analyzed', days)}; "
|
f"nächte_mit_wert={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"(Quellen: sleep_segments und/oder sleep_log.duration_minutes)",
|
||||||
)
|
)
|
||||||
|
|
||||||
return f"{data['avg_duration_hours']:.1f}h"
|
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':
|
if data['confidence'] == 'insufficient':
|
||||||
return pv_unavailable(
|
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"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)"
|
return f"{data['quality_score']:.0f}% (Deep+REM)"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user