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 <ruv@ruv.net>
This commit is contained in:
arsen 2026-05-17 16:35:18 +07:00
parent 432753e188
commit 5a79127780
3 changed files with 198 additions and 38 deletions

View File

@ -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

View File

@ -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

View File

@ -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.