//! Vital sign anomaly detection. //! //! Monitors vital sign readings for anomalies (apnea, tachycardia, //! bradycardia, sudden changes) using z-score detection with //! running mean and standard deviation. //! //! Modeled on the DNA biomarker anomaly detection pattern from //! `vendor/ruvector/examples/dna`, using Welford's online algorithm //! for numerically stable running statistics. use crate::types::VitalReading; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; /// An anomaly alert generated from vital sign analysis. #[derive(Debug, Clone)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] pub struct AnomalyAlert { /// Type of vital sign: `"respiratory"` or `"cardiac"`. pub vital_type: String, /// Type of anomaly: `"apnea"`, `"tachypnea"`, `"bradypnea"`, /// `"tachycardia"`, `"bradycardia"`, `"sudden_change"`. pub alert_type: String, /// Severity [0.0, 1.0]. pub severity: f64, /// Human-readable description. pub message: String, } /// Welford online statistics accumulator. #[derive(Debug, Clone)] struct WelfordStats { count: u64, mean: f64, m2: f64, } impl WelfordStats { fn new() -> Self { Self { count: 0, mean: 0.0, m2: 0.0, } } fn update(&mut self, value: f64) { self.count += 1; let delta = value - self.mean; self.mean += delta / self.count as f64; let delta2 = value - self.mean; self.m2 += delta * delta2; } fn variance(&self) -> f64 { if self.count < 2 { return 0.0; } self.m2 / (self.count - 1) as f64 } fn std_dev(&self) -> f64 { self.variance().sqrt() } fn z_score(&self, value: f64) -> f64 { let sd = self.std_dev(); if sd < 1e-10 { return 0.0; } (value - self.mean) / sd } } /// Vital sign anomaly detector using z-score analysis with /// running statistics. pub struct VitalAnomalyDetector { /// Running statistics for respiratory rate. rr_stats: WelfordStats, /// Running statistics for heart rate. hr_stats: WelfordStats, /// Recent respiratory rate values for windowed analysis. rr_history: Vec, /// Recent heart rate values for windowed analysis. hr_history: Vec, /// Maximum window size for history. window: usize, /// Z-score threshold for anomaly detection. z_threshold: f64, } impl VitalAnomalyDetector { /// Create a new anomaly detector. /// /// - `window`: number of recent readings to retain. /// - `z_threshold`: z-score threshold for anomaly alerts (default: 2.5). #[must_use] pub fn new(window: usize, z_threshold: f64) -> Self { Self { rr_stats: WelfordStats::new(), hr_stats: WelfordStats::new(), rr_history: Vec::with_capacity(window), hr_history: Vec::with_capacity(window), window, z_threshold, } } /// Create with defaults (window = 60, z_threshold = 2.5). #[must_use] pub fn default_config() -> Self { Self::new(60, 2.5) } /// Check a vital sign reading for anomalies. /// /// Updates running statistics and returns a list of detected /// anomaly alerts (may be empty if all readings are normal). pub fn check(&mut self, reading: &VitalReading) -> Vec { let mut alerts = Vec::new(); let rr = reading.respiratory_rate.value_bpm; let hr = reading.heart_rate.value_bpm; // Update histories self.rr_history.push(rr); if self.rr_history.len() > self.window { self.rr_history.remove(0); } self.hr_history.push(hr); if self.hr_history.len() > self.window { self.hr_history.remove(0); } // Update running statistics self.rr_stats.update(rr); self.hr_stats.update(hr); // Need at least a few readings before detecting anomalies if self.rr_stats.count < 5 { return alerts; } // --- Respiratory rate anomalies --- let rr_z = self.rr_stats.z_score(rr); // Clinical thresholds for respiratory rate (adult) if rr < 4.0 && reading.respiratory_rate.confidence > 0.3 { alerts.push(AnomalyAlert { vital_type: "respiratory".to_string(), alert_type: "apnea".to_string(), severity: 0.9, message: format!("Possible apnea detected: RR = {rr:.1} BPM"), }); } else if rr > 30.0 && reading.respiratory_rate.confidence > 0.3 { alerts.push(AnomalyAlert { vital_type: "respiratory".to_string(), alert_type: "tachypnea".to_string(), severity: ((rr - 30.0) / 20.0).clamp(0.3, 1.0), message: format!("Elevated respiratory rate: RR = {rr:.1} BPM"), }); } else if rr < 8.0 && reading.respiratory_rate.confidence > 0.3 { alerts.push(AnomalyAlert { vital_type: "respiratory".to_string(), alert_type: "bradypnea".to_string(), severity: ((8.0 - rr) / 8.0).clamp(0.3, 0.8), message: format!("Low respiratory rate: RR = {rr:.1} BPM"), }); } // Z-score based sudden change detection for RR if rr_z.abs() > self.z_threshold { alerts.push(AnomalyAlert { vital_type: "respiratory".to_string(), alert_type: "sudden_change".to_string(), severity: (rr_z.abs() / (self.z_threshold * 2.0)).clamp(0.2, 1.0), message: format!( "Sudden respiratory rate change: z-score = {rr_z:.2} (RR = {rr:.1} BPM)" ), }); } // --- Heart rate anomalies --- let hr_z = self.hr_stats.z_score(hr); if hr > 100.0 && reading.heart_rate.confidence > 0.3 { alerts.push(AnomalyAlert { vital_type: "cardiac".to_string(), alert_type: "tachycardia".to_string(), severity: ((hr - 100.0) / 80.0).clamp(0.3, 1.0), message: format!("Elevated heart rate: HR = {hr:.1} BPM"), }); } else if hr < 50.0 && reading.heart_rate.confidence > 0.3 { alerts.push(AnomalyAlert { vital_type: "cardiac".to_string(), alert_type: "bradycardia".to_string(), severity: ((50.0 - hr) / 30.0).clamp(0.3, 1.0), message: format!("Low heart rate: HR = {hr:.1} BPM"), }); } // Z-score based sudden change detection for HR if hr_z.abs() > self.z_threshold { alerts.push(AnomalyAlert { vital_type: "cardiac".to_string(), alert_type: "sudden_change".to_string(), severity: (hr_z.abs() / (self.z_threshold * 2.0)).clamp(0.2, 1.0), message: format!( "Sudden heart rate change: z-score = {hr_z:.2} (HR = {hr:.1} BPM)" ), }); } alerts } /// Reset all accumulated statistics and history. pub fn reset(&mut self) { self.rr_stats = WelfordStats::new(); self.hr_stats = WelfordStats::new(); self.rr_history.clear(); self.hr_history.clear(); } /// Number of readings processed so far. #[must_use] pub fn reading_count(&self) -> u64 { self.rr_stats.count } /// Current running mean for respiratory rate. #[must_use] pub fn rr_mean(&self) -> f64 { self.rr_stats.mean } /// Current running mean for heart rate. #[must_use] pub fn hr_mean(&self) -> f64 { self.hr_stats.mean } } #[cfg(test)] mod tests { use super::*; use crate::types::{VitalEstimate, VitalReading, VitalStatus}; fn make_reading(rr_bpm: f64, hr_bpm: f64) -> VitalReading { VitalReading { respiratory_rate: VitalEstimate { value_bpm: rr_bpm, confidence: 0.8, status: VitalStatus::Valid, }, heart_rate: VitalEstimate { value_bpm: hr_bpm, confidence: 0.8, status: VitalStatus::Valid, }, subcarrier_count: 56, signal_quality: 0.9, timestamp_secs: 0.0, } } #[test] fn no_alerts_for_normal_readings() { let mut det = VitalAnomalyDetector::new(30, 2.5); // Feed 20 normal readings for _ in 0..20 { let alerts = det.check(&make_reading(15.0, 72.0)); // After warmup, should have no alerts if det.reading_count() > 5 { assert!(alerts.is_empty(), "normal readings should not trigger alerts"); } } } #[test] fn detects_tachycardia() { let mut det = VitalAnomalyDetector::new(30, 2.5); // Warmup with normal for _ in 0..10 { det.check(&make_reading(15.0, 72.0)); } // Elevated HR let alerts = det.check(&make_reading(15.0, 130.0)); let tachycardia = alerts .iter() .any(|a| a.alert_type == "tachycardia"); assert!(tachycardia, "should detect tachycardia at 130 BPM"); } #[test] fn detects_bradycardia() { let mut det = VitalAnomalyDetector::new(30, 2.5); for _ in 0..10 { det.check(&make_reading(15.0, 72.0)); } let alerts = det.check(&make_reading(15.0, 40.0)); let brady = alerts.iter().any(|a| a.alert_type == "bradycardia"); assert!(brady, "should detect bradycardia at 40 BPM"); } #[test] fn detects_apnea() { let mut det = VitalAnomalyDetector::new(30, 2.5); for _ in 0..10 { det.check(&make_reading(15.0, 72.0)); } let alerts = det.check(&make_reading(2.0, 72.0)); let apnea = alerts.iter().any(|a| a.alert_type == "apnea"); assert!(apnea, "should detect apnea at 2 BPM"); } #[test] fn detects_tachypnea() { let mut det = VitalAnomalyDetector::new(30, 2.5); for _ in 0..10 { det.check(&make_reading(15.0, 72.0)); } let alerts = det.check(&make_reading(35.0, 72.0)); let tachypnea = alerts.iter().any(|a| a.alert_type == "tachypnea"); assert!(tachypnea, "should detect tachypnea at 35 BPM"); } #[test] fn detects_sudden_change() { let mut det = VitalAnomalyDetector::new(30, 2.0); // Build a stable baseline for _ in 0..30 { det.check(&make_reading(15.0, 72.0)); } // Sudden jump (still in normal clinical range but statistically anomalous) let alerts = det.check(&make_reading(15.0, 95.0)); let sudden = alerts.iter().any(|a| a.alert_type == "sudden_change"); assert!(sudden, "should detect sudden HR change from 72 to 95 BPM"); } #[test] fn reset_clears_state() { let mut det = VitalAnomalyDetector::new(30, 2.5); for _ in 0..10 { det.check(&make_reading(15.0, 72.0)); } assert!(det.reading_count() > 0); det.reset(); assert_eq!(det.reading_count(), 0); } #[test] fn welford_stats_basic() { let mut stats = WelfordStats::new(); stats.update(10.0); stats.update(20.0); stats.update(30.0); assert!((stats.mean - 20.0).abs() < 1e-10); assert!(stats.std_dev() > 0.0); } #[test] fn welford_z_score() { let mut stats = WelfordStats::new(); for i in 0..100 { stats.update(50.0 + (i % 3) as f64); } // A value far from the mean should have a high z-score let z = stats.z_score(100.0); assert!(z > 2.0, "z-score for extreme value should be > 2: {z}"); } #[test] fn running_means_are_tracked() { let mut det = VitalAnomalyDetector::new(30, 2.5); for _ in 0..10 { det.check(&make_reading(16.0, 75.0)); } assert!((det.rr_mean() - 16.0).abs() < 0.5); assert!((det.hr_mean() - 75.0).abs() < 0.5); } #[test] fn severity_is_clamped() { let mut det = VitalAnomalyDetector::new(30, 2.5); for _ in 0..10 { det.check(&make_reading(15.0, 72.0)); } let alerts = det.check(&make_reading(15.0, 200.0)); for alert in &alerts { assert!( alert.severity >= 0.0 && alert.severity <= 1.0, "severity should be in [0,1]: {}", alert.severity, ); } } }