Updated charts for glucose data and profile chart
CI/CD / test (push) Failing after 6s
CI/CD / sonarqube (push) Skipped
CI/CD / build-and-scan (push) Skipped
CI/CD / deploy (push) Skipped

This commit is contained in:
2026-09-06 14:43:19 +02:00
parent bd66d3dacd
commit 95b3aff310
6 changed files with 262 additions and 120 deletions
+126 -100
View File
@@ -1,3 +1,5 @@
from datetime import timedelta
import pandas as pd
import plotly.graph_objects as go
import requests
@@ -7,16 +9,17 @@ from plotly.subplots import make_subplots
from auth import require_auth
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 reporting import BOLUS_CATEGORY_SMB, classify_bolus, daily_stats, effective_basal_series, target_glucose_series
from ui_common import (
MMOL_HOVERTEMPLATE,
add_basal_area_trace,
add_bolus_marker_traces,
add_carb_bars_trace,
add_target_line_trace,
add_threshold_lines,
get_colors,
load_day_data,
load_profile,
load_range_data,
render_completeness_warning,
select_period_days,
)
@@ -25,17 +28,48 @@ st.set_page_config(page_title="Glucose dashboard — Dagoverzicht", page_icon="
require_auth()
st.title("Dagoverzicht")
st.caption("Eén glucosegrafiek per kalenderdag, met basaal/bolus/koolhydraten-overlay (FR4).")
st.caption(
"Eén dag per keer: boven glucose met koolhydraten/bolus-overlay, onder basaal "
"en SMB — apart gehouden omdat één gecombineerde grafiek te veel informatie bevatte."
)
start, end, days = select_period_days(default_label="14 dagen")
if days > 31:
st.info(
"Bij langere periodes wordt de pagina lang en kan laden traag zijn — "
"voor het bekijken van individuele dagen is 14 of 30 dagen prettiger."
# De periode-knop bepaalt alleen het navigeerbare venster (hoe ver terug je kunt
# bladeren), niet welke data wordt opgehaald — dat gebeurt hieronder voor precies
# de geselecteerde dag (load_day_data), zodat het wisselen tussen dagen snel blijft.
window_start, window_end, days = select_period_days(default_label="14 dagen")
state_key = "dagoverzicht_selected_day"
if state_key not in st.session_state:
st.session_state[state_key] = window_end
if not (window_start <= st.session_state[state_key] <= window_end):
st.session_state[state_key] = window_end
selected_day = st.session_state[state_key]
col_prev, col_date, col_next = st.columns([1, 3, 1])
with col_prev:
if st.button("← Vorige dag", disabled=selected_day <= window_start, width="stretch"):
selected_day = selected_day - timedelta(days=1)
st.session_state[state_key] = selected_day
with col_next:
if st.button("Volgende dag →", disabled=selected_day >= window_end, width="stretch"):
selected_day = selected_day + timedelta(days=1)
st.session_state[state_key] = selected_day
with col_date:
date_options = [window_end - timedelta(days=i) for i in range(days)]
chosen = st.selectbox(
"Datum",
options=date_options,
index=date_options.index(selected_day),
format_func=lambda d: d.strftime("%A %d-%m-%Y"),
label_visibility="collapsed",
)
if chosen != selected_day:
selected_day = chosen
st.session_state[state_key] = selected_day
try:
glucose, treatments = load_range_data(start, end)
glucose, treatments = load_day_data(selected_day)
profile = load_profile()
except NightscoutConfigError as exc:
st.error(str(exc))
@@ -45,132 +79,124 @@ except (requests.RequestException, ValueError) as exc:
st.stop()
if glucose.empty:
st.info("Geen glucosemetingen gevonden in deze periode.")
st.info("Geen glucosemetingen gevonden op deze dag.")
st.stop()
render_completeness_warning(glucose, start, end)
render_completeness_warning(glucose, selected_day, selected_day)
colors = get_colors()
glucose = glucose.copy()
glucose["date"] = glucose["timestamp"].dt.date
glucose["hour"] = glucose["timestamp"].dt.hour + glucose["timestamp"].dt.minute / 60
day_start = pd.Timestamp(selected_day)
day_end = day_start + pd.Timedelta(1, unit="D")
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
# treatments bevat een lookback-marge vóór middernacht (zie
# nightscout_client.fetch_treatments_for_day) zodat een temp basal die al
# vóór deze dag begon toch meetelt — effective_basal_series() clipt dat
# zelf aan [day_start, day_end). Voor de koolhydraten/bolus-overlays willen
# we die marge juist NIET, anders duiken gisteravond se stippen op vandaag op.
day_treatments = treatments[(treatments["timestamp"] >= day_start) & (treatments["timestamp"] < day_end)]
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
carbs_day = classify_treatments(day_treatments)["carbs"]
bolus_day = classify_bolus(day_treatments)
basal_day = effective_basal_series(profile, treatments, day_start, day_end)
target_day = target_glucose_series(profile, day_start, day_end)
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
"""Voeg een laatste punt op day_end 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}])
closing_row = pd.DataFrame([{"timestamp": day_end, value_col: last_value}])
return pd.concat([df, closing_row], ignore_index=True)
dates = sorted(glucose["date"].unique())
row_height = 190
fig = make_subplots(
rows=len(dates),
cols=1,
shared_xaxes=True,
vertical_spacing=min(0.4 / max(len(dates) - 1, 1), 0.05),
specs=[[{"secondary_y": True}]] * len(dates),
)
basal_day = _close_step_to_day_end(basal_day, "rate")
target_day = _close_step_to_day_end(target_day, "target")
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")
def build_day_figure(glucose_day, carbs_day, bolus_day, basal_day, target_day, colors) -> go.Figure:
fig = make_subplots(
rows=2,
cols=1,
shared_xaxes=True,
row_heights=[0.68, 0.32],
vertical_spacing=0.08,
specs=[[{"secondary_y": True}], [{"secondary_y": False}]],
)
# Rij 1: glucose + streefwaarde + koolhydraten + voeding-/correctiebolus.
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,
y0=TARGET_LOW, y1=TARGET_HIGH, fillcolor=colors["band"], line_width=0, layer="below", row=1, col=1
)
add_target_line_trace(fig, target_day, colors, row=1)
add_carb_bars_trace(fig, carbs_day, colors, row=1, secondary_y=True)
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")
top_bolus = bolus_day[bolus_day["category"] != BOLUS_CATEGORY_SMB] if not bolus_day.empty else bolus_day
lane_y = glucose_day["mmol"].min() - 1.0 if not glucose_day.empty else TARGET_LOW - 1.0
add_bolus_marker_traces(fig, top_bolus, colors, lane_y, row=1)
fig.add_trace(
go.Scatter(
x=day_df["hour"],
y=day_df["mmol"],
x=glucose_day["timestamp"],
y=glucose_day["mmol"],
mode="lines",
line={"color": colors["glucose"], "width": 2},
name="Glucose",
hovertemplate="%{y:.1f} mmol/L<extra></extra>",
hovertemplate=MMOL_HOVERTEMPLATE,
),
row=i,
row=1,
col=1,
secondary_y=False,
)
fig.update_yaxes(title_text="mmol/L", gridcolor=colors["grid"], row=i, col=1, secondary_y=False)
add_threshold_lines(fig, colors, row=1)
fig.update_yaxes(title_text="mmol/L", gridcolor=colors["grid"], row=1, col=1, secondary_y=False)
fig.update_yaxes(title_text="Koolhydraten (g)", showgrid=False, row=1, col=1, secondary_y=True)
# Rij 2: basaal + SMB — apart van rij 1 zodat de glucosegrafiek niet
# overladen raakt met insulinedetails.
add_basal_area_trace(fig, basal_day, colors, row=2)
smb_bolus = bolus_day[bolus_day["category"] == BOLUS_CATEGORY_SMB] if not bolus_day.empty else bolus_day
smb_lane_y = (basal_day["rate"].max() * 1.15) if not basal_day.empty else 1.0
add_bolus_marker_traces(fig, smb_bolus, colors, smb_lane_y, row=2)
fig.update_yaxes(
title_text="Basaal (E/uur) · KH (g)", showgrid=False, row=i, col=1, secondary_y=True
title_text="Basaal (E/uur)", gridcolor=colors["grid"], rangemode="tozero", row=2, 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(range=[day_start, day_end], dtick=4 * 3600 * 1000, 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=560,
showlegend=True,
legend={"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "left", "x": 0},
margin={"l": 10, "r": 10, "t": 40, "b": 10},
)
return fig
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) + 40,
showlegend=True,
legend={"orientation": "h", "yanchor": "bottom", "y": 1.0, "xanchor": "left", "x": 0},
margin={"l": 10, "r": 10, "t": 40, "b": 10},
)
fig = build_day_figure(glucose, carbs_day, bolus_day, basal_day, target_day, colors)
st.plotly_chart(fig, width="stretch")
stats = daily_stats(glucose, day_treatments, profile, day_start, day_end)
col1, col2, col3, col4 = st.columns(4)
col1.metric(
"Glucose (gem./min/max)",
f"{stats['mean_mmol']:.1f} mmol/L",
delta=f"{stats['min_mmol']:.1f} - {stats['max_mmol']:.1f}",
delta_color="off",
)
col2.metric("Totale insuline", f"{stats['total_insulin']:.1f} E")
col3.metric("Basaal / bolus / SMB", f"{stats['basal_insulin']:.1f} / {stats['bolus_insulin']:.1f} / {stats['smb_insulin']:.1f} E")
col4.metric(
"Hypo's / hyper's",
f"{stats['hypo_count']} / {stats['hyper_count']}",
delta=f"{stats['hypo_duration_minutes']:.0f} / {stats['hyper_duration_minutes']:.0f} min",
delta_color="off",
)
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"
"Boven: stippen = bolusinsuline (voeding/correctie, grootte naar dosis) · staafjes = koolhydraten · "
"streepstippellijn = streefwaarde uit pompprofiel. Onder: vlak = basaal · stippen = SMB."
)