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