//! Respiratory rate extraction from CSI residuals. //! //! Uses bandpass filtering (0.1-0.5 Hz) and spectral analysis //! to extract breathing rate from multi-subcarrier CSI data. //! //! The approach follows the same IIR bandpass + zero-crossing pattern //! used by [`CoarseBreathingExtractor`](wifi_densepose_wifiscan::pipeline::CoarseBreathingExtractor) //! in the wifiscan crate, adapted for multi-subcarrier f64 processing //! with weighted subcarrier fusion. use crate::types::{VitalEstimate, VitalStatus}; use std::collections::VecDeque; /// IIR bandpass filter state (2nd-order resonator). #[derive(Clone, Debug)] struct IirState { x1: f64, x2: f64, y1: f64, y2: f64, } impl Default for IirState { fn default() -> Self { Self { x1: 0.0, x2: 0.0, y1: 0.0, y2: 0.0, } } } /// Respiratory rate extractor using bandpass filtering and zero-crossing analysis. pub struct BreathingExtractor { /// Per-sample filtered signal history (sliding window; O(1) push/pop). filtered_history: VecDeque, /// Sample rate in Hz. sample_rate: f64, /// Analysis window in seconds. window_secs: f64, /// Maximum subcarrier slots. n_subcarriers: usize, /// Breathing band low cutoff (Hz). freq_low: f64, /// Breathing band high cutoff (Hz). freq_high: f64, /// IIR filter state. filter_state: IirState, } impl BreathingExtractor { /// Create a new breathing extractor. /// /// - `n_subcarriers`: number of subcarrier channels. /// - `sample_rate`: input sample rate in Hz. /// - `window_secs`: analysis window length in seconds (default: 30). #[must_use] #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] pub fn new(n_subcarriers: usize, sample_rate: f64, window_secs: f64) -> Self { let capacity = (sample_rate * window_secs) as usize; Self { filtered_history: VecDeque::with_capacity(capacity), sample_rate, window_secs, n_subcarriers, freq_low: 0.1, freq_high: 0.5, filter_state: IirState::default(), } } /// Create with ESP32 defaults (56 subcarriers, 100 Hz, 30 s window). #[must_use] pub fn esp32_default() -> Self { Self::new(56, 100.0, 30.0) } /// Extract respiratory rate from a vector of per-subcarrier residuals. /// /// - `residuals`: amplitude residuals from the preprocessor. /// - `weights`: per-subcarrier attention weights (higher = more /// body-sensitive). If shorter than `residuals`, missing weights /// default to uniform. /// /// Returns a `VitalEstimate` with the breathing rate in BPM, or /// `None` if insufficient history has been accumulated. pub fn extract(&mut self, residuals: &[f64], weights: &[f64]) -> Option { let n = residuals.len().min(self.n_subcarriers); if n == 0 { return None; } // Weighted fusion of subcarrier residuals (normalized — see // `fuse_weighted_residuals`). let weighted_signal = fuse_weighted_residuals(residuals, weights, n); // Apply IIR bandpass filter let filtered = self.bandpass_filter(weighted_signal); // Defense-in-depth: never let a non-finite filter output (e.g. a // diverged resonator pole at a pathological sample rate) enter the // history buffer. Mirrors ADR-154 §3 / ADR-157 §A3. if !filtered.is_finite() { return None; } // Append to history, enforce window limit. `VecDeque` gives O(1) // push_back + pop_front for the sliding window (was a `Vec` with an // O(n) `remove(0)` per sample — ADR-157 §A1). self.filtered_history.push_back(filtered); let max_len = (self.sample_rate * self.window_secs) as usize; if self.filtered_history.len() > max_len { self.filtered_history.pop_front(); } // Need at least 10 seconds of data let min_samples = (self.sample_rate * 10.0) as usize; if self.filtered_history.len() < min_samples { return None; } // Zero-crossing rate -> frequency. `make_contiguous` rotates the ring // buffer in place once so the slice helpers below can borrow it. let history = self.filtered_history.make_contiguous(); let crossings = count_zero_crossings(history); let duration_s = history.len() as f64 / self.sample_rate; let frequency_hz = crossings as f64 / (2.0 * duration_s); // Validate frequency is within the breathing band if frequency_hz < self.freq_low || frequency_hz > self.freq_high { return None; } let bpm = frequency_hz * 60.0; let confidence = compute_confidence(history); let status = if confidence >= 0.7 { VitalStatus::Valid } else if confidence >= 0.4 { VitalStatus::Degraded } else { VitalStatus::Unreliable }; Some(VitalEstimate { value_bpm: bpm, confidence, status, }) } /// 2nd-order IIR bandpass filter using a resonator topology. /// /// y[n] = (1-r)*(x[n] - x[n-2]) + 2*r*cos(w0)*y[n-1] - r^2*y[n-2] fn bandpass_filter(&mut self, input: f64) -> f64 { let state = &mut self.filter_state; let omega_low = 2.0 * std::f64::consts::PI * self.freq_low / self.sample_rate; let omega_high = 2.0 * std::f64::consts::PI * self.freq_high / self.sample_rate; let bw = omega_high - omega_low; let center = f64::midpoint(omega_low, omega_high); // Clamp the resonator pole radius into a stable range. The pole // magnitude is `|r|`; stability needs `|r| < 1`. When `bw` exceeds 4 // (a very low `fs` relative to the band width) `1 - bw/2` drops below // -1, pushing the pole outside the unit circle and diverging the filter // exponentially to ±inf. (A merely-negative `r` with `|r| < 1` is still // stable.) The clamp keeps the pole inside the unit circle for any // sample-rate / band-edge configuration (ADR-157 §A3). let r = (1.0 - bw / 2.0).clamp(0.0, 0.9999); let cos_w0 = center.cos(); let output = (1.0 - r) * (input - state.x2) + 2.0 * r * cos_w0 * state.y1 - r * r * state.y2; state.x2 = state.x1; state.x1 = input; state.y2 = state.y1; state.y1 = output; output } /// Reset all filter state and history. pub fn reset(&mut self) { self.filtered_history.clear(); self.filter_state = IirState::default(); } /// Current number of samples in the history buffer. #[must_use] pub fn history_len(&self) -> usize { self.filtered_history.len() } /// Breathing band cutoff frequencies. #[must_use] pub fn band(&self) -> (f64, f64) { (self.freq_low, self.freq_high) } } /// Fuse the first `n` per-subcarrier residuals into a single scalar using /// the supplied attention `weights`, normalized by the sum of the /// **effective** weights actually used. /// /// Missing weights (when `weights.len() < n`) default to the uniform weight /// `1/n`. Normalizing by `Σ(effective weights)` is what makes a partial /// `weights` slice safe: without it, supplied entries (used raw) and the /// uniform tail are summed at two different scales, silently mis-scaling the /// breathing signal. Mirrors `heartrate::compute_phase_coherence_signal` /// (`weighted_sum / weight_total`). (ADR-157 §A2) fn fuse_weighted_residuals(residuals: &[f64], weights: &[f64], n: usize) -> f64 { let uniform_w = 1.0 / n as f64; let mut weighted_sum = 0.0; let mut weight_total = 0.0; for (i, &r) in residuals.iter().enumerate().take(n) { let w = weights.get(i).copied().unwrap_or(uniform_w); weighted_sum += r * w; weight_total += w; } if weight_total.abs() > 1e-15 { weighted_sum / weight_total } else { 0.0 } } /// Count zero crossings in a signal. fn count_zero_crossings(signal: &[f64]) -> usize { signal.windows(2).filter(|w| w[0] * w[1] < 0.0).count() } /// Compute confidence in the breathing estimate based on signal regularity. fn compute_confidence(history: &[f64]) -> f64 { if history.len() < 4 { return 0.0; } let n = history.len() as f64; let mean: f64 = history.iter().sum::() / n; let variance: f64 = history.iter().map(|x| (x - mean) * (x - mean)).sum::() / n; if variance < 1e-15 { return 0.0; } let peak = history.iter().map(|x| x.abs()).fold(0.0_f64, f64::max); let noise = variance.sqrt(); let snr = if noise > 1e-15 { peak / noise } else { 0.0 }; // Map SNR to [0, 1] confidence (snr / 5.0).min(1.0) } #[cfg(test)] mod tests { use super::*; #[test] fn no_data_returns_none() { let mut ext = BreathingExtractor::new(4, 10.0, 30.0); assert!(ext.extract(&[], &[]).is_none()); } #[test] fn insufficient_history_returns_none() { let mut ext = BreathingExtractor::new(2, 10.0, 30.0); // Just a few frames are not enough for _ in 0..5 { assert!(ext.extract(&[1.0, 2.0], &[0.5, 0.5]).is_none()); } } #[test] fn zero_crossings_count() { let signal = vec![1.0, -1.0, 1.0, -1.0, 1.0]; assert_eq!(count_zero_crossings(&signal), 4); } #[test] fn zero_crossings_constant() { let signal = vec![1.0, 1.0, 1.0, 1.0]; assert_eq!(count_zero_crossings(&signal), 0); } #[test] fn sinusoidal_breathing_detected() { let sample_rate = 10.0; let mut ext = BreathingExtractor::new(1, sample_rate, 60.0); let breathing_freq = 0.25; // 15 BPM // Generate 60 seconds of sinusoidal breathing signal for i in 0..600 { let t = i as f64 / sample_rate; let signal = (2.0 * std::f64::consts::PI * breathing_freq * t).sin(); ext.extract(&[signal], &[1.0]); } let result = ext.extract(&[0.0], &[1.0]); if let Some(est) = result { // Should be approximately 15 BPM (0.25 Hz * 60) assert!( est.value_bpm > 5.0 && est.value_bpm < 40.0, "estimated BPM should be in breathing range: {}", est.value_bpm, ); assert!(est.confidence > 0.0, "confidence should be > 0"); } } #[test] fn reset_clears_state() { let mut ext = BreathingExtractor::new(2, 10.0, 30.0); ext.extract(&[1.0, 2.0], &[0.5, 0.5]); assert!(ext.history_len() > 0); ext.reset(); assert_eq!(ext.history_len(), 0); } #[test] fn band_returns_correct_values() { let ext = BreathingExtractor::new(1, 10.0, 30.0); let (low, high) = ext.band(); assert!((low - 0.1).abs() < f64::EPSILON); assert!((high - 0.5).abs() < f64::EPSILON); } #[test] fn confidence_zero_for_flat_signal() { let history = vec![0.0; 100]; let conf = compute_confidence(&history); assert!((conf - 0.0).abs() < f64::EPSILON); } #[test] fn confidence_positive_for_oscillating_signal() { let history: Vec = (0..100).map(|i| (i as f64 * 0.5).sin()).collect(); let conf = compute_confidence(&history); assert!(conf > 0.0); } #[test] fn esp32_default_creates_correctly() { let ext = BreathingExtractor::esp32_default(); assert_eq!(ext.n_subcarriers, 56); } /// ADR-157 §A2 bug-catching test. /// /// With `residuals = [1.0; 8]` and `weights = [10.0, 10.0]` (len 2 < n=8), /// the supplied weights (10.0) and the uniform-fallback tail (1/8) are at /// two different scales. The correct, normalized fusion divides by the sum /// of the *effective* weights, so the fused value must equal the /// renormalized weighted mean of the residuals = 1.0 (all residuals equal /// 1.0). The OLD code returned the un-normalized sum /// (`2*10 + 6*0.125 = 20.75`), so this asserts the fix. #[test] fn partial_weights_are_renormalized_not_scale_mixed() { let residuals = [1.0_f64; 8]; let weights = [10.0_f64, 10.0]; let fused = fuse_weighted_residuals(&residuals, &weights, 8); // Renormalized weighted mean of equal residuals is exactly the residual // value, regardless of the weight scale. assert!( (fused - 1.0).abs() < 1e-12, "partial weights must renormalize to the weighted mean (1.0), got {fused}" ); // Explicitly pin that we are NOT returning the old scale-mixed sum. let old_scale_mixed_sum: f64 = 2.0 * 10.0 + 6.0 * (1.0 / 8.0); assert!( (fused - old_scale_mixed_sum).abs() > 1.0, "fused value must not equal the old un-normalized sum {old_scale_mixed_sum}" ); } /// ADR-157 §A2: with differing residual values, the normalized fusion is a /// proper weighted average dominated by the high-weight entries. #[test] fn partial_weights_fusion_is_weighted_average() { // Two heavily-weighted residuals of 2.0, the rest (uniform) of 0.0. let residuals = [2.0, 2.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]; let weights = [10.0_f64, 10.0]; let fused = fuse_weighted_residuals(&residuals, &weights, 8); // weighted_sum = 2*10*2 ... = 40; weight_total = 20 + 6*0.125 = 20.75 let expected = (2.0 * 10.0 + 2.0 * 10.0) / (20.0 + 6.0 * 0.125); assert!( (fused - expected).abs() < 1e-12, "expected weighted average {expected}, got {fused}" ); // Must lie within the residual range [0, 2] — a scale-mixed sum would not. assert!((0.0..=2.0).contains(&fused), "weighted average must be in-range: {fused}"); } /// ADR-157 §A3 bug-catching test. Divergence needs the pole magnitude /// `|r| >= 1`, i.e. `bw >= 4`. At `fs = 0.5` Hz with the band widened to /// 0.1-0.9 Hz, `bw = 2*pi*(0.9-0.1)/0.5 = 10.05`, so the OLD pole radius /// `r = 1 - bw/2 = -4.03` has `|r| = 4.03 > 1` and the filter blows up /// exponentially, overflowing to ±inf within ~600 unit-step frames. The /// clamp + finite-guard keep every accumulated sample finite. This FAILS on /// the old code (verified by reverting). #[test] fn low_sample_rate_filter_stays_finite() { let mut ext = BreathingExtractor::new(4, 0.5, 3600.0); ext.freq_low = 0.1; ext.freq_high = 0.9; // Feed a unit step for 600 frames — enough for the un-clamped resonator // to overflow to inf. for _ in 0..600 { ext.extract(&[1.0, 1.0, 1.0, 1.0], &[0.25, 0.25, 0.25, 0.25]); } assert!(ext.history_len() > 0, "history should accumulate"); for (i, &v) in ext.filtered_history.iter().enumerate() { assert!(v.is_finite(), "filtered_history[{i}] must be finite, got {v}"); } } }