diff --git a/backend/calculations/recovery_metrics.py b/backend/calculations/recovery_metrics.py index 529a824..47bbe0d 100644 --- a/backend/calculations/recovery_metrics.py +++ b/backend/calculations/recovery_metrics.py @@ -509,17 +509,24 @@ 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 - # 40-60% deep+REM is good - if quality_pct >= 45: - quality_scores.append(100) - elif quality_pct >= 35: - quality_scores.append(75) - elif quality_pct >= 25: - quality_scores.append(50) - else: - quality_scores.append(30) + 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) + elif quality_pct >= 35: + quality_scores.append(75) + elif quality_pct >= 25: + quality_scores.append(50) + else: + quality_scores.append(30) if not quality_scores: return None diff --git a/backend/data_layer/activity_metrics.py b/backend/data_layer/activity_metrics.py index b8360ef..dea96f0 100644 --- a/backend/data_layer/activity_metrics.py +++ b/backend/data_layer/activity_metrics.py @@ -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) diff --git a/backend/data_layer/body_metrics.py b/backend/data_layer/body_metrics.py index 2fde741..4bfde2b 100644 --- a/backend/data_layer/body_metrics.py +++ b/backend/data_layer/body_metrics.py @@ -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) diff --git a/backend/data_layer/nutrition_metrics.py b/backend/data_layer/nutrition_metrics.py index c9af479..6c17cf0 100644 --- a/backend/data_layer/nutrition_metrics.py +++ b/backend/data_layer/nutrition_metrics.py @@ -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) diff --git a/backend/data_layer/recovery_metrics.py b/backend/data_layer/recovery_metrics.py index 8260f29..9b03e9b 100644 --- a/backend/data_layer/recovery_metrics.py +++ b/backend/data_layer/recovery_metrics.py @@ -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,12 +110,17 @@ 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) - if night_minutes > 0: - total_minutes += night_minutes - nights_with_data += 1 + 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 if nights_with_data == 0: return { @@ -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,17 +864,24 @@ 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 - # 40-60% deep+REM is good - if quality_pct >= 45: - quality_scores.append(100) - elif quality_pct >= 35: - quality_scores.append(75) - elif quality_pct >= 25: - quality_scores.append(50) - else: - quality_scores.append(30) + 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) + elif quality_pct >= 35: + quality_scores.append(75) + elif quality_pct >= 25: + quality_scores.append(50) + else: + quality_scores.append(30) if not quality_scores: return None diff --git a/backend/data_layer/scores.py b/backend/data_layer/scores.py index 007cf09..eca5b1f 100644 --- a/backend/data_layer/scores.py +++ b/backend/data_layer/scores.py @@ -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 diff --git a/backend/placeholder_resolver.py b/backend/placeholder_resolver.py index 4f83d0a..c227bbb 100644 --- a/backend/placeholder_resolver.py +++ b/backend/placeholder_resolver.py @@ -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)"