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
|
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
|
WORKDIR /app
|
||||||
|
|
||||||
# Systeem-dependency voor de HEALTHCHECK hieronder
|
# Systeem-dependency voor de HEALTHCHECK hieronder
|
||||||
@@ -11,8 +18,9 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
RUN chgrp -R 0 /app && chmod -R g=u /app
|
||||||
|
|
||||||
|
USER 1001
|
||||||
EXPOSE 8501
|
EXPOSE 8501
|
||||||
|
|
||||||
HEALTHCHECK CMD curl --fail http://localhost:8501/_stcore/health || exit 1
|
HEALTHCHECK CMD curl --fail http://localhost:8501/_stcore/health || exit 1
|
||||||
|
CMD ["streamlit", "run", "app.py"]
|
||||||
ENTRYPOINT ["streamlit", "run", "app.py", "--server.port=8501", "--server.address=0.0.0.0"]
|
|
||||||
|
|||||||
Vendored
+95
-3
@@ -1,11 +1,103 @@
|
|||||||
pipeline {
|
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 {
|
stages {
|
||||||
stage('Do nothing') {
|
stage('Checkout') {
|
||||||
steps {
|
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)
|
since = datetime.combine(start_date, time.min, tzinfo=timezone.utc)
|
||||||
until = datetime.combine(end_date, time.max, tzinfo=timezone.utc)
|
until = datetime.combine(end_date, time.max, tzinfo=timezone.utc)
|
||||||
return _treatments_since(since, until)
|
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
|
||||||
@@ -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
|
|
||||||
@@ -357,29 +357,6 @@ def test_fetch_treatments_range_retrieves_full_history_even_with_a_hidden_server
|
|||||||
assert result["timestamp"].max().date() == end_date
|
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):
|
def test_paginated_get_uses_created_at_field_for_treatments_cursor(monkeypatch):
|
||||||
"""Losstaande, gerichte assertie op het daadwerkelijke queryparameter-veld
|
"""Losstaande, gerichte assertie op het daadwerkelijke queryparameter-veld
|
||||||
— moet `find[created_at][$lte]` zijn, nooit `find[date][$lte]`, voor het
|
— 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
|
|
||||||
+1
-21
@@ -14,8 +14,7 @@ import pandas as pd
|
|||||||
import plotly.graph_objects as go
|
import plotly.graph_objects as go
|
||||||
import streamlit as st
|
import streamlit as st
|
||||||
|
|
||||||
from nightscout_client import fetch_entries_range, fetch_profile, fetch_treatments_range
|
from nightscout_client import fetch_entries_range, fetch_treatments_range
|
||||||
from profile_data import parse_profile
|
|
||||||
from reporting import (
|
from reporting import (
|
||||||
HIGH,
|
HIGH,
|
||||||
LOW,
|
LOW,
|
||||||
@@ -58,11 +57,6 @@ PALETTE = {
|
|||||||
"tir_in_range": "#0ca30c",
|
"tir_in_range": "#0ca30c",
|
||||||
"tir_high": "#fab219",
|
"tir_high": "#fab219",
|
||||||
"tir_very_high": "#c76f0a",
|
"tir_very_high": "#c76f0a",
|
||||||
"basal": "#7b5ea3",
|
|
||||||
"bolus_food": "#1baf7a",
|
|
||||||
"bolus_correction": "#2a78d6",
|
|
||||||
"bolus_smb": "#898781",
|
|
||||||
"target": "#52514e",
|
|
||||||
},
|
},
|
||||||
"dark": {
|
"dark": {
|
||||||
"glucose": "#3987e5",
|
"glucose": "#3987e5",
|
||||||
@@ -82,11 +76,6 @@ PALETTE = {
|
|||||||
"tir_in_range": "#0ca30c",
|
"tir_in_range": "#0ca30c",
|
||||||
"tir_high": "#fab219",
|
"tir_high": "#fab219",
|
||||||
"tir_very_high": "#d9852e",
|
"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
|
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:
|
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.
|
"""Teken de 4 klinische drempellijnen (3.0/3.9/10.0/13.9 mmol/L) op een grafiek.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user