Merge pull request #18 from ruvnet/claude/wifi-mat-disaster-detection-MxxnQ

Create WiFi-Mat disaster detection module
This commit was merged in pull request #18.
This commit is contained in:
rUv
2026-01-13 14:09:14 -05:00
committed by GitHub
59 changed files with 24792 additions and 80 deletions

View File

@@ -1,50 +1,50 @@
{
"running": true,
"startedAt": "2026-01-13T03:24:14.137Z",
"startedAt": "2026-01-13T18:06:18.421Z",
"workers": {
"map": {
"runCount": 3,
"successCount": 3,
"runCount": 8,
"successCount": 8,
"failureCount": 0,
"averageDurationMs": 1.3333333333333333,
"lastRun": "2026-01-13T03:39:14.149Z",
"nextRun": "2026-01-13T03:39:14.144Z",
"averageDurationMs": 1.25,
"lastRun": "2026-01-13T18:21:18.435Z",
"nextRun": "2026-01-13T18:21:18.428Z",
"isRunning": false
},
"audit": {
"runCount": 2,
"runCount": 5,
"successCount": 0,
"failureCount": 2,
"failureCount": 5,
"averageDurationMs": 0,
"lastRun": "2026-01-13T03:31:14.142Z",
"nextRun": "2026-01-13T03:41:14.143Z",
"lastRun": "2026-01-13T18:13:18.424Z",
"nextRun": "2026-01-13T18:23:18.425Z",
"isRunning": false
},
"optimize": {
"runCount": 2,
"runCount": 4,
"successCount": 0,
"failureCount": 2,
"failureCount": 4,
"averageDurationMs": 0,
"lastRun": "2026-01-13T03:33:14.140Z",
"nextRun": "2026-01-13T03:48:14.141Z",
"lastRun": "2026-01-13T18:15:18.424Z",
"nextRun": "2026-01-13T18:30:18.424Z",
"isRunning": false
},
"consolidate": {
"runCount": 1,
"successCount": 1,
"runCount": 3,
"successCount": 3,
"failureCount": 0,
"averageDurationMs": 0,
"lastRun": "2026-01-13T03:11:00.656Z",
"nextRun": "2026-01-13T03:41:00.656Z",
"averageDurationMs": 0.6666666666666666,
"lastRun": "2026-01-13T18:13:18.428Z",
"nextRun": "2026-01-13T18:42:18.422Z",
"isRunning": false
},
"testgaps": {
"runCount": 1,
"runCount": 3,
"successCount": 0,
"failureCount": 1,
"failureCount": 3,
"averageDurationMs": 0,
"lastRun": "2026-01-13T03:37:14.141Z",
"nextRun": "2026-01-13T03:57:14.142Z",
"lastRun": "2026-01-13T18:19:18.457Z",
"nextRun": "2026-01-13T18:39:18.457Z",
"isRunning": false
},
"predict": {
@@ -131,5 +131,5 @@
}
]
},
"savedAt": "2026-01-13T03:39:14.149Z"
"savedAt": "2026-01-13T18:21:18.435Z"
}

View File

@@ -1,5 +1,5 @@
{
"timestamp": "2026-01-13T03:39:14.148Z",
"timestamp": "2026-01-13T18:21:18.434Z",
"projectRoot": "/home/user/wifi-densepose",
"structure": {
"hasPackageJson": false,
@@ -7,5 +7,5 @@
"hasClaudeConfig": true,
"hasClaudeFlow": true
},
"scannedAt": 1768275554149
"scannedAt": 1768328478434
}

View File

