From 00fb81b28310e1f998fc2522d4e0b24b8752b1f6 Mon Sep 17 00:00:00 2001 From: willemjan Date: Sat, 1 Aug 2026 22:26:26 +0200 Subject: [PATCH] Add clinical threshold lines to AGP/Dagoverzicht charts, TIR stacked bar to Samenvatting Closes gitea #14, #15. --- pages/2_Samenvatting.py | 10 ++++- pages/3_AGP_Profiel.py | 2 + pages/4_Dagoverzicht.py | 2 + tests/test_ui_common.py | 31 +++++++++++++- ui_common.py | 93 ++++++++++++++++++++++++++++++++++++++++- 5 files changed, 135 insertions(+), 3 deletions(-) diff --git a/pages/2_Samenvatting.py b/pages/2_Samenvatting.py index 80cd119..337688f 100644 --- a/pages/2_Samenvatting.py +++ b/pages/2_Samenvatting.py @@ -9,7 +9,13 @@ from reporting import ( sensor_active_percent, tir_breakdown_5band, ) -from ui_common import load_range_data, render_completeness_warning, select_period_days +from ui_common import ( + build_tir_stacked_bar_figure, + get_colors, + load_range_data, + render_completeness_warning, + select_period_days, +) st.set_page_config(page_title="Glucose dashboard — Samenvatting", page_icon="📊", layout="wide") require_auth() @@ -66,6 +72,8 @@ cols = st.columns(5) for col, band in zip(cols, band_labels, strict=True): col.metric(band_labels[band], f"{tir[band]:.0f}%", help=f"Streefwaarde: {band_targets[band]}") +st.plotly_chart(build_tir_stacked_bar_figure(tir, get_colors()), width="stretch") + st.caption( f"Periode: {start.strftime('%d-%m-%Y')} t/m {end.strftime('%d-%m-%Y')} " f"({len(glucose)} metingen) · streefwaarden volgens Battelino et al. 2019" diff --git a/pages/3_AGP_Profiel.py b/pages/3_AGP_Profiel.py index 6ae0d34..99e20a9 100644 --- a/pages/3_AGP_Profiel.py +++ b/pages/3_AGP_Profiel.py @@ -7,6 +7,7 @@ from data_processing import TARGET_HIGH, TARGET_LOW from nightscout_client import NightscoutConfigError from reporting import agp_percentile_bands from ui_common import ( + add_threshold_lines, get_colors, hex_to_rgba, load_range_data, @@ -102,6 +103,7 @@ fig.update_layout( yaxis={"title": "mmol/L", "gridcolor": colors["grid"], "zeroline": False}, xaxis={"title": "Uur van de dag", "gridcolor": colors["grid"], "tickmode": "linear", "dtick": 2}, ) +add_threshold_lines(fig, colors) st.plotly_chart(fig, width="stretch") st.caption( f"Periode: {start.strftime('%d-%m-%Y')} t/m {end.strftime('%d-%m-%Y')} ({len(glucose)} metingen)" diff --git a/pages/4_Dagoverzicht.py b/pages/4_Dagoverzicht.py index 7f1cdd3..110e278 100644 --- a/pages/4_Dagoverzicht.py +++ b/pages/4_Dagoverzicht.py @@ -7,6 +7,7 @@ from auth import require_auth from data_processing import TARGET_HIGH, TARGET_LOW from nightscout_client import NightscoutConfigError from ui_common import ( + add_threshold_lines, get_colors, load_range_data, render_completeness_warning, @@ -74,6 +75,7 @@ for i, day in enumerate(dates, start=1): col=1, ) fig.update_yaxes(title_text="mmol/L", gridcolor=colors["grid"], row=i, col=1) + add_threshold_lines(fig, colors, row=i) fig.update_xaxes(title_text="Uur van de dag", range=[0, 24], dtick=4, gridcolor=colors["grid"]) fig.update_layout( diff --git a/tests/test_ui_common.py b/tests/test_ui_common.py index 8df8981..38ab716 100644 --- a/tests/test_ui_common.py +++ b/tests/test_ui_common.py @@ -1,4 +1,12 @@ -from ui_common import hex_to_rgba +import plotly.graph_objects as go + +from ui_common import ( + PALETTE, + THRESHOLD_LINES, + add_threshold_lines, + build_tir_stacked_bar_figure, + hex_to_rgba, +) def test_hex_to_rgba_converts_correctly(): @@ -7,3 +15,24 @@ def test_hex_to_rgba_converts_correctly(): def test_hex_to_rgba_handles_hash_optional(): assert hex_to_rgba("2a78d6", 0.3) == "rgba(42,120,214,0.3)" + + +def test_add_threshold_lines_adds_one_line_per_clinical_threshold(): + fig = go.Figure() + add_threshold_lines(fig, PALETTE["light"]) + assert len(fig.layout.shapes) == len(THRESHOLD_LINES) + + +def test_add_threshold_lines_targets_subplot_row_when_given(): + fig = go.Figure() + fig.add_trace(go.Scatter(x=[0], y=[0])) + add_threshold_lines(fig, PALETTE["light"], row=1) + assert len(fig.layout.shapes) == len(THRESHOLD_LINES) + + +def test_build_tir_stacked_bar_figure_has_one_bar_trace_per_band(): + tir = {"very_low": 1.0, "low": 3.0, "in_range": 80.0, "high": 12.0, "very_high": 4.0} + fig = build_tir_stacked_bar_figure(tir, PALETTE["light"]) + 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 fig.layout.barmode == "stack" diff --git a/ui_common.py b/ui_common.py index ce05490..cd9f85e 100644 --- a/ui_common.py +++ b/ui_common.py @@ -15,7 +15,15 @@ import plotly.graph_objects as go import streamlit as st from nightscout_client import fetch_entries_range, fetch_treatments_range -from reporting import completeness_warning, detect_events, summarize_events +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" @@ -41,6 +49,14 @@ PALETTE = { "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", }, "dark": { "glucose": "#3987e5", @@ -55,9 +71,24 @@ PALETTE = { "muted": "#898781", "text": "#c3c2b7", "good": "#0ca30c", + "tir_very_low": "#a32626", + "tir_low": "#d95d5d", + "tir_in_range": "#0ca30c", + "tir_high": "#fab219", + "tir_very_high": "#d9852e", }, } +# 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.""" @@ -98,6 +129,66 @@ def load_range_data(start: date, end: date): return glucose, treatments +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}}%", + ) + ) + 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 + + def render_completeness_warning(glucose: pd.DataFrame, start: date, end: date) -> None: """Toon reporting.completeness_warning()'s oordeel als st.warning, indien nodig.