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
+78 -31
View File
@@ -1,6 +1,8 @@
import pandas as pd
import plotly.graph_objects as go
import requests
import streamlit as st
from plotly.subplots import make_subplots
from auth import require_auth
from data_processing import (
@@ -10,7 +12,15 @@ from data_processing import (
summarize_time_in_range,
)
from nightscout_client import NightscoutConfigError, fetch_entries, fetch_treatments
from ui_common import MMOL_HOVERTEMPLATE, get_colors
from reporting import classify_bolus, effective_basal_series, target_glucose_series
from ui_common import (
MMOL_HOVERTEMPLATE,
add_basal_area_trace,
add_bolus_marker_traces,
add_target_line_trace,
get_colors,
load_profile,
)
st.set_page_config(page_title="Glucose dashboard — Live", page_icon="📊", layout="wide")
require_auth()
@@ -27,7 +37,28 @@ def load_nightscout_data(hours: int):
return glucose, treatments
def build_chart(glucose, insulin, carbs, site_changes, sensor_changes, colors) -> go.Figure:
def _basal_and_target_series(profile: dict, treatments: pd.DataFrame, window_start, window_end):
"""Basaal- en streefwaarde-reeksen over een rollend venster dat meerdere
kalenderdagen kan overspannen: per dag berekend (reporting.effective_basal_series/
target_glucose_series zijn dag-begrensd) en daarna aan elkaar geplakt en
geclipt tot het venster."""
dates = pd.date_range(window_start.normalize(), window_end.normalize(), freq="D")
basal_parts, target_parts = [], []
for day_start in dates:
day_end = day_start + pd.Timedelta(days=1)
basal_parts.append(effective_basal_series(profile, treatments, day_start, day_end))
target_parts.append(target_glucose_series(profile, day_start, day_end))
basal = pd.concat(basal_parts, ignore_index=True) if basal_parts else pd.DataFrame(columns=["timestamp", "rate"])
target = (
pd.concat(target_parts, ignore_index=True) if target_parts else pd.DataFrame(columns=["timestamp", "target"])
)
basal = basal[(basal["timestamp"] >= window_start) & (basal["timestamp"] <= window_end)]
target = target[(target["timestamp"] >= window_start) & (target["timestamp"] <= window_end)]
return basal.reset_index(drop=True), target.reset_index(drop=True)
def build_chart(glucose, bolus, carbs, basal, target, site_changes, sensor_changes, colors) -> go.Figure:
glucose_min = min(glucose["mmol"].min(), TARGET_LOW)
glucose_max = max(glucose["mmol"].max(), TARGET_HIGH)
y_top = glucose_max + 1.0
@@ -36,7 +67,7 @@ def build_chart(glucose, insulin, carbs, site_changes, sensor_changes, colors) -
insulin_lane_y = lane_base + 1.5
carbs_lane_y = lane_base + 0.7
fig = go.Figure()
fig = make_subplots(specs=[[{"secondary_y": True}]])
# Streefbereik als achtergrondband, geen losse legenda-entry nodig.
fig.add_hrect(
@@ -45,8 +76,13 @@ def build_chart(glucose, insulin, carbs, site_changes, sensor_changes, colors) -
fillcolor=colors["band"],
line_width=0,
layer="below",
row=1,
col=1,
)
add_basal_area_trace(fig, basal, colors, row=1, secondary_y=True)
add_target_line_trace(fig, target, colors, row=1)
fig.add_trace(
go.Scatter(
x=glucose["timestamp"],
@@ -55,7 +91,9 @@ def build_chart(glucose, insulin, carbs, site_changes, sensor_changes, colors) -
name="Glucose",
line={"color": colors["glucose"], "width": 2, "shape": "spline"},
hovertemplate=MMOL_HOVERTEMPLATE,
)
),
row=1,
col=1,
)
low_points = glucose[glucose["mmol"] < TARGET_LOW]
@@ -69,7 +107,9 @@ def build_chart(glucose, insulin, carbs, site_changes, sensor_changes, colors) -
name=f"Laag (<{TARGET_LOW})",
marker={"color": colors["low"], "size": 6},
hovertemplate=MMOL_HOVERTEMPLATE,
)
),
row=1,
col=1,
)
if not high_points.empty:
fig.add_trace(
@@ -80,21 +120,12 @@ def build_chart(glucose, insulin, carbs, site_changes, sensor_changes, colors) -
name=f"Hoog (>{TARGET_HIGH})",
marker={"color": colors["high"], "size": 6},
hovertemplate=MMOL_HOVERTEMPLATE,
)
),
row=1,
col=1,
)
if not insulin.empty:
fig.add_trace(
go.Scatter(
x=insulin["timestamp"],
y=[insulin_lane_y] * len(insulin),
mode="markers",
name="Insuline",
marker={"color": colors["insulin"], "size": 10, "symbol": "triangle-up"},
customdata=insulin["insulin"],
hovertemplate="%{x|%d-%m %H:%M} · %{customdata:.1f} E<extra></extra>",
)
)
add_bolus_marker_traces(fig, bolus, colors, insulin_lane_y, row=1)
if not carbs.empty:
fig.add_trace(
@@ -106,14 +137,16 @@ def build_chart(glucose, insulin, carbs, site_changes, sensor_changes, colors) -
marker={"color": colors["carbs"], "size": 10, "symbol": "circle"},
customdata=carbs["carbs"],
hovertemplate="%{x|%d-%m %H:%M} · %{customdata:.0f} g<extra></extra>",
)
),
row=1,
col=1,
)
# Let op: annotation_text/-position via add_vline() zelf triggert een bug
# in plotly's shapeannotation-helper met pandas Timestamp-x-waarden, dus
# de lijn en het label worden hier los toegevoegd.
for _, row in site_changes.iterrows():
fig.add_vline(x=row["timestamp"], line_dash="dot", line_color=colors["muted"], opacity=0.6)
fig.add_vline(x=row["timestamp"], line_dash="dot", line_color=colors["muted"], opacity=0.6, row=1, col=1)
fig.add_annotation(
x=row["timestamp"],
y=y_top,
@@ -121,9 +154,11 @@ def build_chart(glucose, insulin, carbs, site_changes, sensor_changes, colors) -
text="Pomp",
showarrow=False,
font={"color": colors["muted"], "size": 10},
row=1,
col=1,
)
for _, row in sensor_changes.iterrows():
fig.add_vline(x=row["timestamp"], line_dash="dash", line_color=colors["muted"], opacity=0.6)
fig.add_vline(x=row["timestamp"], line_dash="dash", line_color=colors["muted"], opacity=0.6, row=1, col=1)
fig.add_annotation(
x=row["timestamp"],
y=y_top,
@@ -131,6 +166,8 @@ def build_chart(glucose, insulin, carbs, site_changes, sensor_changes, colors) -
text="Sensor",
showarrow=False,
font={"color": colors["muted"], "size": 10},
row=1,
col=1,
)
fig.update_layout(
@@ -140,14 +177,18 @@ def build_chart(glucose, insulin, carbs, site_changes, sensor_changes, colors) -
legend={"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "left", "x": 0},
margin={"l": 10, "r": 10, "t": 10, "b": 10},
height=480,
yaxis={
"title": "mmol/L",
"range": [y_bottom, y_top],
"gridcolor": colors["grid"],
"zeroline": False,
},
xaxis={"gridcolor": colors["grid"]},
)
fig.update_yaxes(
title_text="mmol/L",
range=[y_bottom, y_top],
gridcolor=colors["grid"],
zeroline=False,
row=1,
col=1,
secondary_y=False,
)
fig.update_yaxes(title_text="Basaal (E/uur)", showgrid=False, row=1, col=1, secondary_y=True)
fig.update_xaxes(gridcolor=colors["grid"], row=1, col=1)
return fig
@@ -160,6 +201,7 @@ with st.sidebar:
try:
glucose, treatments = load_nightscout_data(hours)
profile = load_profile()
except NightscoutConfigError as exc:
st.error(str(exc))
st.stop()
@@ -172,7 +214,9 @@ if glucose.empty:
st.stop()
events = classify_treatments(treatments)
bolus = classify_bolus(treatments)
tir = summarize_time_in_range(glucose)
basal, target = _basal_and_target_series(profile, treatments, glucose["timestamp"].min(), glucose["timestamp"].max())
latest = glucose.iloc[-1]
previous = glucose.iloc[-2] if len(glucose) > 1 else None
@@ -192,14 +236,17 @@ colors = get_colors()
fig = build_chart(
glucose,
events["insulin"],
bolus,
events["carbs"],
basal,
target,
events["site_changes"],
events["sensor_changes"],
colors,
)
st.plotly_chart(fig, width="stretch")
st.caption(
"Driehoekjes = insuline · cirkels = koolhydraten · stippellijn = pompwissel · "
"streepjeslijn = sensorwissel"
"Stippen = bolusinsuline (voeding/correctie/SMB, grootte naar dosis) · cirkels = koolhydraten · "
"vlak = basaal · streepstippellijn = streefwaarde uit pompprofiel · "
"stippellijn = pompwissel · streepjeslijn = sensorwissel"
)