@@ -1,5 +1,5 @@
{
"timestamp": "2026-01-13T03:11:00.656Z",
"timestamp": "2026-01-13T18:13:18.428Z",
"patternsConsolidated": 0,
"memoryCleaned": 0,
"duplicatesRemoved": 0

View File

@@ -73,6 +73,61 @@ Mathematical correctness validated:
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>
@@ -81,6 +136,8 @@ See [Rust Port Documentation](/rust-port/wifi-densepose-rs/docs/) for ADRs and D
**🚀 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)
- [Using pip (Recommended)](#using-pip-recommended)

View File

@@ -0,0 +1,173 @@
# 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)

View File

@@ -0,0 +1,497 @@
# 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>;
}
```

962
docs/wifi-mat-user-guide.md Normal file
View File

@@ -0,0 +1,962 @@
# 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(&amplitudes, 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(&amplitudes, 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.*

View File

@@ -1,49 +1,49 @@
{
"running": true,
"startedAt": "2026-01-13T03:30:55.475Z",
"startedAt": "2026-01-13T18:18:54.985Z",
"workers": {
"map": {
"runCount": 1,
"successCount": 1,
"runCount": 2,
"successCount": 2,
"failureCount": 0,
"averageDurationMs": 1,
"isRunning": false,
"nextRun": "2026-01-13T03:45:55.482Z",
"lastRun": "2026-01-13T03:30:55.481Z"
"averageDurationMs": 2,
"lastRun": "2026-01-13T18:18:55.021Z",
"nextRun": "2026-01-13T18:18:54.985Z",
"isRunning": false
},
"audit": {
"runCount": 1,
"successCount": 0,
"failureCount": 1,
"averageDurationMs": 0,
"isRunning": false,
"nextRun": "2026-01-13T03:47:55.481Z",
"lastRun": "2026-01-13T03:37:55.480Z"
"lastRun": "2026-01-13T03:37:55.480Z",
"nextRun": "2026-01-13T18:20:54.985Z",
"isRunning": false
},
"optimize": {
"runCount": 0,
"successCount": 0,
"failureCount": 0,
"averageDurationMs": 0,
"isRunning": true,
"nextRun": "2026-01-13T03:34:55.475Z"
"nextRun": "2026-01-13T18:22:54.985Z",
"isRunning": false
},
"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"
"lastRun": "2026-01-13T03:37:55.485Z",
"nextRun": "2026-01-13T18:24:54.985Z",
"isRunning": false
},
"testgaps": {
"runCount": 0,
"successCount": 0,
"failureCount": 0,
"averageDurationMs": 0,
"isRunning": false,
"nextRun": "2026-01-13T03:38:55.475Z"
"nextRun": "2026-01-13T18:26:54.985Z",
"isRunning": false
},
"predict": {
"runCount": 0,
@@ -129,5 +129,5 @@
}
]
},
"savedAt": "2026-01-13T03:37:55.485Z"
"savedAt": "2026-01-13T18:18:55.021Z"
}

View File

@@ -1 +1 @@
3707
44457

View File

@@ -1,5 +1,5 @@
{
"timestamp": "2026-01-13T03:30:55.480Z",
"timestamp": "2026-01-13T18:18:55.019Z",
"projectRoot": "/home/user/wifi-densepose/rust-port/wifi-densepose-rs",
"structure": {
"hasPackageJson": false,
@@ -7,5 +7,5 @@
"hasClaudeConfig": false,
"hasClaudeFlow": true
},
"scannedAt": 1768275055481
"scannedAt": 1768328335020
}

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@ members = [
"crates/wifi-densepose-hardware",
"crates/wifi-densepose-wasm",
"crates/wifi-densepose-cli",
"crates/wifi-densepose-mat",
]
[workspace.package]
@@ -91,6 +92,7 @@ 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

View File

@@ -3,5 +3,54 @@ 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"

View File

@@ -1 +1,51 @@
//! WiFi-DensePose CLI (stub)
//! 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,
}

View File

@@ -0,0 +1,31 @@
//! 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

View File

@@ -0,0 +1,72 @@
[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"]

View File

@@ -0,0 +1,906 @@
//! 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(&amplitudes, &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(&amplitudes, &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(&amplitudes), 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(&amplitudes, &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
);

View File

@@ -0,0 +1,339 @@
//! 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 (placeholder)
pub struct AudioAlertHandler;
#[async_trait::async_trait]
impl AlertHandler for AudioAlertHandler {
fn name(&self) -> &str {
"audio"
}
async fn handle(&self, alert: &Alert) -> Result<(), MatError> {
// In production, this would trigger actual audio alerts
let pattern = alert.priority().audio_pattern();
tracing::debug!(
alert_id = %alert.id(),
pattern,
"Would play audio alert"
);
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);
}
}

View File

@@ -0,0 +1,288 @@
//! 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"));
}
}

View File

@@ -0,0 +1,9 @@
//! 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};

View File

@@ -0,0 +1,317 @@
//! 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);
}
}

View File

@@ -0,0 +1,892 @@
//! Data Transfer Objects (DTOs) for the MAT REST API.
//!
//! These types are used for serializing/deserializing API requests and responses.
//! They provide a clean separation between domain models and API contracts.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::domain::{
DisasterType, EventStatus, ZoneStatus, TriageStatus, Priority,
AlertStatus, SurvivorStatus,
};
// ============================================================================
// Event DTOs
// ============================================================================
/// Request body for creating a new disaster event.
///
/// ## Example
///
/// ```json
/// {
/// "event_type": "Earthquake",
/// "latitude": 37.7749,
/// "longitude": -122.4194,
/// "description": "Magnitude 6.8 earthquake in San Francisco",
/// "estimated_occupancy": 500,
/// "lead_agency": "SF Fire Department"
/// }
/// ```
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct CreateEventRequest {
/// Type of disaster event
pub event_type: DisasterTypeDto,
/// Latitude of disaster epicenter
pub latitude: f64,
/// Longitude of disaster epicenter
pub longitude: f64,
/// Human-readable description of the event
pub description: String,
/// Estimated number of people in the affected area
#[serde(default)]
pub estimated_occupancy: Option<u32>,
/// Lead responding agency
#[serde(default)]
pub lead_agency: Option<String>,
}
/// Response body for disaster event details.
///
/// ## Example Response
///
/// ```json
/// {
/// "id": "550e8400-e29b-41d4-a716-446655440000",
/// "event_type": "Earthquake",
/// "status": "Active",
/// "start_time": "2024-01-15T14:30:00Z",
/// "latitude": 37.7749,
/// "longitude": -122.4194,
/// "description": "Magnitude 6.8 earthquake",
/// "zone_count": 5,
/// "survivor_count": 12,
/// "triage_summary": {
/// "immediate": 3,
/// "delayed": 5,
/// "minor": 4,
/// "deceased": 0
/// }
/// }
/// ```
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct EventResponse {
/// Unique event identifier
pub id: Uuid,
/// Type of disaster
pub event_type: DisasterTypeDto,
/// Current event status
pub status: EventStatusDto,
/// When the event was created/started
pub start_time: DateTime<Utc>,
/// Latitude of epicenter
pub latitude: f64,
/// Longitude of epicenter
pub longitude: f64,
/// Event description
pub description: String,
/// Number of scan zones
pub zone_count: usize,
/// Number of detected survivors
pub survivor_count: usize,
/// Summary of triage classifications
pub triage_summary: TriageSummary,
/// Metadata about the event
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<EventMetadataDto>,
}
/// Summary of triage counts across all survivors.
#[derive(Debug, Clone, Serialize, Default)]
#[serde(rename_all = "snake_case")]
pub struct TriageSummary {
/// Immediate (Red) - life-threatening
pub immediate: u32,
/// Delayed (Yellow) - serious but stable
pub delayed: u32,
/// Minor (Green) - walking wounded
pub minor: u32,
/// Deceased (Black)
pub deceased: u32,
/// Unknown status
pub unknown: u32,
}
/// Event metadata DTO
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct EventMetadataDto {
/// Estimated number of people in area at time of disaster
#[serde(skip_serializing_if = "Option::is_none")]
pub estimated_occupancy: Option<u32>,
/// Known survivors (already rescued)
#[serde(default)]
pub confirmed_rescued: u32,
/// Known fatalities
#[serde(default)]
pub confirmed_deceased: u32,
/// Weather conditions
#[serde(skip_serializing_if = "Option::is_none")]
pub weather: Option<String>,
/// Lead agency
#[serde(skip_serializing_if = "Option::is_none")]
pub lead_agency: Option<String>,
}
/// Paginated list of events.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct EventListResponse {
/// List of events
pub events: Vec<EventResponse>,
/// Total count of events
pub total: usize,
/// Current page number (0-indexed)
pub page: usize,
/// Number of items per page
pub page_size: usize,
}
// ============================================================================
// Zone DTOs
// ============================================================================
/// Request body for adding a scan zone to an event.
///
/// ## Example
///
/// ```json
/// {
/// "name": "Building A - North Wing",
/// "bounds": {
/// "type": "rectangle",
/// "min_x": 0.0,
/// "min_y": 0.0,
/// "max_x": 50.0,
/// "max_y": 30.0
/// },
/// "parameters": {
/// "sensitivity": 0.85,
/// "max_depth": 5.0,
/// "heartbeat_detection": true
/// }
/// }
/// ```
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct CreateZoneRequest {
/// Human-readable zone name
pub name: String,
/// Geographic bounds of the zone
pub bounds: ZoneBoundsDto,
/// Optional scan parameters
#[serde(default)]
pub parameters: Option<ScanParametersDto>,
}
/// Zone boundary definition.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ZoneBoundsDto {
/// Rectangular boundary
Rectangle {
min_x: f64,
min_y: f64,
max_x: f64,
max_y: f64,
},
/// Circular boundary
Circle {
center_x: f64,
center_y: f64,
radius: f64,
},
/// Polygon boundary (list of vertices)
Polygon {
vertices: Vec<(f64, f64)>,
},
}
/// Scan parameters for a zone.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct ScanParametersDto {
/// Detection sensitivity (0.0-1.0)
#[serde(default = "default_sensitivity")]
pub sensitivity: f64,
/// Maximum depth to scan in meters
#[serde(default = "default_max_depth")]
pub max_depth: f64,
/// Scan resolution level
#[serde(default)]
pub resolution: ScanResolutionDto,
/// Enable enhanced breathing detection
#[serde(default = "default_true")]
pub enhanced_breathing: bool,
/// Enable heartbeat detection (slower but more accurate)
#[serde(default)]
pub heartbeat_detection: bool,
}
fn default_sensitivity() -> f64 { 0.8 }
fn default_max_depth() -> f64 { 5.0 }
fn default_true() -> bool { true }
impl Default for ScanParametersDto {
fn default() -> Self {
Self {
sensitivity: default_sensitivity(),
max_depth: default_max_depth(),
resolution: ScanResolutionDto::default(),
enhanced_breathing: default_true(),
heartbeat_detection: false,
}
}
}
/// Scan resolution levels.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ScanResolutionDto {
Quick,
#[default]
Standard,
High,
Maximum,
}
/// Response for zone details.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct ZoneResponse {
/// Zone identifier
pub id: Uuid,
/// Zone name
pub name: String,
/// Zone status
pub status: ZoneStatusDto,
/// Zone boundaries
pub bounds: ZoneBoundsDto,
/// Zone area in square meters
pub area: f64,
/// Scan parameters
pub parameters: ScanParametersDto,
/// Last scan time
#[serde(skip_serializing_if = "Option::is_none")]
pub last_scan: Option<DateTime<Utc>>,
/// Total scan count
pub scan_count: u32,
/// Number of detections in this zone
pub detections_count: u32,
}
/// List of zones response.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct ZoneListResponse {
/// List of zones
pub zones: Vec<ZoneResponse>,
/// Total count
pub total: usize,
}
// ============================================================================
// Survivor DTOs
// ============================================================================
/// Response for survivor details.
///
/// ## Example Response
///
/// ```json
/// {
/// "id": "550e8400-e29b-41d4-a716-446655440001",
/// "zone_id": "550e8400-e29b-41d4-a716-446655440002",
/// "status": "Active",
/// "triage_status": "Immediate",
/// "location": {
/// "x": 25.5,
/// "y": 12.3,
/// "z": -2.1,
/// "uncertainty_radius": 1.5
/// },
/// "vital_signs": {
/// "breathing_rate": 22.5,
/// "has_heartbeat": true,
/// "has_movement": false
/// },
/// "confidence": 0.87,
/// "first_detected": "2024-01-15T14:32:00Z",
/// "last_updated": "2024-01-15T14:45:00Z"
/// }
/// ```
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct SurvivorResponse {
/// Survivor identifier
pub id: Uuid,
/// Zone where survivor was detected
pub zone_id: Uuid,
/// Current survivor status
pub status: SurvivorStatusDto,
/// Triage classification
pub triage_status: TriageStatusDto,
/// Location information
#[serde(skip_serializing_if = "Option::is_none")]
pub location: Option<LocationDto>,
/// Latest vital signs summary
pub vital_signs: VitalSignsSummaryDto,
/// Detection confidence (0.0-1.0)
pub confidence: f64,
/// When survivor was first detected
pub first_detected: DateTime<Utc>,
/// Last update time
pub last_updated: DateTime<Utc>,
/// Whether survivor is deteriorating
pub is_deteriorating: bool,
/// Metadata
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<SurvivorMetadataDto>,
}
/// Location information DTO.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct LocationDto {
/// X coordinate (east-west, meters)
pub x: f64,
/// Y coordinate (north-south, meters)
pub y: f64,
/// Z coordinate (depth, negative is below surface)
pub z: f64,
/// Estimated depth below surface (positive meters)
pub depth: f64,
/// Horizontal uncertainty radius in meters
pub uncertainty_radius: f64,
/// Location confidence score
pub confidence: f64,
}
/// Summary of vital signs for API response.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct VitalSignsSummaryDto {
/// Breathing rate (breaths per minute)
#[serde(skip_serializing_if = "Option::is_none")]
pub breathing_rate: Option<f32>,
/// Breathing pattern type
#[serde(skip_serializing_if = "Option::is_none")]
pub breathing_type: Option<String>,
/// Heart rate if detected (bpm)
#[serde(skip_serializing_if = "Option::is_none")]
pub heart_rate: Option<f32>,
/// Whether heartbeat is detected
pub has_heartbeat: bool,
/// Whether movement is detected
pub has_movement: bool,
/// Movement type if present
#[serde(skip_serializing_if = "Option::is_none")]
pub movement_type: Option<String>,
/// Timestamp of reading
pub timestamp: DateTime<Utc>,
}
/// Survivor metadata DTO.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct SurvivorMetadataDto {
/// Estimated age category
#[serde(skip_serializing_if = "Option::is_none")]
pub estimated_age_category: Option<String>,
/// Assigned rescue team
#[serde(skip_serializing_if = "Option::is_none")]
pub assigned_team: Option<String>,
/// Notes
pub notes: Vec<String>,
/// Tags
pub tags: Vec<String>,
}
/// List of survivors response.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct SurvivorListResponse {
/// List of survivors
pub survivors: Vec<SurvivorResponse>,
/// Total count
pub total: usize,
/// Triage summary
pub triage_summary: TriageSummary,
}
// ============================================================================
// Alert DTOs
// ============================================================================
/// Response for alert details.
///
/// ## Example Response
///
/// ```json
/// {
/// "id": "550e8400-e29b-41d4-a716-446655440003",
/// "survivor_id": "550e8400-e29b-41d4-a716-446655440001",
/// "priority": "Critical",
/// "status": "Pending",
/// "title": "Immediate: Survivor detected with abnormal breathing",
/// "message": "Survivor in Zone A showing signs of respiratory distress",
/// "triage_status": "Immediate",
/// "location": { ... },
/// "created_at": "2024-01-15T14:35:00Z"
/// }
/// ```
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct AlertResponse {
/// Alert identifier
pub id: Uuid,
/// Related survivor ID
pub survivor_id: Uuid,
/// Alert priority
pub priority: PriorityDto,
/// Alert status
pub status: AlertStatusDto,
/// Alert title
pub title: String,
/// Detailed message
pub message: String,
/// Associated triage status
pub triage_status: TriageStatusDto,
/// Location if available
#[serde(skip_serializing_if = "Option::is_none")]
pub location: Option<LocationDto>,
/// Recommended action
#[serde(skip_serializing_if = "Option::is_none")]
pub recommended_action: Option<String>,
/// When alert was created
pub created_at: DateTime<Utc>,
/// When alert was acknowledged
#[serde(skip_serializing_if = "Option::is_none")]
pub acknowledged_at: Option<DateTime<Utc>>,
/// Who acknowledged the alert
#[serde(skip_serializing_if = "Option::is_none")]
pub acknowledged_by: Option<String>,
/// Escalation count
pub escalation_count: u32,
}
/// Request to acknowledge an alert.
///
/// ## Example
///
/// ```json
/// {
/// "acknowledged_by": "Team Alpha",
/// "notes": "En route to location"
/// }
/// ```
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct AcknowledgeAlertRequest {
/// Who is acknowledging the alert
pub acknowledged_by: String,
/// Optional notes
#[serde(default)]
pub notes: Option<String>,
}
/// Response after acknowledging an alert.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct AcknowledgeAlertResponse {
/// Whether acknowledgement was successful
pub success: bool,
/// Updated alert
pub alert: AlertResponse,
}
/// List of alerts response.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct AlertListResponse {
/// List of alerts
pub alerts: Vec<AlertResponse>,
/// Total count
pub total: usize,
/// Count by priority
pub priority_counts: PriorityCounts,
}
/// Count of alerts by priority.
#[derive(Debug, Clone, Serialize, Default)]
#[serde(rename_all = "snake_case")]
pub struct PriorityCounts {
pub critical: usize,
pub high: usize,
pub medium: usize,
pub low: usize,
}
// ============================================================================
// WebSocket DTOs
// ============================================================================
/// WebSocket message types for real-time streaming.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum WebSocketMessage {
/// New survivor detected
SurvivorDetected {
event_id: Uuid,
survivor: SurvivorResponse,
},
/// Survivor status updated
SurvivorUpdated {
event_id: Uuid,
survivor: SurvivorResponse,
},
/// Survivor lost (signal lost)
SurvivorLost {
event_id: Uuid,
survivor_id: Uuid,
},
/// New alert generated
AlertCreated {
event_id: Uuid,
alert: AlertResponse,
},
/// Alert status changed
AlertUpdated {
event_id: Uuid,
alert: AlertResponse,
},
/// Zone scan completed
ZoneScanComplete {
event_id: Uuid,
zone_id: Uuid,
detections: u32,
},
/// Event status changed
EventStatusChanged {
event_id: Uuid,
old_status: EventStatusDto,
new_status: EventStatusDto,
},
/// Heartbeat/keep-alive
Heartbeat {
timestamp: DateTime<Utc>,
},
/// Error message
Error {
code: String,
message: String,
},
}
/// WebSocket subscription request.
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum WebSocketRequest {
/// Subscribe to events for a disaster event
Subscribe {
event_id: Uuid,
},
/// Unsubscribe from events
Unsubscribe {
event_id: Uuid,
},
/// Subscribe to all events
SubscribeAll,
/// Request current state
GetState {
event_id: Uuid,
},
}
// ============================================================================
// Enum DTOs (mirroring domain enums with serde)
// ============================================================================
/// Disaster type DTO.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "PascalCase")]
pub enum DisasterTypeDto {
BuildingCollapse,
Earthquake,
Landslide,
Avalanche,
Flood,
MineCollapse,
Industrial,
TunnelCollapse,
Unknown,
}
impl From<DisasterType> for DisasterTypeDto {
fn from(dt: DisasterType) -> Self {
match dt {
DisasterType::BuildingCollapse => DisasterTypeDto::BuildingCollapse,
DisasterType::Earthquake => DisasterTypeDto::Earthquake,
DisasterType::Landslide => DisasterTypeDto::Landslide,
DisasterType::Avalanche => DisasterTypeDto::Avalanche,
DisasterType::Flood => DisasterTypeDto::Flood,
DisasterType::MineCollapse => DisasterTypeDto::MineCollapse,
DisasterType::Industrial => DisasterTypeDto::Industrial,
DisasterType::TunnelCollapse => DisasterTypeDto::TunnelCollapse,
DisasterType::Unknown => DisasterTypeDto::Unknown,
}
}
}
impl From<DisasterTypeDto> for DisasterType {
fn from(dt: DisasterTypeDto) -> Self {
match dt {
DisasterTypeDto::BuildingCollapse => DisasterType::BuildingCollapse,
DisasterTypeDto::Earthquake => DisasterType::Earthquake,
DisasterTypeDto::Landslide => DisasterType::Landslide,
DisasterTypeDto::Avalanche => DisasterType::Avalanche,
DisasterTypeDto::Flood => DisasterType::Flood,
DisasterTypeDto::MineCollapse => DisasterType::MineCollapse,
DisasterTypeDto::Industrial => DisasterType::Industrial,
DisasterTypeDto::TunnelCollapse => DisasterType::TunnelCollapse,
DisasterTypeDto::Unknown => DisasterType::Unknown,
}
}
}
/// Event status DTO.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum EventStatusDto {
Initializing,
Active,
Suspended,
SecondarySearch,
Closed,
}
impl From<EventStatus> for EventStatusDto {
fn from(es: EventStatus) -> Self {
match es {
EventStatus::Initializing => EventStatusDto::Initializing,
EventStatus::Active => EventStatusDto::Active,
EventStatus::Suspended => EventStatusDto::Suspended,
EventStatus::SecondarySearch => EventStatusDto::SecondarySearch,
EventStatus::Closed => EventStatusDto::Closed,
}
}
}
/// Zone status DTO.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum ZoneStatusDto {
Active,
Paused,
Complete,
Inaccessible,
Deactivated,
}
impl From<ZoneStatus> for ZoneStatusDto {
fn from(zs: ZoneStatus) -> Self {
match zs {
ZoneStatus::Active => ZoneStatusDto::Active,
ZoneStatus::Paused => ZoneStatusDto::Paused,
ZoneStatus::Complete => ZoneStatusDto::Complete,
ZoneStatus::Inaccessible => ZoneStatusDto::Inaccessible,
ZoneStatus::Deactivated => ZoneStatusDto::Deactivated,
}
}
}
/// Triage status DTO.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum TriageStatusDto {
Immediate,
Delayed,
Minor,
Deceased,
Unknown,
}
impl From<TriageStatus> for TriageStatusDto {
fn from(ts: TriageStatus) -> Self {
match ts {
TriageStatus::Immediate => TriageStatusDto::Immediate,
TriageStatus::Delayed => TriageStatusDto::Delayed,
TriageStatus::Minor => TriageStatusDto::Minor,
TriageStatus::Deceased => TriageStatusDto::Deceased,
TriageStatus::Unknown => TriageStatusDto::Unknown,
}
}
}
/// Priority DTO.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum PriorityDto {
Critical,
High,
Medium,
Low,
}
impl From<Priority> for PriorityDto {
fn from(p: Priority) -> Self {
match p {
Priority::Critical => PriorityDto::Critical,
Priority::High => PriorityDto::High,
Priority::Medium => PriorityDto::Medium,
Priority::Low => PriorityDto::Low,
}
}
}
/// Alert status DTO.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum AlertStatusDto {
Pending,
Acknowledged,
InProgress,
Resolved,
Cancelled,
Expired,
}
impl From<AlertStatus> for AlertStatusDto {
fn from(as_: AlertStatus) -> Self {
match as_ {
AlertStatus::Pending => AlertStatusDto::Pending,
AlertStatus::Acknowledged => AlertStatusDto::Acknowledged,
AlertStatus::InProgress => AlertStatusDto::InProgress,
AlertStatus::Resolved => AlertStatusDto::Resolved,
AlertStatus::Cancelled => AlertStatusDto::Cancelled,
AlertStatus::Expired => AlertStatusDto::Expired,
}
}
}
/// Survivor status DTO.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum SurvivorStatusDto {
Active,
Rescued,
Lost,
Deceased,
FalsePositive,
}
impl From<SurvivorStatus> for SurvivorStatusDto {
fn from(ss: SurvivorStatus) -> Self {
match ss {
SurvivorStatus::Active => SurvivorStatusDto::Active,
SurvivorStatus::Rescued => SurvivorStatusDto::Rescued,
SurvivorStatus::Lost => SurvivorStatusDto::Lost,
SurvivorStatus::Deceased => SurvivorStatusDto::Deceased,
SurvivorStatus::FalsePositive => SurvivorStatusDto::FalsePositive,
}
}
}
// ============================================================================
// Query Parameters
// ============================================================================
/// Query parameters for listing events.
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub struct ListEventsQuery {
/// Filter by status
pub status: Option<EventStatusDto>,
/// Filter by disaster type
pub event_type: Option<DisasterTypeDto>,
/// Page number (0-indexed)
#[serde(default)]
pub page: usize,
/// Page size (default 20, max 100)
#[serde(default = "default_page_size")]
pub page_size: usize,
}
fn default_page_size() -> usize { 20 }
/// Query parameters for listing survivors.
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub struct ListSurvivorsQuery {
/// Filter by triage status
pub triage_status: Option<TriageStatusDto>,
/// Filter by zone ID
pub zone_id: Option<Uuid>,
/// Filter by minimum confidence
pub min_confidence: Option<f64>,
/// Include only deteriorating
#[serde(default)]
pub deteriorating_only: bool,
}
/// Query parameters for listing alerts.
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub struct ListAlertsQuery {
/// Filter by priority
pub priority: Option<PriorityDto>,
/// Filter by status
pub status: Option<AlertStatusDto>,
/// Only pending alerts
#[serde(default)]
pub pending_only: bool,
/// Only active alerts
#[serde(default)]
pub active_only: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_event_request_deserialize() {
let json = r#"{
"event_type": "Earthquake",
"latitude": 37.7749,
"longitude": -122.4194,
"description": "Test earthquake"
}"#;
let req: CreateEventRequest = serde_json::from_str(json).unwrap();
assert_eq!(req.event_type, DisasterTypeDto::Earthquake);
assert!((req.latitude - 37.7749).abs() < 0.0001);
}
#[test]
fn test_zone_bounds_dto_deserialize() {
let rect_json = r#"{
"type": "rectangle",
"min_x": 0.0,
"min_y": 0.0,
"max_x": 10.0,
"max_y": 10.0
}"#;
let bounds: ZoneBoundsDto = serde_json::from_str(rect_json).unwrap();
assert!(matches!(bounds, ZoneBoundsDto::Rectangle { .. }));
}
#[test]
fn test_websocket_message_serialize() {
let msg = WebSocketMessage::Heartbeat {
timestamp: Utc::now(),
};
let json = serde_json::to_string(&msg).unwrap();
assert!(json.contains("\"type\":\"heartbeat\""));
}
}

