wifi-densepose/docs/adr/ADR-101-raw-amplitude-class...

6.1 KiB
Raw Blame History

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:

  1. Push current full amplitude vector into the NBVI ranking buffer (nbvi_history, capacity 600 frames ≈ 30 s).
  2. Periodically (every NBVI_REFRESH_TICKS=200 calls, ~5 s) rank subcarriers by NBVI (see ADR-102) and pick the top-12.
  3. Compute broadband_mean as the average of NBVI-selected subcarriers. Falls back to all non-zero subcarriers during warmup.
  4. 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.
  5. Compute cv = std(short) / mean(short).
  6. Compute baseline — see ADR-103 for the persistent-override path.
  7. Stash (cv, mean_short, baseline) per node in AMP_LATEST for cross-node fusion.
  8. 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 droppresent_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_still requires 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.