Files
glucose-dashboard/docs/design-profile-tuning.md
T
willemjan 55883480c0 docs: record FR1 findings and add profile-tuning design spec
requirements-profile-tuning.md: document the LXC 102/107 investigation
outcome (LXC 102's houthacker/nightscout-autotune image already solves
both the analysis and NS-upload directions) and resolve the two open
questions this depended on.

design-profile-tuning.md: full design spec for the separate
glucose-profile-tuning app/service, reviewed and approved.
2026-07-31 14:21:46 +02:00

329 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
> **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).