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:
Claude
2026-01-13 17:24:50 +00:00
parent 0fa9a0b882
commit a17b630c02
31 changed files with 9042 additions and 0 deletions

View File

@@ -0,0 +1,297 @@
//! Depth estimation through debris layers.
use crate::domain::{DebrisProfile, DepthEstimate, DebrisMaterial, MoistureLevel};
/// Configuration for depth estimation
#[derive(Debug, Clone)]
pub struct DepthEstimatorConfig {
/// Maximum depth to estimate (meters)
pub max_depth: f64,
/// Minimum signal attenuation to consider (dB)
pub min_attenuation: f64,
/// WiFi frequency in GHz
pub frequency_ghz: f64,
/// Free space path loss at 1 meter (dB)
pub free_space_loss_1m: f64,
}
impl Default for DepthEstimatorConfig {
fn default() -> Self {
Self {
max_depth: 10.0,
min_attenuation: 3.0,
frequency_ghz: 5.8, // 5.8 GHz WiFi
free_space_loss_1m: 47.0, // FSPL at 1m for 5.8 GHz
}
}
}
/// Estimator for survivor depth through debris
pub struct DepthEstimator {
config: DepthEstimatorConfig,
}
impl DepthEstimator {
/// Create a new depth estimator
pub fn new(config: DepthEstimatorConfig) -> Self {
Self { config }
}
/// Create with default configuration
pub fn with_defaults() -> Self {
Self::new(DepthEstimatorConfig::default())
}
/// Estimate depth from signal attenuation
pub fn estimate_depth(
&self,
signal_attenuation: f64, // Total attenuation in dB
distance_2d: f64, // Horizontal distance in meters
debris_profile: &DebrisProfile,
) -> Option<DepthEstimate> {
if signal_attenuation < self.config.min_attenuation {
// Very little attenuation - probably not buried
return Some(DepthEstimate {
depth: 0.0,
uncertainty: 0.5,
debris_profile: debris_profile.clone(),
confidence: 0.9,
});
}
// Calculate free space path loss for horizontal distance
let fspl = self.free_space_path_loss(distance_2d);
// Debris attenuation = total - free space loss
let debris_attenuation = (signal_attenuation - fspl).max(0.0);
// Get attenuation coefficient for debris type
let attenuation_per_meter = debris_profile.attenuation_factor();
if attenuation_per_meter < 0.1 {
return None;
}
// Estimate depth
let depth = debris_attenuation / attenuation_per_meter;
// Clamp to maximum
if depth > self.config.max_depth {
return None;
}
// Calculate uncertainty (increases with depth and material variability)
let base_uncertainty = 0.3;
let depth_uncertainty = depth * 0.15;
let material_uncertainty = self.material_uncertainty(debris_profile);
let uncertainty = base_uncertainty + depth_uncertainty + material_uncertainty;
// Calculate confidence (decreases with depth)
let confidence = (1.0 - depth / self.config.max_depth).max(0.3);
Some(DepthEstimate {
depth,
uncertainty,
debris_profile: debris_profile.clone(),
confidence,
})
}
/// Estimate debris profile from signal characteristics
pub fn estimate_debris_profile(
&self,
signal_variance: f64,
signal_multipath: f64,
moisture_indicator: f64,
) -> DebrisProfile {
// Estimate material based on signal characteristics
let primary_material = if signal_variance > 0.5 {
// High variance suggests heterogeneous material
DebrisMaterial::Mixed
} else if signal_multipath > 0.7 {
// High multipath suggests reflective surfaces
DebrisMaterial::HeavyConcrete
} else if signal_multipath < 0.3 {
// Low multipath suggests absorptive material
DebrisMaterial::Soil
} else {
DebrisMaterial::LightConcrete
};
// Estimate void fraction from multipath
let void_fraction = signal_multipath.clamp(0.1, 0.5);
// Estimate moisture from signal characteristics
let moisture_content = if moisture_indicator > 0.7 {
MoistureLevel::Wet
} else if moisture_indicator > 0.4 {
MoistureLevel::Damp
} else {
MoistureLevel::Dry
};
DebrisProfile {
primary_material,
void_fraction,
moisture_content,
metal_content: crate::domain::MetalContent::Low,
}
}
/// Calculate free space path loss
fn free_space_path_loss(&self, distance: f64) -> f64 {
// FSPL = 20*log10(d) + 20*log10(f) + 20*log10(4*pi/c)
// Simplified: FSPL(d) = FSPL(1m) + 20*log10(d)
if distance <= 0.0 {
return 0.0;
}
self.config.free_space_loss_1m + 20.0 * distance.log10()
}
/// Calculate uncertainty based on material properties
fn material_uncertainty(&self, profile: &DebrisProfile) -> f64 {
// Mixed materials have higher uncertainty
let material_factor = match profile.primary_material {
DebrisMaterial::Mixed => 0.4,
DebrisMaterial::HeavyConcrete => 0.2,
DebrisMaterial::LightConcrete => 0.2,
DebrisMaterial::Soil => 0.3,
DebrisMaterial::Wood => 0.15,
DebrisMaterial::Snow => 0.1,
DebrisMaterial::Metal => 0.5, // Very unpredictable
};
// Moisture adds uncertainty
let moisture_factor = match profile.moisture_content {
MoistureLevel::Dry => 0.0,
MoistureLevel::Damp => 0.1,
MoistureLevel::Wet => 0.2,
MoistureLevel::Saturated => 0.3,
};
material_factor + moisture_factor
}
/// Estimate depth from multiple signal paths
pub fn estimate_from_multipath(
&self,
direct_path_attenuation: f64,
reflected_paths: &[(f64, f64)], // (attenuation, delay)
debris_profile: &DebrisProfile,
) -> Option<DepthEstimate> {
// Use path differences to estimate depth
if reflected_paths.is_empty() {
return self.estimate_depth(direct_path_attenuation, 0.0, debris_profile);
}
// Average extra path length from reflections
const SPEED_OF_LIGHT: f64 = 299_792_458.0;
let avg_extra_path: f64 = reflected_paths
.iter()
.map(|(_, delay)| delay * SPEED_OF_LIGHT / 2.0) // Round trip
.sum::<f64>() / reflected_paths.len() as f64;
// Extra path length is approximately related to depth
// (reflections bounce off debris layers)
let estimated_depth = avg_extra_path / 4.0; // Empirical factor
let attenuation_per_meter = debris_profile.attenuation_factor();
let attenuation_based_depth = direct_path_attenuation / attenuation_per_meter;
// Combine estimates
let depth = (estimated_depth + attenuation_based_depth) / 2.0;
if depth > self.config.max_depth {
return None;
}
let uncertainty = 0.5 + depth * 0.2;
let confidence = (1.0 - depth / self.config.max_depth).max(0.3);
Some(DepthEstimate {
depth,
uncertainty,
debris_profile: debris_profile.clone(),
confidence,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn default_debris() -> DebrisProfile {
DebrisProfile {
primary_material: DebrisMaterial::Mixed,
void_fraction: 0.25,
moisture_content: MoistureLevel::Dry,
metal_content: crate::domain::MetalContent::Low,
}
}
#[test]
fn test_low_attenuation_surface() {
let estimator = DepthEstimator::with_defaults();
let result = estimator.estimate_depth(1.0, 5.0, &default_debris());
assert!(result.is_some());
let estimate = result.unwrap();
assert!(estimate.depth < 0.1);
assert!(estimate.confidence > 0.8);
}
#[test]
fn test_depth_increases_with_attenuation() {
let estimator = DepthEstimator::with_defaults();
let debris = default_debris();
let low = estimator.estimate_depth(10.0, 0.0, &debris);
let high = estimator.estimate_depth(30.0, 0.0, &debris);
assert!(low.is_some() && high.is_some());
assert!(high.unwrap().depth > low.unwrap().depth);
}
#[test]
fn test_confidence_decreases_with_depth() {
let estimator = DepthEstimator::with_defaults();
let debris = default_debris();
let shallow = estimator.estimate_depth(5.0, 0.0, &debris);
let deep = estimator.estimate_depth(40.0, 0.0, &debris);
if let (Some(s), Some(d)) = (shallow, deep) {
assert!(s.confidence > d.confidence);
}
}
#[test]
fn test_debris_profile_estimation() {
let estimator = DepthEstimator::with_defaults();
// High variance = mixed materials
let profile = estimator.estimate_debris_profile(0.7, 0.5, 0.3);
assert!(matches!(profile.primary_material, DebrisMaterial::Mixed));
// High multipath = concrete
let profile2 = estimator.estimate_debris_profile(0.2, 0.8, 0.3);
assert!(matches!(profile2.primary_material, DebrisMaterial::HeavyConcrete));
}
#[test]
fn test_free_space_path_loss() {
let estimator = DepthEstimator::with_defaults();
// FSPL increases with distance
let fspl_1m = estimator.free_space_path_loss(1.0);
let fspl_10m = estimator.free_space_path_loss(10.0);
assert!(fspl_10m > fspl_1m);
// Should be about 20 dB difference (20*log10(10))
assert!((fspl_10m - fspl_1m - 20.0).abs() < 1.0);
}
}

View File

@@ -0,0 +1,393 @@
//! Position fusion combining multiple localization techniques.
use crate::domain::{
Coordinates3D, LocationUncertainty, ScanZone, VitalSignsReading,
DepthEstimate, DebrisProfile,
};
use super::{Triangulator, TriangulationConfig, DepthEstimator, DepthEstimatorConfig};
/// Service for survivor localization
pub struct LocalizationService {
triangulator: Triangulator,
depth_estimator: DepthEstimator,
position_fuser: PositionFuser,
}
impl LocalizationService {
/// Create a new localization service
pub fn new() -> Self {
Self {
triangulator: Triangulator::with_defaults(),
depth_estimator: DepthEstimator::with_defaults(),
position_fuser: PositionFuser::new(),
}
}
/// Create with custom configurations
pub fn with_config(
triangulation_config: TriangulationConfig,
depth_config: DepthEstimatorConfig,
) -> Self {
Self {
triangulator: Triangulator::new(triangulation_config),
depth_estimator: DepthEstimator::new(depth_config),
position_fuser: PositionFuser::new(),
}
}
/// Estimate survivor position
pub fn estimate_position(
&self,
vitals: &VitalSignsReading,
zone: &ScanZone,
) -> Option<Coordinates3D> {
// Get sensor positions
let sensors = zone.sensor_positions();
if sensors.len() < 3 {
return None;
}
// Estimate 2D position from triangulation
// In real implementation, RSSI values would come from actual measurements
let rssi_values = self.simulate_rssi_measurements(sensors, vitals);
let position_2d = self.triangulator.estimate_position(sensors, &rssi_values)?;
// Estimate depth
let debris_profile = self.estimate_debris_profile(zone);
let signal_attenuation = self.calculate_signal_attenuation(&rssi_values);
let depth_estimate = self.depth_estimator.estimate_depth(
signal_attenuation,
0.0,
&debris_profile,
)?;
// Combine into 3D position
let position_3d = Coordinates3D::new(
position_2d.x,
position_2d.y,
-depth_estimate.depth, // Negative = below surface
self.combine_uncertainties(&position_2d.uncertainty, &depth_estimate),
);
Some(position_3d)
}
/// Simulate RSSI measurements (placeholder for real sensor data)
fn simulate_rssi_measurements(
&self,
sensors: &[crate::domain::SensorPosition],
_vitals: &VitalSignsReading,
) -> Vec<(String, f64)> {
// In production, this would read actual sensor values
// For now, return placeholder values
sensors.iter()
.map(|s| (s.id.clone(), -50.0 + rand_range(-10.0, 10.0)))
.collect()
}
/// Estimate debris profile for the zone
fn estimate_debris_profile(&self, _zone: &ScanZone) -> DebrisProfile {
// Would use zone metadata and signal analysis
DebrisProfile::default()
}
/// Calculate average signal attenuation
fn calculate_signal_attenuation(&self, rssi_values: &[(String, f64)]) -> f64 {
if rssi_values.is_empty() {
return 0.0;
}
// Reference RSSI at surface (typical open-air value)
const REFERENCE_RSSI: f64 = -30.0;
let avg_rssi: f64 = rssi_values.iter().map(|(_, r)| r).sum::<f64>()
/ rssi_values.len() as f64;
(REFERENCE_RSSI - avg_rssi).max(0.0)
}
/// Combine horizontal and depth uncertainties
fn combine_uncertainties(
&self,
horizontal: &LocationUncertainty,
depth: &DepthEstimate,
) -> LocationUncertainty {
LocationUncertainty {
horizontal_error: horizontal.horizontal_error,
vertical_error: depth.uncertainty,
confidence: (horizontal.confidence * depth.confidence).sqrt(),
}
}
}
impl Default for LocalizationService {
fn default() -> Self {
Self::new()
}
}
/// Fuses multiple position estimates
pub struct PositionFuser {
/// History of position estimates for smoothing
history: parking_lot::RwLock<Vec<PositionEstimate>>,
/// Maximum history size
max_history: usize,
}
/// A position estimate with metadata
#[derive(Debug, Clone)]
pub struct PositionEstimate {
/// The position
pub position: Coordinates3D,
/// Timestamp
pub timestamp: chrono::DateTime<chrono::Utc>,
/// Source of estimate
pub source: EstimateSource,
/// Weight for fusion
pub weight: f64,
}
/// Source of a position estimate
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EstimateSource {
/// From RSSI-based triangulation
RssiTriangulation,
/// From time-of-arrival
TimeOfArrival,
/// From CSI fingerprinting
CsiFingerprint,
/// From angle of arrival
AngleOfArrival,
/// From depth estimation
DepthEstimation,
/// Fused from multiple sources
Fused,
}
impl PositionFuser {
/// Create a new position fuser
pub fn new() -> Self {
Self {
history: parking_lot::RwLock::new(Vec::new()),
max_history: 20,
}
}
/// Add a position estimate
pub fn add_estimate(&self, estimate: PositionEstimate) {
let mut history = self.history.write();
history.push(estimate);
// Keep only recent history
if history.len() > self.max_history {
history.remove(0);
}
}
/// Fuse multiple position estimates into one
pub fn fuse(&self, estimates: &[PositionEstimate]) -> Option<Coordinates3D> {
if estimates.is_empty() {
return None;
}
if estimates.len() == 1 {
return Some(estimates[0].position.clone());
}
// Weighted average based on uncertainty and source confidence
let mut total_weight = 0.0;
let mut sum_x = 0.0;
let mut sum_y = 0.0;
let mut sum_z = 0.0;
for estimate in estimates {
let weight = self.calculate_weight(estimate);
total_weight += weight;
sum_x += estimate.position.x * weight;
sum_y += estimate.position.y * weight;
sum_z += estimate.position.z * weight;
}
if total_weight == 0.0 {
return None;
}
let fused_x = sum_x / total_weight;
let fused_y = sum_y / total_weight;
let fused_z = sum_z / total_weight;
// Calculate fused uncertainty (reduced due to multiple estimates)
let fused_uncertainty = self.calculate_fused_uncertainty(estimates);
Some(Coordinates3D::new(
fused_x,
fused_y,
fused_z,
fused_uncertainty,
))
}
/// Fuse with temporal smoothing
pub fn fuse_with_history(&self, current: &PositionEstimate) -> Option<Coordinates3D> {
// Add current to history
self.add_estimate(current.clone());
let history = self.history.read();
// Use exponentially weighted moving average
let alpha = 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);
}
}

View File

@@ -0,0 +1,14 @@
//! Localization module for survivor position estimation.
//!
//! This module provides:
//! - Triangulation from multiple access points
//! - Depth estimation through debris
//! - Position fusion combining multiple techniques
mod triangulation;
mod depth;
mod fusion;
pub use triangulation::{Triangulator, TriangulationConfig};
pub use depth::{DepthEstimator, DepthEstimatorConfig};
pub use fusion::{PositionFuser, LocalizationService};

View File

@@ -0,0 +1,377 @@
//! Triangulation for 2D/3D position estimation from multiple sensors.
use crate::domain::{Coordinates3D, LocationUncertainty, SensorPosition};
/// Configuration for triangulation
#[derive(Debug, Clone)]
pub struct TriangulationConfig {
/// Minimum number of sensors required
pub min_sensors: usize,
/// Maximum position uncertainty to accept (meters)
pub max_uncertainty: f64,
/// Path loss exponent for distance estimation
pub path_loss_exponent: f64,
/// Reference distance for path loss model (meters)
pub reference_distance: f64,
/// Reference RSSI at reference distance (dBm)
pub reference_rssi: f64,
/// Use weighted least squares
pub weighted: bool,
}
impl Default for TriangulationConfig {
fn default() -> Self {
Self {
min_sensors: 3,
max_uncertainty: 5.0,
path_loss_exponent: 3.0, // Indoor with obstacles
reference_distance: 1.0,
reference_rssi: -30.0,
weighted: true,
}
}
}
/// Result of a distance estimation
#[derive(Debug, Clone)]
pub struct DistanceEstimate {
/// Sensor ID
pub sensor_id: String,
/// Estimated distance in meters
pub distance: f64,
/// Estimation confidence
pub confidence: f64,
}
/// Triangulator for position estimation
pub struct Triangulator {
config: TriangulationConfig,
}
impl Triangulator {
/// Create a new triangulator
pub fn new(config: TriangulationConfig) -> Self {
Self { config }
}
/// Create with default configuration
pub fn with_defaults() -> Self {
Self::new(TriangulationConfig::default())
}
/// Estimate position from RSSI measurements
pub fn estimate_position(
&self,
sensors: &[SensorPosition],
rssi_values: &[(String, f64)], // (sensor_id, rssi)
) -> Option<Coordinates3D> {
// Get distance estimates from RSSI
let distances: Vec<(SensorPosition, f64)> = rssi_values
.iter()
.filter_map(|(id, rssi)| {
let sensor = sensors.iter().find(|s| &s.id == id)?;
if !sensor.is_operational {
return None;
}
let distance = self.rssi_to_distance(*rssi);
Some((sensor.clone(), distance))
})
.collect();
if distances.len() < self.config.min_sensors {
return None;
}
// Perform trilateration
self.trilaterate(&distances)
}
/// Estimate position from Time of Arrival measurements
pub fn estimate_from_toa(
&self,
sensors: &[SensorPosition],
toa_values: &[(String, f64)], // (sensor_id, time_of_arrival_ns)
) -> Option<Coordinates3D> {
const SPEED_OF_LIGHT: f64 = 299_792_458.0; // m/s
let distances: Vec<(SensorPosition, f64)> = toa_values
.iter()
.filter_map(|(id, toa)| {
let sensor = sensors.iter().find(|s| &s.id == id)?;
if !sensor.is_operational {
return None;
}
// Convert nanoseconds to distance
let distance = (*toa * 1e-9) * SPEED_OF_LIGHT / 2.0; // Round trip
Some((sensor.clone(), distance))
})
.collect();
if distances.len() < self.config.min_sensors {
return None;
}
self.trilaterate(&distances)
}
/// Convert RSSI to distance using path loss model
fn rssi_to_distance(&self, rssi: f64) -> f64 {
// Log-distance path loss model:
// RSSI = RSSI_0 - 10 * n * log10(d / d_0)
// Solving for d:
// d = d_0 * 10^((RSSI_0 - RSSI) / (10 * n))
let exponent = (self.config.reference_rssi - rssi)
/ (10.0 * self.config.path_loss_exponent);
self.config.reference_distance * 10.0_f64.powf(exponent)
}
/// Perform trilateration using least squares
fn trilaterate(&self, distances: &[(SensorPosition, f64)]) -> Option<Coordinates3D> {
if distances.len() < 3 {
return None;
}
// Use linearized least squares approach
// Reference: https://en.wikipedia.org/wiki/Trilateration
// Use first sensor as reference
let (ref_sensor, ref_dist) = &distances[0];
let x1 = ref_sensor.x;
let y1 = ref_sensor.y;
let r1 = *ref_dist;
// Build system of linear equations: A * [x, y]^T = b
let n = distances.len() - 1;
let mut a_matrix = vec![vec![0.0; 2]; n];
let mut b_vector = vec![0.0; n];
for (i, (sensor, dist)) in distances.iter().skip(1).enumerate() {
let xi = sensor.x;
let yi = sensor.y;
let ri = *dist;
// Linearized equation from difference of squared distances
a_matrix[i][0] = 2.0 * (xi - x1);
a_matrix[i][1] = 2.0 * (yi - y1);
b_vector[i] = r1 * r1 - ri * ri - x1 * x1 + xi * xi - y1 * y1 + yi * yi;
}
// Solve using least squares: (A^T * A)^-1 * A^T * b
let solution = self.solve_least_squares(&a_matrix, &b_vector)?;
// Calculate uncertainty from residuals
let uncertainty = self.calculate_uncertainty(&solution, distances);
if uncertainty.horizontal_error > self.config.max_uncertainty {
return None;
}
Some(Coordinates3D::new(
solution[0],
solution[1],
0.0, // Z estimated separately
uncertainty,
))
}
/// Solve linear system using least squares
fn solve_least_squares(&self, a: &[Vec<f64>], b: &[f64]) -> Option<Vec<f64>> {
let n = a.len();
if n < 2 || a[0].len() != 2 {
return None;
}
// Calculate A^T * A
let mut ata = vec![vec![0.0; 2]; 2];
for i in 0..2 {
for j in 0..2 {
for k in 0..n {
ata[i][j] += a[k][i] * a[k][j];
}
}
}
// Calculate A^T * b
let mut atb = vec![0.0; 2];
for i in 0..2 {
for k in 0..n {
atb[i] += a[k][i] * b[k];
}
}
// Solve 2x2 system using Cramer's rule
let det = ata[0][0] * ata[1][1] - ata[0][1] * ata[1][0];
if det.abs() < 1e-10 {
return None;
}
let x = (atb[0] * ata[1][1] - atb[1] * ata[0][1]) / det;
let y = (ata[0][0] * atb[1] - ata[1][0] * atb[0]) / det;
Some(vec![x, y])
}
/// Calculate position uncertainty from residuals
fn calculate_uncertainty(
&self,
position: &[f64],
distances: &[(SensorPosition, f64)],
) -> LocationUncertainty {
// Calculate root mean square error
let mut sum_sq_error = 0.0;
for (sensor, measured_dist) in distances {
let dx = position[0] - sensor.x;
let dy = position[1] - sensor.y;
let estimated_dist = (dx * dx + dy * dy).sqrt();
let error = measured_dist - estimated_dist;
sum_sq_error += error * error;
}
let rmse = (sum_sq_error / distances.len() as f64).sqrt();
// GDOP (Geometric Dilution of Precision) approximation
let gdop = self.estimate_gdop(position, distances);
LocationUncertainty {
horizontal_error: rmse * gdop,
vertical_error: rmse * gdop * 1.5, // Vertical typically less accurate
confidence: 0.95,
}
}
/// Estimate Geometric Dilution of Precision
fn estimate_gdop(&self, position: &[f64], distances: &[(SensorPosition, f64)]) -> f64 {
// Simplified GDOP based on sensor geometry
let mut sum_angle = 0.0;
let n = distances.len();
for i in 0..n {
for j in (i + 1)..n {
let dx1 = distances[i].0.x - position[0];
let dy1 = distances[i].0.y - position[1];
let dx2 = distances[j].0.x - position[0];
let dy2 = distances[j].0.y - position[1];
let dot = dx1 * dx2 + dy1 * dy2;
let mag1 = (dx1 * dx1 + dy1 * dy1).sqrt();
let mag2 = (dx2 * dx2 + dy2 * dy2).sqrt();
if mag1 > 0.0 && mag2 > 0.0 {
let cos_angle = (dot / (mag1 * mag2)).clamp(-1.0, 1.0);
let angle = cos_angle.acos();
sum_angle += angle;
}
}
}
// Average angle between sensor pairs
let num_pairs = (n * (n - 1)) as f64 / 2.0;
let avg_angle = if num_pairs > 0.0 {
sum_angle / num_pairs
} else {
std::f64::consts::PI / 4.0
};
// GDOP is better when sensors are spread out (angle closer to 90 degrees)
// GDOP gets worse as sensors are collinear
let optimal_angle = std::f64::consts::PI / 2.0;
let angle_factor = (avg_angle / optimal_angle - 1.0).abs() + 1.0;
angle_factor.max(1.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::SensorType;
fn create_test_sensors() -> Vec<SensorPosition> {
vec![
SensorPosition {
id: "s1".to_string(),
x: 0.0,
y: 0.0,
z: 1.5,
sensor_type: SensorType::Transceiver,
is_operational: true,
},
SensorPosition {
id: "s2".to_string(),
x: 10.0,
y: 0.0,
z: 1.5,
sensor_type: SensorType::Transceiver,
is_operational: true,
},
SensorPosition {
id: "s3".to_string(),
x: 5.0,
y: 10.0,
z: 1.5,
sensor_type: SensorType::Transceiver,
is_operational: true,
},
]
}
#[test]
fn test_rssi_to_distance() {
let triangulator = Triangulator::with_defaults();
// At reference distance, RSSI should equal reference RSSI
let distance = triangulator.rssi_to_distance(-30.0);
assert!((distance - 1.0).abs() < 0.1);
// Weaker signal = further distance
let distance2 = triangulator.rssi_to_distance(-60.0);
assert!(distance2 > distance);
}
#[test]
fn test_trilateration() {
let triangulator = Triangulator::with_defaults();
let sensors = create_test_sensors();
// Target at (5, 4) - calculate distances
let target = (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());
}
}