Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot] da64132703 chore: update vendor submodules to latest upstream 2026-06-10 19:00:39 +00:00
50 changed files with 125 additions and 5846 deletions
-4
View File
@@ -19,10 +19,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **`--export-rvf` no longer silently produces a placeholder model — PR #920.** The `--export-rvf` handler ran *before* `--train`/`--pretrain` and unconditionally wrote placeholder sine-wave weights, so the documented `--train … --export-rvf <path>` workflow short-circuited to a fake model and never trained (while printing "exported successfully"). It now emits the placeholder **container-format demo** only standalone (with a clear warning), and falls through to real training when `--train`/`--pretrain` is set; docs point to `--save-rvf` for the real model. 3 guard tests.
### Added
- **ADR-151 per-room calibration & specialist training — full `baseline → enroll → extract → train` pipeline (new `wifi-densepose-calibration` crate).** "Teach the room before you teach the model": a local-first pipeline that turns a few minutes of clean human anchors — layered on the ADR-135 empty-room baseline — into a versioned bank of small, room-calibrated specialists for **presence, posture, breathing, heartbeat, restlessness, and anomaly**. Stages: guided enrollment with an adaptive quality gate (event-sourced `EnrollmentSession`, re-prompts bad anchors); feature extraction (autocorrelation periodicity in breathing/HR bands + variance/motion); six small specialists (learned threshold / nearest-prototype / band-limited periodicity / novelty); a `SpecialistBank` with baseline-drift **STALE** invalidation; and a `MixtureOfSpecialists` runtime with presence short-circuit + anomaly veto + confidence gating. Specialists are statistical heads today (runnable + hardware-validated); the frozen ADR-150 HF RF Foundation Encoder backbone is the documented upgrade path.
- **CLI:** `enroll` / `train-room` / `room-status` / `room-watch`, plus the Stage-1 `calibrate-serve` HTTP API (CORS-enabled: `POST /start`, `GET /status`, `POST /stop`, `GET /result`, `GET /baselines`, `GET /health`) and a firewall-free `scripts/csi-udp-relay.py` for local Windows ESP32 testing without admin.
- **Multistatic fusion (ADR-029):** `MultiNodeMixture` fuses several co-located nodes (each with its own room-calibrated bank) into one room state — presence OR'd across nodes, posture/breathing/heartbeat from the highest-confidence node, a single implausible node vetoes the room's vitals. Driven via `room-watch --node-bank N:path` (repeatable), which groups live frames by `node_id` and fuses. Same-room only; cross-room is federation (ADR-105).
- **Validated on live ESP32-S3 (COM8, `edge_tier=0` raw CSI):** baseline capture (120 frames → 52-subcarrier baseline); the real parser → feature-extraction → mixture runtime detecting breathing (~1631 BPM); and the multistatic ingest grouping/fusing by node-id end-to-end. Full multi-anchor enrollment accuracy requires the operator to perform the poses; true 2-node fusion + phase-based breathing + RVF/HNSW storage are noted follow-ups. 54 tests pass (35 calibration + 19 CLI).
- **WiFi-CSI pose: efficiency frontier + per-room calibration service** (ADR-150 §3.23.6). Two beyond-SOTA results on the MM-Fi benchmark, plus the deployment mechanism that resolves real-world generalization:
- **Efficiency frontier** — a **75 K-param model beats published SOTA** (74.3% vs MultiFormer 72.25% torso-PCK@20); every config from `micro` up is Pareto-dominant (smaller *and* more accurate than prior work). Shipped a deployable **int4 edge model (~20 KB, verified 74.08%, 0.135 ms single-thread CPU)** — published at [`ruvnet/wifi-densepose-mmfi-pose/edge`](https://huggingface.co/ruvnet/wifi-densepose-mmfi-pose). See [`docs/benchmarks/wifi-pose-efficiency-frontier.md`](docs/benchmarks/wifi-pose-efficiency-frontier.md).
- **Generalization solved by few-shot calibration** — zero-shot cross-subject (~64%) and cross-environment (~10%) are *not* closeable by algorithms (CORAL, DANN, instance-norm, contrastive foundation-pretraining all tested, all failed) or by more training subjects (saturates ~64%). But **~100200 labeled in-room samples recover SOTA-level pose**: cross-subject 64→76%, **cross-environment 10→73% (60% from just 5 samples)** — deployable as a **~11 KB per-room LoRA adapter** on a frozen shared base. Full empirical chain in ADR-150 §3.23.6.
+1 -2
View File
@@ -15,8 +15,7 @@ Dual codebase: Python v1 (`v1/`) and Rust port (`v2/`).
| `wifi-densepose-hardware` | ESP32 aggregator, TDM protocol, channel hopping firmware |
| `wifi-densepose-ruvector` | RuVector v2.0.4 integration + cross-viewpoint fusion (5 modules) |
| `wifi-densepose-wasm` | WebAssembly bindings for browser deployment |
| `wifi-densepose-cli` | CLI tool (`wifi-densepose` binary)`calibrate`/`calibrate-serve`/`enroll`/`train-room`/`room-watch` + MAT (MAT gated behind the `mat` feature; build `--no-default-features` for the aarch64/appliance calibration binary) |
| `wifi-densepose-calibration` | ADR-151 per-room calibration & specialist training — `baseline → enroll → extract → train` → bank of small specialists (presence/posture/breathing/heartbeat/restlessness/anomaly) + multistatic fusion; pure Rust, edge-deployable |
| `wifi-densepose-cli` | CLI tool (`wifi-densepose` binary) |
| `wifi-densepose-sensing-server` | Lightweight Axum server for WiFi sensing UI |
| `wifi-densepose-wifiscan` | Multi-BSSID WiFi scanning (ADR-022) |
| `wifi-densepose-vitals` | ESP32 CSI-grade vital sign extraction (ADR-021) |
+3 -7
View File
@@ -221,15 +221,11 @@ class ESP32BinaryParser:
snr = float(rssi - noise_floor)
frequency = float(freq_mhz) * 1e6
bandwidth = 20e6 # default; could infer from n_subcarriers
# Bandwidth inference (issue #1005): HE-LTF uses a 4x denser tone
# grid than HT-LTF on the same channel width — an HE-SU frame with
# 256 bins (242 active HE20 tones) is a *20 MHz* capture, not 160.
if ppdu_byte in (1, 2, 3): # HE-SU / HE-MU / HE-TB
bandwidth = 40e6 if (flags_byte & 0x01) or n_subcarriers > 256 else 20e6
elif n_subcarriers <= 64: # ESP32 HT20 delivers the full 64-bin FFT
if n_subcarriers <= 56:
bandwidth = 20e6
elif n_subcarriers <= 128:
elif n_subcarriers <= 114:
bandwidth = 40e6
elif n_subcarriers <= 242:
bandwidth = 80e6
+1 -1
View File
@@ -57,7 +57,7 @@ This witness separates what was **empirically observed on real silicon today** f
| # | Claim | Why it's not verified |
|---|---|---|
| **B1** | "Wi-Fi 6 HE-LTF: 242 subcarriers per HE20 frame" | The only AP in range (`ruv.net`) is 11n-only. Every captured frame is 128 bytes = 64 subcarriers (HT-LTF, `ppdu_type=0`). No HE-SU/HE-MU/HE-TB observed. Even if an 11ax AP were available, **whether ESP-IDF v5.4's CSI callback exposes HE-LTF subcarriers via `wifi_csi_info_t.buf` is an open question** — the public API was designed for HT-LTF, and the driver may quietly downconvert. **Validate by capturing CSI against an 11ax AP and comparing `info->len` between HT and HE frames.**<br><br>**RESOLVED WITH MEASUREMENT (2026-06-11, external — issue #1005, production deployment by @stuinfla):** the open question is answered in both directions. **IDF v5.4's driver blob downconverts** (148 B / 64-subcarrier HT frames, PPDU byte 0x00, on a confirmed-HE link); **IDF v5.5.2 delivers true HE-LTF** — 532 B frames = 256 bins (242 active HE20 tones), PPDU byte 0x01 (HE-SU), ~90% of frames, same board/AP/link. Setup: XIAO ESP32-C6 → hostapd on Intel AX210, 2.4 GHz ch 6, `ieee80211ax=1`. No firmware change required (`acquire_csi_su=1` was already set); the gate was purely the IDF driver version. Three C6 nodes ran this mode simultaneously with ADR-110 ESP-NOW sync. Requires the issue-#1005 version-guard fix in `c6_sync_espnow.c` to build on v5.5.x. |<br><br>**REPLICATED IN-HOUSE (2026-06-11):** same source + fix, fresh IDF v5.5.2 toolchain, original COM12 board (`20:6e:f1:17:00:84`), AP `ruv.net` (11ax 2.4 GHz): **84% of 1,525 captured frames at 532 B / PPDU 0x01 (HE-SU)**, HT minority 148 B / 0x00. Evidence grade: MEASURED (two independent rigs). |
| **B1** | "Wi-Fi 6 HE-LTF: 242 subcarriers per HE20 frame" | The only AP in range (`ruv.net`) is 11n-only. Every captured frame is 128 bytes = 64 subcarriers (HT-LTF, `ppdu_type=0`). No HE-SU/HE-MU/HE-TB observed. Even if an 11ax AP were available, **whether ESP-IDF v5.4's CSI callback exposes HE-LTF subcarriers via `wifi_csi_info_t.buf` is an open question** — the public API was designed for HT-LTF, and the driver may quietly downconvert. **Validate by capturing CSI against an 11ax AP and comparing `info->len` between HT and HE frames.** |
| **B2** | "TWT-bounded deterministic CSI cadence (10 ms wake)" | No 11ax AP in range. The TWT setup *call* was exercised live and the graceful fallback path is now correct (A9), but the agreement itself was never accepted. **Validate by associating with an 11ax AP that has TWT Responder=1, then capturing the timestamped CSI cadence vs the wall clock.** |
| **B3** | "±100 µs cross-node alignment over 802.15.4" | 3 boards initialized their radios with correct EUIs (A4/A5), but **none stepped down from candidate-leader to follower** during repeated 35-second multi-board captures. <br><br>**Coex hypothesis REJECTED**: rebuilt + reflashed all 3 boards with `CONFIG_C6_TIMESYNC_CHANNEL=26` (2480 MHz, non-overlapping with WiFi ch 5 at 2432 MHz). Result identical: 3× candidate, 0× "stepping down". So 2.4 GHz radio coex was NOT the cause. <br><br>**Current leading hypothesis**: OpenThread (CONFIG_OPENTHREAD_ENABLED=y) owns the 802.15.4 radio when its stack is initialized — our weak-symbol overrides of `esp_ieee802154_receive_done` / `_transmit_done` may never be called because OpenThread registers strong handlers. Validation in progress: rebuilding with `CONFIG_OPENTHREAD_ENABLED=n` (raw 802.15.4 only, our beacon protocol is private — no need for the Thread stack). If leader election fires under raw-15.4-only, hypothesis confirmed. <br><br>If raw-only also fails, next move is to dump the actual PHY frame bytes via the IEEE 802.15.4 sniffer mode on a 4th board and diagnose at the frame level. |
| **B4** | "~5 µA hibernation for battery seed nodes" | No INA / Joulescope current measurement available on this bench. The shipped code uses `esp_deep_sleep_enable_gpio_wakeup` (ext1 path, ESP-IDF default ~10 µA), not a true LP-core polling program. The 5 µA number is the C6 datasheet figure for ULP-level hibernation, not a measured value. **Validate by hooking an INA219/INA226 between the dev board's 3V3 rail and the regulator output, then averaging current over a 60-second cycle with the LP-core armed.** |
@@ -19,7 +19,7 @@ The production CSI node firmware (`firmware/esp32-csi-node`) was built around th
| C6 capability | What it enables for sensing | Why we can't get it on S3 |
|---|---|---|
| **802.11ax (Wi-Fi 6) HE-LTF CSI** | 242 subcarriers per HE20 frame (vs 52 for HT-LTF), HE-MU/HE-TB PPDU types, OFDMA-aware channel sounding. **Hardware-confirmed 2026-06-11** (issue #1005, external production deployment): requires **ESP-IDF ≥ 5.5** — the v5.4 driver blob silently downconverts to 64-subcarrier HT even on a confirmed-HE link; v5.5.2 delivers 532 B frames = 256 bins (242 active tones), PPDU 0x01 (HE-SU). See WITNESS-LOG-110 §B1 (resolved). | S3 radio is HT-only (n) |
| **802.11ax (Wi-Fi 6) HE-LTF CSI** | 242 subcarriers per HE20 frame (vs 52 for HT-LTF), HE-MU/HE-TB PPDU types, OFDMA-aware channel sounding | S3 radio is HT-only (n) |
| **802.15.4 (Thread / Zigbee)** | Cross-node time-sync over a separate radio — frees Wi-Fi airtime for CSI, ±100 µs alignment possible without coordination traffic on the sensing channel | S3 has no 802.15.4 |
| **TWT (Target Wake Time)** | Sensor negotiates a deterministic wake slot with the AP; CSI cadence becomes scheduler-bounded instead of opportunistic | Requires 802.11ax — S3 can't speak it |
| **LP-core + hibernation (~5 µA)** | Always-on motion gate runs on a separate RISC-V LP core in deep sleep; HP core stays off until a real event | S3 ULP is FSM-only, ~10 µA floor |
@@ -1,260 +0,0 @@
# ADR-151: RuView Per-Room Calibration & Specialized Model Training System
| Field | Value |
|-------|-------|
| **Status** | Accepted — Stages 15 implemented (statistical specialists); HF-backbone distillation pending |
| **Date** | 2026-06-09 |
| **Deciders** | ruv |
| **Codebase target** | New `wifi-densepose-calibration` crate (orchestration); `wifi-densepose-train` (`rapid_adapt.rs`, `signal_features.rs`, `trainer.rs`); `wifi-densepose-ruvector` (RVF specialist storage); `wifi-densepose-signal/ruvsense/*` (feature extractors); `wifi-densepose-cli` (`enroll`, `train-room`, `room-status` subcommands) |
| **Relates to** | ADR-135 (Empty-Room Baseline Calibration), ADR-030 (Persistent Field Model), ADR-134 (CIR), ADR-024 (Contrastive CSI Embedding / AETHER), ADR-027 (Cross-Environment Domain Generalization / MERIDIAN), ADR-070 (Self-Supervised Pretraining), ADR-105 (Federated CSI Training), ADR-149 (AetherArena / Hugging Face), ADR-150 (RF Foundation Encoder) |
---
## 1. Context
### 1.1 The thesis — teach the room before you teach the model
RuView's deployment frontier is not a better generic model. ADR-150 documents the wall directly: an MM-Fi pose head scores **81.63% torso-PCK@20 in-domain but ~11.6% leakage-free cross-subject**, and bigger capacity *hurts* cross-subject (transformer 24.8% < conv 27.3%). A single oversized model that "understands the world" overfits the rooms and bodies it has seen. The lever is the opposite of scale: **a small model that understands *one* room and *one* person**, calibrated in minutes, run locally, and specialised per biological signal.
This positions RuView between the two incumbents in ambient sensing:
- **Wearables** — high fidelity, but people forget to wear them, and they only measure the wearer.
- **Cameras** — powerful, but invasive, store identifiable video, and fail in the dark / under covers.
RuView sits in the middle: it learns the *space*, learns the *person*, and tracks biological rhythm (breathing, heartbeat, restlessness, posture, presence) without seeing skin or storing video. Heartbeat and breathing are not visual problems — they are tiny, repeating disturbances in the RF field. Capturing them well is a *calibration* problem, not a *model-size* problem.
### 1.2 What already exists (and what is missing)
The pieces of a calibration→training pipeline exist as disconnected modules. There is no system that runs them end to end and emits a per-room model bank.
| Capability | Status today | Gap |
|------------|--------------|-----|
| Empty-room baseline (environmental fingerprint) | ADR-135 `BaselineCalibration` (Proposed): per-subcarrier amplitude + circular-phase stats, `ruvcal` NVS namespace | Captures the *room*, but there is no step that captures *guided human anchors* on top of it |
| Field eigenstructure | ADR-030 `field_model.rs` (SVD room eigenmodes) | Consumes calibration; not wired to a training trigger |
| Shared invariant backbone | ADR-150 RF Foundation Encoder (pose-preserving, subject/room/device-invariant) | Defined as a *foundation* embedding; nothing distills it into per-room specialists |
| Few-shot adaptation | `train/src/rapid_adapt.rs` — test-time training → LoRA weight deltas (MERIDIAN P5) | Produces a *single* pose-adaptation delta, not a bank of per-modality specialists |
| Feature extractors | `ruvsense/{bvp,longitudinal,intention,gesture,pose_tracker,adversarial}.rs`, `train/src/signal_features.rs` | Each emits a signal; none is packaged as a labelled training source for enrollment |
| Small-model storage | `wifi-densepose-ruvector` (RVF cognitive containers, HNSW, sketch) | No schema for "a bank of specialist models scoped to a room_id" |
| HF publishing | ADR-149 AetherArena (Hugging Face Space + signed scorer), `sensing-server` `from_pretrained` path | Publishes/評価s a *global* model; no notion of a published *base* + private *local* heads |
**The missing system is the connective tissue**: a guided enrollment protocol, a feature-extraction-to-label bridge, a specialist-bank trainer that reuses the frozen HF backbone, and a runtime that fuses the specialists with confidence gating. This ADR defines that system.
### 1.3 The four-step user model (and where each step lands)
The system is deliberately presented to operators as four plain steps. Each maps to existing or new code:
1. **Capture a quiet baseline** — no people, just room/router/reflections/noise/drift → the *environmental fingerprint*. → **Reuse ADR-135** `BaselineCalibration` + **ADR-030** field eigenmodes. No new capture code; the calibration crate calls it.
2. **Capture guided samples** — stand, sit, lie down, slow vs normal breathing, small movement, sleep posture. Clean anchors, not hours of data. → **NEW** `EnrollmentProtocol` (Section 2.2).
3. **Extract the useful signal** — CSI phase, amplitude, Doppler shift, micro-motion, periodicity, variance, timing. → **Reuse** `signal_features.rs` + ruvsense extractors, packaged as labelled `AnchorFeature` records (Section 2.3).
4. **Compress patterns into small ruVector models***specialised* per signal: breathing, heartbeat, sleep restlessness, posture, presence, anomaly. → **NEW** `SpecialistBank` trained via `rapid_adapt` LoRA heads over the frozen ADR-150 backbone, stored as RVF (Section 2.4).
---
## 2. Decision
**Build the RuView Per-Room Calibration & Specialized Model Training System: a four-stage, local-first pipeline (`baseline → enroll → extract → train`) that produces a versioned *bank of small specialised ruVector models* scoped to one `room_id`, each a lightweight head distilled/adapted from the frozen, Hugging-Face-published RF Foundation Encoder (ADR-150).** Big model understands the world; small ruVector models understand *your room*.
Two invariants govern every design choice below:
> **(A) Specialisation over scale.** One small model per biological signal, not one large model for all of them. Each specialist is faster, cheaper, more private, and — because it is calibrated to the room's actual fingerprint — often *more accurate* than a general model.
>
> **(B) Local-first, base-shared.** The frozen room/subject/device-invariant backbone is the only artifact published to Hugging Face. Per-room baselines and per-specialist heads never leave the device unless the operator opts into federation (ADR-105).
### 2.1 System architecture
```
HUGGING FACE HUB (public, room-agnostic)
┌───────────────────────────────────────┐
│ RF Foundation Encoder (ADR-150) │
│ pose-preserving · subject/room/device │
│ -invariant · frozen · safetensors │
└───────────────┬───────────────────────┘
│ from_pretrained() once, cached on device
STAGE 1 baseline STAGE 2 enroll STAGE 3 extract STAGE 4 train (per room_id)
┌──────────────┐ ┌──────────────┐ ┌────────────────┐ ┌─────────────────────────┐
│ ADR-135 │ │ Enrollment │ │ signal_features│ │ SpecialistBank │
│ Baseline- │──fp──► │ Protocol │─clip►│ + ruvsense │─AF──►│ frozen backbone │
│ Calibration │ │ guided │ │ extractors │ │ │ ┌────────────────┐ │
│ (env finger- │ │ anchors: │ │ → AnchorFeature│ │ ├─►│ breathing head │ │
│ print) │ │ stand/sit/ │ │ (phase, amp, │ │ ├─►│ heartbeat head │ │
│ ADR-030 │ │ lie/breathe/ │ │ doppler, │ │ ├─►│ restless head │ │
│ field eigen │ │ move/sleep │ │ micromotion, │ │ ├─►│ posture head │ │
└──────────────┘ └──────────────┘ │ periodicity, │ │ ├─►│ presence head │ │
│ │ variance, │ │ └─►│ anomaly head │ │
│ baseline drift > τ → invalidate bank │ timing) │ │ (LoRA / ruVector │
└───────────────────────────────────────┴────────────────┴──────┤ small models) │
└───────────┬─────────────┘
│ RVF container
RUNTIME: Mixture-of-Specialists
each head emits {value, confidence};
coherence_gate (ADR-135) + anomaly
head veto → fused RoomState
```
The shared backbone is loaded **once per device** and frozen. Every specialist is a small head over its embedding — so the marginal cost of a sixth specialist is kilobytes of LoRA weights, not another full model.
### 2.2 Stage 2 — the guided enrollment protocol (NEW)
`EnrollmentProtocol` is a CLI-driven state machine that walks the operator through a fixed sequence of labelled **anchors**. The design rule from the user vision is explicit: *clean anchors, not hours of data.* Each anchor is a short (default 20 s @ 20 Hz = 400 frames) labelled clip captured against the already-recorded baseline.
| Anchor | Label | Duration | Primary signal taught | Feature emphasis |
|--------|-------|----------|-----------------------|------------------|
| `empty` | presence=0 | (reuse ADR-135 baseline) | absence reference | amplitude variance floor |
| `stand_still` | posture=standing, presence=1 | 20 s | static human load | amplitude mean shift, eigenmode delta |
| `sit` | posture=sitting | 20 s | lower static load | amplitude profile |
| `lie_down` | posture=lying | 20 s | sleep-position load | amplitude profile, low Doppler |
| `breathe_slow` | resp≈0.10.15 Hz | 30 s | slow respiration | periodicity, micro-Doppler |
| `breathe_normal` | resp≈0.20.3 Hz | 30 s | normal respiration | periodicity, BVP phase |
| `small_move` | motion=1 | 20 s | limb micro-motion | Doppler spread, variance |
| `sleep_posture` | posture=lying, restless=0 | 30 s | quiescent sleep baseline | long-window variance, timing |
The protocol is **adaptive**: an anchor is only accepted when its captured features pass a quality gate (coherence ≥ threshold from `coherence_gate.rs`, sufficient SNR vs baseline, no saturation). A failed anchor is re-prompted rather than silently kept — bad anchors poison small models far more than large ones. Total guided enrollment is ~4 minutes of wall-clock, producing 8 clean anchors. This is intentionally far below the "hours of data" that a from-scratch model needs, because the backbone already carries world knowledge; enrollment only teaches *this* room's offsets.
Anchors are persisted as an append-only `EnrollmentSession` (event-sourced, per CLAUDE.md state rules) under `room_id`, so re-enrollment is incremental and auditable.
### 2.3 Stage 3 — feature extraction to labelled records (REUSE + bridge)
Each accepted anchor clip is run through the existing extractor stack, baseline-subtracted per ADR-135, and packaged into an `AnchorFeature` record. No new DSP is invented — this stage is a *bridge*, not a new algorithm.
| Feature group | Source module | Used by specialists |
|---------------|---------------|---------------------|
| CSI amplitude mean/variance | ADR-135 baseline subtraction + `signal_features.rs` | presence, posture |
| CSI phase (sanitised, LO-aligned) | `phase_sanitizer``phase_align` | posture, heartbeat |
| Doppler shift / micro-Doppler | `ruvsense/bvp.rs`, `breathing` path | breathing, small-move |
| Micro-motion / intention lead | `ruvsense/intention.rs` | restlessness, anomaly |
| Periodicity / spectral peaks | `bvp.rs` autocorrelation + FFT | breathing, heartbeat |
| Long-window variance / drift | `ruvsense/longitudinal.rs` (Welford) | restlessness, presence |
| Timing / inter-frame epoch | `c6_timesync` epoch, frame Δt | all (rhythm alignment) |
| Field eigenmode coefficients | ADR-030 `field_model.rs` | posture, presence |
`AnchorFeature` = `{ room_id, anchor_label, t_epoch_us, embedding: [f32; D] (backbone output), aux: { resp_hz?, doppler_spread, variance, periodicity_score, eigen_coeffs } }`. The backbone embedding is the *shared* representation; `aux` carries the cheap hand-features that let small heads specialise without re-learning DSP.
### 2.4 Stage 4 — the specialist bank (NEW, the core contribution)
A **`SpecialistBank`** is a versioned collection of small models scoped to one `room_id`, persisted as a single RVF cognitive container (`wifi-densepose-ruvector`). Each specialist is a *head* over the frozen backbone embedding, trained from the labelled `AnchorFeature` records via the existing `rapid_adapt.rs` LoRA machinery (test-time/few-shot training, contrastive + entropy losses), **not** a from-scratch network.
| Specialist | Model type | Params (typ.) | Label source | Output |
|------------|-----------|---------------|--------------|--------|
| **breathing** | 1-D temporal head + periodicity regressor | ~8 KB LoRA + aux | `breathe_slow`/`breathe_normal` | resp rate (Hz) + confidence |
| **heartbeat** | narrowband phase head (harmonic-aware) | ~12 KB | quiescent anchors + periodicity | HR (bpm) + confidence |
| **sleep restlessness** | variance/drift classifier | ~4 KB | `sleep_posture` vs `small_move` | restlessness score [0,1] |
| **posture** | k-way prototype classifier (HNSW NN) | prototypes only | `stand/sit/lie` anchors | posture class + margin |
| **presence** | binary energy/eigenmode gate | ~2 KB | `empty` vs occupied anchors | presence prob |
| **anomaly** | one-class / physically-impossible detector (`adversarial.rs`) | ~6 KB | baseline + all anchors (novelty) | anomaly score + veto flag |
Design properties that follow from invariant (A):
- **Independently versioned & swappable.** Re-enrolling breathing does not retrain posture. A specialist carries its own `{trained_at, anchor_set_hash, baseline_hash, backbone_rev}`.
- **HNSW prototype storage for the classifiers.** Posture and presence are nearest-prototype lookups in the RVF index — no inference engine, microsecond latency, and new postures are added by inserting a prototype, not retraining.
- **SONA online adaptation.** Each specialist may carry a SONA/MicroLoRA online-adaptation slot (`ruvllm_sona_*` / `microlora` primitives) so it tracks slow drift (furniture moved, seasonal RF change) between full re-enrollments, gated by ADR-135 baseline drift.
- **Teacherstudent distillation (optional, offline).** Where a labelled public corpus exists (MM-Fi, Wi-Pose), the ADR-150 backbone acts as teacher to pre-shape a head before per-room fine-tuning, improving cold-start. The *teacher* is global/HF; the *student head* is local.
**Invalidation contract.** The bank stores the `baseline_id` (the baseline UUID) it was trained against. **As implemented**, the runtime marks the bank `STALE` whenever the *current* baseline id differs from the trained one — a conservative trigger that catches re-calibration (room rearranged, AP moved, band changed) because any of those produces a new baseline. A finer **drift-threshold** trigger (mark STALE when ADR-135's per-subcarrier deviation exceeds τ *without* a full re-baseline) is a planned refinement (P6). Either way the runtime prompts re-enrollment rather than emitting silently wrong vitals — the calibration analogue of the #954 `DEGRADED` honesty rule: never report confident numbers from an invalid model.
### 2.5 Runtime — mixture of specialists with confidence gating
At inference, the frozen backbone embeds each CSI window once; every specialist consumes that shared embedding and emits `{value, confidence}`. Fusion rules:
- The **anomaly** specialist holds a **veto**: a high anomaly score (physically-impossible signal per `adversarial.rs`, or a coherence-gate `Reject`) suppresses positive vitals/posture output and raises a flag, rather than propagating a hallucinated reading.
- **presence=0** short-circuits breathing/heartbeat/posture to `null` (you cannot have a respiration rate in an empty room).
- Each emitted reading is tagged with the specialist's confidence and the `baseline_hash`/`backbone_rev` provenance, so downstream consumers (sensing-server, MQTT, Home Assistant) can gate on quality — consistent with ADR-135 coherence-gate semantics.
### 2.6 Crate & module layout
New bounded-context crate `wifi-densepose-calibration` (orchestration only; files < 500 lines, typed public APIs, event-sourced sessions — per CLAUDE.md):
```
wifi-densepose-calibration/
src/
lib.rs # public API: CalibrationSystem facade
enrollment.rs # EnrollmentProtocol state machine (Stage 2)
anchor.rs # Anchor, EnrollmentSession (event-sourced)
extract.rs # AnchorFeature bridge over signal_features + ruvsense (Stage 3)
specialist.rs # Specialist trait, SpecialistKind enum
bank.rs # SpecialistBank (RVF container, versioning, invalidation)
runtime.rs # MixtureOfSpecialists fusion + veto (Stage 5)
backbone.rs # frozen ADR-150 encoder loader (hf_hub from_pretrained, cached)
error.rs
```
Dependencies (no duplication — orchestrates existing crates): `wifi-densepose-signal` (ruvsense extractors, ADR-135 baseline), `wifi-densepose-train` (`rapid_adapt`, `signal_features`, `trainer`), `wifi-densepose-ruvector` (RVF, HNSW), `wifi-densepose-nn` (backbone inference). The `wifi-densepose-cli` gains `enroll`, `train-room`, and `room-status` subcommands, sequenced after the existing ADR-135 `calibrate`.
### 2.7 CLI flow (operator-facing)
```bash
# Stage 1 — environmental fingerprint (ADR-135, existing)
wifi-densepose calibrate --room living-room --duration 60s # empty room
# Stage 2+3 — guided enrollment (NEW); prompts through 8 anchors, ~4 min
wifi-densepose enroll --room living-room
# → "Stand still in view of the sensor…" [✓ anchor accepted: coherence 0.91]
# → "Sit down…" [✗ low SNR, retrying]
# ...
# Stage 4 — train the specialist bank (NEW); reuses cached HF backbone
wifi-densepose train-room --room living-room \
--specialists breathing,heartbeat,restlessness,posture,presence,anomaly
# Status / invalidation
wifi-densepose room-status --room living-room
# baseline: fresh (drift 0.04 < 0.20) · backbone: rf-foundation@1.2.0
# breathing ✓ trained 2026-06-09 conf p50 0.88
# heartbeat ✓ trained 2026-06-09 conf p50 0.71
# posture ✓ 3 prototypes (stand/sit/lie)
# anomaly ✓ · presence ✓ · restlessness ✓
```
---
## 3. Consequences
### 3.1 Positive
- **Fidelity through specialisation.** Six small calibrated heads beat one oversized general model on the cross-room/cross-subject frontier that ADR-150 quantified — and each runs in microseconds-to-milliseconds, on-device.
- **Privacy by construction.** Only the room-agnostic backbone is public (HF). The environmental fingerprint and the person-specific heads stay local; no video, no skin, no cloud round-trip. This is the core differentiator vs cameras and the convenience differentiator vs wearables.
- **Minutes, not hours.** Because the backbone carries world knowledge, ~4 minutes of clean anchors calibrates a room. Re-enrollment is incremental.
- **Honest degradation.** The `baseline_hash` invalidation + anomaly veto mean an out-of-calibration room reports `STALE`/flagged rather than confidently wrong — the same honesty principle as the firmware `DEGRADED` flag.
- **Composable & cheap to extend.** A new biological signal = a new small head over the same embedding, not a new model.
### 3.2 Negative / risks
- **Backbone dependency.** Every specialist rides on ADR-150's encoder; its quality and revision compatibility (`backbone_rev`) are a single point of leverage. Mitigation: pin `backbone_rev` in each specialist; distillation cold-start reduces sensitivity.
- **Enrollment burden.** 4 minutes is small but non-zero, and anchor quality depends on the operator following prompts. Mitigation: adaptive re-prompting + quality gates; ship sane defaults so a partial bank (presence+posture) works after just the static anchors.
- **Heartbeat is hard.** Sub-mm chest displacement at HR frequencies is near the ESP32-S3 noise floor; the heartbeat specialist will have lower and more variable confidence than breathing. The confidence-gated runtime surfaces this rather than faking it.
- **Per-room storage proliferation.** A bank per room per person; needs a clear RVF lifecycle (list/prune/export) — handled by `bank.rs` versioning and the `room-status` CLI.
### 3.3 Alternatives considered
| Alternative | Verdict | Reason |
|-------------|---------|--------|
| One large general model for all signals | **Rejected** | The ADR-150 evidence: scale overfits rooms/subjects and collapses cross-domain; also slower, costlier, less private. Directly contradicts invariant (A). |
| Cloud training of per-room models | **Rejected** | Violates invariant (B): would ship raw CSI of a person's home/sleep to a server. Local-first is the privacy promise. Federation (ADR-105) is the *opt-in* path for shared improvement, exchanging gradients/deltas, never raw CSI. |
| Skip the backbone; train each specialist from scratch | **Rejected** | Reintroduces the "hours of data" requirement the user vision explicitly rejects, and loses cross-room priors. |
| Fold this into ADR-135 | **Rejected** | ADR-135 is *room* calibration (no humans). This ADR is *human-anchor* enrollment + model training on top of it. Distinct lifecycles, distinct invalidation; kept as separate bounded contexts. |
---
## 4. Implementation phases
| Phase | Scope | Exit criterion | Status |
|-------|-------|----------------|--------|
| **P1** | Scaffold `wifi-densepose-calibration` crate; `AnchorFeature` schema; (backbone via `hf_hub` deferred) | Crate + schema; unit tests | ✅ Done (crate + Stage-1 baseline via `calibrate`/`calibrate-serve`; HF backbone deferred) |
| **P2** | `EnrollmentProtocol` + `anchor.rs` (event-sourced sessions) + CLI `enroll` with quality gates | 8-anchor enrollment; bad anchors re-prompt | ✅ Done (`anchor.rs`, `enrollment.rs`, CLI `enroll`) |
| **P3** | `extract.rs` bridge → labelled records; baseline subtraction (ADR-135) | `AnchorFeature` records persisted per `room_id` | ✅ Done (`extract.rs`; autocorr periodicity + variance/motion) |
| **P4** | `SpecialistBank` + presence/posture (prototype) + breathing (periodicity); persistence + versioning | `train-room` produces a bank; `room-status` reads it back | ✅ Done (`specialist.rs`, `bank.rs`, CLI `train-room`/`room-status`; JSON persistence — RVF/HNSW = future) |
| **P5** | heartbeat + restlessness + anomaly specialists; `runtime.rs` mixture + veto + confidence gating | End-to-end RoomState on hardware; anomaly veto verified | ✅ Done (`runtime.rs`, CLI `room-watch`; breathing read live on COM8 ESP32) |
| **P6** | Baseline-drift `STALE` invalidation; SONA online adaptation; optional ADR-105 federation; HF teacherstudent distillation | Drift marks bank STALE; AetherArena entry | ◐ Partial (STALE done; SONA/federation/HF-backbone = follow-ups) |
**Current status (2026-06-10):** Stages 15 implemented with *statistical* specialists (threshold/prototype/autocorrelation). 55 tests (35 unit incl. multistatic + 1 full-loop integration + 19 CLI), all passing under qemu-aarch64. **Validation scope is precise:** baseline capture + HTTP API + auth are proven on real CSI (Pi-5 nexmon, 6,813 frames; and an ESP32-S3). The complete `baseline → enroll → train-room → infer` loop is now **proven in-process** on deterministic synthetic CSI (`tests/full_loop.rs`: clean baseline with zero motion flags, 8/8 anchors through the quality gate, 6 specialists trained, JSON bank round-trip, trained-bank inference 18±2 BPM positive / absent negative / foreign-baseline STALE; seed-robust). The one live runtime signal (breathing ~1631 BPM via `room-watch`) used the *stateless* breathing head, **not** a trained bank; the clean empty-room loop has **not** yet run on-target — the remaining gap is strictly the hardware session (empty room + operator anchors). The four behavioral findings from the full-loop test (z-band squeeze, variance-only presence, ungated hz embedding, heart-band lag-floor leakage) are FIXED and regression-guarded — see the integration doc §7. SOTA-intake decisions affecting this system (geometry conditioning, checkerboard alignment) are recorded in ADR-152. Open refinements: `--source-format adr018v6` (drive from the Pi's own nexmon), phase-based breathing carrier, RVF/HNSW storage, and the ADR-150 frozen HF backbone the specialists would distill from.
Validation per CLAUDE.md: `cargo test --workspace --no-default-features` green; hardware verification on the ESP32-S3 (currently COM8) before any release; witness bundle regenerated if the proof surface changes.
---
## 5. Summary
> Big models understand the world. Small ruVector models understand *your room*.
ADR-151 makes that operational: a local-first `baseline → enroll → extract → train` pipeline that turns ~4 minutes of clean human anchors — layered on ADR-135's empty-room fingerprint and ADR-150's Hugging-Face-published invariant backbone — into a versioned bank of tiny, specialised, privacy-preserving models for breathing, heartbeat, restlessness, posture, presence, and anomaly. Specialisation over scale; local heads over a shared base; honest `STALE` degradation over confident error.
@@ -1,98 +0,0 @@
# ADR-152: WiFi-Pose SOTA 2026 Intake — Geometry-Conditioned Calibration, External Benchmarks, and the Foundation-Encoder Training Recipe
| Field | Value |
|-------|-------|
| **Status** | Proposed |
| **Date** | 2026-06-10 |
| **Deciders** | ruv |
| **Codebase target** | `wifi-densepose-calibration` (geometry conditioning, ADR-151 Stage 2), `wifi-densepose-train` (camera-supervised path, MAE recipe), `wifi-densepose-cli` (benchmark harness), docs |
| **Relates to** | ADR-151 (Per-Room Calibration), ADR-150 (RF Foundation Encoder), ADR-135 (Empty-Room Baseline), ADR-079 (Camera-Supervised Pose), ADR-027 (MERIDIAN), ADR-024 (AETHER), ADR-149 (AetherArena), ADR-029 (Multistatic) |
| **Research provenance** | Deep-research run 2026-06-10: 22 sources fetched, 110 claims extracted, 25 adversarially verified (3-vote), 24 confirmed / 1 refuted. Evidence grades per source below. |
---
## 1. Context
A structured survey of the 20252026 WiFi human-sensing state of the art was run on 2026-06-10 to answer: *what should RuView integrate next, and does anything published invalidate our current direction?* Every claim below was verified against the primary source by independent adversarial reviewers; **evidence grades distinguish what the papers measured from what they merely claim**. Almost all performance numbers are author-self-reported preprint results — treated here as CLAIMED until reproduced on our hardware.
### 1.1 The five verified findings
**(F1) "Coordinate overfitting" is a named, diagnosed failure mode of camera-supervised WiFi pose — and our ADR-079 pipeline has the exact shape of it.**
PerceptAlign (arXiv [2601.12252](https://arxiv.org/abs/2601.12252), accepted ACM MobiCom 2026) shows that models regressing CSI directly to camera-frame coordinates memorize the deployment-specific transceiver layout; SOTA baselines degrade to >600 mm MPJPE in unseen scenes. Their fix is cheap: a <5-minute calibration using two checkerboards and a few photos to align WiFi and vision in one shared 3D frame, plus **fusing transceiver-position embeddings with CSI features**. Claimed: 12.3% in-domain error, 60%+ cross-domain error. They release the claimed-largest cross-domain 3D WiFi pose dataset (21 subjects, 5 scenes, 18 actions, **7 device layouts**). *Evidence: improvements CLAIMED (preprint w/ MobiCom acceptance); the failure mode itself is corroborated across the cross-domain literature — and independently by our own ADR-150 data (81.63% in-domain vs ~11.6% leakage-free cross-subject torso-PCK).*
**(F2) An external model named "WiFlow" claims 97.25% PCK@20 with 2.23M params and ships everything.**
arXiv [2602.08661](https://arxiv.org/abs/2602.08661) (Apr 2026) — spatio-temporal-decoupled CSI pose, 97.25% PCK@20 / 99.48% PCK@50 / 0.007 m MPJPE, 2.23M parameters (~2.2 MB int8). Code, pretrained weights, and a 360k-sample CSI-pose dataset are public under Apache-2.0 ([repo](https://github.com/DY2434/WiFlow-WiFi-Pose-Estimation-with-Spatio-Temporal-Decoupling), Kaggle dataset). *Evidence: artifact availability MEASURED (verified by direct repo inspection); PCK numbers CLAIMED (5-subject, in-domain, self-collected dataset; hardware unspecified; 15 keypoints vs our 17).* ⚠️ **Name collision:** this is unrelated to RuView's internal WiFlow model. In all RuView docs the external model is referred to as **WiFlow-STD (DY2434)**.
**(F3) For CSI foundation encoders, data scale — not model capacity — is the bottleneck, and the tokenization recipe is now known.**
UNSW's MAE pretraining study (arXiv [2511.18792](https://arxiv.org/abs/2511.18792), Nov 2025) — the largest heterogeneous CSI pretraining run to date (1,320,892 samples, 14 public datasets incl. MM-Fi, Widar 3.0, Person-in-WiFi 3D; 4 devices; 2.4/5/6 GHz; 20160 MHz) — reports zero-shot cross-domain gains of 2.215.7% over supervised baselines, with unseen-domain performance scaling **log-linearly with pretraining data, unsaturated at 1.3M samples**, while ViT-Base adds only 0.40.9% over ViT-Small. Optimal recipe: **80% masking ratio, small (30,3) patches** (+4.7% over (40,5) by preserving fine temporal dynamics). *Evidence: MEASURED within-study (ablations verified in body text) but preprint; downstream tasks are classification, NOT pose — pose transfer is a hypothesis. Independently corroborates ADR-150's finding that capacity hurts cross-subject.*
**(F4) Hardware/standards: 802.11bf is finished; Espressif ships official sensing; Wi-Fi 6 AP CSI is reachable.**
- **IEEE 802.11bf-2025** published **2025-09-26** (verified against the IEEE SA record) — sensing standardization is complete for both sub-7 GHz and >45 GHz, with formal sensing setup/feedback procedures. No ESP32 silicon implements it yet. *Evidence: MEASURED (standards-body record).*
- **Espressif `esp_wifi_sensing`** (Apache-2.0, v0.1.x, ESP Component Registry): official CSI presence/motion FSM; esp-csi actively maintained (commit 2026-04-22, verified), CSI confirmed across ESP32/S2/C3/S3/C5/C6/C61. *Evidence: MEASURED (vendor pages + commit log).* ⚠️ A stronger "drop-in compatible with RuView nodes" claim was **REFUTED 0-3** — WiFi-6 parts use a different CSI acquisition config struct.
- **ZTECSITool** (arXiv [2506.16957](https://arxiv.org/abs/2506.16957), [code](https://github.com/WiFiZTE2025/ZTE_WiFi_Sensing)): CSI from commercial Wi-Fi 6 APs at up to 160 MHz / 512 subcarriers (~510× ESP32 subcarrier count; the gain is aperture, not per-Hz granularity). Firmware is gated behind a ZTE serial-number approval. *Evidence: capability CLAIMED by the vendor-authored tool paper; code artifact MEASURED.*
**(F5) Nothing in 20252026 does full DensePose UV regression from commodity WiFi.** Keypoint pose remains the field's frontier. Three "wireless foundation model" papers were screened out by full-text inspection (HeterCSI = simulated cellular channels only; the NeurIPS-2025 FMCW pilot = mmWave radar, presence-only; arXiv 2509.15258 = survey, no artifacts). *Evidence: MEASURED (absence verified by full-text inspection of the candidates that surfaced; absence of evidence across the whole literature is necessarily weaker).*
### 1.2 What this means for the ADR-151 calibration system
ADR-151's enrollment protocol captures guided human anchors but does **not** record or condition on transceiver geometry. F1 says that omission is precisely the thing that makes camera-supervised (and, plausibly, anchor-supervised) heads layout-brittle. ADR-151's per-room thesis ("teach the room before you teach the model") is *strengthened* by F1 — PerceptAlign is independent evidence that layout must be modeled explicitly — and the fix composes naturally with our Stage-2 enrollment.
ADR-150's masked-CSI-encoder design is *validated* by F3, which also hands us the hyperparameters and the priority call: **collect/aggregate more heterogeneous CSI before scaling the encoder.**
## 2. Decision
Adopt four changes, ordered by effort-vs-gain:
### 2.1 Geometry-condition the calibration system (extends ADR-151 Stage 2) — ACCEPTED
1. **Record transceiver geometry at enrollment.** `EnrollmentProtocol` gains an optional `NodeGeometry` record per node (position estimate, antenna orientation, inter-node distances where known). Stored alongside the room baseline in the bank; schema-versioned so existing banks remain readable.
2. **Fuse geometry embeddings into specialist training.** Where a specialist head consumes the (future, ADR-150) backbone embedding, concatenate a small learned embedding of `NodeGeometry` — the PerceptAlign mechanism, transplanted to our per-room banks. Statistical specialists (current) ignore it; LoRA heads (ADR-151 P6) consume it.
3. **Adopt the two-checkerboard alignment for the camera-supervised path (ADR-079).** When MediaPipe supervision is used, calibrate camera↔WiFi into one shared 3D frame before regression (<5 min, two checkerboards, a few photos). This is the direct defense against F1 for our 92.9%-PCK@20 pipeline.
4. **Evaluate on the PerceptAlign cross-domain dataset** (21 subjects / 7 layouts) as the MERIDIAN cross-layout benchmark — *gated on confirming its license and downloadability* (open question; repo per paper: github.com/Trymore-lab/PerceptAlign).
### 2.2 Benchmark against WiFlow-STD (DY2434) — ACCEPTED
Pull the Apache-2.0 weights + 360k-sample dataset; run three measurements: (a) their model on their data (reproduce 97.25% claim), (b) their model fine-tuned on our ESP32 17-keypoint eval set, (c) our internal WiFlow on their dataset (15-keypoint subset mapping). Until (a)(c) are measured, **no RuView doc may cite 97.25% as a comparable number** — different dataset, subjects, keypoints.
### 2.3 Apply the UNSW recipe to the ADR-150 encoder — ACCEPTED (amends ADR-150 §2.3)
- Pretraining corpus: start from the same 14 public datasets (1.3M samples) + our home/MM-Fi frames; data aggregation takes priority over architecture work.
- Tokenization: 80% masking, (30,3)-class small patches; encoder stays ViT-Small-class (~15M params) — F3 and our own DANN/transformer results agree that capacity does not pay.
- The published log-linear scaling (unsaturated) sets the expectation: more heterogeneous CSI in, better zero-shot out.
### 2.4 Hardware watch items — ACCEPTED (no code now)
- **802.11bf**: track silicon/certification; revisit when any commodity chipset exposes standardized sensing measurements. Our opportunistic CSI extraction remains the mechanism until then.
- **esp_wifi_sensing**: benchmark our presence pipeline against the vendor FSM (one afternoon; useful external baseline). Do **not** treat as drop-in (refuted claim).
- **ZTECSITool AP**: optional high-resolution anchor node for the ADR-029 multistatic mesh — procurement-gated; only pursue if a 160 MHz anchor materially helps tomography.
### 2.5 Explicitly NOT adopted
- No pivot toward "wireless foundation model" papers that don't ship WiFi-CSI artifacts (HeterCSI, FMCW pilot, surveys).
- No DensePose-UV work item: the field has not demonstrated UV regression from commodity WiFi; keypoints remain our supervised target (F5).
## 3. Consequences
**Positive:** the calibration system gains the one mechanism (geometry conditioning) the 2026 literature identifies as the difference between layout-brittle and layout-robust supervised WiFi pose; ADR-150 gets a measured training recipe instead of a guessed one; we acquire two external benchmarks (WiFlow-STD, PerceptAlign dataset) to keep our claims honest.
**Negative / risks:** geometry records add schema surface to banks (mitigated: optional + versioned); every adopted number is preprint-grade until our own benchmark runs land (mitigated by §2.2's no-citation rule); PerceptAlign dataset license is unconfirmed (gated); name collision risk in docs (mitigated: "WiFlow-STD (DY2434)" naming rule).
**Re-check by 2026-12:** 802.11bf silicon, esp_wifi_sensing maturity (v0.1.x today), and the preprint field (newest source Apr 2026).
## 4. Open questions (carried from the research run)
1. Does WiFlow-STD retain accuracy when fine-tuned on ESP32-S3/C6 CSI (fewer subcarriers, lower SNR), scored on our 17-keypoint set? (§2.2 answers this.)
2. Is the PerceptAlign dataset downloadable under a usable license, and does the two-checkerboard procedure work with ESP32 transceiver geometry? (§2.1.4 gate.)
3. Will esp_wifi_sensing evolve toward 802.11bf compliance, replacing opportunistic CSI extraction?
## 5. Source register (evidence-graded)
| Source | Type | Used for | Grade |
|---|---|---|---|
| arXiv 2601.12252 (PerceptAlign, MobiCom'26) | preprint+acceptance | F1, §2.1 | CLAIMED numbers; failure mode corroborated |
| arXiv 2602.08661 + DY2434 repo (WiFlow-STD) | preprint + code | F2, §2.2 | numbers CLAIMED; artifacts MEASURED |
| arXiv 2511.18792 (UNSW MAE) | preprint | F3, §2.3 | ablations MEASURED in-study; pose transfer hypothesis |
| IEEE SA 802.11bf-2025 record | standards body | F4, §2.4 | MEASURED |
| Espressif component registry + esp-csi repo | vendor | F4, §2.4 | MEASURED; "drop-in" REFUTED 0-3 |
| arXiv 2506.16957 + ZTE repo (ZTECSITool) | vendor preprint + code | F4, §2.4 | capability CLAIMED; code MEASURED |
| arXiv 2601.18200 (HeterCSI), OpenReview LMufK3vzE5 (FMCW pilot), arXiv 2509.15258 (survey) | preprints | F5, §2.5 (screened out) | MEASURED (full-text inspection) |
-6
View File
@@ -79,10 +79,6 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
| [ADR-023](ADR-023-trained-densepose-model-ruvector-pipeline.md) | Trained DensePose Model with RuVector Pipeline | Proposed |
| [ADR-024](ADR-024-contrastive-csi-embedding-model.md) | Project AETHER: Contrastive CSI Embeddings | Required |
| [ADR-027](ADR-027-cross-environment-domain-generalization.md) | Project MERIDIAN: Cross-Environment Generalization | Proposed |
| [ADR-149](ADR-149-public-community-leaderboard-huggingface.md) | AetherArena: public spatial-intelligence benchmark on Hugging Face | Proposed |
| [ADR-150](ADR-150-rf-foundation-encoder.md) | RF Foundation Encoder: pose-preserving, subject/room/device-invariant CSI embedding | Proposed |
| [ADR-151](ADR-151-room-calibration-specialist-training.md) | Per-Room Calibration & Specialized Model Training (room-first → bank of small ruVector specialists) | Proposed |
| [ADR-152](ADR-152-wifi-pose-sota-2026-intake.md) | WiFi-Pose SOTA 2026 Intake: geometry-conditioned calibration, external benchmarks, foundation-encoder recipe | Proposed |
### Platform and UI
@@ -97,8 +93,6 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
| [ADR-036](ADR-036-rvf-training-pipeline-ui.md) | Training Pipeline UI Integration | Proposed |
| [ADR-043](ADR-043-sensing-server-ui-api-completion.md) | Sensing Server UI API Completion (14 endpoints) | Accepted |
| [ADR-115](ADR-115-home-assistant-integration.md) | Home Assistant integration via MQTT auto-discovery + Matter bridge (HA-DISCO + HA-FABRIC + HA-MIND) | Accepted (MQTT track) / Proposed (Matter SDK P8b) |
| [ADR-147](ADR-147-adam-mode-light-theme.md) | adam-mode — light theme toggle for the three.js realtime demo | Proposed |
| [ADR-148](ADR-148-yoga-mode-pose-system.md) | yoga-mode — yoga pose detection, classification, and scoring for the three.js realtime demo | Proposed |
### Architecture and infrastructure
@@ -1,234 +0,0 @@
# Per-Room Calibration — Integration Overview (for `cognitum-one/v0-appliance`)
**Audience:** integrators wiring the RuView per-room calibration system (ADR-151) into the
Cognitum V0 appliance (`cognitum-v0`, Pi 5 + Hailo). This document is the contract +
deployment spec: data formats, API surface, crate API, and the appliance integration plan.
**Source of truth:** crate `v2/crates/wifi-densepose-calibration` + CLI `v2/crates/wifi-densepose-cli`
(`calibrate`, `calibrate-serve`, `enroll`, `train-room`, `room-status`, `room-watch`) on this PR's branch.
---
## 1. What it is
"Teach the room before you teach the model." A local-first pipeline that turns a few minutes of
clean human anchors — layered on an empty-room baseline — into a versioned **bank of small,
room-calibrated specialists** for presence, posture, breathing, heartbeat, restlessness, and anomaly.
```
baseline (ADR-135) → enroll (anchors + quality gate) → extract (features) → train (specialist bank) → runtime (mixture + veto)
environmental stand/sit/lie/breathe/move periodicity/variance 6 small models RoomState per window
fingerprint (re-prompts bad captures) + STALE invalidation (+ multistatic fusion)
```
**Design invariants (carry these into the appliance):**
- **Specialisation over scale** — six tiny models (threshold / nearest-prototype / autocorrelation), not one big model. They run in microseconds on a Pi CPU; **they do not need the Hailo HAT**.
- **Local-first** — baselines + per-room banks stay on the device. Cross-room sharing is *model deltas* (federation, ADR-105), **never raw CSI**.
- **Honest degradation** — baseline drift marks a bank `STALE`; a physically-implausible window is vetoed rather than emitting a hallucinated reading.
---
## 2. Tiering on the Pi 5 + Hailo (what runs where)
| Tier | Runs on | What | Status |
|------|---------|------|--------|
| **CSI source** | ESP32-S3/C6 nodes (`edge_tier=0` raw CSI) | `0xC5110001` frames over UDP | shipping (v0.7.1-esp32) |
| **Calibration service** | **Pi 5 CPU** (aarch64) | this crate: baseline/enroll/train/runtime + HTTP API | **this PR** |
| **Shared backbone (optional)** | **Hailo HAT (HAILO10H)** | ADR-150 RF Foundation Encoder + neural pose head as HEF | future (ADR-150) |
> The appliance's WiFi (`wlan0`) is `managed` with no nexmon — **the Pi is a CSI *processor*, not a CSI radio.** CSI arrives from the ESP32 nodes (the existing `ruview-vitals-worker:50054` already receives it). Calibration *consumes* that stream; it does not sense directly.
---
## 3. Data contracts (the integration surface)
### 3.1 CSI ingest — ESP32 `0xC5110001` (UDP, little-endian)
```
Offset Size Field
0 4 magic = 0xC511_0001 (LE u32)
4 1 node_id (u8) ← group multistatic nodes by this
5 1 n_antennas (u8)
6 1 n_subcarriers (u8) ← 52/64 (HT20), 114 (HT40), 242 (HE20)
7 1 reserved
8 2 freq_mhz (LE u16)
10 4 sequence (LE u32)
14 1 rssi (i8)
15 1 noise_floor (i8)
16 4 reserved
20 2·n_antennas·n_subcarriers IQ pairs: i (i8), q (i8)
```
Parser reference: `wifi-densepose-cli/src/calibrate.rs::parse_csi_packet`. The appliance can reuse the
ESP32 stream the vitals worker already receives, or tee it to the calibration UDP port.
### 3.2 Baseline (ADR-135) — binary, magic `0xCA1B_0001`
```
Header (16 B LE): magic(4)=0xCA1B0001, version(1)=1, tier(1) {0=HT20,1=HT40,2=HE20,3=HE40},
reserved(2), captured_at_unix_s(8, i64)
Body: frame_count(8,u64), num_subcarriers(4,u32),
per subcarrier: amp_mean(f32), amp_variance(f32), phase_mean(f32), phase_dispersion(f32)
```
Produced by `calibrate` / `calibrate-serve`; `BaselineCalibration::{to_bytes,from_bytes}`. A baseline's
UUID (`calibration_uuid()`) is the `baseline_id` referenced by enrollments and banks for STALE checks.
### 3.3 Enrollment output — JSON (`enroll` → `train-room`)
```jsonc
{
"room_id": "living-room",
"baseline_id": "<uuid>",
"fs_hz": 15.0,
"anchors": [
{ "room_id": "living-room", "label": "stand_still",
"features": { "mean": f32, "variance": f32, "motion": f32,
"breathing_score": f32, "breathing_hz": f32,
"heart_score": f32, "heart_hz": f32 } }
],
"session": { "room_id": "...", "baseline_id": "...", "events": [ /* event-sourced audit log */ ] }
}
```
Anchor labels (fixed sequence, **JSON wire = snake_case**, test-enforced): `empty, stand_still, sit, lie_down, breathe_slow, breathe_normal, small_move, sleep_posture`.
### 3.4 Specialist bank — JSON (`train-room` → `room-watch` / runtime)
```jsonc
{
"room_id": "living-room",
"baseline_id": "<uuid>", // drift vs current → STALE
"trained_at_unix_s": 0,
"anchor_count": 6,
"presence": { "threshold": f32, "occupied_var": f32 } | null,
"posture": { "prototypes": [ ["Standing", [f32;5]], ... ] } | null,
"breathing": { "min_score": f32 },
"heartbeat": { "min_score": f32 },
"restlessness": { "calm_motion": f32, "active_motion": f32 } | null,
"anomaly": { "prototypes": [ [f32;5], ... ], "scale": f32 } | null
}
```
`SpecialistBank::{to_json,from_json}`. A *partial* bank is valid (missing-anchor specialists are `null`).
### 3.5 Runtime output — `RoomState` JSON (per window)
```jsonc
{
"presence": { "kind":"Presence", "value":0|1, "confidence":f32, "label":"present|absent" } | null,
"posture": { "kind":"Posture", "value":f32, "confidence":f32, "label":"standing|sitting|lying" } | null,
"breathing": { "kind":"Breathing", "value": <BPM>, "confidence":f32, "label":null } | null,
"heartbeat": { "kind":"Heartbeat", "value": <BPM>, "confidence":f32, "label":null } | null,
"restlessness": { "kind":"Restlessness", "value": 0.0..1.0, "confidence":f32 } | null,
"anomaly": { "kind":"Anomaly", "value": 0.0..1.0, "confidence":f32, "label":"normal|anomalous" } | null,
"vetoed": bool, // anomaly veto fired → vitals/posture suppressed
"stale": bool // bank trained against a different baseline
}
```
---
## 4. HTTP API — `calibrate-serve` (CORS-enabled; this is what a UI/appliance drives)
| Method | Path | Body / returns |
|--------|------|----------------|
| GET | `/api/v1/calibration/health` | `{ udp_port, frames_seen, last_frame_age_ms, streaming, default_tier, output_dir, session_active }` |
| POST | `/api/v1/calibration/start` | `{ tier?, duration_s?, room_id?, min_frames? }``202` session snapshot |
| GET | `/api/v1/calibration/status` | live `{ state, frames_recorded, target_frames, progress, z_median, eta_s, ... }` |
| POST | `/api/v1/calibration/stop` | finalize early → result summary |
| GET | `/api/v1/calibration/result` | last finalized baseline summary |
| GET | `/api/v1/calibration/baselines` | list persisted `.bin` baselines |
| GET | `/api/v1/room/state?bank=<name>` | **live RoomState** (mixture-of-specialists over the CSI window; bank resolved as a sanitized name under `output_dir`) |
| POST | `/api/v1/room/train` | `{ room_id, baseline_id, anchors[]? }` → train + persist a specialist bank as `<output_dir>/<room_id>.json` (anchors[] optional if enrolled via `/enroll/anchor`; read back via `/room/state?bank=<room_id>`) |
| POST | `/api/v1/enroll/anchor` | `{ room_id, baseline, label, duration_s? }` → capture one guided anchor against a baseline (blocks for the capture); returns the gate verdict + progress |
| GET | `/api/v1/enroll/status?room=<id>` | enrollment progress (accepted anchors, next, complete) |
A single background task owns the UDP socket + recorder (handlers talk to it over an mpsc channel +
shared status snapshot), so the API is non-blocking. **The full pipeline is now drivable over HTTP** — baseline (`start`/`stop`) → `enroll/anchor` (×8) → `room/train``room/state` — so the appliance UI needs no CLI. (The CLI `enroll`/`train-room`/`room-watch` remain for scripted/headless use.)
---
## 5. Public crate API (`wifi-densepose-calibration`)
```rust
// Stage 2 — enrollment
anchor::{AnchorLabel, Anchor, AnchorQuality, EnrollmentEvent, EnrollmentSession, Posture}
enrollment::{AnchorQualityGate, AnchorRecorder}
// Stage 3 — features
extract::{Features, AnchorFeature, autocorr_dominant}
// Stage 4 — specialists + bank
specialist::{Specialist, SpecialistKind, SpecialistReading,
PresenceSpecialist, PostureSpecialist, BreathingSpecialist,
HeartbeatSpecialist, RestlessnessSpecialist, AnomalySpecialist}
bank::SpecialistBank
// Stage 5 — runtime
runtime::{MixtureOfSpecialists, RoomState}
multistatic::MultiNodeMixture // fuse co-located nodes (ADR-029)
```
Pure Rust; deps are `wifi-densepose-core` + `wifi-densepose-signal` (default-features off) + serde/uuid.
**No GPU / no system BLAS** in the calibration path → builds cleanly on aarch64.
---
## 6. Appliance integration plan (`cognitum-one/v0-appliance`)
Verified on `cognitum-v0`: aarch64, `cargo 1.96.0`, Hailo `HAILO10H`, `ruview-vitals-worker:50054`.
**Step 1 — vendor / depend on the crate.** Add `wifi-densepose-calibration` (path or published crate)
to the appliance workspace. It builds natively on aarch64 — no BLAS/GPU, **and no ONNX/OpenSSL**:
the CLI's `mat``nn``ort`(ONNX)→`openssl-sys` chain is now feature-gated out of the calibration build.
```bash
# Pi/appliance calibration binary — cross-compiles clean (no ort/openssl):
cargo build -p wifi-densepose-cli --no-default-features --release
# (omit `--no-default-features` only if you also need the MAT subcommands)
```
Verified: `cargo tree -p wifi-densepose-cli --no-default-features` shows **0** `ort`/`openssl-sys` deps;
`cross test --target aarch64-unknown-linux-gnu` passes the calibration suite under qemu.
**Step 2 — wire the CSI source.** Two options:
- (a) Tee the ESP32 UDP stream the vitals worker already receives into the calibration ingest, or
- (b) point ESP32 nodes (`edge_tier=0`) at the appliance's calibration UDP port directly.
Reuse `parse_csi_packet` (or the rvCSI `CsiFrame` schema if you normalise upstream).
**Step 3 — run the calibration service.** Either embed the crate (call `CalibrationRecorder` /
`MixtureOfSpecialists` in-process from a worker like `ruview-vitals-worker`), or run the
`calibrate-serve` binary as a sidecar (systemd unit, bind `127.0.0.1` + reverse-proxy through the
appliance gateway on `:9000`). Persist baselines/banks under the appliance data dir, keyed by `room_id`.
**Step 4 — expose to the dashboard.** Surface the `/api/v1/calibration/*` endpoints (and add
`enroll`/`train`/`room-state` endpoints — small additive work) behind the appliance's bearer-token
auth + the existing `Seeds`/`Edge` nav. `RoomState` (§3.5) is the live readout payload.
**Step 5 — (optional) Hailo backbone tier.** Compile the ADR-150 RF Foundation Encoder + neural pose
head to Hailo HEF, serve via `ruvector-hailo-worker:50051`; the small specialists become heads over its
embedding. This is the ADR-150 follow-on — *not required* for the calibration service to run.
**Privacy / security:** keep baselines + banks local; if federating across appliances (ADR-105),
exchange bank/model deltas, never raw CSI. Hardening already in place:
- **`--token <T>`** (or `CALIBRATE_TOKEN` env) requires `Authorization: Bearer <T>` on every route; the
server warns loudly if bound to a non-loopback address without a token.
- **`room_id` is sanitized** to `[A-Za-z0-9_-]` (≤64 chars) before it touches the baseline write path —
no `../` / absolute-path traversal.
- CORS is permissive for dev — in production bind to loopback and reverse-proxy through the appliance
gateway (which already enforces bearer auth).
---
## 7. Status & validation
- **Implemented:** all 5 stages + multistatic fusion; CLI + Stage-1 HTTP API (auth + path-traversal hardened). **55 tests** (35 calibration unit + 1 full-loop integration + 19 CLI), all passing under qemu-aarch64.
**Precise validation matrix (don't overstate this — no clean full calibration has run on-target yet):**
| Stage | Pi-5 (real nexmon→`0xC5110001`, 6,813 frames) | ESP32-S3 (COM8, `edge_tier=0`) | qemu / unit / integration |
|---|---|---|---|
| baseline capture + HTTP API + **auth gate** | ✅ | ✅ (120-frame) | full-loop ✅ |
| **clean** empty-room baseline | ❌ `motion_flagged` (artifact) | ❌ (occupied) | full-loop ✅ (synthetic, zero motion flags) |
| enroll → train-room | ❌ | ❌ (needs operator poses) | full-loop ✅ (8/8 anchors, 6 specialists, JSON round-trip) |
| runtime infer | ❌ on-target | ◐ single-node breathing ~1631 BPM via the **stateless** head (not a trained bank) + node-id fusion | full-loop ✅ (trained bank: 18±2 BPM positive, absent negative, foreign-baseline STALE) |
The complete `baseline → enroll → train-room → infer` loop is now **proven in-process** on deterministic synthetic CSI (`wifi-densepose-calibration/tests/full_loop.rs` — drives the CLI's exact stage order through the public API, seed-robust across 5 seeds, runs with and without default features). Capture + API + auth are proven on real CSI (both boxes). What remains is strictly the **on-target** run: real CSI, a physically empty room for baseline, and an operator performing the 8 guided anchors — that hardware session is the last open item.
- **Known follow-ups (appliance backlog):** `--source-format adr018v6` to drive calibration from the Pi's own nexmon (no ESP32/transcoder); the on-target clean-room enroll→train→infer session (above); phase-based (vs mean-amplitude) breathing carrier; RVF/HNSW persistence (currently JSON); enroll/train HTTP endpoints (live `/room/state` already added); ADR-150 Hailo backbone; true 2-node multistatic; ADR-105 federation.
- **Behavioral findings from the full-loop test — all four FIXED pre-hardware-session:** (1) *z-band squeeze* — anchor motion is now measured from frame-to-frame deltas of the deviation series (`|Δz| > 0.5 |Δφ| > π/6`), not from the absolute `motion_flagged` (which conflated presence strength with motion); a strongly-reflecting still person (z = 3.0, every frame flagged by the old heuristic) now enrolls — regression-guarded in the full-loop test's `StandStill` anchor and `enrollment::tests`. (2) *Variance-only presence*`PresenceSpecialist` gained a mean-shift channel (|mean empty mean| vs a trained threshold); a motionless person is detected via the mean even at empty-level variance — regression-guarded in the full-loop motionless-person case; old persisted banks deserialize with the channel inert (variance-only behavior preserved). (3) *Ungated hz embedding*`Features::embedding()` zeroes `breathing_hz`/`heart_hz` below `EMBED_MIN_SCORE` (0.25), keeping noise-window random frequencies out of the prototype space. (4) *Heart-band leakage* (found while fixing 3): a strong breathing rhythm's autocorrelation leaks into the HR band as a high-score lag-floor edge value (e.g. score 0.67 at 3.33 Hz from a pure 0.30 Hz breath); `autocorr_dominant` now requires the winning lag to be an interior local maximum, rejecting band-edge leakage while preserving true in-band peaks.
**Reference:** ADR-151 (`docs/adr/ADR-151-room-calibration-specialist-training.md`), ADR-135 (baseline),
ADR-029 (multistatic), ADR-150 (RF Foundation Encoder), ADR-105 (federation), ADR-147 (OccWorld/Hailo).
@@ -151,13 +151,9 @@ static void on_recv(const uint8_t *src_mac, const uint8_t *data, int len)
* void (*)(const esp_now_send_info_t *tx_info, esp_now_send_status_t status)
* Both signatures ignore the address-side argument here — we only inspect
* `status` to bump the TX-fail counter — so the body is identical; only the
* function-pointer type differs.
*
* Issue #1005: Espressif backported the new signature to v5.5
* (`esp_now_send_info_t` = typedef of `wifi_tx_info_t` there), so the guard
* must be the full version triple, not ESP_IDF_VERSION_MAJOR.
* function-pointer type differs. ESP_IDF_VERSION_MAJOR is the canonical guard.
*/
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0)
#if ESP_IDF_VERSION_MAJOR >= 6
static void on_send(const esp_now_send_info_t *tx_info, esp_now_send_status_t status)
{
(void)tx_info;
@@ -1,48 +0,0 @@
/* Host-fuzzing stub for esp_netif.h (ADR-061).
*
* csi_collector.c's #954 self-ping needs the STA netif handle + gateway IP.
* In the fuzz environment there is no network stack: the handle lookup
* returns NULL, so csi_start_self_ping() takes its no-gateway early-out and
* the esp_ping path is never exercised (but must compile and link).
*/
#pragma once
#include <stdint.h>
#include <stdio.h>
#include "esp_err.h"
typedef struct esp_netif_obj esp_netif_t;
typedef struct {
uint32_t addr;
} esp_ip4_addr_t;
typedef struct {
esp_ip4_addr_t ip;
esp_ip4_addr_t netmask;
esp_ip4_addr_t gw;
} esp_netif_ip_info_t;
static inline esp_netif_t *esp_netif_get_handle_from_ifkey(const char *if_key)
{
(void)if_key;
return NULL; /* no netif in fuzz env -> self-ping early-out */
}
static inline esp_err_t esp_netif_get_ip_info(esp_netif_t *netif, esp_netif_ip_info_t *ip_info)
{
(void)netif;
(void)ip_info;
return ESP_FAIL;
}
static inline char *esp_ip4addr_ntoa(const esp_ip4_addr_t *addr, char *buf, int buflen)
{
if (buf != NULL && buflen > 0) {
snprintf(buf, (size_t)buflen, "%u.%u.%u.%u",
(unsigned)(addr->addr & 0xff), (unsigned)((addr->addr >> 8) & 0xff),
(unsigned)((addr->addr >> 16) & 0xff), (unsigned)((addr->addr >> 24) & 0xff));
}
return buf;
}
@@ -1,20 +0,0 @@
/* Host-fuzzing stub for lwip/ip_addr.h (ADR-061). Minimal surface for the
* #954 self-ping block; never functionally exercised in the fuzz env. */
#pragma once
#include <stdint.h>
typedef struct {
uint32_t addr;
uint8_t type;
} ip_addr_t;
static inline int ipaddr_aton(const char *cp, ip_addr_t *addr)
{
(void)cp;
if (addr != NULL) {
addr->addr = 0;
addr->type = 0;
}
return 1;
}
@@ -1,79 +0,0 @@
/* Host-fuzzing stub for ping/ping_sock.h (ADR-061). The #954 self-ping is
* unreachable in the fuzz env (esp_netif stub returns no gateway), but the
* symbols must compile and link. */
#pragma once
#include <stdint.h>
#include "esp_err.h"
#include "lwip/ip_addr.h"
typedef void *esp_ping_handle_t;
typedef void (*esp_ping_cb_t)(esp_ping_handle_t hdl, void *args);
typedef struct {
uint32_t count;
uint32_t interval_ms;
uint32_t timeout_ms;
uint32_t data_size;
uint8_t tos;
int ttl;
ip_addr_t target_addr;
uint32_t task_stack_size;
uint32_t task_prio;
uint32_t interface;
} esp_ping_config_t;
#define ESP_PING_COUNT_INFINITE (0)
#define ESP_PING_DEFAULT_CONFIG() \
{ \
.count = 5, \
.interval_ms = 1000, \
.timeout_ms = 1000, \
.data_size = 64, \
.tos = 0, \
.ttl = 64, \
.target_addr = {0, 0}, \
.task_stack_size = 2048, \
.task_prio = 2, \
.interface = 0, \
}
typedef struct {
void *cb_args;
esp_ping_cb_t on_ping_success;
esp_ping_cb_t on_ping_timeout;
esp_ping_cb_t on_ping_end;
} esp_ping_callbacks_t;
static inline esp_err_t esp_ping_new_session(const esp_ping_config_t *config,
const esp_ping_callbacks_t *cbs,
esp_ping_handle_t *hdl_out)
{
(void)config;
(void)cbs;
if (hdl_out != NULL) {
*hdl_out = (void *)0;
}
return ESP_FAIL; /* never starts a ping task in the fuzz env */
}
static inline esp_err_t esp_ping_start(esp_ping_handle_t hdl)
{
(void)hdl;
return ESP_OK;
}
static inline esp_err_t esp_ping_stop(esp_ping_handle_t hdl)
{
(void)hdl;
return ESP_OK;
}
static inline esp_err_t esp_ping_delete_session(esp_ping_handle_t hdl)
{
(void)hdl;
return ESP_OK;
}
-66
View File
@@ -1,66 +0,0 @@
#!/usr/bin/env python3
"""Firewall-free CSI UDP relay for local Windows ESP32 testing.
On Windows, a freshly-built binary (e.g. `wifi-densepose calibrate-serve`) is
blocked from receiving inbound LAN UDP by Windows Defender Firewall unless an
admin adds an allow rule. `python.exe` is typically already allowed. This relay
binds the public CSI port, receives the ESP32's frames, and forwards each
datagram verbatim to a loopback port where the calibration server listens
(loopback is exempt from the inbound firewall). No admin required.
Usage:
python scripts/csi-udp-relay.py --listen 5005 --forward 5006
Then run the calibration server on the loopback port:
wifi-densepose calibrate-serve --udp-bind 127.0.0.1 --udp-port 5006
Frames are passed through byte-for-byte; the relay never parses or mutates them.
"""
import argparse
import socket
import time
def main() -> None:
ap = argparse.ArgumentParser(description="Forward ESP32 CSI UDP to a loopback port (no admin).")
ap.add_argument("--listen", type=int, default=5005, help="public UDP port the ESP32 streams to")
ap.add_argument("--listen-host", default="0.0.0.0", help="bind address for the public port")
ap.add_argument("--forward", type=int, default=5006, help="loopback port the calibration server listens on")
ap.add_argument("--forward-host", default="127.0.0.1", help="loopback host to forward to")
ap.add_argument("--quiet", action="store_true", help="suppress the periodic stats line")
args = ap.parse_args()
rx = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
rx.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
rx.bind((args.listen_host, args.listen))
rx.settimeout(1.0)
tx = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
dst = (args.forward_host, args.forward)
print(f"[relay] {args.listen_host}:{args.listen} -> {dst[0]}:{dst[1]} (Ctrl-C to stop)")
count = 0
last_report = time.time()
last_src = None
try:
while True:
try:
data, src = rx.recvfrom(2048)
except socket.timeout:
data = None
if data:
tx.sendto(data, dst)
count += 1
last_src = src
now = time.time()
if not args.quiet and now - last_report >= 5.0:
print(f"[relay] forwarded {count} frames (last src={last_src})")
last_report = now
except KeyboardInterrupt:
print(f"\n[relay] stopped after {count} frames")
finally:
rx.close()
tx.close()
if __name__ == "__main__":
main()
-1
View File
@@ -1 +0,0 @@
baselines/
Generated
+33 -24
View File
@@ -10811,27 +10811,12 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "wifi-densepose-calibration"
version = "0.3.0"
dependencies = [
"ndarray 0.17.2",
"num-complex",
"serde",
"serde_json",
"thiserror 2.0.18",
"uuid",
"wifi-densepose-core",
"wifi-densepose-signal",
]
[[package]]
name = "wifi-densepose-cli"
version = "0.3.0"
dependencies = [
"anyhow",
"assert_cmd",
"axum",
"chrono",
"clap",
"colored",
@@ -10847,12 +10832,9 @@ dependencies = [
"tempfile",
"thiserror 2.0.18",
"tokio",
"tower 0.4.13",
"tower-http",
"tracing",
"tracing-subscriber",
"uuid",
"wifi-densepose-calibration",
"wifi-densepose-core",
"wifi-densepose-mat",
"wifi-densepose-signal",
@@ -10912,10 +10894,10 @@ dependencies = [
"criterion",
"wifi-densepose-bfld",
"wifi-densepose-core",
"wifi-densepose-geo",
"wifi-densepose-geo 0.1.0",
"wifi-densepose-ruvector",
"wifi-densepose-signal",
"wifi-densepose-worldgraph",
"wifi-densepose-worldgraph 0.3.0",
]
[[package]]
@@ -10930,6 +10912,20 @@ dependencies = [
"tokio",
]
[[package]]
name = "wifi-densepose-geo"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "092ea59d81e7be76d6d9c2d81628c1dbe768fd77591f0e82dd3c80e2963ff04a"
dependencies = [
"anyhow",
"chrono",
"reqwest 0.12.28",
"serde",
"serde_json",
"tokio",
]
[[package]]
name = "wifi-densepose-hardware"
version = "0.3.0"
@@ -11191,24 +11187,37 @@ dependencies = [
[[package]]
name = "wifi-densepose-worldgraph"
version = "0.3.1"
version = "0.3.0"
dependencies = [
"petgraph",
"serde",
"serde_json",
"thiserror 2.0.18",
"wifi-densepose-geo",
"wifi-densepose-geo 0.1.0",
]
[[package]]
name = "wifi-densepose-worldgraph"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13ad8df7b323061ed7afae1917dac7eedfbd24a463a668a55a16cde79df067e2"
dependencies = [
"petgraph",
"serde",
"serde_json",
"thiserror 2.0.18",
"wifi-densepose-geo 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "wifi-densepose-worldmodel"
version = "0.3.1"
version = "0.3.0"
dependencies = [
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"wifi-densepose-worldgraph",
"wifi-densepose-worldgraph 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
-1
View File
@@ -28,7 +28,6 @@ members = [
"crates/wifi-densepose-geo",
"crates/wifi-densepose-worldgraph", # ADR-139 — WorldGraph environmental digital twin
"crates/wifi-densepose-engine", # ADR-135..146 integration/composition layer
"crates/wifi-densepose-calibration", # ADR-151 — per-room calibration & specialist training
"crates/nvsim",
"crates/nvsim-server",
"crates/homecore", # ADR-127 — HOMECORE state machine
@@ -1,21 +0,0 @@
[package]
name = "wifi-densepose-calibration"
version.workspace = true
edition.workspace = true
description = "ADR-151 per-room calibration & specialized model training (baseline → enroll → extract → train)"
authors.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
wifi-densepose-core = { workspace = true }
wifi-densepose-signal = { version = "0.3.0", path = "../wifi-densepose-signal", default-features = false }
serde = { workspace = true }
serde_json = "1.0"
thiserror = { workspace = true }
uuid = { version = "1.6", features = ["v4", "serde"] }
[dev-dependencies]
ndarray = { workspace = true }
num-complex = { workspace = true }
@@ -1,351 +0,0 @@
//! Guided anchors + event-sourced enrollment session (ADR-151 Stage 2).
//!
//! Enrollment teaches the room a small set of *clean anchors* — not hours of
//! data. Each anchor is a short labelled capture (stand / sit / lie / breathe /
//! move / sleep) layered on top of the ADR-135 empty-room baseline. The session
//! is event-sourced so re-enrollment is incremental and auditable (per CLAUDE.md
//! state rules).
use serde::{Deserialize, Serialize};
/// Coarse posture an anchor establishes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Posture {
/// Standing.
Standing,
/// Sitting.
Sitting,
/// Lying down.
Lying,
}
/// The fixed guided-anchor sequence (ADR-151 §2.2).
///
/// Serializes as snake_case (`empty`, `stand_still`, …) to match
/// [`AnchorLabel::as_str`] and the documented JSON contract.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AnchorLabel {
/// Empty room reference (reuses the ADR-135 baseline).
Empty,
/// Person standing still, in view of the sensor.
StandStill,
/// Person sitting.
Sit,
/// Person lying down.
LieDown,
/// Slow respiration (~0.10.15 Hz).
BreatheSlow,
/// Normal respiration (~0.20.3 Hz).
BreatheNormal,
/// Small limb movement.
SmallMove,
/// Quiescent sleep posture (lying, still).
SleepPosture,
}
impl AnchorLabel {
/// The canonical enrollment order.
pub const SEQUENCE: [AnchorLabel; 8] = [
AnchorLabel::Empty,
AnchorLabel::StandStill,
AnchorLabel::Sit,
AnchorLabel::LieDown,
AnchorLabel::BreatheSlow,
AnchorLabel::BreatheNormal,
AnchorLabel::SmallMove,
AnchorLabel::SleepPosture,
];
/// Stable string id (used in persistence / API).
pub fn as_str(&self) -> &'static str {
match self {
AnchorLabel::Empty => "empty",
AnchorLabel::StandStill => "stand_still",
AnchorLabel::Sit => "sit",
AnchorLabel::LieDown => "lie_down",
AnchorLabel::BreatheSlow => "breathe_slow",
AnchorLabel::BreatheNormal => "breathe_normal",
AnchorLabel::SmallMove => "small_move",
AnchorLabel::SleepPosture => "sleep_posture",
}
}
/// Parse from the stable string id.
pub fn from_str(s: &str) -> Option<AnchorLabel> {
AnchorLabel::SEQUENCE
.iter()
.copied()
.find(|a| a.as_str() == s)
}
/// Operator-facing prompt shown by the CLI / UI.
pub fn prompt(&self) -> &'static str {
match self {
AnchorLabel::Empty => "Leave the room empty and still…",
AnchorLabel::StandStill => "Stand still, in view of the sensor…",
AnchorLabel::Sit => "Sit down and stay still…",
AnchorLabel::LieDown => "Lie down and stay still…",
AnchorLabel::BreatheSlow => "Lie or sit still and breathe slowly…",
AnchorLabel::BreatheNormal => "Stay still and breathe normally…",
AnchorLabel::SmallMove => "Make small movements (wave a hand, shift)…",
AnchorLabel::SleepPosture => "Lie in your sleep posture and relax…",
}
}
/// Suggested capture duration (seconds).
pub fn duration_s(&self) -> u32 {
match self {
AnchorLabel::BreatheSlow
| AnchorLabel::BreatheNormal
| AnchorLabel::SleepPosture => 30,
_ => 20,
}
}
/// Whether a person is expected to be present for this anchor.
pub fn expects_presence(&self) -> bool {
!matches!(self, AnchorLabel::Empty)
}
/// Whether the subject is expected to be (largely) still.
pub fn expects_still(&self) -> bool {
!matches!(self, AnchorLabel::SmallMove)
}
/// Posture this anchor establishes, if any.
pub fn posture(&self) -> Option<Posture> {
match self {
AnchorLabel::StandStill => Some(Posture::Standing),
AnchorLabel::Sit => Some(Posture::Sitting),
AnchorLabel::LieDown | AnchorLabel::SleepPosture => Some(Posture::Lying),
_ => None,
}
}
}
/// Quality assessment of a captured anchor (from the enrollment quality gate).
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct AnchorQuality {
/// Median amplitude z-score vs the empty-room baseline (presence strength).
pub presence_z: f32,
/// Fraction of frames flagged as motion.
pub motion_rate: f32,
/// Number of frames captured.
pub frames: u32,
/// Whether the anchor passed the gate.
pub accepted: bool,
}
/// A captured, accepted anchor.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Anchor {
/// Which anchor in the sequence.
pub label: AnchorLabel,
/// Capture time (unix seconds).
pub captured_at_unix_s: i64,
/// Quality metrics.
pub quality: AnchorQuality,
}
/// Event log entry for an enrollment session (event sourcing).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum EnrollmentEvent {
/// Session opened.
Started {
/// Room scope.
room_id: String,
/// Baseline id the enrollment layers on.
baseline_id: String,
/// Unix seconds.
at: i64,
},
/// An anchor passed the gate and was accepted.
AnchorAccepted {
/// The accepted anchor.
anchor: Anchor,
},
/// An anchor failed the gate (re-prompt).
AnchorRejected {
/// Which anchor.
label: AnchorLabel,
/// Human-readable reason.
reason: String,
/// Unix seconds.
at: i64,
},
/// All required anchors accepted.
Completed {
/// Unix seconds.
at: i64,
},
}
/// Event-sourced enrollment session for one room.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnrollmentSession {
/// Room scope.
pub room_id: String,
/// Baseline id this session layers on.
pub baseline_id: String,
/// Append-only event log.
pub events: Vec<EnrollmentEvent>,
}
impl EnrollmentSession {
/// Open a new session.
pub fn new(room_id: impl Into<String>, baseline_id: impl Into<String>, at: i64) -> Self {
let room_id = room_id.into();
let baseline_id = baseline_id.into();
let mut s = Self {
room_id: room_id.clone(),
baseline_id: baseline_id.clone(),
events: Vec::new(),
};
s.events.push(EnrollmentEvent::Started {
room_id,
baseline_id,
at,
});
s
}
/// Append an event (event sourcing — state is derived, never mutated in place).
pub fn apply(&mut self, event: EnrollmentEvent) {
self.events.push(event);
}
/// The set of accepted anchors (latest acceptance per label wins).
pub fn accepted_anchors(&self) -> Vec<Anchor> {
let mut out: Vec<Anchor> = Vec::new();
for ev in &self.events {
if let EnrollmentEvent::AnchorAccepted { anchor } = ev {
if let Some(slot) = out.iter_mut().find(|a| a.label == anchor.label) {
*slot = anchor.clone();
} else {
out.push(anchor.clone());
}
}
}
out
}
/// The next anchor in the canonical sequence not yet accepted, if any.
pub fn next_anchor(&self) -> Option<AnchorLabel> {
let accepted = self.accepted_anchors();
AnchorLabel::SEQUENCE
.iter()
.copied()
.find(|label| !accepted.iter().any(|a| a.label == *label))
}
/// `(accepted, total)` progress.
pub fn progress(&self) -> (usize, usize) {
(
self.accepted_anchors().len(),
AnchorLabel::SEQUENCE.len(),
)
}
/// Whether every anchor in the sequence has been accepted.
pub fn is_complete(&self) -> bool {
self.next_anchor().is_none()
}
/// Labels still required.
pub fn missing(&self) -> Vec<AnchorLabel> {
let accepted = self.accepted_anchors();
AnchorLabel::SEQUENCE
.iter()
.copied()
.filter(|label| !accepted.iter().any(|a| a.label == *label))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn anchor(label: AnchorLabel) -> Anchor {
Anchor {
label,
captured_at_unix_s: 1,
quality: AnchorQuality {
presence_z: 3.0,
motion_rate: 0.1,
frames: 400,
accepted: true,
},
}
}
#[test]
fn label_roundtrip() {
for l in AnchorLabel::SEQUENCE {
assert_eq!(AnchorLabel::from_str(l.as_str()), Some(l));
}
assert_eq!(AnchorLabel::from_str("nope"), None);
}
#[test]
fn label_serde_is_snake_case_matching_as_str() {
// The JSON wire format must equal as_str() (the documented contract).
for l in AnchorLabel::SEQUENCE {
let json = serde_json::to_string(&l).unwrap();
assert_eq!(json, format!("\"{}\"", l.as_str()));
let back: AnchorLabel = serde_json::from_str(&json).unwrap();
assert_eq!(back, l);
}
}
#[test]
fn sequence_order_and_next() {
let mut s = EnrollmentSession::new("living-room", "base-1", 0);
assert_eq!(s.next_anchor(), Some(AnchorLabel::Empty));
s.apply(EnrollmentEvent::AnchorAccepted {
anchor: anchor(AnchorLabel::Empty),
});
assert_eq!(s.next_anchor(), Some(AnchorLabel::StandStill));
assert_eq!(s.progress(), (1, 8));
assert!(!s.is_complete());
}
#[test]
fn completion_and_missing() {
let mut s = EnrollmentSession::new("r", "b", 0);
for l in AnchorLabel::SEQUENCE {
s.apply(EnrollmentEvent::AnchorAccepted { anchor: anchor(l) });
}
assert!(s.is_complete());
assert!(s.missing().is_empty());
assert_eq!(s.progress(), (8, 8));
}
#[test]
fn reaccept_replaces_not_duplicates() {
let mut s = EnrollmentSession::new("r", "b", 0);
s.apply(EnrollmentEvent::AnchorAccepted {
anchor: anchor(AnchorLabel::Sit),
});
s.apply(EnrollmentEvent::AnchorAccepted {
anchor: anchor(AnchorLabel::Sit),
});
assert_eq!(
s.accepted_anchors()
.iter()
.filter(|a| a.label == AnchorLabel::Sit)
.count(),
1
);
}
#[test]
fn posture_mapping() {
assert_eq!(AnchorLabel::StandStill.posture(), Some(Posture::Standing));
assert_eq!(AnchorLabel::LieDown.posture(), Some(Posture::Lying));
assert_eq!(AnchorLabel::SmallMove.posture(), None);
assert!(!AnchorLabel::SmallMove.expects_still());
assert!(!AnchorLabel::Empty.expects_presence());
}
}
@@ -1,188 +0,0 @@
//! The per-room specialist bank (ADR-151 Stage 4).
//!
//! A versioned collection of small models scoped to one `room_id`, fit from the
//! enrollment anchors and tied to the ADR-135 baseline it was trained against.
//! When the baseline drifts (room rearranged, AP moved), the bank is marked
//! STALE rather than emitting confident-but-wrong readings — the calibration
//! analogue of the firmware's honest `DEGRADED` flag.
use serde::{Deserialize, Serialize};
use crate::error::{CalibrationError, Result};
use crate::extract::AnchorFeature;
use crate::specialist::{
AnomalySpecialist, BreathingSpecialist, HeartbeatSpecialist, PostureSpecialist,
PresenceSpecialist, RestlessnessSpecialist, SpecialistKind,
};
/// A versioned bank of room-calibrated specialists.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpecialistBank {
/// Room scope.
pub room_id: String,
/// ADR-135 baseline id this bank was trained against (drift → STALE).
pub baseline_id: String,
/// Training time (unix seconds).
pub trained_at_unix_s: i64,
/// Number of anchors used.
pub anchor_count: usize,
/// Presence gate (requires the `empty` + an occupied anchor).
pub presence: Option<PresenceSpecialist>,
/// Posture classifier (requires posture anchors).
pub posture: Option<PostureSpecialist>,
/// Breathing (band-limited periodicity; stateless).
pub breathing: BreathingSpecialist,
/// Heartbeat (band-limited periodicity; stateless).
pub heartbeat: HeartbeatSpecialist,
/// Restlessness (requires calm + active anchors).
pub restlessness: Option<RestlessnessSpecialist>,
/// Anomaly novelty detector (requires ≥2 anchors).
pub anomaly: Option<AnomalySpecialist>,
}
impl SpecialistBank {
/// Train a bank from enrollment anchor features.
///
/// Requires at least one anchor; specialists whose prerequisite anchors are
/// missing are simply left `None` (a partial bank still works for the
/// signals it could fit).
pub fn train(
room_id: impl Into<String>,
baseline_id: impl Into<String>,
anchors: &[AnchorFeature],
at_unix_s: i64,
) -> Result<Self> {
if anchors.is_empty() {
return Err(CalibrationError::InsufficientSamples {
kind: "bank".into(),
have: 0,
need: 1,
});
}
Ok(Self {
room_id: room_id.into(),
baseline_id: baseline_id.into(),
trained_at_unix_s: at_unix_s,
anchor_count: anchors.len(),
presence: PresenceSpecialist::train(anchors),
posture: PostureSpecialist::train(anchors),
breathing: BreathingSpecialist::default(),
heartbeat: HeartbeatSpecialist::default(),
restlessness: RestlessnessSpecialist::train(anchors),
anomaly: AnomalySpecialist::train(anchors),
})
}
/// `true` if the bank was trained against a different baseline (it is STALE).
pub fn is_stale(&self, current_baseline_id: &str) -> bool {
self.baseline_id != current_baseline_id
}
/// Error out if stale.
pub fn check_fresh(&self, current_baseline_id: &str) -> Result<()> {
if self.is_stale(current_baseline_id) {
Err(CalibrationError::StaleBaseline {
trained: self.baseline_id.clone(),
current: current_baseline_id.to_string(),
})
} else {
Ok(())
}
}
/// Which specialists were successfully fit.
pub fn trained_kinds(&self) -> Vec<SpecialistKind> {
let mut v = vec![SpecialistKind::Breathing, SpecialistKind::Heartbeat];
if self.presence.is_some() {
v.push(SpecialistKind::Presence);
}
if self.posture.is_some() {
v.push(SpecialistKind::Posture);
}
if self.restlessness.is_some() {
v.push(SpecialistKind::Restlessness);
}
if self.anomaly.is_some() {
v.push(SpecialistKind::Anomaly);
}
v
}
/// Serialize to JSON.
pub fn to_json(&self) -> Result<String> {
serde_json::to_string_pretty(self).map_err(|e| CalibrationError::Serde(e.to_string()))
}
/// Deserialize from JSON.
pub fn from_json(s: &str) -> Result<Self> {
serde_json::from_str(s).map_err(|e| CalibrationError::Serde(e.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::anchor::AnchorLabel;
use crate::extract::Features;
fn af(label: AnchorLabel, variance: f32, motion: f32) -> AnchorFeature {
AnchorFeature {
room_id: "living-room".into(),
label,
features: Features {
mean: 1.0,
variance,
motion,
breathing_score: 0.0,
breathing_hz: 0.0,
heart_score: 0.0,
heart_hz: 0.0,
},
}
}
fn full_anchors() -> Vec<AnchorFeature> {
vec![
af(AnchorLabel::Empty, 1.0, 0.1),
af(AnchorLabel::StandStill, 10.0, 0.2),
af(AnchorLabel::Sit, 6.0, 0.2),
af(AnchorLabel::LieDown, 3.0, 0.2),
af(AnchorLabel::SmallMove, 4.0, 1.2),
af(AnchorLabel::SleepPosture, 3.0, 0.1),
]
}
#[test]
fn train_full_bank() {
let bank = SpecialistBank::train("living-room", "base-1", &full_anchors(), 1000).unwrap();
let kinds = bank.trained_kinds();
assert!(kinds.contains(&SpecialistKind::Presence));
assert!(kinds.contains(&SpecialistKind::Posture));
assert!(kinds.contains(&SpecialistKind::Restlessness));
assert!(kinds.contains(&SpecialistKind::Anomaly));
assert_eq!(bank.anchor_count, 6);
}
#[test]
fn empty_anchors_error() {
assert!(SpecialistBank::train("r", "b", &[], 0).is_err());
}
#[test]
fn json_roundtrip() {
let bank = SpecialistBank::train("r", "base-1", &full_anchors(), 1000).unwrap();
let json = bank.to_json().unwrap();
let back = SpecialistBank::from_json(&json).unwrap();
assert_eq!(back.room_id, "r");
assert_eq!(back.anchor_count, 6);
}
#[test]
fn staleness() {
let bank = SpecialistBank::train("r", "base-1", &full_anchors(), 1000).unwrap();
assert!(!bank.is_stale("base-1"));
assert!(bank.is_stale("base-2"));
assert!(bank.check_fresh("base-2").is_err());
}
}
@@ -1,327 +0,0 @@
//! Enrollment protocol — per-anchor capture with an adaptive quality gate
//! (ADR-151 Stage 2).
//!
//! Bad anchors poison small calibrated models far more than large ones, so an
//! anchor is only *accepted* when its captured statistics match what the anchor
//! is supposed to teach: a person present (or absent for `empty`), and the
//! expected stillness/motion. Failed anchors are re-prompted, not silently kept.
//!
//! Quality is measured against the ADR-135 empty-room baseline via
//! [`wifi_densepose_signal::BaselineCalibration::deviation`], whose
//! `CalibrationDeviationScore` gives a per-frame amplitude z-score (presence
//! strength).
//!
//! **Motion is NOT taken from the score's `motion_flagged`** (ADR-152 finding,
//! "z-band squeeze"): that flag fires on `amplitude_z_median > 2.0` — deviation
//! from the *empty* baseline — which conflates presence strength with motion. A
//! strongly-reflecting person standing perfectly still (z > 2 on every frame)
//! would be rejected as "too much motion". Instead the recorder derives motion
//! from the frame-to-frame *change* in the deviation series (|Δz| and |Δφ|),
//! which is presence-independent: a still strong reflector has high z but a
//! flat z-series; a moving person has a jittery one.
use wifi_densepose_core::types::CsiFrame;
use wifi_densepose_signal::{BaselineCalibration, CalibrationDeviationScore};
use crate::anchor::{Anchor, AnchorLabel, AnchorQuality};
/// Thresholds for accepting an anchor.
#[derive(Debug, Clone, Copy)]
pub struct AnchorQualityGate {
/// Minimum mean amplitude z-score to consider a person present.
pub min_presence_z: f32,
/// For `empty`: maximum mean z-score to consider the room truly empty.
pub empty_max_z: f32,
/// For "still" anchors: maximum motion-flag rate tolerated.
pub max_still_motion: f32,
/// For the "move" anchor: minimum motion-flag rate required.
pub min_move_motion: f32,
/// Minimum frames required to evaluate an anchor.
pub min_frames: u32,
}
impl Default for AnchorQualityGate {
fn default() -> Self {
Self {
min_presence_z: 1.5,
empty_max_z: 1.0,
max_still_motion: 0.6,
min_move_motion: 0.3,
min_frames: 60,
}
}
}
impl AnchorQualityGate {
/// Evaluate accumulated stats for `label`, returning the quality verdict
/// and (on rejection) a human-readable reason.
pub fn evaluate(
&self,
label: AnchorLabel,
presence_z: f32,
motion_rate: f32,
frames: u32,
) -> (AnchorQuality, Option<String>) {
let mut reason: Option<String> = None;
if frames < self.min_frames {
reason = Some(format!(
"only {frames} frames (need ≥{}); is the ESP32 streaming?",
self.min_frames
));
} else if label.expects_presence() {
if presence_z < self.min_presence_z {
reason = Some(format!(
"no person detected (presence_z {presence_z:.2} < {:.2}) — move closer / face the sensor",
self.min_presence_z
));
} else if label.expects_still() && motion_rate > self.max_still_motion {
reason = Some(format!(
"too much motion ({:.0}% > {:.0}%) for a still anchor — hold still",
motion_rate * 100.0,
self.max_still_motion * 100.0
));
} else if !label.expects_still() && motion_rate < self.min_move_motion {
reason = Some(format!(
"not enough motion ({:.0}% < {:.0}%) — move a bit more",
motion_rate * 100.0,
self.min_move_motion * 100.0
));
}
} else {
// `empty` anchor: the room must actually be empty.
if presence_z > self.empty_max_z {
reason = Some(format!(
"room not empty (presence_z {presence_z:.2} > {:.2}) — clear the room",
self.empty_max_z
));
}
}
let quality = AnchorQuality {
presence_z,
motion_rate,
frames,
accepted: reason.is_none(),
};
(quality, reason)
}
}
/// Frame-to-frame amplitude-z change above which a frame counts as motion.
///
/// Presence-independent by construction: a still person shifts the z *level*
/// but not its frame-to-frame delta (only noise-scale jitter survives), while
/// body movement modulates the reflected paths every frame. Sized well above
/// the delta the baseline's own noise floor produces (≲0.3σ) and well below
/// the delta even small limb movements produce (≳1σ). See ADR-152.
pub const Z_DELTA_MOTION: f32 = 0.5;
/// Frame-to-frame phase-drift change above which a frame counts as motion.
/// Same constant family as the absolute π/6 drift bound in
/// `CalibrationDeviationScore`, applied to the delta (static body phase shift
/// cancels out).
pub const PHASE_DELTA_MOTION: f32 = std::f32::consts::PI / 6.0;
/// Accumulates per-frame deviation statistics for a single anchor capture.
pub struct AnchorRecorder {
label: AnchorLabel,
z_sum: f64,
motion_count: u32,
frames: u32,
/// Previous frame's (amplitude_z_median, phase_drift_median) for the
/// delta-based motion measure (ADR-152 z-band-squeeze fix).
prev: Option<(f32, f32)>,
}
impl AnchorRecorder {
/// Start recording the given anchor.
pub fn new(label: AnchorLabel) -> Self {
Self {
label,
z_sum: 0.0,
motion_count: 0,
frames: 0,
prev: None,
}
}
/// The anchor being recorded.
pub fn label(&self) -> AnchorLabel {
self.label
}
/// Frames recorded so far.
pub fn frames(&self) -> u32 {
self.frames
}
/// Record a pre-computed deviation score (caller runs `baseline.deviation`).
///
/// Motion is derived from the frame-to-frame change of the deviation
/// series, NOT from `score.motion_flagged` — the flag conflates presence
/// strength with motion (z-band squeeze, see module docs / ADR-152). The
/// first frame of a capture is never motion (no predecessor).
pub fn record_score(&mut self, score: &CalibrationDeviationScore) {
let z = score.amplitude_z_median;
let phase = score.phase_drift_median;
if let Some((pz, pp)) = self.prev {
if (z - pz).abs() > Z_DELTA_MOTION || (phase - pp).abs() > PHASE_DELTA_MOTION {
self.motion_count += 1;
}
}
self.prev = Some((z, phase));
self.z_sum += z as f64;
self.frames += 1;
}
/// Convenience: record a CSI frame directly against a baseline.
/// Frames that fail baseline geometry checks are skipped (not counted).
pub fn record_frame(&mut self, baseline: &BaselineCalibration, frame: &CsiFrame) {
if let Ok(score) = baseline.deviation(frame) {
self.record_score(&score);
}
}
/// Mean presence z-score over the capture.
pub fn presence_z(&self) -> f32 {
if self.frames == 0 {
0.0
} else {
(self.z_sum / self.frames as f64) as f32
}
}
/// Fraction of frames flagged as motion.
pub fn motion_rate(&self) -> f32 {
if self.frames == 0 {
0.0
} else {
self.motion_count as f32 / self.frames as f32
}
}
/// Evaluate the capture against the gate and produce an `Anchor` (accepted
/// or not) plus a rejection reason.
pub fn finalize(
&self,
gate: &AnchorQualityGate,
at_unix_s: i64,
) -> (Anchor, Option<String>) {
let (quality, reason) =
gate.evaluate(self.label, self.presence_z(), self.motion_rate(), self.frames);
(
Anchor {
label: self.label,
captured_at_unix_s: at_unix_s,
quality,
},
reason,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Build a score the way `BaselineCalibration::deviation` actually would:
/// `motion_flagged` is DERIVED from z (z > 2.0 ⇒ flagged), never free.
/// The old tests mocked `(z=3.0, motion=false)` — a combination the real
/// producer can never emit, which is exactly how the z-band squeeze hid.
fn score(z: f32) -> CalibrationDeviationScore {
CalibrationDeviationScore {
amplitude_z_median: z,
amplitude_z_max: z + 1.0,
phase_drift_median: 0.05,
motion_flagged: z > 2.0,
}
}
/// Record a z-series and finalize against the default gate.
fn run_series(label: AnchorLabel, zs: &[f32]) -> (Anchor, Option<String>) {
let mut r = AnchorRecorder::new(label);
for &z in zs {
r.record_score(&score(z));
}
r.finalize(&AnchorQualityGate::default(), 100)
}
/// Constant z (a perfectly still capture at the given presence strength).
fn run_still(label: AnchorLabel, z: f32, n: usize) -> (Anchor, Option<String>) {
run_series(label, &vec![z; n])
}
/// Alternating z (every frame's |Δz| exceeds Z_DELTA_MOTION ⇒ all motion).
fn run_jittery(label: AnchorLabel, z: f32, n: usize) -> (Anchor, Option<String>) {
let zs: Vec<f32> = (0..n)
.map(|i| if i % 2 == 0 { z } else { z + 2.0 * Z_DELTA_MOTION })
.collect();
run_series(label, &zs)
}
/// ADR-152 z-band-squeeze regression: a STRONGLY-reflecting still person
/// (z = 3.0, so every frame is motion_flagged by the baseline heuristic)
/// must still pass a still anchor — presence strength is not motion.
#[test]
fn still_anchor_with_strong_still_person_accepts() {
let (a, reason) = run_still(AnchorLabel::StandStill, 3.0, 400);
assert!(a.quality.accepted, "z-band squeeze is back: {reason:?}");
assert!(reason.is_none());
assert!(a.quality.motion_rate < 0.05, "flat z-series must read still");
}
#[test]
fn still_anchor_rejects_when_no_presence() {
let (a, reason) = run_still(AnchorLabel::Sit, 0.4, 400);
assert!(!a.quality.accepted);
assert!(reason.unwrap().contains("no person"));
}
#[test]
fn still_anchor_rejects_on_motion() {
let (a, reason) = run_jittery(AnchorLabel::LieDown, 3.0, 400);
assert!(!a.quality.accepted);
assert!(reason.unwrap().contains("motion"));
}
#[test]
fn move_anchor_requires_motion() {
let (still, r1) = run_still(AnchorLabel::SmallMove, 3.0, 400);
assert!(!still.quality.accepted);
assert!(r1.unwrap().contains("not enough motion"));
let (moving, r2) = run_jittery(AnchorLabel::SmallMove, 3.0, 400);
assert!(moving.quality.accepted, "reason: {r2:?}");
}
#[test]
fn phase_delta_also_counts_as_motion() {
// Constant z but a phase-drift series that swings past PHASE_DELTA_MOTION
// every frame — motion must be detected from the phase channel alone.
let mut r = AnchorRecorder::new(AnchorLabel::LieDown);
for i in 0..400 {
let mut s = score(1.8);
s.phase_drift_median = if i % 2 == 0 { 0.0 } else { PHASE_DELTA_MOTION * 1.5 };
r.record_score(&s);
}
let (a, reason) = r.finalize(&AnchorQualityGate::default(), 100);
assert!(!a.quality.accepted);
assert!(reason.unwrap().contains("motion"));
}
#[test]
fn empty_anchor_rejects_when_occupied() {
let (occupied, reason) = run_still(AnchorLabel::Empty, 3.0, 400);
assert!(!occupied.quality.accepted);
assert!(reason.unwrap().contains("not empty"));
let (empty, _) = run_still(AnchorLabel::Empty, 0.3, 400);
assert!(empty.quality.accepted);
}
#[test]
fn too_few_frames_rejected() {
let (a, reason) = run_still(AnchorLabel::Sit, 3.0, 10);
assert!(!a.quality.accepted);
assert!(reason.unwrap().contains("frames"));
}
}
@@ -1,49 +0,0 @@
//! Error types for the calibration pipeline.
use thiserror::Error;
/// Errors surfaced by the per-room calibration & training pipeline (ADR-151).
#[derive(Debug, Error)]
pub enum CalibrationError {
/// An anchor was recorded with zero frames.
#[error("anchor '{0}' captured no frames")]
EmptyAnchor(String),
/// The enrollment session is missing anchors required to train a specialist.
#[error("enrollment incomplete: missing anchors {missing:?}")]
IncompleteEnrollment {
/// Labels still required.
missing: Vec<String>,
},
/// A frame did not match the expected tier geometry.
#[error("frame geometry mismatch: {0}")]
Geometry(String),
/// Not enough samples to fit a specialist.
#[error("insufficient samples for '{kind}': have {have}, need {need}")]
InsufficientSamples {
/// Specialist kind.
kind: String,
/// Samples available.
have: usize,
/// Samples required.
need: usize,
},
/// Serialization / persistence failure.
#[error("serialization error: {0}")]
Serde(String),
/// The specialist bank was trained against a different baseline and is stale.
#[error("bank is STALE: trained against baseline {trained}, current is {current}")]
StaleBaseline {
/// Baseline id the bank was trained against.
trained: String,
/// Current baseline id.
current: String,
},
}
/// Convenience result alias.
pub type Result<T> = std::result::Result<T, CalibrationError>;
@@ -1,295 +0,0 @@
//! Feature extraction (ADR-151 Stage 3).
//!
//! Turns an anchor capture — a per-frame scalar series derived from the
//! baseline-subtracted CSI (mean amplitude or dominant-subcarrier phase) — into
//! a compact [`Features`] vector the small specialists consume. No giant model:
//! the useful signal (variance, motion, periodicity, dominant rhythm) is cheap
//! to compute and is exactly what breathing/heartbeat/posture/presence need.
//!
//! Heartbeat and breathing are tiny *repeating* disturbances in the RF field, so
//! periodicity is estimated by autocorrelation over the relevant band — the same
//! technique that fixed the firmware HR estimator (#987).
use serde::{Deserialize, Serialize};
use crate::anchor::AnchorLabel;
/// Compact per-capture (or per-window) feature vector.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Features {
/// Mean of the scalar series (presence / static load).
pub mean: f32,
/// Variance of the series (motion / occupancy energy).
pub variance: f32,
/// Mean absolute first difference (instantaneous motion proxy).
pub motion: f32,
/// Dominant periodicity score in the breathing band [0, 1].
pub breathing_score: f32,
/// Dominant breathing frequency (Hz), 0 if none.
pub breathing_hz: f32,
/// Dominant periodicity score in the heart-rate band [0, 1].
pub heart_score: f32,
/// Dominant heart-rate frequency (Hz), 0 if none.
pub heart_hz: f32,
}
/// Minimum periodicity score for a band's frequency to enter the prototype
/// embedding. Below it `autocorr_dominant` still reports its best in-band
/// peak, but for noise windows that peak is a *random* in-band frequency —
/// letting it into the embedding makes posture/anomaly prototype distances
/// noisy (ADR-152 finding, "ungated hz embedding"). The raw `breathing_hz` /
/// `heart_hz` fields stay un-gated: the breathing/heartbeat specialists apply
/// their own (stricter) `min_score` gates.
pub const EMBED_MIN_SCORE: f32 = 0.25;
impl Features {
/// A fixed-length numeric embedding for nearest-prototype classifiers.
///
/// The hz components are zeroed unless their periodicity score clears
/// [`EMBED_MIN_SCORE`] — see the constant's docs.
pub fn embedding(&self) -> [f32; 5] {
let breathing_hz = if self.breathing_score >= EMBED_MIN_SCORE {
self.breathing_hz
} else {
0.0
};
let heart_hz = if self.heart_score >= EMBED_MIN_SCORE {
self.heart_hz
} else {
0.0
};
[self.mean, self.variance, self.motion, breathing_hz, heart_hz]
}
/// Squared Euclidean distance between two embeddings.
pub fn distance2(&self, other: &Features) -> f32 {
self.embedding()
.iter()
.zip(other.embedding().iter())
.map(|(a, b)| (a - b) * (a - b))
.sum()
}
/// Extract features from a per-frame scalar series sampled at `fs` Hz.
pub fn from_series(series: &[f32], fs: f32) -> Features {
let n = series.len();
if n == 0 {
return Features {
mean: 0.0,
variance: 0.0,
motion: 0.0,
breathing_score: 0.0,
breathing_hz: 0.0,
heart_score: 0.0,
heart_hz: 0.0,
};
}
let mean = series.iter().copied().sum::<f32>() / n as f32;
let variance =
series.iter().map(|v| (v - mean) * (v - mean)).sum::<f32>() / n as f32;
let motion = if n > 1 {
series.windows(2).map(|w| (w[1] - w[0]).abs()).sum::<f32>() / (n - 1) as f32
} else {
0.0
};
// De-mean before periodicity search.
let centered: Vec<f32> = series.iter().map(|v| v - mean).collect();
let (breathing_hz, breathing_score) = autocorr_dominant(&centered, fs, 0.1, 0.6);
let (heart_hz, heart_score) = autocorr_dominant(&centered, fs, 0.8, 3.0);
Features {
mean,
variance,
motion,
breathing_score,
breathing_hz,
heart_score,
heart_hz,
}
}
}
/// A labelled feature record from an enrollment anchor (ADR-151 Stage 3).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AnchorFeature {
/// Room scope.
pub room_id: String,
/// Which anchor this came from.
pub label: AnchorLabel,
/// The extracted features.
pub features: Features,
}
impl AnchorFeature {
/// Build from a per-frame scalar series.
pub fn from_series(
room_id: impl Into<String>,
label: AnchorLabel,
series: &[f32],
fs: f32,
) -> AnchorFeature {
AnchorFeature {
room_id: room_id.into(),
label,
features: Features::from_series(series, fs),
}
}
}
/// Dominant frequency in `[lo_hz, hi_hz]` via autocorrelation, with a normalized
/// peak score in `[0, 1]`. Returns `(0, 0)` if no confident peak.
///
/// The winning lag must be an **interior local maximum** of the in-band
/// autocorrelation, not a band-edge value (ADR-152 finding, "heart-band
/// leakage"): a strong out-of-band rhythm — breathing bleeding into the HR
/// band — produces a monotonic slope whose largest in-band value sits at the
/// lag floor (pinning `heart_hz` near the band's top frequency with a high
/// score). A genuine in-band periodicity peaks *inside* the band; an edge
/// maximum is leakage and is rejected.
pub fn autocorr_dominant(sig: &[f32], fs: f32, lo_hz: f32, hi_hz: f32) -> (f32, f32) {
let n = sig.len();
if n < 16 || fs <= 0.0 || hi_hz <= lo_hz {
return (0.0, 0.0);
}
let lag_min = ((fs / hi_hz).floor() as usize).max(1);
let lag_max = ((fs / lo_hz).ceil() as usize).min(n - 1);
if lag_max <= lag_min + 1 {
return (0.0, 0.0);
}
let r0: f32 = sig.iter().map(|v| v * v).sum();
if r0 <= 1e-6 {
return (0.0, 0.0);
}
// Autocorrelation over the band, extended one lag on each side so the
// band edges have real neighbors for the local-max test.
let ext_min = lag_min.saturating_sub(1).max(1);
let ext_max = (lag_max + 1).min(n - 1);
let acc: Vec<f32> = (ext_min..=ext_max)
.map(|lag| (0..(n - lag)).map(|i| sig[i] * sig[i + lag]).sum())
.collect();
let mut best = 0.0f32;
let mut best_lag = 0usize;
for lag in lag_min..=lag_max {
let idx = lag - ext_min;
if idx == 0 || idx + 1 >= acc.len() {
continue; // no neighbor on one side — cannot prove a local max
}
let v = acc[idx];
// Interior local maximum (ties to the left tolerated for plateaus).
if v >= acc[idx - 1] && v > acc[idx + 1] && v > best {
best = v;
best_lag = lag;
}
}
if best_lag == 0 {
return (0.0, 0.0);
}
let score = (best / r0).clamp(0.0, 1.0);
(fs / best_lag as f32, score)
}
#[cfg(test)]
mod tests {
use super::*;
use std::f32::consts::PI;
fn sine(freq_hz: f32, fs: f32, n: usize) -> Vec<f32> {
(0..n)
.map(|i| (2.0 * PI * freq_hz * i as f32 / fs).sin())
.collect()
}
#[test]
fn autocorr_finds_breathing_freq() {
// 0.25 Hz (15 BPM) breathing, sampled at 15 Hz for 20 s.
let fs = 15.0;
let s = sine(0.25, fs, (fs * 20.0) as usize);
let (hz, score) = autocorr_dominant(&s, fs, 0.1, 0.6);
assert!((hz - 0.25).abs() < 0.05, "got {hz}");
assert!(score > 0.5, "score {score}");
}
#[test]
fn autocorr_finds_heart_freq() {
// 1.45 Hz (~87 BPM), sampled at 15 Hz.
let fs = 15.0;
let s = sine(1.45, fs, (fs * 20.0) as usize);
let (hz, _) = autocorr_dominant(&s, fs, 0.8, 3.0);
assert!((hz * 60.0 - 87.0).abs() < 12.0, "got {} bpm", hz * 60.0);
}
#[test]
fn features_capture_breathing() {
let fs = 15.0;
let s = sine(0.3, fs, 300);
let f = Features::from_series(&s, fs);
assert!(f.breathing_score > 0.4);
assert!((f.breathing_hz - 0.3).abs() < 0.06);
}
#[test]
fn motion_distinguishes_still_from_noisy() {
let still = vec![1.0f32; 200];
let noisy: Vec<f32> = (0..200).map(|i| if i % 2 == 0 { 0.0 } else { 5.0 }).collect();
assert!(Features::from_series(&still, 15.0).motion < Features::from_series(&noisy, 15.0).motion);
}
#[test]
fn empty_series_is_safe() {
let f = Features::from_series(&[], 15.0);
assert_eq!(f.mean, 0.0);
assert_eq!(f.breathing_hz, 0.0);
}
/// ADR-152 "heart-band leakage" regression: a strong breathing rhythm must
/// NOT register as a heart-band periodicity — its in-band autocorr maximum
/// sits at the band edge (monotonic leak), not an interior peak.
#[test]
fn heart_band_rejects_breathing_leakage() {
let fs = 20.0;
// Pure 0.30 Hz breathing, no heart component at all.
let s = sine(0.30, fs, (fs * 30.0) as usize);
let (hz, score) = autocorr_dominant(&s, fs, 0.8, 3.0);
assert!(
score < 0.25,
"breathing-only signal scored {score} in the heart band (hz {hz}) — \
the lag-floor leak is back"
);
// The breathing band itself must still find the true rate.
let (bhz, bscore) = autocorr_dominant(&s, fs, 0.1, 0.6);
assert!((bhz - 0.30).abs() < 0.05, "breathing band got {bhz}");
assert!(bscore > 0.5);
}
/// ADR-152 "ungated hz embedding" regression: a low-score in-band peak
/// (noise) must NOT leak its random frequency into the prototype
/// embedding, while a confident peak must pass through unchanged.
#[test]
fn embedding_gates_hz_on_score() {
let noisy = Features {
mean: 1.0,
variance: 2.0,
motion: 0.3,
breathing_score: EMBED_MIN_SCORE - 0.05,
breathing_hz: 0.42, // random in-band peak from a noise window
heart_score: EMBED_MIN_SCORE - 0.05,
heart_hz: 3.3, // breathing leakage pinned at the lag floor
};
let e = noisy.embedding();
assert_eq!(e[3], 0.0, "low-score breathing_hz must be gated out");
assert_eq!(e[4], 0.0, "low-score heart_hz must be gated out");
let confident = Features {
breathing_score: EMBED_MIN_SCORE + 0.3,
heart_score: EMBED_MIN_SCORE + 0.3,
..noisy
};
let e = confident.embedding();
assert_eq!(e[3], 0.42, "confident breathing_hz must pass through");
assert_eq!(e[4], 3.3, "confident heart_hz must pass through");
}
}
@@ -1,37 +0,0 @@
//! # wifi-densepose-calibration — ADR-151 per-room calibration & specialist training
//!
//! "Teach the room before you teach the model." A local-first pipeline that turns
//! a few minutes of clean human anchors — layered on the ADR-135 empty-room
//! baseline — into a versioned bank of small, specialised models for breathing,
//! heartbeat, restlessness, posture, presence, and anomaly.
//!
//! Stages (ADR-151 §1.3):
//! 1. **baseline** — empty-room environmental fingerprint (ADR-135; consumed here).
//! 2. **enroll** — guided anchors with an adaptive quality gate ([`anchor`], [`enrollment`]).
//! 3. **extract** — labelled feature records from anchor captures ([`extract`]).
//! 4. **train** — a bank of small specialist models ([`specialist`], [`bank`]) and a
//! confidence-gated mixture runtime ([`runtime`]).
//!
//! Invariants: specialisation over scale; local-first; honest `STALE` degradation
//! when the baseline drifts.
#![forbid(unsafe_code)]
#![warn(missing_docs)]
pub mod anchor;
pub mod enrollment;
pub mod error;
pub mod extract;
pub mod specialist;
pub mod bank;
pub mod runtime;
pub mod multistatic;
pub use anchor::{Anchor, AnchorLabel, AnchorQuality, EnrollmentEvent, EnrollmentSession, Posture};
pub use bank::SpecialistBank;
pub use enrollment::{AnchorQualityGate, AnchorRecorder};
pub use error::{CalibrationError, Result};
pub use extract::AnchorFeature;
pub use multistatic::MultiNodeMixture;
pub use runtime::{MixtureOfSpecialists, RoomState};
pub use specialist::{Specialist, SpecialistKind, SpecialistReading};
@@ -1,265 +0,0 @@
//! Multistatic fusion (ADR-029 / ADR-151) — combine several *co-located* nodes
//! observing one room.
//!
//! More links = more geometric diversity, so a person hidden from one node's
//! line of sight is caught by another. Each node carries its own room-calibrated
//! [`SpecialistBank`] (its own baseline + anchors); this fuses their per-window
//! readings into a single [`RoomState`]:
//!
//! - **presence** — OR across nodes (any node seeing a person wins);
//! - **posture / breathing / heartbeat** — the highest-*confidence* node (best
//! viewpoint for that signal that window);
//! - **restlessness** — max (any node detecting movement);
//! - **anomaly / veto** — max / any (a single implausible node vetoes the room);
//! - **stale** — any node's bank stale flags the fused result.
//!
//! This is *same-room* multistatic. Nodes in *different* rooms are a federation
//! concern (ADR-105), not fusion — see ADR-151 §3.3.
use std::collections::BTreeMap;
use crate::bank::SpecialistBank;
use crate::extract::Features;
use crate::runtime::{MixtureOfSpecialists, RoomState};
use crate::specialist::SpecialistReading;
/// A bank plus the node's current baseline id (for per-node staleness).
struct NodeEntry {
mixture: MixtureOfSpecialists,
baseline_id: String,
}
/// Fuses co-located nodes' specialist banks into one room state.
#[derive(Default)]
pub struct MultiNodeMixture {
nodes: BTreeMap<u8, NodeEntry>,
}
impl MultiNodeMixture {
/// Empty fusion set.
pub fn new() -> Self {
Self {
nodes: BTreeMap::new(),
}
}
/// Register a node's bank. `current_baseline_id` is the baseline the node is
/// observing now (drift vs the bank's training baseline → STALE).
pub fn add_node(&mut self, node_id: u8, bank: SpecialistBank, current_baseline_id: impl Into<String>) {
self.nodes.insert(
node_id,
NodeEntry {
mixture: MixtureOfSpecialists::new(bank),
baseline_id: current_baseline_id.into(),
},
);
}
/// Number of registered nodes.
pub fn node_count(&self) -> usize {
self.nodes.len()
}
/// Fuse per-node feature windows into one room state. Nodes without a feature
/// entry this window are skipped.
pub fn infer(&self, per_node: &BTreeMap<u8, Features>) -> RoomState {
let states: Vec<RoomState> = per_node
.iter()
.filter_map(|(id, f)| {
self.nodes
.get(id)
.map(|e| e.mixture.infer(f, &e.baseline_id))
})
.collect();
if states.is_empty() {
return RoomState::default();
}
let presence = fuse_presence(&states);
let anomaly = max_value(states.iter().map(|s| &s.anomaly));
// Conservative: a single node seeing a physically-implausible signal
// vetoes the room (anti-hallucination, same as the single-node runtime).
let vetoed = states.iter().any(|s| s.vetoed);
let present = presence.as_ref().map(|r| r.value > 0.5).unwrap_or(true);
// Vitals/posture only when present and not vetoed.
let (posture, breathing, heartbeat) = if present && !vetoed {
(
best_confidence(states.iter().map(|s| &s.posture)),
best_confidence(states.iter().map(|s| &s.breathing)),
best_confidence(states.iter().map(|s| &s.heartbeat)),
)
} else {
(None, None, None)
};
RoomState {
presence,
posture,
breathing,
heartbeat,
restlessness: max_value(states.iter().map(|s| &s.restlessness)),
anomaly,
vetoed,
stale: states.iter().any(|s| s.stale),
}
}
}
/// Presence: a person is present if ANY node sees one; confidence = max.
fn fuse_presence(states: &[RoomState]) -> Option<SpecialistReading> {
let readings: Vec<&SpecialistReading> = states.iter().filter_map(|s| s.presence.as_ref()).collect();
if readings.is_empty() {
return None;
}
let any_present = readings.iter().any(|r| r.value > 0.5);
let confidence = readings
.iter()
.map(|r| r.confidence)
.fold(0.0f32, f32::max);
Some(SpecialistReading {
kind: readings[0].kind,
value: if any_present { 1.0 } else { 0.0 },
confidence,
label: Some(if any_present { "present" } else { "absent" }.into()),
})
}
/// Pick the highest-confidence reading across nodes.
fn best_confidence<'a>(
readings: impl Iterator<Item = &'a Option<SpecialistReading>>,
) -> Option<SpecialistReading> {
readings
.flatten()
.fold(None::<&SpecialistReading>, |best, r| match best {
Some(b) if b.confidence >= r.confidence => Some(b),
_ => Some(r),
})
.cloned()
}
/// Pick the reading with the maximum value across nodes (movement / anomaly).
fn max_value<'a>(
readings: impl Iterator<Item = &'a Option<SpecialistReading>>,
) -> Option<SpecialistReading> {
readings
.flatten()
.fold(None::<&SpecialistReading>, |best, r| match best {
Some(b) if b.value >= r.value => Some(b),
_ => Some(r),
})
.cloned()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::anchor::AnchorLabel;
use crate::extract::AnchorFeature;
fn af(label: AnchorLabel, variance: f32, motion: f32) -> AnchorFeature {
AnchorFeature {
room_id: "r".into(),
label,
features: Features {
mean: 1.0,
variance,
motion,
breathing_score: 0.0,
breathing_hz: 0.0,
heart_score: 0.0,
heart_hz: 0.0,
},
}
}
fn bank(baseline: &str) -> SpecialistBank {
let anchors = vec![
af(AnchorLabel::Empty, 1.0, 0.1),
af(AnchorLabel::StandStill, 10.0, 0.2),
af(AnchorLabel::Sit, 6.0, 0.2),
af(AnchorLabel::SmallMove, 4.0, 1.2),
af(AnchorLabel::SleepPosture, 3.0, 0.1),
];
SpecialistBank::train("r", baseline, &anchors, 1).unwrap()
}
fn live(variance: f32, motion: f32, br_hz: f32, br_score: f32) -> Features {
Features {
mean: 1.0,
variance,
motion,
breathing_score: br_score,
breathing_hz: br_hz,
heart_score: 0.0,
heart_hz: 0.0,
}
}
#[test]
fn two_nodes_register() {
let mut m = MultiNodeMixture::new();
m.add_node(1, bank("b1"), "b1");
m.add_node(2, bank("b2"), "b2");
assert_eq!(m.node_count(), 2);
}
#[test]
fn presence_or_across_nodes() {
let mut m = MultiNodeMixture::new();
m.add_node(1, bank("b1"), "b1");
m.add_node(2, bank("b1"), "b1");
// Node 1 sees nobody (low variance), node 2 sees a person (high variance).
let mut per = BTreeMap::new();
per.insert(1u8, live(1.0, 0.1, 0.0, 0.0));
per.insert(2u8, live(12.0, 0.2, 0.3, 0.9));
let s = m.infer(&per);
assert_eq!(s.presence.unwrap().value, 1.0, "any node present → present");
assert!(s.breathing.is_some());
}
#[test]
fn breathing_picks_best_confidence_node() {
let mut m = MultiNodeMixture::new();
m.add_node(1, bank("b1"), "b1");
m.add_node(2, bank("b1"), "b1");
let mut per = BTreeMap::new();
// Both present; node 2 has the stronger breathing periodicity.
per.insert(1u8, live(12.0, 0.2, 0.2, 0.4));
per.insert(2u8, live(12.0, 0.2, 0.3, 0.95));
let s = m.infer(&per);
let br = s.breathing.unwrap();
assert!((br.value - 18.0).abs() < 0.3, "picked 0.3 Hz node");
assert!(br.confidence > 0.9);
}
#[test]
fn anomaly_in_one_node_vetoes_room() {
let mut m = MultiNodeMixture::new();
m.add_node(1, bank("b1"), "b1");
m.add_node(2, bank("b1"), "b1");
let mut per = BTreeMap::new();
per.insert(1u8, live(12.0, 0.2, 0.3, 0.9));
per.insert(2u8, live(9000.0, 500.0, 0.0, 0.0)); // wild outlier
let s = m.infer(&per);
assert!(s.vetoed);
assert!(s.breathing.is_none());
}
#[test]
fn stale_node_flags_room() {
let mut m = MultiNodeMixture::new();
m.add_node(1, bank("b1"), "b2"); // trained on b1, now observing b2 → stale
let mut per = BTreeMap::new();
per.insert(1u8, live(12.0, 0.2, 0.3, 0.9));
assert!(m.infer(&per).stale);
}
#[test]
fn empty_window_safe() {
let m = MultiNodeMixture::new();
let s = m.infer(&BTreeMap::new());
assert!(s.presence.is_none());
}
}
@@ -1,178 +0,0 @@
//! Mixture-of-specialists runtime (ADR-151 §2.5).
//!
//! Every specialist consumes the same live feature window and emits a
//! `{value, confidence}`. Fusion rules keep the output honest:
//! - the **anomaly** specialist holds a veto — a physically-implausible window
//! suppresses positive vitals/posture rather than propagating a hallucination;
//! - **presence = absent** short-circuits breathing/heartbeat/posture to `None`
//! (you cannot have a respiration rate in an empty room);
//! - a **STALE** bank (baseline drift) flags every reading.
use serde::{Deserialize, Serialize};
use crate::bank::SpecialistBank;
use crate::extract::Features;
use crate::specialist::{Specialist, SpecialistReading};
/// Fused room state for one feature window.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RoomState {
/// Presence reading.
pub presence: Option<SpecialistReading>,
/// Posture reading.
pub posture: Option<SpecialistReading>,
/// Breathing reading (BPM).
pub breathing: Option<SpecialistReading>,
/// Heartbeat reading (BPM).
pub heartbeat: Option<SpecialistReading>,
/// Restlessness reading [0, 1].
pub restlessness: Option<SpecialistReading>,
/// Anomaly reading [0, 1].
pub anomaly: Option<SpecialistReading>,
/// Anomaly veto fired — vitals/posture suppressed.
pub vetoed: bool,
/// Bank is stale (baseline drift) — readings are not trustworthy.
pub stale: bool,
}
/// Confidence-gated mixture over a [`SpecialistBank`].
pub struct MixtureOfSpecialists {
bank: SpecialistBank,
/// Anomaly score above which vitals/posture are vetoed.
pub veto_threshold: f32,
}
impl MixtureOfSpecialists {
/// Wrap a bank with the default veto threshold (0.5).
pub fn new(bank: SpecialistBank) -> Self {
Self {
bank,
veto_threshold: 0.5,
}
}
/// The underlying bank.
pub fn bank(&self) -> &SpecialistBank {
&self.bank
}
/// Infer fused room state, marking `stale` if the bank was trained against a
/// different baseline than `current_baseline_id`.
pub fn infer(&self, f: &Features, current_baseline_id: &str) -> RoomState {
let mut state = RoomState {
stale: self.bank.is_stale(current_baseline_id),
..Default::default()
};
// Anomaly first — it can veto everything else.
state.anomaly = self.bank.anomaly.as_ref().and_then(|a| a.infer(f));
let vetoed = state
.anomaly
.as_ref()
.map(|r| r.value >= self.veto_threshold)
.unwrap_or(false);
state.vetoed = vetoed;
// Presence gate.
state.presence = self.bank.presence.as_ref().and_then(|p| p.infer(f));
let present = state
.presence
.as_ref()
.map(|r| r.value > 0.5)
// No presence specialist → assume present so vitals still run.
.unwrap_or(true);
// Restlessness is reported regardless of presence (movement implies presence).
state.restlessness = self.bank.restlessness.as_ref().and_then(|r| r.infer(f));
// Vitals + posture only when present and not vetoed.
if present && !vetoed {
state.posture = self.bank.posture.as_ref().and_then(|p| p.infer(f));
state.breathing = self.bank.breathing.infer(f);
state.heartbeat = self.bank.heartbeat.infer(f);
}
state
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::anchor::AnchorLabel;
use crate::extract::{AnchorFeature, Features};
fn af(label: AnchorLabel, variance: f32, motion: f32) -> AnchorFeature {
AnchorFeature {
room_id: "r".into(),
label,
features: Features {
mean: 1.0,
variance,
motion,
breathing_score: 0.0,
breathing_hz: 0.0,
heart_score: 0.0,
heart_hz: 0.0,
},
}
}
fn bank() -> SpecialistBank {
let anchors = vec![
af(AnchorLabel::Empty, 1.0, 0.1),
af(AnchorLabel::StandStill, 10.0, 0.2),
af(AnchorLabel::Sit, 6.0, 0.2),
af(AnchorLabel::LieDown, 3.0, 0.2),
af(AnchorLabel::SmallMove, 4.0, 1.2),
af(AnchorLabel::SleepPosture, 3.0, 0.1),
];
SpecialistBank::train("r", "base-1", &anchors, 1000).unwrap()
}
fn live(variance: f32, motion: f32, br_hz: f32, br_score: f32) -> Features {
Features {
mean: 1.0,
variance,
motion,
breathing_score: br_score,
breathing_hz: br_hz,
heart_score: 0.0,
heart_hz: 0.0,
}
}
#[test]
fn empty_room_suppresses_vitals() {
let mix = MixtureOfSpecialists::new(bank());
let s = mix.infer(&live(1.0, 0.1, 0.3, 0.9), "base-1");
assert_eq!(s.presence.unwrap().value, 0.0);
assert!(s.breathing.is_none(), "no breathing in an empty room");
assert!(s.posture.is_none());
}
#[test]
fn present_room_reports_breathing() {
let mix = MixtureOfSpecialists::new(bank());
let s = mix.infer(&live(10.0, 0.2, 0.3, 0.9), "base-1");
assert_eq!(s.presence.unwrap().value, 1.0);
let br = s.breathing.unwrap();
assert!((br.value - 18.0).abs() < 0.2);
}
#[test]
fn anomaly_vetoes_vitals() {
let mix = MixtureOfSpecialists::new(bank());
// Wildly out-of-distribution window → anomaly veto.
let s = mix.infer(&live(5000.0, 200.0, 0.3, 0.9), "base-1");
assert!(s.vetoed);
assert!(s.breathing.is_none());
}
#[test]
fn stale_bank_flagged() {
let mix = MixtureOfSpecialists::new(bank());
let s = mix.infer(&live(10.0, 0.2, 0.3, 0.9), "base-2");
assert!(s.stale);
}
}
@@ -1,525 +0,0 @@
//! Specialist models (ADR-151 Stage 4).
//!
//! One small, room-calibrated model per biological signal — *specialisation over
//! scale*. Each is fit from the labelled enrollment anchors and is tiny: a
//! threshold, a handful of nearest-prototype vectors, or a band-limited
//! periodicity read. Faster, cheaper, more private, and — because it is tuned to
//! this room's fingerprint — often better than one oversized general model.
//!
//! (ADR-151's frozen Hugging-Face RF Foundation Encoder backbone is the planned
//! upgrade path: these heads would then sit over a shared embedding. The
//! statistical heads here make the pipeline runnable and validatable today.)
use serde::{Deserialize, Serialize};
use crate::anchor::{AnchorLabel, Posture};
use crate::extract::{AnchorFeature, Features};
/// Which biological signal a specialist estimates.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SpecialistKind {
/// Respiration rate.
Breathing,
/// Heart rate (experimental on commodity CSI).
Heartbeat,
/// Sleep restlessness / movement intensity.
Restlessness,
/// Body posture (standing / sitting / lying).
Posture,
/// Presence (room occupied or not).
Presence,
/// Physically-implausible / out-of-distribution signal.
Anomaly,
}
/// A single specialist's output.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SpecialistReading {
/// Which specialist.
pub kind: SpecialistKind,
/// Numeric value (BPM, score, or class index — see [`SpecialistReading::label`]).
pub value: f32,
/// Confidence in `[0, 1]`.
pub confidence: f32,
/// Optional human-readable label (e.g. posture class).
pub label: Option<String>,
}
/// Common specialist behaviour.
pub trait Specialist {
/// Which signal this estimates.
fn kind(&self) -> SpecialistKind;
/// Infer from a live feature window; `None` when not applicable / no confidence.
fn infer(&self, f: &Features) -> Option<SpecialistReading>;
}
// ---------------------------------------------------------------------------
// Presence
// ---------------------------------------------------------------------------
/// Binary presence gate learned from empty vs occupied anchors.
///
/// Two complementary signals (ADR-152 finding, "variance-only presence"):
/// - **variance** — motion/occupancy energy; catches a moving person but is
/// blind to a *motionless* one, whose body raises the scalar *mean* (extra
/// multipath energy) while barely raising variance;
/// - **mean shift** — |mean empty-room mean|; catches the motionless person
/// the variance channel misses. Symmetric (abs) because a body can shadow
/// paths and *lower* the mean too.
///
/// Present when EITHER channel fires.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PresenceSpecialist {
/// Decision threshold on series variance.
pub threshold: f32,
/// Occupied-anchor mean variance (for confidence scaling).
pub occupied_var: f32,
/// Empty-room mean of the scalar series (mean-shift reference).
#[serde(default)]
pub empty_mean: f32,
/// |mean empty_mean| beyond which the mean alone indicates presence.
/// `None` disables the channel — both for banks persisted before the
/// channel existed (serde default) and for rooms where the empty/occupied
/// means don't separate at train time.
#[serde(default)]
pub mean_dist_threshold: Option<f32>,
}
impl PresenceSpecialist {
/// Fit from anchors: variance threshold at the midpoint between the empty
/// variance and the mean occupied variance; mean-shift threshold at half
/// the empty→occupied mean distance (inert when the means don't separate).
pub fn train(anchors: &[AnchorFeature]) -> Option<Self> {
let empty = anchors.iter().find(|a| a.label == AnchorLabel::Empty)?;
let occ: Vec<&Features> = anchors
.iter()
.filter(|a| a.label.expects_presence())
.map(|a| &a.features)
.collect();
if occ.is_empty() {
return None;
}
let occ_var = occ.iter().map(|f| f.variance).sum::<f32>() / occ.len() as f32;
let occ_mean = occ.iter().map(|f| f.mean).sum::<f32>() / occ.len() as f32;
let empty_var = empty.features.variance;
let empty_mean = empty.features.mean;
let mean_dist = (occ_mean - empty_mean).abs();
let mean_dist_threshold = (mean_dist > 1e-4).then(|| 0.5 * mean_dist);
Some(Self {
threshold: 0.5 * (empty_var + occ_var),
occupied_var: occ_var.max(empty_var + 1e-3),
empty_mean,
mean_dist_threshold,
})
}
}
impl Specialist for PresenceSpecialist {
fn kind(&self) -> SpecialistKind {
SpecialistKind::Presence
}
fn infer(&self, f: &Features) -> Option<SpecialistReading> {
let by_variance = f.variance > self.threshold;
let mean_dist = (f.mean - self.empty_mean).abs();
let by_mean = self
.mean_dist_threshold
.is_some_and(|thr| mean_dist > thr);
let present = by_variance || by_mean;
// Confidence: strongest margin among the channels that are enabled.
let var_span = (self.occupied_var - self.threshold).max(1e-3);
let var_conf = ((f.variance - self.threshold).abs() / var_span).clamp(0.0, 1.0);
let mean_conf = self
.mean_dist_threshold
.map(|thr| ((mean_dist - thr).abs() / thr.max(1e-3)).clamp(0.0, 1.0))
.unwrap_or(0.0);
let confidence = var_conf.max(mean_conf);
Some(SpecialistReading {
kind: SpecialistKind::Presence,
value: if present { 1.0 } else { 0.0 },
confidence,
label: Some(if present { "present" } else { "absent" }.into()),
})
}
}
// ---------------------------------------------------------------------------
// Posture (nearest-prototype)
// ---------------------------------------------------------------------------
/// Posture classifier: nearest prototype over the feature embedding.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PostureSpecialist {
/// `(posture, embedding)` prototypes from the posture anchors.
pub prototypes: Vec<(Posture, [f32; 5])>,
}
impl PostureSpecialist {
/// Fit prototypes from any anchor that establishes a posture.
pub fn train(anchors: &[AnchorFeature]) -> Option<Self> {
let prototypes: Vec<(Posture, [f32; 5])> = anchors
.iter()
.filter_map(|a| a.label.posture().map(|p| (p, a.features.embedding())))
.collect();
if prototypes.is_empty() {
None
} else {
Some(Self { prototypes })
}
}
fn posture_str(p: Posture) -> &'static str {
match p {
Posture::Standing => "standing",
Posture::Sitting => "sitting",
Posture::Lying => "lying",
}
}
}
impl Specialist for PostureSpecialist {
fn kind(&self) -> SpecialistKind {
SpecialistKind::Posture
}
fn infer(&self, f: &Features) -> Option<SpecialistReading> {
let emb = f.embedding();
let mut best = (f32::MAX, Posture::Standing);
let mut second = f32::MAX;
for (p, proto) in &self.prototypes {
let d: f32 = emb.iter().zip(proto).map(|(a, b)| (a - b) * (a - b)).sum();
if d < best.0 {
second = best.0;
best = (d, *p);
} else if d < second {
second = d;
}
}
// Confidence from the margin between nearest and runner-up.
let confidence = if second.is_finite() && (best.0 + second) > 1e-6 {
((second - best.0) / (second + best.0)).clamp(0.0, 1.0)
} else {
0.5
};
Some(SpecialistReading {
kind: SpecialistKind::Posture,
value: best.1 as u8 as f32,
confidence,
label: Some(Self::posture_str(best.1).into()),
})
}
}
// ---------------------------------------------------------------------------
// Breathing / Heartbeat (band-limited periodicity)
// ---------------------------------------------------------------------------
/// Respiration-rate read from the breathing-band periodicity.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BreathingSpecialist {
/// Minimum periodicity score to report a rate.
pub min_score: f32,
}
impl Specialist for BreathingSpecialist {
fn kind(&self) -> SpecialistKind {
SpecialistKind::Breathing
}
fn infer(&self, f: &Features) -> Option<SpecialistReading> {
let min = if self.min_score > 0.0 { self.min_score } else { 0.25 };
if f.breathing_score < min || f.breathing_hz <= 0.0 {
return None;
}
Some(SpecialistReading {
kind: SpecialistKind::Breathing,
value: f.breathing_hz * 60.0,
confidence: f.breathing_score,
label: None,
})
}
}
/// Heart-rate read from the HR-band periodicity (experimental on CSI).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HeartbeatSpecialist {
/// Minimum periodicity score to report a rate.
pub min_score: f32,
}
impl Specialist for HeartbeatSpecialist {
fn kind(&self) -> SpecialistKind {
SpecialistKind::Heartbeat
}
fn infer(&self, f: &Features) -> Option<SpecialistReading> {
let min = if self.min_score > 0.0 { self.min_score } else { 0.3 };
if f.heart_score < min || f.heart_hz <= 0.0 {
return None;
}
Some(SpecialistReading {
kind: SpecialistKind::Heartbeat,
value: f.heart_hz * 60.0,
confidence: f.heart_score,
label: None,
})
}
}
// ---------------------------------------------------------------------------
// Restlessness
// ---------------------------------------------------------------------------
/// Restlessness: live motion normalized between the calm (sleep) and active
/// (small-move) anchors.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RestlessnessSpecialist {
/// Motion at rest (sleep posture).
pub calm_motion: f32,
/// Motion when actively moving.
pub active_motion: f32,
}
impl RestlessnessSpecialist {
/// Fit from the sleep-posture (calm) and small-move (active) anchors.
pub fn train(anchors: &[AnchorFeature]) -> Option<Self> {
let calm = anchors
.iter()
.find(|a| a.label == AnchorLabel::SleepPosture)
.or_else(|| anchors.iter().find(|a| a.label == AnchorLabel::LieDown))?
.features
.motion;
let active = anchors
.iter()
.find(|a| a.label == AnchorLabel::SmallMove)?
.features
.motion;
if active <= calm {
return None;
}
Some(Self {
calm_motion: calm,
active_motion: active,
})
}
}
impl Specialist for RestlessnessSpecialist {
fn kind(&self) -> SpecialistKind {
SpecialistKind::Restlessness
}
fn infer(&self, f: &Features) -> Option<SpecialistReading> {
let span = (self.active_motion - self.calm_motion).max(1e-3);
let r = ((f.motion - self.calm_motion) / span).clamp(0.0, 1.0);
Some(SpecialistReading {
kind: SpecialistKind::Restlessness,
value: r,
confidence: 0.7,
label: None,
})
}
}
// ---------------------------------------------------------------------------
// Anomaly (novelty vs anchor prototypes)
// ---------------------------------------------------------------------------
/// Anomaly detector: distance from the manifold of enrolled anchors. A live
/// window far from every anchor prototype is out-of-distribution.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnomalySpecialist {
/// Anchor embeddings (the in-distribution manifold).
pub prototypes: Vec<[f32; 5]>,
/// Distance scale (typical inter-anchor spread) for normalization.
pub scale: f32,
}
impl AnomalySpecialist {
/// Fit from all anchor embeddings.
pub fn train(anchors: &[AnchorFeature]) -> Option<Self> {
if anchors.len() < 2 {
return None;
}
let prototypes: Vec<[f32; 5]> = anchors.iter().map(|a| a.features.embedding()).collect();
// Scale = mean nearest-neighbour distance among prototypes.
let mut nn_sum = 0.0f32;
for (i, p) in prototypes.iter().enumerate() {
let mut best = f32::MAX;
for (j, q) in prototypes.iter().enumerate() {
if i == j {
continue;
}
let d: f32 = p.iter().zip(q).map(|(a, b)| (a - b) * (a - b)).sum();
best = best.min(d);
}
if best.is_finite() {
nn_sum += best.sqrt();
}
}
let scale = (nn_sum / prototypes.len() as f32).max(1e-3);
Some(Self { prototypes, scale })
}
}
impl Specialist for AnomalySpecialist {
fn kind(&self) -> SpecialistKind {
SpecialistKind::Anomaly
}
fn infer(&self, f: &Features) -> Option<SpecialistReading> {
let emb = f.embedding();
let mut best = f32::MAX;
for proto in &self.prototypes {
let d: f32 = emb
.iter()
.zip(proto)
.map(|(a, b)| (a - b) * (a - b))
.sum::<f32>()
.sqrt();
best = best.min(d);
}
// >2× the typical spread → anomalous.
let score = (best / (2.0 * self.scale)).clamp(0.0, 1.0);
Some(SpecialistReading {
kind: SpecialistKind::Anomaly,
value: score,
confidence: 0.6,
label: Some(if score > 0.5 { "anomalous" } else { "normal" }.into()),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn feat(variance: f32, motion: f32, br_hz: f32, br_score: f32) -> Features {
Features {
mean: 1.0,
variance,
motion,
breathing_score: br_score,
breathing_hz: br_hz,
heart_score: 0.0,
heart_hz: 0.0,
}
}
fn af(label: AnchorLabel, variance: f32, motion: f32) -> AnchorFeature {
AnchorFeature {
room_id: "r".into(),
label,
features: feat(variance, motion, 0.0, 0.0),
}
}
/// Like `feat` but with an explicit series mean (the presence mean-gate input).
fn feat_mean(mean: f32, variance: f32, motion: f32) -> Features {
Features {
mean,
variance,
motion,
breathing_score: 0.0,
breathing_hz: 0.0,
heart_score: 0.0,
heart_hz: 0.0,
}
}
fn af_mean(label: AnchorLabel, mean: f32, variance: f32, motion: f32) -> AnchorFeature {
AnchorFeature {
room_id: "r".into(),
label,
features: feat_mean(mean, variance, motion),
}
}
#[test]
fn presence_learns_threshold_and_classifies() {
let anchors = vec![
af(AnchorLabel::Empty, 1.0, 0.1),
af(AnchorLabel::StandStill, 10.0, 0.2),
];
let p = PresenceSpecialist::train(&anchors).unwrap();
assert!(p.infer(&feat(12.0, 0.2, 0.0, 0.0)).unwrap().value == 1.0);
assert!(p.infer(&feat(1.0, 0.1, 0.0, 0.0)).unwrap().value == 0.0);
}
/// ADR-152 "variance-only presence" regression: a MOTIONLESS person raises
/// the scalar mean (extra multipath energy) but barely the variance — the
/// mean channel must still detect them, and a window matching the empty
/// room on BOTH channels must still read absent.
#[test]
fn presence_detects_motionless_person_via_mean_shift() {
let anchors = vec![
af_mean(AnchorLabel::Empty, 1.0, 1.0, 0.1),
af_mean(AnchorLabel::StandStill, 1.6, 10.0, 0.2),
af_mean(AnchorLabel::LieDown, 1.5, 8.0, 0.15),
];
let p = PresenceSpecialist::train(&anchors).unwrap();
// Motionless person: variance at the empty level, mean shifted.
let r = p.infer(&feat_mean(1.55, 1.0, 0.05)).unwrap();
assert_eq!(r.value, 1.0, "motionless person must read present");
// Truly empty window: both channels quiet.
let r = p.infer(&feat_mean(1.0, 1.0, 0.05)).unwrap();
assert_eq!(r.value, 0.0, "empty room must still read absent");
}
/// Banks persisted BEFORE the mean gate existed must deserialize to the
/// inert (+∞) gate and keep their original variance-only behavior.
#[test]
fn presence_old_bank_json_stays_variance_only() {
let old_json = r#"{"threshold":5.5,"occupied_var":10.0}"#;
let p: PresenceSpecialist = serde_json::from_str(old_json).unwrap();
assert!(p.mean_dist_threshold.is_none());
// Mean wildly shifted but variance below threshold → still absent
// (old behavior preserved; the mean channel is disabled).
let r = p.infer(&feat_mean(99.0, 1.0, 0.05)).unwrap();
assert_eq!(r.value, 0.0);
}
#[test]
fn posture_nearest_prototype() {
let anchors = vec![
af(AnchorLabel::StandStill, 10.0, 0.2),
af(AnchorLabel::Sit, 6.0, 0.2),
af(AnchorLabel::LieDown, 3.0, 0.2),
];
let post = PostureSpecialist::train(&anchors).unwrap();
// A window close to the standing prototype.
let r = post.infer(&feat(10.1, 0.2, 0.0, 0.0)).unwrap();
assert_eq!(r.label.as_deref(), Some("standing"));
}
#[test]
fn breathing_reports_bpm() {
let b = BreathingSpecialist::default();
let r = b.infer(&feat(5.0, 0.2, 0.3, 0.8)).unwrap();
assert!((r.value - 18.0).abs() < 0.1); // 0.3 Hz = 18 BPM
assert!(r.confidence > 0.5);
assert!(b.infer(&feat(5.0, 0.2, 0.3, 0.1)).is_none()); // low score → none
}
#[test]
fn restlessness_normalizes() {
let anchors = vec![
af(AnchorLabel::SleepPosture, 3.0, 0.1),
af(AnchorLabel::SmallMove, 3.0, 1.1),
];
let rs = RestlessnessSpecialist::train(&anchors).unwrap();
assert!(rs.infer(&feat(3.0, 0.1, 0.0, 0.0)).unwrap().value < 0.1);
assert!(rs.infer(&feat(3.0, 1.1, 0.0, 0.0)).unwrap().value > 0.9);
}
#[test]
fn anomaly_flags_outliers() {
let anchors = vec![
af(AnchorLabel::Empty, 1.0, 0.1),
af(AnchorLabel::StandStill, 10.0, 0.2),
af(AnchorLabel::Sit, 6.0, 0.2),
];
let a = AnomalySpecialist::train(&anchors).unwrap();
// Far-out window.
let r = a.infer(&feat(500.0, 50.0, 0.0, 0.0)).unwrap();
assert!(r.value > 0.5, "score {}", r.value);
}
}
@@ -1,437 +0,0 @@
//! Full-loop integration test for the ADR-151 calibration pipeline (software half
//! of the §7 validation gap): a clean empty-room **baseline → enroll → extract →
//! train → infer** loop, driven end-to-end through the crates' public API in the
//! exact order the CLI (`calibrate` → `enroll` → `train-room` → `room-watch`)
//! wires the stages.
//!
//! CSI is synthetic but physically plausible:
//! - **empty room**: stable per-subcarrier amplitudes + small complex Gaussian
//! noise (the ADR-135 roundtrip-test fingerprint) — never motion-flagged;
//! - **person present**: a common amplitude offset (extra multipath energy),
//! small body sway, and a constant phase shift. Presence strength is free to
//! exceed z = 2.0 — since the ADR-152 z-band-squeeze fix, anchor motion is
//! measured from frame-to-frame deltas, not from the absolute deviation, so
//! a strongly-reflecting *still* person is no longer misread as "moving";
//! - **breathing**: a few-percent periodic amplitude modulation (0.1250.3 Hz)
//! on a subset of subcarriers — visible in the mean-amplitude scalar the CLI
//! uses, invisible to the per-frame *median* z (so still anchors stay still);
//! - **small movement**: per-frame amplitude jitter + a phase wobble that swings
//! past the π/6 drift threshold.
//!
//! Deterministic (xorshift32, fixed seeds), no I/O, no hardware. What remains
//! hardware-only is the on-target run with real ESP32 CSI and a live operator.
use std::f32::consts::PI;
use ndarray::Array2;
use num_complex::Complex64;
use wifi_densepose_calibration::extract::Features;
use wifi_densepose_calibration::{
AnchorFeature, AnchorLabel, AnchorQualityGate, AnchorRecorder, EnrollmentEvent,
EnrollmentSession, MixtureOfSpecialists, SpecialistBank, SpecialistKind,
};
use wifi_densepose_core::types::{AntennaConfig, CsiFrame, CsiMetadata, DeviceId, FrequencyBand};
use wifi_densepose_signal::{BaselineCalibration, CalibrationConfig, CalibrationRecorder};
// ---------------------------------------------------------------------------
// Deterministic PRNG (xorshift32 + Box-Muller) — same pattern as
// wifi-densepose-signal/tests/calibration_roundtrip.rs.
// ---------------------------------------------------------------------------
struct Rng(u32);
impl Rng {
fn new(seed: u32) -> Self {
assert_ne!(seed, 0, "xorshift seed must be non-zero");
Self(seed)
}
fn next_u32(&mut self) -> u32 {
let mut x = self.0;
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
self.0 = x;
x
}
fn next_normal(&mut self) -> f32 {
let u1 = (self.next_u32() as f32 + 1.0) / (u32::MAX as f32 + 2.0);
let u2 = (self.next_u32() as f32 + 1.0) / (u32::MAX as f32 + 2.0);
(-2.0 * u1.ln()).sqrt() * (2.0 * PI * u2).cos()
}
}
// ---------------------------------------------------------------------------
// Synthetic room (HT20: 52 active subcarriers @ 20 Hz)
// ---------------------------------------------------------------------------
const N_SC: usize = 52;
const FS_HZ: f32 = 20.0;
/// Complex-noise std per quadrature ⇒ amplitude noise std ≈ NOISE_STD.
const NOISE_STD: f32 = 0.01;
/// Capture length per enrollment anchor (20 s @ 20 Hz; gate needs ≥ 60).
const ANCHOR_FRAMES: usize = 400;
/// Baseline / runtime window length (30 s @ 20 Hz; recorder needs ≥ 600).
const WINDOW_FRAMES: usize = 600;
/// What the person in the room is doing (None ⇒ empty room).
#[derive(Clone, Copy, Default)]
struct Person {
/// Common amplitude offset in units of NOISE_STD (presence strength).
/// Anything ≥ 1.5 reads as present; values above 2.0 are explicitly
/// exercised to guard the ADR-152 z-band-squeeze fix (presence strength
/// must not read as motion).
presence_z: f32,
/// Per-frame common amplitude jitter (body sway / fidgeting), in NOISE_STD.
sway_z: f32,
/// Respiration rate (Hz); 0 = no modulation.
breathing_hz: f32,
/// Relative amplitude-modulation depth on every 4th subcarrier.
breathing_depth: f32,
/// Constant phase shift from the body's multipath (radians).
phase_shift: f32,
/// Phase-wobble amplitude (radians) at 1.5 Hz — drives the motion flag.
phase_wobble: f32,
}
/// Deterministic CSI source for one room. Time advances one frame per call.
struct RoomSim {
rng: Rng,
/// Static per-subcarrier amplitude fingerprint.
amp: Vec<f32>,
/// Static per-subcarrier phase fingerprint.
phase: Vec<f32>,
/// Frame counter (continuous room clock).
t: u64,
}
impl RoomSim {
fn new(seed: u32) -> Self {
// Same HT20 fingerprint as the ADR-135 roundtrip test.
let amp = (0..N_SC)
.map(|k| 0.3 + 0.7 * (k as f32 * PI / N_SC as f32).sin().abs())
.collect();
let phase = (0..N_SC)
.map(|k| (k as f32 * 0.1).rem_euclid(2.0 * PI) - PI)
.collect();
Self { rng: Rng::new(seed), amp, phase, t: 0 }
}
/// Generate the next CSI frame for the given occupancy.
fn frame(&mut self, person: Option<&Person>) -> CsiFrame {
let secs = self.t as f32 / FS_HZ;
let (offset, wobble) = match person {
Some(p) => {
let sway = p.sway_z * NOISE_STD * self.rng.next_normal();
(
p.presence_z * NOISE_STD + sway,
p.phase_shift + p.phase_wobble * (2.0 * PI * 1.5 * secs).sin(),
)
}
None => (0.0, 0.0),
};
let mut data = Array2::<Complex64>::zeros((1, N_SC));
for k in 0..N_SC {
let mut a = self.amp[k] + offset;
if let Some(p) = person {
if p.breathing_hz > 0.0 && k % 4 == 0 {
a *= 1.0 + p.breathing_depth * (2.0 * PI * p.breathing_hz * secs).sin();
}
}
let th = self.phase[k] + wobble;
let re = a * th.cos() + NOISE_STD * self.rng.next_normal();
let im = a * th.sin() + NOISE_STD * self.rng.next_normal();
data[(0, k)] = Complex64::new(re as f64, im as f64);
}
let mut meta =
CsiMetadata::new(DeviceId::new("full-loop-test"), FrequencyBand::Band2_4GHz, 6);
meta.bandwidth_mhz = 20;
meta.antenna_config = AntennaConfig::new(1, 1);
self.t += 1;
CsiFrame::new(meta, data)
}
}
/// Per-frame scalar — mean amplitude across subcarriers/streams, the same
/// carrier the CLI's `frame_scalar` feeds into `Features::from_series`.
fn frame_scalar(frame: &CsiFrame) -> f32 {
frame.mean_amplitude() as f32
}
/// Synthetic occupancy for each guided anchor in the canonical sequence.
fn anchor_person(label: AnchorLabel) -> Option<Person> {
let p = match label {
AnchorLabel::Empty => return None,
// Strong reflector at z = 3.0 — every frame exceeds the baseline's
// absolute motion threshold (z > 2.0). Pre-ADR-152 this anchor was
// unenrollable ("too much motion"); the delta-based gate must accept it.
AnchorLabel::StandStill => Person {
presence_z: 3.0, sway_z: 0.25, phase_shift: 0.10, ..Default::default()
},
AnchorLabel::Sit => Person {
presence_z: 1.65, sway_z: 0.25, phase_shift: 0.08, ..Default::default()
},
AnchorLabel::LieDown => Person {
presence_z: 1.6, sway_z: 0.25, phase_shift: 0.06, ..Default::default()
},
AnchorLabel::BreatheSlow => Person {
presence_z: 1.7, sway_z: 0.2, breathing_hz: 0.125, breathing_depth: 0.03,
phase_shift: 0.08, ..Default::default()
},
AnchorLabel::BreatheNormal => Person {
presence_z: 1.7, sway_z: 0.2, breathing_hz: 0.25, breathing_depth: 0.03,
phase_shift: 0.08, ..Default::default()
},
AnchorLabel::SmallMove => Person {
presence_z: 1.7, sway_z: 1.0, phase_shift: 0.10, phase_wobble: 1.0,
..Default::default()
},
AnchorLabel::SleepPosture => Person {
presence_z: 1.6, sway_z: 0.2, breathing_hz: 0.2, breathing_depth: 0.03,
phase_shift: 0.06, ..Default::default()
},
};
Some(p)
}
/// Capture one anchor exactly as the CLI's `enroll` does: per-frame deviation
/// into the `AnchorRecorder`, scalar series for feature extraction, then the
/// quality-gate verdict.
fn capture_anchor(
sim: &mut RoomSim,
baseline: &BaselineCalibration,
gate: &AnchorQualityGate,
label: AnchorLabel,
room_id: &str,
at_unix_s: i64,
) -> (Option<AnchorFeature>, wifi_densepose_calibration::Anchor, Option<String>) {
let person = anchor_person(label);
let mut recorder = AnchorRecorder::new(label);
let mut series = Vec::with_capacity(ANCHOR_FRAMES);
for _ in 0..ANCHOR_FRAMES {
let frame = sim.frame(person.as_ref());
recorder.record_frame(baseline, &frame);
series.push(frame_scalar(&frame));
}
let (anchor, reason) = recorder.finalize(gate, at_unix_s);
let feature = anchor
.quality
.accepted
.then(|| AnchorFeature::from_series(room_id, label, &series, FS_HZ));
(feature, anchor, reason)
}
/// Generate a live feature window (Stage-5 runtime input).
fn live_window(sim: &mut RoomSim, person: Option<&Person>) -> Features {
let series: Vec<f32> = (0..WINDOW_FRAMES)
.map(|_| frame_scalar(&sim.frame(person)))
.collect();
Features::from_series(&series, FS_HZ)
}
// ---------------------------------------------------------------------------
// The full loop
// ---------------------------------------------------------------------------
#[test]
fn full_loop_baseline_enroll_extract_train_infer() {
let room_id = "living-room";
let mut sim = RoomSim::new(42);
// -- Stage 1: clean empty-room baseline capture (ADR-135) ----------------
let mut recorder = CalibrationRecorder::new(CalibrationConfig::ht20());
let mut flagged_after_warmup = 0u32;
for i in 0..WINDOW_FRAMES {
let frame = sim.frame(None);
let score = recorder.record(&frame).expect("baseline record");
// Welford stats need a short warmup before the partial z is meaningful.
if i >= 100 && score.motion_flagged {
flagged_after_warmup += 1;
}
}
assert_eq!(recorder.frames_recorded(), WINDOW_FRAMES as u32);
assert_eq!(
flagged_after_warmup, 0,
"a static empty room must never be motion-flagged after warmup"
);
let baseline = recorder.finalize().expect("baseline finalize");
assert_eq!(baseline.subcarriers.len(), N_SC);
let baseline_id = baseline.calibration_uuid().to_string();
// A fresh empty frame deviates negligibly from its own baseline.
let check = baseline.deviation(&sim.frame(None)).expect("deviation");
assert!(!check.motion_flagged, "empty frame flagged: {check:?}");
assert!(
check.amplitude_z_median < 1.0,
"empty frame z {} should be < 1.0",
check.amplitude_z_median
);
// -- Stage 2: guided-anchor enrollment with the quality gate -------------
let gate = AnchorQualityGate::default();
let mut session = EnrollmentSession::new(room_id, &baseline_id, 1_700_000_000);
let mut features: Vec<AnchorFeature> = Vec::new();
for (i, label) in AnchorLabel::SEQUENCE.into_iter().enumerate() {
let at = 1_700_000_000 + (i as i64 + 1) * 30;
let (feat, anchor, reason) =
capture_anchor(&mut sim, &baseline, &gate, label, room_id, at);
assert!(
anchor.quality.accepted,
"anchor {} rejected: {} (presence_z={:.2} motion={:.0}% frames={})",
label.as_str(),
reason.unwrap_or_default(),
anchor.quality.presence_z,
anchor.quality.motion_rate * 100.0,
anchor.quality.frames,
);
match label {
AnchorLabel::Empty => assert!(
anchor.quality.presence_z < 1.0,
"empty room must read empty, got z {}",
anchor.quality.presence_z
),
AnchorLabel::SmallMove => assert!(
anchor.quality.motion_rate >= 0.3,
"small-move motion {} too low",
anchor.quality.motion_rate
),
_ => assert!(
anchor.quality.presence_z >= 1.5,
"{} presence_z {} below gate",
label.as_str(),
anchor.quality.presence_z
),
}
features.push(feat.expect("accepted anchor yields a feature"));
session.apply(EnrollmentEvent::AnchorAccepted { anchor });
}
assert!(session.is_complete(), "missing anchors: {:?}", session.missing());
assert_eq!(session.progress(), (8, 8));
session.apply(EnrollmentEvent::Completed { at: 1_700_000_300 });
// -- Stage 3: feature extraction sanity ----------------------------------
assert_eq!(features.len(), 8);
let by_label = |l: AnchorLabel| {
features
.iter()
.find(|f| f.label == l)
.unwrap_or_else(|| panic!("no feature for {}", l.as_str()))
};
let breathe = by_label(AnchorLabel::BreatheNormal);
assert!(
(breathe.features.breathing_hz - 0.25).abs() < 0.04,
"normal breathing extracted at {} Hz, injected 0.25 Hz",
breathe.features.breathing_hz
);
assert!(
breathe.features.breathing_score > 0.25,
"breathing score {} too weak",
breathe.features.breathing_score
);
let slow = by_label(AnchorLabel::BreatheSlow);
assert!(
(slow.features.breathing_hz - 0.125).abs() < 0.04,
"slow breathing extracted at {} Hz, injected 0.125 Hz",
slow.features.breathing_hz
);
let empty = by_label(AnchorLabel::Empty);
assert!(
empty.features.variance < breathe.features.variance,
"empty variance {} should be below occupied {}",
empty.features.variance,
breathe.features.variance
);
// -- Stage 4: train the specialist bank + JSON persistence round-trip ----
let bank = SpecialistBank::train(room_id, &baseline_id, &features, 1_700_000_400)
.expect("bank training");
assert_eq!(bank.room_id, room_id);
assert_eq!(bank.anchor_count, 8);
let kinds = bank.trained_kinds();
for kind in [
SpecialistKind::Presence,
SpecialistKind::Posture,
SpecialistKind::Breathing,
SpecialistKind::Heartbeat,
SpecialistKind::Restlessness,
SpecialistKind::Anomaly,
] {
assert!(kinds.contains(&kind), "bank missing {kind:?} (got {kinds:?})");
}
// Persist and reload (JSON today) — the runtime below uses the *reloaded*
// bank, so the round-trip is proven inside the loop, not as a side check.
let json = bank.to_json().expect("bank to_json");
let reloaded = SpecialistBank::from_json(&json).expect("bank from_json");
assert_eq!(reloaded.room_id, bank.room_id);
assert_eq!(reloaded.baseline_id, bank.baseline_id);
assert_eq!(reloaded.anchor_count, bank.anchor_count);
assert_eq!(
reloaded.presence.as_ref().map(|p| p.threshold),
bank.presence.as_ref().map(|p| p.threshold),
"presence threshold must survive persistence"
);
// -- Stage 5: runtime inference through the mixture ----------------------
let mix = MixtureOfSpecialists::new(reloaded);
// Positive case: a person breathing at a KNOWN 0.30 Hz (18 BPM) — a rate
// never used during enrollment.
let occupied = Person {
presence_z: 1.7,
sway_z: 0.25,
breathing_hz: 0.30,
breathing_depth: 0.04,
phase_shift: 0.08,
..Default::default()
};
let f = live_window(&mut sim, Some(&occupied));
let state = mix.infer(&f, &baseline_id);
assert!(!state.stale, "bank trained against this baseline must be fresh");
assert!(!state.vetoed, "plausible occupied window must not be vetoed");
let presence = state.presence.expect("presence specialist trained");
assert_eq!(presence.value, 1.0, "person in the room must be detected");
let breathing = state.breathing.expect("breathing must be reported when present");
assert!(
(breathing.value - 18.0).abs() <= 2.0,
"breathing {} BPM, injected 18 BPM",
breathing.value
);
assert!(state.restlessness.is_some(), "restlessness specialist trained");
// Motionless-person case (ADR-152 "variance-only presence" regression):
// a strong reflector standing perfectly still — variance stays at the
// empty-room level, only the scalar MEAN shifts. The mean channel of the
// presence specialist must still detect them.
let motionless = Person {
presence_z: 3.0,
sway_z: 0.05,
phase_shift: 0.10,
..Default::default()
};
let f_still = live_window(&mut sim, Some(&motionless));
let state = mix.infer(&f_still, &baseline_id);
let presence = state.presence.expect("presence specialist trained");
assert_eq!(
presence.value, 1.0,
"motionless person must be detected via the mean-shift channel \
(variance {:.2e} vs empty-level)",
f_still.variance
);
// Negative case: a fresh empty-room window must NOT report presence,
// breathing, heartbeat, or posture.
let f_empty = live_window(&mut sim, None);
let state = mix.infer(&f_empty, &baseline_id);
let presence = state.presence.expect("presence specialist trained");
assert_eq!(presence.value, 0.0, "empty room must read absent");
assert!(state.breathing.is_none(), "no breathing in an empty room");
assert!(state.heartbeat.is_none(), "no heartbeat in an empty room");
assert!(state.posture.is_none(), "no posture in an empty room");
// Honest degradation: a drifted baseline flags the bank STALE.
let state = mix.infer(&f, "some-other-baseline");
assert!(state.stale, "baseline drift must mark readings STALE");
}
+2 -11
View File
@@ -16,18 +16,14 @@ name = "wifi-densepose"
path = "src/main.rs"
[features]
# `mat` pulls wifi-densepose-mat → -nn → ort (ONNX) → openssl-sys, which does NOT
# cross-compile to aarch64 and is irrelevant to the calibration path. Build the
# Pi/appliance calibration binary with `--no-default-features` to exclude it.
default = ["mat"]
mat = ["dep:wifi-densepose-mat"]
mat = []
[dependencies]
# Internal crates
wifi-densepose-mat = { version = "0.3.0", path = "../wifi-densepose-mat", optional = true }
wifi-densepose-mat = { version = "0.3.0", path = "../wifi-densepose-mat" }
wifi-densepose-signal = { version = "0.3.1", path = "../wifi-densepose-signal", default-features = false }
wifi-densepose-core = { version = "0.3.0", path = "../wifi-densepose-core" }
wifi-densepose-calibration = { version = "0.3.0", path = "../wifi-densepose-calibration" }
# Linear algebra / complex numbers (used by calibrate.rs to build CsiFrame)
ndarray = { workspace = true }
@@ -45,10 +41,6 @@ console = "0.16"
# Async runtime
tokio = { version = "1.35", features = ["full"] }
# HTTP API server (calibrate-serve subcommand — drives a future UI)
axum = { workspace = true }
tower-http = { version = "0.6", features = ["cors", "trace"] }
# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
@@ -72,4 +64,3 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
assert_cmd = "2.0"
predicates = "3.0"
tempfile = "3.9"
tower = { workspace = true }
+26 -61
View File
@@ -8,24 +8,22 @@
//!
//! # Wire format parsed here (option b — local parser, no cross-crate dep)
//!
//! Authoritative layout: firmware `csi_collector.c` (ADR-018 + ADR-110).
//!
//! Offset Size Field
//! ────── ──── ─────────────────────────────────────────────────────────────
//! 0 4 Magic: 0xC511_0001 (LE u32)
//! 4 1 node_id (u8)
//! 5 1 n_antennas (u8)
//! 6 2 n_subcarriers (LE u16 — 256 for ESP32-C6 HE-SU frames, #1005)
//! 8 4 freq_mhz (LE u32)
//! 12 4 sequence (LE u32)
//! 16 1 rssi (i8)
//! 17 1 noise_floor (i8)
//! 18 1 PPDU type (ADR-110: 0=HT/legacy, 1=HE-SU, 2=HE-MU, 3=HE-TB)
//! 19 1 flags (ADR-110: bit0 bw40, bit4 time-sync valid)
//! 6 1 n_subcarriers (u8)
//! 7 1 (reserved)
//! 8 2 freq_mhz (LE u16)
//! 10 4 sequence (LE u32)
//! 14 1 rssi (i8)
//! 15 1 noise_floor (i8)
//! 16 4 (reserved / padding)
//! 20 2 × n_antennas × n_subcarriers IQ pairs: i_val (i8), q_val (i8)
//!
//! This parser mirrors `parse_esp32_frame` in
//! `wifi-densepose-sensing-server/src/csi.rs` (same magic, same layout).
//! `wifi-densepose-sensing-server/src/csi.rs` exactly (same magic, same layout).
use anyhow::{bail, Result};
use clap::Args;
@@ -234,7 +232,7 @@ fn finalise_and_save(recorder: CalibrationRecorder, output: &str) -> Result<()>
// Tier helper
// ---------------------------------------------------------------------------
pub(crate) fn tier_config(tier: &str) -> CalibrationConfig {
fn tier_config(tier: &str) -> CalibrationConfig {
match tier.to_ascii_lowercase().as_str() {
"ht40" => CalibrationConfig::ht40(),
"he20" => CalibrationConfig::he20(),
@@ -252,7 +250,7 @@ pub(crate) fn tier_config(tier: &str) -> CalibrationConfig {
/// Parse a single UDP datagram and return a `CsiFrame` ready for
/// `CalibrationRecorder::record()`. Returns `None` on any parse failure.
pub(crate) fn parse_csi_packet(buf: &[u8], tier: &str) -> Option<CsiFrame> {
fn parse_csi_packet(buf: &[u8], tier: &str) -> Option<CsiFrame> {
if buf.len() < 20 {
return None;
}
@@ -263,15 +261,11 @@ pub(crate) fn parse_csi_packet(buf: &[u8], tier: &str) -> Option<CsiFrame> {
let node_id = buf[4];
let n_antennas = buf[5] as usize;
// u16 since ADR-110 / #1005: ESP32-C6 HE-SU frames carry 256 bins
// (the old single-byte read decoded 256 = 0x0100 LE as 0 subcarriers).
let n_subcarriers = u16::from_le_bytes([buf[6], buf[7]]) as usize;
let freq_mhz = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]);
let freq_mhz = u16::try_from(freq_mhz).unwrap_or(0);
let _sequence = u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]);
let rssi = buf[16] as i8;
let noise_floor = buf[17] as i8;
let _ppdu_type = buf[18]; // ADR-110; baseline tier gating is by count
let n_subcarriers = buf[6] as usize;
let freq_mhz = u16::from_le_bytes([buf[8], buf[9]]);
let _sequence = u32::from_le_bytes([buf[10], buf[11], buf[12], buf[13]]);
let rssi = buf[14] as i8;
let noise_floor = buf[15] as i8;
let n_pairs = n_antennas * n_subcarriers;
let iq_start = 20usize;
@@ -420,53 +414,24 @@ mod tests {
assert!(parse_csi_packet(&buf, "ht20").is_none());
}
/// Build an ADR-018 frame (correct firmware layout, ADR-110 bytes 18-19).
fn build_frame(n_subcarriers: u16, ppdu: u8) -> Vec<u8> {
let mut buf = vec![0u8; 20 + n_subcarriers as usize * 2];
buf[0..4].copy_from_slice(&0xC511_0001u32.to_le_bytes());
buf[4] = 12; // node_id
buf[5] = 1; // n_antennas
buf[6..8].copy_from_slice(&n_subcarriers.to_le_bytes());
buf[8..12].copy_from_slice(&2432u32.to_le_bytes()); // freq_mhz
buf[12..16].copy_from_slice(&11610u32.to_le_bytes()); // sequence
buf[16] = (-40i8) as u8; // rssi
buf[17] = (-87i8) as u8; // noise floor
buf[18] = ppdu;
buf[19] = 0x10; // time-sync valid
for k in 0..n_subcarriers as usize {
buf[20 + k * 2] = (10 + (k % 100) as i8) as u8;
buf[20 + k * 2 + 1] = (k % 50) as u8;
}
buf
}
#[test]
fn test_parse_csi_packet_valid() {
let buf = build_frame(2, 0);
let mut buf = vec![0u8; 24]; // 20-byte header + 2 IQ pairs (1 antenna, 2 subcarriers)
// Magic 0xC511_0001 LE
buf[0] = 0x01; buf[1] = 0x00; buf[2] = 0x11; buf[3] = 0xC5;
buf[5] = 1; // n_antennas
buf[6] = 2; // n_subcarriers
// freq_mhz = 2437 (channel 6)
buf[8] = 0x85; buf[9] = 0x09;
// IQ pairs at offset 20: (10, 20), (5, 15)
buf[20] = 10i8 as u8; buf[21] = 20i8 as u8;
buf[22] = (-5i8) as u8; buf[23] = 15i8 as u8;
let frame = parse_csi_packet(&buf, "ht20");
assert!(frame.is_some());
let f = frame.unwrap();
assert_eq!(f.num_spatial_streams(), 1);
assert_eq!(f.num_subcarriers(), 2);
assert_eq!(f.metadata.rssi_dbm, -40);
assert_eq!(f.metadata.noise_floor_dbm, -87);
}
#[test]
fn test_parse_csi_packet_he_su_256_bins() {
// ESP32-C6 HE-SU frame (issue #1005): n_subcarriers = 256 = 0x0100 LE.
// The pre-#1005 single-byte read decoded this as 0 subcarriers.
let buf = build_frame(256, 1);
assert_eq!(buf.len(), 532); // matches the live wire size
let f = parse_csi_packet(&buf, "he20").expect("256-bin HE frame must parse");
assert_eq!(f.num_subcarriers(), 256);
assert_eq!(f.metadata.rssi_dbm, -40);
// A 256-bin frame is accepted by the he20 recorder (num_subcarriers
// tier total) and rejected by ht20 (52/64) — no HT/HE mixing.
let mut he = wifi_densepose_signal::CalibrationRecorder::new(tier_config("he20"));
assert!(he.record(&f).is_ok());
let mut ht = wifi_densepose_signal::CalibrationRecorder::new(tier_config("ht20"));
assert!(ht.record(&f).is_err());
}
#[test]
File diff suppressed because it is too large Load Diff
-22
View File
@@ -27,9 +27,6 @@
use clap::{Parser, Subcommand};
pub mod calibrate;
pub mod calibrate_api;
pub mod room;
#[cfg(feature = "mat")]
pub mod mat;
/// WiFi-DensePose Command Line Interface
@@ -55,26 +52,7 @@ pub enum Commands {
/// baseline used for real-time motion z-scoring and CIR reference.
Calibrate(calibrate::CalibrateArgs),
/// Run the calibration HTTP API (ADR-135/151) for a UI to drive.
/// Receives ESP32 CSI over UDP and exposes start/status/stop/result
/// endpoints at `/api/v1/calibration/*` (CORS-enabled).
CalibrateServe(calibrate_api::CalibrateServeArgs),
/// Guided per-room enrollment (ADR-151 Stage 2) — walk the anchor sequence
/// against a baseline, writing labelled features.
Enroll(room::EnrollArgs),
/// Train the per-room specialist bank from an enrollment (ADR-151 Stage 4).
TrainRoom(room::TrainRoomArgs),
/// Show a trained specialist bank's summary.
RoomStatus(room::RoomStatusArgs),
/// Live mixture-of-specialists readout from the CSI stream (ADR-151 Stage 5).
RoomWatch(room::RoomWatchArgs),
/// Mass Casualty Assessment Tool commands
#[cfg(feature = "mat")]
#[command(subcommand)]
Mat(mat::MatCommand),
-17
View File
@@ -21,28 +21,11 @@ async fn main() -> anyhow::Result<()> {
Commands::Calibrate(args) => {
wifi_densepose_cli::calibrate::execute(args).await?;
}
Commands::CalibrateServe(args) => {
wifi_densepose_cli::calibrate_api::execute(args).await?;
}
Commands::Enroll(args) => {
wifi_densepose_cli::room::enroll(args).await?;
}
Commands::TrainRoom(args) => {
wifi_densepose_cli::room::train_room(args).await?;
}
Commands::RoomStatus(args) => {
wifi_densepose_cli::room::room_status(args).await?;
}
Commands::RoomWatch(args) => {
wifi_densepose_cli::room::room_watch(args).await?;
}
#[cfg(feature = "mat")]
Commands::Mat(mat_cmd) => {
wifi_densepose_cli::mat::execute(mat_cmd).await?;
}
Commands::Version => {
println!("wifi-densepose {}", env!("CARGO_PKG_VERSION"));
#[cfg(feature = "mat")]
println!("MAT module version: {}", wifi_densepose_mat::VERSION);
}
}
-458
View File
@@ -1,458 +0,0 @@
//! `enroll` / `train-room` / `room-status` / `room-watch` — ADR-151 Stages 25 CLI.
//!
//! Drives the `wifi-densepose-calibration` pipeline against a live ESP32 CSI
//! stream (requires `edge_tier=0` raw CSI). `enroll` walks the guided anchors and
//! writes labelled features; `train-room` fits the specialist bank; `room-watch`
//! runs the mixture runtime and prints live room state.
use anyhow::{bail, Result};
use clap::Args;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tokio::net::UdpSocket;
use wifi_densepose_calibration::{
Anchor, AnchorLabel, AnchorQualityGate, AnchorRecorder, EnrollmentEvent, EnrollmentSession,
MixtureOfSpecialists, MultiNodeMixture, SpecialistBank,
};
use wifi_densepose_calibration::extract::{AnchorFeature, Features};
use wifi_densepose_core::types::CsiFrame;
use wifi_densepose_signal::BaselineCalibration;
use crate::calibrate::parse_csi_packet;
const RECV_BUF: usize = 2048;
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
fn now_unix() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
/// Per-frame scalar: mean amplitude across all subcarriers/streams.
///
/// Carries presence/motion energy plus the breathing amplitude modulation.
/// (Validated live on the ESP32 — picks up breathing where a max-variance
/// subcarrier instead locks onto motion artifacts. A phase-based carrier on a
/// *stable* subcarrier is the proper higher-SNR refinement — ADR-151 §4.)
fn frame_scalar(frame: &CsiFrame) -> f32 {
let a = &frame.amplitude;
if a.is_empty() {
return 0.0;
}
(a.sum() / a.len() as f64) as f32
}
fn load_baseline(path: &str) -> Result<BaselineCalibration> {
let bytes = std::fs::read(path)
.map_err(|e| anyhow::anyhow!("cannot read baseline {path}: {e} — run `calibrate` first"))?;
BaselineCalibration::from_bytes(&bytes)
.map_err(|e| anyhow::anyhow!("invalid baseline {path}: {e}"))
}
/// Persisted enrollment output (labelled features + audit log).
#[derive(serde::Serialize, serde::Deserialize)]
struct EnrollmentData {
room_id: String,
baseline_id: String,
fs_hz: f32,
anchors: Vec<AnchorFeature>,
session: EnrollmentSession,
}
// ---------------------------------------------------------------------------
// enroll
// ---------------------------------------------------------------------------
/// Arguments for `enroll`.
#[derive(Args, Debug, Clone)]
pub struct EnrollArgs {
/// UDP port for ESP32 CSI frames (raw CSI; provision with `--edge-tier 0`).
#[arg(long, default_value_t = 5005)]
pub udp_port: u16,
/// Bind address for the UDP socket.
#[arg(long, default_value = "0.0.0.0")]
pub bind: String,
/// Path to the empty-room baseline produced by `calibrate`.
#[arg(long, default_value = "./baseline.bin")]
pub baseline: String,
/// PHY tier (ht20 / ht40 / he20 / he40).
#[arg(long, default_value = "ht20")]
pub tier: String,
/// Room label.
#[arg(long, default_value = "default")]
pub room_id: String,
/// Output enrollment file.
#[arg(long, default_value = "./enrollment.json")]
pub output: String,
/// CSI sample rate (Hz) used for periodicity extraction.
#[arg(long, default_value_t = 15.0)]
pub fs_hz: f32,
/// Max attempts per anchor before moving on.
#[arg(long, default_value_t = 2)]
pub attempts: u32,
}
/// Capture one anchor: returns (accepted feature?, anchor verdict, reason).
async fn capture_anchor(
socket: &UdpSocket,
baseline: &BaselineCalibration,
gate: &AnchorQualityGate,
label: AnchorLabel,
tier: &str,
fs_hz: f32,
room_id: &str,
) -> Result<(Option<AnchorFeature>, Anchor, Option<String>)> {
eprintln!("\n[enroll] {}{}", label.as_str(), label.prompt());
for c in (1..=3).rev() {
eprintln!("[enroll] starting in {c}");
tokio::time::sleep(Duration::from_secs(1)).await;
}
eprintln!("[enroll] capturing {} s…", label.duration_s());
let mut recorder = AnchorRecorder::new(label);
let mut series: Vec<f32> = Vec::new();
let mut buf = vec![0u8; RECV_BUF];
let deadline = Instant::now() + Duration::from_secs(label.duration_s() as u64);
while Instant::now() < deadline {
let timeout = Duration::from_millis(500);
if let Ok(Ok(n)) = tokio::time::timeout(timeout, socket.recv(&mut buf)).await {
if let Some(frame) = parse_csi_packet(&buf[..n], tier) {
recorder.record_frame(baseline, &frame);
series.push(frame_scalar(&frame));
}
}
}
let (anchor, reason) = recorder.finalize(gate, now_unix());
let feature = if anchor.quality.accepted {
Some(AnchorFeature::from_series(room_id, label, &series, fs_hz))
} else {
None
};
Ok((feature, anchor, reason))
}
/// Execute `enroll`.
pub async fn enroll(args: EnrollArgs) -> Result<()> {
let baseline = load_baseline(&args.baseline)?;
let baseline_id = baseline.calibration_uuid().to_string();
let gate = AnchorQualityGate::default();
let addr = format!("{}:{}", args.bind, args.udp_port);
let socket = UdpSocket::bind(&addr)
.await
.map_err(|e| anyhow::anyhow!("cannot bind {addr}: {e}"))?;
eprintln!("[enroll] room='{}' baseline={} on udp://{addr}", args.room_id, &baseline_id[..8]);
eprintln!("[enroll] follow each prompt; bad captures are re-prompted.");
let mut session = EnrollmentSession::new(&args.room_id, &baseline_id, now_unix());
let mut features: Vec<AnchorFeature> = Vec::new();
for label in AnchorLabel::SEQUENCE {
let mut accepted = false;
for attempt in 1..=args.attempts {
let (feat, anchor, reason) =
capture_anchor(&socket, &baseline, &gate, label, &args.tier, args.fs_hz, &args.room_id)
.await?;
if anchor.quality.accepted {
eprintln!(
"[enroll] ✓ accepted (presence_z={:.2} motion={:.0}% frames={})",
anchor.quality.presence_z,
anchor.quality.motion_rate * 100.0,
anchor.quality.frames
);
if let Some(f) = feat {
features.push(f);
}
session.apply(EnrollmentEvent::AnchorAccepted { anchor });
accepted = true;
break;
} else {
let why = reason.unwrap_or_default();
eprintln!("[enroll] ✗ rejected: {why}");
session.apply(EnrollmentEvent::AnchorRejected {
label,
reason: why,
at: now_unix(),
});
if attempt < args.attempts {
eprintln!("[enroll] retrying ({}/{})…", attempt + 1, args.attempts);
}
}
}
if !accepted {
eprintln!("[enroll] moving on without '{}'", label.as_str());
}
}
if session.is_complete() {
session.apply(EnrollmentEvent::Completed { at: now_unix() });
}
let (got, total) = session.progress();
let data = EnrollmentData {
room_id: args.room_id.clone(),
baseline_id,
fs_hz: args.fs_hz,
anchors: features,
session,
};
std::fs::write(
&args.output,
serde_json::to_string_pretty(&data).map_err(|e| anyhow::anyhow!("serialize: {e}"))?,
)
.map_err(|e| anyhow::anyhow!("cannot write {}: {e}", args.output))?;
eprintln!(
"\n[enroll] done: {got}/{total} anchors accepted → {} (next: `train-room`)",
args.output
);
Ok(())
}
// ---------------------------------------------------------------------------
// train-room
// ---------------------------------------------------------------------------
/// Arguments for `train-room`.
#[derive(Args, Debug, Clone)]
pub struct TrainRoomArgs {
/// Enrollment file from `enroll`.
#[arg(long, default_value = "./enrollment.json")]
pub enrollment: String,
/// Output specialist-bank file.
#[arg(long, default_value = "./room-bank.json")]
pub output: String,
}
/// Execute `train-room`.
pub async fn train_room(args: TrainRoomArgs) -> Result<()> {
let raw = std::fs::read_to_string(&args.enrollment)
.map_err(|e| anyhow::anyhow!("cannot read {}: {e} — run `enroll` first", args.enrollment))?;
let data: EnrollmentData =
serde_json::from_str(&raw).map_err(|e| anyhow::anyhow!("invalid enrollment: {e}"))?;
if data.anchors.is_empty() {
bail!("no accepted anchors in {} — re-run enroll", args.enrollment);
}
let bank = SpecialistBank::train(&data.room_id, &data.baseline_id, &data.anchors, now_unix())
.map_err(|e| anyhow::anyhow!("training failed: {e}"))?;
std::fs::write(&args.output, bank.to_json().map_err(|e| anyhow::anyhow!("{e}"))?)
.map_err(|e| anyhow::anyhow!("cannot write {}: {e}", args.output))?;
eprintln!(
"[train-room] room='{}' trained {} specialists from {} anchors → {}",
bank.room_id,
bank.trained_kinds().len(),
bank.anchor_count,
args.output
);
for k in bank.trained_kinds() {
eprintln!("[train-room] • {k:?}");
}
Ok(())
}
// ---------------------------------------------------------------------------
// room-status
// ---------------------------------------------------------------------------
/// Arguments for `room-status`.
#[derive(Args, Debug, Clone)]
pub struct RoomStatusArgs {
/// Specialist-bank file.
#[arg(long, default_value = "./room-bank.json")]
pub bank: String,
}
/// Execute `room-status`.
pub async fn room_status(args: RoomStatusArgs) -> Result<()> {
let raw = std::fs::read_to_string(&args.bank)
.map_err(|e| anyhow::anyhow!("cannot read {}: {e}", args.bank))?;
let bank = SpecialistBank::from_json(&raw).map_err(|e| anyhow::anyhow!("{e}"))?;
println!("room: {}", bank.room_id);
println!("baseline: {}", bank.baseline_id);
println!("trained_at: {}", bank.trained_at_unix_s);
println!("anchors: {}", bank.anchor_count);
println!("specialists: {:?}", bank.trained_kinds());
Ok(())
}
// ---------------------------------------------------------------------------
// room-watch
// ---------------------------------------------------------------------------
/// Arguments for `room-watch`.
#[derive(Args, Debug, Clone)]
pub struct RoomWatchArgs {
/// Specialist-bank file (single-node mode).
#[arg(long, default_value = "./room-bank.json")]
pub bank: String,
/// Multistatic mode: map a node id to its bank as `N:path` (repeatable).
/// When supplied, frames are grouped by node id and fused (ADR-029/151).
#[arg(long = "node-bank", value_name = "N:PATH")]
pub node_bank: Vec<String>,
/// UDP port for ESP32 CSI frames (raw CSI).
#[arg(long, default_value_t = 5005)]
pub udp_port: u16,
/// Bind address.
#[arg(long, default_value = "0.0.0.0")]
pub bind: String,
/// PHY tier.
#[arg(long, default_value = "ht20")]
pub tier: String,
/// CSI sample rate (Hz).
#[arg(long, default_value_t = 15.0)]
pub fs_hz: f32,
/// Rolling window length (frames) for each inference.
#[arg(long, default_value_t = 200)]
pub window: usize,
/// Seconds to run (0 = until Ctrl-C).
#[arg(long, default_value_t = 0)]
pub seconds: u32,
}
/// Execute `room-watch` — live (multistatic) mixture-of-specialists readout.
pub async fn room_watch(args: RoomWatchArgs) -> Result<()> {
if !args.node_bank.is_empty() {
return room_watch_multi(args).await;
}
let raw = std::fs::read_to_string(&args.bank)
.map_err(|e| anyhow::anyhow!("cannot read {}: {e}", args.bank))?;
let bank = SpecialistBank::from_json(&raw).map_err(|e| anyhow::anyhow!("{e}"))?;
let baseline_id = bank.baseline_id.clone();
let mix = MixtureOfSpecialists::new(bank);
let addr = format!("{}:{}", args.bind, args.udp_port);
let socket = UdpSocket::bind(&addr)
.await
.map_err(|e| anyhow::anyhow!("cannot bind {addr}: {e}"))?;
eprintln!("[room-watch] inferring on udp://{addr} (window={} frames)", args.window);
let mut buf = vec![0u8; RECV_BUF];
let mut win: std::collections::VecDeque<f32> = std::collections::VecDeque::new();
let start = Instant::now();
let mut last_print = Instant::now();
loop {
if args.seconds > 0 && start.elapsed() >= Duration::from_secs(args.seconds as u64) {
break;
}
if let Ok(Ok(n)) = tokio::time::timeout(Duration::from_millis(500), socket.recv(&mut buf)).await {
if let Some(frame) = parse_csi_packet(&buf[..n], &args.tier) {
win.push_back(frame_scalar(&frame));
while win.len() > args.window {
win.pop_front();
}
}
}
if last_print.elapsed() >= Duration::from_secs(1) && win.len() >= 32 {
let series: Vec<f32> = win.iter().copied().collect();
let f = Features::from_series(&series, args.fs_hz);
let s = mix.infer(&f, &baseline_id);
let pres = s.presence.as_ref().map(|r| r.label.clone().unwrap_or_default()).unwrap_or("-".into());
let post = s.posture.as_ref().and_then(|r| r.label.clone()).unwrap_or("-".into());
let br = s.breathing.as_ref().map(|r| format!("{:.1}bpm", r.value)).unwrap_or("-".into());
let hr = s.heartbeat.as_ref().map(|r| format!("{:.0}bpm", r.value)).unwrap_or("-".into());
let rest = s.restlessness.as_ref().map(|r| format!("{:.2}", r.value)).unwrap_or("-".into());
let flags = format!(
"{}{}",
if s.vetoed { " VETO" } else { "" },
if s.stale { " STALE" } else { "" }
);
println!(
"presence={pres:<7} posture={post:<8} breathing={br:<8} heart={hr:<7} restless={rest}{flags}"
);
last_print = Instant::now();
}
}
Ok(())
}
/// Multistatic `room-watch`: fuse several co-located nodes (ADR-029/151).
async fn room_watch_multi(args: RoomWatchArgs) -> Result<()> {
use std::collections::{BTreeMap, VecDeque};
let mut mix = MultiNodeMixture::new();
let mut node_ids: Vec<u8> = Vec::new();
for spec in &args.node_bank {
let (id_s, path) = spec
.split_once(':')
.ok_or_else(|| anyhow::anyhow!("--node-bank must be N:path (got {spec:?})"))?;
let id: u8 = id_s
.parse()
.map_err(|_| anyhow::anyhow!("bad node id in {spec:?}"))?;
let raw = std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("cannot read {path}: {e}"))?;
let bank = SpecialistBank::from_json(&raw).map_err(|e| anyhow::anyhow!("{e}"))?;
let baseline = bank.baseline_id.clone();
mix.add_node(id, bank, baseline);
node_ids.push(id);
}
eprintln!("[room-watch] multistatic over nodes {node_ids:?}");
let addr = format!("{}:{}", args.bind, args.udp_port);
let socket = UdpSocket::bind(&addr)
.await
.map_err(|e| anyhow::anyhow!("cannot bind {addr}: {e}"))?;
eprintln!("[room-watch] fusing on udp://{addr} (window={} frames)", args.window);
let mut buf = vec![0u8; RECV_BUF];
let mut wins: BTreeMap<u8, VecDeque<f32>> = BTreeMap::new();
let start = Instant::now();
let mut last_print = Instant::now();
loop {
if args.seconds > 0 && start.elapsed() >= Duration::from_secs(args.seconds as u64) {
break;
}
if let Ok(Ok(n)) =
tokio::time::timeout(Duration::from_millis(500), socket.recv(&mut buf)).await
{
if n < 5 {
continue;
}
let node_id = buf[4];
if !node_ids.contains(&node_id) {
continue;
}
if let Some(frame) = parse_csi_packet(&buf[..n], &args.tier) {
let w = wins.entry(node_id).or_default();
w.push_back(frame_scalar(&frame));
while w.len() > args.window {
w.pop_front();
}
}
}
if last_print.elapsed() >= Duration::from_secs(1) {
let per_node: BTreeMap<u8, Features> = wins
.iter()
.filter(|(_, w)| w.len() >= 32)
.map(|(id, w)| {
let series: Vec<f32> = w.iter().copied().collect();
(*id, Features::from_series(&series, args.fs_hz))
})
.collect();
if !per_node.is_empty() {
let active: Vec<u8> = per_node.keys().copied().collect();
let s = mix.infer(&per_node);
let pres = s.presence.as_ref().and_then(|r| r.label.clone()).unwrap_or("-".into());
let post = s.posture.as_ref().and_then(|r| r.label.clone()).unwrap_or("-".into());
let br = s.breathing.as_ref().map(|r| format!("{:.1}bpm", r.value)).unwrap_or("-".into());
let flags = format!(
"{}{}",
if s.vetoed { " VETO" } else { "" },
if s.stale { " STALE" } else { "" }
);
println!(
"nodes={active:?} presence={pres:<7} posture={post:<8} breathing={br:<8}{flags}"
);
}
last_print = Instant::now();
}
}
Ok(())
}
@@ -16,8 +16,7 @@
//! 12 4 Sequence number (LE u32)
//! 16 1 RSSI (i8)
//! 17 1 Noise floor (i8)
//! 18 1 PPDU type (ADR-110: 0=HT/legacy, 1=HE-SU, 2=HE-MU, 3=HE-TB)
//! 19 1 Flags (ADR-110: bit0 bw40, bit2 STBC, bit3 LDPC, bit4 15.4-sync)
//! 18 2 Reserved
//! 20 N*2 I/Q pairs (n_antennas * n_subcarriers * 2 bytes)
//! ```
//!
@@ -241,31 +240,12 @@ impl Esp32CsiParser {
}
}
// Determine bandwidth from PPDU type + subcarrier count (ADR-110).
//
// HE-LTF uses a 4x denser tone grid than HT-LTF on the same channel
// width: HE20 = 256-FFT (242 active tones), HE40 = 512-FFT (484
// active). So a 256-bin frame on an HE PPDU is *20 MHz*, not 160.
// For HE frames the firmware also writes the bandwidth into byte 19
// bit 0 (see Adr018Flags::bw40) — prefer that when set.
//
// HT/legacy keeps the count heuristic, with 64 included in the 20 MHz
// bucket: ESP32 HT20 CSI delivers the full 64-bin FFT grid (live
// capture evidence: 148-byte frames = 64 subcarriers on a 20 MHz
// channel, issue #1005).
let bandwidth = if ppdu_type.is_he() {
if adr018_flags.bw40 || n_subcarriers > 256 {
Bandwidth::Bw40
} else {
Bandwidth::Bw20
}
} else {
match n_subcarriers {
0..=64 => Bandwidth::Bw20,
65..=128 => Bandwidth::Bw40,
129..=242 => Bandwidth::Bw80,
_ => Bandwidth::Bw160,
}
// Determine bandwidth from subcarrier count
let bandwidth = match n_subcarriers {
0..=56 => Bandwidth::Bw20,
57..=114 => Bandwidth::Bw40,
115..=242 => Bandwidth::Bw80,
_ => Bandwidth::Bw160,
};
let frame = CsiFrame {
+1 -3
View File
@@ -49,9 +49,7 @@ pub mod sync_packet;
pub mod radio_ops;
pub use bridge::CsiData;
pub use csi_frame::{
Adr018Flags, AntennaConfig, Bandwidth, CsiFrame, CsiMetadata, PpduType, SubcarrierData,
};
pub use csi_frame::{AntennaConfig, Bandwidth, CsiFrame, CsiMetadata, SubcarrierData};
pub use error::ParseError;
pub use esp32_parser::{
ruview_sibling_packet_name, Esp32CsiParser, ESP32_CSI_MAGIC, RUVIEW_COMPRESSED_CSI_MAGIC,
@@ -1,90 +0,0 @@
//! ADR-110 / issue #1005: real ESP32-C6 HE-LTF CSI frames captured live.
//!
//! Both fixtures below are verbatim UDP payloads captured on 2026-06-11 from
//! an ESP32-C6 (node_id 12, IDF v5.5 build) streaming to UDP :5005 — the
//! same node, same link, seconds apart. The 532-byte frame is an HE-SU
//! capture (256 subcarrier bins = 242 active HE20 tones); the 148-byte frame
//! is the HT fallback grid (64 bins) the same firmware emits for non-HE
//! traffic. They are the canonical regression fixtures for the non-fixed
//! subcarrier count introduced by HE-LTF.
use wifi_densepose_hardware::{Bandwidth, Esp32CsiParser, PpduType};
/// 532-byte HE-SU frame: header + 256 subcarrier I/Q pairs.
/// magic=0xC5110001 node=12 ant=1 nsub=256 freq=2432 seq=11610
/// rssi=-40 noise=-87 byte18=0x01 (HE-SU) byte19=0x10 (15.4-sync valid)
const HE_FRAME_HEX: &str = "010011c50c010001800900005a2d0000d8a9011000000000000000000000f70ef70ef50cf30bf209f108f006ef03ee02ee00eefdeffbeff8f0f7f1f4f2f3f4f1f5f0f7eef8edfaecfdecffeb01ea03ea05e908ea0aeb0deb0fec11ee13f015f216f318f519f71afa1bfd1bff1c021c051b071b0a1a0c190f1811161315161218101a0e1b0c1c091d071e041f0120ff20fc20f91ff71ff41ef11def1cec1be919e717e615e413e311e10edf0cde09dd06dc04dc01dcffdcfbdcf9ddf6def3dff0e0ede2eae4e8e6e6e8e4eae2ebe0eedef1dcf4dbf7dafad9fdd900d903d806d909d90cda0fdc12dc14dd17df1ae11ce31ee520e722e924ed25f127f328f629f929fd2900290329062809270c260e26122516061a00001c201c1f1a211722142411250e260c27082804280129fe29fb28f927f627f426f125ef23ec22ea20e81eea20e81e891b53a82951565d4ffafbfebe9abddb10222aa47b3b371fd2c0860cd4d86ea2f35faccd46b0b66f6ff0050f2da27d1c92f7f8e1017cb545afd3e3fe60db6f478dc85a33b3454cf6df9061194a0a0fc3e0eedf76f1d292cb25c8f541dfcc4109f9f1a34955520ad8ffa3694ac395cbf6c19073a4aefb1ebf47c76730458431805d9f18ff2e81955e8752b29757f66e289f72f8e35309a737547c040444cbda1a81d221d950037ec38fd9d1dd0f56c3dc707a7bbfe66ca5a97ab7cc17d68d38ba43a1806f91f5911a5967e2c9f7f07186";
/// 148-byte HT frame from the same node: header + 64 subcarrier I/Q pairs.
/// magic=0xC5110001 node=12 ant=1 nsub=64 freq=2432 seq=11622
/// rssi=-79 noise=-87 byte18=0x00 (HT/legacy) byte19=0x10
const HT_FRAME_HEX: &str = "010011c50c01400080090000662d0000b1a900100000000000000000fcfaf909f013f112f213f212f311f410f511f510f610f510f411f410f411f312f213f214f214f212f313f513f512f611f610f80ef90df90c0000010eff11fe13ff11fe1300000000ff01000001010002000200020204000301040103000400040002ff03ff03fe02fe02fe01fd00edfc03fa000000000000";
fn unhex(s: &str) -> Vec<u8> {
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
.collect()
}
#[test]
fn live_he_su_frame_532_bytes_parses_with_256_subcarriers() {
let data = unhex(HE_FRAME_HEX);
assert_eq!(data.len(), 532);
let (frame, consumed) = Esp32CsiParser::parse_frame(&data).expect("HE frame must parse");
assert_eq!(consumed, 532);
assert_eq!(frame.metadata.node_id, 12);
assert_eq!(frame.metadata.n_antennas, 1);
assert_eq!(frame.metadata.n_subcarriers, 256);
assert_eq!(frame.subcarrier_count(), 256);
assert_eq!(frame.metadata.channel_freq_mhz, 2432);
assert_eq!(frame.metadata.sequence, 11610);
assert_eq!(frame.metadata.rssi_dbm, -40);
assert_eq!(frame.metadata.noise_floor_dbm, -87);
// ADR-110 byte 18: HE-SU PPDU. Byte 19 bit 4: ESP-NOW time-sync valid.
assert_eq!(frame.metadata.ppdu_type, PpduType::HeSu);
assert!(frame.metadata.ppdu_type.is_he());
assert!(frame.metadata.adr018_flags.ieee802154_sync_valid);
assert!(!frame.metadata.adr018_flags.bw40);
// 256-FFT HE-LTF on a 20 MHz channel — NOT 160 MHz.
assert_eq!(frame.metadata.bandwidth, Bandwidth::Bw20);
assert!(frame.is_valid());
}
#[test]
fn live_ht_frame_148_bytes_parses_with_64_subcarriers() {
let data = unhex(HT_FRAME_HEX);
assert_eq!(data.len(), 148);
let (frame, consumed) = Esp32CsiParser::parse_frame(&data).expect("HT frame must parse");
assert_eq!(consumed, 148);
assert_eq!(frame.metadata.node_id, 12);
assert_eq!(frame.metadata.n_subcarriers, 64);
assert_eq!(frame.metadata.channel_freq_mhz, 2432);
assert_eq!(frame.metadata.sequence, 11622);
assert_eq!(frame.metadata.rssi_dbm, -79);
assert_eq!(frame.metadata.noise_floor_dbm, -87);
assert_eq!(frame.metadata.ppdu_type, PpduType::HtLegacy);
assert!(!frame.metadata.ppdu_type.is_he());
// 64-bin full HT20 FFT grid on a 20 MHz channel — NOT 40 MHz.
assert_eq!(frame.metadata.bandwidth, Bandwidth::Bw20);
assert!(frame.is_valid());
}
#[test]
fn live_interleaved_stream_parses_both_grids() {
// The live node interleaves HE (84%) and HT (16%) frames on one socket.
let mut stream = unhex(HE_FRAME_HEX);
stream.extend_from_slice(&unhex(HT_FRAME_HEX));
stream.extend_from_slice(&unhex(HE_FRAME_HEX));
let (frames, consumed) = Esp32CsiParser::parse_stream(&stream);
assert_eq!(frames.len(), 3);
assert_eq!(consumed, 532 + 148 + 532);
assert_eq!(frames[0].metadata.n_subcarriers, 256);
assert_eq!(frames[1].metadata.n_subcarriers, 64);
assert_eq!(frames[2].metadata.n_subcarriers, 256);
assert_eq!(frames[0].metadata.ppdu_type, PpduType::HeSu);
assert_eq!(frames[1].metadata.ppdu_type, PpduType::HtLegacy);
}
+3 -3
View File
@@ -15,12 +15,12 @@ readme = "README.md"
default = ["std", "api", "ruvector"]
ruvector = ["dep:ruvector-solver", "dep:ruvector-temporal-tensor"]
std = []
api = ["chrono/serde", "geo/use-serde"]
api = ["dep:serde", "chrono/serde", "geo/use-serde"]
portable = ["low-power"]
low-power = []
distributed = ["tokio/sync"]
drone = ["distributed"]
serde = ["chrono/serde", "geo/use-serde"]
serde = ["dep:serde", "chrono/serde", "geo/use-serde"]
[dependencies]
# Workspace dependencies
@@ -43,7 +43,7 @@ thiserror = "2.0"
anyhow = "1.0"
# Serialization
serde = { version = "1.0", features = ["derive"] }
serde = { version = "1.0", features = ["derive"], optional = true }
serde_json = "1.0"
# Time handling
@@ -3,7 +3,6 @@
use ruvector_mincut::{DynamicMinCut, MinCutBuilder};
use std::collections::{HashMap, VecDeque};
use wifi_densepose_hardware::PpduType;
use crate::adaptive_classifier;
use crate::types::*;
@@ -85,18 +84,6 @@ pub fn parse_wasm_output(buf: &[u8]) -> Option<WasmOutputPacket> {
})
}
/// Parse an ADR-018 raw CSI frame (magic 0xC511_0001).
///
/// Header layout (authoritative: firmware `csi_collector.c` / ADR-018):
/// magic u32 LE @0, node_id u8 @4, n_antennas u8 @5, n_subcarriers u16 LE
/// @6-7, freq_mhz u32 LE @8-11, sequence u32 LE @12-15, rssi i8 @16,
/// noise_floor i8 @17, PPDU type u8 @18 (ADR-110), flags u8 @19 (ADR-110),
/// I/Q pairs from @20.
///
/// Until issue #1005 this function read `n_subcarriers` from byte 6 alone
/// (an ESP32-C6 HE-SU frame's 256 = 0x0100 LE decoded as 0 — the frame
/// parsed "successfully" with zero subcarriers) and read sequence/rssi/
/// noise at stale offsets 10/14/15 (rssi landed on sequence bytes ⇒ 0).
pub fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
if buf.len() < 20 {
return None;
@@ -108,18 +95,16 @@ pub fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
let node_id = buf[4];
let n_antennas = buf[5];
let n_subcarriers = u16::from_le_bytes([buf[6], buf[7]]);
let freq_mhz_u32 = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]);
let freq_mhz = u16::try_from(freq_mhz_u32).unwrap_or(0);
let sequence = u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]);
let rssi_raw = buf[16] as i8;
let n_subcarriers = buf[6];
let freq_mhz = u16::from_le_bytes([buf[8], buf[9]]);
let sequence = u32::from_le_bytes([buf[10], buf[11], buf[12], buf[13]]);
let rssi_raw = buf[14] as i8;
let rssi = if rssi_raw > 0 {
rssi_raw.saturating_neg()
} else {
rssi_raw
};
let noise_floor = buf[17] as i8;
let ppdu_type = PpduType::from_byte(buf[18]);
let noise_floor = buf[15] as i8;
let iq_start = 20;
let n_pairs = n_antennas as usize * n_subcarriers as usize;
@@ -146,7 +131,6 @@ pub fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
sequence,
rssi,
noise_floor,
ppdu_type,
amplitudes,
phases,
})
@@ -980,12 +964,11 @@ pub fn generate_simulated_frame(tick: u64) -> Esp32Frame {
magic: 0xC511_0001,
node_id: 1,
n_antennas: 1,
n_subcarriers: n_sub as u16,
n_subcarriers: n_sub as u8,
freq_mhz: 2437,
sequence: tick as u32,
rssi: (-40.0 + 5.0 * (t * 0.2).sin()) as i8,
noise_floor: -90,
ppdu_type: PpduType::HtLegacy,
amplitudes,
phases,
}
@@ -998,76 +981,3 @@ pub fn chrono_timestamp() -> u64 {
.map(|d| d.as_secs())
.unwrap_or(0)
}
// ── ADR-110 / issue #1005 tests: live ESP32-C6 HE-LTF frames ────────────────
#[cfg(test)]
mod adr110_tests {
use super::*;
use crate::types::NodeState;
/// Verbatim 532-byte HE-SU UDP payload captured live 2026-06-11 from an
/// ESP32-C6 (node 12, IDF v5.5): 256 subcarrier bins, byte18=0x01.
const HE_FRAME_HEX: &str = "010011c50c010001800900005a2d0000d8a9011000000000000000000000f70ef70ef50cf30bf209f108f006ef03ee02ee00eefdeffbeff8f0f7f1f4f2f3f4f1f5f0f7eef8edfaecfdecffeb01ea03ea05e908ea0aeb0deb0fec11ee13f015f216f318f519f71afa1bfd1bff1c021c051b071b0a1a0c190f1811161315161218101a0e1b0c1c091d071e041f0120ff20fc20f91ff71ff41ef11def1cec1be919e717e615e413e311e10edf0cde09dd06dc04dc01dcffdcfbdcf9ddf6def3dff0e0ede2eae4e8e6e6e8e4eae2ebe0eedef1dcf4dbf7dafad9fdd900d903d806d909d90cda0fdc12dc14dd17df1ae11ce31ee520e722e924ed25f127f328f629f929fd2900290329062809270c260e26122516061a00001c201c1f1a211722142411250e260c27082804280129fe29fb28f927f627f426f125ef23ec22ea20e81eea20e81e891b53a82951565d4ffafbfebe9abddb10222aa47b3b371fd2c0860cd4d86ea2f35faccd46b0b66f6ff0050f2da27d1c92f7f8e1017cb545afd3e3fe60db6f478dc85a33b3454cf6df9061194a0a0fc3e0eedf76f1d292cb25c8f541dfcc4109f9f1a34955520ad8ffa3694ac395cbf6c19073a4aefb1ebf47c76730458431805d9f18ff2e81955e8752b29757f66e289f72f8e35309a737547c040444cbda1a81d221d950037ec38fd9d1dd0f56c3dc707a7bbfe66ca5a97ab7cc17d68d38ba43a1806f91f5911a5967e2c9f7f07186";
/// Verbatim 148-byte HT payload from the same node seconds later:
/// 64 bins, byte18=0x00.
const HT_FRAME_HEX: &str = "010011c50c01400080090000662d0000b1a900100000000000000000fcfaf909f013f112f213f212f311f410f511f510f610f510f411f410f411f312f213f214f214f212f313f513f512f611f610f80ef90df90c0000010eff11fe13ff11fe1300000000ff01000001010002000200020204000301040103000400040002ff03ff03fe02fe02fe01fd00edfc03fa000000000000";
fn unhex(s: &str) -> Vec<u8> {
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
.collect()
}
#[test]
fn live_he_su_frame_parses_with_256_subcarriers() {
let buf = unhex(HE_FRAME_HEX);
assert_eq!(buf.len(), 532);
let f = parse_esp32_frame(&buf).expect("532-byte HE frame must parse");
assert_eq!(f.node_id, 12);
assert_eq!(f.n_subcarriers, 256);
assert_eq!(f.amplitudes.len(), 256);
assert_eq!(f.freq_mhz, 2432);
assert_eq!(f.sequence, 11610);
assert_eq!(f.rssi, -40);
assert_eq!(f.noise_floor, -87);
assert_eq!(f.ppdu_type, PpduType::HeSu);
}
#[test]
fn live_ht_frame_parses_with_64_subcarriers() {
let buf = unhex(HT_FRAME_HEX);
assert_eq!(buf.len(), 148);
let f = parse_esp32_frame(&buf).expect("148-byte HT frame must parse");
assert_eq!(f.node_id, 12);
assert_eq!(f.n_subcarriers, 64);
assert_eq!(f.amplitudes.len(), 64);
assert_eq!(f.rssi, -79);
assert_eq!(f.ppdu_type, PpduType::HtLegacy);
}
#[test]
fn grid_gate_never_mixes_ht_and_he_windows() {
let he = parse_esp32_frame(&unhex(HE_FRAME_HEX)).unwrap();
let ht = parse_esp32_frame(&unhex(HT_FRAME_HEX)).unwrap();
let mut ns = NodeState::new();
// First frame locks the grid.
assert!(ns.accept_grid(ht.grid()));
ns.frame_history.push_back(ht.amplitudes.clone());
// HE upgrade: accepted, denser grid wins, history re-keyed.
assert!(ns.accept_grid(he.grid()));
assert!(ns.frame_history.is_empty(), "upgrade must clear HT history");
ns.frame_history.push_back(he.amplitudes.clone());
// Interleaved HT minority frames are rejected from the feature path.
assert!(!ns.accept_grid(ht.grid()));
assert_eq!(ns.frame_history.len(), 1, "HT frame must not touch window");
// Steady-state HE frames keep flowing.
assert!(ns.accept_grid(he.grid()));
}
}
@@ -226,28 +226,15 @@ struct Esp32Frame {
magic: u32,
node_id: u8,
n_antennas: u8,
/// u16 since ADR-110 / issue #1005: ESP32-C6 HE-SU frames carry 256
/// subcarrier bins (242 active HE20 tones). HT frames stay ≤128.
n_subcarriers: u16,
n_subcarriers: u8,
freq_mhz: u16,
sequence: u32,
rssi: i8,
noise_floor: i8,
/// ADR-110 byte 18: PPDU type the CSI was sampled from. Pre-ADR-110
/// firmware sends 0 ⇒ `PpduType::HtLegacy`.
ppdu_type: wifi_densepose_hardware::PpduType,
amplitudes: Vec<f64>,
phases: Vec<f64>,
}
impl Esp32Frame {
/// The `(n_subcarriers, ppdu_type)` symbol-grid identity of this frame.
/// HT-LTF and HE-LTF grids are not bin-comparable (ADR-110 / #1005).
fn grid(&self) -> (u16, wifi_densepose_hardware::PpduType) {
(self.n_subcarriers, self.ppdu_type)
}
}
/// Sensing update broadcast to WebSocket clients
#[derive(Debug, Clone, Serialize, Deserialize)]
struct SensingUpdate {
@@ -455,12 +442,6 @@ struct NodeState {
/// Most recent novelty score in [0.0, 1.0] (0 = exact-match in bank,
/// 1 = no overlap). Consumed by the model-wake gate downstream.
pub(crate) last_novelty_score: Option<f32>,
/// ADR-110 / issue #1005: the `(n_subcarriers, ppdu_type)` grid this
/// node's rolling windows were built on. ESP32-C6 nodes interleave
/// HE-SU 256-bin frames with HT 64-bin frames on one socket; mixing
/// the two symbol grids in `frame_history` corrupts variance/baseline
/// statistics. See [`NodeState::accept_grid`].
active_grid: Option<(u16, wifi_densepose_hardware::PpduType)>,
}
/// Default EMA alpha for temporal keypoint smoothing (RuVector Phase 2).
@@ -666,35 +647,6 @@ impl NodeState {
),
),
last_novelty_score: None,
active_grid: None,
}
}
/// ADR-110 / issue #1005 grid gate: decide whether a frame on `grid`
/// may enter this node's feature path, and update `active_grid`.
///
/// Returns `true` to accept. Policy: lock onto the densest grid seen.
/// On a grid *upgrade* (more subcarriers — e.g. the first HE-SU 256-bin
/// frame after HT 64-bin history) the rolling amplitude history and
/// motion baseline are cleared so HT and HE symbol grids are never
/// mixed in one window. Sparser-grid frames (the ~16% HT minority an
/// ESP32-C6 keeps emitting alongside HE) are rejected from the feature
/// path; the caller still records the arrival for fps/liveness.
fn accept_grid(&mut self, grid: (u16, wifi_densepose_hardware::PpduType)) -> bool {
match self.active_grid {
None => {
self.active_grid = Some(grid);
true
}
Some(active) if active == grid => true,
Some((active_n, _)) if grid.0 > active_n => {
self.active_grid = Some(grid);
self.frame_history.clear();
self.baseline_motion = 0.0;
self.baseline_frames = 0;
true
}
Some(_) => false,
}
}
@@ -1422,25 +1374,19 @@ fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
// [17] noise_floor (i8)
// [18..19] reserved
// [20..] I/Q data
// Issue #1005: until 2026-06 this code read n_subcarriers from byte 6
// alone (an ESP32-C6 HE-SU frame's 256 = 0x0100 LE decoded as 0 — the
// frame parsed with zero subcarriers) and read sequence/rssi/noise at
// stale offsets 10/14/15. Offsets below match the comment (and firmware).
let node_id = buf[4];
let n_antennas = buf[5];
let n_subcarriers = u16::from_le_bytes([buf[6], buf[7]]);
let freq_mhz =
u16::try_from(u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]])).unwrap_or(0);
let sequence = u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]);
let rssi_raw = buf[16] as i8;
let n_subcarriers = buf[6];
let freq_mhz = u16::from_le_bytes([buf[8], buf[9]]);
let sequence = u32::from_le_bytes([buf[10], buf[11], buf[12], buf[13]]);
let rssi_raw = buf[14] as i8;
// Fix RSSI sign: ensure it's always negative (dBm convention).
let rssi = if rssi_raw > 0 {
rssi_raw.saturating_neg()
} else {
rssi_raw
};
let noise_floor = buf[17] as i8;
let ppdu_type = wifi_densepose_hardware::PpduType::from_byte(buf[18]);
let noise_floor = buf[15] as i8;
let iq_start = 20;
let n_pairs = n_antennas as usize * n_subcarriers as usize;
@@ -1469,7 +1415,6 @@ fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
sequence,
rssi,
noise_floor,
ppdu_type,
amplitudes,
phases,
})
@@ -2351,12 +2296,11 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) {
magic: 0xC511_0001,
node_id: 0,
n_antennas: 1,
n_subcarriers: obs_count.min(u16::MAX as usize) as u16,
n_subcarriers: obs_count.min(255) as u8,
freq_mhz: 2437,
sequence: seq,
rssi: first_rssi.clamp(-128.0, 127.0) as i8,
noise_floor: -90,
ppdu_type: wifi_densepose_hardware::PpduType::HtLegacy,
amplitudes: multi_ap_frame.amplitudes.clone(),
phases: multi_ap_frame.phases.clone(),
};
@@ -2538,7 +2482,6 @@ async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) {
sequence: seq,
rssi: rssi_dbm as i8,
noise_floor: -90,
ppdu_type: wifi_densepose_hardware::PpduType::HtLegacy,
amplitudes: vec![signal_pct],
phases: vec![0.0],
};
@@ -2672,11 +2615,7 @@ async fn probe_esp32(port: u16) -> bool {
let addr = format!("0.0.0.0:{port}");
match UdpSocket::bind(&addr).await {
Ok(sock) => {
// 2048 covers the largest ADR-018 frame: an ESP32-C6 HE-SU
// capture is 532 bytes (issue #1005); on Windows a too-small
// recv buffer makes recv_from error on the oversized datagram,
// which made this probe fail against HE-only streams.
let mut buf = [0u8; 2048];
let mut buf = [0u8; 256];
match tokio::time::timeout(Duration::from_secs(2), sock.recv_from(&mut buf)).await {
Ok(Ok((len, _))) => parse_esp32_frame(&buf[..len]).is_some(),
_ => false,
@@ -2705,12 +2644,11 @@ fn generate_simulated_frame(tick: u64) -> Esp32Frame {
magic: 0xC511_0001,
node_id: 1,
n_antennas: 1,
n_subcarriers: n_sub as u16,
n_subcarriers: n_sub as u8,
freq_mhz: 2437,
sequence: tick as u32,
rssi: (-40.0 + 5.0 * (t * 0.2).sin()) as i8,
noise_floor: -90,
ppdu_type: wifi_densepose_hardware::PpduType::HtLegacy,
amplitudes,
phases,
}
@@ -5293,34 +5231,6 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
s.source = "esp32".to_string();
s.last_esp32_frame = Some(std::time::Instant::now());
// ── ADR-110 / issue #1005: per-node subcarrier-grid gate ──
// ESP32-C6 nodes interleave HE-SU 256-bin frames (~84%)
// with HT 64-bin frames on the same socket. HT-LTF and
// HE-LTF symbol grids are not bin-comparable, so a frame
// on a different grid than the node's rolling window must
// not enter the feature path. Policy (NodeState::accept_grid):
// lock onto the densest grid seen, clear+re-warm on
// upgrade, skip sparser-grid frames (arrival still
// recorded for fps/liveness).
let grid_accepted = s
.node_states
.entry(frame.node_id)
.or_insert_with(NodeState::new)
.accept_grid(frame.grid());
if !grid_accepted {
debug!(
"node {}: skipping {}-subcarrier {:?} frame (active grid {:?})",
frame.node_id,
frame.n_subcarriers,
frame.ppdu_type,
s.node_states.get(&frame.node_id).and_then(|ns| ns.active_grid),
);
if let Some(ns) = s.node_states.get_mut(&frame.node_id) {
ns.observe_csi_frame_arrival(std::time::Instant::now());
}
continue;
}
// Also maintain global frame_history for backward compat
// (simulation path, REST endpoints, etc.).
s.frame_history.push_back(frame.amplitudes.clone());
@@ -12,7 +12,6 @@ use crate::rvf_container::RvfContainerInfo;
use crate::rvf_pipeline::ProgressiveLoader;
use crate::vital_signs::{VitalSignDetector, VitalSigns};
use wifi_densepose_hardware::PpduType;
use wifi_densepose_signal::ruvsense::field_model::FieldModel;
use wifi_densepose_signal::ruvsense::longitudinal::{EmbeddingEntry, EmbeddingHistory};
use wifi_densepose_signal::ruvsense::multistatic::MultistaticFuser;
@@ -85,33 +84,15 @@ pub struct Esp32Frame {
pub magic: u32,
pub node_id: u8,
pub n_antennas: u8,
/// Subcarrier bin count. u16 since ADR-110: ESP32-C6 HE-LTF frames carry
/// 256 bins (242 active HE20 tones) — issue #1005. HT frames stay ≤128.
pub n_subcarriers: u16,
pub n_subcarriers: u8,
pub freq_mhz: u16,
pub sequence: u32,
pub rssi: i8,
pub noise_floor: i8,
/// ADR-110 byte 18: PPDU type the CSI was sampled from (HT-LTF vs
/// HE-LTF symbol grids are NOT comparable bin-for-bin). Pre-ADR-110
/// firmware sends 0 ⇒ `PpduType::HtLegacy`.
pub ppdu_type: PpduType,
pub amplitudes: Vec<f64>,
pub phases: Vec<f64>,
}
impl Esp32Frame {
/// The (subcarrier-count, PPDU-type) pair identifying which symbol grid
/// this frame was sampled on. Frames from different grids must never be
/// mixed in one rolling baseline window (ADR-110 / issue #1005).
pub fn grid(&self) -> CsiGrid {
(self.n_subcarriers, self.ppdu_type)
}
}
/// Subcarrier-grid identity: `(n_subcarriers, ppdu_type)`.
pub type CsiGrid = (u16, PpduType);
// ── Sensing Update ──────────────────────────────────────────────────────────
/// Sensing update broadcast to WebSocket clients
@@ -300,14 +281,6 @@ pub struct NodeState {
/// `None` until the first `update_novelty` call. Consumed by the
/// model-wake gate downstream (low novelty → skip CNN, save energy).
pub last_novelty_score: Option<f32>,
/// ADR-110 / issue #1005: the `(n_subcarriers, ppdu_type)` grid this
/// node's rolling windows were built on. ESP32-C6 nodes interleave
/// HE-SU 256-bin frames with HT 64-bin frames on one socket; mixing
/// the two symbol grids in `frame_history` corrupts variance/baseline
/// statistics. Policy: lock onto the densest grid seen; frames on a
/// sparser grid are counted as arrivals but skipped by the feature
/// path; a grid upgrade clears the history and re-warms the baseline.
pub active_grid: Option<CsiGrid>,
}
impl Default for NodeState {
@@ -349,35 +322,6 @@ impl NodeState {
NOVELTY_SKETCH_VERSION,
)),
last_novelty_score: None,
active_grid: None,
}
}
/// ADR-110 / issue #1005 grid gate: decide whether a frame on `grid`
/// may enter this node's feature path, and update `active_grid`.
///
/// Returns `true` to accept. On a grid *upgrade* (more subcarriers than
/// the current grid — e.g. first HE-SU 256-bin frame after HT 64-bin
/// history) the rolling amplitude history and motion baseline are
/// cleared so HT and HE symbol grids are never mixed in one window.
/// Sparser-grid frames (the ~16% HT minority a C6 keeps emitting) are
/// rejected from the feature path.
pub fn accept_grid(&mut self, grid: CsiGrid) -> bool {
match self.active_grid {
None => {
self.active_grid = Some(grid);
true
}
Some(active) if active == grid => true,
Some((active_n, _)) if grid.0 > active_n => {
// Denser grid wins: re-key the window and re-warm baselines.
self.active_grid = Some(grid);
self.frame_history.clear();
self.baseline_motion = 0.0;
self.baseline_frames = 0;
true
}
Some(_) => false,
}
}
@@ -13,19 +13,19 @@ use std::time::Duration;
/// Build a minimal valid ESP32 CSI frame (magic 0xC511_0001).
///
/// Format (ADR-018, authoritative: firmware `csi_collector.c`):
/// [0..3] magic: 0xC511_0001 (LE)
/// [4] node_id
/// [5] n_antennas (1)
/// [6..7] n_subcarriers (LE u16 — 256 for ESP32-C6 HE-SU, issue #1005)
/// [8..11] freq_mhz (LE u32, 2437 = channel 6)
/// [12..15] sequence (LE u32)
/// [16] rssi (signed)
/// [17] noise_floor
/// [18] PPDU type (ADR-110: 0=HT/legacy, 1=HE-SU)
/// [19] flags (ADR-110)
/// [20..] I/Q pairs (n_antennas * n_subcarriers * 2 bytes)
fn build_csi_frame(node_id: u8, seq: u32, rssi: i8, n_sub: u16) -> Vec<u8> {
/// Format (ADR-018):
/// [0..3] magic: 0xC511_0001 (LE)
/// [4] node_id
/// [5] n_antennas (1)
/// [6] n_subcarriers (e.g., 32)
/// [7] reserved
/// [8..9] freq_mhz (2437 = channel 6)
/// [10..13] sequence (LE u32)
/// [14] rssi (signed)
/// [15] noise_floor
/// [16..19] reserved
/// [20..] I/Q pairs (n_antennas * n_subcarriers * 2 bytes)
fn build_csi_frame(node_id: u8, seq: u32, rssi: i8, n_sub: u8) -> Vec<u8> {
let n_pairs = n_sub as usize;
let mut buf = vec![0u8; 20 + n_pairs * 2];
@@ -35,19 +35,18 @@ fn build_csi_frame(node_id: u8, seq: u32, rssi: i8, n_sub: u16) -> Vec<u8> {
buf[4] = node_id;
buf[5] = 1; // n_antennas
buf[6..8].copy_from_slice(&n_sub.to_le_bytes());
buf[6] = n_sub;
buf[7] = 0;
// freq = 2437 MHz (channel 6)
let freq: u32 = 2437;
buf[8..12].copy_from_slice(&freq.to_le_bytes());
let freq: u16 = 2437;
buf[8..10].copy_from_slice(&freq.to_le_bytes());
// sequence
buf[12..16].copy_from_slice(&seq.to_le_bytes());
buf[10..14].copy_from_slice(&seq.to_le_bytes());
buf[16] = rssi as u8;
buf[17] = (-90i8) as u8; // noise floor
buf[18] = u8::from(n_sub >= 256); // ADR-110 PPDU type: HE-SU for 256-bin
buf[19] = 0; // ADR-110 flags
buf[14] = rssi as u8;
buf[15] = (-90i8) as u8; // noise floor
// Generate I/Q pairs with node-specific patterns.
// Different nodes produce different amplitude patterns so the server
@@ -137,7 +136,7 @@ fn test_multi_node_udp_send() {
sock.set_write_timeout(Some(Duration::from_millis(100)))
.ok();
let n_sub = 32u16;
let n_sub = 32u8;
let node_ids = [1u8, 2, 3, 5, 7];
for &nid in &node_ids {
@@ -162,13 +161,11 @@ fn test_multi_node_udp_send() {
/// size for various subcarrier counts (boundary testing).
#[test]
fn test_frame_sizes() {
// 256 = ESP32-C6 HE-SU grid (issue #1005) → 532-byte frame as on the wire.
for n_sub in [1u16, 16, 32, 52, 56, 64, 128, 256] {
for n_sub in [1u8, 16, 32, 52, 56, 64, 128] {
let frame = build_csi_frame(1, 0, -50, n_sub);
let expected = 20 + (n_sub as usize) * 2;
assert_eq!(frame.len(), expected, "wrong size for n_sub={n_sub}");
}
assert_eq!(build_csi_frame(1, 0, -50, 256).len(), 532);
}
/// Simulate a mesh of N nodes sending frames at different rates.
@@ -1,8 +1,7 @@
[package]
name = "wifi-densepose-worldgraph"
description = "ADR-139 — WorldGraph environmental digital twin (typed petgraph) for RuView"
readme = "README.md"
version = "0.3.1"
version = "0.3.0"
edition.workspace = true
authors.workspace = true
license.workspace = true
@@ -1,109 +0,0 @@
# wifi-densepose-worldgraph
**The environmental digital twin for RF sensing — a typed, evidence-tracked graph of a building and the people in it.**
[![crates.io](https://img.shields.io/crates/v/wifi-densepose-worldgraph.svg)](https://crates.io/crates/wifi-densepose-worldgraph)
[![docs.rs](https://docs.rs/wifi-densepose-worldgraph/badge.svg)](https://docs.rs/wifi-densepose-worldgraph)
Part of the [RuView / WiFi-DensePose](https://github.com/ruvnet/RuView) project. Implements **ADR-139**.
---
## What it is (plain language)
When you sense a space with WiFi/RF (people, motion, vital signs), you get a firehose of *frames*.
What you actually want is a **living map**: which rooms exist, where the walls and doorways are, which
sensors watch which zones, where each person is right now, and *why the system believes that* — with
enough structure to reason over and enough provenance to trust.
`wifi-densepose-worldgraph` is that map. It's a **typed graph** (built on [`petgraph`](https://crates.io/crates/petgraph)):
- **Nodes** are real things — `Room`, `Zone`, `Wall`, `Doorway`, `Sensor`, `RfLink`, `PersonTrack`, `ObjectAnchor`, `Event`, and `SemanticState` (a belief).
- **Edges** are typed relations — `Observes`, `LocatedIn`, `AdjacentTo`, `Supports`, `Contradicts`, `DerivedFrom`, `PrivacyLimitedBy`.
It stores **fused beliefs, not raw frames** — it sits *downstream* of signal fusion and *upstream* of the
semantic/agent layer. Every belief (`SemanticState`) is required to carry **provenance**: the signal
evidence, the model, the calibration id, and the privacy decision that produced it. That's enforced
*structurally*, so "where did this conclusion come from?" always has an answer.
## Why a graph (and not an occupancy grid or an event log)?
| Approach | Good at | Misses |
|---|---|---|
| **Raw event log** | append-only history, audit | no structure; can't ask "who's in the kitchen?" without re-deriving it |
| **Occupancy grid / voxels** | dense geometry, ML input | no identity, no relations, no provenance, no semantics |
| **Scene graph (this crate)** | relations, identity, semantics, provenance, privacy | not a dense field — pair it with a grid for ML (see [`wifi-densepose-worldmodel`](https://crates.io/crates/wifi-densepose-worldmodel)) |
The graph is the **symbolic, interpretable** layer. It answers *relational* questions ("is this person in a
zone observed by sensor X?", "are these two beliefs contradictory?") in O(neighbors), and it keeps the
*why* attached to every *what*.
## Features
- 🧱 **Typed node/edge model** — a closed `enum` schema (serde-tagged) → deterministic, schema-versioned wire format.
- 🧭 **Geometry in ENU meters** — rooms/zones/walls/doorways carry East-North-Up bounds; walls carry `rf_attenuation_db`.
- 🧠 **Beliefs with mandatory provenance**`SemanticState``SemanticProvenance { signal evidence, model, calibration_id, privacy_decision }`.
- 🔀 **Evidence reasoning built in**`Supports` / `Contradicts` / `DerivedFrom` edges let you score and challenge conclusions, not just store them.
- 🔒 **Privacy as a first-class edge**`PrivacyLimitedBy` + `apply_privacy_mode()` roll up what a given mode/action is allowed to see.
- 💾 **Deterministic JSON persistence**`to_json` / `from_json` (the RVF payload), schema-versioned.
- 🚫 **`#![forbid(unsafe_code)]`**, `missing_docs = warn`. Pure Rust, no async, edge-deployable (builds clean on aarch64 — runs on a Raspberry Pi).
## Install
```toml
[dependencies]
wifi-densepose-worldgraph = "0.3"
```
## Usage
```rust
use wifi_densepose_worldgraph::{WorldGraph, WorldNode, WorldEdge, ZoneBoundsEnu};
// (GeoRegistration comes from wifi-densepose-geo — it anchors ENU to a real lat/lon origin)
let mut wg = WorldGraph::new(registration);
// Add a room and a sensor that observes it.
let living_room = wg.upsert_node(WorldNode::Room {
id: Default::default(),
area_id: Some("living_room".into()),
name: "Living Room".into(),
bounds_enu: ZoneBoundsEnu { /* … */ },
floor: 0,
});
let sensor = wg.upsert_node(/* WorldNode::Sensor { … } */);
wg.add_edge(sensor, living_room, WorldEdge::Observes { quality: 0.9, last_seen_unix_ms: now });
// Query relations.
let watched = wg.observed_by(sensor); // what this sensor sees
let room = wg.room_for_area("living_room"); // area_id → room node
// Record a belief WITH provenance, and a contradiction against it.
wg.add_semantic_state(/* state + SemanticProvenance */);
wg.add_contradiction(belief_a, belief_b, /* magnitude */, "two sensors disagree");
// Privacy rollup for a mode/action, then persist.
let rollup = wg.apply_privacy_mode("HOME", "occworld_inference", |node| /* allow? */ true);
let bytes = wg.to_json()?; // RVF payload
let restored = WorldGraph::from_json(&bytes)?;
```
## Technical details
- **Backing store:** `petgraph::StableDiGraph` (stable indices across removals) wrapped as `WorldGraph`.
- **Identity:** every node has a `WorldId`; `upsert_node` is idempotent on identity.
- **Snapshots:** `snapshot()``WorldGraphSnapshot` (a serializable point-in-time view) with a `PrivacyRollup`.
- **Schema versioning:** `SCHEMA_VERSION` is embedded in the JSON; the closed enum model means readers fail fast on incompatible payloads rather than silently mis-parsing.
- **Coordinates:** ENU (East/North/Up) meters relative to a `GeoRegistration` origin (`wifi-densepose-geo`), so the twin can be georeferenced to a real building.
- **Position in the pipeline:** `fusion (ADR-137) → WorldGraph (ADR-139) → semantic/agent layer (ADR-140) → eval harness (ADR-145)`. For **forward prediction** (where will people be next?), pair it with [`wifi-densepose-worldmodel`](https://crates.io/crates/wifi-densepose-worldmodel), which turns `PersonTrack` history into predicted occupancy + trajectory priors.
## Related crates
| Crate | Role |
|---|---|
| [`wifi-densepose-worldmodel`](https://crates.io/crates/wifi-densepose-worldmodel) | Forward **prediction** — occupancy world model over this graph's tracks |
| [`wifi-densepose-geo`](https://crates.io/crates/wifi-densepose-geo) | Geospatial registration (ENU ↔ lat/lon, DEM, OSM) |
## License
Licensed as the parent project. See the [repository](https://github.com/ruvnet/RuView).
@@ -1,8 +1,7 @@
[package]
name = "wifi-densepose-worldmodel"
description = "ADR-147 — OccWorld thin-client bridge: WorldGraph PersonTrack history → OccWorld Python subprocess → TrajectoryPrior"
readme = "README.md"
version = "0.3.1"
version = "0.3.0"
edition.workspace = true
authors.workspace = true
license.workspace = true
@@ -13,7 +12,7 @@ tokio = { version = "1", features = ["net", "io-util", "macros", "time"] }
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
thiserror.workspace = true
wifi-densepose-worldgraph = { version = "0.3.1", path = "../wifi-densepose-worldgraph" }
wifi-densepose-worldgraph = "0.3.0"
[lints.rust]
unsafe_code = "forbid"
@@ -1,127 +0,0 @@
# wifi-densepose-worldmodel
**Forward prediction for RF sensing — turn where people *were* into where they'll *be*, as occupancy + trajectory priors.**
[![crates.io](https://img.shields.io/crates/v/wifi-densepose-worldmodel.svg)](https://crates.io/crates/wifi-densepose-worldmodel)
[![docs.rs](https://docs.rs/wifi-densepose-worldmodel/badge.svg)](https://docs.rs/wifi-densepose-worldmodel)
Part of the [RuView / WiFi-DensePose](https://github.com/ruvnet/RuView) project. Implements **ADR-147**.
---
## What it is (plain language)
[`wifi-densepose-worldgraph`](https://crates.io/crates/wifi-densepose-worldgraph) tells you **what the room is
*now*** (who's where, the walls, the doorways). This crate answers the next question: **what happens *next*?**
It's a **thin, async client** to an *occupancy world model* (OccWorld). You give it a short history of where
people have been (their `PersonTrack` positions); it rasterizes that into 3-D occupancy grids, ships them to
an OccWorld inference process, and gets back:
- **predicted future occupancy** (the model rolls the scene forward N steps), and
- **`TrajectoryPrior`s** — per-person predicted waypoints you can feed straight into a Kalman pose tracker to
stabilize and *anticipate* movement (e.g. someone heading for a doorway).
It is **camera-free and privacy-first**: the world model reasons over **occupancy voxels**, not video — so it
predicts *where*, never *who-looks-like-what*. (This is the deliberate contrast with pixel-space robot world
models like ByteDance's IRASim: same "predict-the-future-conditioned-on-state" idea, kept in occupancy space
for privacy and edge deployment.)
## Where it sits
```
RF frames → fusion → WorldGraph (what is) ──PersonTrack history──► wifi-densepose-worldmodel
▲ │
│ OccWorld inference (Python subprocess)
└────────── TrajectoryPriors (what's next) ◄──────┘
(injected back into the Kalman tracker)
```
## Symbolic vs predictive — the two halves of the world model
| | `wifi-densepose-worldgraph` | `wifi-densepose-worldmodel` (this crate) |
|---|---|---|
| **Question** | "What is the room *now*?" | "What happens *next*?" |
| **Representation** | typed symbolic graph (rooms, tracks, beliefs) | dense 3-D occupancy voxels + trajectory priors |
| **Nature** | interpretable, evidential, provenance-tracked | predictive, learned (OccWorld) |
| **Compute** | pure Rust, microseconds, edge | Rust client + GPU inference subprocess |
| **Output** | relations & beliefs | future occupancy + per-person waypoints |
Use them together: the graph supplies tracks + privacy decisions; this crate predicts forward and feeds the
priors back.
## Features
- 🔌 **Thin async bridge**`OccWorldBridge` talks to the OccWorld inference process over a Unix socket (newline-delimited JSON request/response).
- 🧊 **Occupancy rasterization**`worldgraph_to_occupancy()` turns person positions + scene bounds into a 3-D voxel grid (`200 × 200 × 16` by default; `CLASS_PERSON` / `CLASS_FREE` semantics).
- 🧭 **ENU ↔ voxel mapping**`SceneBounds::to_voxel_xy()` / `to_voxel_z()` with a configurable resolution (e.g. 0.1 m).
- 🛰️ **Trajectory priors** — predicted per-`track_id` waypoints, ready for Kalman injection.
- 🔁 **Backend-swappable** — the request/response contract (`OccupancyWorldModelRequest` → response with `confidence` + `trajectory_priors`) is model-agnostic (OccWorld today, RoboOccWorld / others later).
- 🔒 **Privacy-gated by design** — meant to be called only when the WorldGraph's privacy mode permits it (ADR-141); reasons over occupancy, never pixels.
- 🚫 **`#![forbid(unsafe_code)]`**, `missing_docs = warn`.
## Install
```toml
[dependencies]
wifi-densepose-worldmodel = "0.3"
```
> The bridge uses Unix domain sockets (`tokio`), so the client targets Unix-like hosts (Linux/macOS — e.g. a Raspberry Pi appliance). The data types (occupancy, bounds, priors) are platform-agnostic.
## Usage
```rust
use wifi_densepose_worldmodel::{
OccWorldBridge, OccupancyWorldModelRequest, SceneBoundsJson, worldgraph_to_occupancy,
};
use wifi_densepose_worldmodel::occupancy::{PersonPosition, SceneBounds};
# async fn example() -> Result<(), wifi_densepose_worldmodel::WorldModelError> {
let bridge = OccWorldBridge::new("/tmp/occworld.sock");
let bounds = SceneBounds { min_e: -10.0, min_n: -10.0, max_e: 10.0, max_n: 10.0 };
let persons = vec![PersonPosition { track_id: 1, east_m: 2.0, north_m: 3.0, up_m: 1.0 }];
// Rasterize current positions → an occupancy frame (0.1 m voxels).
let frame = worldgraph_to_occupancy(&persons, &bounds, 0.1);
// Ask OccWorld to roll the scene forward 15 steps.
let response = bridge.predict(OccupancyWorldModelRequest {
past_frames: vec![frame],
voxel_resolution_m: 0.1,
scene_bounds: SceneBoundsJson { min_e: bounds.min_e, min_n: bounds.min_n,
max_e: bounds.max_e, max_n: bounds.max_n },
prediction_steps: 15,
}).await?;
println!("confidence={:.2}", response.confidence);
for prior in &response.trajectory_priors {
println!("track {} → {} predicted waypoints", prior.track_id, prior.waypoints.len());
}
# Ok(())
# }
```
## Technical details
- **Wire protocol:** newline-delimited JSON over a Unix socket; one request → one response. The Python side
(OccWorld) loads `PersonTrack` history as a `(B, F, H, W, D)` occupancy tensor and returns predicted voxels
decoded into `TrajectoryPrior`s.
- **Grid:** `GRID_WIDTH=200 × GRID_HEIGHT=200 × GRID_DEPTH=16` voxels by default; `CLASS_PERSON=10`,
`CLASS_FREE=17` (RuView indoor class remap from the nuScenes outdoor set).
- **Resolution:** configurable meters-per-voxel; `to_voxel_xy`/`to_voxel_z` handle ENU→index.
- **Backend:** OccWorld (1.65 GB VRAM, ~375 ms/inference on an RTX-class GPU; runs on the Pi+Hailo appliance
tier). Cosmos is the deferred heavier alternative (ADR-148).
- **Provenance:** predictions carry the originating `calibration_id` + privacy decision so downstream
consumers can gate on quality and consent (ADR-141).
## Related crates
| Crate | Role |
|---|---|
| [`wifi-densepose-worldgraph`](https://crates.io/crates/wifi-densepose-worldgraph) | The symbolic twin ("what is") that supplies the tracks this crate predicts from |
## License
Licensed as the parent project. See the [repository](https://github.com/ruvnet/RuView).
+1 -1