View File

@@ -0,0 +1,276 @@
//! 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");
}
}

View File

@@ -0,0 +1,886 @@
//! Axum request handlers for the MAT REST API.
//!
//! This module contains all the HTTP endpoint handlers for disaster response operations.
//! Each handler is documented with OpenAPI-style documentation comments.
use axum::{
extract::{Path, Query, State},
http::StatusCode,
Json,
};
use geo::Point;
use uuid::Uuid;
use super::dto::*;
use super::error::{ApiError, ApiResult};
use super::state::AppState;
use crate::domain::{
DisasterEvent, DisasterType, ScanZone, ZoneBounds,
ScanParameters, ScanResolution, MovementType,
};
// ============================================================================
// Event Handlers
// ============================================================================
/// List all disaster events.
///
/// # OpenAPI Specification
///
/// ```yaml
/// /api/v1/mat/events:
/// get:
/// summary: List disaster events
/// description: Returns a paginated list of disaster events with optional filtering
/// tags: [Events]
/// parameters:
/// - name: status
/// in: query
/// description: Filter by event status
/// schema:
/// type: string
/// enum: [Initializing, Active, Suspended, SecondarySearch, Closed]
/// - name: event_type
/// in: query
/// description: Filter by disaster type
/// schema:
/// type: string
/// - name: page
/// in: query
/// description: Page number (0-indexed)
/// schema:
/// type: integer
/// default: 0
/// - name: page_size
/// in: query
/// description: Items per page (max 100)
/// schema:
/// type: integer
/// default: 20
/// responses:
/// 200:
/// description: List of events
/// content:
/// application/json:
/// schema:
/// $ref: '#/components/schemas/EventListResponse'
/// ```
#[tracing::instrument(skip(state))]
pub async fn list_events(
State(state): State<AppState>,
Query(query): Query<ListEventsQuery>,
) -> ApiResult<Json<EventListResponse>> {
let all_events = state.list_events();
// Apply filters
let filtered: Vec<_> = all_events
.into_iter()
.filter(|e| {
if let Some(ref status) = query.status {
let event_status: EventStatusDto = e.status().clone().into();
if !matches_status(&event_status, status) {
return false;
}
}
if let Some(ref event_type) = query.event_type {
let et: DisasterTypeDto = e.event_type().clone().into();
if et != *event_type {
return false;
}
}
true
})
.collect();
let total = filtered.len();
// Apply pagination
let page_size = query.page_size.min(100).max(1);
let start = query.page * page_size;
let events: Vec<_> = filtered
.into_iter()
.skip(start)
.take(page_size)
.map(event_to_response)
.collect();
Ok(Json(EventListResponse {
events,
total,
page: query.page,
page_size,
}))
}
/// Create a new disaster event.
///
/// # OpenAPI Specification
///
/// ```yaml
/// /api/v1/mat/events:
/// post:
/// summary: Create a new disaster event
/// description: Creates a new disaster event for search and rescue operations
/// tags: [Events]
/// requestBody:
/// required: true
/// content:
/// application/json:
/// schema:
/// $ref: '#/components/schemas/CreateEventRequest'
/// responses:
/// 201:
/// description: Event created successfully
/// content:
/// application/json:
/// schema:
/// $ref: '#/components/schemas/EventResponse'
/// 400:
/// description: Invalid request data
/// content:
/// application/json:
/// schema:
/// $ref: '#/components/schemas/ErrorResponse'
/// ```
#[tracing::instrument(skip(state))]
pub async fn create_event(
State(state): State<AppState>,
Json(request): Json<CreateEventRequest>,
) -> ApiResult<(StatusCode, Json<EventResponse>)> {
// Validate coordinates
if request.latitude < -90.0 || request.latitude > 90.0 {
return Err(ApiError::validation(
"Latitude must be between -90 and 90",
Some("latitude".to_string()),
));
}
if request.longitude < -180.0 || request.longitude > 180.0 {
return Err(ApiError::validation(
"Longitude must be between -180 and 180",
Some("longitude".to_string()),
));
}
let disaster_type: DisasterType = request.event_type.into();
let location = Point::new(request.longitude, request.latitude);
let mut event = DisasterEvent::new(disaster_type, location, &request.description);
// Set metadata if provided
if let Some(occupancy) = request.estimated_occupancy {
event.metadata_mut().estimated_occupancy = Some(occupancy);
}
if let Some(agency) = request.lead_agency {
event.metadata_mut().lead_agency = Some(agency);
}
let response = event_to_response(event.clone());
let event_id = *event.id().as_uuid();
state.store_event(event);
// Broadcast event creation
state.broadcast(WebSocketMessage::EventStatusChanged {
event_id,
old_status: EventStatusDto::Initializing,
new_status: response.status,
});
tracing::info!(event_id = %event_id, "Created new disaster event");
Ok((StatusCode::CREATED, Json(response)))
}
/// Get a specific disaster event by ID.
///
/// # OpenAPI Specification
///
/// ```yaml
/// /api/v1/mat/events/{event_id}:
/// get:
/// summary: Get event details
/// description: Returns detailed information about a specific disaster event
/// tags: [Events]
/// parameters:
/// - name: event_id
/// in: path
/// required: true
/// description: Event UUID
/// schema:
/// type: string
/// format: uuid
/// responses:
/// 200:
/// description: Event details
/// content:
/// application/json:
/// schema:
/// $ref: '#/components/schemas/EventResponse'
/// 404:
/// description: Event not found
/// ```
#[tracing::instrument(skip(state))]
pub async fn get_event(
State(state): State<AppState>,
Path(event_id): Path<Uuid>,
) -> ApiResult<Json<EventResponse>> {
let event = state
.get_event(event_id)
.ok_or_else(|| ApiError::event_not_found(event_id))?;
Ok(Json(event_to_response(event)))
}
// ============================================================================
// Zone Handlers
// ============================================================================
/// List all zones for a disaster event.
///
/// # OpenAPI Specification
///
/// ```yaml
/// /api/v1/mat/events/{event_id}/zones:
/// get:
/// summary: List zones for an event
/// description: Returns all scan zones configured for a disaster event
/// tags: [Zones]
/// parameters:
/// - name: event_id
/// in: path
/// required: true
/// schema:
/// type: string
/// format: uuid
/// responses:
/// 200:
/// description: List of zones
/// content:
/// application/json:
/// schema:
/// $ref: '#/components/schemas/ZoneListResponse'
/// 404:
/// description: Event not found
/// ```
#[tracing::instrument(skip(state))]
pub async fn list_zones(
State(state): State<AppState>,
Path(event_id): Path<Uuid>,
) -> ApiResult<Json<ZoneListResponse>> {
let event = state
.get_event(event_id)
.ok_or_else(|| ApiError::event_not_found(event_id))?;
let zones: Vec<_> = event.zones().iter().map(zone_to_response).collect();
let total = zones.len();
Ok(Json(ZoneListResponse { zones, total }))
}
/// Add a scan zone to a disaster event.
///
/// # OpenAPI Specification
///
/// ```yaml
/// /api/v1/mat/events/{event_id}/zones:
/// post:
/// summary: Add a scan zone
/// description: Creates a new scan zone within a disaster event area
/// tags: [Zones]
/// parameters:
/// - name: event_id
/// in: path
/// required: true
/// schema:
/// type: string
/// format: uuid
/// requestBody:
/// required: true
/// content:
/// application/json:
/// schema:
/// $ref: '#/components/schemas/CreateZoneRequest'
/// responses:
/// 201:
/// description: Zone created successfully
/// content:
/// application/json:
/// schema:
/// $ref: '#/components/schemas/ZoneResponse'
/// 404:
/// description: Event not found
/// 400:
/// description: Invalid zone configuration
/// ```
#[tracing::instrument(skip(state))]
pub async fn add_zone(
State(state): State<AppState>,
Path(event_id): Path<Uuid>,
Json(request): Json<CreateZoneRequest>,
) -> ApiResult<(StatusCode, Json<ZoneResponse>)> {
// Convert DTO to domain
let bounds = match request.bounds {
ZoneBoundsDto::Rectangle { min_x, min_y, max_x, max_y } => {
if max_x <= min_x || max_y <= min_y {
return Err(ApiError::validation(
"max coordinates must be greater than min coordinates",
Some("bounds".to_string()),
));
}
ZoneBounds::rectangle(min_x, min_y, max_x, max_y)
}
ZoneBoundsDto::Circle { center_x, center_y, radius } => {
if radius <= 0.0 {
return Err(ApiError::validation(
"radius must be positive",
Some("bounds.radius".to_string()),
));
}
ZoneBounds::circle(center_x, center_y, radius)
}
ZoneBoundsDto::Polygon { vertices } => {
if vertices.len() < 3 {
return Err(ApiError::validation(
"polygon must have at least 3 vertices",
Some("bounds.vertices".to_string()),
));
}
ZoneBounds::polygon(vertices)
}
};
let params = if let Some(p) = request.parameters {
ScanParameters {
sensitivity: p.sensitivity.clamp(0.0, 1.0),
max_depth: p.max_depth.max(0.0),
resolution: match p.resolution {
ScanResolutionDto::Quick => ScanResolution::Quick,
ScanResolutionDto::Standard => ScanResolution::Standard,
ScanResolutionDto::High => ScanResolution::High,
ScanResolutionDto::Maximum => ScanResolution::Maximum,
},
enhanced_breathing: p.enhanced_breathing,
heartbeat_detection: p.heartbeat_detection,
}
} else {
ScanParameters::default()
};
let zone = ScanZone::with_parameters(&request.name, bounds, params);
let zone_response = zone_to_response(&zone);
let zone_id = *zone.id().as_uuid();
// Add zone to event
let added = state.update_event(event_id, move |e| {
e.add_zone(zone);
true
});
if added.is_none() {
return Err(ApiError::event_not_found(event_id));
}
tracing::info!(event_id = %event_id, zone_id = %zone_id, "Added scan zone");
Ok((StatusCode::CREATED, Json(zone_response)))
}
// ============================================================================
// Survivor Handlers
// ============================================================================
/// List survivors detected in a disaster event.
///
/// # OpenAPI Specification
///
/// ```yaml
/// /api/v1/mat/events/{event_id}/survivors:
/// get:
/// summary: List survivors
/// description: Returns all detected survivors in a disaster event
/// tags: [Survivors]
/// parameters:
/// - name: event_id
/// in: path
/// required: true
/// schema:
/// type: string
/// format: uuid
/// - name: triage_status
/// in: query
/// description: Filter by triage status
/// schema:
/// type: string
/// enum: [Immediate, Delayed, Minor, Deceased, Unknown]
/// - name: zone_id
/// in: query
/// description: Filter by zone
/// schema:
/// type: string
/// format: uuid
/// - name: min_confidence
/// in: query
/// description: Minimum confidence threshold
/// schema:
/// type: number
/// - name: deteriorating_only
/// in: query
/// description: Only return deteriorating survivors
/// schema:
/// type: boolean
/// responses:
/// 200:
/// description: List of survivors
/// content:
/// application/json:
/// schema:
/// $ref: '#/components/schemas/SurvivorListResponse'
/// 404:
/// description: Event not found
/// ```
#[tracing::instrument(skip(state))]
pub async fn list_survivors(
State(state): State<AppState>,
Path(event_id): Path<Uuid>,
Query(query): Query<ListSurvivorsQuery>,
) -> ApiResult<Json<SurvivorListResponse>> {
let event = state
.get_event(event_id)
.ok_or_else(|| ApiError::event_not_found(event_id))?;
let mut triage_summary = TriageSummary::default();
let survivors: Vec<_> = event
.survivors()
.into_iter()
.filter(|s| {
// Update triage counts for all survivors
update_triage_summary(&mut triage_summary, s.triage_status());
// Apply filters
if let Some(ref ts) = query.triage_status {
let survivor_triage: TriageStatusDto = s.triage_status().clone().into();
if !matches_triage_status(&survivor_triage, ts) {
return false;
}
}
if let Some(zone_id) = query.zone_id {
if s.zone_id().as_uuid() != &zone_id {
return false;
}
}
if let Some(min_conf) = query.min_confidence {
if s.confidence() < min_conf {
return false;
}
}
if query.deteriorating_only && !s.is_deteriorating() {
return false;
}
true
})
.map(survivor_to_response)
.collect();
let total = survivors.len();
Ok(Json(SurvivorListResponse {
survivors,
total,
triage_summary,
}))
}
// ============================================================================
// Alert Handlers
// ============================================================================
/// List alerts for a disaster event.
///
/// # OpenAPI Specification
///
/// ```yaml
/// /api/v1/mat/events/{event_id}/alerts:
/// get:
/// summary: List alerts
/// description: Returns all alerts generated for a disaster event
/// tags: [Alerts]
/// parameters:
/// - name: event_id
/// in: path
/// required: true
/// schema:
/// type: string
/// format: uuid
/// - name: priority
/// in: query
/// description: Filter by priority
/// schema:
/// type: string
/// enum: [Critical, High, Medium, Low]
/// - name: status
/// in: query
/// description: Filter by status
/// schema:
/// type: string
/// - name: pending_only
/// in: query
/// description: Only return pending alerts
/// schema:
/// type: boolean
/// - name: active_only
/// in: query
/// description: Only return active alerts
/// schema:
/// type: boolean
/// responses:
/// 200:
/// description: List of alerts
/// content:
/// application/json:
/// schema:
/// $ref: '#/components/schemas/AlertListResponse'
/// 404:
/// description: Event not found
/// ```
#[tracing::instrument(skip(state))]
pub async fn list_alerts(
State(state): State<AppState>,
Path(event_id): Path<Uuid>,
Query(query): Query<ListAlertsQuery>,
) -> ApiResult<Json<AlertListResponse>> {
// Verify event exists
if state.get_event(event_id).is_none() {
return Err(ApiError::event_not_found(event_id));
}
let all_alerts = state.list_alerts_for_event(event_id);
let mut priority_counts = PriorityCounts::default();
let alerts: Vec<_> = all_alerts
.into_iter()
.filter(|a| {
// Update priority counts
update_priority_counts(&mut priority_counts, a.priority());
// Apply filters
if let Some(ref priority) = query.priority {
let alert_priority: PriorityDto = a.priority().into();
if !matches_priority(&alert_priority, priority) {
return false;
}
}
if let Some(ref status) = query.status {
let alert_status: AlertStatusDto = a.status().clone().into();
if !matches_alert_status(&alert_status, status) {
return false;
}
}
if query.pending_only && !a.is_pending() {
return false;
}
if query.active_only && !a.is_active() {
return false;
}
true
})
.map(|a| alert_to_response(&a))
.collect();
let total = alerts.len();
Ok(Json(AlertListResponse {
alerts,
total,
priority_counts,
}))
}
/// Acknowledge an alert.
///
/// # OpenAPI Specification
///
/// ```yaml
/// /api/v1/mat/alerts/{alert_id}/acknowledge:
/// post:
/// summary: Acknowledge an alert
/// description: Marks an alert as acknowledged by a rescue team
/// tags: [Alerts]
/// parameters:
/// - name: alert_id
/// in: path
/// required: true
/// schema:
/// type: string
/// format: uuid
/// requestBody:
/// required: true
/// content:
/// application/json:
/// schema:
/// $ref: '#/components/schemas/AcknowledgeAlertRequest'
/// responses:
/// 200:
/// description: Alert acknowledged
/// content:
/// application/json:
/// schema:
/// $ref: '#/components/schemas/AcknowledgeAlertResponse'
/// 404:
/// description: Alert not found
/// 409:
/// description: Alert already acknowledged
/// ```
#[tracing::instrument(skip(state))]
pub async fn acknowledge_alert(
State(state): State<AppState>,
Path(alert_id): Path<Uuid>,
Json(request): Json<AcknowledgeAlertRequest>,
) -> ApiResult<Json<AcknowledgeAlertResponse>> {
let alert_data = state
.get_alert(alert_id)
.ok_or_else(|| ApiError::alert_not_found(alert_id))?;
if !alert_data.alert.is_pending() {
return Err(ApiError::InvalidState {
message: "Alert is not in pending state".to_string(),
current_state: format!("{:?}", alert_data.alert.status()),
});
}
let event_id = alert_data.event_id;
// Acknowledge the alert
state.update_alert(alert_id, |a| {
a.acknowledge(&request.acknowledged_by);
});
// Get updated alert
let updated = state
.get_alert(alert_id)
.ok_or_else(|| ApiError::alert_not_found(alert_id))?;
let response = alert_to_response(&updated.alert);
// Broadcast update
state.broadcast(WebSocketMessage::AlertUpdated {
event_id,
alert: response.clone(),
});
tracing::info!(
alert_id = %alert_id,
acknowledged_by = %request.acknowledged_by,
"Alert acknowledged"
);
Ok(Json(AcknowledgeAlertResponse {
success: true,
alert: response,
}))
}
// ============================================================================
// Helper Functions
// ============================================================================
fn event_to_response(event: DisasterEvent) -> EventResponse {
let triage_counts = event.triage_counts();
EventResponse {
id: *event.id().as_uuid(),
event_type: event.event_type().clone().into(),
status: event.status().clone().into(),
start_time: *event.start_time(),
latitude: event.location().y(),
longitude: event.location().x(),
description: event.description().to_string(),
zone_count: event.zones().len(),
survivor_count: event.survivors().len(),
triage_summary: TriageSummary {
immediate: triage_counts.immediate,
delayed: triage_counts.delayed,
minor: triage_counts.minor,
deceased: triage_counts.deceased,
unknown: triage_counts.unknown,
},
metadata: Some(EventMetadataDto {
estimated_occupancy: event.metadata().estimated_occupancy,
confirmed_rescued: event.metadata().confirmed_rescued,
confirmed_deceased: event.metadata().confirmed_deceased,
weather: event.metadata().weather.clone(),
lead_agency: event.metadata().lead_agency.clone(),
}),
}
}
fn zone_to_response(zone: &ScanZone) -> ZoneResponse {
let bounds = match zone.bounds() {
ZoneBounds::Rectangle { min_x, min_y, max_x, max_y } => {
ZoneBoundsDto::Rectangle {
min_x: *min_x,
min_y: *min_y,
max_x: *max_x,
max_y: *max_y,
}
}
ZoneBounds::Circle { center_x, center_y, radius } => {
ZoneBoundsDto::Circle {
center_x: *center_x,
center_y: *center_y,
radius: *radius,
}
}
ZoneBounds::Polygon { vertices } => {
ZoneBoundsDto::Polygon {
vertices: vertices.clone(),
}
}
};
let params = zone.parameters();
let parameters = ScanParametersDto {
sensitivity: params.sensitivity,
max_depth: params.max_depth,
resolution: match params.resolution {
ScanResolution::Quick => ScanResolutionDto::Quick,
ScanResolution::Standard => ScanResolutionDto::Standard,
ScanResolution::High => ScanResolutionDto::High,
ScanResolution::Maximum => ScanResolutionDto::Maximum,
},
enhanced_breathing: params.enhanced_breathing,
heartbeat_detection: params.heartbeat_detection,
};
ZoneResponse {
id: *zone.id().as_uuid(),
name: zone.name().to_string(),
status: zone.status().clone().into(),
bounds,
area: zone.area(),
parameters,
last_scan: zone.last_scan().cloned(),
scan_count: zone.scan_count(),
detections_count: zone.detections_count(),
}
}
fn survivor_to_response(survivor: &crate::Survivor) -> SurvivorResponse {
let location = survivor.location().map(|loc| LocationDto {
x: loc.x,
y: loc.y,
z: loc.z,
depth: loc.depth(),
uncertainty_radius: loc.uncertainty.horizontal_error,
confidence: loc.uncertainty.confidence,
});
let latest_vitals = survivor.vital_signs().latest();
let vital_signs = VitalSignsSummaryDto {
breathing_rate: latest_vitals.and_then(|v| v.breathing.as_ref().map(|b| b.rate_bpm)),
breathing_type: latest_vitals.and_then(|v| v.breathing.as_ref().map(|b| format!("{:?}", b.pattern_type))),
heart_rate: latest_vitals.and_then(|v| v.heartbeat.as_ref().map(|h| h.rate_bpm)),
has_heartbeat: latest_vitals.map(|v| v.has_heartbeat()).unwrap_or(false),
has_movement: latest_vitals.map(|v| v.has_movement()).unwrap_or(false),
movement_type: latest_vitals.and_then(|v| {
if v.movement.movement_type != MovementType::None {
Some(format!("{:?}", v.movement.movement_type))
} else {
None
}
}),
timestamp: latest_vitals.map(|v| v.timestamp).unwrap_or_else(chrono::Utc::now),
};
let metadata = {
let m = survivor.metadata();
if m.notes.is_empty() && m.tags.is_empty() && m.assigned_team.is_none() {
None
} else {
Some(SurvivorMetadataDto {
estimated_age_category: m.estimated_age_category.as_ref().map(|a| format!("{:?}", a)),
assigned_team: m.assigned_team.clone(),
notes: m.notes.clone(),
tags: m.tags.clone(),
})
}
};
SurvivorResponse {
id: *survivor.id().as_uuid(),
zone_id: *survivor.zone_id().as_uuid(),
status: survivor.status().clone().into(),
triage_status: survivor.triage_status().clone().into(),
location,
vital_signs,
confidence: survivor.confidence(),
first_detected: *survivor.first_detected(),
last_updated: *survivor.last_updated(),
is_deteriorating: survivor.is_deteriorating(),
metadata,
}
}
fn alert_to_response(alert: &crate::Alert) -> AlertResponse {
let location = alert.payload().location.as_ref().map(|loc| LocationDto {
x: loc.x,
y: loc.y,
z: loc.z,
depth: loc.depth(),
uncertainty_radius: loc.uncertainty.horizontal_error,
confidence: loc.uncertainty.confidence,
});
AlertResponse {
id: *alert.id().as_uuid(),
survivor_id: *alert.survivor_id().as_uuid(),
priority: alert.priority().into(),
status: alert.status().clone().into(),
title: alert.payload().title.clone(),
message: alert.payload().message.clone(),
triage_status: alert.payload().triage_status.clone().into(),
location,
recommended_action: if alert.payload().recommended_action.is_empty() {
None
} else {
Some(alert.payload().recommended_action.clone())
},
created_at: *alert.created_at(),
acknowledged_at: alert.acknowledged_at().cloned(),
acknowledged_by: alert.acknowledged_by().map(String::from),
escalation_count: alert.escalation_count(),
}
}
fn update_triage_summary(summary: &mut TriageSummary, status: &crate::TriageStatus) {
match status {
crate::TriageStatus::Immediate => summary.immediate += 1,
crate::TriageStatus::Delayed => summary.delayed += 1,
crate::TriageStatus::Minor => summary.minor += 1,
crate::TriageStatus::Deceased => summary.deceased += 1,
crate::TriageStatus::Unknown => summary.unknown += 1,
}
}
fn update_priority_counts(counts: &mut PriorityCounts, priority: crate::Priority) {
match priority {
crate::Priority::Critical => counts.critical += 1,
crate::Priority::High => counts.high += 1,
crate::Priority::Medium => counts.medium += 1,
crate::Priority::Low => counts.low += 1,
}
}
// Match helper functions (avoiding PartialEq on DTOs for flexibility)
fn matches_status(a: &EventStatusDto, b: &EventStatusDto) -> bool {
std::mem::discriminant(a) == std::mem::discriminant(b)
}
fn matches_triage_status(a: &TriageStatusDto, b: &TriageStatusDto) -> bool {
std::mem::discriminant(a) == std::mem::discriminant(b)
}
fn matches_priority(a: &PriorityDto, b: &PriorityDto) -> bool {
std::mem::discriminant(a) == std::mem::discriminant(b)
}
fn matches_alert_status(a: &AlertStatusDto, b: &AlertStatusDto) -> bool {
std::mem::discriminant(a) == std::mem::discriminant(b)
}

