6.2 KiB
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):
{
"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:
- Trim
trim_head_sec(15 s default) andtrim_tail_sec(15 s default) to discard door-open / re-entry transients. - Slide a
clean_window_sec(30 s default) sub-window across the trimmed buffer, pick the one with the lowest broadband CV. - Per node, compute full-broadband mean / median / p95 / std / CV % and rssi mean over that cleanest window.
- Also compute per-subcarrier mean across the cleanest window (saved as diagnostic for future per-subcarrier delta classifier).
- 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— Pace's full technique catalogue including Problem #3 normalization.