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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user