Author SHA1 Message Date
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 d33516e2a1 Avoid pandas Timedelta(days=1) deprecation warning
pd.Timedelta(days=1) triggers a DeprecationWarning about NumPy's
"generic" timedelta unit in this pandas version; pd.Timedelta(1, unit="D")
is equivalent and warning-free.
2026-09-05 22:38:07 +02:00
willemjan c54dcf616a Add basal/bolus/carb/target overlays to the Live and Dagoverzicht charts
willemjan/glucose-dashboard/pipeline/pr-feature/insulin-profile This commit looks good
Overlay the full insulin picture onto the interactive glucose charts:
basal rate as a semi-transparent step-area (profile schedule overridden
by active temp basal), bolus doses as size-scaled dots split into
food/correction/SMB categories (food and correction labeled with units,
SMB left unlabeled), carbs as bars, and the pump's target glucose as a
line — each with its own color, alpha, and legend entry. Shared trace-
building helpers live in ui_common.py so both pages (and the PDF report
in a follow-up PR) render identically.

Builds on the pump profile work in #29 (profile_data.py, load_profile()).
2026-09-05 22:30:10 +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
willemjan 79249958c2 Add pump profile fetching and Insulineprofiel page
willemjan/glucose-dashboard/pipeline/head This commit looks good
Fetch and parse the latest-used Nightscout profile (basal schedule, ISF,
carb ratio, DIA, targets) and show it as a new dashboard page: a list of
basal/ISF/carb-ratio segments plus a bar chart of basal rate by hour of
day. This is the data foundation for treatment overlays on the glucose
charts and the redesigned PDF report in follow-up PRs.
2026-09-05 21:37:17 +02:00
willemjan 8641022219 Merge pull request 'test jenkins' (#28) from dev into main
CI/CD / test (push) Successful in 9s
CI/CD / sonarqube (push) Successful in 22s
CI/CD / build-and-scan (push) Successful in 1m56s
willemjan/glucose-dashboard/pipeline/head Something is wrong with the build of this commit
CI/CD / deploy (push) Successful in 16s
Reviewed-on: #28
2026-08-10 21:20:28 +00:00
willemjan db3204ab58 Merge pull request 'Show estimated HbA1c in mmol/mol (IFCC) alongside % (#25)' (#27) from dev into main
CI/CD / test (push) Successful in 9s
CI/CD / sonarqube (push) Successful in 20s
CI/CD / build-and-scan (push) Successful in 49s
CI/CD / deploy (push) Successful in 3s
2026-08-01 21:13:00 +00:00
willemjan 25c3fe6730 Merge pull request 'GMI trend: daily scatter + rolling average + linear trend/CI (#19)' (#26) from dev into main
CI/CD / test (push) Successful in 10s
CI/CD / sonarqube (push) Successful in 20s
CI/CD / build-and-scan (push) Successful in 43s
CI/CD / deploy (push) Successful in 2s
2026-08-01 20:58:12 +00:00
willemjan d637ad39e4 Merge pull request 'Fix bolus/basal insulin categorization: split SMB from manual corrections (#20)' (#24) from dev into main
CI/CD / test (push) Successful in 9s
CI/CD / sonarqube (push) Successful in 20s
CI/CD / build-and-scan (push) Successful in 40s
CI/CD / deploy (push) Successful in 3s
2026-08-01 20:53:10 +00:00
willemjan 5addd6a49b Merge pull request 'Add TIR bar/threshold lines, Dagoverzicht + hypo/hyper Level-1 charts to PDF (#14, #15, #16, #17, #18)' (#23) from dev into main
CI/CD / test (push) Successful in 9s
CI/CD / sonarqube (push) Successful in 23s
CI/CD / build-and-scan (push) Successful in 42s
CI/CD / deploy (push) Successful in 3s
2026-08-01 20:48:51 +00:00
willemjan 95f006078f Merge pull request 'Add clinical threshold lines + TIR stacked bar chart (#14, #15)' (#22) from dev into main
CI/CD / test (push) Successful in 7s
CI/CD / sonarqube (push) Successful in 21s
CI/CD / build-and-scan (push) Successful in 41s
CI/CD / deploy (push) Successful in 2s
2026-08-01 20:28:23 +00:00
willemjan 4bb9251d7d Merge pull request 'Fix treatments API pagination cursor field (created_at, not date)' (#21) from dev into main
CI/CD / test (push) Successful in 8s
CI/CD / sonarqube (push) Successful in 19s
CI/CD / build-and-scan (push) Successful in 41s
CI/CD / deploy (push) Successful in 3s
2026-08-01 19:43:40 +00:00
willemjan 6c6b175a28 Merge pull request 'Fix multi-day Nightscout fetch truncation + data-completeness warnings' (#12) from dev into main
CI/CD / test (push) Successful in 7s
CI/CD / sonarqube (push) Successful in 20s
CI/CD / build-and-scan (push) Successful in 41s
CI/CD / deploy (push) Successful in 3s
2026-07-31 12:22:10 +00:00
willemjan c39646ceaa Merge pull request 'PDF export (FR10) - completes reporting requirements' (#10) from dev into main
CI/CD / test (push) Successful in 11s
CI/CD / sonarqube (push) Successful in 20s
CI/CD / build-and-scan (push) Successful in 1m58s
CI/CD / deploy (push) Successful in 14s
2026-07-26 19:11:06 +00:00
willemjan 52c9284f85 Merge pull request 'Trends page: GMI trend, weekday pattern, bolus, site-age (FR7, FR8)' (#9) from dev into main
CI/CD / test (push) Successful in 6s
CI/CD / sonarqube (push) Successful in 18s
CI/CD / build-and-scan (push) Successful in 40s
CI/CD / deploy (push) Successful in 4s
2026-07-26 19:06:24 +00:00
willemjan 419508dd5e Merge pull request 'Dagoverzicht + Hypo/Hyper Events pages (FR4, FR5, FR6)' (#8) from dev into main
CI/CD / test (push) Successful in 5s
CI/CD / sonarqube (push) Successful in 17s
CI/CD / build-and-scan (push) Successful in 44s
CI/CD / deploy (push) Successful in 3s
2026-07-26 18:13:56 +00:00
willemjan 689452cf4c Merge pull request 'Samenvatting + AGP Profiel pages (FR2, FR3, FR9)' (#7) from dev into main
CI/CD / test (push) Successful in 6s
CI/CD / sonarqube (push) Successful in 22s
CI/CD / build-and-scan (push) Successful in 47s
CI/CD / deploy (push) Successful in 3s
2026-07-26 18:09:41 +00:00
willemjan e600d6cf7d Merge pull request 'Reporting foundation: multipage nav, auth gate, date-range fetch' (#6) from dev into main
CI/CD / test (push) Successful in 6s
CI/CD / sonarqube (push) Successful in 17s
CI/CD / build-and-scan (push) Successful in 38s
CI/CD / deploy (push) Successful in 3s
2026-07-26 18:01:15 +00:00
willemjan 10aa315e40 Merge pull request 'Pin trivy-action to last docker-based release' (#5) from dev into main
CI/CD / test (push) Successful in 6s
CI/CD / sonarqube (push) Successful in 19s
CI/CD / build-and-scan (push) Successful in 57s
CI/CD / deploy (push) Successful in 19s
2026-07-26 12:54:59 +00:00
willemjan e3443e9f9f Merge pull request 'Fix registry hostname (duckdns) and Trivy github auth' (#4) from dev into main
CI/CD / test (push) Successful in 6s
CI/CD / sonarqube (push) Successful in 20s
CI/CD / build-and-scan (push) Failing after 10s
CI/CD / deploy (push) Skipped
2026-07-25 20:05:33 +00:00
willemjan 274d27c5bc Merge pull request 'Fix pytest module discovery in CI' (#3) from dev into main
CI/CD / test (push) Successful in 5s
CI/CD / sonarqube (push) Successful in 1m17s
CI/CD / build-and-scan (push) Failing after 4m37s
CI/CD / deploy (push) Skipped
2026-07-25 19:12:20 +00:00
willemjan f17e4f6c39 Merge pull request 'Nightscout-koppeling + CI/CD-fixes' (#2) from dev into main
CI/CD / test (push) Failing after 49s
CI/CD / sonarqube (push) Skipped
CI/CD / build-and-scan (push) Skipped
CI/CD / deploy (push) Skipped
2026-07-25 07:30:10 +00:00
24 changed files with 1993 additions and 155 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@v4
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@v4
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
+18
View File
@@ -252,3 +252,21 @@ def fetch_treatments_range(start_date: date, end_date: date) -> pd.DataFrame:
since = datetime.combine(start_date, time.min, tzinfo=timezone.utc)
until = datetime.combine(end_date, time.max, tzinfo=timezone.utc)
return _treatments_since(since, until)
def fetch_profile() -> dict:
"""Haal het meest recent gebruikte pompprofiel op.
Nightscout's /api/v1/profile.json geeft profieldocumenten terug, nieuwste
eerst; met count=1 krijgen we alleen het actief gebruikte document. Een
profiel is geen tijdreeks (in tegenstelling tot entries/treatments) dus
geen paginering nodig — één klein verzoek volstaat.
Geeft {} terug als er geen profiel bestaat (bijv. lege Nightscout-instantie).
Het resultaat is het rauwe Nightscout-documument; zie profile_data.parse_profile()
voor de genormaliseerde vorm (basaalschema, ISF, koolhydraatratio, DIA, streefwaarden).
"""
data = _get("/api/v1/profile.json", {"count": 1})
if not data:
return {}
return data[0]
+78 -31
View File
@@ -1,6 +1,8 @@
import pandas as pd
import plotly.graph_objects as go
import requests
import streamlit as st
from plotly.subplots import make_subplots
from auth import require_auth
from data_processing import (
@@ -10,7 +12,15 @@ from data_processing import (
summarize_time_in_range,
)
from nightscout_client import NightscoutConfigError, fetch_entries, fetch_treatments
from ui_common import MMOL_HOVERTEMPLATE, get_colors
from reporting import classify_bolus, effective_basal_series, target_glucose_series
from ui_common import (
MMOL_HOVERTEMPLATE,
add_basal_area_trace,
add_bolus_marker_traces,
add_target_line_trace,
get_colors,
load_profile,
)
st.set_page_config(page_title="Glucose dashboard — Live", page_icon="📊", layout="wide")
require_auth()
@@ -27,7 +37,28 @@ def load_nightscout_data(hours: int):
return glucose, treatments
def build_chart(glucose, insulin, carbs, site_changes, sensor_changes, colors) -> go.Figure:
def _basal_and_target_series(profile: dict, treatments: pd.DataFrame, window_start, window_end):
"""Basaal- en streefwaarde-reeksen over een rollend venster dat meerdere
kalenderdagen kan overspannen: per dag berekend (reporting.effective_basal_series/
target_glucose_series zijn dag-begrensd) en daarna aan elkaar geplakt en
geclipt tot het venster."""
dates = pd.date_range(window_start.normalize(), window_end.normalize(), freq="D")
basal_parts, target_parts = [], []
for day_start in dates:
day_end = day_start + pd.Timedelta(1, unit="D")
basal_parts.append(effective_basal_series(profile, treatments, day_start, day_end))
target_parts.append(target_glucose_series(profile, day_start, day_end))
basal = pd.concat(basal_parts, ignore_index=True) if basal_parts else pd.DataFrame(columns=["timestamp", "rate"])
target = (
pd.concat(target_parts, ignore_index=True) if target_parts else pd.DataFrame(columns=["timestamp", "target"])
)
basal = basal[(basal["timestamp"] >= window_start) & (basal["timestamp"] <= window_end)]
target = target[(target["timestamp"] >= window_start) & (target["timestamp"] <= window_end)]
return basal.reset_index(drop=True), target.reset_index(drop=True)
def build_chart(glucose, bolus, carbs, basal, target, site_changes, sensor_changes, colors) -> go.Figure:
glucose_min = min(glucose["mmol"].min(), TARGET_LOW)
glucose_max = max(glucose["mmol"].max(), TARGET_HIGH)
y_top = glucose_max + 1.0
@@ -36,7 +67,7 @@ def build_chart(glucose, insulin, carbs, site_changes, sensor_changes, colors) -
insulin_lane_y = lane_base + 1.5
carbs_lane_y = lane_base + 0.7
fig = go.Figure()
fig = make_subplots(specs=[[{"secondary_y": True}]])
# Streefbereik als achtergrondband, geen losse legenda-entry nodig.
fig.add_hrect(
@@ -45,8 +76,13 @@ def build_chart(glucose, insulin, carbs, site_changes, sensor_changes, colors) -
fillcolor=colors["band"],
line_width=0,
layer="below",
row=1,
col=1,
)
add_basal_area_trace(fig, basal, colors, row=1, secondary_y=True)
add_target_line_trace(fig, target, colors, row=1)
fig.add_trace(
go.Scatter(
x=glucose["timestamp"],
@@ -55,7 +91,9 @@ def build_chart(glucose, insulin, carbs, site_changes, sensor_changes, colors) -
name="Glucose",
line={"color": colors["glucose"], "width": 2, "shape": "spline"},
hovertemplate=MMOL_HOVERTEMPLATE,
)
),
row=1,
col=1,
)
low_points = glucose[glucose["mmol"] < TARGET_LOW]
@@ -69,7 +107,9 @@ def build_chart(glucose, insulin, carbs, site_changes, sensor_changes, colors) -
name=f"Laag (<{TARGET_LOW})",
marker={"color": colors["low"], "size": 6},
hovertemplate=MMOL_HOVERTEMPLATE,
)
),
row=1,
col=1,
)
if not high_points.empty:
fig.add_trace(
@@ -80,21 +120,12 @@ def build_chart(glucose, insulin, carbs, site_changes, sensor_changes, colors) -
name=f"Hoog (>{TARGET_HIGH})",
marker={"color": colors["high"], "size": 6},
hovertemplate=MMOL_HOVERTEMPLATE,
)
),
row=1,
col=1,
)
if not insulin.empty:
fig.add_trace(
go.Scatter(
x=insulin["timestamp"],
y=[insulin_lane_y] * len(insulin),
mode="markers",
name="Insuline",
marker={"color": colors["insulin"], "size": 10, "symbol": "triangle-up"},
customdata=insulin["insulin"],
hovertemplate="%{x|%d-%m %H:%M} · %{customdata:.1f} E<extra></extra>",
)
)
add_bolus_marker_traces(fig, bolus, colors, insulin_lane_y, row=1)
if not carbs.empty:
fig.add_trace(
@@ -106,14 +137,16 @@ def build_chart(glucose, insulin, carbs, site_changes, sensor_changes, colors) -
marker={"color": colors["carbs"], "size": 10, "symbol": "circle"},
customdata=carbs["carbs"],
hovertemplate="%{x|%d-%m %H:%M} · %{customdata:.0f} g<extra></extra>",
)
),
row=1,
col=1,
)
# Let op: annotation_text/-position via add_vline() zelf triggert een bug
# in plotly's shapeannotation-helper met pandas Timestamp-x-waarden, dus
# de lijn en het label worden hier los toegevoegd.
for _, row in site_changes.iterrows():
fig.add_vline(x=row["timestamp"], line_dash="dot", line_color=colors["muted"], opacity=0.6)
fig.add_vline(x=row["timestamp"], line_dash="dot", line_color=colors["muted"], opacity=0.6, row=1, col=1)
fig.add_annotation(
x=row["timestamp"],
y=y_top,
@@ -121,9 +154,11 @@ def build_chart(glucose, insulin, carbs, site_changes, sensor_changes, colors) -
text="Pomp",
showarrow=False,
font={"color": colors["muted"], "size": 10},
row=1,
col=1,
)
for _, row in sensor_changes.iterrows():
fig.add_vline(x=row["timestamp"], line_dash="dash", line_color=colors["muted"], opacity=0.6)
fig.add_vline(x=row["timestamp"], line_dash="dash", line_color=colors["muted"], opacity=0.6, row=1, col=1)
fig.add_annotation(
x=row["timestamp"],
y=y_top,
@@ -131,6 +166,8 @@ def build_chart(glucose, insulin, carbs, site_changes, sensor_changes, colors) -
text="Sensor",
showarrow=False,
font={"color": colors["muted"], "size": 10},
row=1,
col=1,
)
fig.update_layout(
@@ -140,14 +177,18 @@ def build_chart(glucose, insulin, carbs, site_changes, sensor_changes, colors) -
legend={"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "left", "x": 0},
margin={"l": 10, "r": 10, "t": 10, "b": 10},
height=480,
yaxis={
"title": "mmol/L",
"range": [y_bottom, y_top],
"gridcolor": colors["grid"],
"zeroline": False,
},
xaxis={"gridcolor": colors["grid"]},
)
fig.update_yaxes(
title_text="mmol/L",
range=[y_bottom, y_top],
gridcolor=colors["grid"],
zeroline=False,
row=1,
col=1,
secondary_y=False,
)
fig.update_yaxes(title_text="Basaal (E/uur)", showgrid=False, row=1, col=1, secondary_y=True)
fig.update_xaxes(gridcolor=colors["grid"], row=1, col=1)
return fig
@@ -160,6 +201,7 @@ with st.sidebar:
try:
glucose, treatments = load_nightscout_data(hours)
profile = load_profile()
except NightscoutConfigError as exc:
st.error(str(exc))
st.stop()
@@ -172,7 +214,9 @@ if glucose.empty:
st.stop()
events = classify_treatments(treatments)
bolus = classify_bolus(treatments)
tir = summarize_time_in_range(glucose)
basal, target = _basal_and_target_series(profile, treatments, glucose["timestamp"].min(), glucose["timestamp"].max())
latest = glucose.iloc[-1]
previous = glucose.iloc[-2] if len(glucose) > 1 else None
@@ -192,14 +236,17 @@ colors = get_colors()
fig = build_chart(
glucose,
events["insulin"],
bolus,
events["carbs"],
basal,
target,
events["site_changes"],
events["sensor_changes"],
colors,
)
st.plotly_chart(fig, width="stretch")
st.caption(
"Driehoekjes = insuline · cirkels = koolhydraten · stippellijn = pompwissel · "
"streepjeslijn = sensorwissel"
"Stippen = bolusinsuline (voeding/correctie/SMB, grootte naar dosis) · cirkels = koolhydraten · "
"vlak = basaal · streepstippellijn = streefwaarde uit pompprofiel · "
"stippellijn = pompwissel · streepjeslijn = sensorwissel"
)
+97 -11
View File
@@ -1,14 +1,21 @@
import pandas as pd
import plotly.graph_objects as go
import requests
import streamlit as st
from plotly.subplots import make_subplots
from auth import require_auth
from data_processing import TARGET_HIGH, TARGET_LOW
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 ui_common import (
add_basal_area_trace,
add_bolus_marker_traces,
add_carb_bars_trace,
add_target_line_trace,
add_threshold_lines,
get_colors,
load_profile,
load_range_data,
render_completeness_warning,
select_period_days,
@@ -18,7 +25,7 @@ st.set_page_config(page_title="Glucose dashboard — Dagoverzicht", page_icon="
require_auth()
st.title("Dagoverzicht")
st.caption("Eén glucosegrafiek per kalenderdag (FR4).")
st.caption("Eén glucosegrafiek per kalenderdag, met basaal/bolus/koolhydraten-overlay (FR4).")
start, end, days = select_period_days(default_label="14 dagen")
if days > 31:
@@ -28,7 +35,8 @@ if days > 31:
)
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()
@@ -47,44 +55,122 @@ glucose = glucose.copy()
glucose["date"] = glucose["timestamp"].dt.date
glucose["hour"] = glucose["timestamp"].dt.hour + glucose["timestamp"].dt.minute / 60
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
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
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
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}])
return pd.concat([df, closing_row], ignore_index=True)
dates = sorted(glucose["date"].unique())
row_height = 150
row_height = 190
fig = make_subplots(
rows=len(dates),
cols=1,
shared_xaxes=True,
subplot_titles=[d.strftime("%A %d-%m-%Y") for d in dates],
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")
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,
)
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")
fig.add_trace(
go.Scatter(
x=day_df["hour"],
y=day_df["mmol"],
mode="lines",
line={"color": colors["glucose"], "width": 2},
showlegend=False,
name="Glucose",
hovertemplate="%{y:.1f} mmol/L<extra></extra>",
),
row=i,
col=1,
secondary_y=False,
)
fig.update_yaxes(title_text="mmol/L", gridcolor=colors["grid"], row=i, col=1, secondary_y=False)
fig.update_yaxes(
title_text="Basaal (E/uur) · KH (g)", showgrid=False, row=i, col=1, secondary_y=True
)
fig.update_yaxes(title_text="mmol/L", gridcolor=colors["grid"], row=i, 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(
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font_color=colors["text"],
height=row_height * len(dates),
showlegend=False,
margin={"l": 10, "r": 10, "t": 30, "b": 10},
height=row_height * len(dates) + 40,
showlegend=True,
legend={"orientation": "h", "yanchor": "bottom", "y": 1.0, "xanchor": "left", "x": 0},
margin={"l": 10, "r": 10, "t": 40, "b": 10},
)
st.plotly_chart(fig, width="stretch")
st.caption(f"{len(dates)} dagen · {len(glucose)} metingen")
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"
)
+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",
+98
View File
@@ -0,0 +1,98 @@
import pandas as pd
import plotly.graph_objects as go
import requests
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
st.set_page_config(page_title="Glucose dashboard — Insulineprofiel", page_icon="📊", layout="wide")
require_auth()
st.title("Insulineprofiel")
st.caption("Het meest recent gebruikte pompprofiel: basaalschema, ISF, koolhydraatratio en DIA.")
try:
profile = load_profile()
except NightscoutConfigError as exc:
st.error(str(exc))
st.stop()
except (requests.RequestException, ValueError) as exc:
st.error(f"Kon geen profiel ophalen van Nightscout: {exc}")
st.stop()
if not profile:
st.info("Geen pompprofiel gevonden op deze Nightscout-instantie.")
st.stop()
colors = get_colors()
col1, col2 = st.columns(2)
col1.metric("DIA (werkingsduur insuline)", f"{profile['dia']:.1f} uur")
col2.caption(f"Profielnaam: {profile['name'] or '—'}")
st.subheader("Basaalschema")
bars = segments_to_hourly_bars(profile["basal"])
fig = go.Figure(
go.Scatter(
x=[b["hour"] for b in bars],
y=[b["value"] for b in bars],
mode="lines",
line={"shape": "hv", "color": colors["basal"], "width": 2},
fill="tozeroy",
fillcolor=colors["basal"],
opacity=0.7,
name="Basaal",
hovertemplate="%{x:.0f}:00 · %{y:.2f} E/uur<extra></extra>",
)
)
fig.update_layout(
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font_color=colors["text"],
height=320,
margin={"l": 10, "r": 10, "t": 10, "b": 10},
xaxis={"title": "Uur van de dag", "range": [0, 24], "dtick": 2, "gridcolor": colors["grid"]},
yaxis={"title": "E/uur", "gridcolor": colors["grid"], "rangemode": "tozero"},
showlegend=False,
)
st.plotly_chart(fig, width="stretch")
basal_rows = segments_table(profile["basal"])
st.dataframe(
pd.DataFrame(basal_rows).rename(
columns={"start": "Van", "end": "Tot", "value": "Basaalsnelheid (E/uur)"}
),
width="stretch",
hide_index=True,
)
col_isf, col_ratio = st.columns(2)
with col_isf:
st.subheader("Insulinegevoeligheid (ISF)")
isf_rows = segments_table(profile["sens"])
st.dataframe(
pd.DataFrame(isf_rows).rename(
columns={"start": "Van", "end": "Tot", "value": "ISF (mmol/L per E)"}
),
width="stretch",
hide_index=True,
)
with col_ratio:
st.subheader("Koolhydraatratio")
ratio_rows = segments_table(profile["carbratio"])
st.dataframe(
pd.DataFrame(ratio_rows).rename(
columns={"start": "Van", "end": "Tot", "value": "Ratio (g koolhydraten per E)"}
),
width="stretch",
hide_index=True,
)
st.caption(
f"Totale dagelijkse basaal: {total_daily_basal(profile['basal']):.1f} E "
"(volgens profielschema, exclusief tijdelijke aanpassingen)."
)
+363 -96
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,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)
+146
View File
@@ -0,0 +1,146 @@
"""
Parsing van het Nightscout-pompprofiel (/api/v1/profile): basaalschema,
insulinegevoeligheid (ISF), koolhydraatratio, DIA en streefwaarden.
Framework-vrije module (geen streamlit-import), zelfde opzet als reporting.py:
puur functies op dicts, zodat dit los van de UI te unit-testen is. Zie
nightscout_client.fetch_profile() voor de ruwe fetch; deze module normaliseert
alleen (uren -> minuten sinds middernacht, mg/dl -> mmol/L waar nodig).
"""
from __future__ import annotations
from nightscout_client import mgdl_to_mmol
SEGMENT_FIELDS = ("basal", "sens", "carbratio", "target_low", "target_high")
def _time_to_minutes(time_str: str) -> int:
"""Zet 'HH:MM' (Nightscout's segmenttijd-notatie) om naar minuten sinds middernacht."""
hours, minutes = time_str.split(":")
return int(hours) * 60 + int(minutes)
def _parse_segments(raw_segments: list[dict], *, to_mmol: bool = False) -> list[dict]:
"""Zet Nightscout-segmenten ([{'time': 'HH:MM', 'value': ...}, ...]) om naar
[{'minutes': int, 'value': float}, ...], oplopend gesorteerd op tijd."""
segments = []
for seg in raw_segments:
value = float(seg["value"])
if to_mmol:
value = mgdl_to_mmol(value)
segments.append({"minutes": _time_to_minutes(seg["time"]), "value": value})
return sorted(segments, key=lambda s: s["minutes"])
def parse_profile(raw: dict) -> dict:
"""Parse één Nightscout-profieldocument (zoals teruggegeven door
nightscout_client.fetch_profile()) naar een genormaliseerde dict:
{
"name": str,
"units": "mmol", # altijd genormaliseerd, ongeacht bronformaat
"dia": float, # uur
"basal": [{"minutes": int, "value": float}, ...], # E/uur
"sens": [{"minutes": int, "value": float}, ...], # mmol/L per E (ISF)
"carbratio": [{"minutes": int, "value": float}, ...], # g koolhydraten per E
"target_low": [{"minutes": int, "value": float}, ...], # mmol/L
"target_high": [{"minutes": int, "value": float}, ...], # mmol/L
}
Geeft {} terug als er geen (bruikbaar) profiel is.
"""
if not raw:
return {}
default_name = raw.get("defaultProfile")
store = raw.get("store", {})
profile = store.get(default_name) if default_name else None
if profile is None and store:
# defaultProfile ontbreekt of matcht niet met een sleutel in store:
# val terug op het enige/eerste beschikbare profiel.
profile = next(iter(store.values()))
if profile is None:
return {}
units = str(profile.get("units", "mg/dl")).strip().lower()
to_mmol = units != "mmol"
return {
"name": default_name or "",
"units": "mmol",
"dia": float(profile.get("dia", 0) or 0),
"basal": _parse_segments(profile.get("basal", [])),
"sens": _parse_segments(profile.get("sens", []), to_mmol=to_mmol),
"carbratio": _parse_segments(profile.get("carbratio", [])),
"target_low": _parse_segments(profile.get("target_low", []), to_mmol=to_mmol),
"target_high": _parse_segments(profile.get("target_high", []), to_mmol=to_mmol),
}
def value_at(segments: list[dict], minutes: int) -> float | None:
"""Geef de waarde terug die actief is op het gegeven tijdstip (minuten
sinds middernacht) — het laatste segment met `minutes <= t`. Ligt `t` vóór
het eerste segment van de dag, dan geldt het laatste segment (dat loopt
door vanaf de vorige dag, want een profiel dekt altijd de volle 24 uur).
Geeft None terug als `segments` leeg is.
"""
if not segments:
return None
active = None
for seg in segments:
if seg["minutes"] <= minutes:
active = seg
else:
break
if active is None:
active = segments[-1]
return active["value"]
def _format_time(minutes: int) -> str:
"""Zet minuten sinds middernacht om naar 'HH:MM'."""
return f"{minutes // 60:02d}:{minutes % 60:02d}"
def segments_table(segments: list[dict]) -> list[dict]:
"""Zet segmenten om naar rijen met een start- en eindtijd voor tabelweergave:
[{'start': 'HH:MM', 'end': 'HH:MM', 'value': float}, ...]. Het laatste
segment loopt door tot 24:00 (een profiel dekt altijd de volle dag)."""
rows = []
for i, seg in enumerate(segments):
end_minutes = segments[i + 1]["minutes"] if i + 1 < len(segments) else 24 * 60
rows.append(
{
"start": _format_time(seg["minutes"]),
"end": "24:00" if end_minutes == 24 * 60 else _format_time(end_minutes),
"value": seg["value"],
}
)
return rows
def total_daily_basal(segments: list[dict]) -> float:
"""Totale basaaldosis per dag in E: som van elk segment se duur (uur) x snelheid (E/uur)."""
if not segments:
return 0.0
total = 0.0
for i, seg in enumerate(segments):
end_minutes = segments[i + 1]["minutes"] if i + 1 < len(segments) else 24 * 60
total += seg["value"] * (end_minutes - seg["minutes"]) / 60
return total
def segments_to_hourly_bars(segments: list[dict]) -> list[dict]:
"""Zet segmenten om naar een lijst geschikt voor een staafgrafiek per uur van
de dag: [{'hour': float, 'value': float}, ...], met een extra punt op uur 24
(gelijk aan het eerste segment) zodat een staaf/stap-grafiek de volle dag dekt.
Gebruikt door de basaalgrafiek op de Insulineprofiel-pagina.
"""
if not segments:
return []
bars = [{"hour": seg["minutes"] / 60, "value": seg["value"]} for seg in segments]
bars.append({"hour": 24.0, "value": segments[0]["value"]})
return bars
+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
+228
View File
@@ -15,6 +15,7 @@ import numpy as np
import pandas as pd
from nightscout_client import INSULIN_EVENT_TYPES
from profile_data import value_at
# Klinische drempels in mmol/L (Battelino et al. 2019, Diabetes Care 42(8):1593-1603).
VERY_LOW = 3.0
@@ -440,6 +441,233 @@ def bolus_stats(treatments: pd.DataFrame) -> dict:
}
BOLUS_CATEGORY_FOOD = "food"
BOLUS_CATEGORY_CORRECTION = "correction"
BOLUS_CATEGORY_SMB = "smb"
BOLUS_CATEGORY_COLUMNS = ["timestamp", "eventType", "insulin", "carbs", "category"]
def classify_bolus(treatments: pd.DataFrame) -> pd.DataFrame:
"""Splits bolus-insuline op in 'food'/'correction'/'smb', voor grafiekoverlays
(gitea insulineprofiel-werk, vervolg op bolus_stats()'s SMB-detectie).
Herkenningsregels, in volgorde:
- SMB: `eventType == "SMB"`, of `eventType == "Correction Bolus"` met
`type == "SMB"` (zie bolus_stats()) — automatische AndroidAPS-microbolus.
- food: `eventType == "Meal Bolus"`, of een andere bolus-eventType
(`Bolus`/`Combo Bolus`) met koolhydraten op dezelfde treatment-rij (een
sterke aanwijzing dat het om een maaltijdbolus gaat).
- correction: alle overige bolus-rijen (typisch `Correction Bolus` zonder
SMB-markering, of een generieke `Bolus`/`Combo Bolus` zonder koolhydraten).
Geeft een DataFrame terug met kolommen 'timestamp', 'eventType', 'insulin',
'carbs', 'category' — alleen de rijen die daadwerkelijk bolusinsuline zijn.
"""
if treatments.empty:
return pd.DataFrame(columns=BOLUS_CATEGORY_COLUMNS)
bolus = treatments[treatments["eventType"].isin(INSULIN_EVENT_TYPES) & treatments["insulin"].notna()].copy()
if bolus.empty:
return pd.DataFrame(columns=BOLUS_CATEGORY_COLUMNS)
is_smb = (bolus["eventType"] == SMB_TYPE) | (
(bolus["eventType"] == "Correction Bolus") & (bolus.get("type", "") == SMB_TYPE)
)
has_carbs = pd.to_numeric(bolus["carbs"], errors="coerce").fillna(0) > 0
is_food = ~is_smb & ((bolus["eventType"] == "Meal Bolus") | has_carbs)
bolus["category"] = BOLUS_CATEGORY_CORRECTION
bolus.loc[is_food, "category"] = BOLUS_CATEGORY_FOOD
bolus.loc[is_smb, "category"] = BOLUS_CATEGORY_SMB
return bolus[BOLUS_CATEGORY_COLUMNS].sort_values("timestamp").reset_index(drop=True)
def glucose_min_max(glucose: pd.DataFrame) -> tuple[float, float]:
"""Minimum en maximum glucosewaarde (mmol/L) in de gegeven data."""
if glucose.empty:
return (0.0, 0.0)
return (round(glucose["mmol"].min(), 1), round(glucose["mmol"].max(), 1))
def _temp_basal_intervals(treatments: pd.DataFrame, day_start, day_end) -> list[tuple]:
"""Temp Basal-intervallen (start, eind, rate) die (deels) binnen [day_start, day_end) vallen,
geclipt aan de daggrenzen."""
if treatments is None or treatments.empty:
return []
temp_basal = treatments[
(treatments["eventType"] == BASAL_EVENT_TYPE)
& treatments["absolute"].notna()
& treatments["duration"].notna()
]
intervals = []
for _, row in temp_basal.iterrows():
start = row["timestamp"]
end = start + pd.Timedelta(float(row["duration"]), unit="min")
if end <= day_start or start >= day_end:
continue
intervals.append((max(start, day_start), min(end, day_end), row["absolute"]))
return intervals
def _basal_breakpoints(basal_segments: list[dict], temp_intervals: list[tuple], day_start, day_end) -> list:
"""Alle tijdstippen waarop de effectieve basaalsnelheid kan wijzigen: daggrens,
profielsegment-grenzen, en temp-basal start/eind — oplopend gesorteerd."""
breakpoints = {day_start}
for seg in basal_segments:
ts = day_start + pd.Timedelta(float(seg["minutes"]), unit="min")
if day_start <= ts < day_end:
breakpoints.add(ts)
for start, end, _ in temp_intervals:
breakpoints.add(start)
breakpoints.add(end)
return sorted(t for t in breakpoints if day_start <= t < day_end)
def _rate_at(ts, basal_segments: list[dict], temp_intervals: list[tuple], day_start) -> float:
"""Effectieve basaalsnelheid op tijdstip `ts`: een actieve Temp Basal wint van het profieltempo."""
for start, end, rate in temp_intervals:
if start <= ts < end:
return rate
minutes_of_day = int((ts - day_start).total_seconds() / 60) % MINUTES_PER_DAY
value = value_at(basal_segments, minutes_of_day)
return value if value is not None else 0.0
def effective_basal_series(profile: dict | None, treatments: pd.DataFrame, day_start, day_end) -> pd.DataFrame:
"""Basaalsnelheid (E/uur) als stapfunctie over [day_start, day_end).
Basislijn is het pompprofiel se basaalschema (`profile["basal"]`); waar een
Temp Basal-treatment actief is (rate/duur uit de treatment) overschrijft
die het profieltempo voor de duur van die tijdelijke aanpassing — dit is de
daadwerkelijk afgeleverde basaal, in tegenstelling tot basal_stats() (die
alleen temp-basal-periodes optelt en periodes zonder actieve temp basal
mist omdat het profiel daar niet bij betrokken wordt).
Zonder profiel (`profile` is None/leeg) is de basislijn 0 — dan toont dit
alleen de temp-basal-periodes, net als basal_stats().
Geeft een DataFrame terug met kolommen 'timestamp' en 'rate': elke rij
geldt tot de volgende rij (stapfunctie), geschikt voor een
step-area-plot (line_shape='hv', fill='tozeroy').
"""
day_start = pd.Timestamp(day_start)
day_end = pd.Timestamp(day_end)
basal_segments = (profile or {}).get("basal", [])
temp_intervals = _temp_basal_intervals(treatments, day_start, day_end)
breakpoints = _basal_breakpoints(basal_segments, temp_intervals, day_start, day_end)
rows = [
{"timestamp": ts, "rate": _rate_at(ts, basal_segments, temp_intervals, day_start)} for ts in breakpoints
]
return pd.DataFrame(rows, columns=["timestamp", "rate"])
def _target_midpoint_at(minutes_of_day: int, target_low: list[dict], target_high: list[dict]) -> float | None:
low = value_at(target_low, minutes_of_day) if target_low else None
high = value_at(target_high, minutes_of_day) if target_high else None
if low is not None and high is not None:
return (low + high) / 2
return low if low is not None else high
def target_glucose_series(profile: dict | None, day_start, day_end) -> pd.DataFrame:
"""Streefglucose (mmol/L) uit het profiel als stapfunctie over [day_start, day_end) —
het midden van target_low/target_high per tijdsegment (de pomp kent vaak één
streefwaarde per segment; met losse low/high-segmenten is dat gewoon het
gemiddelde). Geeft een lege DataFrame terug als het profiel geen streefwaarden
bevat.
Zelfde stapfunctie-vorm als effective_basal_series(): elke rij geldt tot de
volgende rij.
"""
day_start = pd.Timestamp(day_start)
day_end = pd.Timestamp(day_end)
target_low = (profile or {}).get("target_low", [])
target_high = (profile or {}).get("target_high", [])
if not target_low and not target_high:
return pd.DataFrame(columns=["timestamp", "target"])
breakpoints = _basal_breakpoints(target_low + target_high, [], day_start, day_end)
rows = [
{
"timestamp": ts,
"target": _target_midpoint_at(
int((ts - day_start).total_seconds() / 60) % MINUTES_PER_DAY, target_low, target_high
),
}
for ts in breakpoints
]
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
+23
View File
@@ -357,6 +357,29 @@ def test_fetch_treatments_range_retrieves_full_history_even_with_a_hidden_server
assert result["timestamp"].max().date() == end_date
def test_fetch_profile_returns_first_document(monkeypatch):
monkeypatch.setenv("NIGHTSCOUT_URL", "https://example.com")
payload = [{"defaultProfile": "Default", "store": {"Default": {"dia": "5"}}}]
seen_params = []
monkeypatch.setattr(
ns.requests,
"get",
lambda *a, **k: (seen_params.append(k["params"]), FakeResponse(payload))[1],
)
result = ns.fetch_profile()
assert result == payload[0]
assert seen_params[0]["count"] == 1
def test_fetch_profile_handles_empty_response(monkeypatch):
monkeypatch.setenv("NIGHTSCOUT_URL", "https://example.com")
monkeypatch.setattr(ns.requests, "get", lambda *a, **k: FakeResponse([]))
assert ns.fetch_profile() == {}
def test_paginated_get_uses_created_at_field_for_treatments_cursor(monkeypatch):
"""Losstaande, gerichte assertie op het daadwerkelijke queryparameter-veld
— moet `find[created_at][$lte]` zijn, nooit `find[date][$lte]`, voor het
+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")
+117
View File
@@ -0,0 +1,117 @@
import pytest
import profile_data as pd_
def _raw_profile(units="mg/dl", default_name="Default"):
return {
"defaultProfile": default_name,
"store": {
"Default": {
"units": units,
"dia": "5",
"basal": [
{"time": "00:00", "value": "0.8"},
{"time": "06:00", "value": "1.2"},
{"time": "22:00", "value": "0.7"},
],
"sens": [{"time": "00:00", "value": "50"}],
"carbratio": [{"time": "00:00", "value": "10"}],
"target_low": [{"time": "00:00", "value": "90"}],
"target_high": [{"time": "00:00", "value": "120"}],
}
},
}
def test_parse_profile_returns_empty_dict_for_empty_input():
assert pd_.parse_profile({}) == {}
def test_parse_profile_converts_mgdl_to_mmol():
profile = pd_.parse_profile(_raw_profile(units="mg/dl"))
assert profile["units"] == "mmol"
# 90 mg/dl -> ~5.0 mmol/L, 120 mg/dl -> ~6.7 mmol/L, 50 mg/dl (ISF) -> ~2.8 mmol/L
assert profile["target_low"][0]["value"] == pytest.approx(5.0, abs=0.05)
assert profile["target_high"][0]["value"] == pytest.approx(6.7, abs=0.05)
assert profile["sens"][0]["value"] == pytest.approx(2.8, abs=0.05)
def test_parse_profile_leaves_mmol_profiles_unconverted():
profile = pd_.parse_profile(_raw_profile(units="mmol"))
assert profile["target_low"][0]["value"] == pytest.approx(90.0)
def test_parse_profile_does_not_convert_basal_or_carbratio():
profile = pd_.parse_profile(_raw_profile(units="mg/dl"))
assert profile["basal"][0]["value"] == pytest.approx(0.8)
assert profile["carbratio"][0]["value"] == pytest.approx(10.0)
def test_parse_profile_sorts_basal_segments_by_time():
raw = _raw_profile()
raw["store"]["Default"]["basal"] = [
{"time": "22:00", "value": "0.7"},
{"time": "00:00", "value": "0.8"},
{"time": "06:00", "value": "1.2"},
]
profile = pd_.parse_profile(raw)
assert [seg["minutes"] for seg in profile["basal"]] == [0, 360, 1320]
def test_parse_profile_falls_back_to_first_profile_when_default_name_missing():
raw = _raw_profile(default_name="DoesNotExist")
profile = pd_.parse_profile(raw)
assert profile["dia"] == pytest.approx(5.0)
def test_parse_profile_dia_and_name():
profile = pd_.parse_profile(_raw_profile())
assert profile["dia"] == pytest.approx(5.0)
assert profile["name"] == "Default"
def test_value_at_returns_active_segment():
segments = [{"minutes": 0, "value": 0.8}, {"minutes": 360, "value": 1.2}, {"minutes": 1320, "value": 0.7}]
assert pd_.value_at(segments, 100) == pytest.approx(0.8)
assert pd_.value_at(segments, 360) == pytest.approx(1.2)
assert pd_.value_at(segments, 1000) == pytest.approx(1.2)
assert pd_.value_at(segments, 1350) == pytest.approx(0.7)
def test_value_at_wraps_to_last_segment_before_first():
segments = [{"minutes": 360, "value": 1.2}, {"minutes": 1320, "value": 0.7}]
assert pd_.value_at(segments, 100) == pytest.approx(0.7)
def test_value_at_empty_segments_returns_none():
assert pd_.value_at([], 100) is None
def test_segments_table_computes_end_times_from_next_segment():
segments = [{"minutes": 0, "value": 0.8}, {"minutes": 360, "value": 1.2}]
rows = pd_.segments_table(segments)
assert rows == [
{"start": "00:00", "end": "06:00", "value": 0.8},
{"start": "06:00", "end": "24:00", "value": 1.2},
]
def test_segments_to_hourly_bars_closes_the_24h_loop():
segments = [{"minutes": 0, "value": 0.8}, {"minutes": 360, "value": 1.2}]
bars = pd_.segments_to_hourly_bars(segments)
assert bars[-1] == {"hour": 24.0, "value": 0.8}
def test_segments_to_hourly_bars_empty_input():
assert pd_.segments_to_hourly_bars([]) == []
def test_total_daily_basal_weights_by_segment_duration():
# 6h @ 0.8 + 18h @ 1.2 = 4.8 + 21.6 = 26.4
segments = [{"minutes": 0, "value": 0.8}, {"minutes": 360, "value": 1.2}]
assert pd_.total_daily_basal(segments) == pytest.approx(26.4)
def test_total_daily_basal_empty_input():
assert pd_.total_daily_basal([]) == 0.0
+208
View File
@@ -8,10 +8,14 @@ from reporting import (
agp_percentile_bands,
basal_stats,
bolus_stats,
classify_bolus,
completeness_warning,
daily_gmi,
daily_stats,
day_of_week_pattern,
detect_events,
effective_basal_series,
glucose_min_max,
gmi_mmol_per_mol,
gmi_percent,
gmi_trend,
@@ -21,6 +25,7 @@ from reporting import (
sensor_active_percent,
site_interval_stats,
summarize_events,
target_glucose_series,
tir_breakdown_5band,
)
@@ -480,6 +485,209 @@ def test_basal_stats_handles_empty_input():
assert result == {"total_units": 0.0, "avg_units_per_day": 0.0}
def test_glucose_min_max_returns_min_and_max():
glucose = _series([("2026-01-01 00:00", 6.5), ("2026-01-01 01:00", 3.2), ("2026-01-01 02:00", 14.1)])
assert glucose_min_max(glucose) == (3.2, 14.1)
def test_glucose_min_max_handles_empty_input():
empty = pd.DataFrame(columns=["timestamp", "mmol"])
assert glucose_min_max(empty) == (0.0, 0.0)
def _bolus_treatment(dt, event_type, insulin=1.0, carbs=None, bolus_type=""):
return {
"timestamp": pd.Timestamp(dt),
"eventType": event_type,
"insulin": insulin,
"carbs": carbs,
"type": bolus_type,
}
def test_classify_bolus_recognizes_meal_bolus_as_food():
treatments = pd.DataFrame([_bolus_treatment("2026-01-01 08:00", "Meal Bolus", insulin=4.0)])
result = classify_bolus(treatments)
assert result.iloc[0]["category"] == "food"
def test_classify_bolus_recognizes_smb_via_type_field():
treatments = pd.DataFrame(
[_bolus_treatment("2026-01-01 08:00", "Correction Bolus", insulin=0.3, bolus_type="SMB")]
)
result = classify_bolus(treatments)
assert result.iloc[0]["category"] == "smb"
def test_classify_bolus_recognizes_smb_via_event_type():
treatments = pd.DataFrame([_bolus_treatment("2026-01-01 08:00", "SMB", insulin=0.3)])
result = classify_bolus(treatments)
assert result.iloc[0]["category"] == "smb"
def test_classify_bolus_treats_correction_bolus_without_carbs_as_correction():
treatments = pd.DataFrame([_bolus_treatment("2026-01-01 08:00", "Correction Bolus", insulin=1.5)])
result = classify_bolus(treatments)
assert result.iloc[0]["category"] == "correction"
def test_classify_bolus_treats_generic_bolus_with_carbs_as_food():
treatments = pd.DataFrame([_bolus_treatment("2026-01-01 08:00", "Bolus", insulin=3.0, carbs=40.0)])
result = classify_bolus(treatments)
assert result.iloc[0]["category"] == "food"
def test_classify_bolus_ignores_non_bolus_rows():
treatments = pd.DataFrame(
[
_bolus_treatment("2026-01-01 08:00", "Meal Bolus", insulin=4.0),
{"timestamp": pd.Timestamp("2026-01-01 08:05"), "eventType": "Site Change", "insulin": None, "carbs": None, "type": ""},
]
)
result = classify_bolus(treatments)
assert len(result) == 1
def test_classify_bolus_handles_empty_input():
empty = pd.DataFrame(columns=["timestamp", "eventType", "insulin", "carbs", "type"])
result = classify_bolus(empty)
assert result.empty
assert list(result.columns) == ["timestamp", "eventType", "insulin", "carbs", "category"]
def test_effective_basal_series_uses_profile_baseline_without_temp_basal():
profile = {"basal": [{"minutes": 0, "value": 0.8}, {"minutes": 360, "value": 1.2}]}
empty_treatments = pd.DataFrame(columns=["timestamp", "eventType", "absolute", "duration"])
result = effective_basal_series(
profile, empty_treatments, pd.Timestamp("2026-01-01"), pd.Timestamp("2026-01-02")
)
assert result.iloc[0]["rate"] == pytest.approx(0.8)
assert result[result["timestamp"] == pd.Timestamp("2026-01-01 06:00")].iloc[0]["rate"] == pytest.approx(1.2)
def test_effective_basal_series_temp_basal_overrides_profile_rate():
profile = {"basal": [{"minutes": 0, "value": 0.8}]}
treatments = pd.DataFrame(
[{"timestamp": pd.Timestamp("2026-01-01 08:00"), "eventType": "Temp Basal", "absolute": 2.0, "duration": 30}]
)
result = effective_basal_series(profile, treatments, pd.Timestamp("2026-01-01"), pd.Timestamp("2026-01-02"))
during_temp = result[result["timestamp"] == pd.Timestamp("2026-01-01 08:00")].iloc[0]
after_temp = result[result["timestamp"] == pd.Timestamp("2026-01-01 08:30")].iloc[0]
assert during_temp["rate"] == pytest.approx(2.0)
assert after_temp["rate"] == pytest.approx(0.8) # terug naar het profieltempo
def test_effective_basal_series_without_profile_defaults_to_zero_baseline():
empty_treatments = pd.DataFrame(columns=["timestamp", "eventType", "absolute", "duration"])
result = effective_basal_series(None, empty_treatments, pd.Timestamp("2026-01-01"), pd.Timestamp("2026-01-02"))
assert result.iloc[0]["rate"] == pytest.approx(0.0)
def test_effective_basal_series_clips_temp_basal_to_day_boundaries():
profile = {"basal": [{"minutes": 0, "value": 0.8}]}
# Temp basal begint de dag ervoor en loopt door tot 01:00 van de dag zelf.
treatments = pd.DataFrame(
[{"timestamp": pd.Timestamp("2025-12-31 23:00"), "eventType": "Temp Basal", "absolute": 2.0, "duration": 120}]
)
result = effective_basal_series(profile, treatments, pd.Timestamp("2026-01-01"), pd.Timestamp("2026-01-02"))
assert result.iloc[0]["timestamp"] == pd.Timestamp("2026-01-01")
assert result.iloc[0]["rate"] == pytest.approx(2.0)
def test_target_glucose_series_averages_low_and_high():
profile = {
"target_low": [{"minutes": 0, "value": 4.5}],
"target_high": [{"minutes": 0, "value": 8.5}],
}
result = target_glucose_series(profile, pd.Timestamp("2026-01-01"), pd.Timestamp("2026-01-02"))
assert result.iloc[0]["target"] == pytest.approx(6.5)
def test_target_glucose_series_falls_back_to_single_bound():
profile = {"target_low": [{"minutes": 0, "value": 5.0}], "target_high": []}
result = target_glucose_series(profile, pd.Timestamp("2026-01-01"), pd.Timestamp("2026-01-02"))
assert result.iloc[0]["target"] == pytest.approx(5.0)
def test_target_glucose_series_empty_without_targets_in_profile():
result = target_glucose_series({}, pd.Timestamp("2026-01-01"), pd.Timestamp("2026-01-02"))
assert result.empty
def test_target_glucose_series_handles_none_profile():
result = target_glucose_series(None, pd.Timestamp("2026-01-01"), pd.Timestamp("2026-01-02"))
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(
[
+148
View File
@@ -1,14 +1,38 @@
from types import SimpleNamespace
import pandas as pd
import plotly.graph_objects as go
import streamlit as st
from ui_common import (
PALETTE,
THRESHOLD_LINES,
add_basal_area_trace,
add_bolus_marker_traces,
add_carb_bars_trace,
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)"
@@ -36,3 +60,127 @@ def test_build_tir_stacked_bar_figure_has_one_bar_trace_per_band():
assert len(fig.data) == 5
assert [trace.y[0] for trace in fig.data] == [1.0, 3.0, 80.0, 12.0, 4.0]
assert fig.layout.barmode == "stack"
def test_add_basal_area_trace_adds_one_filled_step_trace():
basal = pd.DataFrame(
{"timestamp": pd.to_datetime(["2026-01-01 00:00", "2026-01-01 06:00"]), "rate": [0.8, 1.2]}
)
fig = go.Figure()
add_basal_area_trace(fig, basal, PALETTE["light"])
assert len(fig.data) == 1
assert fig.data[0].name == "Basaal"
assert fig.data[0].fill == "tozeroy"
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()
add_carb_bars_trace(fig, carbs, PALETTE["light"])
assert len(fig.data) == 1
assert fig.data[0].name == "Koolhydraten"
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(
{
"timestamp": pd.to_datetime(["2026-01-01 08:00", "2026-01-01 12:00"]),
"insulin": [4.0, 0.3],
"category": ["food", "smb"],
}
)
fig = go.Figure()
add_bolus_marker_traces(fig, bolus, PALETTE["light"], lane_y=2.0)
names = {trace.name for trace in fig.data}
assert names == {"Bolus (voeding)", "SMB"}
def test_add_bolus_marker_traces_labels_food_and_correction_but_not_smb():
bolus = pd.DataFrame(
{
"timestamp": pd.to_datetime(["2026-01-01 08:00", "2026-01-01 12:00"]),
"insulin": [4.0, 0.3],
"category": ["food", "smb"],
}
)
fig = go.Figure()
add_bolus_marker_traces(fig, bolus, PALETTE["light"], lane_y=2.0)
by_name = {trace.name: trace for trace in fig.data}
assert by_name["Bolus (voeding)"].mode == "markers+text"
assert by_name["SMB"].mode == "markers"
def test_add_bolus_marker_traces_skips_empty_input():
fig = go.Figure()
add_bolus_marker_traces(fig, pd.DataFrame(columns=["timestamp", "insulin", "category"]), PALETTE["light"], 2.0)
assert len(fig.data) == 0
def test_add_target_line_trace_adds_one_line_trace():
target = pd.DataFrame({"timestamp": pd.to_datetime(["2026-01-01 00:00"]), "target": [6.5]})
fig = go.Figure()
add_target_line_trace(fig, target, PALETTE["light"])
assert len(fig.data) == 1
assert fig.data[0].name == "Streefwaarde (pomp)"
def test_add_target_line_trace_skips_empty_input():
fig = go.Figure()
add_target_line_trace(fig, pd.DataFrame(columns=["timestamp", "target"]), PALETTE["light"])
assert len(fig.data) == 0
def test_overlay_traces_support_an_hour_based_x_column_for_dagoverzicht():
"""Dagoverzicht plot elke dag op een gedeelde 0-24u-as ('hour') i.p.v. echte
datums; de hover mag daar dan ook geen datumformattering tonen."""
basal = pd.DataFrame({"hour": [0.0, 6.0], "rate": [0.8, 1.2]})
carbs = pd.DataFrame({"hour": [8.0], "carbs": [40.0]})
bolus = pd.DataFrame({"hour": [8.0], "insulin": [4.0], "category": ["food"]})
target = pd.DataFrame({"hour": [0.0], "target": [6.5]})
fig = go.Figure()
add_basal_area_trace(fig, basal, PALETTE["light"], x_col="hour")
add_carb_bars_trace(fig, carbs, PALETTE["light"], x_col="hour")
add_bolus_marker_traces(fig, bolus, PALETTE["light"], lane_y=2.0, x_col="hour")
add_target_line_trace(fig, target, PALETTE["light"], x_col="hour")
assert len(fig.data) == 4
bolus_trace = next(t for t in fig.data if t.name == "Bolus (voeding)")
assert list(bolus_trace.x) == [8.0]
assert "%d-%m" not in bolus_trace.hovertemplate
+200 -9
View File
@@ -14,7 +14,8 @@ import pandas as pd
import plotly.graph_objects as go
import streamlit as st
from nightscout_client import fetch_entries_range, fetch_treatments_range
from nightscout_client import fetch_entries_range, fetch_profile, fetch_treatments_range
from profile_data import parse_profile
from reporting import (
HIGH,
LOW,
@@ -57,6 +58,11 @@ PALETTE = {
"tir_in_range": "#0ca30c",
"tir_high": "#fab219",
"tir_very_high": "#c76f0a",
"basal": "#7b5ea3",
"bolus_food": "#1baf7a",
"bolus_correction": "#2a78d6",
"bolus_smb": "#898781",
"target": "#52514e",
},
"dark": {
"glucose": "#3987e5",
@@ -76,6 +82,11 @@ PALETTE = {
"tir_in_range": "#0ca30c",
"tir_high": "#fab219",
"tir_very_high": "#d9852e",
"basal": "#9b7ec2",
"bolus_food": "#199e70",
"bolus_correction": "#3987e5",
"bolus_smb": "#898781",
"target": "#c3c2b7",
},
}
@@ -107,8 +118,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]
@@ -118,17 +133,34 @@ 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=21600, show_spinner="Profiel ophalen van Nightscout...")
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.
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)
def add_threshold_lines(fig: go.Figure, colors: dict, row: int | None = None) -> None:
"""Teken de 4 klinische drempellijnen (3.0/3.9/10.0/13.9 mmol/L) op een grafiek.
@@ -189,19 +221,175 @@ def build_tir_stacked_bar_figure(tir: dict, colors: dict) -> go.Figure:
return fig
def render_completeness_warning(glucose: pd.DataFrame, start: date, end: date) -> None:
BOLUS_CATEGORY_LABELS = {
"food": "Bolus (voeding)",
"correction": "Bolus (correctie)",
"smb": "SMB",
}
BOLUS_CATEGORY_COLOR_KEYS = {
"food": "bolus_food",
"correction": "bolus_correction",
"smb": "bolus_smb",
}
# Tekenvolgorde van bolus-categorieën: SMB eerst (onderop, meestal talrijker en
# kleiner), voeding/correctie erbovenop (grotere, gelabelde stippen blijven
# zichtbaar in plaats van bedolven te raken onder de SMB-stippen).
BOLUS_CATEGORY_DRAW_ORDER = ["smb", "correction", "food"]
def add_basal_area_trace(
fig: go.Figure,
basal: pd.DataFrame,
colors: dict,
*,
row: int | None = None,
secondary_y: bool = False,
x_col: str = "timestamp",
) -> None:
"""Basaal-insuline als semi-transparant stapvlak (line_shape='hv'), met alpha
zodat onderliggende data zichtbaar blijft. Verwacht kolommen 'rate' en
`x_col` (standaard 'timestamp'; de Dagoverzicht-pagina geeft hier 'hour'
aan omdat elke dag daar op een gedeelde 0-24u-as staat i.p.v. echte
datums — zie reporting.effective_basal_series). Bedoeld voor een
secundaire y-as (E/uur past niet op de mmol/L-schaal van de glucoselijn).
"""
if basal.empty:
return
kwargs: dict = {"row": row, "col": 1} if row is not None else {}
if secondary_y:
kwargs["secondary_y"] = True
fig.add_trace(
go.Scatter(
x=basal[x_col],
y=basal["rate"],
mode="lines",
line={"shape": "hv", "color": colors["basal"], "width": 1},
fill="tozeroy",
fillcolor=hex_to_rgba(colors["basal"], 0.3),
opacity=0.8,
name="Basaal",
hovertemplate="%{y:.2f} E/uur<extra></extra>",
),
**kwargs,
)
def add_carb_bars_trace(
fig: go.Figure,
carbs: pd.DataFrame,
colors: dict,
*,
row: int | None = None,
secondary_y: bool = False,
x_col: str = "timestamp",
) -> None:
"""Koolhydraten als staafjes op het moment van invoer. Verwacht kolommen
'carbs' en `x_col` (zie add_basal_area_trace voor waarom dit configureerbaar
is). Zelfde secundaire as als de basaal-area (allebei geen mmol/L-grootheid),
met een eigen kleur en alpha zodat beide onderscheidbaar blijven."""
if carbs.empty:
return
kwargs: dict = {"row": row, "col": 1} if row is not None else {}
if secondary_y:
kwargs["secondary_y"] = True
fig.add_trace(
go.Bar(
x=carbs[x_col],
y=carbs["carbs"],
marker_color=colors["carbs"],
opacity=0.55,
name="Koolhydraten",
hovertemplate="%{y:.0f} g<extra></extra>",
),
**kwargs,
)
def add_bolus_marker_traces(
fig: go.Figure,
bolus: pd.DataFrame,
colors: dict,
lane_y: float,
*,
row: int | None = None,
x_col: str = "timestamp",
) -> None:
"""Eén Scatter-trace per boluscategorie (food/correction/smb), gepositioneerd
op een vaste 'lane'-hoogte op de primaire (mmol/L-)as — net als de bestaande
insuline/carbs-lanes op de Live-pagina. De hoogte zelf draagt geen
kwantitatieve betekenis; de markergrootte (geschaald naar toegediende
eenheden) en hoverwaarde wel. Voeding/correctie krijgen een tekstlabel met
de dosis; SMB's niet (te talrijk/te klein voor leesbare labels). Verwacht
kolommen 'insulin'/'category' en `x_col` (zie reporting.classify_bolus).
"""
# Datumformattering in de hover werkt alleen op een echte datetime-as; de
# Dagoverzicht-pagina geeft hier 'hour' (een kaal getal 0-24) aan, dus
# daar laten we het tijdstip weg en toont de hover alleen de dosis.
hover_prefix = "%{x|%d-%m %H:%M} · " if x_col == "timestamp" else ""
kwargs: dict = {"row": row, "col": 1} if row is not None else {}
for category in BOLUS_CATEGORY_DRAW_ORDER:
subset = bolus[bolus["category"] == category] if not bolus.empty else bolus
if subset.empty:
continue
show_label = category != "smb"
fig.add_trace(
go.Scatter(
x=subset[x_col],
y=[lane_y] * len(subset),
mode="markers+text" if show_label else "markers",
marker={
"size": (subset["insulin"] * 8).clip(lower=6, upper=30),
"color": colors[BOLUS_CATEGORY_COLOR_KEYS[category]],
"opacity": 0.75,
},
text=[f"{v:.1f}E" for v in subset["insulin"]] if show_label else None,
textposition="top center",
textfont={"size": 9, "color": colors["text"]},
name=BOLUS_CATEGORY_LABELS[category],
customdata=subset["insulin"],
hovertemplate=f"{hover_prefix}%{{customdata:.2f}} E<extra></extra>",
),
**kwargs,
)
def add_target_line_trace(
fig: go.Figure, target: pd.DataFrame, colors: dict, *, row: int | None = None, x_col: str = "timestamp"
) -> None:
"""Streefglucose uit het pompprofiel als lijn op de primaire (mmol/L-)as.
Verwacht kolommen 'target' en `x_col` (zie reporting.target_glucose_series)."""
if target.empty:
return
kwargs: dict = {"row": row, "col": 1} if row is not None else {}
fig.add_trace(
go.Scatter(
x=target[x_col],
y=target["target"],
mode="lines",
line={"shape": "hv", "color": colors["target"], "width": 1.5, "dash": "dashdot"},
opacity=0.9,
name="Streefwaarde (pomp)",
hovertemplate="%{y:.1f} mmol/L<extra></extra>",
),
**kwargs,
)
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,
@@ -212,7 +400,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)