Fix bolus/basal insulin categorization: split SMB from manual corrections (#20) #24

Merged
willemjan merged 1 commits from dev into main 2026-08-01 20:53:11 +00: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)
_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:
+17 -9
View File
@@ -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")
+48 -6
View File
@@ -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",
+32
View File
@@ -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):
+67 -1
View File
@@ -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(
[