from datetime import date import pandas as pd import pytest from reporting import ( agp_percentile_bands, basal_stats, bolus_stats, completeness_warning, day_of_week_pattern, detect_events, gmi_percent, minutes_per_day, overnight_daytime_split, percent_cv, sensor_active_percent, site_interval_stats, summarize_events, 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_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_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, }