wifi-densepose/docs/adr/ADR-103-persistent-baseline.md

6.2 KiB
Raw Blame History

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_p95full_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 — 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.