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
+154
View File
@@ -209,6 +209,160 @@ def build_tir_stacked_bar_figure(tir: dict, colors: dict) -> go.Figure:
return fig
BOLUS_CATEGORY_LABELS = {
"food": "Bolus (voeding)",
"correction": "Bolus (correctie)",
"smb": "SMB",
}
BOLUS_CATEGORY_COLOR_KEYS = {
"food": "bolus_food",
"correction": "bolus_correction",
"smb": "bolus_smb",
}
# Tekenvolgorde van bolus-categorieën: SMB eerst (onderop, meestal talrijker en
# kleiner), voeding/correctie erbovenop (grotere, gelabelde stippen blijven
# zichtbaar in plaats van bedolven te raken onder de SMB-stippen).
BOLUS_CATEGORY_DRAW_ORDER = ["smb", "correction", "food"]
def add_basal_area_trace(
fig: go.Figure,
basal: pd.DataFrame,
colors: dict,
*,
row: int | None = None,
secondary_y: bool = False,
x_col: str = "timestamp",
) -> None:
"""Basaal-insuline als semi-transparant stapvlak (line_shape='hv'), met alpha
zodat onderliggende data zichtbaar blijft. Verwacht kolommen 'rate' en
`x_col` (standaard 'timestamp'; de Dagoverzicht-pagina geeft hier 'hour'
aan omdat elke dag daar op een gedeelde 0-24u-as staat i.p.v. echte
datums — zie reporting.effective_basal_series). Bedoeld voor een
secundaire y-as (E/uur past niet op de mmol/L-schaal van de glucoselijn).
"""
if basal.empty:
return
kwargs: dict = {"row": row, "col": 1} if row is not None else {}
if secondary_y:
kwargs["secondary_y"] = True
fig.add_trace(
go.Scatter(
x=basal[x_col],
y=basal["rate"],
mode="lines",
line={"shape": "hv", "color": colors["basal"], "width": 1},
fill="tozeroy",
fillcolor=hex_to_rgba(colors["basal"], 0.3),
opacity=0.8,
name="Basaal",
hovertemplate="%{y:.2f} E/uur<extra></extra>",
),
**kwargs,
)
def add_carb_bars_trace(
fig: go.Figure,
carbs: pd.DataFrame,
colors: dict,
*,
row: int | None = None,
secondary_y: bool = False,
x_col: str = "timestamp",
) -> None:
"""Koolhydraten als staafjes op het moment van invoer. Verwacht kolommen
'carbs' en `x_col` (zie add_basal_area_trace voor waarom dit configureerbaar
is). Zelfde secundaire as als de basaal-area (allebei geen mmol/L-grootheid),
met een eigen kleur en alpha zodat beide onderscheidbaar blijven."""
if carbs.empty:
return
kwargs: dict = {"row": row, "col": 1} if row is not None else {}
if secondary_y:
kwargs["secondary_y"] = True
fig.add_trace(
go.Bar(
x=carbs[x_col],
y=carbs["carbs"],
marker_color=colors["carbs"],
opacity=0.55,
name="Koolhydraten",
hovertemplate="%{y:.0f} g<extra></extra>",
),
**kwargs,
)
def add_bolus_marker_traces(
fig: go.Figure,
bolus: pd.DataFrame,
colors: dict,
lane_y: float,
*,
row: int | None = None,
x_col: str = "timestamp",
) -> None:
"""Eén Scatter-trace per boluscategorie (food/correction/smb), gepositioneerd
op een vaste 'lane'-hoogte op de primaire (mmol/L-)as — net als de bestaande
insuline/carbs-lanes op de Live-pagina. De hoogte zelf draagt geen
kwantitatieve betekenis; de markergrootte (geschaald naar toegediende
eenheden) en hoverwaarde wel. Voeding/correctie krijgen een tekstlabel met
de dosis; SMB's niet (te talrijk/te klein voor leesbare labels). Verwacht
kolommen 'insulin'/'category' en `x_col` (zie reporting.classify_bolus).
"""
# Datumformattering in de hover werkt alleen op een echte datetime-as; de
# Dagoverzicht-pagina geeft hier 'hour' (een kaal getal 0-24) aan, dus
# daar laten we het tijdstip weg en toont de hover alleen de dosis.
hover_prefix = "%{x|%d-%m %H:%M} · " if x_col == "timestamp" else ""
kwargs: dict = {"row": row, "col": 1} if row is not None else {}
for category in BOLUS_CATEGORY_DRAW_ORDER:
subset = bolus[bolus["category"] == category] if not bolus.empty else bolus
if subset.empty:
continue
show_label = category != "smb"
fig.add_trace(
go.Scatter(
x=subset[x_col],
y=[lane_y] * len(subset),
mode="markers+text" if show_label else "markers",
marker={
"size": (subset["insulin"] * 8).clip(lower=6, upper=30),
"color": colors[BOLUS_CATEGORY_COLOR_KEYS[category]],
"opacity": 0.75,
},
text=[f"{v:.1f}E" for v in subset["insulin"]] if show_label else None,
textposition="top center",
textfont={"size": 9, "color": colors["text"]},
name=BOLUS_CATEGORY_LABELS[category],
customdata=subset["insulin"],
hovertemplate=f"{hover_prefix}%{{customdata:.2f}} E<extra></extra>",
),
**kwargs,
)
def add_target_line_trace(
fig: go.Figure, target: pd.DataFrame, colors: dict, *, row: int | None = None, x_col: str = "timestamp"
) -> None:
"""Streefglucose uit het pompprofiel als lijn op de primaire (mmol/L-)as.
Verwacht kolommen 'target' en `x_col` (zie reporting.target_glucose_series)."""
if target.empty:
return
kwargs: dict = {"row": row, "col": 1} if row is not None else {}
fig.add_trace(
go.Scatter(
x=target[x_col],
y=target["target"],
mode="lines",
line={"shape": "hv", "color": colors["target"], "width": 1.5, "dash": "dashdot"},
opacity=0.9,
name="Streefwaarde (pomp)",
hovertemplate="%{y:.1f} mmol/L<extra></extra>",
),
**kwargs,
)
def render_completeness_warning(glucose: pd.DataFrame, start: date, end: date) -> None:
"""Toon reporting.completeness_warning()'s oordeel als st.warning, indien nodig.