Nightscout-koppeling + CI/CD-fixes #2
@@ -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=
|
||||||
@@ -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: |
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
@@ -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 .
|
||||||
|
```
|
||||||
|
|||||||
@@ -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",
|
||||||
)
|
)
|
||||||
|
|
||||||
day_data = filter_by_date(df, selected_date)
|
fig.add_trace(
|
||||||
|
go.Scatter(
|
||||||
if day_data.empty:
|
x=glucose["timestamp"],
|
||||||
st.info("Geen metingen gevonden voor deze datum.")
|
y=glucose["mmol"],
|
||||||
else:
|
mode="lines",
|
||||||
hourly = hourly_average(day_data)
|
name="Glucose",
|
||||||
st.bar_chart(
|
line={"color": colors["glucose"], "width": 2, "shape": "spline"},
|
||||||
hourly.set_index("hour")["glucose_value"],
|
hovertemplate=MMOL_HOVERTEMPLATE,
|
||||||
x_label="Uur van de dag",
|
)
|
||||||
y_label="Gemiddelde glucosewaarde",
|
)
|
||||||
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
theme_type = "light"
|
||||||
|
try:
|
||||||
|
theme_type = st.context.theme.type or "light"
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
colors = PALETTE["dark" if theme_type == "dark" else "light"]
|
||||||
|
|
||||||
|
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"
|
||||||
)
|
)
|
||||||
st.caption(f"{len(day_data)} metingen op {selected_date.strftime('%d-%m-%Y')}")
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
+42
-32
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user