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:
@@ -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