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.
255 lines
10 KiB
Python
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)
|