Replace monthly GMI bar chart with daily scatter + rolling avg + linear trend/CI
Adds daily_gmi() and gmi_trend(): per-day GMI as scatter points, a 7-day rolling average line, and a linear regression trend line with a 95% confidence band (normal approximation, no scipy dependency, consistent with the existing preference for a lean container image). Recomputed from the already-fetched period data on each page load; the existing 1-hour Streamlit cache on load_range_data() already avoids repeated Nightscout calls for the same period, so no separate storage layer is needed at the current max. 90-day report window. Closes gitea #19.
This commit is contained in:
@@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
|
||||
from itertools import pairwise
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from nightscout_client import INSULIN_EVENT_TYPES
|
||||
@@ -35,6 +36,68 @@ def gmi_percent(mean_mmol: float) -> float:
|
||||
return round(3.31 + 0.4311 * mean_mmol, 1)
|
||||
|
||||
|
||||
GMI_TREND_COLUMNS = ["date", "gmi", "rolling_avg", "trend", "trend_lower", "trend_upper"]
|
||||
|
||||
|
||||
def daily_gmi(glucose: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Geschat HbA1c (GMI) per kalenderdag — scatterpunten voor de trendgrafiek (gitea #19)."""
|
||||
if glucose.empty:
|
||||
return pd.DataFrame(columns=["date", "gmi"])
|
||||
|
||||
df = glucose.copy()
|
||||
df["date"] = df["timestamp"].dt.date
|
||||
daily_mean = df.groupby("date")["mmol"].mean()
|
||||
result = daily_mean.apply(gmi_percent).reset_index()
|
||||
result.columns = ["date", "gmi"]
|
||||
return result.sort_values("date").reset_index(drop=True)
|
||||
|
||||
|
||||
def gmi_trend(glucose: pd.DataFrame, rolling_window_days: int = 7) -> pd.DataFrame:
|
||||
"""Dagelijkse GMI-punten plus voortschrijdend gemiddelde en een lineaire
|
||||
trendlijn met 95%-betrouwbaarheidsband (gitea #19).
|
||||
|
||||
Gebruikt een normaalbenadering (z=1.96) voor de betrouwbaarheidsband i.p.v.
|
||||
een exacte t-verdeling — dat zou een scipy-dependency toevoegen aan een
|
||||
verder bewust lichte container (dezelfde afweging als het niet gebruiken
|
||||
van kaleido in pdf_export.py). Bij de hier gebruikelijke steekproefgroottes
|
||||
(14-90 dagpunten) is het verschil met de exacte t-kritieke waarde
|
||||
verwaarloosbaar voor een visuele trendindicatie.
|
||||
|
||||
Elke keer opnieuw berekend uit de al-opgehaalde periode-data (geen aparte
|
||||
database) — de bestaande 1-uurs Streamlit-cache op load_range_data()
|
||||
voorkomt al herhaalde Nightscout-calls voor dezelfde periode. Bij een
|
||||
veel langere trend (jaren i.p.v. de huidige max. 90 dagen) zou een aparte
|
||||
opslag wél de moeite waard worden.
|
||||
"""
|
||||
daily = daily_gmi(glucose)
|
||||
if len(daily) < 2:
|
||||
return daily.reindex(columns=GMI_TREND_COLUMNS)
|
||||
|
||||
daily = daily.copy()
|
||||
daily["rolling_avg"] = daily["gmi"].rolling(window=rolling_window_days, min_periods=1).mean()
|
||||
|
||||
x = np.arange(len(daily), dtype=float)
|
||||
y = daily["gmi"].to_numpy()
|
||||
slope, intercept = np.polyfit(x, y, 1)
|
||||
fitted = intercept + slope * x
|
||||
|
||||
residuals = y - fitted
|
||||
degrees_of_freedom = max(len(x) - 2, 1)
|
||||
residual_std = np.sqrt(np.sum(residuals**2) / degrees_of_freedom)
|
||||
x_mean = x.mean()
|
||||
sum_sq_x = np.sum((x - x_mean) ** 2)
|
||||
standard_error = (
|
||||
np.zeros_like(x)
|
||||
if sum_sq_x == 0
|
||||
else residual_std * np.sqrt(1 / len(x) + (x - x_mean) ** 2 / sum_sq_x)
|
||||
)
|
||||
z_95 = 1.96
|
||||
daily["trend"] = fitted
|
||||
daily["trend_lower"] = fitted - z_95 * standard_error
|
||||
daily["trend_upper"] = fitted + z_95 * standard_error
|
||||
return daily[GMI_TREND_COLUMNS]
|
||||
|
||||
|
||||
def percent_cv(glucose: pd.DataFrame) -> float:
|
||||
"""Glucosevariabiliteit als %CV (standaarddeviatie / gemiddelde * 100)."""
|
||||
if glucose.empty:
|
||||
|
||||
Reference in New Issue
Block a user