Merge pull request 'Fix multi-day Nightscout fetch truncation + data-completeness warnings' (#12) from dev into main
This commit was merged in pull request #12.
This commit is contained in:
@@ -0,0 +1,328 @@
|
|||||||
|
> **Moved.** Implementation now lives in the separate `glucose-profile-tuning`
|
||||||
|
> repo (per this doc's own §2 isolation decision); the canonical, actively
|
||||||
|
> maintained copy of this file is at `glucose-profile-tuning/docs/design-profile-tuning.md`.
|
||||||
|
> This copy is kept for history and is no longer updated.
|
||||||
|
|
||||||
|
# Design specification: oref0-autotune profile tuning
|
||||||
|
|
||||||
|
Status: **final — user reviewed**
|
||||||
|
Requirements: [requirements-profile-tuning.md](requirements-profile-tuning.md) (FR1–FR9, NFR1–NFR5)
|
||||||
|
Related: [design-reporting.md](design-reporting.md) (same conventions, separate app)
|
||||||
|
|
||||||
|
This is a design spec, not code. Nothing here has been implemented yet —
|
||||||
|
implementation starts after this doc is reviewed/approved, one phase at a
|
||||||
|
time, each shipped through its own CI/CD pipeline (§8).
|
||||||
|
|
||||||
|
## 1. FR1 recap — infra decision (already executed)
|
||||||
|
|
||||||
|
FR1 (boot + inspect LXC 102/107) is done; full findings are recorded directly
|
||||||
|
in [requirements-profile-tuning.md §4 FR1](requirements-profile-tuning.md).
|
||||||
|
Summary: **LXC 102 (`autotune`, 192.168.2.13)** already has a working, pinned
|
||||||
|
Docker image — `ghcr.io/houthacker/nightscout-autotune:latest` — that bundles
|
||||||
|
the *entire* oref0 npm toolkit, not just the autotune algorithm:
|
||||||
|
|
||||||
|
| Tool (already installed in the image) | Role in this design |
|
||||||
|
|---|---|
|
||||||
|
| `nightscout-autotune` (image's default CMD) | Fetches NS entries/treatments/profile, runs oref0-autotune, writes results to `$OPENAPS_WORKDIR` |
|
||||||
|
| `oref0-upload-profile profile.json NSURL secret [--preview] [--switch]` | Converts an oref0-format profile back to Nightscout's profile-store format **and** uploads it, optionally issuing a Profile Switch treatment |
|
||||||
|
| `convert-ns-profile` / `mm-format-ns-profile` | The NS-format ⇄ oref0-format conversion primitives, used internally by the two tools above |
|
||||||
|
|
||||||
|
This changes the shape of the whole feature for the better: **we do not need
|
||||||
|
to write any Nightscout-profile-format conversion code ourselves.** Both
|
||||||
|
directions (NS → oref0 for analysis, oref0 → NS for staging) are already
|
||||||
|
solved, tested, and previously proven against the user's real Nightscout
|
||||||
|
instance (bash history on LXC 102 shows a full run against
|
||||||
|
`https://zb-ns.duckdns.org` completing 9 months ago). This is exactly the
|
||||||
|
"had to manually parse parts of the profile" pain point called out in FR2 —
|
||||||
|
resolved by wrapping existing tools instead of reimplementing them.
|
||||||
|
|
||||||
|
LXC 107 is not used; it's a messier, native (non-Docker) install of the same
|
||||||
|
underlying toolkit with no functional advantage over 102. It stays stopped.
|
||||||
|
|
||||||
|
## 2. Architecture overview
|
||||||
|
|
||||||
|
Two new pieces, one existing piece untouched:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────┐ ┌──────────────────────────────────┐
|
||||||
|
│ LXC 120 (glucose-app) │ │ LXC 102 (autotune) │
|
||||||
|
│ │ │ │
|
||||||
|
│ ┌──────────────────────────┐ │ HTTPS │ ┌────────────────────────────────┐│
|
||||||
|
│ │ glucose-dashboard │ │ Bearer│ │ autotune-service (new) ││
|
||||||
|
│ │ (existing, untouched) │ │ token │ │ Node/Express, image FROM ││
|
||||||
|
│ └──────────────────────────┘ │◄───────┼─┤ ghcr.io/houthacker/ ││
|
||||||
|
│ │ │ │ nightscout-autotune ││
|
||||||
|
│ ┌──────────────────────────┐ │ │ │ - owns the NS write token ││
|
||||||
|
│ │ autotune-app (new) │─┼────────┼─▶ - owns the SQLite audit DB ││
|
||||||
|
│ │ Streamlit, own auth gate │ │ │ │ - runs analyses (spawn) ││
|
||||||
|
│ └──────────────────────────┘ │ │ │ - runs oref0-upload-profile ││
|
||||||
|
│ │ │ │ - nightly scheduler (04:00) ││
|
||||||
|
└──────────────────────────────┘ │ └────────────────────────────────┘│
|
||||||
|
│ │ │
|
||||||
|
└────────────┼─────────────────────┘
|
||||||
|
│ HTTPS (existing NS
|
||||||
|
│ read/write token)
|
||||||
|
▼
|
||||||
|
Nightscout (zb-ns.duckdns.org)
|
||||||
|
```
|
||||||
|
|
||||||
|
Key property driving this split: **the frontend never talks to Nightscout
|
||||||
|
directly, and never holds a Nightscout credential.** It only holds a shared
|
||||||
|
bearer token to call `autotune-service`. All Nightscout I/O — reading
|
||||||
|
entries/treatments/profile for analysis, and writing the staged profile /
|
||||||
|
Profile Switch — happens on LXC 102, inside `autotune-service`, using the one
|
||||||
|
write-scoped NS token (NFR2). This keeps the dosing-adjacent credential in
|
||||||
|
exactly one place, matches architecture decision #3 in the requirements doc
|
||||||
|
(isolating write-capable code from the read-only dashboard), and means the
|
||||||
|
Streamlit app can be redeployed or rebuilt at any time without touching
|
||||||
|
secrets or state — it's a pure, stateless UI.
|
||||||
|
|
||||||
|
Repo layout: one new repo, **`glucose-profile-tuning`**, with two
|
||||||
|
subdirectories and their own Dockerfiles:
|
||||||
|
- `service/` — the Node microservice (deployed to LXC 102)
|
||||||
|
- `app/` — the Streamlit frontend (deployed to LXC 120, alongside
|
||||||
|
`glucose-dashboard` as a second `docker-compose` service — that LXC has
|
||||||
|
~800MB RAM headroom today, plenty for a second, mostly-idle Streamlit UI)
|
||||||
|
|
||||||
|
One repo keeps this a single reviewable unit (matching how the user framed
|
||||||
|
FR1–FR9 as one feature); the two Dockerfiles/deploy targets keep the runtime
|
||||||
|
isolation NFR1 asks for.
|
||||||
|
|
||||||
|
## 3. `autotune-service` (LXC 102)
|
||||||
|
|
||||||
|
### 3.1 Why Node, not Python
|
||||||
|
|
||||||
|
The base image is Alpine + Node 24 with the entire oref0 toolkit already
|
||||||
|
installed as Node/shell scripts under `/usr/local/bin`. Adding Python +
|
||||||
|
FastAPI on top would mean two language runtimes doing the same job one
|
||||||
|
already does. Building the HTTP layer in Node (Express) lets it `spawn()`
|
||||||
|
`nightscout-autotune` and `oref0-upload-profile` as sibling processes in the
|
||||||
|
same container/filesystem — no Docker socket, no Docker-in-Docker. (Mounting
|
||||||
|
the host's `docker.sock` to let a container launch sibling containers was
|
||||||
|
considered and rejected — unnecessary privilege escalation for a homelab
|
||||||
|
service that already has direct in-image access to the tools it needs.)
|
||||||
|
|
||||||
|
### 3.2 Job model
|
||||||
|
|
||||||
|
A single SQLite table, `analysis_runs`:
|
||||||
|
|
||||||
|
| column | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | TEXT (uuid) | PK |
|
||||||
|
| `trigger` | TEXT | `manual` \| `nightly` |
|
||||||
|
| `requested_at` / `started_at` / `finished_at` | TEXT (ISO 8601) | |
|
||||||
|
| `status` | TEXT | `pending` \| `running` \| `done` \| `failed` |
|
||||||
|
| `days`, `profile_name`, `autosens_min`, `autosens_max`, `insulin_type`, `min_5m_carb_impact`, `uam_as_basal` | job params as submitted | `profile_name` nullable — omitted means "NS's current default", per the tool's own documented behavior |
|
||||||
|
| `error_message` | TEXT, nullable | |
|
||||||
|
| `current_profile_json` / `proposed_profile_json` | TEXT (JSON), nullable until done | oref0 pump-profile shape (`basalprofile[]`, `isf`, `carbratio`, `dia`, `autosens_min/max`) — this is the *input* `profile.json` and *output* `newprofile.json` the tool already produces |
|
||||||
|
| `recommendations_log` | TEXT, nullable | verbatim `autotune_recommendations.log` — human-readable Pump/Autotune/Days-missing table, used for FR4's estimation detail |
|
||||||
|
| `staged_at` | TEXT, nullable | set once FR6 staging succeeds |
|
||||||
|
| `staged_profile_json` | TEXT (JSON), nullable | the final merged profile actually sent to NS |
|
||||||
|
| `switched` | INTEGER (bool), nullable | whether `--switch` was used |
|
||||||
|
| `staging_decisions_json` | TEXT (JSON), nullable | per-slot accept/reject/custom record, for FR7 audit trail |
|
||||||
|
|
||||||
|
Only one job may be `pending`/`running` at a time (LXC 102 is 1 core / 1GB —
|
||||||
|
not built for concurrency); a second trigger while one is in flight gets
|
||||||
|
`409 Conflict`.
|
||||||
|
|
||||||
|
Job execution: `POST /v1/analyses` inserts a `pending` row, immediately
|
||||||
|
returns `{id, status: "pending"}`, then asynchronously:
|
||||||
|
1. mark `running`, set `started_at`
|
||||||
|
2. `spawn("nightscout-autotune", [], {env: {...NS_HOST, AUTOTUNE_DAYS, UAM_AS_BASAL, NS_TOKEN (sha1'd), NS_PROFILE, AUTOSENS_MIN/MAX, INSULIN_TYPE, MIN_5MIN_CARBIMPACT, OPENAPS_WORKDIR: "/data/jobs/<id>"}})`
|
||||||
|
3. on exit code 0: read `profile.json`, `newprofile.*.json`, and
|
||||||
|
`autotune_recommendations.log` out of the job's workdir, populate the row,
|
||||||
|
mark `done`
|
||||||
|
4. on non-zero exit: capture stderr/stdout tail as `error_message`, mark
|
||||||
|
`failed`
|
||||||
|
|
||||||
|
### 3.3 HTTP API
|
||||||
|
|
||||||
|
All endpoints (except `/healthz`) require `Authorization: Bearer
|
||||||
|
<AUTOTUNE_SERVICE_API_KEY>`.
|
||||||
|
|
||||||
|
- `POST /v1/analyses` — body `{days, profile_name?, autosens_min?, autosens_max?, insulin_type?, min_5m_carb_impact?, uam_as_basal, trigger: "manual"}` → `202 {id, status}` or `409` if one's already running.
|
||||||
|
- `GET /v1/analyses/{id}` — full row as JSON (shape above).
|
||||||
|
- `GET /v1/analyses?trigger=&limit=` — history list, newest first (backs FR7 and FR9's nightly-runs page).
|
||||||
|
- `POST /v1/analyses/{id}/stage` — body `{profile: <merged oref0-shape JSON>, switch: bool, decisions: [...]}` → runs `oref0-upload-profile <tmpfile> $NS_HOST <secret> [--switch]`, updates the row, returns `200` or `502` with the CLI's stderr on failure. **To verify during implementation:** whether `oref0-upload-profile`'s positional `api-secret` argument accepts a token interchangeably (the analyze side supports both `NS_API_SECRET` and `NS_TOKEN`; the upload side's `--help` only documents `api-secret` positionally) — confirm against the installed script's source before wiring this up, rather than assuming.
|
||||||
|
- `GET /v1/profiles` — thin passthrough listing NS profile-store names, so the frontend can offer a dropdown instead of a free-text field for FR3's "select a current profile."
|
||||||
|
- `GET /healthz` — liveness, no auth required (used by the frontend's connectivity check and Docker's own healthcheck).
|
||||||
|
|
||||||
|
### 3.4 Nightly scheduler (FR9)
|
||||||
|
|
||||||
|
`node-cron` inside the same process, firing daily at 04:00 (container
|
||||||
|
timezone set to `Europe/Amsterdam`). Calls the same code path as
|
||||||
|
`POST /v1/analyses` with `trigger: "nightly"`, `profile_name` omitted (always
|
||||||
|
tunes from whatever is currently active — correct behavior for an unattended
|
||||||
|
run), and `days` from `NIGHTLY_AUTOTUNE_DAYS` (env var, default **7** — a
|
||||||
|
reasonable middle ground between enough data for stable estimates and not
|
||||||
|
diluting recent pattern changes; the doc's own wording — "whatever gives
|
||||||
|
usually the most accurate results" — doesn't have one universally-correct
|
||||||
|
answer, so this is exposed as a tunable env var rather than a hardcoded
|
||||||
|
guess, and the user can adjust it after watching a few nights of real
|
||||||
|
output). No result is ever auto-staged; a nightly run populates a `done`/
|
||||||
|
`failed` row exactly like a manual one, staging is always a separate FR6
|
||||||
|
action.
|
||||||
|
|
||||||
|
### 3.5 Logging (NFR "production grade")
|
||||||
|
|
||||||
|
Structured JSON lines to stdout via `pino`, one line per job lifecycle event
|
||||||
|
(`job.started`, `job.finished`, `job.failed` with `id`/`duration_ms`), `WARN`
|
||||||
|
for FR8 large-jump flags, `ERROR` for NS/upload failures — **never** logging
|
||||||
|
the raw token/secret. Captured by Docker's `json-file` driver with
|
||||||
|
`max-size`/`max-file` set in `docker-compose.yml` (no external log
|
||||||
|
aggregator exists in the homelab today per inspection of the `monitoring`
|
||||||
|
LXC, so this is deliberately self-contained rather than assuming a
|
||||||
|
Loki/Elastic-style sink that isn't there).
|
||||||
|
|
||||||
|
## 4. `autotune-app` (LXC 120, Streamlit)
|
||||||
|
|
||||||
|
### 4.1 Framework decision (NFR5)
|
||||||
|
|
||||||
|
**Streamlit**, not Shiny for Python. Reasons: every functional requirement
|
||||||
|
here (form inputs, polling with a spinner, a comparison table, an editable
|
||||||
|
grid, history lists) is already something `glucose-dashboard` does or
|
||||||
|
Streamlit does natively — `st.data_editor` with
|
||||||
|
`column_config.NumberColumn(step=0.01)` directly implements FR5's "editable
|
||||||
|
numeric fields, 0.01 steps" requirement without extra code. Shiny's reactive
|
||||||
|
model would add a second UI paradigm for the user to maintain with no
|
||||||
|
functional gain here. The password-gate (`auth.py`), theming, and
|
||||||
|
`AppTest`-based testing conventions from `glucose-dashboard` carry over
|
||||||
|
directly (copied into this new repo, not imported cross-repo, per NFR1's
|
||||||
|
isolation).
|
||||||
|
|
||||||
|
### 4.2 Pages
|
||||||
|
|
||||||
|
1. **`app.py`** — landing page, `require_auth()`, reads
|
||||||
|
`AUTOTUNE_SERVICE_URL` + `AUTOTUNE_SERVICE_API_KEY` from env, shows a
|
||||||
|
connectivity check against `GET /healthz`.
|
||||||
|
2. **`pages/1_Nieuwe_Analyse.py`** (FR3) — form: days, starting profile
|
||||||
|
(dropdown from `GET /v1/profiles`), an expander for advanced overrides
|
||||||
|
(autosens min/max, insulin type, min 5-min carb impact). Submits
|
||||||
|
`POST /v1/analyses`, then polls `GET /v1/analyses/{id}` (via
|
||||||
|
`st.spinner` + a rerun loop) until `done`/`failed`; a clear NS-connection
|
||||||
|
error surfaces the service's `error_message` verbatim (FR3 acceptance:
|
||||||
|
fail loud, don't hang).
|
||||||
|
3. **`pages/2_Overzicht_Resultaat.py`** (FR4 + FR5 + FR6 + FR8) — given a job
|
||||||
|
id: summary cards (ISF/CR pump vs. autotune, `autosens_min`/`max` bounds
|
||||||
|
shown explicitly per FR8), an `st.data_editor` grid — columns *time-slot*
|
||||||
|
(hourly), *huidig profiel*, *voorstel*, *bron* (select: current/suggested/
|
||||||
|
custom), *definitieve waarde* (editable number, step 0.01, disabled unless
|
||||||
|
*bron* = custom) — plus a raw `autotune_recommendations.log` expander for
|
||||||
|
the underlying estimation detail. Rows whose suggested value differs from
|
||||||
|
current by more than a configurable threshold (default: 20% relative or
|
||||||
|
0.1 U/hr absolute, whichever is smaller magnitude) are visually flagged
|
||||||
|
(FR8's "unusually large jump"). A "Stage to Nightscout" section requires
|
||||||
|
an explicit confirmation checkbox before `POST /v1/analyses/{id}/stage`
|
||||||
|
fires (FR8: no silent staging), with a separate "ook direct activeren
|
||||||
|
(Profile Switch)" checkbox controlling `switch`.
|
||||||
|
4. **`pages/3_Nachtelijke_Runs.py`** (FR9) — `GET /v1/analyses?trigger=nightly`,
|
||||||
|
latest first; failed rows show `error_message` inline; clicking a row
|
||||||
|
opens it in the same view as page 2.
|
||||||
|
5. **`pages/4_Geschiedenis.py`** (FR7) — full audit list (manual + nightly),
|
||||||
|
filterable by status/trigger, showing staged/switched state — this *is*
|
||||||
|
the audit trail; no separate mechanism needed since every run (and every
|
||||||
|
staging decision) is already a row in `autotune-service`'s database.
|
||||||
|
|
||||||
|
### 4.3 Merge logic (the one piece of real business logic on this side)
|
||||||
|
|
||||||
|
`merge_profile(current, proposed, decisions) -> final_profile` — a pure
|
||||||
|
function, unit-tested exactly like `reporting.py`'s style in
|
||||||
|
`glucose-dashboard`: for each slot, `decisions[slot]` is
|
||||||
|
`"current"`/`"suggested"`/`"custom"` (+ a value when `"custom"`); rejecting a
|
||||||
|
slot must leave `current`'s value byte-for-byte in `final_profile`
|
||||||
|
(FR5 acceptance criterion). This function, plus the large-jump-flagging
|
||||||
|
function, are the only two pieces of logic in this app worth unit tests
|
||||||
|
beyond `AppTest` page smoke tests.
|
||||||
|
|
||||||
|
## 5. Nightscout token (NFR2)
|
||||||
|
|
||||||
|
A new Subject/Access Token created in Nightscout's Admin Tools, distinct from
|
||||||
|
`glucose-dashboard`'s existing read-only token. Role: **`careportal`** — the
|
||||||
|
conventional Nightscout role that grants treatment and profile writes on top
|
||||||
|
of read access (vs. `readable`, read-only, or `admin`, full control this
|
||||||
|
doesn't need). **To confirm during implementation**: exact role-string
|
||||||
|
behavior can vary slightly by Nightscout version — verify a `careportal`-role
|
||||||
|
token can actually write `/api/v1/profile` on this specific instance before
|
||||||
|
relying on it, rather than assuming the classic-NS role semantics hold
|
||||||
|
exactly. Only `autotune-service`'s environment ever holds this token; it is
|
||||||
|
never sent to or stored in the browser/frontend.
|
||||||
|
|
||||||
|
## 6. Data requirements — resolved vs. the original doc
|
||||||
|
|
||||||
|
The original requirements doc's §6 assumed `nightscout_client.py`-style
|
||||||
|
`GET`/`POST /api/v1/profile.json` functions would need to be written. With
|
||||||
|
the LXC 102 findings, that's no longer true for the write direction
|
||||||
|
(`oref0-upload-profile` already does it) or the analysis-time read direction
|
||||||
|
(`nightscout-autotune` already does it). The frontend's only remaining NS-ish
|
||||||
|
need is the profile-name dropdown, served via `autotune-service`'s
|
||||||
|
`GET /v1/profiles` passthrough — so **no Nightscout client code is written in
|
||||||
|
Python at all** for this feature. All NS-format handling stays inside the
|
||||||
|
existing, already-proven Node/oref0 tooling.
|
||||||
|
|
||||||
|
## 7. Testing strategy (NFR3)
|
||||||
|
|
||||||
|
- **`service/`**: unit tests mock `child_process.spawn` and the filesystem —
|
||||||
|
never invoke the real `nightscout-autotune`/`oref0-upload-profile` binaries
|
||||||
|
or a real Nightscout in CI. Cover: job state transitions, the
|
||||||
|
pending/running 409 guard, SQLite persistence (temp-file DB per test),
|
||||||
|
route handlers, and the large-jump/merge-adjacent logic that lives here
|
||||||
|
(none currently — kept in the frontend, see §4.3).
|
||||||
|
- **`app/`**: `merge_profile`/large-jump pure-function unit tests; `AppTest`
|
||||||
|
page tests mocking `requests` against `autotune-service` (same
|
||||||
|
`FakeResponse`/`monkeypatch` pattern as `glucose-dashboard`'s suite);
|
||||||
|
`auth.py` tests copied from the existing suite.
|
||||||
|
- **UAT** (post-implementation, against the real LXC 102 service and the
|
||||||
|
user's real Nightscout): trigger a manual analysis → review the
|
||||||
|
comparison/flagged-jump view → reject a slot and confirm it stays at the
|
||||||
|
current value → accept + custom-edit others → stage without switch →
|
||||||
|
manually confirm in Nightscout the new profile appears correctly and the
|
||||||
|
previously active one is untouched → separately test staging *with*
|
||||||
|
switch → confirm a nightly run appears on the nightly page the next
|
||||||
|
morning, including a deliberately-forced-failure case showing a clear
|
||||||
|
reason.
|
||||||
|
|
||||||
|
## 8. CI/CD (NFR1, NFR4)
|
||||||
|
|
||||||
|
New Gitea repo `glucose-profile-tuning`, pipeline modeled on
|
||||||
|
`glucose-dashboard/.gitea/workflows/pipeline.yml` with two differences:
|
||||||
|
- **Two build targets**: `service/Dockerfile` (deploy → LXC 102) and
|
||||||
|
`app/Dockerfile` (deploy → LXC 120, second compose service alongside
|
||||||
|
`glucose-dashboard`), each independently Trivy-scanned and pushed to
|
||||||
|
`gitea.zb-kw.duckdns.org`.
|
||||||
|
- **`pull_request` trigger added to the test/lint job** (NFR4) — unlike
|
||||||
|
`glucose-dashboard`'s current `push`-only pipeline, this one can end in a
|
||||||
|
write to a real medical-data store, so tests must gate merge, not just run
|
||||||
|
after the fact.
|
||||||
|
|
||||||
|
New secrets/variables to provision (mirroring the existing dashboard's
|
||||||
|
setup): SSH deploy credentials for both target hosts, the new NS
|
||||||
|
`careportal` token, and a freshly generated `AUTOTUNE_SERVICE_API_KEY`
|
||||||
|
shared between the two new containers.
|
||||||
|
|
||||||
|
## 9. Implementation phasing
|
||||||
|
|
||||||
|
Same incremental, ship-behind-CI approach used for the reporting feature —
|
||||||
|
each phase is a separate PR, merged only once green:
|
||||||
|
|
||||||
|
1. **Scaffold** — both Dockerfiles building, empty pipeline green end-to-end
|
||||||
|
(test → Trivy → deploy to both LXCs), before any real feature — this
|
||||||
|
phase alone would have caught the hostname/secret/token issues the
|
||||||
|
reporting feature's deployment hit, ahead of time.
|
||||||
|
2. **Microservice core** (FR2) — SQLite schema, job runner wrapping
|
||||||
|
`nightscout-autotune`, `POST`/`GET /v1/analyses` only, unit-tested with
|
||||||
|
mocked `spawn`. No frontend yet — verified via `curl` against the real
|
||||||
|
LXC 102 (matches FR2's own acceptance criterion).
|
||||||
|
3. **Frontend read path** (FR3 + FR4) — pages 1 + 2 up to the comparison
|
||||||
|
view, no staging yet.
|
||||||
|
4. **Staging** (FR5 + FR6 + FR8) — `st.data_editor` merge grid, `/stage`
|
||||||
|
endpoint, confirmation gating, large-jump flags.
|
||||||
|
5. **Nightly + audit** (FR9 + FR7) — scheduler, nightly-runs page, history
|
||||||
|
page.
|
||||||
|
|
||||||
|
## 10. Open items carried into implementation (not blocking doc approval)
|
||||||
|
|
||||||
|
- Verify `oref0-upload-profile`'s secret-vs-token argument behavior (§3.3).
|
||||||
|
- Verify the `careportal` role actually permits profile writes on this NS
|
||||||
|
version before building against it (§5).
|
||||||
|
- Confirm the default `NIGHTLY_AUTOTUNE_DAYS=7` after watching real output
|
||||||
|
for a week or two; it's a tunable, not a fixed clinical constant (§3.4).
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
|
> **Moved.** Implementation now lives in the separate `glucose-profile-tuning`
|
||||||
|
> repo; the canonical, actively maintained copy of this file is at
|
||||||
|
> `glucose-profile-tuning/docs/requirements-profile-tuning.md`. This copy is
|
||||||
|
> kept for history and is no longer updated.
|
||||||
|
|
||||||
# Requirements: oref0-autotune profile tuning
|
# Requirements: oref0-autotune profile tuning
|
||||||
|
|
||||||
Status: **draft — for review and editing**
|
Status: **final**
|
||||||
Owner: willemjan
|
Owner: willemjan
|
||||||
Related: [requirements-reporting.md](requirements-reporting.md) (separate feature, same Nightscout data source)
|
Related: [requirements-reporting.md](requirements-reporting.md) (separate feature, same Nightscout data source)
|
||||||
|
|
||||||
## 1. Purpose & background
|
## 1. Purpose & background
|
||||||
@@ -12,16 +17,16 @@ on-device history — it doesn't use Nightscout at all, and the user is still on
|
|||||||
AAPS 3.3 regardless. Previously, the user ran a hosted tool (inspired by
|
AAPS 3.3 regardless. Previously, the user ran a hosted tool (inspired by
|
||||||
`MarkMpn/AutotuneWeb`, now archived/discontinued) that analyzed Nightscout data
|
`MarkMpn/AutotuneWeb`, now archived/discontinued) that analyzed Nightscout data
|
||||||
with `oref0-autotune` and proposed a new pump profile, which was then manually
|
with `oref0-autotune` and proposed a new pump profile, which was then manually
|
||||||
entered into AAPS — a process the user describes as "a lot of trial and error."
|
entered into AAPS — a process the user describes as "a lot of trial and error." Before that, the user ran the tool from `MarkMpn/AutotuneWeb` but that was discontinued and cost a lot of effort to make changes to the profile by manually entering the changes into AAPS.
|
||||||
|
|
||||||
This feature builds a guided, human-in-the-loop workflow: trigger an
|
This feature builds a guided, human-in-the-loop workflow: trigger an
|
||||||
oref0-autotune analysis from a dashboard, review the proposed profile against
|
oref0-autotune analysis from an internally self hosted application, review the proposed profile against
|
||||||
the current one with the underlying estimations visible, selectively
|
the current one with the underlying estimations visible, selectively
|
||||||
accept/adjust values, and stage the result in Nightscout for manual activation.
|
accept/adjust values, and stage the result in Nightscout for manual activation.
|
||||||
|
|
||||||
The user has two LXC containers on Proxmox from earlier experimentation —
|
The user has two LXC containers on Proxmox from earlier experimentation —
|
||||||
`autotune` (VMID 102, 192.168.2.13) and `oref0` (VMID 107, 192.168.2.14) —
|
`autotune` (VMID 102, 192.168.2.13) and `oref0` (VMID 107, 192.168.2.14) —
|
||||||
currently stopped, of unknown/half-remembered working state.
|
currently stopped, of unknown/half-remembered working state. The working container can be used for the profile calculations, but if another solutions surpasses in terms of integratability a new solutions can be set up.
|
||||||
|
|
||||||
## 2. Scope
|
## 2. Scope
|
||||||
|
|
||||||
@@ -29,20 +34,17 @@ currently stopped, of unknown/half-remembered working state.
|
|||||||
- Booting and inspecting the two existing LXCs to determine (or establish) a
|
- Booting and inspecting the two existing LXCs to determine (or establish) a
|
||||||
working oref0-autotune setup.
|
working oref0-autotune setup.
|
||||||
- A small HTTP microservice on that LXC that runs oref0-autotune against the
|
- A small HTTP microservice on that LXC that runs oref0-autotune against the
|
||||||
user's Nightscout data on demand.
|
user's Nightscout data on demand. Either one of the two existing LXCs or a new one.
|
||||||
- A **separate** Streamlit app/service (own deployment, own CI/CD pipeline) that
|
- A **separate** app/service (own deployment, own CI/CD pipeline) that
|
||||||
triggers the analysis, displays old-vs-new profile comparisons with
|
triggers the analysis, displays old-vs-new profile comparisons with
|
||||||
estimation detail, lets the user selectively adjust proposed values, and
|
estimation detail, lets the user selectively adjust proposed values, and
|
||||||
stages the result in Nightscout.
|
stages the result in Nightscout. The user can then choose if the profile is automatically activated in AndroidAPS (creating a
|
||||||
|
`"Profile Switch"` treatment)
|
||||||
|
- This app could be based on the glucose-dashboard streamlit app, but streamlit is not a fixed requirement, another solution can be used. For example, shiny for python.
|
||||||
|
- A nightly scheduled autotune run of which the results are visible in the morning. The profile of each run should be stored in a database. The user can read those back from the database in the application and can send it to nightscout/androidAPS, but the nightly scheduled runs should not send profiles automatically to nightscout and/or AAPS, this should always be a user action.
|
||||||
|
|
||||||
**Out of scope (this iteration):**
|
**Out of scope (this iteration):**
|
||||||
- Automatically activating the new profile in AndroidAPS (creating a
|
|
||||||
`"Profile Switch"` treatment) — the dashboard stages the profile only; the
|
|
||||||
user activates it manually. This may become an opt-in "phase 2" once trust is
|
|
||||||
established (see §8).
|
|
||||||
- Any change to AAPS settings or on-device configuration.
|
- Any change to AAPS settings or on-device configuration.
|
||||||
- Continuous/scheduled autotune runs — this is a manually triggered, on-demand
|
|
||||||
analysis, not a nightly automated loop.
|
|
||||||
|
|
||||||
## 3. Architecture decisions (made during spec review)
|
## 3. Architecture decisions (made during spec review)
|
||||||
|
|
||||||
@@ -51,13 +53,12 @@ currently stopped, of unknown/half-remembered working state.
|
|||||||
run or bundling autotune into the dashboard's own container. The dashboard
|
run or bundling autotune into the dashboard's own container. The dashboard
|
||||||
calls it over HTTP and polls for job completion.
|
calls it over HTTP and polls for job completion.
|
||||||
2. **Profile push safety**: **stage-only**. The service writes a new named
|
2. **Profile push safety**: **stage-only**. The service writes a new named
|
||||||
profile into the Nightscout profile store but does **not** create a
|
profile into the Nightscout profile store and does create a
|
||||||
`"Profile Switch"` treatment — final activation stays a deliberate manual
|
`"Profile Switch"` treatment when the user selected that option — final activation stays a manual
|
||||||
step in AAPS or the Nightscout UI. This matters because AndroidAPS applies
|
step in AAPS when the option is not selected. This matters because AndroidAPS applies
|
||||||
profiles via Profile Switch treatments (gated behind an explicit "accept
|
profiles via Profile Switch treatments (gated behind an explicit "accept
|
||||||
profile switch from Nightscout" AAPS setting), not by simply reading
|
profile switch from Nightscout" AAPS setting), not by simply reading
|
||||||
`profile.json` — so "stage-only" is a real, meaningful safety boundary, not
|
`profile.json`.
|
||||||
just a formality.
|
|
||||||
3. **Deployment**: a separate app/service from `glucose-dashboard`, with its own
|
3. **Deployment**: a separate app/service from `glucose-dashboard`, with its own
|
||||||
repo-or-directory, Dockerfile, and Gitea CI/CD pipeline — isolating
|
repo-or-directory, Dockerfile, and Gitea CI/CD pipeline — isolating
|
||||||
write-capable, dosing-adjacent code from the read-only dashboard.
|
write-capable, dosing-adjacent code from the read-only dashboard.
|
||||||
@@ -73,6 +74,60 @@ Document findings (what's there, what's missing, what needs rebuilding).
|
|||||||
*Acceptance:* a clear decision on which LXC hosts the microservice, and what
|
*Acceptance:* a clear decision on which LXC hosts the microservice, and what
|
||||||
(if anything) needs to be installed/fixed there before FR2 can start.
|
(if anything) needs to be installed/fixed there before FR2 can start.
|
||||||
|
|
||||||
|
**Status: done (2026-07-26).** Both LXCs were booted and inspected, then
|
||||||
|
stopped again (back to their original state — no state was left running).
|
||||||
|
|
||||||
|
- **LXC 102 (`autotune`, 192.168.2.13, 1 core / 1GB RAM / 16GB disk)** —
|
||||||
|
Docker installed, with the image `ghcr.io/houthacker/nightscout-autotune:latest`
|
||||||
|
(Alpine + Node 24, ~279MB) already pulled and previously run successfully
|
||||||
|
(9 months ago) against the real Nightscout instance
|
||||||
|
(`https://zb-ns.duckdns.org`). It bundles the full `oref0` npm toolkit
|
||||||
|
(`oref0-autotune`, `oref0-upload-profile`, `convert-ns-profile`,
|
||||||
|
`mm-format-ns-profile`, and more) under `/usr/local/bin`, not just the
|
||||||
|
autotune algorithm itself. Its documented interface is a single container
|
||||||
|
run with env vars: `NS_HOST`, `AUTOTUNE_DAYS`, `UAM_AS_BASAL` (mandatory),
|
||||||
|
plus optional `NS_TOKEN`/`NS_API_SECRET` (as sha1 hashes), `NS_PROFILE`,
|
||||||
|
`AUTOSENS_MIN`/`MAX`, `MIN_5MIN_CARBIMPACT`, `INSULIN_TYPE`,
|
||||||
|
`OPENAPS_WORKDIR`, `HTML_EXPORT`. A prior run's output is still on disk at
|
||||||
|
`/root/autotune-output/autotune/`: per-day `ns-entries`/`ns-treatments`
|
||||||
|
fetched from Nightscout, the oref0-format `profile.json` (input) and
|
||||||
|
`newprofile.YYYY-MM-DD.json` (recommendation), and a human-readable
|
||||||
|
`autotune_recommendations.log` (a `Pump | Autotune | Days Missing` table per
|
||||||
|
parameter/basal-slot — ideal source for FR4's estimation detail). Critically,
|
||||||
|
the image also has `oref0-upload-profile` (`profile.json NSURL api-secret
|
||||||
|
[--preview] [--switch]`) already installed — this is the piece that converts
|
||||||
|
an oref0-format profile back into Nightscout's profile-store format *and*
|
||||||
|
optionally issues the Profile Switch treatment, i.e. it already implements
|
||||||
|
what FR6 needs. `--preview` (dry run, no upload) is also useful for FR8's
|
||||||
|
confirmation step.
|
||||||
|
- **LXC 107 (`oref0`, 192.168.2.14, same specs)** — no Docker; a native
|
||||||
|
npm/`openaps` install of the raw `oref0` toolkit. Bash history shows a
|
||||||
|
messier path to a working run (locale errors needing `locale-gen`, several
|
||||||
|
retries with different flag spellings) before landing on a working
|
||||||
|
`oref0-autotune --dir=~/myopenaps --ns-host=... --start-date=...` invocation
|
||||||
|
and a manual `oref0-upload-profile ... --switch=true` call. It reaches the
|
||||||
|
same destination but through an unreproducible, hand-patched native
|
||||||
|
environment rather than a pinned, documented container image.
|
||||||
|
|
||||||
|
**Decision: LXC 102 is the base.** It already has a working, versioned,
|
||||||
|
well-documented container with both the analysis direction (Nightscout →
|
||||||
|
oref0 profile → recommendation) and the upload direction
|
||||||
|
(oref0 recommendation → Nightscout profile store, with optional Profile
|
||||||
|
Switch) solved, via tools already installed in the same image. No new
|
||||||
|
autotune image needs to be built or evaluated — the open question in §8 about
|
||||||
|
choosing between `p5nbTgip0r/autotune-docker` /
|
||||||
|
`houthacker42/nightscout-autotune` / a from-scratch `oref0` build is moot:
|
||||||
|
`houthacker/nightscout-autotune` is what's already there, already proven
|
||||||
|
against this Nightscout instance, and turns out to be the most complete of
|
||||||
|
the three. LXC 107 will be left stopped/decommissioned — its only asset (a
|
||||||
|
successful manual run) is already superseded by 102's cleaner path. FR2's
|
||||||
|
microservice will be built as a thin wrapper *around* the CLI tools already
|
||||||
|
baked into the `nightscout-autotune` image (see
|
||||||
|
[design-profile-tuning.md](design-profile-tuning.md)), rather than
|
||||||
|
reimplementing Nightscout-profile ⇄ oref0-profile conversion from scratch —
|
||||||
|
directly resolving the "had to manually parse parts of the profile" pain
|
||||||
|
point named in FR2.
|
||||||
|
|
||||||
### FR2 — Autotune HTTP microservice
|
### FR2 — Autotune HTTP microservice
|
||||||
A small HTTP API (exact framework TBD during design — e.g. Flask/FastAPI
|
A small HTTP API (exact framework TBD during design — e.g. Flask/FastAPI
|
||||||
wrapping a Docker-based oref0-autotune image) exposing at minimum:
|
wrapping a Docker-based oref0-autotune image) exposing at minimum:
|
||||||
@@ -81,15 +136,17 @@ wrapping a Docker-based oref0-autotune image) exposing at minimum:
|
|||||||
- `GET /analyze/{job_id}` — status (`pending`/`running`/`done`/`failed`) and,
|
- `GET /analyze/{job_id}` — status (`pending`/`running`/`done`/`failed`) and,
|
||||||
once done, the recommended profile JSON plus the estimation/recommendation
|
once done, the recommended profile JSON plus the estimation/recommendation
|
||||||
detail (per-hour deviations, the autotune log).
|
detail (per-hour deviations, the autotune log).
|
||||||
|
- In previous tries, the user had to manually parse parts of the profile as obtained from Nightscout to have it accpeted by the autotune service. This should be thoroughly investigated and subsequently automated
|
||||||
|
|
||||||
*Acceptance:* a manual `curl` against the running service, pointed at the
|
*Acceptance:* a manual `curl` against the running service, pointed at the
|
||||||
user's real Nightscout instance, returns a plausible recommended profile within
|
user's real Nightscout instance, returns a plausible recommended profile within
|
||||||
a reasonable time (minutes, not seconds — oref0-autotune is not instant).
|
a reasonable time (minutes, not seconds — oref0-autotune is not instant).
|
||||||
|
|
||||||
### FR3 — Trigger analysis from the dashboard
|
### FR3 — Trigger analysis from the dashboard
|
||||||
A new Streamlit app page where the user picks a date range (and confirms which
|
A new app where the user picks a date range and selects a
|
||||||
current profile to tune from, fetched live from Nightscout), starts the
|
current profile to tune from (fetched live from Nightscout), starts the
|
||||||
analysis, and sees progress while the microservice job runs (via polling).
|
analysis, and sees progress while the microservice job runs (via polling).
|
||||||
|
Nightscout connection should be made using a token with a proper role.
|
||||||
|
|
||||||
*Acceptance:* starting an analysis with bad/unreachable Nightscout credentials
|
*Acceptance:* starting an analysis with bad/unreachable Nightscout credentials
|
||||||
fails with a clear error rather than hanging silently.
|
fails with a clear error rather than hanging silently.
|
||||||
@@ -109,6 +166,7 @@ Per time-slot controls (accept/reject each proposed basal/ISF/CR change
|
|||||||
individually) plus editable numeric fields so the user can fine-tune any
|
individually) plus editable numeric fields so the user can fine-tune any
|
||||||
accepted value before staging — matching the user's explicit request to
|
accepted value before staging — matching the user's explicit request to
|
||||||
"select parts of the proposed profile and still adjust the values a bit."
|
"select parts of the proposed profile and still adjust the values a bit."
|
||||||
|
This should look like a table with the columns 'time-slot' (per hour), 'current profile', 'suggestion', a input to select which column to select (current vs suggestion) and on the last column the final selected value, but as input numeric fields which can be changed by 0.01 steps.
|
||||||
|
|
||||||
*Acceptance:* rejecting a slot keeps the current value unchanged in the
|
*Acceptance:* rejecting a slot keeps the current value unchanged in the
|
||||||
staged profile; editing an accepted value's number is reflected in what
|
staged profile; editing an accepted value's number is reflected in what
|
||||||
@@ -117,7 +175,7 @@ actually gets sent to Nightscout.
|
|||||||
### FR6 — Stage to Nightscout
|
### FR6 — Stage to Nightscout
|
||||||
Write the resulting merged profile (accepted + manually adjusted values) as a
|
Write the resulting merged profile (accepted + manually adjusted values) as a
|
||||||
new named profile in the Nightscout profile store (e.g. `"Autotune
|
new named profile in the Nightscout profile store (e.g. `"Autotune
|
||||||
YYYY-MM-DD"`) via a new POST to `/api/v1/profile`, without creating a Profile
|
YYYY-MM-DD"` or `"Autotune YYYY-MM-DD ID"`) via a new POST to `/api/v1/profile`, with optionally creating a Profile
|
||||||
Switch treatment. After staging, show clear instructions for manual activation
|
Switch treatment. After staging, show clear instructions for manual activation
|
||||||
in AAPS (or the Nightscout UI).
|
in AAPS (or the Nightscout UI).
|
||||||
|
|
||||||
@@ -140,6 +198,11 @@ separate store — **decide during design**.
|
|||||||
- Require an explicit confirmation step (e.g. a review summary + confirmation
|
- Require an explicit confirmation step (e.g. a review summary + confirmation
|
||||||
checkbox) before the FR6 write happens — no silent/implicit staging.
|
checkbox) before the FR6 write happens — no silent/implicit staging.
|
||||||
|
|
||||||
|
### FR9 — Automated nightly runs
|
||||||
|
Each night at 4:00 the analysis is triggered with a fixed amount of days, whatever gives usually the most accurate results, and stores the result in a database. The app should have a page where the latest run is shown and should have the same downstream options as FR3 has (FR4, FR5 and FR6).
|
||||||
|
|
||||||
|
*Acceptance:* The analysis appears on the nightly analysis page and gets pulled from the database with autotune results. When the analysis failed a reason for failure must be shown.
|
||||||
|
|
||||||
## 5. Non-functional requirements
|
## 5. Non-functional requirements
|
||||||
|
|
||||||
- **NFR1 — Isolation**: separate repository-or-directory, Dockerfile, and Gitea
|
- **NFR1 — Isolation**: separate repository-or-directory, Dockerfile, and Gitea
|
||||||
@@ -156,10 +219,11 @@ separate store — **decide during design**.
|
|||||||
existing `monkeypatch`-on-`requests` pattern from `glucose-dashboard`'s test
|
existing `monkeypatch`-on-`requests` pattern from `glucose-dashboard`'s test
|
||||||
suite.
|
suite.
|
||||||
- **NFR4 — CI/CD gating**: given this pipeline can end in a write to a real
|
- **NFR4 — CI/CD gating**: given this pipeline can end in a write to a real
|
||||||
medical data store, consider (during design) adding a `pull_request` trigger
|
medical data store, add a `pull_request` trigger
|
||||||
for the test/lint job so changes are validated *before* merge, not only
|
for the test/lint job so changes are validated *before* merge, not only
|
||||||
after — the current `glucose-dashboard` pipeline only triggers on `push` to
|
after — the current `glucose-dashboard` pipeline only triggers on `push` to
|
||||||
`main`.
|
`main`.
|
||||||
|
- **NFR5 — Framework**: make a descision on which framework the solution can be based best. For example streamlit, shiny for python etc.
|
||||||
|
|
||||||
## 6. Data requirements
|
## 6. Data requirements
|
||||||
|
|
||||||
@@ -187,16 +251,16 @@ separate store — **decide during design**.
|
|||||||
|
|
||||||
## 8. Open questions for the user's edit pass
|
## 8. Open questions for the user's edit pass
|
||||||
|
|
||||||
1. Which LXC (102 or 107) ends up hosting the service, once FR1 is done?
|
1. ~~Which LXC (102 or 107 or new) ends up hosting the service, once FR1 is
|
||||||
2. Which autotune Docker image to standardize on —
|
done?~~ **Resolved (FR1 complete, see below): LXC 102 (`autotune`).**
|
||||||
`p5nbTgip0r/autotune-docker`, `houthacker42/nightscout-autotune`, or
|
2. ~~Which autotune Docker image to standardize on?~~ **Resolved: the image
|
||||||
building directly from the `oref0` npm package? (Design-phase decision,
|
already installed on LXC 102 —
|
||||||
informed by FR1's findings.)
|
`ghcr.io/houthacker/nightscout-autotune:latest`.** See findings below.
|
||||||
3. Should automatic Profile Switch activation become an opt-in "phase 2" later,
|
3. Should automatic Profile Switch activation become an opt-in "phase 2" later,
|
||||||
once you've built trust in the recommendations — or should manual
|
once you've built trust in the recommendations — or should manual
|
||||||
activation remain permanent policy regardless of trust?
|
activation remain permanent policy regardless of trust? Can already start automate it as i've added in the requirements
|
||||||
4. FR7 (audit/history) — is reusing Nightscout's own profile-document history
|
4. FR7 (audit/history) — is reusing Nightscout's own profile-document history
|
||||||
sufficient, or do you want a dedicated log outside Nightscout?
|
sufficient, or do you want a dedicated log outside Nightscout? Create a dedicated store of analysis and include proper production grade logging for the full application so debugging later will be easier.
|
||||||
5. Confirm the separate write-scoped Nightscout token is something you're
|
5. Confirm the separate write-scoped Nightscout token is something you're
|
||||||
comfortable creating/managing (NFR2) — this is a manual one-time setup step
|
comfortable creating/managing (NFR2) — this is a manual one-time setup step
|
||||||
in the Nightscout admin UI, not something the app can do for you.
|
in the Nightscout admin UI, not something the app can do for you. Yes, i can create a new token, if you indicate which rights it needs.
|
||||||
|
|||||||
+55
-19
@@ -31,6 +31,19 @@ SENSOR_CHANGE_EVENT_TYPES = {"Sensor Change", "Sensor Start"}
|
|||||||
|
|
||||||
_REQUEST_TIMEOUT = 10
|
_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[date][$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").
|
||||||
|
_PAGE_SIZE = 1000
|
||||||
|
_MAX_PAGES = 150 # 150 000 documenten; ruim boven wat een 90-dagenrapport nodig heeft
|
||||||
|
|
||||||
|
|
||||||
class NightscoutConfigError(RuntimeError):
|
class NightscoutConfigError(RuntimeError):
|
||||||
"""NIGHTSCOUT_URL ontbreekt of is ongeldig."""
|
"""NIGHTSCOUT_URL ontbreekt of is ongeldig."""
|
||||||
@@ -75,15 +88,44 @@ def _get(path: str, params: dict) -> list:
|
|||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
def _entries_since(since: datetime, count: int, until: datetime | None = None) -> pd.DataFrame:
|
def _paginated_get(path: str, since: datetime, until: datetime | None = None) -> list:
|
||||||
"""Haal entries op sinds `since` (en optioneel tot `until`), lokaal gefilterd.
|
"""Haal alle documenten op tussen `since` en `until` (of tot nu), pagina voor pagina.
|
||||||
|
|
||||||
Nightscout geeft resultaten terug in aflopende volgorde; vraag ruim genoeg
|
Zie de toelichting bij _PAGE_SIZE hierboven voor waarom dit niet één
|
||||||
entries op (CGM meet doorgaans elke 5 min) en filter lokaal op tijd. Dit is
|
verzoek met een groot `count` kan zijn (BUG: dat leverde bij een
|
||||||
robuuster dan server-side find[]-query's, die niet op elke Nightscout-versie
|
meerdaags rapport stilzwijgend alleen de laatste ~3-4 dagen op, ongeacht
|
||||||
identiek werken.
|
de gekozen periode). Elke pagina wordt opgevraagd met `find[date][$lte]`
|
||||||
|
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).
|
||||||
"""
|
"""
|
||||||
data = _get("/api/v1/entries/sgv.json", {"count": count})
|
since_ms = int(since.timestamp() * 1000)
|
||||||
|
cursor_ms = int(until.timestamp() * 1000) if until is not None else None
|
||||||
|
|
||||||
|
results: list = []
|
||||||
|
for _ in range(_MAX_PAGES):
|
||||||
|
params = {"count": _PAGE_SIZE}
|
||||||
|
if cursor_ms is not None:
|
||||||
|
params["find[date][$lte]"] = cursor_ms
|
||||||
|
page = _get(path, params)
|
||||||
|
if not page:
|
||||||
|
break
|
||||||
|
results.extend(page)
|
||||||
|
|
||||||
|
dates = [item["date"] for item in page if "date" in item]
|
||||||
|
if not dates:
|
||||||
|
break
|
||||||
|
oldest_ms = min(dates)
|
||||||
|
if oldest_ms <= since_ms or len(page) < _PAGE_SIZE:
|
||||||
|
break
|
||||||
|
cursor_ms = oldest_ms - 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)
|
||||||
|
|
||||||
if not data:
|
if not data:
|
||||||
return pd.DataFrame(columns=["timestamp", "mmol"])
|
return pd.DataFrame(columns=["timestamp", "mmol"])
|
||||||
@@ -97,9 +139,9 @@ def _entries_since(since: datetime, count: int, until: datetime | None = None) -
|
|||||||
return df[["timestamp", "mmol"]].sort_values("timestamp").reset_index(drop=True)
|
return df[["timestamp", "mmol"]].sort_values("timestamp").reset_index(drop=True)
|
||||||
|
|
||||||
|
|
||||||
def _treatments_since(since: datetime, count: int, until: datetime | None = None) -> pd.DataFrame:
|
def _treatments_since(since: datetime, until: datetime | None = None) -> pd.DataFrame:
|
||||||
"""Haal treatments op sinds `since` (en optioneel tot `until`), lokaal gefilterd."""
|
"""Haal treatments op sinds `since` (en optioneel tot `until`), lokaal gefilterd."""
|
||||||
data = _get("/api/v1/treatments.json", {"count": count})
|
data = _paginated_get("/api/v1/treatments.json", since, until)
|
||||||
|
|
||||||
if not data:
|
if not data:
|
||||||
return pd.DataFrame(columns=["timestamp", "eventType", "insulin", "carbs"])
|
return pd.DataFrame(columns=["timestamp", "eventType", "insulin", "carbs"])
|
||||||
@@ -129,8 +171,7 @@ def fetch_entries(hours: int = 24) -> pd.DataFrame:
|
|||||||
gesorteerd op tijd.
|
gesorteerd op tijd.
|
||||||
"""
|
"""
|
||||||
since = datetime.now(timezone.utc) - timedelta(hours=hours)
|
since = datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||||
count = max(300, hours * 15)
|
return _entries_since(since)
|
||||||
return _entries_since(since, count)
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_treatments(hours: int = 24) -> pd.DataFrame:
|
def fetch_treatments(hours: int = 24) -> pd.DataFrame:
|
||||||
@@ -141,8 +182,7 @@ def fetch_treatments(hours: int = 24) -> pd.DataFrame:
|
|||||||
'insulin' en 'carbs' (de laatste twee NaN waar niet van toepassing).
|
'insulin' en 'carbs' (de laatste twee NaN waar niet van toepassing).
|
||||||
"""
|
"""
|
||||||
since = datetime.now(timezone.utc) - timedelta(hours=hours)
|
since = datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||||
count = max(200, hours * 10)
|
return _treatments_since(since)
|
||||||
return _treatments_since(since, count)
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_entries_range(start_date: date, end_date: date) -> pd.DataFrame:
|
def fetch_entries_range(start_date: date, end_date: date) -> pd.DataFrame:
|
||||||
@@ -154,9 +194,7 @@ def fetch_entries_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)
|
||||||
days = (end_date - start_date).days + 1
|
return _entries_since(since, until)
|
||||||
count = min(days * 300, 100_000) # ruime marge boven 5-min-interval
|
|
||||||
return _entries_since(since, count, until)
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_treatments_range(start_date: date, end_date: date) -> pd.DataFrame:
|
def fetch_treatments_range(start_date: date, end_date: date) -> pd.DataFrame:
|
||||||
@@ -166,6 +204,4 @@ 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)
|
||||||
days = (end_date - start_date).days + 1
|
return _treatments_since(since, until)
|
||||||
count = min(days * 50, 50_000)
|
|
||||||
return _treatments_since(since, count, until)
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from reporting import (
|
|||||||
sensor_active_percent,
|
sensor_active_percent,
|
||||||
tir_breakdown_5band,
|
tir_breakdown_5band,
|
||||||
)
|
)
|
||||||
from ui_common import load_range_data, select_period_days
|
from ui_common import load_range_data, render_completeness_warning, select_period_days
|
||||||
|
|
||||||
st.set_page_config(page_title="Glucose dashboard — Samenvatting", page_icon="📊", layout="wide")
|
st.set_page_config(page_title="Glucose dashboard — Samenvatting", page_icon="📊", layout="wide")
|
||||||
require_auth()
|
require_auth()
|
||||||
@@ -32,12 +32,8 @@ if glucose.empty:
|
|||||||
st.info("Geen glucosemetingen gevonden in deze periode.")
|
st.info("Geen glucosemetingen gevonden in deze periode.")
|
||||||
st.stop()
|
st.stop()
|
||||||
|
|
||||||
|
render_completeness_warning(glucose, start, end)
|
||||||
active_pct = sensor_active_percent(glucose, start, end)
|
active_pct = sensor_active_percent(glucose, start, end)
|
||||||
if active_pct < 70:
|
|
||||||
st.warning(
|
|
||||||
f"Slechts {active_pct:.0f}% sensordekking in deze periode (streefwaarde ≥70%). "
|
|
||||||
"De onderstaande cijfers zijn mogelijk minder betrouwbaar door de vele hiaten."
|
|
||||||
)
|
|
||||||
|
|
||||||
mean_mmol = glucose["mmol"].mean()
|
mean_mmol = glucose["mmol"].mean()
|
||||||
gmi = gmi_percent(mean_mmol)
|
gmi = gmi_percent(mean_mmol)
|
||||||
|
|||||||
@@ -6,7 +6,13 @@ from auth import require_auth
|
|||||||
from data_processing import TARGET_HIGH, TARGET_LOW
|
from data_processing import TARGET_HIGH, TARGET_LOW
|
||||||
from nightscout_client import NightscoutConfigError
|
from nightscout_client import NightscoutConfigError
|
||||||
from reporting import agp_percentile_bands
|
from reporting import agp_percentile_bands
|
||||||
from ui_common import get_colors, hex_to_rgba, load_range_data, select_period_days
|
from ui_common import (
|
||||||
|
get_colors,
|
||||||
|
hex_to_rgba,
|
||||||
|
load_range_data,
|
||||||
|
render_completeness_warning,
|
||||||
|
select_period_days,
|
||||||
|
)
|
||||||
|
|
||||||
st.set_page_config(page_title="Glucose dashboard — AGP profiel", page_icon="📊", layout="wide")
|
st.set_page_config(page_title="Glucose dashboard — AGP profiel", page_icon="📊", layout="wide")
|
||||||
require_auth()
|
require_auth()
|
||||||
@@ -32,6 +38,8 @@ if glucose.empty:
|
|||||||
st.info("Geen glucosemetingen gevonden in deze periode.")
|
st.info("Geen glucosemetingen gevonden in deze periode.")
|
||||||
st.stop()
|
st.stop()
|
||||||
|
|
||||||
|
render_completeness_warning(glucose, start, end)
|
||||||
|
|
||||||
bands = agp_percentile_bands(glucose, bin_minutes=30)
|
bands = agp_percentile_bands(glucose, bin_minutes=30)
|
||||||
colors = get_colors()
|
colors = get_colors()
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ from plotly.subplots import make_subplots
|
|||||||
from auth import require_auth
|
from auth import require_auth
|
||||||
from data_processing import TARGET_HIGH, TARGET_LOW
|
from data_processing import TARGET_HIGH, TARGET_LOW
|
||||||
from nightscout_client import NightscoutConfigError
|
from nightscout_client import NightscoutConfigError
|
||||||
from ui_common import get_colors, load_range_data, select_period_days
|
from ui_common import (
|
||||||
|
get_colors,
|
||||||
|
load_range_data,
|
||||||
|
render_completeness_warning,
|
||||||
|
select_period_days,
|
||||||
|
)
|
||||||
|
|
||||||
st.set_page_config(page_title="Glucose dashboard — Dagoverzicht", page_icon="📊", layout="wide")
|
st.set_page_config(page_title="Glucose dashboard — Dagoverzicht", page_icon="📊", layout="wide")
|
||||||
require_auth()
|
require_auth()
|
||||||
@@ -34,6 +39,8 @@ if glucose.empty:
|
|||||||
st.info("Geen glucosemetingen gevonden in deze periode.")
|
st.info("Geen glucosemetingen gevonden in deze periode.")
|
||||||
st.stop()
|
st.stop()
|
||||||
|
|
||||||
|
render_completeness_warning(glucose, start, end)
|
||||||
|
|
||||||
colors = get_colors()
|
colors = get_colors()
|
||||||
glucose = glucose.copy()
|
glucose = glucose.copy()
|
||||||
glucose["date"] = glucose["timestamp"].dt.date
|
glucose["date"] = glucose["timestamp"].dt.date
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from reporting import LOW, VERY_LOW
|
|||||||
from ui_common import (
|
from ui_common import (
|
||||||
get_colors,
|
get_colors,
|
||||||
load_range_data,
|
load_range_data,
|
||||||
|
render_completeness_warning,
|
||||||
render_event_level,
|
render_event_level,
|
||||||
select_period_days,
|
select_period_days,
|
||||||
)
|
)
|
||||||
@@ -36,6 +37,8 @@ if glucose.empty:
|
|||||||
st.info("Geen glucosemetingen gevonden in deze periode.")
|
st.info("Geen glucosemetingen gevonden in deze periode.")
|
||||||
st.stop()
|
st.stop()
|
||||||
|
|
||||||
|
render_completeness_warning(glucose, start, end)
|
||||||
|
|
||||||
colors = get_colors()
|
colors = get_colors()
|
||||||
|
|
||||||
render_event_level(glucose, LOW, "below", f"Level 1 (<{LOW} mmol/L)", days, colors)
|
render_event_level(glucose, LOW, "below", f"Level 1 (<{LOW} mmol/L)", days, colors)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from reporting import HIGH, VERY_HIGH
|
|||||||
from ui_common import (
|
from ui_common import (
|
||||||
get_colors,
|
get_colors,
|
||||||
load_range_data,
|
load_range_data,
|
||||||
|
render_completeness_warning,
|
||||||
render_event_level,
|
render_event_level,
|
||||||
select_period_days,
|
select_period_days,
|
||||||
)
|
)
|
||||||
@@ -35,6 +36,8 @@ if glucose.empty:
|
|||||||
st.info("Geen glucosemetingen gevonden in deze periode.")
|
st.info("Geen glucosemetingen gevonden in deze periode.")
|
||||||
st.stop()
|
st.stop()
|
||||||
|
|
||||||
|
render_completeness_warning(glucose, start, end)
|
||||||
|
|
||||||
colors = get_colors()
|
colors = get_colors()
|
||||||
|
|
||||||
render_event_level(glucose, HIGH, "above", f"Level 1 (>{HIGH} mmol/L)", days, colors)
|
render_event_level(glucose, HIGH, "above", f"Level 1 (>{HIGH} mmol/L)", days, colors)
|
||||||
|
|||||||
+8
-1
@@ -12,7 +12,12 @@ from reporting import (
|
|||||||
overnight_daytime_split,
|
overnight_daytime_split,
|
||||||
site_interval_stats,
|
site_interval_stats,
|
||||||
)
|
)
|
||||||
from ui_common import get_colors, load_range_data, select_period_days
|
from ui_common import (
|
||||||
|
get_colors,
|
||||||
|
load_range_data,
|
||||||
|
render_completeness_warning,
|
||||||
|
select_period_days,
|
||||||
|
)
|
||||||
|
|
||||||
st.set_page_config(page_title="Glucose dashboard — Trends", page_icon="📊", layout="wide")
|
st.set_page_config(page_title="Glucose dashboard — Trends", page_icon="📊", layout="wide")
|
||||||
require_auth()
|
require_auth()
|
||||||
@@ -35,6 +40,8 @@ if glucose.empty:
|
|||||||
st.info("Geen glucosemetingen gevonden in deze periode.")
|
st.info("Geen glucosemetingen gevonden in deze periode.")
|
||||||
st.stop()
|
st.stop()
|
||||||
|
|
||||||
|
render_completeness_warning(glucose, start, end)
|
||||||
|
|
||||||
colors = get_colors()
|
colors = get_colors()
|
||||||
events = classify_treatments(treatments)
|
events = classify_treatments(treatments)
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -5,7 +5,7 @@ from auth import require_auth
|
|||||||
from nightscout_client import NightscoutConfigError
|
from nightscout_client import NightscoutConfigError
|
||||||
from pdf_export import AVAILABLE_SECTIONS, SECTION_EVENTS, build_report_pdf
|
from pdf_export import AVAILABLE_SECTIONS, SECTION_EVENTS, build_report_pdf
|
||||||
from reporting import HIGH, LOW, VERY_HIGH, VERY_LOW, detect_events, summarize_events
|
from reporting import HIGH, LOW, VERY_HIGH, VERY_LOW, detect_events, summarize_events
|
||||||
from ui_common import load_range_data, select_period_days
|
from ui_common import load_range_data, render_completeness_warning, select_period_days
|
||||||
|
|
||||||
st.set_page_config(page_title="Glucose dashboard — Export", page_icon="📊", layout="wide")
|
st.set_page_config(page_title="Glucose dashboard — Export", page_icon="📊", layout="wide")
|
||||||
require_auth()
|
require_auth()
|
||||||
@@ -29,6 +29,8 @@ if glucose.empty:
|
|||||||
st.info("Geen glucosemetingen gevonden in deze periode.")
|
st.info("Geen glucosemetingen gevonden in deze periode.")
|
||||||
st.stop()
|
st.stop()
|
||||||
|
|
||||||
|
render_completeness_warning(glucose, start, end)
|
||||||
|
|
||||||
events_summary = {}
|
events_summary = {}
|
||||||
if SECTION_EVENTS in sections:
|
if SECTION_EVENTS in sections:
|
||||||
levels = {
|
levels = {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from fpdf.enums import XPos, YPos
|
|||||||
|
|
||||||
from reporting import (
|
from reporting import (
|
||||||
agp_percentile_bands,
|
agp_percentile_bands,
|
||||||
|
completeness_warning,
|
||||||
gmi_percent,
|
gmi_percent,
|
||||||
percent_cv,
|
percent_cv,
|
||||||
sensor_active_percent,
|
sensor_active_percent,
|
||||||
@@ -64,6 +65,13 @@ def _add_summary_section(pdf: FPDF, glucose: pd.DataFrame, start, end) -> None:
|
|||||||
|
|
||||||
pdf.set_font("Helvetica", "B", 13)
|
pdf.set_font("Helvetica", "B", 13)
|
||||||
pdf.cell(0, 8, "Samenvatting", **_LINE_KWARGS)
|
pdf.cell(0, 8, "Samenvatting", **_LINE_KWARGS)
|
||||||
|
|
||||||
|
warning = completeness_warning(glucose, start, end)
|
||||||
|
if warning:
|
||||||
|
pdf.set_font("Helvetica", "BI", 10)
|
||||||
|
pdf.multi_cell(0, 5, warning)
|
||||||
|
pdf.ln(2)
|
||||||
|
|
||||||
pdf.set_font("Helvetica", "", 11)
|
pdf.set_font("Helvetica", "", 11)
|
||||||
pdf.cell(0, 6, f"Sensordekking: {active_pct:.0f}%", **_LINE_KWARGS)
|
pdf.cell(0, 6, f"Sensordekking: {active_pct:.0f}%", **_LINE_KWARGS)
|
||||||
pdf.cell(0, 6, f"Gemiddelde glucose: {mean_mmol:.1f} mmol/L", **_LINE_KWARGS)
|
pdf.cell(0, 6, f"Gemiddelde glucose: {mean_mmol:.1f} mmol/L", **_LINE_KWARGS)
|
||||||
|
|||||||
@@ -95,6 +95,56 @@ def sensor_active_percent(glucose: pd.DataFrame, start, end, expected_interval_m
|
|||||||
return round(min(len(glucose) / expected_readings, 1.0) * 100, 1)
|
return round(min(len(glucose) / expected_readings, 1.0) * 100, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def completeness_warning(glucose: pd.DataFrame, start, end) -> str | None:
|
||||||
|
"""Bouw een waarschuwingstekst als er te weinig data is voor de gekozen periode.
|
||||||
|
|
||||||
|
Pure functie zodat zowel de interactieve pagina's (ui_common.py, als
|
||||||
|
st.warning) als het PDF-rapport (pdf_export.py, als tekstregel) exact
|
||||||
|
dezelfde beoordeling en bewoording gebruiken — een rapport dat naar het
|
||||||
|
diabetesteam gaat moet dezelfde disclaimer bevatten als wat de gebruiker
|
||||||
|
zelf op het scherm ziet.
|
||||||
|
|
||||||
|
Onderscheidt twee oorzaken:
|
||||||
|
- een structureel gat aan het BEGIN van de periode (de vroegste
|
||||||
|
binnengekomen meting ligt later dan de gekozen startdatum) — data is
|
||||||
|
simpelweg niet opgehaald/beschikbaar voor dat stuk, geen sensorhiaat.
|
||||||
|
- een lagere dekking dan de klinische AGP-ondergrens (Danne et al. 2017)
|
||||||
|
zonder zo'n gat aan het begin — verspreide hiaten.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None als er geen waarschuwing nodig is; anders de te tonen tekst.
|
||||||
|
"""
|
||||||
|
if glucose.empty:
|
||||||
|
return "Geen glucosedata gevonden in deze periode."
|
||||||
|
|
||||||
|
start_date = pd.Timestamp(start).date()
|
||||||
|
end_date = pd.Timestamp(end).date()
|
||||||
|
active_pct = sensor_active_percent(glucose, start, end)
|
||||||
|
earliest = glucose["timestamp"].min().date()
|
||||||
|
missing_days_at_start = max((earliest - start_date).days, 0)
|
||||||
|
|
||||||
|
if missing_days_at_start > 0:
|
||||||
|
return (
|
||||||
|
f"Nightscout heeft pas data vanaf {earliest:%d-%m-%Y} teruggegeven — "
|
||||||
|
f"de eerste {missing_days_at_start} dag(en) van de gekozen periode "
|
||||||
|
f"({start_date:%d-%m-%Y} t/m {end_date:%d-%m-%Y}) ontbreken volledig, niet alleen "
|
||||||
|
f"losse sensorhiaten. Sensordekking over de wél opgehaalde periode: "
|
||||||
|
f"{active_pct:.0f}%. Onderstaande cijfers zijn dus berekend over een "
|
||||||
|
"kortere periode dan geselecteerd."
|
||||||
|
)
|
||||||
|
if active_pct < 70:
|
||||||
|
# ">=" i.p.v. "≥": deze tekst komt ook in het PDF-rapport terecht
|
||||||
|
# (pdf_export.py), en fpdf2's ingebouwde Helvetica-font ondersteunt
|
||||||
|
# alleen Latin-1 — zie gitea issue #11 voor het opruimen hiervan
|
||||||
|
# (Unicode-lettertype embedden) zodra dat de moeite waard is.
|
||||||
|
return (
|
||||||
|
f"Slechts {active_pct:.0f}% sensordekking in deze periode (streefwaarde "
|
||||||
|
">=70% voor een klinisch betrouwbaar rapport, Danne et al. 2017). De "
|
||||||
|
"onderstaande cijfers zijn mogelijk minder betrouwbaar door de vele hiaten."
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _state_blocks(df: pd.DataFrame, out: pd.Series) -> list[dict]:
|
def _state_blocks(df: pd.DataFrame, out: pd.Series) -> list[dict]:
|
||||||
"""Groepeer aaneengesloten metingen met dezelfde in/out-status.
|
"""Groepeer aaneengesloten metingen met dezelfde in/out-status.
|
||||||
|
|
||||||
|
|||||||
@@ -134,3 +134,124 @@ def test_fetch_treatments_range_filters_to_date_boundaries(monkeypatch):
|
|||||||
result = ns.fetch_treatments_range(date(2026, 1, 1), date(2026, 1, 31))
|
result = ns.fetch_treatments_range(date(2026, 1, 1), date(2026, 1, 31))
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result.iloc[0]["insulin"] == pytest.approx(4.0)
|
assert result.iloc[0]["insulin"] == pytest.approx(4.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _entry(dt, sgv=100):
|
||||||
|
return {"date": int(dt.timestamp() * 1000), "sgv": sgv, "dateString": dt.isoformat()}
|
||||||
|
|
||||||
|
|
||||||
|
def test_paginated_get_stops_when_a_short_page_is_returned(monkeypatch):
|
||||||
|
"""Nightscout kan minder dan _PAGE_SIZE teruggeven omdat er simpelweg niet meer data is.
|
||||||
|
|
||||||
|
Dit was precies de bug: een enkel groot count-verzoek werd stilzwijgend
|
||||||
|
afgeknipt. Met paginering moet een pagina die kleiner is dan de gevraagde
|
||||||
|
paginagrootte de paginering laten stoppen — dat betekent "geen oudere
|
||||||
|
data beschikbaar", niet "er ging iets mis".
|
||||||
|
"""
|
||||||
|
monkeypatch.setenv("NIGHTSCOUT_URL", "https://example.com")
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
payload = [_entry(now - timedelta(minutes=5 * i)) for i in range(5)] # << _PAGE_SIZE
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ns.requests,
|
||||||
|
"get",
|
||||||
|
lambda *a, **k: (calls.append(k), FakeResponse(payload))[1],
|
||||||
|
)
|
||||||
|
|
||||||
|
result = ns._paginated_get("/api/v1/entries/sgv.json", now - timedelta(days=1))
|
||||||
|
assert len(result) == 5
|
||||||
|
assert len(calls) == 1 # één pagina was genoeg, geen verdere paginering nodig
|
||||||
|
|
||||||
|
|
||||||
|
def test_paginated_get_walks_back_multiple_pages_until_since_is_reached(monkeypatch):
|
||||||
|
"""De kern-regressietest voor de bug: data die verder terugligt dan één
|
||||||
|
pagina (~1000 documenten) moet toch volledig worden opgehaald door
|
||||||
|
meerdere pagina's met find[date][$lte] te doorlopen."""
|
||||||
|
monkeypatch.setenv("NIGHTSCOUT_URL", "https://example.com")
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# 3 volle pagina's (elk _PAGE_SIZE groot, 5 minuten uit elkaar, aflopend in tijd)
|
||||||
|
# + een laatste kleinere pagina die de paginering laat stoppen.
|
||||||
|
page_size = ns._PAGE_SIZE
|
||||||
|
pages = []
|
||||||
|
cursor = now
|
||||||
|
for _ in range(3):
|
||||||
|
page = [_entry(cursor - timedelta(minutes=5 * i)) for i in range(page_size)]
|
||||||
|
pages.append(page)
|
||||||
|
cursor = cursor - timedelta(minutes=5 * (page_size - 1)) - timedelta(minutes=5)
|
||||||
|
pages.append([_entry(cursor - timedelta(minutes=5 * i)) for i in range(10)])
|
||||||
|
|
||||||
|
call_count = {"n": 0}
|
||||||
|
|
||||||
|
def fake_get(*_a, **_k):
|
||||||
|
response = FakeResponse(pages[call_count["n"]])
|
||||||
|
call_count["n"] += 1
|
||||||
|
return response
|
||||||
|
|
||||||
|
monkeypatch.setattr(ns.requests, "get", fake_get)
|
||||||
|
|
||||||
|
since = now - timedelta(days=365) # ruim voorbij alle test-pagina's
|
||||||
|
result = ns._paginated_get("/api/v1/entries/sgv.json", since)
|
||||||
|
|
||||||
|
assert call_count["n"] == 4 # 3 volle pagina's + 1 korte slotpagina
|
||||||
|
assert len(result) == 3 * page_size + 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_paginated_get_stops_at_since_boundary_without_fetching_further(monkeypatch):
|
||||||
|
"""Zodra een pagina data ouder dan `since` bevat, hoeft er geen volgende
|
||||||
|
pagina meer opgehaald te worden, ook al was de pagina zelf vol."""
|
||||||
|
monkeypatch.setenv("NIGHTSCOUT_URL", "https://example.com")
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
page_size = ns._PAGE_SIZE
|
||||||
|
# deze ene pagina spant ruim meer dan een dag, dus bevat al data voorbij `since`
|
||||||
|
page = [_entry(now - timedelta(minutes=5 * i)) for i in range(page_size)]
|
||||||
|
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
def fake_get(*_a, **_k):
|
||||||
|
calls["n"] += 1
|
||||||
|
return FakeResponse(page)
|
||||||
|
|
||||||
|
monkeypatch.setattr(ns.requests, "get", fake_get)
|
||||||
|
|
||||||
|
since = now - timedelta(hours=6) # ruim binnen de eerste pagina
|
||||||
|
result = ns._paginated_get("/api/v1/entries/sgv.json", since)
|
||||||
|
|
||||||
|
assert calls["n"] == 1
|
||||||
|
assert len(result) == page_size # filtering op since gebeurt pas in _entries_since, niet hier
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_entries_range_retrieves_data_spanning_multiple_pages(monkeypatch):
|
||||||
|
"""End-to-end regressietest: een 90-dagenrapport moet echt 90 dagen data
|
||||||
|
krijgen, niet stilzwijgend worden afgeknipt tot de meest recente pagina."""
|
||||||
|
monkeypatch.setenv("NIGHTSCOUT_URL", "https://example.com")
|
||||||
|
monkeypatch.delenv("NIGHTSCOUT_TOKEN", raising=False)
|
||||||
|
monkeypatch.delenv("NIGHTSCOUT_API_SECRET", raising=False)
|
||||||
|
|
||||||
|
end_date = date(2026, 3, 31)
|
||||||
|
start_date = date(2026, 1, 1) # 90 dagen
|
||||||
|
|
||||||
|
# simuleer Nightscout: elke 5 minuten een meting over de volle periode,
|
||||||
|
# opgeknipt in door de server afgedwongen pagina's van _PAGE_SIZE
|
||||||
|
until = datetime.combine(end_date, datetime.max.time(), tzinfo=timezone.utc)
|
||||||
|
since = datetime.combine(start_date, datetime.min.time(), tzinfo=timezone.utc)
|
||||||
|
all_entries = []
|
||||||
|
t = until
|
||||||
|
while t >= since:
|
||||||
|
all_entries.append(_entry(t))
|
||||||
|
t -= timedelta(minutes=5)
|
||||||
|
|
||||||
|
page_size = ns._PAGE_SIZE
|
||||||
|
|
||||||
|
def fake_get(*_args, **kwargs):
|
||||||
|
cursor_ms = kwargs["params"].get("find[date][$lte]")
|
||||||
|
pool = all_entries if cursor_ms is None else [e for e in all_entries if e["date"] <= cursor_ms]
|
||||||
|
return FakeResponse(pool[:page_size])
|
||||||
|
|
||||||
|
monkeypatch.setattr(ns.requests, "get", fake_get)
|
||||||
|
|
||||||
|
result = ns.fetch_entries_range(start_date, end_date)
|
||||||
|
assert len(result) == len(all_entries)
|
||||||
|
assert result["timestamp"].min().date() == start_date
|
||||||
|
assert result["timestamp"].max().date() == end_date
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
|
from datetime import date
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from reporting import (
|
from reporting import (
|
||||||
agp_percentile_bands,
|
agp_percentile_bands,
|
||||||
bolus_stats,
|
bolus_stats,
|
||||||
|
completeness_warning,
|
||||||
day_of_week_pattern,
|
day_of_week_pattern,
|
||||||
detect_events,
|
detect_events,
|
||||||
gmi_percent,
|
gmi_percent,
|
||||||
@@ -93,6 +96,42 @@ def test_sensor_active_percent_handles_gaps():
|
|||||||
assert result == pytest.approx(50.0, abs=1.0)
|
assert result == pytest.approx(50.0, abs=1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_completeness_warning_none_for_empty_glucose():
|
||||||
|
empty = pd.DataFrame(columns=["timestamp", "mmol"])
|
||||||
|
assert completeness_warning(empty, "2026-01-01", "2026-01-14") == "Geen glucosedata gevonden in deze periode."
|
||||||
|
|
||||||
|
|
||||||
|
def test_completeness_warning_none_when_period_is_fully_covered():
|
||||||
|
# 14 volle dagen aan 5-minuten-metingen, precies de gekozen periode
|
||||||
|
timestamps = pd.date_range("2026-01-01 00:00", "2026-01-14 23:55", freq="5min")
|
||||||
|
glucose = pd.DataFrame({"timestamp": timestamps, "mmol": [6.0] * len(timestamps)})
|
||||||
|
assert completeness_warning(glucose, date(2026, 1, 1), date(2026, 1, 14)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_completeness_warning_flags_missing_days_at_start_of_period():
|
||||||
|
# gevraagd: 1 t/m 14 januari, maar data begint pas op 11 januari (de
|
||||||
|
# exacte bug: alleen de laatste ~4 dagen van een langere periode zijn
|
||||||
|
# opgehaald)
|
||||||
|
timestamps = pd.date_range("2026-01-11 00:00", "2026-01-14 23:55", freq="5min")
|
||||||
|
glucose = pd.DataFrame({"timestamp": timestamps, "mmol": [6.0] * len(timestamps)})
|
||||||
|
message = completeness_warning(glucose, date(2026, 1, 1), date(2026, 1, 14))
|
||||||
|
assert message is not None
|
||||||
|
assert "11-01-2026" in message
|
||||||
|
assert "10 dag" in message # 1 t/m 10 januari ontbreken volledig = 10 dagen
|
||||||
|
assert "sensorhiaten" in message
|
||||||
|
|
||||||
|
|
||||||
|
def test_completeness_warning_flags_low_coverage_without_start_gap():
|
||||||
|
# data start op tijd, maar met veel verspreide hiaten -> lage dekking
|
||||||
|
# zonder dat er een aaneengesloten gat aan het begin is
|
||||||
|
timestamps = pd.date_range("2026-01-01 00:00", periods=20, freq="5min")
|
||||||
|
glucose = pd.DataFrame({"timestamp": timestamps, "mmol": [6.0] * 20})
|
||||||
|
message = completeness_warning(glucose, date(2026, 1, 1), date(2026, 1, 14))
|
||||||
|
assert message is not None
|
||||||
|
assert "sensordekking" in message
|
||||||
|
assert "sensorhiaten" not in message # dit is de generieke, niet de "ontbreekt volledig"-boodschap
|
||||||
|
|
||||||
|
|
||||||
def test_detect_events_ignores_dip_shorter_than_minimum_duration():
|
def test_detect_events_ignores_dip_shorter_than_minimum_duration():
|
||||||
# dip van 10 minuten (< 15 min drempel) mag niet meetellen
|
# dip van 10 minuten (< 15 min drempel) mag niet meetellen
|
||||||
glucose = _series(
|
glucose = _series(
|
||||||
|
|||||||
+13
-1
@@ -15,7 +15,7 @@ import plotly.graph_objects as go
|
|||||||
import streamlit as st
|
import streamlit as st
|
||||||
|
|
||||||
from nightscout_client import fetch_entries_range, fetch_treatments_range
|
from nightscout_client import fetch_entries_range, fetch_treatments_range
|
||||||
from reporting import detect_events, summarize_events
|
from reporting import completeness_warning, detect_events, summarize_events
|
||||||
|
|
||||||
MMOL_HOVERTEMPLATE = "%{x|%d-%m %H:%M} · %{y:.1f} mmol/L<extra></extra>"
|
MMOL_HOVERTEMPLATE = "%{x|%d-%m %H:%M} · %{y:.1f} mmol/L<extra></extra>"
|
||||||
|
|
||||||
@@ -98,6 +98,18 @@ def load_range_data(start: date, end: date):
|
|||||||
return glucose, treatments
|
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(
|
def render_event_level(
|
||||||
glucose: pd.DataFrame,
|
glucose: pd.DataFrame,
|
||||||
threshold: float,
|
threshold: float,
|
||||||
|
|||||||
Reference in New Issue
Block a user