wifi-densepose/docs/adr/ADR-103-persistent-baseline.md

181 lines
6.2 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.

# ADR-103 — Persistent Empty-Room Baseline + Universal Threshold
**Status**: Accepted
**Date**: 2026-05-17
**Scope**: `v2/crates/wifi-densepose-sensing-server/src/main.rs`
(`AMP_BASELINE_OVERRIDE`, `AMP_BASELINE_CV`, `load_baseline_file`,
`amp_node_level`), `v2/data/baseline.json`, `scripts/record-baseline.py`.
## Context
ADR-101's classifier relies on a `baseline` value per node — the
mean amplitude the room exhibits when empty. Pre-ADR-103 the baseline
was the rolling 95 %ile of the last 1 200 samples (≈ 60 s) of
broadband mean. That meant every server restart triggered a "step
outside for 60 seconds" ritual before the detector worked, and if
the operator stayed in the room longer than ~4 min the baseline
silently drifted down to the *occupied* amplitude — making
`present_still` under-trigger forever after.
Additionally, motion gates were hard-coded to the operator's
deployment (10 % / 22 % CV) — wouldn't transfer to a different room
with different noise floor.
## Decisions
### D1 — Persistent baseline file at `data/baseline.json`
JSON schema **v2** (per node):
```json
{
"version": 2,
"captured_at": "ISO-8601",
"duration_sec": 90.0,
"trim_head_sec": 15.0,
"trim_tail_sec": 15.0,
"clean_window_sec": 30.0,
"method": "record → trim head/tail → find lowest-CV sub-window → FULL-broadband stats per node",
"nodes": {
"1": {
"full_broadband_mean": 26.11,
"full_broadband_p50": 26.16,
"full_broadband_p95": 27.04, used as `baseline`
"full_broadband_std": 0.68,
"full_broadband_cv_pct": 2.62, used to normalize gates (D3)
"rssi_dbm": -52.3,
"n_samples": 149,
"per_subcarrier_mean": [..56 floats..]
}
}
}
```
Loader (`load_baseline_file`) reads at server startup. Path is
`$RUVIEW_BASELINE_FILE` or `data/baseline.json` by default. Missing
or unparseable file = log warning + fall back to rolling p95 (= old
ADR-101 behaviour, no breaking change).
Per-node lookup priority: `full_broadband_p95``full_broadband_mean`
→ legacy `p95_amp` → legacy `mean_amp`. v1 baselines load but get
warning about NBVI-drift incompatibility.
### D2 — FULL broadband for baseline comparison, NBVI for CV
The persisted baseline must be comparable across server restarts.
NBVI top-12 re-selects on each boot (ADR-102 D4), so a NBVI-subset
mean recorded today doesn't match a NBVI-subset mean tomorrow even
in the same empty room. Fix:
`amp_presence_override` keeps two short windows:
| Field | Source | Used for |
|---|---|---|
| `short` | NBVI-subset broadband mean | CV (motion sensitivity) |
| `short_full` | **all non-zero subcarriers** mean | baseline drop check |
The recording script also computes full-broadband statistics from
the captured frames. Both sides of `mean / baseline` ratio are
full-broadband ⇒ stable across NBVI selection.
### D3 — Universal threshold via baseline-CV normalization
(Pace's Problem #3.) Hard-coded gates are deployment-tuned. Fix:
normalize the runtime CV by the empty-room CV measured during
calibration:
```
norm_cv = current_cv / baseline_cv
gates: norm_cv ≥ 3.0 → present_moving
norm_cv ≥ 6.0 → active
```
Both `amp_node_level` (per-node) and `amp_classify_from_latest`
(global) use the same normalization. When no calibration is loaded,
fall back to absolute gates `0.10 / 0.22` (the deployment-tuned
values) — keeps backwards compatibility.
`AMP_BASELINE_CV` is a separate per-node map loaded alongside
`AMP_BASELINE_OVERRIDE`. The CV value is the FULL-broadband CV % from
the calibration file divided by 100.
### D4 — Recording script `scripts/record-baseline.py`
CLI helper (Python 3, requires `pip install websockets`). Connects
to the live `ws://localhost:8765/ws/sensing`, records `duration` (90
s default), then:
1. Trim `trim_head_sec` (15 s default) and `trim_tail_sec` (15 s
default) to discard door-open / re-entry transients.
2. Slide a `clean_window_sec` (30 s default) sub-window across the
trimmed buffer, pick the one with the lowest broadband CV.
3. Per node, compute full-broadband mean / median / p95 / std / CV %
and rssi mean over that cleanest window.
4. Also compute per-subcarrier mean across the cleanest window (saved
as diagnostic for future per-subcarrier delta classifier).
5. Write `v2/data/baseline.json` (path overridable via `--out`).
Operator workflow now: step out, run script, come back, restart
server. One-time per deployment (or after room rearrangement). No
recurring ritual.
## Files Touched
```
v2/crates/wifi-densepose-sensing-server/src/main.rs # ~120 lines added
v2/data/baseline.json # new, gitignored?
scripts/record-baseline.py # new helper
docs/adr/ADR-103-persistent-baseline.md # this ADR
```
## Verified Acceptance (operator's deployment, 2026-05-17)
```
boot: baseline: loaded 2 node overrides from data/baseline.json
(node1=27.04, node2=14.72;
node1_cv=2.62%, node2_cv=3.65%)
```
Empty room, immediately after restart (no warmup wait):
```
GLOBAL: absent CV=5.0%
node 1 ratio=0.93, norm_cv=0.80×
node 2 ratio=0.93, norm_cv=0.83×
```
Sitting in node 2 path (off-axis from node 1):
```
GLOBAL: present_still CV=8.1%
node 1 ratio=1.05, norm_cv=1.2× (not in path, no drop)
node 2 ratio=0.70, norm_cv=1.7× ← drop fires present_still
```
Walking:
```
GLOBAL: active CV=28-36%
node 1 norm_cv=4-6×, node 2 norm_cv=7-9× ← well above 6× gate
```
Universal-threshold gates `3.0 / 6.0` map to the same absolute
12 % / 22 % we hand-tuned earlier — but now any-room-portable.
## Open Items
***REST endpoint POST /api/v1/baseline/calibrate** — closed in
ADR-107 D3 + UI button D6.
* ✅ **Per-subcarrier baseline comparison** — closed in ADR-104
(per-sub drift channel consumes `per_subcarrier_mean`).
* ✅ **Auto-recalibrate on long quiet periods** — closed in ADR-107 D5
(30-min quiet + 1-h cooldown defaults).
## References
* ADR-100 — gain lock.
* ADR-101 — classifier consumes the baseline.
* ADR-102 — NBVI selection drift was the root cause of D1/D2.
* [`docs/references/espectre-techniques.md`](../references/espectre-techniques.md)
— Pace's full technique catalogue including Problem #3 normalization.