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:
ruv
2026-02-28 23:50:20 -05:00
parent add9f192aa
commit 3e06970428
37 changed files with 10667 additions and 8 deletions

View File

@@ -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,
);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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};

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}