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:
parent
432753e188
commit
5a79127780
73
CHECKLIST.md
73
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
Loading…
Reference in New Issue