From 374ead70e56aae2cb0096b8a6cccc0a0f46123f1 Mon Sep 17 00:00:00 2001 From: willemjan Date: Sat, 1 Aug 2026 22:57:54 +0200 Subject: [PATCH] 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. --- pages/7_Trends.py | 67 ++++++++++++++++++++++++++++++++++------- reporting.py | 63 ++++++++++++++++++++++++++++++++++++++ tests/test_reporting.py | 54 +++++++++++++++++++++++++++++++++ 3 files changed, 173 insertions(+), 11 deletions(-) diff --git a/pages/7_Trends.py b/pages/7_Trends.py index 071a9c0..3a4d527 100644 --- a/pages/7_Trends.py +++ b/pages/7_Trends.py @@ -9,12 +9,13 @@ from reporting import ( basal_stats, bolus_stats, day_of_week_pattern, - gmi_percent, + gmi_trend, overnight_daytime_split, site_interval_stats, ) from ui_common import ( get_colors, + hex_to_rgba, load_range_data, render_completeness_warning, select_period_days, @@ -46,26 +47,70 @@ render_completeness_warning(glucose, start, end) colors = get_colors() events = classify_treatments(treatments) -# --- FR7: geschat HbA1c (GMI) per maand ------------------------------------ -st.subheader("Geschat HbA1c (GMI) per maand") -monthly = glucose.copy() -monthly["month"] = monthly["timestamp"].dt.to_period("M").astype(str) -monthly_gmi = monthly.groupby("month")["mmol"].mean().apply(gmi_percent) +# --- FR7: geschat HbA1c (GMI) trend ---------------------------------------- +st.subheader("Geschat HbA1c (GMI) trend") +trend = gmi_trend(glucose) -if len(monthly_gmi) >= 1: - fig = go.Figure( - go.Bar(x=monthly_gmi.index, y=monthly_gmi.to_numpy(), marker_color=colors["glucose"]) +if len(trend) >= 2: + fig = go.Figure() + fig.add_trace( + go.Scatter( + x=trend["date"], y=trend["trend_upper"], mode="lines", line={"width": 0}, showlegend=False, + hoverinfo="skip", + ) + ) + fig.add_trace( + go.Scatter( + x=trend["date"], + y=trend["trend_lower"], + mode="lines", + line={"width": 0}, + fill="tonexty", + fillcolor=hex_to_rgba(colors["muted"], 0.2), + name="95% betrouwbaarheidsband", + hoverinfo="skip", + ) + ) + fig.add_trace( + go.Scatter( + x=trend["date"], + y=trend["trend"], + mode="lines", + line={"color": colors["muted"], "width": 2, "dash": "dash"}, + name="Trend (lineair)", + ) + ) + fig.add_trace( + go.Scatter( + x=trend["date"], + y=trend["rolling_avg"], + mode="lines", + line={"color": colors["glucose"], "width": 2}, + name="Voortschrijdend gemiddelde (7d)", + ) + ) + fig.add_trace( + go.Scatter( + x=trend["date"], + y=trend["gmi"], + mode="markers", + marker={"color": colors["glucose"], "size": 5, "opacity": 0.5}, + name="Dag-GMI", + ) ) fig.update_layout( paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)", font_color=colors["text"], - height=280, + height=320, margin={"l": 10, "r": 10, "t": 10, "b": 10}, yaxis={"title": "GMI (%)", "gridcolor": colors["grid"]}, - xaxis={"title": "Maand", "gridcolor": colors["grid"]}, + xaxis={"title": "Datum", "gridcolor": colors["grid"]}, + legend={"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "left", "x": 0}, ) st.plotly_chart(fig, width="stretch") +else: + st.info("Niet genoeg dagen met data voor een trendlijn (minstens 2 nodig).") st.caption("GMI is een schatting (Bergenstal et al. 2018), geen labwaarde.") # --- FR8: nacht vs. overdag -------------------------------------------------- diff --git a/reporting.py b/reporting.py index de259a3..132f908 100644 --- a/reporting.py +++ b/reporting.py @@ -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: diff --git a/tests/test_reporting.py b/tests/test_reporting.py index 1048fb0..1d57c85 100644 --- a/tests/test_reporting.py +++ b/tests/test_reporting.py @@ -4,13 +4,16 @@ import pandas as pd import pytest from reporting import ( + GMI_TREND_COLUMNS, agp_percentile_bands, basal_stats, bolus_stats, completeness_warning, + daily_gmi, day_of_week_pattern, detect_events, gmi_percent, + gmi_trend, minutes_per_day, overnight_daytime_split, percent_cv, @@ -32,6 +35,57 @@ def test_gmi_percent_matches_bergenstal_formula(): assert gmi_percent(7.0) == pytest.approx(6.3, abs=0.05) +def test_daily_gmi_aggregates_per_calendar_day(): + glucose = _series( + [ + ("2026-01-01 08:00", 6.0), + ("2026-01-01 20:00", 8.0), + ("2026-01-02 08:00", 7.0), + ] + ) + result = daily_gmi(glucose) + assert list(result["date"].astype(str)) == ["2026-01-01", "2026-01-02"] + assert result["gmi"].iloc[0] == pytest.approx(gmi_percent(7.0)) # gemiddelde van 6.0 en 8.0 + assert result["gmi"].iloc[1] == pytest.approx(gmi_percent(7.0)) + + +def test_daily_gmi_handles_empty_input(): + empty = pd.DataFrame(columns=["timestamp", "mmol"]) + result = daily_gmi(empty) + assert result.empty + assert list(result.columns) == ["date", "gmi"] + + +def test_gmi_trend_handles_empty_input(): + empty = pd.DataFrame(columns=["timestamp", "mmol"]) + result = gmi_trend(empty) + assert result.empty + assert list(result.columns) == GMI_TREND_COLUMNS + + +def test_gmi_trend_returns_no_line_for_a_single_day(): + glucose = _series([("2026-01-01 08:00", 6.0)]) + result = gmi_trend(glucose) + assert list(result.columns) == GMI_TREND_COLUMNS + assert len(result) == 1 + assert pd.isna(result["trend"].iloc[0]) + + +def test_gmi_trend_fits_increasing_linear_trend_with_band_around_it(): + pairs = [(f"2026-01-{i + 1:02d} 08:00", 6.0 + i * 0.2) for i in range(10)] + glucose = _series(pairs) + result = gmi_trend(glucose, rolling_window_days=3) + + assert len(result) == 10 + assert result["trend"].iloc[-1] > result["trend"].iloc[0] + assert (result["trend_lower"] <= result["trend"]).all() + assert (result["trend"] <= result["trend_upper"]).all() + # Vrijwel lineaire input (gmi_percent rondt af op 1 decimaal, dus een + # kleine restfout blijft over) -> smalle betrouwbaarheidsband. + assert (result["trend_upper"] - result["trend_lower"]).max() == pytest.approx(0, abs=0.15) + assert result["rolling_avg"].iloc[-1] > result["rolling_avg"].iloc[0] + + def test_percent_cv_handles_empty_input(): empty = pd.DataFrame(columns=["timestamp", "mmol"]) assert percent_cv(empty) == 0.0