574 lines
22 KiB
Python
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())
|