Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7a0b94a54 | ||
|
|
95b3aff310 | ||
|
|
bd66d3dacd | ||
|
|
47050bf031 | ||
|
|
de0ecebce5 | ||
|
|
caf28046cb | ||
|
|
a4d29e85a8 | ||
|
|
7f4377329a | ||
|
|
c617f4e41b | ||
|
|
a10a4c0414 | ||
|
|
a3d89c4d21 | ||
|
|
1376101212 | ||
|
|
f2c3f97b1f | ||
|
|
d33516e2a1 | ||
|
|
c54dcf616a | ||
|
|
6004f76d76 | ||
|
|
79249958c2 | ||
|
|
8641022219 | ||
|
|
db3204ab58 | ||
|
|
25c3fe6730 | ||
|
|
d637ad39e4 | ||
|
|
5addd6a49b | ||
|
|
95f006078f | ||
|
|
4bb9251d7d | ||
|
|
6c6b175a28 | ||
|
|
c39646ceaa | ||
|
|
52c9284f85 | ||
|
|
419508dd5e | ||
|
|
689452cf4c | ||
|
|
e600d6cf7d | ||
|
|
10aa315e40 | ||
|
|
e3443e9f9f | ||
|
|
274d27c5bc | ||
|
|
f17e4f6c39 |
@@ -5,4 +5,6 @@ __pycache__/
|
|||||||
.git/
|
.git/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
|
.coverage
|
||||||
|
coverage.xml
|
||||||
tests/
|
tests/
|
||||||
|
|||||||
@@ -15,3 +15,9 @@ NIGHTSCOUT_TOKEN=
|
|||||||
# het diabetesteam). Zet hier een sha256-hash, NIET het wachtwoord zelf:
|
# het diabetesteam). Zet hier een sha256-hash, NIET het wachtwoord zelf:
|
||||||
# python -c "import hashlib; print(hashlib.sha256(b'jouw-wachtwoord').hexdigest())"
|
# python -c "import hashlib; print(hashlib.sha256(b'jouw-wachtwoord').hexdigest())"
|
||||||
DASHBOARD_PASSWORD_HASH=
|
DASHBOARD_PASSWORD_HASH=
|
||||||
|
|
||||||
|
# Optioneel: apparatuurinfo voor de kop van het PDF-rapport (pages/8_Export.py).
|
||||||
|
# Puur informatief, geen effect op de berekeningen — leeg = "Onbekend ...".
|
||||||
|
REPORT_INSULIN_TYPE=Lyumjev 100U
|
||||||
|
REPORT_PUMP_TYPE=
|
||||||
|
REPORT_SENSOR_TYPE=
|
||||||
|
|||||||
@@ -19,11 +19,21 @@ jobs:
|
|||||||
run: pip install -r requirements.txt
|
run: pip install -r requirements.txt
|
||||||
- name: Lint (ruff)
|
- name: Lint (ruff)
|
||||||
run: ruff check .
|
run: ruff check .
|
||||||
- name: Unit tests (pytest)
|
- name: Unit tests + coverage-gate (pytest)
|
||||||
run: pytest tests/ -v
|
# fail_under (pyproject.toml, [tool.coverage.report]) laat deze stap
|
||||||
|
# falen als de dekking op de gemeten modules onder 80% zakt — app.py/
|
||||||
|
# pages/*.py zijn uitgesloten (zie pyproject.toml se coverage-omit),
|
||||||
|
# dat zijn dunne Streamlit-UI-scripts die niet zinvol te unit-testen
|
||||||
|
# zijn zonder een draaiende Streamlit-sessie.
|
||||||
|
run: pytest tests/ -v --cov=. --cov-report=term-missing --cov-report=xml
|
||||||
|
- name: Bewaar coverage-rapport
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: coverage-report
|
||||||
|
path: coverage.xml
|
||||||
|
|
||||||
# ---------------------------------------------------------------------
|
# ---------------------------------------------------------------------
|
||||||
# 2. SonarQube — codekwaliteit-gate
|
# 2. SonarQube — codekwaliteit-gate (incl. coverage uit de vorige stap)
|
||||||
# ---------------------------------------------------------------------
|
# ---------------------------------------------------------------------
|
||||||
sonarqube:
|
sonarqube:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -32,6 +42,10 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # SonarQube wil de volledige git-historie zien
|
fetch-depth: 0 # SonarQube wil de volledige git-historie zien
|
||||||
|
- name: Download coverage-rapport
|
||||||
|
uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: coverage-report
|
||||||
- name: SonarQube scan
|
- name: SonarQube scan
|
||||||
uses: sonarsource/sonarqube-scan-action@v6
|
uses: sonarsource/sonarqube-scan-action@v6
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -4,4 +4,6 @@ __pycache__/
|
|||||||
.env
|
.env
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
|
.coverage
|
||||||
|
coverage.xml
|
||||||
homelab-cicd-werkinstructie.md
|
homelab-cicd-werkinstructie.md
|
||||||
@@ -16,7 +16,8 @@ klinische rapportagepagina's (zie `docs/requirements-reporting.md` en
|
|||||||
diabetesteam, dus elke pagina zit achter een wachtwoord). `app.py` laadt
|
diabetesteam, dus elke pagina zit achter een wachtwoord). `app.py` laadt
|
||||||
`.env` zelf in (via `python-dotenv`), dus dit werkt hetzelfde onder
|
`.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
|
PowerShell, cmd of bash — je hoeft de variabelen niet zelf in je shell te
|
||||||
zetten.
|
zetten. `REPORT_INSULIN_TYPE`/`REPORT_PUMP_TYPE`/`REPORT_SENSOR_TYPE` zijn
|
||||||
|
optioneel en puur informatief (getoond in de kop van het PDF-rapport).
|
||||||
3. Lokaal draaien:
|
3. Lokaal draaien:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -40,3 +41,12 @@ klinische rapportagepagina's (zie `docs/requirements-reporting.md` en
|
|||||||
pytest tests/ -v
|
pytest tests/ -v
|
||||||
ruff check .
|
ruff check .
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Coverage (zie `pyproject.toml` se `[tool.coverage.*]`): de CI-pipeline faalt
|
||||||
|
als de dekking op de gemeten modules onder 80% zakt (`app.py`/`pages/*.py`
|
||||||
|
zijn uitgesloten — dunne Streamlit-UI-scripts, niet zinvol te unit-testen
|
||||||
|
zonder een draaiende Streamlit-sessie).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/ --cov=. --cov-report=term-missing
|
||||||
|
```
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<path d="M32 4 C20 22, 10 34, 10 46 A22 22 0 0 0 54 46 C54 34, 44 22, 32 4 Z" fill="#2a78d6"/>
|
||||||
|
<polyline points="14,42 24,42 28,32 34,52 40,38 46,42 50,42" fill="none" stroke="#ffffff" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 325 B |
@@ -17,13 +17,18 @@ import os
|
|||||||
import streamlit as st
|
import streamlit as st
|
||||||
|
|
||||||
|
|
||||||
def require_auth() -> None:
|
def require_auth() -> None: # pragma: no cover
|
||||||
"""Blokkeer de pagina totdat het juiste wachtwoord is ingevoerd.
|
"""Blokkeer de pagina totdat het juiste wachtwoord is ingevoerd.
|
||||||
|
|
||||||
Slaat de status op in st.session_state zodat dit maar één keer per
|
Slaat de status op in st.session_state zodat dit maar één keer per
|
||||||
browsersessie hoeft. Moet als eerste statement van elke pagina aangeroepen
|
browsersessie hoeft. Moet als eerste statement van elke pagina aangeroepen
|
||||||
worden (app.py en elk bestand in pages/), omdat Streamlit iedere pagina
|
worden (app.py en elk bestand in pages/), omdat Streamlit iedere pagina
|
||||||
als los script uitvoert.
|
als los script uitvoert.
|
||||||
|
|
||||||
|
Pure Streamlit-widget-orkestratie (st.text_input/st.session_state/
|
||||||
|
st.rerun) — buiten scope van de unit-testdekking net als pages/*.py,
|
||||||
|
zie pyproject.toml se coverage-omit-lijst. De veiligheidsrelevante
|
||||||
|
logica zelf (verify_password()) is wel volledig getest.
|
||||||
"""
|
"""
|
||||||
if st.session_state.get("authenticated"):
|
if st.session_state.get("authenticated"):
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -252,3 +252,48 @@ def fetch_treatments_range(start_date: date, end_date: date) -> pd.DataFrame:
|
|||||||
since = datetime.combine(start_date, time.min, tzinfo=timezone.utc)
|
since = datetime.combine(start_date, time.min, tzinfo=timezone.utc)
|
||||||
until = datetime.combine(end_date, time.max, tzinfo=timezone.utc)
|
until = datetime.combine(end_date, time.max, tzinfo=timezone.utc)
|
||||||
return _treatments_since(since, until)
|
return _treatments_since(since, until)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_entries_for_day(day: date) -> pd.DataFrame:
|
||||||
|
"""Haal glucosemetingen op voor precies één kalenderdag.
|
||||||
|
|
||||||
|
Dunne wrapper om fetch_entries_range() met start=end=day, voor de
|
||||||
|
dag-navigatie op de Dagoverzicht-pagina: laadt alleen de geselecteerde
|
||||||
|
dag in plaats van een hele periode, zodat het wisselen tussen dagen snel
|
||||||
|
blijft.
|
||||||
|
"""
|
||||||
|
return fetch_entries_range(day, day)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_treatments_for_day(day: date, lookback_hours: int = 6) -> pd.DataFrame:
|
||||||
|
"""Haal behandelingen op voor één kalenderdag, met een lookback-marge vóór middernacht.
|
||||||
|
|
||||||
|
reporting.effective_basal_series() heeft de starttijd van een Temp Basal
|
||||||
|
nodig om de basaalsnelheid vroeg in de dag correct te berekenen — ook als
|
||||||
|
die temp basal al vóór middernacht is gestart en doorloopt. Zonder deze
|
||||||
|
marge zou de eerste uren van elke los opgevraagde dag ten onrechte het
|
||||||
|
profieltempo tonen in plaats van een nog actieve temp basal. 6 uur ligt
|
||||||
|
ruim boven de gebruikelijke temp-basal-duur in gesloten-lus-modus
|
||||||
|
(doorgaans 30-60 minuten).
|
||||||
|
"""
|
||||||
|
since = datetime.combine(day, time.min, tzinfo=timezone.utc) - timedelta(hours=lookback_hours)
|
||||||
|
until = datetime.combine(day, time.max, tzinfo=timezone.utc)
|
||||||
|
return _treatments_since(since, until)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_profile() -> dict:
|
||||||
|
"""Haal het meest recent gebruikte pompprofiel op.
|
||||||
|
|
||||||
|
Nightscout's /api/v1/profile.json geeft profieldocumenten terug, nieuwste
|
||||||
|
eerst; met count=1 krijgen we alleen het actief gebruikte document. Een
|
||||||
|
profiel is geen tijdreeks (in tegenstelling tot entries/treatments) dus
|
||||||
|
geen paginering nodig — één klein verzoek volstaat.
|
||||||
|
|
||||||
|
Geeft {} terug als er geen profiel bestaat (bijv. lege Nightscout-instantie).
|
||||||
|
Het resultaat is het rauwe Nightscout-documument; zie profile_data.parse_profile()
|
||||||
|
voor de genormaliseerde vorm (basaalschema, ISF, koolhydraatratio, DIA, streefwaarden).
|
||||||
|
"""
|
||||||
|
data = _get("/api/v1/profile.json", {"count": 1})
|
||||||
|
if not data:
|
||||||
|
return {}
|
||||||
|
return data[0]
|
||||||
|
|||||||
+78
-31
@@ -1,6 +1,8 @@
|
|||||||
|
import pandas as pd
|
||||||
import plotly.graph_objects as go
|
import plotly.graph_objects as go
|
||||||
import requests
|
import requests
|
||||||
import streamlit as st
|
import streamlit as st
|
||||||
|
from plotly.subplots import make_subplots
|
||||||
|
|
||||||
from auth import require_auth
|
from auth import require_auth
|
||||||
from data_processing import (
|
from data_processing import (
|
||||||
@@ -10,7 +12,15 @@ from data_processing import (
|
|||||||
summarize_time_in_range,
|
summarize_time_in_range,
|
||||||
)
|
)
|
||||||
from nightscout_client import NightscoutConfigError, fetch_entries, fetch_treatments
|
from nightscout_client import NightscoutConfigError, fetch_entries, fetch_treatments
|
||||||
from ui_common import MMOL_HOVERTEMPLATE, get_colors
|
from reporting import classify_bolus, effective_basal_series, target_glucose_series
|
||||||
|
from ui_common import (
|
||||||
|
MMOL_HOVERTEMPLATE,
|
||||||
|
add_basal_area_trace,
|
||||||
|
add_bolus_marker_traces,
|
||||||
|
add_target_line_trace,
|
||||||
|
get_colors,
|
||||||
|
load_profile,
|
||||||
|
)
|
||||||
|
|
||||||
st.set_page_config(page_title="Glucose dashboard — Live", page_icon="📊", layout="wide")
|
st.set_page_config(page_title="Glucose dashboard — Live", page_icon="📊", layout="wide")
|
||||||
require_auth()
|
require_auth()
|
||||||
@@ -27,7 +37,28 @@ def load_nightscout_data(hours: int):
|
|||||||
return glucose, treatments
|
return glucose, treatments
|
||||||
|
|
||||||
|
|
||||||
def build_chart(glucose, insulin, carbs, site_changes, sensor_changes, colors) -> go.Figure:
|
def _basal_and_target_series(profile: dict, treatments: pd.DataFrame, window_start, window_end):
|
||||||
|
"""Basaal- en streefwaarde-reeksen over een rollend venster dat meerdere
|
||||||
|
kalenderdagen kan overspannen: per dag berekend (reporting.effective_basal_series/
|
||||||
|
target_glucose_series zijn dag-begrensd) en daarna aan elkaar geplakt en
|
||||||
|
geclipt tot het venster."""
|
||||||
|
dates = pd.date_range(window_start.normalize(), window_end.normalize(), freq="D")
|
||||||
|
basal_parts, target_parts = [], []
|
||||||
|
for day_start in dates:
|
||||||
|
day_end = day_start + pd.Timedelta(1, unit="D")
|
||||||
|
basal_parts.append(effective_basal_series(profile, treatments, day_start, day_end))
|
||||||
|
target_parts.append(target_glucose_series(profile, day_start, day_end))
|
||||||
|
|
||||||
|
basal = pd.concat(basal_parts, ignore_index=True) if basal_parts else pd.DataFrame(columns=["timestamp", "rate"])
|
||||||
|
target = (
|
||||||
|
pd.concat(target_parts, ignore_index=True) if target_parts else pd.DataFrame(columns=["timestamp", "target"])
|
||||||
|
)
|
||||||
|
basal = basal[(basal["timestamp"] >= window_start) & (basal["timestamp"] <= window_end)]
|
||||||
|
target = target[(target["timestamp"] >= window_start) & (target["timestamp"] <= window_end)]
|
||||||
|
return basal.reset_index(drop=True), target.reset_index(drop=True)
|
||||||
|
|
||||||
|
|
||||||
|
def build_chart(glucose, bolus, carbs, basal, target, site_changes, sensor_changes, colors) -> go.Figure:
|
||||||
glucose_min = min(glucose["mmol"].min(), TARGET_LOW)
|
glucose_min = min(glucose["mmol"].min(), TARGET_LOW)
|
||||||
glucose_max = max(glucose["mmol"].max(), TARGET_HIGH)
|
glucose_max = max(glucose["mmol"].max(), TARGET_HIGH)
|
||||||
y_top = glucose_max + 1.0
|
y_top = glucose_max + 1.0
|
||||||
@@ -36,7 +67,7 @@ def build_chart(glucose, insulin, carbs, site_changes, sensor_changes, colors) -
|
|||||||
insulin_lane_y = lane_base + 1.5
|
insulin_lane_y = lane_base + 1.5
|
||||||
carbs_lane_y = lane_base + 0.7
|
carbs_lane_y = lane_base + 0.7
|
||||||
|
|
||||||
fig = go.Figure()
|
fig = make_subplots(specs=[[{"secondary_y": True}]])
|
||||||
|
|
||||||
# Streefbereik als achtergrondband, geen losse legenda-entry nodig.
|
# Streefbereik als achtergrondband, geen losse legenda-entry nodig.
|
||||||
fig.add_hrect(
|
fig.add_hrect(
|
||||||
@@ -45,8 +76,13 @@ def build_chart(glucose, insulin, carbs, site_changes, sensor_changes, colors) -
|
|||||||
fillcolor=colors["band"],
|
fillcolor=colors["band"],
|
||||||
line_width=0,
|
line_width=0,
|
||||||
layer="below",
|
layer="below",
|
||||||
|
row=1,
|
||||||
|
col=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
add_basal_area_trace(fig, basal, colors, row=1, secondary_y=True)
|
||||||
|
add_target_line_trace(fig, target, colors, row=1)
|
||||||
|
|
||||||
fig.add_trace(
|
fig.add_trace(
|
||||||
go.Scatter(
|
go.Scatter(
|
||||||
x=glucose["timestamp"],
|
x=glucose["timestamp"],
|
||||||
@@ -55,7 +91,9 @@ def build_chart(glucose, insulin, carbs, site_changes, sensor_changes, colors) -
|
|||||||
name="Glucose",
|
name="Glucose",
|
||||||
line={"color": colors["glucose"], "width": 2, "shape": "spline"},
|
line={"color": colors["glucose"], "width": 2, "shape": "spline"},
|
||||||
hovertemplate=MMOL_HOVERTEMPLATE,
|
hovertemplate=MMOL_HOVERTEMPLATE,
|
||||||
)
|
),
|
||||||
|
row=1,
|
||||||
|
col=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
low_points = glucose[glucose["mmol"] < TARGET_LOW]
|
low_points = glucose[glucose["mmol"] < TARGET_LOW]
|
||||||
@@ -69,7 +107,9 @@ def build_chart(glucose, insulin, carbs, site_changes, sensor_changes, colors) -
|
|||||||
name=f"Laag (<{TARGET_LOW})",
|
name=f"Laag (<{TARGET_LOW})",
|
||||||
marker={"color": colors["low"], "size": 6},
|
marker={"color": colors["low"], "size": 6},
|
||||||
hovertemplate=MMOL_HOVERTEMPLATE,
|
hovertemplate=MMOL_HOVERTEMPLATE,
|
||||||
)
|
),
|
||||||
|
row=1,
|
||||||
|
col=1,
|
||||||
)
|
)
|
||||||
if not high_points.empty:
|
if not high_points.empty:
|
||||||
fig.add_trace(
|
fig.add_trace(
|
||||||
@@ -80,21 +120,12 @@ def build_chart(glucose, insulin, carbs, site_changes, sensor_changes, colors) -
|
|||||||
name=f"Hoog (>{TARGET_HIGH})",
|
name=f"Hoog (>{TARGET_HIGH})",
|
||||||
marker={"color": colors["high"], "size": 6},
|
marker={"color": colors["high"], "size": 6},
|
||||||
hovertemplate=MMOL_HOVERTEMPLATE,
|
hovertemplate=MMOL_HOVERTEMPLATE,
|
||||||
)
|
),
|
||||||
|
row=1,
|
||||||
|
col=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not insulin.empty:
|
add_bolus_marker_traces(fig, bolus, colors, insulin_lane_y, row=1)
|
||||||
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:
|
if not carbs.empty:
|
||||||
fig.add_trace(
|
fig.add_trace(
|
||||||
@@ -106,14 +137,16 @@ def build_chart(glucose, insulin, carbs, site_changes, sensor_changes, colors) -
|
|||||||
marker={"color": colors["carbs"], "size": 10, "symbol": "circle"},
|
marker={"color": colors["carbs"], "size": 10, "symbol": "circle"},
|
||||||
customdata=carbs["carbs"],
|
customdata=carbs["carbs"],
|
||||||
hovertemplate="%{x|%d-%m %H:%M} · %{customdata:.0f} g<extra></extra>",
|
hovertemplate="%{x|%d-%m %H:%M} · %{customdata:.0f} g<extra></extra>",
|
||||||
)
|
),
|
||||||
|
row=1,
|
||||||
|
col=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Let op: annotation_text/-position via add_vline() zelf triggert een bug
|
# Let op: annotation_text/-position via add_vline() zelf triggert een bug
|
||||||
# in plotly's shapeannotation-helper met pandas Timestamp-x-waarden, dus
|
# in plotly's shapeannotation-helper met pandas Timestamp-x-waarden, dus
|
||||||
# de lijn en het label worden hier los toegevoegd.
|
# de lijn en het label worden hier los toegevoegd.
|
||||||
for _, row in site_changes.iterrows():
|
for _, row in site_changes.iterrows():
|
||||||
fig.add_vline(x=row["timestamp"], line_dash="dot", line_color=colors["muted"], opacity=0.6)
|
fig.add_vline(x=row["timestamp"], line_dash="dot", line_color=colors["muted"], opacity=0.6, row=1, col=1)
|
||||||
fig.add_annotation(
|
fig.add_annotation(
|
||||||
x=row["timestamp"],
|
x=row["timestamp"],
|
||||||
y=y_top,
|
y=y_top,
|
||||||
@@ -121,9 +154,11 @@ def build_chart(glucose, insulin, carbs, site_changes, sensor_changes, colors) -
|
|||||||
text="Pomp",
|
text="Pomp",
|
||||||
showarrow=False,
|
showarrow=False,
|
||||||
font={"color": colors["muted"], "size": 10},
|
font={"color": colors["muted"], "size": 10},
|
||||||
|
row=1,
|
||||||
|
col=1,
|
||||||
)
|
)
|
||||||
for _, row in sensor_changes.iterrows():
|
for _, row in sensor_changes.iterrows():
|
||||||
fig.add_vline(x=row["timestamp"], line_dash="dash", line_color=colors["muted"], opacity=0.6)
|
fig.add_vline(x=row["timestamp"], line_dash="dash", line_color=colors["muted"], opacity=0.6, row=1, col=1)
|
||||||
fig.add_annotation(
|
fig.add_annotation(
|
||||||
x=row["timestamp"],
|
x=row["timestamp"],
|
||||||
y=y_top,
|
y=y_top,
|
||||||
@@ -131,6 +166,8 @@ def build_chart(glucose, insulin, carbs, site_changes, sensor_changes, colors) -
|
|||||||
text="Sensor",
|
text="Sensor",
|
||||||
showarrow=False,
|
showarrow=False,
|
||||||
font={"color": colors["muted"], "size": 10},
|
font={"color": colors["muted"], "size": 10},
|
||||||
|
row=1,
|
||||||
|
col=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
fig.update_layout(
|
fig.update_layout(
|
||||||
@@ -140,14 +177,18 @@ def build_chart(glucose, insulin, carbs, site_changes, sensor_changes, colors) -
|
|||||||
legend={"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "left", "x": 0},
|
legend={"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "left", "x": 0},
|
||||||
margin={"l": 10, "r": 10, "t": 10, "b": 10},
|
margin={"l": 10, "r": 10, "t": 10, "b": 10},
|
||||||
height=480,
|
height=480,
|
||||||
yaxis={
|
|
||||||
"title": "mmol/L",
|
|
||||||
"range": [y_bottom, y_top],
|
|
||||||
"gridcolor": colors["grid"],
|
|
||||||
"zeroline": False,
|
|
||||||
},
|
|
||||||
xaxis={"gridcolor": colors["grid"]},
|
|
||||||
)
|
)
|
||||||
|
fig.update_yaxes(
|
||||||
|
title_text="mmol/L",
|
||||||
|
range=[y_bottom, y_top],
|
||||||
|
gridcolor=colors["grid"],
|
||||||
|
zeroline=False,
|
||||||
|
row=1,
|
||||||
|
col=1,
|
||||||
|
secondary_y=False,
|
||||||
|
)
|
||||||
|
fig.update_yaxes(title_text="Basaal (E/uur)", showgrid=False, row=1, col=1, secondary_y=True)
|
||||||
|
fig.update_xaxes(gridcolor=colors["grid"], row=1, col=1)
|
||||||
return fig
|
return fig
|
||||||
|
|
||||||
|
|
||||||
@@ -160,6 +201,7 @@ with st.sidebar:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
glucose, treatments = load_nightscout_data(hours)
|
glucose, treatments = load_nightscout_data(hours)
|
||||||
|
profile = load_profile()
|
||||||
except NightscoutConfigError as exc:
|
except NightscoutConfigError as exc:
|
||||||
st.error(str(exc))
|
st.error(str(exc))
|
||||||
st.stop()
|
st.stop()
|
||||||
@@ -172,7 +214,9 @@ if glucose.empty:
|
|||||||
st.stop()
|
st.stop()
|
||||||
|
|
||||||
events = classify_treatments(treatments)
|
events = classify_treatments(treatments)
|
||||||
|
bolus = classify_bolus(treatments)
|
||||||
tir = summarize_time_in_range(glucose)
|
tir = summarize_time_in_range(glucose)
|
||||||
|
basal, target = _basal_and_target_series(profile, treatments, glucose["timestamp"].min(), glucose["timestamp"].max())
|
||||||
|
|
||||||
latest = glucose.iloc[-1]
|
latest = glucose.iloc[-1]
|
||||||
previous = glucose.iloc[-2] if len(glucose) > 1 else None
|
previous = glucose.iloc[-2] if len(glucose) > 1 else None
|
||||||
@@ -192,14 +236,17 @@ colors = get_colors()
|
|||||||
|
|
||||||
fig = build_chart(
|
fig = build_chart(
|
||||||
glucose,
|
glucose,
|
||||||
events["insulin"],
|
bolus,
|
||||||
events["carbs"],
|
events["carbs"],
|
||||||
|
basal,
|
||||||
|
target,
|
||||||
events["site_changes"],
|
events["site_changes"],
|
||||||
events["sensor_changes"],
|
events["sensor_changes"],
|
||||||
colors,
|
colors,
|
||||||
)
|
)
|
||||||
st.plotly_chart(fig, width="stretch")
|
st.plotly_chart(fig, width="stretch")
|
||||||
st.caption(
|
st.caption(
|
||||||
"Driehoekjes = insuline · cirkels = koolhydraten · stippellijn = pompwissel · "
|
"Stippen = bolusinsuline (voeding/correctie/SMB, grootte naar dosis) · cirkels = koolhydraten · "
|
||||||
"streepjeslijn = sensorwissel"
|
"vlak = basaal · streepstippellijn = streefwaarde uit pompprofiel · "
|
||||||
|
"stippellijn = pompwissel · streepjeslijn = sensorwissel"
|
||||||
)
|
)
|
||||||
|
|||||||
+162
-44
@@ -1,15 +1,31 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
import plotly.graph_objects as go
|
import plotly.graph_objects as go
|
||||||
import requests
|
import requests
|
||||||
import streamlit as st
|
import streamlit as st
|
||||||
from plotly.subplots import make_subplots
|
from plotly.subplots import make_subplots
|
||||||
|
|
||||||
from auth import require_auth
|
from auth import require_auth
|
||||||
from data_processing import TARGET_HIGH, TARGET_LOW
|
from data_processing import TARGET_HIGH, TARGET_LOW, classify_treatments
|
||||||
from nightscout_client import NightscoutConfigError
|
from nightscout_client import NightscoutConfigError
|
||||||
|
from reporting import (
|
||||||
|
BOLUS_CATEGORY_SMB,
|
||||||
|
classify_bolus,
|
||||||
|
daily_stats,
|
||||||
|
effective_basal_series,
|
||||||
|
target_glucose_series,
|
||||||
|
)
|
||||||
from ui_common import (
|
from ui_common import (
|
||||||
|
MMOL_HOVERTEMPLATE,
|
||||||
|
add_basal_area_trace,
|
||||||
|
add_bolus_marker_traces,
|
||||||
|
add_carb_bars_trace,
|
||||||
|
add_target_line_trace,
|
||||||
add_threshold_lines,
|
add_threshold_lines,
|
||||||
get_colors,
|
get_colors,
|
||||||
load_range_data,
|
load_day_data,
|
||||||
|
load_profile,
|
||||||
render_completeness_warning,
|
render_completeness_warning,
|
||||||
select_period_days,
|
select_period_days,
|
||||||
)
|
)
|
||||||
@@ -18,17 +34,49 @@ st.set_page_config(page_title="Glucose dashboard — Dagoverzicht", page_icon="
|
|||||||
require_auth()
|
require_auth()
|
||||||
|
|
||||||
st.title("Dagoverzicht")
|
st.title("Dagoverzicht")
|
||||||
st.caption("Eén glucosegrafiek per kalenderdag (FR4).")
|
st.caption(
|
||||||
|
"Eén dag per keer: boven glucose met koolhydraten/bolus-overlay, onder basaal "
|
||||||
|
"en SMB — apart gehouden omdat één gecombineerde grafiek te veel informatie bevatte."
|
||||||
|
)
|
||||||
|
|
||||||
start, end, days = select_period_days(default_label="14 dagen")
|
# De periode-knop bepaalt alleen het navigeerbare venster (hoe ver terug je kunt
|
||||||
if days > 31:
|
# bladeren), niet welke data wordt opgehaald — dat gebeurt hieronder voor precies
|
||||||
st.info(
|
# de geselecteerde dag (load_day_data), zodat het wisselen tussen dagen snel blijft.
|
||||||
"Bij langere periodes wordt de pagina lang en kan laden traag zijn — "
|
window_start, window_end, days = select_period_days(default_label="14 dagen")
|
||||||
"voor het bekijken van individuele dagen is 14 of 30 dagen prettiger."
|
|
||||||
|
state_key = "dagoverzicht_selected_day"
|
||||||
|
if state_key not in st.session_state:
|
||||||
|
st.session_state[state_key] = window_end
|
||||||
|
if not (window_start <= st.session_state[state_key] <= window_end):
|
||||||
|
st.session_state[state_key] = window_end
|
||||||
|
|
||||||
|
selected_day = st.session_state[state_key]
|
||||||
|
|
||||||
|
col_prev, col_date, col_next = st.columns([1, 3, 1])
|
||||||
|
with col_prev:
|
||||||
|
if st.button("← Vorige dag", disabled=selected_day <= window_start, width="stretch"):
|
||||||
|
selected_day = selected_day - timedelta(days=1)
|
||||||
|
st.session_state[state_key] = selected_day
|
||||||
|
with col_next:
|
||||||
|
if st.button("Volgende dag →", disabled=selected_day >= window_end, width="stretch"):
|
||||||
|
selected_day = selected_day + timedelta(days=1)
|
||||||
|
st.session_state[state_key] = selected_day
|
||||||
|
with col_date:
|
||||||
|
date_options = [window_end - timedelta(days=i) for i in range(days)]
|
||||||
|
chosen = st.selectbox(
|
||||||
|
"Datum",
|
||||||
|
options=date_options,
|
||||||
|
index=date_options.index(selected_day),
|
||||||
|
format_func=lambda d: d.strftime("%A %d-%m-%Y"),
|
||||||
|
label_visibility="collapsed",
|
||||||
)
|
)
|
||||||
|
if chosen != selected_day:
|
||||||
|
selected_day = chosen
|
||||||
|
st.session_state[state_key] = selected_day
|
||||||
|
|
||||||
try:
|
try:
|
||||||
glucose, _treatments = load_range_data(start, end)
|
glucose, treatments = load_day_data(selected_day)
|
||||||
|
profile = load_profile()
|
||||||
except NightscoutConfigError as exc:
|
except NightscoutConfigError as exc:
|
||||||
st.error(str(exc))
|
st.error(str(exc))
|
||||||
st.stop()
|
st.stop()
|
||||||
@@ -37,54 +85,124 @@ except (requests.RequestException, ValueError) as exc:
|
|||||||
st.stop()
|
st.stop()
|
||||||
|
|
||||||
if glucose.empty:
|
if glucose.empty:
|
||||||
st.info("Geen glucosemetingen gevonden in deze periode.")
|
st.info("Geen glucosemetingen gevonden op deze dag.")
|
||||||
st.stop()
|
st.stop()
|
||||||
|
|
||||||
render_completeness_warning(glucose, start, end)
|
render_completeness_warning(glucose, selected_day, selected_day)
|
||||||
|
|
||||||
colors = get_colors()
|
colors = get_colors()
|
||||||
glucose = glucose.copy()
|
day_start = pd.Timestamp(selected_day)
|
||||||
glucose["date"] = glucose["timestamp"].dt.date
|
day_end = day_start + pd.Timedelta(1, unit="D")
|
||||||
glucose["hour"] = glucose["timestamp"].dt.hour + glucose["timestamp"].dt.minute / 60
|
|
||||||
|
|
||||||
dates = sorted(glucose["date"].unique())
|
# treatments bevat een lookback-marge vóór middernacht (zie
|
||||||
row_height = 150
|
# nightscout_client.fetch_treatments_for_day) zodat een temp basal die al
|
||||||
fig = make_subplots(
|
# vóór deze dag begon toch meetelt — effective_basal_series() clipt dat
|
||||||
rows=len(dates),
|
# zelf aan [day_start, day_end). Voor de koolhydraten/bolus-overlays willen
|
||||||
cols=1,
|
# we die marge juist NIET, anders duiken gisteravond se stippen op vandaag op.
|
||||||
shared_xaxes=True,
|
day_treatments = treatments[(treatments["timestamp"] >= day_start) & (treatments["timestamp"] < day_end)]
|
||||||
subplot_titles=[d.strftime("%A %d-%m-%Y") for d in dates],
|
|
||||||
vertical_spacing=min(0.4 / max(len(dates) - 1, 1), 0.05),
|
|
||||||
)
|
|
||||||
|
|
||||||
for i, day in enumerate(dates, start=1):
|
carbs_day = classify_treatments(day_treatments)["carbs"]
|
||||||
day_df = glucose[glucose["date"] == day]
|
bolus_day = classify_bolus(day_treatments)
|
||||||
fig.add_hrect(
|
basal_day = effective_basal_series(profile, treatments, day_start, day_end)
|
||||||
y0=TARGET_LOW, y1=TARGET_HIGH, fillcolor=colors["band"], line_width=0, layer="below", row=i, col=1
|
target_day = target_glucose_series(profile, day_start, day_end)
|
||||||
|
|
||||||
|
|
||||||
|
def _close_step_to_day_end(df: pd.DataFrame, value_col: str) -> pd.DataFrame:
|
||||||
|
"""Voeg een laatste punt op day_end toe (zelfde waarde als het laatste
|
||||||
|
segment) zodat een stapvlak/-lijn de volle dag doortekent i.p.v. te
|
||||||
|
stoppen bij het laatste breekpunt."""
|
||||||
|
if df.empty:
|
||||||
|
return df
|
||||||
|
last_value = df.iloc[-1][value_col]
|
||||||
|
closing_row = pd.DataFrame([{"timestamp": day_end, value_col: last_value}])
|
||||||
|
return pd.concat([df, closing_row], ignore_index=True)
|
||||||
|
|
||||||
|
|
||||||
|
basal_day = _close_step_to_day_end(basal_day, "rate")
|
||||||
|
target_day = _close_step_to_day_end(target_day, "target")
|
||||||
|
|
||||||
|
|
||||||
|
def build_day_figure(glucose_day, carbs_day, bolus_day, basal_day, target_day, colors) -> go.Figure:
|
||||||
|
fig = make_subplots(
|
||||||
|
rows=2,
|
||||||
|
cols=1,
|
||||||
|
shared_xaxes=True,
|
||||||
|
row_heights=[0.68, 0.32],
|
||||||
|
vertical_spacing=0.08,
|
||||||
|
specs=[[{"secondary_y": True}], [{"secondary_y": False}]],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Rij 1: glucose + streefwaarde + koolhydraten + voeding-/correctiebolus.
|
||||||
|
fig.add_hrect(
|
||||||
|
y0=TARGET_LOW, y1=TARGET_HIGH, fillcolor=colors["band"], line_width=0, layer="below", row=1, col=1
|
||||||
|
)
|
||||||
|
add_target_line_trace(fig, target_day, colors, row=1)
|
||||||
|
add_carb_bars_trace(fig, carbs_day, colors, row=1, secondary_y=True)
|
||||||
|
|
||||||
|
top_bolus = bolus_day[bolus_day["category"] != BOLUS_CATEGORY_SMB] if not bolus_day.empty else bolus_day
|
||||||
|
lane_y = glucose_day["mmol"].min() - 1.0 if not glucose_day.empty else TARGET_LOW - 1.0
|
||||||
|
add_bolus_marker_traces(fig, top_bolus, colors, lane_y, row=1)
|
||||||
|
|
||||||
fig.add_trace(
|
fig.add_trace(
|
||||||
go.Scatter(
|
go.Scatter(
|
||||||
x=day_df["hour"],
|
x=glucose_day["timestamp"],
|
||||||
y=day_df["mmol"],
|
y=glucose_day["mmol"],
|
||||||
mode="lines",
|
mode="lines",
|
||||||
line={"color": colors["glucose"], "width": 2},
|
line={"color": colors["glucose"], "width": 2},
|
||||||
showlegend=False,
|
name="Glucose",
|
||||||
hovertemplate="%{y:.1f} mmol/L<extra></extra>",
|
hovertemplate=MMOL_HOVERTEMPLATE,
|
||||||
),
|
),
|
||||||
row=i,
|
row=1,
|
||||||
col=1,
|
col=1,
|
||||||
)
|
)
|
||||||
fig.update_yaxes(title_text="mmol/L", gridcolor=colors["grid"], row=i, col=1)
|
add_threshold_lines(fig, colors, row=1)
|
||||||
add_threshold_lines(fig, colors, row=i)
|
fig.update_yaxes(title_text="mmol/L", gridcolor=colors["grid"], row=1, col=1, secondary_y=False)
|
||||||
|
fig.update_yaxes(title_text="Koolhydraten (g)", showgrid=False, row=1, col=1, secondary_y=True)
|
||||||
|
|
||||||
fig.update_xaxes(title_text="Uur van de dag", range=[0, 24], dtick=4, gridcolor=colors["grid"])
|
# Rij 2: basaal + SMB — apart van rij 1 zodat de glucosegrafiek niet
|
||||||
fig.update_layout(
|
# overladen raakt met insulinedetails.
|
||||||
paper_bgcolor="rgba(0,0,0,0)",
|
add_basal_area_trace(fig, basal_day, colors, row=2)
|
||||||
plot_bgcolor="rgba(0,0,0,0)",
|
smb_bolus = bolus_day[bolus_day["category"] == BOLUS_CATEGORY_SMB] if not bolus_day.empty else bolus_day
|
||||||
font_color=colors["text"],
|
smb_lane_y = (basal_day["rate"].max() * 1.15) if not basal_day.empty else 1.0
|
||||||
height=row_height * len(dates),
|
add_bolus_marker_traces(fig, smb_bolus, colors, smb_lane_y, row=2)
|
||||||
showlegend=False,
|
fig.update_yaxes(
|
||||||
margin={"l": 10, "r": 10, "t": 30, "b": 10},
|
title_text="Basaal (E/uur)", gridcolor=colors["grid"], rangemode="tozero", row=2, col=1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fig.update_xaxes(range=[day_start, day_end], dtick=4 * 3600 * 1000, gridcolor=colors["grid"])
|
||||||
|
fig.update_layout(
|
||||||
|
paper_bgcolor="rgba(0,0,0,0)",
|
||||||
|
plot_bgcolor="rgba(0,0,0,0)",
|
||||||
|
font_color=colors["text"],
|
||||||
|
height=560,
|
||||||
|
showlegend=True,
|
||||||
|
legend={"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "left", "x": 0},
|
||||||
|
margin={"l": 10, "r": 10, "t": 40, "b": 10},
|
||||||
|
)
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
|
fig = build_day_figure(glucose, carbs_day, bolus_day, basal_day, target_day, colors)
|
||||||
st.plotly_chart(fig, width="stretch")
|
st.plotly_chart(fig, width="stretch")
|
||||||
st.caption(f"{len(dates)} dagen · {len(glucose)} metingen")
|
|
||||||
|
stats = daily_stats(glucose, day_treatments, profile, day_start, day_end)
|
||||||
|
col1, col2, col3, col4 = st.columns(4)
|
||||||
|
col1.metric(
|
||||||
|
"Glucose (gem./min/max)",
|
||||||
|
f"{stats['mean_mmol']:.1f} mmol/L",
|
||||||
|
delta=f"{stats['min_mmol']:.1f} - {stats['max_mmol']:.1f}",
|
||||||
|
delta_color="off",
|
||||||
|
)
|
||||||
|
col2.metric("Totale insuline", f"{stats['total_insulin']:.1f} E")
|
||||||
|
col3.metric("Basaal / bolus / SMB", f"{stats['basal_insulin']:.1f} / {stats['bolus_insulin']:.1f} / {stats['smb_insulin']:.1f} E")
|
||||||
|
col4.metric(
|
||||||
|
"Hypo's / hyper's",
|
||||||
|
f"{stats['hypo_count']} / {stats['hyper_count']}",
|
||||||
|
delta=f"{stats['hypo_duration_minutes']:.0f} / {stats['hyper_duration_minutes']:.0f} min",
|
||||||
|
delta_color="off",
|
||||||
|
)
|
||||||
|
|
||||||
|
st.caption(
|
||||||
|
"Boven: stippen = bolusinsuline (voeding/correctie, grootte naar dosis) · staafjes = koolhydraten · "
|
||||||
|
"streepstippellijn = streefwaarde uit pompprofiel. Onder: vlak = basaal · stippen = SMB."
|
||||||
|
)
|
||||||
|
|||||||
+9
-3
@@ -5,7 +5,12 @@ from auth import require_auth
|
|||||||
from nightscout_client import NightscoutConfigError
|
from nightscout_client import NightscoutConfigError
|
||||||
from pdf_export import AVAILABLE_SECTIONS, SECTION_EVENTS, build_report_pdf
|
from pdf_export import AVAILABLE_SECTIONS, SECTION_EVENTS, build_report_pdf
|
||||||
from reporting import HIGH, LOW, VERY_HIGH, VERY_LOW, detect_events, summarize_events
|
from reporting import HIGH, LOW, VERY_HIGH, VERY_LOW, detect_events, summarize_events
|
||||||
from ui_common import load_range_data, render_completeness_warning, select_period_days
|
from ui_common import (
|
||||||
|
load_profile,
|
||||||
|
load_range_data,
|
||||||
|
render_completeness_warning,
|
||||||
|
select_period_days,
|
||||||
|
)
|
||||||
|
|
||||||
st.set_page_config(page_title="Glucose dashboard — Export", page_icon="📊", layout="wide")
|
st.set_page_config(page_title="Glucose dashboard — Export", page_icon="📊", layout="wide")
|
||||||
require_auth()
|
require_auth()
|
||||||
@@ -17,7 +22,8 @@ start, end, days = select_period_days(default_label="90 dagen")
|
|||||||
sections = st.multiselect("Onderdelen", AVAILABLE_SECTIONS, default=AVAILABLE_SECTIONS)
|
sections = st.multiselect("Onderdelen", AVAILABLE_SECTIONS, default=AVAILABLE_SECTIONS)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
glucose, _treatments = load_range_data(start, end)
|
glucose, treatments = load_range_data(start, end)
|
||||||
|
profile = load_profile()
|
||||||
except NightscoutConfigError as exc:
|
except NightscoutConfigError as exc:
|
||||||
st.error(str(exc))
|
st.error(str(exc))
|
||||||
st.stop()
|
st.stop()
|
||||||
@@ -43,7 +49,7 @@ if SECTION_EVENTS in sections:
|
|||||||
events = detect_events(glucose, threshold, direction)
|
events = detect_events(glucose, threshold, direction)
|
||||||
events_summary[label] = summarize_events(events, days)
|
events_summary[label] = summarize_events(events, days)
|
||||||
|
|
||||||
pdf_bytes = build_report_pdf(sections, glucose, events_summary, start, end)
|
pdf_bytes = build_report_pdf(sections, glucose, events_summary, start, end, treatments=treatments, profile=profile)
|
||||||
|
|
||||||
st.download_button(
|
st.download_button(
|
||||||
"Download PDF",
|
"Download PDF",
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import pandas as pd
|
||||||
|
import plotly.graph_objects as go
|
||||||
|
import requests
|
||||||
|
import streamlit as st
|
||||||
|
|
||||||
|
from auth import require_auth
|
||||||
|
from nightscout_client import NightscoutConfigError
|
||||||
|
from profile_data import segments_table, segments_to_hourly_bars, total_daily_basal
|
||||||
|
from ui_common import get_colors, hex_to_rgba, load_profile
|
||||||
|
|
||||||
|
st.set_page_config(page_title="Glucose dashboard — Insulineprofiel", page_icon="📊", layout="wide")
|
||||||
|
require_auth()
|
||||||
|
|
||||||
|
st.title("Insulineprofiel")
|
||||||
|
st.caption("Het meest recent gebruikte pompprofiel: basaalschema, ISF, koolhydraatratio en DIA.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
profile = load_profile()
|
||||||
|
except NightscoutConfigError as exc:
|
||||||
|
st.error(str(exc))
|
||||||
|
st.stop()
|
||||||
|
except (requests.RequestException, ValueError) as exc:
|
||||||
|
st.error(f"Kon geen profiel ophalen van Nightscout: {exc}")
|
||||||
|
st.stop()
|
||||||
|
|
||||||
|
if not profile:
|
||||||
|
st.info("Geen pompprofiel gevonden op deze Nightscout-instantie.")
|
||||||
|
st.stop()
|
||||||
|
|
||||||
|
colors = get_colors()
|
||||||
|
|
||||||
|
col1, col2 = st.columns(2)
|
||||||
|
col1.metric("DIA (werkingsduur insuline)", f"{profile['dia']:.1f} uur")
|
||||||
|
col2.caption(f"Profielnaam: {profile['name'] or '—'}")
|
||||||
|
|
||||||
|
st.subheader("Basaalschema")
|
||||||
|
bars = segments_to_hourly_bars(profile["basal"])
|
||||||
|
fig = go.Figure(
|
||||||
|
go.Scatter(
|
||||||
|
x=[b["hour"] for b in bars],
|
||||||
|
y=[b["value"] for b in bars],
|
||||||
|
mode="lines",
|
||||||
|
line={"shape": "hv", "color": colors["basal"], "width": 2},
|
||||||
|
fill="tozeroy",
|
||||||
|
fillcolor=hex_to_rgba(colors["basal"], 0.3),
|
||||||
|
opacity=0.8,
|
||||||
|
name="Basaal",
|
||||||
|
hovertemplate="%{x:.0f}:00 · %{y:.2f} E/uur<extra></extra>",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
fig.update_layout(
|
||||||
|
paper_bgcolor="rgba(0,0,0,0)",
|
||||||
|
plot_bgcolor="rgba(0,0,0,0)",
|
||||||
|
font_color=colors["text"],
|
||||||
|
height=320,
|
||||||
|
margin={"l": 10, "r": 10, "t": 10, "b": 10},
|
||||||
|
xaxis={"title": "Uur van de dag", "range": [0, 24], "dtick": 2, "gridcolor": colors["grid"]},
|
||||||
|
yaxis={"title": "E/uur", "gridcolor": colors["grid"], "rangemode": "tozero"},
|
||||||
|
showlegend=False,
|
||||||
|
)
|
||||||
|
st.plotly_chart(fig, width="stretch")
|
||||||
|
|
||||||
|
basal_rows = segments_table(profile["basal"])
|
||||||
|
st.dataframe(
|
||||||
|
pd.DataFrame(basal_rows).rename(
|
||||||
|
columns={"start": "Van", "end": "Tot", "value": "Basaalsnelheid (E/uur)"}
|
||||||
|
),
|
||||||
|
width="stretch",
|
||||||
|
hide_index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
col_isf, col_ratio = st.columns(2)
|
||||||
|
with col_isf:
|
||||||
|
st.subheader("Insulinegevoeligheid (ISF)")
|
||||||
|
isf_rows = segments_table(profile["sens"])
|
||||||
|
st.dataframe(
|
||||||
|
pd.DataFrame(isf_rows).rename(
|
||||||
|
columns={"start": "Van", "end": "Tot", "value": "ISF (mmol/L per E)"}
|
||||||
|
),
|
||||||
|
width="stretch",
|
||||||
|
hide_index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with col_ratio:
|
||||||
|
st.subheader("Koolhydraatratio")
|
||||||
|
ratio_rows = segments_table(profile["carbratio"])
|
||||||
|
st.dataframe(
|
||||||
|
pd.DataFrame(ratio_rows).rename(
|
||||||
|
columns={"start": "Van", "end": "Tot", "value": "Ratio (g koolhydraten per E)"}
|
||||||
|
),
|
||||||
|
width="stretch",
|
||||||
|
hide_index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
st.caption(
|
||||||
|
f"Totale dagelijkse basaal: {total_daily_basal(profile['basal']):.1f} E "
|
||||||
|
"(volgens profielschema, exclusief tijdelijke aanpassingen)."
|
||||||
|
)
|
||||||
+384
-97
@@ -2,15 +2,23 @@
|
|||||||
PDF-rapportgeneratie voor geselecteerde rapportageonderdelen (FR10).
|
PDF-rapportgeneratie voor geselecteerde rapportageonderdelen (FR10).
|
||||||
|
|
||||||
Gebruikt fpdf2 (puur Python, geen systeemdependencies) voor de PDF-opbouw en
|
Gebruikt fpdf2 (puur Python, geen systeemdependencies) voor de PDF-opbouw en
|
||||||
matplotlib voor een eenvoudige statische AGP-grafiek specifiek voor de PDF —
|
matplotlib voor de statische grafieken specifiek voor de PDF — bewust niet
|
||||||
bewust niet dezelfde Plotly-figuren als op de pagina's zelf (die zouden
|
dezelfde Plotly-figuren als op de pagina's zelf (die zouden kaleido vereisen,
|
||||||
kaleido vereisen, met een ingebakken Chromium-build: een groter image en meer
|
met een ingebakken Chromium-build: een groter image en meer Trivy-oppervlak,
|
||||||
Trivy-oppervlak, voor dit doel niet nodig).
|
voor dit doel niet nodig).
|
||||||
|
|
||||||
|
Elke sectie is één (of, voor Dagoverzicht, meerdere) liggende A4-pagina('s)
|
||||||
|
met een vaste kop-/voettekst (device-info, paginanummering, genereerdatum,
|
||||||
|
Nightscout-link) via de GlucoseReportPDF-subklasse hieronder — geïnspireerd op
|
||||||
|
de LibreView/AGP-rapportopmaak (zie docs/design-reporting.md).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import io
|
import io
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import matplotlib
|
import matplotlib
|
||||||
|
|
||||||
@@ -20,34 +28,54 @@ import pandas as pd
|
|||||||
from fpdf import FPDF
|
from fpdf import FPDF
|
||||||
from fpdf.enums import XPos, YPos
|
from fpdf.enums import XPos, YPos
|
||||||
|
|
||||||
|
from profile_data import segments_table, segments_to_hourly_bars, total_daily_basal
|
||||||
from reporting import (
|
from reporting import (
|
||||||
|
BOLUS_CATEGORY_CORRECTION,
|
||||||
|
BOLUS_CATEGORY_FOOD,
|
||||||
|
BOLUS_CATEGORY_SMB,
|
||||||
HIGH,
|
HIGH,
|
||||||
LOW,
|
LOW,
|
||||||
VERY_HIGH,
|
VERY_HIGH,
|
||||||
VERY_LOW,
|
VERY_LOW,
|
||||||
agp_percentile_bands,
|
agp_percentile_bands,
|
||||||
|
classify_bolus,
|
||||||
completeness_warning,
|
completeness_warning,
|
||||||
|
daily_stats,
|
||||||
detect_events,
|
detect_events,
|
||||||
|
effective_basal_series,
|
||||||
|
glucose_min_max,
|
||||||
gmi_mmol_per_mol,
|
gmi_mmol_per_mol,
|
||||||
gmi_percent,
|
gmi_percent,
|
||||||
percent_cv,
|
percent_cv,
|
||||||
sensor_active_percent,
|
sensor_active_percent,
|
||||||
|
target_glucose_series,
|
||||||
tir_breakdown_5band,
|
tir_breakdown_5band,
|
||||||
)
|
)
|
||||||
|
|
||||||
SECTION_SUMMARY = "Samenvatting"
|
SECTION_SUMMARY = "Samenvatting"
|
||||||
SECTION_AGP = "AGP profiel"
|
SECTION_AGP = "AGP profiel"
|
||||||
|
SECTION_INSULIN_PROFILE = "Insulineprofiel"
|
||||||
SECTION_DAGOVERZICHT = "Dagoverzicht"
|
SECTION_DAGOVERZICHT = "Dagoverzicht"
|
||||||
SECTION_EVENTS = "Hypo-/hyperevents"
|
SECTION_EVENTS = "Hypo-/hyperevents"
|
||||||
|
|
||||||
AVAILABLE_SECTIONS = [SECTION_SUMMARY, SECTION_AGP, SECTION_DAGOVERZICHT, SECTION_EVENTS]
|
AVAILABLE_SECTIONS = [
|
||||||
|
SECTION_SUMMARY,
|
||||||
|
SECTION_AGP,
|
||||||
|
SECTION_INSULIN_PROFILE,
|
||||||
|
SECTION_DAGOVERZICHT,
|
||||||
|
SECTION_EVENTS,
|
||||||
|
]
|
||||||
|
|
||||||
|
REPORT_VERSION = "1.0"
|
||||||
|
_ICON_PATH = Path(__file__).parent / "assets" / "icon.svg"
|
||||||
|
|
||||||
_LINE_KWARGS = {"new_x": XPos.LMARGIN, "new_y": YPos.NEXT}
|
_LINE_KWARGS = {"new_x": XPos.LMARGIN, "new_y": YPos.NEXT}
|
||||||
|
_SAME_LINE_KWARGS = {"new_x": XPos.RIGHT, "new_y": YPos.TOP}
|
||||||
|
|
||||||
# Zelfde kleuren als het PALETTE["light"] in ui_common.py — bewust hier
|
# Zelfde kleuren als het PALETTE["light"] in ui_common.py — bewust hier
|
||||||
# gedupliceerd (5 hex-waarden) in plaats van ui_common.py te importeren, want
|
# gedupliceerd in plaats van ui_common.py te importeren, want dat zou een
|
||||||
# dat zou een streamlit-dependency in deze module trekken die verder bewust
|
# streamlit-dependency in deze module trekken die verder bewust vrij van
|
||||||
# vrij van UI-framework-imports is (los te unit-testen, zie moduledocstring).
|
# UI-framework-imports is (los te unit-testen, zie moduledocstring).
|
||||||
_MUTED = "#898781"
|
_MUTED = "#898781"
|
||||||
_TIR_BAND_ORDER = ["very_low", "low", "in_range", "high", "very_high"]
|
_TIR_BAND_ORDER = ["very_low", "low", "in_range", "high", "very_high"]
|
||||||
_TIR_BAND_LABELS = {
|
_TIR_BAND_LABELS = {
|
||||||
@@ -65,33 +93,83 @@ _TIR_COLORS = {
|
|||||||
"very_high": "#c76f0a",
|
"very_high": "#c76f0a",
|
||||||
}
|
}
|
||||||
_THRESHOLD_LINES = [VERY_LOW, LOW, HIGH, VERY_HIGH]
|
_THRESHOLD_LINES = [VERY_LOW, LOW, HIGH, VERY_HIGH]
|
||||||
|
_BASAL_COLOR = "#7b5ea3"
|
||||||
|
_CARBS_COLOR = "#eb6834"
|
||||||
|
_TARGET_COLOR = "#52514e"
|
||||||
|
_BOLUS_CATEGORY_STYLE = {
|
||||||
|
BOLUS_CATEGORY_FOOD: ("#1baf7a", "Bolus (voeding)"),
|
||||||
|
BOLUS_CATEGORY_CORRECTION: ("#2a78d6", "Bolus (correctie)"),
|
||||||
|
BOLUS_CATEGORY_SMB: ("#898781", "SMB"),
|
||||||
|
}
|
||||||
|
_EMPTY_TREATMENT_COLUMNS = ["timestamp", "eventType", "insulin", "carbs", "type", "absolute", "duration"]
|
||||||
DAGOVERZICHT_MAX_DAYS = 14
|
DAGOVERZICHT_MAX_DAYS = 14
|
||||||
|
_HOUR_OF_DAY_LABEL = "Uur van de dag"
|
||||||
|
|
||||||
|
|
||||||
def _add_threshold_lines(ax) -> None:
|
def _report_config() -> dict:
|
||||||
"""Teken de 4 klinische drempellijnen (3.0/3.9/10.0/13.9 mmol/L) op een matplotlib-as.
|
"""Rapportmetadata uit environment variables (insuline/pomp/sensor-type,
|
||||||
|
Nightscout-URL) — zelfde os.environ-patroon als nightscout_client.py.
|
||||||
PDF-tegenhanger van ui_common.add_threshold_lines() voor de Plotly-versie
|
Alles heeft een fallback zodat een rapport ook zonder configuratie werkt."""
|
||||||
op de dashboardpagina's (gitea #15) — zelfde 4 waarden, zelfde grijstint.
|
return {
|
||||||
"""
|
"insulin_type": os.environ.get("REPORT_INSULIN_TYPE", "").strip() or "Onbekend insulinetype",
|
||||||
for value in _THRESHOLD_LINES:
|
"pump_type": os.environ.get("REPORT_PUMP_TYPE", "").strip() or "Onbekende pomp",
|
||||||
ax.axhline(value, color=_MUTED, linewidth=0.7, linestyle=":")
|
"sensor_type": os.environ.get("REPORT_SENSOR_TYPE", "").strip() or "Onbekende sensor",
|
||||||
|
"nightscout_url": os.environ.get("NIGHTSCOUT_URL", "").strip().rstrip("/"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _agp_figure_png(glucose: pd.DataFrame) -> bytes:
|
class GlucoseReportPDF(FPDF):
|
||||||
bands = agp_percentile_bands(glucose)
|
"""FPDF-subklasse met een vaste kop-/voettekst op elke pagina en één sectie
|
||||||
fig, ax = plt.subplots(figsize=(7, 3))
|
per liggende pagina (zie begin_report_section())."""
|
||||||
hours = bands["minute_of_day"] / 60
|
|
||||||
ax.fill_between(hours, bands["p5"], bands["p95"], alpha=0.15, color="#2a78d6", label="5e-95e pct")
|
|
||||||
ax.fill_between(hours, bands["p25"], bands["p75"], alpha=0.3, color="#2a78d6", label="25e-75e pct")
|
|
||||||
ax.plot(hours, bands["p50"], color="#2a78d6", linewidth=2, label="Mediaan")
|
|
||||||
_add_threshold_lines(ax)
|
|
||||||
ax.set_xlabel("Uur van de dag")
|
|
||||||
ax.set_ylabel("mmol/L")
|
|
||||||
ax.set_xlim(0, 24)
|
|
||||||
ax.legend(loc="upper right", fontsize=8)
|
|
||||||
fig.tight_layout()
|
|
||||||
|
|
||||||
|
def __init__(self, config: dict, generated_at: datetime) -> None:
|
||||||
|
super().__init__(orientation="L", unit="mm", format="A4")
|
||||||
|
self._config = config
|
||||||
|
self._generated_at = generated_at
|
||||||
|
self.section_title = ""
|
||||||
|
self.set_auto_page_break(auto=True, margin=18)
|
||||||
|
self.alias_nb_pages()
|
||||||
|
|
||||||
|
def begin_report_section(self, title: str) -> None:
|
||||||
|
"""Begin een nieuwe pagina voor sectie `title`. Voor secties die over
|
||||||
|
meerdere pagina's lopen (Dagoverzicht: één pagina per dag) wordt dit
|
||||||
|
per pagina aangeroepen zodat de kop op elke pagina klopt."""
|
||||||
|
self.section_title = title
|
||||||
|
self.add_page()
|
||||||
|
|
||||||
|
def header(self) -> None:
|
||||||
|
if _ICON_PATH.exists():
|
||||||
|
self.image(str(_ICON_PATH), x=10, y=8, w=8)
|
||||||
|
self.set_xy(20, 8)
|
||||||
|
self.set_font("Helvetica", "B", 14)
|
||||||
|
self.cell(0, 6, self.section_title, **_LINE_KWARGS)
|
||||||
|
self.set_x(20)
|
||||||
|
self.set_font("Helvetica", "", 8)
|
||||||
|
self.set_text_color(120, 120, 120)
|
||||||
|
device_line = (
|
||||||
|
f"Glucoserapport v{REPORT_VERSION} · Insuline: {self._config['insulin_type']} · "
|
||||||
|
f"Pomp: {self._config['pump_type']} · Sensor: {self._config['sensor_type']}"
|
||||||
|
)
|
||||||
|
self.cell(0, 4, device_line, **_LINE_KWARGS)
|
||||||
|
self.set_text_color(0, 0, 0)
|
||||||
|
self.ln(3)
|
||||||
|
self.set_draw_color(200, 200, 200)
|
||||||
|
self.line(10, self.get_y(), self.w - 10, self.get_y())
|
||||||
|
self.ln(4)
|
||||||
|
|
||||||
|
def footer(self) -> None:
|
||||||
|
self.set_y(-14)
|
||||||
|
self.set_font("Helvetica", "I", 8)
|
||||||
|
self.set_text_color(120, 120, 120)
|
||||||
|
left = f"Gegenereerd: {self._generated_at.strftime('%d-%m-%Y %H:%M')}"
|
||||||
|
if self._config["nightscout_url"]:
|
||||||
|
left += f" · {self._config['nightscout_url']}"
|
||||||
|
self.cell(200, 8, left, **_SAME_LINE_KWARGS)
|
||||||
|
self.cell(0, 8, f"Pagina {self.page_no()}/{{nb}}", align="R")
|
||||||
|
self.set_text_color(0, 0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _fig_to_png(fig) -> bytes:
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
fig.savefig(buf, format="png", dpi=150)
|
fig.savefig(buf, format="png", dpi=150)
|
||||||
plt.close(fig)
|
plt.close(fig)
|
||||||
@@ -99,10 +177,33 @@ def _agp_figure_png(glucose: pd.DataFrame) -> bytes:
|
|||||||
return buf.getvalue()
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _add_threshold_lines(ax) -> None:
|
||||||
|
"""Teken de 4 klinische drempellijnen (3.0/3.9/10.0/13.9 mmol/L) op een matplotlib-as."""
|
||||||
|
for value in _THRESHOLD_LINES:
|
||||||
|
ax.axhline(value, color=_MUTED, linewidth=0.7, linestyle=":")
|
||||||
|
|
||||||
|
|
||||||
|
def _agp_figure_png(glucose: pd.DataFrame) -> bytes:
|
||||||
|
bands = agp_percentile_bands(glucose)
|
||||||
|
fig, ax = plt.subplots(figsize=(11, 4.2))
|
||||||
|
hours = bands["minute_of_day"] / 60
|
||||||
|
ax.axhspan(LOW, HIGH, color="#0ca30c", alpha=0.08, label="Doelbereik")
|
||||||
|
ax.fill_between(hours, bands["p5"], bands["p95"], alpha=0.15, color="#2a78d6", label="5e-95e pct")
|
||||||
|
ax.fill_between(hours, bands["p25"], bands["p75"], alpha=0.3, color="#2a78d6", label="25e-75e pct")
|
||||||
|
ax.plot(hours, bands["p50"], color="#2a78d6", linewidth=2.5, label="Mediaan")
|
||||||
|
_add_threshold_lines(ax)
|
||||||
|
ax.set_xlabel(_HOUR_OF_DAY_LABEL)
|
||||||
|
ax.set_ylabel("mmol/L")
|
||||||
|
ax.set_xlim(0, 24)
|
||||||
|
ax.set_xticks(range(0, 25, 3))
|
||||||
|
ax.legend(loc="upper right", fontsize=9, ncol=4, frameon=False)
|
||||||
|
fig.tight_layout()
|
||||||
|
return _fig_to_png(fig)
|
||||||
|
|
||||||
|
|
||||||
def _tir_bar_figure_png(tir: dict) -> bytes:
|
def _tir_bar_figure_png(tir: dict) -> bytes:
|
||||||
"""Gestapelde staafgrafiek van de 5 TIR-banden — PDF-tegenhanger van
|
"""Gestapelde staafgrafiek van de 5 TIR-banden."""
|
||||||
ui_common.build_tir_stacked_bar_figure() (gitea #14)."""
|
fig, ax = plt.subplots(figsize=(9, 1.1))
|
||||||
fig, ax = plt.subplots(figsize=(7, 1.1))
|
|
||||||
left = 0.0
|
left = 0.0
|
||||||
for band in _TIR_BAND_ORDER:
|
for band in _TIR_BAND_ORDER:
|
||||||
value = tir[band]
|
value = tir[band]
|
||||||
@@ -123,52 +224,147 @@ def _tir_bar_figure_png(tir: dict) -> bytes:
|
|||||||
return buf.getvalue()
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
def _daily_figure_png(day_df: pd.DataFrame, day_label: str) -> bytes:
|
def _insulin_profile_figure_png(profile: dict) -> bytes:
|
||||||
"""Eén dag als kleine lijngrafiek — PDF-tegenhanger van de Dagoverzicht-pagina (gitea #16)."""
|
bars = segments_to_hourly_bars(profile.get("basal", []))
|
||||||
hours = day_df["timestamp"].dt.hour + day_df["timestamp"].dt.minute / 60
|
fig, ax = plt.subplots(figsize=(11, 3.2))
|
||||||
fig, ax = plt.subplots(figsize=(7, 1.4))
|
if bars:
|
||||||
ax.plot(hours, day_df["mmol"], color="#2a78d6", linewidth=1.2)
|
hours = [b["hour"] for b in bars]
|
||||||
_add_threshold_lines(ax)
|
values = [b["value"] for b in bars]
|
||||||
|
ax.step(hours, values, where="post", color=_BASAL_COLOR, linewidth=1.5, alpha=0.8)
|
||||||
|
ax.fill_between(hours, values, step="post", color=_BASAL_COLOR, alpha=0.3)
|
||||||
ax.set_xlim(0, 24)
|
ax.set_xlim(0, 24)
|
||||||
ax.set_xticks(range(0, 25, 4))
|
ax.set_xticks(range(0, 25, 2))
|
||||||
ax.set_ylabel("mmol/L", fontsize=8)
|
ax.set_ylim(bottom=0)
|
||||||
ax.set_title(day_label, fontsize=9, loc="left")
|
ax.set_xlabel(_HOUR_OF_DAY_LABEL)
|
||||||
ax.tick_params(labelsize=7)
|
ax.set_ylabel("E/uur")
|
||||||
fig.tight_layout()
|
fig.tight_layout()
|
||||||
|
return _fig_to_png(fig)
|
||||||
|
|
||||||
buf = io.BytesIO()
|
|
||||||
fig.savefig(buf, format="png", dpi=150)
|
def _add_basal_overlay(ax2, basal: pd.DataFrame, day_start) -> None:
|
||||||
plt.close(fig)
|
if basal.empty:
|
||||||
buf.seek(0)
|
return
|
||||||
return buf.getvalue()
|
hours = (basal["timestamp"] - day_start).dt.total_seconds() / 3600
|
||||||
|
hours = pd.concat([hours, pd.Series([24.0])], ignore_index=True)
|
||||||
|
values = pd.concat([basal["rate"], basal["rate"].iloc[-1:]], ignore_index=True)
|
||||||
|
ax2.fill_between(hours, values, step="post", color=_BASAL_COLOR, alpha=0.3, label="Basaal")
|
||||||
|
ax2.step(hours, values, where="post", color=_BASAL_COLOR, linewidth=1, alpha=0.8)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_carbs_overlay(ax2, day_treatments: pd.DataFrame, day_start) -> None:
|
||||||
|
if day_treatments.empty:
|
||||||
|
return
|
||||||
|
carbs = day_treatments[day_treatments["carbs"].fillna(0) > 0]
|
||||||
|
if carbs.empty:
|
||||||
|
return
|
||||||
|
hours = (carbs["timestamp"] - day_start).dt.total_seconds() / 3600
|
||||||
|
ax2.bar(hours, carbs["carbs"], width=0.25, color=_CARBS_COLOR, alpha=0.55, label="Koolhydraten")
|
||||||
|
|
||||||
|
|
||||||
|
def _add_bolus_overlay(ax, bolus: pd.DataFrame, day_start, lane_y: float, categories: tuple[str, ...]) -> None:
|
||||||
|
for category in categories:
|
||||||
|
subset = bolus[bolus["category"] == category] if not bolus.empty else bolus
|
||||||
|
if subset.empty:
|
||||||
|
continue
|
||||||
|
color, label = _BOLUS_CATEGORY_STYLE[category]
|
||||||
|
hours = (subset["timestamp"] - day_start).dt.total_seconds() / 3600
|
||||||
|
sizes = (subset["insulin"] * 25).clip(lower=15, upper=200)
|
||||||
|
ax.scatter(hours, [lane_y] * len(subset), s=sizes, color=color, alpha=0.75, label=label, zorder=5)
|
||||||
|
if category != BOLUS_CATEGORY_SMB:
|
||||||
|
for x, units in zip(hours, subset["insulin"], strict=True):
|
||||||
|
ax.annotate(
|
||||||
|
f"{units:.1f}E", (x, lane_y), textcoords="offset points", xytext=(0, 8), fontsize=6, ha="center"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_target_overlay(ax, profile: dict | None, day_start, day_end) -> None:
|
||||||
|
target = target_glucose_series(profile, day_start, day_end)
|
||||||
|
if target.empty:
|
||||||
|
return
|
||||||
|
hours = (target["timestamp"] - day_start).dt.total_seconds() / 3600
|
||||||
|
hours = pd.concat([hours, pd.Series([24.0])], ignore_index=True)
|
||||||
|
values = pd.concat([target["target"], target["target"].iloc[-1:]], ignore_index=True)
|
||||||
|
ax.step(hours, values, where="post", color=_TARGET_COLOR, linewidth=1.2, linestyle="-.", label="Streefwaarde (pomp)")
|
||||||
|
|
||||||
|
|
||||||
|
def _daily_figure_png(
|
||||||
|
day_df: pd.DataFrame, day_treatments: pd.DataFrame, profile: dict | None, day_label: str, day_start, day_end
|
||||||
|
) -> bytes:
|
||||||
|
"""Eén dag als twee gestapelde grafieken (gedeelde tijdas, één gedeelde legenda):
|
||||||
|
boven glucose + streefwaarde + koolhydraten + voeding/correctiebolus, onder
|
||||||
|
basaal + SMB — gescheiden omdat één gecombineerde grafiek te veel informatie
|
||||||
|
bevatte om nog leesbaar te zijn."""
|
||||||
|
hours = day_df["timestamp"].dt.hour + day_df["timestamp"].dt.minute / 60
|
||||||
|
fig, (ax, ax_basal) = plt.subplots(2, 1, figsize=(11, 5.2), sharex=True, height_ratios=[2.2, 1])
|
||||||
|
ax2 = ax.twinx()
|
||||||
|
|
||||||
|
bolus = classify_bolus(day_treatments)
|
||||||
|
_add_carbs_overlay(ax2, day_treatments, day_start)
|
||||||
|
lane_y = (day_df["mmol"].min() - 1.0) if not day_df.empty else LOW - 1.0
|
||||||
|
_add_bolus_overlay(ax, bolus, day_start, lane_y, categories=(BOLUS_CATEGORY_CORRECTION, BOLUS_CATEGORY_FOOD))
|
||||||
|
_add_target_overlay(ax, profile, day_start, day_end)
|
||||||
|
|
||||||
|
ax.plot(hours, day_df["mmol"], color="#2a78d6", linewidth=1.4, label="Glucose", zorder=6)
|
||||||
|
_add_threshold_lines(ax)
|
||||||
|
ax.set_ylabel("mmol/L", fontsize=9)
|
||||||
|
ax2.set_ylabel("Koolhydraten (g)", fontsize=9)
|
||||||
|
ax.set_title(day_label, fontsize=11, loc="left")
|
||||||
|
ax.tick_params(labelsize=8)
|
||||||
|
ax2.tick_params(labelsize=8)
|
||||||
|
|
||||||
|
basal = effective_basal_series(profile, day_treatments, day_start, day_end)
|
||||||
|
_add_basal_overlay(ax_basal, basal, day_start)
|
||||||
|
smb_lane_y = (basal["rate"].max() * 1.15) if not basal.empty else 1.0
|
||||||
|
_add_bolus_overlay(ax_basal, bolus, day_start, smb_lane_y, categories=(BOLUS_CATEGORY_SMB,))
|
||||||
|
ax_basal.set_xlim(0, 24)
|
||||||
|
ax_basal.set_xticks(range(0, 25, 2))
|
||||||
|
ax_basal.set_xlabel(_HOUR_OF_DAY_LABEL)
|
||||||
|
ax_basal.set_ylabel("Basaal (E/uur)", fontsize=9)
|
||||||
|
ax_basal.tick_params(labelsize=8)
|
||||||
|
|
||||||
|
lines1, labels1 = ax.get_legend_handles_labels()
|
||||||
|
lines2, labels2 = ax2.get_legend_handles_labels()
|
||||||
|
lines3, labels3 = ax_basal.get_legend_handles_labels()
|
||||||
|
ax_basal.legend(
|
||||||
|
lines1 + lines2 + lines3,
|
||||||
|
labels1 + labels2 + labels3,
|
||||||
|
loc="upper center",
|
||||||
|
bbox_to_anchor=(0.5, -0.35),
|
||||||
|
ncol=6,
|
||||||
|
fontsize=7,
|
||||||
|
frameon=False,
|
||||||
|
)
|
||||||
|
# fig.tight_layout() introspecteert elke as se bounding box om marges te
|
||||||
|
# berekenen, en matplotlib ondersteunt dat niet betrouwbaar zodra er twee
|
||||||
|
# hoofd-assen (ax/ax_basal) plus een twinx (ax2) tegelijk in de figuur
|
||||||
|
# zitten (waarschuwing "Axes ... not compatible with tight_layout").
|
||||||
|
# subplots_adjust() met vaste marges omzeilt die introspectie.
|
||||||
|
fig.subplots_adjust(left=0.06, right=0.94, top=0.93, bottom=0.24, hspace=0.35)
|
||||||
|
return _fig_to_png(fig)
|
||||||
|
|
||||||
|
|
||||||
def _event_hour_histogram_png(events: pd.DataFrame, color: str) -> bytes:
|
def _event_hour_histogram_png(events: pd.DataFrame, color: str) -> bytes:
|
||||||
"""Aantal events per uur-van-de-dag — PDF-tegenhanger van render_event_level()'s
|
"""Aantal events per uur-van-de-dag."""
|
||||||
histogram op de Hypo/Hyper Events-pagina's (gitea #17, #18)."""
|
|
||||||
hour_counts = events["start"].dt.hour.value_counts().reindex(range(24), fill_value=0).sort_index()
|
hour_counts = events["start"].dt.hour.value_counts().reindex(range(24), fill_value=0).sort_index()
|
||||||
fig, ax = plt.subplots(figsize=(7, 2))
|
fig, ax = plt.subplots(figsize=(11, 2.6))
|
||||||
ax.bar(hour_counts.index, hour_counts.to_numpy(), color=color)
|
ax.bar(hour_counts.index, hour_counts.to_numpy(), color=color)
|
||||||
ax.set_xlabel("Uur van de dag")
|
ax.set_xlabel(_HOUR_OF_DAY_LABEL)
|
||||||
ax.set_ylabel("Aantal events")
|
ax.set_ylabel("Aantal events")
|
||||||
ax.set_xlim(-0.5, 23.5)
|
ax.set_xlim(-0.5, 23.5)
|
||||||
ax.set_xticks(range(0, 24, 2))
|
ax.set_xticks(range(0, 24, 2))
|
||||||
fig.tight_layout()
|
fig.tight_layout()
|
||||||
|
return _fig_to_png(fig)
|
||||||
buf = io.BytesIO()
|
|
||||||
fig.savefig(buf, format="png", dpi=150)
|
|
||||||
plt.close(fig)
|
|
||||||
buf.seek(0)
|
|
||||||
return buf.getvalue()
|
|
||||||
|
|
||||||
|
|
||||||
def _add_summary_section(pdf: FPDF, glucose: pd.DataFrame, start, end) -> None:
|
def _add_summary_section(pdf: GlucoseReportPDF, glucose: pd.DataFrame, start, end) -> None:
|
||||||
|
pdf.begin_report_section(SECTION_SUMMARY)
|
||||||
mean_mmol = glucose["mmol"].mean()
|
mean_mmol = glucose["mmol"].mean()
|
||||||
|
min_mmol, max_mmol = glucose_min_max(glucose)
|
||||||
tir = tir_breakdown_5band(glucose)
|
tir = tir_breakdown_5band(glucose)
|
||||||
active_pct = sensor_active_percent(glucose, start, end)
|
active_pct = sensor_active_percent(glucose, start, end)
|
||||||
|
|
||||||
pdf.set_font("Helvetica", "B", 13)
|
pdf.set_font("Helvetica", "", 11)
|
||||||
pdf.cell(0, 8, "Samenvatting", **_LINE_KWARGS)
|
pdf.cell(0, 6, f"Periode: {start.strftime('%d-%m-%Y')} t/m {end.strftime('%d-%m-%Y')}", **_LINE_KWARGS)
|
||||||
|
|
||||||
warning = completeness_warning(glucose, start, end)
|
warning = completeness_warning(glucose, start, end)
|
||||||
if warning:
|
if warning:
|
||||||
@@ -178,7 +374,12 @@ def _add_summary_section(pdf: FPDF, glucose: pd.DataFrame, start, end) -> None:
|
|||||||
|
|
||||||
pdf.set_font("Helvetica", "", 11)
|
pdf.set_font("Helvetica", "", 11)
|
||||||
pdf.cell(0, 6, f"Sensordekking: {active_pct:.0f}%", **_LINE_KWARGS)
|
pdf.cell(0, 6, f"Sensordekking: {active_pct:.0f}%", **_LINE_KWARGS)
|
||||||
pdf.cell(0, 6, f"Gemiddelde glucose: {mean_mmol:.1f} mmol/L", **_LINE_KWARGS)
|
pdf.cell(
|
||||||
|
0,
|
||||||
|
6,
|
||||||
|
f"Gemiddelde glucose: {mean_mmol:.1f} mmol/L (min {min_mmol:.1f} · max {max_mmol:.1f})",
|
||||||
|
**_LINE_KWARGS,
|
||||||
|
)
|
||||||
gmi = gmi_percent(mean_mmol)
|
gmi = gmi_percent(mean_mmol)
|
||||||
pdf.cell(0, 6, f"Geschat HbA1c (GMI): {gmi:.1f}% ({gmi_mmol_per_mol(gmi):.0f} mmol/mol)", **_LINE_KWARGS)
|
pdf.cell(0, 6, f"Geschat HbA1c (GMI): {gmi:.1f}% ({gmi_mmol_per_mol(gmi):.0f} mmol/mol)", **_LINE_KWARGS)
|
||||||
pdf.cell(0, 6, f"Variabiliteit (%CV): {percent_cv(glucose):.0f}%", **_LINE_KWARGS)
|
pdf.cell(0, 6, f"Variabiliteit (%CV): {percent_cv(glucose):.0f}%", **_LINE_KWARGS)
|
||||||
@@ -190,43 +391,117 @@ def _add_summary_section(pdf: FPDF, glucose: pd.DataFrame, start, end) -> None:
|
|||||||
pdf.cell(0, 6, f"Zeer hoog (>13.9 mmol/L): {tir['very_high']:.0f}%", **_LINE_KWARGS)
|
pdf.cell(0, 6, f"Zeer hoog (>13.9 mmol/L): {tir['very_high']:.0f}%", **_LINE_KWARGS)
|
||||||
pdf.ln(2)
|
pdf.ln(2)
|
||||||
png_bytes = _tir_bar_figure_png(tir)
|
png_bytes = _tir_bar_figure_png(tir)
|
||||||
pdf.image(io.BytesIO(png_bytes), w=160)
|
pdf.image(io.BytesIO(png_bytes), w=220)
|
||||||
pdf.ln(4)
|
|
||||||
|
|
||||||
|
|
||||||
def _add_agp_section(pdf: FPDF, glucose: pd.DataFrame) -> None:
|
def _add_agp_section(pdf: GlucoseReportPDF, glucose: pd.DataFrame) -> None:
|
||||||
pdf.set_font("Helvetica", "B", 13)
|
pdf.begin_report_section(SECTION_AGP)
|
||||||
pdf.cell(0, 8, "AGP profiel", **_LINE_KWARGS)
|
|
||||||
png_bytes = _agp_figure_png(glucose)
|
png_bytes = _agp_figure_png(glucose)
|
||||||
pdf.image(io.BytesIO(png_bytes), w=180)
|
pdf.image(io.BytesIO(png_bytes), w=270)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_insulin_profile_section(pdf: GlucoseReportPDF, profile: dict | None) -> None:
|
||||||
|
pdf.begin_report_section(SECTION_INSULIN_PROFILE)
|
||||||
|
if not profile:
|
||||||
|
pdf.set_font("Helvetica", "", 11)
|
||||||
|
pdf.cell(0, 6, "Geen pompprofiel gevonden op deze Nightscout-instantie.", **_LINE_KWARGS)
|
||||||
|
return
|
||||||
|
|
||||||
|
pdf.set_font("Helvetica", "", 11)
|
||||||
|
pdf.cell(0, 6, f"Profielnaam: {profile.get('name') or '-'}", **_LINE_KWARGS)
|
||||||
|
pdf.cell(0, 6, f"DIA (werkingsduur insuline): {profile['dia']:.1f} uur", **_LINE_KWARGS)
|
||||||
|
pdf.cell(0, 6, f"Totale dagelijkse basaal: {total_daily_basal(profile['basal']):.1f} E", **_LINE_KWARGS)
|
||||||
|
pdf.ln(2)
|
||||||
|
|
||||||
|
png_bytes = _insulin_profile_figure_png(profile)
|
||||||
|
pdf.image(io.BytesIO(png_bytes), w=270)
|
||||||
pdf.ln(4)
|
pdf.ln(4)
|
||||||
|
|
||||||
|
pdf.set_font("Helvetica", "B", 11)
|
||||||
|
pdf.cell(90, 6, "Insulinegevoeligheid (ISF)", **_SAME_LINE_KWARGS)
|
||||||
|
pdf.cell(0, 6, "Koolhydraatratio", **_LINE_KWARGS)
|
||||||
|
pdf.set_font("Helvetica", "", 10)
|
||||||
|
isf_rows = segments_table(profile["sens"])
|
||||||
|
ratio_rows = segments_table(profile["carbratio"])
|
||||||
|
for i in range(max(len(isf_rows), len(ratio_rows))):
|
||||||
|
isf_text = (
|
||||||
|
f"{isf_rows[i]['start']}-{isf_rows[i]['end']}: {isf_rows[i]['value']:.1f} mmol/L per E"
|
||||||
|
if i < len(isf_rows)
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
ratio_text = (
|
||||||
|
f"{ratio_rows[i]['start']}-{ratio_rows[i]['end']}: {ratio_rows[i]['value']:.0f} g per E"
|
||||||
|
if i < len(ratio_rows)
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
pdf.cell(90, 5, isf_text, **_SAME_LINE_KWARGS)
|
||||||
|
pdf.cell(0, 5, ratio_text, **_LINE_KWARGS)
|
||||||
|
|
||||||
def _add_dagoverzicht_section(pdf: FPDF, glucose: pd.DataFrame) -> None:
|
|
||||||
"""Eén dagelijkse mini-grafiek per kalenderdag (gitea #16), max. de meest
|
|
||||||
recente DAGOVERZICHT_MAX_DAYS dagen — voor langere periodes zou de PDF
|
|
||||||
anders onwerkbaar lang worden."""
|
|
||||||
pdf.set_font("Helvetica", "B", 13)
|
|
||||||
pdf.cell(0, 8, "Dagoverzicht", **_LINE_KWARGS)
|
|
||||||
|
|
||||||
|
def _add_daily_stats_lines(pdf: GlucoseReportPDF, stats: dict) -> None:
|
||||||
|
pdf.set_font("Helvetica", "", 10)
|
||||||
|
pdf.cell(
|
||||||
|
0,
|
||||||
|
6,
|
||||||
|
f"Glucose: gem. {stats['mean_mmol']:.1f} · min {stats['min_mmol']:.1f} · "
|
||||||
|
f"max {stats['max_mmol']:.1f} mmol/L",
|
||||||
|
**_LINE_KWARGS,
|
||||||
|
)
|
||||||
|
pdf.cell(
|
||||||
|
0,
|
||||||
|
6,
|
||||||
|
f"Insuline: totaal {stats['total_insulin']:.1f} E · basaal {stats['basal_insulin']:.1f} E · "
|
||||||
|
f"bolus {stats['bolus_insulin']:.1f} E · SMB {stats['smb_insulin']:.1f} E",
|
||||||
|
**_LINE_KWARGS,
|
||||||
|
)
|
||||||
|
pdf.cell(
|
||||||
|
0,
|
||||||
|
6,
|
||||||
|
f"Hypo's: {stats['hypo_count']} ({stats['hypo_duration_minutes']:.0f} min) · "
|
||||||
|
f"Hyper's: {stats['hyper_count']} ({stats['hyper_duration_minutes']:.0f} min)",
|
||||||
|
**_LINE_KWARGS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_dagoverzicht_section(
|
||||||
|
pdf: GlucoseReportPDF, glucose: pd.DataFrame, treatments: pd.DataFrame, profile: dict | None
|
||||||
|
) -> None:
|
||||||
|
"""Eén liggende pagina per kalenderdag, max. de meest recente
|
||||||
|
DAGOVERZICHT_MAX_DAYS dagen — voor langere periodes zou de PDF anders
|
||||||
|
onwerkbaar lang worden."""
|
||||||
df = glucose.copy()
|
df = glucose.copy()
|
||||||
df["date"] = df["timestamp"].dt.date
|
df["date"] = df["timestamp"].dt.date
|
||||||
dates = sorted(df["date"].unique())
|
dates = sorted(df["date"].unique())
|
||||||
if len(dates) > DAGOVERZICHT_MAX_DAYS:
|
show_limited_note = len(dates) > DAGOVERZICHT_MAX_DAYS
|
||||||
|
if show_limited_note:
|
||||||
dates = dates[-DAGOVERZICHT_MAX_DAYS:]
|
dates = dates[-DAGOVERZICHT_MAX_DAYS:]
|
||||||
pdf.set_font("Helvetica", "I", 9)
|
|
||||||
pdf.cell(0, 5, f"Beperkt tot de meest recente {DAGOVERZICHT_MAX_DAYS} dagen.", **_LINE_KWARGS)
|
|
||||||
|
|
||||||
for day in dates:
|
for day in dates:
|
||||||
|
pdf.begin_report_section(SECTION_DAGOVERZICHT)
|
||||||
|
if show_limited_note:
|
||||||
|
pdf.set_font("Helvetica", "I", 9)
|
||||||
|
pdf.cell(0, 5, f"Beperkt tot de meest recente {DAGOVERZICHT_MAX_DAYS} dagen.", **_LINE_KWARGS)
|
||||||
|
show_limited_note = False # alleen op de eerste paginas van deze sectie
|
||||||
|
|
||||||
|
day_start = pd.Timestamp(day)
|
||||||
|
day_end = day_start + pd.Timedelta(1, unit="D")
|
||||||
day_df = df[df["date"] == day]
|
day_df = df[df["date"] == day]
|
||||||
png_bytes = _daily_figure_png(day_df, day.strftime("%A %d-%m-%Y"))
|
day_treatments = (
|
||||||
pdf.image(io.BytesIO(png_bytes), w=180)
|
treatments[(treatments["timestamp"] >= day_start) & (treatments["timestamp"] < day_end)]
|
||||||
pdf.ln(4)
|
if not treatments.empty
|
||||||
|
else treatments
|
||||||
|
)
|
||||||
|
|
||||||
|
png_bytes = _daily_figure_png(day_df, day_treatments, profile, day.strftime("%A %d-%m-%Y"), day_start, day_end)
|
||||||
|
pdf.image(io.BytesIO(png_bytes), w=270)
|
||||||
|
pdf.ln(2)
|
||||||
|
|
||||||
|
stats = daily_stats(day_df, day_treatments, profile, day_start, day_end)
|
||||||
|
_add_daily_stats_lines(pdf, stats)
|
||||||
|
|
||||||
|
|
||||||
def _add_events_section(pdf: FPDF, glucose: pd.DataFrame, events_summary: dict[str, dict]) -> None:
|
def _add_events_section(pdf: GlucoseReportPDF, glucose: pd.DataFrame, events_summary: dict[str, dict]) -> None:
|
||||||
pdf.set_font("Helvetica", "B", 13)
|
pdf.begin_report_section(SECTION_EVENTS)
|
||||||
pdf.cell(0, 8, "Hypo-/hyperevents", **_LINE_KWARGS)
|
|
||||||
pdf.set_font("Helvetica", "", 11)
|
pdf.set_font("Helvetica", "", 11)
|
||||||
for label, summary in events_summary.items():
|
for label, summary in events_summary.items():
|
||||||
pdf.cell(
|
pdf.cell(
|
||||||
@@ -238,17 +513,16 @@ def _add_events_section(pdf: FPDF, glucose: pd.DataFrame, events_summary: dict[s
|
|||||||
)
|
)
|
||||||
pdf.ln(2)
|
pdf.ln(2)
|
||||||
|
|
||||||
# Alleen Level 1 krijgt een grafiek in het PDF-rapport (gitea #17, #18) —
|
# Alleen Level 1 krijgt een grafiek in het PDF-rapport — geen tabel met
|
||||||
# geen tabel met losse events, de samenvattende cijfers staan al hierboven.
|
# losse events, de samenvattende cijfers staan al hierboven.
|
||||||
if not glucose.empty:
|
if not glucose.empty:
|
||||||
hypo_events = detect_events(glucose, LOW, "below")
|
hypo_events = detect_events(glucose, LOW, "below")
|
||||||
if not hypo_events.empty:
|
if not hypo_events.empty:
|
||||||
pdf.image(io.BytesIO(_event_hour_histogram_png(hypo_events, _TIR_COLORS["low"])), w=180)
|
pdf.image(io.BytesIO(_event_hour_histogram_png(hypo_events, _TIR_COLORS["low"])), w=260)
|
||||||
|
pdf.ln(2)
|
||||||
hyper_events = detect_events(glucose, HIGH, "above")
|
hyper_events = detect_events(glucose, HIGH, "above")
|
||||||
if not hyper_events.empty:
|
if not hyper_events.empty:
|
||||||
pdf.image(io.BytesIO(_event_hour_histogram_png(hyper_events, _TIR_COLORS["high"])), w=180)
|
pdf.image(io.BytesIO(_event_hour_histogram_png(hyper_events, _TIR_COLORS["high"])), w=260)
|
||||||
|
|
||||||
pdf.ln(4)
|
|
||||||
|
|
||||||
|
|
||||||
def build_report_pdf(
|
def build_report_pdf(
|
||||||
@@ -257,20 +531,33 @@ def build_report_pdf(
|
|||||||
events_summary: dict[str, dict],
|
events_summary: dict[str, dict],
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
|
treatments: pd.DataFrame | None = None,
|
||||||
|
profile: dict | None = None,
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""Bouw een PDF met de geselecteerde onderdelen.
|
"""Bouw een PDF met de geselecteerde onderdelen: één liggende voorpagina,
|
||||||
|
daarna één (of voor Dagoverzicht: meerdere) liggende pagina('s) per sectie.
|
||||||
|
|
||||||
`events_summary` bevat vooraf berekende hypo/hyper-samenvattingen (label ->
|
`events_summary` bevat vooraf berekende hypo/hyper-samenvattingen (label ->
|
||||||
resultaat van reporting.summarize_events), zodat deze module geen
|
resultaat van reporting.summarize_events), zodat deze module geen
|
||||||
event-detectie hoeft te herhalen.
|
event-detectie hoeft te herhalen. `treatments`/`profile` zijn optioneel
|
||||||
|
(leeg/None) zodat oudere aanroepen zonder insulinedata blijven werken.
|
||||||
"""
|
"""
|
||||||
pdf = FPDF()
|
if treatments is None:
|
||||||
pdf.add_page()
|
treatments = pd.DataFrame(columns=_EMPTY_TREATMENT_COLUMNS)
|
||||||
pdf.set_font("Helvetica", "B", 16)
|
|
||||||
pdf.cell(0, 10, "Glucoserapport", **_LINE_KWARGS)
|
config = _report_config()
|
||||||
pdf.set_font("Helvetica", "", 11)
|
pdf = GlucoseReportPDF(config, datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
pdf.begin_report_section("Glucoserapport")
|
||||||
|
pdf.set_font("Helvetica", "B", 22)
|
||||||
|
pdf.cell(0, 14, "Glucoserapport", **_LINE_KWARGS)
|
||||||
|
pdf.set_font("Helvetica", "", 13)
|
||||||
pdf.cell(0, 8, f"Periode: {start.strftime('%d-%m-%Y')} t/m {end.strftime('%d-%m-%Y')}", **_LINE_KWARGS)
|
pdf.cell(0, 8, f"Periode: {start.strftime('%d-%m-%Y')} t/m {end.strftime('%d-%m-%Y')}", **_LINE_KWARGS)
|
||||||
pdf.ln(4)
|
if sections:
|
||||||
|
pdf.cell(0, 8, f"Onderdelen: {', '.join(sections)}", **_LINE_KWARGS)
|
||||||
|
|
||||||
|
if SECTION_INSULIN_PROFILE in sections:
|
||||||
|
_add_insulin_profile_section(pdf, profile)
|
||||||
|
|
||||||
if not glucose.empty:
|
if not glucose.empty:
|
||||||
if SECTION_SUMMARY in sections:
|
if SECTION_SUMMARY in sections:
|
||||||
@@ -278,7 +565,7 @@ def build_report_pdf(
|
|||||||
if SECTION_AGP in sections:
|
if SECTION_AGP in sections:
|
||||||
_add_agp_section(pdf, glucose)
|
_add_agp_section(pdf, glucose)
|
||||||
if SECTION_DAGOVERZICHT in sections:
|
if SECTION_DAGOVERZICHT in sections:
|
||||||
_add_dagoverzicht_section(pdf, glucose)
|
_add_dagoverzicht_section(pdf, glucose, treatments, profile)
|
||||||
|
|
||||||
if SECTION_EVENTS in sections and events_summary:
|
if SECTION_EVENTS in sections and events_summary:
|
||||||
_add_events_section(pdf, glucose, events_summary)
|
_add_events_section(pdf, glucose, events_summary)
|
||||||
|
|||||||
+146
@@ -0,0 +1,146 @@
|
|||||||
|
"""
|
||||||
|
Parsing van het Nightscout-pompprofiel (/api/v1/profile): basaalschema,
|
||||||
|
insulinegevoeligheid (ISF), koolhydraatratio, DIA en streefwaarden.
|
||||||
|
|
||||||
|
Framework-vrije module (geen streamlit-import), zelfde opzet als reporting.py:
|
||||||
|
puur functies op dicts, zodat dit los van de UI te unit-testen is. Zie
|
||||||
|
nightscout_client.fetch_profile() voor de ruwe fetch; deze module normaliseert
|
||||||
|
alleen (uren -> minuten sinds middernacht, mg/dl -> mmol/L waar nodig).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from nightscout_client import mgdl_to_mmol
|
||||||
|
|
||||||
|
SEGMENT_FIELDS = ("basal", "sens", "carbratio", "target_low", "target_high")
|
||||||
|
|
||||||
|
|
||||||
|
def _time_to_minutes(time_str: str) -> int:
|
||||||
|
"""Zet 'HH:MM' (Nightscout's segmenttijd-notatie) om naar minuten sinds middernacht."""
|
||||||
|
hours, minutes = time_str.split(":")
|
||||||
|
return int(hours) * 60 + int(minutes)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_segments(raw_segments: list[dict], *, to_mmol: bool = False) -> list[dict]:
|
||||||
|
"""Zet Nightscout-segmenten ([{'time': 'HH:MM', 'value': ...}, ...]) om naar
|
||||||
|
[{'minutes': int, 'value': float}, ...], oplopend gesorteerd op tijd."""
|
||||||
|
segments = []
|
||||||
|
for seg in raw_segments:
|
||||||
|
value = float(seg["value"])
|
||||||
|
if to_mmol:
|
||||||
|
value = mgdl_to_mmol(value)
|
||||||
|
segments.append({"minutes": _time_to_minutes(seg["time"]), "value": value})
|
||||||
|
return sorted(segments, key=lambda s: s["minutes"])
|
||||||
|
|
||||||
|
|
||||||
|
def parse_profile(raw: dict) -> dict:
|
||||||
|
"""Parse één Nightscout-profieldocument (zoals teruggegeven door
|
||||||
|
nightscout_client.fetch_profile()) naar een genormaliseerde dict:
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": str,
|
||||||
|
"units": "mmol", # altijd genormaliseerd, ongeacht bronformaat
|
||||||
|
"dia": float, # uur
|
||||||
|
"basal": [{"minutes": int, "value": float}, ...], # E/uur
|
||||||
|
"sens": [{"minutes": int, "value": float}, ...], # mmol/L per E (ISF)
|
||||||
|
"carbratio": [{"minutes": int, "value": float}, ...], # g koolhydraten per E
|
||||||
|
"target_low": [{"minutes": int, "value": float}, ...], # mmol/L
|
||||||
|
"target_high": [{"minutes": int, "value": float}, ...], # mmol/L
|
||||||
|
}
|
||||||
|
|
||||||
|
Geeft {} terug als er geen (bruikbaar) profiel is.
|
||||||
|
"""
|
||||||
|
if not raw:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
default_name = raw.get("defaultProfile")
|
||||||
|
store = raw.get("store", {})
|
||||||
|
profile = store.get(default_name) if default_name else None
|
||||||
|
if profile is None and store:
|
||||||
|
# defaultProfile ontbreekt of matcht niet met een sleutel in store:
|
||||||
|
# val terug op het enige/eerste beschikbare profiel.
|
||||||
|
profile = next(iter(store.values()))
|
||||||
|
if profile is None:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
units = str(profile.get("units", "mg/dl")).strip().lower()
|
||||||
|
to_mmol = units != "mmol"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": default_name or "",
|
||||||
|
"units": "mmol",
|
||||||
|
"dia": float(profile.get("dia", 0) or 0),
|
||||||
|
"basal": _parse_segments(profile.get("basal", [])),
|
||||||
|
"sens": _parse_segments(profile.get("sens", []), to_mmol=to_mmol),
|
||||||
|
"carbratio": _parse_segments(profile.get("carbratio", [])),
|
||||||
|
"target_low": _parse_segments(profile.get("target_low", []), to_mmol=to_mmol),
|
||||||
|
"target_high": _parse_segments(profile.get("target_high", []), to_mmol=to_mmol),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def value_at(segments: list[dict], minutes: int) -> float | None:
|
||||||
|
"""Geef de waarde terug die actief is op het gegeven tijdstip (minuten
|
||||||
|
sinds middernacht) — het laatste segment met `minutes <= t`. Ligt `t` vóór
|
||||||
|
het eerste segment van de dag, dan geldt het laatste segment (dat loopt
|
||||||
|
door vanaf de vorige dag, want een profiel dekt altijd de volle 24 uur).
|
||||||
|
|
||||||
|
Geeft None terug als `segments` leeg is.
|
||||||
|
"""
|
||||||
|
if not segments:
|
||||||
|
return None
|
||||||
|
active = None
|
||||||
|
for seg in segments:
|
||||||
|
if seg["minutes"] <= minutes:
|
||||||
|
active = seg
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
if active is None:
|
||||||
|
active = segments[-1]
|
||||||
|
return active["value"]
|
||||||
|
|
||||||
|
|
||||||
|
def _format_time(minutes: int) -> str:
|
||||||
|
"""Zet minuten sinds middernacht om naar 'HH:MM'."""
|
||||||
|
return f"{minutes // 60:02d}:{minutes % 60:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def segments_table(segments: list[dict]) -> list[dict]:
|
||||||
|
"""Zet segmenten om naar rijen met een start- en eindtijd voor tabelweergave:
|
||||||
|
[{'start': 'HH:MM', 'end': 'HH:MM', 'value': float}, ...]. Het laatste
|
||||||
|
segment loopt door tot 24:00 (een profiel dekt altijd de volle dag)."""
|
||||||
|
rows = []
|
||||||
|
for i, seg in enumerate(segments):
|
||||||
|
end_minutes = segments[i + 1]["minutes"] if i + 1 < len(segments) else 24 * 60
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"start": _format_time(seg["minutes"]),
|
||||||
|
"end": "24:00" if end_minutes == 24 * 60 else _format_time(end_minutes),
|
||||||
|
"value": seg["value"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def total_daily_basal(segments: list[dict]) -> float:
|
||||||
|
"""Totale basaaldosis per dag in E: som van elk segment se duur (uur) x snelheid (E/uur)."""
|
||||||
|
if not segments:
|
||||||
|
return 0.0
|
||||||
|
total = 0.0
|
||||||
|
for i, seg in enumerate(segments):
|
||||||
|
end_minutes = segments[i + 1]["minutes"] if i + 1 < len(segments) else 24 * 60
|
||||||
|
total += seg["value"] * (end_minutes - seg["minutes"]) / 60
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def segments_to_hourly_bars(segments: list[dict]) -> list[dict]:
|
||||||
|
"""Zet segmenten om naar een lijst geschikt voor een staafgrafiek per uur van
|
||||||
|
de dag: [{'hour': float, 'value': float}, ...], met een extra punt op uur 24
|
||||||
|
(gelijk aan het eerste segment) zodat een staaf/stap-grafiek de volle dag dekt.
|
||||||
|
|
||||||
|
Gebruikt door de basaalgrafiek op de Insulineprofiel-pagina.
|
||||||
|
"""
|
||||||
|
if not segments:
|
||||||
|
return []
|
||||||
|
bars = [{"hour": seg["minutes"] / 60, "value": seg["value"]} for seg in segments]
|
||||||
|
bars.append({"hour": 24.0, "value": segments[0]["value"]})
|
||||||
|
return bars
|
||||||
@@ -1,2 +1,15 @@
|
|||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
pythonpath = ["."]
|
pythonpath = ["."]
|
||||||
|
|
||||||
|
[tool.coverage.run]
|
||||||
|
# app.py en pages/*.py zijn dunne Streamlit-UI-scripts (net als de
|
||||||
|
# functies in ui_common.py/auth.py die zelf al met "# pragma: no cover"
|
||||||
|
# gemarkeerd zijn) — niet zinvol te unit-testen zonder een draaiende
|
||||||
|
# Streamlit-sessie, en buiten scope van de coverage-eis. De logica die ze
|
||||||
|
# aanroepen (reporting.py, nightscout_client.py, data_processing.py,
|
||||||
|
# pdf_export.py, profile_data.py, ui_common.py) wordt wél volledig gemeten.
|
||||||
|
omit = ["app.py", "pages/*", "tests/*", ".venv/*"]
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
fail_under = 80
|
||||||
|
show_missing = true
|
||||||
|
|||||||
+228
@@ -15,6 +15,7 @@ import numpy as np
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
from nightscout_client import INSULIN_EVENT_TYPES
|
from nightscout_client import INSULIN_EVENT_TYPES
|
||||||
|
from profile_data import value_at
|
||||||
|
|
||||||
# Klinische drempels in mmol/L (Battelino et al. 2019, Diabetes Care 42(8):1593-1603).
|
# Klinische drempels in mmol/L (Battelino et al. 2019, Diabetes Care 42(8):1593-1603).
|
||||||
VERY_LOW = 3.0
|
VERY_LOW = 3.0
|
||||||
@@ -440,6 +441,233 @@ def bolus_stats(treatments: pd.DataFrame) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
BOLUS_CATEGORY_FOOD = "food"
|
||||||
|
BOLUS_CATEGORY_CORRECTION = "correction"
|
||||||
|
BOLUS_CATEGORY_SMB = "smb"
|
||||||
|
|
||||||
|
BOLUS_CATEGORY_COLUMNS = ["timestamp", "eventType", "insulin", "carbs", "category"]
|
||||||
|
|
||||||
|
|
||||||
|
def classify_bolus(treatments: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
"""Splits bolus-insuline op in 'food'/'correction'/'smb', voor grafiekoverlays
|
||||||
|
(gitea insulineprofiel-werk, vervolg op bolus_stats()'s SMB-detectie).
|
||||||
|
|
||||||
|
Herkenningsregels, in volgorde:
|
||||||
|
- SMB: `eventType == "SMB"`, of `eventType == "Correction Bolus"` met
|
||||||
|
`type == "SMB"` (zie bolus_stats()) — automatische AndroidAPS-microbolus.
|
||||||
|
- food: `eventType == "Meal Bolus"`, of een andere bolus-eventType
|
||||||
|
(`Bolus`/`Combo Bolus`) met koolhydraten op dezelfde treatment-rij (een
|
||||||
|
sterke aanwijzing dat het om een maaltijdbolus gaat).
|
||||||
|
- correction: alle overige bolus-rijen (typisch `Correction Bolus` zonder
|
||||||
|
SMB-markering, of een generieke `Bolus`/`Combo Bolus` zonder koolhydraten).
|
||||||
|
|
||||||
|
Geeft een DataFrame terug met kolommen 'timestamp', 'eventType', 'insulin',
|
||||||
|
'carbs', 'category' — alleen de rijen die daadwerkelijk bolusinsuline zijn.
|
||||||
|
"""
|
||||||
|
if treatments.empty:
|
||||||
|
return pd.DataFrame(columns=BOLUS_CATEGORY_COLUMNS)
|
||||||
|
|
||||||
|
bolus = treatments[treatments["eventType"].isin(INSULIN_EVENT_TYPES) & treatments["insulin"].notna()].copy()
|
||||||
|
if bolus.empty:
|
||||||
|
return pd.DataFrame(columns=BOLUS_CATEGORY_COLUMNS)
|
||||||
|
|
||||||
|
is_smb = (bolus["eventType"] == SMB_TYPE) | (
|
||||||
|
(bolus["eventType"] == "Correction Bolus") & (bolus.get("type", "") == SMB_TYPE)
|
||||||
|
)
|
||||||
|
has_carbs = pd.to_numeric(bolus["carbs"], errors="coerce").fillna(0) > 0
|
||||||
|
is_food = ~is_smb & ((bolus["eventType"] == "Meal Bolus") | has_carbs)
|
||||||
|
|
||||||
|
bolus["category"] = BOLUS_CATEGORY_CORRECTION
|
||||||
|
bolus.loc[is_food, "category"] = BOLUS_CATEGORY_FOOD
|
||||||
|
bolus.loc[is_smb, "category"] = BOLUS_CATEGORY_SMB
|
||||||
|
return bolus[BOLUS_CATEGORY_COLUMNS].sort_values("timestamp").reset_index(drop=True)
|
||||||
|
|
||||||
|
|
||||||
|
def glucose_min_max(glucose: pd.DataFrame) -> tuple[float, float]:
|
||||||
|
"""Minimum en maximum glucosewaarde (mmol/L) in de gegeven data."""
|
||||||
|
if glucose.empty:
|
||||||
|
return (0.0, 0.0)
|
||||||
|
return (round(glucose["mmol"].min(), 1), round(glucose["mmol"].max(), 1))
|
||||||
|
|
||||||
|
|
||||||
|
def _temp_basal_intervals(treatments: pd.DataFrame, day_start, day_end) -> list[tuple]:
|
||||||
|
"""Temp Basal-intervallen (start, eind, rate) die (deels) binnen [day_start, day_end) vallen,
|
||||||
|
geclipt aan de daggrenzen."""
|
||||||
|
if treatments is None or treatments.empty:
|
||||||
|
return []
|
||||||
|
temp_basal = treatments[
|
||||||
|
(treatments["eventType"] == BASAL_EVENT_TYPE)
|
||||||
|
& treatments["absolute"].notna()
|
||||||
|
& treatments["duration"].notna()
|
||||||
|
]
|
||||||
|
intervals = []
|
||||||
|
for _, row in temp_basal.iterrows():
|
||||||
|
start = row["timestamp"]
|
||||||
|
end = start + pd.Timedelta(float(row["duration"]), unit="min")
|
||||||
|
if end <= day_start or start >= day_end:
|
||||||
|
continue
|
||||||
|
intervals.append((max(start, day_start), min(end, day_end), row["absolute"]))
|
||||||
|
return intervals
|
||||||
|
|
||||||
|
|
||||||
|
def _basal_breakpoints(basal_segments: list[dict], temp_intervals: list[tuple], day_start, day_end) -> list:
|
||||||
|
"""Alle tijdstippen waarop de effectieve basaalsnelheid kan wijzigen: daggrens,
|
||||||
|
profielsegment-grenzen, en temp-basal start/eind — oplopend gesorteerd."""
|
||||||
|
breakpoints = {day_start}
|
||||||
|
for seg in basal_segments:
|
||||||
|
ts = day_start + pd.Timedelta(float(seg["minutes"]), unit="min")
|
||||||
|
if day_start <= ts < day_end:
|
||||||
|
breakpoints.add(ts)
|
||||||
|
for start, end, _ in temp_intervals:
|
||||||
|
breakpoints.add(start)
|
||||||
|
breakpoints.add(end)
|
||||||
|
return sorted(t for t in breakpoints if day_start <= t < day_end)
|
||||||
|
|
||||||
|
|
||||||
|
def _rate_at(ts, basal_segments: list[dict], temp_intervals: list[tuple], day_start) -> float:
|
||||||
|
"""Effectieve basaalsnelheid op tijdstip `ts`: een actieve Temp Basal wint van het profieltempo."""
|
||||||
|
for start, end, rate in temp_intervals:
|
||||||
|
if start <= ts < end:
|
||||||
|
return rate
|
||||||
|
minutes_of_day = int((ts - day_start).total_seconds() / 60) % MINUTES_PER_DAY
|
||||||
|
value = value_at(basal_segments, minutes_of_day)
|
||||||
|
return value if value is not None else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def effective_basal_series(profile: dict | None, treatments: pd.DataFrame, day_start, day_end) -> pd.DataFrame:
|
||||||
|
"""Basaalsnelheid (E/uur) als stapfunctie over [day_start, day_end).
|
||||||
|
|
||||||
|
Basislijn is het pompprofiel se basaalschema (`profile["basal"]`); waar een
|
||||||
|
Temp Basal-treatment actief is (rate/duur uit de treatment) overschrijft
|
||||||
|
die het profieltempo voor de duur van die tijdelijke aanpassing — dit is de
|
||||||
|
daadwerkelijk afgeleverde basaal, in tegenstelling tot basal_stats() (die
|
||||||
|
alleen temp-basal-periodes optelt en periodes zonder actieve temp basal
|
||||||
|
mist omdat het profiel daar niet bij betrokken wordt).
|
||||||
|
|
||||||
|
Zonder profiel (`profile` is None/leeg) is de basislijn 0 — dan toont dit
|
||||||
|
alleen de temp-basal-periodes, net als basal_stats().
|
||||||
|
|
||||||
|
Geeft een DataFrame terug met kolommen 'timestamp' en 'rate': elke rij
|
||||||
|
geldt tot de volgende rij (stapfunctie), geschikt voor een
|
||||||
|
step-area-plot (line_shape='hv', fill='tozeroy').
|
||||||
|
"""
|
||||||
|
day_start = pd.Timestamp(day_start)
|
||||||
|
day_end = pd.Timestamp(day_end)
|
||||||
|
basal_segments = (profile or {}).get("basal", [])
|
||||||
|
temp_intervals = _temp_basal_intervals(treatments, day_start, day_end)
|
||||||
|
breakpoints = _basal_breakpoints(basal_segments, temp_intervals, day_start, day_end)
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
{"timestamp": ts, "rate": _rate_at(ts, basal_segments, temp_intervals, day_start)} for ts in breakpoints
|
||||||
|
]
|
||||||
|
return pd.DataFrame(rows, columns=["timestamp", "rate"])
|
||||||
|
|
||||||
|
|
||||||
|
def _target_midpoint_at(minutes_of_day: int, target_low: list[dict], target_high: list[dict]) -> float | None:
|
||||||
|
low = value_at(target_low, minutes_of_day) if target_low else None
|
||||||
|
high = value_at(target_high, minutes_of_day) if target_high else None
|
||||||
|
if low is not None and high is not None:
|
||||||
|
return (low + high) / 2
|
||||||
|
return low if low is not None else high
|
||||||
|
|
||||||
|
|
||||||
|
def target_glucose_series(profile: dict | None, day_start, day_end) -> pd.DataFrame:
|
||||||
|
"""Streefglucose (mmol/L) uit het profiel als stapfunctie over [day_start, day_end) —
|
||||||
|
het midden van target_low/target_high per tijdsegment (de pomp kent vaak één
|
||||||
|
streefwaarde per segment; met losse low/high-segmenten is dat gewoon het
|
||||||
|
gemiddelde). Geeft een lege DataFrame terug als het profiel geen streefwaarden
|
||||||
|
bevat.
|
||||||
|
|
||||||
|
Zelfde stapfunctie-vorm als effective_basal_series(): elke rij geldt tot de
|
||||||
|
volgende rij.
|
||||||
|
"""
|
||||||
|
day_start = pd.Timestamp(day_start)
|
||||||
|
day_end = pd.Timestamp(day_end)
|
||||||
|
target_low = (profile or {}).get("target_low", [])
|
||||||
|
target_high = (profile or {}).get("target_high", [])
|
||||||
|
if not target_low and not target_high:
|
||||||
|
return pd.DataFrame(columns=["timestamp", "target"])
|
||||||
|
|
||||||
|
breakpoints = _basal_breakpoints(target_low + target_high, [], day_start, day_end)
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"timestamp": ts,
|
||||||
|
"target": _target_midpoint_at(
|
||||||
|
int((ts - day_start).total_seconds() / 60) % MINUTES_PER_DAY, target_low, target_high
|
||||||
|
),
|
||||||
|
}
|
||||||
|
for ts in breakpoints
|
||||||
|
]
|
||||||
|
return pd.DataFrame(rows, columns=["timestamp", "target"])
|
||||||
|
|
||||||
|
|
||||||
|
def _integrate_step_series(series: pd.DataFrame, value_col: str, day_end) -> float:
|
||||||
|
"""Oppervlakte onder een stapfunctie-DataFrame (zoals effective_basal_series()):
|
||||||
|
som van elke rij se waarde x duur (in uur) tot de volgende rij, of tot
|
||||||
|
`day_end` voor de laatste rij. Gebruikt om een E/uur-stapfunctie om te
|
||||||
|
rekenen naar een totaal aantal eenheden."""
|
||||||
|
if series.empty:
|
||||||
|
return 0.0
|
||||||
|
rows = series.reset_index(drop=True)
|
||||||
|
total = 0.0
|
||||||
|
for i in range(len(rows)):
|
||||||
|
start = rows.loc[i, "timestamp"]
|
||||||
|
end = rows.loc[i + 1, "timestamp"] if i + 1 < len(rows) else day_end
|
||||||
|
total += rows.loc[i, value_col] * (end - start).total_seconds() / 3600
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
DAILY_STATS_KEYS = [
|
||||||
|
"mean_mmol",
|
||||||
|
"min_mmol",
|
||||||
|
"max_mmol",
|
||||||
|
"total_insulin",
|
||||||
|
"basal_insulin",
|
||||||
|
"bolus_insulin",
|
||||||
|
"smb_insulin",
|
||||||
|
"hypo_count",
|
||||||
|
"hypo_duration_minutes",
|
||||||
|
"hyper_count",
|
||||||
|
"hyper_duration_minutes",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def daily_stats(day_glucose: pd.DataFrame, day_treatments: pd.DataFrame, profile: dict | None, day_start, day_end) -> dict:
|
||||||
|
"""Samenvattende statistieken voor één kalenderdag, voor naast de dagelijkse
|
||||||
|
grafiek in het PDF-rapport: gemiddelde/min/max glucose, totale/basaal-/bolus-/
|
||||||
|
SMB-insuline (SMB apart gerapporteerd omdat dit zowel als basaal- als
|
||||||
|
bolus-vervanging kan gelden, zie classify_bolus()), en het aantal + de totale
|
||||||
|
duur van hypo- en hyperevents die dag.
|
||||||
|
"""
|
||||||
|
min_mmol, max_mmol = glucose_min_max(day_glucose)
|
||||||
|
mean_mmol = round(day_glucose["mmol"].mean(), 1) if not day_glucose.empty else 0.0
|
||||||
|
|
||||||
|
bolus = classify_bolus(day_treatments)
|
||||||
|
by_category = bolus.groupby("category")["insulin"].sum().to_dict() if not bolus.empty else {}
|
||||||
|
bolus_units = by_category.get(BOLUS_CATEGORY_FOOD, 0.0) + by_category.get(BOLUS_CATEGORY_CORRECTION, 0.0)
|
||||||
|
smb_units = by_category.get(BOLUS_CATEGORY_SMB, 0.0)
|
||||||
|
|
||||||
|
basal_series = effective_basal_series(profile, day_treatments, day_start, day_end)
|
||||||
|
basal_units = _integrate_step_series(basal_series, "rate", day_end)
|
||||||
|
|
||||||
|
hypo_summary = summarize_events(detect_events(day_glucose, LOW, "below"), period_days=1)
|
||||||
|
hyper_summary = summarize_events(detect_events(day_glucose, HIGH, "above"), period_days=1)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"mean_mmol": mean_mmol,
|
||||||
|
"min_mmol": min_mmol,
|
||||||
|
"max_mmol": max_mmol,
|
||||||
|
"total_insulin": round(bolus_units + smb_units + basal_units, 1),
|
||||||
|
"basal_insulin": round(basal_units, 1),
|
||||||
|
"bolus_insulin": round(bolus_units, 1),
|
||||||
|
"smb_insulin": round(smb_units, 1),
|
||||||
|
"hypo_count": hypo_summary["count"],
|
||||||
|
"hypo_duration_minutes": hypo_summary["total_minutes"],
|
||||||
|
"hyper_count": hyper_summary["count"],
|
||||||
|
"hyper_duration_minutes": hyper_summary["total_minutes"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def basal_stats(treatments: pd.DataFrame, period_days: float) -> dict:
|
def basal_stats(treatments: pd.DataFrame, period_days: float) -> dict:
|
||||||
"""Totale basaalinsuline uit Temp Basal-treatments (rate x duur), zonder pompprofiel.
|
"""Totale basaalinsuline uit Temp Basal-treatments (rate x duur), zonder pompprofiel.
|
||||||
|
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ python-dotenv>=1.0,<2.0
|
|||||||
matplotlib>=3.9,<4.0
|
matplotlib>=3.9,<4.0
|
||||||
fpdf2>=2.7,<3.0
|
fpdf2>=2.7,<3.0
|
||||||
pytest>=8.0,<9.0
|
pytest>=8.0,<9.0
|
||||||
|
pytest-cov>=5.0,<6.0
|
||||||
ruff>=0.6,<1.0
|
ruff>=0.6,<1.0
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ sonar.sources=.
|
|||||||
sonar.exclusions=data/**,tests/**
|
sonar.exclusions=data/**,tests/**
|
||||||
sonar.tests=tests
|
sonar.tests=tests
|
||||||
sonar.python.version=3.12
|
sonar.python.version=3.12
|
||||||
|
sonar.python.coverage.reportPaths=coverage.xml
|
||||||
|
|||||||
@@ -168,6 +168,50 @@ def test_fetch_treatments_range_filters_to_date_boundaries(monkeypatch):
|
|||||||
assert result.iloc[0]["insulin"] == pytest.approx(4.0)
|
assert result.iloc[0]["insulin"] == pytest.approx(4.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_entries_for_day_scopes_to_a_single_calendar_day(monkeypatch):
|
||||||
|
monkeypatch.setenv("NIGHTSCOUT_URL", "https://example.com")
|
||||||
|
monkeypatch.delenv("NIGHTSCOUT_TOKEN", raising=False)
|
||||||
|
monkeypatch.delenv("NIGHTSCOUT_API_SECRET", raising=False)
|
||||||
|
|
||||||
|
payload = [
|
||||||
|
{"date": int(datetime(2026, 1, 15, 12, 0, tzinfo=timezone.utc).timestamp() * 1000), "sgv": 126},
|
||||||
|
{"date": int(datetime(2026, 1, 16, 12, 0, tzinfo=timezone.utc).timestamp() * 1000), "sgv": 100},
|
||||||
|
]
|
||||||
|
monkeypatch.setattr(ns.requests, "get", lambda *a, **k: FakeResponse(payload))
|
||||||
|
|
||||||
|
result = ns.fetch_entries_for_day(date(2026, 1, 15))
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result.iloc[0]["timestamp"].date() == date(2026, 1, 15)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_treatments_for_day_includes_lookback_before_midnight(monkeypatch):
|
||||||
|
"""Een Temp Basal die net vóór middernacht is gestart moet toch meekomen
|
||||||
|
(zie de docstring bij fetch_treatments_for_day) — zonder lookback-marge
|
||||||
|
zou effective_basal_series() de vroege ochtenduren fout berekenen."""
|
||||||
|
monkeypatch.setenv("NIGHTSCOUT_URL", "https://example.com")
|
||||||
|
payload = [
|
||||||
|
{"created_at": "2026-01-14T22:00:00Z", "eventType": "Temp Basal", "absolute": 1.2, "duration": 480},
|
||||||
|
{"created_at": "2026-01-15T12:00:00Z", "eventType": "Meal Bolus", "insulin": 4.0, "carbs": 40},
|
||||||
|
{"created_at": "2026-01-10T08:00:00Z", "eventType": "Meal Bolus", "insulin": 3.0, "carbs": 30},
|
||||||
|
]
|
||||||
|
monkeypatch.setattr(ns.requests, "get", lambda *a, **k: FakeResponse(payload))
|
||||||
|
|
||||||
|
result = ns.fetch_treatments_for_day(date(2026, 1, 15), lookback_hours=6)
|
||||||
|
assert len(result) == 2 # de 10 januari-bolus valt buiten de lookback-marge
|
||||||
|
assert set(result["eventType"]) == {"Temp Basal", "Meal Bolus"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_treatments_for_day_excludes_treatments_before_the_lookback_window(monkeypatch):
|
||||||
|
monkeypatch.setenv("NIGHTSCOUT_URL", "https://example.com")
|
||||||
|
payload = [
|
||||||
|
{"created_at": "2026-01-14T12:00:00Z", "eventType": "Meal Bolus", "insulin": 4.0, "carbs": 40},
|
||||||
|
]
|
||||||
|
monkeypatch.setattr(ns.requests, "get", lambda *a, **k: FakeResponse(payload))
|
||||||
|
|
||||||
|
result = ns.fetch_treatments_for_day(date(2026, 1, 15), lookback_hours=6)
|
||||||
|
assert result.empty
|
||||||
|
|
||||||
|
|
||||||
def _entry(dt, sgv=100):
|
def _entry(dt, sgv=100):
|
||||||
return {"date": int(dt.timestamp() * 1000), "sgv": sgv, "dateString": dt.isoformat()}
|
return {"date": int(dt.timestamp() * 1000), "sgv": sgv, "dateString": dt.isoformat()}
|
||||||
|
|
||||||
@@ -357,6 +401,29 @@ def test_fetch_treatments_range_retrieves_full_history_even_with_a_hidden_server
|
|||||||
assert result["timestamp"].max().date() == end_date
|
assert result["timestamp"].max().date() == end_date
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_profile_returns_first_document(monkeypatch):
|
||||||
|
monkeypatch.setenv("NIGHTSCOUT_URL", "https://example.com")
|
||||||
|
payload = [{"defaultProfile": "Default", "store": {"Default": {"dia": "5"}}}]
|
||||||
|
|
||||||
|
seen_params = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ns.requests,
|
||||||
|
"get",
|
||||||
|
lambda *a, **k: (seen_params.append(k["params"]), FakeResponse(payload))[1],
|
||||||
|
)
|
||||||
|
|
||||||
|
result = ns.fetch_profile()
|
||||||
|
assert result == payload[0]
|
||||||
|
assert seen_params[0]["count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_profile_handles_empty_response(monkeypatch):
|
||||||
|
monkeypatch.setenv("NIGHTSCOUT_URL", "https://example.com")
|
||||||
|
monkeypatch.setattr(ns.requests, "get", lambda *a, **k: FakeResponse([]))
|
||||||
|
|
||||||
|
assert ns.fetch_profile() == {}
|
||||||
|
|
||||||
|
|
||||||
def test_paginated_get_uses_created_at_field_for_treatments_cursor(monkeypatch):
|
def test_paginated_get_uses_created_at_field_for_treatments_cursor(monkeypatch):
|
||||||
"""Losstaande, gerichte assertie op het daadwerkelijke queryparameter-veld
|
"""Losstaande, gerichte assertie op het daadwerkelijke queryparameter-veld
|
||||||
— moet `find[created_at][$lte]` zijn, nooit `find[date][$lte]`, voor het
|
— moet `find[created_at][$lte]` zijn, nooit `find[date][$lte]`, voor het
|
||||||
|
|||||||
@@ -1,17 +1,32 @@
|
|||||||
|
import re
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
import pytest
|
||||||
|
|
||||||
from pdf_export import (
|
from pdf_export import (
|
||||||
DAGOVERZICHT_MAX_DAYS,
|
DAGOVERZICHT_MAX_DAYS,
|
||||||
SECTION_AGP,
|
SECTION_AGP,
|
||||||
SECTION_DAGOVERZICHT,
|
SECTION_DAGOVERZICHT,
|
||||||
SECTION_EVENTS,
|
SECTION_EVENTS,
|
||||||
|
SECTION_INSULIN_PROFILE,
|
||||||
SECTION_SUMMARY,
|
SECTION_SUMMARY,
|
||||||
|
_report_config,
|
||||||
build_report_pdf,
|
build_report_pdf,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _page_count(pdf_bytes: bytes) -> int:
|
||||||
|
"""Grof-maar-betrouwbaar paginatelling op de ruwe PDF-bytes: elke
|
||||||
|
paginaobject bevat een '/Type /Page' (zonder de 's' van '/Type /Pages')
|
||||||
|
in zijn objectdictionary, wat altijd onleesbaar-platte tekst blijft ook
|
||||||
|
al staat contentstream-compressie aan — geen losse PDF-parser nodig voor
|
||||||
|
deze structurele check (zelfde black-box-filosofie als de rest van deze
|
||||||
|
testset: geen pixel-vergelijkingen, wel structuur)."""
|
||||||
|
return len(re.findall(rb"/Type\s*/Page[^s]", pdf_bytes))
|
||||||
|
|
||||||
|
|
||||||
def _sample_glucose():
|
def _sample_glucose():
|
||||||
timestamps = pd.date_range("2026-01-01", periods=48, freq="30min")
|
timestamps = pd.date_range("2026-01-01", periods=48, freq="30min")
|
||||||
return pd.DataFrame({"timestamp": timestamps, "mmol": [6.0 + (i % 5) for i in range(48)]})
|
return pd.DataFrame({"timestamp": timestamps, "mmol": [6.0 + (i % 5) for i in range(48)]})
|
||||||
@@ -90,3 +105,185 @@ def test_build_report_pdf_draws_hypo_and_hyper_charts_when_events_present():
|
|||||||
)
|
)
|
||||||
assert pdf_bytes.startswith(b"%PDF")
|
assert pdf_bytes.startswith(b"%PDF")
|
||||||
assert len(pdf_bytes) > 500
|
assert len(pdf_bytes) > 500
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_report_pdf_always_has_at_least_a_cover_page():
|
||||||
|
empty = pd.DataFrame(columns=["timestamp", "mmol"])
|
||||||
|
pdf_bytes = build_report_pdf([], empty, {}, date(2026, 1, 1), date(2026, 1, 2))
|
||||||
|
assert _page_count(pdf_bytes) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_report_pdf_one_page_per_single_page_section():
|
||||||
|
glucose = _sample_glucose()
|
||||||
|
pdf_bytes = build_report_pdf(
|
||||||
|
[SECTION_SUMMARY, SECTION_AGP], glucose, {}, date(2026, 1, 1), date(2026, 1, 2)
|
||||||
|
)
|
||||||
|
# cover + summary + agp = 3
|
||||||
|
assert _page_count(pdf_bytes) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_report_pdf_one_page_per_day_for_dagoverzicht():
|
||||||
|
timestamps = pd.date_range("2026-01-01", periods=3 * 24 * 12, freq="5min") # 3 dagen
|
||||||
|
glucose = pd.DataFrame({"timestamp": timestamps, "mmol": [7.0] * len(timestamps)})
|
||||||
|
pdf_bytes = build_report_pdf(
|
||||||
|
[SECTION_DAGOVERZICHT], glucose, {}, date(2026, 1, 1), date(2026, 1, 3)
|
||||||
|
)
|
||||||
|
# cover + 3 dagen
|
||||||
|
assert _page_count(pdf_bytes) == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_report_pdf_caps_dagoverzicht_page_count_at_max_days():
|
||||||
|
timestamps = pd.date_range("2026-01-01", periods=(DAGOVERZICHT_MAX_DAYS + 10) * 24, freq="1h")
|
||||||
|
glucose = pd.DataFrame({"timestamp": timestamps, "mmol": [7.0] * len(timestamps)})
|
||||||
|
pdf_bytes = build_report_pdf(
|
||||||
|
[SECTION_DAGOVERZICHT], glucose, {}, timestamps[0].date(), timestamps[-1].date()
|
||||||
|
)
|
||||||
|
# cover + max. DAGOVERZICHT_MAX_DAYS dagpagina's, ondanks (DAGOVERZICHT_MAX_DAYS + 10) dagen aan data
|
||||||
|
assert _page_count(pdf_bytes) == 1 + DAGOVERZICHT_MAX_DAYS
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_report_pdf_includes_insulin_profile_section_page():
|
||||||
|
glucose = _sample_glucose()
|
||||||
|
profile = {
|
||||||
|
"name": "Default",
|
||||||
|
"dia": 5.0,
|
||||||
|
"basal": [{"minutes": 0, "value": 0.8}, {"minutes": 720, "value": 1.1}],
|
||||||
|
"sens": [{"minutes": 0, "value": 2.8}],
|
||||||
|
"carbratio": [{"minutes": 0, "value": 10.0}],
|
||||||
|
"target_low": [{"minutes": 0, "value": 4.5}],
|
||||||
|
"target_high": [{"minutes": 0, "value": 8.0}],
|
||||||
|
}
|
||||||
|
pdf_bytes = build_report_pdf(
|
||||||
|
[SECTION_INSULIN_PROFILE], glucose, {}, date(2026, 1, 1), date(2026, 1, 2), profile=profile
|
||||||
|
)
|
||||||
|
# cover + insulineprofiel
|
||||||
|
assert _page_count(pdf_bytes) == 2
|
||||||
|
assert pdf_bytes.startswith(b"%PDF")
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_report_pdf_insulin_profile_section_handles_missing_profile():
|
||||||
|
glucose = _sample_glucose()
|
||||||
|
pdf_bytes = build_report_pdf(
|
||||||
|
[SECTION_INSULIN_PROFILE], glucose, {}, date(2026, 1, 1), date(2026, 1, 2), profile=None
|
||||||
|
)
|
||||||
|
assert _page_count(pdf_bytes) == 2
|
||||||
|
assert pdf_bytes.startswith(b"%PDF")
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_report_pdf_insulin_profile_section_renders_without_glucose():
|
||||||
|
"""De Insulineprofiel-sectie gaat niet over glucosedata en moet dus ook
|
||||||
|
renderen als er geen glucosemetingen zijn (in tegenstelling tot
|
||||||
|
Samenvatting/AGP/Dagoverzicht, die wel glucosedata vereisen)."""
|
||||||
|
empty = pd.DataFrame(columns=["timestamp", "mmol"])
|
||||||
|
profile = {
|
||||||
|
"name": "Default",
|
||||||
|
"dia": 5.0,
|
||||||
|
"basal": [{"minutes": 0, "value": 0.8}],
|
||||||
|
"sens": [{"minutes": 0, "value": 2.8}],
|
||||||
|
"carbratio": [{"minutes": 0, "value": 10.0}],
|
||||||
|
"target_low": [],
|
||||||
|
"target_high": [],
|
||||||
|
}
|
||||||
|
pdf_bytes = build_report_pdf(
|
||||||
|
[SECTION_INSULIN_PROFILE], empty, {}, date(2026, 1, 1), date(2026, 1, 2), profile=profile
|
||||||
|
)
|
||||||
|
assert _page_count(pdf_bytes) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_report_pdf_dagoverzicht_uses_treatments_and_profile_without_error():
|
||||||
|
"""Regressietest: de dagpagina moet basaal/bolus/koolhydraten/streefwaarde-
|
||||||
|
overlays tekenen zonder te crashen als er echte treatments/profile worden
|
||||||
|
meegegeven (in plaats van de lege defaults)."""
|
||||||
|
timestamps = pd.date_range("2026-01-01", periods=24 * 12, freq="5min") # 1 dag
|
||||||
|
glucose = pd.DataFrame({"timestamp": timestamps, "mmol": [7.0] * len(timestamps)})
|
||||||
|
treatments = pd.DataFrame(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"timestamp": pd.Timestamp("2026-01-01 08:00"),
|
||||||
|
"eventType": "Meal Bolus",
|
||||||
|
"insulin": 4.0,
|
||||||
|
"carbs": 40.0,
|
||||||
|
"type": "",
|
||||||
|
"absolute": None,
|
||||||
|
"duration": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": pd.Timestamp("2026-01-01 12:00"),
|
||||||
|
"eventType": "Correction Bolus",
|
||||||
|
"insulin": 0.3,
|
||||||
|
"carbs": None,
|
||||||
|
"type": "SMB",
|
||||||
|
"absolute": None,
|
||||||
|
"duration": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": pd.Timestamp("2026-01-01 06:00"),
|
||||||
|
"eventType": "Temp Basal",
|
||||||
|
"insulin": None,
|
||||||
|
"carbs": None,
|
||||||
|
"type": "",
|
||||||
|
"absolute": 2.0,
|
||||||
|
"duration": 60,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
profile = {
|
||||||
|
"name": "Default",
|
||||||
|
"dia": 5.0,
|
||||||
|
"basal": [{"minutes": 0, "value": 0.8}],
|
||||||
|
"sens": [{"minutes": 0, "value": 2.8}],
|
||||||
|
"carbratio": [{"minutes": 0, "value": 10.0}],
|
||||||
|
"target_low": [{"minutes": 0, "value": 4.5}],
|
||||||
|
"target_high": [{"minutes": 0, "value": 8.0}],
|
||||||
|
}
|
||||||
|
pdf_bytes = build_report_pdf(
|
||||||
|
[SECTION_DAGOVERZICHT],
|
||||||
|
glucose,
|
||||||
|
{},
|
||||||
|
date(2026, 1, 1),
|
||||||
|
date(2026, 1, 1),
|
||||||
|
treatments=treatments,
|
||||||
|
profile=profile,
|
||||||
|
)
|
||||||
|
assert pdf_bytes.startswith(b"%PDF")
|
||||||
|
assert _page_count(pdf_bytes) == 2 # cover + 1 dag
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_config_uses_defaults_when_env_vars_unset(monkeypatch):
|
||||||
|
for var in ("REPORT_INSULIN_TYPE", "REPORT_PUMP_TYPE", "REPORT_SENSOR_TYPE", "NIGHTSCOUT_URL"):
|
||||||
|
monkeypatch.delenv(var, raising=False)
|
||||||
|
config = _report_config()
|
||||||
|
assert config["insulin_type"] == "Onbekend insulinetype"
|
||||||
|
assert config["pump_type"] == "Onbekende pomp"
|
||||||
|
assert config["sensor_type"] == "Onbekende sensor"
|
||||||
|
assert config["nightscout_url"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_config_reads_env_vars_when_set(monkeypatch):
|
||||||
|
monkeypatch.setenv("REPORT_INSULIN_TYPE", "Lyumjev 100U")
|
||||||
|
monkeypatch.setenv("REPORT_PUMP_TYPE", "Omnipod DASH")
|
||||||
|
monkeypatch.setenv("REPORT_SENSOR_TYPE", "Dexcom G7")
|
||||||
|
monkeypatch.setenv("NIGHTSCOUT_URL", "https://example.com/")
|
||||||
|
config = _report_config()
|
||||||
|
assert config["insulin_type"] == "Lyumjev 100U"
|
||||||
|
assert config["pump_type"] == "Omnipod DASH"
|
||||||
|
assert config["sensor_type"] == "Dexcom G7"
|
||||||
|
assert config["nightscout_url"] == "https://example.com" # trailing slash gestript
|
||||||
|
|
||||||
|
|
||||||
|
def test_icon_asset_exists():
|
||||||
|
icon_path = Path(__file__).parent.parent / "assets" / "icon.svg"
|
||||||
|
assert icon_path.exists()
|
||||||
|
assert icon_path.read_text(encoding="utf-8").strip().startswith("<svg")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("section", [SECTION_SUMMARY, SECTION_AGP, SECTION_DAGOVERZICHT, SECTION_EVENTS])
|
||||||
|
def test_all_sections_still_work_without_treatments_or_profile_args(section):
|
||||||
|
"""Achterwaartse compatibiliteit: treatments/profile zijn optioneel — een
|
||||||
|
aanroep zoals vóór deze PR (zonder die argumenten) moet blijven werken."""
|
||||||
|
glucose = _glucose_with_hypo_and_hyper_events() if section == SECTION_EVENTS else _sample_glucose()
|
||||||
|
events_summary = {"Hypo Level 1": {"count": 1, "avg_duration_minutes": 25.0, "events_per_week": 2.3}}
|
||||||
|
pdf_bytes = build_report_pdf(
|
||||||
|
[section], glucose, events_summary if section == SECTION_EVENTS else {}, date(2026, 1, 1), date(2026, 1, 4)
|
||||||
|
)
|
||||||
|
assert pdf_bytes.startswith(b"%PDF")
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
import profile_data as pd_
|
||||||
|
|
||||||
|
|
||||||
|
def _raw_profile(units="mg/dl", default_name="Default"):
|
||||||
|
return {
|
||||||
|
"defaultProfile": default_name,
|
||||||
|
"store": {
|
||||||
|
"Default": {
|
||||||
|
"units": units,
|
||||||
|
"dia": "5",
|
||||||
|
"basal": [
|
||||||
|
{"time": "00:00", "value": "0.8"},
|
||||||
|
{"time": "06:00", "value": "1.2"},
|
||||||
|
{"time": "22:00", "value": "0.7"},
|
||||||
|
],
|
||||||
|
"sens": [{"time": "00:00", "value": "50"}],
|
||||||
|
"carbratio": [{"time": "00:00", "value": "10"}],
|
||||||
|
"target_low": [{"time": "00:00", "value": "90"}],
|
||||||
|
"target_high": [{"time": "00:00", "value": "120"}],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_profile_returns_empty_dict_for_empty_input():
|
||||||
|
assert pd_.parse_profile({}) == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_profile_converts_mgdl_to_mmol():
|
||||||
|
profile = pd_.parse_profile(_raw_profile(units="mg/dl"))
|
||||||
|
assert profile["units"] == "mmol"
|
||||||
|
# 90 mg/dl -> ~5.0 mmol/L, 120 mg/dl -> ~6.7 mmol/L, 50 mg/dl (ISF) -> ~2.8 mmol/L
|
||||||
|
assert profile["target_low"][0]["value"] == pytest.approx(5.0, abs=0.05)
|
||||||
|
assert profile["target_high"][0]["value"] == pytest.approx(6.7, abs=0.05)
|
||||||
|
assert profile["sens"][0]["value"] == pytest.approx(2.8, abs=0.05)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_profile_leaves_mmol_profiles_unconverted():
|
||||||
|
profile = pd_.parse_profile(_raw_profile(units="mmol"))
|
||||||
|
assert profile["target_low"][0]["value"] == pytest.approx(90.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_profile_does_not_convert_basal_or_carbratio():
|
||||||
|
profile = pd_.parse_profile(_raw_profile(units="mg/dl"))
|
||||||
|
assert profile["basal"][0]["value"] == pytest.approx(0.8)
|
||||||
|
assert profile["carbratio"][0]["value"] == pytest.approx(10.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_profile_sorts_basal_segments_by_time():
|
||||||
|
raw = _raw_profile()
|
||||||
|
raw["store"]["Default"]["basal"] = [
|
||||||
|
{"time": "22:00", "value": "0.7"},
|
||||||
|
{"time": "00:00", "value": "0.8"},
|
||||||
|
{"time": "06:00", "value": "1.2"},
|
||||||
|
]
|
||||||
|
profile = pd_.parse_profile(raw)
|
||||||
|
assert [seg["minutes"] for seg in profile["basal"]] == [0, 360, 1320]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_profile_falls_back_to_first_profile_when_default_name_missing():
|
||||||
|
raw = _raw_profile(default_name="DoesNotExist")
|
||||||
|
profile = pd_.parse_profile(raw)
|
||||||
|
assert profile["dia"] == pytest.approx(5.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_profile_dia_and_name():
|
||||||
|
profile = pd_.parse_profile(_raw_profile())
|
||||||
|
assert profile["dia"] == pytest.approx(5.0)
|
||||||
|
assert profile["name"] == "Default"
|
||||||
|
|
||||||
|
|
||||||
|
def test_value_at_returns_active_segment():
|
||||||
|
segments = [{"minutes": 0, "value": 0.8}, {"minutes": 360, "value": 1.2}, {"minutes": 1320, "value": 0.7}]
|
||||||
|
assert pd_.value_at(segments, 100) == pytest.approx(0.8)
|
||||||
|
assert pd_.value_at(segments, 360) == pytest.approx(1.2)
|
||||||
|
assert pd_.value_at(segments, 1000) == pytest.approx(1.2)
|
||||||
|
assert pd_.value_at(segments, 1350) == pytest.approx(0.7)
|
||||||
|
|
||||||
|
|
||||||
|
def test_value_at_wraps_to_last_segment_before_first():
|
||||||
|
segments = [{"minutes": 360, "value": 1.2}, {"minutes": 1320, "value": 0.7}]
|
||||||
|
assert pd_.value_at(segments, 100) == pytest.approx(0.7)
|
||||||
|
|
||||||
|
|
||||||
|
def test_value_at_empty_segments_returns_none():
|
||||||
|
assert pd_.value_at([], 100) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_segments_table_computes_end_times_from_next_segment():
|
||||||
|
segments = [{"minutes": 0, "value": 0.8}, {"minutes": 360, "value": 1.2}]
|
||||||
|
rows = pd_.segments_table(segments)
|
||||||
|
assert rows == [
|
||||||
|
{"start": "00:00", "end": "06:00", "value": 0.8},
|
||||||
|
{"start": "06:00", "end": "24:00", "value": 1.2},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_segments_to_hourly_bars_closes_the_24h_loop():
|
||||||
|
segments = [{"minutes": 0, "value": 0.8}, {"minutes": 360, "value": 1.2}]
|
||||||
|
bars = pd_.segments_to_hourly_bars(segments)
|
||||||
|
assert bars[-1] == {"hour": 24.0, "value": 0.8}
|
||||||
|
|
||||||
|
|
||||||
|
def test_segments_to_hourly_bars_empty_input():
|
||||||
|
assert pd_.segments_to_hourly_bars([]) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_total_daily_basal_weights_by_segment_duration():
|
||||||
|
# 6h @ 0.8 + 18h @ 1.2 = 4.8 + 21.6 = 26.4
|
||||||
|
segments = [{"minutes": 0, "value": 0.8}, {"minutes": 360, "value": 1.2}]
|
||||||
|
assert pd_.total_daily_basal(segments) == pytest.approx(26.4)
|
||||||
|
|
||||||
|
|
||||||
|
def test_total_daily_basal_empty_input():
|
||||||
|
assert pd_.total_daily_basal([]) == 0.0
|
||||||
@@ -8,10 +8,14 @@ from reporting import (
|
|||||||
agp_percentile_bands,
|
agp_percentile_bands,
|
||||||
basal_stats,
|
basal_stats,
|
||||||
bolus_stats,
|
bolus_stats,
|
||||||
|
classify_bolus,
|
||||||
completeness_warning,
|
completeness_warning,
|
||||||
daily_gmi,
|
daily_gmi,
|
||||||
|
daily_stats,
|
||||||
day_of_week_pattern,
|
day_of_week_pattern,
|
||||||
detect_events,
|
detect_events,
|
||||||
|
effective_basal_series,
|
||||||
|
glucose_min_max,
|
||||||
gmi_mmol_per_mol,
|
gmi_mmol_per_mol,
|
||||||
gmi_percent,
|
gmi_percent,
|
||||||
gmi_trend,
|
gmi_trend,
|
||||||
@@ -21,6 +25,7 @@ from reporting import (
|
|||||||
sensor_active_percent,
|
sensor_active_percent,
|
||||||
site_interval_stats,
|
site_interval_stats,
|
||||||
summarize_events,
|
summarize_events,
|
||||||
|
target_glucose_series,
|
||||||
tir_breakdown_5band,
|
tir_breakdown_5band,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -480,6 +485,209 @@ def test_basal_stats_handles_empty_input():
|
|||||||
assert result == {"total_units": 0.0, "avg_units_per_day": 0.0}
|
assert result == {"total_units": 0.0, "avg_units_per_day": 0.0}
|
||||||
|
|
||||||
|
|
||||||
|
def test_glucose_min_max_returns_min_and_max():
|
||||||
|
glucose = _series([("2026-01-01 00:00", 6.5), ("2026-01-01 01:00", 3.2), ("2026-01-01 02:00", 14.1)])
|
||||||
|
assert glucose_min_max(glucose) == (3.2, 14.1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_glucose_min_max_handles_empty_input():
|
||||||
|
empty = pd.DataFrame(columns=["timestamp", "mmol"])
|
||||||
|
assert glucose_min_max(empty) == (0.0, 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _bolus_treatment(dt, event_type, insulin=1.0, carbs=None, bolus_type=""):
|
||||||
|
return {
|
||||||
|
"timestamp": pd.Timestamp(dt),
|
||||||
|
"eventType": event_type,
|
||||||
|
"insulin": insulin,
|
||||||
|
"carbs": carbs,
|
||||||
|
"type": bolus_type,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_bolus_recognizes_meal_bolus_as_food():
|
||||||
|
treatments = pd.DataFrame([_bolus_treatment("2026-01-01 08:00", "Meal Bolus", insulin=4.0)])
|
||||||
|
result = classify_bolus(treatments)
|
||||||
|
assert result.iloc[0]["category"] == "food"
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_bolus_recognizes_smb_via_type_field():
|
||||||
|
treatments = pd.DataFrame(
|
||||||
|
[_bolus_treatment("2026-01-01 08:00", "Correction Bolus", insulin=0.3, bolus_type="SMB")]
|
||||||
|
)
|
||||||
|
result = classify_bolus(treatments)
|
||||||
|
assert result.iloc[0]["category"] == "smb"
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_bolus_recognizes_smb_via_event_type():
|
||||||
|
treatments = pd.DataFrame([_bolus_treatment("2026-01-01 08:00", "SMB", insulin=0.3)])
|
||||||
|
result = classify_bolus(treatments)
|
||||||
|
assert result.iloc[0]["category"] == "smb"
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_bolus_treats_correction_bolus_without_carbs_as_correction():
|
||||||
|
treatments = pd.DataFrame([_bolus_treatment("2026-01-01 08:00", "Correction Bolus", insulin=1.5)])
|
||||||
|
result = classify_bolus(treatments)
|
||||||
|
assert result.iloc[0]["category"] == "correction"
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_bolus_treats_generic_bolus_with_carbs_as_food():
|
||||||
|
treatments = pd.DataFrame([_bolus_treatment("2026-01-01 08:00", "Bolus", insulin=3.0, carbs=40.0)])
|
||||||
|
result = classify_bolus(treatments)
|
||||||
|
assert result.iloc[0]["category"] == "food"
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_bolus_ignores_non_bolus_rows():
|
||||||
|
treatments = pd.DataFrame(
|
||||||
|
[
|
||||||
|
_bolus_treatment("2026-01-01 08:00", "Meal Bolus", insulin=4.0),
|
||||||
|
{"timestamp": pd.Timestamp("2026-01-01 08:05"), "eventType": "Site Change", "insulin": None, "carbs": None, "type": ""},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
result = classify_bolus(treatments)
|
||||||
|
assert len(result) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_bolus_handles_empty_input():
|
||||||
|
empty = pd.DataFrame(columns=["timestamp", "eventType", "insulin", "carbs", "type"])
|
||||||
|
result = classify_bolus(empty)
|
||||||
|
assert result.empty
|
||||||
|
assert list(result.columns) == ["timestamp", "eventType", "insulin", "carbs", "category"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_effective_basal_series_uses_profile_baseline_without_temp_basal():
|
||||||
|
profile = {"basal": [{"minutes": 0, "value": 0.8}, {"minutes": 360, "value": 1.2}]}
|
||||||
|
empty_treatments = pd.DataFrame(columns=["timestamp", "eventType", "absolute", "duration"])
|
||||||
|
result = effective_basal_series(
|
||||||
|
profile, empty_treatments, pd.Timestamp("2026-01-01"), pd.Timestamp("2026-01-02")
|
||||||
|
)
|
||||||
|
assert result.iloc[0]["rate"] == pytest.approx(0.8)
|
||||||
|
assert result[result["timestamp"] == pd.Timestamp("2026-01-01 06:00")].iloc[0]["rate"] == pytest.approx(1.2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_effective_basal_series_temp_basal_overrides_profile_rate():
|
||||||
|
profile = {"basal": [{"minutes": 0, "value": 0.8}]}
|
||||||
|
treatments = pd.DataFrame(
|
||||||
|
[{"timestamp": pd.Timestamp("2026-01-01 08:00"), "eventType": "Temp Basal", "absolute": 2.0, "duration": 30}]
|
||||||
|
)
|
||||||
|
result = effective_basal_series(profile, treatments, pd.Timestamp("2026-01-01"), pd.Timestamp("2026-01-02"))
|
||||||
|
during_temp = result[result["timestamp"] == pd.Timestamp("2026-01-01 08:00")].iloc[0]
|
||||||
|
after_temp = result[result["timestamp"] == pd.Timestamp("2026-01-01 08:30")].iloc[0]
|
||||||
|
assert during_temp["rate"] == pytest.approx(2.0)
|
||||||
|
assert after_temp["rate"] == pytest.approx(0.8) # terug naar het profieltempo
|
||||||
|
|
||||||
|
|
||||||
|
def test_effective_basal_series_without_profile_defaults_to_zero_baseline():
|
||||||
|
empty_treatments = pd.DataFrame(columns=["timestamp", "eventType", "absolute", "duration"])
|
||||||
|
result = effective_basal_series(None, empty_treatments, pd.Timestamp("2026-01-01"), pd.Timestamp("2026-01-02"))
|
||||||
|
assert result.iloc[0]["rate"] == pytest.approx(0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_effective_basal_series_clips_temp_basal_to_day_boundaries():
|
||||||
|
profile = {"basal": [{"minutes": 0, "value": 0.8}]}
|
||||||
|
# Temp basal begint de dag ervoor en loopt door tot 01:00 van de dag zelf.
|
||||||
|
treatments = pd.DataFrame(
|
||||||
|
[{"timestamp": pd.Timestamp("2025-12-31 23:00"), "eventType": "Temp Basal", "absolute": 2.0, "duration": 120}]
|
||||||
|
)
|
||||||
|
result = effective_basal_series(profile, treatments, pd.Timestamp("2026-01-01"), pd.Timestamp("2026-01-02"))
|
||||||
|
assert result.iloc[0]["timestamp"] == pd.Timestamp("2026-01-01")
|
||||||
|
assert result.iloc[0]["rate"] == pytest.approx(2.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_target_glucose_series_averages_low_and_high():
|
||||||
|
profile = {
|
||||||
|
"target_low": [{"minutes": 0, "value": 4.5}],
|
||||||
|
"target_high": [{"minutes": 0, "value": 8.5}],
|
||||||
|
}
|
||||||
|
result = target_glucose_series(profile, pd.Timestamp("2026-01-01"), pd.Timestamp("2026-01-02"))
|
||||||
|
assert result.iloc[0]["target"] == pytest.approx(6.5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_target_glucose_series_falls_back_to_single_bound():
|
||||||
|
profile = {"target_low": [{"minutes": 0, "value": 5.0}], "target_high": []}
|
||||||
|
result = target_glucose_series(profile, pd.Timestamp("2026-01-01"), pd.Timestamp("2026-01-02"))
|
||||||
|
assert result.iloc[0]["target"] == pytest.approx(5.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_target_glucose_series_empty_without_targets_in_profile():
|
||||||
|
result = target_glucose_series({}, pd.Timestamp("2026-01-01"), pd.Timestamp("2026-01-02"))
|
||||||
|
assert result.empty
|
||||||
|
|
||||||
|
|
||||||
|
def test_target_glucose_series_handles_none_profile():
|
||||||
|
result = target_glucose_series(None, pd.Timestamp("2026-01-01"), pd.Timestamp("2026-01-02"))
|
||||||
|
assert result.empty
|
||||||
|
|
||||||
|
|
||||||
|
def test_daily_stats_computes_glucose_and_insulin_breakdown():
|
||||||
|
day_start = pd.Timestamp("2026-01-01")
|
||||||
|
day_end = pd.Timestamp("2026-01-02")
|
||||||
|
glucose = _series(
|
||||||
|
[("2026-01-01 08:00", 3.2), ("2026-01-01 12:00", 14.1), ("2026-01-01 20:00", 6.5)]
|
||||||
|
)
|
||||||
|
treatments = pd.DataFrame(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"timestamp": pd.Timestamp("2026-01-01 08:00"),
|
||||||
|
"eventType": "Meal Bolus",
|
||||||
|
"insulin": 4.0,
|
||||||
|
"carbs": 40.0,
|
||||||
|
"type": "",
|
||||||
|
"absolute": None,
|
||||||
|
"duration": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": pd.Timestamp("2026-01-01 12:00"),
|
||||||
|
"eventType": "Correction Bolus",
|
||||||
|
"insulin": 0.3,
|
||||||
|
"carbs": None,
|
||||||
|
"type": "SMB",
|
||||||
|
"absolute": None,
|
||||||
|
"duration": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": pd.Timestamp("2026-01-01 06:00"),
|
||||||
|
"eventType": "Temp Basal",
|
||||||
|
"insulin": None,
|
||||||
|
"carbs": None,
|
||||||
|
"type": "",
|
||||||
|
"absolute": 2.0,
|
||||||
|
"duration": 60,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
profile = {"basal": [{"minutes": 0, "value": 0.8}]}
|
||||||
|
|
||||||
|
result = daily_stats(glucose, treatments, profile, day_start, day_end)
|
||||||
|
|
||||||
|
assert result["mean_mmol"] == pytest.approx((3.2 + 14.1 + 6.5) / 3, abs=0.05)
|
||||||
|
assert result["min_mmol"] == pytest.approx(3.2)
|
||||||
|
assert result["max_mmol"] == pytest.approx(14.1)
|
||||||
|
assert result["bolus_insulin"] == pytest.approx(4.0)
|
||||||
|
assert result["smb_insulin"] == pytest.approx(0.3)
|
||||||
|
# basaal: 23h @ 0.8 profieltempo + 1h @ 2.0 (temp basal 06:00-07:00) = 18.4 + 2.0 = 20.4
|
||||||
|
assert result["basal_insulin"] == pytest.approx(20.4, abs=0.05)
|
||||||
|
assert result["total_insulin"] == pytest.approx(4.0 + 0.3 + 20.4, abs=0.05)
|
||||||
|
assert result["hypo_count"] == 1
|
||||||
|
assert result["hyper_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_daily_stats_handles_empty_glucose_and_treatments():
|
||||||
|
day_start = pd.Timestamp("2026-01-01")
|
||||||
|
day_end = pd.Timestamp("2026-01-02")
|
||||||
|
empty_glucose = pd.DataFrame(columns=["timestamp", "mmol"])
|
||||||
|
empty_treatments = pd.DataFrame(columns=["timestamp", "eventType", "insulin", "carbs", "type", "absolute", "duration"])
|
||||||
|
|
||||||
|
result = daily_stats(empty_glucose, empty_treatments, None, day_start, day_end)
|
||||||
|
|
||||||
|
assert result["mean_mmol"] == 0.0
|
||||||
|
assert result["min_mmol"] == 0.0
|
||||||
|
assert result["max_mmol"] == 0.0
|
||||||
|
assert result["total_insulin"] == 0.0
|
||||||
|
assert result["hypo_count"] == 0
|
||||||
|
assert result["hyper_count"] == 0
|
||||||
|
|
||||||
|
|
||||||
def test_site_interval_stats_splits_by_site_change():
|
def test_site_interval_stats_splits_by_site_change():
|
||||||
glucose = _series(
|
glucose = _series(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -1,14 +1,38 @@
|
|||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
import plotly.graph_objects as go
|
import plotly.graph_objects as go
|
||||||
|
import streamlit as st
|
||||||
|
|
||||||
from ui_common import (
|
from ui_common import (
|
||||||
PALETTE,
|
PALETTE,
|
||||||
THRESHOLD_LINES,
|
THRESHOLD_LINES,
|
||||||
|
add_basal_area_trace,
|
||||||
|
add_bolus_marker_traces,
|
||||||
|
add_carb_bars_trace,
|
||||||
|
add_target_line_trace,
|
||||||
add_threshold_lines,
|
add_threshold_lines,
|
||||||
build_tir_stacked_bar_figure,
|
build_tir_stacked_bar_figure,
|
||||||
|
get_colors,
|
||||||
hex_to_rgba,
|
hex_to_rgba,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_colors_returns_dark_palette_when_theme_is_dark(monkeypatch):
|
||||||
|
monkeypatch.setattr(st, "context", SimpleNamespace(theme=SimpleNamespace(type="dark")))
|
||||||
|
assert get_colors() == PALETTE["dark"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_colors_returns_light_palette_when_theme_is_light(monkeypatch):
|
||||||
|
monkeypatch.setattr(st, "context", SimpleNamespace(theme=SimpleNamespace(type="light")))
|
||||||
|
assert get_colors() == PALETTE["light"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_colors_falls_back_to_light_when_theme_context_unavailable(monkeypatch):
|
||||||
|
monkeypatch.setattr(st, "context", SimpleNamespace()) # geen .theme-attribuut
|
||||||
|
assert get_colors() == PALETTE["light"]
|
||||||
|
|
||||||
|
|
||||||
def test_hex_to_rgba_converts_correctly():
|
def test_hex_to_rgba_converts_correctly():
|
||||||
assert hex_to_rgba("#2a78d6", 0.3) == "rgba(42,120,214,0.3)"
|
assert hex_to_rgba("#2a78d6", 0.3) == "rgba(42,120,214,0.3)"
|
||||||
|
|
||||||
@@ -36,3 +60,127 @@ def test_build_tir_stacked_bar_figure_has_one_bar_trace_per_band():
|
|||||||
assert len(fig.data) == 5
|
assert len(fig.data) == 5
|
||||||
assert [trace.y[0] for trace in fig.data] == [1.0, 3.0, 80.0, 12.0, 4.0]
|
assert [trace.y[0] for trace in fig.data] == [1.0, 3.0, 80.0, 12.0, 4.0]
|
||||||
assert fig.layout.barmode == "stack"
|
assert fig.layout.barmode == "stack"
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_basal_area_trace_adds_one_filled_step_trace():
|
||||||
|
basal = pd.DataFrame(
|
||||||
|
{"timestamp": pd.to_datetime(["2026-01-01 00:00", "2026-01-01 06:00"]), "rate": [0.8, 1.2]}
|
||||||
|
)
|
||||||
|
fig = go.Figure()
|
||||||
|
add_basal_area_trace(fig, basal, PALETTE["light"])
|
||||||
|
assert len(fig.data) == 1
|
||||||
|
assert fig.data[0].name == "Basaal"
|
||||||
|
assert fig.data[0].fill == "tozeroy"
|
||||||
|
assert fig.data[0].line.shape == "hv"
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_basal_area_trace_supports_secondary_y_on_a_subplot_figure():
|
||||||
|
from plotly.subplots import make_subplots
|
||||||
|
|
||||||
|
basal = pd.DataFrame(
|
||||||
|
{"timestamp": pd.to_datetime(["2026-01-01 00:00", "2026-01-01 06:00"]), "rate": [0.8, 1.2]}
|
||||||
|
)
|
||||||
|
fig = make_subplots(specs=[[{"secondary_y": True}]])
|
||||||
|
add_basal_area_trace(fig, basal, PALETTE["light"], row=1, secondary_y=True)
|
||||||
|
assert len(fig.data) == 1
|
||||||
|
assert fig.data[0].yaxis == "y2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_basal_area_trace_skips_empty_input():
|
||||||
|
fig = go.Figure()
|
||||||
|
add_basal_area_trace(fig, pd.DataFrame(columns=["timestamp", "rate"]), PALETTE["light"])
|
||||||
|
assert len(fig.data) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_carb_bars_trace_supports_secondary_y_on_a_subplot_figure():
|
||||||
|
from plotly.subplots import make_subplots
|
||||||
|
|
||||||
|
carbs = pd.DataFrame({"timestamp": pd.to_datetime(["2026-01-01 08:00"]), "carbs": [40.0]})
|
||||||
|
fig = make_subplots(specs=[[{"secondary_y": True}]])
|
||||||
|
add_carb_bars_trace(fig, carbs, PALETTE["light"], row=1, secondary_y=True)
|
||||||
|
assert len(fig.data) == 1
|
||||||
|
assert fig.data[0].yaxis == "y2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_carb_bars_trace_adds_one_bar_trace():
|
||||||
|
carbs = pd.DataFrame({"timestamp": pd.to_datetime(["2026-01-01 08:00"]), "carbs": [40.0]})
|
||||||
|
fig = go.Figure()
|
||||||
|
add_carb_bars_trace(fig, carbs, PALETTE["light"])
|
||||||
|
assert len(fig.data) == 1
|
||||||
|
assert fig.data[0].name == "Koolhydraten"
|
||||||
|
assert isinstance(fig.data[0], go.Bar)
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_carb_bars_trace_skips_empty_input():
|
||||||
|
fig = go.Figure()
|
||||||
|
add_carb_bars_trace(fig, pd.DataFrame(columns=["timestamp", "carbs"]), PALETTE["light"])
|
||||||
|
assert len(fig.data) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_bolus_marker_traces_adds_one_trace_per_category_present():
|
||||||
|
bolus = pd.DataFrame(
|
||||||
|
{
|
||||||
|
"timestamp": pd.to_datetime(["2026-01-01 08:00", "2026-01-01 12:00"]),
|
||||||
|
"insulin": [4.0, 0.3],
|
||||||
|
"category": ["food", "smb"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
fig = go.Figure()
|
||||||
|
add_bolus_marker_traces(fig, bolus, PALETTE["light"], lane_y=2.0)
|
||||||
|
names = {trace.name for trace in fig.data}
|
||||||
|
assert names == {"Bolus (voeding)", "SMB"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_bolus_marker_traces_labels_food_and_correction_but_not_smb():
|
||||||
|
bolus = pd.DataFrame(
|
||||||
|
{
|
||||||
|
"timestamp": pd.to_datetime(["2026-01-01 08:00", "2026-01-01 12:00"]),
|
||||||
|
"insulin": [4.0, 0.3],
|
||||||
|
"category": ["food", "smb"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
fig = go.Figure()
|
||||||
|
add_bolus_marker_traces(fig, bolus, PALETTE["light"], lane_y=2.0)
|
||||||
|
by_name = {trace.name: trace for trace in fig.data}
|
||||||
|
assert by_name["Bolus (voeding)"].mode == "markers+text"
|
||||||
|
assert by_name["SMB"].mode == "markers"
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_bolus_marker_traces_skips_empty_input():
|
||||||
|
fig = go.Figure()
|
||||||
|
add_bolus_marker_traces(fig, pd.DataFrame(columns=["timestamp", "insulin", "category"]), PALETTE["light"], 2.0)
|
||||||
|
assert len(fig.data) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_target_line_trace_adds_one_line_trace():
|
||||||
|
target = pd.DataFrame({"timestamp": pd.to_datetime(["2026-01-01 00:00"]), "target": [6.5]})
|
||||||
|
fig = go.Figure()
|
||||||
|
add_target_line_trace(fig, target, PALETTE["light"])
|
||||||
|
assert len(fig.data) == 1
|
||||||
|
assert fig.data[0].name == "Streefwaarde (pomp)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_target_line_trace_skips_empty_input():
|
||||||
|
fig = go.Figure()
|
||||||
|
add_target_line_trace(fig, pd.DataFrame(columns=["timestamp", "target"]), PALETTE["light"])
|
||||||
|
assert len(fig.data) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_overlay_traces_support_an_hour_based_x_column_for_dagoverzicht():
|
||||||
|
"""Dagoverzicht plot elke dag op een gedeelde 0-24u-as ('hour') i.p.v. echte
|
||||||
|
datums; de hover mag daar dan ook geen datumformattering tonen."""
|
||||||
|
basal = pd.DataFrame({"hour": [0.0, 6.0], "rate": [0.8, 1.2]})
|
||||||
|
carbs = pd.DataFrame({"hour": [8.0], "carbs": [40.0]})
|
||||||
|
bolus = pd.DataFrame({"hour": [8.0], "insulin": [4.0], "category": ["food"]})
|
||||||
|
target = pd.DataFrame({"hour": [0.0], "target": [6.5]})
|
||||||
|
|
||||||
|
fig = go.Figure()
|
||||||
|
add_basal_area_trace(fig, basal, PALETTE["light"], x_col="hour")
|
||||||
|
add_carb_bars_trace(fig, carbs, PALETTE["light"], x_col="hour")
|
||||||
|
add_bolus_marker_traces(fig, bolus, PALETTE["light"], lane_y=2.0, x_col="hour")
|
||||||
|
add_target_line_trace(fig, target, PALETTE["light"], x_col="hour")
|
||||||
|
|
||||||
|
assert len(fig.data) == 4
|
||||||
|
bolus_trace = next(t for t in fig.data if t.name == "Bolus (voeding)")
|
||||||
|
assert list(bolus_trace.x) == [8.0]
|
||||||
|
assert "%d-%m" not in bolus_trace.hovertemplate
|
||||||
|
|||||||
+225
-9
@@ -14,7 +14,14 @@ import pandas as pd
|
|||||||
import plotly.graph_objects as go
|
import plotly.graph_objects as go
|
||||||
import streamlit as st
|
import streamlit as st
|
||||||
|
|
||||||
from nightscout_client import fetch_entries_range, fetch_treatments_range
|
from nightscout_client import (
|
||||||
|
fetch_entries_for_day,
|
||||||
|
fetch_entries_range,
|
||||||
|
fetch_profile,
|
||||||
|
fetch_treatments_for_day,
|
||||||
|
fetch_treatments_range,
|
||||||
|
)
|
||||||
|
from profile_data import parse_profile
|
||||||
from reporting import (
|
from reporting import (
|
||||||
HIGH,
|
HIGH,
|
||||||
LOW,
|
LOW,
|
||||||
@@ -57,6 +64,11 @@ PALETTE = {
|
|||||||
"tir_in_range": "#0ca30c",
|
"tir_in_range": "#0ca30c",
|
||||||
"tir_high": "#fab219",
|
"tir_high": "#fab219",
|
||||||
"tir_very_high": "#c76f0a",
|
"tir_very_high": "#c76f0a",
|
||||||
|
"basal": "#7b5ea3",
|
||||||
|
"bolus_food": "#1baf7a",
|
||||||
|
"bolus_correction": "#2a78d6",
|
||||||
|
"bolus_smb": "#898781",
|
||||||
|
"target": "#52514e",
|
||||||
},
|
},
|
||||||
"dark": {
|
"dark": {
|
||||||
"glucose": "#3987e5",
|
"glucose": "#3987e5",
|
||||||
@@ -76,6 +88,11 @@ PALETTE = {
|
|||||||
"tir_in_range": "#0ca30c",
|
"tir_in_range": "#0ca30c",
|
||||||
"tir_high": "#fab219",
|
"tir_high": "#fab219",
|
||||||
"tir_very_high": "#d9852e",
|
"tir_very_high": "#d9852e",
|
||||||
|
"basal": "#9b7ec2",
|
||||||
|
"bolus_food": "#199e70",
|
||||||
|
"bolus_correction": "#3987e5",
|
||||||
|
"bolus_smb": "#898781",
|
||||||
|
"target": "#c3c2b7",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,8 +124,12 @@ def get_colors() -> dict:
|
|||||||
return PALETTE["dark" if theme_type == "dark" else "light"]
|
return PALETTE["dark" if theme_type == "dark" else "light"]
|
||||||
|
|
||||||
|
|
||||||
def select_period_days(default_label: str) -> tuple[date, date, int]:
|
def select_period_days(default_label: str) -> tuple[date, date, int]: # pragma: no cover
|
||||||
"""Periodekiezer voor rapportagepagina's; geeft (start, eind, aantal_dagen) terug."""
|
"""Periodekiezer voor rapportagepagina's; geeft (start, eind, aantal_dagen) terug.
|
||||||
|
|
||||||
|
Pure Streamlit-widget-orkestratie (st.radio) — buiten scope van de
|
||||||
|
unit-testdekking net als pages/*.py, zie pyproject.toml se coverage-omit-lijst.
|
||||||
|
"""
|
||||||
labels = list(PERIOD_OPTIONS_DAYS.keys())
|
labels = list(PERIOD_OPTIONS_DAYS.keys())
|
||||||
label = st.radio("Periode", labels, index=labels.index(default_label), horizontal=True)
|
label = st.radio("Periode", labels, index=labels.index(default_label), horizontal=True)
|
||||||
days = PERIOD_OPTIONS_DAYS[label]
|
days = PERIOD_OPTIONS_DAYS[label]
|
||||||
@@ -118,17 +139,53 @@ def select_period_days(default_label: str) -> tuple[date, date, int]:
|
|||||||
|
|
||||||
|
|
||||||
@st.cache_data(ttl=3600, show_spinner="Data ophalen van Nightscout...")
|
@st.cache_data(ttl=3600, show_spinner="Data ophalen van Nightscout...")
|
||||||
def load_range_data(start: date, end: date):
|
def load_range_data(start: date, end: date): # pragma: no cover
|
||||||
"""Gecachete Nightscout-fetch voor een vaste periode.
|
"""Gecachete Nightscout-fetch voor een vaste periode.
|
||||||
|
|
||||||
Lange TTL (in tegenstelling tot de 60s van de live-pagina): historische
|
Lange TTL (in tegenstelling tot de 60s van de live-pagina): historische
|
||||||
data van een afgeronde periode verandert niet.
|
data van een afgeronde periode verandert niet.
|
||||||
|
|
||||||
|
Dunne st.cache_data-wrapper rond al-geteste fetch_entries_range()/
|
||||||
|
fetch_treatments_range() — buiten scope van de unit-testdekking net als
|
||||||
|
pages/*.py, zie pyproject.toml se coverage-omit-lijst.
|
||||||
"""
|
"""
|
||||||
glucose = fetch_entries_range(start, end)
|
glucose = fetch_entries_range(start, end)
|
||||||
treatments = fetch_treatments_range(start, end)
|
treatments = fetch_treatments_range(start, end)
|
||||||
return glucose, treatments
|
return glucose, treatments
|
||||||
|
|
||||||
|
|
||||||
|
@st.cache_data(ttl=300, show_spinner="Data ophalen van Nightscout...")
|
||||||
|
def load_day_data(day: date): # pragma: no cover
|
||||||
|
"""Gecachete Nightscout-fetch voor precies één kalenderdag (Dagoverzicht-navigatie).
|
||||||
|
|
||||||
|
Kortere TTL dan load_range_data (5 min i.p.v. 1 uur): in tegenstelling tot
|
||||||
|
een afgeronde periode kan de geselecteerde dag "vandaag" zijn, waar
|
||||||
|
nieuwe metingen blijven binnenkomen. fetch_treatments_for_day() haalt
|
||||||
|
zelf een lookback-marge vóór middernacht op zodat effective_basal_series()
|
||||||
|
de vroege ochtenduren correct kan berekenen.
|
||||||
|
|
||||||
|
Dunne st.cache_data-wrapper rond al-geteste fetch_entries_for_day()/
|
||||||
|
fetch_treatments_for_day() — buiten scope van de unit-testdekking net als
|
||||||
|
pages/*.py, zie pyproject.toml se coverage-omit-lijst.
|
||||||
|
"""
|
||||||
|
glucose = fetch_entries_for_day(day)
|
||||||
|
treatments = fetch_treatments_for_day(day)
|
||||||
|
return glucose, treatments
|
||||||
|
|
||||||
|
|
||||||
|
@st.cache_data(ttl=21600, show_spinner="Profiel ophalen van Nightscout...")
|
||||||
|
def load_profile() -> dict: # pragma: no cover
|
||||||
|
"""Gecachete profiel-fetch. Langere TTL dan load_range_data (6 uur i.p.v.
|
||||||
|
1 uur): een pompprofiel wijzigt zelden, in tegenstelling tot glucose-/
|
||||||
|
behandeldata van een lopende periode.
|
||||||
|
|
||||||
|
Dunne st.cache_data-wrapper rond al-geteste fetch_profile()/parse_profile()
|
||||||
|
— buiten scope van de unit-testdekking net als pages/*.py.
|
||||||
|
"""
|
||||||
|
raw = fetch_profile()
|
||||||
|
return parse_profile(raw)
|
||||||
|
|
||||||
|
|
||||||
def add_threshold_lines(fig: go.Figure, colors: dict, row: int | None = None) -> None:
|
def add_threshold_lines(fig: go.Figure, colors: dict, row: int | None = None) -> None:
|
||||||
"""Teken de 4 klinische drempellijnen (3.0/3.9/10.0/13.9 mmol/L) op een grafiek.
|
"""Teken de 4 klinische drempellijnen (3.0/3.9/10.0/13.9 mmol/L) op een grafiek.
|
||||||
|
|
||||||
@@ -189,19 +246,175 @@ def build_tir_stacked_bar_figure(tir: dict, colors: dict) -> go.Figure:
|
|||||||
return fig
|
return fig
|
||||||
|
|
||||||
|
|
||||||
def render_completeness_warning(glucose: pd.DataFrame, start: date, end: date) -> None:
|
BOLUS_CATEGORY_LABELS = {
|
||||||
|
"food": "Bolus (voeding)",
|
||||||
|
"correction": "Bolus (correctie)",
|
||||||
|
"smb": "SMB",
|
||||||
|
}
|
||||||
|
BOLUS_CATEGORY_COLOR_KEYS = {
|
||||||
|
"food": "bolus_food",
|
||||||
|
"correction": "bolus_correction",
|
||||||
|
"smb": "bolus_smb",
|
||||||
|
}
|
||||||
|
# Tekenvolgorde van bolus-categorieën: SMB eerst (onderop, meestal talrijker en
|
||||||
|
# kleiner), voeding/correctie erbovenop (grotere, gelabelde stippen blijven
|
||||||
|
# zichtbaar in plaats van bedolven te raken onder de SMB-stippen).
|
||||||
|
BOLUS_CATEGORY_DRAW_ORDER = ["smb", "correction", "food"]
|
||||||
|
|
||||||
|
|
||||||
|
def add_basal_area_trace(
|
||||||
|
fig: go.Figure,
|
||||||
|
basal: pd.DataFrame,
|
||||||
|
colors: dict,
|
||||||
|
*,
|
||||||
|
row: int | None = None,
|
||||||
|
secondary_y: bool = False,
|
||||||
|
x_col: str = "timestamp",
|
||||||
|
) -> None:
|
||||||
|
"""Basaal-insuline als semi-transparant stapvlak (line_shape='hv'), met alpha
|
||||||
|
zodat onderliggende data zichtbaar blijft. Verwacht kolommen 'rate' en
|
||||||
|
`x_col` (standaard 'timestamp'; de Dagoverzicht-pagina geeft hier 'hour'
|
||||||
|
aan omdat elke dag daar op een gedeelde 0-24u-as staat i.p.v. echte
|
||||||
|
datums — zie reporting.effective_basal_series). Bedoeld voor een
|
||||||
|
secundaire y-as (E/uur past niet op de mmol/L-schaal van de glucoselijn).
|
||||||
|
"""
|
||||||
|
if basal.empty:
|
||||||
|
return
|
||||||
|
kwargs: dict = {"row": row, "col": 1} if row is not None else {}
|
||||||
|
if secondary_y:
|
||||||
|
kwargs["secondary_y"] = True
|
||||||
|
fig.add_trace(
|
||||||
|
go.Scatter(
|
||||||
|
x=basal[x_col],
|
||||||
|
y=basal["rate"],
|
||||||
|
mode="lines",
|
||||||
|
line={"shape": "hv", "color": colors["basal"], "width": 1},
|
||||||
|
fill="tozeroy",
|
||||||
|
fillcolor=hex_to_rgba(colors["basal"], 0.3),
|
||||||
|
opacity=0.8,
|
||||||
|
name="Basaal",
|
||||||
|
hovertemplate="%{y:.2f} E/uur<extra></extra>",
|
||||||
|
),
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def add_carb_bars_trace(
|
||||||
|
fig: go.Figure,
|
||||||
|
carbs: pd.DataFrame,
|
||||||
|
colors: dict,
|
||||||
|
*,
|
||||||
|
row: int | None = None,
|
||||||
|
secondary_y: bool = False,
|
||||||
|
x_col: str = "timestamp",
|
||||||
|
) -> None:
|
||||||
|
"""Koolhydraten als staafjes op het moment van invoer. Verwacht kolommen
|
||||||
|
'carbs' en `x_col` (zie add_basal_area_trace voor waarom dit configureerbaar
|
||||||
|
is). Zelfde secundaire as als de basaal-area (allebei geen mmol/L-grootheid),
|
||||||
|
met een eigen kleur en alpha zodat beide onderscheidbaar blijven."""
|
||||||
|
if carbs.empty:
|
||||||
|
return
|
||||||
|
kwargs: dict = {"row": row, "col": 1} if row is not None else {}
|
||||||
|
if secondary_y:
|
||||||
|
kwargs["secondary_y"] = True
|
||||||
|
fig.add_trace(
|
||||||
|
go.Bar(
|
||||||
|
x=carbs[x_col],
|
||||||
|
y=carbs["carbs"],
|
||||||
|
marker_color=colors["carbs"],
|
||||||
|
opacity=0.55,
|
||||||
|
name="Koolhydraten",
|
||||||
|
hovertemplate="%{y:.0f} g<extra></extra>",
|
||||||
|
),
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def add_bolus_marker_traces(
|
||||||
|
fig: go.Figure,
|
||||||
|
bolus: pd.DataFrame,
|
||||||
|
colors: dict,
|
||||||
|
lane_y: float,
|
||||||
|
*,
|
||||||
|
row: int | None = None,
|
||||||
|
x_col: str = "timestamp",
|
||||||
|
) -> None:
|
||||||
|
"""Eén Scatter-trace per boluscategorie (food/correction/smb), gepositioneerd
|
||||||
|
op een vaste 'lane'-hoogte op de primaire (mmol/L-)as — net als de bestaande
|
||||||
|
insuline/carbs-lanes op de Live-pagina. De hoogte zelf draagt geen
|
||||||
|
kwantitatieve betekenis; de markergrootte (geschaald naar toegediende
|
||||||
|
eenheden) en hoverwaarde wel. Voeding/correctie krijgen een tekstlabel met
|
||||||
|
de dosis; SMB's niet (te talrijk/te klein voor leesbare labels). Verwacht
|
||||||
|
kolommen 'insulin'/'category' en `x_col` (zie reporting.classify_bolus).
|
||||||
|
"""
|
||||||
|
# Datumformattering in de hover werkt alleen op een echte datetime-as; de
|
||||||
|
# Dagoverzicht-pagina geeft hier 'hour' (een kaal getal 0-24) aan, dus
|
||||||
|
# daar laten we het tijdstip weg en toont de hover alleen de dosis.
|
||||||
|
hover_prefix = "%{x|%d-%m %H:%M} · " if x_col == "timestamp" else ""
|
||||||
|
kwargs: dict = {"row": row, "col": 1} if row is not None else {}
|
||||||
|
for category in BOLUS_CATEGORY_DRAW_ORDER:
|
||||||
|
subset = bolus[bolus["category"] == category] if not bolus.empty else bolus
|
||||||
|
if subset.empty:
|
||||||
|
continue
|
||||||
|
show_label = category != "smb"
|
||||||
|
fig.add_trace(
|
||||||
|
go.Scatter(
|
||||||
|
x=subset[x_col],
|
||||||
|
y=[lane_y] * len(subset),
|
||||||
|
mode="markers+text" if show_label else "markers",
|
||||||
|
marker={
|
||||||
|
"size": (subset["insulin"] * 8).clip(lower=6, upper=30),
|
||||||
|
"color": colors[BOLUS_CATEGORY_COLOR_KEYS[category]],
|
||||||
|
"opacity": 0.75,
|
||||||
|
},
|
||||||
|
text=[f"{v:.1f}E" for v in subset["insulin"]] if show_label else None,
|
||||||
|
textposition="top center",
|
||||||
|
textfont={"size": 9, "color": colors["text"]},
|
||||||
|
name=BOLUS_CATEGORY_LABELS[category],
|
||||||
|
customdata=subset["insulin"],
|
||||||
|
hovertemplate=f"{hover_prefix}%{{customdata:.2f}} E<extra></extra>",
|
||||||
|
),
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def add_target_line_trace(
|
||||||
|
fig: go.Figure, target: pd.DataFrame, colors: dict, *, row: int | None = None, x_col: str = "timestamp"
|
||||||
|
) -> None:
|
||||||
|
"""Streefglucose uit het pompprofiel als lijn op de primaire (mmol/L-)as.
|
||||||
|
Verwacht kolommen 'target' en `x_col` (zie reporting.target_glucose_series)."""
|
||||||
|
if target.empty:
|
||||||
|
return
|
||||||
|
kwargs: dict = {"row": row, "col": 1} if row is not None else {}
|
||||||
|
fig.add_trace(
|
||||||
|
go.Scatter(
|
||||||
|
x=target[x_col],
|
||||||
|
y=target["target"],
|
||||||
|
mode="lines",
|
||||||
|
line={"shape": "hv", "color": colors["target"], "width": 1.5, "dash": "dashdot"},
|
||||||
|
opacity=0.9,
|
||||||
|
name="Streefwaarde (pomp)",
|
||||||
|
hovertemplate="%{y:.1f} mmol/L<extra></extra>",
|
||||||
|
),
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_completeness_warning(glucose: pd.DataFrame, start: date, end: date) -> None: # pragma: no cover
|
||||||
"""Toon reporting.completeness_warning()'s oordeel als st.warning, indien nodig.
|
"""Toon reporting.completeness_warning()'s oordeel als st.warning, indien nodig.
|
||||||
|
|
||||||
Gebruikt door elke rapportagepagina, direct na de load_range_data-call.
|
Gebruikt door elke rapportagepagina, direct na de load_range_data-call.
|
||||||
De beoordelingslogica zelf staat in reporting.py (pure functie) zodat het
|
De beoordelingslogica zelf staat in reporting.py (pure functie, wél volledig
|
||||||
PDF-rapport (pdf_export.py) exact dezelfde disclaimer kan hergebruiken.
|
getest) zodat het PDF-rapport (pdf_export.py) exact dezelfde disclaimer kan
|
||||||
|
hergebruiken. Deze functie is alleen de st.warning-weergave — buiten scope
|
||||||
|
van de unit-testdekking net als pages/*.py.
|
||||||
"""
|
"""
|
||||||
message = completeness_warning(glucose, start, end)
|
message = completeness_warning(glucose, start, end)
|
||||||
if message:
|
if message:
|
||||||
st.warning(message)
|
st.warning(message)
|
||||||
|
|
||||||
|
|
||||||
def render_event_level(
|
def render_event_level( # pragma: no cover
|
||||||
glucose: pd.DataFrame,
|
glucose: pd.DataFrame,
|
||||||
threshold: float,
|
threshold: float,
|
||||||
direction: str,
|
direction: str,
|
||||||
@@ -212,7 +425,10 @@ def render_event_level(
|
|||||||
"""Render de metrics/tabel/tijdstip-histogram voor één hypo-/hyperniveau.
|
"""Render de metrics/tabel/tijdstip-histogram voor één hypo-/hyperniveau.
|
||||||
|
|
||||||
Gedeeld tussen de Hypo Events- en Hyper Events-pagina's (FR5/FR6), die
|
Gedeeld tussen de Hypo Events- en Hyper Events-pagina's (FR5/FR6), die
|
||||||
elk twee niveaus (Level 1/Level 2) op dezelfde manier tonen.
|
elk twee niveaus (Level 1/Level 2) op dezelfde manier tonen. Pure
|
||||||
|
Streamlit-widget-weergave rond al-geteste reporting.detect_events()/
|
||||||
|
summarize_events() — buiten scope van de unit-testdekking net als
|
||||||
|
pages/*.py.
|
||||||
"""
|
"""
|
||||||
events = detect_events(glucose, threshold, direction)
|
events = detect_events(glucose, threshold, direction)
|
||||||
summary = summarize_events(events, period_days)
|
summary = summarize_events(events, period_days)
|
||||||
|
|||||||
Reference in New Issue
Block a user