389 lines
13 KiB
Rust
389 lines
13 KiB
Rust
//! 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;
|
||
use ruvector_mincut::MinCutBuilder;
|
||
|
||
/// 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)
|
||
}
|
||
|
||
/// Partition subcarriers into (sensitive, insensitive) groups via DynamicMinCut.
|
||
///
|
||
/// Builds a similarity graph: subcarriers are vertices, edges encode inverse
|
||
/// variance-ratio distance. The min-cut separates high-sensitivity from
|
||
/// low-sensitivity subcarriers in O(n^1.5 log n) amortized time.
|
||
///
|
||
/// # Arguments
|
||
/// * `sensitivity` - Per-subcarrier sensitivity score (variance_motion / variance_static)
|
||
///
|
||
/// # Returns
|
||
/// (sensitive_indices, insensitive_indices) — indices into the input slice
|
||
pub fn mincut_subcarrier_partition(sensitivity: &[f32]) -> (Vec<usize>, Vec<usize>) {
|
||
let n = sensitivity.len();
|
||
if n < 4 {
|
||
// Too small for meaningful cut — put all in sensitive
|
||
return ((0..n).collect(), Vec::new());
|
||
}
|
||
|
||
// Build similarity graph: edge weight = 1 / |sensitivity_i - sensitivity_j|
|
||
// Only include edges where weight > min_weight (prune very weak similarities)
|
||
let min_weight = 0.5_f64;
|
||
let mut edges: Vec<(u64, u64, f64)> = Vec::new();
|
||
for i in 0..n {
|
||
for j in (i + 1)..n {
|
||
let diff = (sensitivity[i] - sensitivity[j]).abs() as f64;
|
||
let weight = if diff > 1e-9 { 1.0 / diff } else { 1e6_f64 };
|
||
if weight > min_weight {
|
||
edges.push((i as u64, j as u64, weight));
|
||
}
|
||
}
|
||
}
|
||
|
||
if edges.is_empty() {
|
||
// All subcarriers equally sensitive — split by median
|
||
let median_idx = n / 2;
|
||
return ((0..median_idx).collect(), (median_idx..n).collect());
|
||
}
|
||
|
||
let mc = MinCutBuilder::new()
|
||
.exact()
|
||
.with_edges(edges)
|
||
.build()
|
||
.expect("MinCutBuilder::build failed");
|
||
let (side_a, side_b) = mc.partition();
|
||
|
||
// The side with higher mean sensitivity is the "sensitive" group
|
||
let mean_a: f32 = if side_a.is_empty() {
|
||
0.0_f32
|
||
} else {
|
||
side_a.iter().map(|&i| sensitivity[i as usize]).sum::<f32>() / side_a.len() as f32
|
||
};
|
||
let mean_b: f32 = if side_b.is_empty() {
|
||
0.0_f32
|
||
} else {
|
||
side_b.iter().map(|&i| sensitivity[i as usize]).sum::<f32>() / side_b.len() as f32
|
||
};
|
||
|
||
if mean_a >= mean_b {
|
||
(
|
||
side_a.into_iter().map(|x| x as usize).collect(),
|
||
side_b.into_iter().map(|x| x as usize).collect(),
|
||
)
|
||
} else {
|
||
(
|
||
side_b.into_iter().map(|x| x as usize).collect(),
|
||
side_a.into_iter().map(|x| x as usize).collect(),
|
||
)
|
||
}
|
||
}
|
||
|
||
/// 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 { .. })
|
||
));
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod mincut_tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn mincut_partition_separates_high_low() {
|
||
// High sensitivity: indices 0,1,2; low: 3,4,5
|
||
let sensitivity = vec![0.9_f32, 0.85, 0.92, 0.1, 0.12, 0.08];
|
||
let (sensitive, insensitive) = mincut_subcarrier_partition(&sensitivity);
|
||
// High-sensitivity indices should cluster together
|
||
assert!(!sensitive.is_empty());
|
||
assert!(!insensitive.is_empty());
|
||
let sens_mean: f32 = sensitive.iter().map(|&i| sensitivity[i]).sum::<f32>() / sensitive.len() as f32;
|
||
let insens_mean: f32 = insensitive.iter().map(|&i| sensitivity[i]).sum::<f32>() / insensitive.len() as f32;
|
||
assert!(sens_mean > insens_mean, "sensitive mean {sens_mean} should exceed insensitive mean {insens_mean}");
|
||
}
|
||
|
||
#[test]
|
||
fn mincut_partition_small_input() {
|
||
let sensitivity = vec![0.5_f32, 0.8];
|
||
let (sensitive, insensitive) = mincut_subcarrier_partition(&sensitivity);
|
||
assert_eq!(sensitive.len() + insensitive.len(), 2);
|
||
}
|
||
}
|