2 Commits
Author SHA1 Message Date
willemjan d637ad39e4 Merge pull request 'Fix bolus/basal insulin categorization: split SMB from manual corrections (#20)' (#24) from dev into main
CI/CD / test (push) Successful in 9s
CI/CD / sonarqube (push) Successful in 20s
CI/CD / build-and-scan (push) Successful in 40s
CI/CD / deploy (push) Successful in 3s
2026-08-01 20:53:10 +00:00
willemjan 9e11dd8d73 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.
2026-08-01 22:52:54 +02:00
5 changed files with 184 additions and 26 deletions
+20 -10
View File
@@ -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) 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: 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) data = _paginated_get("/api/v1/treatments.json", since, until, TREATMENTS_CURSOR_FIELD)
if not data: if not data:
return pd.DataFrame(columns=["timestamp", "eventType", "insulin", "carbs"]) return pd.DataFrame(columns=_TREATMENT_COLUMNS)
df = pd.DataFrame(data) df = pd.DataFrame(data)
df["timestamp"] = pd.to_datetime(df["created_at"], utc=True).dt.tz_localize(None) 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: if col not in df.columns:
df[col] = pd.NA df[col] = pd.NA
df[col] = pd.to_numeric(df[col], errors="coerce") df[col] = pd.to_numeric(df[col], errors="coerce")
if "eventType" not in df.columns: for col in ("eventType", "type"):
df["eventType"] = "" if col not in df.columns:
df[col] = ""
df[col] = df[col].fillna("")
df = df[df["timestamp"] >= since.replace(tzinfo=None)] df = df[df["timestamp"] >= since.replace(tzinfo=None)]
if until is not None: if until is not None:
df = df[df["timestamp"] <= until.replace(tzinfo=None)] df = df[df["timestamp"] <= until.replace(tzinfo=None)]
return ( return df[_TREATMENT_COLUMNS].sort_values("timestamp").reset_index(drop=True)
df[["timestamp", "eventType", "insulin", "carbs"]]
.sort_values("timestamp")
.reset_index(drop=True)
)
def fetch_entries(hours: int = 24) -> pd.DataFrame: def fetch_entries(hours: int = 24) -> pd.DataFrame:
+17 -9
View File
@@ -6,6 +6,7 @@ from auth import require_auth
from data_processing import classify_treatments from data_processing import classify_treatments
from nightscout_client import NightscoutConfigError from nightscout_client import NightscoutConfigError
from reporting import ( from reporting import (
basal_stats,
bolus_stats, bolus_stats,
day_of_week_pattern, day_of_week_pattern,
gmi_percent, gmi_percent,
@@ -93,18 +94,25 @@ if not weekday_plot.empty:
) )
st.plotly_chart(fig, width="stretch") st.plotly_chart(fig, width="stretch")
# --- FR8: bolus-insuline -------------------------------------------------- # --- FR8: insuline (basaal + bolus-uitsplitsing) ---------------------------
st.subheader("Bolus-insuline") st.subheader("Insuline")
st.caption( st.caption(
"Alleen bolusinsuline (maaltijd/correctie/combo/SMB) — basaalinsuline vereist " "Basaal berekend uit Temp Basal-treatments (snelheid x duur), zonder pompprofiel "
"profielgegevens en is hier niet in verwerkt (zie docs/design-reporting.md)." "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) bolus = bolus_stats(treatments)
col1, col2 = st.columns(2) basal = basal_stats(treatments, days)
col1.metric("Totaal bolus", f"{bolus['total_units']:.1f} E") total_units = bolus["total_units"] + basal["total_units"]
col2.metric("Aantal boluses", bolus["count"]) col1, col2, col3 = st.columns(3)
if bolus["by_type"]: col1.metric("Totaal insuline", f"{total_units:.1f} E")
st.table({"type": list(bolus["by_type"].keys()), "eenheden": list(bolus["by_type"].values())}) 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 --------------------------- # --- FR8: pompsite-leeftijd vs. glucose/insuline ---------------------------
st.subheader("Pompsite-leeftijd vs. glucose en insuline") st.subheader("Pompsite-leeftijd vs. glucose en insuline")
+48 -6
View File
@@ -330,22 +330,35 @@ def day_of_week_pattern(glucose: pd.DataFrame) -> pd.DataFrame:
return pd.DataFrame(rows, columns=columns) return pd.DataFrame(rows, columns=columns)
SMB_TYPE = "SMB"
BASAL_EVENT_TYPE = "Temp Basal"
def bolus_stats(treatments: pd.DataFrame) -> dict: def bolus_stats(treatments: pd.DataFrame) -> dict:
"""Samenvatting van bolus-insuline, opgesplitst naar type. """Samenvatting van bolus-insuline, opgesplitst naar type.
Let op: dit betreft alleen bolusinsuline (maaltijd/correctie/combo/SMB). AndroidAPS in SMB-looping registreert een automatische micro-bolus niet
Basaalinsuline wordt hier niet in meegenomen — dat vereist profielgegevens als eigen `eventType`, maar als `eventType == "Correction Bolus"` met
(zie docs/design-reporting.md, scoping-kanttekening) en levert dus geen `type == "SMB"` — zonder dit onderscheid werden alle SMB's stilzwijgend
volledige totale-dagdosis op. 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: if treatments.empty:
return {"total_units": 0.0, "count": 0, "by_type": {}} 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: if bolus.empty:
return {"total_units": 0.0, "count": 0, "by_type": {}} 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 { return {
"total_units": round(bolus["insulin"].sum(), 1), "total_units": round(bolus["insulin"].sum(), 1),
"count": len(bolus), "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 = [ SITE_INTERVAL_COLUMNS = [
"start", "start",
"end", "end",
+32
View File
@@ -90,6 +90,38 @@ def test_fetch_treatments_fills_missing_columns(monkeypatch):
assert result.iloc[0]["eventType"] == "Site Change" assert result.iloc[0]["eventType"] == "Site Change"
assert pd_isna(result.iloc[0]["insulin"]) assert pd_isna(result.iloc[0]["insulin"])
assert pd_isna(result.iloc[0]["carbs"]) 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): def pd_isna(value):
+67 -1
View File
@@ -5,6 +5,7 @@ import pytest
from reporting import ( from reporting import (
agp_percentile_bands, agp_percentile_bands,
basal_stats,
bolus_stats, bolus_stats,
completeness_warning, completeness_warning,
day_of_week_pattern, day_of_week_pattern,
@@ -339,6 +340,7 @@ def test_bolus_stats_splits_by_event_type():
"eventType": ["Meal Bolus", "Correction Bolus", "Site Change"], "eventType": ["Meal Bolus", "Correction Bolus", "Site Change"],
"insulin": [4.0, 1.5, None], "insulin": [4.0, 1.5, None],
"carbs": [45.0, None, None], "carbs": [45.0, None, None],
"type": ["NORMAL", "NORMAL", ""],
} }
) )
result = bolus_stats(treatments) 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} 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(): 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) result = bolus_stats(empty)
assert result == {"total_units": 0.0, "count": 0, "by_type": {}} 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(): def test_site_interval_stats_splits_by_site_change():
glucose = _series( glucose = _series(
[ [