diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7ac7315 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Kopieer dit bestand naar .env en vul je eigen Nightscout-gegevens in. +# .env wordt niet meegecommit (zie .gitignore). + +NIGHTSCOUT_URL=https://jouw-nightscout.example.com + +# Gebruik precies EEN van de twee onderstaande auth-opties. +# Optie 1: een Nightscout API-token (rol-gebaseerd, aan te raden — kan +# read-only worden ingesteld in Nightscout onder Admin Tools > Manage Tokens). +NIGHTSCOUT_TOKEN= + +# Optie 2: het klassieke "API secret" van je Nightscout-instantie. +# NIGHTSCOUT_API_SECRET= diff --git a/.gitea/workflows/pipeline.yml b/.gitea/workflows/pipeline.yml index 35548ad..03d121d 100644 --- a/.gitea/workflows/pipeline.yml +++ b/.gitea/workflows/pipeline.yml @@ -57,7 +57,7 @@ jobs: exit-code: "1" # laat de pipeline falen bij kritieke kwetsbaarheden - name: Login bij Gitea-registry - run: echo "${{ secrets.GITEA_TOKEN }}" | docker login gitea.thuis.local -u willem --password-stdin + run: echo "${{ secrets.GLUCOSE_TOKEN }}" | docker login gitea.thuis.local -u willem --password-stdin - name: Tag en push run: | diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..edc638d --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.venv/ +__pycache__/ +*.pyc +.env +.pytest_cache/ +.ruff_cache/ diff --git a/README.md b/README.md index 4182a11..2f49329 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,37 @@ # glucose-dashboard -dashboard van glucose data \ No newline at end of file +Dashboard van glucosedata, live opgehaald uit een [Nightscout](https://nightscout.github.io/)-instantie. +Toont een rollend venster (standaard 24 uur) van je CGM-curve met insuline, +koolhydraten en pomp-/sensorwissels overlay, plus time-in-range-cijfers. + +## Instellen + +1. Zorg voor een draaiende Nightscout-instantie en een API-token (Admin Tools + > Manage Tokens in Nightscout — geef het token minimaal `readable`-rechten) + of het klassieke API secret. +2. Kopieer `.env.example` naar `.env` en vul `NIGHTSCOUT_URL` en één van de + twee auth-opties in. +3. Lokaal draaien: + + ```bash + uv venv + uv pip install -r requirements.txt + set -a; source .env; set +a # of: laad de vars op je eigen manier + streamlit run app.py + ``` + +4. Met Docker Compose: + + ```bash + docker compose up -d + ``` + + `docker-compose.yml` leest `.env` via `env_file` — dat bestand wordt niet + meegecommit (zie `.gitignore`). + +## Tests en lint + +```bash +pytest tests/ -v +ruff check . +``` diff --git a/app.py b/app.py index 43e2672..f8d6250 100644 --- a/app.py +++ b/app.py @@ -1,34 +1,241 @@ -from datetime import date - +import plotly.graph_objects as go +import requests import streamlit as st +from dotenv import load_dotenv -from data_processing import filter_by_date, hourly_average, load_data +from data_processing import ( + TARGET_HIGH, + TARGET_LOW, + classify_treatments, + summarize_time_in_range, +) +from nightscout_client import NightscoutConfigError, fetch_entries, fetch_treatments -st.set_page_config(page_title="Glucose dashboard", page_icon="📊", layout="centered") +load_dotenv() # laadt .env als die bestaat; bestaande env vars blijven leidend -st.title("Glucose per uur") +st.set_page_config(page_title="Glucose dashboard", page_icon="📊", layout="wide") -df = load_data() +st.title("Glucose dashboard") -min_date = df["timestamp"].dt.date.min() if not df.empty else date.today() -max_date = df["timestamp"].dt.date.max() if not df.empty else date.today() +HOURS_OPTIONS = {"6 uur": 6, "12 uur": 12, "24 uur": 24, "48 uur": 48} -selected_date = st.date_input( - "Kies een datum", - value=max_date, - min_value=min_date, - max_value=max_date, +# Kleuren per rol, licht/donker — zie de dataviz-conventies in dit project: +# 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", + "high": "#fab219", + "band": "rgba(12,163,12,0.08)", + "grid": "#e1e0d9", + "muted": "#898781", + "text": "#52514e", + }, + "dark": { + "glucose": "#3987e5", + "insulin": "#199e70", + "carbs": "#d95926", + "low": "#d03b3b", + "high": "#fab219", + "band": "rgba(12,163,12,0.15)", + "grid": "#2c2c2a", + "muted": "#898781", + "text": "#c3c2b7", + }, +} + + +@st.cache_data(ttl=60, show_spinner="Data ophalen van Nightscout...") +def load_nightscout_data(hours: int): + glucose = fetch_entries(hours=hours) + treatments = fetch_treatments(hours=hours) + return glucose, treatments + + +MMOL_HOVERTEMPLATE = "%{x|%d-%m %H:%M} · %{y:.1f} mmol/L" + + +def build_chart(glucose, insulin, carbs, site_changes, sensor_changes, colors) -> go.Figure: + glucose_min = min(glucose["mmol"].min(), TARGET_LOW) + glucose_max = max(glucose["mmol"].max(), TARGET_HIGH) + y_top = glucose_max + 1.0 + lane_base = glucose_min - 2.5 + y_bottom = lane_base - 0.5 + insulin_lane_y = lane_base + 1.5 + carbs_lane_y = lane_base + 0.7 + + fig = go.Figure() + + # Streefbereik als achtergrondband, geen losse legenda-entry nodig. + fig.add_hrect( + y0=TARGET_LOW, + y1=TARGET_HIGH, + fillcolor=colors["band"], + line_width=0, + layer="below", + ) + + fig.add_trace( + go.Scatter( + x=glucose["timestamp"], + y=glucose["mmol"], + mode="lines", + name="Glucose", + line={"color": colors["glucose"], "width": 2, "shape": "spline"}, + hovertemplate=MMOL_HOVERTEMPLATE, + ) + ) + + low_points = glucose[glucose["mmol"] < TARGET_LOW] + high_points = glucose[glucose["mmol"] > TARGET_HIGH] + if not low_points.empty: + fig.add_trace( + go.Scatter( + x=low_points["timestamp"], + y=low_points["mmol"], + mode="markers", + name=f"Laag (<{TARGET_LOW})", + marker={"color": colors["low"], "size": 6}, + hovertemplate=MMOL_HOVERTEMPLATE, + ) + ) + if not high_points.empty: + fig.add_trace( + go.Scatter( + x=high_points["timestamp"], + y=high_points["mmol"], + mode="markers", + name=f"Hoog (>{TARGET_HIGH})", + marker={"color": colors["high"], "size": 6}, + hovertemplate=MMOL_HOVERTEMPLATE, + ) + ) + + if not insulin.empty: + 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", + ) + ) + + if not carbs.empty: + fig.add_trace( + go.Scatter( + x=carbs["timestamp"], + y=[carbs_lane_y] * len(carbs), + mode="markers", + name="Koolhydraten", + marker={"color": colors["carbs"], "size": 10, "symbol": "circle"}, + customdata=carbs["carbs"], + hovertemplate="%{x|%d-%m %H:%M} · %{customdata:.0f} g", + ) + ) + + # Let op: annotation_text/-position via add_vline() zelf triggert een bug + # in plotly's shapeannotation-helper met pandas Timestamp-x-waarden, dus + # de lijn en het label worden hier los toegevoegd. + for _, row in site_changes.iterrows(): + fig.add_vline(x=row["timestamp"], line_dash="dot", line_color=colors["muted"], opacity=0.6) + fig.add_annotation( + x=row["timestamp"], + y=y_top, + yanchor="bottom", + text="Pomp", + showarrow=False, + font={"color": colors["muted"], "size": 10}, + ) + for _, row in sensor_changes.iterrows(): + fig.add_vline(x=row["timestamp"], line_dash="dash", line_color=colors["muted"], opacity=0.6) + fig.add_annotation( + x=row["timestamp"], + y=y_top, + yanchor="bottom", + text="Sensor", + showarrow=False, + font={"color": colors["muted"], "size": 10}, + ) + + fig.update_layout( + paper_bgcolor="rgba(0,0,0,0)", + plot_bgcolor="rgba(0,0,0,0)", + font_color=colors["text"], + legend={"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "left", "x": 0}, + margin={"l": 10, "r": 10, "t": 10, "b": 10}, + height=480, + yaxis={ + "title": "mmol/L", + "range": [y_bottom, y_top], + "gridcolor": colors["grid"], + "zeroline": False, + }, + xaxis={"gridcolor": colors["grid"]}, + ) + return fig + + +with st.sidebar: + st.header("Instellingen") + hours_label = st.radio("Periode", list(HOURS_OPTIONS.keys()), index=2) + hours = HOURS_OPTIONS[hours_label] + if st.button("Vernieuwen"): + st.cache_data.clear() + +try: + glucose, treatments = load_nightscout_data(hours) +except NightscoutConfigError as exc: + st.error(str(exc)) + st.stop() +except (requests.RequestException, ValueError) as exc: # netwerk-/API-fouten van Nightscout + st.error(f"Kon geen data ophalen van Nightscout: {exc}") + st.stop() + +if glucose.empty: + st.info("Geen glucosemetingen gevonden in deze periode.") + st.stop() + +events = classify_treatments(treatments) +tir = summarize_time_in_range(glucose) + +latest = glucose.iloc[-1] +previous = glucose.iloc[-2] if len(glucose) > 1 else None +delta = round(latest["mmol"] - previous["mmol"], 1) if previous is not None else None + +col1, col2, col3, col4 = st.columns(4) +col1.metric("Actuele glucose", f"{latest['mmol']:.1f} mmol/L", delta=delta, delta_color="off") +col2.metric("Tijd in bereik", f"{tir['in_range']:.0f}%") +col3.metric("Tijd laag", f"{tir['low']:.0f}%") +col4.metric("Tijd hoog", f"{tir['high']:.0f}%") +st.caption( + f"Laatste meting: {latest['timestamp'].strftime('%d-%m-%Y %H:%M')} · " + f"streefbereik {TARGET_LOW}-{TARGET_HIGH} mmol/L" ) -day_data = filter_by_date(df, selected_date) +theme_type = "light" +try: + theme_type = st.context.theme.type or "light" +except AttributeError: + pass +colors = PALETTE["dark" if theme_type == "dark" else "light"] -if day_data.empty: - st.info("Geen metingen gevonden voor deze datum.") -else: - hourly = hourly_average(day_data) - st.bar_chart( - hourly.set_index("hour")["glucose_value"], - x_label="Uur van de dag", - y_label="Gemiddelde glucosewaarde", - ) - st.caption(f"{len(day_data)} metingen op {selected_date.strftime('%d-%m-%Y')}") +fig = build_chart( + glucose, + events["insulin"], + events["carbs"], + events["site_changes"], + events["sensor_changes"], + colors, +) +st.plotly_chart(fig, width="stretch") +st.caption( + "Driehoekjes = insuline · cirkels = koolhydraten · stippellijn = pompwissel · " + "streepjeslijn = sensorwissel" +) diff --git a/data/glucose_readings.csv b/data/glucose_readings.csv deleted file mode 100644 index 453268b..0000000 --- a/data/glucose_readings.csv +++ /dev/null @@ -1,193 +0,0 @@ -timestamp,glucose_value -2026-07-22 00:00:00,6.7 -2026-07-22 00:15:00,6.7 -2026-07-22 00:30:00,7.3 -2026-07-22 00:45:00,7.9 -2026-07-22 01:00:00,7.5 -2026-07-22 01:15:00,7.7 -2026-07-22 01:30:00,8.5 -2026-07-22 01:45:00,8.3 -2026-07-22 02:00:00,7.8 -2026-07-22 02:15:00,8.2 -2026-07-22 02:30:00,7.7 -2026-07-22 02:45:00,7.6 -2026-07-22 03:00:00,7.7 -2026-07-22 03:15:00,6.6 -2026-07-22 03:30:00,6.4 -2026-07-22 03:45:00,6.6 -2026-07-22 04:00:00,6.1 -2026-07-22 04:15:00,6.3 -2026-07-22 04:30:00,5.6 -2026-07-22 04:45:00,5.1 -2026-07-22 05:00:00,6.0 -2026-07-22 05:15:00,5.2 -2026-07-22 05:30:00,5.1 -2026-07-22 05:45:00,4.5 -2026-07-22 06:00:00,4.8 -2026-07-22 06:15:00,5.1 -2026-07-22 06:30:00,4.7 -2026-07-22 06:45:00,5.4 -2026-07-22 07:00:00,5.2 -2026-07-22 07:15:00,5.5 -2026-07-22 07:30:00,5.7 -2026-07-22 07:45:00,6.9 -2026-07-22 08:00:00,6.5 -2026-07-22 08:15:00,6.4 -2026-07-22 08:30:00,7.4 -2026-07-22 08:45:00,6.8 -2026-07-22 09:00:00,7.6 -2026-07-22 09:15:00,7.0 -2026-07-22 09:30:00,7.4 -2026-07-22 09:45:00,8.0 -2026-07-22 10:00:00,8.3 -2026-07-22 10:15:00,8.0 -2026-07-22 10:30:00,7.8 -2026-07-22 10:45:00,7.6 -2026-07-22 11:00:00,7.0 -2026-07-22 11:15:00,7.0 -2026-07-22 11:30:00,6.9 -2026-07-22 11:45:00,7.2 -2026-07-22 12:00:00,6.6 -2026-07-22 12:15:00,5.5 -2026-07-22 12:30:00,6.1 -2026-07-22 12:45:00,5.5 -2026-07-22 13:00:00,5.2 -2026-07-22 13:15:00,5.5 -2026-07-22 13:30:00,5.5 -2026-07-22 13:45:00,5.4 -2026-07-22 14:00:00,4.7 -2026-07-22 14:15:00,4.9 -2026-07-22 14:30:00,5.2 -2026-07-22 14:45:00,5.6 -2026-07-22 15:00:00,5.2 -2026-07-22 15:15:00,5.6 -2026-07-22 15:30:00,5.5 -2026-07-22 15:45:00,5.7 -2026-07-22 16:00:00,6.8 -2026-07-22 16:15:00,7.3 -2026-07-22 16:30:00,7.0 -2026-07-22 16:45:00,7.7 -2026-07-22 17:00:00,7.7 -2026-07-22 17:15:00,7.5 -2026-07-22 17:30:00,8.0 -2026-07-22 17:45:00,8.6 -2026-07-22 18:00:00,8.0 -2026-07-22 18:15:00,8.6 -2026-07-22 18:30:00,6.8 -2026-07-22 18:45:00,8.1 -2026-07-22 19:00:00,7.6 -2026-07-22 19:15:00,7.2 -2026-07-22 19:30:00,7.1 -2026-07-22 19:45:00,6.0 -2026-07-22 20:00:00,6.4 -2026-07-22 20:15:00,6.4 -2026-07-22 20:30:00,6.5 -2026-07-22 20:45:00,5.5 -2026-07-22 21:00:00,5.1 -2026-07-22 21:15:00,5.1 -2026-07-22 21:30:00,5.5 -2026-07-22 21:45:00,5.2 -2026-07-22 22:00:00,4.8 -2026-07-22 22:15:00,5.2 -2026-07-22 22:30:00,5.2 -2026-07-22 22:45:00,5.6 -2026-07-22 23:00:00,5.2 -2026-07-22 23:15:00,5.5 -2026-07-22 23:30:00,5.8 -2026-07-22 23:45:00,5.6 -2026-07-23 00:00:00,6.6 -2026-07-23 00:15:00,6.9 -2026-07-23 00:30:00,7.1 -2026-07-23 00:45:00,7.2 -2026-07-23 01:00:00,7.0 -2026-07-23 01:15:00,7.6 -2026-07-23 01:30:00,7.7 -2026-07-23 01:45:00,7.7 -2026-07-23 02:00:00,7.9 -2026-07-23 02:15:00,8.1 -2026-07-23 02:30:00,8.6 -2026-07-23 02:45:00,7.8 -2026-07-23 03:00:00,7.7 -2026-07-23 03:15:00,7.3 -2026-07-23 03:30:00,6.3 -2026-07-23 03:45:00,6.8 -2026-07-23 04:00:00,6.5 -2026-07-23 04:15:00,7.2 -2026-07-23 04:30:00,5.8 -2026-07-23 04:45:00,5.8 -2026-07-23 05:00:00,5.4 -2026-07-23 05:15:00,4.8 -2026-07-23 05:30:00,5.6 -2026-07-23 05:45:00,5.3 -2026-07-23 06:00:00,5.3 -2026-07-23 06:15:00,4.7 -2026-07-23 06:30:00,5.7 -2026-07-23 06:45:00,4.7 -2026-07-23 07:00:00,5.7 -2026-07-23 07:15:00,6.5 -2026-07-23 07:30:00,5.5 -2026-07-23 07:45:00,6.0 -2026-07-23 08:00:00,6.5 -2026-07-23 08:15:00,6.6 -2026-07-23 08:30:00,6.5 -2026-07-23 08:45:00,7.4 -2026-07-23 09:00:00,7.1 -2026-07-23 09:15:00,7.9 -2026-07-23 09:30:00,7.5 -2026-07-23 09:45:00,8.6 -2026-07-23 10:00:00,7.7 -2026-07-23 10:15:00,7.8 -2026-07-23 10:30:00,8.2 -2026-07-23 10:45:00,7.3 -2026-07-23 11:00:00,7.7 -2026-07-23 11:15:00,7.9 -2026-07-23 11:30:00,6.4 -2026-07-23 11:45:00,6.9 -2026-07-23 12:00:00,6.6 -2026-07-23 12:15:00,6.5 -2026-07-23 12:30:00,5.4 -2026-07-23 12:45:00,5.1 -2026-07-23 13:00:00,5.6 -2026-07-23 13:15:00,5.4 -2026-07-23 13:30:00,5.2 -2026-07-23 13:45:00,5.2 -2026-07-23 14:00:00,4.7 -2026-07-23 14:15:00,5.1 -2026-07-23 14:30:00,5.2 -2026-07-23 14:45:00,5.0 -2026-07-23 15:00:00,6.2 -2026-07-23 15:15:00,5.9 -2026-07-23 15:30:00,5.4 -2026-07-23 15:45:00,6.5 -2026-07-23 16:00:00,6.1 -2026-07-23 16:15:00,7.1 -2026-07-23 16:30:00,7.5 -2026-07-23 16:45:00,7.0 -2026-07-23 17:00:00,7.9 -2026-07-23 17:15:00,7.9 -2026-07-23 17:30:00,8.2 -2026-07-23 17:45:00,8.7 -2026-07-23 18:00:00,7.9 -2026-07-23 18:15:00,7.7 -2026-07-23 18:30:00,7.5 -2026-07-23 18:45:00,7.4 -2026-07-23 19:00:00,7.5 -2026-07-23 19:15:00,7.5 -2026-07-23 19:30:00,7.2 -2026-07-23 19:45:00,7.1 -2026-07-23 20:00:00,6.5 -2026-07-23 20:15:00,6.8 -2026-07-23 20:30:00,5.8 -2026-07-23 20:45:00,6.8 -2026-07-23 21:00:00,5.7 -2026-07-23 21:15:00,4.9 -2026-07-23 21:30:00,4.7 -2026-07-23 21:45:00,5.2 -2026-07-23 22:00:00,4.9 -2026-07-23 22:15:00,5.3 -2026-07-23 22:30:00,5.3 -2026-07-23 22:45:00,5.2 -2026-07-23 23:00:00,5.1 -2026-07-23 23:15:00,5.1 -2026-07-23 23:30:00,5.7 -2026-07-23 23:45:00,6.5 diff --git a/data_processing.py b/data_processing.py index 99c3ef5..9ae5f63 100644 --- a/data_processing.py +++ b/data_processing.py @@ -3,50 +3,60 @@ Data processing logic voor het glucose-dashboard. Bewust gescheiden van app.py: Streamlit-UI-code kun je niet zomaar los unit-testen (die heeft een draaiende Streamlit-context nodig). Door de -logica (inladen, aggregeren) in een los module te zetten, kun je die +logica (classificeren, samenvatten) in een los module te zetten, kun je die logica wel gewoon met pytest testen, en blijft app.py alleen de UI-laag. """ -from pathlib import Path +from __future__ import annotations import pandas as pd -DATA_PATH = Path(__file__).parent / "data" / "glucose_readings.csv" +from nightscout_client import SENSOR_CHANGE_EVENT_TYPES, SITE_CHANGE_EVENT_TYPES + +# Streefbereik in mmol/L — pas aan naar je eigen behandeldoelen. +TARGET_LOW = 3.9 +TARGET_HIGH = 10.0 -def load_data(path: Path = DATA_PATH) -> pd.DataFrame: - """Laad de ruwe metingen in. +def classify_treatments(treatments: pd.DataFrame) -> dict[str, pd.DataFrame]: + """Splits ruwe Nightscout-treatments op in aparte, tijd-gesorteerde subsets. - Verwacht kolommen: 'timestamp' (parsebaar als datetime) en - 'glucose_value' (numeriek). Vervang deze functie later door je - eigen databron (database, CGM-export, API-call, etc.) — de rest - van de app blijft dan ongewijzigd werken. + Een insuline- en koolhydraat-registratie kunnen dezelfde treatment zijn + (bijv. een "Meal Bolus" met zowel insuline als carbs) en verschijnen dan + in beide subsets. """ - df = pd.read_csv(path, parse_dates=["timestamp"]) - return df + insulin = treatments[treatments["insulin"].fillna(0) > 0] + carbs = treatments[treatments["carbs"].fillna(0) > 0] + site_changes = treatments[treatments["eventType"].isin(SITE_CHANGE_EVENT_TYPES)] + sensor_changes = treatments[treatments["eventType"].isin(SENSOR_CHANGE_EVENT_TYPES)] + return { + "insulin": insulin.reset_index(drop=True), + "carbs": carbs.reset_index(drop=True), + "site_changes": site_changes.reset_index(drop=True), + "sensor_changes": sensor_changes.reset_index(drop=True), + } -def filter_by_date(df: pd.DataFrame, selected_date) -> pd.DataFrame: - """Filter de dataset op een enkele kalenderdag.""" - return df[df["timestamp"].dt.date == selected_date] +def glucose_status(mmol: float, low: float = TARGET_LOW, high: float = TARGET_HIGH) -> str: + """Classificeer een glucosewaarde t.o.v. het streefbereik.""" + if mmol < low: + return "low" + if mmol > high: + return "high" + return "in_range" -def hourly_average(df: pd.DataFrame) -> pd.DataFrame: - """Groepeer per uur (0-23) en bereken de gemiddelde waarde. +def summarize_time_in_range( + glucose: pd.DataFrame, low: float = TARGET_LOW, high: float = TARGET_HIGH +) -> dict[str, float]: + """Bereken het percentage metingen onder/in/boven het streefbereik.""" + if glucose.empty: + return {"low": 0.0, "in_range": 0.0, "high": 0.0} - Geeft een DataFrame terug met kolommen 'hour' en 'glucose_value', - één rij per uur waarin daadwerkelijk data zit. - """ - if df.empty: - return pd.DataFrame(columns=["hour", "glucose_value"]) - - working = df.copy() - working["hour"] = working["timestamp"].dt.hour - result = ( - working.groupby("hour")["glucose_value"] - .mean() - .round(1) - .reset_index() - .sort_values("hour") - ) - return result + statuses = glucose["mmol"].apply(lambda v: glucose_status(v, low, high)) + counts = statuses.value_counts(normalize=True) * 100 + return { + "low": round(counts.get("low", 0.0), 1), + "in_range": round(counts.get("in_range", 0.0), 1), + "high": round(counts.get("high", 0.0), 1), + } diff --git a/docker-compose.yml b/docker-compose.yml index dd6eb51..44a0906 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,5 +5,5 @@ services: restart: unless-stopped ports: - "8501:8501" - volumes: - - ./data:/app/data # zet hier je eigen databron neer i.p.v. de voorbeelddata + env_file: + - .env # zie .env.example — NIGHTSCOUT_URL + NIGHTSCOUT_TOKEN of NIGHTSCOUT_API_SECRET diff --git a/nightscout_client.py b/nightscout_client.py new file mode 100644 index 0000000..f302613 --- /dev/null +++ b/nightscout_client.py @@ -0,0 +1,129 @@ +""" +Nightscout API client. + +Praat met een bestaande Nightscout-instantie (CGM-data, insuline/koolhydraten +en pomp-/sensorwissels). Authenticatie loopt via environment variables: + +- NIGHTSCOUT_URL: basis-URL van je Nightscout-instantie, bijv. + "https://jouw-nightscout.example.com" (verplicht). +- NIGHTSCOUT_TOKEN: een Nightscout API-token (rol-gebaseerd), OF +- NIGHTSCOUT_API_SECRET: het "API secret" van je instantie (wordt lokaal + ge-hashed, nooit in plaintext verstuurd). + +Gebruik precies één van de twee auth-opties; als beide gezet zijn heeft het +token voorrang. +""" + +from __future__ import annotations + +import hashlib +import os +from datetime import datetime, timedelta, timezone + +import pandas as pd +import requests + +MGDL_PER_MMOL = 18.0182 + +INSULIN_EVENT_TYPES = {"Bolus", "Meal Bolus", "Correction Bolus", "Combo Bolus", "SMB"} +SITE_CHANGE_EVENT_TYPES = {"Site Change"} +SENSOR_CHANGE_EVENT_TYPES = {"Sensor Change", "Sensor Start"} + +_REQUEST_TIMEOUT = 10 + + +class NightscoutConfigError(RuntimeError): + """NIGHTSCOUT_URL ontbreekt of is ongeldig.""" + + +def mgdl_to_mmol(value: float) -> float: + """Reken mg/dL (Nightscout's interne eenheid) om naar mmol/L.""" + return round(value / MGDL_PER_MMOL, 1) + + +def _base_url() -> str: + url = os.environ.get("NIGHTSCOUT_URL", "").strip().rstrip("/") + if not url: + raise NightscoutConfigError( + "NIGHTSCOUT_URL is niet ingesteld. Zet deze environment variable, " + "bijv. https://jouw-nightscout.example.com" + ) + return url + + +def _auth() -> tuple[dict, dict]: + """Geeft (headers, params) terug voor authenticatie met Nightscout.""" + token = os.environ.get("NIGHTSCOUT_TOKEN", "").strip() + api_secret = os.environ.get("NIGHTSCOUT_API_SECRET", "").strip() + if token: + return {}, {"token": token} + if api_secret: + hashed = hashlib.sha1(api_secret.encode("utf-8")).hexdigest() + return {"API-SECRET": hashed}, {} + return {}, {} + + +def _get(path: str, params: dict) -> list: + headers, auth_params = _auth() + response = requests.get( + f"{_base_url()}{path}", + headers=headers, + params={**params, **auth_params}, + timeout=_REQUEST_TIMEOUT, + ) + response.raise_for_status() + return response.json() + + +def fetch_entries(hours: int = 24) -> pd.DataFrame: + """Haal glucosemetingen (SGV) op van de laatste `hours` uur, in mmol/L. + + Geeft een DataFrame terug met kolommen 'timestamp' en 'mmol', oplopend + gesorteerd op tijd. + """ + since = datetime.now(timezone.utc) - timedelta(hours=hours) + # Nightscout geeft resultaten terug in aflopende volgorde; vraag ruim + # genoeg entries op (CGM meet doorgaans elke 5 min) en filter lokaal op + # tijd. Dit is robuuster dan server-side find[]-query's, die niet op elke + # Nightscout-versie identiek werken. + count = max(300, hours * 15) + data = _get("/api/v1/entries/sgv.json", {"count": count}) + + if not data: + return pd.DataFrame(columns=["timestamp", "mmol"]) + + df = pd.DataFrame(data) + df["timestamp"] = pd.to_datetime(df["date"], unit="ms", utc=True).dt.tz_localize(None) + df["mmol"] = df["sgv"].astype(float).apply(mgdl_to_mmol) + df = df[df["timestamp"] >= since.replace(tzinfo=None)] + return df[["timestamp", "mmol"]].sort_values("timestamp").reset_index(drop=True) + + +def fetch_treatments(hours: int = 24) -> pd.DataFrame: + """Haal behandelingen op van de laatste `hours` uur: insuline, koolhydraten + en pomp-/sensorwissels. + + Geeft een DataFrame terug met kolommen 'timestamp', 'eventType', + 'insulin' en 'carbs' (de laatste twee NaN waar niet van toepassing). + """ + since = datetime.now(timezone.utc) - timedelta(hours=hours) + count = max(200, hours * 10) + data = _get("/api/v1/treatments.json", {"count": count}) + + if not data: + return pd.DataFrame(columns=["timestamp", "eventType", "insulin", "carbs"]) + + df = pd.DataFrame(data) + df["timestamp"] = pd.to_datetime(df["created_at"], utc=True).dt.tz_localize(None) + for col in ("insulin", "carbs"): + if col not in df.columns: + df[col] = pd.NA + df[col] = pd.to_numeric(df[col], errors="coerce") + if "eventType" not in df.columns: + df["eventType"] = "" + df = df[df["timestamp"] >= since.replace(tzinfo=None)] + return ( + df[["timestamp", "eventType", "insulin", "carbs"]] + .sort_values("timestamp") + .reset_index(drop=True) + ) diff --git a/requirements.txt b/requirements.txt index 71cd39e..8338c4d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,7 @@ streamlit>=1.38,<2.0 pandas>=2.2,<3.0 +requests>=2.32,<3.0 +plotly>=5.24,<6.0 +python-dotenv>=1.0,<2.0 pytest>=8.0,<9.0 ruff>=0.6,<1.0 diff --git a/tests/test_data_processing.py b/tests/test_data_processing.py index d7da5e6..a6358d7 100644 --- a/tests/test_data_processing.py +++ b/tests/test_data_processing.py @@ -1,52 +1,74 @@ import pandas as pd import pytest -from data_processing import filter_by_date, hourly_average +from data_processing import classify_treatments, glucose_status, summarize_time_in_range @pytest.fixture -def sample_df(): +def sample_glucose(): return pd.DataFrame( { "timestamp": pd.to_datetime( - [ - "2026-07-22 08:05", - "2026-07-22 08:40", - "2026-07-22 09:10", - "2026-07-23 08:05", # andere dag, moet uitgefilterd worden - ] + ["2026-07-25 08:00", "2026-07-25 09:00", "2026-07-25 10:00", "2026-07-25 11:00"] ), - "glucose_value": [6.0, 8.0, 7.0, 12.0], + "mmol": [3.5, 6.0, 11.0, 7.0], # laag, in bereik, hoog, in bereik } ) -def test_filter_by_date_keeps_only_selected_day(sample_df): - result = filter_by_date(sample_df, pd.Timestamp("2026-07-22").date()) - assert len(result) == 3 - assert (result["timestamp"].dt.date == pd.Timestamp("2026-07-22").date()).all() +@pytest.fixture +def sample_treatments(): + return pd.DataFrame( + { + "timestamp": pd.to_datetime( + [ + "2026-07-25 08:00", + "2026-07-25 12:00", + "2026-07-24 09:00", + "2026-07-20 09:00", + ] + ), + "eventType": ["Meal Bolus", "Correction Bolus", "Site Change", "Sensor Change"], + "insulin": [4.0, 1.5, None, None], + "carbs": [45.0, None, None, None], + } + ) -def test_hourly_average_computes_mean_per_hour(sample_df): - filtered = filter_by_date(sample_df, pd.Timestamp("2026-07-22").date()) - result = hourly_average(filtered) - - hour_8 = result.loc[result["hour"] == 8, "glucose_value"].iloc[0] - hour_9 = result.loc[result["hour"] == 9, "glucose_value"].iloc[0] - - assert hour_8 == pytest.approx(7.0) # gemiddelde van 6.0 en 8.0 - assert hour_9 == pytest.approx(7.0) +def test_glucose_status_classifies_correctly(): + assert glucose_status(3.5) == "low" + assert glucose_status(6.0) == "in_range" + assert glucose_status(11.0) == "high" -def test_hourly_average_handles_empty_input(): - empty = pd.DataFrame(columns=["timestamp", "glucose_value"]) - result = hourly_average(empty) - assert result.empty - assert list(result.columns) == ["hour", "glucose_value"] +def test_summarize_time_in_range(sample_glucose): + result = summarize_time_in_range(sample_glucose) + assert result == {"low": 25.0, "in_range": 50.0, "high": 25.0} -def test_hourly_average_only_returns_hours_with_data(sample_df): - filtered = filter_by_date(sample_df, pd.Timestamp("2026-07-22").date()) - result = hourly_average(filtered) - # alleen uur 8 en 9 hebben data op deze dag, dus precies 2 rijen - assert len(result) == 2 +def test_summarize_time_in_range_handles_empty_input(): + empty = pd.DataFrame(columns=["timestamp", "mmol"]) + result = summarize_time_in_range(empty) + assert result == {"low": 0.0, "in_range": 0.0, "high": 0.0} + + +def test_classify_treatments_splits_into_expected_subsets(sample_treatments): + events = classify_treatments(sample_treatments) + + assert len(events["insulin"]) == 2 + assert set(events["insulin"]["insulin"]) == {4.0, 1.5} + + assert len(events["carbs"]) == 1 + assert events["carbs"].iloc[0]["carbs"] == 45.0 + + assert len(events["site_changes"]) == 1 + assert events["site_changes"].iloc[0]["eventType"] == "Site Change" + + assert len(events["sensor_changes"]) == 1 + assert events["sensor_changes"].iloc[0]["eventType"] == "Sensor Change" + + +def test_classify_treatments_handles_empty_input(): + empty = pd.DataFrame(columns=["timestamp", "eventType", "insulin", "carbs"]) + events = classify_treatments(empty) + assert all(df.empty for df in events.values()) diff --git a/tests/test_nightscout_client.py b/tests/test_nightscout_client.py new file mode 100644 index 0000000..a828b5d --- /dev/null +++ b/tests/test_nightscout_client.py @@ -0,0 +1,98 @@ +from datetime import datetime, timedelta, timezone + +import pytest + +import nightscout_client as ns + + +class FakeResponse: + def __init__(self, payload): + self._payload = payload + + def raise_for_status(self): + pass + + def json(self): + return self._payload + + +def test_mgdl_to_mmol_converts_correctly(): + assert ns.mgdl_to_mmol(126.0) == pytest.approx(7.0, abs=0.05) + + +def test_base_url_requires_nightscout_url(monkeypatch): + monkeypatch.delenv("NIGHTSCOUT_URL", raising=False) + with pytest.raises(ns.NightscoutConfigError): + ns._base_url() + + +def test_base_url_strips_trailing_slash(monkeypatch): + monkeypatch.setenv("NIGHTSCOUT_URL", "https://example.com/") + assert ns._base_url() == "https://example.com" + + +def test_auth_prefers_token_over_api_secret(monkeypatch): + monkeypatch.setenv("NIGHTSCOUT_TOKEN", "my-token") + monkeypatch.setenv("NIGHTSCOUT_API_SECRET", "my-secret") + headers, params = ns._auth() + assert params == {"token": "my-token"} + assert headers == {} + + +def test_auth_hashes_api_secret(monkeypatch): + monkeypatch.delenv("NIGHTSCOUT_TOKEN", raising=False) + monkeypatch.setenv("NIGHTSCOUT_API_SECRET", "my-secret") + headers, params = ns._auth() + assert params == {} + assert headers["API-SECRET"] != "my-secret" # nooit in plaintext + assert len(headers["API-SECRET"]) == 40 # sha1 hexdigest + + +def test_fetch_entries_converts_and_filters_by_time(monkeypatch): + monkeypatch.setenv("NIGHTSCOUT_URL", "https://example.com") + monkeypatch.delenv("NIGHTSCOUT_TOKEN", raising=False) + monkeypatch.delenv("NIGHTSCOUT_API_SECRET", raising=False) + + now = datetime.now(timezone.utc) + recent = now - timedelta(hours=1) + too_old = now - timedelta(hours=48) + payload = [ + {"date": int(recent.timestamp() * 1000), "sgv": 126}, + {"date": int(too_old.timestamp() * 1000), "sgv": 90}, + ] + + monkeypatch.setattr(ns.requests, "get", lambda *a, **k: FakeResponse(payload)) + + result = ns.fetch_entries(hours=24) + assert len(result) == 1 + assert result.iloc[0]["mmol"] == pytest.approx(7.0, abs=0.05) + + +def test_fetch_entries_handles_empty_response(monkeypatch): + monkeypatch.setenv("NIGHTSCOUT_URL", "https://example.com") + monkeypatch.setattr(ns.requests, "get", lambda *a, **k: FakeResponse([])) + + result = ns.fetch_entries(hours=24) + assert result.empty + assert list(result.columns) == ["timestamp", "mmol"] + + +def test_fetch_treatments_fills_missing_columns(monkeypatch): + monkeypatch.setenv("NIGHTSCOUT_URL", "https://example.com") + now = datetime.now(timezone.utc) + payload = [ + {"created_at": now.isoformat(), "eventType": "Site Change"}, + ] + monkeypatch.setattr(ns.requests, "get", lambda *a, **k: FakeResponse(payload)) + + result = ns.fetch_treatments(hours=24) + assert len(result) == 1 + assert result.iloc[0]["eventType"] == "Site Change" + assert pd_isna(result.iloc[0]["insulin"]) + assert pd_isna(result.iloc[0]["carbs"]) + + +def pd_isna(value): + import pandas as pd + + return pd.isna(value)