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

This commit was merged in pull request #6.
This commit is contained in:
2026-07-26 18:01:15 +00:00
14 changed files with 1464 additions and 258 deletions
+5
View File
@@ -10,3 +10,8 @@ NIGHTSCOUT_TOKEN=
# Optie 2: het klassieke "API secret" van je Nightscout-instantie. # Optie 2: het klassieke "API secret" van je Nightscout-instantie.
# NIGHTSCOUT_API_SECRET= # NIGHTSCOUT_API_SECRET=
# Wachtwoord voor toegang tot het dashboard (de URL wordt straks gedeeld met
# 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=
+10 -6
View File
@@ -1,18 +1,22 @@
# glucose-dashboard # glucose-dashboard
Dashboard van glucosedata, live opgehaald uit een [Nightscout](https://nightscout.github.io/)-instantie. Dashboard van glucosedata, live opgehaald uit een [Nightscout](https://nightscout.github.io/)-instantie.
Toont een rollend venster (standaard 24 uur) van je CGM-curve met insuline, Meerdere pagina's (zie zijbalk): een rollend live-overzicht en een set
koolhydraten en pomp-/sensorwissels overlay, plus time-in-range-cijfers. klinische rapportagepagina's (zie `docs/requirements-reporting.md` en
`docs/design-reporting.md`).
## Instellen ## Instellen
1. Zorg voor een draaiende Nightscout-instantie en een API-token (Admin Tools 1. Zorg voor een draaiende Nightscout-instantie en een API-token (Admin Tools
> Manage Tokens in Nightscout — geef het token minimaal `readable`-rechten) > Manage Tokens in Nightscout — geef het token minimaal `readable`-rechten)
of het klassieke API secret. of het klassieke API secret.
2. Kopieer `.env.example` naar `.env` en vul `NIGHTSCOUT_URL` en één van de 2. Kopieer `.env.example` naar `.env` en vul `NIGHTSCOUT_URL`, één van de
twee auth-opties in. `app.py` laadt `.env` zelf in (via `python-dotenv`), twee auth-opties, en `DASHBOARD_PASSWORD_HASH` in (zie het voorbeeldbestand
dus dit werkt hetzelfde onder PowerShell, cmd of bash — je hoeft de voor hoe je die hash genereert — de dashboard-URL wordt gedeeld met het
variabelen niet zelf in je shell te zetten. 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.
3. Lokaal draaien: 3. Lokaal draaien:
```powershell ```powershell
+13 -231
View File
@@ -1,241 +1,23 @@
import plotly.graph_objects as go
import requests
import streamlit as st import streamlit as st
from dotenv import load_dotenv from dotenv import load_dotenv
from data_processing import ( from auth import require_auth
TARGET_HIGH,
TARGET_LOW,
classify_treatments,
summarize_time_in_range,
)
from nightscout_client import NightscoutConfigError, fetch_entries, fetch_treatments
load_dotenv() # laadt .env als die bestaat; bestaande env vars blijven leidend load_dotenv() # laadt .env als die bestaat; bestaande env vars blijven leidend
st.set_page_config(page_title="Glucose dashboard", page_icon="📊", layout="wide") st.set_page_config(page_title="Glucose dashboard", page_icon="📊", layout="wide")
require_auth()
st.title("Glucose dashboard") st.title("Glucose dashboard")
st.write("Kies een pagina in de zijbalk.")
HOURS_OPTIONS = {"6 uur": 6, "12 uur": 12, "24 uur": 24, "48 uur": 48} st.markdown(
"""
# Kleuren per rol, licht/donker — zie de dataviz-conventies in dit project: - **Live** — rollend overzicht van de laatste uren, met insuline/koolhydraten/pomp-/sensorwissels
# categorale kleuren voor identiteit (glucose/insuline/carbs), status-kleuren - **Samenvatting** — AGP-samenvattingsstatistieken en tijd-in-bereik over een gekozen periode
# (vast, nooit hergebruikt) voor te laag/te hoog. - **AGP profiel** — percentielgrafiek over een 24-uurs samengesteld beeld
PALETTE = { - **Dagoverzicht** — losse glucosegrafiek per dag
"light": { - **Hypo events** / **Hyper events** — detectie en analyse van te lage/hoge periodes
"glucose": "#2a78d6", - **Trends** — geschat HbA1c (GMI) over tijd en overige statistieken
"insulin": "#1baf7a", - **Export** — PDF-rapport van geselecteerde onderdelen
"carbs": "#eb6834", """
"low": "#d03b3b",
"high": "#fab219",
"band": "rgba(12,163,12,0.08)",
"grid": "#e1e0d9",
"muted": "#898781",
"text": "#52514e",
},
"dark": {
"glucose": "#3987e5",
"insulin": "#199e70",
"carbs": "#d95926",
"low": "#d03b3b",
"high": "#fab219",
"band": "rgba(12,163,12,0.15)",
"grid": "#2c2c2a",
"muted": "#898781",
"text": "#c3c2b7",
},
}
@st.cache_data(ttl=60, show_spinner="Data ophalen van Nightscout...")
def load_nightscout_data(hours: int):
glucose = fetch_entries(hours=hours)
treatments = fetch_treatments(hours=hours)
return glucose, treatments
MMOL_HOVERTEMPLATE = "%{x|%d-%m %H:%M} · %{y:.1f} mmol/L<extra></extra>"
def build_chart(glucose, insulin, carbs, 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
lane_base = glucose_min - 2.5
y_bottom = lane_base - 0.5
insulin_lane_y = lane_base + 1.5
carbs_lane_y = lane_base + 0.7
fig = go.Figure()
# Streefbereik als achtergrondband, geen losse legenda-entry nodig.
fig.add_hrect(
y0=TARGET_LOW,
y1=TARGET_HIGH,
fillcolor=colors["band"],
line_width=0,
layer="below",
)
fig.add_trace(
go.Scatter(
x=glucose["timestamp"],
y=glucose["mmol"],
mode="lines",
name="Glucose",
line={"color": colors["glucose"], "width": 2, "shape": "spline"},
hovertemplate=MMOL_HOVERTEMPLATE,
)
)
low_points = glucose[glucose["mmol"] < TARGET_LOW]
high_points = glucose[glucose["mmol"] > TARGET_HIGH]
if not low_points.empty:
fig.add_trace(
go.Scatter(
x=low_points["timestamp"],
y=low_points["mmol"],
mode="markers",
name=f"Laag (<{TARGET_LOW})",
marker={"color": colors["low"], "size": 6},
hovertemplate=MMOL_HOVERTEMPLATE,
)
)
if not high_points.empty:
fig.add_trace(
go.Scatter(
x=high_points["timestamp"],
y=high_points["mmol"],
mode="markers",
name=f"Hoog (>{TARGET_HIGH})",
marker={"color": colors["high"], "size": 6},
hovertemplate=MMOL_HOVERTEMPLATE,
)
)
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>",
)
)
if not carbs.empty:
fig.add_trace(
go.Scatter(
x=carbs["timestamp"],
y=[carbs_lane_y] * len(carbs),
mode="markers",
name="Koolhydraten",
marker={"color": colors["carbs"], "size": 10, "symbol": "circle"},
customdata=carbs["carbs"],
hovertemplate="%{x|%d-%m %H:%M} · %{customdata:.0f} g<extra></extra>",
)
)
# 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_annotation(
x=row["timestamp"],
y=y_top,
yanchor="bottom",
text="Pomp",
showarrow=False,
font={"color": colors["muted"], "size": 10},
)
for _, row in sensor_changes.iterrows():
fig.add_vline(x=row["timestamp"], line_dash="dash", line_color=colors["muted"], opacity=0.6)
fig.add_annotation(
x=row["timestamp"],
y=y_top,
yanchor="bottom",
text="Sensor",
showarrow=False,
font={"color": colors["muted"], "size": 10},
)
fig.update_layout(
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font_color=colors["text"],
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"]},
)
return fig
with st.sidebar:
st.header("Instellingen")
hours_label = st.radio("Periode", list(HOURS_OPTIONS.keys()), index=2)
hours = HOURS_OPTIONS[hours_label]
if st.button("Vernieuwen"):
st.cache_data.clear()
try:
glucose, treatments = load_nightscout_data(hours)
except NightscoutConfigError as exc:
st.error(str(exc))
st.stop()
except (requests.RequestException, ValueError) as exc: # netwerk-/API-fouten van Nightscout
st.error(f"Kon geen data ophalen van Nightscout: {exc}")
st.stop()
if glucose.empty:
st.info("Geen glucosemetingen gevonden in deze periode.")
st.stop()
events = classify_treatments(treatments)
tir = summarize_time_in_range(glucose)
latest = glucose.iloc[-1]
previous = glucose.iloc[-2] if len(glucose) > 1 else None
delta = round(latest["mmol"] - previous["mmol"], 1) if previous is not None else None
col1, col2, col3, col4 = st.columns(4)
col1.metric("Actuele glucose", f"{latest['mmol']:.1f} mmol/L", delta=delta, delta_color="off")
col2.metric("Tijd in bereik", f"{tir['in_range']:.0f}%")
col3.metric("Tijd laag", f"{tir['low']:.0f}%")
col4.metric("Tijd hoog", f"{tir['high']:.0f}%")
st.caption(
f"Laatste meting: {latest['timestamp'].strftime('%d-%m-%Y %H:%M')} · "
f"streefbereik {TARGET_LOW}-{TARGET_HIGH} mmol/L"
)
theme_type = "light"
try:
theme_type = st.context.theme.type or "light"
except AttributeError:
pass
colors = PALETTE["dark" if theme_type == "dark" else "light"]
fig = build_chart(
glucose,
events["insulin"],
events["carbs"],
events["site_changes"],
events["sensor_changes"],
colors,
)
st.plotly_chart(fig, width="stretch")
st.caption(
"Driehoekjes = insuline · cirkels = koolhydraten · stippellijn = pompwissel · "
"streepjeslijn = sensorwissel"
) )
+49
View File
@@ -0,0 +1,49 @@
"""
Eenvoudige wachtwoord-gate voor de dashboardpagina's.
Streamlit heeft geen ingebouwde authenticatie. Omdat dashboard-URL's straks
gedeeld worden met het diabetesteam, staat hier een lichte sessie-gebaseerde
wachtwoordcontrole voor — geen volwaardig account-systeem, wel een drempel
tegen toevallig meekijken. Het wachtwoord zelf staat nooit in plaintext in de
configuratie: net als bij het Nightscout API secret in nightscout_client.py
wordt hier een hash vergeleken, niet de ruwe waarde.
"""
from __future__ import annotations
import hashlib
import os
import streamlit as st
def require_auth() -> None:
"""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.
"""
if st.session_state.get("authenticated"):
return
st.title("Glucose dashboard")
password = st.text_input("Wachtwoord", type="password")
if not password:
st.stop()
if verify_password(password, os.environ.get("DASHBOARD_PASSWORD_HASH", "")):
st.session_state["authenticated"] = True
st.rerun()
st.error("Onjuist wachtwoord")
st.stop()
def verify_password(password: str, expected_hash: str) -> bool:
"""Vergelijk een ingevoerd wachtwoord met de geconfigureerde sha256-hash."""
if not expected_hash:
return False
return hashlib.sha256(password.encode("utf-8")).hexdigest() == expected_hash
+178
View File
@@ -0,0 +1,178 @@
# Design: Clinical reporting page
Status: **implementation in progress**
Implements: [requirements-reporting.md](requirements-reporting.md) (see that doc for the final, user-edited FR list)
## 1. Architecture overview
Streamlit's classic `pages/` directory convention (numbered filenames, auto-populated
sidebar nav) rather than the newer `st.navigation`/`st.Page` object API — same
end result (multipage nav), simpler to reason about since each page is an
independent script sharing only imported modules, and it's what the project's
own `homelab-cicd-werkinstructie.md` anticipated.
```
app.py # home/landing page — auth gate + short intro
pages/
1_Live.py # existing rolling 6-48h view, moved as-is from current app.py
2_Samenvatting.py # FR2 (summary stats) + FR9 (data completeness)
3_AGP_Profiel.py # FR3 (percentile chart)
4_Dagoverzicht.py # FR4 (day-by-day graphs)
5_Hypo_Events.py # FR5
6_Hyper_Events.py # FR6
7_Trends.py # FR7 (GMI trend) + FR8 (other stats)
8_Export.py # FR10 (PDF export)
auth.py # password gate, imported by app.py and every page
reporting.py # new pure-logic module (mirrors data_processing.py's role)
nightscout_client.py # + fetch_entries_range / fetch_treatments_range
```
Each report page has its **own** period control per the user's edit to FR1
(90-day default for statistics/TIR pages, 14-day default for the daily-log page),
rather than one global selector — consistent with "each page separately."
## 2. Auth (NFR6 — new requirement from the user's edit)
Streamlit has no built-in auth. Given this is a single-user-plus-a-few-clinicians
homelab app (not a multi-tenant product), a lightweight session-based password
gate is proportionate — consistent with how `nightscout_client._auth()` already
hashes the API secret rather than storing/sending it raw:
```python
# auth.py
import hashlib, os
import streamlit as st
def require_auth() -> None:
if st.session_state.get("authenticated"):
return
st.title("Glucose dashboard")
password = st.text_input("Wachtwoord", type="password")
if not password:
st.stop()
expected = os.environ.get("DASHBOARD_PASSWORD_HASH", "")
if hashlib.sha256(password.encode()).hexdigest() == expected:
st.session_state["authenticated"] = True
st.rerun()
st.error("Onjuist wachtwoord")
st.stop()
```
Called as the first line of `app.py` and every `pages/*.py` file (Streamlit runs
each page as an independent script, so the guard must repeat per-file — no
shared middleware). New env var `DASHBOARD_PASSWORD_HASH` (sha256 hex digest,
not the plaintext password) added to `.env.example`/README with instructions to
generate it (`python -c "import hashlib; print(hashlib.sha256(b'yourpassword').hexdigest())"`).
Session-based means each browser session re-enters the password once; this is a
basic gate against casual URL sharing, not a hardened auth system — acceptable
for this context but worth knowing if the diabetes team needs individual
accounts later.
## 3. `nightscout_client.py` additions
`fetch_entries`/`fetch_treatments` scale `count` linearly with `hours` — fine for
the existing rolling 6-48h view, not for a 90-day report (~26,000 5-min
readings). New functions follow the exact same "overfetch by count, then filter
locally by timestamp" pattern already used (rather than introducing Mongo
`find[]` date-query params, which the existing code's own comments flag as
inconsistent across Nightscout versions):
```python
def fetch_entries_range(start_date: date, end_date: date) -> pd.DataFrame:
"""Haal glucosemetingen op tussen start_date en end_date (inclusief)."""
days = (end_date - start_date).days + 1
count = min(days * 300, 100_000) # ruime marge boven 5-min-interval
... # zelfde _get/parse-logica als fetch_entries, lokaal gefilterd op [start_date, end_date]
def fetch_treatments_range(start_date: date, end_date: date) -> pd.DataFrame:
... # analoog, count = min(days * 50, 50_000)
```
## 4. `reporting.py` (new module)
Pure functions only (no Streamlit imports), mirroring `data_processing.py`'s
existing style — testable with the same fixture-based pytest pattern.
- `gmi_percent(mean_mmol: float) -> float` — `3.31 + 0.4311 * mean_mmol`
- `percent_cv(glucose: pd.DataFrame) -> float` — `stdev/mean * 100`
- `tir_breakdown_5band(glucose) -> dict` — extends `data_processing.summarize_time_in_range`
(which only has 3 bands) to the 5 clinical bands (<3.0, 3.0–3.9, 3.9–10.0,
10.0–13.9, >13.9), each as % and as minutes/day
- `detect_events(glucose, threshold, direction, min_duration_minutes=15) -> pd.DataFrame`
— generic engine for FR5/FR6 (direction="below" for hypo, "above" for hyper);
finds runs of consecutive out-of-threshold readings ≥15 min, merging across
brief in-range blips per the persistence rule (a return above/below threshold
only "counts" once it has itself persisted ≥15 min)
- `summarize_events(events: pd.DataFrame, period_days: int) -> dict` — count, avg/longest
duration, total time, events/week
- `overnight_daytime_split(glucose, overnight_start=0, overnight_end=6) -> dict`
— TIR breakdown for each segment
- `day_of_week_pattern(glucose) -> pd.DataFrame` — per-weekday summary stats
- `bolus_stats(treatments) -> dict` — meal vs. correction bolus split, using the
existing `INSULIN_EVENT_TYPES` set from `nightscout_client.py` (currently
defined but unused — this is its first real consumer)
- `site_change_glucose_impact(glucose, site_changes, window_hours=6) -> pd.DataFrame`
— TIR in the N hours after each site change vs. the period baseline
**Scoping caveat surfaced during design** (flagging rather than silently
under-delivering): a true basal:bolus insulin ratio and total-daily-dose trend
need the *scheduled* basal rate (from the Nightscout profile) reconciled against
any temp-basal overrides — neither the profile nor "Temp Basal" treatments are
fetched anywhere in this codebase today, and profile access is explicitly the
other feature's territory ([requirements-profile-tuning.md](requirements-profile-tuning.md)).
Implementing this now would mean quietly duplicating profile-fetch logic between
two features that are supposed to stay separate. First pass ships **bolus-only**
stats (which are fully derivable from `treatments` alone) with an explicit label
that basal totals aren't included; true basal:bolus/TDD becomes a fast-follow
once the profile-tuning feature's Nightscout profile client exists and can be
reused read-only from here.
## 5. PDF export (FR10)
`kaleido` (Plotly's static-image renderer) bundles its own Chromium build —
meaningfully bigger image and more Trivy surface for not much benefit here.
Instead: `matplotlib` (already a transitive pandas dependency's peer, tiny
incremental footprint) renders simplified static charts specifically for the
PDF, and `fpdf2` (pure-Python, no system deps) assembles the PDF — same
approach, smaller/simpler Docker image, consistent with how carefully the
Trivy/registry-hostname issues were chased down in this project's CI history.
`pages/8_Export.py` lets the user check which sections to include, renders the
selected stats/charts via a new `pdf_export.py` (`build_report_pdf(sections: list[str], ...) -> bytes`),
and offers it via `st.download_button`.
## 6. Testing strategy
- `reporting.py` functions: unit tests in `tests/test_reporting.py`, same
fixture style as `tests/test_data_processing.py`, explicitly covering the
edge cases named in the requirements doc (14:59 vs 15:00 min events, two dips
separated by a brief recovery, empty input, 100%-in-range).
- `nightscout_client.py` additions: tests in the existing
`tests/test_nightscout_client.py`, same `monkeypatch`-on-`requests` pattern.
- `auth.py`: the hashing/compare logic is trivial enough to test directly
(`hashlib.sha256(...).hexdigest() == expected`) without needing to drive
Streamlit's UI.
- Page files (`pages/*.py`) are UI wiring only (like the existing `app.py`) —
consistent with this project's existing convention of not unit-testing the
Streamlit UI layer itself, only the logic it calls into.
- `pdf_export.py`: unit test that a given set of sections produces a non-empty,
valid PDF (`fpdf2`'s output starts with `%PDF`), without asserting on exact
rendered pixels.
## 7. Implementation order
1. Foundation: `auth.py`, multipage restructure (move current `app.py` →
`pages/1_Live.py`, new minimal `app.py`), `nightscout_client.py` range-fetch
additions — all independently testable, no visible feature yet.
2. `reporting.py` core: GMI, %CV, 5-band TIR, event detection + summaries —
the clinically load-bearing pieces, fully unit tested.
3. Wire pages 2–3–4–5–6 (summary, AGP, daily, hypo/hyper events) to `reporting.py`.
4. `reporting.py` extras: overnight/daytime split, day-of-week, bolus stats,
site-change impact; wire page 7 (trends).
5. PDF export (page 8) — last, since it's the most novel dependency and lowest
clinical risk if delayed a step.
Each step lands as its own commit(s) behind `ruff check .` + `pytest tests/ -v`,
pushed through the existing `dev` → PR → `main` flow so the CI/CD pipeline
(test → SonarQube → build+Trivy+push → deploy) validates every increment, per
the user's original request to implement requirement-by-requirement with as
much automated testing as possible.
+202
View File
@@ -0,0 +1,202 @@
# Requirements: oref0-autotune profile tuning
Status: **draft — for review and editing**
Owner: willemjan
Related: [requirements-reporting.md](requirements-reporting.md) (separate feature, same Nightscout data source)
## 1. Purpose & background
AndroidAPS (AAPS) only gained built-in Autotune in version 3.4, and even that
version tunes only a single ISF/carb-ratio value plus the basal schedule from
on-device history — it doesn't use Nightscout at all, and the user is still on
AAPS 3.3 regardless. Previously, the user ran a hosted tool (inspired by
`MarkMpn/AutotuneWeb`, now archived/discontinued) that analyzed Nightscout data
with `oref0-autotune` and proposed a new pump profile, which was then manually
entered into AAPS — a process the user describes as "a lot of trial and error."
This feature builds a guided, human-in-the-loop workflow: trigger an
oref0-autotune analysis from a dashboard, review the proposed profile against
the current one with the underlying estimations visible, selectively
accept/adjust values, and stage the result in Nightscout for manual activation.
The user has two LXC containers on Proxmox from earlier experimentation —
`autotune` (VMID 102, 192.168.2.13) and `oref0` (VMID 107, 192.168.2.14) —
currently stopped, of unknown/half-remembered working state.
## 2. Scope
**In scope:**
- Booting and inspecting the two existing LXCs to determine (or establish) a
working oref0-autotune setup.
- A small HTTP microservice on that LXC that runs oref0-autotune against the
user's Nightscout data on demand.
- A **separate** Streamlit app/service (own deployment, own CI/CD pipeline) that
triggers the analysis, displays old-vs-new profile comparisons with
estimation detail, lets the user selectively adjust proposed values, and
stages the result in Nightscout.
**Out of scope (this iteration):**
- Automatically activating the new profile in AndroidAPS (creating a
`"Profile Switch"` treatment) — the dashboard stages the profile only; the
user activates it manually. This may become an opt-in "phase 2" once trust is
established (see §8).
- Any change to AAPS settings or on-device configuration.
- Continuous/scheduled autotune runs — this is a manually triggered, on-demand
analysis, not a nightly automated loop.
## 3. Architecture decisions (made during spec review)
1. **Execution**: an always-on HTTP microservice on whichever LXC has (or gets)
a working oref0-autotune setup, rather than SSH-triggering a one-off Docker
run or bundling autotune into the dashboard's own container. The dashboard
calls it over HTTP and polls for job completion.
2. **Profile push safety**: **stage-only**. The service writes a new named
profile into the Nightscout profile store but does **not** create a
`"Profile Switch"` treatment — final activation stays a deliberate manual
step in AAPS or the Nightscout UI. This matters because AndroidAPS applies
profiles via Profile Switch treatments (gated behind an explicit "accept
profile switch from Nightscout" AAPS setting), not by simply reading
`profile.json` — so "stage-only" is a real, meaningful safety boundary, not
just a formality.
3. **Deployment**: a separate app/service from `glucose-dashboard`, with its own
repo-or-directory, Dockerfile, and Gitea CI/CD pipeline — isolating
write-capable, dosing-adjacent code from the read-only dashboard.
## 4. Functional requirements
### FR1 — Infra investigation (first task, needs explicit go-ahead)
Boot LXC 102 (`autotune`) and LXC 107 (`oref0`) and inspect what's installed
(Docker containers, cron jobs, scripts, config, any README/notes left behind)
to determine which one — if either — has a working oref0-autotune setup.
Document findings (what's there, what's missing, what needs rebuilding).
*Acceptance:* a clear decision on which LXC hosts the microservice, and what
(if anything) needs to be installed/fixed there before FR2 can start.
### FR2 — Autotune HTTP microservice
A small HTTP API (exact framework TBD during design — e.g. Flask/FastAPI
wrapping a Docker-based oref0-autotune image) exposing at minimum:
- `POST /analyze` — body: Nightscout URL/token, date range, (optionally) the
starting profile to tune from. Returns a job ID.
- `GET /analyze/{job_id}` — status (`pending`/`running`/`done`/`failed`) and,
once done, the recommended profile JSON plus the estimation/recommendation
detail (per-hour deviations, the autotune log).
*Acceptance:* a manual `curl` against the running service, pointed at the
user's real Nightscout instance, returns a plausible recommended profile within
a reasonable time (minutes, not seconds — oref0-autotune is not instant).
### FR3 — Trigger analysis from the dashboard
A new Streamlit app page where the user picks a date range (and confirms which
current profile to tune from, fetched live from Nightscout), starts the
analysis, and sees progress while the microservice job runs (via polling).
*Acceptance:* starting an analysis with bad/unreachable Nightscout credentials
fails with a clear error rather than hanging silently.
### FR4 — Old-vs-new profile comparison view
Side-by-side display of the current and proposed profile: hourly basal rates,
ISF schedule, carb ratio schedule, DIA — plus the underlying estimation detail
(per-hour deviations, confidence notes) surfaced from the autotune log, so the
user can judge *why* a change is being proposed, not just see the new number.
*Acceptance:* every changed value is visually distinguishable from unchanged
values; the estimation detail for a given time slot is viewable alongside that
slot's proposed change.
### FR5 — Selective adjustment
Per time-slot controls (accept/reject each proposed basal/ISF/CR change
individually) plus editable numeric fields so the user can fine-tune any
accepted value before staging — matching the user's explicit request to
"select parts of the proposed profile and still adjust the values a bit."
*Acceptance:* rejecting a slot keeps the current value unchanged in the
staged profile; editing an accepted value's number is reflected in what
actually gets sent to Nightscout.
### FR6 — Stage to Nightscout
Write the resulting merged profile (accepted + manually adjusted values) as a
new named profile in the Nightscout profile store (e.g. `"Autotune
YYYY-MM-DD"`) via a new POST to `/api/v1/profile`, without creating a Profile
Switch treatment. After staging, show clear instructions for manual activation
in AAPS (or the Nightscout UI).
*Acceptance:* the new profile appears in Nightscout's profile list/editor with
exactly the merged values; the previously active profile is untouched and
still active until the user manually switches.
### FR7 — Audit / history
Keep a record of past analysis runs and what was accepted vs. rejected vs.
manually adjusted, for traceability (useful both for the user's own reference
and potentially for the hospital team). Could reuse Nightscout's own profile
history (each staged profile is already a new dated document) rather than a
separate store — **decide during design**.
### FR8 — Safety guardrails
- Surface oref0-autotune's own `autosens_min`/`autosens_max` bounds so the user
can see the algorithm's own safety rails, not just the raw numbers.
- Flag any accepted value that represents an unusually large jump from the
current profile.
- Require an explicit confirmation step (e.g. a review summary + confirmation
checkbox) before the FR6 write happens — no silent/implicit staging.
## 5. Non-functional requirements
- **NFR1 — Isolation**: separate repository-or-directory, Dockerfile, and Gitea
CI/CD pipeline from `glucose-dashboard`, modeled on the existing pipeline
(test/lint → build+Trivy+push → deploy) but with its own secrets.
- **NFR2 — Nightscout token scope**: the existing dashboard token is documented
read-only by convention (see `glucose-dashboard/README.md`). This feature
needs a **separate, write-scoped** Nightscout token (Nightscout supports
role-based tokens — e.g. a `careportal`-level role) rather than upgrading the
shared read-only token, so the read-only reporting dashboard's blast radius
doesn't change.
- **NFR3 — Test safety**: automated tests must never call a real Nightscout
instance or the real autotune microservice — mock both, following the
existing `monkeypatch`-on-`requests` pattern from `glucose-dashboard`'s test
suite.
- **NFR4 — CI/CD gating**: given this pipeline can end in a write to a real
medical data store, consider (during design) adding a `pull_request` trigger
for the test/lint job so changes are validated *before* merge, not only
after — the current `glucose-dashboard` pipeline only triggers on `push` to
`main`.
## 6. Data requirements
- `GET /api/v1/profile.json` — read the current active profile store
(net-new capability; nothing in `nightscout_client.py` handles profiles today).
- `POST /api/v1/profile.json` — write the new named profile (net-new).
- The autotune microservice's own job API (FR2).
- Read access to entries/treatments over the analysis date range (can reuse the
reporting feature's date-range fetch capability once built, or the existing
rolling-window fetch for shorter tuning windows).
## 7. Testing & acceptance criteria
- Unit tests for the profile merge/validation logic (accept/reject/adjust
producing the correct final profile document) — pure functions, no I/O,
fully testable without any live service.
- Unit tests for the new Nightscout profile read/write client functions, using
the existing `FakeResponse`/`monkeypatch.setattr(requests, ...)` pattern.
- Integration-style tests against the autotune microservice using a mocked
HTTP layer (never the real LXC service in CI).
- A UAT checklist (produced during the design phase) exercising the full flow
end-to-end against the user's real Nightscout instance and the real
microservice: trigger → review → adjust → stage → manually verify the new
profile appears correctly in Nightscout, with no unintended Profile Switch.
## 8. Open questions for the user's edit pass
1. Which LXC (102 or 107) ends up hosting the service, once FR1 is done?
2. Which autotune Docker image to standardize on —
`p5nbTgip0r/autotune-docker`, `houthacker42/nightscout-autotune`, or
building directly from the `oref0` npm package? (Design-phase decision,
informed by FR1's findings.)
3. Should automatic Profile Switch activation become an opt-in "phase 2" later,
once you've built trust in the recommendations — or should manual
activation remain permanent policy regardless of trust?
4. FR7 (audit/history) — is reusing Nightscout's own profile-document history
sufficient, or do you want a dedicated log outside Nightscout?
5. Confirm the separate write-scoped Nightscout token is something you're
comfortable creating/managing (NFR2) — this is a manual one-time setup step
in the Nightscout admin UI, not something the app can do for you.
+198
View File
@@ -0,0 +1,198 @@
# Requirements: Clinical reporting page
Status: **draft — for review and editing**
Owner: willemjan
Related: [requirements-profile-tuning.md](requirements-profile-tuning.md) (separate feature, separate app)
## 1. Purpose & background
The previous FreeStyle Libre 2 setup (via LibreView) produced clinical reports
that the hospital/diabetes team wants to keep seeing: a multi-month time-in-range
(TIR) overview, day-by-day glucose graphs, hypoglycemia event analysis, and an
estimated HbA1c. The current DIY closed-loop setup (Medtrum TouchCare Nano 200U +
Lyumjev + Dexcom One+ via xDrip+ + AndroidAPS) stores everything in Nightscout,
which has no equivalent built-in report.
This feature adds a **reporting page to the existing `glucose-dashboard` Streamlit
app**, generating the closest practical equivalent from Nightscout data — read-only,
no new infrastructure, deployed through the existing CI/CD pipeline.
## 2. Scope
**In scope:**
- A new set of pages in the existing Streamlit app, reading `entries` (glucose) and
`treatments` (insulin, carbs) from Nightscout.
- Multi-day statistical summaries, an AGP-style percentile chart, day-by-day
glucose graphs, hypo/hyper event analysis, and an estimated HbA1c (GMI) on separate pages where not closely related.
- Generating PDF output of user selected pages, so the user can send the report to the hospital.
**Out of scope (this feature):**
- Anything that writes to Nightscout (see the separate profile-tuning spec).
- Non-Nightscout data sources.
- The oref0-autotune integration.
## 3. Clinical definitions & standards used
These are the standard definitions this report is built against (international
consensus, mainly based on dutch requirements, not invented for this project) the ranges are described in the table below.
| Term | Definition | Source |
|---|---|---|
| Hypoglycemia Level 1 | < 3.9 mmol/L (<70 mg/dL) | Battelino et al. 2019, *Diabetes Care* 42(8):1593–1603 |
| Hypoglycemia Level 2 | < 3.0 mmol/L (<54 mg/dL) | same |
| Hyperglycemia Level 1 | > 10.0 mmol/L (>180 mg/dL) | same |
| Hyperglycemia Level 2 | > 13.9 mmol/L (>250 mg/dL) | same |
| Event persistence rule | A hypo/hyper **event** starts once glucose has been continuously past the threshold for **≥15 minutes**, and ends once it has recovered past the threshold for **≥15 minutes**. A brief single reading crossing back doesn't end the event. | Battelino et al. 2019 |
| Time-in-range targets (adult, general) | TIR (3.9–10.0) >70% · TBR<3.9 <4% · TBR<3.0 <1% · TAR>10.0 <25% · TAR>13.9 <5% | Battelino et al. 2019 |
| GMI (estimated HbA1c) | `GMI% = 3.31 + 0.02392 × mean(mg/dL)` = `3.31 + 0.4311 × mean(mmol/L)` | Bergenstal et al. 2018, *Diabetes Care* 41(11):2275–2280 |
| Minimum valid report | ≥14 days of data with ≥70% sensor-active time | consensus standard |
Note: this dashboard already uses `TARGET_LOW = 3.9` / `TARGET_HIGH = 10.0` in
[`data_processing.py`](../data_processing.py) — that matches Level 1 thresholds
above, so the existing constants carry over directly; Level 2 thresholds are new.
GMI is explicitly **not** a substitute for a lab HbA1c (can differ by ~0.5
percentage points due to red blood cell/hemoglobin factors) — the report should
label it "estimated HbA1c (GMI)", not "HbA1c", to avoid confusing the hospital
team.
## 4. Functional requirements
Each FR below is meant to be independently implementable and testable, so they
can be delivered one at a time through the CI/CD pipeline.
### FR1 — Report period selector
The user can choose the reporting period: **14 days** (clinical default), 30
days, **90 days** (matches the old Libre 2 quarterly view), or a custom date
range. The period should be settable for each page separately since 90 days are relevant for A1C calculations and time in range but not so much for daily overview where 14 days is more relavant to the clinic. The selection drives every other requirement below.
*Acceptance:* selecting a period reloads the specific report page for that exact date
range; the 90-day option must not time out or hang (see NFR1 on data volume).
### FR2 — AGP-style summary statistics
A summary panel showing, for the selected period:
- % of time CGM was active (data completeness)
- Average glucose (mmol/L)
- Estimated HbA1c (GMI)
- Glucose variability (%CV = stdev/mean × 100)
- Time-in-range breakdown across the 5 standard bands (<3.0, 3.0–3.9, 3.9–10.0,
10.0–13.9, >13.9), each shown as a percentage and as hours:minutes/day
- Each stat shown against its consensus target (pass/fail or color-coded)
*Acceptance:* stats match a hand-calculated reference on a small fixture dataset
(see §7); TIR/TBR/TAR percentages sum to 100%.
### FR3 — AGP percentile chart
A single chart showing the 5th/25th/50th(median)/75th/95th percentile glucose
bands, aggregated by time-of-day across the selected period onto one 24-hour
composite axis (the standard AGP visualization).
*Acceptance:* median line at a given hour matches the median of all readings at
that hour-of-day across the period, computed independently in a test.
### FR4 — Day-by-day glucose graphs
One glucose trace per calendar day in the selected period (reusing the existing
target-range-band chart style from [`app.py`](../app.py)'s `build_chart`, adapted
for a single day per panel), so the hospital team can review specific days the
way the Libre 2 report's daily log did.
*Acceptance:* each day's panel shows exactly that day's readings; days with no
data are shown as empty/skipped rather than causing an error.
### FR5 — Hypoglycemia event analysis
Detect discrete hypo events per the persistence rule in §3, and report:
- Total event count (Level 1 and Level 2 separately)
- Duration of each event, and summary stats (average, longest, total time low)
- Events per week
- Time-of-day distribution (e.g., overnight vs. daytime split)
*Acceptance:* event count/duration matches a hand-traced fixture (a synthetic
glucose series with known dips of known duration, including edge cases: a dip
just under 15 minutes that should NOT count, and two dips separated by a brief
recovery that should count as one continuous event per the persistence rule).
### FR6 — Hyperglycemia event analysis
Same as FR5, symmetric, for Level 1/Level 2 highs. (Not explicitly requested by
the user but a natural complement and confirmed to implement by the user.)
### FR7 — Estimated HbA1c trend
GMI computed per rolling period (e.g., per month within a 90-day report),
plotted as a small trend, so the hospital team can see whether control is
improving/worsening over the quarter, not just the endpoint value.
### FR8 — Candidate additional statistics
The user mentioned having seen "other interesting stats" in other reports
without specifying which. Candidates to consider:
- Day-of-week pattern (e.g., weekends vs. weekdays)
- Overnight (00:00–06:00) vs. daytime TIR split
- Basal:bolus insulin ratio
- Insulin pump site-change and sensor-change adherence (interval between changes,
reusing existing `classify_treatments` output)
- Glucose trends after site-change
- Pump site age vs insuline usage vs glucose levels. Corrected for carb intake to see if age of the pump-site and/or insuline age influence insulin usage
- Total daily insulin dose trend
### FR9 — Data completeness / quality indicator
Flag periods with low sensor-active time (<70%) or visible data gaps, since a
report built on incomplete data is clinically unreliable — the report should
surface this rather than silently computing misleading stats.
### FR10 — Export / print-friendly view (open question)
The Libre 2 reports were shown to or printed for the hospital team.
The pages needs a print-optimized layout, a PDF export, and a
"share the dashboard URL".
## 5. Non-functional requirements
- **NFR1 — Data volume**: the existing `fetch_entries`/`fetch_treatments` in
[`nightscout_client.py`](../nightscout_client.py) scale their `count` parameter
linearly with `hours`, which doesn't scale cleanly to 90 days (~26,000 5-minute
readings). This feature needs either a proper date-range fetch (`find[date]`
query params) or paginated fetching — a new capability, not a reuse of the
existing rolling-window functions as-is.
- **NFR2 — Caching**: historical data for a fixed past period doesn't change; cache
TTL should be much longer than the existing 60-second rolling-view cache
(`app.py`'s `load_nightscout_data`), e.g. cached per exact date-range until
explicitly refreshed.
- **NFR3 — Consistency**: reuse the existing `PALETTE` dict and `st.context.theme`
detection pattern from `app.py` rather than introducing new colors; reuse
`TARGET_LOW`/`TARGET_HIGH` from `data_processing.py`.
- **NFR4 — Language**: Dutch UI strings and docstrings, consistent with the rest
of the app.
- **NFR5 — Multipage navigation**: this is the first feature needing more than one
page — introduces `st.Page`/`st.navigation` (anticipated in the project's own
`homelab-cicd-werkinstructie.md`).
- **NFR6 - Password protection on the dashboard pages**: as dashboard urls will be sharable and reachable by the diabetes team, access should be restricted to authorized users.
## 6. Data requirements
- Glucose entries over an arbitrary date range (new: `fetch_entries` needs a
date-range mode, not just "last N hours").
- Treatments over the same range (same scaling concern).
- No new Nightscout permissions needed — this feature only reads, so the
existing read-only token is sufficient.
## 7. Testing & acceptance criteria
- Each new pure function (event detection, GMI calculation, percentile
aggregation, %CV) gets unit tests in `data_processing.py` or a new
`reporting.py` module, following the existing `tests/test_data_processing.py`
fixture-based pattern — including edge cases called out per FR above (empty
data, exactly-15-minute boundary events, 100% in-range, etc.).
- Any new Nightscout fetch logic gets tests following the existing
`tests/test_nightscout_client.py` `monkeypatch`-on-`requests` pattern.
- A UAT checklist (produced during the design phase) will map each FR's
"Acceptance" line above to a concrete scenario the user can manually verify
against their own real Nightscout data.
## 8. Open questions for the user's edit pass
1. Confirm the clinical thresholds/targets in §3 match what your hospital
actually uses, or note any local adaptations. - Use the suggested clinical thresholds and targets from §3
2. FR6 (hyperglycemia analysis) — keep
3. FR8 — which candidate stats actually matter to you / your team? All that are in FR8 now
4. FR10 — is a print/export view needed, or is viewing the dashboard directly
enough? - pdf export needed
5. Default report period: 14-day (clinical standard) or 90-day (matches your
old Libre 2 habit) as the page's default view? Default 90 days for statistics, but 14 days for recent glucose charts.
+62 -20
View File
@@ -18,7 +18,7 @@ from __future__ import annotations
import hashlib import hashlib
import os import os
from datetime import datetime, timedelta, timezone from datetime import date, datetime, time, timedelta, timezone
import pandas as pd import pandas as pd
import requests import requests
@@ -75,18 +75,14 @@ def _get(path: str, params: dict) -> list:
return response.json() return response.json()
def fetch_entries(hours: int = 24) -> pd.DataFrame: def _entries_since(since: datetime, count: int, until: datetime | None = None) -> pd.DataFrame:
"""Haal glucosemetingen (SGV) op van de laatste `hours` uur, in mmol/L. """Haal entries op sinds `since` (en optioneel tot `until`), lokaal gefilterd.
Geeft een DataFrame terug met kolommen 'timestamp' en 'mmol', oplopend Nightscout geeft resultaten terug in aflopende volgorde; vraag ruim genoeg
gesorteerd op tijd. entries op (CGM meet doorgaans elke 5 min) en filter lokaal op tijd. Dit is
robuuster dan server-side find[]-query's, die niet op elke Nightscout-versie
identiek werken.
""" """
since = datetime.now(timezone.utc) - timedelta(hours=hours)
# Nightscout geeft resultaten terug in aflopende volgorde; vraag ruim
# genoeg entries op (CGM meet doorgaans elke 5 min) en filter lokaal op
# tijd. Dit is robuuster dan server-side find[]-query's, die niet op elke
# Nightscout-versie identiek werken.
count = max(300, hours * 15)
data = _get("/api/v1/entries/sgv.json", {"count": count}) data = _get("/api/v1/entries/sgv.json", {"count": count})
if not data: if not data:
@@ -96,18 +92,13 @@ def fetch_entries(hours: int = 24) -> pd.DataFrame:
df["timestamp"] = pd.to_datetime(df["date"], unit="ms", utc=True).dt.tz_localize(None) df["timestamp"] = pd.to_datetime(df["date"], unit="ms", utc=True).dt.tz_localize(None)
df["mmol"] = df["sgv"].astype(float).apply(mgdl_to_mmol) df["mmol"] = df["sgv"].astype(float).apply(mgdl_to_mmol)
df = df[df["timestamp"] >= since.replace(tzinfo=None)] df = df[df["timestamp"] >= since.replace(tzinfo=None)]
if until is not None:
df = df[df["timestamp"] <= until.replace(tzinfo=None)]
return df[["timestamp", "mmol"]].sort_values("timestamp").reset_index(drop=True) return df[["timestamp", "mmol"]].sort_values("timestamp").reset_index(drop=True)
def fetch_treatments(hours: int = 24) -> pd.DataFrame: def _treatments_since(since: datetime, count: int, until: datetime | None = None) -> pd.DataFrame:
"""Haal behandelingen op van de laatste `hours` uur: insuline, koolhydraten """Haal treatments op sinds `since` (en optioneel tot `until`), lokaal gefilterd."""
en pomp-/sensorwissels.
Geeft een DataFrame terug met kolommen 'timestamp', 'eventType',
'insulin' en 'carbs' (de laatste twee NaN waar niet van toepassing).
"""
since = datetime.now(timezone.utc) - timedelta(hours=hours)
count = max(200, hours * 10)
data = _get("/api/v1/treatments.json", {"count": count}) data = _get("/api/v1/treatments.json", {"count": count})
if not data: if not data:
@@ -122,8 +113,59 @@ def fetch_treatments(hours: int = 24) -> pd.DataFrame:
if "eventType" not in df.columns: if "eventType" not in df.columns:
df["eventType"] = "" df["eventType"] = ""
df = df[df["timestamp"] >= since.replace(tzinfo=None)] df = df[df["timestamp"] >= since.replace(tzinfo=None)]
if until is not None:
df = df[df["timestamp"] <= until.replace(tzinfo=None)]
return ( return (
df[["timestamp", "eventType", "insulin", "carbs"]] df[["timestamp", "eventType", "insulin", "carbs"]]
.sort_values("timestamp") .sort_values("timestamp")
.reset_index(drop=True) .reset_index(drop=True)
) )
def fetch_entries(hours: int = 24) -> pd.DataFrame:
"""Haal glucosemetingen (SGV) op van de laatste `hours` uur, in mmol/L.
Geeft een DataFrame terug met kolommen 'timestamp' en 'mmol', oplopend
gesorteerd op tijd.
"""
since = datetime.now(timezone.utc) - timedelta(hours=hours)
count = max(300, hours * 15)
return _entries_since(since, count)
def fetch_treatments(hours: int = 24) -> pd.DataFrame:
"""Haal behandelingen op van de laatste `hours` uur: insuline, koolhydraten
en pomp-/sensorwissels.
Geeft een DataFrame terug met kolommen 'timestamp', 'eventType',
'insulin' en 'carbs' (de laatste twee NaN waar niet van toepassing).
"""
since = datetime.now(timezone.utc) - timedelta(hours=hours)
count = max(200, hours * 10)
return _treatments_since(since, count)
def fetch_entries_range(start_date: date, end_date: date) -> pd.DataFrame:
"""Haal glucosemetingen op tussen start_date en end_date (beide inclusief).
Bedoeld voor meerdaagse rapportages (bijv. 90 dagen) waarbij het
uren-schalende `fetch_entries` niet praktisch is. Zelfde kolommen als
`fetch_entries`.
"""
since = datetime.combine(start_date, time.min, tzinfo=timezone.utc)
until = datetime.combine(end_date, time.max, tzinfo=timezone.utc)
days = (end_date - start_date).days + 1
count = min(days * 300, 100_000) # ruime marge boven 5-min-interval
return _entries_since(since, count, until)
def fetch_treatments_range(start_date: date, end_date: date) -> pd.DataFrame:
"""Haal behandelingen op tussen start_date en end_date (beide inclusief).
Zelfde kolommen als `fetch_treatments`.
"""
since = datetime.combine(start_date, time.min, tzinfo=timezone.utc)
until = datetime.combine(end_date, time.max, tzinfo=timezone.utc)
days = (end_date - start_date).days + 1
count = min(days * 50, 50_000)
return _treatments_since(since, count, until)
+205
View File
@@ -0,0 +1,205 @@
import plotly.graph_objects as go
import requests
import streamlit as st
from auth import require_auth
from data_processing import (
TARGET_HIGH,
TARGET_LOW,
classify_treatments,
summarize_time_in_range,
)
from nightscout_client import NightscoutConfigError, fetch_entries, fetch_treatments
from ui_common import MMOL_HOVERTEMPLATE, get_colors
st.set_page_config(page_title="Glucose dashboard — Live", page_icon="📊", layout="wide")
require_auth()
st.title("Glucose dashboard")
HOURS_OPTIONS = {"6 uur": 6, "12 uur": 12, "24 uur": 24, "48 uur": 48}
@st.cache_data(ttl=60, show_spinner="Data ophalen van Nightscout...")
def load_nightscout_data(hours: int):
glucose = fetch_entries(hours=hours)
treatments = fetch_treatments(hours=hours)
return glucose, treatments
def build_chart(glucose, insulin, carbs, 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
lane_base = glucose_min - 2.5
y_bottom = lane_base - 0.5
insulin_lane_y = lane_base + 1.5
carbs_lane_y = lane_base + 0.7
fig = go.Figure()
# Streefbereik als achtergrondband, geen losse legenda-entry nodig.
fig.add_hrect(
y0=TARGET_LOW,
y1=TARGET_HIGH,
fillcolor=colors["band"],
line_width=0,
layer="below",
)
fig.add_trace(
go.Scatter(
x=glucose["timestamp"],
y=glucose["mmol"],
mode="lines",
name="Glucose",
line={"color": colors["glucose"], "width": 2, "shape": "spline"},
hovertemplate=MMOL_HOVERTEMPLATE,
)
)
low_points = glucose[glucose["mmol"] < TARGET_LOW]
high_points = glucose[glucose["mmol"] > TARGET_HIGH]
if not low_points.empty:
fig.add_trace(
go.Scatter(
x=low_points["timestamp"],
y=low_points["mmol"],
mode="markers",
name=f"Laag (<{TARGET_LOW})",
marker={"color": colors["low"], "size": 6},
hovertemplate=MMOL_HOVERTEMPLATE,
)
)
if not high_points.empty:
fig.add_trace(
go.Scatter(
x=high_points["timestamp"],
y=high_points["mmol"],
mode="markers",
name=f"Hoog (>{TARGET_HIGH})",
marker={"color": colors["high"], "size": 6},
hovertemplate=MMOL_HOVERTEMPLATE,
)
)
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>",
)
)
if not carbs.empty:
fig.add_trace(
go.Scatter(
x=carbs["timestamp"],
y=[carbs_lane_y] * len(carbs),
mode="markers",
name="Koolhydraten",
marker={"color": colors["carbs"], "size": 10, "symbol": "circle"},
customdata=carbs["carbs"],
hovertemplate="%{x|%d-%m %H:%M} · %{customdata:.0f} g<extra></extra>",
)
)
# 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_annotation(
x=row["timestamp"],
y=y_top,
yanchor="bottom",
text="Pomp",
showarrow=False,
font={"color": colors["muted"], "size": 10},
)
for _, row in sensor_changes.iterrows():
fig.add_vline(x=row["timestamp"], line_dash="dash", line_color=colors["muted"], opacity=0.6)
fig.add_annotation(
x=row["timestamp"],
y=y_top,
yanchor="bottom",
text="Sensor",
showarrow=False,
font={"color": colors["muted"], "size": 10},
)
fig.update_layout(
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font_color=colors["text"],
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"]},
)
return fig
with st.sidebar:
st.header("Instellingen")
hours_label = st.radio("Periode", list(HOURS_OPTIONS.keys()), index=2)
hours = HOURS_OPTIONS[hours_label]
if st.button("Vernieuwen"):
st.cache_data.clear()
try:
glucose, treatments = load_nightscout_data(hours)
except NightscoutConfigError as exc:
st.error(str(exc))
st.stop()
except (requests.RequestException, ValueError) as exc: # netwerk-/API-fouten van Nightscout
st.error(f"Kon geen data ophalen van Nightscout: {exc}")
st.stop()
if glucose.empty:
st.info("Geen glucosemetingen gevonden in deze periode.")
st.stop()
events = classify_treatments(treatments)
tir = summarize_time_in_range(glucose)
latest = glucose.iloc[-1]
previous = glucose.iloc[-2] if len(glucose) > 1 else None
delta = round(latest["mmol"] - previous["mmol"], 1) if previous is not None else None
col1, col2, col3, col4 = st.columns(4)
col1.metric("Actuele glucose", f"{latest['mmol']:.1f} mmol/L", delta=delta, delta_color="off")
col2.metric("Tijd in bereik", f"{tir['in_range']:.0f}%")
col3.metric("Tijd laag", f"{tir['low']:.0f}%")
col4.metric("Tijd hoog", f"{tir['high']:.0f}%")
st.caption(
f"Laatste meting: {latest['timestamp'].strftime('%d-%m-%Y %H:%M')} · "
f"streefbereik {TARGET_LOW}-{TARGET_HIGH} mmol/L"
)
colors = get_colors()
fig = build_chart(
glucose,
events["insulin"],
events["carbs"],
events["site_changes"],
events["sensor_changes"],
colors,
)
st.plotly_chart(fig, width="stretch")
st.caption(
"Driehoekjes = insuline · cirkels = koolhydraten · stippellijn = pompwissel · "
"streepjeslijn = sensorwissel"
)
+204
View File
@@ -0,0 +1,204 @@
"""
Rapportage-logica voor de klinische rapportagepagina's.
Zelfde opzet als data_processing.py: pure functies, geen Streamlit-imports, dus
los te unit-testen. Zie docs/requirements-reporting.md voor de klinische
definities waarop dit gebaseerd is (Battelino et al. 2019, Bergenstal et al.
2018).
"""
from __future__ import annotations
import pandas as pd
# Klinische drempels in mmol/L (Battelino et al. 2019, Diabetes Care 42(8):1593-1603).
VERY_LOW = 3.0
LOW = 3.9
HIGH = 10.0
VERY_HIGH = 13.9
MINUTES_PER_DAY = 24 * 60
def gmi_percent(mean_mmol: float) -> float:
"""Geschat HbA1c (Glucose Management Indicator) uit gemiddelde glucose.
GMI% = 3.31 + 0.4311 * gemiddelde(mmol/L) — Bergenstal et al. 2018,
Diabetes Care 41(11):2275-2280. Dit is GEEN vervanging voor een labwaarde
(kan tot ~0.5 procentpunt afwijken door o.a. rode-bloedcel-factoren) en moet
als zodanig gelabeld worden in de UI.
"""
return round(3.31 + 0.4311 * mean_mmol, 1)
def percent_cv(glucose: pd.DataFrame) -> float:
"""Glucosevariabiliteit als %CV (standaarddeviatie / gemiddelde * 100)."""
if glucose.empty:
return 0.0
mean = glucose["mmol"].mean()
if mean == 0:
return 0.0
return round(glucose["mmol"].std() / mean * 100, 1)
def tir_breakdown_5band(glucose: pd.DataFrame) -> dict[str, float]:
"""Percentage metingen in elk van de 5 klinische glucosebanden.
Banden: very_low (<3.0), low (3.0-3.9), in_range (3.9-10.0),
high (10.0-13.9), very_high (>13.9). Sluit exact aan op de 5-bands AGP-
indeling uit de consensus.
"""
if glucose.empty:
return {"very_low": 0.0, "low": 0.0, "in_range": 0.0, "high": 0.0, "very_high": 0.0}
def _band(mmol: float) -> str:
if mmol < VERY_LOW:
return "very_low"
if mmol < LOW:
return "low"
if mmol <= HIGH:
return "in_range"
if mmol <= VERY_HIGH:
return "high"
return "very_high"
counts = glucose["mmol"].apply(_band).value_counts(normalize=True) * 100
return {
band: round(counts.get(band, 0.0), 1)
for band in ("very_low", "low", "in_range", "high", "very_high")
}
def minutes_per_day(percentage: float) -> float:
"""Reken een percentage van de tijd om naar minuten per dag (ruwe vuistregel)."""
return round(percentage / 100 * MINUTES_PER_DAY, 0)
def sensor_active_percent(glucose: pd.DataFrame, start, end, expected_interval_minutes: int = 5) -> float:
"""Percentage van de periode waarin daadwerkelijk metingen binnenkwamen.
Vuistregel op basis van het verwachte meetinterval (CGM's meten doorgaans
elke 5 minuten) — geen exacte sensor-uptime-registratie, maar voldoende om
een periode met te veel hiaten te signaleren (zie FR9 / NFR "minimaal
geldig rapport").
"""
total_minutes = (pd.Timestamp(end) - pd.Timestamp(start)).total_seconds() / 60
if total_minutes <= 0:
return 0.0
expected_readings = total_minutes / expected_interval_minutes
if expected_readings <= 0:
return 0.0
return round(min(len(glucose) / expected_readings, 1.0) * 100, 1)
def _state_blocks(df: pd.DataFrame, out: pd.Series) -> list[dict]:
"""Groepeer aaneengesloten metingen met dezelfde in/out-status.
Eindtijd van elk blok = starttijd van het volgende blok (de status geldt
tot de eerstvolgende meting anders aangeeft); het laatste blok behoudt
zijn eigen laatste meting als eind.
"""
block_id = (out != out.shift()).cumsum()
blocks = [
{
"is_out": bool(out.loc[group.index[0]]),
"start": group["timestamp"].iloc[0],
"end": group["timestamp"].iloc[-1],
}
for _, group in df.groupby(block_id)
]
for i in range(len(blocks) - 1):
blocks[i]["end"] = blocks[i + 1]["start"]
return blocks
def _maybe_add_episode(events: list[dict], start, end, min_duration_minutes: int) -> None:
duration = (end - start).total_seconds() / 60
if duration >= min_duration_minutes:
events.append({"start": start, "end": end, "duration_minutes": round(duration, 1)})
def _scan_episodes(blocks: list[dict], min_duration_minutes: int) -> list[dict]:
"""Statelijke doorloop van de blokken volgens de persistentieregel.
Een lopende episode wordt alleen verlengd voorbij een kort in-bereik-blok
als er daarna weer een out-blok komt (het korte herstel wordt dan met
terugwerkende kracht "geabsorbeerd"). Een kort slot-blok waarna de data
simpelweg ophoudt telt dus NIET automatisch mee als voortzetting — anders
zou een niet-bevestigd herstel de duur van het event kunstmatig oprekken.
"""
events: list[dict] = []
episode_start = None
episode_end = None
for block in blocks:
if block["is_out"]:
episode_start = episode_start or block["start"]
episode_end = block["end"]
continue
if episode_start is None:
continue
recovery_minutes = (block["end"] - block["start"]).total_seconds() / 60
if recovery_minutes >= min_duration_minutes:
_maybe_add_episode(events, episode_start, episode_end, min_duration_minutes)
episode_start = None
episode_end = None
# anders: kortstondig herstel — episode blijft open, wordt pas
# verlengd als er weer een out-blok volgt (hierboven).
if episode_start is not None:
_maybe_add_episode(events, episode_start, episode_end, min_duration_minutes)
return events
def detect_events(
glucose: pd.DataFrame,
threshold: float,
direction: str,
min_duration_minutes: int = 15,
) -> pd.DataFrame:
"""Detecteer discrete hypo-/hyperglykemie-events volgens de persistentieregel.
Een event begint zodra glucose ononderbroken voorbij de drempel is
geweest voor >= min_duration_minutes, en eindigt pas zodra glucose
ononderbroken hersteld is voor >= min_duration_minutes — een korte terugval
binnen die hersteltijd beëindigt het event dus niet (Battelino et al.
2019). `direction` is "below" (hypo) of "above" (hyper).
Geeft een DataFrame terug met kolommen 'start', 'end', 'duration_minutes'.
"""
if glucose.empty:
return pd.DataFrame(columns=["start", "end", "duration_minutes"])
df = glucose.sort_values("timestamp").reset_index(drop=True)
if direction == "below":
out = df["mmol"] < threshold
elif direction == "above":
out = df["mmol"] > threshold
else:
raise ValueError("direction moet 'below' of 'above' zijn")
blocks = _state_blocks(df, out)
events = _scan_episodes(blocks, min_duration_minutes)
return pd.DataFrame(events, columns=["start", "end", "duration_minutes"])
def summarize_events(events: pd.DataFrame, period_days: float) -> dict[str, float]:
"""Samenvattende statistieken over een set gedetecteerde events."""
if events.empty or period_days <= 0:
return {
"count": 0,
"avg_duration_minutes": 0.0,
"longest_duration_minutes": 0.0,
"total_minutes": 0.0,
"events_per_week": 0.0,
}
total_minutes = events["duration_minutes"].sum()
return {
"count": len(events),
"avg_duration_minutes": round(events["duration_minutes"].mean(), 1),
"longest_duration_minutes": round(events["duration_minutes"].max(), 1),
"total_minutes": round(total_minutes, 1),
"events_per_week": round(len(events) / period_days * 7, 1),
}
+17
View File
@@ -0,0 +1,17 @@
import hashlib
from auth import verify_password
def test_verify_password_accepts_matching_hash():
expected = hashlib.sha256(b"correct-horse").hexdigest()
assert verify_password("correct-horse", expected) is True
def test_verify_password_rejects_wrong_password():
expected = hashlib.sha256(b"correct-horse").hexdigest()
assert verify_password("wrong-password", expected) is False
def test_verify_password_rejects_when_no_hash_configured():
assert verify_password("anything", "") is False
+39 -1
View File
@@ -1,4 +1,4 @@
from datetime import datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
import pytest import pytest
@@ -96,3 +96,41 @@ def pd_isna(value):
import pandas as pd import pandas as pd
return pd.isna(value) return pd.isna(value)
def test_fetch_entries_range_filters_to_date_boundaries(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, 1, 12, 0, tzinfo=timezone.utc).timestamp() * 1000), "sgv": 90},
{"date": int(datetime(2026, 2, 1, 12, 0, tzinfo=timezone.utc).timestamp() * 1000), "sgv": 100},
]
monkeypatch.setattr(ns.requests, "get", lambda *a, **k: FakeResponse(payload))
result = ns.fetch_entries_range(date(2026, 1, 1), date(2026, 1, 31))
assert len(result) == 2 # de 1 feb-meting valt buiten het bereik
assert list(result.columns) == ["timestamp", "mmol"]
def test_fetch_entries_range_handles_empty_response(monkeypatch):
monkeypatch.setenv("NIGHTSCOUT_URL", "https://example.com")
monkeypatch.setattr(ns.requests, "get", lambda *a, **k: FakeResponse([]))
result = ns.fetch_entries_range(date(2026, 1, 1), date(2026, 1, 31))
assert result.empty
def test_fetch_treatments_range_filters_to_date_boundaries(monkeypatch):
monkeypatch.setenv("NIGHTSCOUT_URL", "https://example.com")
payload = [
{"created_at": "2026-01-15T12:00:00Z", "eventType": "Meal Bolus", "insulin": 4.0, "carbs": 40},
{"created_at": "2026-02-01T12:00:00Z", "eventType": "Meal Bolus", "insulin": 3.0, "carbs": 30},
]
monkeypatch.setattr(ns.requests, "get", lambda *a, **k: FakeResponse(payload))
result = ns.fetch_treatments_range(date(2026, 1, 1), date(2026, 1, 31))
assert len(result) == 1
assert result.iloc[0]["insulin"] == pytest.approx(4.0)
+225
View File
@@ -0,0 +1,225 @@
import pandas as pd
import pytest
from reporting import (
detect_events,
gmi_percent,
minutes_per_day,
percent_cv,
sensor_active_percent,
summarize_events,
tir_breakdown_5band,
)
def _series(pairs):
"""Bouw een glucose-DataFrame uit (tijd-string, mmol)-paren."""
timestamps, values = zip(*pairs, strict=True)
return pd.DataFrame({"timestamp": pd.to_datetime(list(timestamps)), "mmol": list(values)})
def test_gmi_percent_matches_bergenstal_formula():
# 3.31 + 0.4311 * 7.0 = 6.3277 -> 6.3
assert gmi_percent(7.0) == pytest.approx(6.3, abs=0.05)
def test_percent_cv_handles_empty_input():
empty = pd.DataFrame(columns=["timestamp", "mmol"])
assert percent_cv(empty) == 0.0
def test_percent_cv_computes_stdev_over_mean():
glucose = _series(
[
("2026-01-01 00:00", 6.0),
("2026-01-01 00:05", 8.0),
("2026-01-01 00:10", 6.0),
("2026-01-01 00:15", 8.0),
]
)
expected = glucose["mmol"].std() / glucose["mmol"].mean() * 100
assert percent_cv(glucose) == pytest.approx(round(expected, 1))
def test_tir_breakdown_5band_covers_all_bands_and_sums_to_100():
glucose = _series(
[
("2026-01-01 00:00", 2.5), # very_low
("2026-01-01 00:05", 3.5), # low
("2026-01-01 00:10", 6.0), # in_range
("2026-01-01 00:15", 11.0), # high
("2026-01-01 00:20", 14.0), # very_high
]
)
result = tir_breakdown_5band(glucose)
assert result == {
"very_low": 20.0,
"low": 20.0,
"in_range": 20.0,
"high": 20.0,
"very_high": 20.0,
}
assert sum(result.values()) == pytest.approx(100.0)
def test_tir_breakdown_5band_handles_empty_input():
empty = pd.DataFrame(columns=["timestamp", "mmol"])
result = tir_breakdown_5band(empty)
assert result == {"very_low": 0.0, "low": 0.0, "in_range": 0.0, "high": 0.0, "very_high": 0.0}
def test_minutes_per_day_converts_percentage():
assert minutes_per_day(50.0) == 720.0
assert minutes_per_day(100.0) == 1440.0
def test_sensor_active_percent_full_coverage():
# 12 metingen om de 5 minuten = precies 1 uur dekking
timestamps = pd.date_range("2026-01-01 00:00", periods=12, freq="5min")
glucose = pd.DataFrame({"timestamp": timestamps, "mmol": [6.0] * 12})
result = sensor_active_percent(glucose, "2026-01-01 00:00", "2026-01-01 01:00")
assert result == pytest.approx(100.0, abs=1.0)
def test_sensor_active_percent_handles_gaps():
timestamps = pd.date_range("2026-01-01 00:00", periods=6, freq="5min") # maar 30 min dekking
glucose = pd.DataFrame({"timestamp": timestamps, "mmol": [6.0] * 6})
result = sensor_active_percent(glucose, "2026-01-01 00:00", "2026-01-01 01:00")
assert result == pytest.approx(50.0, abs=1.0)
def test_detect_events_ignores_dip_shorter_than_minimum_duration():
# dip van 10 minuten (< 15 min drempel) mag niet meetellen
glucose = _series(
[
("2026-01-01 00:00", 6.0),
("2026-01-01 00:05", 3.5),
("2026-01-01 00:10", 3.4),
("2026-01-01 00:15", 6.0),
("2026-01-01 00:20", 6.1),
("2026-01-01 00:25", 6.2),
]
)
events = detect_events(glucose, threshold=3.9, direction="below")
assert events.empty
def test_detect_events_counts_dip_of_exactly_20_minutes():
glucose = _series(
[
("2026-01-01 00:00", 6.0),
("2026-01-01 00:05", 3.5),
("2026-01-01 00:10", 3.4),
("2026-01-01 00:15", 3.3),
("2026-01-01 00:20", 3.2),
("2026-01-01 00:25", 6.0),
("2026-01-01 00:30", 6.1),
("2026-01-01 00:35", 6.2),
("2026-01-01 00:40", 6.3),
]
)
events = detect_events(glucose, threshold=3.9, direction="below")
assert len(events) == 1
assert events.iloc[0]["duration_minutes"] == pytest.approx(20.0)
def test_detect_events_merges_across_brief_recovery():
# twee dips gescheiden door een kort (5 min) herstel moeten één
# doorlopend event vormen volgens de persistentieregel
glucose = _series(
[
("2026-01-01 00:00", 6.0),
("2026-01-01 00:05", 3.5),
("2026-01-01 00:10", 3.4),
("2026-01-01 00:15", 6.0), # kort herstel, 5 min
("2026-01-01 00:20", 3.3),
("2026-01-01 00:25", 3.2),
("2026-01-01 00:30", 3.1),
("2026-01-01 00:35", 6.0),
("2026-01-01 00:40", 6.1),
("2026-01-01 00:45", 6.2),
("2026-01-01 00:50", 6.3), # bevestigd herstel, 15 min
]
)
events = detect_events(glucose, threshold=3.9, direction="below")
assert len(events) == 1
assert events.iloc[0]["start"] == pd.Timestamp("2026-01-01 00:05")
assert events.iloc[0]["end"] == pd.Timestamp("2026-01-01 00:35")
assert events.iloc[0]["duration_minutes"] == pytest.approx(30.0)
def test_detect_events_does_not_inflate_duration_on_trailing_brief_blip():
# regressie: een out-periode van 10 min gevolgd door een niet-bevestigd
# (kort, en aan het eind van de data) herstel mag niet meetellen
glucose = _series(
[
("2026-01-01 00:00", 6.0),
("2026-01-01 00:05", 3.5),
("2026-01-01 00:10", 3.4),
("2026-01-01 00:15", 6.0),
("2026-01-01 00:20", 6.1),
("2026-01-01 00:25", 6.2),
]
)
events = detect_events(glucose, threshold=3.9, direction="below")
assert events.empty
def test_detect_events_supports_hyperglycemia_direction():
glucose = _series(
[
("2026-01-01 00:00", 6.0),
("2026-01-01 00:05", 12.0),
("2026-01-01 00:10", 12.5),
("2026-01-01 00:15", 13.0),
("2026-01-01 00:20", 12.8),
("2026-01-01 00:25", 6.0),
("2026-01-01 00:30", 6.1),
("2026-01-01 00:35", 6.2),
("2026-01-01 00:40", 6.3),
]
)
events = detect_events(glucose, threshold=10.0, direction="above")
assert len(events) == 1
assert events.iloc[0]["duration_minutes"] == pytest.approx(20.0)
def test_detect_events_rejects_invalid_direction():
glucose = _series([("2026-01-01 00:00", 6.0)])
with pytest.raises(ValueError, match="direction"):
detect_events(glucose, threshold=3.9, direction="sideways")
def test_detect_events_handles_empty_input():
empty = pd.DataFrame(columns=["timestamp", "mmol"])
events = detect_events(empty, threshold=3.9, direction="below")
assert events.empty
assert list(events.columns) == ["start", "end", "duration_minutes"]
def test_summarize_events_computes_expected_stats():
events = pd.DataFrame(
{
"start": pd.to_datetime(["2026-01-01 00:00", "2026-01-03 00:00"]),
"end": pd.to_datetime(["2026-01-01 00:20", "2026-01-03 01:00"]),
"duration_minutes": [20.0, 60.0],
}
)
result = summarize_events(events, period_days=7)
assert result["count"] == 2
assert result["avg_duration_minutes"] == pytest.approx(40.0)
assert result["longest_duration_minutes"] == pytest.approx(60.0)
assert result["total_minutes"] == pytest.approx(80.0)
assert result["events_per_week"] == pytest.approx(2.0)
def test_summarize_events_handles_empty_input():
empty = pd.DataFrame(columns=["start", "end", "duration_minutes"])
result = summarize_events(empty, period_days=14)
assert result == {
"count": 0,
"avg_duration_minutes": 0.0,
"longest_duration_minutes": 0.0,
"total_minutes": 0.0,
"events_per_week": 0.0,
}
+57
View File
@@ -0,0 +1,57 @@
"""
Gedeelde UI-hulpmiddelen voor app.py en alle pages/*.py.
Elke Streamlit-pagina draait als los script, dus gedeelde constanten/functies
(kleurenpalet, thema-detectie) horen hier thuis in plaats van gedupliceerd te
worden per pagina.
"""
from __future__ import annotations
import streamlit as st
MMOL_HOVERTEMPLATE = "%{x|%d-%m %H:%M} · %{y:.1f} mmol/L<extra></extra>"
# Kleuren per rol, licht/donker — categorale kleuren voor identiteit
# (glucose/insuline/carbs), status-kleuren (vast, nooit hergebruikt) voor
# te laag/te hoog.
PALETTE = {
"light": {
"glucose": "#2a78d6",
"insulin": "#1baf7a",
"carbs": "#eb6834",
"low": "#d03b3b",
"very_low": "#d03b3b",
"high": "#fab219",
"very_high": "#fab219",
"band": "rgba(12,163,12,0.08)",
"grid": "#e1e0d9",
"muted": "#898781",
"text": "#52514e",
"good": "#0ca30c",
},
"dark": {
"glucose": "#3987e5",
"insulin": "#199e70",
"carbs": "#d95926",
"low": "#d03b3b",
"very_low": "#d03b3b",
"high": "#fab219",
"very_high": "#fab219",
"band": "rgba(12,163,12,0.15)",
"grid": "#2c2c2a",
"muted": "#898781",
"text": "#c3c2b7",
"good": "#0ca30c",
},
}
def get_colors() -> dict:
"""Geef de kleurenset terug die past bij het actieve Streamlit-thema."""
theme_type = "light"
try:
theme_type = st.context.theme.type or "light"
except AttributeError:
pass
return PALETTE["dark" if theme_type == "dark" else "light"]