docs: ADR-101 / ADR-102 / ADR-103 — full session record

* ADR-101 raw-amplitude presence/motion classifier — per-node and
  cross-node fusion logic, hysteresis, per-node UI surface
  (`PerNodeFeatureInfo.classification` override).
* ADR-102 server-side NBVI subcarrier selection — formula, dead-zone
  gate, ESPectre Step-1 quiet-window finder, why we split FULL vs
  NBVI-subset broadband.
* ADR-103 persistent baseline + universal threshold normalization —
  JSON schema v2 at `v2/data/baseline.json`, FULL-broadband over
  NBVI for cross-restart stability, `norm_cv = cv / baseline_cv`
  with universal 3×/6× gates, recording script workflow.
* Updated espectre-techniques.md to reflect the DONE items (Steps
  1+2+4 of NBVI, baseline persistence, universal threshold) and the
  remaining open items in priority order.

Each ADR ≤ 200 lines per the operator's docs convention; deep detail
lives in `docs/references/espectre-techniques.md` (also ≤ 200) which
the ADRs link to. README.md and CLAUDE.md unchanged (no extra
content added; existing >200-line state pre-dates this session).
This commit is contained in:
arsen 2026-05-17 10:46:36 +07:00
parent 2f4b2d5304
commit 4d3ca49fba
4 changed files with 514 additions and 60 deletions

View File

@ -0,0 +1,145 @@
# 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.

View File