View File

@@ -0,0 +1,71 @@
//! 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
//!
//! ### 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))
// WebSocket endpoint
.route("/ws/mat/stream", get(websocket::ws_handler))
.with_state(state)
}

View File

@@ -0,0 +1,258 @@
//! 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,
};
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,
}
/// 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);
Self {
inner: Arc::new(AppStateInner {
events: RwLock::new(HashMap::new()),
alerts: RwLock::new(HashMap::new()),
broadcast_tx,
config,
}),
}
}
// ========================================================================
// 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);
}
}

View File

@@ -0,0 +1,330 @@
//! 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));
}
}

View File

@@ -0,0 +1,293 @@
//! 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);
}
}
}

View File

@@ -0,0 +1,390 @@
//! 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);
}
}

View File

@@ -0,0 +1,17 @@
//! 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 heartbeat;
mod movement;
mod pipeline;
pub use breathing::{BreathingDetector, BreathingDetectorConfig};
pub use heartbeat::{HeartbeatDetector, HeartbeatDetectorConfig};
pub use movement::{MovementClassifier, MovementClassifierConfig};
pub use pipeline::{DetectionPipeline, DetectionConfig, VitalSignsDetector, CsiDataBuffer};

View File

@@ -0,0 +1,275 @@
//! 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);
}
}

