feat: Implement ADR-014 SOTA signal processing (6 algorithms, 83 tests)
Add six research-grade signal processing algorithms to wifi-densepose-signal: - Conjugate Multiplication: CFO/SFO cancellation via antenna ratio (SpotFi) - Hampel Filter: Robust median/MAD outlier detection (50% contamination resistant) - Fresnel Zone Model: Physics-based breathing detection from chest displacement - CSI Spectrogram: STFT time-frequency generation with 4 window functions - Subcarrier Selection: Variance-ratio ranking for top-K motion-sensitive subcarriers - Body Velocity Profile: Domain-independent Doppler velocity mapping (Widar 3.0) All 313 workspace tests pass, 0 failures. Updated README with new capabilities. https://claude.ai/code/session_01Ki7pvEZtJDvqJkmyn6B714
This commit is contained in:
15
README.md
15
README.md
@@ -64,7 +64,7 @@ A high-performance Rust port is available in `/rust-port/wifi-densepose-rs/`:
|
||||
| Memory Usage | ~500MB | ~100MB |
|
||||
| WASM Support | ❌ | ✅ |
|
||||
| Binary Size | N/A | ~10MB |
|
||||
| Test Coverage | 100% | 107 tests |
|
||||
| Test Coverage | 100% | 313 tests |
|
||||
|
||||
**Quick Start (Rust):**
|
||||
```bash
|
||||
@@ -83,6 +83,19 @@ Mathematical correctness validated:
|
||||
- ✅ Correlation: 1.0 for identical signals
|
||||
- ✅ Phase coherence: 1.0 for coherent signals
|
||||
|
||||
### SOTA Signal Processing (ADR-014)
|
||||
|
||||
Six research-grade algorithms implemented in the `wifi-densepose-signal` crate:
|
||||
|
||||
| Algorithm | Purpose | Reference |
|
||||
|-----------|---------|-----------|
|
||||
| **Conjugate Multiplication** | Cancels CFO/SFO from raw CSI phase via antenna ratio | SpotFi (SIGCOMM 2015) |
|
||||
| **Hampel Filter** | Robust outlier removal using median/MAD (resists 50% contamination) | Hampel (1974) |
|
||||
| **Fresnel Zone Model** | Physics-based breathing detection from chest displacement | FarSense (MobiCom 2019) |
|
||||
| **CSI Spectrogram** | STFT time-frequency matrices for CNN-based activity recognition | Standard since 2018 |
|
||||
| **Subcarrier Selection** | Variance-ratio ranking to pick top-K motion-sensitive subcarriers | WiDance (MobiCom 2017) |
|
||||
| **Body Velocity Profile** | Domain-independent velocity x time representation from Doppler | Widar 3.0 (MobiSys 2019) |
|
||||
|
||||
See [Rust Port Documentation](/rust-port/wifi-densepose-rs/docs/) for ADRs and DDD patterns.
|
||||
|
||||
## 🚨 WiFi-Mat: Disaster Response Module
|
||||
|
||||
160
docs/adr/ADR-014-sota-signal-processing.md
Normal file
160
docs/adr/ADR-014-sota-signal-processing.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# ADR-014: SOTA Signal Processing Algorithms for WiFi Sensing
|
||||
|
||||
## Status
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The existing signal processing pipeline (ADR-002) provides foundational CSI processing:
|
||||
phase unwrapping, FFT-based feature extraction, and variance-based motion detection.
|
||||
However, the academic state-of-the-art in WiFi sensing (2020-2025) has advanced
|
||||
significantly beyond these basics. To achieve research-grade accuracy, we need
|
||||
algorithms grounded in the physics of WiFi signal propagation and human body interaction.
|
||||
|
||||
### Current Gaps vs SOTA
|
||||
|
||||
| Capability | Current | SOTA Reference |
|
||||
|-----------|---------|----------------|
|
||||
| Phase cleaning | Z-score outlier + unwrapping | Conjugate multiplication (SpotFi 2015, IndoTrack 2017) |
|
||||
| Outlier detection | Z-score | Hampel filter (robust median-based) |
|
||||
| Breathing detection | Zero-crossing frequency | Fresnel zone model (FarSense 2019, Wi-Sleep 2021) |
|
||||
| Signal representation | Raw amplitude/phase | CSI spectrogram (time-frequency 2D matrix) |
|
||||
| Subcarrier usage | All subcarriers equally | Sensitivity-based selection (variance ratio) |
|
||||
| Motion profiling | Single motion score | Body Velocity Profile / BVP (Widar 3.0 2019) |
|
||||
|
||||
## Decision
|
||||
|
||||
Implement six SOTA algorithms in the `wifi-densepose-signal` crate as new modules,
|
||||
each with deterministic tests and no mock data.
|
||||
|
||||
### 1. Conjugate Multiplication (CSI Ratio Model)
|
||||
|
||||
**What:** Multiply CSI from antenna pair (i,j) as `H_i * conj(H_j)` to cancel
|
||||
carrier frequency offset (CFO), sampling frequency offset (SFO), and packet
|
||||
detection delay — all of which corrupt raw phase measurements.
|
||||
|
||||
**Why:** Raw CSI phase from commodity hardware (ESP32, Intel 5300) includes
|
||||
random offsets that change per packet. Conjugate multiplication preserves only
|
||||
the phase difference caused by the environment (human motion), not the hardware.
|
||||
|
||||
**Math:** `CSI_ratio[k] = H_1[k] * conj(H_2[k])` where k is subcarrier index.
|
||||
The resulting phase `angle(CSI_ratio[k])` reflects only path differences between
|
||||
the two antenna elements.
|
||||
|
||||
**Reference:** SpotFi (SIGCOMM 2015), IndoTrack (MobiCom 2017)
|
||||
|
||||
### 2. Hampel Filter
|
||||
|
||||
**What:** Replace outliers using running median ± scaled MAD (Median Absolute
|
||||
Deviation), which is robust to the outliers themselves (unlike mean/std Z-score).
|
||||
|
||||
**Why:** WiFi CSI has burst interference, multipath spikes, and hardware glitches
|
||||
that create outliers. Z-score outlier detection uses mean/std, which are themselves
|
||||
corrupted by the outliers (masking effect). Hampel filter uses median/MAD, which
|
||||
resist up to 50% contamination.
|
||||
|
||||
**Math:** For window around sample i: `median = med(x[i-w..i+w])`,
|
||||
`MAD = med(|x[j] - median|)`, `σ_est = 1.4826 * MAD`.
|
||||
If `|x[i] - median| > t * σ_est`, replace x[i] with median.
|
||||
|
||||
**Reference:** Standard DSP technique, used in WiGest (2015), WiDance (2017)
|
||||
|
||||
### 3. Fresnel Zone Breathing Model
|
||||
|
||||
**What:** Model WiFi signal variation as a function of human chest displacement
|
||||
crossing Fresnel zone boundaries. The chest moves ~5-10mm during breathing,
|
||||
which at 5 GHz (λ=60mm) is a significant fraction of the Fresnel zone width.
|
||||
|
||||
**Why:** Zero-crossing counting works for strong signals but fails in multipath-rich
|
||||
environments. The Fresnel model predicts *where* in the signal cycle a breathing
|
||||
motion should appear based on the TX-RX-body geometry, enabling detection even
|
||||
with weak signals.
|
||||
|
||||
**Math:** Fresnel zone radius at point P: `F_n = sqrt(n * λ * d1 * d2 / (d1 + d2))`.
|
||||
Signal variation: `ΔΦ = 2π * 2Δd / λ` where Δd is chest displacement.
|
||||
Expected breathing amplitude: `A = |sin(ΔΦ/2)|`.
|
||||
|
||||
**Reference:** FarSense (MobiCom 2019), Wi-Sleep (UbiComp 2021)
|
||||
|
||||
### 4. CSI Spectrogram
|
||||
|
||||
**What:** Construct a 2D time-frequency matrix by applying sliding-window FFT
|
||||
(STFT) to the temporal CSI amplitude stream per subcarrier. This reveals how
|
||||
the frequency content of body motion changes over time.
|
||||
|
||||
**Why:** Spectrograms are the standard input to CNN-based activity recognition.
|
||||
A breathing person shows a ~0.2-0.4 Hz band, walking shows 1-2 Hz, and
|
||||
stationary environment shows only noise. The 2D structure allows spatial
|
||||
pattern recognition that 1D features miss.
|
||||
|
||||
**Math:** `S[t,f] = |Σ_n x[n] * w[n-t] * exp(-j2πfn)|²`
|
||||
|
||||
**Reference:** Used in virtually all CNN-based WiFi sensing papers since 2018
|
||||
|
||||
### 5. Subcarrier Sensitivity Selection
|
||||
|
||||
**What:** Rank subcarriers by their sensitivity to human motion (variance ratio
|
||||
between motion and static periods) and select only the top-K for further processing.
|
||||
|
||||
**Why:** Not all subcarriers respond equally to body motion. Some are in
|
||||
multipath nulls, some carry mainly noise. Using all subcarriers dilutes the signal.
|
||||
Selecting the 10-20 most sensitive subcarriers improves SNR by 6-10 dB.
|
||||
|
||||
**Math:** `sensitivity[k] = var_motion(amp[k]) / (var_static(amp[k]) + ε)`.
|
||||
Select top-K subcarriers by sensitivity score.
|
||||
|
||||
**Reference:** WiDance (MobiCom 2017), WiGest (SenSys 2015)
|
||||
|
||||
### 6. Body Velocity Profile (BVP)
|
||||
|
||||
**What:** Extract velocity distribution of body parts from Doppler shifts across
|
||||
subcarriers. BVP is a 2D representation (velocity × time) that encodes how
|
||||
different body parts move at different speeds.
|
||||
|
||||
**Why:** BVP is domain-independent — the same velocity profile appears regardless
|
||||
of room layout, furniture, or AP placement. This makes it the basis for
|
||||
cross-environment gesture and activity recognition.
|
||||
|
||||
**Math:** Apply DFT across time for each subcarrier, then aggregate across
|
||||
subcarriers: `BVP[v,t] = Σ_k |STFT_k[v,t]|` where v maps to velocity via
|
||||
`v = f_doppler * λ / 2`.
|
||||
|
||||
**Reference:** Widar 3.0 (MobiSys 2019), WiDar (MobiSys 2017)
|
||||
|
||||
## Implementation
|
||||
|
||||
All algorithms implemented in `wifi-densepose-signal/src/` as new modules:
|
||||
- `csi_ratio.rs` — Conjugate multiplication
|
||||
- `hampel.rs` — Hampel filter
|
||||
- `fresnel.rs` — Fresnel zone breathing model
|
||||
- `spectrogram.rs` — CSI spectrogram generation
|
||||
- `subcarrier_selection.rs` — Sensitivity-based selection
|
||||
- `bvp.rs` — Body Velocity Profile extraction
|
||||
|
||||
Each module has:
|
||||
- Deterministic unit tests with known input/output
|
||||
- No random data, no mocks
|
||||
- Documentation with references to source papers
|
||||
- Integration with existing `CsiData` types
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- Research-grade signal processing matching 2019-2023 publications
|
||||
- Physics-grounded algorithms (Fresnel zones, Doppler) not just heuristics
|
||||
- Cross-environment robustness via BVP and CSI ratio
|
||||
- CNN-ready features via spectrograms
|
||||
- Improved SNR via subcarrier selection
|
||||
|
||||
### Negative
|
||||
- Increased computational cost (STFT, complex multiplication per frame)
|
||||
- Fresnel model requires TX-RX distance estimate (geometry input)
|
||||
- BVP requires sufficient temporal history (>1 second at 100+ Hz sampling)
|
||||
|
||||
## References
|
||||
- SpotFi: Decimeter Level Localization Using WiFi (SIGCOMM 2015)
|
||||
- IndoTrack: Device-Free Indoor Human Tracking (MobiCom 2017)
|
||||
- FarSense: Pushing the Range Limit of WiFi-based Respiration Sensing (MobiCom 2019)
|
||||
- Widar 3.0: Zero-Effort Cross-Domain Gesture Recognition (MobiSys 2019)
|
||||
- Wi-Sleep: Contactless Sleep Staging (UbiComp 2021)
|
||||
- DensePose from WiFi (arXiv 2022, CMU)
|
||||
@@ -0,0 +1,296 @@
|
||||
//! Body Velocity Profile (BVP) extraction.
|
||||
//!
|
||||
//! BVP is a domain-independent 2D representation (velocity × time) that encodes
|
||||
//! how different body parts move at different speeds. Because BVP captures
|
||||
//! velocity distributions rather than raw CSI values, it generalizes across
|
||||
//! environments (different rooms, furniture, AP placement).
|
||||
//!
|
||||
//! # Algorithm
|
||||
//! 1. Apply STFT to each subcarrier's temporal amplitude stream
|
||||
//! 2. Map frequency bins to velocity via v = f_doppler * λ / 2
|
||||
//! 3. Aggregate |STFT| across subcarriers to form BVP
|
||||
//!
|
||||
//! # References
|
||||
//! - Widar 3.0: Zero-Effort Cross-Domain Gesture Recognition (MobiSys 2019)
|
||||
|
||||
use ndarray::Array2;
|
||||
use num_complex::Complex64;
|
||||
use rustfft::FftPlanner;
|
||||
use std::f64::consts::PI;
|
||||
|
||||
/// Configuration for BVP extraction.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BvpConfig {
|
||||
/// STFT window size (samples)
|
||||
pub window_size: usize,
|
||||
/// STFT hop size (samples)
|
||||
pub hop_size: usize,
|
||||
/// Carrier frequency in Hz (for velocity mapping)
|
||||
pub carrier_frequency: f64,
|
||||
/// Number of velocity bins to output
|
||||
pub n_velocity_bins: usize,
|
||||
/// Maximum velocity to resolve (m/s)
|
||||
pub max_velocity: f64,
|
||||
}
|
||||
|
||||
impl Default for BvpConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
window_size: 128,
|
||||
hop_size: 32,
|
||||
carrier_frequency: 5.0e9,
|
||||
n_velocity_bins: 64,
|
||||
max_velocity: 2.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Body Velocity Profile result.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BodyVelocityProfile {
|
||||
/// BVP matrix: (n_velocity_bins × n_time_frames)
|
||||
/// Each column is a velocity distribution at a time instant.
|
||||
pub data: Array2<f64>,
|
||||
/// Velocity values for each row bin (m/s)
|
||||
pub velocity_bins: Vec<f64>,
|
||||
/// Number of time frames
|
||||
pub n_time: usize,
|
||||
/// Time resolution (seconds per frame)
|
||||
pub time_resolution: f64,
|
||||
/// Velocity resolution (m/s per bin)
|
||||
pub velocity_resolution: f64,
|
||||
}
|
||||
|
||||
/// Extract Body Velocity Profile from temporal CSI data.
|
||||
///
|
||||
/// `csi_temporal`: (num_samples × num_subcarriers) amplitude matrix
|
||||
/// `sample_rate`: sampling rate in Hz
|
||||
pub fn extract_bvp(
|
||||
csi_temporal: &Array2<f64>,
|
||||
sample_rate: f64,
|
||||
config: &BvpConfig,
|
||||
) -> Result<BodyVelocityProfile, BvpError> {
|
||||
let (n_samples, n_sc) = csi_temporal.dim();
|
||||
|
||||
if n_samples < config.window_size {
|
||||
return Err(BvpError::InsufficientSamples {
|
||||
needed: config.window_size,
|
||||
got: n_samples,
|
||||
});
|
||||
}
|
||||
if n_sc == 0 {
|
||||
return Err(BvpError::NoSubcarriers);
|
||||
}
|
||||
if config.hop_size == 0 || config.window_size == 0 {
|
||||
return Err(BvpError::InvalidConfig("window_size and hop_size must be > 0".into()));
|
||||
}
|
||||
|
||||
let wavelength = 2.998e8 / config.carrier_frequency;
|
||||
let n_frames = (n_samples - config.window_size) / config.hop_size + 1;
|
||||
let n_fft_bins = config.window_size / 2 + 1;
|
||||
|
||||
// Hann window
|
||||
let window: Vec<f64> = (0..config.window_size)
|
||||
.map(|i| 0.5 * (1.0 - (2.0 * PI * i as f64 / (config.window_size - 1) as f64).cos()))
|
||||
.collect();
|
||||
|
||||
let mut planner = FftPlanner::new();
|
||||
let fft = planner.plan_fft_forward(config.window_size);
|
||||
|
||||
// Compute STFT magnitude for each subcarrier, then aggregate
|
||||
let mut aggregated = Array2::zeros((n_fft_bins, n_frames));
|
||||
|
||||
for sc in 0..n_sc {
|
||||
let col: Vec<f64> = csi_temporal.column(sc).to_vec();
|
||||
|
||||
// Remove DC from this subcarrier
|
||||
let mean: f64 = col.iter().sum::<f64>() / col.len() as f64;
|
||||
|
||||
for frame in 0..n_frames {
|
||||
let start = frame * config.hop_size;
|
||||
|
||||
let mut buffer: Vec<Complex64> = col[start..start + config.window_size]
|
||||
.iter()
|
||||
.zip(window.iter())
|
||||
.map(|(&s, &w)| Complex64::new((s - mean) * w, 0.0))
|
||||
.collect();
|
||||
|
||||
fft.process(&mut buffer);
|
||||
|
||||
// Accumulate magnitude across subcarriers
|
||||
for bin in 0..n_fft_bins {
|
||||
aggregated[[bin, frame]] += buffer[bin].norm();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize by number of subcarriers
|
||||
aggregated /= n_sc as f64;
|
||||
|
||||
// Map FFT bins to velocity bins
|
||||
let freq_resolution = sample_rate / config.window_size as f64;
|
||||
let velocity_resolution = config.max_velocity * 2.0 / config.n_velocity_bins as f64;
|
||||
|
||||
let velocity_bins: Vec<f64> = (0..config.n_velocity_bins)
|
||||
.map(|i| -config.max_velocity + i as f64 * velocity_resolution)
|
||||
.collect();
|
||||
|
||||
// Resample FFT bins to velocity bins using v = f_doppler * λ / 2
|
||||
let mut bvp = Array2::zeros((config.n_velocity_bins, n_frames));
|
||||
|
||||
for (v_idx, &velocity) in velocity_bins.iter().enumerate() {
|
||||
// Convert velocity to Doppler frequency
|
||||
let doppler_freq = 2.0 * velocity / wavelength;
|
||||
// Convert to FFT bin index
|
||||
let fft_bin = (doppler_freq.abs() / freq_resolution).round() as usize;
|
||||
|
||||
if fft_bin < n_fft_bins {
|
||||
for frame in 0..n_frames {
|
||||
bvp[[v_idx, frame]] = aggregated[[fft_bin, frame]];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(BodyVelocityProfile {
|
||||
data: bvp,
|
||||
velocity_bins,
|
||||
n_time: n_frames,
|
||||
time_resolution: config.hop_size as f64 / sample_rate,
|
||||
velocity_resolution,
|
||||
})
|
||||
}
|
||||
|
||||
/// Errors from BVP extraction.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum BvpError {
|
||||
#[error("Insufficient samples: need {needed}, got {got}")]
|
||||
InsufficientSamples { needed: usize, got: usize },
|
||||
|
||||
#[error("No subcarriers in input")]
|
||||
NoSubcarriers,
|
||||
|
||||
#[error("Invalid configuration: {0}")]
|
||||
InvalidConfig(String),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_bvp_dimensions() {
|
||||
let n_samples = 1000;
|
||||
let n_sc = 10;
|
||||
let csi = Array2::from_shape_fn((n_samples, n_sc), |(t, sc)| {
|
||||
let freq = 1.0 + sc as f64 * 0.3;
|
||||
(2.0 * PI * freq * t as f64 / 100.0).sin()
|
||||
});
|
||||
|
||||
let config = BvpConfig {
|
||||
window_size: 128,
|
||||
hop_size: 32,
|
||||
n_velocity_bins: 64,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let bvp = extract_bvp(&csi, 100.0, &config).unwrap();
|
||||
assert_eq!(bvp.data.dim().0, 64); // velocity bins
|
||||
let expected_frames = (1000 - 128) / 32 + 1;
|
||||
assert_eq!(bvp.n_time, expected_frames);
|
||||
assert_eq!(bvp.velocity_bins.len(), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bvp_velocity_range() {
|
||||
let csi = Array2::from_shape_fn((500, 5), |(t, _)| (t as f64 * 0.05).sin());
|
||||
|
||||
let config = BvpConfig {
|
||||
max_velocity: 3.0,
|
||||
n_velocity_bins: 60,
|
||||
window_size: 64,
|
||||
hop_size: 16,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let bvp = extract_bvp(&csi, 100.0, &config).unwrap();
|
||||
|
||||
// Velocity bins should span [-3.0, +3.0)
|
||||
assert!(bvp.velocity_bins[0] < 0.0);
|
||||
assert!(*bvp.velocity_bins.last().unwrap() > 0.0);
|
||||
assert!((bvp.velocity_bins[0] - (-3.0)).abs() < 0.2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_static_scene_low_velocity() {
|
||||
// Constant signal → no Doppler → BVP should peak at velocity=0
|
||||
let csi = Array2::from_elem((500, 10), 1.0);
|
||||
|
||||
let config = BvpConfig {
|
||||
window_size: 64,
|
||||
hop_size: 32,
|
||||
n_velocity_bins: 32,
|
||||
max_velocity: 1.0,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let bvp = extract_bvp(&csi, 100.0, &config).unwrap();
|
||||
|
||||
// After removing DC and applying window, constant signal has
|
||||
// near-zero energy at all Doppler frequencies
|
||||
let total_energy: f64 = bvp.data.iter().sum();
|
||||
// For a constant signal with DC removed, total energy should be very small
|
||||
assert!(
|
||||
total_energy < 1.0,
|
||||
"Static scene should have low Doppler energy, got {}",
|
||||
total_energy
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_moving_body_nonzero_velocity() {
|
||||
// A sinusoidal amplitude modulation simulates motion → Doppler energy
|
||||
let n = 1000;
|
||||
let motion_freq = 5.0; // Hz
|
||||
let csi = Array2::from_shape_fn((n, 8), |(t, _)| {
|
||||
1.0 + 0.5 * (2.0 * PI * motion_freq * t as f64 / 100.0).sin()
|
||||
});
|
||||
|
||||
let config = BvpConfig {
|
||||
window_size: 128,
|
||||
hop_size: 32,
|
||||
n_velocity_bins: 64,
|
||||
max_velocity: 2.0,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let bvp = extract_bvp(&csi, 100.0, &config).unwrap();
|
||||
let total_energy: f64 = bvp.data.iter().sum();
|
||||
assert!(total_energy > 0.0, "Moving body should produce Doppler energy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insufficient_samples() {
|
||||
let csi = Array2::from_elem((10, 5), 1.0);
|
||||
let config = BvpConfig {
|
||||
window_size: 128,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
extract_bvp(&csi, 100.0, &config),
|
||||
Err(BvpError::InsufficientSamples { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_time_resolution() {
|
||||
let csi = Array2::from_elem((500, 5), 1.0);
|
||||
let config = BvpConfig {
|
||||
window_size: 64,
|
||||
hop_size: 32,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let bvp = extract_bvp(&csi, 100.0, &config).unwrap();
|
||||
assert!((bvp.time_resolution - 0.32).abs() < 1e-6); // 32/100
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
//! Conjugate Multiplication (CSI Ratio Model)
|
||||
//!
|
||||
//! Cancels carrier frequency offset (CFO), sampling frequency offset (SFO),
|
||||
//! and packet detection delay by computing `H_i[k] * conj(H_j[k])` across
|
||||
//! antenna pairs. The resulting phase reflects only environmental changes
|
||||
//! (human motion), not hardware artifacts.
|
||||
//!
|
||||
//! # References
|
||||
//! - SpotFi: Decimeter Level Localization Using WiFi (SIGCOMM 2015)
|
||||
//! - IndoTrack: Device-Free Indoor Human Tracking (MobiCom 2017)
|
||||
|
||||
use ndarray::Array2;
|
||||
use num_complex::Complex64;
|
||||
|
||||
/// Compute CSI ratio between two antenna streams.
|
||||
///
|
||||
/// For each subcarrier k: `ratio[k] = H_ref[k] * conj(H_target[k])`
|
||||
///
|
||||
/// This eliminates hardware phase offsets (CFO, SFO, PDD) that are
|
||||
/// common to both antennas, preserving only the path-difference phase
|
||||
/// caused by signal propagation through the environment.
|
||||
pub fn conjugate_multiply(
|
||||
h_ref: &[Complex64],
|
||||
h_target: &[Complex64],
|
||||
) -> Result<Vec<Complex64>, CsiRatioError> {
|
||||
if h_ref.len() != h_target.len() {
|
||||
return Err(CsiRatioError::LengthMismatch {
|
||||
ref_len: h_ref.len(),
|
||||
target_len: h_target.len(),
|
||||
});
|
||||
}
|
||||
if h_ref.is_empty() {
|
||||
return Err(CsiRatioError::EmptyInput);
|
||||
}
|
||||
|
||||
Ok(h_ref
|
||||
.iter()
|
||||
.zip(h_target.iter())
|
||||
.map(|(r, t)| r * t.conj())
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Compute CSI ratio matrix for all antenna pairs from a multi-antenna CSI snapshot.
|
||||
///
|
||||
/// Input: `csi_complex` is (num_antennas × num_subcarriers) complex CSI.
|
||||
/// Output: For each pair (i, j) where j > i, a row of conjugate-multiplied values.
|
||||
/// Returns (num_pairs × num_subcarriers) matrix.
|
||||
pub fn compute_ratio_matrix(csi_complex: &Array2<Complex64>) -> Result<Array2<Complex64>, CsiRatioError> {
|
||||
let (n_ant, n_sc) = csi_complex.dim();
|
||||
if n_ant < 2 {
|
||||
return Err(CsiRatioError::InsufficientAntennas { count: n_ant });
|
||||
}
|
||||
|
||||
let n_pairs = n_ant * (n_ant - 1) / 2;
|
||||
let mut ratio_matrix = Array2::zeros((n_pairs, n_sc));
|
||||
let mut pair_idx = 0;
|
||||
|
||||
for i in 0..n_ant {
|
||||
for j in (i + 1)..n_ant {
|
||||
let ref_row: Vec<Complex64> = csi_complex.row(i).to_vec();
|
||||
let target_row: Vec<Complex64> = csi_complex.row(j).to_vec();
|
||||
let ratio = conjugate_multiply(&ref_row, &target_row)?;
|
||||
for (k, &val) in ratio.iter().enumerate() {
|
||||
ratio_matrix[[pair_idx, k]] = val;
|
||||
}
|
||||
pair_idx += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ratio_matrix)
|
||||
}
|
||||
|
||||
/// Extract sanitized amplitude and phase from a CSI ratio matrix.
|
||||
///
|
||||
/// Returns (amplitude, phase) each as (num_pairs × num_subcarriers).
|
||||
pub fn ratio_to_amplitude_phase(ratio: &Array2<Complex64>) -> (Array2<f64>, Array2<f64>) {
|
||||
let (nrows, ncols) = ratio.dim();
|
||||
let mut amplitude = Array2::zeros((nrows, ncols));
|
||||
let mut phase = Array2::zeros((nrows, ncols));
|
||||
|
||||
for ((i, j), val) in ratio.indexed_iter() {
|
||||
amplitude[[i, j]] = val.norm();
|
||||
phase[[i, j]] = val.arg();
|
||||
}
|
||||
|
||||
(amplitude, phase)
|
||||
}
|
||||
|
||||
/// Errors from CSI ratio computation
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum CsiRatioError {
|
||||
#[error("Antenna stream length mismatch: ref={ref_len}, target={target_len}")]
|
||||
LengthMismatch { ref_len: usize, target_len: usize },
|
||||
|
||||
#[error("Empty input")]
|
||||
EmptyInput,
|
||||
|
||||
#[error("Need at least 2 antennas, got {count}")]
|
||||
InsufficientAntennas { count: usize },
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::f64::consts::PI;
|
||||
|
||||
#[test]
|
||||
fn test_conjugate_multiply_cancels_common_phase() {
|
||||
// Both antennas see the same CFO phase offset θ.
|
||||
// H_1[k] = A1 * exp(j*(φ1 + θ)), H_2[k] = A2 * exp(j*(φ2 + θ))
|
||||
// ratio = H_1 * conj(H_2) = A1*A2 * exp(j*(φ1 - φ2))
|
||||
// The common offset θ is cancelled.
|
||||
let cfo_offset = 1.7; // arbitrary CFO phase
|
||||
let phi1 = 0.3;
|
||||
let phi2 = 0.8;
|
||||
|
||||
let h1 = vec![Complex64::from_polar(2.0, phi1 + cfo_offset)];
|
||||
let h2 = vec![Complex64::from_polar(3.0, phi2 + cfo_offset)];
|
||||
|
||||
let ratio = conjugate_multiply(&h1, &h2).unwrap();
|
||||
let result_phase = ratio[0].arg();
|
||||
let result_amp = ratio[0].norm();
|
||||
|
||||
// Phase should be φ1 - φ2, CFO cancelled
|
||||
assert!((result_phase - (phi1 - phi2)).abs() < 1e-10);
|
||||
// Amplitude should be A1 * A2
|
||||
assert!((result_amp - 6.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ratio_matrix_pair_count() {
|
||||
// 3 antennas → 3 pairs, 4 antennas → 6 pairs
|
||||
let csi = Array2::from_shape_fn((3, 10), |(i, j)| {
|
||||
Complex64::from_polar(1.0, (i * 10 + j) as f64 * 0.1)
|
||||
});
|
||||
|
||||
let ratio = compute_ratio_matrix(&csi).unwrap();
|
||||
assert_eq!(ratio.dim(), (3, 10)); // C(3,2) = 3 pairs
|
||||
|
||||
let csi4 = Array2::from_shape_fn((4, 8), |(i, j)| {
|
||||
Complex64::from_polar(1.0, (i * 8 + j) as f64 * 0.1)
|
||||
});
|
||||
let ratio4 = compute_ratio_matrix(&csi4).unwrap();
|
||||
assert_eq!(ratio4.dim(), (6, 8)); // C(4,2) = 6 pairs
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ratio_preserves_path_difference() {
|
||||
// Two antennas separated by d, signal from angle θ
|
||||
// Phase difference = 2π * d * sin(θ) / λ
|
||||
let wavelength = 0.06; // 5 GHz
|
||||
let antenna_spacing = 0.025; // 2.5 cm
|
||||
let arrival_angle = PI / 6.0; // 30 degrees
|
||||
|
||||
let path_diff_phase = 2.0 * PI * antenna_spacing * arrival_angle.sin() / wavelength;
|
||||
let cfo = 2.5; // large CFO
|
||||
|
||||
let n_sc = 56;
|
||||
let csi = Array2::from_shape_fn((2, n_sc), |(ant, k)| {
|
||||
let sc_phase = k as f64 * 0.05; // subcarrier-dependent phase
|
||||
let ant_phase = if ant == 0 { 0.0 } else { path_diff_phase };
|
||||
Complex64::from_polar(1.0, sc_phase + ant_phase + cfo)
|
||||
});
|
||||
|
||||
let ratio = compute_ratio_matrix(&csi).unwrap();
|
||||
let (_, phase) = ratio_to_amplitude_phase(&ratio);
|
||||
|
||||
// All subcarriers should show the same path-difference phase
|
||||
for j in 0..n_sc {
|
||||
assert!(
|
||||
(phase[[0, j]] - (-path_diff_phase)).abs() < 1e-10,
|
||||
"Subcarrier {} phase={}, expected={}",
|
||||
j, phase[[0, j]], -path_diff_phase
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_antenna_error() {
|
||||
let csi = Array2::from_shape_fn((1, 10), |(_, j)| {
|
||||
Complex64::new(j as f64, 0.0)
|
||||
});
|
||||
assert!(matches!(
|
||||
compute_ratio_matrix(&csi),
|
||||
Err(CsiRatioError::InsufficientAntennas { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_length_mismatch() {
|
||||
let h1 = vec![Complex64::new(1.0, 0.0); 10];
|
||||
let h2 = vec![Complex64::new(1.0, 0.0); 5];
|
||||
assert!(matches!(
|
||||
conjugate_multiply(&h1, &h2),
|
||||
Err(CsiRatioError::LengthMismatch { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
//! Fresnel Zone Breathing Model
|
||||
//!
|
||||
//! Models WiFi signal variation as a function of human chest displacement
|
||||
//! crossing Fresnel zone boundaries. At 5 GHz (λ=60mm), chest displacement
|
||||
//! of 5-10mm during breathing is a significant fraction of the Fresnel zone
|
||||
//! width, producing measurable phase and amplitude changes.
|
||||
//!
|
||||
//! # References
|
||||
//! - FarSense: Pushing the Range Limit (MobiCom 2019)
|
||||
//! - Wi-Sleep: Contactless Sleep Staging (UbiComp 2021)
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
/// Physical constants and defaults for WiFi sensing.
|
||||
pub const SPEED_OF_LIGHT: f64 = 2.998e8; // m/s
|
||||
|
||||
/// Fresnel zone geometry for a TX-RX-body configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FresnelGeometry {
|
||||
/// Distance from TX to body reflection point (meters)
|
||||
pub d_tx_body: f64,
|
||||
/// Distance from body reflection point to RX (meters)
|
||||
pub d_body_rx: f64,
|
||||
/// Carrier frequency in Hz (e.g., 5.8e9 for 5.8 GHz)
|
||||
pub frequency: f64,
|
||||
}
|
||||
|
||||
impl FresnelGeometry {
|
||||
/// Create geometry for a given TX-body-RX configuration.
|
||||
pub fn new(d_tx_body: f64, d_body_rx: f64, frequency: f64) -> Result<Self, FresnelError> {
|
||||
if d_tx_body <= 0.0 || d_body_rx <= 0.0 {
|
||||
return Err(FresnelError::InvalidDistance);
|
||||
}
|
||||
if frequency <= 0.0 {
|
||||
return Err(FresnelError::InvalidFrequency);
|
||||
}
|
||||
Ok(Self {
|
||||
d_tx_body,
|
||||
d_body_rx,
|
||||
frequency,
|
||||
})
|
||||
}
|
||||
|
||||
/// Wavelength in meters.
|
||||
pub fn wavelength(&self) -> f64 {
|
||||
SPEED_OF_LIGHT / self.frequency
|
||||
}
|
||||
|
||||
/// Radius of the nth Fresnel zone at the body point.
|
||||
///
|
||||
/// F_n = sqrt(n * λ * d1 * d2 / (d1 + d2))
|
||||
pub fn fresnel_radius(&self, n: u32) -> f64 {
|
||||
let lambda = self.wavelength();
|
||||
let d1 = self.d_tx_body;
|
||||
let d2 = self.d_body_rx;
|
||||
(n as f64 * lambda * d1 * d2 / (d1 + d2)).sqrt()
|
||||
}
|
||||
|
||||
/// Phase change caused by a small body displacement Δd (meters).
|
||||
///
|
||||
/// The reflected path changes by 2*Δd (there and back), producing
|
||||
/// phase change: ΔΦ = 2π * 2Δd / λ
|
||||
pub fn phase_change(&self, displacement_m: f64) -> f64 {
|
||||
2.0 * PI * 2.0 * displacement_m / self.wavelength()
|
||||
}
|
||||
|
||||
/// Expected amplitude variation from chest displacement.
|
||||
///
|
||||
/// The signal amplitude varies as |sin(ΔΦ/2)| when the reflection
|
||||
/// point crosses Fresnel zone boundaries.
|
||||
pub fn expected_amplitude_variation(&self, displacement_m: f64) -> f64 {
|
||||
let delta_phi = self.phase_change(displacement_m);
|
||||
(delta_phi / 2.0).sin().abs()
|
||||
}
|
||||
}
|
||||
|
||||
/// Breathing rate estimation using Fresnel zone model.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FresnelBreathingEstimator {
|
||||
geometry: FresnelGeometry,
|
||||
/// Expected chest displacement range (meters) for breathing
|
||||
min_displacement: f64,
|
||||
max_displacement: f64,
|
||||
}
|
||||
|
||||
impl FresnelBreathingEstimator {
|
||||
/// Create estimator with geometry and chest displacement bounds.
|
||||
///
|
||||
/// Typical adult chest displacement: 4-12mm (0.004-0.012 m)
|
||||
pub fn new(geometry: FresnelGeometry) -> Self {
|
||||
Self {
|
||||
geometry,
|
||||
min_displacement: 0.003,
|
||||
max_displacement: 0.015,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if observed amplitude variation is consistent with breathing.
|
||||
///
|
||||
/// Returns confidence (0.0-1.0) based on whether the observed signal
|
||||
/// variation matches the expected Fresnel model prediction for chest
|
||||
/// displacements in the breathing range.
|
||||
pub fn breathing_confidence(&self, observed_amplitude_variation: f64) -> f64 {
|
||||
let min_expected = self.geometry.expected_amplitude_variation(self.min_displacement);
|
||||
let max_expected = self.geometry.expected_amplitude_variation(self.max_displacement);
|
||||
|
||||
let (low, high) = if min_expected < max_expected {
|
||||
(min_expected, max_expected)
|
||||
} else {
|
||||
(max_expected, min_expected)
|
||||
};
|
||||
|
||||
if observed_amplitude_variation >= low && observed_amplitude_variation <= high {
|
||||
// Within expected range: high confidence
|
||||
1.0
|
||||
} else if observed_amplitude_variation < low {
|
||||
// Below range: scale linearly
|
||||
(observed_amplitude_variation / low).clamp(0.0, 1.0)
|
||||
} else {
|
||||
// Above range: could be larger motion (walking), lower confidence for breathing
|
||||
(high / observed_amplitude_variation).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Estimate breathing rate from temporal amplitude signal using the Fresnel model.
|
||||
///
|
||||
/// Uses autocorrelation to find periodicity, then validates against
|
||||
/// expected Fresnel amplitude range. Returns (rate_bpm, confidence).
|
||||
pub fn estimate_breathing_rate(
|
||||
&self,
|
||||
amplitude_signal: &[f64],
|
||||
sample_rate: f64,
|
||||
) -> Result<BreathingEstimate, FresnelError> {
|
||||
if amplitude_signal.len() < 10 {
|
||||
return Err(FresnelError::InsufficientData {
|
||||
needed: 10,
|
||||
got: amplitude_signal.len(),
|
||||
});
|
||||
}
|
||||
if sample_rate <= 0.0 {
|
||||
return Err(FresnelError::InvalidFrequency);
|
||||
}
|
||||
|
||||
// Remove DC (mean)
|
||||
let mean: f64 = amplitude_signal.iter().sum::<f64>() / amplitude_signal.len() as f64;
|
||||
let centered: Vec<f64> = amplitude_signal.iter().map(|x| x - mean).collect();
|
||||
|
||||
// Autocorrelation to find periodicity
|
||||
let n = centered.len();
|
||||
let max_lag = (sample_rate * 10.0) as usize; // Up to 10 seconds (6 BPM)
|
||||
let min_lag = (sample_rate * 1.5) as usize; // At least 1.5 seconds (40 BPM)
|
||||
let max_lag = max_lag.min(n / 2);
|
||||
|
||||
if min_lag >= max_lag {
|
||||
return Err(FresnelError::InsufficientData {
|
||||
needed: (min_lag * 2 + 1),
|
||||
got: n,
|
||||
});
|
||||
}
|
||||
|
||||
// Compute autocorrelation for breathing-range lags
|
||||
let mut best_lag = min_lag;
|
||||
let mut best_corr = f64::NEG_INFINITY;
|
||||
let norm: f64 = centered.iter().map(|x| x * x).sum();
|
||||
|
||||
if norm < 1e-15 {
|
||||
return Err(FresnelError::NoSignal);
|
||||
}
|
||||
|
||||
for lag in min_lag..max_lag {
|
||||
let mut corr = 0.0;
|
||||
for i in 0..(n - lag) {
|
||||
corr += centered[i] * centered[i + lag];
|
||||
}
|
||||
corr /= norm;
|
||||
|
||||
if corr > best_corr {
|
||||
best_corr = corr;
|
||||
best_lag = lag;
|
||||
}
|
||||
}
|
||||
|
||||
let period_seconds = best_lag as f64 / sample_rate;
|
||||
let rate_bpm = 60.0 / period_seconds;
|
||||
|
||||
// Compute amplitude variation for Fresnel confidence
|
||||
let amp_var = amplitude_variation(¢ered);
|
||||
let fresnel_conf = self.breathing_confidence(amp_var);
|
||||
|
||||
// Autocorrelation quality (>0.3 is good periodicity)
|
||||
let autocorr_conf = best_corr.max(0.0).min(1.0);
|
||||
|
||||
let confidence = fresnel_conf * 0.4 + autocorr_conf * 0.6;
|
||||
|
||||
Ok(BreathingEstimate {
|
||||
rate_bpm,
|
||||
confidence,
|
||||
period_seconds,
|
||||
autocorrelation_peak: best_corr,
|
||||
fresnel_confidence: fresnel_conf,
|
||||
amplitude_variation: amp_var,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of breathing rate estimation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BreathingEstimate {
|
||||
/// Estimated breathing rate in breaths per minute
|
||||
pub rate_bpm: f64,
|
||||
/// Combined confidence (0.0-1.0)
|
||||
pub confidence: f64,
|
||||
/// Estimated breathing period in seconds
|
||||
pub period_seconds: f64,
|
||||
/// Peak autocorrelation value at detected period
|
||||
pub autocorrelation_peak: f64,
|
||||
/// Confidence from Fresnel model match
|
||||
pub fresnel_confidence: f64,
|
||||
/// Observed amplitude variation
|
||||
pub amplitude_variation: f64,
|
||||
}
|
||||
|
||||
/// Compute peak-to-peak amplitude variation (normalized).
|
||||
fn amplitude_variation(signal: &[f64]) -> f64 {
|
||||
if signal.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let max = signal.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
|
||||
let min = signal.iter().cloned().fold(f64::INFINITY, f64::min);
|
||||
max - min
|
||||
}
|
||||
|
||||
/// Errors from Fresnel computations.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum FresnelError {
|
||||
#[error("Distance must be positive")]
|
||||
InvalidDistance,
|
||||
|
||||
#[error("Frequency must be positive")]
|
||||
InvalidFrequency,
|
||||
|
||||
#[error("Insufficient data: need {needed}, got {got}")]
|
||||
InsufficientData { needed: usize, got: usize },
|
||||
|
||||
#[error("No signal detected (zero variance)")]
|
||||
NoSignal,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_geometry() -> FresnelGeometry {
|
||||
// TX 3m from body, body 2m from RX, 5 GHz WiFi
|
||||
FresnelGeometry::new(3.0, 2.0, 5.0e9).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wavelength() {
|
||||
let g = test_geometry();
|
||||
let lambda = g.wavelength();
|
||||
assert!((lambda - 0.06).abs() < 0.001); // 5 GHz → 60mm
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fresnel_radius() {
|
||||
let g = test_geometry();
|
||||
let f1 = g.fresnel_radius(1);
|
||||
// F1 = sqrt(λ * d1 * d2 / (d1 + d2))
|
||||
let lambda = g.wavelength(); // actual: 2.998e8 / 5e9 = 0.05996
|
||||
let expected = (lambda * 3.0 * 2.0 / 5.0_f64).sqrt();
|
||||
assert!((f1 - expected).abs() < 1e-6);
|
||||
assert!(f1 > 0.1 && f1 < 0.5); // Reasonable range
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_phase_change_from_displacement() {
|
||||
let g = test_geometry();
|
||||
// 5mm chest displacement at 5 GHz
|
||||
let delta_phi = g.phase_change(0.005);
|
||||
// ΔΦ = 2π * 2 * 0.005 / λ
|
||||
let lambda = g.wavelength();
|
||||
let expected = 2.0 * PI * 2.0 * 0.005 / lambda;
|
||||
assert!((delta_phi - expected).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_amplitude_variation_breathing_range() {
|
||||
let g = test_geometry();
|
||||
// 5mm displacement should produce detectable variation
|
||||
let var_5mm = g.expected_amplitude_variation(0.005);
|
||||
assert!(var_5mm > 0.01, "5mm should produce measurable variation");
|
||||
|
||||
// 10mm should produce more variation
|
||||
let var_10mm = g.expected_amplitude_variation(0.010);
|
||||
assert!(var_10mm > var_5mm || (var_10mm - var_5mm).abs() < 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_breathing_confidence() {
|
||||
let g = test_geometry();
|
||||
let estimator = FresnelBreathingEstimator::new(g.clone());
|
||||
|
||||
// Signal matching expected breathing range → high confidence
|
||||
let expected_var = g.expected_amplitude_variation(0.007);
|
||||
let conf = estimator.breathing_confidence(expected_var);
|
||||
assert!(conf > 0.5, "Expected breathing variation should give high confidence");
|
||||
|
||||
// Zero variation → low confidence
|
||||
let conf_zero = estimator.breathing_confidence(0.0);
|
||||
assert!(conf_zero < 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_breathing_rate_estimation() {
|
||||
let g = test_geometry();
|
||||
let estimator = FresnelBreathingEstimator::new(g);
|
||||
|
||||
// Generate 30 seconds of breathing signal at 16 BPM (0.267 Hz)
|
||||
let sample_rate = 100.0; // Hz
|
||||
let duration = 30.0;
|
||||
let n = (sample_rate * duration) as usize;
|
||||
let breathing_freq = 0.267; // 16 BPM
|
||||
|
||||
let signal: Vec<f64> = (0..n)
|
||||
.map(|i| {
|
||||
let t = i as f64 / sample_rate;
|
||||
0.5 + 0.1 * (2.0 * PI * breathing_freq * t).sin()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let result = estimator
|
||||
.estimate_breathing_rate(&signal, sample_rate)
|
||||
.unwrap();
|
||||
|
||||
// Should detect ~16 BPM (within 2 BPM tolerance)
|
||||
assert!(
|
||||
(result.rate_bpm - 16.0).abs() < 2.0,
|
||||
"Expected ~16 BPM, got {:.1}",
|
||||
result.rate_bpm
|
||||
);
|
||||
assert!(result.confidence > 0.3);
|
||||
assert!(result.autocorrelation_peak > 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_geometry() {
|
||||
assert!(FresnelGeometry::new(-1.0, 2.0, 5e9).is_err());
|
||||
assert!(FresnelGeometry::new(1.0, 0.0, 5e9).is_err());
|
||||
assert!(FresnelGeometry::new(1.0, 2.0, 0.0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insufficient_data() {
|
||||
let g = test_geometry();
|
||||
let estimator = FresnelBreathingEstimator::new(g);
|
||||
let short_signal = vec![1.0; 5];
|
||||
assert!(matches!(
|
||||
estimator.estimate_breathing_rate(&short_signal, 100.0),
|
||||
Err(FresnelError::InsufficientData { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
//! Hampel Filter for robust outlier detection and removal.
|
||||
//!
|
||||
//! Uses running median and MAD (Median Absolute Deviation) instead of
|
||||
//! mean/std, making it resistant to up to 50% contamination — unlike
|
||||
//! Z-score methods where outliers corrupt the mean and mask themselves.
|
||||
//!
|
||||
//! # References
|
||||
//! - Hampel (1974), "The Influence Curve and its Role in Robust Estimation"
|
||||
//! - Used in WiGest (SenSys 2015), WiDance (MobiCom 2017)
|
||||
|
||||
/// Configuration for the Hampel filter.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HampelConfig {
|
||||
/// Half-window size (total window = 2*half_window + 1)
|
||||
pub half_window: usize,
|
||||
/// Threshold in units of estimated σ (typically 3.0)
|
||||
pub threshold: f64,
|
||||
}
|
||||
|
||||
impl Default for HampelConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
half_window: 3,
|
||||
threshold: 3.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of Hampel filtering.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HampelResult {
|
||||
/// Filtered signal (outliers replaced with local median)
|
||||
pub filtered: Vec<f64>,
|
||||
/// Indices where outliers were detected
|
||||
pub outlier_indices: Vec<usize>,
|
||||
/// Local median values at each sample
|
||||
pub medians: Vec<f64>,
|
||||
/// Estimated local σ at each sample
|
||||
pub sigma_estimates: Vec<f64>,
|
||||
}
|
||||
|
||||
/// Scale factor converting MAD to σ for Gaussian distributions.
|
||||
/// MAD = 0.6745 * σ → σ = MAD / 0.6745 = 1.4826 * MAD
|
||||
const MAD_SCALE: f64 = 1.4826;
|
||||
|
||||
/// Apply Hampel filter to a 1D signal.
|
||||
///
|
||||
/// For each sample, computes the median and MAD of the surrounding window.
|
||||
/// If the sample deviates from the median by more than `threshold * σ_est`,
|
||||
/// it is replaced with the median.
|
||||
pub fn hampel_filter(signal: &[f64], config: &HampelConfig) -> Result<HampelResult, HampelError> {
|
||||
if signal.is_empty() {
|
||||
return Err(HampelError::EmptySignal);
|
||||
}
|
||||
if config.half_window == 0 {
|
||||
return Err(HampelError::InvalidWindow);
|
||||
}
|
||||
|
||||
let n = signal.len();
|
||||
let mut filtered = signal.to_vec();
|
||||
let mut outlier_indices = Vec::new();
|
||||
let mut medians = Vec::with_capacity(n);
|
||||
let mut sigma_estimates = Vec::with_capacity(n);
|
||||
|
||||
for i in 0..n {
|
||||
let start = i.saturating_sub(config.half_window);
|
||||
let end = (i + config.half_window + 1).min(n);
|
||||
let window: Vec<f64> = signal[start..end].to_vec();
|
||||
|
||||
let med = median(&window);
|
||||
let mad = median_absolute_deviation(&window, med);
|
||||
let sigma = MAD_SCALE * mad;
|
||||
|
||||
medians.push(med);
|
||||
sigma_estimates.push(sigma);
|
||||
|
||||
let deviation = (signal[i] - med).abs();
|
||||
let is_outlier = if sigma > 1e-15 {
|
||||
// Normal case: compare deviation to threshold * sigma
|
||||
deviation > config.threshold * sigma
|
||||
} else {
|
||||
// Zero-MAD case: all window values identical except possibly this sample.
|
||||
// Any non-zero deviation from the median is an outlier.
|
||||
deviation > 1e-15
|
||||
};
|
||||
|
||||
if is_outlier {
|
||||
filtered[i] = med;
|
||||
outlier_indices.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(HampelResult {
|
||||
filtered,
|
||||
outlier_indices,
|
||||
medians,
|
||||
sigma_estimates,
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply Hampel filter to each row of a 2D array (e.g., per-antenna CSI).
|
||||
pub fn hampel_filter_2d(
|
||||
data: &[Vec<f64>],
|
||||
config: &HampelConfig,
|
||||
) -> Result<Vec<HampelResult>, HampelError> {
|
||||
data.iter().map(|row| hampel_filter(row, config)).collect()
|
||||
}
|
||||
|
||||
/// Compute median of a slice (sorts a copy).
|
||||
fn median(data: &[f64]) -> f64 {
|
||||
if data.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let mut sorted = data.to_vec();
|
||||
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let mid = sorted.len() / 2;
|
||||
if sorted.len() % 2 == 0 {
|
||||
(sorted[mid - 1] + sorted[mid]) / 2.0
|
||||
} else {
|
||||
sorted[mid]
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute MAD (Median Absolute Deviation) given precomputed median.
|
||||
fn median_absolute_deviation(data: &[f64], med: f64) -> f64 {
|
||||
let deviations: Vec<f64> = data.iter().map(|x| (x - med).abs()).collect();
|
||||
median(&deviations)
|
||||
}
|
||||
|
||||
/// Errors from Hampel filtering.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum HampelError {
|
||||
#[error("Signal is empty")]
|
||||
EmptySignal,
|
||||
#[error("Half-window must be > 0")]
|
||||
InvalidWindow,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_clean_signal_unchanged() {
|
||||
// A smooth sinusoid should have zero outliers
|
||||
let signal: Vec<f64> = (0..100)
|
||||
.map(|i| (i as f64 * 0.1).sin())
|
||||
.collect();
|
||||
|
||||
let result = hampel_filter(&signal, &HampelConfig::default()).unwrap();
|
||||
assert!(result.outlier_indices.is_empty());
|
||||
|
||||
for i in 0..signal.len() {
|
||||
assert!(
|
||||
(result.filtered[i] - signal[i]).abs() < 1e-10,
|
||||
"Clean signal modified at index {}",
|
||||
i
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_spike_detected() {
|
||||
let mut signal: Vec<f64> = vec![1.0; 50];
|
||||
signal[25] = 100.0; // Huge spike
|
||||
|
||||
let result = hampel_filter(&signal, &HampelConfig::default()).unwrap();
|
||||
assert!(result.outlier_indices.contains(&25));
|
||||
assert!((result.filtered[25] - 1.0).abs() < 1e-10); // Replaced with median
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_spikes() {
|
||||
let mut signal: Vec<f64> = (0..200)
|
||||
.map(|i| (i as f64 * 0.05).sin())
|
||||
.collect();
|
||||
|
||||
// Insert spikes
|
||||
signal[30] = 50.0;
|
||||
signal[100] = -50.0;
|
||||
signal[170] = 80.0;
|
||||
|
||||
let config = HampelConfig {
|
||||
half_window: 5,
|
||||
threshold: 3.0,
|
||||
};
|
||||
let result = hampel_filter(&signal, &config).unwrap();
|
||||
|
||||
assert!(result.outlier_indices.contains(&30));
|
||||
assert!(result.outlier_indices.contains(&100));
|
||||
assert!(result.outlier_indices.contains(&170));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_z_score_masking_resistance() {
|
||||
// 50 clean samples + many outliers: Z-score would fail, Hampel should work
|
||||
let mut signal: Vec<f64> = vec![0.0; 100];
|
||||
// Insert 30% contamination (Z-score would be confused)
|
||||
for i in (0..100).step_by(3) {
|
||||
signal[i] = 50.0;
|
||||
}
|
||||
|
||||
let config = HampelConfig {
|
||||
half_window: 5,
|
||||
threshold: 3.0,
|
||||
};
|
||||
let result = hampel_filter(&signal, &config).unwrap();
|
||||
|
||||
// The contaminated samples should be detected as outliers
|
||||
assert!(!result.outlier_indices.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_2d_filtering() {
|
||||
let rows = vec![
|
||||
vec![1.0, 1.0, 100.0, 1.0, 1.0, 1.0, 1.0],
|
||||
vec![2.0, 2.0, 2.0, 2.0, -80.0, 2.0, 2.0],
|
||||
];
|
||||
|
||||
let results = hampel_filter_2d(&rows, &HampelConfig::default()).unwrap();
|
||||
assert_eq!(results.len(), 2);
|
||||
assert!(results[0].outlier_indices.contains(&2));
|
||||
assert!(results[1].outlier_indices.contains(&4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_median_computation() {
|
||||
assert!((median(&[1.0, 3.0, 2.0]) - 2.0).abs() < 1e-10);
|
||||
assert!((median(&[1.0, 2.0, 3.0, 4.0]) - 2.5).abs() < 1e-10);
|
||||
assert!((median(&[5.0]) - 5.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_signal_error() {
|
||||
assert!(matches!(
|
||||
hampel_filter(&[], &HampelConfig::default()),
|
||||
Err(HampelError::EmptySignal)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -31,10 +31,16 @@
|
||||
//! let processor = CsiProcessor::new(config);
|
||||
//! ```
|
||||
|
||||
pub mod bvp;
|
||||
pub mod csi_processor;
|
||||
pub mod csi_ratio;
|
||||
pub mod features;
|
||||
pub mod fresnel;
|
||||
pub mod hampel;
|
||||
pub mod motion;
|
||||
pub mod phase_sanitizer;
|
||||
pub mod spectrogram;
|
||||
pub mod subcarrier_selection;
|
||||
|
||||
// Re-export main types for convenience
|
||||
pub use csi_processor::{
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
//! CSI Spectrogram Generation
|
||||
//!
|
||||
//! Constructs 2D time-frequency matrices via Short-Time Fourier Transform (STFT)
|
||||
//! applied to temporal CSI amplitude streams. The resulting spectrograms are the
|
||||
//! standard input format for CNN-based WiFi activity recognition.
|
||||
//!
|
||||
//! # References
|
||||
//! - Used in virtually all CNN-based WiFi sensing papers since 2018
|
||||
|
||||
use ndarray::Array2;
|
||||
use num_complex::Complex64;
|
||||
use rustfft::FftPlanner;
|
||||
use std::f64::consts::PI;
|
||||
|
||||
/// Configuration for spectrogram generation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SpectrogramConfig {
|
||||
/// FFT window size (number of samples per frame)
|
||||
pub window_size: usize,
|
||||
/// Hop size (step between consecutive frames). Smaller = more overlap.
|
||||
pub hop_size: usize,
|
||||
/// Window function to apply
|
||||
pub window_fn: WindowFunction,
|
||||
/// Whether to compute power (magnitude squared) or magnitude
|
||||
pub power: bool,
|
||||
}
|
||||
|
||||
impl Default for SpectrogramConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
window_size: 256,
|
||||
hop_size: 64,
|
||||
window_fn: WindowFunction::Hann,
|
||||
power: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Window function types.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WindowFunction {
|
||||
/// Rectangular (no windowing)
|
||||
Rectangular,
|
||||
/// Hann window (cosine-squared taper)
|
||||
Hann,
|
||||
/// Hamming window
|
||||
Hamming,
|
||||
/// Blackman window (lower sidelobe level)
|
||||
Blackman,
|
||||
}
|
||||
|
||||
/// Result of spectrogram computation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Spectrogram {
|
||||
/// Power/magnitude values: rows = frequency bins, columns = time frames.
|
||||
/// Only positive frequencies (0 to Nyquist), so rows = window_size/2 + 1.
|
||||
pub data: Array2<f64>,
|
||||
/// Number of frequency bins
|
||||
pub n_freq: usize,
|
||||
/// Number of time frames
|
||||
pub n_time: usize,
|
||||
/// Frequency resolution (Hz per bin)
|
||||
pub freq_resolution: f64,
|
||||
/// Time resolution (seconds per frame)
|
||||
pub time_resolution: f64,
|
||||
}
|
||||
|
||||
/// Compute spectrogram of a 1D signal.
|
||||
///
|
||||
/// Returns a time-frequency matrix suitable as CNN input.
|
||||
pub fn compute_spectrogram(
|
||||
signal: &[f64],
|
||||
sample_rate: f64,
|
||||
config: &SpectrogramConfig,
|
||||
) -> Result<Spectrogram, SpectrogramError> {
|
||||
if signal.len() < config.window_size {
|
||||
return Err(SpectrogramError::SignalTooShort {
|
||||
signal_len: signal.len(),
|
||||
window_size: config.window_size,
|
||||
});
|
||||
}
|
||||
if config.hop_size == 0 {
|
||||
return Err(SpectrogramError::InvalidHopSize);
|
||||
}
|
||||
if config.window_size == 0 {
|
||||
return Err(SpectrogramError::InvalidWindowSize);
|
||||
}
|
||||
|
||||
let n_frames = (signal.len() - config.window_size) / config.hop_size + 1;
|
||||
let n_freq = config.window_size / 2 + 1;
|
||||
let window = make_window(config.window_fn, config.window_size);
|
||||
|
||||
let mut planner = FftPlanner::new();
|
||||
let fft = planner.plan_fft_forward(config.window_size);
|
||||
|
||||
let mut data = Array2::zeros((n_freq, n_frames));
|
||||
|
||||
for frame in 0..n_frames {
|
||||
let start = frame * config.hop_size;
|
||||
let end = start + config.window_size;
|
||||
|
||||
// Apply window and convert to complex
|
||||
let mut buffer: Vec<Complex64> = signal[start..end]
|
||||
.iter()
|
||||
.zip(window.iter())
|
||||
.map(|(&s, &w)| Complex64::new(s * w, 0.0))
|
||||
.collect();
|
||||
|
||||
fft.process(&mut buffer);
|
||||
|
||||
// Store positive frequencies
|
||||
for bin in 0..n_freq {
|
||||
let mag = buffer[bin].norm();
|
||||
data[[bin, frame]] = if config.power { mag * mag } else { mag };
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Spectrogram {
|
||||
data,
|
||||
n_freq,
|
||||
n_time: n_frames,
|
||||
freq_resolution: sample_rate / config.window_size as f64,
|
||||
time_resolution: config.hop_size as f64 / sample_rate,
|
||||
})
|
||||
}
|
||||
|
||||
/// Compute spectrogram for each subcarrier from a temporal CSI matrix.
|
||||
///
|
||||
/// Input: `csi_temporal` is (num_samples × num_subcarriers) amplitude matrix.
|
||||
/// Returns one spectrogram per subcarrier.
|
||||
pub fn compute_multi_subcarrier_spectrogram(
|
||||
csi_temporal: &Array2<f64>,
|
||||
sample_rate: f64,
|
||||
config: &SpectrogramConfig,
|
||||
) -> Result<Vec<Spectrogram>, SpectrogramError> {
|
||||
let (_, n_sc) = csi_temporal.dim();
|
||||
let mut spectrograms = Vec::with_capacity(n_sc);
|
||||
|
||||
for sc in 0..n_sc {
|
||||
let col: Vec<f64> = csi_temporal.column(sc).to_vec();
|
||||
spectrograms.push(compute_spectrogram(&col, sample_rate, config)?);
|
||||
}
|
||||
|
||||
Ok(spectrograms)
|
||||
}
|
||||
|
||||
/// Generate a window function.
|
||||
fn make_window(kind: WindowFunction, size: usize) -> Vec<f64> {
|
||||
match kind {
|
||||
WindowFunction::Rectangular => vec![1.0; size],
|
||||
WindowFunction::Hann => (0..size)
|
||||
.map(|i| 0.5 * (1.0 - (2.0 * PI * i as f64 / (size - 1) as f64).cos()))
|
||||
.collect(),
|
||||
WindowFunction::Hamming => (0..size)
|
||||
.map(|i| 0.54 - 0.46 * (2.0 * PI * i as f64 / (size - 1) as f64).cos())
|
||||
.collect(),
|
||||
WindowFunction::Blackman => (0..size)
|
||||
.map(|i| {
|
||||
let n = (size - 1) as f64;
|
||||
0.42 - 0.5 * (2.0 * PI * i as f64 / n).cos()
|
||||
+ 0.08 * (4.0 * PI * i as f64 / n).cos()
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors from spectrogram computation.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SpectrogramError {
|
||||
#[error("Signal too short ({signal_len} samples) for window size {window_size}")]
|
||||
SignalTooShort { signal_len: usize, window_size: usize },
|
||||
|
||||
#[error("Hop size must be > 0")]
|
||||
InvalidHopSize,
|
||||
|
||||
#[error("Window size must be > 0")]
|
||||
InvalidWindowSize,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_spectrogram_dimensions() {
|
||||
let sample_rate = 100.0;
|
||||
let signal: Vec<f64> = (0..1000)
|
||||
.map(|i| (i as f64 / sample_rate * 2.0 * PI * 5.0).sin())
|
||||
.collect();
|
||||
|
||||
let config = SpectrogramConfig {
|
||||
window_size: 128,
|
||||
hop_size: 32,
|
||||
window_fn: WindowFunction::Hann,
|
||||
power: true,
|
||||
};
|
||||
|
||||
let spec = compute_spectrogram(&signal, sample_rate, &config).unwrap();
|
||||
assert_eq!(spec.n_freq, 65); // 128/2 + 1
|
||||
assert_eq!(spec.n_time, (1000 - 128) / 32 + 1); // 28 frames
|
||||
assert_eq!(spec.data.dim(), (65, 28));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_frequency_peak() {
|
||||
// A pure 10 Hz tone at 100 Hz sampling → peak at bin 10/100*256 ≈ bin 26
|
||||
let sample_rate = 100.0;
|
||||
let freq = 10.0;
|
||||
let signal: Vec<f64> = (0..1024)
|
||||
.map(|i| (2.0 * PI * freq * i as f64 / sample_rate).sin())
|
||||
.collect();
|
||||
|
||||
let config = SpectrogramConfig {
|
||||
window_size: 256,
|
||||
hop_size: 128,
|
||||
window_fn: WindowFunction::Hann,
|
||||
power: true,
|
||||
};
|
||||
|
||||
let spec = compute_spectrogram(&signal, sample_rate, &config).unwrap();
|
||||
|
||||
// Find peak frequency bin in the first frame
|
||||
let frame = spec.data.column(0);
|
||||
let peak_bin = frame
|
||||
.iter()
|
||||
.enumerate()
|
||||
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
|
||||
.map(|(i, _)| i)
|
||||
.unwrap();
|
||||
|
||||
let peak_freq = peak_bin as f64 * spec.freq_resolution;
|
||||
assert!(
|
||||
(peak_freq - freq).abs() < spec.freq_resolution * 2.0,
|
||||
"Peak at {:.1} Hz, expected {:.1} Hz",
|
||||
peak_freq,
|
||||
freq
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_window_functions_symmetric() {
|
||||
for wf in [
|
||||
WindowFunction::Hann,
|
||||
WindowFunction::Hamming,
|
||||
WindowFunction::Blackman,
|
||||
] {
|
||||
let w = make_window(wf, 64);
|
||||
for i in 0..32 {
|
||||
assert!(
|
||||
(w[i] - w[63 - i]).abs() < 1e-10,
|
||||
"{:?} not symmetric at {}",
|
||||
wf,
|
||||
i
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectangular_window_all_ones() {
|
||||
let w = make_window(WindowFunction::Rectangular, 100);
|
||||
assert!(w.iter().all(|&v| (v - 1.0).abs() < 1e-10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_signal_too_short() {
|
||||
let signal = vec![1.0; 10];
|
||||
let config = SpectrogramConfig {
|
||||
window_size: 256,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
compute_spectrogram(&signal, 100.0, &config),
|
||||
Err(SpectrogramError::SignalTooShort { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multi_subcarrier() {
|
||||
let n_samples = 500;
|
||||
let n_sc = 8;
|
||||
let csi = Array2::from_shape_fn((n_samples, n_sc), |(t, sc)| {
|
||||
let freq = 1.0 + sc as f64 * 0.5;
|
||||
(2.0 * PI * freq * t as f64 / 100.0).sin()
|
||||
});
|
||||
|
||||
let config = SpectrogramConfig {
|
||||
window_size: 128,
|
||||
hop_size: 64,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let specs = compute_multi_subcarrier_spectrogram(&csi, 100.0, &config).unwrap();
|
||||
assert_eq!(specs.len(), n_sc);
|
||||
for spec in &specs {
|
||||
assert_eq!(spec.n_freq, 65);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
//! Subcarrier Sensitivity Selection
|
||||
//!
|
||||
//! Ranks subcarriers by their response to human motion using variance ratio
|
||||
//! (motion variance / static variance) and selects the top-K most sensitive
|
||||
//! ones. This improves SNR by 6-10 dB compared to using all subcarriers.
|
||||
//!
|
||||
//! # References
|
||||
//! - WiDance (MobiCom 2017)
|
||||
//! - WiGest: Using WiFi Gestures for Device-Free Sensing (SenSys 2015)
|
||||
|
||||
use ndarray::Array2;
|
||||
|
||||
/// Configuration for subcarrier selection.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SubcarrierSelectionConfig {
|
||||
/// Number of top subcarriers to select
|
||||
pub top_k: usize,
|
||||
/// Minimum sensitivity ratio to include a subcarrier
|
||||
pub min_sensitivity: f64,
|
||||
}
|
||||
|
||||
impl Default for SubcarrierSelectionConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
top_k: 20,
|
||||
min_sensitivity: 1.5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of subcarrier selection.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SubcarrierSelection {
|
||||
/// Selected subcarrier indices (sorted by sensitivity, descending)
|
||||
pub selected_indices: Vec<usize>,
|
||||
/// Sensitivity scores for ALL subcarriers (variance ratio)
|
||||
pub sensitivity_scores: Vec<f64>,
|
||||
/// The filtered data matrix containing only selected subcarrier columns
|
||||
pub selected_data: Option<Array2<f64>>,
|
||||
}
|
||||
|
||||
/// Select the most motion-sensitive subcarriers using variance ratio.
|
||||
///
|
||||
/// `motion_data`: (num_samples × num_subcarriers) CSI amplitude during motion
|
||||
/// `static_data`: (num_samples × num_subcarriers) CSI amplitude during static period
|
||||
///
|
||||
/// Sensitivity = var(motion[k]) / (var(static[k]) + ε)
|
||||
pub fn select_sensitive_subcarriers(
|
||||
motion_data: &Array2<f64>,
|
||||
static_data: &Array2<f64>,
|
||||
config: &SubcarrierSelectionConfig,
|
||||
) -> Result<SubcarrierSelection, SelectionError> {
|
||||
let (_, n_sc_motion) = motion_data.dim();
|
||||
let (_, n_sc_static) = static_data.dim();
|
||||
|
||||
if n_sc_motion != n_sc_static {
|
||||
return Err(SelectionError::SubcarrierCountMismatch {
|
||||
motion: n_sc_motion,
|
||||
statik: n_sc_static,
|
||||
});
|
||||
}
|
||||
if n_sc_motion == 0 {
|
||||
return Err(SelectionError::NoSubcarriers);
|
||||
}
|
||||
|
||||
let n_sc = n_sc_motion;
|
||||
let mut scores = Vec::with_capacity(n_sc);
|
||||
|
||||
for k in 0..n_sc {
|
||||
let motion_var = column_variance(motion_data, k);
|
||||
let static_var = column_variance(static_data, k);
|
||||
let sensitivity = motion_var / (static_var + 1e-12);
|
||||
scores.push(sensitivity);
|
||||
}
|
||||
|
||||
// Rank by sensitivity (descending)
|
||||
let mut ranked: Vec<(usize, f64)> = scores.iter().copied().enumerate().collect();
|
||||
ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
// Select top-K above minimum threshold
|
||||
let selected: Vec<usize> = ranked
|
||||
.iter()
|
||||
.filter(|(_, score)| *score >= config.min_sensitivity)
|
||||
.take(config.top_k)
|
||||
.map(|(idx, _)| *idx)
|
||||
.collect();
|
||||
|
||||
Ok(SubcarrierSelection {
|
||||
selected_indices: selected,
|
||||
sensitivity_scores: scores,
|
||||
selected_data: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Select and extract data for sensitive subcarriers from a temporal matrix.
|
||||
///
|
||||
/// `data`: (num_samples × num_subcarriers) - the full CSI matrix to filter
|
||||
/// `selection`: previously computed subcarrier selection
|
||||
///
|
||||
/// Returns a new matrix with only the selected columns.
|
||||
pub fn extract_selected(
|
||||
data: &Array2<f64>,
|
||||
selection: &SubcarrierSelection,
|
||||
) -> Result<Array2<f64>, SelectionError> {
|
||||
let (n_samples, n_sc) = data.dim();
|
||||
|
||||
for &idx in &selection.selected_indices {
|
||||
if idx >= n_sc {
|
||||
return Err(SelectionError::IndexOutOfBounds { index: idx, max: n_sc });
|
||||
}
|
||||
}
|
||||
|
||||
if selection.selected_indices.is_empty() {
|
||||
return Err(SelectionError::NoSubcarriersSelected);
|
||||
}
|
||||
|
||||
let n_selected = selection.selected_indices.len();
|
||||
let mut result = Array2::zeros((n_samples, n_selected));
|
||||
|
||||
for (col, &sc_idx) in selection.selected_indices.iter().enumerate() {
|
||||
for row in 0..n_samples {
|
||||
result[[row, col]] = data[[row, sc_idx]];
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Online subcarrier selection using only variance (no separate static period).
|
||||
///
|
||||
/// Ranks by absolute variance — high-variance subcarriers carry more
|
||||
/// information about environmental changes.
|
||||
pub fn select_by_variance(
|
||||
data: &Array2<f64>,
|
||||
config: &SubcarrierSelectionConfig,
|
||||
) -> SubcarrierSelection {
|
||||
let (_, n_sc) = data.dim();
|
||||
let mut scores = Vec::with_capacity(n_sc);
|
||||
|
||||
for k in 0..n_sc {
|
||||
scores.push(column_variance(data, k));
|
||||
}
|
||||
|
||||
let mut ranked: Vec<(usize, f64)> = scores.iter().copied().enumerate().collect();
|
||||
ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
let selected: Vec<usize> = ranked
|
||||
.iter()
|
||||
.take(config.top_k)
|
||||
.map(|(idx, _)| *idx)
|
||||
.collect();
|
||||
|
||||
SubcarrierSelection {
|
||||
selected_indices: selected,
|
||||
sensitivity_scores: scores,
|
||||
selected_data: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute variance of a single column in a 2D array.
|
||||
fn column_variance(data: &Array2<f64>, col: usize) -> f64 {
|
||||
let n = data.nrows() as f64;
|
||||
if n < 2.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let col_data = data.column(col);
|
||||
let mean: f64 = col_data.sum() / n;
|
||||
col_data.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / (n - 1.0)
|
||||
}
|
||||
|
||||
/// Errors from subcarrier selection.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SelectionError {
|
||||
#[error("Subcarrier count mismatch: motion={motion}, static={statik}")]
|
||||
SubcarrierCountMismatch { motion: usize, statik: usize },
|
||||
|
||||
#[error("No subcarriers in input")]
|
||||
NoSubcarriers,
|
||||
|
||||
#[error("No subcarriers met selection criteria")]
|
||||
NoSubcarriersSelected,
|
||||
|
||||
#[error("Subcarrier index {index} out of bounds (max {max})")]
|
||||
IndexOutOfBounds { index: usize, max: usize },
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_sensitive_subcarriers_ranked() {
|
||||
// 3 subcarriers: SC0 has high motion variance, SC1 low, SC2 medium
|
||||
let motion = Array2::from_shape_fn((100, 3), |(t, sc)| match sc {
|
||||
0 => (t as f64 * 0.1).sin() * 5.0, // high variance
|
||||
1 => (t as f64 * 0.1).sin() * 0.1, // low variance
|
||||
2 => (t as f64 * 0.1).sin() * 2.0, // medium variance
|
||||
_ => 0.0,
|
||||
});
|
||||
let statik = Array2::from_shape_fn((100, 3), |(_, _)| 0.01);
|
||||
|
||||
let config = SubcarrierSelectionConfig {
|
||||
top_k: 3,
|
||||
min_sensitivity: 0.0,
|
||||
};
|
||||
let result = select_sensitive_subcarriers(&motion, &statik, &config).unwrap();
|
||||
|
||||
// SC0 should be ranked first (highest sensitivity)
|
||||
assert_eq!(result.selected_indices[0], 0);
|
||||
// SC2 should be second
|
||||
assert_eq!(result.selected_indices[1], 2);
|
||||
// SC1 should be last
|
||||
assert_eq!(result.selected_indices[2], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_top_k_limits_output() {
|
||||
let motion = Array2::from_shape_fn((50, 20), |(t, sc)| {
|
||||
(t as f64 * 0.05).sin() * (sc as f64 + 1.0)
|
||||
});
|
||||
let statik = Array2::from_elem((50, 20), 0.01);
|
||||
|
||||
let config = SubcarrierSelectionConfig {
|
||||
top_k: 5,
|
||||
min_sensitivity: 0.0,
|
||||
};
|
||||
let result = select_sensitive_subcarriers(&motion, &statik, &config).unwrap();
|
||||
assert_eq!(result.selected_indices.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_min_sensitivity_filter() {
|
||||
// All subcarriers have very low sensitivity
|
||||
let motion = Array2::from_elem((50, 10), 1.0);
|
||||
let statik = Array2::from_elem((50, 10), 1.0);
|
||||
|
||||
let config = SubcarrierSelectionConfig {
|
||||
top_k: 10,
|
||||
min_sensitivity: 2.0, // None will pass
|
||||
};
|
||||
let result = select_sensitive_subcarriers(&motion, &statik, &config).unwrap();
|
||||
assert!(result.selected_indices.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_selected_columns() {
|
||||
let data = Array2::from_shape_fn((10, 5), |(r, c)| (r * 5 + c) as f64);
|
||||
|
||||
let selection = SubcarrierSelection {
|
||||
selected_indices: vec![1, 3],
|
||||
sensitivity_scores: vec![0.0; 5],
|
||||
selected_data: None,
|
||||
};
|
||||
|
||||
let extracted = extract_selected(&data, &selection).unwrap();
|
||||
assert_eq!(extracted.dim(), (10, 2));
|
||||
|
||||
// Column 0 of extracted should be column 1 of original
|
||||
for r in 0..10 {
|
||||
assert_eq!(extracted[[r, 0]], data[[r, 1]]);
|
||||
assert_eq!(extracted[[r, 1]], data[[r, 3]]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_variance_based_selection() {
|
||||
let data = Array2::from_shape_fn((100, 5), |(t, sc)| {
|
||||
(t as f64 * 0.1).sin() * (sc as f64 + 1.0)
|
||||
});
|
||||
|
||||
let config = SubcarrierSelectionConfig {
|
||||
top_k: 3,
|
||||
min_sensitivity: 0.0,
|
||||
};
|
||||
let result = select_by_variance(&data, &config);
|
||||
|
||||
assert_eq!(result.selected_indices.len(), 3);
|
||||
// SC4 (highest amplitude) should be first
|
||||
assert_eq!(result.selected_indices[0], 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mismatch_error() {
|
||||
let motion = Array2::zeros((10, 5));
|
||||
let statik = Array2::zeros((10, 3));
|
||||
|
||||
assert!(matches!(
|
||||
select_sensitive_subcarriers(&motion, &statik, &SubcarrierSelectionConfig::default()),
|
||||
Err(SelectionError::SubcarrierCountMismatch { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user