mirror of
https://github.com/ruvnet/RuView
synced 2026-06-23 12:33:18 +00:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b12b36889 | |||
| 27d17431c5 | |||
| a4bd2308b7 | |||
| 3733e54aef | |||
| cd84c35f8f | |||
| dd45160cc5 | |||
| 5e5781b28a | |||
| 6f23e89909 | |||
| 1dcf5d42eb | |||
| 9814d2bc62 | |||
| 7f02c87c6f | |||
| 9a074bdf4f | |||
| 3c02f6cfb0 | |||
| 23dedecf0c | |||
| c2e564a9f4 | |||
| 40f19622af | |||
| 022499b2f5 | |||
| e6068c5efe | |||
| 7a13877fa3 | |||
| 6c98c98920 | |||
| 5f3c90bf1c | |||
| 4713a30402 | |||
| 2b8a7cc458 | |||
| 8a84748a83 | |||
| 578d84c25e | |||
| 7eba8c7286 | |||
| a7d417837f | |||
| 4239dfa35a | |||
| 24ea88cbe0 | |||
| ef582b4429 | |||
| 8318f9c677 | |||
| 92a6986b79 | |||
| 66e2fa0835 | |||
| 7a97ffd8c7 | |||
| 2b3c3e4b45 | |||
| 024d2583f0 |
@@ -0,0 +1 @@
|
||||
{"intelligence":7,"timestamp":1774922079152}
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
name: Build ESP32-S3 Firmware
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: espressif/idf:v5.2
|
||||
image: espressif/idf:v5.4
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -27,16 +27,16 @@ jobs:
|
||||
idf.py set-target esp32s3
|
||||
idf.py build
|
||||
|
||||
- name: Verify binary size (< 950 KB gate)
|
||||
- name: Verify binary size (< 1100 KB gate)
|
||||
working-directory: firmware/esp32-csi-node
|
||||
run: |
|
||||
BIN=build/esp32-csi-node.bin
|
||||
SIZE=$(stat -c%s "$BIN")
|
||||
MAX=$((950 * 1024))
|
||||
MAX=$((1100 * 1024))
|
||||
echo "Binary size: $SIZE bytes ($(( SIZE / 1024 )) KB)"
|
||||
echo "Size limit: $MAX bytes (950 KB — includes Tier 3 WASM runtime)"
|
||||
echo "Size limit: $MAX bytes (1100 KB — includes WASM runtime + HTTP client for Seed swarm bridge)"
|
||||
if [ "$SIZE" -gt "$MAX" ]; then
|
||||
echo "::error::Firmware binary exceeds 950 KB size gate ($SIZE > $MAX)"
|
||||
echo "::error::Firmware binary exceeds 1100 KB size gate ($SIZE > $MAX)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Binary size OK: $SIZE <= $MAX"
|
||||
@@ -54,9 +54,10 @@ jobs:
|
||||
fi
|
||||
|
||||
# Check partition table magic (0xAA50 at offset 0).
|
||||
# Use od instead of xxd (xxd not available in espressif/idf container).
|
||||
PT=build/partition_table/partition-table.bin
|
||||
if [ -f "$PT" ]; then
|
||||
MAGIC=$(xxd -l2 -p "$PT")
|
||||
MAGIC=$(od -A n -t x1 -N 2 "$PT" | tr -d ' ')
|
||||
if [ "$MAGIC" != "aa50" ]; then
|
||||
echo "::warning::Partition table magic mismatch: $MAGIC (expected aa50)"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
@@ -71,7 +72,7 @@ jobs:
|
||||
fi
|
||||
|
||||
# Verify non-zero data in binary (not all 0xFF padding).
|
||||
NONZERO=$(xxd -l 1024 -p "$BIN" | tr -d 'f' | wc -c)
|
||||
NONZERO=$(od -A n -t x1 -N 1024 "$BIN" | tr -d ' f\n' | wc -c)
|
||||
if [ "$NONZERO" -lt 100 ]; then
|
||||
echo "::error::Binary appears to be mostly padding (non-zero chars: $NONZERO)"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
@@ -97,4 +98,5 @@ jobs:
|
||||
firmware/esp32-csi-node/build/esp32-csi-node.bin
|
||||
firmware/esp32-csi-node/build/bootloader/bootloader.bin
|
||||
firmware/esp32-csi-node/build/partition_table/partition-table.bin
|
||||
retention-days: 30
|
||||
firmware/esp32-csi-node/build/ota_data_initial.bin
|
||||
retention-days: 90
|
||||
|
||||
@@ -23,6 +23,14 @@ rust-port/wifi-densepose-rs/data/recordings/
|
||||
nvs.bin
|
||||
nvs_config.csv
|
||||
nvs_provision.bin
|
||||
firmware/esp32-csi-node/nvs_seed.csv
|
||||
firmware/esp32-csi-node/nvs_seed.bin
|
||||
firmware/esp32-csi-node/nvs_config.bin
|
||||
firmware/esp32-csi-node/nvs_wifi.bin
|
||||
firmware/esp32-csi-node/nvs.bin
|
||||
# Catch any other NVS binaries/CSVs with credentials
|
||||
**/nvs_*.bin
|
||||
**/nvs_*.csv
|
||||
|
||||
# Working artifacts that should not land in root
|
||||
/*.wasm
|
||||
|
||||
@@ -5,6 +5,99 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [v0.5.4-esp32] — 2026-04-02
|
||||
|
||||
### Added
|
||||
- **ADR-069: ESP32 CSI → Cognitum Seed RVF ingest pipeline** — Live-validated pipeline connecting ESP32-S3 CSI sensing to Cognitum Seed (Pi Zero 2 W) edge intelligence appliance. 339 vectors ingested, 100% kNN validation, SHA-256 witness chain verified.
|
||||
- **Feature vector packet (magic 0xC5110003)** — New 48-byte packet with 8 normalized dimensions (presence, motion, breathing, heart rate, phase variance, person count, fall, RSSI) sent at 1 Hz alongside vitals.
|
||||
- **`scripts/seed_csi_bridge.py`** — Python bridge: UDP listener → HTTPS ingest with bearer token auth, `--validate` (kNN + PIR ground truth), `--stats`, `--compact` modes, hash-based vector IDs, NaN/inf rejection, source IP filtering, retry logic.
|
||||
- **Arena Physica research** — 26 research documents in `docs/research/` covering Maxwell's equations in WiFi sensing, Arena Physica Studio analysis, SOTA WiFi sensing 2025-2026, GOAP implementation plan for ESP32 + Pi Zero.
|
||||
- **Cognitum Seed MCP integration** — 114-tool MCP proxy enables AI assistants to query sensing state, vectors, witness chain, and device status directly.
|
||||
|
||||
### Fixed
|
||||
- **Compressed frame magic collision** — Reassigned compressed frame magic from `0xC5110003` to `0xC5110005` to free `0xC5110003` for feature vectors.
|
||||
- **Uninitialized `s_top_k[0]` read** — Guarded variance computation against `s_top_k_count == 0` in `send_feature_vector()`.
|
||||
- **Presence score normalization** — Bridge now divides by 15.0 instead of clamping, preserving dynamic range for raw values 1.41-14.92.
|
||||
- **Stale magic references** — Updated ADR-039, DDD model to reflect `0xC5110005` for compressed frames.
|
||||
|
||||
### Security
|
||||
- **Credential exposure remediation** — Removed hardcoded WiFi passwords and bearer tokens from source files. Added NVS binary/CSV patterns to `.gitignore`. Environment variable fallback for bearer token.
|
||||
- **NaN/Inf injection prevention** — Bridge validates all feature dimensions are finite before Seed ingest.
|
||||
- **UDP source filtering** — `--allowed-sources` argument restricts packet acceptance to known ESP32 IPs.
|
||||
|
||||
### Changed
|
||||
- Wire format table now includes 6 magic numbers: `0xC5110001` (raw), `0xC5110002` (vitals), `0xC5110003` (features), `0xC5110004` (WASM events), `0xC5110005` (compressed), `0xC5110006` (fused vitals).
|
||||
|
||||
## [v0.5.3-esp32] — 2026-03-30
|
||||
|
||||
### Added
|
||||
- **Cross-node RSSI-weighted feature fusion** — Multiple ESP32 nodes fuse CSI features using RSSI-based weighting. Closer node gets higher weight. Reduces variance noise by 29%, keypoint jitter by 72%.
|
||||
- **DynamicMinCut person separation** — Uses `ruvector_mincut::DynamicMinCut` on the subcarrier temporal correlation graph to detect independent motion clusters. Replaces variance-based heuristic for multi-person counting.
|
||||
- **RSSI-based position tracking** — Skeleton position driven by RSSI differential between nodes. Walk between ESP32s and the skeleton follows you.
|
||||
- **Per-node state pipeline (ADR-068)** — Each ESP32 node gets independent `HashMap<u8, NodeState>` with frame history, classification, vitals, and person count. Fixes #249 (the #1 user-reported issue).
|
||||
- **RuVector Phase 1-3 integration** — Subcarrier importance weighting, temporal keypoint smoothing (EMA), coherence gating, skeleton kinematic constraints (Jakobsen relaxation), compressed pose history.
|
||||
- **Client-side lerp smoothing** — UI keypoints interpolate between frames (alpha=0.15) for fluid skeleton movement.
|
||||
- **Multi-node mesh tests** — 8 integration tests covering 1-255 node configurations.
|
||||
- **`wifi_densepose` Python package** — `from wifi_densepose import WiFiDensePose` now works (#314).
|
||||
|
||||
### Fixed
|
||||
- **Watchdog crash on busy LANs (#321)** — Batch-limited edge_dsp to 4 frames before 20ms yield. Fixed idle-path busy-spin (`pdMS_TO_TICKS(5)==0`).
|
||||
- **No detection from edge vitals (#323)** — Server now generates `sensing_update` from Tier 2+ vitals packets.
|
||||
- **RSSI byte offset mismatch (#332)** — Server parsed RSSI from wrong byte (was reading sequence counter).
|
||||
- **Stack overflow risk** — Moved 4KB of BPM scratch buffers from stack to static storage.
|
||||
- **Stale node memory leak** — `node_states` HashMap evicts nodes inactive >60s.
|
||||
- **Unsafe raw pointer removed** — Replaced with safe `.clone()` for adaptive model borrow.
|
||||
- **Firmware CI** — Upgraded to IDF v5.4, replaced `xxd` with `od` (#327).
|
||||
- **Person count double-counting** — Multi-node aggregation changed from `sum` to `max`.
|
||||
- **Skeleton jitter** — Removed tick-based noise, dampened procedural animation, recalibrated feature scaling for real ESP32 data.
|
||||
|
||||
### Changed
|
||||
- Motion-responsive skeleton: arm swing (0-80px) driven by CSI variance, leg kick (0-50px) by motion_band_power, vertical bob when walking.
|
||||
- Person count thresholds recalibrated for real ESP32 hardware (1→2 at 0.70, EMA alpha 0.04).
|
||||
- Vital sign filtering: larger median window (31), faster EMA (0.05), looser HR jump filter (15 BPM).
|
||||
- Vendored ruvector updated to v2.1.0-40 (316 commits ahead).
|
||||
|
||||
### Benchmarks (2-node mesh, COM6 + COM9, 30s)
|
||||
| Metric | Baseline | v0.5.3 | Improvement |
|
||||
|--------|----------|--------|-------------|
|
||||
| Variance noise | 109.4 | 77.6 | **-29%** |
|
||||
| Feature stability | std=154.1 | std=105.4 | **-32%** |
|
||||
| Keypoint jitter | std=4.5px | std=1.3px | **-72%** |
|
||||
| Confidence | 0.643 | 0.686 | **+7%** |
|
||||
| Presence accuracy | 93.4% | 94.6% | **+1.3pp** |
|
||||
|
||||
### Verified
|
||||
- Real hardware: COM6 (node 1) + COM9 (node 2) on ruv.net WiFi
|
||||
- All 284 Rust tests pass, 352 signal crate tests pass
|
||||
- Firmware builds clean at 843 KB
|
||||
- QEMU CI: 11/11 jobs green
|
||||
|
||||
## [v0.5.2-esp32] — 2026-03-28
|
||||
|
||||
### Fixed
|
||||
- RSSI byte offset in frame parser (#332)
|
||||
- Per-node state pipeline for multi-node sensing (#249)
|
||||
- Firmware CI upgraded to IDF v5.4 (#327)
|
||||
|
||||
## [v0.5.1-esp32] — 2026-03-27
|
||||
|
||||
### Fixed
|
||||
- Watchdog crash on busy LANs (#321)
|
||||
- No detection from edge vitals (#323)
|
||||
- `wifi_densepose` Python package import (#314)
|
||||
- Pre-compiled firmware binaries added to release
|
||||
|
||||
## [v0.5.0-esp32] — 2026-03-15
|
||||
|
||||
### Added
|
||||
- **60 GHz mmWave sensor fusion (ADR-063)** — Auto-detects Seeed MR60BHA2 (60 GHz, HR/BR/presence) and HLK-LD2410 (24 GHz, presence/distance) on UART at boot. Probes 115200 then 256000 baud, registers device capabilities, starts background parser.
|
||||
- **48-byte fused vitals packet** (magic `0xC5110004`) — Kalman-style fusion: mmWave 80% + CSI 20% when both available. Automatic fallback to standard 32-byte CSI-only packet.
|
||||
- **Server-side fusion bridge** (`scripts/mmwave_fusion_bridge.py`) — Reads two serial ports simultaneously for dual-sensor setups where mmWave runs on a separate ESP32.
|
||||
- **Multimodal ambient intelligence roadmap (ADR-064)** — 25+ applications from fall detection to sleep monitoring to RF tomography.
|
||||
|
||||
### Verified
|
||||
- Real hardware: ESP32-S3 (COM7) WiFi CSI + ESP32-C6/MR60BHA2 (COM4) 60 GHz mmWave running concurrently. HR=75 bpm, BR=25/min at 52 cm range. All 11 QEMU CI jobs green.
|
||||
|
||||
## [v0.4.3-esp32] — 2026-03-15
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -70,6 +70,17 @@ All 5 ruvector crates integrated in workspace:
|
||||
- ADR-031: RuView sensing-first RF mode (Proposed)
|
||||
- ADR-032: Multistatic mesh security hardening (Proposed)
|
||||
|
||||
### Supported Hardware
|
||||
|
||||
| Device | Port | Chip | Role | Cost |
|
||||
|--------|------|------|------|------|
|
||||
| ESP32-S3 (8MB flash) | COM7 | Xtensa dual-core | WiFi CSI sensing node | ~$9 |
|
||||
| ESP32-S3 SuperMini (4MB) | — | Xtensa dual-core | WiFi CSI (compact) | ~$6 |
|
||||
| ESP32-C6 + Seeed MR60BHA2 | COM4 | RISC-V + 60 GHz FMCW | mmWave HR/BR/presence | ~$15 |
|
||||
| HLK-LD2410 | — | 24 GHz FMCW | Presence + distance | ~$3 |
|
||||
|
||||
**Not supported:** ESP32 (original), ESP32-C3 — single-core, can't run CSI DSP pipeline.
|
||||
|
||||
### Build & Test Commands (this repo)
|
||||
```bash
|
||||
# Rust — full workspace tests (1,031+ tests, ~2 min)
|
||||
@@ -79,11 +90,6 @@ cargo test --workspace --no-default-features
|
||||
# Rust — single crate check (no GPU needed)
|
||||
cargo check -p wifi-densepose-train --no-default-features
|
||||
|
||||
# Rust — publish crates (dependency order)
|
||||
cargo publish -p wifi-densepose-core --no-default-features
|
||||
cargo publish -p wifi-densepose-signal --no-default-features
|
||||
# ... see crate publishing order below
|
||||
|
||||
# Python — deterministic proof verification (SHA-256)
|
||||
python v1/data/proof/verify.py
|
||||
|
||||
@@ -91,6 +97,36 @@ python v1/data/proof/verify.py
|
||||
cd v1 && python -m pytest tests/ -x -q
|
||||
```
|
||||
|
||||
### ESP32 Firmware Build (Windows — Python subprocess required)
|
||||
```bash
|
||||
# Build 8MB firmware (real WiFi CSI mode, no mocks)
|
||||
# See CLAUDE.local.md for the full Python subprocess command
|
||||
# Key: must strip MSYSTEM env vars for ESP-IDF v5.4 on Git Bash
|
||||
|
||||
# Build 4MB firmware
|
||||
cp sdkconfig.defaults.4mb sdkconfig.defaults
|
||||
# then same build process
|
||||
|
||||
# Flash to COM7
|
||||
# [python, idf_py, '-p', 'COM7', 'flash']
|
||||
|
||||
# Provision WiFi
|
||||
python firmware/esp32-csi-node/provision.py --port COM7 \
|
||||
--ssid "YourWiFi" --password "secret" --target-ip 192.168.1.20
|
||||
|
||||
# Monitor serial
|
||||
python -m serial.tools.miniterm COM7 115200
|
||||
```
|
||||
|
||||
### Firmware Release Process
|
||||
1. Build 8MB from `sdkconfig.defaults.template` (no mock)
|
||||
2. Build 4MB from `sdkconfig.defaults.4mb` (no mock)
|
||||
3. Save 6 binaries: `esp32-csi-node.bin`, `bootloader.bin`, `partition-table.bin`, `ota_data_initial.bin`, `esp32-csi-node-4mb.bin`, `partition-table-4mb.bin`
|
||||
4. Tag: `git tag v0.X.Y-esp32 && git push origin v0.X.Y-esp32`
|
||||
5. Release: `gh release create v0.X.Y-esp32 <binaries> --title "..." --notes-file ...`
|
||||
6. Verify on real hardware (COM7) before publishing
|
||||
7. **CRITICAL:** Always test with real WiFi CSI, not mock mode — mock missed the Kconfig threshold bug
|
||||
|
||||
### Crate Publishing Order
|
||||
Crates must be published in dependency order:
|
||||
1. `wifi-densepose-core` (no internal deps)
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
# π RuView
|
||||
|
||||
<p align="center">
|
||||
<a href="https://ruvnet.github.io/RuView/">
|
||||
<a href="https://x.com/rUv/status/2037556932802761004">
|
||||
<img src="assets/ruview-small-gemini.jpg" alt="RuView - WiFi DensePose" width="100%">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
> **Alpha Software** — This project is under active development. APIs, firmware behavior, and documentation may change. Known limitations:
|
||||
> - Multi-node person counting may show identical output regardless of the number of people (#249)
|
||||
> - Training pipeline on MM-Fi dataset may plateau at low PCK (#318) — hyperparameter tuning in progress
|
||||
> - No pre-trained model weights are provided; training from scratch is required
|
||||
> - ESP32-C3 and original ESP32 are not supported (single-core, insufficient for CSI DSP)
|
||||
> - Single ESP32 deployments have limited spatial resolution
|
||||
>
|
||||
> Contributions and bug reports welcome at [Issues](https://github.com/ruvnet/RuView/issues).
|
||||
|
||||
## **See through walls with WiFi + Ai** ##
|
||||
|
||||
**Perceive the world through signals.** No cameras. No wearables. No Internet. Just physics.
|
||||
@@ -14,7 +23,7 @@
|
||||
|
||||
Instead of relying on cameras or cloud models, it observes whatever signals exist in a space such as WiFi, radio waves across the spectrum, motion patterns, vibration, sound, or other sensory inputs and builds an understanding of what is happening locally.
|
||||
|
||||
Built on top of [RuVector](https://github.com/ruvnet/ruvector/), the project became widely known for its implementation of WiFi DensePose — a sensing technique first explored in academic research such as Carnegie Mellon University's *DensePose From WiFi* work. That research demonstrated that WiFi signals can be used to reconstruct human pose.
|
||||
Built on top of [RuVector](https://github.com/ruvnet/ruvector/) Self Learning Vector Memory system and [Cognitum.One](https://Cognitum.One) , the project became widely known for its implementation of WiFi DensePose — a sensing technique first explored in academic research such as Carnegie Mellon University's *DensePose From WiFi* work. That research demonstrated that WiFi signals can be used to reconstruct human pose.
|
||||
|
||||
RuView extends that concept into a practical edge system. By analyzing Channel State Information (CSI) disturbances caused by human movement, RuView reconstructs body position, breathing rate, heart rate, and presence in real time using physics-based signal processing and machine learning.
|
||||
|
||||
@@ -61,7 +70,8 @@ docker run -p 3000:3000 ruvnet/wifi-densepose:latest
|
||||
>
|
||||
> | Option | Hardware | Cost | Full CSI | Capabilities |
|
||||
> |--------|----------|------|----------|-------------|
|
||||
> | **ESP32 Mesh** (recommended) | 3-6x ESP32-S3 + WiFi router | ~$54 | Yes | Pose, breathing, heartbeat, motion, presence |
|
||||
> | **ESP32 + Cognitum Seed** (recommended) | ESP32-S3 + Cognitum Seed (Pi Zero 2 W) | ~$27 | Yes | Pose, breathing, heartbeat, motion, presence + persistent vector store, kNN search, witness chain, MCP proxy |
|
||||
> | **ESP32 Mesh** | 3-6x ESP32-S3 + WiFi router | ~$54 | Yes | Pose, breathing, heartbeat, motion, presence |
|
||||
> | **Research NIC** | Intel 5300 / Atheros AR9580 | ~$50-100 | Yes | Full CSI with 3x3 MIMO |
|
||||
> | **Any WiFi** | Windows, macOS, or Linux laptop | $0 | No | RSSI-only: coarse presence and motion |
|
||||
>
|
||||
@@ -78,6 +88,7 @@ docker run -p 3000:3000 ruvnet/wifi-densepose:latest
|
||||
| [Architecture Decisions](docs/adr/README.md) | 62 ADRs — why each technical choice was made, organized by domain (hardware, signal processing, ML, platform, infrastructure) |
|
||||
| [Domain Models](docs/ddd/README.md) | 7 DDD models (RuvSense, Signal Processing, Training Pipeline, Hardware Platform, Sensing Server, WiFi-Mat, CHCI) — bounded contexts, aggregates, domain events, and ubiquitous language |
|
||||
| [Desktop App](rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/README.md) | **WIP** — Tauri v2 desktop app for node management, OTA updates, WASM deployment, and mesh visualization |
|
||||
| [Medical Examples](examples/medical/README.md) | Contactless blood pressure, heart rate, breathing rate via 60 GHz mmWave radar — $15 hardware, no wearable |
|
||||
|
||||
---
|
||||
|
||||
@@ -1038,7 +1049,7 @@ ESP32-S3 node UDP/5005 Host server (optional)
|
||||
| Subcarriers per frame | 64 / 128 / 192 (depends on WiFi mode) |
|
||||
| UDP latency | < 1 ms on local network |
|
||||
| Presence detection range | Reliable at 3 m through walls |
|
||||
| Binary size | 947 KB (fits in 1 MB flash partition) |
|
||||
| Binary size | 990 KB (8MB flash) / 773 KB (4MB flash) |
|
||||
| Boot to ready | ~3.9 seconds |
|
||||
|
||||
### Flash and provision
|
||||
@@ -1047,7 +1058,9 @@ Download a pre-built binary — no build toolchain needed:
|
||||
|
||||
| Release | What's included | Tag |
|
||||
|---------|-----------------|-----|
|
||||
| [v0.4.3](https://github.com/ruvnet/RuView/releases/tag/v0.4.3-esp32) | **Stable** — Fall detection fix ([#263](https://github.com/ruvnet/RuView/issues/263)), 4MB flash support ([#265](https://github.com/ruvnet/RuView/issues/265)), QEMU CI green | `v0.4.3-esp32` |
|
||||
| [v0.5.4](https://github.com/ruvnet/RuView/releases/tag/v0.5.4-esp32) | **Latest** — Cognitum Seed integration ([ADR-069](docs/adr/ADR-069-cognitum-seed-csi-pipeline.md)), 8-dim feature vectors at 1 Hz, RVF vector store ingest, witness chain attestation, security hardening | `v0.5.4-esp32` |
|
||||
| [v0.5.0](https://github.com/ruvnet/RuView/releases/tag/v0.5.0-esp32) | mmWave sensor fusion ([ADR-063](docs/adr/ADR-063-mmwave-sensor-fusion.md)), auto-detect MR60BHA2/LD2410, 48-byte fused vitals, all v0.4.3.1 fixes | `v0.5.0-esp32` |
|
||||
| [v0.4.3.1](https://github.com/ruvnet/RuView/releases/tag/v0.4.3.1-esp32) | Fall detection fix ([#263](https://github.com/ruvnet/RuView/issues/263)), 4MB flash ([#265](https://github.com/ruvnet/RuView/issues/265)), watchdog fix ([#266](https://github.com/ruvnet/RuView/issues/266)) | `v0.4.3.1-esp32` |
|
||||
| [v0.4.1](https://github.com/ruvnet/RuView/releases/tag/v0.4.1-esp32) | CSI build fix, compile guard, AMOLED display, edge intelligence ([ADR-057](docs/adr/ADR-057-firmware-csi-build-guard.md)) | `v0.4.1-esp32` |
|
||||
| [v0.3.0-alpha](https://github.com/ruvnet/RuView/releases/tag/v0.3.0-alpha-esp32) | Alpha — adds on-device edge intelligence and WASM modules ([ADR-039](docs/adr/ADR-039-esp32-edge-intelligence.md), [ADR-040](docs/adr/ADR-040-wasm-programmable-sensing.md)) | `v0.3.0-alpha-esp32` |
|
||||
| [v0.2.0](https://github.com/ruvnet/RuView/releases/tag/v0.2.0-esp32) | Raw CSI streaming, multi-node TDM, channel hopping | `v0.2.0-esp32` |
|
||||
@@ -1092,6 +1105,34 @@ python firmware/esp32-csi-node/provision.py --port COM8 \
|
||||
|
||||
Nodes can also hop across WiFi channels (1, 6, 11) to increase sensing bandwidth — configured via [ADR-029](docs/adr/ADR-029-ruvsense-multistatic-sensing-mode.md) channel hopping.
|
||||
|
||||
### Cognitum Seed integration (ADR-069)
|
||||
|
||||
Connect an ESP32 to a [Cognitum Seed](https://cognitum.one) (Pi Zero 2 W, ~$15) for persistent vector storage, kNN search, cryptographic witness chain, and AI-accessible MCP proxy:
|
||||
|
||||
```
|
||||
ESP32-S3 ($9) ──UDP──> Host bridge ──HTTPS──> Cognitum Seed ($15)
|
||||
CSI capture seed_csi_bridge.py RVF vector store
|
||||
8-dim features @ 1 Hz kNN similarity search
|
||||
Vitals + presence Ed25519 witness chain
|
||||
114-tool MCP proxy
|
||||
```
|
||||
|
||||
```bash
|
||||
# 1. Provision ESP32 to send features to your laptop
|
||||
python firmware/esp32-csi-node/provision.py --port COM9 \
|
||||
--ssid "YourWiFi" --password "secret" --target-ip 192.168.1.20 --target-port 5006
|
||||
|
||||
# 2. Run the bridge (forwards to Seed via HTTPS)
|
||||
export SEED_TOKEN="your-pairing-token"
|
||||
python scripts/seed_csi_bridge.py \
|
||||
--seed-url https://169.254.42.1:8443 --token "$SEED_TOKEN" --validate
|
||||
|
||||
# 3. Check Seed stats
|
||||
python scripts/seed_csi_bridge.py --token "$SEED_TOKEN" --stats
|
||||
```
|
||||
|
||||
The 8-dim feature vector captures: presence, motion, breathing rate, heart rate, phase variance, person count, fall detection, and RSSI — all normalized to [0.0, 1.0]. See [ADR-069](docs/adr/ADR-069-cognitum-seed-csi-pipeline.md) for the full architecture.
|
||||
|
||||
### On-device intelligence (v0.3.0-alpha)
|
||||
|
||||
The alpha firmware can analyze signals locally and send compact results instead of raw data. This means the ESP32 works standalone — no server needed for basic sensing. Disabled by default for backward compatibility.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -24,7 +24,7 @@ No on-device processing. CSI frames streamed as-is (magic `0xC5110001`).
|
||||
- Phase extraction and unwrapping from I/Q pairs
|
||||
- Welford running variance per subcarrier
|
||||
- Top-K subcarrier selection by variance
|
||||
- Delta compression (XOR + RLE) for 30-50% bandwidth reduction (magic `0xC5110003`)
|
||||
- Delta compression (XOR + RLE) for 30-50% bandwidth reduction (magic `0xC5110005`, reassigned from `0xC5110003` by ADR-069)
|
||||
|
||||
### Tier 2 — Full Edge Intelligence
|
||||
All of Tier 1, plus:
|
||||
@@ -50,7 +50,7 @@ Core 0 (WiFi) Core 1 (DSP)
|
||||
│ Multi-person clustering │
|
||||
│ Delta compression │
|
||||
│ ──▶ UDP vitals (0xC5110002)│
|
||||
│ ──▶ UDP compressed (0x03) │
|
||||
│ ──▶ UDP compressed (0x05) │
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -73,11 +73,11 @@ Core 0 (WiFi) Core 1 (DSP)
|
||||
| 24-27 | u32 LE | Timestamp (ms since boot) |
|
||||
| 28-31 | u32 LE | Reserved |
|
||||
|
||||
**Compressed Frame (magic `0xC5110003`)**:
|
||||
**Compressed Frame (magic `0xC5110005`, reassigned from `0xC5110003` by ADR-069)**:
|
||||
|
||||
| Offset | Type | Field |
|
||||
|--------|------|-------|
|
||||
| 0-3 | u32 LE | Magic `0xC5110003` |
|
||||
| 0-3 | u32 LE | Magic `0xC5110005` |
|
||||
| 4 | u8 | Node ID |
|
||||
| 5 | u8 | WiFi channel |
|
||||
| 6-7 | u16 LE | Original I/Q length |
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
# ADR-063: 60 GHz mmWave Sensor Fusion with WiFi CSI
|
||||
|
||||
**Status:** Proposed
|
||||
**Date:** 2026-03-15
|
||||
**Deciders:** @ruvnet
|
||||
**Related:** ADR-014 (SOTA signal processing), ADR-021 (vital sign extraction), ADR-029 (RuvSense multistatic), ADR-039 (edge intelligence), ADR-042 (CHCI coherent sensing)
|
||||
|
||||
## Context
|
||||
|
||||
RuView currently senses the environment using WiFi CSI — a passive technique that analyzes how WiFi signals are disturbed by human presence and movement. While this works through walls and requires no line of sight, CSI-derived vital signs (breathing rate, heart rate) are inherently noisy because they rely on phase extraction from multipath-rich WiFi channels.
|
||||
|
||||
A complementary sensing modality exists: **60 GHz mmWave radar** modules (e.g., Seeed MR60BHA2) that use active FMCW radar at 60 GHz to measure breathing and heart rate with clinical-grade accuracy. These modules are inexpensive (~$15), run on ESP32-C6/C3, and output structured vital signs over UART.
|
||||
|
||||
**Live hardware capture (COM4, 2026-03-15)** from a Seeed MR60BHA2 on an ESP32-C6 running ESPHome:
|
||||
|
||||
```
|
||||
[D][sensor:093]: 'Real-time respiratory rate': Sending state 22.00000
|
||||
[D][sensor:093]: 'Real-time heart rate': Sending state 92.00000 bpm
|
||||
[D][sensor:093]: 'Distance to detection object': Sending state 0.00000 cm
|
||||
[D][sensor:093]: 'Target Number': Sending state 0.00000
|
||||
[D][binary_sensor:036]: 'Person Information': Sending state OFF
|
||||
[D][sensor:093]: 'Seeed MR60BHA2 Illuminance': Sending state 0.67913 lx
|
||||
```
|
||||
|
||||
### The Opportunity
|
||||
|
||||
Fusing WiFi CSI with mmWave radar creates a sensor system that is greater than the sum of its parts:
|
||||
|
||||
| Capability | WiFi CSI Alone | mmWave Alone | Fused |
|
||||
|-----------|---------------|-------------|-------|
|
||||
| Through-wall sensing | Yes (5m+) | No (LoS only, ~3m) | Yes — CSI for room-scale, mmWave for precision |
|
||||
| Heart rate accuracy | ±5-10 BPM | ±1-2 BPM | ±1-2 BPM (mmWave primary, CSI cross-validates) |
|
||||
| Breathing accuracy | ±2-3 BPM | ±0.5 BPM | ±0.5 BPM |
|
||||
| Presence detection | Good (adaptive threshold) | Excellent (range-gated) | Excellent + through-wall |
|
||||
| Multi-person | Via subcarrier clustering | Via range-Doppler bins | Combined spatial + RF resolution |
|
||||
| Fall detection | Phase acceleration | Range/velocity + micro-Doppler | Dual-confirm reduces false positives to near-zero |
|
||||
| Pose estimation | Via trained model | Not available | CSI provides pose; mmWave provides ground-truth vitals for training |
|
||||
| Coverage | Whole room (passive) | ~120° cone, 3m range | Full room + precision zone |
|
||||
| Cost per node | ~$9 (ESP32-S3) | ~$15 (ESP32-C6 + MR60BHA2) | ~$24 combined |
|
||||
|
||||
### RuVector Integration Points
|
||||
|
||||
The RuVector v2.0.4 stack (already integrated per ADR-016) provides the signal processing backbone:
|
||||
|
||||
| RuVector Component | Role in mmWave Fusion |
|
||||
|-------------------|----------------------|
|
||||
| `ruvector-attention` (`bvp.rs`) | Blood Volume Pulse estimation — mmWave heart rate can calibrate the WiFi CSI BVP phase extraction |
|
||||
| `ruvector-temporal-tensor` (`breathing.rs`) | Breathing rate estimation — mmWave provides ground-truth for adaptive filter tuning |
|
||||
| `ruvector-solver` (`triangulation.rs`) | Multilateration — mmWave range-gated distance + CSI amplitude = 3D position |
|
||||
| `ruvector-attn-mincut` (`spectrogram.rs`) | Time-frequency decomposition — mmWave Doppler complements CSI phase spectrogram |
|
||||
| `ruvector-mincut` (`metrics.rs`, DynamicPersonMatcher) | Multi-person association — mmWave target IDs help disambiguate CSI subcarrier clusters |
|
||||
|
||||
### RuvSense Integration Points
|
||||
|
||||
The RuvSense multistatic sensing pipeline (ADR-029) gains new capabilities:
|
||||
|
||||
| RuvSense Module | mmWave Integration |
|
||||
|----------------|-------------------|
|
||||
| `pose_tracker.rs` (AETHER re-ID) | mmWave distance + velocity as additional re-ID features for Kalman tracker |
|
||||
| `longitudinal.rs` (Welford stats) | mmWave vitals as reference signal for CSI drift detection |
|
||||
| `intention.rs` (pre-movement) | mmWave micro-Doppler detects pre-movement 100-200ms earlier than CSI |
|
||||
| `adversarial.rs` (consistency check) | mmWave provides independent signal to detect CSI spoofing/anomalies |
|
||||
| `coherence_gate.rs` | mmWave presence as additional gate input — if mmWave says "no person", CSI coherence gate rejects |
|
||||
|
||||
### Cross-Viewpoint Fusion Integration
|
||||
|
||||
The viewpoint fusion pipeline (`ruvector/src/viewpoint/`) extends naturally:
|
||||
|
||||
| Viewpoint Module | mmWave Extension |
|
||||
|-----------------|-----------------|
|
||||
| `attention.rs` (CrossViewpointAttention) | mmWave range becomes a new "viewpoint" in the attention mechanism |
|
||||
| `geometry.rs` (GeometricDiversityIndex) | mmWave cone geometry contributes to Fisher Information / Cramer-Rao bounds |
|
||||
| `coherence.rs` (phase phasor) | mmWave phase coherence as validation for WiFi phasor coherence |
|
||||
| `fusion.rs` (MultistaticArray) | mmWave node becomes a member of the multistatic array with its own domain events |
|
||||
|
||||
## Decision
|
||||
|
||||
Add 60 GHz mmWave radar sensor support to the RuView firmware and sensing pipeline with auto-detection and device-specific capabilities.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Sensing Node │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌────────────┐ │
|
||||
│ │ ESP32-S3 │ │ ESP32-C6 │ │ Combined │ │
|
||||
│ │ WiFi CSI │ │ + MR60BHA2 │ │ S3 + UART │ │
|
||||
│ │ (COM7) │ │ 60GHz mmWave │ │ mmWave │ │
|
||||
│ │ │ │ (COM4) │ │ │ │
|
||||
│ │ Passive │ │ Active radar │ │ Both modes │ │
|
||||
│ │ Through-wall │ │ LoS, precise │ │ │ │
|
||||
│ └──────┬───────┘ └──────┬───────┘ └─────┬──────┘ │
|
||||
│ │ │ │ │
|
||||
│ └────────┬───────────┘ │ │
|
||||
│ ▼ │ │
|
||||
│ ┌────────────────┐ │ │
|
||||
│ │ Fusion Engine │◄──────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ │ • Kalman fuse │ Vitals packet (extended): │
|
||||
│ │ • Cross-validate│ magic 0xC5110004 │
|
||||
│ │ • Ground-truth │ + mmwave_hr, mmwave_br │
|
||||
│ │ calibration │ + mmwave_distance │
|
||||
│ │ • Fall confirm │ + mmwave_target_count │
|
||||
│ └────────────────┘ + confidence scores │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Three Deployment Modes
|
||||
|
||||
**Mode 1: Standalone CSI (existing)** — ESP32-S3 only, WiFi CSI sensing.
|
||||
|
||||
**Mode 2: Standalone mmWave** — ESP32-C6 + MR60BHA2, precise vitals in a single room.
|
||||
|
||||
**Mode 3: Fused (recommended)** — ESP32-S3 + mmWave module on UART, or two separate nodes with server-side fusion.
|
||||
|
||||
### Auto-Detection Protocol
|
||||
|
||||
The firmware will auto-detect connected mmWave modules at boot:
|
||||
|
||||
1. **UART probe** — On configured UART pins, send the MR60BHA2 identification command (`0x01 0x01 0x00 0x01 ...`) and check for valid response header
|
||||
2. **Protocol detection** — Identify the sensor family:
|
||||
- Seeed MR60BHA2 (breathing + heart rate)
|
||||
- Seeed MR60FDA1 (fall detection)
|
||||
- Seeed MR24HPC1 (presence + light sleep/deep sleep)
|
||||
- HLK-LD2410 (presence + distance)
|
||||
- HLK-LD2450 (multi-target tracking)
|
||||
3. **Capability registration** — Register detected sensor capabilities in the edge config:
|
||||
|
||||
```c
|
||||
typedef struct {
|
||||
uint8_t mmwave_detected; /** 1 if mmWave module found on UART */
|
||||
uint8_t mmwave_type; /** Sensor family (MR60BHA2, MR60FDA1, etc.) */
|
||||
uint8_t mmwave_has_hr; /** Heart rate capability */
|
||||
uint8_t mmwave_has_br; /** Breathing rate capability */
|
||||
uint8_t mmwave_has_fall; /** Fall detection capability */
|
||||
uint8_t mmwave_has_presence; /** Presence detection capability */
|
||||
uint8_t mmwave_has_distance; /** Range measurement capability */
|
||||
uint8_t mmwave_has_tracking; /** Multi-target tracking capability */
|
||||
float mmwave_hr_bpm; /** Latest heart rate from mmWave */
|
||||
float mmwave_br_bpm; /** Latest breathing rate from mmWave */
|
||||
float mmwave_distance_cm; /** Distance to nearest target */
|
||||
uint8_t mmwave_target_count; /** Number of detected targets */
|
||||
bool mmwave_person_present;/** mmWave presence state */
|
||||
} mmwave_state_t;
|
||||
```
|
||||
|
||||
### Supported Sensors
|
||||
|
||||
| Sensor | Frequency | Capabilities | UART Protocol | Cost |
|
||||
|--------|-----------|-------------|---------------|------|
|
||||
| **Seeed MR60BHA2** | 60 GHz | HR, BR, presence, illuminance | Seeed proprietary frames | ~$15 |
|
||||
| **Seeed MR60FDA1** | 60 GHz | Fall detection, presence | Seeed proprietary frames | ~$15 |
|
||||
| **Seeed MR24HPC1** | 24 GHz | Presence, sleep stage, distance | Seeed proprietary frames | ~$10 |
|
||||
| **HLK-LD2410** | 24 GHz | Presence, distance (motion + static) | HLK binary protocol | ~$3 |
|
||||
| **HLK-LD2450** | 24 GHz | Multi-target tracking (x,y,speed) | HLK binary protocol | ~$5 |
|
||||
|
||||
### Fusion Algorithms
|
||||
|
||||
**1. Vital Sign Fusion (Kalman filter)**
|
||||
```
|
||||
mmWave HR (high confidence, 1 Hz) ─┐
|
||||
├─► Kalman fuse → fused HR ± confidence
|
||||
CSI-derived HR (lower confidence) ─┘
|
||||
```
|
||||
|
||||
**2. Fall Detection (dual-confirm)**
|
||||
```
|
||||
CSI phase accel > thresh ──────┐
|
||||
├─► AND gate → confirmed fall (near-zero false positives)
|
||||
mmWave range-velocity pattern ─┘
|
||||
```
|
||||
|
||||
**3. Presence Validation**
|
||||
```
|
||||
CSI adaptive threshold ────┐
|
||||
├─► Weighted vote → robust presence
|
||||
mmWave target count > 0 ──┘
|
||||
```
|
||||
|
||||
**4. Training Calibration**
|
||||
```
|
||||
mmWave ground-truth vitals → train CSI BVP extraction model
|
||||
mmWave distance → calibrate CSI triangulation
|
||||
mmWave micro-Doppler → label CSI activity patterns
|
||||
```
|
||||
|
||||
### Vitals Packet Extension
|
||||
|
||||
Extend the existing 32-byte vitals packet (magic `0xC5110002`) with a new 48-byte fused packet:
|
||||
|
||||
```c
|
||||
typedef struct __attribute__((packed)) {
|
||||
/* Existing 32-byte vitals fields */
|
||||
uint32_t magic; /* 0xC5110004 (fused vitals) */
|
||||
uint8_t node_id;
|
||||
uint8_t flags; /* Bit0=presence, Bit1=fall, Bit2=motion, Bit3=mmwave_present */
|
||||
uint16_t breathing_rate; /* Fused BPM * 100 */
|
||||
uint32_t heartrate; /* Fused BPM * 10000 */
|
||||
int8_t rssi;
|
||||
uint8_t n_persons;
|
||||
uint8_t mmwave_type; /* Sensor type enum */
|
||||
uint8_t fusion_confidence;/* 0-100 fusion quality score */
|
||||
float motion_energy;
|
||||
float presence_score;
|
||||
uint32_t timestamp_ms;
|
||||
/* New mmWave fields (16 bytes) */
|
||||
float mmwave_hr_bpm; /* Raw mmWave heart rate */
|
||||
float mmwave_br_bpm; /* Raw mmWave breathing rate */
|
||||
float mmwave_distance; /* Distance to nearest target (cm) */
|
||||
uint8_t mmwave_targets; /* Target count */
|
||||
uint8_t mmwave_confidence;/* mmWave signal quality 0-100 */
|
||||
uint16_t reserved;
|
||||
} edge_fused_vitals_pkt_t;
|
||||
|
||||
_Static_assert(sizeof(edge_fused_vitals_pkt_t) == 48, "fused vitals must be 48 bytes");
|
||||
```
|
||||
|
||||
### NVS Configuration
|
||||
|
||||
New provisioning parameters:
|
||||
|
||||
```bash
|
||||
python provision.py --port COM7 \
|
||||
--mmwave-uart-tx 17 --mmwave-uart-rx 18 \ # UART pins for mmWave module
|
||||
--mmwave-type auto \ # auto-detect, or: mr60bha2, ld2410, etc.
|
||||
--fusion-mode kalman \ # kalman, vote, mmwave-primary, csi-primary
|
||||
--fall-dual-confirm true # require both CSI + mmWave for fall alert
|
||||
```
|
||||
|
||||
### Implementation Phases
|
||||
|
||||
| Phase | Scope | Effort |
|
||||
|-------|-------|--------|
|
||||
| **Phase 1** | UART driver + MR60BHA2 parser + auto-detection | 2 weeks |
|
||||
| **Phase 2** | Fused vitals packet + Kalman vital sign fusion | 1 week |
|
||||
| **Phase 3** | Dual-confirm fall detection + presence voting | 1 week |
|
||||
| **Phase 4** | HLK-LD2410/LD2450 support + multi-target fusion | 2 weeks |
|
||||
| **Phase 5** | RuVector calibration pipeline (mmWave as ground truth) | 3 weeks |
|
||||
| **Phase 6** | Server-side fusion for separate CSI + mmWave nodes | 2 weeks |
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- Near-zero false positive fall detection (dual-confirm)
|
||||
- Clinical-grade vital signs when mmWave is present, with CSI as fallback
|
||||
- Self-calibrating CSI pipeline using mmWave ground truth
|
||||
- Backward compatible — existing CSI-only nodes work unchanged
|
||||
- Low incremental cost (~$3-15 per mmWave module)
|
||||
- Auto-detection means zero configuration for supported sensors
|
||||
- RuVector attention/solver/temporal-tensor modules gain a high-quality reference signal
|
||||
|
||||
### Negative
|
||||
- Added firmware complexity (~2-3 KB RAM for mmWave state + UART buffer)
|
||||
- mmWave modules require line-of-sight (complementary to CSI, not replacement)
|
||||
- Multiple UART protocols to maintain (Seeed, HLK families)
|
||||
- 48-byte fused packet requires server parser update
|
||||
|
||||
### Neutral
|
||||
- ESP32-C6 cannot run the full CSI pipeline (single-core RISC-V) but can serve as a dedicated mmWave bridge node
|
||||
- mmWave modules add ~15 mA power draw per node
|
||||
@@ -0,0 +1,327 @@
|
||||
# ADR-064: Multimodal Ambient Intelligence — WiFi CSI + mmWave + Environmental Sensors
|
||||
|
||||
**Status:** Proposed
|
||||
**Date:** 2026-03-15
|
||||
**Deciders:** @ruvnet
|
||||
**Related:** ADR-063 (mmWave fusion), ADR-039 (edge intelligence), ADR-042 (CHCI), ADR-029 (RuvSense multistatic), ADR-024 (AETHER contrastive embeddings)
|
||||
|
||||
## Context
|
||||
|
||||
With ADR-063 we demonstrated real-time fusion of WiFi CSI (ESP32-S3, COM7) and 60 GHz mmWave radar (Seeed MR60BHA2 on ESP32-C6, COM4). The live capture showed:
|
||||
|
||||
- **mmWave**: HR 75 bpm, BR 25/min, presence at 52 cm, 1.4 Hz update
|
||||
- **WiFi CSI**: Channel 5, RSSI -41, 20+ Hz frame rate, through-wall coverage
|
||||
- **BH1750**: Ambient light 0.0-0.7 lux (room darkness level)
|
||||
|
||||
This ADR explores the full spectrum of what becomes possible when these modalities are combined — from immediately practical applications to speculative research directions.
|
||||
|
||||
---
|
||||
|
||||
## Tier 1: Practical (Build Now)
|
||||
|
||||
### 1.1 Intelligent Fall Detection with Zero False Positives
|
||||
|
||||
**Current state:** CSI-only fall detection with 15.0 rad/s² threshold (v0.4.3.1).
|
||||
**With fusion:** mmWave confirms fall via range-velocity signature (sudden height drop + impact deceleration). CSI provides the alert; mmWave provides the confirmation.
|
||||
|
||||
```
|
||||
CSI phase acceleration > 15 rad/s² ─┐
|
||||
├─► AND gate + temporal correlation
|
||||
mmWave: height drop > 50cm in <1s ──┘ → CONFIRMED FALL (call 911)
|
||||
```
|
||||
|
||||
**Impact:** Elderly care facilities spend $34B/year on fall injuries. A $24 sensor node with zero false positives replaces $200/month medical alert wearables that residents forget to wear.
|
||||
|
||||
### 1.2 Sleep Quality Monitoring
|
||||
|
||||
**Sensors used:** mmWave (BR/HR), CSI (bed occupancy, movement), BH1750 (light)
|
||||
|
||||
| Metric | Source | Method |
|
||||
|--------|--------|--------|
|
||||
| Sleep onset | CSI motion → still transition | Phase variance drops below threshold |
|
||||
| Sleep stages | mmWave BR variability | BR 12-20 = light sleep, 6-12 = deep sleep |
|
||||
| REM detection | mmWave HR variability | HR variability increases during REM |
|
||||
| Restlessness | CSI motion energy | Counts of motion episodes per hour |
|
||||
| Room darkness | BH1750 | Correlate light exposure with sleep latency |
|
||||
| Wake events | CSI + mmWave | Motion + HR spike = awakening |
|
||||
|
||||
**Output:** Sleep score (0-100), time in each stage, disturbance log.
|
||||
**No wearable required.** Works through a mattress.
|
||||
|
||||
### 1.3 Occupancy-Aware HVAC and Lighting
|
||||
|
||||
**Sensors:** CSI (room-level presence through walls), mmWave (precise count + distance), BH1750 (ambient light)
|
||||
|
||||
- CSI detects which rooms are occupied (through walls, whole-floor sensing)
|
||||
- mmWave counts exact number of people in the sensor's room
|
||||
- BH1750 measures if lights are on/needed
|
||||
- System sends MQTT/UDP commands to smart home controllers
|
||||
|
||||
**Energy savings:** 20-40% HVAC reduction by not heating/cooling empty rooms.
|
||||
|
||||
### 1.4 Bathroom Safety for Elderly
|
||||
|
||||
**Sensor placement:** One CSI node outside bathroom (through-wall), one mmWave inside.
|
||||
|
||||
- CSI detects person entered bathroom (through-wall)
|
||||
- mmWave monitors vitals while showering (waterproof enclosure)
|
||||
- If no movement for > N minutes AND HR drops: alert
|
||||
- Fall detection in shower (slippery surface = high risk)
|
||||
|
||||
### 1.5 Baby/Infant Breathing Monitor
|
||||
|
||||
**mmWave at crib-side:** Contactless breathing monitoring at 0.5-1m range.
|
||||
- BR < 10 or BR = 0 for > 20s: alarm (apnea detection)
|
||||
- CSI provides room context (parent present? other motion?)
|
||||
- BH1750 tracks night feeding times (light on/off events)
|
||||
|
||||
---
|
||||
|
||||
## Tier 2: Advanced (Research Prototype)
|
||||
|
||||
### 2.1 Gait Analysis and Fall Risk Prediction
|
||||
|
||||
**Method:** CSI tracks walking pattern across the room; mmWave measures stride length and velocity.
|
||||
|
||||
| Feature | Source | Clinical Use |
|
||||
|---------|--------|-------------|
|
||||
| Gait velocity | mmWave Doppler | < 0.8 m/s = fall risk indicator |
|
||||
| Stride variability | CSI phase patterns | High variability = cognitive decline marker |
|
||||
| Turning stability | CSI + mmWave | Difficulty turning = Parkinson's indicator |
|
||||
| Get-up time | mmWave (sit→stand) | Timed Up and Go (TUG) test, contactless |
|
||||
|
||||
**Clinical value:** Gait velocity is called the "sixth vital sign" — it predicts hospitalization, cognitive decline, and mortality. Currently requires a $10,000 GAITRite mat. A $24 sensor node replaces it.
|
||||
|
||||
### 2.2 Emotion and Stress Detection via Micro-Vitals
|
||||
|
||||
**mmWave at desk:** Continuous HR variability (HRV) monitoring during work.
|
||||
|
||||
- **HRV time-domain:** SDNN, RMSSD from beat-to-beat intervals
|
||||
- **HRV frequency-domain:** LF/HF ratio (sympathetic/parasympathetic balance)
|
||||
- Low HF power = stress; high HF = relaxation
|
||||
- CSI detects fidgeting, posture shifts (correlated with stress)
|
||||
- BH1750 correlates lighting with mood/productivity
|
||||
|
||||
**Application:** Smart office that adjusts lighting, temperature, and notification frequency based on detected stress level.
|
||||
|
||||
### 2.3 Gesture Recognition as Room Control
|
||||
|
||||
**CSI:** Already has DTW template matching gesture classifier (`ruvsense/gesture.rs`).
|
||||
**mmWave:** Adds range-Doppler micro-gesture detection (hand wave, swipe, circle).
|
||||
|
||||
- CSI recognizes gross gestures (wave arm, walk pattern)
|
||||
- mmWave recognizes fine hand gestures (swipe left/right, push/pull)
|
||||
- Fused: spatial context (CSI knows where you are) + precise gesture (mmWave knows what your hand did)
|
||||
|
||||
**Use case:** Wave at the sensor to turn off lights. Swipe to change music. No voice assistant, no camera, no wearable.
|
||||
|
||||
### 2.4 Respiratory Disease Screening
|
||||
|
||||
**mmWave BR patterns over days/weeks:**
|
||||
|
||||
| Pattern | Indicator |
|
||||
|---------|-----------|
|
||||
| BR > 20 at rest, trending up | Possible pneumonia/COVID |
|
||||
| Periodic breathing (Cheyne-Stokes) | Heart failure |
|
||||
| Obstructive apnea pattern | Sleep apnea (> 5 events/hour) |
|
||||
| BR variability decrease | COPD exacerbation |
|
||||
|
||||
**CSI adds:** Cough detection (sudden phase disturbance pattern), movement reduction (malaise indicator).
|
||||
|
||||
**Longitudinal tracking** via `ruvsense/longitudinal.rs` (Welford stats, biomechanics drift detection) — the system learns your normal breathing pattern and alerts on deviations.
|
||||
|
||||
### 2.5 Multi-Room Activity Recognition
|
||||
|
||||
**3-6 CSI nodes (through walls) + 1-2 mmWave (key rooms):**
|
||||
|
||||
```
|
||||
Kitchen (CSI): person detected, high motion → cooking
|
||||
Living room (mmWave + CSI): 2 people, low motion, HR stable → watching TV
|
||||
Bedroom (CSI): person detected, minimal motion → sleeping
|
||||
Bathroom (CSI): person entered 3 min ago, still inside → OK
|
||||
Front door (CSI): motion pattern = leaving/arriving
|
||||
```
|
||||
|
||||
**Output:** Activity timeline, daily routine deviation alerts, loneliness detection (no visitors in N days).
|
||||
|
||||
---
|
||||
|
||||
## Tier 3: Speculative (Research Frontier)
|
||||
|
||||
### 3.1 Cardiac Arrhythmia Detection
|
||||
|
||||
**mmWave at < 1m range:** Beat-to-beat interval extraction from chest wall displacement.
|
||||
|
||||
- Atrial fibrillation: irregular R-R intervals (coefficient of variation > 0.1)
|
||||
- Bradycardia/tachycardia: sustained HR < 60 or > 100
|
||||
- Premature ventricular contractions: occasional short-long-short patterns
|
||||
|
||||
**Challenge:** Requires sub-millimeter displacement resolution. The MR60BHA2 may lack the SNR for single-beat extraction, but clinical-grade 60 GHz modules (Infineon BGT60TR13C) can achieve this.
|
||||
|
||||
**CSI role:** Validates that the person is stationary (motion corrupts beat-to-beat analysis).
|
||||
|
||||
### 3.2 Blood Pressure Estimation (Contactless)
|
||||
|
||||
**Theory:** Pulse Transit Time (PTT) between two body points correlates with blood pressure. With two mmWave sensors at different body positions, PTT can be estimated from the phase difference of reflected chest/wrist signals.
|
||||
|
||||
**Feasibility:** Academic papers demonstrate ±10 mmHg accuracy in controlled settings. Far from clinical grade but useful for trending.
|
||||
|
||||
### 3.3 RF Tomography — 3D Occupancy Imaging
|
||||
|
||||
**Method:** Multiple CSI nodes form a tomographic array. Each TX-RX pair measures signal attenuation. Inverse problem (ISTA L1 solver, already in `ruvsense/tomography.rs`) reconstructs a 3D voxel grid of where absorbers (people) are.
|
||||
|
||||
**mmWave adds:** Range-gated targets as sparse priors for the tomographic reconstruction, dramatically reducing the ill-posedness of the inverse problem.
|
||||
|
||||
```
|
||||
CSI tomography (coarse 3D grid, 50cm resolution) ─┐
|
||||
├─► Sparse fusion
|
||||
mmWave targets (precise range, cm resolution) ─────┘ → 10cm 3D occupancy map
|
||||
```
|
||||
|
||||
### 3.4 Sign Language Recognition
|
||||
|
||||
**CSI phase patterns (body/arm movement) + mmWave Doppler (hand micro-movements):**
|
||||
|
||||
- CSI captures the gross arm trajectory of each sign
|
||||
- mmWave captures the finger configuration at the pause point
|
||||
- AETHER contrastive embeddings (`ADR-024`) learn to map (CSI phase sequence, mmWave Doppler) → sign label
|
||||
- No camera required — works in the dark, preserves privacy
|
||||
|
||||
**Training data:** Record CSI + mmWave while performing signs with a camera as ground truth, then deploy camera-free.
|
||||
|
||||
### 3.5 Cognitive Load Estimation
|
||||
|
||||
**Multimodal features:**
|
||||
|
||||
| Feature | Source | Cognitive Load Indicator |
|
||||
|---------|--------|------------------------|
|
||||
| HR increase | mmWave | Sympathetic activation |
|
||||
| BR irregularity | mmWave | Cognitive interference |
|
||||
| Posture stiffness | CSI motion variance | Reduced when concentrating |
|
||||
| Fidgeting frequency | CSI high-freq motion | Increases with frustration |
|
||||
| Micro-saccade proxy | mmWave head micro-movement | Correlated with attention |
|
||||
|
||||
**Application:** Adaptive learning systems that slow down when the student is overloaded. Smart meeting rooms that detect when participants are disengaged.
|
||||
|
||||
### 3.6 Drone/Robot Navigation via RF Sensing
|
||||
|
||||
**CSI mesh as indoor GPS:** A network of CSI nodes creates a spatial RF fingerprint map. A robot or drone with an ESP32 can localize itself by matching its observed CSI to the map.
|
||||
|
||||
**mmWave on the robot:** Obstacle avoidance + human detection (don't collide with people).
|
||||
|
||||
**CSI from the environment:** Tells the robot where people are in adjacent rooms (through walls) so it can plan routes that avoid occupied spaces.
|
||||
|
||||
### 3.7 Building Structural Health Monitoring
|
||||
|
||||
**CSI multipath signature over months/years:**
|
||||
|
||||
- The CSI channel response is a fingerprint of the room's geometry
|
||||
- Subtle shifts in multipath (wall crack propagation, foundation settlement) change the CSI signature
|
||||
- `ruvsense/cross_room.rs` (environment fingerprinting) tracks these long-term drifts
|
||||
- mmWave detects surface vibrations (micro-displacement from traffic, wind, seismic)
|
||||
|
||||
**Application:** Early warning for structural degradation in bridges, tunnels, old buildings.
|
||||
|
||||
### 3.8 Swarm Sensing — Emergent Spatial Awareness
|
||||
|
||||
**50+ nodes across a building:**
|
||||
|
||||
Each node runs local edge intelligence (ADR-039). The `hive-mind` consensus system (ADR-062) aggregates across nodes. Emergent behaviors:
|
||||
|
||||
- **Flow detection:** Track how people move between rooms over time
|
||||
- **Anomaly detection:** "This hallway usually has 5 people/hour but had 0 today"
|
||||
- **Emergency routing:** During fire, track which exits are blocked (no movement) vs available
|
||||
- **Crowd density:** Concert/stadium safety — detect dangerous compression zones through walls
|
||||
|
||||
---
|
||||
|
||||
## Tier 4: Exotic / Sci-Fi Adjacent
|
||||
|
||||
### 4.1 Emotion Contagion Mapping
|
||||
|
||||
If multiple people are in a room and the system can estimate individual HR/HRV (via multi-target mmWave + CSI subcarrier clustering), you can detect:
|
||||
|
||||
- Physiological synchrony (two people's HR converging = rapport/empathy)
|
||||
- Stress propagation (one person's stress → others' HR rises)
|
||||
- "Emotional temperature" of a room
|
||||
|
||||
### 4.2 Dream State Detection and Lucid Dream Induction
|
||||
|
||||
During REM sleep (detected via mmWave HR variability + CSI minimal body movement):
|
||||
|
||||
- Detect REM onset with high confidence
|
||||
- Trigger a subtle environmental cue (gentle light via smart bulb, barely audible tone)
|
||||
- The sleeper incorporates the cue into the dream, recognizing it as a dream trigger
|
||||
- BH1750 confirms room is dark (not a natural awakening)
|
||||
|
||||
Based on published lucid dreaming induction research (e.g., LaBerge's MILD technique with external cues).
|
||||
|
||||
### 4.3 Plant Growth Monitoring
|
||||
|
||||
WiFi signals pass through plant tissue differently based on water content.
|
||||
|
||||
- CSI amplitude through a greenhouse changes as plants absorb/release water
|
||||
- mmWave reflects off leaf surfaces — micro-displacement from growth
|
||||
- Long-term CSI drift correlates with biomass increase
|
||||
|
||||
Academic proof-of-concept: "Sensing Plant Water Content Using WiFi Signals" (2023).
|
||||
|
||||
### 4.4 Pet Behavior Analysis
|
||||
|
||||
- CSI detects pet movement patterns (different phase signature than humans — lower, faster)
|
||||
- mmWave detects breathing rate (pets have higher BR than humans)
|
||||
- System learns pet's daily routine and alerts on deviations (lethargy, pacing, not eating)
|
||||
|
||||
### 4.5 Paranormal Investigation Tool
|
||||
|
||||
(For the entertainment/hobbyist market)
|
||||
|
||||
- CSI detects "unexplained" signal disturbances in empty rooms
|
||||
- mmWave confirms no physical presence
|
||||
- System logs "anomalous RF events" with timestamps
|
||||
- Export as Ghost Hunting report
|
||||
|
||||
**Actual explanation:** Temperature changes, HVAC drafts, and EMI cause CSI fluctuations. But it would sell.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Priority Matrix
|
||||
|
||||
| Application | Sensors Needed | Effort | Value | Priority |
|
||||
|------------|---------------|--------|-------|----------|
|
||||
| Fall detection (zero false positive) | CSI + mmWave | 1 week | Critical (healthcare) | **P0** |
|
||||
| Sleep monitoring | mmWave + BH1750 | 2 weeks | High (wellness) | **P1** |
|
||||
| Occupancy HVAC/lighting | CSI + mmWave | 1 week | High (energy) | **P1** |
|
||||
| Baby breathing monitor | mmWave | 1 week | Critical (safety) | **P1** |
|
||||
| Bathroom safety | CSI + mmWave | 1 week | Critical (elderly) | **P1** |
|
||||
| Gait analysis | CSI + mmWave | 3 weeks | High (clinical) | **P2** |
|
||||
| Gesture control | CSI + mmWave | 4 weeks | Medium (UX) | **P2** |
|
||||
| Multi-room activity | CSI mesh + mmWave | 4 weeks | High (elder care) | **P2** |
|
||||
| Respiratory screening | mmWave longitudinal | 6 weeks | High (health) | **P2** |
|
||||
| Stress/emotion detection | mmWave HRV + CSI | 6 weeks | Medium (wellness) | **P3** |
|
||||
| RF tomography | CSI mesh + mmWave | 8 weeks | Medium (research) | **P3** |
|
||||
| Sign language | CSI + mmWave + ML | 12 weeks | Medium (accessibility) | **P3** |
|
||||
| Cardiac arrhythmia | High-res mmWave | 12 weeks | High (clinical) | **P3** |
|
||||
| Swarm sensing | 50+ nodes | 16 weeks | High (safety) | **P3** |
|
||||
|
||||
## Decision
|
||||
|
||||
Document these possibilities as the product roadmap for the RuView multimodal ambient intelligence platform. Prioritize P0-P1 items (fall detection, sleep, occupancy, baby monitor, bathroom safety) for immediate implementation using the existing hardware (ESP32-S3 + MR60BHA2 + BH1750).
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- Positions RuView as a platform, not just a WiFi sensing demo
|
||||
- Each application can ship as a WASM edge module (ADR-040), deployable to existing hardware
|
||||
- Healthcare applications have clear regulatory paths (fall detection is FDA Class I exempt)
|
||||
- Most P0-P1 applications require no additional hardware beyond what's already deployed
|
||||
|
||||
### Negative
|
||||
- Clinical applications (arrhythmia, blood pressure) require medical device validation
|
||||
- Privacy concerns scale with capability — need clear data retention policies
|
||||
- Some exotic applications may attract scrutiny (surveillance concerns)
|
||||
|
||||
### Risk Mitigation
|
||||
- All processing happens on-device (edge) — no cloud, no recordings by default
|
||||
- No cameras — signal-based sensing preserves visual privacy
|
||||
- Open source — users can audit exactly what is sensed and transmitted
|
||||
@@ -0,0 +1,234 @@
|
||||
# ADR-065: Hotel Guest Happiness Scoring -- WiFi CSI + Cognitum Seed Bridge
|
||||
|
||||
**Status:** Proposed
|
||||
**Date:** 2026-03-20
|
||||
**Deciders:** @ruvnet
|
||||
**Related:** ADR-040 (WASM edge modules), ADR-039 (edge intelligence), ADR-042 (CHCI), ADR-064 (multimodal ambient intelligence), ADR-060 (multi-node aggregation)
|
||||
|
||||
## Context
|
||||
|
||||
Hotels lack objective, privacy-preserving methods to measure guest satisfaction in real time. Current approaches (post-stay surveys, NPS scores) are delayed, biased toward extremes, and capture less than 10% of guests. Meanwhile, ambient RF sensing can infer behavioral cues that correlate with comfort and well-being -- without cameras, wearables, or any guest interaction.
|
||||
|
||||
### Hardware
|
||||
|
||||
Two ESP32-S3 variants are deployed:
|
||||
|
||||
| Device | Flash | PSRAM | MAC | Port | Notes |
|
||||
|--------|-------|-------|-----|------|-------|
|
||||
| ESP32-S3 (QFN56 rev 0.2) | 4 MB | 2 MB | 1C:DB:D4:83:D2:40 | COM5 | Budget node, uses `sdkconfig.defaults.4mb` + `partitions_4mb.csv` |
|
||||
| ESP32-S3 | 8 MB | 8 MB | -- | COM7 | Full-featured node, existing deployment |
|
||||
|
||||
Both run the Tier 2 DSP firmware with presence detection, vitals extraction, fall detection, and gait analysis.
|
||||
|
||||
### Cognitum Seed Device
|
||||
|
||||
A Cognitum Seed unit is deployed on the same network segment:
|
||||
|
||||
- **Address:** 169.254.42.1 (link-local)
|
||||
- **Hardware:** Raspberry Pi Zero 2 W
|
||||
- **Firmware:** 0.7.0
|
||||
- **Vector store:** 398 vectors, dim=8
|
||||
- **API endpoints:** 98 (REST, fully documented)
|
||||
- **Sensors:** PIR, reed switch (door), vibration, ADS1115 ADC (4-ch analog), BME280 (temp/humidity/pressure)
|
||||
- **Security:** Ed25519 custody chain with tamper-evident witness log
|
||||
|
||||
The Seed's 8-dimensional vector store and drift detection engine make it a natural aggregation point for behavioral feature vectors extracted from CSI data.
|
||||
|
||||
### Existing WASM Edge Modules
|
||||
|
||||
The following modules already run on-device and produce features relevant to happiness scoring:
|
||||
|
||||
| Module | Event IDs | Outputs |
|
||||
|--------|-----------|---------|
|
||||
| `exo_emotion_detect.rs` | 610-613 | Arousal level, stress index |
|
||||
| `med_gait_analysis.rs` | 130-134 | Cadence, stride length, regularity |
|
||||
| `ret_customer_flow.rs` | 410-413 | Entry/exit count, direction |
|
||||
| `ret_dwell_heatmap.rs` | 420-423 | Dwell time per zone |
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. New WASM Module: `exo_happiness_score.rs`
|
||||
|
||||
Create a new WASM edge module that fuses outputs from existing modules into an 8-dimensional happiness vector, matching the Seed's vector dimensionality (dim=8).
|
||||
|
||||
**Event ID registry (690-694):**
|
||||
|
||||
| Event ID | Name | Description |
|
||||
|----------|------|-------------|
|
||||
| 690 | `HAPPINESS_VECTOR` | Full 8-dim happiness vector emitted per scoring window |
|
||||
| 691 | `HAPPINESS_TREND` | Windowed trend (rising/falling/stable) over last N vectors |
|
||||
| 692 | `HAPPINESS_ALERT` | Score crossed a configured threshold (low satisfaction) |
|
||||
| 693 | `HAPPINESS_GROUP` | Aggregate score for multi-person zone |
|
||||
| 694 | `HAPPINESS_CALIBRATION` | Baseline recalibration event (new guest check-in) |
|
||||
|
||||
### 2. Happiness Vector Schema (8 Dimensions)
|
||||
|
||||
Each dimension is normalized to [0.0, 1.0] where 1.0 = maximal positive signal:
|
||||
|
||||
| Dim | Name | Source | Derivation |
|
||||
|-----|------|--------|------------|
|
||||
| 0 | `gait_speed` | `med_gait_analysis` (130) | Normalized walking velocity. Brisk = positive. |
|
||||
| 1 | `stride_regularity` | `med_gait_analysis` (131) | Low stride-to-stride variance = relaxed gait. |
|
||||
| 2 | `movement_fluidity` | CSI phase jerk (d3/dt3) | Low jerk = smooth, unhurried movement. |
|
||||
| 3 | `breathing_calm` | Vitals BR extraction | BR 12-18 at rest = calm. Deviation penalized. |
|
||||
| 4 | `posture_openness` | CSI subcarrier spread | Wide phase spread across subcarriers = open posture. |
|
||||
| 5 | `dwell_comfort` | `ret_dwell_heatmap` (420) | Moderate dwell in amenity zones = engagement. |
|
||||
| 6 | `direction_entropy` | `ret_customer_flow` (410) | Low entropy = purposeful movement. Wandering penalized. |
|
||||
| 7 | `group_energy` | Multi-target CSI clustering | Synchronized movement of 2+ people = social engagement. |
|
||||
|
||||
The composite scalar happiness score is the weighted L2 norm:
|
||||
|
||||
```
|
||||
score = sum(w[i] * v[i] for i in 0..7) / sum(w[i])
|
||||
```
|
||||
|
||||
Default weights are uniform (all 1.0), configurable via NVS or Seed API.
|
||||
|
||||
### 3. ESP32 to Seed Bridge
|
||||
|
||||
```
|
||||
ESP32-S3 (CSI) Cognitum Seed (169.254.42.1)
|
||||
+------------------+ +----------------------------+
|
||||
| Tier 2 DSP | | |
|
||||
| + WASM modules | UDP 5555 | /api/v1/store/ingest |
|
||||
| exo_happiness |──────────────| (POST, 8-dim vector) |
|
||||
| _score.rs | | |
|
||||
| | | /api/v1/drift/check |
|
||||
| |◄─────────────| (drift alerts via webhook) |
|
||||
| | | |
|
||||
| | | /api/v1/witness/append |
|
||||
| | | (Ed25519 audit trail) |
|
||||
+------------------+ +----------------------------+
|
||||
```
|
||||
|
||||
**Data flow:**
|
||||
|
||||
1. ESP32 runs CSI capture at 20+ Hz and feeds subcarrier data through existing WASM modules.
|
||||
2. `exo_happiness_score.rs` collects outputs from emotion, gait, flow, and dwell modules every scoring window (default: 30 seconds).
|
||||
3. The 8-dim happiness vector is packed as a 32-byte payload (8x float32) and sent via UDP to port 5555 on 169.254.42.1.
|
||||
4. A lightweight bridge task on the Seed receives the UDP packet and POSTs it to `/api/v1/store/ingest` with metadata (room ID, timestamp, MAC).
|
||||
5. The Seed's drift detection engine monitors the happiness vector stream and flags anomalies (sudden drops, sustained low scores).
|
||||
6. Every ingested vector is appended to the Seed's Ed25519 witness chain, providing a tamper-proof audit trail.
|
||||
|
||||
### 4. Seed Drift Detection for Happiness Trends
|
||||
|
||||
The Seed's built-in drift detection compares incoming vectors against a rolling baseline:
|
||||
|
||||
- **Check-in calibration:** When a new guest checks in, event 694 resets the baseline.
|
||||
- **Drift threshold:** Configurable (default: cosine distance > 0.3 from baseline triggers alert).
|
||||
- **Trend window:** Last 20 vectors (~10 minutes at 30s intervals).
|
||||
- **Alert routing:** Seed webhook notifies hotel management system when happiness trend is declining.
|
||||
|
||||
### 5. RuView Live Dashboard Update
|
||||
|
||||
`ruview_live.py` gains a `--seed` flag:
|
||||
|
||||
```bash
|
||||
python ruview_live.py --port COM5 --seed 169.254.42.1 --mode happiness
|
||||
```
|
||||
|
||||
This mode displays:
|
||||
- Real-time 8-dim radar chart of the happiness vector
|
||||
- Scalar happiness score (0-100) with color coding (red/yellow/green)
|
||||
- Trend sparkline over the last hour
|
||||
- Seed witness chain status (last hash, chain length)
|
||||
- Room-level aggregate when multiple ESP32 nodes report
|
||||
|
||||
### 6. Architecture
|
||||
|
||||
```
|
||||
+------------------------------------------+
|
||||
| Hotel Room |
|
||||
| |
|
||||
| [ESP32-S3] [Cognitum Seed] |
|
||||
| COM5 or COM7 169.254.42.1 |
|
||||
| 4MB or 8MB flash Pi Zero 2 W |
|
||||
| | | |
|
||||
| | WiFi CSI | PIR, reed, |
|
||||
| | 20+ Hz | BME280, |
|
||||
| v | vibration |
|
||||
| +-----------+ | |
|
||||
| | Tier 2 DSP| v |
|
||||
| | presence | +-------------+ |
|
||||
| | vitals | | Seed API | |
|
||||
| | gait | | 98 endpoints| |
|
||||
| | fall det | | 398 vectors | |
|
||||
| +-----------+ | dim=8 | |
|
||||
| | +-------------+ |
|
||||
| v ^ |
|
||||
| +-----------+ UDP 5555 | |
|
||||
| | WASM edge |─────────────┘ |
|
||||
| | happiness | |
|
||||
| | score | Drift alerts |
|
||||
| | (690-694) |◄────────────── |
|
||||
| +-----------+ /api/v1/drift/check |
|
||||
| |
|
||||
+------------------------------------------+
|
||||
|
|
||||
| MQTT / HTTP
|
||||
v
|
||||
+------------------+
|
||||
| Hotel Management |
|
||||
| System / RuView |
|
||||
| Live Dashboard |
|
||||
+------------------+
|
||||
```
|
||||
|
||||
### 7. 4MB Flash Support
|
||||
|
||||
The 4MB ESP32-S3 variant (COM5) is officially supported for happiness scoring. The existing `partitions_4mb.csv` and `sdkconfig.defaults.4mb` from ADR-265 provide dual OTA slots (1.856 MB each), sufficient for the full Tier 2 DSP firmware plus `exo_happiness_score.wasm` (estimated < 40 KB).
|
||||
|
||||
Build for 4MB variant:
|
||||
|
||||
```bash
|
||||
cp sdkconfig.defaults.4mb sdkconfig.defaults
|
||||
idf.py build
|
||||
```
|
||||
|
||||
The WASM module loader selects which modules to instantiate based on available heap. On the 4MB/2MB PSRAM variant, happiness scoring runs with a reduced scoring window (60s instead of 30s) to conserve memory.
|
||||
|
||||
### 8. Privacy Considerations
|
||||
|
||||
- **No cameras.** All sensing is RF-based (WiFi subcarrier amplitude/phase).
|
||||
- **No facial recognition.** Happiness is inferred from movement patterns, not expressions.
|
||||
- **No audio capture.** Breathing rate is extracted from chest wall displacement via RF, not microphone.
|
||||
- **No PII stored on device.** Vectors are anonymous; room-to-guest mapping lives only in the hotel PMS.
|
||||
- **Seed witness chain** provides auditable proof of what data was collected and when, satisfying GDPR Article 30 record-keeping requirements.
|
||||
- **Guest opt-out:** A physical switch on the ESP32 node (GPIO connected to a toggle) disables CSI capture entirely. The Seed's reed switch can also serve as a "privacy mode" trigger (door-mounted magnet removed = sensing paused).
|
||||
- **Data retention:** Vectors are retained on the Seed for the duration of the stay plus 24 hours, then purged. The witness chain retains hashes (not vectors) indefinitely for audit.
|
||||
|
||||
### 9. API Integration
|
||||
|
||||
Key Cognitum Seed endpoints used:
|
||||
|
||||
| Endpoint | Method | Purpose |
|
||||
|----------|--------|---------|
|
||||
| `/api/v1/store/ingest` | POST | Ingest 8-dim happiness vector |
|
||||
| `/api/v1/store/query` | POST | Retrieve vectors by room/time range |
|
||||
| `/api/v1/drift/check` | GET | Check if current vector drifts from baseline |
|
||||
| `/api/v1/drift/configure` | PUT | Set drift threshold and window size |
|
||||
| `/api/v1/witness/append` | POST | Append event to Ed25519 custody chain |
|
||||
| `/api/v1/witness/verify` | GET | Verify chain integrity |
|
||||
| `/api/v1/sensors/bme280` | GET | Room temperature/humidity (comfort correlation) |
|
||||
| `/api/v1/sensors/pir` | GET | PIR presence (cross-validate with CSI) |
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Provides real-time, objective guest satisfaction measurement without surveys or wearables.
|
||||
- Reuses four existing WASM modules -- the happiness module is a fusion layer, not a rewrite.
|
||||
- The Seed's 8-dim vector store is a natural fit; no schema changes needed.
|
||||
- Ed25519 witness chain satisfies hospitality industry audit requirements and GDPR record-keeping.
|
||||
- Both 4MB and 8MB ESP32-S3 variants are supported, enabling low-cost deployment at scale (~$8 per room for the 4MB node).
|
||||
- Seed's environmental sensors (BME280, PIR) provide complementary context (room temperature, humidity) that can be correlated with happiness scores.
|
||||
- No cloud dependency -- all processing is local (ESP32 edge + Seed link-local network).
|
||||
|
||||
### Negative
|
||||
|
||||
- Happiness inference from movement patterns is a proxy, not a direct measurement. Correlation with actual guest satisfaction must be validated empirically.
|
||||
- The 4MB variant has reduced scoring frequency (60s vs 30s) due to memory constraints.
|
||||
- UDP transport between ESP32 and Seed is unreliable; packets may be lost. Mitigation: sequence numbers and a small retry buffer on the ESP32 side.
|
||||
- Link-local addressing (169.254.x.x) limits the Seed to the same network segment as the ESP32. Multi-room deployments need one Seed per subnet or a routed bridge.
|
||||
- Drift detection thresholds require per-property tuning; a luxury resort has different movement patterns than a budget hotel.
|
||||
- The system cannot distinguish between guests in a multi-occupancy room without additional multi-target CSI clustering, which is experimental (ADR-064, Tier 3).
|
||||
@@ -0,0 +1,278 @@
|
||||
# ADR-066: ESP32 CSI Swarm with Cognitum Seed Coordinator
|
||||
|
||||
**Status:** Proposed
|
||||
**Date:** 2026-03-20
|
||||
**Deciders:** @ruvnet
|
||||
**Related:** ADR-065 (happiness scoring + Seed bridge), ADR-039 (edge intelligence), ADR-060 (provisioning), ADR-018 (CSI binary protocol), ADR-040 (WASM runtime)
|
||||
|
||||
## Context
|
||||
|
||||
ADR-065 established a single ESP32-S3 node pushing happiness vectors to a Cognitum Seed at `169.254.42.1` (Pi Zero 2 W, firmware 0.7.0). The Seed is now on the same WiFi network (`RedCloverWifi`, `10.1.10.236`) as the ESP32 node (`10.1.10.168`).
|
||||
|
||||
The Seed already exposes REST APIs for:
|
||||
- Peer discovery (`/api/v1/peers`) — 0 peers currently registered
|
||||
- Delta sync (`/api/v1/delta/pull`, `/api/v1/delta/push`) — epoch-based replication
|
||||
- Reflex rules (`/api/v1/sensor/reflex/rules`) — 3 rules (fragility alarm, drift cutoff, HD anomaly indicator)
|
||||
- Actuators (`/api/v1/sensor/actuators`) — relay + PWM outputs
|
||||
- Cognitive engine (`/api/v1/cognitive/tick`) — periodic inference loop
|
||||
- Witness chain (`/api/v1/custody/epoch`) — epoch 316, cryptographically signed
|
||||
- kNN search (`/api/v1/store/search`) — similarity queries across the full vector store
|
||||
|
||||
A hotel deployment requires multiple ESP32 nodes (lobby, hallway, restaurant, rooms) coordinated as a swarm with centralized analytics on the Seed.
|
||||
|
||||
## Decision
|
||||
|
||||
Implement a Seed-coordinated ESP32 swarm where each node operates autonomously for CSI sensing and edge processing, while the Seed serves as the swarm coordinator for registration, aggregation, drift detection, cross-zone inference, and actuator control.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
ESP32 Node A ESP32 Node B ESP32 Node C
|
||||
(Lobby) (Hallway) (Restaurant)
|
||||
node_id=1 node_id=2 node_id=3
|
||||
10.1.10.168 10.1.10.xxx 10.1.10.xxx
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ WiFi CSI │ │ WiFi CSI │ │ WiFi CSI │
|
||||
│ Tier 2 DSP │ │ Tier 2 DSP │ │ Tier 2 DSP │
|
||||
│ WASM Tier 3 │ │ WASM Tier 3 │ │ WASM Tier 3 │
|
||||
│ Swarm Bridge │ │ Swarm Bridge │ │ Swarm Bridge │
|
||||
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
|
||||
│ HTTP POST │ HTTP POST │ HTTP POST
|
||||
│ (happiness vectors, │ │
|
||||
│ heartbeat, events) │ │
|
||||
└──────────┬───────────────┴──────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────┐
|
||||
│ Cognitum Seed │
|
||||
│ (Coordinator) │
|
||||
│ 10.1.10.236 │
|
||||
├───────────────┤
|
||||
│ Vector Store │ ← 8-dim vectors tagged with node_id + zone
|
||||
│ kNN Search │ ← Cross-zone similarity ("which room matches?")
|
||||
│ Drift Detect │ ← Global mood trend across all zones
|
||||
│ Witness Chain │ ← Tamper-proof audit trail per node
|
||||
│ Reflex Rules │ ← Trigger actuators on swarm-wide patterns
|
||||
│ Cognitive Eng │ ← Periodic cross-zone inference
|
||||
│ Peer Registry │ ← Node health, last-seen, capabilities
|
||||
└───────────────┘
|
||||
```
|
||||
|
||||
### Swarm Protocol
|
||||
|
||||
#### 1. Node Registration (on boot)
|
||||
|
||||
Each ESP32 registers with the Seed via HTTP POST on startup. The Seed's peer discovery API tracks active nodes.
|
||||
|
||||
```
|
||||
POST /api/v1/store/ingest
|
||||
{
|
||||
"vectors": [{
|
||||
"id": "node-1-reg",
|
||||
"values": [0,0,0,0,0,0,0,0],
|
||||
"metadata": {
|
||||
"type": "registration",
|
||||
"node_id": 1,
|
||||
"zone": "lobby",
|
||||
"mac": "1C:DB:D4:83:D2:40",
|
||||
"ip": "10.1.10.168",
|
||||
"firmware": "0.5.0",
|
||||
"capabilities": ["csi", "tier2", "presence", "vitals", "happiness"],
|
||||
"flash_mb": 4,
|
||||
"psram_mb": 2
|
||||
}
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Heartbeat (every 30 seconds)
|
||||
|
||||
```
|
||||
POST /api/v1/store/ingest
|
||||
{
|
||||
"vectors": [{
|
||||
"id": "node-1-hb-{epoch}",
|
||||
"values": [happiness, gait, stride, fluidity, calm, posture, dwell, social],
|
||||
"metadata": {
|
||||
"type": "heartbeat",
|
||||
"node_id": 1,
|
||||
"zone": "lobby",
|
||||
"uptime_s": 3600,
|
||||
"csi_frames": 72000,
|
||||
"free_heap": 317140,
|
||||
"presence_now": true,
|
||||
"persons": 2,
|
||||
"rssi": -60
|
||||
}
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Happiness Vector Ingestion (every 5 seconds when presence detected)
|
||||
|
||||
```
|
||||
POST /api/v1/store/ingest
|
||||
{
|
||||
"vectors": [{
|
||||
"id": "node-1-h-{epoch}-{ts}",
|
||||
"values": [0.72, 0.65, 0.80, 0.71, 0.55, 0.60, 0.85, 0.45],
|
||||
"metadata": {
|
||||
"type": "happiness",
|
||||
"node_id": 1,
|
||||
"zone": "lobby",
|
||||
"timestamp_ms": 1742486400000,
|
||||
"persons": 2,
|
||||
"direction": "entering"
|
||||
}
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. Cross-Zone Queries (Seed-side)
|
||||
|
||||
The Seed can answer questions across the entire swarm:
|
||||
|
||||
```
|
||||
POST /api/v1/store/search
|
||||
{"vector": [0.8, 0.7, 0.9, 0.8, 0.6, 0.7, 0.9, 0.5], "k": 5}
|
||||
|
||||
Response: nearest neighbors across all zones, showing which
|
||||
rooms had the most similar mood to a "happy" reference vector.
|
||||
```
|
||||
|
||||
#### 5. Reflex Rules for Swarm Patterns
|
||||
|
||||
Configure the Seed's reflex engine to act on swarm-wide patterns:
|
||||
|
||||
| Rule | Trigger | Action | Use Case |
|
||||
|------|---------|--------|----------|
|
||||
| `low_happiness_alert` | Mean happiness < 0.3 across 3+ nodes for 5 min | Activate `alarm` relay | Staff alert: guest dissatisfaction |
|
||||
| `crowd_surge` | Presence count > 10 across lobby + hallway | PWM indicator brightness 100% | Lobby congestion warning |
|
||||
| `zone_drift` | Drift score > 0.5 on any node | Log to witness chain | Trend change documentation |
|
||||
| `ghost_anomaly` | Event 650 (anomaly) from any node | Notify + log | Security: unexpected RF disturbance |
|
||||
|
||||
### ESP32 Firmware: Swarm Bridge Module
|
||||
|
||||
New module `swarm_bridge.c` added to the CSI firmware, activated via NVS config:
|
||||
|
||||
```c
|
||||
typedef struct {
|
||||
char seed_url[64]; // e.g. "http://10.1.10.236"
|
||||
char zone_name[16]; // e.g. "lobby"
|
||||
uint16_t heartbeat_sec; // Default: 30
|
||||
uint16_t ingest_sec; // Default: 5
|
||||
uint8_t enabled; // 0 = disabled, 1 = enabled
|
||||
} swarm_config_t;
|
||||
```
|
||||
|
||||
NVS keys (provisioned via `provision.py --seed-url http://10.1.10.236 --zone lobby`):
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `seed_url` | string | (empty) | Seed base URL; empty = swarm disabled |
|
||||
| `zone_name` | string | `"default"` | Zone identifier for this node |
|
||||
| `swarm_hb` | u16 | 30 | Heartbeat interval (seconds) |
|
||||
| `swarm_ingest` | u16 | 5 | Vector ingest interval (seconds) |
|
||||
|
||||
The swarm bridge runs as a FreeRTOS task on Core 0 (separate from DSP on Core 1):
|
||||
|
||||
```
|
||||
swarm_bridge_task (Core 0, priority 3, stack 4096)
|
||||
├── On boot: POST registration to Seed
|
||||
├── Every 30s: POST heartbeat with latest happiness vector
|
||||
├── Every 5s (if presence): POST happiness vector
|
||||
└── On event 650+ (anomaly): POST immediately
|
||||
```
|
||||
|
||||
HTTP client uses `esp_http_client` (already in ESP-IDF, no extra dependencies). JSON is formatted with `snprintf` (no cJSON dependency needed for the small payloads).
|
||||
|
||||
### Node Discovery and Addressing
|
||||
|
||||
Nodes find the Seed via:
|
||||
|
||||
1. **NVS provisioned URL** (primary) — `provision.py --seed-url http://10.1.10.236`
|
||||
2. **mDNS fallback** — Seed advertises `_cognitum._tcp.local`; ESP32 resolves `cognitum.local`
|
||||
3. **Link-local fallback** — `http://169.254.42.1` when connected via USB
|
||||
|
||||
### Vector ID Scheme
|
||||
|
||||
```
|
||||
{node_id}-{type}-{epoch}-{timestamp_ms}
|
||||
```
|
||||
|
||||
Examples:
|
||||
- `1-reg` — Node 1 registration
|
||||
- `1-hb-316` — Node 1 heartbeat at epoch 316
|
||||
- `1-h-316-1742486400000` — Node 1 happiness vector at epoch 316, timestamp T
|
||||
- `2-h-316-1742486401000` — Node 2 happiness vector at same epoch
|
||||
|
||||
### Witness Chain Integration
|
||||
|
||||
Every vector ingested into the Seed increments the epoch and extends the witness chain. The chain provides:
|
||||
|
||||
- **Per-node audit trail** — filter by node_id metadata to get one node's history
|
||||
- **Tamper detection** — Ed25519 signed, hash-chained; break = detectable
|
||||
- **Regulatory compliance** — prove "sensor X reported Y at time Z" for disputes
|
||||
- **Cross-node ordering** — Seed epoch gives total order across all nodes
|
||||
|
||||
### Scaling Considerations
|
||||
|
||||
| Nodes | Vectors/hour | Seed storage/day | kNN latency |
|
||||
|-------|---|---|---|
|
||||
| 1 | 720 | ~1.5 MB | < 1 ms |
|
||||
| 5 | 3,600 | ~7.5 MB | < 2 ms |
|
||||
| 10 | 7,200 | ~15 MB | < 5 ms |
|
||||
| 20 | 14,400 | ~30 MB | < 10 ms |
|
||||
|
||||
The Seed's Pi Zero 2 W has 512 MB RAM and typically an 8-32 GB SD card. At 30 MB/day for 20 nodes, storage lasts 250+ days before compaction is needed. The Seed's optimizer runs automatic compaction in the background.
|
||||
|
||||
### Provisioning for Swarm
|
||||
|
||||
```bash
|
||||
# Node 1: Lobby (COM5, existing)
|
||||
python provision.py --port COM5 \
|
||||
--ssid "RedCloverWifi" --password "redclover2.4" \
|
||||
--node-id 1 --seed-url "http://10.1.10.236" --zone "lobby"
|
||||
|
||||
# Node 2: Hallway (future device)
|
||||
python provision.py --port COM6 \
|
||||
--ssid "RedCloverWifi" --password "redclover2.4" \
|
||||
--node-id 2 --seed-url "http://10.1.10.236" --zone "hallway"
|
||||
|
||||
# Node 3: Restaurant (future device)
|
||||
python provision.py --port COM8 \
|
||||
--ssid "RedCloverWifi" --password "redclover2.4" \
|
||||
--node-id 3 --seed-url "http://10.1.10.236" --zone "restaurant"
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Zero infrastructure** — no cloud, no server, no database. Seed + ESP32s + WiFi router is the entire stack
|
||||
- **Autonomous nodes** — each ESP32 runs full Tier 2 DSP independently; Seed loss degrades gracefully to local-only operation
|
||||
- **Cryptographic audit** — witness chain gives tamper-proof history for every observation across all nodes
|
||||
- **Real-time cross-zone analytics** — Seed kNN search answers "which zones are happy/stressed right now" in < 5 ms
|
||||
- **Physical actuators** — Seed's relay/PWM outputs can trigger real-world actions (lights, alarms, displays) based on swarm-wide patterns
|
||||
- **Horizontal scaling** — add ESP32 nodes by flashing firmware + running provision.py; no Seed reconfiguration needed
|
||||
- **Privacy-preserving** — no cameras, no audio, no PII; only 8-dimensional feature vectors stored
|
||||
|
||||
### Negative
|
||||
|
||||
- **Single point of aggregation** — Seed failure loses cross-zone analytics (nodes continue autonomously)
|
||||
- **WiFi dependency** — nodes must be on the same network as the Seed; no mesh/LoRa fallback yet
|
||||
- **HTTP overhead** — REST/JSON adds ~200 bytes overhead per vector vs raw binary UDP; acceptable at 5-second intervals
|
||||
- **Pi Zero 2 W limits** — 512 MB RAM, single-core ARM; adequate for 20 nodes but not 100+
|
||||
- **No WASM OTA via Seed** — currently WASM modules are uploaded per-node; future work could use Seed as WASM distribution hub
|
||||
|
||||
### Implementation Progress
|
||||
|
||||
**ADR-069** implements the first stage of this swarm vision with live hardware validation (2026-04-02). A single ESP32-S3 node (COM9, firmware v0.5.2) was validated sending CSI-derived feature vectors through a host-side bridge into the Cognitum Seed's RVF store (firmware v0.8.1). The pipeline confirmed: UDP streaming (211 packets/15s), 8-dim feature extraction, batched HTTPS ingest (4 batches of 5 vectors), and witness chain integrity (193 entries, SHA-256 verified). Multi-node deployment (Phase 4 of ADR-069) is the next step toward the full swarm architecture described here.
|
||||
|
||||
### Future Work
|
||||
|
||||
- **Seed-initiated WASM push** — Seed distributes WASM modules to all nodes via their OTA endpoints
|
||||
- **mDNS auto-discovery** — nodes find Seed without provisioned URL
|
||||
- **Mesh fallback** — ESP-NOW peer-to-peer when WiFi is down
|
||||
- **Multi-Seed federation** — multiple Seeds for multi-floor/multi-building deployments
|
||||
- **Seed dashboard** — web UI on the Seed showing live swarm map with per-zone happiness
|
||||
@@ -0,0 +1,151 @@
|
||||
# ADR-067: RuVector v2.0.4 to v2.0.5 Upgrade + New Crate Adoption
|
||||
|
||||
**Status:** Proposed
|
||||
**Date:** 2026-03-23
|
||||
**Deciders:** @ruvnet
|
||||
**Related:** ADR-016 (RuVector training pipeline integration), ADR-017 (RuVector signal + MAT integration), ADR-029 (RuvSense multistatic sensing)
|
||||
|
||||
## Context
|
||||
|
||||
RuView currently pins all five core RuVector crates at **v2.0.4** (from crates.io) plus a vendored `ruvector-crv` v0.1.1 and optional `ruvector-gnn` v2.0.5. The upstream RuVector workspace has moved to **v2.0.5** with meaningful improvements to the crates we depend on, and has introduced new crates that could benefit RuView's detection pipeline.
|
||||
|
||||
### Current Integration Map
|
||||
|
||||
| RuView Module | RuVector Crate | Current Version | Purpose |
|
||||
|---------------|----------------|-----------------|---------|
|
||||
| `signal/subcarrier.rs` | ruvector-mincut | 2.0.4 | Graph min-cut subcarrier partitioning |
|
||||
| `signal/spectrogram.rs` | ruvector-attn-mincut | 2.0.4 | Attention-gated spectrogram denoising |
|
||||
| `signal/bvp.rs` | ruvector-attention | 2.0.4 | Attention-weighted BVP aggregation |
|
||||
| `signal/fresnel.rs` | ruvector-solver | 2.0.4 | Fresnel geometry estimation |
|
||||
| `mat/triangulation.rs` | ruvector-solver | 2.0.4 | TDoA survivor localization |
|
||||
| `mat/breathing.rs` | ruvector-temporal-tensor | 2.0.4 | Tiered compressed breathing buffer |
|
||||
| `mat/heartbeat.rs` | ruvector-temporal-tensor | 2.0.4 | Tiered compressed heartbeat spectrogram |
|
||||
| `viewpoint/*` (4 files) | ruvector-attention | 2.0.4 | Cross-viewpoint fusion with geometric bias |
|
||||
| `crv/` (optional) | ruvector-crv | 0.1.1 (vendored) | CRV protocol integration |
|
||||
| `crv/` (optional) | ruvector-gnn | 2.0.5 | GNN graph topology |
|
||||
|
||||
### What Changed Upstream (v2.0.4 → v2.0.5 → HEAD)
|
||||
|
||||
**ruvector-mincut:**
|
||||
- Flat capacity matrix + allocation reuse — **10-30% faster** for all min-cut operations
|
||||
- Tier 2-3 Dynamic MinCut (ADR-124): Gomory-Hu tree construction for fast global min-cut, incremental edge insert/delete without full recomputation
|
||||
- Source-anchored canonical min-cut with SHA-256 witness hashing
|
||||
- Fixed: unsafe indexing removed, WASM Node.js panic from `std::time`
|
||||
|
||||
**ruvector-attention / ruvector-attn-mincut:**
|
||||
- Migrated to workspace versioning (no API changes)
|
||||
- Documentation improvements
|
||||
|
||||
**ruvector-temporal-tensor:**
|
||||
- Formatting fixes only (no API changes)
|
||||
|
||||
**ruvector-gnn:**
|
||||
- Panic replaced with `Result` in `MultiHeadAttention` and `RuvectorLayer` constructors (breaking improvement — safer)
|
||||
- Bumped to v2.0.5
|
||||
|
||||
**sona (new — Self-Optimizing Neural Architecture):**
|
||||
- v0.1.6 → v0.1.8: state persistence (`loadState`/`saveState`), trajectory counter fix
|
||||
- Micro-LoRA and Base-LoRA for instant and background learning
|
||||
- EWC++ (Elastic Weight Consolidation) to prevent catastrophic forgetting
|
||||
- ReasoningBank pattern extraction and similarity search
|
||||
- WASM support for edge devices
|
||||
|
||||
**ruvector-coherence (new):**
|
||||
- Spectral coherence scoring for graph index health
|
||||
- Fiedler eigenvalue estimation, effective resistance sampling
|
||||
- HNSW health monitoring with alerts
|
||||
- Batch evaluation of attention mechanism quality
|
||||
|
||||
**ruvector-core (new):**
|
||||
- ONNX embedding support for real semantic embeddings
|
||||
- HNSW index with SIMD-accelerated distance metrics
|
||||
- Quantization (4-32x memory reduction)
|
||||
- Arena allocator for cache-optimized operations
|
||||
|
||||
## Decision
|
||||
|
||||
### Phase 1: Version Bump (Low Risk)
|
||||
|
||||
Bump the 5 core crates from v2.0.4 to v2.0.5 in the workspace `Cargo.toml`:
|
||||
|
||||
```toml
|
||||
ruvector-mincut = "2.0.5" # was 2.0.4 — 10-30% faster, safer
|
||||
ruvector-attn-mincut = "2.0.5" # was 2.0.4 — workspace versioning
|
||||
ruvector-temporal-tensor = "2.0.5" # was 2.0.4 — fmt only
|
||||
ruvector-solver = "2.0.5" # was 2.0.4 — workspace versioning
|
||||
ruvector-attention = "2.0.5" # was 2.0.4 — workspace versioning
|
||||
```
|
||||
|
||||
**Expected impact:** The mincut performance improvement directly benefits `signal/subcarrier.rs` which runs subcarrier graph partitioning every tick. 10-30% faster partitioning reduces per-frame CPU cost.
|
||||
|
||||
### Phase 2: Add ruvector-coherence (Medium Value)
|
||||
|
||||
Add `ruvector-coherence` with `spectral` feature to `wifi-densepose-ruvector`:
|
||||
|
||||
**Use case:** Replace or augment the custom phase coherence logic in `viewpoint/coherence.rs` with spectral graph coherence scoring. The current implementation uses phasor magnitude for phase coherence — spectral Fiedler estimation would provide a more robust measure of multi-node CSI consistency, especially for detecting when a node's signal quality degrades.
|
||||
|
||||
**Integration point:** `viewpoint/coherence.rs` — add `SpectralCoherenceScore` as a secondary coherence metric alongside existing phase phasor coherence. Use spectral gap estimation to detect structural changes in the multi-node CSI graph (e.g., a node dropping out or a new reflector appearing).
|
||||
|
||||
### Phase 3: Add SONA for Adaptive Learning (High Value)
|
||||
|
||||
Replace the logistic regression adaptive classifier in the sensing server with a SONA-backed learning engine:
|
||||
|
||||
**Current state:** The sensing server's adaptive training (`POST /api/v1/adaptive/train`) uses a hand-rolled logistic regression on 15 CSI features. It requires explicit labeled recordings and provides no cross-session persistence.
|
||||
|
||||
**Proposed improvement:** Use `sona::SonaEngine` to:
|
||||
1. **Learn from implicit feedback** — trajectory tracking on person-count decisions (was the count stable? did the user correct it?)
|
||||
2. **Persist across sessions** — `saveState()`/`loadState()` replaces the current `adaptive_model.json`
|
||||
3. **Pattern matching** — `find_patterns()` enables "this CSI signature looks like room X where we learned Y"
|
||||
4. **Prevent forgetting** — EWC++ ensures learning in a new room doesn't overwrite patterns from previous rooms
|
||||
|
||||
**Integration point:** New `adaptive_sona.rs` module in `wifi-densepose-sensing-server`, behind a `sona` feature flag. The existing logistic regression remains the default.
|
||||
|
||||
### Phase 4: Evaluate ruvector-core for CSI Embeddings (Exploratory)
|
||||
|
||||
**Current state:** The person detection pipeline uses hand-crafted features (variance, change_points, motion_band_power, spectral_power) with fixed normalization ranges.
|
||||
|
||||
**Potential:** Use `ruvector-core`'s ONNX embedding support to generate learned CSI embeddings that capture room geometry, person count, and activity patterns in a single vector. This would enable:
|
||||
- Similarity search: "is this CSI frame similar to known 2-person patterns?"
|
||||
- Transfer learning: embeddings learned in one room partially transfer to similar rooms
|
||||
- Quantized storage: 4-32x memory reduction for pattern databases
|
||||
|
||||
**Status:** Exploratory — requires training data collection and embedding model design. Not a near-term target.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- **Phase 1:** Free 10-30% performance gain in subcarrier partitioning. Security fixes (unsafe indexing, WASM panic). Zero API changes required.
|
||||
- **Phase 2:** More robust multi-node coherence detection. Helps with the "flickering persons" issue (#292) by providing a second opinion on signal quality.
|
||||
- **Phase 3:** Fundamentally improves the adaptive learning pipeline. Users no longer need to manually record labeled data — the system learns from ongoing use.
|
||||
- **Phase 4:** Path toward real ML-based detection instead of heuristic thresholds.
|
||||
|
||||
### Negative
|
||||
- **Phase 1:** Minimal risk — semver minor bump, no API breaks.
|
||||
- **Phase 2:** Adds a dependency. Spectral computation has O(n) cost per tick for Fiedler estimation (n = number of subcarriers, typically 56-128). Acceptable.
|
||||
- **Phase 3:** SONA adds ~200KB to the binary. The learning loop needs careful tuning to avoid adapting to noise.
|
||||
- **Phase 4:** Requires significant research and training data. Not guaranteed to outperform tuned heuristics for WiFi CSI.
|
||||
|
||||
### Risks
|
||||
- `ruvector-gnn` v2.0.5 changed constructors from panic to `Result` — any existing `crv` feature users need to handle the `Result`. Our vendored `ruvector-crv` may need updates.
|
||||
- SONA's WASM support is experimental — keep it behind a feature flag until validated.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
| Phase | Scope | Effort | Priority |
|
||||
|-------|-------|--------|----------|
|
||||
| 1 | Bump 5 crates to v2.0.5 | 1 hour | High — free perf + security |
|
||||
| 2 | Add ruvector-coherence | 1 day | Medium — improves multi-node stability |
|
||||
| 3 | SONA adaptive learning | 3 days | Medium — replaces manual training workflow |
|
||||
| 4 | CSI embeddings via ruvector-core | 1-2 weeks | Low — exploratory research |
|
||||
|
||||
## Vendor Submodule
|
||||
|
||||
The `vendor/ruvector` git submodule has been updated from commit `f8f2c60` (v2.0.4 era) to `51a3557` (latest `origin/main`). This provides local reference for the full upstream source when developing Phases 2-4.
|
||||
|
||||
## References
|
||||
|
||||
- Upstream repo: https://github.com/ruvnet/ruvector
|
||||
- ADR-124 (Dynamic MinCut): `vendor/ruvector/docs/adr/ADR-124*.md`
|
||||
- SONA docs: `vendor/ruvector/crates/sona/src/lib.rs`
|
||||
- ruvector-coherence spectral: `vendor/ruvector/crates/ruvector-coherence/src/spectral.rs`
|
||||
- ruvector-core embeddings: `vendor/ruvector/crates/ruvector-core/src/embeddings.rs`
|
||||
@@ -0,0 +1,186 @@
|
||||
# ADR-068: Per-Node State Pipeline for Multi-Node Sensing
|
||||
|
||||
| Field | Value |
|
||||
|------------|-------------------------------------|
|
||||
| Status | Accepted |
|
||||
| Date | 2026-03-27 |
|
||||
| Authors | rUv, claude-flow |
|
||||
| Drivers | #249, #237, #276, #282 |
|
||||
| Supersedes | — |
|
||||
|
||||
## Context
|
||||
|
||||
The sensing server (`wifi-densepose-sensing-server`) was originally designed for
|
||||
single-node operation. When multiple ESP32 nodes send CSI frames simultaneously,
|
||||
all data is mixed into a single shared pipeline:
|
||||
|
||||
- **One** `frame_history` VecDeque for all nodes
|
||||
- **One** `smoothed_person_score` / `smoothed_motion` / vital sign buffers
|
||||
- **One** baseline and debounce state
|
||||
|
||||
This means the classification, person count, and vital signs reported to the UI
|
||||
are an uncontrolled aggregate of all nodes' data. The result: the detection
|
||||
window shows identical output regardless of how many nodes are deployed, where
|
||||
people stand, or how many people are in the room (#249 — 24 comments, the most
|
||||
reported issue).
|
||||
|
||||
### Root Cause Verified
|
||||
|
||||
Investigation of `AppStateInner` (main.rs lines 279-367) confirmed:
|
||||
|
||||
| Shared field | Impact |
|
||||
|---------------------------|--------------------------------------------|
|
||||
| `frame_history` | Temporal analysis mixes all nodes' CSI data |
|
||||
| `smoothed_person_score` | Person count aggregates all nodes |
|
||||
| `smoothed_motion` | Motion classification undifferentiated |
|
||||
| `smoothed_hr` / `br` | Vital signs are global, not per-node |
|
||||
| `baseline_motion` | Adaptive baseline learned from mixed data |
|
||||
| `debounce_counter` | All nodes share debounce state |
|
||||
|
||||
## Decision
|
||||
|
||||
Introduce **per-node state tracking** via a `HashMap<u8, NodeState>` in
|
||||
`AppStateInner`. Each ESP32 node (identified by its `node_id` byte) gets an
|
||||
independent sensing pipeline with its own temporal history, smoothing buffers,
|
||||
baseline, and classification state.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
UDP frames │ AppStateInner │
|
||||
───────────► │ │
|
||||
node_id=1 ──► │ node_states: HashMap<u8, NodeState> │
|
||||
node_id=2 ──► │ ├── 1: NodeState { frame_history, │
|
||||
node_id=3 ──► │ │ smoothed_motion, vitals, ... }│
|
||||
│ ├── 2: NodeState { ... } │
|
||||
│ └── 3: NodeState { ... } │
|
||||
│ │
|
||||
│ ┌── Per-Node Pipeline ──┐ │
|
||||
│ │ extract_features() │ │
|
||||
│ │ smooth_and_classify() │ │
|
||||
│ │ smooth_vitals() │ │
|
||||
│ │ score_to_person_count()│ │
|
||||
│ └────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌── Multi-Node Fusion ──┐ │
|
||||
│ │ Aggregate person count │ │
|
||||
│ │ Per-node classification│ │
|
||||
│ │ All-nodes WebSocket msg│ │
|
||||
│ └────────────────────────┘ │
|
||||
│ │
|
||||
│ ──► WebSocket broadcast (sensing_update) │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### NodeState Struct
|
||||
|
||||
```rust
|
||||
struct NodeState {
|
||||
frame_history: VecDeque<Vec<f64>>,
|
||||
smoothed_person_score: f64,
|
||||
prev_person_count: usize,
|
||||
smoothed_motion: f64,
|
||||
current_motion_level: String,
|
||||
debounce_counter: u32,
|
||||
debounce_candidate: String,
|
||||
baseline_motion: f64,
|
||||
baseline_frames: u64,
|
||||
smoothed_hr: f64,
|
||||
smoothed_br: f64,
|
||||
smoothed_hr_conf: f64,
|
||||
smoothed_br_conf: f64,
|
||||
hr_buffer: VecDeque<f64>,
|
||||
br_buffer: VecDeque<f64>,
|
||||
rssi_history: VecDeque<f64>,
|
||||
vital_detector: VitalSignDetector,
|
||||
latest_vitals: VitalSigns,
|
||||
last_frame_time: Option<std::time::Instant>,
|
||||
edge_vitals: Option<Esp32VitalsPacket>,
|
||||
}
|
||||
```
|
||||
|
||||
### Multi-Node Aggregation
|
||||
|
||||
- **Person count**: Sum of per-node `prev_person_count` for active nodes
|
||||
(seen within last 10 seconds).
|
||||
- **Classification**: Per-node classification included in `SensingUpdate.nodes`.
|
||||
- **Vital signs**: Per-node vital signs; UI can render per-node or aggregate.
|
||||
- **Signal field**: Generated from the most-recently-updated node's features.
|
||||
- **Stale nodes**: Nodes with no frame for >10 seconds are excluded from
|
||||
aggregation and marked offline (consistent with PR #300).
|
||||
|
||||
### Backward Compatibility
|
||||
|
||||
- The simulated data path (`simulated_data_task`) continues using global state.
|
||||
- Single-node deployments behave identically (HashMap has one entry).
|
||||
- The WebSocket message format (`sensing_update`) remains the same but the
|
||||
`nodes` array now contains all active nodes, and `estimated_persons` reflects
|
||||
the cross-node aggregate.
|
||||
- The edge vitals path (#323 fix) also uses per-node state.
|
||||
|
||||
## Scaling Characteristics
|
||||
|
||||
| Nodes | Per-Node Memory | Total Overhead | Notes |
|
||||
|-------|----------------|----------------|-------|
|
||||
| 1 | ~50 KB | ~50 KB | Identical to current |
|
||||
| 3 | ~50 KB | ~150 KB | Typical home setup |
|
||||
| 10 | ~50 KB | ~500 KB | Small office |
|
||||
| 50 | ~50 KB | ~2.5 MB | Building floor |
|
||||
| 100 | ~50 KB | ~5 MB | Large deployment |
|
||||
| 256 | ~50 KB | ~12.8 MB | Max (u8 node_id) |
|
||||
|
||||
Memory is dominated by `frame_history` (100 frames x ~500 bytes each = ~50 KB
|
||||
per node). This scales linearly and fits comfortably in server memory even at
|
||||
256 nodes.
|
||||
|
||||
## QEMU Validation
|
||||
|
||||
The existing QEMU swarm infrastructure (ADR-062, `scripts/qemu_swarm.py`)
|
||||
supports multi-node simulation with configurable topologies:
|
||||
|
||||
- `star`: Central coordinator + sensor nodes
|
||||
- `mesh`: Fully connected peer network
|
||||
- `line`: Sequential chain
|
||||
- `ring`: Circular topology
|
||||
|
||||
Each QEMU instance runs with a unique `node_id` via NVS provisioning. The
|
||||
swarm health validator (`scripts/swarm_health.py`) checks per-node UART output.
|
||||
|
||||
Validation plan:
|
||||
1. QEMU swarm with 3-5 nodes in mesh topology
|
||||
2. Verify server produces distinct per-node classifications
|
||||
3. Verify aggregate person count reflects multi-node contributions
|
||||
4. Verify stale-node eviction after timeout
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- Each node's CSI data is processed independently — no cross-contamination
|
||||
- Person count scales with the number of deployed nodes
|
||||
- Vital signs are per-node, enabling room-level health monitoring
|
||||
- Foundation for spatial localization (per-node positions + triangulation)
|
||||
- Scales to 256 nodes with <13 MB memory overhead
|
||||
|
||||
### Negative
|
||||
- Slightly more memory per node (~50 KB each)
|
||||
- `smooth_and_classify_node` function duplicates some logic from global version
|
||||
- Per-node `VitalSignDetector` instances add CPU cost proportional to node count
|
||||
|
||||
### Risks
|
||||
- Node ID collisions (mitigated by NVS persistence since v0.5.0)
|
||||
- HashMap growth without cleanup (mitigated by stale-node eviction)
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- **ADR-069** (ESP32 CSI → Cognitum Seed RVF Ingest Pipeline) extends this ADR's per-node state architecture with Cognitum Seed integration. Live hardware validation (2026-04-02) confirmed per-node feature vectors flowing through the bridge into the Seed's RVF store with witness chain attestation.
|
||||
|
||||
## References
|
||||
|
||||
- Issue #249: Detection window same regardless (24 comments)
|
||||
- Issue #237: Same display for 0/1/2 people (12 comments)
|
||||
- Issue #276: Only one can be detected (8 comments)
|
||||
- Issue #282: Detection fail (5 comments)
|
||||
- PR #295: Hysteresis smoothing (partial mitigation)
|
||||
- PR #300: ESP32 offline detection after 5s
|
||||
- ADR-062: QEMU Swarm Configurator
|
||||
@@ -0,0 +1,403 @@
|
||||
# ADR-069: ESP32 CSI → Cognitum Seed RVF Ingest Pipeline
|
||||
|
||||
| Field | Value |
|
||||
|------------|----------------------------------------------------------|
|
||||
| Status | Accepted |
|
||||
| Date | 2026-04-02 |
|
||||
| Authors | rUv, claude-flow |
|
||||
| Drivers | #348 (multinode mesh accuracy), Research: Arena Physica |
|
||||
| Supersedes | — |
|
||||
| Related | ADR-066 (ESP32 swarm + Seed coordinator), ADR-068 (per-node state), ADR-018 (CSI binary protocol), ADR-039 (edge intelligence), ADR-065 (happiness scoring + Seed bridge) |
|
||||
|
||||
## Context
|
||||
|
||||
The wifi-densepose project has two hardware components that need to work as an integrated sensing pipeline:
|
||||
|
||||
1. **ESP32-S3** (COM9 / 192.168.1.105) — Captures WiFi CSI at 100 Hz, runs dual-core DSP pipeline (phase extraction, subcarrier selection, breathing/heart rate estimation, presence/fall detection), and sends ADR-018 binary frames via UDP.
|
||||
|
||||
2. **Cognitum Seed** (USB / 169.254.42.1 / 192.168.1.109) — A Pi Zero 2 W edge intelligence appliance running firmware v0.8.1. It provides:
|
||||
- **RVF vector store** — Append-only binary format with content-addressed IDs, kNN queries (cosine/L2/dot), and kNN graph with boundary analysis
|
||||
- **Witness chain** — SHA-256 tamper-evident audit trail for every write operation
|
||||
- **Ed25519 custody** — Device-bound keypair for cryptographic attestation
|
||||
- **Sensor pipeline** — 5 sensors (reed switch, PIR, vibration, ADS1115 4-ch ADC, BME280), 13 drift detectors, anti-spoofing
|
||||
- **Cognitive container** — Spectral graph analysis with Stoer-Wagner min-cut fragility scoring
|
||||
- **MCP proxy** — 114 tools via JSON-RPC 2.0 for AI assistant integration
|
||||
- **Thermal governor** — DVFS management with zone-based frequency scaling
|
||||
- **Temporal coherence** — Phase boundary detection across vector store evolution
|
||||
- **Swarm sync** — Epoch-based delta replication between peers
|
||||
- **Reflex rules** — 3 rules (fragility alarm, drift cutoff, HD anomaly indicator)
|
||||
- **98 HTTPS API endpoints** with per-client bearer token authentication
|
||||
|
||||
### Current State
|
||||
|
||||
| Component | Status | Details |
|
||||
|-----------|--------|---------|
|
||||
| ESP32 CSI capture | Working | 100 Hz, ADR-018 binary frames via UDP |
|
||||
| ESP32 edge DSP | Working | 10-stage pipeline on Core 1 (phase, variance, vitals, fall) |
|
||||
| ESP32 → sensing-server | Working | UDP port 5005, binary protocol |
|
||||
| Cognitum Seed | Online | v0.8.1, paired, 19 vectors, epoch 25, WiFi connected |
|
||||
| Seed vector store | Working | 8-dim RVF, kNN queries in 85ms for 20k vectors |
|
||||
| Seed MCP proxy | Working | 114 tools, default-deny policy |
|
||||
| ESP32 → Seed pipeline | **Validated** | Bridge on host laptop, UDP 5006 → HTTPS ingest (see Validation Results) |
|
||||
|
||||
### Gap Analysis (from Arena Physica research)
|
||||
|
||||
Arena Physica's approach (Heaviside-0 forward model, Marconi-0 inverse diffusion) demonstrates that neural surrogates for Maxwell's equations are production-viable. Our research identified that:
|
||||
|
||||
1. **Physics-informed intermediate supervision** — Evaluating pipeline stages independently catches failures that end-to-end metrics miss
|
||||
2. **Vector embeddings for EM fields** — Storing CSI features as vectors enables similarity search for environment fingerprinting and anomaly detection
|
||||
3. **Witness chain for sensing integrity** — Tamper-evident audit trails are critical for healthcare/safety applications (fall detection, vital signs)
|
||||
4. **Edge compute for inference** — Pi Zero 2 W can run ~2.5M parameter models at 10+ Hz with INT8 quantization
|
||||
|
||||
### Problem
|
||||
|
||||
There is no pipeline connecting ESP32 CSI sensing to the Cognitum Seed's vector store. The ESP32 sends raw CSI frames to the Rust sensing-server (typically running on a laptop/desktop), but cannot leverage the Seed's:
|
||||
- Persistent vector storage with kNN search
|
||||
- Cryptographic witness chain for data integrity
|
||||
- Cognitive container for structural analysis
|
||||
- Sensor fusion with environmental sensors (BME280 temperature/humidity, PIR motion)
|
||||
- Swarm sync for multi-Seed deployments
|
||||
|
||||
## Decision
|
||||
|
||||
Build a three-stage pipeline connecting ESP32 CSI capture to Cognitum Seed RVF storage:
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────┐
|
||||
│ ESP32-S3 (COM9) │
|
||||
│ node_id=1 │
|
||||
│ 192.168.1.105 │
|
||||
│ Firmware v0.5.2 │
|
||||
│ ┌──────────────────────┐ │
|
||||
│ │ Core 0: WiFi + CSI │ │
|
||||
│ │ 100 Hz capture │ │
|
||||
│ │ ADR-018 framing │ │
|
||||
│ ├──────────────────────┤ │
|
||||
│ │ Core 1: Edge DSP │ │
|
||||
│ │ Phase extraction │ │
|
||||
│ │ Subcarrier select │ │
|
||||
│ │ Vital signs (HR/BR)│ │
|
||||
│ │ Presence/fall det. │ │
|
||||
│ │ Feature vector │ │◄── 8-dim feature extraction
|
||||
│ └──────────┬───────────┘ │
|
||||
│ │ UDP │
|
||||
└────────────┼─────────────┘
|
||||
│ Port 5005 (raw CSI, magic 0xC5110001)
|
||||
│ + Port 5006 (vitals 0xC5110002 + features 0xC5110003)
|
||||
▼
|
||||
┌────────────────────────────────────────────┐
|
||||
│ Host Laptop (192.168.1.20) │
|
||||
│ Bridge script (Python) │
|
||||
│ ┌────────────────────────────────────────┐ │
|
||||
│ │ Stage 1: CSI Receiver │ │
|
||||
│ │ UDP listener on port 5006 │ │
|
||||
│ │ Parses 0xC5110003 feature packets │ │
|
||||
│ │ (also accepts 0xC5110001/0002) │ │
|
||||
│ │ Batches 10 vectors per ingest │ │
|
||||
│ └──────────┬─────────────────────────────┘ │
|
||||
└────────────┼───────────────────────────────┘
|
||||
│ HTTPS POST (bearer token)
|
||||
▼
|
||||
┌────────────────────────────────────────────┐
|
||||
│ Cognitum Seed (Pi Zero 2 W) │
|
||||
│ 169.254.42.1 / 192.168.1.109 │
|
||||
│ Firmware v0.8.1 │
|
||||
│ ┌────────────────────────────────────────┐ │
|
||||
│ │ Stage 2: RVF Ingest │ │
|
||||
│ │ POST /api/v1/store/ingest │ │
|
||||
│ │ Content-addressed vector ID │ │
|
||||
│ │ Metadata: node_id, timestamp, type │ │
|
||||
│ │ Witness chain entry per batch │ │
|
||||
│ ├────────────────────────────────────────┤ │
|
||||
│ │ Stage 3: Cognitive Analysis │ │
|
||||
│ │ kNN graph rebuild (every 10s) │ │
|
||||
│ │ Boundary analysis (fragility) │ │
|
||||
│ │ Temporal coherence (phase detect) │ │
|
||||
│ │ Reflex rules (alarm triggers) │ │
|
||||
│ ├────────────────────────────────────────┤ │
|
||||
│ │ Existing Sensors │ │
|
||||
│ │ BME280 → temp/humidity/pressure │ │
|
||||
│ │ PIR → motion ground truth │ │
|
||||
│ │ Reed switch → door/window state │ │
|
||||
│ │ ADS1115 → analog inputs │ │
|
||||
│ └────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Outputs: │
|
||||
│ • /api/v1/store/query — kNN search │
|
||||
│ • /api/v1/boundary — fragility score │
|
||||
│ • /api/v1/coherence/profile — phases │
|
||||
│ • /api/v1/cognitive/snapshot — graph │
|
||||
│ • /api/v1/custody/attestation — signed │
|
||||
│ • MCP proxy — 114 tools for AI agents │
|
||||
└────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Stage 1: ESP32 Feature Vector Extraction
|
||||
|
||||
The ESP32 edge processing pipeline (Core 1) already computes all signals needed. We add a compact 8-dimensional feature vector extracted from the existing DSP outputs:
|
||||
|
||||
| Dimension | Feature | Source | Range |
|
||||
|-----------|---------|--------|-------|
|
||||
| 0 | Presence score | `s_presence_score / 10.0` (clamped) | 0.0–1.0 |
|
||||
| 1 | Motion energy | `s_motion_energy / 10.0` (clamped) | 0.0–1.0 |
|
||||
| 2 | Breathing rate | `s_breathing_bpm / 30.0` (clamped) | 0.0–1.0 |
|
||||
| 3 | Heart rate | `s_heartrate_bpm / 120.0` (clamped) | 0.0–1.0 |
|
||||
| 4 | Phase variance (mean) | Top-K subcarrier Welford variance mean | 0.0–1.0 |
|
||||
| 5 | Person count | `n_active_persons / 4.0` (clamped) | 0.0–1.0 |
|
||||
| 6 | Fall detected | Binary: 1.0 if `s_fall_detected`, else 0.0 | 0.0 or 1.0 |
|
||||
| 7 | RSSI (normalized) | `(s_latest_rssi + 100) / 100` (clamped) | 0.0–1.0 |
|
||||
|
||||
This maps directly to the Seed's store dimension of 8, enabling kNN queries like "find the 10 most similar sensing states to the current one."
|
||||
|
||||
**Packet format** (magic `0xC5110003`, defined as `edge_feature_pkt_t` in `edge_processing.h`):
|
||||
|
||||
```c
|
||||
typedef struct __attribute__((packed)) {
|
||||
uint32_t magic; // EDGE_FEATURE_MAGIC = 0xC5110003
|
||||
uint8_t node_id; // ESP32 node identifier
|
||||
uint8_t reserved; // alignment padding
|
||||
uint16_t seq; // sequence number
|
||||
int64_t timestamp_us; // microseconds since boot
|
||||
float features[8]; // 8-dim normalized feature vector (32 bytes)
|
||||
} edge_feature_pkt_t; // Total: 48 bytes (static_assert enforced)
|
||||
```
|
||||
|
||||
**Transmission rate:** 1 Hz (one feature vector per second, aggregated from 100 Hz CSI). This keeps UDP bandwidth under 50 bytes/s per node and avoids overwhelming the Seed's vector store.
|
||||
|
||||
### Stage 2: Seed-Side RVF Ingest
|
||||
|
||||
A lightweight Rust service on the Seed (or a Python bridge script) listens for feature packets on UDP port 5006 and ingests them via the Seed's REST API:
|
||||
|
||||
```bash
|
||||
# Ingest a feature vector with metadata
|
||||
curl -sk -X POST https://169.254.42.1:8443/api/v1/store/ingest \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"vectors": [[0, [0.85, 0.3, 0.52, 0.65, 0.4, 0.78, 0.1, -0.45]]],
|
||||
"metadata": {
|
||||
"node_id": 1,
|
||||
"type": "csi_feature",
|
||||
"timestamp": 1775166970
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
**Batching:** Accumulate 10 vectors (10 seconds) per ingest call to reduce HTTP overhead (`--batch-size 10` default in `seed_csi_bridge.py`; also supports time-based flushing via `--flush-interval`). At 1 vector/second per node, a 4-node mesh generates 14,400 vectors/hour (345,600/day). Daily compaction is required to stay within the Seed's 100K vector working set (see Storage Budget).
|
||||
|
||||
**Witness chain:** Each ingest automatically appends a witness entry, providing a tamper-evident record of all sensing data. The epoch increments monotonically, and the SHA-256 chain can be verified at any time via `POST /api/v1/witness/verify`.
|
||||
|
||||
### Stage 3: Cognitive Analysis & Sensor Fusion
|
||||
|
||||
Once CSI feature vectors are in the RVF store, the Seed's existing subsystems activate:
|
||||
|
||||
1. **kNN Graph** — Rebuilt every 10 seconds. Similar sensing states cluster together. Anomalous states (intruder, fall, unusual breathing) appear as outliers.
|
||||
|
||||
2. **Boundary Analysis** — Stoer-Wagner min-cut computes a fragility score (0.0–1.0). High fragility indicates the vector space is splitting — a regime change in the environment (door opened, person entered/left, HVAC state change).
|
||||
|
||||
3. **Temporal Coherence** — Phase boundary detection across the vector store timeline identifies when the environment transitions between states (occupied → empty, day → night, normal → abnormal).
|
||||
|
||||
4. **Reflex Rules** — Three pre-configured rules fire automatically:
|
||||
- `fragility_alarm` (threshold 0.3) → relay actuator for presence alert
|
||||
- `drift_cutoff` (threshold 1.0) → cutoff when sensor drift detected
|
||||
- `hd_anomaly_indicator` (threshold 200) → PWM brightness for anomaly severity
|
||||
|
||||
5. **Sensor Fusion** — The Seed's BME280 (temperature/humidity/pressure) and PIR sensor provide environmental ground truth that correlates with CSI features:
|
||||
- PIR motion validates CSI presence detection
|
||||
- Temperature changes correlate with occupancy
|
||||
- Humidity changes correlate with breathing detection fidelity
|
||||
|
||||
6. **MCP Integration** — AI assistants can query the full pipeline via the 114-tool MCP proxy:
|
||||
```json
|
||||
{"method": "tools/call", "params": {"name": "seed.memory.query", "arguments": {"vector": [0.8, 0.5, 0.4, 0.6, 0.3, 0.7, 0.1, -0.3], "k": 5}}}
|
||||
```
|
||||
|
||||
### ESP32 Provisioning
|
||||
|
||||
The ESP32's existing NVS provisioning system supports configuring the Seed as the target:
|
||||
|
||||
```bash
|
||||
python firmware/esp32-csi-node/provision.py \
|
||||
--port COM9 \
|
||||
--target-ip 192.168.1.20 \
|
||||
--target-port 5006 \
|
||||
--node-id 1
|
||||
```
|
||||
|
||||
Note: `--target-ip` is the host laptop (192.168.1.20), not the Seed IP, because the bridge runs on the host and forwards to the Seed via HTTPS (see Known Issue 4).
|
||||
|
||||
No firmware recompilation needed — the `stream_sender` module reads target IP/port from NVS at boot.
|
||||
|
||||
### Data Flow Rates
|
||||
|
||||
| Path | Rate | Size | Bandwidth |
|
||||
|------|------|------|-----------|
|
||||
| CSI capture → ring buffer | 100 Hz | ~400 B | 40 KB/s (internal) |
|
||||
| Edge DSP → sensing-server | 100 Hz | ~200 B | 20 KB/s (existing) |
|
||||
| Edge DSP → Seed features | 1 Hz | 48 B | 48 B/s (new) |
|
||||
| Seed ingest (batched) | 0.1 Hz | ~500 B | 50 B/s (HTTP) |
|
||||
| Seed kNN graph rebuild | 0.1 Hz | internal | — |
|
||||
| Seed witness chain | per batch | 32 B hash | — |
|
||||
|
||||
### Storage Budget
|
||||
|
||||
| Timeframe | Vectors/node | 4 nodes | RVF size | RAM |
|
||||
|-----------|-------------|---------|----------|-----|
|
||||
| 1 hour | 3,600 | 14,400 | ~580 KB | ~6 MB |
|
||||
| 24 hours | 86,400 | 345,600 | ~14 MB | ~140 MB |
|
||||
| 7 days | 604,800 | 2,419,200 | ~97 MB | exceeds |
|
||||
|
||||
**Compaction policy:** Run `POST /api/v1/store/compact` daily at 03:00, retaining only the last 24 hours of vectors. Archive older vectors to USB drive via `POST /api/v1/store/export` before compaction.
|
||||
|
||||
**Dimension reduction:** For deployments exceeding 100K vectors, reduce feature extraction rate to 0.1 Hz (one vector per 10 seconds) or increase compaction frequency.
|
||||
|
||||
## Validation Results
|
||||
|
||||
**Live hardware test performed 2026-04-02.**
|
||||
|
||||
### Hardware Under Test
|
||||
|
||||
| Component | Port | IP | Firmware | WiFi | RSSI |
|
||||
|-----------|------|----|----------|------|------|
|
||||
| ESP32-S3 (8MB) | COM9 | 192.168.1.105 | v0.5.2 | ruv.net (ch 5) | -34 dBm |
|
||||
| Cognitum Seed | USB | 169.254.42.1 / 192.168.1.109 | v0.8.1 | ruv.net | — |
|
||||
| Host laptop | — | 192.168.1.20 | — | ruv.net | — |
|
||||
|
||||
Seed device_id: `ecaf97dd-fc90-4b0e-b0e7-e9f896b9fbb6`. Pairing token issued to `wifi-densepose-claude`.
|
||||
|
||||
### Pipeline Validated
|
||||
|
||||
1. **UDP streaming** -- 211 packets captured in 15 seconds:
|
||||
- 196 raw CSI frames (magic `0xC5110001`)
|
||||
- 15 vitals frames (magic `0xC5110002`)
|
||||
|
||||
2. **Bridge pipeline** -- 20 vitals packets (`0xC5110002`) parsed, converted to 8-dim feature vectors via the bridge's `parse_vitals_packet()` fallback path, ingested in 4 batches of 5 vectors each (`--batch-size 5`). The native `0xC5110003` feature packet path is implemented in firmware but was not exercised in this validation run (firmware was v0.5.2; the `send_feature_vector()` addition requires a reflash).
|
||||
|
||||
3. **RVF ingest** -- All 20 vectors accepted by Seed. Epochs advanced 88 to 91. Witness chain verified valid (193 entries, SHA-256 chain intact).
|
||||
|
||||
4. **Seed sensors** -- BME280, PIR, reed switch, ADS1115, vibration sensor all present and healthy.
|
||||
|
||||
### Live Vital Signs Captured
|
||||
|
||||
| Metric | Observed Range | Expected | Notes |
|
||||
|--------|---------------|----------|-------|
|
||||
| Presence score | 1.41 -- 14.92 | 0.0 -- 1.0 | **Needs normalization** (see Known Issues) |
|
||||
| Motion energy | 1.41 -- 14.92 | 0.0 -- 1.0 | Same raw value as presence score |
|
||||
| Breathing rate | 19.8 -- 33.5 BPM | 12 -- 25 BPM | Plausible but slightly high |
|
||||
| Heart rate | 75.3 -- 99.1 BPM | 60 -- 100 BPM | Plausible range |
|
||||
| RSSI | -43 to -72 dBm | -30 to -80 dBm | Normal |
|
||||
| Fall detected | No | — | Correct (no falls occurred) |
|
||||
| n_persons | 4 | 1 | **Miscalibrated** (see Known Issues) |
|
||||
|
||||
### Known Issues Found
|
||||
|
||||
1. **`presence_score` exceeds 1.0 in vitals packets** -- Raw values range 1.41 to 14.92 in the vitals packet (`0xC5110002`). The bridge's vitals-to-feature conversion clamps to 1.0 for dim 0 and divides by 10.0 for dim 1 (`motion_energy / 10.0`), but dim 0 clamps without scaling. **Note:** The firmware's native feature vector (`0xC5110003`) already normalizes correctly by dividing `s_presence_score` by 10.0 (see `edge_processing.c` line 657). This issue only affects the vitals-packet fallback path in the bridge.
|
||||
|
||||
2. **`n_persons = 4` with 1 person present** -- The multi-person counting algorithm is miscalibrated for single-occupancy scenarios. The per-node state pipeline (ADR-068) may mitigate this when the baseline is properly trained, but the raw edge count is unreliable.
|
||||
|
||||
3. **Content-addressed vector IDs cause deduplication** -- Similar feature vectors hash to the same ID, causing the Seed to silently drop duplicates. **Fixed in bridge:** `seed_csi_bridge.py` now uses `_make_vector_id()` which generates a SHA-256 hash of `node_id:timestamp_us:seq_counter`, producing unique 32-bit IDs. This was observed during validation and fixed before the final test run.
|
||||
|
||||
4. **Bridge runs on host, not Seed** -- The ESP32 target IP must be the host laptop (192.168.1.20), not the Seed IP. The bridge script on the host forwards to the Seed via HTTPS. This adds a hop but avoids running a UDP listener on the Pi Zero 2 W.
|
||||
|
||||
5. **PIR GPIO read returned 404** -- `GET /api/v1/sensor/gpio/read?pin=6` returned 404. The PIR endpoint may require a different pin number or endpoint format. Ground-truth validation against PIR is deferred to Phase 3.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: ESP32 Feature Extraction (firmware change) -- DONE
|
||||
|
||||
Implemented as `send_feature_vector()` in `edge_processing.c` (lines 644-699) and `edge_feature_pkt_t` in `edge_processing.h` (lines 112-124). The function reads from static globals (`s_presence_score`, `s_motion_energy`, `s_breathing_bpm`, `s_heartrate_bpm`, subcarrier Welford variance, person tracker, fall flag, RSSI) and normalizes each dimension to 0.0-1.0 with clamping.
|
||||
|
||||
Called at the same 1 Hz cadence as `send_vitals_packet()` in Step 13 of the edge processing pipeline (line 855). The compressed frame magic was reassigned from `0xC5110003` to `0xC5110005` to free up `0xC5110003` for feature vectors (`EDGE_COMPRESSED_MAGIC` in `edge_processing.h` line 29).
|
||||
|
||||
### Phase 2: Seed Ingest Bridge (Python script on host) -- DONE
|
||||
|
||||
Implemented as `scripts/seed_csi_bridge.py`. The bridge:
|
||||
1. Listens on UDP port 5006 (configurable via `--udp-port`)
|
||||
2. Accepts all three packet formats: `0xC5110003` (ADR-069 features), `0xC5110002` (vitals, converted to 8-dim), and `0xC5110001` (raw CSI, minimal features)
|
||||
3. Generates unique vector IDs via SHA-256 hash of `node_id:timestamp:seq` (avoids content-addressed deduplication -- see Known Issue 3)
|
||||
4. Batches vectors (default 10, configurable via `--batch-size`) with time-based flush fallback (`--flush-interval`)
|
||||
5. POSTs to Seed's `/api/v1/store/ingest` with bearer token
|
||||
6. Supports `--validate` mode (kNN query + PIR comparison after each batch)
|
||||
7. Supports `--stats` mode (print Seed status, boundary, coherence, graph)
|
||||
8. Supports `--compact` mode (trigger store compaction)
|
||||
|
||||
### Phase 3: Validation & Ground Truth -- BLOCKED
|
||||
|
||||
Use the Seed's PIR sensor as ground truth for presence detection:
|
||||
1. Query PIR state: `GET /api/v1/sensor/gpio/read?pin=6`
|
||||
2. Compare with CSI presence score (feature dim 0)
|
||||
3. Log agreement/disagreement rate
|
||||
4. Use kNN to find historical vectors matching current PIR state → validate CSI accuracy
|
||||
|
||||
**Status:** The bridge implements `--validate` mode with PIR comparison (see `_run_validation()` in `seed_csi_bridge.py`). However, the PIR endpoint returned 404 during validation (Known Issue 5). This phase is blocked until the correct PIR API endpoint is identified.
|
||||
|
||||
### Phase 4: Multi-Node Mesh (addresses #348)
|
||||
|
||||
Deploy 3 ESP32 nodes, each sending feature vectors to the bridge host (which forwards to the Seed):
|
||||
- Node 1 (lobby): `--node-id 1 --target-ip 192.168.1.20 --target-port 5006`
|
||||
- Node 2 (hallway): `--node-id 2 --target-ip 192.168.1.20 --target-port 5006`
|
||||
- Node 3 (room): `--node-id 3 --target-ip 192.168.1.20 --target-port 5006`
|
||||
|
||||
All nodes target the host laptop (192.168.1.20) where the bridge script runs. The bridge batches and forwards all nodes' vectors to the Seed via HTTPS. The Seed's kNN graph naturally clusters vectors by node and by sensing state. Cross-node analysis via boundary fragility detects when a person moves between zones.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Bearer token** — All write operations require the pairing token. Token stored as SHA-256 hash on device.
|
||||
2. **TLS** — All API calls over HTTPS (port 8443) with device-provisioned CA certificate.
|
||||
3. **Witness chain** — Every ingest is cryptographically chained. Tampering detection via `POST /api/v1/witness/verify`.
|
||||
4. **Ed25519 attestation** — Device identity bound to hardware keypair. Attestation includes epoch, vector count, and witness head.
|
||||
5. **Anti-spoofing** — Sensor pipeline has entropy-based spoofing detection (min 0.5 bits entropy, streak threshold 3).
|
||||
6. **USB-only pairing** — Pairing window can only be opened from USB interface (169.254.42.1), not from WiFi.
|
||||
|
||||
## Hardware Bill of Materials
|
||||
|
||||
| Component | Port | IP | Cost |
|
||||
|-----------|------|----|------|
|
||||
| ESP32-S3 (8MB) | COM9 | 192.168.1.105 (DHCP) | ~$9 |
|
||||
| Cognitum Seed (Pi Zero 2W) | USB | 169.254.42.1 / 192.168.1.109 | ~$15 |
|
||||
| USB-C cable (data) | — | — | ~$3 |
|
||||
| **Total** | | | **~$27** |
|
||||
|
||||
### Seed Sensors (included)
|
||||
|
||||
| Sensor | Interface | Channels | Purpose |
|
||||
|--------|-----------|----------|---------|
|
||||
| Reed switch | GPIO 5 | 1 | Door/window state |
|
||||
| PIR motion | GPIO 6 | 1 | Motion ground truth |
|
||||
| Vibration | GPIO 13 | 1 | Structural vibration |
|
||||
| ADS1115 | I2C 0x48 | 4 | Analog inputs (extensible) |
|
||||
| BME280 | I2C 0x76 | 3 | Temperature, humidity, pressure |
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|------|-----------|--------|------------|
|
||||
| Pi Zero thermal throttling at sustained ingest | Medium | Performance degrades | Thermal governor already manages DVFS; 1 Hz ingest is minimal load |
|
||||
| WiFi congestion with ESP32 CSI + UDP | Low | Lost packets | Feature vectors are 48 bytes at 1 Hz; negligible vs CSI traffic |
|
||||
| RVF store exceeds RAM at high vector count | Medium | OOM | Compaction policy + dimension reduction + daily export |
|
||||
| Bearer token exposure | Low | Unauthorized writes | TLS encryption + USB-only pairing + token hashing |
|
||||
| ESP32 NVS corruption | Low | Config lost | NVS is wear-leveled flash with CRC; re-provision via USB |
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- ESP32 CSI features become persistent, searchable, and cryptographically attested
|
||||
- kNN similarity search enables environment fingerprinting and anomaly detection
|
||||
- PIR + BME280 provide ground truth for CSI validation
|
||||
- MCP proxy enables AI assistants to query sensing state directly
|
||||
- Witness chain provides audit trail for healthcare/safety applications
|
||||
- Architecture aligns with Arena Physica's insight: store embeddings, not raw signals
|
||||
|
||||
### Negative
|
||||
- Additional firmware packet type (48 bytes, trivial)
|
||||
- Bridge script needed on Seed or host machine
|
||||
- Daily compaction required for long-running deployments
|
||||
- Bearer token must be managed (stored securely, rotated if compromised)
|
||||
|
||||
### Neutral
|
||||
- Existing sensing-server pipeline unchanged (ESP32 still sends to port 5005)
|
||||
- Seed's existing sensors continue operating independently
|
||||
- Target IP/port configurable via NVS provisioning (no recompilation for deployment changes)
|
||||
- Firmware recompilation needed once to add `send_feature_vector()` (Phase 1), but subsequent node deployments only need provisioning
|
||||
@@ -31,7 +31,7 @@ All firmware paths are relative to the repository root. Rust crate paths are rel
|
||||
| **Core 0 / Core 1** | The two Xtensa LX7 cores on ESP32-S3; Core 0 runs WiFi + CSI callback, Core 1 runs the DSP pipeline |
|
||||
| **SPSC Ring Buffer** | Single-producer single-consumer lock-free queue between Core 0 (CSI callback) and Core 1 (DSP task) |
|
||||
| **Vitals Packet** | 32-byte UDP packet (magic `0xC5110002`) containing presence, breathing BPM, heart rate BPM, fall flag |
|
||||
| **Compressed Frame** | Delta-compressed CSI frame (magic `0xC5110003`) using XOR + RLE for 30-50% bandwidth reduction |
|
||||
| **Compressed Frame** | Delta-compressed CSI frame (magic `0xC5110005`, reassigned from `0xC5110003` by ADR-069) using XOR + RLE for 30-50% bandwidth reduction |
|
||||
| **WASM Module** | A `no_std` Rust program compiled to `wasm32-unknown-unknown`, executed on-device via WASM3 interpreter |
|
||||
| **Module Slot** | One of 4 pre-allocated PSRAM arenas (160 KB each) that host a WASM module instance |
|
||||
| **Host API** | 12 functions in the `csi` namespace that WASM modules call to read sensor data and emit events |
|
||||
@@ -158,7 +158,7 @@ All firmware paths are relative to the repository root. Rust crate paths are rel
|
||||
| +------------------+--------+ |
|
||||
| | Multi-Person Clustering | |
|
||||
| | (subcarrier groups, <=4) |----> VitalsPacket (0xC5110002) |
|
||||
| +---------------------------+----> CompressedFrame (0xC5110003)|
|
||||
| +---------------------------+----> CompressedFrame (0xC5110005)|
|
||||
| |
|
||||
+--------------------------------------------------------------+
|
||||
```
|
||||
@@ -1197,7 +1197,7 @@ pub trait ProvisioningService {
|
||||
| Sensor Node | Edge Processing | **Partnership** | Tightly coupled via SPSC ring buffer on the same chip |
|
||||
| Edge Processing | WASM Runtime | **Customer/Supplier** | Edge pipeline feeds CSI data to WASM modules via Host API |
|
||||
| Sensor Node | Aggregation | **Published Language** | ADR-018 binary wire format (magic bytes, fixed offsets) |
|
||||
| Edge Processing | Aggregation | **Published Language** | Vitals (0xC5110002) and compressed (0xC5110003) wire formats |
|
||||
| Edge Processing | Aggregation | **Published Language** | Vitals (0xC5110002), compressed (0xC5110005), and feature vectors (0xC5110003) wire formats |
|
||||
| WASM Runtime | Aggregation | **Published Language** | WASM events (0xC5110004) wire format |
|
||||
| Aggregation | Downstream crates | **Customer/Supplier** | Aggregator produces `FusedFrame` consumed by signal/nn/mat |
|
||||
|
||||
@@ -1223,7 +1223,8 @@ impl Esp32ToPipelineAdapter {
|
||||
/// Handles magic byte demuxing:
|
||||
/// 0xC5110001 -> raw CSI frame
|
||||
/// 0xC5110002 -> vitals packet
|
||||
/// 0xC5110003 -> compressed frame (decompress first)
|
||||
/// 0xC5110003 -> feature vector (ADR-069, 48-byte 8-dim)
|
||||
/// 0xC5110005 -> compressed frame (decompress first)
|
||||
/// 0xC5110004 -> WASM event packet
|
||||
pub fn parse_datagram(
|
||||
&self,
|
||||
@@ -1306,8 +1307,9 @@ All ESP32 UDP packets share a 4-byte magic prefix for demuxing at the aggregator
|
||||
|-------|------|--------|------|------|-------------|
|
||||
| `0xC5110001` | Raw CSI | Tier 0+ | ~128-404 B | 20-28.5 Hz | Full I/Q per subcarrier |
|
||||
| `0xC5110002` | Vitals | Tier 2+ | 32 B | 1 Hz (configurable) | Presence, BPM, fall flag |
|
||||
| `0xC5110003` | Compressed | Tier 1+ | variable | 20-28.5 Hz | XOR+RLE delta-compressed CSI |
|
||||
| `0xC5110003` | Feature Vector | Tier 2+ | 48 B | 1 Hz | ADR-069 8-dim normalized features for Cognitum Seed RVF ingest |
|
||||
| `0xC5110004` | WASM Events | Tier 3 | variable | event-driven | Module event_type + value tuples |
|
||||
| `0xC5110005` | Compressed | Tier 1+ | variable | 20-28.5 Hz | XOR+RLE delta-compressed CSI (reassigned from 0xC5110003) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,996 @@
|
||||
# GOAP Implementation Plan: ESP32-S3 + Pi Zero 2 W WiFi Pose Estimation
|
||||
|
||||
**Date:** 2026-04-02
|
||||
**Version:** 1.0
|
||||
**Status:** Proposed
|
||||
**Depends on:** ADR-029, ADR-068, SOTA survey (sota-wifi-sensing-2025.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal State Definition
|
||||
|
||||
### 1.1 Terminal Goal
|
||||
|
||||
A production-ready WiFi-based human pose estimation system where:
|
||||
- **ESP32-S3** nodes capture WiFi CSI at 100 Hz, perform temporal feature extraction, and transmit compressed features via UDP
|
||||
- **Raspberry Pi Zero 2 W** receives features from 1-4 ESP32 nodes, runs neural inference, and outputs 17-keypoint COCO poses at >= 10 Hz
|
||||
- **Single-person MPJPE** < 100mm in trained environments
|
||||
- **End-to-end latency** < 150ms (CSI capture to pose output)
|
||||
- **Total BOM cost** < $30 per sensing zone (1x Pi Zero + 2x ESP32)
|
||||
|
||||
### 1.2 World State Variables
|
||||
|
||||
```
|
||||
current_state:
|
||||
esp32_csi_capture: true # Already implemented
|
||||
multi_node_aggregation: true # ADR-018 UDP aggregator
|
||||
phase_alignment: true # ruvsense/phase_align.rs
|
||||
coherence_gating: true # ruvsense/coherence_gate.rs
|
||||
multistatic_fusion: true # ruvsense/multistatic.rs
|
||||
kalman_pose_tracking: true # ruvsense/pose_tracker.rs
|
||||
onnx_inference_engine: true # wifi-densepose-nn
|
||||
modality_translator: true # wifi-densepose-nn/translator.rs
|
||||
training_pipeline: true # wifi-densepose-train
|
||||
pi_zero_deployment: false # No Pi Zero target
|
||||
lightweight_model: false # No edge-optimized model
|
||||
temporal_conv_module: false # No TCN in inference path
|
||||
csi_compression: false # No ESP32-side compression
|
||||
int8_quantization: false # No quantization pipeline
|
||||
bone_constraint_loss: false # No skeleton physics in loss
|
||||
esp32_pi_protocol: false # No lightweight protocol
|
||||
edge_inference_engine: false # No ARM-optimized inference
|
||||
cross_env_adaptation: false # No domain adaptation
|
||||
multi_person_paf: false # No PAF-based multi-person
|
||||
3d_pose_lifting: false # No Z-axis estimation
|
||||
|
||||
goal_state:
|
||||
esp32_csi_capture: true
|
||||
multi_node_aggregation: true
|
||||
phase_alignment: true
|
||||
coherence_gating: true
|
||||
multistatic_fusion: true
|
||||
kalman_pose_tracking: true
|
||||
onnx_inference_engine: true
|
||||
modality_translator: true
|
||||
training_pipeline: true
|
||||
pi_zero_deployment: true # TARGET
|
||||
lightweight_model: true # TARGET
|
||||
temporal_conv_module: true # TARGET
|
||||
csi_compression: true # TARGET
|
||||
int8_quantization: true # TARGET
|
||||
bone_constraint_loss: true # TARGET
|
||||
esp32_pi_protocol: true # TARGET
|
||||
edge_inference_engine: true # TARGET
|
||||
cross_env_adaptation: true # TARGET (Phase 2)
|
||||
multi_person_paf: true # TARGET (Phase 2)
|
||||
3d_pose_lifting: true # TARGET (Phase 3)
|
||||
```
|
||||
|
||||
## 2. Action Definitions
|
||||
|
||||
Each action has preconditions, effects, estimated cost (developer-days), and priority.
|
||||
|
||||
### Action 1: Define ESP32-Pi Communication Protocol (ADR-069)
|
||||
|
||||
```
|
||||
name: define_esp32_pi_protocol
|
||||
cost: 3 days
|
||||
priority: CRITICAL (blocks all Pi Zero work)
|
||||
preconditions: [esp32_csi_capture]
|
||||
effects: [esp32_pi_protocol := true]
|
||||
```
|
||||
|
||||
**Description:** Design a lightweight binary protocol for ESP32 -> Pi Zero communication over UDP (WiFi) or UART (wired fallback).
|
||||
|
||||
**Protocol specification:**
|
||||
|
||||
```
|
||||
Frame Header (8 bytes):
|
||||
[0:1] magic: 0xCF01 (CSI Frame v1)
|
||||
[2] node_id: u8 (0-255, identifies ESP32 node)
|
||||
[3] frame_type: u8 (0=raw_csi, 1=compressed_features, 2=heartbeat)
|
||||
[4:5] sequence: u16 (monotonic frame counter, wraps at 65535)
|
||||
[6:7] payload_len: u16 (bytes following header)
|
||||
|
||||
Raw CSI Payload (frame_type=0):
|
||||
[0:3] timestamp_us: u32 (microseconds since boot, wraps at ~71 minutes)
|
||||
[4] channel: u8 (WiFi channel 1-13)
|
||||
[5] bandwidth: u8 (0=20MHz, 1=40MHz)
|
||||
[6] rssi: i8 (dBm)
|
||||
[7] noise_floor: i8 (dBm)
|
||||
[8:9] num_sc: u16 (number of subcarriers, typically 52 or 114)
|
||||
[10..] csi_data: [i16; num_sc * 2] (interleaved I/Q, little-endian)
|
||||
|
||||
Compressed Feature Payload (frame_type=1):
|
||||
[0:3] timestamp_us: u32
|
||||
[4] compression: u8 (0=none, 1=pca_16, 2=pca_32, 3=autoencoder)
|
||||
[5] num_features: u8 (number of feature dimensions)
|
||||
[6..] features: [f16; num_features] (half-precision floats)
|
||||
|
||||
Heartbeat Payload (frame_type=2):
|
||||
[0:3] uptime_s: u32
|
||||
[4:7] frames_sent: u32
|
||||
[8:9] free_heap: u16 (KB)
|
||||
[10] wifi_rssi: i8 (connection to AP)
|
||||
[11] battery_pct: u8 (0-100, 0xFF if wired)
|
||||
```
|
||||
|
||||
**Implementation locations:**
|
||||
- ESP32 firmware: `firmware/esp32-csi-node/main/protocol_v2.h`
|
||||
- Rust parser: `wifi-densepose-hardware/src/protocol_v2.rs`
|
||||
|
||||
**Design rationale:**
|
||||
- Fixed 8-byte header with magic number for frame synchronization
|
||||
- Half-precision (f16) for compressed features saves 50% bandwidth vs f32
|
||||
- Heartbeat enables Pi Zero to detect node failures and rebalance
|
||||
- Raw CSI mode for debugging; compressed mode for production
|
||||
|
||||
### Action 2: Implement Lightweight Model Architecture
|
||||
|
||||
```
|
||||
name: implement_lightweight_model
|
||||
cost: 10 days
|
||||
priority: CRITICAL (core inference capability)
|
||||
preconditions: [training_pipeline, onnx_inference_engine]
|
||||
effects: [lightweight_model := true, temporal_conv_module := true]
|
||||
```
|
||||
|
||||
**Architecture: WiFlowPose (hybrid WiFlow + MultiFormer)**
|
||||
|
||||
Based on SOTA analysis, we define a custom architecture combining the best elements:
|
||||
|
||||
```
|
||||
Input: CSI amplitude tensor [B, T, S]
|
||||
B = batch size
|
||||
T = temporal window (20 frames at 20 Hz = 1 second context)
|
||||
S = subcarriers (52 for ESP32-S3 20MHz, 114 for 40MHz)
|
||||
|
||||
Stage 1: Temporal Encoder (runs on ESP32 optionally, or Pi Zero)
|
||||
TCN with 4 layers, dilation [1, 2, 4, 8]
|
||||
Input: [B, T, S] = [B, 20, 52]
|
||||
Output: [B, T', C_t] = [B, 20, 64] (temporal features)
|
||||
|
||||
Stage 2: Spatial Encoder (runs on Pi Zero)
|
||||
Asymmetric convolution blocks (1xk kernels on subcarrier dimension)
|
||||
4 residual blocks: 64 -> 128 -> 128 -> 64 channels
|
||||
Subcarrier compression: 52 -> 26 -> 13 -> 7
|
||||
Output: [B, 64, 7]
|
||||
|
||||
Stage 3: Keypoint Decoder (runs on Pi Zero)
|
||||
Axial self-attention (2-stage, 4 heads)
|
||||
Reshape to [B, 17, 64] (17 keypoints x 64 features)
|
||||
Linear projection: 64 -> 2 (x, y coordinates)
|
||||
Output: [B, 17, 2] (17 COCO keypoints, normalized 0-1)
|
||||
|
||||
Optional Stage 4: Multi-person (Phase 2)
|
||||
PAF branch: predict 19 limb affinity fields
|
||||
Hungarian assignment for person grouping
|
||||
```
|
||||
|
||||
**Estimated model size:**
|
||||
- Temporal encoder: ~0.5M params
|
||||
- Spatial encoder: ~1.2M params
|
||||
- Keypoint decoder: ~0.8M params
|
||||
- Total: ~2.5M params
|
||||
- INT8 size: ~2.5 MB
|
||||
- FP16 size: ~5 MB
|
||||
- Estimated Pi Zero 2 W inference: 30-60ms per frame
|
||||
|
||||
**Rust implementation location:** New module in `wifi-densepose-nn/src/wiflow_pose.rs`
|
||||
|
||||
```rust
|
||||
/// WiFlowPose: Lightweight WiFi CSI to pose estimation model
|
||||
///
|
||||
/// Hybrid architecture combining WiFlow's TCN temporal encoder
|
||||
/// with MultiFormer's dual-token spatial processing and
|
||||
/// axial self-attention for keypoint decoding.
|
||||
pub struct WiFlowPoseConfig {
|
||||
/// Number of input subcarriers (52 for ESP32 20MHz, 114 for 40MHz)
|
||||
pub num_subcarriers: usize,
|
||||
/// Temporal window size in frames (default: 20)
|
||||
pub temporal_window: usize,
|
||||
/// TCN dilation factors (default: [1, 2, 4, 8])
|
||||
pub tcn_dilations: Vec<usize>,
|
||||
/// Number of output keypoints (default: 17, COCO format)
|
||||
pub num_keypoints: usize,
|
||||
/// Hidden dimension for spatial encoder (default: 64)
|
||||
pub hidden_dim: usize,
|
||||
/// Number of attention heads in axial attention (default: 4)
|
||||
pub num_attention_heads: usize,
|
||||
/// Enable multi-person PAF branch (default: false)
|
||||
pub multi_person: bool,
|
||||
}
|
||||
|
||||
impl Default for WiFlowPoseConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
num_subcarriers: 52,
|
||||
temporal_window: 20,
|
||||
tcn_dilations: vec![1, 2, 4, 8],
|
||||
num_keypoints: 17,
|
||||
hidden_dim: 64,
|
||||
num_attention_heads: 4,
|
||||
multi_person: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Action 3: Implement Bone Constraint Loss
|
||||
|
||||
```
|
||||
name: implement_bone_constraint_loss
|
||||
cost: 2 days
|
||||
priority: HIGH
|
||||
preconditions: [training_pipeline, lightweight_model]
|
||||
effects: [bone_constraint_loss := true]
|
||||
```
|
||||
|
||||
**Loss function following WiFlow:**
|
||||
|
||||
```
|
||||
L_total = L_keypoint + lambda_bone * L_bone + lambda_physics * L_physics
|
||||
|
||||
L_keypoint = SmoothL1(pred, gt, beta=0.1)
|
||||
|
||||
L_bone = (1/|B|) * sum_{(i,j) in bones} | ||pred_i - pred_j|| - bone_length_{ij} |
|
||||
|
||||
L_physics = (1/N) * sum_t max(0, ||pred_t - pred_{t-1}|| - v_max * dt)
|
||||
```
|
||||
|
||||
Where:
|
||||
- `bones` = 14 COCO bone connections (e.g., left_shoulder-left_elbow)
|
||||
- `bone_length_{ij}` = average human bone length ratios (normalized to torso length)
|
||||
- `v_max` = maximum physiologically plausible keypoint velocity (2 m/s for walking, 10 m/s for fast gestures)
|
||||
- `lambda_bone = 0.2`, `lambda_physics = 0.1`
|
||||
|
||||
**Bone length ratios (normalized to torso = shoulder_center to hip_center = 1.0):**
|
||||
|
||||
| Bone | Ratio |
|
||||
|------|-------|
|
||||
| shoulder-elbow | 0.55 |
|
||||
| elbow-wrist | 0.50 |
|
||||
| hip-knee | 0.85 |
|
||||
| knee-ankle | 0.80 |
|
||||
| shoulder-hip | 1.00 |
|
||||
| neck-nose | 0.30 |
|
||||
| nose-eye | 0.08 |
|
||||
| eye-ear | 0.12 |
|
||||
|
||||
**Implementation location:** `wifi-densepose-train/src/losses.rs` (add `BoneConstraintLoss`)
|
||||
|
||||
### Action 4: Implement INT8 Quantization Pipeline
|
||||
|
||||
```
|
||||
name: implement_int8_quantization
|
||||
cost: 5 days
|
||||
priority: HIGH
|
||||
preconditions: [lightweight_model, training_pipeline]
|
||||
effects: [int8_quantization := true]
|
||||
```
|
||||
|
||||
**Approach: Post-Training Quantization (PTQ) with calibration**
|
||||
|
||||
1. Train model in FP32 using standard pipeline
|
||||
2. Export to ONNX format
|
||||
3. Run ONNX Runtime quantization tool with calibration dataset:
|
||||
- Collect 1000 representative CSI frames across multiple environments
|
||||
- Run calibration to determine per-layer quantization ranges
|
||||
- Apply symmetric INT8 quantization for weights, asymmetric for activations
|
||||
4. Validate quantized model accuracy (target: <2% PCK@20 degradation)
|
||||
|
||||
**Quantization-aware considerations:**
|
||||
- TCN layers: quantize per-channel (dilated convolutions are sensitive to quantization)
|
||||
- Attention layers: keep attention logits in FP16 (softmax is numerically sensitive)
|
||||
- Output layer: keep in FP32 (final coordinate regression needs precision)
|
||||
|
||||
**Rust implementation:**
|
||||
```rust
|
||||
// In wifi-densepose-nn/src/quantize.rs
|
||||
pub struct QuantizationConfig {
|
||||
/// Quantization method
|
||||
pub method: QuantMethod, // PTQ, QAT, Dynamic
|
||||
/// Per-layer precision overrides
|
||||
pub layer_overrides: HashMap<String, Precision>,
|
||||
/// Calibration dataset path
|
||||
pub calibration_data: PathBuf,
|
||||
/// Number of calibration samples
|
||||
pub num_calibration_samples: usize,
|
||||
/// Target accuracy degradation threshold
|
||||
pub max_accuracy_loss: f32,
|
||||
}
|
||||
|
||||
pub enum Precision {
|
||||
INT8,
|
||||
FP16,
|
||||
FP32,
|
||||
}
|
||||
```
|
||||
|
||||
**ONNX quantization command (for build pipeline):**
|
||||
```bash
|
||||
python -m onnxruntime.quantization.quantize \
|
||||
--input model_fp32.onnx \
|
||||
--output model_int8.onnx \
|
||||
--calibrate \
|
||||
--calibration_data_reader CsiCalibrationReader \
|
||||
--quant_format QDQ \
|
||||
--activation_type QUInt8 \
|
||||
--weight_type QInt8
|
||||
```
|
||||
|
||||
### Action 5: Build Edge Inference Engine for Pi Zero
|
||||
|
||||
```
|
||||
name: build_edge_inference_engine
|
||||
cost: 8 days
|
||||
priority: CRITICAL
|
||||
preconditions: [lightweight_model, int8_quantization, esp32_pi_protocol]
|
||||
effects: [edge_inference_engine := true, pi_zero_deployment := true]
|
||||
```
|
||||
|
||||
**Architecture: Streaming inference with ring buffer**
|
||||
|
||||
```
|
||||
UDP/UART
|
||||
ESP32-S3 ---------> Pi Zero 2 W
|
||||
|
|
||||
v
|
||||
+-- RingBuffer<CsiFrame> --+
|
||||
| (capacity: 64 frames) |
|
||||
+------ | | -------------+
|
||||
v v
|
||||
+-- TemporalWindow --------+
|
||||
| (20 frames, sliding) |
|
||||
+------ | ----------------+
|
||||
v
|
||||
+-- WiFlowPose ONNX ------+
|
||||
| (INT8, XNNPACK accel) |
|
||||
+------ | ----------------+
|
||||
v
|
||||
+-- PoseTracker -----------+
|
||||
| (Kalman + skeleton) |
|
||||
+------ | ----------------+
|
||||
v
|
||||
PoseEstimate output
|
||||
(17 keypoints + confidence)
|
||||
```
|
||||
|
||||
**New Rust binary:** `wifi-densepose-cli/src/bin/edge_infer.rs`
|
||||
|
||||
```rust
|
||||
/// Edge inference daemon for Raspberry Pi Zero 2 W
|
||||
///
|
||||
/// Receives CSI frames from ESP32 nodes via UDP, maintains a temporal
|
||||
/// sliding window, runs INT8 ONNX inference, and outputs pose estimates.
|
||||
///
|
||||
/// Usage:
|
||||
/// wifi-densepose edge-infer \
|
||||
/// --model model_int8.onnx \
|
||||
/// --listen 0.0.0.0:5555 \
|
||||
/// --output-port 5556 \
|
||||
/// --window-size 20 \
|
||||
/// --max-nodes 4
|
||||
|
||||
struct EdgeInferConfig {
|
||||
/// Path to INT8 ONNX model
|
||||
model_path: PathBuf,
|
||||
/// UDP listen address for CSI frames
|
||||
listen_addr: SocketAddr,
|
||||
/// UDP output address for pose results
|
||||
output_addr: Option<SocketAddr>,
|
||||
/// Temporal window size
|
||||
window_size: usize,
|
||||
/// Maximum ESP32 nodes to accept
|
||||
max_nodes: usize,
|
||||
/// Inference thread count (1-4 on Pi Zero 2 W)
|
||||
num_threads: usize,
|
||||
/// Enable XNNPACK acceleration
|
||||
use_xnnpack: bool,
|
||||
}
|
||||
```
|
||||
|
||||
**Cross-compilation for Pi Zero 2 W:**
|
||||
|
||||
```bash
|
||||
# Install cross-compilation toolchain
|
||||
rustup target add aarch64-unknown-linux-gnu
|
||||
sudo apt install gcc-aarch64-linux-gnu
|
||||
|
||||
# Build for Pi Zero 2 W (64-bit Raspberry Pi OS)
|
||||
cross build --target aarch64-unknown-linux-gnu \
|
||||
--release \
|
||||
-p wifi-densepose-cli \
|
||||
--features edge-inference \
|
||||
--no-default-features
|
||||
|
||||
# Or for 32-bit Raspberry Pi OS:
|
||||
# rustup target add armv7-unknown-linux-gnueabihf
|
||||
# cross build --target armv7-unknown-linux-gnueabihf ...
|
||||
```
|
||||
|
||||
**ONNX Runtime linking for ARM:**
|
||||
- Use `ort` crate with `download-binaries` feature for automatic aarch64 binary download
|
||||
- Alternative: build OnnxStream from source for minimal binary size (~2 MB vs ~30 MB for full ONNX Runtime)
|
||||
|
||||
### Action 6: Implement CSI Compression on ESP32
|
||||
|
||||
```
|
||||
name: implement_csi_compression
|
||||
cost: 5 days
|
||||
priority: MEDIUM
|
||||
preconditions: [esp32_csi_capture, esp32_pi_protocol]
|
||||
effects: [csi_compression := true]
|
||||
```
|
||||
|
||||
**Three compression tiers:**
|
||||
|
||||
**Tier 0: No compression (raw CSI)**
|
||||
- Payload: 52 subcarriers x 2 (I/Q) x 2 bytes = 208 bytes per frame
|
||||
- Use case: debugging, maximum fidelity
|
||||
|
||||
**Tier 1: PCA-16 (run on ESP32)**
|
||||
- Pre-computed PCA projection matrix (52 -> 16 dimensions)
|
||||
- Stored in NVS flash during provisioning
|
||||
- Payload: 16 features x 2 bytes (f16) = 32 bytes per frame
|
||||
- Compression: 6.5x
|
||||
- Compute: ~0.1ms on ESP32-S3 (matrix-vector multiply, SIMD)
|
||||
|
||||
**Tier 2: PCA-32 (higher fidelity)**
|
||||
- 52 -> 32 dimensions
|
||||
- Payload: 32 x 2 = 64 bytes
|
||||
- Compression: 3.25x
|
||||
|
||||
**Tier 3: Learned autoencoder (future)**
|
||||
- ESP32-S3 has enough compute for a small encoder (~10K params)
|
||||
- Requires quantized encoder weights in flash
|
||||
- Most bandwidth-efficient but requires training
|
||||
|
||||
**PCA computation (offline, during provisioning):**
|
||||
|
||||
```rust
|
||||
// wifi-densepose-train/src/compression.rs
|
||||
|
||||
/// Compute PCA projection matrix from calibration CSI data
|
||||
pub fn compute_pca_projection(
|
||||
calibration_data: &[CsiFrame],
|
||||
target_dims: usize,
|
||||
) -> PcaProjection {
|
||||
// 1. Stack all CSI amplitude vectors into matrix [N, S]
|
||||
// 2. Center (subtract mean)
|
||||
// 3. Compute covariance matrix [S, S]
|
||||
// 4. Eigendecomposition, take top `target_dims` eigenvectors
|
||||
// 5. Return projection matrix [S, target_dims] and mean vector [S]
|
||||
// ...
|
||||
}
|
||||
|
||||
pub struct PcaProjection {
|
||||
/// Projection matrix [num_subcarriers, target_dims]
|
||||
pub matrix: Vec<f32>,
|
||||
/// Mean vector for centering [num_subcarriers]
|
||||
pub mean: Vec<f32>,
|
||||
/// Number of input subcarriers
|
||||
pub input_dims: usize,
|
||||
/// Number of output features
|
||||
pub output_dims: usize,
|
||||
}
|
||||
```
|
||||
|
||||
**ESP32 firmware integration:**
|
||||
- Store PCA matrix in NVS partition (32x52x4 = 6.5 KB for PCA-32)
|
||||
- Apply projection in CSI callback before UDP transmission
|
||||
- Selectable via provisioning command
|
||||
|
||||
### Action 7: Implement Cross-Environment Adaptation
|
||||
|
||||
```
|
||||
name: implement_cross_env_adaptation
|
||||
cost: 8 days
|
||||
priority: MEDIUM (Phase 2)
|
||||
preconditions: [lightweight_model, training_pipeline, pi_zero_deployment]
|
||||
effects: [cross_env_adaptation := true]
|
||||
```
|
||||
|
||||
**Approach: Rapid environment calibration with few-shot adaptation**
|
||||
|
||||
Inspired by Arena Physica's template-based design space and MERIDIAN (ADR-027):
|
||||
|
||||
1. **Environment fingerprinting (on Pi Zero, at deployment time):**
|
||||
- Collect 60 seconds of "empty room" CSI
|
||||
- Compute room signature: mean amplitude profile, delay spread, K-factor
|
||||
- Match to nearest room template (corridor, office, bedroom, etc.)
|
||||
- Load template-specific model weights
|
||||
|
||||
2. **Few-shot fine-tuning (optional, on workstation):**
|
||||
- Collect 5 minutes of calibration data with known poses
|
||||
- Fine-tune last 2 layers of the model (~50K params)
|
||||
- Transfer updated model back to Pi Zero
|
||||
|
||||
3. **Online adaptation (continuous, on Pi Zero):**
|
||||
- Track CSI statistics over time (sliding window mean/variance)
|
||||
- Detect distribution shift (KL divergence exceeds threshold)
|
||||
- Apply batch normalization statistics update (no gradient computation needed)
|
||||
|
||||
**Implementation location:** `wifi-densepose-train/src/rapid_adapt.rs` (extend existing module)
|
||||
|
||||
### Action 8: Implement Multi-Person PAF Decoding
|
||||
|
||||
```
|
||||
name: implement_multi_person_paf
|
||||
cost: 6 days
|
||||
priority: LOW (Phase 2)
|
||||
preconditions: [lightweight_model, bone_constraint_loss]
|
||||
effects: [multi_person_paf := true]
|
||||
```
|
||||
|
||||
**Architecture (following MultiFormer):**
|
||||
|
||||
Add a PAF branch to the WiFlowPose model:
|
||||
|
||||
```
|
||||
Stage 3 features [B, 64, 7]
|
||||
|
|
||||
+--> Keypoint head: [B, 17, 2] (single-person keypoints)
|
||||
|
|
||||
+--> PAF head: [B, 38, H, W] (19 limb affinity fields)
|
||||
|
|
||||
+--> Confidence head: [B, 19, H, W] (part confidence maps)
|
||||
```
|
||||
|
||||
**Multi-person assignment on Pi Zero:**
|
||||
1. Extract candidate keypoints from confidence maps via NMS
|
||||
2. Compute PAF integral scores between candidate pairs
|
||||
3. Solve bipartite matching with Hungarian algorithm
|
||||
4. Group keypoints into person instances
|
||||
|
||||
**Estimated additional cost:** ~1M parameters, ~10ms additional inference time
|
||||
|
||||
### Action 9: Implement 3D Pose Lifting
|
||||
|
||||
```
|
||||
name: implement_3d_pose_lifting
|
||||
cost: 5 days
|
||||
priority: LOW (Phase 3)
|
||||
preconditions: [lightweight_model, multi_person_paf, multistatic_fusion]
|
||||
effects: [3d_pose_lifting := true]
|
||||
```
|
||||
|
||||
**Approach: Multi-view triangulation + learned depth prior**
|
||||
|
||||
With 2+ ESP32 nodes at known positions, compute 3D pose via:
|
||||
|
||||
1. Each node pair provides a different viewing angle of the WiFi field
|
||||
2. 2D pose from each viewpoint is estimated independently
|
||||
3. Epipolar geometry constrains 3D position from 2D observations
|
||||
4. Learned depth prior resolves ambiguities (front/back confusion)
|
||||
|
||||
This leverages the existing `viewpoint/geometry.rs` module in wifi-densepose-ruvector which already computes GeometricDiversityIndex and Fisher Information for multi-node configurations.
|
||||
|
||||
## 3. Hardware Architecture
|
||||
|
||||
### 3.1 System Topology
|
||||
|
||||
```
|
||||
WiFi AP (existing home router)
|
||||
/ | \
|
||||
/ | \
|
||||
ESP32-S3 #1 ESP32-S3 #2 ESP32-S3 #3
|
||||
(CSI node) (CSI node) (CSI node, optional)
|
||||
| | |
|
||||
+------+------+------+-------+
|
||||
| UDP (WiFi) |
|
||||
v v
|
||||
Raspberry Pi Zero 2 W
|
||||
(edge inference node)
|
||||
|
|
||||
v
|
||||
Pose output (UDP/MQTT/WebSocket)
|
||||
to display / home automation / API
|
||||
```
|
||||
|
||||
### 3.2 Data Flow Timing
|
||||
|
||||
```
|
||||
T=0ms ESP32 #1 captures CSI frame (channel 1)
|
||||
T=2ms ESP32 #1 applies PCA compression (0.1ms compute)
|
||||
T=3ms ESP32 #1 sends UDP packet to Pi Zero (64 bytes)
|
||||
T=5ms ESP32 #2 captures CSI frame (channel 6, TDM slot)
|
||||
T=7ms ESP32 #2 sends UDP packet to Pi Zero
|
||||
T=10ms Pi Zero receives both frames, adds to ring buffer
|
||||
T=10ms Pi Zero checks temporal window (20 frames accumulated?)
|
||||
If yes: run inference
|
||||
T=15ms Temporal encoder processes 20-frame window (5ms)
|
||||
T=35ms Spatial encoder + attention (20ms)
|
||||
T=45ms Keypoint decoder (10ms)
|
||||
T=48ms Kalman filter update + skeleton constraints (3ms)
|
||||
T=50ms Pose estimate emitted (17 keypoints + confidence)
|
||||
```
|
||||
|
||||
**Total latency: ~50ms** (well under 150ms target)
|
||||
**Throughput: 20 Hz** (matching TDMA cycle)
|
||||
|
||||
### 3.3 Hardware Bill of Materials
|
||||
|
||||
| Component | Unit Cost | Quantity | Total |
|
||||
|-----------|----------|----------|-------|
|
||||
| ESP32-S3 DevKit (8MB) | $9 | 2 | $18 |
|
||||
| Raspberry Pi Zero 2 W | $15 | 1 | $15 |
|
||||
| MicroSD card (16GB) | $5 | 1 | $5 |
|
||||
| USB-C power supply | $5 | 1 | $5 |
|
||||
| **Total** | | | **$43** |
|
||||
|
||||
With ESP32-S3 SuperMini ($6 each), total drops to **$37**.
|
||||
|
||||
For minimum viable setup (1 ESP32 + 1 Pi Zero): **$24**.
|
||||
|
||||
### 3.4 Pi Zero 2 W Specifications
|
||||
|
||||
| Parameter | Value |
|
||||
|-----------|-------|
|
||||
| SoC | BCM2710A1 (quad-core Cortex-A53 @ 1 GHz) |
|
||||
| RAM | 512 MB LPDDR2 |
|
||||
| WiFi | 802.11b/g/n (2.4 GHz only) |
|
||||
| Bluetooth | BLE 4.2 |
|
||||
| GPIO | 40-pin header (UART, SPI, I2C) |
|
||||
| Power | 5V/2A USB micro-B |
|
||||
| OS | Raspberry Pi OS Lite (64-bit, headless) |
|
||||
|
||||
**Memory budget for inference:**
|
||||
|
||||
| Component | Memory |
|
||||
|-----------|--------|
|
||||
| OS + services | ~100 MB |
|
||||
| WiFlowPose INT8 model | ~3 MB |
|
||||
| ONNX Runtime / OnnxStream | ~10-30 MB |
|
||||
| Ring buffer (64 frames x 4 nodes) | ~1 MB |
|
||||
| Inference workspace | ~20 MB |
|
||||
| **Total** | ~134-164 MB |
|
||||
| **Available** | ~348-378 MB headroom |
|
||||
|
||||
Comfortable fit within 512 MB RAM.
|
||||
|
||||
## 4. Rust Crate Modifications
|
||||
|
||||
### 4.1 Modified Crates
|
||||
|
||||
#### wifi-densepose-hardware
|
||||
|
||||
**New files:**
|
||||
- `src/protocol_v2.rs` -- Lightweight ESP32-Pi binary protocol parser/serializer
|
||||
- `src/pi_zero.rs` -- Pi Zero UDP receiver with ring buffer management
|
||||
|
||||
**Modified files:**
|
||||
- `src/lib.rs` -- Add `pub mod protocol_v2; pub mod pi_zero;`
|
||||
- `src/aggregator/mod.rs` -- Add support for protocol_v2 frame format
|
||||
|
||||
#### wifi-densepose-nn
|
||||
|
||||
**New files:**
|
||||
- `src/wiflow_pose.rs` -- WiFlowPose model definition (TCN + asymmetric conv + axial attention)
|
||||
- `src/edge_engine.rs` -- Edge-optimized inference engine (streaming, ARM NEON)
|
||||
- `src/quantize.rs` -- INT8 quantization configuration and validation
|
||||
|
||||
**Modified files:**
|
||||
- `src/lib.rs` -- Add new module exports
|
||||
- `src/onnx.rs` -- Add XNNPACK execution provider option, INT8 model loading
|
||||
- `src/translator.rs` -- Add WiFlowPose-compatible input format
|
||||
|
||||
#### wifi-densepose-train
|
||||
|
||||
**New files:**
|
||||
- `src/wiflow_pose_trainer.rs` -- Training loop for WiFlowPose architecture
|
||||
- `src/compression.rs` -- PCA computation for ESP32 CSI compression
|
||||
- `src/bone_loss.rs` -- Bone constraint and physics consistency losses
|
||||
|
||||
**Modified files:**
|
||||
- `src/losses.rs` -- Add `BoneConstraintLoss`, `PhysicsConsistencyLoss`
|
||||
- `src/config.rs` -- Add WiFlowPose training configuration options
|
||||
- `src/dataset.rs` -- Add ESP32-S3 CSI format support (52/114 subcarriers)
|
||||
- `src/rapid_adapt.rs` -- Add few-shot environment calibration
|
||||
|
||||
#### wifi-densepose-signal
|
||||
|
||||
**New files:**
|
||||
- `src/ruvsense/temporal_encoder.rs` -- TCN temporal feature extraction (shared code for ESP32 and Pi)
|
||||
|
||||
**Modified files:**
|
||||
- `src/ruvsense/mod.rs` -- Add `pub mod temporal_encoder;`
|
||||
|
||||
#### wifi-densepose-cli
|
||||
|
||||
**New files:**
|
||||
- `src/bin/edge_infer.rs` -- Pi Zero edge inference daemon
|
||||
- `src/bin/calibrate.rs` -- Environment calibration tool (PCA computation, room fingerprinting)
|
||||
|
||||
#### wifi-densepose-core
|
||||
|
||||
**Modified files:**
|
||||
- `src/types.rs` -- Add `CompressedCsiFrame`, `EdgePoseEstimate` types
|
||||
|
||||
### 4.2 New Feature Flags
|
||||
|
||||
```toml
|
||||
# wifi-densepose-nn/Cargo.toml
|
||||
[features]
|
||||
default = ["onnx"]
|
||||
onnx = ["ort"]
|
||||
edge-inference = ["onnx", "xnnpack"] # NEW: ARM NEON + XNNPACK
|
||||
candle = ["candle-core", "candle-nn"]
|
||||
tch-backend = ["tch"]
|
||||
|
||||
# wifi-densepose-cli/Cargo.toml
|
||||
[features]
|
||||
default = ["full"]
|
||||
full = ["wifi-densepose-nn/onnx", "wifi-densepose-train/tch-backend"]
|
||||
edge-inference = ["wifi-densepose-nn/edge-inference"] # NEW: minimal binary for Pi
|
||||
```
|
||||
|
||||
### 4.3 Cross-Compilation Configuration
|
||||
|
||||
```toml
|
||||
# .cargo/config.toml (add section)
|
||||
[target.aarch64-unknown-linux-gnu]
|
||||
linker = "aarch64-linux-gnu-gcc"
|
||||
rustflags = ["-C", "target-cpu=cortex-a53", "-C", "target-feature=+neon"]
|
||||
```
|
||||
|
||||
## 5. ESP32 Firmware Modifications
|
||||
|
||||
### 5.1 New Files
|
||||
|
||||
- `firmware/esp32-csi-node/main/protocol_v2.h` -- Protocol v2 frame packing
|
||||
- `firmware/esp32-csi-node/main/pca_compress.h` -- PCA compression for CSI
|
||||
- `firmware/esp32-csi-node/main/pca_compress.c` -- PCA implementation with ESP32 SIMD
|
||||
- `firmware/esp32-csi-node/main/pi_zero_mode.c` -- Pi Zero communication mode (lighter than full server mode)
|
||||
|
||||
### 5.2 Modified Files
|
||||
|
||||
- `firmware/esp32-csi-node/main/csi_handler.c` -- Add compression step in CSI callback
|
||||
- `firmware/esp32-csi-node/main/nvs_config.c` -- Store PCA matrix in NVS
|
||||
- `firmware/esp32-csi-node/main/Kconfig.projbuild` -- Add CONFIG_PI_ZERO_MODE, CONFIG_CSI_COMPRESSION options
|
||||
|
||||
### 5.3 Provisioning Updates
|
||||
|
||||
```bash
|
||||
# Provision for Pi Zero mode with PCA-16 compression
|
||||
python firmware/esp32-csi-node/provision.py \
|
||||
--port COM7 \
|
||||
--ssid "MyWiFi" \
|
||||
--password "secret" \
|
||||
--target-ip 192.168.1.50 \ # Pi Zero IP
|
||||
--target-port 5555 \
|
||||
--compression pca-16 \
|
||||
--pca-matrix pca_matrix_16.bin
|
||||
```
|
||||
|
||||
## 6. Training Pipeline
|
||||
|
||||
### 6.1 Training Workflow
|
||||
|
||||
```
|
||||
Phase 1: Pre-train on public datasets (GPU workstation)
|
||||
Dataset: MM-Fi + Wi-Pose (Intel 5300 format, 30 subcarriers)
|
||||
Model: WiFlowPose with 30 subcarriers
|
||||
Loss: L_keypoint + 0.2 * L_bone + 0.1 * L_physics
|
||||
Duration: ~20 hours on single A100
|
||||
|
||||
Phase 2: Domain adaptation for ESP32 CSI (GPU workstation)
|
||||
Dataset: Self-collected ESP32-S3 data (52 subcarriers)
|
||||
Method: Fine-tune all layers with lower learning rate (1e-4)
|
||||
Subcarrier interpolation: 30 -> 52 using existing interpolate_subcarriers()
|
||||
Duration: ~4 hours
|
||||
|
||||
Phase 3: Quantization (CPU workstation)
|
||||
Method: Post-training quantization with 1000 calibration samples
|
||||
Format: ONNX INT8 (QDQ format)
|
||||
Validation: PCK@20 degradation < 2%
|
||||
|
||||
Phase 4: Environment calibration (on Pi Zero)
|
||||
Method: 60-second empty-room CSI collection
|
||||
Output: Room fingerprint + PCA matrix
|
||||
Duration: ~2 minutes total
|
||||
```
|
||||
|
||||
### 6.2 Dataset Collection Protocol
|
||||
|
||||
For self-collected ESP32 training data:
|
||||
|
||||
1. **Setup:** 2 ESP32-S3 nodes at opposite corners of 4x4m room, Pi Zero receiving
|
||||
2. **Ground truth:** Smartphone camera running MediaPipe Pose (30 FPS), synchronized via NTP
|
||||
3. **Activities:** Standing, walking, sitting, waving, falling, idle (2 minutes each)
|
||||
4. **Subjects:** 5+ volunteers with varying body types
|
||||
5. **Environments:** 3+ rooms (bedroom, office, corridor) for generalization
|
||||
6. **Total target:** ~100K synchronized CSI-pose frame pairs
|
||||
|
||||
**Synchronization approach:**
|
||||
- ESP32 and Pi Zero synchronized via NTP (< 10ms accuracy on LAN)
|
||||
- Camera frames timestamped with system clock
|
||||
- Offline alignment via cross-correlation of movement signals
|
||||
|
||||
### 6.3 Transfer Learning Strategy
|
||||
|
||||
Following DensePose-WiFi's proven approach:
|
||||
|
||||
```
|
||||
L_total = lambda_pose * L_pose
|
||||
+ lambda_bone * L_bone
|
||||
+ lambda_transfer * L_transfer
|
||||
+ lambda_physics * L_physics
|
||||
|
||||
L_transfer = MSE(features_student, features_teacher)
|
||||
```
|
||||
|
||||
Where `features_teacher` come from a pre-trained image-based pose model (HRNet or ViTPose) and `features_student` come from the WiFi CSI model at corresponding intermediate layers.
|
||||
|
||||
**Lambda schedule:**
|
||||
- Epochs 1-20: lambda_transfer = 0.5 (heavy transfer guidance)
|
||||
- Epochs 20-50: lambda_transfer = 0.2 (moderate guidance)
|
||||
- Epochs 50-100: lambda_transfer = 0.05 (fine-tuning freedom)
|
||||
|
||||
## 7. Timeline and Milestones
|
||||
|
||||
### Phase 1: Foundation (Weeks 1-4)
|
||||
|
||||
| Week | Actions | Deliverable |
|
||||
|------|---------|-------------|
|
||||
| 1 | Action 1 (protocol), ADR-069 draft | Protocol spec + parser tests |
|
||||
| 2 | Action 2 (model architecture, begin) | WiFlowPose model definition in Rust |
|
||||
| 2 | Action 3 (bone loss) | Loss functions implemented and tested |
|
||||
| 3 | Action 2 (model architecture, complete) | Full model with ONNX export |
|
||||
| 4 | Action 4 (quantization) | INT8 model, accuracy validated |
|
||||
|
||||
**Milestone M1:** WiFlowPose model trained on MM-Fi, exported to INT8 ONNX, PCK@20 > 85% on validation set.
|
||||
|
||||
### Phase 2: Edge Deployment (Weeks 5-8)
|
||||
|
||||
| Week | Actions | Deliverable |
|
||||
|------|---------|-------------|
|
||||
| 5 | Action 5 (edge engine, begin) | Cross-compilation working, model loads on Pi |
|
||||
| 6 | Action 5 (edge engine, complete) | Streaming inference at >= 10 Hz on Pi Zero |
|
||||
| 6 | Action 6 (CSI compression) | PCA compression on ESP32, verified bandwidth reduction |
|
||||
| 7 | Integration testing | ESP32 -> Pi Zero full pipeline working |
|
||||
| 8 | Performance optimization | Latency < 100ms, memory < 200 MB |
|
||||
|
||||
**Milestone M2:** End-to-end demo: ESP32 captures CSI, Pi Zero outputs pose at 10+ Hz.
|
||||
|
||||
### Phase 3: Accuracy and Adaptation (Weeks 9-12)
|
||||
|
||||
| Week | Actions | Deliverable |
|
||||
|------|---------|-------------|
|
||||
| 9 | Data collection (ESP32-S3 training data) | 50K+ synchronized CSI-pose frames |
|
||||
| 10 | Domain adaptation training | ESP32-specific model, MPJPE < 120mm |
|
||||
| 11 | Action 7 (cross-env adaptation) | Room calibration working |
|
||||
| 12 | Validation and documentation | ADR-069 finalized, witness bundle |
|
||||
|
||||
**Milestone M3:** Single-person MPJPE < 100mm in calibrated environment, cross-environment deployment working with 60-second calibration.
|
||||
|
||||
### Phase 4: Multi-Person and 3D (Weeks 13-20)
|
||||
|
||||
| Week | Actions | Deliverable |
|
||||
|------|---------|-------------|
|
||||
| 13-14 | Action 8 (multi-person PAF) | 2-person pose separation working |
|
||||
| 15-16 | Action 9 (3D lifting) | Z-axis estimation from multi-node |
|
||||
| 17-18 | Advanced optimization | Model distillation, QAT |
|
||||
| 19-20 | Production hardening | OTA updates, monitoring, alerting |
|
||||
|
||||
**Milestone M4:** Multi-person 3D pose at 10 Hz on Pi Zero 2 W.
|
||||
|
||||
## 8. Risk Analysis
|
||||
|
||||
### 8.1 Technical Risks
|
||||
|
||||
| Risk | Probability | Impact | Mitigation |
|
||||
|------|------------|--------|------------|
|
||||
| Pi Zero 2 W inference too slow (> 100ms) | Medium | High | Fall back to activity recognition (smaller model); use Pi 4 instead |
|
||||
| ESP32-S3 CSI quality insufficient for pose | Low | Critical | Already validated in ADR-028; add directional antennas if needed |
|
||||
| INT8 quantization degrades accuracy > 5% | Medium | Medium | Use FP16 instead (2x size, ~1.5x slower); apply QAT |
|
||||
| Cross-environment generalization poor | High | High | Room calibration (Action 7); template-based models; continuous adaptation |
|
||||
| WiFi interference degrades CSI | Medium | Medium | Coherence gating (already implemented); channel hopping; 5 GHz fallback |
|
||||
| ONNX Runtime binary too large for Pi Zero | Low | Medium | Use OnnxStream (2 MB) instead of full ONNX Runtime (30 MB) |
|
||||
| Multi-person association errors | High | Medium | Limit to 2 persons initially; use PAF + Hungarian; AETHER re-ID |
|
||||
|
||||
### 8.2 Hardware Risks
|
||||
|
||||
| Risk | Probability | Impact | Mitigation |
|
||||
|------|------------|--------|------------|
|
||||
| Pi Zero 2 W supply shortage | Medium | Medium | Design also works with Pi 3A+ or Pi 4 |
|
||||
| ESP32-S3 firmware instability | Low | Medium | Existing firmware battle-tested; OTA rollback |
|
||||
| WiFi AP interference with CSI | Low | Low | Dedicated 2.4 GHz channel; ESP32 channel hopping |
|
||||
| Power supply issues (brownout) | Low | Medium | Proper power supply; ESP32 brownout detection |
|
||||
|
||||
### 8.3 Research Risks
|
||||
|
||||
| Risk | Probability | Impact | Mitigation |
|
||||
|------|------------|--------|------------|
|
||||
| WiFlow results don't reproduce | Medium | High | Fall back to CSI-Former or MultiFormer architecture |
|
||||
| ESP32 CSI fundamentally different from Intel 5300 | Medium | High | Collect ESP32-specific training data; subcarrier interpolation |
|
||||
| Bone constraint loss doesn't improve edge accuracy | Low | Low | Remove if no benefit; constraint is simple and cheap |
|
||||
| PCA compression loses critical CSI information | Low | Medium | Validate with ablation study; fall back to raw CSI if needed |
|
||||
|
||||
## 9. Dependency Graph (Action Ordering)
|
||||
|
||||
```
|
||||
[esp32_csi_capture] (DONE)
|
||||
/ \
|
||||
v v
|
||||
[Action 1: Protocol] [training_pipeline] (DONE)
|
||||
| / | \
|
||||
v v v v
|
||||
[Action 6: Compression] [Action 2: Model] [Action 3: Bone Loss]
|
||||
| | |
|
||||
| +------+-------+
|
||||
| v
|
||||
| [Action 4: Quantization]
|
||||
| |
|
||||
+---------------+------------+
|
||||
v
|
||||
[Action 5: Edge Engine]
|
||||
|
|
||||
v
|
||||
[Action 7: Cross-Env] (Phase 2)
|
||||
|
|
||||
v
|
||||
[Action 8: Multi-Person] (Phase 2)
|
||||
|
|
||||
v
|
||||
[Action 9: 3D Lifting] (Phase 3)
|
||||
```
|
||||
|
||||
**Critical path:** Action 1 -> Action 2 -> Action 4 -> Action 5
|
||||
**Parallel path:** Action 3 can proceed concurrently with Action 2
|
||||
**Parallel path:** Action 6 can proceed concurrently with Actions 2-4
|
||||
|
||||
## 10. Success Criteria
|
||||
|
||||
### Phase 1 Exit Criteria
|
||||
|
||||
- [ ] WiFlowPose model trains to convergence on MM-Fi dataset
|
||||
- [ ] PCK@20 >= 85% on MM-Fi validation set
|
||||
- [ ] INT8 ONNX model size < 5 MB
|
||||
- [ ] Bone constraint loss reduces physically implausible predictions by > 50%
|
||||
|
||||
### Phase 2 Exit Criteria
|
||||
|
||||
- [ ] edge_infer binary cross-compiles for aarch64 and runs on Pi Zero 2 W
|
||||
- [ ] End-to-end latency < 150ms (CSI capture to pose output)
|
||||
- [ ] Inference rate >= 10 Hz sustained
|
||||
- [ ] PCA compression reduces bandwidth by >= 3x without > 5% accuracy loss
|
||||
- [ ] Multi-node support (2 ESP32 nodes + 1 Pi Zero) working
|
||||
|
||||
### Phase 3 Exit Criteria
|
||||
|
||||
- [ ] Single-person MPJPE < 100mm in calibrated environment
|
||||
- [ ] Cross-environment deployment works with 60-second calibration
|
||||
- [ ] System runs continuously for 24 hours without crashes
|
||||
- [ ] ESP32 OTA firmware update working for CSI compression parameters
|
||||
|
||||
### Phase 4 Exit Criteria
|
||||
|
||||
- [ ] 2-person pose separation working (MPJPE < 150mm per person)
|
||||
- [ ] 3D pose estimation from 2+ nodes (Z-axis error < 200mm)
|
||||
- [ ] Production monitoring and alerting operational
|
||||
|
||||
## 11. Relationship to Existing ADRs
|
||||
|
||||
| ADR | Relationship |
|
||||
|-----|-------------|
|
||||
| ADR-018 | Protocol v2 (Action 1) extends ADR-018 binary frame format |
|
||||
| ADR-024 | AETHER re-ID embeddings used in multi-person tracking (Action 8) |
|
||||
| ADR-027 | MERIDIAN cross-env generalization informs Action 7 |
|
||||
| ADR-028 | ESP32 capability audit validates CSI quality assumptions |
|
||||
| ADR-029 | RuvSense pipeline stages feed into edge inference (Action 5) |
|
||||
| ADR-068 | Per-node state pipeline directly used by multi-node inference |
|
||||
|
||||
## 12. New ADR Required
|
||||
|
||||
**ADR-069: Edge Inference on Raspberry Pi Zero 2 W**
|
||||
|
||||
This implementation plan should be formalized as ADR-069 covering:
|
||||
- Protocol v2 specification
|
||||
- WiFlowPose architecture selection rationale
|
||||
- Pi Zero deployment constraints and optimizations
|
||||
- INT8 quantization strategy
|
||||
- Cross-compilation approach
|
||||
- Environment calibration protocol
|
||||
|
||||
Status: Proposed, pending this plan's approval.
|
||||
@@ -0,0 +1,142 @@
|
||||
# Analysis: Arena Physica and Atlas RF Studio
|
||||
|
||||
## Company Overview
|
||||
|
||||
Arena Physica positions itself as building "Electromagnetic Superintelligence" -- a foundation model trained directly on electromagnetic fields, one of the four fundamental forces of physics.
|
||||
|
||||
**Website:** https://www.arenaphysica.com/
|
||||
**Key Product:** Atlas RF Studio (Beta)
|
||||
**Core Models:** Heaviside-0 (forward prediction), Marconi-0 (inverse design)
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
### Heaviside-0: Forward Electromagnetic Model
|
||||
|
||||
A transformer-based neural network that predicts S-parameters (scattering parameters) from circuit geometry.
|
||||
|
||||
**Performance claims:**
|
||||
- Weighted MAE: < 1 dB
|
||||
- Speed: 13ms per design vs 4 minutes for traditional EM solvers
|
||||
- Speedup: 18,000x to 800,000x over commercial solvers (HFSS, CST)
|
||||
|
||||
**Architecture insights:**
|
||||
- Transformer backbone (specific architecture undisclosed)
|
||||
- Trained on electromagnetic field data, not just input-output mappings
|
||||
- Field augmentation acts as a regularizer -- even 0.3% field coverage during training reduced OOD loss
|
||||
|
||||
### Marconi-0: Inverse Design Model
|
||||
|
||||
A diffusion-based generative model that produces physical RF geometries matching target S-parameter specifications.
|
||||
|
||||
**Approach:**
|
||||
- Iterative refinement (diffusion process)
|
||||
- Generates "alien structures" -- non-intuitive geometries that meet specs
|
||||
- Trades compute time for quality (more diffusion steps = better designs)
|
||||
|
||||
### Training Data
|
||||
|
||||
**Simulated data:** 3 million designs across 25 expert templates with procedural variations, plus random organic structures to force learning in unexplored design space regions.
|
||||
|
||||
**Measured data:** Fabricated designs tested with vector network analyzers to capture manufacturing tolerances, material variations, connector parasitics.
|
||||
|
||||
**Total claimed:** 20M+ simulated designs in the broader training set.
|
||||
|
||||
### Current Design Space
|
||||
|
||||
- 2-layer PCB designs (8mm x 8mm)
|
||||
- 3 dielectric material choices
|
||||
- Ground vias
|
||||
- Filters and antennas
|
||||
|
||||
## Key Technical Insight: Fields as Fundamental Quantities
|
||||
|
||||
Arena Physica's central thesis is that Maxwell's equations govern electromagnetic fields, and models trained on field distributions learn the underlying physics rather than surface-level correlations between geometry and S-parameters.
|
||||
|
||||
This is directly relevant to WiFi sensing because:
|
||||
|
||||
1. **CSI IS an electromagnetic field measurement.** WiFi Channel State Information captures the complex transfer function H(f) between transmitter and receiver antennas across frequency subcarriers. This is a discrete sampling of the electromagnetic field in the propagation environment.
|
||||
|
||||
2. **Human bodies perturb the electromagnetic field.** Pose estimation from WiFi works because the human body (70% water, high permittivity) creates measurable perturbations in the ambient electromagnetic field.
|
||||
|
||||
3. **Foundation model approach could apply to sensing.** A model trained on electromagnetic field distributions in rooms with human bodies could potentially generalize across environments better than models trained on CSI-to-pose mappings directly.
|
||||
|
||||
## Relevance to WiFi-DensePose Project
|
||||
|
||||
### Direct Applicability: Moderate
|
||||
|
||||
Arena Physica's current focus is RF component design (filters, antennas), not sensing. However, several concepts transfer directly:
|
||||
|
||||
### 1. Physics-Informed Neural Architecture
|
||||
|
||||
Arena Physica trains on the electromagnetic field itself, not just input-output pairs. We should adopt this principle:
|
||||
|
||||
**Current approach in wifi-densepose:**
|
||||
```
|
||||
CSI amplitude/phase -> CNN/Transformer -> Keypoint coordinates
|
||||
```
|
||||
|
||||
**Physics-informed approach inspired by Arena Physica:**
|
||||
```
|
||||
CSI amplitude/phase -> Field reconstruction -> Body perturbation extraction -> Pose estimation
|
||||
```
|
||||
|
||||
Concretely, this means adding an intermediate field reconstruction stage that produces a spatial electromagnetic field map (similar to our existing `tomography.rs` module in RuvSense) and then extracting body perturbation from the field rather than going directly from CSI to pose.
|
||||
|
||||
### 2. Forward Model for Data Augmentation
|
||||
|
||||
Heaviside-0 predicts S-parameters from geometry. An analogous forward model for WiFi sensing would predict CSI from (room geometry + human pose). This enables:
|
||||
|
||||
- **Synthetic training data generation:** Generate CSI samples for arbitrary room layouts and poses
|
||||
- **Domain adaptation:** Bridge the sim-to-real gap by training the forward model on measured data
|
||||
- **Physics-based data augmentation:** Perturb room geometry parameters to generate diverse training environments
|
||||
|
||||
This directly addresses our MERIDIAN cross-environment generalization challenge (ADR-027).
|
||||
|
||||
### 3. Diffusion-Based Inverse Models
|
||||
|
||||
Marconi-0 uses diffusion to solve the inverse problem (S-parameters -> geometry). The analogous inverse problem for WiFi sensing is (CSI -> pose). Recent work on diffusion-based pose estimation could be adapted:
|
||||
|
||||
- Generate multiple pose hypotheses from a single CSI observation
|
||||
- Score hypotheses by physical plausibility (bone length constraints, joint angle limits)
|
||||
- Select the highest-scoring hypothesis
|
||||
|
||||
This is more robust than single-shot regression for ambiguous CSI measurements.
|
||||
|
||||
### 4. Multi-Resolution Field Representation
|
||||
|
||||
Arena Physica operates on 2-layer PCB designs at the mm scale. WiFi sensing operates at the wavelength scale (12.5 cm at 2.4 GHz). However, the principle of multi-resolution field representation applies:
|
||||
|
||||
- **Coarse grid:** Room-level field structure (presence detection, zone occupancy)
|
||||
- **Medium grid:** Body-level perturbation (bounding box, silhouette)
|
||||
- **Fine grid:** Limb-level detail (keypoint localization)
|
||||
|
||||
This maps to our existing RuvSense tomography module which implements RF tomography on a voxel grid, but suggests a multi-resolution approach would be more efficient.
|
||||
|
||||
## Adaptation Strategy for ESP32 + Pi Zero Deployment
|
||||
|
||||
### What to borrow from Arena Physica:
|
||||
|
||||
1. **Field-augmented training:** During training (on GPU workstation), include an auxiliary loss that encourages the model to predict the electromagnetic field distribution, not just keypoints. This regularizes the model and improves OOD generalization. At inference time on Pi Zero, the field prediction head is pruned.
|
||||
|
||||
2. **Lightweight forward model:** Train a small forward model (CSI predictor given room parameters) on the ESP32 side. This enables on-device anomaly detection: if observed CSI deviates significantly from the forward model prediction, flag the observation as potentially adversarial or corrupted.
|
||||
|
||||
3. **Template-based design space:** Arena Physica uses 25 expert templates with procedural variations. We should define "room templates" (corridor, open office, bedroom, living room) and train specialized lightweight models per template, selected at deployment time.
|
||||
|
||||
### What does NOT transfer:
|
||||
|
||||
1. **Scale of training data:** 20M+ designs is infeasible for WiFi sensing. Real CSI data collection is expensive. Synthetic data (ray tracing simulation) partially addresses this but lacks the fidelity of Arena Physica's EM simulations.
|
||||
|
||||
2. **Diffusion models on edge:** Marconi-0's diffusion approach is too computationally expensive for Pi Zero inference. We need single-shot architectures for real-time operation.
|
||||
|
||||
3. **2D geometry inputs:** Arena Physica processes 2D PCB layouts. WiFi sensing requires processing time-series data with complex spatial structure. The input representations are fundamentally different.
|
||||
|
||||
## Conclusions
|
||||
|
||||
Arena Physica demonstrates that foundation models trained on electromagnetic field data achieve superior generalization compared to models trained on input-output mappings alone. The key transferable insights for WiFi-DensePose are:
|
||||
|
||||
1. **Train on fields, not just observations** -- include field reconstruction as an auxiliary task
|
||||
2. **Use forward models for augmentation** -- predict CSI from room+pose for synthetic data
|
||||
3. **Multi-resolution representations** -- coarse-to-fine field reconstruction improves efficiency
|
||||
4. **Template-based specialization** -- room-type-specific models improve accuracy with lower compute
|
||||
|
||||
These insights inform the implementation plan, particularly the training pipeline design and the novel "field-augmented" training approach proposed in the implementation plan.
|
||||
@@ -0,0 +1,444 @@
|
||||
# Arena Physica Studio Analysis
|
||||
|
||||
Research document for wifi-densepose project.
|
||||
Date: 2026-04-02
|
||||
|
||||
---
|
||||
|
||||
## 1. What is Arena Physica?
|
||||
|
||||
Arena Physica (trading as Arena, arena-ai.com / arenaphysica.com) is a startup pursuing "Electromagnetic Superintelligence" -- building AI foundation models that develop superhuman intuition for how geometry shapes electromagnetic fields.
|
||||
|
||||
- **Founded**: 2019
|
||||
- **Founders**: Pratap Ranade (CEO), Arya Hezarkhani, Claire Pan, Michael Frei, Harish Krishnaswamy
|
||||
- **Funding**: $30M Series B (April 2025)
|
||||
- **Offices**: NYC (HQ), SF, LA
|
||||
- **Customers**: AMD, Anduril Industries, Sivers Semiconductors, Bausch & Lomb
|
||||
- **Impact claimed**: 35% reduction in engineering man-hours, multi-month acceleration in time-to-market, >3% improvement in product quality
|
||||
|
||||
Arena does NOT do WiFi sensing. They build AI-driven tools for RF/electromagnetic hardware design -- antennas, PCBs, filters, RF components. Their relevance to our project is methodological: they demonstrate how to build neural surrogates for Maxwell's equations that run 18,000x to 800,000x faster than traditional solvers.
|
||||
|
||||
|
||||
## 2. Atlas Platform and RF Studio
|
||||
|
||||
### 2.1 Atlas (Main Platform)
|
||||
|
||||
Atlas is Arena's "agentic platform" for hardware design workflows. It is deployed in production with Fortune 500 companies. Atlas encompasses:
|
||||
|
||||
- AI-driven electromagnetic simulation
|
||||
- Design generation and optimization
|
||||
- Hardware verification workflows
|
||||
- Integration with existing engineering tools
|
||||
|
||||
### 2.2 Atlas RF Studio (Public Beta)
|
||||
|
||||
Atlas RF Studio (https://studio.arenaphysica.com/) is a lightweight public instance of the Atlas platform, released as an "interactive sandbox for AI-driven inverse RF design." It serves as a research preview of their electromagnetic foundation model.
|
||||
|
||||
**Current capabilities (Beta):**
|
||||
- Two-layer RF structures
|
||||
- 8mm x 8mm maximum dimensions
|
||||
- Ground vias support
|
||||
- 3 dielectric material choices
|
||||
- AI-driven design generation from specifications
|
||||
- Real-time S-parameter prediction
|
||||
|
||||
**Workflow:**
|
||||
1. User inputs electromagnetic specifications (target S-parameters)
|
||||
2. Marconi-0 (inverse model) generates candidate geometries via conditional diffusion
|
||||
3. Heaviside-0 (forward model) evaluates each candidate in 13ms
|
||||
4. System iterates: generate -> simulate -> refine
|
||||
5. User receives optimized RF component design
|
||||
|
||||
### 2.3 Foundation Models
|
||||
|
||||
**Heaviside-0 (Forward Model)**:
|
||||
- Named after Oliver Heaviside (reformulated Maxwell's equations into modern vector form)
|
||||
- Predicts: S-parameters (magnitude + phase) and electromagnetic field distributions
|
||||
- Speed: 13ms single design, 0.3ms batched
|
||||
- Traditional solver comparison: ~4 minutes (HFSS/FDTD)
|
||||
- Speedup: 18,000x - 800,000x
|
||||
- Trained on 3 million designs across 25 expert templates + random structures
|
||||
- Training data represents 20+ years of combined simulation time
|
||||
- Accuracy: < 1 dB magnitude-weighted MAE
|
||||
|
||||
**Marconi-0 (Inverse Model)**:
|
||||
- Named after Guglielmo Marconi (radio pioneer)
|
||||
- Generates physical geometries from target S-parameter specifications
|
||||
- Uses conditional diffusion process (similar to Stable Diffusion / DALL-E architecture)
|
||||
- Can produce unconventional geometries that outperform human-designed solutions
|
||||
|
||||
### 2.4 Roadmap
|
||||
|
||||
Planned extensions include:
|
||||
- Multi-layer structures
|
||||
- Silicon integration (tapeout planned by end 2026)
|
||||
- Multiphysics integration (thermal, mechanical beyond EM)
|
||||
- Broader frequency ranges and design spaces
|
||||
|
||||
|
||||
## 3. Studio Technical Architecture
|
||||
|
||||
### 3.1 Frontend Stack
|
||||
|
||||
Based on runtime analysis of https://studio.arenaphysica.com/:
|
||||
|
||||
| Component | Technology | Evidence |
|
||||
|---|---|---|
|
||||
| Framework | Next.js (App Router, server-side streaming) | `__next_f`, `__next_s` arrays, static chunk loading |
|
||||
| UI Library | Mantine | Responsive breakpoint utilities (xs, sm, md, lg, xl) |
|
||||
| Rendering | React (server components + client hydration) | React streaming, component loading |
|
||||
| Fonts | Custom: Rules (Regular/Medium/Bold), EditionNumericalXXIX, Geist Mono (Google Fonts) | Font declarations in page source |
|
||||
| Theme | Dark mode default for "rf" domain | `ATLAS_DOMAIN: "rf"` config triggers dark theme |
|
||||
|
||||
### 3.2 Backend / API Infrastructure
|
||||
|
||||
| Service | Detail |
|
||||
|---|---|
|
||||
| API Domain | `https://api.emfm.atlas.arena-ai.com` (Auth0 audience) |
|
||||
| Organization | `emfmprod` |
|
||||
| Authentication | Auth0 with custom organization ID |
|
||||
| Feature Flags | DevCycle SDK (A/B testing) |
|
||||
| Monitoring | Datadog RUM (Real User Monitoring) |
|
||||
| 3D Rendering | Unreal Engine server at `https://52.61.97.121` (AWS IP) |
|
||||
| Terms of Service | Required (`ATLAS_REQUIRE_TOS: true`) |
|
||||
|
||||
### 3.3 Configuration Flags (from runtime config)
|
||||
|
||||
```json
|
||||
{
|
||||
"AUTH0_AUDIENCE": "https://api.emfm.atlas.arena-ai.com",
|
||||
"ATLAS_DOMAIN": "rf",
|
||||
"ATLAS_REQUIRE_TOS": true,
|
||||
"POLL_FOR_MESSAGES": false,
|
||||
"ENABLE_HOTJAR": false,
|
||||
"SHOW_DEBUG_LOGS": false
|
||||
}
|
||||
```
|
||||
|
||||
Key observations:
|
||||
- `POLL_FOR_MESSAGES: false` -- Messages likely use WebSocket/SSE push rather than polling
|
||||
- `ENABLE_HOTJAR: false` -- Session replay disabled in production
|
||||
- `SHOW_DEBUG_LOGS: false` -- Debug mode off
|
||||
- The `emfm` in the API domain likely stands for "ElectroMagnetic Field Model"
|
||||
|
||||
### 3.4 3D Visualization via Unreal Engine
|
||||
|
||||
The most technically interesting finding: Studio connects to an Unreal Engine server (IP: 52.61.97.121, AWS us-west region) for 3D electromagnetic field visualization.
|
||||
|
||||
**Likely architecture:**
|
||||
1. User submits design geometry in the Next.js frontend
|
||||
2. Backend runs Heaviside-0/Marconi-0 inference
|
||||
3. S-parameter results and field distribution data sent to Unreal Engine instance
|
||||
4. Unreal Engine renders 3D field visualization (E-field, H-field, current distributions)
|
||||
5. Pixel streaming sends rendered frames back to browser via WebRTC/WebSocket
|
||||
6. Interactive controls (rotate, zoom, slice planes) forwarded to Unreal Engine
|
||||
|
||||
This is consistent with Unreal Engine's Pixel Streaming technology, which renders on a remote GPU and streams video to a web browser. The `52.61.97.121` IP being hardcoded suggests a dedicated rendering server or fleet.
|
||||
|
||||
**Unreal Engine WebSocket Protocol** (standard):
|
||||
- Signaling server negotiates WebRTC connection
|
||||
- Control messages: `{ type: "input", data: { ... } }` for mouse/keyboard
|
||||
- Video stream: H.264/VP8 encoded, streamed via WebRTC data channel
|
||||
- Bidirectional: user input -> Unreal, rendered frames -> browser
|
||||
|
||||
### 3.5 Data Formats (Inferred)
|
||||
|
||||
Based on the S-parameter focus:
|
||||
|
||||
**Input (Design Specification):**
|
||||
- Target S-parameters: S11, S21, S12, S22 (magnitude + phase vs frequency)
|
||||
- Frequency range (likely GHz, given RF focus)
|
||||
- Material properties (dielectric constant, loss tangent)
|
||||
- Geometric constraints (layer count, max dimensions)
|
||||
|
||||
**Output (Design Result):**
|
||||
- Geometry: likely a discretized grid (64x64 binary material map based on Not Boring article)
|
||||
- S-parameters: complex-valued frequency response curves
|
||||
- Field distributions: 2D/3D electromagnetic field maps
|
||||
- Performance metrics: return loss, insertion loss, bandwidth
|
||||
|
||||
**Probable API format** (speculative, based on EM conventions):
|
||||
```json
|
||||
{
|
||||
"design": {
|
||||
"layers": [
|
||||
{
|
||||
"geometry": [[0,1,1,0,...], ...], // Binary material grid
|
||||
"material": "FR4",
|
||||
"thickness_mm": 0.2
|
||||
}
|
||||
],
|
||||
"vias": [{"x": 3, "y": 5, "radius_mm": 0.15}],
|
||||
"dielectric": "rogers_4003c"
|
||||
},
|
||||
"simulation": {
|
||||
"s_parameters": {
|
||||
"frequencies_ghz": [1.0, 1.1, ..., 40.0],
|
||||
"s11_mag_db": [-5.2, -5.4, ...],
|
||||
"s11_phase_deg": [45.2, 44.8, ...],
|
||||
"s21_mag_db": [-0.3, -0.3, ...]
|
||||
},
|
||||
"field_data": {
|
||||
"type": "near_field",
|
||||
"grid_size": [64, 64],
|
||||
"e_field_magnitude": [[...], ...]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
## 4. UI Components and Features
|
||||
|
||||
### 4.1 Observed UI Elements
|
||||
|
||||
Based on page source analysis:
|
||||
|
||||
- **Dark theme** with custom fonts (Rules family -- geometric sans-serif)
|
||||
- **Icon system** ("IconMark" component -- likely a custom RF/EM icon set)
|
||||
- **Responsive design** via Mantine breakpoints
|
||||
- **ToS gate** requiring acceptance before use
|
||||
- **Organization-scoped access** (Auth0 org-based multi-tenancy)
|
||||
|
||||
### 4.2 Likely Feature Set (inferred from product description and tech stack)
|
||||
|
||||
| Feature | Description | UI Component |
|
||||
|---|---|---|
|
||||
| Specification Input | Enter target S-parameters, frequency range, constraints | Form with frequency sweep chart |
|
||||
| Design Canvas | View/edit 2D geometry layers | Interactive grid editor |
|
||||
| S-parameter Viewer | Plot S11/S21/S12/S22 vs frequency | Interactive chart (likely Recharts or D3) |
|
||||
| 3D Field Viewer | Visualize E/H field distributions | Unreal Engine pixel-streamed viewport |
|
||||
| Design History | Browse previous designs and iterations | List/card view with thumbnails |
|
||||
| Compare View | Side-by-side design comparison | Split-pane layout |
|
||||
| Export | Download design files (Gerber, GDSII, S-parameter Touchstone) | Download buttons |
|
||||
|
||||
### 4.3 Agentic Workflow UI
|
||||
|
||||
Atlas RF Studio describes "agentic workflows" that:
|
||||
1. Accept natural-language or parametric specifications
|
||||
2. Generate multiple candidate designs
|
||||
3. Simulate each candidate
|
||||
4. Present ranked results
|
||||
5. Allow iterative refinement
|
||||
|
||||
This suggests an LLM chat interface (translating intent to specs) alongside the technical EM visualization. The pairing of LLM + LFM (Large Field Model) is explicitly described in their architecture.
|
||||
|
||||
|
||||
## 5. Lessons for Our Sensing Server UI
|
||||
|
||||
### 5.1 Architecture Patterns to Adopt
|
||||
|
||||
| Arena Physica Pattern | Application to wifi-densepose sensing-server |
|
||||
|---|---|
|
||||
| Dark theme default | Already appropriate for a sensing/monitoring dashboard |
|
||||
| Next.js + Mantine | Consider for our sensing-server UI (currently Axum + vanilla) |
|
||||
| Auth0 multi-tenancy | Overkill for local deployment; useful for cloud/multi-site |
|
||||
| Unreal Engine 3D | Too heavy; use Three.js/WebGL for 3D pose visualization |
|
||||
| WebSocket push (not polling) | Match our real-time CSI streaming needs |
|
||||
| Feature flags (DevCycle) | Useful for gradual feature rollout |
|
||||
| Datadog RUM | Consider lightweight alternative (e.g., self-hosted analytics) |
|
||||
|
||||
### 5.2 Visualization Approaches
|
||||
|
||||
**What Arena visualizes:**
|
||||
- S-parameters (frequency-domain complex response) -- charts
|
||||
- Electromagnetic field distributions -- 3D heatmaps
|
||||
- Design geometry -- 2D grid with material layers
|
||||
|
||||
**What we need to visualize:**
|
||||
- CSI amplitude/phase across subcarriers -- frequency-domain charts (similar to S-parameters)
|
||||
- Person occupancy heatmap -- 2D/3D voxel grid (similar to field visualization)
|
||||
- Pose skeleton overlay -- 2D/3D joint rendering
|
||||
- Vital signs (HR, BR) -- time-series charts
|
||||
- Node mesh topology -- graph visualization
|
||||
- Signal quality metrics -- dashboard gauges
|
||||
|
||||
**Shared patterns:**
|
||||
- Both need real-time frequency-domain data visualization
|
||||
- Both show spatial field/occupancy distributions
|
||||
- Both benefit from interactive 3D (but at different scales)
|
||||
- Both require low-latency streaming from computation backend
|
||||
|
||||
### 5.3 Data Flow Architecture Comparison
|
||||
|
||||
**Arena Physica:**
|
||||
```
|
||||
Browser (Next.js) -> API (inference) -> Heaviside-0/Marconi-0 -> Unreal Engine -> Pixel Stream -> Browser
|
||||
```
|
||||
|
||||
**wifi-densepose (recommended):**
|
||||
```
|
||||
ESP32 nodes -> sensing-server (Axum) -> WebSocket -> Browser (React/Mantine)
|
||||
|
|
||||
v
|
||||
RuvSense pipeline -> pose/vitals -> WebSocket -> Browser
|
||||
```
|
||||
|
||||
Key difference: Arena renders 3D on the server (Unreal Engine) and streams pixels. We should render 3D on the client (Three.js/WebGL) and stream data, because:
|
||||
- Our 3D scenes are simpler (skeleton + voxels vs. full EM field)
|
||||
- Client-side rendering avoids GPU server costs
|
||||
- Lower latency for real-time sensing feedback
|
||||
- Works offline / on local network
|
||||
|
||||
### 5.4 API Design Lessons
|
||||
|
||||
**Arena's API pattern** (REST + WebSocket):
|
||||
- REST for design submission and retrieval
|
||||
- WebSocket/SSE for live simulation progress and results
|
||||
- Auth0 JWT for authentication
|
||||
- Organization-scoped resources
|
||||
|
||||
**Recommended for sensing-server:**
|
||||
- REST endpoints for configuration, history, calibration
|
||||
- WebSocket for real-time CSI, pose, and vitals streaming
|
||||
- Optional: SSE as fallback for environments where WebSocket is blocked
|
||||
- API key or local-only access (no OAuth needed for embedded deployment)
|
||||
|
||||
**Proposed WebSocket protocol for sensing-server:**
|
||||
```json
|
||||
// Server -> Client: CSI frame
|
||||
{
|
||||
"type": "csi_frame",
|
||||
"timestamp_us": 1712000000000,
|
||||
"node_id": "esp32-node-1",
|
||||
"subcarriers": 56,
|
||||
"amplitude": [0.45, 0.52, ...],
|
||||
"phase": [-1.23, 0.87, ...]
|
||||
}
|
||||
|
||||
// Server -> Client: Pose update
|
||||
{
|
||||
"type": "pose",
|
||||
"timestamp_us": 1712000000000,
|
||||
"persons": [
|
||||
{
|
||||
"id": 0,
|
||||
"keypoints": [
|
||||
{"name": "nose", "x": 2.3, "y": 1.5, "z": 1.7, "confidence": 0.92},
|
||||
...
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// Server -> Client: Vitals update
|
||||
{
|
||||
"type": "vitals",
|
||||
"timestamp_us": 1712000000000,
|
||||
"person_id": 0,
|
||||
"heart_rate_bpm": 72.5,
|
||||
"breathing_rate_rpm": 16.2,
|
||||
"presence_score": 0.98
|
||||
}
|
||||
|
||||
// Server -> Client: Occupancy grid
|
||||
{
|
||||
"type": "occupancy",
|
||||
"timestamp_us": 1712000000000,
|
||||
"nx": 8, "ny": 8, "nz": 4,
|
||||
"bounds": [0.0, 0.0, 0.0, 6.0, 6.0, 3.0],
|
||||
"densities": [0.0, 0.0, 0.12, ...]
|
||||
}
|
||||
|
||||
// Client -> Server: Configuration
|
||||
{
|
||||
"type": "config",
|
||||
"action": "set",
|
||||
"key": "tomography.lambda",
|
||||
"value": 0.15
|
||||
}
|
||||
```
|
||||
|
||||
### 5.5 Specific UI Components to Build
|
||||
|
||||
Based on Arena Physica's approach and our sensing needs:
|
||||
|
||||
**Priority 1 (Core Dashboard):**
|
||||
1. **Real-time CSI waterfall** -- Subcarrier amplitude over time, color-mapped (similar to spectrogram)
|
||||
2. **Pose skeleton view** -- 2D/3D rendering of detected keypoints with skeleton connections
|
||||
3. **Node topology map** -- Show ESP32 mesh with RSSI-colored edges
|
||||
4. **Vitals panel** -- Heart rate and breathing rate with time-series charts
|
||||
|
||||
**Priority 2 (Advanced Visualization):**
|
||||
5. **Occupancy heatmap** -- 2D top-down view of tomographic voxel grid
|
||||
6. **Phase coherence indicator** -- Per-link coherence scores (green/yellow/red)
|
||||
7. **Fresnel zone overlay** -- Show first Fresnel zone on room floor plan per link
|
||||
|
||||
**Priority 3 (Configuration/Debug):**
|
||||
8. **Calibration wizard** -- Guide through empty-room calibration for field_model
|
||||
9. **Link quality matrix** -- NxN grid showing per-link signal metrics
|
||||
10. **Raw CSI inspector** -- Select individual link, view amplitude + phase per subcarrier
|
||||
|
||||
|
||||
## 6. Public API Endpoints and Protocols
|
||||
|
||||
### 6.1 Confirmed Endpoints
|
||||
|
||||
| Endpoint | Protocol | Purpose |
|
||||
|---|---|---|
|
||||
| `https://studio.arenaphysica.com` | HTTPS | Main web application (Next.js SSR) |
|
||||
| `https://api.emfm.atlas.arena-ai.com` | HTTPS | Backend API (Auth0 audience) |
|
||||
| `https://52.61.97.121` | HTTPS/WSS | Unreal Engine rendering server |
|
||||
|
||||
### 6.2 Authentication
|
||||
|
||||
- Auth0-based with organization scoping
|
||||
- Custom audience: `https://api.emfm.atlas.arena-ai.com`
|
||||
- Organization: `emfmprod`
|
||||
- Terms of Service required before access
|
||||
|
||||
### 6.3 Feature Flags
|
||||
|
||||
DevCycle SDK integrated for A/B testing and feature gating. This suggests gradual rollout of new capabilities.
|
||||
|
||||
### 6.4 Monitoring
|
||||
|
||||
Datadog RUM (Real User Monitoring) for performance tracking. Session replay (Hotjar) is available but disabled in production.
|
||||
|
||||
### 6.5 What is NOT Publicly Documented
|
||||
|
||||
- REST API endpoints (no public API docs found)
|
||||
- WebSocket message schemas
|
||||
- S-parameter data format
|
||||
- Geometry encoding format
|
||||
- Rate limits or usage quotas
|
||||
- Pricing model
|
||||
|
||||
Arena Physica appears to operate as a closed platform without public API access. The Studio beta is a controlled preview, not an open API.
|
||||
|
||||
|
||||
## 7. Summary of Findings
|
||||
|
||||
### What Arena Physica Is
|
||||
A $30M-funded startup building neural surrogates for electromagnetic simulation. Their AI predicts S-parameters and field distributions 18,000-800,000x faster than traditional solvers. They serve Fortune 500 hardware companies (AMD, Anduril) for RF component design.
|
||||
|
||||
### What Arena Physica Is NOT
|
||||
They are not a WiFi sensing company. They do not do human pose estimation, CSI analysis, or IoT sensing. The relevance to our project is purely methodological.
|
||||
|
||||
### Key Technical Takeaways for wifi-densepose
|
||||
|
||||
1. **Neural surrogates for Maxwell's equations work** -- Arena proves that training on millions of simulation examples produces models accurate to < 1 dB MAE running in milliseconds. We could apply the same approach to CSI prediction.
|
||||
|
||||
2. **Inverse design via conditional diffusion** -- Marconi-0's approach (generating geometry from target specs) parallels our inverse problem (generating pose from CSI). Conditional diffusion is a viable architecture.
|
||||
|
||||
3. **Bidirectional search** -- The generate-evaluate-refine loop is more effective than direct inversion. For real-time sensing, the evaluator (forward model) must be fast.
|
||||
|
||||
4. **Domain-specific models beat general LLMs** -- For electromagnetic tasks, specialized architectures substantially outperform GPT-4 / Claude. This validates our approach of building specialized CSI processing rather than relying on general-purpose models.
|
||||
|
||||
5. **Studio UI is Next.js + Mantine + Unreal Engine** -- A modern stack, but the Unreal Engine component is overkill for our visualization needs. Three.js/WebGL on the client is more appropriate for our real-time sensing dashboard.
|
||||
|
||||
6. **WebSocket push over polling** -- Confirmed by their `POLL_FOR_MESSAGES: false` configuration. Our sensing-server should use WebSocket push for real-time data streaming.
|
||||
|
||||
|
||||
## References
|
||||
|
||||
- Arena Physica Homepage: https://www.arenaphysica.com/
|
||||
- Atlas RF Studio Beta: https://studio.arenaphysica.com/
|
||||
- Introducing Atlas RF Studio (publication): https://www.arenaphysica.com/publications/rf-studio
|
||||
- Electromagnetism Secretly Runs the World (Not Boring essay): https://www.notboring.co/p/electromagnetism-secretly-runs-the
|
||||
- Arena Launches Atlas (press release): https://www.prnewswire.com/news-releases/arena-launches-atlas-to-accelerate-humanitys-rate-of-hardware-innovation-302423412.html
|
||||
- Arena AI raises $30M (SiliconANGLE): https://siliconangle.com/2025/04/08/arena-ai-raises-30m-accelerate-innovation-hardware-testing-atlas/
|
||||
- Artificial Intuition (CDFAM presentation): https://www.designforam.com/p/artificial-intuition-building-an
|
||||
- Pratap Ranade LinkedIn announcement: https://www.linkedin.com/posts/pratap-ranade-7272829_today-im-excited-to-introduce-arena-physica-activity-7442204772725723137-RRtE
|
||||
- Mantine UI: https://mantine.dev/
|
||||
- Unreal Engine Pixel Streaming: https://dev.epicgames.com/documentation/en-us/unreal-engine/remote-control-api-websocket-reference-for-unreal-engine
|
||||
@@ -0,0 +1,141 @@
|
||||
# Deep Analysis: arXiv 2505.15472 -- PhysicsArena
|
||||
|
||||
**Date:** 2026-04-02
|
||||
**Analyst:** GOAP Planning Agent
|
||||
**Relevance to wifi-densepose:** Indirect (physics reasoning benchmark, not WiFi sensing)
|
||||
|
||||
---
|
||||
|
||||
## 1. Paper Identity
|
||||
|
||||
- **Title:** PhysicsArena: The First Multimodal Physics Reasoning Benchmark Exploring Variable, Process, and Solution Dimensions
|
||||
- **Authors:** Song Dai, Yibo Yan, Jiamin Su, Dongfang Zihao, Yubo Gao, Yonghua Hei, Jungang Li, Junyan Zhang, Sicheng Tao, Zhuoran Gao, Xuming Hu
|
||||
- **Submitted:** 2025-05-21, revised 2025-05-22
|
||||
- **Category:** cs.CL (Computation and Language)
|
||||
- **arXiv ID:** 2505.15472v2
|
||||
|
||||
## 2. Core Contribution
|
||||
|
||||
PhysicsArena introduces a multimodal benchmark for evaluating how Large Language Models (MLLMs) reason about physics problems. The benchmark assesses three dimensions:
|
||||
|
||||
1. **Variable Identification** -- Can the model correctly identify physical variables from multimodal inputs (diagrams, text, equations)?
|
||||
2. **Physical Process Formulation** -- Can the model select and chain the correct physical laws and processes?
|
||||
3. **Solution Derivation** -- Can the model produce correct numerical/symbolic solutions?
|
||||
|
||||
This is the first benchmark to decompose physics reasoning into these three granular dimensions rather than only evaluating final answers.
|
||||
|
||||
## 3. Technical Approach
|
||||
|
||||
### 3.1 Benchmark Structure
|
||||
|
||||
The benchmark presents physics problems with multimodal inputs (text descriptions accompanied by diagrams, graphs, and physical setups). Problems span classical mechanics, electromagnetism, thermodynamics, optics, and modern physics.
|
||||
|
||||
### 3.2 Evaluation Protocol
|
||||
|
||||
Unlike prior benchmarks that score only final answers, PhysicsArena evaluates intermediate reasoning:
|
||||
|
||||
- **Variable extraction accuracy:** Does the model identify all relevant physical quantities (mass, velocity, charge, field strength, etc.)?
|
||||
- **Process correctness:** Does the model apply the right sequence of physical laws (Newton's laws, Maxwell's equations, conservation laws)?
|
||||
- **Solution accuracy:** Does the final numerical answer match the ground truth within tolerance?
|
||||
|
||||
### 3.3 Key Finding
|
||||
|
||||
Current MLLMs (GPT-4V, Claude, Gemini) perform significantly worse on variable identification and process formulation than on final solution derivation when provided with correct intermediate steps. This reveals that models often arrive at correct answers through pattern matching rather than genuine physics reasoning.
|
||||
|
||||
## 4. Relevance to WiFi-DensePose
|
||||
|
||||
### 4.1 Direct Relevance: Low
|
||||
|
||||
This paper is not about WiFi sensing, CSI processing, pose estimation, or edge deployment. It benchmarks LLM reasoning about physics problems.
|
||||
|
||||
### 4.2 Indirect Relevance: Moderate
|
||||
|
||||
Several concepts transfer to our domain:
|
||||
|
||||
#### 4.2.1 Physics-Informed Reasoning for Signal Processing
|
||||
|
||||
The paper's decomposition of physics reasoning into (variables, process, solution) maps onto WiFi sensing:
|
||||
|
||||
| PhysicsArena Dimension | WiFi-DensePose Analog |
|
||||
|------------------------|----------------------|
|
||||
| Variable identification | CSI feature extraction (amplitude, phase, subcarrier indices, antenna config) |
|
||||
| Process formulation | Signal processing pipeline selection (phase alignment, coherence gating, multiband fusion) |
|
||||
| Solution derivation | Pose/activity estimation output |
|
||||
|
||||
This suggests a potential architecture where intermediate representations are explicitly supervised -- not just end-to-end loss on final pose, but also losses on intermediate physical quantities (estimated path lengths, Doppler shifts, angle-of-arrival).
|
||||
|
||||
#### 4.2.2 Multimodal Grounding
|
||||
|
||||
PhysicsArena's core challenge is grounding abstract reasoning in physical reality from multimodal inputs. WiFi-DensePose faces the same challenge: grounding neural network predictions in the actual physics of electromagnetic wave propagation through space containing human bodies.
|
||||
|
||||
#### 4.2.3 Decomposed Evaluation
|
||||
|
||||
The three-dimension evaluation framework suggests we should evaluate our pipeline at multiple stages:
|
||||
|
||||
1. **CSI quality metrics** (SNR, coherence, phase stability) -- analogous to variable identification
|
||||
2. **Feature extraction quality** (does the modality translator preserve physically meaningful information?) -- analogous to process formulation
|
||||
3. **Pose accuracy** (PCK@50, MPJPE) -- analogous to solution derivation
|
||||
|
||||
This would help diagnose whether failures in pose estimation originate from poor CSI capture, lossy feature translation, or incorrect pose regression.
|
||||
|
||||
### 4.3 Transferable Insight: Intermediate Supervision
|
||||
|
||||
The paper's key insight -- that evaluating only final outputs masks fundamental reasoning failures -- argues for adding intermediate supervision signals to the wifi-densepose training pipeline:
|
||||
|
||||
```
|
||||
L_total = lambda_pose * L_pose
|
||||
+ lambda_physics * L_physics_consistency
|
||||
+ lambda_intermediate * L_intermediate_features
|
||||
```
|
||||
|
||||
Where `L_physics_consistency` penalizes predictions that violate known electromagnetic propagation physics (e.g., predicted person positions that are inconsistent with observed CSI phase relationships).
|
||||
|
||||
## 5. Applicable Techniques for Implementation Plan
|
||||
|
||||
### 5.1 Physics-Constrained Loss Functions
|
||||
|
||||
Add a physics consistency loss that enforces:
|
||||
|
||||
- **Fresnel zone consistency:** Predicted body positions must be consistent with the Fresnel zones that would produce the observed CSI perturbations
|
||||
- **Multipath geometry:** The number of strong multipath components should be consistent with the predicted scene geometry
|
||||
- **Doppler-velocity consistency:** If temporal CSI changes indicate Doppler shift, the predicted keypoint velocities must match
|
||||
|
||||
### 5.2 Hierarchical Evaluation Pipeline
|
||||
|
||||
Implement three-stage evaluation matching PhysicsArena's decomposition:
|
||||
|
||||
```rust
|
||||
pub struct HierarchicalEvaluation {
|
||||
/// Stage 1: CSI quality assessment
|
||||
pub csi_quality: CsiQualityMetrics,
|
||||
/// Stage 2: Feature translation fidelity
|
||||
pub translation_fidelity: TranslationMetrics,
|
||||
/// Stage 3: Pose estimation accuracy
|
||||
pub pose_accuracy: PoseMetrics,
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 Structured Intermediate Representations
|
||||
|
||||
Rather than a single encoder-decoder, structure the network to produce interpretable intermediate outputs:
|
||||
|
||||
```
|
||||
CSI input -> [Physics Encoder] -> physical_features (AoA, ToF, Doppler)
|
||||
-> [Geometry Decoder] -> spatial_occupancy_map
|
||||
-> [Pose Regressor] -> keypoint_coordinates
|
||||
```
|
||||
|
||||
Each intermediate output can be supervised independently where ground truth is available.
|
||||
|
||||
## 6. Conclusion
|
||||
|
||||
While arXiv 2505.15472 is not directly about WiFi sensing, its framework for decomposing physics reasoning into interpretable stages provides a valuable architectural pattern. The key takeaway for wifi-densepose is: **do not rely solely on end-to-end training; add intermediate physics-grounded supervision signals to improve robustness and interpretability.**
|
||||
|
||||
This aligns with the existing RuvSense architecture which already has explicit stages (multiband fusion, phase alignment, coherence scoring, coherence gating, pose tracking) -- the paper's framework validates this design choice and argues for adding supervision at each stage boundary.
|
||||
|
||||
## 7. Cross-References
|
||||
|
||||
- **Arena Physica (arena-physica-analysis.md):** Their thesis that "fields are the fundamental quantities" reinforces the physics-first approach recommended here. Training on electromagnetic field distributions rather than end-to-end CSI-to-pose would constitute the WiFi sensing analog of PhysicsArena's decomposed evaluation.
|
||||
- **WiFlow (sota-wifi-sensing-2025.md, Section 1.1):** WiFlow's bone constraint loss is a concrete implementation of physics-informed intermediate supervision -- the skeleton must obey anatomical constraints at every prediction step.
|
||||
- **MultiFormer (sota-wifi-sensing-2025.md, Section 1.2):** MultiFormer's dual-token (time + frequency) tokenization is analogous to PhysicsArena's variable identification -- it explicitly separates the physical dimensions of the CSI measurement before reasoning about them.
|
||||
- **Implementation plan (implementation-plan.md):** The hierarchical evaluation pipeline in Section 5.2 directly implements the three-stage evaluation framework recommended here.
|
||||
@@ -0,0 +1,615 @@
|
||||
# Maxwell's Equations in WiFi/RF Sensing
|
||||
|
||||
Research document for wifi-densepose project.
|
||||
Date: 2026-04-02
|
||||
|
||||
---
|
||||
|
||||
## 1. Maxwell's Equations and CSI Extraction
|
||||
|
||||
### 1.1 Foundational Electromagnetic Theory
|
||||
|
||||
All WiFi-based sensing ultimately derives from Maxwell's four partial differential equations governing electromagnetic field behavior:
|
||||
|
||||
```
|
||||
(1) Gauss's Law (Electric): nabla . E = rho / epsilon_0
|
||||
(2) Gauss's Law (Magnetic): nabla . B = 0
|
||||
(3) Faraday's Law: nabla x E = -dB/dt
|
||||
(4) Ampere-Maxwell Law: nabla x B = mu_0 * J + mu_0 * epsilon_0 * dE/dt
|
||||
```
|
||||
|
||||
In free space with no charges or currents (the indoor propagation case), these simplify to the wave equation:
|
||||
|
||||
```
|
||||
nabla^2 E - mu_0 * epsilon_0 * d^2 E / dt^2 = 0
|
||||
```
|
||||
|
||||
yielding plane wave solutions `E(r, t) = E_0 * exp(j(k . r - omega * t))` where `k = 2*pi / lambda` is the wavenumber. At 2.4 GHz WiFi, `lambda ~ 12.5 cm`; at 5 GHz, `lambda ~ 6 cm`.
|
||||
|
||||
### 1.2 From Maxwell to Channel State Information
|
||||
|
||||
Channel State Information (CSI) is the frequency-domain representation of the wireless channel's impulse response. The derivation from Maxwell's equations proceeds through several simplification layers:
|
||||
|
||||
**Layer 1: Full Maxwell's equations** -- Exact but computationally intractable for room-scale environments at GHz frequencies.
|
||||
|
||||
**Layer 2: High-frequency ray optics (Geometrical Optics / Uniform Theory of Diffraction)** -- When object dimensions >> lambda (walls, furniture), Maxwell's equations reduce to ray tracing. Each ray follows Snell's law at interfaces, with Fresnel reflection/transmission coefficients computed from the dielectric contrast.
|
||||
|
||||
**Layer 3: Multipath channel model** -- The channel impulse response aggregates all propagation paths:
|
||||
|
||||
```
|
||||
h(t) = sum_{n=1}^{N} alpha_n * exp(-j * phi_n) * delta(t - tau_n)
|
||||
```
|
||||
|
||||
where for each path n:
|
||||
- `alpha_n` = complex attenuation (from free-space path loss, reflection, diffraction)
|
||||
- `phi_n = 2*pi*f*tau_n` = phase shift
|
||||
- `tau_n = d_n / c` = propagation delay (distance / speed of light)
|
||||
|
||||
**Layer 4: Channel Frequency Response (CFR) = CSI** -- The Fourier transform of h(t):
|
||||
|
||||
```
|
||||
H(f_k) = sum_{n=1}^{N} alpha_n * exp(-j * 2*pi * f_k * tau_n)
|
||||
```
|
||||
|
||||
Each OFDM subcarrier k at frequency f_k provides one complex CSI measurement:
|
||||
|
||||
```
|
||||
H(f_k) = |H(f_k)| * exp(j * angle(H(f_k)))
|
||||
```
|
||||
|
||||
With 802.11n/ac providing 56-256 subcarriers and 802.11ax up to 512 subcarriers across 160 MHz bandwidth, CSI captures a frequency-sampled version of the channel's multipath structure.
|
||||
|
||||
**Key insight for sensing**: When a human moves in the environment, paths reflecting off the body change their `alpha_n`, `tau_n`, and `phi_n`, modulating the CSI. The sensing problem is to invert this relationship -- recover body state from CSI changes.
|
||||
|
||||
### 1.3 The Two CSI Models
|
||||
|
||||
The Tsinghua WiFi Sensing Tutorial (tns.thss.tsinghua.edu.cn) identifies two mainstream models:
|
||||
|
||||
**Ray-Tracing Model**: Establishes explicit geometric relationships between signal paths and CSI. The received signal is:
|
||||
|
||||
```
|
||||
V = sum_{n=1}^{N} |V_n| * exp(-j * phi_n)
|
||||
```
|
||||
|
||||
This model enables extraction of geometric parameters (distances, reflection points, angles of arrival) from CSI data. It underpins localization and tracking applications.
|
||||
|
||||
**Scattering Model**: Decomposes CSI into static and dynamic contributions:
|
||||
|
||||
```
|
||||
H(f,t) = sum_{o in Omega_s} H_o(f,t) + sum_{p in Omega_d} H_p(f,t)
|
||||
```
|
||||
|
||||
Dynamic scatterers (moving bodies) contribute through angular integration:
|
||||
|
||||
```
|
||||
H_p(f,t) = integral_0^{2pi} integral_0^{pi} h_p(alpha, beta, f, t) * exp(-j*k*v_p*cos(alpha)*t) d_alpha d_beta
|
||||
```
|
||||
|
||||
The scattering model yields the CSI autocorrelation:
|
||||
|
||||
```
|
||||
rho_H(f, tau) ~ sinc(k * v * tau)
|
||||
```
|
||||
|
||||
enabling speed extraction from autocorrelation peak analysis:
|
||||
|
||||
```
|
||||
v = x_0 * lambda / (2 * pi * tau_0)
|
||||
```
|
||||
|
||||
where `x_0` is the first sinc extremum location and `tau_0` is the corresponding time lag.
|
||||
|
||||
### 1.4 Practical Simplifications Used in WiFi Sensing
|
||||
|
||||
| Approximation | Physical Basis | Used When | Accuracy |
|
||||
|---|---|---|---|
|
||||
| Ray tracing (GO/UTD) | High-frequency limit of Maxwell | Objects >> lambda | Good for LOS + major reflections |
|
||||
| Fresnel zone model | Wave diffraction | Target near TX-RX line | Excellent for presence/respiration |
|
||||
| Born approximation | Weak scattering (small perturbation) | Low-contrast objects | Breaks down for human body |
|
||||
| Rytov approximation | Phase perturbation expansion | Moderate scattering | Better for lossy media |
|
||||
| Free-space path loss | 1/r^2 power decay | Coarse attenuation models | Adequate for RSSI-based sensing |
|
||||
|
||||
**Relevance to wifi-densepose**: Our `field_model.rs` implements the eigenstructure approach (Layer 2.5 -- between full ray tracing and statistical models), decomposing the channel covariance via SVD to separate environmental modes from body perturbation. Our `tomography.rs` implements the voxel-based inverse at Layer 3 using L1-regularized least squares.
|
||||
|
||||
|
||||
## 2. Physics-Informed Neural Networks (PINNs) for RF Sensing
|
||||
|
||||
### 2.1 PINN Architecture for Wireless Channels
|
||||
|
||||
Physics-Informed Neural Networks embed physical laws as constraints in the loss function or network architecture. For RF sensing, PINNs encode electromagnetic propagation principles:
|
||||
|
||||
**Standard PINN loss for RF propagation:**
|
||||
|
||||
```
|
||||
L_total = L_data + lambda_physics * L_physics + lambda_boundary * L_boundary
|
||||
|
||||
where:
|
||||
L_data = (1/N) * sum |H_pred(f_k) - H_meas(f_k)|^2 (CSI measurement fit)
|
||||
L_physics = (1/M) * sum |nabla^2 E + k^2 * E|^2 (Helmholtz equation residual)
|
||||
L_boundary = (1/B) * sum |E_pred - E_bc|^2 (boundary conditions)
|
||||
```
|
||||
|
||||
The Helmholtz equation `nabla^2 E + k^2 * n^2(r) * E = 0` (time-harmonic Maxwell) constrains the solution space, where `n(r)` is the spatially varying refractive index.
|
||||
|
||||
### 2.2 Key Papers and Approaches
|
||||
|
||||
**PINN + GNN for RF Map Construction** (arXiv 2507.22513):
|
||||
- Combines Physics-Informed Neural Networks with Graph Neural Networks
|
||||
- Physical constraints from EM propagation laws guide learning
|
||||
- Parameterizes multipath signals into received power, delay, and angle of arrival
|
||||
- Integrates spatial dependencies for accurate prediction
|
||||
|
||||
**PINN for Wireless Channel Estimation** (NeurIPS 2025, OpenReview r3plaU6DvW):
|
||||
- Synergistically combines model-based channel estimation with deep network
|
||||
- Exploits prior information about environmental propagation
|
||||
- Critical for next-gen wireless systems: precoding, interference reduction, sensing
|
||||
|
||||
**ReVeal: High-Fidelity Radio Propagation** (DySPAN 2025):
|
||||
- Physics-informed approach for radio environment mapping
|
||||
- Achieves high fidelity with limited measurement data
|
||||
|
||||
**Physics-Informed Generative Model for Passive RF Sensing** (arXiv 2310.04173, Savazzi et al.):
|
||||
- Variational Auto-Encoder integrating EM body diffraction
|
||||
- Forward model: predicts CSI perturbation from body position/pose
|
||||
- Validated against classical diffraction-based EM tools AND real RF measurements
|
||||
- Enables real-time processing where traditional EM is too slow
|
||||
|
||||
**Multi-Modal Foundational Model** (arXiv 2602.04016, February 2026):
|
||||
- Foundation model for AI-driven physical-layer wireless systems
|
||||
- Physics-guided pretraining grounded in EM propagation principles
|
||||
- Treats wireless as inherently multimodal physical system
|
||||
|
||||
**Generative AI for Wireless Sensing** (arXiv 2509.15258, September 2025):
|
||||
- Physics-informed diffusion models for data augmentation
|
||||
- Channel prediction and environment modeling
|
||||
- Conditional mechanisms constrained by EM laws
|
||||
|
||||
### 2.3 PINN Architecture for CSI-Based Sensing
|
||||
|
||||
```
|
||||
Algorithm: Physics-Informed CSI Sensing Network
|
||||
|
||||
Input: CSI tensor H[time, subcarrier, antenna] of shape (T, K, M)
|
||||
Output: Body state estimate (pose, position, or occupancy)
|
||||
|
||||
1. PREPROCESSING (physics-guided):
|
||||
a. Remove carrier frequency offset (CFO): H_clean = H * exp(-j*2*pi*delta_f*t)
|
||||
b. Conjugate multiply across antenna pairs to cancel common phase noise
|
||||
c. Compute CSI-ratio: H_ratio(f,t) = H_dynamic(f,t) / H_static(f,t)
|
||||
|
||||
2. PHYSICS ENCODER:
|
||||
a. Embed Fresnel zone geometry as positional encoding
|
||||
b. Apply multi-head attention with frequency-aware kernels
|
||||
c. Enforce causality: attention mask respects propagation delay ordering
|
||||
|
||||
3. PHYSICS-CONSTRAINED DECODER:
|
||||
a. Predict body state x_hat
|
||||
b. Forward-simulate expected CSI from x_hat using ray-tracing differentiable renderer
|
||||
c. Compute physics loss: L_phys = ||H_simulated(x_hat) - H_measured||^2
|
||||
|
||||
4. TRAINING LOSS:
|
||||
L = L_pose_supervision + alpha * L_phys + beta * L_temporal_smoothness
|
||||
```
|
||||
|
||||
### 2.4 Relevance to wifi-densepose
|
||||
|
||||
Our RuvSense pipeline already implements physics-guided preprocessing (phase alignment, coherence gating, Fresnel zone awareness). The next step would be to:
|
||||
|
||||
1. Add a differentiable ray-tracing forward model as a physics constraint during NN training
|
||||
2. Use the field model eigenstructure (from `field_model.rs`) as an informed prior
|
||||
3. Embed Fresnel zone geometry from link topology as architectural bias
|
||||
|
||||
|
||||
## 3. Inverse Electromagnetic Scattering for Body Reconstruction
|
||||
|
||||
### 3.1 The Inverse Problem
|
||||
|
||||
The forward problem: given a known body position/shape and room geometry, predict the CSI.
|
||||
|
||||
```
|
||||
Forward: body_state -> Maxwell/ray-tracing -> H(f,t) [well-posed]
|
||||
Inverse: H(f,t) -> ??? -> body_state [ill-posed]
|
||||
```
|
||||
|
||||
WiFi sensing is fundamentally an inverse scattering problem. A WiFi antenna receives signal as 1D amplitude/phase -- the spatial information of the 3D scene is collapsed to a single CSI complex number per subcarrier per antenna pair. Reconstructing fine-grained spatial information from this compressed observation is severely ill-posed.
|
||||
|
||||
### 3.2 Linearized Inverse Scattering: Born and Rytov Approximations
|
||||
|
||||
**Helmholtz equation with scatterer:**
|
||||
|
||||
```
|
||||
nabla^2 E(r) + k^2 * (1 + O(r)) * E(r) = 0
|
||||
```
|
||||
|
||||
where `O(r) = epsilon_r(r) - 1` is the object function (dielectric contrast of the body relative to free space).
|
||||
|
||||
**Born approximation** (first-order): Assumes the field inside the scatterer equals the incident field:
|
||||
|
||||
```
|
||||
E_scattered(r) ~ k^2 * integral O(r') * E_incident(r') * G(r, r') dr'
|
||||
```
|
||||
|
||||
where `G(r, r')` is the free-space Green's function. This is valid when `O(r)` is small and the object is electrically small. For the human body at 2.4 GHz (`epsilon_r ~ 40-60` for muscle tissue), the Born approximation is grossly violated.
|
||||
|
||||
**Rytov approximation**: Expands the complex phase rather than the field:
|
||||
|
||||
```
|
||||
E_total(r) = E_incident(r) * exp(psi(r))
|
||||
|
||||
psi(r) ~ (k^2 / E_incident(r)) * integral O(r') * E_incident(r') * G(r, r') dr'
|
||||
```
|
||||
|
||||
The Rytov approximation handles larger phase accumulation than Born but still assumes weak scattering. It works better for lossy media where absorption limits multiple scattering.
|
||||
|
||||
**Extended Phaseless Rytov Approximation (xPRA-LM)** (Dubey et al., arXiv 2110.03211):
|
||||
- First linear phaseless inverse scattering approximation with large validity range
|
||||
- Demonstrated with 2.4 GHz WiFi nodes for indoor imaging
|
||||
- Handles objects with `epsilon_r` up to 15+j1.5 (20x wavelength size)
|
||||
- At `epsilon_r = 77+j7` (water/tissue), shape reconstruction still accurate
|
||||
|
||||
### 3.3 Iterative Nonlinear Methods
|
||||
|
||||
For high-contrast scatterers like the human body, iterative methods are required:
|
||||
|
||||
**Distorted Born Iterative Method (DBIM):**
|
||||
|
||||
```
|
||||
Algorithm: DBIM for WiFi Body Imaging
|
||||
|
||||
Input: Measured scattered field E_s at receiver locations
|
||||
Output: Object function O(r) (dielectric map of scene)
|
||||
|
||||
1. Initialize: O_0(r) = 0 (empty room)
|
||||
2. For iteration i = 0, 1, 2, ...:
|
||||
a. Solve forward problem: compute total field E_i(r) in medium with O_i(r)
|
||||
b. Compute Green's function G_i(r, r') for medium O_i(r)
|
||||
c. Linearize: delta_E_s = K_i * delta_O (Frechet derivative)
|
||||
d. Solve: delta_O = K_i^+ * (E_s_measured - E_s_computed(O_i))
|
||||
e. Update: O_{i+1} = O_i + delta_O
|
||||
f. Check convergence: ||E_s_measured - E_s_computed(O_{i+1})|| < epsilon
|
||||
```
|
||||
|
||||
**Challenges for WiFi sensing:**
|
||||
- WiFi provides sparse spatial sampling (few antenna pairs vs. full aperture)
|
||||
- Phase is often unavailable (RSSI-only) or corrupted by hardware imperfections
|
||||
- Real-time requirement conflicts with iterative forward solves
|
||||
- Human body is a strong, moving scatterer
|
||||
|
||||
### 3.4 Radio Tomographic Imaging (RTI)
|
||||
|
||||
RTI (Wilson & Patwari, 2010) simplifies the inverse scattering problem by:
|
||||
1. Using only RSS (received signal strength) -- phaseless
|
||||
2. Assuming a voxelized scene with additive attenuation model
|
||||
3. Linearizing: measured attenuation = sum of voxel attenuations along path
|
||||
|
||||
**Forward model:**
|
||||
|
||||
```
|
||||
y = W * x + n
|
||||
|
||||
where:
|
||||
y = [y_1, ..., y_L]^T attenuation measurements (L links)
|
||||
x = [x_1, ..., x_V]^T voxel occupancy values (V voxels)
|
||||
W = [w_{l,v}] weight matrix (link-voxel intersection)
|
||||
n = measurement noise
|
||||
```
|
||||
|
||||
**Weight model (elliptical):**
|
||||
|
||||
```
|
||||
w_{l,v} = { 1 / sqrt(d_l) if d_{l,v}^tx + d_{l,v}^rx < d_l + lambda_w
|
||||
{ 0 otherwise
|
||||
|
||||
where:
|
||||
d_l = distance between TX_l and RX_l
|
||||
d_{l,v}^tx = distance from TX_l to voxel v center
|
||||
d_{l,v}^rx = distance from RX_l to voxel v center
|
||||
lambda_w = excess path length parameter (typically ~lambda/4)
|
||||
```
|
||||
|
||||
**Inverse solution (Tikhonov-regularized):**
|
||||
|
||||
```
|
||||
x_hat = (W^T W + alpha * C^{-1})^{-1} * W^T * y
|
||||
```
|
||||
|
||||
where `C` is the spatial covariance matrix and `alpha` controls regularization.
|
||||
|
||||
**Our implementation** (`tomography.rs`) uses ISTA (Iterative Shrinkage-Thresholding Algorithm) with L1 regularization for sparsity:
|
||||
|
||||
```
|
||||
Algorithm: ISTA for RF Tomography (as in tomography.rs)
|
||||
|
||||
Input: Weight matrix W, observations y, lambda (L1 weight)
|
||||
Output: Sparse voxel densities x
|
||||
|
||||
1. Initialize x = 0
|
||||
2. step_size = 1 / ||W^T * W||_spectral
|
||||
3. For iter = 1 to max_iterations:
|
||||
a. gradient = W^T * (W * x - y)
|
||||
b. x_candidate = x - step_size * gradient
|
||||
c. x = soft_threshold(x_candidate, lambda * step_size)
|
||||
where soft_threshold(z, t) = sign(z) * max(|z| - t, 0)
|
||||
d. residual = ||W * x - y||
|
||||
e. if residual < tolerance: break
|
||||
```
|
||||
|
||||
### 3.5 Reconciling RTI with Inverse Scattering
|
||||
|
||||
Dubey, Li & Murch (arXiv 2311.09633) reconciled empirical RTI with formal inverse scattering theory:
|
||||
- RTI's additive attenuation model corresponds to a first-order Born approximation of the scattered field amplitude
|
||||
- Their enhanced method reconstructs both shape AND material properties
|
||||
- Validated at 2.4 GHz with WiFi transceivers indoors
|
||||
|
||||
### 3.6 State-of-the-Art: Deep Learning Approaches
|
||||
|
||||
**DensePose From WiFi** (Geng, Huang, De la Torre, arXiv 2301.00250, CMU):
|
||||
- Maps WiFi CSI amplitude+phase to UV coordinates across 24 body regions
|
||||
- Uses 3 TX + 3 RX antennas, 56 subcarriers per link
|
||||
- Teacher-student training: camera-based DensePose provides labels
|
||||
- Performance comparable to image-based approaches
|
||||
- Works through walls and in darkness
|
||||
|
||||
**RF-Pose** (Zhao et al., CVPR 2018, MIT CSAIL):
|
||||
- Through-wall human pose estimation using radio signals
|
||||
- Cross-modal supervision: vision model trains RF model
|
||||
- Generalizes to through-wall scenarios with no through-wall training data
|
||||
|
||||
**Person-in-WiFi** (Wang et al., ICCV 2019, CMU):
|
||||
- End-to-end body segmentation and pose from WiFi
|
||||
- Standard 802.11n signals, off-the-shelf hardware
|
||||
|
||||
**3D WiFi Pose Estimation** (arXiv 2204.07878):
|
||||
- Free-form and moving activities
|
||||
- 3D joint position estimation from CSI
|
||||
|
||||
**HoloCSI** (2025-2026):
|
||||
- Holographic tomography pipeline coupling physics-guided projection with adaptive top-k sparse transformer
|
||||
- Preprocesses: CFO rectification, Doppler compensation, antenna-pair normalization
|
||||
- Sparse multi-head attention prunes low-magnitude query-key pairs (quadratic -> near-linear complexity)
|
||||
- Results: +2.9 dB PSNR, +3.6% SSIM, +12.4% mesh IoU vs baselines
|
||||
- 25 fps on RTX-4070-mobile at 5% sparsity; 7 fps on Raspberry Pi 5 with attention-GRU variant
|
||||
|
||||
|
||||
## 4. Computational Electromagnetics for WiFi Sensing
|
||||
|
||||
### 4.1 FDTD (Finite-Difference Time-Domain)
|
||||
|
||||
FDTD discretizes Maxwell's curl equations on a Yee grid and marches forward in time:
|
||||
|
||||
```
|
||||
Algorithm: FDTD Update (2D TM mode, simplified)
|
||||
|
||||
Grid: dx = dy = lambda/20 (minimum 10 cells per wavelength)
|
||||
Time step: dt = dx / (c * sqrt(2)) [Courant condition]
|
||||
|
||||
For each time step n:
|
||||
1. Update H fields:
|
||||
H_z^{n+1/2}(i,j) = H_z^{n-1/2}(i,j) + (dt/mu_0) * [
|
||||
(E_x^n(i,j+1) - E_x^n(i,j)) / dy -
|
||||
(E_y^n(i+1,j) - E_y^n(i,j)) / dx
|
||||
]
|
||||
|
||||
2. Update E fields:
|
||||
E_x^{n+1}(i,j) = E_x^n(i,j) + (dt / epsilon(i,j)) * [
|
||||
(H_z^{n+1/2}(i,j) - H_z^{n+1/2}(i,j-1)) / dy
|
||||
]
|
||||
```
|
||||
|
||||
**For WiFi at 2.4 GHz:**
|
||||
- Wavelength: 12.5 cm
|
||||
- Grid cell: ~6 mm (20 cells/lambda)
|
||||
- Room 6m x 6m x 3m: 1000 x 1000 x 500 = 500M cells
|
||||
- Memory: ~24 GB (6 field components * 4 bytes * 500M)
|
||||
- Time steps: ~10,000 for steady state
|
||||
|
||||
**Key references for WiFi FDTD:**
|
||||
- Lauer & Ertel (2003), "Using Large-Scale FDTD for Indoor WLAN" -- Full FDTD at 2.45 GHz in office environments
|
||||
- Lui et al. (2018), "Human Body Shadowing" -- FDTD human body model for ray-tracing calibration (Hindawi IJAP 9084830)
|
||||
- Martinez-Gonzalez et al. (2008), "FDTD Assessment Human Exposure WiFi/Bluetooth" -- SAR computation with anatomical body models
|
||||
|
||||
**Practical limitations**: FDTD is too slow for real-time sensing but valuable for:
|
||||
- Generating training data for neural networks
|
||||
- Validating approximate models
|
||||
- Understanding near-field body-wave interaction
|
||||
|
||||
### 4.2 Method of Moments (MoM)
|
||||
|
||||
MoM converts Maxwell's integral equations into matrix equations by expanding fields in basis functions:
|
||||
|
||||
```
|
||||
[Z] * [I] = [V]
|
||||
|
||||
where:
|
||||
Z_{mn} = integral integral G(r_m, r_n) * f_m(r) * f_n(r') dS dS'
|
||||
I_n = unknown current coefficients
|
||||
V_m = incident field excitation
|
||||
```
|
||||
|
||||
**Application**: MoM excels for antenna analysis and is used to model WiFi antenna patterns. Less practical for full room simulation due to O(N^2) memory and O(N^3) solve time.
|
||||
|
||||
### 4.3 FEM (Finite Element Method)
|
||||
|
||||
FEM handles complex geometries and material interfaces more naturally than FDTD:
|
||||
|
||||
```
|
||||
Weak form of Helmholtz equation:
|
||||
integral nabla x E_test . (1/mu_r * nabla x E) dV - k_0^2 * integral E_test . epsilon_r * E dV
|
||||
= -j * omega * integral E_test . J_s dV
|
||||
```
|
||||
|
||||
**Application**: HFSS (Ansys) and COMSOL use FEM for electromagnetic simulation. Arena Physica's Heaviside-0 model was trained against such commercial FEM solvers.
|
||||
|
||||
### 4.4 Comparison for WiFi Sensing Applications
|
||||
|
||||
| Method | Speed | Accuracy | Body Modeling | Room Scale | Real-Time |
|
||||
|---|---|---|---|---|---|
|
||||
| FDTD | Hours | Full-wave exact | Excellent | Feasible (GPU) | No |
|
||||
| MoM | Hours | Exact for surfaces | Good (surface) | Impractical | No |
|
||||
| FEM | Hours | Exact | Excellent | Feasible | No |
|
||||
| Ray tracing | Seconds | GO/UTD approximation | Coarse | Easy | Near real-time |
|
||||
| RTI (ISTA) | Milliseconds | Linear approximation | Voxelized | Easy | Yes |
|
||||
| Neural surrogate | Milliseconds | Trained accuracy | Implicit | Trained domain | Yes |
|
||||
|
||||
### 4.5 Hybrid Approaches: Neural Surrogates Trained on CEM
|
||||
|
||||
The most promising direction combines full-wave accuracy with real-time speed:
|
||||
|
||||
1. **Offline**: Run thousands of FDTD/FEM simulations with different body positions
|
||||
2. **Train**: Neural network learns the mapping from body state to CSI
|
||||
3. **Deploy**: Neural surrogate runs in milliseconds for real-time inference
|
||||
|
||||
This is exactly Arena Physica's approach (Section 5), applied to RF component design rather than sensing. The same methodology applies to WiFi sensing: train a neural forward model on FDTD data, then use it as a differentiable physics constraint during inverse model training.
|
||||
|
||||
|
||||
## 5. Arena Physica's Approach
|
||||
|
||||
### 5.1 Company Overview
|
||||
|
||||
Arena Physica (arena-ai.com / arenaphysica.com) pursues "Electromagnetic Superintelligence" -- building foundation models that develop superhuman intuition for how geometry shapes electromagnetic fields. Founded by Pratap Ranade (CEO), Arya Hezarkhani, Claire Pan, Michael Frei, and Harish Krishnaswamy. Offices in NYC (HQ), SF, LA.
|
||||
|
||||
Raised $30M Series B (April 2025). Deployed with AMD, Anduril Industries, Sivers Semiconductors, Bausch & Lomb. Claims 35% reduction in engineering man-hours and multi-month acceleration in time-to-market.
|
||||
|
||||
### 5.2 Technical Architecture
|
||||
|
||||
Arena's Atlas platform uses two foundation models:
|
||||
|
||||
**Heaviside-0 (Forward Model)**:
|
||||
- Input: PCB/RF geometry (discretized as grid)
|
||||
- Output: S-parameters (magnitude + phase) and field distributions
|
||||
- Speed: 13ms per design (single), 0.3ms batched
|
||||
- Comparison: Traditional solver (HFSS/FDTD) takes ~4 minutes
|
||||
- Speedup: 18,000x to 800,000x
|
||||
|
||||
**Marconi-0 (Inverse Model)**:
|
||||
- Input: Target S-parameter specification
|
||||
- Output: Physical geometry that achieves the specification
|
||||
- Method: Conditional diffusion process (similar to image generation)
|
||||
- Generates unconventional geometries no human designer would conceive
|
||||
|
||||
**Training data**: 3 million simulated designs across 25 expert templates + random structures, totaling 20+ years of combined simulation time. Incorporates both S-parameter data and electromagnetic field distributions.
|
||||
|
||||
**Validation**: Predictions validated against commercial numerical field solvers (likely HFSS). Internal testing shows < 1 dB magnitude-weighted MAE (RF engineers operate in 20-30 dB ranges).
|
||||
|
||||
### 5.3 Relationship to Maxwell's Equations
|
||||
|
||||
Arena does NOT solve Maxwell's equations directly. Instead:
|
||||
|
||||
1. **Training phase**: Maxwell's equations are solved by conventional solvers (FDTD/FEM/MoM) millions of times to generate training data
|
||||
2. **Inference phase**: Neural surrogate approximates Maxwell's solutions in milliseconds
|
||||
3. **Design loop**: Generator proposes geometry -> Evaluator predicts EM behavior -> Iterate
|
||||
|
||||
As Pratap Ranade states: the model "learns the syntax of physics" inductively from examples, rather than deductively from equations. This trades precision for speed -- acceptable when searching design space where "speed and direction matter more than precision."
|
||||
|
||||
### 5.4 The "Large Field Model" (LFM) Concept
|
||||
|
||||
Arena's LFM is distinct from Large Language Models:
|
||||
- LLMs learn linguistic patterns from text
|
||||
- LFMs learn electromagnetic field patterns from simulation data
|
||||
- The input is geometry (not text); the output is field distributions (not tokens)
|
||||
- Domain-specific architecture substantially outperforms general LLMs on EM tasks
|
||||
|
||||
### 5.5 Relevance to WiFi Sensing
|
||||
|
||||
Arena Physica focuses on RF component design (antennas, PCBs, filters), not WiFi sensing. However, their approach is directly transferable:
|
||||
|
||||
| Arena Physica (Design) | WiFi Sensing (Our Case) |
|
||||
|---|---|
|
||||
| Forward: geometry -> S-parameters | Forward: body pose -> CSI |
|
||||
| Inverse: S-parameters -> geometry | Inverse: CSI -> body pose |
|
||||
| Train on FDTD/FEM simulations | Train on ray-tracing / FDTD simulations |
|
||||
| 13ms inference | Real-time CSI inference |
|
||||
| Conditional diffusion for generation | Conditional generation for pose prediction |
|
||||
|
||||
**Key lesson for wifi-densepose**: Building a neural forward model (body_pose -> expected_CSI) trained on electromagnetic simulation data, then using it as a differentiable physics constraint during inverse model training, could significantly improve our pose estimation accuracy and generalization. This is the "physics-informed" approach with the computational burden shifted to offline training.
|
||||
|
||||
|
||||
## 6. Connections to wifi-densepose Codebase
|
||||
|
||||
### 6.1 Existing Physics-Based Modules
|
||||
|
||||
| Module | Physical Model | Maxwell Connection |
|
||||
|---|---|---|
|
||||
| `field_model.rs` | SVD eigenstructure decomposition | Eigenmode basis of room's EM field |
|
||||
| `tomography.rs` | L1-regularized RTI (ISTA solver) | Linearized inverse scattering |
|
||||
| `multistatic.rs` | Attention-weighted cross-node fusion | Exploits geometric diversity of multiple TX/RX |
|
||||
| `phase_align.rs` | LO phase offset estimation | Corrects hardware-induced phase corruption |
|
||||
| `coherence.rs` | Z-score coherence scoring | Statistical test on EM field stability |
|
||||
| `coherence_gate.rs` | Accept/Reject decisions | Quality control on EM measurements |
|
||||
| `adversarial.rs` | Physical impossibility detection | Enforces EM consistency constraints |
|
||||
|
||||
### 6.2 Potential Enhancements Based on This Research
|
||||
|
||||
1. **Differentiable ray-tracing forward model**: Train a neural surrogate on ray-tracing simulations of CSI for various body poses in the deployment room. Use as physics constraint in pose estimation.
|
||||
|
||||
2. **Fresnel zone integration**: Augment the attention mechanism in `multistatic.rs` with Fresnel zone geometry -- links where the body falls within the first Fresnel zone should receive higher attention weight.
|
||||
|
||||
3. **xPRA-LM inverse scattering**: For higher-resolution body imaging than RTI, implement the Extended Phaseless Rytov Approximation. Our tomography module currently uses the simpler additive attenuation model.
|
||||
|
||||
4. **HoloCSI-style sparse transformer**: Replace the dense attention in cross-viewpoint fusion with top-k sparse attention for efficiency on ESP32-constrained deployments.
|
||||
|
||||
5. **Physics-informed training loss**: When training the DensePose model, add a loss term penalizing physically impossible CSI patterns (e.g., signals that would require faster-than-light propagation or negative attenuation).
|
||||
|
||||
|
||||
## 7. References
|
||||
|
||||
### Core WiFi Sensing Surveys
|
||||
- WiFi Sensing with Channel State Information: A Survey. ACM Computing Surveys, 2019. https://dl.acm.org/doi/fullHtml/10.1145/3310194
|
||||
- Cross-Domain WiFi Sensing with Channel State Information: A Survey. ACM Computing Surveys, 2022. https://dl.acm.org/doi/10.1145/3570325
|
||||
- Wireless sensing applications with Wi-Fi CSI, preprocessing techniques, and detection algorithms: A survey. Computer Communications, 2024. https://www.sciencedirect.com/science/article/abs/pii/S0140366424002214
|
||||
- Understanding CSI (Tsinghua Tutorial). https://tns.thss.tsinghua.edu.cn/wst/docs/pre/
|
||||
|
||||
### Physics-Informed Neural Networks for RF
|
||||
- PINN and GNN-based RF Map Construction. arXiv 2507.22513
|
||||
- Physics-Informed Neural Networks for Wireless Channel Estimation. NeurIPS 2025, OpenReview r3plaU6DvW
|
||||
- ReVeal: High-Fidelity Radio Propagation. DySPAN 2025. https://wici.iastate.edu/wp-content/uploads/2025/03/ReVeal-DySPAN25.pdf
|
||||
- Physics-informed generative model for passive RF sensing. Savazzi et al., arXiv 2310.04173
|
||||
- Multi-Modal Foundational Model for Wireless Communication and Sensing. arXiv 2602.04016
|
||||
- Generative AI Meets Wireless Sensing: Towards Wireless Foundation Model. arXiv 2509.15258
|
||||
- Physics-Informed Neural Networks for Sensing Radio Spectrum. IJRTE v14i3, 2025
|
||||
|
||||
### Inverse Scattering and Body Reconstruction
|
||||
- DensePose From WiFi. Geng, Huang, De la Torre. arXiv 2301.00250
|
||||
- Through-Wall Human Pose Estimation Using Radio Signals. Zhao et al., CVPR 2018. https://rfpose.csail.mit.edu/
|
||||
- Person-in-WiFi: Fine-grained Person Perception. Wang et al., ICCV 2019
|
||||
- 3D Human Pose Estimation for Free-from Activities Using WiFi. arXiv 2204.07878
|
||||
- EM-POSE: 3D Human Pose from Sparse Electromagnetic Trackers. ICCV 2021
|
||||
- Reconciling Radio Tomographic Imaging with Phaseless Inverse Scattering. Dubey, Li, Murch. arXiv 2311.09633
|
||||
- Accurate Indoor RF Imaging using Extended Rytov Approximation. Dubey et al., arXiv 2110.03211
|
||||
- Phaseless Extended Rytov Approximation for Strongly Scattering Low-Loss Media. IEEE, 2022. https://ieeexplore.ieee.org/document/9766313/
|
||||
- Distorted Wave Extended Phaseless Rytov Iterative Method. arXiv 2205.12578
|
||||
- 3D Full Convolution Electromagnetic Reconstruction Neural Network (3D-FCERNN). PMC 9689780
|
||||
|
||||
### Radio Tomographic Imaging
|
||||
- Radio Tomographic Imaging with Wireless Networks. Wilson & Patwari, 2010. https://span.ece.utah.edu/uploads/RTI_version_3.pdf
|
||||
- Compressive Sensing Based Radio Tomographic Imaging with Spatial Diversity. PMC 6386865
|
||||
- Passive Localization Based on Radio Tomography Images with CNN. Nature Scientific Reports, 2025
|
||||
- Enhancing Accuracy of WiFi Tomographic Imaging Using Human-Interference Model. 2018
|
||||
|
||||
### Fresnel Zone Models
|
||||
- WiFi CSI-based device-free sensing: from Fresnel zone model to CSI-ratio model. CCF Trans. Pervasive Computing, 2021. https://link.springer.com/article/10.1007/s42486-021-00077-z
|
||||
- Towards a Dynamic Fresnel Zone Model for WiFi-based Human Activity Recognition. ACM IMWUT, 2023. https://dl.acm.org/doi/10.1145/3596270
|
||||
- CSI-based human sensing using model-based approaches: a survey. JCDE, 2021. https://academic.oup.com/jcde/article/8/2/510/6137731
|
||||
|
||||
### Computational Electromagnetics
|
||||
- Using Large-Scale FDTD for Indoor WLAN. ResearchGate. https://www.researchgate.net/publication/42637096
|
||||
- Human Body Shadowing -- FDTD and UTD. Hindawi IJAP, 2018. https://www.hindawi.com/journals/ijap/2018/9084830/
|
||||
- FDTD Assessment Human Exposure WiFi/Bluetooth. ResearchGate. https://www.researchgate.net/publication/23400115
|
||||
- Simulation of Wireless LAN Indoor Propagation Using FDTD. IEEE, 2007. https://ieeexplore.ieee.org/document/4396450
|
||||
- Waveguide Models of Indoor Channels: FDTD Insights. ResearchGate. https://www.researchgate.net/publication/4368711
|
||||
- XFdtd 3D EM Simulation Software. Remcom. https://www.remcom.com/xfdtd-3d-em-simulation-software
|
||||
- Wireless InSite Ray Tracing. Remcom. https://www.remcom.com/wireless-insite-em-propagation-software/
|
||||
|
||||
### Arena Physica
|
||||
- Introducing Atlas RF Studio. https://www.arenaphysica.com/publications/rf-studio
|
||||
- Electromagnetism Secretly Runs the World. Not Boring (Packy McCormick). https://www.notboring.co/p/electromagnetism-secretly-runs-the
|
||||
- Arena Launches Atlas (Press Release). https://www.prnewswire.com/news-releases/arena-launches-atlas-to-accelerate-humanitys-rate-of-hardware-innovation-302423412.html
|
||||
- Arena AI raises $30M. SiliconANGLE. https://siliconangle.com/2025/04/08/arena-ai-raises-30m-accelerate-innovation-hardware-testing-atlas/
|
||||
- Artificial Intuition: Building an AI Mind for EM Design. CDFAM NYC 2025. https://www.designforam.com/p/artificial-intuition-building-an
|
||||
|
||||
### Holographic / Advanced
|
||||
- HoloCSI: Holographic tomography pipeline with physics-guided projection and sparse transformer. 2025-2026
|
||||
- CSI-Bench: Large-Scale In-the-Wild Dataset for Multi-task WiFi Sensing. arXiv 2505.21866
|
||||
- RFBoost: Understanding and Boosting Deep WiFi Sensing via Physical Data Augmentation. arXiv 2410.07230
|
||||
- Vision Reimagined: AI-Powered Breakthroughs in WiFi Indoor Imaging. arXiv 2401.04317
|
||||
- Electromagnetic Information Theory for 6G. arXiv 2401.08921
|
||||
@@ -0,0 +1,341 @@
|
||||
# SOTA WiFi Sensing for Edge Pose Estimation (2024-2026 Update)
|
||||
|
||||
**Date:** 2026-04-02
|
||||
**Focus:** New architectures, lightweight models, edge deployment, ESP32+Pi Zero inference
|
||||
**Complements:** `wifi-sensing-ruvector-sota-2026.md` (February 2026 survey)
|
||||
|
||||
---
|
||||
|
||||
## 1. New Architectures Since Last Survey
|
||||
|
||||
### 1.1 WiFlow: Lightweight Continuous Pose Estimation (February 2026)
|
||||
|
||||
**Paper:** WiFlow: A Lightweight WiFi-based Continuous Human Pose Estimation Network with Spatio-Temporal Feature Decoupling ([arXiv:2602.08661](https://arxiv.org/html/2602.08661))
|
||||
|
||||
WiFlow is the most directly relevant architecture for our ESP32 + Pi Zero deployment target.
|
||||
|
||||
#### Architecture
|
||||
|
||||
Three-stage encoder-decoder with spatio-temporal decoupling:
|
||||
|
||||
**Stage 1: Temporal Encoder (TCN)**
|
||||
- Dilated causal convolution with exponentially growing dilation factors (1, 2, 4, 8)
|
||||
- Input: 540x20 tensor (18 antenna links x 30 subcarriers = 540 features, 20 time steps)
|
||||
- Progressive channel compression: 540 -> 440 -> 340 -> 240
|
||||
- Preserves temporal causality while achieving full receptive field coverage
|
||||
|
||||
**Stage 2: Spatial Encoder (Asymmetric Convolution)**
|
||||
- 1xk kernels operating only in the subcarrier dimension
|
||||
- 4 residual blocks: 8 -> 16 -> 32 -> 64 channels
|
||||
- Subcarrier compression: 240 -> 120 -> 60 -> 30 -> 15
|
||||
- Stride (1,2) downsampling -- no pooling layers
|
||||
|
||||
**Stage 3: Axial Self-Attention**
|
||||
- Two-stage axial attention reduces complexity from O(H^2 W^2) to O(H^2 W + HW^2)
|
||||
- Stage one: width direction (temporal axis), 8 groups
|
||||
- Stage two: height direction (keypoint axis)
|
||||
- Input reshaped to (B x K) x C x T for first stage
|
||||
|
||||
**Decoder:**
|
||||
- Adaptive average pooling instead of fully connected layers
|
||||
- Direct coordinate regression to 2D keypoint positions
|
||||
|
||||
#### Key Metrics
|
||||
|
||||
| Metric | WiFlow | WPformer | WiSPPN |
|
||||
|--------|--------|----------|--------|
|
||||
| Parameters | **4.82M** | 10.04M | 121.5M |
|
||||
| FLOPs | **0.47B** | 35.00B | 338.45B |
|
||||
| PCK@20 (random split) | **97.00%** | 70.02% | 85.87% |
|
||||
| MPJPE (random split) | **0.008m** | 0.028m | 0.016m |
|
||||
| PCK@20 (cross-subject) | **86.89%** | -- | -- |
|
||||
| Training time (5-fold) | **18.17h** | 137.5h | -- |
|
||||
|
||||
**Critical observations for our project:**
|
||||
- 4.82M parameters at INT8 quantization = ~4.8 MB model size -- fits in Pi Zero 2 W RAM (512 MB)
|
||||
- 0.47B FLOPs suggests ~50ms inference on Cortex-A53 with NEON SIMD (estimated)
|
||||
- Only uses amplitude, discards phase (phase is "heavily corrupted by CFO and SFO in commercial WiFi devices")
|
||||
- ESP32-S3 CSI has similar CFO/SFO issues, so amplitude-only approach is pragmatic
|
||||
|
||||
**Loss function:**
|
||||
```
|
||||
L = L_H + lambda * L_B
|
||||
L_H = SmoothL1(predicted_keypoints, ground_truth, beta=0.1)
|
||||
L_B = sum of bone length constraint violations across 14 bone connections
|
||||
lambda = 0.2
|
||||
```
|
||||
|
||||
The bone constraint loss is particularly important for edge deployment where noisy predictions need physical plausibility enforcement.
|
||||
|
||||
#### Adaptation for ESP32 + Pi Zero
|
||||
|
||||
WiFlow's architecture maps well to our hardware:
|
||||
- TCN runs on ESP32 (temporal feature extraction from raw CSI stream)
|
||||
- Asymmetric conv + axial attention runs on Pi Zero (spatial encoding + pose regression)
|
||||
- The 540-dimensional input assumes Intel 5300 NIC (18 links x 30 subcarriers); for ESP32-S3 with 1 TX x 1 RX and 52 subcarriers, input dimension is 52x20 = 1040 -- even smaller
|
||||
|
||||
### 1.2 MultiFormer: Multi-Person WiFi Pose (May 2025)
|
||||
|
||||
**Paper:** MultiFormer: A Multi-Person Pose Estimation System Based on CSI and Attention Mechanism ([arXiv:2505.22555](https://arxiv.org/html/2505.22555v1))
|
||||
|
||||
#### Architecture
|
||||
|
||||
Teacher-student framework with OpenPose teacher providing ground truth labels.
|
||||
|
||||
**Time-Frequency Dual-Dimensional Tokenization (TFDDT):**
|
||||
- Input: CSI matrix from 1 TX, 3 RX, 30 subcarriers
|
||||
- Upsampled via zero-insertion + low-pass filtering to 64x3x64
|
||||
- Two parallel token streams:
|
||||
- Frequency tokens F_j: N_S tokens of length M x N_R (subcarrier-centric view)
|
||||
- Temporal tokens T_i: M tokens of length N_S x N_R (time-centric view)
|
||||
|
||||
**Dual Transformer Encoder:**
|
||||
- 8 layers per branch (frequency and temporal)
|
||||
- Multi-head self-attention: MSA(X) = (1/H) * sum(Softmax(QK^T / sqrt(d_k)) V)
|
||||
- Each branch followed by FFN with ReLU, dropout, residual connections
|
||||
|
||||
**Multi-Stage Pose Estimation:**
|
||||
- Part Confidence Maps (PCM): 19x36x36 heatmaps (18 keypoints + average)
|
||||
- Part Affinity Fields (PAF): 38x36x36 directional fields for 19 limb connections
|
||||
- Pose-Attentive Perception Module (PAPM): channel + spatial attention on PCM/PAF
|
||||
- Multi-person assignment via Hungarian algorithm on PAF integrals
|
||||
|
||||
#### Model Variants
|
||||
|
||||
| Variant | Encoder Layers | Input | Parameters |
|
||||
|---------|---------------|-------|------------|
|
||||
| MultiFormer | 8 | 64x1296 | 11.93M |
|
||||
| MultiFormer-24 | 8 | 64x576 | 4.05M |
|
||||
| MultiFormer-18 | 6 | 64x324 | **2.80M** |
|
||||
|
||||
**Key result on MM-Fi dataset:** MultiFormer achieves PCK@20 of 0.7225, outperforming CSI2Pose (0.6841). The compact MultiFormer-18 at 2.80M parameters is edge-deployable.
|
||||
|
||||
#### Relevance to Our Project
|
||||
|
||||
MultiFormer's dual-token approach is valuable because:
|
||||
1. It explicitly separates temporal and frequency information (like WiFlow's decoupling)
|
||||
2. The PAF-based multi-person assignment using Hungarian algorithm can run on Pi Zero
|
||||
3. The 2.80M parameter variant (MultiFormer-18) at INT8 = ~2.8 MB, well within Pi Zero constraints
|
||||
|
||||
### 1.3 Person-in-WiFi 3D (CVPR 2024)
|
||||
|
||||
**Paper:** Person-in-WiFi 3D: End-to-End Multi-Person 3D Pose Estimation with Wi-Fi (CVPR 2024)
|
||||
|
||||
First multi-person 3D WiFi pose estimation.
|
||||
|
||||
**Key results:**
|
||||
- Single person MPJPE: 91.7mm
|
||||
- Two persons: 108.1mm
|
||||
- Three persons: 125.3mm
|
||||
- Dataset: 97K frames, 4m x 3.5m area, 7 volunteers
|
||||
- Transformer-based end-to-end architecture
|
||||
|
||||
**Relevance:** Establishes the accuracy ceiling for WiFi 3D pose. Our ESP32+Pi system should target comparable single-person performance (sub-100mm MPJPE) as a milestone.
|
||||
|
||||
### 1.4 Spatio-Temporal 3D Point Clouds from WiFi-CSI (October 2024)
|
||||
|
||||
**Paper:** [arXiv:2410.16303](https://arxiv.org/html/2410.16303v1)
|
||||
|
||||
Novel approach: generates 3D point clouds from WiFi CSI data using transformer networks.
|
||||
|
||||
**Key innovation:** Positional encoding with learned embeddings for antennas and subcarriers, followed by multi-head attention over antenna-subcarrier pairs. This captures both spatial (antenna geometry) and spectral (subcarrier frequency response) dependencies.
|
||||
|
||||
**Relevance:** Point cloud output is a richer representation than keypoints alone, enabling:
|
||||
- Silhouette estimation for activity recognition
|
||||
- Body volume estimation for person identification
|
||||
- Occlusion reasoning when fused with multiple viewpoints
|
||||
|
||||
### 1.5 Graph-Based 3D Human Pose from WiFi (November 2025)
|
||||
|
||||
**Paper:** Graph-based 3D Human Pose Estimation using WiFi Signals ([arXiv:2511.19105](https://arxiv.org/html/2511.19105))
|
||||
|
||||
Uses graph neural networks where nodes represent keypoints and edges represent skeletal connections. CSI features are injected as node/edge attributes.
|
||||
|
||||
**Relevance:** Graph structure naturally maps to our RuvSense pose_tracker which already maintains a 17-keypoint skeleton with Kalman filtering. Adding graph-based message passing between keypoints could improve joint prediction coherence.
|
||||
|
||||
## 2. Edge Deployment Landscape
|
||||
|
||||
### 2.1 CSI-Sense-Zero: ESP32 + Pi Zero Reference Implementation
|
||||
|
||||
**Repository:** [github.com/winwinashwin/CSI-Sense-Zero](https://github.com/winwinashwin/CSI-Sense-Zero)
|
||||
|
||||
The most directly relevant prior art for our hardware target.
|
||||
|
||||
**Architecture:**
|
||||
- Two ESP32-WROOM-32: one TX, one RX (captures CSI)
|
||||
- Pi Zero: inference node
|
||||
- Communication: USB serial at 921,600 baud
|
||||
- Buffer: 235KB FIFO at `/tmp/csififo` (~256 CSI records)
|
||||
- Inference rate: 2 Hz (configurable)
|
||||
- WebSocket output for real-time visualization
|
||||
|
||||
**Data flow:**
|
||||
```
|
||||
ESP32 TX -> WiFi signal -> ESP32 RX -> Serial (921.6 kbaud) -> Pi Zero FIFO -> Model -> WebSocket
|
||||
```
|
||||
|
||||
**Limitations:**
|
||||
- Original Pi Zero (single-core ARM11) -- very slow inference
|
||||
- Activity recognition only (not pose estimation)
|
||||
- Python inference (not optimized for ARM)
|
||||
|
||||
**What we improve:**
|
||||
- Pi Zero 2 W has quad-core Cortex-A53 -- roughly 5-10x faster than Pi Zero
|
||||
- Rust inference (ONNX/Candle) vs Python -- 3-10x faster
|
||||
- ESP32-S3 vs ESP32-WROOM-32 -- better CSI quality, more subcarriers
|
||||
- Pose estimation instead of just activity classification
|
||||
- UDP transport instead of USB serial -- supports multi-node mesh
|
||||
|
||||
### 2.2 OnnxStream: Lightweight ONNX on Pi Zero 2 W
|
||||
|
||||
**Repository:** [github.com/vitoplantamura/OnnxStream](https://github.com/vitoplantamura/OnnxStream)
|
||||
|
||||
Runs Stable Diffusion XL on Pi Zero 2 W in 298 MB RAM. Key features:
|
||||
- C++ implementation, XNNPACK acceleration
|
||||
- ARM NEON SIMD optimization
|
||||
- Memory-efficient streaming execution (processes one operator at a time)
|
||||
- Supports INT8 quantization
|
||||
|
||||
**Benchmark estimates for our model sizes:**
|
||||
|
||||
| Model | Parameters | INT8 Size | Est. Pi Zero 2 Latency |
|
||||
|-------|-----------|-----------|----------------------|
|
||||
| MultiFormer-18 | 2.80M | ~2.8 MB | ~30-50ms |
|
||||
| WiFlow | 4.82M | ~4.8 MB | ~50-80ms |
|
||||
| MultiFormer | 11.93M | ~11.9 MB | ~120-200ms |
|
||||
| DensePose-WiFi | ~25M (est.) | ~25 MB | ~300-500ms |
|
||||
|
||||
These estimates assume XNNPACK-accelerated INT8 inference on Cortex-A53 @ 1 GHz. The WiFlow and MultiFormer-18 models can achieve 12-20 Hz inference, matching our 20 Hz TDMA cycle target.
|
||||
|
||||
### 2.3 ONNX Runtime on ARM
|
||||
|
||||
ONNX Runtime officially supports Raspberry Pi deployment with:
|
||||
- ARM NEON execution provider
|
||||
- INT8 quantization support
|
||||
- Python and C++ APIs
|
||||
- Model optimization tools (graph optimization, operator fusion)
|
||||
|
||||
For Rust integration, the `ort` crate (ONNX Runtime Rust bindings) supports cross-compilation to aarch64-linux-gnu.
|
||||
|
||||
### 2.4 EfficientFi: CSI Compression for Edge
|
||||
|
||||
**Paper:** EfficientFi: Towards Large-Scale Lightweight WiFi Sensing via CSI Compression ([arXiv:2204.04138](https://arxiv.org/pdf/2204.04138))
|
||||
|
||||
Proposes compressing CSI data on the sensing device before transmission to the inference node. Key idea: train a CSI autoencoder where the encoder runs on the constrained device and the decoder runs on the more powerful inference node.
|
||||
|
||||
**Relevance:** For our ESP32 -> Pi Zero pipeline, CSI compression on ESP32 reduces:
|
||||
- UDP packet size (lower bandwidth, less packet loss)
|
||||
- Pi Zero preprocessing time (compressed features are more compact)
|
||||
- Effective latency (less data to transmit per frame)
|
||||
|
||||
## 3. Comparative Analysis: Architecture Selection for ESP32 + Pi Zero
|
||||
|
||||
### 3.1 Decision Matrix
|
||||
|
||||
| Criterion | WiFlow | MultiFormer-18 | DensePose-WiFi | Graph-3D |
|
||||
|-----------|--------|----------------|----------------|----------|
|
||||
| Parameters | 4.82M | 2.80M | ~25M | ~8M (est.) |
|
||||
| FLOPs | 0.47B | ~0.3B (est.) | ~5B (est.) | ~1B (est.) |
|
||||
| Multi-person | No | Yes (PAF+Hungarian) | Yes (RCNN-based) | No |
|
||||
| 3D output | No (2D) | No (2D) | No (UV map) | Yes (3D) |
|
||||
| Amplitude-only | Yes | Yes | No (amp+phase) | Unknown |
|
||||
| Edge-viable | Yes | Yes | No | Marginal |
|
||||
| Open source | Not yet | Not yet | Limited | Not yet |
|
||||
|
||||
### 3.2 Recommended Architecture: Hybrid WiFlow + MultiFormer
|
||||
|
||||
For the ESP32 + Pi Zero deployment, we recommend a hybrid architecture:
|
||||
|
||||
1. **WiFlow's TCN temporal encoder** on ESP32 -- extract temporal features from raw CSI
|
||||
2. **MultiFormer's dual-token approach** on Pi Zero -- process both frequency and temporal views
|
||||
3. **WiFlow's bone constraint loss** during training -- enforce physical skeleton plausibility
|
||||
4. **RuvSense coherence gating** before inference -- reject low-quality CSI frames
|
||||
|
||||
This hybrid achieves:
|
||||
- ~3-5M parameters (between WiFlow and MultiFormer-18)
|
||||
- Amplitude-only input (robust to ESP32 CFO/SFO)
|
||||
- Sub-100ms inference on Pi Zero 2 W
|
||||
- Optional multi-person support via PAF module
|
||||
|
||||
### 3.3 Training Data Strategy
|
||||
|
||||
Based on the surveyed papers:
|
||||
|
||||
| Dataset | Subjects | Frames | Hardware | Availability |
|
||||
|---------|----------|--------|----------|--------------|
|
||||
| CMU DensePose-WiFi | 8 | ~250K | Intel 5300 | Limited |
|
||||
| Person-in-WiFi 3D | 7 | 97K | Custom WiFi | GitHub |
|
||||
| MM-Fi | Multiple | Large | WiFi + mmWave | Public |
|
||||
| Wi-Pose | Multiple | Large | Intel 5300 | Public |
|
||||
|
||||
**Our approach:**
|
||||
1. Pre-train on MM-Fi/Wi-Pose public datasets (Intel 5300 CSI format)
|
||||
2. Apply domain adaptation for ESP32-S3 CSI format (different subcarrier count, CFO characteristics)
|
||||
3. Fine-tune on self-collected ESP32-S3 data in target environments
|
||||
4. Augment with synthetic CSI from ray-tracing forward model (Arena Physica insight)
|
||||
|
||||
## 4. Gap Analysis: Current wifi-densepose vs SOTA
|
||||
|
||||
### 4.1 What We Have
|
||||
|
||||
| Capability | Status | Module |
|
||||
|-----------|--------|--------|
|
||||
| ESP32 CSI capture | Production | `wifi-densepose-hardware` |
|
||||
| Multi-node fusion | Production | `ruvsense/multistatic.rs` |
|
||||
| Phase alignment | Production | `ruvsense/phase_align.rs` |
|
||||
| Coherence gating | Production | `ruvsense/coherence_gate.rs` |
|
||||
| 17-keypoint tracking | Production | `ruvsense/pose_tracker.rs` |
|
||||
| ONNX inference engine | Production | `wifi-densepose-nn` |
|
||||
| Modality translator | Production | `wifi-densepose-nn/translator.rs` |
|
||||
| Training pipeline | Production | `wifi-densepose-train` |
|
||||
| Subcarrier interpolation | Production | `wifi-densepose-train/subcarrier.rs` |
|
||||
|
||||
### 4.2 What We Are Missing
|
||||
|
||||
| Gap | Required For | Priority |
|
||||
|-----|-------------|----------|
|
||||
| **Pi Zero deployment target** | Edge inference node | Critical |
|
||||
| **Lightweight model architecture** | Sub-100ms inference on Cortex-A53 | Critical |
|
||||
| **Temporal causal convolution** | Real-time streaming inference | High |
|
||||
| **Axial attention module** | Efficient spatial encoding | High |
|
||||
| **Bone constraint loss** | Physical plausibility | High |
|
||||
| **CSI compression on ESP32** | Bandwidth reduction | Medium |
|
||||
| **INT8 quantization pipeline** | Model size reduction | Medium |
|
||||
| **Cross-environment adaptation** | Deployment generalization | Medium |
|
||||
| **Multi-person PAF decoding** | Multiple subject support | Low (Phase 2) |
|
||||
| **3D pose lifting** | Z-axis estimation | Low (Phase 3) |
|
||||
| **Diffusion-based pose refinement** | Uncertainty quantification | Research |
|
||||
|
||||
### 4.3 Architecture Gaps in Detail
|
||||
|
||||
**1. No lightweight inference path.** The current `wifi-densepose-nn` crate assumes GPU or high-end CPU inference. We need an `EdgeInferenceEngine` optimized for:
|
||||
- INT8 ONNX models
|
||||
- ARM NEON SIMD via XNNPACK
|
||||
- Streaming inference (process CSI frames as they arrive, not in batches)
|
||||
- Memory-mapped model loading (avoid loading entire model into RAM)
|
||||
|
||||
**2. No ESP32 -> Pi Zero communication protocol.** The `wifi-densepose-hardware` crate handles ESP32 CSI capture and UDP aggregation to a server, but has no lightweight protocol for ESP32 -> Pi Zero direct communication. We need:
|
||||
- Compact binary frame format (not the full ADR-018 format)
|
||||
- Optional CSI compression (autoencoder on ESP32 or simple PCA)
|
||||
- Heartbeat and synchronization for multi-ESP32 setups
|
||||
|
||||
**3. No temporal convolution module.** The existing signal processing pipeline uses frame-by-frame processing. WiFlow and MultiFormer both show that temporal context (20 frames for WiFlow, 64 frames for MultiFormer) significantly improves accuracy. We need a ring buffer + TCN module in the inference path.
|
||||
|
||||
**4. No bone/skeleton constraint enforcement at inference time.** The `pose_tracker.rs` has Kalman filtering and skeleton constraints, but these are post-hoc corrections. WiFlow shows that baking bone constraints into the loss function during training produces better models that need less post-processing.
|
||||
|
||||
## 5. References
|
||||
|
||||
1. DensePose From WiFi, Geng et al., arXiv:2301.00250, 2023
|
||||
2. Person-in-WiFi 3D, Yan et al., CVPR 2024
|
||||
3. WiFlow, arXiv:2602.08661, 2026
|
||||
4. MultiFormer, arXiv:2505.22555, 2025
|
||||
5. CSI-Channel Spatial Decomposition, MDPI Electronics 14(4), 2025
|
||||
6. CSI-Former, MDPI Entropy 25(1), 2023
|
||||
7. Spatio-Temporal 3D Point Clouds from WiFi-CSI, arXiv:2410.16303, 2024
|
||||
8. Graph-based 3D Human Pose from WiFi, arXiv:2511.19105, 2025
|
||||
9. EfficientFi, arXiv:2204.04138, 2022
|
||||
10. CSI-Sense-Zero, github.com/winwinashwin/CSI-Sense-Zero
|
||||
11. OnnxStream, github.com/vitoplantamura/OnnxStream
|
||||
12. Arena Physica, arenaphysica.com (Atlas RF Studio, Heaviside-0/Marconi-0)
|
||||
13. Tools and Methods for WiFi Sensing in Embedded Devices, MDPI Sensors 25(19), 2025
|
||||
14. Real-Time HAR using WiFi CSI and LSTM on Edge Devices, SASI-ITE 2025
|
||||
+73
-4
@@ -21,6 +21,7 @@ WiFi DensePose turns commodity WiFi signals into real-time human pose estimation
|
||||
- [Windows WiFi (RSSI Only)](#windows-wifi-rssi-only)
|
||||
- [ESP32-S3 (Full CSI)](#esp32-s3-full-csi)
|
||||
- [ESP32 Multistatic Mesh (Advanced)](#esp32-multistatic-mesh-advanced)
|
||||
- [Cognitum Seed Integration (ADR-069)](#cognitum-seed-integration-adr-069)
|
||||
5. [REST API Reference](#rest-api-reference)
|
||||
6. [WebSocket Streaming](#websocket-streaming)
|
||||
7. [Web UI](#web-ui)
|
||||
@@ -314,6 +315,72 @@ The mesh uses a **Time-Division Multiplexing (TDM)** protocol so nodes take turn
|
||||
|
||||
See [ADR-029](adr/ADR-029-ruvsense-multistatic-sensing-mode.md) and [ADR-032](adr/ADR-032-multistatic-mesh-security-hardening.md) for the full design.
|
||||
|
||||
### Cognitum Seed Integration (ADR-069)
|
||||
|
||||
Connect an ESP32-S3 to a [Cognitum Seed](https://cognitum.one) (Pi Zero 2 W, ~$15) for persistent vector storage, kNN similarity search, cryptographic witness chain, and AI-accessible sensing via MCP proxy.
|
||||
|
||||
**What the Seed adds:**
|
||||
- **RVF vector store** — Persistent 8-dim feature vectors with content-addressed IDs and kNN search (cosine, L2, dot product)
|
||||
- **Witness chain** — SHA-256 tamper-evident audit trail for every ingest operation
|
||||
- **Ed25519 custody** — Device-bound keypair for cryptographic attestation of sensing data
|
||||
- **Sensor fusion** — BME280 (temp/humidity/pressure), PIR motion, reed switch, 4-ch ADC provide environmental ground truth
|
||||
- **MCP proxy** — 114 tools via JSON-RPC 2.0 so AI assistants (Claude, GPT) can query sensing state directly
|
||||
- **Reflex rules** — Automatic alarm triggers based on fragility, drift, and anomaly thresholds
|
||||
|
||||
**Setup:**
|
||||
|
||||
```bash
|
||||
# 1. Plug in the Cognitum Seed via USB — appears as a network adapter at 169.254.42.1
|
||||
|
||||
# 2. Pair your client (opens a 30-second window, USB-only for security)
|
||||
curl -sk -X POST https://169.254.42.1:8443/api/v1/pair/window
|
||||
curl -sk -X POST https://169.254.42.1:8443/api/v1/pair \
|
||||
-H 'Content-Type: application/json' -d '{"client_name":"my-laptop"}'
|
||||
# Save the returned token — it is shown only once
|
||||
|
||||
# 3. Provision ESP32 to send features to your laptop (where the bridge runs)
|
||||
python firmware/esp32-csi-node/provision.py --port COM9 \
|
||||
--ssid "YourWiFi" --password "secret" \
|
||||
--target-ip 192.168.1.20 --target-port 5006 --node-id 1
|
||||
|
||||
# 4. Run the bridge (receives ESP32 UDP, ingests into Seed via HTTPS)
|
||||
export SEED_TOKEN="your-pairing-token"
|
||||
python scripts/seed_csi_bridge.py \
|
||||
--seed-url https://169.254.42.1:8443 --token "$SEED_TOKEN" \
|
||||
--udp-port 5006 --batch-size 10 --validate
|
||||
|
||||
# 5. Check Seed status
|
||||
python scripts/seed_csi_bridge.py --token "$SEED_TOKEN" --stats
|
||||
|
||||
# 6. Trigger compaction (reclaim disk space from deleted vectors)
|
||||
python scripts/seed_csi_bridge.py --token "$SEED_TOKEN" --compact
|
||||
```
|
||||
|
||||
**Feature vector dimensions (magic `0xC5110003`, 48 bytes, 1 Hz):**
|
||||
|
||||
| Dim | Feature | Range | Source |
|
||||
|-----|---------|-------|--------|
|
||||
| 0 | Presence score | 0.0–1.0 | `s_presence_score / 10.0` |
|
||||
| 1 | Motion energy | 0.0–1.0 | `s_motion_energy / 10.0` |
|
||||
| 2 | Breathing rate | 0.0–1.0 | `s_breathing_bpm / 30.0` |
|
||||
| 3 | Heart rate | 0.0–1.0 | `s_heartrate_bpm / 120.0` |
|
||||
| 4 | Phase variance | 0.0–1.0 | Mean Welford variance of top-K subcarriers |
|
||||
| 5 | Person count | 0.0–1.0 | Active persons / 4 |
|
||||
| 6 | Fall detected | 0.0 or 1.0 | Binary fall flag |
|
||||
| 7 | RSSI | 0.0–1.0 | `(rssi + 100) / 100` |
|
||||
|
||||
**Architecture:**
|
||||
|
||||
```
|
||||
ESP32-S3 ($9) ──UDP:5006──> Host (bridge) ──HTTPS──> Cognitum Seed ($15)
|
||||
CSI @ 100 Hz seed_csi_bridge.py RVF vector store
|
||||
Features @ 1 Hz Batches, validates kNN graph + boundary
|
||||
Vitals @ 1 Hz NaN rejection Witness chain
|
||||
Source IP filtering 114-tool MCP proxy
|
||||
```
|
||||
|
||||
See [ADR-069](adr/ADR-069-cognitum-seed-csi-pipeline.md) for the complete design, validation results, and security analysis.
|
||||
|
||||
---
|
||||
|
||||
## REST API Reference
|
||||
@@ -819,11 +886,13 @@ Pre-built binaries are available at [Releases](https://github.com/ruvnet/RuView/
|
||||
|
||||
| Release | What It Includes | Tag |
|
||||
|---------|-----------------|-----|
|
||||
| [v0.4.1](https://github.com/ruvnet/RuView/releases/tag/v0.4.1-esp32) | **Stable** — CSI build fix, compile guard, AMOLED display, edge intelligence ([ADR-057](../docs/adr/ADR-057-firmware-csi-build-guard.md)) | `v0.4.1-esp32` |
|
||||
| [v0.5.0](https://github.com/ruvnet/RuView/releases/tag/v0.5.0-esp32) | **Stable (recommended)** — mmWave sensor fusion (MR60BHA2/LD2410 auto-detect), 48-byte fused vitals, all v0.4.3.1 fixes | `v0.5.0-esp32` |
|
||||
| [v0.4.3.1](https://github.com/ruvnet/RuView/releases/tag/v0.4.3.1-esp32) | Fall detection fix ([#263](https://github.com/ruvnet/RuView/issues/263)), 4MB flash ([#265](https://github.com/ruvnet/RuView/issues/265)), watchdog fix ([#266](https://github.com/ruvnet/RuView/issues/266)) | `v0.4.3.1-esp32` |
|
||||
| [v0.4.1](https://github.com/ruvnet/RuView/releases/tag/v0.4.1-esp32) | CSI build fix, compile guard, AMOLED display, edge intelligence ([ADR-057](../docs/adr/ADR-057-firmware-csi-build-guard.md)) | `v0.4.1-esp32` |
|
||||
| [v0.3.0-alpha](https://github.com/ruvnet/RuView/releases/tag/v0.3.0-alpha-esp32) | Alpha — adds on-device edge intelligence (ADR-039) | `v0.3.0-alpha-esp32` |
|
||||
| [v0.2.0](https://github.com/ruvnet/RuView/releases/tag/v0.2.0-esp32) | Raw CSI streaming, TDM, channel hopping, QUIC mesh | `v0.2.0-esp32` |
|
||||
|
||||
> **Important:** Firmware versions prior to v0.4.1 had CSI **disabled** in the build config, causing a runtime error (`E wifi:CSI not enabled in menuconfig!`). Always use v0.4.1 or later.
|
||||
> **Important:** Always use **v0.4.3.1 or later**. Earlier versions have false fall detection alerts (v0.4.2 and below) and CSI disabled in the build config (pre-v0.4.1).
|
||||
|
||||
```bash
|
||||
# Flash an ESP32-S3 with 8MB flash (most boards)
|
||||
@@ -903,14 +972,14 @@ Key NVS settings for edge processing:
|
||||
|---------|---------|-----------------|
|
||||
| `edge_tier` | 0 | Processing tier (0=off, 1=stats, 2=vitals) |
|
||||
| `pres_thresh` | 50 | Sensitivity for presence detection (lower = more sensitive) |
|
||||
| `fall_thresh` | 500 | Fall detection threshold (variance spike trigger) |
|
||||
| `fall_thresh` | 15000 | Fall detection threshold in milli-units (15000 = 15.0 rad/s²). Normal walking is 2-5, real falls are 20+. Raise to reduce false positives. |
|
||||
| `vital_win` | 300 | How many frames of phase history to keep for breathing/HR extraction |
|
||||
| `vital_int` | 1000 | How often to send a vitals packet, in milliseconds |
|
||||
| `subk_count` | 32 | Number of best subcarriers to keep (out of 56) |
|
||||
|
||||
When Tier 2 is active, the node sends a 32-byte vitals packet at 1 Hz (configurable) containing presence state, motion score, breathing BPM, heart rate BPM, confidence values, fall flag, and occupancy estimate. The packet uses magic `0xC5110002` and is sent to the same aggregator IP and port as raw CSI frames.
|
||||
|
||||
Binary size: 777 KB (24% free in the 1 MB app partition).
|
||||
Binary size: 990 KB (8MB flash, 52% free) or 773 KB (4MB flash). v0.5.0 adds mmWave sensor fusion (~12 KB larger).
|
||||
|
||||
> **Alpha notice**: Vital sign estimation uses heuristic BPM extraction. Accuracy is best with stationary subjects in controlled environments. Not for medical use.
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# Examples
|
||||
|
||||
Real-time sensing applications built on the RuView platform.
|
||||
|
||||
## Unified Dashboard (start here)
|
||||
|
||||
```bash
|
||||
pip install pyserial numpy
|
||||
python examples/ruview_live.py --csi COM7 --mmwave COM4
|
||||
```
|
||||
|
||||
The live dashboard auto-detects available sensors and displays fused vitals, environment data, and events in real-time. Works with any combination of sensors.
|
||||
|
||||
## Individual Examples
|
||||
|
||||
| Example | Sensors | What It Does |
|
||||
|---------|---------|-------------|
|
||||
| [**ruview_live.py**](ruview_live.py) | CSI + mmWave + Light | Unified dashboard: HR, BR, BP, stress, presence, light, RSSI |
|
||||
| [Medical: Blood Pressure](medical/) | mmWave | Contactless BP estimation from HRV |
|
||||
| [Medical: Vitals Suite](medical/vitals_suite.py) | mmWave | 10-in-1: HR, BR, BP, HRV, sleep stages, apnea, cough, snoring, activity, meditation |
|
||||
| [Sleep: Apnea Screener](sleep/) | mmWave | Detects breathing cessation events, computes AHI |
|
||||
| [Stress: HRV Monitor](stress/) | mmWave | Real-time stress level from heart rate variability |
|
||||
| [Environment: Room Monitor](environment/) | CSI + mmWave | Occupancy, light, RF fingerprint, activity events |
|
||||
|
||||
## Hardware
|
||||
|
||||
| Port | Device | Cost | What It Provides |
|
||||
|------|--------|------|-----------------|
|
||||
| COM7 | ESP32-S3 (WiFi CSI) | ~$9 | Presence, motion, breathing, heart rate (through walls) |
|
||||
| COM4 | ESP32-C6 + Seeed MR60BHA2 | ~$15 | Precise HR/BR, presence, distance, ambient light |
|
||||
|
||||
Either sensor works alone. Both together enable fusion (mmWave 80% + CSI 20%).
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
pip install pyserial numpy
|
||||
|
||||
# Unified dashboard (recommended)
|
||||
python examples/ruview_live.py --csi COM7 --mmwave COM4
|
||||
|
||||
# Blood pressure estimation
|
||||
python examples/medical/bp_estimator.py --port COM4
|
||||
|
||||
# Sleep apnea screening (run overnight)
|
||||
python examples/sleep/apnea_screener.py --port COM4 --duration 28800
|
||||
|
||||
# Stress monitoring (workday session)
|
||||
python examples/stress/hrv_stress_monitor.py --port COM4 --duration 3600
|
||||
|
||||
# Room environment monitor
|
||||
python examples/environment/room_monitor.py --csi-port COM7 --mmwave-port COM4
|
||||
|
||||
# CSI only (no mmWave)
|
||||
python examples/ruview_live.py --csi COM7 --mmwave none
|
||||
```
|
||||
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Room Environment Monitor — WiFi CSI + mmWave + Light Sensor Fusion
|
||||
|
||||
Combines all available sensors to build a real-time room awareness picture:
|
||||
- WiFi CSI (COM7): Presence, motion energy, room RF fingerprint
|
||||
- mmWave (COM4): Occupancy count, distance, HR/BR of nearest person
|
||||
- BH1750 (COM4): Ambient light level
|
||||
|
||||
Detects: occupancy changes, lighting anomalies, activity patterns,
|
||||
room RF fingerprint drift (door/window state changes).
|
||||
|
||||
Usage:
|
||||
python examples/environment/room_monitor.py --csi-port COM7 --mmwave-port COM4
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import math
|
||||
import re
|
||||
import serial
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
RE_HR = re.compile(r"'Real-time heart rate'.*?(\d+\.?\d*)\s*bpm", re.IGNORECASE)
|
||||
RE_BR = re.compile(r"'Real-time respiratory rate'.*?(\d+\.?\d*)", re.IGNORECASE)
|
||||
RE_PRES = re.compile(r"'Person Information'.*?state\s+(ON|OFF)", re.IGNORECASE)
|
||||
RE_DIST = re.compile(r"'Distance to detection object'.*?(\d+\.?\d*)\s*cm", re.IGNORECASE)
|
||||
RE_LUX = re.compile(r"'Seeed MR60BHA2 Illuminance'.*?(\d+\.?\d*)\s*lx", re.IGNORECASE)
|
||||
RE_TARGETS = re.compile(r"'Target Number'.*?(\d+\.?\d*)", re.IGNORECASE)
|
||||
RE_CSI_CB = re.compile(r"CSI cb #(\d+).*?len=(\d+).*?rssi=(-?\d+)")
|
||||
RE_ANSI = re.compile(r"\x1b\[[0-9;]*m")
|
||||
|
||||
# Light categories
|
||||
def light_category(lux):
|
||||
if lux < 1: return "Dark"
|
||||
if lux < 10: return "Dim"
|
||||
if lux < 50: return "Low"
|
||||
if lux < 200: return "Normal"
|
||||
if lux < 500: return "Bright"
|
||||
return "Very Bright"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Room Environment Monitor")
|
||||
parser.add_argument("--csi-port", default="COM7")
|
||||
parser.add_argument("--mmwave-port", default="COM4")
|
||||
parser.add_argument("--duration", type=int, default=120)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Shared state
|
||||
state = {
|
||||
"hr": 0.0, "br": 0.0, "presence_mw": False, "distance": 0.0,
|
||||
"lux": 0.0, "targets": 0, "rssi": 0, "csi_frames": 0,
|
||||
"mw_frames": 0, "events": [],
|
||||
}
|
||||
rssi_history = collections.deque(maxlen=60)
|
||||
lux_history = collections.deque(maxlen=60)
|
||||
lock = threading.Lock()
|
||||
stop = threading.Event()
|
||||
|
||||
def read_mmwave():
|
||||
try:
|
||||
ser = serial.Serial(args.mmwave_port, 115200, timeout=1)
|
||||
except Exception:
|
||||
return
|
||||
while not stop.is_set():
|
||||
line = ser.readline().decode("utf-8", errors="replace")
|
||||
clean = RE_ANSI.sub("", line)
|
||||
with lock:
|
||||
m = RE_HR.search(clean)
|
||||
if m: state["hr"] = float(m.group(1)); state["mw_frames"] += 1
|
||||
m = RE_BR.search(clean)
|
||||
if m: state["br"] = float(m.group(1))
|
||||
m = RE_PRES.search(clean)
|
||||
if m:
|
||||
new_pres = m.group(1) == "ON"
|
||||
if new_pres != state["presence_mw"]:
|
||||
event = f"Person {'arrived' if new_pres else 'left'} (mmWave)"
|
||||
state["events"].append((time.time(), event))
|
||||
state["presence_mw"] = new_pres
|
||||
m = RE_DIST.search(clean)
|
||||
if m: state["distance"] = float(m.group(1))
|
||||
m = RE_LUX.search(clean)
|
||||
if m:
|
||||
lux = float(m.group(1))
|
||||
old_cat = light_category(state["lux"])
|
||||
new_cat = light_category(lux)
|
||||
if old_cat != new_cat and state["lux"] > 0:
|
||||
state["events"].append((time.time(), f"Light: {old_cat} -> {new_cat} ({lux:.1f} lx)"))
|
||||
state["lux"] = lux
|
||||
lux_history.append(lux)
|
||||
m = RE_TARGETS.search(clean)
|
||||
if m: state["targets"] = int(float(m.group(1)))
|
||||
ser.close()
|
||||
|
||||
def read_csi():
|
||||
try:
|
||||
ser = serial.Serial(args.csi_port, 115200, timeout=1)
|
||||
except Exception:
|
||||
return
|
||||
while not stop.is_set():
|
||||
line = ser.readline().decode("utf-8", errors="replace")
|
||||
m = RE_CSI_CB.search(line)
|
||||
if m:
|
||||
with lock:
|
||||
state["csi_frames"] = int(m.group(1))
|
||||
state["rssi"] = int(m.group(3))
|
||||
rssi_history.append(int(m.group(3)))
|
||||
ser.close()
|
||||
|
||||
t1 = threading.Thread(target=read_mmwave, daemon=True)
|
||||
t2 = threading.Thread(target=read_csi, daemon=True)
|
||||
t1.start()
|
||||
t2.start()
|
||||
|
||||
print()
|
||||
print("=" * 70)
|
||||
print(" Room Environment Monitor (WiFi CSI + mmWave + Light)")
|
||||
print("=" * 70)
|
||||
print()
|
||||
|
||||
start_time = time.time()
|
||||
last_print = 0
|
||||
|
||||
try:
|
||||
while time.time() - start_time < args.duration:
|
||||
time.sleep(1)
|
||||
elapsed = int(time.time() - start_time)
|
||||
if elapsed <= last_print or elapsed % 5 != 0:
|
||||
continue
|
||||
last_print = elapsed
|
||||
|
||||
with lock:
|
||||
s = dict(state)
|
||||
events = list(state["events"][-3:])
|
||||
|
||||
# RSSI stability (RF fingerprint drift)
|
||||
rssi_std = 0
|
||||
if len(rssi_history) >= 5:
|
||||
vals = list(rssi_history)
|
||||
mean = sum(vals) / len(vals)
|
||||
rssi_std = math.sqrt(sum((x - mean)**2 for x in vals) / len(vals))
|
||||
|
||||
rf_status = "Stable" if rssi_std < 3 else "Shifting" if rssi_std < 6 else "Volatile"
|
||||
|
||||
pres = "YES" if s["presence_mw"] else "no"
|
||||
lcat = light_category(s["lux"])
|
||||
|
||||
print(f" {elapsed:>4}s | Pres:{pres:>3} Dist:{s['distance']:>4.0f}cm | "
|
||||
f"HR:{s['hr']:>3.0f} BR:{s['br']:>2.0f} | "
|
||||
f"Light:{s['lux']:>5.1f}lx ({lcat:<6}) | "
|
||||
f"RSSI:{s['rssi']:>3}dBm RF:{rf_status:<8} | "
|
||||
f"CSI:{s['csi_frames']} MW:{s['mw_frames']}")
|
||||
|
||||
for ts, event in events:
|
||||
age = elapsed - int(ts - start_time)
|
||||
if age < 10:
|
||||
print(f" ** EVENT: {event}")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
stop.set()
|
||||
time.sleep(1)
|
||||
|
||||
print()
|
||||
print("=" * 70)
|
||||
print(" ROOM SUMMARY")
|
||||
print("=" * 70)
|
||||
with lock:
|
||||
print(f" Duration: {time.time()-start_time:.0f}s")
|
||||
print(f" CSI frames: {state['csi_frames']}")
|
||||
print(f" mmWave data: {state['mw_frames']} readings")
|
||||
print(f" Last HR: {state['hr']:.0f} bpm")
|
||||
print(f" Last BR: {state['br']:.0f}/min")
|
||||
print(f" Light: {state['lux']:.1f} lux ({light_category(state['lux'])})")
|
||||
if lux_history:
|
||||
print(f" Light range: {min(lux_history):.1f} - {max(lux_history):.1f} lux")
|
||||
if rssi_history:
|
||||
print(f" RSSI range: {min(rssi_history)} to {max(rssi_history)} dBm (std={rssi_std:.1f})")
|
||||
print(f" Events: {len(state['events'])}")
|
||||
for ts, event in state["events"]:
|
||||
print(f" [{int(ts-start_time):>4}s] {event}")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,206 @@
|
||||
# Happiness Vector — WiFi CSI Guest Sentiment Sensing
|
||||
|
||||
Contactless hotel guest happiness scoring using WiFi Channel State Information (CSI) from ESP32-S3 nodes, coordinated by a Cognitum Seed edge intelligence appliance.
|
||||
|
||||
No cameras. No microphones. No PII. Just radio waves.
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
Guest walks through lobby
|
||||
|
|
||||
v
|
||||
ESP32-S3 Node (WiFi CSI at 20 Hz)
|
||||
|
|
||||
v
|
||||
Tier 2 Edge DSP (Core 1)
|
||||
- Phase rate-of-change --> gait speed
|
||||
- Step interval variance --> stride regularity
|
||||
- Phase 2nd derivative --> movement fluidity
|
||||
- 0.15-0.5 Hz oscillation --> breathing rate
|
||||
- Amplitude spread --> posture
|
||||
- Presence duration --> dwell time
|
||||
|
|
||||
v
|
||||
8-dim Happiness Vector
|
||||
[happiness, gait, stride, fluidity, calm, posture, dwell, social]
|
||||
|
|
||||
v
|
||||
Cognitum Seed (Pi Zero 2 W)
|
||||
- kNN similarity search
|
||||
- Concept drift detection (13 detectors)
|
||||
- Ed25519 witness chain (tamper-proof audit)
|
||||
- Reflex rules (trigger actuators on patterns)
|
||||
```
|
||||
|
||||
## The 8 Dimensions
|
||||
|
||||
| Dim | Name | Source | Happy | Unhappy |
|
||||
|-----|------|--------|-------|---------|
|
||||
| 0 | **Happiness Score** | Weighted composite of dims 1-6 | 0.7-1.0 | 0.0-0.3 |
|
||||
| 1 | **Gait Speed** | Phase Doppler shift | Fast (0.8+) | Slow (0.2) |
|
||||
| 2 | **Stride Regularity** | Step interval CV (inverted) | Regular (0.9) | Erratic (0.3) |
|
||||
| 3 | **Movement Fluidity** | Phase acceleration (inverted) | Smooth (0.8) | Jerky (0.2) |
|
||||
| 4 | **Breathing Calm** | 0.15-0.5 Hz phase oscillation | Slow/deep (0.8) | Rapid (0.2) |
|
||||
| 5 | **Posture Score** | Amplitude spread across subcarriers | Upright (0.7) | Slouched (0.3) |
|
||||
| 6 | **Dwell Factor** | Presence frame ratio | Lingering (0.8) | Rushing (0.2) |
|
||||
| 7 | **Social Energy** | Motion + dwell + HR proxy | Animated group (0.8) | Solitary (0.2) |
|
||||
|
||||
Weights: gait 25%, fluidity 20%, calm 20%, stride 15%, posture 10%, dwell 10%.
|
||||
|
||||
## Hardware
|
||||
|
||||
| Component | Model | Role | Cost |
|
||||
|-----------|-------|------|------|
|
||||
| ESP32-S3 | QFN56 (4MB flash, 2MB PSRAM) | CSI sensing node | ~$4 |
|
||||
| Cognitum Seed | Pi Zero 2 W | Swarm coordinator | ~$20 |
|
||||
| WiFi Router | Any 2.4 GHz | CSI signal source | existing |
|
||||
|
||||
One Seed manages up to 20 ESP32 nodes. Each node covers ~10m radius through walls.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Flash and Provision an ESP32 Node
|
||||
|
||||
```bash
|
||||
# Build firmware (from repo root)
|
||||
cd firmware/esp32-csi-node
|
||||
idf.py build
|
||||
|
||||
# Flash to device
|
||||
idf.py -p COM5 flash
|
||||
|
||||
# Provision with WiFi + Seed credentials
|
||||
python provision.py \
|
||||
--port COM5 \
|
||||
--ssid "YourWiFi" \
|
||||
--password "yourpassword" \
|
||||
--node-id 1 \
|
||||
--seed-url "http://10.1.10.236" \
|
||||
--seed-token "YOUR_SEED_TOKEN" \
|
||||
--zone "lobby"
|
||||
```
|
||||
|
||||
### 2. Pair the Seed (first time only)
|
||||
|
||||
```bash
|
||||
# Via USB (link-local, no token needed)
|
||||
curl -X POST http://169.254.42.1/api/v1/pair/window
|
||||
curl -X POST http://169.254.42.1/api/v1/pair -H "Content-Type: application/json" \
|
||||
-d '{"name":"esp32-swarm"}'
|
||||
# Save the token from the response
|
||||
```
|
||||
|
||||
### 3. Run the Dashboard
|
||||
|
||||
```bash
|
||||
# Happiness mode with Seed bridge
|
||||
python examples/ruview_live.py \
|
||||
--mode happiness \
|
||||
--csi COM5 \
|
||||
--seed http://10.1.10.236 \
|
||||
--duration 300
|
||||
|
||||
# Output:
|
||||
# s Happy Gait Calm Social Pres RSSI Seed CSI#
|
||||
# 2s [====------] 0.43 0.00 0.64 0.00 no -59 OK 1800
|
||||
# 10s [=======---] 0.72 0.65 0.80 0.45 YES -55 OK 4200
|
||||
```
|
||||
|
||||
### 4. Query the Seed
|
||||
|
||||
```bash
|
||||
# Status
|
||||
python examples/happiness-vector/seed_query.py \
|
||||
--seed http://10.1.10.236 --token YOUR_TOKEN status
|
||||
|
||||
# Live monitor vectors flowing in
|
||||
python examples/happiness-vector/seed_query.py \
|
||||
--seed http://10.1.10.236 --token YOUR_TOKEN monitor
|
||||
|
||||
# Happiness report
|
||||
python examples/happiness-vector/seed_query.py \
|
||||
--seed http://10.1.10.236 --token YOUR_TOKEN report
|
||||
|
||||
# Witness chain audit
|
||||
python examples/happiness-vector/seed_query.py \
|
||||
--seed http://10.1.10.236 --token YOUR_TOKEN witness
|
||||
```
|
||||
|
||||
## Multi-Node Swarm
|
||||
|
||||
Deploy multiple ESP32 nodes across zones. The Seed aggregates all vectors and detects cross-zone patterns.
|
||||
|
||||
```bash
|
||||
# Provision all nodes at once
|
||||
bash examples/happiness-vector/provision_swarm.sh
|
||||
|
||||
# Or manually per node
|
||||
python provision.py --port COM5 --node-id 1 --zone lobby ...
|
||||
python provision.py --port COM6 --node-id 2 --zone hallway ...
|
||||
python provision.py --port COM8 --node-id 3 --zone restaurant ...
|
||||
```
|
||||
|
||||
Each node independently:
|
||||
- Collects CSI at ~100 fps
|
||||
- Runs Tier 2 DSP on Core 1 (presence, vitals, fall detection)
|
||||
- Pushes happiness vectors to Seed every 5 seconds (when presence detected)
|
||||
- Sends heartbeats every 30 seconds
|
||||
|
||||
The Seed provides:
|
||||
- **kNN search** across all zones ("which room is happiest right now?")
|
||||
- **Drift detection** (13 detectors monitoring mood trends over time)
|
||||
- **Witness chain** (Ed25519-signed, tamper-proof audit trail)
|
||||
- **Reflex rules** (trigger alarms, lights, or alerts on swarm-wide patterns)
|
||||
|
||||
## WASM Edge Modules
|
||||
|
||||
The happiness scoring algorithm also exists as a WASM module for on-device execution:
|
||||
|
||||
```bash
|
||||
# Build the happiness scorer WASM
|
||||
cd rust-port/wifi-densepose-rs/crates/wifi-densepose-wasm-edge
|
||||
cargo build --bin ghost_hunter --target wasm32-unknown-unknown --release --no-default-features
|
||||
|
||||
# Output: target/wasm32-unknown-unknown/release/ghost_hunter.wasm (5.7 KB)
|
||||
```
|
||||
|
||||
Event IDs emitted by the WASM module:
|
||||
|
||||
| ID | Event | Rate |
|
||||
|----|-------|------|
|
||||
| 690 | `HAPPINESS_SCORE` | Every frame (20 Hz) |
|
||||
| 691 | `GAIT_ENERGY` | Every 4th frame (5 Hz) |
|
||||
| 692 | `AFFECT_VALENCE` | Every 4th frame |
|
||||
| 693 | `SOCIAL_ENERGY` | Every 4th frame |
|
||||
| 694 | `TRANSIT_DIRECTION` | Every 4th frame |
|
||||
|
||||
## Privacy
|
||||
|
||||
This system is designed to be privacy-preserving by construction:
|
||||
|
||||
- **No images** — WiFi CSI captures RF signal patterns, not visual data
|
||||
- **No audio** — radio waves only
|
||||
- **No facial recognition** — physically impossible with CSI
|
||||
- **No individual identity** — cannot distinguish Bob from Alice
|
||||
- **Aggregate only** — 8 floating-point numbers per observation
|
||||
- **Works in the dark** — RF sensing needs no lighting
|
||||
- **Through-wall** — single sensor covers adjacent rooms without line-of-sight
|
||||
- **GDPR-friendly** — no personal data collected; happiness scores are anonymous statistical aggregates
|
||||
|
||||
## Files
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `seed_query.py` | CLI tool: status, search, witness, monitor, report |
|
||||
| `provision_swarm.sh` | Batch provisioning for multi-node deployment |
|
||||
| `happiness_vector_schema.json` | JSON Schema for the 8-dim vector format |
|
||||
| `README.md` | This file |
|
||||
|
||||
## Related
|
||||
|
||||
- [ADR-065](../../docs/adr/ADR-065-happiness-scoring-seed-bridge.md) — Happiness scoring pipeline architecture
|
||||
- [ADR-066](../../docs/adr/ADR-066-esp32-swarm-seed-coordinator.md) — ESP32 swarm with Seed coordinator
|
||||
- [exo_happiness_score.rs](../../rust-port/wifi-densepose-rs/crates/wifi-densepose-wasm-edge/src/exo_happiness_score.rs) — WASM edge module (Rust)
|
||||
- [swarm_bridge.c](../../firmware/esp32-csi-node/main/swarm_bridge.c) — ESP32 firmware swarm bridge
|
||||
- [ruview_live.py](../ruview_live.py) — RuView Live dashboard with `--mode happiness`
|
||||
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "Happiness Vector",
|
||||
"description": "8-dimensional happiness feature vector for Cognitum Seed ingestion (ADR-065). Each dimension is normalized to [0, 1] where higher values indicate more positive affect.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"vectors": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "array",
|
||||
"prefixItems": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Vector ID: node_id * 1000000 + type_offset + timestamp_component. Type offsets: 0=registration, 100000=heartbeat, 200000=happiness."
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||
"minItems": 8,
|
||||
"maxItems": 8,
|
||||
"description": "8-dim happiness vector: [happiness_score, gait_speed, stride_regularity, movement_fluidity, breathing_calm, posture_score, dwell_factor, social_energy]"
|
||||
}
|
||||
],
|
||||
"minItems": 2,
|
||||
"maxItems": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["vectors"],
|
||||
|
||||
"$defs": {
|
||||
"dimensions": {
|
||||
"type": "object",
|
||||
"description": "Happiness vector dimension definitions",
|
||||
"properties": {
|
||||
"dim_0_happiness_score": {
|
||||
"description": "Composite happiness [0=sad, 0.5=neutral, 1=happy]. Weighted sum of dims 1-6.",
|
||||
"weights": "gait=0.25, stride=0.15, fluidity=0.20, calm=0.20, posture=0.10, dwell=0.10"
|
||||
},
|
||||
"dim_1_gait_speed": {
|
||||
"description": "Walking speed from CSI phase rate-of-change. Happy people walk ~12% faster.",
|
||||
"source": "Phase Doppler shift",
|
||||
"units": "normalized phase delta / MAX_GAIT_SPEED"
|
||||
},
|
||||
"dim_2_stride_regularity": {
|
||||
"description": "Step interval consistency. Regular strides indicate confidence/positive affect.",
|
||||
"source": "Variance coefficient of step intervals (inverted)",
|
||||
"interpretation": "1.0=perfectly regular, 0.0=erratic/stumbling"
|
||||
},
|
||||
"dim_3_movement_fluidity": {
|
||||
"description": "Smoothness of body movement trajectory. Jerky motion indicates anxiety.",
|
||||
"source": "Phase second derivative (acceleration), inverted",
|
||||
"interpretation": "1.0=smooth/flowing, 0.0=jerky/hesitant"
|
||||
},
|
||||
"dim_4_breathing_calm": {
|
||||
"description": "Breathing rate mapped to calmness. Slow deep breathing = relaxed.",
|
||||
"source": "0.15-0.5 Hz phase oscillation (breathing proxy)",
|
||||
"interpretation": "1.0=calm (6-14 BPM), 0.0=rapid/stressed (>22 BPM)"
|
||||
},
|
||||
"dim_5_posture_score": {
|
||||
"description": "Upright vs slouched posture from RF scattering cross-section.",
|
||||
"source": "Amplitude coefficient of variation across subcarrier groups",
|
||||
"interpretation": "1.0=upright (wide spread), 0.0=slouched (narrow spread)"
|
||||
},
|
||||
"dim_6_dwell_factor": {
|
||||
"description": "How long the person stays in the sensing zone.",
|
||||
"source": "Fraction of recent frames with presence detected",
|
||||
"interpretation": "1.0=lingering (happy guests browse), 0.0=rushing through"
|
||||
},
|
||||
"dim_7_social_energy": {
|
||||
"description": "Group animation and interaction level.",
|
||||
"source": "Motion energy + dwell + heart rate proxy",
|
||||
"interpretation": "1.0=animated group interaction, 0.0=solitary/withdrawn"
|
||||
}
|
||||
}
|
||||
},
|
||||
"event_ids": {
|
||||
"type": "object",
|
||||
"description": "WASM edge module event IDs (690-694)",
|
||||
"properties": {
|
||||
"690_HAPPINESS_SCORE": "Composite happiness [0, 1] — emitted every frame",
|
||||
"691_GAIT_ENERGY": "Gait speed + stride regularity composite — emitted every 4th frame",
|
||||
"692_AFFECT_VALENCE": "Breathing calm + fluidity + posture composite — emitted every 4th frame",
|
||||
"693_SOCIAL_ENERGY": "Group animation level — emitted every 4th frame",
|
||||
"694_TRANSIT_DIRECTION": "1.0=entering, 0.0=exiting — emitted every 4th frame"
|
||||
}
|
||||
},
|
||||
"seed_id_scheme": {
|
||||
"type": "object",
|
||||
"description": "Vector ID encoding for Cognitum Seed",
|
||||
"properties": {
|
||||
"format": "node_id * 1000000 + type_offset + timestamp_component",
|
||||
"registration": "offset 0 (e.g. node 1 = 1000000)",
|
||||
"heartbeat": "offset 100000 + uptime_sec % 100000 (e.g. 1100042)",
|
||||
"happiness": "offset 200000 + ms_timestamp / 1000 % 100000 (e.g. 1212345)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/bin/bash
|
||||
# ESP32 Swarm Provisioning — ADR-065/066
|
||||
#
|
||||
# Provisions multiple ESP32-S3 nodes for a hotel happiness sensing deployment.
|
||||
# Each node gets WiFi credentials, a unique node_id, zone name, and Seed token.
|
||||
#
|
||||
# Prerequisites:
|
||||
# - ESP-IDF Python venv with esptool and nvs_partition_gen
|
||||
# - Firmware already flashed to each ESP32
|
||||
# - Seed paired (obtain token via: curl -X POST http://169.254.42.1/api/v1/pair)
|
||||
#
|
||||
# Usage:
|
||||
# bash provision_swarm.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ---- Configuration ----
|
||||
SSID="${SWARM_WIFI_SSID:?Set SWARM_WIFI_SSID env var}"
|
||||
PASSWORD="${SWARM_WIFI_PASSWORD:?Set SWARM_WIFI_PASSWORD env var}"
|
||||
SEED_URL="${SWARM_SEED_URL:?Set SWARM_SEED_URL env var}"
|
||||
SEED_TOKEN="${SWARM_SEED_TOKEN:?Set SWARM_SEED_TOKEN env var}"
|
||||
|
||||
PROVISION="../../firmware/esp32-csi-node/provision.py"
|
||||
|
||||
# ---- Node definitions: PORT NODE_ID ZONE ----
|
||||
NODES=(
|
||||
"COM5 1 lobby"
|
||||
"COM6 2 hallway"
|
||||
"COM8 3 restaurant"
|
||||
"COM9 4 pool"
|
||||
"COM10 5 conference"
|
||||
)
|
||||
|
||||
echo "========================================"
|
||||
echo " ESP32 Swarm Provisioning"
|
||||
echo " Seed: $SEED_URL"
|
||||
echo " WiFi: $SSID"
|
||||
echo " Nodes: ${#NODES[@]}"
|
||||
echo "========================================"
|
||||
echo
|
||||
|
||||
for entry in "${NODES[@]}"; do
|
||||
read -r port node_id zone <<< "$entry"
|
||||
echo "--- Node $node_id: $zone ($port) ---"
|
||||
python "$PROVISION" \
|
||||
--port "$port" \
|
||||
--ssid "$SSID" \
|
||||
--password "$PASSWORD" \
|
||||
--node-id "$node_id" \
|
||||
--seed-url "$SEED_URL" \
|
||||
--seed-token "$SEED_TOKEN" \
|
||||
--zone "$zone" \
|
||||
&& echo " OK" || echo " FAILED (device not connected?)"
|
||||
echo
|
||||
done
|
||||
|
||||
echo "========================================"
|
||||
echo " Provisioning complete."
|
||||
echo " Monitor with: python seed_query.py monitor --seed $SEED_URL --token $SEED_TOKEN"
|
||||
echo "========================================"
|
||||
@@ -0,0 +1,260 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Cognitum Seed — Happiness Vector Query Tool
|
||||
|
||||
Query the Seed's vector store for happiness patterns across ESP32 swarm nodes.
|
||||
Demonstrates kNN search, drift monitoring, and witness chain verification.
|
||||
|
||||
Usage:
|
||||
python seed_query.py --seed http://10.1.10.236 --token <bearer_token>
|
||||
python seed_query.py --seed http://169.254.42.1 # USB link-local (no token needed)
|
||||
|
||||
Requirements:
|
||||
Python 3.7+ (stdlib only, no dependencies)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
|
||||
def api(base, path, token=None, method="GET", data=None):
|
||||
"""Make an API request to the Seed."""
|
||||
url = f"{base}{path}"
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
body = json.dumps(data).encode() if data else None
|
||||
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
return {"error": f"HTTP {e.code}", "detail": e.read().decode()[:200]}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
def print_header(title):
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" {title}")
|
||||
print(f"{'=' * 60}")
|
||||
|
||||
|
||||
def cmd_status(args):
|
||||
"""Show Seed and swarm status."""
|
||||
print_header("Seed Status")
|
||||
s = api(args.seed, "/api/v1/status", args.token)
|
||||
if "error" in s:
|
||||
print(f" Error: {s['error']}")
|
||||
return
|
||||
print(f" Device: {s['device_id'][:8]}...")
|
||||
print(f" Vectors: {s['total_vectors']} (dim={s['dimension']})")
|
||||
print(f" Epoch: {s['epoch']}")
|
||||
print(f" Store: {s['file_size_bytes'] / 1024:.1f} KB")
|
||||
print(f" Uptime: {s['uptime_secs'] // 3600}h {(s['uptime_secs'] % 3600) // 60}m")
|
||||
print(f" Witness: {s['witness_chain_length']} entries")
|
||||
|
||||
print_header("Drift Detection")
|
||||
d = api(args.seed, "/api/v1/sensor/drift/status", args.token)
|
||||
if "error" not in d:
|
||||
print(f" Drifting: {d.get('drifting', False)}")
|
||||
print(f" Score: {d.get('current_drift_score', 0):.4f}")
|
||||
print(f" Detectors: {d.get('detectors_active', 0)} active")
|
||||
print(f" Total: {d.get('detections_total', 0)} detections")
|
||||
|
||||
|
||||
def cmd_search(args):
|
||||
"""Search for similar happiness vectors."""
|
||||
print_header("Happiness kNN Search")
|
||||
|
||||
# Reference vectors for common moods
|
||||
refs = {
|
||||
"happy": [0.8, 0.7, 0.9, 0.8, 0.6, 0.7, 0.9, 0.5],
|
||||
"neutral": [0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5],
|
||||
"stressed":[0.2, 0.3, 0.2, 0.2, 0.3, 0.3, 0.2, 0.7],
|
||||
}
|
||||
|
||||
query = refs.get(args.mood, refs["happy"])
|
||||
print(f" Query mood: {args.mood}")
|
||||
print(f" Vector: [{', '.join(f'{v:.1f}' for v in query)}]")
|
||||
print(f" k: {args.k}")
|
||||
print()
|
||||
|
||||
result = api(args.seed, "/api/v1/store/search", args.token,
|
||||
method="POST", data={"vector": query, "k": args.k})
|
||||
|
||||
if "error" in result:
|
||||
print(f" Error: {result['error']}")
|
||||
return
|
||||
|
||||
neighbors = result.get("neighbors", result.get("results", []))
|
||||
if not neighbors:
|
||||
print(" No results found.")
|
||||
return
|
||||
|
||||
print(f" {'ID':>10} {'Distance':>10} {'Vector'}")
|
||||
print(f" {'-'*10} {'-'*10} {'-'*40}")
|
||||
for n in neighbors:
|
||||
vid = n.get("id", "?")
|
||||
dist = n.get("distance", n.get("dist", 0))
|
||||
vec = n.get("vector", n.get("values", []))
|
||||
vec_str = "[" + ", ".join(f"{v:.2f}" for v in vec[:4]) + ", ...]" if len(vec) > 4 else str(vec)
|
||||
print(f" {vid:>10} {dist:>10.4f} {vec_str}")
|
||||
|
||||
|
||||
def cmd_witness(args):
|
||||
"""Show the witness chain for audit trail."""
|
||||
print_header("Witness Chain (Audit Trail)")
|
||||
|
||||
epoch = api(args.seed, "/api/v1/custody/epoch", args.token)
|
||||
if "error" not in epoch:
|
||||
print(f" Current epoch: {epoch.get('epoch', '?')}")
|
||||
head = epoch.get("witness_head", "?")
|
||||
print(f" Chain head: {head[:16]}..." if len(head) > 16 else f" Chain head: {head}")
|
||||
|
||||
chain = api(args.seed, "/api/v1/cognitive/status", args.token)
|
||||
if "error" not in chain:
|
||||
cv = chain.get("chain_valid", {})
|
||||
print(f" Chain valid: {cv.get('valid', '?')}")
|
||||
print(f" Chain length: {cv.get('chain_length', '?')}")
|
||||
print(f" Epoch range: {cv.get('first_epoch', '?')} - {cv.get('last_epoch', '?')}")
|
||||
|
||||
|
||||
def cmd_monitor(args):
|
||||
"""Live monitor happiness vectors flowing into the Seed."""
|
||||
print_header("Live Happiness Monitor")
|
||||
print(f" Polling every {args.interval}s (Ctrl+C to stop)")
|
||||
print()
|
||||
|
||||
prev_epoch = 0
|
||||
prev_vectors = 0
|
||||
|
||||
try:
|
||||
while True:
|
||||
s = api(args.seed, "/api/v1/status", args.token)
|
||||
if "error" in s:
|
||||
print(f" [{time.strftime('%H:%M:%S')}] Error: {s['error']}")
|
||||
time.sleep(args.interval)
|
||||
continue
|
||||
|
||||
epoch = s["epoch"]
|
||||
vectors = s["total_vectors"]
|
||||
new_v = vectors - prev_vectors if prev_vectors > 0 else 0
|
||||
new_e = epoch - prev_epoch if prev_epoch > 0 else 0
|
||||
|
||||
d = api(args.seed, "/api/v1/sensor/drift/status", args.token)
|
||||
drift = d.get("current_drift_score", 0) if "error" not in d else 0
|
||||
drifting = d.get("drifting", False) if "error" not in d else False
|
||||
|
||||
ts = time.strftime("%H:%M:%S")
|
||||
drift_str = f" DRIFT!" if drifting else ""
|
||||
print(f" [{ts}] epoch={epoch} vectors={vectors} (+{new_v}) "
|
||||
f"drift={drift:.4f} chain={s['witness_chain_length']}{drift_str}")
|
||||
|
||||
prev_epoch = epoch
|
||||
prev_vectors = vectors
|
||||
time.sleep(args.interval)
|
||||
except KeyboardInterrupt:
|
||||
print("\n Stopped.")
|
||||
|
||||
|
||||
def cmd_happiness_report(args):
|
||||
"""Generate a happiness report from stored vectors."""
|
||||
print_header("Happiness Report")
|
||||
|
||||
s = api(args.seed, "/api/v1/status", args.token)
|
||||
if "error" in s:
|
||||
print(f" Error: {s['error']}")
|
||||
return
|
||||
|
||||
print(f" Total vectors: {s['total_vectors']}")
|
||||
print(f" Store epoch: {s['epoch']}")
|
||||
print()
|
||||
|
||||
# Search for happiest and saddest vectors
|
||||
happy_ref = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.5]
|
||||
sad_ref = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.5]
|
||||
|
||||
print(" Happiest moments (closest to ideal happy):")
|
||||
happy = api(args.seed, "/api/v1/store/search", args.token,
|
||||
method="POST", data={"vector": happy_ref, "k": 3})
|
||||
for n in happy.get("neighbors", happy.get("results", [])):
|
||||
dist = n.get("distance", n.get("dist", 0))
|
||||
vec = n.get("vector", n.get("values", []))
|
||||
score = vec[0] if vec else 0
|
||||
print(f" id={n.get('id','?'):>10} happiness={score:.2f} dist={dist:.4f}")
|
||||
|
||||
print()
|
||||
print(" Most stressed moments (closest to stressed reference):")
|
||||
sad = api(args.seed, "/api/v1/store/search", args.token,
|
||||
method="POST", data={"vector": sad_ref, "k": 3})
|
||||
for n in sad.get("neighbors", sad.get("results", [])):
|
||||
dist = n.get("distance", n.get("dist", 0))
|
||||
vec = n.get("vector", n.get("values", []))
|
||||
score = vec[0] if vec else 0
|
||||
print(f" id={n.get('id','?'):>10} happiness={score:.2f} dist={dist:.4f}")
|
||||
|
||||
# Drift status
|
||||
print()
|
||||
d = api(args.seed, "/api/v1/sensor/drift/status", args.token)
|
||||
if "error" not in d:
|
||||
if d.get("drifting"):
|
||||
print(f" WARNING: Mood drift detected (score={d['current_drift_score']:.4f})")
|
||||
print(f" This may indicate a change in guest satisfaction.")
|
||||
else:
|
||||
print(f" Mood stable (drift score={d.get('current_drift_score', 0):.4f})")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Happiness Vector Query Tool for Cognitum Seed",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
%(prog)s status --seed http://169.254.42.1
|
||||
%(prog)s search --seed http://10.1.10.236 --token TOKEN --mood happy
|
||||
%(prog)s monitor --seed http://10.1.10.236 --token TOKEN
|
||||
%(prog)s report --seed http://10.1.10.236 --token TOKEN
|
||||
%(prog)s witness --seed http://10.1.10.236 --token TOKEN
|
||||
"""
|
||||
)
|
||||
parser.add_argument("--seed", default="http://169.254.42.1",
|
||||
help="Seed base URL (default: USB link-local)")
|
||||
parser.add_argument("--token", default=None,
|
||||
help="Bearer token for WiFi access (not needed for USB)")
|
||||
|
||||
sub = parser.add_subparsers(dest="command")
|
||||
|
||||
sub.add_parser("status", help="Show Seed and swarm status")
|
||||
sub.add_parser("witness", help="Show witness chain audit trail")
|
||||
|
||||
p_search = sub.add_parser("search", help="kNN search for mood patterns")
|
||||
p_search.add_argument("--mood", default="happy",
|
||||
choices=["happy", "neutral", "stressed"])
|
||||
p_search.add_argument("--k", type=int, default=5)
|
||||
|
||||
p_monitor = sub.add_parser("monitor", help="Live monitor incoming vectors")
|
||||
p_monitor.add_argument("--interval", type=int, default=5)
|
||||
|
||||
sub.add_parser("report", help="Generate happiness report")
|
||||
|
||||
args = parser.parse_args()
|
||||
if not args.command:
|
||||
args.command = "status"
|
||||
|
||||
cmds = {
|
||||
"status": cmd_status,
|
||||
"search": cmd_search,
|
||||
"witness": cmd_witness,
|
||||
"monitor": cmd_monitor,
|
||||
"report": cmd_happiness_report,
|
||||
}
|
||||
cmds[args.command](args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,111 @@
|
||||
# Medical Sensing Examples
|
||||
|
||||
Contactless vital sign monitoring using 60 GHz mmWave radar — no wearable, no camera, no physical contact.
|
||||
|
||||
## Blood Pressure Estimator
|
||||
|
||||
Estimates blood pressure in real-time from heart rate variability (HRV) captured by a Seeed MR60BHA2 60 GHz mmWave radar module connected to an ESP32-C6.
|
||||
|
||||
### How It Works
|
||||
|
||||
The radar detects **microscopic chest wall displacement** caused by:
|
||||
- **Respiration**: 0.1-1.0 mm displacement at 12-25 breaths/min
|
||||
- **Cardiac pulse**: 0.01-0.1 mm displacement at 60-100 bpm
|
||||
|
||||
Modern 60 GHz FMCW radar resolves displacement down to **fractions of a millimeter**. Once the signal is isolated and filtered, the heartbeat-by-heartbeat pattern is remarkably clear.
|
||||
|
||||
From there, the estimator:
|
||||
|
||||
1. **Extracts beat-to-beat intervals** from the HR time series
|
||||
2. **Computes HRV metrics**: SDNN (overall variability), LF/HF ratio (sympathetic/parasympathetic balance)
|
||||
3. **Estimates blood pressure** using the correlation between HR, HRV, and cardiovascular tone:
|
||||
- Higher HR → higher BP (sympathetic activation)
|
||||
- Lower HRV (SDNN) → higher BP (reduced parasympathetic)
|
||||
- Higher LF/HF ratio → higher BP (sympathetic dominance)
|
||||
|
||||
### Hardware Required
|
||||
|
||||
| Component | Cost | Role |
|
||||
|-----------|------|------|
|
||||
| ESP32-C6 + Seeed MR60BHA2 | ~$15 | 60 GHz mmWave radar (HR, BR, presence) |
|
||||
| USB cable | — | Power + serial data |
|
||||
|
||||
That's it. Total cost: **~$15**.
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
pip install pyserial numpy
|
||||
|
||||
# Basic (uncalibrated — shows trends)
|
||||
python examples/medical/bp_estimator.py --port COM4
|
||||
|
||||
# Calibrated (take a real BP reading first, then enter it)
|
||||
python examples/medical/bp_estimator.py --port COM4 \
|
||||
--cal-systolic 120 --cal-diastolic 80 --cal-hr 72
|
||||
```
|
||||
|
||||
### Sample Output (Real Hardware, 2026-03-15)
|
||||
|
||||
```
|
||||
Contactless Blood Pressure Estimation (mmWave 60 GHz)
|
||||
|
||||
Time HR SBP DBP Category Samples
|
||||
-------------------------------------------------------
|
||||
15s | 64 | 117/78 | Normal | SDNN 22ms | n=4
|
||||
20s | 65 | 117/78 | Normal | SDNN 28ms | n=5
|
||||
25s | 71 | 119/79 | Normal | SDNN 88ms | n=9
|
||||
30s | 77 | 122/81 | Elevated | SDNN 108ms | n=14
|
||||
35s | 80 | 123/82 | Elevated | SDNN 106ms | n=18
|
||||
40s | 80 | 123/82 | Elevated | SDNN 98ms | n=22
|
||||
45s | 82 | 124/83 | Elevated | SDNN 97ms | n=26
|
||||
50s | 83 | 125/83 | Elevated | SDNN 95ms | n=29
|
||||
55s | 83 | 125/83 | Elevated | SDNN 92ms | n=32
|
||||
60s | 84 | 125/83 | Elevated | SDNN 91ms | n=35
|
||||
|
||||
RESULT: 125/83 mmHg | HR 84 bpm | SDNN 91ms | 35 samples
|
||||
```
|
||||
|
||||
### Accuracy
|
||||
|
||||
| Condition | Accuracy |
|
||||
|-----------|----------|
|
||||
| Uncalibrated, stationary | ±15-20 mmHg (trend tracking) |
|
||||
| Calibrated, stationary | ±8-12 mmHg |
|
||||
| Moving subject | Not reliable — wait for subject to be still |
|
||||
|
||||
Accuracy improves with:
|
||||
- Longer recording duration (60s minimum, 120s recommended)
|
||||
- Calibration with a real cuff reading
|
||||
- Stationary subject within 1m of sensor
|
||||
- Minimal environmental RF interference
|
||||
|
||||
### AHA Blood Pressure Categories
|
||||
|
||||
| Category | Systolic | Diastolic |
|
||||
|----------|----------|-----------|
|
||||
| Normal | < 120 | < 80 |
|
||||
| Elevated | 120-129 | < 80 |
|
||||
| High BP Stage 1 | 130-139 | 80-89 |
|
||||
| High BP Stage 2 | 140+ | 90+ |
|
||||
|
||||
### Disclaimer
|
||||
|
||||
**This is NOT a medical device.** Blood pressure estimates from heart rate variability are approximations based on population-level correlations. Individual variation is significant. Always use a validated cuff-based sphygmomanometer for clinical decisions.
|
||||
|
||||
This tool is intended for:
|
||||
- Research into contactless vital sign monitoring
|
||||
- Wellness trend tracking (is my BP going up or down over days?)
|
||||
- Technology demonstration
|
||||
- Educational purposes
|
||||
|
||||
### How This Connects to RuView
|
||||
|
||||
This example is part of the [RuView](https://github.com/ruvnet/RuView) ambient intelligence platform. When combined with WiFi CSI sensing:
|
||||
|
||||
- **WiFi CSI** provides through-wall presence detection and room-scale activity recognition
|
||||
- **mmWave radar** provides clinical-grade heart rate, breathing rate, and BP estimation
|
||||
- **Sensor fusion** (ADR-063) combines both for zero false-positive fall detection and comprehensive health monitoring
|
||||
- **RuVector** dynamic min-cut analysis treats physiological signals as a coherence graph, automatically separating noise, motion artifacts, and environmental interference
|
||||
|
||||
The result: cheap sensors ($15-24 per node), local computation (no cloud), real physiological understanding.
|
||||
@@ -0,0 +1,376 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Contactless Blood Pressure Estimation via mmWave Heart Rate Variability
|
||||
|
||||
Reads real-time heart rate from a Seeed MR60BHA2 (60 GHz mmWave) sensor
|
||||
and estimates blood pressure trends using the Pulse Transit Time (PTT)
|
||||
correlation method.
|
||||
|
||||
Theory:
|
||||
Blood pressure correlates inversely with Pulse Transit Time — the time
|
||||
for a pulse wave to travel from the heart to the periphery. While we
|
||||
can't measure PTT directly with a single sensor, heart rate variability
|
||||
(HRV) features — specifically the ratio of low-frequency to high-frequency
|
||||
power (LF/HF ratio) — correlate with sympathetic nervous system activity,
|
||||
which drives blood pressure changes.
|
||||
|
||||
The model uses:
|
||||
1. Mean HR over a window → baseline systolic/diastolic estimate
|
||||
2. HR variability (SDNN) → adjustment for sympathetic tone
|
||||
3. LF/HF ratio from HR intervals → fine adjustment
|
||||
|
||||
Calibration: Provide a known BP reading to anchor the estimates.
|
||||
Without calibration, the system shows relative trends only.
|
||||
|
||||
⚠️ NOT A MEDICAL DEVICE. For research and wellness tracking only.
|
||||
Accuracy is ±15-20 mmHg without calibration. With calibration and
|
||||
a stationary subject, ±8-12 mmHg is achievable for trending.
|
||||
|
||||
Usage:
|
||||
python examples/medical/bp_estimator.py --port COM4
|
||||
|
||||
# With calibration (take a real BP reading first):
|
||||
python examples/medical/bp_estimator.py --port COM4 \
|
||||
--cal-systolic 120 --cal-diastolic 80 --cal-hr 72
|
||||
|
||||
Requirements:
|
||||
pip install pyserial numpy
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import math
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
import serial
|
||||
|
||||
try:
|
||||
import numpy as np
|
||||
HAS_NUMPY = True
|
||||
except ImportError:
|
||||
HAS_NUMPY = False
|
||||
|
||||
|
||||
# ---- ESPHome MR60BHA2 log parsing ----
|
||||
RE_HR = re.compile(r"'Real-time heart rate'.*?(\d+\.?\d*)\s*bpm", re.IGNORECASE)
|
||||
RE_BR = re.compile(r"'Real-time respiratory rate'.*?(\d+\.?\d*)", re.IGNORECASE)
|
||||
RE_ANSI = re.compile(r"\x1b\[[0-9;]*m")
|
||||
|
||||
|
||||
class BPEstimator:
|
||||
"""
|
||||
Estimates blood pressure from heart rate time series.
|
||||
|
||||
Uses a physiological model:
|
||||
SBP = a * HR + b * SDNN + c * (LF/HF) + offset_sys
|
||||
DBP = d * HR + e * SDNN + f * (LF/HF) + offset_dia
|
||||
|
||||
Coefficients derived from published PTT-BP correlation studies:
|
||||
- Mukkamala et al., "Toward Ubiquitous Blood Pressure Monitoring
|
||||
via Pulse Transit Time", IEEE TBME 2015
|
||||
- Ding et al., "Continuous Cuffless Blood Pressure Estimation
|
||||
Using Pulse Transit Time and Photoplethysmogram", EMBC 2016
|
||||
"""
|
||||
|
||||
# Population-average model coefficients
|
||||
# These assume resting adult, seated position
|
||||
HR_COEFF_SYS = 0.5 # mmHg per bpm
|
||||
HR_COEFF_DIA = 0.3
|
||||
SDNN_COEFF_SYS = -0.8 # Higher HRV → lower BP (parasympathetic)
|
||||
SDNN_COEFF_DIA = -0.5
|
||||
LFHF_COEFF_SYS = 3.0 # Higher sympathetic → higher BP
|
||||
LFHF_COEFF_DIA = 2.0
|
||||
|
||||
# Population baseline (average resting adult)
|
||||
BASE_SYS = 120.0
|
||||
BASE_DIA = 80.0
|
||||
BASE_HR = 72.0
|
||||
|
||||
def __init__(self, window_sec=60, cal_sys=None, cal_dia=None, cal_hr=None):
|
||||
self.hr_history = collections.deque(maxlen=300) # 5 min at 1 Hz
|
||||
self.hr_timestamps = collections.deque(maxlen=300)
|
||||
self.window_sec = window_sec
|
||||
|
||||
# Calibration offsets
|
||||
self.cal_offset_sys = 0.0
|
||||
self.cal_offset_dia = 0.0
|
||||
|
||||
if cal_sys is not None and cal_hr is not None:
|
||||
# Compute what the model would predict at calibration HR
|
||||
predicted_sys = self.BASE_SYS + self.HR_COEFF_SYS * (cal_hr - self.BASE_HR)
|
||||
self.cal_offset_sys = cal_sys - predicted_sys
|
||||
|
||||
if cal_dia is not None and cal_hr is not None:
|
||||
predicted_dia = self.BASE_DIA + self.HR_COEFF_DIA * (cal_hr - self.BASE_HR)
|
||||
self.cal_offset_dia = cal_dia - predicted_dia
|
||||
|
||||
def add_hr(self, hr_bpm: float) -> None:
|
||||
"""Add a heart rate measurement."""
|
||||
if hr_bpm <= 0 or hr_bpm > 220:
|
||||
return
|
||||
self.hr_history.append(hr_bpm)
|
||||
self.hr_timestamps.append(time.time())
|
||||
|
||||
def _get_recent(self, window_sec: float):
|
||||
"""Get HR values within the last window_sec seconds."""
|
||||
now = time.time()
|
||||
cutoff = now - window_sec
|
||||
values = []
|
||||
for t, hr in zip(self.hr_timestamps, self.hr_history):
|
||||
if t >= cutoff:
|
||||
values.append(hr)
|
||||
return values
|
||||
|
||||
def _compute_sdnn(self, hrs: list) -> float:
|
||||
"""Standard deviation of beat-to-beat intervals (SDNN proxy).
|
||||
|
||||
We don't have R-R intervals, so we approximate from HR:
|
||||
RR_i ≈ 60 / HR_i (seconds)
|
||||
SDNN = std(RR_i) * 1000 (milliseconds)
|
||||
"""
|
||||
if len(hrs) < 5:
|
||||
return 50.0 # Default: normal HRV
|
||||
|
||||
rr_intervals = [60.0 / hr * 1000.0 for hr in hrs if hr > 0]
|
||||
if len(rr_intervals) < 5:
|
||||
return 50.0
|
||||
|
||||
if HAS_NUMPY:
|
||||
return float(np.std(rr_intervals))
|
||||
else:
|
||||
mean = sum(rr_intervals) / len(rr_intervals)
|
||||
variance = sum((x - mean) ** 2 for x in rr_intervals) / len(rr_intervals)
|
||||
return math.sqrt(variance)
|
||||
|
||||
def _compute_lf_hf_ratio(self, hrs: list) -> float:
|
||||
"""Estimate LF/HF ratio from HR variability.
|
||||
|
||||
LF (0.04-0.15 Hz): sympathetic + parasympathetic
|
||||
HF (0.15-0.4 Hz): parasympathetic only
|
||||
LF/HF > 2: sympathetic dominant (stress, higher BP)
|
||||
LF/HF < 1: parasympathetic dominant (relaxed, lower BP)
|
||||
|
||||
Without true spectral analysis, we approximate from the
|
||||
ratio of slow (>10s period) to fast (<7s period) HR fluctuations.
|
||||
"""
|
||||
if len(hrs) < 20:
|
||||
return 1.5 # Default: slight sympathetic
|
||||
|
||||
if not HAS_NUMPY:
|
||||
return 1.5 # Need numpy for spectral estimate
|
||||
|
||||
arr = np.array(hrs, dtype=float)
|
||||
detrended = arr - np.mean(arr)
|
||||
|
||||
# Simple spectral power estimate via autocorrelation
|
||||
n = len(detrended)
|
||||
fft = np.fft.rfft(detrended)
|
||||
psd = np.abs(fft) ** 2 / n
|
||||
|
||||
# Frequency bins (assuming 1 Hz sampling from mmWave)
|
||||
freqs = np.fft.rfftfreq(n, d=1.0)
|
||||
|
||||
# LF band: 0.04-0.15 Hz
|
||||
lf_mask = (freqs >= 0.04) & (freqs < 0.15)
|
||||
lf_power = np.sum(psd[lf_mask]) if np.any(lf_mask) else 0.0
|
||||
|
||||
# HF band: 0.15-0.4 Hz
|
||||
hf_mask = (freqs >= 0.15) & (freqs < 0.4)
|
||||
hf_power = np.sum(psd[hf_mask]) if np.any(hf_mask) else 0.001
|
||||
|
||||
ratio = lf_power / max(hf_power, 0.001)
|
||||
return min(max(ratio, 0.1), 10.0) # Clamp to reasonable range
|
||||
|
||||
def estimate(self) -> dict:
|
||||
"""Estimate current blood pressure.
|
||||
|
||||
Returns dict with: systolic, diastolic, mean_hr, sdnn, lf_hf,
|
||||
confidence (0-100), n_samples.
|
||||
"""
|
||||
recent = self._get_recent(self.window_sec)
|
||||
|
||||
if len(recent) < 3:
|
||||
return {
|
||||
"systolic": 0, "diastolic": 0,
|
||||
"mean_hr": 0, "sdnn": 0, "lf_hf": 0,
|
||||
"confidence": 0, "n_samples": len(recent),
|
||||
"status": "Collecting data..."
|
||||
}
|
||||
|
||||
mean_hr = sum(recent) / len(recent)
|
||||
sdnn = self._compute_sdnn(recent)
|
||||
lf_hf = self._compute_lf_hf_ratio(recent)
|
||||
|
||||
# Model
|
||||
hr_delta = mean_hr - self.BASE_HR
|
||||
sys = (self.BASE_SYS
|
||||
+ self.HR_COEFF_SYS * hr_delta
|
||||
+ self.SDNN_COEFF_SYS * (sdnn - 50.0) / 50.0
|
||||
+ self.LFHF_COEFF_SYS * (lf_hf - 1.5)
|
||||
+ self.cal_offset_sys)
|
||||
|
||||
dia = (self.BASE_DIA
|
||||
+ self.HR_COEFF_DIA * hr_delta
|
||||
+ self.SDNN_COEFF_DIA * (sdnn - 50.0) / 50.0
|
||||
+ self.LFHF_COEFF_DIA * (lf_hf - 1.5)
|
||||
+ self.cal_offset_dia)
|
||||
|
||||
# Physiological clamps
|
||||
sys = max(80, min(200, sys))
|
||||
dia = max(50, min(130, dia))
|
||||
if dia >= sys:
|
||||
dia = sys - 20
|
||||
|
||||
# Confidence based on data quality
|
||||
conf = min(100, len(recent) * 2)
|
||||
if self.cal_offset_sys != 0:
|
||||
conf = min(100, conf + 20) # Calibrated = higher confidence
|
||||
|
||||
status = "Estimating"
|
||||
if len(recent) < 10:
|
||||
status = "Warming up..."
|
||||
elif conf >= 80:
|
||||
status = "Stable estimate"
|
||||
|
||||
return {
|
||||
"systolic": round(sys),
|
||||
"diastolic": round(dia),
|
||||
"mean_hr": round(mean_hr, 1),
|
||||
"sdnn": round(sdnn, 1),
|
||||
"lf_hf": round(lf_hf, 2),
|
||||
"confidence": conf,
|
||||
"n_samples": len(recent),
|
||||
"status": status,
|
||||
}
|
||||
|
||||
|
||||
def bp_category(sys: int, dia: int) -> str:
|
||||
"""AHA blood pressure category."""
|
||||
if sys == 0:
|
||||
return "—"
|
||||
if sys < 120 and dia < 80:
|
||||
return "Normal"
|
||||
elif sys < 130 and dia < 80:
|
||||
return "Elevated"
|
||||
elif sys < 140 or dia < 90:
|
||||
return "High BP Stage 1"
|
||||
elif sys >= 140 or dia >= 90:
|
||||
return "High BP Stage 2"
|
||||
elif sys > 180 or dia > 120:
|
||||
return "Hypertensive Crisis"
|
||||
return "Unknown"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Contactless BP estimation from mmWave heart rate",
|
||||
epilog="NOT A MEDICAL DEVICE. For research/wellness tracking only.",
|
||||
)
|
||||
parser.add_argument("--port", default="COM4", help="mmWave sensor serial port")
|
||||
parser.add_argument("--baud", type=int, default=115200)
|
||||
parser.add_argument("--window", type=int, default=60, help="Analysis window in seconds")
|
||||
parser.add_argument("--cal-systolic", type=int, help="Calibration: your actual systolic BP")
|
||||
parser.add_argument("--cal-diastolic", type=int, help="Calibration: your actual diastolic BP")
|
||||
parser.add_argument("--cal-hr", type=int, help="Calibration: your HR at time of BP reading")
|
||||
parser.add_argument("--duration", type=int, default=120, help="Recording duration in seconds")
|
||||
args = parser.parse_args()
|
||||
|
||||
estimator = BPEstimator(
|
||||
window_sec=args.window,
|
||||
cal_sys=args.cal_systolic,
|
||||
cal_dia=args.cal_diastolic,
|
||||
cal_hr=args.cal_hr,
|
||||
)
|
||||
|
||||
try:
|
||||
ser = serial.Serial(args.port, args.baud, timeout=1)
|
||||
except Exception as e:
|
||||
print(f"Error opening {args.port}: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
print()
|
||||
print("=" * 66)
|
||||
print(" Contactless Blood Pressure Estimation (mmWave 60 GHz)")
|
||||
print(" ⚠️ NOT A MEDICAL DEVICE — research/wellness only")
|
||||
print("=" * 66)
|
||||
if args.cal_systolic:
|
||||
print(f" Calibrated: {args.cal_systolic}/{args.cal_diastolic} mmHg at {args.cal_hr} bpm")
|
||||
else:
|
||||
print(" Uncalibrated — showing relative trends. Use --cal-* for accuracy.")
|
||||
print()
|
||||
|
||||
header = f" {'Time':>5} {'HR':>5} {'SBP':>5} {'DBP':>5} {'Category':>20} {'SDNN':>6} {'LF/HF':>6} {'Conf':>4} {'Status'}"
|
||||
print(header)
|
||||
print(" " + "-" * (len(header) - 2))
|
||||
|
||||
# Print initial blank lines for live update area
|
||||
for _ in range(3):
|
||||
print()
|
||||
|
||||
start = time.time()
|
||||
last_print = 0
|
||||
|
||||
try:
|
||||
while time.time() - start < args.duration:
|
||||
line = ser.readline().decode("utf-8", errors="replace")
|
||||
clean = RE_ANSI.sub("", line)
|
||||
|
||||
m = RE_HR.search(clean)
|
||||
if m:
|
||||
hr = float(m.group(1))
|
||||
estimator.add_hr(hr)
|
||||
|
||||
# Update display every 3 seconds
|
||||
elapsed = int(time.time() - start)
|
||||
if elapsed > last_print and elapsed % 3 == 0:
|
||||
last_print = elapsed
|
||||
est = estimator.estimate()
|
||||
|
||||
if est["systolic"] > 0:
|
||||
cat = bp_category(est["systolic"], est["diastolic"])
|
||||
sys.stdout.write(f"\r {elapsed:>4}s {est['mean_hr']:>4.0f} "
|
||||
f"{est['systolic']:>4} {est['diastolic']:>4} "
|
||||
f"{cat:>20} {est['sdnn']:>5.1f} {est['lf_hf']:>5.2f} "
|
||||
f"{est['confidence']:>3}% {est['status']}")
|
||||
sys.stdout.write(" \n")
|
||||
else:
|
||||
sys.stdout.write(f"\r {elapsed:>4}s {'—':>4} {'—':>4} {'—':>4} "
|
||||
f"{'—':>20} {'—':>5} {'—':>5} "
|
||||
f"{'—':>3} {est['status']}")
|
||||
sys.stdout.write(" \n")
|
||||
sys.stdout.flush()
|
||||
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
ser.close()
|
||||
|
||||
# Final summary
|
||||
est = estimator.estimate()
|
||||
print()
|
||||
print()
|
||||
print("=" * 66)
|
||||
print(" BLOOD PRESSURE ESTIMATION SUMMARY")
|
||||
print("=" * 66)
|
||||
if est["systolic"] > 0:
|
||||
cat = bp_category(est["systolic"], est["diastolic"])
|
||||
print(f" Systolic: {est['systolic']} mmHg")
|
||||
print(f" Diastolic: {est['diastolic']} mmHg")
|
||||
print(f" Category: {cat}")
|
||||
print(f" Mean HR: {est['mean_hr']} bpm")
|
||||
print(f" HRV (SDNN): {est['sdnn']} ms")
|
||||
print(f" LF/HF ratio: {est['lf_hf']}")
|
||||
print(f" Confidence: {est['confidence']}%")
|
||||
print(f" Samples: {est['n_samples']} readings over {args.window}s window")
|
||||
else:
|
||||
print(" Insufficient data. Ensure person is within sensor range.")
|
||||
print()
|
||||
print(" ⚠️ This is an ESTIMATE based on HR/HRV correlation models.")
|
||||
print(" For actual BP measurement, use a validated cuff device.")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,391 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
RuView Medical Vitals Suite — 10 capabilities from a single mmWave sensor
|
||||
|
||||
Capabilities:
|
||||
1. Heart rate monitoring (continuous)
|
||||
2. Breathing rate monitoring (continuous)
|
||||
3. Blood pressure estimation (HRV-based)
|
||||
4. HRV stress analysis (SDNN, RMSSD, pNN50, LF/HF)
|
||||
5. Sleep stage classification (awake/light/deep/REM)
|
||||
6. Apnea event detection (BR=0 for >10s)
|
||||
7. Cough detection (BR spike pattern)
|
||||
8. Snoring detection (periodic high-amplitude BR)
|
||||
9. Activity state (resting/active/exercising)
|
||||
10. Meditation quality scorer (coherence of BR+HR)
|
||||
|
||||
Usage:
|
||||
python examples/medical/vitals_suite.py --port COM4 --duration 120
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import math
|
||||
import re
|
||||
import serial
|
||||
import sys
|
||||
import time
|
||||
|
||||
try:
|
||||
import numpy as np
|
||||
HAS_NP = True
|
||||
except ImportError:
|
||||
HAS_NP = False
|
||||
|
||||
RE_HR = re.compile(r"'Real-time heart rate'.*?(\d+\.?\d*)\s*bpm", re.I)
|
||||
RE_BR = re.compile(r"'Real-time respiratory rate'.*?(\d+\.?\d*)", re.I)
|
||||
RE_PRES = re.compile(r"'Person Information'.*?state\s+(ON|OFF)", re.I)
|
||||
RE_DIST = re.compile(r"'Distance to detection object'.*?(\d+\.?\d*)\s*cm", re.I)
|
||||
RE_ANSI = re.compile(r"\x1b\[[0-9;]*m")
|
||||
|
||||
|
||||
class WelfordStats:
|
||||
def __init__(self):
|
||||
self.count = 0
|
||||
self.mean = 0.0
|
||||
self.m2 = 0.0
|
||||
|
||||
def update(self, v):
|
||||
self.count += 1
|
||||
d = v - self.mean
|
||||
self.mean += d / self.count
|
||||
self.m2 += d * (v - self.mean)
|
||||
|
||||
def std(self):
|
||||
return math.sqrt(self.m2 / self.count) if self.count > 1 else 0.0
|
||||
|
||||
def cv(self):
|
||||
return self.std() / self.mean if self.mean > 0 else 0.0
|
||||
|
||||
|
||||
class VitalsSuite:
|
||||
def __init__(self):
|
||||
# Raw buffers
|
||||
self.hr_buf = collections.deque(maxlen=300)
|
||||
self.br_buf = collections.deque(maxlen=300)
|
||||
self.hr_ts = collections.deque(maxlen=300)
|
||||
self.br_ts = collections.deque(maxlen=300)
|
||||
self.distance = 0.0
|
||||
self.presence = False
|
||||
self.frames = 0
|
||||
|
||||
# Welford trackers
|
||||
self.hr_stats = WelfordStats()
|
||||
self.br_stats = WelfordStats()
|
||||
|
||||
# Apnea detection
|
||||
self.last_br_time = time.time()
|
||||
self.last_nonzero_br = 0.0
|
||||
self.apnea_events = []
|
||||
self.in_apnea = False
|
||||
self.apnea_start = 0.0
|
||||
|
||||
# Cough detection
|
||||
self.cough_events = []
|
||||
self.prev_br = 0.0
|
||||
|
||||
# Snoring detection
|
||||
self.snore_events = 0
|
||||
self.br_amplitude_buf = collections.deque(maxlen=30)
|
||||
|
||||
# Sleep state
|
||||
self.sleep_state = "Awake"
|
||||
self.sleep_onset = 0.0
|
||||
|
||||
# Meditation
|
||||
self.meditation_score = 0.0
|
||||
|
||||
# Events
|
||||
self.events = collections.deque(maxlen=50)
|
||||
|
||||
def feed(self, hr=0.0, br=0.0, presence=False, distance=0.0):
|
||||
now = time.time()
|
||||
self.presence = presence
|
||||
self.distance = distance
|
||||
self.frames += 1
|
||||
|
||||
if hr > 0:
|
||||
self.hr_buf.append(hr)
|
||||
self.hr_ts.append(now)
|
||||
self.hr_stats.update(hr)
|
||||
|
||||
if br > 0:
|
||||
self.br_buf.append(br)
|
||||
self.br_ts.append(now)
|
||||
self.br_stats.update(br)
|
||||
self.last_br_time = now
|
||||
self.last_nonzero_br = br
|
||||
|
||||
# Cough: sudden BR spike > 2x baseline
|
||||
if self.prev_br > 0 and br > self.prev_br * 2.5 and self.br_stats.count > 10:
|
||||
self.cough_events.append(now)
|
||||
self.events.append((now, "Cough detected"))
|
||||
|
||||
# Snoring: track BR amplitude variation
|
||||
if len(self.br_buf) >= 2:
|
||||
amp = abs(br - list(self.br_buf)[-2])
|
||||
self.br_amplitude_buf.append(amp)
|
||||
|
||||
self.prev_br = br
|
||||
|
||||
# End apnea
|
||||
if self.in_apnea:
|
||||
duration = now - self.apnea_start
|
||||
self.apnea_events.append(duration)
|
||||
self.events.append((now, f"Apnea ended ({duration:.0f}s)"))
|
||||
self.in_apnea = False
|
||||
else:
|
||||
# Apnea: BR=0 for >10s
|
||||
gap = now - self.last_br_time
|
||||
if gap >= 10 and not self.in_apnea and self.br_stats.count > 5:
|
||||
self.in_apnea = True
|
||||
self.apnea_start = self.last_br_time
|
||||
self.events.append((now, f"APNEA started (no breath for {gap:.0f}s)"))
|
||||
|
||||
# Sleep stage classification
|
||||
self._classify_sleep()
|
||||
|
||||
# Meditation score
|
||||
self._compute_meditation()
|
||||
|
||||
# Snoring: periodic high-amplitude BR oscillation
|
||||
if len(self.br_amplitude_buf) >= 10:
|
||||
amps = list(self.br_amplitude_buf)
|
||||
mean_amp = sum(amps) / len(amps)
|
||||
if mean_amp > 3.0 and self.sleep_state != "Awake":
|
||||
self.snore_events += 1
|
||||
|
||||
def _classify_sleep(self):
|
||||
"""Sleep stage from BR variability + HR patterns."""
|
||||
hrs = list(self.hr_buf)
|
||||
brs = list(self.br_buf)
|
||||
|
||||
if len(hrs) < 10 or len(brs) < 10:
|
||||
self.sleep_state = "Awake"
|
||||
return
|
||||
|
||||
recent_hr = hrs[-10:]
|
||||
recent_br = brs[-10:]
|
||||
mean_hr = sum(recent_hr) / len(recent_hr)
|
||||
mean_br = sum(recent_br) / len(recent_br)
|
||||
|
||||
# HR variability of last 10 readings
|
||||
hr_std = math.sqrt(sum((h - mean_hr) ** 2 for h in recent_hr) / len(recent_hr))
|
||||
br_std = math.sqrt(sum((b - mean_br) ** 2 for b in recent_br) / len(recent_br))
|
||||
|
||||
# Activity check
|
||||
if mean_hr > 100 or mean_br > 25:
|
||||
self.sleep_state = "Awake"
|
||||
return
|
||||
|
||||
# Low HR + low BR + low variability = deep sleep
|
||||
if mean_hr < 60 and mean_br < 14 and hr_std < 3 and br_std < 1:
|
||||
if self.sleep_state != "Deep Sleep":
|
||||
self.events.append((time.time(), "Entered deep sleep"))
|
||||
self.sleep_state = "Deep Sleep"
|
||||
# Moderate HR + high HR variability = REM
|
||||
elif hr_std > 5 and br_std > 2 and mean_br < 20:
|
||||
if self.sleep_state != "REM":
|
||||
self.events.append((time.time(), "Entered REM sleep"))
|
||||
self.sleep_state = "REM"
|
||||
# Low-moderate HR + low motion = light sleep
|
||||
elif mean_hr < 75 and mean_br < 20:
|
||||
if self.sleep_state != "Light Sleep":
|
||||
self.events.append((time.time(), "Entered light sleep"))
|
||||
self.sleep_state = "Light Sleep"
|
||||
else:
|
||||
self.sleep_state = "Awake"
|
||||
|
||||
def _compute_meditation(self):
|
||||
"""Meditation quality: BR regularity + HR deceleration + HRV increase."""
|
||||
brs = list(self.br_buf)
|
||||
hrs = list(self.hr_buf)
|
||||
if len(brs) < 15 or len(hrs) < 15:
|
||||
self.meditation_score = 0.0
|
||||
return
|
||||
|
||||
# BR regularity (lower CV = more regular breathing)
|
||||
br_recent = brs[-15:]
|
||||
br_mean = sum(br_recent) / len(br_recent)
|
||||
br_std = math.sqrt(sum((b - br_mean) ** 2 for b in br_recent) / len(br_recent))
|
||||
br_cv = br_std / br_mean if br_mean > 0 else 1.0
|
||||
br_score = max(0, min(1, 1.0 - br_cv * 5)) # CV < 0.05 = perfect
|
||||
|
||||
# HR deceleration (lower HR = better)
|
||||
hr_recent = hrs[-15:]
|
||||
mean_hr = sum(hr_recent) / len(hr_recent)
|
||||
hr_score = max(0, min(1, (90 - mean_hr) / 30)) # 60bpm=1.0, 90bpm=0.0
|
||||
|
||||
# HRV increase (higher SDNN = better)
|
||||
rr = [60000 / h for h in hr_recent if h > 0]
|
||||
if len(rr) >= 5:
|
||||
rr_mean = sum(rr) / len(rr)
|
||||
sdnn = math.sqrt(sum((r - rr_mean) ** 2 for r in rr) / len(rr))
|
||||
hrv_score = max(0, min(1, sdnn / 100)) # 100ms SDNN = perfect
|
||||
else:
|
||||
hrv_score = 0.0
|
||||
|
||||
self.meditation_score = (br_score * 0.4 + hr_score * 0.3 + hrv_score * 0.3) * 100
|
||||
|
||||
def activity_state(self):
|
||||
if len(self.hr_buf) < 3:
|
||||
return "Unknown"
|
||||
recent = list(self.hr_buf)[-5:]
|
||||
mean_hr = sum(recent) / len(recent)
|
||||
if mean_hr > 120:
|
||||
return "Exercising"
|
||||
elif mean_hr > 90:
|
||||
return "Active"
|
||||
elif mean_hr > 60:
|
||||
return "Resting"
|
||||
else:
|
||||
return "Deep Rest"
|
||||
|
||||
def hrv(self):
|
||||
hrs = list(self.hr_buf)
|
||||
if len(hrs) < 5:
|
||||
return {"sdnn": 0, "rmssd": 0, "pnn50": 0}
|
||||
rr = [60000 / h for h in hrs if h > 0]
|
||||
if len(rr) < 5:
|
||||
return {"sdnn": 0, "rmssd": 0, "pnn50": 0}
|
||||
mean = sum(rr) / len(rr)
|
||||
sdnn = math.sqrt(sum((r - mean) ** 2 for r in rr) / len(rr))
|
||||
diffs = [abs(rr[i + 1] - rr[i]) for i in range(len(rr) - 1)]
|
||||
rmssd = math.sqrt(sum(d ** 2 for d in diffs) / len(diffs)) if diffs else 0
|
||||
pnn50 = sum(1 for d in diffs if d > 50) / len(diffs) * 100 if diffs else 0
|
||||
return {"sdnn": sdnn, "rmssd": rmssd, "pnn50": pnn50}
|
||||
|
||||
def bp(self):
|
||||
hrs = list(self.hr_buf)
|
||||
if len(hrs) < 5:
|
||||
return 0, 0
|
||||
mean_hr = sum(hrs) / len(hrs)
|
||||
hrv = self.hrv()
|
||||
if hrv["sdnn"] <= 0:
|
||||
return 0, 0
|
||||
delta = mean_hr - 72
|
||||
sbp = round(max(80, min(200, 120 + 0.5 * delta - 0.8 * (hrv["sdnn"] - 50) / 50)))
|
||||
dbp = round(max(50, min(130, 80 + 0.3 * delta - 0.5 * (hrv["sdnn"] - 50) / 50)))
|
||||
return sbp, dbp
|
||||
|
||||
def stress(self):
|
||||
h = self.hrv()
|
||||
s = h["sdnn"]
|
||||
if s <= 0: return "---"
|
||||
if s < 30: return "HIGH"
|
||||
if s < 50: return "Moderate"
|
||||
if s < 80: return "Mild"
|
||||
if s < 100: return "Relaxed"
|
||||
return "Calm"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Medical Vitals Suite (10 capabilities)")
|
||||
parser.add_argument("--port", default="COM4")
|
||||
parser.add_argument("--baud", type=int, default=115200)
|
||||
parser.add_argument("--duration", type=int, default=120)
|
||||
args = parser.parse_args()
|
||||
|
||||
ser = serial.Serial(args.port, args.baud, timeout=1)
|
||||
suite = VitalsSuite()
|
||||
start = time.time()
|
||||
last_print = 0
|
||||
|
||||
print()
|
||||
print("=" * 80)
|
||||
print(" RuView Medical Vitals Suite (10 capabilities from 1 sensor)")
|
||||
print(" Point MR60BHA2 at yourself within 1m. Sit still.")
|
||||
print("=" * 80)
|
||||
print()
|
||||
print(f"{'s':>4} {'HR':>4} {'BR':>3} {'BP':>7} {'Stress':>8} {'SDNN':>5} "
|
||||
f"{'Sleep':>11} {'Activity':>10} {'Medit':>5} "
|
||||
f"{'Apnea':>5} {'Cough':>5} {'Snore':>5}")
|
||||
print("-" * 80)
|
||||
|
||||
try:
|
||||
while time.time() - start < args.duration:
|
||||
line = ser.readline().decode("utf-8", errors="replace")
|
||||
clean = RE_ANSI.sub("", line)
|
||||
|
||||
hr, br, pres, dist = 0.0, 0.0, suite.presence, suite.distance
|
||||
m = RE_HR.search(clean)
|
||||
if m: hr = float(m.group(1))
|
||||
m = RE_BR.search(clean)
|
||||
if m: br = float(m.group(1))
|
||||
m = RE_PRES.search(clean)
|
||||
if m: pres = m.group(1) == "ON"
|
||||
m = RE_DIST.search(clean)
|
||||
if m: dist = float(m.group(1))
|
||||
|
||||
if hr > 0 or br > 0:
|
||||
suite.feed(hr=hr, br=br, presence=pres, distance=dist)
|
||||
|
||||
elapsed = int(time.time() - start)
|
||||
if elapsed > last_print and elapsed % 5 == 0:
|
||||
last_print = elapsed
|
||||
hrv = suite.hrv()
|
||||
sbp, dbp = suite.bp()
|
||||
bp_s = f"{sbp:>3}/{dbp:<3}" if sbp > 0 else " --- "
|
||||
sdnn_s = f"{hrv['sdnn']:>5.0f}" if hrv["sdnn"] > 0 else " ---"
|
||||
|
||||
hrs = list(suite.hr_buf)
|
||||
mean_hr = sum(hrs) / len(hrs) if hrs else 0
|
||||
|
||||
brs = list(suite.br_buf)
|
||||
mean_br = sum(brs) / len(brs) if brs else 0
|
||||
|
||||
print(f"{elapsed:>3}s {mean_hr:>4.0f} {mean_br:>3.0f} {bp_s} {suite.stress():>8} {sdnn_s} "
|
||||
f"{suite.sleep_state:>11} {suite.activity_state():>10} {suite.meditation_score:>5.0f} "
|
||||
f"{len(suite.apnea_events):>5} {len(suite.cough_events):>5} {suite.snore_events:>5}")
|
||||
|
||||
# Print recent events
|
||||
for ts, msg in list(suite.events)[-3:]:
|
||||
if time.time() - ts < 6:
|
||||
print(f" >> {msg}")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
ser.close()
|
||||
elapsed = time.time() - start
|
||||
|
||||
print()
|
||||
print("=" * 80)
|
||||
print(" VITALS SUITE SUMMARY")
|
||||
print("=" * 80)
|
||||
hrv = suite.hrv()
|
||||
sbp, dbp = suite.bp()
|
||||
hrs = list(suite.hr_buf)
|
||||
brs = list(suite.br_buf)
|
||||
|
||||
print(f" Duration: {elapsed:.0f}s")
|
||||
print(f" Readings: {suite.frames}")
|
||||
print()
|
||||
|
||||
if hrs:
|
||||
print(f" 1. Heart Rate: {sum(hrs)/len(hrs):.0f} bpm (range {min(hrs):.0f}-{max(hrs):.0f})")
|
||||
if brs:
|
||||
print(f" 2. Breathing: {sum(brs)/len(brs):.0f}/min (range {min(brs):.0f}-{max(brs):.0f})")
|
||||
if sbp:
|
||||
print(f" 3. BP Estimate: {sbp}/{dbp} mmHg")
|
||||
if hrv["sdnn"] > 0:
|
||||
print(f" 4. HRV/Stress: SDNN={hrv['sdnn']:.0f}ms RMSSD={hrv['rmssd']:.0f}ms pNN50={hrv['pnn50']:.1f}% -> {suite.stress()}")
|
||||
print(f" 5. Sleep State: {suite.sleep_state}")
|
||||
print(f" 6. Apnea Events: {len(suite.apnea_events)} {'(AHI=' + str(round(len(suite.apnea_events)/(elapsed/3600),1)) + '/hr)' if suite.apnea_events else ''}")
|
||||
print(f" 7. Cough Events: {len(suite.cough_events)}")
|
||||
print(f" 8. Snore Events: {suite.snore_events}")
|
||||
print(f" 9. Activity: {suite.activity_state()}")
|
||||
print(f" 10. Meditation: {suite.meditation_score:.0f}/100")
|
||||
|
||||
if suite.events:
|
||||
print(f"\n Events ({len(suite.events)}):")
|
||||
for ts, msg in list(suite.events)[-15:]:
|
||||
print(f" [{int(ts-start):>4}s] {msg}")
|
||||
|
||||
print()
|
||||
print(" NOT A MEDICAL DEVICE. For research/wellness only.")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,776 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
RuView Live — Ambient Intelligence Dashboard with RuVector Signal Processing
|
||||
|
||||
Fuses WiFi CSI (ESP32-S3) + 60 GHz mmWave (MR60BHA2) with signal processing
|
||||
algorithms ported from RuView's Rust crates:
|
||||
|
||||
- wifi-densepose-vitals: BreathingExtractor (bandpass + zero-crossing),
|
||||
HeartRateExtractor, VitalAnomalyDetector (Welford z-score)
|
||||
- ruvsense/longitudinal: Drift detection via Welford online statistics
|
||||
- ruvsense/adversarial: Signal consistency checks
|
||||
- ruvsense/coherence: Z-score coherence scoring with DriftProfile
|
||||
|
||||
Usage:
|
||||
python examples/ruview_live.py --csi COM7 --mmwave COM4
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import serial
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
try:
|
||||
import numpy as np
|
||||
HAS_NP = True
|
||||
except ImportError:
|
||||
HAS_NP = False
|
||||
|
||||
RE_ANSI = re.compile(r"\x1b\[[0-9;]*m")
|
||||
RE_MW_HR = re.compile(r"'Real-time heart rate'.*?(\d+\.?\d*)\s*bpm", re.I)
|
||||
RE_MW_BR = re.compile(r"'Real-time respiratory rate'.*?(\d+\.?\d*)", re.I)
|
||||
RE_MW_PRES = re.compile(r"'Person Information'.*?state\s+(ON|OFF)", re.I)
|
||||
RE_MW_DIST = re.compile(r"'Distance to detection object'.*?(\d+\.?\d*)\s*cm", re.I)
|
||||
RE_MW_LUX = re.compile(r"illuminance=(\d+\.?\d*)", re.I)
|
||||
RE_CSI_CB = re.compile(r"CSI cb #(\d+).*?rssi=(-?\d+)")
|
||||
RE_CSI_VITALS = re.compile(r"Vitals:.*?br=(\d+\.?\d*).*?hr=(\d+\.?\d*).*?motion=(\d+\.?\d*).*?pres=(\w+)", re.I)
|
||||
RE_CSI_FALL = re.compile(r"Fall detected.*?accel=(\d+\.?\d*)")
|
||||
RE_CSI_CALIB = re.compile(r"Adaptive calibration.*?threshold=(\d+\.?\d*)")
|
||||
|
||||
|
||||
# ====================================================================
|
||||
# RuVector-inspired signal processing (ported from Rust crates)
|
||||
# ====================================================================
|
||||
|
||||
class WelfordStats:
|
||||
"""Welford online statistics — from ruvsense/field_model.rs and vitals/anomaly.rs"""
|
||||
|
||||
def __init__(self):
|
||||
self.count = 0
|
||||
self.mean = 0.0
|
||||
self.m2 = 0.0
|
||||
|
||||
def update(self, value):
|
||||
self.count += 1
|
||||
delta = value - self.mean
|
||||
self.mean += delta / self.count
|
||||
delta2 = value - self.mean
|
||||
self.m2 += delta * delta2
|
||||
|
||||
def variance(self):
|
||||
return self.m2 / self.count if self.count > 1 else 0.0
|
||||
|
||||
def std(self):
|
||||
return math.sqrt(self.variance())
|
||||
|
||||
def z_score(self, value):
|
||||
s = self.std()
|
||||
return abs(value - self.mean) / s if s > 0 else 0.0
|
||||
|
||||
|
||||
class VitalAnomalyDetector:
|
||||
"""Ported from wifi-densepose-vitals/anomaly.rs — Welford z-score detection."""
|
||||
|
||||
def __init__(self, z_threshold=2.5):
|
||||
self.z_threshold = z_threshold
|
||||
self.hr_stats = WelfordStats()
|
||||
self.br_stats = WelfordStats()
|
||||
self.rr_stats = WelfordStats() # R-R interval stats
|
||||
self.alerts = []
|
||||
|
||||
def check(self, hr=0.0, br=0.0):
|
||||
self.alerts.clear()
|
||||
|
||||
if hr > 0:
|
||||
if self.hr_stats.count >= 10:
|
||||
z = self.hr_stats.z_score(hr)
|
||||
if z > self.z_threshold:
|
||||
if hr > self.hr_stats.mean:
|
||||
self.alerts.append(("cardiac", "tachycardia", z, f"HR {hr:.0f} ({z:.1f}sd above baseline {self.hr_stats.mean:.0f})"))
|
||||
else:
|
||||
self.alerts.append(("cardiac", "bradycardia", z, f"HR {hr:.0f} ({z:.1f}sd below baseline {self.hr_stats.mean:.0f})"))
|
||||
self.hr_stats.update(hr)
|
||||
|
||||
rr = 60000.0 / hr
|
||||
self.rr_stats.update(rr)
|
||||
|
||||
if br > 0:
|
||||
if self.br_stats.count >= 10:
|
||||
z = self.br_stats.z_score(br)
|
||||
if z > self.z_threshold:
|
||||
self.alerts.append(("respiratory", "abnormal_rate", z, f"BR {br:.0f} ({z:.1f}sd from baseline {self.br_stats.mean:.0f})"))
|
||||
elif br == 0 and self.br_stats.count > 5 and self.br_stats.mean > 5:
|
||||
self.alerts.append(("respiratory", "apnea", 5.0, "Breathing stopped"))
|
||||
self.br_stats.update(br)
|
||||
|
||||
return self.alerts
|
||||
|
||||
|
||||
class LongitudinalTracker:
|
||||
"""Ported from ruvsense/longitudinal.rs — drift detection over time."""
|
||||
|
||||
def __init__(self, drift_sigma=2.0, min_observations=10):
|
||||
self.drift_sigma = drift_sigma
|
||||
self.min_obs = min_observations
|
||||
self.metrics = {} # name -> WelfordStats
|
||||
|
||||
def observe(self, metric_name, value):
|
||||
if metric_name not in self.metrics:
|
||||
self.metrics[metric_name] = WelfordStats()
|
||||
self.metrics[metric_name].update(value)
|
||||
|
||||
def check_drift(self, metric_name, value):
|
||||
if metric_name not in self.metrics:
|
||||
return None
|
||||
stats = self.metrics[metric_name]
|
||||
if stats.count < self.min_obs:
|
||||
return None
|
||||
z = stats.z_score(value)
|
||||
if z > self.drift_sigma:
|
||||
direction = "above" if value > stats.mean else "below"
|
||||
return f"{metric_name} drifting {direction} baseline ({z:.1f}sd, mean={stats.mean:.1f})"
|
||||
return None
|
||||
|
||||
def summary(self):
|
||||
result = {}
|
||||
for name, stats in self.metrics.items():
|
||||
result[name] = {"mean": stats.mean, "std": stats.std(), "n": stats.count}
|
||||
return result
|
||||
|
||||
|
||||
class CoherenceScorer:
|
||||
"""Ported from ruvsense/coherence.rs — signal quality scoring."""
|
||||
|
||||
def __init__(self, decay=0.95):
|
||||
self.decay = decay
|
||||
self.score = 0.5
|
||||
self.stale_count = 0
|
||||
self.last_update = 0.0
|
||||
|
||||
def update(self, signal_quality):
|
||||
"""signal_quality: 0.0 (bad) to 1.0 (perfect)."""
|
||||
self.score = self.decay * self.score + (1 - self.decay) * signal_quality
|
||||
self.last_update = time.time()
|
||||
if signal_quality < 0.1:
|
||||
self.stale_count += 1
|
||||
else:
|
||||
self.stale_count = 0
|
||||
|
||||
def is_coherent(self):
|
||||
return self.score > 0.3 and self.stale_count < 10
|
||||
|
||||
def age_ms(self):
|
||||
return int((time.time() - self.last_update) * 1000) if self.last_update > 0 else -1
|
||||
|
||||
|
||||
class HRVAnalyzer:
|
||||
"""Advanced HRV analysis — ported from wifi-densepose-vitals/heartrate.rs concepts."""
|
||||
|
||||
def __init__(self, window=60):
|
||||
self.rr_intervals = collections.deque(maxlen=window)
|
||||
|
||||
def add_hr(self, hr):
|
||||
if 30 < hr < 200:
|
||||
self.rr_intervals.append(60000.0 / hr)
|
||||
|
||||
def compute(self):
|
||||
rr = list(self.rr_intervals)
|
||||
if len(rr) < 5:
|
||||
return {"sdnn": 0, "rmssd": 0, "pnn50": 0, "lf_hf": 1.5, "n": len(rr)}
|
||||
|
||||
mean = sum(rr) / len(rr)
|
||||
sdnn = math.sqrt(sum((x - mean) ** 2 for x in rr) / len(rr))
|
||||
|
||||
diffs = [abs(rr[i + 1] - rr[i]) for i in range(len(rr) - 1)]
|
||||
rmssd = math.sqrt(sum(d ** 2 for d in diffs) / len(diffs)) if diffs else 0
|
||||
pnn50 = sum(1 for d in diffs if d > 50) / len(diffs) * 100 if diffs else 0
|
||||
|
||||
# Spectral LF/HF estimate
|
||||
lf_hf = 1.5
|
||||
if HAS_NP and len(rr) >= 20:
|
||||
arr = np.array(rr) - np.mean(rr)
|
||||
fft = np.fft.rfft(arr)
|
||||
psd = np.abs(fft) ** 2 / len(arr)
|
||||
freqs = np.fft.rfftfreq(len(arr), d=1.0)
|
||||
lf = np.sum(psd[(freqs >= 0.04) & (freqs < 0.15)])
|
||||
hf = np.sum(psd[(freqs >= 0.15) & (freqs < 0.4)])
|
||||
lf_hf = float(lf / max(hf, 0.001))
|
||||
lf_hf = min(max(lf_hf, 0.1), 10.0)
|
||||
|
||||
return {"sdnn": sdnn, "rmssd": rmssd, "pnn50": pnn50, "lf_hf": lf_hf, "n": len(rr)}
|
||||
|
||||
|
||||
class BPEstimator:
|
||||
"""Blood pressure from HRV — calibratable."""
|
||||
|
||||
def __init__(self, cal_sys=None, cal_dia=None, cal_hr=None):
|
||||
self.offset_sys = 0.0
|
||||
self.offset_dia = 0.0
|
||||
if cal_sys and cal_hr:
|
||||
self.offset_sys = cal_sys - (120 + 0.5 * (cal_hr - 72))
|
||||
if cal_dia and cal_hr:
|
||||
self.offset_dia = cal_dia - (80 + 0.3 * (cal_hr - 72))
|
||||
|
||||
def estimate(self, hr, sdnn, lf_hf=1.5):
|
||||
if hr <= 0 or sdnn <= 0:
|
||||
return 0, 0
|
||||
delta = hr - 72
|
||||
sbp = 120 + 0.5 * delta - 0.8 * (sdnn - 50) / 50 + 3.0 * (lf_hf - 1.5) + self.offset_sys
|
||||
dbp = 80 + 0.3 * delta - 0.5 * (sdnn - 50) / 50 + 2.0 * (lf_hf - 1.5) + self.offset_dia
|
||||
return round(max(80, min(200, sbp))), round(max(50, min(130, dbp)))
|
||||
|
||||
|
||||
class HappinessScorer:
|
||||
"""Multimodal happiness estimator fusing gait, breathing, and social signals."""
|
||||
|
||||
def __init__(self):
|
||||
self.gait_speed = WelfordStats()
|
||||
self.stride_regularity = WelfordStats()
|
||||
self.movement_fluidity = 0.5
|
||||
self.breathing_calm = 0.5
|
||||
self.posture_score = 0.5
|
||||
self.dwell_frames = 0
|
||||
self._prev_motion = 0.0
|
||||
self._motion_deltas = collections.deque(maxlen=30)
|
||||
self._br_baseline = WelfordStats()
|
||||
self._rssi_baseline = WelfordStats()
|
||||
|
||||
def update(self, motion_energy, br, hr, rssi):
|
||||
# Gait speed proxy from motion energy
|
||||
self.gait_speed.update(motion_energy)
|
||||
|
||||
# Stride regularity from motion delta consistency
|
||||
delta = abs(motion_energy - self._prev_motion)
|
||||
self._motion_deltas.append(delta)
|
||||
self._prev_motion = motion_energy
|
||||
if len(self._motion_deltas) >= 5:
|
||||
deltas = list(self._motion_deltas)
|
||||
mean_d = sum(deltas) / len(deltas)
|
||||
var_d = sum((x - mean_d) ** 2 for x in deltas) / len(deltas)
|
||||
self.stride_regularity.update(1.0 / (1.0 + math.sqrt(var_d)))
|
||||
|
||||
# Movement fluidity — smooth transitions score higher
|
||||
if len(self._motion_deltas) >= 3:
|
||||
recent = list(self._motion_deltas)[-3:]
|
||||
jerk = abs(recent[-1] - recent[-2]) - abs(recent[-2] - recent[-3]) if len(recent) == 3 else 0
|
||||
self.movement_fluidity = 0.9 * self.movement_fluidity + 0.1 * (1.0 / (1.0 + abs(jerk)))
|
||||
|
||||
# Breathing calm — low BR variance means relaxed
|
||||
if br > 0:
|
||||
self._br_baseline.update(br)
|
||||
if self._br_baseline.count >= 5:
|
||||
br_z = self._br_baseline.z_score(br)
|
||||
self.breathing_calm = 0.9 * self.breathing_calm + 0.1 * max(0.0, 1.0 - br_z / 3.0)
|
||||
|
||||
# Posture proxy from RSSI stability
|
||||
if rssi != 0:
|
||||
self._rssi_baseline.update(rssi)
|
||||
if self._rssi_baseline.count >= 5:
|
||||
rssi_z = self._rssi_baseline.z_score(rssi)
|
||||
self.posture_score = 0.9 * self.posture_score + 0.1 * max(0.0, 1.0 - rssi_z / 3.0)
|
||||
|
||||
# Dwell — presence accumulation
|
||||
if motion_energy > 0.01 or br > 0:
|
||||
self.dwell_frames += 1
|
||||
|
||||
def compute(self):
|
||||
# Normalize gait energy to 0-1 range
|
||||
gait_e = min(1.0, self.gait_speed.mean / 5.0) if self.gait_speed.count > 0 else 0.0
|
||||
|
||||
# Stride regularity average
|
||||
stride_r = min(1.0, self.stride_regularity.mean) if self.stride_regularity.count > 0 else 0.5
|
||||
|
||||
# Dwell factor — saturates after ~300 frames (~5 min at 1 Hz)
|
||||
dwell_factor = min(1.0, self.dwell_frames / 300.0)
|
||||
|
||||
# Weighted happiness score
|
||||
happiness = (
|
||||
0.25 * gait_e
|
||||
+ 0.15 * stride_r
|
||||
+ 0.20 * self.movement_fluidity
|
||||
+ 0.20 * self.breathing_calm
|
||||
+ 0.10 * self.posture_score
|
||||
+ 0.10 * dwell_factor
|
||||
)
|
||||
happiness = max(0.0, min(1.0, happiness))
|
||||
|
||||
# Affect valence: breathing_calm and fluidity dominant
|
||||
affect_valence = 0.5 * self.breathing_calm + 0.3 * self.movement_fluidity + 0.2 * stride_r
|
||||
|
||||
# Social energy: gait + dwell
|
||||
social_energy = 0.6 * gait_e + 0.4 * dwell_factor
|
||||
|
||||
vector = [
|
||||
happiness, gait_e, stride_r, self.movement_fluidity,
|
||||
self.breathing_calm, self.posture_score, dwell_factor, affect_valence,
|
||||
]
|
||||
|
||||
return {
|
||||
"happiness": happiness,
|
||||
"gait_energy": gait_e,
|
||||
"affect_valence": affect_valence,
|
||||
"social_energy": social_energy,
|
||||
"vector": vector,
|
||||
}
|
||||
|
||||
|
||||
class SeedBridge:
|
||||
"""HTTP bridge to Cognitum Seed for happiness vector ingestion."""
|
||||
|
||||
def __init__(self, base_url):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self._last_drift = None
|
||||
self._drift_lock = threading.Lock()
|
||||
|
||||
def ingest(self, vector, metadata=None):
|
||||
"""POST happiness vector to Seed in a background thread."""
|
||||
payload = json.dumps({"vector": vector, "metadata": metadata or {}}).encode()
|
||||
|
||||
def _post():
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
f"{self.base_url}/api/v1/store/ingest",
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
urllib.request.urlopen(req, timeout=5)
|
||||
except Exception:
|
||||
pass # silently ignore connection errors
|
||||
|
||||
threading.Thread(target=_post, daemon=True).start()
|
||||
|
||||
def get_drift(self):
|
||||
"""GET drift status from Seed. Returns dict or None."""
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
f"{self.base_url}/api/v1/sensor/drift/status",
|
||||
method="GET",
|
||||
)
|
||||
resp = urllib.request.urlopen(req, timeout=3)
|
||||
data = json.loads(resp.read().decode())
|
||||
with self._drift_lock:
|
||||
self._last_drift = data
|
||||
return data
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@property
|
||||
def last_drift(self):
|
||||
with self._drift_lock:
|
||||
return self._last_drift
|
||||
|
||||
|
||||
# ====================================================================
|
||||
# Sensor Hub
|
||||
# ====================================================================
|
||||
|
||||
class SensorHub:
|
||||
def __init__(self, seed_url=None):
|
||||
self.lock = threading.Lock()
|
||||
self.mw_hr = 0.0
|
||||
self.mw_br = 0.0
|
||||
self.mw_presence = False
|
||||
self.mw_distance = 0.0
|
||||
self.mw_lux = 0.0
|
||||
self.mw_frames = 0
|
||||
self.mw_ok = False
|
||||
self.csi_hr = 0.0
|
||||
self.csi_br = 0.0
|
||||
self.csi_motion = 0.0
|
||||
self.csi_presence = False
|
||||
self.csi_rssi = 0
|
||||
self.csi_frames = 0
|
||||
self.csi_ok = False
|
||||
self.csi_fall = False
|
||||
self.events = collections.deque(maxlen=50)
|
||||
# RuVector processors
|
||||
self.hrv = HRVAnalyzer()
|
||||
self.anomaly = VitalAnomalyDetector()
|
||||
self.longitudinal = LongitudinalTracker()
|
||||
self.coherence_mw = CoherenceScorer()
|
||||
self.coherence_csi = CoherenceScorer()
|
||||
self.bp = BPEstimator()
|
||||
# Happiness + Seed
|
||||
self.happiness = HappinessScorer()
|
||||
self.seed = SeedBridge(seed_url) if seed_url else None
|
||||
self._last_seed_ingest = 0.0
|
||||
|
||||
def update_mw(self, **kw):
|
||||
with self.lock:
|
||||
for k, v in kw.items():
|
||||
setattr(self, f"mw_{k}", v)
|
||||
self.mw_ok = True
|
||||
hr = kw.get("hr", 0)
|
||||
br = kw.get("br", 0)
|
||||
if hr > 0:
|
||||
self.hrv.add_hr(hr)
|
||||
self.longitudinal.observe("hr", hr)
|
||||
self.coherence_mw.update(1.0)
|
||||
else:
|
||||
self.coherence_mw.update(0.1)
|
||||
if br > 0:
|
||||
self.longitudinal.observe("br", br)
|
||||
alerts = self.anomaly.check(hr=hr, br=br)
|
||||
for a in alerts:
|
||||
self.events.append((time.time(), f"ANOMALY: {a[3]}"))
|
||||
|
||||
def update_csi(self, **kw):
|
||||
with self.lock:
|
||||
for k, v in kw.items():
|
||||
setattr(self, f"csi_{k}", v)
|
||||
self.csi_ok = True
|
||||
rssi = kw.get("rssi", 0)
|
||||
if rssi != 0:
|
||||
self.longitudinal.observe("rssi", rssi)
|
||||
self.coherence_csi.update(min(1.0, max(0.0, (rssi + 90) / 50)))
|
||||
# Feed happiness scorer
|
||||
self.happiness.update(
|
||||
motion_energy=kw.get("motion", self.csi_motion),
|
||||
br=kw.get("br", self.csi_br),
|
||||
hr=kw.get("hr", self.csi_hr),
|
||||
rssi=rssi,
|
||||
)
|
||||
|
||||
def add_event(self, msg):
|
||||
with self.lock:
|
||||
self.events.append((time.time(), msg))
|
||||
|
||||
def compute(self):
|
||||
with self.lock:
|
||||
hrv = self.hrv.compute()
|
||||
mw_hr = self.mw_hr
|
||||
csi_hr = self.csi_hr
|
||||
|
||||
if mw_hr > 0 and csi_hr > 0:
|
||||
fused_hr = mw_hr * 0.8 + csi_hr * 0.2
|
||||
hr_src = "Fused"
|
||||
elif mw_hr > 0:
|
||||
fused_hr = mw_hr
|
||||
hr_src = "mmWave"
|
||||
elif csi_hr > 0:
|
||||
fused_hr = csi_hr
|
||||
hr_src = "CSI"
|
||||
else:
|
||||
fused_hr = 0
|
||||
hr_src = "—"
|
||||
|
||||
mw_br = self.mw_br
|
||||
csi_br = self.csi_br
|
||||
fused_br = mw_br * 0.8 + csi_br * 0.2 if mw_br > 0 and csi_br > 0 else mw_br or csi_br
|
||||
|
||||
sbp, dbp = self.bp.estimate(fused_hr, hrv["sdnn"], hrv["lf_hf"])
|
||||
|
||||
# Stress from SDNN
|
||||
sdnn = hrv["sdnn"]
|
||||
if sdnn <= 0:
|
||||
stress = "—"
|
||||
elif sdnn < 30:
|
||||
stress = "HIGH"
|
||||
elif sdnn < 50:
|
||||
stress = "Moderate"
|
||||
elif sdnn < 80:
|
||||
stress = "Mild"
|
||||
elif sdnn < 100:
|
||||
stress = "Relaxed"
|
||||
else:
|
||||
stress = "Calm"
|
||||
|
||||
# Drift checks
|
||||
drifts = []
|
||||
for metric in ["hr", "br", "rssi"]:
|
||||
val = {"hr": fused_hr, "br": fused_br, "rssi": self.csi_rssi}.get(metric, 0)
|
||||
if val:
|
||||
d = self.longitudinal.check_drift(metric, val)
|
||||
if d:
|
||||
drifts.append(d)
|
||||
|
||||
# Happiness
|
||||
happy = self.happiness.compute()
|
||||
|
||||
# Seed ingestion every 5 seconds
|
||||
now = time.time()
|
||||
if self.seed and now - self._last_seed_ingest >= 5.0:
|
||||
self._last_seed_ingest = now
|
||||
self.seed.ingest(happy["vector"], {
|
||||
"hr": fused_hr, "br": fused_br, "rssi": self.csi_rssi,
|
||||
"presence": self.mw_presence or self.csi_presence,
|
||||
})
|
||||
|
||||
return {
|
||||
"hr": fused_hr, "hr_src": hr_src,
|
||||
"br": fused_br, "sbp": sbp, "dbp": dbp,
|
||||
"stress": stress, "sdnn": sdnn, "rmssd": hrv["rmssd"],
|
||||
"pnn50": hrv["pnn50"], "lf_hf": hrv["lf_hf"],
|
||||
"presence": self.mw_presence or self.csi_presence,
|
||||
"distance": self.mw_distance, "lux": self.mw_lux,
|
||||
"rssi": self.csi_rssi, "motion": self.csi_motion,
|
||||
"csi_frames": self.csi_frames, "mw_frames": self.mw_frames,
|
||||
"coh_mw": self.coherence_mw.score, "coh_csi": self.coherence_csi.score,
|
||||
"fall": self.csi_fall, "drifts": drifts,
|
||||
"events": list(self.events),
|
||||
"longitudinal": self.longitudinal.summary(),
|
||||
"happiness": happy["happiness"],
|
||||
"gait_energy": happy["gait_energy"],
|
||||
"affect_valence": happy["affect_valence"],
|
||||
"social_energy": happy["social_energy"],
|
||||
"happiness_vector": happy["vector"],
|
||||
}
|
||||
|
||||
|
||||
# ====================================================================
|
||||
# Serial readers
|
||||
# ====================================================================
|
||||
|
||||
def reader_mmwave(port, baud, hub, stop):
|
||||
try:
|
||||
ser = serial.Serial(port, baud, timeout=1)
|
||||
hub.add_event(f"mmWave: {port}")
|
||||
except Exception as e:
|
||||
hub.add_event(f"mmWave FAIL: {e}")
|
||||
return
|
||||
prev_pres = None
|
||||
while not stop.is_set():
|
||||
try:
|
||||
line = ser.readline().decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
continue
|
||||
c = RE_ANSI.sub("", line)
|
||||
m = RE_MW_HR.search(c)
|
||||
if m:
|
||||
hub.update_mw(hr=float(m.group(1)), frames=hub.mw_frames + 1)
|
||||
m = RE_MW_BR.search(c)
|
||||
if m:
|
||||
hub.update_mw(br=float(m.group(1)))
|
||||
m = RE_MW_PRES.search(c)
|
||||
if m:
|
||||
p = m.group(1) == "ON"
|
||||
if prev_pres is not None and p != prev_pres:
|
||||
hub.add_event(f"Person {'arrived' if p else 'left'}")
|
||||
prev_pres = p
|
||||
hub.update_mw(presence=p)
|
||||
m = RE_MW_DIST.search(c)
|
||||
if m:
|
||||
hub.update_mw(distance=float(m.group(1)))
|
||||
m = RE_MW_LUX.search(c)
|
||||
if m:
|
||||
hub.update_mw(lux=float(m.group(1)))
|
||||
ser.close()
|
||||
|
||||
|
||||
def reader_csi(port, baud, hub, stop):
|
||||
try:
|
||||
ser = serial.Serial(port, baud, timeout=1)
|
||||
hub.add_event(f"CSI: {port}")
|
||||
except Exception as e:
|
||||
hub.add_event(f"CSI FAIL: {e}")
|
||||
return
|
||||
while not stop.is_set():
|
||||
try:
|
||||
line = ser.readline().decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
continue
|
||||
m = RE_CSI_VITALS.search(line)
|
||||
if m:
|
||||
hub.update_csi(br=float(m.group(1)), hr=float(m.group(2)),
|
||||
motion=float(m.group(3)), presence=m.group(4).upper() == "YES")
|
||||
m = RE_CSI_CB.search(line)
|
||||
if m:
|
||||
hub.update_csi(frames=int(m.group(1)), rssi=int(m.group(2)))
|
||||
m = RE_CSI_FALL.search(line)
|
||||
if m:
|
||||
hub.update_csi(fall=True)
|
||||
hub.add_event(f"FALL (accel={m.group(1)})")
|
||||
m = RE_CSI_CALIB.search(line)
|
||||
if m:
|
||||
hub.add_event(f"CSI calibrated (thresh={m.group(1)})")
|
||||
ser.close()
|
||||
|
||||
|
||||
# ====================================================================
|
||||
# Display
|
||||
# ====================================================================
|
||||
|
||||
def _happiness_bar(value, width=10):
|
||||
"""Render a bar like [====------] 0.62"""
|
||||
filled = int(round(value * width))
|
||||
return "[" + "=" * filled + "-" * (width - filled) + "]"
|
||||
|
||||
|
||||
def run_display(hub, duration, interval, mode="vitals"):
|
||||
start = time.time()
|
||||
last = 0
|
||||
|
||||
print()
|
||||
print("=" * 80)
|
||||
if mode == "happiness":
|
||||
print(" RuView Live — Happiness + Cognitum Seed Dashboard")
|
||||
else:
|
||||
print(" RuView Live — Ambient Intelligence + RuVector Signal Processing")
|
||||
print("=" * 80)
|
||||
print()
|
||||
|
||||
if mode == "happiness":
|
||||
hdr = (f"{'s':>4} {'Happy':>16} {'Gait':>5} {'Calm':>5} "
|
||||
f"{'Social':>6} {'Pres':>4} {'RSSI':>5} {'Seed':>6} {'CSI#':>5}")
|
||||
print(hdr)
|
||||
print("-" * 80)
|
||||
else:
|
||||
hdr = (f"{'s':>4} {'HR':>4} {'BR':>3} {'BP':>7} {'Stress':>8} "
|
||||
f"{'SDNN':>5} {'RMSSD':>5} {'LF/HF':>5} "
|
||||
f"{'Pres':>4} {'Dist':>5} {'Lux':>5} {'RSSI':>5} "
|
||||
f"{'Coh':>4} {'CSI#':>5}")
|
||||
print(hdr)
|
||||
print("-" * 80)
|
||||
|
||||
# Periodic Seed drift check (every 15s)
|
||||
_last_drift_check = 0.0
|
||||
|
||||
while time.time() - start < duration:
|
||||
time.sleep(0.5)
|
||||
elapsed = int(time.time() - start)
|
||||
if elapsed <= last or elapsed % interval != 0:
|
||||
continue
|
||||
last = elapsed
|
||||
|
||||
d = hub.compute()
|
||||
|
||||
if mode == "happiness":
|
||||
h = d["happiness"]
|
||||
bar = _happiness_bar(h)
|
||||
gait_s = f"{d['gait_energy']:>5.2f}"
|
||||
calm_s = f"{d['affect_valence']:>5.2f}"
|
||||
social_s = f"{d['social_energy']:>6.2f}"
|
||||
pres_s = "YES" if d["presence"] else " no"
|
||||
rssi_s = f"{d['rssi']:>5}" if d["rssi"] != 0 else " — "
|
||||
|
||||
# Seed status
|
||||
seed_s = " — "
|
||||
if hub.seed:
|
||||
now = time.time()
|
||||
if now - _last_drift_check >= 15.0:
|
||||
_last_drift_check = now
|
||||
hub.seed.get_drift()
|
||||
drift = hub.seed.last_drift
|
||||
if drift:
|
||||
seed_s = f"{'OK' if not drift.get('drifting') else 'DRIFT':>6}"
|
||||
else:
|
||||
seed_s = " conn?"
|
||||
|
||||
print(f"{elapsed:>3}s {bar} {h:.2f} {gait_s} {calm_s} "
|
||||
f"{social_s} {pres_s:>4} {rssi_s} {seed_s} {d['csi_frames']:>5}")
|
||||
|
||||
# Show drift detail if drifting
|
||||
if hub.seed and hub.seed.last_drift and hub.seed.last_drift.get("drifting"):
|
||||
print(f" SEED DRIFT: {hub.seed.last_drift.get('message', 'unknown')}")
|
||||
else:
|
||||
hr_s = f"{d['hr']:>4.0f}" if d["hr"] > 0 else " —"
|
||||
br_s = f"{d['br']:>3.0f}" if d["br"] > 0 else " —"
|
||||
bp_s = f"{d['sbp']:>3}/{d['dbp']:<3}" if d["sbp"] > 0 else " —/— "
|
||||
sdnn_s = f"{d['sdnn']:>5.0f}" if d["sdnn"] > 0 else " — "
|
||||
rmssd_s = f"{d['rmssd']:>5.0f}" if d["rmssd"] > 0 else " — "
|
||||
lfhf_s = f"{d['lf_hf']:>5.2f}" if d["sdnn"] > 0 else " — "
|
||||
pres_s = "YES" if d["presence"] else " no"
|
||||
dist_s = f"{d['distance']:>4.0f}cm" if d["distance"] > 0 else " — "
|
||||
lux_s = f"{d['lux']:>5.1f}" if d["lux"] > 0 else " — "
|
||||
rssi_s = f"{d['rssi']:>5}" if d["rssi"] != 0 else " — "
|
||||
coh = max(d["coh_mw"], d["coh_csi"])
|
||||
coh_s = f"{coh:>.2f}"
|
||||
|
||||
print(f"{elapsed:>3}s {hr_s} {br_s} {bp_s} {d['stress']:>8} "
|
||||
f"{sdnn_s} {rmssd_s} {lfhf_s} "
|
||||
f"{pres_s:>4} {dist_s} {lux_s} {rssi_s} "
|
||||
f"{coh_s:>4} {d['csi_frames']:>5}")
|
||||
|
||||
for drift in d["drifts"]:
|
||||
print(f" DRIFT: {drift}")
|
||||
for ts, msg in d["events"][-3:]:
|
||||
if time.time() - ts < interval + 1:
|
||||
print(f" >> {msg}")
|
||||
|
||||
# Final summary
|
||||
d = hub.compute()
|
||||
print()
|
||||
print("=" * 80)
|
||||
print(" SESSION SUMMARY (RuVector Analysis)")
|
||||
print("=" * 80)
|
||||
sensors = []
|
||||
if hub.mw_ok:
|
||||
sensors.append(f"mmWave ({d['mw_frames']})")
|
||||
if hub.csi_ok:
|
||||
sensors.append(f"CSI ({d['csi_frames']})")
|
||||
print(f" Sensors: {', '.join(sensors)}")
|
||||
if d["hr"] > 0:
|
||||
print(f" Heart Rate: {d['hr']:.0f} bpm ({d['hr_src']})")
|
||||
if d["br"] > 0:
|
||||
print(f" Breathing: {d['br']:.0f}/min")
|
||||
if d["sbp"] > 0:
|
||||
print(f" BP Estimate: {d['sbp']}/{d['dbp']} mmHg")
|
||||
if d["sdnn"] > 0:
|
||||
print(f" HRV SDNN: {d['sdnn']:.0f} ms — {d['stress']}")
|
||||
print(f" HRV RMSSD: {d['rmssd']:.0f} ms")
|
||||
print(f" HRV pNN50: {d['pnn50']:.1f}%")
|
||||
print(f" LF/HF ratio: {d['lf_hf']:.2f} {'(sympathetic dominant)' if d['lf_hf'] > 2 else '(balanced)' if d['lf_hf'] > 0.5 else '(parasympathetic)'}")
|
||||
if d["lux"] > 0:
|
||||
print(f" Ambient Light: {d['lux']:.1f} lux")
|
||||
# Longitudinal baselines
|
||||
longi = d["longitudinal"]
|
||||
if longi:
|
||||
print(f" Baselines ({len(longi)} metrics tracked):")
|
||||
for name, stats in sorted(longi.items()):
|
||||
print(f" {name}: mean={stats['mean']:.1f} std={stats['std']:.1f} n={stats['n']}")
|
||||
# Happiness
|
||||
if d.get("happiness", 0) > 0:
|
||||
print(f" Happiness: {d['happiness']:.2f} (gait={d['gait_energy']:.2f} affect={d['affect_valence']:.2f} social={d['social_energy']:.2f})")
|
||||
# Signal coherence
|
||||
print(f" Coherence: mmWave={d['coh_mw']:.2f} CSI={d['coh_csi']:.2f}")
|
||||
events = d["events"]
|
||||
if events:
|
||||
print(f" Events ({len(events)}):")
|
||||
for ts, msg in events[-10:]:
|
||||
print(f" {msg}")
|
||||
print()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="RuView Live + RuVector Analysis")
|
||||
parser.add_argument("--csi", default=None, help="CSI port (or 'none'); defaults to COM5 for happiness mode, COM7 otherwise")
|
||||
parser.add_argument("--mmwave", default="COM4", help="mmWave port (or 'none')")
|
||||
parser.add_argument("--duration", type=int, default=120)
|
||||
parser.add_argument("--interval", type=int, default=3)
|
||||
parser.add_argument("--seed", default="none", help="Cognitum Seed HTTP base URL (e.g. 'http://169.254.42.1')")
|
||||
parser.add_argument("--mode", default="vitals", choices=["vitals", "happiness"],
|
||||
help="Dashboard mode: vitals (default) or happiness")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Default CSI port depends on mode
|
||||
if args.csi is None:
|
||||
args.csi = "COM5" if args.mode == "happiness" else "COM7"
|
||||
|
||||
seed_url = args.seed if args.seed.lower() != "none" else None
|
||||
hub = SensorHub(seed_url=seed_url)
|
||||
stop = threading.Event()
|
||||
|
||||
if args.mmwave.lower() != "none":
|
||||
threading.Thread(target=reader_mmwave, args=(args.mmwave, 115200, hub, stop), daemon=True).start()
|
||||
if args.csi.lower() != "none":
|
||||
threading.Thread(target=reader_csi, args=(args.csi, 115200, hub, stop), daemon=True).start()
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
try:
|
||||
run_display(hub, args.duration, args.interval, mode=args.mode)
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopping...")
|
||||
stop.set()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Sleep Apnea Screener — Contactless via 60 GHz mmWave
|
||||
|
||||
Monitors breathing rate from MR60BHA2 and detects apnea events
|
||||
(breathing cessation > 10 seconds). Clinical threshold: > 5 events/hour
|
||||
= Obstructive Sleep Apnea (mild), > 15 = moderate, > 30 = severe.
|
||||
|
||||
Usage:
|
||||
python examples/sleep/apnea_screener.py --port COM4
|
||||
python examples/sleep/apnea_screener.py --port COM4 --duration 3600 # 1 hour
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import re
|
||||
import serial
|
||||
import sys
|
||||
import time
|
||||
|
||||
RE_BR = re.compile(r"'Real-time respiratory rate'.*?(\d+\.?\d*)", re.IGNORECASE)
|
||||
RE_HR = re.compile(r"'Real-time heart rate'.*?(\d+\.?\d*)", re.IGNORECASE)
|
||||
RE_PRES = re.compile(r"'Person Information'.*?state\s+(ON|OFF)", re.IGNORECASE)
|
||||
RE_ANSI = re.compile(r"\x1b\[[0-9;]*m")
|
||||
|
||||
APNEA_THRESHOLD_SEC = 10 # Breathing absent for >10s = apnea event
|
||||
HYPOPNEA_BR = 6.0 # BR < 6/min = hypopnea (shallow breathing)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Sleep Apnea Screener (mmWave)")
|
||||
parser.add_argument("--port", default="COM4")
|
||||
parser.add_argument("--baud", type=int, default=115200)
|
||||
parser.add_argument("--duration", type=int, default=120, help="Duration in seconds")
|
||||
args = parser.parse_args()
|
||||
|
||||
ser = serial.Serial(args.port, args.baud, timeout=1)
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print(" Sleep Apnea Screener (60 GHz mmWave)")
|
||||
print(" Lie still within 1m of sensor. Monitoring breathing.")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
br_history = collections.deque(maxlen=600)
|
||||
apnea_events = []
|
||||
hypopnea_events = []
|
||||
last_br_time = time.time()
|
||||
last_br_value = 0.0
|
||||
last_hr = 0.0
|
||||
in_apnea = False
|
||||
apnea_start = 0.0
|
||||
start = time.time()
|
||||
last_print = 0
|
||||
|
||||
try:
|
||||
while time.time() - start < args.duration:
|
||||
line = ser.readline().decode("utf-8", errors="replace")
|
||||
clean = RE_ANSI.sub("", line)
|
||||
|
||||
m = RE_BR.search(clean)
|
||||
if m:
|
||||
br = float(m.group(1))
|
||||
br_history.append((time.time(), br))
|
||||
|
||||
if br > 0:
|
||||
last_br_time = time.time()
|
||||
last_br_value = br
|
||||
|
||||
if in_apnea:
|
||||
duration = time.time() - apnea_start
|
||||
apnea_events.append(duration)
|
||||
print(f" ** APNEA EVENT ENDED: {duration:.1f}s **")
|
||||
in_apnea = False
|
||||
|
||||
if br < HYPOPNEA_BR and br > 0:
|
||||
hypopnea_events.append(br)
|
||||
|
||||
elif br == 0 and not in_apnea:
|
||||
gap = time.time() - last_br_time
|
||||
if gap >= APNEA_THRESHOLD_SEC:
|
||||
in_apnea = True
|
||||
apnea_start = last_br_time
|
||||
print(f" ** APNEA DETECTED at {int(time.time()-start)}s (no breath for {gap:.0f}s) **")
|
||||
|
||||
m = RE_HR.search(clean)
|
||||
if m:
|
||||
last_hr = float(m.group(1))
|
||||
|
||||
elapsed = int(time.time() - start)
|
||||
if elapsed > last_print and elapsed % 10 == 0:
|
||||
last_print = elapsed
|
||||
gap = time.time() - last_br_time
|
||||
status = "APNEA" if in_apnea else ("OK" if gap < 5 else f"gap {gap:.0f}s")
|
||||
print(f" {elapsed:>4}s | BR {last_br_value:>4.0f}/min | HR {last_hr:>4.0f} | "
|
||||
f"Apneas: {len(apnea_events)} | Hypopneas: {len(hypopnea_events)} | {status}")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
ser.close()
|
||||
duration_hr = (time.time() - start) / 3600.0
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print(" APNEA SCREENING RESULTS")
|
||||
print("=" * 60)
|
||||
ahi = (len(apnea_events) + len(hypopnea_events)) / max(duration_hr, 0.01)
|
||||
print(f" Duration: {time.time()-start:.0f}s ({duration_hr*60:.1f} min)")
|
||||
print(f" Apnea events: {len(apnea_events)} (breathing absent > {APNEA_THRESHOLD_SEC}s)")
|
||||
print(f" Hypopneas: {len(hypopnea_events)} (BR < {HYPOPNEA_BR}/min)")
|
||||
print(f" AHI estimate: {ahi:.1f} events/hour")
|
||||
print()
|
||||
if ahi < 5:
|
||||
print(" Classification: Normal (AHI < 5)")
|
||||
elif ahi < 15:
|
||||
print(" Classification: Mild OSA (AHI 5-14)")
|
||||
elif ahi < 30:
|
||||
print(" Classification: Moderate OSA (AHI 15-29)")
|
||||
else:
|
||||
print(" Classification: Severe OSA (AHI >= 30)")
|
||||
print()
|
||||
print(" NOT A MEDICAL DEVICE. Consult a sleep specialist for diagnosis.")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Real-Time Stress Monitor via Heart Rate Variability (HRV)
|
||||
|
||||
Reads heart rate from MR60BHA2 mmWave radar and computes HRV metrics
|
||||
to estimate stress level continuously.
|
||||
|
||||
HRV Science:
|
||||
- SDNN < 50ms = high stress / low parasympathetic tone
|
||||
- SDNN 50-100ms = moderate
|
||||
- SDNN > 100ms = relaxed / high vagal tone
|
||||
- RMSSD: successive difference metric, more sensitive to acute stress
|
||||
|
||||
Usage:
|
||||
python examples/stress/hrv_stress_monitor.py --port COM4
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import math
|
||||
import re
|
||||
import serial
|
||||
import sys
|
||||
import time
|
||||
|
||||
RE_HR = re.compile(r"'Real-time heart rate'.*?(\d+\.?\d*)\s*bpm", re.IGNORECASE)
|
||||
RE_ANSI = re.compile(r"\x1b\[[0-9;]*m")
|
||||
|
||||
|
||||
def compute_hrv(hr_values):
|
||||
"""Compute HRV metrics from HR time series."""
|
||||
if len(hr_values) < 5:
|
||||
return {"sdnn": 0, "rmssd": 0, "mean_hr": 0, "stress": "—"}
|
||||
|
||||
rr = [60000.0 / h for h in hr_values if h > 0]
|
||||
if len(rr) < 5:
|
||||
return {"sdnn": 0, "rmssd": 0, "mean_hr": 0, "stress": "—"}
|
||||
|
||||
mean_rr = sum(rr) / len(rr)
|
||||
sdnn = math.sqrt(sum((x - mean_rr) ** 2 for x in rr) / len(rr))
|
||||
|
||||
# RMSSD: root mean square of successive differences
|
||||
diffs = [(rr[i+1] - rr[i]) ** 2 for i in range(len(rr) - 1)]
|
||||
rmssd = math.sqrt(sum(diffs) / len(diffs)) if diffs else 0
|
||||
|
||||
mean_hr = sum(hr_values) / len(hr_values)
|
||||
|
||||
if sdnn < 30:
|
||||
stress = "HIGH STRESS"
|
||||
elif sdnn < 50:
|
||||
stress = "Moderate Stress"
|
||||
elif sdnn < 80:
|
||||
stress = "Mild Stress"
|
||||
elif sdnn < 100:
|
||||
stress = "Relaxed"
|
||||
else:
|
||||
stress = "Very Relaxed"
|
||||
|
||||
return {"sdnn": sdnn, "rmssd": rmssd, "mean_hr": mean_hr, "stress": stress}
|
||||
|
||||
|
||||
def stress_bar(sdnn, width=30):
|
||||
"""Visual stress bar: more filled = more stressed."""
|
||||
level = max(0, min(1, 1.0 - sdnn / 120.0))
|
||||
filled = int(level * width)
|
||||
bar = "#" * filled + "." * (width - filled)
|
||||
return f"[{bar}] {level*100:.0f}%"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="HRV Stress Monitor (mmWave)")
|
||||
parser.add_argument("--port", default="COM4")
|
||||
parser.add_argument("--baud", type=int, default=115200)
|
||||
parser.add_argument("--duration", type=int, default=120)
|
||||
parser.add_argument("--window", type=int, default=60, help="HRV window in seconds")
|
||||
args = parser.parse_args()
|
||||
|
||||
ser = serial.Serial(args.port, args.baud, timeout=1)
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print(" Real-Time Stress Monitor (mmWave HRV)")
|
||||
print(" Sit still within 1m. Lower stress = higher HRV.")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
hr_buffer = collections.deque(maxlen=args.window)
|
||||
start = time.time()
|
||||
last_print = 0
|
||||
min_stress = 999.0
|
||||
max_stress = 0.0
|
||||
readings = []
|
||||
|
||||
try:
|
||||
while time.time() - start < args.duration:
|
||||
line = ser.readline().decode("utf-8", errors="replace")
|
||||
clean = RE_ANSI.sub("", line)
|
||||
|
||||
m = RE_HR.search(clean)
|
||||
if m:
|
||||
hr = float(m.group(1))
|
||||
if 30 < hr < 200:
|
||||
hr_buffer.append(hr)
|
||||
|
||||
elapsed = int(time.time() - start)
|
||||
if elapsed > last_print and elapsed % 5 == 0 and len(hr_buffer) >= 3:
|
||||
last_print = elapsed
|
||||
hrv = compute_hrv(list(hr_buffer))
|
||||
bar = stress_bar(hrv["sdnn"])
|
||||
readings.append(hrv)
|
||||
|
||||
if hrv["sdnn"] > 0:
|
||||
min_stress = min(min_stress, hrv["sdnn"])
|
||||
max_stress = max(max_stress, hrv["sdnn"])
|
||||
|
||||
print(f" {elapsed:>4}s | HR {hrv['mean_hr']:>4.0f} | "
|
||||
f"SDNN {hrv['sdnn']:>5.1f}ms | RMSSD {hrv['rmssd']:>5.1f}ms | "
|
||||
f"{hrv['stress']:<16} | {bar}")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
ser.close()
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print(" STRESS SESSION SUMMARY")
|
||||
print("=" * 60)
|
||||
if readings:
|
||||
avg_sdnn = sum(r["sdnn"] for r in readings) / len(readings)
|
||||
avg_rmssd = sum(r["rmssd"] for r in readings) / len(readings)
|
||||
avg_hr = sum(r["mean_hr"] for r in readings) / len(readings)
|
||||
final_stress = readings[-1]["stress"]
|
||||
|
||||
print(f" Duration: {time.time()-start:.0f}s")
|
||||
print(f" Avg HR: {avg_hr:.0f} bpm")
|
||||
print(f" Avg SDNN: {avg_sdnn:.1f} ms {'(low — consider a break)' if avg_sdnn < 50 else '(healthy range)' if avg_sdnn > 70 else ''}")
|
||||
print(f" Avg RMSSD: {avg_rmssd:.1f} ms")
|
||||
print(f" SDNN range: {min_stress:.0f} - {max_stress:.0f} ms")
|
||||
print(f" Assessment: {final_stress}")
|
||||
print()
|
||||
print(" SDNN Guide: <30=high stress, 30-50=moderate, 50-100=normal, >100=relaxed")
|
||||
else:
|
||||
print(" No data collected. Ensure person is in range.")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -2,6 +2,8 @@ set(SRCS
|
||||
"main.c" "csi_collector.c" "stream_sender.c" "nvs_config.c"
|
||||
"edge_processing.c" "ota_update.c" "power_mgmt.c"
|
||||
"wasm_runtime.c" "wasm_upload.c" "rvf_parser.c"
|
||||
"mmwave_sensor.c"
|
||||
"swarm_bridge.c"
|
||||
)
|
||||
|
||||
set(REQUIRES "")
|
||||
|
||||
@@ -117,8 +117,8 @@ size_t csi_serialize_frame(const wifi_csi_info_t *info, uint8_t *buf, size_t buf
|
||||
uint32_t magic = CSI_MAGIC;
|
||||
memcpy(&buf[0], &magic, 4);
|
||||
|
||||
/* Node ID */
|
||||
buf[4] = (uint8_t)CONFIG_CSI_NODE_ID;
|
||||
/* Node ID (from NVS runtime config, not compile-time Kconfig) */
|
||||
buf[4] = g_nvs_config.node_id;
|
||||
|
||||
/* Number of antennas */
|
||||
buf[5] = n_antennas;
|
||||
@@ -273,7 +273,7 @@ void csi_collector_init(void)
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "CSI collection initialized (node_id=%d, channel=%u)",
|
||||
CONFIG_CSI_NODE_ID, (unsigned)csi_channel);
|
||||
g_nvs_config.node_id, (unsigned)csi_channel);
|
||||
}
|
||||
|
||||
/* ---- ADR-029: Channel hopping ---- */
|
||||
|
||||
@@ -7,8 +7,11 @@
|
||||
*/
|
||||
|
||||
#include "display_ui.h"
|
||||
#include "nvs_config.h"
|
||||
#include "sdkconfig.h"
|
||||
|
||||
extern nvs_config_t g_nvs_config;
|
||||
|
||||
#if CONFIG_DISPLAY_ENABLE
|
||||
|
||||
#include <stdio.h>
|
||||
@@ -347,11 +350,7 @@ void display_ui_update(void)
|
||||
{
|
||||
char buf[48];
|
||||
|
||||
#ifdef CONFIG_CSI_NODE_ID
|
||||
snprintf(buf, sizeof(buf), "Node: %d", CONFIG_CSI_NODE_ID);
|
||||
#else
|
||||
snprintf(buf, sizeof(buf), "Node: --");
|
||||
#endif
|
||||
snprintf(buf, sizeof(buf), "Node: %d", g_nvs_config.node_id);
|
||||
lv_label_set_text(s_sys_node, buf);
|
||||
|
||||
snprintf(buf, sizeof(buf), "Heap: %lu KB free",
|
||||
|
||||
@@ -18,6 +18,11 @@
|
||||
*/
|
||||
|
||||
#include "edge_processing.h"
|
||||
#include "nvs_config.h"
|
||||
#include "mmwave_sensor.h"
|
||||
|
||||
/* Runtime config — declared in main.c, loaded from NVS at boot. */
|
||||
extern nvs_config_t g_nvs_config;
|
||||
#include "wasm_runtime.h"
|
||||
#include "stream_sender.h"
|
||||
|
||||
@@ -36,12 +41,20 @@ static const char *TAG = "edge_proc";
|
||||
* ====================================================================== */
|
||||
|
||||
static edge_ring_buf_t s_ring;
|
||||
static uint32_t s_ring_drops; /* Frames dropped due to full ring buffer. */
|
||||
|
||||
/* Scratch buffers for BPM estimation — moved from stack to static to avoid
|
||||
* stack overflow. process_frame + update_multi_person_vitals combined used
|
||||
* ~6.5-7.5 KB of the 8 KB task stack. These save ~4 KB of stack. */
|
||||
static float s_scratch_br[EDGE_PHASE_HISTORY_LEN];
|
||||
static float s_scratch_hr[EDGE_PHASE_HISTORY_LEN];
|
||||
|
||||
static inline bool ring_push(const uint8_t *iq, uint16_t len,
|
||||
int8_t rssi, uint8_t channel)
|
||||
{
|
||||
uint32_t next = (s_ring.head + 1) % EDGE_RING_SLOTS;
|
||||
if (next == s_ring.tail) {
|
||||
s_ring_drops++;
|
||||
return false; /* Full — drop frame. */
|
||||
}
|
||||
|
||||
@@ -263,6 +276,9 @@ static uint8_t s_prev_iq[EDGE_MAX_IQ_BYTES];
|
||||
static uint16_t s_prev_iq_len;
|
||||
static bool s_has_prev_iq;
|
||||
|
||||
/** ADR-069: Feature vector sequence counter. */
|
||||
static uint16_t s_feature_seq;
|
||||
|
||||
/** Multi-person vitals state. */
|
||||
static edge_person_vitals_t s_persons[EDGE_MAX_PERSONS];
|
||||
static edge_biquad_t s_person_bq_br[EDGE_MAX_PERSONS];
|
||||
@@ -397,10 +413,10 @@ static uint16_t delta_compress(const uint8_t *curr, uint16_t len,
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a compressed CSI frame (magic 0xC5110003).
|
||||
* Send a compressed CSI frame (magic 0xC5110005, reassigned from 0xC5110003 for ADR-069).
|
||||
*
|
||||
* Header:
|
||||
* [0..3] Magic 0xC5110003 (LE)
|
||||
* [0..3] Magic 0xC5110005 (LE)
|
||||
* [4] Node ID
|
||||
* [5] Channel
|
||||
* [6..7] Original I/Q length (LE u16)
|
||||
@@ -425,11 +441,7 @@ static void send_compressed_frame(const uint8_t *iq_data, uint16_t iq_len,
|
||||
uint32_t magic = EDGE_COMPRESSED_MAGIC;
|
||||
memcpy(&pkt[0], &magic, 4);
|
||||
|
||||
#ifdef CONFIG_CSI_NODE_ID
|
||||
pkt[4] = (uint8_t)CONFIG_CSI_NODE_ID;
|
||||
#else
|
||||
pkt[4] = 0;
|
||||
#endif
|
||||
pkt[4] = g_nvs_config.node_id;
|
||||
pkt[5] = channel;
|
||||
memcpy(&pkt[6], &iq_len, 2);
|
||||
memcpy(&pkt[8], &comp_len, 2);
|
||||
@@ -510,20 +522,18 @@ static void update_multi_person_vitals(const uint8_t *iq_data, uint16_t n_sc,
|
||||
|
||||
/* Estimate BPM when we have enough history. */
|
||||
if (pv->history_len >= 64) {
|
||||
/* Build contiguous buffer for zero-crossing. */
|
||||
float br_buf[EDGE_PHASE_HISTORY_LEN];
|
||||
float hr_buf[EDGE_PHASE_HISTORY_LEN];
|
||||
/* Build contiguous buffer (reuse static scratch to save ~2 KB stack). */
|
||||
uint16_t buf_len = pv->history_len;
|
||||
|
||||
for (uint16_t i = 0; i < buf_len; i++) {
|
||||
uint16_t ri = (pv->history_idx + EDGE_PHASE_HISTORY_LEN
|
||||
- buf_len + i) % EDGE_PHASE_HISTORY_LEN;
|
||||
br_buf[i] = s_person_br_filt[p][ri];
|
||||
hr_buf[i] = s_person_hr_filt[p][ri];
|
||||
s_scratch_br[i] = s_person_br_filt[p][ri];
|
||||
s_scratch_hr[i] = s_person_hr_filt[p][ri];
|
||||
}
|
||||
|
||||
float br = estimate_bpm_zero_crossing(br_buf, buf_len, sample_rate);
|
||||
float hr = estimate_bpm_zero_crossing(hr_buf, buf_len, sample_rate);
|
||||
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);
|
||||
|
||||
/* Sanity clamp. */
|
||||
if (br >= 6.0f && br <= 40.0f) pv->breathing_bpm = br;
|
||||
@@ -547,11 +557,7 @@ static void send_vitals_packet(void)
|
||||
memset(&pkt, 0, sizeof(pkt));
|
||||
|
||||
pkt.magic = EDGE_VITALS_MAGIC;
|
||||
#ifdef CONFIG_CSI_NODE_ID
|
||||
pkt.node_id = (uint8_t)CONFIG_CSI_NODE_ID;
|
||||
#else
|
||||
pkt.node_id = 0;
|
||||
#endif
|
||||
pkt.node_id = g_nvs_config.node_id;
|
||||
|
||||
pkt.flags = 0;
|
||||
if (s_presence_detected) pkt.flags |= 0x01;
|
||||
@@ -577,7 +583,121 @@ static void send_vitals_packet(void)
|
||||
s_latest_pkt = pkt;
|
||||
s_pkt_valid = true;
|
||||
|
||||
/* Send over UDP. */
|
||||
/* ADR-063: If mmWave is active, send fused 48-byte packet instead. */
|
||||
mmwave_state_t mw;
|
||||
if (mmwave_sensor_get_state(&mw) && mw.detected) {
|
||||
edge_fused_vitals_pkt_t fpkt;
|
||||
memset(&fpkt, 0, sizeof(fpkt));
|
||||
|
||||
fpkt.magic = EDGE_FUSED_MAGIC;
|
||||
fpkt.node_id = pkt.node_id;
|
||||
fpkt.flags = pkt.flags;
|
||||
if (mw.person_present) fpkt.flags |= 0x08; /* Bit3 = mmwave_present */
|
||||
fpkt.rssi = pkt.rssi;
|
||||
fpkt.n_persons = pkt.n_persons;
|
||||
fpkt.mmwave_type = (uint8_t)mw.type;
|
||||
fpkt.motion_energy = pkt.motion_energy;
|
||||
fpkt.presence_score = pkt.presence_score;
|
||||
fpkt.timestamp_ms = pkt.timestamp_ms;
|
||||
|
||||
/* Kalman-style fusion: prefer mmWave when available, CSI as fallback. */
|
||||
if (mw.heart_rate_bpm > 0.0f && s_heartrate_bpm > 0.0f) {
|
||||
/* Weighted average: mmWave 80%, CSI 20% (mmWave is more accurate). */
|
||||
float fused_hr = mw.heart_rate_bpm * 0.8f + s_heartrate_bpm * 0.2f;
|
||||
fpkt.heartrate = (uint32_t)(fused_hr * 10000.0f);
|
||||
fpkt.fusion_confidence = 90;
|
||||
} else if (mw.heart_rate_bpm > 0.0f) {
|
||||
fpkt.heartrate = (uint32_t)(mw.heart_rate_bpm * 10000.0f);
|
||||
fpkt.fusion_confidence = 85;
|
||||
} else {
|
||||
fpkt.heartrate = pkt.heartrate;
|
||||
fpkt.fusion_confidence = 50;
|
||||
}
|
||||
|
||||
if (mw.breathing_rate > 0.0f && s_breathing_bpm > 0.0f) {
|
||||
float fused_br = mw.breathing_rate * 0.8f + s_breathing_bpm * 0.2f;
|
||||
fpkt.breathing_rate = (uint16_t)(fused_br * 100.0f);
|
||||
} else if (mw.breathing_rate > 0.0f) {
|
||||
fpkt.breathing_rate = (uint16_t)(mw.breathing_rate * 100.0f);
|
||||
} else {
|
||||
fpkt.breathing_rate = pkt.breathing_rate;
|
||||
}
|
||||
|
||||
/* Raw mmWave values for server-side analysis. */
|
||||
fpkt.mmwave_hr_bpm = mw.heart_rate_bpm;
|
||||
fpkt.mmwave_br_bpm = mw.breathing_rate;
|
||||
fpkt.mmwave_distance = mw.distance_cm;
|
||||
fpkt.mmwave_targets = mw.target_count;
|
||||
fpkt.mmwave_confidence = (mw.frame_count > 10) ? 80 : 40;
|
||||
|
||||
stream_sender_send((const uint8_t *)&fpkt, sizeof(fpkt));
|
||||
} else {
|
||||
/* No mmWave — send standard 32-byte packet. */
|
||||
stream_sender_send((const uint8_t *)&pkt, sizeof(pkt));
|
||||
}
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
* ADR-069: Feature Vector Packet (48 bytes, sent at 1 Hz alongside vitals)
|
||||
* ====================================================================== */
|
||||
|
||||
static void send_feature_vector(void)
|
||||
{
|
||||
edge_feature_pkt_t pkt;
|
||||
memset(&pkt, 0, sizeof(pkt));
|
||||
|
||||
pkt.magic = EDGE_FEATURE_MAGIC;
|
||||
pkt.node_id = g_nvs_config.node_id;
|
||||
pkt.reserved = 0;
|
||||
pkt.seq = s_feature_seq++;
|
||||
pkt.timestamp_us = esp_timer_get_time();
|
||||
|
||||
/* Dim 0: Presence score (0.0-1.0, normalized from raw score) */
|
||||
float p = s_presence_score;
|
||||
pkt.features[0] = p > 10.0f ? 1.0f : (p < 0.0f ? 0.0f : p / 10.0f);
|
||||
|
||||
/* Dim 1: Motion energy (normalized, 0-1 range) */
|
||||
float m = s_motion_energy;
|
||||
pkt.features[1] = m > 10.0f ? 1.0f : (m < 0.0f ? 0.0f : m / 10.0f);
|
||||
|
||||
/* Dim 2: Breathing rate (BPM / 30, 0-1 range) */
|
||||
pkt.features[2] = s_breathing_bpm > 0.0f
|
||||
? (s_breathing_bpm / 30.0f > 1.0f ? 1.0f : s_breathing_bpm / 30.0f)
|
||||
: 0.0f;
|
||||
|
||||
/* Dim 3: Heart rate (BPM / 120, 0-1 range) */
|
||||
pkt.features[3] = s_heartrate_bpm > 0.0f
|
||||
? (s_heartrate_bpm / 120.0f > 1.0f ? 1.0f : s_heartrate_bpm / 120.0f)
|
||||
: 0.0f;
|
||||
|
||||
/* Dim 4: Phase variance mean (top-K subcarriers) */
|
||||
float var_mean = 0.0f;
|
||||
if (s_top_k_count > 0) {
|
||||
float var_sum = 0.0f;
|
||||
uint8_t k = s_top_k_count < EDGE_TOP_K ? s_top_k_count : EDGE_TOP_K;
|
||||
for (uint8_t i = 0; i < k; i++) {
|
||||
var_sum += (float)welford_variance(&s_subcarrier_var[s_top_k[i]]);
|
||||
}
|
||||
var_mean = var_sum / (float)k;
|
||||
}
|
||||
pkt.features[4] = var_mean > 1.0f ? 1.0f : (var_mean < 0.0f ? 0.0f : var_mean);
|
||||
|
||||
/* Dim 5: Person count (n_persons / 4, 0-1 range) */
|
||||
uint8_t n_active = 0;
|
||||
for (uint8_t i = 0; i < EDGE_MAX_PERSONS; i++) {
|
||||
if (s_persons[i].active) n_active++;
|
||||
}
|
||||
pkt.features[5] = (float)n_active / 4.0f;
|
||||
if (pkt.features[5] > 1.0f) pkt.features[5] = 1.0f;
|
||||
|
||||
/* Dim 6: Fall risk (0.0 or 1.0 based on recent detection) */
|
||||
pkt.features[6] = s_fall_detected ? 1.0f : 0.0f;
|
||||
|
||||
/* Dim 7: RSSI normalized ((rssi + 100) / 100, 0-1 range) */
|
||||
pkt.features[7] = ((float)s_latest_rssi + 100.0f) / 100.0f;
|
||||
if (pkt.features[7] > 1.0f) pkt.features[7] = 1.0f;
|
||||
if (pkt.features[7] < 0.0f) pkt.features[7] = 0.0f;
|
||||
|
||||
stream_sender_send((const uint8_t *)&pkt, sizeof(pkt));
|
||||
}
|
||||
|
||||
@@ -641,20 +761,18 @@ static void process_frame(const edge_ring_slot_t *slot)
|
||||
|
||||
/* --- Step 7: BPM estimation (zero-crossing) --- */
|
||||
if (s_history_len >= 64) {
|
||||
/* Build contiguous buffers from ring. */
|
||||
float br_buf[EDGE_PHASE_HISTORY_LEN];
|
||||
float hr_buf[EDGE_PHASE_HISTORY_LEN];
|
||||
/* Build contiguous buffers from ring (using static scratch to save stack). */
|
||||
uint16_t buf_len = s_history_len;
|
||||
|
||||
for (uint16_t i = 0; i < buf_len; i++) {
|
||||
uint16_t ri = (s_history_idx + EDGE_PHASE_HISTORY_LEN
|
||||
- buf_len + i) % EDGE_PHASE_HISTORY_LEN;
|
||||
br_buf[i] = s_breathing_filtered[ri];
|
||||
hr_buf[i] = s_heartrate_filtered[ri];
|
||||
s_scratch_br[i] = s_breathing_filtered[ri];
|
||||
s_scratch_hr[i] = s_heartrate_filtered[ri];
|
||||
}
|
||||
|
||||
float br_bpm = estimate_bpm_zero_crossing(br_buf, buf_len, sample_rate);
|
||||
float hr_bpm = estimate_bpm_zero_crossing(hr_buf, buf_len, sample_rate);
|
||||
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);
|
||||
|
||||
/* 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;
|
||||
@@ -737,16 +855,18 @@ static void process_frame(const edge_ring_slot_t *slot)
|
||||
int64_t interval_us = (int64_t)s_cfg.vital_interval_ms * 1000;
|
||||
if ((now_us - s_last_vitals_send_us) >= interval_us) {
|
||||
send_vitals_packet();
|
||||
send_feature_vector(); /* ADR-069: 48-byte feature vector at same 1 Hz cadence. */
|
||||
s_last_vitals_send_us = now_us;
|
||||
|
||||
if ((s_frame_count % 200) == 0) {
|
||||
ESP_LOGI(TAG, "Vitals: br=%.1f hr=%.1f motion=%.4f pres=%s "
|
||||
"fall=%s persons=%u frames=%lu",
|
||||
"fall=%s persons=%u frames=%lu drops=%lu",
|
||||
s_breathing_bpm, s_heartrate_bpm, s_motion_energy,
|
||||
s_presence_detected ? "YES" : "no",
|
||||
s_fall_detected ? "YES" : "no",
|
||||
(unsigned)s_latest_pkt.n_persons,
|
||||
(unsigned long)s_frame_count);
|
||||
(unsigned long)s_frame_count,
|
||||
(unsigned long)s_ring_drops);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -784,12 +904,31 @@ static void edge_task(void *arg)
|
||||
|
||||
edge_ring_slot_t slot;
|
||||
|
||||
/* Maximum frames to process before a longer yield. On busy LANs
|
||||
* (corporate networks, many APs), the ring buffer fills continuously.
|
||||
* Without a batch limit the task processes frames back-to-back with
|
||||
* only 1-tick yields, which on high frame rates can still starve
|
||||
* IDLE1 enough to trip the 5-second task watchdog. See #266, #321. */
|
||||
|
||||
while (1) {
|
||||
if (ring_pop(&slot)) {
|
||||
uint8_t processed = 0;
|
||||
|
||||
while (processed < EDGE_BATCH_LIMIT && ring_pop(&slot)) {
|
||||
process_frame(&slot);
|
||||
processed++;
|
||||
/* 1-tick yield between frames within a batch. */
|
||||
vTaskDelay(1);
|
||||
}
|
||||
|
||||
if (processed > 0) {
|
||||
/* Post-batch yield: ~20 ms so IDLE1 can run and feed the
|
||||
* Core 1 watchdog even under sustained load. Uses pdMS_TO_TICKS
|
||||
* for tick-rate independence (minimum 1 tick). */
|
||||
{ TickType_t d = pdMS_TO_TICKS(20); vTaskDelay(d > 0 ? d : 1); }
|
||||
} else {
|
||||
/* No frames available — yield briefly. */
|
||||
vTaskDelay(pdMS_TO_TICKS(1));
|
||||
/* No frames available — sleep one full tick.
|
||||
* NOTE: pdMS_TO_TICKS(5) == 0 at 100 Hz, which would busy-spin. */
|
||||
vTaskDelay(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
|
||||
/* ---- Magic numbers ---- */
|
||||
#define EDGE_VITALS_MAGIC 0xC5110002 /**< Vitals packet magic. */
|
||||
#define EDGE_COMPRESSED_MAGIC 0xC5110003 /**< Compressed frame magic. */
|
||||
#define EDGE_COMPRESSED_MAGIC 0xC5110005 /**< Compressed frame magic (was 0xC5110003, reassigned for ADR-069). */
|
||||
|
||||
/* ---- Buffer sizes ---- */
|
||||
#define EDGE_RING_SLOTS 16 /**< SPSC ring buffer slots (power of 2). */
|
||||
@@ -46,6 +46,9 @@
|
||||
#define EDGE_FALL_COOLDOWN_MS 5000 /**< Minimum ms between fall alerts (debounce). */
|
||||
#define EDGE_FALL_CONSEC_MIN 3 /**< Consecutive frames above threshold to trigger. */
|
||||
|
||||
/* ---- DSP task tuning ---- */
|
||||
#define EDGE_BATCH_LIMIT 4 /**< Max frames per batch before longer yield. */
|
||||
|
||||
/* ---- SPSC ring buffer slot ---- */
|
||||
typedef struct {
|
||||
uint8_t iq_data[EDGE_MAX_IQ_BYTES]; /**< Raw I/Q bytes from CSI callback. */
|
||||
@@ -106,6 +109,49 @@ typedef struct __attribute__((packed)) {
|
||||
|
||||
_Static_assert(sizeof(edge_vitals_pkt_t) == 32, "vitals packet must be 32 bytes");
|
||||
|
||||
/* ---- ADR-069: CSI Feature Vector packet (48 bytes, wire format) ---- */
|
||||
#define EDGE_FEATURE_MAGIC 0xC5110003 /**< Feature vector packet magic. */
|
||||
|
||||
typedef struct __attribute__((packed)) {
|
||||
uint32_t magic; /**< EDGE_FEATURE_MAGIC = 0xC5110003. */
|
||||
uint8_t node_id; /**< ESP32 node identifier. */
|
||||
uint8_t reserved; /**< Alignment padding. */
|
||||
uint16_t seq; /**< Sequence number. */
|
||||
int64_t timestamp_us; /**< Microseconds since boot. */
|
||||
float features[8]; /**< 8-dim normalized feature vector. */
|
||||
} edge_feature_pkt_t;
|
||||
|
||||
_Static_assert(sizeof(edge_feature_pkt_t) == 48, "feature packet must be 48 bytes");
|
||||
|
||||
/* ---- ADR-063: Fused vitals packet (48 bytes, wire format) ---- */
|
||||
#define EDGE_FUSED_MAGIC 0xC5110004 /**< Fused vitals packet magic. */
|
||||
|
||||
typedef struct __attribute__((packed)) {
|
||||
/* First 32 bytes match edge_vitals_pkt_t layout */
|
||||
uint32_t magic; /**< EDGE_FUSED_MAGIC = 0xC5110004. */
|
||||
uint8_t node_id;
|
||||
uint8_t flags; /**< Bit0=presence, Bit1=fall, Bit2=motion, Bit3=mmwave_present. */
|
||||
uint16_t breathing_rate; /**< Fused BPM * 100 (CSI + mmWave Kalman). */
|
||||
uint32_t heartrate; /**< Fused BPM * 10000. */
|
||||
int8_t rssi;
|
||||
uint8_t n_persons;
|
||||
uint8_t mmwave_type; /**< mmwave_type_t enum. */
|
||||
uint8_t fusion_confidence; /**< 0-100 fusion quality score. */
|
||||
float motion_energy;
|
||||
float presence_score;
|
||||
uint32_t timestamp_ms;
|
||||
/* mmWave extension (16 bytes) */
|
||||
float mmwave_hr_bpm; /**< Raw mmWave heart rate. */
|
||||
float mmwave_br_bpm; /**< Raw mmWave breathing rate. */
|
||||
float mmwave_distance;/**< Distance to nearest target (cm). */
|
||||
uint8_t mmwave_targets; /**< Target count from mmWave. */
|
||||
uint8_t mmwave_confidence; /**< mmWave signal quality 0-100. */
|
||||
uint16_t reserved3;
|
||||
uint32_t reserved4; /**< Pad to 48 bytes for alignment. */
|
||||
} edge_fused_vitals_pkt_t;
|
||||
|
||||
_Static_assert(sizeof(edge_fused_vitals_pkt_t) == 48, "fused vitals must be 48 bytes");
|
||||
|
||||
/* ---- Edge configuration (from NVS) ---- */
|
||||
typedef struct {
|
||||
uint8_t tier; /**< Processing tier: 0=raw, 1=basic, 2=full. */
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
#include "wasm_runtime.h"
|
||||
#include "wasm_upload.h"
|
||||
#include "display_task.h"
|
||||
#include "mmwave_sensor.h"
|
||||
#include "swarm_bridge.h"
|
||||
#ifdef CONFIG_CSI_MOCK_ENABLED
|
||||
#include "mock_csi.h"
|
||||
#endif
|
||||
@@ -227,6 +229,41 @@ void app_main(void)
|
||||
}
|
||||
}
|
||||
|
||||
/* ADR-063: Initialize mmWave sensor (auto-detect on UART). */
|
||||
esp_err_t mmwave_ret = mmwave_sensor_init(-1, -1); /* -1 = use default GPIO pins */
|
||||
if (mmwave_ret == ESP_OK) {
|
||||
mmwave_state_t mw;
|
||||
if (mmwave_sensor_get_state(&mw)) {
|
||||
ESP_LOGI(TAG, "mmWave sensor: %s (caps=0x%04x)",
|
||||
mmwave_type_name(mw.type), mw.capabilities);
|
||||
}
|
||||
} else {
|
||||
ESP_LOGI(TAG, "No mmWave sensor detected (CSI-only mode)");
|
||||
}
|
||||
|
||||
/* ADR-066: Initialize swarm bridge to Cognitum Seed (if configured). */
|
||||
esp_err_t swarm_ret = ESP_ERR_INVALID_ARG;
|
||||
#ifndef CONFIG_CSI_MOCK_SKIP_WIFI_CONNECT
|
||||
if (g_nvs_config.seed_url[0] != '\0') {
|
||||
swarm_config_t swarm_cfg = {
|
||||
.heartbeat_sec = g_nvs_config.swarm_heartbeat_sec,
|
||||
.ingest_sec = g_nvs_config.swarm_ingest_sec,
|
||||
.enabled = 1,
|
||||
};
|
||||
strncpy(swarm_cfg.seed_url, g_nvs_config.seed_url, sizeof(swarm_cfg.seed_url) - 1);
|
||||
strncpy(swarm_cfg.seed_token, g_nvs_config.seed_token, sizeof(swarm_cfg.seed_token) - 1);
|
||||
strncpy(swarm_cfg.zone_name, g_nvs_config.zone_name, sizeof(swarm_cfg.zone_name) - 1);
|
||||
swarm_ret = swarm_bridge_init(&swarm_cfg, g_nvs_config.node_id);
|
||||
if (swarm_ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Swarm bridge init failed: %s", esp_err_to_name(swarm_ret));
|
||||
}
|
||||
} else {
|
||||
ESP_LOGI(TAG, "Swarm bridge disabled (no seed_url configured)");
|
||||
}
|
||||
#else
|
||||
ESP_LOGI(TAG, "Mock CSI mode: skipping swarm bridge");
|
||||
#endif
|
||||
|
||||
/* Initialize power management. */
|
||||
power_mgmt_init(g_nvs_config.power_duty);
|
||||
|
||||
@@ -238,11 +275,13 @@ void app_main(void)
|
||||
}
|
||||
#endif
|
||||
|
||||
ESP_LOGI(TAG, "CSI streaming active → %s:%d (edge_tier=%u, OTA=%s, WASM=%s)",
|
||||
ESP_LOGI(TAG, "CSI streaming active → %s:%d (edge_tier=%u, OTA=%s, WASM=%s, mmWave=%s, swarm=%s)",
|
||||
g_nvs_config.target_ip, g_nvs_config.target_port,
|
||||
g_nvs_config.edge_tier,
|
||||
(ota_ret == ESP_OK) ? "ready" : "off",
|
||||
(wasm_ret == ESP_OK) ? "ready" : "off");
|
||||
(wasm_ret == ESP_OK) ? "ready" : "off",
|
||||
(mmwave_ret == ESP_OK) ? "active" : "off",
|
||||
(swarm_ret == ESP_OK) ? g_nvs_config.seed_url : "off");
|
||||
|
||||
/* Main loop — keep alive */
|
||||
while (1) {
|
||||
|
||||
@@ -0,0 +1,571 @@
|
||||
/**
|
||||
* @file mmwave_sensor.c
|
||||
* @brief ADR-063: mmWave sensor UART driver with auto-detection.
|
||||
*
|
||||
* Supports Seeed MR60BHA2 (60 GHz) and HLK-LD2410 (24 GHz).
|
||||
* Under QEMU (CONFIG_CSI_MOCK_ENABLED), uses a mock generator
|
||||
* that produces synthetic vital signs for pipeline testing.
|
||||
*
|
||||
* MR60BHA2 frame format (Seeed mmWave protocol):
|
||||
* [0] SOF = 0x01
|
||||
* [1-2] Frame ID (uint16, big-endian)
|
||||
* [3-4] Data Length (uint16, big-endian)
|
||||
* [5-6] Frame Type (uint16, big-endian)
|
||||
* [7] Header Checksum = ~XOR(bytes 0..6)
|
||||
* [8..N] Payload (N = data_length)
|
||||
* [N+1] Data Checksum = ~XOR(payload bytes)
|
||||
*
|
||||
* Frame types: 0x0A14=breathing, 0x0A15=heart rate,
|
||||
* 0x0A16=distance, 0x0F09=presence
|
||||
*
|
||||
* LD2410 frame format (HLK binary, 256000 baud):
|
||||
* Header: 0xF4 0xF3 0xF2 0xF1
|
||||
* Length: uint16 LE
|
||||
* Data: [type 0xAA] [target_state] [moving_dist LE] [energy] ...
|
||||
* Footer: 0xF8 0xF7 0xF6 0xF5
|
||||
*/
|
||||
|
||||
#include "mmwave_sensor.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <math.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_timer.h"
|
||||
#include "sdkconfig.h"
|
||||
|
||||
#ifndef CONFIG_CSI_MOCK_ENABLED
|
||||
#include "driver/uart.h"
|
||||
#endif
|
||||
|
||||
static const char *TAG = "mmwave";
|
||||
|
||||
/* ---- Configuration ---- */
|
||||
#define MMWAVE_UART_NUM UART_NUM_1
|
||||
#define MMWAVE_MR60_BAUD 115200
|
||||
#define MMWAVE_LD2410_BAUD 256000
|
||||
#define MMWAVE_BUF_SIZE 256
|
||||
#define MMWAVE_TASK_STACK 4096
|
||||
#define MMWAVE_TASK_PRIORITY 3
|
||||
#define MMWAVE_PROBE_TIMEOUT_MS 2000
|
||||
#define MMWAVE_MR60_MAX_PAYLOAD 30 /* Sanity limit from Arduino lib */
|
||||
|
||||
/* ---- MR60BHA2 protocol constants (Seeed mmWave) ---- */
|
||||
#define MR60_SOF 0x01
|
||||
|
||||
/* Frame types (big-endian uint16 at offset 5-6) */
|
||||
#define MR60_TYPE_BREATHING 0x0A14
|
||||
#define MR60_TYPE_HEARTRATE 0x0A15
|
||||
#define MR60_TYPE_DISTANCE 0x0A16
|
||||
#define MR60_TYPE_PRESENCE 0x0F09
|
||||
#define MR60_TYPE_PHASE 0x0A13
|
||||
#define MR60_TYPE_POINTCLOUD 0x0A04
|
||||
|
||||
/* ---- LD2410 protocol constants ---- */
|
||||
#define LD2410_REPORT_HEAD 0xAA
|
||||
#define LD2410_REPORT_TAIL 0x55
|
||||
|
||||
/* ---- Shared state ---- */
|
||||
static mmwave_state_t s_state;
|
||||
static volatile bool s_running;
|
||||
|
||||
/* ======================================================================
|
||||
* MR60BHA2 Parser (corrected protocol from Seeed Arduino library)
|
||||
* ====================================================================== */
|
||||
|
||||
static uint8_t mr60_calc_checksum(const uint8_t *data, uint16_t len)
|
||||
{
|
||||
uint8_t cksum = 0;
|
||||
for (uint16_t i = 0; i < len; i++) {
|
||||
cksum ^= data[i];
|
||||
}
|
||||
return ~cksum;
|
||||
}
|
||||
|
||||
typedef enum {
|
||||
MR60_WAIT_SOF,
|
||||
MR60_READ_HEADER, /* Accumulate bytes 1..7 (frame_id, len, type, hdr_cksum) */
|
||||
MR60_READ_DATA,
|
||||
MR60_READ_DATA_CKSUM,
|
||||
} mr60_parse_state_t;
|
||||
|
||||
typedef struct {
|
||||
mr60_parse_state_t state;
|
||||
uint8_t header[8]; /* Full header: SOF + frame_id(2) + len(2) + type(2) + hdr_cksum */
|
||||
uint8_t hdr_idx;
|
||||
uint16_t data_len;
|
||||
uint16_t frame_type;
|
||||
uint16_t data_idx;
|
||||
uint8_t data[MMWAVE_BUF_SIZE];
|
||||
} mr60_parser_t;
|
||||
|
||||
static mr60_parser_t s_mr60;
|
||||
|
||||
static void mr60_process_frame(uint16_t type, const uint8_t *data, uint16_t len)
|
||||
{
|
||||
s_state.frame_count++;
|
||||
s_state.last_update_us = esp_timer_get_time();
|
||||
|
||||
switch (type) {
|
||||
case MR60_TYPE_BREATHING:
|
||||
if (len >= 4) {
|
||||
/* Breathing rate as float32 (little-endian in payload). */
|
||||
float br;
|
||||
memcpy(&br, data, sizeof(float));
|
||||
if (br >= 0.0f && br <= 60.0f) {
|
||||
s_state.breathing_rate = br;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case MR60_TYPE_HEARTRATE:
|
||||
if (len >= 4) {
|
||||
float hr;
|
||||
memcpy(&hr, data, sizeof(float));
|
||||
if (hr >= 0.0f && hr <= 250.0f) {
|
||||
s_state.heart_rate_bpm = hr;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case MR60_TYPE_DISTANCE:
|
||||
if (len >= 8) {
|
||||
/* Bytes 0-3: range flag (uint32 LE). 0 = no valid distance. */
|
||||
uint32_t range_flag;
|
||||
memcpy(&range_flag, data, sizeof(uint32_t));
|
||||
if (range_flag != 0 && len >= 8) {
|
||||
float dist;
|
||||
memcpy(&dist, &data[4], sizeof(float));
|
||||
s_state.distance_cm = dist;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case MR60_TYPE_PRESENCE:
|
||||
if (len >= 1) {
|
||||
s_state.person_present = (data[0] != 0);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
static void mr60_feed_byte(uint8_t b)
|
||||
{
|
||||
switch (s_mr60.state) {
|
||||
case MR60_WAIT_SOF:
|
||||
if (b == MR60_SOF) {
|
||||
s_mr60.header[0] = b;
|
||||
s_mr60.hdr_idx = 1;
|
||||
s_mr60.state = MR60_READ_HEADER;
|
||||
}
|
||||
break;
|
||||
|
||||
case MR60_READ_HEADER:
|
||||
s_mr60.header[s_mr60.hdr_idx++] = b;
|
||||
if (s_mr60.hdr_idx >= 8) {
|
||||
/* Validate header checksum: ~XOR(bytes 0..6) == byte 7 */
|
||||
uint8_t expected = mr60_calc_checksum(s_mr60.header, 7);
|
||||
if (expected != s_mr60.header[7]) {
|
||||
s_state.error_count++;
|
||||
s_mr60.state = MR60_WAIT_SOF;
|
||||
break;
|
||||
}
|
||||
/* Parse header fields (big-endian) */
|
||||
s_mr60.data_len = ((uint16_t)s_mr60.header[3] << 8) | s_mr60.header[4];
|
||||
s_mr60.frame_type = ((uint16_t)s_mr60.header[5] << 8) | s_mr60.header[6];
|
||||
s_mr60.data_idx = 0;
|
||||
|
||||
if (s_mr60.data_len > MMWAVE_MR60_MAX_PAYLOAD) {
|
||||
s_state.error_count++;
|
||||
s_mr60.state = MR60_WAIT_SOF;
|
||||
} else if (s_mr60.data_len == 0) {
|
||||
s_mr60.state = MR60_READ_DATA_CKSUM;
|
||||
} else {
|
||||
s_mr60.state = MR60_READ_DATA;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case MR60_READ_DATA:
|
||||
s_mr60.data[s_mr60.data_idx++] = b;
|
||||
if (s_mr60.data_idx >= s_mr60.data_len) {
|
||||
s_mr60.state = MR60_READ_DATA_CKSUM;
|
||||
}
|
||||
break;
|
||||
|
||||
case MR60_READ_DATA_CKSUM:
|
||||
/* Validate data checksum */
|
||||
if (s_mr60.data_len > 0) {
|
||||
uint8_t expected = mr60_calc_checksum(s_mr60.data, s_mr60.data_len);
|
||||
if (expected == b) {
|
||||
mr60_process_frame(s_mr60.frame_type, s_mr60.data, s_mr60.data_len);
|
||||
} else {
|
||||
s_state.error_count++;
|
||||
}
|
||||
} else {
|
||||
/* Zero-length payload — checksum byte is for empty data */
|
||||
mr60_process_frame(s_mr60.frame_type, s_mr60.data, 0);
|
||||
}
|
||||
s_mr60.state = MR60_WAIT_SOF;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
* LD2410 Parser (HLK binary protocol, 256000 baud)
|
||||
* ====================================================================== */
|
||||
|
||||
typedef enum {
|
||||
LD_WAIT_F4, LD_WAIT_F3, LD_WAIT_F2, LD_WAIT_F1,
|
||||
LD_READ_LEN_L, LD_READ_LEN_H,
|
||||
LD_READ_DATA,
|
||||
LD_WAIT_F8, LD_WAIT_F7, LD_WAIT_F6, LD_WAIT_F5,
|
||||
} ld2410_parse_state_t;
|
||||
|
||||
typedef struct {
|
||||
ld2410_parse_state_t state;
|
||||
uint16_t data_len;
|
||||
uint16_t data_idx;
|
||||
uint8_t data[MMWAVE_BUF_SIZE];
|
||||
} ld2410_parser_t;
|
||||
|
||||
static ld2410_parser_t s_ld;
|
||||
|
||||
static void ld2410_process_frame(const uint8_t *data, uint16_t len)
|
||||
{
|
||||
s_state.frame_count++;
|
||||
s_state.last_update_us = esp_timer_get_time();
|
||||
|
||||
if (len < 12) return;
|
||||
|
||||
uint8_t data_type = data[0]; /* 0x02 = normal, 0x01 = engineering */
|
||||
uint8_t head_marker = data[1]; /* Must be 0xAA */
|
||||
|
||||
if (head_marker != LD2410_REPORT_HEAD) return;
|
||||
|
||||
/* Normal mode target report (data_type 0x02 or 0x01) */
|
||||
uint8_t target_state = data[2];
|
||||
uint16_t moving_dist = data[3] | ((uint16_t)data[4] << 8);
|
||||
uint8_t moving_energy = data[5];
|
||||
uint16_t static_dist = data[6] | ((uint16_t)data[7] << 8);
|
||||
uint8_t static_energy = data[8];
|
||||
uint16_t detect_dist = data[9] | ((uint16_t)data[10] << 8);
|
||||
|
||||
(void)moving_energy;
|
||||
(void)static_energy;
|
||||
(void)detect_dist;
|
||||
|
||||
s_state.person_present = (target_state != 0);
|
||||
s_state.target_count = (target_state != 0) ? 1 : 0;
|
||||
|
||||
if (target_state == 1 || target_state == 3) {
|
||||
s_state.distance_cm = (float)moving_dist;
|
||||
} else if (target_state == 2) {
|
||||
s_state.distance_cm = (float)static_dist;
|
||||
} else {
|
||||
s_state.distance_cm = 0.0f;
|
||||
}
|
||||
}
|
||||
|
||||
static void ld2410_feed_byte(uint8_t b)
|
||||
{
|
||||
switch (s_ld.state) {
|
||||
case LD_WAIT_F4: s_ld.state = (b == 0xF4) ? LD_WAIT_F3 : LD_WAIT_F4; break;
|
||||
case LD_WAIT_F3: s_ld.state = (b == 0xF3) ? LD_WAIT_F2 : LD_WAIT_F4; break;
|
||||
case LD_WAIT_F2: s_ld.state = (b == 0xF2) ? LD_WAIT_F1 : LD_WAIT_F4; break;
|
||||
case LD_WAIT_F1: s_ld.state = (b == 0xF1) ? LD_READ_LEN_L : LD_WAIT_F4; break;
|
||||
case LD_READ_LEN_L:
|
||||
s_ld.data_len = b;
|
||||
s_ld.state = LD_READ_LEN_H;
|
||||
break;
|
||||
case LD_READ_LEN_H:
|
||||
s_ld.data_len |= ((uint16_t)b << 8);
|
||||
s_ld.data_idx = 0;
|
||||
if (s_ld.data_len == 0 || s_ld.data_len > MMWAVE_BUF_SIZE) {
|
||||
s_ld.state = LD_WAIT_F4;
|
||||
} else {
|
||||
s_ld.state = LD_READ_DATA;
|
||||
}
|
||||
break;
|
||||
case LD_READ_DATA:
|
||||
s_ld.data[s_ld.data_idx++] = b;
|
||||
if (s_ld.data_idx >= s_ld.data_len) s_ld.state = LD_WAIT_F8;
|
||||
break;
|
||||
case LD_WAIT_F8: s_ld.state = (b == 0xF8) ? LD_WAIT_F7 : LD_WAIT_F4; break;
|
||||
case LD_WAIT_F7: s_ld.state = (b == 0xF7) ? LD_WAIT_F6 : LD_WAIT_F4; break;
|
||||
case LD_WAIT_F6: s_ld.state = (b == 0xF6) ? LD_WAIT_F5 : LD_WAIT_F4; break;
|
||||
case LD_WAIT_F5:
|
||||
if (b == 0xF5) {
|
||||
ld2410_process_frame(s_ld.data, s_ld.data_len);
|
||||
}
|
||||
s_ld.state = LD_WAIT_F4;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
* Mock mmWave Generator (for QEMU testing)
|
||||
* ====================================================================== */
|
||||
|
||||
#ifdef CONFIG_CSI_MOCK_ENABLED
|
||||
|
||||
static void mock_mmwave_task(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
ESP_LOGI(TAG, "Mock mmWave generator started (simulating MR60BHA2)");
|
||||
|
||||
s_state.type = MMWAVE_TYPE_MOCK;
|
||||
s_state.detected = true;
|
||||
s_state.capabilities = MMWAVE_CAP_HEART_RATE | MMWAVE_CAP_BREATHING
|
||||
| MMWAVE_CAP_PRESENCE | MMWAVE_CAP_DISTANCE;
|
||||
|
||||
float hr_base = 72.0f;
|
||||
float br_base = 16.0f;
|
||||
uint32_t tick = 0;
|
||||
|
||||
while (s_running) {
|
||||
tick++;
|
||||
|
||||
/* Simulate realistic vital sign variation. */
|
||||
float hr_noise = 2.0f * sinf((float)tick * 0.1f) + 0.5f * sinf((float)tick * 0.37f);
|
||||
float br_noise = 1.0f * sinf((float)tick * 0.07f) + 0.3f * sinf((float)tick * 0.23f);
|
||||
|
||||
s_state.heart_rate_bpm = hr_base + hr_noise;
|
||||
s_state.breathing_rate = br_base + br_noise;
|
||||
s_state.person_present = true;
|
||||
s_state.distance_cm = 150.0f + 20.0f * sinf((float)tick * 0.05f);
|
||||
s_state.target_count = 1;
|
||||
s_state.frame_count++;
|
||||
s_state.last_update_us = esp_timer_get_time();
|
||||
|
||||
/* Simulate person leaving at tick 200-250 (for scenario testing). */
|
||||
if (tick >= 200 && tick <= 250) {
|
||||
s_state.person_present = false;
|
||||
s_state.heart_rate_bpm = 0.0f;
|
||||
s_state.breathing_rate = 0.0f;
|
||||
s_state.distance_cm = 0.0f;
|
||||
s_state.target_count = 0;
|
||||
}
|
||||
|
||||
/* ~1 Hz update rate (matches real MR60BHA2). */
|
||||
vTaskDelay(pdMS_TO_TICKS(1000));
|
||||
}
|
||||
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
|
||||
#endif /* CONFIG_CSI_MOCK_ENABLED */
|
||||
|
||||
/* ======================================================================
|
||||
* UART Auto-Detection and Task
|
||||
* ====================================================================== */
|
||||
|
||||
#ifndef CONFIG_CSI_MOCK_ENABLED
|
||||
|
||||
/**
|
||||
* Try to detect a sensor at the given baud rate.
|
||||
* Returns the sensor type if detected, MMWAVE_TYPE_NONE otherwise.
|
||||
*/
|
||||
static mmwave_type_t probe_at_baud(uint32_t baud)
|
||||
{
|
||||
/* Reconfigure baud rate. */
|
||||
uart_set_baudrate(MMWAVE_UART_NUM, baud);
|
||||
uart_flush_input(MMWAVE_UART_NUM);
|
||||
|
||||
uint8_t buf[128];
|
||||
int mr60_sof_seen = 0;
|
||||
int ld2410_header_seen = 0;
|
||||
|
||||
int64_t deadline = esp_timer_get_time() + (int64_t)(MMWAVE_PROBE_TIMEOUT_MS / 2) * 1000;
|
||||
|
||||
while (esp_timer_get_time() < deadline) {
|
||||
int len = uart_read_bytes(MMWAVE_UART_NUM, buf, sizeof(buf), pdMS_TO_TICKS(100));
|
||||
if (len <= 0) continue;
|
||||
|
||||
for (int i = 0; i < len; i++) {
|
||||
/* MR60BHA2: SOF = 0x01, followed by valid-looking frame_id bytes */
|
||||
if (buf[i] == MR60_SOF && baud == MMWAVE_MR60_BAUD) {
|
||||
mr60_sof_seen++;
|
||||
}
|
||||
/* LD2410: 4-byte header 0xF4F3F2F1 */
|
||||
if (i + 3 < len && buf[i] == 0xF4 && buf[i+1] == 0xF3
|
||||
&& buf[i+2] == 0xF2 && buf[i+3] == 0xF1
|
||||
&& baud == MMWAVE_LD2410_BAUD) {
|
||||
ld2410_header_seen++;
|
||||
}
|
||||
}
|
||||
|
||||
if (mr60_sof_seen >= 3) return MMWAVE_TYPE_MR60BHA2;
|
||||
if (ld2410_header_seen >= 2) return MMWAVE_TYPE_LD2410;
|
||||
}
|
||||
|
||||
if (mr60_sof_seen > 0) return MMWAVE_TYPE_MR60BHA2;
|
||||
if (ld2410_header_seen > 0) return MMWAVE_TYPE_LD2410;
|
||||
|
||||
return MMWAVE_TYPE_NONE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-detect sensor by probing at both baud rates.
|
||||
* MR60BHA2 uses 115200, LD2410 uses 256000.
|
||||
*/
|
||||
static mmwave_type_t probe_sensor(void)
|
||||
{
|
||||
ESP_LOGI(TAG, "Probing at %d baud (MR60BHA2)...", MMWAVE_MR60_BAUD);
|
||||
mmwave_type_t result = probe_at_baud(MMWAVE_MR60_BAUD);
|
||||
if (result != MMWAVE_TYPE_NONE) return result;
|
||||
|
||||
ESP_LOGI(TAG, "Probing at %d baud (LD2410)...", MMWAVE_LD2410_BAUD);
|
||||
result = probe_at_baud(MMWAVE_LD2410_BAUD);
|
||||
return result;
|
||||
}
|
||||
|
||||
static void mmwave_uart_task(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
ESP_LOGI(TAG, "mmWave UART task started (type=%s)",
|
||||
mmwave_type_name(s_state.type));
|
||||
|
||||
uint8_t buf[128];
|
||||
|
||||
while (s_running) {
|
||||
int len = uart_read_bytes(MMWAVE_UART_NUM, buf, sizeof(buf), pdMS_TO_TICKS(100));
|
||||
if (len <= 0) {
|
||||
vTaskDelay(1);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (int i = 0; i < len; i++) {
|
||||
if (s_state.type == MMWAVE_TYPE_MR60BHA2) {
|
||||
mr60_feed_byte(buf[i]);
|
||||
} else if (s_state.type == MMWAVE_TYPE_LD2410) {
|
||||
ld2410_feed_byte(buf[i]);
|
||||
}
|
||||
}
|
||||
|
||||
vTaskDelay(1);
|
||||
}
|
||||
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
|
||||
#endif /* !CONFIG_CSI_MOCK_ENABLED */
|
||||
|
||||
/* ======================================================================
|
||||
* Public API
|
||||
* ====================================================================== */
|
||||
|
||||
const char *mmwave_type_name(mmwave_type_t type)
|
||||
{
|
||||
switch (type) {
|
||||
case MMWAVE_TYPE_MR60BHA2: return "MR60BHA2";
|
||||
case MMWAVE_TYPE_LD2410: return "LD2410";
|
||||
case MMWAVE_TYPE_MOCK: return "Mock";
|
||||
case MMWAVE_TYPE_NONE:
|
||||
default: return "None";
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t mmwave_sensor_init(int uart_tx_pin, int uart_rx_pin)
|
||||
{
|
||||
memset(&s_state, 0, sizeof(s_state));
|
||||
memset(&s_mr60, 0, sizeof(s_mr60));
|
||||
memset(&s_ld, 0, sizeof(s_ld));
|
||||
s_running = true;
|
||||
|
||||
#ifdef CONFIG_CSI_MOCK_ENABLED
|
||||
ESP_LOGI(TAG, "Mock mode: starting synthetic mmWave generator");
|
||||
|
||||
BaseType_t ret = xTaskCreatePinnedToCore(
|
||||
mock_mmwave_task, "mmwave_mock", MMWAVE_TASK_STACK,
|
||||
NULL, MMWAVE_TASK_PRIORITY, NULL, 0);
|
||||
|
||||
if (ret != pdPASS) {
|
||||
ESP_LOGE(TAG, "Failed to create mock mmWave task");
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
return ESP_OK;
|
||||
|
||||
#else
|
||||
if (uart_tx_pin < 0) uart_tx_pin = 17;
|
||||
if (uart_rx_pin < 0) uart_rx_pin = 18;
|
||||
|
||||
/* Install UART driver at MR60 baud (will be changed during probe). */
|
||||
uart_config_t uart_config = {
|
||||
.baud_rate = MMWAVE_MR60_BAUD,
|
||||
.data_bits = UART_DATA_8_BITS,
|
||||
.parity = UART_PARITY_DISABLE,
|
||||
.stop_bits = UART_STOP_BITS_1,
|
||||
.flow_ctrl = UART_HW_FLOWCTRL_DISABLE,
|
||||
.source_clk = UART_SCLK_DEFAULT,
|
||||
};
|
||||
|
||||
esp_err_t err = uart_driver_install(MMWAVE_UART_NUM, MMWAVE_BUF_SIZE * 2, 0, 0, NULL, 0);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "UART driver install failed: %s", esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
|
||||
uart_param_config(MMWAVE_UART_NUM, &uart_config);
|
||||
uart_set_pin(MMWAVE_UART_NUM, uart_tx_pin, uart_rx_pin,
|
||||
UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE);
|
||||
|
||||
ESP_LOGI(TAG, "Probing UART%d (TX=%d, RX=%d) for mmWave sensor...",
|
||||
MMWAVE_UART_NUM, uart_tx_pin, uart_rx_pin);
|
||||
|
||||
mmwave_type_t detected = probe_sensor();
|
||||
|
||||
if (detected == MMWAVE_TYPE_NONE) {
|
||||
ESP_LOGI(TAG, "No mmWave sensor detected on UART%d", MMWAVE_UART_NUM);
|
||||
uart_driver_delete(MMWAVE_UART_NUM);
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
|
||||
/* Set final baud rate for the detected sensor. */
|
||||
uint32_t final_baud = (detected == MMWAVE_TYPE_LD2410)
|
||||
? MMWAVE_LD2410_BAUD : MMWAVE_MR60_BAUD;
|
||||
uart_set_baudrate(MMWAVE_UART_NUM, final_baud);
|
||||
|
||||
s_state.type = detected;
|
||||
s_state.detected = true;
|
||||
|
||||
switch (detected) {
|
||||
case MMWAVE_TYPE_MR60BHA2:
|
||||
s_state.capabilities = MMWAVE_CAP_HEART_RATE | MMWAVE_CAP_BREATHING
|
||||
| MMWAVE_CAP_PRESENCE | MMWAVE_CAP_DISTANCE;
|
||||
break;
|
||||
case MMWAVE_TYPE_LD2410:
|
||||
s_state.capabilities = MMWAVE_CAP_PRESENCE | MMWAVE_CAP_DISTANCE;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Detected %s at %lu baud (caps=0x%04x)",
|
||||
mmwave_type_name(detected), (unsigned long)final_baud,
|
||||
s_state.capabilities);
|
||||
|
||||
BaseType_t ret = xTaskCreatePinnedToCore(
|
||||
mmwave_uart_task, "mmwave_uart", MMWAVE_TASK_STACK,
|
||||
NULL, MMWAVE_TASK_PRIORITY, NULL, 0);
|
||||
|
||||
if (ret != pdPASS) {
|
||||
ESP_LOGE(TAG, "Failed to create mmWave UART task");
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
return ESP_OK;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool mmwave_sensor_get_state(mmwave_state_t *state)
|
||||
{
|
||||
if (!s_state.detected || state == NULL) return false;
|
||||
memcpy(state, &s_state, sizeof(mmwave_state_t));
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* @file mmwave_sensor.h
|
||||
* @brief ADR-063: 60 GHz mmWave sensor auto-detection and UART driver.
|
||||
*
|
||||
* Supports:
|
||||
* - Seeed MR60BHA2 (60 GHz, heart rate + breathing + presence)
|
||||
* - HLK-LD2410 (24 GHz, presence + distance)
|
||||
*
|
||||
* Auto-detects sensor type at boot by probing UART for known frame headers.
|
||||
* Runs a background task that parses incoming frames and updates shared state.
|
||||
*/
|
||||
|
||||
#ifndef MMWAVE_SENSOR_H
|
||||
#define MMWAVE_SENSOR_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
#include "esp_err.h"
|
||||
|
||||
/* ---- Sensor type enumeration ---- */
|
||||
typedef enum {
|
||||
MMWAVE_TYPE_NONE = 0, /**< No sensor detected. */
|
||||
MMWAVE_TYPE_MR60BHA2 = 1, /**< Seeed MR60BHA2 (60 GHz, HR + BR). */
|
||||
MMWAVE_TYPE_LD2410 = 2, /**< HLK-LD2410 (24 GHz, presence + range). */
|
||||
MMWAVE_TYPE_MOCK = 99, /**< Mock sensor for QEMU testing. */
|
||||
} mmwave_type_t;
|
||||
|
||||
/* ---- Capability flags ---- */
|
||||
#define MMWAVE_CAP_HEART_RATE (1 << 0)
|
||||
#define MMWAVE_CAP_BREATHING (1 << 1)
|
||||
#define MMWAVE_CAP_PRESENCE (1 << 2)
|
||||
#define MMWAVE_CAP_DISTANCE (1 << 3)
|
||||
#define MMWAVE_CAP_FALL (1 << 4)
|
||||
#define MMWAVE_CAP_MULTI_TARGET (1 << 5)
|
||||
|
||||
/* ---- Shared mmWave state (updated by background task) ---- */
|
||||
typedef struct {
|
||||
/* Detection */
|
||||
mmwave_type_t type; /**< Detected sensor type. */
|
||||
uint16_t capabilities; /**< Bitmask of MMWAVE_CAP_* flags. */
|
||||
bool detected; /**< True if sensor responded on UART. */
|
||||
|
||||
/* Vital signs (MR60BHA2) */
|
||||
float heart_rate_bpm; /**< Heart rate in BPM (0 if unavailable). */
|
||||
float breathing_rate; /**< Breathing rate in breaths/min. */
|
||||
|
||||
/* Presence and range (LD2410 / MR60BHA2) */
|
||||
bool person_present; /**< True if person detected. */
|
||||
float distance_cm; /**< Distance to nearest target in cm. */
|
||||
uint8_t target_count; /**< Number of detected targets. */
|
||||
|
||||
/* Quality metrics */
|
||||
uint32_t frame_count; /**< Total parsed frames since boot. */
|
||||
uint32_t error_count; /**< Parse errors / CRC failures. */
|
||||
int64_t last_update_us; /**< Timestamp of last valid frame. */
|
||||
} mmwave_state_t;
|
||||
|
||||
/**
|
||||
* Initialize the mmWave sensor subsystem.
|
||||
*
|
||||
* Probes the configured UART for known sensor types. If a sensor is
|
||||
* detected, starts a background FreeRTOS task to parse incoming frames.
|
||||
*
|
||||
* @param uart_tx_pin GPIO pin for UART TX (to sensor RX). Use -1 for default.
|
||||
* @param uart_rx_pin GPIO pin for UART RX (from sensor TX). Use -1 for default.
|
||||
* @return ESP_OK if sensor detected, ESP_ERR_NOT_FOUND if no sensor.
|
||||
*/
|
||||
esp_err_t mmwave_sensor_init(int uart_tx_pin, int uart_rx_pin);
|
||||
|
||||
/**
|
||||
* Get a snapshot of the current mmWave state (thread-safe copy).
|
||||
*
|
||||
* @param state Output state struct.
|
||||
* @return true if valid data is available (sensor detected and running).
|
||||
*/
|
||||
bool mmwave_sensor_get_state(mmwave_state_t *state);
|
||||
|
||||
/**
|
||||
* Get the detected sensor type name as a string.
|
||||
*/
|
||||
const char *mmwave_type_name(mmwave_type_t type);
|
||||
|
||||
#endif /* MMWAVE_SENSOR_H */
|
||||
@@ -302,6 +302,26 @@ void nvs_config_load(nvs_config_t *cfg)
|
||||
cfg->filter_mac[3], cfg->filter_mac[4], cfg->filter_mac[5]);
|
||||
}
|
||||
|
||||
/* ADR-066: Swarm bridge */
|
||||
len = sizeof(cfg->seed_url);
|
||||
if (nvs_get_str(handle, "seed_url", cfg->seed_url, &len) != ESP_OK) {
|
||||
cfg->seed_url[0] = '\0'; /* Disabled by default */
|
||||
}
|
||||
len = sizeof(cfg->seed_token);
|
||||
if (nvs_get_str(handle, "seed_token", cfg->seed_token, &len) != ESP_OK) {
|
||||
cfg->seed_token[0] = '\0';
|
||||
}
|
||||
len = sizeof(cfg->zone_name);
|
||||
if (nvs_get_str(handle, "zone_name", cfg->zone_name, &len) != ESP_OK) {
|
||||
strncpy(cfg->zone_name, "default", sizeof(cfg->zone_name) - 1);
|
||||
}
|
||||
if (nvs_get_u16(handle, "swarm_hb", &cfg->swarm_heartbeat_sec) != ESP_OK) {
|
||||
cfg->swarm_heartbeat_sec = 30;
|
||||
}
|
||||
if (nvs_get_u16(handle, "swarm_ingest", &cfg->swarm_ingest_sec) != ESP_OK) {
|
||||
cfg->swarm_ingest_sec = 5;
|
||||
}
|
||||
|
||||
/* Validate tdm_slot_index < tdm_node_count */
|
||||
if (cfg->tdm_slot_index >= cfg->tdm_node_count) {
|
||||
ESP_LOGW(TAG, "tdm_slot_index=%u >= tdm_node_count=%u, clamping to 0",
|
||||
|
||||
@@ -55,6 +55,13 @@ typedef struct {
|
||||
uint8_t csi_channel; /**< Explicit CSI channel override (0 = auto-detect). */
|
||||
uint8_t filter_mac[6]; /**< MAC address to filter CSI frames. */
|
||||
uint8_t filter_mac_set; /**< 1 if filter_mac was loaded from NVS. */
|
||||
|
||||
/* ADR-066: Swarm bridge configuration */
|
||||
char seed_url[64]; /**< Cognitum Seed base URL (empty = disabled). */
|
||||
char seed_token[64]; /**< Seed Bearer token (from pairing). */
|
||||
char zone_name[16]; /**< Zone name for this node (e.g. "lobby"). */
|
||||
uint16_t swarm_heartbeat_sec; /**< Heartbeat interval (seconds, default 30). */
|
||||
uint16_t swarm_ingest_sec; /**< Vector ingest interval (seconds, default 5). */
|
||||
} nvs_config_t;
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* @file swarm_bridge.c
|
||||
* @brief ADR-066: ESP32 Swarm Bridge — Cognitum Seed coordinator client.
|
||||
*
|
||||
* Runs a FreeRTOS task on Core 0 that periodically POSTs registration,
|
||||
* heartbeat, and happiness vectors to a Cognitum Seed ingest endpoint.
|
||||
*/
|
||||
|
||||
#include "swarm_bridge.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_timer.h"
|
||||
#include "esp_system.h"
|
||||
#include "esp_app_desc.h"
|
||||
#include "esp_netif.h"
|
||||
#include "esp_http_client.h"
|
||||
|
||||
static const char *TAG = "swarm";
|
||||
|
||||
/* ---- Task parameters ---- */
|
||||
#define SWARM_TASK_STACK 3072 /**< 3 KB stack — HTTP client uses ~2.5 KB. */
|
||||
#define SWARM_TASK_PRIO 3
|
||||
#define SWARM_TASK_CORE 0
|
||||
#define SWARM_HTTP_TIMEOUT 3000 /**< HTTP timeout in ms (Seed responds <100ms on LAN). */
|
||||
|
||||
/* ---- Ingest endpoint path ---- */
|
||||
#define SWARM_INGEST_PATH "/api/v1/store/ingest"
|
||||
|
||||
/* ---- JSON buffer size (Seed tuple format: max ~120 bytes per vector) ---- */
|
||||
#define SWARM_JSON_BUF 256
|
||||
|
||||
/* ---- Module state ---- */
|
||||
static swarm_config_t s_cfg;
|
||||
static uint8_t s_node_id;
|
||||
static SemaphoreHandle_t s_mutex;
|
||||
static TaskHandle_t s_task_handle;
|
||||
|
||||
/* ---- Protected shared data ---- */
|
||||
static edge_vitals_pkt_t s_vitals;
|
||||
static float s_happiness[SWARM_VECTOR_DIM];
|
||||
static bool s_vitals_valid;
|
||||
|
||||
/* ---- Counters ---- */
|
||||
static uint32_t s_cnt_regs;
|
||||
static uint32_t s_cnt_heartbeats;
|
||||
static uint32_t s_cnt_ingests;
|
||||
static uint32_t s_cnt_errors;
|
||||
|
||||
/* ---- Forward declarations ---- */
|
||||
static void swarm_task(void *arg);
|
||||
static esp_err_t swarm_post_json(esp_http_client_handle_t client,
|
||||
const char *json, int json_len);
|
||||
static void swarm_get_ip_str(char *buf, size_t buf_len);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
esp_err_t swarm_bridge_init(const swarm_config_t *cfg, uint8_t node_id)
|
||||
{
|
||||
if (cfg == NULL || cfg->seed_url[0] == '\0') {
|
||||
ESP_LOGW(TAG, "seed_url is empty — swarm bridge disabled");
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
memcpy(&s_cfg, cfg, sizeof(s_cfg));
|
||||
s_node_id = node_id;
|
||||
|
||||
/* Apply defaults for zero-valued intervals. */
|
||||
if (s_cfg.heartbeat_sec == 0) {
|
||||
s_cfg.heartbeat_sec = 30;
|
||||
}
|
||||
if (s_cfg.ingest_sec == 0) {
|
||||
s_cfg.ingest_sec = 5;
|
||||
}
|
||||
|
||||
s_mutex = xSemaphoreCreateMutex();
|
||||
if (s_mutex == NULL) {
|
||||
ESP_LOGE(TAG, "failed to create mutex");
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
s_vitals_valid = false;
|
||||
memset(s_happiness, 0, sizeof(s_happiness));
|
||||
s_cnt_regs = 0;
|
||||
s_cnt_heartbeats = 0;
|
||||
s_cnt_ingests = 0;
|
||||
s_cnt_errors = 0;
|
||||
|
||||
BaseType_t ret = xTaskCreatePinnedToCore(
|
||||
swarm_task, "swarm", SWARM_TASK_STACK, NULL,
|
||||
SWARM_TASK_PRIO, &s_task_handle, SWARM_TASK_CORE);
|
||||
|
||||
if (ret != pdPASS) {
|
||||
ESP_LOGE(TAG, "failed to create swarm task");
|
||||
vSemaphoreDelete(s_mutex);
|
||||
s_mutex = NULL;
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "bridge init OK — seed=%s zone=%s hb=%us ingest=%us",
|
||||
s_cfg.seed_url, s_cfg.zone_name,
|
||||
s_cfg.heartbeat_sec, s_cfg.ingest_sec);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void swarm_bridge_update_vitals(const edge_vitals_pkt_t *vitals)
|
||||
{
|
||||
if (vitals == NULL || s_mutex == NULL) {
|
||||
return;
|
||||
}
|
||||
xSemaphoreTake(s_mutex, portMAX_DELAY);
|
||||
memcpy(&s_vitals, vitals, sizeof(s_vitals));
|
||||
s_vitals_valid = true;
|
||||
xSemaphoreGive(s_mutex);
|
||||
}
|
||||
|
||||
void swarm_bridge_update_happiness(const float *vector, uint8_t dim)
|
||||
{
|
||||
if (vector == NULL || s_mutex == NULL) {
|
||||
return;
|
||||
}
|
||||
uint8_t n = (dim < SWARM_VECTOR_DIM) ? dim : SWARM_VECTOR_DIM;
|
||||
|
||||
xSemaphoreTake(s_mutex, portMAX_DELAY);
|
||||
memcpy(s_happiness, vector, n * sizeof(float));
|
||||
/* Zero-fill remaining dimensions. */
|
||||
for (uint8_t i = n; i < SWARM_VECTOR_DIM; i++) {
|
||||
s_happiness[i] = 0.0f;
|
||||
}
|
||||
xSemaphoreGive(s_mutex);
|
||||
}
|
||||
|
||||
void swarm_bridge_get_stats(uint32_t *regs, uint32_t *heartbeats,
|
||||
uint32_t *ingests, uint32_t *errors)
|
||||
{
|
||||
if (regs) *regs = s_cnt_regs;
|
||||
if (heartbeats) *heartbeats = s_cnt_heartbeats;
|
||||
if (ingests) *ingests = s_cnt_ingests;
|
||||
if (errors) *errors = s_cnt_errors;
|
||||
}
|
||||
|
||||
/* ---- HTTP POST helper ---- */
|
||||
|
||||
static esp_err_t swarm_post_json(esp_http_client_handle_t client,
|
||||
const char *json, int json_len)
|
||||
{
|
||||
esp_http_client_set_post_field(client, json, json_len);
|
||||
|
||||
esp_err_t err = esp_http_client_perform(client);
|
||||
if (err != ESP_OK) {
|
||||
/* Connection may have been closed by Seed between requests.
|
||||
* Close our end and let the next perform() reconnect. */
|
||||
esp_http_client_close(client);
|
||||
/* Retry once. */
|
||||
err = esp_http_client_perform(client);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "HTTP POST failed: %s", esp_err_to_name(err));
|
||||
s_cnt_errors++;
|
||||
esp_http_client_close(client);
|
||||
return err;
|
||||
}
|
||||
}
|
||||
|
||||
int status = esp_http_client_get_status_code(client);
|
||||
/* Close connection after each request to avoid stale keep-alive. */
|
||||
esp_http_client_close(client);
|
||||
|
||||
if (status < 200 || status >= 300) {
|
||||
ESP_LOGW(TAG, "HTTP POST status %d", status);
|
||||
s_cnt_errors++;
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/* ---- Get local IP address as string ---- */
|
||||
|
||||
static void swarm_get_ip_str(char *buf, size_t buf_len)
|
||||
{
|
||||
esp_netif_t *netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
|
||||
if (netif == NULL) {
|
||||
snprintf(buf, buf_len, "0.0.0.0");
|
||||
return;
|
||||
}
|
||||
|
||||
esp_netif_ip_info_t ip_info;
|
||||
if (esp_netif_get_ip_info(netif, &ip_info) != ESP_OK) {
|
||||
snprintf(buf, buf_len, "0.0.0.0");
|
||||
return;
|
||||
}
|
||||
|
||||
snprintf(buf, buf_len, IPSTR, IP2STR(&ip_info.ip));
|
||||
}
|
||||
|
||||
/* ---- Swarm bridge task ---- */
|
||||
|
||||
static void swarm_task(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
|
||||
/* Build the full ingest URL once. */
|
||||
char url[128];
|
||||
snprintf(url, sizeof(url), "%s%s", s_cfg.seed_url, SWARM_INGEST_PATH);
|
||||
|
||||
/* Create a reusable HTTP client. */
|
||||
esp_http_client_config_t http_cfg = {
|
||||
.url = url,
|
||||
.method = HTTP_METHOD_POST,
|
||||
.timeout_ms = SWARM_HTTP_TIMEOUT,
|
||||
};
|
||||
esp_http_client_handle_t client = esp_http_client_init(&http_cfg);
|
||||
if (client == NULL) {
|
||||
ESP_LOGE(TAG, "failed to create HTTP client — task exiting");
|
||||
vTaskDelete(NULL);
|
||||
return;
|
||||
}
|
||||
|
||||
esp_http_client_set_header(client, "Content-Type", "application/json");
|
||||
|
||||
/* ADR-066: Set Bearer token for Seed WiFi auth (from pairing). */
|
||||
if (s_cfg.seed_token[0] != '\0') {
|
||||
char auth_hdr[80];
|
||||
snprintf(auth_hdr, sizeof(auth_hdr), "Bearer %s", s_cfg.seed_token);
|
||||
esp_http_client_set_header(client, "Authorization", auth_hdr);
|
||||
ESP_LOGI(TAG, "Bearer token configured for Seed auth");
|
||||
}
|
||||
|
||||
/* Get firmware version string. */
|
||||
const esp_app_desc_t *app = esp_app_get_description();
|
||||
const char *fw_ver = app ? app->version : "unknown";
|
||||
|
||||
/* Get local IP. */
|
||||
char ip_str[16];
|
||||
swarm_get_ip_str(ip_str, sizeof(ip_str));
|
||||
|
||||
/* ---- Registration POST ---- */
|
||||
/* Seed ingest format: {"vectors":[[u64_id, [f32; dim]]]} */
|
||||
{
|
||||
/* ID scheme: node_id * 1000000 + type_code (0=reg, 1=hb, 2=happiness) */
|
||||
uint32_t reg_id = (uint32_t)s_node_id * 1000000U;
|
||||
char json[SWARM_JSON_BUF];
|
||||
int len = snprintf(json, sizeof(json),
|
||||
"{\"vectors\":[[%lu,[0,0,0,0,0,0,0,0]]]}",
|
||||
(unsigned long)reg_id);
|
||||
|
||||
if (swarm_post_json(client, json, len) == ESP_OK) {
|
||||
s_cnt_regs++;
|
||||
ESP_LOGI(TAG, "registered node %u with seed (id=%lu)", s_node_id, (unsigned long)reg_id);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "registration failed — will retry on next heartbeat");
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Main loop ---- */
|
||||
TickType_t last_heartbeat = xTaskGetTickCount();
|
||||
TickType_t last_ingest = xTaskGetTickCount();
|
||||
const TickType_t poll_interval = pdMS_TO_TICKS(1000); /* Wake every 1 s. */
|
||||
|
||||
for (;;) {
|
||||
vTaskDelay(poll_interval);
|
||||
|
||||
TickType_t now = xTaskGetTickCount();
|
||||
|
||||
/* Snapshot shared data under mutex. */
|
||||
float hv[SWARM_VECTOR_DIM];
|
||||
edge_vitals_pkt_t vit;
|
||||
bool vit_valid;
|
||||
|
||||
xSemaphoreTake(s_mutex, portMAX_DELAY);
|
||||
memcpy(hv, s_happiness, sizeof(hv));
|
||||
memcpy(&vit, &s_vitals, sizeof(vit));
|
||||
vit_valid = s_vitals_valid;
|
||||
xSemaphoreGive(s_mutex);
|
||||
|
||||
uint32_t uptime_s = (uint32_t)(esp_timer_get_time() / 1000000ULL);
|
||||
uint32_t free_heap = esp_get_free_heap_size();
|
||||
uint32_t ts = (uint32_t)(esp_timer_get_time() / 1000ULL);
|
||||
|
||||
/* ---- Heartbeat ---- */
|
||||
if ((now - last_heartbeat) >= pdMS_TO_TICKS(s_cfg.heartbeat_sec * 1000U)) {
|
||||
last_heartbeat = now;
|
||||
|
||||
bool presence = vit_valid && (vit.flags & 0x01);
|
||||
|
||||
/* Heartbeat ID: node_id * 1000000 + 100000 + ts_sec */
|
||||
uint32_t hb_id = (uint32_t)s_node_id * 1000000U + 100000U + (uptime_s % 100000U);
|
||||
char json[SWARM_JSON_BUF];
|
||||
int len = snprintf(json, sizeof(json),
|
||||
"{\"vectors\":[[%lu,[%.4f,%.4f,%.4f,%.4f,%.4f,%.4f,%.4f,%.4f]]]}",
|
||||
(unsigned long)hb_id,
|
||||
hv[0], hv[1], hv[2], hv[3], hv[4], hv[5], hv[6], hv[7]);
|
||||
|
||||
if (swarm_post_json(client, json, len) == ESP_OK) {
|
||||
s_cnt_heartbeats++;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Happiness ingest (only when presence detected) ---- */
|
||||
if ((now - last_ingest) >= pdMS_TO_TICKS(s_cfg.ingest_sec * 1000U)) {
|
||||
last_ingest = now;
|
||||
|
||||
bool presence = vit_valid && (vit.flags & 0x01);
|
||||
if (presence) {
|
||||
/* Happiness ID: node_id * 1000000 + 200000 + ts_sec */
|
||||
uint32_t h_id = (uint32_t)s_node_id * 1000000U + 200000U + (ts / 1000U % 100000U);
|
||||
char json[SWARM_JSON_BUF];
|
||||
int len = snprintf(json, sizeof(json),
|
||||
"{\"vectors\":[[%lu,[%.4f,%.4f,%.4f,%.4f,%.4f,%.4f,%.4f,%.4f]]]}",
|
||||
(unsigned long)h_id,
|
||||
hv[0], hv[1], hv[2], hv[3], hv[4], hv[5], hv[6], hv[7]);
|
||||
|
||||
if (swarm_post_json(client, json, len) == ESP_OK) {
|
||||
s_cnt_ingests++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Unreachable, but clean up for completeness. */
|
||||
esp_http_client_cleanup(client);
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* @file swarm_bridge.h
|
||||
* @brief ADR-066: ESP32 Swarm Bridge — Cognitum Seed coordinator client.
|
||||
*
|
||||
* Registers this node with a Cognitum Seed, sends periodic heartbeats,
|
||||
* and pushes happiness vectors for cross-zone analytics.
|
||||
* Runs as a FreeRTOS task on Core 0.
|
||||
*/
|
||||
|
||||
#ifndef SWARM_BRIDGE_H
|
||||
#define SWARM_BRIDGE_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include "esp_err.h"
|
||||
#include "edge_processing.h"
|
||||
|
||||
/** Happiness vector dimension. */
|
||||
#define SWARM_VECTOR_DIM 8
|
||||
|
||||
/** Swarm bridge configuration. */
|
||||
typedef struct {
|
||||
char seed_url[64]; /**< Cognitum Seed base URL (e.g. "http://192.168.1.10:8080"). */
|
||||
char seed_token[64]; /**< Bearer token for Seed WiFi API auth (from pairing). */
|
||||
char zone_name[16]; /**< Zone name for this node (e.g. "bedroom"). */
|
||||
uint16_t heartbeat_sec; /**< Heartbeat interval in seconds (default 30). */
|
||||
uint16_t ingest_sec; /**< Happiness ingest interval in seconds (default 5). */
|
||||
uint8_t enabled; /**< 1 = bridge active, 0 = disabled. */
|
||||
} swarm_config_t;
|
||||
|
||||
/**
|
||||
* Initialize the swarm bridge and start the background task.
|
||||
* Registers this node with the Cognitum Seed on first successful POST.
|
||||
*
|
||||
* @param cfg Swarm bridge configuration.
|
||||
* @param node_id This node's identifier (from NVS).
|
||||
* @return ESP_OK on success, ESP_ERR_INVALID_ARG if seed_url is empty.
|
||||
*/
|
||||
esp_err_t swarm_bridge_init(const swarm_config_t *cfg, uint8_t node_id);
|
||||
|
||||
/**
|
||||
* Feed the latest vitals packet into the swarm bridge.
|
||||
* Called from the main loop whenever new vitals are available.
|
||||
*
|
||||
* @param vitals Pointer to the latest vitals packet.
|
||||
*/
|
||||
void swarm_bridge_update_vitals(const edge_vitals_pkt_t *vitals);
|
||||
|
||||
/**
|
||||
* Update the happiness vector to be pushed at the next ingest cycle.
|
||||
*
|
||||
* @param vector Float array of happiness values.
|
||||
* @param dim Number of elements (clamped to SWARM_VECTOR_DIM).
|
||||
*/
|
||||
void swarm_bridge_update_happiness(const float *vector, uint8_t dim);
|
||||
|
||||
/**
|
||||
* Get cumulative bridge statistics.
|
||||
*
|
||||
* @param regs Output: number of successful registrations.
|
||||
* @param heartbeats Output: number of successful heartbeats sent.
|
||||
* @param ingests Output: number of successful happiness ingests sent.
|
||||
* @param errors Output: number of HTTP errors encountered.
|
||||
*/
|
||||
void swarm_bridge_get_stats(uint32_t *regs, uint32_t *heartbeats,
|
||||
uint32_t *ingests, uint32_t *errors);
|
||||
|
||||
#endif /* SWARM_BRIDGE_H */
|
||||
@@ -12,6 +12,9 @@
|
||||
|
||||
#include "sdkconfig.h"
|
||||
#include "wasm_runtime.h"
|
||||
#include "nvs_config.h"
|
||||
|
||||
extern nvs_config_t g_nvs_config;
|
||||
|
||||
#if defined(CONFIG_WASM_ENABLE) && defined(WASM3_AVAILABLE)
|
||||
|
||||
@@ -380,11 +383,7 @@ static void send_wasm_output(uint8_t slot_id)
|
||||
memset(&pkt, 0, sizeof(pkt));
|
||||
|
||||
pkt.magic = WASM_OUTPUT_MAGIC;
|
||||
#ifdef CONFIG_CSI_NODE_ID
|
||||
pkt.node_id = (uint8_t)CONFIG_CSI_NODE_ID;
|
||||
#else
|
||||
pkt.node_id = 0;
|
||||
#endif
|
||||
pkt.node_id = g_nvs_config.node_id;
|
||||
pkt.module_id = slot_id;
|
||||
pkt.event_count = n_filtered;
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -71,6 +71,17 @@ def build_nvs_csv(args):
|
||||
mac_bytes = bytes(int(b, 16) for b in args.filter_mac.split(":"))
|
||||
# NVS blob: write as hex-encoded string for CSV compatibility
|
||||
writer.writerow(["filter_mac", "data", "hex2bin", mac_bytes.hex()])
|
||||
# ADR-066: Swarm bridge configuration
|
||||
if args.seed_url is not None:
|
||||
writer.writerow(["seed_url", "data", "string", args.seed_url])
|
||||
if args.seed_token is not None:
|
||||
writer.writerow(["seed_token", "data", "string", args.seed_token])
|
||||
if args.zone is not None:
|
||||
writer.writerow(["zone_name", "data", "string", args.zone])
|
||||
if args.swarm_hb is not None:
|
||||
writer.writerow(["swarm_hb", "data", "u16", str(args.swarm_hb)])
|
||||
if args.swarm_ingest is not None:
|
||||
writer.writerow(["swarm_ingest", "data", "u16", str(args.swarm_ingest)])
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
@@ -170,6 +181,12 @@ def main():
|
||||
parser.add_argument("--channel", type=int, help="CSI channel (1-14 for 2.4GHz, 36-177 for 5GHz). "
|
||||
"Overrides auto-detection from connected AP.")
|
||||
parser.add_argument("--filter-mac", type=str, help="MAC address to filter CSI frames (AA:BB:CC:DD:EE:FF)")
|
||||
# ADR-066: Swarm bridge
|
||||
parser.add_argument("--seed-url", type=str, help="Cognitum Seed base URL (e.g. http://10.1.10.236)")
|
||||
parser.add_argument("--seed-token", type=str, help="Seed Bearer token (from pairing)")
|
||||
parser.add_argument("--zone", type=str, help="Zone name for this node (e.g. lobby, hallway)")
|
||||
parser.add_argument("--swarm-hb", type=int, help="Swarm heartbeat interval in seconds (default 30)")
|
||||
parser.add_argument("--swarm-ingest", type=int, help="Swarm vector ingest interval in seconds (default 5)")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Generate NVS binary but don't flash")
|
||||
|
||||
args = parser.parse_args()
|
||||
@@ -182,6 +199,7 @@ def main():
|
||||
args.fall_thresh is not None, args.vital_win is not None,
|
||||
args.vital_int is not None, args.subk_count is not None,
|
||||
args.channel is not None, args.filter_mac is not None,
|
||||
args.seed_url is not None, args.zone is not None,
|
||||
])
|
||||
if not has_value:
|
||||
parser.error("At least one config value must be specified")
|
||||
@@ -238,6 +256,14 @@ def main():
|
||||
print(f" CSI Channel: {args.channel}")
|
||||
if args.filter_mac is not None:
|
||||
print(f" Filter MAC: {args.filter_mac}")
|
||||
if args.seed_url is not None:
|
||||
print(f" Seed URL: {args.seed_url}")
|
||||
if args.zone is not None:
|
||||
print(f" Zone: {args.zone}")
|
||||
if args.swarm_hb is not None:
|
||||
print(f" Swarm HB: {args.swarm_hb}s")
|
||||
if args.swarm_ingest is not None:
|
||||
print(f" Swarm Ingest: {args.swarm_ingest}s")
|
||||
|
||||
csv_content = build_nvs_csv(args)
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,33 @@
|
||||
# ESP32-S3 CSI Node — Default SDK Configuration
|
||||
# This file is applied automatically by idf.py when no sdkconfig exists.
|
||||
|
||||
# Target: ESP32-S3
|
||||
CONFIG_IDF_TARGET="esp32s3"
|
||||
|
||||
# Use custom partition table (8MB flash with OTA — ADR-045)
|
||||
CONFIG_PARTITION_TABLE_CUSTOM=y
|
||||
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions_display.csv"
|
||||
|
||||
# Flash configuration: 8MB (Quad SPI)
|
||||
CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y
|
||||
CONFIG_ESPTOOLPY_FLASHSIZE="8MB"
|
||||
|
||||
# Compiler optimization: optimize for size to reduce binary
|
||||
CONFIG_COMPILER_OPTIMIZATION_SIZE=y
|
||||
|
||||
# Enable CSI (Channel State Information) in WiFi driver
|
||||
CONFIG_ESP_WIFI_CSI_ENABLED=y
|
||||
|
||||
# NVS encryption disabled by default (requires eFuse provisioning).
|
||||
# Enable only after burning HMAC key to eFuse block.
|
||||
# CONFIG_NVS_ENCRYPTION is not set
|
||||
|
||||
# Disable unused features to reduce binary size
|
||||
CONFIG_BOOTLOADER_LOG_LEVEL_WARN=y
|
||||
CONFIG_LOG_DEFAULT_LEVEL_INFO=y
|
||||
|
||||
# LWIP: enable extended socket options for UDP multicast
|
||||
CONFIG_LWIP_SO_RCVBUF=y
|
||||
|
||||
# FreeRTOS: increase task stack for CSI processing
|
||||
CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
|
||||
@@ -63,3 +63,13 @@ esp_err_t wasm_runtime_unload(uint8_t id) { (void)id; return ESP_OK; }
|
||||
void wasm_runtime_on_timer(void) {}
|
||||
void wasm_runtime_get_info(wasm_module_info_t *info, uint8_t *count) { (void)info; if(count) *count = 0; }
|
||||
esp_err_t wasm_runtime_set_manifest(uint8_t id, const char *n, uint32_t c, uint32_t m) { (void)id; (void)n; (void)c; (void)m; return ESP_OK; }
|
||||
|
||||
/* ---- mmwave_sensor stubs (ADR-063) ---- */
|
||||
|
||||
#include "mmwave_sensor.h"
|
||||
|
||||
static mmwave_state_t s_stub_mmwave = {0};
|
||||
|
||||
esp_err_t mmwave_sensor_init(int tx, int rx) { (void)tx; (void)rx; return ESP_ERR_NOT_FOUND; }
|
||||
bool mmwave_sensor_get_state(mmwave_state_t *s) { if (s) *s = s_stub_mmwave; return false; }
|
||||
const char *mmwave_type_name(mmwave_type_t t) { (void)t; return "None"; }
|
||||
|
||||
@@ -20,8 +20,9 @@
|
||||
typedef int esp_err_t;
|
||||
#define ESP_OK 0
|
||||
#define ESP_FAIL (-1)
|
||||
#define ESP_ERR_NO_MEM 0x101
|
||||
#define ESP_ERR_NO_MEM 0x101
|
||||
#define ESP_ERR_INVALID_ARG 0x102
|
||||
#define ESP_ERR_NOT_FOUND 0x105
|
||||
|
||||
/* ---- esp_log.h ---- */
|
||||
#define ESP_LOGI(tag, fmt, ...) ((void)0)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# ESP32-S3 Hello World — Capability Discovery
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
|
||||
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||
project(esp32-hello-world)
|
||||
@@ -0,0 +1,4 @@
|
||||
idf_component_register(
|
||||
SRCS "main.c"
|
||||
INCLUDE_DIRS "."
|
||||
)
|
||||
@@ -0,0 +1,437 @@
|
||||
/**
|
||||
* @file main.c
|
||||
* @brief ESP32-S3 Hello World — Full Capability Discovery
|
||||
*
|
||||
* Boots up, prints "Hello World!", then probes and reports every major
|
||||
* hardware/software capability of the ESP32-S3: chip info, flash, PSRAM,
|
||||
* WiFi (including CSI), Bluetooth, GPIOs, peripherals, FreeRTOS stats,
|
||||
* and power management features. No WiFi connection required.
|
||||
*/
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <inttypes.h>
|
||||
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "esp_system.h"
|
||||
#include "esp_chip_info.h"
|
||||
#include "esp_flash.h"
|
||||
#include "esp_mac.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_wifi.h"
|
||||
#include "esp_event.h"
|
||||
#include "esp_timer.h"
|
||||
#include "esp_heap_caps.h"
|
||||
#include "esp_partition.h"
|
||||
#include "esp_ota_ops.h"
|
||||
#include "esp_efuse.h"
|
||||
#include "esp_pm.h"
|
||||
#include "nvs_flash.h"
|
||||
#include "soc/soc_caps.h"
|
||||
#include "driver/gpio.h"
|
||||
#include "driver/temperature_sensor.h"
|
||||
#include "sdkconfig.h"
|
||||
|
||||
static const char *TAG = "hello";
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────────────────────── */
|
||||
|
||||
static const char *chip_model_str(esp_chip_model_t model)
|
||||
{
|
||||
switch (model) {
|
||||
case CHIP_ESP32: return "ESP32";
|
||||
case CHIP_ESP32S2: return "ESP32-S2";
|
||||
case CHIP_ESP32S3: return "ESP32-S3";
|
||||
case CHIP_ESP32C3: return "ESP32-C3";
|
||||
case CHIP_ESP32H2: return "ESP32-H2";
|
||||
case CHIP_ESP32C2: return "ESP32-C2";
|
||||
default: return "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
static void print_separator(const char *title)
|
||||
{
|
||||
printf("\n╔══════════════════════════════════════════════════════════╗\n");
|
||||
printf("║ %-55s ║\n", title);
|
||||
printf("╚══════════════════════════════════════════════════════════╝\n");
|
||||
}
|
||||
|
||||
/* ── Capability Probes ───────────────────────────────────────────────── */
|
||||
|
||||
static void probe_chip_info(void)
|
||||
{
|
||||
print_separator("CHIP INFO");
|
||||
|
||||
esp_chip_info_t info;
|
||||
esp_chip_info(&info);
|
||||
|
||||
printf(" Model: %s (rev %d.%d)\n",
|
||||
chip_model_str(info.model),
|
||||
info.revision / 100, info.revision % 100);
|
||||
printf(" Cores: %d\n", info.cores);
|
||||
printf(" Features: ");
|
||||
if (info.features & CHIP_FEATURE_WIFI_BGN) printf("WiFi ");
|
||||
if (info.features & CHIP_FEATURE_BLE) printf("BLE ");
|
||||
if (info.features & CHIP_FEATURE_BT) printf("BT-Classic ");
|
||||
if (info.features & CHIP_FEATURE_IEEE802154) printf("802.15.4 ");
|
||||
if (info.features & CHIP_FEATURE_EMB_FLASH) printf("EmbFlash ");
|
||||
if (info.features & CHIP_FEATURE_EMB_PSRAM) printf("EmbPSRAM ");
|
||||
printf("\n");
|
||||
|
||||
/* MAC addresses */
|
||||
uint8_t mac[6];
|
||||
if (esp_read_mac(mac, ESP_MAC_WIFI_STA) == ESP_OK) {
|
||||
printf(" WiFi STA MAC: %02X:%02X:%02X:%02X:%02X:%02X\n",
|
||||
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
|
||||
}
|
||||
if (esp_read_mac(mac, ESP_MAC_BT) == ESP_OK) {
|
||||
printf(" BT MAC: %02X:%02X:%02X:%02X:%02X:%02X\n",
|
||||
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
|
||||
}
|
||||
|
||||
printf(" IDF Version: %s\n", esp_get_idf_version());
|
||||
printf(" Reset Reason: %d\n", esp_reset_reason());
|
||||
}
|
||||
|
||||
static void probe_memory(void)
|
||||
{
|
||||
print_separator("MEMORY");
|
||||
|
||||
/* Internal RAM */
|
||||
printf(" Internal DRAM:\n");
|
||||
printf(" Total: %"PRIu32" bytes\n",
|
||||
(uint32_t)heap_caps_get_total_size(MALLOC_CAP_INTERNAL));
|
||||
printf(" Free: %"PRIu32" bytes\n",
|
||||
(uint32_t)heap_caps_get_free_size(MALLOC_CAP_INTERNAL));
|
||||
printf(" Min Free: %"PRIu32" bytes\n",
|
||||
(uint32_t)heap_caps_get_minimum_free_size(MALLOC_CAP_INTERNAL));
|
||||
|
||||
/* PSRAM */
|
||||
size_t psram_total = heap_caps_get_total_size(MALLOC_CAP_SPIRAM);
|
||||
if (psram_total > 0) {
|
||||
printf(" External PSRAM:\n");
|
||||
printf(" Total: %"PRIu32" bytes (%.1f MB)\n",
|
||||
(uint32_t)psram_total, psram_total / (1024.0 * 1024.0));
|
||||
printf(" Free: %"PRIu32" bytes\n",
|
||||
(uint32_t)heap_caps_get_free_size(MALLOC_CAP_SPIRAM));
|
||||
} else {
|
||||
printf(" External PSRAM: Not available\n");
|
||||
}
|
||||
|
||||
/* DMA-capable */
|
||||
printf(" DMA-capable: %"PRIu32" bytes free\n",
|
||||
(uint32_t)heap_caps_get_free_size(MALLOC_CAP_DMA));
|
||||
}
|
||||
|
||||
static void probe_flash(void)
|
||||
{
|
||||
print_separator("FLASH STORAGE");
|
||||
|
||||
uint32_t flash_size = 0;
|
||||
if (esp_flash_get_size(NULL, &flash_size) == ESP_OK) {
|
||||
printf(" Flash Size: %"PRIu32" bytes (%.0f MB)\n",
|
||||
flash_size, flash_size / (1024.0 * 1024.0));
|
||||
}
|
||||
|
||||
/* Partition table */
|
||||
printf(" Partitions:\n");
|
||||
esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_ANY,
|
||||
ESP_PARTITION_SUBTYPE_ANY, NULL);
|
||||
while (it != NULL) {
|
||||
const esp_partition_t *p = esp_partition_get(it);
|
||||
printf(" %-16s type=0x%02x sub=0x%02x offset=0x%06"PRIx32" size=%"PRIu32" KB\n",
|
||||
p->label, p->type, p->subtype, p->address, p->size / 1024);
|
||||
it = esp_partition_next(it);
|
||||
}
|
||||
esp_partition_iterator_release(it);
|
||||
|
||||
/* Running partition */
|
||||
const esp_partition_t *running = esp_ota_get_running_partition();
|
||||
if (running) {
|
||||
printf(" Running from: %s (0x%06"PRIx32")\n", running->label, running->address);
|
||||
}
|
||||
}
|
||||
|
||||
static void probe_wifi_capabilities(void)
|
||||
{
|
||||
print_separator("WiFi CAPABILITIES");
|
||||
|
||||
/* Init WiFi just enough to query capabilities (no connection) */
|
||||
ESP_ERROR_CHECK(esp_netif_init());
|
||||
ESP_ERROR_CHECK(esp_event_loop_create_default());
|
||||
esp_netif_create_default_wifi_sta();
|
||||
|
||||
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
|
||||
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
|
||||
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
|
||||
ESP_ERROR_CHECK(esp_wifi_start());
|
||||
|
||||
/* Protocol capabilities */
|
||||
printf(" Protocols: 802.11 b/g/n\n");
|
||||
|
||||
/* CSI (Channel State Information) */
|
||||
#ifdef CONFIG_ESP_WIFI_CSI_ENABLED
|
||||
printf(" CSI: ENABLED (Channel State Information)\n");
|
||||
printf(" - Subcarrier amplitude & phase data\n");
|
||||
printf(" - Per-packet callback available\n");
|
||||
printf(" - Use for: presence detection, gesture recognition,\n");
|
||||
printf(" breathing/heart rate, indoor positioning\n");
|
||||
#else
|
||||
printf(" CSI: DISABLED (enable CONFIG_ESP_WIFI_CSI_ENABLED)\n");
|
||||
#endif
|
||||
|
||||
/* Scan to show what's visible */
|
||||
printf(" WiFi Scan: Scanning nearby APs...\n");
|
||||
wifi_scan_config_t scan_cfg = {
|
||||
.show_hidden = true,
|
||||
.scan_type = WIFI_SCAN_TYPE_ACTIVE,
|
||||
.scan_time.active.min = 100,
|
||||
.scan_time.active.max = 300,
|
||||
};
|
||||
esp_wifi_scan_start(&scan_cfg, true); /* blocking scan */
|
||||
|
||||
uint16_t ap_count = 0;
|
||||
esp_wifi_scan_get_ap_num(&ap_count);
|
||||
printf(" APs Found: %d\n", ap_count);
|
||||
|
||||
if (ap_count > 0) {
|
||||
uint16_t max_show = (ap_count > 10) ? 10 : ap_count;
|
||||
wifi_ap_record_t *ap_list = malloc(sizeof(wifi_ap_record_t) * max_show);
|
||||
if (ap_list) {
|
||||
esp_wifi_scan_get_ap_records(&max_show, ap_list);
|
||||
printf(" %-32s CH RSSI Auth\n", " SSID");
|
||||
printf(" %-32s -- ---- ----\n", " ----");
|
||||
for (int i = 0; i < max_show; i++) {
|
||||
const char *auth_str = "OPEN";
|
||||
switch (ap_list[i].authmode) {
|
||||
case WIFI_AUTH_WEP: auth_str = "WEP"; break;
|
||||
case WIFI_AUTH_WPA_PSK: auth_str = "WPA"; break;
|
||||
case WIFI_AUTH_WPA2_PSK: auth_str = "WPA2"; break;
|
||||
case WIFI_AUTH_WPA_WPA2_PSK: auth_str = "WPA/2"; break;
|
||||
case WIFI_AUTH_WPA3_PSK: auth_str = "WPA3"; break;
|
||||
case WIFI_AUTH_WPA2_WPA3_PSK: auth_str = "WPA2/3"; break;
|
||||
default: break;
|
||||
}
|
||||
printf(" %-30s %2d %4d %s\n",
|
||||
(char *)ap_list[i].ssid,
|
||||
ap_list[i].primary,
|
||||
ap_list[i].rssi,
|
||||
auth_str);
|
||||
}
|
||||
free(ap_list);
|
||||
if (ap_count > max_show)
|
||||
printf(" ... and %d more\n", ap_count - max_show);
|
||||
}
|
||||
}
|
||||
|
||||
/* WiFi modes supported */
|
||||
printf("\n Supported Modes:\n");
|
||||
printf(" - STA (Station / Client)\n");
|
||||
printf(" - AP (Access Point / Soft-AP)\n");
|
||||
printf(" - STA+AP (Concurrent)\n");
|
||||
printf(" - Promiscuous (raw 802.11 frame capture)\n");
|
||||
printf(" - ESP-NOW (peer-to-peer, no router needed)\n");
|
||||
printf(" - WiFi Aware / NAN (Neighbor Awareness)\n");
|
||||
|
||||
esp_wifi_stop();
|
||||
esp_wifi_deinit();
|
||||
}
|
||||
|
||||
static void probe_bluetooth(void)
|
||||
{
|
||||
print_separator("BLUETOOTH CAPABILITIES");
|
||||
|
||||
esp_chip_info_t info;
|
||||
esp_chip_info(&info);
|
||||
|
||||
if (info.features & CHIP_FEATURE_BLE) {
|
||||
printf(" BLE: Supported (Bluetooth 5.0 LE)\n");
|
||||
printf(" - GATT Server/Client\n");
|
||||
printf(" - Advertising & Scanning\n");
|
||||
printf(" - Mesh Networking\n");
|
||||
printf(" - Long Range (Coded PHY)\n");
|
||||
printf(" - 2 Mbps PHY\n");
|
||||
} else {
|
||||
printf(" BLE: Not supported on this chip\n");
|
||||
}
|
||||
|
||||
if (info.features & CHIP_FEATURE_BT) {
|
||||
printf(" BT Classic: Supported (A2DP, SPP, HFP)\n");
|
||||
} else {
|
||||
printf(" BT Classic: Not available (ESP32-S3 is BLE-only)\n");
|
||||
}
|
||||
}
|
||||
|
||||
static void probe_peripherals(void)
|
||||
{
|
||||
print_separator("PERIPHERAL CAPABILITIES");
|
||||
|
||||
printf(" GPIOs: %d total\n", SOC_GPIO_PIN_COUNT);
|
||||
printf(" ADC:\n");
|
||||
printf(" - ADC1: %d channels (12-bit SAR)\n", SOC_ADC_CHANNEL_NUM(0));
|
||||
printf(" - ADC2: %d channels (shared with WiFi)\n", SOC_ADC_CHANNEL_NUM(1));
|
||||
printf(" DAC: Not available on ESP32-S3\n");
|
||||
printf(" Touch Sensors: %d channels (capacitive)\n", SOC_TOUCH_SENSOR_NUM);
|
||||
printf(" SPI: %d controllers (SPI2/SPI3 for user)\n", SOC_SPI_PERIPH_NUM);
|
||||
printf(" I2C: %d controllers\n", SOC_I2C_NUM);
|
||||
printf(" I2S: %d controllers (audio/PDM/TDM)\n", SOC_I2S_NUM);
|
||||
printf(" UART: %d controllers\n", SOC_UART_NUM);
|
||||
printf(" USB: USB-OTG 1.1 (Host & Device)\n");
|
||||
printf(" USB-Serial: Built-in USB-JTAG/Serial (this console)\n");
|
||||
printf(" TWAI (CAN): 1 controller (CAN 2.0B compatible)\n");
|
||||
printf(" RMT: %d channels (IR/WS2812/NeoPixel)\n", SOC_RMT_TX_CANDIDATES_PER_GROUP + SOC_RMT_RX_CANDIDATES_PER_GROUP);
|
||||
printf(" LEDC (PWM): %d channels\n", SOC_LEDC_CHANNEL_NUM);
|
||||
printf(" MCPWM: %d groups (motor control)\n", SOC_MCPWM_GROUPS);
|
||||
printf(" PCNT: %d units (pulse counter / encoder)\n", SOC_PCNT_UNITS_PER_GROUP);
|
||||
printf(" LCD: Parallel 8/16-bit + SPI + I2C interfaces\n");
|
||||
printf(" Camera: DVP 8/16-bit parallel interface\n");
|
||||
printf(" SDMMC: SD/MMC host controller (1-bit / 4-bit)\n");
|
||||
}
|
||||
|
||||
static void probe_security(void)
|
||||
{
|
||||
print_separator("SECURITY & CRYPTO");
|
||||
|
||||
printf(" AES: 128/256-bit hardware accelerator\n");
|
||||
printf(" SHA: SHA-1/224/256 hardware accelerator\n");
|
||||
printf(" RSA: Up to 4096-bit hardware accelerator\n");
|
||||
printf(" HMAC: Hardware HMAC (eFuse key)\n");
|
||||
printf(" Digital Sig: Hardware digital signature (RSA)\n");
|
||||
printf(" Flash Encrypt: AES-256-XTS (eFuse controlled)\n");
|
||||
printf(" Secure Boot: V2 (RSA-3072 / ECDSA)\n");
|
||||
printf(" eFuse: %d bits (MAC, keys, config)\n", 256 * 11);
|
||||
printf(" World Ctrl: Dual-world isolation (TEE)\n");
|
||||
printf(" Random: Hardware TRNG available\n");
|
||||
}
|
||||
|
||||
static void probe_power(void)
|
||||
{
|
||||
print_separator("POWER MANAGEMENT");
|
||||
|
||||
printf(" Clock Modes:\n");
|
||||
printf(" - 240 MHz (max performance)\n");
|
||||
printf(" - 160 MHz (balanced)\n");
|
||||
printf(" - 80 MHz (low power)\n");
|
||||
printf(" Sleep Modes:\n");
|
||||
printf(" - Modem Sleep (WiFi off, CPU active)\n");
|
||||
printf(" - Light Sleep (CPU paused, fast wake)\n");
|
||||
printf(" - Deep Sleep (RTC only, ~10 uA)\n");
|
||||
printf(" - Hibernation (RTC timer only, ~5 uA)\n");
|
||||
printf(" Wake Sources: GPIO, timer, touch, ULP, UART\n");
|
||||
printf(" ULP Coprocessor: RISC-V + FSM (runs in deep sleep)\n");
|
||||
}
|
||||
|
||||
static void probe_temperature(void)
|
||||
{
|
||||
print_separator("TEMPERATURE SENSOR");
|
||||
|
||||
temperature_sensor_handle_t tsens = NULL;
|
||||
temperature_sensor_config_t tsens_cfg = TEMPERATURE_SENSOR_CONFIG_DEFAULT(-10, 80);
|
||||
|
||||
esp_err_t ret = temperature_sensor_install(&tsens_cfg, &tsens);
|
||||
if (ret == ESP_OK) {
|
||||
temperature_sensor_enable(tsens);
|
||||
float temp_c = 0;
|
||||
temperature_sensor_get_celsius(tsens, &temp_c);
|
||||
printf(" Chip Temp: %.1f °C (%.1f °F)\n", temp_c, temp_c * 9.0 / 5.0 + 32.0);
|
||||
temperature_sensor_disable(tsens);
|
||||
temperature_sensor_uninstall(tsens);
|
||||
} else {
|
||||
printf(" Chip Temp: Sensor not available (%s)\n", esp_err_to_name(ret));
|
||||
}
|
||||
}
|
||||
|
||||
static void probe_freertos(void)
|
||||
{
|
||||
print_separator("FreeRTOS / SYSTEM");
|
||||
|
||||
printf(" FreeRTOS: v%s\n", tskKERNEL_VERSION_NUMBER);
|
||||
printf(" Tick Rate: %d Hz\n", configTICK_RATE_HZ);
|
||||
printf(" Task Count: %"PRIu32"\n", (uint32_t)uxTaskGetNumberOfTasks());
|
||||
printf(" Main Stack: %d bytes\n", CONFIG_ESP_MAIN_TASK_STACK_SIZE);
|
||||
printf(" Uptime: %lld ms\n", esp_timer_get_time() / 1000LL);
|
||||
}
|
||||
|
||||
static void probe_csi_details(void)
|
||||
{
|
||||
print_separator("CSI (Channel State Information) DETAILS");
|
||||
|
||||
#ifdef CONFIG_ESP_WIFI_CSI_ENABLED
|
||||
printf(" Status: ENABLED in this build\n");
|
||||
printf("\n What is CSI?\n");
|
||||
printf(" WiFi CSI captures the amplitude and phase of each OFDM\n");
|
||||
printf(" subcarrier in received WiFi frames. This gives a detailed\n");
|
||||
printf(" view of how radio signals propagate through a space.\n");
|
||||
printf("\n Subcarriers: 52 (20 MHz) / 114 (40 MHz) per frame\n");
|
||||
printf(" Data Rate: Up to ~100 frames/sec\n");
|
||||
printf(" Data per Frame: ~200-500 bytes (amplitude + phase)\n");
|
||||
printf("\n Applications:\n");
|
||||
printf(" 1. Presence Detection — detect humans in a room\n");
|
||||
printf(" 2. Gesture Recognition — classify hand gestures\n");
|
||||
printf(" 3. Activity Recognition — walking, sitting, falling\n");
|
||||
printf(" 4. Breathing/Heart Rate — contactless vital signs\n");
|
||||
printf(" 5. Indoor Positioning — sub-meter localization\n");
|
||||
printf(" 6. Fall Detection — elderly safety monitoring\n");
|
||||
printf(" 7. People Counting — crowd estimation\n");
|
||||
printf(" 8. Sleep Monitoring — non-contact sleep staging\n");
|
||||
printf("\n How to use:\n");
|
||||
printf(" esp_wifi_set_csi_config(&csi_config);\n");
|
||||
printf(" esp_wifi_set_csi_rx_cb(my_callback, NULL);\n");
|
||||
printf(" esp_wifi_set_csi(true);\n");
|
||||
#else
|
||||
printf(" Status: DISABLED\n");
|
||||
printf(" To enable: Set CONFIG_ESP_WIFI_CSI_ENABLED=y in sdkconfig\n");
|
||||
#endif
|
||||
}
|
||||
|
||||
/* ── Main ────────────────────────────────────────────────────────────── */
|
||||
|
||||
void app_main(void)
|
||||
{
|
||||
/* NVS required for WiFi */
|
||||
esp_err_t ret = nvs_flash_init();
|
||||
if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
|
||||
nvs_flash_erase();
|
||||
ret = nvs_flash_init();
|
||||
}
|
||||
ESP_ERROR_CHECK(ret);
|
||||
|
||||
/* ── Hello World! ── */
|
||||
printf("\n");
|
||||
printf(" ╭─────────────────────────────────────────────────╮\n");
|
||||
printf(" │ │\n");
|
||||
printf(" │ HELLO WORLD from ESP32-S3! │\n");
|
||||
printf(" │ │\n");
|
||||
printf(" │ WiFi-DensePose Capability Discovery v1.0 │\n");
|
||||
printf(" │ │\n");
|
||||
printf(" ╰─────────────────────────────────────────────────╯\n");
|
||||
printf("\n");
|
||||
|
||||
/* Run all probes */
|
||||
probe_chip_info();
|
||||
probe_memory();
|
||||
probe_flash();
|
||||
probe_temperature();
|
||||
probe_peripherals();
|
||||
probe_security();
|
||||
probe_power();
|
||||
probe_freertos();
|
||||
probe_wifi_capabilities();
|
||||
probe_bluetooth();
|
||||
probe_csi_details();
|
||||
|
||||
print_separator("DONE — ALL CAPABILITIES REPORTED");
|
||||
printf("\n This ESP32-S3 is ready for WiFi-DensePose!\n");
|
||||
printf(" Flash the full firmware (esp32-csi-node) to begin CSI sensing.\n\n");
|
||||
|
||||
/* Keep alive — blink a status message every 10 seconds */
|
||||
int tick = 0;
|
||||
while (1) {
|
||||
vTaskDelay(pdMS_TO_TICKS(10000));
|
||||
tick++;
|
||||
printf("[hello] Still running... uptime=%lld sec, free_heap=%"PRIu32"\n",
|
||||
esp_timer_get_time() / 1000000LL,
|
||||
(uint32_t)heap_caps_get_free_size(MALLOC_CAP_INTERNAL));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
# ESP32-S3 Hello World — SDK Configuration
|
||||
CONFIG_IDF_TARGET="esp32s3"
|
||||
|
||||
# Flash: 4MB (this chip has Embedded Flash 4MB)
|
||||
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
|
||||
CONFIG_ESPTOOLPY_FLASHSIZE="4MB"
|
||||
|
||||
# Enable WiFi CSI so we can probe it
|
||||
CONFIG_ESP_WIFI_CSI_ENABLED=y
|
||||
|
||||
# Verbose logging so user sees everything
|
||||
CONFIG_LOG_DEFAULT_LEVEL_INFO=y
|
||||
|
||||
# Bigger main task stack for printf-heavy capability dump
|
||||
CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
|
||||
|
||||
# Enable temperature sensor driver
|
||||
CONFIG_SOC_TEMP_SENSOR_SUPPORTED=y
|
||||
+1
-1
@@ -185,7 +185,7 @@ package-dir = {"" = "."}
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["src*"]
|
||||
include = ["wifi_densepose*", "src*"]
|
||||
exclude = ["tests*", "docs*", "scripts*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"intelligence":35,"timestamp":1774903706609}
|
||||
@@ -117,6 +117,7 @@ midstreamer-temporal-compare = "0.1.0"
|
||||
midstreamer-attractor = "0.1.0"
|
||||
|
||||
# ruvector integration (published on crates.io)
|
||||
# Vendored at v2.1.0 in vendor/ruvector; using crates.io versions until published.
|
||||
ruvector-mincut = "2.0.4"
|
||||
ruvector-attn-mincut = "2.0.4"
|
||||
ruvector-temporal-tensor = "2.0.4"
|
||||
|
||||
@@ -21,3 +21,4 @@ pub use bvp::attention_weighted_bvp;
|
||||
pub use fresnel::solve_fresnel_geometry;
|
||||
pub use spectrogram::gate_spectrogram;
|
||||
pub use subcarrier::mincut_subcarrier_partition;
|
||||
pub use subcarrier::subcarrier_importance_weights;
|
||||
|
||||
@@ -142,6 +142,29 @@ pub fn mincut_subcarrier_partition(sensitivity: &[f32]) -> (Vec<usize>, Vec<usiz
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a mincut partition into per-subcarrier importance weights.
|
||||
///
|
||||
/// Sensitive subcarriers (high body-motion correlation) get weight > 1.0,
|
||||
/// insensitive ones get weight 0.5. This allows downstream feature extraction
|
||||
/// to emphasise the most informative subcarriers.
|
||||
pub fn subcarrier_importance_weights(sensitivity: &[f32]) -> Vec<f32> {
|
||||
if sensitivity.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
let (sensitive, _insensitive) = mincut_subcarrier_partition(sensitivity);
|
||||
let max_sens = sensitivity
|
||||
.iter()
|
||||
.cloned()
|
||||
.fold(f32::NEG_INFINITY, f32::max)
|
||||
.max(1e-9);
|
||||
|
||||
let mut weights = vec![0.5f32; sensitivity.len()];
|
||||
for &idx in &sensitive {
|
||||
weights[idx] = 1.0 + (sensitivity[idx] / max_sens).min(1.0);
|
||||
}
|
||||
weights
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -175,4 +198,38 @@ mod tests {
|
||||
assert_eq!(s, vec![0]);
|
||||
assert!(i.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_importance_weights_empty() {
|
||||
let w = subcarrier_importance_weights(&[]);
|
||||
assert!(w.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_importance_weights_all_equal() {
|
||||
let sensitivity = vec![1.0f32; 8];
|
||||
let w = subcarrier_importance_weights(&sensitivity);
|
||||
assert_eq!(w.len(), 8);
|
||||
// All subcarriers have identical sensitivity so all should be classified
|
||||
// the same way (either all sensitive or all insensitive after mincut).
|
||||
// At minimum, no weight should exceed 2.0 or be negative.
|
||||
for &wt in &w {
|
||||
assert!(wt >= 0.5 && wt <= 2.0, "weight {wt} out of range");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_importance_weights_sensitive_higher() {
|
||||
// First 5 subcarriers have high sensitivity, last 5 low.
|
||||
let sensitivity: Vec<f32> = (0..10).map(|i| if i < 5 { 0.9 } else { 0.1 }).collect();
|
||||
let w = subcarrier_importance_weights(&sensitivity);
|
||||
assert_eq!(w.len(), 10);
|
||||
|
||||
let mean_high: f32 = w[..5].iter().sum::<f32>() / 5.0;
|
||||
let mean_low: f32 = w[5..].iter().sum::<f32>() / 5.0;
|
||||
assert!(
|
||||
mean_high > mean_low,
|
||||
"sensitive subcarriers should have higher mean weight ({mean_high}) than insensitive ({mean_low})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,5 +43,8 @@ clap = { workspace = true }
|
||||
# Multi-BSSID WiFi scanning pipeline (ADR-022 Phase 3)
|
||||
wifi-densepose-wifiscan = { version = "0.3.0", path = "../wifi-densepose-wifiscan" }
|
||||
|
||||
# RuVector graph min-cut for person separation (ADR-068)
|
||||
ruvector-mincut = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.10"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+233
@@ -0,0 +1,233 @@
|
||||
//! Integration test: multi-node per-node state isolation (ADR-068, #249).
|
||||
//!
|
||||
//! Sends simulated ESP32 CSI frames from multiple node IDs to the server's
|
||||
//! UDP port and verifies that:
|
||||
//! 1. Each node gets independent state (no cross-contamination)
|
||||
//! 2. Person count aggregates across active nodes
|
||||
//! 3. Stale nodes are excluded from aggregation
|
||||
//!
|
||||
//! This does NOT require QEMU — it sends raw UDP packets directly.
|
||||
|
||||
use std::net::UdpSocket;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Build a minimal valid ESP32 CSI frame (magic 0xC511_0001).
|
||||
///
|
||||
/// Format (ADR-018):
|
||||
/// [0..3] magic: 0xC511_0001 (LE)
|
||||
/// [4] node_id
|
||||
/// [5] n_antennas (1)
|
||||
/// [6] n_subcarriers (e.g., 32)
|
||||
/// [7] reserved
|
||||
/// [8..9] freq_mhz (2437 = channel 6)
|
||||
/// [10..13] sequence (LE u32)
|
||||
/// [14] rssi (signed)
|
||||
/// [15] noise_floor
|
||||
/// [16..19] reserved
|
||||
/// [20..] I/Q pairs (n_antennas * n_subcarriers * 2 bytes)
|
||||
fn build_csi_frame(node_id: u8, seq: u32, rssi: i8, n_sub: u8) -> Vec<u8> {
|
||||
let n_pairs = n_sub as usize;
|
||||
let mut buf = vec![0u8; 20 + n_pairs * 2];
|
||||
|
||||
// Magic
|
||||
let magic: u32 = 0xC511_0001;
|
||||
buf[0..4].copy_from_slice(&magic.to_le_bytes());
|
||||
|
||||
buf[4] = node_id;
|
||||
buf[5] = 1; // n_antennas
|
||||
buf[6] = n_sub;
|
||||
buf[7] = 0;
|
||||
|
||||
// freq = 2437 MHz (channel 6)
|
||||
let freq: u16 = 2437;
|
||||
buf[8..10].copy_from_slice(&freq.to_le_bytes());
|
||||
|
||||
// sequence
|
||||
buf[10..14].copy_from_slice(&seq.to_le_bytes());
|
||||
|
||||
buf[14] = rssi as u8;
|
||||
buf[15] = (-90i8) as u8; // noise floor
|
||||
|
||||
// Generate I/Q pairs with node-specific patterns.
|
||||
// Different nodes produce different amplitude patterns so the server
|
||||
// computes different features for each.
|
||||
for i in 0..n_pairs {
|
||||
let phase = (i as f64 + node_id as f64 * 0.5) * 0.3;
|
||||
let amplitude = 20.0 + (node_id as f64) * 5.0 + (phase.sin() * 10.0);
|
||||
let i_val = (amplitude * phase.cos()) as i8;
|
||||
let q_val = (amplitude * phase.sin()) as i8;
|
||||
buf[20 + i * 2] = i_val as u8;
|
||||
buf[20 + i * 2 + 1] = q_val as u8;
|
||||
}
|
||||
|
||||
buf
|
||||
}
|
||||
|
||||
/// Build an edge vitals packet (magic 0xC511_0002).
|
||||
fn build_vitals_packet(node_id: u8, presence: bool, n_persons: u8, rssi: i8) -> Vec<u8> {
|
||||
let mut buf = vec![0u8; 32];
|
||||
|
||||
let magic: u32 = 0xC511_0002;
|
||||
buf[0..4].copy_from_slice(&magic.to_le_bytes());
|
||||
|
||||
buf[4] = node_id;
|
||||
buf[5] = if presence { 0x01 } else { 0x00 }; // flags
|
||||
// breathing_rate (u16 LE) = 15.0 * 100 = 1500
|
||||
buf[6..8].copy_from_slice(&1500u16.to_le_bytes());
|
||||
// heartrate (u32 LE) = 72.0 * 10000 = 720000
|
||||
buf[8..12].copy_from_slice(&720000u32.to_le_bytes());
|
||||
buf[12] = rssi as u8;
|
||||
buf[13] = n_persons;
|
||||
// bytes 14-15: reserved
|
||||
// motion_energy (f32 LE)
|
||||
let me: f32 = if presence { 0.5 } else { 0.0 };
|
||||
buf[16..20].copy_from_slice(&me.to_le_bytes());
|
||||
// presence_score (f32 LE)
|
||||
let ps: f32 = if presence { 0.8 } else { 0.0 };
|
||||
buf[20..24].copy_from_slice(&ps.to_le_bytes());
|
||||
// timestamp_ms (u32 LE)
|
||||
buf[24..28].copy_from_slice(&1000u32.to_le_bytes());
|
||||
|
||||
buf
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_csi_frame_builder_valid() {
|
||||
let frame = build_csi_frame(1, 0, -50, 32);
|
||||
assert_eq!(frame.len(), 20 + 32 * 2);
|
||||
assert_eq!(u32::from_le_bytes([frame[0], frame[1], frame[2], frame[3]]), 0xC511_0001);
|
||||
assert_eq!(frame[4], 1); // node_id
|
||||
assert_eq!(frame[5], 1); // n_antennas
|
||||
assert_eq!(frame[6], 32); // n_subcarriers
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vitals_packet_builder_valid() {
|
||||
let pkt = build_vitals_packet(2, true, 1, -45);
|
||||
assert_eq!(pkt.len(), 32);
|
||||
assert_eq!(u32::from_le_bytes([pkt[0], pkt[1], pkt[2], pkt[3]]), 0xC511_0002);
|
||||
assert_eq!(pkt[4], 2); // node_id
|
||||
assert_eq!(pkt[5], 0x01); // flags: presence
|
||||
assert_eq!(pkt[13], 1); // n_persons
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_different_nodes_produce_different_frames() {
|
||||
let frame1 = build_csi_frame(1, 0, -50, 32);
|
||||
let frame2 = build_csi_frame(2, 0, -50, 32);
|
||||
// I/Q data should differ due to node_id-based amplitude offset
|
||||
assert_ne!(&frame1[20..], &frame2[20..]);
|
||||
}
|
||||
|
||||
/// Send multiple frames from different nodes to a UDP port.
|
||||
/// This test verifies the packet format is accepted by a real server
|
||||
/// if one is running, but doesn't fail if no server is available.
|
||||
#[test]
|
||||
fn test_multi_node_udp_send() {
|
||||
// Try to bind to a random port and send to localhost:5005
|
||||
// This is a smoke test — it verifies frames can be sent without panic.
|
||||
let sock = UdpSocket::bind("0.0.0.0:0").expect("bind");
|
||||
sock.set_write_timeout(Some(Duration::from_millis(100))).ok();
|
||||
|
||||
let n_sub = 32u8;
|
||||
let node_ids = [1u8, 2, 3, 5, 7];
|
||||
|
||||
for &nid in &node_ids {
|
||||
for seq in 0..10u32 {
|
||||
let frame = build_csi_frame(nid, seq, -50 + nid as i8, n_sub);
|
||||
// Send to localhost:5005 (won't fail even if nothing is listening)
|
||||
let _ = sock.send_to(&frame, "127.0.0.1:5005");
|
||||
}
|
||||
}
|
||||
|
||||
// Also send vitals packets
|
||||
for &nid in &node_ids {
|
||||
let pkt = build_vitals_packet(nid, true, 1, -45);
|
||||
let _ = sock.send_to(&pkt, "127.0.0.1:5005");
|
||||
}
|
||||
|
||||
// If we get here without panic, the frame builders work correctly
|
||||
assert!(true, "Multi-node UDP send completed without errors");
|
||||
}
|
||||
|
||||
/// Verify that the frame builder produces frames of the correct minimum
|
||||
/// size for various subcarrier counts (boundary testing).
|
||||
#[test]
|
||||
fn test_frame_sizes() {
|
||||
for n_sub in [1u8, 16, 32, 52, 56, 64, 128] {
|
||||
let frame = build_csi_frame(1, 0, -50, n_sub);
|
||||
let expected = 20 + (n_sub as usize) * 2;
|
||||
assert_eq!(frame.len(), expected, "wrong size for n_sub={n_sub}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Simulate a mesh of N nodes sending frames at different rates.
|
||||
/// Nodes 1-3 send every "tick", node 4 sends every other tick,
|
||||
/// node 5 stops after 5 ticks (simulating going offline).
|
||||
#[test]
|
||||
fn test_mesh_simulation_pattern() {
|
||||
let sock = UdpSocket::bind("0.0.0.0:0").expect("bind");
|
||||
sock.set_write_timeout(Some(Duration::from_millis(50))).ok();
|
||||
|
||||
let mut total_sent = 0u32;
|
||||
|
||||
for tick in 0..20u32 {
|
||||
// Nodes 1-3: every tick
|
||||
for nid in 1..=3u8 {
|
||||
let frame = build_csi_frame(nid, tick, -50, 32);
|
||||
let _ = sock.send_to(&frame, "127.0.0.1:5005");
|
||||
total_sent += 1;
|
||||
}
|
||||
|
||||
// Node 4: every other tick
|
||||
if tick % 2 == 0 {
|
||||
let frame = build_csi_frame(4, tick / 2, -55, 32);
|
||||
let _ = sock.send_to(&frame, "127.0.0.1:5005");
|
||||
total_sent += 1;
|
||||
}
|
||||
|
||||
// Node 5: stops after tick 5
|
||||
if tick < 5 {
|
||||
let frame = build_csi_frame(5, tick, -60, 32);
|
||||
let _ = sock.send_to(&frame, "127.0.0.1:5005");
|
||||
total_sent += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Expected: 3*20 + 10 + 5 = 75 frames
|
||||
assert_eq!(total_sent, 75, "unexpected frame count");
|
||||
}
|
||||
|
||||
/// Large mesh: simulate 100 nodes each sending 10 frames.
|
||||
/// Verifies the frame builder scales without issues.
|
||||
#[test]
|
||||
fn test_large_mesh_100_nodes() {
|
||||
let sock = UdpSocket::bind("0.0.0.0:0").expect("bind");
|
||||
sock.set_write_timeout(Some(Duration::from_millis(50))).ok();
|
||||
|
||||
let mut total = 0u32;
|
||||
for nid in 1..=100u8 {
|
||||
for seq in 0..10u32 {
|
||||
let frame = build_csi_frame(nid, seq, -50 + (nid % 30) as i8, 32);
|
||||
let _ = sock.send_to(&frame, "127.0.0.1:5005");
|
||||
total += 1;
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(total, 1000);
|
||||
}
|
||||
|
||||
/// Max mesh: simulate 255 nodes (max u8 node_id) with 1 frame each.
|
||||
#[test]
|
||||
fn test_max_nodes_255() {
|
||||
let sock = UdpSocket::bind("0.0.0.0:0").expect("bind");
|
||||
sock.set_write_timeout(Some(Duration::from_millis(100))).ok();
|
||||
|
||||
for nid in 1..=255u8 {
|
||||
let frame = build_csi_frame(nid, 0, -50, 16);
|
||||
let _ = sock.send_to(&frame, "127.0.0.1:5005");
|
||||
}
|
||||
|
||||
// 255 unique node_ids — the HashMap should handle this fine
|
||||
assert!(true);
|
||||
}
|
||||
@@ -61,7 +61,10 @@ pub use coherence_gate::{GateDecision, GatePolicy};
|
||||
pub use multiband::MultiBandCsiFrame;
|
||||
pub use multistatic::FusedSensingFrame;
|
||||
pub use phase_align::{PhaseAligner, PhaseAlignError};
|
||||
pub use pose_tracker::{KeypointState, PoseTrack, TrackLifecycleState};
|
||||
pub use pose_tracker::{
|
||||
CompressedPoseHistory, KeypointState, PoseTrack, SkeletonConstraints,
|
||||
TemporalKeypointAttention, TrackLifecycleState,
|
||||
};
|
||||
|
||||
/// Number of keypoints in a full-body pose skeleton (COCO-17).
|
||||
pub const NUM_KEYPOINTS: usize = 17;
|
||||
|
||||
+580
@@ -26,6 +26,8 @@
|
||||
//!
|
||||
//! - `ruvector-mincut` -> Person separation and track assignment
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use super::{TrackId, NUM_KEYPOINTS};
|
||||
|
||||
/// Errors from the pose tracker.
|
||||
@@ -648,6 +650,365 @@ impl PoseDetection {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skeleton kinematic constraints (RuVector Phase 3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Expected bone lengths in normalized coordinates (parent_idx, child_idx, length).
|
||||
///
|
||||
/// These define the COCO-17 kinematic tree edges with approximate proportions
|
||||
/// derived from anthropometric averages. Used by [`SkeletonConstraints`] to
|
||||
/// reject impossible poses (e.g., arm longer than torso).
|
||||
const BONE_LENGTHS: &[(usize, usize, f32)] = &[
|
||||
(5, 7, 0.15), // L shoulder -> L elbow
|
||||
(7, 9, 0.14), // L elbow -> L wrist
|
||||
(6, 8, 0.15), // R shoulder -> R elbow
|
||||
(8, 10, 0.14), // R elbow -> R wrist
|
||||
(5, 11, 0.25), // L shoulder -> L hip
|
||||
(6, 12, 0.25), // R shoulder -> R hip
|
||||
(11, 13, 0.22), // L hip -> L knee
|
||||
(13, 15, 0.22), // L knee -> L ankle
|
||||
(12, 14, 0.22), // R hip -> R knee
|
||||
(14, 16, 0.22), // R knee -> R ankle
|
||||
(5, 6, 0.18), // L shoulder -> R shoulder
|
||||
(11, 12, 0.15), // L hip -> R hip
|
||||
];
|
||||
|
||||
/// Skeleton kinematic constraint enforcer using Jakobsen relaxation.
|
||||
///
|
||||
/// Iteratively projects bone lengths toward their expected values so that
|
||||
/// the resulting skeleton obeys basic anthropometric limits. Bones that
|
||||
/// deviate more than [`Self::TOLERANCE`] (30 %) from their rest length are
|
||||
/// corrected over [`Self::ITERATIONS`] passes.
|
||||
pub struct SkeletonConstraints;
|
||||
|
||||
impl SkeletonConstraints {
|
||||
/// Maximum allowed fractional deviation before correction kicks in.
|
||||
const TOLERANCE: f32 = 0.30;
|
||||
|
||||
/// Number of Jakobsen relaxation iterations.
|
||||
const ITERATIONS: usize = 3;
|
||||
|
||||
/// Enforce kinematic constraints in-place on `keypoints`.
|
||||
///
|
||||
/// Each element is `[x, y, z]`. The method runs several iterations of
|
||||
/// distance-constraint projection (Jakobsen method) over the edges
|
||||
/// defined in [`BONE_LENGTHS`].
|
||||
pub fn enforce_constraints(keypoints: &mut [[f32; 3]; 17]) {
|
||||
for _ in 0..Self::ITERATIONS {
|
||||
for &(a, b, rest_len) in BONE_LENGTHS {
|
||||
let dx = keypoints[b][0] - keypoints[a][0];
|
||||
let dy = keypoints[b][1] - keypoints[a][1];
|
||||
let dz = keypoints[b][2] - keypoints[a][2];
|
||||
let current_len = (dx * dx + dy * dy + dz * dz).sqrt();
|
||||
|
||||
// Skip degenerate / zero-length bones (e.g. all-zero pose).
|
||||
if current_len < 1e-9 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let ratio = current_len / rest_len;
|
||||
// Only correct if deviation exceeds tolerance.
|
||||
if ratio < (1.0 - Self::TOLERANCE) || ratio > (1.0 + Self::TOLERANCE) {
|
||||
let correction = (rest_len - current_len) / current_len * 0.5;
|
||||
let cx = dx * correction;
|
||||
let cy = dy * correction;
|
||||
let cz = dz * correction;
|
||||
|
||||
keypoints[a][0] -= cx;
|
||||
keypoints[a][1] -= cy;
|
||||
keypoints[a][2] -= cz;
|
||||
keypoints[b][0] += cx;
|
||||
keypoints[b][1] += cy;
|
||||
keypoints[b][2] += cz;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compressed pose history (RuVector Phase 3 -- temporal tensor)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Two-tier compressed pose history.
|
||||
///
|
||||
/// Recent poses are stored at full `f32` precision in the *hot* ring buffer.
|
||||
/// Once the hot buffer is full the oldest pose is quantised to `i16` and
|
||||
/// pushed into the *warm* tier, keeping memory usage bounded while still
|
||||
/// allowing similarity queries against a longer temporal window.
|
||||
pub struct CompressedPoseHistory {
|
||||
/// Recent poses at full precision.
|
||||
hot: VecDeque<[[f32; 3]; 17]>,
|
||||
/// Older poses quantised to i16.
|
||||
warm: VecDeque<[[i16; 3]; 17]>,
|
||||
/// Scale factor used for warm quantisation (divide f32, multiply to
|
||||
/// reconstruct).
|
||||
scale: f32,
|
||||
max_hot: usize,
|
||||
max_warm: usize,
|
||||
}
|
||||
|
||||
impl CompressedPoseHistory {
|
||||
/// Create a new history with the given tier sizes.
|
||||
///
|
||||
/// `scale` controls the fixed-point quantisation: warm values are stored
|
||||
/// as `(value / scale).round() as i16`.
|
||||
pub fn new(max_hot: usize, max_warm: usize, scale: f32) -> Self {
|
||||
Self {
|
||||
hot: VecDeque::with_capacity(max_hot),
|
||||
warm: VecDeque::with_capacity(max_warm),
|
||||
scale: if scale.abs() < 1e-12 { 1.0 } else { scale },
|
||||
max_hot,
|
||||
max_warm,
|
||||
}
|
||||
}
|
||||
|
||||
/// Push a new pose into the history.
|
||||
///
|
||||
/// When the hot tier is full the oldest entry is quantised and moved to
|
||||
/// the warm tier. When the warm tier overflows the oldest warm entry is
|
||||
/// discarded.
|
||||
pub fn push(&mut self, pose: &[[f32; 3]; 17]) {
|
||||
if self.hot.len() >= self.max_hot {
|
||||
if let Some(evicted) = self.hot.pop_front() {
|
||||
let quantised = self.quantise(&evicted);
|
||||
if self.warm.len() >= self.max_warm {
|
||||
self.warm.pop_front();
|
||||
}
|
||||
self.warm.push_back(quantised);
|
||||
}
|
||||
}
|
||||
self.hot.push_back(*pose);
|
||||
}
|
||||
|
||||
/// Cosine similarity between `pose` and the most recent stored pose.
|
||||
///
|
||||
/// Both poses are flattened to 51-element vectors before the dot-product
|
||||
/// is computed. Returns 0.0 when the history is empty or either vector
|
||||
/// has zero norm.
|
||||
pub fn similarity(&self, pose: &[[f32; 3]; 17]) -> f32 {
|
||||
let recent = match self.hot.back() {
|
||||
Some(r) => r,
|
||||
None => return 0.0,
|
||||
};
|
||||
|
||||
let mut dot = 0.0_f32;
|
||||
let mut norm_a = 0.0_f32;
|
||||
let mut norm_b = 0.0_f32;
|
||||
|
||||
for kp in 0..17 {
|
||||
for d in 0..3 {
|
||||
let a = recent[kp][d];
|
||||
let b = pose[kp][d];
|
||||
dot += a * b;
|
||||
norm_a += a * a;
|
||||
norm_b += b * b;
|
||||
}
|
||||
}
|
||||
|
||||
let denom = (norm_a * norm_b).sqrt();
|
||||
if denom < 1e-12 {
|
||||
return 0.0;
|
||||
}
|
||||
(dot / denom).clamp(-1.0, 1.0)
|
||||
}
|
||||
|
||||
/// Total number of stored poses (hot + warm).
|
||||
pub fn len(&self) -> usize {
|
||||
self.hot.len() + self.warm.len()
|
||||
}
|
||||
|
||||
/// Returns `true` when the history contains no poses.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.hot.is_empty() && self.warm.is_empty()
|
||||
}
|
||||
|
||||
// -- internal helpers ---------------------------------------------------
|
||||
|
||||
fn quantise(&self, pose: &[[f32; 3]; 17]) -> [[i16; 3]; 17] {
|
||||
let inv = 1.0 / self.scale;
|
||||
let mut out = [[0_i16; 3]; 17];
|
||||
for kp in 0..17 {
|
||||
for d in 0..3 {
|
||||
out[kp][d] = (pose[kp][d] * inv)
|
||||
.round()
|
||||
.clamp(i16::MIN as f32, i16::MAX as f32)
|
||||
as i16;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CompressedPoseHistory {
|
||||
fn default() -> Self {
|
||||
Self::new(10, 50, 0.001)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Temporal Keypoint Attention (RuVector Phase 2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Sliding-window temporal smoother for 17-keypoint pose estimates.
|
||||
///
|
||||
/// Maintains a ring buffer of the last `WINDOW_SIZE` pose frames and applies
|
||||
/// exponential-decay weighted averaging to produce temporally coherent output.
|
||||
/// Additionally enforces kinematic constraints: bone lengths cannot change by
|
||||
/// more than 20% between consecutive frames.
|
||||
///
|
||||
/// This is a lightweight inline implementation that mirrors the algorithm in
|
||||
/// `ruvector-attention` without pulling the crate into the sensing server.
|
||||
pub struct TemporalKeypointAttention {
|
||||
/// Ring buffer of recent pose frames (newest at back).
|
||||
window: std::collections::VecDeque<[[f32; 3]; NUM_KEYPOINTS]>,
|
||||
/// Maximum number of frames to retain.
|
||||
window_size: usize,
|
||||
/// Exponential decay factor per frame (e.g., 0.7 means frame t-1 has
|
||||
/// weight 0.7, frame t-2 has weight 0.49, etc.).
|
||||
decay: f32,
|
||||
}
|
||||
|
||||
impl TemporalKeypointAttention {
|
||||
/// Default window size (10 frames at 10-20 Hz = 0.5-1.0 s look-back).
|
||||
pub const DEFAULT_WINDOW: usize = 10;
|
||||
/// Default decay factor.
|
||||
pub const DEFAULT_DECAY: f32 = 0.7;
|
||||
/// Maximum allowed bone-length change ratio between consecutive frames.
|
||||
pub const MAX_BONE_CHANGE: f32 = 0.20;
|
||||
|
||||
/// Create a new temporal attention smoother with default parameters.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
window: std::collections::VecDeque::with_capacity(Self::DEFAULT_WINDOW),
|
||||
window_size: Self::DEFAULT_WINDOW,
|
||||
decay: Self::DEFAULT_DECAY,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with custom window size and decay.
|
||||
pub fn with_params(window_size: usize, decay: f32) -> Self {
|
||||
Self {
|
||||
window: std::collections::VecDeque::with_capacity(window_size),
|
||||
window_size,
|
||||
decay: decay.clamp(0.0, 1.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Smooth the current keypoint estimate using the temporal window.
|
||||
///
|
||||
/// 1. Pushes `current` into the window (evicting oldest if full).
|
||||
/// 2. Computes exponential-decay weighted average across all frames.
|
||||
/// 3. Enforces bone-length constraints against the previous frame.
|
||||
pub fn smooth_keypoints(
|
||||
&mut self,
|
||||
current: &[[f32; 3]; NUM_KEYPOINTS],
|
||||
) -> [[f32; 3]; NUM_KEYPOINTS] {
|
||||
// Grab the previous frame (before pushing current) for bone clamping.
|
||||
let prev_frame = self.window.back().copied();
|
||||
|
||||
// Push current frame into the window.
|
||||
if self.window.len() >= self.window_size {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(*current);
|
||||
|
||||
// Compute weighted average with exponential decay (newest = highest weight).
|
||||
let n = self.window.len();
|
||||
let mut result = [[0.0_f32; 3]; NUM_KEYPOINTS];
|
||||
let mut total_weight = 0.0_f32;
|
||||
|
||||
for (age, frame) in self.window.iter().rev().enumerate() {
|
||||
let w = self.decay.powi(age as i32);
|
||||
total_weight += w;
|
||||
for kp in 0..NUM_KEYPOINTS {
|
||||
for dim in 0..3 {
|
||||
result[kp][dim] += w * frame[kp][dim];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if total_weight > 0.0 {
|
||||
for kp in 0..NUM_KEYPOINTS {
|
||||
for dim in 0..3 {
|
||||
result[kp][dim] /= total_weight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce bone-length constraints: no bone can change >20% from prev frame.
|
||||
if let Some(prev) = prev_frame {
|
||||
if n >= 2 {
|
||||
Self::clamp_bone_lengths(&mut result, &prev);
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Clamp bone lengths so they don't change by more than MAX_BONE_CHANGE
|
||||
/// compared to the previous frame.
|
||||
fn clamp_bone_lengths(
|
||||
pose: &mut [[f32; 3]; NUM_KEYPOINTS],
|
||||
prev: &[[f32; 3]; NUM_KEYPOINTS],
|
||||
) {
|
||||
for &(parent, child, _) in BONE_LENGTHS {
|
||||
let prev_len = Self::bone_len(prev, parent, child);
|
||||
if prev_len < 1e-6 {
|
||||
continue; // skip degenerate bones
|
||||
}
|
||||
let cur_len = Self::bone_len(pose, parent, child);
|
||||
if cur_len < 1e-6 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let ratio = cur_len / prev_len;
|
||||
let lo = 1.0 - Self::MAX_BONE_CHANGE;
|
||||
let hi = 1.0 + Self::MAX_BONE_CHANGE;
|
||||
|
||||
if ratio < lo || ratio > hi {
|
||||
// Scale the child position toward/away from parent to clamp.
|
||||
let target_len = prev_len * ratio.clamp(lo, hi);
|
||||
let scale = target_len / cur_len;
|
||||
for dim in 0..3 {
|
||||
let diff = pose[child][dim] - pose[parent][dim];
|
||||
pose[child][dim] = pose[parent][dim] + diff * scale;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Euclidean distance between two keypoints in a pose.
|
||||
fn bone_len(pose: &[[f32; 3]; NUM_KEYPOINTS], a: usize, b: usize) -> f32 {
|
||||
let dx = pose[b][0] - pose[a][0];
|
||||
let dy = pose[b][1] - pose[a][1];
|
||||
let dz = pose[b][2] - pose[a][2];
|
||||
(dx * dx + dy * dy + dz * dz).sqrt()
|
||||
}
|
||||
|
||||
/// Number of frames currently in the window.
|
||||
pub fn len(&self) -> usize {
|
||||
self.window.len()
|
||||
}
|
||||
|
||||
/// Whether the window is empty.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.window.is_empty()
|
||||
}
|
||||
|
||||
/// Clear the window (e.g., on track reset).
|
||||
pub fn clear(&mut self) {
|
||||
self.window.clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TemporalKeypointAttention {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -940,4 +1301,223 @@ mod tests {
|
||||
track.mark_lost(); // Should not override Terminated
|
||||
assert_eq!(track.lifecycle, TrackLifecycleState::Terminated);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// SkeletonConstraints tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Build a plausible standing skeleton in normalised coordinates.
|
||||
fn valid_skeleton() -> [[f32; 3]; 17] {
|
||||
let mut kps = [[0.0_f32; 3]; 17];
|
||||
// Head / face (indices 0-4) clustered near top.
|
||||
kps[0] = [0.0, 1.0, 0.0]; // nose
|
||||
kps[1] = [-0.02, 1.02, 0.0]; // left eye
|
||||
kps[2] = [0.02, 1.02, 0.0]; // right eye
|
||||
kps[3] = [-0.04, 1.0, 0.0]; // left ear
|
||||
kps[4] = [0.04, 1.0, 0.0]; // right ear
|
||||
// Torso
|
||||
kps[5] = [-0.09, 0.85, 0.0]; // L shoulder
|
||||
kps[6] = [0.09, 0.85, 0.0]; // R shoulder
|
||||
kps[7] = [-0.09, 0.70, 0.0]; // L elbow (dist ~0.15 from shoulder)
|
||||
kps[8] = [0.09, 0.70, 0.0]; // R elbow
|
||||
kps[9] = [-0.09, 0.56, 0.0]; // L wrist (dist ~0.14 from elbow)
|
||||
kps[10] = [0.09, 0.56, 0.0]; // R wrist
|
||||
kps[11] = [-0.075, 0.60, 0.0]; // L hip (dist ~0.25 from shoulder)
|
||||
kps[12] = [0.075, 0.60, 0.0]; // R hip
|
||||
kps[13] = [-0.075, 0.38, 0.0]; // L knee (dist ~0.22 from hip)
|
||||
kps[14] = [0.075, 0.38, 0.0]; // R knee
|
||||
kps[15] = [-0.075, 0.16, 0.0]; // L ankle (dist ~0.22 from knee)
|
||||
kps[16] = [0.075, 0.16, 0.0]; // R ankle
|
||||
kps
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_skeleton_unchanged() {
|
||||
let mut kps = valid_skeleton();
|
||||
let before = kps;
|
||||
SkeletonConstraints::enforce_constraints(&mut kps);
|
||||
|
||||
// Each keypoint should move by less than 0.02 (small perturbation
|
||||
// from iterative relaxation on an already-valid skeleton).
|
||||
for i in 0..17 {
|
||||
let d = ((kps[i][0] - before[i][0]).powi(2)
|
||||
+ (kps[i][1] - before[i][1]).powi(2)
|
||||
+ (kps[i][2] - before[i][2]).powi(2))
|
||||
.sqrt();
|
||||
assert!(
|
||||
d < 0.05,
|
||||
"keypoint {} moved {:.4}, expected < 0.05",
|
||||
i,
|
||||
d
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stretched_bone_corrected() {
|
||||
let mut kps = valid_skeleton();
|
||||
|
||||
// Stretch L shoulder -> L elbow to 2x expected (0.30 instead of 0.15).
|
||||
kps[7] = [-0.09, 0.55, 0.0]; // push elbow far down
|
||||
|
||||
let dist_before = {
|
||||
let dx = kps[7][0] - kps[5][0];
|
||||
let dy = kps[7][1] - kps[5][1];
|
||||
let dz = kps[7][2] - kps[5][2];
|
||||
(dx * dx + dy * dy + dz * dz).sqrt()
|
||||
};
|
||||
assert!(
|
||||
dist_before > 0.25,
|
||||
"pre-condition: bone should be stretched, got {}",
|
||||
dist_before
|
||||
);
|
||||
|
||||
SkeletonConstraints::enforce_constraints(&mut kps);
|
||||
|
||||
let dist_after = {
|
||||
let dx = kps[7][0] - kps[5][0];
|
||||
let dy = kps[7][1] - kps[5][1];
|
||||
let dz = kps[7][2] - kps[5][2];
|
||||
(dx * dx + dy * dy + dz * dz).sqrt()
|
||||
};
|
||||
|
||||
// After enforcement the bone should be much closer to the rest
|
||||
// length of 0.15 (within tolerance band 0.105 .. 0.195).
|
||||
assert!(
|
||||
dist_after < dist_before,
|
||||
"bone should be shorter after correction: before={:.4}, after={:.4}",
|
||||
dist_before,
|
||||
dist_after
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zero_skeleton_handled() {
|
||||
// All-zero keypoints must not panic.
|
||||
let mut kps = [[0.0_f32; 3]; 17];
|
||||
SkeletonConstraints::enforce_constraints(&mut kps);
|
||||
// Just assert it didn't panic; the result should still be all-zero
|
||||
// since zero-length bones are skipped.
|
||||
for kp in &kps {
|
||||
assert!(kp[0].is_finite());
|
||||
assert!(kp[1].is_finite());
|
||||
assert!(kp[2].is_finite());
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// CompressedPoseHistory tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn compressed_history_push_and_len() {
|
||||
let mut hist = CompressedPoseHistory::new(3, 5, 0.001);
|
||||
assert!(hist.is_empty());
|
||||
assert_eq!(hist.len(), 0);
|
||||
|
||||
let pose = valid_skeleton();
|
||||
hist.push(&pose);
|
||||
assert_eq!(hist.len(), 1);
|
||||
assert!(!hist.is_empty());
|
||||
|
||||
// Fill hot
|
||||
hist.push(&pose);
|
||||
hist.push(&pose);
|
||||
assert_eq!(hist.len(), 3); // 3 hot, 0 warm
|
||||
|
||||
// Overflow hot -> warm promotion
|
||||
hist.push(&pose);
|
||||
assert_eq!(hist.len(), 4); // 3 hot, 1 warm
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compressed_history_warm_overflow() {
|
||||
let mut hist = CompressedPoseHistory::new(2, 2, 0.001);
|
||||
let pose = valid_skeleton();
|
||||
|
||||
// Push 6 poses: hot=2, warm should cap at 2
|
||||
for _ in 0..6 {
|
||||
hist.push(&pose);
|
||||
}
|
||||
// hot=2, warm capped at 2
|
||||
assert_eq!(hist.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compressed_history_similarity_identical() {
|
||||
let mut hist = CompressedPoseHistory::default();
|
||||
let pose = valid_skeleton();
|
||||
hist.push(&pose);
|
||||
|
||||
let sim = hist.similarity(&pose);
|
||||
assert!(
|
||||
(sim - 1.0).abs() < 1e-5,
|
||||
"identical pose should have similarity ~1.0, got {}",
|
||||
sim
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compressed_history_similarity_empty() {
|
||||
let hist = CompressedPoseHistory::default();
|
||||
let pose = valid_skeleton();
|
||||
assert_eq!(hist.similarity(&pose), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compressed_history_default() {
|
||||
let hist = CompressedPoseHistory::default();
|
||||
assert_eq!(hist.max_hot, 10);
|
||||
assert_eq!(hist.max_warm, 50);
|
||||
assert!((hist.scale - 0.001).abs() < 1e-9);
|
||||
}
|
||||
|
||||
// ── TemporalKeypointAttention tests (RuVector Phase 2) ─────────────
|
||||
|
||||
#[test]
|
||||
fn temporal_attention_empty_returns_input() {
|
||||
let mut attn = TemporalKeypointAttention::new();
|
||||
let input: [[f32; 3]; NUM_KEYPOINTS] = std::array::from_fn(|i| [i as f32, 0.0, 0.0]);
|
||||
let out = attn.smooth_keypoints(&input);
|
||||
// First frame: no history, so output should equal input.
|
||||
for i in 0..NUM_KEYPOINTS {
|
||||
assert!((out[i][0] - input[i][0]).abs() < 1e-5);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporal_attention_smooths_jitter() {
|
||||
let mut attn = TemporalKeypointAttention::new();
|
||||
let base: [[f32; 3]; NUM_KEYPOINTS] = std::array::from_fn(|_| [100.0, 200.0, 0.0]);
|
||||
// Feed stable frames first.
|
||||
for _ in 0..5 {
|
||||
attn.smooth_keypoints(&base);
|
||||
}
|
||||
// Now feed a jittery frame.
|
||||
let jittery: [[f32; 3]; NUM_KEYPOINTS] = std::array::from_fn(|_| [110.0, 210.0, 0.0]);
|
||||
let out = attn.smooth_keypoints(&jittery);
|
||||
// Output should be closer to base than to jittery (smoothed).
|
||||
assert!(out[0][0] < 110.0, "Expected smoothing, got {}", out[0][0]);
|
||||
assert!(out[0][0] > 100.0, "Expected some movement, got {}", out[0][0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporal_attention_window_size_capped() {
|
||||
let mut attn = TemporalKeypointAttention::with_params(3, 0.7);
|
||||
let frame: [[f32; 3]; NUM_KEYPOINTS] = std::array::from_fn(|_| [1.0, 1.0, 1.0]);
|
||||
for _ in 0..10 {
|
||||
attn.smooth_keypoints(&frame);
|
||||
}
|
||||
assert_eq!(attn.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporal_attention_clear() {
|
||||
let mut attn = TemporalKeypointAttention::new();
|
||||
let frame = zero_positions();
|
||||
attn.smooth_keypoints(&frame);
|
||||
assert!(!attn.is_empty());
|
||||
attn.clear();
|
||||
assert!(attn.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"intelligence":60,"timestamp":1774039923051}
|
||||
@@ -19,9 +19,12 @@ libm = "0.2"
|
||||
sha2 = { version = "0.10", optional = true, default-features = false }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
default = ["default-pipeline"]
|
||||
# Enable std for testing on host + RVF builder
|
||||
std = ["sha2/std"]
|
||||
# Include the default combined pipeline (gesture+coherence+adversarial) entry points.
|
||||
# Disable this when building standalone module binaries (ghost_hunter, etc.)
|
||||
default-pipeline = []
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s" # Optimize for size
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Standalone Ghost Hunter WASM module for ESP32-S3.
|
||||
//!
|
||||
//! Compiles to a self-contained .wasm binary that runs the
|
||||
//! GhostHunterDetector as a hot-loadable Tier 3 edge module.
|
||||
//!
|
||||
//! Build:
|
||||
//! cargo build --bin ghost_hunter --target wasm32-unknown-unknown --release
|
||||
//!
|
||||
//! The resulting .wasm file can be uploaded to an ESP32 running the
|
||||
//! CSI firmware via the HTTP /api/wasm/upload endpoint.
|
||||
|
||||
#![cfg_attr(target_arch = "wasm32", no_std)]
|
||||
#![cfg_attr(target_arch = "wasm32", no_main)]
|
||||
|
||||
// The lib crate already provides the panic handler for wasm32.
|
||||
// We use its host API bindings and the GhostHunterDetector.
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use wifi_densepose_wasm_edge::{
|
||||
host_get_phase, host_get_amplitude, host_get_variance,
|
||||
host_get_presence, host_get_motion_energy,
|
||||
host_emit_event, host_log,
|
||||
exo_ghost_hunter::GhostHunterDetector,
|
||||
};
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
static mut DETECTOR: GhostHunterDetector = GhostHunterDetector::new();
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn log_str(s: &str) {
|
||||
unsafe { host_log(s.as_ptr() as i32, s.len() as i32) }
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn emit(event_type: i32, value: f32) {
|
||||
unsafe { host_emit_event(event_type, value) }
|
||||
}
|
||||
|
||||
// ── WASM entry points (exported to host) ───────────────────────────────────
|
||||
|
||||
/// Called once when the module is loaded onto the ESP32.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[no_mangle]
|
||||
pub extern "C" fn on_init() {
|
||||
log_str("ghost-hunter v1.0: anomaly detector active");
|
||||
}
|
||||
|
||||
/// Called per CSI frame (~20 Hz) by the WASM3 runtime.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[no_mangle]
|
||||
pub extern "C" fn on_frame(n_subcarriers: i32) {
|
||||
let n_sc = if n_subcarriers < 0 { 0 } else { n_subcarriers as usize };
|
||||
let max_sc = if n_sc > 32 { 32 } else { n_sc };
|
||||
if max_sc < 8 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Read CSI data from host
|
||||
let mut phases = [0.0f32; 32];
|
||||
let mut amplitudes = [0.0f32; 32];
|
||||
let mut variances = [0.0f32; 32];
|
||||
|
||||
for i in 0..max_sc {
|
||||
unsafe {
|
||||
phases[i] = host_get_phase(i as i32);
|
||||
amplitudes[i] = host_get_amplitude(i as i32);
|
||||
variances[i] = host_get_variance(i as i32);
|
||||
}
|
||||
}
|
||||
|
||||
let presence = unsafe { host_get_presence() };
|
||||
let motion_energy = unsafe { host_get_motion_energy() };
|
||||
|
||||
let detector = unsafe { &mut *core::ptr::addr_of_mut!(DETECTOR) };
|
||||
let events = detector.process_frame(
|
||||
&phases[..max_sc],
|
||||
&litudes[..max_sc],
|
||||
&variances[..max_sc],
|
||||
presence,
|
||||
motion_energy,
|
||||
);
|
||||
|
||||
for &(event_id, value) in events {
|
||||
emit(event_id, value);
|
||||
}
|
||||
}
|
||||
|
||||
/// Called at configurable interval (default 1 second).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[no_mangle]
|
||||
pub extern "C" fn on_timer() {
|
||||
let detector = unsafe { &*core::ptr::addr_of!(DETECTOR) };
|
||||
let energy = detector.anomaly_energy();
|
||||
if energy > 0.001 {
|
||||
emit(650, energy);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Non-WASM main (for native host builds) ─────────────────────────────────
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn main() {
|
||||
println!("Ghost Hunter WASM module");
|
||||
println!("Build: cargo build --bin ghost_hunter --target wasm32-unknown-unknown --release");
|
||||
println!("Upload: POST the .wasm to http://<esp32-ip>/api/wasm/upload");
|
||||
}
|
||||
+812
@@ -0,0 +1,812 @@
|
||||
//! Happiness score from WiFi CSI physiological proxies -- ADR-041 exotic module.
|
||||
//!
|
||||
//! # Algorithm
|
||||
//!
|
||||
//! Combines six physiological proxies extracted from CSI into a composite
|
||||
//! happiness score [0, 1]:
|
||||
//!
|
||||
//! 1. **Gait speed** -- Doppler proxy from phase rate-of-change. Happy people
|
||||
//! walk approximately 12% faster than neutral baseline.
|
||||
//!
|
||||
//! 2. **Stride regularity** -- Variance of step intervals from successive phase
|
||||
//! differences. Regular strides correlate with confidence and positive affect.
|
||||
//!
|
||||
//! 3. **Movement fluidity** -- Smoothness of phase trajectory (second derivative).
|
||||
//! Jerky motion indicates anxiety; smooth motion indicates relaxation.
|
||||
//!
|
||||
//! 4. **Breathing calm** -- Inverse of breathing rate, extracted from 0.15-0.5 Hz
|
||||
//! phase oscillation. Slow, deep breathing correlates with positive mood.
|
||||
//!
|
||||
//! 5. **Posture score** -- Amplitude spread across subcarrier groups. Upright
|
||||
//! posture scatters signal across more subcarriers than slouched.
|
||||
//!
|
||||
//! 6. **Dwell time** -- Fraction of recent frames with presence in the sensing
|
||||
//! zone. Longer dwell in social spaces correlates with engagement.
|
||||
//!
|
||||
//! The composite happiness score is a weighted sum of these six features,
|
||||
//! EMA-smoothed for temporal stability.
|
||||
//!
|
||||
//! An 8-dimensional "happiness vector" is also produced for ingestion into a
|
||||
//! Cognitum Seed vector store (dim=8).
|
||||
//!
|
||||
//! # Events (690-694: Exotic / Research)
|
||||
//!
|
||||
//! - `HAPPINESS_SCORE` (690): Composite happiness [0.0 = sad, 0.5 = neutral, 1.0 = happy].
|
||||
//! - `GAIT_ENERGY` (691): Normalized gait speed/stride score [0, 1].
|
||||
//! - `AFFECT_VALENCE` (692): Emotional valence from breathing + motion [0, 1].
|
||||
//! - `SOCIAL_ENERGY` (693): Group animation/interaction level [0, 1].
|
||||
//! - `TRANSIT_DIRECTION` (694): 1.0 = entering, 0.0 = exiting (from motion trend).
|
||||
//!
|
||||
//! # Budget
|
||||
//!
|
||||
//! H (heavy, < 10 ms) -- rolling statistics + weighted scoring.
|
||||
|
||||
use crate::vendor_common::{CircularBuffer, Ema, WelfordStats};
|
||||
use libm::fabsf;
|
||||
|
||||
// ── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Rolling window for phase rate-of-change (gait speed proxy).
|
||||
/// ESP32: 16 frames at 20 Hz = 0.8s — sufficient for step detection.
|
||||
const PHASE_ROC_LEN: usize = 16;
|
||||
|
||||
/// Rolling window for step interval detection.
|
||||
const STEP_INTERVAL_LEN: usize = 16;
|
||||
|
||||
/// Rolling window for movement fluidity (second derivative of phase).
|
||||
/// ESP32: 16 frames captures 2-3 stride cycles at walking cadence.
|
||||
const FLUIDITY_BUF_LEN: usize = 16;
|
||||
|
||||
/// Rolling window for breathing rate history.
|
||||
/// ESP32: 16 samples at 1 Hz timer rate = 16 seconds of breathing data.
|
||||
const BREATH_HIST_LEN: usize = 16;
|
||||
|
||||
/// Rolling window for amplitude spread (posture).
|
||||
/// ESP32: 8 samples is enough for posture averaging.
|
||||
const AMP_SPREAD_LEN: usize = 8;
|
||||
|
||||
/// Rolling window for presence/dwell tracking.
|
||||
/// ESP32: 32 frames at 20 Hz = 1.6s dwell window (was 3.2s).
|
||||
const DWELL_BUF_LEN: usize = 32;
|
||||
|
||||
/// Rolling window for motion energy trend (transit direction).
|
||||
/// ESP32: 16 frames gives clear entering/exiting gradient.
|
||||
const MOTION_TREND_LEN: usize = 16;
|
||||
|
||||
/// EMA smoothing for happiness output.
|
||||
const HAPPINESS_ALPHA: f32 = 0.10;
|
||||
|
||||
/// EMA smoothing for gait speed.
|
||||
const GAIT_ALPHA: f32 = 0.12;
|
||||
|
||||
/// EMA smoothing for fluidity.
|
||||
const FLUIDITY_ALPHA: f32 = 0.12;
|
||||
|
||||
/// EMA smoothing for social energy.
|
||||
const SOCIAL_ALPHA: f32 = 0.10;
|
||||
|
||||
/// Minimum frames before emitting events.
|
||||
const MIN_WARMUP: u32 = 20;
|
||||
|
||||
/// Maximum subcarriers from host API.
|
||||
/// ESP32 CSI provides up to 52 subcarriers; host caps at 32.
|
||||
const MAX_SC: usize = 32;
|
||||
|
||||
/// Event emission decimation: emit full event set every Nth frame.
|
||||
/// At 20 Hz, N=4 means events at 5 Hz — reduces UDP packet rate by 75%.
|
||||
const EVENT_DECIMATION: u32 = 4;
|
||||
|
||||
/// Baseline gait speed (phase rate-of-change, arbitrary units).
|
||||
/// Happy gait is ~12% above this.
|
||||
const BASELINE_GAIT_SPEED: f32 = 0.5;
|
||||
|
||||
/// Maximum expected gait speed for normalization.
|
||||
const MAX_GAIT_SPEED: f32 = 2.0;
|
||||
|
||||
/// Calm breathing range: 6-14 BPM (slow = calm = happier).
|
||||
const CALM_BREATH_LOW: f32 = 6.0;
|
||||
const CALM_BREATH_HIGH: f32 = 14.0;
|
||||
|
||||
/// Stressed breathing threshold.
|
||||
const STRESS_BREATH_THRESH: f32 = 22.0;
|
||||
|
||||
// ── Weights for composite happiness score ────────────────────────────────────
|
||||
|
||||
const W_GAIT_SPEED: f32 = 0.25;
|
||||
const W_STRIDE_REG: f32 = 0.15;
|
||||
const W_FLUIDITY: f32 = 0.20;
|
||||
const W_BREATH_CALM: f32 = 0.20;
|
||||
const W_POSTURE: f32 = 0.10;
|
||||
const W_DWELL: f32 = 0.10;
|
||||
|
||||
// ── Event IDs (690-694: Exotic) ──────────────────────────────────────────────
|
||||
|
||||
pub const EVENT_HAPPINESS_SCORE: i32 = 690;
|
||||
pub const EVENT_GAIT_ENERGY: i32 = 691;
|
||||
pub const EVENT_AFFECT_VALENCE: i32 = 692;
|
||||
pub const EVENT_SOCIAL_ENERGY: i32 = 693;
|
||||
pub const EVENT_TRANSIT_DIRECTION: i32 = 694;
|
||||
|
||||
/// Dimension of the happiness vector for Cognitum Seed ingestion.
|
||||
pub const HAPPINESS_VECTOR_DIM: usize = 8;
|
||||
|
||||
// ── Happiness Score Detector ─────────────────────────────────────────────────
|
||||
|
||||
/// Computes a composite happiness score from WiFi CSI physiological proxies.
|
||||
///
|
||||
/// Outputs a scalar happiness score [0, 1] and an 8-dim happiness vector
|
||||
/// suitable for ingestion into a Cognitum Seed vector store.
|
||||
pub struct HappinessScoreDetector {
|
||||
/// Phase rate-of-change history (gait speed proxy).
|
||||
phase_roc: CircularBuffer<PHASE_ROC_LEN>,
|
||||
/// Step interval variance tracking.
|
||||
step_stats: WelfordStats,
|
||||
/// Movement fluidity buffer (phase second derivative).
|
||||
fluidity_buf: CircularBuffer<FLUIDITY_BUF_LEN>,
|
||||
/// Breathing rate history.
|
||||
breath_hist: CircularBuffer<BREATH_HIST_LEN>,
|
||||
/// Amplitude spread history (posture proxy).
|
||||
amp_spread_hist: CircularBuffer<AMP_SPREAD_LEN>,
|
||||
/// Dwell buffer: 1.0 if presence, 0.0 if not.
|
||||
dwell_buf: CircularBuffer<DWELL_BUF_LEN>,
|
||||
/// Motion energy trend buffer (for transit direction).
|
||||
motion_trend: CircularBuffer<MOTION_TREND_LEN>,
|
||||
|
||||
/// EMA-smoothed happiness score.
|
||||
happiness_ema: Ema,
|
||||
/// EMA-smoothed gait energy.
|
||||
gait_ema: Ema,
|
||||
/// EMA-smoothed fluidity.
|
||||
fluidity_ema: Ema,
|
||||
/// EMA-smoothed social energy.
|
||||
social_ema: Ema,
|
||||
|
||||
/// Previous frame mean phase (for rate-of-change).
|
||||
prev_mean_phase: f32,
|
||||
/// Previous phase rate-of-change (for second derivative).
|
||||
prev_phase_roc: f32,
|
||||
|
||||
/// Current happiness score [0, 1].
|
||||
happiness: f32,
|
||||
|
||||
/// 8-dim happiness vector for Cognitum Seed ingestion.
|
||||
///
|
||||
/// Layout:
|
||||
/// [0] = happiness_score
|
||||
/// [1] = gait_speed_norm
|
||||
/// [2] = stride_regularity
|
||||
/// [3] = movement_fluidity
|
||||
/// [4] = breathing_calm
|
||||
/// [5] = posture_score
|
||||
/// [6] = dwell_factor
|
||||
/// [7] = social_energy
|
||||
pub happiness_vector: [f32; HAPPINESS_VECTOR_DIM],
|
||||
|
||||
/// Total frames processed.
|
||||
frame_count: u32,
|
||||
}
|
||||
|
||||
impl HappinessScoreDetector {
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
phase_roc: CircularBuffer::new(),
|
||||
step_stats: WelfordStats::new(),
|
||||
fluidity_buf: CircularBuffer::new(),
|
||||
breath_hist: CircularBuffer::new(),
|
||||
amp_spread_hist: CircularBuffer::new(),
|
||||
dwell_buf: CircularBuffer::new(),
|
||||
motion_trend: CircularBuffer::new(),
|
||||
|
||||
happiness_ema: Ema::new(HAPPINESS_ALPHA),
|
||||
gait_ema: Ema::new(GAIT_ALPHA),
|
||||
fluidity_ema: Ema::new(FLUIDITY_ALPHA),
|
||||
social_ema: Ema::new(SOCIAL_ALPHA),
|
||||
|
||||
prev_mean_phase: 0.0,
|
||||
prev_phase_roc: 0.0,
|
||||
|
||||
happiness: 0.5,
|
||||
happiness_vector: [0.0; HAPPINESS_VECTOR_DIM],
|
||||
|
||||
frame_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Process one CSI frame.
|
||||
///
|
||||
/// # Arguments
|
||||
/// - `phases` -- subcarrier phase values.
|
||||
/// - `amplitudes` -- subcarrier amplitude values.
|
||||
/// - `variance` -- subcarrier phase variance values.
|
||||
/// - `presence` -- 1 if person present, 0 if not.
|
||||
/// - `motion_energy` -- host-reported motion energy.
|
||||
/// - `breathing_bpm` -- breathing rate from Tier 2 DSP.
|
||||
/// - `heart_rate_bpm` -- heart rate from Tier 2 DSP.
|
||||
///
|
||||
/// Returns events as `(event_id, value)` pairs.
|
||||
pub fn process_frame(
|
||||
&mut self,
|
||||
phases: &[f32],
|
||||
amplitudes: &[f32],
|
||||
variance: &[f32],
|
||||
presence: i32,
|
||||
motion_energy: f32,
|
||||
breathing_bpm: f32,
|
||||
heart_rate_bpm: f32,
|
||||
) -> &[(i32, f32)] {
|
||||
static mut EVENTS: [(i32, f32); 5] = [(0, 0.0); 5];
|
||||
let mut n_ev = 0usize;
|
||||
|
||||
self.frame_count += 1;
|
||||
|
||||
let present = presence > 0;
|
||||
|
||||
// ── Update dwell buffer ──
|
||||
self.dwell_buf.push(if present { 1.0 } else { 0.0 });
|
||||
|
||||
// ── Update motion trend ──
|
||||
self.motion_trend.push(motion_energy);
|
||||
|
||||
// If nobody is present, emit nothing.
|
||||
if !present {
|
||||
return &[];
|
||||
}
|
||||
|
||||
// ── 1. Gait speed: phase rate-of-change ──
|
||||
let mean_phase = mean_slice(phases);
|
||||
let phase_roc = fabsf(mean_phase - self.prev_mean_phase);
|
||||
self.phase_roc.push(phase_roc);
|
||||
self.prev_mean_phase = mean_phase;
|
||||
|
||||
// ── 2. Stride regularity: step interval variance from successive diffs ──
|
||||
// Use variance across subcarriers as a step-impact proxy.
|
||||
let var_mean = mean_slice(variance);
|
||||
self.step_stats.update(var_mean);
|
||||
|
||||
// ── 3. Movement fluidity: second derivative of phase ──
|
||||
let phase_accel = fabsf(phase_roc - self.prev_phase_roc);
|
||||
self.fluidity_buf.push(phase_accel);
|
||||
self.prev_phase_roc = phase_roc;
|
||||
|
||||
// ── 4. Breathing calm ──
|
||||
self.breath_hist.push(breathing_bpm);
|
||||
|
||||
// ── 5. Posture: amplitude spread across subcarrier groups ──
|
||||
let amp_spread = compute_amplitude_spread(amplitudes);
|
||||
self.amp_spread_hist.push(amp_spread);
|
||||
|
||||
// ── Warmup period ──
|
||||
if self.frame_count < MIN_WARMUP {
|
||||
return &[];
|
||||
}
|
||||
|
||||
// ── Feature extraction ──
|
||||
|
||||
// Feature 1: Gait speed score [0, 1].
|
||||
let gait_speed = self.compute_gait_speed();
|
||||
let gait_speed_norm = clamp01(gait_speed / MAX_GAIT_SPEED);
|
||||
let gait_score = clamp01(self.gait_ema.update(gait_speed_norm));
|
||||
|
||||
// Feature 2: Stride regularity [0, 1] (low CV = regular = higher score).
|
||||
let stride_regularity = self.compute_stride_regularity();
|
||||
|
||||
// Feature 3: Movement fluidity [0, 1] (low jerk = fluid = higher score).
|
||||
let fluidity_raw = self.compute_fluidity();
|
||||
let fluidity = clamp01(self.fluidity_ema.update(fluidity_raw));
|
||||
|
||||
// Feature 4: Breathing calm [0, 1] (slow breathing = calm = higher score).
|
||||
let breath_calm = self.compute_breath_calm(breathing_bpm);
|
||||
|
||||
// Feature 5: Posture score [0, 1] (wide spread = upright = higher score).
|
||||
let posture_score = self.compute_posture_score();
|
||||
|
||||
// Feature 6: Dwell factor [0, 1] (fraction of recent frames with presence).
|
||||
let dwell_factor = self.compute_dwell_factor();
|
||||
|
||||
// ── Composite happiness score ──
|
||||
let raw_happiness = W_GAIT_SPEED * gait_score
|
||||
+ W_STRIDE_REG * stride_regularity
|
||||
+ W_FLUIDITY * fluidity
|
||||
+ W_BREATH_CALM * breath_calm
|
||||
+ W_POSTURE * posture_score
|
||||
+ W_DWELL * dwell_factor;
|
||||
|
||||
self.happiness = clamp01(self.happiness_ema.update(raw_happiness));
|
||||
|
||||
// ── Derived outputs ──
|
||||
|
||||
// Gait energy: combination of gait speed + stride regularity.
|
||||
let gait_energy = clamp01(0.6 * gait_score + 0.4 * stride_regularity);
|
||||
|
||||
// Affect valence: breathing calm + fluidity (emotional valence).
|
||||
let affect_valence = clamp01(0.5 * breath_calm + 0.3 * fluidity + 0.2 * posture_score);
|
||||
|
||||
// Social energy: motion energy + dwell + heart rate proxy.
|
||||
let hr_factor = clamp01((heart_rate_bpm - 60.0) / 60.0);
|
||||
let raw_social = 0.4 * clamp01(motion_energy) + 0.3 * dwell_factor + 0.3 * hr_factor;
|
||||
let social_energy = clamp01(self.social_ema.update(raw_social));
|
||||
|
||||
// Transit direction: motion energy trend (increasing = entering, decreasing = exiting).
|
||||
let transit = self.compute_transit_direction();
|
||||
|
||||
// ── Update happiness vector ──
|
||||
self.happiness_vector[0] = self.happiness;
|
||||
self.happiness_vector[1] = gait_score;
|
||||
self.happiness_vector[2] = stride_regularity;
|
||||
self.happiness_vector[3] = fluidity;
|
||||
self.happiness_vector[4] = breath_calm;
|
||||
self.happiness_vector[5] = posture_score;
|
||||
self.happiness_vector[6] = dwell_factor;
|
||||
self.happiness_vector[7] = social_energy;
|
||||
|
||||
// ── Emit events (decimated for ESP32 bandwidth) ──
|
||||
// Always emit happiness score; other events only every Nth frame.
|
||||
unsafe {
|
||||
EVENTS[n_ev] = (EVENT_HAPPINESS_SCORE, self.happiness);
|
||||
}
|
||||
n_ev += 1;
|
||||
|
||||
if self.frame_count % EVENT_DECIMATION == 0 {
|
||||
unsafe {
|
||||
EVENTS[n_ev] = (EVENT_GAIT_ENERGY, gait_energy);
|
||||
}
|
||||
n_ev += 1;
|
||||
|
||||
unsafe {
|
||||
EVENTS[n_ev] = (EVENT_AFFECT_VALENCE, affect_valence);
|
||||
}
|
||||
n_ev += 1;
|
||||
|
||||
unsafe {
|
||||
EVENTS[n_ev] = (EVENT_SOCIAL_ENERGY, social_energy);
|
||||
}
|
||||
n_ev += 1;
|
||||
|
||||
unsafe {
|
||||
EVENTS[n_ev] = (EVENT_TRANSIT_DIRECTION, transit);
|
||||
}
|
||||
n_ev += 1;
|
||||
}
|
||||
|
||||
unsafe { &EVENTS[..n_ev] }
|
||||
}
|
||||
|
||||
/// Average phase rate-of-change over the rolling window.
|
||||
fn compute_gait_speed(&self) -> f32 {
|
||||
let n = self.phase_roc.len();
|
||||
if n == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
let mut sum = 0.0f32;
|
||||
for i in 0..n {
|
||||
sum += self.phase_roc.get(i);
|
||||
}
|
||||
sum / n as f32
|
||||
}
|
||||
|
||||
/// Stride regularity: inverse of step interval CV, mapped to [0, 1].
|
||||
/// Low CV (regular) -> high score.
|
||||
fn compute_stride_regularity(&self) -> f32 {
|
||||
if self.step_stats.count() < 4 {
|
||||
return 0.5;
|
||||
}
|
||||
let mean = self.step_stats.mean();
|
||||
if mean < 1e-6 {
|
||||
return 0.5;
|
||||
}
|
||||
let cv = self.step_stats.std_dev() / mean;
|
||||
// CV of 0 -> score 1.0, CV of 1.0 -> score 0.0.
|
||||
clamp01(1.0 - cv)
|
||||
}
|
||||
|
||||
/// Movement fluidity: inverse of mean phase acceleration, mapped to [0, 1].
|
||||
/// Low jerk -> high fluidity.
|
||||
fn compute_fluidity(&self) -> f32 {
|
||||
let n = self.fluidity_buf.len();
|
||||
if n == 0 {
|
||||
return 0.5;
|
||||
}
|
||||
let mut sum = 0.0f32;
|
||||
for i in 0..n {
|
||||
sum += self.fluidity_buf.get(i);
|
||||
}
|
||||
let mean_accel = sum / n as f32;
|
||||
// Mean acceleration of 0 -> fluidity 1.0, > 1.0 -> fluidity 0.0.
|
||||
clamp01(1.0 - mean_accel)
|
||||
}
|
||||
|
||||
/// Breathing calm score [0, 1].
|
||||
/// Slow breathing (6-14 BPM) -> high calm, fast breathing (>22) -> low calm.
|
||||
fn compute_breath_calm(&self, bpm: f32) -> f32 {
|
||||
if bpm >= CALM_BREATH_LOW && bpm <= CALM_BREATH_HIGH {
|
||||
return 1.0;
|
||||
}
|
||||
if bpm < CALM_BREATH_LOW {
|
||||
// Very slow -- still fairly calm.
|
||||
return 0.7;
|
||||
}
|
||||
// Linear ramp from calm to stressed.
|
||||
let score = 1.0 - (bpm - CALM_BREATH_HIGH) / (STRESS_BREATH_THRESH - CALM_BREATH_HIGH);
|
||||
clamp01(score)
|
||||
}
|
||||
|
||||
/// Posture score [0, 1] from amplitude spread across subcarriers.
|
||||
/// Wide spread = upright posture.
|
||||
fn compute_posture_score(&self) -> f32 {
|
||||
let n = self.amp_spread_hist.len();
|
||||
if n == 0 {
|
||||
return 0.5;
|
||||
}
|
||||
let mut sum = 0.0f32;
|
||||
for i in 0..n {
|
||||
sum += self.amp_spread_hist.get(i);
|
||||
}
|
||||
let mean_spread = sum / n as f32;
|
||||
// Normalize: typical spread range is [0, 1].
|
||||
clamp01(mean_spread)
|
||||
}
|
||||
|
||||
/// Dwell factor [0, 1]: fraction of recent frames with presence.
|
||||
fn compute_dwell_factor(&self) -> f32 {
|
||||
let n = self.dwell_buf.len();
|
||||
if n == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
let mut sum = 0.0f32;
|
||||
for i in 0..n {
|
||||
sum += self.dwell_buf.get(i);
|
||||
}
|
||||
sum / n as f32
|
||||
}
|
||||
|
||||
/// Transit direction from motion energy trend.
|
||||
/// Returns 1.0 for entering (increasing trend), 0.0 for exiting (decreasing).
|
||||
fn compute_transit_direction(&self) -> f32 {
|
||||
let n = self.motion_trend.len();
|
||||
if n < 4 {
|
||||
return 0.5;
|
||||
}
|
||||
// Compare recent half to older half.
|
||||
let half = n / 2;
|
||||
let mut old_sum = 0.0f32;
|
||||
let mut new_sum = 0.0f32;
|
||||
for i in 0..half {
|
||||
old_sum += self.motion_trend.get(i);
|
||||
}
|
||||
for i in half..n {
|
||||
new_sum += self.motion_trend.get(i);
|
||||
}
|
||||
let old_avg = old_sum / half as f32;
|
||||
let new_avg = new_sum / (n - half) as f32;
|
||||
// Increasing -> entering (1.0), decreasing -> exiting (0.0).
|
||||
if new_avg > old_avg + 0.01 {
|
||||
1.0
|
||||
} else if new_avg < old_avg - 0.01 {
|
||||
0.0
|
||||
} else {
|
||||
0.5
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current happiness score [0, 1].
|
||||
pub fn happiness(&self) -> f32 {
|
||||
self.happiness
|
||||
}
|
||||
|
||||
/// Get the 8-dim happiness vector.
|
||||
pub fn happiness_vector(&self) -> &[f32; HAPPINESS_VECTOR_DIM] {
|
||||
&self.happiness_vector
|
||||
}
|
||||
|
||||
/// Total frames processed.
|
||||
pub fn frame_count(&self) -> u32 {
|
||||
self.frame_count
|
||||
}
|
||||
|
||||
/// Reset to initial state.
|
||||
pub fn reset(&mut self) {
|
||||
*self = Self::new();
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute mean of a slice. Returns 0.0 if empty.
|
||||
/// ESP32-optimized: caps at MAX_SC to avoid processing more subcarriers
|
||||
/// than the host provides, and uses `#[inline]` for WASM3 interpreter.
|
||||
#[inline]
|
||||
fn mean_slice(s: &[f32]) -> f32 {
|
||||
let n = s.len();
|
||||
if n == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
let n_use = if n > MAX_SC { MAX_SC } else { n };
|
||||
let mut sum = 0.0f32;
|
||||
for i in 0..n_use {
|
||||
sum += s[i];
|
||||
}
|
||||
sum / n_use as f32
|
||||
}
|
||||
|
||||
/// Compute amplitude spread: normalized variance across subcarriers.
|
||||
/// Higher spread means signal is distributed across more subcarriers (upright posture).
|
||||
/// ESP32-optimized: uses variance/mean^2 (CV^2) to avoid sqrtf.
|
||||
#[inline]
|
||||
fn compute_amplitude_spread(amplitudes: &[f32]) -> f32 {
|
||||
let n = amplitudes.len();
|
||||
if n < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
let n_use = if n > MAX_SC { MAX_SC } else { n };
|
||||
|
||||
// Single-pass mean + variance (Welford online, unrolled for speed).
|
||||
let mut sum = 0.0f32;
|
||||
for i in 0..n_use {
|
||||
sum += amplitudes[i];
|
||||
}
|
||||
let mean = sum / n_use as f32;
|
||||
if mean < 1e-6 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let mut var_sum = 0.0f32;
|
||||
for i in 0..n_use {
|
||||
let d = amplitudes[i] - mean;
|
||||
var_sum += d * d;
|
||||
}
|
||||
// CV^2 = variance / mean^2 — avoids sqrtf on ESP32.
|
||||
// Typical CV range [0, 2] -> CV^2 range [0, 4].
|
||||
// Map CV^2 to [0, 1] with saturating scale at 1.0.
|
||||
let cv_sq = var_sum / (n_use as f32 * mean * mean);
|
||||
clamp01(cv_sq)
|
||||
}
|
||||
|
||||
/// Clamp a value to [0, 1].
|
||||
#[inline(always)]
|
||||
fn clamp01(x: f32) -> f32 {
|
||||
if x < 0.0 {
|
||||
0.0
|
||||
} else if x > 1.0 {
|
||||
1.0
|
||||
} else {
|
||||
x
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use libm::fabsf;
|
||||
|
||||
/// Helper: feed N frames with presence and reasonable CSI data.
|
||||
fn feed_frames(
|
||||
det: &mut HappinessScoreDetector,
|
||||
n: u32,
|
||||
phases: &[f32],
|
||||
amplitudes: &[f32],
|
||||
variance: &[f32],
|
||||
presence: i32,
|
||||
motion_energy: f32,
|
||||
breathing_bpm: f32,
|
||||
heart_rate_bpm: f32,
|
||||
) {
|
||||
for _ in 0..n {
|
||||
det.process_frame(
|
||||
phases,
|
||||
amplitudes,
|
||||
variance,
|
||||
presence,
|
||||
motion_energy,
|
||||
breathing_bpm,
|
||||
heart_rate_bpm,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_const_new() {
|
||||
let det = HappinessScoreDetector::new();
|
||||
assert_eq!(det.frame_count(), 0);
|
||||
assert!(fabsf(det.happiness() - 0.5) < 1e-6);
|
||||
assert_eq!(det.happiness_vector().len(), HAPPINESS_VECTOR_DIM);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_presence_no_score() {
|
||||
let mut det = HappinessScoreDetector::new();
|
||||
let phases = [0.1, 0.2, 0.3, 0.4];
|
||||
let amps = [1.0, 1.0, 1.0, 1.0];
|
||||
let var = [0.1, 0.1, 0.1, 0.1];
|
||||
|
||||
// Feed 100 frames with no presence.
|
||||
for _ in 0..100 {
|
||||
let events = det.process_frame(&phases, &s, &var, 0, 0.5, 14.0, 70.0);
|
||||
assert!(events.is_empty(), "should not emit events without presence");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_happy_gait() {
|
||||
let mut det = HappinessScoreDetector::new();
|
||||
|
||||
// Simulate happy gait: fast phase changes (high gait speed), regular variance,
|
||||
// smooth trajectory, calm breathing, good posture.
|
||||
let amps = [1.0, 0.8, 1.2, 0.9, 1.1, 0.7, 1.3, 0.85];
|
||||
let var = [0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3];
|
||||
|
||||
for i in 0..200u32 {
|
||||
// Steadily increasing phase = fast gait (0.8 rad/frame is brisk walking).
|
||||
let phase_val = (i as f32) * 0.8;
|
||||
let phases = [phase_val; 8];
|
||||
det.process_frame(&phases, &s, &var, 1, 0.6, 10.0, 72.0);
|
||||
}
|
||||
|
||||
// Gait energy should be moderate-to-high due to consistent phase changes.
|
||||
let vec = det.happiness_vector();
|
||||
let gait_score = vec[1];
|
||||
assert!(
|
||||
gait_score > 0.2,
|
||||
"fast regular gait should yield moderate+ gait score, got {}",
|
||||
gait_score
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calm_breathing() {
|
||||
let mut det = HappinessScoreDetector::new();
|
||||
|
||||
let phases = [0.1, 0.2, 0.15, 0.18];
|
||||
let amps = [1.0, 1.0, 1.0, 1.0];
|
||||
let var = [0.2, 0.2, 0.2, 0.2];
|
||||
|
||||
// Feed with calm breathing (10 BPM, in calm range).
|
||||
feed_frames(&mut det, 200, &phases, &s, &var, 1, 0.3, 10.0, 68.0);
|
||||
|
||||
let vec = det.happiness_vector();
|
||||
let breath_calm = vec[4];
|
||||
assert!(
|
||||
breath_calm > 0.7,
|
||||
"slow calm breathing should yield high calm score, got {}",
|
||||
breath_calm
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_score_bounds() {
|
||||
let mut det = HappinessScoreDetector::new();
|
||||
|
||||
// Feed extreme values.
|
||||
let phases = [10.0, -10.0, 5.0, -5.0];
|
||||
let amps = [100.0, 0.0, 50.0, 200.0];
|
||||
let var = [5.0, 5.0, 5.0, 5.0];
|
||||
|
||||
feed_frames(&mut det, 100, &phases, &s, &var, 1, 5.0, 40.0, 150.0);
|
||||
|
||||
assert!(
|
||||
det.happiness() >= 0.0 && det.happiness() <= 1.0,
|
||||
"happiness must be in [0,1], got {}",
|
||||
det.happiness()
|
||||
);
|
||||
|
||||
let vec = det.happiness_vector();
|
||||
for (i, &v) in vec.iter().enumerate() {
|
||||
assert!(
|
||||
v >= 0.0 && v <= 1.0,
|
||||
"happiness_vector[{}] must be in [0,1], got {}",
|
||||
i,
|
||||
v
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_happiness_vector_dim() {
|
||||
let det = HappinessScoreDetector::new();
|
||||
assert_eq!(
|
||||
det.happiness_vector().len(),
|
||||
8,
|
||||
"happiness vector must be exactly 8 dimensions"
|
||||
);
|
||||
assert_eq!(HAPPINESS_VECTOR_DIM, 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_event_ids_emitted() {
|
||||
let mut det = HappinessScoreDetector::new();
|
||||
let phases = [0.1, 0.2, 0.3, 0.4];
|
||||
let amps = [1.0, 1.0, 1.0, 1.0];
|
||||
let var = [0.1, 0.1, 0.1, 0.1];
|
||||
|
||||
// Past warmup — feed enough frames so next one lands on decimation boundary.
|
||||
// EVENT_DECIMATION=4, MIN_WARMUP=20, so frame 24 is first full-emit after warmup.
|
||||
// We need frame_count % EVENT_DECIMATION == 0 for full event set.
|
||||
let warmup_frames = MIN_WARMUP + (EVENT_DECIMATION - (MIN_WARMUP % EVENT_DECIMATION)) % EVENT_DECIMATION;
|
||||
for _ in 0..warmup_frames {
|
||||
det.process_frame(&phases, &s, &var, 1, 0.3, 14.0, 70.0);
|
||||
}
|
||||
// Next frame should land on decimation boundary and emit all 5 events.
|
||||
// Feed (EVENT_DECIMATION - 1) more frames that emit only happiness score.
|
||||
for _ in 0..EVENT_DECIMATION - 1 {
|
||||
det.process_frame(&phases, &s, &var, 1, 0.3, 14.0, 70.0);
|
||||
}
|
||||
let events = det.process_frame(&phases, &s, &var, 1, 0.3, 14.0, 70.0);
|
||||
// On non-decimation frames: 1 event (happiness only).
|
||||
// On decimation frames: 5 events (all).
|
||||
// Check that we get either 1 or 5; full event set when on boundary.
|
||||
assert!(events.len() == 1 || events.len() == 5,
|
||||
"should emit 1 or 5 events, got {}", events.len());
|
||||
assert_eq!(events[0].0, EVENT_HAPPINESS_SCORE);
|
||||
// Verify all 5 on a decimation frame.
|
||||
if events.len() == 5 {
|
||||
assert_eq!(events[1].0, EVENT_GAIT_ENERGY);
|
||||
assert_eq!(events[2].0, EVENT_AFFECT_VALENCE);
|
||||
assert_eq!(events[3].0, EVENT_SOCIAL_ENERGY);
|
||||
assert_eq!(events[4].0, EVENT_TRANSIT_DIRECTION);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clamp01() {
|
||||
assert!(fabsf(clamp01(-1.0)) < 1e-6);
|
||||
assert!(fabsf(clamp01(0.5) - 0.5) < 1e-6);
|
||||
assert!(fabsf(clamp01(2.0) - 1.0) < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transit_direction() {
|
||||
let mut det = HappinessScoreDetector::new();
|
||||
let phases = [0.1, 0.2, 0.3, 0.4];
|
||||
let amps = [1.0, 1.0, 1.0, 1.0];
|
||||
let var = [0.1, 0.1, 0.1, 0.1];
|
||||
|
||||
// Feed increasing motion energy -> entering.
|
||||
// Use enough frames so we land on a decimation boundary with transit event.
|
||||
for i in 0..64u32 {
|
||||
let energy = (i as f32) * 0.02;
|
||||
det.process_frame(&phases, &s, &var, 1, energy, 14.0, 70.0);
|
||||
}
|
||||
// Collect events across EVENT_DECIMATION frames to catch the transit event.
|
||||
let mut found_transit = false;
|
||||
let mut transit_val = 0.0f32;
|
||||
for _ in 0..EVENT_DECIMATION {
|
||||
let events = det.process_frame(&phases, &s, &var, 1, 1.5, 14.0, 70.0);
|
||||
if let Some(ev) = events.iter().find(|e| e.0 == EVENT_TRANSIT_DIRECTION) {
|
||||
found_transit = true;
|
||||
transit_val = ev.1;
|
||||
}
|
||||
}
|
||||
assert!(found_transit, "should emit transit direction within decimation window");
|
||||
assert!(
|
||||
transit_val >= 0.5,
|
||||
"increasing motion should indicate entering, got {}",
|
||||
transit_val
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reset() {
|
||||
let mut det = HappinessScoreDetector::new();
|
||||
let phases = [0.1, 0.2, 0.3, 0.4];
|
||||
let amps = [1.0, 1.0, 1.0, 1.0];
|
||||
let var = [0.1, 0.1, 0.1, 0.1];
|
||||
|
||||
feed_frames(&mut det, 100, &phases, &s, &var, 1, 0.3, 14.0, 70.0);
|
||||
assert!(det.frame_count() > 0);
|
||||
det.reset();
|
||||
assert_eq!(det.frame_count(), 0);
|
||||
assert!(fabsf(det.happiness() - 0.5) < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_amplitude_spread() {
|
||||
// Uniform amplitudes -> low spread.
|
||||
let uniform = [1.0, 1.0, 1.0, 1.0];
|
||||
let s1 = compute_amplitude_spread(&uniform);
|
||||
assert!(s1 < 0.01, "uniform amps should have near-zero spread, got {}", s1);
|
||||
|
||||
// Varied amplitudes -> higher spread.
|
||||
let varied = [0.1, 2.0, 0.5, 3.0, 0.2, 1.5];
|
||||
let s2 = compute_amplitude_spread(&varied);
|
||||
assert!(s2 > 0.3, "varied amps should have significant spread, got {}", s2);
|
||||
}
|
||||
}
|
||||
@@ -139,6 +139,7 @@ pub mod exo_plant_growth;
|
||||
pub mod exo_ghost_hunter;
|
||||
pub mod exo_rain_detect;
|
||||
pub mod exo_breathing_sync;
|
||||
pub mod exo_happiness_score;
|
||||
|
||||
// ── Host API FFI bindings ────────────────────────────────────────────────────
|
||||
|
||||
@@ -382,6 +383,13 @@ pub mod event_types {
|
||||
pub const HIDDEN_PRESENCE: i32 = 652;
|
||||
pub const ENVIRONMENTAL_DRIFT: i32 = 653;
|
||||
|
||||
// exo_happiness_score (690-694)
|
||||
pub const HAPPINESS_SCORE: i32 = 690;
|
||||
pub const GAIT_ENERGY: i32 = 691;
|
||||
pub const AFFECT_VALENCE: i32 = 692;
|
||||
pub const SOCIAL_ENERGY: i32 = 693;
|
||||
pub const TRANSIT_DIRECTION: i32 = 694;
|
||||
|
||||
// exo_rain_detect (660-662)
|
||||
pub const RAIN_ONSET: i32 = 660;
|
||||
pub const RAIN_INTENSITY: i32 = 661;
|
||||
@@ -569,10 +577,15 @@ fn panic(_info: &core::panic::PanicInfo) -> ! {
|
||||
// Individual modules (gesture, coherence, adversarial) can define their own
|
||||
// on_init/on_frame/on_timer. This default implementation demonstrates the
|
||||
// combined pipeline: gesture detection + coherence monitoring + anomaly check.
|
||||
//
|
||||
// Gated behind the "default-pipeline" feature so that standalone module
|
||||
// binaries (ghost_hunter, etc.) can define their own on_frame without
|
||||
// symbol collisions.
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[cfg(all(target_arch = "wasm32", feature = "default-pipeline"))]
|
||||
static mut STATE: CombinedState = CombinedState::new();
|
||||
|
||||
#[cfg(feature = "default-pipeline")]
|
||||
struct CombinedState {
|
||||
gesture: gesture::GestureDetector,
|
||||
coherence: coherence::CoherenceMonitor,
|
||||
@@ -580,6 +593,7 @@ struct CombinedState {
|
||||
frame_count: u32,
|
||||
}
|
||||
|
||||
#[cfg(feature = "default-pipeline")]
|
||||
impl CombinedState {
|
||||
const fn new() -> Self {
|
||||
Self {
|
||||
@@ -591,13 +605,13 @@ impl CombinedState {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[cfg(all(target_arch = "wasm32", feature = "default-pipeline"))]
|
||||
#[no_mangle]
|
||||
pub extern "C" fn on_init() {
|
||||
log_msg("wasm-edge: combined pipeline init");
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[cfg(all(target_arch = "wasm32", feature = "default-pipeline"))]
|
||||
#[no_mangle]
|
||||
pub extern "C" fn on_frame(n_subcarriers: i32) {
|
||||
// M-01 fix: treat negative host values as 0 instead of wrapping to usize::MAX.
|
||||
@@ -634,7 +648,7 @@ pub extern "C" fn on_frame(n_subcarriers: i32) {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[cfg(all(target_arch = "wasm32", feature = "default-pipeline"))]
|
||||
#[no_mangle]
|
||||
pub extern "C" fn on_timer() {
|
||||
// Periodic summary.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user