455 lines
14 KiB
Python
455 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, total_sleep_min
|
|||
|
|
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["total_sleep_min"]) / 60 if row["total_sleep_min"] 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("sleep_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, total_sleep_min
|
|||
|
|
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["total_sleep_min"]) / 60 if row["total_sleep_min"] 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",
|
|||
|
|
},
|
|||
|
|
}
|