Redesign the PDF report: landscape pages, headers/footers, icon, daily stats

Every report section now gets its own landscape A4 page (or, for
Dagoverzicht, one page per day) via a GlucoseReportPDF(FPDF) subclass
with a real header (section title, report version, insulin/pump/sensor
type from new REPORT_* env vars) and footer (page number, generation
timestamp, Nightscout link) on every page, plus an original SVG icon
in the header — inspired by the LibreView AGP report.

New "Insulineprofiel" PDF section mirrors the dashboard page (basal
bar chart, ISF/carb-ratio segments, DIA). Each Dagoverzicht day now
draws the same basal/bolus/carb/target overlays as the interactive
charts (matplotlib port of the Plotly helpers from #30) plus a new
per-day stats block (reporting.daily_stats: avg/min/max glucose,
total/basal/bolus/SMB insulin, hypo/hyper counts and durations).

Builds on #29 (profile data) and #30 (overlay logic/colors).
This commit is contained in:
2026-09-05 22:44:14 +02:00
parent d33516e2a1
commit f2c3f97b1f
8 changed files with 718 additions and 100 deletions
+67
View File
@@ -601,6 +601,73 @@ def target_glucose_series(profile: dict | None, day_start, day_end) -> pd.DataFr
return pd.DataFrame(rows, columns=["timestamp", "target"])
def _integrate_step_series(series: pd.DataFrame, value_col: str, day_end) -> float:
"""Oppervlakte onder een stapfunctie-DataFrame (zoals effective_basal_series()):
som van elke rij se waarde x duur (in uur) tot de volgende rij, of tot
`day_end` voor de laatste rij. Gebruikt om een E/uur-stapfunctie om te
rekenen naar een totaal aantal eenheden."""
if series.empty:
return 0.0
rows = series.reset_index(drop=True)
total = 0.0
for i in range(len(rows)):
start = rows.loc[i, "timestamp"]
end = rows.loc[i + 1, "timestamp"] if i + 1 < len(rows) else day_end
total += rows.loc[i, value_col] * (end - start).total_seconds() / 3600
return total
DAILY_STATS_KEYS = [
"mean_mmol",
"min_mmol",
"max_mmol",
"total_insulin",
"basal_insulin",
"bolus_insulin",
"smb_insulin",
"hypo_count",
"hypo_duration_minutes",
"hyper_count",
"hyper_duration_minutes",
]
def daily_stats(day_glucose: pd.DataFrame, day_treatments: pd.DataFrame, profile: dict | None, day_start, day_end) -> dict:
"""Samenvattende statistieken voor één kalenderdag, voor naast de dagelijkse
grafiek in het PDF-rapport: gemiddelde/min/max glucose, totale/basaal-/bolus-/
SMB-insuline (SMB apart gerapporteerd omdat dit zowel als basaal- als
bolus-vervanging kan gelden, zie classify_bolus()), en het aantal + de totale
duur van hypo- en hyperevents die dag.
"""
min_mmol, max_mmol = glucose_min_max(day_glucose)
mean_mmol = round(day_glucose["mmol"].mean(), 1) if not day_glucose.empty else 0.0
bolus = classify_bolus(day_treatments)
by_category = bolus.groupby("category")["insulin"].sum().to_dict() if not bolus.empty else {}
bolus_units = by_category.get(BOLUS_CATEGORY_FOOD, 0.0) + by_category.get(BOLUS_CATEGORY_CORRECTION, 0.0)
smb_units = by_category.get(BOLUS_CATEGORY_SMB, 0.0)
basal_series = effective_basal_series(profile, day_treatments, day_start, day_end)
basal_units = _integrate_step_series(basal_series, "rate", day_end)
hypo_summary = summarize_events(detect_events(day_glucose, LOW, "below"), period_days=1)
hyper_summary = summarize_events(detect_events(day_glucose, HIGH, "above"), period_days=1)
return {
"mean_mmol": mean_mmol,
"min_mmol": min_mmol,
"max_mmol": max_mmol,
"total_insulin": round(bolus_units + smb_units + basal_units, 1),
"basal_insulin": round(basal_units, 1),
"bolus_insulin": round(bolus_units, 1),
"smb_insulin": round(smb_units, 1),
"hypo_count": hypo_summary["count"],
"hypo_duration_minutes": hypo_summary["total_minutes"],
"hyper_count": hyper_summary["count"],
"hyper_duration_minutes": hyper_summary["total_minutes"],
}
def basal_stats(treatments: pd.DataFrame, period_days: float) -> dict:
"""Totale basaalinsuline uit Temp Basal-treatments (rate x duur), zonder pompprofiel.