added getting data from nightscout

This commit is contained in:
2026-07-25 08:54:08 +02:00
parent c02110a82b
commit 60735de445
12 changed files with 613 additions and 285 deletions
+12
View File
@@ -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=
+1 -1
View File
@@ -57,7 +57,7 @@ jobs:
exit-code: "1" # laat de pipeline falen bij kritieke kwetsbaarheden exit-code: "1" # laat de pipeline falen bij kritieke kwetsbaarheden
- name: Login bij Gitea-registry - 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 - name: Tag en push
run: | run: |
+6
View File
@@ -0,0 +1,6 @@
.venv/
__pycache__/
*.pyc
.env
.pytest_cache/
.ruff_cache/
+35 -1
View File
@@ -1,3 +1,37 @@
# glucose-dashboard # glucose-dashboard
dashboard van glucose data 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 .
```
+231 -24
View File
@@ -1,34 +1,241 @@
from datetime import date import plotly.graph_objects as go
import requests
import streamlit as st 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() HOURS_OPTIONS = {"6 uur": 6, "12 uur": 12, "24 uur": 24, "48 uur": 48}
max_date = df["timestamp"].dt.date.max() if not df.empty else date.today()
selected_date = st.date_input( # Kleuren per rol, licht/donker — zie de dataviz-conventies in dit project:
"Kies een datum", # categorale kleuren voor identiteit (glucose/insuline/carbs), status-kleuren
value=max_date, # (vast, nooit hergebruikt) voor te laag/te hoog.
min_value=min_date, PALETTE = {
max_value=max_date, "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<extra></extra>"
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<extra></extra>",
)
)
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<extra></extra>",
)
)
# 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: fig = build_chart(
st.info("Geen metingen gevonden voor deze datum.") glucose,
else: events["insulin"],
hourly = hourly_average(day_data) events["carbs"],
st.bar_chart( events["site_changes"],
hourly.set_index("hour")["glucose_value"], events["sensor_changes"],
x_label="Uur van de dag", colors,
y_label="Gemiddelde glucosewaarde", )
) st.plotly_chart(fig, width="stretch")
st.caption(f"{len(day_data)} metingen op {selected_date.strftime('%d-%m-%Y')}") st.caption(
"Driehoekjes = insuline · cirkels = koolhydraten · stippellijn = pompwissel · "
"streepjeslijn = sensorwissel"
)
-193
View File
@@ -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
1 timestamp glucose_value
2 2026-07-22 00:00:00 6.7
3 2026-07-22 00:15:00 6.7
4 2026-07-22 00:30:00 7.3
5 2026-07-22 00:45:00 7.9
6 2026-07-22 01:00:00 7.5
7 2026-07-22 01:15:00 7.7
8 2026-07-22 01:30:00 8.5
9 2026-07-22 01:45:00 8.3
10 2026-07-22 02:00:00 7.8
11 2026-07-22 02:15:00 8.2
12 2026-07-22 02:30:00 7.7
13 2026-07-22 02:45:00 7.6
14 2026-07-22 03:00:00 7.7
15 2026-07-22 03:15:00 6.6
16 2026-07-22 03:30:00 6.4
17 2026-07-22 03:45:00 6.6
18 2026-07-22 04:00:00 6.1
19 2026-07-22 04:15:00 6.3
20 2026-07-22 04:30:00 5.6
21 2026-07-22 04:45:00 5.1
22 2026-07-22 05:00:00 6.0
23 2026-07-22 05:15:00 5.2
24 2026-07-22 05:30:00 5.1
25 2026-07-22 05:45:00 4.5
26 2026-07-22 06:00:00 4.8
27 2026-07-22 06:15:00 5.1
28 2026-07-22 06:30:00 4.7
29 2026-07-22 06:45:00 5.4
30 2026-07-22 07:00:00 5.2
31 2026-07-22 07:15:00 5.5
32 2026-07-22 07:30:00 5.7
33 2026-07-22 07:45:00 6.9
34 2026-07-22 08:00:00 6.5
35 2026-07-22 08:15:00 6.4
36 2026-07-22 08:30:00 7.4
37 2026-07-22 08:45:00 6.8
38 2026-07-22 09:00:00 7.6
39 2026-07-22 09:15:00 7.0
40 2026-07-22 09:30:00 7.4
41 2026-07-22 09:45:00 8.0
42 2026-07-22 10:00:00 8.3
43 2026-07-22 10:15:00 8.0
44 2026-07-22 10:30:00 7.8
45 2026-07-22 10:45:00 7.6
46 2026-07-22 11:00:00 7.0
47 2026-07-22 11:15:00 7.0
48 2026-07-22 11:30:00 6.9
49 2026-07-22 11:45:00 7.2
50 2026-07-22 12:00:00 6.6
51 2026-07-22 12:15:00 5.5
52 2026-07-22 12:30:00 6.1
53 2026-07-22 12:45:00 5.5
54 2026-07-22 13:00:00 5.2
55 2026-07-22 13:15:00 5.5
56 2026-07-22 13:30:00 5.5
57 2026-07-22 13:45:00 5.4
58 2026-07-22 14:00:00 4.7
59 2026-07-22 14:15:00 4.9
60 2026-07-22 14:30:00 5.2
61 2026-07-22 14:45:00 5.6
62 2026-07-22 15:00:00 5.2
63 2026-07-22 15:15:00 5.6
64 2026-07-22 15:30:00 5.5
65 2026-07-22 15:45:00 5.7
66 2026-07-22 16:00:00 6.8
67 2026-07-22 16:15:00 7.3
68 2026-07-22 16:30:00 7.0
69 2026-07-22 16:45:00 7.7
70 2026-07-22 17:00:00 7.7
71 2026-07-22 17:15:00 7.5
72 2026-07-22 17:30:00 8.0
73 2026-07-22 17:45:00 8.6
74 2026-07-22 18:00:00 8.0
75 2026-07-22 18:15:00 8.6
76 2026-07-22 18:30:00 6.8
77 2026-07-22 18:45:00 8.1
78 2026-07-22 19:00:00 7.6
79 2026-07-22 19:15:00 7.2
80 2026-07-22 19:30:00 7.1
81 2026-07-22 19:45:00 6.0
82 2026-07-22 20:00:00 6.4
83 2026-07-22 20:15:00 6.4
84 2026-07-22 20:30:00 6.5
85 2026-07-22 20:45:00 5.5
86 2026-07-22 21:00:00 5.1
87 2026-07-22 21:15:00 5.1
88 2026-07-22 21:30:00 5.5
89 2026-07-22 21:45:00 5.2
90 2026-07-22 22:00:00 4.8
91 2026-07-22 22:15:00 5.2
92 2026-07-22 22:30:00 5.2
93 2026-07-22 22:45:00 5.6
94 2026-07-22 23:00:00 5.2
95 2026-07-22 23:15:00 5.5
96 2026-07-22 23:30:00 5.8
97 2026-07-22 23:45:00 5.6
98 2026-07-23 00:00:00 6.6
99 2026-07-23 00:15:00 6.9
100 2026-07-23 00:30:00 7.1
101 2026-07-23 00:45:00 7.2
102 2026-07-23 01:00:00 7.0
103 2026-07-23 01:15:00 7.6
104 2026-07-23 01:30:00 7.7
105 2026-07-23 01:45:00 7.7
106 2026-07-23 02:00:00 7.9
107 2026-07-23 02:15:00 8.1
108 2026-07-23 02:30:00 8.6
109 2026-07-23 02:45:00 7.8
110 2026-07-23 03:00:00 7.7
111 2026-07-23 03:15:00 7.3
112 2026-07-23 03:30:00 6.3
113 2026-07-23 03:45:00 6.8
114 2026-07-23 04:00:00 6.5
115 2026-07-23 04:15:00 7.2
116 2026-07-23 04:30:00 5.8
117 2026-07-23 04:45:00 5.8
118 2026-07-23 05:00:00 5.4
119 2026-07-23 05:15:00 4.8
120 2026-07-23 05:30:00 5.6
121 2026-07-23 05:45:00 5.3
122 2026-07-23 06:00:00 5.3
123 2026-07-23 06:15:00 4.7
124 2026-07-23 06:30:00 5.7
125 2026-07-23 06:45:00 4.7
126 2026-07-23 07:00:00 5.7
127 2026-07-23 07:15:00 6.5
128 2026-07-23 07:30:00 5.5
129 2026-07-23 07:45:00 6.0
130 2026-07-23 08:00:00 6.5
131 2026-07-23 08:15:00 6.6
132 2026-07-23 08:30:00 6.5
133 2026-07-23 08:45:00 7.4
134 2026-07-23 09:00:00 7.1
135 2026-07-23 09:15:00 7.9
136 2026-07-23 09:30:00 7.5
137 2026-07-23 09:45:00 8.6
138 2026-07-23 10:00:00 7.7
139 2026-07-23 10:15:00 7.8
140 2026-07-23 10:30:00 8.2
141 2026-07-23 10:45:00 7.3
142 2026-07-23 11:00:00 7.7
143 2026-07-23 11:15:00 7.9
144 2026-07-23 11:30:00 6.4
145 2026-07-23 11:45:00 6.9
146 2026-07-23 12:00:00 6.6
147 2026-07-23 12:15:00 6.5
148 2026-07-23 12:30:00 5.4
149 2026-07-23 12:45:00 5.1
150 2026-07-23 13:00:00 5.6
151 2026-07-23 13:15:00 5.4
152 2026-07-23 13:30:00 5.2
153 2026-07-23 13:45:00 5.2
154 2026-07-23 14:00:00 4.7
155 2026-07-23 14:15:00 5.1
156 2026-07-23 14:30:00 5.2
157 2026-07-23 14:45:00 5.0
158 2026-07-23 15:00:00 6.2
159 2026-07-23 15:15:00 5.9
160 2026-07-23 15:30:00 5.4
161 2026-07-23 15:45:00 6.5
162 2026-07-23 16:00:00 6.1
163 2026-07-23 16:15:00 7.1
164 2026-07-23 16:30:00 7.5
165 2026-07-23 16:45:00 7.0
166 2026-07-23 17:00:00 7.9
167 2026-07-23 17:15:00 7.9
168 2026-07-23 17:30:00 8.2
169 2026-07-23 17:45:00 8.7
170 2026-07-23 18:00:00 7.9
171 2026-07-23 18:15:00 7.7
172 2026-07-23 18:30:00 7.5
173 2026-07-23 18:45:00 7.4
174 2026-07-23 19:00:00 7.5
175 2026-07-23 19:15:00 7.5
176 2026-07-23 19:30:00 7.2
177 2026-07-23 19:45:00 7.1
178 2026-07-23 20:00:00 6.5
179 2026-07-23 20:15:00 6.8
180 2026-07-23 20:30:00 5.8
181 2026-07-23 20:45:00 6.8
182 2026-07-23 21:00:00 5.7
183 2026-07-23 21:15:00 4.9
184 2026-07-23 21:30:00 4.7
185 2026-07-23 21:45:00 5.2
186 2026-07-23 22:00:00 4.9
187 2026-07-23 22:15:00 5.3
188 2026-07-23 22:30:00 5.3
189 2026-07-23 22:45:00 5.2
190 2026-07-23 23:00:00 5.1
191 2026-07-23 23:15:00 5.1
192 2026-07-23 23:30:00 5.7
193 2026-07-23 23:45:00 6.5
+42 -32
View File
@@ -3,50 +3,60 @@ Data processing logic voor het glucose-dashboard.
Bewust gescheiden van app.py: Streamlit-UI-code kun je niet zomaar los Bewust gescheiden van app.py: Streamlit-UI-code kun je niet zomaar los
unit-testen (die heeft een draaiende Streamlit-context nodig). Door de 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. 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 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: def classify_treatments(treatments: pd.DataFrame) -> dict[str, pd.DataFrame]:
"""Laad de ruwe metingen in. """Splits ruwe Nightscout-treatments op in aparte, tijd-gesorteerde subsets.
Verwacht kolommen: 'timestamp' (parsebaar als datetime) en Een insuline- en koolhydraat-registratie kunnen dezelfde treatment zijn
'glucose_value' (numeriek). Vervang deze functie later door je (bijv. een "Meal Bolus" met zowel insuline als carbs) en verschijnen dan
eigen databron (database, CGM-export, API-call, etc.) — de rest in beide subsets.
van de app blijft dan ongewijzigd werken.
""" """
df = pd.read_csv(path, parse_dates=["timestamp"]) insulin = treatments[treatments["insulin"].fillna(0) > 0]
return df 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: def glucose_status(mmol: float, low: float = TARGET_LOW, high: float = TARGET_HIGH) -> str:
"""Filter de dataset op een enkele kalenderdag.""" """Classificeer een glucosewaarde t.o.v. het streefbereik."""
return df[df["timestamp"].dt.date == selected_date] if mmol < low:
return "low"
if mmol > high:
return "high"
return "in_range"
def hourly_average(df: pd.DataFrame) -> pd.DataFrame: def summarize_time_in_range(
"""Groepeer per uur (0-23) en bereken de gemiddelde waarde. 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', statuses = glucose["mmol"].apply(lambda v: glucose_status(v, low, high))
één rij per uur waarin daadwerkelijk data zit. counts = statuses.value_counts(normalize=True) * 100
""" return {
if df.empty: "low": round(counts.get("low", 0.0), 1),
return pd.DataFrame(columns=["hour", "glucose_value"]) "in_range": round(counts.get("in_range", 0.0), 1),
"high": round(counts.get("high", 0.0), 1),
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
+2 -2
View File
@@ -5,5 +5,5 @@ services:
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8501:8501" - "8501:8501"
volumes: env_file:
- ./data:/app/data # zet hier je eigen databron neer i.p.v. de voorbeelddata - .env # zie .env.example — NIGHTSCOUT_URL + NIGHTSCOUT_TOKEN of NIGHTSCOUT_API_SECRET
+129
View File
@@ -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)
)
+3
View File
@@ -1,4 +1,7 @@
streamlit>=1.38,<2.0 streamlit>=1.38,<2.0
pandas>=2.2,<3.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 pytest>=8.0,<9.0
ruff>=0.6,<1.0 ruff>=0.6,<1.0
+54 -32
View File
@@ -1,52 +1,74 @@
import pandas as pd import pandas as pd
import pytest 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 @pytest.fixture
def sample_df(): def sample_glucose():
return pd.DataFrame( return pd.DataFrame(
{ {
"timestamp": pd.to_datetime( "timestamp": pd.to_datetime(
[ ["2026-07-25 08:00", "2026-07-25 09:00", "2026-07-25 10:00", "2026-07-25 11:00"]
"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
]
), ),
"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): @pytest.fixture
result = filter_by_date(sample_df, pd.Timestamp("2026-07-22").date()) def sample_treatments():
assert len(result) == 3 return pd.DataFrame(
assert (result["timestamp"].dt.date == pd.Timestamp("2026-07-22").date()).all() {
"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): def test_glucose_status_classifies_correctly():
filtered = filter_by_date(sample_df, pd.Timestamp("2026-07-22").date()) assert glucose_status(3.5) == "low"
result = hourly_average(filtered) assert glucose_status(6.0) == "in_range"
assert glucose_status(11.0) == "high"
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_hourly_average_handles_empty_input(): def test_summarize_time_in_range(sample_glucose):
empty = pd.DataFrame(columns=["timestamp", "glucose_value"]) result = summarize_time_in_range(sample_glucose)
result = hourly_average(empty) assert result == {"low": 25.0, "in_range": 50.0, "high": 25.0}
assert result.empty
assert list(result.columns) == ["hour", "glucose_value"]
def test_hourly_average_only_returns_hours_with_data(sample_df): def test_summarize_time_in_range_handles_empty_input():
filtered = filter_by_date(sample_df, pd.Timestamp("2026-07-22").date()) empty = pd.DataFrame(columns=["timestamp", "mmol"])
result = hourly_average(filtered) result = summarize_time_in_range(empty)
# alleen uur 8 en 9 hebben data op deze dag, dus precies 2 rijen assert result == {"low": 0.0, "in_range": 0.0, "high": 0.0}
assert len(result) == 2
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())
+98
View File
@@ -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)