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/1_Live.py b/pages/1_Live.py
index e653dff..6687d04 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(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
@@ -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..0bc6e5a 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(1, unit="D")
+
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/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 d59cf58..7c6c23f 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,233 @@ 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 _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("