From 45c1464cc0d96723c09fccca615dc919347b2f0d Mon Sep 17 00:00:00 2001 From: arsen Date: Sun, 17 May 2026 12:15:09 +0700 Subject: [PATCH] feat(adr-107): raw.html calibrate button + ADR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI side of ADR-107: green "calibrate empty" button in raw.html next to the existing reset/log-y controls. Click → confirm dialog tells the operator to step out → POST /api/v1/baseline/calibrate with 90 s capture window → polls GET /api/v1/baseline every 2 s, surfaces "recording… N/90 s" then "baseline updated ✓". ADR-107 documents: D1 in-process capture_baseline_to_disk (port of record-baseline.py) D2 BASELINE_BUS broadcast forwarder so capture stays decoupled from WS clients D3 POST /api/v1/baseline/calibrate (immediate ack, background work) D4 GET /api/v1/baseline (current state + cooldown + status) D5 auto_recalibrate_task — 30-min absent+low-CV trigger, 1-h cooldown D6 raw.html button + polling --- ...-107-auto-recalibrate-and-rest-baseline.md | 186 ++++++++++++++++++ .../static/raw.html | 38 ++++ 2 files changed, 224 insertions(+) create mode 100644 docs/adr/ADR-107-auto-recalibrate-and-rest-baseline.md diff --git a/docs/adr/ADR-107-auto-recalibrate-and-rest-baseline.md b/docs/adr/ADR-107-auto-recalibrate-and-rest-baseline.md new file mode 100644 index 00000000..10bcd33c --- /dev/null +++ b/docs/adr/ADR-107-auto-recalibrate-and-rest-baseline.md @@ -0,0 +1,186 @@ +# ADR-107 — REST Baseline Calibration + Auto-Recalibrate + +**Status**: Accepted +**Date**: 2026-05-17 +**Scope**: `v2/crates/wifi-densepose-sensing-server/src/main.rs` +(`baseline_get`, `baseline_calibrate`, `auto_recalibrate_task`, +`capture_baseline_to_disk`, `BASELINE_BUS`), `static/raw.html` +(`calibrate empty` button), CLI flags +`--auto-recalibrate-quiet-sec` / `--auto-recalibrate-min-age-sec`. + +## Context + +ADR-103 introduced a persistent empty-room baseline at +`data/baseline.json` so the classifier no longer needed a 60 s warm-up +after every server restart. To refresh it the operator had to: + +1. Step out of the room. +2. SSH / open a terminal, run `python scripts/record-baseline.py + --duration 90`. +3. Wait for the "saved" message. +4. Restart the sensing-server (so it reloads the file). +5. Walk back in. + +Steps 2, 4 are friction. The operator asked to remove them so a +fresh device that just wants to monitor a room doesn't need a CLI +or a restart. Two changes: + +* **`POST /api/v1/baseline/calibrate`** — fires the same record-and- + trim pipeline from inside the server, hot-reloads the override map + on success. UI button in `raw.html` triggers it. +* **Auto-recalibrate background task** — silently refreshes the + baseline when the classifier reports `absent` and CV stays low for + a long-enough window, without any operator action. + +## Decisions + +### D1 — `capture_baseline_to_disk` in-process + +Pure-Rust port of `scripts/record-baseline.py`: + +1. Subscribe to `BASELINE_BUS` (a `tokio::sync::broadcast::Sender` + that mirrors every WS JSON message published by the broadcaster). +2. Collect `duration_sec` of per-node `(t, amplitudes, rssi)`. +3. Trim `trim_sec` from head and tail. +4. Slide `clean_window_sec` window across, pick lowest-CV chunk per + node. +5. Compute FULL-broadband mean/p50/p95/std/CV% (same schema as + ADR-103 v2; reload uses the same `load_baseline_file`). +6. Write `data/baseline.json` (configurable via JSON body `out`). +7. Call `load_baseline_file(path)` to hot-reload `AMP_BASELINE_OVERRIDE` + and `AMP_BASELINE_CV`. + +### D2 — `BASELINE_BUS` broadcast forwarder + +Decouples baseline capture from individual WS clients. A small task +spawned at startup subscribes to `AppState.tx` and re-publishes every +message into `BASELINE_BUS`. Capture subscribers don't need a WS +connection or any external network path. + +### D3 — `POST /api/v1/baseline/calibrate` + +Optional JSON body: `{ duration_sec, trim_sec, clean_window_sec, out }`. +Defaults: 90 / 15 / 30 s and `data/baseline.json`. Returns immediately +with `{ "started": true, "hint": "..." }`. Subsequent calls while a +job is running return `{ "started": false, "reason": "calibration +already running" }`. + +### D4 — `GET /api/v1/baseline` + +```json +{ + "nodes": { "1": {"full_broadband_p95": …, "full_broadband_cv_pct": …}, … }, + "last_written_sec_ago": , + "calibration_status": "idle" | "running" | "running (auto)" + | "complete" | "complete (auto)" | "error: …" +} +``` + +UI polls this every 2 s while a calibration is running to drive the +button state machine. + +### D5 — Auto-recalibrate background task + +Wakes every 5 s. State machine: + +* Read latest `classification.motion_level` and `confidence` (=CV). +* `quiet = (motion_level == "absent") && (cv < 0.08)`. +* If `quiet` is true continuously for `--auto-recalibrate-quiet-sec` + (default 1800 = 30 min) **AND** the last baseline write is older than + `--auto-recalibrate-min-age-sec` (default 3600 = 1 h), kick off + `capture_baseline_to_disk(90, 5, 45, "data/baseline.json")` in the + background. +* On error, log + set `calibration_status` so the UI surfaces it. + +The 30-minute / 1-hour defaults are conservative: a person briefly +walking through doesn't reset the baseline; long-term drift from +WiFi reconfiguration or furniture rearrangement does. `--auto- +recalibrate-quiet-sec 0` disables entirely. + +### D6 — `raw.html` button + +`calibrate empty` next to the existing `reset` button. Click → +`confirm()` reminds operator to step out → POSTs the endpoint → polls +status every 2 s, updating the inline pill `recording… 12/90 s` → +`baseline updated ✓` on success. Disables itself while running. + +## Files Touched + +``` +v2/crates/wifi-densepose-sensing-server/src/main.rs + - statics: BASELINE_LAST_WRITTEN, BASELINE_CALIBRATION_STATUS, BASELINE_BUS + - fn capture_baseline_to_disk (D1) + - fn auto_recalibrate_task (D5) + - fn baseline_get (D4) + - fn baseline_calibrate (D3) + - routes /api/v1/baseline + /api/v1/baseline/calibrate + - Args { auto_recalibrate_quiet_sec, auto_recalibrate_min_age_sec } + - main(): bus init + auto-recalibrate spawn +v2/crates/wifi-densepose-sensing-server/static/raw.html + - + + @@ -323,6 +325,42 @@ function renderTick() { } requestAnimationFrame(renderTick); +// ── ADR-107: baseline calibrate button + polling ────────────────── +let calibPollTimer = null; +async function startCalibrate() { + if (!confirm('Step OUT of the room now. Calibration will record for 90 s.\nClick OK when you are out.')) return; + const btn = document.getElementById('calibrateBtn'); + const stat = document.getElementById('calibStatus'); + btn.disabled = true; btn.textContent = 'recording…'; + stat.style.display = 'inline-block'; stat.textContent = 'starting…'; + try { + const res = await fetch('/api/v1/baseline/calibrate', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ duration_sec: 90, trim_sec: 15, clean_window_sec: 30 }), + }); + const j = await res.json(); + if (!j.started) { stat.textContent = j.reason || 'failed to start'; btn.disabled = false; btn.textContent = 'calibrate empty'; return; } + } catch (e) { + stat.textContent = 'network error'; btn.disabled = false; btn.textContent = 'calibrate empty'; return; + } + if (calibPollTimer) clearInterval(calibPollTimer); + let elapsed = 0; + calibPollTimer = setInterval(async () => { + elapsed += 2; + try { + const r = await fetch('/api/v1/baseline'); const j = await r.json(); + const s = j.calibration_status || 'idle'; + stat.textContent = s.startsWith('running') ? `recording… ${elapsed}/90 s` : s; + if (!s.startsWith('running')) { + clearInterval(calibPollTimer); calibPollTimer = null; + btn.disabled = false; btn.textContent = 'calibrate empty'; + if (s === 'complete') stat.textContent = 'baseline updated ✓'; + } + } catch (e) {} + }, 2000); +} + // ── WS ───────────────────────────────────────────────────────────── function connect() { const ws = new WebSocket('ws://' + location.hostname + ':8765/ws/sensing');