@ -0,0 +1,138 @@
# ADR-102 — NBVI Subcarrier Selection (server-side)
**Status**: Accepted
**Date**: 2026-05-17
**Scope**: `v2/crates/wifi-densepose-sensing-server/src/main.rs`
(`AmpState.nbvi_*`, `nbvi_select_top_k`).
## Context
Each ESP32-S3 CSI frame carries 56 active subcarriers on the HT20
20 MHz channel. The amplitudes per subcarrier have very different
SNR depending on frequency-selective fading: in the operator's
deployment subcarriers `k=6..11` and `k=22..26` sit at CV ≈ 6 % when
the room is empty, while subcarriers `k=38..43` (middle of the band,
near the LTF nulls) sit at CV ≈ 11 % — pure channel noise, no
information about the room.
ADR-101's classifier computes broadband-mean CV. Averaging over all
56 subcarriers means the noisy ones drag the baseline CV up to
5-7 %. That blunted the motion gates and we had to push them up to
10-22 %, losing sensitivity to subtle motion.
## Decisions
### D1 — Port Francesco Pace's NBVI to the server (not the FW)
Formula (ESPectre, GPLv3):
```
NBVI(k) = α · (σ_k / μ_k²) + (1 - α) · (σ_k / μ_k), α = 0.5
```
* `σ_k / μ_k²` — penalises weak subcarriers (a quiet bin with mean ≈ 0
gets `∞` and is filtered out).
* `σ_k / μ_k` — standard coefficient of variation; rewards stability.
* `α = 0.5` — empirically balanced (per Pace's α-sweep tests).
**Where**: in the server, not in FW. Pros: trivial to retune per
deployment, no flash cycle, single source of truth across two FW
variants we ship (`runbot_csi_node` and `esp32s3_csi_capture`). Cons:
we lose the ability to *only emit* selected subcarriers (would save
UDP bandwidth) — but at ~25 fps × 56 × 2 bytes = 2.8 KB/s per node,
bandwidth isn't a concern.
### D2 — Top-K with K = 12
Selected at server boot once `nbvi_history` has 90+ samples; then
re-selected every `NBVI_REFRESH_TICKS = 200` calls (~5 s of combined
classifier ticks). The selected indices live in
`AmpState.nbvi_selected`.
K=12 matches ESPectre's default. Smaller K = less averaging
smoothing; larger K = drags in worse subcarriers.
### D3 — Dead-zone gate at 25 % of median mean
Before NBVI scoring, drop any subcarrier whose mean amplitude is
below `0.25 × median(all subcarrier means)`. Guard tones (FW reports
amp[0] = 0 for DC), edge bins, and dead frequencies are excluded so
they can't "win" with σ/μ² → ∞.
### D4 — ESPectre Step 1: quiet-window finder
Naive NBVI ranking over the *entire* history is biased if a body
walked through during the calibration buffer. ADR-102 v2 adds the
quiet-window finder from Pace's Step 1:
1. Slide an `AMP_SHORT_WIN=90`-sample window across `nbvi_history`
with stride `AMP_SHORT_WIN/3 = 30`.
2. For each window, compute the CV of its per-frame broadband mean.
3. The window with the lowest CV is "quietest".
4. Per-subcarrier mean and std for NBVI scoring use **only that
window**.
If history is smaller than one window, the whole buffer is used.
Stride 30 (overlap of 60) keeps wall-clock cost trivial for 600
frames.
### D5 — `mean_for_baseline` uses FULL broadband, not NBVI
NBVI top-K re-selects between server restarts (different "quietest"
window may give different ranking). That made the persisted baseline
value incomparable across restarts (see ADR-103 D1). Fix: ADR-101
classifier keeps a parallel `short_full` ring buffer of FULL
broadband means (all non-zero subcarriers, no NBVI filter). When
ADR-103's persistent override is active, the baseline-drop check
compares full-broadband short window to full-broadband baseline.
NBVI subset is still used for CV (motion sensitivity is what NBVI
shines at — full broadband mean is just the integral level).
## Files Touched
```
v2/crates/wifi-densepose-sensing-server/src/main.rs
- struct AmpState
- nbvi_select_top_k()
- amp_presence_override() (broadband_mean computation)
```
## Verified Acceptance (operator's deployment, 2026-05-17)
Idle empty-room CV, sensing-server with 2 pps housekeeping ping:
| | Full 56 subc | NBVI top-12 |
|---|---|---|
| node 1 (rssi -53 dBm) | ~5.0 % | **3.1 %** |
| node 2 (rssi -67 dBm) | ~7.0 % | **3.9 %** |
Reduction 38-44 %. The lower baseline let ADR-101 gates be tightened
from `15 % / 30 %` down to `10 % / 22 %` for moving/active without
raising the false-positive rate — subtler motions like waving while
sitting near a sensor now trigger.
## Open Items
* **ADR-102 Step 3 (FP-rate validation)** — Pace's full pipeline
validates each candidate top-K by running the motion detector on
the calibration buffer and picking the K with the lowest FP rate.
We take the raw ranking without validation. Could add later for
defense against NBVI accidentally selecting noise-source overlap.
* **Persist NBVI selection**`AMP_BASELINE_OVERRIDE` (ADR-103)
persists baseline scalar but not the chosen subcarrier indices.
After server restart NBVI re-ranks from scratch; in deployments
where the channel changes over hours we'd want to re-rank anyway,
so for now this is correct, not an open item.
* **FW boot-time NBVI freeze** — ESPectre's Pace freezes NBVI for
the lifetime of the boot. Trade-off vs our adaptive rolling
refresh. Worth exploring if FP rate is a problem in real homes.
## References
* ADR-100 — gain lock (gives NBVI a stable per-subcarrier baseline).
* ADR-101 — classifier that consumes NBVI selection.
* ADR-103 — persistent baseline + universal threshold normalization.
* [Pace's *Part 2*](https://medium.com/@francesco.pace/how-i-turned-my-wi-fi-into-a-motion-sensor-part-2-62038130e530)
+ [francescopace/espectre](https://github.com/francescopace/espectre)
on GitHub (GPLv3).
* [`docs/references/espectre-techniques.md`](../references/espectre-techniques.md).

View File

@ -0,0 +1,185 @@
# 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** — would let the
operator press a button in `raw.html` instead of running the
Python script. ~30 min.
* **Per-subcarrier baseline comparison**`per_subcarrier_mean` is
saved but not yet consumed; future ADR-104 can use L2 distance
per-subcarrier instead of broadband mean ratio for off-axis
presence detection.
* **Auto-recalibrate on long quiet periods** — if the classifier sees
`absent` for 30 minutes with low variance, it could opportunistically
update the baseline. Would eliminate the manual script step
entirely. ~1 h.
## 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.

View File

@ -69,30 +69,12 @@ Four-step pipeline at boot:
| 3 | **Rank + validate** | Sort by NBVI ascending. Run the motion detector on each candidate config, measure false-positive rate, take the config with the lowest FP. |
| 4 | **Pick winners** | Top-K by lowest NBVI (typically K = 12 for HT20). |
Memory: O(N) running with on-the-fly mean/variance updates ⇒ ≈ 256 B
for 64 subcarriers. Time: O(N · L) per recompute, milliseconds on a
$10 device.
Memory: O(N) running with on-the-fly mean/variance, ≈ 256 B for 64
subcarriers. Time: O(N · L) per recompute, ms on a $10 device.
**RuView status — PARTIALLY DONE.** ADR-102 (commit `2f12a223`).
Server-side port in `amp_presence_override` /
`nbvi_select_top_k`. What we have:
- ✅ NBVI formula with α = 0.5
- ✅ Top-12 selection
- ✅ Dead-zone gate (`NBVI_DEAD_GATE_PCT = 0.25`)
- ✅ Recompute throttled (`NBVI_REFRESH_TICKS = 200` ≈ every 5 s)
What we **do not** have:
- ❌ **Step 1 quiet-window finder** — we use the *whole* history
buffer. If the buffer captures someone moving, ranking is biased.
Pace's percentile-window detector should be added.
- ❌ **Step 3 FP-rate validation** — we accept the raw NBVI ranking
without testing it on the calibration data.
- ❌ **Boot calibration sequence** (FW-side, 7 s post gain-lock).
Ours is server-side rolling, which means selection drifts forever
rather than locking after boot. Trade-off: adapts to room
rearrangement, but never "settles".
**RuView status — DONE (Steps 1, 2, 4).** ADR-102 (commits
`2f12a223` + `f4119924`). Server-side, see ADR-102 for detail.
Missing: ❌ Step 3 FP-rate validation, ❌ FW-side boot freeze.
Empirically on the operator's deployment NBVI alone gave a 1.5-2× CV
reduction:
@ -118,9 +100,11 @@ Reference 0.25 is what a quiet room typically measures during NBVI
calibration. Apply the scale to the live motion score, so the user-
facing threshold (`= 1.0`) is universal across rooms.
**RuView status — NOT DONE.** Our `amp_node_level` uses fixed
thresholds tuned to one deployment (CV 10 % moving, CV 22 % active,
mean/baseline < 0.75 still). Other deployments will need re-tuning.
**RuView status — DONE.** ADR-103 D3 (commit `2f4b2d53`).
`amp_node_level` and `amp_classify_from_latest` divide live CV by
`baseline_cv` loaded from `data/baseline.json` and gate at universal
`3×` (moving) / `6×` (active). Falls back to absolute gates
`0.10 / 0.22` when no calibration loaded — backwards compatible.
## 4. Two-phase boot calibration
@ -142,9 +126,12 @@ After NBVI calibration, ESPectre writes the AGC/FFT lock values, the
chosen subcarrier set, the baseline variance, and the threshold into
NVS so reboots don't need re-calibration.
**RuView status — NOT DONE.** Each server restart triggers a fresh
60-second baseline learn. NBVI also re-ranks from scratch on restart.
Open item: persist `AMP_LATEST.baseline` to disk + load at startup.
**RuView status — DONE for baseline; PARTIAL for the rest.** ADR-103
(commits `f4119924`, `2f4b2d53`). `data/baseline.json` persists per-
node full-broadband mean/p95/CV + per-subcarrier means, loaded at
server boot via `load_baseline_file`. Gain lock + NBVI selection
still happen fresh on each FW boot / server boot respectively (open
items 4 + 5 below).
## 6. Interactive Web Serial game (`espectre.dev/game`)
@ -176,37 +163,36 @@ for the amplitude classifier or NBVI.
| Item | Pace / ESPectre | RuView |
|---|---|---|
| Gain lock | FW, 300 pkt median, AGC+FFT, AGC<30 skip | Same, in `csi_collector.c` |
| NBVI formula | α·σ/μ² + (1-α)·σ/μ, α=0.5, top-12 | ✅ Same, server-side |
| Dead-zone gate | 25th percentile of mean | ✅ `NBVI_DEAD_GATE_PCT=0.25` |
| Quiet-window finder | Percentile-window in calibration buffer | ❌ Use full window verbatim |
| FP-rate validation of NBVI pick | Yes | ❌ Take raw ranking |
| Boot-time NBVI freeze | FW, ~7 s post-lock | ❌ Server-side rolling |
| Baseline variance normalization | `scale = 0.25 / σ²` | ❌ Fixed thresholds per deployment |
| NVS persistence of calibration | Yes | ❌ Fresh learn each restart |
| Universal threshold | One value across rooms | ❌ Re-tune per deployment |
| Calibration UI | Web Serial game | ❌ Read-only raw.html |
| HA integration | ESPHome native | ❌ None |
| Test suite | 500+ tests, 90 % coverage | ❌ ~2 parser tests only |
| Phase / amplitude | Amplitude only (TPR avoidance) | ✅ Same |
| Subcarrier count | 64 (HT20) | 56 (ESP32-S3 reports 56 non-guard) |
| Gain lock | FW, 300 pkt median, AGC+FFT, AGC<30 skip | ADR-100 |
| NBVI formula α=0.5, top-12, dead-zone gate | ✅ | ✅ ADR-102 |
| Quiet-window finder (Step 1) | ✅ | ✅ ADR-102 v2 |
| FP-rate validation (Step 3) | ✅ | ❌ raw ranking |
| Boot-time NBVI freeze | FW, ~7 s post-lock | ❌ server-side rolling |
| Baseline variance normalization (universal threshold) | ✅ | ✅ ADR-103 D3 |
| Persisted baseline to disk | NVS | ✅ ADR-103 D1 (`data/baseline.json`) |
| NVS persistence of FW calibration | ✅ | ❌ fresh each FW boot |
| Calibration UI | Web Serial game | ❌ read-only `raw.html` |
| HA / ESPHome integration | ✅ | ❌ none |
| Test suite | 500+ tests, 90 % cov | ❌ 2 parser tests |
| Phase / amplitude | amplitude only | ✅ same |
## Open items, ranked by expected impact on RuView
1. **Quiet-window finder for NBVI Step 1** — if the operator restarts
the server while the room is occupied, current NBVI biases its
ranking toward subcarriers stable on the *occupied* state. Bug:
present_still then under-triggers. ~1 h.
2. **Persist `AMP_LATEST.baseline` to disk** — eliminates the
"step outside for 60 s" ritual after every restart. ~30 min.
3. **Baseline variance normalization** — would let us ship one
threshold set for any deployment. ~1 h.
4. **FP-rate validation of NBVI pick** — would catch the case where
the top-12 ranked subcarriers happen to overlap with a noise
source. ~1 h.
5. **Boot-time NBVI freeze** — if we want fully reproducible
behaviour. Trade-off: doesn't adapt to room changes. ~2 h.
6. **HA / ESPHome integration** — depends on whether RuView wants
to be a HA sensor or stay standalone. ~1 day.
7. **Web Serial calibration UI** — nice-to-have, lower priority than
the algorithmic items. ~1 day.
1. **REST `POST /api/v1/baseline/calibrate`** — drives the recording
script from a button in `raw.html` instead of CLI. ~30 min.
2. **FP-rate validation of NBVI pick** — defense against the top-12
accidentally overlapping a noise source. ~1 h.
3. **Per-subcarrier baseline comparison (ADR-104 draft)** — uses the
already-saved `per_subcarrier_mean` in `baseline.json` for L2
distance instead of broadband mean ratio. Better off-axis
presence sensing. ~1 h.
4. **Auto-recalibrate on long quiet periods** — if classifier sees
`absent` with low variance for 30 min, refresh baseline in
background. Eliminates manual script step entirely. ~1 h.
5. **FW-side NBVI boot-freeze + NVS persistence** — full
reproducibility, sub-second post-boot ready. Trade-off: doesn't
adapt to room changes. ~2 h.
6. **HA / ESPHome integration** — sensor as HA entity. ~1 day.
7. **Test suite vs fixed 2 000-packet replay** — regression
protection for the classifier + NBVI. ~1 day.
8. **Web Serial calibration game** — nice-to-have. ~1 day.