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

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

Builds on #29/#30/#31.
2026-09-05 22:52:29 +02:00
willemjan c617f4e41b Merge pull request 'Feature/insulin profile' (#33) from feature/insulin-profile into main
CI/CD / test (push) Successful in 17s
CI/CD / sonarqube (push) Successful in 20s
CI/CD / build-and-scan (push) Successful in 42s
CI/CD / deploy (push) Successful in 3s
Reviewed-on: #33
2026-09-05 20:47:43 +00:00
willemjan a10a4c0414 Merge pull request 'Feature/treatment overlays' (#32) from feature/treatment-overlays into feature/insulin-profile
Reviewed-on: #32
2026-09-05 20:47:10 +00:00
willemjan 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 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
15 changed files with 393 additions and 134 deletions
+2
View File
@@ -5,4 +5,6 @@ __pycache__/
.git/ .git/
.pytest_cache/ .pytest_cache/
.ruff_cache/ .ruff_cache/
.coverage
coverage.xml
tests/ tests/
+17 -3
View File
@@ -19,11 +19,21 @@ jobs:
run: pip install -r requirements.txt run: pip install -r requirements.txt
- name: Lint (ruff) - name: Lint (ruff)
run: ruff check . run: ruff check .
- name: Unit tests (pytest) - name: Unit tests + coverage-gate (pytest)
run: pytest tests/ -v # fail_under (pyproject.toml, [tool.coverage.report]) laat deze stap
# falen als de dekking op de gemeten modules onder 80% zakt — app.py/
# pages/*.py zijn uitgesloten (zie pyproject.toml se coverage-omit),
# dat zijn dunne Streamlit-UI-scripts die niet zinvol te unit-testen
# zijn zonder een draaiende Streamlit-sessie.
run: pytest tests/ -v --cov=. --cov-report=term-missing --cov-report=xml
- name: Bewaar coverage-rapport
uses: actions/upload-artifact@v3
with:
name: coverage-report
path: coverage.xml
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
# 2. SonarQube — codekwaliteit-gate # 2. SonarQube — codekwaliteit-gate (incl. coverage uit de vorige stap)
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
sonarqube: sonarqube:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -32,6 +42,10 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 # SonarQube wil de volledige git-historie zien fetch-depth: 0 # SonarQube wil de volledige git-historie zien
- name: Download coverage-rapport
uses: actions/download-artifact@v3
with:
name: coverage-report
- name: SonarQube scan - name: SonarQube scan
uses: sonarsource/sonarqube-scan-action@v6 uses: sonarsource/sonarqube-scan-action@v6
env: env:
+2
View File
@@ -4,4 +4,6 @@ __pycache__/
.env .env
.pytest_cache/ .pytest_cache/
.ruff_cache/ .ruff_cache/
.coverage
coverage.xml
homelab-cicd-werkinstructie.md homelab-cicd-werkinstructie.md
+9
View File
@@ -41,3 +41,12 @@ klinische rapportagepagina's (zie `docs/requirements-reporting.md` en
pytest tests/ -v pytest tests/ -v
ruff check . 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
```
+6 -1
View File
@@ -17,13 +17,18 @@ import os
import streamlit as st import streamlit as st
def require_auth() -> None: def require_auth() -> None: # pragma: no cover
"""Blokkeer de pagina totdat het juiste wachtwoord is ingevoerd. """Blokkeer de pagina totdat het juiste wachtwoord is ingevoerd.
Slaat de status op in st.session_state zodat dit maar één keer per Slaat de status op in st.session_state zodat dit maar één keer per
browsersessie hoeft. Moet als eerste statement van elke pagina aangeroepen browsersessie hoeft. Moet als eerste statement van elke pagina aangeroepen
worden (app.py en elk bestand in pages/), omdat Streamlit iedere pagina worden (app.py en elk bestand in pages/), omdat Streamlit iedere pagina
als los script uitvoert. 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"): if st.session_state.get("authenticated"):
return return
+27
View File
@@ -254,6 +254,33 @@ def fetch_treatments_range(start_date: date, end_date: date) -> pd.DataFrame:
return _treatments_since(since, until) return _treatments_since(since, until)
def fetch_entries_for_day(day: date) -> pd.DataFrame:
"""Haal glucosemetingen op voor precies één kalenderdag.
Dunne wrapper om fetch_entries_range() met start=end=day, voor de
dag-navigatie op de Dagoverzicht-pagina: laadt alleen de geselecteerde
dag in plaats van een hele periode, zodat het wisselen tussen dagen snel
blijft.
"""
return fetch_entries_range(day, day)
def fetch_treatments_for_day(day: date, lookback_hours: int = 6) -> pd.DataFrame:
"""Haal behandelingen op voor één kalenderdag, met een lookback-marge vóór middernacht.
reporting.effective_basal_series() heeft de starttijd van een Temp Basal
nodig om de basaalsnelheid vroeg in de dag correct te berekenen — ook als
die temp basal al vóór middernacht is gestart en doorloopt. Zonder deze
marge zou de eerste uren van elke los opgevraagde dag ten onrechte het
profieltempo tonen in plaats van een nog actieve temp basal. 6 uur ligt
ruim boven de gebruikelijke temp-basal-duur in gesloten-lus-modus
(doorgaans 30-60 minuten).
"""
since = datetime.combine(day, time.min, tzinfo=timezone.utc) - timedelta(hours=lookback_hours)
until = datetime.combine(day, time.max, tzinfo=timezone.utc)
return _treatments_since(since, until)
def fetch_profile() -> dict: def fetch_profile() -> dict:
"""Haal het meest recent gebruikte pompprofiel op. """Haal het meest recent gebruikte pompprofiel op.
+132 -100
View File
@@ -1,3 +1,5 @@
from datetime import timedelta
import pandas as pd import pandas as pd
import plotly.graph_objects as go import plotly.graph_objects as go
import requests import requests
@@ -7,16 +9,23 @@ from plotly.subplots import make_subplots
from auth import require_auth from auth import require_auth
from data_processing import TARGET_HIGH, TARGET_LOW, classify_treatments from data_processing import TARGET_HIGH, TARGET_LOW, classify_treatments
from nightscout_client import NightscoutConfigError from nightscout_client import NightscoutConfigError
from reporting import classify_bolus, effective_basal_series, target_glucose_series from reporting import (
BOLUS_CATEGORY_SMB,
classify_bolus,
daily_stats,
effective_basal_series,
target_glucose_series,
)
from ui_common import ( from ui_common import (
MMOL_HOVERTEMPLATE,
add_basal_area_trace, add_basal_area_trace,
add_bolus_marker_traces, add_bolus_marker_traces,
add_carb_bars_trace, add_carb_bars_trace,
add_target_line_trace, add_target_line_trace,
add_threshold_lines, add_threshold_lines,
get_colors, get_colors,
load_day_data,
load_profile, load_profile,
load_range_data,
render_completeness_warning, render_completeness_warning,
select_period_days, select_period_days,
) )
@@ -25,17 +34,48 @@ st.set_page_config(page_title="Glucose dashboard — Dagoverzicht", page_icon="
require_auth() require_auth()
st.title("Dagoverzicht") st.title("Dagoverzicht")
st.caption("Eén glucosegrafiek per kalenderdag, met basaal/bolus/koolhydraten-overlay (FR4).") st.caption(
"Eén dag per keer: boven glucose met koolhydraten/bolus-overlay, onder basaal "
"en SMB — apart gehouden omdat één gecombineerde grafiek te veel informatie bevatte."
)
start, end, days = select_period_days(default_label="14 dagen") # De periode-knop bepaalt alleen het navigeerbare venster (hoe ver terug je kunt
if days > 31: # bladeren), niet welke data wordt opgehaald — dat gebeurt hieronder voor precies
st.info( # de geselecteerde dag (load_day_data), zodat het wisselen tussen dagen snel blijft.
"Bij langere periodes wordt de pagina lang en kan laden traag zijn — " window_start, window_end, days = select_period_days(default_label="14 dagen")
"voor het bekijken van individuele dagen is 14 of 30 dagen prettiger."
state_key = "dagoverzicht_selected_day"
if state_key not in st.session_state:
st.session_state[state_key] = window_end
if not (window_start <= st.session_state[state_key] <= window_end):
st.session_state[state_key] = window_end
selected_day = st.session_state[state_key]
col_prev, col_date, col_next = st.columns([1, 3, 1])
with col_prev:
if st.button("← Vorige dag", disabled=selected_day <= window_start, width="stretch"):
selected_day = selected_day - timedelta(days=1)
st.session_state[state_key] = selected_day
with col_next:
if st.button("Volgende dag →", disabled=selected_day >= window_end, width="stretch"):
selected_day = selected_day + timedelta(days=1)
st.session_state[state_key] = selected_day
with col_date:
date_options = [window_end - timedelta(days=i) for i in range(days)]
chosen = st.selectbox(
"Datum",
options=date_options,
index=date_options.index(selected_day),
format_func=lambda d: d.strftime("%A %d-%m-%Y"),
label_visibility="collapsed",
) )
if chosen != selected_day:
selected_day = chosen
st.session_state[state_key] = selected_day
try: try:
glucose, treatments = load_range_data(start, end) glucose, treatments = load_day_data(selected_day)
profile = load_profile() profile = load_profile()
except NightscoutConfigError as exc: except NightscoutConfigError as exc:
st.error(str(exc)) st.error(str(exc))
@@ -45,132 +85,124 @@ except (requests.RequestException, ValueError) as exc:
st.stop() st.stop()
if glucose.empty: if glucose.empty:
st.info("Geen glucosemetingen gevonden in deze periode.") st.info("Geen glucosemetingen gevonden op deze dag.")
st.stop() st.stop()
render_completeness_warning(glucose, start, end) render_completeness_warning(glucose, selected_day, selected_day)
colors = get_colors() colors = get_colors()
glucose = glucose.copy() day_start = pd.Timestamp(selected_day)
glucose["date"] = glucose["timestamp"].dt.date day_end = day_start + pd.Timedelta(1, unit="D")
glucose["hour"] = glucose["timestamp"].dt.hour + glucose["timestamp"].dt.minute / 60
carbs_events = classify_treatments(treatments)["carbs"].copy() # treatments bevat een lookback-marge vóór middernacht (zie
if not carbs_events.empty: # nightscout_client.fetch_treatments_for_day) zodat een temp basal die al
carbs_events["date"] = carbs_events["timestamp"].dt.date # vóór deze dag begon toch meetelt — effective_basal_series() clipt dat
carbs_events["hour"] = carbs_events["timestamp"].dt.hour + carbs_events["timestamp"].dt.minute / 60 # zelf aan [day_start, day_end). Voor de koolhydraten/bolus-overlays willen
# we die marge juist NIET, anders duiken gisteravond se stippen op vandaag op.
day_treatments = treatments[(treatments["timestamp"] >= day_start) & (treatments["timestamp"] < day_end)]
bolus_events = classify_bolus(treatments) carbs_day = classify_treatments(day_treatments)["carbs"]
if not bolus_events.empty: bolus_day = classify_bolus(day_treatments)
bolus_events["date"] = bolus_events["timestamp"].dt.date basal_day = effective_basal_series(profile, treatments, day_start, day_end)
bolus_events["hour"] = bolus_events["timestamp"].dt.hour + bolus_events["timestamp"].dt.minute / 60 target_day = target_glucose_series(profile, day_start, day_end)
def _close_step_to_day_end(df: pd.DataFrame, value_col: str) -> pd.DataFrame: def _close_step_to_day_end(df: pd.DataFrame, value_col: str) -> pd.DataFrame:
"""Voeg een laatste punt op hour=24 toe (zelfde waarde als het laatste """Voeg een laatste punt op day_end toe (zelfde waarde als het laatste
segment) zodat een stapvlak/-lijn de volle dag doortekent i.p.v. te segment) zodat een stapvlak/-lijn de volle dag doortekent i.p.v. te
stoppen bij het laatste breekpunt.""" stoppen bij het laatste breekpunt."""
if df.empty: if df.empty:
return df return df
last_value = df.iloc[-1][value_col] last_value = df.iloc[-1][value_col]
closing_row = pd.DataFrame([{"hour": 24.0, value_col: last_value}]) closing_row = pd.DataFrame([{"timestamp": day_end, value_col: last_value}])
return pd.concat([df, closing_row], ignore_index=True) return pd.concat([df, closing_row], ignore_index=True)
dates = sorted(glucose["date"].unique()) basal_day = _close_step_to_day_end(basal_day, "rate")
row_height = 190 target_day = _close_step_to_day_end(target_day, "target")
fig = make_subplots(
rows=len(dates),
cols=1,
shared_xaxes=True,
vertical_spacing=min(0.4 / max(len(dates) - 1, 1), 0.05),
specs=[[{"secondary_y": True}]] * len(dates),
)
for i, day in enumerate(dates, start=1):
day_df = glucose[glucose["date"] == day]
day_start = pd.Timestamp(day)
day_end = day_start + pd.Timedelta(1, unit="D")
def build_day_figure(glucose_day, carbs_day, bolus_day, basal_day, target_day, colors) -> go.Figure:
fig = make_subplots(
rows=2,
cols=1,
shared_xaxes=True,
row_heights=[0.68, 0.32],
vertical_spacing=0.08,
specs=[[{"secondary_y": True}], [{"secondary_y": False}]],
)
# Rij 1: glucose + streefwaarde + koolhydraten + voeding-/correctiebolus.
fig.add_hrect( fig.add_hrect(
y0=TARGET_LOW, y1=TARGET_HIGH, fillcolor=colors["band"], line_width=0, layer="below", row=i, col=1 y0=TARGET_LOW, y1=TARGET_HIGH, fillcolor=colors["band"], line_width=0, layer="below", row=1, 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,
) )
add_target_line_trace(fig, target_day, colors, row=1)
add_carb_bars_trace(fig, carbs_day, colors, row=1, secondary_y=True)
basal_day = effective_basal_series(profile, treatments, day_start, day_end) top_bolus = bolus_day[bolus_day["category"] != BOLUS_CATEGORY_SMB] if not bolus_day.empty else bolus_day
if not basal_day.empty: lane_y = glucose_day["mmol"].min() - 1.0 if not glucose_day.empty else TARGET_LOW - 1.0
basal_day["hour"] = (basal_day["timestamp"] - day_start).dt.total_seconds() / 3600 add_bolus_marker_traces(fig, top_bolus, colors, lane_y, row=1)
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( fig.add_trace(
go.Scatter( go.Scatter(
x=day_df["hour"], x=glucose_day["timestamp"],
y=day_df["mmol"], y=glucose_day["mmol"],
mode="lines", mode="lines",
line={"color": colors["glucose"], "width": 2}, line={"color": colors["glucose"], "width": 2},
name="Glucose", name="Glucose",
hovertemplate="%{y:.1f} mmol/L<extra></extra>", hovertemplate=MMOL_HOVERTEMPLATE,
), ),
row=i, row=1,
col=1, col=1,
secondary_y=False,
) )
fig.update_yaxes(title_text="mmol/L", gridcolor=colors["grid"], row=i, col=1, secondary_y=False) add_threshold_lines(fig, colors, row=1)
fig.update_yaxes(title_text="mmol/L", gridcolor=colors["grid"], row=1, col=1, secondary_y=False)
fig.update_yaxes(title_text="Koolhydraten (g)", showgrid=False, row=1, col=1, secondary_y=True)
# Rij 2: basaal + SMB — apart van rij 1 zodat de glucosegrafiek niet
# overladen raakt met insulinedetails.
add_basal_area_trace(fig, basal_day, colors, row=2)
smb_bolus = bolus_day[bolus_day["category"] == BOLUS_CATEGORY_SMB] if not bolus_day.empty else bolus_day
smb_lane_y = (basal_day["rate"].max() * 1.15) if not basal_day.empty else 1.0
add_bolus_marker_traces(fig, smb_bolus, colors, smb_lane_y, row=2)
fig.update_yaxes( fig.update_yaxes(
title_text="Basaal (E/uur) · KH (g)", showgrid=False, row=i, col=1, secondary_y=True title_text="Basaal (E/uur)", gridcolor=colors["grid"], rangemode="tozero", row=2, col=1
) )
add_threshold_lines(fig, colors, row=i)
# Elke dag herhaalt dezelfde spoornamen (Glucose/Basaal/Koolhydraten/...) — zonder fig.update_xaxes(range=[day_start, day_end], dtick=4 * 3600 * 1000, gridcolor=colors["grid"])
# deduplicatie zou de legenda 7x "Glucose" etc. tonen. Alleen de eerste keer per fig.update_layout(
# naam blijft in de legenda staan. paper_bgcolor="rgba(0,0,0,0)",
seen_names = set() plot_bgcolor="rgba(0,0,0,0)",
for trace in fig.data: font_color=colors["text"],
if trace.name in seen_names: height=560,
trace.showlegend = False showlegend=True,
else: legend={"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "left", "x": 0},
seen_names.add(trace.name) margin={"l": 10, "r": 10, "t": 40, "b": 10},
)
return fig
fig.update_xaxes(title_text="Uur van de dag", range=[0, 24], dtick=4, gridcolor=colors["grid"])
fig.update_layout( fig = build_day_figure(glucose, carbs_day, bolus_day, basal_day, target_day, colors)
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font_color=colors["text"],
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.plotly_chart(fig, width="stretch")
stats = daily_stats(glucose, day_treatments, profile, day_start, day_end)
col1, col2, col3, col4 = st.columns(4)
col1.metric(
"Glucose (gem./min/max)",
f"{stats['mean_mmol']:.1f} mmol/L",
delta=f"{stats['min_mmol']:.1f} - {stats['max_mmol']:.1f}",
delta_color="off",
)
col2.metric("Totale insuline", f"{stats['total_insulin']:.1f} E")
col3.metric("Basaal / bolus / SMB", f"{stats['basal_insulin']:.1f} / {stats['bolus_insulin']:.1f} / {stats['smb_insulin']:.1f} E")
col4.metric(
"Hypo's / hyper's",
f"{stats['hypo_count']} / {stats['hyper_count']}",
delta=f"{stats['hypo_duration_minutes']:.0f} / {stats['hyper_duration_minutes']:.0f} min",
delta_color="off",
)
st.caption( st.caption(
f"{len(dates)} dagen · {len(glucose)} metingen · stippen = bolusinsuline " "Boven: stippen = bolusinsuline (voeding/correctie, grootte naar dosis) · staafjes = koolhydraten · "
"(voeding/correctie/SMB, grootte naar dosis) · vlak = basaal · staafjes = koolhydraten · " "streepstippellijn = streefwaarde uit pompprofiel. Onder: vlak = basaal · stippen = SMB."
"streepstippellijn = streefwaarde uit pompprofiel"
) )
+3 -3
View File
@@ -6,7 +6,7 @@ import streamlit as st
from auth import require_auth from auth import require_auth
from nightscout_client import NightscoutConfigError from nightscout_client import NightscoutConfigError
from profile_data import segments_table, segments_to_hourly_bars, total_daily_basal from profile_data import segments_table, segments_to_hourly_bars, total_daily_basal
from ui_common import get_colors, load_profile from ui_common import get_colors, hex_to_rgba, load_profile
st.set_page_config(page_title="Glucose dashboard — Insulineprofiel", page_icon="📊", layout="wide") st.set_page_config(page_title="Glucose dashboard — Insulineprofiel", page_icon="📊", layout="wide")
require_auth() require_auth()
@@ -42,8 +42,8 @@ fig = go.Figure(
mode="lines", mode="lines",
line={"shape": "hv", "color": colors["basal"], "width": 2}, line={"shape": "hv", "color": colors["basal"], "width": 2},
fill="tozeroy", fill="tozeroy",
fillcolor=colors["basal"], fillcolor=hex_to_rgba(colors["basal"], 0.3),
opacity=0.7, opacity=0.8,
name="Basaal", name="Basaal",
hovertemplate="%{x:.0f}:00 · %{y:.2f} E/uur<extra></extra>", hovertemplate="%{x:.0f}:00 · %{y:.2f} E/uur<extra></extra>",
) )
+36 -16
View File
@@ -230,7 +230,7 @@ def _insulin_profile_figure_png(profile: dict) -> bytes:
if bars: if bars:
hours = [b["hour"] for b in bars] hours = [b["hour"] for b in bars]
values = [b["value"] for b in bars] values = [b["value"] for b in bars]
ax.step(hours, values, where="post", color=_BASAL_COLOR, linewidth=1.5) ax.step(hours, values, where="post", color=_BASAL_COLOR, linewidth=1.5, alpha=0.8)
ax.fill_between(hours, values, step="post", color=_BASAL_COLOR, alpha=0.3) ax.fill_between(hours, values, step="post", color=_BASAL_COLOR, alpha=0.3)
ax.set_xlim(0, 24) ax.set_xlim(0, 24)
ax.set_xticks(range(0, 25, 2)) ax.set_xticks(range(0, 25, 2))
@@ -261,9 +261,8 @@ def _add_carbs_overlay(ax2, day_treatments: pd.DataFrame, day_start) -> None:
ax2.bar(hours, carbs["carbs"], width=0.25, color=_CARBS_COLOR, alpha=0.55, label="Koolhydraten") 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: def _add_bolus_overlay(ax, bolus: pd.DataFrame, day_start, lane_y: float, categories: tuple[str, ...]) -> None:
bolus = classify_bolus(day_treatments) for category in categories:
for category in (BOLUS_CATEGORY_SMB, BOLUS_CATEGORY_CORRECTION, BOLUS_CATEGORY_FOOD):
subset = bolus[bolus["category"] == category] if not bolus.empty else bolus subset = bolus[bolus["category"] == category] if not bolus.empty else bolus
if subset.empty: if subset.empty:
continue continue
@@ -291,35 +290,56 @@ def _add_target_overlay(ax, profile: dict | None, day_start, day_end) -> None:
def _daily_figure_png( def _daily_figure_png(
day_df: pd.DataFrame, day_treatments: pd.DataFrame, profile: dict | None, day_label: str, day_start, day_end day_df: pd.DataFrame, day_treatments: pd.DataFrame, profile: dict | None, day_label: str, day_start, day_end
) -> bytes: ) -> bytes:
"""Eén dag als grafiek: glucoselijn + basaal/bolus/koolhydraten/streefwaarde-overlay.""" """Eén dag als twee gestapelde grafieken (gedeelde tijdas, één gedeelde legenda):
boven glucose + streefwaarde + koolhydraten + voeding/correctiebolus, onder
basaal + SMB — gescheiden omdat één gecombineerde grafiek te veel informatie
bevatte om nog leesbaar te zijn."""
hours = day_df["timestamp"].dt.hour + day_df["timestamp"].dt.minute / 60 hours = day_df["timestamp"].dt.hour + day_df["timestamp"].dt.minute / 60
fig, ax = plt.subplots(figsize=(11, 3.8)) fig, (ax, ax_basal) = plt.subplots(2, 1, figsize=(11, 5.2), sharex=True, height_ratios=[2.2, 1])
ax2 = ax.twinx() ax2 = ax.twinx()
basal = effective_basal_series(profile, day_treatments, day_start, day_end) bolus = classify_bolus(day_treatments)
_add_basal_overlay(ax2, basal, day_start)
_add_carbs_overlay(ax2, day_treatments, 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 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_bolus_overlay(ax, bolus, day_start, lane_y, categories=(BOLUS_CATEGORY_CORRECTION, BOLUS_CATEGORY_FOOD))
_add_target_overlay(ax, profile, day_start, day_end) _add_target_overlay(ax, profile, day_start, day_end)
ax.plot(hours, day_df["mmol"], color="#2a78d6", linewidth=1.4, label="Glucose", zorder=6) ax.plot(hours, day_df["mmol"], color="#2a78d6", linewidth=1.4, label="Glucose", zorder=6)
_add_threshold_lines(ax) _add_threshold_lines(ax)
ax.set_xlim(0, 24)
ax.set_xticks(range(0, 25, 2))
ax.set_ylabel("mmol/L", fontsize=9) ax.set_ylabel("mmol/L", fontsize=9)
ax2.set_ylabel("Basaal (E/uur) · KH (g)", fontsize=9) ax2.set_ylabel("Koolhydraten (g)", fontsize=9)
ax.set_title(day_label, fontsize=11, loc="left") ax.set_title(day_label, fontsize=11, loc="left")
ax.tick_params(labelsize=8) ax.tick_params(labelsize=8)
ax2.tick_params(labelsize=8) ax2.tick_params(labelsize=8)
basal = effective_basal_series(profile, day_treatments, day_start, day_end)
_add_basal_overlay(ax_basal, basal, day_start)
smb_lane_y = (basal["rate"].max() * 1.15) if not basal.empty else 1.0
_add_bolus_overlay(ax_basal, bolus, day_start, smb_lane_y, categories=(BOLUS_CATEGORY_SMB,))
ax_basal.set_xlim(0, 24)
ax_basal.set_xticks(range(0, 25, 2))
ax_basal.set_xlabel(_HOUR_OF_DAY_LABEL)
ax_basal.set_ylabel("Basaal (E/uur)", fontsize=9)
ax_basal.tick_params(labelsize=8)
lines1, labels1 = ax.get_legend_handles_labels() lines1, labels1 = ax.get_legend_handles_labels()
lines2, labels2 = ax2.get_legend_handles_labels() lines2, labels2 = ax2.get_legend_handles_labels()
ax.legend( lines3, labels3 = ax_basal.get_legend_handles_labels()
lines1 + lines2, labels1 + labels2, loc="upper center", bbox_to_anchor=(0.5, -0.2), ncol=6, fontsize=7, frameon=False ax_basal.legend(
lines1 + lines2 + lines3,
labels1 + labels2 + labels3,
loc="upper center",
bbox_to_anchor=(0.5, -0.35),
ncol=6,
fontsize=7,
frameon=False,
) )
fig.tight_layout() # fig.tight_layout() introspecteert elke as se bounding box om marges te
# berekenen, en matplotlib ondersteunt dat niet betrouwbaar zodra er twee
# hoofd-assen (ax/ax_basal) plus een twinx (ax2) tegelijk in de figuur
# zitten (waarschuwing "Axes ... not compatible with tight_layout").
# subplots_adjust() met vaste marges omzeilt die introspectie.
fig.subplots_adjust(left=0.06, right=0.94, top=0.93, bottom=0.24, hspace=0.35)
return _fig_to_png(fig) return _fig_to_png(fig)
+13
View File
@@ -1,2 +1,15 @@
[tool.pytest.ini_options] [tool.pytest.ini_options]
pythonpath = ["."] 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
+1
View File
@@ -6,4 +6,5 @@ python-dotenv>=1.0,<2.0
matplotlib>=3.9,<4.0 matplotlib>=3.9,<4.0
fpdf2>=2.7,<3.0 fpdf2>=2.7,<3.0
pytest>=8.0,<9.0 pytest>=8.0,<9.0
pytest-cov>=5.0,<6.0
ruff>=0.6,<1.0 ruff>=0.6,<1.0
+1
View File
@@ -4,3 +4,4 @@ sonar.sources=.
sonar.exclusions=data/**,tests/** sonar.exclusions=data/**,tests/**
sonar.tests=tests sonar.tests=tests
sonar.python.version=3.12 sonar.python.version=3.12
sonar.python.coverage.reportPaths=coverage.xml
+44
View File
@@ -168,6 +168,50 @@ def test_fetch_treatments_range_filters_to_date_boundaries(monkeypatch):
assert result.iloc[0]["insulin"] == pytest.approx(4.0) assert result.iloc[0]["insulin"] == pytest.approx(4.0)
def test_fetch_entries_for_day_scopes_to_a_single_calendar_day(monkeypatch):
monkeypatch.setenv("NIGHTSCOUT_URL", "https://example.com")
monkeypatch.delenv("NIGHTSCOUT_TOKEN", raising=False)
monkeypatch.delenv("NIGHTSCOUT_API_SECRET", raising=False)
payload = [
{"date": int(datetime(2026, 1, 15, 12, 0, tzinfo=timezone.utc).timestamp() * 1000), "sgv": 126},
{"date": int(datetime(2026, 1, 16, 12, 0, tzinfo=timezone.utc).timestamp() * 1000), "sgv": 100},
]
monkeypatch.setattr(ns.requests, "get", lambda *a, **k: FakeResponse(payload))
result = ns.fetch_entries_for_day(date(2026, 1, 15))
assert len(result) == 1
assert result.iloc[0]["timestamp"].date() == date(2026, 1, 15)
def test_fetch_treatments_for_day_includes_lookback_before_midnight(monkeypatch):
"""Een Temp Basal die net vóór middernacht is gestart moet toch meekomen
(zie de docstring bij fetch_treatments_for_day) — zonder lookback-marge
zou effective_basal_series() de vroege ochtenduren fout berekenen."""
monkeypatch.setenv("NIGHTSCOUT_URL", "https://example.com")
payload = [
{"created_at": "2026-01-14T22:00:00Z", "eventType": "Temp Basal", "absolute": 1.2, "duration": 480},
{"created_at": "2026-01-15T12:00:00Z", "eventType": "Meal Bolus", "insulin": 4.0, "carbs": 40},
{"created_at": "2026-01-10T08:00:00Z", "eventType": "Meal Bolus", "insulin": 3.0, "carbs": 30},
]
monkeypatch.setattr(ns.requests, "get", lambda *a, **k: FakeResponse(payload))
result = ns.fetch_treatments_for_day(date(2026, 1, 15), lookback_hours=6)
assert len(result) == 2 # de 10 januari-bolus valt buiten de lookback-marge
assert set(result["eventType"]) == {"Temp Basal", "Meal Bolus"}
def test_fetch_treatments_for_day_excludes_treatments_before_the_lookback_window(monkeypatch):
monkeypatch.setenv("NIGHTSCOUT_URL", "https://example.com")
payload = [
{"created_at": "2026-01-14T12:00:00Z", "eventType": "Meal Bolus", "insulin": 4.0, "carbs": 40},
]
monkeypatch.setattr(ns.requests, "get", lambda *a, **k: FakeResponse(payload))
result = ns.fetch_treatments_for_day(date(2026, 1, 15), lookback_hours=6)
assert result.empty
def _entry(dt, sgv=100): def _entry(dt, sgv=100):
return {"date": int(dt.timestamp() * 1000), "sgv": sgv, "dateString": dt.isoformat()} return {"date": int(dt.timestamp() * 1000), "sgv": sgv, "dateString": dt.isoformat()}
+47
View File
@@ -1,5 +1,8 @@
from types import SimpleNamespace
import pandas as pd import pandas as pd
import plotly.graph_objects as go import plotly.graph_objects as go
import streamlit as st
from ui_common import ( from ui_common import (
PALETTE, PALETTE,
@@ -10,10 +13,26 @@ from ui_common import (
add_target_line_trace, add_target_line_trace,
add_threshold_lines, add_threshold_lines,
build_tir_stacked_bar_figure, build_tir_stacked_bar_figure,
get_colors,
hex_to_rgba, 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(): def test_hex_to_rgba_converts_correctly():
assert hex_to_rgba("#2a78d6", 0.3) == "rgba(42,120,214,0.3)" assert hex_to_rgba("#2a78d6", 0.3) == "rgba(42,120,214,0.3)"
@@ -55,12 +74,34 @@ def test_add_basal_area_trace_adds_one_filled_step_trace():
assert fig.data[0].line.shape == "hv" 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(): def test_add_basal_area_trace_skips_empty_input():
fig = go.Figure() fig = go.Figure()
add_basal_area_trace(fig, pd.DataFrame(columns=["timestamp", "rate"]), PALETTE["light"]) add_basal_area_trace(fig, pd.DataFrame(columns=["timestamp", "rate"]), PALETTE["light"])
assert len(fig.data) == 0 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(): 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]}) carbs = pd.DataFrame({"timestamp": pd.to_datetime(["2026-01-01 08:00"]), "carbs": [40.0]})
fig = go.Figure() fig = go.Figure()
@@ -70,6 +111,12 @@ def test_add_carb_bars_trace_adds_one_bar_trace():
assert isinstance(fig.data[0], go.Bar) 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(): def test_add_bolus_marker_traces_adds_one_trace_per_category_present():
bolus = pd.DataFrame( bolus = pd.DataFrame(
{ {
+53 -11
View File
@@ -14,7 +14,13 @@ import pandas as pd
import plotly.graph_objects as go import plotly.graph_objects as go
import streamlit as st import streamlit as st
from nightscout_client import fetch_entries_range, fetch_profile, fetch_treatments_range from nightscout_client import (
fetch_entries_for_day,
fetch_entries_range,
fetch_profile,
fetch_treatments_for_day,
fetch_treatments_range,
)
from profile_data import parse_profile from profile_data import parse_profile
from reporting import ( from reporting import (
HIGH, HIGH,
@@ -118,8 +124,12 @@ def get_colors() -> dict:
return PALETTE["dark" if theme_type == "dark" else "light"] return PALETTE["dark" if theme_type == "dark" else "light"]
def select_period_days(default_label: str) -> tuple[date, date, int]: def select_period_days(default_label: str) -> tuple[date, date, int]: # pragma: no cover
"""Periodekiezer voor rapportagepagina's; geeft (start, eind, aantal_dagen) terug.""" """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()) labels = list(PERIOD_OPTIONS_DAYS.keys())
label = st.radio("Periode", labels, index=labels.index(default_label), horizontal=True) label = st.radio("Periode", labels, index=labels.index(default_label), horizontal=True)
days = PERIOD_OPTIONS_DAYS[label] days = PERIOD_OPTIONS_DAYS[label]
@@ -129,22 +139,49 @@ def select_period_days(default_label: str) -> tuple[date, date, int]:
@st.cache_data(ttl=3600, show_spinner="Data ophalen van Nightscout...") @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. """Gecachete Nightscout-fetch voor een vaste periode.
Lange TTL (in tegenstelling tot de 60s van de live-pagina): historische Lange TTL (in tegenstelling tot de 60s van de live-pagina): historische
data van een afgeronde periode verandert niet. 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) glucose = fetch_entries_range(start, end)
treatments = fetch_treatments_range(start, end) treatments = fetch_treatments_range(start, end)
return glucose, treatments return glucose, treatments
@st.cache_data(ttl=300, show_spinner="Data ophalen van Nightscout...")
def load_day_data(day: date): # pragma: no cover
"""Gecachete Nightscout-fetch voor precies één kalenderdag (Dagoverzicht-navigatie).
Kortere TTL dan load_range_data (5 min i.p.v. 1 uur): in tegenstelling tot
een afgeronde periode kan de geselecteerde dag "vandaag" zijn, waar
nieuwe metingen blijven binnenkomen. fetch_treatments_for_day() haalt
zelf een lookback-marge vóór middernacht op zodat effective_basal_series()
de vroege ochtenduren correct kan berekenen.
Dunne st.cache_data-wrapper rond al-geteste fetch_entries_for_day()/
fetch_treatments_for_day() — buiten scope van de unit-testdekking net als
pages/*.py, zie pyproject.toml se coverage-omit-lijst.
"""
glucose = fetch_entries_for_day(day)
treatments = fetch_treatments_for_day(day)
return glucose, treatments
@st.cache_data(ttl=21600, show_spinner="Profiel ophalen van Nightscout...") @st.cache_data(ttl=21600, show_spinner="Profiel ophalen van Nightscout...")
def load_profile() -> dict: def load_profile() -> dict: # pragma: no cover
"""Gecachete profiel-fetch. Langere TTL dan load_range_data (6 uur i.p.v. """Gecachete profiel-fetch. Langere TTL dan load_range_data (6 uur i.p.v.
1 uur): een pompprofiel wijzigt zelden, in tegenstelling tot glucose-/ 1 uur): een pompprofiel wijzigt zelden, in tegenstelling tot glucose-/
behandeldata van een lopende periode.""" behandeldata van een lopende periode.
Dunne st.cache_data-wrapper rond al-geteste fetch_profile()/parse_profile()
— buiten scope van de unit-testdekking net als pages/*.py.
"""
raw = fetch_profile() raw = fetch_profile()
return parse_profile(raw) return parse_profile(raw)
@@ -363,19 +400,21 @@ def add_target_line_trace(
) )
def render_completeness_warning(glucose: pd.DataFrame, start: date, end: date) -> None: def render_completeness_warning(glucose: pd.DataFrame, start: date, end: date) -> None: # pragma: no cover
"""Toon reporting.completeness_warning()'s oordeel als st.warning, indien nodig. """Toon reporting.completeness_warning()'s oordeel als st.warning, indien nodig.
Gebruikt door elke rapportagepagina, direct na de load_range_data-call. Gebruikt door elke rapportagepagina, direct na de load_range_data-call.
De beoordelingslogica zelf staat in reporting.py (pure functie) zodat het De beoordelingslogica zelf staat in reporting.py (pure functie, wél volledig
PDF-rapport (pdf_export.py) exact dezelfde disclaimer kan hergebruiken. 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) message = completeness_warning(glucose, start, end)
if message: if message:
st.warning(message) st.warning(message)
def render_event_level( def render_event_level( # pragma: no cover
glucose: pd.DataFrame, glucose: pd.DataFrame,
threshold: float, threshold: float,
direction: str, direction: str,
@@ -386,7 +425,10 @@ def render_event_level(
"""Render de metrics/tabel/tijdstip-histogram voor één hypo-/hyperniveau. """Render de metrics/tabel/tijdstip-histogram voor één hypo-/hyperniveau.
Gedeeld tussen de Hypo Events- en Hyper Events-pagina's (FR5/FR6), die 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) events = detect_events(glucose, threshold, direction)
summary = summarize_events(events, period_days) summary = summarize_events(events, period_days)