""" PDF-rapportgeneratie voor geselecteerde rapportageonderdelen (FR10). Gebruikt fpdf2 (puur Python, geen systeemdependencies) voor de PDF-opbouw en 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 matplotlib.use("Agg") import matplotlib.pyplot as plt 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_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 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 = { "very_low": "Zeer laag", "low": "Laag", "in_range": "In bereik", "high": "Hoog", "very_high": "Zeer hoog", } _TIR_COLORS = { "very_low": "#8b1a1a", "low": "#d03b3b", "in_range": "#0ca30c", "high": "#fab219", "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 _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("/"), } 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) buf.seek(0) 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.""" fig, ax = plt.subplots(figsize=(9, 1.1)) left = 0.0 for band in _TIR_BAND_ORDER: value = tir[band] ax.barh(0, value, left=left, color=_TIR_COLORS[band], label=_TIR_BAND_LABELS[band]) left += value ax.set_xlim(0, 100) ax.set_yticks([]) ax.set_xlabel("%") ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.75), ncol=5, fontsize=7, frameon=False) buf = io.BytesIO() # bbox_inches="tight" i.p.v. fig.tight_layout(): de legend zit buiten de # axes (bbox_to_anchor met negatieve y), waar tight_layout() niet voor # kan compenseren — savefig's eigen bbox-berekening kan dat wel. fig.savefig(buf, format="png", dpi=150, bbox_inches="tight") plt.close(fig) buf.seek(0) return buf.getvalue() 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=(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, 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) 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.""" hour_counts = events["start"].dt.hour.value_counts().reindex(range(24), fill_value=0).sort_index() fig, ax = plt.subplots(figsize=(11, 2.6)) ax.bar(hour_counts.index, hour_counts.to_numpy(), color=color) 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() return _fig_to_png(fig) 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", "", 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: pdf.set_font("Helvetica", "BI", 10) pdf.multi_cell(0, 5, warning) pdf.ln(2) 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 (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) pdf.ln(2) pdf.cell(0, 6, f"Zeer laag (<3.0 mmol/L): {tir['very_low']:.0f}%", **_LINE_KWARGS) pdf.cell(0, 6, f"Laag (3.0-3.9 mmol/L): {tir['low']:.0f}%", **_LINE_KWARGS) pdf.cell(0, 6, f"In bereik (3.9-10.0 mmol/L): {tir['in_range']:.0f}%", **_LINE_KWARGS) pdf.cell(0, 6, f"Hoog (10.0-13.9 mmol/L): {tir['high']:.0f}%", **_LINE_KWARGS) 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=220) 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=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_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()) show_limited_note = len(dates) > DAGOVERZICHT_MAX_DAYS if show_limited_note: dates = dates[-DAGOVERZICHT_MAX_DAYS:] 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] 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: 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( 0, 6, f"{label}: {summary['count']} events, gemiddeld {summary['avg_duration_minutes']:.0f} min, " f"{summary['events_per_week']:.1f}/week", **_LINE_KWARGS, ) pdf.ln(2) # 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=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=260) def build_report_pdf( sections: list[str], glucose: pd.DataFrame, events_summary: dict[str, dict], start, end, treatments: pd.DataFrame | None = None, profile: dict | None = None, ) -> bytes: """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. `treatments`/`profile` zijn optioneel (leeg/None) zodat oudere aanroepen zonder insulinedata blijven werken. """ 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) 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: _add_summary_section(pdf, glucose, start, end) if SECTION_AGP in sections: _add_agp_section(pdf, glucose) if SECTION_DAGOVERZICHT in sections: _add_dagoverzicht_section(pdf, glucose, treatments, profile) if SECTION_EVENTS in sections and events_summary: _add_events_section(pdf, glucose, events_summary) return bytes(pdf.output())