Files
ruvnet--RuView/v2/crates/wifi-densepose-signal/src/spectrogram.rs
T
rUv 865f9dee77 perf(beyond-sota): ADR-154 M2 — FFT planner hoist (1.84x, bit-identical) + 3 honest perf nulls + boundary tests (#1055)
* perf(signal): hoist FFT planner across subcarriers (ADR-154 §7.4 #20)

compute_multi_subcarrier_spectrogram called compute_spectrogram once per
subcarrier, and each call built a fresh FftPlanner + re-planned the same
length-window_size FFT. Hoist the plan + window out of the per-subcarrier
loop via a new compute_spectrogram_with_plan core that takes a pre-planned
Arc<dyn Fft> and pre-built window. compute_spectrogram delegates to it
(unchanged behaviour); the multi-subcarrier path plans once and reuses.

MEASURED-HOT (dsp_perf_bench, this box): at 56 subcarriers, window 128,
fresh-planner-per-subcarrier 467.88 µs -> hoisted-plan 254.75 µs = 1.84x;
window 256: 627.27 µs -> 448.39 µs = 1.40x. Plan-forward cost alone is
~1.86 µs (w128), x56 subcarriers ~= the removed delta.

Output is bit-identical: multi_subcarrier_hoisted_plan_bit_identical
compares f64::to_bits of every spectrogram value + freq/time resolution
against the per-call fresh-planner path across all 4 window functions x
{power,magnitude} on a 56-subcarrier matrix. The numeric STFT body is the
old loop verbatim; only plan/window construction is lifted.

Co-Authored-By: claude-flow <ruv@ruv.net>

* test(signal): boundary/tolerance tests for ADR-154 §7.4 #14 #16 #19

Three "+ test" backlog gaps closed — pure additions, no behaviour change
(phase_align refactor is internal: estimate_phase_offsets still returns the
identical offset vector; a counted core is split out only to observe the
iteration count).

#14 cir.rs fft_operator — fft_operator_within_tolerance_of_dense_canonical56:
  the opt-in FFT Φ/Φᴴ path changes the witness hash, so pin it numerically
  CLOSE to the dense path (not silently divergent). Asserts the full Cir
  output (every tap within 1e-2·dominant, dominant idx/ratio, active_tap_count,
  ranging_valid, rms_delay_spread) on the production canonical-56 config
  across τ ∈ {20,50,90} ns. Extends the existing HT20/single-τ test.

#16 phase_align.rs — refinement_terminates_at_iteration_cap_when_not_converging:
  forces non-convergence (tolerance=0.0, unreachable) and asserts the loop
  runs exactly max_iterations then returns — proving the cap, not convergence,
  bounds the loop (no infinite spin). Companion
  refinement_converges_before_cap_on_easy_input proves the cap is an upper
  bound, not the only exit.

#19 csi_ratio.rs — ratio_finite_at_and_below_1e_12_epsilon: the module
  implements the CSI ratio as the conjugate product H_i·conj(H_j) (no
  division), so it is finite even at/below the 1e-12 magnitude boundary a
  naive H_i/H_j division would need an epsilon to guard. Pins finiteness +
  bit-exact conjugate product at the boundary (zero target → zero, never
  inf/NaN), through the amplitude/phase extraction.

cargo test -p wifi-densepose-signal --no-default-features --lib: 447 passed,
0 failed; --features cir --lib: 447 passed, 0 failed.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(adr-154): record Milestone-2 P2-perf verdicts + boundary tests (§7.4)

§7.4: #20 MEASURED-HOT (1.40–1.84× spectrogram FFT-plan hoist, bit-identical);
#5/#6/#7 MEASURED-NULL (benched, not hot, left as-is — sub-µs / stack-only /
alloc-once); #8 MEASUREMENT-ONLY (per-call 56×56 eigh cost; eigenvalue/BLAS
backend un-buildable on this Windows host, number deferred to a BLAS box, NOT
fabricated; also corrects the finding — extract_perturbation reuses cached
modes, the recompute is in estimate_occupancy). #14/#16/#19 RESOLVED (tolerance
/ convergence-cap / epsilon-boundary tests). Updated §7.4 intro + Horizon-ledger
(deferred count 41→36). CHANGELOG [Unreleased] entry added.

Co-Authored-By: claude-flow <ruv@ruv.net>

