From 9929f44daa362ae7a8a1dbeec87047d3ced2c647 Mon Sep 17 00:00:00 2001 From: willemjan Date: Sun, 26 Jul 2026 21:06:06 +0200 Subject: [PATCH] add Trends page: GMI trend, overnight/daytime, weekday pattern, bolus, site-age (FR7, FR8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reporting.py gains overnight_daytime_split, day_of_week_pattern, bolus_stats, and site_interval_stats. The last two are explicitly scoped to bolus-only insulin (no basal reconstruction) since that needs the Nightscout profile, which is the separate profile-tuning feature's territory — documented as a caveat in both the UI and the design doc. Co-Authored-By: Claude Sonnet 5 --- pages/7_Trends.py | 118 +++++++++++++++++++++++++++++++++++ reporting.py | 133 ++++++++++++++++++++++++++++++++++++++++ tests/test_reporting.py | 112 +++++++++++++++++++++++++++++++++ 3 files changed, 363 insertions(+) create mode 100644 pages/7_Trends.py diff --git a/pages/7_Trends.py b/pages/7_Trends.py new file mode 100644 index 0000000..ee6ecfa --- /dev/null +++ b/pages/7_Trends.py @@ -0,0 +1,118 @@ +import plotly.graph_objects as go +import requests +import streamlit as st + +from auth import require_auth +from data_processing import classify_treatments +from nightscout_client import NightscoutConfigError +from reporting import ( + bolus_stats, + day_of_week_pattern, + gmi_percent, + overnight_daytime_split, + site_interval_stats, +) +from ui_common import get_colors, load_range_data, select_period_days + +st.set_page_config(page_title="Glucose dashboard — Trends", page_icon="📊", layout="wide") +require_auth() + +st.title("Trends") +st.caption("Geschat HbA1c (GMI) over tijd en overige statistieken (FR7, FR8).") + +start, end, days = select_period_days(default_label="90 dagen") + +try: + glucose, treatments = load_range_data(start, end) +except NightscoutConfigError as exc: + st.error(str(exc)) + st.stop() +except (requests.RequestException, ValueError) as exc: + st.error(f"Kon geen data ophalen van Nightscout: {exc}") + st.stop() + +if glucose.empty: + st.info("Geen glucosemetingen gevonden in deze periode.") + st.stop() + +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) + +if len(monthly_gmi) >= 1: + fig = go.Figure( + go.Bar(x=monthly_gmi.index, y=monthly_gmi.to_numpy(), marker_color=colors["glucose"]) + ) + fig.update_layout( + paper_bgcolor="rgba(0,0,0,0)", + plot_bgcolor="rgba(0,0,0,0)", + font_color=colors["text"], + height=280, + margin={"l": 10, "r": 10, "t": 10, "b": 10}, + yaxis={"title": "GMI (%)", "gridcolor": colors["grid"]}, + xaxis={"title": "Maand", "gridcolor": colors["grid"]}, + ) + st.plotly_chart(fig, width="stretch") +st.caption("GMI is een schatting (Bergenstal et al. 2018), geen labwaarde.") + +# --- FR8: nacht vs. overdag -------------------------------------------------- +st.subheader("Nacht (00:00-06:00) vs. overdag") +split = overnight_daytime_split(glucose) +col1, col2 = st.columns(2) +col1.metric("Tijd in bereik 's nachts", f"{split['overnight']['in_range']:.0f}%") +col2.metric("Tijd in bereik overdag", f"{split['daytime']['in_range']:.0f}%") + +# --- FR8: patroon per weekdag ------------------------------------------------- +st.subheader("Patroon per weekdag") +weekday_df = day_of_week_pattern(glucose) +weekday_plot = weekday_df.dropna(subset=["mean_mmol"]) +if not weekday_plot.empty: + fig = go.Figure( + go.Bar(x=weekday_plot["weekday"], y=weekday_plot["mean_mmol"], marker_color=colors["glucose"]) + ) + fig.update_layout( + paper_bgcolor="rgba(0,0,0,0)", + plot_bgcolor="rgba(0,0,0,0)", + font_color=colors["text"], + height=280, + margin={"l": 10, "r": 10, "t": 10, "b": 10}, + yaxis={"title": "Gem. glucose (mmol/L)", "gridcolor": colors["grid"]}, + xaxis={"gridcolor": colors["grid"]}, + ) + st.plotly_chart(fig, width="stretch") + +# --- FR8: bolus-insuline -------------------------------------------------- +st.subheader("Bolus-insuline") +st.caption( + "Alleen bolusinsuline (maaltijd/correctie/combo/SMB) — basaalinsuline vereist " + "profielgegevens en is hier niet in verwerkt (zie docs/design-reporting.md)." +) +bolus = bolus_stats(treatments) +col1, col2 = st.columns(2) +col1.metric("Totaal bolus", f"{bolus['total_units']:.1f} E") +col2.metric("Aantal boluses", bolus["count"]) +if bolus["by_type"]: + st.table({"type": list(bolus["by_type"].keys()), "eenheden": list(bolus["by_type"].values())}) + +# --- FR8: pompsite-leeftijd vs. glucose/insuline --------------------------- +st.subheader("Pompsite-leeftijd vs. glucose en insuline") +st.caption( + "Statistieken per interval tussen opeenvolgende pompwissels — een benadering " + "(zonder basaalinsuline) van of insulinebehoefte samenhangt met de leeftijd " + "van de pompsite." +) +site_stats = site_interval_stats(glucose, treatments, events["site_changes"]) +if site_stats.empty: + st.info("Geen pompwissels gevonden in deze periode.") +else: + display = site_stats.copy() + display["start"] = display["start"].dt.strftime("%d-%m-%Y") + display["end"] = display["end"].dt.strftime("%d-%m-%Y") + st.dataframe(display, width="stretch", hide_index=True) + +st.caption(f"Periode: {start.strftime('%d-%m-%Y')} t/m {end.strftime('%d-%m-%Y')}") diff --git a/reporting.py b/reporting.py index a5e0253..8e3a80c 100644 --- a/reporting.py +++ b/reporting.py @@ -9,8 +9,12 @@ definities waarop dit gebaseerd is (Battelino et al. 2019, Bergenstal et al. from __future__ import annotations +from itertools import pairwise + import pandas as pd +from nightscout_client import INSULIN_EVENT_TYPES + # Klinische drempels in mmol/L (Battelino et al. 2019, Diabetes Care 42(8):1593-1603). VERY_LOW = 3.0 LOW = 3.9 @@ -226,3 +230,132 @@ def agp_percentile_bands(glucose: pd.DataFrame, bin_minutes: int = 30) -> pd.Dat grouped.columns = ["p5", "p25", "p50", "p75", "p95"] grouped = grouped.reset_index().rename(columns={"bin": "minute_of_day"}) return grouped[AGP_PERCENTILE_COLUMNS].sort_values("minute_of_day").reset_index(drop=True) + + +def overnight_daytime_split( + glucose: pd.DataFrame, overnight_start: int = 0, overnight_end: int = 6 +) -> dict[str, dict]: + """Tijd-in-bereik apart voor de nacht (overnight_start-overnight_end uur) en overdag.""" + empty = tir_breakdown_5band(glucose) + if glucose.empty: + return {"overnight": empty, "daytime": empty} + + hour = glucose["timestamp"].dt.hour + is_overnight = (hour >= overnight_start) & (hour < overnight_end) + return { + "overnight": tir_breakdown_5band(glucose[is_overnight]), + "daytime": tir_breakdown_5band(glucose[~is_overnight]), + } + + +WEEKDAY_NAMES = ["Maandag", "Dinsdag", "Woensdag", "Donderdag", "Vrijdag", "Zaterdag", "Zondag"] + + +def day_of_week_pattern(glucose: pd.DataFrame) -> pd.DataFrame: + """Gemiddelde glucose en tijd-in-bereik per weekdag.""" + columns = ["weekday", "mean_mmol", "in_range_pct", "count"] + if glucose.empty: + return pd.DataFrame(columns=columns) + + df = glucose.copy() + df["weekday"] = df["timestamp"].dt.dayofweek + + rows = [] + for weekday in range(7): + day_df = df[df["weekday"] == weekday] + if day_df.empty: + rows.append( + {"weekday": WEEKDAY_NAMES[weekday], "mean_mmol": None, "in_range_pct": None, "count": 0} + ) + continue + tir = tir_breakdown_5band(day_df) + rows.append( + { + "weekday": WEEKDAY_NAMES[weekday], + "mean_mmol": round(day_df["mmol"].mean(), 1), + "in_range_pct": tir["in_range"], + "count": len(day_df), + } + ) + return pd.DataFrame(rows, columns=columns) + + +def bolus_stats(treatments: pd.DataFrame) -> dict: + """Samenvatting van bolus-insuline, opgesplitst naar type. + + Let op: dit betreft alleen bolusinsuline (maaltijd/correctie/combo/SMB). + Basaalinsuline wordt hier niet in meegenomen — dat vereist profielgegevens + (zie docs/design-reporting.md, scoping-kanttekening) en levert dus geen + volledige totale-dagdosis op. + """ + if treatments.empty: + return {"total_units": 0.0, "count": 0, "by_type": {}} + + bolus = treatments[treatments["eventType"].isin(INSULIN_EVENT_TYPES) & treatments["insulin"].notna()] + if bolus.empty: + return {"total_units": 0.0, "count": 0, "by_type": {}} + + by_type = bolus.groupby("eventType")["insulin"].sum().round(1).to_dict() + return { + "total_units": round(bolus["insulin"].sum(), 1), + "count": len(bolus), + "by_type": by_type, + } + + +SITE_INTERVAL_COLUMNS = [ + "start", + "end", + "duration_days", + "mean_mmol", + "in_range_pct", + "total_bolus_units", + "avg_bolus_units_per_day", + "total_carbs_g", +] + + +def _site_interval_row(glucose: pd.DataFrame, treatments: pd.DataFrame, start, end) -> dict: + duration_days = max((end - start).total_seconds() / 86400, 1e-9) + window_glucose = glucose[(glucose["timestamp"] >= start) & (glucose["timestamp"] < end)] + window_treatments = ( + treatments[(treatments["timestamp"] >= start) & (treatments["timestamp"] < end)] + if not treatments.empty + else treatments + ) + bolus = bolus_stats(window_treatments) + total_carbs = window_treatments["carbs"].fillna(0).sum() if not treatments.empty else 0.0 + tir = tir_breakdown_5band(window_glucose) + + return { + "start": start, + "end": end, + "duration_days": round(duration_days, 1), + "mean_mmol": round(window_glucose["mmol"].mean(), 1) if not window_glucose.empty else None, + "in_range_pct": tir["in_range"], + "total_bolus_units": bolus["total_units"], + "avg_bolus_units_per_day": round(bolus["total_units"] / duration_days, 1), + "total_carbs_g": round(total_carbs, 0), + } + + +def site_interval_stats( + glucose: pd.DataFrame, treatments: pd.DataFrame, site_changes: pd.DataFrame +) -> pd.DataFrame: + """Statistieken per interval tussen opeenvolgende pompwissels (FR8). + + Voor elk interval sinds een pompwissel: duur in dagen ("leeftijd" van de + pompsite bij het einde van het interval), gemiddelde glucose, + tijd-in-bereik, en bolus-insuline/koolhydraten in dat interval — als + benadering van of insulinebehoefte samenhangt met de leeftijd van de + pompsite. Zie bolus_stats() voor de kanttekening dat basaalinsuline hier + niet in zit. + """ + if site_changes.empty or glucose.empty: + return pd.DataFrame(columns=SITE_INTERVAL_COLUMNS) + + change_times = sorted(site_changes["timestamp"]) + boundaries = [*change_times, glucose["timestamp"].max()] + + rows = [_site_interval_row(glucose, treatments, start, end) for start, end in pairwise(boundaries)] + return pd.DataFrame(rows, columns=SITE_INTERVAL_COLUMNS) diff --git a/tests/test_reporting.py b/tests/test_reporting.py index c1b7d49..90aed6e 100644 --- a/tests/test_reporting.py +++ b/tests/test_reporting.py @@ -3,11 +3,15 @@ import pytest from reporting import ( agp_percentile_bands, + bolus_stats, + day_of_week_pattern, detect_events, gmi_percent, minutes_per_day, + overnight_daytime_split, percent_cv, sensor_active_percent, + site_interval_stats, summarize_events, tir_breakdown_5band, ) @@ -243,6 +247,114 @@ def test_agp_percentile_bands_handles_empty_input(): assert list(result.columns) == ["minute_of_day", "p5", "p25", "p50", "p75", "p95"] +def test_overnight_daytime_split_separates_by_hour(): + glucose = _series( + [ + ("2026-01-01 02:00", 3.0), # nacht, laag + ("2026-01-01 03:00", 3.0), # nacht, laag + ("2026-01-01 12:00", 6.0), # dag, in bereik + ("2026-01-01 13:00", 6.0), # dag, in bereik + ] + ) + result = overnight_daytime_split(glucose, overnight_start=0, overnight_end=6) + assert result["overnight"]["low"] == pytest.approx(100.0) + assert result["daytime"]["in_range"] == pytest.approx(100.0) + + +def test_overnight_daytime_split_handles_empty_input(): + empty = pd.DataFrame(columns=["timestamp", "mmol"]) + result = overnight_daytime_split(empty) + assert result["overnight"] == result["daytime"] + + +def test_day_of_week_pattern_computes_per_weekday_stats(): + # 2026-01-05 is een maandag + glucose = _series( + [ + ("2026-01-05 08:00", 6.0), + ("2026-01-05 09:00", 8.0), + ("2026-01-12 08:00", 7.0), # ook een maandag + ] + ) + result = day_of_week_pattern(glucose) + monday = result[result["weekday"] == "Maandag"].iloc[0] + assert monday["count"] == 3 + assert monday["mean_mmol"] == pytest.approx(7.0) + + tuesday = result[result["weekday"] == "Dinsdag"].iloc[0] + assert tuesday["count"] == 0 + assert pd.isna(tuesday["mean_mmol"]) + + +def test_day_of_week_pattern_handles_empty_input(): + empty = pd.DataFrame(columns=["timestamp", "mmol"]) + result = day_of_week_pattern(empty) + assert result.empty + assert list(result.columns) == ["weekday", "mean_mmol", "in_range_pct", "count"] + + +def test_bolus_stats_splits_by_event_type(): + treatments = pd.DataFrame( + { + "timestamp": pd.to_datetime(["2026-01-01 08:00", "2026-01-01 12:00", "2026-01-01 20:00"]), + "eventType": ["Meal Bolus", "Correction Bolus", "Site Change"], + "insulin": [4.0, 1.5, None], + "carbs": [45.0, None, None], + } + ) + result = bolus_stats(treatments) + assert result["total_units"] == pytest.approx(5.5) + assert result["count"] == 2 + assert result["by_type"] == {"Meal Bolus": 4.0, "Correction Bolus": 1.5} + + +def test_bolus_stats_handles_empty_input(): + empty = pd.DataFrame(columns=["timestamp", "eventType", "insulin", "carbs"]) + result = bolus_stats(empty) + assert result == {"total_units": 0.0, "count": 0, "by_type": {}} + + +def test_site_interval_stats_splits_by_site_change(): + glucose = _series( + [ + ("2026-01-01 00:00", 6.0), + ("2026-01-05 00:00", 6.0), + ("2026-01-10 00:00", 8.0), + ] + ) + treatments = pd.DataFrame( + { + "timestamp": pd.to_datetime(["2026-01-02 00:00", "2026-01-06 00:00"]), + "eventType": ["Meal Bolus", "Meal Bolus"], + "insulin": [3.0, 4.0], + "carbs": [30.0, 40.0], + } + ) + site_changes = pd.DataFrame( + { + "timestamp": pd.to_datetime(["2026-01-01 00:00", "2026-01-06 00:00"]), + "eventType": ["Site Change", "Site Change"], + "insulin": [None, None], + "carbs": [None, None], + } + ) + + result = site_interval_stats(glucose, treatments, site_changes) + assert len(result) == 2 + assert result.iloc[0]["duration_days"] == pytest.approx(5.0) + assert result.iloc[0]["total_bolus_units"] == pytest.approx(3.0) + # de bolus op 6 jan valt binnen het tweede interval ([6 jan, 10 jan)) + assert result.iloc[1]["total_bolus_units"] == pytest.approx(4.0) + + +def test_site_interval_stats_handles_no_site_changes(): + glucose = _series([("2026-01-01 00:00", 6.0)]) + treatments = pd.DataFrame(columns=["timestamp", "eventType", "insulin", "carbs"]) + empty_site_changes = pd.DataFrame(columns=["timestamp", "eventType", "insulin", "carbs"]) + result = site_interval_stats(glucose, treatments, empty_site_changes) + assert result.empty + + def test_summarize_events_handles_empty_input(): empty = pd.DataFrame(columns=["start", "end", "duration_minutes"]) result = summarize_events(empty, period_days=14) -- 2.54.0