mirror of
https://github.com/ruvnet/RuView
synced 2026-06-11 10:33:19 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a307138f2 | |||
| 992c2b25cb | |||
| 5789351b78 | |||
| b6420ac9ba | |||
| c353255672 | |||
| 872d7593bb | |||
| 2c136aca74 | |||
| 69e61e3437 | |||
| d9e87e13b4 |
@@ -46,7 +46,10 @@ jobs:
|
||||
|
||||
- name: Run Bandit security scan
|
||||
run: |
|
||||
bandit -r src/ -f sarif -o bandit-results.sarif
|
||||
# The Python codebase lives under archive/v1/src (it moved there when
|
||||
# the runtime was rewritten in Rust). Scanning `src/` matched nothing,
|
||||
# so this SAST step was a silent no-op.
|
||||
bandit -r archive/v1/src/ -f sarif -o bandit-results.sarif
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload Bandit results to GitHub Security
|
||||
@@ -57,22 +60,20 @@ jobs:
|
||||
sarif_file: bandit-results.sarif
|
||||
category: bandit
|
||||
|
||||
- name: Run Semgrep security scan
|
||||
continue-on-error: true
|
||||
uses: returntocorp/semgrep-action@v1
|
||||
with:
|
||||
config: >-
|
||||
p/security-audit
|
||||
p/secrets
|
||||
p/python
|
||||
p/docker
|
||||
p/kubernetes
|
||||
env:
|
||||
SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }}
|
||||
|
||||
- name: Generate Semgrep SARIF
|
||||
# Removed the deprecated `returntocorp/semgrep-action@v1` step: it was
|
||||
# redundant (the pip `semgrep --sarif` below is what feeds GitHub Security;
|
||||
# the action only pushed to the Semgrep cloud app via SEMGREP_APP_TOKEN) and
|
||||
# it pulled `returntocorp/semgrep-agent:v1` from Docker Hub on every run,
|
||||
# which intermittently timed out and turned this check red. The pip semgrep
|
||||
# (installed above) needs no Docker pull. The action's `p/docker` +
|
||||
# `p/kubernetes` rulesets are folded into the command below so coverage is
|
||||
# preserved.
|
||||
- name: Run Semgrep + generate SARIF
|
||||
run: |
|
||||
semgrep --config=p/security-audit --config=p/secrets --config=p/python --sarif --output=semgrep.sarif src/
|
||||
semgrep \
|
||||
--config=p/security-audit --config=p/secrets --config=p/python \
|
||||
--config=p/docker --config=p/kubernetes \
|
||||
--sarif --output=semgrep.sarif archive/v1/src/
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload Semgrep results to GitHub Security
|
||||
|
||||
@@ -8,12 +8,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- **ESP32 edge heart rate no longer stuck at ~45 BPM / dropping wildly — #987.** The on-device HR estimator (`edge_processing.c`, `0xC5110002`) reported ~45 BPM regardless of true heart rate (Apple-Watch ground truth 87 BPM read as ~45) and swung frame-to-frame. Two root causes: (1) a hardcoded `sample_rate = 10.0f` that became wrong after #985's self-ping raised the CSI callback rate to a variable ~13–19 Hz — BPM scales as `assumed/actual × true`, so 87 read ~45 and the reading swung as CSI yield fluctuated; (2) the zero-crossing estimator locked onto a breathing harmonic (a 0.25 Hz breathing fundamental puts its 3rd harmonic at ~0.74 Hz ≈ 44 BPM inside the HR band). Fix: measure the real sample rate from inter-frame timestamps (used for BPM conversion + biquad re-tuning on >15% drift); replace the HR zero-crossing with an autocorrelation estimator that rejects breathing harmonics (driven by a robust autocorr breathing period); median-13 smooth the output. Hardware A/B (fixed vs unmodified control board, both `edge_tier=2`): control pegged 40–49 BPM; fixed reaches the true 88–91 BPM (vs 87 GT) and holds a stable physiological value (spread 59→0 for a steady subject). Known limitation: heavy subject motion still degrades the estimate (motion gating is a follow-up).
|
||||
- **Person count no longer leaks up to 10 in heuristic mode — addresses #894.** `field_bridge::occupancy_or_fallback` returned the eigenvalue-based `FieldModel::estimate_occupancy` count **unbounded** (its internal ceiling is 10), while the sibling estimators on the same single-link data — the perturbation-energy fallback right below it and `score_to_person_count` — both cap at 3 ("1-3 for single ESP32"). On noisy / under-calibrated CSI the eigenvalue count inflated, producing the "10 persons reported when 1 present" symptom (seen when `--model` fails to load and the server runs on heuristics). Bounded the eigenvalue path to the shared `MAX_SINGLE_LINK_OCCUPANCY` (3) so every estimator on one link agrees; genuine higher counts come from the multistatic fusion path, not a single-link covariance estimate.
|
||||
- **MQTT multi-node deployments now create one Home-Assistant device per node — closes #898.** After the #872 MQTT wiring landed, the JSON→`VitalsSnapshot` bridge hard-coded a single `node_id` (the MQTT client id) and the publisher used a single `OwnedDiscoveryBuilder`, so every physical node collapsed into one device (`identifiers:["wifi_densepose_wifi-densepose-1"]`), contradicting the "one device per node" docs. The bridge now emits one snapshot per node in the sensing update's `nodes[]` (each with its own `node_id` + RSSI, falling back to a single aggregate snapshot for wifi/simulate sources), and the publisher derives a per-node builder (`OwnedDiscoveryBuilder::for_node`) that publishes discovery + availability lazily on first sight of each `node_id` and routes state to per-node topics — yielding N distinct HA devices with per-node availability/LWT. Unit-tested (distinct nodes → distinct `wifi_densepose_<node>` identifiers); 71 MQTT tests pass.
|
||||
- **Person count no longer pinned to 1 — addresses #803.** The aggregate occupancy reported by the sensing server was derived from `smoothed_person_score`, an EMA-smoothed *activity* score (amplitude variance / motion / spectral energy). That score saturates near a single occupant — one moving person maxes it out — so it cannot discriminate occupancy *count* and stayed clamped at 1 across S3/C6 and the Python/Docker/Rust servers. Meanwhile the count-aware per-node estimates the ESP32 paths already compute (firmware `n_persons`, and the DynamicMinCut `corr_persons`) were stashed in `NodeState::prev_person_count` and then **discarded** by the aggregator (same dead-wiring class as #872). The aggregator now takes `max(activity_count, node_max)` via a unit-tested `aggregate_person_count` helper, so a node positively estimating 2–3 occupants is surfaced instead of overwritten. The fix can only ever *raise* the count when a node reports more people, so the single-occupant case is provably never inflated (regression-guarded by test). **Second half:** the pure-CSI per-node path itself clamped its own estimate — the DynamicMinCut occupancy (`estimate_persons_from_correlation`, 0–3) was mapped to a score via `corr_persons / 3.0`, putting 2 people at 0.667, *just under* the 0.70 up-threshold of `score_to_person_count`, so the per-node count never climbed past 1 (so `node_max` was also stuck at 1 for CSI-only nodes). Replaced it with a threshold-aligned `corr_persons_to_score` mapping (1→0.40, 2→0.74, 3→0.96) whose steady state round-trips back to the same count through the EMA + hysteresis, while still gating transient noise. A convergence test replays the exact EMA loop to prove min-cut=2 now reports 2 (and documents that the old `/3.0` mapping reported 1). Full multi-person accuracy still depends on the underlying estimator quality; this removes the two server-side clamps that masked it. 586 sensing-server tests pass.
|
||||
- **MQTT publisher now actually runs (`--mqtt`) — closes #872.** The `--mqtt*` flags were defined only in `cli::Args` (dead code, referenced nowhere) while the binary parses a *separate* `main::Args` with no mqtt fields, and `main.rs` never started the `mqtt::` publisher — so MQTT/Home-Assistant integration was completely unwired (`--mqtt` errored as an unexpected argument, and even with the Docker image's `--features mqtt` build the publisher never ran). Earlier attempts chased a Docker *rebuild*; the real cause was disconnected *code*. Extracted the flags into a shared `cli::MqttArgs` (`#[command(flatten)]` into both structs), spawn the publisher on `--mqtt`, and bridge the JSON sensing broadcast into the typed `VitalsSnapshot` stream with a defensive `serde_json::Value` mapping. Verified end-to-end against `mosquitto`: 20 HA auto-discovery entities + live state (presence/person-count/…). 577 (default) / 580 (`--features mqtt`) tests pass.
|
||||
- **Mass Casualty triage never reports a survivor with a heartbeat as Deceased (safety) — PR #926.** Both triage paths in `wifi-densepose-mat` — `TriageCalculator::calculate` (`combine_assessments(Absent, None) ⇒ Deceased`) and the detection path `EnsembleClassifier::determine_triage` (`!has_breathing && !has_movement ⇒ Deceased`) — ignored the `heartbeat` field. A survivor with a detectable **pulse** but no sensed breathing/movement (respiratory arrest — the most time-critical *savable* state, Immediate/Red) was therefore reported **Deceased (Black)** and deprioritized for rescue. The domain path was in fact only reachable *because* a heartbeat made `has_vitals()` true, so every "Deceased" was a live person. Both paths now escalate to **Immediate** when a heartbeat is present; total absence of breathing, movement *and* heartbeat is unchanged (domain → `Unknown`, ensemble → `Deceased`). 2 safety regression tests; full MAT suite (177) green.
|
||||
- **Per-node Home-Assistant devices now report each node's *own* presence/motion — PR #918.** After the one-device-per-node fan-out landed, the MQTT bridge still applied the *room-level aggregate* `classification` to every node, so in a multi-node deployment a node watching an empty corner inherited another node's "present" (and `motion_level: "absent"` was mis-mapped to full motion). Each node in the broadcast `nodes[]` already carries its own `classification`; the bridge now reads it per node (extracted into a testable `vitals_snapshots_from_sensing_json`), keeping vitals + person count room-level. 4 unit tests.
|
||||
- **`--model` gives an actionable diagnostic instead of a cryptic magic error — PR #919 (refs #894).** Passing a HuggingFace `ruvnet/wifi-densepose-pretrained` file (`model.safetensors` / `model-q4.bin` / `model.rvf.jsonl`) to `--model` produced `invalid magic at offset 0: … got 0x77455735`, then a silent fall back to heuristics. The load-failure path now detects the format (safetensors / quantized blob / JSONL manifest) and explains that those files are a different format **and** encoder architecture than the RVF binary container the progressive loader expects, pointing to #894. Pure `diagnose_model_load_error` + 4 tests.
|
||||
- **`--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 (~16–31 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.2–3.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 **~100–200 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.2–3.6.
|
||||
@@ -33,6 +42,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Security
|
||||
- **ESP32 OTA upload now fails closed when no PSK is provisioned** (#596 audit finding — critical, **breaking change for unprovisioned nodes**). `ota_check_auth()` previously returned `true` when `s_ota_psk[0] == '\0'`, so a freshly-flashed node would accept attacker-controlled firmware over plain HTTP on port 8032 from any host on the WiFi. No Secure Boot V2, no signed-image verification — a single LAN call could brick or backdoor a node. The fix rejects every OTA upload until a PSK is written to NVS (the OTA HTTP server still starts so operators can run `provision.py --ota-psk <hex>` over USB-CDC without reflashing). **Operators affected**: any deployment that relied on the unauthenticated OTA endpoint working out of the box now needs to provision a PSK before subsequent OTA pushes will succeed. Boot-time `ESP_LOGW` makes the new posture visible.
|
||||
- **Bearer-token auth accepts the scheme case-insensitively (RFC 6750) — PR #929.** `require_bearer` parsed the `Authorization` header with a case-sensitive `strip_prefix("Bearer ")`, so a *correct* `RUVIEW_API_TOKEN` sent as `Authorization: bearer <token>` (or `BEARER`, or with extra whitespace) was rejected with a confusing 401 — needless friction when enabling auth. The scheme is now matched with `eq_ignore_ascii_case` (per RFC 6750 §2.1 / RFC 7235 §2.1); the token compare is unchanged — still exact and constant-time (`ct_eq`) — so a wrong token or a non-Bearer scheme (`Basic …`) still returns 401. Audited the surrounding code while here: `ct_eq` correctly rejects length mismatch (no prefix-auth bypass) and the middleware fails closed. New `accepts_case_insensitive_bearer_scheme` test.
|
||||
- **Path-traversal vulnerabilities patched in five sensing-server endpoints** (closes #615 — critical). New `wifi_densepose_sensing_server::path_safety::safe_id()` enforces `[A-Za-z0-9._-]` only (no leading `.`, max 64 chars) before any user-controlled identifier reaches a `format!()` building a filesystem path. Applied at:
|
||||
- `POST /api/v1/recording/start` (`recording.rs` — `session_name`)
|
||||
- `GET /api/v1/recording/download/:id` (`recording.rs` — `id`)
|
||||
|
||||
@@ -15,7 +15,8 @@ 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) |
|
||||
| `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-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) |
|
||||
|
||||
@@ -24,10 +24,13 @@ services:
|
||||
environment:
|
||||
- RUST_LOG=info
|
||||
# CSI_SOURCE controls the data source for the sensing server.
|
||||
# Options: auto (default) — probe for ESP32 UDP then fall back to simulation
|
||||
# Options: auto (default) — probe for ESP32 UDP then host WiFi; **fail
|
||||
# hard with exit 78 if neither is detected**.
|
||||
# Synthetic data is no longer a silent fallback
|
||||
# (issue #937 fix) — operators must opt in.
|
||||
# esp32 — receive real CSI frames from an ESP32 on UDP port 5005
|
||||
# wifi — use host Wi-Fi RSSI/scan data (Windows netsh)
|
||||
# simulated — generate synthetic CSI data (no hardware required)
|
||||
# simulated — explicitly generate synthetic CSI for demo mode
|
||||
- CSI_SOURCE=${CSI_SOURCE:-auto}
|
||||
# MODELS_DIR controls where the server scans for .rvf model files.
|
||||
# Mount a host directory and set this to make models visible:
|
||||
|
||||
@@ -11,10 +11,65 @@
|
||||
# docker run ruvnet/wifi-densepose:latest --model /app/models/my.rvf
|
||||
#
|
||||
# Environment variables:
|
||||
# CSI_SOURCE — data source: auto (default), esp32, wifi, simulated
|
||||
# CSI_SOURCE — data source. Valid values:
|
||||
# auto — try ESP32 then Windows WiFi, **fail-loud if no
|
||||
# real hardware is detected** (issue #937 fix:
|
||||
# the server no longer silently falls back to
|
||||
# synthetic data — that's now opt-in only).
|
||||
# esp32 — listen for UDP CSI on the configured port.
|
||||
# wifi — Windows-native WiFi capture.
|
||||
# simulated — explicit demo mode with synthetic CSI.
|
||||
# Default is `auto`. Set CSI_SOURCE=simulated when you want
|
||||
# fake data tagged as such; never set it implicitly.
|
||||
# MODELS_DIR — directory to scan for .rvf model files (default: data/models)
|
||||
set -e
|
||||
|
||||
# ── Issue #864: fail-closed on default posture ───────────────────────────────
|
||||
# The pre-fix default was: empty RUVIEW_API_TOKEN (auth off) + --bind-addr
|
||||
# 0.0.0.0 + docker-compose publishing :3000/:3001/:5005 → an unauthenticated
|
||||
# attacker on any reachable network segment could read /api/v1/sensing/latest
|
||||
# and the /ws/sensing live stream. That posture is unsafe on guest WiFi,
|
||||
# untrusted LANs, accidentally-port-forwarded hosts, or any reverse-proxied
|
||||
# deployment. Refuse to start with this combination.
|
||||
#
|
||||
# Escape hatches (operator must opt in explicitly):
|
||||
# * Set RUVIEW_API_TOKEN to a strong secret → auth enabled on /api/v1/*.
|
||||
# * Set RUVIEW_ALLOW_UNAUTHENTICATED=1 → preserves the pre-fix behaviour;
|
||||
# only safe on an isolated trust boundary.
|
||||
# * Set RUVIEW_BIND_ADDR to a loopback / private interface → unauth is fine
|
||||
# when the socket isn't reachable. The auto-bind nudges toward 127.0.0.1.
|
||||
#
|
||||
# This check runs only for the default sensing-server path (no args + flag-only
|
||||
# args). The `cog-ha-matter` / `homecore` routes below are excluded because
|
||||
# they own their own auth lifecycle.
|
||||
case "${1:-}" in
|
||||
cog-ha-matter|ha-matter|homecore|homecore-server) ;;
|
||||
*)
|
||||
if [ -z "${RUVIEW_API_TOKEN:-}" ] && [ "${RUVIEW_ALLOW_UNAUTHENTICATED:-}" != "1" ]; then
|
||||
# If the operator hasn't overridden the bind, refuse outright on
|
||||
# the default 0.0.0.0. If they've nailed it to loopback (or a
|
||||
# specific private address they trust), let it run.
|
||||
__bind_default="${RUVIEW_BIND_ADDR:-0.0.0.0}"
|
||||
case "$__bind_default" in
|
||||
127.*|localhost|::1)
|
||||
: ;; # loopback bind is safe even without a token
|
||||
*)
|
||||
echo "[entrypoint] ERROR: refusing to start sensing-server with default" >&2
|
||||
echo "[entrypoint] posture: RUVIEW_API_TOKEN is unset AND bind is" >&2
|
||||
echo "[entrypoint] ${__bind_default}. /ws/sensing streams live sensing" >&2
|
||||
echo "[entrypoint] frames; that data would be readable by anyone who" >&2
|
||||
echo "[entrypoint] can reach this host. Pick one:" >&2
|
||||
echo "[entrypoint] docker run -e RUVIEW_API_TOKEN=\$(openssl rand -hex 32) ..." >&2
|
||||
echo "[entrypoint] docker run -e RUVIEW_BIND_ADDR=127.0.0.1 ..." >&2
|
||||
echo "[entrypoint] docker run -e RUVIEW_ALLOW_UNAUTHENTICATED=1 ... # only on trusted network" >&2
|
||||
echo "[entrypoint] See https://github.com/ruvnet/RuView/issues/864" >&2
|
||||
exit 64
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
# Route to cog-ha-matter (ADR-116) when invoked as:
|
||||
# docker run <image> cog-ha-matter [--flags]
|
||||
# or via the short alias `ha-matter`. Strips the keyword and execs the
|
||||
@@ -48,7 +103,7 @@ if [ "${1#-}" != "$1" ] || [ -z "$1" ]; then
|
||||
--ui-path /app/ui \
|
||||
--http-port 3000 \
|
||||
--ws-port 3001 \
|
||||
--bind-addr 0.0.0.0 \
|
||||
--bind-addr "${RUVIEW_BIND_ADDR:-0.0.0.0}" \
|
||||
"$@"
|
||||
fi
|
||||
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
# ADR-151: RuView Per-Room Calibration & Specialized Model Training System
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — Stages 1–5 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.1–0.15 Hz | 30 s | slow respiration | periodicity, micro-Doppler |
|
||||
| `breathe_normal` | resp≈0.2–0.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.
|
||||
- **Teacher–student 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 teacher–student distillation | Drift marks bank STALE; AetherArena entry | ◐ Partial (STALE done; SONA/federation/HF-backbone = follow-ups) |
|
||||
|
||||
**Current status (2026-06-10):** Stages 1–5 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 ~16–31 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.
|
||||
@@ -0,0 +1,98 @@
|
||||
# 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 2025–2026 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; 20–160 MHz) — reports zero-shot cross-domain gains of 2.2–15.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.4–0.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 (~5–10× 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 2025–2026 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) |
|
||||
@@ -79,6 +79,10 @@ 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
|
||||
|
||||
@@ -93,6 +97,8 @@ 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
|
||||
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
# 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 ~16–31 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).
|
||||
@@ -65,6 +65,15 @@ target_compile_definitions(${COMPONENT_LIB} PUBLIC
|
||||
d_m3LogOutput=0 # Disable WASM3 stdout logging (use ESP_LOG)
|
||||
d_m3FixedHeap=0 # Use dynamic allocation (PSRAM-friendly)
|
||||
WASM3_AVAILABLE=1 # Flag for conditional compilation
|
||||
# Issue #946: GCC 15.2.0 for Xtensa (ESP-IDF v6.0.1) rejects wasm3's
|
||||
# `M3_MUSTTAIL` aggressive tail-call attribute with
|
||||
# "cannot tail-call: machine description does not have a sibcall_epilogue
|
||||
# instruction pattern". wasm3 falls back to a regular call sequence when
|
||||
# M3_NO_MUSTTAIL is defined — slightly slower per opcode but functionally
|
||||
# identical. Forcing it off unconditionally on Xtensa is fine because the
|
||||
# tail-call optimisation was never reliable on this target anyway. Older
|
||||
# IDF/GCC builds also accept the define (it just becomes a no-op).
|
||||
M3_NO_MUSTTAIL=1
|
||||
)
|
||||
|
||||
# Suppress warnings from third-party code.
|
||||
|
||||
@@ -220,11 +220,20 @@ static void fast_loop_cb(TimerHandle_t t)
|
||||
adaptive_controller_decide(&s_cfg, s_state, &obs, &dec);
|
||||
apply_decision(&dec);
|
||||
|
||||
/* ADR-081 Layer 4/5: emit compact feature state on every fast tick
|
||||
* (default 200 ms → 5 Hz, within the 1–10 Hz spec). Replaces raw
|
||||
* ADR-018 CSI as the default upstream; raw remains available as a
|
||||
* debug stream gated by the channel plan. */
|
||||
emit_feature_state();
|
||||
/* ADR-081 Layer 4/5: emit compact feature state at 1 Hz (the spec's
|
||||
* 1–10 Hz floor). Was previously emitted on every fast tick (~5 Hz at
|
||||
* the default 200 ms fast period), which combined with CSI promiscuous
|
||||
* RX saturated the WiFi TX airtime — measured live on COM8 (S3) and
|
||||
* COM9 (C6): every adaptive cycle showed `sendto ENOMEM — backing off
|
||||
* for 100 ms`, and bumping LWIP/WiFi buffer pools to 4× had no effect
|
||||
* on the rate because the bottleneck was radio TX time, not pool size.
|
||||
* Dropping to 1 Hz (5× less feature_state traffic) frees the TX queue
|
||||
* for CSI sends and lands well within the spec. */
|
||||
static uint8_t s_emit_divider = 0;
|
||||
if (++s_emit_divider >= 5) {
|
||||
s_emit_divider = 0;
|
||||
emit_feature_state();
|
||||
}
|
||||
}
|
||||
|
||||
static void medium_loop_cb(TimerHandle_t t)
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
#include "esp_wifi.h"
|
||||
#include "esp_mac.h"
|
||||
#include "esp_timer.h"
|
||||
#include "esp_idf_version.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/timers.h"
|
||||
#include <string.h>
|
||||
@@ -144,11 +145,27 @@ static void on_recv(const uint8_t *src_mac, const uint8_t *data, int len)
|
||||
}
|
||||
}
|
||||
|
||||
/* Issue #944: ESP-IDF v6.0 changed `esp_now_send_cb_t` from
|
||||
* void (*)(const uint8_t *mac, esp_now_send_status_t status)
|
||||
* to
|
||||
* 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. ESP_IDF_VERSION_MAJOR is the canonical guard.
|
||||
*/
|
||||
#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;
|
||||
if (status != ESP_NOW_SEND_SUCCESS) s_tx_fail++;
|
||||
}
|
||||
#else
|
||||
static void on_send(const uint8_t *mac, esp_now_send_status_t status)
|
||||
{
|
||||
(void)mac;
|
||||
if (status != ESP_NOW_SEND_SUCCESS) s_tx_fail++;
|
||||
}
|
||||
#endif
|
||||
|
||||
static void beacon_timer_cb(TimerHandle_t t)
|
||||
{
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
#include "esp_wifi.h"
|
||||
#include "esp_timer.h"
|
||||
#include "sdkconfig.h"
|
||||
#include "esp_netif.h" /* #954: STA gateway lookup for self-ping CSI source */
|
||||
#include "ping/ping_sock.h" /* #954: esp_ping gateway traffic generator */
|
||||
#include "lwip/ip_addr.h" /* #954: ip_addr_t target for esp_ping */
|
||||
|
||||
/* ADR-060: Access the global NVS config for MAC filter and channel override. */
|
||||
extern nvs_config_t g_nvs_config;
|
||||
@@ -365,6 +368,67 @@ static void wifi_promiscuous_cb(void *buf, wifi_promiscuous_pkt_type_t type)
|
||||
(void)type;
|
||||
}
|
||||
|
||||
/* ---- RuView#521/#954: connected-STA CSI traffic source (additive) ----
|
||||
*
|
||||
* The ESP32 CSI engine only produces CSI for received OFDM frames (L-LTF/HT-LTF).
|
||||
* On a quiet network — or on a display-enabled build where the #893 MGMT->MGMT+DATA
|
||||
* promiscuous upgrade is skipped (has_display=true) — the only CSI-eligible frames
|
||||
* are sparse beacons (often non-OFDM DSSS), so wifi_csi_callback can starve to
|
||||
* yield=0pps -> DEGRADED -> motion/presence=0 (#521, #954).
|
||||
*
|
||||
* This guarantees a ~50 Hz OFDM unicast floor by pinging the STA's own gateway:
|
||||
* the router's ICMP echo replies are OFDM frames destined to this station, which
|
||||
* drive the CSI engine regardless of promiscuous filter state or ambient traffic.
|
||||
* It is ADDITIVE — promiscuous capture (#396/#893) is left fully intact so
|
||||
* multistatic/multi-node sensing still hears other stations' frames. Mirrors
|
||||
* Espressif's esp-csi csi_recv_router reference.
|
||||
*/
|
||||
static esp_ping_handle_t s_self_ping = NULL;
|
||||
static void csi_ping_cb_noop(esp_ping_handle_t hdl, void *args) { (void)hdl; (void)args; }
|
||||
|
||||
static void csi_start_self_ping(void)
|
||||
{
|
||||
if (s_self_ping != NULL) {
|
||||
return; /* already running */
|
||||
}
|
||||
|
||||
esp_netif_t *sta = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
|
||||
esp_netif_ip_info_t ip;
|
||||
if (sta == NULL || esp_netif_get_ip_info(sta, &ip) != ESP_OK || ip.gw.addr == 0) {
|
||||
ESP_LOGW(TAG, "self-ping: no gateway IP yet; CSI relies on ambient frames (#954)");
|
||||
return;
|
||||
}
|
||||
|
||||
char gw_str[16];
|
||||
esp_ip4addr_ntoa(&ip.gw, gw_str, sizeof(gw_str));
|
||||
|
||||
ip_addr_t target;
|
||||
memset(&target, 0, sizeof(target));
|
||||
ipaddr_aton(gw_str, &target);
|
||||
|
||||
esp_ping_config_t cfg = ESP_PING_DEFAULT_CONFIG();
|
||||
cfg.target_addr = target;
|
||||
cfg.count = ESP_PING_COUNT_INFINITE;
|
||||
cfg.interval_ms = 20; /* 50 Hz -> ~50 received OFDM replies/sec */
|
||||
cfg.data_size = 1;
|
||||
cfg.task_stack_size = 4096;
|
||||
|
||||
esp_ping_callbacks_t cbs = {
|
||||
.cb_args = NULL,
|
||||
.on_ping_success = csi_ping_cb_noop,
|
||||
.on_ping_timeout = csi_ping_cb_noop,
|
||||
.on_ping_end = csi_ping_cb_noop,
|
||||
};
|
||||
|
||||
if (esp_ping_new_session(&cfg, &cbs, &s_self_ping) == ESP_OK && s_self_ping != NULL) {
|
||||
esp_ping_start(s_self_ping);
|
||||
ESP_LOGI(TAG, "self-ping started -> %s @50Hz (CSI OFDM source, fix #521/#954)", gw_str);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "self-ping: esp_ping_new_session failed");
|
||||
s_self_ping = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
void csi_collector_set_node_id(uint8_t node_id)
|
||||
{
|
||||
s_node_id = node_id;
|
||||
@@ -526,6 +590,11 @@ void csi_collector_init(void)
|
||||
|
||||
ESP_LOGI(TAG, "CSI collection initialized (node_id=%u, channel=%u)",
|
||||
(unsigned)s_node_id, (unsigned)csi_channel);
|
||||
|
||||
/* RuView#521/#954: start the connected-STA traffic source so the CSI engine
|
||||
* receives a guaranteed OFDM unicast floor even when promiscuous capture is
|
||||
* starved (display builds / quiet networks). Additive to #396/#893. */
|
||||
csi_start_self_ping();
|
||||
}
|
||||
|
||||
/* Accessor for other modules that need the authoritative runtime node_id. */
|
||||
|
||||
@@ -215,6 +215,113 @@ static float estimate_bpm_zero_crossing(const float *history, uint16_t len,
|
||||
return freq_hz * 60.0f; /* Hz to BPM. */
|
||||
}
|
||||
|
||||
/**
|
||||
* Autocorrelation periodicity estimator (RuView #954/#985/#987 follow-up).
|
||||
*
|
||||
* Zero-crossing HR estimation parked at ~45 BPM for two reasons: (1) it used a
|
||||
* stale fixed sample rate (10 Hz) after #985's self-ping raised the real CSI
|
||||
* rate to a variable ~13-19 Hz, and (2) it locked onto breathing harmonics —
|
||||
* a 0.25 Hz breathing fundamental puts its 3rd harmonic at ~0.74 Hz ≈ 44 BPM,
|
||||
* right inside the HR band. This finds the dominant period in the HR band by
|
||||
* autocorrelation, explicitly rejecting lags that coincide with breathing
|
||||
* harmonics, and refines the peak with parabolic interpolation. Uses the
|
||||
* MEASURED sample rate so the BPM is in real units.
|
||||
*
|
||||
* @param sig Band-filtered signal (contiguous, oldest..newest).
|
||||
* @param len Number of samples.
|
||||
* @param fs Measured sample rate in Hz.
|
||||
* @param bpm_lo Low edge of the search band (BPM).
|
||||
* @param bpm_hi High edge of the search band (BPM).
|
||||
* @param reject_br_hz Breathing fundamental (Hz) whose harmonics are rejected
|
||||
* (k=1..6); pass 0 to disable rejection (fundamental search).
|
||||
* @return Dominant rate in BPM within the band, or 0 if no confident peak.
|
||||
*/
|
||||
static float estimate_periodicity_autocorr(const float *sig, uint16_t len, float fs,
|
||||
float bpm_lo, float bpm_hi, float reject_br_hz)
|
||||
{
|
||||
if (len < 32 || fs <= 0.0f || bpm_hi <= bpm_lo) return 0.0f;
|
||||
|
||||
int lag_min = (int)(fs * 60.0f / bpm_hi);
|
||||
int lag_max = (int)(fs * 60.0f / bpm_lo);
|
||||
if (lag_min < 2) lag_min = 2;
|
||||
if (lag_max >= (int)len) lag_max = (int)len - 1;
|
||||
if (lag_max <= lag_min + 1) return 0.0f;
|
||||
|
||||
const float br_hz = reject_br_hz;
|
||||
|
||||
float r0 = 0.0f;
|
||||
for (uint16_t i = 0; i < len; i++) r0 += sig[i] * sig[i];
|
||||
if (r0 <= 1e-6f) return 0.0f;
|
||||
|
||||
float best = -1.0f;
|
||||
int best_lag = 0;
|
||||
|
||||
for (int lag = lag_min; lag <= lag_max; lag++) {
|
||||
float f = fs / (float)lag; /* candidate HR frequency (Hz) */
|
||||
|
||||
/* Reject candidates within 8% of a breathing harmonic k*f_br (k=1..6). */
|
||||
if (br_hz > 0.0f) {
|
||||
bool harmonic = false;
|
||||
for (int k = 1; k <= 6; k++) {
|
||||
float h = (float)k * br_hz;
|
||||
if (fabsf(f - h) < 0.08f * h) { harmonic = true; break; }
|
||||
}
|
||||
if (harmonic) continue;
|
||||
}
|
||||
|
||||
float acc = 0.0f;
|
||||
for (int i = 0; i + lag < (int)len; i++) acc += sig[i] * sig[i + lag];
|
||||
if (acc > best) { best = acc; best_lag = lag; }
|
||||
}
|
||||
|
||||
if (best_lag == 0) return 0.0f;
|
||||
/* Require a real periodicity, not a noise peak. */
|
||||
if (best / r0 < 0.2f) return 0.0f;
|
||||
|
||||
/* Parabolic interpolation around best_lag for sub-sample period resolution. */
|
||||
float lag_ref = (float)best_lag;
|
||||
{
|
||||
float a = 0.0f, c = 0.0f;
|
||||
for (int i = 0; i + (best_lag - 1) < (int)len; i++) a += sig[i] * sig[i + best_lag - 1];
|
||||
for (int i = 0; i + (best_lag + 1) < (int)len; i++) c += sig[i] * sig[i + best_lag + 1];
|
||||
float denom = a - 2.0f * best + c;
|
||||
if (fabsf(denom) > 1e-6f) {
|
||||
float delta = 0.5f * (a - c) / denom;
|
||||
if (delta > -1.0f && delta < 1.0f) lag_ref += delta;
|
||||
}
|
||||
}
|
||||
|
||||
return fs / lag_ref * 60.0f;
|
||||
}
|
||||
|
||||
/* Median smoother for the emitted heart rate. The per-frame autocorr estimate
|
||||
* still has occasional single-frame outliers (startup transient before the
|
||||
* filters re-tune, momentary harmonic mis-locks); a median over the last few
|
||||
* VALID estimates stops the reported HR from "dropping a lot" between frames
|
||||
* without lagging real changes much. Only valid (in-range) estimates are
|
||||
* pushed, so out-of-range/zero results never pollute the window. */
|
||||
#define HR_SMOOTH_N 13
|
||||
static float s_hr_ring[HR_SMOOTH_N];
|
||||
static uint8_t s_hr_ring_n;
|
||||
static uint8_t s_hr_ring_idx;
|
||||
|
||||
static float hr_smooth_push(float hr)
|
||||
{
|
||||
s_hr_ring[s_hr_ring_idx] = hr;
|
||||
s_hr_ring_idx = (uint8_t)((s_hr_ring_idx + 1) % HR_SMOOTH_N);
|
||||
if (s_hr_ring_n < HR_SMOOTH_N) s_hr_ring_n++;
|
||||
|
||||
float tmp[HR_SMOOTH_N];
|
||||
for (uint8_t i = 0; i < s_hr_ring_n; i++) tmp[i] = s_hr_ring[i];
|
||||
for (uint8_t i = 1; i < s_hr_ring_n; i++) { /* insertion sort, tiny N */
|
||||
float v = tmp[i];
|
||||
int j = (int)i - 1;
|
||||
while (j >= 0 && tmp[j] > v) { tmp[j + 1] = tmp[j]; j--; }
|
||||
tmp[j + 1] = v;
|
||||
}
|
||||
return tmp[s_hr_ring_n / 2];
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
* DSP Pipeline State
|
||||
* ====================================================================== */
|
||||
@@ -246,6 +353,14 @@ static edge_biquad_t s_bq_heartrate;
|
||||
static float s_breathing_filtered[EDGE_PHASE_HISTORY_LEN];
|
||||
static float s_heartrate_filtered[EDGE_PHASE_HISTORY_LEN];
|
||||
|
||||
/** Measured CSI sample rate (Hz), smoothed from frame timestamps.
|
||||
* #985's self-ping raised the callback rate above the old ~10 Hz beacon
|
||||
* assumption and made it variable (~13-19 Hz); a fixed rate scaled BPM wrong
|
||||
* and made HR swing with CSI yield. See update in process_csi_frame(). */
|
||||
static float s_sample_rate_hz = 15.0f;
|
||||
static float s_filter_design_fs = 20.0f; /* fs the biquads were last designed at */
|
||||
static uint32_t s_last_frame_ts_us = 0;
|
||||
|
||||
/** Latest vitals state. */
|
||||
static float s_breathing_bpm;
|
||||
static float s_heartrate_bpm;
|
||||
@@ -535,7 +650,11 @@ static void update_multi_person_vitals(const uint8_t *iq_data, uint16_t n_sc,
|
||||
}
|
||||
|
||||
float br = estimate_bpm_zero_crossing(s_scratch_br, buf_len, sample_rate);
|
||||
float hr = estimate_bpm_zero_crossing(s_scratch_hr, buf_len, sample_rate);
|
||||
/* Robust breathing period (autocorr) drives HR harmonic rejection —
|
||||
* the zero-crossing estimate is too noisy under motion and notched
|
||||
* the wrong frequencies, letting HR lock onto a breathing harmonic. */
|
||||
float br_rob = estimate_periodicity_autocorr(s_scratch_br, buf_len, sample_rate, 6.0f, 40.0f, 0.0f);
|
||||
float hr = estimate_periodicity_autocorr(s_scratch_hr, buf_len, sample_rate, 45.0f, 180.0f, br_rob / 60.0f);
|
||||
|
||||
/* Sanity clamp. */
|
||||
if (br >= 6.0f && br <= 40.0f) pv->breathing_bpm = br;
|
||||
@@ -715,11 +834,36 @@ static void process_frame(const edge_ring_slot_t *slot)
|
||||
s_frame_count++;
|
||||
s_latest_rssi = slot->rssi;
|
||||
|
||||
/* CSI sample rate. MGMT-only promiscuous filter (RuView#396, csi_collector.c)
|
||||
* yields ~10 Hz from beacons; keep this value aligned with csi_collector's
|
||||
* effective callback rate or estimate_bpm_zero_crossing() reports the wrong
|
||||
* BPM (2× rate mismatch → 2× wrong breathing/HR). */
|
||||
const float sample_rate = 10.0f;
|
||||
/* Measure the REAL CSI sample rate from inter-frame timestamps. #985's
|
||||
* self-ping made the callback rate variable (~13-19 Hz); the old fixed
|
||||
* 10 Hz both scaled BPM wrong (true ~87 BPM read as ~45) and made HR swing
|
||||
* as CSI yield fluctuated. EMA-smooth and clamp to a plausible band. */
|
||||
if (s_last_frame_ts_us != 0 && slot->timestamp_us > s_last_frame_ts_us) {
|
||||
float dt = (float)(slot->timestamp_us - s_last_frame_ts_us) * 1e-6f;
|
||||
if (dt > 0.02f && dt < 0.5f) { /* 2-50 Hz plausible; reject gaps/hops */
|
||||
float inst = 1.0f / dt;
|
||||
s_sample_rate_hz += 0.05f * (inst - s_sample_rate_hz);
|
||||
if (s_sample_rate_hz < 8.0f) s_sample_rate_hz = 8.0f;
|
||||
if (s_sample_rate_hz > 30.0f) s_sample_rate_hz = 30.0f;
|
||||
}
|
||||
}
|
||||
s_last_frame_ts_us = slot->timestamp_us;
|
||||
|
||||
/* Re-tune the biquads if the measured rate has drifted from their design fs,
|
||||
* so the breathing (0.1-0.5 Hz) and HR (0.8-2.0 Hz) passbands stay in real
|
||||
* Hz. biquad_bandpass_design resets delay state, so only redesign on real
|
||||
* drift (>15%) — the autocorr window averages over the one-time transient. */
|
||||
if (fabsf(s_sample_rate_hz - s_filter_design_fs) > 0.15f * s_filter_design_fs) {
|
||||
biquad_bandpass_design(&s_bq_breathing, s_sample_rate_hz, 0.1f, 0.5f);
|
||||
biquad_bandpass_design(&s_bq_heartrate, s_sample_rate_hz, 0.8f, 2.0f);
|
||||
for (uint8_t pp = 0; pp < EDGE_MAX_PERSONS; pp++) {
|
||||
biquad_bandpass_design(&s_person_bq_br[pp], s_sample_rate_hz, 0.1f, 0.5f);
|
||||
biquad_bandpass_design(&s_person_bq_hr[pp], s_sample_rate_hz, 0.8f, 2.0f);
|
||||
}
|
||||
s_filter_design_fs = s_sample_rate_hz;
|
||||
}
|
||||
|
||||
const float sample_rate = s_sample_rate_hz;
|
||||
|
||||
/* --- Step 1-2: Phase extraction + unwrapping per subcarrier --- */
|
||||
float phases[EDGE_MAX_SUBCARRIERS];
|
||||
@@ -777,11 +921,13 @@ static void process_frame(const edge_ring_slot_t *slot)
|
||||
}
|
||||
|
||||
float br_bpm = estimate_bpm_zero_crossing(s_scratch_br, buf_len, sample_rate);
|
||||
float hr_bpm = estimate_bpm_zero_crossing(s_scratch_hr, buf_len, sample_rate);
|
||||
/* Robust breathing period (autocorr) drives HR harmonic rejection. */
|
||||
float br_rob = estimate_periodicity_autocorr(s_scratch_br, buf_len, sample_rate, 6.0f, 40.0f, 0.0f);
|
||||
float hr_bpm = estimate_periodicity_autocorr(s_scratch_hr, buf_len, sample_rate, 45.0f, 180.0f, br_rob / 60.0f);
|
||||
|
||||
/* Sanity clamp: breathing 6-40 BPM, heart rate 40-180 BPM. */
|
||||
if (br_bpm >= 6.0f && br_bpm <= 40.0f) s_breathing_bpm = br_bpm;
|
||||
if (hr_bpm >= 40.0f && hr_bpm <= 180.0f) s_heartrate_bpm = hr_bpm;
|
||||
if (hr_bpm >= 40.0f && hr_bpm <= 180.0f) s_heartrate_bpm = hr_smooth_push(hr_bpm);
|
||||
}
|
||||
|
||||
/* --- Step 8: Motion energy (variance of recent phases) --- */
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
* 0xC5110003 — ADR-069 feature vector (edge_processing.h)
|
||||
* 0xC5110004 — ADR-063 fused vitals (edge_processing.h)
|
||||
* 0xC5110005 — ADR-039 compressed CSI (edge_processing.h)
|
||||
* 0xC5110006 — ADR-081 feature state (this file) ← new
|
||||
* 0xC5110006 — ADR-081 feature state (this file)
|
||||
* 0xC5110007 — ADR-040 WASM output (wasm_runtime.h, reassigned per issue #928)
|
||||
*/
|
||||
|
||||
#ifndef RV_FEATURE_STATE_H
|
||||
|
||||
@@ -23,7 +23,16 @@
|
||||
static const char *TAG = "swarm";
|
||||
|
||||
/* ---- Task parameters ---- */
|
||||
#define SWARM_TASK_STACK 3072 /**< 3 KB stack — HTTP client uses ~2.5 KB. */
|
||||
/* Issue #949: 3 KB was sized for plain HTTP (~2.5 KB). The bug reporter
|
||||
* configured `--seed-url https://…` which exercises TLS — mbedTLS handshake
|
||||
* alone needs 4-6 KB on the stack (cipher suite + cert chain + ECDH), and on
|
||||
* top of that esp_http_client adds another 1.5-2 KB. The task panicked with
|
||||
* `0xa5a5a5a5` (FreeRTOS stack-fill sentinel) immediately after "bridge init
|
||||
* OK". 8 KB comfortably fits TLS with margin for the cert chain + headers;
|
||||
* confirmed against mbedTLS's stack analyser. Plain-HTTP deployments waste
|
||||
* ~5 KB of headroom but that's <0.1 % of PSRAM, an acceptable cost for the
|
||||
* bug class this prevents. */
|
||||
#define SWARM_TASK_STACK 8192 /**< 8 KB stack — fits mbedTLS handshake. */
|
||||
#define SWARM_TASK_PRIO 3
|
||||
#define SWARM_TASK_CORE 0
|
||||
#define SWARM_HTTP_TIMEOUT 3000 /**< HTTP timeout in ms (Seed responds <100ms on LAN). */
|
||||
|
||||
@@ -43,7 +43,16 @@
|
||||
|
||||
#define WASM_MAX_MODULE_SIZE (128 * 1024) /**< Max .wasm binary size (128 KB). */
|
||||
#define WASM_STACK_SIZE (8 * 1024) /**< WASM execution stack (8 KB). */
|
||||
#define WASM_OUTPUT_MAGIC 0xC5110004 /**< WASM output packet magic. */
|
||||
/* Issue #928: WASM output was originally 0xC5110004, but that magic is
|
||||
* canonically owned by ADR-063 fused vitals (edge_processing.h). Both packets
|
||||
* were transmitted on the same magic, and the host parser only knew the WASM
|
||||
* shape, so on the ESP32-C6 + MR60BHA2 mmWave config the 48-byte fused-vitals
|
||||
* packet was being read as garbage WASM events. Reassigned to 0xC5110007 (next
|
||||
* free slot in the registry — see rv_feature_state.h). Firmware older than
|
||||
* this commit will silently lose its WASM event stream against an updated host
|
||||
* — that's the deliberate "fail loud" choice over silent misparsing.
|
||||
*/
|
||||
#define WASM_OUTPUT_MAGIC 0xC5110007 /**< WASM output packet magic (post-#928). */
|
||||
#define WASM_MAX_EVENTS 16 /**< Max events per output packet. */
|
||||
|
||||
/* ---- WASM Event (5 bytes: u8 type + f32 value) ---- */
|
||||
@@ -54,7 +63,7 @@ typedef struct __attribute__((packed)) {
|
||||
|
||||
/* ---- WASM Output Packet ---- */
|
||||
typedef struct __attribute__((packed)) {
|
||||
uint32_t magic; /**< WASM_OUTPUT_MAGIC = 0xC5110004. */
|
||||
uint32_t magic; /**< WASM_OUTPUT_MAGIC = 0xC5110007 (issue #928). */
|
||||
uint8_t node_id; /**< ESP32 node identifier. */
|
||||
uint8_t module_id; /**< Module slot index. */
|
||||
uint16_t event_count; /**< Number of events in this packet. */
|
||||
|
||||
@@ -29,6 +29,30 @@ CONFIG_LOG_DEFAULT_LEVEL_INFO=y
|
||||
# LWIP: enable extended socket options for UDP multicast
|
||||
CONFIG_LWIP_SO_RCVBUF=y
|
||||
|
||||
# Issue (sibling of #946/#949/#864 cluster): UDP `sendto` returned ENOMEM
|
||||
# in a tight loop on both ESP32-S3 (COM8) and ESP32-C6 (COM9) at the v0.7.0
|
||||
# CSI packet rate (CSI cb + status + sync + feature_state all sharing the
|
||||
# LWIP/WiFi pools). stream_sender.c has a cooldown path so the device
|
||||
# doesn't crash, but ~90 % of CSI frames were dropped before reaching the
|
||||
# host — boot trace showed `sendto ENOMEM — backing off 100 ms` repeating
|
||||
# every capture cycle. Stock IDF v5.4 defaults: UDP recv mbox=6, TCPIP
|
||||
# mbox=32, WiFi dynamic TX buffers=32 — too small once CSI promiscuous
|
||||
# mode is active. These bumps roughly quadruple the relevant pools at
|
||||
# ~3 KB extra heap cost, measured live on both targets Jun 8 2026.
|
||||
CONFIG_LWIP_UDP_RECVMBOX_SIZE=32
|
||||
CONFIG_LWIP_TCPIP_RECVMBOX_SIZE=64
|
||||
CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM=64
|
||||
# NOTE: Empirical 25 s measurements on the S3 at COM8 showed these bumps
|
||||
# eliminate the csi_collector.sendto failure path (`fail #1..5` →
|
||||
# `fail #0`) — real improvement — but do NOT eliminate the broader
|
||||
# `feature_state emit` ENOMEM at ~10/s. That residual is the WiFi
|
||||
# radio's TX airtime saturating under CSI promiscuous RX, and bigger
|
||||
# buffers cap out at the 100 ms backoff window regardless of size
|
||||
# (verified at WIFI_DYNAMIC_TX=128 + PBUF_POOL=32 — identical count).
|
||||
# The proper fix is rate-limiting adaptive_controller.c's emit cadence
|
||||
# from ~50 ms to the intended 1 Hz, which is a code refactor tracked
|
||||
# in a separate follow-up issue.
|
||||
|
||||
# FreeRTOS: increase task stack for CSI processing
|
||||
CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1 @@
|
||||
baselines/
|
||||
Generated
+24
-33
@@ -10811,12 +10811,27 @@ 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",
|
||||
@@ -10832,9 +10847,12 @@ 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",
|
||||
@@ -10894,10 +10912,10 @@ dependencies = [
|
||||
"criterion",
|
||||
"wifi-densepose-bfld",
|
||||
"wifi-densepose-core",
|
||||
"wifi-densepose-geo 0.1.0",
|
||||
"wifi-densepose-geo",
|
||||
"wifi-densepose-ruvector",
|
||||
"wifi-densepose-signal",
|
||||
"wifi-densepose-worldgraph 0.3.0",
|
||||
"wifi-densepose-worldgraph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -10912,20 +10930,6 @@ 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"
|
||||
@@ -11187,37 +11191,24 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-worldgraph"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"petgraph",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"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)",
|
||||
"wifi-densepose-geo",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-worldmodel"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"wifi-densepose-worldgraph 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"wifi-densepose-worldgraph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -28,6 +28,7 @@ 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
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
[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 }
|
||||
@@ -0,0 +1,351 @@
|
||||
//! 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.1–0.15 Hz).
|
||||
BreatheSlow,
|
||||
/// Normal respiration (~0.2–0.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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
//! 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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//! 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>;
|
||||
@@ -0,0 +1,295 @@
|
||||
//! 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(¢ered, fs, 0.1, 0.6);
|
||||
let (heart_hz, heart_score) = autocorr_dominant(¢ered, 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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//! # 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};
|
||||
@@ -0,0 +1,265 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
//! 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.125–0.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");
|
||||
}
|
||||
@@ -16,14 +16,18 @@ 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 = []
|
||||
mat = ["dep:wifi-densepose-mat"]
|
||||
|
||||
[dependencies]
|
||||
# Internal crates
|
||||
wifi-densepose-mat = { version = "0.3.0", path = "../wifi-densepose-mat" }
|
||||
wifi-densepose-mat = { version = "0.3.0", path = "../wifi-densepose-mat", optional = true }
|
||||
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 }
|
||||
@@ -41,6 +45,10 @@ 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"
|
||||
@@ -64,3 +72,4 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
assert_cmd = "2.0"
|
||||
predicates = "3.0"
|
||||
tempfile = "3.9"
|
||||
tower = { workspace = true }
|
||||
|
||||
@@ -232,7 +232,7 @@ fn finalise_and_save(recorder: CalibrationRecorder, output: &str) -> Result<()>
|
||||
// Tier helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn tier_config(tier: &str) -> CalibrationConfig {
|
||||
pub(crate) fn tier_config(tier: &str) -> CalibrationConfig {
|
||||
match tier.to_ascii_lowercase().as_str() {
|
||||
"ht40" => CalibrationConfig::ht40(),
|
||||
"he20" => CalibrationConfig::he20(),
|
||||
@@ -250,7 +250,7 @@ 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.
|
||||
fn parse_csi_packet(buf: &[u8], tier: &str) -> Option<CsiFrame> {
|
||||
pub(crate) fn parse_csi_packet(buf: &[u8], tier: &str) -> Option<CsiFrame> {
|
||||
if buf.len() < 20 {
|
||||
return None;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,9 @@
|
||||
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
|
||||
@@ -52,7 +55,26 @@ 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),
|
||||
|
||||
|
||||
@@ -21,11 +21,28 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
//! `enroll` / `train-room` / `room-status` / `room-watch` — ADR-151 Stages 2–5 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(())
|
||||
}
|
||||
@@ -108,8 +108,14 @@ pub async fn start_server(
|
||||
cmd.args(["--log-level", log_level]);
|
||||
}
|
||||
|
||||
// Set data source (default to "simulate" if not specified for demo mode)
|
||||
let source = config.source.as_deref().unwrap_or("simulate");
|
||||
// Default to explicit "simulated" demo mode when the desktop user hasn't
|
||||
// chosen a source — this is the *Tauri demo* app, not a production
|
||||
// sensing endpoint, so the demo default is correct here. Critically, the
|
||||
// value passed downstream is the **explicit** "simulated", not "auto",
|
||||
// which means the sensing-server will tag the data as synthetic in its
|
||||
// API responses rather than silently fall back (issue #937 fix in
|
||||
// sensing-server's `auto` handler).
|
||||
let source = config.source.as_deref().unwrap_or("simulated");
|
||||
cmd.args(["--source", source]);
|
||||
|
||||
// Redirect stdout/stderr to pipes for monitoring
|
||||
@@ -317,7 +323,7 @@ pub async fn restart_server(
|
||||
log_level: None,
|
||||
bind_address: None,
|
||||
server_path: None,
|
||||
source: None, // Use default (simulate)
|
||||
source: None, // Falls through to explicit "simulated" — Tauri demo default.
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -15,12 +15,12 @@ readme = "README.md"
|
||||
default = ["std", "api", "ruvector"]
|
||||
ruvector = ["dep:ruvector-solver", "dep:ruvector-temporal-tensor"]
|
||||
std = []
|
||||
api = ["dep:serde", "chrono/serde", "geo/use-serde"]
|
||||
api = ["chrono/serde", "geo/use-serde"]
|
||||
portable = ["low-power"]
|
||||
low-power = []
|
||||
distributed = ["tokio/sync"]
|
||||
drone = ["distributed"]
|
||||
serde = ["dep:serde", "chrono/serde", "geo/use-serde"]
|
||||
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"], optional = true }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
# Time handling
|
||||
|
||||
@@ -45,13 +45,14 @@ pub fn parse_esp32_vitals(buf: &[u8]) -> Option<Esp32VitalsPacket> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a WASM output packet (magic 0xC511_0004).
|
||||
/// Parse a WASM output packet (magic 0xC511_0007 — reassigned per issue #928;
|
||||
/// the original 0xC511_0004 collided with ADR-063 fused vitals).
|
||||
pub fn parse_wasm_output(buf: &[u8]) -> Option<WasmOutputPacket> {
|
||||
if buf.len() < 8 {
|
||||
return None;
|
||||
}
|
||||
let magic = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
|
||||
if magic != 0xC511_0004 {
|
||||
if magic != 0xC511_0007 {
|
||||
return None;
|
||||
}
|
||||
|
||||
|
||||
@@ -1114,7 +1114,7 @@ fn parse_esp32_vitals(buf: &[u8]) -> Option<Esp32VitalsPacket> {
|
||||
})
|
||||
}
|
||||
|
||||
// ── ADR-040: WASM Output Packet (magic 0xC511_0004) ───────────────────────────
|
||||
// ── ADR-040: WASM Output Packet (magic 0xC511_0007 — reassigned per #928) ─────
|
||||
|
||||
/// Single WASM event (type + value).
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -1131,13 +1131,14 @@ struct WasmOutputPacket {
|
||||
events: Vec<WasmEvent>,
|
||||
}
|
||||
|
||||
/// Parse a WASM output packet (magic 0xC511_0004).
|
||||
/// Parse a WASM output packet (magic 0xC511_0007 — reassigned per issue #928;
|
||||
/// the original 0xC511_0004 was a collision with ADR-063 fused vitals).
|
||||
fn parse_wasm_output(buf: &[u8]) -> Option<WasmOutputPacket> {
|
||||
if buf.len() < 8 {
|
||||
return None;
|
||||
}
|
||||
let magic = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
|
||||
if magic != 0xC511_0004 {
|
||||
if magic != 0xC511_0007 {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -1169,6 +1170,187 @@ fn parse_wasm_output(buf: &[u8]) -> Option<WasmOutputPacket> {
|
||||
})
|
||||
}
|
||||
|
||||
// ── ADR-063: Edge Fused Vitals Packet (magic 0xC511_0004) ─────────────────────
|
||||
//
|
||||
// 48-byte packed struct emitted by the ESP32-C6 + MR60BHA2 mmWave config when
|
||||
// `mmwave_sensor_get_state().detected` is true. Byte layout from
|
||||
// `firmware/esp32-csi-node/main/edge_processing.h` line 129 — kept in lockstep
|
||||
// with the firmware's `_Static_assert(sizeof(edge_fused_vitals_pkt_t) == 48)`.
|
||||
// Issue #928 surfaced that this magic was being parsed as WASM output and the
|
||||
// fused vitals were silently lost. Adding the proper parser here.
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct EdgeFusedVitalsPacket {
|
||||
node_id: u8,
|
||||
/// Bit0=presence, Bit1=fall, Bit2=motion, Bit3=mmwave_present.
|
||||
flags: u8,
|
||||
/// Fused breathing rate in BPM (firmware sends BPM*100; we scale here).
|
||||
breathing_rate_bpm: f32,
|
||||
/// Fused heartrate in BPM (firmware sends BPM*10000; we scale here).
|
||||
heartrate_bpm: f32,
|
||||
rssi: i8,
|
||||
n_persons: u8,
|
||||
/// `mmwave_type_t` enum value from firmware.
|
||||
mmwave_type: u8,
|
||||
/// 0-100 fusion quality score.
|
||||
fusion_confidence: u8,
|
||||
motion_energy: f32,
|
||||
presence_score: f32,
|
||||
timestamp_ms: u32,
|
||||
/// Raw mmWave heart rate (BPM).
|
||||
mmwave_hr_bpm: f32,
|
||||
/// Raw mmWave breathing rate (BPM).
|
||||
mmwave_br_bpm: f32,
|
||||
/// Distance to nearest target (cm).
|
||||
mmwave_distance_cm: f32,
|
||||
/// Target count from mmWave.
|
||||
mmwave_targets: u8,
|
||||
/// mmWave signal quality 0-100.
|
||||
mmwave_confidence: u8,
|
||||
}
|
||||
|
||||
/// Parse an ADR-063 edge fused vitals packet (magic 0xC511_0004, 48 bytes).
|
||||
fn parse_edge_fused_vitals(buf: &[u8]) -> Option<EdgeFusedVitalsPacket> {
|
||||
if buf.len() < 48 {
|
||||
return None;
|
||||
}
|
||||
let magic = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
|
||||
if magic != 0xC511_0004 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let node_id = buf[4];
|
||||
let flags = buf[5];
|
||||
let breathing_raw = u16::from_le_bytes([buf[6], buf[7]]);
|
||||
let heartrate_raw = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]);
|
||||
let rssi = buf[12] as i8;
|
||||
let n_persons = buf[13];
|
||||
let mmwave_type = buf[14];
|
||||
let fusion_confidence = buf[15];
|
||||
let motion_energy = f32::from_le_bytes([buf[16], buf[17], buf[18], buf[19]]);
|
||||
let presence_score = f32::from_le_bytes([buf[20], buf[21], buf[22], buf[23]]);
|
||||
let timestamp_ms = u32::from_le_bytes([buf[24], buf[25], buf[26], buf[27]]);
|
||||
let mmwave_hr_bpm = f32::from_le_bytes([buf[28], buf[29], buf[30], buf[31]]);
|
||||
let mmwave_br_bpm = f32::from_le_bytes([buf[32], buf[33], buf[34], buf[35]]);
|
||||
let mmwave_distance_cm = f32::from_le_bytes([buf[36], buf[37], buf[38], buf[39]]);
|
||||
let mmwave_targets = buf[40];
|
||||
let mmwave_confidence = buf[41];
|
||||
// buf[42..48] are firmware reserved fields (reserved3 u16 + reserved4 u32).
|
||||
|
||||
Some(EdgeFusedVitalsPacket {
|
||||
node_id,
|
||||
flags,
|
||||
breathing_rate_bpm: breathing_raw as f32 / 100.0,
|
||||
heartrate_bpm: heartrate_raw as f32 / 10000.0,
|
||||
rssi,
|
||||
n_persons,
|
||||
mmwave_type,
|
||||
fusion_confidence,
|
||||
motion_energy,
|
||||
presence_score,
|
||||
timestamp_ms,
|
||||
mmwave_hr_bpm,
|
||||
mmwave_br_bpm,
|
||||
mmwave_distance_cm,
|
||||
mmwave_targets,
|
||||
mmwave_confidence,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod issue_928_magic_collision_tests {
|
||||
//! Issue #928 — `0xC511_0004` was being parsed as WASM output, eating the
|
||||
//! C6+mmWave fused-vitals packets. After this fix, `0xC511_0004` routes to
|
||||
//! `parse_edge_fused_vitals` and WASM output owns the freshly-allocated
|
||||
//! `0xC511_0007` slot. Tests guard both halves of the swap.
|
||||
use super::*;
|
||||
|
||||
/// Build a 48-byte synthetic fused-vitals packet matching the firmware's
|
||||
/// `edge_fused_vitals_pkt_t` layout from `edge_processing.h:129`.
|
||||
fn build_fused_vitals_packet() -> Vec<u8> {
|
||||
let mut buf = vec![0u8; 48];
|
||||
buf[0..4].copy_from_slice(&0xC511_0004u32.to_le_bytes());
|
||||
buf[4] = 9; // node_id
|
||||
buf[5] = 0b0000_1001; // flags: presence | mmwave_present
|
||||
buf[6..8].copy_from_slice(&1600u16.to_le_bytes()); // breathing 16.00 BPM
|
||||
buf[8..12].copy_from_slice(&720_000u32.to_le_bytes()); // heartrate 72.0 BPM
|
||||
buf[12] = (-55i8) as u8; // rssi
|
||||
buf[13] = 1; // n_persons
|
||||
buf[14] = 2; // mmwave_type
|
||||
buf[15] = 85; // fusion_confidence
|
||||
buf[16..20].copy_from_slice(&0.42f32.to_le_bytes()); // motion_energy
|
||||
buf[20..24].copy_from_slice(&0.95f32.to_le_bytes()); // presence_score
|
||||
buf[24..28].copy_from_slice(&1_234_567u32.to_le_bytes()); // timestamp_ms
|
||||
buf[28..32].copy_from_slice(&71.5f32.to_le_bytes()); // mmwave_hr_bpm
|
||||
buf[32..36].copy_from_slice(&15.8f32.to_le_bytes()); // mmwave_br_bpm
|
||||
buf[36..40].copy_from_slice(&182.0f32.to_le_bytes()); // mmwave_distance_cm
|
||||
buf[40] = 1; // mmwave_targets
|
||||
buf[41] = 90; // mmwave_confidence
|
||||
// bytes 42..48 — firmware reserved fields, left as zero
|
||||
buf
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_edge_fused_vitals_extracts_fields_correctly() {
|
||||
let buf = build_fused_vitals_packet();
|
||||
let pkt = parse_edge_fused_vitals(&buf).expect("must parse a well-formed packet");
|
||||
assert_eq!(pkt.node_id, 9);
|
||||
assert_eq!(pkt.flags, 0b0000_1001);
|
||||
assert!((pkt.breathing_rate_bpm - 16.0).abs() < 1e-3, "breathing scale 100");
|
||||
assert!((pkt.heartrate_bpm - 72.0).abs() < 1e-3, "heartrate scale 10000");
|
||||
assert_eq!(pkt.rssi, -55);
|
||||
assert_eq!(pkt.n_persons, 1);
|
||||
assert_eq!(pkt.mmwave_type, 2);
|
||||
assert_eq!(pkt.fusion_confidence, 85);
|
||||
assert!((pkt.motion_energy - 0.42).abs() < 1e-6);
|
||||
assert!((pkt.presence_score - 0.95).abs() < 1e-6);
|
||||
assert_eq!(pkt.timestamp_ms, 1_234_567);
|
||||
assert!((pkt.mmwave_hr_bpm - 71.5).abs() < 1e-6);
|
||||
assert!((pkt.mmwave_br_bpm - 15.8).abs() < 1e-3);
|
||||
assert!((pkt.mmwave_distance_cm - 182.0).abs() < 1e-6);
|
||||
assert_eq!(pkt.mmwave_targets, 1);
|
||||
assert_eq!(pkt.mmwave_confidence, 90);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_edge_fused_vitals_rejects_short_buffer() {
|
||||
let buf = build_fused_vitals_packet();
|
||||
// Truncate to 47 bytes — one short of the 48-byte minimum.
|
||||
assert!(parse_edge_fused_vitals(&buf[..47]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_edge_fused_vitals_rejects_wrong_magic() {
|
||||
let mut buf = build_fused_vitals_packet();
|
||||
buf[0..4].copy_from_slice(&0xC511_0007u32.to_le_bytes()); // WASM magic, not fused
|
||||
assert!(parse_edge_fused_vitals(&buf).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_wasm_output_rejects_legacy_0004_magic() {
|
||||
// The old WASM magic collided with fused vitals — must no longer be
|
||||
// accepted. A real fused-vitals packet starts with 0xC511_0004 and
|
||||
// would have been misparsed before this fix.
|
||||
let buf = build_fused_vitals_packet();
|
||||
assert!(parse_wasm_output(&buf).is_none(),
|
||||
"issue #928: WASM parser must NOT accept 0xC511_0004");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_wasm_output_accepts_new_0007_magic() {
|
||||
// Build a tiny well-formed WASM output packet on the new magic.
|
||||
let mut buf = vec![0u8; 8];
|
||||
buf[0..4].copy_from_slice(&0xC511_0007u32.to_le_bytes());
|
||||
buf[4] = 5; // node_id
|
||||
buf[5] = 1; // module_id
|
||||
buf[6..8].copy_from_slice(&0u16.to_le_bytes()); // event_count = 0
|
||||
let pkt = parse_wasm_output(&buf).expect("0xC511_0007 must parse");
|
||||
assert_eq!(pkt.node_id, 5);
|
||||
assert_eq!(pkt.module_id, 1);
|
||||
assert!(pkt.events.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
// ── ESP32 UDP frame parser ───────────────────────────────────────────────────
|
||||
|
||||
fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
|
||||
@@ -4979,7 +5161,45 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||
}
|
||||
}
|
||||
|
||||
// ADR-040: Try WASM output packet (magic 0xC511_0004).
|
||||
// ADR-063: Try edge fused vitals packet (magic 0xC511_0004).
|
||||
// Must come BEFORE the WASM parser — issue #928: these two
|
||||
// packet types shared a magic and the WASM parser was eating
|
||||
// fused-vitals frames on the C6+mmWave config. The reassign of
|
||||
// WASM_OUTPUT_MAGIC → 0xC511_0007 (firmware side) plus this
|
||||
// dedicated parser resolve the collision.
|
||||
if let Some(fused) = parse_edge_fused_vitals(&buf[..len]) {
|
||||
debug!(
|
||||
"Edge fused vitals from {src}: node={} br={:.1} hr={:.1} \
|
||||
mmwave_targets={} fusion_conf={}",
|
||||
fused.node_id, fused.breathing_rate_bpm, fused.heartrate_bpm,
|
||||
fused.mmwave_targets, fused.fusion_confidence,
|
||||
);
|
||||
let s = state.write().await;
|
||||
if let Ok(json) = serde_json::to_string(&serde_json::json!({
|
||||
"type": "edge_fused_vitals",
|
||||
"node_id": fused.node_id,
|
||||
"breathing_rate_bpm": fused.breathing_rate_bpm,
|
||||
"heartrate_bpm": fused.heartrate_bpm,
|
||||
"n_persons": fused.n_persons,
|
||||
"fusion_confidence": fused.fusion_confidence,
|
||||
"mmwave": {
|
||||
"hr_bpm": fused.mmwave_hr_bpm,
|
||||
"br_bpm": fused.mmwave_br_bpm,
|
||||
"distance_cm": fused.mmwave_distance_cm,
|
||||
"targets": fused.mmwave_targets,
|
||||
"confidence": fused.mmwave_confidence,
|
||||
"type": fused.mmwave_type,
|
||||
},
|
||||
"motion_energy": fused.motion_energy,
|
||||
"presence_score": fused.presence_score,
|
||||
"timestamp_ms": fused.timestamp_ms,
|
||||
})) {
|
||||
let _ = s.tx.send(json);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// ADR-040: Try WASM output packet (magic 0xC511_0007 post-#928).
|
||||
if let Some(wasm_output) = parse_wasm_output(&buf[..len]) {
|
||||
debug!(
|
||||
"WASM output from {src}: node={} module={} events={}",
|
||||
@@ -6201,7 +6421,17 @@ async fn main() {
|
||||
info!(" UI path: {}", args.ui_path.display());
|
||||
info!(" Source: {}", args.source);
|
||||
|
||||
// Auto-detect data source
|
||||
// Auto-detect data source.
|
||||
//
|
||||
// Issue #937 / sibling fix: previously `auto` silently fell back to the
|
||||
// synthetic data source when no ESP32 or Windows WiFi was reachable, with
|
||||
// only an `info!` log line as the signal. Downstream API consumers
|
||||
// (`/api/v1/sensing/latest`, `/ws/sensing`) had no in-band way to know they
|
||||
// were being served fake CSI tagged as production telemetry. That is the
|
||||
// exact "where's the real data?" pattern external reviewers (#943, #934)
|
||||
// cited as the most damaging evidence of the project misrepresenting its
|
||||
// posture. Synthetic-data is now opt-in only — operators who want demo
|
||||
// mode must explicitly set `--source simulated` or `CSI_SOURCE=simulated`.
|
||||
let source = match args.source.as_str() {
|
||||
"auto" => {
|
||||
info!("Auto-detecting data source...");
|
||||
@@ -6212,10 +6442,23 @@ async fn main() {
|
||||
info!(" Windows WiFi detected");
|
||||
"wifi"
|
||||
} else {
|
||||
info!(" No hardware detected, using simulation");
|
||||
"simulate"
|
||||
error!(
|
||||
"No real CSI source detected. Auto-detection refuses to silently \
|
||||
fall back to synthetic data because that would expose downstream \
|
||||
consumers (/api/v1/sensing/latest, /ws/sensing) to fake telemetry \
|
||||
tagged as production. To run with synthetic data, set the source \
|
||||
explicitly: --source simulated (or CSI_SOURCE=simulated in Docker). \
|
||||
To use real hardware: provision an ESP32 to emit CSI on UDP :{} or \
|
||||
install the Windows WiFi capture driver. See \
|
||||
https://github.com/ruvnet/RuView/issues/937 for context.",
|
||||
args.udp_port
|
||||
);
|
||||
std::process::exit(78); // EX_CONFIG
|
||||
}
|
||||
}
|
||||
// "simulate" is a synonym for "simulated" (back-compat alias kept so
|
||||
// existing operators who already opted in don't get broken by this fix).
|
||||
"simulate" => "simulated",
|
||||
other => other,
|
||||
};
|
||||
|
||||
|
||||
@@ -276,6 +276,13 @@ pub struct FieldNormalMode {
|
||||
pub geometry_hash: u64,
|
||||
/// Baseline eigenvalue count above Marcenko-Pastur threshold (empty-room).
|
||||
pub baseline_eigenvalue_count: usize,
|
||||
/// Baseline noise variance estimate (median of bottom-half positive
|
||||
/// eigenvalues from the calibration covariance). Persisted so that
|
||||
/// `estimate_occupancy` can anchor its Marcenko-Pastur threshold to the
|
||||
/// calibration noise floor instead of letting it drift with the
|
||||
/// per-window sample size. Defaults to 0.0 in the diagonal-fallback path.
|
||||
/// Issue #942.
|
||||
pub baseline_noise_var: f64,
|
||||
}
|
||||
|
||||
/// Body perturbation extracted from a CSI observation.
|
||||
@@ -504,7 +511,11 @@ impl FieldModel {
|
||||
let baseline: Vec<Vec<f64>> = self.link_stats.iter().map(|ls| ls.mean_vector()).collect();
|
||||
|
||||
// --- True eigenvalue decomposition (with diagonal fallback) ---
|
||||
let (mode_energies, environmental_modes, baseline_eig_count) =
|
||||
// Returns: (energies, modes, baseline_count, baseline_noise_var).
|
||||
// The noise_var slot is 0.0 in the diagonal-fallback paths; the
|
||||
// estimation hot path treats 0.0 as "no anchored noise floor" and
|
||||
// falls back to per-window noise_var, preserving pre-#942 behavior.
|
||||
let (mode_energies, environmental_modes, baseline_eig_count, baseline_noise_var) =
|
||||
if let Some(ref cov_sum) = self.covariance_sum {
|
||||
if self.covariance_count > 1 {
|
||||
// Compute sample covariance from raw outer products:
|
||||
@@ -588,23 +599,28 @@ impl FieldModel {
|
||||
let baseline_count =
|
||||
eigenvalues.iter().filter(|&&ev| ev > mp_threshold).count();
|
||||
|
||||
(energies, modes, baseline_count)
|
||||
(energies, modes, baseline_count, noise_var)
|
||||
}
|
||||
Err(_) => {
|
||||
// Fallback to diagonal approximation on SVD failure
|
||||
diagonal_fallback(&self.link_stats, n_sc, n_modes)
|
||||
let (e, m, b) =
|
||||
diagonal_fallback(&self.link_stats, n_sc, n_modes);
|
||||
(e, m, b, 0.0_f64)
|
||||
}
|
||||
}
|
||||
// When eigenvalue feature is disabled, use diagonal fallback
|
||||
#[cfg(not(feature = "eigenvalue"))]
|
||||
{
|
||||
diagonal_fallback(&self.link_stats, n_sc, n_modes)
|
||||
let (e, m, b) = diagonal_fallback(&self.link_stats, n_sc, n_modes);
|
||||
(e, m, b, 0.0_f64)
|
||||
}
|
||||
} else {
|
||||
diagonal_fallback(&self.link_stats, n_sc, n_modes)
|
||||
let (e, m, b) = diagonal_fallback(&self.link_stats, n_sc, n_modes);
|
||||
(e, m, b, 0.0_f64)
|
||||
}
|
||||
} else {
|
||||
diagonal_fallback(&self.link_stats, n_sc, n_modes)
|
||||
let (e, m, b) = diagonal_fallback(&self.link_stats, n_sc, n_modes);
|
||||
(e, m, b, 0.0_f64)
|
||||
};
|
||||
|
||||
// Compute variance explained using the same centered covariance as modes.
|
||||
@@ -648,6 +664,7 @@ impl FieldModel {
|
||||
calibrated_at_us: timestamp_us,
|
||||
geometry_hash,
|
||||
baseline_eigenvalue_count: baseline_eig_count,
|
||||
baseline_noise_var,
|
||||
};
|
||||
|
||||
self.modes = Some(field_mode);
|
||||
@@ -794,7 +811,7 @@ impl FieldModel {
|
||||
// Marcenko-Pastur noise estimate: median of POSITIVE eigenvalues
|
||||
// in the bottom half. Excludes zeros from rank-deficient matrices
|
||||
// (common when n_subcarriers > n_frames, e.g. 56 subcarriers / 50 frames).
|
||||
let noise_var = {
|
||||
let local_noise_var = {
|
||||
let mut positive: Vec<f64> =
|
||||
eigenvalues.iter().copied().filter(|&e| e > 1e-10).collect();
|
||||
positive.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
@@ -807,6 +824,22 @@ impl FieldModel {
|
||||
return Ok(0); // All zero eigenvalues — can't estimate
|
||||
}
|
||||
};
|
||||
|
||||
// Issue #942: anchor the noise floor to the calibration's noise_var
|
||||
// when it's available. Per-window noise_var drifts with sample size —
|
||||
// a short estimation window can produce a small local_noise_var that
|
||||
// inflates `significant` and breaks the test_estimate_occupancy_noise_only
|
||||
// invariant. The max of (calibration noise, local noise) keeps the
|
||||
// threshold from collapsing on small windows while still letting the
|
||||
// per-window noise dominate when it's the larger estimate. Falls back
|
||||
// to local_noise_var when baseline_noise_var == 0 (diagonal-fallback
|
||||
// calibration path, or pre-#942 stored modes).
|
||||
let noise_var = if modes.baseline_noise_var > 0.0 {
|
||||
local_noise_var.max(modes.baseline_noise_var)
|
||||
} else {
|
||||
local_noise_var
|
||||
};
|
||||
|
||||
let ratio = n as f64 / count as f64;
|
||||
let mp_threshold = noise_var * (1.0 + ratio.sqrt()).powi(2);
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
[package]
|
||||
name = "wifi-densepose-worldgraph"
|
||||
description = "ADR-139 — WorldGraph environmental digital twin (typed petgraph) for RuView"
|
||||
version = "0.3.0"
|
||||
readme = "README.md"
|
||||
version = "0.3.1"
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# wifi-densepose-worldgraph
|
||||
|
||||
**The environmental digital twin for RF sensing — a typed, evidence-tracked graph of a building and the people in it.**
|
||||
|
||||
[](https://crates.io/crates/wifi-densepose-worldgraph)
|
||||
[](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,7 +1,8 @@
|
||||
[package]
|
||||
name = "wifi-densepose-worldmodel"
|
||||
description = "ADR-147 — OccWorld thin-client bridge: WorldGraph PersonTrack history → OccWorld Python subprocess → TrajectoryPrior"
|
||||
version = "0.3.0"
|
||||
readme = "README.md"
|
||||
version = "0.3.1"
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
@@ -12,7 +13,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 = "0.3.0"
|
||||
wifi-densepose-worldgraph = { version = "0.3.1", path = "../wifi-densepose-worldgraph" }
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
# wifi-densepose-worldmodel
|
||||
|
||||
**Forward prediction for RF sensing — turn where people *were* into where they'll *be*, as occupancy + trajectory priors.**
|
||||
|
||||
[](https://crates.io/crates/wifi-densepose-worldmodel)
|
||||
[](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).
|
||||
Reference in New Issue
Block a user