Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d637ad39e4 | ||
|
|
9e11dd8d73 |
+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)
|
||||
|
||||
|
||||
_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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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(
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user