use crate::types::{NodeId, Position3D, CsiDetection}; /// A fused detection result from multiple drone viewpoints. #[derive(Debug, Clone)] pub struct FusedDetection { pub confidence: f32, pub estimated_position: Position3D, pub contributing_drones: Vec, /// Localization uncertainty ellipse (std dev in metres). pub uncertainty_m: f64, } /// Geometric diversity metric (Cramer-Rao bound proxy). /// More diverse viewpoints -> lower bound -> better localization. fn geometric_diversity_index(positions: &[Position3D]) -> f64 { if positions.len() < 2 { return 0.0; } // Compute average pairwise angular separation let n = positions.len(); let centroid = Position3D { x: positions.iter().map(|p| p.x).sum::() / n as f64, y: positions.iter().map(|p| p.y).sum::() / n as f64, z: positions.iter().map(|p| p.z).sum::() / n as f64, }; let mut total_angle = 0.0_f64; let mut pairs = 0; for i in 0..n { for j in (i + 1)..n { let a = (positions[i].x - centroid.x, positions[i].y - centroid.y); let b = (positions[j].x - centroid.x, positions[j].y - centroid.y); let dot = a.0 * b.0 + a.1 * b.1; let mag_a = (a.0 * a.0 + a.1 * a.1).sqrt().max(1e-9); let mag_b = (b.0 * b.0 + b.1 * b.1).sqrt().max(1e-9); let cos_angle = (dot / (mag_a * mag_b)).clamp(-1.0, 1.0); total_angle += cos_angle.acos(); pairs += 1; } } if pairs > 0 { total_angle / pairs as f64 } else { 0.0 } } /// Multi-drone CSI fusion via confidence-weighted position averaging with geometric bias. pub struct MultiViewFusion { /// Minimum number of independent viewpoints required to produce a fused result. pub min_viewpoints: usize, /// Minimum confidence of individual detections to include in fusion. pub min_confidence: f32, } impl Default for MultiViewFusion { fn default() -> Self { Self { min_viewpoints: 2, min_confidence: 0.5 } } } impl MultiViewFusion { /// Fuse multiple CSI detections from different drone viewpoints. /// Returns None if fewer than min_viewpoints pass the confidence threshold. pub fn fuse( &self, detections: &[CsiDetection], drone_positions: &[(NodeId, Position3D)], ) -> Option { // Filter by confidence and require estimated position let valid: Vec<(&CsiDetection, &Position3D)> = detections .iter() .filter(|d| d.confidence >= self.min_confidence && d.victim_position.is_some()) .filter_map(|d| { let drone_pos = drone_positions .iter() .find(|(id, _)| *id == d.drone_id) .map(|(_, p)| p)?; Some((d, drone_pos)) }) .collect(); if valid.len() < self.min_viewpoints { return None; } // Compute geometric diversity index for uncertainty estimate let drone_pos_list: Vec = valid.iter().map(|(_, p)| **p).collect(); let gdi = geometric_diversity_index(&drone_pos_list); // Weighted average of victim position estimates let total_weight: f32 = valid.iter().map(|(d, _)| d.confidence).sum(); let mut fused_x = 0.0_f64; let mut fused_y = 0.0_f64; let mut fused_z = 0.0_f64; let mut fused_conf = 0.0_f32; for (det, _) in &valid { let w = det.confidence / total_weight; let vp = det.victim_position.unwrap(); fused_x += w as f64 * vp.x; fused_y += w as f64 * vp.y; fused_z += w as f64 * vp.z; fused_conf += w * det.confidence; } // Uncertainty shrinks with geometric diversity and number of viewpoints: // baseline 5 m (single drone) -> scales down by sqrt(n) and gdi factor let base_uncertainty_m = 5.0; let n = valid.len() as f64; let gdi_factor = (1.0 + gdi / std::f64::consts::PI).clamp(1.0, 2.0); let uncertainty_m = base_uncertainty_m / (n.sqrt() * gdi_factor); Some(FusedDetection { confidence: fused_conf, estimated_position: Position3D { x: fused_x, y: fused_y, z: fused_z }, contributing_drones: valid.iter().map(|(d, _)| d.drone_id).collect(), uncertainty_m, }) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_fusion_single_view_insufficient() { let fusion = MultiViewFusion { min_viewpoints: 2, min_confidence: 0.5 }; let det = CsiDetection { drone_id: NodeId(0), confidence: 0.9, victim_position: Some(Position3D { x: 10.0, y: 5.0, z: 0.0 }), timestamp_ms: 0, }; let result = fusion.fuse(&[det], &[(NodeId(0), Position3D::zero())]); assert!(result.is_none(), "single viewpoint should not produce fusion"); } #[test] fn test_fusion_three_views() { let fusion = MultiViewFusion::default(); let victim = Position3D { x: 50.0, y: 50.0, z: 0.0 }; let detections = vec![ CsiDetection { drone_id: NodeId(0), confidence: 0.85, victim_position: Some(Position3D { x: 51.0, y: 49.0, z: 0.0 }), timestamp_ms: 0, }, CsiDetection { drone_id: NodeId(1), confidence: 0.78, victim_position: Some(Position3D { x: 49.0, y: 51.0, z: 0.0 }), timestamp_ms: 0, }, CsiDetection { drone_id: NodeId(2), confidence: 0.92, victim_position: Some(Position3D { x: 50.0, y: 50.0, z: 0.0 }), timestamp_ms: 0, }, ]; let positions = vec![ (NodeId(0), Position3D { x: 0.0, y: 0.0, z: -30.0 }), (NodeId(1), Position3D { x: 100.0, y: 0.0, z: -30.0 }), (NodeId(2), Position3D { x: 50.0, y: 86.6, z: -30.0 }), // equilateral triangle ]; let result = fusion.fuse(&detections, &positions).unwrap(); let err = result.estimated_position.distance_to(&victim); assert!( err < 3.0, "fusion error {} m should be < 3 m for 3 equilateral viewpoints", err ); assert!( result.uncertainty_m < 5.0, "uncertainty {} should be < 5 m single-drone baseline", result.uncertainty_m ); } }