View File

@@ -0,0 +1,466 @@
//! 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
pub async fn process_zone(&self, zone: &ScanZone) -> Result<Option<VitalSignsReading>, MatError> {
// In a real implementation, this would:
// 1. Collect CSI data from sensors in the zone
// 2. Preprocess the data
// 3. Run detection algorithms
// For now, check if we have buffered data
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(&amplitudes, &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(&amplitudes, &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);
}
}

View File

@@ -0,0 +1,459 @@
//! 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);
}
}

View File

@@ -0,0 +1,371 @@
//! 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());
}
}

View File

@@ -0,0 +1,494 @@
//! 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);
Ok(self.survivors.last().unwrap())
}
/// 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());
}
}

View File

@@ -0,0 +1,497 @@
//! 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);
}
}

View File

@@ -0,0 +1,26 @@
//! 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::*;

View File

@@ -0,0 +1,494 @@
//! 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));
}
}

View File

@@ -0,0 +1,424 @@
//! 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));
}
}

View File

@@ -0,0 +1,350 @@
//! 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
);
}
}

View File

@@ -0,0 +1,375 @@
//! 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);
}
}

View File

@@ -0,0 +1,235 @@
//! 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
));
}
}

View File

@@ -0,0 +1,293 @@
//! 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);
}
}

