Files
glucose-dashboard/pdf_export.py
T
willemjan 95b3aff310
CI/CD / test (push) Failing after 6s
CI/CD / sonarqube (push) Skipped
CI/CD / build-and-scan (push) Skipped
CI/CD / deploy (push) Skipped
Updated charts for glucose data and profile chart
2026-09-06 14:43:19 +02:00

574 lines
22 KiB
Python

"""
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, alpha=0.8)
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, bolus: pd.DataFrame, day_start, lane_y: float, categories: tuple[str, ...]) -> None:
for category in categories:
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 twee gestapelde grafieken (gedeelde tijdas, één gedeelde legenda):
boven glucose + streefwaarde + koolhydraten + voeding/correctiebolus, onder
basaal + SMB — gescheiden omdat één gecombineerde grafiek te veel informatie
bevatte om nog leesbaar te zijn."""
hours = day_df["timestamp"].dt.hour + day_df["timestamp"].dt.minute / 60
fig, (ax, ax_basal) = plt.subplots(2, 1, figsize=(11, 5.2), sharex=True, height_ratios=[2.2, 1])
ax2 = ax.twinx()
bolus = classify_bolus(day_treatments)
_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, bolus, day_start, lane_y, categories=(BOLUS_CATEGORY_CORRECTION, BOLUS_CATEGORY_FOOD))
_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_ylabel("mmol/L", fontsize=9)
ax2.set_ylabel("Koolhydraten (g)", fontsize=9)
ax.set_title(day_label, fontsize=11, loc="left")
ax.tick_params(labelsize=8)
ax2.tick_params(labelsize=8)
basal = effective_basal_series(profile, day_treatments, day_start, day_end)
_add_basal_overlay(ax_basal, basal, day_start)
smb_lane_y = (basal["rate"].max() * 1.15) if not basal.empty else 1.0
_add_bolus_overlay(ax_basal, bolus, day_start, smb_lane_y, categories=(BOLUS_CATEGORY_SMB,))
ax_basal.set_xlim(0, 24)
ax_basal.set_xticks(range(0, 25, 2))
ax_basal.set_xlabel(_HOUR_OF_DAY_LABEL)
ax_basal.set_ylabel("Basaal (E/uur)", fontsize=9)
ax_basal.tick_params(labelsize=8)
lines1, labels1 = ax.get_legend_handles_labels()
lines2, labels2 = ax2.get_legend_handles_labels()
lines3, labels3 = ax_basal.get_legend_handles_labels()
ax_basal.legend(
lines1 + lines2 + lines3,
labels1 + labels2 + labels3,
loc="upper center",
bbox_to_anchor=(0.5, -0.35),
ncol=6,
fontsize=7,
frameon=False,
)
# fig.tight_layout() introspecteert elke as se bounding box om marges te
# berekenen, en matplotlib ondersteunt dat niet betrouwbaar zodra er twee
# hoofd-assen (ax/ax_basal) plus een twinx (ax2) tegelijk in de figuur
# zitten (waarschuwing "Axes ... not compatible with tight_layout").
# subplots_adjust() met vaste marges omzeilt die introspectie.
fig.subplots_adjust(left=0.06, right=0.94, top=0.93, bottom=0.24, hspace=0.35)
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())