add reporting foundation: multipage nav, auth gate, date-range fetch
First increment of the clinical reporting feature (see docs/requirements-reporting.md and docs/design-reporting.md): moves the existing live view to pages/1_Live.py, adds a password gate since the dashboard URL will be shared with the diabetes team, adds fetch_entries_range/fetch_treatments_range for multi-day report queries, and adds reporting.py with the core clinical calculations (GMI, %CV, 5-band time-in-range, hypo/hyper event detection per the Battelino et al. 2019 persistence rule). No report pages wired up yet — that's next. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from reporting import (
|
||||
detect_events,
|
||||
gmi_percent,
|
||||
minutes_per_day,
|
||||
percent_cv,
|
||||
sensor_active_percent,
|
||||
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_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_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,
|
||||
}
|
||||
Reference in New Issue
Block a user