mirror of
https://github.com/ruvnet/RuView.git
synced 2026-04-26 13:10:40 +00:00
The Rust port lived two directories deep (rust-port/wifi-densepose-rs/) without any sibling under rust-port/ that warranted the extra level. Move the whole workspace up to v2/ to match v1/ (Python) at the same depth and shorten every cd / build command across the repo. git mv preserves history for all tracked files. 60 files updated for path references (CI workflows, ADRs, docs, scripts, READMEs, internal .claude-flow state). Two manual fixes for relative-cd paths in CLAUDE.md and ADR-043 that became wrong after the depth change (cd ../.. → cd ..). Validated: - cargo check --workspace --no-default-features → clean (after target/ nuke; the gitignored target/ was carried by the OS rename and had hard-coded old paths in build scripts) - cargo test --workspace --no-default-features → 1,539 passed, 0 failed, 8 ignored (same totals as pre-rename) - ESP32-S3 on COM7 → still streaming live CSI (cb #40300, RSSI -64 dBm) After-merge follow-up: contributors should `rm -rf v2/target` once and let cargo regenerate from the new path.
449 lines
14 KiB
Rust
449 lines
14 KiB
Rust
//! Sparse subcarrier recovery via ISTA — ADR-041 signal processing module.
|
|
//!
|
|
//! When CSI frames have null/zero subcarriers (dropout from hardware faults,
|
|
//! multipath nulls, or firmware glitches), this module recovers missing values
|
|
//! using Iterative Shrinkage-Thresholding Algorithm (ISTA) — an L1-minimizing
|
|
//! sparse recovery method.
|
|
//!
|
|
//! Algorithm:
|
|
//! x_{k+1} = soft_threshold(x_k + step * A^T * (b - A*x_k), lambda)
|
|
//! soft_threshold(x, t) = sign(x) * max(|x| - t, 0)
|
|
//!
|
|
//! The correlation structure A is estimated from recent valid frames using a
|
|
//! compact representation: diagonal + immediate neighbors (96 f32s instead of
|
|
//! the full 32x32 = 1024 upper triangle).
|
|
//!
|
|
//! Budget: H (heavy, < 10ms) — max 10 ISTA iterations per frame.
|
|
|
|
use libm::{fabsf, sqrtf};
|
|
|
|
/// Maximum subcarriers tracked.
|
|
const MAX_SC: usize = 32;
|
|
|
|
/// Amplitude threshold below which a subcarrier is considered dropped out.
|
|
const NULL_THRESHOLD: f32 = 0.001;
|
|
|
|
/// Minimum dropout rate (fraction) to trigger recovery.
|
|
const MIN_DROPOUT_RATE: f32 = 0.10;
|
|
|
|
/// Maximum ISTA iterations per frame (bounded computation).
|
|
const MAX_ITERATIONS: usize = 10;
|
|
|
|
/// ISTA step size (gradient descent learning rate).
|
|
const STEP_SIZE: f32 = 0.05;
|
|
|
|
/// ISTA regularization parameter (L1 penalty weight).
|
|
const LAMBDA: f32 = 0.01;
|
|
|
|
/// EMA blending factor for correlation estimate updates.
|
|
const CORR_ALPHA: f32 = 0.05;
|
|
|
|
/// Number of neighbor hops stored per subcarrier in the correlation model.
|
|
/// For each subcarrier i we store: corr(i, i-1), corr(i, i), corr(i, i+1).
|
|
const NEIGHBORS: usize = 3;
|
|
|
|
/// Event IDs (700-series: Signal Processing).
|
|
pub const EVENT_RECOVERY_COMPLETE: i32 = 715;
|
|
pub const EVENT_RECOVERY_ERROR: i32 = 716;
|
|
pub const EVENT_DROPOUT_RATE: i32 = 717;
|
|
|
|
/// Soft-thresholding operator for ISTA.
|
|
///
|
|
/// S(x, t) = sign(x) * max(|x| - t, 0)
|
|
#[inline]
|
|
fn soft_threshold(x: f32, t: f32) -> f32 {
|
|
let abs_x = fabsf(x);
|
|
if abs_x <= t {
|
|
0.0
|
|
} else if x > 0.0 {
|
|
abs_x - t
|
|
} else {
|
|
-(abs_x - t)
|
|
}
|
|
}
|
|
|
|
/// Sparse subcarrier recovery engine.
|
|
pub struct SparseRecovery {
|
|
/// Compact correlation estimate: [MAX_SC][NEIGHBORS].
|
|
/// For subcarrier i: [corr(i,i-1), corr(i,i), corr(i,i+1)].
|
|
/// Edge entries (i=0 left neighbor, i=31 right neighbor) are zero.
|
|
correlation: [[f32; NEIGHBORS]; MAX_SC],
|
|
/// Most recent valid amplitude per subcarrier (used as reference).
|
|
recent_valid: [f32; MAX_SC],
|
|
/// Whether the correlation model has been seeded.
|
|
initialized: bool,
|
|
/// Number of valid frames ingested for correlation estimation.
|
|
valid_frame_count: u32,
|
|
/// Frame counter.
|
|
frame_count: u32,
|
|
/// Last dropout rate for diagnostics.
|
|
last_dropout_rate: f32,
|
|
/// Last recovery residual L2 norm.
|
|
last_residual: f32,
|
|
/// Last count of recovered subcarriers.
|
|
last_recovered: u32,
|
|
}
|
|
|
|
impl SparseRecovery {
|
|
pub const fn new() -> Self {
|
|
Self {
|
|
correlation: [[0.0; NEIGHBORS]; MAX_SC],
|
|
recent_valid: [0.0; MAX_SC],
|
|
initialized: false,
|
|
valid_frame_count: 0,
|
|
frame_count: 0,
|
|
last_dropout_rate: 0.0,
|
|
last_residual: 0.0,
|
|
last_recovered: 0,
|
|
}
|
|
}
|
|
|
|
/// Process one CSI frame. Detects null subcarriers, recovers via ISTA if
|
|
/// dropout rate exceeds threshold, and returns events plus recovered data
|
|
/// written back into the provided `amplitudes` buffer.
|
|
///
|
|
/// Returns a slice of (event_type, value) pairs to emit.
|
|
pub fn process_frame(&mut self, amplitudes: &mut [f32]) -> &[(i32, f32)] {
|
|
let n_sc = amplitudes.len().min(MAX_SC);
|
|
if n_sc < 4 {
|
|
return &[];
|
|
}
|
|
|
|
self.frame_count += 1;
|
|
|
|
// -- Detect null subcarriers ------------------------------------------
|
|
let mut null_mask = [false; MAX_SC];
|
|
let mut null_count = 0u32;
|
|
|
|
for i in 0..n_sc {
|
|
if fabsf(amplitudes[i]) < NULL_THRESHOLD {
|
|
null_mask[i] = true;
|
|
null_count += 1;
|
|
}
|
|
}
|
|
|
|
let dropout_rate = null_count as f32 / n_sc as f32;
|
|
self.last_dropout_rate = dropout_rate;
|
|
|
|
// -- Update correlation from valid subcarriers ------------------------
|
|
if null_count == 0 {
|
|
self.update_correlation(amplitudes, n_sc);
|
|
// Update recent valid snapshot.
|
|
for i in 0..n_sc {
|
|
self.recent_valid[i] = amplitudes[i];
|
|
}
|
|
}
|
|
|
|
// -- Build event output -----------------------------------------------
|
|
static mut EVENTS: [(i32, f32); 3] = [(0, 0.0); 3];
|
|
let mut n_events = 0usize;
|
|
|
|
// Always emit dropout rate periodically (every 20 frames).
|
|
if self.frame_count % 20 == 0 {
|
|
unsafe {
|
|
EVENTS[n_events] = (EVENT_DROPOUT_RATE, dropout_rate);
|
|
}
|
|
n_events += 1;
|
|
}
|
|
|
|
// -- Skip recovery if dropout too low or model not ready ---------------
|
|
if dropout_rate < MIN_DROPOUT_RATE || !self.initialized {
|
|
unsafe { return &EVENTS[..n_events]; }
|
|
}
|
|
|
|
// -- ISTA recovery ----------------------------------------------------
|
|
let (recovered, residual) = self.ista_recover(amplitudes, &null_mask, n_sc);
|
|
self.last_recovered = recovered;
|
|
self.last_residual = residual;
|
|
|
|
// Emit recovery results.
|
|
if n_events < 3 {
|
|
unsafe {
|
|
EVENTS[n_events] = (EVENT_RECOVERY_COMPLETE, recovered as f32);
|
|
}
|
|
n_events += 1;
|
|
}
|
|
if n_events < 3 {
|
|
unsafe {
|
|
EVENTS[n_events] = (EVENT_RECOVERY_ERROR, residual);
|
|
}
|
|
n_events += 1;
|
|
}
|
|
|
|
unsafe { &EVENTS[..n_events] }
|
|
}
|
|
|
|
/// Update the compact correlation model from a fully valid frame.
|
|
fn update_correlation(&mut self, amplitudes: &[f32], n_sc: usize) {
|
|
self.valid_frame_count += 1;
|
|
|
|
// Compute products for diagonal and 1-hop neighbors.
|
|
for i in 0..n_sc {
|
|
// Self-correlation (diagonal): a_i * a_i
|
|
let self_prod = amplitudes[i] * amplitudes[i];
|
|
self.correlation[i][1] = CORR_ALPHA * self_prod
|
|
+ (1.0 - CORR_ALPHA) * self.correlation[i][1];
|
|
|
|
// Left neighbor correlation: a_i * a_{i-1}
|
|
if i > 0 {
|
|
let left_prod = amplitudes[i] * amplitudes[i - 1];
|
|
self.correlation[i][0] = CORR_ALPHA * left_prod
|
|
+ (1.0 - CORR_ALPHA) * self.correlation[i][0];
|
|
}
|
|
|
|
// Right neighbor correlation: a_i * a_{i+1}
|
|
if i + 1 < n_sc {
|
|
let right_prod = amplitudes[i] * amplitudes[i + 1];
|
|
self.correlation[i][2] = CORR_ALPHA * right_prod
|
|
+ (1.0 - CORR_ALPHA) * self.correlation[i][2];
|
|
}
|
|
}
|
|
|
|
if self.valid_frame_count >= 10 {
|
|
self.initialized = true;
|
|
}
|
|
}
|
|
|
|
/// Run ISTA to recover null subcarriers in place.
|
|
///
|
|
/// Returns (count_recovered, residual_l2_norm).
|
|
fn ista_recover(
|
|
&self,
|
|
amplitudes: &mut [f32],
|
|
null_mask: &[bool; MAX_SC],
|
|
n_sc: usize,
|
|
) -> (u32, f32) {
|
|
// Initialize null subcarriers from recent valid values.
|
|
for i in 0..n_sc {
|
|
if null_mask[i] {
|
|
amplitudes[i] = self.recent_valid[i];
|
|
}
|
|
}
|
|
|
|
// The observation vector b is the non-null entries.
|
|
// We iterate: x <- S_lambda(x + step * A^T * (b - A*x))
|
|
// Using our tridiagonal correlation model as A.
|
|
|
|
for _iter in 0..MAX_ITERATIONS {
|
|
// Compute A*x (tridiagonal matrix-vector product).
|
|
let mut ax = [0.0f32; MAX_SC];
|
|
for i in 0..n_sc {
|
|
// Diagonal term.
|
|
ax[i] = self.correlation[i][1] * amplitudes[i];
|
|
// Left neighbor.
|
|
if i > 0 {
|
|
ax[i] += self.correlation[i][0] * amplitudes[i - 1];
|
|
}
|
|
// Right neighbor.
|
|
if i + 1 < n_sc {
|
|
ax[i] += self.correlation[i][2] * amplitudes[i + 1];
|
|
}
|
|
}
|
|
|
|
// Compute residual r = b - A*x (only at observed positions).
|
|
let mut residual = [0.0f32; MAX_SC];
|
|
for i in 0..n_sc {
|
|
if !null_mask[i] {
|
|
// b[i] is the original observed value (which is still in
|
|
// amplitudes since we only modify null positions).
|
|
residual[i] = amplitudes[i] - ax[i];
|
|
}
|
|
}
|
|
|
|
// Compute A^T * residual (tridiagonal transpose = same structure).
|
|
let mut grad = [0.0f32; MAX_SC];
|
|
for i in 0..n_sc {
|
|
// Diagonal.
|
|
grad[i] = self.correlation[i][1] * residual[i];
|
|
// Left neighbor (A^T row i gets contribution from row i-1 right).
|
|
if i > 0 {
|
|
grad[i] += self.correlation[i - 1][2] * residual[i - 1];
|
|
}
|
|
// Right neighbor (A^T row i gets contribution from row i+1 left).
|
|
if i + 1 < n_sc {
|
|
grad[i] += self.correlation[i + 1][0] * residual[i + 1];
|
|
}
|
|
}
|
|
|
|
// Update only null subcarriers: x <- S_lambda(x + step * grad).
|
|
for i in 0..n_sc {
|
|
if null_mask[i] {
|
|
let updated = amplitudes[i] + STEP_SIZE * grad[i];
|
|
amplitudes[i] = soft_threshold(updated, LAMBDA);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Compute final residual L2 norm across observed positions.
|
|
let mut residual_sq = 0.0f32;
|
|
let mut recovered_count = 0u32;
|
|
|
|
// Recompute A*x for residual.
|
|
let mut ax_final = [0.0f32; MAX_SC];
|
|
for i in 0..n_sc {
|
|
ax_final[i] = self.correlation[i][1] * amplitudes[i];
|
|
if i > 0 {
|
|
ax_final[i] += self.correlation[i][0] * amplitudes[i - 1];
|
|
}
|
|
if i + 1 < n_sc {
|
|
ax_final[i] += self.correlation[i][2] * amplitudes[i + 1];
|
|
}
|
|
}
|
|
for i in 0..n_sc {
|
|
if null_mask[i] {
|
|
recovered_count += 1;
|
|
} else {
|
|
let r = amplitudes[i] - ax_final[i];
|
|
residual_sq += r * r;
|
|
}
|
|
}
|
|
|
|
(recovered_count, sqrtf(residual_sq))
|
|
}
|
|
|
|
/// Get the last observed dropout rate.
|
|
pub fn dropout_rate(&self) -> f32 {
|
|
self.last_dropout_rate
|
|
}
|
|
|
|
/// Get the residual L2 norm from the last recovery pass.
|
|
pub fn last_residual_norm(&self) -> f32 {
|
|
self.last_residual
|
|
}
|
|
|
|
/// Get the count of subcarriers recovered in the last pass.
|
|
pub fn last_recovered_count(&self) -> u32 {
|
|
self.last_recovered
|
|
}
|
|
|
|
/// Check whether the correlation model is ready.
|
|
pub fn is_initialized(&self) -> bool {
|
|
self.initialized
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_sparse_recovery_init() {
|
|
let sr = SparseRecovery::new();
|
|
assert_eq!(sr.frame_count, 0);
|
|
assert!(!sr.is_initialized());
|
|
assert_eq!(sr.dropout_rate(), 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_soft_threshold() {
|
|
assert!((soft_threshold(0.5, 0.3) - 0.2).abs() < 1e-6);
|
|
assert!((soft_threshold(-0.5, 0.3) - (-0.2)).abs() < 1e-6);
|
|
assert_eq!(soft_threshold(0.1, 0.3), 0.0);
|
|
assert_eq!(soft_threshold(-0.1, 0.3), 0.0);
|
|
assert_eq!(soft_threshold(0.0, 0.1), 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_no_recovery_below_threshold() {
|
|
let mut sr = SparseRecovery::new();
|
|
// 16 subcarriers, only 1 null => 6.25% < 10% threshold.
|
|
let mut amps = [1.0f32; 16];
|
|
amps[0] = 0.0;
|
|
|
|
let events = sr.process_frame(&mut amps);
|
|
// Should not emit recovery events (model not initialized anyway).
|
|
for &(et, _) in events {
|
|
assert_ne!(et, EVENT_RECOVERY_COMPLETE);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_correlation_model_builds() {
|
|
let mut sr = SparseRecovery::new();
|
|
let mut amps = [1.0f32; 16];
|
|
|
|
// Feed 10 valid frames to initialize correlation model.
|
|
for _ in 0..10 {
|
|
sr.process_frame(&mut amps);
|
|
}
|
|
|
|
assert!(sr.is_initialized());
|
|
}
|
|
|
|
#[test]
|
|
fn test_recovery_triggered_above_threshold() {
|
|
let mut sr = SparseRecovery::new();
|
|
|
|
// Build correlation model with valid frames.
|
|
let mut valid_amps = [0.0f32; 16];
|
|
for i in 0..16 {
|
|
valid_amps[i] = 1.0 + 0.1 * (i as f32);
|
|
}
|
|
|
|
for _ in 0..15 {
|
|
let mut frame = valid_amps;
|
|
sr.process_frame(&mut frame);
|
|
}
|
|
assert!(sr.is_initialized());
|
|
|
|
// Now create a frame with >10% dropout (3 of 16 = 18.75%).
|
|
let mut dropout_frame = valid_amps;
|
|
dropout_frame[2] = 0.0;
|
|
dropout_frame[5] = 0.0;
|
|
dropout_frame[9] = 0.0;
|
|
|
|
let events = sr.process_frame(&mut dropout_frame);
|
|
|
|
// Should emit recovery events.
|
|
let mut found_recovery = false;
|
|
for &(et, _) in events {
|
|
if et == EVENT_RECOVERY_COMPLETE {
|
|
found_recovery = true;
|
|
}
|
|
}
|
|
assert!(found_recovery, "recovery should trigger when dropout > 10%");
|
|
assert_eq!(sr.last_recovered_count(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn test_recovered_values_nonzero() {
|
|
let mut sr = SparseRecovery::new();
|
|
|
|
// Build model.
|
|
let valid_amps = [2.0f32; 16];
|
|
for _ in 0..15 {
|
|
let mut frame = valid_amps;
|
|
sr.process_frame(&mut frame);
|
|
}
|
|
|
|
// Create dropout frame.
|
|
let mut dropout = valid_amps;
|
|
dropout[0] = 0.0;
|
|
dropout[1] = 0.0;
|
|
|
|
sr.process_frame(&mut dropout);
|
|
|
|
// Recovered values should be non-zero (ISTA should restore something).
|
|
assert!(
|
|
dropout[0].abs() > 0.001 || dropout[1].abs() > 0.001,
|
|
"recovered subcarriers should have non-zero amplitude"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_dropout_rate_event() {
|
|
let mut sr = SparseRecovery::new();
|
|
let mut amps = [1.0f32; 16];
|
|
|
|
// Process exactly 20 frames to hit the periodic emit.
|
|
for _ in 0..20 {
|
|
sr.process_frame(&mut amps);
|
|
}
|
|
|
|
// Frame 20 should emit dropout rate event.
|
|
let _events = sr.process_frame(&mut amps);
|
|
// frame_count is now 21, not divisible by 20 — check frame 20.
|
|
// We already processed it above. Let's just verify the counter.
|
|
assert_eq!(sr.frame_count, 21);
|
|
}
|
|
}
|