From 9e11dd8d731fd49469165e07b25d4b55e860b75b Mon Sep 17 00:00:00 2001 From: willemjan Date: Sat, 1 Aug 2026 22:52:54 +0200 Subject: [PATCH] Fix bolus/basal insulin categorization: split SMB from manual corrections, add basal calc AndroidAPS records an automatic SMB micro-bolus as eventType "Correction Bolus" with type "SMB", not as its own eventType. bolus_stats() was grouping by eventType alone, so every SMB was silently counted as a manual correction bolus. Verified against production data: 100% of "Correction Bolus" treatments over 90 days were actually type=SMB. Also adds basal_stats(), computing delivered basal directly from Temp Basal treatments (rate x duration) without needing the pump profile. Closes gitea #20. --- nightscout_client.py | 30 ++++++++++----- pages/7_Trends.py | 26 ++++++++----- reporting.py | 54 +++++++++++++++++++++++--- tests/test_nightscout_client.py | 32 ++++++++++++++++ tests/test_reporting.py | 68 ++++++++++++++++++++++++++++++++- 5 files changed, 184 insertions(+), 26 deletions(-) diff --git a/nightscout_client.py b/nightscout_client.py index 6b38b94..47cbb1d 100644 --- a/nightscout_client.py +++ b/nightscout_client.py @@ -176,29 +176,39 @@ def _entries_since(since: datetime, until: datetime | None = None) -> pd.DataFra return df[["timestamp", "mmol"]].sort_values("timestamp").reset_index(drop=True) +_TREATMENT_COLUMNS = ["timestamp", "eventType", "insulin", "carbs", "type", "absolute", "duration"] + + def _treatments_since(since: datetime, until: datetime | None = None) -> pd.DataFrame: - """Haal treatments op sinds `since` (en optioneel tot `until`), lokaal gefilterd.""" + """Haal treatments op sinds `since` (en optioneel tot `until`), lokaal gefilterd. + + `type` en `absolute`/`duration` komen erbij naast de oorspronkelijke + kolommen: AndroidAPS registreert een SMB-microbolus niet als eigen + `eventType`, maar als `eventType == "Correction Bolus"` met + `type == "SMB"` (zie reporting.bolus_stats() / gitea #20) — en + `absolute` (E/uur) + `duration` (minuten) op Temp Basal-treatments maken + het mogelijk afgeleverde basaalinsuline te berekenen zonder het + pompprofiel te hoeven ophalen (zie reporting.basal_stats()). + """ data = _paginated_get("/api/v1/treatments.json", since, until, TREATMENTS_CURSOR_FIELD) if not data: - return pd.DataFrame(columns=["timestamp", "eventType", "insulin", "carbs"]) + return pd.DataFrame(columns=_TREATMENT_COLUMNS) df = pd.DataFrame(data) df["timestamp"] = pd.to_datetime(df["created_at"], utc=True).dt.tz_localize(None) - for col in ("insulin", "carbs"): + for col in ("insulin", "carbs", "absolute", "duration"): if col not in df.columns: df[col] = pd.NA df[col] = pd.to_numeric(df[col], errors="coerce") - if "eventType" not in df.columns: - df["eventType"] = "" + for col in ("eventType", "type"): + if col not in df.columns: + df[col] = "" + df[col] = df[col].fillna("") df = df[df["timestamp"] >= since.replace(tzinfo=None)] if until is not None: df = df[df["timestamp"] <= until.replace(tzinfo=None)] - return ( - df[["timestamp", "eventType", "insulin", "carbs"]] - .sort_values("timestamp") - .reset_index(drop=True) - ) + return df[_TREATMENT_COLUMNS].sort_values("timestamp").reset_index(drop=True) def fetch_entries(hours: int = 24) -> pd.DataFrame: diff --git a/pages/7_Trends.py b/pages/7_Trends.py index 6cf3fbf..071a9c0 100644 --- a/pages/7_Trends.py +++ b/pages/7_Trends.py @@ -6,6 +6,7 @@ from auth import require_auth from data_processing import classify_treatments from nightscout_client import NightscoutConfigError from reporting import ( + basal_stats, bolus_stats, day_of_week_pattern, gmi_percent, @@ -93,18 +94,25 @@ if not weekday_plot.empty: ) st.plotly_chart(fig, width="stretch") -# --- FR8: bolus-insuline -------------------------------------------------- -st.subheader("Bolus-insuline") +# --- FR8: insuline (basaal + bolus-uitsplitsing) --------------------------- +st.subheader("Insuline") st.caption( - "Alleen bolusinsuline (maaltijd/correctie/combo/SMB) — basaalinsuline vereist " - "profielgegevens en is hier niet in verwerkt (zie docs/design-reporting.md)." + "Basaal berekend uit Temp Basal-treatments (snelheid x duur), zonder pompprofiel " + "nodig — een ondergrens bij periodes zonder actieve temp basal. Boluses " + "opgesplitst naar voeding, handmatige correctie en automatische SMB-microbolus " + "(gitea #20)." ) 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())}) +basal = basal_stats(treatments, days) +total_units = bolus["total_units"] + basal["total_units"] +col1, col2, col3 = st.columns(3) +col1.metric("Totaal insuline", f"{total_units:.1f} E") +col2.metric("Gem. per dag", f"{total_units / days:.1f} E") +col3.metric("Aantal boluses", bolus["count"]) + +breakdown = {"Basaal": basal["total_units"], **bolus["by_type"]} +if any(breakdown.values()): + st.table({"Categorie": list(breakdown.keys()), "Eenheden": list(breakdown.values())}) # --- FR8: pompsite-leeftijd vs. glucose/insuline --------------------------- st.subheader("Pompsite-leeftijd vs. glucose en insuline") diff --git a/reporting.py b/reporting.py index f61c8c6..de259a3 100644 --- a/reporting.py +++ b/reporting.py @@ -330,22 +330,35 @@ def day_of_week_pattern(glucose: pd.DataFrame) -> pd.DataFrame: return pd.DataFrame(rows, columns=columns) +SMB_TYPE = "SMB" +BASAL_EVENT_TYPE = "Temp Basal" + + 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. + AndroidAPS in SMB-looping registreert een automatische micro-bolus niet + als eigen `eventType`, maar als `eventType == "Correction Bolus"` met + `type == "SMB"` — zonder dit onderscheid werden alle SMB's stilzwijgend + als "Correction Bolus" (handmatige correctie) meegeteld, wat gitea #20 + meldde als "bolus stats empty/onduidelijk". Geverifieerd rechtstreeks + tegen de productiedata: bij deze gebruiker is vrijwel elke "Correction + Bolus" in werkelijkheid een automatische SMB. + + Let op: dit betreft alleen bolusinsuline (voeding/correctie/combo/SMB). + Basaalinsuline zit hier niet in — zie basal_stats(). """ if treatments.empty: return {"total_units": 0.0, "count": 0, "by_type": {}} - bolus = treatments[treatments["eventType"].isin(INSULIN_EVENT_TYPES) & treatments["insulin"].notna()] + bolus = treatments[treatments["eventType"].isin(INSULIN_EVENT_TYPES) & treatments["insulin"].notna()].copy() if bolus.empty: return {"total_units": 0.0, "count": 0, "by_type": {}} - by_type = bolus.groupby("eventType")["insulin"].sum().round(1).to_dict() + is_smb = (bolus["eventType"] == "Correction Bolus") & (bolus.get("type", "") == SMB_TYPE) + bolus["category"] = bolus["eventType"].where(~is_smb, SMB_TYPE) + + by_type = bolus.groupby("category")["insulin"].sum().round(1).to_dict() return { "total_units": round(bolus["insulin"].sum(), 1), "count": len(bolus), @@ -353,6 +366,35 @@ def bolus_stats(treatments: pd.DataFrame) -> dict: } +def basal_stats(treatments: pd.DataFrame, period_days: float) -> dict: + """Totale basaalinsuline uit Temp Basal-treatments (rate x duur), zonder pompprofiel. + + AndroidAPS zet in gesloten-lus-modus vrijwel continu Temp Basal-treatments + met een `absolute` snelheid (E/uur) en `duration` (minuten); de som van + afgeleverde eenheden daaruit benadert de daadwerkelijke basaaldosis + nauwkeurig genoeg zonder het pompprofiel te hoeven ophalen (gitea #20). + Periodes zonder actieve temp basal (dan geldt het profiel-basaaltempo) + worden hierin gemist — dit is dus een ondergrens, geen exacte totale- + dagdosis. + """ + if treatments.empty or period_days <= 0: + return {"total_units": 0.0, "avg_units_per_day": 0.0} + + temp_basal = treatments[ + (treatments["eventType"] == BASAL_EVENT_TYPE) + & treatments["absolute"].notna() + & treatments["duration"].notna() + ] + if temp_basal.empty: + return {"total_units": 0.0, "avg_units_per_day": 0.0} + + total_units = (temp_basal["absolute"] * temp_basal["duration"] / 60).sum() + return { + "total_units": round(total_units, 1), + "avg_units_per_day": round(total_units / period_days, 1), + } + + SITE_INTERVAL_COLUMNS = [ "start", "end", diff --git a/tests/test_nightscout_client.py b/tests/test_nightscout_client.py index adbd37f..6add455 100644 --- a/tests/test_nightscout_client.py +++ b/tests/test_nightscout_client.py @@ -90,6 +90,38 @@ def test_fetch_treatments_fills_missing_columns(monkeypatch): assert result.iloc[0]["eventType"] == "Site Change" assert pd_isna(result.iloc[0]["insulin"]) assert pd_isna(result.iloc[0]["carbs"]) + assert result.iloc[0]["type"] == "" + assert pd_isna(result.iloc[0]["absolute"]) + assert pd_isna(result.iloc[0]["duration"]) + + +def test_fetch_treatments_passes_through_type_and_temp_basal_fields(monkeypatch): + """SMB-classificatie (reporting.bolus_stats) en basaalberekening + (reporting.basal_stats) hebben deze velden nodig — zie gitea #20.""" + monkeypatch.setenv("NIGHTSCOUT_URL", "https://example.com") + now = datetime.now(timezone.utc) + payload = [ + { + "created_at": now.isoformat(), + "eventType": "Correction Bolus", + "insulin": 0.5, + "type": "SMB", + }, + { + "created_at": now.isoformat(), + "eventType": "Temp Basal", + "absolute": 1.2, + "duration": 30, + }, + ] + monkeypatch.setattr(ns.requests, "get", lambda *a, **k: FakeResponse(payload)) + + result = ns.fetch_treatments(hours=24) + smb_row = result[result["eventType"] == "Correction Bolus"].iloc[0] + basal_row = result[result["eventType"] == "Temp Basal"].iloc[0] + assert smb_row["type"] == "SMB" + assert basal_row["absolute"] == pytest.approx(1.2) + assert basal_row["duration"] == pytest.approx(30) def pd_isna(value): diff --git a/tests/test_reporting.py b/tests/test_reporting.py index 8aef601..1048fb0 100644 --- a/tests/test_reporting.py +++ b/tests/test_reporting.py @@ -5,6 +5,7 @@ import pytest from reporting import ( agp_percentile_bands, + basal_stats, bolus_stats, completeness_warning, day_of_week_pattern, @@ -339,6 +340,7 @@ def test_bolus_stats_splits_by_event_type(): "eventType": ["Meal Bolus", "Correction Bolus", "Site Change"], "insulin": [4.0, 1.5, None], "carbs": [45.0, None, None], + "type": ["NORMAL", "NORMAL", ""], } ) result = bolus_stats(treatments) @@ -347,12 +349,76 @@ def test_bolus_stats_splits_by_event_type(): assert result["by_type"] == {"Meal Bolus": 4.0, "Correction Bolus": 1.5} +def test_bolus_stats_reclassifies_smb_correction_boluses_separately(): + """AndroidAPS registreert een automatische SMB-microbolus als + eventType == "Correction Bolus" met type == "SMB" — dit moet apart van + een handmatige correctiebolus (type != "SMB") geteld worden (gitea #20).""" + treatments = pd.DataFrame( + { + "timestamp": pd.to_datetime(["2026-01-01 08:00", "2026-01-01 12:00"]), + "eventType": ["Correction Bolus", "Correction Bolus"], + "insulin": [0.3, 0.9], + "carbs": [None, None], + "type": ["SMB", "NORMAL"], + } + ) + result = bolus_stats(treatments) + assert result["by_type"] == {"SMB": 0.3, "Correction Bolus": 0.9} + + +def test_bolus_stats_treats_missing_type_column_as_non_smb(): + treatments = pd.DataFrame( + { + "timestamp": pd.to_datetime(["2026-01-01 08:00"]), + "eventType": ["Correction Bolus"], + "insulin": [0.9], + "carbs": [None], + } + ) + result = bolus_stats(treatments) + assert result["by_type"] == {"Correction Bolus": 0.9} + + def test_bolus_stats_handles_empty_input(): - empty = pd.DataFrame(columns=["timestamp", "eventType", "insulin", "carbs"]) + empty = pd.DataFrame(columns=["timestamp", "eventType", "insulin", "carbs", "type"]) result = bolus_stats(empty) assert result == {"total_units": 0.0, "count": 0, "by_type": {}} +def test_basal_stats_sums_temp_basal_rate_times_duration(): + treatments = pd.DataFrame( + { + "timestamp": pd.to_datetime(["2026-01-01 08:00", "2026-01-01 08:30"]), + "eventType": ["Temp Basal", "Temp Basal"], + "absolute": [1.2, 0.6], + "duration": [30, 60], + } + ) + # 1.2 E/u * 0.5u + 0.6 E/u * 1u = 0.6 + 0.6 = 1.2 E over 1 dag + result = basal_stats(treatments, period_days=1) + assert result["total_units"] == pytest.approx(1.2) + assert result["avg_units_per_day"] == pytest.approx(1.2) + + +def test_basal_stats_ignores_non_temp_basal_rows_and_missing_fields(): + treatments = pd.DataFrame( + { + "timestamp": pd.to_datetime(["2026-01-01 08:00", "2026-01-01 08:30"]), + "eventType": ["Meal Bolus", "Temp Basal"], + "absolute": [None, None], + "duration": [None, 30], + } + ) + result = basal_stats(treatments, period_days=1) + assert result == {"total_units": 0.0, "avg_units_per_day": 0.0} + + +def test_basal_stats_handles_empty_input(): + empty = pd.DataFrame(columns=["timestamp", "eventType", "absolute", "duration"]) + result = basal_stats(empty, period_days=1) + assert result == {"total_units": 0.0, "avg_units_per_day": 0.0} + + def test_site_interval_stats_splits_by_site_change(): glucose = _series( [ -- 2.54.0