Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4ae6fc037 | ||
|
|
11489bccdd | ||
|
|
53bc8a0121 | ||
|
|
8dd3d5acea | ||
|
|
0fb9a0c0a2 | ||
|
|
0017d14936 | ||
|
|
17279b69f1 | ||
|
|
ae0c736ea7 | ||
|
|
1f2b0d917a | ||
|
|
908fd6f730 | ||
|
|
c2bbc5a753 | ||
|
|
abf0c95bac | ||
|
|
76f27f674d | ||
|
|
3e18aadc8f | ||
|
|
290022ab8e | ||
|
|
e8159771de | ||
|
|
3c534fec3a | ||
|
|
7c729f38ec | ||
|
|
164ac5e42d |
+11
-3
@@ -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
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: glucose-dashboard-config
|
||||
data:
|
||||
NIGHTSCOUT_URL: "https://zb-ns.duckdns.org"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,10 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: glucose-dashboard
|
||||
spec:
|
||||
selector:
|
||||
app: glucose-dashboard
|
||||
ports:
|
||||
- port: 8501
|
||||
targetPort: 8501
|
||||
+31
-78
@@ -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
@@ -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")
|
||||
|
||||
@@ -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)."
|
||||
)
|
||||
-146
@@ -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
|
||||
-161
@@ -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,166 +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 basal_stats(treatments: pd.DataFrame, period_days: float) -> dict:
|
||||
"""Totale basaalinsuline uit Temp Basal-treatments (rate x duur), zonder pompprofiel.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -8,13 +8,10 @@ from reporting import (
|
||||
agp_percentile_bands,
|
||||
basal_stats,
|
||||
bolus_stats,
|
||||
classify_bolus,
|
||||
completeness_warning,
|
||||
daily_gmi,
|
||||
day_of_week_pattern,
|
||||
detect_events,
|
||||
effective_basal_series,
|
||||
glucose_min_max,
|
||||
gmi_mmol_per_mol,
|
||||
gmi_percent,
|
||||
gmi_trend,
|
||||
@@ -24,7 +21,6 @@ from reporting import (
|
||||
sensor_active_percent,
|
||||
site_interval_stats,
|
||||
summarize_events,
|
||||
target_glucose_series,
|
||||
tir_breakdown_5band,
|
||||
)
|
||||
|
||||
@@ -484,140 +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_site_interval_stats_splits_by_site_change():
|
||||
glucose = _series(
|
||||
[
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user