6.1 KiB
ADR-101 — Raw-Amplitude Presence/Motion Classifier
Status: Accepted
Date: 2026-05-17
Scope: v2/crates/wifi-densepose-sensing-server/src/main.rs
(amp_presence_override, amp_classify_from_latest,
amp_node_level, amp_node_snapshot).
Context
After ADR-100 the AGC drift is gone and the broadband baseline is clean.
Before this ADR the live classification.motion_level was being driven
by the legacy DSP (variance + motion_band_power thresholds) plus an
RSSI MAD-Δ override from ADR-099. Both failed on the operator's
deployment: variance overlaps empty/sit/walk within noise, and RSSI
MAD-Δ overlaps within ±0.03 of 0.49 across all three states. The
operator could lie still in the path between AP and sensor and the
detector would silently report absent.
The 30 sec × 3 controlled captures done on 2026-05-17 (lying between TP-Link AP and sensor 1, see ADR-100 Verified Acceptance) showed that the broadband CV of mean amplitude separates the three states by 3-6× on this geometry. EMPTY = 2.7-5 %, STILL = 3.7-5 %, WALK = 12.5-29.7 %. EMPTY vs STILL are best separated by the mean-amplitude drop (37 → 22 on the active sensor, -40 %).
This ADR replaces the RSSI MAD-Δ classifier with a pure-amplitude one that uses both signals: CV for motion, baseline drop for static body.
Decisions
D1 — amp_presence_override per-node classifier
For each frame received on the raw-CSI path:
- Push current full amplitude vector into the NBVI ranking buffer
(
nbvi_history, capacity 600 frames ≈ 30 s). - Periodically (every
NBVI_REFRESH_TICKS=200calls, ~5 s) rank subcarriers by NBVI (see ADR-102) and pick the top-12. - Compute broadband_mean as the average of NBVI-selected subcarriers. Falls back to all non-zero subcarriers during warmup.
- Push to two rolling windows:
short(90 samples ≈ 4.5 s) — for CV.long(1200 samples ≈ 60 s) — for the rolling-fallback 95 %ile baseline.
- Compute
cv = std(short) / mean(short). - Compute
baseline— see ADR-103 for the persistent-override path. - Stash
(cv, mean_short, baseline)per node inAMP_LATESTfor cross-node fusion. - Run
amp_classify_from_latest(D2 below) to produce the global(level, presence, confidence).
Returns None until the short window is full so the very first
seconds after boot don't emit garbage.
D2 — Cross-node fusion in amp_classify_from_latest
The deployment has two sensors with very different SNR (node 1 mean amplitude ~22, node 2 mean ~9 on the operator's TP-Link). A single bursty node should not flip the whole detector. We use:
- MAX CV across nodes for the motion gate. Any node seeing movement is enough — body modulates only the line-of-sight path it crosses, the other node may stay clean.
- ANY baseline drop →
present_still. One well-placed node seeing the body is enough.
Decision (universal-threshold normalized — see ADR-103 D3):
norm_max_cv = max_cv / baseline_cv (when calibration loaded)
gates: fallback when no calibration:
norm ≥ 6.0 → "active" max_cv ≥ 0.22
norm ≥ 3.0 → "present_moving" max_cv ≥ 0.10
any drop → "present_still" (same)
otherwise → "absent" (same)
D3 — Sticky 3-second motion hysteresis
After each fusion pass, a global AMP_HOLD counter is reset to
AMP_MOTION_HOLD_TICKS = 120 whenever the candidate is moving /
active. Each subsequent quiet tick decrements the counter; the
prior motion label is kept until it expires (≈ 3 s at the ~40
combined classifier ticks/s). This bridges the brief CV dips between
walking steps so the GLOBAL doesn't flicker between moving and
absent.
D4 — amp_classify_from_latest read-only entry point
The server has multiple SensingUpdate producers — the raw-CSI path
runs the full pipeline above, but the feature_state path (0xC5110006)
arrives without raw amplitudes. We expose a parallel read-only
classifier that pulls the latest stashed per-node (cv, mean, baseline)
from AMP_LATEST and runs the same fusion. The feature_state path
calls it so its emitted classification agrees with the raw-CSI
path's — no flicker between the two SensingUpdate sources.
D5 — Per-node labels in build_node_features
PerNodeFeatureInfo.classification is overridden via
amp_node_snapshot(node_id), which runs the same per-node
classifier (without cross-node fusion or hysteresis) against the
stashed (cv, mean, baseline) for that node alone. UI consumers
(raw.html badges) see each sensor's independent decision plus the
global fused one — useful for finding sensor placement without
moving them.
Files Touched
v2/crates/wifi-densepose-sensing-server/src/main.rs # ~230 lines added
v2/crates/wifi-densepose-sensing-server/static/raw.html # per-node badges
Verified Acceptance
| State | GLOBAL | CV | Per-node detail |
|---|---|---|---|
| EMPTY | absent |
4-6 % | both nodes baseline mean, low CV |
| STILL (lying, in node 1 path) | present_still |
3-8 % | node 1 mean drops 70 %, RSSI -20 dB |
| WALK | active |
12-36 % | node 2 CV explodes, RSSI swings ±5 dB |
Cross-state separation ratio = 3.4× on node 1 broadband mean, 5.9× on node 2 CV, compared to ±0.02 inside ±0.10 noise with the old RSSI MAD-Δ classifier from ADR-099.
Open Items
- ADR-104 will add per-node baseline-drop detection on per-subcarrier L2 distance — currently the CV signal saturates above ~30 % so we lose granularity on heavy movement.
present_stillrequires the body to actually attenuate the path. Off-axis sit doesn't trigger. Future: bring in per-subcarrier delta for off-path presence sensing.
References
- ADR-099 — first RSSI MAD-Δ attempt (superseded for
motion_level/presence/confidence; helper kept as#[allow(dead_code)]). - ADR-100 — gain lock that makes this classifier possible.
- ADR-102 — NBVI subcarrier selection that drives the CV computation.
- ADR-103 — persistent baseline + universal threshold normalization.
docs/references/espectre-techniques.md— full RuView ↔ ESPectre comparison.