View File

@@ -0,0 +1,337 @@
//! 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(&amplitudes, &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);
}
}

View File

@@ -0,0 +1,488 @@
//! # 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},
};
pub use detection::{
BreathingDetector, BreathingDetectorConfig,
HeartbeatDetector, HeartbeatDetectorConfig,
MovementClassifier, MovementClassifierConfig,
VitalSignsDetector, DetectionPipeline, DetectionConfig,
};
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,
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());
Self {
config,
event: None,
detection_pipeline,
localization_service,
alert_dispatcher,
running: std::sync::atomic::AtomicBool::new(false),
}
}
/// 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
async fn scan_cycle(&mut self) -> Result<()> {
// 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;
}
// This would integrate with actual hardware in production
// For now, we process any available CSI data
let detection_result = self.detection_pipeline.process_zone(zone).await?;
if let Some(vital_signs) = detection_result {
// Attempt localization
let location = self.localization_service
.estimate_position(&vital_signs, zone);
detections.push((zone.id().clone(), vital_signs, location));
}
}
}
// 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, vital_signs, location) in detections {
let survivor = event.record_detection(zone_id, vital_signs, location)?;
// Generate alert if needed
if survivor.should_alert() {
let alert = self.alert_dispatcher.generate_alert(survivor)?;
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,
// Detection
DetectionPipeline, VitalSignsDetector,
// 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());
}
}

