Platzhalter finalisiert - Option |d und Option |x implementiert #77

Merged
Lars merged 9 commits from develop into main 2026-04-11 22:10:10 +02:00
7 changed files with 174 additions and 74 deletions
Showing only changes of commit 41bf593d4c - Show all commits

View File

@ -509,17 +509,24 @@ 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:
# 40-60% deep+REM is good continue
if quality_pct >= 45: d = s["deep_minutes"]
quality_scores.append(100) r = s["rem_minutes"]
elif quality_pct >= 35: if d is None and r is None:
quality_scores.append(75) continue
elif quality_pct >= 25: di, ri = (d or 0), (r or 0)
quality_scores.append(50) quality_pct = ((di + ri) / dur) * 100
else: # 40-60% deep+REM is good
quality_scores.append(30) 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: if not quality_scores:
return None return None

View File

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

View File

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

View File

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

View File

@ -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,12 +110,17 @@ 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: if night_minutes <= 0:
total_minutes += night_minutes dm = row.get("duration_minutes")
nights_with_data += 1 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: if nights_with_data == 0:
return { return {
@ -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,17 +864,24 @@ 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:
# 40-60% deep+REM is good continue
if quality_pct >= 45: d = s["deep_minutes"]
quality_scores.append(100) r = s["rem_minutes"]
elif quality_pct >= 35: if d is None and r is None:
quality_scores.append(75) continue
elif quality_pct >= 25: di, ri = (d or 0), (r or 0)
quality_scores.append(50) quality_pct = ((di + ri) / dur) * 100
else: # 40-60% deep+REM is good
quality_scores.append(30) 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: if not quality_scores:
return None return None

View File

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

View File

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