Add basal/bolus/carb/target overlays to the Live and Dagoverzicht charts
willemjan/glucose-dashboard/pipeline/pr-feature/insulin-profile This commit looks good
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:
@@ -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(
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user