Updated charts for glucose data and profile chart
CI/CD / test (push) Failing after 6s
CI/CD / sonarqube (push) Skipped
CI/CD / build-and-scan (push) Skipped
CI/CD / deploy (push) Skipped

This commit is contained in:
2026-09-06 14:43:19 +02:00
parent bd66d3dacd
commit 95b3aff310
6 changed files with 262 additions and 120 deletions
+27
View File
@@ -254,6 +254,33 @@ def fetch_treatments_range(start_date: date, end_date: date) -> pd.DataFrame:
return _treatments_since(since, until) return _treatments_since(since, until)
def fetch_entries_for_day(day: date) -> pd.DataFrame:
"""Haal glucosemetingen op voor precies één kalenderdag.
Dunne wrapper om fetch_entries_range() met start=end=day, voor de
dag-navigatie op de Dagoverzicht-pagina: laadt alleen de geselecteerde
dag in plaats van een hele periode, zodat het wisselen tussen dagen snel
blijft.
"""
return fetch_entries_range(day, day)
def fetch_treatments_for_day(day: date, lookback_hours: int = 6) -> pd.DataFrame:
"""Haal behandelingen op voor één kalenderdag, met een lookback-marge vóór middernacht.
reporting.effective_basal_series() heeft de starttijd van een Temp Basal
nodig om de basaalsnelheid vroeg in de dag correct te berekenen — ook als
die temp basal al vóór middernacht is gestart en doorloopt. Zonder deze
marge zou de eerste uren van elke los opgevraagde dag ten onrechte het
profieltempo tonen in plaats van een nog actieve temp basal. 6 uur ligt
ruim boven de gebruikelijke temp-basal-duur in gesloten-lus-modus
(doorgaans 30-60 minuten).
"""
since = datetime.combine(day, time.min, tzinfo=timezone.utc) - timedelta(hours=lookback_hours)
until = datetime.combine(day, time.max, tzinfo=timezone.utc)
return _treatments_since(since, until)
def fetch_profile() -> dict: def fetch_profile() -> dict:
"""Haal het meest recent gebruikte pompprofiel op. """Haal het meest recent gebruikte pompprofiel op.
+126 -100
View File
@@ -1,3 +1,5 @@
from datetime import timedelta
import pandas as pd import pandas as pd
import plotly.graph_objects as go import plotly.graph_objects as go
import requests import requests
@@ -7,16 +9,17 @@ from plotly.subplots import make_subplots
from auth import require_auth from auth import require_auth
from data_processing import TARGET_HIGH, TARGET_LOW, classify_treatments from data_processing import TARGET_HIGH, TARGET_LOW, classify_treatments
from nightscout_client import NightscoutConfigError from nightscout_client import NightscoutConfigError
from reporting import classify_bolus, effective_basal_series, target_glucose_series from reporting import BOLUS_CATEGORY_SMB, classify_bolus, daily_stats, effective_basal_series, target_glucose_series
from ui_common import ( from ui_common import (
MMOL_HOVERTEMPLATE,
add_basal_area_trace, add_basal_area_trace,
add_bolus_marker_traces, add_bolus_marker_traces,
add_carb_bars_trace, add_carb_bars_trace,
add_target_line_trace, add_target_line_trace,
add_threshold_lines, add_threshold_lines,
get_colors, get_colors,
load_day_data,
load_profile, load_profile,
load_range_data,
render_completeness_warning, render_completeness_warning,
select_period_days, select_period_days,
) )
@@ -25,17 +28,48 @@ st.set_page_config(page_title="Glucose dashboard — Dagoverzicht", page_icon="
require_auth() require_auth()
st.title("Dagoverzicht") st.title("Dagoverzicht")
st.caption("Eén glucosegrafiek per kalenderdag, met basaal/bolus/koolhydraten-overlay (FR4).") st.caption(
"Eén dag per keer: boven glucose met koolhydraten/bolus-overlay, onder basaal "
"en SMB — apart gehouden omdat één gecombineerde grafiek te veel informatie bevatte."
)
start, end, days = select_period_days(default_label="14 dagen") # De periode-knop bepaalt alleen het navigeerbare venster (hoe ver terug je kunt
if days > 31: # bladeren), niet welke data wordt opgehaald — dat gebeurt hieronder voor precies
st.info( # de geselecteerde dag (load_day_data), zodat het wisselen tussen dagen snel blijft.
"Bij langere periodes wordt de pagina lang en kan laden traag zijn — " window_start, window_end, days = select_period_days(default_label="14 dagen")
"voor het bekijken van individuele dagen is 14 of 30 dagen prettiger."
state_key = "dagoverzicht_selected_day"
if state_key not in st.session_state:
st.session_state[state_key] = window_end
if not (window_start <= st.session_state[state_key] <= window_end):
st.session_state[state_key] = window_end
selected_day = st.session_state[state_key]
col_prev, col_date, col_next = st.columns([1, 3, 1])
with col_prev:
if st.button("← Vorige dag", disabled=selected_day <= window_start, width="stretch"):
selected_day = selected_day - timedelta(days=1)
st.session_state[state_key] = selected_day
with col_next:
if st.button("Volgende dag →", disabled=selected_day >= window_end, width="stretch"):
selected_day = selected_day + timedelta(days=1)
st.session_state[state_key] = selected_day
with col_date:
date_options = [window_end - timedelta(days=i) for i in range(days)]
chosen = st.selectbox(
"Datum",
options=date_options,
index=date_options.index(selected_day),
format_func=lambda d: d.strftime("%A %d-%m-%Y"),
label_visibility="collapsed",
) )
if chosen != selected_day:
selected_day = chosen
st.session_state[state_key] = selected_day
try: try:
glucose, treatments = load_range_data(start, end) glucose, treatments = load_day_data(selected_day)
profile = load_profile() profile = load_profile()
except NightscoutConfigError as exc: except NightscoutConfigError as exc:
st.error(str(exc)) st.error(str(exc))
@@ -45,132 +79,124 @@ except (requests.RequestException, ValueError) as exc:
st.stop() st.stop()
if glucose.empty: if glucose.empty:
st.info("Geen glucosemetingen gevonden in deze periode.") st.info("Geen glucosemetingen gevonden op deze dag.")
st.stop() st.stop()
render_completeness_warning(glucose, start, end) render_completeness_warning(glucose, selected_day, selected_day)
colors = get_colors() colors = get_colors()
glucose = glucose.copy() day_start = pd.Timestamp(selected_day)
glucose["date"] = glucose["timestamp"].dt.date day_end = day_start + pd.Timedelta(1, unit="D")
glucose["hour"] = glucose["timestamp"].dt.hour + glucose["timestamp"].dt.minute / 60
carbs_events = classify_treatments(treatments)["carbs"].copy() # treatments bevat een lookback-marge vóór middernacht (zie
if not carbs_events.empty: # nightscout_client.fetch_treatments_for_day) zodat een temp basal die al
carbs_events["date"] = carbs_events["timestamp"].dt.date # vóór deze dag begon toch meetelt — effective_basal_series() clipt dat
carbs_events["hour"] = carbs_events["timestamp"].dt.hour + carbs_events["timestamp"].dt.minute / 60 # zelf aan [day_start, day_end). Voor de koolhydraten/bolus-overlays willen
# we die marge juist NIET, anders duiken gisteravond se stippen op vandaag op.
day_treatments = treatments[(treatments["timestamp"] >= day_start) & (treatments["timestamp"] < day_end)]
bolus_events = classify_bolus(treatments) carbs_day = classify_treatments(day_treatments)["carbs"]
if not bolus_events.empty: bolus_day = classify_bolus(day_treatments)
bolus_events["date"] = bolus_events["timestamp"].dt.date basal_day = effective_basal_series(profile, treatments, day_start, day_end)
bolus_events["hour"] = bolus_events["timestamp"].dt.hour + bolus_events["timestamp"].dt.minute / 60 target_day = target_glucose_series(profile, day_start, day_end)
def _close_step_to_day_end(df: pd.DataFrame, value_col: str) -> pd.DataFrame: 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 """Voeg een laatste punt op day_end toe (zelfde waarde als het laatste
segment) zodat een stapvlak/-lijn de volle dag doortekent i.p.v. te segment) zodat een stapvlak/-lijn de volle dag doortekent i.p.v. te
stoppen bij het laatste breekpunt.""" stoppen bij het laatste breekpunt."""
if df.empty: if df.empty:
return df return df
last_value = df.iloc[-1][value_col] last_value = df.iloc[-1][value_col]
closing_row = pd.DataFrame([{"hour": 24.0, value_col: last_value}]) closing_row = pd.DataFrame([{"timestamp": day_end, value_col: last_value}])
return pd.concat([df, closing_row], ignore_index=True) return pd.concat([df, closing_row], ignore_index=True)
dates = sorted(glucose["date"].unique()) basal_day = _close_step_to_day_end(basal_day, "rate")
row_height = 190 target_day = _close_step_to_day_end(target_day, "target")
fig = make_subplots(
rows=len(dates),
cols=1,
shared_xaxes=True,
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")
def build_day_figure(glucose_day, carbs_day, bolus_day, basal_day, target_day, colors) -> go.Figure:
fig = make_subplots(
rows=2,
cols=1,
shared_xaxes=True,
row_heights=[0.68, 0.32],
vertical_spacing=0.08,
specs=[[{"secondary_y": True}], [{"secondary_y": False}]],
)
# Rij 1: glucose + streefwaarde + koolhydraten + voeding-/correctiebolus.
fig.add_hrect( fig.add_hrect(
y0=TARGET_LOW, y1=TARGET_HIGH, fillcolor=colors["band"], line_width=0, layer="below", row=i, col=1 y0=TARGET_LOW, y1=TARGET_HIGH, fillcolor=colors["band"], line_width=0, layer="below", row=1, 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,
) )
add_target_line_trace(fig, target_day, colors, row=1)
add_carb_bars_trace(fig, carbs_day, colors, row=1, secondary_y=True)
basal_day = effective_basal_series(profile, treatments, day_start, day_end) top_bolus = bolus_day[bolus_day["category"] != BOLUS_CATEGORY_SMB] if not bolus_day.empty else bolus_day
if not basal_day.empty: lane_y = glucose_day["mmol"].min() - 1.0 if not glucose_day.empty else TARGET_LOW - 1.0
basal_day["hour"] = (basal_day["timestamp"] - day_start).dt.total_seconds() / 3600 add_bolus_marker_traces(fig, top_bolus, colors, lane_y, row=1)
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( fig.add_trace(
go.Scatter( go.Scatter(
x=day_df["hour"], x=glucose_day["timestamp"],
y=day_df["mmol"], y=glucose_day["mmol"],
mode="lines", mode="lines",
line={"color": colors["glucose"], "width": 2}, line={"color": colors["glucose"], "width": 2},
name="Glucose", name="Glucose",
hovertemplate="%{y:.1f} mmol/L<extra></extra>", hovertemplate=MMOL_HOVERTEMPLATE,
), ),
row=i, row=1,
col=1, col=1,
secondary_y=False,
) )
fig.update_yaxes(title_text="mmol/L", gridcolor=colors["grid"], row=i, col=1, secondary_y=False) add_threshold_lines(fig, colors, row=1)
fig.update_yaxes(title_text="mmol/L", gridcolor=colors["grid"], row=1, col=1, secondary_y=False)
fig.update_yaxes(title_text="Koolhydraten (g)", showgrid=False, row=1, col=1, secondary_y=True)
# Rij 2: basaal + SMB — apart van rij 1 zodat de glucosegrafiek niet
# overladen raakt met insulinedetails.
add_basal_area_trace(fig, basal_day, colors, row=2)
smb_bolus = bolus_day[bolus_day["category"] == BOLUS_CATEGORY_SMB] if not bolus_day.empty else bolus_day
smb_lane_y = (basal_day["rate"].max() * 1.15) if not basal_day.empty else 1.0
add_bolus_marker_traces(fig, smb_bolus, colors, smb_lane_y, row=2)
fig.update_yaxes( fig.update_yaxes(
title_text="Basaal (E/uur) · KH (g)", showgrid=False, row=i, col=1, secondary_y=True title_text="Basaal (E/uur)", gridcolor=colors["grid"], rangemode="tozero", row=2, col=1
) )
add_threshold_lines(fig, colors, row=i)
# Elke dag herhaalt dezelfde spoornamen (Glucose/Basaal/Koolhydraten/...) — zonder fig.update_xaxes(range=[day_start, day_end], dtick=4 * 3600 * 1000, gridcolor=colors["grid"])
# deduplicatie zou de legenda 7x "Glucose" etc. tonen. Alleen de eerste keer per fig.update_layout(
# naam blijft in de legenda staan. paper_bgcolor="rgba(0,0,0,0)",
seen_names = set() plot_bgcolor="rgba(0,0,0,0)",
for trace in fig.data: font_color=colors["text"],
if trace.name in seen_names: height=560,
trace.showlegend = False showlegend=True,
else: legend={"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "left", "x": 0},
seen_names.add(trace.name) margin={"l": 10, "r": 10, "t": 40, "b": 10},
)
return fig
fig.update_xaxes(title_text="Uur van de dag", range=[0, 24], dtick=4, gridcolor=colors["grid"])
fig.update_layout( fig = build_day_figure(glucose, carbs_day, bolus_day, basal_day, target_day, colors)
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},
)
st.plotly_chart(fig, width="stretch") st.plotly_chart(fig, width="stretch")
stats = daily_stats(glucose, day_treatments, profile, day_start, day_end)
col1, col2, col3, col4 = st.columns(4)
col1.metric(
"Glucose (gem./min/max)",
f"{stats['mean_mmol']:.1f} mmol/L",
delta=f"{stats['min_mmol']:.1f} - {stats['max_mmol']:.1f}",
delta_color="off",
)
col2.metric("Totale insuline", f"{stats['total_insulin']:.1f} E")
col3.metric("Basaal / bolus / SMB", f"{stats['basal_insulin']:.1f} / {stats['bolus_insulin']:.1f} / {stats['smb_insulin']:.1f} E")
col4.metric(
"Hypo's / hyper's",
f"{stats['hypo_count']} / {stats['hyper_count']}",
delta=f"{stats['hypo_duration_minutes']:.0f} / {stats['hyper_duration_minutes']:.0f} min",
delta_color="off",
)
st.caption( st.caption(
f"{len(dates)} dagen · {len(glucose)} metingen · stippen = bolusinsuline " "Boven: stippen = bolusinsuline (voeding/correctie, grootte naar dosis) · staafjes = koolhydraten · "
"(voeding/correctie/SMB, grootte naar dosis) · vlak = basaal · staafjes = koolhydraten · " "streepstippellijn = streefwaarde uit pompprofiel. Onder: vlak = basaal · stippen = SMB."
"streepstippellijn = streefwaarde uit pompprofiel"
) )
+3 -3
View File
@@ -6,7 +6,7 @@ import streamlit as st
from auth import require_auth from auth import require_auth
from nightscout_client import NightscoutConfigError from nightscout_client import NightscoutConfigError
from profile_data import segments_table, segments_to_hourly_bars, total_daily_basal from profile_data import segments_table, segments_to_hourly_bars, total_daily_basal
from ui_common import get_colors, load_profile from ui_common import get_colors, hex_to_rgba, load_profile
st.set_page_config(page_title="Glucose dashboard — Insulineprofiel", page_icon="📊", layout="wide") st.set_page_config(page_title="Glucose dashboard — Insulineprofiel", page_icon="📊", layout="wide")
require_auth() require_auth()
@@ -42,8 +42,8 @@ fig = go.Figure(
mode="lines", mode="lines",
line={"shape": "hv", "color": colors["basal"], "width": 2}, line={"shape": "hv", "color": colors["basal"], "width": 2},
fill="tozeroy", fill="tozeroy",
fillcolor=colors["basal"], fillcolor=hex_to_rgba(colors["basal"], 0.3),
opacity=0.7, opacity=0.8,
name="Basaal", name="Basaal",
hovertemplate="%{x:.0f}:00 · %{y:.2f} E/uur<extra></extra>", hovertemplate="%{x:.0f}:00 · %{y:.2f} E/uur<extra></extra>",
) )
+36 -16
View File
@@ -230,7 +230,7 @@ def _insulin_profile_figure_png(profile: dict) -> bytes:
if bars: if bars:
hours = [b["hour"] for b in bars] hours = [b["hour"] for b in bars]
values = [b["value"] for b in bars] values = [b["value"] for b in bars]
ax.step(hours, values, where="post", color=_BASAL_COLOR, linewidth=1.5) ax.step(hours, values, where="post", color=_BASAL_COLOR, linewidth=1.5, alpha=0.8)
ax.fill_between(hours, values, step="post", color=_BASAL_COLOR, alpha=0.3) ax.fill_between(hours, values, step="post", color=_BASAL_COLOR, alpha=0.3)
ax.set_xlim(0, 24) ax.set_xlim(0, 24)
ax.set_xticks(range(0, 25, 2)) ax.set_xticks(range(0, 25, 2))
@@ -261,9 +261,8 @@ def _add_carbs_overlay(ax2, day_treatments: pd.DataFrame, day_start) -> None:
ax2.bar(hours, carbs["carbs"], width=0.25, color=_CARBS_COLOR, alpha=0.55, label="Koolhydraten") ax2.bar(hours, carbs["carbs"], width=0.25, color=_CARBS_COLOR, alpha=0.55, label="Koolhydraten")
def _add_bolus_overlay(ax, day_treatments: pd.DataFrame, day_start, lane_y: float) -> None: def _add_bolus_overlay(ax, bolus: pd.DataFrame, day_start, lane_y: float, categories: tuple[str, ...]) -> None:
bolus = classify_bolus(day_treatments) for category in categories:
for category in (BOLUS_CATEGORY_SMB, BOLUS_CATEGORY_CORRECTION, BOLUS_CATEGORY_FOOD):
subset = bolus[bolus["category"] == category] if not bolus.empty else bolus subset = bolus[bolus["category"] == category] if not bolus.empty else bolus
if subset.empty: if subset.empty:
continue continue
@@ -291,35 +290,56 @@ def _add_target_overlay(ax, profile: dict | None, day_start, day_end) -> None:
def _daily_figure_png( def _daily_figure_png(
day_df: pd.DataFrame, day_treatments: pd.DataFrame, profile: dict | None, day_label: str, day_start, day_end day_df: pd.DataFrame, day_treatments: pd.DataFrame, profile: dict | None, day_label: str, day_start, day_end
) -> bytes: ) -> bytes:
"""Eén dag als grafiek: glucoselijn + basaal/bolus/koolhydraten/streefwaarde-overlay.""" """Eén dag als twee gestapelde grafieken (gedeelde tijdas, één gedeelde legenda):
boven glucose + streefwaarde + koolhydraten + voeding/correctiebolus, onder
basaal + SMB — gescheiden omdat één gecombineerde grafiek te veel informatie
bevatte om nog leesbaar te zijn."""
hours = day_df["timestamp"].dt.hour + day_df["timestamp"].dt.minute / 60 hours = day_df["timestamp"].dt.hour + day_df["timestamp"].dt.minute / 60
fig, ax = plt.subplots(figsize=(11, 3.8)) fig, (ax, ax_basal) = plt.subplots(2, 1, figsize=(11, 5.2), sharex=True, height_ratios=[2.2, 1])
ax2 = ax.twinx() ax2 = ax.twinx()
basal = effective_basal_series(profile, day_treatments, day_start, day_end) bolus = classify_bolus(day_treatments)
_add_basal_overlay(ax2, basal, day_start)
_add_carbs_overlay(ax2, day_treatments, day_start) _add_carbs_overlay(ax2, day_treatments, day_start)
lane_y = (day_df["mmol"].min() - 1.0) if not day_df.empty else LOW - 1.0 lane_y = (day_df["mmol"].min() - 1.0) if not day_df.empty else LOW - 1.0
_add_bolus_overlay(ax, day_treatments, day_start, lane_y) _add_bolus_overlay(ax, bolus, day_start, lane_y, categories=(BOLUS_CATEGORY_CORRECTION, BOLUS_CATEGORY_FOOD))
_add_target_overlay(ax, profile, day_start, day_end) _add_target_overlay(ax, profile, day_start, day_end)
ax.plot(hours, day_df["mmol"], color="#2a78d6", linewidth=1.4, label="Glucose", zorder=6) ax.plot(hours, day_df["mmol"], color="#2a78d6", linewidth=1.4, label="Glucose", zorder=6)
_add_threshold_lines(ax) _add_threshold_lines(ax)
ax.set_xlim(0, 24)
ax.set_xticks(range(0, 25, 2))
ax.set_ylabel("mmol/L", fontsize=9) ax.set_ylabel("mmol/L", fontsize=9)
ax2.set_ylabel("Basaal (E/uur) · KH (g)", fontsize=9) ax2.set_ylabel("Koolhydraten (g)", fontsize=9)
ax.set_title(day_label, fontsize=11, loc="left") ax.set_title(day_label, fontsize=11, loc="left")
ax.tick_params(labelsize=8) ax.tick_params(labelsize=8)
ax2.tick_params(labelsize=8) ax2.tick_params(labelsize=8)
basal = effective_basal_series(profile, day_treatments, day_start, day_end)
_add_basal_overlay(ax_basal, basal, day_start)
smb_lane_y = (basal["rate"].max() * 1.15) if not basal.empty else 1.0
_add_bolus_overlay(ax_basal, bolus, day_start, smb_lane_y, categories=(BOLUS_CATEGORY_SMB,))
ax_basal.set_xlim(0, 24)
ax_basal.set_xticks(range(0, 25, 2))
ax_basal.set_xlabel(_HOUR_OF_DAY_LABEL)
ax_basal.set_ylabel("Basaal (E/uur)", fontsize=9)
ax_basal.tick_params(labelsize=8)
lines1, labels1 = ax.get_legend_handles_labels() lines1, labels1 = ax.get_legend_handles_labels()
lines2, labels2 = ax2.get_legend_handles_labels() lines2, labels2 = ax2.get_legend_handles_labels()
ax.legend( lines3, labels3 = ax_basal.get_legend_handles_labels()
lines1 + lines2, labels1 + labels2, loc="upper center", bbox_to_anchor=(0.5, -0.2), ncol=6, fontsize=7, frameon=False ax_basal.legend(
lines1 + lines2 + lines3,
labels1 + labels2 + labels3,
loc="upper center",
bbox_to_anchor=(0.5, -0.35),
ncol=6,
fontsize=7,
frameon=False,
) )
fig.tight_layout() # fig.tight_layout() introspecteert elke as se bounding box om marges te
# berekenen, en matplotlib ondersteunt dat niet betrouwbaar zodra er twee
# hoofd-assen (ax/ax_basal) plus een twinx (ax2) tegelijk in de figuur
# zitten (waarschuwing "Axes ... not compatible with tight_layout").
# subplots_adjust() met vaste marges omzeilt die introspectie.
fig.subplots_adjust(left=0.06, right=0.94, top=0.93, bottom=0.24, hspace=0.35)
return _fig_to_png(fig) return _fig_to_png(fig)
+44
View File
@@ -168,6 +168,50 @@ def test_fetch_treatments_range_filters_to_date_boundaries(monkeypatch):
assert result.iloc[0]["insulin"] == pytest.approx(4.0) assert result.iloc[0]["insulin"] == pytest.approx(4.0)
def test_fetch_entries_for_day_scopes_to_a_single_calendar_day(monkeypatch):
monkeypatch.setenv("NIGHTSCOUT_URL", "https://example.com")
monkeypatch.delenv("NIGHTSCOUT_TOKEN", raising=False)
monkeypatch.delenv("NIGHTSCOUT_API_SECRET", raising=False)
payload = [
{"date": int(datetime(2026, 1, 15, 12, 0, tzinfo=timezone.utc).timestamp() * 1000), "sgv": 126},
{"date": int(datetime(2026, 1, 16, 12, 0, tzinfo=timezone.utc).timestamp() * 1000), "sgv": 100},
]
monkeypatch.setattr(ns.requests, "get", lambda *a, **k: FakeResponse(payload))
result = ns.fetch_entries_for_day(date(2026, 1, 15))
assert len(result) == 1
assert result.iloc[0]["timestamp"].date() == date(2026, 1, 15)
def test_fetch_treatments_for_day_includes_lookback_before_midnight(monkeypatch):
"""Een Temp Basal die net vóór middernacht is gestart moet toch meekomen
(zie de docstring bij fetch_treatments_for_day) — zonder lookback-marge
zou effective_basal_series() de vroege ochtenduren fout berekenen."""
monkeypatch.setenv("NIGHTSCOUT_URL", "https://example.com")
payload = [
{"created_at": "2026-01-14T22:00:00Z", "eventType": "Temp Basal", "absolute": 1.2, "duration": 480},
{"created_at": "2026-01-15T12:00:00Z", "eventType": "Meal Bolus", "insulin": 4.0, "carbs": 40},
{"created_at": "2026-01-10T08:00:00Z", "eventType": "Meal Bolus", "insulin": 3.0, "carbs": 30},
]
monkeypatch.setattr(ns.requests, "get", lambda *a, **k: FakeResponse(payload))
result = ns.fetch_treatments_for_day(date(2026, 1, 15), lookback_hours=6)
assert len(result) == 2 # de 10 januari-bolus valt buiten de lookback-marge
assert set(result["eventType"]) == {"Temp Basal", "Meal Bolus"}
def test_fetch_treatments_for_day_excludes_treatments_before_the_lookback_window(monkeypatch):
monkeypatch.setenv("NIGHTSCOUT_URL", "https://example.com")
payload = [
{"created_at": "2026-01-14T12:00:00Z", "eventType": "Meal Bolus", "insulin": 4.0, "carbs": 40},
]
monkeypatch.setattr(ns.requests, "get", lambda *a, **k: FakeResponse(payload))
result = ns.fetch_treatments_for_day(date(2026, 1, 15), lookback_hours=6)
assert result.empty
def _entry(dt, sgv=100): def _entry(dt, sgv=100):
return {"date": int(dt.timestamp() * 1000), "sgv": sgv, "dateString": dt.isoformat()} return {"date": int(dt.timestamp() * 1000), "sgv": sgv, "dateString": dt.isoformat()}
+26 -1
View File
@@ -14,7 +14,13 @@ 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_for_day,
fetch_entries_range,
fetch_profile,
fetch_treatments_for_day,
fetch_treatments_range,
)
from profile_data import parse_profile from profile_data import parse_profile
from reporting import ( from reporting import (
HIGH, HIGH,
@@ -148,6 +154,25 @@ def load_range_data(start: date, end: date): # pragma: no cover
return glucose, treatments return glucose, treatments
@st.cache_data(ttl=300, show_spinner="Data ophalen van Nightscout...")
def load_day_data(day: date): # pragma: no cover
"""Gecachete Nightscout-fetch voor precies één kalenderdag (Dagoverzicht-navigatie).
Kortere TTL dan load_range_data (5 min i.p.v. 1 uur): in tegenstelling tot
een afgeronde periode kan de geselecteerde dag "vandaag" zijn, waar
nieuwe metingen blijven binnenkomen. fetch_treatments_for_day() haalt
zelf een lookback-marge vóór middernacht op zodat effective_basal_series()
de vroege ochtenduren correct kan berekenen.
Dunne st.cache_data-wrapper rond al-geteste fetch_entries_for_day()/
fetch_treatments_for_day() — buiten scope van de unit-testdekking net als
pages/*.py, zie pyproject.toml se coverage-omit-lijst.
"""
glucose = fetch_entries_for_day(day)
treatments = fetch_treatments_for_day(day)
return glucose, treatments
@st.cache_data(ttl=21600, show_spinner="Profiel ophalen van Nightscout...") @st.cache_data(ttl=21600, show_spinner="Profiel ophalen van Nightscout...")
def load_profile() -> dict: # pragma: no cover def load_profile() -> dict: # pragma: no cover
"""Gecachete profiel-fetch. Langere TTL dan load_range_data (6 uur i.p.v. """Gecachete profiel-fetch. Langere TTL dan load_range_data (6 uur i.p.v.