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", ), 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" )