* bench(signal): committed P2 bench-first benches (ADR-154 §7.4 #5/#6/#7/#8/#20)

New dsp_perf_bench.rs backs every Milestone-2 perf verdict with a committed
criterion bench — no speedup claimed without a before/after number here, and
a benched NULL is the proof a micro-opt was unnecessary (the §5.x "already
amortized" pattern). Registered in Cargo.toml [[bench]].

MEASURED (this box, criterion medians):
  #20 spectrogram_multi_subcarrier (fresh vs hoisted plan):
      MEASURED-HOT — 467.88→254.75 µs (1.84x) @ sc56/w128; 627.27→448.39 µs
      (1.40x) @ sc56/w256. Optimized in the prior commit.
  #5 multistatic_attention/weights: MEASURED-NULL — 181 ns (2 nodes) ..
      848 ns (8 nodes); sub-µs, no hot-path alloc — left as-is.
  #6 tomography_reconstruct/solve: MEASURED-NULL — 47.5 µs (16 links) /
      60.4 µs (32 links) for a full 50-iter ISTA solve; the 2 per-solve voxel
      buffers (~4 KB) are negligible vs O(iters·links·voxels) compute, and
      reconstruct(&self) reuses them across iterations already — left as-is.
  #7 pose_kalman_update/cycles: MEASURED-NULL — 150 ns (17 kpts) / 2.82 µs
      (170); the Kalman "gain matrices" are fixed-size STACK arrays
      ([[f32;3];6]), zero heap — nothing to reuse — left as-is.
  #8 field_model_occupancy (eigenvalue feature): MEASUREMENT-ONLY — quantifies
      the per-call n×n eigendecomposition cost; incremental SVD is a sized
      future project, not attempted (number recorded in ADR-154 §7.4).

Reproduce:
  cargo bench -p wifi-densepose-signal --no-default-features --bench dsp_perf_bench
  cargo bench -p wifi-densepose-signal --bench dsp_perf_bench  # adds #8

Cargo.lock: dev-dep (criterion/clap) graph + crate version bumps from the
build; no runtime-dependency change.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-13 17:34:37 -04:00

520 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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::{Fft, FftPlanner};
use ruvector_attn_mincut::attn_mincut;
use std::f64::consts::PI;
use std::sync::Arc;
/// 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 mut planner = FftPlanner::new();
let fft = planner.plan_fft_forward(config.window_size);
let window = make_window(config.window_fn, config.window_size);
Ok(compute_spectrogram_with_plan(
signal,
sample_rate,
config,
&fft,
&window,
))
}
/// STFT core that runs against a **pre-planned** FFT and pre-built window.
///
/// ADR-154 §7.4 #20: `compute_spectrogram` re-plans the FFT on every call, so
/// `compute_multi_subcarrier_spectrogram` (which calls it once per subcarrier)
/// re-planned the same length-`window_size` FFT for *every* subcarrier. This
/// helper hoists the plan + window out of the per-subcarrier loop. The numeric
/// body is byte-for-byte the old loop — only the plan/window construction is
/// lifted — so the output is **bit-identical** to the per-call path (asserted by
/// `multi_subcarrier_hoisted_plan_bit_identical`). Callers must pass a plan
/// built for exactly `config.window_size` and a window of that length.
fn compute_spectrogram_with_plan(
signal: &[f64],
sample_rate: f64,
config: &SpectrogramConfig,
fft: &Arc<dyn Fft<f64>>,
window: &[f64],
) -> Spectrogram {
debug_assert_eq!(window.len(), config.window_size, "window/plan size mismatch");
debug_assert_eq!(fft.len(), config.window_size, "FFT/window size mismatch");
let n_frames = (signal.len() - config.window_size) / config.hop_size + 1;
let n_freq = config.window_size / 2 + 1;
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 };
}
}
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_samples, n_sc) = csi_temporal.dim();
// ADR-154 §7.4 #20: validate *once* (same checks `compute_spectrogram`
// makes), then plan the FFT + build the window *once* and reuse them across
// every subcarrier instead of re-planning per column. The window length is
// identical for all subcarriers, so this is pure hoisting — output stays
// bit-identical to the per-call path.
if n_samples < config.window_size {
return Err(SpectrogramError::SignalTooShort {
signal_len: n_samples,
window_size: config.window_size,
});
}
if config.hop_size == 0 {
return Err(SpectrogramError::InvalidHopSize);
}
if config.window_size == 0 {
return Err(SpectrogramError::InvalidWindowSize);
}
let mut planner = FftPlanner::new();
let fft = planner.plan_fft_forward(config.window_size);
let window = make_window(config.window_fn, config.window_size);
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_with_plan(
&col,
sample_rate,
config,
&fft,
&window,
));
}
Ok(spectrograms)
}
/// Generate a window function.
///
/// ADR-154: the cosine windows divide by `(size - 1)`, which is zero for
/// `size == 1` (→ NaN samples) and underflows the empty-range maths for tiny
/// sizes. We short-circuit `size <= 1` to a safe constant window (empty for 0,
/// single unit sample for 1) before any `size - 1` arithmetic runs.
fn make_window(kind: WindowFunction, size: usize) -> Vec<f64> {
if size <= 1 {
return vec![1.0; size];
}
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(),
}
}
/// Apply attention-gating to a computed CSI spectrogram using ruvector-attn-mincut.
///
/// Treats each time frame as an attention token (d = n_freq_bins features,
/// seq_len = n_time_frames tokens). Self-attention (Q=K=V) gates coherent
/// body-motion frames and suppresses uncorrelated noise/interference frames.
///
/// # Arguments
/// * `spectrogram` - Row-major [n_freq_bins × n_time_frames] f32 slice
/// * `n_freq` - Number of frequency bins (feature dimension d)
/// * `n_time` - Number of time frames (sequence length)
/// * `lambda` - Gating strength: 0.1 = mild, 0.3 = moderate, 0.5 = aggressive
///
/// # Returns
/// Gated spectrogram as Vec<f32>, same shape as input
pub fn gate_spectrogram(
spectrogram: &[f32],
n_freq: usize,
n_time: usize,
lambda: f32,
) -> Vec<f32> {
debug_assert_eq!(
spectrogram.len(),
n_freq * n_time,
"spectrogram length must equal n_freq * n_time"
);
if n_freq == 0 || n_time == 0 {
return spectrogram.to_vec();
}
// Q = K = V = spectrogram (self-attention over time frames)
let result = attn_mincut(
spectrogram,
spectrogram,
spectrogram,
n_freq, // d = feature dimension
n_time, // seq_len = time tokens
lambda,
/*tau=*/ 2,
/*eps=*/ 1e-7_f32,
);
result.output
}
/// 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));
}
// ADR-154: degenerate window sizes must not divide by (n-1)==0 → NaN.
#[test]
fn make_window_size_0_and_1_are_safe() {
for wf in [
WindowFunction::Hann,
WindowFunction::Hamming,
WindowFunction::Blackman,
WindowFunction::Rectangular,
] {
assert!(make_window(wf, 0).is_empty(), "{wf:?} size-0 must be empty");
let w1 = make_window(wf, 1);
assert_eq!(w1.len(), 1, "{wf:?} size-1 must have one sample");
assert!(
w1[0].is_finite() && (w1[0] - 1.0).abs() < 1e-12,
"{wf:?} size-1 must be a finite unit sample, got {}",
w1[0]
);
}
}
#[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);
}
}
// ADR-154 §7.4 #20: the FFT-planner hoist in
// `compute_multi_subcarrier_spectrogram` must produce **bit-identical**
// output to calling `compute_spectrogram` (fresh planner) per subcarrier.
// We compare `f64::to_bits` of every spectrogram value across several
// window functions and a realistic 56-subcarrier CSI matrix — the planner
// change only reorders *when* the (identical) plan is built, never the math.
#[test]
fn multi_subcarrier_hoisted_plan_bit_identical() {
let n_samples = 600;
let n_sc = 56; // canonical-56 grid — the production subcarrier count
let sample_rate = 100.0;
let csi = Array2::from_shape_fn((n_samples, n_sc), |(t, sc)| {
// Deterministic, non-trivial per-subcarrier content.
let freq = 0.7 + sc as f64 * 0.13;
(2.0 * PI * freq * t as f64 / sample_rate).sin()
+ 0.3 * (2.0 * PI * (freq * 2.1) * t as f64 / sample_rate).cos()
});
for window_fn in [
WindowFunction::Hann,
WindowFunction::Hamming,
WindowFunction::Blackman,
WindowFunction::Rectangular,
] {
for &power in &[true, false] {
let config = SpectrogramConfig {
window_size: 128,
hop_size: 37, // non-divisor hop to exercise frame edges
window_fn,
power,
};
// AFTER: hoisted-plan path.
let hoisted =
compute_multi_subcarrier_spectrogram(&csi, sample_rate, &config).unwrap();
// BEFORE: independent per-subcarrier fresh-planner path.
let reference: Vec<Spectrogram> = (0..n_sc)
.map(|sc| {
let col: Vec<f64> = csi.column(sc).to_vec();
compute_spectrogram(&col, sample_rate, &config).unwrap()
})
.collect();
assert_eq!(hoisted.len(), reference.len());
for (sc, (h, r)) in hoisted.iter().zip(reference.iter()).enumerate() {
assert_eq!(h.data.dim(), r.data.dim(), "dim sc={sc} {window_fn:?}");
for (a, b) in h.data.iter().zip(r.data.iter()) {
assert_eq!(
a.to_bits(),
b.to_bits(),
"bit mismatch sc={sc} {window_fn:?} power={power}: {a} vs {b}"
);
}
assert_eq!(h.freq_resolution.to_bits(), r.freq_resolution.to_bits());
assert_eq!(h.time_resolution.to_bits(), r.time_resolution.to_bits());
}
}
}
}
}
#[cfg(test)]
mod gate_tests {
use super::*;
#[test]
fn gate_spectrogram_preserves_shape() {
let n_freq = 16_usize;
let n_time = 10_usize;
let spectrogram: Vec<f32> = (0..n_freq * n_time).map(|i| i as f32 * 0.01).collect();
let gated = gate_spectrogram(&spectrogram, n_freq, n_time, 0.3);
assert_eq!(gated.len(), n_freq * n_time);
}
#[test]
fn gate_spectrogram_zero_lambda_is_identity_ish() {
let n_freq = 8_usize;
let n_time = 4_usize;
let spectrogram: Vec<f32> = vec![1.0; n_freq * n_time];
// Uniform input — gated output should also be approximately uniform
let gated = gate_spectrogram(&spectrogram, n_freq, n_time, 0.01);
assert_eq!(gated.len(), n_freq * n_time);
// All values should be finite
assert!(gated.iter().all(|x| x.is_finite()));
}
}