- Modified the SQL query in `build_vital_signs_matrix_chart_payload` to use `measured_at::date` for date comparisons, ensuring correct data retrieval based on the measurement date. - Adjusted the order of results to sort by `measured_at` instead of `date`, improving the accuracy of the latest vital signs data fetched.
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 measured_at::date >= %s::date
|
||
ORDER BY measured_at 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",
|
||
},
|
||
}
|