- Add --train CLI flag with dataset loading, graph transformer training, cosine-scheduled SGD, PCK/OKS validation, and checkpoint saving - Refactor main.rs to import training modules from lib.rs instead of duplicating mod declarations - Add ADR-021 (vital sign detection), ADR-022 (Windows WiFi enhanced fidelity), ADR-023 (trained DensePose pipeline) documentation - Add wifi-densepose-vitals crate: breathing, heartrate, anomaly detection, preprocessor, and temporal store - Add wifi-densepose-wifiscan crate: 8-stage signal intelligence pipeline with netsh/wlanapi adapters, multi-BSSID registry, attention weighting, spatial correlation, and breathing extraction Co-Authored-By: claude-flow <ruv@ruv.net>
400 lines
13 KiB
Rust
400 lines
13 KiB
Rust
//! 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<f64>,
|
|
/// Recent heart rate values for windowed analysis.
|
|
hr_history: Vec<f64>,
|
|
/// 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<AnomalyAlert> {
|
|
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,
|
|
);
|
|
}
|
|
}
|
|
}
|