Every report section now gets its own landscape A4 page (or, for Dagoverzicht, one page per day) via a GlucoseReportPDF(FPDF) subclass with a real header (section title, report version, insulin/pump/sensor type from new REPORT_* env vars) and footer (page number, generation timestamp, Nightscout link) on every page, plus an original SVG icon in the header — inspired by the LibreView AGP report. New "Insulineprofiel" PDF section mirrors the dashboard page (basal bar chart, ISF/carb-ratio segments, DIA). Each Dagoverzicht day now draws the same basal/bolus/carb/target overlays as the interactive charts (matplotlib port of the Plotly helpers from #30) plus a new per-day stats block (reporting.daily_stats: avg/min/max glucose, total/basal/bolus/SMB insulin, hypo/hyper counts and durations). Builds on #29 (profile data) and #30 (overlay logic/colors).
742 lines
27 KiB
Python
742 lines
27 KiB
Python
from datetime import date
|
|
|
|
import pandas as pd
|
|
import pytest
|
|
|
|
from reporting import (
|
|
GMI_TREND_COLUMNS,
|
|
agp_percentile_bands,
|
|
basal_stats,
|
|
bolus_stats,
|
|
classify_bolus,
|
|
completeness_warning,
|
|
daily_gmi,
|
|
daily_stats,
|
|
day_of_week_pattern,
|
|
detect_events,
|
|
effective_basal_series,
|
|
glucose_min_max,
|
|
gmi_mmol_per_mol,
|
|
gmi_percent,
|
|
gmi_trend,
|
|
minutes_per_day,
|
|
overnight_daytime_split,
|
|
percent_cv,
|
|
sensor_active_percent,
|
|
site_interval_stats,
|
|
summarize_events,
|
|
target_glucose_series,
|
|
tir_breakdown_5band,
|
|
)
|
|
|
|
|
|
def _series(pairs):
|
|
"""Bouw een glucose-DataFrame uit (tijd-string, mmol)-paren."""
|
|
timestamps, values = zip(*pairs, strict=True)
|
|
return pd.DataFrame({"timestamp": pd.to_datetime(list(timestamps)), "mmol": list(values)})
|
|
|
|
|
|
def test_gmi_percent_matches_bergenstal_formula():
|
|
# 3.31 + 0.4311 * 7.0 = 6.3277 -> 6.3
|
|
assert gmi_percent(7.0) == pytest.approx(6.3, abs=0.05)
|
|
|
|
|
|
def test_gmi_mmol_per_mol_matches_known_ifcc_reference_values():
|
|
# 6.5% is de bekende diagnostische drempel = 48 mmol/mol (IFCC).
|
|
assert gmi_mmol_per_mol(6.5) == 48
|
|
assert gmi_mmol_per_mol(7.0) == 53
|
|
|
|
|
|
def test_daily_gmi_aggregates_per_calendar_day():
|
|
glucose = _series(
|
|
[
|
|
("2026-01-01 08:00", 6.0),
|
|
("2026-01-01 20:00", 8.0),
|
|
("2026-01-02 08:00", 7.0),
|
|
]
|
|
)
|
|
result = daily_gmi(glucose)
|
|
assert list(result["date"].astype(str)) == ["2026-01-01", "2026-01-02"]
|
|
assert result["gmi"].iloc[0] == pytest.approx(gmi_percent(7.0)) # gemiddelde van 6.0 en 8.0
|
|
assert result["gmi"].iloc[1] == pytest.approx(gmi_percent(7.0))
|
|
|
|
|
|
def test_daily_gmi_handles_empty_input():
|
|
empty = pd.DataFrame(columns=["timestamp", "mmol"])
|
|
result = daily_gmi(empty)
|
|
assert result.empty
|
|
assert list(result.columns) == ["date", "gmi"]
|
|
|
|
|
|
def test_gmi_trend_handles_empty_input():
|
|
empty = pd.DataFrame(columns=["timestamp", "mmol"])
|
|
result = gmi_trend(empty)
|
|
assert result.empty
|
|
assert list(result.columns) == GMI_TREND_COLUMNS
|
|
|
|
|
|
def test_gmi_trend_returns_no_line_for_a_single_day():
|
|
glucose = _series([("2026-01-01 08:00", 6.0)])
|
|
result = gmi_trend(glucose)
|
|
assert list(result.columns) == GMI_TREND_COLUMNS
|
|
assert len(result) == 1
|
|
assert pd.isna(result["trend"].iloc[0])
|
|
|
|
|
|
def test_gmi_trend_fits_increasing_linear_trend_with_band_around_it():
|
|
pairs = [(f"2026-01-{i + 1:02d} 08:00", 6.0 + i * 0.2) for i in range(10)]
|
|
glucose = _series(pairs)
|
|
result = gmi_trend(glucose, rolling_window_days=3)
|
|
|
|
assert len(result) == 10
|
|
assert result["trend"].iloc[-1] > result["trend"].iloc[0]
|
|
assert (result["trend_lower"] <= result["trend"]).all()
|
|
assert (result["trend"] <= result["trend_upper"]).all()
|
|
# Vrijwel lineaire input (gmi_percent rondt af op 1 decimaal, dus een
|
|
# kleine restfout blijft over) -> smalle betrouwbaarheidsband.
|
|
assert (result["trend_upper"] - result["trend_lower"]).max() == pytest.approx(0, abs=0.15)
|
|
assert result["rolling_avg"].iloc[-1] > result["rolling_avg"].iloc[0]
|
|
|
|
|
|
def test_percent_cv_handles_empty_input():
|
|
empty = pd.DataFrame(columns=["timestamp", "mmol"])
|
|
assert percent_cv(empty) == 0.0
|
|
|
|
|
|
def test_percent_cv_computes_stdev_over_mean():
|
|
glucose = _series(
|
|
[
|
|
("2026-01-01 00:00", 6.0),
|
|
("2026-01-01 00:05", 8.0),
|
|
("2026-01-01 00:10", 6.0),
|
|
("2026-01-01 00:15", 8.0),
|
|
]
|
|
)
|
|
expected = glucose["mmol"].std() / glucose["mmol"].mean() * 100
|
|
assert percent_cv(glucose) == pytest.approx(round(expected, 1))
|
|
|
|
|
|
def test_tir_breakdown_5band_covers_all_bands_and_sums_to_100():
|
|
glucose = _series(
|
|
[
|
|
("2026-01-01 00:00", 2.5), # very_low
|
|
("2026-01-01 00:05", 3.5), # low
|
|
("2026-01-01 00:10", 6.0), # in_range
|
|
("2026-01-01 00:15", 11.0), # high
|
|
("2026-01-01 00:20", 14.0), # very_high
|
|
]
|
|
)
|
|
result = tir_breakdown_5band(glucose)
|
|
assert result == {
|
|
"very_low": 20.0,
|
|
"low": 20.0,
|
|
"in_range": 20.0,
|
|
"high": 20.0,
|
|
"very_high": 20.0,
|
|
}
|
|
assert sum(result.values()) == pytest.approx(100.0)
|
|
|
|
|
|
def test_tir_breakdown_5band_handles_empty_input():
|
|
empty = pd.DataFrame(columns=["timestamp", "mmol"])
|
|
result = tir_breakdown_5band(empty)
|
|
assert result == {"very_low": 0.0, "low": 0.0, "in_range": 0.0, "high": 0.0, "very_high": 0.0}
|
|
|
|
|
|
def test_minutes_per_day_converts_percentage():
|
|
assert minutes_per_day(50.0) == 720.0
|
|
assert minutes_per_day(100.0) == 1440.0
|
|
|
|
|
|
def test_sensor_active_percent_full_coverage():
|
|
# 12 metingen om de 5 minuten = precies 1 uur dekking
|
|
timestamps = pd.date_range("2026-01-01 00:00", periods=12, freq="5min")
|
|
glucose = pd.DataFrame({"timestamp": timestamps, "mmol": [6.0] * 12})
|
|
result = sensor_active_percent(glucose, "2026-01-01 00:00", "2026-01-01 01:00")
|
|
assert result == pytest.approx(100.0, abs=1.0)
|
|
|
|
|
|
def test_sensor_active_percent_handles_gaps():
|
|
timestamps = pd.date_range("2026-01-01 00:00", periods=6, freq="5min") # maar 30 min dekking
|
|
glucose = pd.DataFrame({"timestamp": timestamps, "mmol": [6.0] * 6})
|
|
result = sensor_active_percent(glucose, "2026-01-01 00:00", "2026-01-01 01:00")
|
|
assert result == pytest.approx(50.0, abs=1.0)
|
|
|
|
|
|
def test_completeness_warning_none_for_empty_glucose():
|
|
empty = pd.DataFrame(columns=["timestamp", "mmol"])
|
|
assert completeness_warning(empty, "2026-01-01", "2026-01-14") == "Geen glucosedata gevonden in deze periode."
|
|
|
|
|
|
def test_completeness_warning_none_when_period_is_fully_covered():
|
|
# 14 volle dagen aan 5-minuten-metingen, precies de gekozen periode
|
|
timestamps = pd.date_range("2026-01-01 00:00", "2026-01-14 23:55", freq="5min")
|
|
glucose = pd.DataFrame({"timestamp": timestamps, "mmol": [6.0] * len(timestamps)})
|
|
assert completeness_warning(glucose, date(2026, 1, 1), date(2026, 1, 14)) is None
|
|
|
|
|
|
def test_completeness_warning_flags_missing_days_at_start_of_period():
|
|
# gevraagd: 1 t/m 14 januari, maar data begint pas op 11 januari (de
|
|
# exacte bug: alleen de laatste ~4 dagen van een langere periode zijn
|
|
# opgehaald)
|
|
timestamps = pd.date_range("2026-01-11 00:00", "2026-01-14 23:55", freq="5min")
|
|
glucose = pd.DataFrame({"timestamp": timestamps, "mmol": [6.0] * len(timestamps)})
|
|
message = completeness_warning(glucose, date(2026, 1, 1), date(2026, 1, 14))
|
|
assert message is not None
|
|
assert "11-01-2026" in message
|
|
assert "10 dag" in message # 1 t/m 10 januari ontbreken volledig = 10 dagen
|
|
assert "sensorhiaten" in message
|
|
|
|
|
|
def test_completeness_warning_flags_low_coverage_without_start_gap():
|
|
# data start op tijd, maar met veel verspreide hiaten -> lage dekking
|
|
# zonder dat er een aaneengesloten gat aan het begin is
|
|
timestamps = pd.date_range("2026-01-01 00:00", periods=20, freq="5min")
|
|
glucose = pd.DataFrame({"timestamp": timestamps, "mmol": [6.0] * 20})
|
|
message = completeness_warning(glucose, date(2026, 1, 1), date(2026, 1, 14))
|
|
assert message is not None
|
|
assert "sensordekking" in message
|
|
assert "sensorhiaten" not in message # dit is de generieke, niet de "ontbreekt volledig"-boodschap
|
|
|
|
|
|
def test_detect_events_ignores_dip_shorter_than_minimum_duration():
|
|
# dip van 10 minuten (< 15 min drempel) mag niet meetellen
|
|
glucose = _series(
|
|
[
|
|
("2026-01-01 00:00", 6.0),
|
|
("2026-01-01 00:05", 3.5),
|
|
("2026-01-01 00:10", 3.4),
|
|
("2026-01-01 00:15", 6.0),
|
|
("2026-01-01 00:20", 6.1),
|
|
("2026-01-01 00:25", 6.2),
|
|
]
|
|
)
|
|
events = detect_events(glucose, threshold=3.9, direction="below")
|
|
assert events.empty
|
|
|
|
|
|
def test_detect_events_counts_dip_of_exactly_20_minutes():
|
|
glucose = _series(
|
|
[
|
|
("2026-01-01 00:00", 6.0),
|
|
("2026-01-01 00:05", 3.5),
|
|
("2026-01-01 00:10", 3.4),
|
|
("2026-01-01 00:15", 3.3),
|
|
("2026-01-01 00:20", 3.2),
|
|
("2026-01-01 00:25", 6.0),
|
|
("2026-01-01 00:30", 6.1),
|
|
("2026-01-01 00:35", 6.2),
|
|
("2026-01-01 00:40", 6.3),
|
|
]
|
|
)
|
|
events = detect_events(glucose, threshold=3.9, direction="below")
|
|
assert len(events) == 1
|
|
assert events.iloc[0]["duration_minutes"] == pytest.approx(20.0)
|
|
|
|
|
|
def test_detect_events_merges_across_brief_recovery():
|
|
# twee dips gescheiden door een kort (5 min) herstel moeten één
|
|
# doorlopend event vormen volgens de persistentieregel
|
|
glucose = _series(
|
|
[
|
|
("2026-01-01 00:00", 6.0),
|
|
("2026-01-01 00:05", 3.5),
|
|
("2026-01-01 00:10", 3.4),
|
|
("2026-01-01 00:15", 6.0), # kort herstel, 5 min
|
|
("2026-01-01 00:20", 3.3),
|
|
("2026-01-01 00:25", 3.2),
|
|
("2026-01-01 00:30", 3.1),
|
|
("2026-01-01 00:35", 6.0),
|
|
("2026-01-01 00:40", 6.1),
|
|
("2026-01-01 00:45", 6.2),
|
|
("2026-01-01 00:50", 6.3), # bevestigd herstel, 15 min
|
|
]
|
|
)
|
|
events = detect_events(glucose, threshold=3.9, direction="below")
|
|
assert len(events) == 1
|
|
assert events.iloc[0]["start"] == pd.Timestamp("2026-01-01 00:05")
|
|
assert events.iloc[0]["end"] == pd.Timestamp("2026-01-01 00:35")
|
|
assert events.iloc[0]["duration_minutes"] == pytest.approx(30.0)
|
|
|
|
|
|
def test_detect_events_does_not_inflate_duration_on_trailing_brief_blip():
|
|
# regressie: een out-periode van 10 min gevolgd door een niet-bevestigd
|
|
# (kort, en aan het eind van de data) herstel mag niet meetellen
|
|
glucose = _series(
|
|
[
|
|
("2026-01-01 00:00", 6.0),
|
|
("2026-01-01 00:05", 3.5),
|
|
("2026-01-01 00:10", 3.4),
|
|
("2026-01-01 00:15", 6.0),
|
|
("2026-01-01 00:20", 6.1),
|
|
("2026-01-01 00:25", 6.2),
|
|
]
|
|
)
|
|
events = detect_events(glucose, threshold=3.9, direction="below")
|
|
assert events.empty
|
|
|
|
|
|
def test_detect_events_supports_hyperglycemia_direction():
|
|
glucose = _series(
|
|
[
|
|
("2026-01-01 00:00", 6.0),
|
|
("2026-01-01 00:05", 12.0),
|
|
("2026-01-01 00:10", 12.5),
|
|
("2026-01-01 00:15", 13.0),
|
|
("2026-01-01 00:20", 12.8),
|
|
("2026-01-01 00:25", 6.0),
|
|
("2026-01-01 00:30", 6.1),
|
|
("2026-01-01 00:35", 6.2),
|
|
("2026-01-01 00:40", 6.3),
|
|
]
|
|
)
|
|
events = detect_events(glucose, threshold=10.0, direction="above")
|
|
assert len(events) == 1
|
|
assert events.iloc[0]["duration_minutes"] == pytest.approx(20.0)
|
|
|
|
|
|
def test_detect_events_rejects_invalid_direction():
|
|
glucose = _series([("2026-01-01 00:00", 6.0)])
|
|
with pytest.raises(ValueError, match="direction"):
|
|
detect_events(glucose, threshold=3.9, direction="sideways")
|
|
|
|
|
|
def test_detect_events_handles_empty_input():
|
|
empty = pd.DataFrame(columns=["timestamp", "mmol"])
|
|
events = detect_events(empty, threshold=3.9, direction="below")
|
|
assert events.empty
|
|
assert list(events.columns) == ["start", "end", "duration_minutes"]
|
|
|
|
|
|
def test_summarize_events_computes_expected_stats():
|
|
events = pd.DataFrame(
|
|
{
|
|
"start": pd.to_datetime(["2026-01-01 00:00", "2026-01-03 00:00"]),
|
|
"end": pd.to_datetime(["2026-01-01 00:20", "2026-01-03 01:00"]),
|
|
"duration_minutes": [20.0, 60.0],
|
|
}
|
|
)
|
|
result = summarize_events(events, period_days=7)
|
|
assert result["count"] == 2
|
|
assert result["avg_duration_minutes"] == pytest.approx(40.0)
|
|
assert result["longest_duration_minutes"] == pytest.approx(60.0)
|
|
assert result["total_minutes"] == pytest.approx(80.0)
|
|
assert result["events_per_week"] == pytest.approx(2.0)
|
|
|
|
|
|
def test_agp_percentile_bands_groups_by_time_of_day_across_days():
|
|
# zelfde tijdstip (08:00) op twee verschillende dagen, met bekende waarden
|
|
glucose = _series(
|
|
[
|
|
("2026-01-01 08:00", 6.0),
|
|
("2026-01-02 08:00", 8.0),
|
|
("2026-01-01 20:00", 10.0),
|
|
("2026-01-02 20:00", 10.0),
|
|
]
|
|
)
|
|
result = agp_percentile_bands(glucose, bin_minutes=30)
|
|
assert list(result.columns) == ["minute_of_day", "p5", "p25", "p50", "p75", "p95"]
|
|
|
|
morning_bin = 8 * 60
|
|
row = result[result["minute_of_day"] == morning_bin].iloc[0]
|
|
assert row["p50"] == pytest.approx(7.0) # mediaan van 6.0 en 8.0
|
|
|
|
evening_bin = 20 * 60
|
|
evening_row = result[result["minute_of_day"] == evening_bin].iloc[0]
|
|
assert evening_row["p50"] == pytest.approx(10.0)
|
|
|
|
|
|
def test_agp_percentile_bands_handles_empty_input():
|
|
empty = pd.DataFrame(columns=["timestamp", "mmol"])
|
|
result = agp_percentile_bands(empty)
|
|
assert result.empty
|
|
assert list(result.columns) == ["minute_of_day", "p5", "p25", "p50", "p75", "p95"]
|
|
|
|
|
|
def test_overnight_daytime_split_separates_by_hour():
|
|
glucose = _series(
|
|
[
|
|
("2026-01-01 02:00", 3.0), # nacht, laag
|
|
("2026-01-01 03:00", 3.0), # nacht, laag
|
|
("2026-01-01 12:00", 6.0), # dag, in bereik
|
|
("2026-01-01 13:00", 6.0), # dag, in bereik
|
|
]
|
|
)
|
|
result = overnight_daytime_split(glucose, overnight_start=0, overnight_end=6)
|
|
assert result["overnight"]["low"] == pytest.approx(100.0)
|
|
assert result["daytime"]["in_range"] == pytest.approx(100.0)
|
|
|
|
|
|
def test_overnight_daytime_split_handles_empty_input():
|
|
empty = pd.DataFrame(columns=["timestamp", "mmol"])
|
|
result = overnight_daytime_split(empty)
|
|
assert result["overnight"] == result["daytime"]
|
|
|
|
|
|
def test_day_of_week_pattern_computes_per_weekday_stats():
|
|
# 2026-01-05 is een maandag
|
|
glucose = _series(
|
|
[
|
|
("2026-01-05 08:00", 6.0),
|
|
("2026-01-05 09:00", 8.0),
|
|
("2026-01-12 08:00", 7.0), # ook een maandag
|
|
]
|
|
)
|
|
result = day_of_week_pattern(glucose)
|
|
monday = result[result["weekday"] == "Maandag"].iloc[0]
|
|
assert monday["count"] == 3
|
|
assert monday["mean_mmol"] == pytest.approx(7.0)
|
|
|
|
tuesday = result[result["weekday"] == "Dinsdag"].iloc[0]
|
|
assert tuesday["count"] == 0
|
|
assert pd.isna(tuesday["mean_mmol"])
|
|
|
|
|
|
def test_day_of_week_pattern_handles_empty_input():
|
|
empty = pd.DataFrame(columns=["timestamp", "mmol"])
|
|
result = day_of_week_pattern(empty)
|
|
assert result.empty
|
|
assert list(result.columns) == ["weekday", "mean_mmol", "in_range_pct", "count"]
|
|
|
|
|
|
def test_bolus_stats_splits_by_event_type():
|
|
treatments = pd.DataFrame(
|
|
{
|
|
"timestamp": pd.to_datetime(["2026-01-01 08:00", "2026-01-01 12:00", "2026-01-01 20:00"]),
|
|
"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)
|
|
assert result["total_units"] == pytest.approx(5.5)
|
|
assert result["count"] == 2
|
|
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", "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_glucose_min_max_returns_min_and_max():
|
|
glucose = _series([("2026-01-01 00:00", 6.5), ("2026-01-01 01:00", 3.2), ("2026-01-01 02:00", 14.1)])
|
|
assert glucose_min_max(glucose) == (3.2, 14.1)
|
|
|
|
|
|
def test_glucose_min_max_handles_empty_input():
|
|
empty = pd.DataFrame(columns=["timestamp", "mmol"])
|
|
assert glucose_min_max(empty) == (0.0, 0.0)
|
|
|
|
|
|
def _bolus_treatment(dt, event_type, insulin=1.0, carbs=None, bolus_type=""):
|
|
return {
|
|
"timestamp": pd.Timestamp(dt),
|
|
"eventType": event_type,
|
|
"insulin": insulin,
|
|
"carbs": carbs,
|
|
"type": bolus_type,
|
|
}
|
|
|
|
|
|
def test_classify_bolus_recognizes_meal_bolus_as_food():
|
|
treatments = pd.DataFrame([_bolus_treatment("2026-01-01 08:00", "Meal Bolus", insulin=4.0)])
|
|
result = classify_bolus(treatments)
|
|
assert result.iloc[0]["category"] == "food"
|
|
|
|
|
|
def test_classify_bolus_recognizes_smb_via_type_field():
|
|
treatments = pd.DataFrame(
|
|
[_bolus_treatment("2026-01-01 08:00", "Correction Bolus", insulin=0.3, bolus_type="SMB")]
|
|
)
|
|
result = classify_bolus(treatments)
|
|
assert result.iloc[0]["category"] == "smb"
|
|
|
|
|
|
def test_classify_bolus_recognizes_smb_via_event_type():
|
|
treatments = pd.DataFrame([_bolus_treatment("2026-01-01 08:00", "SMB", insulin=0.3)])
|
|
result = classify_bolus(treatments)
|
|
assert result.iloc[0]["category"] == "smb"
|
|
|
|
|
|
def test_classify_bolus_treats_correction_bolus_without_carbs_as_correction():
|
|
treatments = pd.DataFrame([_bolus_treatment("2026-01-01 08:00", "Correction Bolus", insulin=1.5)])
|
|
result = classify_bolus(treatments)
|
|
assert result.iloc[0]["category"] == "correction"
|
|
|
|
|
|
def test_classify_bolus_treats_generic_bolus_with_carbs_as_food():
|
|
treatments = pd.DataFrame([_bolus_treatment("2026-01-01 08:00", "Bolus", insulin=3.0, carbs=40.0)])
|
|
result = classify_bolus(treatments)
|
|
assert result.iloc[0]["category"] == "food"
|
|
|
|
|
|
def test_classify_bolus_ignores_non_bolus_rows():
|
|
treatments = pd.DataFrame(
|
|
[
|
|
_bolus_treatment("2026-01-01 08:00", "Meal Bolus", insulin=4.0),
|
|
{"timestamp": pd.Timestamp("2026-01-01 08:05"), "eventType": "Site Change", "insulin": None, "carbs": None, "type": ""},
|
|
]
|
|
)
|
|
result = classify_bolus(treatments)
|
|
assert len(result) == 1
|
|
|
|
|
|
def test_classify_bolus_handles_empty_input():
|
|
empty = pd.DataFrame(columns=["timestamp", "eventType", "insulin", "carbs", "type"])
|
|
result = classify_bolus(empty)
|
|
assert result.empty
|
|
assert list(result.columns) == ["timestamp", "eventType", "insulin", "carbs", "category"]
|
|
|
|
|
|
def test_effective_basal_series_uses_profile_baseline_without_temp_basal():
|
|
profile = {"basal": [{"minutes": 0, "value": 0.8}, {"minutes": 360, "value": 1.2}]}
|
|
empty_treatments = pd.DataFrame(columns=["timestamp", "eventType", "absolute", "duration"])
|
|
result = effective_basal_series(
|
|
profile, empty_treatments, pd.Timestamp("2026-01-01"), pd.Timestamp("2026-01-02")
|
|
)
|
|
assert result.iloc[0]["rate"] == pytest.approx(0.8)
|
|
assert result[result["timestamp"] == pd.Timestamp("2026-01-01 06:00")].iloc[0]["rate"] == pytest.approx(1.2)
|
|
|
|
|
|
def test_effective_basal_series_temp_basal_overrides_profile_rate():
|
|
profile = {"basal": [{"minutes": 0, "value": 0.8}]}
|
|
treatments = pd.DataFrame(
|
|
[{"timestamp": pd.Timestamp("2026-01-01 08:00"), "eventType": "Temp Basal", "absolute": 2.0, "duration": 30}]
|
|
)
|
|
result = effective_basal_series(profile, treatments, pd.Timestamp("2026-01-01"), pd.Timestamp("2026-01-02"))
|
|
during_temp = result[result["timestamp"] == pd.Timestamp("2026-01-01 08:00")].iloc[0]
|
|
after_temp = result[result["timestamp"] == pd.Timestamp("2026-01-01 08:30")].iloc[0]
|
|
assert during_temp["rate"] == pytest.approx(2.0)
|
|
assert after_temp["rate"] == pytest.approx(0.8) # terug naar het profieltempo
|
|
|
|
|
|
def test_effective_basal_series_without_profile_defaults_to_zero_baseline():
|
|
empty_treatments = pd.DataFrame(columns=["timestamp", "eventType", "absolute", "duration"])
|
|
result = effective_basal_series(None, empty_treatments, pd.Timestamp("2026-01-01"), pd.Timestamp("2026-01-02"))
|
|
assert result.iloc[0]["rate"] == pytest.approx(0.0)
|
|
|
|
|
|
def test_effective_basal_series_clips_temp_basal_to_day_boundaries():
|
|
profile = {"basal": [{"minutes": 0, "value": 0.8}]}
|
|
# Temp basal begint de dag ervoor en loopt door tot 01:00 van de dag zelf.
|
|
treatments = pd.DataFrame(
|
|
[{"timestamp": pd.Timestamp("2025-12-31 23:00"), "eventType": "Temp Basal", "absolute": 2.0, "duration": 120}]
|
|
)
|
|
result = effective_basal_series(profile, treatments, pd.Timestamp("2026-01-01"), pd.Timestamp("2026-01-02"))
|
|
assert result.iloc[0]["timestamp"] == pd.Timestamp("2026-01-01")
|
|
assert result.iloc[0]["rate"] == pytest.approx(2.0)
|
|
|
|
|
|
def test_target_glucose_series_averages_low_and_high():
|
|
profile = {
|
|
"target_low": [{"minutes": 0, "value": 4.5}],
|
|
"target_high": [{"minutes": 0, "value": 8.5}],
|
|
}
|
|
result = target_glucose_series(profile, pd.Timestamp("2026-01-01"), pd.Timestamp("2026-01-02"))
|
|
assert result.iloc[0]["target"] == pytest.approx(6.5)
|
|
|
|
|
|
def test_target_glucose_series_falls_back_to_single_bound():
|
|
profile = {"target_low": [{"minutes": 0, "value": 5.0}], "target_high": []}
|
|
result = target_glucose_series(profile, pd.Timestamp("2026-01-01"), pd.Timestamp("2026-01-02"))
|
|
assert result.iloc[0]["target"] == pytest.approx(5.0)
|
|
|
|
|
|
def test_target_glucose_series_empty_without_targets_in_profile():
|
|
result = target_glucose_series({}, pd.Timestamp("2026-01-01"), pd.Timestamp("2026-01-02"))
|
|
assert result.empty
|
|
|
|
|
|
def test_target_glucose_series_handles_none_profile():
|
|
result = target_glucose_series(None, pd.Timestamp("2026-01-01"), pd.Timestamp("2026-01-02"))
|
|
assert result.empty
|
|
|
|
|
|
def test_daily_stats_computes_glucose_and_insulin_breakdown():
|
|
day_start = pd.Timestamp("2026-01-01")
|
|
day_end = pd.Timestamp("2026-01-02")
|
|
glucose = _series(
|
|
[("2026-01-01 08:00", 3.2), ("2026-01-01 12:00", 14.1), ("2026-01-01 20:00", 6.5)]
|
|
)
|
|
treatments = pd.DataFrame(
|
|
[
|
|
{
|
|
"timestamp": pd.Timestamp("2026-01-01 08:00"),
|
|
"eventType": "Meal Bolus",
|
|
"insulin": 4.0,
|
|
"carbs": 40.0,
|
|
"type": "",
|
|
"absolute": None,
|
|
"duration": None,
|
|
},
|
|
{
|
|
"timestamp": pd.Timestamp("2026-01-01 12:00"),
|
|
"eventType": "Correction Bolus",
|
|
"insulin": 0.3,
|
|
"carbs": None,
|
|
"type": "SMB",
|
|
"absolute": None,
|
|
"duration": None,
|
|
},
|
|
{
|
|
"timestamp": pd.Timestamp("2026-01-01 06:00"),
|
|
"eventType": "Temp Basal",
|
|
"insulin": None,
|
|
"carbs": None,
|
|
"type": "",
|
|
"absolute": 2.0,
|
|
"duration": 60,
|
|
},
|
|
]
|
|
)
|
|
profile = {"basal": [{"minutes": 0, "value": 0.8}]}
|
|
|
|
result = daily_stats(glucose, treatments, profile, day_start, day_end)
|
|
|
|
assert result["mean_mmol"] == pytest.approx((3.2 + 14.1 + 6.5) / 3, abs=0.05)
|
|
assert result["min_mmol"] == pytest.approx(3.2)
|
|
assert result["max_mmol"] == pytest.approx(14.1)
|
|
assert result["bolus_insulin"] == pytest.approx(4.0)
|
|
assert result["smb_insulin"] == pytest.approx(0.3)
|
|
# basaal: 23h @ 0.8 profieltempo + 1h @ 2.0 (temp basal 06:00-07:00) = 18.4 + 2.0 = 20.4
|
|
assert result["basal_insulin"] == pytest.approx(20.4, abs=0.05)
|
|
assert result["total_insulin"] == pytest.approx(4.0 + 0.3 + 20.4, abs=0.05)
|
|
assert result["hypo_count"] == 1
|
|
assert result["hyper_count"] == 1
|
|
|
|
|
|
def test_daily_stats_handles_empty_glucose_and_treatments():
|
|
day_start = pd.Timestamp("2026-01-01")
|
|
day_end = pd.Timestamp("2026-01-02")
|
|
empty_glucose = pd.DataFrame(columns=["timestamp", "mmol"])
|
|
empty_treatments = pd.DataFrame(columns=["timestamp", "eventType", "insulin", "carbs", "type", "absolute", "duration"])
|
|
|
|
result = daily_stats(empty_glucose, empty_treatments, None, day_start, day_end)
|
|
|
|
assert result["mean_mmol"] == 0.0
|
|
assert result["min_mmol"] == 0.0
|
|
assert result["max_mmol"] == 0.0
|
|
assert result["total_insulin"] == 0.0
|
|
assert result["hypo_count"] == 0
|
|
assert result["hyper_count"] == 0
|
|
|
|
|
|
def test_site_interval_stats_splits_by_site_change():
|
|
glucose = _series(
|
|
[
|
|
("2026-01-01 00:00", 6.0),
|
|
("2026-01-05 00:00", 6.0),
|
|
("2026-01-10 00:00", 8.0),
|
|
]
|
|
)
|
|
treatments = pd.DataFrame(
|
|
{
|
|
"timestamp": pd.to_datetime(["2026-01-02 00:00", "2026-01-06 00:00"]),
|
|
"eventType": ["Meal Bolus", "Meal Bolus"],
|
|
"insulin": [3.0, 4.0],
|
|
"carbs": [30.0, 40.0],
|
|
}
|
|
)
|
|
site_changes = pd.DataFrame(
|
|
{
|
|
"timestamp": pd.to_datetime(["2026-01-01 00:00", "2026-01-06 00:00"]),
|
|
"eventType": ["Site Change", "Site Change"],
|
|
"insulin": [None, None],
|
|
"carbs": [None, None],
|
|
}
|
|
)
|
|
|
|
result = site_interval_stats(glucose, treatments, site_changes)
|
|
assert len(result) == 2
|
|
assert result.iloc[0]["duration_days"] == pytest.approx(5.0)
|
|
assert result.iloc[0]["total_bolus_units"] == pytest.approx(3.0)
|
|
# de bolus op 6 jan valt binnen het tweede interval ([6 jan, 10 jan))
|
|
assert result.iloc[1]["total_bolus_units"] == pytest.approx(4.0)
|
|
|
|
|
|
def test_site_interval_stats_handles_no_site_changes():
|
|
glucose = _series([("2026-01-01 00:00", 6.0)])
|
|
treatments = pd.DataFrame(columns=["timestamp", "eventType", "insulin", "carbs"])
|
|
empty_site_changes = pd.DataFrame(columns=["timestamp", "eventType", "insulin", "carbs"])
|
|
result = site_interval_stats(glucose, treatments, empty_site_changes)
|
|
assert result.empty
|
|
|
|
|
|
def test_summarize_events_handles_empty_input():
|
|
empty = pd.DataFrame(columns=["start", "end", "duration_minutes"])
|
|
result = summarize_events(empty, period_days=14)
|
|
assert result == {
|
|
"count": 0,
|
|
"avg_duration_minutes": 0.0,
|
|
"longest_duration_minutes": 0.0,
|
|
"total_minutes": 0.0,
|
|
"events_per_week": 0.0,
|
|
}
|