diff --git a/pages/1_Live.py b/pages/1_Live.py
index e653dff..6687d04 100644
--- a/pages/1_Live.py
+++ b/pages/1_Live.py
@@ -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(1, unit="D")
+ 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",
- )
- )
+ 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",
- )
+ ),
+ 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"
)
diff --git a/pages/4_Dagoverzicht.py b/pages/4_Dagoverzicht.py
index 110e278..0bc6e5a 100644
--- a/pages/4_Dagoverzicht.py
+++ b/pages/4_Dagoverzicht.py
@@ -1,14 +1,21 @@
+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 TARGET_HIGH, TARGET_LOW
+from data_processing import TARGET_HIGH, TARGET_LOW, classify_treatments
from nightscout_client import NightscoutConfigError
+from reporting import classify_bolus, effective_basal_series, target_glucose_series
from ui_common import (
+ add_basal_area_trace,
+ add_bolus_marker_traces,
+ add_carb_bars_trace,
+ add_target_line_trace,
add_threshold_lines,
get_colors,
+ load_profile,
load_range_data,
render_completeness_warning,
select_period_days,
@@ -18,7 +25,7 @@ st.set_page_config(page_title="Glucose dashboard — Dagoverzicht", page_icon="
require_auth()
st.title("Dagoverzicht")
-st.caption("Eén glucosegrafiek per kalenderdag (FR4).")
+st.caption("Eén glucosegrafiek per kalenderdag, met basaal/bolus/koolhydraten-overlay (FR4).")
start, end, days = select_period_days(default_label="14 dagen")
if days > 31:
@@ -28,7 +35,8 @@ if days > 31:
)
try:
- glucose, _treatments = load_range_data(start, end)
+ glucose, treatments = load_range_data(start, end)
+ profile = load_profile()
except NightscoutConfigError as exc:
st.error(str(exc))
st.stop()
@@ -47,44 +55,122 @@ glucose = glucose.copy()
glucose["date"] = glucose["timestamp"].dt.date
glucose["hour"] = glucose["timestamp"].dt.hour + glucose["timestamp"].dt.minute / 60
+carbs_events = classify_treatments(treatments)["carbs"].copy()
+if not carbs_events.empty:
+ carbs_events["date"] = carbs_events["timestamp"].dt.date
+ carbs_events["hour"] = carbs_events["timestamp"].dt.hour + carbs_events["timestamp"].dt.minute / 60
+
+bolus_events = classify_bolus(treatments)
+if not bolus_events.empty:
+ bolus_events["date"] = bolus_events["timestamp"].dt.date
+ bolus_events["hour"] = bolus_events["timestamp"].dt.hour + bolus_events["timestamp"].dt.minute / 60
+
+
+def _close_step_to_day_end(df: pd.DataFrame, value_col: str) -> pd.DataFrame:
+ """Voeg een laatste punt op hour=24 toe (zelfde waarde als het laatste
+ segment) zodat een stapvlak/-lijn de volle dag doortekent i.p.v. te
+ stoppen bij het laatste breekpunt."""
+ if df.empty:
+ return df
+ last_value = df.iloc[-1][value_col]
+ closing_row = pd.DataFrame([{"hour": 24.0, value_col: last_value}])
+ return pd.concat([df, closing_row], ignore_index=True)
+
+
dates = sorted(glucose["date"].unique())
-row_height = 150
+row_height = 190
fig = make_subplots(
rows=len(dates),
cols=1,
shared_xaxes=True,
- subplot_titles=[d.strftime("%A %d-%m-%Y") for d in dates],
vertical_spacing=min(0.4 / max(len(dates) - 1, 1), 0.05),
+ specs=[[{"secondary_y": True}]] * len(dates),
)
for i, day in enumerate(dates, start=1):
day_df = glucose[glucose["date"] == day]
+ day_start = pd.Timestamp(day)
+ day_end = day_start + pd.Timedelta(1, unit="D")
+
fig.add_hrect(
y0=TARGET_LOW, y1=TARGET_HIGH, fillcolor=colors["band"], line_width=0, layer="below", row=i, col=1
)
+ # Handmatige titel-annotatie t.o.v. het domein van déze rij i.p.v.
+ # make_subplots' subplot_titles (die t.o.v. de hele figuur positioneert en
+ # daardoor bij veel dagen de gedeelde legenda bovenaan kan overlappen).
+ fig.add_annotation(
+ text=day.strftime("%A %d-%m-%Y"),
+ xref="x domain",
+ yref="y domain",
+ x=0.0,
+ y=1.15,
+ xanchor="left",
+ yanchor="bottom",
+ showarrow=False,
+ font={"size": 13, "color": colors["text"]},
+ row=i,
+ col=1,
+ )
+
+ basal_day = effective_basal_series(profile, treatments, day_start, day_end)
+ if not basal_day.empty:
+ basal_day["hour"] = (basal_day["timestamp"] - day_start).dt.total_seconds() / 3600
+ basal_day = _close_step_to_day_end(basal_day, "rate")
+ add_basal_area_trace(fig, basal_day, colors, row=i, secondary_y=True, x_col="hour")
+
+ target_day = target_glucose_series(profile, day_start, day_end)
+ if not target_day.empty:
+ target_day["hour"] = (target_day["timestamp"] - day_start).dt.total_seconds() / 3600
+ target_day = _close_step_to_day_end(target_day, "target")
+ add_target_line_trace(fig, target_day, colors, row=i, x_col="hour")
+
+ add_carb_bars_trace(fig, carbs_events[carbs_events["date"] == day], colors, row=i, secondary_y=True, x_col="hour")
+
+ lane_y = day_df["mmol"].min() - 1.0 if not day_df.empty else TARGET_LOW - 1.0
+ add_bolus_marker_traces(fig, bolus_events[bolus_events["date"] == day], colors, lane_y, row=i, x_col="hour")
+
fig.add_trace(
go.Scatter(
x=day_df["hour"],
y=day_df["mmol"],
mode="lines",
line={"color": colors["glucose"], "width": 2},
- showlegend=False,
+ name="Glucose",
hovertemplate="%{y:.1f} mmol/L",
),
row=i,
col=1,
+ secondary_y=False,
+ )
+ fig.update_yaxes(title_text="mmol/L", gridcolor=colors["grid"], row=i, col=1, secondary_y=False)
+ fig.update_yaxes(
+ title_text="Basaal (E/uur) · KH (g)", showgrid=False, row=i, col=1, secondary_y=True
)
- fig.update_yaxes(title_text="mmol/L", gridcolor=colors["grid"], row=i, col=1)
add_threshold_lines(fig, colors, row=i)
+# Elke dag herhaalt dezelfde spoornamen (Glucose/Basaal/Koolhydraten/...) — zonder
+# deduplicatie zou de legenda 7x "Glucose" etc. tonen. Alleen de eerste keer per
+# naam blijft in de legenda staan.
+seen_names = set()
+for trace in fig.data:
+ if trace.name in seen_names:
+ trace.showlegend = False
+ else:
+ seen_names.add(trace.name)
+
fig.update_xaxes(title_text="Uur van de dag", range=[0, 24], dtick=4, gridcolor=colors["grid"])
fig.update_layout(
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font_color=colors["text"],
- height=row_height * len(dates),
- showlegend=False,
- margin={"l": 10, "r": 10, "t": 30, "b": 10},
+ height=row_height * len(dates) + 40,
+ showlegend=True,
+ legend={"orientation": "h", "yanchor": "bottom", "y": 1.0, "xanchor": "left", "x": 0},
+ margin={"l": 10, "r": 10, "t": 40, "b": 10},
)
st.plotly_chart(fig, width="stretch")
-st.caption(f"{len(dates)} dagen · {len(glucose)} metingen")
+st.caption(
+ f"{len(dates)} dagen · {len(glucose)} metingen · stippen = bolusinsuline "
+ "(voeding/correctie/SMB, grootte naar dosis) · vlak = basaal · staafjes = koolhydraten · "
+ "streepstippellijn = streefwaarde uit pompprofiel"
+)
diff --git a/reporting.py b/reporting.py
index d59cf58..1c881d7 100644
--- a/reporting.py
+++ b/reporting.py
@@ -15,6 +15,7 @@ import numpy as np
import pandas as pd
from nightscout_client import INSULIN_EVENT_TYPES
+from profile_data import value_at
# Klinische drempels in mmol/L (Battelino et al. 2019, Diabetes Care 42(8):1593-1603).
VERY_LOW = 3.0
@@ -440,6 +441,166 @@ def bolus_stats(treatments: pd.DataFrame) -> dict:
}
+BOLUS_CATEGORY_FOOD = "food"
+BOLUS_CATEGORY_CORRECTION = "correction"
+BOLUS_CATEGORY_SMB = "smb"
+
+BOLUS_CATEGORY_COLUMNS = ["timestamp", "eventType", "insulin", "carbs", "category"]
+
+
+def classify_bolus(treatments: pd.DataFrame) -> pd.DataFrame:
+ """Splits bolus-insuline op in 'food'/'correction'/'smb', voor grafiekoverlays
+ (gitea insulineprofiel-werk, vervolg op bolus_stats()'s SMB-detectie).
+
+ Herkenningsregels, in volgorde:
+ - SMB: `eventType == "SMB"`, of `eventType == "Correction Bolus"` met
+ `type == "SMB"` (zie bolus_stats()) — automatische AndroidAPS-microbolus.
+ - food: `eventType == "Meal Bolus"`, of een andere bolus-eventType
+ (`Bolus`/`Combo Bolus`) met koolhydraten op dezelfde treatment-rij (een
+ sterke aanwijzing dat het om een maaltijdbolus gaat).
+ - correction: alle overige bolus-rijen (typisch `Correction Bolus` zonder
+ SMB-markering, of een generieke `Bolus`/`Combo Bolus` zonder koolhydraten).
+
+ Geeft een DataFrame terug met kolommen 'timestamp', 'eventType', 'insulin',
+ 'carbs', 'category' — alleen de rijen die daadwerkelijk bolusinsuline zijn.
+ """
+ if treatments.empty:
+ return pd.DataFrame(columns=BOLUS_CATEGORY_COLUMNS)
+
+ bolus = treatments[treatments["eventType"].isin(INSULIN_EVENT_TYPES) & treatments["insulin"].notna()].copy()
+ if bolus.empty:
+ return pd.DataFrame(columns=BOLUS_CATEGORY_COLUMNS)
+
+ is_smb = (bolus["eventType"] == SMB_TYPE) | (
+ (bolus["eventType"] == "Correction Bolus") & (bolus.get("type", "") == SMB_TYPE)
+ )
+ has_carbs = pd.to_numeric(bolus["carbs"], errors="coerce").fillna(0) > 0
+ is_food = ~is_smb & ((bolus["eventType"] == "Meal Bolus") | has_carbs)
+
+ bolus["category"] = BOLUS_CATEGORY_CORRECTION
+ bolus.loc[is_food, "category"] = BOLUS_CATEGORY_FOOD
+ bolus.loc[is_smb, "category"] = BOLUS_CATEGORY_SMB
+ return bolus[BOLUS_CATEGORY_COLUMNS].sort_values("timestamp").reset_index(drop=True)
+
+
+def glucose_min_max(glucose: pd.DataFrame) -> tuple[float, float]:
+ """Minimum en maximum glucosewaarde (mmol/L) in de gegeven data."""
+ if glucose.empty:
+ return (0.0, 0.0)
+ return (round(glucose["mmol"].min(), 1), round(glucose["mmol"].max(), 1))
+
+
+def _temp_basal_intervals(treatments: pd.DataFrame, day_start, day_end) -> list[tuple]:
+ """Temp Basal-intervallen (start, eind, rate) die (deels) binnen [day_start, day_end) vallen,
+ geclipt aan de daggrenzen."""
+ if treatments is None or treatments.empty:
+ return []
+ temp_basal = treatments[
+ (treatments["eventType"] == BASAL_EVENT_TYPE)
+ & treatments["absolute"].notna()
+ & treatments["duration"].notna()
+ ]
+ intervals = []
+ for _, row in temp_basal.iterrows():
+ start = row["timestamp"]
+ end = start + pd.Timedelta(float(row["duration"]), unit="min")
+ if end <= day_start or start >= day_end:
+ continue
+ intervals.append((max(start, day_start), min(end, day_end), row["absolute"]))
+ return intervals
+
+
+def _basal_breakpoints(basal_segments: list[dict], temp_intervals: list[tuple], day_start, day_end) -> list:
+ """Alle tijdstippen waarop de effectieve basaalsnelheid kan wijzigen: daggrens,
+ profielsegment-grenzen, en temp-basal start/eind — oplopend gesorteerd."""
+ breakpoints = {day_start}
+ for seg in basal_segments:
+ ts = day_start + pd.Timedelta(float(seg["minutes"]), unit="min")
+ if day_start <= ts < day_end:
+ breakpoints.add(ts)
+ for start, end, _ in temp_intervals:
+ breakpoints.add(start)
+ breakpoints.add(end)
+ return sorted(t for t in breakpoints if day_start <= t < day_end)
+
+
+def _rate_at(ts, basal_segments: list[dict], temp_intervals: list[tuple], day_start) -> float:
+ """Effectieve basaalsnelheid op tijdstip `ts`: een actieve Temp Basal wint van het profieltempo."""
+ for start, end, rate in temp_intervals:
+ if start <= ts < end:
+ return rate
+ minutes_of_day = int((ts - day_start).total_seconds() / 60) % MINUTES_PER_DAY
+ value = value_at(basal_segments, minutes_of_day)
+ return value if value is not None else 0.0
+
+
+def effective_basal_series(profile: dict | None, treatments: pd.DataFrame, day_start, day_end) -> pd.DataFrame:
+ """Basaalsnelheid (E/uur) als stapfunctie over [day_start, day_end).
+
+ Basislijn is het pompprofiel se basaalschema (`profile["basal"]`); waar een
+ Temp Basal-treatment actief is (rate/duur uit de treatment) overschrijft
+ die het profieltempo voor de duur van die tijdelijke aanpassing — dit is de
+ daadwerkelijk afgeleverde basaal, in tegenstelling tot basal_stats() (die
+ alleen temp-basal-periodes optelt en periodes zonder actieve temp basal
+ mist omdat het profiel daar niet bij betrokken wordt).
+
+ Zonder profiel (`profile` is None/leeg) is de basislijn 0 — dan toont dit
+ alleen de temp-basal-periodes, net als basal_stats().
+
+ Geeft een DataFrame terug met kolommen 'timestamp' en 'rate': elke rij
+ geldt tot de volgende rij (stapfunctie), geschikt voor een
+ step-area-plot (line_shape='hv', fill='tozeroy').
+ """
+ day_start = pd.Timestamp(day_start)
+ day_end = pd.Timestamp(day_end)
+ basal_segments = (profile or {}).get("basal", [])
+ temp_intervals = _temp_basal_intervals(treatments, day_start, day_end)
+ breakpoints = _basal_breakpoints(basal_segments, temp_intervals, day_start, day_end)
+
+ rows = [
+ {"timestamp": ts, "rate": _rate_at(ts, basal_segments, temp_intervals, day_start)} for ts in breakpoints
+ ]
+ return pd.DataFrame(rows, columns=["timestamp", "rate"])
+
+
+def _target_midpoint_at(minutes_of_day: int, target_low: list[dict], target_high: list[dict]) -> float | None:
+ low = value_at(target_low, minutes_of_day) if target_low else None
+ high = value_at(target_high, minutes_of_day) if target_high else None
+ if low is not None and high is not None:
+ return (low + high) / 2
+ return low if low is not None else high
+
+
+def target_glucose_series(profile: dict | None, day_start, day_end) -> pd.DataFrame:
+ """Streefglucose (mmol/L) uit het profiel als stapfunctie over [day_start, day_end) —
+ het midden van target_low/target_high per tijdsegment (de pomp kent vaak één
+ streefwaarde per segment; met losse low/high-segmenten is dat gewoon het
+ gemiddelde). Geeft een lege DataFrame terug als het profiel geen streefwaarden
+ bevat.
+
+ Zelfde stapfunctie-vorm als effective_basal_series(): elke rij geldt tot de
+ volgende rij.
+ """
+ day_start = pd.Timestamp(day_start)
+ day_end = pd.Timestamp(day_end)
+ target_low = (profile or {}).get("target_low", [])
+ target_high = (profile or {}).get("target_high", [])
+ if not target_low and not target_high:
+ return pd.DataFrame(columns=["timestamp", "target"])
+
+ breakpoints = _basal_breakpoints(target_low + target_high, [], day_start, day_end)
+ rows = [
+ {
+ "timestamp": ts,
+ "target": _target_midpoint_at(
+ int((ts - day_start).total_seconds() / 60) % MINUTES_PER_DAY, target_low, target_high
+ ),
+ }
+ for ts in breakpoints
+ ]
+ return pd.DataFrame(rows, columns=["timestamp", "target"])
+
+
def basal_stats(treatments: pd.DataFrame, period_days: float) -> dict:
"""Totale basaalinsuline uit Temp Basal-treatments (rate x duur), zonder pompprofiel.
diff --git a/tests/test_reporting.py b/tests/test_reporting.py
index b981d83..5eca552 100644
--- a/tests/test_reporting.py
+++ b/tests/test_reporting.py
@@ -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(
[
diff --git a/tests/test_ui_common.py b/tests/test_ui_common.py
index 38ab716..c2829e6 100644
--- a/tests/test_ui_common.py
+++ b/tests/test_ui_common.py
@@ -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
diff --git a/ui_common.py b/ui_common.py
index bd81d6f..554e74a 100644
--- a/ui_common.py
+++ b/ui_common.py
@@ -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",
+ ),
+ **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",
+ ),
+ **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",
+ ),
+ **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",
+ ),
+ **kwargs,
+ )
+
+
def render_completeness_warning(glucose: pd.DataFrame, start: date, end: date) -> None:
"""Toon reporting.completeness_warning()'s oordeel als st.warning, indien nodig.