add reporting foundation: multipage nav, auth gate, date-range fetch
First increment of the clinical reporting feature (see docs/requirements-reporting.md and docs/design-reporting.md): moves the existing live view to pages/1_Live.py, adds a password gate since the dashboard URL will be shared with the diabetes team, adds fetch_entries_range/fetch_treatments_range for multi-day report queries, and adds reporting.py with the core clinical calculations (GMI, %CV, 5-band time-in-range, hypo/hyper event detection per the Battelino et al. 2019 persistence rule). No report pages wired up yet — that's next. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
@@ -10,3 +10,8 @@ NIGHTSCOUT_TOKEN=
|
||||
|
||||
# Optie 2: het klassieke "API secret" van je Nightscout-instantie.
|
||||
# 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=
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
# glucose-dashboard
|
||||
|
||||
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,
|
||||
koolhydraten en pomp-/sensorwissels overlay, plus time-in-range-cijfers.
|
||||
Meerdere pagina's (zie zijbalk): een rollend live-overzicht en een set
|
||||
klinische rapportagepagina's (zie `docs/requirements-reporting.md` en
|
||||
`docs/design-reporting.md`).
|
||||
|
||||
## Instellen
|
||||
|
||||
1. Zorg voor een draaiende Nightscout-instantie en een API-token (Admin Tools
|
||||
> Manage Tokens in Nightscout — geef het token minimaal `readable`-rechten)
|
||||
of het klassieke API secret.
|
||||
2. Kopieer `.env.example` naar `.env` en vul `NIGHTSCOUT_URL` en één van de
|
||||
twee auth-opties in. `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.
|
||||
2. Kopieer `.env.example` naar `.env` en vul `NIGHTSCOUT_URL`, één van de
|
||||
twee auth-opties, en `DASHBOARD_PASSWORD_HASH` in (zie het voorbeeldbestand
|
||||
voor hoe je die hash genereert — de dashboard-URL wordt gedeeld met het
|
||||
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:
|
||||
|
||||
```powershell
|
||||
|
||||
@@ -1,241 +1,23 @@
|
||||
import plotly.graph_objects as go
|
||||
import requests
|
||||
import streamlit as st
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from data_processing import (
|
||||
TARGET_HIGH,
|
||||
TARGET_LOW,
|
||||
classify_treatments,
|
||||
summarize_time_in_range,
|
||||
)
|
||||
from nightscout_client import NightscoutConfigError, fetch_entries, fetch_treatments
|
||||
from auth import require_auth
|
||||
|
||||
load_dotenv() # laadt .env als die bestaat; bestaande env vars blijven leidend
|
||||
|
||||
st.set_page_config(page_title="Glucose dashboard", page_icon="📊", layout="wide")
|
||||
require_auth()
|
||||
|
||||
st.title("Glucose dashboard")
|
||||
|
||||
HOURS_OPTIONS = {"6 uur": 6, "12 uur": 12, "24 uur": 24, "48 uur": 48}
|
||||
|
||||
# Kleuren per rol, licht/donker — zie de dataviz-conventies in dit project:
|
||||
# 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",
|
||||
"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"
|
||||
st.write("Kies een pagina in de zijbalk.")
|
||||
st.markdown(
|
||||
"""
|
||||
- **Live** — rollend overzicht van de laatste uren, met insuline/koolhydraten/pomp-/sensorwissels
|
||||
- **Samenvatting** — AGP-samenvattingsstatistieken en tijd-in-bereik over een gekozen periode
|
||||
- **AGP profiel** — percentielgrafiek over een 24-uurs samengesteld beeld
|
||||
- **Dagoverzicht** — losse glucosegrafiek per dag
|
||||
- **Hypo events** / **Hyper events** — detectie en analyse van te lage/hoge periodes
|
||||
- **Trends** — geschat HbA1c (GMI) over tijd en overige statistieken
|
||||
- **Export** — PDF-rapport van geselecteerde onderdelen
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -18,7 +18,7 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
|
||||
import pandas as pd
|
||||
import requests
|
||||
@@ -75,18 +75,14 @@ def _get(path: str, params: dict) -> list:
|
||||
return response.json()
|
||||
|
||||
|
||||
def fetch_entries(hours: int = 24) -> pd.DataFrame:
|
||||
"""Haal glucosemetingen (SGV) op van de laatste `hours` uur, in mmol/L.
|
||||
def _entries_since(since: datetime, count: int, until: datetime | None = None) -> pd.DataFrame:
|
||||
"""Haal entries op sinds `since` (en optioneel tot `until`), lokaal gefilterd.
|
||||
|
||||
Geeft een DataFrame terug met kolommen 'timestamp' en 'mmol', oplopend
|
||||
gesorteerd op tijd.
|
||||
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.
|
||||
"""
|
||||
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})
|
||||
|
||||
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["mmol"] = df["sgv"].astype(float).apply(mgdl_to_mmol)
|
||||
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)
|
||||
|
||||
|
||||
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)
|
||||
def _treatments_since(since: datetime, count: int, until: datetime | None = None) -> pd.DataFrame:
|
||||
"""Haal treatments op sinds `since` (en optioneel tot `until`), lokaal gefilterd."""
|
||||
data = _get("/api/v1/treatments.json", {"count": count})
|
||||
|
||||
if not data:
|
||||
@@ -122,8 +113,59 @@ def fetch_treatments(hours: int = 24) -> pd.DataFrame:
|
||||
if "eventType" not in df.columns:
|
||||
df["eventType"] = ""
|
||||
df = df[df["timestamp"] >= since.replace(tzinfo=None)]
|
||||
if until is not None:
|
||||
df = df[df["timestamp"] <= until.replace(tzinfo=None)]
|
||||
return (
|
||||
df[["timestamp", "eventType", "insulin", "carbs"]]
|
||||
.sort_values("timestamp")
|
||||
.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
@@ -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
@@ -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),
|
||||
}
|
||||
@@ -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
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -96,3 +96,41 @@ def pd_isna(value):
|
||||
import pandas as pd
|
||||
|
||||
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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user