- Changed SQL queries in `build_sleep_duration_quality_chart_payload` and `build_sleep_debt_chart_payload` to select `duration_minutes` instead of `total_sleep_min`. - Updated calculations for sleep duration and quality scores to reflect the new field names, ensuring accurate data representation in the recovery charts.
457 lines
14 KiB
Python
457 lines
14 KiB
Python
"""
|
||
Chart.js-Payloads für Recovery (R1–R5) — gemeinsam mit routers/charts und recovery-dashboard-viz.
|
||
|
||
Ausgelagert aus routers/charts.py (Issue 53 / Layer 1).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import datetime, timedelta
|
||
from typing import Any, Dict
|
||
|
||
from db import get_db, get_cursor
|
||
from data_layer.recovery_metrics import (
|
||
calculate_hrv_vs_baseline_pct,
|
||
calculate_recovery_score_v2,
|
||
calculate_rhr_vs_baseline_pct,
|
||
calculate_sleep_debt_hours,
|
||
get_sleep_duration_data,
|
||
get_sleep_quality_data,
|
||
)
|
||
from data_layer.utils import calculate_confidence, safe_float, serialize_dates
|
||
|
||
|
||
def build_recovery_score_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
|
||
if days < 7:
|
||
days = 7
|
||
if days > 90:
|
||
days = 90
|
||
current_score = calculate_recovery_score_v2(profile_id)
|
||
|
||
if current_score is None:
|
||
return {
|
||
"chart_type": "line",
|
||
"data": {"labels": [], "datasets": []},
|
||
"metadata": {
|
||
"confidence": "insufficient",
|
||
"data_points": 0,
|
||
"message": "Keine Recovery-Daten vorhanden",
|
||
},
|
||
}
|
||
|
||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
cur.execute(
|
||
"""SELECT date, resting_hr, hrv
|
||
FROM vitals_baseline
|
||
WHERE profile_id=%s AND date >= %s
|
||
ORDER BY date""",
|
||
(profile_id, cutoff),
|
||
)
|
||
rows = cur.fetchall()
|
||
|
||
if not rows:
|
||
return {
|
||
"chart_type": "line",
|
||
"data": {
|
||
"labels": [datetime.now().strftime("%Y-%m-%d")],
|
||
"datasets": [
|
||
{
|
||
"label": "Recovery Score",
|
||
"data": [current_score],
|
||
"borderColor": "#1D9E75",
|
||
"backgroundColor": "rgba(29, 158, 117, 0.1)",
|
||
"borderWidth": 2,
|
||
"tension": 0.3,
|
||
"fill": True,
|
||
}
|
||
],
|
||
},
|
||
"metadata": {
|
||
"confidence": "low",
|
||
"data_points": 1,
|
||
"current_score": current_score,
|
||
},
|
||
}
|
||
|
||
labels = [row["date"].isoformat() for row in rows]
|
||
values = [min(100, max(0, safe_float(row["hrv"]) if row["hrv"] else 50)) for row in rows]
|
||
|
||
return {
|
||
"chart_type": "line",
|
||
"data": {
|
||
"labels": labels,
|
||
"datasets": [
|
||
{
|
||
"label": "Recovery Score (proxy)",
|
||
"data": values,
|
||
"borderColor": "#1D9E75",
|
||
"backgroundColor": "rgba(29, 158, 117, 0.1)",
|
||
"borderWidth": 2,
|
||
"tension": 0.3,
|
||
"fill": True,
|
||
}
|
||
],
|
||
},
|
||
"metadata": serialize_dates(
|
||
{
|
||
"confidence": calculate_confidence(len(rows), days, "general"),
|
||
"data_points": len(rows),
|
||
"current_score": current_score,
|
||
"note": "Score based on HRV proxy; true recovery score calculation in development",
|
||
}
|
||
),
|
||
}
|
||
|
||
|
||
def build_hrv_rhr_baseline_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
|
||
if days < 7:
|
||
days = 7
|
||
if days > 90:
|
||
days = 90
|
||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
cur.execute(
|
||
"""SELECT date, resting_hr, hrv
|
||
FROM vitals_baseline
|
||
WHERE profile_id=%s AND date >= %s
|
||
ORDER BY date""",
|
||
(profile_id, cutoff),
|
||
)
|
||
rows = cur.fetchall()
|
||
|
||
if not rows:
|
||
return {
|
||
"chart_type": "line",
|
||
"data": {"labels": [], "datasets": []},
|
||
"metadata": {
|
||
"confidence": "insufficient",
|
||
"data_points": 0,
|
||
"message": "Keine Vitalwerte vorhanden",
|
||
},
|
||
}
|
||
|
||
labels = [row["date"].isoformat() for row in rows]
|
||
hrv_values = [safe_float(row["hrv"]) if row["hrv"] else None for row in rows]
|
||
rhr_values = [safe_float(row["resting_hr"]) if row["resting_hr"] else None for row in rows]
|
||
|
||
hrv_baseline = calculate_hrv_vs_baseline_pct(profile_id)
|
||
rhr_baseline = calculate_rhr_vs_baseline_pct(profile_id)
|
||
|
||
hrv_filtered = [v for v in hrv_values if v is not None]
|
||
rhr_filtered = [v for v in rhr_values if v is not None]
|
||
|
||
avg_hrv = sum(hrv_filtered) / len(hrv_filtered) if hrv_filtered else 50
|
||
avg_rhr = sum(rhr_filtered) / len(rhr_filtered) if rhr_filtered else 60
|
||
|
||
datasets = [
|
||
{
|
||
"label": "HRV (ms)",
|
||
"data": hrv_values,
|
||
"borderColor": "#1D9E75",
|
||
"backgroundColor": "rgba(29, 158, 117, 0.1)",
|
||
"borderWidth": 2,
|
||
"tension": 0.3,
|
||
"yAxisID": "y1",
|
||
"fill": False,
|
||
},
|
||
{
|
||
"label": "RHR (bpm)",
|
||
"data": rhr_values,
|
||
"borderColor": "#3B82F6",
|
||
"backgroundColor": "rgba(59, 130, 246, 0.1)",
|
||
"borderWidth": 2,
|
||
"tension": 0.3,
|
||
"yAxisID": "y2",
|
||
"fill": False,
|
||
},
|
||
]
|
||
|
||
return {
|
||
"chart_type": "line",
|
||
"data": {"labels": labels, "datasets": datasets},
|
||
"metadata": serialize_dates(
|
||
{
|
||
"confidence": calculate_confidence(len(rows), days, "general"),
|
||
"data_points": len(rows),
|
||
"avg_hrv": round(avg_hrv, 1),
|
||
"avg_rhr": round(avg_rhr, 1),
|
||
"hrv_vs_baseline_pct": hrv_baseline,
|
||
"rhr_vs_baseline_pct": rhr_baseline,
|
||
}
|
||
),
|
||
}
|
||
|
||
|
||
def build_sleep_duration_quality_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
|
||
if days < 7:
|
||
days = 7
|
||
if days > 90:
|
||
days = 90
|
||
duration_data = get_sleep_duration_data(profile_id, days)
|
||
quality_data = get_sleep_quality_data(profile_id, days)
|
||
|
||
if duration_data["confidence"] == "insufficient":
|
||
return {
|
||
"chart_type": "line",
|
||
"data": {"labels": [], "datasets": []},
|
||
"metadata": {
|
||
"confidence": "insufficient",
|
||
"data_points": 0,
|
||
"message": "Keine Schlafdaten vorhanden",
|
||
},
|
||
}
|
||
|
||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
cur.execute(
|
||
"""SELECT date, duration_minutes
|
||
FROM sleep_log
|
||
WHERE profile_id=%s AND date >= %s
|
||
ORDER BY date""",
|
||
(profile_id, cutoff),
|
||
)
|
||
rows = cur.fetchall()
|
||
|
||
if not rows:
|
||
return {
|
||
"chart_type": "line",
|
||
"data": {"labels": [], "datasets": []},
|
||
"metadata": {
|
||
"confidence": "insufficient",
|
||
"data_points": 0,
|
||
"message": "Keine Schlafdaten",
|
||
},
|
||
}
|
||
|
||
labels = [row["date"].isoformat() for row in rows]
|
||
duration_hours = [
|
||
safe_float(row["duration_minutes"]) / 60 if row["duration_minutes"] else None for row in rows
|
||
]
|
||
|
||
quality_scores = [(d / 8 * 100) if d else None for d in duration_hours]
|
||
|
||
datasets = [
|
||
{
|
||
"label": "Schlafdauer (h)",
|
||
"data": duration_hours,
|
||
"borderColor": "#3B82F6",
|
||
"backgroundColor": "rgba(59, 130, 246, 0.1)",
|
||
"borderWidth": 2,
|
||
"tension": 0.3,
|
||
"yAxisID": "y1",
|
||
"fill": True,
|
||
},
|
||
{
|
||
"label": "Qualität (%)",
|
||
"data": quality_scores,
|
||
"borderColor": "#1D9E75",
|
||
"backgroundColor": "rgba(29, 158, 117, 0.1)",
|
||
"borderWidth": 2,
|
||
"tension": 0.3,
|
||
"yAxisID": "y2",
|
||
"fill": False,
|
||
},
|
||
]
|
||
|
||
return {
|
||
"chart_type": "line",
|
||
"data": {"labels": labels, "datasets": datasets},
|
||
"metadata": serialize_dates(
|
||
{
|
||
"confidence": duration_data["confidence"],
|
||
"data_points": len(rows),
|
||
"avg_duration_hours": round(duration_data["avg_duration_hours"], 1),
|
||
"sleep_quality_score": quality_data.get("quality_score", 0),
|
||
}
|
||
),
|
||
}
|
||
|
||
|
||
def build_sleep_debt_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
|
||
if days < 7:
|
||
days = 7
|
||
if days > 90:
|
||
days = 90
|
||
current_debt = calculate_sleep_debt_hours(profile_id)
|
||
|
||
if current_debt is None:
|
||
return {
|
||
"chart_type": "line",
|
||
"data": {"labels": [], "datasets": []},
|
||
"metadata": {
|
||
"confidence": "insufficient",
|
||
"data_points": 0,
|
||
"message": "Keine Schlafdaten für Schulden-Berechnung",
|
||
},
|
||
}
|
||
|
||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
cur.execute(
|
||
"""SELECT date, duration_minutes
|
||
FROM sleep_log
|
||
WHERE profile_id=%s AND date >= %s
|
||
ORDER BY date""",
|
||
(profile_id, cutoff),
|
||
)
|
||
rows = cur.fetchall()
|
||
|
||
if not rows:
|
||
return {
|
||
"chart_type": "line",
|
||
"data": {"labels": [], "datasets": []},
|
||
"metadata": {
|
||
"confidence": "insufficient",
|
||
"data_points": 0,
|
||
"message": "Keine Schlafdaten",
|
||
},
|
||
}
|
||
|
||
labels = [row["date"].isoformat() for row in rows]
|
||
|
||
target_hours = 8.0
|
||
cumulative_debt = 0.0
|
||
debt_values = []
|
||
|
||
for row in rows:
|
||
actual_hours = safe_float(row["duration_minutes"]) / 60 if row["duration_minutes"] else 0
|
||
daily_deficit = target_hours - actual_hours
|
||
cumulative_debt += daily_deficit
|
||
debt_values.append(cumulative_debt)
|
||
|
||
return {
|
||
"chart_type": "line",
|
||
"data": {
|
||
"labels": labels,
|
||
"datasets": [
|
||
{
|
||
"label": "Schlafschuld (Stunden)",
|
||
"data": debt_values,
|
||
"borderColor": "#EF4444",
|
||
"backgroundColor": "rgba(239, 68, 68, 0.1)",
|
||
"borderWidth": 2,
|
||
"tension": 0.3,
|
||
"fill": True,
|
||
}
|
||
],
|
||
},
|
||
"metadata": serialize_dates(
|
||
{
|
||
"confidence": calculate_confidence(len(rows), days, "general"),
|
||
"data_points": len(rows),
|
||
"current_debt_hours": round(float(current_debt), 1),
|
||
"final_debt_hours": round(float(cumulative_debt), 1),
|
||
}
|
||
),
|
||
}
|
||
|
||
|
||
def build_vital_signs_matrix_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
|
||
if days < 7:
|
||
days = 7
|
||
if days > 30:
|
||
days = 30
|
||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
cur.execute(
|
||
"""SELECT resting_hr, hrv, vo2_max, spo2, respiratory_rate
|
||
FROM vitals_baseline
|
||
WHERE profile_id=%s AND date >= %s
|
||
ORDER BY date DESC
|
||
LIMIT 1""",
|
||
(profile_id, cutoff),
|
||
)
|
||
vitals_row = cur.fetchone()
|
||
|
||
cur.execute(
|
||
"""SELECT systolic, diastolic
|
||
FROM blood_pressure_log
|
||
WHERE profile_id=%s AND date >= %s
|
||
ORDER BY date DESC, time DESC
|
||
LIMIT 1""",
|
||
(profile_id, cutoff),
|
||
)
|
||
bp_row = cur.fetchone()
|
||
|
||
if not vitals_row and not bp_row:
|
||
return {
|
||
"chart_type": "bar",
|
||
"data": {"labels": [], "datasets": []},
|
||
"metadata": {
|
||
"confidence": "insufficient",
|
||
"data_points": 0,
|
||
"message": "Keine aktuellen Vitalwerte",
|
||
},
|
||
}
|
||
|
||
labels = []
|
||
values = []
|
||
|
||
if vitals_row:
|
||
if vitals_row["resting_hr"]:
|
||
labels.append("Ruhepuls (bpm)")
|
||
values.append(safe_float(vitals_row["resting_hr"]))
|
||
if vitals_row["hrv"]:
|
||
labels.append("HRV (ms)")
|
||
values.append(safe_float(vitals_row["hrv"]))
|
||
if vitals_row["vo2_max"]:
|
||
labels.append("VO2 Max")
|
||
values.append(safe_float(vitals_row["vo2_max"]))
|
||
if vitals_row["spo2"]:
|
||
labels.append("SpO2 (%)")
|
||
values.append(safe_float(vitals_row["spo2"]))
|
||
if vitals_row["respiratory_rate"]:
|
||
labels.append("Atemfrequenz")
|
||
values.append(safe_float(vitals_row["respiratory_rate"]))
|
||
|
||
if bp_row:
|
||
if bp_row["systolic"]:
|
||
labels.append("Blutdruck sys (mmHg)")
|
||
values.append(safe_float(bp_row["systolic"]))
|
||
if bp_row["diastolic"]:
|
||
labels.append("Blutdruck dia (mmHg)")
|
||
values.append(safe_float(bp_row["diastolic"]))
|
||
|
||
if not labels:
|
||
return {
|
||
"chart_type": "bar",
|
||
"data": {"labels": [], "datasets": []},
|
||
"metadata": {
|
||
"confidence": "insufficient",
|
||
"data_points": 0,
|
||
"message": "Keine Vitalwerte verfügbar",
|
||
},
|
||
}
|
||
|
||
return {
|
||
"chart_type": "bar",
|
||
"data": {
|
||
"labels": labels,
|
||
"datasets": [
|
||
{
|
||
"label": "Wert",
|
||
"data": values,
|
||
"backgroundColor": "#1D9E75",
|
||
"borderColor": "#085041",
|
||
"borderWidth": 1,
|
||
}
|
||
],
|
||
},
|
||
"metadata": {
|
||
"confidence": "medium",
|
||
"data_points": len(values),
|
||
"note": "Latest measurements within last " + str(days) + " days",
|
||
},
|
||
}
|