From c54dcf616ab35b7fe84f8795636755d0051db775 Mon Sep 17 00:00:00 2001 From: willemjan Date: Sat, 5 Sep 2026 22:30:10 +0200 Subject: [PATCH 1/3] Add basal/bolus/carb/target overlays to the Live and Dagoverzicht charts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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()). --- pages/1_Live.py | 109 +++++++++++++++++++-------- pages/4_Dagoverzicht.py | 108 ++++++++++++++++++++++++--- reporting.py | 161 ++++++++++++++++++++++++++++++++++++++++ tests/test_reporting.py | 138 ++++++++++++++++++++++++++++++++++ tests/test_ui_common.py | 101 +++++++++++++++++++++++++ ui_common.py | 154 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 729 insertions(+), 42 deletions(-) diff --git a/pages/1_Live.py b/pages/1_Live.py index e653dff..9052a84 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(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", - ) - ) + 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..6584a08 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(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", ), 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. -- 2.54.0 From d33516e2a10ee292df228fc4b8683fa5f2850e59 Mon Sep 17 00:00:00 2001 From: willemjan Date: Sat, 5 Sep 2026 22:38:07 +0200 Subject: [PATCH 2/3] Avoid pandas Timedelta(days=1) deprecation warning 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. --- pages/1_Live.py | 2 +- pages/4_Dagoverzicht.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pages/1_Live.py b/pages/1_Live.py index 9052a84..6687d04 100644 --- a/pages/1_Live.py +++ b/pages/1_Live.py @@ -45,7 +45,7 @@ def _basal_and_target_series(profile: dict, treatments: pd.DataFrame, window_sta 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) + 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)) diff --git a/pages/4_Dagoverzicht.py b/pages/4_Dagoverzicht.py index 6584a08..0bc6e5a 100644 --- a/pages/4_Dagoverzicht.py +++ b/pages/4_Dagoverzicht.py @@ -90,7 +90,7 @@ fig = make_subplots( 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) + 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 -- 2.54.0 From f2c3f97b1fd2b0f0238155cfbcd2065a2d2e1619 Mon Sep 17 00:00:00 2001 From: willemjan Date: Sat, 5 Sep 2026 22:44:14 +0200 Subject: [PATCH 3/3] Redesign the PDF report: landscape pages, headers/footers, icon, daily stats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every report section now gets its own landscape A4 page (or, for Dagoverzicht, one page per day) via a GlucoseReportPDF(FPDF) subclass with a real header (section title, report version, insulin/pump/sensor type from new REPORT_* env vars) and footer (page number, generation timestamp, Nightscout link) on every page, plus an original SVG icon in the header — inspired by the LibreView AGP report. New "Insulineprofiel" PDF section mirrors the dashboard page (basal bar chart, ISF/carb-ratio segments, DIA). Each Dagoverzicht day now draws the same basal/bolus/carb/target overlays as the interactive charts (matplotlib port of the Plotly helpers from #30) plus a new per-day stats block (reporting.daily_stats: avg/min/max glucose, total/basal/bolus/SMB insulin, hypo/hyper counts and durations). Builds on #29 (profile data) and #30 (overlay logic/colors). --- .env.example | 6 + README.md | 3 +- assets/icon.svg | 4 + pages/8_Export.py | 12 +- pdf_export.py | 459 +++++++++++++++++++++++++++++++-------- reporting.py | 67 ++++++ tests/test_pdf_export.py | 197 +++++++++++++++++ tests/test_reporting.py | 70 ++++++ 8 files changed, 718 insertions(+), 100 deletions(-) create mode 100644 assets/icon.svg diff --git a/.env.example b/.env.example index 564bcfd..1129896 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,9 @@ NIGHTSCOUT_TOKEN= # het diabetesteam). Zet hier een sha256-hash, NIET het wachtwoord zelf: # python -c "import hashlib; print(hashlib.sha256(b'jouw-wachtwoord').hexdigest())" DASHBOARD_PASSWORD_HASH= + +# Optioneel: apparatuurinfo voor de kop van het PDF-rapport (pages/8_Export.py). +# Puur informatief, geen effect op de berekeningen — leeg = "Onbekend ...". +REPORT_INSULIN_TYPE=Lyumjev 100U +REPORT_PUMP_TYPE= +REPORT_SENSOR_TYPE= diff --git a/README.md b/README.md index 5d4b30f..63b54ee 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,8 @@ klinische rapportagepagina's (zie `docs/requirements-reporting.md` en diabetesteam, dus elke pagina zit achter een wachtwoord). `app.py` laadt `.env` zelf in (via `python-dotenv`), dus dit werkt hetzelfde onder PowerShell, cmd of bash — je hoeft de variabelen niet zelf in je shell te - zetten. + zetten. `REPORT_INSULIN_TYPE`/`REPORT_PUMP_TYPE`/`REPORT_SENSOR_TYPE` zijn + optioneel en puur informatief (getoond in de kop van het PDF-rapport). 3. Lokaal draaien: ```powershell diff --git a/assets/icon.svg b/assets/icon.svg new file mode 100644 index 0000000..d6658c2 --- /dev/null +++ b/assets/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/pages/8_Export.py b/pages/8_Export.py index 07b2a14..44c494a 100644 --- a/pages/8_Export.py +++ b/pages/8_Export.py @@ -5,7 +5,12 @@ from auth import require_auth from nightscout_client import NightscoutConfigError from pdf_export import AVAILABLE_SECTIONS, SECTION_EVENTS, build_report_pdf from reporting import HIGH, LOW, VERY_HIGH, VERY_LOW, detect_events, summarize_events -from ui_common import load_range_data, render_completeness_warning, select_period_days +from ui_common import ( + load_profile, + load_range_data, + render_completeness_warning, + select_period_days, +) st.set_page_config(page_title="Glucose dashboard — Export", page_icon="📊", layout="wide") require_auth() @@ -17,7 +22,8 @@ start, end, days = select_period_days(default_label="90 dagen") sections = st.multiselect("Onderdelen", AVAILABLE_SECTIONS, default=AVAILABLE_SECTIONS) 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() @@ -43,7 +49,7 @@ if SECTION_EVENTS in sections: events = detect_events(glucose, threshold, direction) events_summary[label] = summarize_events(events, days) -pdf_bytes = build_report_pdf(sections, glucose, events_summary, start, end) +pdf_bytes = build_report_pdf(sections, glucose, events_summary, start, end, treatments=treatments, profile=profile) st.download_button( "Download PDF", diff --git a/pdf_export.py b/pdf_export.py index 06a78f4..8b7f9cd 100644 --- a/pdf_export.py +++ b/pdf_export.py @@ -2,15 +2,23 @@ PDF-rapportgeneratie voor geselecteerde rapportageonderdelen (FR10). Gebruikt fpdf2 (puur Python, geen systeemdependencies) voor de PDF-opbouw en -matplotlib voor een eenvoudige statische AGP-grafiek specifiek voor de PDF — -bewust niet dezelfde Plotly-figuren als op de pagina's zelf (die zouden -kaleido vereisen, met een ingebakken Chromium-build: een groter image en meer -Trivy-oppervlak, voor dit doel niet nodig). +matplotlib voor de statische grafieken specifiek voor de PDF — bewust niet +dezelfde Plotly-figuren als op de pagina's zelf (die zouden kaleido vereisen, +met een ingebakken Chromium-build: een groter image en meer Trivy-oppervlak, +voor dit doel niet nodig). + +Elke sectie is één (of, voor Dagoverzicht, meerdere) liggende A4-pagina('s) +met een vaste kop-/voettekst (device-info, paginanummering, genereerdatum, +Nightscout-link) via de GlucoseReportPDF-subklasse hieronder — geïnspireerd op +de LibreView/AGP-rapportopmaak (zie docs/design-reporting.md). """ from __future__ import annotations import io +import os +from datetime import datetime, timezone +from pathlib import Path import matplotlib @@ -20,34 +28,54 @@ import pandas as pd from fpdf import FPDF from fpdf.enums import XPos, YPos +from profile_data import segments_table, segments_to_hourly_bars, total_daily_basal from reporting import ( + BOLUS_CATEGORY_CORRECTION, + BOLUS_CATEGORY_FOOD, + BOLUS_CATEGORY_SMB, HIGH, LOW, VERY_HIGH, VERY_LOW, agp_percentile_bands, + classify_bolus, completeness_warning, + daily_stats, detect_events, + effective_basal_series, + glucose_min_max, gmi_mmol_per_mol, gmi_percent, percent_cv, sensor_active_percent, + target_glucose_series, tir_breakdown_5band, ) SECTION_SUMMARY = "Samenvatting" SECTION_AGP = "AGP profiel" +SECTION_INSULIN_PROFILE = "Insulineprofiel" SECTION_DAGOVERZICHT = "Dagoverzicht" SECTION_EVENTS = "Hypo-/hyperevents" -AVAILABLE_SECTIONS = [SECTION_SUMMARY, SECTION_AGP, SECTION_DAGOVERZICHT, SECTION_EVENTS] +AVAILABLE_SECTIONS = [ + SECTION_SUMMARY, + SECTION_AGP, + SECTION_INSULIN_PROFILE, + SECTION_DAGOVERZICHT, + SECTION_EVENTS, +] + +REPORT_VERSION = "1.0" +_ICON_PATH = Path(__file__).parent / "assets" / "icon.svg" _LINE_KWARGS = {"new_x": XPos.LMARGIN, "new_y": YPos.NEXT} +_SAME_LINE_KWARGS = {"new_x": XPos.RIGHT, "new_y": YPos.TOP} # Zelfde kleuren als het PALETTE["light"] in ui_common.py — bewust hier -# gedupliceerd (5 hex-waarden) in plaats van ui_common.py te importeren, want -# dat zou een streamlit-dependency in deze module trekken die verder bewust -# vrij van UI-framework-imports is (los te unit-testen, zie moduledocstring). +# gedupliceerd in plaats van ui_common.py te importeren, want dat zou een +# streamlit-dependency in deze module trekken die verder bewust vrij van +# UI-framework-imports is (los te unit-testen, zie moduledocstring). _MUTED = "#898781" _TIR_BAND_ORDER = ["very_low", "low", "in_range", "high", "very_high"] _TIR_BAND_LABELS = { @@ -65,33 +93,83 @@ _TIR_COLORS = { "very_high": "#c76f0a", } _THRESHOLD_LINES = [VERY_LOW, LOW, HIGH, VERY_HIGH] +_BASAL_COLOR = "#7b5ea3" +_CARBS_COLOR = "#eb6834" +_TARGET_COLOR = "#52514e" +_BOLUS_CATEGORY_STYLE = { + BOLUS_CATEGORY_FOOD: ("#1baf7a", "Bolus (voeding)"), + BOLUS_CATEGORY_CORRECTION: ("#2a78d6", "Bolus (correctie)"), + BOLUS_CATEGORY_SMB: ("#898781", "SMB"), +} +_EMPTY_TREATMENT_COLUMNS = ["timestamp", "eventType", "insulin", "carbs", "type", "absolute", "duration"] DAGOVERZICHT_MAX_DAYS = 14 +_HOUR_OF_DAY_LABEL = "Uur van de dag" -def _add_threshold_lines(ax) -> None: - """Teken de 4 klinische drempellijnen (3.0/3.9/10.0/13.9 mmol/L) op een matplotlib-as. - - PDF-tegenhanger van ui_common.add_threshold_lines() voor de Plotly-versie - op de dashboardpagina's (gitea #15) — zelfde 4 waarden, zelfde grijstint. - """ - for value in _THRESHOLD_LINES: - ax.axhline(value, color=_MUTED, linewidth=0.7, linestyle=":") +def _report_config() -> dict: + """Rapportmetadata uit environment variables (insuline/pomp/sensor-type, + Nightscout-URL) — zelfde os.environ-patroon als nightscout_client.py. + Alles heeft een fallback zodat een rapport ook zonder configuratie werkt.""" + return { + "insulin_type": os.environ.get("REPORT_INSULIN_TYPE", "").strip() or "Onbekend insulinetype", + "pump_type": os.environ.get("REPORT_PUMP_TYPE", "").strip() or "Onbekende pomp", + "sensor_type": os.environ.get("REPORT_SENSOR_TYPE", "").strip() or "Onbekende sensor", + "nightscout_url": os.environ.get("NIGHTSCOUT_URL", "").strip().rstrip("/"), + } -def _agp_figure_png(glucose: pd.DataFrame) -> bytes: - bands = agp_percentile_bands(glucose) - fig, ax = plt.subplots(figsize=(7, 3)) - hours = bands["minute_of_day"] / 60 - ax.fill_between(hours, bands["p5"], bands["p95"], alpha=0.15, color="#2a78d6", label="5e-95e pct") - ax.fill_between(hours, bands["p25"], bands["p75"], alpha=0.3, color="#2a78d6", label="25e-75e pct") - ax.plot(hours, bands["p50"], color="#2a78d6", linewidth=2, label="Mediaan") - _add_threshold_lines(ax) - ax.set_xlabel("Uur van de dag") - ax.set_ylabel("mmol/L") - ax.set_xlim(0, 24) - ax.legend(loc="upper right", fontsize=8) - fig.tight_layout() +class GlucoseReportPDF(FPDF): + """FPDF-subklasse met een vaste kop-/voettekst op elke pagina en één sectie + per liggende pagina (zie begin_report_section()).""" + def __init__(self, config: dict, generated_at: datetime) -> None: + super().__init__(orientation="L", unit="mm", format="A4") + self._config = config + self._generated_at = generated_at + self.section_title = "" + self.set_auto_page_break(auto=True, margin=18) + self.alias_nb_pages() + + def begin_report_section(self, title: str) -> None: + """Begin een nieuwe pagina voor sectie `title`. Voor secties die over + meerdere pagina's lopen (Dagoverzicht: één pagina per dag) wordt dit + per pagina aangeroepen zodat de kop op elke pagina klopt.""" + self.section_title = title + self.add_page() + + def header(self) -> None: + if _ICON_PATH.exists(): + self.image(str(_ICON_PATH), x=10, y=8, w=8) + self.set_xy(20, 8) + self.set_font("Helvetica", "B", 14) + self.cell(0, 6, self.section_title, **_LINE_KWARGS) + self.set_x(20) + self.set_font("Helvetica", "", 8) + self.set_text_color(120, 120, 120) + device_line = ( + f"Glucoserapport v{REPORT_VERSION} · Insuline: {self._config['insulin_type']} · " + f"Pomp: {self._config['pump_type']} · Sensor: {self._config['sensor_type']}" + ) + self.cell(0, 4, device_line, **_LINE_KWARGS) + self.set_text_color(0, 0, 0) + self.ln(3) + self.set_draw_color(200, 200, 200) + self.line(10, self.get_y(), self.w - 10, self.get_y()) + self.ln(4) + + def footer(self) -> None: + self.set_y(-14) + self.set_font("Helvetica", "I", 8) + self.set_text_color(120, 120, 120) + left = f"Gegenereerd: {self._generated_at.strftime('%d-%m-%Y %H:%M')}" + if self._config["nightscout_url"]: + left += f" · {self._config['nightscout_url']}" + self.cell(200, 8, left, **_SAME_LINE_KWARGS) + self.cell(0, 8, f"Pagina {self.page_no()}/{{nb}}", align="R") + self.set_text_color(0, 0, 0) + + +def _fig_to_png(fig) -> bytes: buf = io.BytesIO() fig.savefig(buf, format="png", dpi=150) plt.close(fig) @@ -99,10 +177,33 @@ def _agp_figure_png(glucose: pd.DataFrame) -> bytes: return buf.getvalue() +def _add_threshold_lines(ax) -> None: + """Teken de 4 klinische drempellijnen (3.0/3.9/10.0/13.9 mmol/L) op een matplotlib-as.""" + for value in _THRESHOLD_LINES: + ax.axhline(value, color=_MUTED, linewidth=0.7, linestyle=":") + + +def _agp_figure_png(glucose: pd.DataFrame) -> bytes: + bands = agp_percentile_bands(glucose) + fig, ax = plt.subplots(figsize=(11, 4.2)) + hours = bands["minute_of_day"] / 60 + ax.axhspan(LOW, HIGH, color="#0ca30c", alpha=0.08, label="Doelbereik") + ax.fill_between(hours, bands["p5"], bands["p95"], alpha=0.15, color="#2a78d6", label="5e-95e pct") + ax.fill_between(hours, bands["p25"], bands["p75"], alpha=0.3, color="#2a78d6", label="25e-75e pct") + ax.plot(hours, bands["p50"], color="#2a78d6", linewidth=2.5, label="Mediaan") + _add_threshold_lines(ax) + ax.set_xlabel(_HOUR_OF_DAY_LABEL) + ax.set_ylabel("mmol/L") + ax.set_xlim(0, 24) + ax.set_xticks(range(0, 25, 3)) + ax.legend(loc="upper right", fontsize=9, ncol=4, frameon=False) + fig.tight_layout() + return _fig_to_png(fig) + + def _tir_bar_figure_png(tir: dict) -> bytes: - """Gestapelde staafgrafiek van de 5 TIR-banden — PDF-tegenhanger van - ui_common.build_tir_stacked_bar_figure() (gitea #14).""" - fig, ax = plt.subplots(figsize=(7, 1.1)) + """Gestapelde staafgrafiek van de 5 TIR-banden.""" + fig, ax = plt.subplots(figsize=(9, 1.1)) left = 0.0 for band in _TIR_BAND_ORDER: value = tir[band] @@ -123,52 +224,127 @@ def _tir_bar_figure_png(tir: dict) -> bytes: return buf.getvalue() -def _daily_figure_png(day_df: pd.DataFrame, day_label: str) -> bytes: - """Eén dag als kleine lijngrafiek — PDF-tegenhanger van de Dagoverzicht-pagina (gitea #16).""" +def _insulin_profile_figure_png(profile: dict) -> bytes: + bars = segments_to_hourly_bars(profile.get("basal", [])) + fig, ax = plt.subplots(figsize=(11, 3.2)) + if bars: + hours = [b["hour"] for b in bars] + values = [b["value"] for b in bars] + ax.step(hours, values, where="post", color=_BASAL_COLOR, linewidth=1.5) + ax.fill_between(hours, values, step="post", color=_BASAL_COLOR, alpha=0.3) + ax.set_xlim(0, 24) + ax.set_xticks(range(0, 25, 2)) + ax.set_ylim(bottom=0) + ax.set_xlabel(_HOUR_OF_DAY_LABEL) + ax.set_ylabel("E/uur") + fig.tight_layout() + return _fig_to_png(fig) + + +def _add_basal_overlay(ax2, basal: pd.DataFrame, day_start) -> None: + if basal.empty: + return + hours = (basal["timestamp"] - day_start).dt.total_seconds() / 3600 + hours = pd.concat([hours, pd.Series([24.0])], ignore_index=True) + values = pd.concat([basal["rate"], basal["rate"].iloc[-1:]], ignore_index=True) + ax2.fill_between(hours, values, step="post", color=_BASAL_COLOR, alpha=0.3, label="Basaal") + ax2.step(hours, values, where="post", color=_BASAL_COLOR, linewidth=1, alpha=0.8) + + +def _add_carbs_overlay(ax2, day_treatments: pd.DataFrame, day_start) -> None: + if day_treatments.empty: + return + carbs = day_treatments[day_treatments["carbs"].fillna(0) > 0] + if carbs.empty: + return + hours = (carbs["timestamp"] - day_start).dt.total_seconds() / 3600 + ax2.bar(hours, carbs["carbs"], width=0.25, color=_CARBS_COLOR, alpha=0.55, label="Koolhydraten") + + +def _add_bolus_overlay(ax, day_treatments: pd.DataFrame, day_start, lane_y: float) -> None: + bolus = classify_bolus(day_treatments) + for category in (BOLUS_CATEGORY_SMB, BOLUS_CATEGORY_CORRECTION, BOLUS_CATEGORY_FOOD): + subset = bolus[bolus["category"] == category] if not bolus.empty else bolus + if subset.empty: + continue + color, label = _BOLUS_CATEGORY_STYLE[category] + hours = (subset["timestamp"] - day_start).dt.total_seconds() / 3600 + sizes = (subset["insulin"] * 25).clip(lower=15, upper=200) + ax.scatter(hours, [lane_y] * len(subset), s=sizes, color=color, alpha=0.75, label=label, zorder=5) + if category != BOLUS_CATEGORY_SMB: + for x, units in zip(hours, subset["insulin"], strict=True): + ax.annotate( + f"{units:.1f}E", (x, lane_y), textcoords="offset points", xytext=(0, 8), fontsize=6, ha="center" + ) + + +def _add_target_overlay(ax, profile: dict | None, day_start, day_end) -> None: + target = target_glucose_series(profile, day_start, day_end) + if target.empty: + return + hours = (target["timestamp"] - day_start).dt.total_seconds() / 3600 + hours = pd.concat([hours, pd.Series([24.0])], ignore_index=True) + values = pd.concat([target["target"], target["target"].iloc[-1:]], ignore_index=True) + ax.step(hours, values, where="post", color=_TARGET_COLOR, linewidth=1.2, linestyle="-.", label="Streefwaarde (pomp)") + + +def _daily_figure_png( + day_df: pd.DataFrame, day_treatments: pd.DataFrame, profile: dict | None, day_label: str, day_start, day_end +) -> bytes: + """Eén dag als grafiek: glucoselijn + basaal/bolus/koolhydraten/streefwaarde-overlay.""" hours = day_df["timestamp"].dt.hour + day_df["timestamp"].dt.minute / 60 - fig, ax = plt.subplots(figsize=(7, 1.4)) - ax.plot(hours, day_df["mmol"], color="#2a78d6", linewidth=1.2) + fig, ax = plt.subplots(figsize=(11, 3.8)) + ax2 = ax.twinx() + + basal = effective_basal_series(profile, day_treatments, day_start, day_end) + _add_basal_overlay(ax2, basal, day_start) + _add_carbs_overlay(ax2, day_treatments, day_start) + + lane_y = (day_df["mmol"].min() - 1.0) if not day_df.empty else LOW - 1.0 + _add_bolus_overlay(ax, day_treatments, day_start, lane_y) + _add_target_overlay(ax, profile, day_start, day_end) + + ax.plot(hours, day_df["mmol"], color="#2a78d6", linewidth=1.4, label="Glucose", zorder=6) _add_threshold_lines(ax) ax.set_xlim(0, 24) - ax.set_xticks(range(0, 25, 4)) - ax.set_ylabel("mmol/L", fontsize=8) - ax.set_title(day_label, fontsize=9, loc="left") - ax.tick_params(labelsize=7) - fig.tight_layout() + ax.set_xticks(range(0, 25, 2)) + ax.set_ylabel("mmol/L", fontsize=9) + ax2.set_ylabel("Basaal (E/uur) · KH (g)", fontsize=9) + ax.set_title(day_label, fontsize=11, loc="left") + ax.tick_params(labelsize=8) + ax2.tick_params(labelsize=8) - buf = io.BytesIO() - fig.savefig(buf, format="png", dpi=150) - plt.close(fig) - buf.seek(0) - return buf.getvalue() + lines1, labels1 = ax.get_legend_handles_labels() + lines2, labels2 = ax2.get_legend_handles_labels() + ax.legend( + lines1 + lines2, labels1 + labels2, loc="upper center", bbox_to_anchor=(0.5, -0.2), ncol=6, fontsize=7, frameon=False + ) + fig.tight_layout() + return _fig_to_png(fig) def _event_hour_histogram_png(events: pd.DataFrame, color: str) -> bytes: - """Aantal events per uur-van-de-dag — PDF-tegenhanger van render_event_level()'s - histogram op de Hypo/Hyper Events-pagina's (gitea #17, #18).""" + """Aantal events per uur-van-de-dag.""" hour_counts = events["start"].dt.hour.value_counts().reindex(range(24), fill_value=0).sort_index() - fig, ax = plt.subplots(figsize=(7, 2)) + fig, ax = plt.subplots(figsize=(11, 2.6)) ax.bar(hour_counts.index, hour_counts.to_numpy(), color=color) - ax.set_xlabel("Uur van de dag") + ax.set_xlabel(_HOUR_OF_DAY_LABEL) ax.set_ylabel("Aantal events") ax.set_xlim(-0.5, 23.5) ax.set_xticks(range(0, 24, 2)) fig.tight_layout() - - buf = io.BytesIO() - fig.savefig(buf, format="png", dpi=150) - plt.close(fig) - buf.seek(0) - return buf.getvalue() + return _fig_to_png(fig) -def _add_summary_section(pdf: FPDF, glucose: pd.DataFrame, start, end) -> None: +def _add_summary_section(pdf: GlucoseReportPDF, glucose: pd.DataFrame, start, end) -> None: + pdf.begin_report_section(SECTION_SUMMARY) mean_mmol = glucose["mmol"].mean() + min_mmol, max_mmol = glucose_min_max(glucose) tir = tir_breakdown_5band(glucose) active_pct = sensor_active_percent(glucose, start, end) - pdf.set_font("Helvetica", "B", 13) - pdf.cell(0, 8, "Samenvatting", **_LINE_KWARGS) + pdf.set_font("Helvetica", "", 11) + pdf.cell(0, 6, f"Periode: {start.strftime('%d-%m-%Y')} t/m {end.strftime('%d-%m-%Y')}", **_LINE_KWARGS) warning = completeness_warning(glucose, start, end) if warning: @@ -178,7 +354,12 @@ def _add_summary_section(pdf: FPDF, glucose: pd.DataFrame, start, end) -> None: pdf.set_font("Helvetica", "", 11) pdf.cell(0, 6, f"Sensordekking: {active_pct:.0f}%", **_LINE_KWARGS) - pdf.cell(0, 6, f"Gemiddelde glucose: {mean_mmol:.1f} mmol/L", **_LINE_KWARGS) + pdf.cell( + 0, + 6, + f"Gemiddelde glucose: {mean_mmol:.1f} mmol/L (min {min_mmol:.1f} · max {max_mmol:.1f})", + **_LINE_KWARGS, + ) gmi = gmi_percent(mean_mmol) pdf.cell(0, 6, f"Geschat HbA1c (GMI): {gmi:.1f}% ({gmi_mmol_per_mol(gmi):.0f} mmol/mol)", **_LINE_KWARGS) pdf.cell(0, 6, f"Variabiliteit (%CV): {percent_cv(glucose):.0f}%", **_LINE_KWARGS) @@ -190,43 +371,117 @@ def _add_summary_section(pdf: FPDF, glucose: pd.DataFrame, start, end) -> None: pdf.cell(0, 6, f"Zeer hoog (>13.9 mmol/L): {tir['very_high']:.0f}%", **_LINE_KWARGS) pdf.ln(2) png_bytes = _tir_bar_figure_png(tir) - pdf.image(io.BytesIO(png_bytes), w=160) - pdf.ln(4) + pdf.image(io.BytesIO(png_bytes), w=220) -def _add_agp_section(pdf: FPDF, glucose: pd.DataFrame) -> None: - pdf.set_font("Helvetica", "B", 13) - pdf.cell(0, 8, "AGP profiel", **_LINE_KWARGS) +def _add_agp_section(pdf: GlucoseReportPDF, glucose: pd.DataFrame) -> None: + pdf.begin_report_section(SECTION_AGP) png_bytes = _agp_figure_png(glucose) - pdf.image(io.BytesIO(png_bytes), w=180) + pdf.image(io.BytesIO(png_bytes), w=270) + + +def _add_insulin_profile_section(pdf: GlucoseReportPDF, profile: dict | None) -> None: + pdf.begin_report_section(SECTION_INSULIN_PROFILE) + if not profile: + pdf.set_font("Helvetica", "", 11) + pdf.cell(0, 6, "Geen pompprofiel gevonden op deze Nightscout-instantie.", **_LINE_KWARGS) + return + + pdf.set_font("Helvetica", "", 11) + pdf.cell(0, 6, f"Profielnaam: {profile.get('name') or '-'}", **_LINE_KWARGS) + pdf.cell(0, 6, f"DIA (werkingsduur insuline): {profile['dia']:.1f} uur", **_LINE_KWARGS) + pdf.cell(0, 6, f"Totale dagelijkse basaal: {total_daily_basal(profile['basal']):.1f} E", **_LINE_KWARGS) + pdf.ln(2) + + png_bytes = _insulin_profile_figure_png(profile) + pdf.image(io.BytesIO(png_bytes), w=270) pdf.ln(4) + pdf.set_font("Helvetica", "B", 11) + pdf.cell(90, 6, "Insulinegevoeligheid (ISF)", **_SAME_LINE_KWARGS) + pdf.cell(0, 6, "Koolhydraatratio", **_LINE_KWARGS) + pdf.set_font("Helvetica", "", 10) + isf_rows = segments_table(profile["sens"]) + ratio_rows = segments_table(profile["carbratio"]) + for i in range(max(len(isf_rows), len(ratio_rows))): + isf_text = ( + f"{isf_rows[i]['start']}-{isf_rows[i]['end']}: {isf_rows[i]['value']:.1f} mmol/L per E" + if i < len(isf_rows) + else "" + ) + ratio_text = ( + f"{ratio_rows[i]['start']}-{ratio_rows[i]['end']}: {ratio_rows[i]['value']:.0f} g per E" + if i < len(ratio_rows) + else "" + ) + pdf.cell(90, 5, isf_text, **_SAME_LINE_KWARGS) + pdf.cell(0, 5, ratio_text, **_LINE_KWARGS) -def _add_dagoverzicht_section(pdf: FPDF, glucose: pd.DataFrame) -> None: - """Eén dagelijkse mini-grafiek per kalenderdag (gitea #16), max. de meest - recente DAGOVERZICHT_MAX_DAYS dagen — voor langere periodes zou de PDF - anders onwerkbaar lang worden.""" - pdf.set_font("Helvetica", "B", 13) - pdf.cell(0, 8, "Dagoverzicht", **_LINE_KWARGS) +def _add_daily_stats_lines(pdf: GlucoseReportPDF, stats: dict) -> None: + pdf.set_font("Helvetica", "", 10) + pdf.cell( + 0, + 6, + f"Glucose: gem. {stats['mean_mmol']:.1f} · min {stats['min_mmol']:.1f} · " + f"max {stats['max_mmol']:.1f} mmol/L", + **_LINE_KWARGS, + ) + pdf.cell( + 0, + 6, + f"Insuline: totaal {stats['total_insulin']:.1f} E · basaal {stats['basal_insulin']:.1f} E · " + f"bolus {stats['bolus_insulin']:.1f} E · SMB {stats['smb_insulin']:.1f} E", + **_LINE_KWARGS, + ) + pdf.cell( + 0, + 6, + f"Hypo's: {stats['hypo_count']} ({stats['hypo_duration_minutes']:.0f} min) · " + f"Hyper's: {stats['hyper_count']} ({stats['hyper_duration_minutes']:.0f} min)", + **_LINE_KWARGS, + ) + + +def _add_dagoverzicht_section( + pdf: GlucoseReportPDF, glucose: pd.DataFrame, treatments: pd.DataFrame, profile: dict | None +) -> None: + """Eén liggende pagina per kalenderdag, max. de meest recente + DAGOVERZICHT_MAX_DAYS dagen — voor langere periodes zou de PDF anders + onwerkbaar lang worden.""" df = glucose.copy() df["date"] = df["timestamp"].dt.date dates = sorted(df["date"].unique()) - if len(dates) > DAGOVERZICHT_MAX_DAYS: + show_limited_note = len(dates) > DAGOVERZICHT_MAX_DAYS + if show_limited_note: dates = dates[-DAGOVERZICHT_MAX_DAYS:] - pdf.set_font("Helvetica", "I", 9) - pdf.cell(0, 5, f"Beperkt tot de meest recente {DAGOVERZICHT_MAX_DAYS} dagen.", **_LINE_KWARGS) for day in dates: + pdf.begin_report_section(SECTION_DAGOVERZICHT) + if show_limited_note: + pdf.set_font("Helvetica", "I", 9) + pdf.cell(0, 5, f"Beperkt tot de meest recente {DAGOVERZICHT_MAX_DAYS} dagen.", **_LINE_KWARGS) + show_limited_note = False # alleen op de eerste paginas van deze sectie + + day_start = pd.Timestamp(day) + day_end = day_start + pd.Timedelta(1, unit="D") day_df = df[df["date"] == day] - png_bytes = _daily_figure_png(day_df, day.strftime("%A %d-%m-%Y")) - pdf.image(io.BytesIO(png_bytes), w=180) - pdf.ln(4) + day_treatments = ( + treatments[(treatments["timestamp"] >= day_start) & (treatments["timestamp"] < day_end)] + if not treatments.empty + else treatments + ) + + png_bytes = _daily_figure_png(day_df, day_treatments, profile, day.strftime("%A %d-%m-%Y"), day_start, day_end) + pdf.image(io.BytesIO(png_bytes), w=270) + pdf.ln(2) + + stats = daily_stats(day_df, day_treatments, profile, day_start, day_end) + _add_daily_stats_lines(pdf, stats) -def _add_events_section(pdf: FPDF, glucose: pd.DataFrame, events_summary: dict[str, dict]) -> None: - pdf.set_font("Helvetica", "B", 13) - pdf.cell(0, 8, "Hypo-/hyperevents", **_LINE_KWARGS) +def _add_events_section(pdf: GlucoseReportPDF, glucose: pd.DataFrame, events_summary: dict[str, dict]) -> None: + pdf.begin_report_section(SECTION_EVENTS) pdf.set_font("Helvetica", "", 11) for label, summary in events_summary.items(): pdf.cell( @@ -238,17 +493,16 @@ def _add_events_section(pdf: FPDF, glucose: pd.DataFrame, events_summary: dict[s ) pdf.ln(2) - # Alleen Level 1 krijgt een grafiek in het PDF-rapport (gitea #17, #18) — - # geen tabel met losse events, de samenvattende cijfers staan al hierboven. + # Alleen Level 1 krijgt een grafiek in het PDF-rapport — geen tabel met + # losse events, de samenvattende cijfers staan al hierboven. if not glucose.empty: hypo_events = detect_events(glucose, LOW, "below") if not hypo_events.empty: - pdf.image(io.BytesIO(_event_hour_histogram_png(hypo_events, _TIR_COLORS["low"])), w=180) + pdf.image(io.BytesIO(_event_hour_histogram_png(hypo_events, _TIR_COLORS["low"])), w=260) + pdf.ln(2) hyper_events = detect_events(glucose, HIGH, "above") if not hyper_events.empty: - pdf.image(io.BytesIO(_event_hour_histogram_png(hyper_events, _TIR_COLORS["high"])), w=180) - - pdf.ln(4) + pdf.image(io.BytesIO(_event_hour_histogram_png(hyper_events, _TIR_COLORS["high"])), w=260) def build_report_pdf( @@ -257,20 +511,33 @@ def build_report_pdf( events_summary: dict[str, dict], start, end, + treatments: pd.DataFrame | None = None, + profile: dict | None = None, ) -> bytes: - """Bouw een PDF met de geselecteerde onderdelen. + """Bouw een PDF met de geselecteerde onderdelen: één liggende voorpagina, + daarna één (of voor Dagoverzicht: meerdere) liggende pagina('s) per sectie. `events_summary` bevat vooraf berekende hypo/hyper-samenvattingen (label -> resultaat van reporting.summarize_events), zodat deze module geen - event-detectie hoeft te herhalen. + event-detectie hoeft te herhalen. `treatments`/`profile` zijn optioneel + (leeg/None) zodat oudere aanroepen zonder insulinedata blijven werken. """ - pdf = FPDF() - pdf.add_page() - pdf.set_font("Helvetica", "B", 16) - pdf.cell(0, 10, "Glucoserapport", **_LINE_KWARGS) - pdf.set_font("Helvetica", "", 11) + if treatments is None: + treatments = pd.DataFrame(columns=_EMPTY_TREATMENT_COLUMNS) + + config = _report_config() + pdf = GlucoseReportPDF(config, datetime.now(timezone.utc)) + + pdf.begin_report_section("Glucoserapport") + pdf.set_font("Helvetica", "B", 22) + pdf.cell(0, 14, "Glucoserapport", **_LINE_KWARGS) + pdf.set_font("Helvetica", "", 13) pdf.cell(0, 8, f"Periode: {start.strftime('%d-%m-%Y')} t/m {end.strftime('%d-%m-%Y')}", **_LINE_KWARGS) - pdf.ln(4) + if sections: + pdf.cell(0, 8, f"Onderdelen: {', '.join(sections)}", **_LINE_KWARGS) + + if SECTION_INSULIN_PROFILE in sections: + _add_insulin_profile_section(pdf, profile) if not glucose.empty: if SECTION_SUMMARY in sections: @@ -278,7 +545,7 @@ def build_report_pdf( if SECTION_AGP in sections: _add_agp_section(pdf, glucose) if SECTION_DAGOVERZICHT in sections: - _add_dagoverzicht_section(pdf, glucose) + _add_dagoverzicht_section(pdf, glucose, treatments, profile) if SECTION_EVENTS in sections and events_summary: _add_events_section(pdf, glucose, events_summary) diff --git a/reporting.py b/reporting.py index 1c881d7..7c6c23f 100644 --- a/reporting.py +++ b/reporting.py @@ -601,6 +601,73 @@ def target_glucose_series(profile: dict | None, day_start, day_end) -> pd.DataFr return pd.DataFrame(rows, columns=["timestamp", "target"]) +def _integrate_step_series(series: pd.DataFrame, value_col: str, day_end) -> float: + """Oppervlakte onder een stapfunctie-DataFrame (zoals effective_basal_series()): + som van elke rij se waarde x duur (in uur) tot de volgende rij, of tot + `day_end` voor de laatste rij. Gebruikt om een E/uur-stapfunctie om te + rekenen naar een totaal aantal eenheden.""" + if series.empty: + return 0.0 + rows = series.reset_index(drop=True) + total = 0.0 + for i in range(len(rows)): + start = rows.loc[i, "timestamp"] + end = rows.loc[i + 1, "timestamp"] if i + 1 < len(rows) else day_end + total += rows.loc[i, value_col] * (end - start).total_seconds() / 3600 + return total + + +DAILY_STATS_KEYS = [ + "mean_mmol", + "min_mmol", + "max_mmol", + "total_insulin", + "basal_insulin", + "bolus_insulin", + "smb_insulin", + "hypo_count", + "hypo_duration_minutes", + "hyper_count", + "hyper_duration_minutes", +] + + +def daily_stats(day_glucose: pd.DataFrame, day_treatments: pd.DataFrame, profile: dict | None, day_start, day_end) -> dict: + """Samenvattende statistieken voor één kalenderdag, voor naast de dagelijkse + grafiek in het PDF-rapport: gemiddelde/min/max glucose, totale/basaal-/bolus-/ + SMB-insuline (SMB apart gerapporteerd omdat dit zowel als basaal- als + bolus-vervanging kan gelden, zie classify_bolus()), en het aantal + de totale + duur van hypo- en hyperevents die dag. + """ + min_mmol, max_mmol = glucose_min_max(day_glucose) + mean_mmol = round(day_glucose["mmol"].mean(), 1) if not day_glucose.empty else 0.0 + + bolus = classify_bolus(day_treatments) + by_category = bolus.groupby("category")["insulin"].sum().to_dict() if not bolus.empty else {} + bolus_units = by_category.get(BOLUS_CATEGORY_FOOD, 0.0) + by_category.get(BOLUS_CATEGORY_CORRECTION, 0.0) + smb_units = by_category.get(BOLUS_CATEGORY_SMB, 0.0) + + basal_series = effective_basal_series(profile, day_treatments, day_start, day_end) + basal_units = _integrate_step_series(basal_series, "rate", day_end) + + hypo_summary = summarize_events(detect_events(day_glucose, LOW, "below"), period_days=1) + hyper_summary = summarize_events(detect_events(day_glucose, HIGH, "above"), period_days=1) + + return { + "mean_mmol": mean_mmol, + "min_mmol": min_mmol, + "max_mmol": max_mmol, + "total_insulin": round(bolus_units + smb_units + basal_units, 1), + "basal_insulin": round(basal_units, 1), + "bolus_insulin": round(bolus_units, 1), + "smb_insulin": round(smb_units, 1), + "hypo_count": hypo_summary["count"], + "hypo_duration_minutes": hypo_summary["total_minutes"], + "hyper_count": hyper_summary["count"], + "hyper_duration_minutes": hyper_summary["total_minutes"], + } + + 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_pdf_export.py b/tests/test_pdf_export.py index 96b147b..75d46ac 100644 --- a/tests/test_pdf_export.py +++ b/tests/test_pdf_export.py @@ -1,17 +1,32 @@ +import re from datetime import date +from pathlib import Path import pandas as pd +import pytest from pdf_export import ( DAGOVERZICHT_MAX_DAYS, SECTION_AGP, SECTION_DAGOVERZICHT, SECTION_EVENTS, + SECTION_INSULIN_PROFILE, SECTION_SUMMARY, + _report_config, build_report_pdf, ) +def _page_count(pdf_bytes: bytes) -> int: + """Grof-maar-betrouwbaar paginatelling op de ruwe PDF-bytes: elke + paginaobject bevat een '/Type /Page' (zonder de 's' van '/Type /Pages') + in zijn objectdictionary, wat altijd onleesbaar-platte tekst blijft ook + al staat contentstream-compressie aan — geen losse PDF-parser nodig voor + deze structurele check (zelfde black-box-filosofie als de rest van deze + testset: geen pixel-vergelijkingen, wel structuur).""" + return len(re.findall(rb"/Type\s*/Page[^s]", pdf_bytes)) + + def _sample_glucose(): timestamps = pd.date_range("2026-01-01", periods=48, freq="30min") return pd.DataFrame({"timestamp": timestamps, "mmol": [6.0 + (i % 5) for i in range(48)]}) @@ -90,3 +105,185 @@ def test_build_report_pdf_draws_hypo_and_hyper_charts_when_events_present(): ) assert pdf_bytes.startswith(b"%PDF") assert len(pdf_bytes) > 500 + + +def test_build_report_pdf_always_has_at_least_a_cover_page(): + empty = pd.DataFrame(columns=["timestamp", "mmol"]) + pdf_bytes = build_report_pdf([], empty, {}, date(2026, 1, 1), date(2026, 1, 2)) + assert _page_count(pdf_bytes) == 1 + + +def test_build_report_pdf_one_page_per_single_page_section(): + glucose = _sample_glucose() + pdf_bytes = build_report_pdf( + [SECTION_SUMMARY, SECTION_AGP], glucose, {}, date(2026, 1, 1), date(2026, 1, 2) + ) + # cover + summary + agp = 3 + assert _page_count(pdf_bytes) == 3 + + +def test_build_report_pdf_one_page_per_day_for_dagoverzicht(): + timestamps = pd.date_range("2026-01-01", periods=3 * 24 * 12, freq="5min") # 3 dagen + glucose = pd.DataFrame({"timestamp": timestamps, "mmol": [7.0] * len(timestamps)}) + pdf_bytes = build_report_pdf( + [SECTION_DAGOVERZICHT], glucose, {}, date(2026, 1, 1), date(2026, 1, 3) + ) + # cover + 3 dagen + assert _page_count(pdf_bytes) == 4 + + +def test_build_report_pdf_caps_dagoverzicht_page_count_at_max_days(): + timestamps = pd.date_range("2026-01-01", periods=(DAGOVERZICHT_MAX_DAYS + 10) * 24, freq="1h") + glucose = pd.DataFrame({"timestamp": timestamps, "mmol": [7.0] * len(timestamps)}) + pdf_bytes = build_report_pdf( + [SECTION_DAGOVERZICHT], glucose, {}, timestamps[0].date(), timestamps[-1].date() + ) + # cover + max. DAGOVERZICHT_MAX_DAYS dagpagina's, ondanks (DAGOVERZICHT_MAX_DAYS + 10) dagen aan data + assert _page_count(pdf_bytes) == 1 + DAGOVERZICHT_MAX_DAYS + + +def test_build_report_pdf_includes_insulin_profile_section_page(): + glucose = _sample_glucose() + profile = { + "name": "Default", + "dia": 5.0, + "basal": [{"minutes": 0, "value": 0.8}, {"minutes": 720, "value": 1.1}], + "sens": [{"minutes": 0, "value": 2.8}], + "carbratio": [{"minutes": 0, "value": 10.0}], + "target_low": [{"minutes": 0, "value": 4.5}], + "target_high": [{"minutes": 0, "value": 8.0}], + } + pdf_bytes = build_report_pdf( + [SECTION_INSULIN_PROFILE], glucose, {}, date(2026, 1, 1), date(2026, 1, 2), profile=profile + ) + # cover + insulineprofiel + assert _page_count(pdf_bytes) == 2 + assert pdf_bytes.startswith(b"%PDF") + + +def test_build_report_pdf_insulin_profile_section_handles_missing_profile(): + glucose = _sample_glucose() + pdf_bytes = build_report_pdf( + [SECTION_INSULIN_PROFILE], glucose, {}, date(2026, 1, 1), date(2026, 1, 2), profile=None + ) + assert _page_count(pdf_bytes) == 2 + assert pdf_bytes.startswith(b"%PDF") + + +def test_build_report_pdf_insulin_profile_section_renders_without_glucose(): + """De Insulineprofiel-sectie gaat niet over glucosedata en moet dus ook + renderen als er geen glucosemetingen zijn (in tegenstelling tot + Samenvatting/AGP/Dagoverzicht, die wel glucosedata vereisen).""" + empty = pd.DataFrame(columns=["timestamp", "mmol"]) + profile = { + "name": "Default", + "dia": 5.0, + "basal": [{"minutes": 0, "value": 0.8}], + "sens": [{"minutes": 0, "value": 2.8}], + "carbratio": [{"minutes": 0, "value": 10.0}], + "target_low": [], + "target_high": [], + } + pdf_bytes = build_report_pdf( + [SECTION_INSULIN_PROFILE], empty, {}, date(2026, 1, 1), date(2026, 1, 2), profile=profile + ) + assert _page_count(pdf_bytes) == 2 + + +def test_build_report_pdf_dagoverzicht_uses_treatments_and_profile_without_error(): + """Regressietest: de dagpagina moet basaal/bolus/koolhydraten/streefwaarde- + overlays tekenen zonder te crashen als er echte treatments/profile worden + meegegeven (in plaats van de lege defaults).""" + timestamps = pd.date_range("2026-01-01", periods=24 * 12, freq="5min") # 1 dag + glucose = pd.DataFrame({"timestamp": timestamps, "mmol": [7.0] * len(timestamps)}) + treatments = pd.DataFrame( + [ + { + "timestamp": pd.Timestamp("2026-01-01 08:00"), + "eventType": "Meal Bolus", + "insulin": 4.0, + "carbs": 40.0, + "type": "", + "absolute": None, + "duration": None, + }, + { + "timestamp": pd.Timestamp("2026-01-01 12:00"), + "eventType": "Correction Bolus", + "insulin": 0.3, + "carbs": None, + "type": "SMB", + "absolute": None, + "duration": None, + }, + { + "timestamp": pd.Timestamp("2026-01-01 06:00"), + "eventType": "Temp Basal", + "insulin": None, + "carbs": None, + "type": "", + "absolute": 2.0, + "duration": 60, + }, + ] + ) + profile = { + "name": "Default", + "dia": 5.0, + "basal": [{"minutes": 0, "value": 0.8}], + "sens": [{"minutes": 0, "value": 2.8}], + "carbratio": [{"minutes": 0, "value": 10.0}], + "target_low": [{"minutes": 0, "value": 4.5}], + "target_high": [{"minutes": 0, "value": 8.0}], + } + pdf_bytes = build_report_pdf( + [SECTION_DAGOVERZICHT], + glucose, + {}, + date(2026, 1, 1), + date(2026, 1, 1), + treatments=treatments, + profile=profile, + ) + assert pdf_bytes.startswith(b"%PDF") + assert _page_count(pdf_bytes) == 2 # cover + 1 dag + + +def test_report_config_uses_defaults_when_env_vars_unset(monkeypatch): + for var in ("REPORT_INSULIN_TYPE", "REPORT_PUMP_TYPE", "REPORT_SENSOR_TYPE", "NIGHTSCOUT_URL"): + monkeypatch.delenv(var, raising=False) + config = _report_config() + assert config["insulin_type"] == "Onbekend insulinetype" + assert config["pump_type"] == "Onbekende pomp" + assert config["sensor_type"] == "Onbekende sensor" + assert config["nightscout_url"] == "" + + +def test_report_config_reads_env_vars_when_set(monkeypatch): + monkeypatch.setenv("REPORT_INSULIN_TYPE", "Lyumjev 100U") + monkeypatch.setenv("REPORT_PUMP_TYPE", "Omnipod DASH") + monkeypatch.setenv("REPORT_SENSOR_TYPE", "Dexcom G7") + monkeypatch.setenv("NIGHTSCOUT_URL", "https://example.com/") + config = _report_config() + assert config["insulin_type"] == "Lyumjev 100U" + assert config["pump_type"] == "Omnipod DASH" + assert config["sensor_type"] == "Dexcom G7" + assert config["nightscout_url"] == "https://example.com" # trailing slash gestript + + +def test_icon_asset_exists(): + icon_path = Path(__file__).parent.parent / "assets" / "icon.svg" + assert icon_path.exists() + assert icon_path.read_text(encoding="utf-8").strip().startswith("