Author SHA1 Message Date
willemjan c4ae6fc037 test indent issue
willemjan/glucose-dashboard/pipeline/head This commit looks good
2026-08-28 21:51:40 +02:00
willemjan 11489bccdd removed secrets
willemjan/glucose-dashboard/pipeline/head There was a failure building this commit
2026-08-28 21:47:39 +02:00
willemjan 53bc8a0121 fix configmap in jenkins
willemjan/glucose-dashboard/pipeline/head There was a failure building this commit
2026-08-28 21:45:41 +02:00
willemjan 8dd3d5acea added secrets and env variables to openshift deployment
willemjan/glucose-dashboard/pipeline/head There was a failure building this commit
2026-08-28 21:43:16 +02:00
willemjan 0fb9a0c0a2 fix oc_project
willemjan/glucose-dashboard/pipeline/head This commit looks good
2026-08-28 14:42:03 +02:00
willemjan 0017d14936 fix test issue
willemjan/glucose-dashboard/pipeline/head There was a failure building this commit
2026-08-28 14:33:13 +02:00
willemjan 17279b69f1 updated test stage
willemjan/glucose-dashboard/pipeline/head There was a failure building this commit
2026-08-28 14:29:49 +02:00
willemjan ae0c736ea7 updated scan stage
willemjan/glucose-dashboard/pipeline/head There was a failure building this commit
2026-08-28 14:24:48 +02:00
willemjan 1f2b0d917a test
willemjan/glucose-dashboard/pipeline/head There was a failure building this commit
2026-08-15 22:20:12 +02:00
willemjan 908fd6f730 req dev file removed
willemjan/glucose-dashboard/pipeline/head There was a failure building this commit
2026-08-13 23:58:57 +02:00
willemjan c2bbc5a753 test in own container
willemjan/glucose-dashboard/pipeline/head There was a failure building this commit
2026-08-13 23:56:28 +02:00
willemjan abf0c95bac roll back
willemjan/glucose-dashboard/pipeline/head There was a failure building this commit
2026-08-13 23:49:47 +02:00
willemjan 76f27f674d any agent
willemjan/glucose-dashboard/pipeline/head There was a failure building this commit
2026-08-13 23:42:45 +02:00
willemjan 3e18aadc8f updated commenting
willemjan/glucose-dashboard/pipeline/head Something is wrong with the build of this commit
2026-08-13 23:31:33 +02:00
willemjan 290022ab8e test
willemjan/glucose-dashboard/pipeline/head There was a failure building this commit
2026-08-13 23:24:16 +02:00
willemjan e8159771de test scratch pipeline
willemjan/glucose-dashboard/pipeline/head There was a failure building this commit
2026-08-13 22:30:37 +02:00
willemjan 3c534fec3a updated credentials Id
willemjan/glucose-dashboard/pipeline/head There was a failure building this commit
2026-08-13 22:24:38 +02:00
willemjan 7c729f38ec test
willemjan/glucose-dashboard/pipeline/head There was a failure building this commit
2026-08-11 01:01:30 +02:00
willemjan 164ac5e42d test openshigt
willemjan/glucose-dashboard/pipeline/head There was a failure building this commit
2026-08-11 00:28:56 +02:00
23 changed files with 315 additions and 1878 deletions
-6
View File
@@ -15,9 +15,3 @@ NIGHTSCOUT_TOKEN=
# het diabetesteam). Zet hier een sha256-hash, NIET het wachtwoord zelf:
# python -c "import hashlib; print(hashlib.sha256(b'jouw-wachtwoord').hexdigest())"
DASHBOARD_PASSWORD_HASH=
# Optioneel: apparatuurinfo voor de kop van het PDF-rapport (pages/8_Export.py).
# Puur informatief, geen effect op de berekeningen — leeg = "Onbekend ...".
REPORT_INSULIN_TYPE=Lyumjev 100U
REPORT_PUMP_TYPE=
REPORT_SENSOR_TYPE=
+11 -3
View File
@@ -1,5 +1,12 @@
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
@@ -11,8 +18,9 @@ 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
ENTRYPOINT ["streamlit", "run", "app.py", "--server.port=8501", "--server.address=0.0.0.0"]
CMD ["streamlit", "run", "app.py"]
Vendored
+95 -3
View File
@@ -1,11 +1,103 @@
pipeline {
agent any
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')
}
stages {
stage('Do nothing') {
stage('Checkout') {
steps {
sh '/bin/true'
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'
}
}
}
}
}
+1 -2
View File
@@ -16,8 +16,7 @@ klinische rapportagepagina's (zie `docs/requirements-reporting.md` en
diabetesteam, dus elke pagina zit achter een wachtwoord). `app.py` laadt
`.env` zelf in (via `python-dotenv`), dus dit werkt hetzelfde onder
PowerShell, cmd of bash — je hoeft de variabelen niet zelf in je shell te
zetten. `REPORT_INSULIN_TYPE`/`REPORT_PUMP_TYPE`/`REPORT_SENSOR_TYPE` zijn
optioneel en puur informatief (getoond in de kop van het PDF-rapport).
zetten.
3. Lokaal draaien:
```powershell
-4
View File
@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<path d="M32 4 C20 22, 10 34, 10 46 A22 22 0 0 0 54 46 C54 34, 44 22, 32 4 Z" fill="#2a78d6"/>
<polyline points="14,42 24,42 28,32 34,52 40,38 46,42 50,42" fill="none" stroke="#ffffff" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 325 B

-18
View File
@@ -252,21 +252,3 @@ 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
@@ -0,0 +1,6 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: glucose-dashboard-config
data:
NIGHTSCOUT_URL: "https://zb-ns.duckdns.org"
+35
View File
@@ -0,0 +1,35 @@
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
@@ -0,0 +1,13 @@
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
@@ -0,0 +1,10 @@
apiVersion: v1
kind: Service
metadata:
name: glucose-dashboard
spec:
selector:
app: glucose-dashboard
ports:
- port: 8501
targetPort: 8501
+31 -78
View File
@@ -1,8 +1,6 @@
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 (
@@ -12,15 +10,7 @@ from data_processing import (
summarize_time_in_range,
)
from nightscout_client import NightscoutConfigError, fetch_entries, fetch_treatments
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,
)
from ui_common import MMOL_HOVERTEMPLATE, get_colors
st.set_page_config(page_title="Glucose dashboard — Live", page_icon="📊", layout="wide")
require_auth()
@@ -37,28 +27,7 @@ def load_nightscout_data(hours: int):
return glucose, treatments
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:
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
@@ -67,7 +36,7 @@ def build_chart(glucose, bolus, carbs, basal, target, site_changes, sensor_chang
insulin_lane_y = lane_base + 1.5
carbs_lane_y = lane_base + 0.7
fig = make_subplots(specs=[[{"secondary_y": True}]])
fig = go.Figure()
# Streefbereik als achtergrondband, geen losse legenda-entry nodig.
fig.add_hrect(
@@ -76,13 +45,8 @@ def build_chart(glucose, bolus, carbs, basal, target, site_changes, sensor_chang
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"],
@@ -91,9 +55,7 @@ def build_chart(glucose, bolus, carbs, basal, target, site_changes, sensor_chang
name="Glucose",
line={"color": colors["glucose"], "width": 2, "shape": "spline"},
hovertemplate=MMOL_HOVERTEMPLATE,
),
row=1,
col=1,
)
)
low_points = glucose[glucose["mmol"] < TARGET_LOW]
@@ -107,9 +69,7 @@ def build_chart(glucose, bolus, carbs, basal, target, site_changes, sensor_chang
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(
@@ -120,12 +80,21 @@ def build_chart(glucose, bolus, carbs, basal, target, site_changes, sensor_chang
name=f"Hoog (>{TARGET_HIGH})",
marker={"color": colors["high"], "size": 6},
hovertemplate=MMOL_HOVERTEMPLATE,
),
row=1,
col=1,
)
)
add_bolus_marker_traces(fig, bolus, colors, insulin_lane_y, row=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>",
)
)
if not carbs.empty:
fig.add_trace(
@@ -137,16 +106,14 @@ def build_chart(glucose, bolus, carbs, basal, target, site_changes, sensor_chang
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, row=1, col=1)
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,
@@ -154,11 +121,9 @@ def build_chart(glucose, bolus, carbs, basal, target, site_changes, sensor_chang
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, row=1, col=1)
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,
@@ -166,8 +131,6 @@ def build_chart(glucose, bolus, carbs, basal, target, site_changes, sensor_chang
text="Sensor",
showarrow=False,
font={"color": colors["muted"], "size": 10},
row=1,
col=1,
)
fig.update_layout(
@@ -177,18 +140,14 @@ def build_chart(glucose, bolus, carbs, basal, target, site_changes, sensor_chang
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
@@ -201,7 +160,6 @@ with st.sidebar:
try:
glucose, treatments = load_nightscout_data(hours)
profile = load_profile()
except NightscoutConfigError as exc:
st.error(str(exc))
st.stop()
@@ -214,9 +172,7 @@ 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
@@ -236,17 +192,14 @@ colors = get_colors()
fig = build_chart(
glucose,
bolus,
events["insulin"],
events["carbs"],
basal,
target,
events["site_changes"],
events["sensor_changes"],
colors,
)
st.plotly_chart(fig, width="stretch")
st.caption(
"Stippen = bolusinsuline (voeding/correctie/SMB, grootte naar dosis) · cirkels = koolhydraten · "
"vlak = basaal · streepstippellijn = streefwaarde uit pompprofiel · "
"stippellijn = pompwissel · streepjeslijn = sensorwissel"
"Driehoekjes = insuline · cirkels = koolhydraten · stippellijn = pompwissel · "
"streepjeslijn = sensorwissel"
)
+11 -97
View File
@@ -1,21 +1,14 @@
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, classify_treatments
from data_processing import TARGET_HIGH, TARGET_LOW
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,
@@ -25,7 +18,7 @@ st.set_page_config(page_title="Glucose dashboard — Dagoverzicht", page_icon="
require_auth()
st.title("Dagoverzicht")
st.caption("Eén glucosegrafiek per kalenderdag, met basaal/bolus/koolhydraten-overlay (FR4).")
st.caption("Eén glucosegrafiek per kalenderdag (FR4).")
start, end, days = select_period_days(default_label="14 dagen")
if days > 31:
@@ -35,8 +28,7 @@ if days > 31:
)
try:
glucose, treatments = load_range_data(start, end)
profile = load_profile()
glucose, _treatments = load_range_data(start, end)
except NightscoutConfigError as exc:
st.error(str(exc))
st.stop()
@@ -55,122 +47,44 @@ 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 = 190
row_height = 150
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},
name="Glucose",
showlegend=False,
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) + 40,
showlegend=True,
legend={"orientation": "h", "yanchor": "bottom", "y": 1.0, "xanchor": "left", "x": 0},
margin={"l": 10, "r": 10, "t": 40, "b": 10},
height=row_height * len(dates),
showlegend=False,
margin={"l": 10, "r": 10, "t": 30, "b": 10},
)
st.plotly_chart(fig, width="stretch")
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"
)
st.caption(f"{len(dates)} dagen · {len(glucose)} metingen")
+3 -9
View File
@@ -5,12 +5,7 @@ from auth import require_auth
from nightscout_client import NightscoutConfigError
from pdf_export import AVAILABLE_SECTIONS, SECTION_EVENTS, build_report_pdf
from reporting import HIGH, LOW, VERY_HIGH, VERY_LOW, detect_events, summarize_events
from ui_common import (
load_profile,
load_range_data,
render_completeness_warning,
select_period_days,
)
from ui_common import load_range_data, render_completeness_warning, select_period_days
st.set_page_config(page_title="Glucose dashboard — Export", page_icon="📊", layout="wide")
require_auth()
@@ -22,8 +17,7 @@ start, end, days = select_period_days(default_label="90 dagen")
sections = st.multiselect("Onderdelen", AVAILABLE_SECTIONS, default=AVAILABLE_SECTIONS)
try:
glucose, treatments = load_range_data(start, end)
profile = load_profile()
glucose, _treatments = load_range_data(start, end)
except NightscoutConfigError as exc:
st.error(str(exc))
st.stop()
@@ -49,7 +43,7 @@ if SECTION_EVENTS in sections:
events = detect_events(glucose, threshold, direction)
events_summary[label] = summarize_events(events, days)
pdf_bytes = build_report_pdf(sections, glucose, events_summary, start, end, treatments=treatments, profile=profile)
pdf_bytes = build_report_pdf(sections, glucose, events_summary, start, end)
st.download_button(
"Download PDF",
-98
View File
@@ -1,98 +0,0 @@
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)."
)
+98 -365
View File
@@ -2,23 +2,15 @@
PDF-rapportgeneratie voor geselecteerde rapportageonderdelen (FR10).
Gebruikt fpdf2 (puur Python, geen systeemdependencies) voor de PDF-opbouw en
matplotlib voor de statische grafieken specifiek voor de PDF — bewust niet
dezelfde Plotly-figuren als op de pagina's zelf (die zouden kaleido vereisen,
met een ingebakken Chromium-build: een groter image en meer Trivy-oppervlak,
voor dit doel niet nodig).
Elke sectie is één (of, voor Dagoverzicht, meerdere) liggende A4-pagina('s)
met een vaste kop-/voettekst (device-info, paginanummering, genereerdatum,
Nightscout-link) via de GlucoseReportPDF-subklasse hieronder — geïnspireerd op
de LibreView/AGP-rapportopmaak (zie docs/design-reporting.md).
matplotlib voor een eenvoudige statische AGP-grafiek specifiek voor de PDF —
bewust niet dezelfde Plotly-figuren als op de pagina's zelf (die zouden
kaleido vereisen, met een ingebakken Chromium-build: een groter image en meer
Trivy-oppervlak, voor dit doel niet nodig).
"""
from __future__ import annotations
import io
import os
from datetime import datetime, timezone
from pathlib import Path
import matplotlib
@@ -28,54 +20,34 @@ import pandas as pd
from fpdf import FPDF
from fpdf.enums import XPos, YPos
from profile_data import segments_table, segments_to_hourly_bars, total_daily_basal
from reporting import (
BOLUS_CATEGORY_CORRECTION,
BOLUS_CATEGORY_FOOD,
BOLUS_CATEGORY_SMB,
HIGH,
LOW,
VERY_HIGH,
VERY_LOW,
agp_percentile_bands,
classify_bolus,
completeness_warning,
daily_stats,
detect_events,
effective_basal_series,
glucose_min_max,
gmi_mmol_per_mol,
gmi_percent,
percent_cv,
sensor_active_percent,
target_glucose_series,
tir_breakdown_5band,
)
SECTION_SUMMARY = "Samenvatting"
SECTION_AGP = "AGP profiel"
SECTION_INSULIN_PROFILE = "Insulineprofiel"
SECTION_DAGOVERZICHT = "Dagoverzicht"
SECTION_EVENTS = "Hypo-/hyperevents"
AVAILABLE_SECTIONS = [
SECTION_SUMMARY,
SECTION_AGP,
SECTION_INSULIN_PROFILE,
SECTION_DAGOVERZICHT,
SECTION_EVENTS,
]
REPORT_VERSION = "1.0"
_ICON_PATH = Path(__file__).parent / "assets" / "icon.svg"
AVAILABLE_SECTIONS = [SECTION_SUMMARY, SECTION_AGP, SECTION_DAGOVERZICHT, SECTION_EVENTS]
_LINE_KWARGS = {"new_x": XPos.LMARGIN, "new_y": YPos.NEXT}
_SAME_LINE_KWARGS = {"new_x": XPos.RIGHT, "new_y": YPos.TOP}
# Zelfde kleuren als het PALETTE["light"] in ui_common.py — bewust hier
# gedupliceerd in plaats van ui_common.py te importeren, want dat zou een
# streamlit-dependency in deze module trekken die verder bewust vrij van
# UI-framework-imports is (los te unit-testen, zie moduledocstring).
# gedupliceerd (5 hex-waarden) in plaats van ui_common.py te importeren, want
# dat zou een streamlit-dependency in deze module trekken die verder bewust
# vrij van UI-framework-imports is (los te unit-testen, zie moduledocstring).
_MUTED = "#898781"
_TIR_BAND_ORDER = ["very_low", "low", "in_range", "high", "very_high"]
_TIR_BAND_LABELS = {
@@ -93,83 +65,33 @@ _TIR_COLORS = {
"very_high": "#c76f0a",
}
_THRESHOLD_LINES = [VERY_LOW, LOW, HIGH, VERY_HIGH]
_BASAL_COLOR = "#7b5ea3"
_CARBS_COLOR = "#eb6834"
_TARGET_COLOR = "#52514e"
_BOLUS_CATEGORY_STYLE = {
BOLUS_CATEGORY_FOOD: ("#1baf7a", "Bolus (voeding)"),
BOLUS_CATEGORY_CORRECTION: ("#2a78d6", "Bolus (correctie)"),
BOLUS_CATEGORY_SMB: ("#898781", "SMB"),
}
_EMPTY_TREATMENT_COLUMNS = ["timestamp", "eventType", "insulin", "carbs", "type", "absolute", "duration"]
DAGOVERZICHT_MAX_DAYS = 14
_HOUR_OF_DAY_LABEL = "Uur van de dag"
def _report_config() -> dict:
"""Rapportmetadata uit environment variables (insuline/pomp/sensor-type,
Nightscout-URL) — zelfde os.environ-patroon als nightscout_client.py.
Alles heeft een fallback zodat een rapport ook zonder configuratie werkt."""
return {
"insulin_type": os.environ.get("REPORT_INSULIN_TYPE", "").strip() or "Onbekend insulinetype",
"pump_type": os.environ.get("REPORT_PUMP_TYPE", "").strip() or "Onbekende pomp",
"sensor_type": os.environ.get("REPORT_SENSOR_TYPE", "").strip() or "Onbekende sensor",
"nightscout_url": os.environ.get("NIGHTSCOUT_URL", "").strip().rstrip("/"),
}
def _add_threshold_lines(ax) -> None:
"""Teken de 4 klinische drempellijnen (3.0/3.9/10.0/13.9 mmol/L) op een matplotlib-as.
PDF-tegenhanger van ui_common.add_threshold_lines() voor de Plotly-versie
op de dashboardpagina's (gitea #15) — zelfde 4 waarden, zelfde grijstint.
"""
for value in _THRESHOLD_LINES:
ax.axhline(value, color=_MUTED, linewidth=0.7, linestyle=":")
class GlucoseReportPDF(FPDF):
"""FPDF-subklasse met een vaste kop-/voettekst op elke pagina en één sectie
per liggende pagina (zie begin_report_section())."""
def _agp_figure_png(glucose: pd.DataFrame) -> bytes:
bands = agp_percentile_bands(glucose)
fig, ax = plt.subplots(figsize=(7, 3))
hours = bands["minute_of_day"] / 60
ax.fill_between(hours, bands["p5"], bands["p95"], alpha=0.15, color="#2a78d6", label="5e-95e pct")
ax.fill_between(hours, bands["p25"], bands["p75"], alpha=0.3, color="#2a78d6", label="25e-75e pct")
ax.plot(hours, bands["p50"], color="#2a78d6", linewidth=2, label="Mediaan")
_add_threshold_lines(ax)
ax.set_xlabel("Uur van de dag")
ax.set_ylabel("mmol/L")
ax.set_xlim(0, 24)
ax.legend(loc="upper right", fontsize=8)
fig.tight_layout()
def __init__(self, config: dict, generated_at: datetime) -> None:
super().__init__(orientation="L", unit="mm", format="A4")
self._config = config
self._generated_at = generated_at
self.section_title = ""
self.set_auto_page_break(auto=True, margin=18)
self.alias_nb_pages()
def begin_report_section(self, title: str) -> None:
"""Begin een nieuwe pagina voor sectie `title`. Voor secties die over
meerdere pagina's lopen (Dagoverzicht: één pagina per dag) wordt dit
per pagina aangeroepen zodat de kop op elke pagina klopt."""
self.section_title = title
self.add_page()
def header(self) -> None:
if _ICON_PATH.exists():
self.image(str(_ICON_PATH), x=10, y=8, w=8)
self.set_xy(20, 8)
self.set_font("Helvetica", "B", 14)
self.cell(0, 6, self.section_title, **_LINE_KWARGS)
self.set_x(20)
self.set_font("Helvetica", "", 8)
self.set_text_color(120, 120, 120)
device_line = (
f"Glucoserapport v{REPORT_VERSION} · Insuline: {self._config['insulin_type']} · "
f"Pomp: {self._config['pump_type']} · Sensor: {self._config['sensor_type']}"
)
self.cell(0, 4, device_line, **_LINE_KWARGS)
self.set_text_color(0, 0, 0)
self.ln(3)
self.set_draw_color(200, 200, 200)
self.line(10, self.get_y(), self.w - 10, self.get_y())
self.ln(4)
def footer(self) -> None:
self.set_y(-14)
self.set_font("Helvetica", "I", 8)
self.set_text_color(120, 120, 120)
left = f"Gegenereerd: {self._generated_at.strftime('%d-%m-%Y %H:%M')}"
if self._config["nightscout_url"]:
left += f" · {self._config['nightscout_url']}"
self.cell(200, 8, left, **_SAME_LINE_KWARGS)
self.cell(0, 8, f"Pagina {self.page_no()}/{{nb}}", align="R")
self.set_text_color(0, 0, 0)
def _fig_to_png(fig) -> bytes:
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=150)
plt.close(fig)
@@ -177,33 +99,10 @@ def _fig_to_png(fig) -> bytes:
return buf.getvalue()
def _add_threshold_lines(ax) -> None:
"""Teken de 4 klinische drempellijnen (3.0/3.9/10.0/13.9 mmol/L) op een matplotlib-as."""
for value in _THRESHOLD_LINES:
ax.axhline(value, color=_MUTED, linewidth=0.7, linestyle=":")
def _agp_figure_png(glucose: pd.DataFrame) -> bytes:
bands = agp_percentile_bands(glucose)
fig, ax = plt.subplots(figsize=(11, 4.2))
hours = bands["minute_of_day"] / 60
ax.axhspan(LOW, HIGH, color="#0ca30c", alpha=0.08, label="Doelbereik")
ax.fill_between(hours, bands["p5"], bands["p95"], alpha=0.15, color="#2a78d6", label="5e-95e pct")
ax.fill_between(hours, bands["p25"], bands["p75"], alpha=0.3, color="#2a78d6", label="25e-75e pct")
ax.plot(hours, bands["p50"], color="#2a78d6", linewidth=2.5, label="Mediaan")
_add_threshold_lines(ax)
ax.set_xlabel(_HOUR_OF_DAY_LABEL)
ax.set_ylabel("mmol/L")
ax.set_xlim(0, 24)
ax.set_xticks(range(0, 25, 3))
ax.legend(loc="upper right", fontsize=9, ncol=4, frameon=False)
fig.tight_layout()
return _fig_to_png(fig)
def _tir_bar_figure_png(tir: dict) -> bytes:
"""Gestapelde staafgrafiek van de 5 TIR-banden."""
fig, ax = plt.subplots(figsize=(9, 1.1))
"""Gestapelde staafgrafiek van de 5 TIR-banden — PDF-tegenhanger van
ui_common.build_tir_stacked_bar_figure() (gitea #14)."""
fig, ax = plt.subplots(figsize=(7, 1.1))
left = 0.0
for band in _TIR_BAND_ORDER:
value = tir[band]
@@ -224,127 +123,52 @@ def _tir_bar_figure_png(tir: dict) -> bytes:
return buf.getvalue()
def _insulin_profile_figure_png(profile: dict) -> bytes:
bars = segments_to_hourly_bars(profile.get("basal", []))
fig, ax = plt.subplots(figsize=(11, 3.2))
if bars:
hours = [b["hour"] for b in bars]
values = [b["value"] for b in bars]
ax.step(hours, values, where="post", color=_BASAL_COLOR, linewidth=1.5)
ax.fill_between(hours, values, step="post", color=_BASAL_COLOR, alpha=0.3)
ax.set_xlim(0, 24)
ax.set_xticks(range(0, 25, 2))
ax.set_ylim(bottom=0)
ax.set_xlabel(_HOUR_OF_DAY_LABEL)
ax.set_ylabel("E/uur")
fig.tight_layout()
return _fig_to_png(fig)
def _add_basal_overlay(ax2, basal: pd.DataFrame, day_start) -> None:
if basal.empty:
return
hours = (basal["timestamp"] - day_start).dt.total_seconds() / 3600
hours = pd.concat([hours, pd.Series([24.0])], ignore_index=True)
values = pd.concat([basal["rate"], basal["rate"].iloc[-1:]], ignore_index=True)
ax2.fill_between(hours, values, step="post", color=_BASAL_COLOR, alpha=0.3, label="Basaal")
ax2.step(hours, values, where="post", color=_BASAL_COLOR, linewidth=1, alpha=0.8)
def _add_carbs_overlay(ax2, day_treatments: pd.DataFrame, day_start) -> None:
if day_treatments.empty:
return
carbs = day_treatments[day_treatments["carbs"].fillna(0) > 0]
if carbs.empty:
return
hours = (carbs["timestamp"] - day_start).dt.total_seconds() / 3600
ax2.bar(hours, carbs["carbs"], width=0.25, color=_CARBS_COLOR, alpha=0.55, label="Koolhydraten")
def _add_bolus_overlay(ax, day_treatments: pd.DataFrame, day_start, lane_y: float) -> None:
bolus = classify_bolus(day_treatments)
for category in (BOLUS_CATEGORY_SMB, BOLUS_CATEGORY_CORRECTION, BOLUS_CATEGORY_FOOD):
subset = bolus[bolus["category"] == category] if not bolus.empty else bolus
if subset.empty:
continue
color, label = _BOLUS_CATEGORY_STYLE[category]
hours = (subset["timestamp"] - day_start).dt.total_seconds() / 3600
sizes = (subset["insulin"] * 25).clip(lower=15, upper=200)
ax.scatter(hours, [lane_y] * len(subset), s=sizes, color=color, alpha=0.75, label=label, zorder=5)
if category != BOLUS_CATEGORY_SMB:
for x, units in zip(hours, subset["insulin"], strict=True):
ax.annotate(
f"{units:.1f}E", (x, lane_y), textcoords="offset points", xytext=(0, 8), fontsize=6, ha="center"
)
def _add_target_overlay(ax, profile: dict | None, day_start, day_end) -> None:
target = target_glucose_series(profile, day_start, day_end)
if target.empty:
return
hours = (target["timestamp"] - day_start).dt.total_seconds() / 3600
hours = pd.concat([hours, pd.Series([24.0])], ignore_index=True)
values = pd.concat([target["target"], target["target"].iloc[-1:]], ignore_index=True)
ax.step(hours, values, where="post", color=_TARGET_COLOR, linewidth=1.2, linestyle="-.", label="Streefwaarde (pomp)")
def _daily_figure_png(
day_df: pd.DataFrame, day_treatments: pd.DataFrame, profile: dict | None, day_label: str, day_start, day_end
) -> bytes:
"""Eén dag als grafiek: glucoselijn + basaal/bolus/koolhydraten/streefwaarde-overlay."""
def _daily_figure_png(day_df: pd.DataFrame, day_label: str) -> bytes:
"""Eén dag als kleine lijngrafiek — PDF-tegenhanger van de Dagoverzicht-pagina (gitea #16)."""
hours = day_df["timestamp"].dt.hour + day_df["timestamp"].dt.minute / 60
fig, ax = plt.subplots(figsize=(11, 3.8))
ax2 = ax.twinx()
basal = effective_basal_series(profile, day_treatments, day_start, day_end)
_add_basal_overlay(ax2, basal, day_start)
_add_carbs_overlay(ax2, day_treatments, day_start)
lane_y = (day_df["mmol"].min() - 1.0) if not day_df.empty else LOW - 1.0
_add_bolus_overlay(ax, day_treatments, day_start, lane_y)
_add_target_overlay(ax, profile, day_start, day_end)
ax.plot(hours, day_df["mmol"], color="#2a78d6", linewidth=1.4, label="Glucose", zorder=6)
fig, ax = plt.subplots(figsize=(7, 1.4))
ax.plot(hours, day_df["mmol"], color="#2a78d6", linewidth=1.2)
_add_threshold_lines(ax)
ax.set_xlim(0, 24)
ax.set_xticks(range(0, 25, 2))
ax.set_ylabel("mmol/L", fontsize=9)
ax2.set_ylabel("Basaal (E/uur) · KH (g)", fontsize=9)
ax.set_title(day_label, fontsize=11, loc="left")
ax.tick_params(labelsize=8)
ax2.tick_params(labelsize=8)
lines1, labels1 = ax.get_legend_handles_labels()
lines2, labels2 = ax2.get_legend_handles_labels()
ax.legend(
lines1 + lines2, labels1 + labels2, loc="upper center", bbox_to_anchor=(0.5, -0.2), ncol=6, fontsize=7, frameon=False
)
ax.set_xticks(range(0, 25, 4))
ax.set_ylabel("mmol/L", fontsize=8)
ax.set_title(day_label, fontsize=9, loc="left")
ax.tick_params(labelsize=7)
fig.tight_layout()
return _fig_to_png(fig)
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=150)
plt.close(fig)
buf.seek(0)
return buf.getvalue()
def _event_hour_histogram_png(events: pd.DataFrame, color: str) -> bytes:
"""Aantal events per uur-van-de-dag."""
"""Aantal events per uur-van-de-dag — PDF-tegenhanger van render_event_level()'s
histogram op de Hypo/Hyper Events-pagina's (gitea #17, #18)."""
hour_counts = events["start"].dt.hour.value_counts().reindex(range(24), fill_value=0).sort_index()
fig, ax = plt.subplots(figsize=(11, 2.6))
fig, ax = plt.subplots(figsize=(7, 2))
ax.bar(hour_counts.index, hour_counts.to_numpy(), color=color)
ax.set_xlabel(_HOUR_OF_DAY_LABEL)
ax.set_xlabel("Uur van de dag")
ax.set_ylabel("Aantal events")
ax.set_xlim(-0.5, 23.5)
ax.set_xticks(range(0, 24, 2))
fig.tight_layout()
return _fig_to_png(fig)
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=150)
plt.close(fig)
buf.seek(0)
return buf.getvalue()
def _add_summary_section(pdf: GlucoseReportPDF, glucose: pd.DataFrame, start, end) -> None:
pdf.begin_report_section(SECTION_SUMMARY)
def _add_summary_section(pdf: FPDF, glucose: pd.DataFrame, start, end) -> None:
mean_mmol = glucose["mmol"].mean()
min_mmol, max_mmol = glucose_min_max(glucose)
tir = tir_breakdown_5band(glucose)
active_pct = sensor_active_percent(glucose, start, end)
pdf.set_font("Helvetica", "", 11)
pdf.cell(0, 6, f"Periode: {start.strftime('%d-%m-%Y')} t/m {end.strftime('%d-%m-%Y')}", **_LINE_KWARGS)
pdf.set_font("Helvetica", "B", 13)
pdf.cell(0, 8, "Samenvatting", **_LINE_KWARGS)
warning = completeness_warning(glucose, start, end)
if warning:
@@ -354,12 +178,7 @@ def _add_summary_section(pdf: GlucoseReportPDF, glucose: pd.DataFrame, start, en
pdf.set_font("Helvetica", "", 11)
pdf.cell(0, 6, f"Sensordekking: {active_pct:.0f}%", **_LINE_KWARGS)
pdf.cell(
0,
6,
f"Gemiddelde glucose: {mean_mmol:.1f} mmol/L (min {min_mmol:.1f} · max {max_mmol:.1f})",
**_LINE_KWARGS,
)
pdf.cell(0, 6, f"Gemiddelde glucose: {mean_mmol:.1f} mmol/L", **_LINE_KWARGS)
gmi = gmi_percent(mean_mmol)
pdf.cell(0, 6, f"Geschat HbA1c (GMI): {gmi:.1f}% ({gmi_mmol_per_mol(gmi):.0f} mmol/mol)", **_LINE_KWARGS)
pdf.cell(0, 6, f"Variabiliteit (%CV): {percent_cv(glucose):.0f}%", **_LINE_KWARGS)
@@ -371,117 +190,43 @@ def _add_summary_section(pdf: GlucoseReportPDF, glucose: pd.DataFrame, start, en
pdf.cell(0, 6, f"Zeer hoog (>13.9 mmol/L): {tir['very_high']:.0f}%", **_LINE_KWARGS)
pdf.ln(2)
png_bytes = _tir_bar_figure_png(tir)
pdf.image(io.BytesIO(png_bytes), w=220)
def _add_agp_section(pdf: GlucoseReportPDF, glucose: pd.DataFrame) -> None:
pdf.begin_report_section(SECTION_AGP)
png_bytes = _agp_figure_png(glucose)
pdf.image(io.BytesIO(png_bytes), w=270)
def _add_insulin_profile_section(pdf: GlucoseReportPDF, profile: dict | None) -> None:
pdf.begin_report_section(SECTION_INSULIN_PROFILE)
if not profile:
pdf.set_font("Helvetica", "", 11)
pdf.cell(0, 6, "Geen pompprofiel gevonden op deze Nightscout-instantie.", **_LINE_KWARGS)
return
pdf.set_font("Helvetica", "", 11)
pdf.cell(0, 6, f"Profielnaam: {profile.get('name') or '-'}", **_LINE_KWARGS)
pdf.cell(0, 6, f"DIA (werkingsduur insuline): {profile['dia']:.1f} uur", **_LINE_KWARGS)
pdf.cell(0, 6, f"Totale dagelijkse basaal: {total_daily_basal(profile['basal']):.1f} E", **_LINE_KWARGS)
pdf.ln(2)
png_bytes = _insulin_profile_figure_png(profile)
pdf.image(io.BytesIO(png_bytes), w=270)
pdf.image(io.BytesIO(png_bytes), w=160)
pdf.ln(4)
pdf.set_font("Helvetica", "B", 11)
pdf.cell(90, 6, "Insulinegevoeligheid (ISF)", **_SAME_LINE_KWARGS)
pdf.cell(0, 6, "Koolhydraatratio", **_LINE_KWARGS)
pdf.set_font("Helvetica", "", 10)
isf_rows = segments_table(profile["sens"])
ratio_rows = segments_table(profile["carbratio"])
for i in range(max(len(isf_rows), len(ratio_rows))):
isf_text = (
f"{isf_rows[i]['start']}-{isf_rows[i]['end']}: {isf_rows[i]['value']:.1f} mmol/L per E"
if i < len(isf_rows)
else ""
)
ratio_text = (
f"{ratio_rows[i]['start']}-{ratio_rows[i]['end']}: {ratio_rows[i]['value']:.0f} g per E"
if i < len(ratio_rows)
else ""
)
pdf.cell(90, 5, isf_text, **_SAME_LINE_KWARGS)
pdf.cell(0, 5, ratio_text, **_LINE_KWARGS)
def _add_agp_section(pdf: FPDF, glucose: pd.DataFrame) -> None:
pdf.set_font("Helvetica", "B", 13)
pdf.cell(0, 8, "AGP profiel", **_LINE_KWARGS)
png_bytes = _agp_figure_png(glucose)
pdf.image(io.BytesIO(png_bytes), w=180)
pdf.ln(4)
def _add_daily_stats_lines(pdf: GlucoseReportPDF, stats: dict) -> None:
pdf.set_font("Helvetica", "", 10)
pdf.cell(
0,
6,
f"Glucose: gem. {stats['mean_mmol']:.1f} · min {stats['min_mmol']:.1f} · "
f"max {stats['max_mmol']:.1f} mmol/L",
**_LINE_KWARGS,
)
pdf.cell(
0,
6,
f"Insuline: totaal {stats['total_insulin']:.1f} E · basaal {stats['basal_insulin']:.1f} E · "
f"bolus {stats['bolus_insulin']:.1f} E · SMB {stats['smb_insulin']:.1f} E",
**_LINE_KWARGS,
)
pdf.cell(
0,
6,
f"Hypo's: {stats['hypo_count']} ({stats['hypo_duration_minutes']:.0f} min) · "
f"Hyper's: {stats['hyper_count']} ({stats['hyper_duration_minutes']:.0f} min)",
**_LINE_KWARGS,
)
def _add_dagoverzicht_section(pdf: FPDF, glucose: pd.DataFrame) -> None:
"""Eén dagelijkse mini-grafiek per kalenderdag (gitea #16), max. de meest
recente DAGOVERZICHT_MAX_DAYS dagen — voor langere periodes zou de PDF
anders onwerkbaar lang worden."""
pdf.set_font("Helvetica", "B", 13)
pdf.cell(0, 8, "Dagoverzicht", **_LINE_KWARGS)
def _add_dagoverzicht_section(
pdf: GlucoseReportPDF, glucose: pd.DataFrame, treatments: pd.DataFrame, profile: dict | None
) -> None:
"""Eén liggende pagina per kalenderdag, max. de meest recente
DAGOVERZICHT_MAX_DAYS dagen — voor langere periodes zou de PDF anders
onwerkbaar lang worden."""
df = glucose.copy()
df["date"] = df["timestamp"].dt.date
dates = sorted(df["date"].unique())
show_limited_note = len(dates) > DAGOVERZICHT_MAX_DAYS
if show_limited_note:
if len(dates) > DAGOVERZICHT_MAX_DAYS:
dates = dates[-DAGOVERZICHT_MAX_DAYS:]
pdf.set_font("Helvetica", "I", 9)
pdf.cell(0, 5, f"Beperkt tot de meest recente {DAGOVERZICHT_MAX_DAYS} dagen.", **_LINE_KWARGS)
for day in dates:
pdf.begin_report_section(SECTION_DAGOVERZICHT)
if show_limited_note:
pdf.set_font("Helvetica", "I", 9)
pdf.cell(0, 5, f"Beperkt tot de meest recente {DAGOVERZICHT_MAX_DAYS} dagen.", **_LINE_KWARGS)
show_limited_note = False # alleen op de eerste paginas van deze sectie
day_start = pd.Timestamp(day)
day_end = day_start + pd.Timedelta(1, unit="D")
day_df = df[df["date"] == day]
day_treatments = (
treatments[(treatments["timestamp"] >= day_start) & (treatments["timestamp"] < day_end)]
if not treatments.empty
else treatments
)
png_bytes = _daily_figure_png(day_df, day_treatments, profile, day.strftime("%A %d-%m-%Y"), day_start, day_end)
pdf.image(io.BytesIO(png_bytes), w=270)
pdf.ln(2)
stats = daily_stats(day_df, day_treatments, profile, day_start, day_end)
_add_daily_stats_lines(pdf, stats)
png_bytes = _daily_figure_png(day_df, day.strftime("%A %d-%m-%Y"))
pdf.image(io.BytesIO(png_bytes), w=180)
pdf.ln(4)
def _add_events_section(pdf: GlucoseReportPDF, glucose: pd.DataFrame, events_summary: dict[str, dict]) -> None:
pdf.begin_report_section(SECTION_EVENTS)
def _add_events_section(pdf: FPDF, glucose: pd.DataFrame, events_summary: dict[str, dict]) -> None:
pdf.set_font("Helvetica", "B", 13)
pdf.cell(0, 8, "Hypo-/hyperevents", **_LINE_KWARGS)
pdf.set_font("Helvetica", "", 11)
for label, summary in events_summary.items():
pdf.cell(
@@ -493,16 +238,17 @@ def _add_events_section(pdf: GlucoseReportPDF, glucose: pd.DataFrame, events_sum
)
pdf.ln(2)
# Alleen Level 1 krijgt een grafiek in het PDF-rapport — geen tabel met
# losse events, de samenvattende cijfers staan al hierboven.
# Alleen Level 1 krijgt een grafiek in het PDF-rapport (gitea #17, #18) —
# geen tabel met losse events, de samenvattende cijfers staan al hierboven.
if not glucose.empty:
hypo_events = detect_events(glucose, LOW, "below")
if not hypo_events.empty:
pdf.image(io.BytesIO(_event_hour_histogram_png(hypo_events, _TIR_COLORS["low"])), w=260)
pdf.ln(2)
pdf.image(io.BytesIO(_event_hour_histogram_png(hypo_events, _TIR_COLORS["low"])), w=180)
hyper_events = detect_events(glucose, HIGH, "above")
if not hyper_events.empty:
pdf.image(io.BytesIO(_event_hour_histogram_png(hyper_events, _TIR_COLORS["high"])), w=260)
pdf.image(io.BytesIO(_event_hour_histogram_png(hyper_events, _TIR_COLORS["high"])), w=180)
pdf.ln(4)
def build_report_pdf(
@@ -511,33 +257,20 @@ def build_report_pdf(
events_summary: dict[str, dict],
start,
end,
treatments: pd.DataFrame | None = None,
profile: dict | None = None,
) -> bytes:
"""Bouw een PDF met de geselecteerde onderdelen: één liggende voorpagina,
daarna één (of voor Dagoverzicht: meerdere) liggende pagina('s) per sectie.
"""Bouw een PDF met de geselecteerde onderdelen.
`events_summary` bevat vooraf berekende hypo/hyper-samenvattingen (label ->
resultaat van reporting.summarize_events), zodat deze module geen
event-detectie hoeft te herhalen. `treatments`/`profile` zijn optioneel
(leeg/None) zodat oudere aanroepen zonder insulinedata blijven werken.
event-detectie hoeft te herhalen.
"""
if treatments is None:
treatments = pd.DataFrame(columns=_EMPTY_TREATMENT_COLUMNS)
config = _report_config()
pdf = GlucoseReportPDF(config, datetime.now(timezone.utc))
pdf.begin_report_section("Glucoserapport")
pdf.set_font("Helvetica", "B", 22)
pdf.cell(0, 14, "Glucoserapport", **_LINE_KWARGS)
pdf.set_font("Helvetica", "", 13)
pdf = FPDF()
pdf.add_page()
pdf.set_font("Helvetica", "B", 16)
pdf.cell(0, 10, "Glucoserapport", **_LINE_KWARGS)
pdf.set_font("Helvetica", "", 11)
pdf.cell(0, 8, f"Periode: {start.strftime('%d-%m-%Y')} t/m {end.strftime('%d-%m-%Y')}", **_LINE_KWARGS)
if sections:
pdf.cell(0, 8, f"Onderdelen: {', '.join(sections)}", **_LINE_KWARGS)
if SECTION_INSULIN_PROFILE in sections:
_add_insulin_profile_section(pdf, profile)
pdf.ln(4)
if not glucose.empty:
if SECTION_SUMMARY in sections:
@@ -545,7 +278,7 @@ def build_report_pdf(
if SECTION_AGP in sections:
_add_agp_section(pdf, glucose)
if SECTION_DAGOVERZICHT in sections:
_add_dagoverzicht_section(pdf, glucose, treatments, profile)
_add_dagoverzicht_section(pdf, glucose)
if SECTION_EVENTS in sections and events_summary:
_add_events_section(pdf, glucose, events_summary)
-146
View File
@@ -1,146 +0,0 @@
"""
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
-228
View File
@@ -15,7 +15,6 @@ 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
@@ -441,233 +440,6 @@ 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 _integrate_step_series(series: pd.DataFrame, value_col: str, day_end) -> float:
"""Oppervlakte onder een stapfunctie-DataFrame (zoals effective_basal_series()):
som van elke rij se waarde x duur (in uur) tot de volgende rij, of tot
`day_end` voor de laatste rij. Gebruikt om een E/uur-stapfunctie om te
rekenen naar een totaal aantal eenheden."""
if series.empty:
return 0.0
rows = series.reset_index(drop=True)
total = 0.0
for i in range(len(rows)):
start = rows.loc[i, "timestamp"]
end = rows.loc[i + 1, "timestamp"] if i + 1 < len(rows) else day_end
total += rows.loc[i, value_col] * (end - start).total_seconds() / 3600
return total
DAILY_STATS_KEYS = [
"mean_mmol",
"min_mmol",
"max_mmol",
"total_insulin",
"basal_insulin",
"bolus_insulin",
"smb_insulin",
"hypo_count",
"hypo_duration_minutes",
"hyper_count",
"hyper_duration_minutes",
]
def daily_stats(day_glucose: pd.DataFrame, day_treatments: pd.DataFrame, profile: dict | None, day_start, day_end) -> dict:
"""Samenvattende statistieken voor één kalenderdag, voor naast de dagelijkse
grafiek in het PDF-rapport: gemiddelde/min/max glucose, totale/basaal-/bolus-/
SMB-insuline (SMB apart gerapporteerd omdat dit zowel als basaal- als
bolus-vervanging kan gelden, zie classify_bolus()), en het aantal + de totale
duur van hypo- en hyperevents die dag.
"""
min_mmol, max_mmol = glucose_min_max(day_glucose)
mean_mmol = round(day_glucose["mmol"].mean(), 1) if not day_glucose.empty else 0.0
bolus = classify_bolus(day_treatments)
by_category = bolus.groupby("category")["insulin"].sum().to_dict() if not bolus.empty else {}
bolus_units = by_category.get(BOLUS_CATEGORY_FOOD, 0.0) + by_category.get(BOLUS_CATEGORY_CORRECTION, 0.0)
smb_units = by_category.get(BOLUS_CATEGORY_SMB, 0.0)
basal_series = effective_basal_series(profile, day_treatments, day_start, day_end)
basal_units = _integrate_step_series(basal_series, "rate", day_end)
hypo_summary = summarize_events(detect_events(day_glucose, LOW, "below"), period_days=1)
hyper_summary = summarize_events(detect_events(day_glucose, HIGH, "above"), period_days=1)
return {
"mean_mmol": mean_mmol,
"min_mmol": min_mmol,
"max_mmol": max_mmol,
"total_insulin": round(bolus_units + smb_units + basal_units, 1),
"basal_insulin": round(basal_units, 1),
"bolus_insulin": round(bolus_units, 1),
"smb_insulin": round(smb_units, 1),
"hypo_count": hypo_summary["count"],
"hypo_duration_minutes": hypo_summary["total_minutes"],
"hyper_count": hyper_summary["count"],
"hyper_duration_minutes": hyper_summary["total_minutes"],
}
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,29 +357,6 @@ 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
-197
View File
@@ -1,32 +1,17 @@
import re
from datetime import date
from pathlib import Path
import pandas as pd
import pytest
from pdf_export import (
DAGOVERZICHT_MAX_DAYS,
SECTION_AGP,
SECTION_DAGOVERZICHT,
SECTION_EVENTS,
SECTION_INSULIN_PROFILE,
SECTION_SUMMARY,
_report_config,
build_report_pdf,
)
def _page_count(pdf_bytes: bytes) -> int:
"""Grof-maar-betrouwbaar paginatelling op de ruwe PDF-bytes: elke
paginaobject bevat een '/Type /Page' (zonder de 's' van '/Type /Pages')
in zijn objectdictionary, wat altijd onleesbaar-platte tekst blijft ook
al staat contentstream-compressie aan — geen losse PDF-parser nodig voor
deze structurele check (zelfde black-box-filosofie als de rest van deze
testset: geen pixel-vergelijkingen, wel structuur)."""
return len(re.findall(rb"/Type\s*/Page[^s]", pdf_bytes))
def _sample_glucose():
timestamps = pd.date_range("2026-01-01", periods=48, freq="30min")
return pd.DataFrame({"timestamp": timestamps, "mmol": [6.0 + (i % 5) for i in range(48)]})
@@ -105,185 +90,3 @@ def test_build_report_pdf_draws_hypo_and_hyper_charts_when_events_present():
)
assert pdf_bytes.startswith(b"%PDF")
assert len(pdf_bytes) > 500
def test_build_report_pdf_always_has_at_least_a_cover_page():
empty = pd.DataFrame(columns=["timestamp", "mmol"])
pdf_bytes = build_report_pdf([], empty, {}, date(2026, 1, 1), date(2026, 1, 2))
assert _page_count(pdf_bytes) == 1
def test_build_report_pdf_one_page_per_single_page_section():
glucose = _sample_glucose()
pdf_bytes = build_report_pdf(
[SECTION_SUMMARY, SECTION_AGP], glucose, {}, date(2026, 1, 1), date(2026, 1, 2)
)
# cover + summary + agp = 3
assert _page_count(pdf_bytes) == 3
def test_build_report_pdf_one_page_per_day_for_dagoverzicht():
timestamps = pd.date_range("2026-01-01", periods=3 * 24 * 12, freq="5min") # 3 dagen
glucose = pd.DataFrame({"timestamp": timestamps, "mmol": [7.0] * len(timestamps)})
pdf_bytes = build_report_pdf(
[SECTION_DAGOVERZICHT], glucose, {}, date(2026, 1, 1), date(2026, 1, 3)
)
# cover + 3 dagen
assert _page_count(pdf_bytes) == 4
def test_build_report_pdf_caps_dagoverzicht_page_count_at_max_days():
timestamps = pd.date_range("2026-01-01", periods=(DAGOVERZICHT_MAX_DAYS + 10) * 24, freq="1h")
glucose = pd.DataFrame({"timestamp": timestamps, "mmol": [7.0] * len(timestamps)})
pdf_bytes = build_report_pdf(
[SECTION_DAGOVERZICHT], glucose, {}, timestamps[0].date(), timestamps[-1].date()
)
# cover + max. DAGOVERZICHT_MAX_DAYS dagpagina's, ondanks (DAGOVERZICHT_MAX_DAYS + 10) dagen aan data
assert _page_count(pdf_bytes) == 1 + DAGOVERZICHT_MAX_DAYS
def test_build_report_pdf_includes_insulin_profile_section_page():
glucose = _sample_glucose()
profile = {
"name": "Default",
"dia": 5.0,
"basal": [{"minutes": 0, "value": 0.8}, {"minutes": 720, "value": 1.1}],
"sens": [{"minutes": 0, "value": 2.8}],
"carbratio": [{"minutes": 0, "value": 10.0}],
"target_low": [{"minutes": 0, "value": 4.5}],
"target_high": [{"minutes": 0, "value": 8.0}],
}
pdf_bytes = build_report_pdf(
[SECTION_INSULIN_PROFILE], glucose, {}, date(2026, 1, 1), date(2026, 1, 2), profile=profile
)
# cover + insulineprofiel
assert _page_count(pdf_bytes) == 2
assert pdf_bytes.startswith(b"%PDF")
def test_build_report_pdf_insulin_profile_section_handles_missing_profile():
glucose = _sample_glucose()
pdf_bytes = build_report_pdf(
[SECTION_INSULIN_PROFILE], glucose, {}, date(2026, 1, 1), date(2026, 1, 2), profile=None
)
assert _page_count(pdf_bytes) == 2
assert pdf_bytes.startswith(b"%PDF")
def test_build_report_pdf_insulin_profile_section_renders_without_glucose():
"""De Insulineprofiel-sectie gaat niet over glucosedata en moet dus ook
renderen als er geen glucosemetingen zijn (in tegenstelling tot
Samenvatting/AGP/Dagoverzicht, die wel glucosedata vereisen)."""
empty = pd.DataFrame(columns=["timestamp", "mmol"])
profile = {
"name": "Default",
"dia": 5.0,
"basal": [{"minutes": 0, "value": 0.8}],
"sens": [{"minutes": 0, "value": 2.8}],
"carbratio": [{"minutes": 0, "value": 10.0}],
"target_low": [],
"target_high": [],
}
pdf_bytes = build_report_pdf(
[SECTION_INSULIN_PROFILE], empty, {}, date(2026, 1, 1), date(2026, 1, 2), profile=profile
)
assert _page_count(pdf_bytes) == 2
def test_build_report_pdf_dagoverzicht_uses_treatments_and_profile_without_error():
"""Regressietest: de dagpagina moet basaal/bolus/koolhydraten/streefwaarde-
overlays tekenen zonder te crashen als er echte treatments/profile worden
meegegeven (in plaats van de lege defaults)."""
timestamps = pd.date_range("2026-01-01", periods=24 * 12, freq="5min") # 1 dag
glucose = pd.DataFrame({"timestamp": timestamps, "mmol": [7.0] * len(timestamps)})
treatments = pd.DataFrame(
[
{
"timestamp": pd.Timestamp("2026-01-01 08:00"),
"eventType": "Meal Bolus",
"insulin": 4.0,
"carbs": 40.0,
"type": "",
"absolute": None,
"duration": None,
},
{
"timestamp": pd.Timestamp("2026-01-01 12:00"),
"eventType": "Correction Bolus",
"insulin": 0.3,
"carbs": None,
"type": "SMB",
"absolute": None,
"duration": None,
},
{
"timestamp": pd.Timestamp("2026-01-01 06:00"),
"eventType": "Temp Basal",
"insulin": None,
"carbs": None,
"type": "",
"absolute": 2.0,
"duration": 60,
},
]
)
profile = {
"name": "Default",
"dia": 5.0,
"basal": [{"minutes": 0, "value": 0.8}],
"sens": [{"minutes": 0, "value": 2.8}],
"carbratio": [{"minutes": 0, "value": 10.0}],
"target_low": [{"minutes": 0, "value": 4.5}],
"target_high": [{"minutes": 0, "value": 8.0}],
}
pdf_bytes = build_report_pdf(
[SECTION_DAGOVERZICHT],
glucose,
{},
date(2026, 1, 1),
date(2026, 1, 1),
treatments=treatments,
profile=profile,
)
assert pdf_bytes.startswith(b"%PDF")
assert _page_count(pdf_bytes) == 2 # cover + 1 dag
def test_report_config_uses_defaults_when_env_vars_unset(monkeypatch):
for var in ("REPORT_INSULIN_TYPE", "REPORT_PUMP_TYPE", "REPORT_SENSOR_TYPE", "NIGHTSCOUT_URL"):
monkeypatch.delenv(var, raising=False)
config = _report_config()
assert config["insulin_type"] == "Onbekend insulinetype"
assert config["pump_type"] == "Onbekende pomp"
assert config["sensor_type"] == "Onbekende sensor"
assert config["nightscout_url"] == ""
def test_report_config_reads_env_vars_when_set(monkeypatch):
monkeypatch.setenv("REPORT_INSULIN_TYPE", "Lyumjev 100U")
monkeypatch.setenv("REPORT_PUMP_TYPE", "Omnipod DASH")
monkeypatch.setenv("REPORT_SENSOR_TYPE", "Dexcom G7")
monkeypatch.setenv("NIGHTSCOUT_URL", "https://example.com/")
config = _report_config()
assert config["insulin_type"] == "Lyumjev 100U"
assert config["pump_type"] == "Omnipod DASH"
assert config["sensor_type"] == "Dexcom G7"
assert config["nightscout_url"] == "https://example.com" # trailing slash gestript
def test_icon_asset_exists():
icon_path = Path(__file__).parent.parent / "assets" / "icon.svg"
assert icon_path.exists()
assert icon_path.read_text(encoding="utf-8").strip().startswith("<svg")
@pytest.mark.parametrize("section", [SECTION_SUMMARY, SECTION_AGP, SECTION_DAGOVERZICHT, SECTION_EVENTS])
def test_all_sections_still_work_without_treatments_or_profile_args(section):
"""Achterwaartse compatibiliteit: treatments/profile zijn optioneel — een
aanroep zoals vóór deze PR (zonder die argumenten) moet blijven werken."""
glucose = _glucose_with_hypo_and_hyper_events() if section == SECTION_EVENTS else _sample_glucose()
events_summary = {"Hypo Level 1": {"count": 1, "avg_duration_minutes": 25.0, "events_per_week": 2.3}}
pdf_bytes = build_report_pdf(
[section], glucose, events_summary if section == SECTION_EVENTS else {}, date(2026, 1, 1), date(2026, 1, 4)
)
assert pdf_bytes.startswith(b"%PDF")
-117
View File
@@ -1,117 +0,0 @@
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
-208
View File
@@ -8,14 +8,10 @@ from reporting import (
agp_percentile_bands,
basal_stats,
bolus_stats,
classify_bolus,
completeness_warning,
daily_gmi,
daily_stats,
day_of_week_pattern,
detect_events,
effective_basal_series,
glucose_min_max,
gmi_mmol_per_mol,
gmi_percent,
gmi_trend,
@@ -25,7 +21,6 @@ from reporting import (
sensor_active_percent,
site_interval_stats,
summarize_events,
target_glucose_series,
tir_breakdown_5band,
)
@@ -485,209 +480,6 @@ 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_daily_stats_computes_glucose_and_insulin_breakdown():
day_start = pd.Timestamp("2026-01-01")
day_end = pd.Timestamp("2026-01-02")
glucose = _series(
[("2026-01-01 08:00", 3.2), ("2026-01-01 12:00", 14.1), ("2026-01-01 20:00", 6.5)]
)
treatments = pd.DataFrame(
[
{
"timestamp": pd.Timestamp("2026-01-01 08:00"),
"eventType": "Meal Bolus",
"insulin": 4.0,
"carbs": 40.0,
"type": "",
"absolute": None,
"duration": None,
},
{
"timestamp": pd.Timestamp("2026-01-01 12:00"),
"eventType": "Correction Bolus",
"insulin": 0.3,
"carbs": None,
"type": "SMB",
"absolute": None,
"duration": None,
},
{
"timestamp": pd.Timestamp("2026-01-01 06:00"),
"eventType": "Temp Basal",
"insulin": None,
"carbs": None,
"type": "",
"absolute": 2.0,
"duration": 60,
},
]
)
profile = {"basal": [{"minutes": 0, "value": 0.8}]}
result = daily_stats(glucose, treatments, profile, day_start, day_end)
assert result["mean_mmol"] == pytest.approx((3.2 + 14.1 + 6.5) / 3, abs=0.05)
assert result["min_mmol"] == pytest.approx(3.2)
assert result["max_mmol"] == pytest.approx(14.1)
assert result["bolus_insulin"] == pytest.approx(4.0)
assert result["smb_insulin"] == pytest.approx(0.3)
# basaal: 23h @ 0.8 profieltempo + 1h @ 2.0 (temp basal 06:00-07:00) = 18.4 + 2.0 = 20.4
assert result["basal_insulin"] == pytest.approx(20.4, abs=0.05)
assert result["total_insulin"] == pytest.approx(4.0 + 0.3 + 20.4, abs=0.05)
assert result["hypo_count"] == 1
assert result["hyper_count"] == 1
def test_daily_stats_handles_empty_glucose_and_treatments():
day_start = pd.Timestamp("2026-01-01")
day_end = pd.Timestamp("2026-01-02")
empty_glucose = pd.DataFrame(columns=["timestamp", "mmol"])
empty_treatments = pd.DataFrame(columns=["timestamp", "eventType", "insulin", "carbs", "type", "absolute", "duration"])
result = daily_stats(empty_glucose, empty_treatments, None, day_start, day_end)
assert result["mean_mmol"] == 0.0
assert result["min_mmol"] == 0.0
assert result["max_mmol"] == 0.0
assert result["total_insulin"] == 0.0
assert result["hypo_count"] == 0
assert result["hyper_count"] == 0
def test_site_interval_stats_splits_by_site_change():
glucose = _series(
[
-101
View File
@@ -1,13 +1,8 @@
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,
@@ -41,99 +36,3 @@ 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
+1 -175
View File
@@ -14,8 +14,7 @@ import pandas as pd
import plotly.graph_objects as go
import streamlit as st
from nightscout_client import fetch_entries_range, fetch_profile, fetch_treatments_range
from profile_data import parse_profile
from nightscout_client import fetch_entries_range, fetch_treatments_range
from reporting import (
HIGH,
LOW,
@@ -58,11 +57,6 @@ 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",
@@ -82,11 +76,6 @@ 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",
},
}
@@ -140,15 +129,6 @@ 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.
@@ -209,160 +189,6 @@ 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.