View File

@@ -0,0 +1,297 @@
//! 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);
}
}

View File

@@ -0,0 +1,393 @@
//! 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)
}
/// Simulate RSSI measurements (placeholder for real sensor data)
fn simulate_rssi_measurements(
&self,
sensors: &[crate::domain::SensorPosition],
_vitals: &VitalSignsReading,
) -> Vec<(String, f64)> {
// In production, this would read actual sensor values
// For now, return placeholder values
sensors.iter()
.map(|s| (s.id.clone(), -50.0 + rand_range(-10.0, 10.0)))
.collect()
}
/// 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()
}
}
/// Simple random range (for simulation)
fn rand_range(min: f64, max: f64) -> f64 {
use std::time::{SystemTime, UNIX_EPOCH};
let seed = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let pseudo_random = ((seed * 1103515245 + 12345) % (1 << 31)) as f64 / (1u64 << 31) as f64;
min + pseudo_random * (max - min)
}
#[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);
}
}

View File

@@ -0,0 +1,14 @@
//! 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};

View File

@@ -0,0 +1,377 @@
//! 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());
}
}

View File

@@ -0,0 +1,765 @@
//! 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())
.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())
.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]);
}
}

View File

@@ -0,0 +1,692 @@
//! 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(&amplitudes, &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);
}
}

