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.
20 KiB
Moved. Implementation now lives in the separate
glucose-profile-tuningrepo (per this doc's own §2 isolation decision); the canonical, actively maintained copy of this file is atglucose-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 (FR1–FR9, NFR1–NFR5) Related: 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.
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, alongsideglucose-dashboardas a seconddocker-composeservice — 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:
- mark
running, setstarted_at 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>"}})- on exit code 0: read
profile.json,newprofile.*.json, andautotune_recommendations.logout of the job's workdir, populate the row, markdone - on non-zero exit: capture stderr/stdout tail as
error_message, markfailed
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}or409if 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: [...]}→ runsoref0-upload-profile <tmpfile> $NS_HOST <secret> [--switch], updates the row, returns200or502with the CLI's stderr on failure. To verify during implementation: whetheroref0-upload-profile's positionalapi-secretargument accepts a token interchangeably (the analyze side supports bothNS_API_SECRETandNS_TOKEN; the upload side's--helponly documentsapi-secretpositionally) — 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
app.py— landing page,require_auth(), readsAUTOTUNE_SERVICE_URL+AUTOTUNE_SERVICE_API_KEYfrom env, shows a connectivity check againstGET /healthz.pages/1_Nieuwe_Analyse.py(FR3) — form: days, starting profile (dropdown fromGET /v1/profiles), an expander for advanced overrides (autosens min/max, insulin type, min 5-min carb impact). SubmitsPOST /v1/analyses, then pollsGET /v1/analyses/{id}(viast.spinner+ a rerun loop) untildone/failed; a clear NS-connection error surfaces the service'serror_messageverbatim (FR3 acceptance: fail loud, don't hang).pages/2_Overzicht_Resultaat.py(FR4 + FR5 + FR6 + FR8) — given a job id: summary cards (ISF/CR pump vs. autotune,autosens_min/maxbounds shown explicitly per FR8), anst.data_editorgrid — 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 rawautotune_recommendations.logexpander 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 beforePOST /v1/analyses/{id}/stagefires (FR8: no silent staging), with a separate "ook direct activeren (Profile Switch)" checkbox controllingswitch.pages/3_Nachtelijke_Runs.py(FR9) —GET /v1/analyses?trigger=nightly, latest first; failed rows showerror_messageinline; clicking a row opens it in the same view as page 2.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 inautotune-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 mockchild_process.spawnand the filesystem — never invoke the realnightscout-autotune/oref0-upload-profilebinaries 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;AppTestpage tests mockingrequestsagainstautotune-service(sameFakeResponse/monkeypatchpattern asglucose-dashboard's suite);auth.pytests 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) andapp/Dockerfile(deploy → LXC 120, second compose service alongsideglucose-dashboard), each independently Trivy-scanned and pushed togitea.zb-kw.duckdns.org. pull_requesttrigger added to the test/lint job (NFR4) — unlikeglucose-dashboard's currentpush-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:
- 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.
- Microservice core (FR2) — SQLite schema, job runner wrapping
nightscout-autotune,POST/GET /v1/analysesonly, unit-tested with mockedspawn. No frontend yet — verified viacurlagainst the real LXC 102 (matches FR2's own acceptance criterion). - Frontend read path (FR3 + FR4) — pages 1 + 2 up to the comparison view, no staging yet.
- Staging (FR5 + FR6 + FR8) —
st.data_editormerge grid,/stageendpoint, confirmation gating, large-jump flags. - 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
careportalrole actually permits profile writes on this NS version before building against it (§5). - Confirm the default
NIGHTLY_AUTOTUNE_DAYS=7after watching real output for a week or two; it's a tunable, not a fixed clinical constant (§3.4).