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:
+78
-31
@@ -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"
|
||||
)
|
||||
|
||||
+97
-11
@@ -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(days=1)
|
||||
|
||||
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<extra></extra>",
|
||||
),
|
||||
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"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user