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,293 @@
|
||||
//! Breathing pattern detection from CSI signals.
|
||||
|
||||
use crate::domain::{BreathingPattern, BreathingType, ConfidenceScore};
|
||||
|
||||
/// Configuration for breathing detection
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BreathingDetectorConfig {
|
||||
/// Minimum breathing rate to detect (breaths per minute)
|
||||
pub min_rate_bpm: f32,
|
||||
/// Maximum breathing rate to detect
|
||||
pub max_rate_bpm: f32,
|
||||
/// Minimum signal amplitude to consider
|
||||
pub min_amplitude: f32,
|
||||
/// Window size for FFT analysis (samples)
|
||||
pub window_size: usize,
|
||||
/// Overlap between windows (0.0-1.0)
|
||||
pub window_overlap: f32,
|
||||
/// Confidence threshold
|
||||
pub confidence_threshold: f32,
|
||||
}
|
||||
|
||||
impl Default for BreathingDetectorConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
min_rate_bpm: 4.0, // Very slow breathing
|
||||
max_rate_bpm: 40.0, // Fast breathing (distressed)
|
||||
min_amplitude: 0.1,
|
||||
window_size: 512,
|
||||
window_overlap: 0.5,
|
||||
confidence_threshold: 0.3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Detector for breathing patterns in CSI signals
|
||||
pub struct BreathingDetector {
|
||||
config: BreathingDetectorConfig,
|
||||
}
|
||||
|
||||
impl BreathingDetector {
|
||||
/// Create a new breathing detector
|
||||
pub fn new(config: BreathingDetectorConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
/// Create with default configuration
|
||||
pub fn with_defaults() -> Self {
|
||||
Self::new(BreathingDetectorConfig::default())
|
||||
}
|
||||
|
||||
/// Detect breathing pattern from CSI amplitude variations
|
||||
///
|
||||
/// Breathing causes periodic chest movement that modulates the WiFi signal.
|
||||
/// We detect this by looking for periodic variations in the 0.1-0.67 Hz range
|
||||
/// (corresponding to 6-40 breaths per minute).
|
||||
pub fn detect(&self, csi_amplitudes: &[f64], sample_rate: f64) -> Option<BreathingPattern> {
|
||||
if csi_amplitudes.len() < self.config.window_size {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Calculate the frequency spectrum
|
||||
let spectrum = self.compute_spectrum(csi_amplitudes);
|
||||
|
||||
// Find the dominant frequency in the breathing range
|
||||
let min_freq = self.config.min_rate_bpm as f64 / 60.0;
|
||||
let max_freq = self.config.max_rate_bpm as f64 / 60.0;
|
||||
|
||||
let (dominant_freq, amplitude) = self.find_dominant_frequency(
|
||||
&spectrum,
|
||||
sample_rate,
|
||||
min_freq,
|
||||
max_freq,
|
||||
)?;
|
||||
|
||||
// Convert to BPM
|
||||
let rate_bpm = (dominant_freq * 60.0) as f32;
|
||||
|
||||
// Check amplitude threshold
|
||||
if amplitude < self.config.min_amplitude as f64 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Calculate regularity (how peaked is the spectrum)
|
||||
let regularity = self.calculate_regularity(&spectrum, dominant_freq, sample_rate);
|
||||
|
||||
// Determine breathing type based on rate and regularity
|
||||
let pattern_type = self.classify_pattern(rate_bpm, regularity);
|
||||
|
||||
// Calculate confidence
|
||||
let confidence = self.calculate_confidence(amplitude, regularity);
|
||||
|
||||
if confidence < self.config.confidence_threshold {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(BreathingPattern {
|
||||
rate_bpm,
|
||||
amplitude: amplitude as f32,
|
||||
regularity,
|
||||
pattern_type,
|
||||
})
|
||||
}
|
||||
|
||||
/// Compute frequency spectrum using FFT
|
||||
fn compute_spectrum(&self, signal: &[f64]) -> Vec<f64> {
|
||||
use rustfft::{FftPlanner, num_complex::Complex};
|
||||
|
||||
let n = signal.len().next_power_of_two();
|
||||
let mut planner = FftPlanner::new();
|
||||
let fft = planner.plan_fft_forward(n);
|
||||
|
||||
// Prepare input with zero padding
|
||||
let mut buffer: Vec<Complex<f64>> = signal
|
||||
.iter()
|
||||
.map(|&x| Complex::new(x, 0.0))
|
||||
.collect();
|
||||
buffer.resize(n, Complex::new(0.0, 0.0));
|
||||
|
||||
// Apply Hanning window
|
||||
for (i, sample) in buffer.iter_mut().enumerate().take(signal.len()) {
|
||||
let window = 0.5 * (1.0 - (2.0 * std::f64::consts::PI * i as f64 / signal.len() as f64).cos());
|
||||
*sample = Complex::new(sample.re * window, 0.0);
|
||||
}
|
||||
|
||||
fft.process(&mut buffer);
|
||||
|
||||
// Return magnitude spectrum (only positive frequencies)
|
||||
buffer.iter()
|
||||
.take(n / 2)
|
||||
.map(|c| c.norm())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Find dominant frequency in a given range
|
||||
fn find_dominant_frequency(
|
||||
&self,
|
||||
spectrum: &[f64],
|
||||
sample_rate: f64,
|
||||
min_freq: f64,
|
||||
max_freq: f64,
|
||||
) -> Option<(f64, f64)> {
|
||||
let n = spectrum.len() * 2; // Original FFT size
|
||||
let freq_resolution = sample_rate / n as f64;
|
||||
|
||||
let min_bin = (min_freq / freq_resolution).ceil() as usize;
|
||||
let max_bin = (max_freq / freq_resolution).floor() as usize;
|
||||
|
||||
if min_bin >= spectrum.len() || max_bin >= spectrum.len() || min_bin >= max_bin {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Find peak in range
|
||||
let mut max_amplitude = 0.0;
|
||||
let mut max_bin_idx = min_bin;
|
||||
|
||||
for i in min_bin..=max_bin {
|
||||
if spectrum[i] > max_amplitude {
|
||||
max_amplitude = spectrum[i];
|
||||
max_bin_idx = i;
|
||||
}
|
||||
}
|
||||
|
||||
if max_amplitude < self.config.min_amplitude as f64 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Interpolate for better frequency estimate
|
||||
let freq = max_bin_idx as f64 * freq_resolution;
|
||||
|
||||
Some((freq, max_amplitude))
|
||||
}
|
||||
|
||||
/// Calculate how regular/periodic the signal is
|
||||
fn calculate_regularity(&self, spectrum: &[f64], dominant_freq: f64, sample_rate: f64) -> f32 {
|
||||
let n = spectrum.len() * 2;
|
||||
let freq_resolution = sample_rate / n as f64;
|
||||
let peak_bin = (dominant_freq / freq_resolution).round() as usize;
|
||||
|
||||
if peak_bin >= spectrum.len() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Measure how much energy is concentrated at the peak vs spread
|
||||
let peak_power = spectrum[peak_bin];
|
||||
let total_power: f64 = spectrum.iter().sum();
|
||||
|
||||
if total_power == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Also check harmonics (2x, 3x frequency)
|
||||
let harmonic_power: f64 = [2, 3].iter()
|
||||
.filter_map(|&mult| {
|
||||
let harmonic_bin = peak_bin * mult;
|
||||
if harmonic_bin < spectrum.len() {
|
||||
Some(spectrum[harmonic_bin])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.sum();
|
||||
|
||||
((peak_power + harmonic_power * 0.5) / total_power * 3.0).min(1.0) as f32
|
||||
}
|
||||
|
||||
/// Classify the breathing pattern type
|
||||
fn classify_pattern(&self, rate_bpm: f32, regularity: f32) -> BreathingType {
|
||||
if rate_bpm < 6.0 {
|
||||
if regularity < 0.3 {
|
||||
BreathingType::Agonal
|
||||
} else {
|
||||
BreathingType::Shallow
|
||||
}
|
||||
} else if rate_bpm < 10.0 {
|
||||
BreathingType::Shallow
|
||||
} else if rate_bpm > 30.0 {
|
||||
BreathingType::Labored
|
||||
} else if regularity < 0.4 {
|
||||
BreathingType::Irregular
|
||||
} else {
|
||||
BreathingType::Normal
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate overall detection confidence
|
||||
fn calculate_confidence(&self, amplitude: f64, regularity: f32) -> f32 {
|
||||
// Combine amplitude strength and regularity
|
||||
let amplitude_score = (amplitude / 1.0).min(1.0) as f32;
|
||||
let regularity_score = regularity;
|
||||
|
||||
// Weight regularity more heavily for breathing detection
|
||||
amplitude_score * 0.4 + regularity_score * 0.6
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn generate_breathing_signal(rate_bpm: f64, sample_rate: f64, duration: f64) -> Vec<f64> {
|
||||
let num_samples = (sample_rate * duration) as usize;
|
||||
let freq = rate_bpm / 60.0;
|
||||
|
||||
(0..num_samples)
|
||||
.map(|i| {
|
||||
let t = i as f64 / sample_rate;
|
||||
(2.0 * std::f64::consts::PI * freq * t).sin()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_normal_breathing() {
|
||||
let detector = BreathingDetector::with_defaults();
|
||||
let signal = generate_breathing_signal(16.0, 100.0, 30.0);
|
||||
|
||||
let result = detector.detect(&signal, 100.0);
|
||||
assert!(result.is_some());
|
||||
|
||||
let pattern = result.unwrap();
|
||||
assert!(pattern.rate_bpm >= 14.0 && pattern.rate_bpm <= 18.0);
|
||||
assert!(matches!(pattern.pattern_type, BreathingType::Normal));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_fast_breathing() {
|
||||
let detector = BreathingDetector::with_defaults();
|
||||
let signal = generate_breathing_signal(35.0, 100.0, 30.0);
|
||||
|
||||
let result = detector.detect(&signal, 100.0);
|
||||
assert!(result.is_some());
|
||||
|
||||
let pattern = result.unwrap();
|
||||
assert!(pattern.rate_bpm > 30.0);
|
||||
assert!(matches!(pattern.pattern_type, BreathingType::Labored));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_detection_on_noise() {
|
||||
let detector = BreathingDetector::with_defaults();
|
||||
|
||||
// Random noise with low amplitude
|
||||
let signal: Vec<f64> = (0..1000)
|
||||
.map(|i| (i as f64 * 0.1).sin() * 0.01)
|
||||
.collect();
|
||||
|
||||
let result = detector.detect(&signal, 100.0);
|
||||
// Should either be None or have very low confidence
|
||||
if let Some(pattern) = result {
|
||||
assert!(pattern.amplitude < 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
//! Heartbeat detection from micro-Doppler signatures in CSI.
|
||||
|
||||
use crate::domain::{HeartbeatSignature, SignalStrength};
|
||||
|
||||
/// Configuration for heartbeat detection
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HeartbeatDetectorConfig {
|
||||
/// Minimum heart rate to detect (BPM)
|
||||
pub min_rate_bpm: f32,
|
||||
/// Maximum heart rate to detect (BPM)
|
||||
pub max_rate_bpm: f32,
|
||||
/// Minimum signal strength required
|
||||
pub min_signal_strength: f64,
|
||||
/// Window size for analysis
|
||||
pub window_size: usize,
|
||||
/// Enable enhanced micro-Doppler processing
|
||||
pub enhanced_processing: bool,
|
||||
/// Confidence threshold
|
||||
pub confidence_threshold: f32,
|
||||
}
|
||||
|
||||
impl Default for HeartbeatDetectorConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
min_rate_bpm: 30.0, // Very slow (bradycardia)
|
||||
max_rate_bpm: 200.0, // Very fast (extreme tachycardia)
|
||||
min_signal_strength: 0.05,
|
||||
window_size: 1024,
|
||||
enhanced_processing: true,
|
||||
confidence_threshold: 0.4,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Detector for heartbeat signatures using micro-Doppler analysis
|
||||
///
|
||||
/// Heartbeats cause very small chest wall movements (~0.5mm) that can be
|
||||
/// detected through careful analysis of CSI phase variations at higher
|
||||
/// frequencies than breathing (0.8-3.3 Hz for 48-200 BPM).
|
||||
pub struct HeartbeatDetector {
|
||||
config: HeartbeatDetectorConfig,
|
||||
}
|
||||
|
||||
impl HeartbeatDetector {
|
||||
/// Create a new heartbeat detector
|
||||
pub fn new(config: HeartbeatDetectorConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
/// Create with default configuration
|
||||
pub fn with_defaults() -> Self {
|
||||
Self::new(HeartbeatDetectorConfig::default())
|
||||
}
|
||||
|
||||
/// Detect heartbeat from CSI phase data
|
||||
///
|
||||
/// Heartbeat detection is more challenging than breathing due to:
|
||||
/// - Much smaller displacement (~0.5mm vs ~10mm for breathing)
|
||||
/// - Higher frequency (masked by breathing harmonics)
|
||||
/// - Lower signal-to-noise ratio
|
||||
///
|
||||
/// We use micro-Doppler analysis on the phase component after
|
||||
/// removing the breathing component.
|
||||
pub fn detect(
|
||||
&self,
|
||||
csi_phase: &[f64],
|
||||
sample_rate: f64,
|
||||
breathing_rate: Option<f64>,
|
||||
) -> Option<HeartbeatSignature> {
|
||||
if csi_phase.len() < self.config.window_size {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Remove breathing component if known
|
||||
let filtered = if let Some(br) = breathing_rate {
|
||||
self.remove_breathing_component(csi_phase, sample_rate, br)
|
||||
} else {
|
||||
self.highpass_filter(csi_phase, sample_rate, 0.8)
|
||||
};
|
||||
|
||||
// Compute micro-Doppler spectrum
|
||||
let spectrum = self.compute_micro_doppler_spectrum(&filtered, sample_rate);
|
||||
|
||||
// Find heartbeat frequency
|
||||
let min_freq = self.config.min_rate_bpm as f64 / 60.0;
|
||||
let max_freq = self.config.max_rate_bpm as f64 / 60.0;
|
||||
|
||||
let (heart_freq, strength) = self.find_heartbeat_frequency(
|
||||
&spectrum,
|
||||
sample_rate,
|
||||
min_freq,
|
||||
max_freq,
|
||||
)?;
|
||||
|
||||
if strength < self.config.min_signal_strength {
|
||||
return None;
|
||||
}
|
||||
|
||||
let rate_bpm = (heart_freq * 60.0) as f32;
|
||||
|
||||
// Calculate heart rate variability from peak width
|
||||
let variability = self.estimate_hrv(&spectrum, heart_freq, sample_rate);
|
||||
|
||||
// Determine signal strength category
|
||||
let signal_strength = self.categorize_strength(strength);
|
||||
|
||||
// Calculate confidence
|
||||
let confidence = self.calculate_confidence(strength, variability);
|
||||
|
||||
if confidence < self.config.confidence_threshold {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(HeartbeatSignature {
|
||||
rate_bpm,
|
||||
variability,
|
||||
strength: signal_strength,
|
||||
})
|
||||
}
|
||||
|
||||
/// Remove breathing component using notch filter
|
||||
fn remove_breathing_component(
|
||||
&self,
|
||||
signal: &[f64],
|
||||
sample_rate: f64,
|
||||
breathing_rate: f64,
|
||||
) -> Vec<f64> {
|
||||
// Simple IIR notch filter at breathing frequency and harmonics
|
||||
let mut filtered = signal.to_vec();
|
||||
let breathing_freq = breathing_rate / 60.0;
|
||||
|
||||
// Notch at fundamental and first two harmonics
|
||||
for harmonic in 1..=3 {
|
||||
let notch_freq = breathing_freq * harmonic as f64;
|
||||
filtered = self.apply_notch_filter(&filtered, sample_rate, notch_freq, 0.05);
|
||||
}
|
||||
|
||||
filtered
|
||||
}
|
||||
|
||||
/// Apply a simple notch filter
|
||||
fn apply_notch_filter(
|
||||
&self,
|
||||
signal: &[f64],
|
||||
sample_rate: f64,
|
||||
center_freq: f64,
|
||||
bandwidth: f64,
|
||||
) -> Vec<f64> {
|
||||
// Second-order IIR notch filter
|
||||
let w0 = 2.0 * std::f64::consts::PI * center_freq / sample_rate;
|
||||
let bw = 2.0 * std::f64::consts::PI * bandwidth / sample_rate;
|
||||
|
||||
let r = 1.0 - bw / 2.0;
|
||||
let cos_w0 = w0.cos();
|
||||
|
||||
let b0 = 1.0;
|
||||
let b1 = -2.0 * cos_w0;
|
||||
let b2 = 1.0;
|
||||
let a1 = -2.0 * r * cos_w0;
|
||||
let a2 = r * r;
|
||||
|
||||
let mut output = vec![0.0; signal.len()];
|
||||
let mut x1 = 0.0;
|
||||
let mut x2 = 0.0;
|
||||
let mut y1 = 0.0;
|
||||
let mut y2 = 0.0;
|
||||
|
||||
for (i, &x) in signal.iter().enumerate() {
|
||||
let y = b0 * x + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2;
|
||||
output[i] = y;
|
||||
|
||||
x2 = x1;
|
||||
x1 = x;
|
||||
y2 = y1;
|
||||
y1 = y;
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
/// High-pass filter to remove low frequencies
|
||||
fn highpass_filter(&self, signal: &[f64], sample_rate: f64, cutoff: f64) -> Vec<f64> {
|
||||
// Simple first-order high-pass filter
|
||||
let rc = 1.0 / (2.0 * std::f64::consts::PI * cutoff);
|
||||
let dt = 1.0 / sample_rate;
|
||||
let alpha = rc / (rc + dt);
|
||||
|
||||
let mut output = vec![0.0; signal.len()];
|
||||
if signal.is_empty() {
|
||||
return output;
|
||||
}
|
||||
|
||||
output[0] = signal[0];
|
||||
for i in 1..signal.len() {
|
||||
output[i] = alpha * (output[i - 1] + signal[i] - signal[i - 1]);
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
/// Compute micro-Doppler spectrum optimized for heartbeat detection
|
||||
fn compute_micro_doppler_spectrum(&self, signal: &[f64], _sample_rate: f64) -> Vec<f64> {
|
||||
use rustfft::{FftPlanner, num_complex::Complex};
|
||||
|
||||
let n = signal.len().next_power_of_two();
|
||||
let mut planner = FftPlanner::new();
|
||||
let fft = planner.plan_fft_forward(n);
|
||||
|
||||
// Apply Blackman window for better frequency resolution
|
||||
let mut buffer: Vec<Complex<f64>> = signal
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &x)| {
|
||||
let n_f = signal.len() as f64;
|
||||
let window = 0.42
|
||||
- 0.5 * (2.0 * std::f64::consts::PI * i as f64 / n_f).cos()
|
||||
+ 0.08 * (4.0 * std::f64::consts::PI * i as f64 / n_f).cos();
|
||||
Complex::new(x * window, 0.0)
|
||||
})
|
||||
.collect();
|
||||
buffer.resize(n, Complex::new(0.0, 0.0));
|
||||
|
||||
fft.process(&mut buffer);
|
||||
|
||||
// Return power spectrum
|
||||
buffer.iter()
|
||||
.take(n / 2)
|
||||
.map(|c| c.norm_sqr())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Find heartbeat frequency in spectrum
|
||||
fn find_heartbeat_frequency(
|
||||
&self,
|
||||
spectrum: &[f64],
|
||||
sample_rate: f64,
|
||||
min_freq: f64,
|
||||
max_freq: f64,
|
||||
) -> Option<(f64, f64)> {
|
||||
let n = spectrum.len() * 2;
|
||||
let freq_resolution = sample_rate / n as f64;
|
||||
|
||||
let min_bin = (min_freq / freq_resolution).ceil() as usize;
|
||||
let max_bin = (max_freq / freq_resolution).floor() as usize;
|
||||
|
||||
if min_bin >= spectrum.len() || max_bin >= spectrum.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Find the strongest peak
|
||||
let mut max_power = 0.0;
|
||||
let mut max_bin_idx = min_bin;
|
||||
|
||||
for i in min_bin..=max_bin.min(spectrum.len() - 1) {
|
||||
if spectrum[i] > max_power {
|
||||
max_power = spectrum[i];
|
||||
max_bin_idx = i;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a real peak (local maximum)
|
||||
if max_bin_idx > 0 && max_bin_idx < spectrum.len() - 1 {
|
||||
if spectrum[max_bin_idx] <= spectrum[max_bin_idx - 1]
|
||||
|| spectrum[max_bin_idx] <= spectrum[max_bin_idx + 1]
|
||||
{
|
||||
// Not a real peak
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
let freq = max_bin_idx as f64 * freq_resolution;
|
||||
let strength = max_power.sqrt(); // Convert power to amplitude
|
||||
|
||||
Some((freq, strength))
|
||||
}
|
||||
|
||||
/// Estimate heart rate variability from spectral peak width
|
||||
fn estimate_hrv(&self, spectrum: &[f64], peak_freq: f64, sample_rate: f64) -> f32 {
|
||||
let n = spectrum.len() * 2;
|
||||
let freq_resolution = sample_rate / n as f64;
|
||||
let peak_bin = (peak_freq / freq_resolution).round() as usize;
|
||||
|
||||
if peak_bin >= spectrum.len() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let peak_power = spectrum[peak_bin];
|
||||
if peak_power == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Find -3dB width (half-power points)
|
||||
let half_power = peak_power / 2.0;
|
||||
let mut left = peak_bin;
|
||||
let mut right = peak_bin;
|
||||
|
||||
while left > 0 && spectrum[left] > half_power {
|
||||
left -= 1;
|
||||
}
|
||||
while right < spectrum.len() - 1 && spectrum[right] > half_power {
|
||||
right += 1;
|
||||
}
|
||||
|
||||
// HRV is proportional to bandwidth
|
||||
let bandwidth = (right - left) as f64 * freq_resolution;
|
||||
let hrv_estimate = bandwidth * 60.0; // Convert to BPM variation
|
||||
|
||||
// Normalize to 0-1 range (typical HRV is 2-20 BPM)
|
||||
(hrv_estimate / 20.0).min(1.0) as f32
|
||||
}
|
||||
|
||||
/// Categorize signal strength
|
||||
fn categorize_strength(&self, strength: f64) -> SignalStrength {
|
||||
if strength > 0.5 {
|
||||
SignalStrength::Strong
|
||||
} else if strength > 0.2 {
|
||||
SignalStrength::Moderate
|
||||
} else if strength > 0.1 {
|
||||
SignalStrength::Weak
|
||||
} else {
|
||||
SignalStrength::VeryWeak
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate detection confidence
|
||||
fn calculate_confidence(&self, strength: f64, hrv: f32) -> f32 {
|
||||
// Strong signal with reasonable HRV indicates real heartbeat
|
||||
let strength_score = (strength / 0.5).min(1.0) as f32;
|
||||
|
||||
// Very low or very high HRV might indicate noise
|
||||
let hrv_score = if hrv > 0.05 && hrv < 0.5 {
|
||||
1.0
|
||||
} else {
|
||||
0.5
|
||||
};
|
||||
|
||||
strength_score * 0.7 + hrv_score * 0.3
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn generate_heartbeat_signal(rate_bpm: f64, sample_rate: f64, duration: f64) -> Vec<f64> {
|
||||
let num_samples = (sample_rate * duration) as usize;
|
||||
let freq = rate_bpm / 60.0;
|
||||
|
||||
(0..num_samples)
|
||||
.map(|i| {
|
||||
let t = i as f64 / sample_rate;
|
||||
// Heartbeat is more pulse-like than sine
|
||||
let phase = 2.0 * std::f64::consts::PI * freq * t;
|
||||
0.3 * phase.sin() + 0.1 * (2.0 * phase).sin()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_heartbeat() {
|
||||
let detector = HeartbeatDetector::with_defaults();
|
||||
let signal = generate_heartbeat_signal(72.0, 1000.0, 10.0);
|
||||
|
||||
let result = detector.detect(&signal, 1000.0, None);
|
||||
|
||||
// Heartbeat detection is challenging, may not always succeed
|
||||
if let Some(signature) = result {
|
||||
assert!(signature.rate_bpm >= 50.0 && signature.rate_bpm <= 100.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_highpass_filter() {
|
||||
let detector = HeartbeatDetector::with_defaults();
|
||||
|
||||
// Signal with DC offset and low frequency component
|
||||
let signal: Vec<f64> = (0..1000)
|
||||
.map(|i| {
|
||||
let t = i as f64 / 100.0;
|
||||
5.0 + (0.1 * t).sin() + (5.0 * t).sin() * 0.2
|
||||
})
|
||||
.collect();
|
||||
|
||||
let filtered = detector.highpass_filter(&signal, 100.0, 0.5);
|
||||
|
||||
// DC component should be removed
|
||||
let mean: f64 = filtered.iter().skip(100).sum::<f64>() / (filtered.len() - 100) as f64;
|
||||
assert!(mean.abs() < 1.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//! Detection module for vital signs detection from CSI data.
|
||||
//!
|
||||
//! This module provides detectors for:
|
||||
//! - Breathing patterns
|
||||
//! - Heartbeat signatures
|
||||
//! - Movement classification
|
||||
//! - Ensemble classification combining all signals
|
||||
|
||||
mod breathing;
|
||||
mod heartbeat;
|
||||
mod movement;
|
||||
mod pipeline;
|
||||
|
||||
pub use breathing::{BreathingDetector, BreathingDetectorConfig};
|
||||
pub use heartbeat::{HeartbeatDetector, HeartbeatDetectorConfig};
|
||||
pub use movement::{MovementClassifier, MovementClassifierConfig};
|
||||
pub use pipeline::{DetectionPipeline, DetectionConfig, VitalSignsDetector};
|
||||
@@ -0,0 +1,274 @@
|
||||
//! Movement classification from CSI signal variations.
|
||||
|
||||
use crate::domain::{MovementProfile, MovementType};
|
||||
|
||||
/// Configuration for movement classification
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MovementClassifierConfig {
|
||||
/// Threshold for detecting any movement
|
||||
pub movement_threshold: f64,
|
||||
/// Threshold for gross movement
|
||||
pub gross_movement_threshold: f64,
|
||||
/// Window size for variance calculation
|
||||
pub window_size: usize,
|
||||
/// Threshold for periodic movement detection
|
||||
pub periodicity_threshold: f64,
|
||||
}
|
||||
|
||||
impl Default for MovementClassifierConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
movement_threshold: 0.1,
|
||||
gross_movement_threshold: 0.5,
|
||||
window_size: 100,
|
||||
periodicity_threshold: 0.3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Classifier for movement types from CSI signals
|
||||
pub struct MovementClassifier {
|
||||
config: MovementClassifierConfig,
|
||||
}
|
||||
|
||||
impl MovementClassifier {
|
||||
/// Create a new movement classifier
|
||||
pub fn new(config: MovementClassifierConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
/// Create with default configuration
|
||||
pub fn with_defaults() -> Self {
|
||||
Self::new(MovementClassifierConfig::default())
|
||||
}
|
||||
|
||||
/// Classify movement from CSI signal
|
||||
pub fn classify(&self, csi_signal: &[f64], sample_rate: f64) -> MovementProfile {
|
||||
if csi_signal.len() < self.config.window_size {
|
||||
return MovementProfile::default();
|
||||
}
|
||||
|
||||
// Calculate signal statistics
|
||||
let variance = self.calculate_variance(csi_signal);
|
||||
let max_change = self.calculate_max_change(csi_signal);
|
||||
let periodicity = self.calculate_periodicity(csi_signal, sample_rate);
|
||||
|
||||
// Determine movement type
|
||||
let (movement_type, is_voluntary) = self.determine_movement_type(
|
||||
variance,
|
||||
max_change,
|
||||
periodicity,
|
||||
);
|
||||
|
||||
// Calculate intensity
|
||||
let intensity = self.calculate_intensity(variance, max_change);
|
||||
|
||||
// Calculate frequency of movement
|
||||
let frequency = self.calculate_movement_frequency(csi_signal, sample_rate);
|
||||
|
||||
MovementProfile {
|
||||
movement_type,
|
||||
intensity,
|
||||
frequency,
|
||||
is_voluntary,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate signal variance
|
||||
fn calculate_variance(&self, signal: &[f64]) -> f64 {
|
||||
if signal.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let mean = signal.iter().sum::<f64>() / signal.len() as f64;
|
||||
let variance = signal.iter()
|
||||
.map(|x| (x - mean).powi(2))
|
||||
.sum::<f64>() / signal.len() as f64;
|
||||
|
||||
variance
|
||||
}
|
||||
|
||||
/// Calculate maximum change in signal
|
||||
fn calculate_max_change(&self, signal: &[f64]) -> f64 {
|
||||
if signal.len() < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
signal.windows(2)
|
||||
.map(|w| (w[1] - w[0]).abs())
|
||||
.fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
/// Calculate periodicity score using autocorrelation
|
||||
fn calculate_periodicity(&self, signal: &[f64], _sample_rate: f64) -> f64 {
|
||||
if signal.len() < 3 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Calculate autocorrelation
|
||||
let n = signal.len();
|
||||
let mean = signal.iter().sum::<f64>() / n as f64;
|
||||
let centered: Vec<f64> = signal.iter().map(|x| x - mean).collect();
|
||||
|
||||
let variance: f64 = centered.iter().map(|x| x * x).sum();
|
||||
if variance == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Find first peak in autocorrelation after lag 0
|
||||
let max_lag = n / 2;
|
||||
let mut max_corr = 0.0;
|
||||
|
||||
for lag in 1..max_lag {
|
||||
let corr: f64 = centered.iter()
|
||||
.take(n - lag)
|
||||
.zip(centered.iter().skip(lag))
|
||||
.map(|(a, b)| a * b)
|
||||
.sum();
|
||||
|
||||
let normalized_corr = corr / variance;
|
||||
if normalized_corr > max_corr {
|
||||
max_corr = normalized_corr;
|
||||
}
|
||||
}
|
||||
|
||||
max_corr.max(0.0)
|
||||
}
|
||||
|
||||
/// Determine movement type based on signal characteristics
|
||||
fn determine_movement_type(
|
||||
&self,
|
||||
variance: f64,
|
||||
max_change: f64,
|
||||
periodicity: f64,
|
||||
) -> (MovementType, bool) {
|
||||
// No significant movement
|
||||
if variance < self.config.movement_threshold * 0.5
|
||||
&& max_change < self.config.movement_threshold
|
||||
{
|
||||
return (MovementType::None, false);
|
||||
}
|
||||
|
||||
// Check for gross movement (large, purposeful)
|
||||
if max_change > self.config.gross_movement_threshold
|
||||
&& variance > self.config.movement_threshold
|
||||
{
|
||||
// Gross movement with low periodicity suggests voluntary
|
||||
let is_voluntary = periodicity < self.config.periodicity_threshold;
|
||||
return (MovementType::Gross, is_voluntary);
|
||||
}
|
||||
|
||||
// Check for periodic movement (breathing-related or tremor)
|
||||
if periodicity > self.config.periodicity_threshold {
|
||||
// High periodicity with low variance = breathing-related
|
||||
if variance < self.config.movement_threshold * 2.0 {
|
||||
return (MovementType::Periodic, false);
|
||||
}
|
||||
// High periodicity with higher variance = tremor
|
||||
return (MovementType::Tremor, false);
|
||||
}
|
||||
|
||||
// Fine movement (small but detectable)
|
||||
if variance > self.config.movement_threshold * 0.5 {
|
||||
// Fine movement might be voluntary if not very periodic
|
||||
let is_voluntary = periodicity < 0.2;
|
||||
return (MovementType::Fine, is_voluntary);
|
||||
}
|
||||
|
||||
(MovementType::None, false)
|
||||
}
|
||||
|
||||
/// Calculate movement intensity (0.0-1.0)
|
||||
fn calculate_intensity(&self, variance: f64, max_change: f64) -> f32 {
|
||||
// Combine variance and max change
|
||||
let variance_score = (variance / (self.config.gross_movement_threshold * 2.0)).min(1.0);
|
||||
let change_score = (max_change / self.config.gross_movement_threshold).min(1.0);
|
||||
|
||||
((variance_score * 0.6 + change_score * 0.4) as f32).min(1.0)
|
||||
}
|
||||
|
||||
/// Calculate movement frequency (movements per second)
|
||||
fn calculate_movement_frequency(&self, signal: &[f64], sample_rate: f64) -> f32 {
|
||||
if signal.len() < 3 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Count zero crossings (after removing mean)
|
||||
let mean = signal.iter().sum::<f64>() / signal.len() as f64;
|
||||
let centered: Vec<f64> = signal.iter().map(|x| x - mean).collect();
|
||||
|
||||
let zero_crossings: usize = centered.windows(2)
|
||||
.filter(|w| (w[0] >= 0.0) != (w[1] >= 0.0))
|
||||
.count();
|
||||
|
||||
// Each zero crossing is half a cycle
|
||||
let duration = signal.len() as f64 / sample_rate;
|
||||
let frequency = zero_crossings as f64 / (2.0 * duration);
|
||||
|
||||
frequency as f32
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_no_movement() {
|
||||
let classifier = MovementClassifier::with_defaults();
|
||||
let signal: Vec<f64> = vec![1.0; 200];
|
||||
|
||||
let profile = classifier.classify(&signal, 100.0);
|
||||
assert!(matches!(profile.movement_type, MovementType::None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gross_movement() {
|
||||
let classifier = MovementClassifier::with_defaults();
|
||||
|
||||
// Simulate large movement
|
||||
let mut signal: Vec<f64> = vec![0.0; 200];
|
||||
for i in 50..100 {
|
||||
signal[i] = 2.0;
|
||||
}
|
||||
for i in 150..180 {
|
||||
signal[i] = -1.5;
|
||||
}
|
||||
|
||||
let profile = classifier.classify(&signal, 100.0);
|
||||
assert!(matches!(profile.movement_type, MovementType::Gross));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_periodic_movement() {
|
||||
let classifier = MovementClassifier::with_defaults();
|
||||
|
||||
// Simulate periodic signal (like breathing)
|
||||
let signal: Vec<f64> = (0..1000)
|
||||
.map(|i| (2.0 * std::f64::consts::PI * i as f64 / 100.0).sin() * 0.3)
|
||||
.collect();
|
||||
|
||||
let profile = classifier.classify(&signal, 100.0);
|
||||
// Should detect periodic or fine movement
|
||||
assert!(!matches!(profile.movement_type, MovementType::None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intensity_calculation() {
|
||||
let classifier = MovementClassifier::with_defaults();
|
||||
|
||||
// Low intensity
|
||||
let low_signal: Vec<f64> = (0..200)
|
||||
.map(|i| (i as f64 * 0.1).sin() * 0.05)
|
||||
.collect();
|
||||
let low_profile = classifier.classify(&low_signal, 100.0);
|
||||
|
||||
// High intensity
|
||||
let high_signal: Vec<f64> = (0..200)
|
||||
.map(|i| (i as f64 * 0.1).sin() * 2.0)
|
||||
.collect();
|
||||
let high_profile = classifier.classify(&high_signal, 100.0);
|
||||
|
||||
assert!(high_profile.intensity > low_profile.intensity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
//! Detection pipeline combining all vital signs detectors.
|
||||
|
||||
use crate::domain::{ScanZone, VitalSignsReading, ConfidenceScore};
|
||||
use crate::{DisasterConfig, MatError};
|
||||
use super::{
|
||||
BreathingDetector, BreathingDetectorConfig,
|
||||
HeartbeatDetector, HeartbeatDetectorConfig,
|
||||
MovementClassifier, MovementClassifierConfig,
|
||||
};
|
||||
|
||||
/// Configuration for the detection pipeline
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DetectionConfig {
|
||||
/// Breathing detector configuration
|
||||
pub breathing: BreathingDetectorConfig,
|
||||
/// Heartbeat detector configuration
|
||||
pub heartbeat: HeartbeatDetectorConfig,
|
||||
/// Movement classifier configuration
|
||||
pub movement: MovementClassifierConfig,
|
||||
/// Sample rate of CSI data (Hz)
|
||||
pub sample_rate: f64,
|
||||
/// Whether to enable heartbeat detection (slower, more processing)
|
||||
pub enable_heartbeat: bool,
|
||||
/// Minimum overall confidence to report detection
|
||||
pub min_confidence: f64,
|
||||
}
|
||||
|
||||
impl Default for DetectionConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
breathing: BreathingDetectorConfig::default(),
|
||||
heartbeat: HeartbeatDetectorConfig::default(),
|
||||
movement: MovementClassifierConfig::default(),
|
||||
sample_rate: 1000.0,
|
||||
enable_heartbeat: false,
|
||||
min_confidence: 0.3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DetectionConfig {
|
||||
/// Create configuration from disaster config
|
||||
pub fn from_disaster_config(config: &DisasterConfig) -> Self {
|
||||
let mut detection_config = Self::default();
|
||||
|
||||
// Adjust sensitivity
|
||||
detection_config.breathing.confidence_threshold = (1.0 - config.sensitivity) as f32 * 0.5;
|
||||
detection_config.heartbeat.confidence_threshold = (1.0 - config.sensitivity) as f32 * 0.5;
|
||||
detection_config.min_confidence = 1.0 - config.sensitivity * 0.7;
|
||||
|
||||
// Enable heartbeat for high sensitivity
|
||||
detection_config.enable_heartbeat = config.sensitivity > 0.7;
|
||||
|
||||
detection_config
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait for vital signs detection
|
||||
pub trait VitalSignsDetector: Send + Sync {
|
||||
/// Process CSI data and detect vital signs
|
||||
fn detect(&self, csi_data: &CsiDataBuffer) -> Option<VitalSignsReading>;
|
||||
}
|
||||
|
||||
/// Buffer for CSI data samples
|
||||
#[derive(Debug, Default)]
|
||||
pub struct CsiDataBuffer {
|
||||
/// Amplitude samples
|
||||
pub amplitudes: Vec<f64>,
|
||||
/// Phase samples (unwrapped)
|
||||
pub phases: Vec<f64>,
|
||||
/// Sample timestamps
|
||||
pub timestamps: Vec<f64>,
|
||||
/// Sample rate
|
||||
pub sample_rate: f64,
|
||||
}
|
||||
|
||||
impl CsiDataBuffer {
|
||||
/// Create a new buffer
|
||||
pub fn new(sample_rate: f64) -> Self {
|
||||
Self {
|
||||
amplitudes: Vec::new(),
|
||||
phases: Vec::new(),
|
||||
timestamps: Vec::new(),
|
||||
sample_rate,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add samples to the buffer
|
||||
pub fn add_samples(&mut self, amplitudes: &[f64], phases: &[f64]) {
|
||||
self.amplitudes.extend(amplitudes);
|
||||
self.phases.extend(phases);
|
||||
|
||||
// Generate timestamps
|
||||
let start = self.timestamps.last().copied().unwrap_or(0.0);
|
||||
let dt = 1.0 / self.sample_rate;
|
||||
for i in 0..amplitudes.len() {
|
||||
self.timestamps.push(start + (i + 1) as f64 * dt);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the buffer
|
||||
pub fn clear(&mut self) {
|
||||
self.amplitudes.clear();
|
||||
self.phases.clear();
|
||||
self.timestamps.clear();
|
||||
}
|
||||
|
||||
/// Get the duration of data in the buffer (seconds)
|
||||
pub fn duration(&self) -> f64 {
|
||||
self.amplitudes.len() as f64 / self.sample_rate
|
||||
}
|
||||
|
||||
/// Check if buffer has enough data for analysis
|
||||
pub fn has_sufficient_data(&self, min_duration: f64) -> bool {
|
||||
self.duration() >= min_duration
|
||||
}
|
||||
}
|
||||
|
||||
/// Detection pipeline that combines all detectors
|
||||
pub struct DetectionPipeline {
|
||||
config: DetectionConfig,
|
||||
breathing_detector: BreathingDetector,
|
||||
heartbeat_detector: HeartbeatDetector,
|
||||
movement_classifier: MovementClassifier,
|
||||
data_buffer: parking_lot::RwLock<CsiDataBuffer>,
|
||||
}
|
||||
|
||||
impl DetectionPipeline {
|
||||
/// Create a new detection pipeline
|
||||
pub fn new(config: DetectionConfig) -> Self {
|
||||
Self {
|
||||
breathing_detector: BreathingDetector::new(config.breathing.clone()),
|
||||
heartbeat_detector: HeartbeatDetector::new(config.heartbeat.clone()),
|
||||
movement_classifier: MovementClassifier::new(config.movement.clone()),
|
||||
data_buffer: parking_lot::RwLock::new(CsiDataBuffer::new(config.sample_rate)),
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Process a scan zone and return detected vital signs
|
||||
pub async fn process_zone(&self, zone: &ScanZone) -> Result<Option<VitalSignsReading>, MatError> {
|
||||
// In a real implementation, this would:
|
||||
// 1. Collect CSI data from sensors in the zone
|
||||
// 2. Preprocess the data
|
||||
// 3. Run detection algorithms
|
||||
|
||||
// For now, check if we have buffered data
|
||||
let buffer = self.data_buffer.read();
|
||||
|
||||
if !buffer.has_sufficient_data(5.0) {
|
||||
// Need at least 5 seconds of data
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Detect vital signs
|
||||
let reading = self.detect_from_buffer(&buffer, zone)?;
|
||||
|
||||
// Check minimum confidence
|
||||
if let Some(ref r) = reading {
|
||||
if r.confidence.value() < self.config.min_confidence {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(reading)
|
||||
}
|
||||
|
||||
/// Add CSI data to the processing buffer
|
||||
pub fn add_data(&self, amplitudes: &[f64], phases: &[f64]) {
|
||||
let mut buffer = self.data_buffer.write();
|
||||
buffer.add_samples(amplitudes, phases);
|
||||
|
||||
// Keep only recent data (last 30 seconds)
|
||||
let max_samples = (30.0 * self.config.sample_rate) as usize;
|
||||
if buffer.amplitudes.len() > max_samples {
|
||||
let drain_count = buffer.amplitudes.len() - max_samples;
|
||||
buffer.amplitudes.drain(0..drain_count);
|
||||
buffer.phases.drain(0..drain_count);
|
||||
buffer.timestamps.drain(0..drain_count);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the data buffer
|
||||
pub fn clear_buffer(&self) {
|
||||
self.data_buffer.write().clear();
|
||||
}
|
||||
|
||||
/// Detect vital signs from buffered data
|
||||
fn detect_from_buffer(
|
||||
&self,
|
||||
buffer: &CsiDataBuffer,
|
||||
_zone: &ScanZone,
|
||||
) -> Result<Option<VitalSignsReading>, MatError> {
|
||||
// Detect breathing
|
||||
let breathing = self.breathing_detector.detect(
|
||||
&buffer.amplitudes,
|
||||
buffer.sample_rate,
|
||||
);
|
||||
|
||||
// Detect heartbeat (if enabled)
|
||||
let heartbeat = if self.config.enable_heartbeat {
|
||||
let breathing_rate = breathing.as_ref().map(|b| b.rate_bpm as f64);
|
||||
self.heartbeat_detector.detect(
|
||||
&buffer.phases,
|
||||
buffer.sample_rate,
|
||||
breathing_rate,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Classify movement
|
||||
let movement = self.movement_classifier.classify(
|
||||
&buffer.amplitudes,
|
||||
buffer.sample_rate,
|
||||
);
|
||||
|
||||
// Check if we detected anything
|
||||
if breathing.is_none() && heartbeat.is_none() && movement.movement_type == crate::domain::MovementType::None {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Create vital signs reading
|
||||
let reading = VitalSignsReading::new(breathing, heartbeat, movement);
|
||||
|
||||
Ok(Some(reading))
|
||||
}
|
||||
|
||||
/// Get configuration
|
||||
pub fn config(&self) -> &DetectionConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// Update configuration
|
||||
pub fn update_config(&mut self, config: DetectionConfig) {
|
||||
self.breathing_detector = BreathingDetector::new(config.breathing.clone());
|
||||
self.heartbeat_detector = HeartbeatDetector::new(config.heartbeat.clone());
|
||||
self.movement_classifier = MovementClassifier::new(config.movement.clone());
|
||||
self.config = config;
|
||||
}
|
||||
}
|
||||
|
||||
impl VitalSignsDetector for DetectionPipeline {
|
||||
fn detect(&self, csi_data: &CsiDataBuffer) -> Option<VitalSignsReading> {
|
||||
// Detect breathing from amplitude variations
|
||||
let breathing = self.breathing_detector.detect(
|
||||
&csi_data.amplitudes,
|
||||
csi_data.sample_rate,
|
||||
);
|
||||
|
||||
// Detect heartbeat from phase variations
|
||||
let heartbeat = if self.config.enable_heartbeat {
|
||||
let breathing_rate = breathing.as_ref().map(|b| b.rate_bpm as f64);
|
||||
self.heartbeat_detector.detect(
|
||||
&csi_data.phases,
|
||||
csi_data.sample_rate,
|
||||
breathing_rate,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Classify movement
|
||||
let movement = self.movement_classifier.classify(
|
||||
&csi_data.amplitudes,
|
||||
csi_data.sample_rate,
|
||||
);
|
||||
|
||||
// Create reading if we detected anything
|
||||
if breathing.is_some() || heartbeat.is_some()
|
||||
|| movement.movement_type != crate::domain::MovementType::None
|
||||
{
|
||||
Some(VitalSignsReading::new(breathing, heartbeat, movement))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn create_test_buffer() -> CsiDataBuffer {
|
||||
let mut buffer = CsiDataBuffer::new(100.0);
|
||||
|
||||
// Add 10 seconds of simulated breathing signal
|
||||
let num_samples = 1000;
|
||||
let amplitudes: Vec<f64> = (0..num_samples)
|
||||
.map(|i| {
|
||||
let t = i as f64 / 100.0;
|
||||
// 16 BPM breathing (0.267 Hz)
|
||||
(2.0 * std::f64::consts::PI * 0.267 * t).sin()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let phases: Vec<f64> = (0..num_samples)
|
||||
.map(|i| {
|
||||
let t = i as f64 / 100.0;
|
||||
// Phase variation from movement
|
||||
(2.0 * std::f64::consts::PI * 0.267 * t).sin() * 0.5
|
||||
})
|
||||
.collect();
|
||||
|
||||
buffer.add_samples(&litudes, &phases);
|
||||
buffer
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pipeline_creation() {
|
||||
let config = DetectionConfig::default();
|
||||
let pipeline = DetectionPipeline::new(config);
|
||||
assert_eq!(pipeline.config().sample_rate, 1000.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_csi_buffer() {
|
||||
let mut buffer = CsiDataBuffer::new(100.0);
|
||||
|
||||
assert!(!buffer.has_sufficient_data(5.0));
|
||||
|
||||
let amplitudes: Vec<f64> = vec![1.0; 600];
|
||||
let phases: Vec<f64> = vec![0.0; 600];
|
||||
buffer.add_samples(&litudes, &phases);
|
||||
|
||||
assert!(buffer.has_sufficient_data(5.0));
|
||||
assert_eq!(buffer.duration(), 6.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vital_signs_detection() {
|
||||
let config = DetectionConfig::default();
|
||||
let pipeline = DetectionPipeline::new(config);
|
||||
let buffer = create_test_buffer();
|
||||
|
||||
let result = pipeline.detect(&buffer);
|
||||
assert!(result.is_some());
|
||||
|
||||
let reading = result.unwrap();
|
||||
assert!(reading.has_vitals());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_from_disaster_config() {
|
||||
let disaster_config = DisasterConfig::builder()
|
||||
.sensitivity(0.9)
|
||||
.build();
|
||||
|
||||
let detection_config = DetectionConfig::from_disaster_config(&disaster_config);
|
||||
|
||||
// High sensitivity should enable heartbeat detection
|
||||
assert!(detection_config.enable_heartbeat);
|
||||
// Low minimum confidence due to high sensitivity
|
||||
assert!(detection_config.min_confidence < 0.4);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user