feat: Training mode, ADR docs, vitals and wifiscan crates
- 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>
This commit is contained in:
@@ -0,0 +1,399 @@
|
||||
//! 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
//! 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};
|
||||
|
||||
/// 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.
|
||||
filtered_history: Vec<f64>,
|
||||
/// 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: Vec::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<VitalEstimate> {
|
||||
let n = residuals.len().min(self.n_subcarriers);
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Weighted fusion of subcarrier residuals
|
||||
let uniform_w = 1.0 / n as f64;
|
||||
let weighted_signal: f64 = residuals
|
||||
.iter()
|
||||
.enumerate()
|
||||
.take(n)
|
||||
.map(|(i, &r)| {
|
||||
let w = weights.get(i).copied().unwrap_or(uniform_w);
|
||||
r * w
|
||||
})
|
||||
.sum();
|
||||
|
||||
// Apply IIR bandpass filter
|
||||
let filtered = self.bandpass_filter(weighted_signal);
|
||||
|
||||
// Append to history, enforce window limit
|
||||
self.filtered_history.push(filtered);
|
||||
let max_len = (self.sample_rate * self.window_secs) as usize;
|
||||
if self.filtered_history.len() > max_len {
|
||||
self.filtered_history.remove(0);
|
||||
}
|
||||
|
||||
// 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
|
||||
let crossings = count_zero_crossings(&self.filtered_history);
|
||||
let duration_s = self.filtered_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(&self.filtered_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);
|
||||
|
||||
let r = 1.0 - bw / 2.0;
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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::<f64>() / n;
|
||||
let variance: f64 = history.iter().map(|x| (x - mean) * (x - mean)).sum::<f64>() / 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<f64> = (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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
//! Heart rate extraction from CSI phase coherence.
|
||||
//!
|
||||
//! Uses bandpass filtering (0.8-2.0 Hz) and autocorrelation-based
|
||||
//! peak detection to extract cardiac rate from inter-subcarrier
|
||||
//! phase data. Requires multi-subcarrier CSI data (ESP32 mode only).
|
||||
//!
|
||||
//! The cardiac signal (0.1-0.5 mm body surface displacement) is
|
||||
//! ~10x weaker than the respiratory signal (1-5 mm chest displacement),
|
||||
//! so this module relies on phase coherence across subcarriers rather
|
||||
//! than single-channel amplitude analysis.
|
||||
|
||||
use crate::types::{VitalEstimate, VitalStatus};
|
||||
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Heart rate extractor using bandpass filtering and autocorrelation
|
||||
/// peak detection.
|
||||
pub struct HeartRateExtractor {
|
||||
/// Per-sample filtered signal history.
|
||||
filtered_history: Vec<f64>,
|
||||
/// Sample rate in Hz.
|
||||
sample_rate: f64,
|
||||
/// Analysis window in seconds.
|
||||
window_secs: f64,
|
||||
/// Maximum subcarrier slots.
|
||||
n_subcarriers: usize,
|
||||
/// Cardiac band low cutoff (Hz) -- 0.8 Hz = 48 BPM.
|
||||
freq_low: f64,
|
||||
/// Cardiac band high cutoff (Hz) -- 2.0 Hz = 120 BPM.
|
||||
freq_high: f64,
|
||||
/// IIR filter state.
|
||||
filter_state: IirState,
|
||||
/// Minimum subcarriers required for reliable HR estimation.
|
||||
min_subcarriers: usize,
|
||||
}
|
||||
|
||||
impl HeartRateExtractor {
|
||||
/// Create a new heart rate extractor.
|
||||
///
|
||||
/// - `n_subcarriers`: number of subcarrier channels.
|
||||
/// - `sample_rate`: input sample rate in Hz.
|
||||
/// - `window_secs`: analysis window length in seconds (default: 15).
|
||||
#[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: Vec::with_capacity(capacity),
|
||||
sample_rate,
|
||||
window_secs,
|
||||
n_subcarriers,
|
||||
freq_low: 0.8,
|
||||
freq_high: 2.0,
|
||||
filter_state: IirState::default(),
|
||||
min_subcarriers: 4,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with ESP32 defaults (56 subcarriers, 100 Hz, 15 s window).
|
||||
#[must_use]
|
||||
pub fn esp32_default() -> Self {
|
||||
Self::new(56, 100.0, 15.0)
|
||||
}
|
||||
|
||||
/// Extract heart rate from per-subcarrier residuals and phase data.
|
||||
///
|
||||
/// - `residuals`: amplitude residuals from the preprocessor.
|
||||
/// - `phases`: per-subcarrier unwrapped phases (radians).
|
||||
///
|
||||
/// Returns a `VitalEstimate` with heart rate in BPM, or `None`
|
||||
/// if insufficient data or too few subcarriers.
|
||||
pub fn extract(&mut self, residuals: &[f64], phases: &[f64]) -> Option<VitalEstimate> {
|
||||
let n = residuals.len().min(self.n_subcarriers).min(phases.len());
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// For cardiac signals, use phase-coherence weighted fusion.
|
||||
// Compute mean phase differential as a proxy for body-surface
|
||||
// displacement sensitivity.
|
||||
let phase_signal = compute_phase_coherence_signal(residuals, phases, n);
|
||||
|
||||
// Apply cardiac-band IIR bandpass filter
|
||||
let filtered = self.bandpass_filter(phase_signal);
|
||||
|
||||
// Append to history, enforce window limit
|
||||
self.filtered_history.push(filtered);
|
||||
let max_len = (self.sample_rate * self.window_secs) as usize;
|
||||
if self.filtered_history.len() > max_len {
|
||||
self.filtered_history.remove(0);
|
||||
}
|
||||
|
||||
// Need at least 5 seconds of data for cardiac detection
|
||||
let min_samples = (self.sample_rate * 5.0) as usize;
|
||||
if self.filtered_history.len() < min_samples {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Use autocorrelation to find the dominant periodicity
|
||||
let (period_samples, acf_peak) =
|
||||
autocorrelation_peak(&self.filtered_history, self.sample_rate, self.freq_low, self.freq_high);
|
||||
|
||||
if period_samples == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let frequency_hz = self.sample_rate / period_samples as f64;
|
||||
let bpm = frequency_hz * 60.0;
|
||||
|
||||
// Validate BPM is in physiological range (40-180 BPM)
|
||||
if !(40.0..=180.0).contains(&bpm) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Confidence based on autocorrelation peak strength and subcarrier count
|
||||
let subcarrier_factor = if n >= self.min_subcarriers {
|
||||
1.0
|
||||
} else {
|
||||
n as f64 / self.min_subcarriers as f64
|
||||
};
|
||||
let confidence = (acf_peak * subcarrier_factor).clamp(0.0, 1.0);
|
||||
|
||||
let status = if confidence >= 0.6 && n >= self.min_subcarriers {
|
||||
VitalStatus::Valid
|
||||
} else if confidence >= 0.3 {
|
||||
VitalStatus::Degraded
|
||||
} else {
|
||||
VitalStatus::Unreliable
|
||||
};
|
||||
|
||||
Some(VitalEstimate {
|
||||
value_bpm: bpm,
|
||||
confidence,
|
||||
status,
|
||||
})
|
||||
}
|
||||
|
||||
/// 2nd-order IIR bandpass filter (cardiac band: 0.8-2.0 Hz).
|
||||
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);
|
||||
|
||||
let r = 1.0 - bw / 2.0;
|
||||
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()
|
||||
}
|
||||
|
||||
/// Cardiac band cutoff frequencies.
|
||||
#[must_use]
|
||||
pub fn band(&self) -> (f64, f64) {
|
||||
(self.freq_low, self.freq_high)
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute a phase-coherence-weighted signal from residuals and phases.
|
||||
///
|
||||
/// Combines amplitude residuals with inter-subcarrier phase coherence
|
||||
/// to enhance the cardiac signal. Subcarriers with similar phase
|
||||
/// derivatives are likely sensing the same body surface.
|
||||
fn compute_phase_coherence_signal(residuals: &[f64], phases: &[f64], n: usize) -> f64 {
|
||||
if n <= 1 {
|
||||
return residuals.first().copied().unwrap_or(0.0);
|
||||
}
|
||||
|
||||
// Compute inter-subcarrier phase differences as coherence weights.
|
||||
// Adjacent subcarriers with small phase differences are more coherent.
|
||||
let mut weighted_sum = 0.0;
|
||||
let mut weight_total = 0.0;
|
||||
|
||||
for i in 0..n {
|
||||
let coherence = if i + 1 < n {
|
||||
let phase_diff = (phases[i + 1] - phases[i]).abs();
|
||||
// Higher coherence when phase difference is small
|
||||
(-phase_diff).exp()
|
||||
} else if i > 0 {
|
||||
let phase_diff = (phases[i] - phases[i - 1]).abs();
|
||||
(-phase_diff).exp()
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
weighted_sum += residuals[i] * coherence;
|
||||
weight_total += coherence;
|
||||
}
|
||||
|
||||
if weight_total > 1e-15 {
|
||||
weighted_sum / weight_total
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the dominant periodicity via autocorrelation in the cardiac band.
|
||||
///
|
||||
/// Returns `(period_in_samples, peak_normalized_acf)`. If no peak is
|
||||
/// found, returns `(0, 0.0)`.
|
||||
fn autocorrelation_peak(
|
||||
signal: &[f64],
|
||||
sample_rate: f64,
|
||||
freq_low: f64,
|
||||
freq_high: f64,
|
||||
) -> (usize, f64) {
|
||||
let n = signal.len();
|
||||
if n < 4 {
|
||||
return (0, 0.0);
|
||||
}
|
||||
|
||||
// Lag range corresponding to the cardiac band
|
||||
let min_lag = (sample_rate / freq_high).floor() as usize; // highest freq = shortest period
|
||||
let max_lag = (sample_rate / freq_low).ceil() as usize; // lowest freq = longest period
|
||||
let max_lag = max_lag.min(n / 2);
|
||||
|
||||
if min_lag >= max_lag || min_lag >= n {
|
||||
return (0, 0.0);
|
||||
}
|
||||
|
||||
// Compute mean-subtracted signal
|
||||
let mean: f64 = signal.iter().sum::<f64>() / n as f64;
|
||||
|
||||
// Autocorrelation at lag 0 for normalisation
|
||||
let acf0: f64 = signal.iter().map(|&x| (x - mean) * (x - mean)).sum();
|
||||
if acf0 < 1e-15 {
|
||||
return (0, 0.0);
|
||||
}
|
||||
|
||||
// Search for the peak in the cardiac lag range
|
||||
let mut best_lag = 0;
|
||||
let mut best_acf = f64::MIN;
|
||||
|
||||
for lag in min_lag..=max_lag {
|
||||
let acf: f64 = signal
|
||||
.iter()
|
||||
.take(n - lag)
|
||||
.enumerate()
|
||||
.map(|(i, &x)| (x - mean) * (signal[i + lag] - mean))
|
||||
.sum();
|
||||
|
||||
let normalized = acf / acf0;
|
||||
if normalized > best_acf {
|
||||
best_acf = normalized;
|
||||
best_lag = lag;
|
||||
}
|
||||
}
|
||||
|
||||
if best_acf > 0.0 {
|
||||
(best_lag, best_acf)
|
||||
} else {
|
||||
(0, 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn no_data_returns_none() {
|
||||
let mut ext = HeartRateExtractor::new(4, 100.0, 15.0);
|
||||
assert!(ext.extract(&[], &[]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insufficient_history_returns_none() {
|
||||
let mut ext = HeartRateExtractor::new(2, 100.0, 15.0);
|
||||
for _ in 0..10 {
|
||||
assert!(ext.extract(&[0.1, 0.2], &[0.0, 0.0]).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sinusoidal_heartbeat_detected() {
|
||||
let sample_rate = 50.0;
|
||||
let mut ext = HeartRateExtractor::new(4, sample_rate, 20.0);
|
||||
let heart_freq = 1.2; // 72 BPM
|
||||
|
||||
// Generate 20 seconds of simulated cardiac signal across 4 subcarriers
|
||||
for i in 0..1000 {
|
||||
let t = i as f64 / sample_rate;
|
||||
let base = (2.0 * std::f64::consts::PI * heart_freq * t).sin();
|
||||
let residuals = vec![base * 0.1, base * 0.08, base * 0.12, base * 0.09];
|
||||
let phases = vec![0.0, 0.01, 0.02, 0.03]; // highly coherent
|
||||
ext.extract(&residuals, &phases);
|
||||
}
|
||||
|
||||
let final_residuals = vec![0.0; 4];
|
||||
let final_phases = vec![0.0; 4];
|
||||
let result = ext.extract(&final_residuals, &final_phases);
|
||||
|
||||
if let Some(est) = result {
|
||||
assert!(
|
||||
est.value_bpm > 40.0 && est.value_bpm < 180.0,
|
||||
"estimated BPM should be in cardiac range: {}",
|
||||
est.value_bpm,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut ext = HeartRateExtractor::new(2, 100.0, 15.0);
|
||||
ext.extract(&[0.1, 0.2], &[0.0, 0.1]);
|
||||
assert!(ext.history_len() > 0);
|
||||
ext.reset();
|
||||
assert_eq!(ext.history_len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn band_returns_correct_values() {
|
||||
let ext = HeartRateExtractor::new(1, 100.0, 15.0);
|
||||
let (low, high) = ext.band();
|
||||
assert!((low - 0.8).abs() < f64::EPSILON);
|
||||
assert!((high - 2.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autocorrelation_finds_known_period() {
|
||||
let sample_rate = 50.0;
|
||||
let freq = 1.0; // 1 Hz = period of 50 samples
|
||||
let signal: Vec<f64> = (0..500)
|
||||
.map(|i| (2.0 * std::f64::consts::PI * freq * i as f64 / sample_rate).sin())
|
||||
.collect();
|
||||
|
||||
let (period, acf) = autocorrelation_peak(&signal, sample_rate, 0.8, 2.0);
|
||||
assert!(period > 0, "should find a period");
|
||||
assert!(acf > 0.5, "autocorrelation peak should be strong: {acf}");
|
||||
|
||||
let estimated_freq = sample_rate / period as f64;
|
||||
assert!(
|
||||
(estimated_freq - 1.0).abs() < 0.1,
|
||||
"estimated frequency should be ~1 Hz, got {estimated_freq}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase_coherence_single_subcarrier() {
|
||||
let result = compute_phase_coherence_signal(&[5.0], &[0.0], 1);
|
||||
assert!((result - 5.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase_coherence_multi_subcarrier() {
|
||||
// Two coherent subcarriers (small phase difference)
|
||||
let result = compute_phase_coherence_signal(&[1.0, 1.0], &[0.0, 0.01], 2);
|
||||
// Both weights should be ~1.0 (exp(-0.01) ~ 0.99), so result ~ 1.0
|
||||
assert!((result - 1.0).abs() < 0.1, "coherent result should be ~1.0: {result}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esp32_default_creates_correctly() {
|
||||
let ext = HeartRateExtractor::esp32_default();
|
||||
assert_eq!(ext.n_subcarriers, 56);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//! ESP32 CSI-grade vital sign extraction (ADR-021).
|
||||
//!
|
||||
//! Extracts heart rate and respiratory rate from WiFi Channel
|
||||
//! State Information using multi-subcarrier amplitude and phase
|
||||
//! analysis.
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! The pipeline processes CSI frames through four stages:
|
||||
//!
|
||||
//! 1. **Preprocessing** ([`CsiVitalPreprocessor`]): EMA-based static
|
||||
//! component suppression, producing per-subcarrier residuals.
|
||||
//! 2. **Breathing extraction** ([`BreathingExtractor`]): Bandpass
|
||||
//! filtering (0.1-0.5 Hz) with zero-crossing analysis for
|
||||
//! respiratory rate.
|
||||
//! 3. **Heart rate extraction** ([`HeartRateExtractor`]): Bandpass
|
||||
//! filtering (0.8-2.0 Hz) with autocorrelation peak detection
|
||||
//! and inter-subcarrier phase coherence weighting.
|
||||
//! 4. **Anomaly detection** ([`VitalAnomalyDetector`]): Z-score
|
||||
//! analysis with Welford running statistics for clinical alerts
|
||||
//! (apnea, tachycardia, bradycardia).
|
||||
//!
|
||||
//! Results are stored in a [`VitalSignStore`] with configurable
|
||||
//! retention for historical analysis.
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```
|
||||
//! use wifi_densepose_vitals::{
|
||||
//! CsiVitalPreprocessor, BreathingExtractor, HeartRateExtractor,
|
||||
//! VitalAnomalyDetector, VitalSignStore, CsiFrame,
|
||||
//! VitalReading, VitalEstimate, VitalStatus,
|
||||
//! };
|
||||
//!
|
||||
//! let mut preprocessor = CsiVitalPreprocessor::new(56, 0.05);
|
||||
//! let mut breathing = BreathingExtractor::new(56, 100.0, 30.0);
|
||||
//! let mut heartrate = HeartRateExtractor::new(56, 100.0, 15.0);
|
||||
//! let mut anomaly = VitalAnomalyDetector::default_config();
|
||||
//! let mut store = VitalSignStore::new(3600);
|
||||
//!
|
||||
//! // Process a CSI frame
|
||||
//! let frame = CsiFrame {
|
||||
//! amplitudes: vec![1.0; 56],
|
||||
//! phases: vec![0.0; 56],
|
||||
//! n_subcarriers: 56,
|
||||
//! sample_index: 0,
|
||||
//! sample_rate_hz: 100.0,
|
||||
//! };
|
||||
//!
|
||||
//! if let Some(residuals) = preprocessor.process(&frame) {
|
||||
//! let weights = vec![1.0 / 56.0; 56];
|
||||
//! let rr = breathing.extract(&residuals, &weights);
|
||||
//! let hr = heartrate.extract(&residuals, &frame.phases);
|
||||
//!
|
||||
//! let reading = VitalReading {
|
||||
//! respiratory_rate: rr.unwrap_or_else(VitalEstimate::unavailable),
|
||||
//! heart_rate: hr.unwrap_or_else(VitalEstimate::unavailable),
|
||||
//! subcarrier_count: frame.n_subcarriers,
|
||||
//! signal_quality: 0.9,
|
||||
//! timestamp_secs: 0.0,
|
||||
//! };
|
||||
//!
|
||||
//! let alerts = anomaly.check(&reading);
|
||||
//! store.push(reading);
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
pub mod anomaly;
|
||||
pub mod breathing;
|
||||
pub mod heartrate;
|
||||
pub mod preprocessor;
|
||||
pub mod store;
|
||||
pub mod types;
|
||||
|
||||
pub use anomaly::{AnomalyAlert, VitalAnomalyDetector};
|
||||
pub use breathing::BreathingExtractor;
|
||||
pub use heartrate::HeartRateExtractor;
|
||||
pub use preprocessor::CsiVitalPreprocessor;
|
||||
pub use store::{VitalSignStore, VitalStats};
|
||||
pub use types::{CsiFrame, VitalEstimate, VitalReading, VitalStatus};
|
||||
@@ -0,0 +1,206 @@
|
||||
//! CSI vital sign preprocessor.
|
||||
//!
|
||||
//! Suppresses static subcarrier components and extracts the
|
||||
//! body-modulated signal residuals for vital sign analysis.
|
||||
//!
|
||||
//! Uses an EMA-based predictive filter (same pattern as
|
||||
//! [`PredictiveGate`](wifi_densepose_wifiscan::pipeline::PredictiveGate)
|
||||
//! in the wifiscan crate) operating on per-subcarrier amplitudes.
|
||||
//! The residuals represent deviations from the static environment
|
||||
//! baseline, isolating physiological movements (breathing, heartbeat).
|
||||
|
||||
use crate::types::CsiFrame;
|
||||
|
||||
/// EMA-based preprocessor that extracts body-modulated residuals
|
||||
/// from raw CSI subcarrier amplitudes.
|
||||
pub struct CsiVitalPreprocessor {
|
||||
/// EMA predictions per subcarrier.
|
||||
predictions: Vec<f64>,
|
||||
/// Whether each subcarrier slot has been initialised.
|
||||
initialized: Vec<bool>,
|
||||
/// EMA smoothing factor (lower = slower tracking, better static suppression).
|
||||
alpha: f64,
|
||||
/// Number of subcarrier slots.
|
||||
n_subcarriers: usize,
|
||||
}
|
||||
|
||||
impl CsiVitalPreprocessor {
|
||||
/// Create a new preprocessor.
|
||||
///
|
||||
/// - `n_subcarriers`: number of subcarrier slots to track.
|
||||
/// - `alpha`: EMA smoothing factor in `(0, 1)`. Lower values
|
||||
/// provide better static component suppression but slower
|
||||
/// adaptation. Default for vital signs: `0.05`.
|
||||
#[must_use]
|
||||
pub fn new(n_subcarriers: usize, alpha: f64) -> Self {
|
||||
Self {
|
||||
predictions: vec![0.0; n_subcarriers],
|
||||
initialized: vec![false; n_subcarriers],
|
||||
alpha: alpha.clamp(0.001, 0.999),
|
||||
n_subcarriers,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a preprocessor with defaults suitable for ESP32 CSI
|
||||
/// vital sign extraction (56 subcarriers, alpha = 0.05).
|
||||
#[must_use]
|
||||
pub fn esp32_default() -> Self {
|
||||
Self::new(56, 0.05)
|
||||
}
|
||||
|
||||
/// Process a CSI frame and return the residual vector.
|
||||
///
|
||||
/// The residuals represent the difference between observed and
|
||||
/// predicted (EMA) amplitudes. On the first frame for each
|
||||
/// subcarrier, the prediction is seeded and the raw amplitude
|
||||
/// is returned.
|
||||
///
|
||||
/// Returns `None` if the frame has zero subcarriers.
|
||||
pub fn process(&mut self, frame: &CsiFrame) -> Option<Vec<f64>> {
|
||||
let n = frame.amplitudes.len().min(self.n_subcarriers);
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut residuals = vec![0.0; n];
|
||||
|
||||
for (i, residual) in residuals.iter_mut().enumerate().take(n) {
|
||||
if self.initialized[i] {
|
||||
// Compute residual: observed - predicted
|
||||
*residual = frame.amplitudes[i] - self.predictions[i];
|
||||
// Update EMA prediction
|
||||
self.predictions[i] =
|
||||
self.alpha * frame.amplitudes[i] + (1.0 - self.alpha) * self.predictions[i];
|
||||
} else {
|
||||
// First observation: seed the prediction
|
||||
self.predictions[i] = frame.amplitudes[i];
|
||||
self.initialized[i] = true;
|
||||
// First-frame residual is zero (no prior to compare against)
|
||||
*residual = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
Some(residuals)
|
||||
}
|
||||
|
||||
/// Reset all predictions and initialisation state.
|
||||
pub fn reset(&mut self) {
|
||||
self.predictions.fill(0.0);
|
||||
self.initialized.fill(false);
|
||||
}
|
||||
|
||||
/// Current EMA smoothing factor.
|
||||
#[must_use]
|
||||
pub fn alpha(&self) -> f64 {
|
||||
self.alpha
|
||||
}
|
||||
|
||||
/// Update the EMA smoothing factor.
|
||||
pub fn set_alpha(&mut self, alpha: f64) {
|
||||
self.alpha = alpha.clamp(0.001, 0.999);
|
||||
}
|
||||
|
||||
/// Number of subcarrier slots.
|
||||
#[must_use]
|
||||
pub fn n_subcarriers(&self) -> usize {
|
||||
self.n_subcarriers
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::CsiFrame;
|
||||
|
||||
fn make_frame(amplitudes: Vec<f64>, n: usize) -> CsiFrame {
|
||||
let phases = vec![0.0; n];
|
||||
CsiFrame {
|
||||
amplitudes,
|
||||
phases,
|
||||
n_subcarriers: n,
|
||||
sample_index: 0,
|
||||
sample_rate_hz: 100.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_frame_returns_none() {
|
||||
let mut pp = CsiVitalPreprocessor::new(4, 0.05);
|
||||
let frame = make_frame(vec![], 0);
|
||||
assert!(pp.process(&frame).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_frame_residuals_are_zero() {
|
||||
let mut pp = CsiVitalPreprocessor::new(3, 0.05);
|
||||
let frame = make_frame(vec![1.0, 2.0, 3.0], 3);
|
||||
let residuals = pp.process(&frame).unwrap();
|
||||
assert_eq!(residuals.len(), 3);
|
||||
for &r in &residuals {
|
||||
assert!((r - 0.0).abs() < f64::EPSILON, "first frame residual should be 0");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_signal_residuals_converge_to_zero() {
|
||||
let mut pp = CsiVitalPreprocessor::new(2, 0.1);
|
||||
let frame = make_frame(vec![5.0, 10.0], 2);
|
||||
|
||||
// Seed
|
||||
pp.process(&frame);
|
||||
|
||||
// After many identical frames, residuals should be near zero
|
||||
let mut last_residuals = vec![0.0; 2];
|
||||
for _ in 0..100 {
|
||||
last_residuals = pp.process(&frame).unwrap();
|
||||
}
|
||||
|
||||
for &r in &last_residuals {
|
||||
assert!(r.abs() < 0.01, "residuals should converge to ~0 for static signal, got {r}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_change_produces_large_residual() {
|
||||
let mut pp = CsiVitalPreprocessor::new(1, 0.05);
|
||||
let frame1 = make_frame(vec![10.0], 1);
|
||||
|
||||
// Converge EMA
|
||||
pp.process(&frame1);
|
||||
for _ in 0..200 {
|
||||
pp.process(&frame1);
|
||||
}
|
||||
|
||||
// Step change
|
||||
let frame2 = make_frame(vec![20.0], 1);
|
||||
let residuals = pp.process(&frame2).unwrap();
|
||||
assert!(residuals[0] > 5.0, "step change should produce large residual, got {}", residuals[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut pp = CsiVitalPreprocessor::new(2, 0.1);
|
||||
let frame = make_frame(vec![1.0, 2.0], 2);
|
||||
pp.process(&frame);
|
||||
pp.reset();
|
||||
// After reset, next frame is treated as first
|
||||
let residuals = pp.process(&frame).unwrap();
|
||||
for &r in &residuals {
|
||||
assert!((r - 0.0).abs() < f64::EPSILON);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alpha_clamped() {
|
||||
let pp = CsiVitalPreprocessor::new(1, -5.0);
|
||||
assert!(pp.alpha() > 0.0);
|
||||
let pp = CsiVitalPreprocessor::new(1, 100.0);
|
||||
assert!(pp.alpha() < 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esp32_default_has_correct_subcarriers() {
|
||||
let pp = CsiVitalPreprocessor::esp32_default();
|
||||
assert_eq!(pp.n_subcarriers(), 56);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
//! Vital sign time series store.
|
||||
//!
|
||||
//! Stores vital sign readings with configurable retention.
|
||||
//! Designed for upgrade to `TieredStore` when `ruvector-temporal-tensor`
|
||||
//! becomes available (ADR-021 phase 2).
|
||||
|
||||
use crate::types::{VitalReading, VitalStatus};
|
||||
|
||||
/// Simple vital sign store with capacity-limited ring buffer semantics.
|
||||
pub struct VitalSignStore {
|
||||
/// Stored readings (oldest first).
|
||||
readings: Vec<VitalReading>,
|
||||
/// Maximum number of readings to retain.
|
||||
max_readings: usize,
|
||||
}
|
||||
|
||||
/// Summary statistics for stored vital sign readings.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VitalStats {
|
||||
/// Number of readings in the store.
|
||||
pub count: usize,
|
||||
/// Mean respiratory rate (BPM).
|
||||
pub rr_mean: f64,
|
||||
/// Mean heart rate (BPM).
|
||||
pub hr_mean: f64,
|
||||
/// Min respiratory rate (BPM).
|
||||
pub rr_min: f64,
|
||||
/// Max respiratory rate (BPM).
|
||||
pub rr_max: f64,
|
||||
/// Min heart rate (BPM).
|
||||
pub hr_min: f64,
|
||||
/// Max heart rate (BPM).
|
||||
pub hr_max: f64,
|
||||
/// Fraction of readings with Valid status.
|
||||
pub valid_fraction: f64,
|
||||
}
|
||||
|
||||
impl VitalSignStore {
|
||||
/// Create a new store with a given maximum capacity.
|
||||
///
|
||||
/// When the capacity is exceeded, the oldest readings are evicted.
|
||||
#[must_use]
|
||||
pub fn new(max_readings: usize) -> Self {
|
||||
Self {
|
||||
readings: Vec::with_capacity(max_readings.min(4096)),
|
||||
max_readings: max_readings.max(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with default capacity (3600 readings ~ 1 hour at 1 Hz).
|
||||
#[must_use]
|
||||
pub fn default_capacity() -> Self {
|
||||
Self::new(3600)
|
||||
}
|
||||
|
||||
/// Push a new reading into the store.
|
||||
///
|
||||
/// If the store is at capacity, the oldest reading is evicted.
|
||||
pub fn push(&mut self, reading: VitalReading) {
|
||||
if self.readings.len() >= self.max_readings {
|
||||
self.readings.remove(0);
|
||||
}
|
||||
self.readings.push(reading);
|
||||
}
|
||||
|
||||
/// Get the most recent reading, if any.
|
||||
#[must_use]
|
||||
pub fn latest(&self) -> Option<&VitalReading> {
|
||||
self.readings.last()
|
||||
}
|
||||
|
||||
/// Get the last `n` readings (most recent last).
|
||||
///
|
||||
/// Returns fewer than `n` if the store contains fewer readings.
|
||||
#[must_use]
|
||||
pub fn history(&self, n: usize) -> &[VitalReading] {
|
||||
let start = self.readings.len().saturating_sub(n);
|
||||
&self.readings[start..]
|
||||
}
|
||||
|
||||
/// Compute summary statistics over all stored readings.
|
||||
///
|
||||
/// Returns `None` if the store is empty.
|
||||
#[must_use]
|
||||
pub fn stats(&self) -> Option<VitalStats> {
|
||||
if self.readings.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let n = self.readings.len() as f64;
|
||||
let mut rr_sum = 0.0;
|
||||
let mut hr_sum = 0.0;
|
||||
let mut rr_min = f64::MAX;
|
||||
let mut rr_max = f64::MIN;
|
||||
let mut hr_min = f64::MAX;
|
||||
let mut hr_max = f64::MIN;
|
||||
let mut valid_count = 0_usize;
|
||||
|
||||
for r in &self.readings {
|
||||
let rr = r.respiratory_rate.value_bpm;
|
||||
let hr = r.heart_rate.value_bpm;
|
||||
rr_sum += rr;
|
||||
hr_sum += hr;
|
||||
rr_min = rr_min.min(rr);
|
||||
rr_max = rr_max.max(rr);
|
||||
hr_min = hr_min.min(hr);
|
||||
hr_max = hr_max.max(hr);
|
||||
|
||||
if r.respiratory_rate.status == VitalStatus::Valid
|
||||
&& r.heart_rate.status == VitalStatus::Valid
|
||||
{
|
||||
valid_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Some(VitalStats {
|
||||
count: self.readings.len(),
|
||||
rr_mean: rr_sum / n,
|
||||
hr_mean: hr_sum / n,
|
||||
rr_min,
|
||||
rr_max,
|
||||
hr_min,
|
||||
hr_max,
|
||||
valid_fraction: valid_count as f64 / n,
|
||||
})
|
||||
}
|
||||
|
||||
/// Number of readings currently stored.
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.readings.len()
|
||||
}
|
||||
|
||||
/// Whether the store is empty.
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.readings.is_empty()
|
||||
}
|
||||
|
||||
/// Maximum capacity of the store.
|
||||
#[must_use]
|
||||
pub fn capacity(&self) -> usize {
|
||||
self.max_readings
|
||||
}
|
||||
|
||||
/// Clear all stored readings.
|
||||
pub fn clear(&mut self) {
|
||||
self.readings.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::{VitalEstimate, VitalReading, VitalStatus};
|
||||
|
||||
fn make_reading(rr: f64, hr: f64) -> VitalReading {
|
||||
VitalReading {
|
||||
respiratory_rate: VitalEstimate {
|
||||
value_bpm: rr,
|
||||
confidence: 0.9,
|
||||
status: VitalStatus::Valid,
|
||||
},
|
||||
heart_rate: VitalEstimate {
|
||||
value_bpm: hr,
|
||||
confidence: 0.85,
|
||||
status: VitalStatus::Valid,
|
||||
},
|
||||
subcarrier_count: 56,
|
||||
signal_quality: 0.9,
|
||||
timestamp_secs: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_store() {
|
||||
let store = VitalSignStore::new(10);
|
||||
assert!(store.is_empty());
|
||||
assert_eq!(store.len(), 0);
|
||||
assert!(store.latest().is_none());
|
||||
assert!(store.stats().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_and_retrieve() {
|
||||
let mut store = VitalSignStore::new(10);
|
||||
store.push(make_reading(15.0, 72.0));
|
||||
assert_eq!(store.len(), 1);
|
||||
assert!(!store.is_empty());
|
||||
|
||||
let latest = store.latest().unwrap();
|
||||
assert!((latest.respiratory_rate.value_bpm - 15.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eviction_at_capacity() {
|
||||
let mut store = VitalSignStore::new(3);
|
||||
store.push(make_reading(10.0, 60.0));
|
||||
store.push(make_reading(15.0, 72.0));
|
||||
store.push(make_reading(20.0, 80.0));
|
||||
assert_eq!(store.len(), 3);
|
||||
|
||||
// Push one more; oldest should be evicted
|
||||
store.push(make_reading(25.0, 90.0));
|
||||
assert_eq!(store.len(), 3);
|
||||
|
||||
// Oldest should now be 15.0, not 10.0
|
||||
let oldest = &store.history(10)[0];
|
||||
assert!((oldest.respiratory_rate.value_bpm - 15.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_returns_last_n() {
|
||||
let mut store = VitalSignStore::new(10);
|
||||
for i in 0..5 {
|
||||
store.push(make_reading(10.0 + i as f64, 60.0 + i as f64));
|
||||
}
|
||||
|
||||
let last3 = store.history(3);
|
||||
assert_eq!(last3.len(), 3);
|
||||
assert!((last3[0].respiratory_rate.value_bpm - 12.0).abs() < f64::EPSILON);
|
||||
assert!((last3[2].respiratory_rate.value_bpm - 14.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_when_fewer_than_n() {
|
||||
let mut store = VitalSignStore::new(10);
|
||||
store.push(make_reading(15.0, 72.0));
|
||||
let all = store.history(100);
|
||||
assert_eq!(all.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stats_computation() {
|
||||
let mut store = VitalSignStore::new(10);
|
||||
store.push(make_reading(10.0, 60.0));
|
||||
store.push(make_reading(20.0, 80.0));
|
||||
store.push(make_reading(15.0, 70.0));
|
||||
|
||||
let stats = store.stats().unwrap();
|
||||
assert_eq!(stats.count, 3);
|
||||
assert!((stats.rr_mean - 15.0).abs() < f64::EPSILON);
|
||||
assert!((stats.hr_mean - 70.0).abs() < f64::EPSILON);
|
||||
assert!((stats.rr_min - 10.0).abs() < f64::EPSILON);
|
||||
assert!((stats.rr_max - 20.0).abs() < f64::EPSILON);
|
||||
assert!((stats.hr_min - 60.0).abs() < f64::EPSILON);
|
||||
assert!((stats.hr_max - 80.0).abs() < f64::EPSILON);
|
||||
assert!((stats.valid_fraction - 1.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stats_valid_fraction() {
|
||||
let mut store = VitalSignStore::new(10);
|
||||
store.push(make_reading(15.0, 72.0)); // Valid
|
||||
store.push(VitalReading {
|
||||
respiratory_rate: VitalEstimate {
|
||||
value_bpm: 15.0,
|
||||
confidence: 0.3,
|
||||
status: VitalStatus::Degraded,
|
||||
},
|
||||
heart_rate: VitalEstimate {
|
||||
value_bpm: 72.0,
|
||||
confidence: 0.8,
|
||||
status: VitalStatus::Valid,
|
||||
},
|
||||
subcarrier_count: 56,
|
||||
signal_quality: 0.5,
|
||||
timestamp_secs: 1.0,
|
||||
});
|
||||
|
||||
let stats = store.stats().unwrap();
|
||||
assert!((stats.valid_fraction - 0.5).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_empties_store() {
|
||||
let mut store = VitalSignStore::new(10);
|
||||
store.push(make_reading(15.0, 72.0));
|
||||
store.push(make_reading(16.0, 73.0));
|
||||
assert_eq!(store.len(), 2);
|
||||
store.clear();
|
||||
assert!(store.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_capacity_is_3600() {
|
||||
let store = VitalSignStore::default_capacity();
|
||||
assert_eq!(store.capacity(), 3600);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
//! Vital sign domain types (ADR-021).
|
||||
|
||||
#[cfg(feature = "serde")]
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Status of a vital sign measurement.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
pub enum VitalStatus {
|
||||
/// Valid measurement with clinical-grade confidence.
|
||||
Valid,
|
||||
/// Measurement present but with reduced confidence.
|
||||
Degraded,
|
||||
/// Measurement unreliable (e.g., single RSSI source).
|
||||
Unreliable,
|
||||
/// No measurement possible.
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
/// A single vital sign estimate.
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
pub struct VitalEstimate {
|
||||
/// Estimated value in BPM (beats/breaths per minute).
|
||||
pub value_bpm: f64,
|
||||
/// Confidence in the estimate [0.0, 1.0].
|
||||
pub confidence: f64,
|
||||
/// Measurement status.
|
||||
pub status: VitalStatus,
|
||||
}
|
||||
|
||||
/// Combined vital sign reading.
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
pub struct VitalReading {
|
||||
/// Respiratory rate estimate.
|
||||
pub respiratory_rate: VitalEstimate,
|
||||
/// Heart rate estimate.
|
||||
pub heart_rate: VitalEstimate,
|
||||
/// Number of subcarriers used.
|
||||
pub subcarrier_count: usize,
|
||||
/// Signal quality score [0.0, 1.0].
|
||||
pub signal_quality: f64,
|
||||
/// Timestamp (seconds since epoch).
|
||||
pub timestamp_secs: f64,
|
||||
}
|
||||
|
||||
/// Input frame for the vital sign pipeline.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CsiFrame {
|
||||
/// Per-subcarrier amplitudes.
|
||||
pub amplitudes: Vec<f64>,
|
||||
/// Per-subcarrier phases (radians).
|
||||
pub phases: Vec<f64>,
|
||||
/// Number of subcarriers.
|
||||
pub n_subcarriers: usize,
|
||||
/// Sample index (monotonically increasing).
|
||||
pub sample_index: u64,
|
||||
/// Sample rate in Hz.
|
||||
pub sample_rate_hz: f64,
|
||||
}
|
||||
|
||||
impl CsiFrame {
|
||||
/// Create a new CSI frame, validating that amplitude and phase
|
||||
/// vectors match the declared subcarrier count.
|
||||
///
|
||||
/// Returns `None` if the lengths are inconsistent.
|
||||
pub fn new(
|
||||
amplitudes: Vec<f64>,
|
||||
phases: Vec<f64>,
|
||||
n_subcarriers: usize,
|
||||
sample_index: u64,
|
||||
sample_rate_hz: f64,
|
||||
) -> Option<Self> {
|
||||
if amplitudes.len() != n_subcarriers || phases.len() != n_subcarriers {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
amplitudes,
|
||||
phases,
|
||||
n_subcarriers,
|
||||
sample_index,
|
||||
sample_rate_hz,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl VitalEstimate {
|
||||
/// Create an unavailable estimate (no measurement possible).
|
||||
pub fn unavailable() -> Self {
|
||||
Self {
|
||||
value_bpm: 0.0,
|
||||
confidence: 0.0,
|
||||
status: VitalStatus::Unavailable,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn vital_status_equality() {
|
||||
assert_eq!(VitalStatus::Valid, VitalStatus::Valid);
|
||||
assert_ne!(VitalStatus::Valid, VitalStatus::Degraded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vital_estimate_unavailable() {
|
||||
let est = VitalEstimate::unavailable();
|
||||
assert_eq!(est.status, VitalStatus::Unavailable);
|
||||
assert!((est.value_bpm - 0.0).abs() < f64::EPSILON);
|
||||
assert!((est.confidence - 0.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csi_frame_new_valid() {
|
||||
let frame = CsiFrame::new(
|
||||
vec![1.0, 2.0, 3.0],
|
||||
vec![0.1, 0.2, 0.3],
|
||||
3,
|
||||
0,
|
||||
100.0,
|
||||
);
|
||||
assert!(frame.is_some());
|
||||
let f = frame.unwrap();
|
||||
assert_eq!(f.n_subcarriers, 3);
|
||||
assert_eq!(f.amplitudes.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csi_frame_new_mismatched_lengths() {
|
||||
let frame = CsiFrame::new(
|
||||
vec![1.0, 2.0],
|
||||
vec![0.1, 0.2, 0.3],
|
||||
3,
|
||||
0,
|
||||
100.0,
|
||||
);
|
||||
assert!(frame.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csi_frame_clone() {
|
||||
let frame = CsiFrame::new(vec![1.0], vec![0.5], 1, 42, 50.0).unwrap();
|
||||
let cloned = frame.clone();
|
||||
assert_eq!(cloned.sample_index, 42);
|
||||
assert_eq!(cloned.n_subcarriers, 1);
|
||||
}
|
||||
|
||||
#[cfg(feature = "serde")]
|
||||
#[test]
|
||||
fn vital_reading_serde_roundtrip() {
|
||||
let reading = VitalReading {
|
||||
respiratory_rate: VitalEstimate {
|
||||
value_bpm: 15.0,
|
||||
confidence: 0.9,
|
||||
status: VitalStatus::Valid,
|
||||
},
|
||||
heart_rate: VitalEstimate {
|
||||
value_bpm: 72.0,
|
||||
confidence: 0.85,
|
||||
status: VitalStatus::Valid,
|
||||
},
|
||||
subcarrier_count: 56,
|
||||
signal_quality: 0.92,
|
||||
timestamp_secs: 1_700_000_000.0,
|
||||
};
|
||||
let json = serde_json::to_string(&reading).unwrap();
|
||||
let parsed: VitalReading = serde_json::from_str(&json).unwrap();
|
||||
assert!((parsed.heart_rate.value_bpm - 72.0).abs() < f64::EPSILON);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user