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.
This commit is contained in:
2026-08-01 22:52:54 +02:00
parent 441bb84212
commit 9e11dd8d73
5 changed files with 184 additions and 26 deletions
+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",