Feature/treatment overlays #32
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<path d="M32 4 C20 22, 10 34, 10 46 A22 22 0 0 0 54 46 C54 34, 44 22, 32 4 Z" fill="#2a78d6"/>
|
||||
<polyline points="14,42 24,42 28,32 34,52 40,38 46,42 50,42" fill="none" stroke="#ffffff" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 325 B |
+9
-3
@@ -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",
|
||||
|
||||
+363
-96
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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("<svg")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("section", [SECTION_SUMMARY, SECTION_AGP, SECTION_DAGOVERZICHT, SECTION_EVENTS])
|
||||
def test_all_sections_still_work_without_treatments_or_profile_args(section):
|
||||
"""Achterwaartse compatibiliteit: treatments/profile zijn optioneel — een
|
||||
aanroep zoals vóór deze PR (zonder die argumenten) moet blijven werken."""
|
||||
glucose = _glucose_with_hypo_and_hyper_events() if section == SECTION_EVENTS else _sample_glucose()
|
||||
events_summary = {"Hypo Level 1": {"count": 1, "avg_duration_minutes": 25.0, "events_per_week": 2.3}}
|
||||
pdf_bytes = build_report_pdf(
|
||||
[section], glucose, events_summary if section == SECTION_EVENTS else {}, date(2026, 1, 1), date(2026, 1, 4)
|
||||
)
|
||||
assert pdf_bytes.startswith(b"%PDF")
|
||||
|
||||
@@ -11,6 +11,7 @@ from reporting import (
|
||||
classify_bolus,
|
||||
completeness_warning,
|
||||
daily_gmi,
|
||||
daily_stats,
|
||||
day_of_week_pattern,
|
||||
detect_events,
|
||||
effective_basal_series,
|
||||
@@ -618,6 +619,75 @@ def test_target_glucose_series_handles_none_profile():
|
||||
assert result.empty
|
||||
|
||||
|
||||
def test_daily_stats_computes_glucose_and_insulin_breakdown():
|
||||
day_start = pd.Timestamp("2026-01-01")
|
||||
day_end = pd.Timestamp("2026-01-02")
|
||||
glucose = _series(
|
||||
[("2026-01-01 08:00", 3.2), ("2026-01-01 12:00", 14.1), ("2026-01-01 20:00", 6.5)]
|
||||
)
|
||||
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 = {"basal": [{"minutes": 0, "value": 0.8}]}
|
||||
|
||||
result = daily_stats(glucose, treatments, profile, day_start, day_end)
|
||||
|
||||
assert result["mean_mmol"] == pytest.approx((3.2 + 14.1 + 6.5) / 3, abs=0.05)
|
||||
assert result["min_mmol"] == pytest.approx(3.2)
|
||||
assert result["max_mmol"] == pytest.approx(14.1)
|
||||
assert result["bolus_insulin"] == pytest.approx(4.0)
|
||||
assert result["smb_insulin"] == pytest.approx(0.3)
|
||||
# basaal: 23h @ 0.8 profieltempo + 1h @ 2.0 (temp basal 06:00-07:00) = 18.4 + 2.0 = 20.4
|
||||
assert result["basal_insulin"] == pytest.approx(20.4, abs=0.05)
|
||||
assert result["total_insulin"] == pytest.approx(4.0 + 0.3 + 20.4, abs=0.05)
|
||||
assert result["hypo_count"] == 1
|
||||
assert result["hyper_count"] == 1
|
||||
|
||||
|
||||
def test_daily_stats_handles_empty_glucose_and_treatments():
|
||||
day_start = pd.Timestamp("2026-01-01")
|
||||
day_end = pd.Timestamp("2026-01-02")
|
||||
empty_glucose = pd.DataFrame(columns=["timestamp", "mmol"])
|
||||
empty_treatments = pd.DataFrame(columns=["timestamp", "eventType", "insulin", "carbs", "type", "absolute", "duration"])
|
||||
|
||||
result = daily_stats(empty_glucose, empty_treatments, None, day_start, day_end)
|
||||
|
||||
assert result["mean_mmol"] == 0.0
|
||||
assert result["min_mmol"] == 0.0
|
||||
assert result["max_mmol"] == 0.0
|
||||
assert result["total_insulin"] == 0.0
|
||||
assert result["hypo_count"] == 0
|
||||
assert result["hyper_count"] == 0
|
||||
|
||||
|
||||
def test_site_interval_stats_splits_by_site_change():
|
||||
glucose = _series(
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user