Compare commits
10
Commits
a10a4c0414
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7a0b94a54 | ||
|
|
95b3aff310 | ||
|
|
bd66d3dacd | ||
|
|
47050bf031 | ||
|
|
de0ecebce5 | ||
|
|
caf28046cb | ||
|
|
a4d29e85a8 | ||
|
|
7f4377329a | ||
|
|
c617f4e41b | ||
|
|
6004f76d76 |
@@ -5,4 +5,6 @@ __pycache__/
|
|||||||
.git/
|
.git/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
|
.coverage
|
||||||
|
coverage.xml
|
||||||
tests/
|
tests/
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -41,3 +41,12 @@ klinische rapportagepagina's (zie `docs/requirements-reporting.md` en
|
|||||||
pytest tests/ -v
|
pytest tests/ -v
|
||||||
ruff check .
|
ruff check .
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Coverage (zie `pyproject.toml` se `[tool.coverage.*]`): de CI-pipeline faalt
|
||||||
|
als de dekking op de gemeten modules onder 80% zakt (`app.py`/`pages/*.py`
|
||||||
|
zijn uitgesloten — dunne Streamlit-UI-scripts, niet zinvol te unit-testen
|
||||||
|
zonder een draaiende Streamlit-sessie).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/ --cov=. --cov-report=term-missing
|
||||||
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -254,6 +254,33 @@ def fetch_treatments_range(start_date: date, end_date: date) -> pd.DataFrame:
|
|||||||
return _treatments_since(since, until)
|
return _treatments_since(since, until)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_entries_for_day(day: date) -> pd.DataFrame:
|
||||||
|
"""Haal glucosemetingen op voor precies één kalenderdag.
|
||||||
|
|
||||||
|
Dunne wrapper om fetch_entries_range() met start=end=day, voor de
|
||||||
|
dag-navigatie op de Dagoverzicht-pagina: laadt alleen de geselecteerde
|
||||||
|
dag in plaats van een hele periode, zodat het wisselen tussen dagen snel
|
||||||
|
blijft.
|
||||||
|
"""
|
||||||
|
return fetch_entries_range(day, day)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_treatments_for_day(day: date, lookback_hours: int = 6) -> pd.DataFrame:
|
||||||
|
"""Haal behandelingen op voor één kalenderdag, met een lookback-marge vóór middernacht.
|
||||||
|
|
||||||
|
reporting.effective_basal_series() heeft de starttijd van een Temp Basal
|
||||||
|
nodig om de basaalsnelheid vroeg in de dag correct te berekenen — ook als
|
||||||
|
die temp basal al vóór middernacht is gestart en doorloopt. Zonder deze
|
||||||
|
marge zou de eerste uren van elke los opgevraagde dag ten onrechte het
|
||||||
|
profieltempo tonen in plaats van een nog actieve temp basal. 6 uur ligt
|
||||||
|
ruim boven de gebruikelijke temp-basal-duur in gesloten-lus-modus
|
||||||
|
(doorgaans 30-60 minuten).
|
||||||
|
"""
|
||||||
|
since = datetime.combine(day, time.min, tzinfo=timezone.utc) - timedelta(hours=lookback_hours)
|
||||||
|
until = datetime.combine(day, time.max, tzinfo=timezone.utc)
|
||||||
|
return _treatments_since(since, until)
|
||||||
|
|
||||||
|
|
||||||
def fetch_profile() -> dict:
|
def fetch_profile() -> dict:
|
||||||
"""Haal het meest recent gebruikte pompprofiel op.
|
"""Haal het meest recent gebruikte pompprofiel op.
|
||||||
|
|
||||||
|
|||||||
+132
-100
@@ -1,3 +1,5 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import plotly.graph_objects as go
|
import plotly.graph_objects as go
|
||||||
import requests
|
import requests
|
||||||
@@ -7,16 +9,23 @@ from plotly.subplots import make_subplots
|
|||||||
from auth import require_auth
|
from auth import require_auth
|
||||||
from data_processing import TARGET_HIGH, TARGET_LOW, classify_treatments
|
from data_processing import TARGET_HIGH, TARGET_LOW, classify_treatments
|
||||||
from nightscout_client import NightscoutConfigError
|
from nightscout_client import NightscoutConfigError
|
||||||
from reporting import classify_bolus, effective_basal_series, target_glucose_series
|
from reporting import (
|
||||||
|
BOLUS_CATEGORY_SMB,
|
||||||
|
classify_bolus,
|
||||||
|
daily_stats,
|
||||||
|
effective_basal_series,
|
||||||
|
target_glucose_series,
|
||||||
|
)
|
||||||
from ui_common import (
|
from ui_common import (
|
||||||
|
MMOL_HOVERTEMPLATE,
|
||||||
add_basal_area_trace,
|
add_basal_area_trace,
|
||||||
add_bolus_marker_traces,
|
add_bolus_marker_traces,
|
||||||
add_carb_bars_trace,
|
add_carb_bars_trace,
|
||||||
add_target_line_trace,
|
add_target_line_trace,
|
||||||
add_threshold_lines,
|
add_threshold_lines,
|
||||||
get_colors,
|
get_colors,
|
||||||
|
load_day_data,
|
||||||
load_profile,
|
load_profile,
|
||||||
load_range_data,
|
|
||||||
render_completeness_warning,
|
render_completeness_warning,
|
||||||
select_period_days,
|
select_period_days,
|
||||||
)
|
)
|
||||||
@@ -25,17 +34,48 @@ st.set_page_config(page_title="Glucose dashboard — Dagoverzicht", page_icon="
|
|||||||
require_auth()
|
require_auth()
|
||||||
|
|
||||||
st.title("Dagoverzicht")
|
st.title("Dagoverzicht")
|
||||||
st.caption("Eén glucosegrafiek per kalenderdag, met basaal/bolus/koolhydraten-overlay (FR4).")
|
st.caption(
|
||||||
|
"Eén dag per keer: boven glucose met koolhydraten/bolus-overlay, onder basaal "
|
||||||
|
"en SMB — apart gehouden omdat één gecombineerde grafiek te veel informatie bevatte."
|
||||||
|
)
|
||||||
|
|
||||||
start, end, days = select_period_days(default_label="14 dagen")
|
# De periode-knop bepaalt alleen het navigeerbare venster (hoe ver terug je kunt
|
||||||
if days > 31:
|
# bladeren), niet welke data wordt opgehaald — dat gebeurt hieronder voor precies
|
||||||
st.info(
|
# de geselecteerde dag (load_day_data), zodat het wisselen tussen dagen snel blijft.
|
||||||
"Bij langere periodes wordt de pagina lang en kan laden traag zijn — "
|
window_start, window_end, days = select_period_days(default_label="14 dagen")
|
||||||
"voor het bekijken van individuele dagen is 14 of 30 dagen prettiger."
|
|
||||||
|
state_key = "dagoverzicht_selected_day"
|
||||||
|
if state_key not in st.session_state:
|
||||||
|
st.session_state[state_key] = window_end
|
||||||
|
if not (window_start <= st.session_state[state_key] <= window_end):
|
||||||
|
st.session_state[state_key] = window_end
|
||||||
|
|
||||||
|
selected_day = st.session_state[state_key]
|
||||||
|
|
||||||
|
col_prev, col_date, col_next = st.columns([1, 3, 1])
|
||||||
|
with col_prev:
|
||||||
|
if st.button("← Vorige dag", disabled=selected_day <= window_start, width="stretch"):
|
||||||
|
selected_day = selected_day - timedelta(days=1)
|
||||||
|
st.session_state[state_key] = selected_day
|
||||||
|
with col_next:
|
||||||
|
if st.button("Volgende dag →", disabled=selected_day >= window_end, width="stretch"):
|
||||||
|
selected_day = selected_day + timedelta(days=1)
|
||||||
|
st.session_state[state_key] = selected_day
|
||||||
|
with col_date:
|
||||||
|
date_options = [window_end - timedelta(days=i) for i in range(days)]
|
||||||
|
chosen = st.selectbox(
|
||||||
|
"Datum",
|
||||||
|
options=date_options,
|
||||||
|
index=date_options.index(selected_day),
|
||||||
|
format_func=lambda d: d.strftime("%A %d-%m-%Y"),
|
||||||
|
label_visibility="collapsed",
|
||||||
)
|
)
|
||||||
|
if chosen != selected_day:
|
||||||
|
selected_day = chosen
|
||||||
|
st.session_state[state_key] = selected_day
|
||||||
|
|
||||||
try:
|
try:
|
||||||
glucose, treatments = load_range_data(start, end)
|
glucose, treatments = load_day_data(selected_day)
|
||||||
profile = load_profile()
|
profile = load_profile()
|
||||||
except NightscoutConfigError as exc:
|
except NightscoutConfigError as exc:
|
||||||
st.error(str(exc))
|
st.error(str(exc))
|
||||||
@@ -45,132 +85,124 @@ except (requests.RequestException, ValueError) as exc:
|
|||||||
st.stop()
|
st.stop()
|
||||||
|
|
||||||
if glucose.empty:
|
if glucose.empty:
|
||||||
st.info("Geen glucosemetingen gevonden in deze periode.")
|
st.info("Geen glucosemetingen gevonden op deze dag.")
|
||||||
st.stop()
|
st.stop()
|
||||||
|
|
||||||
render_completeness_warning(glucose, start, end)
|
render_completeness_warning(glucose, selected_day, selected_day)
|
||||||
|
|
||||||
colors = get_colors()
|
colors = get_colors()
|
||||||
glucose = glucose.copy()
|
day_start = pd.Timestamp(selected_day)
|
||||||
glucose["date"] = glucose["timestamp"].dt.date
|
day_end = day_start + pd.Timedelta(1, unit="D")
|
||||||
glucose["hour"] = glucose["timestamp"].dt.hour + glucose["timestamp"].dt.minute / 60
|
|
||||||
|
|
||||||
carbs_events = classify_treatments(treatments)["carbs"].copy()
|
# treatments bevat een lookback-marge vóór middernacht (zie
|
||||||
if not carbs_events.empty:
|
# nightscout_client.fetch_treatments_for_day) zodat een temp basal die al
|
||||||
carbs_events["date"] = carbs_events["timestamp"].dt.date
|
# vóór deze dag begon toch meetelt — effective_basal_series() clipt dat
|
||||||
carbs_events["hour"] = carbs_events["timestamp"].dt.hour + carbs_events["timestamp"].dt.minute / 60
|
# zelf aan [day_start, day_end). Voor de koolhydraten/bolus-overlays willen
|
||||||
|
# we die marge juist NIET, anders duiken gisteravond se stippen op vandaag op.
|
||||||
|
day_treatments = treatments[(treatments["timestamp"] >= day_start) & (treatments["timestamp"] < day_end)]
|
||||||
|
|
||||||
bolus_events = classify_bolus(treatments)
|
carbs_day = classify_treatments(day_treatments)["carbs"]
|
||||||
if not bolus_events.empty:
|
bolus_day = classify_bolus(day_treatments)
|
||||||
bolus_events["date"] = bolus_events["timestamp"].dt.date
|
basal_day = effective_basal_series(profile, treatments, day_start, day_end)
|
||||||
bolus_events["hour"] = bolus_events["timestamp"].dt.hour + bolus_events["timestamp"].dt.minute / 60
|
target_day = target_glucose_series(profile, day_start, day_end)
|
||||||
|
|
||||||
|
|
||||||
def _close_step_to_day_end(df: pd.DataFrame, value_col: str) -> pd.DataFrame:
|
def _close_step_to_day_end(df: pd.DataFrame, value_col: str) -> pd.DataFrame:
|
||||||
"""Voeg een laatste punt op hour=24 toe (zelfde waarde als het laatste
|
"""Voeg een laatste punt op day_end toe (zelfde waarde als het laatste
|
||||||
segment) zodat een stapvlak/-lijn de volle dag doortekent i.p.v. te
|
segment) zodat een stapvlak/-lijn de volle dag doortekent i.p.v. te
|
||||||
stoppen bij het laatste breekpunt."""
|
stoppen bij het laatste breekpunt."""
|
||||||
if df.empty:
|
if df.empty:
|
||||||
return df
|
return df
|
||||||
last_value = df.iloc[-1][value_col]
|
last_value = df.iloc[-1][value_col]
|
||||||
closing_row = pd.DataFrame([{"hour": 24.0, value_col: last_value}])
|
closing_row = pd.DataFrame([{"timestamp": day_end, value_col: last_value}])
|
||||||
return pd.concat([df, closing_row], ignore_index=True)
|
return pd.concat([df, closing_row], ignore_index=True)
|
||||||
|
|
||||||
|
|
||||||
dates = sorted(glucose["date"].unique())
|
basal_day = _close_step_to_day_end(basal_day, "rate")
|
||||||
row_height = 190
|
target_day = _close_step_to_day_end(target_day, "target")
|
||||||
fig = make_subplots(
|
|
||||||
rows=len(dates),
|
|
||||||
cols=1,
|
|
||||||
shared_xaxes=True,
|
|
||||||
vertical_spacing=min(0.4 / max(len(dates) - 1, 1), 0.05),
|
|
||||||
specs=[[{"secondary_y": True}]] * len(dates),
|
|
||||||
)
|
|
||||||
|
|
||||||
for i, day in enumerate(dates, start=1):
|
|
||||||
day_df = glucose[glucose["date"] == day]
|
|
||||||
day_start = pd.Timestamp(day)
|
|
||||||
day_end = day_start + pd.Timedelta(1, unit="D")
|
|
||||||
|
|
||||||
|
def build_day_figure(glucose_day, carbs_day, bolus_day, basal_day, target_day, colors) -> go.Figure:
|
||||||
|
fig = make_subplots(
|
||||||
|
rows=2,
|
||||||
|
cols=1,
|
||||||
|
shared_xaxes=True,
|
||||||
|
row_heights=[0.68, 0.32],
|
||||||
|
vertical_spacing=0.08,
|
||||||
|
specs=[[{"secondary_y": True}], [{"secondary_y": False}]],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Rij 1: glucose + streefwaarde + koolhydraten + voeding-/correctiebolus.
|
||||||
fig.add_hrect(
|
fig.add_hrect(
|
||||||
y0=TARGET_LOW, y1=TARGET_HIGH, fillcolor=colors["band"], line_width=0, layer="below", row=i, col=1
|
y0=TARGET_LOW, y1=TARGET_HIGH, fillcolor=colors["band"], line_width=0, layer="below", row=1, col=1
|
||||||
)
|
|
||||||
# Handmatige titel-annotatie t.o.v. het domein van déze rij i.p.v.
|
|
||||||
# make_subplots' subplot_titles (die t.o.v. de hele figuur positioneert en
|
|
||||||
# daardoor bij veel dagen de gedeelde legenda bovenaan kan overlappen).
|
|
||||||
fig.add_annotation(
|
|
||||||
text=day.strftime("%A %d-%m-%Y"),
|
|
||||||
xref="x domain",
|
|
||||||
yref="y domain",
|
|
||||||
x=0.0,
|
|
||||||
y=1.15,
|
|
||||||
xanchor="left",
|
|
||||||
yanchor="bottom",
|
|
||||||
showarrow=False,
|
|
||||||
font={"size": 13, "color": colors["text"]},
|
|
||||||
row=i,
|
|
||||||
col=1,
|
|
||||||
)
|
)
|
||||||
|
add_target_line_trace(fig, target_day, colors, row=1)
|
||||||
|
add_carb_bars_trace(fig, carbs_day, colors, row=1, secondary_y=True)
|
||||||
|
|
||||||
basal_day = effective_basal_series(profile, treatments, day_start, day_end)
|
top_bolus = bolus_day[bolus_day["category"] != BOLUS_CATEGORY_SMB] if not bolus_day.empty else bolus_day
|
||||||
if not basal_day.empty:
|
lane_y = glucose_day["mmol"].min() - 1.0 if not glucose_day.empty else TARGET_LOW - 1.0
|
||||||
basal_day["hour"] = (basal_day["timestamp"] - day_start).dt.total_seconds() / 3600
|
add_bolus_marker_traces(fig, top_bolus, colors, lane_y, row=1)
|
||||||
basal_day = _close_step_to_day_end(basal_day, "rate")
|
|
||||||
add_basal_area_trace(fig, basal_day, colors, row=i, secondary_y=True, x_col="hour")
|
|
||||||
|
|
||||||
target_day = target_glucose_series(profile, day_start, day_end)
|
|
||||||
if not target_day.empty:
|
|
||||||
target_day["hour"] = (target_day["timestamp"] - day_start).dt.total_seconds() / 3600
|
|
||||||
target_day = _close_step_to_day_end(target_day, "target")
|
|
||||||
add_target_line_trace(fig, target_day, colors, row=i, x_col="hour")
|
|
||||||
|
|
||||||
add_carb_bars_trace(fig, carbs_events[carbs_events["date"] == day], colors, row=i, secondary_y=True, x_col="hour")
|
|
||||||
|
|
||||||
lane_y = day_df["mmol"].min() - 1.0 if not day_df.empty else TARGET_LOW - 1.0
|
|
||||||
add_bolus_marker_traces(fig, bolus_events[bolus_events["date"] == day], colors, lane_y, row=i, x_col="hour")
|
|
||||||
|
|
||||||
fig.add_trace(
|
fig.add_trace(
|
||||||
go.Scatter(
|
go.Scatter(
|
||||||
x=day_df["hour"],
|
x=glucose_day["timestamp"],
|
||||||
y=day_df["mmol"],
|
y=glucose_day["mmol"],
|
||||||
mode="lines",
|
mode="lines",
|
||||||
line={"color": colors["glucose"], "width": 2},
|
line={"color": colors["glucose"], "width": 2},
|
||||||
name="Glucose",
|
name="Glucose",
|
||||||
hovertemplate="%{y:.1f} mmol/L<extra></extra>",
|
hovertemplate=MMOL_HOVERTEMPLATE,
|
||||||
),
|
),
|
||||||
row=i,
|
row=1,
|
||||||
col=1,
|
col=1,
|
||||||
secondary_y=False,
|
|
||||||
)
|
)
|
||||||
fig.update_yaxes(title_text="mmol/L", gridcolor=colors["grid"], row=i, col=1, secondary_y=False)
|
add_threshold_lines(fig, colors, row=1)
|
||||||
|
fig.update_yaxes(title_text="mmol/L", gridcolor=colors["grid"], row=1, col=1, secondary_y=False)
|
||||||
|
fig.update_yaxes(title_text="Koolhydraten (g)", showgrid=False, row=1, col=1, secondary_y=True)
|
||||||
|
|
||||||
|
# Rij 2: basaal + SMB — apart van rij 1 zodat de glucosegrafiek niet
|
||||||
|
# overladen raakt met insulinedetails.
|
||||||
|
add_basal_area_trace(fig, basal_day, colors, row=2)
|
||||||
|
smb_bolus = bolus_day[bolus_day["category"] == BOLUS_CATEGORY_SMB] if not bolus_day.empty else bolus_day
|
||||||
|
smb_lane_y = (basal_day["rate"].max() * 1.15) if not basal_day.empty else 1.0
|
||||||
|
add_bolus_marker_traces(fig, smb_bolus, colors, smb_lane_y, row=2)
|
||||||
fig.update_yaxes(
|
fig.update_yaxes(
|
||||||
title_text="Basaal (E/uur) · KH (g)", showgrid=False, row=i, col=1, secondary_y=True
|
title_text="Basaal (E/uur)", gridcolor=colors["grid"], rangemode="tozero", row=2, col=1
|
||||||
)
|
)
|
||||||
add_threshold_lines(fig, colors, row=i)
|
|
||||||
|
|
||||||
# Elke dag herhaalt dezelfde spoornamen (Glucose/Basaal/Koolhydraten/...) — zonder
|
fig.update_xaxes(range=[day_start, day_end], dtick=4 * 3600 * 1000, gridcolor=colors["grid"])
|
||||||
# deduplicatie zou de legenda 7x "Glucose" etc. tonen. Alleen de eerste keer per
|
fig.update_layout(
|
||||||
# naam blijft in de legenda staan.
|
paper_bgcolor="rgba(0,0,0,0)",
|
||||||
seen_names = set()
|
plot_bgcolor="rgba(0,0,0,0)",
|
||||||
for trace in fig.data:
|
font_color=colors["text"],
|
||||||
if trace.name in seen_names:
|
height=560,
|
||||||
trace.showlegend = False
|
showlegend=True,
|
||||||
else:
|
legend={"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "left", "x": 0},
|
||||||
seen_names.add(trace.name)
|
margin={"l": 10, "r": 10, "t": 40, "b": 10},
|
||||||
|
)
|
||||||
|
return fig
|
||||||
|
|
||||||
fig.update_xaxes(title_text="Uur van de dag", range=[0, 24], dtick=4, gridcolor=colors["grid"])
|
|
||||||
fig.update_layout(
|
fig = build_day_figure(glucose, carbs_day, bolus_day, basal_day, target_day, colors)
|
||||||
paper_bgcolor="rgba(0,0,0,0)",
|
|
||||||
plot_bgcolor="rgba(0,0,0,0)",
|
|
||||||
font_color=colors["text"],
|
|
||||||
height=row_height * len(dates) + 40,
|
|
||||||
showlegend=True,
|
|
||||||
legend={"orientation": "h", "yanchor": "bottom", "y": 1.0, "xanchor": "left", "x": 0},
|
|
||||||
margin={"l": 10, "r": 10, "t": 40, "b": 10},
|
|
||||||
)
|
|
||||||
st.plotly_chart(fig, width="stretch")
|
st.plotly_chart(fig, width="stretch")
|
||||||
|
|
||||||
|
stats = daily_stats(glucose, day_treatments, profile, day_start, day_end)
|
||||||
|
col1, col2, col3, col4 = st.columns(4)
|
||||||
|
col1.metric(
|
||||||
|
"Glucose (gem./min/max)",
|
||||||
|
f"{stats['mean_mmol']:.1f} mmol/L",
|
||||||
|
delta=f"{stats['min_mmol']:.1f} - {stats['max_mmol']:.1f}",
|
||||||
|
delta_color="off",
|
||||||
|
)
|
||||||
|
col2.metric("Totale insuline", f"{stats['total_insulin']:.1f} E")
|
||||||
|
col3.metric("Basaal / bolus / SMB", f"{stats['basal_insulin']:.1f} / {stats['bolus_insulin']:.1f} / {stats['smb_insulin']:.1f} E")
|
||||||
|
col4.metric(
|
||||||
|
"Hypo's / hyper's",
|
||||||
|
f"{stats['hypo_count']} / {stats['hyper_count']}",
|
||||||
|
delta=f"{stats['hypo_duration_minutes']:.0f} / {stats['hyper_duration_minutes']:.0f} min",
|
||||||
|
delta_color="off",
|
||||||
|
)
|
||||||
|
|
||||||
st.caption(
|
st.caption(
|
||||||
f"{len(dates)} dagen · {len(glucose)} metingen · stippen = bolusinsuline "
|
"Boven: stippen = bolusinsuline (voeding/correctie, grootte naar dosis) · staafjes = koolhydraten · "
|
||||||
"(voeding/correctie/SMB, grootte naar dosis) · vlak = basaal · staafjes = koolhydraten · "
|
"streepstippellijn = streefwaarde uit pompprofiel. Onder: vlak = basaal · stippen = SMB."
|
||||||
"streepstippellijn = streefwaarde uit pompprofiel"
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import streamlit as st
|
|||||||
from auth import require_auth
|
from auth import require_auth
|
||||||
from nightscout_client import NightscoutConfigError
|
from nightscout_client import NightscoutConfigError
|
||||||
from profile_data import segments_table, segments_to_hourly_bars, total_daily_basal
|
from profile_data import segments_table, segments_to_hourly_bars, total_daily_basal
|
||||||
from ui_common import get_colors, load_profile
|
from ui_common import get_colors, hex_to_rgba, load_profile
|
||||||
|
|
||||||
st.set_page_config(page_title="Glucose dashboard — Insulineprofiel", page_icon="📊", layout="wide")
|
st.set_page_config(page_title="Glucose dashboard — Insulineprofiel", page_icon="📊", layout="wide")
|
||||||
require_auth()
|
require_auth()
|
||||||
@@ -42,8 +42,8 @@ fig = go.Figure(
|
|||||||
mode="lines",
|
mode="lines",
|
||||||
line={"shape": "hv", "color": colors["basal"], "width": 2},
|
line={"shape": "hv", "color": colors["basal"], "width": 2},
|
||||||
fill="tozeroy",
|
fill="tozeroy",
|
||||||
fillcolor=colors["basal"],
|
fillcolor=hex_to_rgba(colors["basal"], 0.3),
|
||||||
opacity=0.7,
|
opacity=0.8,
|
||||||
name="Basaal",
|
name="Basaal",
|
||||||
hovertemplate="%{x:.0f}:00 · %{y:.2f} E/uur<extra></extra>",
|
hovertemplate="%{x:.0f}:00 · %{y:.2f} E/uur<extra></extra>",
|
||||||
)
|
)
|
||||||
|
|||||||
+36
-16
@@ -230,7 +230,7 @@ def _insulin_profile_figure_png(profile: dict) -> bytes:
|
|||||||
if bars:
|
if bars:
|
||||||
hours = [b["hour"] for b in bars]
|
hours = [b["hour"] for b in bars]
|
||||||
values = [b["value"] for b in bars]
|
values = [b["value"] for b in bars]
|
||||||
ax.step(hours, values, where="post", color=_BASAL_COLOR, linewidth=1.5)
|
ax.step(hours, values, where="post", color=_BASAL_COLOR, linewidth=1.5, alpha=0.8)
|
||||||
ax.fill_between(hours, values, step="post", color=_BASAL_COLOR, alpha=0.3)
|
ax.fill_between(hours, values, step="post", color=_BASAL_COLOR, alpha=0.3)
|
||||||
ax.set_xlim(0, 24)
|
ax.set_xlim(0, 24)
|
||||||
ax.set_xticks(range(0, 25, 2))
|
ax.set_xticks(range(0, 25, 2))
|
||||||
@@ -261,9 +261,8 @@ def _add_carbs_overlay(ax2, day_treatments: pd.DataFrame, day_start) -> None:
|
|||||||
ax2.bar(hours, carbs["carbs"], width=0.25, color=_CARBS_COLOR, alpha=0.55, label="Koolhydraten")
|
ax2.bar(hours, carbs["carbs"], width=0.25, color=_CARBS_COLOR, alpha=0.55, label="Koolhydraten")
|
||||||
|
|
||||||
|
|
||||||
def _add_bolus_overlay(ax, day_treatments: pd.DataFrame, day_start, lane_y: float) -> None:
|
def _add_bolus_overlay(ax, bolus: pd.DataFrame, day_start, lane_y: float, categories: tuple[str, ...]) -> None:
|
||||||
bolus = classify_bolus(day_treatments)
|
for category in categories:
|
||||||
for category in (BOLUS_CATEGORY_SMB, BOLUS_CATEGORY_CORRECTION, BOLUS_CATEGORY_FOOD):
|
|
||||||
subset = bolus[bolus["category"] == category] if not bolus.empty else bolus
|
subset = bolus[bolus["category"] == category] if not bolus.empty else bolus
|
||||||
if subset.empty:
|
if subset.empty:
|
||||||
continue
|
continue
|
||||||
@@ -291,35 +290,56 @@ def _add_target_overlay(ax, profile: dict | None, day_start, day_end) -> None:
|
|||||||
def _daily_figure_png(
|
def _daily_figure_png(
|
||||||
day_df: pd.DataFrame, day_treatments: pd.DataFrame, profile: dict | None, day_label: str, day_start, day_end
|
day_df: pd.DataFrame, day_treatments: pd.DataFrame, profile: dict | None, day_label: str, day_start, day_end
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""Eén dag als grafiek: glucoselijn + basaal/bolus/koolhydraten/streefwaarde-overlay."""
|
"""Eén dag als twee gestapelde grafieken (gedeelde tijdas, één gedeelde legenda):
|
||||||
|
boven glucose + streefwaarde + koolhydraten + voeding/correctiebolus, onder
|
||||||
|
basaal + SMB — gescheiden omdat één gecombineerde grafiek te veel informatie
|
||||||
|
bevatte om nog leesbaar te zijn."""
|
||||||
hours = day_df["timestamp"].dt.hour + day_df["timestamp"].dt.minute / 60
|
hours = day_df["timestamp"].dt.hour + day_df["timestamp"].dt.minute / 60
|
||||||
fig, ax = plt.subplots(figsize=(11, 3.8))
|
fig, (ax, ax_basal) = plt.subplots(2, 1, figsize=(11, 5.2), sharex=True, height_ratios=[2.2, 1])
|
||||||
ax2 = ax.twinx()
|
ax2 = ax.twinx()
|
||||||
|
|
||||||
basal = effective_basal_series(profile, day_treatments, day_start, day_end)
|
bolus = classify_bolus(day_treatments)
|
||||||
_add_basal_overlay(ax2, basal, day_start)
|
|
||||||
_add_carbs_overlay(ax2, day_treatments, day_start)
|
_add_carbs_overlay(ax2, day_treatments, day_start)
|
||||||
|
|
||||||
lane_y = (day_df["mmol"].min() - 1.0) if not day_df.empty else LOW - 1.0
|
lane_y = (day_df["mmol"].min() - 1.0) if not day_df.empty else LOW - 1.0
|
||||||
_add_bolus_overlay(ax, day_treatments, day_start, lane_y)
|
_add_bolus_overlay(ax, bolus, day_start, lane_y, categories=(BOLUS_CATEGORY_CORRECTION, BOLUS_CATEGORY_FOOD))
|
||||||
_add_target_overlay(ax, profile, day_start, day_end)
|
_add_target_overlay(ax, profile, day_start, day_end)
|
||||||
|
|
||||||
ax.plot(hours, day_df["mmol"], color="#2a78d6", linewidth=1.4, label="Glucose", zorder=6)
|
ax.plot(hours, day_df["mmol"], color="#2a78d6", linewidth=1.4, label="Glucose", zorder=6)
|
||||||
_add_threshold_lines(ax)
|
_add_threshold_lines(ax)
|
||||||
ax.set_xlim(0, 24)
|
|
||||||
ax.set_xticks(range(0, 25, 2))
|
|
||||||
ax.set_ylabel("mmol/L", fontsize=9)
|
ax.set_ylabel("mmol/L", fontsize=9)
|
||||||
ax2.set_ylabel("Basaal (E/uur) · KH (g)", fontsize=9)
|
ax2.set_ylabel("Koolhydraten (g)", fontsize=9)
|
||||||
ax.set_title(day_label, fontsize=11, loc="left")
|
ax.set_title(day_label, fontsize=11, loc="left")
|
||||||
ax.tick_params(labelsize=8)
|
ax.tick_params(labelsize=8)
|
||||||
ax2.tick_params(labelsize=8)
|
ax2.tick_params(labelsize=8)
|
||||||
|
|
||||||
|
basal = effective_basal_series(profile, day_treatments, day_start, day_end)
|
||||||
|
_add_basal_overlay(ax_basal, basal, day_start)
|
||||||
|
smb_lane_y = (basal["rate"].max() * 1.15) if not basal.empty else 1.0
|
||||||
|
_add_bolus_overlay(ax_basal, bolus, day_start, smb_lane_y, categories=(BOLUS_CATEGORY_SMB,))
|
||||||
|
ax_basal.set_xlim(0, 24)
|
||||||
|
ax_basal.set_xticks(range(0, 25, 2))
|
||||||
|
ax_basal.set_xlabel(_HOUR_OF_DAY_LABEL)
|
||||||
|
ax_basal.set_ylabel("Basaal (E/uur)", fontsize=9)
|
||||||
|
ax_basal.tick_params(labelsize=8)
|
||||||
|
|
||||||
lines1, labels1 = ax.get_legend_handles_labels()
|
lines1, labels1 = ax.get_legend_handles_labels()
|
||||||
lines2, labels2 = ax2.get_legend_handles_labels()
|
lines2, labels2 = ax2.get_legend_handles_labels()
|
||||||
ax.legend(
|
lines3, labels3 = ax_basal.get_legend_handles_labels()
|
||||||
lines1 + lines2, labels1 + labels2, loc="upper center", bbox_to_anchor=(0.5, -0.2), ncol=6, fontsize=7, frameon=False
|
ax_basal.legend(
|
||||||
|
lines1 + lines2 + lines3,
|
||||||
|
labels1 + labels2 + labels3,
|
||||||
|
loc="upper center",
|
||||||
|
bbox_to_anchor=(0.5, -0.35),
|
||||||
|
ncol=6,
|
||||||
|
fontsize=7,
|
||||||
|
frameon=False,
|
||||||
)
|
)
|
||||||
fig.tight_layout()
|
# fig.tight_layout() introspecteert elke as se bounding box om marges te
|
||||||
|
# berekenen, en matplotlib ondersteunt dat niet betrouwbaar zodra er twee
|
||||||
|
# hoofd-assen (ax/ax_basal) plus een twinx (ax2) tegelijk in de figuur
|
||||||
|
# zitten (waarschuwing "Axes ... not compatible with tight_layout").
|
||||||
|
# subplots_adjust() met vaste marges omzeilt die introspectie.
|
||||||
|
fig.subplots_adjust(left=0.06, right=0.94, top=0.93, bottom=0.24, hspace=0.35)
|
||||||
return _fig_to_png(fig)
|
return _fig_to_png(fig)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import plotly.graph_objects as go
|
import plotly.graph_objects as go
|
||||||
|
import streamlit as st
|
||||||
|
|
||||||
from ui_common import (
|
from ui_common import (
|
||||||
PALETTE,
|
PALETTE,
|
||||||
@@ -10,10 +13,26 @@ from ui_common import (
|
|||||||
add_target_line_trace,
|
add_target_line_trace,
|
||||||
add_threshold_lines,
|
add_threshold_lines,
|
||||||
build_tir_stacked_bar_figure,
|
build_tir_stacked_bar_figure,
|
||||||
|
get_colors,
|
||||||
hex_to_rgba,
|
hex_to_rgba,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_colors_returns_dark_palette_when_theme_is_dark(monkeypatch):
|
||||||
|
monkeypatch.setattr(st, "context", SimpleNamespace(theme=SimpleNamespace(type="dark")))
|
||||||
|
assert get_colors() == PALETTE["dark"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_colors_returns_light_palette_when_theme_is_light(monkeypatch):
|
||||||
|
monkeypatch.setattr(st, "context", SimpleNamespace(theme=SimpleNamespace(type="light")))
|
||||||
|
assert get_colors() == PALETTE["light"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_colors_falls_back_to_light_when_theme_context_unavailable(monkeypatch):
|
||||||
|
monkeypatch.setattr(st, "context", SimpleNamespace()) # geen .theme-attribuut
|
||||||
|
assert get_colors() == PALETTE["light"]
|
||||||
|
|
||||||
|
|
||||||
def test_hex_to_rgba_converts_correctly():
|
def test_hex_to_rgba_converts_correctly():
|
||||||
assert hex_to_rgba("#2a78d6", 0.3) == "rgba(42,120,214,0.3)"
|
assert hex_to_rgba("#2a78d6", 0.3) == "rgba(42,120,214,0.3)"
|
||||||
|
|
||||||
@@ -55,12 +74,34 @@ def test_add_basal_area_trace_adds_one_filled_step_trace():
|
|||||||
assert fig.data[0].line.shape == "hv"
|
assert fig.data[0].line.shape == "hv"
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_basal_area_trace_supports_secondary_y_on_a_subplot_figure():
|
||||||
|
from plotly.subplots import make_subplots
|
||||||
|
|
||||||
|
basal = pd.DataFrame(
|
||||||
|
{"timestamp": pd.to_datetime(["2026-01-01 00:00", "2026-01-01 06:00"]), "rate": [0.8, 1.2]}
|
||||||
|
)
|
||||||
|
fig = make_subplots(specs=[[{"secondary_y": True}]])
|
||||||
|
add_basal_area_trace(fig, basal, PALETTE["light"], row=1, secondary_y=True)
|
||||||
|
assert len(fig.data) == 1
|
||||||
|
assert fig.data[0].yaxis == "y2"
|
||||||
|
|
||||||
|
|
||||||
def test_add_basal_area_trace_skips_empty_input():
|
def test_add_basal_area_trace_skips_empty_input():
|
||||||
fig = go.Figure()
|
fig = go.Figure()
|
||||||
add_basal_area_trace(fig, pd.DataFrame(columns=["timestamp", "rate"]), PALETTE["light"])
|
add_basal_area_trace(fig, pd.DataFrame(columns=["timestamp", "rate"]), PALETTE["light"])
|
||||||
assert len(fig.data) == 0
|
assert len(fig.data) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_carb_bars_trace_supports_secondary_y_on_a_subplot_figure():
|
||||||
|
from plotly.subplots import make_subplots
|
||||||
|
|
||||||
|
carbs = pd.DataFrame({"timestamp": pd.to_datetime(["2026-01-01 08:00"]), "carbs": [40.0]})
|
||||||
|
fig = make_subplots(specs=[[{"secondary_y": True}]])
|
||||||
|
add_carb_bars_trace(fig, carbs, PALETTE["light"], row=1, secondary_y=True)
|
||||||
|
assert len(fig.data) == 1
|
||||||
|
assert fig.data[0].yaxis == "y2"
|
||||||
|
|
||||||
|
|
||||||
def test_add_carb_bars_trace_adds_one_bar_trace():
|
def test_add_carb_bars_trace_adds_one_bar_trace():
|
||||||
carbs = pd.DataFrame({"timestamp": pd.to_datetime(["2026-01-01 08:00"]), "carbs": [40.0]})
|
carbs = pd.DataFrame({"timestamp": pd.to_datetime(["2026-01-01 08:00"]), "carbs": [40.0]})
|
||||||
fig = go.Figure()
|
fig = go.Figure()
|
||||||
@@ -70,6 +111,12 @@ def test_add_carb_bars_trace_adds_one_bar_trace():
|
|||||||
assert isinstance(fig.data[0], go.Bar)
|
assert isinstance(fig.data[0], go.Bar)
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_carb_bars_trace_skips_empty_input():
|
||||||
|
fig = go.Figure()
|
||||||
|
add_carb_bars_trace(fig, pd.DataFrame(columns=["timestamp", "carbs"]), PALETTE["light"])
|
||||||
|
assert len(fig.data) == 0
|
||||||
|
|
||||||
|
|
||||||
def test_add_bolus_marker_traces_adds_one_trace_per_category_present():
|
def test_add_bolus_marker_traces_adds_one_trace_per_category_present():
|
||||||
bolus = pd.DataFrame(
|
bolus = pd.DataFrame(
|
||||||
{
|
{
|
||||||
|
|||||||
+53
-11
@@ -14,7 +14,13 @@ import pandas as pd
|
|||||||
import plotly.graph_objects as go
|
import plotly.graph_objects as go
|
||||||
import streamlit as st
|
import streamlit as st
|
||||||
|
|
||||||
from nightscout_client import fetch_entries_range, fetch_profile, fetch_treatments_range
|
from nightscout_client import (
|
||||||
|
fetch_entries_for_day,
|
||||||
|
fetch_entries_range,
|
||||||
|
fetch_profile,
|
||||||
|
fetch_treatments_for_day,
|
||||||
|
fetch_treatments_range,
|
||||||
|
)
|
||||||
from profile_data import parse_profile
|
from profile_data import parse_profile
|
||||||
from reporting import (
|
from reporting import (
|
||||||
HIGH,
|
HIGH,
|
||||||
@@ -118,8 +124,12 @@ def get_colors() -> dict:
|
|||||||
return PALETTE["dark" if theme_type == "dark" else "light"]
|
return PALETTE["dark" if theme_type == "dark" else "light"]
|
||||||
|
|
||||||
|
|
||||||
def select_period_days(default_label: str) -> tuple[date, date, int]:
|
def select_period_days(default_label: str) -> tuple[date, date, int]: # pragma: no cover
|
||||||
"""Periodekiezer voor rapportagepagina's; geeft (start, eind, aantal_dagen) terug."""
|
"""Periodekiezer voor rapportagepagina's; geeft (start, eind, aantal_dagen) terug.
|
||||||
|
|
||||||
|
Pure Streamlit-widget-orkestratie (st.radio) — buiten scope van de
|
||||||
|
unit-testdekking net als pages/*.py, zie pyproject.toml se coverage-omit-lijst.
|
||||||
|
"""
|
||||||
labels = list(PERIOD_OPTIONS_DAYS.keys())
|
labels = list(PERIOD_OPTIONS_DAYS.keys())
|
||||||
label = st.radio("Periode", labels, index=labels.index(default_label), horizontal=True)
|
label = st.radio("Periode", labels, index=labels.index(default_label), horizontal=True)
|
||||||
days = PERIOD_OPTIONS_DAYS[label]
|
days = PERIOD_OPTIONS_DAYS[label]
|
||||||
@@ -129,22 +139,49 @@ def select_period_days(default_label: str) -> tuple[date, date, int]:
|
|||||||
|
|
||||||
|
|
||||||
@st.cache_data(ttl=3600, show_spinner="Data ophalen van Nightscout...")
|
@st.cache_data(ttl=3600, show_spinner="Data ophalen van Nightscout...")
|
||||||
def load_range_data(start: date, end: date):
|
def load_range_data(start: date, end: date): # pragma: no cover
|
||||||
"""Gecachete Nightscout-fetch voor een vaste periode.
|
"""Gecachete Nightscout-fetch voor een vaste periode.
|
||||||
|
|
||||||
Lange TTL (in tegenstelling tot de 60s van de live-pagina): historische
|
Lange TTL (in tegenstelling tot de 60s van de live-pagina): historische
|
||||||
data van een afgeronde periode verandert niet.
|
data van een afgeronde periode verandert niet.
|
||||||
|
|
||||||
|
Dunne st.cache_data-wrapper rond al-geteste fetch_entries_range()/
|
||||||
|
fetch_treatments_range() — buiten scope van de unit-testdekking net als
|
||||||
|
pages/*.py, zie pyproject.toml se coverage-omit-lijst.
|
||||||
"""
|
"""
|
||||||
glucose = fetch_entries_range(start, end)
|
glucose = fetch_entries_range(start, end)
|
||||||
treatments = fetch_treatments_range(start, end)
|
treatments = fetch_treatments_range(start, end)
|
||||||
return glucose, treatments
|
return glucose, treatments
|
||||||
|
|
||||||
|
|
||||||
|
@st.cache_data(ttl=300, show_spinner="Data ophalen van Nightscout...")
|
||||||
|
def load_day_data(day: date): # pragma: no cover
|
||||||
|
"""Gecachete Nightscout-fetch voor precies één kalenderdag (Dagoverzicht-navigatie).
|
||||||
|
|
||||||
|
Kortere TTL dan load_range_data (5 min i.p.v. 1 uur): in tegenstelling tot
|
||||||
|
een afgeronde periode kan de geselecteerde dag "vandaag" zijn, waar
|
||||||
|
nieuwe metingen blijven binnenkomen. fetch_treatments_for_day() haalt
|
||||||
|
zelf een lookback-marge vóór middernacht op zodat effective_basal_series()
|
||||||
|
de vroege ochtenduren correct kan berekenen.
|
||||||
|
|
||||||
|
Dunne st.cache_data-wrapper rond al-geteste fetch_entries_for_day()/
|
||||||
|
fetch_treatments_for_day() — buiten scope van de unit-testdekking net als
|
||||||
|
pages/*.py, zie pyproject.toml se coverage-omit-lijst.
|
||||||
|
"""
|
||||||
|
glucose = fetch_entries_for_day(day)
|
||||||
|
treatments = fetch_treatments_for_day(day)
|
||||||
|
return glucose, treatments
|
||||||
|
|
||||||
|
|
||||||
@st.cache_data(ttl=21600, show_spinner="Profiel ophalen van Nightscout...")
|
@st.cache_data(ttl=21600, show_spinner="Profiel ophalen van Nightscout...")
|
||||||
def load_profile() -> dict:
|
def load_profile() -> dict: # pragma: no cover
|
||||||
"""Gecachete profiel-fetch. Langere TTL dan load_range_data (6 uur i.p.v.
|
"""Gecachete profiel-fetch. Langere TTL dan load_range_data (6 uur i.p.v.
|
||||||
1 uur): een pompprofiel wijzigt zelden, in tegenstelling tot glucose-/
|
1 uur): een pompprofiel wijzigt zelden, in tegenstelling tot glucose-/
|
||||||
behandeldata van een lopende periode."""
|
behandeldata van een lopende periode.
|
||||||
|
|
||||||
|
Dunne st.cache_data-wrapper rond al-geteste fetch_profile()/parse_profile()
|
||||||
|
— buiten scope van de unit-testdekking net als pages/*.py.
|
||||||
|
"""
|
||||||
raw = fetch_profile()
|
raw = fetch_profile()
|
||||||
return parse_profile(raw)
|
return parse_profile(raw)
|
||||||
|
|
||||||
@@ -363,19 +400,21 @@ def add_target_line_trace(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def render_completeness_warning(glucose: pd.DataFrame, start: date, end: date) -> None:
|
def render_completeness_warning(glucose: pd.DataFrame, start: date, end: date) -> None: # pragma: no cover
|
||||||
"""Toon reporting.completeness_warning()'s oordeel als st.warning, indien nodig.
|
"""Toon reporting.completeness_warning()'s oordeel als st.warning, indien nodig.
|
||||||
|
|
||||||
Gebruikt door elke rapportagepagina, direct na de load_range_data-call.
|
Gebruikt door elke rapportagepagina, direct na de load_range_data-call.
|
||||||
De beoordelingslogica zelf staat in reporting.py (pure functie) zodat het
|
De beoordelingslogica zelf staat in reporting.py (pure functie, wél volledig
|
||||||
PDF-rapport (pdf_export.py) exact dezelfde disclaimer kan hergebruiken.
|
getest) zodat het PDF-rapport (pdf_export.py) exact dezelfde disclaimer kan
|
||||||
|
hergebruiken. Deze functie is alleen de st.warning-weergave — buiten scope
|
||||||
|
van de unit-testdekking net als pages/*.py.
|
||||||
"""
|
"""
|
||||||
message = completeness_warning(glucose, start, end)
|
message = completeness_warning(glucose, start, end)
|
||||||
if message:
|
if message:
|
||||||
st.warning(message)
|
st.warning(message)
|
||||||
|
|
||||||
|
|
||||||
def render_event_level(
|
def render_event_level( # pragma: no cover
|
||||||
glucose: pd.DataFrame,
|
glucose: pd.DataFrame,
|
||||||
threshold: float,
|
threshold: float,
|
||||||
direction: str,
|
direction: str,
|
||||||
@@ -386,7 +425,10 @@ def render_event_level(
|
|||||||
"""Render de metrics/tabel/tijdstip-histogram voor één hypo-/hyperniveau.
|
"""Render de metrics/tabel/tijdstip-histogram voor één hypo-/hyperniveau.
|
||||||
|
|
||||||
Gedeeld tussen de Hypo Events- en Hyper Events-pagina's (FR5/FR6), die
|
Gedeeld tussen de Hypo Events- en Hyper Events-pagina's (FR5/FR6), die
|
||||||
elk twee niveaus (Level 1/Level 2) op dezelfde manier tonen.
|
elk twee niveaus (Level 1/Level 2) op dezelfde manier tonen. Pure
|
||||||
|
Streamlit-widget-weergave rond al-geteste reporting.detect_events()/
|
||||||
|
summarize_events() — buiten scope van de unit-testdekking net als
|
||||||
|
pages/*.py.
|
||||||
"""
|
"""
|
||||||
events = detect_events(glucose, threshold, direction)
|
events = detect_events(glucose, threshold, direction)
|
||||||
summary = summarize_events(events, period_days)
|
summary = summarize_events(events, period_days)
|
||||||
|
|||||||
Reference in New Issue
Block a user