View File

@@ -3,5 +3,61 @@ 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"]

View File

@@ -1 +1,132 @@
//! WiFi-DensePose WebAssembly bindings (stub)
//! WiFi-DensePose WebAssembly bindings
//!
//! This crate provides WebAssembly bindings for browser-based applications using
//! WiFi-DensePose technology. It includes:
//!
//! - **mat**: WiFi-Mat disaster response dashboard module for browser integration
//!
//! # Features
//!
//! - `mat` - Enable WiFi-Mat disaster detection WASM bindings
//! - `console_error_panic_hook` - Better panic messages in browser console
//!
//! # Building for WASM
//!
//! ```bash
//! # Build with wasm-pack
//! wasm-pack build --target web --features mat
//!
//! # Or with cargo
//! cargo build --target wasm32-unknown-unknown --features mat
//! ```
//!
//! # Example Usage (JavaScript)
//!
//! ```javascript
//! import init, { MatDashboard, initLogging } from './wifi_densepose_wasm.js';
//!
//! async function main() {
//! await init();
//! initLogging('info');
//!
//! const dashboard = new MatDashboard();
//!
//! // Create a disaster event
//! const eventId = dashboard.createEvent('earthquake', 37.7749, -122.4194, 'Bay Area Earthquake');
//!
//! // Add scan zones
//! dashboard.addRectangleZone('Building A', 50, 50, 200, 150);
//! dashboard.addCircleZone('Search Area B', 400, 200, 80);
//!
//! // Subscribe to events
//! dashboard.onSurvivorDetected((survivor) => {
//! console.log('Survivor detected:', survivor);
//! updateUI(survivor);
//! });
//!
//! dashboard.onAlertGenerated((alert) => {
//! showNotification(alert);
//! });
//!
//! // Render to canvas
//! const canvas = document.getElementById('map');
//! const ctx = canvas.getContext('2d');
//!
//! function render() {
//! ctx.clearRect(0, 0, canvas.width, canvas.height);
//! dashboard.renderZones(ctx);
//! dashboard.renderSurvivors(ctx);
//! requestAnimationFrame(render);
//! }
//! render();
//! }
//!
//! main();
//! ```
use wasm_bindgen::prelude::*;
// WiFi-Mat module for disaster response dashboard
pub mod mat;
pub use mat::*;
/// Initialize the WASM module.
/// Call this once at startup before using any other functions.
#[wasm_bindgen(start)]
pub fn init() {
// Set panic hook for better error messages in browser console
#[cfg(feature = "console_error_panic_hook")]
console_error_panic_hook::set_once();
}
/// Initialize logging with specified level.
///
/// @param {string} level - Log level: "trace", "debug", "info", "warn", "error"
#[wasm_bindgen(js_name = initLogging)]
pub fn init_logging(level: &str) {
let log_level = match level.to_lowercase().as_str() {
"trace" => log::Level::Trace,
"debug" => log::Level::Debug,
"info" => log::Level::Info,
"warn" => log::Level::Warn,
"error" => log::Level::Error,
_ => log::Level::Info,
};
let _ = wasm_logger::init(wasm_logger::Config::new(log_level));
log::info!("WiFi-DensePose WASM initialized with log level: {}", level);
}
/// Get the library version.
///
/// @returns {string} Version string
#[wasm_bindgen(js_name = getVersion)]
pub fn get_version() -> String {
env!("CARGO_PKG_VERSION").to_string()
}
/// Check if the MAT feature is enabled.
///
/// @returns {boolean} True if MAT module is available
#[wasm_bindgen(js_name = isMatEnabled)]
pub fn is_mat_enabled() -> bool {
true
}
/// Get current timestamp in milliseconds (for performance measurements).
///
/// @returns {number} Timestamp in milliseconds
#[wasm_bindgen(js_name = getTimestamp)]
pub fn get_timestamp() -> f64 {
let window = web_sys::window().expect("no global window");
let performance = window.performance().expect("no performance object");
performance.now()
}
// Re-export all public types from mat module for easy access
pub mod types {
pub use super::mat::{
JsAlert, JsAlertPriority, JsDashboardStats, JsDisasterType, JsScanZone, JsSurvivor,
JsTriageStatus, JsZoneStatus,
};
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff