pd.Timedelta(days=1) triggers a DeprecationWarning about NumPy's "generic" timedelta unit in this pandas version; pd.Timedelta(1, unit="D") is equivalent and warning-free.
253 lines
8.5 KiB
Python
253 lines
8.5 KiB
Python
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,
|
|
classify_treatments,
|
|
summarize_time_in_range,
|
|
)
|
|
from nightscout_client import NightscoutConfigError, fetch_entries, fetch_treatments
|
|
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()
|
|
|
|
st.title("Glucose dashboard")
|
|
|
|
HOURS_OPTIONS = {"6 uur": 6, "12 uur": 12, "24 uur": 24, "48 uur": 48}
|
|
|
|
|
|
@st.cache_data(ttl=60, show_spinner="Data ophalen van Nightscout...")
|
|
def load_nightscout_data(hours: int):
|
|
glucose = fetch_entries(hours=hours)
|
|
treatments = fetch_treatments(hours=hours)
|
|
return glucose, treatments
|
|
|
|
|
|
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
|
|
lane_base = glucose_min - 2.5
|
|
y_bottom = lane_base - 0.5
|
|
insulin_lane_y = lane_base + 1.5
|
|
carbs_lane_y = lane_base + 0.7
|
|
|
|
fig = make_subplots(specs=[[{"secondary_y": True}]])
|
|
|
|
# Streefbereik als achtergrondband, geen losse legenda-entry nodig.
|
|
fig.add_hrect(
|
|
y0=TARGET_LOW,
|
|
y1=TARGET_HIGH,
|
|
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"],
|
|
y=glucose["mmol"],
|
|
mode="lines",
|
|
name="Glucose",
|
|
line={"color": colors["glucose"], "width": 2, "shape": "spline"},
|
|
hovertemplate=MMOL_HOVERTEMPLATE,
|
|
),
|
|
row=1,
|
|
col=1,
|
|
)
|
|
|
|
low_points = glucose[glucose["mmol"] < TARGET_LOW]
|
|
high_points = glucose[glucose["mmol"] > TARGET_HIGH]
|
|
if not low_points.empty:
|
|
fig.add_trace(
|
|
go.Scatter(
|
|
x=low_points["timestamp"],
|
|
y=low_points["mmol"],
|
|
mode="markers",
|
|
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(
|
|
go.Scatter(
|
|
x=high_points["timestamp"],
|
|
y=high_points["mmol"],
|
|
mode="markers",
|
|
name=f"Hoog (>{TARGET_HIGH})",
|
|
marker={"color": colors["high"], "size": 6},
|
|
hovertemplate=MMOL_HOVERTEMPLATE,
|
|
),
|
|
row=1,
|
|
col=1,
|
|
)
|
|
|
|
add_bolus_marker_traces(fig, bolus, colors, insulin_lane_y, row=1)
|
|
|
|
if not carbs.empty:
|
|
fig.add_trace(
|
|
go.Scatter(
|
|
x=carbs["timestamp"],
|
|
y=[carbs_lane_y] * len(carbs),
|
|
mode="markers",
|
|
name="Koolhydraten",
|
|
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, row=1, col=1)
|
|
fig.add_annotation(
|
|
x=row["timestamp"],
|
|
y=y_top,
|
|
yanchor="bottom",
|
|
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, row=1, col=1)
|
|
fig.add_annotation(
|
|
x=row["timestamp"],
|
|
y=y_top,
|
|
yanchor="bottom",
|
|
text="Sensor",
|
|
showarrow=False,
|
|
font={"color": colors["muted"], "size": 10},
|
|
row=1,
|
|
col=1,
|
|
)
|
|
|
|
fig.update_layout(
|
|
paper_bgcolor="rgba(0,0,0,0)",
|
|
plot_bgcolor="rgba(0,0,0,0)",
|
|
font_color=colors["text"],
|
|
legend={"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "left", "x": 0},
|
|
margin={"l": 10, "r": 10, "t": 10, "b": 10},
|
|
height=480,
|
|
)
|
|
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
|
|
|
|
|
|
with st.sidebar:
|
|
st.header("Instellingen")
|
|
hours_label = st.radio("Periode", list(HOURS_OPTIONS.keys()), index=2)
|
|
hours = HOURS_OPTIONS[hours_label]
|
|
if st.button("Vernieuwen"):
|
|
st.cache_data.clear()
|
|
|
|
try:
|
|
glucose, treatments = load_nightscout_data(hours)
|
|
profile = load_profile()
|
|
except NightscoutConfigError as exc:
|
|
st.error(str(exc))
|
|
st.stop()
|
|
except (requests.RequestException, ValueError) as exc: # netwerk-/API-fouten van Nightscout
|
|
st.error(f"Kon geen data ophalen van Nightscout: {exc}")
|
|
st.stop()
|
|
|
|
if glucose.empty:
|
|
st.info("Geen glucosemetingen gevonden in deze periode.")
|
|
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
|
|
delta = round(latest["mmol"] - previous["mmol"], 1) if previous is not None else None
|
|
|
|
col1, col2, col3, col4 = st.columns(4)
|
|
col1.metric("Actuele glucose", f"{latest['mmol']:.1f} mmol/L", delta=delta, delta_color="off")
|
|
col2.metric("Tijd in bereik", f"{tir['in_range']:.0f}%")
|
|
col3.metric("Tijd laag", f"{tir['low']:.0f}%")
|
|
col4.metric("Tijd hoog", f"{tir['high']:.0f}%")
|
|
st.caption(
|
|
f"Laatste meting: {latest['timestamp'].strftime('%d-%m-%Y %H:%M')} · "
|
|
f"streefbereik {TARGET_LOW}-{TARGET_HIGH} mmol/L"
|
|
)
|
|
|
|
colors = get_colors()
|
|
|
|
fig = build_chart(
|
|
glucose,
|
|
bolus,
|
|
events["carbs"],
|
|
basal,
|
|
target,
|
|
events["site_changes"],
|
|
events["sensor_changes"],
|
|
colors,
|
|
)
|
|
st.plotly_chart(fig, width="stretch")
|
|
st.caption(
|
|
"Stippen = bolusinsuline (voeding/correctie/SMB, grootte naar dosis) · cirkels = koolhydraten · "
|
|
"vlak = basaal · streepstippellijn = streefwaarde uit pompprofiel · "
|
|
"stippellijn = pompwissel · streepjeslijn = sensorwissel"
|
|
)
|