Fix bolus/basal insulin categorization: split SMB from manual corrections (#20) #24
+20
-10
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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(
|
||||||
[
|
[
|
||||||
|
|||||||
Reference in New Issue
Block a user