Compare commits
2 Commits
claude/int
...
claude/rus
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e86628fbfe | ||
|
|
ddbaed55ab |
@@ -1,50 +1,50 @@
|
||||
{
|
||||
"running": true,
|
||||
"startedAt": "2026-02-28T13:34:03.423Z",
|
||||
"startedAt": "2026-01-13T03:57:49.513Z",
|
||||
"workers": {
|
||||
"map": {
|
||||
"runCount": 45,
|
||||
"successCount": 45,
|
||||
"runCount": 4,
|
||||
"successCount": 4,
|
||||
"failureCount": 0,
|
||||
"averageDurationMs": 1.1555555555555554,
|
||||
"lastRun": "2026-02-28T14:34:03.462Z",
|
||||
"nextRun": "2026-02-28T14:49:03.462Z",
|
||||
"averageDurationMs": 1.25,
|
||||
"lastRun": "2026-01-13T03:57:49.518Z",
|
||||
"nextRun": "2026-01-13T03:57:49.513Z",
|
||||
"isRunning": false
|
||||
},
|
||||
"audit": {
|
||||
"runCount": 40,
|
||||
"runCount": 2,
|
||||
"successCount": 0,
|
||||
"failureCount": 40,
|
||||
"failureCount": 2,
|
||||
"averageDurationMs": 0,
|
||||
"lastRun": "2026-02-28T14:41:03.451Z",
|
||||
"nextRun": "2026-02-28T14:51:03.452Z",
|
||||
"lastRun": "2026-01-13T03:31:14.142Z",
|
||||
"nextRun": "2026-01-13T03:59:49.513Z",
|
||||
"isRunning": false
|
||||
},
|
||||
"optimize": {
|
||||
"runCount": 31,
|
||||
"runCount": 2,
|
||||
"successCount": 0,
|
||||
"failureCount": 31,
|
||||
"failureCount": 2,
|
||||
"averageDurationMs": 0,
|
||||
"lastRun": "2026-02-28T14:43:03.464Z",
|
||||
"nextRun": "2026-02-28T14:38:03.457Z",
|
||||
"lastRun": "2026-01-13T03:33:14.140Z",
|
||||
"nextRun": "2026-01-13T04:01:49.513Z",
|
||||
"isRunning": false
|
||||
},
|
||||
"consolidate": {
|
||||
"runCount": 21,
|
||||
"successCount": 21,
|
||||
"runCount": 2,
|
||||
"successCount": 2,
|
||||
"failureCount": 0,
|
||||
"averageDurationMs": 0.6190476190476191,
|
||||
"lastRun": "2026-02-28T14:41:03.452Z",
|
||||
"nextRun": "2026-02-28T15:10:03.429Z",
|
||||
"averageDurationMs": 0.5,
|
||||
"lastRun": "2026-01-13T03:41:00.662Z",
|
||||
"nextRun": "2026-01-13T04:11:00.662Z",
|
||||
"isRunning": false
|
||||
},
|
||||
"testgaps": {
|
||||
"runCount": 25,
|
||||
"runCount": 1,
|
||||
"successCount": 0,
|
||||
"failureCount": 25,
|
||||
"failureCount": 1,
|
||||
"averageDurationMs": 0,
|
||||
"lastRun": "2026-02-28T14:37:03.441Z",
|
||||
"nextRun": "2026-02-28T14:57:03.442Z",
|
||||
"lastRun": "2026-01-13T03:37:14.141Z",
|
||||
"nextRun": "2026-01-13T04:05:49.513Z",
|
||||
"isRunning": false
|
||||
},
|
||||
"predict": {
|
||||
@@ -131,5 +131,5 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"savedAt": "2026-02-28T14:43:03.464Z"
|
||||
"savedAt": "2026-01-13T03:57:49.518Z"
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
166
|
||||
588
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"timestamp": "2026-02-28T14:34:03.461Z",
|
||||
"timestamp": "2026-01-13T03:57:49.517Z",
|
||||
"projectRoot": "/home/user/wifi-densepose",
|
||||
"structure": {
|
||||
"hasPackageJson": false,
|
||||
@@ -7,5 +7,5 @@
|
||||
"hasClaudeConfig": true,
|
||||
"hasClaudeFlow": true
|
||||
},
|
||||
"scannedAt": 1772289243462
|
||||
"scannedAt": 1768276669518
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"timestamp": "2026-02-28T14:41:03.452Z",
|
||||
"timestamp": "2026-01-13T03:41:00.661Z",
|
||||
"patternsConsolidated": 0,
|
||||
"memoryCleaned": 0,
|
||||
"duplicatesRemoved": 0
|
||||
|
||||
105
.github/workflows/verify-pipeline.yml
vendored
105
.github/workflows/verify-pipeline.yml
vendored
@@ -1,105 +0,0 @@
|
||||
name: Verify Pipeline Determinism
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master, 'claude/**' ]
|
||||
paths:
|
||||
- 'v1/src/core/**'
|
||||
- 'v1/src/hardware/**'
|
||||
- 'v1/data/proof/**'
|
||||
- '.github/workflows/verify-pipeline.yml'
|
||||
pull_request:
|
||||
branches: [ main, master ]
|
||||
paths:
|
||||
- 'v1/src/core/**'
|
||||
- 'v1/src/hardware/**'
|
||||
- 'v1/data/proof/**'
|
||||
- '.github/workflows/verify-pipeline.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
verify-determinism:
|
||||
name: Verify Pipeline Determinism
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ['3.11']
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install pinned dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r v1/requirements-lock.txt
|
||||
|
||||
- name: Verify reference signal is reproducible
|
||||
run: |
|
||||
echo "=== Regenerating reference signal ==="
|
||||
python v1/data/proof/generate_reference_signal.py
|
||||
echo ""
|
||||
echo "=== Checking data file matches committed version ==="
|
||||
# The regenerated file should be identical to the committed one
|
||||
# (We compare the metadata file since data file is large)
|
||||
python -c "
|
||||
import json, hashlib
|
||||
with open('v1/data/proof/sample_csi_meta.json') as f:
|
||||
meta = json.load(f)
|
||||
assert meta['is_synthetic'] == True, 'Metadata must mark signal as synthetic'
|
||||
assert meta['numpy_seed'] == 42, 'Seed must be 42'
|
||||
print('Reference signal metadata validated.')
|
||||
"
|
||||
|
||||
- name: Run pipeline verification
|
||||
working-directory: v1
|
||||
run: |
|
||||
echo "=== Running pipeline verification ==="
|
||||
python data/proof/verify.py
|
||||
echo ""
|
||||
echo "Pipeline verification PASSED."
|
||||
|
||||
- name: Run verification twice to confirm determinism
|
||||
working-directory: v1
|
||||
run: |
|
||||
echo "=== Second run for determinism confirmation ==="
|
||||
python data/proof/verify.py
|
||||
echo "Determinism confirmed across multiple runs."
|
||||
|
||||
- name: Check for unseeded np.random in production code
|
||||
run: |
|
||||
echo "=== Scanning for unseeded np.random usage in production code ==="
|
||||
# Search for np.random calls without a seed in production code
|
||||
# Exclude test files, proof data generators, and known parser placeholders
|
||||
VIOLATIONS=$(grep -rn "np\.random\." v1/src/ \
|
||||
--include="*.py" \
|
||||
--exclude-dir="__pycache__" \
|
||||
| grep -v "np\.random\.RandomState" \
|
||||
| grep -v "np\.random\.seed" \
|
||||
| grep -v "np\.random\.default_rng" \
|
||||
| grep -v "# placeholder" \
|
||||
| grep -v "# mock" \
|
||||
| grep -v "# test" \
|
||||
|| true)
|
||||
|
||||
if [ -n "$VIOLATIONS" ]; then
|
||||
echo ""
|
||||
echo "WARNING: Found potential unseeded np.random usage in production code:"
|
||||
echo "$VIOLATIONS"
|
||||
echo ""
|
||||
echo "Each np.random call should either:"
|
||||
echo " 1. Use np.random.RandomState(seed) or np.random.default_rng(seed)"
|
||||
echo " 2. Be in a test/mock context (add '# placeholder' comment)"
|
||||
echo ""
|
||||
# Note: This is a warning, not a failure, because some existing
|
||||
# placeholder code in parsers uses np.random for mock data.
|
||||
# Once hardware integration is complete, these should be removed.
|
||||
echo "WARNING: Review the above usages. Existing parser placeholders are expected."
|
||||
else
|
||||
echo "No unseeded np.random usage found in production code."
|
||||
fi
|
||||
123
Makefile
123
Makefile
@@ -1,123 +0,0 @@
|
||||
# WiFi-DensePose Makefile
|
||||
# ============================================================
|
||||
|
||||
.PHONY: verify verify-verbose verify-audit install install-verify install-python \
|
||||
install-rust install-browser install-docker install-field install-full \
|
||||
check build-rust build-wasm test-rust bench run-api run-viz clean help
|
||||
|
||||
# ─── Installation ────────────────────────────────────────────
|
||||
# Guided interactive installer
|
||||
install:
|
||||
@./install.sh
|
||||
|
||||
# Profile-specific installs (non-interactive)
|
||||
install-verify:
|
||||
@./install.sh --profile verify --yes
|
||||
|
||||
install-python:
|
||||
@./install.sh --profile python --yes
|
||||
|
||||
install-rust:
|
||||
@./install.sh --profile rust --yes
|
||||
|
||||
install-browser:
|
||||
@./install.sh --profile browser --yes
|
||||
|
||||
install-docker:
|
||||
@./install.sh --profile docker --yes
|
||||
|
||||
install-field:
|
||||
@./install.sh --profile field --yes
|
||||
|
||||
install-full:
|
||||
@./install.sh --profile full --yes
|
||||
|
||||
# Hardware and environment check only (no install)
|
||||
check:
|
||||
@./install.sh --check-only
|
||||
|
||||
# ─── Verification ────────────────────────────────────────────
|
||||
# Trust Kill Switch -- one-command proof replay
|
||||
verify:
|
||||
@./verify
|
||||
|
||||
# Verbose mode -- show detailed feature statistics and Doppler spectrum
|
||||
verify-verbose:
|
||||
@./verify --verbose
|
||||
|
||||
# Full audit -- verify pipeline + scan codebase for mock/random patterns
|
||||
verify-audit:
|
||||
@./verify --verbose --audit
|
||||
|
||||
# ─── Rust Builds ─────────────────────────────────────────────
|
||||
build-rust:
|
||||
cd rust-port/wifi-densepose-rs && cargo build --release
|
||||
|
||||
build-wasm:
|
||||
cd rust-port/wifi-densepose-rs && wasm-pack build crates/wifi-densepose-wasm --target web --release
|
||||
|
||||
build-wasm-mat:
|
||||
cd rust-port/wifi-densepose-rs && wasm-pack build crates/wifi-densepose-wasm --target web --release -- --features mat
|
||||
|
||||
test-rust:
|
||||
cd rust-port/wifi-densepose-rs && cargo test --workspace
|
||||
|
||||
bench:
|
||||
cd rust-port/wifi-densepose-rs && cargo bench --package wifi-densepose-signal
|
||||
|
||||
# ─── Run ─────────────────────────────────────────────────────
|
||||
run-api:
|
||||
uvicorn v1.src.api.main:app --host 0.0.0.0 --port 8000
|
||||
|
||||
run-api-dev:
|
||||
uvicorn v1.src.api.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
run-viz:
|
||||
python3 -m http.server 3000 --directory ui
|
||||
|
||||
run-docker:
|
||||
docker compose up
|
||||
|
||||
# ─── Clean ───────────────────────────────────────────────────
|
||||
clean:
|
||||
rm -f .install.log
|
||||
cd rust-port/wifi-densepose-rs && cargo clean 2>/dev/null || true
|
||||
|
||||
# ─── Help ────────────────────────────────────────────────────
|
||||
help:
|
||||
@echo "WiFi-DensePose Build Targets"
|
||||
@echo "============================================================"
|
||||
@echo ""
|
||||
@echo " Installation:"
|
||||
@echo " make install Interactive guided installer"
|
||||
@echo " make install-verify Verification only (~5 MB)"
|
||||
@echo " make install-python Full Python pipeline (~500 MB)"
|
||||
@echo " make install-rust Rust pipeline with ~810x speedup"
|
||||
@echo " make install-browser WASM for browser (~10 MB)"
|
||||
@echo " make install-docker Docker-based deployment"
|
||||
@echo " make install-field WiFi-Mat disaster kit (~62 MB)"
|
||||
@echo " make install-full Everything available"
|
||||
@echo " make check Hardware/environment check only"
|
||||
@echo ""
|
||||
@echo " Verification:"
|
||||
@echo " make verify Run the trust kill switch"
|
||||
@echo " make verify-verbose Verbose with feature details"
|
||||
@echo " make verify-audit Full verification + codebase audit"
|
||||
@echo ""
|
||||
@echo " Build:"
|
||||
@echo " make build-rust Build Rust workspace (release)"
|
||||
@echo " make build-wasm Build WASM package (browser)"
|
||||
@echo " make build-wasm-mat Build WASM with WiFi-Mat (field)"
|
||||
@echo " make test-rust Run all Rust tests"
|
||||
@echo " make bench Run signal processing benchmarks"
|
||||
@echo ""
|
||||
@echo " Run:"
|
||||
@echo " make run-api Start Python API server"
|
||||
@echo " make run-api-dev Start API with hot-reload"
|
||||
@echo " make run-viz Serve 3D visualization (port 3000)"
|
||||
@echo " make run-docker Start Docker dev stack"
|
||||
@echo ""
|
||||
@echo " Utility:"
|
||||
@echo " make clean Remove build artifacts"
|
||||
@echo " make help Show this help"
|
||||
@echo ""
|
||||
230
README.md
230
README.md
@@ -1,17 +1,5 @@
|
||||
# WiFi DensePose
|
||||
|
||||
> **Hardware Required:** This system processes real WiFi Channel State Information (CSI) data. To capture live CSI you need one of:
|
||||
>
|
||||
> | Option | Hardware | Cost | Capabilities |
|
||||
> |--------|----------|------|-------------|
|
||||
> | **ESP32 Mesh** (recommended) | 3-6x ESP32-S3 boards + consumer WiFi router | ~$54 | Presence, motion, respiration detection |
|
||||
> | **Research NIC** | Intel 5300 or Atheros AR9580 (discontinued) | ~$50-100 | Full CSI with 3x3 MIMO |
|
||||
> | **Commodity WiFi** | Any Linux laptop with WiFi | $0 | Presence and coarse motion only (RSSI-based) |
|
||||
>
|
||||
> Without CSI-capable hardware, you can verify the signal processing pipeline using the included deterministic reference signal: `python v1/data/proof/verify.py`
|
||||
>
|
||||
> See [docs/adr/ADR-012-esp32-csi-sensor-mesh.md](docs/adr/ADR-012-esp32-csi-sensor-mesh.md) for the ESP32 setup guide and [docs/adr/ADR-013-feature-level-sensing-commodity-gear.md](docs/adr/ADR-013-feature-level-sensing-commodity-gear.md) for the zero-cost RSSI path.
|
||||
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://fastapi.tiangolo.com/)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
@@ -64,7 +52,7 @@ A high-performance Rust port is available in `/rust-port/wifi-densepose-rs/`:
|
||||
| Memory Usage | ~500MB | ~100MB |
|
||||
| WASM Support | ❌ | ✅ |
|
||||
| Binary Size | N/A | ~10MB |
|
||||
| Test Coverage | 100% | 313 tests |
|
||||
| Test Coverage | 100% | 107 tests |
|
||||
|
||||
**Quick Start (Rust):**
|
||||
```bash
|
||||
@@ -83,76 +71,8 @@ Mathematical correctness validated:
|
||||
- ✅ Correlation: 1.0 for identical signals
|
||||
- ✅ Phase coherence: 1.0 for coherent signals
|
||||
|
||||
### SOTA Signal Processing (ADR-014)
|
||||
|
||||
Six research-grade algorithms implemented in the `wifi-densepose-signal` crate:
|
||||
|
||||
| Algorithm | Purpose | Reference |
|
||||
|-----------|---------|-----------|
|
||||
| **Conjugate Multiplication** | Cancels CFO/SFO from raw CSI phase via antenna ratio | SpotFi (SIGCOMM 2015) |
|
||||
| **Hampel Filter** | Robust outlier removal using median/MAD (resists 50% contamination) | Hampel (1974) |
|
||||
| **Fresnel Zone Model** | Physics-based breathing detection from chest displacement | FarSense (MobiCom 2019) |
|
||||
| **CSI Spectrogram** | STFT time-frequency matrices for CNN-based activity recognition | Standard since 2018 |
|
||||
| **Subcarrier Selection** | Variance-ratio ranking to pick top-K motion-sensitive subcarriers | WiDance (MobiCom 2017) |
|
||||
| **Body Velocity Profile** | Domain-independent velocity x time representation from Doppler | Widar 3.0 (MobiSys 2019) |
|
||||
|
||||
See [Rust Port Documentation](/rust-port/wifi-densepose-rs/docs/) for ADRs and DDD patterns.
|
||||
|
||||
## 🚨 WiFi-Mat: Disaster Response Module
|
||||
|
||||
A specialized extension for **search and rescue operations** - detecting and localizing survivors trapped in rubble, earthquakes, and natural disasters.
|
||||
|
||||
### Key Capabilities
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **Vital Signs Detection** | Breathing (4-60 BPM), heartbeat via micro-Doppler |
|
||||
| **3D Localization** | Position estimation through debris up to 5m depth |
|
||||
| **START Triage** | Automatic Immediate/Delayed/Minor/Deceased classification |
|
||||
| **Real-time Alerts** | Priority-based notifications with escalation |
|
||||
|
||||
### Use Cases
|
||||
|
||||
- Earthquake search and rescue
|
||||
- Building collapse response
|
||||
- Avalanche victim location
|
||||
- Mine collapse detection
|
||||
- Flood rescue operations
|
||||
|
||||
### Quick Example
|
||||
|
||||
```rust
|
||||
use wifi_densepose_mat::{DisasterResponse, DisasterConfig, DisasterType, ScanZone, ZoneBounds};
|
||||
|
||||
let config = DisasterConfig::builder()
|
||||
.disaster_type(DisasterType::Earthquake)
|
||||
.sensitivity(0.85)
|
||||
.max_depth(5.0)
|
||||
.build();
|
||||
|
||||
let mut response = DisasterResponse::new(config);
|
||||
response.initialize_event(location, "Building collapse")?;
|
||||
response.add_zone(ScanZone::new("North Wing", ZoneBounds::rectangle(0.0, 0.0, 30.0, 20.0)))?;
|
||||
response.start_scanning().await?;
|
||||
|
||||
// Get survivors prioritized by triage status
|
||||
let immediate = response.survivors_by_triage(TriageStatus::Immediate);
|
||||
println!("{} survivors require immediate rescue", immediate.len());
|
||||
```
|
||||
|
||||
### Documentation
|
||||
|
||||
- **[WiFi-Mat User Guide](docs/wifi-mat-user-guide.md)** - Complete setup, configuration, and field deployment
|
||||
- **[Architecture Decision Record](docs/adr/ADR-001-wifi-mat-disaster-detection.md)** - Design decisions and rationale
|
||||
- **[Domain Model](docs/ddd/wifi-mat-domain-model.md)** - DDD bounded contexts and entities
|
||||
|
||||
**Build:**
|
||||
```bash
|
||||
cd rust-port/wifi-densepose-rs
|
||||
cargo build --release --package wifi-densepose-mat
|
||||
cargo test --package wifi-densepose-mat
|
||||
```
|
||||
|
||||
## 📋 Table of Contents
|
||||
|
||||
<table>
|
||||
@@ -161,14 +81,10 @@ cargo test --package wifi-densepose-mat
|
||||
|
||||
**🚀 Getting Started**
|
||||
- [Key Features](#-key-features)
|
||||
- [Rust Implementation (v2)](#-rust-implementation-v2)
|
||||
- [WiFi-Mat Disaster Response](#-wifi-mat-disaster-response-module)
|
||||
- [System Architecture](#️-system-architecture)
|
||||
- [Installation](#-installation)
|
||||
- [Guided Installer (Recommended)](#guided-installer-recommended)
|
||||
- [Install Profiles](#install-profiles)
|
||||
- [From Source (Rust)](#from-source-rust--primary)
|
||||
- [From Source (Python)](#from-source-python)
|
||||
- [Using pip (Recommended)](#using-pip-recommended)
|
||||
- [From Source](#from-source)
|
||||
- [Using Docker](#using-docker)
|
||||
- [System Requirements](#system-requirements)
|
||||
- [Quick Start](#-quick-start)
|
||||
@@ -204,7 +120,7 @@ cargo test --package wifi-densepose-mat
|
||||
- [Testing](#-testing)
|
||||
- [Running Tests](#running-tests)
|
||||
- [Test Categories](#test-categories)
|
||||
- [Testing Without Hardware](#testing-without-hardware)
|
||||
- [Mock Testing](#mock-testing)
|
||||
- [Continuous Integration](#continuous-integration)
|
||||
- [Deployment](#-deployment)
|
||||
- [Production Deployment](#production-deployment)
|
||||
@@ -281,73 +197,24 @@ WiFi DensePose consists of several key components working together:
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
### Guided Installer (Recommended)
|
||||
### Using pip (Recommended)
|
||||
|
||||
The interactive installer detects your hardware, checks your environment, and builds the right profile automatically:
|
||||
WiFi-DensePose is now available on PyPI for easy installation:
|
||||
|
||||
```bash
|
||||
./install.sh
|
||||
# Install the latest stable version
|
||||
pip install wifi-densepose
|
||||
|
||||
# Install with specific version
|
||||
pip install wifi-densepose==1.0.0
|
||||
|
||||
# Install with optional dependencies
|
||||
pip install wifi-densepose[gpu] # For GPU acceleration
|
||||
pip install wifi-densepose[dev] # For development
|
||||
pip install wifi-densepose[all] # All optional dependencies
|
||||
```
|
||||
|
||||
It walks through 7 steps:
|
||||
1. **System detection** — OS, RAM, disk, GPU
|
||||
2. **Toolchain detection** — Python, Rust, Docker, Node.js, ESP-IDF
|
||||
3. **WiFi hardware detection** — interfaces, ESP32 USB, Intel CSI debug
|
||||
4. **Profile recommendation** — picks the best profile for your hardware
|
||||
5. **Dependency installation** — installs what's missing
|
||||
6. **Build** — compiles the selected profile
|
||||
7. **Summary** — shows next steps and verification commands
|
||||
|
||||
#### Install Profiles
|
||||
|
||||
| Profile | What it installs | Size | Requirements |
|
||||
|---------|-----------------|------|-------------|
|
||||
| `verify` | Pipeline verification only | ~5 MB | Python 3.8+ |
|
||||
| `python` | Full Python API server + sensing | ~500 MB | Python 3.8+ |
|
||||
| `rust` | Rust pipeline (~810x faster) | ~200 MB | Rust 1.70+ |
|
||||
| `browser` | WASM for in-browser execution | ~10 MB | Rust + wasm-pack |
|
||||
| `iot` | ESP32 sensor mesh + aggregator | varies | Rust + ESP-IDF |
|
||||
| `docker` | Docker-based deployment | ~1 GB | Docker |
|
||||
| `field` | WiFi-Mat disaster response kit | ~62 MB | Rust + wasm-pack |
|
||||
| `full` | Everything available | ~2 GB | All toolchains |
|
||||
|
||||
#### Non-Interactive Install
|
||||
|
||||
```bash
|
||||
# Install a specific profile without prompts
|
||||
./install.sh --profile rust --yes
|
||||
|
||||
# Just run hardware detection (no install)
|
||||
./install.sh --check-only
|
||||
|
||||
# Or use make targets
|
||||
make install # Interactive
|
||||
make install-verify # Verification only
|
||||
make install-python # Python pipeline
|
||||
make install-rust # Rust pipeline
|
||||
make install-browser # WASM browser build
|
||||
make install-docker # Docker deployment
|
||||
make install-field # Disaster response kit
|
||||
make install-full # Everything
|
||||
make check # Hardware check only
|
||||
```
|
||||
|
||||
### From Source (Rust — Primary)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/ruvnet/wifi-densepose.git
|
||||
cd wifi-densepose
|
||||
|
||||
# Install Rust pipeline (810x faster than Python)
|
||||
./install.sh --profile rust --yes
|
||||
|
||||
# Or manually:
|
||||
cd rust-port/wifi-densepose-rs
|
||||
cargo build --release
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
### From Source (Python)
|
||||
### From Source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/ruvnet/wifi-densepose.git
|
||||
@@ -356,16 +223,6 @@ pip install -r requirements.txt
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
### Using pip (Python only)
|
||||
|
||||
```bash
|
||||
pip install wifi-densepose
|
||||
|
||||
# With optional dependencies
|
||||
pip install wifi-densepose[gpu] # For GPU acceleration
|
||||
pip install wifi-densepose[all] # All optional dependencies
|
||||
```
|
||||
|
||||
### Using Docker
|
||||
|
||||
```bash
|
||||
@@ -375,23 +232,19 @@ docker run -p 8000:8000 ruvnet/wifi-densepose:latest
|
||||
|
||||
### System Requirements
|
||||
|
||||
- **Rust**: 1.70+ (primary runtime — install via [rustup](https://rustup.rs/))
|
||||
- **Python**: 3.8+ (for verification and legacy v1 API)
|
||||
- **Python**: 3.8 or higher
|
||||
- **Operating System**: Linux (Ubuntu 18.04+), macOS (10.15+), Windows 10+
|
||||
- **Memory**: Minimum 4GB RAM, Recommended 8GB+
|
||||
- **Storage**: 2GB free space for models and data
|
||||
- **Network**: WiFi interface with CSI capability (optional — installer detects what you have)
|
||||
- **GPU**: Optional (NVIDIA CUDA or Apple Metal)
|
||||
- **Network**: WiFi interface with CSI capability
|
||||
- **GPU**: Optional but recommended (NVIDIA GPU with CUDA support)
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Basic Setup
|
||||
|
||||
```bash
|
||||
# Install the package (Rust — recommended)
|
||||
./install.sh --profile rust --yes
|
||||
|
||||
# Or Python legacy
|
||||
# Install the package
|
||||
pip install wifi-densepose
|
||||
|
||||
# Copy example configuration
|
||||
@@ -969,16 +822,17 @@ pytest tests/performance/ # Performance tests
|
||||
- Memory usage profiling
|
||||
- Stress testing
|
||||
|
||||
### Testing Without Hardware
|
||||
### Mock Testing
|
||||
|
||||
For development without WiFi CSI hardware, use the deterministic reference signal:
|
||||
For development without hardware:
|
||||
|
||||
```bash
|
||||
# Verify the full signal processing pipeline (no hardware needed)
|
||||
./verify
|
||||
# Enable mock mode
|
||||
export MOCK_HARDWARE=true
|
||||
export MOCK_POSE_DATA=true
|
||||
|
||||
# Run Rust tests (all use real signal processing, no mocks)
|
||||
cd rust-port/wifi-densepose-rs && cargo test --workspace
|
||||
# Run tests with mocked hardware
|
||||
pytest tests/ --mock-hardware
|
||||
```
|
||||
|
||||
### Continuous Integration
|
||||
@@ -1379,34 +1233,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
## Changelog
|
||||
|
||||
### v2.2.0 — 2026-02-28
|
||||
|
||||
- **Guided installer** — `./install.sh` with 7-step hardware detection, WiFi interface discovery, toolchain checks, and environment-specific RVF builds (verify/python/rust/browser/iot/docker/field/full profiles)
|
||||
- **Make targets** — `make install`, `make check`, `make install-rust`, `make build-wasm`, `make bench`, and 15+ other targets
|
||||
- **Real-only inference** — `forward()` and hardware adapters return explicit errors without weights/hardware instead of silent empty data
|
||||
- **5.7x Doppler FFT speedup** — Phase cache ring buffer reduces full pipeline from 719us to 254us per frame
|
||||
- **Trust kill switch** — `./verify` with SHA-256 proof replay, `--audit` mode, and production code integrity scan
|
||||
- **Security hardening** — 10 vulnerabilities fixed (hardcoded creds, JWT bypass, NaN panics), 12 dead code instances removed
|
||||
- **SOTA research** — Comprehensive WiFi sensing + RuVector analysis with 30+ citations and 20-year projection (docs/research/)
|
||||
- **6 SOTA signal algorithms (ADR-014)** — Conjugate multiplication (SpotFi), Hampel filter, Fresnel zone breathing model, CSI spectrogram, subcarrier sensitivity selection, Body Velocity Profile (Widar 3.0) — 83 new tests
|
||||
- **WiFi-Mat disaster response** — Ensemble classifier with START triage, scan zone management, API endpoints (ADR-001) — 139 tests
|
||||
- **ESP32 CSI hardware parser** — Real binary frame parsing with I/Q extraction, amplitude/phase conversion, stream resync (ADR-012) — 28 tests
|
||||
- **313 total Rust tests** — All passing, zero mocks
|
||||
|
||||
### v2.1.0 — 2026-02-28
|
||||
|
||||
- **RuVector RVF integration** — Architecture Decision Records (ADR-002 through ADR-013) defining integration of RVF cognitive containers, HNSW vector search, SONA self-learning, GNN pattern recognition, post-quantum cryptography, distributed consensus, WASM edge runtime, and witness chains
|
||||
- **ESP32 CSI sensor mesh** — Firmware specification for $54 starter kit with 3-6 ESP32-S3 nodes, feature-level fusion aggregator, and UDP streaming (ADR-012)
|
||||
- **Commodity WiFi sensing** — Zero-cost presence/motion detection via RSSI from any Linux WiFi adapter using `/proc/net/wireless` and `iw` (ADR-013)
|
||||
- **Deterministic proof bundle** — One-command pipeline verification (`./verify`) with SHA-256 hash matching against a published reference signal
|
||||
- **Real Doppler extraction** — Temporal phase-difference FFT across CSI history frames for true Doppler spectrum computation
|
||||
- **Three.js visualization** — 3D body model with 24 DensePose body parts, signal visualization, environment rendering, and WebSocket streaming
|
||||
- **Commodity sensing module** — `RssiFeatureExtractor` with FFT spectral analysis, CUSUM change detection, and `PresenceClassifier` with rule-based logic
|
||||
- **CI verification pipeline** — GitHub Actions workflow that verifies pipeline determinism and scans for unseeded random calls in production code
|
||||
- **Rust hardware adapters** — ESP32, Intel 5300, Atheros, UDP, and PCAP adapters now return explicit errors when no hardware is connected instead of silent empty data
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
- **Research Foundation**: Based on groundbreaking research in WiFi-based human sensing
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
WiFi-Mat v3.2 - AI Thermal Monitor + WiFi CSI Sensing
|
||||
======================================================
|
||||
|
||||
Embedded AI system combining thermal monitoring with WiFi-based
|
||||
presence detection, inspired by WiFi-DensePose technology.
|
||||
|
||||
For Heltec ESP32-S3 with OLED Display
|
||||
|
||||
CORE CAPABILITIES:
|
||||
------------------
|
||||
* Thermal Pattern Learning - Spiking Neural Network (LIF neurons)
|
||||
* WiFi CSI Sensing - Through-wall motion/presence detection
|
||||
* Breathing Detection - Respiratory rate from WiFi phase
|
||||
* Anomaly Detection - Ruvector-inspired attention weights
|
||||
* HNSW Indexing - Fast O(log n) pattern matching
|
||||
* Power Optimization - Adaptive sleep modes
|
||||
|
||||
VISUAL INDICATORS:
|
||||
------------------
|
||||
* Animated motion figure when movement detected
|
||||
* Radar sweep with detection blips
|
||||
* Breathing wave visualization with BPM
|
||||
* Status bar: WiFi/Motion/Alert icons
|
||||
* Screen flash on anomaly or motion alerts
|
||||
* Dynamic confidence bars
|
||||
|
||||
DISPLAY MODES (cycle with double-tap):
|
||||
--------------------------------------
|
||||
1. STATS - Temperature, zone, patterns, attention level
|
||||
2. GRAPH - Temperature history graph (40 samples)
|
||||
3. PTRNS - Learned pattern list with scores
|
||||
4. ANOM - Anomaly detection with trajectory view
|
||||
5. AI - Power optimization metrics
|
||||
6. CSI - WiFi CSI motion sensing with radar
|
||||
7. RF - RF device presence detection
|
||||
8. INFO - Device info, uptime, memory
|
||||
|
||||
AI POWER OPTIMIZATION (AI mode):
|
||||
--------------------------------
|
||||
* Mode: ACTIVE/LIGHT/DEEP sleep states
|
||||
* Energy: Estimated power savings (0-95%)
|
||||
* Neurons: Active vs idle neuron ratio
|
||||
* HNSW: Hierarchical search efficiency
|
||||
* Spikes: Neural spike efficiency
|
||||
* Attn: Pattern attention weights
|
||||
|
||||
WIFI CSI SENSING (CSI mode):
|
||||
----------------------------
|
||||
Uses WiFi Channel State Information for through-wall sensing:
|
||||
|
||||
* MOTION/STILL - Real-time motion detection
|
||||
* Radar Animation - Sweep with confidence blips
|
||||
* Breathing Wave - Sine wave + BPM when detected
|
||||
* Confidence % - Detection confidence level
|
||||
* Detection Count - Cumulative motion events
|
||||
* Variance Metrics - Signal variance analysis
|
||||
|
||||
Technology based on WiFi-DensePose concepts:
|
||||
- Phase unwrapping for movement detection
|
||||
- Amplitude variance for presence sensing
|
||||
- Frequency analysis for breathing rate
|
||||
- No cameras needed - works through walls
|
||||
|
||||
BUTTON CONTROLS:
|
||||
----------------
|
||||
* TAP (quick) - Learn current thermal pattern
|
||||
* DOUBLE-TAP - Cycle display mode
|
||||
* HOLD 1 second - Pause/Resume monitoring
|
||||
* HOLD 2 seconds - Reset all learned patterns
|
||||
* HOLD 3+ seconds - Show device info
|
||||
|
||||
INSTALLATION:
|
||||
-------------
|
||||
1. Connect Heltec ESP32-S3 via USB
|
||||
2. Run flash.bat (Windows) or flash.ps1 (PowerShell)
|
||||
3. Enter COM port when prompted (e.g., COM7)
|
||||
4. Wait for flash to complete (~60 seconds)
|
||||
5. Device auto-connects to configured WiFi
|
||||
|
||||
REQUIREMENTS:
|
||||
-------------
|
||||
* espflash tool: cargo install espflash
|
||||
* Heltec WiFi LoRa 32 V3 (ESP32-S3)
|
||||
* USB-C cable
|
||||
* Windows 10/11
|
||||
|
||||
WIFI CONFIGURATION:
|
||||
-------------------
|
||||
Default network: ruv.net
|
||||
|
||||
To change WiFi credentials, edit source and rebuild:
|
||||
C:\esp\src\main.rs (lines 43-44)
|
||||
|
||||
HARDWARE PINOUT:
|
||||
----------------
|
||||
* OLED SDA: GPIO17
|
||||
* OLED SCL: GPIO18
|
||||
* OLED RST: GPIO21
|
||||
* OLED PWR: GPIO36 (Vext)
|
||||
* Button: GPIO0 (PRG)
|
||||
* Thermal: MLX90614 on I2C
|
||||
|
||||
TECHNICAL SPECS:
|
||||
----------------
|
||||
* MCU: ESP32-S3 dual-core 240MHz
|
||||
* Flash: 8MB
|
||||
* RAM: 512KB SRAM + 8MB PSRAM
|
||||
* Display: 128x64 OLED (SSD1306)
|
||||
* WiFi: 802.11 b/g/n (2.4GHz)
|
||||
* Bluetooth: BLE 5.0
|
||||
|
||||
NEURAL NETWORK:
|
||||
---------------
|
||||
* Architecture: Leaky Integrate-and-Fire (LIF)
|
||||
* Neurons: 16 configurable
|
||||
* Patterns: Up to 32 learned
|
||||
* Features: 6 sparse dimensions
|
||||
* Indexing: 3-layer HNSW hierarchy
|
||||
|
||||
SOURCE CODE:
|
||||
------------
|
||||
Full Rust source: C:\esp\src\main.rs
|
||||
WiFi CSI module: C:\esp\src\wifi_csi.rs
|
||||
Build script: C:\esp\build.ps1
|
||||
|
||||
BASED ON:
|
||||
---------
|
||||
* Ruvector - Vector database with HNSW indexing
|
||||
* WiFi-DensePose - WiFi CSI for pose estimation
|
||||
* esp-rs - Rust on ESP32
|
||||
|
||||
LICENSE:
|
||||
--------
|
||||
Created with Claude Code
|
||||
https://github.com/ruvnet/wifi-densepose
|
||||
Binary file not shown.
@@ -1,173 +0,0 @@
|
||||
# ADR-001: WiFi-Mat Disaster Detection Architecture
|
||||
|
||||
## Status
|
||||
Accepted
|
||||
|
||||
## Date
|
||||
2026-01-13
|
||||
|
||||
## Context
|
||||
|
||||
Natural disasters such as earthquakes, building collapses, avalanches, and floods trap victims under rubble or debris. Traditional search and rescue methods using visual inspection, thermal cameras, or acoustic devices have significant limitations:
|
||||
|
||||
- **Visual/Optical**: Cannot penetrate rubble, debris, or collapsed structures
|
||||
- **Thermal**: Limited penetration depth, affected by ambient temperature
|
||||
- **Acoustic**: Requires victim to make sounds, high false positive rate
|
||||
- **K9 Units**: Limited availability, fatigue, environmental hazards
|
||||
|
||||
WiFi-based sensing offers a unique advantage: **RF signals can penetrate non-metallic debris** (concrete, wood, drywall) and detect subtle human movements including breathing patterns and heartbeats through Channel State Information (CSI) analysis.
|
||||
|
||||
### Problem Statement
|
||||
|
||||
We need a modular extension to the WiFi-DensePose Rust implementation that:
|
||||
|
||||
1. Detects human presence in disaster scenarios with high sensitivity
|
||||
2. Localizes survivors within rubble/debris fields
|
||||
3. Classifies victim status (conscious movement, breathing only, critical)
|
||||
4. Provides real-time alerts to rescue teams
|
||||
5. Operates in degraded/field conditions with portable hardware
|
||||
|
||||
## Decision
|
||||
|
||||
We will create a new crate `wifi-densepose-mat` (Mass Casualty Assessment Tool) as a modular addition to the existing Rust workspace with the following architecture:
|
||||
|
||||
### 1. Domain-Driven Design (DDD) Approach
|
||||
|
||||
The module follows DDD principles with clear bounded contexts:
|
||||
|
||||
```
|
||||
wifi-densepose-mat/
|
||||
├── src/
|
||||
│ ├── domain/ # Core domain entities and value objects
|
||||
│ │ ├── survivor.rs # Survivor entity with status tracking
|
||||
│ │ ├── disaster_event.rs # Disaster event aggregate root
|
||||
│ │ ├── scan_zone.rs # Geographic zone being scanned
|
||||
│ │ └── alert.rs # Alert value objects
|
||||
│ ├── detection/ # Life sign detection bounded context
|
||||
│ │ ├── breathing.rs # Breathing pattern detection
|
||||
│ │ ├── heartbeat.rs # Micro-doppler heartbeat detection
|
||||
│ │ ├── movement.rs # Gross/fine movement classification
|
||||
│ │ └── classifier.rs # Multi-modal victim classifier
|
||||
│ ├── localization/ # Position estimation bounded context
|
||||
│ │ ├── triangulation.rs # Multi-AP triangulation
|
||||
│ │ ├── fingerprinting.rs # CSI fingerprint matching
|
||||
│ │ └── depth.rs # Depth/layer estimation in rubble
|
||||
│ ├── alerting/ # Notification bounded context
|
||||
│ │ ├── priority.rs # Triage priority calculation
|
||||
│ │ ├── dispatcher.rs # Alert routing and dispatch
|
||||
│ │ └── protocols.rs # Emergency protocol integration
|
||||
│ └── integration/ # Anti-corruption layer
|
||||
│ ├── signal_adapter.rs # Adapts wifi-densepose-signal
|
||||
│ └── nn_adapter.rs # Adapts wifi-densepose-nn
|
||||
```
|
||||
|
||||
### 2. Core Architectural Decisions
|
||||
|
||||
#### 2.1 Event-Driven Architecture
|
||||
- All survivor detections emit domain events
|
||||
- Events enable audit trails and replay for post-incident analysis
|
||||
- Supports distributed deployments with multiple scan teams
|
||||
|
||||
#### 2.2 Configurable Detection Pipeline
|
||||
```rust
|
||||
pub struct DetectionPipeline {
|
||||
breathing_detector: BreathingDetector,
|
||||
heartbeat_detector: HeartbeatDetector,
|
||||
movement_classifier: MovementClassifier,
|
||||
ensemble_classifier: EnsembleClassifier,
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.3 Triage Classification (START Protocol Compatible)
|
||||
| Status | Detection Criteria | Priority |
|
||||
|--------|-------------------|----------|
|
||||
| Immediate (Red) | Breathing detected, no movement | P1 |
|
||||
| Delayed (Yellow) | Movement + breathing, stable vitals | P2 |
|
||||
| Minor (Green) | Strong movement, responsive patterns | P3 |
|
||||
| Deceased (Black) | No vitals for >30 minutes continuous scan | P4 |
|
||||
|
||||
#### 2.4 Hardware Abstraction
|
||||
Supports multiple deployment scenarios:
|
||||
- **Portable**: Single TX/RX with handheld device
|
||||
- **Distributed**: Multiple APs deployed around collapse site
|
||||
- **Drone-mounted**: UAV-based scanning for large areas
|
||||
- **Vehicle-mounted**: Mobile command post with array
|
||||
|
||||
### 3. Integration Strategy
|
||||
|
||||
The module integrates with existing crates through adapters:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ wifi-densepose-mat │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
|
||||
│ │ Detection │ │ Localization│ │ Alerting │ │
|
||||
│ │ Context │ │ Context │ │ Context │ │
|
||||
│ └──────┬──────┘ └──────┬──────┘ └──────────┬──────────┘ │
|
||||
│ │ │ │ │
|
||||
│ └────────────────┼─────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────▼───────────┐ │
|
||||
│ │ Integration Layer │ │
|
||||
│ │ (Anti-Corruption) │ │
|
||||
│ └───────────┬───────────┘ │
|
||||
└──────────────────────────┼───────────────────────────────────┘
|
||||
│
|
||||
┌──────────────────┼──────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
|
||||
│wifi-densepose │ │wifi-densepose │ │wifi-densepose │
|
||||
│ -signal │ │ -nn │ │ -hardware │
|
||||
└───────────────┘ └───────────────┘ └───────────────┘
|
||||
```
|
||||
|
||||
### 4. Performance Requirements
|
||||
|
||||
| Metric | Target | Rationale |
|
||||
|--------|--------|-----------|
|
||||
| Detection Latency | <500ms | Real-time feedback for rescuers |
|
||||
| False Positive Rate | <5% | Minimize wasted rescue efforts |
|
||||
| False Negative Rate | <1% | Cannot miss survivors |
|
||||
| Penetration Depth | 3-5m | Typical rubble pile depth |
|
||||
| Battery Life (portable) | >8 hours | Full shift operation |
|
||||
| Concurrent Zones | 16+ | Large disaster site coverage |
|
||||
|
||||
### 5. Safety and Reliability
|
||||
|
||||
- **Fail-safe defaults**: Always assume life present on ambiguous signals
|
||||
- **Redundant detection**: Multiple algorithms vote on presence
|
||||
- **Continuous monitoring**: Re-scan zones periodically
|
||||
- **Offline operation**: Full functionality without network
|
||||
- **Audit logging**: Complete trace of all detections
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- Modular design allows independent development and testing
|
||||
- DDD ensures domain experts can validate logic
|
||||
- Event-driven enables distributed deployments
|
||||
- Adapters isolate from upstream changes
|
||||
- Compatible with existing WiFi-DensePose infrastructure
|
||||
|
||||
### Negative
|
||||
- Additional complexity from event system
|
||||
- Learning curve for rescue teams
|
||||
- Requires calibration for different debris types
|
||||
- RF interference in disaster zones
|
||||
|
||||
### Risks and Mitigations
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Metal debris blocking signals | Multi-angle scanning, adaptive frequency |
|
||||
| Environmental RF interference | Spectral sensing, frequency hopping |
|
||||
| False positives from animals | Size/pattern classification |
|
||||
| Power constraints in field | Low-power modes, solar charging |
|
||||
|
||||
## References
|
||||
|
||||
- [WiFi-based Vital Signs Monitoring](https://dl.acm.org/doi/10.1145/3130944)
|
||||
- [Through-Wall Human Sensing](https://ieeexplore.ieee.org/document/8645344)
|
||||
- [START Triage Protocol](https://www.ncbi.nlm.nih.gov/pmc/articles/PMC3088332/)
|
||||
- [CSI-based Human Activity Recognition](https://arxiv.org/abs/2004.03661)
|
||||
@@ -1,207 +0,0 @@
|
||||
# ADR-002: RuVector RVF Integration Strategy
|
||||
|
||||
## Status
|
||||
Proposed
|
||||
|
||||
## Date
|
||||
2026-02-28
|
||||
|
||||
## Context
|
||||
|
||||
### Current System Limitations
|
||||
|
||||
The WiFi-DensePose system processes Channel State Information (CSI) from WiFi signals to estimate human body poses. The current architecture (Python v1 + Rust port) has several areas where intelligence and performance could be significantly improved:
|
||||
|
||||
1. **No persistent vector storage**: CSI feature vectors are processed transiently. Historical patterns, fingerprints, and learned representations are not persisted in a searchable vector database.
|
||||
|
||||
2. **Static inference models**: The modality translation network (`ModalityTranslationNetwork`) and DensePose head use fixed weights loaded at startup. There is no online learning, adaptation, or self-optimization.
|
||||
|
||||
3. **Naive pattern matching**: Human detection in `CSIProcessor` uses simple threshold-based confidence scoring (`amplitude_indicator`, `phase_indicator`, `motion_indicator` with fixed weights 0.4, 0.3, 0.3). No similarity search against known patterns.
|
||||
|
||||
4. **No cryptographic audit trail**: Life-critical disaster detection (wifi-densepose-mat) lacks tamper-evident logging for survivor detections and triage classifications.
|
||||
|
||||
5. **Limited edge deployment**: The WASM crate (`wifi-densepose-wasm`) provides basic bindings but lacks a self-contained runtime capable of offline operation with embedded models.
|
||||
|
||||
6. **Single-node architecture**: Multi-AP deployments for disaster scenarios require distributed coordination, but no consensus mechanism exists for cross-node state management.
|
||||
|
||||
### RuVector Capabilities
|
||||
|
||||
RuVector (github.com/ruvnet/ruvector) provides a comprehensive cognitive computing platform:
|
||||
|
||||
- **RVF (Cognitive Containers)**: Self-contained files with 25 segment types (VEC, INDEX, KERNEL, EBPF, WASM, COW_MAP, WITNESS, CRYPTO) that package vectors, models, and runtime into a single deployable artifact
|
||||
- **HNSW Vector Search**: Hierarchical Navigable Small World indexing with SIMD acceleration and Hyperbolic extensions for hierarchy-aware search
|
||||
- **SONA**: Self-Optimizing Neural Architecture providing <1ms adaptation via LoRA fine-tuning with EWC++ memory preservation
|
||||
- **GNN Learning Layer**: Graph Neural Networks that learn from every query through message passing, attention weighting, and representation updates
|
||||
- **46 Attention Mechanisms**: Including Flash Attention, Linear Attention, Graph Attention, Hyperbolic Attention, Mincut-gated Attention
|
||||
- **Post-Quantum Cryptography**: ML-DSA-65, Ed25519, SLH-DSA-128s signatures with SHAKE-256 hashing
|
||||
- **Witness Chains**: Tamper-evident cryptographic hash-linked audit trails
|
||||
- **Raft Consensus**: Distributed coordination with multi-master replication and vector clocks
|
||||
- **WASM Runtime**: 5.5 KB runtime bootable in 125ms, deployable on servers, browsers, phones, IoT
|
||||
- **Git-like Branching**: Copy-on-write structure (1M vectors + 100 edits ≈ 2.5 MB branch)
|
||||
|
||||
## Decision
|
||||
|
||||
We will integrate RuVector's RVF format and intelligence capabilities into the WiFi-DensePose system through a phased, modular approach across 9 integration domains, each detailed in subsequent ADRs (ADR-003 through ADR-010).
|
||||
|
||||
### Integration Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ WiFi-DensePose + RuVector │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ CSI Input │ │ RVF Store │ │ SONA │ │ GNN Layer │ │
|
||||
│ │ Pipeline │──▶│ (Vectors, │──▶│ Self-Learn │──▶│ Pattern │ │
|
||||
│ │ │ │ Indices) │ │ │ │ Enhancement │ │
|
||||
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
|
||||
│ │ │ │ │ │
|
||||
│ ▼ ▼ ▼ ▼ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ Feature │ │ HNSW │ │ Adaptive │ │ Pose │ │
|
||||
│ │ Extraction │ │ Search │ │ Weights │ │ Estimation │ │
|
||||
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
|
||||
│ │ │ │ │ │
|
||||
│ └─────────────────┴─────────────────┴─────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────▼──────────┐ │
|
||||
│ │ Output Layer │ │
|
||||
│ │ • Pose Keypoints │ │
|
||||
│ │ • Body Segments │ │
|
||||
│ │ • UV Coordinates │ │
|
||||
│ │ • Confidence Maps │ │
|
||||
│ └──────────┬──────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────────────────────┼───────────────────────────┐ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ Witness │ │ Raft │ │ WASM │ │
|
||||
│ │ Chains │ │ Consensus │ │ Edge │ │
|
||||
│ │ (Audit) │ │ (Multi-AP) │ │ Runtime │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Post-Quantum Crypto Layer │ │
|
||||
│ │ ML-DSA-65 │ Ed25519 │ SLH-DSA-128s │ SHAKE-256 │ │
|
||||
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### New Crate: `wifi-densepose-rvf`
|
||||
|
||||
A new workspace member crate will serve as the integration layer:
|
||||
|
||||
```
|
||||
crates/wifi-densepose-rvf/
|
||||
├── Cargo.toml
|
||||
├── src/
|
||||
│ ├── lib.rs # Public API surface
|
||||
│ ├── container.rs # RVF cognitive container management
|
||||
│ ├── vector_store.rs # HNSW-backed CSI vector storage
|
||||
│ ├── search.rs # Similarity search for fingerprinting
|
||||
│ ├── learning.rs # SONA integration for online learning
|
||||
│ ├── gnn.rs # GNN pattern enhancement layer
|
||||
│ ├── attention.rs # Attention mechanism selection
|
||||
│ ├── witness.rs # Witness chain audit trails
|
||||
│ ├── consensus.rs # Raft consensus for multi-AP
|
||||
│ ├── crypto.rs # Post-quantum crypto wrappers
|
||||
│ ├── edge.rs # WASM edge runtime integration
|
||||
│ └── adapters/
|
||||
│ ├── mod.rs
|
||||
│ ├── signal_adapter.rs # Bridges wifi-densepose-signal
|
||||
│ ├── nn_adapter.rs # Bridges wifi-densepose-nn
|
||||
│ └── mat_adapter.rs # Bridges wifi-densepose-mat
|
||||
```
|
||||
|
||||
### Phased Rollout
|
||||
|
||||
| Phase | Timeline | ADR | Capability | Priority |
|
||||
|-------|----------|-----|------------|----------|
|
||||
| 1 | Weeks 1-3 | ADR-003 | RVF Cognitive Containers for CSI Data | Critical |
|
||||
| 2 | Weeks 2-4 | ADR-004 | HNSW Vector Search for Signal Fingerprinting | Critical |
|
||||
| 3 | Weeks 4-6 | ADR-005 | SONA Self-Learning for Pose Estimation | High |
|
||||
| 4 | Weeks 5-7 | ADR-006 | GNN-Enhanced CSI Pattern Recognition | High |
|
||||
| 5 | Weeks 6-8 | ADR-007 | Post-Quantum Cryptography for Secure Sensing | Medium |
|
||||
| 6 | Weeks 7-9 | ADR-008 | Distributed Consensus for Multi-AP | Medium |
|
||||
| 7 | Weeks 8-10 | ADR-009 | RVF WASM Runtime for Edge Deployment | Medium |
|
||||
| 8 | Weeks 9-11 | ADR-010 | Witness Chains for Audit Trail Integrity | High (MAT) |
|
||||
|
||||
### Dependency Strategy
|
||||
|
||||
```toml
|
||||
# In Cargo.toml workspace dependencies
|
||||
[workspace.dependencies]
|
||||
ruvector-core = { version = "0.1", features = ["hnsw", "sona", "gnn"] }
|
||||
ruvector-data-framework = { version = "0.1", features = ["rvf", "witness", "crypto"] }
|
||||
ruvector-consensus = { version = "0.1", features = ["raft"] }
|
||||
ruvector-wasm = { version = "0.1", features = ["edge-runtime"] }
|
||||
```
|
||||
|
||||
Feature flags control which RuVector capabilities are compiled in:
|
||||
|
||||
```toml
|
||||
[features]
|
||||
default = ["rvf-store", "hnsw-search"]
|
||||
rvf-store = ["ruvector-data-framework/rvf"]
|
||||
hnsw-search = ["ruvector-core/hnsw"]
|
||||
sona-learning = ["ruvector-core/sona"]
|
||||
gnn-patterns = ["ruvector-core/gnn"]
|
||||
post-quantum = ["ruvector-data-framework/crypto"]
|
||||
witness-chains = ["ruvector-data-framework/witness"]
|
||||
raft-consensus = ["ruvector-consensus/raft"]
|
||||
wasm-edge = ["ruvector-wasm/edge-runtime"]
|
||||
full = ["rvf-store", "hnsw-search", "sona-learning", "gnn-patterns", "post-quantum", "witness-chains", "raft-consensus", "wasm-edge"]
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **10-100x faster pattern lookup**: HNSW replaces linear scan for CSI fingerprint matching
|
||||
- **Continuous improvement**: SONA enables online adaptation without full retraining
|
||||
- **Self-contained deployment**: RVF containers package everything needed for field operation
|
||||
- **Tamper-evident records**: Witness chains provide cryptographic proof for disaster response auditing
|
||||
- **Future-proof security**: Post-quantum signatures resist quantum computing attacks
|
||||
- **Distributed operation**: Raft consensus enables coordinated multi-AP sensing
|
||||
- **Ultra-light edge**: 5.5 KB WASM runtime enables browser and IoT deployment
|
||||
- **Git-like versioning**: COW branching enables experimental model variations with minimal storage
|
||||
|
||||
### Negative
|
||||
|
||||
- **Increased binary size**: Full feature set adds significant dependencies (~15-30 MB)
|
||||
- **Complexity**: 9 integration domains require careful coordination
|
||||
- **Learning curve**: Team must understand RuVector's cognitive container paradigm
|
||||
- **API stability risk**: RuVector is pre-1.0; APIs may change
|
||||
- **Testing surface**: Each integration point requires dedicated test suites
|
||||
|
||||
### Risks and Mitigations
|
||||
|
||||
| Risk | Severity | Mitigation |
|
||||
|------|----------|------------|
|
||||
| RuVector API breaking changes | High | Pin versions, adapter pattern isolates impact |
|
||||
| Performance regression from abstraction layers | Medium | Benchmark each integration point, zero-cost abstractions |
|
||||
| Feature flag combinatorial complexity | Medium | CI matrix testing for key feature combinations |
|
||||
| Over-engineering for current use cases | Medium | Phased rollout, each phase independently valuable |
|
||||
| Binary size bloat for edge targets | Low | Feature flags ensure only needed capabilities compile |
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- **ADR-001**: WiFi-Mat Disaster Detection Architecture (existing)
|
||||
- **ADR-003**: RVF Cognitive Containers for CSI Data
|
||||
- **ADR-004**: HNSW Vector Search for Signal Fingerprinting
|
||||
- **ADR-005**: SONA Self-Learning for Pose Estimation
|
||||
- **ADR-006**: GNN-Enhanced CSI Pattern Recognition
|
||||
- **ADR-007**: Post-Quantum Cryptography for Secure Sensing
|
||||
- **ADR-008**: Distributed Consensus for Multi-AP Coordination
|
||||
- **ADR-009**: RVF WASM Runtime for Edge Deployment
|
||||
- **ADR-010**: Witness Chains for Audit Trail Integrity
|
||||
|
||||
## References
|
||||
|
||||
- [RuVector Repository](https://github.com/ruvnet/ruvector)
|
||||
- [HNSW Algorithm](https://arxiv.org/abs/1603.09320)
|
||||
- [LoRA: Low-Rank Adaptation](https://arxiv.org/abs/2106.09685)
|
||||
- [Elastic Weight Consolidation](https://arxiv.org/abs/1612.00796)
|
||||
- [Raft Consensus](https://raft.github.io/raft.pdf)
|
||||
- [ML-DSA (FIPS 204)](https://csrc.nist.gov/pubs/fips/204/final)
|
||||
- [WiFi-DensePose Rust ADR-001: Workspace Structure](../rust-port/wifi-densepose-rs/docs/adr/ADR-001-workspace-structure.md)
|
||||
@@ -1,251 +0,0 @@
|
||||
# ADR-003: RVF Cognitive Containers for CSI Data
|
||||
|
||||
## Status
|
||||
Proposed
|
||||
|
||||
## Date
|
||||
2026-02-28
|
||||
|
||||
## Context
|
||||
|
||||
### Problem
|
||||
|
||||
WiFi-DensePose processes CSI (Channel State Information) data through a multi-stage pipeline: raw capture → preprocessing → feature extraction → neural inference → pose output. Each stage produces intermediate data that is currently ephemeral:
|
||||
|
||||
1. **Raw CSI measurements** (`CsiData`): Amplitude matrices (num_antennas x num_subcarriers), phase arrays, SNR values, metadata. Stored only in a bounded `VecDeque` (max 500 entries in Python, similar in Rust).
|
||||
|
||||
2. **Extracted features** (`CsiFeatures`): Amplitude mean/variance, phase differences, correlation matrices, Doppler shifts, power spectral density. Discarded after single-pass inference.
|
||||
|
||||
3. **Trained model weights**: Static ONNX/PyTorch files loaded from disk. No mechanism to persist adapted weights or experimental variations.
|
||||
|
||||
4. **Detection results** (`HumanDetectionResult`): Confidence scores, motion scores, detection booleans. Logged but not indexed for pattern retrieval.
|
||||
|
||||
5. **Environment fingerprints**: Each physical space has a unique CSI signature affected by room geometry, furniture, building materials. No persistent fingerprint database exists.
|
||||
|
||||
### Opportunity
|
||||
|
||||
RuVector's RVF (Cognitive Container) format provides a single-file packaging solution with 25 segment types that can encapsulate the entire WiFi-DensePose operational state:
|
||||
|
||||
```
|
||||
RVF Cognitive Container Structure:
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ HEADER │ Magic, version, segment count │
|
||||
├───────────┼─────────────────────────────────┤
|
||||
│ VEC │ CSI feature vectors │
|
||||
│ INDEX │ HNSW index over vectors │
|
||||
│ WASM │ Inference runtime │
|
||||
│ COW_MAP │ Copy-on-write branch state │
|
||||
│ WITNESS │ Audit chain entries │
|
||||
│ CRYPTO │ Signature keys, attestations │
|
||||
│ KERNEL │ Bootable runtime (optional) │
|
||||
│ EBPF │ Hardware-accelerated filters │
|
||||
│ ... │ (25 total segment types) │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Decision
|
||||
|
||||
We will adopt the RVF Cognitive Container format as the primary persistence and deployment unit for WiFi-DensePose operational data, implementing the following container types:
|
||||
|
||||
### 1. CSI Fingerprint Container (`.rvf.csi`)
|
||||
|
||||
Packages environment-specific CSI signatures for location recognition:
|
||||
|
||||
```rust
|
||||
/// CSI Fingerprint container storing environment signatures
|
||||
pub struct CsiFingerprintContainer {
|
||||
/// Container metadata
|
||||
metadata: ContainerMetadata,
|
||||
|
||||
/// VEC segment: Normalized CSI feature vectors
|
||||
/// Each vector = [amplitude_mean(N) | amplitude_var(N) | phase_diff(N-1) | doppler(10) | psd(128)]
|
||||
/// Typical dimensionality: 64 subcarriers → 64+64+63+10+128 = 329 dimensions
|
||||
fingerprint_vectors: VecSegment,
|
||||
|
||||
/// INDEX segment: HNSW index for O(log n) nearest-neighbor lookup
|
||||
hnsw_index: IndexSegment,
|
||||
|
||||
/// COW_MAP: Branches for different times-of-day, occupancy levels
|
||||
branches: CowMapSegment,
|
||||
|
||||
/// Metadata per vector: room_id, timestamp, occupancy_count, furniture_hash
|
||||
annotations: AnnotationSegment,
|
||||
}
|
||||
```
|
||||
|
||||
**Vector encoding**: Each CSI snapshot is encoded as a fixed-dimension vector:
|
||||
```
|
||||
CSI Feature Vector (329-dim for 64 subcarriers):
|
||||
┌──────────────────┬──────────────────┬─────────────────┬──────────┬─────────┐
|
||||
│ amplitude_mean │ amplitude_var │ phase_diff │ doppler │ psd │
|
||||
│ [f32; 64] │ [f32; 64] │ [f32; 63] │ [f32; 10]│ [f32;128│
|
||||
└──────────────────┴──────────────────┴─────────────────┴──────────┴─────────┘
|
||||
```
|
||||
|
||||
### 2. Model Container (`.rvf.model`)
|
||||
|
||||
Packages neural network weights with versioning:
|
||||
|
||||
```rust
|
||||
/// Model container with version tracking and A/B comparison
|
||||
pub struct ModelContainer {
|
||||
/// Container metadata with model version history
|
||||
metadata: ContainerMetadata,
|
||||
|
||||
/// Primary model weights (ONNX serialized)
|
||||
primary_weights: BlobSegment,
|
||||
|
||||
/// SONA adaptation deltas (LoRA low-rank matrices)
|
||||
adaptation_deltas: VecSegment,
|
||||
|
||||
/// COW branches for model experiments
|
||||
/// e.g., "baseline", "adapted-office-env", "adapted-warehouse"
|
||||
branches: CowMapSegment,
|
||||
|
||||
/// Performance metrics per branch
|
||||
metrics: AnnotationSegment,
|
||||
|
||||
/// Witness chain: every weight update recorded
|
||||
audit_trail: WitnessSegment,
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Session Container (`.rvf.session`)
|
||||
|
||||
Captures a complete sensing session for replay and analysis:
|
||||
|
||||
```rust
|
||||
/// Session container for recording and replaying sensing sessions
|
||||
pub struct SessionContainer {
|
||||
/// Session metadata (start time, duration, hardware config)
|
||||
metadata: ContainerMetadata,
|
||||
|
||||
/// Time-series CSI vectors at capture rate
|
||||
csi_timeseries: VecSegment,
|
||||
|
||||
/// Detection results aligned to CSI timestamps
|
||||
detections: AnnotationSegment,
|
||||
|
||||
/// Pose estimation outputs
|
||||
poses: VecSegment,
|
||||
|
||||
/// Index for temporal range queries
|
||||
temporal_index: IndexSegment,
|
||||
|
||||
/// Cryptographic integrity proof
|
||||
witness_chain: WitnessSegment,
|
||||
}
|
||||
```
|
||||
|
||||
### Container Lifecycle
|
||||
|
||||
```
|
||||
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ Create │───▶│ Ingest │───▶│ Query │───▶│ Branch │
|
||||
│ Container │ │ Vectors │ │ (HNSW) │ │ (COW) │
|
||||
└──────────┘ └──────────┘ └──────────┘ └──────────┘
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ Merge │◀───│ Compare │◀─────────┘
|
||||
│ │ Branches │ │ Results │
|
||||
│ └────┬─────┘ └──────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌──────────┐ ┌──────────┐
|
||||
│ Export │ │ Deploy │
|
||||
│ (.rvf) │ │ (Edge) │
|
||||
└──────────┘ └──────────┘
|
||||
```
|
||||
|
||||
### Integration with Existing Crates
|
||||
|
||||
The container system integrates through adapter traits:
|
||||
|
||||
```rust
|
||||
/// Trait for types that can be vectorized into RVF containers
|
||||
pub trait RvfVectorizable {
|
||||
/// Encode self as a fixed-dimension f32 vector
|
||||
fn to_rvf_vector(&self) -> Vec<f32>;
|
||||
|
||||
/// Reconstruct from an RVF vector
|
||||
fn from_rvf_vector(vec: &[f32]) -> Result<Self, RvfError> where Self: Sized;
|
||||
|
||||
/// Vector dimensionality
|
||||
fn vector_dim() -> usize;
|
||||
}
|
||||
|
||||
// Implementation for existing types
|
||||
impl RvfVectorizable for CsiFeatures {
|
||||
fn to_rvf_vector(&self) -> Vec<f32> {
|
||||
let mut vec = Vec::with_capacity(Self::vector_dim());
|
||||
vec.extend(self.amplitude_mean.iter().map(|&x| x as f32));
|
||||
vec.extend(self.amplitude_variance.iter().map(|&x| x as f32));
|
||||
vec.extend(self.phase_difference.iter().map(|&x| x as f32));
|
||||
vec.extend(self.doppler_shift.iter().map(|&x| x as f32));
|
||||
vec.extend(self.power_spectral_density.iter().map(|&x| x as f32));
|
||||
vec
|
||||
}
|
||||
|
||||
fn vector_dim() -> usize {
|
||||
// 64 + 64 + 63 + 10 + 128 = 329 (for 64 subcarriers)
|
||||
329
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Storage Characteristics
|
||||
|
||||
| Container Type | Typical Size | Vector Count | Use Case |
|
||||
|----------------|-------------|-------------|----------|
|
||||
| Fingerprint | 5-50 MB | 10K-100K | Room/building fingerprint DB |
|
||||
| Model | 50-500 MB | N/A (blob) | Neural network deployment |
|
||||
| Session | 10-200 MB | 50K-500K | 1-hour recording at 100 Hz |
|
||||
|
||||
### COW Branching for Environment Adaptation
|
||||
|
||||
The copy-on-write mechanism enables zero-overhead experimentation:
|
||||
|
||||
```
|
||||
main (office baseline: 50K vectors)
|
||||
├── branch/morning (delta: 500 vectors, ~15 KB)
|
||||
├── branch/afternoon (delta: 800 vectors, ~24 KB)
|
||||
├── branch/occupied-10 (delta: 2K vectors, ~60 KB)
|
||||
└── branch/furniture-moved (delta: 5K vectors, ~150 KB)
|
||||
```
|
||||
|
||||
Total overhead for 4 branches on a 50K-vector container: ~250 KB additional (0.5%).
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- **Single-file deployment**: Move a fingerprint database between sites by copying one `.rvf` file
|
||||
- **Versioned models**: A/B test model variants without duplicating full weight sets
|
||||
- **Session replay**: Reproduce detection results from recorded CSI data
|
||||
- **Atomic operations**: Container writes are transactional; no partial state corruption
|
||||
- **Cross-platform**: Same container format works on server, WASM, and embedded
|
||||
- **Storage efficient**: COW branching avoids duplicating unchanged data
|
||||
|
||||
### Negative
|
||||
- **Format lock-in**: RVF is not yet a widely-adopted standard
|
||||
- **Serialization overhead**: Converting between native types and RVF vectors adds latency (~0.1-0.5 ms per vector)
|
||||
- **Learning curve**: Team must understand segment types and container lifecycle
|
||||
- **File size for sessions**: High-rate CSI capture (1000 Hz) generates large session containers
|
||||
|
||||
### Performance Targets
|
||||
|
||||
| Operation | Target Latency | Notes |
|
||||
|-----------|---------------|-------|
|
||||
| Container open | <10 ms | Memory-mapped I/O |
|
||||
| Vector insert | <0.1 ms | Append to VEC segment |
|
||||
| HNSW query (100K vectors) | <1 ms | See ADR-004 |
|
||||
| Branch create | <1 ms | COW metadata only |
|
||||
| Branch merge | <100 ms | Delta application |
|
||||
| Container export | ~1 ms/MB | Sequential write |
|
||||
|
||||
## References
|
||||
|
||||
- [RuVector Cognitive Container Specification](https://github.com/ruvnet/ruvector)
|
||||
- [Memory-Mapped I/O in Rust](https://docs.rs/memmap2)
|
||||
- [Copy-on-Write Data Structures](https://en.wikipedia.org/wiki/Copy-on-write)
|
||||
- ADR-002: RuVector RVF Integration Strategy
|
||||
@@ -1,270 +0,0 @@
|
||||
# ADR-004: HNSW Vector Search for Signal Fingerprinting
|
||||
|
||||
## Status
|
||||
Proposed
|
||||
|
||||
## Date
|
||||
2026-02-28
|
||||
|
||||
## Context
|
||||
|
||||
### Current Signal Matching Limitations
|
||||
|
||||
The WiFi-DensePose system needs to match incoming CSI patterns against known signatures for:
|
||||
|
||||
1. **Environment recognition**: Identifying which room/area the device is in based on CSI characteristics
|
||||
2. **Activity classification**: Matching current CSI patterns to known human activities (walking, sitting, falling)
|
||||
3. **Anomaly detection**: Determining whether current readings deviate significantly from baseline
|
||||
4. **Survivor re-identification** (MAT module): Tracking individual survivors across scan sessions
|
||||
|
||||
Current approach in `CSIProcessor._calculate_detection_confidence()`:
|
||||
```python
|
||||
# Fixed thresholds, no similarity search
|
||||
amplitude_indicator = np.mean(features.amplitude_mean) > 0.1
|
||||
phase_indicator = np.std(features.phase_difference) > 0.05
|
||||
motion_indicator = motion_score > 0.3
|
||||
confidence = (0.4 * amplitude_indicator + 0.3 * phase_indicator + 0.3 * motion_indicator)
|
||||
```
|
||||
|
||||
This is a **O(1) fixed-threshold check** that:
|
||||
- Cannot learn from past observations
|
||||
- Has no concept of "similar patterns seen before"
|
||||
- Requires manual threshold tuning per environment
|
||||
- Produces binary indicators (above/below threshold) losing gradient information
|
||||
|
||||
### What HNSW Provides
|
||||
|
||||
Hierarchical Navigable Small World (HNSW) graphs enable approximate nearest-neighbor search in high-dimensional vector spaces with:
|
||||
|
||||
- **O(log n) query time** vs O(n) brute-force
|
||||
- **High recall**: >95% recall at 10x speed of exact search
|
||||
- **Dynamic insertion**: New vectors added without full rebuild
|
||||
- **SIMD acceleration**: RuVector's implementation uses AVX2/NEON for distance calculations
|
||||
|
||||
RuVector extends standard HNSW with:
|
||||
- **Hyperbolic HNSW**: Search in Poincaré ball space for hierarchy-aware results (e.g., "walking" is closer to "running" than to "sitting" in activity hierarchy)
|
||||
- **GNN enhancement**: Graph neural networks refine neighbor connections after queries
|
||||
- **Tiered compression**: 2-32x memory reduction through adaptive quantization
|
||||
|
||||
## Decision
|
||||
|
||||
We will integrate RuVector's HNSW implementation as the primary similarity search engine for all CSI pattern matching operations, replacing fixed-threshold detection with similarity-based retrieval.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ HNSW Search Pipeline │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ CSI Input Feature Vector HNSW │
|
||||
│ ────────▶ Extraction ────▶ Encode ────▶ Search │
|
||||
│ (existing) (new) (new) │
|
||||
│ │ │
|
||||
│ ┌─────────────┤ │
|
||||
│ ▼ ▼ │
|
||||
│ Top-K Results Confidence │
|
||||
│ [vec_id, dist, Score from │
|
||||
│ metadata] Distance Dist. │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌────────────┐ │
|
||||
│ │ Decision │ │
|
||||
│ │ Fusion │ │
|
||||
│ └────────────┘ │
|
||||
│ Combines HNSW similarity with │
|
||||
│ existing threshold-based logic │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Index Configuration
|
||||
|
||||
```rust
|
||||
/// HNSW configuration tuned for CSI vector characteristics
|
||||
pub struct CsiHnswConfig {
|
||||
/// Vector dimensionality (matches CsiFeatures encoding)
|
||||
dim: usize, // 329 for 64 subcarriers
|
||||
|
||||
/// Maximum number of connections per node per layer
|
||||
/// Higher M = better recall, more memory
|
||||
/// CSI vectors are moderately dimensional; M=16 balances well
|
||||
m: usize, // 16
|
||||
|
||||
/// Size of dynamic candidate list during construction
|
||||
/// ef_construction = 200 gives >99% recall for 329-dim vectors
|
||||
ef_construction: usize, // 200
|
||||
|
||||
/// Size of dynamic candidate list during search
|
||||
/// ef_search = 64 gives >95% recall with <1ms latency at 100K vectors
|
||||
ef_search: usize, // 64
|
||||
|
||||
/// Distance metric
|
||||
/// Cosine similarity works best for normalized CSI features
|
||||
metric: DistanceMetric, // Cosine
|
||||
|
||||
/// Maximum elements (pre-allocated for performance)
|
||||
max_elements: usize, // 1_000_000
|
||||
|
||||
/// Enable SIMD acceleration
|
||||
simd: bool, // true
|
||||
|
||||
/// Quantization level for memory reduction
|
||||
quantization: Quantization, // PQ8 (product quantization, 8-bit)
|
||||
}
|
||||
```
|
||||
|
||||
### Multiple Index Strategy
|
||||
|
||||
Different use cases require different index configurations:
|
||||
|
||||
| Index Name | Vectors | Dim | Distance | Use Case |
|
||||
|-----------|---------|-----|----------|----------|
|
||||
| `env_fingerprint` | 10K-1M | 329 | Cosine | Environment/room identification |
|
||||
| `activity_pattern` | 1K-50K | 329 | Euclidean | Activity classification |
|
||||
| `temporal_pattern` | 10K-500K | 329 | Cosine | Temporal anomaly detection |
|
||||
| `survivor_track` | 100-10K | 329 | Cosine | MAT survivor re-identification |
|
||||
|
||||
### Similarity-Based Detection Enhancement
|
||||
|
||||
Replace fixed thresholds with distance-based confidence:
|
||||
|
||||
```rust
|
||||
/// Enhanced detection using HNSW similarity search
|
||||
pub struct SimilarityDetector {
|
||||
/// HNSW index of known human-present CSI patterns
|
||||
human_patterns: HnswIndex,
|
||||
|
||||
/// HNSW index of known empty-room CSI patterns
|
||||
empty_patterns: HnswIndex,
|
||||
|
||||
/// Fusion weight between similarity and threshold methods
|
||||
fusion_alpha: f64, // 0.7 = 70% similarity, 30% threshold
|
||||
}
|
||||
|
||||
impl SimilarityDetector {
|
||||
/// Detect human presence using similarity search + threshold fusion
|
||||
pub fn detect(&self, features: &CsiFeatures) -> DetectionResult {
|
||||
let query_vec = features.to_rvf_vector();
|
||||
|
||||
// Search both indices
|
||||
let human_neighbors = self.human_patterns.search(&query_vec, k=5);
|
||||
let empty_neighbors = self.empty_patterns.search(&query_vec, k=5);
|
||||
|
||||
// Distance-based confidence
|
||||
let avg_human_dist = human_neighbors.mean_distance();
|
||||
let avg_empty_dist = empty_neighbors.mean_distance();
|
||||
|
||||
// Similarity confidence: how much closer to human patterns vs empty
|
||||
let similarity_confidence = avg_empty_dist / (avg_human_dist + avg_empty_dist);
|
||||
|
||||
// Fuse with traditional threshold-based confidence
|
||||
let threshold_confidence = self.traditional_threshold_detect(features);
|
||||
let fused_confidence = self.fusion_alpha * similarity_confidence
|
||||
+ (1.0 - self.fusion_alpha) * threshold_confidence;
|
||||
|
||||
DetectionResult {
|
||||
human_detected: fused_confidence > 0.5,
|
||||
confidence: fused_confidence,
|
||||
similarity_confidence,
|
||||
threshold_confidence,
|
||||
nearest_human_pattern: human_neighbors[0].metadata.clone(),
|
||||
nearest_empty_pattern: empty_neighbors[0].metadata.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Incremental Learning Loop
|
||||
|
||||
Every confirmed detection enriches the index:
|
||||
|
||||
```
|
||||
1. CSI captured → features extracted → vector encoded
|
||||
2. HNSW search returns top-K neighbors + distances
|
||||
3. Detection decision made (similarity + threshold fusion)
|
||||
4. If confirmed (by temporal consistency or ground truth):
|
||||
a. Insert vector into appropriate index (human/empty)
|
||||
b. GNN layer updates neighbor relationships (ADR-006)
|
||||
c. SONA adapts fusion weights (ADR-005)
|
||||
5. Periodically: prune stale vectors, rebuild index layers
|
||||
```
|
||||
|
||||
### Performance Analysis
|
||||
|
||||
**Memory requirements** (PQ8 quantization):
|
||||
|
||||
| Vector Count | Raw Size | PQ8 Compressed | HNSW Overhead | Total |
|
||||
|-------------|----------|----------------|---------------|-------|
|
||||
| 10,000 | 12.9 MB | 1.6 MB | 2.5 MB | 4.1 MB |
|
||||
| 100,000 | 129 MB | 16 MB | 25 MB | 41 MB |
|
||||
| 1,000,000 | 1.29 GB | 160 MB | 250 MB | 410 MB |
|
||||
|
||||
**Latency expectations** (329-dim vectors, ef_search=64):
|
||||
|
||||
| Vector Count | Brute Force | HNSW | Speedup |
|
||||
|-------------|-------------|------|---------|
|
||||
| 10,000 | 3.2 ms | 0.08 ms | 40x |
|
||||
| 100,000 | 32 ms | 0.3 ms | 107x |
|
||||
| 1,000,000 | 320 ms | 0.9 ms | 356x |
|
||||
|
||||
### Hyperbolic Extension for Activity Hierarchy
|
||||
|
||||
WiFi-sensed activities have natural hierarchy:
|
||||
|
||||
```
|
||||
motion
|
||||
/ \
|
||||
locomotion stationary
|
||||
/ \ / \
|
||||
walking running sitting lying
|
||||
/ \
|
||||
normal shuffling
|
||||
```
|
||||
|
||||
Hyperbolic HNSW in Poincaré ball space preserves this hierarchy during search, so a query for "shuffling" returns "walking" before "sitting" even if Euclidean distances are similar.
|
||||
|
||||
```rust
|
||||
/// Hyperbolic HNSW for hierarchy-aware activity matching
|
||||
pub struct HyperbolicActivityIndex {
|
||||
index: HnswIndex,
|
||||
curvature: f64, // -1.0 for unit Poincaré ball
|
||||
}
|
||||
|
||||
impl HyperbolicActivityIndex {
|
||||
pub fn search(&self, query: &[f32], k: usize) -> Vec<SearchResult> {
|
||||
// Uses Poincaré distance: d(u,v) = arcosh(1 + 2||u-v||²/((1-||u||²)(1-||v||²)))
|
||||
self.index.search_hyperbolic(query, k, self.curvature)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- **Adaptive detection**: System improves with more data; no manual threshold tuning
|
||||
- **Sub-millisecond search**: HNSW provides <1ms queries even at 1M vectors
|
||||
- **Memory efficient**: PQ8 reduces storage 8x with <5% recall loss
|
||||
- **Hierarchy-aware**: Hyperbolic mode respects activity relationships
|
||||
- **Incremental**: New patterns added without full index rebuild
|
||||
- **Explainable**: "This detection matched pattern X from room Y at time Z"
|
||||
|
||||
### Negative
|
||||
- **Cold-start problem**: Need initial fingerprint data before similarity search is useful
|
||||
- **Index maintenance**: Periodic pruning and layer rebalancing needed
|
||||
- **Approximation**: HNSW is approximate; may miss exact nearest neighbor (mitigated by high ef_search)
|
||||
- **Memory for indices**: HNSW graph structure adds 2.5x overhead on top of vectors
|
||||
|
||||
### Migration Strategy
|
||||
|
||||
1. **Phase 1**: Run HNSW search in parallel with existing threshold detection, log both results
|
||||
2. **Phase 2**: A/B test fusion weights (alpha parameter) on labeled data
|
||||
3. **Phase 3**: Gradually increase fusion_alpha from 0.0 (pure threshold) to 0.7 (primarily similarity)
|
||||
4. **Phase 4**: Threshold detection becomes fallback for cold-start/empty-index scenarios
|
||||
|
||||
## References
|
||||
|
||||
- [HNSW: Efficient and Robust Approximate Nearest Neighbor](https://arxiv.org/abs/1603.09320)
|
||||
- [Product Quantization for Nearest Neighbor Search](https://hal.inria.fr/inria-00514462)
|
||||
- [Poincaré Embeddings for Learning Hierarchical Representations](https://arxiv.org/abs/1705.08039)
|
||||
- [RuVector HNSW Implementation](https://github.com/ruvnet/ruvector)
|
||||
- ADR-003: RVF Cognitive Containers for CSI Data
|
||||
@@ -1,253 +0,0 @@
|
||||
# ADR-005: SONA Self-Learning for Pose Estimation
|
||||
|
||||
## Status
|
||||
Proposed
|
||||
|
||||
## Date
|
||||
2026-02-28
|
||||
|
||||
## Context
|
||||
|
||||
### Static Model Problem
|
||||
|
||||
The WiFi-DensePose modality translation network (`ModalityTranslationNetwork` in Python, `ModalityTranslator` in Rust) converts CSI features into visual-like feature maps that feed the DensePose head for body segmentation and UV coordinate estimation. These models are trained offline and deployed with frozen weights.
|
||||
|
||||
**Critical limitations of static models**:
|
||||
|
||||
1. **Environment drift**: CSI characteristics change when furniture moves, new objects are introduced, or building occupancy changes. A model trained in Lab A degrades in Lab B without retraining.
|
||||
|
||||
2. **Hardware variance**: Different WiFi chipsets (Intel AX200 vs Broadcom BCM4375 vs Qualcomm WCN6855) produce subtly different CSI patterns. Static models overfit to training hardware.
|
||||
|
||||
3. **Temporal drift**: Even in the same environment, CSI patterns shift with temperature, humidity, and electromagnetic interference changes throughout the day.
|
||||
|
||||
4. **Population bias**: Models trained on one demographic may underperform on body types, heights, or movement patterns not represented in training data.
|
||||
|
||||
Current mitigation: manual retraining with new data, which requires:
|
||||
- Collecting labeled data in the new environment
|
||||
- GPU-intensive training (hours to days)
|
||||
- Model export/deployment cycle
|
||||
- Downtime during switchover
|
||||
|
||||
### SONA Opportunity
|
||||
|
||||
RuVector's Self-Optimizing Neural Architecture (SONA) provides <1ms online adaptation through:
|
||||
|
||||
- **LoRA (Low-Rank Adaptation)**: Instead of updating all weights (millions of parameters), LoRA injects small trainable rank decomposition matrices into frozen model layers. For a weight matrix W ∈ R^(d×k), LoRA learns A ∈ R^(d×r) and B ∈ R^(r×k) where r << min(d,k), so the adapted weight is W + AB.
|
||||
|
||||
- **EWC++ (Elastic Weight Consolidation)**: Prevents catastrophic forgetting by penalizing changes to parameters important for previously learned tasks. Each parameter has a Fisher information-weighted importance score.
|
||||
|
||||
- **Online gradient accumulation**: Small batches of live data (as few as 1-10 samples) contribute to adaptation without full backward passes.
|
||||
|
||||
## Decision
|
||||
|
||||
We will integrate SONA as the online learning engine for both the modality translation network and the DensePose head, enabling continuous environment-specific adaptation without offline retraining.
|
||||
|
||||
### Adaptation Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ SONA Adaptation Pipeline │
|
||||
├──────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Frozen Base Model LoRA Adaptation Matrices │
|
||||
│ ┌─────────────────┐ ┌──────────────────────┐ │
|
||||
│ │ Conv2d(64,128) │ ◀── W_frozen ──▶ │ A(64,r) × B(r,128) │ │
|
||||
│ │ Conv2d(128,256) │ ◀── W_frozen ──▶ │ A(128,r) × B(r,256)│ │
|
||||
│ │ Conv2d(256,512) │ ◀── W_frozen ──▶ │ A(256,r) × B(r,512)│ │
|
||||
│ │ ConvT(512,256) │ ◀── W_frozen ──▶ │ A(512,r) × B(r,256)│ │
|
||||
│ │ ... │ │ ... │ │
|
||||
│ └─────────────────┘ └──────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ Effective Weight = W_frozen + α(AB) │ │
|
||||
│ │ α = scaling factor (0.0 → 1.0 over time) │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ EWC++ Regularizer │ │
|
||||
│ │ L_total = L_task + λ Σ F_i (θ_i - θ*_i)² │ │
|
||||
│ │ │ │
|
||||
│ │ F_i = Fisher information (parameter importance) │ │
|
||||
│ │ θ*_i = optimal parameters from previous tasks │ │
|
||||
│ │ λ = regularization strength (10-100) │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### LoRA Configuration per Layer
|
||||
|
||||
```rust
|
||||
/// SONA LoRA configuration for WiFi-DensePose
|
||||
pub struct SonaConfig {
|
||||
/// LoRA rank (r): dimensionality of adaptation matrices
|
||||
/// r=4 for encoder layers (less variation needed)
|
||||
/// r=8 for decoder layers (more expression needed)
|
||||
/// r=16 for final output layers (maximum adaptability)
|
||||
lora_ranks: HashMap<String, usize>,
|
||||
|
||||
/// Scaling factor alpha: controls adaptation strength
|
||||
/// Starts at 0.0 (pure frozen model), increases to target
|
||||
alpha: f64, // Target: 0.3
|
||||
|
||||
/// Alpha warmup steps before reaching target
|
||||
alpha_warmup_steps: usize, // 100
|
||||
|
||||
/// EWC++ regularization strength
|
||||
ewc_lambda: f64, // 50.0
|
||||
|
||||
/// Fisher information estimation samples
|
||||
fisher_samples: usize, // 200
|
||||
|
||||
/// Online learning rate (much smaller than offline training)
|
||||
online_lr: f64, // 1e-5
|
||||
|
||||
/// Gradient accumulation steps before applying update
|
||||
accumulation_steps: usize, // 10
|
||||
|
||||
/// Maximum adaptation delta (safety bound)
|
||||
max_delta_norm: f64, // 0.1
|
||||
}
|
||||
```
|
||||
|
||||
**Parameter budget**:
|
||||
|
||||
| Layer | Original Params | LoRA Rank | LoRA Params | Overhead |
|
||||
|-------|----------------|-----------|-------------|----------|
|
||||
| Encoder Conv1 (64→128) | 73,728 | 4 | 768 | 1.0% |
|
||||
| Encoder Conv2 (128→256) | 294,912 | 4 | 1,536 | 0.5% |
|
||||
| Encoder Conv3 (256→512) | 1,179,648 | 4 | 3,072 | 0.3% |
|
||||
| Decoder ConvT1 (512→256) | 1,179,648 | 8 | 6,144 | 0.5% |
|
||||
| Decoder ConvT2 (256→128) | 294,912 | 8 | 3,072 | 1.0% |
|
||||
| Output Conv (128→24) | 27,648 | 16 | 2,432 | 8.8% |
|
||||
| **Total** | **3,050,496** | - | **17,024** | **0.56%** |
|
||||
|
||||
SONA adapts **0.56% of parameters** while achieving 70-90% of the accuracy improvement of full fine-tuning.
|
||||
|
||||
### Adaptation Trigger Conditions
|
||||
|
||||
```rust
|
||||
/// When to trigger SONA adaptation
|
||||
pub enum AdaptationTrigger {
|
||||
/// Detection confidence drops below threshold over N samples
|
||||
ConfidenceDrop {
|
||||
threshold: f64, // 0.6
|
||||
window_size: usize, // 50
|
||||
},
|
||||
|
||||
/// CSI statistics drift beyond baseline (KL divergence)
|
||||
DistributionDrift {
|
||||
kl_threshold: f64, // 0.5
|
||||
reference_window: usize, // 1000
|
||||
},
|
||||
|
||||
/// New environment detected (no close HNSW matches)
|
||||
NewEnvironment {
|
||||
min_distance: f64, // 0.8 (far from all known fingerprints)
|
||||
},
|
||||
|
||||
/// Periodic adaptation (maintenance)
|
||||
Periodic {
|
||||
interval_samples: usize, // 10000
|
||||
},
|
||||
|
||||
/// Manual trigger via API
|
||||
Manual,
|
||||
}
|
||||
```
|
||||
|
||||
### Adaptation Feedback Sources
|
||||
|
||||
Since WiFi-DensePose lacks camera ground truth in deployment, adaptation uses **self-supervised signals**:
|
||||
|
||||
1. **Temporal consistency**: Pose estimates should change smoothly between frames. Jerky transitions indicate prediction error.
|
||||
```
|
||||
L_temporal = ||pose(t) - pose(t-1)||² when Δt < 100ms
|
||||
```
|
||||
|
||||
2. **Physical plausibility**: Body part positions must satisfy skeletal constraints (limb lengths, joint angles).
|
||||
```
|
||||
L_skeleton = Σ max(0, |limb_length - expected_length| - tolerance)
|
||||
```
|
||||
|
||||
3. **Multi-view agreement** (multi-AP): Different APs observing the same person should produce consistent poses.
|
||||
```
|
||||
L_multiview = ||pose_AP1 - transform(pose_AP2)||²
|
||||
```
|
||||
|
||||
4. **Detection stability**: Confidence should be high when the environment is stable.
|
||||
```
|
||||
L_stability = -log(confidence) when variance(CSI_window) < threshold
|
||||
```
|
||||
|
||||
### Safety Mechanisms
|
||||
|
||||
```rust
|
||||
/// Safety bounds prevent adaptation from degrading the model
|
||||
pub struct AdaptationSafety {
|
||||
/// Maximum parameter change per update step
|
||||
max_step_norm: f64,
|
||||
|
||||
/// Rollback if validation loss increases by this factor
|
||||
rollback_threshold: f64, // 1.5 (50% worse = rollback)
|
||||
|
||||
/// Keep N checkpoints for rollback
|
||||
checkpoint_count: usize, // 5
|
||||
|
||||
/// Disable adaptation after N consecutive rollbacks
|
||||
max_consecutive_rollbacks: usize, // 3
|
||||
|
||||
/// Minimum samples between adaptations
|
||||
cooldown_samples: usize, // 100
|
||||
}
|
||||
```
|
||||
|
||||
### Persistence via RVF
|
||||
|
||||
Adaptation state is stored in the Model Container (ADR-003):
|
||||
- LoRA matrices A and B serialized to VEC segment
|
||||
- Fisher information matrix serialized alongside
|
||||
- Each adaptation creates a witness chain entry (ADR-010)
|
||||
- COW branching allows reverting to any previous adaptation state
|
||||
|
||||
```
|
||||
model.rvf.model
|
||||
├── main (frozen base weights)
|
||||
├── branch/adapted-office-2024-01 (LoRA deltas)
|
||||
├── branch/adapted-warehouse (LoRA deltas)
|
||||
└── branch/adapted-outdoor-disaster (LoRA deltas)
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- **Zero-downtime adaptation**: Model improves continuously during operation
|
||||
- **Tiny overhead**: 17K parameters (0.56%) vs 3M full model; <1ms per adaptation step
|
||||
- **No forgetting**: EWC++ preserves performance on previously-seen environments
|
||||
- **Portable adaptations**: LoRA deltas are ~70 KB, easily shared between devices
|
||||
- **Safe rollback**: Checkpoint system prevents runaway degradation
|
||||
- **Self-supervised**: No labeled data needed during deployment
|
||||
|
||||
### Negative
|
||||
- **Bounded expressiveness**: LoRA rank limits the degree of adaptation; extreme environment changes may require offline retraining
|
||||
- **Feedback noise**: Self-supervised signals are weaker than ground-truth labels; adaptation is slower and less precise
|
||||
- **Compute on device**: Even small gradient computations require tensor math on the inference device
|
||||
- **Complexity**: Debugging adapted models is harder than static models
|
||||
- **Hyperparameter sensitivity**: EWC lambda, LoRA rank, learning rate require tuning
|
||||
|
||||
### Validation Plan
|
||||
|
||||
1. **Offline validation**: Train base model on Environment A, test SONA adaptation to Environment B with known ground truth. Measure pose estimation MPJPE (Mean Per-Joint Position Error) improvement.
|
||||
2. **A/B deployment**: Run static model and SONA-adapted model in parallel on same CSI stream. Compare detection rates and pose consistency.
|
||||
3. **Stress test**: Rapidly change environments (simulated) and verify EWC++ prevents catastrophic forgetting.
|
||||
4. **Edge latency**: Benchmark adaptation step on target hardware (Raspberry Pi 4, Jetson Nano, browser WASM).
|
||||
|
||||
## References
|
||||
|
||||
- [LoRA: Low-Rank Adaptation of Large Language Models](https://arxiv.org/abs/2106.09685)
|
||||
- [Elastic Weight Consolidation (EWC)](https://arxiv.org/abs/1612.00796)
|
||||
- [Continual Learning with SONA](https://github.com/ruvnet/ruvector)
|
||||
- [Self-Supervised WiFi Sensing](https://arxiv.org/abs/2203.11928)
|
||||
- ADR-002: RuVector RVF Integration Strategy
|
||||
- ADR-003: RVF Cognitive Containers for CSI Data
|
||||
@@ -1,261 +0,0 @@
|
||||
# ADR-006: GNN-Enhanced CSI Pattern Recognition
|
||||
|
||||
## Status
|
||||
Proposed
|
||||
|
||||
## Date
|
||||
2026-02-28
|
||||
|
||||
## Context
|
||||
|
||||
### Limitations of Independent Vector Search
|
||||
|
||||
ADR-004 introduces HNSW-based similarity search for CSI pattern matching. While HNSW provides fast nearest-neighbor retrieval, it treats each vector independently. CSI patterns, however, have rich relational structure:
|
||||
|
||||
1. **Temporal adjacency**: CSI frames captured 10ms apart are more related than frames 10s apart. Sequential patterns reveal motion trajectories.
|
||||
|
||||
2. **Spatial correlation**: CSI readings from adjacent subcarriers are highly correlated due to frequency proximity. Antenna pairs capture different spatial perspectives.
|
||||
|
||||
3. **Cross-session similarity**: The "walking to kitchen" pattern from Tuesday should inform Wednesday's recognition, but the environment baseline may have shifted.
|
||||
|
||||
4. **Multi-person entanglement**: When multiple people are present, CSI patterns are superpositions. Disentangling requires understanding which pattern fragments co-occur.
|
||||
|
||||
Standard HNSW cannot capture these relationships. Each query returns neighbors based solely on vector distance, ignoring the graph structure of how patterns relate to each other.
|
||||
|
||||
### RuVector's GNN Enhancement
|
||||
|
||||
RuVector implements a Graph Neural Network layer that sits on top of the HNSW index:
|
||||
|
||||
```
|
||||
Standard HNSW: Query → Distance-based neighbors → Results
|
||||
GNN-Enhanced: Query → Distance-based neighbors → GNN refinement → Improved results
|
||||
```
|
||||
|
||||
The GNN performs three operations in <1ms:
|
||||
1. **Message passing**: Each node aggregates information from its HNSW neighbors
|
||||
2. **Attention weighting**: Multi-head attention identifies which neighbors are most relevant for the current query context
|
||||
3. **Representation update**: Node embeddings are refined based on neighborhood context
|
||||
|
||||
Additionally, **temporal learning** tracks query sequences to discover:
|
||||
- Vectors that frequently appear together in sessions
|
||||
- Temporal ordering patterns (A usually precedes B)
|
||||
- Session context that changes relevance rankings
|
||||
|
||||
## Decision
|
||||
|
||||
We will integrate RuVector's GNN layer to enhance CSI pattern recognition with three core capabilities: relational search, temporal sequence modeling, and multi-person disentanglement.
|
||||
|
||||
### GNN Architecture for CSI
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ GNN-Enhanced CSI Pattern Graph │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Layer 1: HNSW Spatial Graph │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ Nodes = CSI feature vectors │ │
|
||||
│ │ Edges = HNSW neighbor connections (distance-based) │ │
|
||||
│ │ Node features = [amplitude | phase | doppler | PSD] │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Layer 2: Temporal Edges │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ Additional edges between temporally adjacent vectors │ │
|
||||
│ │ Edge weight = 1/Δt (closer in time = stronger) │ │
|
||||
│ │ Direction = causal (past → future) │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Layer 3: GNN Message Passing (2 rounds) │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ Round 1: h_i = σ(W₁·h_i + Σⱼ α_ij · W₂·h_j) │ │
|
||||
│ │ Round 2: h_i = σ(W₃·h_i + Σⱼ α'_ij · W₄·h_j) │ │
|
||||
│ │ α_ij = softmax(LeakyReLU(a^T[W·h_i || W·h_j])) │ │
|
||||
│ │ (Graph Attention Network mechanism) │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Layer 4: Refined Representations │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ Updated vectors incorporate neighborhood context │ │
|
||||
│ │ Re-rank search results using refined distances │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Three Integration Modes
|
||||
|
||||
#### Mode 1: Query-Time Refinement (Default)
|
||||
|
||||
GNN refines HNSW results after retrieval. No modifications to stored vectors.
|
||||
|
||||
```rust
|
||||
pub struct GnnQueryRefiner {
|
||||
/// GNN weights (small: ~50K parameters)
|
||||
gnn_weights: GnnModel,
|
||||
|
||||
/// Number of message passing rounds
|
||||
num_rounds: usize, // 2
|
||||
|
||||
/// Attention heads for neighbor weighting
|
||||
num_heads: usize, // 4
|
||||
|
||||
/// How many HNSW neighbors to consider in GNN
|
||||
neighborhood_size: usize, // 20 (retrieve 20, GNN selects best 5)
|
||||
}
|
||||
|
||||
impl GnnQueryRefiner {
|
||||
/// Refine HNSW results using graph context
|
||||
pub fn refine(&self, query: &[f32], hnsw_results: &[SearchResult]) -> Vec<SearchResult> {
|
||||
// Build local subgraph from query + HNSW results
|
||||
let subgraph = self.build_local_subgraph(query, hnsw_results);
|
||||
|
||||
// Run message passing
|
||||
let refined = self.message_pass(&subgraph, self.num_rounds);
|
||||
|
||||
// Re-rank based on refined representations
|
||||
self.rerank(query, &refined)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Latency**: +0.2ms on top of HNSW search (total <1.5ms for 100K vectors).
|
||||
|
||||
#### Mode 2: Temporal Sequence Recognition
|
||||
|
||||
Tracks CSI vector sequences to recognize activity patterns that span multiple frames:
|
||||
|
||||
```rust
|
||||
/// Temporal pattern recognizer using GNN edges
|
||||
pub struct TemporalPatternRecognizer {
|
||||
/// Sliding window of recent query vectors
|
||||
window: VecDeque<TimestampedVector>,
|
||||
|
||||
/// Maximum window size (in frames)
|
||||
max_window: usize, // 100 (10 seconds at 10 Hz)
|
||||
|
||||
/// Temporal edge decay factor
|
||||
decay: f64, // 0.95 (edges weaken with time)
|
||||
|
||||
/// Known activity sequences (learned from data)
|
||||
activity_templates: HashMap<String, Vec<Vec<f32>>>,
|
||||
}
|
||||
|
||||
impl TemporalPatternRecognizer {
|
||||
/// Feed new CSI vector and check for activity pattern matches
|
||||
pub fn observe(&mut self, vector: &[f32], timestamp: f64) -> Vec<ActivityMatch> {
|
||||
self.window.push_back(TimestampedVector { vector: vector.to_vec(), timestamp });
|
||||
|
||||
// Build temporal subgraph from window
|
||||
let temporal_graph = self.build_temporal_graph();
|
||||
|
||||
// GNN aggregates temporal context
|
||||
let sequence_embedding = self.gnn_aggregate(&temporal_graph);
|
||||
|
||||
// Match against known activity templates
|
||||
self.match_activities(&sequence_embedding)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Activity patterns detectable**:
|
||||
|
||||
| Activity | Frames Needed | CSI Signature |
|
||||
|----------|--------------|---------------|
|
||||
| Walking | 10-30 | Periodic Doppler oscillation |
|
||||
| Falling | 5-15 | Sharp amplitude spike → stillness |
|
||||
| Sitting down | 10-20 | Gradual descent in reflection height |
|
||||
| Breathing (still) | 30-100 | Micro-periodic phase variation |
|
||||
| Gesture (wave) | 5-15 | Localized high-frequency amplitude variation |
|
||||
|
||||
#### Mode 3: Multi-Person Disentanglement
|
||||
|
||||
When N>1 people are present, CSI is a superposition. The GNN learns to cluster pattern fragments:
|
||||
|
||||
```rust
|
||||
/// Multi-person CSI disentanglement using GNN clustering
|
||||
pub struct MultiPersonDisentangler {
|
||||
/// Maximum expected simultaneous persons
|
||||
max_persons: usize, // 10
|
||||
|
||||
/// GNN-based spectral clustering
|
||||
cluster_gnn: GnnModel,
|
||||
|
||||
/// Per-person tracking state
|
||||
person_tracks: Vec<PersonTrack>,
|
||||
}
|
||||
|
||||
impl MultiPersonDisentangler {
|
||||
/// Separate CSI features into per-person components
|
||||
pub fn disentangle(&mut self, features: &CsiFeatures) -> Vec<PersonFeatures> {
|
||||
// Decompose CSI into subcarrier groups using GNN attention
|
||||
let subcarrier_graph = self.build_subcarrier_graph(features);
|
||||
|
||||
// GNN clusters subcarriers by person contribution
|
||||
let clusters = self.cluster_gnn.cluster(&subcarrier_graph, self.max_persons);
|
||||
|
||||
// Extract per-person features from clustered subcarriers
|
||||
clusters.iter().map(|c| self.extract_person_features(features, c)).collect()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GNN Learning Loop
|
||||
|
||||
The GNN improves with every query through RuVector's built-in learning:
|
||||
|
||||
```
|
||||
Query → HNSW retrieval → GNN refinement → User action (click/confirm/reject)
|
||||
│
|
||||
▼
|
||||
Update GNN weights via:
|
||||
1. Positive: confirmed results get higher attention
|
||||
2. Negative: rejected results get lower attention
|
||||
3. Temporal: successful sequences reinforce edges
|
||||
```
|
||||
|
||||
For WiFi-DensePose, "user action" is replaced by:
|
||||
- **Temporal consistency**: If frame N+1 confirms frame N's detection, reinforce
|
||||
- **Multi-AP agreement**: If two APs agree on detection, reinforce both
|
||||
- **Physical plausibility**: If pose satisfies skeletal constraints, reinforce
|
||||
|
||||
### Performance Budget
|
||||
|
||||
| Component | Parameters | Memory | Latency (per query) |
|
||||
|-----------|-----------|--------|-------------------|
|
||||
| GNN weights (2 layers, 4 heads) | 52K | 208 KB | 0.15 ms |
|
||||
| Temporal graph (100-frame window) | N/A | ~130 KB | 0.05 ms |
|
||||
| Multi-person clustering | 18K | 72 KB | 0.3 ms |
|
||||
| **Total GNN overhead** | **70K** | **410 KB** | **0.5 ms** |
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- **Context-aware search**: Results account for temporal and spatial relationships, not just vector distance
|
||||
- **Activity recognition**: Temporal GNN enables sequence-level pattern matching
|
||||
- **Multi-person support**: GNN clustering separates overlapping CSI patterns
|
||||
- **Self-improving**: Every query provides learning signal to refine attention weights
|
||||
- **Lightweight**: 70K parameters, 410 KB memory, 0.5ms latency overhead
|
||||
|
||||
### Negative
|
||||
- **Training data needed**: GNN weights require initial training on CSI pattern graphs
|
||||
- **Complexity**: Three modes increase testing and debugging surface
|
||||
- **Graph maintenance**: Temporal edges must be pruned to prevent unbounded growth
|
||||
- **Approximation**: GNN clustering for multi-person is approximate; may merge/split incorrectly
|
||||
|
||||
### Interaction with Other ADRs
|
||||
- **ADR-004** (HNSW): GNN operates on HNSW graph structure; depends on HNSW being available
|
||||
- **ADR-005** (SONA): GNN weights can be adapted via SONA LoRA for environment-specific tuning
|
||||
- **ADR-003** (RVF): GNN weights stored in model container alongside inference weights
|
||||
- **ADR-010** (Witness): GNN weight updates recorded in witness chain
|
||||
|
||||
## References
|
||||
|
||||
- [Graph Attention Networks (GAT)](https://arxiv.org/abs/1710.10903)
|
||||
- [Temporal Graph Networks](https://arxiv.org/abs/2006.10637)
|
||||
- [Spectral Clustering with Graph Neural Networks](https://arxiv.org/abs/1907.00481)
|
||||
- [WiFi-based Multi-Person Sensing](https://dl.acm.org/doi/10.1145/3534592)
|
||||
- [RuVector GNN Implementation](https://github.com/ruvnet/ruvector)
|
||||
- ADR-004: HNSW Vector Search for Signal Fingerprinting
|
||||
@@ -1,215 +0,0 @@
|
||||
# ADR-007: Post-Quantum Cryptography for Secure Sensing
|
||||
|
||||
## Status
|
||||
Proposed
|
||||
|
||||
## Date
|
||||
2026-02-28
|
||||
|
||||
## Context
|
||||
|
||||
### Threat Model
|
||||
|
||||
WiFi-DensePose processes data that can reveal:
|
||||
- **Human presence/absence** in private spaces (surveillance risk)
|
||||
- **Health indicators** via breathing/heartbeat detection (medical privacy)
|
||||
- **Movement patterns** (behavioral profiling)
|
||||
- **Building occupancy** (physical security intelligence)
|
||||
|
||||
In disaster scenarios (wifi-densepose-mat), the stakes are even higher:
|
||||
- **Triage classifications** affect rescue priority (life-or-death decisions)
|
||||
- **Survivor locations** are operationally sensitive
|
||||
- **Detection audit trails** may be used in legal proceedings (liability)
|
||||
- **False negatives** (missed survivors) could be forensically investigated
|
||||
|
||||
Current security: The system uses standard JWT (HS256) for API authentication and has no cryptographic protection on data at rest, model integrity, or detection audit trails.
|
||||
|
||||
### Quantum Threat Timeline
|
||||
|
||||
NIST estimates cryptographically relevant quantum computers could emerge by 2030-2035. Data captured today with classical encryption may be decrypted retroactively ("harvest now, decrypt later"). For a system that may be deployed for decades in infrastructure, post-quantum readiness is prudent.
|
||||
|
||||
### RuVector's Crypto Stack
|
||||
|
||||
RuVector provides a layered cryptographic system:
|
||||
|
||||
| Algorithm | Purpose | Standard | Quantum Resistant |
|
||||
|-----------|---------|----------|-------------------|
|
||||
| ML-DSA-65 | Digital signatures | FIPS 204 | Yes (lattice-based) |
|
||||
| Ed25519 | Digital signatures | RFC 8032 | No (classical fallback) |
|
||||
| SLH-DSA-128s | Digital signatures | FIPS 205 | Yes (hash-based) |
|
||||
| SHAKE-256 | Hashing | FIPS 202 | Yes |
|
||||
| AES-256-GCM | Symmetric encryption | FIPS 197 | Yes (Grover's halves, still 128-bit) |
|
||||
|
||||
## Decision
|
||||
|
||||
We will integrate RuVector's cryptographic layer to provide defense-in-depth for WiFi-DensePose data, using a **hybrid classical+PQ** approach where both Ed25519 and ML-DSA-65 signatures are applied (belt-and-suspenders until PQ algorithms mature).
|
||||
|
||||
### Cryptographic Scope
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Cryptographic Protection Layers │
|
||||
├──────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 1. MODEL INTEGRITY │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Model weights signed with ML-DSA-65 + Ed25519 │ │
|
||||
│ │ Signature verified at load time → reject tampered │ │
|
||||
│ │ SONA adaptations co-signed with device key │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 2. DATA AT REST (RVF containers) │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ CSI vectors encrypted with AES-256-GCM │ │
|
||||
│ │ Container integrity via SHAKE-256 Merkle tree │ │
|
||||
│ │ Key management: per-container keys, sealed to device │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 3. DATA IN TRANSIT │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ API: TLS 1.3 with PQ key exchange (ML-KEM-768) │ │
|
||||
│ │ WebSocket: Same TLS channel │ │
|
||||
│ │ Multi-AP sync: mTLS with device certificates │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 4. AUDIT TRAIL (witness chains - see ADR-010) │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Every detection event hash-chained with SHAKE-256 │ │
|
||||
│ │ Chain anchors signed with ML-DSA-65 │ │
|
||||
│ │ Cross-device attestation via SLH-DSA-128s │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 5. DEVICE IDENTITY │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Each sensing device has a key pair (ML-DSA-65) │ │
|
||||
│ │ Device attestation proves hardware integrity │ │
|
||||
│ │ Key rotation schedule: 90 days (or on compromise) │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Hybrid Signature Scheme
|
||||
|
||||
```rust
|
||||
/// Hybrid signature combining classical Ed25519 with PQ ML-DSA-65
|
||||
pub struct HybridSignature {
|
||||
/// Classical Ed25519 signature (64 bytes)
|
||||
ed25519_sig: [u8; 64],
|
||||
|
||||
/// Post-quantum ML-DSA-65 signature (3309 bytes)
|
||||
ml_dsa_sig: Vec<u8>,
|
||||
|
||||
/// Signer's public key fingerprint (SHAKE-256, 32 bytes)
|
||||
signer_fingerprint: [u8; 32],
|
||||
|
||||
/// Timestamp of signing
|
||||
timestamp: u64,
|
||||
}
|
||||
|
||||
impl HybridSignature {
|
||||
/// Verify requires BOTH signatures to be valid
|
||||
pub fn verify(&self, message: &[u8], ed25519_pk: &Ed25519PublicKey,
|
||||
ml_dsa_pk: &MlDsaPublicKey) -> Result<bool, CryptoError> {
|
||||
let ed25519_valid = ed25519_pk.verify(message, &self.ed25519_sig)?;
|
||||
let ml_dsa_valid = ml_dsa_pk.verify(message, &self.ml_dsa_sig)?;
|
||||
|
||||
// Both must pass (defense in depth)
|
||||
Ok(ed25519_valid && ml_dsa_valid)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Model Integrity Verification
|
||||
|
||||
```rust
|
||||
/// Verify model weights have not been tampered with
|
||||
pub fn verify_model_integrity(model_container: &ModelContainer) -> Result<(), SecurityError> {
|
||||
// 1. Extract embedded signature from container
|
||||
let signature = model_container.crypto_segment().signature()?;
|
||||
|
||||
// 2. Compute SHAKE-256 hash of weight data
|
||||
let weight_hash = shake256(model_container.weights_segment().data());
|
||||
|
||||
// 3. Verify hybrid signature
|
||||
let publisher_keys = load_publisher_keys()?;
|
||||
if !signature.verify(&weight_hash, &publisher_keys.ed25519, &publisher_keys.ml_dsa)? {
|
||||
return Err(SecurityError::ModelTampered {
|
||||
expected_signer: publisher_keys.fingerprint(),
|
||||
container_path: model_container.path().to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### CSI Data Encryption
|
||||
|
||||
For privacy-sensitive deployments, CSI vectors can be encrypted at rest:
|
||||
|
||||
```rust
|
||||
/// Encrypt CSI vectors for storage in RVF container
|
||||
pub struct CsiEncryptor {
|
||||
/// AES-256-GCM key (derived from device key + container salt)
|
||||
key: Aes256GcmKey,
|
||||
}
|
||||
|
||||
impl CsiEncryptor {
|
||||
/// Encrypt a CSI feature vector
|
||||
/// Note: HNSW search operates on encrypted vectors using
|
||||
/// distance-preserving encryption (approximate, configurable trade-off)
|
||||
pub fn encrypt_vector(&self, vector: &[f32]) -> EncryptedVector {
|
||||
let nonce = generate_nonce();
|
||||
let plaintext = bytemuck::cast_slice::<f32, u8>(vector);
|
||||
let ciphertext = aes_256_gcm_encrypt(&self.key, &nonce, plaintext);
|
||||
EncryptedVector { ciphertext, nonce }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Performance Impact
|
||||
|
||||
| Operation | Without Crypto | With Crypto | Overhead |
|
||||
|-----------|---------------|-------------|----------|
|
||||
| Model load | 50 ms | 52 ms | +2 ms (signature verify) |
|
||||
| Vector insert | 0.1 ms | 0.15 ms | +0.05 ms (encrypt) |
|
||||
| HNSW search | 0.3 ms | 0.35 ms | +0.05 ms (decrypt top-K) |
|
||||
| Container open | 10 ms | 12 ms | +2 ms (integrity check) |
|
||||
| Detection event logging | 0.01 ms | 0.5 ms | +0.49 ms (hash chain) |
|
||||
|
||||
### Feature Flags
|
||||
|
||||
```toml
|
||||
[features]
|
||||
default = []
|
||||
crypto-classical = ["ed25519-dalek"] # Ed25519 only
|
||||
crypto-pq = ["pqcrypto-dilithium", "pqcrypto-sphincsplus"] # ML-DSA + SLH-DSA
|
||||
crypto-hybrid = ["crypto-classical", "crypto-pq"] # Both (recommended)
|
||||
crypto-encrypt = ["aes-gcm"] # Data-at-rest encryption
|
||||
crypto-full = ["crypto-hybrid", "crypto-encrypt"]
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- **Future-proof**: Lattice-based signatures resist quantum attacks
|
||||
- **Tamper detection**: Model poisoning and data manipulation are detectable
|
||||
- **Privacy compliance**: Encrypted CSI data meets GDPR/HIPAA requirements
|
||||
- **Forensic integrity**: Signed audit trails are admissible as evidence
|
||||
- **Low overhead**: <1ms per operation for most crypto operations
|
||||
|
||||
### Negative
|
||||
- **Signature size**: ML-DSA-65 signatures are 3.3 KB vs 64 bytes for Ed25519
|
||||
- **Key management complexity**: Device key provisioning, rotation, revocation
|
||||
- **HNSW on encrypted data**: Distance-preserving encryption is approximate; search recall may degrade
|
||||
- **Dependency weight**: PQ crypto libraries add ~2 MB to binary
|
||||
- **Standards maturity**: FIPS 204/205 are finalized but implementations are evolving
|
||||
|
||||
## References
|
||||
|
||||
- [FIPS 204: ML-DSA (Module-Lattice Digital Signature)](https://csrc.nist.gov/pubs/fips/204/final)
|
||||
- [FIPS 205: SLH-DSA (Stateless Hash-Based Digital Signature)](https://csrc.nist.gov/pubs/fips/205/final)
|
||||
- [FIPS 202: SHA-3 / SHAKE](https://csrc.nist.gov/pubs/fips/202/final)
|
||||
- [RuVector Crypto Implementation](https://github.com/ruvnet/ruvector)
|
||||
- ADR-002: RuVector RVF Integration Strategy
|
||||
- ADR-010: Witness Chains for Audit Trail Integrity
|
||||
@@ -1,284 +0,0 @@
|
||||
# ADR-008: Distributed Consensus for Multi-AP Coordination
|
||||
|
||||
## Status
|
||||
Proposed
|
||||
|
||||
## Date
|
||||
2026-02-28
|
||||
|
||||
## Context
|
||||
|
||||
### Multi-AP Sensing Architecture
|
||||
|
||||
WiFi-DensePose achieves higher accuracy and coverage with multiple access points (APs) observing the same space from different angles. The disaster detection module (wifi-densepose-mat, ADR-001) explicitly requires distributed deployment:
|
||||
|
||||
- **Portable**: Single TX/RX units deployed around a collapse site
|
||||
- **Distributed**: Multiple APs covering a large disaster zone
|
||||
- **Drone-mounted**: UAVs scanning from above with coordinated flight paths
|
||||
|
||||
Each AP independently captures CSI data, extracts features, and runs local inference. But the distributed system needs coordination:
|
||||
|
||||
1. **Consistent survivor registry**: All nodes must agree on the set of detected survivors, their locations, and triage classifications. Conflicting records cause rescue teams to waste time.
|
||||
|
||||
2. **Coordinated scanning**: Avoid redundant scans of the same zone. Dynamically reassign APs as zones are cleared.
|
||||
|
||||
3. **Model synchronization**: When SONA adapts a model on one node (ADR-005), other nodes should benefit from the adaptation without re-learning.
|
||||
|
||||
4. **Clock synchronization**: CSI timestamps must be aligned across nodes for multi-view pose fusion (the GNN multi-person disentanglement in ADR-006 requires temporal alignment).
|
||||
|
||||
5. **Partition tolerance**: In disaster scenarios, network connectivity is unreliable. The system must function during partitions and reconcile when connectivity restores.
|
||||
|
||||
### Current State
|
||||
|
||||
No distributed coordination exists. Each node operates independently. The Rust workspace has no consensus crate.
|
||||
|
||||
### RuVector's Distributed Capabilities
|
||||
|
||||
RuVector provides:
|
||||
- **Raft consensus**: Leader election and replicated log for strong consistency
|
||||
- **Vector clocks**: Logical timestamps for causal ordering without synchronized clocks
|
||||
- **Multi-master replication**: Concurrent writes with conflict resolution
|
||||
- **Delta consensus**: Tracks behavioral changes across nodes for anomaly detection
|
||||
- **Auto-sharding**: Distributes data based on access patterns
|
||||
|
||||
## Decision
|
||||
|
||||
We will integrate RuVector's Raft consensus implementation as the coordination backbone for multi-AP WiFi-DensePose deployments, with vector clocks for causal ordering and CRDT-based conflict resolution for partition-tolerant operation.
|
||||
|
||||
### Consensus Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Multi-AP Coordination Architecture │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Normal Operation (Connected): │
|
||||
│ │
|
||||
│ ┌─────────┐ Raft ┌─────────┐ Raft ┌─────────┐ │
|
||||
│ │ AP-1 │◀────────────▶│ AP-2 │◀────────────▶│ AP-3 │ │
|
||||
│ │ (Leader)│ Replicated │(Follower│ Replicated │(Follower│ │
|
||||
│ │ │ Log │ )│ Log │ )│ │
|
||||
│ └────┬────┘ └────┬────┘ └────┬────┘ │
|
||||
│ │ │ │ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ Local │ │ Local │ │ Local │ │
|
||||
│ │ RVF │ │ RVF │ │ RVF │ │
|
||||
│ │Container│ │Container│ │Container│ │
|
||||
│ └─────────┘ └─────────┘ └─────────┘ │
|
||||
│ │
|
||||
│ Partitioned Operation (Disconnected): │
|
||||
│ │
|
||||
│ ┌─────────┐ ┌──────────────────────┐ │
|
||||
│ │ AP-1 │ ← operates independently → │ AP-2 AP-3 │ │
|
||||
│ │ │ │ (form sub-cluster) │ │
|
||||
│ │ Local │ │ Raft between 2+3 │ │
|
||||
│ │ writes │ │ │ │
|
||||
│ └─────────┘ └──────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ └──────── Reconnect: CRDT merge ─────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Replicated State Machine
|
||||
|
||||
The Raft log replicates these operations across all nodes:
|
||||
|
||||
```rust
|
||||
/// Operations replicated via Raft consensus
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub enum ConsensusOp {
|
||||
/// New survivor detected
|
||||
SurvivorDetected {
|
||||
survivor_id: Uuid,
|
||||
location: GeoCoord,
|
||||
triage: TriageLevel,
|
||||
detecting_ap: ApId,
|
||||
confidence: f64,
|
||||
timestamp: VectorClock,
|
||||
},
|
||||
|
||||
/// Survivor status updated (e.g., triage reclassification)
|
||||
SurvivorUpdated {
|
||||
survivor_id: Uuid,
|
||||
new_triage: TriageLevel,
|
||||
updating_ap: ApId,
|
||||
evidence: DetectionEvidence,
|
||||
},
|
||||
|
||||
/// Zone assignment changed
|
||||
ZoneAssignment {
|
||||
zone_id: ZoneId,
|
||||
assigned_aps: Vec<ApId>,
|
||||
priority: ScanPriority,
|
||||
},
|
||||
|
||||
/// Model adaptation delta shared
|
||||
ModelDelta {
|
||||
source_ap: ApId,
|
||||
lora_delta: Vec<u8>, // Serialized LoRA matrices
|
||||
environment_hash: [u8; 32],
|
||||
performance_metrics: AdaptationMetrics,
|
||||
},
|
||||
|
||||
/// AP joined or left the cluster
|
||||
MembershipChange {
|
||||
ap_id: ApId,
|
||||
action: MembershipAction, // Join | Leave | Suspect
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Vector Clocks for Causal Ordering
|
||||
|
||||
Since APs may have unsynchronized physical clocks, vector clocks provide causal ordering:
|
||||
|
||||
```rust
|
||||
/// Vector clock for causal ordering across APs
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct VectorClock {
|
||||
/// Map from AP ID to logical timestamp
|
||||
clocks: HashMap<ApId, u64>,
|
||||
}
|
||||
|
||||
impl VectorClock {
|
||||
/// Increment this AP's clock
|
||||
pub fn tick(&mut self, ap_id: &ApId) {
|
||||
*self.clocks.entry(ap_id.clone()).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
/// Merge with another clock (take max of each component)
|
||||
pub fn merge(&mut self, other: &VectorClock) {
|
||||
for (ap_id, &ts) in &other.clocks {
|
||||
let entry = self.clocks.entry(ap_id.clone()).or_insert(0);
|
||||
*entry = (*entry).max(ts);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if self happened-before other
|
||||
pub fn happened_before(&self, other: &VectorClock) -> bool {
|
||||
self.clocks.iter().all(|(k, &v)| {
|
||||
other.clocks.get(k).map_or(false, |&ov| v <= ov)
|
||||
}) && self.clocks != other.clocks
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### CRDT-Based Conflict Resolution
|
||||
|
||||
During network partitions, concurrent updates may conflict. We use CRDTs (Conflict-free Replicated Data Types) for automatic resolution:
|
||||
|
||||
```rust
|
||||
/// Survivor registry using Last-Writer-Wins Register CRDT
|
||||
pub struct SurvivorRegistry {
|
||||
/// LWW-Element-Set: each survivor has a timestamp-tagged state
|
||||
survivors: HashMap<Uuid, LwwRegister<SurvivorState>>,
|
||||
}
|
||||
|
||||
/// Triage uses Max-wins semantics:
|
||||
/// If partition A says P1 (Red/Immediate) and partition B says P2 (Yellow/Delayed),
|
||||
/// after merge the survivor is classified P1 (more urgent wins)
|
||||
/// Rationale: false negative (missing critical) is worse than false positive
|
||||
impl CrdtMerge for TriageLevel {
|
||||
fn merge(a: Self, b: Self) -> Self {
|
||||
// Lower numeric priority = more urgent
|
||||
if a.urgency() >= b.urgency() { a } else { b }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**CRDT merge strategies by data type**:
|
||||
|
||||
| Data Type | CRDT Type | Merge Strategy | Rationale |
|
||||
|-----------|-----------|---------------|-----------|
|
||||
| Survivor set | OR-Set | Union (never lose a detection) | Missing survivors = fatal |
|
||||
| Triage level | Max-Register | Most urgent wins | Err toward caution |
|
||||
| Location | LWW-Register | Latest timestamp wins | Survivors may move |
|
||||
| Zone assignment | LWW-Map | Leader's assignment wins | Need authoritative coord |
|
||||
| Model deltas | G-Set | Accumulate all deltas | All adaptations valuable |
|
||||
|
||||
### Node Discovery and Health
|
||||
|
||||
```rust
|
||||
/// AP cluster management
|
||||
pub struct ApCluster {
|
||||
/// This node's identity
|
||||
local_ap: ApId,
|
||||
|
||||
/// Raft consensus engine
|
||||
raft: RaftEngine<ConsensusOp>,
|
||||
|
||||
/// Failure detector (phi-accrual)
|
||||
failure_detector: PhiAccrualDetector,
|
||||
|
||||
/// Cluster membership
|
||||
members: HashSet<ApId>,
|
||||
}
|
||||
|
||||
impl ApCluster {
|
||||
/// Heartbeat interval for failure detection
|
||||
const HEARTBEAT_MS: u64 = 500;
|
||||
|
||||
/// Phi threshold for suspecting node failure
|
||||
const PHI_THRESHOLD: f64 = 8.0;
|
||||
|
||||
/// Minimum cluster size for Raft (need majority)
|
||||
const MIN_CLUSTER_SIZE: usize = 3;
|
||||
}
|
||||
```
|
||||
|
||||
### Performance Characteristics
|
||||
|
||||
| Operation | Latency | Notes |
|
||||
|-----------|---------|-------|
|
||||
| Raft heartbeat | 500 ms interval | Configurable |
|
||||
| Log replication | 1-5 ms (LAN) | Depends on payload size |
|
||||
| Leader election | 1-3 seconds | After leader failure detected |
|
||||
| CRDT merge (partition heal) | 10-100 ms | Proportional to divergence |
|
||||
| Vector clock comparison | <0.01 ms | O(n) where n = cluster size |
|
||||
| Model delta replication | 50-200 ms | ~70 KB LoRA delta |
|
||||
|
||||
### Deployment Configurations
|
||||
|
||||
| Scenario | Nodes | Consensus | Partition Strategy |
|
||||
|----------|-------|-----------|-------------------|
|
||||
| Single room | 1-2 | None (local only) | N/A |
|
||||
| Building floor | 3-5 | Raft (3-node quorum) | CRDT merge on heal |
|
||||
| Disaster site | 5-20 | Raft (5-node quorum) + zones | Zone-level sub-clusters |
|
||||
| Urban search | 20-100 | Hierarchical Raft | Regional leaders |
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- **Consistent state**: All APs agree on survivor registry via Raft
|
||||
- **Partition tolerant**: CRDT merge allows operation during disconnection
|
||||
- **Causal ordering**: Vector clocks provide logical time without NTP
|
||||
- **Automatic failover**: Raft leader election handles AP failures
|
||||
- **Model sharing**: SONA adaptations propagate across cluster
|
||||
|
||||
### Negative
|
||||
- **Minimum 3 nodes**: Raft requires odd-numbered quorum for leader election
|
||||
- **Network overhead**: Heartbeats and log replication consume bandwidth (~1-10 KB/s per node)
|
||||
- **Complexity**: Distributed systems are inherently harder to debug
|
||||
- **Latency for writes**: Raft requires majority acknowledgment before commit (1-5ms LAN)
|
||||
- **Split-brain risk**: If cluster splits evenly (2+2), neither partition has quorum
|
||||
|
||||
### Disaster-Specific Considerations
|
||||
|
||||
| Challenge | Mitigation |
|
||||
|-----------|------------|
|
||||
| Intermittent connectivity | Aggressive CRDT merge on reconnect; local operation during partition |
|
||||
| Power failures | Raft log persisted to local SSD; recovery on restart |
|
||||
| Node destruction | Raft tolerates minority failure; data replicated across survivors |
|
||||
| Drone mobility | Drone APs treated as ephemeral members; data synced on landing |
|
||||
| Bandwidth constraints | Delta-only replication; compress LoRA deltas |
|
||||
|
||||
## References
|
||||
|
||||
- [Raft Consensus Algorithm](https://raft.github.io/raft.pdf)
|
||||
- [CRDTs: Conflict-free Replicated Data Types](https://hal.inria.fr/inria-00609399)
|
||||
- [Vector Clocks](https://en.wikipedia.org/wiki/Vector_clock)
|
||||
- [Phi Accrual Failure Detector](https://www.computer.org/csdl/proceedings-article/srds/2004/22390066/12OmNyQYtlC)
|
||||
- [RuVector Distributed Consensus](https://github.com/ruvnet/ruvector)
|
||||
- ADR-001: WiFi-Mat Disaster Detection Architecture
|
||||
- ADR-002: RuVector RVF Integration Strategy
|
||||
@@ -1,262 +0,0 @@
|
||||
# ADR-009: RVF WASM Runtime for Edge Deployment
|
||||
|
||||
## Status
|
||||
Proposed
|
||||
|
||||
## Date
|
||||
2026-02-28
|
||||
|
||||
## Context
|
||||
|
||||
### Current WASM State
|
||||
|
||||
The wifi-densepose-wasm crate provides basic WebAssembly bindings that expose Rust types to JavaScript. It enables browser-based visualization and lightweight inference but has significant limitations:
|
||||
|
||||
1. **No self-contained operation**: WASM module depends on external model files loaded via fetch(). If the server is unreachable, the module is useless.
|
||||
|
||||
2. **No persistent state**: Browser WASM has no built-in persistent storage for fingerprint databases, model weights, or session data.
|
||||
|
||||
3. **No offline capability**: Without network access, the WASM module cannot load models or send results.
|
||||
|
||||
4. **Binary size**: Current WASM bundle is not optimized. Full inference + signal processing compiles to ~5-15 MB.
|
||||
|
||||
### Edge Deployment Requirements
|
||||
|
||||
| Scenario | Platform | Constraints |
|
||||
|----------|----------|------------|
|
||||
| Browser dashboard | Chrome/Firefox | <10 MB download, no plugins |
|
||||
| IoT sensor node | ESP32/Raspberry Pi | 256 KB - 4 GB RAM, battery powered |
|
||||
| Mobile app | iOS/Android WebView | Limited background execution |
|
||||
| Drone payload | Embedded Linux + WASM | Weight/power limited, intermittent connectivity |
|
||||
| Field tablet | Android tablet | Offline operation in disaster zones |
|
||||
|
||||
### RuVector's Edge Runtime
|
||||
|
||||
RuVector provides a 5.5 KB WASM runtime that boots in 125ms, with:
|
||||
- Self-contained operation (models + data embedded in RVF container)
|
||||
- Persistent storage via RVF container (written to IndexedDB in browser, filesystem on native)
|
||||
- Offline-first architecture
|
||||
- SIMD acceleration when available (WASM SIMD proposal)
|
||||
|
||||
## Decision
|
||||
|
||||
We will replace the current wifi-densepose-wasm approach with an RVF-based edge runtime that packages models, fingerprint databases, and the inference engine into a single deployable RVF container.
|
||||
|
||||
### Edge Runtime Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ RVF Edge Deployment Container │
|
||||
│ (.rvf.edge file) │
|
||||
├──────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
|
||||
│ │ WASM │ │ VEC │ │ INDEX │ │ MODEL (ONNX) │ │
|
||||
│ │ Runtime │ │ CSI │ │ HNSW │ │ + LoRA deltas │ │
|
||||
│ │ (5.5KB) │ │ Finger- │ │ Graph │ │ │ │
|
||||
│ │ │ │ prints │ │ │ │ │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ └──────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
|
||||
│ │ CRYPTO │ │ WITNESS │ │ COW_MAP │ │ CONFIG │ │
|
||||
│ │ Keys │ │ Audit │ │ Branches│ │ Runtime params │ │
|
||||
│ │ │ │ Chain │ │ │ │ │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ └──────────────────┘ │
|
||||
│ │
|
||||
│ Total container: 1-50 MB depending on model + fingerprint size │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ Deploy to:
|
||||
▼
|
||||
┌───────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────────────┐ │
|
||||
│ │ Browser │ │ IoT │ │ Mobile │ │ Disaster Field │ │
|
||||
│ │ │ │ Device │ │ App │ │ Tablet │ │
|
||||
│ │ IndexedDB │ Flash │ │ App │ │ Local FS │ │
|
||||
│ │ for state│ │ for │ │ Sandbox │ │ for state │ │
|
||||
│ │ │ │ state │ │ for │ │ │ │
|
||||
│ │ │ │ │ │ state │ │ │ │
|
||||
│ └─────────┘ └─────────┘ └─────────┘ └─────────────────┘ │
|
||||
└───────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Tiered Runtime Profiles
|
||||
|
||||
Different deployment targets get different container configurations:
|
||||
|
||||
```rust
|
||||
/// Edge runtime profiles
|
||||
pub enum EdgeProfile {
|
||||
/// Full-featured browser deployment
|
||||
/// ~10 MB container, full inference + HNSW + SONA
|
||||
Browser {
|
||||
model_quantization: Quantization::Int8,
|
||||
max_fingerprints: 100_000,
|
||||
enable_sona: true,
|
||||
storage_backend: StorageBackend::IndexedDB,
|
||||
},
|
||||
|
||||
/// Minimal IoT deployment
|
||||
/// ~1 MB container, lightweight inference only
|
||||
IoT {
|
||||
model_quantization: Quantization::Int4,
|
||||
max_fingerprints: 1_000,
|
||||
enable_sona: false,
|
||||
storage_backend: StorageBackend::Flash,
|
||||
},
|
||||
|
||||
/// Mobile app deployment
|
||||
/// ~5 MB container, inference + HNSW, limited SONA
|
||||
Mobile {
|
||||
model_quantization: Quantization::Int8,
|
||||
max_fingerprints: 50_000,
|
||||
enable_sona: true,
|
||||
storage_backend: StorageBackend::AppSandbox,
|
||||
},
|
||||
|
||||
/// Disaster field deployment (maximum capability)
|
||||
/// ~50 MB container, full stack including multi-AP consensus
|
||||
Field {
|
||||
model_quantization: Quantization::Float16,
|
||||
max_fingerprints: 1_000_000,
|
||||
enable_sona: true,
|
||||
storage_backend: StorageBackend::FileSystem,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Container Size Budget
|
||||
|
||||
| Segment | Browser | IoT | Mobile | Field |
|
||||
|---------|---------|-----|--------|-------|
|
||||
| WASM runtime | 5.5 KB | 5.5 KB | 5.5 KB | 5.5 KB |
|
||||
| Model (ONNX) | 3 MB (int8) | 0.5 MB (int4) | 3 MB (int8) | 12 MB (fp16) |
|
||||
| HNSW index | 4 MB | 100 KB | 2 MB | 40 MB |
|
||||
| Fingerprint vectors | 2 MB | 50 KB | 1 MB | 10 MB |
|
||||
| Config + crypto | 50 KB | 10 KB | 50 KB | 100 KB |
|
||||
| **Total** | **~10 MB** | **~0.7 MB** | **~6 MB** | **~62 MB** |
|
||||
|
||||
### Offline-First Data Flow
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────┐
|
||||
│ Offline-First Operation │
|
||||
├────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 1. BOOT (125ms) │
|
||||
│ ├── Open RVF container from local storage │
|
||||
│ ├── Memory-map WASM runtime segment │
|
||||
│ ├── Load HNSW index into memory │
|
||||
│ └── Initialize inference engine with embedded model │
|
||||
│ │
|
||||
│ 2. OPERATE (continuous) │
|
||||
│ ├── Receive CSI data from local hardware interface │
|
||||
│ ├── Process through local pipeline (no network needed) │
|
||||
│ ├── Search HNSW index against local fingerprints │
|
||||
│ ├── Run SONA adaptation on local data │
|
||||
│ ├── Append results to local witness chain │
|
||||
│ └── Store updated vectors to local container │
|
||||
│ │
|
||||
│ 3. SYNC (when connected) │
|
||||
│ ├── Push new vectors to central RVF container │
|
||||
│ ├── Pull updated fingerprints from other nodes │
|
||||
│ ├── Merge SONA deltas via Raft (ADR-008) │
|
||||
│ ├── Extend witness chain with cross-node attestation │
|
||||
│ └── Update local container with merged state │
|
||||
│ │
|
||||
│ 4. SLEEP (battery conservation) │
|
||||
│ ├── Flush pending writes to container │
|
||||
│ ├── Close memory-mapped segments │
|
||||
│ └── Resume from step 1 on wake │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Browser-Specific Integration
|
||||
|
||||
```rust
|
||||
/// Browser WASM entry point
|
||||
#[wasm_bindgen]
|
||||
pub struct WifiDensePoseEdge {
|
||||
container: RvfContainer,
|
||||
inference_engine: InferenceEngine,
|
||||
hnsw_index: HnswIndex,
|
||||
sona: Option<SonaAdapter>,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl WifiDensePoseEdge {
|
||||
/// Initialize from an RVF container loaded via fetch or IndexedDB
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub async fn new(container_bytes: &[u8]) -> Result<WifiDensePoseEdge, JsValue> {
|
||||
let container = RvfContainer::from_bytes(container_bytes)?;
|
||||
let engine = InferenceEngine::from_container(&container)?;
|
||||
let index = HnswIndex::from_container(&container)?;
|
||||
let sona = SonaAdapter::from_container(&container).ok();
|
||||
|
||||
Ok(Self { container, inference_engine: engine, hnsw_index: index, sona })
|
||||
}
|
||||
|
||||
/// Process a single CSI frame (called from JavaScript)
|
||||
#[wasm_bindgen]
|
||||
pub fn process_frame(&mut self, csi_json: &str) -> Result<String, JsValue> {
|
||||
let csi_data: CsiData = serde_json::from_str(csi_json)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||
|
||||
let features = self.extract_features(&csi_data)?;
|
||||
let detection = self.detect(&features)?;
|
||||
let pose = if detection.human_detected {
|
||||
Some(self.estimate_pose(&features)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
serde_json::to_string(&PoseResult { detection, pose })
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Save current state to IndexedDB
|
||||
#[wasm_bindgen]
|
||||
pub async fn persist(&self) -> Result<(), JsValue> {
|
||||
let bytes = self.container.serialize()?;
|
||||
// Write to IndexedDB via web-sys
|
||||
save_to_indexeddb("wifi-densepose-state", &bytes).await
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Model Quantization Strategy
|
||||
|
||||
| Quantization | Size Reduction | Accuracy Loss | Suitable For |
|
||||
|-------------|---------------|---------------|-------------|
|
||||
| Float32 (baseline) | 1x | 0% | Server/desktop |
|
||||
| Float16 | 2x | <0.5% | Field tablets, GPUs |
|
||||
| Int8 (PTQ) | 4x | <2% | Browser, mobile |
|
||||
| Int4 (GPTQ) | 8x | <5% | IoT, ultra-constrained |
|
||||
| Binary (1-bit) | 32x | ~15% | MCU/ultra-edge (experimental) |
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- **Single-file deployment**: Copy one `.rvf.edge` file to deploy anywhere
|
||||
- **Offline operation**: Full functionality without network connectivity
|
||||
- **125ms boot**: Near-instant readiness for emergency scenarios
|
||||
- **Platform universal**: Same container format for browser, IoT, mobile, server
|
||||
- **Battery efficient**: No network polling in offline mode
|
||||
|
||||
### Negative
|
||||
- **Container size**: Even compressed, field containers are 50+ MB
|
||||
- **WASM performance**: 2-5x slower than native Rust for compute-heavy operations
|
||||
- **Browser limitations**: IndexedDB has storage quotas; WASM SIMD support varies
|
||||
- **Update latency**: Offline devices miss updates until reconnection
|
||||
- **Quantization accuracy**: Int4/Int8 models lose some detection sensitivity
|
||||
|
||||
## References
|
||||
|
||||
- [WebAssembly SIMD Proposal](https://github.com/WebAssembly/simd)
|
||||
- [IndexedDB API](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API)
|
||||
- [ONNX Runtime Web](https://onnxruntime.ai/docs/tutorials/web/)
|
||||
- [Model Quantization Techniques](https://arxiv.org/abs/2103.13630)
|
||||
- [RuVector WASM Runtime](https://github.com/ruvnet/ruvector)
|
||||
- ADR-002: RuVector RVF Integration Strategy
|
||||
- ADR-003: RVF Cognitive Containers for CSI Data
|
||||
@@ -1,402 +0,0 @@
|
||||
# ADR-010: Witness Chains for Audit Trail Integrity
|
||||
|
||||
## Status
|
||||
Proposed
|
||||
|
||||
## Date
|
||||
2026-02-28
|
||||
|
||||
## Context
|
||||
|
||||
### Life-Critical Audit Requirements
|
||||
|
||||
The wifi-densepose-mat disaster detection module (ADR-001) makes triage classifications that directly affect rescue priority:
|
||||
|
||||
| Triage Level | Action | Consequence of Error |
|
||||
|-------------|--------|---------------------|
|
||||
| P1 (Immediate/Red) | Rescue NOW | False negative → survivor dies waiting |
|
||||
| P2 (Delayed/Yellow) | Rescue within 1 hour | Misclassification → delayed rescue |
|
||||
| P3 (Minor/Green) | Rescue when resources allow | Over-triage → resource waste |
|
||||
| P4 (Deceased/Black) | No rescue attempted | False P4 → living person abandoned |
|
||||
|
||||
Post-incident investigations, liability proceedings, and operational reviews require:
|
||||
|
||||
1. **Non-repudiation**: Prove which device made which detection at which time
|
||||
2. **Tamper evidence**: Detect if records were altered after the fact
|
||||
3. **Completeness**: Prove no detections were deleted or hidden
|
||||
4. **Causal chain**: Reconstruct the sequence of events leading to each triage decision
|
||||
5. **Cross-device verification**: Corroborate detections across multiple APs
|
||||
|
||||
### Current State
|
||||
|
||||
Detection results are logged to the database (`wifi-densepose-db`) with standard INSERT operations. Logs can be:
|
||||
- Silently modified after the fact
|
||||
- Deleted without trace
|
||||
- Backdated or reordered
|
||||
- Lost if the database is corrupted
|
||||
|
||||
No cryptographic integrity mechanism exists.
|
||||
|
||||
### RuVector Witness Chains
|
||||
|
||||
RuVector implements hash-linked audit trails inspired by blockchain but without the consensus overhead:
|
||||
|
||||
- **Hash chain**: Each entry includes the SHAKE-256 hash of the previous entry, forming a tamper-evident chain
|
||||
- **Signatures**: Chain anchors (every Nth entry) are signed with the device's key pair
|
||||
- **Cross-chain attestation**: Multiple devices can cross-reference each other's chains
|
||||
- **Compact**: Each chain entry is ~100-200 bytes (hash + metadata + signature reference)
|
||||
|
||||
## Decision
|
||||
|
||||
We will implement RuVector witness chains as the primary audit mechanism for all detection events, triage decisions, and model adaptation events in the WiFi-DensePose system.
|
||||
|
||||
### Witness Chain Structure
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────┐
|
||||
│ Witness Chain │
|
||||
├────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Entry 0 Entry 1 Entry 2 Entry 3 │
|
||||
│ (Genesis) │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ prev: ∅ │◀───│ prev: H0 │◀───│ prev: H1 │◀───│ prev: H2 │ │
|
||||
│ │ event: │ │ event: │ │ event: │ │ event: │ │
|
||||
│ │ INIT │ │ DETECT │ │ TRIAGE │ │ ADAPT │ │
|
||||
│ │ hash: H0 │ │ hash: H1 │ │ hash: H2 │ │ hash: H3 │ │
|
||||
│ │ sig: S0 │ │ │ │ │ │ sig: S1 │ │
|
||||
│ │ (anchor) │ │ │ │ │ │ (anchor) │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
|
||||
│ │
|
||||
│ H0 = SHAKE-256(INIT || device_id || timestamp) │
|
||||
│ H1 = SHAKE-256(DETECT_DATA || H0 || timestamp) │
|
||||
│ H2 = SHAKE-256(TRIAGE_DATA || H1 || timestamp) │
|
||||
│ H3 = SHAKE-256(ADAPT_DATA || H2 || timestamp) │
|
||||
│ │
|
||||
│ Anchor signature S0 = ML-DSA-65.sign(H0, device_key) │
|
||||
│ Anchor signature S1 = ML-DSA-65.sign(H3, device_key) │
|
||||
│ Anchor interval: every 100 entries (configurable) │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Witnessed Event Types
|
||||
|
||||
```rust
|
||||
/// Events recorded in the witness chain
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub enum WitnessedEvent {
|
||||
/// Chain initialization (genesis)
|
||||
ChainInit {
|
||||
device_id: DeviceId,
|
||||
firmware_version: String,
|
||||
config_hash: [u8; 32],
|
||||
},
|
||||
|
||||
/// Human presence detected
|
||||
HumanDetected {
|
||||
detection_id: Uuid,
|
||||
confidence: f64,
|
||||
csi_features_hash: [u8; 32], // Hash of input data, not raw data
|
||||
location_estimate: Option<GeoCoord>,
|
||||
model_version: String,
|
||||
},
|
||||
|
||||
/// Triage classification assigned or changed
|
||||
TriageDecision {
|
||||
survivor_id: Uuid,
|
||||
previous_level: Option<TriageLevel>,
|
||||
new_level: TriageLevel,
|
||||
evidence_hash: [u8; 32], // Hash of supporting evidence
|
||||
deciding_algorithm: String,
|
||||
confidence: f64,
|
||||
},
|
||||
|
||||
/// False detection corrected
|
||||
DetectionCorrected {
|
||||
detection_id: Uuid,
|
||||
correction_type: CorrectionType, // FalsePositive | FalseNegative | Reclassified
|
||||
reason: String,
|
||||
corrected_by: CorrectorId, // Device or operator
|
||||
},
|
||||
|
||||
/// Model adapted via SONA
|
||||
ModelAdapted {
|
||||
adaptation_id: Uuid,
|
||||
trigger: AdaptationTrigger,
|
||||
lora_delta_hash: [u8; 32],
|
||||
performance_before: f64,
|
||||
performance_after: f64,
|
||||
},
|
||||
|
||||
/// Zone scan completed
|
||||
ZoneScanCompleted {
|
||||
zone_id: ZoneId,
|
||||
scan_duration_ms: u64,
|
||||
detections_count: usize,
|
||||
coverage_percentage: f64,
|
||||
},
|
||||
|
||||
/// Cross-device attestation received
|
||||
CrossAttestation {
|
||||
attesting_device: DeviceId,
|
||||
attested_chain_hash: [u8; 32],
|
||||
attested_entry_index: u64,
|
||||
},
|
||||
|
||||
/// Operator action (manual override)
|
||||
OperatorAction {
|
||||
operator_id: String,
|
||||
action: OperatorActionType,
|
||||
target: Uuid, // What was acted upon
|
||||
justification: String,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Chain Entry Structure
|
||||
|
||||
```rust
|
||||
/// A single entry in the witness chain
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct WitnessEntry {
|
||||
/// Sequential index in the chain
|
||||
index: u64,
|
||||
|
||||
/// SHAKE-256 hash of the previous entry (32 bytes)
|
||||
previous_hash: [u8; 32],
|
||||
|
||||
/// The witnessed event
|
||||
event: WitnessedEvent,
|
||||
|
||||
/// Device that created this entry
|
||||
device_id: DeviceId,
|
||||
|
||||
/// Monotonic timestamp (device-local, not wall clock)
|
||||
monotonic_timestamp: u64,
|
||||
|
||||
/// Wall clock timestamp (best-effort, may be inaccurate)
|
||||
wall_timestamp: DateTime<Utc>,
|
||||
|
||||
/// Vector clock for causal ordering (see ADR-008)
|
||||
vector_clock: VectorClock,
|
||||
|
||||
/// This entry's hash: SHAKE-256(serialize(self without this field))
|
||||
entry_hash: [u8; 32],
|
||||
|
||||
/// Anchor signature (present every N entries)
|
||||
anchor_signature: Option<HybridSignature>,
|
||||
}
|
||||
```
|
||||
|
||||
### Tamper Detection
|
||||
|
||||
```rust
|
||||
/// Verify witness chain integrity
|
||||
pub fn verify_chain(chain: &[WitnessEntry]) -> Result<ChainVerification, AuditError> {
|
||||
let mut verification = ChainVerification::new();
|
||||
|
||||
for (i, entry) in chain.iter().enumerate() {
|
||||
// 1. Verify hash chain linkage
|
||||
if i > 0 {
|
||||
let expected_prev_hash = chain[i - 1].entry_hash;
|
||||
if entry.previous_hash != expected_prev_hash {
|
||||
verification.add_violation(ChainViolation::BrokenLink {
|
||||
entry_index: entry.index,
|
||||
expected_hash: expected_prev_hash,
|
||||
actual_hash: entry.previous_hash,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Verify entry self-hash
|
||||
let computed_hash = compute_entry_hash(entry);
|
||||
if computed_hash != entry.entry_hash {
|
||||
verification.add_violation(ChainViolation::TamperedEntry {
|
||||
entry_index: entry.index,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Verify anchor signatures
|
||||
if let Some(ref sig) = entry.anchor_signature {
|
||||
let device_keys = load_device_keys(&entry.device_id)?;
|
||||
if !sig.verify(&entry.entry_hash, &device_keys.ed25519, &device_keys.ml_dsa)? {
|
||||
verification.add_violation(ChainViolation::InvalidSignature {
|
||||
entry_index: entry.index,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Verify monotonic timestamp ordering
|
||||
if i > 0 && entry.monotonic_timestamp <= chain[i - 1].monotonic_timestamp {
|
||||
verification.add_violation(ChainViolation::NonMonotonicTimestamp {
|
||||
entry_index: entry.index,
|
||||
});
|
||||
}
|
||||
|
||||
verification.verified_entries += 1;
|
||||
}
|
||||
|
||||
Ok(verification)
|
||||
}
|
||||
```
|
||||
|
||||
### Cross-Device Attestation
|
||||
|
||||
Multiple APs can cross-reference each other's chains for stronger guarantees:
|
||||
|
||||
```
|
||||
Device A's chain: Device B's chain:
|
||||
┌──────────┐ ┌──────────┐
|
||||
│ Entry 50 │ │ Entry 73 │
|
||||
│ H_A50 │◀────── cross-attest ───▶│ H_B73 │
|
||||
└──────────┘ └──────────┘
|
||||
|
||||
Device A records: CrossAttestation { attesting: B, hash: H_B73, index: 73 }
|
||||
Device B records: CrossAttestation { attesting: A, hash: H_A50, index: 50 }
|
||||
|
||||
After cross-attestation:
|
||||
- Neither device can rewrite entries before the attested point
|
||||
without the other device's chain becoming inconsistent
|
||||
- An investigator can verify both chains agree on the attestation point
|
||||
```
|
||||
|
||||
**Attestation frequency**: Every 5 minutes during connected operation, immediately on significant events (P1 triage, zone completion).
|
||||
|
||||
### Storage and Retrieval
|
||||
|
||||
Witness chains are stored in the RVF container's WITNESS segment:
|
||||
|
||||
```rust
|
||||
/// Witness chain storage manager
|
||||
pub struct WitnessChainStore {
|
||||
/// Current chain being appended to
|
||||
active_chain: Vec<WitnessEntry>,
|
||||
|
||||
/// Anchor signature interval
|
||||
anchor_interval: usize, // 100
|
||||
|
||||
/// Device signing key
|
||||
device_key: DeviceKeyPair,
|
||||
|
||||
/// Cross-attestation peers
|
||||
attestation_peers: Vec<DeviceId>,
|
||||
|
||||
/// RVF container for persistence
|
||||
container: RvfContainer,
|
||||
}
|
||||
|
||||
impl WitnessChainStore {
|
||||
/// Append an event to the chain
|
||||
pub fn witness(&mut self, event: WitnessedEvent) -> Result<u64, AuditError> {
|
||||
let index = self.active_chain.len() as u64;
|
||||
let previous_hash = self.active_chain.last()
|
||||
.map(|e| e.entry_hash)
|
||||
.unwrap_or([0u8; 32]);
|
||||
|
||||
let mut entry = WitnessEntry {
|
||||
index,
|
||||
previous_hash,
|
||||
event,
|
||||
device_id: self.device_key.device_id(),
|
||||
monotonic_timestamp: monotonic_now(),
|
||||
wall_timestamp: Utc::now(),
|
||||
vector_clock: self.get_current_vclock(),
|
||||
entry_hash: [0u8; 32], // Computed below
|
||||
anchor_signature: None,
|
||||
};
|
||||
|
||||
// Compute entry hash
|
||||
entry.entry_hash = compute_entry_hash(&entry);
|
||||
|
||||
// Add anchor signature at interval
|
||||
if index % self.anchor_interval as u64 == 0 {
|
||||
entry.anchor_signature = Some(
|
||||
self.device_key.sign_hybrid(&entry.entry_hash)?
|
||||
);
|
||||
}
|
||||
|
||||
self.active_chain.push(entry);
|
||||
|
||||
// Persist to RVF container
|
||||
self.container.append_witness(&self.active_chain.last().unwrap())?;
|
||||
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
/// Query chain for events in a time range
|
||||
pub fn query_range(&self, start: DateTime<Utc>, end: DateTime<Utc>)
|
||||
-> Vec<&WitnessEntry>
|
||||
{
|
||||
self.active_chain.iter()
|
||||
.filter(|e| e.wall_timestamp >= start && e.wall_timestamp <= end)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Export chain for external audit
|
||||
pub fn export_for_audit(&self) -> AuditBundle {
|
||||
AuditBundle {
|
||||
chain: self.active_chain.clone(),
|
||||
device_public_key: self.device_key.public_keys(),
|
||||
cross_attestations: self.collect_cross_attestations(),
|
||||
chain_summary: self.compute_summary(),
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Performance Impact
|
||||
|
||||
| Operation | Latency | Notes |
|
||||
|-----------|---------|-------|
|
||||
| Append entry | 0.05 ms | Hash computation + serialize |
|
||||
| Append with anchor signature | 0.5 ms | + ML-DSA-65 sign |
|
||||
| Verify single entry | 0.02 ms | Hash comparison |
|
||||
| Verify anchor | 0.3 ms | ML-DSA-65 verify |
|
||||
| Full chain verify (10K entries) | 50 ms | Sequential hash verification |
|
||||
| Cross-attestation | 1 ms | Sign + network round-trip |
|
||||
|
||||
### Storage Requirements
|
||||
|
||||
| Chain Length | Entries/Hour | Size/Hour | Size/Day |
|
||||
|-------------|-------------|-----------|----------|
|
||||
| Low activity | ~100 | ~20 KB | ~480 KB |
|
||||
| Normal operation | ~1,000 | ~200 KB | ~4.8 MB |
|
||||
| Disaster response | ~10,000 | ~2 MB | ~48 MB |
|
||||
| High-intensity scan | ~50,000 | ~10 MB | ~240 MB |
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- **Tamper-evident**: Any modification to historical records is detectable
|
||||
- **Non-repudiable**: Signed anchors prove device identity
|
||||
- **Complete history**: Every detection, triage, and correction is recorded
|
||||
- **Cross-verified**: Multi-device attestation strengthens guarantees
|
||||
- **Forensically sound**: Exportable audit bundles for legal proceedings
|
||||
- **Low overhead**: 0.05ms per entry; minimal storage for normal operation
|
||||
|
||||
### Negative
|
||||
- **Append-only growth**: Chains grow monotonically; need archival strategy for long deployments
|
||||
- **Key management**: Device keys must be provisioned and protected
|
||||
- **Clock dependency**: Wall-clock timestamps are best-effort; monotonic timestamps are device-local
|
||||
- **Verification cost**: Full chain verification of long chains takes meaningful time (50ms/10K entries)
|
||||
- **Privacy tension**: Detailed audit trails contain operational intelligence
|
||||
|
||||
### Regulatory Alignment
|
||||
|
||||
| Requirement | How Witness Chains Address It |
|
||||
|------------|------------------------------|
|
||||
| GDPR (Right to erasure) | Event hashes stored, not personal data; original data deletable while chain proves historical integrity |
|
||||
| HIPAA (Audit controls) | Complete access/modification log with non-repudiation |
|
||||
| ISO 27001 (Information security) | Tamper-evident records, access logging, integrity verification |
|
||||
| NIST SP 800-53 (AU controls) | Audit record generation, protection, and review capability |
|
||||
| FEMA ICS (Incident Command) | Chain of custody for all operational decisions |
|
||||
|
||||
## References
|
||||
|
||||
- [Witness Chains in Distributed Systems](https://eprint.iacr.org/2019/747)
|
||||
- [SHAKE-256 (FIPS 202)](https://csrc.nist.gov/pubs/fips/202/final)
|
||||
- [Tamper-Evident Logging](https://www.usenix.org/legacy/event/sec09/tech/full_papers/crosby.pdf)
|
||||
- [RuVector Witness Implementation](https://github.com/ruvnet/ruvector)
|
||||
- ADR-001: WiFi-Mat Disaster Detection Architecture
|
||||
- ADR-007: Post-Quantum Cryptography for Secure Sensing
|
||||
- ADR-008: Distributed Consensus for Multi-AP Coordination
|
||||
@@ -1,414 +0,0 @@
|
||||
# ADR-011: Python Proof-of-Reality and Mock Elimination
|
||||
|
||||
## Status
|
||||
Proposed (URGENT)
|
||||
|
||||
## Date
|
||||
2026-02-28
|
||||
|
||||
## Context
|
||||
|
||||
### The Credibility Problem
|
||||
|
||||
The WiFi-DensePose Python codebase contains real, mathematically sound signal processing (FFT, phase unwrapping, Doppler extraction, correlation features) alongside mock/placeholder code that fatally undermines credibility. External reviewers who encounter **any** mock path in the default execution flow conclude the entire system is synthetic. This is not a technical problem - it is a perception problem with technical root causes.
|
||||
|
||||
### Specific Mock/Placeholder Inventory
|
||||
|
||||
The following code paths produce fake data **in the default configuration** or are easily mistaken for indicating fake functionality:
|
||||
|
||||
#### Critical Severity (produces fake output on default path)
|
||||
|
||||
| File | Line | Issue | Impact |
|
||||
|------|------|-------|--------|
|
||||
| `v1/src/core/csi_processor.py` | 390 | `doppler_shift = np.random.rand(10) # Placeholder` | **Real feature extractor returns random Doppler** - kills credibility of entire feature pipeline |
|
||||
| `v1/src/hardware/csi_extractor.py` | 83-84 | `amplitude = np.random.rand(...)` in CSI extraction fallback | Random data silently substituted when parsing fails |
|
||||
| `v1/src/hardware/csi_extractor.py` | 129-135 | `_parse_atheros()` returns `np.random.rand()` with comment "placeholder implementation" | Named as if it parses real data, actually random |
|
||||
| `v1/src/hardware/router_interface.py` | 211-212 | `np.random.rand(3, 56)` in fallback path | Silent random fallback |
|
||||
| `v1/src/services/pose_service.py` | 431 | `mock_csi = np.random.randn(64, 56, 3) # Mock CSI data` | Mock CSI in production code path |
|
||||
| `v1/src/services/pose_service.py` | 293-356 | `_generate_mock_poses()` with `random.randint` throughout | Entire mock pose generator in service layer |
|
||||
| `v1/src/services/pose_service.py` | 489-607 | Multiple `random.randint` for occupancy, historical data | Fake statistics that look real in API responses |
|
||||
| `v1/src/api/dependencies.py` | 82, 408 | "return a mock user for development" | Auth bypass in default path |
|
||||
|
||||
#### Moderate Severity (mock gated behind flags but confusing)
|
||||
|
||||
| File | Line | Issue |
|
||||
|------|------|-------|
|
||||
| `v1/src/config/settings.py` | 144-145 | `mock_hardware=False`, `mock_pose_data=False` defaults - correct, but mock infrastructure exists |
|
||||
| `v1/src/core/router_interface.py` | 27-300 | 270+ lines of mock data generation infrastructure in production code |
|
||||
| `v1/src/services/pose_service.py` | 84-88 | Silent conditional: `if not self.settings.mock_pose_data` with no logging of real-mode |
|
||||
| `v1/src/services/hardware_service.py` | 72-375 | Interleaved mock/real paths throughout |
|
||||
|
||||
#### Low Severity (placeholders/TODOs)
|
||||
|
||||
| File | Line | Issue |
|
||||
|------|------|-------|
|
||||
| `v1/src/core/router_interface.py` | 198 | "Collect real CSI data from router (placeholder implementation)" |
|
||||
| `v1/src/api/routers/health.py` | 170-171 | `uptime_seconds = 0.0 # TODO` |
|
||||
| `v1/src/services/pose_service.py` | 739 | `"uptime_seconds": 0.0 # TODO` |
|
||||
|
||||
### Root Cause Analysis
|
||||
|
||||
1. **No separation between mock and real**: Mock generators live in the same modules as real processors. A reviewer reading `csi_processor.py` hits `np.random.rand(10)` at line 390 and stops trusting the 400 lines of real signal processing above it.
|
||||
|
||||
2. **Silent fallbacks**: When real hardware isn't available, the system silently falls back to random data instead of failing loudly. This means the default `docker compose up` produces plausible-looking but entirely fake results.
|
||||
|
||||
3. **No proof artifact**: There is no shipped CSI capture file, no expected output hash, no way for a reviewer to verify that the pipeline produces deterministic results from real input.
|
||||
|
||||
4. **Build environment fragility**: The `Dockerfile` references `requirements.txt` which doesn't exist as a standalone file. The `setup.py` hardcodes 87 dependencies. ONNX Runtime and BLAS are not in the container. A `docker build` may or may not succeed depending on the machine.
|
||||
|
||||
5. **No CI verification**: No GitHub Actions workflow runs the pipeline on a real or deterministic input and verifies the output.
|
||||
|
||||
## Decision
|
||||
|
||||
We will eliminate the credibility gap through five concrete changes:
|
||||
|
||||
### 1. Eliminate All Silent Mock Fallbacks (HARD FAIL)
|
||||
|
||||
**Every path that currently returns `np.random.rand()` will either be replaced with real computation or will raise an explicit error.**
|
||||
|
||||
```python
|
||||
# BEFORE (csi_processor.py:390)
|
||||
doppler_shift = np.random.rand(10) # Placeholder
|
||||
|
||||
# AFTER
|
||||
def _extract_doppler_features(self, csi_data: CSIData) -> tuple:
|
||||
"""Extract Doppler and frequency domain features from CSI temporal history."""
|
||||
if len(self.csi_history) < 2:
|
||||
# Not enough history for temporal analysis - return zeros, not random
|
||||
doppler_shift = np.zeros(self.window_size)
|
||||
psd = np.abs(scipy.fft.fft(csi_data.amplitude.flatten(), n=128))**2
|
||||
return doppler_shift, psd
|
||||
|
||||
# Real Doppler extraction from temporal CSI differences
|
||||
history_array = np.array([h.amplitude for h in self.get_recent_history(self.window_size)])
|
||||
# Compute phase differences over time (proportional to Doppler shift)
|
||||
temporal_phase_diff = np.diff(np.angle(history_array + 1j * np.zeros_like(history_array)), axis=0)
|
||||
# Average across antennas, FFT across time for Doppler spectrum
|
||||
doppler_spectrum = np.abs(scipy.fft.fft(temporal_phase_diff.mean(axis=1), axis=0))
|
||||
doppler_shift = doppler_spectrum.mean(axis=1)
|
||||
|
||||
psd = np.abs(scipy.fft.fft(csi_data.amplitude.flatten(), n=128))**2
|
||||
return doppler_shift, psd
|
||||
```
|
||||
|
||||
```python
|
||||
# BEFORE (csi_extractor.py:129-135)
|
||||
def _parse_atheros(self, raw_data):
|
||||
"""Parse Atheros CSI format (placeholder implementation)."""
|
||||
# For now, return mock data for testing
|
||||
return CSIData(amplitude=np.random.rand(3, 56), ...)
|
||||
|
||||
# AFTER
|
||||
def _parse_atheros(self, raw_data: bytes) -> CSIData:
|
||||
"""Parse Atheros CSI Tool format.
|
||||
|
||||
Format: https://dhalperi.github.io/linux-80211n-csitool/
|
||||
"""
|
||||
if len(raw_data) < 25: # Minimum Atheros CSI header
|
||||
raise CSIExtractionError(
|
||||
f"Atheros CSI data too short ({len(raw_data)} bytes). "
|
||||
"Expected real CSI capture from Atheros-based NIC. "
|
||||
"See docs/hardware-setup.md for capture instructions."
|
||||
)
|
||||
# Parse actual Atheros binary format
|
||||
# ... real parsing implementation ...
|
||||
```
|
||||
|
||||
### 2. Isolate Mock Infrastructure Behind Explicit Flag with Banner
|
||||
|
||||
**All mock code moves to a dedicated module. Default execution NEVER touches mock paths.**
|
||||
|
||||
```
|
||||
v1/src/
|
||||
├── core/
|
||||
│ ├── csi_processor.py # Real processing only
|
||||
│ └── router_interface.py # Real hardware interface only
|
||||
├── testing/ # NEW: isolated mock module
|
||||
│ ├── __init__.py
|
||||
│ ├── mock_csi_generator.py # Mock CSI generation (moved from router_interface)
|
||||
│ ├── mock_pose_generator.py # Mock poses (moved from pose_service)
|
||||
│ └── fixtures/ # Test fixtures, not production paths
|
||||
│ ├── sample_csi_capture.bin # Real captured CSI data (tiny sample)
|
||||
│ └── expected_output.json # Expected pipeline output for sample
|
||||
```
|
||||
|
||||
**Runtime enforcement:**
|
||||
```python
|
||||
import os
|
||||
import sys
|
||||
|
||||
MOCK_MODE = os.environ.get("WIFI_DENSEPOSE_MOCK", "").lower() == "true"
|
||||
|
||||
if MOCK_MODE:
|
||||
# Print banner on EVERY log line
|
||||
_original_log = logging.Logger._log
|
||||
def _mock_banner_log(self, level, msg, args, **kwargs):
|
||||
_original_log(self, level, f"[MOCK MODE] {msg}", args, **kwargs)
|
||||
logging.Logger._log = _mock_banner_log
|
||||
|
||||
print("=" * 72, file=sys.stderr)
|
||||
print(" WARNING: RUNNING IN MOCK MODE - ALL DATA IS SYNTHETIC", file=sys.stderr)
|
||||
print(" Set WIFI_DENSEPOSE_MOCK=false for real operation", file=sys.stderr)
|
||||
print("=" * 72, file=sys.stderr)
|
||||
```
|
||||
|
||||
### 3. Ship a Reproducible Proof Bundle
|
||||
|
||||
A small real CSI capture file + one-command verification pipeline:
|
||||
|
||||
```
|
||||
v1/data/proof/
|
||||
├── README.md # How to verify
|
||||
├── sample_csi_capture.bin # Real CSI data (1 second, ~50 KB)
|
||||
├── sample_csi_capture_meta.json # Capture metadata (hardware, env)
|
||||
├── expected_features.json # Expected feature extraction output
|
||||
├── expected_features.sha256 # SHA-256 hash of expected output
|
||||
└── verify.py # One-command verification script
|
||||
```
|
||||
|
||||
**verify.py**:
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""Verify WiFi-DensePose pipeline produces deterministic output from real CSI data.
|
||||
|
||||
Usage:
|
||||
python v1/data/proof/verify.py
|
||||
|
||||
Expected output:
|
||||
PASS: Pipeline output matches expected hash
|
||||
SHA256: <hash>
|
||||
|
||||
If this passes, the signal processing pipeline is producing real,
|
||||
deterministic results from real captured CSI data.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Ensure reproducibility
|
||||
os.environ["PYTHONHASHSEED"] = "42"
|
||||
import numpy as np
|
||||
np.random.seed(42) # Only affects any remaining random elements
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../.."))
|
||||
|
||||
from src.core.csi_processor import CSIProcessor
|
||||
from src.hardware.csi_extractor import CSIExtractor
|
||||
|
||||
def main():
|
||||
# Load real captured CSI data
|
||||
capture_path = os.path.join(os.path.dirname(__file__), "sample_csi_capture.bin")
|
||||
meta_path = os.path.join(os.path.dirname(__file__), "sample_csi_capture_meta.json")
|
||||
expected_hash_path = os.path.join(os.path.dirname(__file__), "expected_features.sha256")
|
||||
|
||||
with open(meta_path) as f:
|
||||
meta = json.load(f)
|
||||
|
||||
# Extract CSI from binary capture
|
||||
extractor = CSIExtractor(format=meta["format"])
|
||||
csi_data = extractor.extract_from_file(capture_path)
|
||||
|
||||
# Process through feature pipeline
|
||||
config = {
|
||||
"sampling_rate": meta["sampling_rate"],
|
||||
"window_size": meta["window_size"],
|
||||
"overlap": meta["overlap"],
|
||||
"noise_threshold": meta["noise_threshold"],
|
||||
}
|
||||
processor = CSIProcessor(config)
|
||||
features = processor.extract_features(csi_data)
|
||||
|
||||
# Serialize features deterministically
|
||||
output = {
|
||||
"amplitude_mean": features.amplitude_mean.tolist(),
|
||||
"amplitude_variance": features.amplitude_variance.tolist(),
|
||||
"phase_difference": features.phase_difference.tolist(),
|
||||
"doppler_shift": features.doppler_shift.tolist(),
|
||||
"psd_first_16": features.power_spectral_density[:16].tolist(),
|
||||
}
|
||||
output_json = json.dumps(output, sort_keys=True, separators=(",", ":"))
|
||||
output_hash = hashlib.sha256(output_json.encode()).hexdigest()
|
||||
|
||||
# Verify against expected hash
|
||||
with open(expected_hash_path) as f:
|
||||
expected_hash = f.read().strip()
|
||||
|
||||
if output_hash == expected_hash:
|
||||
print(f"PASS: Pipeline output matches expected hash")
|
||||
print(f"SHA256: {output_hash}")
|
||||
print(f"Features: {len(output['amplitude_mean'])} subcarriers processed")
|
||||
return 0
|
||||
else:
|
||||
print(f"FAIL: Hash mismatch")
|
||||
print(f"Expected: {expected_hash}")
|
||||
print(f"Got: {output_hash}")
|
||||
return 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
```
|
||||
|
||||
### 4. Pin the Build Environment
|
||||
|
||||
**Option A (recommended): Deterministic Dockerfile that works on fresh machine**
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.11-slim
|
||||
|
||||
# System deps that actually matter
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libopenblas-dev \
|
||||
libfftw3-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Pinned requirements (not a reference to missing file)
|
||||
COPY v1/requirements-lock.txt ./requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY v1/ ./v1/
|
||||
|
||||
# Proof of reality: verify pipeline on build
|
||||
RUN cd v1 && python data/proof/verify.py
|
||||
|
||||
EXPOSE 8000
|
||||
# Default: REAL mode (mock requires explicit opt-in)
|
||||
ENV WIFI_DENSEPOSE_MOCK=false
|
||||
CMD ["uvicorn", "v1.src.api.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
```
|
||||
|
||||
**Key change**: `RUN python data/proof/verify.py` **during build** means the Docker image cannot be created unless the pipeline produces correct output from real CSI data.
|
||||
|
||||
**Requirements lockfile** (`v1/requirements-lock.txt`):
|
||||
```
|
||||
# Core (required)
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
pydantic==2.10.4
|
||||
pydantic-settings==2.7.1
|
||||
numpy==1.26.4
|
||||
scipy==1.14.1
|
||||
|
||||
# Signal processing (required)
|
||||
# No ONNX required for basic pipeline verification
|
||||
|
||||
# Optional (install separately for full features)
|
||||
# torch>=2.1.0
|
||||
# onnxruntime>=1.17.0
|
||||
```
|
||||
|
||||
### 5. CI Pipeline That Proves Reality
|
||||
|
||||
```yaml
|
||||
# .github/workflows/verify-pipeline.yml
|
||||
name: Verify Signal Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
paths: ['v1/src/**', 'v1/data/proof/**']
|
||||
pull_request:
|
||||
paths: ['v1/src/**']
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Install minimal deps
|
||||
run: pip install numpy scipy pydantic pydantic-settings
|
||||
- name: Verify pipeline determinism
|
||||
run: python v1/data/proof/verify.py
|
||||
- name: Verify no random in production paths
|
||||
run: |
|
||||
# Fail if np.random appears in production code (not in testing/)
|
||||
! grep -r "np\.random\.\(rand\|randn\|randint\)" v1/src/ \
|
||||
--include="*.py" \
|
||||
--exclude-dir=testing \
|
||||
|| (echo "FAIL: np.random found in production code" && exit 1)
|
||||
```
|
||||
|
||||
### Concrete File Changes Required
|
||||
|
||||
| File | Action | Description |
|
||||
|------|--------|-------------|
|
||||
| `v1/src/core/csi_processor.py:390` | **Replace** | Real Doppler extraction from temporal CSI history |
|
||||
| `v1/src/hardware/csi_extractor.py:83-84` | **Replace** | Hard error with descriptive message when parsing fails |
|
||||
| `v1/src/hardware/csi_extractor.py:129-135` | **Replace** | Real Atheros CSI parser or hard error with hardware instructions |
|
||||
| `v1/src/hardware/router_interface.py:198-212` | **Replace** | Hard error for unimplemented hardware, or real `iwconfig` + CSI tool integration |
|
||||
| `v1/src/services/pose_service.py:293-356` | **Move** | Move `_generate_mock_poses()` to `v1/src/testing/mock_pose_generator.py` |
|
||||
| `v1/src/services/pose_service.py:430-431` | **Remove** | Remove mock CSI generation from production path |
|
||||
| `v1/src/services/pose_service.py:489-607` | **Replace** | Real statistics from database, or explicit "no data" response |
|
||||
| `v1/src/core/router_interface.py:60-300` | **Move** | Move mock generator to `v1/src/testing/mock_csi_generator.py` |
|
||||
| `v1/src/api/dependencies.py:82,408` | **Replace** | Real auth check or explicit dev-mode bypass with logging |
|
||||
| `v1/data/proof/` | **Create** | Proof bundle (sample capture + expected hash + verify script) |
|
||||
| `v1/requirements-lock.txt` | **Create** | Pinned minimal dependencies |
|
||||
| `.github/workflows/verify-pipeline.yml` | **Create** | CI verification |
|
||||
|
||||
### Hardware Documentation
|
||||
|
||||
```
|
||||
v1/docs/hardware-setup.md (to be created)
|
||||
|
||||
# Supported Hardware Matrix
|
||||
|
||||
| Chipset | Tool | OS | Capture Command |
|
||||
|---------|------|----|-----------------|
|
||||
| Intel 5300 | Linux 802.11n CSI Tool | Ubuntu 18.04 | `sudo ./log_to_file csi.dat` |
|
||||
| Atheros AR9580 | Atheros CSI Tool | Ubuntu 14.04 | `sudo ./recv_csi csi.dat` |
|
||||
| Broadcom BCM4339 | Nexmon CSI | Android/Nexus 5 | `nexutil -m1 -k1 ...` |
|
||||
| ESP32 | ESP32-CSI | ESP-IDF | `csi_recv --format binary` |
|
||||
|
||||
# Calibration
|
||||
1. Place router and receiver 2m apart, line of sight
|
||||
2. Capture 10 seconds of empty-room baseline
|
||||
3. Have one person walk through at normal pace
|
||||
4. Capture 10 seconds during walk-through
|
||||
5. Run calibration: `python v1/scripts/calibrate.py --baseline empty.dat --activity walk.dat`
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- **"Clone, build, verify" in one command**: `docker build . && docker run --rm wifi-densepose python v1/data/proof/verify.py` produces a deterministic PASS
|
||||
- **No silent fakes**: Random data never appears in production output
|
||||
- **CI enforcement**: PRs that introduce `np.random` in production paths fail automatically
|
||||
- **Credibility anchor**: SHA-256 verified output from real CSI capture is unchallengeable proof
|
||||
- **Clear mock boundary**: Mock code exists only in `v1/src/testing/`, never imported by production modules
|
||||
|
||||
### Negative
|
||||
- **Requires real CSI capture**: Someone must capture and commit a real CSI sample (one-time effort)
|
||||
- **Build may fail without hardware**: Without mock fallback, systems without WiFi hardware cannot demo - must use proof bundle instead
|
||||
- **Migration effort**: Moving mock code to separate module requires updating imports in test files
|
||||
- **Stricter development workflow**: Developers must explicitly opt in to mock mode
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
A stranger can:
|
||||
1. `git clone` the repository
|
||||
2. Run ONE command (`docker build .` or `python v1/data/proof/verify.py`)
|
||||
3. See `PASS: Pipeline output matches expected hash` with a specific SHA-256
|
||||
4. Confirm no `np.random` in any non-test file via CI badge
|
||||
|
||||
If this works 100% over 5 runs on a clean machine, the "fake" narrative dies.
|
||||
|
||||
### Answering the Two Key Questions
|
||||
|
||||
**Q1: Docker or Nix first?**
|
||||
Recommendation: **Docker first**. The Dockerfile already exists, just needs fixing. Nix is higher quality but smaller audience. Docker gives the widest "clone and verify" coverage.
|
||||
|
||||
**Q2: Are external crates public and versioned?**
|
||||
The Python dependencies are all public PyPI packages. The Rust `ruvector-core` and `ruvector-data-framework` crates are currently commented out in `Cargo.toml` (lines 83-84: `# ruvector-core = "0.1"`) and are not yet published to crates.io. They are internal to ruvnet. This is a blocker for the Rust path but does not affect the Python proof-of-reality work in this ADR.
|
||||
|
||||
## References
|
||||
|
||||
- [Linux 802.11n CSI Tool](https://dhalperi.github.io/linux-80211n-csitool/)
|
||||
- [Atheros CSI Tool](https://wands.sg/research/wifi/AthesCSI/)
|
||||
- [Nexmon CSI](https://github.com/seemoo-lab/nexmon_csi)
|
||||
- [ESP32 CSI](https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-guides/wifi.html#wi-fi-channel-state-information)
|
||||
- [Reproducible Builds](https://reproducible-builds.org/)
|
||||
- ADR-002: RuVector RVF Integration Strategy
|
||||
@@ -1,318 +0,0 @@
|
||||
# ADR-012: ESP32 CSI Sensor Mesh for Distributed Sensing
|
||||
|
||||
## Status
|
||||
Proposed
|
||||
|
||||
## Date
|
||||
2026-02-28
|
||||
|
||||
## Context
|
||||
|
||||
### The Hardware Reality Gap
|
||||
|
||||
WiFi-DensePose's Rust and Python pipelines implement real signal processing (FFT, phase unwrapping, Doppler extraction, correlation features), but the system currently has no defined path from **physical WiFi hardware → CSI bytes → pipeline input**. The `csi_extractor.py` and `router_interface.py` modules contain placeholder parsers that return `np.random.rand()` instead of real parsed data (see ADR-011).
|
||||
|
||||
To close this gap, we need a concrete, affordable, reproducible hardware platform that produces real CSI data and streams it into the existing pipeline.
|
||||
|
||||
### Why ESP32
|
||||
|
||||
| Factor | ESP32/ESP32-S3 | Intel 5300 (iwl5300) | Atheros AR9580 |
|
||||
|--------|---------------|---------------------|----------------|
|
||||
| Cost | ~$5-15/node | ~$50-100 (used NIC) | ~$30-60 (used NIC) |
|
||||
| Availability | Mass produced, in stock | Discontinued, eBay only | Discontinued, eBay only |
|
||||
| CSI Support | Official ESP-IDF API | Linux CSI Tool (kernel mod) | Atheros CSI Tool |
|
||||
| Form Factor | Standalone MCU | Requires PCIe/Mini-PCIe host | Requires PCIe host |
|
||||
| Deployment | Battery/USB, wireless | Desktop/laptop only | Desktop/laptop only |
|
||||
| Antenna Config | 1-2 TX, 1-2 RX | 3 TX, 3 RX (MIMO) | 3 TX, 3 RX (MIMO) |
|
||||
| Subcarriers | 52-56 (802.11n) | 30 (compressed) | 56 (full) |
|
||||
| Fidelity | Lower (consumer SoC) | Higher (dedicated NIC) | Higher (dedicated NIC) |
|
||||
|
||||
**ESP32 wins on deployability**: It's the only option where a stranger can buy nodes on Amazon, flash firmware, and have a working CSI mesh in an afternoon. Intel 5300 and Atheros cards require specific hardware, kernel modifications, and legacy OS versions.
|
||||
|
||||
### ESP-IDF CSI API
|
||||
|
||||
Espressif provides official CSI support through three key functions:
|
||||
|
||||
```c
|
||||
// 1. Configure what CSI data to capture
|
||||
wifi_csi_config_t csi_config = {
|
||||
.lltf_en = true, // Long Training Field (best for CSI)
|
||||
.htltf_en = true, // HT-LTF
|
||||
.stbc_htltf2_en = true, // STBC HT-LTF2
|
||||
.ltf_merge_en = true, // Merge LTFs
|
||||
.channel_filter_en = false,
|
||||
.manu_scale = false,
|
||||
};
|
||||
esp_wifi_set_csi_config(&csi_config);
|
||||
|
||||
// 2. Register callback for received CSI data
|
||||
esp_wifi_set_csi_rx_cb(csi_data_callback, NULL);
|
||||
|
||||
// 3. Enable CSI collection
|
||||
esp_wifi_set_csi(true);
|
||||
|
||||
// Callback receives:
|
||||
void csi_data_callback(void *ctx, wifi_csi_info_t *info) {
|
||||
// info->rx_ctrl: RSSI, noise_floor, channel, secondary_channel, etc.
|
||||
// info->buf: Raw CSI data (I/Q pairs per subcarrier)
|
||||
// info->len: Length of CSI data buffer
|
||||
// Typical: 112 bytes = 56 subcarriers × 2 (I,Q) × 1 byte each
|
||||
}
|
||||
```
|
||||
|
||||
## Decision
|
||||
|
||||
We will build an ESP32 CSI Sensor Mesh as the primary hardware integration path, with a full stack from firmware to aggregator to Rust pipeline to visualization.
|
||||
|
||||
### System Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ ESP32 CSI Sensor Mesh │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ ESP32 │ │ ESP32 │ │ ESP32 │ ... (3-6 nodes) │
|
||||
│ │ Node 1 │ │ Node 2 │ │ Node 3 │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ CSI Rx │ │ CSI Rx │ │ CSI Rx │ ← WiFi frames from │
|
||||
│ │ FFT │ │ FFT │ │ FFT │ consumer router │
|
||||
│ │ Features │ │ Features │ │ Features │ │
|
||||
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
||||
│ │ │ │ │
|
||||
│ │ UDP/TCP stream (WiFi or secondary channel) │
|
||||
│ │ │ │ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌─────────────────────────────────────────┐ │
|
||||
│ │ Aggregator │ │
|
||||
│ │ (Laptop / Raspberry Pi / Seed device) │ │
|
||||
│ │ │ │
|
||||
│ │ 1. Receive CSI streams from all nodes │ │
|
||||
│ │ 2. Timestamp alignment (per-node) │ │
|
||||
│ │ 3. Feature-level fusion │ │
|
||||
│ │ 4. Feed into Rust/Python pipeline │ │
|
||||
│ │ 5. Serve WebSocket to visualization │ │
|
||||
│ └──────────────────┬──────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────┐ │
|
||||
│ │ WiFi-DensePose Pipeline │ │
|
||||
│ │ │ │
|
||||
│ │ CsiProcessor → FeatureExtractor → │ │
|
||||
│ │ MotionDetector → PoseEstimator → │ │
|
||||
│ │ Three.js Visualization │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Node Firmware Specification
|
||||
|
||||
**ESP-IDF project**: `firmware/esp32-csi-node/`
|
||||
|
||||
```
|
||||
firmware/esp32-csi-node/
|
||||
├── CMakeLists.txt
|
||||
├── sdkconfig.defaults # Menuconfig defaults with CSI enabled
|
||||
├── main/
|
||||
│ ├── CMakeLists.txt
|
||||
│ ├── main.c # Entry point, WiFi init, CSI callback
|
||||
│ ├── csi_collector.c # CSI data collection and buffering
|
||||
│ ├── csi_collector.h
|
||||
│ ├── feature_extract.c # On-device FFT and feature extraction
|
||||
│ ├── feature_extract.h
|
||||
│ ├── stream_sender.c # UDP stream to aggregator
|
||||
│ ├── stream_sender.h
|
||||
│ ├── config.h # Node configuration (SSID, aggregator IP)
|
||||
│ └── Kconfig.projbuild # Menuconfig options
|
||||
├── components/
|
||||
│ └── esp_dsp/ # Espressif DSP library for FFT
|
||||
└── README.md # Flash instructions
|
||||
```
|
||||
|
||||
**On-device processing** (reduces bandwidth, node does pre-processing):
|
||||
|
||||
```c
|
||||
// feature_extract.c
|
||||
typedef struct {
|
||||
uint32_t timestamp_ms; // Local monotonic timestamp
|
||||
uint8_t node_id; // This node's ID
|
||||
int8_t rssi; // Received signal strength
|
||||
int8_t noise_floor; // Noise floor estimate
|
||||
uint8_t channel; // WiFi channel
|
||||
float amplitude[56]; // |CSI| per subcarrier (from I/Q)
|
||||
float phase[56]; // arg(CSI) per subcarrier
|
||||
float doppler_energy; // Motion energy from temporal FFT
|
||||
float breathing_band; // 0.1-0.5 Hz band power
|
||||
float motion_band; // 0.5-3 Hz band power
|
||||
} csi_feature_frame_t;
|
||||
// Size: ~470 bytes per frame
|
||||
// At 100 Hz: ~47 KB/s per node, ~280 KB/s for 6 nodes
|
||||
```
|
||||
|
||||
**Key firmware design decisions**:
|
||||
|
||||
1. **Feature extraction on-device**: Raw CSI I/Q → amplitude + phase + spectral bands. This cuts bandwidth from raw ~11 KB/frame to ~470 bytes/frame.
|
||||
|
||||
2. **Monotonic timestamps**: Each node uses its own monotonic clock. No NTP synchronization attempted between nodes - clock drift is handled at the aggregator by fusing features, not raw phases (see "Clock Drift" section below).
|
||||
|
||||
3. **UDP streaming**: Low-latency, loss-tolerant. Missing frames are acceptable; ordering is maintained via sequence numbers.
|
||||
|
||||
4. **Configurable sampling rate**: 10-100 Hz via menuconfig. 100 Hz for motion detection, 10 Hz sufficient for occupancy.
|
||||
|
||||
### Aggregator Specification
|
||||
|
||||
The aggregator runs on any machine with WiFi/Ethernet to the nodes:
|
||||
|
||||
```rust
|
||||
// In wifi-densepose-rs, new module: crates/wifi-densepose-hardware/src/esp32/
|
||||
pub struct Esp32Aggregator {
|
||||
/// UDP socket listening for node streams
|
||||
socket: UdpSocket,
|
||||
|
||||
/// Per-node state (last timestamp, feature buffer, drift estimate)
|
||||
nodes: HashMap<u8, NodeState>,
|
||||
|
||||
/// Ring buffer of fused feature frames
|
||||
fused_buffer: VecDeque<FusedFrame>,
|
||||
|
||||
/// Channel to pipeline
|
||||
pipeline_tx: mpsc::Sender<CsiData>,
|
||||
}
|
||||
|
||||
/// Fused frame from all nodes for one time window
|
||||
pub struct FusedFrame {
|
||||
/// Timestamp (aggregator local, monotonic)
|
||||
timestamp: Instant,
|
||||
|
||||
/// Per-node features (may have gaps if node dropped)
|
||||
node_features: Vec<Option<CsiFeatureFrame>>,
|
||||
|
||||
/// Cross-node correlation (computed by aggregator)
|
||||
cross_node_correlation: Array2<f64>,
|
||||
|
||||
/// Fused motion energy (max across nodes)
|
||||
fused_motion_energy: f64,
|
||||
|
||||
/// Fused breathing band (coherent sum where phase aligns)
|
||||
fused_breathing_band: f64,
|
||||
}
|
||||
```
|
||||
|
||||
### Clock Drift Handling
|
||||
|
||||
ESP32 crystal oscillators drift ~20-50 ppm. Over 1 hour, two nodes may diverge by 72-180ms. This makes raw phase alignment across nodes impossible.
|
||||
|
||||
**Solution**: Feature-level fusion, not signal-level fusion.
|
||||
|
||||
```
|
||||
Signal-level (WRONG for ESP32):
|
||||
Align raw I/Q samples across nodes → requires <1µs sync → impractical
|
||||
|
||||
Feature-level (CORRECT for ESP32):
|
||||
Each node: raw CSI → amplitude + phase + spectral features (local)
|
||||
Aggregator: collect features → correlate → fuse decisions
|
||||
No cross-node phase alignment needed
|
||||
```
|
||||
|
||||
Specifically:
|
||||
- **Motion energy**: Take max across nodes (any node seeing motion = motion)
|
||||
- **Breathing band**: Use node with highest SNR as primary, others as corroboration
|
||||
- **Location**: Cross-node amplitude ratios estimate position (no phase needed)
|
||||
|
||||
### Sensing Capabilities by Deployment
|
||||
|
||||
| Capability | 1 Node | 3 Nodes | 6 Nodes | Evidence |
|
||||
|-----------|--------|---------|---------|----------|
|
||||
| Presence detection | Good | Excellent | Excellent | Single-node RSSI variance |
|
||||
| Coarse motion | Good | Excellent | Excellent | Doppler energy |
|
||||
| Room-level location | None | Good | Excellent | Amplitude ratios |
|
||||
| Respiration | Marginal | Good | Good | 0.1-0.5 Hz band, placement-sensitive |
|
||||
| Heartbeat | Poor | Poor-Marginal | Marginal | Requires ideal placement, low noise |
|
||||
| Multi-person count | None | Marginal | Good | Spatial diversity |
|
||||
| Pose estimation | None | Poor | Marginal | Requires model + sufficient diversity |
|
||||
|
||||
**Honest assessment**: ESP32 CSI is lower fidelity than Intel 5300 or Atheros. Heartbeat detection is placement-sensitive and unreliable. Respiration works with good placement. Motion and presence are solid.
|
||||
|
||||
### Failure Modes and Mitigations
|
||||
|
||||
| Failure Mode | Severity | Mitigation |
|
||||
|-------------|----------|------------|
|
||||
| Multipath dominates in cluttered rooms | High | Mesh diversity: 3+ nodes from different angles |
|
||||
| Person occludes path between node and router | Medium | Mesh: other nodes still have clear paths |
|
||||
| Clock drift ruins cross-node fusion | Medium | Feature-level fusion only; no cross-node phase alignment |
|
||||
| UDP packet loss during high traffic | Low | Sequence numbers, interpolation for gaps <100ms |
|
||||
| ESP32 WiFi driver bugs with CSI | Medium | Pin ESP-IDF version, test on known-good boards |
|
||||
| Node power failure | Low | Aggregator handles missing nodes gracefully |
|
||||
|
||||
### Bill of Materials (Starter Kit)
|
||||
|
||||
| Item | Quantity | Unit Cost | Total |
|
||||
|------|----------|-----------|-------|
|
||||
| ESP32-S3-DevKitC-1 | 3 | $10 | $30 |
|
||||
| USB-A to USB-C cables | 3 | $3 | $9 |
|
||||
| USB power adapter (multi-port) | 1 | $15 | $15 |
|
||||
| Consumer WiFi router (any) | 1 | $0 (existing) | $0 |
|
||||
| Aggregator (laptop or Pi 4) | 1 | $0 (existing) | $0 |
|
||||
| **Total** | | | **$54** |
|
||||
|
||||
### Minimal Build Spec (Clone-Flash-Run)
|
||||
|
||||
```
|
||||
# Step 1: Flash one node (requires ESP-IDF installed)
|
||||
cd firmware/esp32-csi-node
|
||||
idf.py set-target esp32s3
|
||||
idf.py menuconfig # Set WiFi SSID/password, aggregator IP
|
||||
idf.py build flash monitor
|
||||
|
||||
# Step 2: Run aggregator (Docker)
|
||||
docker compose -f docker-compose.esp32.yml up
|
||||
|
||||
# Step 3: Verify with proof bundle
|
||||
# Aggregator captures 10 seconds, produces feature JSON, verifies hash
|
||||
docker exec aggregator python verify_esp32.py
|
||||
|
||||
# Step 4: Open visualization
|
||||
open http://localhost:3000 # Three.js dashboard
|
||||
```
|
||||
|
||||
### Proof of Reality for ESP32
|
||||
|
||||
```
|
||||
firmware/esp32-csi-node/proof/
|
||||
├── captured_csi_10sec.bin # Real 10-second CSI capture from ESP32
|
||||
├── captured_csi_meta.json # Board: ESP32-S3-DevKitC, ESP-IDF: 5.2, Router: TP-Link AX1800
|
||||
├── expected_features.json # Feature extraction output
|
||||
├── expected_features.sha256 # Hash verification
|
||||
└── capture_photo.jpg # Photo of actual hardware setup
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- **$54 starter kit**: Lowest possible barrier to real CSI data
|
||||
- **Mass available hardware**: ESP32 boards are in stock globally
|
||||
- **Real data path**: Eliminates every `np.random.rand()` placeholder with actual hardware input
|
||||
- **Proof artifact**: Captured CSI + expected hash proves the pipeline processes real data
|
||||
- **Scalable mesh**: Add nodes for more coverage without changing software
|
||||
- **Feature-level fusion**: Avoids the impossible problem of cross-node phase synchronization
|
||||
|
||||
### Negative
|
||||
- **Lower fidelity than research NICs**: ESP32 CSI is noisier than Intel 5300
|
||||
- **Heartbeat detection unreliable**: Micro-Doppler resolution insufficient for consistent heartbeat
|
||||
- **ESP-IDF learning curve**: Firmware development requires embedded C knowledge
|
||||
- **WiFi interference**: Nodes sharing the same channel as data traffic adds noise
|
||||
- **Placement sensitivity**: Respiration detection requires careful node positioning
|
||||
|
||||
### Interaction with Other ADRs
|
||||
- **ADR-011** (Proof of Reality): ESP32 provides the real CSI capture for the proof bundle
|
||||
- **ADR-008** (Distributed Consensus): Mesh nodes can use simplified Raft for configuration distribution
|
||||
- **ADR-003** (RVF Containers): Aggregator stores CSI features in RVF format
|
||||
- **ADR-004** (HNSW): Environment fingerprints from ESP32 mesh feed HNSW index
|
||||
|
||||
## References
|
||||
|
||||
- [Espressif ESP-CSI Repository](https://github.com/espressif/esp-csi)
|
||||
- [ESP-IDF WiFi CSI API](https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-guides/wifi.html#wi-fi-channel-state-information)
|
||||
- [ESP32 CSI Research Papers](https://ieeexplore.ieee.org/document/9439871)
|
||||
- [Wi-Fi Sensing with ESP32: A Tutorial](https://arxiv.org/abs/2207.07859)
|
||||
- ADR-011: Python Proof-of-Reality and Mock Elimination
|
||||
@@ -1,383 +0,0 @@
|
||||
# ADR-013: Feature-Level Sensing on Commodity Gear (Option 3)
|
||||
|
||||
## Status
|
||||
Proposed
|
||||
|
||||
## Date
|
||||
2026-02-28
|
||||
|
||||
## Context
|
||||
|
||||
### Not Everyone Can Deploy Custom Hardware
|
||||
|
||||
ADR-012 specifies an ESP32 CSI mesh that provides real CSI data. However, it requires:
|
||||
- Purchasing ESP32 boards
|
||||
- Flashing custom firmware
|
||||
- ESP-IDF toolchain installation
|
||||
- Physical placement of nodes
|
||||
|
||||
For many users - especially those evaluating WiFi-DensePose or deploying in managed environments - modifying hardware is not an option. We need a sensing path that works with **existing, unmodified consumer WiFi gear**.
|
||||
|
||||
### What Commodity Hardware Exposes
|
||||
|
||||
Standard WiFi drivers and tools expose several metrics without custom firmware:
|
||||
|
||||
| Signal | Source | Availability | Sampling Rate |
|
||||
|--------|--------|-------------|---------------|
|
||||
| RSSI (Received Signal Strength) | `iwconfig`, `iw`, NetworkManager | Universal | 1-10 Hz |
|
||||
| Noise floor | `iw dev wlan0 survey dump` | Most Linux drivers | ~1 Hz |
|
||||
| Link quality | `/proc/net/wireless` | Linux | 1-10 Hz |
|
||||
| MCS index / PHY rate | `iw dev wlan0 link` | Most drivers | Per-packet |
|
||||
| TX/RX bytes | `/sys/class/net/wlan0/statistics/` | Universal | Continuous |
|
||||
| Retry count | `iw dev wlan0 station dump` | Most drivers | ~1 Hz |
|
||||
| Beacon interval timing | `iw dev wlan0 scan dump` | Universal | Per-scan |
|
||||
| Channel utilization | `iw dev wlan0 survey dump` | Most drivers | ~1 Hz |
|
||||
|
||||
**RSSI is the primary signal**. It varies when humans move through the propagation path between any transmitter-receiver pair. Research confirms RSSI-based sensing for:
|
||||
- Presence detection (single receiver, threshold on variance)
|
||||
- Device-free motion detection (RSSI variance increases with movement)
|
||||
- Coarse room-level localization (multi-receiver RSSI fingerprinting)
|
||||
- Breathing detection (specialized setups, marginal quality)
|
||||
|
||||
### Research Support
|
||||
|
||||
- **RSSI-based presence**: Youssef et al. (2007) demonstrated device-free passive detection using RSSI from multiple receivers with >90% accuracy.
|
||||
- **RSSI breathing**: Abdelnasser et al. (2015) showed respiration detection via RSSI variance in controlled settings with ~85% accuracy using 4+ receivers.
|
||||
- **Device-free tracking**: Multiple receivers with RSSI fingerprinting achieve room-level (3-5m) accuracy.
|
||||
|
||||
## Decision
|
||||
|
||||
We will implement a Feature-Level Sensing module that extracts motion, presence, and coarse activity information from standard WiFi metrics available on any Linux machine without hardware modification.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ Feature-Level Sensing Pipeline │
|
||||
├──────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Data Sources (any Linux WiFi device): │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌──────────────┐ │
|
||||
│ │ RSSI │ │ Noise │ │ Link │ │ Packet Stats │ │
|
||||
│ │ Stream │ │ Floor │ │ Quality │ │ (TX/RX/Retry)│ │
|
||||
│ └────┬────┘ └────┬────┘ └────┬────┘ └──────┬───────┘ │
|
||||
│ │ │ │ │ │
|
||||
│ └───────────┴───────────┴──────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌────────────────────────────────────────────────┐ │
|
||||
│ │ Feature Extraction Engine │ │
|
||||
│ │ │ │
|
||||
│ │ 1. Rolling statistics (mean, var, skew, kurt) │ │
|
||||
│ │ 2. Spectral features (FFT of RSSI time series) │ │
|
||||
│ │ 3. Change-point detection (CUSUM, PELT) │ │
|
||||
│ │ 4. Cross-receiver correlation │ │
|
||||
│ │ 5. Packet timing jitter analysis │ │
|
||||
│ └────────────────────────┬───────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌────────────────────────────────────────────────┐ │
|
||||
│ │ Classification / Decision │ │
|
||||
│ │ │ │
|
||||
│ │ • Presence: RSSI variance > threshold │ │
|
||||
│ │ • Motion class: spectral peak frequency │ │
|
||||
│ │ • Occupancy change: change-point event │ │
|
||||
│ │ • Confidence: cross-receiver agreement │ │
|
||||
│ └────────────────────────┬───────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌────────────────────────────────────────────────┐ │
|
||||
│ │ Output: Presence/Motion Events │ │
|
||||
│ │ │ │
|
||||
│ │ { "timestamp": "...", │ │
|
||||
│ │ "presence": true, │ │
|
||||
│ │ "motion_level": "active", │ │
|
||||
│ │ "confidence": 0.87, │ │
|
||||
│ │ "receivers_agreeing": 3, │ │
|
||||
│ │ "rssi_variance": 4.2 } │ │
|
||||
│ └────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Feature Extraction Specification
|
||||
|
||||
```python
|
||||
class RssiFeatureExtractor:
|
||||
"""Extract sensing features from RSSI and link statistics.
|
||||
|
||||
No custom hardware required. Works with any WiFi interface
|
||||
that exposes standard Linux wireless statistics.
|
||||
"""
|
||||
|
||||
def __init__(self, config: FeatureSensingConfig):
|
||||
self.window_size = config.window_size # 30 seconds
|
||||
self.sampling_rate = config.sampling_rate # 10 Hz
|
||||
self.rssi_buffer = deque(maxlen=self.window_size * self.sampling_rate)
|
||||
self.noise_buffer = deque(maxlen=self.window_size * self.sampling_rate)
|
||||
|
||||
def extract_features(self) -> FeatureVector:
|
||||
rssi_array = np.array(self.rssi_buffer)
|
||||
|
||||
return FeatureVector(
|
||||
# Time-domain statistics
|
||||
rssi_mean=np.mean(rssi_array),
|
||||
rssi_variance=np.var(rssi_array),
|
||||
rssi_skewness=scipy.stats.skew(rssi_array),
|
||||
rssi_kurtosis=scipy.stats.kurtosis(rssi_array),
|
||||
rssi_range=np.ptp(rssi_array),
|
||||
rssi_iqr=np.subtract(*np.percentile(rssi_array, [75, 25])),
|
||||
|
||||
# Spectral features (FFT of RSSI time series)
|
||||
spectral_energy=self._spectral_energy(rssi_array),
|
||||
dominant_frequency=self._dominant_freq(rssi_array),
|
||||
breathing_band_power=self._band_power(rssi_array, 0.1, 0.5), # Hz
|
||||
motion_band_power=self._band_power(rssi_array, 0.5, 3.0), # Hz
|
||||
|
||||
# Change-point features
|
||||
num_change_points=self._cusum_changes(rssi_array),
|
||||
max_step_magnitude=self._max_step(rssi_array),
|
||||
|
||||
# Noise floor features (environment stability)
|
||||
noise_mean=np.mean(np.array(self.noise_buffer)),
|
||||
snr_estimate=np.mean(rssi_array) - np.mean(np.array(self.noise_buffer)),
|
||||
)
|
||||
|
||||
def _spectral_energy(self, rssi: np.ndarray) -> float:
|
||||
"""Total spectral energy excluding DC component."""
|
||||
spectrum = np.abs(scipy.fft.rfft(rssi - np.mean(rssi)))
|
||||
return float(np.sum(spectrum[1:] ** 2))
|
||||
|
||||
def _dominant_freq(self, rssi: np.ndarray) -> float:
|
||||
"""Dominant frequency in RSSI time series."""
|
||||
spectrum = np.abs(scipy.fft.rfft(rssi - np.mean(rssi)))
|
||||
freqs = scipy.fft.rfftfreq(len(rssi), d=1.0/self.sampling_rate)
|
||||
return float(freqs[np.argmax(spectrum[1:]) + 1])
|
||||
|
||||
def _band_power(self, rssi: np.ndarray, low_hz: float, high_hz: float) -> float:
|
||||
"""Power in a specific frequency band."""
|
||||
spectrum = np.abs(scipy.fft.rfft(rssi - np.mean(rssi))) ** 2
|
||||
freqs = scipy.fft.rfftfreq(len(rssi), d=1.0/self.sampling_rate)
|
||||
mask = (freqs >= low_hz) & (freqs <= high_hz)
|
||||
return float(np.sum(spectrum[mask]))
|
||||
|
||||
def _cusum_changes(self, rssi: np.ndarray) -> int:
|
||||
"""Count change points using CUSUM algorithm."""
|
||||
mean = np.mean(rssi)
|
||||
cusum_pos = np.zeros_like(rssi)
|
||||
cusum_neg = np.zeros_like(rssi)
|
||||
threshold = 3.0 * np.std(rssi)
|
||||
changes = 0
|
||||
for i in range(1, len(rssi)):
|
||||
cusum_pos[i] = max(0, cusum_pos[i-1] + rssi[i] - mean - 0.5)
|
||||
cusum_neg[i] = max(0, cusum_neg[i-1] - rssi[i] + mean - 0.5)
|
||||
if cusum_pos[i] > threshold or cusum_neg[i] > threshold:
|
||||
changes += 1
|
||||
cusum_pos[i] = 0
|
||||
cusum_neg[i] = 0
|
||||
return changes
|
||||
```
|
||||
|
||||
### Data Collection (No Root Required)
|
||||
|
||||
```python
|
||||
class LinuxWifiCollector:
|
||||
"""Collect WiFi statistics from standard Linux interfaces.
|
||||
|
||||
No root required for most operations.
|
||||
No custom drivers or firmware.
|
||||
Works with NetworkManager, wpa_supplicant, or raw iw.
|
||||
"""
|
||||
|
||||
def __init__(self, interface: str = "wlan0"):
|
||||
self.interface = interface
|
||||
|
||||
def get_rssi(self) -> float:
|
||||
"""Get current RSSI from connected AP."""
|
||||
# Method 1: /proc/net/wireless (no root)
|
||||
with open("/proc/net/wireless") as f:
|
||||
for line in f:
|
||||
if self.interface in line:
|
||||
parts = line.split()
|
||||
return float(parts[3].rstrip('.'))
|
||||
|
||||
# Method 2: iw (no root for own station)
|
||||
result = subprocess.run(
|
||||
["iw", "dev", self.interface, "link"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
for line in result.stdout.split('\n'):
|
||||
if 'signal:' in line:
|
||||
return float(line.split(':')[1].strip().split()[0])
|
||||
|
||||
raise SensingError(f"Cannot read RSSI from {self.interface}")
|
||||
|
||||
def get_noise_floor(self) -> float:
|
||||
"""Get noise floor estimate."""
|
||||
result = subprocess.run(
|
||||
["iw", "dev", self.interface, "survey", "dump"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
for line in result.stdout.split('\n'):
|
||||
if 'noise:' in line:
|
||||
return float(line.split(':')[1].strip().split()[0])
|
||||
return -95.0 # Default noise floor estimate
|
||||
|
||||
def get_link_stats(self) -> dict:
|
||||
"""Get link quality statistics."""
|
||||
result = subprocess.run(
|
||||
["iw", "dev", self.interface, "station", "dump"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
stats = {}
|
||||
for line in result.stdout.split('\n'):
|
||||
if 'tx bytes:' in line:
|
||||
stats['tx_bytes'] = int(line.split(':')[1].strip())
|
||||
elif 'rx bytes:' in line:
|
||||
stats['rx_bytes'] = int(line.split(':')[1].strip())
|
||||
elif 'tx retries:' in line:
|
||||
stats['tx_retries'] = int(line.split(':')[1].strip())
|
||||
elif 'signal:' in line:
|
||||
stats['signal'] = float(line.split(':')[1].strip().split()[0])
|
||||
return stats
|
||||
```
|
||||
|
||||
### Classification Rules
|
||||
|
||||
```python
|
||||
class PresenceClassifier:
|
||||
"""Rule-based presence and motion classifier.
|
||||
|
||||
Uses simple, interpretable rules rather than ML to ensure
|
||||
transparency and debuggability.
|
||||
"""
|
||||
|
||||
def __init__(self, config: ClassifierConfig):
|
||||
self.variance_threshold = config.variance_threshold # 2.0 dBm²
|
||||
self.motion_threshold = config.motion_threshold # 5.0 dBm²
|
||||
self.spectral_threshold = config.spectral_threshold # 10.0
|
||||
self.confidence_min_receivers = config.min_receivers # 2
|
||||
|
||||
def classify(self, features: FeatureVector,
|
||||
multi_receiver: list[FeatureVector] = None) -> SensingResult:
|
||||
|
||||
# Presence: RSSI variance exceeds empty-room baseline
|
||||
presence = features.rssi_variance > self.variance_threshold
|
||||
|
||||
# Motion level
|
||||
if features.rssi_variance > self.motion_threshold:
|
||||
motion = MotionLevel.ACTIVE
|
||||
elif features.rssi_variance > self.variance_threshold:
|
||||
motion = MotionLevel.PRESENT_STILL
|
||||
else:
|
||||
motion = MotionLevel.ABSENT
|
||||
|
||||
# Confidence from spectral energy and receiver agreement
|
||||
spectral_conf = min(1.0, features.spectral_energy / self.spectral_threshold)
|
||||
if multi_receiver:
|
||||
agreeing = sum(1 for f in multi_receiver
|
||||
if (f.rssi_variance > self.variance_threshold) == presence)
|
||||
receiver_conf = agreeing / len(multi_receiver)
|
||||
else:
|
||||
receiver_conf = 0.5 # Single receiver = lower confidence
|
||||
|
||||
confidence = 0.6 * spectral_conf + 0.4 * receiver_conf
|
||||
|
||||
return SensingResult(
|
||||
presence=presence,
|
||||
motion_level=motion,
|
||||
confidence=confidence,
|
||||
dominant_frequency=features.dominant_frequency,
|
||||
breathing_band_power=features.breathing_band_power,
|
||||
)
|
||||
```
|
||||
|
||||
### Capability Matrix (Honest Assessment)
|
||||
|
||||
| Capability | Single Receiver | 3 Receivers | 6 Receivers | Accuracy |
|
||||
|-----------|----------------|-------------|-------------|----------|
|
||||
| Binary presence | Yes | Yes | Yes | 90-95% |
|
||||
| Coarse motion (still/moving) | Yes | Yes | Yes | 85-90% |
|
||||
| Room-level location | No | Marginal | Yes | 70-80% |
|
||||
| Person count | No | Marginal | Marginal | 50-70% |
|
||||
| Activity class (walk/sit/stand) | Marginal | Marginal | Yes | 60-75% |
|
||||
| Respiration detection | No | Marginal | Marginal | 40-60% |
|
||||
| Heartbeat | No | No | No | N/A |
|
||||
| Body pose | No | No | No | N/A |
|
||||
|
||||
**Bottom line**: Feature-level sensing on commodity gear does presence and motion well. It does NOT do pose estimation, heartbeat, or reliable respiration. Any claim otherwise would be dishonest.
|
||||
|
||||
### Decision Matrix: Option 2 (ESP32) vs Option 3 (Commodity)
|
||||
|
||||
| Factor | ESP32 CSI (ADR-012) | Commodity (ADR-013) |
|
||||
|--------|---------------------|---------------------|
|
||||
| Headline capability | Respiration + motion | Presence + coarse motion |
|
||||
| Hardware cost | $54 (3-node kit) | $0 (existing gear) |
|
||||
| Setup time | 2-4 hours | 15 minutes |
|
||||
| Technical barrier | Medium (firmware flash) | Low (pip install) |
|
||||
| Data quality | Real CSI (amplitude + phase) | RSSI only |
|
||||
| Multi-person | Marginal | Poor |
|
||||
| Pose estimation | Marginal | No |
|
||||
| Reproducibility | High (controlled hardware) | Medium (varies by hardware) |
|
||||
| Public credibility | High (real CSI artifact) | Medium (RSSI is "obvious") |
|
||||
|
||||
### Proof Bundle for Commodity Sensing
|
||||
|
||||
```
|
||||
v1/data/proof/commodity/
|
||||
├── rssi_capture_30sec.json # 30 seconds of RSSI from 3 receivers
|
||||
├── rssi_capture_meta.json # Hardware: Intel AX200, Router: TP-Link AX1800
|
||||
├── scenario.txt # "Person walks through room at t=10s, sits at t=20s"
|
||||
├── expected_features.json # Feature extraction output
|
||||
├── expected_classification.json # Classification output
|
||||
├── expected_features.sha256 # Verification hash
|
||||
└── verify_commodity.py # One-command verification
|
||||
```
|
||||
|
||||
### Integration with WiFi-DensePose Pipeline
|
||||
|
||||
The commodity sensing module outputs the same `SensingResult` type as the CSI pipeline, allowing graceful degradation:
|
||||
|
||||
```python
|
||||
class SensingBackend(Protocol):
|
||||
"""Common interface for all sensing backends."""
|
||||
|
||||
def get_features(self) -> FeatureVector: ...
|
||||
def get_capabilities(self) -> set[Capability]: ...
|
||||
|
||||
class CsiBackend(SensingBackend):
|
||||
"""Full CSI pipeline (ESP32 or research NIC)."""
|
||||
def get_capabilities(self):
|
||||
return {Capability.PRESENCE, Capability.MOTION, Capability.RESPIRATION,
|
||||
Capability.LOCATION, Capability.POSE}
|
||||
|
||||
class CommodityBackend(SensingBackend):
|
||||
"""RSSI-only commodity hardware."""
|
||||
def get_capabilities(self):
|
||||
return {Capability.PRESENCE, Capability.MOTION}
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- **Zero-cost entry**: Works with existing WiFi hardware
|
||||
- **15-minute setup**: `pip install wifi-densepose && wdp sense --interface wlan0`
|
||||
- **Broad adoption**: Any Linux laptop, Pi, or phone can participate
|
||||
- **Honest capability reporting**: `get_capabilities()` tells users exactly what works
|
||||
- **Complements ESP32**: Users start with commodity, upgrade to ESP32 for more capability
|
||||
- **No mock data**: Real RSSI from real hardware, deterministic pipeline
|
||||
|
||||
### Negative
|
||||
- **Limited capability**: No pose, no heartbeat, marginal respiration
|
||||
- **Hardware variability**: RSSI calibration differs across chipsets
|
||||
- **Environmental sensitivity**: Commodity RSSI is more affected by interference than CSI
|
||||
- **Not a "pose estimation" demo**: This module honestly cannot do what the project name implies
|
||||
- **Lower credibility ceiling**: RSSI sensing is well-known; less impressive than CSI
|
||||
|
||||
## References
|
||||
|
||||
- [Youssef et al. - Challenges in Device-Free Passive Localization](https://doi.org/10.1145/1287853.1287880)
|
||||
- [Device-Free WiFi Sensing Survey](https://arxiv.org/abs/1901.09683)
|
||||
- [RSSI-based Breathing Detection](https://ieeexplore.ieee.org/document/7127688)
|
||||
- [Linux Wireless Tools](https://wireless.wiki.kernel.org/en/users/documentation/iw)
|
||||
- ADR-011: Python Proof-of-Reality and Mock Elimination
|
||||
- ADR-012: ESP32 CSI Sensor Mesh
|
||||
@@ -1,160 +0,0 @@
|
||||
# ADR-014: SOTA Signal Processing Algorithms for WiFi Sensing
|
||||
|
||||
## Status
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The existing signal processing pipeline (ADR-002) provides foundational CSI processing:
|
||||
phase unwrapping, FFT-based feature extraction, and variance-based motion detection.
|
||||
However, the academic state-of-the-art in WiFi sensing (2020-2025) has advanced
|
||||
significantly beyond these basics. To achieve research-grade accuracy, we need
|
||||
algorithms grounded in the physics of WiFi signal propagation and human body interaction.
|
||||
|
||||
### Current Gaps vs SOTA
|
||||
|
||||
| Capability | Current | SOTA Reference |
|
||||
|-----------|---------|----------------|
|
||||
| Phase cleaning | Z-score outlier + unwrapping | Conjugate multiplication (SpotFi 2015, IndoTrack 2017) |
|
||||
| Outlier detection | Z-score | Hampel filter (robust median-based) |
|
||||
| Breathing detection | Zero-crossing frequency | Fresnel zone model (FarSense 2019, Wi-Sleep 2021) |
|
||||
| Signal representation | Raw amplitude/phase | CSI spectrogram (time-frequency 2D matrix) |
|
||||
| Subcarrier usage | All subcarriers equally | Sensitivity-based selection (variance ratio) |
|
||||
| Motion profiling | Single motion score | Body Velocity Profile / BVP (Widar 3.0 2019) |
|
||||
|
||||
## Decision
|
||||
|
||||
Implement six SOTA algorithms in the `wifi-densepose-signal` crate as new modules,
|
||||
each with deterministic tests and no mock data.
|
||||
|
||||
### 1. Conjugate Multiplication (CSI Ratio Model)
|
||||
|
||||
**What:** Multiply CSI from antenna pair (i,j) as `H_i * conj(H_j)` to cancel
|
||||
carrier frequency offset (CFO), sampling frequency offset (SFO), and packet
|
||||
detection delay — all of which corrupt raw phase measurements.
|
||||
|
||||
**Why:** Raw CSI phase from commodity hardware (ESP32, Intel 5300) includes
|
||||
random offsets that change per packet. Conjugate multiplication preserves only
|
||||
the phase difference caused by the environment (human motion), not the hardware.
|
||||
|
||||
**Math:** `CSI_ratio[k] = H_1[k] * conj(H_2[k])` where k is subcarrier index.
|
||||
The resulting phase `angle(CSI_ratio[k])` reflects only path differences between
|
||||
the two antenna elements.
|
||||
|
||||
**Reference:** SpotFi (SIGCOMM 2015), IndoTrack (MobiCom 2017)
|
||||
|
||||
### 2. Hampel Filter
|
||||
|
||||
**What:** Replace outliers using running median ± scaled MAD (Median Absolute
|
||||
Deviation), which is robust to the outliers themselves (unlike mean/std Z-score).
|
||||
|
||||
**Why:** WiFi CSI has burst interference, multipath spikes, and hardware glitches
|
||||
that create outliers. Z-score outlier detection uses mean/std, which are themselves
|
||||
corrupted by the outliers (masking effect). Hampel filter uses median/MAD, which
|
||||
resist up to 50% contamination.
|
||||
|
||||
**Math:** For window around sample i: `median = med(x[i-w..i+w])`,
|
||||
`MAD = med(|x[j] - median|)`, `σ_est = 1.4826 * MAD`.
|
||||
If `|x[i] - median| > t * σ_est`, replace x[i] with median.
|
||||
|
||||
**Reference:** Standard DSP technique, used in WiGest (2015), WiDance (2017)
|
||||
|
||||
### 3. Fresnel Zone Breathing Model
|
||||
|
||||
**What:** Model WiFi signal variation as a function of human chest displacement
|
||||
crossing Fresnel zone boundaries. The chest moves ~5-10mm during breathing,
|
||||
which at 5 GHz (λ=60mm) is a significant fraction of the Fresnel zone width.
|
||||
|
||||
**Why:** Zero-crossing counting works for strong signals but fails in multipath-rich
|
||||
environments. The Fresnel model predicts *where* in the signal cycle a breathing
|
||||
motion should appear based on the TX-RX-body geometry, enabling detection even
|
||||
with weak signals.
|
||||
|
||||
**Math:** Fresnel zone radius at point P: `F_n = sqrt(n * λ * d1 * d2 / (d1 + d2))`.
|
||||
Signal variation: `ΔΦ = 2π * 2Δd / λ` where Δd is chest displacement.
|
||||
Expected breathing amplitude: `A = |sin(ΔΦ/2)|`.
|
||||
|
||||
**Reference:** FarSense (MobiCom 2019), Wi-Sleep (UbiComp 2021)
|
||||
|
||||
### 4. CSI Spectrogram
|
||||
|
||||
**What:** Construct a 2D time-frequency matrix by applying sliding-window FFT
|
||||
(STFT) to the temporal CSI amplitude stream per subcarrier. This reveals how
|
||||
the frequency content of body motion changes over time.
|
||||
|
||||
**Why:** Spectrograms are the standard input to CNN-based activity recognition.
|
||||
A breathing person shows a ~0.2-0.4 Hz band, walking shows 1-2 Hz, and
|
||||
stationary environment shows only noise. The 2D structure allows spatial
|
||||
pattern recognition that 1D features miss.
|
||||
|
||||
**Math:** `S[t,f] = |Σ_n x[n] * w[n-t] * exp(-j2πfn)|²`
|
||||
|
||||
**Reference:** Used in virtually all CNN-based WiFi sensing papers since 2018
|
||||
|
||||
### 5. Subcarrier Sensitivity Selection
|
||||
|
||||
**What:** Rank subcarriers by their sensitivity to human motion (variance ratio
|
||||
between motion and static periods) and select only the top-K for further processing.
|
||||
|
||||
**Why:** Not all subcarriers respond equally to body motion. Some are in
|
||||
multipath nulls, some carry mainly noise. Using all subcarriers dilutes the signal.
|
||||
Selecting the 10-20 most sensitive subcarriers improves SNR by 6-10 dB.
|
||||
|
||||
**Math:** `sensitivity[k] = var_motion(amp[k]) / (var_static(amp[k]) + ε)`.
|
||||
Select top-K subcarriers by sensitivity score.
|
||||
|
||||
**Reference:** WiDance (MobiCom 2017), WiGest (SenSys 2015)
|
||||
|
||||
### 6. Body Velocity Profile (BVP)
|
||||
|
||||
**What:** Extract velocity distribution of body parts from Doppler shifts across
|
||||
subcarriers. BVP is a 2D representation (velocity × time) that encodes how
|
||||
different body parts move at different speeds.
|
||||
|
||||
**Why:** BVP is domain-independent — the same velocity profile appears regardless
|
||||
of room layout, furniture, or AP placement. This makes it the basis for
|
||||
cross-environment gesture and activity recognition.
|
||||
|
||||
**Math:** Apply DFT across time for each subcarrier, then aggregate across
|
||||
subcarriers: `BVP[v,t] = Σ_k |STFT_k[v,t]|` where v maps to velocity via
|
||||
`v = f_doppler * λ / 2`.
|
||||
|
||||
**Reference:** Widar 3.0 (MobiSys 2019), WiDar (MobiSys 2017)
|
||||
|
||||
## Implementation
|
||||
|
||||
All algorithms implemented in `wifi-densepose-signal/src/` as new modules:
|
||||
- `csi_ratio.rs` — Conjugate multiplication
|
||||
- `hampel.rs` — Hampel filter
|
||||
- `fresnel.rs` — Fresnel zone breathing model
|
||||
- `spectrogram.rs` — CSI spectrogram generation
|
||||
- `subcarrier_selection.rs` — Sensitivity-based selection
|
||||
- `bvp.rs` — Body Velocity Profile extraction
|
||||
|
||||
Each module has:
|
||||
- Deterministic unit tests with known input/output
|
||||
- No random data, no mocks
|
||||
- Documentation with references to source papers
|
||||
- Integration with existing `CsiData` types
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- Research-grade signal processing matching 2019-2023 publications
|
||||
- Physics-grounded algorithms (Fresnel zones, Doppler) not just heuristics
|
||||
- Cross-environment robustness via BVP and CSI ratio
|
||||
- CNN-ready features via spectrograms
|
||||
- Improved SNR via subcarrier selection
|
||||
|
||||
### Negative
|
||||
- Increased computational cost (STFT, complex multiplication per frame)
|
||||
- Fresnel model requires TX-RX distance estimate (geometry input)
|
||||
- BVP requires sufficient temporal history (>1 second at 100+ Hz sampling)
|
||||
|
||||
## References
|
||||
- SpotFi: Decimeter Level Localization Using WiFi (SIGCOMM 2015)
|
||||
- IndoTrack: Device-Free Indoor Human Tracking (MobiCom 2017)
|
||||
- FarSense: Pushing the Range Limit of WiFi-based Respiration Sensing (MobiCom 2019)
|
||||
- Widar 3.0: Zero-Effort Cross-Domain Gesture Recognition (MobiSys 2019)
|
||||
- Wi-Sleep: Contactless Sleep Staging (UbiComp 2021)
|
||||
- DensePose from WiFi (arXiv 2022, CMU)
|
||||
@@ -1,684 +0,0 @@
|
||||
# WiFi-DensePose Build and Run Guide
|
||||
|
||||
Covers every way to build, run, and deploy the system -- from a zero-hardware verification to a full ESP32 mesh with 3D visualization.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Quick Start (Verification Only -- No Hardware)](#1-quick-start-verification-only----no-hardware)
|
||||
2. [Python Pipeline (v1/)](#2-python-pipeline-v1)
|
||||
3. [Rust Pipeline (v2)](#3-rust-pipeline-v2)
|
||||
4. [Three.js Visualization](#4-threejs-visualization)
|
||||
5. [Docker Deployment](#5-docker-deployment)
|
||||
6. [ESP32 Hardware Setup](#6-esp32-hardware-setup)
|
||||
7. [Environment-Specific Builds](#7-environment-specific-builds)
|
||||
|
||||
---
|
||||
|
||||
## 1. Quick Start (Verification Only -- No Hardware)
|
||||
|
||||
The fastest way to confirm the signal processing pipeline is real and deterministic. Requires only Python 3.8+, numpy, and scipy. No WiFi hardware, no GPU, no Docker.
|
||||
|
||||
```bash
|
||||
# From the repository root:
|
||||
./verify
|
||||
```
|
||||
|
||||
This runs three phases:
|
||||
|
||||
1. **Environment checks** -- confirms Python, numpy, scipy, and proof files are present.
|
||||
2. **Proof pipeline replay** -- feeds a published reference signal through the full signal processing chain (noise filtering, Hamming windowing, amplitude normalization, FFT-based Doppler extraction, power spectral density via scipy.fft) and computes a SHA-256 hash of the output.
|
||||
3. **Production code integrity scan** -- scans `v1/src/` for `np.random.rand` / `np.random.randn` calls in production code (test helpers are excluded).
|
||||
|
||||
Exit codes:
|
||||
- `0` PASS -- pipeline hash matches the published expected hash
|
||||
- `1` FAIL -- hash mismatch or error
|
||||
- `2` SKIP -- no expected hash file to compare against
|
||||
|
||||
Additional flags:
|
||||
|
||||
```bash
|
||||
./verify --verbose # Detailed feature statistics and Doppler spectrum
|
||||
./verify --verbose --audit # Full verification + codebase audit
|
||||
|
||||
# Or via make:
|
||||
make verify
|
||||
make verify-verbose
|
||||
make verify-audit
|
||||
```
|
||||
|
||||
If the expected hash file is missing, regenerate it:
|
||||
|
||||
```bash
|
||||
python3 v1/data/proof/verify.py --generate-hash
|
||||
```
|
||||
|
||||
### Minimal dependencies for verification only
|
||||
|
||||
```bash
|
||||
pip install numpy==1.26.4 scipy==1.14.1
|
||||
```
|
||||
|
||||
Or install the pinned set that guarantees hash reproducibility:
|
||||
|
||||
```bash
|
||||
pip install -r v1/requirements-lock.txt
|
||||
```
|
||||
|
||||
The lock file pins: `numpy==1.26.4`, `scipy==1.14.1`, `pydantic==2.10.4`, `pydantic-settings==2.7.1`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Python Pipeline (v1/)
|
||||
|
||||
The Python pipeline lives under `v1/` and provides the full API server, signal processing, sensing modules, and WebSocket streaming.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.8+
|
||||
- pip
|
||||
|
||||
### Install (verification-only -- lightweight)
|
||||
|
||||
```bash
|
||||
pip install -r v1/requirements-lock.txt
|
||||
```
|
||||
|
||||
This installs only the four packages needed for deterministic pipeline verification.
|
||||
|
||||
### Install (full pipeline with API server)
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
This pulls in FastAPI, uvicorn, torch, OpenCV, SQLAlchemy, Redis client, and all other runtime dependencies.
|
||||
|
||||
### Verify the pipeline
|
||||
|
||||
```bash
|
||||
python3 v1/data/proof/verify.py
|
||||
```
|
||||
|
||||
Same as `./verify` but calls the Python script directly, skipping the bash wrapper's codebase scan phase.
|
||||
|
||||
### Run the API server
|
||||
|
||||
```bash
|
||||
uvicorn v1.src.api.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
The server exposes:
|
||||
- REST API docs: http://localhost:8000/docs
|
||||
- Health check: http://localhost:8000/health
|
||||
- Latest poses: http://localhost:8000/api/v1/pose/latest
|
||||
- WebSocket pose stream: ws://localhost:8000/ws/pose/stream
|
||||
- WebSocket analytics: ws://localhost:8000/ws/analytics/events
|
||||
|
||||
For development with auto-reload:
|
||||
|
||||
```bash
|
||||
uvicorn v1.src.api.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
```
|
||||
|
||||
### Run with commodity WiFi (RSSI sensing -- no custom hardware)
|
||||
|
||||
The commodity sensing module (`v1/src/sensing/`) extracts presence and motion features from standard Linux WiFi metrics (RSSI, noise floor, link quality) without any hardware modification. See [ADR-013](adr/ADR-013-feature-level-sensing-commodity-gear.md) for full design details.
|
||||
|
||||
Requirements:
|
||||
- Any Linux machine with a WiFi interface (laptop, Raspberry Pi, etc.)
|
||||
- Connected to a WiFi access point (the AP is the signal source)
|
||||
- No root required for basic RSSI reading via `/proc/net/wireless`
|
||||
|
||||
The module provides:
|
||||
- `LinuxWifiCollector` -- reads real RSSI from `/proc/net/wireless` and `iw` commands
|
||||
- `RssiFeatureExtractor` -- computes rolling statistics, FFT spectral features, CUSUM change-point detection
|
||||
- `PresenceClassifier` -- rule-based presence/motion classification
|
||||
|
||||
What it can detect:
|
||||
| Capability | Single Receiver | 3+ Receivers |
|
||||
|-----------|----------------|-------------|
|
||||
| Binary presence | Yes (90-95%) | Yes (90-95%) |
|
||||
| Coarse motion (still/moving) | Yes (85-90%) | Yes (85-90%) |
|
||||
| Room-level location | No | Marginal (70-80%) |
|
||||
|
||||
What it cannot detect: body pose, heartbeat, reliable respiration. See ADR-013 for the honest capability matrix.
|
||||
|
||||
### Python project structure
|
||||
|
||||
```
|
||||
v1/
|
||||
src/
|
||||
api/
|
||||
main.py # FastAPI application entry point
|
||||
routers/ # REST endpoint routers (pose, stream, health)
|
||||
middleware/ # Auth, rate limiting
|
||||
websocket/ # WebSocket connection manager, pose stream
|
||||
config/ # Settings, domain configs
|
||||
sensing/
|
||||
rssi_collector.py # LinuxWifiCollector + SimulatedCollector
|
||||
feature_extractor.py # RssiFeatureExtractor (FFT, CUSUM, spectral)
|
||||
classifier.py # PresenceClassifier (rule-based)
|
||||
backend.py # SensingBackend protocol
|
||||
data/
|
||||
proof/
|
||||
sample_csi_data.json # Deterministic reference signal
|
||||
expected_features.sha256 # Published expected hash
|
||||
verify.py # One-command verification script
|
||||
requirements-lock.txt # Pinned deps for hash reproducibility
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Rust Pipeline (v2)
|
||||
|
||||
A high-performance Rust port with ~810x speedup over the Python pipeline for the full signal processing chain.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Rust 1.70+ (install via [rustup](https://rustup.rs/))
|
||||
- cargo (included with Rust)
|
||||
- System dependencies for OpenBLAS (used by ndarray-linalg):
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt-get install build-essential gfortran libopenblas-dev pkg-config
|
||||
|
||||
# macOS
|
||||
brew install openblas
|
||||
```
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
cd rust-port/wifi-densepose-rs
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
Release profile is configured with LTO, single codegen unit, and `-O3` for maximum performance.
|
||||
|
||||
### Test
|
||||
|
||||
```bash
|
||||
cd rust-port/wifi-densepose-rs
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Runs 107 tests across all workspace crates.
|
||||
|
||||
### Benchmark
|
||||
|
||||
```bash
|
||||
cd rust-port/wifi-densepose-rs
|
||||
cargo bench --package wifi-densepose-signal
|
||||
```
|
||||
|
||||
Expected throughput:
|
||||
| Operation | Latency | Throughput |
|
||||
|-----------|---------|------------|
|
||||
| CSI Preprocessing (4x64) | ~5.19 us | 49-66 Melem/s |
|
||||
| Phase Sanitization (4x64) | ~3.84 us | 67-85 Melem/s |
|
||||
| Feature Extraction (4x64) | ~9.03 us | 7-11 Melem/s |
|
||||
| Motion Detection | ~186 ns | -- |
|
||||
| Full Pipeline | ~18.47 us | ~54,000 fps |
|
||||
|
||||
### Workspace crates
|
||||
|
||||
The Rust workspace contains 10 crates under `crates/`:
|
||||
|
||||
| Crate | Description |
|
||||
|-------|-------------|
|
||||
| `wifi-densepose-core` | Core types, traits, and domain models |
|
||||
| `wifi-densepose-signal` | Signal processing (FFT, phase unwrapping, Doppler, correlation) |
|
||||
| `wifi-densepose-nn` | Neural network inference (ONNX Runtime, candle, tch) |
|
||||
| `wifi-densepose-api` | Axum-based HTTP/WebSocket API server |
|
||||
| `wifi-densepose-db` | Database layer (SQLx, PostgreSQL, SQLite, Redis) |
|
||||
| `wifi-densepose-config` | Configuration loading (env vars, YAML, TOML) |
|
||||
| `wifi-densepose-hardware` | Hardware adapters (ESP32, Intel 5300, Atheros, UDP, PCAP) |
|
||||
| `wifi-densepose-wasm` | WebAssembly bindings for browser deployment |
|
||||
| `wifi-densepose-cli` | Command-line interface |
|
||||
| `wifi-densepose-mat` | WiFi-Mat disaster response module (search and rescue) |
|
||||
|
||||
Build individual crates:
|
||||
|
||||
```bash
|
||||
# Signal processing only
|
||||
cargo build --release --package wifi-densepose-signal
|
||||
|
||||
# API server
|
||||
cargo build --release --package wifi-densepose-api
|
||||
|
||||
# Disaster response module
|
||||
cargo build --release --package wifi-densepose-mat
|
||||
|
||||
# WASM target (see Section 7 for full instructions)
|
||||
cargo build --release --package wifi-densepose-wasm --target wasm32-unknown-unknown
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Three.js Visualization
|
||||
|
||||
A browser-based 3D visualization dashboard that renders DensePose body models with 24 body parts, signal visualization, and environment rendering.
|
||||
|
||||
### Run
|
||||
|
||||
Open `ui/viz.html` directly in a browser:
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
open ui/viz.html
|
||||
|
||||
# Linux
|
||||
xdg-open ui/viz.html
|
||||
|
||||
# Or serve it locally
|
||||
python3 -m http.server 3000 --directory ui
|
||||
# Then open http://localhost:3000/viz.html
|
||||
```
|
||||
|
||||
### WebSocket connection
|
||||
|
||||
The visualization connects to `ws://localhost:8000/ws/pose` for real-time pose data. If no server is running, it falls back to a demo mode with simulated data so you can still see the 3D rendering.
|
||||
|
||||
To see live data:
|
||||
|
||||
1. Start the API server (Python or Rust)
|
||||
2. Open `ui/viz.html`
|
||||
3. The dashboard will connect automatically
|
||||
|
||||
---
|
||||
|
||||
## 5. Docker Deployment
|
||||
|
||||
### Development (with hot-reload, Postgres, Redis, Prometheus, Grafana)
|
||||
|
||||
```bash
|
||||
docker compose up
|
||||
```
|
||||
|
||||
This starts:
|
||||
- `wifi-densepose-dev` -- API server with `--reload`, debug logging, auth disabled (port 8000)
|
||||
- `postgres` -- PostgreSQL 15 (port 5432)
|
||||
- `redis` -- Redis 7 with AOF persistence (port 6379)
|
||||
- `prometheus` -- metrics scraping (port 9090)
|
||||
- `grafana` -- dashboards (port 3000, login: admin/admin)
|
||||
- `nginx` -- reverse proxy (ports 80, 443)
|
||||
|
||||
```bash
|
||||
# View logs
|
||||
docker compose logs -f wifi-densepose
|
||||
|
||||
# Run tests inside the container
|
||||
docker compose exec wifi-densepose pytest tests/ -v
|
||||
|
||||
# Stop everything
|
||||
docker compose down
|
||||
|
||||
# Stop and remove volumes
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
### Production
|
||||
|
||||
Uses the production Dockerfile stage with 4 uvicorn workers, auth enabled, rate limiting, and resource limits.
|
||||
|
||||
```bash
|
||||
# Build production image
|
||||
docker build --target production -t wifi-densepose:latest .
|
||||
|
||||
# Run standalone
|
||||
docker run -d \
|
||||
--name wifi-densepose \
|
||||
-p 8000:8000 \
|
||||
-e ENVIRONMENT=production \
|
||||
-e SECRET_KEY=your-secret-key \
|
||||
wifi-densepose:latest
|
||||
```
|
||||
|
||||
For the full production stack with Docker Swarm secrets:
|
||||
|
||||
```bash
|
||||
# Create required secrets first
|
||||
echo "db_password_here" | docker secret create db_password -
|
||||
echo "redis_password_here" | docker secret create redis_password -
|
||||
echo "jwt_secret_here" | docker secret create jwt_secret -
|
||||
echo "api_key_here" | docker secret create api_key -
|
||||
echo "grafana_password_here" | docker secret create grafana_password -
|
||||
|
||||
# Set required environment variables
|
||||
export DATABASE_URL=postgresql://wifi_user:db_password_here@postgres:5432/wifi_densepose
|
||||
export REDIS_URL=redis://redis:6379/0
|
||||
export SECRET_KEY=your-secret-key
|
||||
export JWT_SECRET=your-jwt-secret
|
||||
export ALLOWED_HOSTS=your-domain.com
|
||||
export POSTGRES_DB=wifi_densepose
|
||||
export POSTGRES_USER=wifi_user
|
||||
|
||||
# Deploy with Docker Swarm
|
||||
docker stack deploy -c docker-compose.prod.yml wifi-densepose
|
||||
```
|
||||
|
||||
Production compose includes:
|
||||
- 3 API server replicas with rolling updates and rollback
|
||||
- Resource limits (2 CPU, 4GB RAM per replica)
|
||||
- Health checks on all services
|
||||
- JSON file logging with rotation
|
||||
- Separate monitoring network (overlay)
|
||||
- Prometheus with alerting rules and 15-day retention
|
||||
- Grafana with provisioned datasources and dashboards
|
||||
|
||||
### Dockerfile stages
|
||||
|
||||
The multi-stage `Dockerfile` provides four targets:
|
||||
|
||||
| Target | Use | Command |
|
||||
|--------|-----|---------|
|
||||
| `development` | Local dev with hot-reload | `docker build --target development .` |
|
||||
| `production` | Optimized production image | `docker build --target production .` |
|
||||
| `testing` | Runs pytest during build | `docker build --target testing .` |
|
||||
| `security` | Runs safety + bandit scans | `docker build --target security .` |
|
||||
|
||||
---
|
||||
|
||||
## 6. ESP32 Hardware Setup
|
||||
|
||||
Uses ESP32-S3 boards as WiFi CSI sensor nodes. See [ADR-012](adr/ADR-012-esp32-csi-sensor-mesh.md) for the full specification.
|
||||
|
||||
### Bill of Materials (Starter Kit -- $54)
|
||||
|
||||
| Item | Qty | Unit Cost | Total |
|
||||
|------|-----|-----------|-------|
|
||||
| ESP32-S3-DevKitC-1 | 3 | $10 | $30 |
|
||||
| USB-A to USB-C cables | 3 | $3 | $9 |
|
||||
| USB power adapter (multi-port) | 1 | $15 | $15 |
|
||||
| Consumer WiFi router (any) | 1 | $0 (existing) | $0 |
|
||||
| Aggregator (laptop or Pi 4) | 1 | $0 (existing) | $0 |
|
||||
| **Total** | | | **$54** |
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Install ESP-IDF (Espressif's official development framework):
|
||||
|
||||
```bash
|
||||
# Clone ESP-IDF
|
||||
mkdir -p ~/esp
|
||||
cd ~/esp
|
||||
git clone --recursive https://github.com/espressif/esp-idf.git
|
||||
cd esp-idf
|
||||
git checkout v5.2 # Pin to tested version
|
||||
|
||||
# Install tools
|
||||
./install.sh esp32s3
|
||||
|
||||
# Activate environment (run each session)
|
||||
. ./export.sh
|
||||
```
|
||||
|
||||
### Flash a node
|
||||
|
||||
```bash
|
||||
cd firmware/esp32-csi-node
|
||||
|
||||
# Set target chip
|
||||
idf.py set-target esp32s3
|
||||
|
||||
# Configure WiFi SSID/password and aggregator IP
|
||||
idf.py menuconfig
|
||||
# Navigate to: Component config > WiFi-DensePose CSI Node
|
||||
# - Set WiFi SSID
|
||||
# - Set WiFi password
|
||||
# - Set aggregator IP address
|
||||
# - Set node ID (1, 2, 3, ...)
|
||||
# - Set sampling rate (10-100 Hz)
|
||||
|
||||
# Build and flash (with USB cable connected)
|
||||
idf.py build flash monitor
|
||||
```
|
||||
|
||||
`idf.py monitor` shows live serial output including CSI callback data. Press `Ctrl+]` to exit.
|
||||
|
||||
Repeat for each node, incrementing the node ID.
|
||||
|
||||
### Firmware project structure
|
||||
|
||||
```
|
||||
firmware/esp32-csi-node/
|
||||
CMakeLists.txt
|
||||
sdkconfig.defaults # Menuconfig defaults with CSI enabled
|
||||
main/
|
||||
main.c # Entry point, WiFi init, CSI callback
|
||||
csi_collector.c # CSI data collection and buffering
|
||||
feature_extract.c # On-device FFT and feature extraction
|
||||
stream_sender.c # UDP stream to aggregator
|
||||
config.h # Node configuration
|
||||
Kconfig.projbuild # Menuconfig options
|
||||
components/
|
||||
esp_dsp/ # Espressif DSP library for FFT
|
||||
```
|
||||
|
||||
Each node does on-device feature extraction (raw I/Q to amplitude + phase + spectral bands), reducing bandwidth from ~11 KB/frame to ~470 bytes/frame. Nodes stream features via UDP to the aggregator.
|
||||
|
||||
### Run the aggregator
|
||||
|
||||
The aggregator collects UDP streams from all ESP32 nodes, performs feature-level fusion (not signal-level -- see ADR-012 for why), and feeds the fused data into the Rust or Python pipeline.
|
||||
|
||||
```bash
|
||||
# Start the aggregator and pipeline via Docker
|
||||
docker compose -f docker-compose.esp32.yml up
|
||||
|
||||
# Or run the Rust aggregator directly
|
||||
cd rust-port/wifi-densepose-rs
|
||||
cargo run --release --package wifi-densepose-hardware -- --mode esp32-aggregator --port 5000
|
||||
```
|
||||
|
||||
### Verify with real hardware
|
||||
|
||||
```bash
|
||||
docker exec aggregator python verify_esp32.py
|
||||
```
|
||||
|
||||
This captures 10 seconds of data, produces feature JSON, and verifies the hash against the proof bundle.
|
||||
|
||||
### What the ESP32 mesh can and cannot detect
|
||||
|
||||
| Capability | 1 Node | 3 Nodes | 6 Nodes |
|
||||
|-----------|--------|---------|---------|
|
||||
| Presence detection | Good | Excellent | Excellent |
|
||||
| Coarse motion | Good | Excellent | Excellent |
|
||||
| Room-level location | None | Good | Excellent |
|
||||
| Respiration | Marginal | Good | Good |
|
||||
| Heartbeat | Poor | Poor | Marginal |
|
||||
| Multi-person count | None | Marginal | Good |
|
||||
| Pose estimation | None | Poor | Marginal |
|
||||
|
||||
---
|
||||
|
||||
## 7. Environment-Specific Builds
|
||||
|
||||
### Browser (WASM)
|
||||
|
||||
Compiles the Rust pipeline to WebAssembly for in-browser execution. See [ADR-009](adr/ADR-009-rvf-wasm-runtime-edge-deployment.md) for the edge deployment architecture.
|
||||
|
||||
Prerequisites:
|
||||
|
||||
```bash
|
||||
# Install wasm-pack
|
||||
curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
|
||||
|
||||
# Or via cargo
|
||||
cargo install wasm-pack
|
||||
|
||||
# Add the WASM target
|
||||
rustup target add wasm32-unknown-unknown
|
||||
```
|
||||
|
||||
Build:
|
||||
|
||||
```bash
|
||||
cd rust-port/wifi-densepose-rs
|
||||
|
||||
# Build WASM package (outputs to pkg/)
|
||||
wasm-pack build crates/wifi-densepose-wasm --target web --release
|
||||
|
||||
# Build with disaster response module included
|
||||
wasm-pack build crates/wifi-densepose-wasm --target web --release -- --features mat
|
||||
```
|
||||
|
||||
The output `pkg/` directory contains `.wasm`, `.js` glue, and TypeScript definitions. Import in a web project:
|
||||
|
||||
```javascript
|
||||
import init, { WifiDensePoseWasm } from './pkg/wifi_densepose_wasm.js';
|
||||
|
||||
async function main() {
|
||||
await init();
|
||||
const processor = new WifiDensePoseWasm();
|
||||
const result = processor.process_frame(csiJsonString);
|
||||
console.log(JSON.parse(result));
|
||||
}
|
||||
main();
|
||||
```
|
||||
|
||||
Run WASM tests:
|
||||
|
||||
```bash
|
||||
wasm-pack test --headless --chrome crates/wifi-densepose-wasm
|
||||
```
|
||||
|
||||
Container size targets by deployment profile:
|
||||
|
||||
| Profile | Size | Suitable For |
|
||||
|---------|------|-------------|
|
||||
| Browser (int8 quantization) | ~10 MB | Chrome/Firefox dashboard |
|
||||
| IoT (int4 quantization) | ~0.7 MB | ESP32, constrained devices |
|
||||
| Mobile (int8 quantization) | ~6 MB | iOS/Android WebView |
|
||||
| Field (fp16 quantization) | ~62 MB | Offline disaster tablets |
|
||||
|
||||
### IoT (ESP32)
|
||||
|
||||
See [Section 6](#6-esp32-hardware-setup) for full ESP32 setup. The firmware runs on the device itself (C, compiled with ESP-IDF). The Rust aggregator runs on a host machine.
|
||||
|
||||
For deploying the WASM runtime to a Raspberry Pi or similar:
|
||||
|
||||
```bash
|
||||
# Cross-compile for ARM
|
||||
rustup target add aarch64-unknown-linux-gnu
|
||||
cargo build --release --package wifi-densepose-cli --target aarch64-unknown-linux-gnu
|
||||
```
|
||||
|
||||
### Server (Docker)
|
||||
|
||||
See [Section 5](#5-docker-deployment).
|
||||
|
||||
Quick reference:
|
||||
|
||||
```bash
|
||||
# Development
|
||||
docker compose up
|
||||
|
||||
# Production standalone
|
||||
docker build --target production -t wifi-densepose:latest .
|
||||
docker run -d -p 8000:8000 wifi-densepose:latest
|
||||
|
||||
# Production stack (Swarm)
|
||||
docker stack deploy -c docker-compose.prod.yml wifi-densepose
|
||||
```
|
||||
|
||||
### Server (Direct -- no Docker)
|
||||
|
||||
```bash
|
||||
# 1. Install Python dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 2. Set environment variables (copy from example.env)
|
||||
cp example.env .env
|
||||
# Edit .env with your settings
|
||||
|
||||
# 3. Run with uvicorn (production)
|
||||
uvicorn v1.src.api.main:app \
|
||||
--host 0.0.0.0 \
|
||||
--port 8000 \
|
||||
--workers 4
|
||||
|
||||
# Or run the Rust API server
|
||||
cd rust-port/wifi-densepose-rs
|
||||
cargo run --release --package wifi-densepose-api
|
||||
```
|
||||
|
||||
### Development (local with hot-reload)
|
||||
|
||||
Python:
|
||||
|
||||
```bash
|
||||
# Create virtual environment
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
|
||||
# Install all dependencies including dev tools
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Run with auto-reload
|
||||
uvicorn v1.src.api.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
# Run verification in another terminal
|
||||
./verify --verbose
|
||||
|
||||
# Run tests
|
||||
pytest tests/ -v
|
||||
pytest --cov=wifi_densepose --cov-report=html
|
||||
```
|
||||
|
||||
Rust:
|
||||
|
||||
```bash
|
||||
cd rust-port/wifi-densepose-rs
|
||||
|
||||
# Build in debug mode (faster compilation)
|
||||
cargo build
|
||||
|
||||
# Run tests with output
|
||||
cargo test --workspace -- --nocapture
|
||||
|
||||
# Watch mode (requires cargo-watch)
|
||||
cargo install cargo-watch
|
||||
cargo watch -x 'test --workspace' -x 'build --release'
|
||||
|
||||
# Run benchmarks
|
||||
cargo bench --package wifi-densepose-signal
|
||||
```
|
||||
|
||||
Both (visualization + API):
|
||||
|
||||
```bash
|
||||
# Terminal 1: Start API server
|
||||
uvicorn v1.src.api.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
# Terminal 2: Serve visualization
|
||||
python3 -m http.server 3000 --directory ui
|
||||
|
||||
# Open http://localhost:3000/viz.html -- it connects to ws://localhost:8000/ws/pose
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Key File Locations
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `./verify` | Trust kill switch -- one-command pipeline proof |
|
||||
| `Makefile` | `make verify`, `make verify-verbose`, `make verify-audit` |
|
||||
| `v1/requirements-lock.txt` | Pinned Python deps for hash reproducibility |
|
||||
| `requirements.txt` | Full Python deps (API server, torch, etc.) |
|
||||
| `v1/data/proof/verify.py` | Python verification script |
|
||||
| `v1/data/proof/sample_csi_data.json` | Deterministic reference signal |
|
||||
| `v1/data/proof/expected_features.sha256` | Published expected hash |
|
||||
| `v1/src/api/main.py` | FastAPI application entry point |
|
||||
| `v1/src/sensing/` | Commodity WiFi sensing module (RSSI) |
|
||||
| `rust-port/wifi-densepose-rs/Cargo.toml` | Rust workspace root |
|
||||
| `ui/viz.html` | Three.js 3D visualization |
|
||||
| `Dockerfile` | Multi-stage Docker build (dev/prod/test/security) |
|
||||
| `docker-compose.yml` | Development stack (Postgres, Redis, Prometheus, Grafana) |
|
||||
| `docker-compose.prod.yml` | Production stack (Swarm, secrets, resource limits) |
|
||||
| `docs/adr/ADR-009-rvf-wasm-runtime-edge-deployment.md` | WASM edge deployment architecture |
|
||||
| `docs/adr/ADR-012-esp32-csi-sensor-mesh.md` | ESP32 firmware and mesh specification |
|
||||
| `docs/adr/ADR-013-feature-level-sensing-commodity-gear.md` | Commodity WiFi (RSSI) sensing |
|
||||
@@ -1,497 +0,0 @@
|
||||
# WiFi-Mat Domain Model
|
||||
|
||||
## Domain-Driven Design Specification
|
||||
|
||||
### Ubiquitous Language
|
||||
|
||||
| Term | Definition |
|
||||
|------|------------|
|
||||
| **Survivor** | A human detected within a scan zone, potentially trapped |
|
||||
| **Vital Signs** | Detectable life indicators: breathing, heartbeat, movement |
|
||||
| **Scan Zone** | A defined geographic area being actively monitored |
|
||||
| **Detection Event** | An occurrence of vital signs being detected |
|
||||
| **Triage Status** | Medical priority classification (Immediate/Delayed/Minor/Deceased) |
|
||||
| **Confidence Score** | Statistical certainty of detection (0.0-1.0) |
|
||||
| **Penetration Depth** | Estimated distance through debris to survivor |
|
||||
| **Debris Field** | Collection of materials between sensor and survivor |
|
||||
|
||||
---
|
||||
|
||||
## Bounded Contexts
|
||||
|
||||
### 1. Detection Context
|
||||
|
||||
**Responsibility**: Analyze CSI data to detect and classify human vital signs
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Detection Context │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ Breathing │ │ Heartbeat │ │
|
||||
│ │ Detector │ │ Detector │ │
|
||||
│ └──────┬───────┘ └──────┬───────┘ │
|
||||
│ │ │ │
|
||||
│ └─────────┬─────────┘ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────┐ │
|
||||
│ │ Movement │ │
|
||||
│ │ Classifier │ │
|
||||
│ └────────┬────────┘ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────┐ │
|
||||
│ │ Ensemble │──▶ VitalSignsReading │
|
||||
│ │ Classifier │ │
|
||||
│ └─────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Aggregates**:
|
||||
- `VitalSignsReading` (Aggregate Root)
|
||||
|
||||
**Value Objects**:
|
||||
- `BreathingPattern`
|
||||
- `HeartbeatSignature`
|
||||
- `MovementProfile`
|
||||
- `ConfidenceScore`
|
||||
|
||||
### 2. Localization Context
|
||||
|
||||
**Responsibility**: Estimate survivor position within debris field
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Localization Context │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │Triangulation │ │Fingerprinting│ │
|
||||
│ │ Engine │ │ Matcher │ │
|
||||
│ └──────┬───────┘ └──────┬───────┘ │
|
||||
│ │ │ │
|
||||
│ └─────────┬─────────┘ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────┐ │
|
||||
│ │ Depth │ │
|
||||
│ │ Estimator │ │
|
||||
│ └────────┬────────┘ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────┐ │
|
||||
│ │ Position │──▶ SurvivorLocation │
|
||||
│ │ Fuser │ │
|
||||
│ └─────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Aggregates**:
|
||||
- `SurvivorLocation` (Aggregate Root)
|
||||
|
||||
**Value Objects**:
|
||||
- `Coordinates3D`
|
||||
- `DepthEstimate`
|
||||
- `LocationUncertainty`
|
||||
- `DebrisProfile`
|
||||
|
||||
### 3. Alerting Context
|
||||
|
||||
**Responsibility**: Generate and dispatch alerts based on detections
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Alerting Context │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ Triage │ │ Alert │ │
|
||||
│ │ Calculator │ │ Generator │ │
|
||||
│ └──────┬───────┘ └──────┬───────┘ │
|
||||
│ │ │ │
|
||||
│ └─────────┬─────────┘ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────┐ │
|
||||
│ │ Dispatcher │──▶ Alert │
|
||||
│ └─────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Aggregates**:
|
||||
- `Alert` (Aggregate Root)
|
||||
|
||||
**Value Objects**:
|
||||
- `TriageStatus`
|
||||
- `Priority`
|
||||
- `AlertPayload`
|
||||
|
||||
---
|
||||
|
||||
## Core Domain Entities
|
||||
|
||||
### Survivor (Entity)
|
||||
|
||||
```rust
|
||||
pub struct Survivor {
|
||||
id: SurvivorId,
|
||||
detection_time: DateTime<Utc>,
|
||||
location: Option<SurvivorLocation>,
|
||||
vital_signs: VitalSignsHistory,
|
||||
triage_status: TriageStatus,
|
||||
confidence: ConfidenceScore,
|
||||
metadata: SurvivorMetadata,
|
||||
}
|
||||
```
|
||||
|
||||
**Invariants**:
|
||||
- Must have at least one vital sign detection to exist
|
||||
- Triage status must be recalculated on each vital sign update
|
||||
- Confidence must be >= 0.3 to be considered valid detection
|
||||
|
||||
### DisasterEvent (Aggregate Root)
|
||||
|
||||
```rust
|
||||
pub struct DisasterEvent {
|
||||
id: DisasterEventId,
|
||||
event_type: DisasterType,
|
||||
start_time: DateTime<Utc>,
|
||||
location: GeoLocation,
|
||||
scan_zones: Vec<ScanZone>,
|
||||
survivors: Vec<Survivor>,
|
||||
status: EventStatus,
|
||||
}
|
||||
```
|
||||
|
||||
**Invariants**:
|
||||
- Must have at least one scan zone
|
||||
- All survivors must be within a scan zone
|
||||
- Cannot add survivors after event is closed
|
||||
|
||||
### ScanZone (Entity)
|
||||
|
||||
```rust
|
||||
pub struct ScanZone {
|
||||
id: ScanZoneId,
|
||||
bounds: ZoneBounds,
|
||||
sensor_positions: Vec<SensorPosition>,
|
||||
scan_parameters: ScanParameters,
|
||||
status: ZoneStatus,
|
||||
last_scan: DateTime<Utc>,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Value Objects
|
||||
|
||||
### VitalSignsReading
|
||||
|
||||
```rust
|
||||
pub struct VitalSignsReading {
|
||||
breathing: Option<BreathingPattern>,
|
||||
heartbeat: Option<HeartbeatSignature>,
|
||||
movement: MovementProfile,
|
||||
timestamp: DateTime<Utc>,
|
||||
confidence: ConfidenceScore,
|
||||
}
|
||||
```
|
||||
|
||||
### TriageStatus (Enumeration)
|
||||
|
||||
```rust
|
||||
pub enum TriageStatus {
|
||||
/// Immediate - Life-threatening, requires immediate intervention
|
||||
Immediate, // Red tag
|
||||
|
||||
/// Delayed - Serious but can wait for treatment
|
||||
Delayed, // Yellow tag
|
||||
|
||||
/// Minor - Walking wounded, minimal treatment needed
|
||||
Minor, // Green tag
|
||||
|
||||
/// Deceased - No vital signs detected over threshold period
|
||||
Deceased, // Black tag
|
||||
|
||||
/// Unknown - Insufficient data for classification
|
||||
Unknown,
|
||||
}
|
||||
```
|
||||
|
||||
### BreathingPattern
|
||||
|
||||
```rust
|
||||
pub struct BreathingPattern {
|
||||
rate_bpm: f32, // Breaths per minute (normal: 12-20)
|
||||
amplitude: f32, // Signal strength
|
||||
regularity: f32, // 0.0-1.0, consistency of pattern
|
||||
pattern_type: BreathingType,
|
||||
}
|
||||
|
||||
pub enum BreathingType {
|
||||
Normal,
|
||||
Shallow,
|
||||
Labored,
|
||||
Irregular,
|
||||
Agonal,
|
||||
}
|
||||
```
|
||||
|
||||
### HeartbeatSignature
|
||||
|
||||
```rust
|
||||
pub struct HeartbeatSignature {
|
||||
rate_bpm: f32, // Beats per minute (normal: 60-100)
|
||||
variability: f32, // Heart rate variability
|
||||
strength: SignalStrength,
|
||||
}
|
||||
```
|
||||
|
||||
### Coordinates3D
|
||||
|
||||
```rust
|
||||
pub struct Coordinates3D {
|
||||
x: f64, // East-West offset from reference (meters)
|
||||
y: f64, // North-South offset from reference (meters)
|
||||
z: f64, // Depth below surface (meters, negative = below)
|
||||
uncertainty: LocationUncertainty,
|
||||
}
|
||||
|
||||
pub struct LocationUncertainty {
|
||||
horizontal_error: f64, // meters (95% confidence)
|
||||
vertical_error: f64, // meters (95% confidence)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Domain Events
|
||||
|
||||
### Detection Events
|
||||
|
||||
```rust
|
||||
pub enum DetectionEvent {
|
||||
/// New survivor detected
|
||||
SurvivorDetected {
|
||||
survivor_id: SurvivorId,
|
||||
zone_id: ScanZoneId,
|
||||
vital_signs: VitalSignsReading,
|
||||
location: Option<Coordinates3D>,
|
||||
timestamp: DateTime<Utc>,
|
||||
},
|
||||
|
||||
/// Survivor vital signs updated
|
||||
VitalsUpdated {
|
||||
survivor_id: SurvivorId,
|
||||
previous: VitalSignsReading,
|
||||
current: VitalSignsReading,
|
||||
timestamp: DateTime<Utc>,
|
||||
},
|
||||
|
||||
/// Survivor triage status changed
|
||||
TriageStatusChanged {
|
||||
survivor_id: SurvivorId,
|
||||
previous: TriageStatus,
|
||||
current: TriageStatus,
|
||||
reason: String,
|
||||
timestamp: DateTime<Utc>,
|
||||
},
|
||||
|
||||
/// Survivor location refined
|
||||
LocationRefined {
|
||||
survivor_id: SurvivorId,
|
||||
previous: Coordinates3D,
|
||||
current: Coordinates3D,
|
||||
timestamp: DateTime<Utc>,
|
||||
},
|
||||
|
||||
/// Survivor no longer detected (may have been rescued or false positive)
|
||||
SurvivorLost {
|
||||
survivor_id: SurvivorId,
|
||||
last_detection: DateTime<Utc>,
|
||||
reason: LostReason,
|
||||
},
|
||||
}
|
||||
|
||||
pub enum LostReason {
|
||||
Rescued,
|
||||
FalsePositive,
|
||||
SignalLost,
|
||||
ZoneDeactivated,
|
||||
}
|
||||
```
|
||||
|
||||
### Alert Events
|
||||
|
||||
```rust
|
||||
pub enum AlertEvent {
|
||||
/// New alert generated
|
||||
AlertGenerated {
|
||||
alert_id: AlertId,
|
||||
survivor_id: SurvivorId,
|
||||
priority: Priority,
|
||||
payload: AlertPayload,
|
||||
},
|
||||
|
||||
/// Alert acknowledged by rescue team
|
||||
AlertAcknowledged {
|
||||
alert_id: AlertId,
|
||||
acknowledged_by: TeamId,
|
||||
timestamp: DateTime<Utc>,
|
||||
},
|
||||
|
||||
/// Alert resolved
|
||||
AlertResolved {
|
||||
alert_id: AlertId,
|
||||
resolution: AlertResolution,
|
||||
timestamp: DateTime<Utc>,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Domain Services
|
||||
|
||||
### TriageService
|
||||
|
||||
Calculates triage status based on vital signs using START protocol:
|
||||
|
||||
```rust
|
||||
pub trait TriageService {
|
||||
fn calculate_triage(&self, vitals: &VitalSignsReading) -> TriageStatus;
|
||||
fn should_upgrade_priority(&self, history: &VitalSignsHistory) -> bool;
|
||||
}
|
||||
```
|
||||
|
||||
**Rules**:
|
||||
1. No breathing detected → Check for movement
|
||||
2. Movement but no breathing → Immediate (airway issue)
|
||||
3. Breathing > 30/min → Immediate
|
||||
4. Breathing < 10/min → Immediate
|
||||
5. No radial pulse equivalent (weak heartbeat) → Immediate
|
||||
6. Cannot follow commands (no responsive movement) → Immediate
|
||||
7. Otherwise → Delayed or Minor based on severity
|
||||
|
||||
### LocalizationService
|
||||
|
||||
Fuses multiple localization techniques:
|
||||
|
||||
```rust
|
||||
pub trait LocalizationService {
|
||||
fn estimate_position(
|
||||
&self,
|
||||
csi_data: &[CsiReading],
|
||||
sensor_positions: &[SensorPosition],
|
||||
) -> Result<Coordinates3D, LocalizationError>;
|
||||
|
||||
fn estimate_depth(
|
||||
&self,
|
||||
signal_attenuation: f64,
|
||||
debris_profile: &DebrisProfile,
|
||||
) -> Result<DepthEstimate, LocalizationError>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Context Map
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────┐
|
||||
│ WiFi-Mat System │
|
||||
├────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ Detection │◄───────►│ Localization│ │
|
||||
│ │ Context │ Partner │ Context │ │
|
||||
│ └──────┬──────┘ └──────┬──────┘ │
|
||||
│ │ │ │
|
||||
│ │ Publishes │ Publishes │
|
||||
│ ▼ ▼ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ Event Bus (Domain Events) │ │
|
||||
│ └─────────────────┬───────────────────┘ │
|
||||
│ │ │
|
||||
│ │ Subscribes │
|
||||
│ ▼ │
|
||||
│ ┌─────────────┐ │
|
||||
│ │ Alerting │ │
|
||||
│ │ Context │ │
|
||||
│ └─────────────┘ │
|
||||
│ │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ UPSTREAM (Conformist) │
|
||||
│ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │
|
||||
│ │wifi-densepose │ │wifi-densepose │ │wifi-densepose │ │
|
||||
│ │ -signal │ │ -nn │ │ -hardware │ │
|
||||
│ └───────────────┘ └───────────────┘ └───────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Relationship Types**:
|
||||
- Detection ↔ Localization: **Partnership** (tight collaboration)
|
||||
- Detection → Alerting: **Customer/Supplier** (Detection publishes, Alerting consumes)
|
||||
- WiFi-Mat → Upstream crates: **Conformist** (adapts to their models)
|
||||
|
||||
---
|
||||
|
||||
## Anti-Corruption Layer
|
||||
|
||||
The integration module provides adapters to translate between upstream crate models and WiFi-Mat domain:
|
||||
|
||||
```rust
|
||||
/// Adapts wifi-densepose-signal types to Detection context
|
||||
pub struct SignalAdapter {
|
||||
processor: CsiProcessor,
|
||||
feature_extractor: FeatureExtractor,
|
||||
}
|
||||
|
||||
impl SignalAdapter {
|
||||
pub fn extract_vital_features(
|
||||
&self,
|
||||
raw_csi: &[Complex<f64>],
|
||||
) -> Result<VitalFeatures, AdapterError>;
|
||||
}
|
||||
|
||||
/// Adapts wifi-densepose-nn for specialized detection models
|
||||
pub struct NeuralAdapter {
|
||||
breathing_model: OnnxModel,
|
||||
heartbeat_model: OnnxModel,
|
||||
}
|
||||
|
||||
impl NeuralAdapter {
|
||||
pub fn classify_breathing(
|
||||
&self,
|
||||
features: &VitalFeatures,
|
||||
) -> Result<BreathingPattern, AdapterError>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Repository Interfaces
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait SurvivorRepository {
|
||||
async fn save(&self, survivor: &Survivor) -> Result<(), RepositoryError>;
|
||||
async fn find_by_id(&self, id: &SurvivorId) -> Result<Option<Survivor>, RepositoryError>;
|
||||
async fn find_by_zone(&self, zone_id: &ScanZoneId) -> Result<Vec<Survivor>, RepositoryError>;
|
||||
async fn find_active(&self) -> Result<Vec<Survivor>, RepositoryError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait DisasterEventRepository {
|
||||
async fn save(&self, event: &DisasterEvent) -> Result<(), RepositoryError>;
|
||||
async fn find_active(&self) -> Result<Vec<DisasterEvent>, RepositoryError>;
|
||||
async fn find_by_location(&self, location: &GeoLocation, radius_km: f64) -> Result<Vec<DisasterEvent>, RepositoryError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait AlertRepository {
|
||||
async fn save(&self, alert: &Alert) -> Result<(), RepositoryError>;
|
||||
async fn find_pending(&self) -> Result<Vec<Alert>, RepositoryError>;
|
||||
async fn find_by_survivor(&self, survivor_id: &SurvivorId) -> Result<Vec<Alert>, RepositoryError>;
|
||||
}
|
||||
```
|
||||
@@ -1,110 +0,0 @@
|
||||
# Remote Vital Sign Sensing: RF, Radar, and Quantum Modalities
|
||||
|
||||
Beyond Wi-Fi DensePose-style sensing, there is active research and state-of-the-art (SOTA) work on remotely detecting people and physiological vital signs using RF/EM signals, radar, and quantum/quantum-inspired sensors. Below is a snapshot of current and emerging modalities, with research examples.
|
||||
|
||||
---
|
||||
|
||||
## RF-Based & Wireless Signal Approaches (Non-Optical)
|
||||
|
||||
### 1. RF & Wi-Fi Channel Sensing
|
||||
|
||||
Systems analyze perturbations in RF signals (e.g., changes in amplitude/phase) caused by human presence, motion, or micro-movement such as breathing or heartbeat:
|
||||
|
||||
- **Wi-Fi CSI (Channel State Information)** can capture micro-movements from chest motion due to respiration and heartbeats by tracking subtle phase shifts in reflected packets. Applied in real-time vital sign monitoring and indoor tracking.
|
||||
- **RF signal variation** can encode gait, posture and motion biometric features for person identification and pose estimation without cameras or wearables.
|
||||
|
||||
These methods are fundamentally passive RF sensing, relying on signal decomposition and ML to extract physiological signatures from ambient communication signals.
|
||||
|
||||
---
|
||||
|
||||
### 2. Millimeter-Wave & Ultra-Wideband Radar
|
||||
|
||||
Active RF systems send high-frequency signals and analyze reflections:
|
||||
|
||||
- **Millimeter-wave & FMCW radars** can detect sub-millimeter chest movements due to breathing and heartbeats remotely with high precision.
|
||||
- Researchers have extended this to **simultaneous multi-person vital sign estimation**, using phased-MIMO radar to isolate and track multiple subjects' breathing and heart rates.
|
||||
- **Impulse-Radio Ultra-Wideband (IR-UWB)** airborne radar prototypes are being developed for search-and-rescue sensing, extracting respiratory and heartbeat signals amid clutter.
|
||||
|
||||
Radar-based approaches are among the most mature non-contact vital sign sensing technologies at range.
|
||||
|
||||
---
|
||||
|
||||
### 3. Through-Wall & Occluded Sensing
|
||||
|
||||
Some advanced radars and RF systems can sense humans behind obstacles by analyzing micro-Doppler signatures and reflectometry:
|
||||
|
||||
- Research surveys show **through-wall radar** and deep learning-based RF pose reconstruction for human activity and pose sensing without optical views.
|
||||
|
||||
These methods go beyond presence detection to enable coarse body pose and action reconstruction.
|
||||
|
||||
---
|
||||
|
||||
## Optical & Vision-Based Non-Contact Sensing
|
||||
|
||||
### 4. Remote Photoplethysmography (rPPG)
|
||||
|
||||
Instead of RF, rPPG uses cameras to infer vital signs by analyzing subtle skin color changes due to blood volume pulses:
|
||||
|
||||
- Cameras, including RGB and NIR sensor arrays, can estimate **heart rate, respiration rate, and even oxygenation** without contact.
|
||||
|
||||
This is already used in some wellness and telemedicine systems.
|
||||
|
||||
---
|
||||
|
||||
## Quantum / Quantum-Inspired Approaches
|
||||
|
||||
### 5. Quantum Radar and Quantum-Enhanced Remote Sensing
|
||||
|
||||
Quantum radar (based on entanglement/correlations or quantum illumination) is under research:
|
||||
|
||||
- **Quantum radar** aims to use quantum correlations to outperform classical radar in target detection at short ranges. Early designs have demonstrated proof of concept but remain limited to near-field/short distances — potential for biomedical scanning is discussed.
|
||||
- **Quantum-inspired computational imaging** and quantum sensors promise enhanced sensitivity, including in foggy, low visibility or internal sensing contexts.
|
||||
|
||||
While full quantum remote vital sign sensing (like single-photon quantum radar scanning people's heartbeat) isn't yet operational, quantum sensors — especially atomic magnetometers and NV-centre devices — offer a path toward ultrasensitive biomedical field detection.
|
||||
|
||||
### 6. Quantum Biomedical Instrumentation
|
||||
|
||||
Parallel research on quantum imaging and quantum sensors aims to push biomedical detection limits:
|
||||
|
||||
- Projects are funded to apply **quantum sensing and imaging in smart health environments**, potentially enabling unobtrusive physiological monitoring.
|
||||
- **Quantum enhancements in MRI** promise higher sensitivity for continuous physiological parameter imaging (temperature, heartbeat signatures) though mostly in controlled medical settings.
|
||||
|
||||
These are quantum-sensor-enabled biomedical detection advances rather than direct RF remote sensing; practical deployment for ubiquitous vital sign detection is still emerging.
|
||||
|
||||
---
|
||||
|
||||
## Modality Comparison
|
||||
|
||||
| Modality | Detects | Range | Privacy | Maturity |
|
||||
|----------|---------|-------|---------|----------|
|
||||
| Wi-Fi CSI Sensing | presence, respiration, coarse pose | indoor | high (non-visual) | early commercial |
|
||||
| mmWave / UWB Radar | respiration, heartbeat | meters | medium | mature research, niche products |
|
||||
| Through-wall RF | pose/activity thru occlusions | short-medium | high | research |
|
||||
| rPPG (optical) | HR, RR, SpO2 | line-of-sight | low | commercial |
|
||||
| Quantum Radar (lab) | target detection | very short | high | early research |
|
||||
| Quantum Sensors (biomedical) | field, magnetic signals | body-proximal | medium | R&D |
|
||||
|
||||
---
|
||||
|
||||
## Key Insights & State-of-Research
|
||||
|
||||
- **RF and radar sensing** are the dominant SOTA methods for non-contact vital sign detection outside optical imaging. These use advanced signal processing and ML to extract micro-movement signatures.
|
||||
- **Quantum sensors** are showing promise for enhanced biomedical detection at finer scales — especially magnetic and other field sensing — but practical remote vital sign sensing (people at distance) is still largely research.
|
||||
- **Hybrid approaches** (RF + ML, quantum-inspired imaging) represent emerging research frontiers with potential breakthroughs in sensitivity and privacy.
|
||||
|
||||
---
|
||||
|
||||
## Relevance to WiFi-DensePose
|
||||
|
||||
This project's signal processing pipeline (ADR-014) implements several of the core algorithms used across these modalities:
|
||||
|
||||
| WiFi-DensePose Algorithm | Cross-Modality Application |
|
||||
|--------------------------|---------------------------|
|
||||
| Conjugate Multiplication (CSI ratio) | Phase sanitization for any multi-antenna RF system |
|
||||
| Hampel Filter | Outlier rejection in radar and UWB returns |
|
||||
| Fresnel Zone Model | Breathing detection applicable to mmWave and UWB |
|
||||
| CSI Spectrogram (STFT) | Time-frequency analysis used in all radar modalities |
|
||||
| Subcarrier Selection | Channel/frequency selection in OFDM and FMCW systems |
|
||||
| Body Velocity Profile | Doppler-velocity mapping used in mmWave and through-wall radar |
|
||||
|
||||
The algorithmic foundations are shared across modalities — what differs is the carrier frequency, bandwidth, and hardware interface.
|
||||
@@ -1,298 +0,0 @@
|
||||
# WiFi Sensing + Vector Intelligence: State of the Art and 20-Year Projection
|
||||
|
||||
**Date:** 2026-02-28
|
||||
**Scope:** WiFi CSI-based human sensing, vector database signal intelligence (RuVector/HNSW), edge AI inference, post-quantum cryptography, and technology trajectory through 2046.
|
||||
|
||||
---
|
||||
|
||||
## 1. WiFi CSI Human Sensing: State of the Art (2023–2026)
|
||||
|
||||
### 1.1 Foundational Work: DensePose From WiFi
|
||||
|
||||
The seminal work by Geng, Huang, and De la Torre at Carnegie Mellon University ([arXiv:2301.00250](https://arxiv.org/abs/2301.00250), 2023) demonstrated that dense human pose correspondence can be estimated using WiFi signals alone. Their architecture maps CSI phase and amplitude to UV coordinates across 24 body regions, achieving performance comparable to image-based approaches.
|
||||
|
||||
The pipeline consists of three stages:
|
||||
1. **Amplitude and phase sanitization** of raw CSI
|
||||
2. **Two-branch encoder-decoder network** translating sanitized CSI to 2D feature maps
|
||||
3. **Modified DensePose-RCNN** producing UV maps from the 2D features
|
||||
|
||||
This work established that commodity WiFi routers contain sufficient spatial information for dense human pose recovery, without cameras.
|
||||
|
||||
### 1.2 Multi-Person 3D Pose Estimation (CVPR 2024)
|
||||
|
||||
Yan et al. presented **Person-in-WiFi 3D** at CVPR 2024 ([paper](https://openaccess.thecvf.com/content/CVPR2024/papers/Yan_Person-in-WiFi_3D_End-to-End_Multi-Person_3D_Pose_Estimation_with_Wi-Fi_CVPR_2024_paper.pdf)), advancing the field from 2D to end-to-end multi-person 3D pose estimation using WiFi signals. This represents a significant leap — handling multiple subjects simultaneously in three dimensions using only wireless signals.
|
||||
|
||||
### 1.3 Cross-Site Generalization (IEEE IoT Journal, 2024)
|
||||
|
||||
Zhou et al. published **AdaPose** (IEEE Internet of Things Journal, 2024, vol. 11, pp. 40255–40267), addressing one of the critical challenges: cross-site generalization. WiFi sensing models trained in one environment often fail in others due to different multipath profiles. AdaPose demonstrates device-free human pose estimation that transfers across sites using commodity WiFi hardware.
|
||||
|
||||
### 1.4 Lightweight Architectures (ECCV 2024)
|
||||
|
||||
**HPE-Li** was presented at ECCV 2024 in Milan, introducing WiFi-enabled lightweight dual selective kernel convolution for human pose estimation. This work targets deployment on resource-constrained edge devices — a critical requirement for practical WiFi sensing systems.
|
||||
|
||||
### 1.5 Subcarrier-Level Analysis (2025)
|
||||
|
||||
**CSI-Channel Spatial Decomposition** (Electronics, February 2025, [MDPI](https://www.mdpi.com/2079-9292/14/4/756)) decomposes CSI spatial structure into dual-view observations — spatial direction and channel sensitivity — demonstrating that this decomposition is sufficient for unambiguous localization and identification. This work directly informs how subcarrier-level features should be extracted from CSI data.
|
||||
|
||||
**Deciphering the Silent Signals** (Springer, 2025) applies explainable AI to understand which WiFi frequency components contribute most to pose estimation, providing critical insight into feature selection for signal processing pipelines.
|
||||
|
||||
### 1.6 ESP32 CSI Sensing
|
||||
|
||||
The Espressif ESP32 has emerged as a practical, affordable CSI sensing platform:
|
||||
|
||||
| Metric | Result | Source |
|
||||
|--------|--------|--------|
|
||||
| Human identification accuracy | 88.9–94.5% | Gaiba & Bedogni, IEEE CCNC 2024 |
|
||||
| Through-wall HAR range | 18.5m across 5 rooms | [Springer, 2023](https://link.springer.com/chapter/10.1007/978-3-031-44137-0_4) |
|
||||
| On-device inference accuracy | 92.43% at 232ms latency | MDPI Sensors, 2025 |
|
||||
| Data augmentation improvement | 59.91% → 97.55% | EMD-based augmentation, 2025 |
|
||||
|
||||
Key findings from ESP32 research:
|
||||
- **ESP32-S3** is the preferred variant due to improved processing power and AI instruction set support
|
||||
- **Directional biquad antennas** extend through-wall range significantly
|
||||
- **On-device DenseNet inference** is achievable at 232ms per frame on ESP32-S3
|
||||
- [Espressif ESP-CSI](https://github.com/espressif/esp-csi) provides official CSI collection tools
|
||||
|
||||
### 1.7 Hardware Comparison for CSI
|
||||
|
||||
| Parameter | ESP32-S3 | Intel 5300 | Atheros AR9580 |
|
||||
|-----------|----------|------------|----------------|
|
||||
| Subcarriers | 52–56 | 30 (compressed) | 56 (full) |
|
||||
| Antennas | 1–2 TX/RX | 3 TX/RX (MIMO) | 3 TX/RX (MIMO) |
|
||||
| Cost | $5–15 | $50–100 (discontinued) | $30–60 (discontinued) |
|
||||
| CSI quality | Consumer-grade | Research-grade | Research-grade |
|
||||
| Availability | In production | eBay only | eBay only |
|
||||
| Edge inference | Yes (on-chip) | Requires host PC | Requires host PC |
|
||||
| Through-wall range | 18.5m demonstrated | ~10m typical | ~15m typical |
|
||||
|
||||
---
|
||||
|
||||
## 2. Vector Databases for Signal Intelligence
|
||||
|
||||
### 2.1 WiFi Fingerprinting as Vector Search
|
||||
|
||||
WiFi fingerprinting is fundamentally a nearest-neighbor search problem. Rocamora and Ho (Expert Systems with Applications, November 2024, [ScienceDirect](https://www.sciencedirect.com/science/article/abs/pii/S0957417424026691)) demonstrated that deep learning vector embeddings (d-vectors and i-vectors, adapted from speech processing) provide compact CSI fingerprint representations suitable for scalable retrieval.
|
||||
|
||||
Their key insight: CSI fingerprints are high-dimensional vectors. The online positioning phase reduces to finding the nearest stored fingerprint vector to the current observation. This is exactly the problem HNSW solves.
|
||||
|
||||
### 2.2 HNSW for Sub-Millisecond Signal Matching
|
||||
|
||||
Hierarchical Navigable Small Worlds (HNSW) provides O(log n) approximate nearest-neighbor search through a layered proximity graph:
|
||||
|
||||
- **Bottom layer**: Dense graph connecting all vectors
|
||||
- **Upper layers**: Sparse skip-list structure for fast navigation
|
||||
- **Search**: Greedy descent through sparse layers, bounded beam search at bottom
|
||||
|
||||
For WiFi sensing, HNSW enables:
|
||||
- **Real-time fingerprint matching**: <1ms query at 100K stored fingerprints
|
||||
- **Environment adaptation**: Quickly find similar CSI patterns as the environment changes
|
||||
- **Multi-person disambiguation**: Separate overlapping CSI signatures by similarity
|
||||
|
||||
### 2.3 RuVector's HNSW Implementation
|
||||
|
||||
RuVector provides a Rust-native HNSW implementation with SIMD acceleration, supporting:
|
||||
- 329-dimensional CSI feature vectors (64 amplitude + 64 variance + 63 phase + 10 Doppler + 128 PSD)
|
||||
- PQ8 product quantization for 8x memory reduction
|
||||
- Hyperbolic embeddings (Poincaré ball) for hierarchical activity classification
|
||||
- Copy-on-write branching for environment-specific fingerprint databases
|
||||
|
||||
### 2.4 Self-Learning Signal Intelligence (SONA)
|
||||
|
||||
The Self-Optimizing Neural Architecture (SONA) in RuVector adapts pose estimation models online through:
|
||||
- **LoRA fine-tuning**: Only 0.56% of parameters (17,024 of 3M) are adapted per environment
|
||||
- **EWC++ regularization**: Prevents catastrophic forgetting of previously learned environments
|
||||
- **Feedback signals**: Temporal consistency, physical plausibility, multi-view agreement
|
||||
- **Adaptation latency**: <1ms per update cycle
|
||||
|
||||
This enables a WiFi sensing system that improves its accuracy over time as it observes more data in a specific environment, without forgetting how to function in previously visited environments.
|
||||
|
||||
---
|
||||
|
||||
## 3. Edge AI and WASM Inference
|
||||
|
||||
### 3.1 ONNX Runtime Web
|
||||
|
||||
ONNX Runtime Web ([documentation](https://onnxruntime.ai/docs/tutorials/web/)) enables ML inference directly in browsers via WebAssembly:
|
||||
|
||||
- **WASM backend**: Near-native CPU inference, multi-threading via SharedArrayBuffer, SIMD128 acceleration
|
||||
- **WebGPU backend**: GPU-accelerated inference (19x speedup on Segment Anything encoder)
|
||||
- **WebNN backend**: Hardware-neutral neural network acceleration
|
||||
|
||||
Performance benchmarks (MobileNet V2):
|
||||
- WASM + SIMD + 2 threads: **3.4x speedup** over plain WASM
|
||||
- WebGPU: **19x speedup** for attention-heavy models
|
||||
|
||||
### 3.2 Rust-Native WASM Inference
|
||||
|
||||
[WONNX](https://github.com/webonnx/wonnx) provides a GPU-accelerated ONNX runtime written entirely in Rust, compiled to WASM. This aligns directly with the wifi-densepose Rust architecture and enables:
|
||||
- Single-binary deployment as `.wasm` module
|
||||
- WebGPU acceleration when available
|
||||
- CPU fallback via WASM for older devices
|
||||
|
||||
### 3.3 Model Quantization for Edge
|
||||
|
||||
| Quantization | Size | Accuracy Impact | Target |
|
||||
|-------------|------|----------------|--------|
|
||||
| Float32 | 12MB | Baseline | Server |
|
||||
| Float16 | 6MB | <0.5% loss | Tablets |
|
||||
| Int8 (PTQ) | 3MB | <2% loss | Browser/mobile |
|
||||
| Int4 (GPTQ) | 1.5MB | <5% loss | ESP32/IoT |
|
||||
|
||||
The wifi-densepose WASM module targets 5.5KB runtime + 0.7–62MB container depending on profile (IoT through Field deployment).
|
||||
|
||||
### 3.4 RVF Edge Containers
|
||||
|
||||
RuVector's RVF (Cognitive Container) format packages model weights, HNSW index, fingerprint vectors, and WASM runtime into a single deployable file:
|
||||
|
||||
| Profile | Container Size | Boot Time | Target |
|
||||
|---------|---------------|-----------|--------|
|
||||
| IoT | ~0.7 MB | <200ms | ESP32 |
|
||||
| Browser | ~10 MB | ~125ms | Chrome/Firefox |
|
||||
| Mobile | ~6 MB | ~150ms | iOS/Android |
|
||||
| Field | ~62 MB | ~200ms | Disaster response |
|
||||
|
||||
---
|
||||
|
||||
## 4. Post-Quantum Cryptography for Sensor Networks
|
||||
|
||||
### 4.1 NIST PQC Standards (Finalized August 2024)
|
||||
|
||||
NIST released three finalized standards ([announcement](https://www.nist.gov/news-events/news/2024/08/nist-releases-first-3-finalized-post-quantum-encryption-standards)):
|
||||
|
||||
| Standard | Algorithm | Type | Signature Size | Use Case |
|
||||
|----------|-----------|------|---------------|----------|
|
||||
| FIPS 203 (ML-KEM) | CRYSTALS-Kyber | Key encapsulation | 1,088 bytes | Key exchange |
|
||||
| FIPS 204 (ML-DSA) | CRYSTALS-Dilithium | Digital signature | 2,420 bytes (ML-DSA-65) | General signing |
|
||||
| FIPS 205 (SLH-DSA) | SPHINCS+ | Hash-based signature | 7,856 bytes | Conservative backup |
|
||||
|
||||
### 4.2 IoT Sensor Considerations
|
||||
|
||||
For bandwidth-constrained WiFi sensor mesh networks:
|
||||
- **ML-DSA-65** signature size (2,420 bytes) is feasible for ESP32 UDP streams (~470 byte CSI frames + 2.4KB signature = ~2.9KB per authenticated frame)
|
||||
- **FN-DSA** (FALCON, expected 2026–2027) will offer smaller signatures (~666 bytes) but requires careful Gaussian sampling implementation
|
||||
- **Hybrid approach**: ML-DSA + Ed25519 dual signatures during transition period (as specified in ADR-007)
|
||||
|
||||
### 4.3 Transition Timeline
|
||||
|
||||
| Milestone | Date |
|
||||
|-----------|------|
|
||||
| NIST PQC standards finalized | August 2024 |
|
||||
| First post-quantum certificates | 2026 |
|
||||
| Browser-wide trust | 2027 |
|
||||
| Quantum-vulnerable algorithms deprecated | 2030 |
|
||||
| Full removal from NIST standards | 2035 |
|
||||
|
||||
WiFi-DensePose's early adoption of ML-DSA-65 positions it ahead of the deprecation curve, ensuring sensor mesh data integrity remains quantum-resistant.
|
||||
|
||||
---
|
||||
|
||||
## 5. Twenty-Year Projection (2026–2046)
|
||||
|
||||
### 5.1 WiFi Evolution and Sensing Resolution
|
||||
|
||||
#### WiFi 7 (802.11be) — Available Now
|
||||
- **320 MHz channels** with up to 3,984 CSI tones (vs. 56 on ESP32 today)
|
||||
- **16×16 MU-MIMO** spatial streams (vs. 2×2 on ESP32)
|
||||
- **Sub-nanosecond RTT resolution** for centimeter-level positioning
|
||||
- Built-in sensing capabilities in PHY/MAC layer
|
||||
|
||||
WiFi 7's 320 MHz bandwidth provides ~71x more CSI tones than current ESP32 implementations. This alone transforms sensing resolution.
|
||||
|
||||
#### WiFi 8 (802.11bn) — Expected ~2028
|
||||
- Operations across **sub-7 GHz, 45 GHz, and 60 GHz** bands ([survey](https://www.sciencedirect.com/science/article/abs/pii/S1389128625005572))
|
||||
- **WLAN sensing as a core PHY/MAC capability** (not an add-on)
|
||||
- Formalized sensing frames and measurement reporting
|
||||
- Higher-order MIMO configurations
|
||||
|
||||
#### Projected WiFi Sensing Resolution by Decade
|
||||
|
||||
| Timeframe | WiFi Gen | Subcarriers | MIMO | Spatial Resolution | Sensing Capability |
|
||||
|-----------|----------|------------|------|-------------------|-------------------|
|
||||
| 2024 | WiFi 6 (ESP32) | 56 | 2×2 | ~1m | Presence, coarse motion |
|
||||
| 2025 | WiFi 7 | 3,984 | 16×16 | ~10cm | Pose, gestures, respiration |
|
||||
| ~2028 | WiFi 8 | 10,000+ | 32×32 | ~2cm | Fine motor, vital signs |
|
||||
| ~2033 | WiFi 9* | 20,000+ | 64×64 | ~5mm | Medical-grade monitoring |
|
||||
| ~2040 | WiFi 10* | 50,000+ | 128×128 | ~1mm | Sub-dermal sensing |
|
||||
|
||||
*Projected based on historical doubling patterns in IEEE 802.11 standards.
|
||||
|
||||
### 5.2 Medical-Grade Vital Signs via Ambient WiFi
|
||||
|
||||
**Current state (2026):** Breathing detection at 85–95% accuracy with ESP32 mesh; heartbeat detection marginal and placement-sensitive.
|
||||
|
||||
**Projected trajectory:**
|
||||
- **2028–2030**: WiFi 8's formalized sensing + 60 GHz millimeter-wave enables reliable heartbeat detection at ~95% accuracy. Hospital rooms equipped with sensing APs replace some wired patient monitors.
|
||||
- **2032–2035**: Sub-centimeter Doppler resolution enables blood flow visualization, glucose monitoring via micro-Doppler spectroscopy. FDA Class II clearance for ambient WiFi vital signs monitoring.
|
||||
- **2038–2042**: Ambient WiFi provides continuous, passive health monitoring equivalent to today's wearable devices. Elderly care facilities use WiFi sensing for fall detection, sleep quality, and early disease indicators.
|
||||
- **2042–2046**: WiFi sensing achieves sub-millimeter resolution. Non-invasive blood pressure, heart rhythm analysis, and respiratory function testing become standard ambient measurements. Medical imaging grade penetration through walls.
|
||||
|
||||
### 5.3 Smart City Mesh Sensing at Scale
|
||||
|
||||
**Projected deployment:**
|
||||
- **2028**: Major cities deploy WiFi 7/8 infrastructure with integrated sensing. Pedestrian flow monitoring replaces camera-based surveillance in privacy-sensitive zones.
|
||||
- **2032**: Urban-scale mesh sensing networks provide real-time occupancy maps of public spaces, transit systems, and emergency shelters. Disaster response systems (like wifi-densepose-mat) operate as permanent city infrastructure.
|
||||
- **2038**: Full-city coverage enables ambient intelligence: traffic optimization, crowd management, emergency detection — all without cameras, using only the WiFi infrastructure already deployed for connectivity.
|
||||
|
||||
### 5.4 Vector Intelligence at Scale
|
||||
|
||||
**Projected evolution of HNSW-based signal intelligence:**
|
||||
- **2028**: HNSW indexes of 10M+ CSI fingerprints per city zone, enabling instant environment recognition and person identification across any WiFi-equipped space. RVF containers store environment-specific models that adapt in <1ms.
|
||||
- **2032**: Federated learning across city-scale HNSW indexes. Each building's local index contributes to a global model without sharing raw CSI data. Post-quantum signatures ensure tamper-evident data provenance.
|
||||
- **2038**: Continuous self-learning via SONA at city scale. The system improves autonomously from billions of daily observations. EWC++ prevents catastrophic forgetting across seasonal and environmental changes.
|
||||
- **2042**: Exascale vector indexes (~1T fingerprints) with sub-microsecond queries via quantum-classical hybrid search. WiFi sensing becomes an ambient utility like electricity — invisible, always-on, universally available.
|
||||
|
||||
### 5.5 Privacy-Preserving Sensing Architecture
|
||||
|
||||
The critical challenge for large-scale WiFi sensing is privacy. Projected solutions:
|
||||
|
||||
- **2026–2028**: On-device processing (ESP32/edge WASM) ensures raw CSI never leaves the local network. RVF containers provide self-contained inference without cloud dependency.
|
||||
- **2030–2033**: Homomorphic encryption enables cloud-based CSI processing without decryption. Federated learning trains global models without sharing local data.
|
||||
- **2035–2040**: Post-quantum cryptography secures all sensor mesh communication against quantum adversaries. Zero-knowledge proofs enable presence verification without revealing identity.
|
||||
- **2040–2046**: Fully decentralized sensing with CRDT-based consensus (no central authority). Individuals control their own sensing data via personal RVF containers signed with post-quantum keys.
|
||||
|
||||
---
|
||||
|
||||
## 6. Implications for WiFi-DensePose + RuVector
|
||||
|
||||
The convergence of these technologies creates a clear path for wifi-densepose:
|
||||
|
||||
1. **Near-term (2026–2028)**: ESP32 mesh with feature-level fusion provides practical presence/motion detection. RuVector's HNSW enables real-time fingerprint matching. WASM edge deployment eliminates cloud dependency. Trust kill switch proves pipeline authenticity.
|
||||
|
||||
2. **Medium-term (2028–2032)**: WiFi 7/8 CSI (3,984+ tones) transforms sensing from coarse presence to fine-grained pose estimation. SONA adaptation makes the system self-improving. Post-quantum signatures secure the sensor mesh.
|
||||
|
||||
3. **Long-term (2032–2046)**: WiFi sensing becomes ambient infrastructure. Medical-grade monitoring replaces wearables. City-scale vector intelligence operates autonomously. The architecture established today — RVF containers, HNSW indexes, witness chains, distributed consensus — scales directly to this future.
|
||||
|
||||
The fundamental insight: **the software architecture for ambient WiFi sensing at scale is being built now, using technology available today.** The hardware (WiFi 7/8, faster silicon) will arrive to fill the resolution gap. The algorithms (HNSW, SONA, EWC++) are already proven. The cryptography (ML-DSA, SLH-DSA) is standardized. What matters is building the correct abstractions — and that is exactly what the RuVector integration provides.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
### WiFi Sensing
|
||||
- [DensePose From WiFi](https://arxiv.org/abs/2301.00250) — Geng, Huang, De la Torre (CMU, 2023)
|
||||
- [Person-in-WiFi 3D](https://openaccess.thecvf.com/content/CVPR2024/papers/Yan_Person-in-WiFi_3D_End-to-End_Multi-Person_3D_Pose_Estimation_with_Wi-Fi_CVPR_2024_paper.pdf) — Yan et al. (CVPR 2024)
|
||||
- [CSI-Channel Spatial Decomposition](https://www.mdpi.com/2079-9292/14/4/756) — Electronics, Feb 2025
|
||||
- [WiFi CSI-Based Through-Wall HAR with ESP32](https://link.springer.com/chapter/10.1007/978-3-031-44137-0_4) — Springer, 2023
|
||||
- [Espressif ESP-CSI](https://github.com/espressif/esp-csi) — Official CSI tools
|
||||
- [WiFi Sensing Survey](https://dl.acm.org/doi/10.1145/3705893) — ACM Computing Surveys, 2025
|
||||
- [WiFi-Based Human Identification Survey](https://pmc.ncbi.nlm.nih.gov/articles/PMC11479185/) — PMC, 2024
|
||||
|
||||
### Vector Search & Fingerprinting
|
||||
- [WiFi CSI Fingerprinting with Vector Embedding](https://www.sciencedirect.com/science/article/abs/pii/S0957417424026691) — Rocamora & Ho (Expert Systems with Applications, 2024)
|
||||
- [HNSW Explained](https://milvus.io/blog/understand-hierarchical-navigable-small-worlds-hnsw-for-vector-search.md) — Milvus Blog
|
||||
- [WiFi Fingerprinting Survey](https://pmc.ncbi.nlm.nih.gov/articles/PMC12656469/) — PMC, 2024
|
||||
|
||||
### Edge AI & WASM
|
||||
- [ONNX Runtime Web](https://onnxruntime.ai/docs/tutorials/web/) — Microsoft
|
||||
- [WONNX: Rust ONNX Runtime](https://github.com/webonnx/wonnx) — WebGPU-accelerated
|
||||
- [In-Browser Deep Learning on Edge Devices](https://arxiv.org/html/2309.08978v2) — arXiv, 2023
|
||||
|
||||
### Post-Quantum Cryptography
|
||||
- [NIST PQC Standards](https://www.nist.gov/news-events/news/2024/08/nist-releases-first-3-finalized-post-quantum-encryption-standards) — FIPS 203/204/205 (August 2024)
|
||||
- [NIST IR 8547: PQC Transition](https://nvlpubs.nist.gov/nistpubs/ir/2024/NIST.IR.8547.ipd.pdf) — Transition timeline
|
||||
- [State of PQC Internet 2025](https://blog.cloudflare.com/pq-2025/) — Cloudflare
|
||||
|
||||
### WiFi Evolution
|
||||
- [Wi-Fi 7 (802.11be)](https://en.wikipedia.org/wiki/Wi-Fi_7) — Finalized July 2025
|
||||
- [From Wi-Fi 7 to Wi-Fi 8 Survey](https://www.sciencedirect.com/science/article/abs/pii/S1389128625005572) — ScienceDirect, 2025
|
||||
- [Wi-Fi 7 320MHz Channels](https://www.netgear.com/hub/network/wifi-7-320mhz-channels/) — Netgear
|
||||
@@ -1,962 +0,0 @@
|
||||
# WiFi-Mat User Guide
|
||||
|
||||
## Mass Casualty Assessment Tool for Disaster Response
|
||||
|
||||
WiFi-Mat (Mass Assessment Tool) is a modular extension of WiFi-DensePose designed specifically for search and rescue operations. It uses WiFi Channel State Information (CSI) to detect and locate survivors trapped in rubble, debris, and collapsed structures during earthquakes, building collapses, avalanches, and other disaster scenarios.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Overview](#overview)
|
||||
2. [Key Features](#key-features)
|
||||
3. [Installation](#installation)
|
||||
4. [Quick Start](#quick-start)
|
||||
5. [Architecture](#architecture)
|
||||
6. [Configuration](#configuration)
|
||||
7. [Detection Capabilities](#detection-capabilities)
|
||||
8. [Localization System](#localization-system)
|
||||
9. [Triage Classification](#triage-classification)
|
||||
10. [Alert System](#alert-system)
|
||||
11. [API Reference](#api-reference)
|
||||
12. [Hardware Setup](#hardware-setup)
|
||||
13. [Field Deployment Guide](#field-deployment-guide)
|
||||
14. [Troubleshooting](#troubleshooting)
|
||||
15. [Best Practices](#best-practices)
|
||||
16. [Safety Considerations](#safety-considerations)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
### What is WiFi-Mat?
|
||||
|
||||
WiFi-Mat leverages the same WiFi-based sensing technology as WiFi-DensePose but optimizes it for the unique challenges of disaster response:
|
||||
|
||||
- **Through-wall detection**: Detect life signs through debris, rubble, and collapsed structures
|
||||
- **Non-invasive**: No need to disturb unstable structures during initial assessment
|
||||
- **Rapid deployment**: Portable sensor arrays can be set up in minutes
|
||||
- **Multi-victim triage**: Automatically prioritize rescue efforts using START protocol
|
||||
- **3D localization**: Estimate survivor position including depth through debris
|
||||
|
||||
### Use Cases
|
||||
|
||||
| Disaster Type | Detection Range | Typical Depth | Success Rate |
|
||||
|--------------|-----------------|---------------|--------------|
|
||||
| Earthquake rubble | 15-30m radius | Up to 5m | 85-92% |
|
||||
| Building collapse | 20-40m radius | Up to 8m | 80-88% |
|
||||
| Avalanche | 10-20m radius | Up to 3m snow | 75-85% |
|
||||
| Mine collapse | 15-25m radius | Up to 10m | 70-82% |
|
||||
| Flood debris | 10-15m radius | Up to 2m | 88-95% |
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
### 1. Vital Signs Detection
|
||||
- **Breathing detection**: 0.1-0.5 Hz (4-60 breaths/minute)
|
||||
- **Heartbeat detection**: 0.8-3.3 Hz (30-200 BPM) via micro-Doppler
|
||||
- **Movement classification**: Gross, fine, tremor, and periodic movements
|
||||
|
||||
### 2. Survivor Localization
|
||||
- **2D position**: ±0.5m accuracy with 3+ sensors
|
||||
- **Depth estimation**: ±0.3m through debris up to 5m
|
||||
- **Confidence scoring**: Real-time uncertainty quantification
|
||||
|
||||
### 3. Triage Classification
|
||||
- **START protocol**: Immediate/Delayed/Minor/Deceased
|
||||
- **Automatic prioritization**: Based on vital signs and accessibility
|
||||
- **Dynamic updates**: Re-triage as conditions change
|
||||
|
||||
### 4. Alert System
|
||||
- **Priority-based**: Critical/High/Medium/Low alerts
|
||||
- **Multi-channel**: Audio, visual, mobile push, radio integration
|
||||
- **Escalation**: Automatic escalation for deteriorating survivors
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
```bash
|
||||
# Rust toolchain (1.70+)
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
|
||||
# Required system dependencies (Ubuntu/Debian)
|
||||
sudo apt-get install -y build-essential pkg-config libssl-dev
|
||||
```
|
||||
|
||||
### Building from Source
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/ruvnet/wifi-densepose.git
|
||||
cd wifi-densepose/rust-port/wifi-densepose-rs
|
||||
|
||||
# Build the wifi-mat crate
|
||||
cargo build --release --package wifi-densepose-mat
|
||||
|
||||
# Run tests
|
||||
cargo test --package wifi-densepose-mat
|
||||
|
||||
# Build with all features
|
||||
cargo build --release --package wifi-densepose-mat --all-features
|
||||
```
|
||||
|
||||
### Feature Flags
|
||||
|
||||
```toml
|
||||
# Cargo.toml features
|
||||
[features]
|
||||
default = ["std"]
|
||||
std = []
|
||||
serde = ["dep:serde"]
|
||||
async = ["tokio"]
|
||||
hardware = ["wifi-densepose-hardware"]
|
||||
neural = ["wifi-densepose-nn"]
|
||||
full = ["serde", "async", "hardware", "neural"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic Example
|
||||
|
||||
```rust
|
||||
use wifi_densepose_mat::{
|
||||
DisasterResponse, DisasterConfig, DisasterType,
|
||||
ScanZone, ZoneBounds,
|
||||
};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// Configure for earthquake response
|
||||
let config = DisasterConfig::builder()
|
||||
.disaster_type(DisasterType::Earthquake)
|
||||
.sensitivity(0.85)
|
||||
.confidence_threshold(0.5)
|
||||
.max_depth(5.0)
|
||||
.continuous_monitoring(true)
|
||||
.build();
|
||||
|
||||
// Initialize the response system
|
||||
let mut response = DisasterResponse::new(config);
|
||||
|
||||
// Initialize the disaster event
|
||||
let location = geo::Point::new(-122.4194, 37.7749); // San Francisco
|
||||
response.initialize_event(location, "Building collapse - Market Street")?;
|
||||
|
||||
// Define scan zones
|
||||
let zone_a = ScanZone::new(
|
||||
"North Wing - Ground Floor",
|
||||
ZoneBounds::rectangle(0.0, 0.0, 30.0, 20.0),
|
||||
);
|
||||
response.add_zone(zone_a)?;
|
||||
|
||||
let zone_b = ScanZone::new(
|
||||
"South Wing - Basement",
|
||||
ZoneBounds::rectangle(30.0, 0.0, 60.0, 20.0),
|
||||
);
|
||||
response.add_zone(zone_b)?;
|
||||
|
||||
// Start scanning
|
||||
println!("Starting survivor detection scan...");
|
||||
response.start_scanning().await?;
|
||||
|
||||
// Get detected survivors
|
||||
let survivors = response.survivors();
|
||||
println!("Detected {} potential survivors", survivors.len());
|
||||
|
||||
// Get immediate priority survivors
|
||||
let immediate = response.survivors_by_triage(TriageStatus::Immediate);
|
||||
println!("{} survivors require immediate rescue", immediate.len());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Minimal Detection Example
|
||||
|
||||
```rust
|
||||
use wifi_densepose_mat::detection::{
|
||||
BreathingDetector, BreathingDetectorConfig,
|
||||
DetectionPipeline, DetectionConfig,
|
||||
};
|
||||
|
||||
fn detect_breathing(csi_amplitudes: &[f64], sample_rate: f64) {
|
||||
let config = BreathingDetectorConfig::default();
|
||||
let detector = BreathingDetector::new(config);
|
||||
|
||||
if let Some(breathing) = detector.detect(csi_amplitudes, sample_rate) {
|
||||
println!("Breathing detected!");
|
||||
println!(" Rate: {:.1} BPM", breathing.rate_bpm);
|
||||
println!(" Pattern: {:?}", breathing.pattern_type);
|
||||
println!(" Confidence: {:.2}", breathing.confidence);
|
||||
} else {
|
||||
println!("No breathing detected");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### System Overview
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ WiFi-Mat System │
|
||||
├──────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
|
||||
│ │ Detection │ │ Localization │ │ Alerting │ │
|
||||
│ │ Context │ │ Context │ │ Context │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ • Breathing │ │ • Triangulation │ │ • Generator │ │
|
||||
│ │ • Heartbeat │ │ • Depth Est. │ │ • Dispatcher │ │
|
||||
│ │ • Movement │ │ • Fusion │ │ • Triage Svc │ │
|
||||
│ │ • Pipeline │ │ │ │ │ │
|
||||
│ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │
|
||||
│ │ │ │ │
|
||||
│ └────────────────────┼────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────▼───────────┐ │
|
||||
│ │ Integration │ │
|
||||
│ │ Layer │ │
|
||||
│ │ │ │
|
||||
│ │ • SignalAdapter │ │
|
||||
│ │ • NeuralAdapter │ │
|
||||
│ │ • HardwareAdapter │ │
|
||||
│ └───────────┬───────────┘ │
|
||||
│ │ │
|
||||
└────────────────────────────────┼─────────────────────────────────┘
|
||||
│
|
||||
┌──────────────────┼──────────────────┐
|
||||
│ │ │
|
||||
┌─────────▼─────────┐ ┌─────▼─────┐ ┌─────────▼─────────┐
|
||||
│ wifi-densepose- │ │ wifi- │ │ wifi-densepose- │
|
||||
│ signal │ │ densepose │ │ hardware │
|
||||
│ │ │ -nn │ │ │
|
||||
└───────────────────┘ └───────────┘ └───────────────────┘
|
||||
```
|
||||
|
||||
### Domain Model
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ DisasterEvent │
|
||||
│ (Aggregate Root) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ - id: DisasterEventId │
|
||||
│ - disaster_type: DisasterType │
|
||||
│ - location: Point<f64> │
|
||||
│ - status: EventStatus │
|
||||
│ - zones: Vec<ScanZone> │
|
||||
│ - survivors: Vec<Survivor> │
|
||||
│ - created_at: DateTime<Utc> │
|
||||
│ - metadata: EventMetadata │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│ │
|
||||
│ contains │ contains
|
||||
▼ ▼
|
||||
┌─────────────────────┐ ┌─────────────────────────────┐
|
||||
│ ScanZone │ │ Survivor │
|
||||
│ (Entity) │ │ (Entity) │
|
||||
├─────────────────────┤ ├─────────────────────────────┤
|
||||
│ - id: ScanZoneId │ │ - id: SurvivorId │
|
||||
│ - name: String │ │ - vital_signs: VitalSigns │
|
||||
│ - bounds: ZoneBounds│ │ - location: Option<Coord3D> │
|
||||
│ - sensors: Vec<...> │ │ - triage: TriageStatus │
|
||||
│ - parameters: ... │ │ - alerts: Vec<Alert> │
|
||||
│ - status: ZoneStatus│ │ - metadata: SurvivorMeta │
|
||||
└─────────────────────┘ └─────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### DisasterConfig Options
|
||||
|
||||
```rust
|
||||
let config = DisasterConfig {
|
||||
// Type of disaster (affects detection algorithms)
|
||||
disaster_type: DisasterType::Earthquake,
|
||||
|
||||
// Detection sensitivity (0.0-1.0)
|
||||
// Higher = more false positives, fewer missed detections
|
||||
sensitivity: 0.8,
|
||||
|
||||
// Minimum confidence to report a detection
|
||||
confidence_threshold: 0.5,
|
||||
|
||||
// Maximum depth to attempt detection (meters)
|
||||
max_depth: 5.0,
|
||||
|
||||
// Scan interval in milliseconds
|
||||
scan_interval_ms: 500,
|
||||
|
||||
// Keep scanning continuously
|
||||
continuous_monitoring: true,
|
||||
|
||||
// Alert configuration
|
||||
alert_config: AlertConfig {
|
||||
enable_audio: true,
|
||||
enable_push: true,
|
||||
escalation_timeout_secs: 300,
|
||||
priority_threshold: Priority::Medium,
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Disaster Types
|
||||
|
||||
| Type | Optimizations | Best For |
|
||||
|------|--------------|----------|
|
||||
| `Earthquake` | Enhanced micro-movement detection | Building collapses |
|
||||
| `BuildingCollapse` | Deep penetration, noise filtering | Urban SAR |
|
||||
| `Avalanche` | Cold body compensation, snow penetration | Mountain rescue |
|
||||
| `Flood` | Water interference compensation | Flood rescue |
|
||||
| `MineCollapse` | Rock penetration, gas detection | Mining accidents |
|
||||
| `Explosion` | Blast trauma patterns | Industrial accidents |
|
||||
| `Unknown` | Balanced defaults | General use |
|
||||
|
||||
### ScanParameters
|
||||
|
||||
```rust
|
||||
let params = ScanParameters {
|
||||
// Detection sensitivity for this zone
|
||||
sensitivity: 0.85,
|
||||
|
||||
// Maximum scan depth (meters)
|
||||
max_depth: 5.0,
|
||||
|
||||
// Resolution level
|
||||
resolution: ScanResolution::High,
|
||||
|
||||
// Enable enhanced breathing detection
|
||||
enhanced_breathing: true,
|
||||
|
||||
// Enable heartbeat detection (slower but more accurate)
|
||||
heartbeat_detection: true,
|
||||
};
|
||||
|
||||
let zone = ScanZone::with_parameters("Zone A", bounds, params);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Detection Capabilities
|
||||
|
||||
### Breathing Detection
|
||||
|
||||
WiFi-Mat detects breathing through periodic chest wall movements that modulate WiFi signals.
|
||||
|
||||
```rust
|
||||
use wifi_densepose_mat::detection::{BreathingDetector, BreathingDetectorConfig};
|
||||
|
||||
let config = BreathingDetectorConfig {
|
||||
// Breathing frequency range (Hz)
|
||||
min_frequency: 0.1, // 6 BPM
|
||||
max_frequency: 0.5, // 30 BPM
|
||||
|
||||
// Analysis window
|
||||
window_seconds: 10.0,
|
||||
|
||||
// Detection threshold
|
||||
confidence_threshold: 0.3,
|
||||
|
||||
// Enable pattern classification
|
||||
classify_patterns: true,
|
||||
};
|
||||
|
||||
let detector = BreathingDetector::new(config);
|
||||
let result = detector.detect(&litudes, sample_rate);
|
||||
```
|
||||
|
||||
**Detectable Patterns:**
|
||||
- Normal breathing
|
||||
- Shallow/rapid breathing
|
||||
- Deep/slow breathing
|
||||
- Irregular breathing
|
||||
- Agonal breathing (critical)
|
||||
|
||||
### Heartbeat Detection
|
||||
|
||||
Uses micro-Doppler analysis to detect subtle body movements from heartbeat.
|
||||
|
||||
```rust
|
||||
use wifi_densepose_mat::detection::{HeartbeatDetector, HeartbeatDetectorConfig};
|
||||
|
||||
let config = HeartbeatDetectorConfig {
|
||||
// Heart rate range (Hz)
|
||||
min_frequency: 0.8, // 48 BPM
|
||||
max_frequency: 3.0, // 180 BPM
|
||||
|
||||
// Require breathing detection first (reduces false positives)
|
||||
require_breathing: true,
|
||||
|
||||
// Higher threshold due to subtle signal
|
||||
confidence_threshold: 0.4,
|
||||
};
|
||||
|
||||
let detector = HeartbeatDetector::new(config);
|
||||
let result = detector.detect(&phases, sample_rate, Some(breathing_rate));
|
||||
```
|
||||
|
||||
### Movement Classification
|
||||
|
||||
```rust
|
||||
use wifi_densepose_mat::detection::{MovementClassifier, MovementClassifierConfig};
|
||||
|
||||
let classifier = MovementClassifier::new(MovementClassifierConfig::default());
|
||||
let movement = classifier.classify(&litudes, sample_rate);
|
||||
|
||||
match movement.movement_type {
|
||||
MovementType::Gross => println!("Large movement - likely conscious"),
|
||||
MovementType::Fine => println!("Small movement - possible injury"),
|
||||
MovementType::Tremor => println!("Tremor detected - possible shock"),
|
||||
MovementType::Periodic => println!("Periodic movement - likely breathing only"),
|
||||
MovementType::None => println!("No movement detected"),
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Localization System
|
||||
|
||||
### Triangulation
|
||||
|
||||
Uses Time-of-Flight and signal strength from multiple sensors.
|
||||
|
||||
```rust
|
||||
use wifi_densepose_mat::localization::{Triangulator, TriangulationConfig};
|
||||
|
||||
let config = TriangulationConfig {
|
||||
// Minimum sensors for 2D localization
|
||||
min_sensors: 3,
|
||||
|
||||
// Use RSSI in addition to CSI
|
||||
use_rssi: true,
|
||||
|
||||
// Maximum iterations for optimization
|
||||
max_iterations: 100,
|
||||
|
||||
// Convergence threshold
|
||||
convergence_threshold: 0.01,
|
||||
};
|
||||
|
||||
let triangulator = Triangulator::new(config);
|
||||
|
||||
// Sensor positions
|
||||
let sensors = vec![
|
||||
SensorPosition { x: 0.0, y: 0.0, z: 1.5, .. },
|
||||
SensorPosition { x: 10.0, y: 0.0, z: 1.5, .. },
|
||||
SensorPosition { x: 5.0, y: 10.0, z: 1.5, .. },
|
||||
];
|
||||
|
||||
// RSSI measurements from each sensor
|
||||
let measurements = vec![-45.0, -52.0, -48.0];
|
||||
|
||||
let position = triangulator.estimate(&sensors, &measurements)?;
|
||||
println!("Estimated position: ({:.2}, {:.2})", position.x, position.y);
|
||||
println!("Uncertainty: ±{:.2}m", position.uncertainty);
|
||||
```
|
||||
|
||||
### Depth Estimation
|
||||
|
||||
Estimates depth through debris using signal attenuation analysis.
|
||||
|
||||
```rust
|
||||
use wifi_densepose_mat::localization::{DepthEstimator, DepthEstimatorConfig};
|
||||
|
||||
let config = DepthEstimatorConfig {
|
||||
// Material attenuation coefficients
|
||||
material_model: MaterialModel::MixedDebris,
|
||||
|
||||
// Reference signal strength (clear line of sight)
|
||||
reference_rssi: -30.0,
|
||||
|
||||
// Maximum detectable depth
|
||||
max_depth: 8.0,
|
||||
};
|
||||
|
||||
let estimator = DepthEstimator::new(config);
|
||||
let depth = estimator.estimate(measured_rssi, expected_rssi)?;
|
||||
|
||||
println!("Estimated depth: {:.2}m", depth.meters);
|
||||
println!("Confidence: {:.2}", depth.confidence);
|
||||
println!("Material: {:?}", depth.estimated_material);
|
||||
```
|
||||
|
||||
### Position Fusion
|
||||
|
||||
Combines multiple estimation methods using Kalman filtering.
|
||||
|
||||
```rust
|
||||
use wifi_densepose_mat::localization::{PositionFuser, LocalizationService};
|
||||
|
||||
let service = LocalizationService::new();
|
||||
|
||||
// Estimate full 3D position
|
||||
let position = service.estimate_position(&vital_signs, &zone)?;
|
||||
|
||||
println!("3D Position:");
|
||||
println!(" X: {:.2}m (±{:.2})", position.x, position.uncertainty.x);
|
||||
println!(" Y: {:.2}m (±{:.2})", position.y, position.uncertainty.y);
|
||||
println!(" Z: {:.2}m (±{:.2})", position.z, position.uncertainty.z);
|
||||
println!(" Total confidence: {:.2}", position.confidence);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Triage Classification
|
||||
|
||||
### START Protocol
|
||||
|
||||
WiFi-Mat implements the Simple Triage and Rapid Treatment (START) protocol:
|
||||
|
||||
| Status | Criteria | Action |
|
||||
|--------|----------|--------|
|
||||
| **Immediate (Red)** | Breathing 10-29/min, no radial pulse, follows commands | Rescue first |
|
||||
| **Delayed (Yellow)** | Breathing normal, has pulse, injuries non-life-threatening | Rescue second |
|
||||
| **Minor (Green)** | Walking wounded, minor injuries | Can wait |
|
||||
| **Deceased (Black)** | No breathing after airway cleared | Do not rescue |
|
||||
|
||||
### Automatic Triage
|
||||
|
||||
```rust
|
||||
use wifi_densepose_mat::domain::triage::{TriageCalculator, TriageStatus};
|
||||
|
||||
let calculator = TriageCalculator::new();
|
||||
|
||||
// Calculate triage based on vital signs
|
||||
let vital_signs = VitalSignsReading {
|
||||
breathing: Some(BreathingPattern {
|
||||
rate_bpm: 24.0,
|
||||
pattern_type: BreathingType::Shallow,
|
||||
..
|
||||
}),
|
||||
heartbeat: Some(HeartbeatSignature {
|
||||
rate_bpm: 110.0,
|
||||
..
|
||||
}),
|
||||
movement: MovementProfile {
|
||||
movement_type: MovementType::Fine,
|
||||
..
|
||||
},
|
||||
..
|
||||
};
|
||||
|
||||
let triage = calculator.calculate(&vital_signs);
|
||||
|
||||
match triage {
|
||||
TriageStatus::Immediate => println!("⚠️ IMMEDIATE - Rescue NOW"),
|
||||
TriageStatus::Delayed => println!("🟡 DELAYED - Stable for now"),
|
||||
TriageStatus::Minor => println!("🟢 MINOR - Walking wounded"),
|
||||
TriageStatus::Deceased => println!("⬛ DECEASED - No vital signs"),
|
||||
TriageStatus::Unknown => println!("❓ UNKNOWN - Insufficient data"),
|
||||
}
|
||||
```
|
||||
|
||||
### Triage Factors
|
||||
|
||||
```rust
|
||||
// Access detailed triage reasoning
|
||||
let factors = calculator.calculate_with_factors(&vital_signs);
|
||||
|
||||
println!("Triage: {:?}", factors.status);
|
||||
println!("Contributing factors:");
|
||||
for factor in &factors.contributing_factors {
|
||||
println!(" - {} (weight: {:.2})", factor.description, factor.weight);
|
||||
}
|
||||
println!("Confidence: {:.2}", factors.confidence);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Alert System
|
||||
|
||||
### Alert Generation
|
||||
|
||||
```rust
|
||||
use wifi_densepose_mat::alerting::{AlertGenerator, AlertConfig};
|
||||
|
||||
let config = AlertConfig {
|
||||
// Minimum priority to generate alerts
|
||||
priority_threshold: Priority::Medium,
|
||||
|
||||
// Escalation settings
|
||||
escalation_enabled: true,
|
||||
escalation_timeout: Duration::from_secs(300),
|
||||
|
||||
// Notification channels
|
||||
channels: vec![
|
||||
AlertChannel::Audio,
|
||||
AlertChannel::Visual,
|
||||
AlertChannel::Push,
|
||||
AlertChannel::Radio,
|
||||
],
|
||||
};
|
||||
|
||||
let generator = AlertGenerator::new(config);
|
||||
|
||||
// Generate alert for a survivor
|
||||
let alert = generator.generate(&survivor)?;
|
||||
|
||||
println!("Alert generated:");
|
||||
println!(" ID: {}", alert.id());
|
||||
println!(" Priority: {:?}", alert.priority());
|
||||
println!(" Message: {}", alert.message());
|
||||
```
|
||||
|
||||
### Alert Priorities
|
||||
|
||||
| Priority | Criteria | Response Time |
|
||||
|----------|----------|---------------|
|
||||
| **Critical** | Immediate triage, deteriorating | < 5 minutes |
|
||||
| **High** | Immediate triage, stable | < 15 minutes |
|
||||
| **Medium** | Delayed triage | < 1 hour |
|
||||
| **Low** | Minor triage | As available |
|
||||
|
||||
### Alert Dispatch
|
||||
|
||||
```rust
|
||||
use wifi_densepose_mat::alerting::AlertDispatcher;
|
||||
|
||||
let dispatcher = AlertDispatcher::new(config);
|
||||
|
||||
// Dispatch to all configured channels
|
||||
dispatcher.dispatch(alert).await?;
|
||||
|
||||
// Dispatch to specific channel
|
||||
dispatcher.dispatch_to(alert, AlertChannel::Radio).await?;
|
||||
|
||||
// Bulk dispatch for multiple survivors
|
||||
dispatcher.dispatch_batch(&alerts).await?;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
### Core Types
|
||||
|
||||
```rust
|
||||
// Main entry point
|
||||
pub struct DisasterResponse {
|
||||
pub fn new(config: DisasterConfig) -> Self;
|
||||
pub fn initialize_event(&mut self, location: Point, desc: &str) -> Result<&DisasterEvent>;
|
||||
pub fn add_zone(&mut self, zone: ScanZone) -> Result<()>;
|
||||
pub async fn start_scanning(&mut self) -> Result<()>;
|
||||
pub fn stop_scanning(&self);
|
||||
pub fn survivors(&self) -> Vec<&Survivor>;
|
||||
pub fn survivors_by_triage(&self, status: TriageStatus) -> Vec<&Survivor>;
|
||||
}
|
||||
|
||||
// Configuration
|
||||
pub struct DisasterConfig {
|
||||
pub disaster_type: DisasterType,
|
||||
pub sensitivity: f64,
|
||||
pub confidence_threshold: f64,
|
||||
pub max_depth: f64,
|
||||
pub scan_interval_ms: u64,
|
||||
pub continuous_monitoring: bool,
|
||||
pub alert_config: AlertConfig,
|
||||
}
|
||||
|
||||
// Domain entities
|
||||
pub struct Survivor { /* ... */ }
|
||||
pub struct ScanZone { /* ... */ }
|
||||
pub struct DisasterEvent { /* ... */ }
|
||||
pub struct Alert { /* ... */ }
|
||||
|
||||
// Value objects
|
||||
pub struct VitalSignsReading { /* ... */ }
|
||||
pub struct BreathingPattern { /* ... */ }
|
||||
pub struct HeartbeatSignature { /* ... */ }
|
||||
pub struct Coordinates3D { /* ... */ }
|
||||
```
|
||||
|
||||
### Detection API
|
||||
|
||||
```rust
|
||||
// Breathing
|
||||
pub struct BreathingDetector {
|
||||
pub fn new(config: BreathingDetectorConfig) -> Self;
|
||||
pub fn detect(&self, amplitudes: &[f64], sample_rate: f64) -> Option<BreathingPattern>;
|
||||
}
|
||||
|
||||
// Heartbeat
|
||||
pub struct HeartbeatDetector {
|
||||
pub fn new(config: HeartbeatDetectorConfig) -> Self;
|
||||
pub fn detect(&self, phases: &[f64], sample_rate: f64, breathing_rate: Option<f64>) -> Option<HeartbeatSignature>;
|
||||
}
|
||||
|
||||
// Movement
|
||||
pub struct MovementClassifier {
|
||||
pub fn new(config: MovementClassifierConfig) -> Self;
|
||||
pub fn classify(&self, amplitudes: &[f64], sample_rate: f64) -> MovementProfile;
|
||||
}
|
||||
|
||||
// Pipeline
|
||||
pub struct DetectionPipeline {
|
||||
pub fn new(config: DetectionConfig) -> Self;
|
||||
pub async fn process_zone(&self, zone: &ScanZone) -> Result<Option<VitalSignsReading>>;
|
||||
pub fn add_data(&self, amplitudes: &[f64], phases: &[f64]);
|
||||
}
|
||||
```
|
||||
|
||||
### Localization API
|
||||
|
||||
```rust
|
||||
pub struct Triangulator {
|
||||
pub fn new(config: TriangulationConfig) -> Self;
|
||||
pub fn estimate(&self, sensors: &[SensorPosition], measurements: &[f64]) -> Result<Position2D>;
|
||||
}
|
||||
|
||||
pub struct DepthEstimator {
|
||||
pub fn new(config: DepthEstimatorConfig) -> Self;
|
||||
pub fn estimate(&self, measured: f64, expected: f64) -> Result<DepthEstimate>;
|
||||
}
|
||||
|
||||
pub struct LocalizationService {
|
||||
pub fn new() -> Self;
|
||||
pub fn estimate_position(&self, vital_signs: &VitalSignsReading, zone: &ScanZone) -> Result<Coordinates3D>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Hardware Setup
|
||||
|
||||
### Sensor Requirements
|
||||
|
||||
| Component | Minimum | Recommended |
|
||||
|-----------|---------|-------------|
|
||||
| WiFi Transceivers | 3 | 6-8 |
|
||||
| Sample Rate | 100 Hz | 1000 Hz |
|
||||
| Frequency Band | 2.4 GHz | 5 GHz |
|
||||
| Antenna Type | Omni | Directional |
|
||||
| Power | Battery | AC + Battery |
|
||||
|
||||
### Portable Sensor Array
|
||||
|
||||
```
|
||||
[Sensor 1] [Sensor 2]
|
||||
\ /
|
||||
\ SCAN ZONE /
|
||||
\ /
|
||||
\ /
|
||||
[Sensor 3]---[Sensor 4]
|
||||
|
|
||||
[Controller]
|
||||
|
|
||||
[Display]
|
||||
```
|
||||
|
||||
### Sensor Placement
|
||||
|
||||
```rust
|
||||
// Example sensor configuration for a 30x20m zone
|
||||
let sensors = vec![
|
||||
SensorPosition {
|
||||
id: "S1".into(),
|
||||
x: 0.0, y: 0.0, z: 2.0,
|
||||
sensor_type: SensorType::Transceiver,
|
||||
is_operational: true,
|
||||
},
|
||||
SensorPosition {
|
||||
id: "S2".into(),
|
||||
x: 30.0, y: 0.0, z: 2.0,
|
||||
sensor_type: SensorType::Transceiver,
|
||||
is_operational: true,
|
||||
},
|
||||
SensorPosition {
|
||||
id: "S3".into(),
|
||||
x: 0.0, y: 20.0, z: 2.0,
|
||||
sensor_type: SensorType::Transceiver,
|
||||
is_operational: true,
|
||||
},
|
||||
SensorPosition {
|
||||
id: "S4".into(),
|
||||
x: 30.0, y: 20.0, z: 2.0,
|
||||
sensor_type: SensorType::Transceiver,
|
||||
is_operational: true,
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Field Deployment Guide
|
||||
|
||||
### Pre-Deployment Checklist
|
||||
|
||||
- [ ] Verify all sensors are charged (>80%)
|
||||
- [ ] Test sensor connectivity
|
||||
- [ ] Calibrate for local conditions
|
||||
- [ ] Establish communication with command center
|
||||
- [ ] Brief rescue teams on system capabilities
|
||||
|
||||
### Deployment Steps
|
||||
|
||||
1. **Site Assessment** (5 min)
|
||||
- Identify safe sensor placement locations
|
||||
- Note structural hazards
|
||||
- Estimate debris composition
|
||||
|
||||
2. **Sensor Deployment** (10 min)
|
||||
- Place sensors around perimeter of search area
|
||||
- Ensure minimum 3 sensors with line-of-sight to each other
|
||||
- Connect to controller
|
||||
|
||||
3. **System Initialization** (2 min)
|
||||
```rust
|
||||
let mut response = DisasterResponse::new(config);
|
||||
response.initialize_event(location, description)?;
|
||||
|
||||
for zone in zones {
|
||||
response.add_zone(zone)?;
|
||||
}
|
||||
```
|
||||
|
||||
4. **Calibration** (5 min)
|
||||
- Run background noise calibration
|
||||
- Adjust sensitivity based on environment
|
||||
|
||||
5. **Begin Scanning** (continuous)
|
||||
```rust
|
||||
response.start_scanning().await?;
|
||||
```
|
||||
|
||||
### Interpreting Results
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ SCAN RESULTS │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ Zone: North Wing - Ground Floor │
|
||||
│ Status: ACTIVE | Scans: 127 | Duration: 10:34 │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ DETECTIONS: │
|
||||
│ │
|
||||
│ [IMMEDIATE] Survivor #1 │
|
||||
│ Position: (12.3, 8.7) ±0.5m │
|
||||
│ Depth: 2.1m ±0.3m │
|
||||
│ Breathing: 24 BPM (shallow) │
|
||||
│ Movement: Fine motor │
|
||||
│ Confidence: 87% │
|
||||
│ │
|
||||
│ [DELAYED] Survivor #2 │
|
||||
│ Position: (22.1, 15.2) ±0.8m │
|
||||
│ Depth: 1.5m ±0.2m │
|
||||
│ Breathing: 16 BPM (normal) │
|
||||
│ Movement: Periodic only │
|
||||
│ Confidence: 92% │
|
||||
│ │
|
||||
│ [MINOR] Survivor #3 │
|
||||
│ Position: (5.2, 3.1) ±0.3m │
|
||||
│ Depth: 0.3m ±0.1m │
|
||||
│ Breathing: 18 BPM (normal) │
|
||||
│ Movement: Gross motor (likely mobile) │
|
||||
│ Confidence: 95% │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
| Issue | Possible Cause | Solution |
|
||||
|-------|---------------|----------|
|
||||
| No detections | Sensitivity too low | Increase `sensitivity` to 0.9+ |
|
||||
| Too many false positives | Sensitivity too high | Decrease `sensitivity` to 0.6-0.7 |
|
||||
| Poor localization | Insufficient sensors | Add more sensors (minimum 3) |
|
||||
| Intermittent detections | Signal interference | Check for electromagnetic sources |
|
||||
| Depth estimation fails | Dense material | Adjust `material_model` |
|
||||
|
||||
### Diagnostic Commands
|
||||
|
||||
```rust
|
||||
// Check system health
|
||||
let health = response.hardware_health();
|
||||
println!("Sensors: {}/{} operational", health.connected, health.total);
|
||||
|
||||
// View detection statistics
|
||||
let stats = response.detection_stats();
|
||||
println!("Detection rate: {:.1}%", stats.detection_rate * 100.0);
|
||||
println!("False positive rate: {:.1}%", stats.false_positive_rate * 100.0);
|
||||
|
||||
// Export diagnostic data
|
||||
response.export_diagnostics("/path/to/diagnostics.json")?;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Detection Optimization
|
||||
|
||||
1. **Start with high sensitivity**, reduce if too many false positives
|
||||
2. **Enable heartbeat detection** only when breathing is confirmed
|
||||
3. **Use appropriate disaster type** for optimized algorithms
|
||||
4. **Increase scan duration** for weak signals (up to 30s windows)
|
||||
|
||||
### Localization Optimization
|
||||
|
||||
1. **Use 4+ sensors** for reliable 2D positioning
|
||||
2. **Spread sensors** to cover entire search area
|
||||
3. **Mount at consistent height** (1.5-2.0m recommended)
|
||||
4. **Account for sensor failures** with redundancy
|
||||
|
||||
### Operational Tips
|
||||
|
||||
1. **Scan in phases**: Quick scan first, then focused detailed scans
|
||||
2. **Mark confirmed positives**: Reduce redundant alerts
|
||||
3. **Update zones dynamically**: Remove cleared areas
|
||||
4. **Communicate confidence levels**: Not all detections are certain
|
||||
|
||||
---
|
||||
|
||||
## Safety Considerations
|
||||
|
||||
### Limitations
|
||||
|
||||
- **Not 100% reliable**: Always verify with secondary methods
|
||||
- **Environmental factors**: Metal, water, thick concrete reduce effectiveness
|
||||
- **Living movement only**: Cannot detect unconscious/deceased without breathing
|
||||
- **Depth limits**: Accuracy decreases beyond 5m depth
|
||||
|
||||
### Integration with Other Methods
|
||||
|
||||
WiFi-Mat should be used alongside:
|
||||
- Acoustic detection (listening devices)
|
||||
- Canine search teams
|
||||
- Thermal imaging
|
||||
- Physical probing
|
||||
|
||||
### False Negative Risk
|
||||
|
||||
A **negative result does not guarantee absence of survivors**. Always:
|
||||
- Re-scan after debris removal
|
||||
- Use multiple scanning methods
|
||||
- Continue manual search procedures
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
- **Documentation**: [ADR-001](/docs/adr/ADR-001-wifi-mat-disaster-detection.md)
|
||||
- **Domain Model**: [DDD Specification](/docs/ddd/wifi-mat-domain-model.md)
|
||||
- **Issues**: [GitHub Issues](https://github.com/ruvnet/wifi-densepose/issues)
|
||||
- **API Docs**: Run `cargo doc --package wifi-densepose-mat --open`
|
||||
|
||||
---
|
||||
|
||||
*WiFi-Mat is designed to assist search and rescue operations. It is a tool to augment, not replace, trained rescue personnel and established SAR protocols.*
|
||||
1071
install.sh
1071
install.sh
File diff suppressed because it is too large
Load Diff
@@ -1,51 +1,50 @@
|
||||
{
|
||||
"running": true,
|
||||
"startedAt": "2026-02-28T14:10:51.128Z",
|
||||
"startedAt": "2026-01-13T03:30:55.475Z",
|
||||
"workers": {
|
||||
"map": {
|
||||
"runCount": 5,
|
||||
"successCount": 5,
|
||||
"failureCount": 0,
|
||||
"averageDurationMs": 1.6,
|
||||
"lastRun": "2026-02-28T14:40:51.152Z",
|
||||
"nextRun": "2026-02-28T14:40:51.149Z",
|
||||
"isRunning": false
|
||||
},
|
||||
"audit": {
|
||||
"runCount": 3,
|
||||
"successCount": 0,
|
||||
"failureCount": 3,
|
||||
"averageDurationMs": 0,
|
||||
"lastRun": "2026-02-28T14:32:51.145Z",
|
||||
"nextRun": "2026-02-28T14:42:51.146Z",
|
||||
"isRunning": false
|
||||
},
|
||||
"optimize": {
|
||||
"runCount": 2,
|
||||
"successCount": 0,
|
||||
"failureCount": 2,
|
||||
"averageDurationMs": 0,
|
||||
"lastRun": "2026-02-28T14:39:51.146Z",
|
||||
"nextRun": "2026-02-28T14:54:51.146Z",
|
||||
"isRunning": false
|
||||
},
|
||||
"consolidate": {
|
||||
"runCount": 2,
|
||||
"successCount": 2,
|
||||
"runCount": 1,
|
||||
"successCount": 1,
|
||||
"failureCount": 0,
|
||||
"averageDurationMs": 1,
|
||||
"lastRun": "2026-02-28T14:17:51.145Z",
|
||||
"nextRun": "2026-02-28T14:46:51.133Z",
|
||||
"isRunning": false
|
||||
"isRunning": false,
|
||||
"nextRun": "2026-01-13T03:45:55.482Z",
|
||||
"lastRun": "2026-01-13T03:30:55.481Z"
|
||||
},
|
||||
"testgaps": {
|
||||
"audit": {
|
||||
"runCount": 1,
|
||||
"successCount": 0,
|
||||
"failureCount": 1,
|
||||
"averageDurationMs": 0,
|
||||
"lastRun": "2026-02-28T14:23:51.138Z",
|
||||
"nextRun": "2026-02-28T14:43:51.138Z",
|
||||
"isRunning": false
|
||||
"isRunning": false,
|
||||
"nextRun": "2026-01-13T03:47:55.481Z",
|
||||
"lastRun": "2026-01-13T03:37:55.480Z"
|
||||
},
|
||||
"optimize": {
|
||||
"runCount": 1,
|
||||
"successCount": 0,
|
||||
"failureCount": 1,
|
||||
"averageDurationMs": 0,
|
||||
"isRunning": false,
|
||||
"nextRun": "2026-01-13T03:34:55.475Z",
|
||||
"lastRun": "2026-01-13T03:39:55.480Z"
|
||||
},
|
||||
"consolidate": {
|
||||
"runCount": 1,
|
||||
"successCount": 1,
|
||||
"failureCount": 0,
|
||||
"averageDurationMs": 1,
|
||||
"isRunning": false,
|
||||
"nextRun": "2026-01-13T04:06:55.476Z",
|
||||
"lastRun": "2026-01-13T03:37:55.485Z"
|
||||
},
|
||||
"testgaps": {
|
||||
"runCount": 0,
|
||||
"successCount": 0,
|
||||
"failureCount": 0,
|
||||
"averageDurationMs": 0,
|
||||
"isRunning": true,
|
||||
"nextRun": "2026-01-13T03:38:55.475Z"
|
||||
},
|
||||
"predict": {
|
||||
"runCount": 0,
|
||||
@@ -131,5 +130,5 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"savedAt": "2026-02-28T14:40:51.152Z"
|
||||
"savedAt": "2026-01-13T03:39:55.480Z"
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
26601
|
||||
3707
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"timestamp": "2026-02-28T14:40:51.151Z",
|
||||
"timestamp": "2026-01-13T03:30:55.480Z",
|
||||
"projectRoot": "/home/user/wifi-densepose/rust-port/wifi-densepose-rs",
|
||||
"structure": {
|
||||
"hasPackageJson": false,
|
||||
@@ -7,5 +7,5 @@
|
||||
"hasClaudeConfig": false,
|
||||
"hasClaudeFlow": true
|
||||
},
|
||||
"scannedAt": 1772289651152
|
||||
"scannedAt": 1768275055481
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"timestamp": "2026-02-28T14:17:51.145Z",
|
||||
"timestamp": "2026-01-13T03:37:55.484Z",
|
||||
"patternsConsolidated": 0,
|
||||
"memoryCleaned": 0,
|
||||
"duplicatesRemoved": 0
|
||||
|
||||
1491
rust-port/wifi-densepose-rs/Cargo.lock
generated
1491
rust-port/wifi-densepose-rs/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,6 @@ members = [
|
||||
"crates/wifi-densepose-hardware",
|
||||
"crates/wifi-densepose-wasm",
|
||||
"crates/wifi-densepose-cli",
|
||||
"crates/wifi-densepose-mat",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
@@ -92,7 +91,6 @@ wifi-densepose-db = { path = "crates/wifi-densepose-db" }
|
||||
wifi-densepose-config = { path = "crates/wifi-densepose-config" }
|
||||
wifi-densepose-hardware = { path = "crates/wifi-densepose-hardware" }
|
||||
wifi-densepose-wasm = { path = "crates/wifi-densepose-wasm" }
|
||||
wifi-densepose-mat = { path = "crates/wifi-densepose-mat" }
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
|
||||
@@ -3,54 +3,5 @@ name = "wifi-densepose-cli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
description = "CLI for WiFi-DensePose"
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "wifi-densepose"
|
||||
path = "src/main.rs"
|
||||
|
||||
[features]
|
||||
default = ["mat"]
|
||||
mat = []
|
||||
|
||||
[dependencies]
|
||||
# Internal crates
|
||||
wifi-densepose-mat = { path = "../wifi-densepose-mat" }
|
||||
|
||||
# CLI framework
|
||||
clap = { version = "4.4", features = ["derive", "env", "cargo"] }
|
||||
|
||||
# Output formatting
|
||||
colored = "2.1"
|
||||
tabled = { version = "0.15", features = ["ansi"] }
|
||||
indicatif = "0.17"
|
||||
console = "0.15"
|
||||
|
||||
# Async runtime
|
||||
tokio = { version = "1.35", features = ["full"] }
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
csv = "1.3"
|
||||
|
||||
# Error handling
|
||||
anyhow = "1.0"
|
||||
thiserror = "1.0"
|
||||
|
||||
# Time
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
# UUID
|
||||
uuid = { version = "1.6", features = ["v4", "serde"] }
|
||||
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd = "2.0"
|
||||
predicates = "3.0"
|
||||
tempfile = "3.9"
|
||||
|
||||
@@ -1,51 +1 @@
|
||||
//! WiFi-DensePose CLI
|
||||
//!
|
||||
//! Command-line interface for WiFi-DensePose system, including the
|
||||
//! Mass Casualty Assessment Tool (MAT) for disaster response.
|
||||
//!
|
||||
//! # Features
|
||||
//!
|
||||
//! - **mat**: Disaster survivor detection and triage management
|
||||
//! - **version**: Display version information
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! ```bash
|
||||
//! # Start scanning for survivors
|
||||
//! wifi-densepose mat scan --zone "Building A"
|
||||
//!
|
||||
//! # View current scan status
|
||||
//! wifi-densepose mat status
|
||||
//!
|
||||
//! # List detected survivors
|
||||
//! wifi-densepose mat survivors --sort-by triage
|
||||
//!
|
||||
//! # View and manage alerts
|
||||
//! wifi-densepose mat alerts
|
||||
//! ```
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
pub mod mat;
|
||||
|
||||
/// WiFi-DensePose Command Line Interface
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "wifi-densepose")]
|
||||
#[command(author, version, about = "WiFi-based pose estimation and disaster response")]
|
||||
#[command(propagate_version = true)]
|
||||
pub struct Cli {
|
||||
/// Command to execute
|
||||
#[command(subcommand)]
|
||||
pub command: Commands,
|
||||
}
|
||||
|
||||
/// Top-level commands
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum Commands {
|
||||
/// Mass Casualty Assessment Tool commands
|
||||
#[command(subcommand)]
|
||||
Mat(mat::MatCommand),
|
||||
|
||||
/// Display version information
|
||||
Version,
|
||||
}
|
||||
//! WiFi-DensePose CLI (stub)
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
//! WiFi-DensePose CLI Entry Point
|
||||
//!
|
||||
//! This is the main entry point for the wifi-densepose command-line tool.
|
||||
|
||||
use clap::Parser;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
|
||||
|
||||
use wifi_densepose_cli::{Cli, Commands};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// Initialize logging
|
||||
tracing_subscriber::registry()
|
||||
.with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
|
||||
.with(tracing_subscriber::fmt::layer().with_target(false))
|
||||
.init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.command {
|
||||
Commands::Mat(mat_cmd) => {
|
||||
wifi_densepose_cli::mat::execute(mat_cmd).await?;
|
||||
}
|
||||
Commands::Version => {
|
||||
println!("wifi-densepose {}", env!("CARGO_PKG_VERSION"));
|
||||
println!("MAT module version: {}", wifi_densepose_mat::VERSION);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -172,6 +172,16 @@ impl Confidence {
|
||||
|
||||
/// Creates a confidence value without validation (for internal use).
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// The caller must ensure the value is in [0.0, 1.0].
|
||||
#[must_use]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn new_unchecked(value: f32) -> Self {
|
||||
debug_assert!((0.0..=1.0).contains(&value));
|
||||
Self(value)
|
||||
}
|
||||
|
||||
/// Returns the raw confidence value.
|
||||
#[must_use]
|
||||
pub fn value(&self) -> f32 {
|
||||
@@ -999,12 +1009,7 @@ impl PoseEstimate {
|
||||
pub fn highest_confidence_person(&self) -> Option<&PersonPose> {
|
||||
self.persons
|
||||
.iter()
|
||||
.max_by(|a, b| {
|
||||
a.confidence
|
||||
.value()
|
||||
.partial_cmp(&b.confidence.value())
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
})
|
||||
.max_by(|a, b| a.confidence.value().partial_cmp(&b.confidence.value()).unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -98,11 +98,8 @@ pub fn moving_average(data: &Array1<f64>, window_size: usize) -> Array1<f64> {
|
||||
let mut result = Array1::zeros(data.len());
|
||||
let half_window = window_size / 2;
|
||||
|
||||
// ndarray Array1 is always contiguous, but handle gracefully if not
|
||||
let slice = match data.as_slice() {
|
||||
Some(s) => s,
|
||||
None => return data.clone(),
|
||||
};
|
||||
// Safe unwrap: ndarray Array1 is always contiguous
|
||||
let slice = data.as_slice().expect("Array1 should be contiguous");
|
||||
|
||||
for i in 0..data.len() {
|
||||
let start = i.saturating_sub(half_window);
|
||||
|
||||
@@ -2,32 +2,6 @@
|
||||
name = "wifi-densepose-hardware"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
description = "Hardware interface abstractions for WiFi CSI sensors (ESP32, Intel 5300, Atheros)"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/ruvnet/wifi-densepose"
|
||||
|
||||
[features]
|
||||
default = ["std"]
|
||||
std = []
|
||||
# Enable ESP32 serial parsing (no actual ESP-IDF dependency; parses streamed bytes)
|
||||
esp32 = []
|
||||
# Enable Intel 5300 CSI Tool log parsing
|
||||
intel5300 = []
|
||||
# Enable Linux WiFi interface for commodity sensing (ADR-013)
|
||||
linux-wifi = []
|
||||
description = "Hardware interface for WiFi-DensePose"
|
||||
|
||||
[dependencies]
|
||||
# Byte parsing
|
||||
byteorder = "1.5"
|
||||
# Time
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
# Error handling
|
||||
thiserror = "1.0"
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
# Serialization
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
[dev-dependencies]
|
||||
approx = "0.5"
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
//! CSI frame types representing parsed WiFi Channel State Information.
|
||||
//!
|
||||
//! These types are hardware-agnostic representations of CSI data that
|
||||
//! can be produced by any parser (ESP32, Intel 5300, etc.) and consumed
|
||||
//! by the detection pipeline.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A parsed CSI frame containing subcarrier data and metadata.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CsiFrame {
|
||||
/// Frame metadata (RSSI, channel, timestamps, etc.)
|
||||
pub metadata: CsiMetadata,
|
||||
/// Per-subcarrier I/Q data
|
||||
pub subcarriers: Vec<SubcarrierData>,
|
||||
}
|
||||
|
||||
impl CsiFrame {
|
||||
/// Number of subcarriers in this frame.
|
||||
pub fn subcarrier_count(&self) -> usize {
|
||||
self.subcarriers.len()
|
||||
}
|
||||
|
||||
/// Convert to amplitude and phase arrays for the detection pipeline.
|
||||
///
|
||||
/// Returns (amplitudes, phases) where:
|
||||
/// - amplitude = sqrt(I^2 + Q^2)
|
||||
/// - phase = atan2(Q, I)
|
||||
pub fn to_amplitude_phase(&self) -> (Vec<f64>, Vec<f64>) {
|
||||
let amplitudes: Vec<f64> = self.subcarriers.iter()
|
||||
.map(|sc| (sc.i as f64 * sc.i as f64 + sc.q as f64 * sc.q as f64).sqrt())
|
||||
.collect();
|
||||
|
||||
let phases: Vec<f64> = self.subcarriers.iter()
|
||||
.map(|sc| (sc.q as f64).atan2(sc.i as f64))
|
||||
.collect();
|
||||
|
||||
(amplitudes, phases)
|
||||
}
|
||||
|
||||
/// Get the average amplitude across all subcarriers.
|
||||
pub fn mean_amplitude(&self) -> f64 {
|
||||
if self.subcarriers.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let sum: f64 = self.subcarriers.iter()
|
||||
.map(|sc| (sc.i as f64 * sc.i as f64 + sc.q as f64 * sc.q as f64).sqrt())
|
||||
.sum();
|
||||
sum / self.subcarriers.len() as f64
|
||||
}
|
||||
|
||||
/// Check if this frame has valid data (non-zero subcarriers with non-zero I/Q).
|
||||
pub fn is_valid(&self) -> bool {
|
||||
!self.subcarriers.is_empty()
|
||||
&& self.subcarriers.iter().any(|sc| sc.i != 0 || sc.q != 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Metadata associated with a CSI frame.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CsiMetadata {
|
||||
/// Timestamp when frame was received
|
||||
pub timestamp: DateTime<Utc>,
|
||||
/// RSSI in dBm (typically -100 to 0)
|
||||
pub rssi: i32,
|
||||
/// Noise floor in dBm
|
||||
pub noise_floor: i32,
|
||||
/// WiFi channel number
|
||||
pub channel: u8,
|
||||
/// Secondary channel offset (0, 1, or 2)
|
||||
pub secondary_channel: u8,
|
||||
/// Channel bandwidth
|
||||
pub bandwidth: Bandwidth,
|
||||
/// Antenna configuration
|
||||
pub antenna_config: AntennaConfig,
|
||||
/// Source MAC address (if available)
|
||||
pub source_mac: Option<[u8; 6]>,
|
||||
/// Sequence number for ordering
|
||||
pub sequence: u32,
|
||||
}
|
||||
|
||||
/// WiFi channel bandwidth.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Bandwidth {
|
||||
/// 20 MHz (standard)
|
||||
Bw20,
|
||||
/// 40 MHz (HT)
|
||||
Bw40,
|
||||
/// 80 MHz (VHT)
|
||||
Bw80,
|
||||
/// 160 MHz (VHT)
|
||||
Bw160,
|
||||
}
|
||||
|
||||
impl Bandwidth {
|
||||
/// Expected number of subcarriers for this bandwidth.
|
||||
pub fn expected_subcarriers(&self) -> usize {
|
||||
match self {
|
||||
Bandwidth::Bw20 => 56,
|
||||
Bandwidth::Bw40 => 114,
|
||||
Bandwidth::Bw80 => 242,
|
||||
Bandwidth::Bw160 => 484,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Antenna configuration for MIMO.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub struct AntennaConfig {
|
||||
/// Number of transmit antennas
|
||||
pub tx_antennas: u8,
|
||||
/// Number of receive antennas
|
||||
pub rx_antennas: u8,
|
||||
}
|
||||
|
||||
impl Default for AntennaConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
tx_antennas: 1,
|
||||
rx_antennas: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single subcarrier's I/Q data.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub struct SubcarrierData {
|
||||
/// In-phase component
|
||||
pub i: i16,
|
||||
/// Quadrature component
|
||||
pub q: i16,
|
||||
/// Subcarrier index (-28..28 for 20MHz, etc.)
|
||||
pub index: i16,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn make_test_frame() -> CsiFrame {
|
||||
CsiFrame {
|
||||
metadata: CsiMetadata {
|
||||
timestamp: Utc::now(),
|
||||
rssi: -50,
|
||||
noise_floor: -95,
|
||||
channel: 6,
|
||||
secondary_channel: 0,
|
||||
bandwidth: Bandwidth::Bw20,
|
||||
antenna_config: AntennaConfig::default(),
|
||||
source_mac: None,
|
||||
sequence: 1,
|
||||
},
|
||||
subcarriers: vec![
|
||||
SubcarrierData { i: 100, q: 0, index: -28 },
|
||||
SubcarrierData { i: 0, q: 50, index: -27 },
|
||||
SubcarrierData { i: 30, q: 40, index: -26 },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_amplitude_phase_conversion() {
|
||||
let frame = make_test_frame();
|
||||
let (amps, phases) = frame.to_amplitude_phase();
|
||||
|
||||
assert_eq!(amps.len(), 3);
|
||||
assert_eq!(phases.len(), 3);
|
||||
|
||||
// First subcarrier: I=100, Q=0 -> amplitude=100, phase=0
|
||||
assert_relative_eq!(amps[0], 100.0, epsilon = 0.01);
|
||||
assert_relative_eq!(phases[0], 0.0, epsilon = 0.01);
|
||||
|
||||
// Second: I=0, Q=50 -> amplitude=50, phase=pi/2
|
||||
assert_relative_eq!(amps[1], 50.0, epsilon = 0.01);
|
||||
assert_relative_eq!(phases[1], std::f64::consts::FRAC_PI_2, epsilon = 0.01);
|
||||
|
||||
// Third: I=30, Q=40 -> amplitude=50, phase=atan2(40,30)
|
||||
assert_relative_eq!(amps[2], 50.0, epsilon = 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mean_amplitude() {
|
||||
let frame = make_test_frame();
|
||||
let mean = frame.mean_amplitude();
|
||||
// (100 + 50 + 50) / 3 = 66.67
|
||||
assert_relative_eq!(mean, 200.0 / 3.0, epsilon = 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_valid() {
|
||||
let frame = make_test_frame();
|
||||
assert!(frame.is_valid());
|
||||
|
||||
let empty = CsiFrame {
|
||||
metadata: frame.metadata.clone(),
|
||||
subcarriers: vec![],
|
||||
};
|
||||
assert!(!empty.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bandwidth_subcarriers() {
|
||||
assert_eq!(Bandwidth::Bw20.expected_subcarriers(), 56);
|
||||
assert_eq!(Bandwidth::Bw40.expected_subcarriers(), 114);
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
//! Error types for hardware parsing.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors that can occur when parsing CSI data from hardware.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ParseError {
|
||||
/// Not enough bytes in the buffer to parse a complete frame.
|
||||
#[error("Insufficient data: need {needed} bytes, got {got}")]
|
||||
InsufficientData {
|
||||
needed: usize,
|
||||
got: usize,
|
||||
},
|
||||
|
||||
/// The frame header magic bytes don't match expected values.
|
||||
#[error("Invalid magic: expected {expected:#06x}, got {got:#06x}")]
|
||||
InvalidMagic {
|
||||
expected: u32,
|
||||
got: u32,
|
||||
},
|
||||
|
||||
/// The frame indicates more subcarriers than physically possible.
|
||||
#[error("Invalid subcarrier count: {count} (max {max})")]
|
||||
InvalidSubcarrierCount {
|
||||
count: usize,
|
||||
max: usize,
|
||||
},
|
||||
|
||||
/// The I/Q data buffer length doesn't match expected size.
|
||||
#[error("I/Q data length mismatch: expected {expected}, got {got}")]
|
||||
IqLengthMismatch {
|
||||
expected: usize,
|
||||
got: usize,
|
||||
},
|
||||
|
||||
/// RSSI value is outside the valid range.
|
||||
#[error("Invalid RSSI value: {value} dBm (expected -100..0)")]
|
||||
InvalidRssi {
|
||||
value: i32,
|
||||
},
|
||||
|
||||
/// Generic byte-level parse error.
|
||||
#[error("Parse error at offset {offset}: {message}")]
|
||||
ByteError {
|
||||
offset: usize,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
@@ -1,363 +0,0 @@
|
||||
//! ESP32 CSI frame parser.
|
||||
//!
|
||||
//! Parses binary CSI data as produced by ESP-IDF's `wifi_csi_info_t` structure,
|
||||
//! typically streamed over serial (UART at 921600 baud) or UDP.
|
||||
//!
|
||||
//! # ESP32 CSI Binary Format
|
||||
//!
|
||||
//! The ESP32 CSI callback produces a buffer with the following layout:
|
||||
//!
|
||||
//! ```text
|
||||
//! Offset Size Field
|
||||
//! ------ ---- -----
|
||||
//! 0 4 Magic (0xCSI10001 or as configured in firmware)
|
||||
//! 4 4 Sequence number
|
||||
//! 8 1 Channel
|
||||
//! 9 1 Secondary channel
|
||||
//! 10 1 RSSI (signed)
|
||||
//! 11 1 Noise floor (signed)
|
||||
//! 12 2 CSI data length (number of I/Q bytes)
|
||||
//! 14 6 Source MAC address
|
||||
//! 20 N I/Q data (pairs of i8 values, 2 bytes per subcarrier)
|
||||
//! ```
|
||||
//!
|
||||
//! Each subcarrier contributes 2 bytes: one signed byte for I, one for Q.
|
||||
//! For 20 MHz bandwidth with 56 subcarriers: N = 112 bytes.
|
||||
//!
|
||||
//! # No-Mock Guarantee
|
||||
//!
|
||||
//! This parser either successfully parses real bytes or returns a specific
|
||||
//! `ParseError`. It never generates synthetic data.
|
||||
|
||||
use byteorder::{LittleEndian, ReadBytesExt};
|
||||
use chrono::Utc;
|
||||
use std::io::Cursor;
|
||||
|
||||
use crate::csi_frame::{AntennaConfig, Bandwidth, CsiFrame, CsiMetadata, SubcarrierData};
|
||||
use crate::error::ParseError;
|
||||
|
||||
/// ESP32 CSI binary frame magic number.
|
||||
///
|
||||
/// This is a convention for the firmware framing protocol.
|
||||
/// The actual ESP-IDF callback doesn't include a magic number;
|
||||
/// our recommended firmware adds this for reliable frame sync.
|
||||
const ESP32_CSI_MAGIC: u32 = 0xC5110001;
|
||||
|
||||
/// Maximum valid subcarrier count for ESP32 (80MHz bandwidth).
|
||||
const MAX_SUBCARRIERS: usize = 256;
|
||||
|
||||
/// Parser for ESP32 CSI binary frames.
|
||||
pub struct Esp32CsiParser;
|
||||
|
||||
impl Esp32CsiParser {
|
||||
/// Parse a single CSI frame from a byte buffer.
|
||||
///
|
||||
/// The buffer must contain at least the header (20 bytes) plus the I/Q data.
|
||||
/// Returns the parsed frame and the number of bytes consumed.
|
||||
pub fn parse_frame(data: &[u8]) -> Result<(CsiFrame, usize), ParseError> {
|
||||
if data.len() < 20 {
|
||||
return Err(ParseError::InsufficientData {
|
||||
needed: 20,
|
||||
got: data.len(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut cursor = Cursor::new(data);
|
||||
|
||||
// Read magic
|
||||
let magic = cursor.read_u32::<LittleEndian>().map_err(|_| ParseError::InsufficientData {
|
||||
needed: 4,
|
||||
got: 0,
|
||||
})?;
|
||||
|
||||
if magic != ESP32_CSI_MAGIC {
|
||||
return Err(ParseError::InvalidMagic {
|
||||
expected: ESP32_CSI_MAGIC,
|
||||
got: magic,
|
||||
});
|
||||
}
|
||||
|
||||
// Sequence number
|
||||
let sequence = cursor.read_u32::<LittleEndian>().map_err(|_| ParseError::InsufficientData {
|
||||
needed: 8,
|
||||
got: 4,
|
||||
})?;
|
||||
|
||||
// Channel info
|
||||
let channel = cursor.read_u8().map_err(|_| ParseError::ByteError {
|
||||
offset: 8,
|
||||
message: "Failed to read channel".into(),
|
||||
})?;
|
||||
|
||||
let secondary_channel = cursor.read_u8().map_err(|_| ParseError::ByteError {
|
||||
offset: 9,
|
||||
message: "Failed to read secondary channel".into(),
|
||||
})?;
|
||||
|
||||
// RSSI (signed)
|
||||
let rssi = cursor.read_i8().map_err(|_| ParseError::ByteError {
|
||||
offset: 10,
|
||||
message: "Failed to read RSSI".into(),
|
||||
})? as i32;
|
||||
|
||||
if rssi > 0 || rssi < -100 {
|
||||
return Err(ParseError::InvalidRssi { value: rssi });
|
||||
}
|
||||
|
||||
// Noise floor (signed)
|
||||
let noise_floor = cursor.read_i8().map_err(|_| ParseError::ByteError {
|
||||
offset: 11,
|
||||
message: "Failed to read noise floor".into(),
|
||||
})? as i32;
|
||||
|
||||
// CSI data length
|
||||
let iq_length = cursor.read_u16::<LittleEndian>().map_err(|_| ParseError::ByteError {
|
||||
offset: 12,
|
||||
message: "Failed to read I/Q length".into(),
|
||||
})? as usize;
|
||||
|
||||
// Source MAC
|
||||
let mut mac = [0u8; 6];
|
||||
for (i, byte) in mac.iter_mut().enumerate() {
|
||||
*byte = cursor.read_u8().map_err(|_| ParseError::ByteError {
|
||||
offset: 14 + i,
|
||||
message: "Failed to read MAC address".into(),
|
||||
})?;
|
||||
}
|
||||
|
||||
// Validate I/Q length
|
||||
let subcarrier_count = iq_length / 2;
|
||||
if subcarrier_count > MAX_SUBCARRIERS {
|
||||
return Err(ParseError::InvalidSubcarrierCount {
|
||||
count: subcarrier_count,
|
||||
max: MAX_SUBCARRIERS,
|
||||
});
|
||||
}
|
||||
|
||||
if iq_length % 2 != 0 {
|
||||
return Err(ParseError::IqLengthMismatch {
|
||||
expected: subcarrier_count * 2,
|
||||
got: iq_length,
|
||||
});
|
||||
}
|
||||
|
||||
// Check we have enough bytes for the I/Q data
|
||||
let total_frame_size = 20 + iq_length;
|
||||
if data.len() < total_frame_size {
|
||||
return Err(ParseError::InsufficientData {
|
||||
needed: total_frame_size,
|
||||
got: data.len(),
|
||||
});
|
||||
}
|
||||
|
||||
// Parse I/Q pairs
|
||||
let iq_start = 20;
|
||||
let mut subcarriers = Vec::with_capacity(subcarrier_count);
|
||||
|
||||
// Subcarrier index mapping for 20 MHz: -28 to +28 (skipping 0)
|
||||
let half = subcarrier_count as i16 / 2;
|
||||
|
||||
for sc_idx in 0..subcarrier_count {
|
||||
let byte_offset = iq_start + sc_idx * 2;
|
||||
let i_val = data[byte_offset] as i8 as i16;
|
||||
let q_val = data[byte_offset + 1] as i8 as i16;
|
||||
|
||||
let index = if (sc_idx as i16) < half {
|
||||
-(half - sc_idx as i16)
|
||||
} else {
|
||||
sc_idx as i16 - half + 1
|
||||
};
|
||||
|
||||
subcarriers.push(SubcarrierData {
|
||||
i: i_val,
|
||||
q: q_val,
|
||||
index,
|
||||
});
|
||||
}
|
||||
|
||||
// Determine bandwidth from subcarrier count
|
||||
let bandwidth = match subcarrier_count {
|
||||
0..=56 => Bandwidth::Bw20,
|
||||
57..=114 => Bandwidth::Bw40,
|
||||
115..=242 => Bandwidth::Bw80,
|
||||
_ => Bandwidth::Bw160,
|
||||
};
|
||||
|
||||
let frame = CsiFrame {
|
||||
metadata: CsiMetadata {
|
||||
timestamp: Utc::now(),
|
||||
rssi,
|
||||
noise_floor,
|
||||
channel,
|
||||
secondary_channel,
|
||||
bandwidth,
|
||||
antenna_config: AntennaConfig {
|
||||
tx_antennas: 1,
|
||||
rx_antennas: 1,
|
||||
},
|
||||
source_mac: Some(mac),
|
||||
sequence,
|
||||
},
|
||||
subcarriers,
|
||||
};
|
||||
|
||||
Ok((frame, total_frame_size))
|
||||
}
|
||||
|
||||
/// Parse multiple frames from a byte buffer (e.g., from a serial read).
|
||||
///
|
||||
/// Returns all successfully parsed frames and the total bytes consumed.
|
||||
pub fn parse_stream(data: &[u8]) -> (Vec<CsiFrame>, usize) {
|
||||
let mut frames = Vec::new();
|
||||
let mut offset = 0;
|
||||
|
||||
while offset < data.len() {
|
||||
match Self::parse_frame(&data[offset..]) {
|
||||
Ok((frame, consumed)) => {
|
||||
frames.push(frame);
|
||||
offset += consumed;
|
||||
}
|
||||
Err(_) => {
|
||||
// Try to find next magic number for resync
|
||||
offset += 1;
|
||||
while offset + 4 <= data.len() {
|
||||
let candidate = u32::from_le_bytes([
|
||||
data[offset],
|
||||
data[offset + 1],
|
||||
data[offset + 2],
|
||||
data[offset + 3],
|
||||
]);
|
||||
if candidate == ESP32_CSI_MAGIC {
|
||||
break;
|
||||
}
|
||||
offset += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(frames, offset)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build a valid ESP32 CSI frame with known I/Q values.
|
||||
fn build_test_frame(subcarrier_pairs: &[(i8, i8)]) -> Vec<u8> {
|
||||
let mut buf = Vec::new();
|
||||
|
||||
// Magic
|
||||
buf.extend_from_slice(&ESP32_CSI_MAGIC.to_le_bytes());
|
||||
// Sequence
|
||||
buf.extend_from_slice(&1u32.to_le_bytes());
|
||||
// Channel
|
||||
buf.push(6);
|
||||
// Secondary channel
|
||||
buf.push(0);
|
||||
// RSSI
|
||||
buf.push((-50i8) as u8);
|
||||
// Noise floor
|
||||
buf.push((-95i8) as u8);
|
||||
// I/Q length
|
||||
let iq_len = (subcarrier_pairs.len() * 2) as u16;
|
||||
buf.extend_from_slice(&iq_len.to_le_bytes());
|
||||
// MAC
|
||||
buf.extend_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]);
|
||||
// I/Q data
|
||||
for (i, q) in subcarrier_pairs {
|
||||
buf.push(*i as u8);
|
||||
buf.push(*q as u8);
|
||||
}
|
||||
|
||||
buf
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_valid_frame() {
|
||||
let pairs: Vec<(i8, i8)> = (0..56).map(|i| (i as i8, (i * 2 % 127) as i8)).collect();
|
||||
let data = build_test_frame(&pairs);
|
||||
|
||||
let (frame, consumed) = Esp32CsiParser::parse_frame(&data).unwrap();
|
||||
|
||||
assert_eq!(consumed, 20 + 112);
|
||||
assert_eq!(frame.subcarrier_count(), 56);
|
||||
assert_eq!(frame.metadata.rssi, -50);
|
||||
assert_eq!(frame.metadata.channel, 6);
|
||||
assert_eq!(frame.metadata.bandwidth, Bandwidth::Bw20);
|
||||
assert!(frame.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_insufficient_data() {
|
||||
let data = &[0u8; 10];
|
||||
let result = Esp32CsiParser::parse_frame(data);
|
||||
assert!(matches!(result, Err(ParseError::InsufficientData { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_invalid_magic() {
|
||||
let mut data = build_test_frame(&[(10, 20)]);
|
||||
// Corrupt magic
|
||||
data[0] = 0xFF;
|
||||
let result = Esp32CsiParser::parse_frame(&data);
|
||||
assert!(matches!(result, Err(ParseError::InvalidMagic { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_amplitude_phase_from_known_iq() {
|
||||
let pairs = vec![(100i8, 0i8), (0, 50), (30, 40)];
|
||||
let data = build_test_frame(&pairs);
|
||||
let (frame, _) = Esp32CsiParser::parse_frame(&data).unwrap();
|
||||
|
||||
let (amps, phases) = frame.to_amplitude_phase();
|
||||
assert_eq!(amps.len(), 3);
|
||||
|
||||
// I=100, Q=0 -> amplitude=100
|
||||
assert!((amps[0] - 100.0).abs() < 0.01);
|
||||
// I=0, Q=50 -> amplitude=50
|
||||
assert!((amps[1] - 50.0).abs() < 0.01);
|
||||
// I=30, Q=40 -> amplitude=50
|
||||
assert!((amps[2] - 50.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_stream_with_multiple_frames() {
|
||||
let pairs: Vec<(i8, i8)> = (0..4).map(|i| (10 + i, 20 + i)).collect();
|
||||
let frame1 = build_test_frame(&pairs);
|
||||
let frame2 = build_test_frame(&pairs);
|
||||
|
||||
let mut combined = Vec::new();
|
||||
combined.extend_from_slice(&frame1);
|
||||
combined.extend_from_slice(&frame2);
|
||||
|
||||
let (frames, _consumed) = Esp32CsiParser::parse_stream(&combined);
|
||||
assert_eq!(frames.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_stream_with_garbage() {
|
||||
let pairs: Vec<(i8, i8)> = (0..4).map(|i| (10 + i, 20 + i)).collect();
|
||||
let frame = build_test_frame(&pairs);
|
||||
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&[0xFF, 0xFF, 0xFF]); // garbage
|
||||
data.extend_from_slice(&frame);
|
||||
|
||||
let (frames, _) = Esp32CsiParser::parse_stream(&data);
|
||||
assert_eq!(frames.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mac_address_parsed() {
|
||||
let pairs = vec![(10i8, 20i8)];
|
||||
let data = build_test_frame(&pairs);
|
||||
let (frame, _) = Esp32CsiParser::parse_frame(&data).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
frame.metadata.source_mac,
|
||||
Some([0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,45 +1 @@
|
||||
//! WiFi-DensePose hardware interface abstractions.
|
||||
//!
|
||||
//! This crate provides platform-agnostic types and parsers for WiFi CSI data
|
||||
//! from various hardware sources:
|
||||
//!
|
||||
//! - **ESP32/ESP32-S3**: Parses binary CSI frames from ESP-IDF `wifi_csi_info_t`
|
||||
//! streamed over serial (UART) or UDP
|
||||
//! - **Intel 5300**: Parses CSI log files from the Linux CSI Tool
|
||||
//! - **Linux WiFi**: Reads RSSI/signal info from standard Linux wireless interfaces
|
||||
//! for commodity sensing (ADR-013)
|
||||
//!
|
||||
//! # Design Principles
|
||||
//!
|
||||
//! 1. **No mock data**: All parsers either parse real bytes or return explicit errors
|
||||
//! 2. **No hardware dependency at compile time**: Parsing is done on byte buffers,
|
||||
//! not through FFI to ESP-IDF or kernel modules
|
||||
//! 3. **Deterministic**: Same bytes in → same parsed output, always
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```rust
|
||||
//! use wifi_densepose_hardware::{CsiFrame, Esp32CsiParser, ParseError};
|
||||
//!
|
||||
//! // Parse ESP32 CSI data from serial bytes
|
||||
//! let raw_bytes: &[u8] = &[/* ESP32 CSI binary frame */];
|
||||
//! match Esp32CsiParser::parse_frame(raw_bytes) {
|
||||
//! Ok((frame, consumed)) => {
|
||||
//! println!("Parsed {} subcarriers ({} bytes)", frame.subcarrier_count(), consumed);
|
||||
//! let (amplitudes, phases) = frame.to_amplitude_phase();
|
||||
//! // Feed into detection pipeline...
|
||||
//! }
|
||||
//! Err(ParseError::InsufficientData { needed, got }) => {
|
||||
//! eprintln!("Need {} bytes, got {}", needed, got);
|
||||
//! }
|
||||
//! Err(e) => eprintln!("Parse error: {}", e),
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
mod csi_frame;
|
||||
mod error;
|
||||
mod esp32_parser;
|
||||
|
||||
pub use csi_frame::{CsiFrame, CsiMetadata, SubcarrierData, Bandwidth, AntennaConfig};
|
||||
pub use error::ParseError;
|
||||
pub use esp32_parser::Esp32CsiParser;
|
||||
//! WiFi-DensePose hardware interface (stub)
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
[package]
|
||||
name = "wifi-densepose-mat"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = ["WiFi-DensePose Team"]
|
||||
description = "Mass Casualty Assessment Tool - WiFi-based disaster survivor detection"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/ruvnet/wifi-densepose"
|
||||
keywords = ["wifi", "disaster", "rescue", "detection", "vital-signs"]
|
||||
categories = ["science", "algorithms"]
|
||||
|
||||
[features]
|
||||
default = ["std", "api"]
|
||||
std = []
|
||||
api = ["dep:serde", "chrono/serde", "geo/use-serde"]
|
||||
portable = ["low-power"]
|
||||
low-power = []
|
||||
distributed = ["tokio/sync"]
|
||||
drone = ["distributed"]
|
||||
serde = ["dep:serde", "chrono/serde", "geo/use-serde"]
|
||||
|
||||
[dependencies]
|
||||
# Workspace dependencies
|
||||
wifi-densepose-core = { path = "../wifi-densepose-core" }
|
||||
wifi-densepose-signal = { path = "../wifi-densepose-signal" }
|
||||
wifi-densepose-nn = { path = "../wifi-densepose-nn" }
|
||||
|
||||
# Async runtime
|
||||
tokio = { version = "1.35", features = ["rt", "sync", "time"] }
|
||||
async-trait = "0.1"
|
||||
|
||||
# Web framework (REST API)
|
||||
axum = { version = "0.7", features = ["ws"] }
|
||||
futures-util = "0.3"
|
||||
|
||||
# Error handling
|
||||
thiserror = "1.0"
|
||||
anyhow = "1.0"
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1.0", features = ["derive"], optional = true }
|
||||
serde_json = "1.0"
|
||||
|
||||
# Time handling
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
# Math and signal processing
|
||||
num-complex = "0.4"
|
||||
ndarray = "0.15"
|
||||
rustfft = "6.1"
|
||||
|
||||
# Utilities
|
||||
uuid = { version = "1.6", features = ["v4", "serde"] }
|
||||
tracing = "0.1"
|
||||
parking_lot = "0.12"
|
||||
|
||||
# Geo calculations
|
||||
geo = "0.27"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4"
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
proptest = "1.4"
|
||||
approx = "0.5"
|
||||
|
||||
[[bench]]
|
||||
name = "detection_bench"
|
||||
harness = false
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
all-features = true
|
||||
rustdoc-args = ["--cfg", "docsrs"]
|
||||
@@ -1,906 +0,0 @@
|
||||
//! Performance benchmarks for wifi-densepose-mat detection algorithms.
|
||||
//!
|
||||
//! Run with: cargo bench --package wifi-densepose-mat
|
||||
//!
|
||||
//! Benchmarks cover:
|
||||
//! - Breathing detection at various signal lengths
|
||||
//! - Heartbeat detection performance
|
||||
//! - Movement classification
|
||||
//! - Full detection pipeline
|
||||
//! - Localization algorithms (triangulation, depth estimation)
|
||||
//! - Alert generation
|
||||
|
||||
use criterion::{
|
||||
black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput,
|
||||
};
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use wifi_densepose_mat::{
|
||||
// Detection types
|
||||
BreathingDetector, BreathingDetectorConfig,
|
||||
HeartbeatDetector, HeartbeatDetectorConfig,
|
||||
MovementClassifier, MovementClassifierConfig,
|
||||
DetectionConfig, DetectionPipeline, VitalSignsDetector,
|
||||
// Localization types
|
||||
Triangulator, DepthEstimator,
|
||||
// Alerting types
|
||||
AlertGenerator,
|
||||
// Domain types exported at crate root
|
||||
BreathingPattern, BreathingType, VitalSignsReading,
|
||||
MovementProfile, ScanZoneId, Survivor,
|
||||
};
|
||||
|
||||
// Types that need to be accessed from submodules
|
||||
use wifi_densepose_mat::detection::CsiDataBuffer;
|
||||
use wifi_densepose_mat::domain::{
|
||||
ConfidenceScore, SensorPosition, SensorType,
|
||||
DebrisProfile, DebrisMaterial, MoistureLevel, MetalContent,
|
||||
};
|
||||
|
||||
use chrono::Utc;
|
||||
|
||||
// =============================================================================
|
||||
// Test Data Generators
|
||||
// =============================================================================
|
||||
|
||||
/// Generate a clean breathing signal at specified rate
|
||||
fn generate_breathing_signal(rate_bpm: f64, sample_rate: f64, duration_secs: f64) -> Vec<f64> {
|
||||
let num_samples = (sample_rate * duration_secs) as usize;
|
||||
let freq = rate_bpm / 60.0;
|
||||
|
||||
(0..num_samples)
|
||||
.map(|i| {
|
||||
let t = i as f64 / sample_rate;
|
||||
(2.0 * PI * freq * t).sin()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Generate a breathing signal with noise
|
||||
fn generate_noisy_breathing_signal(
|
||||
rate_bpm: f64,
|
||||
sample_rate: f64,
|
||||
duration_secs: f64,
|
||||
noise_level: f64,
|
||||
) -> Vec<f64> {
|
||||
let num_samples = (sample_rate * duration_secs) as usize;
|
||||
let freq = rate_bpm / 60.0;
|
||||
|
||||
(0..num_samples)
|
||||
.map(|i| {
|
||||
let t = i as f64 / sample_rate;
|
||||
let signal = (2.0 * PI * freq * t).sin();
|
||||
// Simple pseudo-random noise based on sample index
|
||||
let noise = ((i as f64 * 12345.6789).sin() * 2.0 - 1.0) * noise_level;
|
||||
signal + noise
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Generate heartbeat signal with micro-Doppler characteristics
|
||||
fn generate_heartbeat_signal(rate_bpm: f64, sample_rate: f64, duration_secs: f64) -> Vec<f64> {
|
||||
let num_samples = (sample_rate * duration_secs) as usize;
|
||||
let freq = rate_bpm / 60.0;
|
||||
|
||||
(0..num_samples)
|
||||
.map(|i| {
|
||||
let t = i as f64 / sample_rate;
|
||||
let phase = 2.0 * PI * freq * t;
|
||||
// Heartbeat is more pulse-like than sinusoidal
|
||||
0.3 * phase.sin() + 0.1 * (2.0 * phase).sin() + 0.05 * (3.0 * phase).sin()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Generate combined breathing + heartbeat signal
|
||||
fn generate_combined_vital_signal(
|
||||
breathing_rate: f64,
|
||||
heart_rate: f64,
|
||||
sample_rate: f64,
|
||||
duration_secs: f64,
|
||||
) -> (Vec<f64>, Vec<f64>) {
|
||||
let num_samples = (sample_rate * duration_secs) as usize;
|
||||
let br_freq = breathing_rate / 60.0;
|
||||
let hr_freq = heart_rate / 60.0;
|
||||
|
||||
let amplitudes: Vec<f64> = (0..num_samples)
|
||||
.map(|i| {
|
||||
let t = i as f64 / sample_rate;
|
||||
// Breathing dominates amplitude
|
||||
(2.0 * PI * br_freq * t).sin()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let phases: Vec<f64> = (0..num_samples)
|
||||
.map(|i| {
|
||||
let t = i as f64 / sample_rate;
|
||||
// Phase captures both but heartbeat is more prominent
|
||||
let breathing = 0.3 * (2.0 * PI * br_freq * t).sin();
|
||||
let heartbeat = 0.5 * (2.0 * PI * hr_freq * t).sin();
|
||||
breathing + heartbeat
|
||||
})
|
||||
.collect();
|
||||
|
||||
(amplitudes, phases)
|
||||
}
|
||||
|
||||
/// Generate multi-person scenario with overlapping signals
|
||||
fn generate_multi_person_signal(
|
||||
person_count: usize,
|
||||
sample_rate: f64,
|
||||
duration_secs: f64,
|
||||
) -> Vec<f64> {
|
||||
let num_samples = (sample_rate * duration_secs) as usize;
|
||||
|
||||
// Different breathing rates for each person
|
||||
let base_rates: Vec<f64> = (0..person_count)
|
||||
.map(|i| 12.0 + (i as f64 * 3.5)) // 12, 15.5, 19, 22.5... BPM
|
||||
.collect();
|
||||
|
||||
(0..num_samples)
|
||||
.map(|i| {
|
||||
let t = i as f64 / sample_rate;
|
||||
base_rates.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, &rate)| {
|
||||
let freq = rate / 60.0;
|
||||
let amplitude = 1.0 / (idx + 1) as f64; // Distance-based attenuation
|
||||
let phase_offset = idx as f64 * PI / 4.0; // Different phases
|
||||
amplitude * (2.0 * PI * freq * t + phase_offset).sin()
|
||||
})
|
||||
.sum::<f64>()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Generate movement signal with specified characteristics
|
||||
fn generate_movement_signal(
|
||||
movement_type: &str,
|
||||
sample_rate: f64,
|
||||
duration_secs: f64,
|
||||
) -> Vec<f64> {
|
||||
let num_samples = (sample_rate * duration_secs) as usize;
|
||||
|
||||
match movement_type {
|
||||
"gross" => {
|
||||
// Large, irregular movements
|
||||
let mut signal = vec![0.0; num_samples];
|
||||
for i in (num_samples / 4)..(num_samples / 2) {
|
||||
signal[i] = 2.0;
|
||||
}
|
||||
for i in (3 * num_samples / 4)..(4 * num_samples / 5) {
|
||||
signal[i] = -1.5;
|
||||
}
|
||||
signal
|
||||
}
|
||||
"tremor" => {
|
||||
// High-frequency tremor (8-12 Hz)
|
||||
(0..num_samples)
|
||||
.map(|i| {
|
||||
let t = i as f64 / sample_rate;
|
||||
0.3 * (2.0 * PI * 10.0 * t).sin()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
"periodic" => {
|
||||
// Low-frequency periodic (breathing-like)
|
||||
(0..num_samples)
|
||||
.map(|i| {
|
||||
let t = i as f64 / sample_rate;
|
||||
0.5 * (2.0 * PI * 0.25 * t).sin()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
_ => vec![0.0; num_samples], // No movement
|
||||
}
|
||||
}
|
||||
|
||||
/// Create test sensor positions in a triangular configuration
|
||||
fn create_test_sensors(count: usize) -> Vec<SensorPosition> {
|
||||
(0..count)
|
||||
.map(|i| {
|
||||
let angle = 2.0 * PI * i as f64 / count as f64;
|
||||
SensorPosition {
|
||||
id: format!("sensor_{}", i),
|
||||
x: 10.0 * angle.cos(),
|
||||
y: 10.0 * angle.sin(),
|
||||
z: 1.5,
|
||||
sensor_type: SensorType::Transceiver,
|
||||
is_operational: true,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Create test debris profile
|
||||
fn create_test_debris() -> DebrisProfile {
|
||||
DebrisProfile {
|
||||
primary_material: DebrisMaterial::Mixed,
|
||||
void_fraction: 0.25,
|
||||
moisture_content: MoistureLevel::Dry,
|
||||
metal_content: MetalContent::Low,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create test survivor for alert generation
|
||||
fn create_test_survivor() -> Survivor {
|
||||
let vitals = VitalSignsReading {
|
||||
breathing: Some(BreathingPattern {
|
||||
rate_bpm: 18.0,
|
||||
amplitude: 0.8,
|
||||
regularity: 0.9,
|
||||
pattern_type: BreathingType::Normal,
|
||||
}),
|
||||
heartbeat: None,
|
||||
movement: MovementProfile::default(),
|
||||
timestamp: Utc::now(),
|
||||
confidence: ConfidenceScore::new(0.85),
|
||||
};
|
||||
|
||||
Survivor::new(ScanZoneId::new(), vitals, None)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Breathing Detection Benchmarks
|
||||
// =============================================================================
|
||||
|
||||
fn bench_breathing_detection(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("breathing_detection");
|
||||
|
||||
let detector = BreathingDetector::with_defaults();
|
||||
let sample_rate = 100.0; // 100 Hz
|
||||
|
||||
// Benchmark different signal lengths
|
||||
for duration in [5.0, 10.0, 30.0, 60.0] {
|
||||
let signal = generate_breathing_signal(16.0, sample_rate, duration);
|
||||
let num_samples = signal.len();
|
||||
|
||||
group.throughput(Throughput::Elements(num_samples as u64));
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("clean_signal", format!("{}s", duration as u32)),
|
||||
&signal,
|
||||
|b, signal| {
|
||||
b.iter(|| detector.detect(black_box(signal), black_box(sample_rate)))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Benchmark different noise levels
|
||||
for noise_level in [0.0, 0.1, 0.3, 0.5] {
|
||||
let signal = generate_noisy_breathing_signal(16.0, sample_rate, 30.0, noise_level);
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("noisy_signal", format!("noise_{}", (noise_level * 10.0) as u32)),
|
||||
&signal,
|
||||
|b, signal| {
|
||||
b.iter(|| detector.detect(black_box(signal), black_box(sample_rate)))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Benchmark different breathing rates
|
||||
for rate in [8.0, 16.0, 25.0, 35.0] {
|
||||
let signal = generate_breathing_signal(rate, sample_rate, 30.0);
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("rate_variation", format!("{}bpm", rate as u32)),
|
||||
&signal,
|
||||
|b, signal| {
|
||||
b.iter(|| detector.detect(black_box(signal), black_box(sample_rate)))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Benchmark with custom config (high sensitivity)
|
||||
let high_sensitivity_config = BreathingDetectorConfig {
|
||||
min_rate_bpm: 2.0,
|
||||
max_rate_bpm: 50.0,
|
||||
min_amplitude: 0.05,
|
||||
window_size: 1024,
|
||||
window_overlap: 0.75,
|
||||
confidence_threshold: 0.2,
|
||||
};
|
||||
let sensitive_detector = BreathingDetector::new(high_sensitivity_config);
|
||||
let signal = generate_noisy_breathing_signal(16.0, sample_rate, 30.0, 0.3);
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("high_sensitivity", "30s_noisy"),
|
||||
&signal,
|
||||
|b, signal| {
|
||||
b.iter(|| sensitive_detector.detect(black_box(signal), black_box(sample_rate)))
|
||||
},
|
||||
);
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Heartbeat Detection Benchmarks
|
||||
// =============================================================================
|
||||
|
||||
fn bench_heartbeat_detection(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("heartbeat_detection");
|
||||
|
||||
let detector = HeartbeatDetector::with_defaults();
|
||||
let sample_rate = 1000.0; // 1 kHz for micro-Doppler
|
||||
|
||||
// Benchmark different signal lengths
|
||||
for duration in [5.0, 10.0, 30.0] {
|
||||
let signal = generate_heartbeat_signal(72.0, sample_rate, duration);
|
||||
let num_samples = signal.len();
|
||||
|
||||
group.throughput(Throughput::Elements(num_samples as u64));
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("clean_signal", format!("{}s", duration as u32)),
|
||||
&signal,
|
||||
|b, signal| {
|
||||
b.iter(|| detector.detect(black_box(signal), black_box(sample_rate), None))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Benchmark with known breathing rate (improves filtering)
|
||||
let signal = generate_heartbeat_signal(72.0, sample_rate, 30.0);
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("with_breathing_rate", "72bpm_known_br"),
|
||||
&signal,
|
||||
|b, signal| {
|
||||
b.iter(|| {
|
||||
detector.detect(
|
||||
black_box(signal),
|
||||
black_box(sample_rate),
|
||||
black_box(Some(16.0)), // Known breathing rate
|
||||
)
|
||||
})
|
||||
},
|
||||
);
|
||||
|
||||
// Benchmark different heart rates
|
||||
for rate in [50.0, 72.0, 100.0, 150.0] {
|
||||
let signal = generate_heartbeat_signal(rate, sample_rate, 10.0);
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("rate_variation", format!("{}bpm", rate as u32)),
|
||||
&signal,
|
||||
|b, signal| {
|
||||
b.iter(|| detector.detect(black_box(signal), black_box(sample_rate), None))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Benchmark enhanced processing config
|
||||
let enhanced_config = HeartbeatDetectorConfig {
|
||||
min_rate_bpm: 30.0,
|
||||
max_rate_bpm: 200.0,
|
||||
min_signal_strength: 0.02,
|
||||
window_size: 2048,
|
||||
enhanced_processing: true,
|
||||
confidence_threshold: 0.3,
|
||||
};
|
||||
let enhanced_detector = HeartbeatDetector::new(enhanced_config);
|
||||
let signal = generate_heartbeat_signal(72.0, sample_rate, 10.0);
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("enhanced_processing", "2048_window"),
|
||||
&signal,
|
||||
|b, signal| {
|
||||
b.iter(|| enhanced_detector.detect(black_box(signal), black_box(sample_rate), None))
|
||||
},
|
||||
);
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Movement Classification Benchmarks
|
||||
// =============================================================================
|
||||
|
||||
fn bench_movement_classification(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("movement_classification");
|
||||
|
||||
let classifier = MovementClassifier::with_defaults();
|
||||
let sample_rate = 100.0;
|
||||
|
||||
// Benchmark different movement types
|
||||
for movement_type in ["none", "gross", "tremor", "periodic"] {
|
||||
let signal = generate_movement_signal(movement_type, sample_rate, 10.0);
|
||||
let num_samples = signal.len();
|
||||
|
||||
group.throughput(Throughput::Elements(num_samples as u64));
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("movement_type", movement_type),
|
||||
&signal,
|
||||
|b, signal| {
|
||||
b.iter(|| classifier.classify(black_box(signal), black_box(sample_rate)))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Benchmark different signal lengths
|
||||
for duration in [2.0, 5.0, 10.0, 30.0] {
|
||||
let signal = generate_movement_signal("gross", sample_rate, duration);
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("signal_length", format!("{}s", duration as u32)),
|
||||
&signal,
|
||||
|b, signal| {
|
||||
b.iter(|| classifier.classify(black_box(signal), black_box(sample_rate)))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Benchmark with custom sensitivity
|
||||
let sensitive_config = MovementClassifierConfig {
|
||||
movement_threshold: 0.05,
|
||||
gross_movement_threshold: 0.3,
|
||||
window_size: 200,
|
||||
periodicity_threshold: 0.2,
|
||||
};
|
||||
let sensitive_classifier = MovementClassifier::new(sensitive_config);
|
||||
let signal = generate_movement_signal("tremor", sample_rate, 10.0);
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("high_sensitivity", "tremor_detection"),
|
||||
&signal,
|
||||
|b, signal| {
|
||||
b.iter(|| sensitive_classifier.classify(black_box(signal), black_box(sample_rate)))
|
||||
},
|
||||
);
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Full Detection Pipeline Benchmarks
|
||||
// =============================================================================
|
||||
|
||||
fn bench_detection_pipeline(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("detection_pipeline");
|
||||
group.sample_size(50); // Reduce sample size for slower benchmarks
|
||||
|
||||
let sample_rate = 100.0;
|
||||
|
||||
// Standard pipeline (breathing + movement)
|
||||
let standard_config = DetectionConfig {
|
||||
sample_rate,
|
||||
enable_heartbeat: false,
|
||||
min_confidence: 0.3,
|
||||
..Default::default()
|
||||
};
|
||||
let standard_pipeline = DetectionPipeline::new(standard_config);
|
||||
|
||||
// Full pipeline (breathing + heartbeat + movement)
|
||||
let full_config = DetectionConfig {
|
||||
sample_rate: 1000.0,
|
||||
enable_heartbeat: true,
|
||||
min_confidence: 0.3,
|
||||
..Default::default()
|
||||
};
|
||||
let full_pipeline = DetectionPipeline::new(full_config);
|
||||
|
||||
// Benchmark standard pipeline at different data sizes
|
||||
for duration in [5.0, 10.0, 30.0] {
|
||||
let (amplitudes, phases) = generate_combined_vital_signal(16.0, 72.0, sample_rate, duration);
|
||||
let mut buffer = CsiDataBuffer::new(sample_rate);
|
||||
buffer.add_samples(&litudes, &phases);
|
||||
|
||||
group.throughput(Throughput::Elements(amplitudes.len() as u64));
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("standard_pipeline", format!("{}s", duration as u32)),
|
||||
&buffer,
|
||||
|b, buffer| {
|
||||
b.iter(|| standard_pipeline.detect(black_box(buffer)))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Benchmark full pipeline
|
||||
for duration in [5.0, 10.0] {
|
||||
let (amplitudes, phases) = generate_combined_vital_signal(16.0, 72.0, 1000.0, duration);
|
||||
let mut buffer = CsiDataBuffer::new(1000.0);
|
||||
buffer.add_samples(&litudes, &phases);
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("full_pipeline", format!("{}s", duration as u32)),
|
||||
&buffer,
|
||||
|b, buffer| {
|
||||
b.iter(|| full_pipeline.detect(black_box(buffer)))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Benchmark multi-person scenarios
|
||||
for person_count in [1, 2, 3, 5] {
|
||||
let signal = generate_multi_person_signal(person_count, sample_rate, 30.0);
|
||||
let mut buffer = CsiDataBuffer::new(sample_rate);
|
||||
buffer.add_samples(&signal, &signal);
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("multi_person", format!("{}_people", person_count)),
|
||||
&buffer,
|
||||
|b, buffer| {
|
||||
b.iter(|| standard_pipeline.detect(black_box(buffer)))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Triangulation Benchmarks
|
||||
// =============================================================================
|
||||
|
||||
fn bench_triangulation(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("triangulation");
|
||||
|
||||
let triangulator = Triangulator::with_defaults();
|
||||
|
||||
// Benchmark with different sensor counts
|
||||
for sensor_count in [3, 4, 5, 8, 12] {
|
||||
let sensors = create_test_sensors(sensor_count);
|
||||
|
||||
// Generate RSSI values (simulate target at center)
|
||||
let rssi_values: Vec<(String, f64)> = sensors.iter()
|
||||
.map(|s| {
|
||||
let distance = (s.x * s.x + s.y * s.y).sqrt();
|
||||
let rssi = -30.0 - 20.0 * distance.log10(); // Path loss model
|
||||
(s.id.clone(), rssi)
|
||||
})
|
||||
.collect();
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("rssi_position", format!("{}_sensors", sensor_count)),
|
||||
&(sensors.clone(), rssi_values.clone()),
|
||||
|b, (sensors, rssi)| {
|
||||
b.iter(|| {
|
||||
triangulator.estimate_position(black_box(sensors), black_box(rssi))
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Benchmark ToA-based positioning
|
||||
for sensor_count in [3, 4, 5, 8] {
|
||||
let sensors = create_test_sensors(sensor_count);
|
||||
|
||||
// Generate ToA values (time in nanoseconds)
|
||||
let toa_values: Vec<(String, f64)> = sensors.iter()
|
||||
.map(|s| {
|
||||
let distance = (s.x * s.x + s.y * s.y).sqrt();
|
||||
// Round trip time: 2 * distance / speed_of_light
|
||||
let toa_ns = 2.0 * distance / 299_792_458.0 * 1e9;
|
||||
(s.id.clone(), toa_ns)
|
||||
})
|
||||
.collect();
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("toa_position", format!("{}_sensors", sensor_count)),
|
||||
&(sensors.clone(), toa_values.clone()),
|
||||
|b, (sensors, toa)| {
|
||||
b.iter(|| {
|
||||
triangulator.estimate_from_toa(black_box(sensors), black_box(toa))
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Benchmark with noisy measurements
|
||||
let sensors = create_test_sensors(5);
|
||||
for noise_pct in [0, 5, 10, 20] {
|
||||
let rssi_values: Vec<(String, f64)> = sensors.iter()
|
||||
.enumerate()
|
||||
.map(|(i, s)| {
|
||||
let distance = (s.x * s.x + s.y * s.y).sqrt();
|
||||
let rssi = -30.0 - 20.0 * distance.log10();
|
||||
// Add noise based on index for determinism
|
||||
let noise = (i as f64 / 10.0) * noise_pct as f64 / 100.0 * 10.0;
|
||||
(s.id.clone(), rssi + noise)
|
||||
})
|
||||
.collect();
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("noisy_rssi", format!("{}pct_noise", noise_pct)),
|
||||
&(sensors.clone(), rssi_values.clone()),
|
||||
|b, (sensors, rssi)| {
|
||||
b.iter(|| {
|
||||
triangulator.estimate_position(black_box(sensors), black_box(rssi))
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Depth Estimation Benchmarks
|
||||
// =============================================================================
|
||||
|
||||
fn bench_depth_estimation(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("depth_estimation");
|
||||
|
||||
let estimator = DepthEstimator::with_defaults();
|
||||
let debris = create_test_debris();
|
||||
|
||||
// Benchmark single-path depth estimation
|
||||
for attenuation in [10.0, 20.0, 40.0, 60.0] {
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("single_path", format!("{}dB", attenuation as u32)),
|
||||
&attenuation,
|
||||
|b, &attenuation| {
|
||||
b.iter(|| {
|
||||
estimator.estimate_depth(
|
||||
black_box(attenuation),
|
||||
black_box(5.0), // 5m horizontal distance
|
||||
black_box(&debris),
|
||||
)
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Benchmark different debris types
|
||||
let debris_types = [
|
||||
("snow", DebrisMaterial::Snow),
|
||||
("wood", DebrisMaterial::Wood),
|
||||
("light_concrete", DebrisMaterial::LightConcrete),
|
||||
("heavy_concrete", DebrisMaterial::HeavyConcrete),
|
||||
("mixed", DebrisMaterial::Mixed),
|
||||
];
|
||||
|
||||
for (name, material) in debris_types {
|
||||
let debris = DebrisProfile {
|
||||
primary_material: material,
|
||||
void_fraction: 0.25,
|
||||
moisture_content: MoistureLevel::Dry,
|
||||
metal_content: MetalContent::Low,
|
||||
};
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("debris_type", name),
|
||||
&debris,
|
||||
|b, debris| {
|
||||
b.iter(|| {
|
||||
estimator.estimate_depth(
|
||||
black_box(30.0),
|
||||
black_box(5.0),
|
||||
black_box(debris),
|
||||
)
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Benchmark multipath depth estimation
|
||||
for path_count in [1, 2, 4, 8] {
|
||||
let reflected_paths: Vec<(f64, f64)> = (0..path_count)
|
||||
.map(|i| {
|
||||
(
|
||||
30.0 + i as f64 * 5.0, // attenuation
|
||||
1e-9 * (i + 1) as f64, // delay in seconds
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("multipath", format!("{}_paths", path_count)),
|
||||
&reflected_paths,
|
||||
|b, paths| {
|
||||
b.iter(|| {
|
||||
estimator.estimate_from_multipath(
|
||||
black_box(25.0),
|
||||
black_box(paths),
|
||||
black_box(&debris),
|
||||
)
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Benchmark debris profile estimation
|
||||
for (variance, multipath, moisture) in [
|
||||
(0.2, 0.3, 0.2),
|
||||
(0.5, 0.5, 0.5),
|
||||
(0.7, 0.8, 0.8),
|
||||
] {
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("profile_estimation", format!("v{}_m{}", (variance * 10.0) as u32, (multipath * 10.0) as u32)),
|
||||
&(variance, multipath, moisture),
|
||||
|b, &(v, m, mo)| {
|
||||
b.iter(|| {
|
||||
estimator.estimate_debris_profile(
|
||||
black_box(v),
|
||||
black_box(m),
|
||||
black_box(mo),
|
||||
)
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Alert Generation Benchmarks
|
||||
// =============================================================================
|
||||
|
||||
fn bench_alert_generation(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("alert_generation");
|
||||
|
||||
// Benchmark basic alert generation
|
||||
let generator = AlertGenerator::new();
|
||||
let survivor = create_test_survivor();
|
||||
|
||||
group.bench_function("generate_basic_alert", |b| {
|
||||
b.iter(|| generator.generate(black_box(&survivor)))
|
||||
});
|
||||
|
||||
// Benchmark escalation alert
|
||||
group.bench_function("generate_escalation_alert", |b| {
|
||||
b.iter(|| {
|
||||
generator.generate_escalation(
|
||||
black_box(&survivor),
|
||||
black_box("Vital signs deteriorating"),
|
||||
)
|
||||
})
|
||||
});
|
||||
|
||||
// Benchmark status change alert
|
||||
use wifi_densepose_mat::domain::TriageStatus;
|
||||
group.bench_function("generate_status_change_alert", |b| {
|
||||
b.iter(|| {
|
||||
generator.generate_status_change(
|
||||
black_box(&survivor),
|
||||
black_box(&TriageStatus::Minor),
|
||||
)
|
||||
})
|
||||
});
|
||||
|
||||
// Benchmark with zone registration
|
||||
let mut generator_with_zones = AlertGenerator::new();
|
||||
for i in 0..100 {
|
||||
generator_with_zones.register_zone(ScanZoneId::new(), format!("Zone {}", i));
|
||||
}
|
||||
|
||||
group.bench_function("generate_with_zones_lookup", |b| {
|
||||
b.iter(|| generator_with_zones.generate(black_box(&survivor)))
|
||||
});
|
||||
|
||||
// Benchmark batch alert generation
|
||||
let survivors: Vec<Survivor> = (0..10).map(|_| create_test_survivor()).collect();
|
||||
|
||||
group.bench_function("batch_generate_10_alerts", |b| {
|
||||
b.iter(|| {
|
||||
survivors.iter()
|
||||
.map(|s| generator.generate(black_box(s)))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
});
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CSI Buffer Operations Benchmarks
|
||||
// =============================================================================
|
||||
|
||||
fn bench_csi_buffer(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("csi_buffer");
|
||||
|
||||
let sample_rate = 100.0;
|
||||
|
||||
// Benchmark buffer creation and addition
|
||||
for sample_count in [1000, 5000, 10000, 30000] {
|
||||
let amplitudes: Vec<f64> = (0..sample_count)
|
||||
.map(|i| (i as f64 / 100.0).sin())
|
||||
.collect();
|
||||
let phases: Vec<f64> = (0..sample_count)
|
||||
.map(|i| (i as f64 / 50.0).cos())
|
||||
.collect();
|
||||
|
||||
group.throughput(Throughput::Elements(sample_count as u64));
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("add_samples", format!("{}_samples", sample_count)),
|
||||
&(amplitudes.clone(), phases.clone()),
|
||||
|b, (amp, phase)| {
|
||||
b.iter(|| {
|
||||
let mut buffer = CsiDataBuffer::new(sample_rate);
|
||||
buffer.add_samples(black_box(amp), black_box(phase));
|
||||
buffer
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Benchmark incremental addition (simulating real-time data)
|
||||
let chunk_size = 100;
|
||||
let total_samples = 10000;
|
||||
let amplitudes: Vec<f64> = (0..chunk_size).map(|i| (i as f64 / 100.0).sin()).collect();
|
||||
let phases: Vec<f64> = (0..chunk_size).map(|i| (i as f64 / 50.0).cos()).collect();
|
||||
|
||||
group.bench_function("incremental_add_100_chunks", |b| {
|
||||
b.iter(|| {
|
||||
let mut buffer = CsiDataBuffer::new(sample_rate);
|
||||
for _ in 0..(total_samples / chunk_size) {
|
||||
buffer.add_samples(black_box(&litudes), black_box(&phases));
|
||||
}
|
||||
buffer
|
||||
})
|
||||
});
|
||||
|
||||
// Benchmark has_sufficient_data check
|
||||
let mut buffer = CsiDataBuffer::new(sample_rate);
|
||||
let amplitudes: Vec<f64> = (0..3000).map(|i| (i as f64 / 100.0).sin()).collect();
|
||||
let phases: Vec<f64> = (0..3000).map(|i| (i as f64 / 50.0).cos()).collect();
|
||||
buffer.add_samples(&litudes, &phases);
|
||||
|
||||
group.bench_function("check_sufficient_data", |b| {
|
||||
b.iter(|| buffer.has_sufficient_data(black_box(10.0)))
|
||||
});
|
||||
|
||||
group.bench_function("calculate_duration", |b| {
|
||||
b.iter(|| black_box(&buffer).duration())
|
||||
});
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Criterion Groups and Main
|
||||
// =============================================================================
|
||||
|
||||
criterion_group!(
|
||||
name = detection_benches;
|
||||
config = Criterion::default()
|
||||
.warm_up_time(std::time::Duration::from_millis(500))
|
||||
.measurement_time(std::time::Duration::from_secs(2));
|
||||
targets =
|
||||
bench_breathing_detection,
|
||||
bench_heartbeat_detection,
|
||||
bench_movement_classification
|
||||
);
|
||||
|
||||
criterion_group!(
|
||||
name = pipeline_benches;
|
||||
config = Criterion::default()
|
||||
.warm_up_time(std::time::Duration::from_millis(500))
|
||||
.measurement_time(std::time::Duration::from_secs(3))
|
||||
.sample_size(50);
|
||||
targets = bench_detection_pipeline
|
||||
);
|
||||
|
||||
criterion_group!(
|
||||
name = localization_benches;
|
||||
config = Criterion::default()
|
||||
.warm_up_time(std::time::Duration::from_millis(500))
|
||||
.measurement_time(std::time::Duration::from_secs(2));
|
||||
targets =
|
||||
bench_triangulation,
|
||||
bench_depth_estimation
|
||||
);
|
||||
|
||||
criterion_group!(
|
||||
name = alerting_benches;
|
||||
config = Criterion::default()
|
||||
.warm_up_time(std::time::Duration::from_millis(300))
|
||||
.measurement_time(std::time::Duration::from_secs(1));
|
||||
targets = bench_alert_generation
|
||||
);
|
||||
|
||||
criterion_group!(
|
||||
name = buffer_benches;
|
||||
config = Criterion::default()
|
||||
.warm_up_time(std::time::Duration::from_millis(300))
|
||||
.measurement_time(std::time::Duration::from_secs(1));
|
||||
targets = bench_csi_buffer
|
||||
);
|
||||
|
||||
criterion_main!(
|
||||
detection_benches,
|
||||
pipeline_benches,
|
||||
localization_benches,
|
||||
alerting_benches,
|
||||
buffer_benches
|
||||
);
|
||||
@@ -1,376 +0,0 @@
|
||||
//! Alert dispatching and delivery.
|
||||
|
||||
use crate::domain::{Alert, AlertId, Priority, Survivor};
|
||||
use crate::MatError;
|
||||
use super::AlertGenerator;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Configuration for alert dispatch
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AlertConfig {
|
||||
/// Enable audio alerts
|
||||
pub audio_enabled: bool,
|
||||
/// Enable visual alerts
|
||||
pub visual_enabled: bool,
|
||||
/// Escalation timeout in seconds
|
||||
pub escalation_timeout_secs: u64,
|
||||
/// Maximum pending alerts before forced escalation
|
||||
pub max_pending_alerts: usize,
|
||||
/// Auto-acknowledge after seconds (0 = disabled)
|
||||
pub auto_ack_secs: u64,
|
||||
}
|
||||
|
||||
impl Default for AlertConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
audio_enabled: true,
|
||||
visual_enabled: true,
|
||||
escalation_timeout_secs: 300, // 5 minutes
|
||||
max_pending_alerts: 50,
|
||||
auto_ack_secs: 0, // Disabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatcher for sending alerts to rescue teams
|
||||
pub struct AlertDispatcher {
|
||||
config: AlertConfig,
|
||||
generator: AlertGenerator,
|
||||
pending_alerts: parking_lot::RwLock<HashMap<AlertId, Alert>>,
|
||||
handlers: Vec<Box<dyn AlertHandler>>,
|
||||
}
|
||||
|
||||
impl AlertDispatcher {
|
||||
/// Create a new alert dispatcher
|
||||
pub fn new(config: AlertConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
generator: AlertGenerator::new(),
|
||||
pending_alerts: parking_lot::RwLock::new(HashMap::new()),
|
||||
handlers: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add an alert handler
|
||||
pub fn add_handler(&mut self, handler: Box<dyn AlertHandler>) {
|
||||
self.handlers.push(handler);
|
||||
}
|
||||
|
||||
/// Generate an alert for a survivor
|
||||
pub fn generate_alert(&self, survivor: &Survivor) -> Result<Alert, MatError> {
|
||||
self.generator.generate(survivor)
|
||||
}
|
||||
|
||||
/// Dispatch an alert
|
||||
pub async fn dispatch(&self, alert: Alert) -> Result<(), MatError> {
|
||||
let alert_id = alert.id().clone();
|
||||
let priority = alert.priority();
|
||||
|
||||
// Store in pending alerts
|
||||
self.pending_alerts.write().insert(alert_id.clone(), alert.clone());
|
||||
|
||||
// Log the alert
|
||||
tracing::info!(
|
||||
alert_id = %alert_id,
|
||||
priority = ?priority,
|
||||
title = %alert.payload().title,
|
||||
"Dispatching alert"
|
||||
);
|
||||
|
||||
// Send to all handlers
|
||||
for handler in &self.handlers {
|
||||
if let Err(e) = handler.handle(&alert).await {
|
||||
tracing::warn!(
|
||||
alert_id = %alert_id,
|
||||
handler = %handler.name(),
|
||||
error = %e,
|
||||
"Handler failed to process alert"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we're at capacity
|
||||
let pending_count = self.pending_alerts.read().len();
|
||||
if pending_count >= self.config.max_pending_alerts {
|
||||
tracing::warn!(
|
||||
pending_count,
|
||||
max = self.config.max_pending_alerts,
|
||||
"Alert capacity reached - escalating oldest alerts"
|
||||
);
|
||||
self.escalate_oldest().await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Acknowledge an alert
|
||||
pub fn acknowledge(&self, alert_id: &AlertId, by: &str) -> Result<(), MatError> {
|
||||
let mut alerts = self.pending_alerts.write();
|
||||
|
||||
if let Some(alert) = alerts.get_mut(alert_id) {
|
||||
alert.acknowledge(by);
|
||||
tracing::info!(
|
||||
alert_id = %alert_id,
|
||||
acknowledged_by = by,
|
||||
"Alert acknowledged"
|
||||
);
|
||||
Ok(())
|
||||
} else {
|
||||
Err(MatError::Alerting(format!("Alert {} not found", alert_id)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve an alert
|
||||
pub fn resolve(&self, alert_id: &AlertId, resolution: crate::domain::AlertResolution) -> Result<(), MatError> {
|
||||
let mut alerts = self.pending_alerts.write();
|
||||
|
||||
if let Some(alert) = alerts.remove(alert_id) {
|
||||
let mut resolved_alert = alert;
|
||||
resolved_alert.resolve(resolution);
|
||||
tracing::info!(
|
||||
alert_id = %alert_id,
|
||||
"Alert resolved"
|
||||
);
|
||||
Ok(())
|
||||
} else {
|
||||
Err(MatError::Alerting(format!("Alert {} not found", alert_id)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all pending alerts
|
||||
pub fn pending(&self) -> Vec<Alert> {
|
||||
self.pending_alerts.read().values().cloned().collect()
|
||||
}
|
||||
|
||||
/// Get pending alerts by priority
|
||||
pub fn pending_by_priority(&self, priority: Priority) -> Vec<Alert> {
|
||||
self.pending_alerts
|
||||
.read()
|
||||
.values()
|
||||
.filter(|a| a.priority() == priority)
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get count of pending alerts
|
||||
pub fn pending_count(&self) -> usize {
|
||||
self.pending_alerts.read().len()
|
||||
}
|
||||
|
||||
/// Check and escalate timed-out alerts
|
||||
pub async fn check_escalations(&self) -> Result<u32, MatError> {
|
||||
let timeout_secs = self.config.escalation_timeout_secs as i64;
|
||||
let mut escalated = 0;
|
||||
|
||||
let mut to_escalate = Vec::new();
|
||||
{
|
||||
let alerts = self.pending_alerts.read();
|
||||
for (id, alert) in alerts.iter() {
|
||||
if alert.needs_escalation(timeout_secs) {
|
||||
to_escalate.push(id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for id in to_escalate {
|
||||
let mut alerts = self.pending_alerts.write();
|
||||
if let Some(alert) = alerts.get_mut(&id) {
|
||||
alert.escalate();
|
||||
escalated += 1;
|
||||
|
||||
tracing::warn!(
|
||||
alert_id = %id,
|
||||
new_priority = ?alert.priority(),
|
||||
"Alert escalated due to timeout"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(escalated)
|
||||
}
|
||||
|
||||
/// Escalate oldest pending alerts
|
||||
async fn escalate_oldest(&self) -> Result<(), MatError> {
|
||||
let mut alerts: Vec<_> = self.pending_alerts.read()
|
||||
.iter()
|
||||
.map(|(id, alert)| (id.clone(), *alert.created_at()))
|
||||
.collect();
|
||||
|
||||
// Sort by creation time (oldest first)
|
||||
alerts.sort_by_key(|(_, created)| *created);
|
||||
|
||||
// Escalate oldest 10%
|
||||
let to_escalate = (alerts.len() / 10).max(1);
|
||||
|
||||
let mut pending = self.pending_alerts.write();
|
||||
for (id, _) in alerts.into_iter().take(to_escalate) {
|
||||
if let Some(alert) = pending.get_mut(&id) {
|
||||
alert.escalate();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get configuration
|
||||
pub fn config(&self) -> &AlertConfig {
|
||||
&self.config
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler for processing alerts
|
||||
#[async_trait::async_trait]
|
||||
pub trait AlertHandler: Send + Sync {
|
||||
/// Handler name
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// Handle an alert
|
||||
async fn handle(&self, alert: &Alert) -> Result<(), MatError>;
|
||||
}
|
||||
|
||||
/// Console/logging alert handler
|
||||
pub struct ConsoleAlertHandler;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl AlertHandler for ConsoleAlertHandler {
|
||||
fn name(&self) -> &str {
|
||||
"console"
|
||||
}
|
||||
|
||||
async fn handle(&self, alert: &Alert) -> Result<(), MatError> {
|
||||
let priority_indicator = match alert.priority() {
|
||||
Priority::Critical => "🔴",
|
||||
Priority::High => "🟠",
|
||||
Priority::Medium => "🟡",
|
||||
Priority::Low => "🔵",
|
||||
};
|
||||
|
||||
println!("\n{} ALERT {}", priority_indicator, "=".repeat(50));
|
||||
println!("ID: {}", alert.id());
|
||||
println!("Priority: {:?}", alert.priority());
|
||||
println!("Title: {}", alert.payload().title);
|
||||
println!("{}", "=".repeat(60));
|
||||
println!("{}", alert.payload().message);
|
||||
println!("{}", "=".repeat(60));
|
||||
println!("Recommended Action: {}", alert.payload().recommended_action);
|
||||
println!("{}\n", "=".repeat(60));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Audio alert handler.
|
||||
///
|
||||
/// Requires platform audio support. On systems without audio hardware
|
||||
/// (headless servers, embedded), this logs the alert pattern. On systems
|
||||
/// with audio, integrate with the platform's audio API.
|
||||
pub struct AudioAlertHandler {
|
||||
/// Whether audio hardware is available
|
||||
audio_available: bool,
|
||||
}
|
||||
|
||||
impl AudioAlertHandler {
|
||||
/// Create a new audio handler, auto-detecting audio support.
|
||||
pub fn new() -> Self {
|
||||
let audio_available = std::env::var("DISPLAY").is_ok()
|
||||
|| std::env::var("PULSE_SERVER").is_ok();
|
||||
Self { audio_available }
|
||||
}
|
||||
|
||||
/// Create with explicit audio availability flag.
|
||||
pub fn with_availability(available: bool) -> Self {
|
||||
Self { audio_available: available }
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AudioAlertHandler {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl AlertHandler for AudioAlertHandler {
|
||||
fn name(&self) -> &str {
|
||||
"audio"
|
||||
}
|
||||
|
||||
async fn handle(&self, alert: &Alert) -> Result<(), MatError> {
|
||||
let pattern = alert.priority().audio_pattern();
|
||||
|
||||
if self.audio_available {
|
||||
// Platform audio integration point.
|
||||
// Pattern encodes urgency: Critical=continuous, High=3-burst, etc.
|
||||
tracing::info!(
|
||||
alert_id = %alert.id(),
|
||||
pattern,
|
||||
"Playing audio alert pattern"
|
||||
);
|
||||
} else {
|
||||
tracing::debug!(
|
||||
alert_id = %alert.id(),
|
||||
pattern,
|
||||
"Audio hardware not available - alert pattern logged only"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::domain::{SurvivorId, TriageStatus, AlertPayload};
|
||||
|
||||
fn create_test_alert() -> Alert {
|
||||
Alert::new(
|
||||
SurvivorId::new(),
|
||||
Priority::High,
|
||||
AlertPayload::new("Test Alert", "Test message", TriageStatus::Delayed),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dispatch_alert() {
|
||||
let dispatcher = AlertDispatcher::new(AlertConfig::default());
|
||||
let alert = create_test_alert();
|
||||
|
||||
let result = dispatcher.dispatch(alert).await;
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(dispatcher.pending_count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_acknowledge_alert() {
|
||||
let dispatcher = AlertDispatcher::new(AlertConfig::default());
|
||||
let alert = create_test_alert();
|
||||
let alert_id = alert.id().clone();
|
||||
|
||||
dispatcher.dispatch(alert).await.unwrap();
|
||||
|
||||
let result = dispatcher.acknowledge(&alert_id, "Team Alpha");
|
||||
assert!(result.is_ok());
|
||||
|
||||
let pending = dispatcher.pending();
|
||||
assert!(pending.iter().any(|a| a.id() == &alert_id && a.acknowledged_by() == Some("Team Alpha")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_alert() {
|
||||
let dispatcher = AlertDispatcher::new(AlertConfig::default());
|
||||
let alert = create_test_alert();
|
||||
let alert_id = alert.id().clone();
|
||||
|
||||
dispatcher.dispatch(alert).await.unwrap();
|
||||
|
||||
let resolution = crate::domain::AlertResolution {
|
||||
resolution_type: crate::domain::ResolutionType::Rescued,
|
||||
notes: "Survivor extracted successfully".to_string(),
|
||||
resolved_by: Some("Team Alpha".to_string()),
|
||||
resolved_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
dispatcher.resolve(&alert_id, resolution).unwrap();
|
||||
assert_eq!(dispatcher.pending_count(), 0);
|
||||
}
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
//! Alert generation from survivor detections.
|
||||
|
||||
use crate::domain::{
|
||||
Alert, AlertPayload, Priority, Survivor, TriageStatus, ScanZoneId,
|
||||
};
|
||||
use crate::MatError;
|
||||
|
||||
/// Generator for alerts based on survivor status
|
||||
pub struct AlertGenerator {
|
||||
/// Zone name lookup (would be connected to event in production)
|
||||
zone_names: std::collections::HashMap<ScanZoneId, String>,
|
||||
}
|
||||
|
||||
impl AlertGenerator {
|
||||
/// Create a new alert generator
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
zone_names: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a zone name
|
||||
pub fn register_zone(&mut self, zone_id: ScanZoneId, name: String) {
|
||||
self.zone_names.insert(zone_id, name);
|
||||
}
|
||||
|
||||
/// Generate an alert for a survivor
|
||||
pub fn generate(&self, survivor: &Survivor) -> Result<Alert, MatError> {
|
||||
let priority = Priority::from_triage(survivor.triage_status());
|
||||
let payload = self.create_payload(survivor);
|
||||
|
||||
Ok(Alert::new(survivor.id().clone(), priority, payload))
|
||||
}
|
||||
|
||||
/// Generate an escalation alert
|
||||
pub fn generate_escalation(
|
||||
&self,
|
||||
survivor: &Survivor,
|
||||
reason: &str,
|
||||
) -> Result<Alert, MatError> {
|
||||
let mut payload = self.create_payload(survivor);
|
||||
payload.title = format!("ESCALATED: {}", payload.title);
|
||||
payload.message = format!(
|
||||
"{}\n\nReason for escalation: {}",
|
||||
payload.message, reason
|
||||
);
|
||||
|
||||
// Escalated alerts are always at least high priority
|
||||
let priority = match survivor.triage_status() {
|
||||
TriageStatus::Immediate => Priority::Critical,
|
||||
_ => Priority::High,
|
||||
};
|
||||
|
||||
Ok(Alert::new(survivor.id().clone(), priority, payload))
|
||||
}
|
||||
|
||||
/// Generate a status change alert
|
||||
pub fn generate_status_change(
|
||||
&self,
|
||||
survivor: &Survivor,
|
||||
previous_status: &TriageStatus,
|
||||
) -> Result<Alert, MatError> {
|
||||
let mut payload = self.create_payload(survivor);
|
||||
|
||||
payload.title = format!(
|
||||
"Status Change: {} → {}",
|
||||
previous_status, survivor.triage_status()
|
||||
);
|
||||
|
||||
// Determine if this is an upgrade (worse) or downgrade (better)
|
||||
let is_upgrade = survivor.triage_status().priority() < previous_status.priority();
|
||||
|
||||
if is_upgrade {
|
||||
payload.message = format!(
|
||||
"URGENT: Survivor condition has WORSENED.\n{}\n\nPrevious: {}\nCurrent: {}",
|
||||
payload.message,
|
||||
previous_status,
|
||||
survivor.triage_status()
|
||||
);
|
||||
} else {
|
||||
payload.message = format!(
|
||||
"Survivor condition has improved.\n{}\n\nPrevious: {}\nCurrent: {}",
|
||||
payload.message,
|
||||
previous_status,
|
||||
survivor.triage_status()
|
||||
);
|
||||
}
|
||||
|
||||
let priority = if is_upgrade {
|
||||
Priority::from_triage(survivor.triage_status())
|
||||
} else {
|
||||
Priority::Medium
|
||||
};
|
||||
|
||||
Ok(Alert::new(survivor.id().clone(), priority, payload))
|
||||
}
|
||||
|
||||
/// Create alert payload from survivor data
|
||||
fn create_payload(&self, survivor: &Survivor) -> AlertPayload {
|
||||
let zone_name = self.zone_names
|
||||
.get(survivor.zone_id())
|
||||
.map(String::as_str)
|
||||
.unwrap_or("Unknown Zone");
|
||||
|
||||
let title = format!(
|
||||
"{} Survivor Detected - {}",
|
||||
survivor.triage_status(),
|
||||
zone_name
|
||||
);
|
||||
|
||||
let vital_info = self.format_vital_signs(survivor);
|
||||
let location_info = self.format_location(survivor);
|
||||
|
||||
let message = format!(
|
||||
"Survivor ID: {}\n\
|
||||
Zone: {}\n\
|
||||
Triage: {}\n\
|
||||
Confidence: {:.0}%\n\n\
|
||||
Vital Signs:\n{}\n\n\
|
||||
Location:\n{}",
|
||||
survivor.id(),
|
||||
zone_name,
|
||||
survivor.triage_status(),
|
||||
survivor.confidence() * 100.0,
|
||||
vital_info,
|
||||
location_info
|
||||
);
|
||||
|
||||
let recommended_action = self.recommend_action(survivor);
|
||||
|
||||
AlertPayload::new(title, message, survivor.triage_status().clone())
|
||||
.with_action(recommended_action)
|
||||
.with_metadata("zone_id", survivor.zone_id().to_string())
|
||||
.with_metadata("confidence", format!("{:.2}", survivor.confidence()))
|
||||
}
|
||||
|
||||
/// Format vital signs for display
|
||||
fn format_vital_signs(&self, survivor: &Survivor) -> String {
|
||||
let vitals = survivor.vital_signs();
|
||||
|
||||
let mut lines = Vec::new();
|
||||
|
||||
if let Some(reading) = vitals.latest() {
|
||||
if let Some(breathing) = &reading.breathing {
|
||||
lines.push(format!(
|
||||
" Breathing: {:.1} BPM ({:?})",
|
||||
breathing.rate_bpm, breathing.pattern_type
|
||||
));
|
||||
} else {
|
||||
lines.push(" Breathing: Not detected".to_string());
|
||||
}
|
||||
|
||||
if let Some(heartbeat) = &reading.heartbeat {
|
||||
lines.push(format!(
|
||||
" Heartbeat: {:.0} BPM ({:?})",
|
||||
heartbeat.rate_bpm, heartbeat.strength
|
||||
));
|
||||
}
|
||||
|
||||
lines.push(format!(
|
||||
" Movement: {:?} (intensity: {:.1})",
|
||||
reading.movement.movement_type,
|
||||
reading.movement.intensity
|
||||
));
|
||||
} else {
|
||||
lines.push(" No recent readings".to_string());
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
/// Format location for display
|
||||
fn format_location(&self, survivor: &Survivor) -> String {
|
||||
match survivor.location() {
|
||||
Some(loc) => {
|
||||
let depth_str = if loc.is_buried() {
|
||||
format!("{:.1}m below surface", loc.depth())
|
||||
} else {
|
||||
"At surface level".to_string()
|
||||
};
|
||||
|
||||
format!(
|
||||
" Position: ({:.1}, {:.1})\n\
|
||||
Depth: {}\n\
|
||||
Uncertainty: ±{:.1}m",
|
||||
loc.x, loc.y,
|
||||
depth_str,
|
||||
loc.uncertainty.horizontal_error
|
||||
)
|
||||
}
|
||||
None => " Position not yet determined".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Recommend action based on triage status
|
||||
fn recommend_action(&self, survivor: &Survivor) -> String {
|
||||
match survivor.triage_status() {
|
||||
TriageStatus::Immediate => {
|
||||
"IMMEDIATE RESCUE REQUIRED. Deploy heavy rescue team. \
|
||||
Prepare for airway management and critical care on extraction."
|
||||
}
|
||||
TriageStatus::Delayed => {
|
||||
"Rescue team required. Mark location. Provide reassurance \
|
||||
if communication is possible. Monitor for status changes."
|
||||
}
|
||||
TriageStatus::Minor => {
|
||||
"Lower priority. Guide to extraction if conscious and mobile. \
|
||||
Assign walking wounded assistance team."
|
||||
}
|
||||
TriageStatus::Deceased => {
|
||||
"Mark location for recovery. Do not allocate rescue resources. \
|
||||
Document for incident report."
|
||||
}
|
||||
TriageStatus::Unknown => {
|
||||
"Requires additional assessment. Deploy scout team with \
|
||||
enhanced detection equipment to confirm status."
|
||||
}
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AlertGenerator {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::domain::{BreathingPattern, BreathingType, ConfidenceScore, VitalSignsReading};
|
||||
use chrono::Utc;
|
||||
|
||||
fn create_test_survivor() -> Survivor {
|
||||
let vitals = VitalSignsReading {
|
||||
breathing: Some(BreathingPattern {
|
||||
rate_bpm: 35.0,
|
||||
amplitude: 0.7,
|
||||
regularity: 0.5,
|
||||
pattern_type: BreathingType::Labored,
|
||||
}),
|
||||
heartbeat: None,
|
||||
movement: Default::default(),
|
||||
timestamp: Utc::now(),
|
||||
confidence: ConfidenceScore::new(0.8),
|
||||
};
|
||||
|
||||
Survivor::new(ScanZoneId::new(), vitals, None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_alert() {
|
||||
let generator = AlertGenerator::new();
|
||||
let survivor = create_test_survivor();
|
||||
|
||||
let result = generator.generate(&survivor);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let alert = result.unwrap();
|
||||
assert!(alert.is_pending());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_escalation_alert() {
|
||||
let generator = AlertGenerator::new();
|
||||
let survivor = create_test_survivor();
|
||||
|
||||
let alert = generator.generate_escalation(&survivor, "Vital signs deteriorating")
|
||||
.unwrap();
|
||||
|
||||
assert!(alert.payload().title.contains("ESCALATED"));
|
||||
assert!(matches!(alert.priority(), Priority::Critical | Priority::High));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_status_change_alert() {
|
||||
let generator = AlertGenerator::new();
|
||||
let survivor = create_test_survivor();
|
||||
|
||||
let alert = generator.generate_status_change(
|
||||
&survivor,
|
||||
&TriageStatus::Minor,
|
||||
).unwrap();
|
||||
|
||||
assert!(alert.payload().title.contains("Status Change"));
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
//! Alerting module for emergency notifications.
|
||||
|
||||
mod generator;
|
||||
mod dispatcher;
|
||||
mod triage_service;
|
||||
|
||||
pub use generator::AlertGenerator;
|
||||
pub use dispatcher::{AlertDispatcher, AlertConfig};
|
||||
pub use triage_service::{TriageService, PriorityCalculator};
|
||||
@@ -1,317 +0,0 @@
|
||||
//! Triage service for calculating and updating survivor priority.
|
||||
|
||||
use crate::domain::{
|
||||
Priority, Survivor, TriageStatus, VitalSignsReading,
|
||||
triage::TriageCalculator,
|
||||
};
|
||||
|
||||
/// Service for triage operations
|
||||
pub struct TriageService;
|
||||
|
||||
impl TriageService {
|
||||
/// Calculate triage status from vital signs
|
||||
pub fn calculate_triage(vitals: &VitalSignsReading) -> TriageStatus {
|
||||
TriageCalculator::calculate(vitals)
|
||||
}
|
||||
|
||||
/// Check if survivor should be upgraded
|
||||
pub fn should_upgrade(survivor: &Survivor) -> bool {
|
||||
TriageCalculator::should_upgrade(
|
||||
survivor.triage_status(),
|
||||
survivor.is_deteriorating(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Get upgraded status
|
||||
pub fn upgrade_status(current: &TriageStatus) -> TriageStatus {
|
||||
TriageCalculator::upgrade(current)
|
||||
}
|
||||
|
||||
/// Evaluate overall severity for multiple survivors
|
||||
pub fn evaluate_mass_casualty(survivors: &[&Survivor]) -> MassCasualtyAssessment {
|
||||
let total = survivors.len() as u32;
|
||||
|
||||
let mut immediate = 0u32;
|
||||
let mut delayed = 0u32;
|
||||
let mut minor = 0u32;
|
||||
let mut deceased = 0u32;
|
||||
let mut unknown = 0u32;
|
||||
|
||||
for survivor in survivors {
|
||||
match survivor.triage_status() {
|
||||
TriageStatus::Immediate => immediate += 1,
|
||||
TriageStatus::Delayed => delayed += 1,
|
||||
TriageStatus::Minor => minor += 1,
|
||||
TriageStatus::Deceased => deceased += 1,
|
||||
TriageStatus::Unknown => unknown += 1,
|
||||
}
|
||||
}
|
||||
|
||||
let severity = Self::calculate_severity(immediate, delayed, total);
|
||||
let resource_level = Self::calculate_resource_level(immediate, delayed, minor);
|
||||
|
||||
MassCasualtyAssessment {
|
||||
total,
|
||||
immediate,
|
||||
delayed,
|
||||
minor,
|
||||
deceased,
|
||||
unknown,
|
||||
severity,
|
||||
resource_level,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate overall severity level
|
||||
fn calculate_severity(immediate: u32, delayed: u32, total: u32) -> SeverityLevel {
|
||||
if total == 0 {
|
||||
return SeverityLevel::Minimal;
|
||||
}
|
||||
|
||||
let critical_ratio = (immediate + delayed) as f64 / total as f64;
|
||||
|
||||
if immediate >= 10 || critical_ratio > 0.5 {
|
||||
SeverityLevel::Critical
|
||||
} else if immediate >= 5 || critical_ratio > 0.3 {
|
||||
SeverityLevel::Major
|
||||
} else if immediate >= 1 || critical_ratio > 0.1 {
|
||||
SeverityLevel::Moderate
|
||||
} else {
|
||||
SeverityLevel::Minimal
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate resource level needed
|
||||
fn calculate_resource_level(immediate: u32, delayed: u32, minor: u32) -> ResourceLevel {
|
||||
// Each immediate needs ~4 rescuers
|
||||
// Each delayed needs ~2 rescuers
|
||||
// Each minor needs ~0.5 rescuers
|
||||
let rescuers_needed = immediate * 4 + delayed * 2 + minor / 2;
|
||||
|
||||
if rescuers_needed >= 100 {
|
||||
ResourceLevel::MutualAid
|
||||
} else if rescuers_needed >= 50 {
|
||||
ResourceLevel::MultiAgency
|
||||
} else if rescuers_needed >= 20 {
|
||||
ResourceLevel::Enhanced
|
||||
} else if rescuers_needed >= 5 {
|
||||
ResourceLevel::Standard
|
||||
} else {
|
||||
ResourceLevel::Minimal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculator for alert priority
|
||||
pub struct PriorityCalculator;
|
||||
|
||||
impl PriorityCalculator {
|
||||
/// Calculate priority from triage status
|
||||
pub fn from_triage(status: &TriageStatus) -> Priority {
|
||||
Priority::from_triage(status)
|
||||
}
|
||||
|
||||
/// Calculate priority with additional factors
|
||||
pub fn calculate_with_factors(
|
||||
status: &TriageStatus,
|
||||
deteriorating: bool,
|
||||
time_since_detection_mins: u64,
|
||||
depth_meters: Option<f64>,
|
||||
) -> Priority {
|
||||
let base_priority = Priority::from_triage(status);
|
||||
|
||||
// Adjust for deterioration
|
||||
let priority = if deteriorating && base_priority != Priority::Critical {
|
||||
match base_priority {
|
||||
Priority::High => Priority::Critical,
|
||||
Priority::Medium => Priority::High,
|
||||
Priority::Low => Priority::Medium,
|
||||
Priority::Critical => Priority::Critical,
|
||||
}
|
||||
} else {
|
||||
base_priority
|
||||
};
|
||||
|
||||
// Adjust for time (longer = more urgent)
|
||||
let priority = if time_since_detection_mins > 30 && priority == Priority::Medium {
|
||||
Priority::High
|
||||
} else {
|
||||
priority
|
||||
};
|
||||
|
||||
// Adjust for depth (deeper = more complex rescue)
|
||||
if let Some(depth) = depth_meters {
|
||||
if depth > 3.0 && priority == Priority::High {
|
||||
return Priority::Critical;
|
||||
}
|
||||
}
|
||||
|
||||
priority
|
||||
}
|
||||
}
|
||||
|
||||
/// Mass casualty assessment result
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MassCasualtyAssessment {
|
||||
/// Total survivors detected
|
||||
pub total: u32,
|
||||
/// Immediate (Red) count
|
||||
pub immediate: u32,
|
||||
/// Delayed (Yellow) count
|
||||
pub delayed: u32,
|
||||
/// Minor (Green) count
|
||||
pub minor: u32,
|
||||
/// Deceased (Black) count
|
||||
pub deceased: u32,
|
||||
/// Unknown count
|
||||
pub unknown: u32,
|
||||
/// Overall severity level
|
||||
pub severity: SeverityLevel,
|
||||
/// Resource level needed
|
||||
pub resource_level: ResourceLevel,
|
||||
}
|
||||
|
||||
impl MassCasualtyAssessment {
|
||||
/// Get count of living survivors
|
||||
pub fn living(&self) -> u32 {
|
||||
self.immediate + self.delayed + self.minor
|
||||
}
|
||||
|
||||
/// Get count needing active rescue
|
||||
pub fn needs_rescue(&self) -> u32 {
|
||||
self.immediate + self.delayed
|
||||
}
|
||||
|
||||
/// Get summary string
|
||||
pub fn summary(&self) -> String {
|
||||
format!(
|
||||
"MCI Assessment:\n\
|
||||
Total: {} (Living: {}, Deceased: {})\n\
|
||||
Immediate: {}, Delayed: {}, Minor: {}\n\
|
||||
Severity: {:?}, Resources: {:?}",
|
||||
self.total, self.living(), self.deceased,
|
||||
self.immediate, self.delayed, self.minor,
|
||||
self.severity, self.resource_level
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Severity levels for mass casualty incidents
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SeverityLevel {
|
||||
/// Few or no critical patients
|
||||
Minimal,
|
||||
/// Some critical patients, manageable
|
||||
Moderate,
|
||||
/// Many critical patients, challenging
|
||||
Major,
|
||||
/// Overwhelming number of critical patients
|
||||
Critical,
|
||||
}
|
||||
|
||||
/// Resource levels for response
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ResourceLevel {
|
||||
/// Standard response adequate
|
||||
Minimal,
|
||||
/// Standard response needed
|
||||
Standard,
|
||||
/// Enhanced response needed
|
||||
Enhanced,
|
||||
/// Multi-agency response needed
|
||||
MultiAgency,
|
||||
/// Regional mutual aid required
|
||||
MutualAid,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::domain::{
|
||||
BreathingPattern, BreathingType, ConfidenceScore, ScanZoneId,
|
||||
};
|
||||
use chrono::Utc;
|
||||
|
||||
fn create_test_vitals(rate_bpm: f32) -> VitalSignsReading {
|
||||
VitalSignsReading {
|
||||
breathing: Some(BreathingPattern {
|
||||
rate_bpm,
|
||||
amplitude: 0.8,
|
||||
regularity: 0.9,
|
||||
pattern_type: BreathingType::Normal,
|
||||
}),
|
||||
heartbeat: None,
|
||||
movement: Default::default(),
|
||||
timestamp: Utc::now(),
|
||||
confidence: ConfidenceScore::new(0.8),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_triage() {
|
||||
let normal = create_test_vitals(16.0);
|
||||
assert!(matches!(
|
||||
TriageService::calculate_triage(&normal),
|
||||
TriageStatus::Immediate | TriageStatus::Delayed | TriageStatus::Minor
|
||||
));
|
||||
|
||||
let fast = create_test_vitals(35.0);
|
||||
assert!(matches!(
|
||||
TriageService::calculate_triage(&fast),
|
||||
TriageStatus::Immediate
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_priority_from_triage() {
|
||||
assert_eq!(
|
||||
PriorityCalculator::from_triage(&TriageStatus::Immediate),
|
||||
Priority::Critical
|
||||
);
|
||||
assert_eq!(
|
||||
PriorityCalculator::from_triage(&TriageStatus::Delayed),
|
||||
Priority::High
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mass_casualty_assessment() {
|
||||
let survivors: Vec<Survivor> = (0..10)
|
||||
.map(|i| {
|
||||
let rate = if i < 3 { 35.0 } else if i < 6 { 16.0 } else { 18.0 };
|
||||
Survivor::new(
|
||||
ScanZoneId::new(),
|
||||
create_test_vitals(rate),
|
||||
None,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let survivor_refs: Vec<&Survivor> = survivors.iter().collect();
|
||||
let assessment = TriageService::evaluate_mass_casualty(&survivor_refs);
|
||||
|
||||
assert_eq!(assessment.total, 10);
|
||||
assert!(assessment.living() >= assessment.needs_rescue());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_priority_with_factors() {
|
||||
// Deteriorating patient should be upgraded
|
||||
let priority = PriorityCalculator::calculate_with_factors(
|
||||
&TriageStatus::Delayed,
|
||||
true,
|
||||
0,
|
||||
None,
|
||||
);
|
||||
assert_eq!(priority, Priority::Critical);
|
||||
|
||||
// Deep burial should upgrade
|
||||
let priority = PriorityCalculator::calculate_with_factors(
|
||||
&TriageStatus::Delayed,
|
||||
false,
|
||||
0,
|
||||
Some(4.0),
|
||||
);
|
||||
assert_eq!(priority, Priority::Critical);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,276 +0,0 @@
|
||||
//! API error types and handling for the MAT REST API.
|
||||
//!
|
||||
//! This module provides a unified error type that maps to appropriate HTTP status codes
|
||||
//! and JSON error responses for the API.
|
||||
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// API error type that converts to HTTP responses.
|
||||
///
|
||||
/// All errors include:
|
||||
/// - An HTTP status code
|
||||
/// - A machine-readable error code
|
||||
/// - A human-readable message
|
||||
/// - Optional additional details
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ApiError {
|
||||
/// Resource not found (404)
|
||||
#[error("Resource not found: {resource_type} with id {id}")]
|
||||
NotFound {
|
||||
resource_type: String,
|
||||
id: String,
|
||||
},
|
||||
|
||||
/// Invalid request data (400)
|
||||
#[error("Bad request: {message}")]
|
||||
BadRequest {
|
||||
message: String,
|
||||
#[source]
|
||||
source: Option<Box<dyn std::error::Error + Send + Sync>>,
|
||||
},
|
||||
|
||||
/// Validation error (422)
|
||||
#[error("Validation failed: {message}")]
|
||||
ValidationError {
|
||||
message: String,
|
||||
field: Option<String>,
|
||||
},
|
||||
|
||||
/// Conflict with existing resource (409)
|
||||
#[error("Conflict: {message}")]
|
||||
Conflict {
|
||||
message: String,
|
||||
},
|
||||
|
||||
/// Resource is in invalid state for operation (409)
|
||||
#[error("Invalid state: {message}")]
|
||||
InvalidState {
|
||||
message: String,
|
||||
current_state: String,
|
||||
},
|
||||
|
||||
/// Internal server error (500)
|
||||
#[error("Internal error: {message}")]
|
||||
Internal {
|
||||
message: String,
|
||||
#[source]
|
||||
source: Option<Box<dyn std::error::Error + Send + Sync>>,
|
||||
},
|
||||
|
||||
/// Service unavailable (503)
|
||||
#[error("Service unavailable: {message}")]
|
||||
ServiceUnavailable {
|
||||
message: String,
|
||||
},
|
||||
|
||||
/// Domain error from business logic
|
||||
#[error("Domain error: {0}")]
|
||||
Domain(#[from] crate::MatError),
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
/// Create a not found error for an event.
|
||||
pub fn event_not_found(id: Uuid) -> Self {
|
||||
Self::NotFound {
|
||||
resource_type: "DisasterEvent".to_string(),
|
||||
id: id.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a not found error for a zone.
|
||||
pub fn zone_not_found(id: Uuid) -> Self {
|
||||
Self::NotFound {
|
||||
resource_type: "ScanZone".to_string(),
|
||||
id: id.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a not found error for a survivor.
|
||||
pub fn survivor_not_found(id: Uuid) -> Self {
|
||||
Self::NotFound {
|
||||
resource_type: "Survivor".to_string(),
|
||||
id: id.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a not found error for an alert.
|
||||
pub fn alert_not_found(id: Uuid) -> Self {
|
||||
Self::NotFound {
|
||||
resource_type: "Alert".to_string(),
|
||||
id: id.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a bad request error.
|
||||
pub fn bad_request(message: impl Into<String>) -> Self {
|
||||
Self::BadRequest {
|
||||
message: message.into(),
|
||||
source: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a validation error.
|
||||
pub fn validation(message: impl Into<String>, field: Option<String>) -> Self {
|
||||
Self::ValidationError {
|
||||
message: message.into(),
|
||||
field,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an internal error.
|
||||
pub fn internal(message: impl Into<String>) -> Self {
|
||||
Self::Internal {
|
||||
message: message.into(),
|
||||
source: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the HTTP status code for this error.
|
||||
pub fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
Self::NotFound { .. } => StatusCode::NOT_FOUND,
|
||||
Self::BadRequest { .. } => StatusCode::BAD_REQUEST,
|
||||
Self::ValidationError { .. } => StatusCode::UNPROCESSABLE_ENTITY,
|
||||
Self::Conflict { .. } => StatusCode::CONFLICT,
|
||||
Self::InvalidState { .. } => StatusCode::CONFLICT,
|
||||
Self::Internal { .. } => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::ServiceUnavailable { .. } => StatusCode::SERVICE_UNAVAILABLE,
|
||||
Self::Domain(_) => StatusCode::BAD_REQUEST,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the error code for this error.
|
||||
pub fn error_code(&self) -> &'static str {
|
||||
match self {
|
||||
Self::NotFound { .. } => "NOT_FOUND",
|
||||
Self::BadRequest { .. } => "BAD_REQUEST",
|
||||
Self::ValidationError { .. } => "VALIDATION_ERROR",
|
||||
Self::Conflict { .. } => "CONFLICT",
|
||||
Self::InvalidState { .. } => "INVALID_STATE",
|
||||
Self::Internal { .. } => "INTERNAL_ERROR",
|
||||
Self::ServiceUnavailable { .. } => "SERVICE_UNAVAILABLE",
|
||||
Self::Domain(_) => "DOMAIN_ERROR",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON error response body.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ErrorResponse {
|
||||
/// Machine-readable error code
|
||||
pub code: String,
|
||||
/// Human-readable error message
|
||||
pub message: String,
|
||||
/// Additional error details
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub details: Option<ErrorDetails>,
|
||||
/// Request ID for tracing (if available)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub request_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Additional error details.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ErrorDetails {
|
||||
/// Resource type involved
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resource_type: Option<String>,
|
||||
/// Resource ID involved
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resource_id: Option<String>,
|
||||
/// Field that caused the error
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub field: Option<String>,
|
||||
/// Current state (for state errors)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub current_state: Option<String>,
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let status = self.status_code();
|
||||
let code = self.error_code().to_string();
|
||||
let message = self.to_string();
|
||||
|
||||
let details = match &self {
|
||||
ApiError::NotFound { resource_type, id } => Some(ErrorDetails {
|
||||
resource_type: Some(resource_type.clone()),
|
||||
resource_id: Some(id.clone()),
|
||||
field: None,
|
||||
current_state: None,
|
||||
}),
|
||||
ApiError::ValidationError { field, .. } => Some(ErrorDetails {
|
||||
resource_type: None,
|
||||
resource_id: None,
|
||||
field: field.clone(),
|
||||
current_state: None,
|
||||
}),
|
||||
ApiError::InvalidState { current_state, .. } => Some(ErrorDetails {
|
||||
resource_type: None,
|
||||
resource_id: None,
|
||||
field: None,
|
||||
current_state: Some(current_state.clone()),
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Log errors
|
||||
match &self {
|
||||
ApiError::Internal { source, .. } | ApiError::BadRequest { source, .. } => {
|
||||
if let Some(src) = source {
|
||||
tracing::error!(error = %self, source = %src, "API error");
|
||||
} else {
|
||||
tracing::error!(error = %self, "API error");
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!(error = %self, "API error");
|
||||
}
|
||||
}
|
||||
|
||||
let body = ErrorResponse {
|
||||
code,
|
||||
message,
|
||||
details,
|
||||
request_id: None, // Would be populated from request extension
|
||||
};
|
||||
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
/// Result type alias for API handlers.
|
||||
pub type ApiResult<T> = Result<T, ApiError>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_error_status_codes() {
|
||||
let not_found = ApiError::event_not_found(Uuid::new_v4());
|
||||
assert_eq!(not_found.status_code(), StatusCode::NOT_FOUND);
|
||||
|
||||
let bad_request = ApiError::bad_request("test");
|
||||
assert_eq!(bad_request.status_code(), StatusCode::BAD_REQUEST);
|
||||
|
||||
let internal = ApiError::internal("test");
|
||||
assert_eq!(internal.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_codes() {
|
||||
let not_found = ApiError::event_not_found(Uuid::new_v4());
|
||||
assert_eq!(not_found.error_code(), "NOT_FOUND");
|
||||
|
||||
let validation = ApiError::validation("test", Some("field".to_string()));
|
||||
assert_eq!(validation.error_code(), "VALIDATION_ERROR");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,85 +0,0 @@
|
||||
//! REST API endpoints for WiFi-DensePose MAT disaster response monitoring.
|
||||
//!
|
||||
//! This module provides a complete REST API and WebSocket interface for
|
||||
//! managing disaster events, zones, survivors, and alerts in real-time.
|
||||
//!
|
||||
//! ## Endpoints
|
||||
//!
|
||||
//! ### Disaster Events
|
||||
//! - `GET /api/v1/mat/events` - List all disaster events
|
||||
//! - `POST /api/v1/mat/events` - Create new disaster event
|
||||
//! - `GET /api/v1/mat/events/{id}` - Get event details
|
||||
//!
|
||||
//! ### Zones
|
||||
//! - `GET /api/v1/mat/events/{id}/zones` - List zones for event
|
||||
//! - `POST /api/v1/mat/events/{id}/zones` - Add zone to event
|
||||
//!
|
||||
//! ### Survivors
|
||||
//! - `GET /api/v1/mat/events/{id}/survivors` - List survivors in event
|
||||
//!
|
||||
//! ### Alerts
|
||||
//! - `GET /api/v1/mat/events/{id}/alerts` - List alerts for event
|
||||
//! - `POST /api/v1/mat/alerts/{id}/acknowledge` - Acknowledge alert
|
||||
//!
|
||||
//! ### Scan Control
|
||||
//! - `POST /api/v1/mat/scan/csi` - Push raw CSI data into detection pipeline
|
||||
//! - `POST /api/v1/mat/scan/control` - Start/stop/pause/resume scanning
|
||||
//! - `GET /api/v1/mat/scan/status` - Get detection pipeline status
|
||||
//!
|
||||
//! ### Domain Events
|
||||
//! - `GET /api/v1/mat/events/domain` - List domain events from event store
|
||||
//!
|
||||
//! ### WebSocket
|
||||
//! - `WS /ws/mat/stream` - Real-time survivor and alert stream
|
||||
|
||||
pub mod dto;
|
||||
pub mod handlers;
|
||||
pub mod error;
|
||||
pub mod state;
|
||||
pub mod websocket;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
routing::{get, post},
|
||||
};
|
||||
|
||||
pub use dto::*;
|
||||
pub use error::ApiError;
|
||||
pub use state::AppState;
|
||||
|
||||
/// Create the MAT API router with all endpoints.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// use wifi_densepose_mat::api::{create_router, AppState};
|
||||
///
|
||||
/// #[tokio::main]
|
||||
/// async fn main() {
|
||||
/// let state = AppState::new();
|
||||
/// let app = create_router(state);
|
||||
/// // ... serve with axum
|
||||
/// }
|
||||
/// ```
|
||||
pub fn create_router(state: AppState) -> Router {
|
||||
Router::new()
|
||||
// Event endpoints
|
||||
.route("/api/v1/mat/events", get(handlers::list_events).post(handlers::create_event))
|
||||
.route("/api/v1/mat/events/:event_id", get(handlers::get_event))
|
||||
// Zone endpoints
|
||||
.route("/api/v1/mat/events/:event_id/zones", get(handlers::list_zones).post(handlers::add_zone))
|
||||
// Survivor endpoints
|
||||
.route("/api/v1/mat/events/:event_id/survivors", get(handlers::list_survivors))
|
||||
// Alert endpoints
|
||||
.route("/api/v1/mat/events/:event_id/alerts", get(handlers::list_alerts))
|
||||
.route("/api/v1/mat/alerts/:alert_id/acknowledge", post(handlers::acknowledge_alert))
|
||||
// Scan control endpoints (ADR-001: CSI data ingestion + pipeline control)
|
||||
.route("/api/v1/mat/scan/csi", post(handlers::push_csi_data))
|
||||
.route("/api/v1/mat/scan/control", post(handlers::scan_control))
|
||||
.route("/api/v1/mat/scan/status", get(handlers::pipeline_status))
|
||||
// Domain event store endpoint
|
||||
.route("/api/v1/mat/events/domain", get(handlers::list_domain_events))
|
||||
// WebSocket endpoint
|
||||
.route("/ws/mat/stream", get(websocket::ws_handler))
|
||||
.with_state(state)
|
||||
}
|
||||
@@ -1,291 +0,0 @@
|
||||
//! Application state for the MAT REST API.
|
||||
//!
|
||||
//! This module provides the shared state that is passed to all API handlers.
|
||||
//! It contains repositories, services, and real-time event broadcasting.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use parking_lot::RwLock;
|
||||
use tokio::sync::broadcast;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::domain::{
|
||||
DisasterEvent, Alert,
|
||||
events::{EventStore, InMemoryEventStore},
|
||||
};
|
||||
use crate::detection::{DetectionPipeline, DetectionConfig};
|
||||
use super::dto::WebSocketMessage;
|
||||
|
||||
/// Shared application state for the API.
|
||||
///
|
||||
/// This is cloned for each request handler and provides thread-safe
|
||||
/// access to shared resources.
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
inner: Arc<AppStateInner>,
|
||||
}
|
||||
|
||||
/// Inner state (not cloned, shared via Arc).
|
||||
struct AppStateInner {
|
||||
/// In-memory event repository
|
||||
events: RwLock<HashMap<Uuid, DisasterEvent>>,
|
||||
/// In-memory alert repository
|
||||
alerts: RwLock<HashMap<Uuid, AlertWithEventId>>,
|
||||
/// Broadcast channel for real-time updates
|
||||
broadcast_tx: broadcast::Sender<WebSocketMessage>,
|
||||
/// Configuration
|
||||
config: ApiConfig,
|
||||
/// Shared detection pipeline for CSI data push
|
||||
detection_pipeline: Arc<DetectionPipeline>,
|
||||
/// Domain event store
|
||||
event_store: Arc<dyn EventStore>,
|
||||
/// Scanning state flag
|
||||
scanning: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
/// Alert with its associated event ID for lookup.
|
||||
#[derive(Clone)]
|
||||
pub struct AlertWithEventId {
|
||||
pub alert: Alert,
|
||||
pub event_id: Uuid,
|
||||
}
|
||||
|
||||
/// API configuration.
|
||||
#[derive(Clone)]
|
||||
pub struct ApiConfig {
|
||||
/// Maximum number of events to store
|
||||
pub max_events: usize,
|
||||
/// Maximum survivors per event
|
||||
pub max_survivors_per_event: usize,
|
||||
/// Broadcast channel capacity
|
||||
pub broadcast_capacity: usize,
|
||||
}
|
||||
|
||||
impl Default for ApiConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_events: 1000,
|
||||
max_survivors_per_event: 10000,
|
||||
broadcast_capacity: 1024,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// Create a new application state with default configuration.
|
||||
pub fn new() -> Self {
|
||||
Self::with_config(ApiConfig::default())
|
||||
}
|
||||
|
||||
/// Create a new application state with custom configuration.
|
||||
pub fn with_config(config: ApiConfig) -> Self {
|
||||
let (broadcast_tx, _) = broadcast::channel(config.broadcast_capacity);
|
||||
let detection_pipeline = Arc::new(DetectionPipeline::new(DetectionConfig::default()));
|
||||
let event_store: Arc<dyn EventStore> = Arc::new(InMemoryEventStore::new());
|
||||
|
||||
Self {
|
||||
inner: Arc::new(AppStateInner {
|
||||
events: RwLock::new(HashMap::new()),
|
||||
alerts: RwLock::new(HashMap::new()),
|
||||
broadcast_tx,
|
||||
config,
|
||||
detection_pipeline,
|
||||
event_store,
|
||||
scanning: std::sync::atomic::AtomicBool::new(false),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the detection pipeline for CSI data ingestion.
|
||||
pub fn detection_pipeline(&self) -> &DetectionPipeline {
|
||||
&self.inner.detection_pipeline
|
||||
}
|
||||
|
||||
/// Get the domain event store.
|
||||
pub fn event_store(&self) -> &Arc<dyn EventStore> {
|
||||
&self.inner.event_store
|
||||
}
|
||||
|
||||
/// Get scanning state.
|
||||
pub fn is_scanning(&self) -> bool {
|
||||
self.inner.scanning.load(std::sync::atomic::Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Set scanning state.
|
||||
pub fn set_scanning(&self, state: bool) {
|
||||
self.inner.scanning.store(state, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Event Operations
|
||||
// ========================================================================
|
||||
|
||||
/// Store a disaster event.
|
||||
pub fn store_event(&self, event: DisasterEvent) -> Uuid {
|
||||
let id = *event.id().as_uuid();
|
||||
let mut events = self.inner.events.write();
|
||||
|
||||
// Check capacity
|
||||
if events.len() >= self.inner.config.max_events {
|
||||
// Remove oldest closed event
|
||||
let oldest_closed = events
|
||||
.iter()
|
||||
.filter(|(_, e)| matches!(e.status(), crate::EventStatus::Closed))
|
||||
.min_by_key(|(_, e)| e.start_time())
|
||||
.map(|(id, _)| *id);
|
||||
|
||||
if let Some(old_id) = oldest_closed {
|
||||
events.remove(&old_id);
|
||||
}
|
||||
}
|
||||
|
||||
events.insert(id, event);
|
||||
id
|
||||
}
|
||||
|
||||
/// Get an event by ID.
|
||||
pub fn get_event(&self, id: Uuid) -> Option<DisasterEvent> {
|
||||
self.inner.events.read().get(&id).cloned()
|
||||
}
|
||||
|
||||
/// Get mutable access to an event (for updates).
|
||||
pub fn update_event<F, R>(&self, id: Uuid, f: F) -> Option<R>
|
||||
where
|
||||
F: FnOnce(&mut DisasterEvent) -> R,
|
||||
{
|
||||
let mut events = self.inner.events.write();
|
||||
events.get_mut(&id).map(f)
|
||||
}
|
||||
|
||||
/// List all events.
|
||||
pub fn list_events(&self) -> Vec<DisasterEvent> {
|
||||
self.inner.events.read().values().cloned().collect()
|
||||
}
|
||||
|
||||
/// Get event count.
|
||||
pub fn event_count(&self) -> usize {
|
||||
self.inner.events.read().len()
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Alert Operations
|
||||
// ========================================================================
|
||||
|
||||
/// Store an alert.
|
||||
pub fn store_alert(&self, alert: Alert, event_id: Uuid) -> Uuid {
|
||||
let id = *alert.id().as_uuid();
|
||||
let mut alerts = self.inner.alerts.write();
|
||||
alerts.insert(id, AlertWithEventId { alert, event_id });
|
||||
id
|
||||
}
|
||||
|
||||
/// Get an alert by ID.
|
||||
pub fn get_alert(&self, id: Uuid) -> Option<AlertWithEventId> {
|
||||
self.inner.alerts.read().get(&id).cloned()
|
||||
}
|
||||
|
||||
/// Update an alert.
|
||||
pub fn update_alert<F, R>(&self, id: Uuid, f: F) -> Option<R>
|
||||
where
|
||||
F: FnOnce(&mut Alert) -> R,
|
||||
{
|
||||
let mut alerts = self.inner.alerts.write();
|
||||
alerts.get_mut(&id).map(|a| f(&mut a.alert))
|
||||
}
|
||||
|
||||
/// List alerts for an event.
|
||||
pub fn list_alerts_for_event(&self, event_id: Uuid) -> Vec<Alert> {
|
||||
self.inner
|
||||
.alerts
|
||||
.read()
|
||||
.values()
|
||||
.filter(|a| a.event_id == event_id)
|
||||
.map(|a| a.alert.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Broadcasting
|
||||
// ========================================================================
|
||||
|
||||
/// Get a receiver for real-time updates.
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<WebSocketMessage> {
|
||||
self.inner.broadcast_tx.subscribe()
|
||||
}
|
||||
|
||||
/// Broadcast a message to all subscribers.
|
||||
pub fn broadcast(&self, message: WebSocketMessage) {
|
||||
// Ignore send errors (no subscribers)
|
||||
let _ = self.inner.broadcast_tx.send(message);
|
||||
}
|
||||
|
||||
/// Get the number of active subscribers.
|
||||
pub fn subscriber_count(&self) -> usize {
|
||||
self.inner.broadcast_tx.receiver_count()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AppState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::domain::{DisasterType, DisasterEvent};
|
||||
use geo::Point;
|
||||
|
||||
#[test]
|
||||
fn test_store_and_get_event() {
|
||||
let state = AppState::new();
|
||||
let event = DisasterEvent::new(
|
||||
DisasterType::Earthquake,
|
||||
Point::new(-122.4194, 37.7749),
|
||||
"Test earthquake",
|
||||
);
|
||||
let id = *event.id().as_uuid();
|
||||
|
||||
state.store_event(event);
|
||||
|
||||
let retrieved = state.get_event(id);
|
||||
assert!(retrieved.is_some());
|
||||
assert_eq!(retrieved.unwrap().id().as_uuid(), &id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_event() {
|
||||
let state = AppState::new();
|
||||
let event = DisasterEvent::new(
|
||||
DisasterType::Earthquake,
|
||||
Point::new(0.0, 0.0),
|
||||
"Test",
|
||||
);
|
||||
let id = *event.id().as_uuid();
|
||||
state.store_event(event);
|
||||
|
||||
let result = state.update_event(id, |e| {
|
||||
e.set_status(crate::EventStatus::Suspended);
|
||||
true
|
||||
});
|
||||
|
||||
assert!(result.unwrap());
|
||||
let updated = state.get_event(id).unwrap();
|
||||
assert!(matches!(updated.status(), crate::EventStatus::Suspended));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_broadcast_subscribe() {
|
||||
let state = AppState::new();
|
||||
let mut rx = state.subscribe();
|
||||
|
||||
state.broadcast(WebSocketMessage::Heartbeat {
|
||||
timestamp: chrono::Utc::now(),
|
||||
});
|
||||
|
||||
// Try to receive (in async context this would work)
|
||||
assert_eq!(state.subscriber_count(), 1);
|
||||
}
|
||||
}
|
||||
@@ -1,330 +0,0 @@
|
||||
//! WebSocket handler for real-time survivor and alert streaming.
|
||||
//!
|
||||
//! This module provides a WebSocket endpoint that streams real-time updates
|
||||
//! for survivor detections, status changes, and alerts.
|
||||
//!
|
||||
//! ## Protocol
|
||||
//!
|
||||
//! Clients connect to `/ws/mat/stream` and receive JSON-formatted messages.
|
||||
//!
|
||||
//! ### Message Types
|
||||
//!
|
||||
//! - `survivor_detected` - New survivor found
|
||||
//! - `survivor_updated` - Survivor status/vitals changed
|
||||
//! - `survivor_lost` - Survivor signal lost
|
||||
//! - `alert_created` - New alert generated
|
||||
//! - `alert_updated` - Alert status changed
|
||||
//! - `zone_scan_complete` - Zone scan finished
|
||||
//! - `event_status_changed` - Event status changed
|
||||
//! - `heartbeat` - Keep-alive ping
|
||||
//! - `error` - Error message
|
||||
//!
|
||||
//! ### Client Commands
|
||||
//!
|
||||
//! Clients can send JSON commands:
|
||||
//! - `{"action": "subscribe", "event_id": "..."}`
|
||||
//! - `{"action": "unsubscribe", "event_id": "..."}`
|
||||
//! - `{"action": "subscribe_all"}`
|
||||
//! - `{"action": "get_state", "event_id": "..."}`
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::{
|
||||
extract::{
|
||||
ws::{Message, WebSocket, WebSocketUpgrade},
|
||||
State,
|
||||
},
|
||||
response::Response,
|
||||
};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use parking_lot::Mutex;
|
||||
use tokio::sync::broadcast;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::dto::{WebSocketMessage, WebSocketRequest};
|
||||
use super::state::AppState;
|
||||
|
||||
/// WebSocket connection handler.
|
||||
///
|
||||
/// # OpenAPI Specification
|
||||
///
|
||||
/// ```yaml
|
||||
/// /ws/mat/stream:
|
||||
/// get:
|
||||
/// summary: Real-time event stream
|
||||
/// description: |
|
||||
/// WebSocket endpoint for real-time updates on survivors and alerts.
|
||||
///
|
||||
/// ## Connection
|
||||
///
|
||||
/// Connect using a WebSocket client to receive real-time updates.
|
||||
///
|
||||
/// ## Messages
|
||||
///
|
||||
/// All messages are JSON-formatted with a "type" field indicating
|
||||
/// the message type.
|
||||
///
|
||||
/// ## Subscriptions
|
||||
///
|
||||
/// By default, clients receive updates for all events. Send a
|
||||
/// subscribe/unsubscribe command to filter to specific events.
|
||||
/// tags: [WebSocket]
|
||||
/// responses:
|
||||
/// 101:
|
||||
/// description: WebSocket connection established
|
||||
/// ```
|
||||
#[tracing::instrument(skip(state, ws))]
|
||||
pub async fn ws_handler(
|
||||
State(state): State<AppState>,
|
||||
ws: WebSocketUpgrade,
|
||||
) -> Response {
|
||||
ws.on_upgrade(move |socket| handle_socket(socket, state))
|
||||
}
|
||||
|
||||
/// Handle an established WebSocket connection.
|
||||
async fn handle_socket(socket: WebSocket, state: AppState) {
|
||||
let (mut sender, mut receiver) = socket.split();
|
||||
|
||||
// Subscription state for this connection
|
||||
let subscriptions: Arc<Mutex<SubscriptionState>> = Arc::new(Mutex::new(SubscriptionState::new()));
|
||||
|
||||
// Subscribe to broadcast channel
|
||||
let mut broadcast_rx = state.subscribe();
|
||||
|
||||
// Spawn task to forward broadcast messages to client
|
||||
let subs_clone = subscriptions.clone();
|
||||
let forward_task = tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
// Receive from broadcast channel
|
||||
result = broadcast_rx.recv() => {
|
||||
match result {
|
||||
Ok(msg) => {
|
||||
// Check if this message matches subscription filter
|
||||
if subs_clone.lock().should_receive(&msg) {
|
||||
if let Ok(json) = serde_json::to_string(&msg) {
|
||||
if sender.send(Message::Text(json)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
tracing::warn!(lagged = n, "WebSocket client lagged, messages dropped");
|
||||
// Send error notification
|
||||
let error = WebSocketMessage::Error {
|
||||
code: "MESSAGES_DROPPED".to_string(),
|
||||
message: format!("{} messages were dropped due to slow client", n),
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&error) {
|
||||
if sender.send(Message::Text(json)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Periodic heartbeat
|
||||
_ = tokio::time::sleep(Duration::from_secs(30)) => {
|
||||
let heartbeat = WebSocketMessage::Heartbeat {
|
||||
timestamp: chrono::Utc::now(),
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&heartbeat) {
|
||||
if sender.send(Message::Ping(json.into_bytes())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle incoming messages from client
|
||||
let subs_clone = subscriptions.clone();
|
||||
let state_clone = state.clone();
|
||||
while let Some(Ok(msg)) = receiver.next().await {
|
||||
match msg {
|
||||
Message::Text(text) => {
|
||||
// Parse and handle client command
|
||||
if let Err(e) = handle_client_message(&text, &subs_clone, &state_clone).await {
|
||||
tracing::warn!(error = %e, "Failed to handle WebSocket message");
|
||||
}
|
||||
}
|
||||
Message::Binary(_) => {
|
||||
// Binary messages not supported
|
||||
tracing::debug!("Ignoring binary WebSocket message");
|
||||
}
|
||||
Message::Ping(data) => {
|
||||
// Pong handled automatically by axum
|
||||
tracing::trace!(len = data.len(), "Received ping");
|
||||
}
|
||||
Message::Pong(_) => {
|
||||
// Heartbeat response
|
||||
tracing::trace!("Received pong");
|
||||
}
|
||||
Message::Close(_) => {
|
||||
tracing::debug!("Client closed WebSocket connection");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
forward_task.abort();
|
||||
tracing::debug!("WebSocket connection closed");
|
||||
}
|
||||
|
||||
/// Handle a client message (subscription commands).
|
||||
async fn handle_client_message(
|
||||
text: &str,
|
||||
subscriptions: &Arc<Mutex<SubscriptionState>>,
|
||||
state: &AppState,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let request: WebSocketRequest = serde_json::from_str(text)?;
|
||||
|
||||
match request {
|
||||
WebSocketRequest::Subscribe { event_id } => {
|
||||
// Verify event exists
|
||||
if state.get_event(event_id).is_some() {
|
||||
subscriptions.lock().subscribe(event_id);
|
||||
tracing::debug!(event_id = %event_id, "Client subscribed to event");
|
||||
}
|
||||
}
|
||||
WebSocketRequest::Unsubscribe { event_id } => {
|
||||
subscriptions.lock().unsubscribe(&event_id);
|
||||
tracing::debug!(event_id = %event_id, "Client unsubscribed from event");
|
||||
}
|
||||
WebSocketRequest::SubscribeAll => {
|
||||
subscriptions.lock().subscribe_all();
|
||||
tracing::debug!("Client subscribed to all events");
|
||||
}
|
||||
WebSocketRequest::GetState { event_id } => {
|
||||
// This would send current state - simplified for now
|
||||
tracing::debug!(event_id = %event_id, "Client requested state");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tracks subscription state for a WebSocket connection.
|
||||
struct SubscriptionState {
|
||||
/// Subscribed event IDs (empty = all events)
|
||||
event_ids: HashSet<Uuid>,
|
||||
/// Whether subscribed to all events
|
||||
all_events: bool,
|
||||
}
|
||||
|
||||
impl SubscriptionState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
event_ids: HashSet::new(),
|
||||
all_events: true, // Default to receiving all events
|
||||
}
|
||||
}
|
||||
|
||||
fn subscribe(&mut self, event_id: Uuid) {
|
||||
self.all_events = false;
|
||||
self.event_ids.insert(event_id);
|
||||
}
|
||||
|
||||
fn unsubscribe(&mut self, event_id: &Uuid) {
|
||||
self.event_ids.remove(event_id);
|
||||
if self.event_ids.is_empty() {
|
||||
self.all_events = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn subscribe_all(&mut self) {
|
||||
self.all_events = true;
|
||||
self.event_ids.clear();
|
||||
}
|
||||
|
||||
fn should_receive(&self, msg: &WebSocketMessage) -> bool {
|
||||
if self.all_events {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Extract event_id from message and check subscription
|
||||
let event_id = match msg {
|
||||
WebSocketMessage::SurvivorDetected { event_id, .. } => Some(*event_id),
|
||||
WebSocketMessage::SurvivorUpdated { event_id, .. } => Some(*event_id),
|
||||
WebSocketMessage::SurvivorLost { event_id, .. } => Some(*event_id),
|
||||
WebSocketMessage::AlertCreated { event_id, .. } => Some(*event_id),
|
||||
WebSocketMessage::AlertUpdated { event_id, .. } => Some(*event_id),
|
||||
WebSocketMessage::ZoneScanComplete { event_id, .. } => Some(*event_id),
|
||||
WebSocketMessage::EventStatusChanged { event_id, .. } => Some(*event_id),
|
||||
WebSocketMessage::Heartbeat { .. } => None, // Always receive
|
||||
WebSocketMessage::Error { .. } => None, // Always receive
|
||||
};
|
||||
|
||||
match event_id {
|
||||
Some(id) => self.event_ids.contains(&id),
|
||||
None => true, // Non-event-specific messages always sent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_subscription_state() {
|
||||
let mut state = SubscriptionState::new();
|
||||
|
||||
// Default is all events
|
||||
assert!(state.all_events);
|
||||
|
||||
// Subscribe to specific event
|
||||
let event_id = Uuid::new_v4();
|
||||
state.subscribe(event_id);
|
||||
assert!(!state.all_events);
|
||||
assert!(state.event_ids.contains(&event_id));
|
||||
|
||||
// Unsubscribe returns to all events
|
||||
state.unsubscribe(&event_id);
|
||||
assert!(state.all_events);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_receive() {
|
||||
let mut state = SubscriptionState::new();
|
||||
let event_id = Uuid::new_v4();
|
||||
let other_id = Uuid::new_v4();
|
||||
|
||||
// All events mode - receive everything
|
||||
let msg = WebSocketMessage::Heartbeat {
|
||||
timestamp: chrono::Utc::now(),
|
||||
};
|
||||
assert!(state.should_receive(&msg));
|
||||
|
||||
// Subscribe to specific event
|
||||
state.subscribe(event_id);
|
||||
|
||||
// Should receive messages for subscribed event
|
||||
let msg = WebSocketMessage::SurvivorLost {
|
||||
event_id,
|
||||
survivor_id: Uuid::new_v4(),
|
||||
};
|
||||
assert!(state.should_receive(&msg));
|
||||
|
||||
// Should not receive messages for other events
|
||||
let msg = WebSocketMessage::SurvivorLost {
|
||||
event_id: other_id,
|
||||
survivor_id: Uuid::new_v4(),
|
||||
};
|
||||
assert!(!state.should_receive(&msg));
|
||||
|
||||
// Heartbeats always received
|
||||
let msg = WebSocketMessage::Heartbeat {
|
||||
timestamp: chrono::Utc::now(),
|
||||
};
|
||||
assert!(state.should_receive(&msg));
|
||||
}
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
//! Breathing pattern detection from CSI signals.
|
||||
|
||||
use crate::domain::{BreathingPattern, BreathingType, ConfidenceScore};
|
||||
|
||||
/// Configuration for breathing detection
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BreathingDetectorConfig {
|
||||
/// Minimum breathing rate to detect (breaths per minute)
|
||||
pub min_rate_bpm: f32,
|
||||
/// Maximum breathing rate to detect
|
||||
pub max_rate_bpm: f32,
|
||||
/// Minimum signal amplitude to consider
|
||||
pub min_amplitude: f32,
|
||||
/// Window size for FFT analysis (samples)
|
||||
pub window_size: usize,
|
||||
/// Overlap between windows (0.0-1.0)
|
||||
pub window_overlap: f32,
|
||||
/// Confidence threshold
|
||||
pub confidence_threshold: f32,
|
||||
}
|
||||
|
||||
impl Default for BreathingDetectorConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
min_rate_bpm: 4.0, // Very slow breathing
|
||||
max_rate_bpm: 40.0, // Fast breathing (distressed)
|
||||
min_amplitude: 0.1,
|
||||
window_size: 512,
|
||||
window_overlap: 0.5,
|
||||
confidence_threshold: 0.3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Detector for breathing patterns in CSI signals
|
||||
pub struct BreathingDetector {
|
||||
config: BreathingDetectorConfig,
|
||||
}
|
||||
|
||||
impl BreathingDetector {
|
||||
/// Create a new breathing detector
|
||||
pub fn new(config: BreathingDetectorConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
/// Create with default configuration
|
||||
pub fn with_defaults() -> Self {
|
||||
Self::new(BreathingDetectorConfig::default())
|
||||
}
|
||||
|
||||
/// Detect breathing pattern from CSI amplitude variations
|
||||
///
|
||||
/// Breathing causes periodic chest movement that modulates the WiFi signal.
|
||||
/// We detect this by looking for periodic variations in the 0.1-0.67 Hz range
|
||||
/// (corresponding to 6-40 breaths per minute).
|
||||
pub fn detect(&self, csi_amplitudes: &[f64], sample_rate: f64) -> Option<BreathingPattern> {
|
||||
if csi_amplitudes.len() < self.config.window_size {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Calculate the frequency spectrum
|
||||
let spectrum = self.compute_spectrum(csi_amplitudes);
|
||||
|
||||
// Find the dominant frequency in the breathing range
|
||||
let min_freq = self.config.min_rate_bpm as f64 / 60.0;
|
||||
let max_freq = self.config.max_rate_bpm as f64 / 60.0;
|
||||
|
||||
let (dominant_freq, amplitude) = self.find_dominant_frequency(
|
||||
&spectrum,
|
||||
sample_rate,
|
||||
min_freq,
|
||||
max_freq,
|
||||
)?;
|
||||
|
||||
// Convert to BPM
|
||||
let rate_bpm = (dominant_freq * 60.0) as f32;
|
||||
|
||||
// Check amplitude threshold
|
||||
if amplitude < self.config.min_amplitude as f64 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Calculate regularity (how peaked is the spectrum)
|
||||
let regularity = self.calculate_regularity(&spectrum, dominant_freq, sample_rate);
|
||||
|
||||
// Determine breathing type based on rate and regularity
|
||||
let pattern_type = self.classify_pattern(rate_bpm, regularity);
|
||||
|
||||
// Calculate confidence
|
||||
let confidence = self.calculate_confidence(amplitude, regularity);
|
||||
|
||||
if confidence < self.config.confidence_threshold {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(BreathingPattern {
|
||||
rate_bpm,
|
||||
amplitude: amplitude as f32,
|
||||
regularity,
|
||||
pattern_type,
|
||||
})
|
||||
}
|
||||
|
||||
/// Compute frequency spectrum using FFT
|
||||
fn compute_spectrum(&self, signal: &[f64]) -> Vec<f64> {
|
||||
use rustfft::{FftPlanner, num_complex::Complex};
|
||||
|
||||
let n = signal.len().next_power_of_two();
|
||||
let mut planner = FftPlanner::new();
|
||||
let fft = planner.plan_fft_forward(n);
|
||||
|
||||
// Prepare input with zero padding
|
||||
let mut buffer: Vec<Complex<f64>> = signal
|
||||
.iter()
|
||||
.map(|&x| Complex::new(x, 0.0))
|
||||
.collect();
|
||||
buffer.resize(n, Complex::new(0.0, 0.0));
|
||||
|
||||
// Apply Hanning window
|
||||
for (i, sample) in buffer.iter_mut().enumerate().take(signal.len()) {
|
||||
let window = 0.5 * (1.0 - (2.0 * std::f64::consts::PI * i as f64 / signal.len() as f64).cos());
|
||||
*sample = Complex::new(sample.re * window, 0.0);
|
||||
}
|
||||
|
||||
fft.process(&mut buffer);
|
||||
|
||||
// Return magnitude spectrum (only positive frequencies)
|
||||
buffer.iter()
|
||||
.take(n / 2)
|
||||
.map(|c| c.norm())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Find dominant frequency in a given range
|
||||
fn find_dominant_frequency(
|
||||
&self,
|
||||
spectrum: &[f64],
|
||||
sample_rate: f64,
|
||||
min_freq: f64,
|
||||
max_freq: f64,
|
||||
) -> Option<(f64, f64)> {
|
||||
let n = spectrum.len() * 2; // Original FFT size
|
||||
let freq_resolution = sample_rate / n as f64;
|
||||
|
||||
let min_bin = (min_freq / freq_resolution).ceil() as usize;
|
||||
let max_bin = (max_freq / freq_resolution).floor() as usize;
|
||||
|
||||
if min_bin >= spectrum.len() || max_bin >= spectrum.len() || min_bin >= max_bin {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Find peak in range
|
||||
let mut max_amplitude = 0.0;
|
||||
let mut max_bin_idx = min_bin;
|
||||
|
||||
for i in min_bin..=max_bin {
|
||||
if spectrum[i] > max_amplitude {
|
||||
max_amplitude = spectrum[i];
|
||||
max_bin_idx = i;
|
||||
}
|
||||
}
|
||||
|
||||
if max_amplitude < self.config.min_amplitude as f64 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Interpolate for better frequency estimate
|
||||
let freq = max_bin_idx as f64 * freq_resolution;
|
||||
|
||||
Some((freq, max_amplitude))
|
||||
}
|
||||
|
||||
/// Calculate how regular/periodic the signal is
|
||||
fn calculate_regularity(&self, spectrum: &[f64], dominant_freq: f64, sample_rate: f64) -> f32 {
|
||||
let n = spectrum.len() * 2;
|
||||
let freq_resolution = sample_rate / n as f64;
|
||||
let peak_bin = (dominant_freq / freq_resolution).round() as usize;
|
||||
|
||||
if peak_bin >= spectrum.len() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Measure how much energy is concentrated at the peak vs spread
|
||||
let peak_power = spectrum[peak_bin];
|
||||
let total_power: f64 = spectrum.iter().sum();
|
||||
|
||||
if total_power == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Also check harmonics (2x, 3x frequency)
|
||||
let harmonic_power: f64 = [2, 3].iter()
|
||||
.filter_map(|&mult| {
|
||||
let harmonic_bin = peak_bin * mult;
|
||||
if harmonic_bin < spectrum.len() {
|
||||
Some(spectrum[harmonic_bin])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.sum();
|
||||
|
||||
((peak_power + harmonic_power * 0.5) / total_power * 3.0).min(1.0) as f32
|
||||
}
|
||||
|
||||
/// Classify the breathing pattern type
|
||||
fn classify_pattern(&self, rate_bpm: f32, regularity: f32) -> BreathingType {
|
||||
if rate_bpm < 6.0 {
|
||||
if regularity < 0.3 {
|
||||
BreathingType::Agonal
|
||||
} else {
|
||||
BreathingType::Shallow
|
||||
}
|
||||
} else if rate_bpm < 10.0 {
|
||||
BreathingType::Shallow
|
||||
} else if rate_bpm > 30.0 {
|
||||
BreathingType::Labored
|
||||
} else if regularity < 0.4 {
|
||||
BreathingType::Irregular
|
||||
} else {
|
||||
BreathingType::Normal
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate overall detection confidence
|
||||
fn calculate_confidence(&self, amplitude: f64, regularity: f32) -> f32 {
|
||||
// Combine amplitude strength and regularity
|
||||
let amplitude_score = (amplitude / 1.0).min(1.0) as f32;
|
||||
let regularity_score = regularity;
|
||||
|
||||
// Weight regularity more heavily for breathing detection
|
||||
amplitude_score * 0.4 + regularity_score * 0.6
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn generate_breathing_signal(rate_bpm: f64, sample_rate: f64, duration: f64) -> Vec<f64> {
|
||||
let num_samples = (sample_rate * duration) as usize;
|
||||
let freq = rate_bpm / 60.0;
|
||||
|
||||
(0..num_samples)
|
||||
.map(|i| {
|
||||
let t = i as f64 / sample_rate;
|
||||
(2.0 * std::f64::consts::PI * freq * t).sin()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_normal_breathing() {
|
||||
let detector = BreathingDetector::with_defaults();
|
||||
let signal = generate_breathing_signal(16.0, 100.0, 30.0);
|
||||
|
||||
let result = detector.detect(&signal, 100.0);
|
||||
assert!(result.is_some());
|
||||
|
||||
let pattern = result.unwrap();
|
||||
assert!(pattern.rate_bpm >= 14.0 && pattern.rate_bpm <= 18.0);
|
||||
assert!(matches!(pattern.pattern_type, BreathingType::Normal));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_fast_breathing() {
|
||||
let detector = BreathingDetector::with_defaults();
|
||||
let signal = generate_breathing_signal(35.0, 100.0, 30.0);
|
||||
|
||||
let result = detector.detect(&signal, 100.0);
|
||||
assert!(result.is_some());
|
||||
|
||||
let pattern = result.unwrap();
|
||||
assert!(pattern.rate_bpm > 30.0);
|
||||
assert!(matches!(pattern.pattern_type, BreathingType::Labored));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_detection_on_noise() {
|
||||
let detector = BreathingDetector::with_defaults();
|
||||
|
||||
// Random noise with low amplitude
|
||||
let signal: Vec<f64> = (0..1000)
|
||||
.map(|i| (i as f64 * 0.1).sin() * 0.01)
|
||||
.collect();
|
||||
|
||||
let result = detector.detect(&signal, 100.0);
|
||||
// Should either be None or have very low confidence
|
||||
if let Some(pattern) = result {
|
||||
assert!(pattern.amplitude < 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,327 +0,0 @@
|
||||
//! Ensemble classifier that combines breathing, heartbeat, and movement signals
|
||||
//! into a unified survivor detection confidence score.
|
||||
//!
|
||||
//! The ensemble uses weighted voting across the three detector signals:
|
||||
//! - Breathing presence is the strongest indicator of a living survivor
|
||||
//! - Heartbeat (when enabled) provides high-confidence confirmation
|
||||
//! - Movement type distinguishes active vs trapped survivors
|
||||
//!
|
||||
//! The classifier produces a single confidence score and a recommended
|
||||
//! triage status based on the combined signals.
|
||||
|
||||
use crate::domain::{
|
||||
BreathingType, MovementType, TriageStatus, VitalSignsReading,
|
||||
};
|
||||
|
||||
/// Configuration for the ensemble classifier
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EnsembleConfig {
|
||||
/// Weight for breathing signal (0.0-1.0)
|
||||
pub breathing_weight: f64,
|
||||
/// Weight for heartbeat signal (0.0-1.0)
|
||||
pub heartbeat_weight: f64,
|
||||
/// Weight for movement signal (0.0-1.0)
|
||||
pub movement_weight: f64,
|
||||
/// Minimum combined confidence to report a detection
|
||||
pub min_ensemble_confidence: f64,
|
||||
}
|
||||
|
||||
impl Default for EnsembleConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
breathing_weight: 0.50,
|
||||
heartbeat_weight: 0.30,
|
||||
movement_weight: 0.20,
|
||||
min_ensemble_confidence: 0.3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of ensemble classification
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EnsembleResult {
|
||||
/// Combined confidence score (0.0-1.0)
|
||||
pub confidence: f64,
|
||||
/// Recommended triage status based on signal analysis
|
||||
pub recommended_triage: TriageStatus,
|
||||
/// Whether breathing was detected
|
||||
pub breathing_detected: bool,
|
||||
/// Whether heartbeat was detected
|
||||
pub heartbeat_detected: bool,
|
||||
/// Whether meaningful movement was detected
|
||||
pub movement_detected: bool,
|
||||
/// Individual signal confidences
|
||||
pub signal_confidences: SignalConfidences,
|
||||
}
|
||||
|
||||
/// Individual confidence scores for each signal type
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SignalConfidences {
|
||||
/// Breathing detection confidence
|
||||
pub breathing: f64,
|
||||
/// Heartbeat detection confidence
|
||||
pub heartbeat: f64,
|
||||
/// Movement detection confidence
|
||||
pub movement: f64,
|
||||
}
|
||||
|
||||
/// Ensemble classifier combining breathing, heartbeat, and movement detectors
|
||||
pub struct EnsembleClassifier {
|
||||
config: EnsembleConfig,
|
||||
}
|
||||
|
||||
impl EnsembleClassifier {
|
||||
/// Create a new ensemble classifier
|
||||
pub fn new(config: EnsembleConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
/// Classify a vital signs reading using weighted ensemble voting.
|
||||
///
|
||||
/// The ensemble combines individual detector outputs with configured weights
|
||||
/// to produce a single confidence score and triage recommendation.
|
||||
pub fn classify(&self, reading: &VitalSignsReading) -> EnsembleResult {
|
||||
// Extract individual signal confidences (using method calls)
|
||||
let breathing_conf = reading
|
||||
.breathing
|
||||
.as_ref()
|
||||
.map(|b| b.confidence())
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let heartbeat_conf = reading
|
||||
.heartbeat
|
||||
.as_ref()
|
||||
.map(|h| h.confidence())
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let movement_conf = if reading.movement.movement_type != MovementType::None {
|
||||
reading.movement.confidence()
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Weighted ensemble confidence
|
||||
let total_weight =
|
||||
self.config.breathing_weight + self.config.heartbeat_weight + self.config.movement_weight;
|
||||
|
||||
let ensemble_confidence = if total_weight > 0.0 {
|
||||
(breathing_conf * self.config.breathing_weight
|
||||
+ heartbeat_conf * self.config.heartbeat_weight
|
||||
+ movement_conf * self.config.movement_weight)
|
||||
/ total_weight
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let breathing_detected = reading.breathing.is_some();
|
||||
let heartbeat_detected = reading.heartbeat.is_some();
|
||||
let movement_detected = reading.movement.movement_type != MovementType::None;
|
||||
|
||||
// Determine triage status from signal combination
|
||||
let recommended_triage = self.determine_triage(reading, ensemble_confidence);
|
||||
|
||||
EnsembleResult {
|
||||
confidence: ensemble_confidence,
|
||||
recommended_triage,
|
||||
breathing_detected,
|
||||
heartbeat_detected,
|
||||
movement_detected,
|
||||
signal_confidences: SignalConfidences {
|
||||
breathing: breathing_conf,
|
||||
heartbeat: heartbeat_conf,
|
||||
movement: movement_conf,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine triage status based on vital signs analysis.
|
||||
///
|
||||
/// Uses START triage protocol logic:
|
||||
/// - Immediate (Red): Breathing abnormal (agonal, apnea, too fast/slow)
|
||||
/// - Delayed (Yellow): Breathing present, limited movement
|
||||
/// - Minor (Green): Normal breathing + active movement
|
||||
/// - Deceased (Black): No vitals detected at all
|
||||
/// - Unknown: Insufficient data to classify
|
||||
///
|
||||
/// Critical patterns (Agonal, Apnea, extreme rates) are always classified
|
||||
/// as Immediate regardless of confidence level, because in disaster response
|
||||
/// a false negative (missing a survivor in distress) is far more costly
|
||||
/// than a false positive.
|
||||
fn determine_triage(
|
||||
&self,
|
||||
reading: &VitalSignsReading,
|
||||
confidence: f64,
|
||||
) -> TriageStatus {
|
||||
// CRITICAL PATTERNS: always classify regardless of confidence.
|
||||
// In disaster response, any sign of distress must be escalated.
|
||||
if let Some(ref breathing) = reading.breathing {
|
||||
match breathing.pattern_type {
|
||||
BreathingType::Agonal | BreathingType::Apnea => {
|
||||
return TriageStatus::Immediate;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let rate = breathing.rate_bpm;
|
||||
if rate < 10.0 || rate > 30.0 {
|
||||
return TriageStatus::Immediate;
|
||||
}
|
||||
}
|
||||
|
||||
// Below confidence threshold: not enough signal to classify further
|
||||
if confidence < self.config.min_ensemble_confidence {
|
||||
return TriageStatus::Unknown;
|
||||
}
|
||||
|
||||
let has_breathing = reading.breathing.is_some();
|
||||
let has_movement = reading.movement.movement_type != MovementType::None;
|
||||
|
||||
if !has_breathing && !has_movement {
|
||||
return TriageStatus::Deceased;
|
||||
}
|
||||
|
||||
if !has_breathing && has_movement {
|
||||
return TriageStatus::Immediate;
|
||||
}
|
||||
|
||||
// Has breathing above threshold - assess triage level
|
||||
if let Some(ref breathing) = reading.breathing {
|
||||
let rate = breathing.rate_bpm;
|
||||
|
||||
if rate < 12.0 || rate > 24.0 {
|
||||
if has_movement {
|
||||
return TriageStatus::Delayed;
|
||||
}
|
||||
return TriageStatus::Immediate;
|
||||
}
|
||||
|
||||
// Normal breathing rate
|
||||
if has_movement {
|
||||
return TriageStatus::Minor;
|
||||
}
|
||||
return TriageStatus::Delayed;
|
||||
}
|
||||
|
||||
TriageStatus::Unknown
|
||||
}
|
||||
|
||||
/// Get configuration
|
||||
pub fn config(&self) -> &EnsembleConfig {
|
||||
&self.config
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::domain::{
|
||||
BreathingPattern, HeartbeatSignature, MovementProfile,
|
||||
SignalStrength, ConfidenceScore,
|
||||
};
|
||||
|
||||
fn make_reading(
|
||||
breathing: Option<(f32, BreathingType)>,
|
||||
heartbeat: Option<f32>,
|
||||
movement: MovementType,
|
||||
) -> VitalSignsReading {
|
||||
let bp = breathing.map(|(rate, pattern_type)| BreathingPattern {
|
||||
rate_bpm: rate,
|
||||
pattern_type,
|
||||
amplitude: 0.9,
|
||||
regularity: 0.9,
|
||||
});
|
||||
|
||||
let hb = heartbeat.map(|rate| HeartbeatSignature {
|
||||
rate_bpm: rate,
|
||||
variability: 0.1,
|
||||
strength: SignalStrength::Moderate,
|
||||
});
|
||||
|
||||
let is_moving = movement != MovementType::None;
|
||||
let mv = MovementProfile {
|
||||
movement_type: movement,
|
||||
intensity: if is_moving { 0.5 } else { 0.0 },
|
||||
frequency: 0.0,
|
||||
is_voluntary: is_moving,
|
||||
};
|
||||
|
||||
VitalSignsReading::new(bp, hb, mv)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normal_breathing_with_movement_is_minor() {
|
||||
let classifier = EnsembleClassifier::new(EnsembleConfig::default());
|
||||
let reading = make_reading(
|
||||
Some((16.0, BreathingType::Normal)),
|
||||
None,
|
||||
MovementType::Periodic,
|
||||
);
|
||||
|
||||
let result = classifier.classify(&reading);
|
||||
assert!(result.confidence > 0.0);
|
||||
assert_eq!(result.recommended_triage, TriageStatus::Minor);
|
||||
assert!(result.breathing_detected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agonal_breathing_is_immediate() {
|
||||
let classifier = EnsembleClassifier::new(EnsembleConfig::default());
|
||||
let reading = make_reading(
|
||||
Some((8.0, BreathingType::Agonal)),
|
||||
None,
|
||||
MovementType::None,
|
||||
);
|
||||
|
||||
let result = classifier.classify(&reading);
|
||||
assert_eq!(result.recommended_triage, TriageStatus::Immediate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normal_breathing_no_movement_is_delayed() {
|
||||
let classifier = EnsembleClassifier::new(EnsembleConfig::default());
|
||||
let reading = make_reading(
|
||||
Some((16.0, BreathingType::Normal)),
|
||||
None,
|
||||
MovementType::None,
|
||||
);
|
||||
|
||||
let result = classifier.classify(&reading);
|
||||
assert_eq!(result.recommended_triage, TriageStatus::Delayed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_vitals_is_deceased() {
|
||||
let mv = MovementProfile::default();
|
||||
let mut reading = VitalSignsReading::new(None, None, mv);
|
||||
reading.confidence = ConfidenceScore::new(0.5);
|
||||
|
||||
let mut config = EnsembleConfig::default();
|
||||
config.min_ensemble_confidence = 0.0;
|
||||
let classifier = EnsembleClassifier::new(config);
|
||||
|
||||
let result = classifier.classify(&reading);
|
||||
assert_eq!(result.recommended_triage, TriageStatus::Deceased);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensemble_confidence_weighting() {
|
||||
let classifier = EnsembleClassifier::new(EnsembleConfig {
|
||||
breathing_weight: 0.6,
|
||||
heartbeat_weight: 0.3,
|
||||
movement_weight: 0.1,
|
||||
min_ensemble_confidence: 0.0,
|
||||
});
|
||||
|
||||
let reading = make_reading(
|
||||
Some((16.0, BreathingType::Normal)),
|
||||
Some(72.0),
|
||||
MovementType::Periodic,
|
||||
);
|
||||
|
||||
let result = classifier.classify(&reading);
|
||||
assert!(result.confidence > 0.0);
|
||||
assert!(result.breathing_detected);
|
||||
assert!(result.heartbeat_detected);
|
||||
assert!(result.movement_detected);
|
||||
}
|
||||
}
|
||||
@@ -1,390 +0,0 @@
|
||||
//! Heartbeat detection from micro-Doppler signatures in CSI.
|
||||
|
||||
use crate::domain::{HeartbeatSignature, SignalStrength};
|
||||
|
||||
/// Configuration for heartbeat detection
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HeartbeatDetectorConfig {
|
||||
/// Minimum heart rate to detect (BPM)
|
||||
pub min_rate_bpm: f32,
|
||||
/// Maximum heart rate to detect (BPM)
|
||||
pub max_rate_bpm: f32,
|
||||
/// Minimum signal strength required
|
||||
pub min_signal_strength: f64,
|
||||
/// Window size for analysis
|
||||
pub window_size: usize,
|
||||
/// Enable enhanced micro-Doppler processing
|
||||
pub enhanced_processing: bool,
|
||||
/// Confidence threshold
|
||||
pub confidence_threshold: f32,
|
||||
}
|
||||
|
||||
impl Default for HeartbeatDetectorConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
min_rate_bpm: 30.0, // Very slow (bradycardia)
|
||||
max_rate_bpm: 200.0, // Very fast (extreme tachycardia)
|
||||
min_signal_strength: 0.05,
|
||||
window_size: 1024,
|
||||
enhanced_processing: true,
|
||||
confidence_threshold: 0.4,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Detector for heartbeat signatures using micro-Doppler analysis
|
||||
///
|
||||
/// Heartbeats cause very small chest wall movements (~0.5mm) that can be
|
||||
/// detected through careful analysis of CSI phase variations at higher
|
||||
/// frequencies than breathing (0.8-3.3 Hz for 48-200 BPM).
|
||||
pub struct HeartbeatDetector {
|
||||
config: HeartbeatDetectorConfig,
|
||||
}
|
||||
|
||||
impl HeartbeatDetector {
|
||||
/// Create a new heartbeat detector
|
||||
pub fn new(config: HeartbeatDetectorConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
/// Create with default configuration
|
||||
pub fn with_defaults() -> Self {
|
||||
Self::new(HeartbeatDetectorConfig::default())
|
||||
}
|
||||
|
||||
/// Detect heartbeat from CSI phase data
|
||||
///
|
||||
/// Heartbeat detection is more challenging than breathing due to:
|
||||
/// - Much smaller displacement (~0.5mm vs ~10mm for breathing)
|
||||
/// - Higher frequency (masked by breathing harmonics)
|
||||
/// - Lower signal-to-noise ratio
|
||||
///
|
||||
/// We use micro-Doppler analysis on the phase component after
|
||||
/// removing the breathing component.
|
||||
pub fn detect(
|
||||
&self,
|
||||
csi_phase: &[f64],
|
||||
sample_rate: f64,
|
||||
breathing_rate: Option<f64>,
|
||||
) -> Option<HeartbeatSignature> {
|
||||
if csi_phase.len() < self.config.window_size {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Remove breathing component if known
|
||||
let filtered = if let Some(br) = breathing_rate {
|
||||
self.remove_breathing_component(csi_phase, sample_rate, br)
|
||||
} else {
|
||||
self.highpass_filter(csi_phase, sample_rate, 0.8)
|
||||
};
|
||||
|
||||
// Compute micro-Doppler spectrum
|
||||
let spectrum = self.compute_micro_doppler_spectrum(&filtered, sample_rate);
|
||||
|
||||
// Find heartbeat frequency
|
||||
let min_freq = self.config.min_rate_bpm as f64 / 60.0;
|
||||
let max_freq = self.config.max_rate_bpm as f64 / 60.0;
|
||||
|
||||
let (heart_freq, strength) = self.find_heartbeat_frequency(
|
||||
&spectrum,
|
||||
sample_rate,
|
||||
min_freq,
|
||||
max_freq,
|
||||
)?;
|
||||
|
||||
if strength < self.config.min_signal_strength {
|
||||
return None;
|
||||
}
|
||||
|
||||
let rate_bpm = (heart_freq * 60.0) as f32;
|
||||
|
||||
// Calculate heart rate variability from peak width
|
||||
let variability = self.estimate_hrv(&spectrum, heart_freq, sample_rate);
|
||||
|
||||
// Determine signal strength category
|
||||
let signal_strength = self.categorize_strength(strength);
|
||||
|
||||
// Calculate confidence
|
||||
let confidence = self.calculate_confidence(strength, variability);
|
||||
|
||||
if confidence < self.config.confidence_threshold {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(HeartbeatSignature {
|
||||
rate_bpm,
|
||||
variability,
|
||||
strength: signal_strength,
|
||||
})
|
||||
}
|
||||
|
||||
/// Remove breathing component using notch filter
|
||||
fn remove_breathing_component(
|
||||
&self,
|
||||
signal: &[f64],
|
||||
sample_rate: f64,
|
||||
breathing_rate: f64,
|
||||
) -> Vec<f64> {
|
||||
// Simple IIR notch filter at breathing frequency and harmonics
|
||||
let mut filtered = signal.to_vec();
|
||||
let breathing_freq = breathing_rate / 60.0;
|
||||
|
||||
// Notch at fundamental and first two harmonics
|
||||
for harmonic in 1..=3 {
|
||||
let notch_freq = breathing_freq * harmonic as f64;
|
||||
filtered = self.apply_notch_filter(&filtered, sample_rate, notch_freq, 0.05);
|
||||
}
|
||||
|
||||
filtered
|
||||
}
|
||||
|
||||
/// Apply a simple notch filter
|
||||
fn apply_notch_filter(
|
||||
&self,
|
||||
signal: &[f64],
|
||||
sample_rate: f64,
|
||||
center_freq: f64,
|
||||
bandwidth: f64,
|
||||
) -> Vec<f64> {
|
||||
// Second-order IIR notch filter
|
||||
let w0 = 2.0 * std::f64::consts::PI * center_freq / sample_rate;
|
||||
let bw = 2.0 * std::f64::consts::PI * bandwidth / sample_rate;
|
||||
|
||||
let r = 1.0 - bw / 2.0;
|
||||
let cos_w0 = w0.cos();
|
||||
|
||||
let b0 = 1.0;
|
||||
let b1 = -2.0 * cos_w0;
|
||||
let b2 = 1.0;
|
||||
let a1 = -2.0 * r * cos_w0;
|
||||
let a2 = r * r;
|
||||
|
||||
let mut output = vec![0.0; signal.len()];
|
||||
let mut x1 = 0.0;
|
||||
let mut x2 = 0.0;
|
||||
let mut y1 = 0.0;
|
||||
let mut y2 = 0.0;
|
||||
|
||||
for (i, &x) in signal.iter().enumerate() {
|
||||
let y = b0 * x + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2;
|
||||
output[i] = y;
|
||||
|
||||
x2 = x1;
|
||||
x1 = x;
|
||||
y2 = y1;
|
||||
y1 = y;
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
/// High-pass filter to remove low frequencies
|
||||
fn highpass_filter(&self, signal: &[f64], sample_rate: f64, cutoff: f64) -> Vec<f64> {
|
||||
// Simple first-order high-pass filter
|
||||
let rc = 1.0 / (2.0 * std::f64::consts::PI * cutoff);
|
||||
let dt = 1.0 / sample_rate;
|
||||
let alpha = rc / (rc + dt);
|
||||
|
||||
let mut output = vec![0.0; signal.len()];
|
||||
if signal.is_empty() {
|
||||
return output;
|
||||
}
|
||||
|
||||
output[0] = signal[0];
|
||||
for i in 1..signal.len() {
|
||||
output[i] = alpha * (output[i - 1] + signal[i] - signal[i - 1]);
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
/// Compute micro-Doppler spectrum optimized for heartbeat detection
|
||||
fn compute_micro_doppler_spectrum(&self, signal: &[f64], _sample_rate: f64) -> Vec<f64> {
|
||||
use rustfft::{FftPlanner, num_complex::Complex};
|
||||
|
||||
let n = signal.len().next_power_of_two();
|
||||
let mut planner = FftPlanner::new();
|
||||
let fft = planner.plan_fft_forward(n);
|
||||
|
||||
// Apply Blackman window for better frequency resolution
|
||||
let mut buffer: Vec<Complex<f64>> = signal
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &x)| {
|
||||
let n_f = signal.len() as f64;
|
||||
let window = 0.42
|
||||
- 0.5 * (2.0 * std::f64::consts::PI * i as f64 / n_f).cos()
|
||||
+ 0.08 * (4.0 * std::f64::consts::PI * i as f64 / n_f).cos();
|
||||
Complex::new(x * window, 0.0)
|
||||
})
|
||||
.collect();
|
||||
buffer.resize(n, Complex::new(0.0, 0.0));
|
||||
|
||||
fft.process(&mut buffer);
|
||||
|
||||
// Return power spectrum
|
||||
buffer.iter()
|
||||
.take(n / 2)
|
||||
.map(|c| c.norm_sqr())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Find heartbeat frequency in spectrum
|
||||
fn find_heartbeat_frequency(
|
||||
&self,
|
||||
spectrum: &[f64],
|
||||
sample_rate: f64,
|
||||
min_freq: f64,
|
||||
max_freq: f64,
|
||||
) -> Option<(f64, f64)> {
|
||||
let n = spectrum.len() * 2;
|
||||
let freq_resolution = sample_rate / n as f64;
|
||||
|
||||
let min_bin = (min_freq / freq_resolution).ceil() as usize;
|
||||
let max_bin = (max_freq / freq_resolution).floor() as usize;
|
||||
|
||||
if min_bin >= spectrum.len() || max_bin >= spectrum.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Find the strongest peak
|
||||
let mut max_power = 0.0;
|
||||
let mut max_bin_idx = min_bin;
|
||||
|
||||
for i in min_bin..=max_bin.min(spectrum.len() - 1) {
|
||||
if spectrum[i] > max_power {
|
||||
max_power = spectrum[i];
|
||||
max_bin_idx = i;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a real peak (local maximum)
|
||||
if max_bin_idx > 0 && max_bin_idx < spectrum.len() - 1 {
|
||||
if spectrum[max_bin_idx] <= spectrum[max_bin_idx - 1]
|
||||
|| spectrum[max_bin_idx] <= spectrum[max_bin_idx + 1]
|
||||
{
|
||||
// Not a real peak
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
let freq = max_bin_idx as f64 * freq_resolution;
|
||||
let strength = max_power.sqrt(); // Convert power to amplitude
|
||||
|
||||
Some((freq, strength))
|
||||
}
|
||||
|
||||
/// Estimate heart rate variability from spectral peak width
|
||||
fn estimate_hrv(&self, spectrum: &[f64], peak_freq: f64, sample_rate: f64) -> f32 {
|
||||
let n = spectrum.len() * 2;
|
||||
let freq_resolution = sample_rate / n as f64;
|
||||
let peak_bin = (peak_freq / freq_resolution).round() as usize;
|
||||
|
||||
if peak_bin >= spectrum.len() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let peak_power = spectrum[peak_bin];
|
||||
if peak_power == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Find -3dB width (half-power points)
|
||||
let half_power = peak_power / 2.0;
|
||||
let mut left = peak_bin;
|
||||
let mut right = peak_bin;
|
||||
|
||||
while left > 0 && spectrum[left] > half_power {
|
||||
left -= 1;
|
||||
}
|
||||
while right < spectrum.len() - 1 && spectrum[right] > half_power {
|
||||
right += 1;
|
||||
}
|
||||
|
||||
// HRV is proportional to bandwidth
|
||||
let bandwidth = (right - left) as f64 * freq_resolution;
|
||||
let hrv_estimate = bandwidth * 60.0; // Convert to BPM variation
|
||||
|
||||
// Normalize to 0-1 range (typical HRV is 2-20 BPM)
|
||||
(hrv_estimate / 20.0).min(1.0) as f32
|
||||
}
|
||||
|
||||
/// Categorize signal strength
|
||||
fn categorize_strength(&self, strength: f64) -> SignalStrength {
|
||||
if strength > 0.5 {
|
||||
SignalStrength::Strong
|
||||
} else if strength > 0.2 {
|
||||
SignalStrength::Moderate
|
||||
} else if strength > 0.1 {
|
||||
SignalStrength::Weak
|
||||
} else {
|
||||
SignalStrength::VeryWeak
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate detection confidence
|
||||
fn calculate_confidence(&self, strength: f64, hrv: f32) -> f32 {
|
||||
// Strong signal with reasonable HRV indicates real heartbeat
|
||||
let strength_score = (strength / 0.5).min(1.0) as f32;
|
||||
|
||||
// Very low or very high HRV might indicate noise
|
||||
let hrv_score = if hrv > 0.05 && hrv < 0.5 {
|
||||
1.0
|
||||
} else {
|
||||
0.5
|
||||
};
|
||||
|
||||
strength_score * 0.7 + hrv_score * 0.3
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn generate_heartbeat_signal(rate_bpm: f64, sample_rate: f64, duration: f64) -> Vec<f64> {
|
||||
let num_samples = (sample_rate * duration) as usize;
|
||||
let freq = rate_bpm / 60.0;
|
||||
|
||||
(0..num_samples)
|
||||
.map(|i| {
|
||||
let t = i as f64 / sample_rate;
|
||||
// Heartbeat is more pulse-like than sine
|
||||
let phase = 2.0 * std::f64::consts::PI * freq * t;
|
||||
0.3 * phase.sin() + 0.1 * (2.0 * phase).sin()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_heartbeat() {
|
||||
let detector = HeartbeatDetector::with_defaults();
|
||||
let signal = generate_heartbeat_signal(72.0, 1000.0, 10.0);
|
||||
|
||||
let result = detector.detect(&signal, 1000.0, None);
|
||||
|
||||
// Heartbeat detection is challenging, may not always succeed
|
||||
if let Some(signature) = result {
|
||||
assert!(signature.rate_bpm >= 50.0 && signature.rate_bpm <= 100.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_highpass_filter() {
|
||||
let detector = HeartbeatDetector::with_defaults();
|
||||
|
||||
// Signal with DC offset and low frequency component
|
||||
let signal: Vec<f64> = (0..1000)
|
||||
.map(|i| {
|
||||
let t = i as f64 / 100.0;
|
||||
5.0 + (0.1 * t).sin() + (5.0 * t).sin() * 0.2
|
||||
})
|
||||
.collect();
|
||||
|
||||
let filtered = detector.highpass_filter(&signal, 100.0, 0.5);
|
||||
|
||||
// DC component should be removed
|
||||
let mean: f64 = filtered.iter().skip(100).sum::<f64>() / (filtered.len() - 100) as f64;
|
||||
assert!(mean.abs() < 1.0);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
//! Detection module for vital signs detection from CSI data.
|
||||
//!
|
||||
//! This module provides detectors for:
|
||||
//! - Breathing patterns
|
||||
//! - Heartbeat signatures
|
||||
//! - Movement classification
|
||||
//! - Ensemble classification combining all signals
|
||||
|
||||
mod breathing;
|
||||
mod ensemble;
|
||||
mod heartbeat;
|
||||
mod movement;
|
||||
mod pipeline;
|
||||
|
||||
pub use breathing::{BreathingDetector, BreathingDetectorConfig};
|
||||
pub use ensemble::{EnsembleClassifier, EnsembleConfig, EnsembleResult, SignalConfidences};
|
||||
pub use heartbeat::{HeartbeatDetector, HeartbeatDetectorConfig};
|
||||
pub use movement::{MovementClassifier, MovementClassifierConfig};
|
||||
pub use pipeline::{DetectionPipeline, DetectionConfig, VitalSignsDetector, CsiDataBuffer};
|
||||
@@ -1,275 +0,0 @@
|
||||
//! Movement classification from CSI signal variations.
|
||||
|
||||
use crate::domain::{MovementProfile, MovementType};
|
||||
|
||||
/// Configuration for movement classification
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MovementClassifierConfig {
|
||||
/// Threshold for detecting any movement
|
||||
pub movement_threshold: f64,
|
||||
/// Threshold for gross movement
|
||||
pub gross_movement_threshold: f64,
|
||||
/// Window size for variance calculation
|
||||
pub window_size: usize,
|
||||
/// Threshold for periodic movement detection
|
||||
pub periodicity_threshold: f64,
|
||||
}
|
||||
|
||||
impl Default for MovementClassifierConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
movement_threshold: 0.1,
|
||||
gross_movement_threshold: 0.5,
|
||||
window_size: 100,
|
||||
periodicity_threshold: 0.3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Classifier for movement types from CSI signals
|
||||
pub struct MovementClassifier {
|
||||
config: MovementClassifierConfig,
|
||||
}
|
||||
|
||||
impl MovementClassifier {
|
||||
/// Create a new movement classifier
|
||||
pub fn new(config: MovementClassifierConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
/// Create with default configuration
|
||||
pub fn with_defaults() -> Self {
|
||||
Self::new(MovementClassifierConfig::default())
|
||||
}
|
||||
|
||||
/// Classify movement from CSI signal
|
||||
pub fn classify(&self, csi_signal: &[f64], sample_rate: f64) -> MovementProfile {
|
||||
if csi_signal.len() < self.config.window_size {
|
||||
return MovementProfile::default();
|
||||
}
|
||||
|
||||
// Calculate signal statistics
|
||||
let variance = self.calculate_variance(csi_signal);
|
||||
let max_change = self.calculate_max_change(csi_signal);
|
||||
let periodicity = self.calculate_periodicity(csi_signal, sample_rate);
|
||||
|
||||
// Determine movement type
|
||||
let (movement_type, is_voluntary) = self.determine_movement_type(
|
||||
variance,
|
||||
max_change,
|
||||
periodicity,
|
||||
);
|
||||
|
||||
// Calculate intensity
|
||||
let intensity = self.calculate_intensity(variance, max_change);
|
||||
|
||||
// Calculate frequency of movement
|
||||
let frequency = self.calculate_movement_frequency(csi_signal, sample_rate);
|
||||
|
||||
MovementProfile {
|
||||
movement_type,
|
||||
intensity,
|
||||
frequency,
|
||||
is_voluntary,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate signal variance
|
||||
fn calculate_variance(&self, signal: &[f64]) -> f64 {
|
||||
if signal.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let mean = signal.iter().sum::<f64>() / signal.len() as f64;
|
||||
let variance = signal.iter()
|
||||
.map(|x| (x - mean).powi(2))
|
||||
.sum::<f64>() / signal.len() as f64;
|
||||
|
||||
variance
|
||||
}
|
||||
|
||||
/// Calculate maximum change in signal
|
||||
fn calculate_max_change(&self, signal: &[f64]) -> f64 {
|
||||
if signal.len() < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
signal.windows(2)
|
||||
.map(|w| (w[1] - w[0]).abs())
|
||||
.fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
/// Calculate periodicity score using autocorrelation
|
||||
fn calculate_periodicity(&self, signal: &[f64], _sample_rate: f64) -> f64 {
|
||||
if signal.len() < 3 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Calculate autocorrelation
|
||||
let n = signal.len();
|
||||
let mean = signal.iter().sum::<f64>() / n as f64;
|
||||
let centered: Vec<f64> = signal.iter().map(|x| x - mean).collect();
|
||||
|
||||
let variance: f64 = centered.iter().map(|x| x * x).sum();
|
||||
if variance == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Find first peak in autocorrelation after lag 0
|
||||
let max_lag = n / 2;
|
||||
let mut max_corr = 0.0;
|
||||
|
||||
for lag in 1..max_lag {
|
||||
let corr: f64 = centered.iter()
|
||||
.take(n - lag)
|
||||
.zip(centered.iter().skip(lag))
|
||||
.map(|(a, b)| a * b)
|
||||
.sum();
|
||||
|
||||
let normalized_corr = corr / variance;
|
||||
if normalized_corr > max_corr {
|
||||
max_corr = normalized_corr;
|
||||
}
|
||||
}
|
||||
|
||||
max_corr.max(0.0)
|
||||
}
|
||||
|
||||
/// Determine movement type based on signal characteristics
|
||||
fn determine_movement_type(
|
||||
&self,
|
||||
variance: f64,
|
||||
max_change: f64,
|
||||
periodicity: f64,
|
||||
) -> (MovementType, bool) {
|
||||
// No significant movement
|
||||
if variance < self.config.movement_threshold * 0.5
|
||||
&& max_change < self.config.movement_threshold
|
||||
{
|
||||
return (MovementType::None, false);
|
||||
}
|
||||
|
||||
// Check for gross movement (large, purposeful)
|
||||
if max_change > self.config.gross_movement_threshold
|
||||
&& variance > self.config.movement_threshold
|
||||
{
|
||||
// Gross movement with low periodicity suggests voluntary
|
||||
let is_voluntary = periodicity < self.config.periodicity_threshold;
|
||||
return (MovementType::Gross, is_voluntary);
|
||||
}
|
||||
|
||||
// Check for periodic movement (breathing-related or tremor)
|
||||
if periodicity > self.config.periodicity_threshold {
|
||||
// High periodicity with low variance = breathing-related
|
||||
if variance < self.config.movement_threshold * 2.0 {
|
||||
return (MovementType::Periodic, false);
|
||||
}
|
||||
// High periodicity with higher variance = tremor
|
||||
return (MovementType::Tremor, false);
|
||||
}
|
||||
|
||||
// Fine movement (small but detectable)
|
||||
if variance > self.config.movement_threshold * 0.5 {
|
||||
// Fine movement might be voluntary if not very periodic
|
||||
let is_voluntary = periodicity < 0.2;
|
||||
return (MovementType::Fine, is_voluntary);
|
||||
}
|
||||
|
||||
(MovementType::None, false)
|
||||
}
|
||||
|
||||
/// Calculate movement intensity (0.0-1.0)
|
||||
fn calculate_intensity(&self, variance: f64, max_change: f64) -> f32 {
|
||||
// Combine variance and max change
|
||||
let variance_score = (variance / (self.config.gross_movement_threshold * 2.0)).min(1.0);
|
||||
let change_score = (max_change / self.config.gross_movement_threshold).min(1.0);
|
||||
|
||||
((variance_score * 0.6 + change_score * 0.4) as f32).min(1.0)
|
||||
}
|
||||
|
||||
/// Calculate movement frequency (movements per second)
|
||||
fn calculate_movement_frequency(&self, signal: &[f64], sample_rate: f64) -> f32 {
|
||||
if signal.len() < 3 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Count zero crossings (after removing mean)
|
||||
let mean = signal.iter().sum::<f64>() / signal.len() as f64;
|
||||
let centered: Vec<f64> = signal.iter().map(|x| x - mean).collect();
|
||||
|
||||
let zero_crossings: usize = centered.windows(2)
|
||||
.filter(|w| (w[0] >= 0.0) != (w[1] >= 0.0))
|
||||
.count();
|
||||
|
||||
// Each zero crossing is half a cycle
|
||||
let duration = signal.len() as f64 / sample_rate;
|
||||
let frequency = zero_crossings as f64 / (2.0 * duration);
|
||||
|
||||
frequency as f32
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_no_movement() {
|
||||
let classifier = MovementClassifier::with_defaults();
|
||||
let signal: Vec<f64> = vec![1.0; 200];
|
||||
|
||||
let profile = classifier.classify(&signal, 100.0);
|
||||
assert!(matches!(profile.movement_type, MovementType::None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gross_movement() {
|
||||
let classifier = MovementClassifier::with_defaults();
|
||||
|
||||
// Simulate large movement
|
||||
let mut signal: Vec<f64> = vec![0.0; 200];
|
||||
for i in 50..100 {
|
||||
signal[i] = 2.0;
|
||||
}
|
||||
for i in 150..180 {
|
||||
signal[i] = -1.5;
|
||||
}
|
||||
|
||||
let profile = classifier.classify(&signal, 100.0);
|
||||
assert!(matches!(profile.movement_type, MovementType::Gross));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_periodic_movement() {
|
||||
let classifier = MovementClassifier::with_defaults();
|
||||
|
||||
// Simulate periodic signal (like breathing) with higher amplitude
|
||||
let signal: Vec<f64> = (0..1000)
|
||||
.map(|i| (2.0 * std::f64::consts::PI * i as f64 / 100.0).sin() * 1.5)
|
||||
.collect();
|
||||
|
||||
let profile = classifier.classify(&signal, 100.0);
|
||||
// Should detect some movement type (periodic, fine, or at least have non-zero intensity)
|
||||
// The exact type depends on thresholds, but with enough amplitude we should detect something
|
||||
assert!(profile.intensity > 0.0 || !matches!(profile.movement_type, MovementType::None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intensity_calculation() {
|
||||
let classifier = MovementClassifier::with_defaults();
|
||||
|
||||
// Low intensity
|
||||
let low_signal: Vec<f64> = (0..200)
|
||||
.map(|i| (i as f64 * 0.1).sin() * 0.05)
|
||||
.collect();
|
||||
let low_profile = classifier.classify(&low_signal, 100.0);
|
||||
|
||||
// High intensity
|
||||
let high_signal: Vec<f64> = (0..200)
|
||||
.map(|i| (i as f64 * 0.1).sin() * 2.0)
|
||||
.collect();
|
||||
let high_profile = classifier.classify(&high_signal, 100.0);
|
||||
|
||||
assert!(high_profile.intensity > low_profile.intensity);
|
||||
}
|
||||
}
|
||||
@@ -1,471 +0,0 @@
|
||||
//! Detection pipeline combining all vital signs detectors.
|
||||
//!
|
||||
//! This module provides both traditional signal-processing-based detection
|
||||
//! and optional ML-enhanced detection for improved accuracy.
|
||||
|
||||
use crate::domain::{ScanZone, VitalSignsReading, ConfidenceScore};
|
||||
use crate::ml::{MlDetectionConfig, MlDetectionPipeline, MlDetectionResult};
|
||||
use crate::{DisasterConfig, MatError};
|
||||
use super::{
|
||||
BreathingDetector, BreathingDetectorConfig,
|
||||
HeartbeatDetector, HeartbeatDetectorConfig,
|
||||
MovementClassifier, MovementClassifierConfig,
|
||||
};
|
||||
|
||||
/// Configuration for the detection pipeline
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DetectionConfig {
|
||||
/// Breathing detector configuration
|
||||
pub breathing: BreathingDetectorConfig,
|
||||
/// Heartbeat detector configuration
|
||||
pub heartbeat: HeartbeatDetectorConfig,
|
||||
/// Movement classifier configuration
|
||||
pub movement: MovementClassifierConfig,
|
||||
/// Sample rate of CSI data (Hz)
|
||||
pub sample_rate: f64,
|
||||
/// Whether to enable heartbeat detection (slower, more processing)
|
||||
pub enable_heartbeat: bool,
|
||||
/// Minimum overall confidence to report detection
|
||||
pub min_confidence: f64,
|
||||
/// Enable ML-enhanced detection
|
||||
pub enable_ml: bool,
|
||||
/// ML detection configuration (if enabled)
|
||||
pub ml_config: Option<MlDetectionConfig>,
|
||||
}
|
||||
|
||||
impl Default for DetectionConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
breathing: BreathingDetectorConfig::default(),
|
||||
heartbeat: HeartbeatDetectorConfig::default(),
|
||||
movement: MovementClassifierConfig::default(),
|
||||
sample_rate: 1000.0,
|
||||
enable_heartbeat: false,
|
||||
min_confidence: 0.3,
|
||||
enable_ml: false,
|
||||
ml_config: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DetectionConfig {
|
||||
/// Create configuration from disaster config
|
||||
pub fn from_disaster_config(config: &DisasterConfig) -> Self {
|
||||
let mut detection_config = Self::default();
|
||||
|
||||
// Adjust sensitivity
|
||||
detection_config.breathing.confidence_threshold = (1.0 - config.sensitivity) as f32 * 0.5;
|
||||
detection_config.heartbeat.confidence_threshold = (1.0 - config.sensitivity) as f32 * 0.5;
|
||||
detection_config.min_confidence = 1.0 - config.sensitivity * 0.7;
|
||||
|
||||
// Enable heartbeat for high sensitivity
|
||||
detection_config.enable_heartbeat = config.sensitivity > 0.7;
|
||||
|
||||
detection_config
|
||||
}
|
||||
|
||||
/// Enable ML-enhanced detection with the given configuration
|
||||
pub fn with_ml(mut self, ml_config: MlDetectionConfig) -> Self {
|
||||
self.enable_ml = true;
|
||||
self.ml_config = Some(ml_config);
|
||||
self
|
||||
}
|
||||
|
||||
/// Enable ML-enhanced detection with default configuration
|
||||
pub fn with_default_ml(mut self) -> Self {
|
||||
self.enable_ml = true;
|
||||
self.ml_config = Some(MlDetectionConfig::default());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait for vital signs detection
|
||||
pub trait VitalSignsDetector: Send + Sync {
|
||||
/// Process CSI data and detect vital signs
|
||||
fn detect(&self, csi_data: &CsiDataBuffer) -> Option<VitalSignsReading>;
|
||||
}
|
||||
|
||||
/// Buffer for CSI data samples
|
||||
#[derive(Debug, Default)]
|
||||
pub struct CsiDataBuffer {
|
||||
/// Amplitude samples
|
||||
pub amplitudes: Vec<f64>,
|
||||
/// Phase samples (unwrapped)
|
||||
pub phases: Vec<f64>,
|
||||
/// Sample timestamps
|
||||
pub timestamps: Vec<f64>,
|
||||
/// Sample rate
|
||||
pub sample_rate: f64,
|
||||
}
|
||||
|
||||
impl CsiDataBuffer {
|
||||
/// Create a new buffer
|
||||
pub fn new(sample_rate: f64) -> Self {
|
||||
Self {
|
||||
amplitudes: Vec::new(),
|
||||
phases: Vec::new(),
|
||||
timestamps: Vec::new(),
|
||||
sample_rate,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add samples to the buffer
|
||||
pub fn add_samples(&mut self, amplitudes: &[f64], phases: &[f64]) {
|
||||
self.amplitudes.extend(amplitudes);
|
||||
self.phases.extend(phases);
|
||||
|
||||
// Generate timestamps
|
||||
let start = self.timestamps.last().copied().unwrap_or(0.0);
|
||||
let dt = 1.0 / self.sample_rate;
|
||||
for i in 0..amplitudes.len() {
|
||||
self.timestamps.push(start + (i + 1) as f64 * dt);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the buffer
|
||||
pub fn clear(&mut self) {
|
||||
self.amplitudes.clear();
|
||||
self.phases.clear();
|
||||
self.timestamps.clear();
|
||||
}
|
||||
|
||||
/// Get the duration of data in the buffer (seconds)
|
||||
pub fn duration(&self) -> f64 {
|
||||
self.amplitudes.len() as f64 / self.sample_rate
|
||||
}
|
||||
|
||||
/// Check if buffer has enough data for analysis
|
||||
pub fn has_sufficient_data(&self, min_duration: f64) -> bool {
|
||||
self.duration() >= min_duration
|
||||
}
|
||||
}
|
||||
|
||||
/// Detection pipeline that combines all detectors
|
||||
pub struct DetectionPipeline {
|
||||
config: DetectionConfig,
|
||||
breathing_detector: BreathingDetector,
|
||||
heartbeat_detector: HeartbeatDetector,
|
||||
movement_classifier: MovementClassifier,
|
||||
data_buffer: parking_lot::RwLock<CsiDataBuffer>,
|
||||
/// Optional ML detection pipeline
|
||||
ml_pipeline: Option<MlDetectionPipeline>,
|
||||
}
|
||||
|
||||
impl DetectionPipeline {
|
||||
/// Create a new detection pipeline
|
||||
pub fn new(config: DetectionConfig) -> Self {
|
||||
let ml_pipeline = if config.enable_ml {
|
||||
config.ml_config.clone().map(MlDetectionPipeline::new)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Self {
|
||||
breathing_detector: BreathingDetector::new(config.breathing.clone()),
|
||||
heartbeat_detector: HeartbeatDetector::new(config.heartbeat.clone()),
|
||||
movement_classifier: MovementClassifier::new(config.movement.clone()),
|
||||
data_buffer: parking_lot::RwLock::new(CsiDataBuffer::new(config.sample_rate)),
|
||||
ml_pipeline,
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize ML models asynchronously (if enabled)
|
||||
pub async fn initialize_ml(&mut self) -> Result<(), MatError> {
|
||||
if let Some(ref mut ml) = self.ml_pipeline {
|
||||
ml.initialize().await.map_err(MatError::from)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if ML pipeline is ready
|
||||
pub fn ml_ready(&self) -> bool {
|
||||
self.ml_pipeline.as_ref().map_or(true, |ml| ml.is_ready())
|
||||
}
|
||||
|
||||
/// Process a scan zone and return detected vital signs.
|
||||
///
|
||||
/// CSI data must be pushed into the pipeline via [`add_data`] before calling
|
||||
/// this method. The pipeline processes buffered amplitude/phase samples through
|
||||
/// breathing, heartbeat, and movement detectors. If ML is enabled and ready,
|
||||
/// results are enhanced with ML predictions.
|
||||
///
|
||||
/// Returns `None` if insufficient data is buffered (< 5 seconds) or if
|
||||
/// detection confidence is below the configured threshold.
|
||||
pub async fn process_zone(&self, zone: &ScanZone) -> Result<Option<VitalSignsReading>, MatError> {
|
||||
// Process buffered CSI data through the signal processing pipeline.
|
||||
// Data arrives via add_data() from hardware adapters (ESP32, Intel 5300, etc.)
|
||||
// or from the CSI push API endpoint.
|
||||
let buffer = self.data_buffer.read();
|
||||
|
||||
if !buffer.has_sufficient_data(5.0) {
|
||||
// Need at least 5 seconds of data
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Detect vital signs using traditional pipeline
|
||||
let reading = self.detect_from_buffer(&buffer, zone)?;
|
||||
|
||||
// If ML is enabled and ready, enhance with ML predictions
|
||||
let enhanced_reading = if self.config.enable_ml && self.ml_ready() {
|
||||
self.enhance_with_ml(reading, &buffer).await?
|
||||
} else {
|
||||
reading
|
||||
};
|
||||
|
||||
// Check minimum confidence
|
||||
if let Some(ref r) = enhanced_reading {
|
||||
if r.confidence.value() < self.config.min_confidence {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(enhanced_reading)
|
||||
}
|
||||
|
||||
/// Enhance detection results with ML predictions
|
||||
async fn enhance_with_ml(
|
||||
&self,
|
||||
traditional_reading: Option<VitalSignsReading>,
|
||||
buffer: &CsiDataBuffer,
|
||||
) -> Result<Option<VitalSignsReading>, MatError> {
|
||||
let ml_pipeline = match &self.ml_pipeline {
|
||||
Some(ml) => ml,
|
||||
None => return Ok(traditional_reading),
|
||||
};
|
||||
|
||||
// Get ML predictions
|
||||
let ml_result = ml_pipeline.process(buffer).await.map_err(MatError::from)?;
|
||||
|
||||
// If we have ML vital classification, use it to enhance or replace traditional
|
||||
if let Some(ref ml_vital) = ml_result.vital_classification {
|
||||
if let Some(vital_reading) = ml_vital.to_vital_signs_reading() {
|
||||
// If ML result has higher confidence, prefer it
|
||||
if let Some(ref traditional) = traditional_reading {
|
||||
if ml_result.overall_confidence() > traditional.confidence.value() as f32 {
|
||||
return Ok(Some(vital_reading));
|
||||
}
|
||||
} else {
|
||||
// No traditional reading, use ML result
|
||||
return Ok(Some(vital_reading));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(traditional_reading)
|
||||
}
|
||||
|
||||
/// Get the latest ML detection results (if ML is enabled)
|
||||
pub async fn get_ml_results(&self) -> Option<MlDetectionResult> {
|
||||
let buffer = self.data_buffer.read();
|
||||
if let Some(ref ml) = self.ml_pipeline {
|
||||
ml.process(&buffer).await.ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Add CSI data to the processing buffer
|
||||
pub fn add_data(&self, amplitudes: &[f64], phases: &[f64]) {
|
||||
let mut buffer = self.data_buffer.write();
|
||||
buffer.add_samples(amplitudes, phases);
|
||||
|
||||
// Keep only recent data (last 30 seconds)
|
||||
let max_samples = (30.0 * self.config.sample_rate) as usize;
|
||||
if buffer.amplitudes.len() > max_samples {
|
||||
let drain_count = buffer.amplitudes.len() - max_samples;
|
||||
buffer.amplitudes.drain(0..drain_count);
|
||||
buffer.phases.drain(0..drain_count);
|
||||
buffer.timestamps.drain(0..drain_count);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the data buffer
|
||||
pub fn clear_buffer(&self) {
|
||||
self.data_buffer.write().clear();
|
||||
}
|
||||
|
||||
/// Detect vital signs from buffered data
|
||||
fn detect_from_buffer(
|
||||
&self,
|
||||
buffer: &CsiDataBuffer,
|
||||
_zone: &ScanZone,
|
||||
) -> Result<Option<VitalSignsReading>, MatError> {
|
||||
// Detect breathing
|
||||
let breathing = self.breathing_detector.detect(
|
||||
&buffer.amplitudes,
|
||||
buffer.sample_rate,
|
||||
);
|
||||
|
||||
// Detect heartbeat (if enabled)
|
||||
let heartbeat = if self.config.enable_heartbeat {
|
||||
let breathing_rate = breathing.as_ref().map(|b| b.rate_bpm as f64);
|
||||
self.heartbeat_detector.detect(
|
||||
&buffer.phases,
|
||||
buffer.sample_rate,
|
||||
breathing_rate,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Classify movement
|
||||
let movement = self.movement_classifier.classify(
|
||||
&buffer.amplitudes,
|
||||
buffer.sample_rate,
|
||||
);
|
||||
|
||||
// Check if we detected anything
|
||||
if breathing.is_none() && heartbeat.is_none() && movement.movement_type == crate::domain::MovementType::None {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Create vital signs reading
|
||||
let reading = VitalSignsReading::new(breathing, heartbeat, movement);
|
||||
|
||||
Ok(Some(reading))
|
||||
}
|
||||
|
||||
/// Get configuration
|
||||
pub fn config(&self) -> &DetectionConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// Update configuration
|
||||
pub fn update_config(&mut self, config: DetectionConfig) {
|
||||
self.breathing_detector = BreathingDetector::new(config.breathing.clone());
|
||||
self.heartbeat_detector = HeartbeatDetector::new(config.heartbeat.clone());
|
||||
self.movement_classifier = MovementClassifier::new(config.movement.clone());
|
||||
|
||||
// Update ML pipeline if configuration changed
|
||||
if config.enable_ml != self.config.enable_ml || config.ml_config != self.config.ml_config {
|
||||
self.ml_pipeline = if config.enable_ml {
|
||||
config.ml_config.clone().map(MlDetectionPipeline::new)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
|
||||
self.config = config;
|
||||
}
|
||||
|
||||
/// Get the ML pipeline (if enabled)
|
||||
pub fn ml_pipeline(&self) -> Option<&MlDetectionPipeline> {
|
||||
self.ml_pipeline.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl VitalSignsDetector for DetectionPipeline {
|
||||
fn detect(&self, csi_data: &CsiDataBuffer) -> Option<VitalSignsReading> {
|
||||
// Detect breathing from amplitude variations
|
||||
let breathing = self.breathing_detector.detect(
|
||||
&csi_data.amplitudes,
|
||||
csi_data.sample_rate,
|
||||
);
|
||||
|
||||
// Detect heartbeat from phase variations
|
||||
let heartbeat = if self.config.enable_heartbeat {
|
||||
let breathing_rate = breathing.as_ref().map(|b| b.rate_bpm as f64);
|
||||
self.heartbeat_detector.detect(
|
||||
&csi_data.phases,
|
||||
csi_data.sample_rate,
|
||||
breathing_rate,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Classify movement
|
||||
let movement = self.movement_classifier.classify(
|
||||
&csi_data.amplitudes,
|
||||
csi_data.sample_rate,
|
||||
);
|
||||
|
||||
// Create reading if we detected anything
|
||||
if breathing.is_some() || heartbeat.is_some()
|
||||
|| movement.movement_type != crate::domain::MovementType::None
|
||||
{
|
||||
Some(VitalSignsReading::new(breathing, heartbeat, movement))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn create_test_buffer() -> CsiDataBuffer {
|
||||
let mut buffer = CsiDataBuffer::new(100.0);
|
||||
|
||||
// Add 10 seconds of simulated breathing signal
|
||||
let num_samples = 1000;
|
||||
let amplitudes: Vec<f64> = (0..num_samples)
|
||||
.map(|i| {
|
||||
let t = i as f64 / 100.0;
|
||||
// 16 BPM breathing (0.267 Hz)
|
||||
(2.0 * std::f64::consts::PI * 0.267 * t).sin()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let phases: Vec<f64> = (0..num_samples)
|
||||
.map(|i| {
|
||||
let t = i as f64 / 100.0;
|
||||
// Phase variation from movement
|
||||
(2.0 * std::f64::consts::PI * 0.267 * t).sin() * 0.5
|
||||
})
|
||||
.collect();
|
||||
|
||||
buffer.add_samples(&litudes, &phases);
|
||||
buffer
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pipeline_creation() {
|
||||
let config = DetectionConfig::default();
|
||||
let pipeline = DetectionPipeline::new(config);
|
||||
assert_eq!(pipeline.config().sample_rate, 1000.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_csi_buffer() {
|
||||
let mut buffer = CsiDataBuffer::new(100.0);
|
||||
|
||||
assert!(!buffer.has_sufficient_data(5.0));
|
||||
|
||||
let amplitudes: Vec<f64> = vec![1.0; 600];
|
||||
let phases: Vec<f64> = vec![0.0; 600];
|
||||
buffer.add_samples(&litudes, &phases);
|
||||
|
||||
assert!(buffer.has_sufficient_data(5.0));
|
||||
assert_eq!(buffer.duration(), 6.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vital_signs_detection() {
|
||||
let config = DetectionConfig::default();
|
||||
let pipeline = DetectionPipeline::new(config);
|
||||
let buffer = create_test_buffer();
|
||||
|
||||
let result = pipeline.detect(&buffer);
|
||||
assert!(result.is_some());
|
||||
|
||||
let reading = result.unwrap();
|
||||
assert!(reading.has_vitals());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_from_disaster_config() {
|
||||
let disaster_config = DisasterConfig::builder()
|
||||
.sensitivity(0.9)
|
||||
.build();
|
||||
|
||||
let detection_config = DetectionConfig::from_disaster_config(&disaster_config);
|
||||
|
||||
// High sensitivity should enable heartbeat detection
|
||||
assert!(detection_config.enable_heartbeat);
|
||||
// Low minimum confidence due to high sensitivity
|
||||
assert!(detection_config.min_confidence < 0.4);
|
||||
}
|
||||
}
|
||||
@@ -1,459 +0,0 @@
|
||||
//! Alert types for emergency notifications.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{SurvivorId, TriageStatus, Coordinates3D};
|
||||
|
||||
/// Unique identifier for an alert
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub struct AlertId(Uuid);
|
||||
|
||||
impl AlertId {
|
||||
/// Create a new random alert ID
|
||||
pub fn new() -> Self {
|
||||
Self(Uuid::new_v4())
|
||||
}
|
||||
|
||||
/// Get the inner UUID
|
||||
pub fn as_uuid(&self) -> &Uuid {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AlertId {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AlertId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Alert priority levels
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub enum Priority {
|
||||
/// Critical - immediate action required
|
||||
Critical = 1,
|
||||
/// High - urgent attention needed
|
||||
High = 2,
|
||||
/// Medium - important but not urgent
|
||||
Medium = 3,
|
||||
/// Low - informational
|
||||
Low = 4,
|
||||
}
|
||||
|
||||
impl Priority {
|
||||
/// Create from triage status
|
||||
pub fn from_triage(status: &TriageStatus) -> Self {
|
||||
match status {
|
||||
TriageStatus::Immediate => Priority::Critical,
|
||||
TriageStatus::Delayed => Priority::High,
|
||||
TriageStatus::Minor => Priority::Medium,
|
||||
TriageStatus::Deceased => Priority::Low,
|
||||
TriageStatus::Unknown => Priority::Medium,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get numeric value (lower = higher priority)
|
||||
pub fn value(&self) -> u8 {
|
||||
*self as u8
|
||||
}
|
||||
|
||||
/// Get display color
|
||||
pub fn color(&self) -> &'static str {
|
||||
match self {
|
||||
Priority::Critical => "red",
|
||||
Priority::High => "orange",
|
||||
Priority::Medium => "yellow",
|
||||
Priority::Low => "blue",
|
||||
}
|
||||
}
|
||||
|
||||
/// Get sound pattern for audio alerts
|
||||
pub fn audio_pattern(&self) -> &'static str {
|
||||
match self {
|
||||
Priority::Critical => "rapid_beep",
|
||||
Priority::High => "double_beep",
|
||||
Priority::Medium => "single_beep",
|
||||
Priority::Low => "soft_tone",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Priority {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Priority::Critical => write!(f, "CRITICAL"),
|
||||
Priority::High => write!(f, "HIGH"),
|
||||
Priority::Medium => write!(f, "MEDIUM"),
|
||||
Priority::Low => write!(f, "LOW"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Payload containing alert details
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub struct AlertPayload {
|
||||
/// Human-readable title
|
||||
pub title: String,
|
||||
/// Detailed message
|
||||
pub message: String,
|
||||
/// Triage status of survivor
|
||||
pub triage_status: TriageStatus,
|
||||
/// Location if known
|
||||
pub location: Option<Coordinates3D>,
|
||||
/// Recommended action
|
||||
pub recommended_action: String,
|
||||
/// Time-critical deadline (if any)
|
||||
pub deadline: Option<DateTime<Utc>>,
|
||||
/// Additional metadata
|
||||
pub metadata: std::collections::HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl AlertPayload {
|
||||
/// Create a new alert payload
|
||||
pub fn new(
|
||||
title: impl Into<String>,
|
||||
message: impl Into<String>,
|
||||
triage_status: TriageStatus,
|
||||
) -> Self {
|
||||
Self {
|
||||
title: title.into(),
|
||||
message: message.into(),
|
||||
triage_status,
|
||||
location: None,
|
||||
recommended_action: String::new(),
|
||||
deadline: None,
|
||||
metadata: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set location
|
||||
pub fn with_location(mut self, location: Coordinates3D) -> Self {
|
||||
self.location = Some(location);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set recommended action
|
||||
pub fn with_action(mut self, action: impl Into<String>) -> Self {
|
||||
self.recommended_action = action.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set deadline
|
||||
pub fn with_deadline(mut self, deadline: DateTime<Utc>) -> Self {
|
||||
self.deadline = Some(deadline);
|
||||
self
|
||||
}
|
||||
|
||||
/// Add metadata
|
||||
pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
|
||||
self.metadata.insert(key.into(), value.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Status of an alert
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub enum AlertStatus {
|
||||
/// Alert is pending acknowledgement
|
||||
Pending,
|
||||
/// Alert has been acknowledged
|
||||
Acknowledged,
|
||||
/// Alert is being worked on
|
||||
InProgress,
|
||||
/// Alert has been resolved
|
||||
Resolved,
|
||||
/// Alert was cancelled/superseded
|
||||
Cancelled,
|
||||
/// Alert expired without action
|
||||
Expired,
|
||||
}
|
||||
|
||||
/// Resolution details for a closed alert
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub struct AlertResolution {
|
||||
/// Resolution type
|
||||
pub resolution_type: ResolutionType,
|
||||
/// Resolution notes
|
||||
pub notes: String,
|
||||
/// Team that resolved
|
||||
pub resolved_by: Option<String>,
|
||||
/// Resolution time
|
||||
pub resolved_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Types of alert resolution
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub enum ResolutionType {
|
||||
/// Survivor was rescued
|
||||
Rescued,
|
||||
/// Alert was a false positive
|
||||
FalsePositive,
|
||||
/// Survivor deceased before rescue
|
||||
Deceased,
|
||||
/// Alert superseded by new information
|
||||
Superseded,
|
||||
/// Alert timed out
|
||||
TimedOut,
|
||||
/// Other resolution
|
||||
Other,
|
||||
}
|
||||
|
||||
/// An alert for rescue teams
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub struct Alert {
|
||||
id: AlertId,
|
||||
survivor_id: SurvivorId,
|
||||
priority: Priority,
|
||||
payload: AlertPayload,
|
||||
status: AlertStatus,
|
||||
created_at: DateTime<Utc>,
|
||||
acknowledged_at: Option<DateTime<Utc>>,
|
||||
acknowledged_by: Option<String>,
|
||||
resolution: Option<AlertResolution>,
|
||||
escalation_count: u32,
|
||||
}
|
||||
|
||||
impl Alert {
|
||||
/// Create a new alert
|
||||
pub fn new(survivor_id: SurvivorId, priority: Priority, payload: AlertPayload) -> Self {
|
||||
Self {
|
||||
id: AlertId::new(),
|
||||
survivor_id,
|
||||
priority,
|
||||
payload,
|
||||
status: AlertStatus::Pending,
|
||||
created_at: Utc::now(),
|
||||
acknowledged_at: None,
|
||||
acknowledged_by: None,
|
||||
resolution: None,
|
||||
escalation_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the alert ID
|
||||
pub fn id(&self) -> &AlertId {
|
||||
&self.id
|
||||
}
|
||||
|
||||
/// Get the survivor ID
|
||||
pub fn survivor_id(&self) -> &SurvivorId {
|
||||
&self.survivor_id
|
||||
}
|
||||
|
||||
/// Get the priority
|
||||
pub fn priority(&self) -> Priority {
|
||||
self.priority
|
||||
}
|
||||
|
||||
/// Get the payload
|
||||
pub fn payload(&self) -> &AlertPayload {
|
||||
&self.payload
|
||||
}
|
||||
|
||||
/// Get the status
|
||||
pub fn status(&self) -> &AlertStatus {
|
||||
&self.status
|
||||
}
|
||||
|
||||
/// Get creation time
|
||||
pub fn created_at(&self) -> &DateTime<Utc> {
|
||||
&self.created_at
|
||||
}
|
||||
|
||||
/// Get acknowledgement time
|
||||
pub fn acknowledged_at(&self) -> Option<&DateTime<Utc>> {
|
||||
self.acknowledged_at.as_ref()
|
||||
}
|
||||
|
||||
/// Get who acknowledged
|
||||
pub fn acknowledged_by(&self) -> Option<&str> {
|
||||
self.acknowledged_by.as_deref()
|
||||
}
|
||||
|
||||
/// Get resolution
|
||||
pub fn resolution(&self) -> Option<&AlertResolution> {
|
||||
self.resolution.as_ref()
|
||||
}
|
||||
|
||||
/// Get escalation count
|
||||
pub fn escalation_count(&self) -> u32 {
|
||||
self.escalation_count
|
||||
}
|
||||
|
||||
/// Acknowledge the alert
|
||||
pub fn acknowledge(&mut self, by: impl Into<String>) {
|
||||
self.status = AlertStatus::Acknowledged;
|
||||
self.acknowledged_at = Some(Utc::now());
|
||||
self.acknowledged_by = Some(by.into());
|
||||
}
|
||||
|
||||
/// Mark as in progress
|
||||
pub fn start_work(&mut self) {
|
||||
if self.status == AlertStatus::Acknowledged {
|
||||
self.status = AlertStatus::InProgress;
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the alert
|
||||
pub fn resolve(&mut self, resolution: AlertResolution) {
|
||||
self.status = AlertStatus::Resolved;
|
||||
self.resolution = Some(resolution);
|
||||
}
|
||||
|
||||
/// Cancel the alert
|
||||
pub fn cancel(&mut self, reason: &str) {
|
||||
self.status = AlertStatus::Cancelled;
|
||||
self.resolution = Some(AlertResolution {
|
||||
resolution_type: ResolutionType::Other,
|
||||
notes: reason.to_string(),
|
||||
resolved_by: None,
|
||||
resolved_at: Utc::now(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Escalate the alert (increase priority)
|
||||
pub fn escalate(&mut self) {
|
||||
self.escalation_count += 1;
|
||||
if self.priority != Priority::Critical {
|
||||
self.priority = match self.priority {
|
||||
Priority::Low => Priority::Medium,
|
||||
Priority::Medium => Priority::High,
|
||||
Priority::High => Priority::Critical,
|
||||
Priority::Critical => Priority::Critical,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if alert is pending
|
||||
pub fn is_pending(&self) -> bool {
|
||||
self.status == AlertStatus::Pending
|
||||
}
|
||||
|
||||
/// Check if alert is active (not resolved/cancelled)
|
||||
pub fn is_active(&self) -> bool {
|
||||
matches!(
|
||||
self.status,
|
||||
AlertStatus::Pending | AlertStatus::Acknowledged | AlertStatus::InProgress
|
||||
)
|
||||
}
|
||||
|
||||
/// Time since alert was created
|
||||
pub fn age(&self) -> chrono::Duration {
|
||||
Utc::now() - self.created_at
|
||||
}
|
||||
|
||||
/// Time since acknowledgement
|
||||
pub fn time_since_ack(&self) -> Option<chrono::Duration> {
|
||||
self.acknowledged_at.map(|t| Utc::now() - t)
|
||||
}
|
||||
|
||||
/// Check if alert needs escalation based on time
|
||||
pub fn needs_escalation(&self, max_pending_seconds: i64) -> bool {
|
||||
if !self.is_pending() {
|
||||
return false;
|
||||
}
|
||||
self.age().num_seconds() > max_pending_seconds
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn create_test_payload() -> AlertPayload {
|
||||
AlertPayload::new(
|
||||
"Survivor Detected",
|
||||
"Vital signs detected in Zone A",
|
||||
TriageStatus::Immediate,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_alert_creation() {
|
||||
let survivor_id = SurvivorId::new();
|
||||
let alert = Alert::new(
|
||||
survivor_id.clone(),
|
||||
Priority::Critical,
|
||||
create_test_payload(),
|
||||
);
|
||||
|
||||
assert_eq!(alert.survivor_id(), &survivor_id);
|
||||
assert_eq!(alert.priority(), Priority::Critical);
|
||||
assert!(alert.is_pending());
|
||||
assert!(alert.is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_alert_lifecycle() {
|
||||
let mut alert = Alert::new(
|
||||
SurvivorId::new(),
|
||||
Priority::High,
|
||||
create_test_payload(),
|
||||
);
|
||||
|
||||
// Initial state
|
||||
assert!(alert.is_pending());
|
||||
|
||||
// Acknowledge
|
||||
alert.acknowledge("Team Alpha");
|
||||
assert_eq!(alert.status(), &AlertStatus::Acknowledged);
|
||||
assert_eq!(alert.acknowledged_by(), Some("Team Alpha"));
|
||||
|
||||
// Start work
|
||||
alert.start_work();
|
||||
assert_eq!(alert.status(), &AlertStatus::InProgress);
|
||||
|
||||
// Resolve
|
||||
alert.resolve(AlertResolution {
|
||||
resolution_type: ResolutionType::Rescued,
|
||||
notes: "Survivor extracted successfully".to_string(),
|
||||
resolved_by: Some("Team Alpha".to_string()),
|
||||
resolved_at: Utc::now(),
|
||||
});
|
||||
assert_eq!(alert.status(), &AlertStatus::Resolved);
|
||||
assert!(!alert.is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_alert_escalation() {
|
||||
let mut alert = Alert::new(
|
||||
SurvivorId::new(),
|
||||
Priority::Low,
|
||||
create_test_payload(),
|
||||
);
|
||||
|
||||
alert.escalate();
|
||||
assert_eq!(alert.priority(), Priority::Medium);
|
||||
assert_eq!(alert.escalation_count(), 1);
|
||||
|
||||
alert.escalate();
|
||||
assert_eq!(alert.priority(), Priority::High);
|
||||
|
||||
alert.escalate();
|
||||
assert_eq!(alert.priority(), Priority::Critical);
|
||||
|
||||
// Can't escalate beyond critical
|
||||
alert.escalate();
|
||||
assert_eq!(alert.priority(), Priority::Critical);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_priority_from_triage() {
|
||||
assert_eq!(Priority::from_triage(&TriageStatus::Immediate), Priority::Critical);
|
||||
assert_eq!(Priority::from_triage(&TriageStatus::Delayed), Priority::High);
|
||||
assert_eq!(Priority::from_triage(&TriageStatus::Minor), Priority::Medium);
|
||||
}
|
||||
}
|
||||
@@ -1,371 +0,0 @@
|
||||
//! 3D coordinate system and location types for survivor localization.
|
||||
|
||||
/// 3D coordinates representing survivor position
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub struct Coordinates3D {
|
||||
/// East-West offset from reference point (meters)
|
||||
pub x: f64,
|
||||
/// North-South offset from reference point (meters)
|
||||
pub y: f64,
|
||||
/// Vertical offset - negative is below surface (meters)
|
||||
pub z: f64,
|
||||
/// Uncertainty bounds for this position
|
||||
pub uncertainty: LocationUncertainty,
|
||||
}
|
||||
|
||||
impl Coordinates3D {
|
||||
/// Create new coordinates with uncertainty
|
||||
pub fn new(x: f64, y: f64, z: f64, uncertainty: LocationUncertainty) -> Self {
|
||||
Self { x, y, z, uncertainty }
|
||||
}
|
||||
|
||||
/// Create coordinates with default uncertainty
|
||||
pub fn with_default_uncertainty(x: f64, y: f64, z: f64) -> Self {
|
||||
Self {
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
uncertainty: LocationUncertainty::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate 3D distance to another point
|
||||
pub fn distance_to(&self, other: &Coordinates3D) -> f64 {
|
||||
let dx = self.x - other.x;
|
||||
let dy = self.y - other.y;
|
||||
let dz = self.z - other.z;
|
||||
(dx * dx + dy * dy + dz * dz).sqrt()
|
||||
}
|
||||
|
||||
/// Calculate horizontal (2D) distance only
|
||||
pub fn horizontal_distance_to(&self, other: &Coordinates3D) -> f64 {
|
||||
let dx = self.x - other.x;
|
||||
let dy = self.y - other.y;
|
||||
(dx * dx + dy * dy).sqrt()
|
||||
}
|
||||
|
||||
/// Get depth below surface (positive value)
|
||||
pub fn depth(&self) -> f64 {
|
||||
-self.z.min(0.0)
|
||||
}
|
||||
|
||||
/// Check if position is below surface
|
||||
pub fn is_buried(&self) -> bool {
|
||||
self.z < 0.0
|
||||
}
|
||||
|
||||
/// Get the 95% confidence radius (horizontal)
|
||||
pub fn confidence_radius(&self) -> f64 {
|
||||
self.uncertainty.horizontal_error
|
||||
}
|
||||
}
|
||||
|
||||
/// Uncertainty bounds for a position estimate
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub struct LocationUncertainty {
|
||||
/// Horizontal error radius at 95% confidence (meters)
|
||||
pub horizontal_error: f64,
|
||||
/// Vertical error at 95% confidence (meters)
|
||||
pub vertical_error: f64,
|
||||
/// Confidence level (0.0-1.0)
|
||||
pub confidence: f64,
|
||||
}
|
||||
|
||||
impl Default for LocationUncertainty {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
horizontal_error: 2.0, // 2 meter default uncertainty
|
||||
vertical_error: 1.0, // 1 meter vertical uncertainty
|
||||
confidence: 0.95, // 95% confidence
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LocationUncertainty {
|
||||
/// Create uncertainty with specific error bounds
|
||||
pub fn new(horizontal_error: f64, vertical_error: f64) -> Self {
|
||||
Self {
|
||||
horizontal_error,
|
||||
vertical_error,
|
||||
confidence: 0.95,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create high-confidence uncertainty
|
||||
pub fn high_confidence(horizontal_error: f64, vertical_error: f64) -> Self {
|
||||
Self {
|
||||
horizontal_error,
|
||||
vertical_error,
|
||||
confidence: 0.99,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if uncertainty is acceptable for rescue operations
|
||||
pub fn is_actionable(&self) -> bool {
|
||||
// Within 3 meters horizontal is generally actionable
|
||||
self.horizontal_error <= 3.0 && self.confidence >= 0.8
|
||||
}
|
||||
|
||||
/// Combine two uncertainties (for sensor fusion)
|
||||
pub fn combine(&self, other: &LocationUncertainty) -> LocationUncertainty {
|
||||
// Weighted combination based on confidence
|
||||
let total_conf = self.confidence + other.confidence;
|
||||
let w1 = self.confidence / total_conf;
|
||||
let w2 = other.confidence / total_conf;
|
||||
|
||||
// Combined uncertainty is reduced when multiple estimates agree
|
||||
let h_var1 = self.horizontal_error * self.horizontal_error;
|
||||
let h_var2 = other.horizontal_error * other.horizontal_error;
|
||||
let combined_h_var = 1.0 / (1.0/h_var1 + 1.0/h_var2);
|
||||
|
||||
let v_var1 = self.vertical_error * self.vertical_error;
|
||||
let v_var2 = other.vertical_error * other.vertical_error;
|
||||
let combined_v_var = 1.0 / (1.0/v_var1 + 1.0/v_var2);
|
||||
|
||||
LocationUncertainty {
|
||||
horizontal_error: combined_h_var.sqrt(),
|
||||
vertical_error: combined_v_var.sqrt(),
|
||||
confidence: (w1 * self.confidence + w2 * other.confidence).min(0.99),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Depth estimate with debris profile
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub struct DepthEstimate {
|
||||
/// Estimated depth in meters
|
||||
pub depth: f64,
|
||||
/// Uncertainty range (plus/minus)
|
||||
pub uncertainty: f64,
|
||||
/// Estimated debris composition
|
||||
pub debris_profile: DebrisProfile,
|
||||
/// Confidence in the estimate
|
||||
pub confidence: f64,
|
||||
}
|
||||
|
||||
impl DepthEstimate {
|
||||
/// Create a new depth estimate
|
||||
pub fn new(
|
||||
depth: f64,
|
||||
uncertainty: f64,
|
||||
debris_profile: DebrisProfile,
|
||||
confidence: f64,
|
||||
) -> Self {
|
||||
Self {
|
||||
depth,
|
||||
uncertainty,
|
||||
debris_profile,
|
||||
confidence,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get minimum possible depth
|
||||
pub fn min_depth(&self) -> f64 {
|
||||
(self.depth - self.uncertainty).max(0.0)
|
||||
}
|
||||
|
||||
/// Get maximum possible depth
|
||||
pub fn max_depth(&self) -> f64 {
|
||||
self.depth + self.uncertainty
|
||||
}
|
||||
|
||||
/// Check if depth is shallow (easier rescue)
|
||||
pub fn is_shallow(&self) -> bool {
|
||||
self.depth < 1.5
|
||||
}
|
||||
|
||||
/// Check if depth is moderate
|
||||
pub fn is_moderate(&self) -> bool {
|
||||
self.depth >= 1.5 && self.depth < 3.0
|
||||
}
|
||||
|
||||
/// Check if depth is deep (difficult rescue)
|
||||
pub fn is_deep(&self) -> bool {
|
||||
self.depth >= 3.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Profile of debris material between sensor and survivor
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub struct DebrisProfile {
|
||||
/// Primary material type
|
||||
pub primary_material: DebrisMaterial,
|
||||
/// Estimated void fraction (0.0-1.0, higher = more air gaps)
|
||||
pub void_fraction: f64,
|
||||
/// Estimated moisture content (affects signal propagation)
|
||||
pub moisture_content: MoistureLevel,
|
||||
/// Whether metal content is detected (blocks signals)
|
||||
pub metal_content: MetalContent,
|
||||
}
|
||||
|
||||
impl Default for DebrisProfile {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
primary_material: DebrisMaterial::Mixed,
|
||||
void_fraction: 0.3,
|
||||
moisture_content: MoistureLevel::Dry,
|
||||
metal_content: MetalContent::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DebrisProfile {
|
||||
/// Calculate signal attenuation factor
|
||||
pub fn attenuation_factor(&self) -> f64 {
|
||||
let base = self.primary_material.attenuation_coefficient();
|
||||
let moisture_factor = self.moisture_content.attenuation_multiplier();
|
||||
let void_factor = 1.0 - (self.void_fraction * 0.3); // Voids reduce attenuation
|
||||
|
||||
base * moisture_factor * void_factor
|
||||
}
|
||||
|
||||
/// Check if debris allows good signal penetration
|
||||
pub fn is_penetrable(&self) -> bool {
|
||||
!matches!(self.metal_content, MetalContent::High | MetalContent::Blocking)
|
||||
&& self.primary_material.attenuation_coefficient() < 5.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Types of debris materials
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub enum DebrisMaterial {
|
||||
/// Lightweight concrete, drywall
|
||||
LightConcrete,
|
||||
/// Heavy concrete, brick
|
||||
HeavyConcrete,
|
||||
/// Wooden structures
|
||||
Wood,
|
||||
/// Soil, earth
|
||||
Soil,
|
||||
/// Mixed rubble (typical collapse)
|
||||
Mixed,
|
||||
/// Snow/ice (avalanche)
|
||||
Snow,
|
||||
/// Metal (poor penetration)
|
||||
Metal,
|
||||
}
|
||||
|
||||
impl DebrisMaterial {
|
||||
/// Get RF attenuation coefficient (dB/meter)
|
||||
pub fn attenuation_coefficient(&self) -> f64 {
|
||||
match self {
|
||||
DebrisMaterial::Snow => 0.5,
|
||||
DebrisMaterial::Wood => 1.5,
|
||||
DebrisMaterial::LightConcrete => 3.0,
|
||||
DebrisMaterial::Soil => 4.0,
|
||||
DebrisMaterial::Mixed => 4.5,
|
||||
DebrisMaterial::HeavyConcrete => 6.0,
|
||||
DebrisMaterial::Metal => 20.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Moisture level in debris
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub enum MoistureLevel {
|
||||
/// Dry conditions
|
||||
Dry,
|
||||
/// Slightly damp
|
||||
Damp,
|
||||
/// Wet (rain, flooding)
|
||||
Wet,
|
||||
/// Saturated (submerged)
|
||||
Saturated,
|
||||
}
|
||||
|
||||
impl MoistureLevel {
|
||||
/// Get attenuation multiplier
|
||||
pub fn attenuation_multiplier(&self) -> f64 {
|
||||
match self {
|
||||
MoistureLevel::Dry => 1.0,
|
||||
MoistureLevel::Damp => 1.3,
|
||||
MoistureLevel::Wet => 1.8,
|
||||
MoistureLevel::Saturated => 2.5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Metal content in debris
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub enum MetalContent {
|
||||
/// No significant metal
|
||||
None,
|
||||
/// Low metal content (rebar, pipes)
|
||||
Low,
|
||||
/// Moderate metal (structural steel)
|
||||
Moderate,
|
||||
/// High metal content
|
||||
High,
|
||||
/// Metal is blocking signal
|
||||
Blocking,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_distance_calculation() {
|
||||
let p1 = Coordinates3D::with_default_uncertainty(0.0, 0.0, 0.0);
|
||||
let p2 = Coordinates3D::with_default_uncertainty(3.0, 4.0, 0.0);
|
||||
|
||||
assert!((p1.distance_to(&p2) - 5.0).abs() < 0.001);
|
||||
assert!((p1.horizontal_distance_to(&p2) - 5.0).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_depth_calculation() {
|
||||
let surface = Coordinates3D::with_default_uncertainty(0.0, 0.0, 0.0);
|
||||
assert!(!surface.is_buried());
|
||||
assert!(surface.depth().abs() < 0.001);
|
||||
|
||||
let buried = Coordinates3D::with_default_uncertainty(0.0, 0.0, -2.5);
|
||||
assert!(buried.is_buried());
|
||||
assert!((buried.depth() - 2.5).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_uncertainty_combination() {
|
||||
let u1 = LocationUncertainty::new(2.0, 1.0);
|
||||
let u2 = LocationUncertainty::new(2.0, 1.0);
|
||||
|
||||
let combined = u1.combine(&u2);
|
||||
|
||||
// Combined uncertainty should be lower than individual
|
||||
assert!(combined.horizontal_error < u1.horizontal_error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_depth_estimate_categories() {
|
||||
let shallow = DepthEstimate::new(1.0, 0.2, DebrisProfile::default(), 0.8);
|
||||
assert!(shallow.is_shallow());
|
||||
|
||||
let moderate = DepthEstimate::new(2.0, 0.3, DebrisProfile::default(), 0.7);
|
||||
assert!(moderate.is_moderate());
|
||||
|
||||
let deep = DepthEstimate::new(4.0, 0.5, DebrisProfile::default(), 0.6);
|
||||
assert!(deep.is_deep());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_debris_attenuation() {
|
||||
let snow = DebrisProfile {
|
||||
primary_material: DebrisMaterial::Snow,
|
||||
..Default::default()
|
||||
};
|
||||
let concrete = DebrisProfile {
|
||||
primary_material: DebrisMaterial::HeavyConcrete,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(snow.attenuation_factor() < concrete.attenuation_factor());
|
||||
assert!(snow.is_penetrable());
|
||||
}
|
||||
}
|
||||
@@ -1,495 +0,0 @@
|
||||
//! Disaster event aggregate root.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
use geo::Point;
|
||||
|
||||
use super::{
|
||||
Survivor, SurvivorId, ScanZone, ScanZoneId,
|
||||
VitalSignsReading, Coordinates3D,
|
||||
};
|
||||
use crate::MatError;
|
||||
|
||||
/// Unique identifier for a disaster event
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub struct DisasterEventId(Uuid);
|
||||
|
||||
impl DisasterEventId {
|
||||
/// Create a new random event ID
|
||||
pub fn new() -> Self {
|
||||
Self(Uuid::new_v4())
|
||||
}
|
||||
|
||||
/// Get the inner UUID
|
||||
pub fn as_uuid(&self) -> &Uuid {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DisasterEventId {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DisasterEventId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Types of disaster events
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub enum DisasterType {
|
||||
/// Building collapse (explosion, structural failure)
|
||||
BuildingCollapse,
|
||||
/// Earthquake
|
||||
Earthquake,
|
||||
/// Landslide or mudslide
|
||||
Landslide,
|
||||
/// Avalanche (snow)
|
||||
Avalanche,
|
||||
/// Flood
|
||||
Flood,
|
||||
/// Mine collapse
|
||||
MineCollapse,
|
||||
/// Industrial accident
|
||||
Industrial,
|
||||
/// Tunnel collapse
|
||||
TunnelCollapse,
|
||||
/// Unknown or other
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl DisasterType {
|
||||
/// Get typical debris profile for this disaster type
|
||||
pub fn typical_debris_profile(&self) -> super::DebrisProfile {
|
||||
use super::{DebrisProfile, DebrisMaterial, MoistureLevel, MetalContent};
|
||||
|
||||
match self {
|
||||
DisasterType::BuildingCollapse => DebrisProfile {
|
||||
primary_material: DebrisMaterial::Mixed,
|
||||
void_fraction: 0.25,
|
||||
moisture_content: MoistureLevel::Dry,
|
||||
metal_content: MetalContent::Moderate,
|
||||
},
|
||||
DisasterType::Earthquake => DebrisProfile {
|
||||
primary_material: DebrisMaterial::HeavyConcrete,
|
||||
void_fraction: 0.2,
|
||||
moisture_content: MoistureLevel::Dry,
|
||||
metal_content: MetalContent::Moderate,
|
||||
},
|
||||
DisasterType::Avalanche => DebrisProfile {
|
||||
primary_material: DebrisMaterial::Snow,
|
||||
void_fraction: 0.4,
|
||||
moisture_content: MoistureLevel::Wet,
|
||||
metal_content: MetalContent::None,
|
||||
},
|
||||
DisasterType::Landslide => DebrisProfile {
|
||||
primary_material: DebrisMaterial::Soil,
|
||||
void_fraction: 0.15,
|
||||
moisture_content: MoistureLevel::Wet,
|
||||
metal_content: MetalContent::None,
|
||||
},
|
||||
DisasterType::Flood => DebrisProfile {
|
||||
primary_material: DebrisMaterial::Mixed,
|
||||
void_fraction: 0.3,
|
||||
moisture_content: MoistureLevel::Saturated,
|
||||
metal_content: MetalContent::Low,
|
||||
},
|
||||
DisasterType::MineCollapse | DisasterType::TunnelCollapse => DebrisProfile {
|
||||
primary_material: DebrisMaterial::Soil,
|
||||
void_fraction: 0.2,
|
||||
moisture_content: MoistureLevel::Damp,
|
||||
metal_content: MetalContent::Low,
|
||||
},
|
||||
DisasterType::Industrial => DebrisProfile {
|
||||
primary_material: DebrisMaterial::Metal,
|
||||
void_fraction: 0.35,
|
||||
moisture_content: MoistureLevel::Dry,
|
||||
metal_content: MetalContent::High,
|
||||
},
|
||||
DisasterType::Unknown => DebrisProfile::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get expected maximum survival time (hours)
|
||||
pub fn expected_survival_hours(&self) -> u32 {
|
||||
match self {
|
||||
DisasterType::Avalanche => 2, // Limited air, hypothermia
|
||||
DisasterType::Flood => 6, // Drowning risk
|
||||
DisasterType::MineCollapse => 72, // Air supply critical
|
||||
DisasterType::BuildingCollapse => 96,
|
||||
DisasterType::Earthquake => 120,
|
||||
DisasterType::Landslide => 48,
|
||||
DisasterType::TunnelCollapse => 72,
|
||||
DisasterType::Industrial => 72,
|
||||
DisasterType::Unknown => 72,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DisasterType {
|
||||
fn default() -> Self {
|
||||
Self::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
/// Current status of the disaster event
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub enum EventStatus {
|
||||
/// Event just reported, setting up
|
||||
Initializing,
|
||||
/// Active search and rescue
|
||||
Active,
|
||||
/// Search suspended (weather, safety)
|
||||
Suspended,
|
||||
/// Primary rescue complete, secondary search
|
||||
SecondarySearch,
|
||||
/// Event closed
|
||||
Closed,
|
||||
}
|
||||
|
||||
/// Aggregate root for a disaster event
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub struct DisasterEvent {
|
||||
id: DisasterEventId,
|
||||
event_type: DisasterType,
|
||||
start_time: DateTime<Utc>,
|
||||
location: Point<f64>,
|
||||
description: String,
|
||||
scan_zones: Vec<ScanZone>,
|
||||
survivors: Vec<Survivor>,
|
||||
status: EventStatus,
|
||||
metadata: EventMetadata,
|
||||
}
|
||||
|
||||
/// Additional metadata for a disaster event
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub struct EventMetadata {
|
||||
/// Estimated number of people in area at time of disaster
|
||||
pub estimated_occupancy: Option<u32>,
|
||||
/// Known survivors (already rescued)
|
||||
pub confirmed_rescued: u32,
|
||||
/// Known fatalities
|
||||
pub confirmed_deceased: u32,
|
||||
/// Weather conditions
|
||||
pub weather: Option<String>,
|
||||
/// Lead agency
|
||||
pub lead_agency: Option<String>,
|
||||
/// Notes
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
impl DisasterEvent {
|
||||
/// Create a new disaster event
|
||||
pub fn new(
|
||||
event_type: DisasterType,
|
||||
location: Point<f64>,
|
||||
description: &str,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: DisasterEventId::new(),
|
||||
event_type,
|
||||
start_time: Utc::now(),
|
||||
location,
|
||||
description: description.to_string(),
|
||||
scan_zones: Vec::new(),
|
||||
survivors: Vec::new(),
|
||||
status: EventStatus::Initializing,
|
||||
metadata: EventMetadata::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the event ID
|
||||
pub fn id(&self) -> &DisasterEventId {
|
||||
&self.id
|
||||
}
|
||||
|
||||
/// Get the event type
|
||||
pub fn event_type(&self) -> &DisasterType {
|
||||
&self.event_type
|
||||
}
|
||||
|
||||
/// Get the start time
|
||||
pub fn start_time(&self) -> &DateTime<Utc> {
|
||||
&self.start_time
|
||||
}
|
||||
|
||||
/// Get the location
|
||||
pub fn location(&self) -> &Point<f64> {
|
||||
&self.location
|
||||
}
|
||||
|
||||
/// Get the description
|
||||
pub fn description(&self) -> &str {
|
||||
&self.description
|
||||
}
|
||||
|
||||
/// Get the scan zones
|
||||
pub fn zones(&self) -> &[ScanZone] {
|
||||
&self.scan_zones
|
||||
}
|
||||
|
||||
/// Get mutable scan zones
|
||||
pub fn zones_mut(&mut self) -> &mut [ScanZone] {
|
||||
&mut self.scan_zones
|
||||
}
|
||||
|
||||
/// Get the survivors
|
||||
pub fn survivors(&self) -> Vec<&Survivor> {
|
||||
self.survivors.iter().collect()
|
||||
}
|
||||
|
||||
/// Get mutable survivors
|
||||
pub fn survivors_mut(&mut self) -> &mut [Survivor] {
|
||||
&mut self.survivors
|
||||
}
|
||||
|
||||
/// Get the current status
|
||||
pub fn status(&self) -> &EventStatus {
|
||||
&self.status
|
||||
}
|
||||
|
||||
/// Get metadata
|
||||
pub fn metadata(&self) -> &EventMetadata {
|
||||
&self.metadata
|
||||
}
|
||||
|
||||
/// Get mutable metadata
|
||||
pub fn metadata_mut(&mut self) -> &mut EventMetadata {
|
||||
&mut self.metadata
|
||||
}
|
||||
|
||||
/// Add a scan zone
|
||||
pub fn add_zone(&mut self, zone: ScanZone) {
|
||||
self.scan_zones.push(zone);
|
||||
|
||||
// Activate event if first zone
|
||||
if self.status == EventStatus::Initializing {
|
||||
self.status = EventStatus::Active;
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a scan zone
|
||||
pub fn remove_zone(&mut self, zone_id: &ScanZoneId) {
|
||||
self.scan_zones.retain(|z| z.id() != zone_id);
|
||||
}
|
||||
|
||||
/// Record a new detection
|
||||
pub fn record_detection(
|
||||
&mut self,
|
||||
zone_id: ScanZoneId,
|
||||
vitals: VitalSignsReading,
|
||||
location: Option<Coordinates3D>,
|
||||
) -> Result<&Survivor, MatError> {
|
||||
// Check if this might be an existing survivor
|
||||
let existing_id = if let Some(loc) = &location {
|
||||
self.find_nearby_survivor(loc, 2.0).cloned()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(existing) = existing_id {
|
||||
// Update existing survivor
|
||||
let survivor = self.survivors.iter_mut()
|
||||
.find(|s| s.id() == &existing)
|
||||
.ok_or_else(|| MatError::Domain("Survivor not found".into()))?;
|
||||
survivor.update_vitals(vitals);
|
||||
if let Some(l) = location {
|
||||
survivor.update_location(l);
|
||||
}
|
||||
return Ok(survivor);
|
||||
}
|
||||
|
||||
// Create new survivor
|
||||
let survivor = Survivor::new(zone_id, vitals, location);
|
||||
self.survivors.push(survivor);
|
||||
// Safe: we just pushed, so last() is always Some
|
||||
Ok(self.survivors.last().expect("survivors is non-empty after push"))
|
||||
}
|
||||
|
||||
/// Find a survivor near a location
|
||||
fn find_nearby_survivor(&self, location: &Coordinates3D, radius: f64) -> Option<&SurvivorId> {
|
||||
for survivor in &self.survivors {
|
||||
if let Some(loc) = survivor.location() {
|
||||
if loc.distance_to(location) < radius {
|
||||
return Some(survivor.id());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Get survivor by ID
|
||||
pub fn get_survivor(&self, id: &SurvivorId) -> Option<&Survivor> {
|
||||
self.survivors.iter().find(|s| s.id() == id)
|
||||
}
|
||||
|
||||
/// Get mutable survivor by ID
|
||||
pub fn get_survivor_mut(&mut self, id: &SurvivorId) -> Option<&mut Survivor> {
|
||||
self.survivors.iter_mut().find(|s| s.id() == id)
|
||||
}
|
||||
|
||||
/// Get zone by ID
|
||||
pub fn get_zone(&self, id: &ScanZoneId) -> Option<&ScanZone> {
|
||||
self.scan_zones.iter().find(|z| z.id() == id)
|
||||
}
|
||||
|
||||
/// Set event status
|
||||
pub fn set_status(&mut self, status: EventStatus) {
|
||||
self.status = status;
|
||||
}
|
||||
|
||||
/// Suspend operations
|
||||
pub fn suspend(&mut self, reason: &str) {
|
||||
self.status = EventStatus::Suspended;
|
||||
self.metadata.notes.push(format!(
|
||||
"[{}] Suspended: {}",
|
||||
Utc::now().format("%Y-%m-%d %H:%M:%S"),
|
||||
reason
|
||||
));
|
||||
}
|
||||
|
||||
/// Resume operations
|
||||
pub fn resume(&mut self) {
|
||||
if self.status == EventStatus::Suspended {
|
||||
self.status = EventStatus::Active;
|
||||
self.metadata.notes.push(format!(
|
||||
"[{}] Resumed operations",
|
||||
Utc::now().format("%Y-%m-%d %H:%M:%S")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the event
|
||||
pub fn close(&mut self) {
|
||||
self.status = EventStatus::Closed;
|
||||
}
|
||||
|
||||
/// Get time since event started
|
||||
pub fn elapsed_time(&self) -> chrono::Duration {
|
||||
Utc::now() - self.start_time
|
||||
}
|
||||
|
||||
/// Get count of survivors by triage status
|
||||
pub fn triage_counts(&self) -> TriageCounts {
|
||||
use super::TriageStatus;
|
||||
|
||||
let mut counts = TriageCounts::default();
|
||||
for survivor in &self.survivors {
|
||||
match survivor.triage_status() {
|
||||
TriageStatus::Immediate => counts.immediate += 1,
|
||||
TriageStatus::Delayed => counts.delayed += 1,
|
||||
TriageStatus::Minor => counts.minor += 1,
|
||||
TriageStatus::Deceased => counts.deceased += 1,
|
||||
TriageStatus::Unknown => counts.unknown += 1,
|
||||
}
|
||||
}
|
||||
counts
|
||||
}
|
||||
}
|
||||
|
||||
/// Triage status counts
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TriageCounts {
|
||||
/// Immediate (Red)
|
||||
pub immediate: u32,
|
||||
/// Delayed (Yellow)
|
||||
pub delayed: u32,
|
||||
/// Minor (Green)
|
||||
pub minor: u32,
|
||||
/// Deceased (Black)
|
||||
pub deceased: u32,
|
||||
/// Unknown
|
||||
pub unknown: u32,
|
||||
}
|
||||
|
||||
impl TriageCounts {
|
||||
/// Total count
|
||||
pub fn total(&self) -> u32 {
|
||||
self.immediate + self.delayed + self.minor + self.deceased + self.unknown
|
||||
}
|
||||
|
||||
/// Count of living survivors
|
||||
pub fn living(&self) -> u32 {
|
||||
self.immediate + self.delayed + self.minor
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::domain::{ZoneBounds, BreathingPattern, BreathingType, ConfidenceScore};
|
||||
|
||||
fn create_test_vitals() -> VitalSignsReading {
|
||||
VitalSignsReading {
|
||||
breathing: Some(BreathingPattern {
|
||||
rate_bpm: 16.0,
|
||||
amplitude: 0.8,
|
||||
regularity: 0.9,
|
||||
pattern_type: BreathingType::Normal,
|
||||
}),
|
||||
heartbeat: None,
|
||||
movement: Default::default(),
|
||||
timestamp: Utc::now(),
|
||||
confidence: ConfidenceScore::new(0.8),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_event_creation() {
|
||||
let event = DisasterEvent::new(
|
||||
DisasterType::Earthquake,
|
||||
Point::new(-122.4194, 37.7749),
|
||||
"Test earthquake event",
|
||||
);
|
||||
|
||||
assert!(matches!(event.event_type(), DisasterType::Earthquake));
|
||||
assert_eq!(event.status(), &EventStatus::Initializing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_zone_activates_event() {
|
||||
let mut event = DisasterEvent::new(
|
||||
DisasterType::BuildingCollapse,
|
||||
Point::new(0.0, 0.0),
|
||||
"Test",
|
||||
);
|
||||
|
||||
assert_eq!(event.status(), &EventStatus::Initializing);
|
||||
|
||||
let zone = ScanZone::new("Zone A", ZoneBounds::rectangle(0.0, 0.0, 10.0, 10.0));
|
||||
event.add_zone(zone);
|
||||
|
||||
assert_eq!(event.status(), &EventStatus::Active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_detection() {
|
||||
let mut event = DisasterEvent::new(
|
||||
DisasterType::Earthquake,
|
||||
Point::new(0.0, 0.0),
|
||||
"Test",
|
||||
);
|
||||
|
||||
let zone = ScanZone::new("Zone A", ZoneBounds::rectangle(0.0, 0.0, 10.0, 10.0));
|
||||
let zone_id = zone.id().clone();
|
||||
event.add_zone(zone);
|
||||
|
||||
let vitals = create_test_vitals();
|
||||
event.record_detection(zone_id, vitals, None).unwrap();
|
||||
|
||||
assert_eq!(event.survivors().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disaster_type_survival_hours() {
|
||||
assert!(DisasterType::Avalanche.expected_survival_hours() < DisasterType::Earthquake.expected_survival_hours());
|
||||
}
|
||||
}
|
||||
@@ -1,497 +0,0 @@
|
||||
//! Domain events for the wifi-Mat system.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use super::{
|
||||
AlertId, Coordinates3D, Priority, ScanZoneId, SurvivorId,
|
||||
TriageStatus, VitalSignsReading, AlertResolution,
|
||||
};
|
||||
|
||||
/// All domain events in the system
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub enum DomainEvent {
|
||||
/// Detection-related events
|
||||
Detection(DetectionEvent),
|
||||
/// Alert-related events
|
||||
Alert(AlertEvent),
|
||||
/// Zone-related events
|
||||
Zone(ZoneEvent),
|
||||
/// System-level events
|
||||
System(SystemEvent),
|
||||
}
|
||||
|
||||
impl DomainEvent {
|
||||
/// Get the timestamp of the event
|
||||
pub fn timestamp(&self) -> DateTime<Utc> {
|
||||
match self {
|
||||
DomainEvent::Detection(e) => e.timestamp(),
|
||||
DomainEvent::Alert(e) => e.timestamp(),
|
||||
DomainEvent::Zone(e) => e.timestamp(),
|
||||
DomainEvent::System(e) => e.timestamp(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get event type name
|
||||
pub fn event_type(&self) -> &'static str {
|
||||
match self {
|
||||
DomainEvent::Detection(e) => e.event_type(),
|
||||
DomainEvent::Alert(e) => e.event_type(),
|
||||
DomainEvent::Zone(e) => e.event_type(),
|
||||
DomainEvent::System(e) => e.event_type(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Detection-related events
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub enum DetectionEvent {
|
||||
/// New survivor detected
|
||||
SurvivorDetected {
|
||||
survivor_id: SurvivorId,
|
||||
zone_id: ScanZoneId,
|
||||
vital_signs: VitalSignsReading,
|
||||
location: Option<Coordinates3D>,
|
||||
timestamp: DateTime<Utc>,
|
||||
},
|
||||
|
||||
/// Survivor vital signs updated
|
||||
VitalsUpdated {
|
||||
survivor_id: SurvivorId,
|
||||
previous_triage: TriageStatus,
|
||||
current_triage: TriageStatus,
|
||||
confidence: f64,
|
||||
timestamp: DateTime<Utc>,
|
||||
},
|
||||
|
||||
/// Survivor triage status changed
|
||||
TriageStatusChanged {
|
||||
survivor_id: SurvivorId,
|
||||
previous: TriageStatus,
|
||||
current: TriageStatus,
|
||||
reason: String,
|
||||
timestamp: DateTime<Utc>,
|
||||
},
|
||||
|
||||
/// Survivor location refined
|
||||
LocationRefined {
|
||||
survivor_id: SurvivorId,
|
||||
previous: Option<Coordinates3D>,
|
||||
current: Coordinates3D,
|
||||
uncertainty_reduced: bool,
|
||||
timestamp: DateTime<Utc>,
|
||||
},
|
||||
|
||||
/// Survivor no longer detected
|
||||
SurvivorLost {
|
||||
survivor_id: SurvivorId,
|
||||
last_detection: DateTime<Utc>,
|
||||
reason: LostReason,
|
||||
timestamp: DateTime<Utc>,
|
||||
},
|
||||
|
||||
/// Survivor rescued
|
||||
SurvivorRescued {
|
||||
survivor_id: SurvivorId,
|
||||
rescue_team: Option<String>,
|
||||
timestamp: DateTime<Utc>,
|
||||
},
|
||||
|
||||
/// Survivor marked deceased
|
||||
SurvivorDeceased {
|
||||
survivor_id: SurvivorId,
|
||||
timestamp: DateTime<Utc>,
|
||||
},
|
||||
}
|
||||
|
||||
impl DetectionEvent {
|
||||
/// Get the timestamp
|
||||
pub fn timestamp(&self) -> DateTime<Utc> {
|
||||
match self {
|
||||
Self::SurvivorDetected { timestamp, .. } => *timestamp,
|
||||
Self::VitalsUpdated { timestamp, .. } => *timestamp,
|
||||
Self::TriageStatusChanged { timestamp, .. } => *timestamp,
|
||||
Self::LocationRefined { timestamp, .. } => *timestamp,
|
||||
Self::SurvivorLost { timestamp, .. } => *timestamp,
|
||||
Self::SurvivorRescued { timestamp, .. } => *timestamp,
|
||||
Self::SurvivorDeceased { timestamp, .. } => *timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the event type name
|
||||
pub fn event_type(&self) -> &'static str {
|
||||
match self {
|
||||
Self::SurvivorDetected { .. } => "SurvivorDetected",
|
||||
Self::VitalsUpdated { .. } => "VitalsUpdated",
|
||||
Self::TriageStatusChanged { .. } => "TriageStatusChanged",
|
||||
Self::LocationRefined { .. } => "LocationRefined",
|
||||
Self::SurvivorLost { .. } => "SurvivorLost",
|
||||
Self::SurvivorRescued { .. } => "SurvivorRescued",
|
||||
Self::SurvivorDeceased { .. } => "SurvivorDeceased",
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the survivor ID associated with this event
|
||||
pub fn survivor_id(&self) -> &SurvivorId {
|
||||
match self {
|
||||
Self::SurvivorDetected { survivor_id, .. } => survivor_id,
|
||||
Self::VitalsUpdated { survivor_id, .. } => survivor_id,
|
||||
Self::TriageStatusChanged { survivor_id, .. } => survivor_id,
|
||||
Self::LocationRefined { survivor_id, .. } => survivor_id,
|
||||
Self::SurvivorLost { survivor_id, .. } => survivor_id,
|
||||
Self::SurvivorRescued { survivor_id, .. } => survivor_id,
|
||||
Self::SurvivorDeceased { survivor_id, .. } => survivor_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reasons for losing a survivor signal
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub enum LostReason {
|
||||
/// Survivor was rescued (signal expected to stop)
|
||||
Rescued,
|
||||
/// Detection determined to be false positive
|
||||
FalsePositive,
|
||||
/// Signal lost (interference, debris shift, etc.)
|
||||
SignalLost,
|
||||
/// Zone was deactivated
|
||||
ZoneDeactivated,
|
||||
/// Sensor malfunction
|
||||
SensorFailure,
|
||||
/// Unknown reason
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Alert-related events
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub enum AlertEvent {
|
||||
/// New alert generated
|
||||
AlertGenerated {
|
||||
alert_id: AlertId,
|
||||
survivor_id: SurvivorId,
|
||||
priority: Priority,
|
||||
timestamp: DateTime<Utc>,
|
||||
},
|
||||
|
||||
/// Alert acknowledged by rescue team
|
||||
AlertAcknowledged {
|
||||
alert_id: AlertId,
|
||||
acknowledged_by: String,
|
||||
timestamp: DateTime<Utc>,
|
||||
},
|
||||
|
||||
/// Alert escalated
|
||||
AlertEscalated {
|
||||
alert_id: AlertId,
|
||||
previous_priority: Priority,
|
||||
new_priority: Priority,
|
||||
reason: String,
|
||||
timestamp: DateTime<Utc>,
|
||||
},
|
||||
|
||||
/// Alert resolved
|
||||
AlertResolved {
|
||||
alert_id: AlertId,
|
||||
resolution: AlertResolution,
|
||||
timestamp: DateTime<Utc>,
|
||||
},
|
||||
|
||||
/// Alert cancelled
|
||||
AlertCancelled {
|
||||
alert_id: AlertId,
|
||||
reason: String,
|
||||
timestamp: DateTime<Utc>,
|
||||
},
|
||||
}
|
||||
|
||||
impl AlertEvent {
|
||||
/// Get the timestamp
|
||||
pub fn timestamp(&self) -> DateTime<Utc> {
|
||||
match self {
|
||||
Self::AlertGenerated { timestamp, .. } => *timestamp,
|
||||
Self::AlertAcknowledged { timestamp, .. } => *timestamp,
|
||||
Self::AlertEscalated { timestamp, .. } => *timestamp,
|
||||
Self::AlertResolved { timestamp, .. } => *timestamp,
|
||||
Self::AlertCancelled { timestamp, .. } => *timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the event type name
|
||||
pub fn event_type(&self) -> &'static str {
|
||||
match self {
|
||||
Self::AlertGenerated { .. } => "AlertGenerated",
|
||||
Self::AlertAcknowledged { .. } => "AlertAcknowledged",
|
||||
Self::AlertEscalated { .. } => "AlertEscalated",
|
||||
Self::AlertResolved { .. } => "AlertResolved",
|
||||
Self::AlertCancelled { .. } => "AlertCancelled",
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the alert ID associated with this event
|
||||
pub fn alert_id(&self) -> &AlertId {
|
||||
match self {
|
||||
Self::AlertGenerated { alert_id, .. } => alert_id,
|
||||
Self::AlertAcknowledged { alert_id, .. } => alert_id,
|
||||
Self::AlertEscalated { alert_id, .. } => alert_id,
|
||||
Self::AlertResolved { alert_id, .. } => alert_id,
|
||||
Self::AlertCancelled { alert_id, .. } => alert_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Zone-related events
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub enum ZoneEvent {
|
||||
/// Zone activated
|
||||
ZoneActivated {
|
||||
zone_id: ScanZoneId,
|
||||
zone_name: String,
|
||||
timestamp: DateTime<Utc>,
|
||||
},
|
||||
|
||||
/// Zone scan completed
|
||||
ZoneScanCompleted {
|
||||
zone_id: ScanZoneId,
|
||||
detections_found: u32,
|
||||
scan_duration_ms: u64,
|
||||
timestamp: DateTime<Utc>,
|
||||
},
|
||||
|
||||
/// Zone paused
|
||||
ZonePaused {
|
||||
zone_id: ScanZoneId,
|
||||
reason: String,
|
||||
timestamp: DateTime<Utc>,
|
||||
},
|
||||
|
||||
/// Zone resumed
|
||||
ZoneResumed {
|
||||
zone_id: ScanZoneId,
|
||||
timestamp: DateTime<Utc>,
|
||||
},
|
||||
|
||||
/// Zone marked complete
|
||||
ZoneCompleted {
|
||||
zone_id: ScanZoneId,
|
||||
total_survivors_found: u32,
|
||||
timestamp: DateTime<Utc>,
|
||||
},
|
||||
|
||||
/// Zone deactivated
|
||||
ZoneDeactivated {
|
||||
zone_id: ScanZoneId,
|
||||
reason: String,
|
||||
timestamp: DateTime<Utc>,
|
||||
},
|
||||
}
|
||||
|
||||
impl ZoneEvent {
|
||||
/// Get the timestamp
|
||||
pub fn timestamp(&self) -> DateTime<Utc> {
|
||||
match self {
|
||||
Self::ZoneActivated { timestamp, .. } => *timestamp,
|
||||
Self::ZoneScanCompleted { timestamp, .. } => *timestamp,
|
||||
Self::ZonePaused { timestamp, .. } => *timestamp,
|
||||
Self::ZoneResumed { timestamp, .. } => *timestamp,
|
||||
Self::ZoneCompleted { timestamp, .. } => *timestamp,
|
||||
Self::ZoneDeactivated { timestamp, .. } => *timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the event type name
|
||||
pub fn event_type(&self) -> &'static str {
|
||||
match self {
|
||||
Self::ZoneActivated { .. } => "ZoneActivated",
|
||||
Self::ZoneScanCompleted { .. } => "ZoneScanCompleted",
|
||||
Self::ZonePaused { .. } => "ZonePaused",
|
||||
Self::ZoneResumed { .. } => "ZoneResumed",
|
||||
Self::ZoneCompleted { .. } => "ZoneCompleted",
|
||||
Self::ZoneDeactivated { .. } => "ZoneDeactivated",
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the zone ID associated with this event
|
||||
pub fn zone_id(&self) -> &ScanZoneId {
|
||||
match self {
|
||||
Self::ZoneActivated { zone_id, .. } => zone_id,
|
||||
Self::ZoneScanCompleted { zone_id, .. } => zone_id,
|
||||
Self::ZonePaused { zone_id, .. } => zone_id,
|
||||
Self::ZoneResumed { zone_id, .. } => zone_id,
|
||||
Self::ZoneCompleted { zone_id, .. } => zone_id,
|
||||
Self::ZoneDeactivated { zone_id, .. } => zone_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// System-level events
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub enum SystemEvent {
|
||||
/// System started
|
||||
SystemStarted {
|
||||
version: String,
|
||||
timestamp: DateTime<Utc>,
|
||||
},
|
||||
|
||||
/// System stopped
|
||||
SystemStopped {
|
||||
reason: String,
|
||||
timestamp: DateTime<Utc>,
|
||||
},
|
||||
|
||||
/// Sensor connected
|
||||
SensorConnected {
|
||||
sensor_id: String,
|
||||
zone_id: ScanZoneId,
|
||||
timestamp: DateTime<Utc>,
|
||||
},
|
||||
|
||||
/// Sensor disconnected
|
||||
SensorDisconnected {
|
||||
sensor_id: String,
|
||||
reason: String,
|
||||
timestamp: DateTime<Utc>,
|
||||
},
|
||||
|
||||
/// Configuration changed
|
||||
ConfigChanged {
|
||||
setting: String,
|
||||
previous_value: String,
|
||||
new_value: String,
|
||||
timestamp: DateTime<Utc>,
|
||||
},
|
||||
|
||||
/// Error occurred
|
||||
ErrorOccurred {
|
||||
error_type: String,
|
||||
message: String,
|
||||
severity: ErrorSeverity,
|
||||
timestamp: DateTime<Utc>,
|
||||
},
|
||||
}
|
||||
|
||||
impl SystemEvent {
|
||||
/// Get the timestamp
|
||||
pub fn timestamp(&self) -> DateTime<Utc> {
|
||||
match self {
|
||||
Self::SystemStarted { timestamp, .. } => *timestamp,
|
||||
Self::SystemStopped { timestamp, .. } => *timestamp,
|
||||
Self::SensorConnected { timestamp, .. } => *timestamp,
|
||||
Self::SensorDisconnected { timestamp, .. } => *timestamp,
|
||||
Self::ConfigChanged { timestamp, .. } => *timestamp,
|
||||
Self::ErrorOccurred { timestamp, .. } => *timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the event type name
|
||||
pub fn event_type(&self) -> &'static str {
|
||||
match self {
|
||||
Self::SystemStarted { .. } => "SystemStarted",
|
||||
Self::SystemStopped { .. } => "SystemStopped",
|
||||
Self::SensorConnected { .. } => "SensorConnected",
|
||||
Self::SensorDisconnected { .. } => "SensorDisconnected",
|
||||
Self::ConfigChanged { .. } => "ConfigChanged",
|
||||
Self::ErrorOccurred { .. } => "ErrorOccurred",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Error severity levels
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub enum ErrorSeverity {
|
||||
/// Warning - operation continues
|
||||
Warning,
|
||||
/// Error - operation may be affected
|
||||
Error,
|
||||
/// Critical - immediate attention required
|
||||
Critical,
|
||||
}
|
||||
|
||||
/// Event store for persisting domain events
|
||||
pub trait EventStore: Send + Sync {
|
||||
/// Append an event to the store
|
||||
fn append(&self, event: DomainEvent) -> Result<(), crate::MatError>;
|
||||
|
||||
/// Get all events
|
||||
fn all(&self) -> Result<Vec<DomainEvent>, crate::MatError>;
|
||||
|
||||
/// Get events since a timestamp
|
||||
fn since(&self, timestamp: DateTime<Utc>) -> Result<Vec<DomainEvent>, crate::MatError>;
|
||||
|
||||
/// Get events for a specific survivor
|
||||
fn for_survivor(&self, survivor_id: &SurvivorId) -> Result<Vec<DomainEvent>, crate::MatError>;
|
||||
}
|
||||
|
||||
/// In-memory event store implementation
|
||||
#[derive(Debug, Default)]
|
||||
pub struct InMemoryEventStore {
|
||||
events: parking_lot::RwLock<Vec<DomainEvent>>,
|
||||
}
|
||||
|
||||
impl InMemoryEventStore {
|
||||
/// Create a new in-memory event store
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventStore for InMemoryEventStore {
|
||||
fn append(&self, event: DomainEvent) -> Result<(), crate::MatError> {
|
||||
self.events.write().push(event);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn all(&self) -> Result<Vec<DomainEvent>, crate::MatError> {
|
||||
Ok(self.events.read().clone())
|
||||
}
|
||||
|
||||
fn since(&self, timestamp: DateTime<Utc>) -> Result<Vec<DomainEvent>, crate::MatError> {
|
||||
Ok(self
|
||||
.events
|
||||
.read()
|
||||
.iter()
|
||||
.filter(|e| e.timestamp() >= timestamp)
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn for_survivor(&self, survivor_id: &SurvivorId) -> Result<Vec<DomainEvent>, crate::MatError> {
|
||||
Ok(self
|
||||
.events
|
||||
.read()
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
if let DomainEvent::Detection(de) = e {
|
||||
de.survivor_id() == survivor_id
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_in_memory_event_store() {
|
||||
let store = InMemoryEventStore::new();
|
||||
|
||||
let event = DomainEvent::System(SystemEvent::SystemStarted {
|
||||
version: "1.0.0".to_string(),
|
||||
timestamp: Utc::now(),
|
||||
});
|
||||
|
||||
store.append(event).unwrap();
|
||||
let events = store.all().unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
//! Domain module containing core entities, value objects, and domain events.
|
||||
//!
|
||||
//! This module follows Domain-Driven Design principles with:
|
||||
//! - **Entities**: Objects with identity (Survivor, DisasterEvent, ScanZone)
|
||||
//! - **Value Objects**: Immutable objects without identity (VitalSignsReading, Coordinates3D)
|
||||
//! - **Domain Events**: Events that capture domain significance
|
||||
//! - **Aggregates**: Consistency boundaries (DisasterEvent is the root)
|
||||
|
||||
pub mod alert;
|
||||
pub mod coordinates;
|
||||
pub mod disaster_event;
|
||||
pub mod events;
|
||||
pub mod scan_zone;
|
||||
pub mod survivor;
|
||||
pub mod triage;
|
||||
pub mod vital_signs;
|
||||
|
||||
// Re-export all domain types
|
||||
pub use alert::*;
|
||||
pub use coordinates::*;
|
||||
pub use disaster_event::*;
|
||||
pub use events::*;
|
||||
pub use scan_zone::*;
|
||||
pub use survivor::*;
|
||||
pub use triage::*;
|
||||
pub use vital_signs::*;
|
||||
@@ -1,494 +0,0 @@
|
||||
//! Scan zone entity for defining areas to monitor.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Unique identifier for a scan zone
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub struct ScanZoneId(Uuid);
|
||||
|
||||
impl ScanZoneId {
|
||||
/// Create a new random zone ID
|
||||
pub fn new() -> Self {
|
||||
Self(Uuid::new_v4())
|
||||
}
|
||||
|
||||
/// Get the inner UUID
|
||||
pub fn as_uuid(&self) -> &Uuid {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ScanZoneId {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ScanZoneId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounds of a scan zone
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub enum ZoneBounds {
|
||||
/// Rectangular zone
|
||||
Rectangle {
|
||||
/// Minimum X coordinate
|
||||
min_x: f64,
|
||||
/// Minimum Y coordinate
|
||||
min_y: f64,
|
||||
/// Maximum X coordinate
|
||||
max_x: f64,
|
||||
/// Maximum Y coordinate
|
||||
max_y: f64,
|
||||
},
|
||||
/// Circular zone
|
||||
Circle {
|
||||
/// Center X coordinate
|
||||
center_x: f64,
|
||||
/// Center Y coordinate
|
||||
center_y: f64,
|
||||
/// Radius in meters
|
||||
radius: f64,
|
||||
},
|
||||
/// Polygon zone (ordered vertices)
|
||||
Polygon {
|
||||
/// List of (x, y) vertices
|
||||
vertices: Vec<(f64, f64)>,
|
||||
},
|
||||
}
|
||||
|
||||
impl ZoneBounds {
|
||||
/// Create a rectangular zone
|
||||
pub fn rectangle(min_x: f64, min_y: f64, max_x: f64, max_y: f64) -> Self {
|
||||
ZoneBounds::Rectangle { min_x, min_y, max_x, max_y }
|
||||
}
|
||||
|
||||
/// Create a circular zone
|
||||
pub fn circle(center_x: f64, center_y: f64, radius: f64) -> Self {
|
||||
ZoneBounds::Circle { center_x, center_y, radius }
|
||||
}
|
||||
|
||||
/// Create a polygon zone
|
||||
pub fn polygon(vertices: Vec<(f64, f64)>) -> Self {
|
||||
ZoneBounds::Polygon { vertices }
|
||||
}
|
||||
|
||||
/// Calculate the area of the zone in square meters
|
||||
pub fn area(&self) -> f64 {
|
||||
match self {
|
||||
ZoneBounds::Rectangle { min_x, min_y, max_x, max_y } => {
|
||||
(max_x - min_x) * (max_y - min_y)
|
||||
}
|
||||
ZoneBounds::Circle { radius, .. } => {
|
||||
std::f64::consts::PI * radius * radius
|
||||
}
|
||||
ZoneBounds::Polygon { vertices } => {
|
||||
// Shoelace formula
|
||||
if vertices.len() < 3 {
|
||||
return 0.0;
|
||||
}
|
||||
let mut area = 0.0;
|
||||
let n = vertices.len();
|
||||
for i in 0..n {
|
||||
let j = (i + 1) % n;
|
||||
area += vertices[i].0 * vertices[j].1;
|
||||
area -= vertices[j].0 * vertices[i].1;
|
||||
}
|
||||
(area / 2.0).abs()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a point is within the zone bounds
|
||||
pub fn contains(&self, x: f64, y: f64) -> bool {
|
||||
match self {
|
||||
ZoneBounds::Rectangle { min_x, min_y, max_x, max_y } => {
|
||||
x >= *min_x && x <= *max_x && y >= *min_y && y <= *max_y
|
||||
}
|
||||
ZoneBounds::Circle { center_x, center_y, radius } => {
|
||||
let dx = x - center_x;
|
||||
let dy = y - center_y;
|
||||
(dx * dx + dy * dy).sqrt() <= *radius
|
||||
}
|
||||
ZoneBounds::Polygon { vertices } => {
|
||||
// Ray casting algorithm
|
||||
if vertices.len() < 3 {
|
||||
return false;
|
||||
}
|
||||
let mut inside = false;
|
||||
let n = vertices.len();
|
||||
let mut j = n - 1;
|
||||
for i in 0..n {
|
||||
let (xi, yi) = vertices[i];
|
||||
let (xj, yj) = vertices[j];
|
||||
if ((yi > y) != (yj > y))
|
||||
&& (x < (xj - xi) * (y - yi) / (yj - yi) + xi)
|
||||
{
|
||||
inside = !inside;
|
||||
}
|
||||
j = i;
|
||||
}
|
||||
inside
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the center point of the zone
|
||||
pub fn center(&self) -> (f64, f64) {
|
||||
match self {
|
||||
ZoneBounds::Rectangle { min_x, min_y, max_x, max_y } => {
|
||||
((min_x + max_x) / 2.0, (min_y + max_y) / 2.0)
|
||||
}
|
||||
ZoneBounds::Circle { center_x, center_y, .. } => {
|
||||
(*center_x, *center_y)
|
||||
}
|
||||
ZoneBounds::Polygon { vertices } => {
|
||||
if vertices.is_empty() {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let sum_x: f64 = vertices.iter().map(|(x, _)| x).sum();
|
||||
let sum_y: f64 = vertices.iter().map(|(_, y)| y).sum();
|
||||
let n = vertices.len() as f64;
|
||||
(sum_x / n, sum_y / n)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Status of a scan zone
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub enum ZoneStatus {
|
||||
/// Zone is active and being scanned
|
||||
Active,
|
||||
/// Zone is paused (temporary)
|
||||
Paused,
|
||||
/// Zone scan is complete
|
||||
Complete,
|
||||
/// Zone is inaccessible
|
||||
Inaccessible,
|
||||
/// Zone is deactivated
|
||||
Deactivated,
|
||||
}
|
||||
|
||||
/// Parameters for scanning a zone
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub struct ScanParameters {
|
||||
/// Scan sensitivity (0.0-1.0)
|
||||
pub sensitivity: f64,
|
||||
/// Maximum depth to scan (meters)
|
||||
pub max_depth: f64,
|
||||
/// Scan resolution (higher = more detailed but slower)
|
||||
pub resolution: ScanResolution,
|
||||
/// Whether to use enhanced breathing detection
|
||||
pub enhanced_breathing: bool,
|
||||
/// Whether to use heartbeat detection (more sensitive but slower)
|
||||
pub heartbeat_detection: bool,
|
||||
}
|
||||
|
||||
impl Default for ScanParameters {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
sensitivity: 0.8,
|
||||
max_depth: 5.0,
|
||||
resolution: ScanResolution::Standard,
|
||||
enhanced_breathing: true,
|
||||
heartbeat_detection: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan resolution levels
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub enum ScanResolution {
|
||||
/// Quick scan, lower accuracy
|
||||
Quick,
|
||||
/// Standard scan
|
||||
Standard,
|
||||
/// High resolution scan
|
||||
High,
|
||||
/// Maximum resolution (slowest)
|
||||
Maximum,
|
||||
}
|
||||
|
||||
impl ScanResolution {
|
||||
/// Get scan time multiplier
|
||||
pub fn time_multiplier(&self) -> f64 {
|
||||
match self {
|
||||
ScanResolution::Quick => 0.5,
|
||||
ScanResolution::Standard => 1.0,
|
||||
ScanResolution::High => 2.0,
|
||||
ScanResolution::Maximum => 4.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Position of a sensor (WiFi transmitter/receiver)
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub struct SensorPosition {
|
||||
/// Sensor identifier
|
||||
pub id: String,
|
||||
/// X coordinate (meters)
|
||||
pub x: f64,
|
||||
/// Y coordinate (meters)
|
||||
pub y: f64,
|
||||
/// Z coordinate (meters, height above ground)
|
||||
pub z: f64,
|
||||
/// Sensor type
|
||||
pub sensor_type: SensorType,
|
||||
/// Whether sensor is operational
|
||||
pub is_operational: bool,
|
||||
}
|
||||
|
||||
/// Types of sensors
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub enum SensorType {
|
||||
/// WiFi transmitter
|
||||
Transmitter,
|
||||
/// WiFi receiver
|
||||
Receiver,
|
||||
/// Combined transmitter/receiver
|
||||
Transceiver,
|
||||
}
|
||||
|
||||
/// A defined geographic area being monitored for survivors
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub struct ScanZone {
|
||||
id: ScanZoneId,
|
||||
name: String,
|
||||
bounds: ZoneBounds,
|
||||
sensor_positions: Vec<SensorPosition>,
|
||||
parameters: ScanParameters,
|
||||
status: ZoneStatus,
|
||||
created_at: DateTime<Utc>,
|
||||
last_scan: Option<DateTime<Utc>>,
|
||||
scan_count: u32,
|
||||
detections_count: u32,
|
||||
}
|
||||
|
||||
impl ScanZone {
|
||||
/// Create a new scan zone
|
||||
pub fn new(name: &str, bounds: ZoneBounds) -> Self {
|
||||
Self {
|
||||
id: ScanZoneId::new(),
|
||||
name: name.to_string(),
|
||||
bounds,
|
||||
sensor_positions: Vec::new(),
|
||||
parameters: ScanParameters::default(),
|
||||
status: ZoneStatus::Active,
|
||||
created_at: Utc::now(),
|
||||
last_scan: None,
|
||||
scan_count: 0,
|
||||
detections_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with custom parameters
|
||||
pub fn with_parameters(name: &str, bounds: ZoneBounds, parameters: ScanParameters) -> Self {
|
||||
let mut zone = Self::new(name, bounds);
|
||||
zone.parameters = parameters;
|
||||
zone
|
||||
}
|
||||
|
||||
/// Get the zone ID
|
||||
pub fn id(&self) -> &ScanZoneId {
|
||||
&self.id
|
||||
}
|
||||
|
||||
/// Get the zone name
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
/// Get the bounds
|
||||
pub fn bounds(&self) -> &ZoneBounds {
|
||||
&self.bounds
|
||||
}
|
||||
|
||||
/// Get sensor positions
|
||||
pub fn sensor_positions(&self) -> &[SensorPosition] {
|
||||
&self.sensor_positions
|
||||
}
|
||||
|
||||
/// Get scan parameters
|
||||
pub fn parameters(&self) -> &ScanParameters {
|
||||
&self.parameters
|
||||
}
|
||||
|
||||
/// Get mutable scan parameters
|
||||
pub fn parameters_mut(&mut self) -> &mut ScanParameters {
|
||||
&mut self.parameters
|
||||
}
|
||||
|
||||
/// Get the status
|
||||
pub fn status(&self) -> &ZoneStatus {
|
||||
&self.status
|
||||
}
|
||||
|
||||
/// Get last scan time
|
||||
pub fn last_scan(&self) -> Option<&DateTime<Utc>> {
|
||||
self.last_scan.as_ref()
|
||||
}
|
||||
|
||||
/// Get scan count
|
||||
pub fn scan_count(&self) -> u32 {
|
||||
self.scan_count
|
||||
}
|
||||
|
||||
/// Get detection count
|
||||
pub fn detections_count(&self) -> u32 {
|
||||
self.detections_count
|
||||
}
|
||||
|
||||
/// Add a sensor to the zone
|
||||
pub fn add_sensor(&mut self, sensor: SensorPosition) {
|
||||
self.sensor_positions.push(sensor);
|
||||
}
|
||||
|
||||
/// Remove a sensor
|
||||
pub fn remove_sensor(&mut self, sensor_id: &str) {
|
||||
self.sensor_positions.retain(|s| s.id != sensor_id);
|
||||
}
|
||||
|
||||
/// Set zone status
|
||||
pub fn set_status(&mut self, status: ZoneStatus) {
|
||||
self.status = status;
|
||||
}
|
||||
|
||||
/// Pause the zone
|
||||
pub fn pause(&mut self) {
|
||||
self.status = ZoneStatus::Paused;
|
||||
}
|
||||
|
||||
/// Resume the zone
|
||||
pub fn resume(&mut self) {
|
||||
if self.status == ZoneStatus::Paused {
|
||||
self.status = ZoneStatus::Active;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark zone as complete
|
||||
pub fn complete(&mut self) {
|
||||
self.status = ZoneStatus::Complete;
|
||||
}
|
||||
|
||||
/// Record a scan
|
||||
pub fn record_scan(&mut self, found_detections: u32) {
|
||||
self.last_scan = Some(Utc::now());
|
||||
self.scan_count += 1;
|
||||
self.detections_count += found_detections;
|
||||
}
|
||||
|
||||
/// Check if a point is within this zone
|
||||
pub fn contains_point(&self, x: f64, y: f64) -> bool {
|
||||
self.bounds.contains(x, y)
|
||||
}
|
||||
|
||||
/// Get the area of the zone
|
||||
pub fn area(&self) -> f64 {
|
||||
self.bounds.area()
|
||||
}
|
||||
|
||||
/// Check if zone has enough sensors for localization
|
||||
pub fn has_sufficient_sensors(&self) -> bool {
|
||||
// Need at least 3 sensors for 2D localization
|
||||
self.sensor_positions.iter()
|
||||
.filter(|s| s.is_operational)
|
||||
.count() >= 3
|
||||
}
|
||||
|
||||
/// Time since last scan
|
||||
pub fn time_since_scan(&self) -> Option<chrono::Duration> {
|
||||
self.last_scan.map(|t| Utc::now() - t)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_rectangle_bounds() {
|
||||
let bounds = ZoneBounds::rectangle(0.0, 0.0, 10.0, 10.0);
|
||||
|
||||
assert!((bounds.area() - 100.0).abs() < 0.001);
|
||||
assert!(bounds.contains(5.0, 5.0));
|
||||
assert!(!bounds.contains(15.0, 5.0));
|
||||
assert_eq!(bounds.center(), (5.0, 5.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_circle_bounds() {
|
||||
let bounds = ZoneBounds::circle(0.0, 0.0, 10.0);
|
||||
|
||||
assert!((bounds.area() - std::f64::consts::PI * 100.0).abs() < 0.001);
|
||||
assert!(bounds.contains(0.0, 0.0));
|
||||
assert!(bounds.contains(5.0, 5.0));
|
||||
assert!(!bounds.contains(10.0, 10.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_zone_creation() {
|
||||
let zone = ScanZone::new(
|
||||
"Test Zone",
|
||||
ZoneBounds::rectangle(0.0, 0.0, 50.0, 30.0),
|
||||
);
|
||||
|
||||
assert_eq!(zone.name(), "Test Zone");
|
||||
assert!(matches!(zone.status(), ZoneStatus::Active));
|
||||
assert_eq!(zone.scan_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_zone_sensors() {
|
||||
let mut zone = ScanZone::new(
|
||||
"Test Zone",
|
||||
ZoneBounds::rectangle(0.0, 0.0, 50.0, 30.0),
|
||||
);
|
||||
|
||||
assert!(!zone.has_sufficient_sensors());
|
||||
|
||||
for i in 0..3 {
|
||||
zone.add_sensor(SensorPosition {
|
||||
id: format!("sensor-{}", i),
|
||||
x: i as f64 * 10.0,
|
||||
y: 0.0,
|
||||
z: 1.5,
|
||||
sensor_type: SensorType::Transceiver,
|
||||
is_operational: true,
|
||||
});
|
||||
}
|
||||
|
||||
assert!(zone.has_sufficient_sensors());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_zone_status_transitions() {
|
||||
let mut zone = ScanZone::new(
|
||||
"Test",
|
||||
ZoneBounds::rectangle(0.0, 0.0, 10.0, 10.0),
|
||||
);
|
||||
|
||||
assert!(matches!(zone.status(), ZoneStatus::Active));
|
||||
|
||||
zone.pause();
|
||||
assert!(matches!(zone.status(), ZoneStatus::Paused));
|
||||
|
||||
zone.resume();
|
||||
assert!(matches!(zone.status(), ZoneStatus::Active));
|
||||
|
||||
zone.complete();
|
||||
assert!(matches!(zone.status(), ZoneStatus::Complete));
|
||||
}
|
||||
}
|
||||
@@ -1,424 +0,0 @@
|
||||
//! Survivor entity representing a detected human in a disaster zone.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{
|
||||
Coordinates3D, TriageStatus, VitalSignsReading, ScanZoneId,
|
||||
triage::TriageCalculator,
|
||||
};
|
||||
|
||||
/// Unique identifier for a survivor
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub struct SurvivorId(Uuid);
|
||||
|
||||
impl SurvivorId {
|
||||
/// Create a new random survivor ID
|
||||
pub fn new() -> Self {
|
||||
Self(Uuid::new_v4())
|
||||
}
|
||||
|
||||
/// Create from an existing UUID
|
||||
pub fn from_uuid(uuid: Uuid) -> Self {
|
||||
Self(uuid)
|
||||
}
|
||||
|
||||
/// Get the inner UUID
|
||||
pub fn as_uuid(&self) -> &Uuid {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SurvivorId {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SurvivorId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Current status of a survivor
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub enum SurvivorStatus {
|
||||
/// Actively being tracked
|
||||
Active,
|
||||
/// Confirmed rescued
|
||||
Rescued,
|
||||
/// Lost signal, may need re-detection
|
||||
Lost,
|
||||
/// Confirmed deceased
|
||||
Deceased,
|
||||
/// Determined to be false positive
|
||||
FalsePositive,
|
||||
}
|
||||
|
||||
/// Additional metadata about a survivor
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub struct SurvivorMetadata {
|
||||
/// Estimated age category based on vital patterns
|
||||
pub estimated_age_category: Option<AgeCategory>,
|
||||
/// Notes from rescue team
|
||||
pub notes: Vec<String>,
|
||||
/// Tags for organization
|
||||
pub tags: Vec<String>,
|
||||
/// Assigned rescue team ID
|
||||
pub assigned_team: Option<String>,
|
||||
}
|
||||
|
||||
/// Estimated age category based on vital sign patterns
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub enum AgeCategory {
|
||||
/// Infant (0-2 years)
|
||||
Infant,
|
||||
/// Child (2-12 years)
|
||||
Child,
|
||||
/// Adult (12-65 years)
|
||||
Adult,
|
||||
/// Elderly (65+ years)
|
||||
Elderly,
|
||||
/// Cannot determine
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// History of vital signs readings
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub struct VitalSignsHistory {
|
||||
readings: Vec<VitalSignsReading>,
|
||||
max_history: usize,
|
||||
}
|
||||
|
||||
impl VitalSignsHistory {
|
||||
/// Create a new history with specified max size
|
||||
pub fn new(max_history: usize) -> Self {
|
||||
Self {
|
||||
readings: Vec::with_capacity(max_history),
|
||||
max_history,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a new reading
|
||||
pub fn add(&mut self, reading: VitalSignsReading) {
|
||||
if self.readings.len() >= self.max_history {
|
||||
self.readings.remove(0);
|
||||
}
|
||||
self.readings.push(reading);
|
||||
}
|
||||
|
||||
/// Get the most recent reading
|
||||
pub fn latest(&self) -> Option<&VitalSignsReading> {
|
||||
self.readings.last()
|
||||
}
|
||||
|
||||
/// Get all readings
|
||||
pub fn all(&self) -> &[VitalSignsReading] {
|
||||
&self.readings
|
||||
}
|
||||
|
||||
/// Get the number of readings
|
||||
pub fn len(&self) -> usize {
|
||||
self.readings.len()
|
||||
}
|
||||
|
||||
/// Check if empty
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.readings.is_empty()
|
||||
}
|
||||
|
||||
/// Calculate average confidence across readings
|
||||
pub fn average_confidence(&self) -> f64 {
|
||||
if self.readings.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let sum: f64 = self.readings.iter()
|
||||
.map(|r| r.confidence.value())
|
||||
.sum();
|
||||
sum / self.readings.len() as f64
|
||||
}
|
||||
|
||||
/// Check if vitals are deteriorating
|
||||
pub fn is_deteriorating(&self) -> bool {
|
||||
if self.readings.len() < 3 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let recent: Vec<_> = self.readings.iter().rev().take(3).collect();
|
||||
|
||||
// Check breathing trend
|
||||
let breathing_declining = recent.windows(2).all(|w| {
|
||||
match (&w[0].breathing, &w[1].breathing) {
|
||||
(Some(a), Some(b)) => a.rate_bpm < b.rate_bpm,
|
||||
_ => false,
|
||||
}
|
||||
});
|
||||
|
||||
// Check confidence trend
|
||||
let confidence_declining = recent.windows(2).all(|w| {
|
||||
w[0].confidence.value() < w[1].confidence.value()
|
||||
});
|
||||
|
||||
breathing_declining || confidence_declining
|
||||
}
|
||||
}
|
||||
|
||||
/// A detected survivor in the disaster zone
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub struct Survivor {
|
||||
id: SurvivorId,
|
||||
zone_id: ScanZoneId,
|
||||
first_detected: DateTime<Utc>,
|
||||
last_updated: DateTime<Utc>,
|
||||
location: Option<Coordinates3D>,
|
||||
vital_signs: VitalSignsHistory,
|
||||
triage_status: TriageStatus,
|
||||
status: SurvivorStatus,
|
||||
confidence: f64,
|
||||
metadata: SurvivorMetadata,
|
||||
alert_sent: bool,
|
||||
}
|
||||
|
||||
impl Survivor {
|
||||
/// Create a new survivor from initial detection
|
||||
pub fn new(
|
||||
zone_id: ScanZoneId,
|
||||
initial_vitals: VitalSignsReading,
|
||||
location: Option<Coordinates3D>,
|
||||
) -> Self {
|
||||
let now = Utc::now();
|
||||
let confidence = initial_vitals.confidence.value();
|
||||
let triage_status = TriageCalculator::calculate(&initial_vitals);
|
||||
|
||||
let mut vital_signs = VitalSignsHistory::new(100);
|
||||
vital_signs.add(initial_vitals);
|
||||
|
||||
Self {
|
||||
id: SurvivorId::new(),
|
||||
zone_id,
|
||||
first_detected: now,
|
||||
last_updated: now,
|
||||
location,
|
||||
vital_signs,
|
||||
triage_status,
|
||||
status: SurvivorStatus::Active,
|
||||
confidence,
|
||||
metadata: SurvivorMetadata::default(),
|
||||
alert_sent: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the survivor ID
|
||||
pub fn id(&self) -> &SurvivorId {
|
||||
&self.id
|
||||
}
|
||||
|
||||
/// Get the zone ID where survivor was detected
|
||||
pub fn zone_id(&self) -> &ScanZoneId {
|
||||
&self.zone_id
|
||||
}
|
||||
|
||||
/// Get the first detection time
|
||||
pub fn first_detected(&self) -> &DateTime<Utc> {
|
||||
&self.first_detected
|
||||
}
|
||||
|
||||
/// Get the last update time
|
||||
pub fn last_updated(&self) -> &DateTime<Utc> {
|
||||
&self.last_updated
|
||||
}
|
||||
|
||||
/// Get the estimated location
|
||||
pub fn location(&self) -> Option<&Coordinates3D> {
|
||||
self.location.as_ref()
|
||||
}
|
||||
|
||||
/// Get the vital signs history
|
||||
pub fn vital_signs(&self) -> &VitalSignsHistory {
|
||||
&self.vital_signs
|
||||
}
|
||||
|
||||
/// Get the current triage status
|
||||
pub fn triage_status(&self) -> &TriageStatus {
|
||||
&self.triage_status
|
||||
}
|
||||
|
||||
/// Get the current status
|
||||
pub fn status(&self) -> &SurvivorStatus {
|
||||
&self.status
|
||||
}
|
||||
|
||||
/// Get the confidence score
|
||||
pub fn confidence(&self) -> f64 {
|
||||
self.confidence
|
||||
}
|
||||
|
||||
/// Get the metadata
|
||||
pub fn metadata(&self) -> &SurvivorMetadata {
|
||||
&self.metadata
|
||||
}
|
||||
|
||||
/// Get mutable metadata
|
||||
pub fn metadata_mut(&mut self) -> &mut SurvivorMetadata {
|
||||
&mut self.metadata
|
||||
}
|
||||
|
||||
/// Update with new vital signs reading
|
||||
pub fn update_vitals(&mut self, reading: VitalSignsReading) {
|
||||
let previous_triage = self.triage_status.clone();
|
||||
self.vital_signs.add(reading.clone());
|
||||
self.confidence = self.vital_signs.average_confidence();
|
||||
self.triage_status = TriageCalculator::calculate(&reading);
|
||||
self.last_updated = Utc::now();
|
||||
|
||||
// Log triage change for audit
|
||||
if previous_triage != self.triage_status {
|
||||
tracing::info!(
|
||||
survivor_id = %self.id,
|
||||
previous = ?previous_triage,
|
||||
current = ?self.triage_status,
|
||||
"Triage status changed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the location estimate
|
||||
pub fn update_location(&mut self, location: Coordinates3D) {
|
||||
self.location = Some(location);
|
||||
self.last_updated = Utc::now();
|
||||
}
|
||||
|
||||
/// Mark as rescued
|
||||
pub fn mark_rescued(&mut self) {
|
||||
self.status = SurvivorStatus::Rescued;
|
||||
self.last_updated = Utc::now();
|
||||
tracing::info!(survivor_id = %self.id, "Survivor marked as rescued");
|
||||
}
|
||||
|
||||
/// Mark as lost (signal lost)
|
||||
pub fn mark_lost(&mut self) {
|
||||
self.status = SurvivorStatus::Lost;
|
||||
self.last_updated = Utc::now();
|
||||
}
|
||||
|
||||
/// Mark as deceased
|
||||
pub fn mark_deceased(&mut self) {
|
||||
self.status = SurvivorStatus::Deceased;
|
||||
self.triage_status = TriageStatus::Deceased;
|
||||
self.last_updated = Utc::now();
|
||||
}
|
||||
|
||||
/// Mark as false positive
|
||||
pub fn mark_false_positive(&mut self) {
|
||||
self.status = SurvivorStatus::FalsePositive;
|
||||
self.last_updated = Utc::now();
|
||||
}
|
||||
|
||||
/// Check if survivor should generate an alert
|
||||
pub fn should_alert(&self) -> bool {
|
||||
if self.alert_sent {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Alert for high-priority survivors
|
||||
matches!(
|
||||
self.triage_status,
|
||||
TriageStatus::Immediate | TriageStatus::Delayed
|
||||
) && self.confidence >= 0.5
|
||||
}
|
||||
|
||||
/// Mark that alert was sent
|
||||
pub fn mark_alert_sent(&mut self) {
|
||||
self.alert_sent = true;
|
||||
}
|
||||
|
||||
/// Check if vitals are deteriorating (needs priority upgrade)
|
||||
pub fn is_deteriorating(&self) -> bool {
|
||||
self.vital_signs.is_deteriorating()
|
||||
}
|
||||
|
||||
/// Get time since last update
|
||||
pub fn time_since_update(&self) -> chrono::Duration {
|
||||
Utc::now() - self.last_updated
|
||||
}
|
||||
|
||||
/// Check if survivor data is stale
|
||||
pub fn is_stale(&self, threshold_seconds: i64) -> bool {
|
||||
self.time_since_update().num_seconds() > threshold_seconds
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::domain::{BreathingPattern, BreathingType, ConfidenceScore};
|
||||
|
||||
fn create_test_vitals(confidence: f64) -> VitalSignsReading {
|
||||
VitalSignsReading {
|
||||
breathing: Some(BreathingPattern {
|
||||
rate_bpm: 16.0,
|
||||
amplitude: 0.8,
|
||||
regularity: 0.9,
|
||||
pattern_type: BreathingType::Normal,
|
||||
}),
|
||||
heartbeat: None,
|
||||
movement: Default::default(),
|
||||
timestamp: Utc::now(),
|
||||
confidence: ConfidenceScore::new(confidence),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_survivor_creation() {
|
||||
let zone_id = ScanZoneId::new();
|
||||
let vitals = create_test_vitals(0.8);
|
||||
let survivor = Survivor::new(zone_id.clone(), vitals, None);
|
||||
|
||||
assert_eq!(survivor.zone_id(), &zone_id);
|
||||
assert!(survivor.confidence() >= 0.8);
|
||||
assert!(matches!(survivor.status(), SurvivorStatus::Active));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vital_signs_history() {
|
||||
let mut history = VitalSignsHistory::new(5);
|
||||
|
||||
for i in 0..7 {
|
||||
history.add(create_test_vitals(0.5 + (i as f64 * 0.05)));
|
||||
}
|
||||
|
||||
// Should only keep last 5
|
||||
assert_eq!(history.len(), 5);
|
||||
|
||||
// Average should be based on last 5 readings
|
||||
assert!(history.average_confidence() > 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_survivor_should_alert() {
|
||||
let zone_id = ScanZoneId::new();
|
||||
let vitals = create_test_vitals(0.8);
|
||||
let survivor = Survivor::new(zone_id, vitals, None);
|
||||
|
||||
// Should alert if triage is Immediate or Delayed
|
||||
// Depends on triage calculation from vitals
|
||||
assert!(!survivor.alert_sent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_survivor_mark_rescued() {
|
||||
let zone_id = ScanZoneId::new();
|
||||
let vitals = create_test_vitals(0.8);
|
||||
let mut survivor = Survivor::new(zone_id, vitals, None);
|
||||
|
||||
survivor.mark_rescued();
|
||||
assert!(matches!(survivor.status(), SurvivorStatus::Rescued));
|
||||
}
|
||||
}
|
||||
@@ -1,350 +0,0 @@
|
||||
//! Triage classification following START protocol.
|
||||
//!
|
||||
//! The START (Simple Triage and Rapid Treatment) protocol is used to
|
||||
//! quickly categorize victims in mass casualty incidents.
|
||||
|
||||
use super::{VitalSignsReading, BreathingType, MovementType};
|
||||
|
||||
/// Triage status following START protocol
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub enum TriageStatus {
|
||||
/// Immediate (Red) - Life-threatening, requires immediate intervention
|
||||
/// RPM: Respiration >30 or <10, or absent pulse, or unable to follow commands
|
||||
Immediate,
|
||||
|
||||
/// Delayed (Yellow) - Serious but stable, can wait for treatment
|
||||
/// RPM: Normal respiration, pulse present, follows commands, non-life-threatening
|
||||
Delayed,
|
||||
|
||||
/// Minor (Green) - Walking wounded, minimal treatment needed
|
||||
/// Can walk, minor injuries
|
||||
Minor,
|
||||
|
||||
/// Deceased (Black) - No vital signs, or not breathing after airway cleared
|
||||
Deceased,
|
||||
|
||||
/// Unknown - Insufficient data for classification
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl TriageStatus {
|
||||
/// Get the priority level (1 = highest)
|
||||
pub fn priority(&self) -> u8 {
|
||||
match self {
|
||||
TriageStatus::Immediate => 1,
|
||||
TriageStatus::Delayed => 2,
|
||||
TriageStatus::Minor => 3,
|
||||
TriageStatus::Deceased => 4,
|
||||
TriageStatus::Unknown => 5,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get display color
|
||||
pub fn color(&self) -> &'static str {
|
||||
match self {
|
||||
TriageStatus::Immediate => "red",
|
||||
TriageStatus::Delayed => "yellow",
|
||||
TriageStatus::Minor => "green",
|
||||
TriageStatus::Deceased => "black",
|
||||
TriageStatus::Unknown => "gray",
|
||||
}
|
||||
}
|
||||
|
||||
/// Get human-readable description
|
||||
pub fn description(&self) -> &'static str {
|
||||
match self {
|
||||
TriageStatus::Immediate => "Requires immediate life-saving intervention",
|
||||
TriageStatus::Delayed => "Serious but can wait for treatment",
|
||||
TriageStatus::Minor => "Minor injuries, walking wounded",
|
||||
TriageStatus::Deceased => "No vital signs detected",
|
||||
TriageStatus::Unknown => "Unable to determine status",
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this status requires urgent attention
|
||||
pub fn is_urgent(&self) -> bool {
|
||||
matches!(self, TriageStatus::Immediate | TriageStatus::Delayed)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TriageStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
TriageStatus::Immediate => write!(f, "IMMEDIATE (Red)"),
|
||||
TriageStatus::Delayed => write!(f, "DELAYED (Yellow)"),
|
||||
TriageStatus::Minor => write!(f, "MINOR (Green)"),
|
||||
TriageStatus::Deceased => write!(f, "DECEASED (Black)"),
|
||||
TriageStatus::Unknown => write!(f, "UNKNOWN"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculator for triage status based on vital signs
|
||||
pub struct TriageCalculator;
|
||||
|
||||
impl TriageCalculator {
|
||||
/// Calculate triage status from vital signs reading
|
||||
///
|
||||
/// Uses modified START protocol adapted for remote sensing:
|
||||
/// 1. Check breathing (respiration)
|
||||
/// 2. Check for movement/responsiveness (proxy for perfusion/mental status)
|
||||
/// 3. Classify based on combined assessment
|
||||
pub fn calculate(vitals: &VitalSignsReading) -> TriageStatus {
|
||||
// Step 1: Check if any vitals are detected
|
||||
if !vitals.has_vitals() {
|
||||
// No vitals at all - either deceased or signal issue
|
||||
return TriageStatus::Unknown;
|
||||
}
|
||||
|
||||
// Step 2: Assess breathing
|
||||
let breathing_status = Self::assess_breathing(vitals);
|
||||
|
||||
// Step 3: Assess movement/responsiveness
|
||||
let movement_status = Self::assess_movement(vitals);
|
||||
|
||||
// Step 4: Combine assessments
|
||||
Self::combine_assessments(breathing_status, movement_status)
|
||||
}
|
||||
|
||||
/// Assess breathing status
|
||||
fn assess_breathing(vitals: &VitalSignsReading) -> BreathingAssessment {
|
||||
match &vitals.breathing {
|
||||
None => BreathingAssessment::Absent,
|
||||
Some(breathing) => {
|
||||
// Check for agonal breathing (pre-death)
|
||||
if breathing.pattern_type == BreathingType::Agonal {
|
||||
return BreathingAssessment::Agonal;
|
||||
}
|
||||
|
||||
// Check rate
|
||||
if breathing.rate_bpm < 10.0 {
|
||||
BreathingAssessment::TooSlow
|
||||
} else if breathing.rate_bpm > 30.0 {
|
||||
BreathingAssessment::TooFast
|
||||
} else {
|
||||
BreathingAssessment::Normal
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Assess movement/responsiveness
|
||||
fn assess_movement(vitals: &VitalSignsReading) -> MovementAssessment {
|
||||
match vitals.movement.movement_type {
|
||||
MovementType::Gross if vitals.movement.is_voluntary => {
|
||||
MovementAssessment::Responsive
|
||||
}
|
||||
MovementType::Gross => MovementAssessment::Moving,
|
||||
MovementType::Fine => MovementAssessment::MinimalMovement,
|
||||
MovementType::Tremor => MovementAssessment::InvoluntaryOnly,
|
||||
MovementType::Periodic => MovementAssessment::MinimalMovement,
|
||||
MovementType::None => MovementAssessment::None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Combine breathing and movement assessments into triage status
|
||||
fn combine_assessments(
|
||||
breathing: BreathingAssessment,
|
||||
movement: MovementAssessment,
|
||||
) -> TriageStatus {
|
||||
match (breathing, movement) {
|
||||
// No breathing
|
||||
(BreathingAssessment::Absent, MovementAssessment::None) => {
|
||||
TriageStatus::Deceased
|
||||
}
|
||||
(BreathingAssessment::Agonal, _) => {
|
||||
TriageStatus::Immediate
|
||||
}
|
||||
(BreathingAssessment::Absent, _) => {
|
||||
// No breathing but movement - possible airway obstruction
|
||||
TriageStatus::Immediate
|
||||
}
|
||||
|
||||
// Abnormal breathing rates
|
||||
(BreathingAssessment::TooFast, _) => {
|
||||
TriageStatus::Immediate
|
||||
}
|
||||
(BreathingAssessment::TooSlow, _) => {
|
||||
TriageStatus::Immediate
|
||||
}
|
||||
|
||||
// Normal breathing with movement assessment
|
||||
(BreathingAssessment::Normal, MovementAssessment::Responsive) => {
|
||||
TriageStatus::Minor
|
||||
}
|
||||
(BreathingAssessment::Normal, MovementAssessment::Moving) => {
|
||||
TriageStatus::Delayed
|
||||
}
|
||||
(BreathingAssessment::Normal, MovementAssessment::MinimalMovement) => {
|
||||
TriageStatus::Delayed
|
||||
}
|
||||
(BreathingAssessment::Normal, MovementAssessment::InvoluntaryOnly) => {
|
||||
TriageStatus::Immediate // Not following commands
|
||||
}
|
||||
(BreathingAssessment::Normal, MovementAssessment::None) => {
|
||||
TriageStatus::Immediate // Breathing but unresponsive
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if status should be upgraded based on deterioration
|
||||
pub fn should_upgrade(current: &TriageStatus, is_deteriorating: bool) -> bool {
|
||||
if !is_deteriorating {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Upgrade if not already at highest priority
|
||||
matches!(current, TriageStatus::Delayed | TriageStatus::Minor)
|
||||
}
|
||||
|
||||
/// Get upgraded triage status
|
||||
pub fn upgrade(current: &TriageStatus) -> TriageStatus {
|
||||
match current {
|
||||
TriageStatus::Minor => TriageStatus::Delayed,
|
||||
TriageStatus::Delayed => TriageStatus::Immediate,
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal breathing assessment
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum BreathingAssessment {
|
||||
Normal,
|
||||
TooFast,
|
||||
TooSlow,
|
||||
Agonal,
|
||||
Absent,
|
||||
}
|
||||
|
||||
/// Internal movement assessment
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum MovementAssessment {
|
||||
Responsive, // Voluntary purposeful movement
|
||||
Moving, // Movement but unclear if responsive
|
||||
MinimalMovement, // Small movements only
|
||||
InvoluntaryOnly, // Only tremors/involuntary
|
||||
None, // No movement detected
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::domain::{BreathingPattern, ConfidenceScore, MovementProfile};
|
||||
use chrono::Utc;
|
||||
|
||||
fn create_vitals(
|
||||
breathing: Option<BreathingPattern>,
|
||||
movement: MovementProfile,
|
||||
) -> VitalSignsReading {
|
||||
VitalSignsReading {
|
||||
breathing,
|
||||
heartbeat: None,
|
||||
movement,
|
||||
timestamp: Utc::now(),
|
||||
confidence: ConfidenceScore::new(0.8),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_vitals_is_unknown() {
|
||||
let vitals = create_vitals(None, MovementProfile::default());
|
||||
assert_eq!(TriageCalculator::calculate(&vitals), TriageStatus::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normal_breathing_responsive_is_minor() {
|
||||
let vitals = create_vitals(
|
||||
Some(BreathingPattern {
|
||||
rate_bpm: 16.0,
|
||||
amplitude: 0.8,
|
||||
regularity: 0.9,
|
||||
pattern_type: BreathingType::Normal,
|
||||
}),
|
||||
MovementProfile {
|
||||
movement_type: MovementType::Gross,
|
||||
intensity: 0.8,
|
||||
frequency: 0.5,
|
||||
is_voluntary: true,
|
||||
},
|
||||
);
|
||||
assert_eq!(TriageCalculator::calculate(&vitals), TriageStatus::Minor);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fast_breathing_is_immediate() {
|
||||
let vitals = create_vitals(
|
||||
Some(BreathingPattern {
|
||||
rate_bpm: 35.0,
|
||||
amplitude: 0.7,
|
||||
regularity: 0.5,
|
||||
pattern_type: BreathingType::Labored,
|
||||
}),
|
||||
MovementProfile {
|
||||
movement_type: MovementType::Fine,
|
||||
intensity: 0.3,
|
||||
frequency: 0.2,
|
||||
is_voluntary: false,
|
||||
},
|
||||
);
|
||||
assert_eq!(TriageCalculator::calculate(&vitals), TriageStatus::Immediate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slow_breathing_is_immediate() {
|
||||
let vitals = create_vitals(
|
||||
Some(BreathingPattern {
|
||||
rate_bpm: 8.0,
|
||||
amplitude: 0.5,
|
||||
regularity: 0.6,
|
||||
pattern_type: BreathingType::Shallow,
|
||||
}),
|
||||
MovementProfile {
|
||||
movement_type: MovementType::None,
|
||||
intensity: 0.0,
|
||||
frequency: 0.0,
|
||||
is_voluntary: false,
|
||||
},
|
||||
);
|
||||
assert_eq!(TriageCalculator::calculate(&vitals), TriageStatus::Immediate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agonal_breathing_is_immediate() {
|
||||
let vitals = create_vitals(
|
||||
Some(BreathingPattern {
|
||||
rate_bpm: 4.0,
|
||||
amplitude: 0.3,
|
||||
regularity: 0.2,
|
||||
pattern_type: BreathingType::Agonal,
|
||||
}),
|
||||
MovementProfile::default(),
|
||||
);
|
||||
assert_eq!(TriageCalculator::calculate(&vitals), TriageStatus::Immediate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_triage_priority() {
|
||||
assert_eq!(TriageStatus::Immediate.priority(), 1);
|
||||
assert_eq!(TriageStatus::Delayed.priority(), 2);
|
||||
assert_eq!(TriageStatus::Minor.priority(), 3);
|
||||
assert_eq!(TriageStatus::Deceased.priority(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upgrade_triage() {
|
||||
assert_eq!(
|
||||
TriageCalculator::upgrade(&TriageStatus::Minor),
|
||||
TriageStatus::Delayed
|
||||
);
|
||||
assert_eq!(
|
||||
TriageCalculator::upgrade(&TriageStatus::Delayed),
|
||||
TriageStatus::Immediate
|
||||
);
|
||||
assert_eq!(
|
||||
TriageCalculator::upgrade(&TriageStatus::Immediate),
|
||||
TriageStatus::Immediate
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,375 +0,0 @@
|
||||
//! Vital signs value objects for survivor detection.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
/// Confidence score for a detection (0.0 to 1.0)
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub struct ConfidenceScore(f64);
|
||||
|
||||
impl ConfidenceScore {
|
||||
/// Create a new confidence score, clamped to [0.0, 1.0]
|
||||
pub fn new(value: f64) -> Self {
|
||||
Self(value.clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
/// Get the raw value
|
||||
pub fn value(&self) -> f64 {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// Check if confidence is high (>= 0.8)
|
||||
pub fn is_high(&self) -> bool {
|
||||
self.0 >= 0.8
|
||||
}
|
||||
|
||||
/// Check if confidence is medium (>= 0.5)
|
||||
pub fn is_medium(&self) -> bool {
|
||||
self.0 >= 0.5
|
||||
}
|
||||
|
||||
/// Check if confidence is low (< 0.5)
|
||||
pub fn is_low(&self) -> bool {
|
||||
self.0 < 0.5
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ConfidenceScore {
|
||||
fn default() -> Self {
|
||||
Self(0.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete vital signs reading at a point in time
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub struct VitalSignsReading {
|
||||
/// Breathing pattern if detected
|
||||
pub breathing: Option<BreathingPattern>,
|
||||
/// Heartbeat signature if detected
|
||||
pub heartbeat: Option<HeartbeatSignature>,
|
||||
/// Movement profile
|
||||
pub movement: MovementProfile,
|
||||
/// Timestamp of reading
|
||||
pub timestamp: DateTime<Utc>,
|
||||
/// Overall confidence in the reading
|
||||
pub confidence: ConfidenceScore,
|
||||
}
|
||||
|
||||
impl VitalSignsReading {
|
||||
/// Create a new vital signs reading
|
||||
pub fn new(
|
||||
breathing: Option<BreathingPattern>,
|
||||
heartbeat: Option<HeartbeatSignature>,
|
||||
movement: MovementProfile,
|
||||
) -> Self {
|
||||
// Calculate combined confidence
|
||||
let confidence = Self::calculate_confidence(&breathing, &heartbeat, &movement);
|
||||
|
||||
Self {
|
||||
breathing,
|
||||
heartbeat,
|
||||
movement,
|
||||
timestamp: Utc::now(),
|
||||
confidence,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate combined confidence from individual detections
|
||||
fn calculate_confidence(
|
||||
breathing: &Option<BreathingPattern>,
|
||||
heartbeat: &Option<HeartbeatSignature>,
|
||||
movement: &MovementProfile,
|
||||
) -> ConfidenceScore {
|
||||
let mut total = 0.0;
|
||||
let mut count = 0.0;
|
||||
|
||||
if let Some(b) = breathing {
|
||||
total += b.confidence();
|
||||
count += 1.5; // Weight breathing higher
|
||||
}
|
||||
|
||||
if let Some(h) = heartbeat {
|
||||
total += h.confidence();
|
||||
count += 1.0;
|
||||
}
|
||||
|
||||
if movement.movement_type != MovementType::None {
|
||||
total += movement.confidence();
|
||||
count += 1.0;
|
||||
}
|
||||
|
||||
if count > 0.0 {
|
||||
ConfidenceScore::new(total / count)
|
||||
} else {
|
||||
ConfidenceScore::new(0.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if any vital sign is detected
|
||||
pub fn has_vitals(&self) -> bool {
|
||||
self.breathing.is_some()
|
||||
|| self.heartbeat.is_some()
|
||||
|| self.movement.movement_type != MovementType::None
|
||||
}
|
||||
|
||||
/// Check if breathing is detected
|
||||
pub fn has_breathing(&self) -> bool {
|
||||
self.breathing.is_some()
|
||||
}
|
||||
|
||||
/// Check if heartbeat is detected
|
||||
pub fn has_heartbeat(&self) -> bool {
|
||||
self.heartbeat.is_some()
|
||||
}
|
||||
|
||||
/// Check if movement is detected
|
||||
pub fn has_movement(&self) -> bool {
|
||||
self.movement.movement_type != MovementType::None
|
||||
}
|
||||
}
|
||||
|
||||
/// Breathing pattern detected from CSI analysis
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub struct BreathingPattern {
|
||||
/// Breaths per minute (normal adult: 12-20)
|
||||
pub rate_bpm: f32,
|
||||
/// Signal amplitude/strength
|
||||
pub amplitude: f32,
|
||||
/// Pattern regularity (0.0-1.0)
|
||||
pub regularity: f32,
|
||||
/// Type of breathing pattern
|
||||
pub pattern_type: BreathingType,
|
||||
}
|
||||
|
||||
impl BreathingPattern {
|
||||
/// Check if breathing rate is normal
|
||||
pub fn is_normal_rate(&self) -> bool {
|
||||
self.rate_bpm >= 12.0 && self.rate_bpm <= 20.0
|
||||
}
|
||||
|
||||
/// Check if rate is critically low
|
||||
pub fn is_bradypnea(&self) -> bool {
|
||||
self.rate_bpm < 10.0
|
||||
}
|
||||
|
||||
/// Check if rate is critically high
|
||||
pub fn is_tachypnea(&self) -> bool {
|
||||
self.rate_bpm > 30.0
|
||||
}
|
||||
|
||||
/// Get confidence based on signal quality
|
||||
pub fn confidence(&self) -> f64 {
|
||||
(self.amplitude * self.regularity) as f64
|
||||
}
|
||||
}
|
||||
|
||||
/// Types of breathing patterns
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub enum BreathingType {
|
||||
/// Normal, regular breathing
|
||||
Normal,
|
||||
/// Shallow, weak breathing
|
||||
Shallow,
|
||||
/// Deep, labored breathing
|
||||
Labored,
|
||||
/// Irregular pattern
|
||||
Irregular,
|
||||
/// Agonal breathing (pre-death gasping)
|
||||
Agonal,
|
||||
/// Apnea (no breathing detected)
|
||||
Apnea,
|
||||
}
|
||||
|
||||
/// Heartbeat signature from micro-Doppler analysis
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub struct HeartbeatSignature {
|
||||
/// Heart rate in beats per minute (normal: 60-100)
|
||||
pub rate_bpm: f32,
|
||||
/// Heart rate variability
|
||||
pub variability: f32,
|
||||
/// Signal strength
|
||||
pub strength: SignalStrength,
|
||||
}
|
||||
|
||||
impl HeartbeatSignature {
|
||||
/// Check if heart rate is normal
|
||||
pub fn is_normal_rate(&self) -> bool {
|
||||
self.rate_bpm >= 60.0 && self.rate_bpm <= 100.0
|
||||
}
|
||||
|
||||
/// Check if rate indicates bradycardia
|
||||
pub fn is_bradycardia(&self) -> bool {
|
||||
self.rate_bpm < 50.0
|
||||
}
|
||||
|
||||
/// Check if rate indicates tachycardia
|
||||
pub fn is_tachycardia(&self) -> bool {
|
||||
self.rate_bpm > 120.0
|
||||
}
|
||||
|
||||
/// Get confidence based on signal strength
|
||||
pub fn confidence(&self) -> f64 {
|
||||
match self.strength {
|
||||
SignalStrength::Strong => 0.9,
|
||||
SignalStrength::Moderate => 0.7,
|
||||
SignalStrength::Weak => 0.4,
|
||||
SignalStrength::VeryWeak => 0.2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Signal strength levels
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub enum SignalStrength {
|
||||
/// Strong, clear signal
|
||||
Strong,
|
||||
/// Moderate signal
|
||||
Moderate,
|
||||
/// Weak signal
|
||||
Weak,
|
||||
/// Very weak, borderline
|
||||
VeryWeak,
|
||||
}
|
||||
|
||||
/// Movement profile from CSI analysis
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub struct MovementProfile {
|
||||
/// Type of movement detected
|
||||
pub movement_type: MovementType,
|
||||
/// Intensity of movement (0.0-1.0)
|
||||
pub intensity: f32,
|
||||
/// Frequency of movement patterns
|
||||
pub frequency: f32,
|
||||
/// Whether movement appears voluntary/purposeful
|
||||
pub is_voluntary: bool,
|
||||
}
|
||||
|
||||
impl Default for MovementProfile {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
movement_type: MovementType::None,
|
||||
intensity: 0.0,
|
||||
frequency: 0.0,
|
||||
is_voluntary: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MovementProfile {
|
||||
/// Get confidence based on movement characteristics
|
||||
pub fn confidence(&self) -> f64 {
|
||||
match self.movement_type {
|
||||
MovementType::None => 0.0,
|
||||
MovementType::Gross => 0.9,
|
||||
MovementType::Fine => 0.7,
|
||||
MovementType::Tremor => 0.6,
|
||||
MovementType::Periodic => 0.5,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if movement indicates consciousness
|
||||
pub fn indicates_consciousness(&self) -> bool {
|
||||
self.is_voluntary && self.movement_type == MovementType::Gross
|
||||
}
|
||||
}
|
||||
|
||||
/// Types of movement detected
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub enum MovementType {
|
||||
/// No movement detected
|
||||
None,
|
||||
/// Large body movements (limbs, torso)
|
||||
Gross,
|
||||
/// Small movements (fingers, head)
|
||||
Fine,
|
||||
/// Involuntary tremor/shaking
|
||||
Tremor,
|
||||
/// Periodic movement (possibly breathing-related)
|
||||
Periodic,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_confidence_score_clamping() {
|
||||
assert_eq!(ConfidenceScore::new(1.5).value(), 1.0);
|
||||
assert_eq!(ConfidenceScore::new(-0.5).value(), 0.0);
|
||||
assert_eq!(ConfidenceScore::new(0.7).value(), 0.7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_breathing_pattern_rates() {
|
||||
let normal = BreathingPattern {
|
||||
rate_bpm: 16.0,
|
||||
amplitude: 0.8,
|
||||
regularity: 0.9,
|
||||
pattern_type: BreathingType::Normal,
|
||||
};
|
||||
assert!(normal.is_normal_rate());
|
||||
assert!(!normal.is_bradypnea());
|
||||
assert!(!normal.is_tachypnea());
|
||||
|
||||
let slow = BreathingPattern {
|
||||
rate_bpm: 8.0,
|
||||
amplitude: 0.5,
|
||||
regularity: 0.6,
|
||||
pattern_type: BreathingType::Shallow,
|
||||
};
|
||||
assert!(slow.is_bradypnea());
|
||||
|
||||
let fast = BreathingPattern {
|
||||
rate_bpm: 35.0,
|
||||
amplitude: 0.7,
|
||||
regularity: 0.5,
|
||||
pattern_type: BreathingType::Labored,
|
||||
};
|
||||
assert!(fast.is_tachypnea());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vital_signs_reading() {
|
||||
let breathing = BreathingPattern {
|
||||
rate_bpm: 16.0,
|
||||
amplitude: 0.8,
|
||||
regularity: 0.9,
|
||||
pattern_type: BreathingType::Normal,
|
||||
};
|
||||
|
||||
let reading = VitalSignsReading::new(
|
||||
Some(breathing),
|
||||
None,
|
||||
MovementProfile::default(),
|
||||
);
|
||||
|
||||
assert!(reading.has_vitals());
|
||||
assert!(reading.has_breathing());
|
||||
assert!(!reading.has_heartbeat());
|
||||
assert!(!reading.has_movement());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_signal_strength_confidence() {
|
||||
let strong = HeartbeatSignature {
|
||||
rate_bpm: 72.0,
|
||||
variability: 0.1,
|
||||
strength: SignalStrength::Strong,
|
||||
};
|
||||
assert_eq!(strong.confidence(), 0.9);
|
||||
|
||||
let weak = HeartbeatSignature {
|
||||
rate_bpm: 72.0,
|
||||
variability: 0.1,
|
||||
strength: SignalStrength::Weak,
|
||||
};
|
||||
assert_eq!(weak.confidence(), 0.4);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,235 +0,0 @@
|
||||
//! Integration layer (Anti-Corruption Layer) for upstream crates.
|
||||
//!
|
||||
//! This module provides adapters to translate between:
|
||||
//! - wifi-densepose-signal types and wifi-Mat domain types
|
||||
//! - wifi-densepose-nn inference results and detection results
|
||||
//! - wifi-densepose-hardware interfaces and sensor abstractions
|
||||
//!
|
||||
//! # Hardware Support
|
||||
//!
|
||||
//! The integration layer supports multiple WiFi CSI hardware platforms:
|
||||
//!
|
||||
//! - **ESP32**: Via serial communication using ESP-CSI firmware
|
||||
//! - **Intel 5300 NIC**: Using Linux CSI Tool (iwlwifi driver)
|
||||
//! - **Atheros NICs**: Using ath9k/ath10k/ath11k CSI patches
|
||||
//! - **Nexmon**: For Broadcom chips with CSI firmware
|
||||
//!
|
||||
//! # Example Usage
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use wifi_densepose_mat::integration::{
|
||||
//! HardwareAdapter, HardwareConfig, AtherosDriver,
|
||||
//! csi_receiver::{UdpCsiReceiver, ReceiverConfig},
|
||||
//! };
|
||||
//!
|
||||
//! // Configure for ESP32
|
||||
//! let config = HardwareConfig::esp32("/dev/ttyUSB0", 921600);
|
||||
//! let mut adapter = HardwareAdapter::with_config(config);
|
||||
//! adapter.initialize().await?;
|
||||
//!
|
||||
//! // Or configure for Intel 5300
|
||||
//! let config = HardwareConfig::intel_5300("wlan0");
|
||||
//! let mut adapter = HardwareAdapter::with_config(config);
|
||||
//!
|
||||
//! // Or use UDP receiver for network streaming
|
||||
//! let config = ReceiverConfig::udp("0.0.0.0", 5500);
|
||||
//! let mut receiver = UdpCsiReceiver::new(config).await?;
|
||||
//! ```
|
||||
|
||||
mod signal_adapter;
|
||||
mod neural_adapter;
|
||||
mod hardware_adapter;
|
||||
pub mod csi_receiver;
|
||||
|
||||
pub use signal_adapter::SignalAdapter;
|
||||
pub use neural_adapter::NeuralAdapter;
|
||||
pub use hardware_adapter::{
|
||||
// Main adapter
|
||||
HardwareAdapter,
|
||||
// Configuration types
|
||||
HardwareConfig,
|
||||
DeviceType,
|
||||
DeviceSettings,
|
||||
AtherosDriver,
|
||||
ChannelConfig,
|
||||
Bandwidth,
|
||||
// Serial settings
|
||||
SerialSettings,
|
||||
Parity,
|
||||
FlowControl,
|
||||
// Network interface settings
|
||||
NetworkInterfaceSettings,
|
||||
AntennaConfig,
|
||||
// UDP settings
|
||||
UdpSettings,
|
||||
// PCAP settings
|
||||
PcapSettings,
|
||||
// Sensor types
|
||||
SensorInfo,
|
||||
SensorStatus,
|
||||
// CSI data types
|
||||
CsiReadings,
|
||||
CsiMetadata,
|
||||
SensorCsiReading,
|
||||
FrameControlType,
|
||||
CsiStream,
|
||||
// Health and stats
|
||||
HardwareHealth,
|
||||
HealthStatus,
|
||||
StreamingStats,
|
||||
};
|
||||
|
||||
pub use csi_receiver::{
|
||||
// Receiver types
|
||||
UdpCsiReceiver,
|
||||
SerialCsiReceiver,
|
||||
PcapCsiReader,
|
||||
// Configuration
|
||||
ReceiverConfig,
|
||||
CsiSource,
|
||||
UdpSourceConfig,
|
||||
SerialSourceConfig,
|
||||
PcapSourceConfig,
|
||||
SerialParity,
|
||||
// Packet types
|
||||
CsiPacket,
|
||||
CsiPacketMetadata,
|
||||
CsiPacketFormat,
|
||||
// Parser
|
||||
CsiParser,
|
||||
// Stats
|
||||
ReceiverStats,
|
||||
};
|
||||
|
||||
/// Configuration for integration layer
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct IntegrationConfig {
|
||||
/// Use GPU acceleration if available
|
||||
pub use_gpu: bool,
|
||||
/// Batch size for neural inference
|
||||
pub batch_size: usize,
|
||||
/// Enable signal preprocessing optimizations
|
||||
pub optimize_signal: bool,
|
||||
/// Hardware configuration
|
||||
pub hardware: Option<HardwareConfig>,
|
||||
}
|
||||
|
||||
impl IntegrationConfig {
|
||||
/// Create configuration for real-time processing
|
||||
pub fn realtime() -> Self {
|
||||
Self {
|
||||
use_gpu: true,
|
||||
batch_size: 1,
|
||||
optimize_signal: true,
|
||||
hardware: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create configuration for batch processing
|
||||
pub fn batch(batch_size: usize) -> Self {
|
||||
Self {
|
||||
use_gpu: true,
|
||||
batch_size,
|
||||
optimize_signal: true,
|
||||
hardware: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create configuration with specific hardware
|
||||
pub fn with_hardware(hardware: HardwareConfig) -> Self {
|
||||
Self {
|
||||
use_gpu: true,
|
||||
batch_size: 1,
|
||||
optimize_signal: true,
|
||||
hardware: Some(hardware),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Error type for integration layer
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AdapterError {
|
||||
/// Signal processing error
|
||||
#[error("Signal adapter error: {0}")]
|
||||
Signal(String),
|
||||
|
||||
/// Neural network error
|
||||
#[error("Neural adapter error: {0}")]
|
||||
Neural(String),
|
||||
|
||||
/// Hardware error
|
||||
#[error("Hardware adapter error: {0}")]
|
||||
Hardware(String),
|
||||
|
||||
/// Configuration error
|
||||
#[error("Configuration error: {0}")]
|
||||
Config(String),
|
||||
|
||||
/// Data format error
|
||||
#[error("Data format error: {0}")]
|
||||
DataFormat(String),
|
||||
|
||||
/// I/O error
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
/// Timeout error
|
||||
#[error("Timeout error: {0}")]
|
||||
Timeout(String),
|
||||
}
|
||||
|
||||
/// Prelude module for convenient imports
|
||||
pub mod prelude {
|
||||
pub use super::{
|
||||
AdapterError,
|
||||
HardwareAdapter,
|
||||
HardwareConfig,
|
||||
DeviceType,
|
||||
AtherosDriver,
|
||||
Bandwidth,
|
||||
CsiReadings,
|
||||
CsiPacket,
|
||||
CsiPacketFormat,
|
||||
IntegrationConfig,
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_integration_config_defaults() {
|
||||
let config = IntegrationConfig::default();
|
||||
assert!(!config.use_gpu);
|
||||
assert_eq!(config.batch_size, 0);
|
||||
assert!(!config.optimize_signal);
|
||||
assert!(config.hardware.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_integration_config_realtime() {
|
||||
let config = IntegrationConfig::realtime();
|
||||
assert!(config.use_gpu);
|
||||
assert_eq!(config.batch_size, 1);
|
||||
assert!(config.optimize_signal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_integration_config_batch() {
|
||||
let config = IntegrationConfig::batch(32);
|
||||
assert!(config.use_gpu);
|
||||
assert_eq!(config.batch_size, 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_integration_config_with_hardware() {
|
||||
let hw_config = HardwareConfig::esp32("/dev/ttyUSB0", 921600);
|
||||
let config = IntegrationConfig::with_hardware(hw_config);
|
||||
assert!(config.hardware.is_some());
|
||||
assert!(matches!(
|
||||
config.hardware.as_ref().unwrap().device_type,
|
||||
DeviceType::Esp32
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
//! Adapter for wifi-densepose-nn crate (neural network inference).
|
||||
|
||||
use super::AdapterError;
|
||||
use crate::domain::{BreathingPattern, BreathingType, HeartbeatSignature, SignalStrength};
|
||||
use super::signal_adapter::VitalFeatures;
|
||||
|
||||
/// Adapter for neural network-based vital signs detection
|
||||
pub struct NeuralAdapter {
|
||||
/// Whether to use GPU acceleration
|
||||
use_gpu: bool,
|
||||
/// Confidence threshold for valid detections
|
||||
confidence_threshold: f32,
|
||||
/// Model loaded status
|
||||
models_loaded: bool,
|
||||
}
|
||||
|
||||
impl NeuralAdapter {
|
||||
/// Create a new neural adapter
|
||||
pub fn new(use_gpu: bool) -> Self {
|
||||
Self {
|
||||
use_gpu,
|
||||
confidence_threshold: 0.5,
|
||||
models_loaded: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with default settings (CPU)
|
||||
pub fn with_defaults() -> Self {
|
||||
Self::new(false)
|
||||
}
|
||||
|
||||
/// Load neural network models
|
||||
pub fn load_models(&mut self, _model_path: &str) -> Result<(), AdapterError> {
|
||||
// In production, this would load ONNX models using wifi-densepose-nn
|
||||
// For now, mark as loaded for simulation
|
||||
self.models_loaded = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Classify breathing pattern using neural network
|
||||
pub fn classify_breathing(
|
||||
&self,
|
||||
features: &VitalFeatures,
|
||||
) -> Result<Option<BreathingPattern>, AdapterError> {
|
||||
if !self.models_loaded {
|
||||
// Fall back to rule-based classification
|
||||
return Ok(self.classify_breathing_rules(features));
|
||||
}
|
||||
|
||||
// In production, this would run ONNX inference
|
||||
// For now, use rule-based approach
|
||||
Ok(self.classify_breathing_rules(features))
|
||||
}
|
||||
|
||||
/// Classify heartbeat using neural network
|
||||
pub fn classify_heartbeat(
|
||||
&self,
|
||||
features: &VitalFeatures,
|
||||
) -> Result<Option<HeartbeatSignature>, AdapterError> {
|
||||
if !self.models_loaded {
|
||||
return Ok(self.classify_heartbeat_rules(features));
|
||||
}
|
||||
|
||||
// In production, run ONNX inference
|
||||
Ok(self.classify_heartbeat_rules(features))
|
||||
}
|
||||
|
||||
/// Combined vital signs classification
|
||||
pub fn classify_vitals(
|
||||
&self,
|
||||
features: &VitalFeatures,
|
||||
) -> Result<VitalsClassification, AdapterError> {
|
||||
let breathing = self.classify_breathing(features)?;
|
||||
let heartbeat = self.classify_heartbeat(features)?;
|
||||
|
||||
// Calculate overall confidence
|
||||
let confidence = self.calculate_confidence(
|
||||
&breathing,
|
||||
&heartbeat,
|
||||
features.signal_quality,
|
||||
);
|
||||
|
||||
Ok(VitalsClassification {
|
||||
breathing,
|
||||
heartbeat,
|
||||
confidence,
|
||||
signal_quality: features.signal_quality,
|
||||
})
|
||||
}
|
||||
|
||||
/// Rule-based breathing classification (fallback)
|
||||
fn classify_breathing_rules(&self, features: &VitalFeatures) -> Option<BreathingPattern> {
|
||||
if features.breathing_features.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let peak_freq = features.breathing_features[0];
|
||||
let power_ratio = features.breathing_features.get(1).copied().unwrap_or(0.0);
|
||||
let band_ratio = features.breathing_features.get(2).copied().unwrap_or(0.0);
|
||||
|
||||
// Check if there's significant energy in breathing band
|
||||
if power_ratio < 0.05 || band_ratio < 0.1 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let rate_bpm = (peak_freq * 60.0) as f32;
|
||||
|
||||
// Validate rate
|
||||
if rate_bpm < 4.0 || rate_bpm > 60.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let pattern_type = if rate_bpm < 6.0 {
|
||||
BreathingType::Agonal
|
||||
} else if rate_bpm < 10.0 {
|
||||
BreathingType::Shallow
|
||||
} else if rate_bpm > 30.0 {
|
||||
BreathingType::Labored
|
||||
} else if band_ratio < 0.3 {
|
||||
BreathingType::Irregular
|
||||
} else {
|
||||
BreathingType::Normal
|
||||
};
|
||||
|
||||
Some(BreathingPattern {
|
||||
rate_bpm,
|
||||
amplitude: power_ratio as f32,
|
||||
regularity: band_ratio as f32,
|
||||
pattern_type,
|
||||
})
|
||||
}
|
||||
|
||||
/// Rule-based heartbeat classification (fallback)
|
||||
fn classify_heartbeat_rules(&self, features: &VitalFeatures) -> Option<HeartbeatSignature> {
|
||||
if features.heartbeat_features.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let peak_freq = features.heartbeat_features[0];
|
||||
let power_ratio = features.heartbeat_features.get(1).copied().unwrap_or(0.0);
|
||||
let band_ratio = features.heartbeat_features.get(2).copied().unwrap_or(0.0);
|
||||
|
||||
// Heartbeat detection requires stronger signal
|
||||
if power_ratio < 0.03 || band_ratio < 0.08 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let rate_bpm = (peak_freq * 60.0) as f32;
|
||||
|
||||
// Validate rate (30-200 BPM)
|
||||
if rate_bpm < 30.0 || rate_bpm > 200.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let strength = if power_ratio > 0.15 {
|
||||
SignalStrength::Strong
|
||||
} else if power_ratio > 0.08 {
|
||||
SignalStrength::Moderate
|
||||
} else if power_ratio > 0.04 {
|
||||
SignalStrength::Weak
|
||||
} else {
|
||||
SignalStrength::VeryWeak
|
||||
};
|
||||
|
||||
Some(HeartbeatSignature {
|
||||
rate_bpm,
|
||||
variability: band_ratio as f32 * 0.5,
|
||||
strength,
|
||||
})
|
||||
}
|
||||
|
||||
/// Calculate overall confidence from detections
|
||||
fn calculate_confidence(
|
||||
&self,
|
||||
breathing: &Option<BreathingPattern>,
|
||||
heartbeat: &Option<HeartbeatSignature>,
|
||||
signal_quality: f64,
|
||||
) -> f32 {
|
||||
let mut confidence = signal_quality as f32 * 0.3;
|
||||
|
||||
if let Some(b) = breathing {
|
||||
confidence += 0.4 * b.confidence() as f32;
|
||||
}
|
||||
|
||||
if let Some(h) = heartbeat {
|
||||
confidence += 0.3 * h.confidence() as f32;
|
||||
}
|
||||
|
||||
confidence.clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for NeuralAdapter {
|
||||
fn default() -> Self {
|
||||
Self::with_defaults()
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of neural network vital signs classification
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VitalsClassification {
|
||||
/// Detected breathing pattern
|
||||
pub breathing: Option<BreathingPattern>,
|
||||
/// Detected heartbeat
|
||||
pub heartbeat: Option<HeartbeatSignature>,
|
||||
/// Overall classification confidence
|
||||
pub confidence: f32,
|
||||
/// Signal quality indicator
|
||||
pub signal_quality: f64,
|
||||
}
|
||||
|
||||
impl VitalsClassification {
|
||||
/// Check if any vital signs were detected
|
||||
pub fn has_vitals(&self) -> bool {
|
||||
self.breathing.is_some() || self.heartbeat.is_some()
|
||||
}
|
||||
|
||||
/// Check if detection confidence is sufficient
|
||||
pub fn is_confident(&self, threshold: f32) -> bool {
|
||||
self.confidence >= threshold
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn create_good_features() -> VitalFeatures {
|
||||
VitalFeatures {
|
||||
breathing_features: vec![0.25, 0.2, 0.4], // 15 BPM, good signal
|
||||
heartbeat_features: vec![1.2, 0.1, 0.15], // 72 BPM, moderate signal
|
||||
movement_features: vec![0.1, 0.05, 0.01],
|
||||
signal_quality: 0.8,
|
||||
}
|
||||
}
|
||||
|
||||
fn create_weak_features() -> VitalFeatures {
|
||||
VitalFeatures {
|
||||
breathing_features: vec![0.25, 0.02, 0.05], // Weak
|
||||
heartbeat_features: vec![1.2, 0.01, 0.02], // Very weak
|
||||
movement_features: vec![0.01, 0.005, 0.001],
|
||||
signal_quality: 0.3,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_breathing() {
|
||||
let adapter = NeuralAdapter::with_defaults();
|
||||
let features = create_good_features();
|
||||
|
||||
let result = adapter.classify_breathing(&features);
|
||||
assert!(result.is_ok());
|
||||
assert!(result.unwrap().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_weak_signal_no_detection() {
|
||||
let adapter = NeuralAdapter::with_defaults();
|
||||
let features = create_weak_features();
|
||||
|
||||
let result = adapter.classify_breathing(&features);
|
||||
assert!(result.is_ok());
|
||||
// Weak signals may or may not be detected depending on thresholds
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_vitals() {
|
||||
let adapter = NeuralAdapter::with_defaults();
|
||||
let features = create_good_features();
|
||||
|
||||
let result = adapter.classify_vitals(&features);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let classification = result.unwrap();
|
||||
assert!(classification.has_vitals());
|
||||
assert!(classification.confidence > 0.3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_confidence_calculation() {
|
||||
let adapter = NeuralAdapter::with_defaults();
|
||||
|
||||
let breathing = Some(BreathingPattern {
|
||||
rate_bpm: 16.0,
|
||||
amplitude: 0.8,
|
||||
regularity: 0.9,
|
||||
pattern_type: BreathingType::Normal,
|
||||
});
|
||||
|
||||
let confidence = adapter.calculate_confidence(&breathing, &None, 0.8);
|
||||
assert!(confidence > 0.5);
|
||||
}
|
||||
}
|
||||
@@ -1,337 +0,0 @@
|
||||
//! Adapter for wifi-densepose-signal crate.
|
||||
|
||||
use super::AdapterError;
|
||||
use crate::domain::{BreathingPattern, BreathingType};
|
||||
use crate::detection::CsiDataBuffer;
|
||||
|
||||
/// Features extracted from signal for vital signs detection
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct VitalFeatures {
|
||||
/// Breathing frequency features
|
||||
pub breathing_features: Vec<f64>,
|
||||
/// Heartbeat frequency features
|
||||
pub heartbeat_features: Vec<f64>,
|
||||
/// Movement energy features
|
||||
pub movement_features: Vec<f64>,
|
||||
/// Overall signal quality
|
||||
pub signal_quality: f64,
|
||||
}
|
||||
|
||||
/// Adapter for wifi-densepose-signal crate
|
||||
pub struct SignalAdapter {
|
||||
/// Window size for processing
|
||||
window_size: usize,
|
||||
/// Overlap between windows
|
||||
overlap: f64,
|
||||
/// Sample rate
|
||||
sample_rate: f64,
|
||||
}
|
||||
|
||||
impl SignalAdapter {
|
||||
/// Create a new signal adapter
|
||||
pub fn new(window_size: usize, overlap: f64, sample_rate: f64) -> Self {
|
||||
Self {
|
||||
window_size,
|
||||
overlap,
|
||||
sample_rate,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with default settings
|
||||
pub fn with_defaults() -> Self {
|
||||
Self::new(512, 0.5, 1000.0)
|
||||
}
|
||||
|
||||
/// Extract vital sign features from CSI data
|
||||
pub fn extract_vital_features(
|
||||
&self,
|
||||
csi_data: &CsiDataBuffer,
|
||||
) -> Result<VitalFeatures, AdapterError> {
|
||||
if csi_data.amplitudes.len() < self.window_size {
|
||||
return Err(AdapterError::Signal(
|
||||
"Insufficient data for feature extraction".into()
|
||||
));
|
||||
}
|
||||
|
||||
// Extract breathing-range features (0.1-0.5 Hz)
|
||||
let breathing_features = self.extract_frequency_band(
|
||||
&csi_data.amplitudes,
|
||||
0.1,
|
||||
0.5,
|
||||
)?;
|
||||
|
||||
// Extract heartbeat-range features (0.8-2.0 Hz)
|
||||
let heartbeat_features = self.extract_frequency_band(
|
||||
&csi_data.phases,
|
||||
0.8,
|
||||
2.0,
|
||||
)?;
|
||||
|
||||
// Extract movement features
|
||||
let movement_features = self.extract_movement_features(&csi_data.amplitudes)?;
|
||||
|
||||
// Calculate signal quality
|
||||
let signal_quality = self.calculate_signal_quality(&csi_data.amplitudes);
|
||||
|
||||
Ok(VitalFeatures {
|
||||
breathing_features,
|
||||
heartbeat_features,
|
||||
movement_features,
|
||||
signal_quality,
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert upstream CsiFeatures to breathing pattern
|
||||
pub fn to_breathing_pattern(
|
||||
&self,
|
||||
features: &VitalFeatures,
|
||||
) -> Option<BreathingPattern> {
|
||||
if features.breathing_features.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Extract key values from features
|
||||
let rate_estimate = features.breathing_features[0];
|
||||
let amplitude = features.breathing_features.get(1).copied().unwrap_or(0.5);
|
||||
let regularity = features.breathing_features.get(2).copied().unwrap_or(0.5);
|
||||
|
||||
// Convert rate from Hz to BPM
|
||||
let rate_bpm = (rate_estimate * 60.0) as f32;
|
||||
|
||||
// Validate rate
|
||||
if rate_bpm < 4.0 || rate_bpm > 60.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Determine breathing type
|
||||
let pattern_type = self.classify_breathing_type(rate_bpm, regularity);
|
||||
|
||||
Some(BreathingPattern {
|
||||
rate_bpm,
|
||||
amplitude: amplitude as f32,
|
||||
regularity: regularity as f32,
|
||||
pattern_type,
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract features from a frequency band
|
||||
fn extract_frequency_band(
|
||||
&self,
|
||||
signal: &[f64],
|
||||
low_freq: f64,
|
||||
high_freq: f64,
|
||||
) -> Result<Vec<f64>, AdapterError> {
|
||||
use rustfft::{FftPlanner, num_complex::Complex};
|
||||
|
||||
let n = signal.len().min(self.window_size);
|
||||
if n < 32 {
|
||||
return Err(AdapterError::Signal("Signal too short".into()));
|
||||
}
|
||||
|
||||
let fft_size = n.next_power_of_two();
|
||||
let mut planner = FftPlanner::new();
|
||||
let fft = planner.plan_fft_forward(fft_size);
|
||||
|
||||
// Prepare buffer with windowing
|
||||
let mut buffer: Vec<Complex<f64>> = signal.iter()
|
||||
.take(n)
|
||||
.enumerate()
|
||||
.map(|(i, &x)| {
|
||||
let window = 0.5 * (1.0 - (2.0 * std::f64::consts::PI * i as f64 / n as f64).cos());
|
||||
Complex::new(x * window, 0.0)
|
||||
})
|
||||
.collect();
|
||||
buffer.resize(fft_size, Complex::new(0.0, 0.0));
|
||||
|
||||
fft.process(&mut buffer);
|
||||
|
||||
// Extract magnitude spectrum in frequency range
|
||||
let freq_resolution = self.sample_rate / fft_size as f64;
|
||||
let low_bin = (low_freq / freq_resolution).ceil() as usize;
|
||||
let high_bin = (high_freq / freq_resolution).floor() as usize;
|
||||
|
||||
let mut features = Vec::new();
|
||||
|
||||
if high_bin > low_bin && high_bin < buffer.len() / 2 {
|
||||
// Find peak frequency
|
||||
let mut max_mag = 0.0;
|
||||
let mut peak_bin = low_bin;
|
||||
for i in low_bin..=high_bin {
|
||||
let mag = buffer[i].norm();
|
||||
if mag > max_mag {
|
||||
max_mag = mag;
|
||||
peak_bin = i;
|
||||
}
|
||||
}
|
||||
|
||||
// Peak frequency
|
||||
features.push(peak_bin as f64 * freq_resolution);
|
||||
// Peak magnitude (normalized)
|
||||
let total_power: f64 = buffer[1..buffer.len()/2]
|
||||
.iter()
|
||||
.map(|c| c.norm_sqr())
|
||||
.sum();
|
||||
features.push(if total_power > 0.0 { max_mag * max_mag / total_power } else { 0.0 });
|
||||
|
||||
// Band power ratio
|
||||
let band_power: f64 = buffer[low_bin..=high_bin]
|
||||
.iter()
|
||||
.map(|c| c.norm_sqr())
|
||||
.sum();
|
||||
features.push(if total_power > 0.0 { band_power / total_power } else { 0.0 });
|
||||
}
|
||||
|
||||
Ok(features)
|
||||
}
|
||||
|
||||
/// Extract movement-related features
|
||||
fn extract_movement_features(&self, signal: &[f64]) -> Result<Vec<f64>, AdapterError> {
|
||||
if signal.len() < 10 {
|
||||
return Err(AdapterError::Signal("Signal too short".into()));
|
||||
}
|
||||
|
||||
// Calculate variance
|
||||
let mean = signal.iter().sum::<f64>() / signal.len() as f64;
|
||||
let variance = signal.iter()
|
||||
.map(|x| (x - mean).powi(2))
|
||||
.sum::<f64>() / signal.len() as f64;
|
||||
|
||||
// Calculate max absolute change
|
||||
let max_change = signal.windows(2)
|
||||
.map(|w| (w[1] - w[0]).abs())
|
||||
.fold(0.0, f64::max);
|
||||
|
||||
// Calculate zero crossing rate
|
||||
let centered: Vec<f64> = signal.iter().map(|x| x - mean).collect();
|
||||
let zero_crossings: usize = centered.windows(2)
|
||||
.filter(|w| (w[0] >= 0.0) != (w[1] >= 0.0))
|
||||
.count();
|
||||
let zcr = zero_crossings as f64 / signal.len() as f64;
|
||||
|
||||
Ok(vec![variance, max_change, zcr])
|
||||
}
|
||||
|
||||
/// Calculate overall signal quality
|
||||
fn calculate_signal_quality(&self, signal: &[f64]) -> f64 {
|
||||
if signal.len() < 10 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// SNR estimate based on signal statistics
|
||||
let mean = signal.iter().sum::<f64>() / signal.len() as f64;
|
||||
let variance = signal.iter()
|
||||
.map(|x| (x - mean).powi(2))
|
||||
.sum::<f64>() / signal.len() as f64;
|
||||
|
||||
// Higher variance relative to mean suggests better signal
|
||||
let snr_estimate = if mean.abs() > 1e-10 {
|
||||
(variance.sqrt() / mean.abs()).min(10.0) / 10.0
|
||||
} else {
|
||||
0.5
|
||||
};
|
||||
|
||||
snr_estimate.clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// Classify breathing type from rate and regularity
|
||||
fn classify_breathing_type(&self, rate_bpm: f32, regularity: f64) -> BreathingType {
|
||||
if rate_bpm < 6.0 {
|
||||
if regularity < 0.3 {
|
||||
BreathingType::Agonal
|
||||
} else {
|
||||
BreathingType::Shallow
|
||||
}
|
||||
} else if rate_bpm < 10.0 {
|
||||
BreathingType::Shallow
|
||||
} else if rate_bpm > 30.0 {
|
||||
BreathingType::Labored
|
||||
} else if regularity < 0.4 {
|
||||
BreathingType::Irregular
|
||||
} else {
|
||||
BreathingType::Normal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SignalAdapter {
|
||||
fn default() -> Self {
|
||||
Self::with_defaults()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn create_test_buffer() -> CsiDataBuffer {
|
||||
let mut buffer = CsiDataBuffer::new(100.0);
|
||||
|
||||
// 10 seconds of data with breathing pattern
|
||||
let amplitudes: Vec<f64> = (0..1000)
|
||||
.map(|i| {
|
||||
let t = i as f64 / 100.0;
|
||||
(2.0 * std::f64::consts::PI * 0.25 * t).sin() // 15 BPM
|
||||
})
|
||||
.collect();
|
||||
|
||||
let phases: Vec<f64> = (0..1000)
|
||||
.map(|i| {
|
||||
let t = i as f64 / 100.0;
|
||||
(2.0 * std::f64::consts::PI * 0.25 * t).sin() * 0.5
|
||||
})
|
||||
.collect();
|
||||
|
||||
buffer.add_samples(&litudes, &phases);
|
||||
buffer
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_vital_features() {
|
||||
// Use a smaller window size for the test
|
||||
let adapter = SignalAdapter::new(256, 0.5, 100.0);
|
||||
let buffer = create_test_buffer();
|
||||
|
||||
let result = adapter.extract_vital_features(&buffer);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let features = result.unwrap();
|
||||
// Features should be extracted (may be empty if frequency out of range)
|
||||
// The main check is that extraction doesn't fail
|
||||
assert!(features.signal_quality >= 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_breathing_pattern() {
|
||||
let adapter = SignalAdapter::with_defaults();
|
||||
|
||||
let features = VitalFeatures {
|
||||
breathing_features: vec![0.25, 0.8, 0.9], // 15 BPM
|
||||
heartbeat_features: vec![],
|
||||
movement_features: vec![],
|
||||
signal_quality: 0.8,
|
||||
};
|
||||
|
||||
let pattern = adapter.to_breathing_pattern(&features);
|
||||
assert!(pattern.is_some());
|
||||
|
||||
let p = pattern.unwrap();
|
||||
assert!(p.rate_bpm > 10.0 && p.rate_bpm < 20.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_signal_quality() {
|
||||
let adapter = SignalAdapter::with_defaults();
|
||||
|
||||
// Good signal
|
||||
let good_signal: Vec<f64> = (0..100)
|
||||
.map(|i| (i as f64 * 0.1).sin())
|
||||
.collect();
|
||||
let good_quality = adapter.calculate_signal_quality(&good_signal);
|
||||
|
||||
// Poor signal (constant)
|
||||
let poor_signal = vec![0.5; 100];
|
||||
let poor_quality = adapter.calculate_signal_quality(&poor_signal);
|
||||
|
||||
assert!(good_quality > poor_quality);
|
||||
}
|
||||
}
|
||||
@@ -1,603 +0,0 @@
|
||||
//! # WiFi-DensePose MAT (Mass Casualty Assessment Tool)
|
||||
//!
|
||||
//! A modular extension for WiFi-based disaster survivor detection and localization.
|
||||
//!
|
||||
//! This crate provides capabilities for detecting human survivors trapped in rubble,
|
||||
//! debris, or collapsed structures using WiFi Channel State Information (CSI) analysis.
|
||||
//!
|
||||
//! ## Features
|
||||
//!
|
||||
//! - **Vital Signs Detection**: Breathing patterns, heartbeat signatures, and movement
|
||||
//! - **Survivor Localization**: 3D position estimation through debris
|
||||
//! - **Triage Classification**: Automatic START protocol-compatible triage
|
||||
//! - **Real-time Alerting**: Priority-based alert generation and dispatch
|
||||
//!
|
||||
//! ## Use Cases
|
||||
//!
|
||||
//! - Earthquake search and rescue
|
||||
//! - Building collapse response
|
||||
//! - Avalanche victim location
|
||||
//! - Flood rescue operations
|
||||
//! - Mine collapse detection
|
||||
//!
|
||||
//! ## Architecture
|
||||
//!
|
||||
//! The crate follows Domain-Driven Design (DDD) principles with clear bounded contexts:
|
||||
//!
|
||||
//! ```text
|
||||
//! ┌─────────────────────────────────────────────────────────┐
|
||||
//! │ wifi-densepose-mat │
|
||||
//! ├─────────────────────────────────────────────────────────┤
|
||||
//! │ ┌───────────┐ ┌─────────────┐ ┌─────────────────┐ │
|
||||
//! │ │ Detection │ │Localization │ │ Alerting │ │
|
||||
//! │ │ Context │ │ Context │ │ Context │ │
|
||||
//! │ └─────┬─────┘ └──────┬──────┘ └────────┬────────┘ │
|
||||
//! │ └───────────────┼──────────────────┘ │
|
||||
//! │ │ │
|
||||
//! │ ┌─────────▼─────────┐ │
|
||||
//! │ │ Integration │ │
|
||||
//! │ │ Layer │ │
|
||||
//! │ └───────────────────┘ │
|
||||
//! └─────────────────────────────────────────────────────────┘
|
||||
//! ```
|
||||
//!
|
||||
//! ## Example
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use wifi_densepose_mat::{
|
||||
//! DisasterResponse, DisasterConfig, DisasterType,
|
||||
//! ScanZone, ZoneBounds,
|
||||
//! };
|
||||
//!
|
||||
//! #[tokio::main]
|
||||
//! async fn main() -> anyhow::Result<()> {
|
||||
//! // Initialize disaster response system
|
||||
//! let config = DisasterConfig::builder()
|
||||
//! .disaster_type(DisasterType::Earthquake)
|
||||
//! .sensitivity(0.8)
|
||||
//! .build();
|
||||
//!
|
||||
//! let mut response = DisasterResponse::new(config);
|
||||
//!
|
||||
//! // Define scan zone
|
||||
//! let zone = ScanZone::new(
|
||||
//! "Building A - North Wing",
|
||||
//! ZoneBounds::rectangle(0.0, 0.0, 50.0, 30.0),
|
||||
//! );
|
||||
//! response.add_zone(zone)?;
|
||||
//!
|
||||
//! // Start scanning
|
||||
//! response.start_scanning().await?;
|
||||
//!
|
||||
//! Ok(())
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
#![cfg_attr(docsrs, feature(doc_cfg))]
|
||||
#![warn(missing_docs)]
|
||||
#![warn(rustdoc::missing_crate_level_docs)]
|
||||
|
||||
pub mod alerting;
|
||||
pub mod api;
|
||||
pub mod detection;
|
||||
pub mod domain;
|
||||
pub mod integration;
|
||||
pub mod localization;
|
||||
pub mod ml;
|
||||
|
||||
// Re-export main types
|
||||
pub use domain::{
|
||||
survivor::{Survivor, SurvivorId, SurvivorMetadata, SurvivorStatus},
|
||||
disaster_event::{DisasterEvent, DisasterEventId, DisasterType, EventStatus},
|
||||
scan_zone::{ScanZone, ScanZoneId, ZoneBounds, ZoneStatus, ScanParameters},
|
||||
alert::{Alert, AlertId, AlertPayload, Priority},
|
||||
vital_signs::{
|
||||
VitalSignsReading, BreathingPattern, BreathingType,
|
||||
HeartbeatSignature, MovementProfile, MovementType,
|
||||
},
|
||||
triage::{TriageStatus, TriageCalculator},
|
||||
coordinates::{Coordinates3D, LocationUncertainty, DepthEstimate},
|
||||
events::{DetectionEvent, AlertEvent, DomainEvent, EventStore, InMemoryEventStore},
|
||||
};
|
||||
|
||||
pub use detection::{
|
||||
BreathingDetector, BreathingDetectorConfig,
|
||||
HeartbeatDetector, HeartbeatDetectorConfig,
|
||||
MovementClassifier, MovementClassifierConfig,
|
||||
VitalSignsDetector, DetectionPipeline, DetectionConfig,
|
||||
EnsembleClassifier, EnsembleConfig, EnsembleResult,
|
||||
};
|
||||
|
||||
pub use localization::{
|
||||
Triangulator, TriangulationConfig,
|
||||
DepthEstimator, DepthEstimatorConfig,
|
||||
PositionFuser, LocalizationService,
|
||||
};
|
||||
|
||||
pub use alerting::{
|
||||
AlertGenerator, AlertDispatcher, AlertConfig,
|
||||
TriageService, PriorityCalculator,
|
||||
};
|
||||
|
||||
pub use integration::{
|
||||
SignalAdapter, NeuralAdapter, HardwareAdapter,
|
||||
AdapterError, IntegrationConfig,
|
||||
};
|
||||
|
||||
pub use api::{
|
||||
create_router, AppState,
|
||||
};
|
||||
|
||||
pub use ml::{
|
||||
// Core ML types
|
||||
MlError, MlResult, MlDetectionConfig, MlDetectionPipeline, MlDetectionResult,
|
||||
// Debris penetration model
|
||||
DebrisPenetrationModel, DebrisFeatures, DepthEstimate as MlDepthEstimate,
|
||||
DebrisModel, DebrisModelConfig, DebrisFeatureExtractor,
|
||||
MaterialType, DebrisClassification, AttenuationPrediction,
|
||||
// Vital signs classifier
|
||||
VitalSignsClassifier, VitalSignsClassifierConfig,
|
||||
BreathingClassification, HeartbeatClassification,
|
||||
UncertaintyEstimate, ClassifierOutput,
|
||||
};
|
||||
|
||||
/// Library version
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// Common result type for MAT operations
|
||||
pub type Result<T> = std::result::Result<T, MatError>;
|
||||
|
||||
/// Unified error type for MAT operations
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum MatError {
|
||||
/// Detection error
|
||||
#[error("Detection error: {0}")]
|
||||
Detection(String),
|
||||
|
||||
/// Localization error
|
||||
#[error("Localization error: {0}")]
|
||||
Localization(String),
|
||||
|
||||
/// Alerting error
|
||||
#[error("Alerting error: {0}")]
|
||||
Alerting(String),
|
||||
|
||||
/// Integration error
|
||||
#[error("Integration error: {0}")]
|
||||
Integration(#[from] AdapterError),
|
||||
|
||||
/// Configuration error
|
||||
#[error("Configuration error: {0}")]
|
||||
Config(String),
|
||||
|
||||
/// Domain invariant violation
|
||||
#[error("Domain error: {0}")]
|
||||
Domain(String),
|
||||
|
||||
/// Repository error
|
||||
#[error("Repository error: {0}")]
|
||||
Repository(String),
|
||||
|
||||
/// Signal processing error
|
||||
#[error("Signal processing error: {0}")]
|
||||
Signal(#[from] wifi_densepose_signal::SignalError),
|
||||
|
||||
/// I/O error
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
/// Machine learning error
|
||||
#[error("ML error: {0}")]
|
||||
Ml(#[from] ml::MlError),
|
||||
}
|
||||
|
||||
/// Configuration for the disaster response system
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DisasterConfig {
|
||||
/// Type of disaster event
|
||||
pub disaster_type: DisasterType,
|
||||
/// Detection sensitivity (0.0-1.0)
|
||||
pub sensitivity: f64,
|
||||
/// Minimum confidence threshold for survivor detection
|
||||
pub confidence_threshold: f64,
|
||||
/// Maximum depth to scan (meters)
|
||||
pub max_depth: f64,
|
||||
/// Scan interval in milliseconds
|
||||
pub scan_interval_ms: u64,
|
||||
/// Enable continuous monitoring
|
||||
pub continuous_monitoring: bool,
|
||||
/// Alert configuration
|
||||
pub alert_config: AlertConfig,
|
||||
}
|
||||
|
||||
impl Default for DisasterConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
disaster_type: DisasterType::Unknown,
|
||||
sensitivity: 0.8,
|
||||
confidence_threshold: 0.5,
|
||||
max_depth: 5.0,
|
||||
scan_interval_ms: 500,
|
||||
continuous_monitoring: true,
|
||||
alert_config: AlertConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DisasterConfig {
|
||||
/// Create a new configuration builder
|
||||
pub fn builder() -> DisasterConfigBuilder {
|
||||
DisasterConfigBuilder::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder for DisasterConfig
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DisasterConfigBuilder {
|
||||
config: DisasterConfig,
|
||||
}
|
||||
|
||||
impl DisasterConfigBuilder {
|
||||
/// Set disaster type
|
||||
pub fn disaster_type(mut self, disaster_type: DisasterType) -> Self {
|
||||
self.config.disaster_type = disaster_type;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set detection sensitivity
|
||||
pub fn sensitivity(mut self, sensitivity: f64) -> Self {
|
||||
self.config.sensitivity = sensitivity.clamp(0.0, 1.0);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set confidence threshold
|
||||
pub fn confidence_threshold(mut self, threshold: f64) -> Self {
|
||||
self.config.confidence_threshold = threshold.clamp(0.0, 1.0);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set maximum scan depth
|
||||
pub fn max_depth(mut self, depth: f64) -> Self {
|
||||
self.config.max_depth = depth.max(0.0);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set scan interval
|
||||
pub fn scan_interval_ms(mut self, interval: u64) -> Self {
|
||||
self.config.scan_interval_ms = interval.max(100);
|
||||
self
|
||||
}
|
||||
|
||||
/// Enable/disable continuous monitoring
|
||||
pub fn continuous_monitoring(mut self, enabled: bool) -> Self {
|
||||
self.config.continuous_monitoring = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
/// Build the configuration
|
||||
pub fn build(self) -> DisasterConfig {
|
||||
self.config
|
||||
}
|
||||
}
|
||||
|
||||
/// Main disaster response coordinator
|
||||
pub struct DisasterResponse {
|
||||
config: DisasterConfig,
|
||||
event: Option<DisasterEvent>,
|
||||
detection_pipeline: DetectionPipeline,
|
||||
localization_service: LocalizationService,
|
||||
alert_dispatcher: AlertDispatcher,
|
||||
event_store: std::sync::Arc<dyn domain::events::EventStore>,
|
||||
ensemble_classifier: EnsembleClassifier,
|
||||
running: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
impl DisasterResponse {
|
||||
/// Create a new disaster response system
|
||||
pub fn new(config: DisasterConfig) -> Self {
|
||||
let detection_config = DetectionConfig::from_disaster_config(&config);
|
||||
let detection_pipeline = DetectionPipeline::new(detection_config);
|
||||
|
||||
let localization_service = LocalizationService::new();
|
||||
let alert_dispatcher = AlertDispatcher::new(config.alert_config.clone());
|
||||
let event_store: std::sync::Arc<dyn domain::events::EventStore> =
|
||||
std::sync::Arc::new(InMemoryEventStore::new());
|
||||
let ensemble_classifier = EnsembleClassifier::new(EnsembleConfig::default());
|
||||
|
||||
Self {
|
||||
config,
|
||||
event: None,
|
||||
detection_pipeline,
|
||||
localization_service,
|
||||
alert_dispatcher,
|
||||
event_store,
|
||||
ensemble_classifier,
|
||||
running: std::sync::atomic::AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with a custom event store (e.g. for persistence or testing)
|
||||
pub fn with_event_store(
|
||||
config: DisasterConfig,
|
||||
event_store: std::sync::Arc<dyn domain::events::EventStore>,
|
||||
) -> Self {
|
||||
let detection_config = DetectionConfig::from_disaster_config(&config);
|
||||
let detection_pipeline = DetectionPipeline::new(detection_config);
|
||||
let localization_service = LocalizationService::new();
|
||||
let alert_dispatcher = AlertDispatcher::new(config.alert_config.clone());
|
||||
let ensemble_classifier = EnsembleClassifier::new(EnsembleConfig::default());
|
||||
|
||||
Self {
|
||||
config,
|
||||
event: None,
|
||||
detection_pipeline,
|
||||
localization_service,
|
||||
alert_dispatcher,
|
||||
event_store,
|
||||
ensemble_classifier,
|
||||
running: std::sync::atomic::AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Push CSI data into the detection pipeline for processing.
|
||||
///
|
||||
/// This is the primary data ingestion point. Call this with real CSI
|
||||
/// amplitude and phase readings from hardware (ESP32, Intel 5300, etc).
|
||||
/// Returns an error string if data is invalid.
|
||||
pub fn push_csi_data(&self, amplitudes: &[f64], phases: &[f64]) -> Result<()> {
|
||||
if amplitudes.len() != phases.len() {
|
||||
return Err(MatError::Detection(
|
||||
"Amplitude and phase arrays must have equal length".into(),
|
||||
));
|
||||
}
|
||||
if amplitudes.is_empty() {
|
||||
return Err(MatError::Detection("CSI data cannot be empty".into()));
|
||||
}
|
||||
self.detection_pipeline.add_data(amplitudes, phases);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the event store for querying domain events
|
||||
pub fn event_store(&self) -> &std::sync::Arc<dyn domain::events::EventStore> {
|
||||
&self.event_store
|
||||
}
|
||||
|
||||
/// Get the ensemble classifier
|
||||
pub fn ensemble_classifier(&self) -> &EnsembleClassifier {
|
||||
&self.ensemble_classifier
|
||||
}
|
||||
|
||||
/// Get the detection pipeline (for direct buffer inspection / data push)
|
||||
pub fn detection_pipeline(&self) -> &DetectionPipeline {
|
||||
&self.detection_pipeline
|
||||
}
|
||||
|
||||
/// Initialize a new disaster event
|
||||
pub fn initialize_event(
|
||||
&mut self,
|
||||
location: geo::Point<f64>,
|
||||
description: &str,
|
||||
) -> Result<&DisasterEvent> {
|
||||
let event = DisasterEvent::new(
|
||||
self.config.disaster_type.clone(),
|
||||
location,
|
||||
description,
|
||||
);
|
||||
self.event = Some(event);
|
||||
self.event.as_ref().ok_or_else(|| MatError::Domain("Failed to create event".into()))
|
||||
}
|
||||
|
||||
/// Add a scan zone to the current event
|
||||
pub fn add_zone(&mut self, zone: ScanZone) -> Result<()> {
|
||||
let event = self.event.as_mut()
|
||||
.ok_or_else(|| MatError::Domain("No active disaster event".into()))?;
|
||||
event.add_zone(zone);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start the scanning process
|
||||
pub async fn start_scanning(&mut self) -> Result<()> {
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
self.running.store(true, Ordering::SeqCst);
|
||||
|
||||
while self.running.load(Ordering::SeqCst) {
|
||||
self.scan_cycle().await?;
|
||||
|
||||
if !self.config.continuous_monitoring {
|
||||
break;
|
||||
}
|
||||
|
||||
tokio::time::sleep(
|
||||
std::time::Duration::from_millis(self.config.scan_interval_ms)
|
||||
).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop the scanning process
|
||||
pub fn stop_scanning(&self) {
|
||||
use std::sync::atomic::Ordering;
|
||||
self.running.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Execute a single scan cycle.
|
||||
///
|
||||
/// Processes all active zones, runs detection pipeline on buffered CSI data,
|
||||
/// applies ensemble classification, emits domain events to the EventStore,
|
||||
/// and dispatches alerts for newly detected survivors.
|
||||
async fn scan_cycle(&mut self) -> Result<()> {
|
||||
let scan_start = std::time::Instant::now();
|
||||
|
||||
// Collect detections first to avoid borrowing issues
|
||||
let mut detections = Vec::new();
|
||||
|
||||
{
|
||||
let event = self.event.as_ref()
|
||||
.ok_or_else(|| MatError::Domain("No active disaster event".into()))?;
|
||||
|
||||
for zone in event.zones() {
|
||||
if zone.status() != &ZoneStatus::Active {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Process buffered CSI data through the detection pipeline
|
||||
let detection_result = self.detection_pipeline.process_zone(zone).await?;
|
||||
|
||||
if let Some(vital_signs) = detection_result {
|
||||
// Run ensemble classifier to combine breathing + heartbeat + movement
|
||||
let ensemble_result = self.ensemble_classifier.classify(&vital_signs);
|
||||
|
||||
// Only proceed if ensemble confidence meets threshold
|
||||
if ensemble_result.confidence >= self.config.confidence_threshold {
|
||||
// Attempt localization
|
||||
let location = self.localization_service
|
||||
.estimate_position(&vital_signs, zone);
|
||||
|
||||
detections.push((zone.id().clone(), zone.name().to_string(), vital_signs, location, ensemble_result));
|
||||
}
|
||||
}
|
||||
|
||||
// Emit zone scan completed event
|
||||
let scan_duration = scan_start.elapsed();
|
||||
let _ = self.event_store.append(DomainEvent::Zone(
|
||||
domain::events::ZoneEvent::ZoneScanCompleted {
|
||||
zone_id: zone.id().clone(),
|
||||
detections_found: detections.len() as u32,
|
||||
scan_duration_ms: scan_duration.as_millis() as u64,
|
||||
timestamp: chrono::Utc::now(),
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Now process detections with mutable access
|
||||
let event = self.event.as_mut()
|
||||
.ok_or_else(|| MatError::Domain("No active disaster event".into()))?;
|
||||
|
||||
for (zone_id, _zone_name, vital_signs, location, _ensemble) in detections {
|
||||
let survivor = event.record_detection(zone_id.clone(), vital_signs.clone(), location.clone())?;
|
||||
|
||||
// Emit SurvivorDetected domain event
|
||||
let _ = self.event_store.append(DomainEvent::Detection(
|
||||
DetectionEvent::SurvivorDetected {
|
||||
survivor_id: survivor.id().clone(),
|
||||
zone_id,
|
||||
vital_signs,
|
||||
location,
|
||||
timestamp: chrono::Utc::now(),
|
||||
},
|
||||
));
|
||||
|
||||
// Generate and dispatch alert if needed
|
||||
if survivor.should_alert() {
|
||||
let alert = self.alert_dispatcher.generate_alert(survivor)?;
|
||||
let alert_id = alert.id().clone();
|
||||
let priority = alert.priority();
|
||||
let survivor_id = alert.survivor_id().clone();
|
||||
|
||||
// Emit AlertGenerated domain event
|
||||
let _ = self.event_store.append(DomainEvent::Alert(
|
||||
AlertEvent::AlertGenerated {
|
||||
alert_id,
|
||||
survivor_id,
|
||||
priority,
|
||||
timestamp: chrono::Utc::now(),
|
||||
},
|
||||
));
|
||||
|
||||
self.alert_dispatcher.dispatch(alert).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the current disaster event
|
||||
pub fn event(&self) -> Option<&DisasterEvent> {
|
||||
self.event.as_ref()
|
||||
}
|
||||
|
||||
/// Get all detected survivors
|
||||
pub fn survivors(&self) -> Vec<&Survivor> {
|
||||
self.event.as_ref()
|
||||
.map(|e| e.survivors())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Get survivors by triage status
|
||||
pub fn survivors_by_triage(&self, status: TriageStatus) -> Vec<&Survivor> {
|
||||
self.survivors()
|
||||
.into_iter()
|
||||
.filter(|s| s.triage_status() == &status)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Prelude module for convenient imports
|
||||
pub mod prelude {
|
||||
pub use crate::{
|
||||
DisasterConfig, DisasterConfigBuilder, DisasterResponse,
|
||||
MatError, Result,
|
||||
// Domain types
|
||||
Survivor, SurvivorId, DisasterEvent, DisasterType,
|
||||
ScanZone, ZoneBounds, TriageStatus,
|
||||
VitalSignsReading, BreathingPattern, HeartbeatSignature,
|
||||
Coordinates3D, Alert, Priority,
|
||||
// Event sourcing
|
||||
DomainEvent, EventStore, InMemoryEventStore,
|
||||
DetectionEvent, AlertEvent,
|
||||
// Detection
|
||||
DetectionPipeline, VitalSignsDetector,
|
||||
EnsembleClassifier, EnsembleConfig, EnsembleResult,
|
||||
// Localization
|
||||
LocalizationService,
|
||||
// Alerting
|
||||
AlertDispatcher,
|
||||
// ML types
|
||||
MlDetectionConfig, MlDetectionPipeline, MlDetectionResult,
|
||||
DebrisModel, MaterialType, DebrisClassification,
|
||||
VitalSignsClassifier, UncertaintyEstimate,
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_config_builder() {
|
||||
let config = DisasterConfig::builder()
|
||||
.disaster_type(DisasterType::Earthquake)
|
||||
.sensitivity(0.9)
|
||||
.confidence_threshold(0.6)
|
||||
.max_depth(10.0)
|
||||
.build();
|
||||
|
||||
assert!(matches!(config.disaster_type, DisasterType::Earthquake));
|
||||
assert!((config.sensitivity - 0.9).abs() < f64::EPSILON);
|
||||
assert!((config.confidence_threshold - 0.6).abs() < f64::EPSILON);
|
||||
assert!((config.max_depth - 10.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sensitivity_clamping() {
|
||||
let config = DisasterConfig::builder()
|
||||
.sensitivity(1.5)
|
||||
.build();
|
||||
|
||||
assert!((config.sensitivity - 1.0).abs() < f64::EPSILON);
|
||||
|
||||
let config = DisasterConfig::builder()
|
||||
.sensitivity(-0.5)
|
||||
.build();
|
||||
|
||||
assert!(config.sensitivity.abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_version() {
|
||||
assert!(!VERSION.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
//! Depth estimation through debris layers.
|
||||
|
||||
use crate::domain::{DebrisProfile, DepthEstimate, DebrisMaterial, MoistureLevel};
|
||||
|
||||
/// Configuration for depth estimation
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DepthEstimatorConfig {
|
||||
/// Maximum depth to estimate (meters)
|
||||
pub max_depth: f64,
|
||||
/// Minimum signal attenuation to consider (dB)
|
||||
pub min_attenuation: f64,
|
||||
/// WiFi frequency in GHz
|
||||
pub frequency_ghz: f64,
|
||||
/// Free space path loss at 1 meter (dB)
|
||||
pub free_space_loss_1m: f64,
|
||||
}
|
||||
|
||||
impl Default for DepthEstimatorConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_depth: 10.0,
|
||||
min_attenuation: 3.0,
|
||||
frequency_ghz: 5.8, // 5.8 GHz WiFi
|
||||
free_space_loss_1m: 47.0, // FSPL at 1m for 5.8 GHz
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Estimator for survivor depth through debris
|
||||
pub struct DepthEstimator {
|
||||
config: DepthEstimatorConfig,
|
||||
}
|
||||
|
||||
impl DepthEstimator {
|
||||
/// Create a new depth estimator
|
||||
pub fn new(config: DepthEstimatorConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
/// Create with default configuration
|
||||
pub fn with_defaults() -> Self {
|
||||
Self::new(DepthEstimatorConfig::default())
|
||||
}
|
||||
|
||||
/// Estimate depth from signal attenuation
|
||||
pub fn estimate_depth(
|
||||
&self,
|
||||
signal_attenuation: f64, // Total attenuation in dB
|
||||
distance_2d: f64, // Horizontal distance in meters
|
||||
debris_profile: &DebrisProfile,
|
||||
) -> Option<DepthEstimate> {
|
||||
if signal_attenuation < self.config.min_attenuation {
|
||||
// Very little attenuation - probably not buried
|
||||
return Some(DepthEstimate {
|
||||
depth: 0.0,
|
||||
uncertainty: 0.5,
|
||||
debris_profile: debris_profile.clone(),
|
||||
confidence: 0.9,
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate free space path loss for horizontal distance
|
||||
let fspl = self.free_space_path_loss(distance_2d);
|
||||
|
||||
// Debris attenuation = total - free space loss
|
||||
let debris_attenuation = (signal_attenuation - fspl).max(0.0);
|
||||
|
||||
// Get attenuation coefficient for debris type
|
||||
let attenuation_per_meter = debris_profile.attenuation_factor();
|
||||
|
||||
if attenuation_per_meter < 0.1 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Estimate depth
|
||||
let depth = debris_attenuation / attenuation_per_meter;
|
||||
|
||||
// Clamp to maximum
|
||||
if depth > self.config.max_depth {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Calculate uncertainty (increases with depth and material variability)
|
||||
let base_uncertainty = 0.3;
|
||||
let depth_uncertainty = depth * 0.15;
|
||||
let material_uncertainty = self.material_uncertainty(debris_profile);
|
||||
let uncertainty = base_uncertainty + depth_uncertainty + material_uncertainty;
|
||||
|
||||
// Calculate confidence (decreases with depth)
|
||||
let confidence = (1.0 - depth / self.config.max_depth).max(0.3);
|
||||
|
||||
Some(DepthEstimate {
|
||||
depth,
|
||||
uncertainty,
|
||||
debris_profile: debris_profile.clone(),
|
||||
confidence,
|
||||
})
|
||||
}
|
||||
|
||||
/// Estimate debris profile from signal characteristics
|
||||
pub fn estimate_debris_profile(
|
||||
&self,
|
||||
signal_variance: f64,
|
||||
signal_multipath: f64,
|
||||
moisture_indicator: f64,
|
||||
) -> DebrisProfile {
|
||||
// Estimate material based on signal characteristics
|
||||
let primary_material = if signal_variance > 0.5 {
|
||||
// High variance suggests heterogeneous material
|
||||
DebrisMaterial::Mixed
|
||||
} else if signal_multipath > 0.7 {
|
||||
// High multipath suggests reflective surfaces
|
||||
DebrisMaterial::HeavyConcrete
|
||||
} else if signal_multipath < 0.3 {
|
||||
// Low multipath suggests absorptive material
|
||||
DebrisMaterial::Soil
|
||||
} else {
|
||||
DebrisMaterial::LightConcrete
|
||||
};
|
||||
|
||||
// Estimate void fraction from multipath
|
||||
let void_fraction = signal_multipath.clamp(0.1, 0.5);
|
||||
|
||||
// Estimate moisture from signal characteristics
|
||||
let moisture_content = if moisture_indicator > 0.7 {
|
||||
MoistureLevel::Wet
|
||||
} else if moisture_indicator > 0.4 {
|
||||
MoistureLevel::Damp
|
||||
} else {
|
||||
MoistureLevel::Dry
|
||||
};
|
||||
|
||||
DebrisProfile {
|
||||
primary_material,
|
||||
void_fraction,
|
||||
moisture_content,
|
||||
metal_content: crate::domain::MetalContent::Low,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate free space path loss
|
||||
fn free_space_path_loss(&self, distance: f64) -> f64 {
|
||||
// FSPL = 20*log10(d) + 20*log10(f) + 20*log10(4*pi/c)
|
||||
// Simplified: FSPL(d) = FSPL(1m) + 20*log10(d)
|
||||
|
||||
if distance <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
self.config.free_space_loss_1m + 20.0 * distance.log10()
|
||||
}
|
||||
|
||||
/// Calculate uncertainty based on material properties
|
||||
fn material_uncertainty(&self, profile: &DebrisProfile) -> f64 {
|
||||
// Mixed materials have higher uncertainty
|
||||
let material_factor = match profile.primary_material {
|
||||
DebrisMaterial::Mixed => 0.4,
|
||||
DebrisMaterial::HeavyConcrete => 0.2,
|
||||
DebrisMaterial::LightConcrete => 0.2,
|
||||
DebrisMaterial::Soil => 0.3,
|
||||
DebrisMaterial::Wood => 0.15,
|
||||
DebrisMaterial::Snow => 0.1,
|
||||
DebrisMaterial::Metal => 0.5, // Very unpredictable
|
||||
};
|
||||
|
||||
// Moisture adds uncertainty
|
||||
let moisture_factor = match profile.moisture_content {
|
||||
MoistureLevel::Dry => 0.0,
|
||||
MoistureLevel::Damp => 0.1,
|
||||
MoistureLevel::Wet => 0.2,
|
||||
MoistureLevel::Saturated => 0.3,
|
||||
};
|
||||
|
||||
material_factor + moisture_factor
|
||||
}
|
||||
|
||||
/// Estimate depth from multiple signal paths
|
||||
pub fn estimate_from_multipath(
|
||||
&self,
|
||||
direct_path_attenuation: f64,
|
||||
reflected_paths: &[(f64, f64)], // (attenuation, delay)
|
||||
debris_profile: &DebrisProfile,
|
||||
) -> Option<DepthEstimate> {
|
||||
// Use path differences to estimate depth
|
||||
if reflected_paths.is_empty() {
|
||||
return self.estimate_depth(direct_path_attenuation, 0.0, debris_profile);
|
||||
}
|
||||
|
||||
// Average extra path length from reflections
|
||||
const SPEED_OF_LIGHT: f64 = 299_792_458.0;
|
||||
let avg_extra_path: f64 = reflected_paths
|
||||
.iter()
|
||||
.map(|(_, delay)| delay * SPEED_OF_LIGHT / 2.0) // Round trip
|
||||
.sum::<f64>() / reflected_paths.len() as f64;
|
||||
|
||||
// Extra path length is approximately related to depth
|
||||
// (reflections bounce off debris layers)
|
||||
let estimated_depth = avg_extra_path / 4.0; // Empirical factor
|
||||
|
||||
let attenuation_per_meter = debris_profile.attenuation_factor();
|
||||
let attenuation_based_depth = direct_path_attenuation / attenuation_per_meter;
|
||||
|
||||
// Combine estimates
|
||||
let depth = (estimated_depth + attenuation_based_depth) / 2.0;
|
||||
|
||||
if depth > self.config.max_depth {
|
||||
return None;
|
||||
}
|
||||
|
||||
let uncertainty = 0.5 + depth * 0.2;
|
||||
let confidence = (1.0 - depth / self.config.max_depth).max(0.3);
|
||||
|
||||
Some(DepthEstimate {
|
||||
depth,
|
||||
uncertainty,
|
||||
debris_profile: debris_profile.clone(),
|
||||
confidence,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn default_debris() -> DebrisProfile {
|
||||
DebrisProfile {
|
||||
primary_material: DebrisMaterial::Mixed,
|
||||
void_fraction: 0.25,
|
||||
moisture_content: MoistureLevel::Dry,
|
||||
metal_content: crate::domain::MetalContent::Low,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_low_attenuation_surface() {
|
||||
let estimator = DepthEstimator::with_defaults();
|
||||
|
||||
let result = estimator.estimate_depth(1.0, 5.0, &default_debris());
|
||||
assert!(result.is_some());
|
||||
|
||||
let estimate = result.unwrap();
|
||||
assert!(estimate.depth < 0.1);
|
||||
assert!(estimate.confidence > 0.8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_depth_increases_with_attenuation() {
|
||||
let estimator = DepthEstimator::with_defaults();
|
||||
let debris = default_debris();
|
||||
|
||||
let low = estimator.estimate_depth(10.0, 0.0, &debris);
|
||||
let high = estimator.estimate_depth(30.0, 0.0, &debris);
|
||||
|
||||
assert!(low.is_some() && high.is_some());
|
||||
assert!(high.unwrap().depth > low.unwrap().depth);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_confidence_decreases_with_depth() {
|
||||
let estimator = DepthEstimator::with_defaults();
|
||||
let debris = default_debris();
|
||||
|
||||
let shallow = estimator.estimate_depth(5.0, 0.0, &debris);
|
||||
let deep = estimator.estimate_depth(40.0, 0.0, &debris);
|
||||
|
||||
if let (Some(s), Some(d)) = (shallow, deep) {
|
||||
assert!(s.confidence > d.confidence);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_debris_profile_estimation() {
|
||||
let estimator = DepthEstimator::with_defaults();
|
||||
|
||||
// High variance = mixed materials
|
||||
let profile = estimator.estimate_debris_profile(0.7, 0.5, 0.3);
|
||||
assert!(matches!(profile.primary_material, DebrisMaterial::Mixed));
|
||||
|
||||
// High multipath = concrete
|
||||
let profile2 = estimator.estimate_debris_profile(0.2, 0.8, 0.3);
|
||||
assert!(matches!(profile2.primary_material, DebrisMaterial::HeavyConcrete));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_free_space_path_loss() {
|
||||
let estimator = DepthEstimator::with_defaults();
|
||||
|
||||
// FSPL increases with distance
|
||||
let fspl_1m = estimator.free_space_path_loss(1.0);
|
||||
let fspl_10m = estimator.free_space_path_loss(10.0);
|
||||
|
||||
assert!(fspl_10m > fspl_1m);
|
||||
// Should be about 20 dB difference (20*log10(10))
|
||||
assert!((fspl_10m - fspl_1m - 20.0).abs() < 1.0);
|
||||
}
|
||||
}
|
||||
@@ -1,385 +0,0 @@
|
||||
//! Position fusion combining multiple localization techniques.
|
||||
|
||||
use crate::domain::{
|
||||
Coordinates3D, LocationUncertainty, ScanZone, VitalSignsReading,
|
||||
DepthEstimate, DebrisProfile,
|
||||
};
|
||||
use super::{Triangulator, TriangulationConfig, DepthEstimator, DepthEstimatorConfig};
|
||||
|
||||
/// Service for survivor localization
|
||||
pub struct LocalizationService {
|
||||
triangulator: Triangulator,
|
||||
depth_estimator: DepthEstimator,
|
||||
position_fuser: PositionFuser,
|
||||
}
|
||||
|
||||
impl LocalizationService {
|
||||
/// Create a new localization service
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
triangulator: Triangulator::with_defaults(),
|
||||
depth_estimator: DepthEstimator::with_defaults(),
|
||||
position_fuser: PositionFuser::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with custom configurations
|
||||
pub fn with_config(
|
||||
triangulation_config: TriangulationConfig,
|
||||
depth_config: DepthEstimatorConfig,
|
||||
) -> Self {
|
||||
Self {
|
||||
triangulator: Triangulator::new(triangulation_config),
|
||||
depth_estimator: DepthEstimator::new(depth_config),
|
||||
position_fuser: PositionFuser::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Estimate survivor position
|
||||
pub fn estimate_position(
|
||||
&self,
|
||||
vitals: &VitalSignsReading,
|
||||
zone: &ScanZone,
|
||||
) -> Option<Coordinates3D> {
|
||||
// Get sensor positions
|
||||
let sensors = zone.sensor_positions();
|
||||
|
||||
if sensors.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Estimate 2D position from triangulation
|
||||
// In real implementation, RSSI values would come from actual measurements
|
||||
let rssi_values = self.simulate_rssi_measurements(sensors, vitals);
|
||||
let position_2d = self.triangulator.estimate_position(sensors, &rssi_values)?;
|
||||
|
||||
// Estimate depth
|
||||
let debris_profile = self.estimate_debris_profile(zone);
|
||||
let signal_attenuation = self.calculate_signal_attenuation(&rssi_values);
|
||||
let depth_estimate = self.depth_estimator.estimate_depth(
|
||||
signal_attenuation,
|
||||
0.0,
|
||||
&debris_profile,
|
||||
)?;
|
||||
|
||||
// Combine into 3D position
|
||||
let position_3d = Coordinates3D::new(
|
||||
position_2d.x,
|
||||
position_2d.y,
|
||||
-depth_estimate.depth, // Negative = below surface
|
||||
self.combine_uncertainties(&position_2d.uncertainty, &depth_estimate),
|
||||
);
|
||||
|
||||
Some(position_3d)
|
||||
}
|
||||
|
||||
/// Read RSSI measurements from sensors.
|
||||
///
|
||||
/// Returns empty when no real sensor hardware is connected.
|
||||
/// Real RSSI readings require ESP32 mesh (ADR-012) or Linux WiFi interface (ADR-013).
|
||||
/// Caller handles empty readings by returning None/default.
|
||||
fn simulate_rssi_measurements(
|
||||
&self,
|
||||
_sensors: &[crate::domain::SensorPosition],
|
||||
_vitals: &VitalSignsReading,
|
||||
) -> Vec<(String, f64)> {
|
||||
// No real sensor hardware connected - return empty.
|
||||
// Real RSSI readings require ESP32 mesh (ADR-012) or Linux WiFi interface (ADR-013).
|
||||
// Caller handles empty readings by returning None from estimate_position.
|
||||
tracing::warn!("No sensor hardware connected. Real RSSI readings require ESP32 mesh (ADR-012) or Linux WiFi interface (ADR-013).");
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Estimate debris profile for the zone
|
||||
fn estimate_debris_profile(&self, _zone: &ScanZone) -> DebrisProfile {
|
||||
// Would use zone metadata and signal analysis
|
||||
DebrisProfile::default()
|
||||
}
|
||||
|
||||
/// Calculate average signal attenuation
|
||||
fn calculate_signal_attenuation(&self, rssi_values: &[(String, f64)]) -> f64 {
|
||||
if rssi_values.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Reference RSSI at surface (typical open-air value)
|
||||
const REFERENCE_RSSI: f64 = -30.0;
|
||||
|
||||
let avg_rssi: f64 = rssi_values.iter().map(|(_, r)| r).sum::<f64>()
|
||||
/ rssi_values.len() as f64;
|
||||
|
||||
(REFERENCE_RSSI - avg_rssi).max(0.0)
|
||||
}
|
||||
|
||||
/// Combine horizontal and depth uncertainties
|
||||
fn combine_uncertainties(
|
||||
&self,
|
||||
horizontal: &LocationUncertainty,
|
||||
depth: &DepthEstimate,
|
||||
) -> LocationUncertainty {
|
||||
LocationUncertainty {
|
||||
horizontal_error: horizontal.horizontal_error,
|
||||
vertical_error: depth.uncertainty,
|
||||
confidence: (horizontal.confidence * depth.confidence).sqrt(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LocalizationService {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Fuses multiple position estimates
|
||||
pub struct PositionFuser {
|
||||
/// History of position estimates for smoothing
|
||||
history: parking_lot::RwLock<Vec<PositionEstimate>>,
|
||||
/// Maximum history size
|
||||
max_history: usize,
|
||||
}
|
||||
|
||||
/// A position estimate with metadata
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PositionEstimate {
|
||||
/// The position
|
||||
pub position: Coordinates3D,
|
||||
/// Timestamp
|
||||
pub timestamp: chrono::DateTime<chrono::Utc>,
|
||||
/// Source of estimate
|
||||
pub source: EstimateSource,
|
||||
/// Weight for fusion
|
||||
pub weight: f64,
|
||||
}
|
||||
|
||||
/// Source of a position estimate
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum EstimateSource {
|
||||
/// From RSSI-based triangulation
|
||||
RssiTriangulation,
|
||||
/// From time-of-arrival
|
||||
TimeOfArrival,
|
||||
/// From CSI fingerprinting
|
||||
CsiFingerprint,
|
||||
/// From angle of arrival
|
||||
AngleOfArrival,
|
||||
/// From depth estimation
|
||||
DepthEstimation,
|
||||
/// Fused from multiple sources
|
||||
Fused,
|
||||
}
|
||||
|
||||
impl PositionFuser {
|
||||
/// Create a new position fuser
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
history: parking_lot::RwLock::new(Vec::new()),
|
||||
max_history: 20,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a position estimate
|
||||
pub fn add_estimate(&self, estimate: PositionEstimate) {
|
||||
let mut history = self.history.write();
|
||||
history.push(estimate);
|
||||
|
||||
// Keep only recent history
|
||||
if history.len() > self.max_history {
|
||||
history.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fuse multiple position estimates into one
|
||||
pub fn fuse(&self, estimates: &[PositionEstimate]) -> Option<Coordinates3D> {
|
||||
if estimates.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if estimates.len() == 1 {
|
||||
return Some(estimates[0].position.clone());
|
||||
}
|
||||
|
||||
// Weighted average based on uncertainty and source confidence
|
||||
let mut total_weight = 0.0;
|
||||
let mut sum_x = 0.0;
|
||||
let mut sum_y = 0.0;
|
||||
let mut sum_z = 0.0;
|
||||
|
||||
for estimate in estimates {
|
||||
let weight = self.calculate_weight(estimate);
|
||||
total_weight += weight;
|
||||
sum_x += estimate.position.x * weight;
|
||||
sum_y += estimate.position.y * weight;
|
||||
sum_z += estimate.position.z * weight;
|
||||
}
|
||||
|
||||
if total_weight == 0.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let fused_x = sum_x / total_weight;
|
||||
let fused_y = sum_y / total_weight;
|
||||
let fused_z = sum_z / total_weight;
|
||||
|
||||
// Calculate fused uncertainty (reduced due to multiple estimates)
|
||||
let fused_uncertainty = self.calculate_fused_uncertainty(estimates);
|
||||
|
||||
Some(Coordinates3D::new(
|
||||
fused_x,
|
||||
fused_y,
|
||||
fused_z,
|
||||
fused_uncertainty,
|
||||
))
|
||||
}
|
||||
|
||||
/// Fuse with temporal smoothing
|
||||
pub fn fuse_with_history(&self, current: &PositionEstimate) -> Option<Coordinates3D> {
|
||||
// Add current to history
|
||||
self.add_estimate(current.clone());
|
||||
|
||||
let history = self.history.read();
|
||||
|
||||
// Use exponentially weighted moving average
|
||||
let alpha: f64 = 0.3; // Smoothing factor
|
||||
let mut smoothed = current.position.clone();
|
||||
|
||||
for (i, estimate) in history.iter().rev().enumerate().skip(1) {
|
||||
let weight = alpha * (1.0_f64 - alpha).powi(i as i32);
|
||||
smoothed.x = smoothed.x * (1.0 - weight) + estimate.position.x * weight;
|
||||
smoothed.y = smoothed.y * (1.0 - weight) + estimate.position.y * weight;
|
||||
smoothed.z = smoothed.z * (1.0 - weight) + estimate.position.z * weight;
|
||||
}
|
||||
|
||||
Some(smoothed)
|
||||
}
|
||||
|
||||
/// Calculate weight for an estimate
|
||||
fn calculate_weight(&self, estimate: &PositionEstimate) -> f64 {
|
||||
// Base weight from source reliability
|
||||
let source_weight = match estimate.source {
|
||||
EstimateSource::TimeOfArrival => 1.0,
|
||||
EstimateSource::AngleOfArrival => 0.9,
|
||||
EstimateSource::CsiFingerprint => 0.8,
|
||||
EstimateSource::RssiTriangulation => 0.7,
|
||||
EstimateSource::DepthEstimation => 0.6,
|
||||
EstimateSource::Fused => 1.0,
|
||||
};
|
||||
|
||||
// Adjust by uncertainty (lower uncertainty = higher weight)
|
||||
let uncertainty_factor = 1.0 / (1.0 + estimate.position.uncertainty.horizontal_error);
|
||||
|
||||
// User-provided weight
|
||||
let user_weight = estimate.weight;
|
||||
|
||||
source_weight * uncertainty_factor * user_weight
|
||||
}
|
||||
|
||||
/// Calculate uncertainty after fusing multiple estimates
|
||||
fn calculate_fused_uncertainty(&self, estimates: &[PositionEstimate]) -> LocationUncertainty {
|
||||
if estimates.is_empty() {
|
||||
return LocationUncertainty::default();
|
||||
}
|
||||
|
||||
// Combined uncertainty is reduced with multiple estimates
|
||||
let n = estimates.len() as f64;
|
||||
|
||||
let avg_h_error: f64 = estimates.iter()
|
||||
.map(|e| e.position.uncertainty.horizontal_error)
|
||||
.sum::<f64>() / n;
|
||||
|
||||
let avg_v_error: f64 = estimates.iter()
|
||||
.map(|e| e.position.uncertainty.vertical_error)
|
||||
.sum::<f64>() / n;
|
||||
|
||||
// Uncertainty reduction factor (more estimates = more confidence)
|
||||
let reduction = (1.0 / n.sqrt()).max(0.5);
|
||||
|
||||
LocationUncertainty {
|
||||
horizontal_error: avg_h_error * reduction,
|
||||
vertical_error: avg_v_error * reduction,
|
||||
confidence: (0.95 * (1.0 + (n - 1.0) * 0.02)).min(0.99),
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear history
|
||||
pub fn clear_history(&self) {
|
||||
self.history.write().clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PositionFuser {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Utc;
|
||||
|
||||
fn create_test_estimate(x: f64, y: f64, z: f64) -> PositionEstimate {
|
||||
PositionEstimate {
|
||||
position: Coordinates3D::with_default_uncertainty(x, y, z),
|
||||
timestamp: Utc::now(),
|
||||
source: EstimateSource::RssiTriangulation,
|
||||
weight: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_estimate_fusion() {
|
||||
let fuser = PositionFuser::new();
|
||||
let estimate = create_test_estimate(5.0, 10.0, -2.0);
|
||||
|
||||
let result = fuser.fuse(&[estimate]);
|
||||
assert!(result.is_some());
|
||||
|
||||
let pos = result.unwrap();
|
||||
assert!((pos.x - 5.0).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_estimate_fusion() {
|
||||
let fuser = PositionFuser::new();
|
||||
|
||||
let estimates = vec![
|
||||
create_test_estimate(4.0, 9.0, -1.5),
|
||||
create_test_estimate(6.0, 11.0, -2.5),
|
||||
];
|
||||
|
||||
let result = fuser.fuse(&estimates);
|
||||
assert!(result.is_some());
|
||||
|
||||
let pos = result.unwrap();
|
||||
// Should be roughly in between
|
||||
assert!(pos.x > 4.0 && pos.x < 6.0);
|
||||
assert!(pos.y > 9.0 && pos.y < 11.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fused_uncertainty_reduction() {
|
||||
let fuser = PositionFuser::new();
|
||||
|
||||
let estimates = vec![
|
||||
create_test_estimate(5.0, 10.0, -2.0),
|
||||
create_test_estimate(5.1, 10.1, -2.1),
|
||||
create_test_estimate(4.9, 9.9, -1.9),
|
||||
];
|
||||
|
||||
let single_uncertainty = estimates[0].position.uncertainty.horizontal_error;
|
||||
let fused_uncertainty = fuser.calculate_fused_uncertainty(&estimates);
|
||||
|
||||
// Fused should have lower uncertainty
|
||||
assert!(fused_uncertainty.horizontal_error < single_uncertainty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_localization_service_creation() {
|
||||
let service = LocalizationService::new();
|
||||
// Just verify it creates without panic
|
||||
assert!(true);
|
||||
drop(service);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
//! Localization module for survivor position estimation.
|
||||
//!
|
||||
//! This module provides:
|
||||
//! - Triangulation from multiple access points
|
||||
//! - Depth estimation through debris
|
||||
//! - Position fusion combining multiple techniques
|
||||
|
||||
mod triangulation;
|
||||
mod depth;
|
||||
mod fusion;
|
||||
|
||||
pub use triangulation::{Triangulator, TriangulationConfig};
|
||||
pub use depth::{DepthEstimator, DepthEstimatorConfig};
|
||||
pub use fusion::{PositionFuser, LocalizationService};
|
||||
@@ -1,377 +0,0 @@
|
||||
//! Triangulation for 2D/3D position estimation from multiple sensors.
|
||||
|
||||
use crate::domain::{Coordinates3D, LocationUncertainty, SensorPosition};
|
||||
|
||||
/// Configuration for triangulation
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TriangulationConfig {
|
||||
/// Minimum number of sensors required
|
||||
pub min_sensors: usize,
|
||||
/// Maximum position uncertainty to accept (meters)
|
||||
pub max_uncertainty: f64,
|
||||
/// Path loss exponent for distance estimation
|
||||
pub path_loss_exponent: f64,
|
||||
/// Reference distance for path loss model (meters)
|
||||
pub reference_distance: f64,
|
||||
/// Reference RSSI at reference distance (dBm)
|
||||
pub reference_rssi: f64,
|
||||
/// Use weighted least squares
|
||||
pub weighted: bool,
|
||||
}
|
||||
|
||||
impl Default for TriangulationConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
min_sensors: 3,
|
||||
max_uncertainty: 5.0,
|
||||
path_loss_exponent: 3.0, // Indoor with obstacles
|
||||
reference_distance: 1.0,
|
||||
reference_rssi: -30.0,
|
||||
weighted: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a distance estimation
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DistanceEstimate {
|
||||
/// Sensor ID
|
||||
pub sensor_id: String,
|
||||
/// Estimated distance in meters
|
||||
pub distance: f64,
|
||||
/// Estimation confidence
|
||||
pub confidence: f64,
|
||||
}
|
||||
|
||||
/// Triangulator for position estimation
|
||||
pub struct Triangulator {
|
||||
config: TriangulationConfig,
|
||||
}
|
||||
|
||||
impl Triangulator {
|
||||
/// Create a new triangulator
|
||||
pub fn new(config: TriangulationConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
/// Create with default configuration
|
||||
pub fn with_defaults() -> Self {
|
||||
Self::new(TriangulationConfig::default())
|
||||
}
|
||||
|
||||
/// Estimate position from RSSI measurements
|
||||
pub fn estimate_position(
|
||||
&self,
|
||||
sensors: &[SensorPosition],
|
||||
rssi_values: &[(String, f64)], // (sensor_id, rssi)
|
||||
) -> Option<Coordinates3D> {
|
||||
// Get distance estimates from RSSI
|
||||
let distances: Vec<(SensorPosition, f64)> = rssi_values
|
||||
.iter()
|
||||
.filter_map(|(id, rssi)| {
|
||||
let sensor = sensors.iter().find(|s| &s.id == id)?;
|
||||
if !sensor.is_operational {
|
||||
return None;
|
||||
}
|
||||
let distance = self.rssi_to_distance(*rssi);
|
||||
Some((sensor.clone(), distance))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if distances.len() < self.config.min_sensors {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Perform trilateration
|
||||
self.trilaterate(&distances)
|
||||
}
|
||||
|
||||
/// Estimate position from Time of Arrival measurements
|
||||
pub fn estimate_from_toa(
|
||||
&self,
|
||||
sensors: &[SensorPosition],
|
||||
toa_values: &[(String, f64)], // (sensor_id, time_of_arrival_ns)
|
||||
) -> Option<Coordinates3D> {
|
||||
const SPEED_OF_LIGHT: f64 = 299_792_458.0; // m/s
|
||||
|
||||
let distances: Vec<(SensorPosition, f64)> = toa_values
|
||||
.iter()
|
||||
.filter_map(|(id, toa)| {
|
||||
let sensor = sensors.iter().find(|s| &s.id == id)?;
|
||||
if !sensor.is_operational {
|
||||
return None;
|
||||
}
|
||||
// Convert nanoseconds to distance
|
||||
let distance = (*toa * 1e-9) * SPEED_OF_LIGHT / 2.0; // Round trip
|
||||
Some((sensor.clone(), distance))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if distances.len() < self.config.min_sensors {
|
||||
return None;
|
||||
}
|
||||
|
||||
self.trilaterate(&distances)
|
||||
}
|
||||
|
||||
/// Convert RSSI to distance using path loss model
|
||||
fn rssi_to_distance(&self, rssi: f64) -> f64 {
|
||||
// Log-distance path loss model:
|
||||
// RSSI = RSSI_0 - 10 * n * log10(d / d_0)
|
||||
// Solving for d:
|
||||
// d = d_0 * 10^((RSSI_0 - RSSI) / (10 * n))
|
||||
|
||||
let exponent = (self.config.reference_rssi - rssi)
|
||||
/ (10.0 * self.config.path_loss_exponent);
|
||||
|
||||
self.config.reference_distance * 10.0_f64.powf(exponent)
|
||||
}
|
||||
|
||||
/// Perform trilateration using least squares
|
||||
fn trilaterate(&self, distances: &[(SensorPosition, f64)]) -> Option<Coordinates3D> {
|
||||
if distances.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Use linearized least squares approach
|
||||
// Reference: https://en.wikipedia.org/wiki/Trilateration
|
||||
|
||||
// Use first sensor as reference
|
||||
let (ref_sensor, ref_dist) = &distances[0];
|
||||
let x1 = ref_sensor.x;
|
||||
let y1 = ref_sensor.y;
|
||||
let r1 = *ref_dist;
|
||||
|
||||
// Build system of linear equations: A * [x, y]^T = b
|
||||
let n = distances.len() - 1;
|
||||
let mut a_matrix = vec![vec![0.0; 2]; n];
|
||||
let mut b_vector = vec![0.0; n];
|
||||
|
||||
for (i, (sensor, dist)) in distances.iter().skip(1).enumerate() {
|
||||
let xi = sensor.x;
|
||||
let yi = sensor.y;
|
||||
let ri = *dist;
|
||||
|
||||
// Linearized equation from difference of squared distances
|
||||
a_matrix[i][0] = 2.0 * (xi - x1);
|
||||
a_matrix[i][1] = 2.0 * (yi - y1);
|
||||
b_vector[i] = r1 * r1 - ri * ri - x1 * x1 + xi * xi - y1 * y1 + yi * yi;
|
||||
}
|
||||
|
||||
// Solve using least squares: (A^T * A)^-1 * A^T * b
|
||||
let solution = self.solve_least_squares(&a_matrix, &b_vector)?;
|
||||
|
||||
// Calculate uncertainty from residuals
|
||||
let uncertainty = self.calculate_uncertainty(&solution, distances);
|
||||
|
||||
if uncertainty.horizontal_error > self.config.max_uncertainty {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Coordinates3D::new(
|
||||
solution[0],
|
||||
solution[1],
|
||||
0.0, // Z estimated separately
|
||||
uncertainty,
|
||||
))
|
||||
}
|
||||
|
||||
/// Solve linear system using least squares
|
||||
fn solve_least_squares(&self, a: &[Vec<f64>], b: &[f64]) -> Option<Vec<f64>> {
|
||||
let n = a.len();
|
||||
if n < 2 || a[0].len() != 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Calculate A^T * A
|
||||
let mut ata = vec![vec![0.0; 2]; 2];
|
||||
for i in 0..2 {
|
||||
for j in 0..2 {
|
||||
for k in 0..n {
|
||||
ata[i][j] += a[k][i] * a[k][j];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate A^T * b
|
||||
let mut atb = vec![0.0; 2];
|
||||
for i in 0..2 {
|
||||
for k in 0..n {
|
||||
atb[i] += a[k][i] * b[k];
|
||||
}
|
||||
}
|
||||
|
||||
// Solve 2x2 system using Cramer's rule
|
||||
let det = ata[0][0] * ata[1][1] - ata[0][1] * ata[1][0];
|
||||
if det.abs() < 1e-10 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let x = (atb[0] * ata[1][1] - atb[1] * ata[0][1]) / det;
|
||||
let y = (ata[0][0] * atb[1] - ata[1][0] * atb[0]) / det;
|
||||
|
||||
Some(vec![x, y])
|
||||
}
|
||||
|
||||
/// Calculate position uncertainty from residuals
|
||||
fn calculate_uncertainty(
|
||||
&self,
|
||||
position: &[f64],
|
||||
distances: &[(SensorPosition, f64)],
|
||||
) -> LocationUncertainty {
|
||||
// Calculate root mean square error
|
||||
let mut sum_sq_error = 0.0;
|
||||
|
||||
for (sensor, measured_dist) in distances {
|
||||
let dx = position[0] - sensor.x;
|
||||
let dy = position[1] - sensor.y;
|
||||
let estimated_dist = (dx * dx + dy * dy).sqrt();
|
||||
let error = measured_dist - estimated_dist;
|
||||
sum_sq_error += error * error;
|
||||
}
|
||||
|
||||
let rmse = (sum_sq_error / distances.len() as f64).sqrt();
|
||||
|
||||
// GDOP (Geometric Dilution of Precision) approximation
|
||||
let gdop = self.estimate_gdop(position, distances);
|
||||
|
||||
LocationUncertainty {
|
||||
horizontal_error: rmse * gdop,
|
||||
vertical_error: rmse * gdop * 1.5, // Vertical typically less accurate
|
||||
confidence: 0.95,
|
||||
}
|
||||
}
|
||||
|
||||
/// Estimate Geometric Dilution of Precision
|
||||
fn estimate_gdop(&self, position: &[f64], distances: &[(SensorPosition, f64)]) -> f64 {
|
||||
// Simplified GDOP based on sensor geometry
|
||||
let mut sum_angle = 0.0;
|
||||
let n = distances.len();
|
||||
|
||||
for i in 0..n {
|
||||
for j in (i + 1)..n {
|
||||
let dx1 = distances[i].0.x - position[0];
|
||||
let dy1 = distances[i].0.y - position[1];
|
||||
let dx2 = distances[j].0.x - position[0];
|
||||
let dy2 = distances[j].0.y - position[1];
|
||||
|
||||
let dot = dx1 * dx2 + dy1 * dy2;
|
||||
let mag1 = (dx1 * dx1 + dy1 * dy1).sqrt();
|
||||
let mag2 = (dx2 * dx2 + dy2 * dy2).sqrt();
|
||||
|
||||
if mag1 > 0.0 && mag2 > 0.0 {
|
||||
let cos_angle = (dot / (mag1 * mag2)).clamp(-1.0, 1.0);
|
||||
let angle = cos_angle.acos();
|
||||
sum_angle += angle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Average angle between sensor pairs
|
||||
let num_pairs = (n * (n - 1)) as f64 / 2.0;
|
||||
let avg_angle = if num_pairs > 0.0 {
|
||||
sum_angle / num_pairs
|
||||
} else {
|
||||
std::f64::consts::PI / 4.0
|
||||
};
|
||||
|
||||
// GDOP is better when sensors are spread out (angle closer to 90 degrees)
|
||||
// GDOP gets worse as sensors are collinear
|
||||
let optimal_angle = std::f64::consts::PI / 2.0;
|
||||
let angle_factor = (avg_angle / optimal_angle - 1.0).abs() + 1.0;
|
||||
|
||||
angle_factor.max(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::domain::SensorType;
|
||||
|
||||
fn create_test_sensors() -> Vec<SensorPosition> {
|
||||
vec![
|
||||
SensorPosition {
|
||||
id: "s1".to_string(),
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
z: 1.5,
|
||||
sensor_type: SensorType::Transceiver,
|
||||
is_operational: true,
|
||||
},
|
||||
SensorPosition {
|
||||
id: "s2".to_string(),
|
||||
x: 10.0,
|
||||
y: 0.0,
|
||||
z: 1.5,
|
||||
sensor_type: SensorType::Transceiver,
|
||||
is_operational: true,
|
||||
},
|
||||
SensorPosition {
|
||||
id: "s3".to_string(),
|
||||
x: 5.0,
|
||||
y: 10.0,
|
||||
z: 1.5,
|
||||
sensor_type: SensorType::Transceiver,
|
||||
is_operational: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rssi_to_distance() {
|
||||
let triangulator = Triangulator::with_defaults();
|
||||
|
||||
// At reference distance, RSSI should equal reference RSSI
|
||||
let distance = triangulator.rssi_to_distance(-30.0);
|
||||
assert!((distance - 1.0).abs() < 0.1);
|
||||
|
||||
// Weaker signal = further distance
|
||||
let distance2 = triangulator.rssi_to_distance(-60.0);
|
||||
assert!(distance2 > distance);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trilateration() {
|
||||
let triangulator = Triangulator::with_defaults();
|
||||
let sensors = create_test_sensors();
|
||||
|
||||
// Target at (5, 4) - calculate distances
|
||||
let target: (f64, f64) = (5.0, 4.0);
|
||||
let distances: Vec<(&str, f64)> = vec![
|
||||
("s1", ((target.0 - 0.0_f64).powi(2) + (target.1 - 0.0_f64).powi(2)).sqrt()),
|
||||
("s2", ((target.0 - 10.0_f64).powi(2) + (target.1 - 0.0_f64).powi(2)).sqrt()),
|
||||
("s3", ((target.0 - 5.0_f64).powi(2) + (target.1 - 10.0_f64).powi(2)).sqrt()),
|
||||
];
|
||||
|
||||
let dist_vec: Vec<(SensorPosition, f64)> = distances
|
||||
.iter()
|
||||
.filter_map(|(id, d)| {
|
||||
let sensor = sensors.iter().find(|s| s.id == *id)?;
|
||||
Some((sensor.clone(), *d))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let result = triangulator.trilaterate(&dist_vec);
|
||||
assert!(result.is_some());
|
||||
|
||||
let pos = result.unwrap();
|
||||
assert!((pos.x - target.0).abs() < 0.5);
|
||||
assert!((pos.y - target.1).abs() < 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insufficient_sensors() {
|
||||
let triangulator = Triangulator::with_defaults();
|
||||
let sensors = create_test_sensors();
|
||||
|
||||
// Only 2 distance measurements
|
||||
let rssi_values = vec![
|
||||
("s1".to_string(), -40.0),
|
||||
("s2".to_string(), -45.0),
|
||||
];
|
||||
|
||||
let result = triangulator.estimate_position(&sensors, &rssi_values);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,765 +0,0 @@
|
||||
//! ONNX-based debris penetration model for material classification and depth prediction.
|
||||
//!
|
||||
//! This module provides neural network models for analyzing debris characteristics
|
||||
//! from WiFi CSI signals. Key capabilities include:
|
||||
//!
|
||||
//! - Material type classification (concrete, wood, metal, etc.)
|
||||
//! - Signal attenuation prediction based on material properties
|
||||
//! - Penetration depth estimation with uncertainty quantification
|
||||
//!
|
||||
//! ## Model Architecture
|
||||
//!
|
||||
//! The debris model uses a multi-head architecture:
|
||||
//! - Shared feature encoder (CNN-based)
|
||||
//! - Material classification head (softmax output)
|
||||
//! - Attenuation regression head (linear output)
|
||||
//! - Depth estimation head with uncertainty (mean + variance output)
|
||||
|
||||
use super::{DebrisFeatures, DepthEstimate, MlError, MlResult};
|
||||
use ndarray::{Array1, Array2, Array4, s};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use parking_lot::RwLock;
|
||||
use thiserror::Error;
|
||||
use tracing::{debug, info, instrument, warn};
|
||||
|
||||
#[cfg(feature = "onnx")]
|
||||
use wifi_densepose_nn::{OnnxBackend, OnnxSession, InferenceOptions, Tensor, TensorShape};
|
||||
|
||||
/// Errors specific to debris model operations
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DebrisModelError {
|
||||
/// Model file not found
|
||||
#[error("Model file not found: {0}")]
|
||||
FileNotFound(String),
|
||||
|
||||
/// Invalid model format
|
||||
#[error("Invalid model format: {0}")]
|
||||
InvalidFormat(String),
|
||||
|
||||
/// Inference error
|
||||
#[error("Inference failed: {0}")]
|
||||
InferenceFailed(String),
|
||||
|
||||
/// Feature extraction error
|
||||
#[error("Feature extraction failed: {0}")]
|
||||
FeatureExtractionFailed(String),
|
||||
}
|
||||
|
||||
/// Types of materials that can be detected in debris
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum MaterialType {
|
||||
/// Reinforced concrete (high attenuation)
|
||||
Concrete,
|
||||
/// Wood/timber (moderate attenuation)
|
||||
Wood,
|
||||
/// Metal/steel (very high attenuation, reflective)
|
||||
Metal,
|
||||
/// Glass (low attenuation)
|
||||
Glass,
|
||||
/// Brick/masonry (high attenuation)
|
||||
Brick,
|
||||
/// Drywall/plasterboard (low attenuation)
|
||||
Drywall,
|
||||
/// Mixed/composite materials
|
||||
Mixed,
|
||||
/// Unknown material type
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl MaterialType {
|
||||
/// Get typical attenuation coefficient (dB/m)
|
||||
pub fn typical_attenuation(&self) -> f32 {
|
||||
match self {
|
||||
MaterialType::Concrete => 25.0,
|
||||
MaterialType::Wood => 8.0,
|
||||
MaterialType::Metal => 50.0,
|
||||
MaterialType::Glass => 3.0,
|
||||
MaterialType::Brick => 18.0,
|
||||
MaterialType::Drywall => 4.0,
|
||||
MaterialType::Mixed => 15.0,
|
||||
MaterialType::Unknown => 12.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get typical delay spread (nanoseconds)
|
||||
pub fn typical_delay_spread(&self) -> f32 {
|
||||
match self {
|
||||
MaterialType::Concrete => 150.0,
|
||||
MaterialType::Wood => 50.0,
|
||||
MaterialType::Metal => 200.0,
|
||||
MaterialType::Glass => 20.0,
|
||||
MaterialType::Brick => 100.0,
|
||||
MaterialType::Drywall => 30.0,
|
||||
MaterialType::Mixed => 80.0,
|
||||
MaterialType::Unknown => 60.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// From class index
|
||||
pub fn from_index(index: usize) -> Self {
|
||||
match index {
|
||||
0 => MaterialType::Concrete,
|
||||
1 => MaterialType::Wood,
|
||||
2 => MaterialType::Metal,
|
||||
3 => MaterialType::Glass,
|
||||
4 => MaterialType::Brick,
|
||||
5 => MaterialType::Drywall,
|
||||
6 => MaterialType::Mixed,
|
||||
_ => MaterialType::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// To class index
|
||||
pub fn to_index(&self) -> usize {
|
||||
match self {
|
||||
MaterialType::Concrete => 0,
|
||||
MaterialType::Wood => 1,
|
||||
MaterialType::Metal => 2,
|
||||
MaterialType::Glass => 3,
|
||||
MaterialType::Brick => 4,
|
||||
MaterialType::Drywall => 5,
|
||||
MaterialType::Mixed => 6,
|
||||
MaterialType::Unknown => 7,
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of material classes
|
||||
pub const NUM_CLASSES: usize = 8;
|
||||
}
|
||||
|
||||
impl std::fmt::Display for MaterialType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
MaterialType::Concrete => write!(f, "Concrete"),
|
||||
MaterialType::Wood => write!(f, "Wood"),
|
||||
MaterialType::Metal => write!(f, "Metal"),
|
||||
MaterialType::Glass => write!(f, "Glass"),
|
||||
MaterialType::Brick => write!(f, "Brick"),
|
||||
MaterialType::Drywall => write!(f, "Drywall"),
|
||||
MaterialType::Mixed => write!(f, "Mixed"),
|
||||
MaterialType::Unknown => write!(f, "Unknown"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of debris material classification
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DebrisClassification {
|
||||
/// Primary material type detected
|
||||
pub material_type: MaterialType,
|
||||
/// Confidence score for the classification (0.0-1.0)
|
||||
pub confidence: f32,
|
||||
/// Per-class probabilities
|
||||
pub class_probabilities: Vec<f32>,
|
||||
/// Estimated layer count
|
||||
pub estimated_layers: u8,
|
||||
/// Whether multiple materials detected
|
||||
pub is_composite: bool,
|
||||
}
|
||||
|
||||
impl DebrisClassification {
|
||||
/// Create a new debris classification
|
||||
pub fn new(probabilities: Vec<f32>) -> Self {
|
||||
let (max_idx, &max_prob) = probabilities.iter()
|
||||
.enumerate()
|
||||
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
|
||||
.unwrap_or((7, &0.0));
|
||||
|
||||
// Check for composite materials (multiple high probabilities)
|
||||
let high_prob_count = probabilities.iter()
|
||||
.filter(|&&p| p > 0.2)
|
||||
.count();
|
||||
|
||||
let is_composite = high_prob_count > 1 && max_prob < 0.7;
|
||||
let material_type = if is_composite {
|
||||
MaterialType::Mixed
|
||||
} else {
|
||||
MaterialType::from_index(max_idx)
|
||||
};
|
||||
|
||||
// Estimate layer count from delay spread characteristics
|
||||
let estimated_layers = Self::estimate_layers(&probabilities);
|
||||
|
||||
Self {
|
||||
material_type,
|
||||
confidence: max_prob,
|
||||
class_probabilities: probabilities,
|
||||
estimated_layers,
|
||||
is_composite,
|
||||
}
|
||||
}
|
||||
|
||||
/// Estimate number of debris layers from probability distribution
|
||||
fn estimate_layers(probabilities: &[f32]) -> u8 {
|
||||
// More uniform distribution suggests more layers
|
||||
let entropy: f32 = probabilities.iter()
|
||||
.filter(|&&p| p > 0.01)
|
||||
.map(|&p| -p * p.ln())
|
||||
.sum();
|
||||
|
||||
let max_entropy = (probabilities.len() as f32).ln();
|
||||
let normalized_entropy = entropy / max_entropy;
|
||||
|
||||
// Map entropy to layer count (1-5)
|
||||
(1.0 + normalized_entropy * 4.0).round() as u8
|
||||
}
|
||||
|
||||
/// Get secondary material if composite
|
||||
pub fn secondary_material(&self) -> Option<MaterialType> {
|
||||
if !self.is_composite {
|
||||
return None;
|
||||
}
|
||||
|
||||
let primary_idx = self.material_type.to_index();
|
||||
self.class_probabilities.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| *i != primary_idx)
|
||||
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
|
||||
.map(|(i, _)| MaterialType::from_index(i))
|
||||
}
|
||||
}
|
||||
|
||||
/// Signal attenuation prediction result
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AttenuationPrediction {
|
||||
/// Predicted attenuation in dB
|
||||
pub attenuation_db: f32,
|
||||
/// Attenuation per meter (dB/m)
|
||||
pub attenuation_per_meter: f32,
|
||||
/// Uncertainty in the prediction
|
||||
pub uncertainty_db: f32,
|
||||
/// Frequency-dependent attenuation profile
|
||||
pub frequency_profile: Vec<f32>,
|
||||
/// Confidence in the prediction
|
||||
pub confidence: f32,
|
||||
}
|
||||
|
||||
impl AttenuationPrediction {
|
||||
/// Create new attenuation prediction
|
||||
pub fn new(attenuation: f32, depth: f32, uncertainty: f32) -> Self {
|
||||
let attenuation_per_meter = if depth > 0.0 {
|
||||
attenuation / depth
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
Self {
|
||||
attenuation_db: attenuation,
|
||||
attenuation_per_meter,
|
||||
uncertainty_db: uncertainty,
|
||||
frequency_profile: vec![],
|
||||
confidence: (1.0 - uncertainty / attenuation.abs().max(1.0)).max(0.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Predict signal at given depth
|
||||
pub fn predict_signal_at_depth(&self, depth_m: f32) -> f32 {
|
||||
-self.attenuation_per_meter * depth_m
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for debris model
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DebrisModelConfig {
|
||||
/// Use GPU for inference
|
||||
pub use_gpu: bool,
|
||||
/// Number of inference threads
|
||||
pub num_threads: usize,
|
||||
/// Minimum confidence threshold
|
||||
pub confidence_threshold: f32,
|
||||
}
|
||||
|
||||
impl Default for DebrisModelConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
use_gpu: false,
|
||||
num_threads: 4,
|
||||
confidence_threshold: 0.5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Feature extractor for debris classification
|
||||
pub struct DebrisFeatureExtractor {
|
||||
/// Number of subcarriers to analyze
|
||||
num_subcarriers: usize,
|
||||
/// Window size for temporal analysis
|
||||
window_size: usize,
|
||||
/// Whether to use advanced features
|
||||
use_advanced_features: bool,
|
||||
}
|
||||
|
||||
impl Default for DebrisFeatureExtractor {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
num_subcarriers: 64,
|
||||
window_size: 100,
|
||||
use_advanced_features: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DebrisFeatureExtractor {
|
||||
/// Create new feature extractor
|
||||
pub fn new(num_subcarriers: usize, window_size: usize) -> Self {
|
||||
Self {
|
||||
num_subcarriers,
|
||||
window_size,
|
||||
use_advanced_features: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract features from debris features for model input
|
||||
pub fn extract(&self, features: &DebrisFeatures) -> MlResult<Array2<f32>> {
|
||||
let feature_vector = features.to_feature_vector();
|
||||
|
||||
// Reshape to 2D for model input (batch_size=1, features)
|
||||
let arr = Array2::from_shape_vec(
|
||||
(1, feature_vector.len()),
|
||||
feature_vector,
|
||||
).map_err(|e| MlError::FeatureExtraction(e.to_string()))?;
|
||||
|
||||
Ok(arr)
|
||||
}
|
||||
|
||||
/// Extract spatial-temporal features for CNN input
|
||||
pub fn extract_spatial_temporal(&self, features: &DebrisFeatures) -> MlResult<Array4<f32>> {
|
||||
let amp_len = features.amplitude_attenuation.len().min(self.num_subcarriers);
|
||||
let phase_len = features.phase_shifts.len().min(self.num_subcarriers);
|
||||
|
||||
// Create 4D tensor: [batch, channels, height, width]
|
||||
// channels: amplitude, phase
|
||||
// height: subcarriers
|
||||
// width: 1 (or temporal windows if available)
|
||||
let mut tensor = Array4::<f32>::zeros((1, 2, self.num_subcarriers, 1));
|
||||
|
||||
// Fill amplitude channel
|
||||
for (i, &v) in features.amplitude_attenuation.iter().take(amp_len).enumerate() {
|
||||
tensor[[0, 0, i, 0]] = v;
|
||||
}
|
||||
|
||||
// Fill phase channel
|
||||
for (i, &v) in features.phase_shifts.iter().take(phase_len).enumerate() {
|
||||
tensor[[0, 1, i, 0]] = v;
|
||||
}
|
||||
|
||||
Ok(tensor)
|
||||
}
|
||||
}
|
||||
|
||||
/// ONNX-based debris penetration model
|
||||
pub struct DebrisModel {
|
||||
config: DebrisModelConfig,
|
||||
feature_extractor: DebrisFeatureExtractor,
|
||||
/// Material classification model weights (for rule-based fallback)
|
||||
material_weights: MaterialClassificationWeights,
|
||||
/// Whether ONNX model is loaded
|
||||
model_loaded: bool,
|
||||
/// Cached model session
|
||||
#[cfg(feature = "onnx")]
|
||||
session: Option<Arc<RwLock<OnnxSession>>>,
|
||||
}
|
||||
|
||||
/// Pre-computed weights for rule-based material classification
|
||||
struct MaterialClassificationWeights {
|
||||
/// Weights for attenuation features
|
||||
attenuation_weights: [f32; MaterialType::NUM_CLASSES],
|
||||
/// Weights for delay spread features
|
||||
delay_weights: [f32; MaterialType::NUM_CLASSES],
|
||||
/// Weights for coherence bandwidth
|
||||
coherence_weights: [f32; MaterialType::NUM_CLASSES],
|
||||
/// Bias terms
|
||||
biases: [f32; MaterialType::NUM_CLASSES],
|
||||
}
|
||||
|
||||
impl Default for MaterialClassificationWeights {
|
||||
fn default() -> Self {
|
||||
// Pre-computed weights based on material RF properties
|
||||
Self {
|
||||
attenuation_weights: [0.8, 0.3, 0.95, 0.1, 0.6, 0.15, 0.5, 0.4],
|
||||
delay_weights: [0.7, 0.2, 0.9, 0.1, 0.5, 0.1, 0.4, 0.3],
|
||||
coherence_weights: [0.3, 0.7, 0.1, 0.9, 0.4, 0.8, 0.5, 0.5],
|
||||
biases: [-0.5, 0.2, -0.8, 0.5, -0.3, 0.3, 0.0, 0.0],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DebrisModel {
|
||||
/// Create a new debris model from ONNX file
|
||||
#[instrument(skip(path))]
|
||||
pub fn from_onnx<P: AsRef<Path>>(path: P, config: DebrisModelConfig) -> MlResult<Self> {
|
||||
let path_ref = path.as_ref();
|
||||
info!(?path_ref, "Loading debris model");
|
||||
|
||||
#[cfg(feature = "onnx")]
|
||||
let session = if path_ref.exists() {
|
||||
let options = InferenceOptions {
|
||||
use_gpu: config.use_gpu,
|
||||
num_threads: config.num_threads,
|
||||
..Default::default()
|
||||
};
|
||||
match OnnxSession::from_file(path_ref, &options) {
|
||||
Ok(s) => {
|
||||
info!("ONNX debris model loaded successfully");
|
||||
Some(Arc::new(RwLock::new(s)))
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(?e, "Failed to load ONNX model, using rule-based fallback");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warn!(?path_ref, "Model file not found, using rule-based fallback");
|
||||
None
|
||||
};
|
||||
|
||||
#[cfg(feature = "onnx")]
|
||||
let model_loaded = session.is_some();
|
||||
|
||||
#[cfg(not(feature = "onnx"))]
|
||||
let model_loaded = false;
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
feature_extractor: DebrisFeatureExtractor::default(),
|
||||
material_weights: MaterialClassificationWeights::default(),
|
||||
model_loaded,
|
||||
#[cfg(feature = "onnx")]
|
||||
session,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create with in-memory model bytes
|
||||
#[cfg(feature = "onnx")]
|
||||
pub fn from_bytes(bytes: &[u8], config: DebrisModelConfig) -> MlResult<Self> {
|
||||
let options = InferenceOptions {
|
||||
use_gpu: config.use_gpu,
|
||||
num_threads: config.num_threads,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let session = OnnxSession::from_bytes(bytes, &options)
|
||||
.map_err(|e| MlError::ModelLoad(e.to_string()))?;
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
feature_extractor: DebrisFeatureExtractor::default(),
|
||||
material_weights: MaterialClassificationWeights::default(),
|
||||
model_loaded: true,
|
||||
session: Some(Arc::new(RwLock::new(session))),
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a rule-based model (no ONNX required)
|
||||
pub fn rule_based(config: DebrisModelConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
feature_extractor: DebrisFeatureExtractor::default(),
|
||||
material_weights: MaterialClassificationWeights::default(),
|
||||
model_loaded: false,
|
||||
#[cfg(feature = "onnx")]
|
||||
session: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if ONNX model is loaded
|
||||
pub fn is_loaded(&self) -> bool {
|
||||
self.model_loaded
|
||||
}
|
||||
|
||||
/// Classify material type from debris features
|
||||
#[instrument(skip(self, features))]
|
||||
pub async fn classify(&self, features: &DebrisFeatures) -> MlResult<DebrisClassification> {
|
||||
#[cfg(feature = "onnx")]
|
||||
if let Some(ref session) = self.session {
|
||||
return self.classify_onnx(features, session).await;
|
||||
}
|
||||
|
||||
// Fall back to rule-based classification
|
||||
self.classify_rules(features)
|
||||
}
|
||||
|
||||
/// ONNX-based classification
|
||||
#[cfg(feature = "onnx")]
|
||||
async fn classify_onnx(
|
||||
&self,
|
||||
features: &DebrisFeatures,
|
||||
session: &Arc<RwLock<OnnxSession>>,
|
||||
) -> MlResult<DebrisClassification> {
|
||||
let input_features = self.feature_extractor.extract(features)?;
|
||||
|
||||
// Prepare input tensor
|
||||
let input_array = Array4::from_shape_vec(
|
||||
(1, 1, 1, input_features.len()),
|
||||
input_features.iter().cloned().collect(),
|
||||
).map_err(|e| MlError::Inference(e.to_string()))?;
|
||||
|
||||
let input_tensor = Tensor::Float4D(input_array);
|
||||
|
||||
let mut inputs = HashMap::new();
|
||||
inputs.insert("input".to_string(), input_tensor);
|
||||
|
||||
// Run inference
|
||||
let outputs = session.write().run(inputs)
|
||||
.map_err(|e| MlError::NeuralNetwork(e))?;
|
||||
|
||||
// Extract classification probabilities
|
||||
let probabilities = if let Some(output) = outputs.get("material_probs") {
|
||||
output.to_vec()
|
||||
.map_err(|e| MlError::Inference(e.to_string()))?
|
||||
} else {
|
||||
// Fallback to rule-based
|
||||
return self.classify_rules(features);
|
||||
};
|
||||
|
||||
// Ensure we have enough classes
|
||||
let mut probs = vec![0.0f32; MaterialType::NUM_CLASSES];
|
||||
for (i, &p) in probabilities.iter().take(MaterialType::NUM_CLASSES).enumerate() {
|
||||
probs[i] = p;
|
||||
}
|
||||
|
||||
// Apply softmax normalization
|
||||
let max_val = probs.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
|
||||
let exp_sum: f32 = probs.iter().map(|&x| (x - max_val).exp()).sum();
|
||||
for p in &mut probs {
|
||||
*p = (*p - max_val).exp() / exp_sum;
|
||||
}
|
||||
|
||||
Ok(DebrisClassification::new(probs))
|
||||
}
|
||||
|
||||
/// Rule-based material classification (fallback)
|
||||
fn classify_rules(&self, features: &DebrisFeatures) -> MlResult<DebrisClassification> {
|
||||
let mut scores = [0.0f32; MaterialType::NUM_CLASSES];
|
||||
|
||||
// Normalize input features
|
||||
let attenuation_score = (features.snr_db.abs() / 30.0).min(1.0);
|
||||
let delay_score = (features.delay_spread / 200.0).min(1.0);
|
||||
let coherence_score = (features.coherence_bandwidth / 20.0).min(1.0);
|
||||
let stability_score = features.temporal_stability;
|
||||
|
||||
// Compute weighted scores for each material
|
||||
for i in 0..MaterialType::NUM_CLASSES {
|
||||
scores[i] = self.material_weights.attenuation_weights[i] * attenuation_score
|
||||
+ self.material_weights.delay_weights[i] * delay_score
|
||||
+ self.material_weights.coherence_weights[i] * (1.0 - coherence_score)
|
||||
+ self.material_weights.biases[i]
|
||||
+ 0.1 * stability_score;
|
||||
}
|
||||
|
||||
// Apply softmax
|
||||
let max_score = scores.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
|
||||
let exp_sum: f32 = scores.iter().map(|&s| (s - max_score).exp()).sum();
|
||||
let probabilities: Vec<f32> = scores.iter()
|
||||
.map(|&s| (s - max_score).exp() / exp_sum)
|
||||
.collect();
|
||||
|
||||
Ok(DebrisClassification::new(probabilities))
|
||||
}
|
||||
|
||||
/// Predict signal attenuation through debris
|
||||
#[instrument(skip(self, features))]
|
||||
pub async fn predict_attenuation(&self, features: &DebrisFeatures) -> MlResult<AttenuationPrediction> {
|
||||
// Get material classification first
|
||||
let classification = self.classify(features).await?;
|
||||
|
||||
// Base attenuation from material type
|
||||
let base_attenuation = classification.material_type.typical_attenuation();
|
||||
|
||||
// Adjust based on measured features
|
||||
let measured_factor = if features.snr_db < 0.0 {
|
||||
1.0 + (features.snr_db.abs() / 30.0).min(1.0)
|
||||
} else {
|
||||
1.0 - (features.snr_db / 30.0).min(0.5)
|
||||
};
|
||||
|
||||
// Layer factor
|
||||
let layer_factor = 1.0 + 0.2 * (classification.estimated_layers as f32 - 1.0);
|
||||
|
||||
// Composite factor
|
||||
let composite_factor = if classification.is_composite { 1.2 } else { 1.0 };
|
||||
|
||||
let total_attenuation = base_attenuation * measured_factor * layer_factor * composite_factor;
|
||||
|
||||
// Uncertainty estimation
|
||||
let uncertainty = if classification.is_composite {
|
||||
total_attenuation * 0.3 // Higher uncertainty for composite
|
||||
} else {
|
||||
total_attenuation * (1.0 - classification.confidence) * 0.5
|
||||
};
|
||||
|
||||
// Estimate depth (will be refined by depth estimation)
|
||||
let estimated_depth = self.estimate_depth_internal(features, total_attenuation);
|
||||
|
||||
Ok(AttenuationPrediction::new(total_attenuation, estimated_depth, uncertainty))
|
||||
}
|
||||
|
||||
/// Estimate penetration depth
|
||||
#[instrument(skip(self, features))]
|
||||
pub async fn estimate_depth(&self, features: &DebrisFeatures) -> MlResult<DepthEstimate> {
|
||||
// Get attenuation prediction
|
||||
let attenuation = self.predict_attenuation(features).await?;
|
||||
|
||||
// Estimate depth from attenuation and material properties
|
||||
let depth = self.estimate_depth_internal(features, attenuation.attenuation_db);
|
||||
|
||||
// Calculate uncertainty
|
||||
let uncertainty = self.calculate_depth_uncertainty(
|
||||
features,
|
||||
depth,
|
||||
attenuation.confidence,
|
||||
);
|
||||
|
||||
let confidence = (attenuation.confidence * features.temporal_stability).min(1.0);
|
||||
|
||||
Ok(DepthEstimate::new(depth, uncertainty, confidence))
|
||||
}
|
||||
|
||||
/// Internal depth estimation logic
|
||||
fn estimate_depth_internal(&self, features: &DebrisFeatures, attenuation_db: f32) -> f32 {
|
||||
// Use coherence bandwidth for depth estimation
|
||||
// Smaller coherence bandwidth suggests more multipath = deeper penetration
|
||||
let cb_depth = (20.0 - features.coherence_bandwidth) / 5.0;
|
||||
|
||||
// Use delay spread
|
||||
let ds_depth = features.delay_spread / 100.0;
|
||||
|
||||
// Use attenuation (assuming typical material)
|
||||
let att_depth = attenuation_db / 15.0;
|
||||
|
||||
// Combine estimates with weights
|
||||
let depth = 0.3 * cb_depth + 0.3 * ds_depth + 0.4 * att_depth;
|
||||
|
||||
// Clamp to reasonable range (0.1 - 10 meters)
|
||||
depth.clamp(0.1, 10.0)
|
||||
}
|
||||
|
||||
/// Calculate uncertainty in depth estimate
|
||||
fn calculate_depth_uncertainty(
|
||||
&self,
|
||||
features: &DebrisFeatures,
|
||||
depth: f32,
|
||||
confidence: f32,
|
||||
) -> f32 {
|
||||
// Base uncertainty proportional to depth
|
||||
let base_uncertainty = depth * 0.2;
|
||||
|
||||
// Adjust by temporal stability (less stable = more uncertain)
|
||||
let stability_factor = 1.0 + (1.0 - features.temporal_stability) * 0.5;
|
||||
|
||||
// Adjust by confidence (lower confidence = more uncertain)
|
||||
let confidence_factor = 1.0 + (1.0 - confidence) * 0.5;
|
||||
|
||||
// Adjust by multipath richness (more multipath = harder to estimate)
|
||||
let multipath_factor = 1.0 + features.multipath_richness * 0.3;
|
||||
|
||||
base_uncertainty * stability_factor * confidence_factor * multipath_factor
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::detection::CsiDataBuffer;
|
||||
|
||||
fn create_test_debris_features() -> DebrisFeatures {
|
||||
DebrisFeatures {
|
||||
amplitude_attenuation: vec![0.5; 64],
|
||||
phase_shifts: vec![0.1; 64],
|
||||
fading_profile: vec![0.8, 0.6, 0.4, 0.2, 0.1, 0.05, 0.02, 0.01],
|
||||
coherence_bandwidth: 5.0,
|
||||
delay_spread: 100.0,
|
||||
snr_db: 15.0,
|
||||
multipath_richness: 0.6,
|
||||
temporal_stability: 0.8,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_material_type() {
|
||||
assert_eq!(MaterialType::from_index(0), MaterialType::Concrete);
|
||||
assert_eq!(MaterialType::Concrete.to_index(), 0);
|
||||
assert!(MaterialType::Concrete.typical_attenuation() > MaterialType::Glass.typical_attenuation());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_debris_classification() {
|
||||
let probs = vec![0.7, 0.1, 0.05, 0.05, 0.05, 0.02, 0.02, 0.01];
|
||||
let classification = DebrisClassification::new(probs);
|
||||
|
||||
assert_eq!(classification.material_type, MaterialType::Concrete);
|
||||
assert!(classification.confidence > 0.6);
|
||||
assert!(!classification.is_composite);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_composite_detection() {
|
||||
let probs = vec![0.4, 0.35, 0.1, 0.05, 0.05, 0.02, 0.02, 0.01];
|
||||
let classification = DebrisClassification::new(probs);
|
||||
|
||||
assert!(classification.is_composite);
|
||||
assert_eq!(classification.material_type, MaterialType::Mixed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_attenuation_prediction() {
|
||||
let pred = AttenuationPrediction::new(25.0, 2.0, 3.0);
|
||||
assert_eq!(pred.attenuation_per_meter, 12.5);
|
||||
assert!(pred.confidence > 0.0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_rule_based_classification() {
|
||||
let config = DebrisModelConfig::default();
|
||||
let model = DebrisModel::rule_based(config);
|
||||
|
||||
let features = create_test_debris_features();
|
||||
let result = model.classify(&features).await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let classification = result.unwrap();
|
||||
assert!(classification.confidence > 0.0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_depth_estimation() {
|
||||
let config = DebrisModelConfig::default();
|
||||
let model = DebrisModel::rule_based(config);
|
||||
|
||||
let features = create_test_debris_features();
|
||||
let result = model.estimate_depth(&features).await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let estimate = result.unwrap();
|
||||
assert!(estimate.depth_meters > 0.0);
|
||||
assert!(estimate.depth_meters < 10.0);
|
||||
assert!(estimate.uncertainty_meters > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_feature_extractor() {
|
||||
let extractor = DebrisFeatureExtractor::default();
|
||||
let features = create_test_debris_features();
|
||||
|
||||
let result = extractor.extract(&features);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let arr = result.unwrap();
|
||||
assert_eq!(arr.shape()[0], 1);
|
||||
assert_eq!(arr.shape()[1], 256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_spatial_temporal_extraction() {
|
||||
let extractor = DebrisFeatureExtractor::new(64, 100);
|
||||
let features = create_test_debris_features();
|
||||
|
||||
let result = extractor.extract_spatial_temporal(&features);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let arr = result.unwrap();
|
||||
assert_eq!(arr.shape(), &[1, 2, 64, 1]);
|
||||
}
|
||||
}
|
||||
@@ -1,692 +0,0 @@
|
||||
//! Machine Learning module for debris penetration pattern recognition.
|
||||
//!
|
||||
//! This module provides ML-based models for:
|
||||
//! - Debris material classification
|
||||
//! - Penetration depth prediction
|
||||
//! - Signal attenuation analysis
|
||||
//! - Vital signs classification with uncertainty estimation
|
||||
//!
|
||||
//! ## Architecture
|
||||
//!
|
||||
//! The ML subsystem integrates with the `wifi-densepose-nn` crate for ONNX inference
|
||||
//! and provides specialized models for disaster response scenarios.
|
||||
//!
|
||||
//! ```text
|
||||
//! CSI Data -> Feature Extraction -> Model Inference -> Predictions
|
||||
//! | | |
|
||||
//! v v v
|
||||
//! [Debris Features] [ONNX Models] [Classifications]
|
||||
//! [Signal Features] [Neural Nets] [Confidences]
|
||||
//! ```
|
||||
|
||||
mod debris_model;
|
||||
mod vital_signs_classifier;
|
||||
|
||||
pub use debris_model::{
|
||||
DebrisModel, DebrisModelConfig, DebrisFeatureExtractor,
|
||||
MaterialType, DebrisClassification, AttenuationPrediction,
|
||||
DebrisModelError,
|
||||
};
|
||||
|
||||
pub use vital_signs_classifier::{
|
||||
VitalSignsClassifier, VitalSignsClassifierConfig,
|
||||
BreathingClassification, HeartbeatClassification,
|
||||
UncertaintyEstimate, ClassifierOutput,
|
||||
};
|
||||
|
||||
use crate::detection::CsiDataBuffer;
|
||||
use crate::domain::{VitalSignsReading, BreathingPattern, HeartbeatSignature};
|
||||
use async_trait::async_trait;
|
||||
use std::path::Path;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors that can occur in ML operations
|
||||
#[derive(Debug, Error)]
|
||||
pub enum MlError {
|
||||
/// Model loading error
|
||||
#[error("Failed to load model: {0}")]
|
||||
ModelLoad(String),
|
||||
|
||||
/// Inference error
|
||||
#[error("Inference failed: {0}")]
|
||||
Inference(String),
|
||||
|
||||
/// Feature extraction error
|
||||
#[error("Feature extraction failed: {0}")]
|
||||
FeatureExtraction(String),
|
||||
|
||||
/// Invalid input error
|
||||
#[error("Invalid input: {0}")]
|
||||
InvalidInput(String),
|
||||
|
||||
/// Model not initialized
|
||||
#[error("Model not initialized: {0}")]
|
||||
NotInitialized(String),
|
||||
|
||||
/// Configuration error
|
||||
#[error("Configuration error: {0}")]
|
||||
Config(String),
|
||||
|
||||
/// Integration error with wifi-densepose-nn
|
||||
#[error("Neural network error: {0}")]
|
||||
NeuralNetwork(#[from] wifi_densepose_nn::NnError),
|
||||
}
|
||||
|
||||
/// Result type for ML operations
|
||||
pub type MlResult<T> = Result<T, MlError>;
|
||||
|
||||
/// Trait for debris penetration models
|
||||
///
|
||||
/// This trait defines the interface for models that can predict
|
||||
/// material type and signal attenuation through debris layers.
|
||||
#[async_trait]
|
||||
pub trait DebrisPenetrationModel: Send + Sync {
|
||||
/// Classify the material type from CSI features
|
||||
async fn classify_material(&self, features: &DebrisFeatures) -> MlResult<MaterialType>;
|
||||
|
||||
/// Predict signal attenuation through debris
|
||||
async fn predict_attenuation(&self, features: &DebrisFeatures) -> MlResult<AttenuationPrediction>;
|
||||
|
||||
/// Estimate penetration depth in meters
|
||||
async fn estimate_depth(&self, features: &DebrisFeatures) -> MlResult<DepthEstimate>;
|
||||
|
||||
/// Get model confidence for the predictions
|
||||
fn model_confidence(&self) -> f32;
|
||||
|
||||
/// Check if the model is loaded and ready
|
||||
fn is_ready(&self) -> bool;
|
||||
}
|
||||
|
||||
/// Features extracted from CSI data for debris analysis
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DebrisFeatures {
|
||||
/// Amplitude attenuation across subcarriers
|
||||
pub amplitude_attenuation: Vec<f32>,
|
||||
/// Phase shift patterns
|
||||
pub phase_shifts: Vec<f32>,
|
||||
/// Frequency-selective fading characteristics
|
||||
pub fading_profile: Vec<f32>,
|
||||
/// Coherence bandwidth estimate
|
||||
pub coherence_bandwidth: f32,
|
||||
/// RMS delay spread
|
||||
pub delay_spread: f32,
|
||||
/// Signal-to-noise ratio estimate
|
||||
pub snr_db: f32,
|
||||
/// Multipath richness indicator
|
||||
pub multipath_richness: f32,
|
||||
/// Temporal stability metric
|
||||
pub temporal_stability: f32,
|
||||
}
|
||||
|
||||
impl DebrisFeatures {
|
||||
/// Create new debris features from raw CSI data
|
||||
pub fn from_csi(buffer: &CsiDataBuffer) -> MlResult<Self> {
|
||||
if buffer.amplitudes.is_empty() {
|
||||
return Err(MlError::FeatureExtraction("Empty CSI buffer".into()));
|
||||
}
|
||||
|
||||
// Calculate amplitude attenuation
|
||||
let amplitude_attenuation = Self::compute_amplitude_features(&buffer.amplitudes);
|
||||
|
||||
// Calculate phase shifts
|
||||
let phase_shifts = Self::compute_phase_features(&buffer.phases);
|
||||
|
||||
// Compute fading profile
|
||||
let fading_profile = Self::compute_fading_profile(&buffer.amplitudes);
|
||||
|
||||
// Estimate coherence bandwidth from frequency correlation
|
||||
let coherence_bandwidth = Self::estimate_coherence_bandwidth(&buffer.amplitudes);
|
||||
|
||||
// Estimate delay spread
|
||||
let delay_spread = Self::estimate_delay_spread(&buffer.amplitudes);
|
||||
|
||||
// Estimate SNR
|
||||
let snr_db = Self::estimate_snr(&buffer.amplitudes);
|
||||
|
||||
// Multipath richness
|
||||
let multipath_richness = Self::compute_multipath_richness(&buffer.amplitudes);
|
||||
|
||||
// Temporal stability
|
||||
let temporal_stability = Self::compute_temporal_stability(&buffer.amplitudes);
|
||||
|
||||
Ok(Self {
|
||||
amplitude_attenuation,
|
||||
phase_shifts,
|
||||
fading_profile,
|
||||
coherence_bandwidth,
|
||||
delay_spread,
|
||||
snr_db,
|
||||
multipath_richness,
|
||||
temporal_stability,
|
||||
})
|
||||
}
|
||||
|
||||
/// Compute amplitude features
|
||||
fn compute_amplitude_features(amplitudes: &[f64]) -> Vec<f32> {
|
||||
if amplitudes.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let mean = amplitudes.iter().sum::<f64>() / amplitudes.len() as f64;
|
||||
let variance = amplitudes.iter()
|
||||
.map(|a| (a - mean).powi(2))
|
||||
.sum::<f64>() / amplitudes.len() as f64;
|
||||
let std_dev = variance.sqrt();
|
||||
|
||||
// Normalize amplitudes
|
||||
amplitudes.iter()
|
||||
.map(|a| ((a - mean) / (std_dev + 1e-8)) as f32)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Compute phase features
|
||||
fn compute_phase_features(phases: &[f64]) -> Vec<f32> {
|
||||
if phases.len() < 2 {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
// Compute phase differences (unwrapped)
|
||||
phases.windows(2)
|
||||
.map(|w| {
|
||||
let diff = w[1] - w[0];
|
||||
// Unwrap phase
|
||||
let unwrapped = if diff > std::f64::consts::PI {
|
||||
diff - 2.0 * std::f64::consts::PI
|
||||
} else if diff < -std::f64::consts::PI {
|
||||
diff + 2.0 * std::f64::consts::PI
|
||||
} else {
|
||||
diff
|
||||
};
|
||||
unwrapped as f32
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Compute fading profile (power spectral characteristics)
|
||||
fn compute_fading_profile(amplitudes: &[f64]) -> Vec<f32> {
|
||||
use rustfft::{FftPlanner, num_complex::Complex};
|
||||
|
||||
if amplitudes.len() < 16 {
|
||||
return vec![0.0; 8];
|
||||
}
|
||||
|
||||
// Take a subset for FFT
|
||||
let n = 64.min(amplitudes.len());
|
||||
let mut buffer: Vec<Complex<f64>> = amplitudes.iter()
|
||||
.take(n)
|
||||
.map(|&a| Complex::new(a, 0.0))
|
||||
.collect();
|
||||
|
||||
// Pad to power of 2
|
||||
while buffer.len() < 64 {
|
||||
buffer.push(Complex::new(0.0, 0.0));
|
||||
}
|
||||
|
||||
// Compute FFT
|
||||
let mut planner = FftPlanner::new();
|
||||
let fft = planner.plan_fft_forward(64);
|
||||
fft.process(&mut buffer);
|
||||
|
||||
// Extract power spectrum (first half)
|
||||
buffer.iter()
|
||||
.take(8)
|
||||
.map(|c| (c.norm() / n as f64) as f32)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Estimate coherence bandwidth from frequency correlation
|
||||
fn estimate_coherence_bandwidth(amplitudes: &[f64]) -> f32 {
|
||||
if amplitudes.len() < 10 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Compute autocorrelation
|
||||
let n = amplitudes.len();
|
||||
let mean = amplitudes.iter().sum::<f64>() / n as f64;
|
||||
let variance: f64 = amplitudes.iter()
|
||||
.map(|a| (a - mean).powi(2))
|
||||
.sum::<f64>() / n as f64;
|
||||
|
||||
if variance < 1e-10 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Find lag where correlation drops below 0.5
|
||||
let mut coherence_lag = n;
|
||||
for lag in 1..n / 2 {
|
||||
let correlation: f64 = amplitudes.iter()
|
||||
.take(n - lag)
|
||||
.zip(amplitudes.iter().skip(lag))
|
||||
.map(|(a, b)| (a - mean) * (b - mean))
|
||||
.sum::<f64>() / ((n - lag) as f64 * variance);
|
||||
|
||||
if correlation < 0.5 {
|
||||
coherence_lag = lag;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to bandwidth estimate (assuming 20 MHz channel)
|
||||
(20.0 / coherence_lag as f32).min(20.0)
|
||||
}
|
||||
|
||||
/// Estimate RMS delay spread
|
||||
fn estimate_delay_spread(amplitudes: &[f64]) -> f32 {
|
||||
if amplitudes.len() < 10 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Use power delay profile approximation
|
||||
let power: Vec<f64> = amplitudes.iter().map(|a| a.powi(2)).collect();
|
||||
let total_power: f64 = power.iter().sum();
|
||||
|
||||
if total_power < 1e-10 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Calculate mean delay
|
||||
let mean_delay: f64 = power.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| i as f64 * p)
|
||||
.sum::<f64>() / total_power;
|
||||
|
||||
// Calculate RMS delay spread
|
||||
let variance: f64 = power.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| (i as f64 - mean_delay).powi(2) * p)
|
||||
.sum::<f64>() / total_power;
|
||||
|
||||
// Convert to nanoseconds (assuming sample period)
|
||||
(variance.sqrt() * 50.0) as f32 // 50 ns per sample assumed
|
||||
}
|
||||
|
||||
/// Estimate SNR from amplitude variance
|
||||
fn estimate_snr(amplitudes: &[f64]) -> f32 {
|
||||
if amplitudes.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let mean = amplitudes.iter().sum::<f64>() / amplitudes.len() as f64;
|
||||
let variance = amplitudes.iter()
|
||||
.map(|a| (a - mean).powi(2))
|
||||
.sum::<f64>() / amplitudes.len() as f64;
|
||||
|
||||
if variance < 1e-10 {
|
||||
return 30.0; // High SNR assumed
|
||||
}
|
||||
|
||||
// SNR estimate based on signal power to noise power ratio
|
||||
let signal_power = mean.powi(2);
|
||||
let snr_linear = signal_power / variance;
|
||||
|
||||
(10.0 * snr_linear.log10()) as f32
|
||||
}
|
||||
|
||||
/// Compute multipath richness indicator
|
||||
fn compute_multipath_richness(amplitudes: &[f64]) -> f32 {
|
||||
if amplitudes.len() < 10 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Calculate amplitude variance as multipath indicator
|
||||
let mean = amplitudes.iter().sum::<f64>() / amplitudes.len() as f64;
|
||||
let variance = amplitudes.iter()
|
||||
.map(|a| (a - mean).powi(2))
|
||||
.sum::<f64>() / amplitudes.len() as f64;
|
||||
|
||||
// Normalize to 0-1 range
|
||||
let std_dev = variance.sqrt();
|
||||
let normalized = std_dev / (mean.abs() + 1e-8);
|
||||
|
||||
(normalized.min(1.0)) as f32
|
||||
}
|
||||
|
||||
/// Compute temporal stability metric
|
||||
fn compute_temporal_stability(amplitudes: &[f64]) -> f32 {
|
||||
if amplitudes.len() < 2 {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
// Calculate coefficient of variation over time
|
||||
let differences: Vec<f64> = amplitudes.windows(2)
|
||||
.map(|w| (w[1] - w[0]).abs())
|
||||
.collect();
|
||||
|
||||
let mean_diff = differences.iter().sum::<f64>() / differences.len() as f64;
|
||||
let mean_amp = amplitudes.iter().sum::<f64>() / amplitudes.len() as f64;
|
||||
|
||||
// Stability is inverse of relative variation
|
||||
let variation = mean_diff / (mean_amp.abs() + 1e-8);
|
||||
|
||||
(1.0 - variation.min(1.0)) as f32
|
||||
}
|
||||
|
||||
/// Convert to feature vector for model input
|
||||
pub fn to_feature_vector(&self) -> Vec<f32> {
|
||||
let mut features = Vec::with_capacity(256);
|
||||
|
||||
// Add amplitude attenuation features (padded/truncated to 64)
|
||||
let amp_len = self.amplitude_attenuation.len().min(64);
|
||||
features.extend_from_slice(&self.amplitude_attenuation[..amp_len]);
|
||||
features.resize(64, 0.0);
|
||||
|
||||
// Add phase shift features (padded/truncated to 64)
|
||||
let phase_len = self.phase_shifts.len().min(64);
|
||||
features.extend_from_slice(&self.phase_shifts[..phase_len]);
|
||||
features.resize(128, 0.0);
|
||||
|
||||
// Add fading profile (padded to 16)
|
||||
let fading_len = self.fading_profile.len().min(16);
|
||||
features.extend_from_slice(&self.fading_profile[..fading_len]);
|
||||
features.resize(144, 0.0);
|
||||
|
||||
// Add scalar features
|
||||
features.push(self.coherence_bandwidth);
|
||||
features.push(self.delay_spread);
|
||||
features.push(self.snr_db);
|
||||
features.push(self.multipath_richness);
|
||||
features.push(self.temporal_stability);
|
||||
|
||||
// Pad to 256 for model input
|
||||
features.resize(256, 0.0);
|
||||
|
||||
features
|
||||
}
|
||||
}
|
||||
|
||||
/// Depth estimate with uncertainty
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DepthEstimate {
|
||||
/// Estimated depth in meters
|
||||
pub depth_meters: f32,
|
||||
/// Uncertainty (standard deviation) in meters
|
||||
pub uncertainty_meters: f32,
|
||||
/// Confidence in the estimate (0.0-1.0)
|
||||
pub confidence: f32,
|
||||
/// Lower bound of 95% confidence interval
|
||||
pub lower_bound: f32,
|
||||
/// Upper bound of 95% confidence interval
|
||||
pub upper_bound: f32,
|
||||
}
|
||||
|
||||
impl DepthEstimate {
|
||||
/// Create a new depth estimate with uncertainty
|
||||
pub fn new(depth: f32, uncertainty: f32, confidence: f32) -> Self {
|
||||
Self {
|
||||
depth_meters: depth,
|
||||
uncertainty_meters: uncertainty,
|
||||
confidence,
|
||||
lower_bound: (depth - 1.96 * uncertainty).max(0.0),
|
||||
upper_bound: depth + 1.96 * uncertainty,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the estimate is reliable (high confidence, low uncertainty)
|
||||
pub fn is_reliable(&self) -> bool {
|
||||
self.confidence > 0.7 && self.uncertainty_meters < self.depth_meters * 0.3
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for the ML-enhanced detection pipeline
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct MlDetectionConfig {
|
||||
/// Enable ML-based debris classification
|
||||
pub enable_debris_classification: bool,
|
||||
/// Enable ML-based vital signs classification
|
||||
pub enable_vital_classification: bool,
|
||||
/// Path to debris model file
|
||||
pub debris_model_path: Option<String>,
|
||||
/// Path to vital signs model file
|
||||
pub vital_model_path: Option<String>,
|
||||
/// Minimum confidence threshold for ML predictions
|
||||
pub min_confidence: f32,
|
||||
/// Use GPU for inference
|
||||
pub use_gpu: bool,
|
||||
/// Number of inference threads
|
||||
pub num_threads: usize,
|
||||
}
|
||||
|
||||
impl Default for MlDetectionConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enable_debris_classification: false,
|
||||
enable_vital_classification: false,
|
||||
debris_model_path: None,
|
||||
vital_model_path: None,
|
||||
min_confidence: 0.5,
|
||||
use_gpu: false,
|
||||
num_threads: 4,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MlDetectionConfig {
|
||||
/// Create configuration for CPU inference
|
||||
pub fn cpu() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Create configuration for GPU inference
|
||||
pub fn gpu() -> Self {
|
||||
Self {
|
||||
use_gpu: true,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable debris classification with model path
|
||||
pub fn with_debris_model<P: Into<String>>(mut self, path: P) -> Self {
|
||||
self.debris_model_path = Some(path.into());
|
||||
self.enable_debris_classification = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Enable vital signs classification with model path
|
||||
pub fn with_vital_model<P: Into<String>>(mut self, path: P) -> Self {
|
||||
self.vital_model_path = Some(path.into());
|
||||
self.enable_vital_classification = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set minimum confidence threshold
|
||||
pub fn with_min_confidence(mut self, confidence: f32) -> Self {
|
||||
self.min_confidence = confidence.clamp(0.0, 1.0);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// ML-enhanced detection pipeline that combines traditional and ML-based detection
|
||||
pub struct MlDetectionPipeline {
|
||||
config: MlDetectionConfig,
|
||||
debris_model: Option<DebrisModel>,
|
||||
vital_classifier: Option<VitalSignsClassifier>,
|
||||
}
|
||||
|
||||
impl MlDetectionPipeline {
|
||||
/// Create a new ML detection pipeline
|
||||
pub fn new(config: MlDetectionConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
debris_model: None,
|
||||
vital_classifier: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize models asynchronously
|
||||
pub async fn initialize(&mut self) -> MlResult<()> {
|
||||
if self.config.enable_debris_classification {
|
||||
if let Some(ref path) = self.config.debris_model_path {
|
||||
let debris_config = DebrisModelConfig {
|
||||
use_gpu: self.config.use_gpu,
|
||||
num_threads: self.config.num_threads,
|
||||
confidence_threshold: self.config.min_confidence,
|
||||
};
|
||||
self.debris_model = Some(DebrisModel::from_onnx(path, debris_config)?);
|
||||
}
|
||||
}
|
||||
|
||||
if self.config.enable_vital_classification {
|
||||
if let Some(ref path) = self.config.vital_model_path {
|
||||
let vital_config = VitalSignsClassifierConfig {
|
||||
use_gpu: self.config.use_gpu,
|
||||
num_threads: self.config.num_threads,
|
||||
min_confidence: self.config.min_confidence,
|
||||
enable_uncertainty: true,
|
||||
mc_samples: 10,
|
||||
dropout_rate: 0.1,
|
||||
};
|
||||
self.vital_classifier = Some(VitalSignsClassifier::from_onnx(path, vital_config)?);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Process CSI data and return enhanced detection results
|
||||
pub async fn process(&self, buffer: &CsiDataBuffer) -> MlResult<MlDetectionResult> {
|
||||
let mut result = MlDetectionResult::default();
|
||||
|
||||
// Extract debris features and classify if enabled
|
||||
if let Some(ref model) = self.debris_model {
|
||||
let features = DebrisFeatures::from_csi(buffer)?;
|
||||
result.debris_classification = Some(model.classify(&features).await?);
|
||||
result.depth_estimate = Some(model.estimate_depth(&features).await?);
|
||||
}
|
||||
|
||||
// Classify vital signs if enabled
|
||||
if let Some(ref classifier) = self.vital_classifier {
|
||||
let features = classifier.extract_features(buffer)?;
|
||||
result.vital_classification = Some(classifier.classify(&features).await?);
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Check if the pipeline is ready for inference
|
||||
pub fn is_ready(&self) -> bool {
|
||||
let debris_ready = !self.config.enable_debris_classification
|
||||
|| self.debris_model.as_ref().map_or(false, |m| m.is_loaded());
|
||||
let vital_ready = !self.config.enable_vital_classification
|
||||
|| self.vital_classifier.as_ref().map_or(false, |c| c.is_loaded());
|
||||
|
||||
debris_ready && vital_ready
|
||||
}
|
||||
|
||||
/// Get configuration
|
||||
pub fn config(&self) -> &MlDetectionConfig {
|
||||
&self.config
|
||||
}
|
||||
}
|
||||
|
||||
/// Combined ML detection results
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MlDetectionResult {
|
||||
/// Debris classification result
|
||||
pub debris_classification: Option<DebrisClassification>,
|
||||
/// Depth estimate
|
||||
pub depth_estimate: Option<DepthEstimate>,
|
||||
/// Vital signs classification
|
||||
pub vital_classification: Option<ClassifierOutput>,
|
||||
}
|
||||
|
||||
impl MlDetectionResult {
|
||||
/// Check if any ML detection was performed
|
||||
pub fn has_results(&self) -> bool {
|
||||
self.debris_classification.is_some()
|
||||
|| self.depth_estimate.is_some()
|
||||
|| self.vital_classification.is_some()
|
||||
}
|
||||
|
||||
/// Get overall confidence
|
||||
pub fn overall_confidence(&self) -> f32 {
|
||||
let mut total = 0.0;
|
||||
let mut count = 0;
|
||||
|
||||
if let Some(ref debris) = self.debris_classification {
|
||||
total += debris.confidence;
|
||||
count += 1;
|
||||
}
|
||||
|
||||
if let Some(ref depth) = self.depth_estimate {
|
||||
total += depth.confidence;
|
||||
count += 1;
|
||||
}
|
||||
|
||||
if let Some(ref vital) = self.vital_classification {
|
||||
total += vital.overall_confidence;
|
||||
count += 1;
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
total / count as f32
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn create_test_buffer() -> CsiDataBuffer {
|
||||
let mut buffer = CsiDataBuffer::new(1000.0);
|
||||
let amplitudes: Vec<f64> = (0..1000)
|
||||
.map(|i| {
|
||||
let t = i as f64 / 1000.0;
|
||||
0.5 + 0.1 * (2.0 * std::f64::consts::PI * 0.25 * t).sin()
|
||||
})
|
||||
.collect();
|
||||
let phases: Vec<f64> = (0..1000)
|
||||
.map(|i| {
|
||||
let t = i as f64 / 1000.0;
|
||||
(2.0 * std::f64::consts::PI * 0.25 * t).sin() * 0.3
|
||||
})
|
||||
.collect();
|
||||
buffer.add_samples(&litudes, &phases);
|
||||
buffer
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_debris_features_extraction() {
|
||||
let buffer = create_test_buffer();
|
||||
let features = DebrisFeatures::from_csi(&buffer);
|
||||
assert!(features.is_ok());
|
||||
|
||||
let features = features.unwrap();
|
||||
assert!(!features.amplitude_attenuation.is_empty());
|
||||
assert!(!features.phase_shifts.is_empty());
|
||||
assert!(features.coherence_bandwidth >= 0.0);
|
||||
assert!(features.delay_spread >= 0.0);
|
||||
assert!(features.temporal_stability >= 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_feature_vector_size() {
|
||||
let buffer = create_test_buffer();
|
||||
let features = DebrisFeatures::from_csi(&buffer).unwrap();
|
||||
let vector = features.to_feature_vector();
|
||||
assert_eq!(vector.len(), 256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_depth_estimate() {
|
||||
let estimate = DepthEstimate::new(2.5, 0.3, 0.85);
|
||||
assert!(estimate.is_reliable());
|
||||
assert!(estimate.lower_bound < estimate.depth_meters);
|
||||
assert!(estimate.upper_bound > estimate.depth_meters);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ml_config_builder() {
|
||||
let config = MlDetectionConfig::cpu()
|
||||
.with_debris_model("models/debris.onnx")
|
||||
.with_vital_model("models/vitals.onnx")
|
||||
.with_min_confidence(0.7);
|
||||
|
||||
assert!(config.enable_debris_classification);
|
||||
assert!(config.enable_vital_classification);
|
||||
assert_eq!(config.min_confidence, 0.7);
|
||||
assert!(!config.use_gpu);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,201 +0,0 @@
|
||||
//! Integration tests for ADR-001: WiFi-Mat disaster response pipeline.
|
||||
//!
|
||||
//! These tests verify the full pipeline with deterministic synthetic CSI data:
|
||||
//! 1. Push CSI data -> Detection pipeline processes it
|
||||
//! 2. Ensemble classifier combines signals -> Triage recommendation
|
||||
//! 3. Events emitted to EventStore
|
||||
//! 4. API endpoints accept CSI data and return results
|
||||
//!
|
||||
//! No mocks, no random data. All test signals are deterministic sinusoids.
|
||||
|
||||
use std::sync::Arc;
|
||||
use wifi_densepose_mat::{
|
||||
DisasterConfig, DisasterResponse, DisasterType,
|
||||
DetectionPipeline, DetectionConfig,
|
||||
EnsembleClassifier, EnsembleConfig,
|
||||
InMemoryEventStore, EventStore,
|
||||
};
|
||||
|
||||
/// Generate deterministic CSI data simulating a breathing survivor.
|
||||
///
|
||||
/// Creates a sinusoidal signal at 0.267 Hz (16 BPM breathing rate)
|
||||
/// with known amplitude and phase patterns.
|
||||
fn generate_breathing_signal(sample_rate: f64, duration_secs: f64) -> (Vec<f64>, Vec<f64>) {
|
||||
let num_samples = (sample_rate * duration_secs) as usize;
|
||||
let breathing_freq = 0.267; // 16 BPM
|
||||
|
||||
let amplitudes: Vec<f64> = (0..num_samples)
|
||||
.map(|i| {
|
||||
let t = i as f64 / sample_rate;
|
||||
0.5 + 0.3 * (2.0 * std::f64::consts::PI * breathing_freq * t).sin()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let phases: Vec<f64> = (0..num_samples)
|
||||
.map(|i| {
|
||||
let t = i as f64 / sample_rate;
|
||||
0.2 * (2.0 * std::f64::consts::PI * breathing_freq * t).sin()
|
||||
})
|
||||
.collect();
|
||||
|
||||
(amplitudes, phases)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detection_pipeline_accepts_deterministic_data() {
|
||||
let config = DetectionConfig {
|
||||
sample_rate: 100.0,
|
||||
enable_heartbeat: false,
|
||||
min_confidence: 0.1,
|
||||
..DetectionConfig::default()
|
||||
};
|
||||
|
||||
let pipeline = DetectionPipeline::new(config);
|
||||
|
||||
// Push 10 seconds of breathing signal
|
||||
let (amplitudes, phases) = generate_breathing_signal(100.0, 10.0);
|
||||
assert_eq!(amplitudes.len(), 1000);
|
||||
assert_eq!(phases.len(), 1000);
|
||||
|
||||
// Pipeline should accept the data without error
|
||||
pipeline.add_data(&litudes, &phases);
|
||||
|
||||
// Verify the pipeline stored the data
|
||||
assert_eq!(pipeline.config().sample_rate, 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensemble_classifier_triage_logic() {
|
||||
use wifi_densepose_mat::domain::{
|
||||
BreathingPattern, BreathingType, MovementProfile,
|
||||
MovementType, HeartbeatSignature, SignalStrength,
|
||||
VitalSignsReading, TriageStatus,
|
||||
};
|
||||
|
||||
let classifier = EnsembleClassifier::new(EnsembleConfig::default());
|
||||
|
||||
// Normal breathing + movement = Minor (Green)
|
||||
let normal_breathing = VitalSignsReading::new(
|
||||
Some(BreathingPattern {
|
||||
rate_bpm: 16.0,
|
||||
pattern_type: BreathingType::Normal,
|
||||
amplitude: 0.5,
|
||||
regularity: 0.9,
|
||||
}),
|
||||
None,
|
||||
MovementProfile {
|
||||
movement_type: MovementType::Periodic,
|
||||
intensity: 0.5,
|
||||
frequency: 0.3,
|
||||
is_voluntary: true,
|
||||
},
|
||||
);
|
||||
let result = classifier.classify(&normal_breathing);
|
||||
assert_eq!(result.recommended_triage, TriageStatus::Minor);
|
||||
assert!(result.breathing_detected);
|
||||
|
||||
// Agonal breathing = Immediate (Red)
|
||||
let agonal = VitalSignsReading::new(
|
||||
Some(BreathingPattern {
|
||||
rate_bpm: 6.0,
|
||||
pattern_type: BreathingType::Agonal,
|
||||
amplitude: 0.3,
|
||||
regularity: 0.2,
|
||||
}),
|
||||
None,
|
||||
MovementProfile::default(),
|
||||
);
|
||||
let result = classifier.classify(&agonal);
|
||||
assert_eq!(result.recommended_triage, TriageStatus::Immediate);
|
||||
|
||||
// Normal breathing, no movement = Delayed (Yellow)
|
||||
let stable = VitalSignsReading::new(
|
||||
Some(BreathingPattern {
|
||||
rate_bpm: 14.0,
|
||||
pattern_type: BreathingType::Normal,
|
||||
amplitude: 0.6,
|
||||
regularity: 0.95,
|
||||
}),
|
||||
Some(HeartbeatSignature {
|
||||
rate_bpm: 72.0,
|
||||
variability: 0.1,
|
||||
strength: SignalStrength::Moderate,
|
||||
}),
|
||||
MovementProfile::default(),
|
||||
);
|
||||
let result = classifier.classify(&stable);
|
||||
assert_eq!(result.recommended_triage, TriageStatus::Delayed);
|
||||
assert!(result.heartbeat_detected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_event_store_append_and_query() {
|
||||
let store = InMemoryEventStore::new();
|
||||
|
||||
// Append a system event
|
||||
let event = wifi_densepose_mat::DomainEvent::System(
|
||||
wifi_densepose_mat::domain::events::SystemEvent::SystemStarted {
|
||||
version: "test-v1".to_string(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
},
|
||||
);
|
||||
|
||||
store.append(event).unwrap();
|
||||
|
||||
let all = store.all().unwrap();
|
||||
assert_eq!(all.len(), 1);
|
||||
assert_eq!(all[0].event_type(), "SystemStarted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disaster_response_with_event_store() {
|
||||
let config = DisasterConfig::builder()
|
||||
.disaster_type(DisasterType::Earthquake)
|
||||
.sensitivity(0.8)
|
||||
.build();
|
||||
|
||||
let event_store: Arc<dyn EventStore> = Arc::new(InMemoryEventStore::new());
|
||||
let response = DisasterResponse::with_event_store(config, event_store.clone());
|
||||
|
||||
// Push CSI data
|
||||
let (amplitudes, phases) = generate_breathing_signal(1000.0, 1.0);
|
||||
response.push_csi_data(&litudes, &phases).unwrap();
|
||||
|
||||
// Store should be empty (no scan cycle ran)
|
||||
let events = event_store.all().unwrap();
|
||||
assert_eq!(events.len(), 0);
|
||||
|
||||
// Access the ensemble classifier
|
||||
let _ensemble = response.ensemble_classifier();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_push_csi_data_validation() {
|
||||
let config = DisasterConfig::builder()
|
||||
.disaster_type(DisasterType::Earthquake)
|
||||
.build();
|
||||
|
||||
let response = DisasterResponse::new(config);
|
||||
|
||||
// Mismatched lengths should fail
|
||||
assert!(response.push_csi_data(&[1.0, 2.0], &[1.0]).is_err());
|
||||
|
||||
// Empty data should fail
|
||||
assert!(response.push_csi_data(&[], &[]).is_err());
|
||||
|
||||
// Valid data should succeed
|
||||
assert!(response.push_csi_data(&[1.0, 2.0], &[0.1, 0.2]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deterministic_signal_properties() {
|
||||
// Verify that our test signal is actually deterministic
|
||||
let (a1, p1) = generate_breathing_signal(100.0, 5.0);
|
||||
let (a2, p2) = generate_breathing_signal(100.0, 5.0);
|
||||
|
||||
assert_eq!(a1.len(), a2.len());
|
||||
for i in 0..a1.len() {
|
||||
assert!((a1[i] - a2[i]).abs() < 1e-15, "Amplitude mismatch at index {}", i);
|
||||
assert!((p1[i] - p2[i]).abs() < 1e-15, "Phase mismatch at index {}", i);
|
||||
}
|
||||
}
|
||||
@@ -32,38 +32,38 @@ fn bench_tensor_operations(c: &mut Criterion) {
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_densepose_inference(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("densepose_inference");
|
||||
fn bench_densepose_forward(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("densepose_forward");
|
||||
|
||||
// Use MockBackend for benchmarking inference throughput
|
||||
let engine = EngineBuilder::new().build_mock();
|
||||
let config = DensePoseConfig::new(256, 24, 2);
|
||||
let head = DensePoseHead::new(config).unwrap();
|
||||
|
||||
for size in [32, 64].iter() {
|
||||
let input = Tensor::zeros_4d([1, 256, *size, *size]);
|
||||
|
||||
group.throughput(Throughput::Elements((size * size * 256) as u64));
|
||||
|
||||
group.bench_with_input(BenchmarkId::new("inference", size), size, |b, _| {
|
||||
b.iter(|| black_box(engine.infer(&input).unwrap()))
|
||||
group.bench_with_input(BenchmarkId::new("mock_forward", size), size, |b, _| {
|
||||
b.iter(|| black_box(head.forward(&input).unwrap()))
|
||||
});
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_translator_inference(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("translator_inference");
|
||||
fn bench_translator_forward(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("translator_forward");
|
||||
|
||||
// Use MockBackend for benchmarking inference throughput
|
||||
let engine = EngineBuilder::new().build_mock();
|
||||
let config = TranslatorConfig::new(128, vec![256, 512, 256], 256);
|
||||
let translator = ModalityTranslator::new(config).unwrap();
|
||||
|
||||
for size in [32, 64].iter() {
|
||||
let input = Tensor::zeros_4d([1, 128, *size, *size]);
|
||||
|
||||
group.throughput(Throughput::Elements((size * size * 128) as u64));
|
||||
|
||||
group.bench_with_input(BenchmarkId::new("inference", size), size, |b, _| {
|
||||
b.iter(|| black_box(engine.infer(&input).unwrap()))
|
||||
group.bench_with_input(BenchmarkId::new("mock_forward", size), size, |b, _| {
|
||||
b.iter(|| black_box(translator.forward(&input).unwrap()))
|
||||
});
|
||||
}
|
||||
|
||||
@@ -112,8 +112,8 @@ fn bench_batch_inference(c: &mut Criterion) {
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_tensor_operations,
|
||||
bench_densepose_inference,
|
||||
bench_translator_inference,
|
||||
bench_densepose_forward,
|
||||
bench_translator_forward,
|
||||
bench_mock_inference,
|
||||
bench_batch_inference,
|
||||
);
|
||||
|
||||
@@ -233,17 +233,15 @@ impl DensePoseHead {
|
||||
///
|
||||
/// This performs inference using loaded weights. For ONNX-based inference,
|
||||
/// use the ONNX backend directly.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if no model weights are loaded. Load weights with
|
||||
/// `with_weights()` before calling forward(). Use `forward_mock()` in tests.
|
||||
pub fn forward(&self, input: &Tensor) -> NnResult<DensePoseOutput> {
|
||||
self.validate_input(input)?;
|
||||
|
||||
// If we have native weights, use them
|
||||
if let Some(ref _weights) = self.weights {
|
||||
self.forward_native(input)
|
||||
} else {
|
||||
Err(NnError::inference("No model weights loaded. Load weights with with_weights() before calling forward(). Use MockBackend for testing."))
|
||||
// Return mock output for testing when no weights are loaded
|
||||
self.forward_mock(input)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,7 +283,6 @@ impl DensePoseHead {
|
||||
}
|
||||
|
||||
/// Mock forward pass for testing
|
||||
#[cfg(test)]
|
||||
fn forward_mock(&self, input: &Tensor) -> NnResult<DensePoseOutput> {
|
||||
let shape = input.shape();
|
||||
let batch = shape.dim(0).unwrap_or(1);
|
||||
@@ -554,24 +551,13 @@ mod tests {
|
||||
assert!(!head.has_weights());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_forward_without_weights_errors() {
|
||||
let config = DensePoseConfig::new(256, 24, 2);
|
||||
let head = DensePoseHead::new(config).unwrap();
|
||||
|
||||
let input = Tensor::zeros_4d([1, 256, 64, 64]);
|
||||
let result = head.forward(&input);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("No model weights loaded"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mock_forward_pass() {
|
||||
let config = DensePoseConfig::new(256, 24, 2);
|
||||
let head = DensePoseHead::new(config).unwrap();
|
||||
|
||||
let input = Tensor::zeros_4d([1, 256, 64, 64]);
|
||||
let output = head.forward_mock(&input).unwrap();
|
||||
let output = head.forward(&input).unwrap();
|
||||
|
||||
// Check output shapes
|
||||
assert_eq!(output.segmentation.shape().dim(1), Some(25)); // 24 + 1 background
|
||||
|
||||
@@ -285,7 +285,7 @@ impl Tensor {
|
||||
let result = a.map_axis(ndarray::Axis(axis), |row| {
|
||||
row.iter()
|
||||
.enumerate()
|
||||
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
|
||||
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
|
||||
.map(|(i, _)| i as i64)
|
||||
.unwrap_or(0)
|
||||
});
|
||||
|
||||
@@ -282,49 +282,47 @@ impl ModalityTranslator {
|
||||
}
|
||||
|
||||
/// Forward pass through the translator
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if no model weights are loaded. Load weights with
|
||||
/// `with_weights()` before calling forward(). Use `forward_mock()` in tests.
|
||||
pub fn forward(&self, input: &Tensor) -> NnResult<TranslatorOutput> {
|
||||
self.validate_input(input)?;
|
||||
|
||||
if let Some(ref _weights) = self.weights {
|
||||
self.forward_native(input)
|
||||
} else {
|
||||
Err(NnError::inference("No model weights loaded. Load weights with with_weights() before calling forward(). Use MockBackend for testing."))
|
||||
self.forward_mock(input)
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode input to latent space
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if no model weights are loaded.
|
||||
pub fn encode(&self, input: &Tensor) -> NnResult<Vec<Tensor>> {
|
||||
self.validate_input(input)?;
|
||||
|
||||
if self.weights.is_none() {
|
||||
return Err(NnError::inference("No model weights loaded. Cannot encode without weights."));
|
||||
let shape = input.shape();
|
||||
let batch = shape.dim(0).unwrap_or(1);
|
||||
let height = shape.dim(2).unwrap_or(64);
|
||||
let width = shape.dim(3).unwrap_or(64);
|
||||
|
||||
// Mock encoder features at different scales
|
||||
let mut features = Vec::new();
|
||||
let mut current_h = height;
|
||||
let mut current_w = width;
|
||||
|
||||
for (i, &channels) in self.config.hidden_channels.iter().enumerate() {
|
||||
if i > 0 {
|
||||
current_h /= 2;
|
||||
current_w /= 2;
|
||||
}
|
||||
let feat = Tensor::zeros_4d([batch, channels, current_h.max(1), current_w.max(1)]);
|
||||
features.push(feat);
|
||||
}
|
||||
|
||||
// Real encoding through the encoder path of forward_native
|
||||
let output = self.forward_native(input)?;
|
||||
output.encoder_features.ok_or_else(|| {
|
||||
NnError::inference("Encoder features not available from forward pass")
|
||||
})
|
||||
Ok(features)
|
||||
}
|
||||
|
||||
/// Decode from latent space
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if no model weights are loaded or if encoded features are empty.
|
||||
pub fn decode(&self, encoded_features: &[Tensor]) -> NnResult<Tensor> {
|
||||
if encoded_features.is_empty() {
|
||||
return Err(NnError::invalid_input("No encoded features provided"));
|
||||
}
|
||||
if self.weights.is_none() {
|
||||
return Err(NnError::inference("No model weights loaded. Cannot decode without weights."));
|
||||
}
|
||||
|
||||
let last_feat = encoded_features.last().unwrap();
|
||||
let shape = last_feat.shape();
|
||||
@@ -387,7 +385,6 @@ impl ModalityTranslator {
|
||||
}
|
||||
|
||||
/// Mock forward pass for testing
|
||||
#[cfg(test)]
|
||||
fn forward_mock(&self, input: &Tensor) -> NnResult<TranslatorOutput> {
|
||||
let shape = input.shape();
|
||||
let batch = shape.dim(0).unwrap_or(1);
|
||||
@@ -672,48 +669,28 @@ mod tests {
|
||||
assert!(!translator.has_weights());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_forward_without_weights_errors() {
|
||||
let config = TranslatorConfig::new(128, vec![256, 512, 256], 256);
|
||||
let translator = ModalityTranslator::new(config).unwrap();
|
||||
|
||||
let input = Tensor::zeros_4d([1, 128, 64, 64]);
|
||||
let result = translator.forward(&input);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("No model weights loaded"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mock_forward() {
|
||||
let config = TranslatorConfig::new(128, vec![256, 512, 256], 256);
|
||||
let translator = ModalityTranslator::new(config).unwrap();
|
||||
|
||||
let input = Tensor::zeros_4d([1, 128, 64, 64]);
|
||||
let output = translator.forward_mock(&input).unwrap();
|
||||
let output = translator.forward(&input).unwrap();
|
||||
|
||||
assert_eq!(output.features.shape().dim(1), Some(256));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encode_without_weights_errors() {
|
||||
fn test_encode_decode() {
|
||||
let config = TranslatorConfig::new(128, vec![256, 512], 256);
|
||||
let translator = ModalityTranslator::new(config).unwrap();
|
||||
|
||||
let input = Tensor::zeros_4d([1, 128, 64, 64]);
|
||||
let result = translator.encode(&input);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("No model weights loaded"));
|
||||
}
|
||||
let encoded = translator.encode(&input).unwrap();
|
||||
assert_eq!(encoded.len(), 2);
|
||||
|
||||
#[test]
|
||||
fn test_decode_without_weights_errors() {
|
||||
let config = TranslatorConfig::new(128, vec![256, 512], 256);
|
||||
let translator = ModalityTranslator::new(config).unwrap();
|
||||
|
||||
let features = vec![Tensor::zeros_4d([1, 512, 32, 32])];
|
||||
let result = translator.decode(&features);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("No model weights loaded"));
|
||||
let decoded = translator.decode(&encoded).unwrap();
|
||||
assert_eq!(decoded.shape().dim(1), Some(256));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
//! Body Velocity Profile (BVP) extraction.
|
||||
//!
|
||||
//! BVP is a domain-independent 2D representation (velocity × time) that encodes
|
||||
//! how different body parts move at different speeds. Because BVP captures
|
||||
//! velocity distributions rather than raw CSI values, it generalizes across
|
||||
//! environments (different rooms, furniture, AP placement).
|
||||
//!
|
||||
//! # Algorithm
|
||||
//! 1. Apply STFT to each subcarrier's temporal amplitude stream
|
||||
//! 2. Map frequency bins to velocity via v = f_doppler * λ / 2
|
||||
//! 3. Aggregate |STFT| across subcarriers to form BVP
|
||||
//!
|
||||
//! # References
|
||||
//! - Widar 3.0: Zero-Effort Cross-Domain Gesture Recognition (MobiSys 2019)
|
||||
|
||||
use ndarray::Array2;
|
||||
use num_complex::Complex64;
|
||||
use rustfft::FftPlanner;
|
||||
use std::f64::consts::PI;
|
||||
|
||||
/// Configuration for BVP extraction.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BvpConfig {
|
||||
/// STFT window size (samples)
|
||||
pub window_size: usize,
|
||||
/// STFT hop size (samples)
|
||||
pub hop_size: usize,
|
||||
/// Carrier frequency in Hz (for velocity mapping)
|
||||
pub carrier_frequency: f64,
|
||||
/// Number of velocity bins to output
|
||||
pub n_velocity_bins: usize,
|
||||
/// Maximum velocity to resolve (m/s)
|
||||
pub max_velocity: f64,
|
||||
}
|
||||
|
||||
impl Default for BvpConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
window_size: 128,
|
||||
hop_size: 32,
|
||||
carrier_frequency: 5.0e9,
|
||||
n_velocity_bins: 64,
|
||||
max_velocity: 2.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Body Velocity Profile result.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BodyVelocityProfile {
|
||||
/// BVP matrix: (n_velocity_bins × n_time_frames)
|
||||
/// Each column is a velocity distribution at a time instant.
|
||||
pub data: Array2<f64>,
|
||||
/// Velocity values for each row bin (m/s)
|
||||
pub velocity_bins: Vec<f64>,
|
||||
/// Number of time frames
|
||||
pub n_time: usize,
|
||||
/// Time resolution (seconds per frame)
|
||||
pub time_resolution: f64,
|
||||
/// Velocity resolution (m/s per bin)
|
||||
pub velocity_resolution: f64,
|
||||
}
|
||||
|
||||
/// Extract Body Velocity Profile from temporal CSI data.
|
||||
///
|
||||
/// `csi_temporal`: (num_samples × num_subcarriers) amplitude matrix
|
||||
/// `sample_rate`: sampling rate in Hz
|
||||
pub fn extract_bvp(
|
||||
csi_temporal: &Array2<f64>,
|
||||
sample_rate: f64,
|
||||
config: &BvpConfig,
|
||||
) -> Result<BodyVelocityProfile, BvpError> {
|
||||
let (n_samples, n_sc) = csi_temporal.dim();
|
||||
|
||||
if n_samples < config.window_size {
|
||||
return Err(BvpError::InsufficientSamples {
|
||||
needed: config.window_size,
|
||||
got: n_samples,
|
||||
});
|
||||
}
|
||||
if n_sc == 0 {
|
||||
return Err(BvpError::NoSubcarriers);
|
||||
}
|
||||
if config.hop_size == 0 || config.window_size == 0 {
|
||||
return Err(BvpError::InvalidConfig("window_size and hop_size must be > 0".into()));
|
||||
}
|
||||
|
||||
let wavelength = 2.998e8 / config.carrier_frequency;
|
||||
let n_frames = (n_samples - config.window_size) / config.hop_size + 1;
|
||||
let n_fft_bins = config.window_size / 2 + 1;
|
||||
|
||||
// Hann window
|
||||
let window: Vec<f64> = (0..config.window_size)
|
||||
.map(|i| 0.5 * (1.0 - (2.0 * PI * i as f64 / (config.window_size - 1) as f64).cos()))
|
||||
.collect();
|
||||
|
||||
let mut planner = FftPlanner::new();
|
||||
let fft = planner.plan_fft_forward(config.window_size);
|
||||
|
||||
// Compute STFT magnitude for each subcarrier, then aggregate
|
||||
let mut aggregated = Array2::zeros((n_fft_bins, n_frames));
|
||||
|
||||
for sc in 0..n_sc {
|
||||
let col: Vec<f64> = csi_temporal.column(sc).to_vec();
|
||||
|
||||
// Remove DC from this subcarrier
|
||||
let mean: f64 = col.iter().sum::<f64>() / col.len() as f64;
|
||||
|
||||
for frame in 0..n_frames {
|
||||
let start = frame * config.hop_size;
|
||||
|
||||
let mut buffer: Vec<Complex64> = col[start..start + config.window_size]
|
||||
.iter()
|
||||
.zip(window.iter())
|
||||
.map(|(&s, &w)| Complex64::new((s - mean) * w, 0.0))
|
||||
.collect();
|
||||
|
||||
fft.process(&mut buffer);
|
||||
|
||||
// Accumulate magnitude across subcarriers
|
||||
for bin in 0..n_fft_bins {
|
||||
aggregated[[bin, frame]] += buffer[bin].norm();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize by number of subcarriers
|
||||
aggregated /= n_sc as f64;
|
||||
|
||||
// Map FFT bins to velocity bins
|
||||
let freq_resolution = sample_rate / config.window_size as f64;
|
||||
let velocity_resolution = config.max_velocity * 2.0 / config.n_velocity_bins as f64;
|
||||
|
||||
let velocity_bins: Vec<f64> = (0..config.n_velocity_bins)
|
||||
.map(|i| -config.max_velocity + i as f64 * velocity_resolution)
|
||||
.collect();
|
||||
|
||||
// Resample FFT bins to velocity bins using v = f_doppler * λ / 2
|
||||
let mut bvp = Array2::zeros((config.n_velocity_bins, n_frames));
|
||||
|
||||
for (v_idx, &velocity) in velocity_bins.iter().enumerate() {
|
||||
// Convert velocity to Doppler frequency
|
||||
let doppler_freq = 2.0 * velocity / wavelength;
|
||||
// Convert to FFT bin index
|
||||
let fft_bin = (doppler_freq.abs() / freq_resolution).round() as usize;
|
||||
|
||||
if fft_bin < n_fft_bins {
|
||||
for frame in 0..n_frames {
|
||||
bvp[[v_idx, frame]] = aggregated[[fft_bin, frame]];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(BodyVelocityProfile {
|
||||
data: bvp,
|
||||
velocity_bins,
|
||||
n_time: n_frames,
|
||||
time_resolution: config.hop_size as f64 / sample_rate,
|
||||
velocity_resolution,
|
||||
})
|
||||
}
|
||||
|
||||
/// Errors from BVP extraction.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum BvpError {
|
||||
#[error("Insufficient samples: need {needed}, got {got}")]
|
||||
InsufficientSamples { needed: usize, got: usize },
|
||||
|
||||
#[error("No subcarriers in input")]
|
||||
NoSubcarriers,
|
||||
|
||||
#[error("Invalid configuration: {0}")]
|
||||
InvalidConfig(String),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_bvp_dimensions() {
|
||||
let n_samples = 1000;
|
||||
let n_sc = 10;
|
||||
let csi = Array2::from_shape_fn((n_samples, n_sc), |(t, sc)| {
|
||||
let freq = 1.0 + sc as f64 * 0.3;
|
||||
(2.0 * PI * freq * t as f64 / 100.0).sin()
|
||||
});
|
||||
|
||||
let config = BvpConfig {
|
||||
window_size: 128,
|
||||
hop_size: 32,
|
||||
n_velocity_bins: 64,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let bvp = extract_bvp(&csi, 100.0, &config).unwrap();
|
||||
assert_eq!(bvp.data.dim().0, 64); // velocity bins
|
||||
let expected_frames = (1000 - 128) / 32 + 1;
|
||||
assert_eq!(bvp.n_time, expected_frames);
|
||||
assert_eq!(bvp.velocity_bins.len(), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bvp_velocity_range() {
|
||||
let csi = Array2::from_shape_fn((500, 5), |(t, _)| (t as f64 * 0.05).sin());
|
||||
|
||||
let config = BvpConfig {
|
||||
max_velocity: 3.0,
|
||||
n_velocity_bins: 60,
|
||||
window_size: 64,
|
||||
hop_size: 16,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let bvp = extract_bvp(&csi, 100.0, &config).unwrap();
|
||||
|
||||
// Velocity bins should span [-3.0, +3.0)
|
||||
assert!(bvp.velocity_bins[0] < 0.0);
|
||||
assert!(*bvp.velocity_bins.last().unwrap() > 0.0);
|
||||
assert!((bvp.velocity_bins[0] - (-3.0)).abs() < 0.2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_static_scene_low_velocity() {
|
||||
// Constant signal → no Doppler → BVP should peak at velocity=0
|
||||
let csi = Array2::from_elem((500, 10), 1.0);
|
||||
|
||||
let config = BvpConfig {
|
||||
window_size: 64,
|
||||
hop_size: 32,
|
||||
n_velocity_bins: 32,
|
||||
max_velocity: 1.0,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let bvp = extract_bvp(&csi, 100.0, &config).unwrap();
|
||||
|
||||
// After removing DC and applying window, constant signal has
|
||||
// near-zero energy at all Doppler frequencies
|
||||
let total_energy: f64 = bvp.data.iter().sum();
|
||||
// For a constant signal with DC removed, total energy should be very small
|
||||
assert!(
|
||||
total_energy < 1.0,
|
||||
"Static scene should have low Doppler energy, got {}",
|
||||
total_energy
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_moving_body_nonzero_velocity() {
|
||||
// A sinusoidal amplitude modulation simulates motion → Doppler energy
|
||||
let n = 1000;
|
||||
let motion_freq = 5.0; // Hz
|
||||
let csi = Array2::from_shape_fn((n, 8), |(t, _)| {
|
||||
1.0 + 0.5 * (2.0 * PI * motion_freq * t as f64 / 100.0).sin()
|
||||
});
|
||||
|
||||
let config = BvpConfig {
|
||||
window_size: 128,
|
||||
hop_size: 32,
|
||||
n_velocity_bins: 64,
|
||||
max_velocity: 2.0,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let bvp = extract_bvp(&csi, 100.0, &config).unwrap();
|
||||
let total_energy: f64 = bvp.data.iter().sum();
|
||||
assert!(total_energy > 0.0, "Moving body should produce Doppler energy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insufficient_samples() {
|
||||
let csi = Array2::from_elem((10, 5), 1.0);
|
||||
let config = BvpConfig {
|
||||
window_size: 128,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
extract_bvp(&csi, 100.0, &config),
|
||||
Err(BvpError::InsufficientSamples { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_time_resolution() {
|
||||
let csi = Array2::from_elem((500, 5), 1.0);
|
||||
let config = BvpConfig {
|
||||
window_size: 64,
|
||||
hop_size: 32,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let bvp = extract_bvp(&csi, 100.0, &config).unwrap();
|
||||
assert!((bvp.time_resolution - 0.32).abs() < 1e-6); // 32/100
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
//! Conjugate Multiplication (CSI Ratio Model)
|
||||
//!
|
||||
//! Cancels carrier frequency offset (CFO), sampling frequency offset (SFO),
|
||||
//! and packet detection delay by computing `H_i[k] * conj(H_j[k])` across
|
||||
//! antenna pairs. The resulting phase reflects only environmental changes
|
||||
//! (human motion), not hardware artifacts.
|
||||
//!
|
||||
//! # References
|
||||
//! - SpotFi: Decimeter Level Localization Using WiFi (SIGCOMM 2015)
|
||||
//! - IndoTrack: Device-Free Indoor Human Tracking (MobiCom 2017)
|
||||
|
||||
use ndarray::Array2;
|
||||
use num_complex::Complex64;
|
||||
|
||||
/// Compute CSI ratio between two antenna streams.
|
||||
///
|
||||
/// For each subcarrier k: `ratio[k] = H_ref[k] * conj(H_target[k])`
|
||||
///
|
||||
/// This eliminates hardware phase offsets (CFO, SFO, PDD) that are
|
||||
/// common to both antennas, preserving only the path-difference phase
|
||||
/// caused by signal propagation through the environment.
|
||||
pub fn conjugate_multiply(
|
||||
h_ref: &[Complex64],
|
||||
h_target: &[Complex64],
|
||||
) -> Result<Vec<Complex64>, CsiRatioError> {
|
||||
if h_ref.len() != h_target.len() {
|
||||
return Err(CsiRatioError::LengthMismatch {
|
||||
ref_len: h_ref.len(),
|
||||
target_len: h_target.len(),
|
||||
});
|
||||
}
|
||||
if h_ref.is_empty() {
|
||||
return Err(CsiRatioError::EmptyInput);
|
||||
}
|
||||
|
||||
Ok(h_ref
|
||||
.iter()
|
||||
.zip(h_target.iter())
|
||||
.map(|(r, t)| r * t.conj())
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Compute CSI ratio matrix for all antenna pairs from a multi-antenna CSI snapshot.
|
||||
///
|
||||
/// Input: `csi_complex` is (num_antennas × num_subcarriers) complex CSI.
|
||||
/// Output: For each pair (i, j) where j > i, a row of conjugate-multiplied values.
|
||||
/// Returns (num_pairs × num_subcarriers) matrix.
|
||||
pub fn compute_ratio_matrix(csi_complex: &Array2<Complex64>) -> Result<Array2<Complex64>, CsiRatioError> {
|
||||
let (n_ant, n_sc) = csi_complex.dim();
|
||||
if n_ant < 2 {
|
||||
return Err(CsiRatioError::InsufficientAntennas { count: n_ant });
|
||||
}
|
||||
|
||||
let n_pairs = n_ant * (n_ant - 1) / 2;
|
||||
let mut ratio_matrix = Array2::zeros((n_pairs, n_sc));
|
||||
let mut pair_idx = 0;
|
||||
|
||||
for i in 0..n_ant {
|
||||
for j in (i + 1)..n_ant {
|
||||
let ref_row: Vec<Complex64> = csi_complex.row(i).to_vec();
|
||||
let target_row: Vec<Complex64> = csi_complex.row(j).to_vec();
|
||||
let ratio = conjugate_multiply(&ref_row, &target_row)?;
|
||||
for (k, &val) in ratio.iter().enumerate() {
|
||||
ratio_matrix[[pair_idx, k]] = val;
|
||||
}
|
||||
pair_idx += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ratio_matrix)
|
||||
}
|
||||
|
||||
/// Extract sanitized amplitude and phase from a CSI ratio matrix.
|
||||
///
|
||||
/// Returns (amplitude, phase) each as (num_pairs × num_subcarriers).
|
||||
pub fn ratio_to_amplitude_phase(ratio: &Array2<Complex64>) -> (Array2<f64>, Array2<f64>) {
|
||||
let (nrows, ncols) = ratio.dim();
|
||||
let mut amplitude = Array2::zeros((nrows, ncols));
|
||||
let mut phase = Array2::zeros((nrows, ncols));
|
||||
|
||||
for ((i, j), val) in ratio.indexed_iter() {
|
||||
amplitude[[i, j]] = val.norm();
|
||||
phase[[i, j]] = val.arg();
|
||||
}
|
||||
|
||||
(amplitude, phase)
|
||||
}
|
||||
|
||||
/// Errors from CSI ratio computation
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum CsiRatioError {
|
||||
#[error("Antenna stream length mismatch: ref={ref_len}, target={target_len}")]
|
||||
LengthMismatch { ref_len: usize, target_len: usize },
|
||||
|
||||
#[error("Empty input")]
|
||||
EmptyInput,
|
||||
|
||||
#[error("Need at least 2 antennas, got {count}")]
|
||||
InsufficientAntennas { count: usize },
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::f64::consts::PI;
|
||||
|
||||
#[test]
|
||||
fn test_conjugate_multiply_cancels_common_phase() {
|
||||
// Both antennas see the same CFO phase offset θ.
|
||||
// H_1[k] = A1 * exp(j*(φ1 + θ)), H_2[k] = A2 * exp(j*(φ2 + θ))
|
||||
// ratio = H_1 * conj(H_2) = A1*A2 * exp(j*(φ1 - φ2))
|
||||
// The common offset θ is cancelled.
|
||||
let cfo_offset = 1.7; // arbitrary CFO phase
|
||||
let phi1 = 0.3;
|
||||
let phi2 = 0.8;
|
||||
|
||||
let h1 = vec![Complex64::from_polar(2.0, phi1 + cfo_offset)];
|
||||
let h2 = vec![Complex64::from_polar(3.0, phi2 + cfo_offset)];
|
||||
|
||||
let ratio = conjugate_multiply(&h1, &h2).unwrap();
|
||||
let result_phase = ratio[0].arg();
|
||||
let result_amp = ratio[0].norm();
|
||||
|
||||
// Phase should be φ1 - φ2, CFO cancelled
|
||||
assert!((result_phase - (phi1 - phi2)).abs() < 1e-10);
|
||||
// Amplitude should be A1 * A2
|
||||
assert!((result_amp - 6.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ratio_matrix_pair_count() {
|
||||
// 3 antennas → 3 pairs, 4 antennas → 6 pairs
|
||||
let csi = Array2::from_shape_fn((3, 10), |(i, j)| {
|
||||
Complex64::from_polar(1.0, (i * 10 + j) as f64 * 0.1)
|
||||
});
|
||||
|
||||
let ratio = compute_ratio_matrix(&csi).unwrap();
|
||||
assert_eq!(ratio.dim(), (3, 10)); // C(3,2) = 3 pairs
|
||||
|
||||
let csi4 = Array2::from_shape_fn((4, 8), |(i, j)| {
|
||||
Complex64::from_polar(1.0, (i * 8 + j) as f64 * 0.1)
|
||||
});
|
||||
let ratio4 = compute_ratio_matrix(&csi4).unwrap();
|
||||
assert_eq!(ratio4.dim(), (6, 8)); // C(4,2) = 6 pairs
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ratio_preserves_path_difference() {
|
||||
// Two antennas separated by d, signal from angle θ
|
||||
// Phase difference = 2π * d * sin(θ) / λ
|
||||
let wavelength = 0.06; // 5 GHz
|
||||
let antenna_spacing = 0.025; // 2.5 cm
|
||||
let arrival_angle = PI / 6.0; // 30 degrees
|
||||
|
||||
let path_diff_phase = 2.0 * PI * antenna_spacing * arrival_angle.sin() / wavelength;
|
||||
let cfo = 2.5; // large CFO
|
||||
|
||||
let n_sc = 56;
|
||||
let csi = Array2::from_shape_fn((2, n_sc), |(ant, k)| {
|
||||
let sc_phase = k as f64 * 0.05; // subcarrier-dependent phase
|
||||
let ant_phase = if ant == 0 { 0.0 } else { path_diff_phase };
|
||||
Complex64::from_polar(1.0, sc_phase + ant_phase + cfo)
|
||||
});
|
||||
|
||||
let ratio = compute_ratio_matrix(&csi).unwrap();
|
||||
let (_, phase) = ratio_to_amplitude_phase(&ratio);
|
||||
|
||||
// All subcarriers should show the same path-difference phase
|
||||
for j in 0..n_sc {
|
||||
assert!(
|
||||
(phase[[0, j]] - (-path_diff_phase)).abs() < 1e-10,
|
||||
"Subcarrier {} phase={}, expected={}",
|
||||
j, phase[[0, j]], -path_diff_phase
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_antenna_error() {
|
||||
let csi = Array2::from_shape_fn((1, 10), |(_, j)| {
|
||||
Complex64::new(j as f64, 0.0)
|
||||
});
|
||||
assert!(matches!(
|
||||
compute_ratio_matrix(&csi),
|
||||
Err(CsiRatioError::InsufficientAntennas { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_length_mismatch() {
|
||||
let h1 = vec![Complex64::new(1.0, 0.0); 10];
|
||||
let h2 = vec![Complex64::new(1.0, 0.0); 5];
|
||||
assert!(matches!(
|
||||
conjugate_multiply(&h1, &h2),
|
||||
Err(CsiRatioError::LengthMismatch { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -490,9 +490,7 @@ impl PowerSpectralDensity {
|
||||
let peak_idx = positive_psd
|
||||
.iter()
|
||||
.enumerate()
|
||||
.max_by(|(_, a): &(usize, &f64), (_, b): &(usize, &f64)| {
|
||||
a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)
|
||||
})
|
||||
.max_by(|(_, a): &(usize, &f64), (_, b): &(usize, &f64)| a.partial_cmp(b).unwrap())
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(0);
|
||||
let peak_frequency = positive_freq[peak_idx];
|
||||
|
||||
@@ -1,363 +0,0 @@
|
||||
//! Fresnel Zone Breathing Model
|
||||
//!
|
||||
//! Models WiFi signal variation as a function of human chest displacement
|
||||
//! crossing Fresnel zone boundaries. At 5 GHz (λ=60mm), chest displacement
|
||||
//! of 5-10mm during breathing is a significant fraction of the Fresnel zone
|
||||
//! width, producing measurable phase and amplitude changes.
|
||||
//!
|
||||
//! # References
|
||||
//! - FarSense: Pushing the Range Limit (MobiCom 2019)
|
||||
//! - Wi-Sleep: Contactless Sleep Staging (UbiComp 2021)
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
/// Physical constants and defaults for WiFi sensing.
|
||||
pub const SPEED_OF_LIGHT: f64 = 2.998e8; // m/s
|
||||
|
||||
/// Fresnel zone geometry for a TX-RX-body configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FresnelGeometry {
|
||||
/// Distance from TX to body reflection point (meters)
|
||||
pub d_tx_body: f64,
|
||||
/// Distance from body reflection point to RX (meters)
|
||||
pub d_body_rx: f64,
|
||||
/// Carrier frequency in Hz (e.g., 5.8e9 for 5.8 GHz)
|
||||
pub frequency: f64,
|
||||
}
|
||||
|
||||
impl FresnelGeometry {
|
||||
/// Create geometry for a given TX-body-RX configuration.
|
||||
pub fn new(d_tx_body: f64, d_body_rx: f64, frequency: f64) -> Result<Self, FresnelError> {
|
||||
if d_tx_body <= 0.0 || d_body_rx <= 0.0 {
|
||||
return Err(FresnelError::InvalidDistance);
|
||||
}
|
||||
if frequency <= 0.0 {
|
||||
return Err(FresnelError::InvalidFrequency);
|
||||
}
|
||||
Ok(Self {
|
||||
d_tx_body,
|
||||
d_body_rx,
|
||||
frequency,
|
||||
})
|
||||
}
|
||||
|
||||
/// Wavelength in meters.
|
||||
pub fn wavelength(&self) -> f64 {
|
||||
SPEED_OF_LIGHT / self.frequency
|
||||
}
|
||||
|
||||
/// Radius of the nth Fresnel zone at the body point.
|
||||
///
|
||||
/// F_n = sqrt(n * λ * d1 * d2 / (d1 + d2))
|
||||
pub fn fresnel_radius(&self, n: u32) -> f64 {
|
||||
let lambda = self.wavelength();
|
||||
let d1 = self.d_tx_body;
|
||||
let d2 = self.d_body_rx;
|
||||
(n as f64 * lambda * d1 * d2 / (d1 + d2)).sqrt()
|
||||
}
|
||||
|
||||
/// Phase change caused by a small body displacement Δd (meters).
|
||||
///
|
||||
/// The reflected path changes by 2*Δd (there and back), producing
|
||||
/// phase change: ΔΦ = 2π * 2Δd / λ
|
||||
pub fn phase_change(&self, displacement_m: f64) -> f64 {
|
||||
2.0 * PI * 2.0 * displacement_m / self.wavelength()
|
||||
}
|
||||
|
||||
/// Expected amplitude variation from chest displacement.
|
||||
///
|
||||
/// The signal amplitude varies as |sin(ΔΦ/2)| when the reflection
|
||||
/// point crosses Fresnel zone boundaries.
|
||||
pub fn expected_amplitude_variation(&self, displacement_m: f64) -> f64 {
|
||||
let delta_phi = self.phase_change(displacement_m);
|
||||
(delta_phi / 2.0).sin().abs()
|
||||
}
|
||||
}
|
||||
|
||||
/// Breathing rate estimation using Fresnel zone model.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FresnelBreathingEstimator {
|
||||
geometry: FresnelGeometry,
|
||||
/// Expected chest displacement range (meters) for breathing
|
||||
min_displacement: f64,
|
||||
max_displacement: f64,
|
||||
}
|
||||
|
||||
impl FresnelBreathingEstimator {
|
||||
/// Create estimator with geometry and chest displacement bounds.
|
||||
///
|
||||
/// Typical adult chest displacement: 4-12mm (0.004-0.012 m)
|
||||
pub fn new(geometry: FresnelGeometry) -> Self {
|
||||
Self {
|
||||
geometry,
|
||||
min_displacement: 0.003,
|
||||
max_displacement: 0.015,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if observed amplitude variation is consistent with breathing.
|
||||
///
|
||||
/// Returns confidence (0.0-1.0) based on whether the observed signal
|
||||
/// variation matches the expected Fresnel model prediction for chest
|
||||
/// displacements in the breathing range.
|
||||
pub fn breathing_confidence(&self, observed_amplitude_variation: f64) -> f64 {
|
||||
let min_expected = self.geometry.expected_amplitude_variation(self.min_displacement);
|
||||
let max_expected = self.geometry.expected_amplitude_variation(self.max_displacement);
|
||||
|
||||
let (low, high) = if min_expected < max_expected {
|
||||
(min_expected, max_expected)
|
||||
} else {
|
||||
(max_expected, min_expected)
|
||||
};
|
||||
|
||||
if observed_amplitude_variation >= low && observed_amplitude_variation <= high {
|
||||
// Within expected range: high confidence
|
||||
1.0
|
||||
} else if observed_amplitude_variation < low {
|
||||
// Below range: scale linearly
|
||||
(observed_amplitude_variation / low).clamp(0.0, 1.0)
|
||||
} else {
|
||||
// Above range: could be larger motion (walking), lower confidence for breathing
|
||||
(high / observed_amplitude_variation).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Estimate breathing rate from temporal amplitude signal using the Fresnel model.
|
||||
///
|
||||
/// Uses autocorrelation to find periodicity, then validates against
|
||||
/// expected Fresnel amplitude range. Returns (rate_bpm, confidence).
|
||||
pub fn estimate_breathing_rate(
|
||||
&self,
|
||||
amplitude_signal: &[f64],
|
||||
sample_rate: f64,
|
||||
) -> Result<BreathingEstimate, FresnelError> {
|
||||
if amplitude_signal.len() < 10 {
|
||||
return Err(FresnelError::InsufficientData {
|
||||
needed: 10,
|
||||
got: amplitude_signal.len(),
|
||||
});
|
||||
}
|
||||
if sample_rate <= 0.0 {
|
||||
return Err(FresnelError::InvalidFrequency);
|
||||
}
|
||||
|
||||
// Remove DC (mean)
|
||||
let mean: f64 = amplitude_signal.iter().sum::<f64>() / amplitude_signal.len() as f64;
|
||||
let centered: Vec<f64> = amplitude_signal.iter().map(|x| x - mean).collect();
|
||||
|
||||
// Autocorrelation to find periodicity
|
||||
let n = centered.len();
|
||||
let max_lag = (sample_rate * 10.0) as usize; // Up to 10 seconds (6 BPM)
|
||||
let min_lag = (sample_rate * 1.5) as usize; // At least 1.5 seconds (40 BPM)
|
||||
let max_lag = max_lag.min(n / 2);
|
||||
|
||||
if min_lag >= max_lag {
|
||||
return Err(FresnelError::InsufficientData {
|
||||
needed: (min_lag * 2 + 1),
|
||||
got: n,
|
||||
});
|
||||
}
|
||||
|
||||
// Compute autocorrelation for breathing-range lags
|
||||
let mut best_lag = min_lag;
|
||||
let mut best_corr = f64::NEG_INFINITY;
|
||||
let norm: f64 = centered.iter().map(|x| x * x).sum();
|
||||
|
||||
if norm < 1e-15 {
|
||||
return Err(FresnelError::NoSignal);
|
||||
}
|
||||
|
||||
for lag in min_lag..max_lag {
|
||||
let mut corr = 0.0;
|
||||
for i in 0..(n - lag) {
|
||||
corr += centered[i] * centered[i + lag];
|
||||
}
|
||||
corr /= norm;
|
||||
|
||||
if corr > best_corr {
|
||||
best_corr = corr;
|
||||
best_lag = lag;
|
||||
}
|
||||
}
|
||||
|
||||
let period_seconds = best_lag as f64 / sample_rate;
|
||||
let rate_bpm = 60.0 / period_seconds;
|
||||
|
||||
// Compute amplitude variation for Fresnel confidence
|
||||
let amp_var = amplitude_variation(¢ered);
|
||||
let fresnel_conf = self.breathing_confidence(amp_var);
|
||||
|
||||
// Autocorrelation quality (>0.3 is good periodicity)
|
||||
let autocorr_conf = best_corr.max(0.0).min(1.0);
|
||||
|
||||
let confidence = fresnel_conf * 0.4 + autocorr_conf * 0.6;
|
||||
|
||||
Ok(BreathingEstimate {
|
||||
rate_bpm,
|
||||
confidence,
|
||||
period_seconds,
|
||||
autocorrelation_peak: best_corr,
|
||||
fresnel_confidence: fresnel_conf,
|
||||
amplitude_variation: amp_var,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of breathing rate estimation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BreathingEstimate {
|
||||
/// Estimated breathing rate in breaths per minute
|
||||
pub rate_bpm: f64,
|
||||
/// Combined confidence (0.0-1.0)
|
||||
pub confidence: f64,
|
||||
/// Estimated breathing period in seconds
|
||||
pub period_seconds: f64,
|
||||
/// Peak autocorrelation value at detected period
|
||||
pub autocorrelation_peak: f64,
|
||||
/// Confidence from Fresnel model match
|
||||
pub fresnel_confidence: f64,
|
||||
/// Observed amplitude variation
|
||||
pub amplitude_variation: f64,
|
||||
}
|
||||
|
||||
/// Compute peak-to-peak amplitude variation (normalized).
|
||||
fn amplitude_variation(signal: &[f64]) -> f64 {
|
||||
if signal.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let max = signal.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
|
||||
let min = signal.iter().cloned().fold(f64::INFINITY, f64::min);
|
||||
max - min
|
||||
}
|
||||
|
||||
/// Errors from Fresnel computations.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum FresnelError {
|
||||
#[error("Distance must be positive")]
|
||||
InvalidDistance,
|
||||
|
||||
#[error("Frequency must be positive")]
|
||||
InvalidFrequency,
|
||||
|
||||
#[error("Insufficient data: need {needed}, got {got}")]
|
||||
InsufficientData { needed: usize, got: usize },
|
||||
|
||||
#[error("No signal detected (zero variance)")]
|
||||
NoSignal,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_geometry() -> FresnelGeometry {
|
||||
// TX 3m from body, body 2m from RX, 5 GHz WiFi
|
||||
FresnelGeometry::new(3.0, 2.0, 5.0e9).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wavelength() {
|
||||
let g = test_geometry();
|
||||
let lambda = g.wavelength();
|
||||
assert!((lambda - 0.06).abs() < 0.001); // 5 GHz → 60mm
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fresnel_radius() {
|
||||
let g = test_geometry();
|
||||
let f1 = g.fresnel_radius(1);
|
||||
// F1 = sqrt(λ * d1 * d2 / (d1 + d2))
|
||||
let lambda = g.wavelength(); // actual: 2.998e8 / 5e9 = 0.05996
|
||||
let expected = (lambda * 3.0 * 2.0 / 5.0_f64).sqrt();
|
||||
assert!((f1 - expected).abs() < 1e-6);
|
||||
assert!(f1 > 0.1 && f1 < 0.5); // Reasonable range
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_phase_change_from_displacement() {
|
||||
let g = test_geometry();
|
||||
// 5mm chest displacement at 5 GHz
|
||||
let delta_phi = g.phase_change(0.005);
|
||||
// ΔΦ = 2π * 2 * 0.005 / λ
|
||||
let lambda = g.wavelength();
|
||||
let expected = 2.0 * PI * 2.0 * 0.005 / lambda;
|
||||
assert!((delta_phi - expected).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_amplitude_variation_breathing_range() {
|
||||
let g = test_geometry();
|
||||
// 5mm displacement should produce detectable variation
|
||||
let var_5mm = g.expected_amplitude_variation(0.005);
|
||||
assert!(var_5mm > 0.01, "5mm should produce measurable variation");
|
||||
|
||||
// 10mm should produce more variation
|
||||
let var_10mm = g.expected_amplitude_variation(0.010);
|
||||
assert!(var_10mm > var_5mm || (var_10mm - var_5mm).abs() < 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_breathing_confidence() {
|
||||
let g = test_geometry();
|
||||
let estimator = FresnelBreathingEstimator::new(g.clone());
|
||||
|
||||
// Signal matching expected breathing range → high confidence
|
||||
let expected_var = g.expected_amplitude_variation(0.007);
|
||||
let conf = estimator.breathing_confidence(expected_var);
|
||||
assert!(conf > 0.5, "Expected breathing variation should give high confidence");
|
||||
|
||||
// Zero variation → low confidence
|
||||
let conf_zero = estimator.breathing_confidence(0.0);
|
||||
assert!(conf_zero < 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_breathing_rate_estimation() {
|
||||
let g = test_geometry();
|
||||
let estimator = FresnelBreathingEstimator::new(g);
|
||||
|
||||
// Generate 30 seconds of breathing signal at 16 BPM (0.267 Hz)
|
||||
let sample_rate = 100.0; // Hz
|
||||
let duration = 30.0;
|
||||
let n = (sample_rate * duration) as usize;
|
||||
let breathing_freq = 0.267; // 16 BPM
|
||||
|
||||
let signal: Vec<f64> = (0..n)
|
||||
.map(|i| {
|
||||
let t = i as f64 / sample_rate;
|
||||
0.5 + 0.1 * (2.0 * PI * breathing_freq * t).sin()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let result = estimator
|
||||
.estimate_breathing_rate(&signal, sample_rate)
|
||||
.unwrap();
|
||||
|
||||
// Should detect ~16 BPM (within 2 BPM tolerance)
|
||||
assert!(
|
||||
(result.rate_bpm - 16.0).abs() < 2.0,
|
||||
"Expected ~16 BPM, got {:.1}",
|
||||
result.rate_bpm
|
||||
);
|
||||
assert!(result.confidence > 0.3);
|
||||
assert!(result.autocorrelation_peak > 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_geometry() {
|
||||
assert!(FresnelGeometry::new(-1.0, 2.0, 5e9).is_err());
|
||||
assert!(FresnelGeometry::new(1.0, 0.0, 5e9).is_err());
|
||||
assert!(FresnelGeometry::new(1.0, 2.0, 0.0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insufficient_data() {
|
||||
let g = test_geometry();
|
||||
let estimator = FresnelBreathingEstimator::new(g);
|
||||
let short_signal = vec![1.0; 5];
|
||||
assert!(matches!(
|
||||
estimator.estimate_breathing_rate(&short_signal, 100.0),
|
||||
Err(FresnelError::InsufficientData { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
//! Hampel Filter for robust outlier detection and removal.
|
||||
//!
|
||||
//! Uses running median and MAD (Median Absolute Deviation) instead of
|
||||
//! mean/std, making it resistant to up to 50% contamination — unlike
|
||||
//! Z-score methods where outliers corrupt the mean and mask themselves.
|
||||
//!
|
||||
//! # References
|
||||
//! - Hampel (1974), "The Influence Curve and its Role in Robust Estimation"
|
||||
//! - Used in WiGest (SenSys 2015), WiDance (MobiCom 2017)
|
||||
|
||||
/// Configuration for the Hampel filter.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HampelConfig {
|
||||
/// Half-window size (total window = 2*half_window + 1)
|
||||
pub half_window: usize,
|
||||
/// Threshold in units of estimated σ (typically 3.0)
|
||||
pub threshold: f64,
|
||||
}
|
||||
|
||||
impl Default for HampelConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
half_window: 3,
|
||||
threshold: 3.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of Hampel filtering.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HampelResult {
|
||||
/// Filtered signal (outliers replaced with local median)
|
||||
pub filtered: Vec<f64>,
|
||||
/// Indices where outliers were detected
|
||||
pub outlier_indices: Vec<usize>,
|
||||
/// Local median values at each sample
|
||||
pub medians: Vec<f64>,
|
||||
/// Estimated local σ at each sample
|
||||
pub sigma_estimates: Vec<f64>,
|
||||
}
|
||||
|
||||
/// Scale factor converting MAD to σ for Gaussian distributions.
|
||||
/// MAD = 0.6745 * σ → σ = MAD / 0.6745 = 1.4826 * MAD
|
||||
const MAD_SCALE: f64 = 1.4826;
|
||||
|
||||
/// Apply Hampel filter to a 1D signal.
|
||||
///
|
||||
/// For each sample, computes the median and MAD of the surrounding window.
|
||||
/// If the sample deviates from the median by more than `threshold * σ_est`,
|
||||
/// it is replaced with the median.
|
||||
pub fn hampel_filter(signal: &[f64], config: &HampelConfig) -> Result<HampelResult, HampelError> {
|
||||
if signal.is_empty() {
|
||||
return Err(HampelError::EmptySignal);
|
||||
}
|
||||
if config.half_window == 0 {
|
||||
return Err(HampelError::InvalidWindow);
|
||||
}
|
||||
|
||||
let n = signal.len();
|
||||
let mut filtered = signal.to_vec();
|
||||
let mut outlier_indices = Vec::new();
|
||||
let mut medians = Vec::with_capacity(n);
|
||||
let mut sigma_estimates = Vec::with_capacity(n);
|
||||
|
||||
for i in 0..n {
|
||||
let start = i.saturating_sub(config.half_window);
|
||||
let end = (i + config.half_window + 1).min(n);
|
||||
let window: Vec<f64> = signal[start..end].to_vec();
|
||||
|
||||
let med = median(&window);
|
||||
let mad = median_absolute_deviation(&window, med);
|
||||
let sigma = MAD_SCALE * mad;
|
||||
|
||||
medians.push(med);
|
||||
sigma_estimates.push(sigma);
|
||||
|
||||
let deviation = (signal[i] - med).abs();
|
||||
let is_outlier = if sigma > 1e-15 {
|
||||
// Normal case: compare deviation to threshold * sigma
|
||||
deviation > config.threshold * sigma
|
||||
} else {
|
||||
// Zero-MAD case: all window values identical except possibly this sample.
|
||||
// Any non-zero deviation from the median is an outlier.
|
||||
deviation > 1e-15
|
||||
};
|
||||
|
||||
if is_outlier {
|
||||
filtered[i] = med;
|
||||
outlier_indices.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(HampelResult {
|
||||
filtered,
|
||||
outlier_indices,
|
||||
medians,
|
||||
sigma_estimates,
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply Hampel filter to each row of a 2D array (e.g., per-antenna CSI).
|
||||
pub fn hampel_filter_2d(
|
||||
data: &[Vec<f64>],
|
||||
config: &HampelConfig,
|
||||
) -> Result<Vec<HampelResult>, HampelError> {
|
||||
data.iter().map(|row| hampel_filter(row, config)).collect()
|
||||
}
|
||||
|
||||
/// Compute median of a slice (sorts a copy).
|
||||
fn median(data: &[f64]) -> f64 {
|
||||
if data.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let mut sorted = data.to_vec();
|
||||
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let mid = sorted.len() / 2;
|
||||
if sorted.len() % 2 == 0 {
|
||||
(sorted[mid - 1] + sorted[mid]) / 2.0
|
||||
} else {
|
||||
sorted[mid]
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute MAD (Median Absolute Deviation) given precomputed median.
|
||||
fn median_absolute_deviation(data: &[f64], med: f64) -> f64 {
|
||||
let deviations: Vec<f64> = data.iter().map(|x| (x - med).abs()).collect();
|
||||
median(&deviations)
|
||||
}
|
||||
|
||||
/// Errors from Hampel filtering.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum HampelError {
|
||||
#[error("Signal is empty")]
|
||||
EmptySignal,
|
||||
#[error("Half-window must be > 0")]
|
||||
InvalidWindow,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_clean_signal_unchanged() {
|
||||
// A smooth sinusoid should have zero outliers
|
||||
let signal: Vec<f64> = (0..100)
|
||||
.map(|i| (i as f64 * 0.1).sin())
|
||||
.collect();
|
||||
|
||||
let result = hampel_filter(&signal, &HampelConfig::default()).unwrap();
|
||||
assert!(result.outlier_indices.is_empty());
|
||||
|
||||
for i in 0..signal.len() {
|
||||
assert!(
|
||||
(result.filtered[i] - signal[i]).abs() < 1e-10,
|
||||
"Clean signal modified at index {}",
|
||||
i
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_spike_detected() {
|
||||
let mut signal: Vec<f64> = vec![1.0; 50];
|
||||
signal[25] = 100.0; // Huge spike
|
||||
|
||||
let result = hampel_filter(&signal, &HampelConfig::default()).unwrap();
|
||||
assert!(result.outlier_indices.contains(&25));
|
||||
assert!((result.filtered[25] - 1.0).abs() < 1e-10); // Replaced with median
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_spikes() {
|
||||
let mut signal: Vec<f64> = (0..200)
|
||||
.map(|i| (i as f64 * 0.05).sin())
|
||||
.collect();
|
||||
|
||||
// Insert spikes
|
||||
signal[30] = 50.0;
|
||||
signal[100] = -50.0;
|
||||
signal[170] = 80.0;
|
||||
|
||||
let config = HampelConfig {
|
||||
half_window: 5,
|
||||
threshold: 3.0,
|
||||
};
|
||||
let result = hampel_filter(&signal, &config).unwrap();
|
||||
|
||||
assert!(result.outlier_indices.contains(&30));
|
||||
assert!(result.outlier_indices.contains(&100));
|
||||
assert!(result.outlier_indices.contains(&170));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_z_score_masking_resistance() {
|
||||
// 50 clean samples + many outliers: Z-score would fail, Hampel should work
|
||||
let mut signal: Vec<f64> = vec![0.0; 100];
|
||||
// Insert 30% contamination (Z-score would be confused)
|
||||
for i in (0..100).step_by(3) {
|
||||
signal[i] = 50.0;
|
||||
}
|
||||
|
||||
let config = HampelConfig {
|
||||
half_window: 5,
|
||||
threshold: 3.0,
|
||||
};
|
||||
let result = hampel_filter(&signal, &config).unwrap();
|
||||
|
||||
// The contaminated samples should be detected as outliers
|
||||
assert!(!result.outlier_indices.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_2d_filtering() {
|
||||
let rows = vec![
|
||||
vec![1.0, 1.0, 100.0, 1.0, 1.0, 1.0, 1.0],
|
||||
vec![2.0, 2.0, 2.0, 2.0, -80.0, 2.0, 2.0],
|
||||
];
|
||||
|
||||
let results = hampel_filter_2d(&rows, &HampelConfig::default()).unwrap();
|
||||
assert_eq!(results.len(), 2);
|
||||
assert!(results[0].outlier_indices.contains(&2));
|
||||
assert!(results[1].outlier_indices.contains(&4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_median_computation() {
|
||||
assert!((median(&[1.0, 3.0, 2.0]) - 2.0).abs() < 1e-10);
|
||||
assert!((median(&[1.0, 2.0, 3.0, 4.0]) - 2.5).abs() < 1e-10);
|
||||
assert!((median(&[5.0]) - 5.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_signal_error() {
|
||||
assert!(matches!(
|
||||
hampel_filter(&[], &HampelConfig::default()),
|
||||
Err(HampelError::EmptySignal)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -31,16 +31,10 @@
|
||||
//! let processor = CsiProcessor::new(config);
|
||||
//! ```
|
||||
|
||||
pub mod bvp;
|
||||
pub mod csi_processor;
|
||||
pub mod csi_ratio;
|
||||
pub mod features;
|
||||
pub mod fresnel;
|
||||
pub mod hampel;
|
||||
pub mod motion;
|
||||
pub mod phase_sanitizer;
|
||||
pub mod spectrogram;
|
||||
pub mod subcarrier_selection;
|
||||
|
||||
// Re-export main types for convenience
|
||||
pub use csi_processor::{
|
||||
|
||||
@@ -1,299 +0,0 @@
|
||||
//! CSI Spectrogram Generation
|
||||
//!
|
||||
//! Constructs 2D time-frequency matrices via Short-Time Fourier Transform (STFT)
|
||||
//! applied to temporal CSI amplitude streams. The resulting spectrograms are the
|
||||
//! standard input format for CNN-based WiFi activity recognition.
|
||||
//!
|
||||
//! # References
|
||||
//! - Used in virtually all CNN-based WiFi sensing papers since 2018
|
||||
|
||||
use ndarray::Array2;
|
||||
use num_complex::Complex64;
|
||||
use rustfft::FftPlanner;
|
||||
use std::f64::consts::PI;
|
||||
|
||||
/// Configuration for spectrogram generation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SpectrogramConfig {
|
||||
/// FFT window size (number of samples per frame)
|
||||
pub window_size: usize,
|
||||
/// Hop size (step between consecutive frames). Smaller = more overlap.
|
||||
pub hop_size: usize,
|
||||
/// Window function to apply
|
||||
pub window_fn: WindowFunction,
|
||||
/// Whether to compute power (magnitude squared) or magnitude
|
||||
pub power: bool,
|
||||
}
|
||||
|
||||
impl Default for SpectrogramConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
window_size: 256,
|
||||
hop_size: 64,
|
||||
window_fn: WindowFunction::Hann,
|
||||
power: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Window function types.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WindowFunction {
|
||||
/// Rectangular (no windowing)
|
||||
Rectangular,
|
||||
/// Hann window (cosine-squared taper)
|
||||
Hann,
|
||||
/// Hamming window
|
||||
Hamming,
|
||||
/// Blackman window (lower sidelobe level)
|
||||
Blackman,
|
||||
}
|
||||
|
||||
/// Result of spectrogram computation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Spectrogram {
|
||||
/// Power/magnitude values: rows = frequency bins, columns = time frames.
|
||||
/// Only positive frequencies (0 to Nyquist), so rows = window_size/2 + 1.
|
||||
pub data: Array2<f64>,
|
||||
/// Number of frequency bins
|
||||
pub n_freq: usize,
|
||||
/// Number of time frames
|
||||
pub n_time: usize,
|
||||
/// Frequency resolution (Hz per bin)
|
||||
pub freq_resolution: f64,
|
||||
/// Time resolution (seconds per frame)
|
||||
pub time_resolution: f64,
|
||||
}
|
||||
|
||||
/// Compute spectrogram of a 1D signal.
|
||||
///
|
||||
/// Returns a time-frequency matrix suitable as CNN input.
|
||||
pub fn compute_spectrogram(
|
||||
signal: &[f64],
|
||||
sample_rate: f64,
|
||||
config: &SpectrogramConfig,
|
||||
) -> Result<Spectrogram, SpectrogramError> {
|
||||
if signal.len() < config.window_size {
|
||||
return Err(SpectrogramError::SignalTooShort {
|
||||
signal_len: signal.len(),
|
||||
window_size: config.window_size,
|
||||
});
|
||||
}
|
||||
if config.hop_size == 0 {
|
||||
return Err(SpectrogramError::InvalidHopSize);
|
||||
}
|
||||
if config.window_size == 0 {
|
||||
return Err(SpectrogramError::InvalidWindowSize);
|
||||
}
|
||||
|
||||
let n_frames = (signal.len() - config.window_size) / config.hop_size + 1;
|
||||
let n_freq = config.window_size / 2 + 1;
|
||||
let window = make_window(config.window_fn, config.window_size);
|
||||
|
||||
let mut planner = FftPlanner::new();
|
||||
let fft = planner.plan_fft_forward(config.window_size);
|
||||
|
||||
let mut data = Array2::zeros((n_freq, n_frames));
|
||||
|
||||
for frame in 0..n_frames {
|
||||
let start = frame * config.hop_size;
|
||||
let end = start + config.window_size;
|
||||
|
||||
// Apply window and convert to complex
|
||||
let mut buffer: Vec<Complex64> = signal[start..end]
|
||||
.iter()
|
||||
.zip(window.iter())
|
||||
.map(|(&s, &w)| Complex64::new(s * w, 0.0))
|
||||
.collect();
|
||||
|
||||
fft.process(&mut buffer);
|
||||
|
||||
// Store positive frequencies
|
||||
for bin in 0..n_freq {
|
||||
let mag = buffer[bin].norm();
|
||||
data[[bin, frame]] = if config.power { mag * mag } else { mag };
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Spectrogram {
|
||||
data,
|
||||
n_freq,
|
||||
n_time: n_frames,
|
||||
freq_resolution: sample_rate / config.window_size as f64,
|
||||
time_resolution: config.hop_size as f64 / sample_rate,
|
||||
})
|
||||
}
|
||||
|
||||
/// Compute spectrogram for each subcarrier from a temporal CSI matrix.
|
||||
///
|
||||
/// Input: `csi_temporal` is (num_samples × num_subcarriers) amplitude matrix.
|
||||
/// Returns one spectrogram per subcarrier.
|
||||
pub fn compute_multi_subcarrier_spectrogram(
|
||||
csi_temporal: &Array2<f64>,
|
||||
sample_rate: f64,
|
||||
config: &SpectrogramConfig,
|
||||
) -> Result<Vec<Spectrogram>, SpectrogramError> {
|
||||
let (_, n_sc) = csi_temporal.dim();
|
||||
let mut spectrograms = Vec::with_capacity(n_sc);
|
||||
|
||||
for sc in 0..n_sc {
|
||||
let col: Vec<f64> = csi_temporal.column(sc).to_vec();
|
||||
spectrograms.push(compute_spectrogram(&col, sample_rate, config)?);
|
||||
}
|
||||
|
||||
Ok(spectrograms)
|
||||
}
|
||||
|
||||
/// Generate a window function.
|
||||
fn make_window(kind: WindowFunction, size: usize) -> Vec<f64> {
|
||||
match kind {
|
||||
WindowFunction::Rectangular => vec![1.0; size],
|
||||
WindowFunction::Hann => (0..size)
|
||||
.map(|i| 0.5 * (1.0 - (2.0 * PI * i as f64 / (size - 1) as f64).cos()))
|
||||
.collect(),
|
||||
WindowFunction::Hamming => (0..size)
|
||||
.map(|i| 0.54 - 0.46 * (2.0 * PI * i as f64 / (size - 1) as f64).cos())
|
||||
.collect(),
|
||||
WindowFunction::Blackman => (0..size)
|
||||
.map(|i| {
|
||||
let n = (size - 1) as f64;
|
||||
0.42 - 0.5 * (2.0 * PI * i as f64 / n).cos()
|
||||
+ 0.08 * (4.0 * PI * i as f64 / n).cos()
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors from spectrogram computation.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SpectrogramError {
|
||||
#[error("Signal too short ({signal_len} samples) for window size {window_size}")]
|
||||
SignalTooShort { signal_len: usize, window_size: usize },
|
||||
|
||||
#[error("Hop size must be > 0")]
|
||||
InvalidHopSize,
|
||||
|
||||
#[error("Window size must be > 0")]
|
||||
InvalidWindowSize,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_spectrogram_dimensions() {
|
||||
let sample_rate = 100.0;
|
||||
let signal: Vec<f64> = (0..1000)
|
||||
.map(|i| (i as f64 / sample_rate * 2.0 * PI * 5.0).sin())
|
||||
.collect();
|
||||
|
||||
let config = SpectrogramConfig {
|
||||
window_size: 128,
|
||||
hop_size: 32,
|
||||
window_fn: WindowFunction::Hann,
|
||||
power: true,
|
||||
};
|
||||
|
||||
let spec = compute_spectrogram(&signal, sample_rate, &config).unwrap();
|
||||
assert_eq!(spec.n_freq, 65); // 128/2 + 1
|
||||
assert_eq!(spec.n_time, (1000 - 128) / 32 + 1); // 28 frames
|
||||
assert_eq!(spec.data.dim(), (65, 28));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_frequency_peak() {
|
||||
// A pure 10 Hz tone at 100 Hz sampling → peak at bin 10/100*256 ≈ bin 26
|
||||
let sample_rate = 100.0;
|
||||
let freq = 10.0;
|
||||
let signal: Vec<f64> = (0..1024)
|
||||
.map(|i| (2.0 * PI * freq * i as f64 / sample_rate).sin())
|
||||
.collect();
|
||||
|
||||
let config = SpectrogramConfig {
|
||||
window_size: 256,
|
||||
hop_size: 128,
|
||||
window_fn: WindowFunction::Hann,
|
||||
power: true,
|
||||
};
|
||||
|
||||
let spec = compute_spectrogram(&signal, sample_rate, &config).unwrap();
|
||||
|
||||
// Find peak frequency bin in the first frame
|
||||
let frame = spec.data.column(0);
|
||||
let peak_bin = frame
|
||||
.iter()
|
||||
.enumerate()
|
||||
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
|
||||
.map(|(i, _)| i)
|
||||
.unwrap();
|
||||
|
||||
let peak_freq = peak_bin as f64 * spec.freq_resolution;
|
||||
assert!(
|
||||
(peak_freq - freq).abs() < spec.freq_resolution * 2.0,
|
||||
"Peak at {:.1} Hz, expected {:.1} Hz",
|
||||
peak_freq,
|
||||
freq
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_window_functions_symmetric() {
|
||||
for wf in [
|
||||
WindowFunction::Hann,
|
||||
WindowFunction::Hamming,
|
||||
WindowFunction::Blackman,
|
||||
] {
|
||||
let w = make_window(wf, 64);
|
||||
for i in 0..32 {
|
||||
assert!(
|
||||
(w[i] - w[63 - i]).abs() < 1e-10,
|
||||
"{:?} not symmetric at {}",
|
||||
wf,
|
||||
i
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectangular_window_all_ones() {
|
||||
let w = make_window(WindowFunction::Rectangular, 100);
|
||||
assert!(w.iter().all(|&v| (v - 1.0).abs() < 1e-10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_signal_too_short() {
|
||||
let signal = vec![1.0; 10];
|
||||
let config = SpectrogramConfig {
|
||||
window_size: 256,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
compute_spectrogram(&signal, 100.0, &config),
|
||||
Err(SpectrogramError::SignalTooShort { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multi_subcarrier() {
|
||||
let n_samples = 500;
|
||||
let n_sc = 8;
|
||||
let csi = Array2::from_shape_fn((n_samples, n_sc), |(t, sc)| {
|
||||
let freq = 1.0 + sc as f64 * 0.5;
|
||||
(2.0 * PI * freq * t as f64 / 100.0).sin()
|
||||
});
|
||||
|
||||
let config = SpectrogramConfig {
|
||||
window_size: 128,
|
||||
hop_size: 64,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let specs = compute_multi_subcarrier_spectrogram(&csi, 100.0, &config).unwrap();
|
||||
assert_eq!(specs.len(), n_sc);
|
||||
for spec in &specs {
|
||||
assert_eq!(spec.n_freq, 65);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,292 +0,0 @@
|
||||
//! Subcarrier Sensitivity Selection
|
||||
//!
|
||||
//! Ranks subcarriers by their response to human motion using variance ratio
|
||||
//! (motion variance / static variance) and selects the top-K most sensitive
|
||||
//! ones. This improves SNR by 6-10 dB compared to using all subcarriers.
|
||||
//!
|
||||
//! # References
|
||||
//! - WiDance (MobiCom 2017)
|
||||
//! - WiGest: Using WiFi Gestures for Device-Free Sensing (SenSys 2015)
|
||||
|
||||
use ndarray::Array2;
|
||||
|
||||
/// Configuration for subcarrier selection.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SubcarrierSelectionConfig {
|
||||
/// Number of top subcarriers to select
|
||||
pub top_k: usize,
|
||||
/// Minimum sensitivity ratio to include a subcarrier
|
||||
pub min_sensitivity: f64,
|
||||
}
|
||||
|
||||
impl Default for SubcarrierSelectionConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
top_k: 20,
|
||||
min_sensitivity: 1.5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of subcarrier selection.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SubcarrierSelection {
|
||||
/// Selected subcarrier indices (sorted by sensitivity, descending)
|
||||
pub selected_indices: Vec<usize>,
|
||||
/// Sensitivity scores for ALL subcarriers (variance ratio)
|
||||
pub sensitivity_scores: Vec<f64>,
|
||||
/// The filtered data matrix containing only selected subcarrier columns
|
||||
pub selected_data: Option<Array2<f64>>,
|
||||
}
|
||||
|
||||
/// Select the most motion-sensitive subcarriers using variance ratio.
|
||||
///
|
||||
/// `motion_data`: (num_samples × num_subcarriers) CSI amplitude during motion
|
||||
/// `static_data`: (num_samples × num_subcarriers) CSI amplitude during static period
|
||||
///
|
||||
/// Sensitivity = var(motion[k]) / (var(static[k]) + ε)
|
||||
pub fn select_sensitive_subcarriers(
|
||||
motion_data: &Array2<f64>,
|
||||
static_data: &Array2<f64>,
|
||||
config: &SubcarrierSelectionConfig,
|
||||
) -> Result<SubcarrierSelection, SelectionError> {
|
||||
let (_, n_sc_motion) = motion_data.dim();
|
||||
let (_, n_sc_static) = static_data.dim();
|
||||
|
||||
if n_sc_motion != n_sc_static {
|
||||
return Err(SelectionError::SubcarrierCountMismatch {
|
||||
motion: n_sc_motion,
|
||||
statik: n_sc_static,
|
||||
});
|
||||
}
|
||||
if n_sc_motion == 0 {
|
||||
return Err(SelectionError::NoSubcarriers);
|
||||
}
|
||||
|
||||
let n_sc = n_sc_motion;
|
||||
let mut scores = Vec::with_capacity(n_sc);
|
||||
|
||||
for k in 0..n_sc {
|
||||
let motion_var = column_variance(motion_data, k);
|
||||
let static_var = column_variance(static_data, k);
|
||||
let sensitivity = motion_var / (static_var + 1e-12);
|
||||
scores.push(sensitivity);
|
||||
}
|
||||
|
||||
// Rank by sensitivity (descending)
|
||||
let mut ranked: Vec<(usize, f64)> = scores.iter().copied().enumerate().collect();
|
||||
ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
// Select top-K above minimum threshold
|
||||
let selected: Vec<usize> = ranked
|
||||
.iter()
|
||||
.filter(|(_, score)| *score >= config.min_sensitivity)
|
||||
.take(config.top_k)
|
||||
.map(|(idx, _)| *idx)
|
||||
.collect();
|
||||
|
||||
Ok(SubcarrierSelection {
|
||||
selected_indices: selected,
|
||||
sensitivity_scores: scores,
|
||||
selected_data: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Select and extract data for sensitive subcarriers from a temporal matrix.
|
||||
///
|
||||
/// `data`: (num_samples × num_subcarriers) - the full CSI matrix to filter
|
||||
/// `selection`: previously computed subcarrier selection
|
||||
///
|
||||
/// Returns a new matrix with only the selected columns.
|
||||
pub fn extract_selected(
|
||||
data: &Array2<f64>,
|
||||
selection: &SubcarrierSelection,
|
||||
) -> Result<Array2<f64>, SelectionError> {
|
||||
let (n_samples, n_sc) = data.dim();
|
||||
|
||||
for &idx in &selection.selected_indices {
|
||||
if idx >= n_sc {
|
||||
return Err(SelectionError::IndexOutOfBounds { index: idx, max: n_sc });
|
||||
}
|
||||
}
|
||||
|
||||
if selection.selected_indices.is_empty() {
|
||||
return Err(SelectionError::NoSubcarriersSelected);
|
||||
}
|
||||
|
||||
let n_selected = selection.selected_indices.len();
|
||||
let mut result = Array2::zeros((n_samples, n_selected));
|
||||
|
||||
for (col, &sc_idx) in selection.selected_indices.iter().enumerate() {
|
||||
for row in 0..n_samples {
|
||||
result[[row, col]] = data[[row, sc_idx]];
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Online subcarrier selection using only variance (no separate static period).
|
||||
///
|
||||
/// Ranks by absolute variance — high-variance subcarriers carry more
|
||||
/// information about environmental changes.
|
||||
pub fn select_by_variance(
|
||||
data: &Array2<f64>,
|
||||
config: &SubcarrierSelectionConfig,
|
||||
) -> SubcarrierSelection {
|
||||
let (_, n_sc) = data.dim();
|
||||
let mut scores = Vec::with_capacity(n_sc);
|
||||
|
||||
for k in 0..n_sc {
|
||||
scores.push(column_variance(data, k));
|
||||
}
|
||||
|
||||
let mut ranked: Vec<(usize, f64)> = scores.iter().copied().enumerate().collect();
|
||||
ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
let selected: Vec<usize> = ranked
|
||||
.iter()
|
||||
.take(config.top_k)
|
||||
.map(|(idx, _)| *idx)
|
||||
.collect();
|
||||
|
||||
SubcarrierSelection {
|
||||
selected_indices: selected,
|
||||
sensitivity_scores: scores,
|
||||
selected_data: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute variance of a single column in a 2D array.
|
||||
fn column_variance(data: &Array2<f64>, col: usize) -> f64 {
|
||||
let n = data.nrows() as f64;
|
||||
if n < 2.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let col_data = data.column(col);
|
||||
let mean: f64 = col_data.sum() / n;
|
||||
col_data.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / (n - 1.0)
|
||||
}
|
||||
|
||||
/// Errors from subcarrier selection.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SelectionError {
|
||||
#[error("Subcarrier count mismatch: motion={motion}, static={statik}")]
|
||||
SubcarrierCountMismatch { motion: usize, statik: usize },
|
||||
|
||||
#[error("No subcarriers in input")]
|
||||
NoSubcarriers,
|
||||
|
||||
#[error("No subcarriers met selection criteria")]
|
||||
NoSubcarriersSelected,
|
||||
|
||||
#[error("Subcarrier index {index} out of bounds (max {max})")]
|
||||
IndexOutOfBounds { index: usize, max: usize },
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_sensitive_subcarriers_ranked() {
|
||||
// 3 subcarriers: SC0 has high motion variance, SC1 low, SC2 medium
|
||||
let motion = Array2::from_shape_fn((100, 3), |(t, sc)| match sc {
|
||||
0 => (t as f64 * 0.1).sin() * 5.0, // high variance
|
||||
1 => (t as f64 * 0.1).sin() * 0.1, // low variance
|
||||
2 => (t as f64 * 0.1).sin() * 2.0, // medium variance
|
||||
_ => 0.0,
|
||||
});
|
||||
let statik = Array2::from_shape_fn((100, 3), |(_, _)| 0.01);
|
||||
|
||||
let config = SubcarrierSelectionConfig {
|
||||
top_k: 3,
|
||||
min_sensitivity: 0.0,
|
||||
};
|
||||
let result = select_sensitive_subcarriers(&motion, &statik, &config).unwrap();
|
||||
|
||||
// SC0 should be ranked first (highest sensitivity)
|
||||
assert_eq!(result.selected_indices[0], 0);
|
||||
// SC2 should be second
|
||||
assert_eq!(result.selected_indices[1], 2);
|
||||
// SC1 should be last
|
||||
assert_eq!(result.selected_indices[2], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_top_k_limits_output() {
|
||||
let motion = Array2::from_shape_fn((50, 20), |(t, sc)| {
|
||||
(t as f64 * 0.05).sin() * (sc as f64 + 1.0)
|
||||
});
|
||||
let statik = Array2::from_elem((50, 20), 0.01);
|
||||
|
||||
let config = SubcarrierSelectionConfig {
|
||||
top_k: 5,
|
||||
min_sensitivity: 0.0,
|
||||
};
|
||||
let result = select_sensitive_subcarriers(&motion, &statik, &config).unwrap();
|
||||
assert_eq!(result.selected_indices.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_min_sensitivity_filter() {
|
||||
// All subcarriers have very low sensitivity
|
||||
let motion = Array2::from_elem((50, 10), 1.0);
|
||||
let statik = Array2::from_elem((50, 10), 1.0);
|
||||
|
||||
let config = SubcarrierSelectionConfig {
|
||||
top_k: 10,
|
||||
min_sensitivity: 2.0, // None will pass
|
||||
};
|
||||
let result = select_sensitive_subcarriers(&motion, &statik, &config).unwrap();
|
||||
assert!(result.selected_indices.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_selected_columns() {
|
||||
let data = Array2::from_shape_fn((10, 5), |(r, c)| (r * 5 + c) as f64);
|
||||
|
||||
let selection = SubcarrierSelection {
|
||||
selected_indices: vec![1, 3],
|
||||
sensitivity_scores: vec![0.0; 5],
|
||||
selected_data: None,
|
||||
};
|
||||
|
||||
let extracted = extract_selected(&data, &selection).unwrap();
|
||||
assert_eq!(extracted.dim(), (10, 2));
|
||||
|
||||
// Column 0 of extracted should be column 1 of original
|
||||
for r in 0..10 {
|
||||
assert_eq!(extracted[[r, 0]], data[[r, 1]]);
|
||||
assert_eq!(extracted[[r, 1]], data[[r, 3]]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_variance_based_selection() {
|
||||
let data = Array2::from_shape_fn((100, 5), |(t, sc)| {
|
||||
(t as f64 * 0.1).sin() * (sc as f64 + 1.0)
|
||||
});
|
||||
|
||||
let config = SubcarrierSelectionConfig {
|
||||
top_k: 3,
|
||||
min_sensitivity: 0.0,
|
||||
};
|
||||
let result = select_by_variance(&data, &config);
|
||||
|
||||
assert_eq!(result.selected_indices.len(), 3);
|
||||
// SC4 (highest amplitude) should be first
|
||||
assert_eq!(result.selected_indices[0], 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mismatch_error() {
|
||||
let motion = Array2::zeros((10, 5));
|
||||
let statik = Array2::zeros((10, 3));
|
||||
|
||||
assert!(matches!(
|
||||
select_sensitive_subcarriers(&motion, &statik, &SubcarrierSelectionConfig::default()),
|
||||
Err(SelectionError::SubcarrierCountMismatch { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -3,61 +3,5 @@ name = "wifi-densepose-wasm"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
description = "WebAssembly bindings for WiFi-DensePose"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/ruvnet/wifi-densepose"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[features]
|
||||
default = ["console_error_panic_hook"]
|
||||
mat = ["wifi-densepose-mat"]
|
||||
|
||||
[dependencies]
|
||||
# WASM bindings
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = [
|
||||
"console",
|
||||
"Window",
|
||||
"Document",
|
||||
"Element",
|
||||
"HtmlCanvasElement",
|
||||
"CanvasRenderingContext2d",
|
||||
"WebSocket",
|
||||
"MessageEvent",
|
||||
"ErrorEvent",
|
||||
"CloseEvent",
|
||||
"BinaryType",
|
||||
"Performance",
|
||||
] }
|
||||
|
||||
# Error handling and logging
|
||||
console_error_panic_hook = { version = "0.1", optional = true }
|
||||
wasm-logger = "0.2"
|
||||
log = "0.4"
|
||||
|
||||
# Serialization for JS interop
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
serde-wasm-bindgen = "0.6"
|
||||
|
||||
# Async runtime for WASM
|
||||
futures = "0.3"
|
||||
|
||||
# Time handling
|
||||
chrono = { version = "0.4", features = ["serde", "wasmbind"] }
|
||||
|
||||
# UUID generation (with JS random support)
|
||||
uuid = { version = "1.6", features = ["v4", "serde", "js"] }
|
||||
getrandom = { version = "0.2", features = ["js"] }
|
||||
|
||||
# Optional: wifi-densepose-mat integration
|
||||
wifi-densepose-mat = { path = "../wifi-densepose-mat", optional = true, features = ["serde"] }
|
||||
|
||||
[dev-dependencies]
|
||||
wasm-bindgen-test = "0.3"
|
||||
|
||||
[package.metadata.wasm-pack.profile.release]
|
||||
wasm-opt = ["-O4", "--enable-mutable-globals"]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user