14 Commits
Author SHA1 Message Date
willemjan f7a0b94a54 lint fix
CI/CD / test (push) Successful in 24s
CI/CD / sonarqube (push) Successful in 21s
CI/CD / build-and-scan (push) Successful in 52s
CI/CD / deploy (push) Successful in 3s
willemjan/glucose-dashboard/pipeline/head This commit looks good
2026-09-06 14:44:50 +02:00
willemjan 95b3aff310 Updated charts for glucose data and profile chart
CI/CD / test (push) Failing after 6s
CI/CD / sonarqube (push) Skipped
CI/CD / build-and-scan (push) Skipped
CI/CD / deploy (push) Skipped
2026-09-06 14:43:19 +02:00
willemjan bd66d3dacd Fix CI: downgrade artifact actions to v3 for Gitea Actions compatibility
CI/CD / test (push) Successful in 41s
CI/CD / sonarqube (push) Successful in 34s
CI/CD / build-and-scan (push) Successful in 1m56s
CI/CD / deploy (push) Successful in 17s
upload-artifact@v4/download-artifact@v4 use the newer Artifact API v2,
which Gitea's act_runner doesn't support yet (GHESNotSupportedError).
v3 uses the older, Gitea-compatible API.
2026-09-05 23:03:16 +02:00
willemjan 47050bf031 Merge pull request 'Feature/insulin profile' (#37) from feature/insulin-profile into main
CI/CD / test (push) Failing after 51s
CI/CD / sonarqube (push) Skipped
CI/CD / build-and-scan (push) Skipped
CI/CD / deploy (push) Skipped
Reviewed-on: #37
2026-09-05 20:58:07 +00:00
willemjan de0ecebce5 Merge pull request 'Feature/treatment overlays' (#36) from feature/treatment-overlays into feature/insulin-profile
willemjan/glucose-dashboard/pipeline/head This commit looks good
Reviewed-on: #36
2026-09-05 20:57:52 +00:00
willemjan caf28046cb Merge pull request 'Feature/pdf report redesign' (#35) from feature/pdf-report-redesign into feature/treatment-overlays
willemjan/glucose-dashboard/pipeline/head This commit looks good
Reviewed-on: #35
2026-09-05 20:56:48 +00:00
willemjan a4d29e85a8 Merge pull request 'Add CI test-coverage gate at 80%' (#34) from feature/ci-coverage-gate into feature/pdf-report-redesign
willemjan/glucose-dashboard/pipeline/head This commit looks good
Reviewed-on: #34
2026-09-05 20:56:20 +00:00
willemjan 7f4377329a Add CI test-coverage gate at 80%, backfill remaining logic-module gaps
willemjan/glucose-dashboard/pipeline/head This commit looks good
pytest-cov + pyproject.toml [tool.coverage.*] measure reporting.py,
nightscout_client.py, data_processing.py, pdf_export.py, profile_data.py,
ui_common.py, and auth.py; app.py and pages/*.py are omitted (thin
Streamlit UI scripts, not meaningfully unit-testable without a running
Streamlit session — the handful of similarly UI-coupled functions left
in ui_common.py/auth.py are marked "# pragma: no cover" with the same
rationale, since the logic they call into is already fully tested).

The Gitea Actions test job now runs with --cov and fails the build
below 80%; the coverage XML is uploaded as a build artifact and fed
into the SonarQube scan (closing the gap flagged in
homelab-cicd-werkinstructie.md). Backfilled a few missing branches
(get_colors() theme fallback, secondary-y-axis overlay traces) to reach
98% on the measured modules — comfortable headroom above the 80% gate.

Builds on #29/#30/#31.
2026-09-05 22:52:29 +02:00
willemjan c617f4e41b Merge pull request 'Feature/insulin profile' (#33) from feature/insulin-profile into main
CI/CD / test (push) Successful in 17s
CI/CD / sonarqube (push) Successful in 20s
CI/CD / build-and-scan (push) Successful in 42s
CI/CD / deploy (push) Successful in 3s
Reviewed-on: #33
2026-09-05 20:47:43 +00:00
willemjan a10a4c0414 Merge pull request 'Feature/treatment overlays' (#32) from feature/treatment-overlays into feature/insulin-profile
Reviewed-on: #32
2026-09-05 20:47:10 +00:00
willemjan a3d89c4d21 Merge pull request 'Redesign PDF report: landscape pages, headers/footers, icon, daily stats' (#31) from feature/pdf-report-redesign into feature/treatment-overlays
Reviewed-on: #31
2026-09-05 20:45:16 +00:00
willemjan 1376101212 Merge pull request 'Add basal/bolus/carb/target overlays to Live and Dagoverzicht charts' (#30) from feature/treatment-overlays into feature/insulin-profile
Reviewed-on: #30
2026-09-05 20:44:26 +00:00
willemjan f2c3f97b1f 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).
2026-09-05 22:44:14 +02:00
willemjan 6004f76d76 Merge pull request 'Add pump profile fetching and Insulineprofiel page' (#29) from feature/insulin-profile into main
CI/CD / test (push) Successful in 51s
CI/CD / sonarqube (push) Successful in 24s
CI/CD / build-and-scan (push) Successful in 2m9s
CI/CD / deploy (push) Successful in 22s
willemjan/glucose-dashboard/pipeline/head This commit looks good
Reviewed-on: #29
2026-09-05 19:43:58 +00:00
21 changed files with 1096 additions and 219 deletions
+2
View File
@@ -5,4 +5,6 @@ __pycache__/
.git/
.pytest_cache/
.ruff_cache/
.coverage
coverage.xml
tests/
+6
View File
@@ -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=
+17 -3
View File
@@ -19,11 +19,21 @@ jobs:
run: pip install -r requirements.txt
- name: Lint (ruff)
run: ruff check .
- name: Unit tests (pytest)
run: pytest tests/ -v
- name: Unit tests + coverage-gate (pytest)
# fail_under (pyproject.toml, [tool.coverage.report]) laat deze stap
# falen als de dekking op de gemeten modules onder 80% zakt — app.py/
# pages/*.py zijn uitgesloten (zie pyproject.toml se coverage-omit),
# dat zijn dunne Streamlit-UI-scripts die niet zinvol te unit-testen
# zijn zonder een draaiende Streamlit-sessie.
run: pytest tests/ -v --cov=. --cov-report=term-missing --cov-report=xml
- name: Bewaar coverage-rapport
uses: actions/upload-artifact@v3
with:
name: coverage-report
path: coverage.xml
# ---------------------------------------------------------------------
# 2. SonarQube — codekwaliteit-gate
# 2. SonarQube — codekwaliteit-gate (incl. coverage uit de vorige stap)
# ---------------------------------------------------------------------
sonarqube:
runs-on: ubuntu-latest
@@ -32,6 +42,10 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # SonarQube wil de volledige git-historie zien
- name: Download coverage-rapport
uses: actions/download-artifact@v3
with:
name: coverage-report
- name: SonarQube scan
uses: sonarsource/sonarqube-scan-action@v6
env:
+2
View File
@@ -4,4 +4,6 @@ __pycache__/
.env
.pytest_cache/
.ruff_cache/
.coverage
coverage.xml
homelab-cicd-werkinstructie.md
+11 -1
View File
@@ -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
@@ -40,3 +41,12 @@ klinische rapportagepagina's (zie `docs/requirements-reporting.md` en
pytest tests/ -v
ruff check .
```
Coverage (zie `pyproject.toml` se `[tool.coverage.*]`): de CI-pipeline faalt
als de dekking op de gemeten modules onder 80% zakt (`app.py`/`pages/*.py`
zijn uitgesloten — dunne Streamlit-UI-scripts, niet zinvol te unit-testen
zonder een draaiende Streamlit-sessie).
```bash
pytest tests/ --cov=. --cov-report=term-missing
```
+4
View File
@@ -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

+6 -1
View File
@@ -17,13 +17,18 @@ import os
import streamlit as st
def require_auth() -> None:
def require_auth() -> None: # pragma: no cover
"""Blokkeer de pagina totdat het juiste wachtwoord is ingevoerd.
Slaat de status op in st.session_state zodat dit maar één keer per
browsersessie hoeft. Moet als eerste statement van elke pagina aangeroepen
worden (app.py en elk bestand in pages/), omdat Streamlit iedere pagina
als los script uitvoert.
Pure Streamlit-widget-orkestratie (st.text_input/st.session_state/
st.rerun) — buiten scope van de unit-testdekking net als pages/*.py,
zie pyproject.toml se coverage-omit-lijst. De veiligheidsrelevante
logica zelf (verify_password()) is wel volledig getest.
"""
if st.session_state.get("authenticated"):
return
+27
View File
@@ -254,6 +254,33 @@ def fetch_treatments_range(start_date: date, end_date: date) -> pd.DataFrame:
return _treatments_since(since, until)
def fetch_entries_for_day(day: date) -> pd.DataFrame:
"""Haal glucosemetingen op voor precies één kalenderdag.
Dunne wrapper om fetch_entries_range() met start=end=day, voor de
dag-navigatie op de Dagoverzicht-pagina: laadt alleen de geselecteerde
dag in plaats van een hele periode, zodat het wisselen tussen dagen snel
blijft.
"""
return fetch_entries_range(day, day)
def fetch_treatments_for_day(day: date, lookback_hours: int = 6) -> pd.DataFrame:
"""Haal behandelingen op voor één kalenderdag, met een lookback-marge vóór middernacht.
reporting.effective_basal_series() heeft de starttijd van een Temp Basal
nodig om de basaalsnelheid vroeg in de dag correct te berekenen — ook als
die temp basal al vóór middernacht is gestart en doorloopt. Zonder deze
marge zou de eerste uren van elke los opgevraagde dag ten onrechte het
profieltempo tonen in plaats van een nog actieve temp basal. 6 uur ligt
ruim boven de gebruikelijke temp-basal-duur in gesloten-lus-modus
(doorgaans 30-60 minuten).
"""
since = datetime.combine(day, time.min, tzinfo=timezone.utc) - timedelta(hours=lookback_hours)
until = datetime.combine(day, time.max, tzinfo=timezone.utc)
return _treatments_since(since, until)
def fetch_profile() -> dict:
"""Haal het meest recent gebruikte pompprofiel op.
+127 -95
View File
@@ -1,3 +1,5 @@
from datetime import timedelta
import pandas as pd
import plotly.graph_objects as go
import requests
@@ -7,16 +9,23 @@ from plotly.subplots import make_subplots
from auth import require_auth
from data_processing import TARGET_HIGH, TARGET_LOW, classify_treatments
from nightscout_client import NightscoutConfigError
from reporting import classify_bolus, effective_basal_series, target_glucose_series
from reporting import (
BOLUS_CATEGORY_SMB,
classify_bolus,
daily_stats,
effective_basal_series,
target_glucose_series,
)
from ui_common import (
MMOL_HOVERTEMPLATE,
add_basal_area_trace,
add_bolus_marker_traces,
add_carb_bars_trace,
add_target_line_trace,
add_threshold_lines,
get_colors,
load_day_data,
load_profile,
load_range_data,
render_completeness_warning,
select_period_days,
)
@@ -25,17 +34,48 @@ st.set_page_config(page_title="Glucose dashboard — Dagoverzicht", page_icon="
require_auth()
st.title("Dagoverzicht")
st.caption("Eén glucosegrafiek per kalenderdag, met basaal/bolus/koolhydraten-overlay (FR4).")
st.caption(
"Eén dag per keer: boven glucose met koolhydraten/bolus-overlay, onder basaal "
"en SMB — apart gehouden omdat één gecombineerde grafiek te veel informatie bevatte."
)
start, end, days = select_period_days(default_label="14 dagen")
if days > 31:
st.info(
"Bij langere periodes wordt de pagina lang en kan laden traag zijn — "
"voor het bekijken van individuele dagen is 14 of 30 dagen prettiger."
# De periode-knop bepaalt alleen het navigeerbare venster (hoe ver terug je kunt
# bladeren), niet welke data wordt opgehaald — dat gebeurt hieronder voor precies
# de geselecteerde dag (load_day_data), zodat het wisselen tussen dagen snel blijft.
window_start, window_end, days = select_period_days(default_label="14 dagen")
state_key = "dagoverzicht_selected_day"
if state_key not in st.session_state:
st.session_state[state_key] = window_end
if not (window_start <= st.session_state[state_key] <= window_end):
st.session_state[state_key] = window_end
selected_day = st.session_state[state_key]
col_prev, col_date, col_next = st.columns([1, 3, 1])
with col_prev:
if st.button("← Vorige dag", disabled=selected_day <= window_start, width="stretch"):
selected_day = selected_day - timedelta(days=1)
st.session_state[state_key] = selected_day
with col_next:
if st.button("Volgende dag →", disabled=selected_day >= window_end, width="stretch"):
selected_day = selected_day + timedelta(days=1)
st.session_state[state_key] = selected_day
with col_date:
date_options = [window_end - timedelta(days=i) for i in range(days)]
chosen = st.selectbox(
"Datum",
options=date_options,
index=date_options.index(selected_day),
format_func=lambda d: d.strftime("%A %d-%m-%Y"),
label_visibility="collapsed",
)
if chosen != selected_day:
selected_day = chosen
st.session_state[state_key] = selected_day
try:
glucose, treatments = load_range_data(start, end)
glucose, treatments = load_day_data(selected_day)
profile = load_profile()
except NightscoutConfigError as exc:
st.error(str(exc))
@@ -45,132 +85,124 @@ except (requests.RequestException, ValueError) as exc:
st.stop()
if glucose.empty:
st.info("Geen glucosemetingen gevonden in deze periode.")
st.info("Geen glucosemetingen gevonden op deze dag.")
st.stop()
render_completeness_warning(glucose, start, end)
render_completeness_warning(glucose, selected_day, selected_day)
colors = get_colors()
glucose = glucose.copy()
glucose["date"] = glucose["timestamp"].dt.date
glucose["hour"] = glucose["timestamp"].dt.hour + glucose["timestamp"].dt.minute / 60
day_start = pd.Timestamp(selected_day)
day_end = day_start + pd.Timedelta(1, unit="D")
carbs_events = classify_treatments(treatments)["carbs"].copy()
if not carbs_events.empty:
carbs_events["date"] = carbs_events["timestamp"].dt.date
carbs_events["hour"] = carbs_events["timestamp"].dt.hour + carbs_events["timestamp"].dt.minute / 60
# treatments bevat een lookback-marge vóór middernacht (zie
# nightscout_client.fetch_treatments_for_day) zodat een temp basal die al
# vóór deze dag begon toch meetelt — effective_basal_series() clipt dat
# zelf aan [day_start, day_end). Voor de koolhydraten/bolus-overlays willen
# we die marge juist NIET, anders duiken gisteravond se stippen op vandaag op.
day_treatments = treatments[(treatments["timestamp"] >= day_start) & (treatments["timestamp"] < day_end)]
bolus_events = classify_bolus(treatments)
if not bolus_events.empty:
bolus_events["date"] = bolus_events["timestamp"].dt.date
bolus_events["hour"] = bolus_events["timestamp"].dt.hour + bolus_events["timestamp"].dt.minute / 60
carbs_day = classify_treatments(day_treatments)["carbs"]
bolus_day = classify_bolus(day_treatments)
basal_day = effective_basal_series(profile, treatments, day_start, day_end)
target_day = target_glucose_series(profile, day_start, day_end)
def _close_step_to_day_end(df: pd.DataFrame, value_col: str) -> pd.DataFrame:
"""Voeg een laatste punt op hour=24 toe (zelfde waarde als het laatste
"""Voeg een laatste punt op day_end toe (zelfde waarde als het laatste
segment) zodat een stapvlak/-lijn de volle dag doortekent i.p.v. te
stoppen bij het laatste breekpunt."""
if df.empty:
return df
last_value = df.iloc[-1][value_col]
closing_row = pd.DataFrame([{"hour": 24.0, value_col: last_value}])
closing_row = pd.DataFrame([{"timestamp": day_end, value_col: last_value}])
return pd.concat([df, closing_row], ignore_index=True)
dates = sorted(glucose["date"].unique())
row_height = 190
fig = make_subplots(
rows=len(dates),
basal_day = _close_step_to_day_end(basal_day, "rate")
target_day = _close_step_to_day_end(target_day, "target")
def build_day_figure(glucose_day, carbs_day, bolus_day, basal_day, target_day, colors) -> go.Figure:
fig = make_subplots(
rows=2,
cols=1,
shared_xaxes=True,
vertical_spacing=min(0.4 / max(len(dates) - 1, 1), 0.05),
specs=[[{"secondary_y": True}]] * len(dates),
)
for i, day in enumerate(dates, start=1):
day_df = glucose[glucose["date"] == day]
day_start = pd.Timestamp(day)
day_end = day_start + pd.Timedelta(1, unit="D")
row_heights=[0.68, 0.32],
vertical_spacing=0.08,
specs=[[{"secondary_y": True}], [{"secondary_y": False}]],
)
# Rij 1: glucose + streefwaarde + koolhydraten + voeding-/correctiebolus.
fig.add_hrect(
y0=TARGET_LOW, y1=TARGET_HIGH, fillcolor=colors["band"], line_width=0, layer="below", row=i, col=1
)
# Handmatige titel-annotatie t.o.v. het domein van déze rij i.p.v.
# make_subplots' subplot_titles (die t.o.v. de hele figuur positioneert en
# daardoor bij veel dagen de gedeelde legenda bovenaan kan overlappen).
fig.add_annotation(
text=day.strftime("%A %d-%m-%Y"),
xref="x domain",
yref="y domain",
x=0.0,
y=1.15,
xanchor="left",
yanchor="bottom",
showarrow=False,
font={"size": 13, "color": colors["text"]},
row=i,
col=1,
y0=TARGET_LOW, y1=TARGET_HIGH, fillcolor=colors["band"], line_width=0, layer="below", row=1, col=1
)
add_target_line_trace(fig, target_day, colors, row=1)
add_carb_bars_trace(fig, carbs_day, colors, row=1, secondary_y=True)
basal_day = effective_basal_series(profile, treatments, day_start, day_end)
if not basal_day.empty:
basal_day["hour"] = (basal_day["timestamp"] - day_start).dt.total_seconds() / 3600
basal_day = _close_step_to_day_end(basal_day, "rate")
add_basal_area_trace(fig, basal_day, colors, row=i, secondary_y=True, x_col="hour")
target_day = target_glucose_series(profile, day_start, day_end)
if not target_day.empty:
target_day["hour"] = (target_day["timestamp"] - day_start).dt.total_seconds() / 3600
target_day = _close_step_to_day_end(target_day, "target")
add_target_line_trace(fig, target_day, colors, row=i, x_col="hour")
add_carb_bars_trace(fig, carbs_events[carbs_events["date"] == day], colors, row=i, secondary_y=True, x_col="hour")
lane_y = day_df["mmol"].min() - 1.0 if not day_df.empty else TARGET_LOW - 1.0
add_bolus_marker_traces(fig, bolus_events[bolus_events["date"] == day], colors, lane_y, row=i, x_col="hour")
top_bolus = bolus_day[bolus_day["category"] != BOLUS_CATEGORY_SMB] if not bolus_day.empty else bolus_day
lane_y = glucose_day["mmol"].min() - 1.0 if not glucose_day.empty else TARGET_LOW - 1.0
add_bolus_marker_traces(fig, top_bolus, colors, lane_y, row=1)
fig.add_trace(
go.Scatter(
x=day_df["hour"],
y=day_df["mmol"],
x=glucose_day["timestamp"],
y=glucose_day["mmol"],
mode="lines",
line={"color": colors["glucose"], "width": 2},
name="Glucose",
hovertemplate="%{y:.1f} mmol/L<extra></extra>",
hovertemplate=MMOL_HOVERTEMPLATE,
),
row=i,
row=1,
col=1,
secondary_y=False,
)
fig.update_yaxes(title_text="mmol/L", gridcolor=colors["grid"], row=i, col=1, secondary_y=False)
add_threshold_lines(fig, colors, row=1)
fig.update_yaxes(title_text="mmol/L", gridcolor=colors["grid"], row=1, col=1, secondary_y=False)
fig.update_yaxes(title_text="Koolhydraten (g)", showgrid=False, row=1, col=1, secondary_y=True)
# Rij 2: basaal + SMB — apart van rij 1 zodat de glucosegrafiek niet
# overladen raakt met insulinedetails.
add_basal_area_trace(fig, basal_day, colors, row=2)
smb_bolus = bolus_day[bolus_day["category"] == BOLUS_CATEGORY_SMB] if not bolus_day.empty else bolus_day
smb_lane_y = (basal_day["rate"].max() * 1.15) if not basal_day.empty else 1.0
add_bolus_marker_traces(fig, smb_bolus, colors, smb_lane_y, row=2)
fig.update_yaxes(
title_text="Basaal (E/uur) · KH (g)", showgrid=False, row=i, col=1, secondary_y=True
title_text="Basaal (E/uur)", gridcolor=colors["grid"], rangemode="tozero", row=2, col=1
)
add_threshold_lines(fig, colors, row=i)
# Elke dag herhaalt dezelfde spoornamen (Glucose/Basaal/Koolhydraten/...) — zonder
# deduplicatie zou de legenda 7x "Glucose" etc. tonen. Alleen de eerste keer per
# naam blijft in de legenda staan.
seen_names = set()
for trace in fig.data:
if trace.name in seen_names:
trace.showlegend = False
else:
seen_names.add(trace.name)
fig.update_xaxes(title_text="Uur van de dag", range=[0, 24], dtick=4, gridcolor=colors["grid"])
fig.update_layout(
fig.update_xaxes(range=[day_start, day_end], dtick=4 * 3600 * 1000, gridcolor=colors["grid"])
fig.update_layout(
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font_color=colors["text"],
height=row_height * len(dates) + 40,
height=560,
showlegend=True,
legend={"orientation": "h", "yanchor": "bottom", "y": 1.0, "xanchor": "left", "x": 0},
legend={"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "left", "x": 0},
margin={"l": 10, "r": 10, "t": 40, "b": 10},
)
)
return fig
fig = build_day_figure(glucose, carbs_day, bolus_day, basal_day, target_day, colors)
st.plotly_chart(fig, width="stretch")
stats = daily_stats(glucose, day_treatments, profile, day_start, day_end)
col1, col2, col3, col4 = st.columns(4)
col1.metric(
"Glucose (gem./min/max)",
f"{stats['mean_mmol']:.1f} mmol/L",
delta=f"{stats['min_mmol']:.1f} - {stats['max_mmol']:.1f}",
delta_color="off",
)
col2.metric("Totale insuline", f"{stats['total_insulin']:.1f} E")
col3.metric("Basaal / bolus / SMB", f"{stats['basal_insulin']:.1f} / {stats['bolus_insulin']:.1f} / {stats['smb_insulin']:.1f} E")
col4.metric(
"Hypo's / hyper's",
f"{stats['hypo_count']} / {stats['hyper_count']}",
delta=f"{stats['hypo_duration_minutes']:.0f} / {stats['hyper_duration_minutes']:.0f} min",
delta_color="off",
)
st.caption(
f"{len(dates)} dagen · {len(glucose)} metingen · stippen = bolusinsuline "
"(voeding/correctie/SMB, grootte naar dosis) · vlak = basaal · staafjes = koolhydraten · "
"streepstippellijn = streefwaarde uit pompprofiel"
"Boven: stippen = bolusinsuline (voeding/correctie, grootte naar dosis) · staafjes = koolhydraten · "
"streepstippellijn = streefwaarde uit pompprofiel. Onder: vlak = basaal · stippen = SMB."
)
+9 -3
View File
@@ -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",
+3 -3
View File
@@ -6,7 +6,7 @@ import streamlit as st
from auth import require_auth
from nightscout_client import NightscoutConfigError
from profile_data import segments_table, segments_to_hourly_bars, total_daily_basal
from ui_common import get_colors, load_profile
from ui_common import get_colors, hex_to_rgba, load_profile
st.set_page_config(page_title="Glucose dashboard — Insulineprofiel", page_icon="📊", layout="wide")
require_auth()
@@ -42,8 +42,8 @@ fig = go.Figure(
mode="lines",
line={"shape": "hv", "color": colors["basal"], "width": 2},
fill="tozeroy",
fillcolor=colors["basal"],
opacity=0.7,
fillcolor=hex_to_rgba(colors["basal"], 0.3),
opacity=0.8,
name="Basaal",
hovertemplate="%{x:.0f}:00 · %{y:.2f} E/uur<extra></extra>",
)
+384 -97
View File
@@ -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,147 @@ 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)."""
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)
_add_threshold_lines(ax)
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, 4))
ax.set_ylabel("mmol/L", fontsize=8)
ax.set_title(day_label, fontsize=9, loc="left")
ax.tick_params(labelsize=7)
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)
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=150)
plt.close(fig)
buf.seek(0)
return buf.getvalue()
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 — 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 +374,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 +391,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 +513,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 +531,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 +565,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)
+13
View File
@@ -1,2 +1,15 @@
[tool.pytest.ini_options]
pythonpath = ["."]
[tool.coverage.run]
# app.py en pages/*.py zijn dunne Streamlit-UI-scripts (net als de
# functies in ui_common.py/auth.py die zelf al met "# pragma: no cover"
# gemarkeerd zijn) — niet zinvol te unit-testen zonder een draaiende
# Streamlit-sessie, en buiten scope van de coverage-eis. De logica die ze
# aanroepen (reporting.py, nightscout_client.py, data_processing.py,
# pdf_export.py, profile_data.py, ui_common.py) wordt wél volledig gemeten.
omit = ["app.py", "pages/*", "tests/*", ".venv/*"]
[tool.coverage.report]
fail_under = 80
show_missing = true
+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.
+1
View File
@@ -6,4 +6,5 @@ python-dotenv>=1.0,<2.0
matplotlib>=3.9,<4.0
fpdf2>=2.7,<3.0
pytest>=8.0,<9.0
pytest-cov>=5.0,<6.0
ruff>=0.6,<1.0
+1
View File
@@ -4,3 +4,4 @@ sonar.sources=.
sonar.exclusions=data/**,tests/**
sonar.tests=tests
sonar.python.version=3.12
sonar.python.coverage.reportPaths=coverage.xml
+44
View File
@@ -168,6 +168,50 @@ def test_fetch_treatments_range_filters_to_date_boundaries(monkeypatch):
assert result.iloc[0]["insulin"] == pytest.approx(4.0)
def test_fetch_entries_for_day_scopes_to_a_single_calendar_day(monkeypatch):
monkeypatch.setenv("NIGHTSCOUT_URL", "https://example.com")
monkeypatch.delenv("NIGHTSCOUT_TOKEN", raising=False)
monkeypatch.delenv("NIGHTSCOUT_API_SECRET", raising=False)
payload = [
{"date": int(datetime(2026, 1, 15, 12, 0, tzinfo=timezone.utc).timestamp() * 1000), "sgv": 126},
{"date": int(datetime(2026, 1, 16, 12, 0, tzinfo=timezone.utc).timestamp() * 1000), "sgv": 100},
]
monkeypatch.setattr(ns.requests, "get", lambda *a, **k: FakeResponse(payload))
result = ns.fetch_entries_for_day(date(2026, 1, 15))
assert len(result) == 1
assert result.iloc[0]["timestamp"].date() == date(2026, 1, 15)
def test_fetch_treatments_for_day_includes_lookback_before_midnight(monkeypatch):
"""Een Temp Basal die net vóór middernacht is gestart moet toch meekomen
(zie de docstring bij fetch_treatments_for_day) — zonder lookback-marge
zou effective_basal_series() de vroege ochtenduren fout berekenen."""
monkeypatch.setenv("NIGHTSCOUT_URL", "https://example.com")
payload = [
{"created_at": "2026-01-14T22:00:00Z", "eventType": "Temp Basal", "absolute": 1.2, "duration": 480},
{"created_at": "2026-01-15T12:00:00Z", "eventType": "Meal Bolus", "insulin": 4.0, "carbs": 40},
{"created_at": "2026-01-10T08:00:00Z", "eventType": "Meal Bolus", "insulin": 3.0, "carbs": 30},
]
monkeypatch.setattr(ns.requests, "get", lambda *a, **k: FakeResponse(payload))
result = ns.fetch_treatments_for_day(date(2026, 1, 15), lookback_hours=6)
assert len(result) == 2 # de 10 januari-bolus valt buiten de lookback-marge
assert set(result["eventType"]) == {"Temp Basal", "Meal Bolus"}
def test_fetch_treatments_for_day_excludes_treatments_before_the_lookback_window(monkeypatch):
monkeypatch.setenv("NIGHTSCOUT_URL", "https://example.com")
payload = [
{"created_at": "2026-01-14T12:00:00Z", "eventType": "Meal Bolus", "insulin": 4.0, "carbs": 40},
]
monkeypatch.setattr(ns.requests, "get", lambda *a, **k: FakeResponse(payload))
result = ns.fetch_treatments_for_day(date(2026, 1, 15), lookback_hours=6)
assert result.empty
def _entry(dt, sgv=100):
return {"date": int(dt.timestamp() * 1000), "sgv": sgv, "dateString": dt.isoformat()}
+197
View File
@@ -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")
+70
View File
@@ -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(
[
+47
View File
@@ -1,5 +1,8 @@
from types import SimpleNamespace
import pandas as pd
import plotly.graph_objects as go
import streamlit as st
from ui_common import (
PALETTE,
@@ -10,10 +13,26 @@ from ui_common import (
add_target_line_trace,
add_threshold_lines,
build_tir_stacked_bar_figure,
get_colors,
hex_to_rgba,
)
def test_get_colors_returns_dark_palette_when_theme_is_dark(monkeypatch):
monkeypatch.setattr(st, "context", SimpleNamespace(theme=SimpleNamespace(type="dark")))
assert get_colors() == PALETTE["dark"]
def test_get_colors_returns_light_palette_when_theme_is_light(monkeypatch):
monkeypatch.setattr(st, "context", SimpleNamespace(theme=SimpleNamespace(type="light")))
assert get_colors() == PALETTE["light"]
def test_get_colors_falls_back_to_light_when_theme_context_unavailable(monkeypatch):
monkeypatch.setattr(st, "context", SimpleNamespace()) # geen .theme-attribuut
assert get_colors() == PALETTE["light"]
def test_hex_to_rgba_converts_correctly():
assert hex_to_rgba("#2a78d6", 0.3) == "rgba(42,120,214,0.3)"
@@ -55,12 +74,34 @@ def test_add_basal_area_trace_adds_one_filled_step_trace():
assert fig.data[0].line.shape == "hv"
def test_add_basal_area_trace_supports_secondary_y_on_a_subplot_figure():
from plotly.subplots import make_subplots
basal = pd.DataFrame(
{"timestamp": pd.to_datetime(["2026-01-01 00:00", "2026-01-01 06:00"]), "rate": [0.8, 1.2]}
)
fig = make_subplots(specs=[[{"secondary_y": True}]])
add_basal_area_trace(fig, basal, PALETTE["light"], row=1, secondary_y=True)
assert len(fig.data) == 1
assert fig.data[0].yaxis == "y2"
def test_add_basal_area_trace_skips_empty_input():
fig = go.Figure()
add_basal_area_trace(fig, pd.DataFrame(columns=["timestamp", "rate"]), PALETTE["light"])
assert len(fig.data) == 0
def test_add_carb_bars_trace_supports_secondary_y_on_a_subplot_figure():
from plotly.subplots import make_subplots
carbs = pd.DataFrame({"timestamp": pd.to_datetime(["2026-01-01 08:00"]), "carbs": [40.0]})
fig = make_subplots(specs=[[{"secondary_y": True}]])
add_carb_bars_trace(fig, carbs, PALETTE["light"], row=1, secondary_y=True)
assert len(fig.data) == 1
assert fig.data[0].yaxis == "y2"
def test_add_carb_bars_trace_adds_one_bar_trace():
carbs = pd.DataFrame({"timestamp": pd.to_datetime(["2026-01-01 08:00"]), "carbs": [40.0]})
fig = go.Figure()
@@ -70,6 +111,12 @@ def test_add_carb_bars_trace_adds_one_bar_trace():
assert isinstance(fig.data[0], go.Bar)
def test_add_carb_bars_trace_skips_empty_input():
fig = go.Figure()
add_carb_bars_trace(fig, pd.DataFrame(columns=["timestamp", "carbs"]), PALETTE["light"])
assert len(fig.data) == 0
def test_add_bolus_marker_traces_adds_one_trace_per_category_present():
bolus = pd.DataFrame(
{
+53 -11
View File
@@ -14,7 +14,13 @@ import pandas as pd
import plotly.graph_objects as go
import streamlit as st
from nightscout_client import fetch_entries_range, fetch_profile, fetch_treatments_range
from nightscout_client import (
fetch_entries_for_day,
fetch_entries_range,
fetch_profile,
fetch_treatments_for_day,
fetch_treatments_range,
)
from profile_data import parse_profile
from reporting import (
HIGH,
@@ -118,8 +124,12 @@ def get_colors() -> dict:
return PALETTE["dark" if theme_type == "dark" else "light"]
def select_period_days(default_label: str) -> tuple[date, date, int]:
"""Periodekiezer voor rapportagepagina's; geeft (start, eind, aantal_dagen) terug."""
def select_period_days(default_label: str) -> tuple[date, date, int]: # pragma: no cover
"""Periodekiezer voor rapportagepagina's; geeft (start, eind, aantal_dagen) terug.
Pure Streamlit-widget-orkestratie (st.radio) — buiten scope van de
unit-testdekking net als pages/*.py, zie pyproject.toml se coverage-omit-lijst.
"""
labels = list(PERIOD_OPTIONS_DAYS.keys())
label = st.radio("Periode", labels, index=labels.index(default_label), horizontal=True)
days = PERIOD_OPTIONS_DAYS[label]
@@ -129,22 +139,49 @@ def select_period_days(default_label: str) -> tuple[date, date, int]:
@st.cache_data(ttl=3600, show_spinner="Data ophalen van Nightscout...")
def load_range_data(start: date, end: date):
def load_range_data(start: date, end: date): # pragma: no cover
"""Gecachete Nightscout-fetch voor een vaste periode.
Lange TTL (in tegenstelling tot de 60s van de live-pagina): historische
data van een afgeronde periode verandert niet.
Dunne st.cache_data-wrapper rond al-geteste fetch_entries_range()/
fetch_treatments_range() — buiten scope van de unit-testdekking net als
pages/*.py, zie pyproject.toml se coverage-omit-lijst.
"""
glucose = fetch_entries_range(start, end)
treatments = fetch_treatments_range(start, end)
return glucose, treatments
@st.cache_data(ttl=300, show_spinner="Data ophalen van Nightscout...")
def load_day_data(day: date): # pragma: no cover
"""Gecachete Nightscout-fetch voor precies één kalenderdag (Dagoverzicht-navigatie).
Kortere TTL dan load_range_data (5 min i.p.v. 1 uur): in tegenstelling tot
een afgeronde periode kan de geselecteerde dag "vandaag" zijn, waar
nieuwe metingen blijven binnenkomen. fetch_treatments_for_day() haalt
zelf een lookback-marge vóór middernacht op zodat effective_basal_series()
de vroege ochtenduren correct kan berekenen.
Dunne st.cache_data-wrapper rond al-geteste fetch_entries_for_day()/
fetch_treatments_for_day() — buiten scope van de unit-testdekking net als
pages/*.py, zie pyproject.toml se coverage-omit-lijst.
"""
glucose = fetch_entries_for_day(day)
treatments = fetch_treatments_for_day(day)
return glucose, treatments
@st.cache_data(ttl=21600, show_spinner="Profiel ophalen van Nightscout...")
def load_profile() -> dict:
def load_profile() -> dict: # pragma: no cover
"""Gecachete profiel-fetch. Langere TTL dan load_range_data (6 uur i.p.v.
1 uur): een pompprofiel wijzigt zelden, in tegenstelling tot glucose-/
behandeldata van een lopende periode."""
behandeldata van een lopende periode.
Dunne st.cache_data-wrapper rond al-geteste fetch_profile()/parse_profile()
— buiten scope van de unit-testdekking net als pages/*.py.
"""
raw = fetch_profile()
return parse_profile(raw)
@@ -363,19 +400,21 @@ def add_target_line_trace(
)
def render_completeness_warning(glucose: pd.DataFrame, start: date, end: date) -> None:
def render_completeness_warning(glucose: pd.DataFrame, start: date, end: date) -> None: # pragma: no cover
"""Toon reporting.completeness_warning()'s oordeel als st.warning, indien nodig.
Gebruikt door elke rapportagepagina, direct na de load_range_data-call.
De beoordelingslogica zelf staat in reporting.py (pure functie) zodat het
PDF-rapport (pdf_export.py) exact dezelfde disclaimer kan hergebruiken.
De beoordelingslogica zelf staat in reporting.py (pure functie, wél volledig
getest) zodat het PDF-rapport (pdf_export.py) exact dezelfde disclaimer kan
hergebruiken. Deze functie is alleen de st.warning-weergave — buiten scope
van de unit-testdekking net als pages/*.py.
"""
message = completeness_warning(glucose, start, end)
if message:
st.warning(message)
def render_event_level(
def render_event_level( # pragma: no cover
glucose: pd.DataFrame,
threshold: float,
direction: str,
@@ -386,7 +425,10 @@ def render_event_level(
"""Render de metrics/tabel/tijdstip-histogram voor één hypo-/hyperniveau.
Gedeeld tussen de Hypo Events- en Hyper Events-pagina's (FR5/FR6), die
elk twee niveaus (Level 1/Level 2) op dezelfde manier tonen.
elk twee niveaus (Level 1/Level 2) op dezelfde manier tonen. Pure
Streamlit-widget-weergave rond al-geteste reporting.detect_events()/
summarize_events() — buiten scope van de unit-testdekking net als
pages/*.py.
"""
events = detect_events(glucose, threshold, direction)
summary = summarize_events(events, period_days)