Files
glucose-dashboard/ui_common.py
willemjan 7f4377329a
willemjan/glucose-dashboard/pipeline/head This commit looks good
Add CI test-coverage gate at 80%, backfill remaining logic-module gaps
pytest-cov + pyproject.toml [tool.coverage.*] measure reporting.py,
nightscout_client.py, data_processing.py, pdf_export.py, profile_data.py,
ui_common.py, and auth.py; app.py and pages/*.py are omitted (thin
Streamlit UI scripts, not meaningfully unit-testable without a running
Streamlit session — the handful of similarly UI-coupled functions left
in ui_common.py/auth.py are marked "# pragma: no cover" with the same
rationale, since the logic they call into is already fully tested).

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

Builds on #29/#30/#31.
2026-09-05 22:52:29 +02:00

444 lines
16 KiB
Python

"""
Gedeelde UI-hulpmiddelen voor app.py en alle pages/*.py.
Elke Streamlit-pagina draait als los script, dus gedeelde constanten/functies
(kleurenpalet, thema-detectie) horen hier thuis in plaats van gedupliceerd te
worden per pagina.
"""
from __future__ import annotations
from datetime import date, datetime, timedelta, timezone
import pandas as pd
import plotly.graph_objects as go
import streamlit as st
from nightscout_client import fetch_entries_range, fetch_profile, fetch_treatments_range
from profile_data import parse_profile
from reporting import (
HIGH,
LOW,
VERY_HIGH,
VERY_LOW,
completeness_warning,
detect_events,
summarize_events,
)
MMOL_HOVERTEMPLATE = "%{x|%d-%m %H:%M} · %{y:.1f} mmol/L<extra></extra>"
# Periode-opties voor de rapportagepagina's (FR1): elke pagina kiest zijn eigen
# standaard (90 dagen voor statistiek/TIR, 14 dagen voor recente
# glucosegrafieken) via `default_label`.
PERIOD_OPTIONS_DAYS = {"14 dagen": 14, "30 dagen": 30, "90 dagen": 90}
# Kleuren per rol, licht/donker — categorale kleuren voor identiteit
# (glucose/insuline/carbs), status-kleuren (vast, nooit hergebruikt) voor
# te laag/te hoog.
PALETTE = {
"light": {
"glucose": "#2a78d6",
"insulin": "#1baf7a",
"carbs": "#eb6834",
"low": "#d03b3b",
"very_low": "#d03b3b",
"high": "#fab219",
"very_high": "#fab219",
"band": "rgba(12,163,12,0.08)",
"grid": "#e1e0d9",
"muted": "#898781",
"text": "#52514e",
"good": "#0ca30c",
# Losstaand van "low"/"high" hierboven (die blijven gedeeld tussen
# andere grafieken): de 5-bands TIR-grafiek (gitea #14) heeft juist
# zichtbaar onderscheid tussen laag/zeer-laag en hoog/zeer-hoog nodig.
"tir_very_low": "#8b1a1a",
"tir_low": "#d03b3b",
"tir_in_range": "#0ca30c",
"tir_high": "#fab219",
"tir_very_high": "#c76f0a",
"basal": "#7b5ea3",
"bolus_food": "#1baf7a",
"bolus_correction": "#2a78d6",
"bolus_smb": "#898781",
"target": "#52514e",
},
"dark": {
"glucose": "#3987e5",
"insulin": "#199e70",
"carbs": "#d95926",
"low": "#d03b3b",
"very_low": "#d03b3b",
"high": "#fab219",
"very_high": "#fab219",
"band": "rgba(12,163,12,0.15)",
"grid": "#2c2c2a",
"muted": "#898781",
"text": "#c3c2b7",
"good": "#0ca30c",
"tir_very_low": "#a32626",
"tir_low": "#d95d5d",
"tir_in_range": "#0ca30c",
"tir_high": "#fab219",
"tir_very_high": "#d9852e",
"basal": "#9b7ec2",
"bolus_food": "#199e70",
"bolus_correction": "#3987e5",
"bolus_smb": "#898781",
"target": "#c3c2b7",
},
}
# Klinische drempellijnen (Battelino et al. 2019), gedeeld tussen de AGP- en
# Dagoverzicht-pagina's (FR3, FR4 — gitea #15) zodat beide grafieken exact
# dezelfde 4 lijnen op dezelfde manier tonen.
THRESHOLD_LINES = [
(VERY_LOW, "3.0"),
(LOW, "3.9"),
(HIGH, "10.0"),
(VERY_HIGH, "13.9"),
]
def hex_to_rgba(hex_color: str, alpha: float) -> str:
"""Zet een '#rrggbb'-kleur om naar een 'rgba(r,g,b,a)'-string voor Plotly-fills."""
hex_color = hex_color.lstrip("#")
r, g, b = (int(hex_color[i : i + 2], 16) for i in (0, 2, 4))
return f"rgba({r},{g},{b},{alpha})"
def get_colors() -> dict:
"""Geef de kleurenset terug die past bij het actieve Streamlit-thema."""
theme_type = "light"
try:
theme_type = st.context.theme.type or "light"
except AttributeError:
pass
return PALETTE["dark" if theme_type == "dark" else "light"]
def select_period_days(default_label: str) -> tuple[date, date, int]: # pragma: no cover
"""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())
label = st.radio("Periode", labels, index=labels.index(default_label), horizontal=True)
days = PERIOD_OPTIONS_DAYS[label]
end = datetime.now(tz=timezone.utc).date()
start = end - timedelta(days=days - 1)
return start, end, days
@st.cache_data(ttl=3600, show_spinner="Data ophalen van Nightscout...")
def load_range_data(start: date, end: date): # pragma: no cover
"""Gecachete Nightscout-fetch voor een vaste periode.
Lange TTL (in tegenstelling tot de 60s van de live-pagina): historische
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)
treatments = fetch_treatments_range(start, end)
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:
"""Teken de 4 klinische drempellijnen (3.0/3.9/10.0/13.9 mmol/L) op een grafiek.
Gedeeld door de AGP- en Dagoverzicht-pagina's (gitea #15). `row` wordt
doorgegeven aan `add_hline` voor subplot-figuren (Dagoverzicht); voor een
los-staande figuur (AGP) blijft dit `None`.
"""
kwargs = {} if row is None else {"row": row, "col": 1}
for value, label in THRESHOLD_LINES:
fig.add_hline(
y=value,
line={"color": colors["muted"], "width": 1, "dash": "dot"},
annotation_text=label,
annotation_position="right",
annotation_font={"size": 9, "color": colors["muted"]},
**kwargs,
)
TIR_BAND_ORDER = ["very_low", "low", "in_range", "high", "very_high"]
TIR_BAND_LABELS = {
"very_low": "Zeer laag",
"low": "Laag",
"in_range": "In bereik",
"high": "Hoog",
"very_high": "Zeer hoog",
}
def build_tir_stacked_bar_figure(tir: dict, colors: dict) -> go.Figure:
"""Bouw de 5-bands tijd-in-bereik als één gestapelde staafgrafiek (gitea #14).
Eén staaf, geen x-as-label, percentage op de y-as; van onder naar boven
"Zeer laag" -> "Zeer hoog" — dezelfde volgorde als de klinische banden.
"""
fig = go.Figure()
for band in TIR_BAND_ORDER:
fig.add_trace(
go.Bar(
x=[""],
y=[tir[band]],
name=TIR_BAND_LABELS[band],
marker_color=colors[f"tir_{band}"],
hovertemplate=f"{TIR_BAND_LABELS[band]}: %{{y:.0f}}%<extra></extra>",
)
)
fig.update_layout(
barmode="stack",
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font_color=colors["text"],
height=140,
margin={"l": 10, "r": 10, "t": 10, "b": 10},
yaxis={"title": "%", "range": [0, 100], "gridcolor": colors["grid"]},
xaxis={"visible": False},
legend={"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "left", "x": 0},
)
return fig
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.
Gebruikt door elke rapportagepagina, direct na de load_range_data-call.
De beoordelingslogica zelf staat in reporting.py (pure functie, wél volledig
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)
if message:
st.warning(message)
def render_event_level( # pragma: no cover
glucose: pd.DataFrame,
threshold: float,
direction: str,
level_name: str,
period_days: int,
colors: dict,
) -> None:
"""Render de metrics/tabel/tijdstip-histogram voor één hypo-/hyperniveau.
Gedeeld tussen de Hypo Events- en Hyper Events-pagina's (FR5/FR6), die
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)
summary = summarize_events(events, period_days)
st.subheader(level_name)
cols = st.columns(4)
cols[0].metric("Aantal events", summary["count"])
cols[1].metric("Gem. duur", f"{summary['avg_duration_minutes']:.0f} min")
cols[2].metric("Langste event", f"{summary['longest_duration_minutes']:.0f} min")
cols[3].metric("Events/week", f"{summary['events_per_week']:.1f}")
if events.empty:
st.info("Geen events gevonden in deze periode.")
return
bar_color = colors["low"] if direction == "below" else colors["high"]
hours = events["start"].dt.hour
hour_counts = hours.value_counts().reindex(range(24), fill_value=0).sort_index()
fig = go.Figure(go.Bar(x=list(hour_counts.index), y=hour_counts.to_numpy(), marker_color=bar_color))
fig.update_layout(
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font_color=colors["text"],
height=220,
margin={"l": 10, "r": 10, "t": 10, "b": 10},
xaxis={"title": "Uur van de dag", "dtick": 2, "gridcolor": colors["grid"]},
yaxis={"title": "Aantal events", "gridcolor": colors["grid"]},
)
st.plotly_chart(fig, width="stretch")
display = events.copy()
display["start"] = display["start"].dt.strftime("%d-%m-%Y %H:%M")
display["end"] = display["end"].dt.strftime("%d-%m-%Y %H:%M")
display = display.rename(
columns={"start": "Start", "end": "Einde", "duration_minutes": "Duur (min)"}
)
st.dataframe(display, width="stretch", hide_index=True)