feat: Add wifi-densepose-mat disaster detection module
Implements WiFi-Mat (Mass Casualty Assessment Tool) for detecting and localizing survivors trapped in rubble, earthquakes, and natural disasters. Architecture: - Domain-Driven Design with bounded contexts (Detection, Localization, Alerting) - Modular Rust crate integrating with existing wifi-densepose-* crates - Event-driven architecture for audit trails and distributed deployments Features: - Breathing pattern detection from CSI amplitude variations - Heartbeat detection using micro-Doppler analysis - Movement classification (gross, fine, tremor, periodic) - START protocol-compatible triage classification - 3D position estimation via triangulation and depth estimation - Real-time alert generation with priority escalation Documentation: - ADR-001: Architecture Decision Record for wifi-Mat - DDD domain model specification
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
[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"]
|
||||
std = []
|
||||
portable = ["low-power"]
|
||||
low-power = []
|
||||
distributed = ["tokio/sync"]
|
||||
drone = ["distributed"]
|
||||
serde = ["dep:serde", "chrono/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"
|
||||
|
||||
# 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"]
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -0,0 +1,274 @@
|
||||
//! 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)
|
||||
let signal: Vec<f64> = (0..1000)
|
||||
.map(|i| (2.0 * std::f64::consts::PI * i as f64 / 100.0).sin() * 0.3)
|
||||
.collect();
|
||||
|
||||
let profile = classifier.classify(&signal, 100.0);
|
||||
// Should detect periodic or fine movement
|
||||
assert!(!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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
//! Detection pipeline combining all vital signs detectors.
|
||||
|
||||
use crate::domain::{ScanZone, VitalSignsReading, ConfidenceScore};
|
||||
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,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
impl DetectionPipeline {
|
||||
/// Create a new detection pipeline
|
||||
pub fn new(config: DetectionConfig) -> Self {
|
||||
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)),
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
let reading = self.detect_from_buffer(&buffer, zone)?;
|
||||
|
||||
// Check minimum confidence
|
||||
if let Some(ref r) = reading {
|
||||
if r.confidence.value() < self.config.min_confidence {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(reading)
|
||||
}
|
||||
|
||||
/// 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());
|
||||
self.config = config;
|
||||
}
|
||||
}
|
||||
|
||||
impl VitalSignsDetector for DetectionPipeline {
|
||||
fn detect(&self, csi_data: &CsiDataBuffer) -> Option<VitalSignsReading> {
|
||||
// Detect breathing from amplitude variations
|
||||
let breathing = self.breathing_detector.detect(
|
||||
&csi_data.amplitudes,
|
||||
csi_data.sample_rate,
|
||||
);
|
||||
|
||||
// Detect heartbeat from phase variations
|
||||
let heartbeat = if self.config.enable_heartbeat {
|
||||
let breathing_rate = breathing.as_ref().map(|b| b.rate_bpm as f64);
|
||||
self.heartbeat_detector.detect(
|
||||
&csi_data.phases,
|
||||
csi_data.sample_rate,
|
||||
breathing_rate,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Classify movement
|
||||
let movement = self.movement_classifier.classify(
|
||||
&csi_data.amplitudes,
|
||||
csi_data.sample_rate,
|
||||
);
|
||||
|
||||
// Create reading if we detected anything
|
||||
if breathing.is_some() || heartbeat.is_some()
|
||||
|| movement.movement_type != crate::domain::MovementType::None
|
||||
{
|
||||
Some(VitalSignsReading::new(breathing, heartbeat, movement))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn create_test_buffer() -> CsiDataBuffer {
|
||||
let mut buffer = CsiDataBuffer::new(100.0);
|
||||
|
||||
// Add 10 seconds of simulated breathing signal
|
||||
let num_samples = 1000;
|
||||
let amplitudes: Vec<f64> = (0..num_samples)
|
||||
.map(|i| {
|
||||
let t = i as f64 / 100.0;
|
||||
// 16 BPM breathing (0.267 Hz)
|
||||
(2.0 * std::f64::consts::PI * 0.267 * t).sin()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let phases: Vec<f64> = (0..num_samples)
|
||||
.map(|i| {
|
||||
let t = i as f64 / 100.0;
|
||||
// Phase variation from movement
|
||||
(2.0 * std::f64::consts::PI * 0.267 * t).sin() * 0.5
|
||||
})
|
||||
.collect();
|
||||
|
||||
buffer.add_samples(&litudes, &phases);
|
||||
buffer
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pipeline_creation() {
|
||||
let config = DetectionConfig::default();
|
||||
let pipeline = DetectionPipeline::new(config);
|
||||
assert_eq!(pipeline.config().sample_rate, 1000.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_csi_buffer() {
|
||||
let mut buffer = CsiDataBuffer::new(100.0);
|
||||
|
||||
assert!(!buffer.has_sufficient_data(5.0));
|
||||
|
||||
let amplitudes: Vec<f64> = vec![1.0; 600];
|
||||
let phases: Vec<f64> = vec![0.0; 600];
|
||||
buffer.add_samples(&litudes, &phases);
|
||||
|
||||
assert!(buffer.has_sufficient_data(5.0));
|
||||
assert_eq!(buffer.duration(), 6.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vital_signs_detection() {
|
||||
let config = DetectionConfig::default();
|
||||
let pipeline = DetectionPipeline::new(config);
|
||||
let buffer = create_test_buffer();
|
||||
|
||||
let result = pipeline.detect(&buffer);
|
||||
assert!(result.is_some());
|
||||
|
||||
let reading = result.unwrap();
|
||||
assert!(reading.has_vitals());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_from_disaster_config() {
|
||||
let disaster_config = DisasterConfig::builder()
|
||||
.sensitivity(0.9)
|
||||
.build();
|
||||
|
||||
let detection_config = DetectionConfig::from_disaster_config(&disaster_config);
|
||||
|
||||
// High sensitivity should enable heartbeat detection
|
||||
assert!(detection_config.enable_heartbeat);
|
||||
// Low minimum confidence due to high sensitivity
|
||||
assert!(detection_config.min_confidence < 0.4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
//! 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(),
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
//! 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
|
||||
if let Some(loc) = &location {
|
||||
if let Some(existing) = self.find_nearby_survivor(loc, 2.0) {
|
||||
// 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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
//! Adapter for wifi-densepose-hardware crate.
|
||||
|
||||
use super::AdapterError;
|
||||
use crate::domain::{SensorPosition, SensorType};
|
||||
|
||||
/// Hardware adapter for sensor communication
|
||||
pub struct HardwareAdapter {
|
||||
/// Connected sensors
|
||||
sensors: Vec<SensorInfo>,
|
||||
/// Whether hardware is initialized
|
||||
initialized: bool,
|
||||
}
|
||||
|
||||
/// Information about a connected sensor
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SensorInfo {
|
||||
/// Unique sensor ID
|
||||
pub id: String,
|
||||
/// Sensor position
|
||||
pub position: SensorPosition,
|
||||
/// Current status
|
||||
pub status: SensorStatus,
|
||||
/// Last RSSI reading (if available)
|
||||
pub last_rssi: Option<f64>,
|
||||
/// Battery level (0-100, if applicable)
|
||||
pub battery_level: Option<u8>,
|
||||
}
|
||||
|
||||
/// Status of a sensor
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SensorStatus {
|
||||
/// Sensor is connected and operational
|
||||
Connected,
|
||||
/// Sensor is disconnected
|
||||
Disconnected,
|
||||
/// Sensor is in error state
|
||||
Error,
|
||||
/// Sensor is initializing
|
||||
Initializing,
|
||||
/// Sensor battery is low
|
||||
LowBattery,
|
||||
}
|
||||
|
||||
impl HardwareAdapter {
|
||||
/// Create a new hardware adapter
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sensors: Vec::new(),
|
||||
initialized: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize hardware communication
|
||||
pub fn initialize(&mut self) -> Result<(), AdapterError> {
|
||||
// In production, this would initialize actual hardware
|
||||
// using wifi-densepose-hardware crate
|
||||
self.initialized = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Discover available sensors
|
||||
pub fn discover_sensors(&mut self) -> Result<Vec<SensorInfo>, AdapterError> {
|
||||
if !self.initialized {
|
||||
return Err(AdapterError::Hardware("Hardware not initialized".into()));
|
||||
}
|
||||
|
||||
// In production, this would scan for WiFi devices
|
||||
// For now, return empty list (would be populated by real hardware)
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
/// Add a sensor
|
||||
pub fn add_sensor(&mut self, sensor: SensorInfo) -> Result<(), AdapterError> {
|
||||
if self.sensors.iter().any(|s| s.id == sensor.id) {
|
||||
return Err(AdapterError::Hardware(format!(
|
||||
"Sensor {} already registered",
|
||||
sensor.id
|
||||
)));
|
||||
}
|
||||
|
||||
self.sensors.push(sensor);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a sensor
|
||||
pub fn remove_sensor(&mut self, sensor_id: &str) -> Result<(), AdapterError> {
|
||||
let initial_len = self.sensors.len();
|
||||
self.sensors.retain(|s| s.id != sensor_id);
|
||||
|
||||
if self.sensors.len() == initial_len {
|
||||
return Err(AdapterError::Hardware(format!(
|
||||
"Sensor {} not found",
|
||||
sensor_id
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get all sensors
|
||||
pub fn sensors(&self) -> &[SensorInfo] {
|
||||
&self.sensors
|
||||
}
|
||||
|
||||
/// Get operational sensors
|
||||
pub fn operational_sensors(&self) -> Vec<&SensorInfo> {
|
||||
self.sensors
|
||||
.iter()
|
||||
.filter(|s| s.status == SensorStatus::Connected)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get sensor positions for localization
|
||||
pub fn sensor_positions(&self) -> Vec<SensorPosition> {
|
||||
self.sensors
|
||||
.iter()
|
||||
.filter(|s| s.status == SensorStatus::Connected)
|
||||
.map(|s| s.position.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Read CSI data from sensors
|
||||
pub fn read_csi(&self) -> Result<CsiReadings, AdapterError> {
|
||||
if !self.initialized {
|
||||
return Err(AdapterError::Hardware("Hardware not initialized".into()));
|
||||
}
|
||||
|
||||
// In production, this would read actual CSI data
|
||||
// For now, return empty readings
|
||||
Ok(CsiReadings {
|
||||
timestamp: chrono::Utc::now(),
|
||||
readings: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Read RSSI from all sensors
|
||||
pub fn read_rssi(&self) -> Result<Vec<(String, f64)>, AdapterError> {
|
||||
if !self.initialized {
|
||||
return Err(AdapterError::Hardware("Hardware not initialized".into()));
|
||||
}
|
||||
|
||||
// Return last known RSSI values
|
||||
Ok(self
|
||||
.sensors
|
||||
.iter()
|
||||
.filter_map(|s| s.last_rssi.map(|rssi| (s.id.clone(), rssi)))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Update sensor position
|
||||
pub fn update_sensor_position(
|
||||
&mut self,
|
||||
sensor_id: &str,
|
||||
position: SensorPosition,
|
||||
) -> Result<(), AdapterError> {
|
||||
let sensor = self
|
||||
.sensors
|
||||
.iter_mut()
|
||||
.find(|s| s.id == sensor_id)
|
||||
.ok_or_else(|| AdapterError::Hardware(format!("Sensor {} not found", sensor_id)))?;
|
||||
|
||||
sensor.position = position;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check hardware health
|
||||
pub fn health_check(&self) -> HardwareHealth {
|
||||
let total = self.sensors.len();
|
||||
let connected = self
|
||||
.sensors
|
||||
.iter()
|
||||
.filter(|s| s.status == SensorStatus::Connected)
|
||||
.count();
|
||||
let low_battery = self
|
||||
.sensors
|
||||
.iter()
|
||||
.filter(|s| matches!(s.battery_level, Some(b) if b < 20))
|
||||
.count();
|
||||
|
||||
let status = if connected == 0 && total > 0 {
|
||||
HealthStatus::Critical
|
||||
} else if connected < total / 2 {
|
||||
HealthStatus::Degraded
|
||||
} else if low_battery > 0 {
|
||||
HealthStatus::Warning
|
||||
} else {
|
||||
HealthStatus::Healthy
|
||||
};
|
||||
|
||||
HardwareHealth {
|
||||
status,
|
||||
total_sensors: total,
|
||||
connected_sensors: connected,
|
||||
low_battery_sensors: low_battery,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for HardwareAdapter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// CSI readings from sensors
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CsiReadings {
|
||||
/// Timestamp of readings
|
||||
pub timestamp: chrono::DateTime<chrono::Utc>,
|
||||
/// Individual sensor readings
|
||||
pub readings: Vec<SensorCsiReading>,
|
||||
}
|
||||
|
||||
/// CSI reading from a single sensor
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SensorCsiReading {
|
||||
/// Sensor ID
|
||||
pub sensor_id: String,
|
||||
/// CSI amplitudes (per subcarrier)
|
||||
pub amplitudes: Vec<f64>,
|
||||
/// CSI phases (per subcarrier)
|
||||
pub phases: Vec<f64>,
|
||||
/// RSSI value
|
||||
pub rssi: f64,
|
||||
/// Noise floor
|
||||
pub noise_floor: f64,
|
||||
}
|
||||
|
||||
/// Hardware health status
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HardwareHealth {
|
||||
/// Overall status
|
||||
pub status: HealthStatus,
|
||||
/// Total number of sensors
|
||||
pub total_sensors: usize,
|
||||
/// Number of connected sensors
|
||||
pub connected_sensors: usize,
|
||||
/// Number of sensors with low battery
|
||||
pub low_battery_sensors: usize,
|
||||
}
|
||||
|
||||
/// Health status levels
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum HealthStatus {
|
||||
/// All systems operational
|
||||
Healthy,
|
||||
/// Minor issues, still functional
|
||||
Warning,
|
||||
/// Significant issues, reduced capability
|
||||
Degraded,
|
||||
/// System not functional
|
||||
Critical,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn create_test_sensor(id: &str) -> SensorInfo {
|
||||
SensorInfo {
|
||||
id: id.to_string(),
|
||||
position: SensorPosition {
|
||||
id: id.to_string(),
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
z: 1.5,
|
||||
sensor_type: SensorType::Transceiver,
|
||||
is_operational: true,
|
||||
},
|
||||
status: SensorStatus::Connected,
|
||||
last_rssi: Some(-45.0),
|
||||
battery_level: Some(80),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_sensor() {
|
||||
let mut adapter = HardwareAdapter::new();
|
||||
adapter.initialize().unwrap();
|
||||
|
||||
let sensor = create_test_sensor("s1");
|
||||
assert!(adapter.add_sensor(sensor).is_ok());
|
||||
assert_eq!(adapter.sensors().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_duplicate_sensor_error() {
|
||||
let mut adapter = HardwareAdapter::new();
|
||||
adapter.initialize().unwrap();
|
||||
|
||||
let sensor1 = create_test_sensor("s1");
|
||||
let sensor2 = create_test_sensor("s1");
|
||||
|
||||
adapter.add_sensor(sensor1).unwrap();
|
||||
assert!(adapter.add_sensor(sensor2).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_health_check() {
|
||||
let mut adapter = HardwareAdapter::new();
|
||||
adapter.initialize().unwrap();
|
||||
|
||||
// No sensors - should be healthy (nothing to fail)
|
||||
let health = adapter.health_check();
|
||||
assert!(matches!(health.status, HealthStatus::Healthy));
|
||||
|
||||
// Add connected sensor
|
||||
adapter.add_sensor(create_test_sensor("s1")).unwrap();
|
||||
let health = adapter.health_check();
|
||||
assert!(matches!(health.status, HealthStatus::Healthy));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sensor_positions() {
|
||||
let mut adapter = HardwareAdapter::new();
|
||||
adapter.initialize().unwrap();
|
||||
|
||||
adapter.add_sensor(create_test_sensor("s1")).unwrap();
|
||||
adapter.add_sensor(create_test_sensor("s2")).unwrap();
|
||||
|
||||
let positions = adapter.sensor_positions();
|
||||
assert_eq!(positions.len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//! 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
|
||||
|
||||
mod signal_adapter;
|
||||
mod neural_adapter;
|
||||
mod hardware_adapter;
|
||||
|
||||
pub use signal_adapter::SignalAdapter;
|
||||
pub use neural_adapter::NeuralAdapter;
|
||||
pub use hardware_adapter::HardwareAdapter;
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// 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),
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
//! Adapter for wifi-densepose-signal crate.
|
||||
|
||||
use super::AdapterError;
|
||||
use crate::domain::{BreathingPattern, BreathingType, ConfidenceScore};
|
||||
use crate::detection::CsiDataBuffer;
|
||||
|
||||
/// Features extracted from signal for vital signs detection
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct VitalFeatures {
|
||||
/// Breathing frequency features
|
||||
pub breathing_features: Vec<f64>,
|
||||
/// Heartbeat frequency features
|
||||
pub heartbeat_features: Vec<f64>,
|
||||
/// Movement energy features
|
||||
pub movement_features: Vec<f64>,
|
||||
/// Overall signal quality
|
||||
pub signal_quality: f64,
|
||||
}
|
||||
|
||||
/// Adapter for wifi-densepose-signal crate
|
||||
pub struct SignalAdapter {
|
||||
/// Window size for processing
|
||||
window_size: usize,
|
||||
/// Overlap between windows
|
||||
overlap: f64,
|
||||
/// Sample rate
|
||||
sample_rate: f64,
|
||||
}
|
||||
|
||||
impl SignalAdapter {
|
||||
/// Create a new signal adapter
|
||||
pub fn new(window_size: usize, overlap: f64, sample_rate: f64) -> Self {
|
||||
Self {
|
||||
window_size,
|
||||
overlap,
|
||||
sample_rate,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with default settings
|
||||
pub fn with_defaults() -> Self {
|
||||
Self::new(512, 0.5, 1000.0)
|
||||
}
|
||||
|
||||
/// Extract vital sign features from CSI data
|
||||
pub fn extract_vital_features(
|
||||
&self,
|
||||
csi_data: &CsiDataBuffer,
|
||||
) -> Result<VitalFeatures, AdapterError> {
|
||||
if csi_data.amplitudes.len() < self.window_size {
|
||||
return Err(AdapterError::Signal(
|
||||
"Insufficient data for feature extraction".into()
|
||||
));
|
||||
}
|
||||
|
||||
// Extract breathing-range features (0.1-0.5 Hz)
|
||||
let breathing_features = self.extract_frequency_band(
|
||||
&csi_data.amplitudes,
|
||||
0.1,
|
||||
0.5,
|
||||
)?;
|
||||
|
||||
// Extract heartbeat-range features (0.8-2.0 Hz)
|
||||
let heartbeat_features = self.extract_frequency_band(
|
||||
&csi_data.phases,
|
||||
0.8,
|
||||
2.0,
|
||||
)?;
|
||||
|
||||
// Extract movement features
|
||||
let movement_features = self.extract_movement_features(&csi_data.amplitudes)?;
|
||||
|
||||
// Calculate signal quality
|
||||
let signal_quality = self.calculate_signal_quality(&csi_data.amplitudes);
|
||||
|
||||
Ok(VitalFeatures {
|
||||
breathing_features,
|
||||
heartbeat_features,
|
||||
movement_features,
|
||||
signal_quality,
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert upstream CsiFeatures to breathing pattern
|
||||
pub fn to_breathing_pattern(
|
||||
&self,
|
||||
features: &VitalFeatures,
|
||||
) -> Option<BreathingPattern> {
|
||||
if features.breathing_features.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Extract key values from features
|
||||
let rate_estimate = features.breathing_features[0];
|
||||
let amplitude = features.breathing_features.get(1).copied().unwrap_or(0.5);
|
||||
let regularity = features.breathing_features.get(2).copied().unwrap_or(0.5);
|
||||
|
||||
// Convert rate from Hz to BPM
|
||||
let rate_bpm = (rate_estimate * 60.0) as f32;
|
||||
|
||||
// Validate rate
|
||||
if rate_bpm < 4.0 || rate_bpm > 60.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Determine breathing type
|
||||
let pattern_type = self.classify_breathing_type(rate_bpm, regularity);
|
||||
|
||||
Some(BreathingPattern {
|
||||
rate_bpm,
|
||||
amplitude: amplitude as f32,
|
||||
regularity: regularity as f32,
|
||||
pattern_type,
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract features from a frequency band
|
||||
fn extract_frequency_band(
|
||||
&self,
|
||||
signal: &[f64],
|
||||
low_freq: f64,
|
||||
high_freq: f64,
|
||||
) -> Result<Vec<f64>, AdapterError> {
|
||||
use rustfft::{FftPlanner, num_complex::Complex};
|
||||
|
||||
let n = signal.len().min(self.window_size);
|
||||
if n < 32 {
|
||||
return Err(AdapterError::Signal("Signal too short".into()));
|
||||
}
|
||||
|
||||
let fft_size = n.next_power_of_two();
|
||||
let mut planner = FftPlanner::new();
|
||||
let fft = planner.plan_fft_forward(fft_size);
|
||||
|
||||
// Prepare buffer with windowing
|
||||
let mut buffer: Vec<Complex<f64>> = signal.iter()
|
||||
.take(n)
|
||||
.enumerate()
|
||||
.map(|(i, &x)| {
|
||||
let window = 0.5 * (1.0 - (2.0 * std::f64::consts::PI * i as f64 / n as f64).cos());
|
||||
Complex::new(x * window, 0.0)
|
||||
})
|
||||
.collect();
|
||||
buffer.resize(fft_size, Complex::new(0.0, 0.0));
|
||||
|
||||
fft.process(&mut buffer);
|
||||
|
||||
// Extract magnitude spectrum in frequency range
|
||||
let freq_resolution = self.sample_rate / fft_size as f64;
|
||||
let low_bin = (low_freq / freq_resolution).ceil() as usize;
|
||||
let high_bin = (high_freq / freq_resolution).floor() as usize;
|
||||
|
||||
let mut features = Vec::new();
|
||||
|
||||
if high_bin > low_bin && high_bin < buffer.len() / 2 {
|
||||
// Find peak frequency
|
||||
let mut max_mag = 0.0;
|
||||
let mut peak_bin = low_bin;
|
||||
for i in low_bin..=high_bin {
|
||||
let mag = buffer[i].norm();
|
||||
if mag > max_mag {
|
||||
max_mag = mag;
|
||||
peak_bin = i;
|
||||
}
|
||||
}
|
||||
|
||||
// Peak frequency
|
||||
features.push(peak_bin as f64 * freq_resolution);
|
||||
// Peak magnitude (normalized)
|
||||
let total_power: f64 = buffer[1..buffer.len()/2]
|
||||
.iter()
|
||||
.map(|c| c.norm_sqr())
|
||||
.sum();
|
||||
features.push(if total_power > 0.0 { max_mag * max_mag / total_power } else { 0.0 });
|
||||
|
||||
// Band power ratio
|
||||
let band_power: f64 = buffer[low_bin..=high_bin]
|
||||
.iter()
|
||||
.map(|c| c.norm_sqr())
|
||||
.sum();
|
||||
features.push(if total_power > 0.0 { band_power / total_power } else { 0.0 });
|
||||
}
|
||||
|
||||
Ok(features)
|
||||
}
|
||||
|
||||
/// Extract movement-related features
|
||||
fn extract_movement_features(&self, signal: &[f64]) -> Result<Vec<f64>, AdapterError> {
|
||||
if signal.len() < 10 {
|
||||
return Err(AdapterError::Signal("Signal too short".into()));
|
||||
}
|
||||
|
||||
// Calculate variance
|
||||
let mean = signal.iter().sum::<f64>() / signal.len() as f64;
|
||||
let variance = signal.iter()
|
||||
.map(|x| (x - mean).powi(2))
|
||||
.sum::<f64>() / signal.len() as f64;
|
||||
|
||||
// Calculate max absolute change
|
||||
let max_change = signal.windows(2)
|
||||
.map(|w| (w[1] - w[0]).abs())
|
||||
.fold(0.0, f64::max);
|
||||
|
||||
// Calculate zero crossing rate
|
||||
let centered: Vec<f64> = signal.iter().map(|x| x - mean).collect();
|
||||
let zero_crossings: usize = centered.windows(2)
|
||||
.filter(|w| (w[0] >= 0.0) != (w[1] >= 0.0))
|
||||
.count();
|
||||
let zcr = zero_crossings as f64 / signal.len() as f64;
|
||||
|
||||
Ok(vec![variance, max_change, zcr])
|
||||
}
|
||||
|
||||
/// Calculate overall signal quality
|
||||
fn calculate_signal_quality(&self, signal: &[f64]) -> f64 {
|
||||
if signal.len() < 10 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// SNR estimate based on signal statistics
|
||||
let mean = signal.iter().sum::<f64>() / signal.len() as f64;
|
||||
let variance = signal.iter()
|
||||
.map(|x| (x - mean).powi(2))
|
||||
.sum::<f64>() / signal.len() as f64;
|
||||
|
||||
// Higher variance relative to mean suggests better signal
|
||||
let snr_estimate = if mean.abs() > 1e-10 {
|
||||
(variance.sqrt() / mean.abs()).min(10.0) / 10.0
|
||||
} else {
|
||||
0.5
|
||||
};
|
||||
|
||||
snr_estimate.clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// Classify breathing type from rate and regularity
|
||||
fn classify_breathing_type(&self, rate_bpm: f32, regularity: f64) -> BreathingType {
|
||||
if rate_bpm < 6.0 {
|
||||
if regularity < 0.3 {
|
||||
BreathingType::Agonal
|
||||
} else {
|
||||
BreathingType::Shallow
|
||||
}
|
||||
} else if rate_bpm < 10.0 {
|
||||
BreathingType::Shallow
|
||||
} else if rate_bpm > 30.0 {
|
||||
BreathingType::Labored
|
||||
} else if regularity < 0.4 {
|
||||
BreathingType::Irregular
|
||||
} else {
|
||||
BreathingType::Normal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SignalAdapter {
|
||||
fn default() -> Self {
|
||||
Self::with_defaults()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn create_test_buffer() -> CsiDataBuffer {
|
||||
let mut buffer = CsiDataBuffer::new(100.0);
|
||||
|
||||
// 10 seconds of data with breathing pattern
|
||||
let amplitudes: Vec<f64> = (0..1000)
|
||||
.map(|i| {
|
||||
let t = i as f64 / 100.0;
|
||||
(2.0 * std::f64::consts::PI * 0.25 * t).sin() // 15 BPM
|
||||
})
|
||||
.collect();
|
||||
|
||||
let phases: Vec<f64> = (0..1000)
|
||||
.map(|i| {
|
||||
let t = i as f64 / 100.0;
|
||||
(2.0 * std::f64::consts::PI * 0.25 * t).sin() * 0.5
|
||||
})
|
||||
.collect();
|
||||
|
||||
buffer.add_samples(&litudes, &phases);
|
||||
buffer
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_vital_features() {
|
||||
let adapter = SignalAdapter::with_defaults();
|
||||
let buffer = create_test_buffer();
|
||||
|
||||
let result = adapter.extract_vital_features(&buffer);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let features = result.unwrap();
|
||||
assert!(!features.breathing_features.is_empty());
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
454
rust-port/wifi-densepose-rs/crates/wifi-densepose-mat/src/lib.rs
Normal file
454
rust-port/wifi-densepose-rs/crates/wifi-densepose-mat/src/lib.rs
Normal file
@@ -0,0 +1,454 @@
|
||||
//! # 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 detection;
|
||||
pub mod domain;
|
||||
pub mod integration;
|
||||
pub mod localization;
|
||||
|
||||
// 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,
|
||||
};
|
||||
|
||||
/// 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),
|
||||
}
|
||||
|
||||
/// 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<()> {
|
||||
let event = self.event.as_mut()
|
||||
.ok_or_else(|| MatError::Domain("No active disaster event".into()))?;
|
||||
|
||||
for zone in event.zones_mut() {
|
||||
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)
|
||||
.ok();
|
||||
|
||||
// Create or update survivor
|
||||
let survivor = event.record_detection(
|
||||
zone.id().clone(),
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 = 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 - 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);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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 = (5.0, 4.0);
|
||||
let distances: Vec<(&str, f64)> = vec![
|
||||
("s1", ((target.0 - 0.0).powi(2) + (target.1 - 0.0).powi(2)).sqrt()),
|
||||
("s2", ((target.0 - 10.0).powi(2) + (target.1 - 0.0).powi(2)).sqrt()),
|
||||
("s3", ((target.0 - 5.0).powi(2) + (target.1 - 10.0).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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user