From 5a79127780d5f2fec9e598ab9268945ee90cfd5c Mon Sep 17 00:00:00 2001 From: arsen Date: Sun, 17 May 2026 16:35:18 +0700 Subject: [PATCH] docs(adr-112): ADR-112 + close ADR-105 + CHECKLIST sweep - ADR-112 (Multi-AP signal_field via MultistaticFuser) added. - ADR-105 closes the Real-signal_field Open Item. - CHECKLIST: ADR-107/112/109/105 closures recorded; out-of-scope items moved to a Deferred section with explicit reasons. Co-Authored-By: claude-flow --- CHECKLIST.md | 73 +++++---- ...no-synthetic-data-in-production-runtime.md | 9 +- docs/adr/ADR-112-multi-ap-signal-field.md | 154 ++++++++++++++++++ 3 files changed, 198 insertions(+), 38 deletions(-) create mode 100644 docs/adr/ADR-112-multi-ap-signal-field.md diff --git a/CHECKLIST.md b/CHECKLIST.md index 740d8711..30cd2498 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md @@ -37,12 +37,24 @@ Last sweep: **2026-05-17**, branch `feat/ota-rssi-mobile`, head `eec3ca6c`. - [x] **ADR-107** `POST /api/v1/baseline/calibrate` + UI button - [x] **ADR-107** Auto-recalibrate on long-quiet periods (30 min default) - [x] **ADR-107** `GET /api/v1/baseline` (status + cooldown) +- [x] **ADR-107** Progress bar in raw.html calibrate button + (commit 432753e1) +- [x] **ADR-112** Multi-AP `signal_field` via `MultistaticFuser` — + coverage × activity heatmap, non-zero only with ≥2 nodes + + positions; preserves ADR-105 zero-grid otherwise (commit c8ac60f6) +- [x] **ADR-105** Hide pose canvas in Docker SPA when + `model_loaded == false` + "no trained model" overlay + (commit 2dcb30a6) ### Firmware (`firmware/esp32-csi-node`) - [x] **ADR-100** Gain-lock (300-packet median, MIN_SAFE_AGC=30 safety) - [x] **ADR-106** Sensor µs timestamp in CSI trailer (`rx_ctrl.timestamp`) - [x] **ADR-108** NVS persistence of gain-lock — reboot ready in ~0.5 s +- [x] **ADR-109** `POST /ota/recalibrate` — clear gain-lock NVS via REST, + no USB needed (commit f92807cd) +- [x] **ADR-109** Track AP MAC in `gl_ap_mac` NVS — auto-invalidate + stale gain-lock on AP swap (commit f92807cd) - [x] (parallel agent) RSSI carry-through via feature_state header fix - [x] (parallel agent) OTA: `OTA_SIZE_UNKNOWN`, httpd stack_size=8192, reset-reason log — all three FW prerequisites for working OTA @@ -68,61 +80,52 @@ Last sweep: **2026-05-17**, branch `feat/ota-rssi-mobile`, head `eec3ca6c`. ### High value, low effort -- [ ] **`POST /ota/recalibrate`** — clear gain-lock NVS via REST, - no USB needed. ~30 min FW + OTA. (ADR-108 open) -- [ ] **Track AP MAC in NVS alongside gain-lock** — auto-invalidate - stale values on AP swap. ~1 h FW + OTA. (ADR-108 open) - [ ] **Tailscale-target in NVS** — sensor stream keeps working when Mac roams networks. ~30 min provision + reflash. (ADR-100 open) + Deferred — Mac is stable on TP-Link, low ROI this session. ### High value, medium effort -- [ ] **HA via MQTT** — sensor as HA entity (`binary_sensor.motion`). - Wide ecosystem reach. ~1 day. - [ ] **2 000-packet fixed-replay test suite** — regression protection over classifier + NBVI. Pace's pattern (1 000 idle + 1 000 motion). ~1 day. -- [ ] **Multi-AP `signal_field` via `MultistaticFuser`** — replaces - zero-filled grid (ADR-105 D6) with physically real spatial map. - ~2-3 h. - [ ] **Phase-domain drift** — phase delta vs baseline phase, picks up sub-mm chest-wall motion for vital signs. Requires phase baseline in `baseline.json`. ~1 h script + ~30 min server. (ADR-104 open) -- [ ] **Hide pose canvas in Docker SPA when `model_loaded == false`** - — stop the upstream UI from rendering empty skeletons. - ~15 min UI patch. (ADR-105 open) -### Bigger, lower urgency +### Bigger, lower urgency (still active) -- [ ] **ESPHome native component** — tighter HA than MQTT bridge. 2-3 days. -- [ ] **Web Serial calibration game** — playful threshold tuning. 1 day. -- [ ] **Boot-time NBVI freeze in FW** — only if FP issues in real homes. - Trade-off: doesn't adapt to channel changes. 2 h. (ADR-102 open) -- [ ] **Per-channel NVS cache for gain-lock** — needed only if channel - hopping (ADR-029) is reactivated. 1 h. (ADR-108 open) -- [ ] **DensePose model train + load** — unlock 17-keypoint pose; - needs dataset (MM-Fi or Wi-Pose) + training run. 1-3 days. -- [ ] **AETHER contrastive pretrain on live data** — code path exists - via `--pretrain`. Self-supervised, no labels. 2-3 h to set up + - hours of training time. -- [ ] **MERIDIAN domain generalization** — code present (parent - project), not loaded. Cross-room transfer. 1 day to integrate. -- [ ] **Channel hopping (ADR-029)** — scaffold in FW, deactivated. - Frequency diversity for anomaly detection. 2-3 h. -- [ ] **Multi-antenna support (`n_antennas` > 1)** — currently hard- - coded to 1 in `csi_collector.c`. ESP32-S3 typically single- - antenna so low value unless we ship on C6/MIMO. 1 h. -- [ ] **Multiple baseline profiles** (day/night/season). 2 h. -- [ ] **Progress bar in calibrate button** instead of text pill. 15 min. +- [ ] **Multiple baseline profiles** (day/night/season). 2 h. — ADR-113 + target this session. ### One-time hygiene -- [ ] **README.md** is 542 lines — review for current relevance, trim. -- [ ] **CLAUDE.md** is 407 lines — same. - [ ] **Re-record `data/baseline.json`** via the new UI calibrate button so `per_subcarrier_mean` field is populated and ADR-104 drift channel activates. ~2 min operator time. +### Deferred — out of session scope + +Marked here so future sessions don't re-litigate; each line carries +an explicit reason. Bring them back only if scope changes. + +- **HA via MQTT** — new integration. Excluded by current session brief + (no new integrations on current hardware). +- **ESPHome native component** — same reason as HA/MQTT. +- **Web Serial calibration game** — explicitly excluded. +- **Boot-time NBVI freeze in FW** — explicitly excluded. +- **Per-channel NVS cache for gain-lock** — explicitly excluded; only + matters if channel hopping is reactivated, which is also excluded. +- **DensePose model train + load** — explicitly excluded. +- **AETHER contrastive pretrain on live data** — explicitly excluded. +- **MERIDIAN domain generalization** — explicitly excluded. +- **Channel hopping (ADR-029)** — explicitly excluded. +- **Multi-antenna support (`n_antennas` > 1)** — explicitly excluded. +- **README.md trim (542 lines)** — explicitly excluded. +- **CLAUDE.md trim (407 lines)** — explicitly excluded. +- **Tailscale-target in NVS** — Mac stable on TP-Link this session, + low ROI. Not blocking. + --- ## Reference diff --git a/docs/adr/ADR-105-no-synthetic-data-in-production-runtime.md b/docs/adr/ADR-105-no-synthetic-data-in-production-runtime.md index 7df8d76b..18ca4711 100644 --- a/docs/adr/ADR-105-no-synthetic-data-in-production-runtime.md +++ b/docs/adr/ADR-105-no-synthetic-data-in-production-runtime.md @@ -167,9 +167,6 @@ classification absent / present_still / present_moving / active per ADR-1 on empty pose data; the richer Docker UI still tries to render a skeleton from `pose_current` even when the array is empty. Need a small UI patch: hide the pose canvas when `model_loaded == false`. -* **Real signal_field** via multistatic fusion — when ≥ 2 nodes are - active, `MultistaticFuser` can produce a physically meaningful - spatial map. ADR-104 will cover wiring it through. ## Closed @@ -178,6 +175,12 @@ classification absent / present_still / present_moving / active per ADR-1 alongside the legacy `contributing_bssids` / `bssid_count` counts. Consumers can gate on `n_aps_used >= 2` before trusting a multi-AP enhancement. (commit 598a4b2f) +* **Real signal_field via multistatic fusion** — shipped in ADR-112. + When ≥ 2 ESP32 nodes are active, `MultistaticFuser` output drives + a coverage × activity 20×20 heatmap (isotropic Gaussian per node + position, gated by `cv²(fused_amplitude) × cross_node_coherence`). + Single-sensor / fusion-fail paths still return ADR-105's zero + grid. Map is honestly framed as coverage, not target position. ## References diff --git a/docs/adr/ADR-112-multi-ap-signal-field.md b/docs/adr/ADR-112-multi-ap-signal-field.md new file mode 100644 index 00000000..f32aac3b --- /dev/null +++ b/docs/adr/ADR-112-multi-ap-signal-field.md @@ -0,0 +1,154 @@ +# ADR-112 — Multi-AP `signal_field` via `MultistaticFuser` + +**Status**: Accepted +**Date**: 2026-05-17 +**Scope**: `v2/crates/wifi-densepose-sensing-server/src/main.rs` +(`signal_field_from_multistatic`, two ESP32 vitals call sites). Closes +the "Real signal_field via multistatic fusion" Open Item in ADR-105. + +## Context + +ADR-105 D6 stripped the synthetic `signal_field` paint and left a 20×20 +zero grid in its place. The honesty contract was: never emit visual +positional output without a physically grounded source. A real +multistatic fuser (`MultistaticFuser` in `wifi-densepose-signal`) is +already wired into the server via `multistatic_bridge::fuse_or_fallback` +and consumed by `compute_person_score_from_amplitudes` — but its +output didn't feed the `signal_field` heatmap. + +This ADR consumes that fusion output to produce a *coverage × activity* +spatial map when ≥ 2 ESP32 nodes are simultaneously active. + +## What the new map honestly is (and isn't) + +* **Is**: a 20×20 floor-plane heatmap where each cell value = + Σ over active nodes of `global_activity · exp(-d²/2σ²)`, with `d` + the Euclidean distance from the cell to that node's configured + position, σ a fixed radius, and `global_activity` = + `cv²(fused_amplitude) · cross_node_coherence`. Both factors live in + `[0, 1]`; their product gates the field on simultaneous CSI + modulation AND inter-node agreement. +* **Is not**: a person-location estimate. Commodity ESP32s have no + phase-coherent ranging (no UWB, no two-way ranging); any "target + position" would be fabrication. The map shows *where the active + sensors' coverage zones overlap when they collectively see + modulation*. That's a real, derivable quantity. A "where is the + person" claim is not, and is deliberately withheld. + +## Decisions + +### D1 — `signal_field_from_multistatic(fuser, node_states) -> SignalField` + +New function in `main.rs`. Re-runs `multistatic_bridge::fuse_or_fallback` +(cheap — attention-weighted mean across O(N_nodes × N_subcarriers)), +discards the count-fallback path, and proceeds only when: + +* `fused.active_nodes >= 2`, AND +* `fused.node_positions` non-empty, AND +* `fused.fused_amplitude` non-empty, AND +* `global_activity > 1e-3` (everything below is rounding noise). + +Otherwise returns the same zero-filled grid `generate_signal_field` +produces. This preserves ADR-105's contract on single-sensor +deployments and degenerate fusion failures. + +### D2 — Render constants + +* Grid `20 × 1 × 20` (matches the existing `SignalField` shape and the + UI's heatmap consumer). +* `ROOM_EXTENT_M = 3.0` m (half-width of the square the grid spans — + 6 m × 6 m floor). Matches the typical "operator room" dimension and + the placement of the two physical sensors. +* `SIGMA_M = ROOM_EXTENT_M / 4.0 = 0.75 m` for the isotropic Gaussian. + Borrowed from Pace's ESPectre heuristic (his code uses ~room/4 for + a similar overlap-rendering pass). +* `(grid_x, grid_y) → (x, z)` projection — the WiFi sensors live in + 3D position space `[x, y, z]` where `y` is height, but the heatmap + is a floor-plan view, so we ignore `y` and use `(x, z)`. + +### D3 — `cv² × coherence` as the activity scalar + +Two factors so that EITHER a quiet channel (low cv²) OR disagreeing +sensors (low coherence) collapses the field to zeros. This means: + +* Empty room (low cv²) → blank map. Truthful. +* One sensor saw a transient (high cv² for one node, low coherence + across nodes) → blank map. Truthful — no multistatic signal. +* All sensors see synchronized modulation → bright map. Truthful — + there really is something in the shared coverage. + +The product is bounded in `[0, 1]`; we clamp each cell to `[0, 1]` +post-sum because two overlapping gaussians can sum to > 1 in their +shared region. + +### D4 — Call-site contract: prefer multistatic, else zero + +Both ESP32 vitals paths build the field as: + +```rust +let multi = signal_field_from_multistatic(&s.multistatic_fuser, &s.node_states); +if multi.values.iter().any(|&v| v > 0.0) { multi } else { /* zero */ } +``` + +A `multi` that is all-zero — either because `< 2` nodes are active or +because the activity threshold wasn't met — gets discarded and the +existing `generate_signal_field` zero is emitted. This keeps the +output identical to today's behavior when the multistatic path can't +produce signal, so no consumer is surprised. + +The Windows WiFi / multi-BSSID paths (`windows_wifi_task`) are not +touched: they have no per-node spatial positions, so the multistatic +approach doesn't apply and they keep their zero grid. + +## Trade-offs + +* **Node positions must be configured.** The `--node-positions` + CLI flag (`SENSING_NODE_POSITIONS` env) is the source of truth. + If unset, `multistatic_fuser` has empty positions, so this ADR + silently degrades to zero output — no user-visible regression. +* **Coverage map ≠ target map.** Operators looking at the heatmap + will be tempted to read it as "the person is here." Mitigation: + the field is brightest *at the nodes themselves*, not between + them, so the visual signature is "sensor coverage glow," not "blob + in the middle of the room." A future ADR (e.g. ADR-115, RF + tomography or RSSI MUSIC) could replace this with a real + localizer; this ADR is the honest baseline that holds until then. +* **σ is fixed.** A room-sized parameter should arguably scale with + the inter-node distance, but until we have more than two sensors + in one deployment that's premature parameter sprawl. The + `ROOM_EXTENT_M` / `SIGMA_M` constants are intentionally + hard-coded in one place to be easy to find and tune. + +## Files Touched + +``` +v2/crates/wifi-densepose-sensing-server/src/main.rs + - signal_field_from_multistatic (D1, D2, D3) + - two vitals-path call sites adopt the prefer-multistatic-else-zero + contract (D4) + +docs/adr/ADR-112-multi-ap-signal-field.md (this) +docs/adr/ADR-105-no-synthetic-data-in-production-runtime.md + - close "Real signal_field via multistatic fusion" Open Item +``` + +## Verified Acceptance + +* `cargo build --release -p wifi-densepose-sensing-server` clean. +* `cargo test --release -p wifi-densepose-sensing-server + --no-default-features` — 313 tests pass (no regressions). +* With one sensor active, `signal_field.values` are all zero — + matches ADR-105 behaviour. +* With two sensors active and a person moving in shared coverage, + the field is non-zero with bright cells overlapping at each + sensor's footprint and tapering between them. + +## References + +* ADR-105 D6 — the "no synthetic signal_field" honesty contract. +* `wifi_densepose_signal::ruvsense::multistatic::MultistaticFuser` — + the upstream attention-weighted fuser this ADR consumes. +* `multistatic_bridge::fuse_or_fallback` — the existing call path + this ADR reuses. +* Francesco Pace, *How I Turned My Wi-Fi Into a Motion Sensor — + Part 2*, "Multi-AP heatmap" — the σ ≈ room/4 heuristic source.