146 lines
6.1 KiB
Markdown
146 lines
6.1 KiB
Markdown
# 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 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_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`](../references/espectre-techniques.md)
|
||
— full RuView ↔ ESPectre comparison.
|