From 79249958c295015e4fe1cd0204394911be9d6a75 Mon Sep 17 00:00:00 2001 From: willemjan Date: Sat, 5 Sep 2026 21:37:17 +0200 Subject: [PATCH] Add pump profile fetching and Insulineprofiel page Fetch and parse the latest-used Nightscout profile (basal schedule, ISF, carb ratio, DIA, targets) and show it as a new dashboard page: a list of basal/ISF/carb-ratio segments plus a bar chart of basal rate by hour of day. This is the data foundation for treatment overlays on the glucose charts and the redesigned PDF report in follow-up PRs. --- nightscout_client.py | 18 ++++ pages/9_Insulineprofiel.py | 98 +++++++++++++++++++++ profile_data.py | 146 ++++++++++++++++++++++++++++++++ tests/test_nightscout_client.py | 23 +++++ tests/test_profile_data.py | 117 +++++++++++++++++++++++++ ui_common.py | 22 ++++- 6 files changed, 423 insertions(+), 1 deletion(-) create mode 100644 pages/9_Insulineprofiel.py create mode 100644 profile_data.py create mode 100644 tests/test_profile_data.py diff --git a/nightscout_client.py b/nightscout_client.py index 47cbb1d..3f16c9a 100644 --- a/nightscout_client.py +++ b/nightscout_client.py @@ -252,3 +252,21 @@ def fetch_treatments_range(start_date: date, end_date: date) -> pd.DataFrame: since = datetime.combine(start_date, time.min, tzinfo=timezone.utc) until = datetime.combine(end_date, 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] diff --git a/pages/9_Insulineprofiel.py b/pages/9_Insulineprofiel.py new file mode 100644 index 0000000..61604a1 --- /dev/null +++ b/pages/9_Insulineprofiel.py @@ -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, 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=colors["basal"], + opacity=0.7, + name="Basaal", + hovertemplate="%{x:.0f}:00 · %{y:.2f} E/uur", + ) +) +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)." +) diff --git a/profile_data.py b/profile_data.py new file mode 100644 index 0000000..e5a07b8 --- /dev/null +++ b/profile_data.py @@ -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 diff --git a/tests/test_nightscout_client.py b/tests/test_nightscout_client.py index 6add455..6f7c538 100644 --- a/tests/test_nightscout_client.py +++ b/tests/test_nightscout_client.py @@ -357,6 +357,29 @@ def test_fetch_treatments_range_retrieves_full_history_even_with_a_hidden_server 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): """Losstaande, gerichte assertie op het daadwerkelijke queryparameter-veld — moet `find[created_at][$lte]` zijn, nooit `find[date][$lte]`, voor het diff --git a/tests/test_profile_data.py b/tests/test_profile_data.py new file mode 100644 index 0000000..e7ea39f --- /dev/null +++ b/tests/test_profile_data.py @@ -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 diff --git a/ui_common.py b/ui_common.py index cd9f85e..bd81d6f 100644 --- a/ui_common.py +++ b/ui_common.py @@ -14,7 +14,8 @@ import pandas as pd import plotly.graph_objects as go import streamlit as st -from nightscout_client import fetch_entries_range, fetch_treatments_range +from nightscout_client import fetch_entries_range, fetch_profile, fetch_treatments_range +from profile_data import parse_profile from reporting import ( HIGH, LOW, @@ -57,6 +58,11 @@ PALETTE = { "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", @@ -76,6 +82,11 @@ PALETTE = { "tir_in_range": "#0ca30c", "tir_high": "#fab219", "tir_very_high": "#d9852e", + "basal": "#9b7ec2", + "bolus_food": "#199e70", + "bolus_correction": "#3987e5", + "bolus_smb": "#898781", + "target": "#c3c2b7", }, } @@ -129,6 +140,15 @@ def load_range_data(start: date, end: date): return glucose, treatments +@st.cache_data(ttl=21600, show_spinner="Profiel ophalen van Nightscout...") +def load_profile() -> dict: + """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.""" + 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. -- 2.54.0