Author SHA1 Message Date
willemjan 1376101212 Merge pull request 'Add basal/bolus/carb/target overlays to Live and Dagoverzicht charts' (#30) from feature/treatment-overlays into feature/insulin-profile
Reviewed-on: #30
2026-09-05 20:44:26 +00:00
willemjan d33516e2a1 Avoid pandas Timedelta(days=1) deprecation warning
pd.Timedelta(days=1) triggers a DeprecationWarning about NumPy's
"generic" timedelta unit in this pandas version; pd.Timedelta(1, unit="D")
is equivalent and warning-free.
2026-09-05 22:38:07 +02:00
willemjan c54dcf616a Add basal/bolus/carb/target overlays to the Live and Dagoverzicht charts
willemjan/glucose-dashboard/pipeline/pr-feature/insulin-profile This commit looks good
Overlay the full insulin picture onto the interactive glucose charts:
basal rate as a semi-transparent step-area (profile schedule overridden
by active temp basal), bolus doses as size-scaled dots split into
food/correction/SMB categories (food and correction labeled with units,
SMB left unlabeled), carbs as bars, and the pump's target glucose as a
line — each with its own color, alpha, and legend entry. Shared trace-
building helpers live in ui_common.py so both pages (and the PDF report
in a follow-up PR) render identically.

Builds on the pump profile work in #29 (profile_data.py, load_profile()).
2026-09-05 22:30:10 +02:00
willemjan 79249958c2 Add pump profile fetching and Insulineprofiel page
willemjan/glucose-dashboard/pipeline/head This commit looks good
Fetch and parse the latest-used Nightscout profile (basal schedule, ISF,
carb ratio, DIA, targets) and show it as a new dashboard page: a list of
basal/ISF/carb-ratio segments plus a bar chart of basal rate by hour of
day. This is the data foundation for treatment overlays on the glucose
charts and the redesigned PDF report in follow-up PRs.
2026-09-05 21:37:17 +02:00
willemjan 8641022219 Merge pull request 'test jenkins' (#28) from dev into main
CI/CD / test (push) Successful in 9s
CI/CD / sonarqube (push) Successful in 22s
CI/CD / build-and-scan (push) Successful in 1m56s
willemjan/glucose-dashboard/pipeline/head Something is wrong with the build of this commit
CI/CD / deploy (push) Successful in 16s
Reviewed-on: #28
2026-08-10 21:20:28 +00:00
willemjan db3204ab58 Merge pull request 'Show estimated HbA1c in mmol/mol (IFCC) alongside % (#25)' (#27) from dev into main
CI/CD / test (push) Successful in 9s
CI/CD / sonarqube (push) Successful in 20s
CI/CD / build-and-scan (push) Successful in 49s
CI/CD / deploy (push) Successful in 3s
2026-08-01 21:13:00 +00:00
willemjan 25c3fe6730 Merge pull request 'GMI trend: daily scatter + rolling average + linear trend/CI (#19)' (#26) from dev into main
CI/CD / test (push) Successful in 10s
CI/CD / sonarqube (push) Successful in 20s
CI/CD / build-and-scan (push) Successful in 43s
CI/CD / deploy (push) Successful in 2s
2026-08-01 20:58:12 +00:00
willemjan d637ad39e4 Merge pull request 'Fix bolus/basal insulin categorization: split SMB from manual corrections (#20)' (#24) from dev into main
CI/CD / test (push) Successful in 9s
CI/CD / sonarqube (push) Successful in 20s
CI/CD / build-and-scan (push) Successful in 40s
CI/CD / deploy (push) Successful in 3s
2026-08-01 20:53:10 +00:00
willemjan 5addd6a49b Merge pull request 'Add TIR bar/threshold lines, Dagoverzicht + hypo/hyper Level-1 charts to PDF (#14, #15, #16, #17, #18)' (#23) from dev into main
CI/CD / test (push) Successful in 9s
CI/CD / sonarqube (push) Successful in 23s
CI/CD / build-and-scan (push) Successful in 42s
CI/CD / deploy (push) Successful in 3s
2026-08-01 20:48:51 +00:00
willemjan 95f006078f Merge pull request 'Add clinical threshold lines + TIR stacked bar chart (#14, #15)' (#22) from dev into main
CI/CD / test (push) Successful in 7s
CI/CD / sonarqube (push) Successful in 21s
CI/CD / build-and-scan (push) Successful in 41s
CI/CD / deploy (push) Successful in 2s
2026-08-01 20:28:23 +00:00
willemjan 4bb9251d7d Merge pull request 'Fix treatments API pagination cursor field (created_at, not date)' (#21) from dev into main
CI/CD / test (push) Successful in 8s
CI/CD / sonarqube (push) Successful in 19s
CI/CD / build-and-scan (push) Successful in 41s
CI/CD / deploy (push) Successful in 3s
2026-08-01 19:43:40 +00:00
willemjan 6c6b175a28 Merge pull request 'Fix multi-day Nightscout fetch truncation + data-completeness warnings' (#12) from dev into main
CI/CD / test (push) Successful in 7s
CI/CD / sonarqube (push) Successful in 20s
CI/CD / build-and-scan (push) Successful in 41s
CI/CD / deploy (push) Successful in 3s
2026-07-31 12:22:10 +00:00
willemjan c39646ceaa Merge pull request 'PDF export (FR10) - completes reporting requirements' (#10) from dev into main
CI/CD / test (push) Successful in 11s
CI/CD / sonarqube (push) Successful in 20s
CI/CD / build-and-scan (push) Successful in 1m58s
CI/CD / deploy (push) Successful in 14s
2026-07-26 19:11:06 +00:00
willemjan 52c9284f85 Merge pull request 'Trends page: GMI trend, weekday pattern, bolus, site-age (FR7, FR8)' (#9) from dev into main
CI/CD / test (push) Successful in 6s
CI/CD / sonarqube (push) Successful in 18s
CI/CD / build-and-scan (push) Successful in 40s
CI/CD / deploy (push) Successful in 4s
2026-07-26 19:06:24 +00:00
willemjan 419508dd5e Merge pull request 'Dagoverzicht + Hypo/Hyper Events pages (FR4, FR5, FR6)' (#8) from dev into main
CI/CD / test (push) Successful in 5s
CI/CD / sonarqube (push) Successful in 17s
CI/CD / build-and-scan (push) Successful in 44s
CI/CD / deploy (push) Successful in 3s
2026-07-26 18:13:56 +00:00
willemjan 689452cf4c Merge pull request 'Samenvatting + AGP Profiel pages (FR2, FR3, FR9)' (#7) from dev into main
CI/CD / test (push) Successful in 6s
CI/CD / sonarqube (push) Successful in 22s
CI/CD / build-and-scan (push) Successful in 47s
CI/CD / deploy (push) Successful in 3s
2026-07-26 18:09:41 +00:00
willemjan e600d6cf7d Merge pull request 'Reporting foundation: multipage nav, auth gate, date-range fetch' (#6) from dev into main
CI/CD / test (push) Successful in 6s
CI/CD / sonarqube (push) Successful in 17s
CI/CD / build-and-scan (push) Successful in 38s
CI/CD / deploy (push) Successful in 3s
2026-07-26 18:01:15 +00:00
willemjan 10aa315e40 Merge pull request 'Pin trivy-action to last docker-based release' (#5) from dev into main
CI/CD / test (push) Successful in 6s
CI/CD / sonarqube (push) Successful in 19s
CI/CD / build-and-scan (push) Successful in 57s
CI/CD / deploy (push) Successful in 19s
2026-07-26 12:54:59 +00:00
willemjan e3443e9f9f Merge pull request 'Fix registry hostname (duckdns) and Trivy github auth' (#4) from dev into main
CI/CD / test (push) Successful in 6s
CI/CD / sonarqube (push) Successful in 20s
CI/CD / build-and-scan (push) Failing after 10s
CI/CD / deploy (push) Skipped
2026-07-25 20:05:33 +00:00
willemjan 274d27c5bc Merge pull request 'Fix pytest module discovery in CI' (#3) from dev into main
CI/CD / test (push) Successful in 5s
CI/CD / sonarqube (push) Successful in 1m17s
CI/CD / build-and-scan (push) Failing after 4m37s
CI/CD / deploy (push) Skipped
2026-07-25 19:12:20 +00:00
willemjan f17e4f6c39 Merge pull request 'Nightscout-koppeling + CI/CD-fixes' (#2) from dev into main
CI/CD / test (push) Failing after 49s
CI/CD / sonarqube (push) Skipped
CI/CD / build-and-scan (push) Skipped
CI/CD / deploy (push) Skipped
2026-07-25 07:30:10 +00:00
17 changed files with 1158 additions and 213 deletions
+3 -11
View File
@@ -1,12 +1,5 @@
FROM python:3.12-slim
ENV HOME=/app \
PYTHONUNBUFFERED=1 \
STREAMLIT_SERVER_PORT=8501 \
STREAMLIT_SERVER_ADDRESS=0.0.0.0 \
STREAMLIT_SERVER_HEADLESS=true \
STREAMLIT_BROWSER_GATHER_USAGE_STATS=false
WORKDIR /app
# Systeem-dependency voor de HEALTHCHECK hieronder
@@ -18,9 +11,8 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN chgrp -R 0 /app && chmod -R g=u /app
USER 1001
EXPOSE 8501
HEALTHCHECK CMD curl --fail http://localhost:8501/_stcore/health || exit 1
CMD ["streamlit", "run", "app.py"]
ENTRYPOINT ["streamlit", "run", "app.py", "--server.port=8501", "--server.address=0.0.0.0"]
Vendored
+3 -95
View File
@@ -1,103 +1,11 @@
pipeline {
agent {
label 'ssh-agent'
}
environment {
REGISTRY = 'gitea.zb-kw.duckdns.org'
IMAGE = "${REGISTRY}/willemjan/glucose-dashboard"
OC_SERVER = 'https://api.rm1.0a51.p1.openshiftapps.com:6443'
OC_PROJECT = 'wulsspaans-dev'
}
options {
buildDiscarder(logRotator(numToKeepStr: '20'))
timeout(time: 20, unit: 'MINUTES')
}
agent any
stages {
stage('Checkout') {
stage('Do nothing') {
steps {
checkout scm
script {
env.TAG = sh(script: 'git rev-parse --short HEAD',
returnStdout: true).trim()
}
}
}
stage('Test') {
steps {
sh '''
pip install --break-system-packages --no-warn-script-location -q -r requirements.txt
python -m pytest -q --junitxml=report.xml
'''
}
post {
always { junit allowEmptyResults: true, testResults: 'report.xml' }
}
}
stage('Build & push image') {
steps {
withCredentials([usernamePassword(
credentialsId: 'gitea_user',
usernameVariable: 'REG_USER',
passwordVariable: 'REG_PASS')]) {
sh '''
echo "$REG_PASS" | docker login $REGISTRY -u "$REG_USER" --password-stdin
docker build -t $IMAGE:$TAG -t $IMAGE:latest .
docker push $IMAGE:$TAG
docker push $IMAGE:latest
'''
}
}
}
stage('Scan') {
steps {
sh '''
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v trivy-cache:/root/.cache/trivy \
aquasec/trivy:latest image \
--severity HIGH,CRITICAL \
--ignore-unfixed \
--exit-code 0 \
$IMAGE:$TAG
'''
}
}
stage('Deploy to OpenShift') {
steps {
withCredentials([string(
credentialsId: 'openshift-token',
variable: 'OC_TOKEN')]) {
sh '''
oc login --token="$OC_TOKEN" --server="$OC_SERVER"
oc project $OC_PROJECT
oc apply -f openshift/service.yaml
oc apply -f openshift/route.yaml
oc apply -f openshift/configmap.yaml
oc apply -f openshift/deployment.yaml
oc set image deployment/glucose-dashboard \
glucose-dashboard=$IMAGE:$TAG
oc rollout status deployment/glucose-dashboard --timeout=5m
'''
}
}
post {
always { sh 'oc logout || true' }
success {
sh 'echo "Deployed: $(oc get route glucose-dashboard -o jsonpath={.spec.host})" || true'
}
sh '/bin/true'
}
}
}
}
+18
View File
@@ -252,3 +252,21 @@ def fetch_treatments_range(start_date: date, end_date: date) -> pd.DataFrame:
since = datetime.combine(start_date, time.min, tzinfo=timezone.utc)
until = datetime.combine(end_date, time.max, tzinfo=timezone.utc)
return _treatments_since(since, until)
def fetch_profile() -> dict:
"""Haal het meest recent gebruikte pompprofiel op.
Nightscout's /api/v1/profile.json geeft profieldocumenten terug, nieuwste
eerst; met count=1 krijgen we alleen het actief gebruikte document. Een
profiel is geen tijdreeks (in tegenstelling tot entries/treatments) dus
geen paginering nodig — één klein verzoek volstaat.
Geeft {} terug als er geen profiel bestaat (bijv. lege Nightscout-instantie).
Het resultaat is het rauwe Nightscout-documument; zie profile_data.parse_profile()
voor de genormaliseerde vorm (basaalschema, ISF, koolhydraatratio, DIA, streefwaarden).
"""
data = _get("/api/v1/profile.json", {"count": 1})
if not data:
return {}
return data[0]
-6
View File
@@ -1,6 +0,0 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: glucose-dashboard-config
data:
NIGHTSCOUT_URL: "https://zb-ns.duckdns.org"
-35
View File
@@ -1,35 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: glucose-dashboard
labels:
app: glucose-dashboard
spec:
replicas: 1
selector:
matchLabels:
app: glucose-dashboard
template:
metadata:
labels:
app: glucose-dashboard
spec:
imagePullSecrets:
- name: gitea-registry
containers:
- name: glucose-dashboard
image: PLACEHOLDER
envFrom:
- configMapRef:
name: glucose-dashboard-config
ports:
- containerPort: 8501
resources:
requests: { cpu: 100m, memory: 256Mi }
limits: { cpu: 500m, memory: 512Mi }
readinessProbe:
httpGet: { path: /_stcore/health, port: 8501 }
initialDelaySeconds: 10
livenessProbe:
httpGet: { path: /_stcore/health, port: 8501 }
initialDelaySeconds: 30
-13
View File
@@ -1,13 +0,0 @@
apiVersion: route.openshift.io/v1
kind: Route
metadata:
name: glucose-dashboard
spec:
to:
kind: Service
name: glucose-dashboard
port:
targetPort: 8501
tls:
termination: edge
insecureEdgeTerminationPolicy: Redirect
-10
View File
@@ -1,10 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: glucose-dashboard
spec:
selector:
app: glucose-dashboard
ports:
- port: 8501
targetPort: 8501
+78 -31
View File
@@ -1,6 +1,8 @@
import pandas as pd
import plotly.graph_objects as go
import requests
import streamlit as st
from plotly.subplots import make_subplots
from auth import require_auth
from data_processing import (
@@ -10,7 +12,15 @@ from data_processing import (
summarize_time_in_range,
)
from nightscout_client import NightscoutConfigError, fetch_entries, fetch_treatments
from ui_common import MMOL_HOVERTEMPLATE, get_colors
from reporting import classify_bolus, effective_basal_series, target_glucose_series
from ui_common import (
MMOL_HOVERTEMPLATE,
add_basal_area_trace,
add_bolus_marker_traces,
add_target_line_trace,
get_colors,
load_profile,
)
st.set_page_config(page_title="Glucose dashboard — Live", page_icon="📊", layout="wide")
require_auth()
@@ -27,7 +37,28 @@ def load_nightscout_data(hours: int):
return glucose, treatments
def build_chart(glucose, insulin, carbs, site_changes, sensor_changes, colors) -> go.Figure:
def _basal_and_target_series(profile: dict, treatments: pd.DataFrame, window_start, window_end):
"""Basaal- en streefwaarde-reeksen over een rollend venster dat meerdere
kalenderdagen kan overspannen: per dag berekend (reporting.effective_basal_series/
target_glucose_series zijn dag-begrensd) en daarna aan elkaar geplakt en
geclipt tot het venster."""
dates = pd.date_range(window_start.normalize(), window_end.normalize(), freq="D")
basal_parts, target_parts = [], []
for day_start in dates:
day_end = day_start + pd.Timedelta(1, unit="D")
basal_parts.append(effective_basal_series(profile, treatments, day_start, day_end))
target_parts.append(target_glucose_series(profile, day_start, day_end))
basal = pd.concat(basal_parts, ignore_index=True) if basal_parts else pd.DataFrame(columns=["timestamp", "rate"])
target = (
pd.concat(target_parts, ignore_index=True) if target_parts else pd.DataFrame(columns=["timestamp", "target"])
)
basal = basal[(basal["timestamp"] >= window_start) & (basal["timestamp"] <= window_end)]
target = target[(target["timestamp"] >= window_start) & (target["timestamp"] <= window_end)]
return basal.reset_index(drop=True), target.reset_index(drop=True)
def build_chart(glucose, bolus, carbs, basal, target, 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
@@ -36,7 +67,7 @@ def build_chart(glucose, insulin, carbs, site_changes, sensor_changes, colors) -
insulin_lane_y = lane_base + 1.5
carbs_lane_y = lane_base + 0.7
fig = go.Figure()
fig = make_subplots(specs=[[{"secondary_y": True}]])
# Streefbereik als achtergrondband, geen losse legenda-entry nodig.
fig.add_hrect(
@@ -45,8 +76,13 @@ def build_chart(glucose, insulin, carbs, site_changes, sensor_changes, colors) -
fillcolor=colors["band"],
line_width=0,
layer="below",
row=1,
col=1,
)
add_basal_area_trace(fig, basal, colors, row=1, secondary_y=True)
add_target_line_trace(fig, target, colors, row=1)
fig.add_trace(
go.Scatter(
x=glucose["timestamp"],
@@ -55,7 +91,9 @@ def build_chart(glucose, insulin, carbs, site_changes, sensor_changes, colors) -
name="Glucose",
line={"color": colors["glucose"], "width": 2, "shape": "spline"},
hovertemplate=MMOL_HOVERTEMPLATE,
)
),
row=1,
col=1,
)
low_points = glucose[glucose["mmol"] < TARGET_LOW]
@@ -69,7 +107,9 @@ def build_chart(glucose, insulin, carbs, site_changes, sensor_changes, colors) -
name=f"Laag (<{TARGET_LOW})",
marker={"color": colors["low"], "size": 6},
hovertemplate=MMOL_HOVERTEMPLATE,
)
),
row=1,
col=1,
)
if not high_points.empty:
fig.add_trace(
@@ -80,21 +120,12 @@ def build_chart(glucose, insulin, carbs, site_changes, sensor_changes, colors) -
name=f"Hoog (>{TARGET_HIGH})",
marker={"color": colors["high"], "size": 6},
hovertemplate=MMOL_HOVERTEMPLATE,
)
),
row=1,
col=1,
)
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>",
)
)
add_bolus_marker_traces(fig, bolus, colors, insulin_lane_y, row=1)
if not carbs.empty:
fig.add_trace(
@@ -106,14 +137,16 @@ def build_chart(glucose, insulin, carbs, site_changes, sensor_changes, colors) -
marker={"color": colors["carbs"], "size": 10, "symbol": "circle"},
customdata=carbs["carbs"],
hovertemplate="%{x|%d-%m %H:%M} · %{customdata:.0f} g<extra></extra>",
)
),
row=1,
col=1,
)
# 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_vline(x=row["timestamp"], line_dash="dot", line_color=colors["muted"], opacity=0.6, row=1, col=1)
fig.add_annotation(
x=row["timestamp"],
y=y_top,
@@ -121,9 +154,11 @@ def build_chart(glucose, insulin, carbs, site_changes, sensor_changes, colors) -
text="Pomp",
showarrow=False,
font={"color": colors["muted"], "size": 10},
row=1,
col=1,
)
for _, row in sensor_changes.iterrows():
fig.add_vline(x=row["timestamp"], line_dash="dash", line_color=colors["muted"], opacity=0.6)
fig.add_vline(x=row["timestamp"], line_dash="dash", line_color=colors["muted"], opacity=0.6, row=1, col=1)
fig.add_annotation(
x=row["timestamp"],
y=y_top,
@@ -131,6 +166,8 @@ def build_chart(glucose, insulin, carbs, site_changes, sensor_changes, colors) -
text="Sensor",
showarrow=False,
font={"color": colors["muted"], "size": 10},
row=1,
col=1,
)
fig.update_layout(
@@ -140,14 +177,18 @@ def build_chart(glucose, insulin, carbs, site_changes, sensor_changes, colors) -
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"]},
)
fig.update_yaxes(
title_text="mmol/L",
range=[y_bottom, y_top],
gridcolor=colors["grid"],
zeroline=False,
row=1,
col=1,
secondary_y=False,
)
fig.update_yaxes(title_text="Basaal (E/uur)", showgrid=False, row=1, col=1, secondary_y=True)
fig.update_xaxes(gridcolor=colors["grid"], row=1, col=1)
return fig
@@ -160,6 +201,7 @@ with st.sidebar:
try:
glucose, treatments = load_nightscout_data(hours)
profile = load_profile()
except NightscoutConfigError as exc:
st.error(str(exc))
st.stop()
@@ -172,7 +214,9 @@ if glucose.empty:
st.stop()
events = classify_treatments(treatments)
bolus = classify_bolus(treatments)
tir = summarize_time_in_range(glucose)
basal, target = _basal_and_target_series(profile, treatments, glucose["timestamp"].min(), glucose["timestamp"].max())
latest = glucose.iloc[-1]
previous = glucose.iloc[-2] if len(glucose) > 1 else None
@@ -192,14 +236,17 @@ colors = get_colors()
fig = build_chart(
glucose,
events["insulin"],
bolus,
events["carbs"],
basal,
target,
events["site_changes"],
events["sensor_changes"],
colors,
)
st.plotly_chart(fig, width="stretch")
st.caption(
"Driehoekjes = insuline · cirkels = koolhydraten · stippellijn = pompwissel · "
"streepjeslijn = sensorwissel"
"Stippen = bolusinsuline (voeding/correctie/SMB, grootte naar dosis) · cirkels = koolhydraten · "
"vlak = basaal · streepstippellijn = streefwaarde uit pompprofiel · "
"stippellijn = pompwissel · streepjeslijn = sensorwissel"
)
+97 -11
View File
@@ -1,14 +1,21 @@
import pandas as pd
import plotly.graph_objects as go
import requests
import streamlit as st
from plotly.subplots import make_subplots
from auth import require_auth
from data_processing import TARGET_HIGH, TARGET_LOW
from data_processing import TARGET_HIGH, TARGET_LOW, classify_treatments
from nightscout_client import NightscoutConfigError
from reporting import classify_bolus, effective_basal_series, target_glucose_series
from ui_common import (
add_basal_area_trace,
add_bolus_marker_traces,
add_carb_bars_trace,
add_target_line_trace,
add_threshold_lines,
get_colors,
load_profile,
load_range_data,
render_completeness_warning,
select_period_days,
@@ -18,7 +25,7 @@ st.set_page_config(page_title="Glucose dashboard — Dagoverzicht", page_icon="
require_auth()
st.title("Dagoverzicht")
st.caption("Eén glucosegrafiek per kalenderdag (FR4).")
st.caption("Eén glucosegrafiek per kalenderdag, met basaal/bolus/koolhydraten-overlay (FR4).")
start, end, days = select_period_days(default_label="14 dagen")
if days > 31:
@@ -28,7 +35,8 @@ if days > 31:
)
try:
glucose, _treatments = load_range_data(start, end)
glucose, treatments = load_range_data(start, end)
profile = load_profile()
except NightscoutConfigError as exc:
st.error(str(exc))
st.stop()
@@ -47,44 +55,122 @@ glucose = glucose.copy()
glucose["date"] = glucose["timestamp"].dt.date
glucose["hour"] = glucose["timestamp"].dt.hour + glucose["timestamp"].dt.minute / 60
carbs_events = classify_treatments(treatments)["carbs"].copy()
if not carbs_events.empty:
carbs_events["date"] = carbs_events["timestamp"].dt.date
carbs_events["hour"] = carbs_events["timestamp"].dt.hour + carbs_events["timestamp"].dt.minute / 60
bolus_events = classify_bolus(treatments)
if not bolus_events.empty:
bolus_events["date"] = bolus_events["timestamp"].dt.date
bolus_events["hour"] = bolus_events["timestamp"].dt.hour + bolus_events["timestamp"].dt.minute / 60
def _close_step_to_day_end(df: pd.DataFrame, value_col: str) -> pd.DataFrame:
"""Voeg een laatste punt op hour=24 toe (zelfde waarde als het laatste
segment) zodat een stapvlak/-lijn de volle dag doortekent i.p.v. te
stoppen bij het laatste breekpunt."""
if df.empty:
return df
last_value = df.iloc[-1][value_col]
closing_row = pd.DataFrame([{"hour": 24.0, value_col: last_value}])
return pd.concat([df, closing_row], ignore_index=True)
dates = sorted(glucose["date"].unique())
row_height = 150
row_height = 190
fig = make_subplots(
rows=len(dates),
cols=1,
shared_xaxes=True,
subplot_titles=[d.strftime("%A %d-%m-%Y") for d in dates],
vertical_spacing=min(0.4 / max(len(dates) - 1, 1), 0.05),
specs=[[{"secondary_y": True}]] * len(dates),
)
for i, day in enumerate(dates, start=1):
day_df = glucose[glucose["date"] == day]
day_start = pd.Timestamp(day)
day_end = day_start + pd.Timedelta(1, unit="D")
fig.add_hrect(
y0=TARGET_LOW, y1=TARGET_HIGH, fillcolor=colors["band"], line_width=0, layer="below", row=i, col=1
)
# Handmatige titel-annotatie t.o.v. het domein van déze rij i.p.v.
# make_subplots' subplot_titles (die t.o.v. de hele figuur positioneert en
# daardoor bij veel dagen de gedeelde legenda bovenaan kan overlappen).
fig.add_annotation(
text=day.strftime("%A %d-%m-%Y"),
xref="x domain",
yref="y domain",
x=0.0,
y=1.15,
xanchor="left",
yanchor="bottom",
showarrow=False,
font={"size": 13, "color": colors["text"]},
row=i,
col=1,
)
basal_day = effective_basal_series(profile, treatments, day_start, day_end)
if not basal_day.empty:
basal_day["hour"] = (basal_day["timestamp"] - day_start).dt.total_seconds() / 3600
basal_day = _close_step_to_day_end(basal_day, "rate")
add_basal_area_trace(fig, basal_day, colors, row=i, secondary_y=True, x_col="hour")
target_day = target_glucose_series(profile, day_start, day_end)
if not target_day.empty:
target_day["hour"] = (target_day["timestamp"] - day_start).dt.total_seconds() / 3600
target_day = _close_step_to_day_end(target_day, "target")
add_target_line_trace(fig, target_day, colors, row=i, x_col="hour")
add_carb_bars_trace(fig, carbs_events[carbs_events["date"] == day], colors, row=i, secondary_y=True, x_col="hour")
lane_y = day_df["mmol"].min() - 1.0 if not day_df.empty else TARGET_LOW - 1.0
add_bolus_marker_traces(fig, bolus_events[bolus_events["date"] == day], colors, lane_y, row=i, x_col="hour")
fig.add_trace(
go.Scatter(
x=day_df["hour"],
y=day_df["mmol"],
mode="lines",
line={"color": colors["glucose"], "width": 2},
showlegend=False,
name="Glucose",
hovertemplate="%{y:.1f} mmol/L<extra></extra>",
),
row=i,
col=1,
secondary_y=False,
)
fig.update_yaxes(title_text="mmol/L", gridcolor=colors["grid"], row=i, col=1, secondary_y=False)
fig.update_yaxes(
title_text="Basaal (E/uur) · KH (g)", showgrid=False, row=i, col=1, secondary_y=True
)
fig.update_yaxes(title_text="mmol/L", gridcolor=colors["grid"], row=i, col=1)
add_threshold_lines(fig, colors, row=i)
# Elke dag herhaalt dezelfde spoornamen (Glucose/Basaal/Koolhydraten/...) — zonder
# deduplicatie zou de legenda 7x "Glucose" etc. tonen. Alleen de eerste keer per
# naam blijft in de legenda staan.
seen_names = set()
for trace in fig.data:
if trace.name in seen_names:
trace.showlegend = False
else:
seen_names.add(trace.name)
fig.update_xaxes(title_text="Uur van de dag", range=[0, 24], dtick=4, gridcolor=colors["grid"])
fig.update_layout(
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font_color=colors["text"],
height=row_height * len(dates),
showlegend=False,
margin={"l": 10, "r": 10, "t": 30, "b": 10},
height=row_height * len(dates) + 40,
showlegend=True,
legend={"orientation": "h", "yanchor": "bottom", "y": 1.0, "xanchor": "left", "x": 0},
margin={"l": 10, "r": 10, "t": 40, "b": 10},
)
st.plotly_chart(fig, width="stretch")
st.caption(f"{len(dates)} dagen · {len(glucose)} metingen")
st.caption(
f"{len(dates)} dagen · {len(glucose)} metingen · stippen = bolusinsuline "
"(voeding/correctie/SMB, grootte naar dosis) · vlak = basaal · staafjes = koolhydraten · "
"streepstippellijn = streefwaarde uit pompprofiel"
)
+98
View File
@@ -0,0 +1,98 @@
import pandas as pd
import plotly.graph_objects as go
import requests
import streamlit as st
from auth import require_auth
from nightscout_client import NightscoutConfigError
from profile_data import segments_table, segments_to_hourly_bars, total_daily_basal
from ui_common import get_colors, load_profile
st.set_page_config(page_title="Glucose dashboard — Insulineprofiel", page_icon="📊", layout="wide")
require_auth()
st.title("Insulineprofiel")
st.caption("Het meest recent gebruikte pompprofiel: basaalschema, ISF, koolhydraatratio en DIA.")
try:
profile = load_profile()
except NightscoutConfigError as exc:
st.error(str(exc))
st.stop()
except (requests.RequestException, ValueError) as exc:
st.error(f"Kon geen profiel ophalen van Nightscout: {exc}")
st.stop()
if not profile:
st.info("Geen pompprofiel gevonden op deze Nightscout-instantie.")
st.stop()
colors = get_colors()
col1, col2 = st.columns(2)
col1.metric("DIA (werkingsduur insuline)", f"{profile['dia']:.1f} uur")
col2.caption(f"Profielnaam: {profile['name'] or '—'}")
st.subheader("Basaalschema")
bars = segments_to_hourly_bars(profile["basal"])
fig = go.Figure(
go.Scatter(
x=[b["hour"] for b in bars],
y=[b["value"] for b in bars],
mode="lines",
line={"shape": "hv", "color": colors["basal"], "width": 2},
fill="tozeroy",
fillcolor=colors["basal"],
opacity=0.7,
name="Basaal",
hovertemplate="%{x:.0f}:00 · %{y:.2f} E/uur<extra></extra>",
)
)
fig.update_layout(
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font_color=colors["text"],
height=320,
margin={"l": 10, "r": 10, "t": 10, "b": 10},
xaxis={"title": "Uur van de dag", "range": [0, 24], "dtick": 2, "gridcolor": colors["grid"]},
yaxis={"title": "E/uur", "gridcolor": colors["grid"], "rangemode": "tozero"},
showlegend=False,
)
st.plotly_chart(fig, width="stretch")
basal_rows = segments_table(profile["basal"])
st.dataframe(
pd.DataFrame(basal_rows).rename(
columns={"start": "Van", "end": "Tot", "value": "Basaalsnelheid (E/uur)"}
),
width="stretch",
hide_index=True,
)
col_isf, col_ratio = st.columns(2)
with col_isf:
st.subheader("Insulinegevoeligheid (ISF)")
isf_rows = segments_table(profile["sens"])
st.dataframe(
pd.DataFrame(isf_rows).rename(
columns={"start": "Van", "end": "Tot", "value": "ISF (mmol/L per E)"}
),
width="stretch",
hide_index=True,
)
with col_ratio:
st.subheader("Koolhydraatratio")
ratio_rows = segments_table(profile["carbratio"])
st.dataframe(
pd.DataFrame(ratio_rows).rename(
columns={"start": "Van", "end": "Tot", "value": "Ratio (g koolhydraten per E)"}
),
width="stretch",
hide_index=True,
)
st.caption(
f"Totale dagelijkse basaal: {total_daily_basal(profile['basal']):.1f} E "
"(volgens profielschema, exclusief tijdelijke aanpassingen)."
)
+146
View File
@@ -0,0 +1,146 @@
"""
Parsing van het Nightscout-pompprofiel (/api/v1/profile): basaalschema,
insulinegevoeligheid (ISF), koolhydraatratio, DIA en streefwaarden.
Framework-vrije module (geen streamlit-import), zelfde opzet als reporting.py:
puur functies op dicts, zodat dit los van de UI te unit-testen is. Zie
nightscout_client.fetch_profile() voor de ruwe fetch; deze module normaliseert
alleen (uren -> minuten sinds middernacht, mg/dl -> mmol/L waar nodig).
"""
from __future__ import annotations
from nightscout_client import mgdl_to_mmol
SEGMENT_FIELDS = ("basal", "sens", "carbratio", "target_low", "target_high")
def _time_to_minutes(time_str: str) -> int:
"""Zet 'HH:MM' (Nightscout's segmenttijd-notatie) om naar minuten sinds middernacht."""
hours, minutes = time_str.split(":")
return int(hours) * 60 + int(minutes)
def _parse_segments(raw_segments: list[dict], *, to_mmol: bool = False) -> list[dict]:
"""Zet Nightscout-segmenten ([{'time': 'HH:MM', 'value': ...}, ...]) om naar
[{'minutes': int, 'value': float}, ...], oplopend gesorteerd op tijd."""
segments = []
for seg in raw_segments:
value = float(seg["value"])
if to_mmol:
value = mgdl_to_mmol(value)
segments.append({"minutes": _time_to_minutes(seg["time"]), "value": value})
return sorted(segments, key=lambda s: s["minutes"])
def parse_profile(raw: dict) -> dict:
"""Parse één Nightscout-profieldocument (zoals teruggegeven door
nightscout_client.fetch_profile()) naar een genormaliseerde dict:
{
"name": str,
"units": "mmol", # altijd genormaliseerd, ongeacht bronformaat
"dia": float, # uur
"basal": [{"minutes": int, "value": float}, ...], # E/uur
"sens": [{"minutes": int, "value": float}, ...], # mmol/L per E (ISF)
"carbratio": [{"minutes": int, "value": float}, ...], # g koolhydraten per E
"target_low": [{"minutes": int, "value": float}, ...], # mmol/L
"target_high": [{"minutes": int, "value": float}, ...], # mmol/L
}
Geeft {} terug als er geen (bruikbaar) profiel is.
"""
if not raw:
return {}
default_name = raw.get("defaultProfile")
store = raw.get("store", {})
profile = store.get(default_name) if default_name else None
if profile is None and store:
# defaultProfile ontbreekt of matcht niet met een sleutel in store:
# val terug op het enige/eerste beschikbare profiel.
profile = next(iter(store.values()))
if profile is None:
return {}
units = str(profile.get("units", "mg/dl")).strip().lower()
to_mmol = units != "mmol"
return {
"name": default_name or "",
"units": "mmol",
"dia": float(profile.get("dia", 0) or 0),
"basal": _parse_segments(profile.get("basal", [])),
"sens": _parse_segments(profile.get("sens", []), to_mmol=to_mmol),
"carbratio": _parse_segments(profile.get("carbratio", [])),
"target_low": _parse_segments(profile.get("target_low", []), to_mmol=to_mmol),
"target_high": _parse_segments(profile.get("target_high", []), to_mmol=to_mmol),
}
def value_at(segments: list[dict], minutes: int) -> float | None:
"""Geef de waarde terug die actief is op het gegeven tijdstip (minuten
sinds middernacht) — het laatste segment met `minutes <= t`. Ligt `t` vóór
het eerste segment van de dag, dan geldt het laatste segment (dat loopt
door vanaf de vorige dag, want een profiel dekt altijd de volle 24 uur).
Geeft None terug als `segments` leeg is.
"""
if not segments:
return None
active = None
for seg in segments:
if seg["minutes"] <= minutes:
active = seg
else:
break
if active is None:
active = segments[-1]
return active["value"]
def _format_time(minutes: int) -> str:
"""Zet minuten sinds middernacht om naar 'HH:MM'."""
return f"{minutes // 60:02d}:{minutes % 60:02d}"
def segments_table(segments: list[dict]) -> list[dict]:
"""Zet segmenten om naar rijen met een start- en eindtijd voor tabelweergave:
[{'start': 'HH:MM', 'end': 'HH:MM', 'value': float}, ...]. Het laatste
segment loopt door tot 24:00 (een profiel dekt altijd de volle dag)."""
rows = []
for i, seg in enumerate(segments):
end_minutes = segments[i + 1]["minutes"] if i + 1 < len(segments) else 24 * 60
rows.append(
{
"start": _format_time(seg["minutes"]),
"end": "24:00" if end_minutes == 24 * 60 else _format_time(end_minutes),
"value": seg["value"],
}
)
return rows
def total_daily_basal(segments: list[dict]) -> float:
"""Totale basaaldosis per dag in E: som van elk segment se duur (uur) x snelheid (E/uur)."""
if not segments:
return 0.0
total = 0.0
for i, seg in enumerate(segments):
end_minutes = segments[i + 1]["minutes"] if i + 1 < len(segments) else 24 * 60
total += seg["value"] * (end_minutes - seg["minutes"]) / 60
return total
def segments_to_hourly_bars(segments: list[dict]) -> list[dict]:
"""Zet segmenten om naar een lijst geschikt voor een staafgrafiek per uur van
de dag: [{'hour': float, 'value': float}, ...], met een extra punt op uur 24
(gelijk aan het eerste segment) zodat een staaf/stap-grafiek de volle dag dekt.
Gebruikt door de basaalgrafiek op de Insulineprofiel-pagina.
"""
if not segments:
return []
bars = [{"hour": seg["minutes"] / 60, "value": seg["value"]} for seg in segments]
bars.append({"hour": 24.0, "value": segments[0]["value"]})
return bars
+161
View File
@@ -15,6 +15,7 @@ import numpy as np
import pandas as pd
from nightscout_client import INSULIN_EVENT_TYPES
from profile_data import value_at
# Klinische drempels in mmol/L (Battelino et al. 2019, Diabetes Care 42(8):1593-1603).
VERY_LOW = 3.0
@@ -440,6 +441,166 @@ def bolus_stats(treatments: pd.DataFrame) -> dict:
}
BOLUS_CATEGORY_FOOD = "food"
BOLUS_CATEGORY_CORRECTION = "correction"
BOLUS_CATEGORY_SMB = "smb"
BOLUS_CATEGORY_COLUMNS = ["timestamp", "eventType", "insulin", "carbs", "category"]
def classify_bolus(treatments: pd.DataFrame) -> pd.DataFrame:
"""Splits bolus-insuline op in 'food'/'correction'/'smb', voor grafiekoverlays
(gitea insulineprofiel-werk, vervolg op bolus_stats()'s SMB-detectie).
Herkenningsregels, in volgorde:
- SMB: `eventType == "SMB"`, of `eventType == "Correction Bolus"` met
`type == "SMB"` (zie bolus_stats()) — automatische AndroidAPS-microbolus.
- food: `eventType == "Meal Bolus"`, of een andere bolus-eventType
(`Bolus`/`Combo Bolus`) met koolhydraten op dezelfde treatment-rij (een
sterke aanwijzing dat het om een maaltijdbolus gaat).
- correction: alle overige bolus-rijen (typisch `Correction Bolus` zonder
SMB-markering, of een generieke `Bolus`/`Combo Bolus` zonder koolhydraten).
Geeft een DataFrame terug met kolommen 'timestamp', 'eventType', 'insulin',
'carbs', 'category' — alleen de rijen die daadwerkelijk bolusinsuline zijn.
"""
if treatments.empty:
return pd.DataFrame(columns=BOLUS_CATEGORY_COLUMNS)
bolus = treatments[treatments["eventType"].isin(INSULIN_EVENT_TYPES) & treatments["insulin"].notna()].copy()
if bolus.empty:
return pd.DataFrame(columns=BOLUS_CATEGORY_COLUMNS)
is_smb = (bolus["eventType"] == SMB_TYPE) | (
(bolus["eventType"] == "Correction Bolus") & (bolus.get("type", "") == SMB_TYPE)
)
has_carbs = pd.to_numeric(bolus["carbs"], errors="coerce").fillna(0) > 0
is_food = ~is_smb & ((bolus["eventType"] == "Meal Bolus") | has_carbs)
bolus["category"] = BOLUS_CATEGORY_CORRECTION
bolus.loc[is_food, "category"] = BOLUS_CATEGORY_FOOD
bolus.loc[is_smb, "category"] = BOLUS_CATEGORY_SMB
return bolus[BOLUS_CATEGORY_COLUMNS].sort_values("timestamp").reset_index(drop=True)
def glucose_min_max(glucose: pd.DataFrame) -> tuple[float, float]:
"""Minimum en maximum glucosewaarde (mmol/L) in de gegeven data."""
if glucose.empty:
return (0.0, 0.0)
return (round(glucose["mmol"].min(), 1), round(glucose["mmol"].max(), 1))
def _temp_basal_intervals(treatments: pd.DataFrame, day_start, day_end) -> list[tuple]:
"""Temp Basal-intervallen (start, eind, rate) die (deels) binnen [day_start, day_end) vallen,
geclipt aan de daggrenzen."""
if treatments is None or treatments.empty:
return []
temp_basal = treatments[
(treatments["eventType"] == BASAL_EVENT_TYPE)
& treatments["absolute"].notna()
& treatments["duration"].notna()
]
intervals = []
for _, row in temp_basal.iterrows():
start = row["timestamp"]
end = start + pd.Timedelta(float(row["duration"]), unit="min")
if end <= day_start or start >= day_end:
continue
intervals.append((max(start, day_start), min(end, day_end), row["absolute"]))
return intervals
def _basal_breakpoints(basal_segments: list[dict], temp_intervals: list[tuple], day_start, day_end) -> list:
"""Alle tijdstippen waarop de effectieve basaalsnelheid kan wijzigen: daggrens,
profielsegment-grenzen, en temp-basal start/eind — oplopend gesorteerd."""
breakpoints = {day_start}
for seg in basal_segments:
ts = day_start + pd.Timedelta(float(seg["minutes"]), unit="min")
if day_start <= ts < day_end:
breakpoints.add(ts)
for start, end, _ in temp_intervals:
breakpoints.add(start)
breakpoints.add(end)
return sorted(t for t in breakpoints if day_start <= t < day_end)
def _rate_at(ts, basal_segments: list[dict], temp_intervals: list[tuple], day_start) -> float:
"""Effectieve basaalsnelheid op tijdstip `ts`: een actieve Temp Basal wint van het profieltempo."""
for start, end, rate in temp_intervals:
if start <= ts < end:
return rate
minutes_of_day = int((ts - day_start).total_seconds() / 60) % MINUTES_PER_DAY
value = value_at(basal_segments, minutes_of_day)
return value if value is not None else 0.0
def effective_basal_series(profile: dict | None, treatments: pd.DataFrame, day_start, day_end) -> pd.DataFrame:
"""Basaalsnelheid (E/uur) als stapfunctie over [day_start, day_end).
Basislijn is het pompprofiel se basaalschema (`profile["basal"]`); waar een
Temp Basal-treatment actief is (rate/duur uit de treatment) overschrijft
die het profieltempo voor de duur van die tijdelijke aanpassing — dit is de
daadwerkelijk afgeleverde basaal, in tegenstelling tot basal_stats() (die
alleen temp-basal-periodes optelt en periodes zonder actieve temp basal
mist omdat het profiel daar niet bij betrokken wordt).
Zonder profiel (`profile` is None/leeg) is de basislijn 0 — dan toont dit
alleen de temp-basal-periodes, net als basal_stats().
Geeft een DataFrame terug met kolommen 'timestamp' en 'rate': elke rij
geldt tot de volgende rij (stapfunctie), geschikt voor een
step-area-plot (line_shape='hv', fill='tozeroy').
"""
day_start = pd.Timestamp(day_start)
day_end = pd.Timestamp(day_end)
basal_segments = (profile or {}).get("basal", [])
temp_intervals = _temp_basal_intervals(treatments, day_start, day_end)
breakpoints = _basal_breakpoints(basal_segments, temp_intervals, day_start, day_end)
rows = [
{"timestamp": ts, "rate": _rate_at(ts, basal_segments, temp_intervals, day_start)} for ts in breakpoints
]
return pd.DataFrame(rows, columns=["timestamp", "rate"])
def _target_midpoint_at(minutes_of_day: int, target_low: list[dict], target_high: list[dict]) -> float | None:
low = value_at(target_low, minutes_of_day) if target_low else None
high = value_at(target_high, minutes_of_day) if target_high else None
if low is not None and high is not None:
return (low + high) / 2
return low if low is not None else high
def target_glucose_series(profile: dict | None, day_start, day_end) -> pd.DataFrame:
"""Streefglucose (mmol/L) uit het profiel als stapfunctie over [day_start, day_end) —
het midden van target_low/target_high per tijdsegment (de pomp kent vaak één
streefwaarde per segment; met losse low/high-segmenten is dat gewoon het
gemiddelde). Geeft een lege DataFrame terug als het profiel geen streefwaarden
bevat.
Zelfde stapfunctie-vorm als effective_basal_series(): elke rij geldt tot de
volgende rij.
"""
day_start = pd.Timestamp(day_start)
day_end = pd.Timestamp(day_end)
target_low = (profile or {}).get("target_low", [])
target_high = (profile or {}).get("target_high", [])
if not target_low and not target_high:
return pd.DataFrame(columns=["timestamp", "target"])
breakpoints = _basal_breakpoints(target_low + target_high, [], day_start, day_end)
rows = [
{
"timestamp": ts,
"target": _target_midpoint_at(
int((ts - day_start).total_seconds() / 60) % MINUTES_PER_DAY, target_low, target_high
),
}
for ts in breakpoints
]
return pd.DataFrame(rows, columns=["timestamp", "target"])
def basal_stats(treatments: pd.DataFrame, period_days: float) -> dict:
"""Totale basaalinsuline uit Temp Basal-treatments (rate x duur), zonder pompprofiel.
+23
View File
@@ -357,6 +357,29 @@ def test_fetch_treatments_range_retrieves_full_history_even_with_a_hidden_server
assert result["timestamp"].max().date() == end_date
def test_fetch_profile_returns_first_document(monkeypatch):
monkeypatch.setenv("NIGHTSCOUT_URL", "https://example.com")
payload = [{"defaultProfile": "Default", "store": {"Default": {"dia": "5"}}}]
seen_params = []
monkeypatch.setattr(
ns.requests,
"get",
lambda *a, **k: (seen_params.append(k["params"]), FakeResponse(payload))[1],
)
result = ns.fetch_profile()
assert result == payload[0]
assert seen_params[0]["count"] == 1
def test_fetch_profile_handles_empty_response(monkeypatch):
monkeypatch.setenv("NIGHTSCOUT_URL", "https://example.com")
monkeypatch.setattr(ns.requests, "get", lambda *a, **k: FakeResponse([]))
assert ns.fetch_profile() == {}
def test_paginated_get_uses_created_at_field_for_treatments_cursor(monkeypatch):
"""Losstaande, gerichte assertie op het daadwerkelijke queryparameter-veld
— moet `find[created_at][$lte]` zijn, nooit `find[date][$lte]`, voor het
+117
View File
@@ -0,0 +1,117 @@
import pytest
import profile_data as pd_
def _raw_profile(units="mg/dl", default_name="Default"):
return {
"defaultProfile": default_name,
"store": {
"Default": {
"units": units,
"dia": "5",
"basal": [
{"time": "00:00", "value": "0.8"},
{"time": "06:00", "value": "1.2"},
{"time": "22:00", "value": "0.7"},
],
"sens": [{"time": "00:00", "value": "50"}],
"carbratio": [{"time": "00:00", "value": "10"}],
"target_low": [{"time": "00:00", "value": "90"}],
"target_high": [{"time": "00:00", "value": "120"}],
}
},
}
def test_parse_profile_returns_empty_dict_for_empty_input():
assert pd_.parse_profile({}) == {}
def test_parse_profile_converts_mgdl_to_mmol():
profile = pd_.parse_profile(_raw_profile(units="mg/dl"))
assert profile["units"] == "mmol"
# 90 mg/dl -> ~5.0 mmol/L, 120 mg/dl -> ~6.7 mmol/L, 50 mg/dl (ISF) -> ~2.8 mmol/L
assert profile["target_low"][0]["value"] == pytest.approx(5.0, abs=0.05)
assert profile["target_high"][0]["value"] == pytest.approx(6.7, abs=0.05)
assert profile["sens"][0]["value"] == pytest.approx(2.8, abs=0.05)
def test_parse_profile_leaves_mmol_profiles_unconverted():
profile = pd_.parse_profile(_raw_profile(units="mmol"))
assert profile["target_low"][0]["value"] == pytest.approx(90.0)
def test_parse_profile_does_not_convert_basal_or_carbratio():
profile = pd_.parse_profile(_raw_profile(units="mg/dl"))
assert profile["basal"][0]["value"] == pytest.approx(0.8)
assert profile["carbratio"][0]["value"] == pytest.approx(10.0)
def test_parse_profile_sorts_basal_segments_by_time():
raw = _raw_profile()
raw["store"]["Default"]["basal"] = [
{"time": "22:00", "value": "0.7"},
{"time": "00:00", "value": "0.8"},
{"time": "06:00", "value": "1.2"},
]
profile = pd_.parse_profile(raw)
assert [seg["minutes"] for seg in profile["basal"]] == [0, 360, 1320]
def test_parse_profile_falls_back_to_first_profile_when_default_name_missing():
raw = _raw_profile(default_name="DoesNotExist")
profile = pd_.parse_profile(raw)
assert profile["dia"] == pytest.approx(5.0)
def test_parse_profile_dia_and_name():
profile = pd_.parse_profile(_raw_profile())
assert profile["dia"] == pytest.approx(5.0)
assert profile["name"] == "Default"
def test_value_at_returns_active_segment():
segments = [{"minutes": 0, "value": 0.8}, {"minutes": 360, "value": 1.2}, {"minutes": 1320, "value": 0.7}]
assert pd_.value_at(segments, 100) == pytest.approx(0.8)
assert pd_.value_at(segments, 360) == pytest.approx(1.2)
assert pd_.value_at(segments, 1000) == pytest.approx(1.2)
assert pd_.value_at(segments, 1350) == pytest.approx(0.7)
def test_value_at_wraps_to_last_segment_before_first():
segments = [{"minutes": 360, "value": 1.2}, {"minutes": 1320, "value": 0.7}]
assert pd_.value_at(segments, 100) == pytest.approx(0.7)
def test_value_at_empty_segments_returns_none():
assert pd_.value_at([], 100) is None
def test_segments_table_computes_end_times_from_next_segment():
segments = [{"minutes": 0, "value": 0.8}, {"minutes": 360, "value": 1.2}]
rows = pd_.segments_table(segments)
assert rows == [
{"start": "00:00", "end": "06:00", "value": 0.8},
{"start": "06:00", "end": "24:00", "value": 1.2},
]
def test_segments_to_hourly_bars_closes_the_24h_loop():
segments = [{"minutes": 0, "value": 0.8}, {"minutes": 360, "value": 1.2}]
bars = pd_.segments_to_hourly_bars(segments)
assert bars[-1] == {"hour": 24.0, "value": 0.8}
def test_segments_to_hourly_bars_empty_input():
assert pd_.segments_to_hourly_bars([]) == []
def test_total_daily_basal_weights_by_segment_duration():
# 6h @ 0.8 + 18h @ 1.2 = 4.8 + 21.6 = 26.4
segments = [{"minutes": 0, "value": 0.8}, {"minutes": 360, "value": 1.2}]
assert pd_.total_daily_basal(segments) == pytest.approx(26.4)
def test_total_daily_basal_empty_input():
assert pd_.total_daily_basal([]) == 0.0
+138
View File
@@ -8,10 +8,13 @@ from reporting import (
agp_percentile_bands,
basal_stats,
bolus_stats,
classify_bolus,
completeness_warning,
daily_gmi,
day_of_week_pattern,
detect_events,
effective_basal_series,
glucose_min_max,
gmi_mmol_per_mol,
gmi_percent,
gmi_trend,
@@ -21,6 +24,7 @@ from reporting import (
sensor_active_percent,
site_interval_stats,
summarize_events,
target_glucose_series,
tir_breakdown_5band,
)
@@ -480,6 +484,140 @@ def test_basal_stats_handles_empty_input():
assert result == {"total_units": 0.0, "avg_units_per_day": 0.0}
def test_glucose_min_max_returns_min_and_max():
glucose = _series([("2026-01-01 00:00", 6.5), ("2026-01-01 01:00", 3.2), ("2026-01-01 02:00", 14.1)])
assert glucose_min_max(glucose) == (3.2, 14.1)
def test_glucose_min_max_handles_empty_input():
empty = pd.DataFrame(columns=["timestamp", "mmol"])
assert glucose_min_max(empty) == (0.0, 0.0)
def _bolus_treatment(dt, event_type, insulin=1.0, carbs=None, bolus_type=""):
return {
"timestamp": pd.Timestamp(dt),
"eventType": event_type,
"insulin": insulin,
"carbs": carbs,
"type": bolus_type,
}
def test_classify_bolus_recognizes_meal_bolus_as_food():
treatments = pd.DataFrame([_bolus_treatment("2026-01-01 08:00", "Meal Bolus", insulin=4.0)])
result = classify_bolus(treatments)
assert result.iloc[0]["category"] == "food"
def test_classify_bolus_recognizes_smb_via_type_field():
treatments = pd.DataFrame(
[_bolus_treatment("2026-01-01 08:00", "Correction Bolus", insulin=0.3, bolus_type="SMB")]
)
result = classify_bolus(treatments)
assert result.iloc[0]["category"] == "smb"
def test_classify_bolus_recognizes_smb_via_event_type():
treatments = pd.DataFrame([_bolus_treatment("2026-01-01 08:00", "SMB", insulin=0.3)])
result = classify_bolus(treatments)
assert result.iloc[0]["category"] == "smb"
def test_classify_bolus_treats_correction_bolus_without_carbs_as_correction():
treatments = pd.DataFrame([_bolus_treatment("2026-01-01 08:00", "Correction Bolus", insulin=1.5)])
result = classify_bolus(treatments)
assert result.iloc[0]["category"] == "correction"
def test_classify_bolus_treats_generic_bolus_with_carbs_as_food():
treatments = pd.DataFrame([_bolus_treatment("2026-01-01 08:00", "Bolus", insulin=3.0, carbs=40.0)])
result = classify_bolus(treatments)
assert result.iloc[0]["category"] == "food"
def test_classify_bolus_ignores_non_bolus_rows():
treatments = pd.DataFrame(
[
_bolus_treatment("2026-01-01 08:00", "Meal Bolus", insulin=4.0),
{"timestamp": pd.Timestamp("2026-01-01 08:05"), "eventType": "Site Change", "insulin": None, "carbs": None, "type": ""},
]
)
result = classify_bolus(treatments)
assert len(result) == 1
def test_classify_bolus_handles_empty_input():
empty = pd.DataFrame(columns=["timestamp", "eventType", "insulin", "carbs", "type"])
result = classify_bolus(empty)
assert result.empty
assert list(result.columns) == ["timestamp", "eventType", "insulin", "carbs", "category"]
def test_effective_basal_series_uses_profile_baseline_without_temp_basal():
profile = {"basal": [{"minutes": 0, "value": 0.8}, {"minutes": 360, "value": 1.2}]}
empty_treatments = pd.DataFrame(columns=["timestamp", "eventType", "absolute", "duration"])
result = effective_basal_series(
profile, empty_treatments, pd.Timestamp("2026-01-01"), pd.Timestamp("2026-01-02")
)
assert result.iloc[0]["rate"] == pytest.approx(0.8)
assert result[result["timestamp"] == pd.Timestamp("2026-01-01 06:00")].iloc[0]["rate"] == pytest.approx(1.2)
def test_effective_basal_series_temp_basal_overrides_profile_rate():
profile = {"basal": [{"minutes": 0, "value": 0.8}]}
treatments = pd.DataFrame(
[{"timestamp": pd.Timestamp("2026-01-01 08:00"), "eventType": "Temp Basal", "absolute": 2.0, "duration": 30}]
)
result = effective_basal_series(profile, treatments, pd.Timestamp("2026-01-01"), pd.Timestamp("2026-01-02"))
during_temp = result[result["timestamp"] == pd.Timestamp("2026-01-01 08:00")].iloc[0]
after_temp = result[result["timestamp"] == pd.Timestamp("2026-01-01 08:30")].iloc[0]
assert during_temp["rate"] == pytest.approx(2.0)
assert after_temp["rate"] == pytest.approx(0.8) # terug naar het profieltempo
def test_effective_basal_series_without_profile_defaults_to_zero_baseline():
empty_treatments = pd.DataFrame(columns=["timestamp", "eventType", "absolute", "duration"])
result = effective_basal_series(None, empty_treatments, pd.Timestamp("2026-01-01"), pd.Timestamp("2026-01-02"))
assert result.iloc[0]["rate"] == pytest.approx(0.0)
def test_effective_basal_series_clips_temp_basal_to_day_boundaries():
profile = {"basal": [{"minutes": 0, "value": 0.8}]}
# Temp basal begint de dag ervoor en loopt door tot 01:00 van de dag zelf.
treatments = pd.DataFrame(
[{"timestamp": pd.Timestamp("2025-12-31 23:00"), "eventType": "Temp Basal", "absolute": 2.0, "duration": 120}]
)
result = effective_basal_series(profile, treatments, pd.Timestamp("2026-01-01"), pd.Timestamp("2026-01-02"))
assert result.iloc[0]["timestamp"] == pd.Timestamp("2026-01-01")
assert result.iloc[0]["rate"] == pytest.approx(2.0)
def test_target_glucose_series_averages_low_and_high():
profile = {
"target_low": [{"minutes": 0, "value": 4.5}],
"target_high": [{"minutes": 0, "value": 8.5}],
}
result = target_glucose_series(profile, pd.Timestamp("2026-01-01"), pd.Timestamp("2026-01-02"))
assert result.iloc[0]["target"] == pytest.approx(6.5)
def test_target_glucose_series_falls_back_to_single_bound():
profile = {"target_low": [{"minutes": 0, "value": 5.0}], "target_high": []}
result = target_glucose_series(profile, pd.Timestamp("2026-01-01"), pd.Timestamp("2026-01-02"))
assert result.iloc[0]["target"] == pytest.approx(5.0)
def test_target_glucose_series_empty_without_targets_in_profile():
result = target_glucose_series({}, pd.Timestamp("2026-01-01"), pd.Timestamp("2026-01-02"))
assert result.empty
def test_target_glucose_series_handles_none_profile():
result = target_glucose_series(None, pd.Timestamp("2026-01-01"), pd.Timestamp("2026-01-02"))
assert result.empty
def test_site_interval_stats_splits_by_site_change():
glucose = _series(
[
+101
View File
@@ -1,8 +1,13 @@
import pandas as pd
import plotly.graph_objects as go
from ui_common import (
PALETTE,
THRESHOLD_LINES,
add_basal_area_trace,
add_bolus_marker_traces,
add_carb_bars_trace,
add_target_line_trace,
add_threshold_lines,
build_tir_stacked_bar_figure,
hex_to_rgba,
@@ -36,3 +41,99 @@ def test_build_tir_stacked_bar_figure_has_one_bar_trace_per_band():
assert len(fig.data) == 5
assert [trace.y[0] for trace in fig.data] == [1.0, 3.0, 80.0, 12.0, 4.0]
assert fig.layout.barmode == "stack"
def test_add_basal_area_trace_adds_one_filled_step_trace():
basal = pd.DataFrame(
{"timestamp": pd.to_datetime(["2026-01-01 00:00", "2026-01-01 06:00"]), "rate": [0.8, 1.2]}
)
fig = go.Figure()
add_basal_area_trace(fig, basal, PALETTE["light"])
assert len(fig.data) == 1
assert fig.data[0].name == "Basaal"
assert fig.data[0].fill == "tozeroy"
assert fig.data[0].line.shape == "hv"
def test_add_basal_area_trace_skips_empty_input():
fig = go.Figure()
add_basal_area_trace(fig, pd.DataFrame(columns=["timestamp", "rate"]), PALETTE["light"])
assert len(fig.data) == 0
def test_add_carb_bars_trace_adds_one_bar_trace():
carbs = pd.DataFrame({"timestamp": pd.to_datetime(["2026-01-01 08:00"]), "carbs": [40.0]})
fig = go.Figure()
add_carb_bars_trace(fig, carbs, PALETTE["light"])
assert len(fig.data) == 1
assert fig.data[0].name == "Koolhydraten"
assert isinstance(fig.data[0], go.Bar)
def test_add_bolus_marker_traces_adds_one_trace_per_category_present():
bolus = pd.DataFrame(
{
"timestamp": pd.to_datetime(["2026-01-01 08:00", "2026-01-01 12:00"]),
"insulin": [4.0, 0.3],
"category": ["food", "smb"],
}
)
fig = go.Figure()
add_bolus_marker_traces(fig, bolus, PALETTE["light"], lane_y=2.0)
names = {trace.name for trace in fig.data}
assert names == {"Bolus (voeding)", "SMB"}
def test_add_bolus_marker_traces_labels_food_and_correction_but_not_smb():
bolus = pd.DataFrame(
{
"timestamp": pd.to_datetime(["2026-01-01 08:00", "2026-01-01 12:00"]),
"insulin": [4.0, 0.3],
"category": ["food", "smb"],
}
)
fig = go.Figure()
add_bolus_marker_traces(fig, bolus, PALETTE["light"], lane_y=2.0)
by_name = {trace.name: trace for trace in fig.data}
assert by_name["Bolus (voeding)"].mode == "markers+text"
assert by_name["SMB"].mode == "markers"
def test_add_bolus_marker_traces_skips_empty_input():
fig = go.Figure()
add_bolus_marker_traces(fig, pd.DataFrame(columns=["timestamp", "insulin", "category"]), PALETTE["light"], 2.0)
assert len(fig.data) == 0
def test_add_target_line_trace_adds_one_line_trace():
target = pd.DataFrame({"timestamp": pd.to_datetime(["2026-01-01 00:00"]), "target": [6.5]})
fig = go.Figure()
add_target_line_trace(fig, target, PALETTE["light"])
assert len(fig.data) == 1
assert fig.data[0].name == "Streefwaarde (pomp)"
def test_add_target_line_trace_skips_empty_input():
fig = go.Figure()
add_target_line_trace(fig, pd.DataFrame(columns=["timestamp", "target"]), PALETTE["light"])
assert len(fig.data) == 0
def test_overlay_traces_support_an_hour_based_x_column_for_dagoverzicht():
"""Dagoverzicht plot elke dag op een gedeelde 0-24u-as ('hour') i.p.v. echte
datums; de hover mag daar dan ook geen datumformattering tonen."""
basal = pd.DataFrame({"hour": [0.0, 6.0], "rate": [0.8, 1.2]})
carbs = pd.DataFrame({"hour": [8.0], "carbs": [40.0]})
bolus = pd.DataFrame({"hour": [8.0], "insulin": [4.0], "category": ["food"]})
target = pd.DataFrame({"hour": [0.0], "target": [6.5]})
fig = go.Figure()
add_basal_area_trace(fig, basal, PALETTE["light"], x_col="hour")
add_carb_bars_trace(fig, carbs, PALETTE["light"], x_col="hour")
add_bolus_marker_traces(fig, bolus, PALETTE["light"], lane_y=2.0, x_col="hour")
add_target_line_trace(fig, target, PALETTE["light"], x_col="hour")
assert len(fig.data) == 4
bolus_trace = next(t for t in fig.data if t.name == "Bolus (voeding)")
assert list(bolus_trace.x) == [8.0]
assert "%d-%m" not in bolus_trace.hovertemplate
+175 -1
View File
@@ -14,7 +14,8 @@ import pandas as pd
import plotly.graph_objects as go
import streamlit as st
from nightscout_client import fetch_entries_range, fetch_treatments_range
from nightscout_client import fetch_entries_range, fetch_profile, fetch_treatments_range
from profile_data import parse_profile
from reporting import (
HIGH,
LOW,
@@ -57,6 +58,11 @@ PALETTE = {
"tir_in_range": "#0ca30c",
"tir_high": "#fab219",
"tir_very_high": "#c76f0a",
"basal": "#7b5ea3",
"bolus_food": "#1baf7a",
"bolus_correction": "#2a78d6",
"bolus_smb": "#898781",
"target": "#52514e",
},
"dark": {
"glucose": "#3987e5",
@@ -76,6 +82,11 @@ PALETTE = {
"tir_in_range": "#0ca30c",
"tir_high": "#fab219",
"tir_very_high": "#d9852e",
"basal": "#9b7ec2",
"bolus_food": "#199e70",
"bolus_correction": "#3987e5",
"bolus_smb": "#898781",
"target": "#c3c2b7",
},
}
@@ -129,6 +140,15 @@ def load_range_data(start: date, end: date):
return glucose, treatments
@st.cache_data(ttl=21600, show_spinner="Profiel ophalen van Nightscout...")
def load_profile() -> dict:
"""Gecachete profiel-fetch. Langere TTL dan load_range_data (6 uur i.p.v.
1 uur): een pompprofiel wijzigt zelden, in tegenstelling tot glucose-/
behandeldata van een lopende periode."""
raw = fetch_profile()
return parse_profile(raw)
def add_threshold_lines(fig: go.Figure, colors: dict, row: int | None = None) -> None:
"""Teken de 4 klinische drempellijnen (3.0/3.9/10.0/13.9 mmol/L) op een grafiek.
@@ -189,6 +209,160 @@ def build_tir_stacked_bar_figure(tir: dict, colors: dict) -> go.Figure:
return fig
BOLUS_CATEGORY_LABELS = {
"food": "Bolus (voeding)",
"correction": "Bolus (correctie)",
"smb": "SMB",
}
BOLUS_CATEGORY_COLOR_KEYS = {
"food": "bolus_food",
"correction": "bolus_correction",
"smb": "bolus_smb",
}
# Tekenvolgorde van bolus-categorieën: SMB eerst (onderop, meestal talrijker en
# kleiner), voeding/correctie erbovenop (grotere, gelabelde stippen blijven
# zichtbaar in plaats van bedolven te raken onder de SMB-stippen).
BOLUS_CATEGORY_DRAW_ORDER = ["smb", "correction", "food"]
def add_basal_area_trace(
fig: go.Figure,
basal: pd.DataFrame,
colors: dict,
*,
row: int | None = None,
secondary_y: bool = False,
x_col: str = "timestamp",
) -> None:
"""Basaal-insuline als semi-transparant stapvlak (line_shape='hv'), met alpha
zodat onderliggende data zichtbaar blijft. Verwacht kolommen 'rate' en
`x_col` (standaard 'timestamp'; de Dagoverzicht-pagina geeft hier 'hour'
aan omdat elke dag daar op een gedeelde 0-24u-as staat i.p.v. echte
datums — zie reporting.effective_basal_series). Bedoeld voor een
secundaire y-as (E/uur past niet op de mmol/L-schaal van de glucoselijn).
"""
if basal.empty:
return
kwargs: dict = {"row": row, "col": 1} if row is not None else {}
if secondary_y:
kwargs["secondary_y"] = True
fig.add_trace(
go.Scatter(
x=basal[x_col],
y=basal["rate"],
mode="lines",
line={"shape": "hv", "color": colors["basal"], "width": 1},
fill="tozeroy",
fillcolor=hex_to_rgba(colors["basal"], 0.3),
opacity=0.8,
name="Basaal",
hovertemplate="%{y:.2f} E/uur<extra></extra>",
),
**kwargs,
)
def add_carb_bars_trace(
fig: go.Figure,
carbs: pd.DataFrame,
colors: dict,
*,
row: int | None = None,
secondary_y: bool = False,
x_col: str = "timestamp",
) -> None:
"""Koolhydraten als staafjes op het moment van invoer. Verwacht kolommen
'carbs' en `x_col` (zie add_basal_area_trace voor waarom dit configureerbaar
is). Zelfde secundaire as als de basaal-area (allebei geen mmol/L-grootheid),
met een eigen kleur en alpha zodat beide onderscheidbaar blijven."""
if carbs.empty:
return
kwargs: dict = {"row": row, "col": 1} if row is not None else {}
if secondary_y:
kwargs["secondary_y"] = True
fig.add_trace(
go.Bar(
x=carbs[x_col],
y=carbs["carbs"],
marker_color=colors["carbs"],
opacity=0.55,
name="Koolhydraten",
hovertemplate="%{y:.0f} g<extra></extra>",
),
**kwargs,
)
def add_bolus_marker_traces(
fig: go.Figure,
bolus: pd.DataFrame,
colors: dict,
lane_y: float,
*,
row: int | None = None,
x_col: str = "timestamp",
) -> None:
"""Eén Scatter-trace per boluscategorie (food/correction/smb), gepositioneerd
op een vaste 'lane'-hoogte op de primaire (mmol/L-)as — net als de bestaande
insuline/carbs-lanes op de Live-pagina. De hoogte zelf draagt geen
kwantitatieve betekenis; de markergrootte (geschaald naar toegediende
eenheden) en hoverwaarde wel. Voeding/correctie krijgen een tekstlabel met
de dosis; SMB's niet (te talrijk/te klein voor leesbare labels). Verwacht
kolommen 'insulin'/'category' en `x_col` (zie reporting.classify_bolus).
"""
# Datumformattering in de hover werkt alleen op een echte datetime-as; de
# Dagoverzicht-pagina geeft hier 'hour' (een kaal getal 0-24) aan, dus
# daar laten we het tijdstip weg en toont de hover alleen de dosis.
hover_prefix = "%{x|%d-%m %H:%M} · " if x_col == "timestamp" else ""
kwargs: dict = {"row": row, "col": 1} if row is not None else {}
for category in BOLUS_CATEGORY_DRAW_ORDER:
subset = bolus[bolus["category"] == category] if not bolus.empty else bolus
if subset.empty:
continue
show_label = category != "smb"
fig.add_trace(
go.Scatter(
x=subset[x_col],
y=[lane_y] * len(subset),
mode="markers+text" if show_label else "markers",
marker={
"size": (subset["insulin"] * 8).clip(lower=6, upper=30),
"color": colors[BOLUS_CATEGORY_COLOR_KEYS[category]],
"opacity": 0.75,
},
text=[f"{v:.1f}E" for v in subset["insulin"]] if show_label else None,
textposition="top center",
textfont={"size": 9, "color": colors["text"]},
name=BOLUS_CATEGORY_LABELS[category],
customdata=subset["insulin"],
hovertemplate=f"{hover_prefix}%{{customdata:.2f}} E<extra></extra>",
),
**kwargs,
)
def add_target_line_trace(
fig: go.Figure, target: pd.DataFrame, colors: dict, *, row: int | None = None, x_col: str = "timestamp"
) -> None:
"""Streefglucose uit het pompprofiel als lijn op de primaire (mmol/L-)as.
Verwacht kolommen 'target' en `x_col` (zie reporting.target_glucose_series)."""
if target.empty:
return
kwargs: dict = {"row": row, "col": 1} if row is not None else {}
fig.add_trace(
go.Scatter(
x=target[x_col],
y=target["target"],
mode="lines",
line={"shape": "hv", "color": colors["target"], "width": 1.5, "dash": "dashdot"},
opacity=0.9,
name="Streefwaarde (pomp)",
hovertemplate="%{y:.1f} mmol/L<extra></extra>",
),
**kwargs,
)
def render_completeness_warning(glucose: pd.DataFrame, start: date, end: date) -> None:
"""Toon reporting.completeness_warning()'s oordeel als st.warning, indien nodig.