Add basal/bolus/carb/target overlays to the Live and Dagoverzicht charts
willemjan/glucose-dashboard/pipeline/pr-feature/insulin-profile This commit looks good

Overlay the full insulin picture onto the interactive glucose charts:
basal rate as a semi-transparent step-area (profile schedule overridden
by active temp basal), bolus doses as size-scaled dots split into
food/correction/SMB categories (food and correction labeled with units,
SMB left unlabeled), carbs as bars, and the pump's target glucose as a
line — each with its own color, alpha, and legend entry. Shared trace-
building helpers live in ui_common.py so both pages (and the PDF report
in a follow-up PR) render identically.

Builds on the pump profile work in #29 (profile_data.py, load_profile()).
This commit is contained in:
2026-09-05 22:30:10 +02:00
parent 79249958c2
commit c54dcf616a
6 changed files with 729 additions and 42 deletions
+138
View File
@@ -8,10 +8,13 @@ from reporting import (
agp_percentile_bands,
basal_stats,
bolus_stats,
classify_bolus,
completeness_warning,
daily_gmi,
day_of_week_pattern,
detect_events,
effective_basal_series,
glucose_min_max,
gmi_mmol_per_mol,
gmi_percent,
gmi_trend,
@@ -21,6 +24,7 @@ from reporting import (
sensor_active_percent,
site_interval_stats,
summarize_events,
target_glucose_series,
tir_breakdown_5band,
)
@@ -480,6 +484,140 @@ def test_basal_stats_handles_empty_input():
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_site_interval_stats_splits_by_site_change():
glucose = _series(
[
+101
View File
@@ -1,8 +1,13 @@
import pandas as pd
import plotly.graph_objects as go
from ui_common import (
PALETTE,
THRESHOLD_LINES,
add_basal_area_trace,
add_bolus_marker_traces,
add_carb_bars_trace,
add_target_line_trace,
add_threshold_lines,
build_tir_stacked_bar_figure,
hex_to_rgba,
@@ -36,3 +41,99 @@ def test_build_tir_stacked_bar_figure_has_one_bar_trace_per_band():
assert len(fig.data) == 5
assert [trace.y[0] for trace in fig.data] == [1.0, 3.0, 80.0, 12.0, 4.0]
assert fig.layout.barmode == "stack"
def test_add_basal_area_trace_adds_one_filled_step_trace():
basal = pd.DataFrame(
{"timestamp": pd.to_datetime(["2026-01-01 00:00", "2026-01-01 06:00"]), "rate": [0.8, 1.2]}
)
fig = go.Figure()
add_basal_area_trace(fig, basal, PALETTE["light"])
assert len(fig.data) == 1
assert fig.data[0].name == "Basaal"
assert fig.data[0].fill == "tozeroy"
assert fig.data[0].line.shape == "hv"
def test_add_basal_area_trace_skips_empty_input():
fig = go.Figure()
add_basal_area_trace(fig, pd.DataFrame(columns=["timestamp", "rate"]), PALETTE["light"])
assert len(fig.data) == 0
def test_add_carb_bars_trace_adds_one_bar_trace():
carbs = pd.DataFrame({"timestamp": pd.to_datetime(["2026-01-01 08:00"]), "carbs": [40.0]})
fig = go.Figure()
add_carb_bars_trace(fig, carbs, PALETTE["light"])
assert len(fig.data) == 1
assert fig.data[0].name == "Koolhydraten"
assert isinstance(fig.data[0], go.Bar)
def test_add_bolus_marker_traces_adds_one_trace_per_category_present():
bolus = pd.DataFrame(
{
"timestamp": pd.to_datetime(["2026-01-01 08:00", "2026-01-01 12:00"]),
"insulin": [4.0, 0.3],
"category": ["food", "smb"],
}
)
fig = go.Figure()
add_bolus_marker_traces(fig, bolus, PALETTE["light"], lane_y=2.0)
names = {trace.name for trace in fig.data}
assert names == {"Bolus (voeding)", "SMB"}
def test_add_bolus_marker_traces_labels_food_and_correction_but_not_smb():
bolus = pd.DataFrame(
{
"timestamp": pd.to_datetime(["2026-01-01 08:00", "2026-01-01 12:00"]),
"insulin": [4.0, 0.3],
"category": ["food", "smb"],
}
)
fig = go.Figure()
add_bolus_marker_traces(fig, bolus, PALETTE["light"], lane_y=2.0)
by_name = {trace.name: trace for trace in fig.data}
assert by_name["Bolus (voeding)"].mode == "markers+text"
assert by_name["SMB"].mode == "markers"
def test_add_bolus_marker_traces_skips_empty_input():
fig = go.Figure()
add_bolus_marker_traces(fig, pd.DataFrame(columns=["timestamp", "insulin", "category"]), PALETTE["light"], 2.0)
assert len(fig.data) == 0
def test_add_target_line_trace_adds_one_line_trace():
target = pd.DataFrame({"timestamp": pd.to_datetime(["2026-01-01 00:00"]), "target": [6.5]})
fig = go.Figure()
add_target_line_trace(fig, target, PALETTE["light"])
assert len(fig.data) == 1
assert fig.data[0].name == "Streefwaarde (pomp)"
def test_add_target_line_trace_skips_empty_input():
fig = go.Figure()
add_target_line_trace(fig, pd.DataFrame(columns=["timestamp", "target"]), PALETTE["light"])
assert len(fig.data) == 0
def test_overlay_traces_support_an_hour_based_x_column_for_dagoverzicht():
"""Dagoverzicht plot elke dag op een gedeelde 0-24u-as ('hour') i.p.v. echte
datums; de hover mag daar dan ook geen datumformattering tonen."""
basal = pd.DataFrame({"hour": [0.0, 6.0], "rate": [0.8, 1.2]})
carbs = pd.DataFrame({"hour": [8.0], "carbs": [40.0]})
bolus = pd.DataFrame({"hour": [8.0], "insulin": [4.0], "category": ["food"]})
target = pd.DataFrame({"hour": [0.0], "target": [6.5]})
fig = go.Figure()
add_basal_area_trace(fig, basal, PALETTE["light"], x_col="hour")
add_carb_bars_trace(fig, carbs, PALETTE["light"], x_col="hour")
add_bolus_marker_traces(fig, bolus, PALETTE["light"], lane_y=2.0, x_col="hour")
add_target_line_trace(fig, target, PALETTE["light"], x_col="hour")
assert len(fig.data) == 4
bolus_trace = next(t for t in fig.data if t.name == "Bolus (voeding)")
assert list(bolus_trace.x) == [8.0]
assert "%d-%m" not in bolus_trace.hovertemplate