Files
willemjan 9e11dd8d73 Fix bolus/basal insulin categorization: split SMB from manual corrections, add basal calc
AndroidAPS records an automatic SMB micro-bolus as eventType "Correction
Bolus" with type "SMB", not as its own eventType. bolus_stats() was
grouping by eventType alone, so every SMB was silently counted as a manual
correction bolus. Verified against production data: 100% of "Correction
Bolus" treatments over 90 days were actually type=SMB.

Also adds basal_stats(), computing delivered basal directly from Temp
Basal treatments (rate x duration) without needing the pump profile.

Closes gitea #20.
2026-08-01 22:52:54 +02:00

255 lines
10 KiB
Python

"""
Nightscout API client.
Praat met een bestaande Nightscout-instantie (CGM-data, insuline/koolhydraten
en pomp-/sensorwissels). Authenticatie loopt via environment variables:
- NIGHTSCOUT_URL: basis-URL van je Nightscout-instantie, bijv.
"https://jouw-nightscout.example.com" (verplicht).
- NIGHTSCOUT_TOKEN: een Nightscout API-token (rol-gebaseerd), OF
- NIGHTSCOUT_API_SECRET: het "API secret" van je instantie (wordt lokaal
ge-hashed, nooit in plaintext verstuurd).
Gebruik precies één van de twee auth-opties; als beide gezet zijn heeft het
token voorrang.
"""
from __future__ import annotations
import hashlib
import os
from datetime import date, datetime, time, timedelta, timezone
import pandas as pd
import requests
MGDL_PER_MMOL = 18.0182
INSULIN_EVENT_TYPES = {"Bolus", "Meal Bolus", "Correction Bolus", "Combo Bolus", "SMB"}
SITE_CHANGE_EVENT_TYPES = {"Site Change"}
SENSOR_CHANGE_EVENT_TYPES = {"Sensor Change", "Sensor Start"}
_REQUEST_TIMEOUT = 10
# Nightscout's /api/v1/entries en /api/v1/treatments negeren een groot
# `count`-verzoek: het antwoord wordt hoe dan ook stilzwijgend afgeknipt rond
# een paar honderd tot ~1150 documenten, ongeacht hoe ver de opgevraagde
# periode teruggaat (waargenomen: count=100000 op entries gaf slechts de
# meest recente ~1150 metingen terug — nog geen 4 dagen — terwijl er wel
# degelijk weken oudere data in Nightscout beschikbaar bleek te zijn). Eén
# verzoek is dus niet genoeg voor meerdaagse rapportages; _paginated_get()
# loopt terug in de tijd met `find[...][$lte]`-cursors tot de gevraagde
# `since`-grens bereikt is of de server minder dan een volle pagina teruggeeft
# (= geen oudere data meer beschikbaar, wat verschilt van "afgeknipt").
#
# De twee endpoints filteren op een ANDER veld, en /treatments heeft nog een
# addertje: entries filtert/sorteert op het numerieke `date` (epoch ms) en
# geeft zonder filter gewoon de meest recente `count` terug. treatments
# filtert/sorteert op het string-veld `created_at` (ISO 8601) — filteren op
# `date` levert daar stil een lege pagina op zodra je voorbij een intern
# venster van ~4 dagen gaat, ook al bevatten de documenten prima een geldig
# `date`-veld (geverifieerd rechtstreeks in MongoDB: alle 123k+ treatments
# hebben een numeriek `date`, en zonder enig filter geeft het endpoint alsnog
# stilzwijgend niet meer dan ~4 dagen terug). Zodra je expliciet op
# `created_at` filtert — zelfs met "nu" als bovengrens op de eerste pagina —
# werkt paginering wél correct en is de volledige geschiedenis (maanden,
# geverifieerd in de database) gewoon opvraagbaar. Vandaar: de cursor wordt
# nooit weggelaten (ook niet op de allereerste pagina), en welk veld
# gebruikt wordt is per endpoint expliciet ("date" voor entries,
# "created_at" voor treatments) — zie ENTRIES_CURSOR_FIELD/TREATMENTS_CURSOR_FIELD.
_PAGE_SIZE = 1000
_MAX_PAGES = 150 # 150 000 documenten; ruim boven wat een 90-dagenrapport nodig heeft
ENTRIES_CURSOR_FIELD = "date"
TREATMENTS_CURSOR_FIELD = "created_at"
class NightscoutConfigError(RuntimeError):
"""NIGHTSCOUT_URL ontbreekt of is ongeldig."""
def mgdl_to_mmol(value: float) -> float:
"""Reken mg/dL (Nightscout's interne eenheid) om naar mmol/L."""
return round(value / MGDL_PER_MMOL, 1)
def _base_url() -> str:
url = os.environ.get("NIGHTSCOUT_URL", "").strip().rstrip("/")
if not url:
raise NightscoutConfigError(
"NIGHTSCOUT_URL is niet ingesteld. Zet deze environment variable, "
"bijv. https://jouw-nightscout.example.com"
)
return url
def _auth() -> tuple[dict, dict]:
"""Geeft (headers, params) terug voor authenticatie met Nightscout."""
token = os.environ.get("NIGHTSCOUT_TOKEN", "").strip()
api_secret = os.environ.get("NIGHTSCOUT_API_SECRET", "").strip()
if token:
return {}, {"token": token}
if api_secret:
hashed = hashlib.sha1(api_secret.encode("utf-8")).hexdigest()
return {"API-SECRET": hashed}, {}
return {}, {}
def _get(path: str, params: dict) -> list:
headers, auth_params = _auth()
response = requests.get(
f"{_base_url()}{path}",
headers=headers,
params={**params, **auth_params},
timeout=_REQUEST_TIMEOUT,
)
response.raise_for_status()
return response.json()
def _format_cursor(cursor_dt: datetime, cursor_field: str) -> int | str:
"""Zet een cursor-datetime om naar de vorm die het gekozen veld verwacht."""
if cursor_field == ENTRIES_CURSOR_FIELD:
return int(cursor_dt.timestamp() * 1000)
# created_at: milliseconde-precisie ISO 8601 met Z-suffix, exact het
# formaat dat Nightscout zelf in created_at opslaat.
return cursor_dt.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
def _parse_item_time(item: dict, cursor_field: str) -> datetime | None:
"""Lees het tijdstip van een document uit het veld waar dit endpoint op filtert."""
if cursor_field not in item:
return None
if cursor_field == ENTRIES_CURSOR_FIELD:
return datetime.fromtimestamp(item[cursor_field] / 1000, tz=timezone.utc)
return datetime.fromisoformat(item[cursor_field].replace("Z", "+00:00"))
def _paginated_get(
path: str, since: datetime, until: datetime | None, cursor_field: str
) -> list:
"""Haal alle documenten op tussen `since` en `until` (of tot nu), pagina voor pagina.
Zie de toelichting bij _PAGE_SIZE hierboven voor waarom dit niet één
verzoek met een groot `count` kan zijn, en waarom `cursor_field` per
endpoint verschilt en nooit wordt weggelaten (BUG, zowel de originele
count-truncatie als treatments' verborgen ~4-dagenvenster zonder
expliciet find[created_at]-filter). Elke pagina wordt opgevraagd met een
cursor net onder de oudste timestamp van de vorige pagina, zodat elk
document precies één keer terugkomt. Stopt zodra de oudste timestamp in
een pagina op of voorbij `since` ligt, of zodra de server minder dan een
volle pagina teruggeeft (dan is er geen oudere data meer, punt).
"""
cursor_dt = until if until is not None else datetime.now(timezone.utc)
results: list = []
for _ in range(_MAX_PAGES):
params = {"count": _PAGE_SIZE, f"find[{cursor_field}][$lte]": _format_cursor(cursor_dt, cursor_field)}
page = _get(path, params)
if not page:
break
results.extend(page)
item_times = [t for t in (_parse_item_time(item, cursor_field) for item in page) if t is not None]
if not item_times:
break
oldest_dt = min(item_times)
if oldest_dt <= since or len(page) < _PAGE_SIZE:
break
cursor_dt = oldest_dt - timedelta(milliseconds=1)
return results
def _entries_since(since: datetime, until: datetime | None = None) -> pd.DataFrame:
"""Haal entries op sinds `since` (en optioneel tot `until`), lokaal gefilterd."""
data = _paginated_get("/api/v1/entries/sgv.json", since, until, ENTRIES_CURSOR_FIELD)
if not data:
return pd.DataFrame(columns=["timestamp", "mmol"])
df = pd.DataFrame(data)
df["timestamp"] = pd.to_datetime(df["date"], unit="ms", utc=True).dt.tz_localize(None)
df["mmol"] = df["sgv"].astype(float).apply(mgdl_to_mmol)
df = df[df["timestamp"] >= since.replace(tzinfo=None)]
if until is not None:
df = df[df["timestamp"] <= until.replace(tzinfo=None)]
return df[["timestamp", "mmol"]].sort_values("timestamp").reset_index(drop=True)
_TREATMENT_COLUMNS = ["timestamp", "eventType", "insulin", "carbs", "type", "absolute", "duration"]
def _treatments_since(since: datetime, until: datetime | None = None) -> pd.DataFrame:
"""Haal treatments op sinds `since` (en optioneel tot `until`), lokaal gefilterd.
`type` en `absolute`/`duration` komen erbij naast de oorspronkelijke
kolommen: AndroidAPS registreert een SMB-microbolus niet als eigen
`eventType`, maar als `eventType == "Correction Bolus"` met
`type == "SMB"` (zie reporting.bolus_stats() / gitea #20) — en
`absolute` (E/uur) + `duration` (minuten) op Temp Basal-treatments maken
het mogelijk afgeleverde basaalinsuline te berekenen zonder het
pompprofiel te hoeven ophalen (zie reporting.basal_stats()).
"""
data = _paginated_get("/api/v1/treatments.json", since, until, TREATMENTS_CURSOR_FIELD)
if not data:
return pd.DataFrame(columns=_TREATMENT_COLUMNS)
df = pd.DataFrame(data)
df["timestamp"] = pd.to_datetime(df["created_at"], utc=True).dt.tz_localize(None)
for col in ("insulin", "carbs", "absolute", "duration"):
if col not in df.columns:
df[col] = pd.NA
df[col] = pd.to_numeric(df[col], errors="coerce")
for col in ("eventType", "type"):
if col not in df.columns:
df[col] = ""
df[col] = df[col].fillna("")
df = df[df["timestamp"] >= since.replace(tzinfo=None)]
if until is not None:
df = df[df["timestamp"] <= until.replace(tzinfo=None)]
return df[_TREATMENT_COLUMNS].sort_values("timestamp").reset_index(drop=True)
def fetch_entries(hours: int = 24) -> pd.DataFrame:
"""Haal glucosemetingen (SGV) op van de laatste `hours` uur, in mmol/L.
Geeft een DataFrame terug met kolommen 'timestamp' en 'mmol', oplopend
gesorteerd op tijd.
"""
since = datetime.now(timezone.utc) - timedelta(hours=hours)
return _entries_since(since)
def fetch_treatments(hours: int = 24) -> pd.DataFrame:
"""Haal behandelingen op van de laatste `hours` uur: insuline, koolhydraten
en pomp-/sensorwissels.
Geeft een DataFrame terug met kolommen 'timestamp', 'eventType',
'insulin' en 'carbs' (de laatste twee NaN waar niet van toepassing).
"""
since = datetime.now(timezone.utc) - timedelta(hours=hours)
return _treatments_since(since)
def fetch_entries_range(start_date: date, end_date: date) -> pd.DataFrame:
"""Haal glucosemetingen op tussen start_date en end_date (beide inclusief).
Bedoeld voor meerdaagse rapportages (bijv. 90 dagen) waarbij het
uren-schalende `fetch_entries` niet praktisch is. Zelfde kolommen als
`fetch_entries`.
"""
since = datetime.combine(start_date, time.min, tzinfo=timezone.utc)
until = datetime.combine(end_date, time.max, tzinfo=timezone.utc)
return _entries_since(since, until)
def fetch_treatments_range(start_date: date, end_date: date) -> pd.DataFrame:
"""Haal behandelingen op tussen start_date en end_date (beide inclusief).
Zelfde kolommen als `fetch_treatments`.
"""
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)