mitai-jinkendo/backend/data_layer/recovery_chart_payloads.py

457 lines
14 KiB
Python
Raw Normal View History

"""
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",
},
}