_entries_since/_treatments_since requested a single page via `count`, but Nightscout's /api/v1/entries and /api/v1/treatments ignore a large `count` and cap the response around ~1150/~700 documents regardless of how far back the requested period goes. Every report page relying on fetch_entries_range/fetch_treatments_range (7/14/30/90-day selectors) was silently only getting the most recent ~3-4 days, no matter the selected period. Fixed by paginating with `find[date][$lte]` cursors in a new _paginated_get(), walking back until the requested `since` boundary is reached or the server returns a short/empty page (= genuinely no more data, confirmed by manually paginating against the real instance: entries go back 50+ days, treatments only ~4 days on this install). Also added reporting.completeness_warning(), shared by the interactive pages (via ui_common.render_completeness_warning) and the PDF export, that distinguishes "a structural gap at the start of the period" (data wasn't fetched/available) from "scattered gaps below the clinical AGP coverage threshold" (Danne et al. 2017) — the previous single generic "X% sensor coverage" message read as a CGM/sensor problem even when the real cause was this fetch bug.
162 lines
5.7 KiB
Python
162 lines
5.7 KiB
Python
"""
|
|
Gedeelde UI-hulpmiddelen voor app.py en alle pages/*.py.
|
|
|
|
Elke Streamlit-pagina draait als los script, dus gedeelde constanten/functies
|
|
(kleurenpalet, thema-detectie) horen hier thuis in plaats van gedupliceerd te
|
|
worden per pagina.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date, datetime, timedelta, timezone
|
|
|
|
import pandas as pd
|
|
import plotly.graph_objects as go
|
|
import streamlit as st
|
|
|
|
from nightscout_client import fetch_entries_range, fetch_treatments_range
|
|
from reporting import completeness_warning, detect_events, summarize_events
|
|
|
|
MMOL_HOVERTEMPLATE = "%{x|%d-%m %H:%M} · %{y:.1f} mmol/L<extra></extra>"
|
|
|
|
# Periode-opties voor de rapportagepagina's (FR1): elke pagina kiest zijn eigen
|
|
# standaard (90 dagen voor statistiek/TIR, 14 dagen voor recente
|
|
# glucosegrafieken) via `default_label`.
|
|
PERIOD_OPTIONS_DAYS = {"14 dagen": 14, "30 dagen": 30, "90 dagen": 90}
|
|
|
|
# Kleuren per rol, licht/donker — categorale kleuren voor identiteit
|
|
# (glucose/insuline/carbs), status-kleuren (vast, nooit hergebruikt) voor
|
|
# te laag/te hoog.
|
|
PALETTE = {
|
|
"light": {
|
|
"glucose": "#2a78d6",
|
|
"insulin": "#1baf7a",
|
|
"carbs": "#eb6834",
|
|
"low": "#d03b3b",
|
|
"very_low": "#d03b3b",
|
|
"high": "#fab219",
|
|
"very_high": "#fab219",
|
|
"band": "rgba(12,163,12,0.08)",
|
|
"grid": "#e1e0d9",
|
|
"muted": "#898781",
|
|
"text": "#52514e",
|
|
"good": "#0ca30c",
|
|
},
|
|
"dark": {
|
|
"glucose": "#3987e5",
|
|
"insulin": "#199e70",
|
|
"carbs": "#d95926",
|
|
"low": "#d03b3b",
|
|
"very_low": "#d03b3b",
|
|
"high": "#fab219",
|
|
"very_high": "#fab219",
|
|
"band": "rgba(12,163,12,0.15)",
|
|
"grid": "#2c2c2a",
|
|
"muted": "#898781",
|
|
"text": "#c3c2b7",
|
|
"good": "#0ca30c",
|
|
},
|
|
}
|
|
|
|
|
|
def hex_to_rgba(hex_color: str, alpha: float) -> str:
|
|
"""Zet een '#rrggbb'-kleur om naar een 'rgba(r,g,b,a)'-string voor Plotly-fills."""
|
|
hex_color = hex_color.lstrip("#")
|
|
r, g, b = (int(hex_color[i : i + 2], 16) for i in (0, 2, 4))
|
|
return f"rgba({r},{g},{b},{alpha})"
|
|
|
|
|
|
def get_colors() -> dict:
|
|
"""Geef de kleurenset terug die past bij het actieve Streamlit-thema."""
|
|
theme_type = "light"
|
|
try:
|
|
theme_type = st.context.theme.type or "light"
|
|
except AttributeError:
|
|
pass
|
|
return PALETTE["dark" if theme_type == "dark" else "light"]
|
|
|
|
|
|
def select_period_days(default_label: str) -> tuple[date, date, int]:
|
|
"""Periodekiezer voor rapportagepagina's; geeft (start, eind, aantal_dagen) terug."""
|
|
labels = list(PERIOD_OPTIONS_DAYS.keys())
|
|
label = st.radio("Periode", labels, index=labels.index(default_label), horizontal=True)
|
|
days = PERIOD_OPTIONS_DAYS[label]
|
|
end = datetime.now(tz=timezone.utc).date()
|
|
start = end - timedelta(days=days - 1)
|
|
return start, end, days
|
|
|
|
|
|
@st.cache_data(ttl=3600, show_spinner="Data ophalen van Nightscout...")
|
|
def load_range_data(start: date, end: date):
|
|
"""Gecachete Nightscout-fetch voor een vaste periode.
|
|
|
|
Lange TTL (in tegenstelling tot de 60s van de live-pagina): historische
|
|
data van een afgeronde periode verandert niet.
|
|
"""
|
|
glucose = fetch_entries_range(start, end)
|
|
treatments = fetch_treatments_range(start, end)
|
|
return glucose, treatments
|
|
|
|
|
|
def render_completeness_warning(glucose: pd.DataFrame, start: date, end: date) -> None:
|
|
"""Toon reporting.completeness_warning()'s oordeel als st.warning, indien nodig.
|
|
|
|
Gebruikt door elke rapportagepagina, direct na de load_range_data-call.
|
|
De beoordelingslogica zelf staat in reporting.py (pure functie) zodat het
|
|
PDF-rapport (pdf_export.py) exact dezelfde disclaimer kan hergebruiken.
|
|
"""
|
|
message = completeness_warning(glucose, start, end)
|
|
if message:
|
|
st.warning(message)
|
|
|
|
|
|
def render_event_level(
|
|
glucose: pd.DataFrame,
|
|
threshold: float,
|
|
direction: str,
|
|
level_name: str,
|
|
period_days: int,
|
|
colors: dict,
|
|
) -> None:
|
|
"""Render de metrics/tabel/tijdstip-histogram voor één hypo-/hyperniveau.
|
|
|
|
Gedeeld tussen de Hypo Events- en Hyper Events-pagina's (FR5/FR6), die
|
|
elk twee niveaus (Level 1/Level 2) op dezelfde manier tonen.
|
|
"""
|
|
events = detect_events(glucose, threshold, direction)
|
|
summary = summarize_events(events, period_days)
|
|
|
|
st.subheader(level_name)
|
|
cols = st.columns(4)
|
|
cols[0].metric("Aantal events", summary["count"])
|
|
cols[1].metric("Gem. duur", f"{summary['avg_duration_minutes']:.0f} min")
|
|
cols[2].metric("Langste event", f"{summary['longest_duration_minutes']:.0f} min")
|
|
cols[3].metric("Events/week", f"{summary['events_per_week']:.1f}")
|
|
|
|
if events.empty:
|
|
st.info("Geen events gevonden in deze periode.")
|
|
return
|
|
|
|
bar_color = colors["low"] if direction == "below" else colors["high"]
|
|
hours = events["start"].dt.hour
|
|
hour_counts = hours.value_counts().reindex(range(24), fill_value=0).sort_index()
|
|
fig = go.Figure(go.Bar(x=list(hour_counts.index), y=hour_counts.to_numpy(), marker_color=bar_color))
|
|
fig.update_layout(
|
|
paper_bgcolor="rgba(0,0,0,0)",
|
|
plot_bgcolor="rgba(0,0,0,0)",
|
|
font_color=colors["text"],
|
|
height=220,
|
|
margin={"l": 10, "r": 10, "t": 10, "b": 10},
|
|
xaxis={"title": "Uur van de dag", "dtick": 2, "gridcolor": colors["grid"]},
|
|
yaxis={"title": "Aantal events", "gridcolor": colors["grid"]},
|
|
)
|
|
st.plotly_chart(fig, width="stretch")
|
|
|
|
display = events.copy()
|
|
display["start"] = display["start"].dt.strftime("%d-%m-%Y %H:%M")
|
|
display["end"] = display["end"].dt.strftime("%d-%m-%Y %H:%M")
|
|
display = display.rename(
|
|
columns={"start": "Start", "end": "Einde", "duration_minutes": "Duur (min)"}
|
|
)
|
|
st.dataframe(display, width="stretch", hide_index=True)
|