mirror of
https://github.com/ruvnet/RuView
synced 2026-08-11 20:41:44 +00:00
8c24b8bdfe
* refactor(signal): de-magic motion.rs tuning constants (ADR-154 §7.4 #18) Lift the bare fusion weights, normalization scales, confidence-indicator weights, and adaptive-threshold clamp bounds in motion.rs out of the scoring functions into named, documented EMPIRICAL-DEFAULT consts. Values are bit-identical to the prior literals — this is cleanup, no behaviour change. Adds boundary/characterization tests pinning current behaviour: - motion_tuning_consts_unchanged_from_literals (consts == old literals) - doppler_component_saturates_at_full_scale (/100 then clamp(0,1)) - correlation_score_zero_below_n2_boundary (n<2 guard) - temporal_variance_zero_below_two_history (len<2 guard) - adaptive_threshold_engages_at_history_boundary (history 9 vs 10) Co-Authored-By: claude-flow <ruv@ruv.net> * refactor(signal): gesture.rs euclidean length guard + de-magic (ADR-154 §7.4 #12) - Add a debug_assert! to euclidean_distance documenting the same-dimension caller contract: zip() silently truncates on a length mismatch, so a mismatch is now loud in debug builds while the release operating path and output are unchanged. - De-magic the bare 1e-10 confidence epsilon into a documented const CONFIDENCE_SECOND_BEST_EPSILON (value unchanged). Tests pinning current behaviour: - confidence_epsilon_unchanged_from_literal - dtw_empty_sequence_is_infinite (n=0/m=0 boundary) - euclidean_distance_equal_length_is_l2 (same-dim contract) Co-Authored-By: claude-flow <ruv@ruv.net> * refactor(signal): de-magic longitudinal.rs drift thresholds (ADR-154 §7.4) Lift the bare drift-detection literals (7-day baseline, 2-sigma z-score, 3-day sustained, 7-day escalation, EMA alpha, cosine epsilon) into named, documented EMPIRICAL-DEFAULT consts encoding the module's Key Invariants. The duplicated `>= 7` in is_ready/is_ready_at now share one const. EMA alpha kept as the exact 0.05 literal (1.0 - 0.95_f32 is not bit-identical in f32). Values unchanged. Tests: - drift_consts_unchanged_from_literals - is_ready_at_day_boundary (day 6 vs 7) - cosine_similarity_zero_vector_is_zero (zero-norm guard) Co-Authored-By: claude-flow <ruv@ruv.net> * refactor(signal): de-magic division/zero-norm epsilons + boundary tests (ADR-154 §7.4) De-magic the bare division-guard epsilons in four modules into named, documented consts (values unchanged) and pin the previously-untested zero-norm / zero-variance / degenerate boundaries: - cross_room.rs: COSINE_SIMILARITY_EPSILON (1e-9) + test_cosine_similarity_zero_vector - multiband.rs: PEARSON_DENOMINATOR_EPSILON (1e-12) + pearson_correlation_zero_variance - intention.rs: LEAD_TIME_MIN_ACCEL (1e-10) + lead_time_zero_for_static_stream - hampel.rs: ZERO_MAD_EPSILON (1e-15) + test_zero_half_window_error + test_zero_mad_constant_window; documented hampel_filter # Errors Each module also gets a *_unchanged_from_literal const-pin test. Co-Authored-By: claude-flow <ruv@ruv.net> * refactor(signal): de-magic rf_slam + attractor_drift constants (ADR-154 §7.4) rf_slam.rs: - NS_PER_DAY (86_400_000_000_000.0), MIGRATION_MIN_SPAN_DAYS (1e-9), and the fixed-map defaults (FIXED_MAP_ASSOC_RADIUS_M/MIN_SIGHTINGS/MIN_COHERENCE) lifted out of inline literals (values unchanged). - migration_zero_span_is_zero_rate pins the single-sighting zero-span guard. attractor_drift.rs: - METRIC_BUFFER_CAPACITY (365), STABLE_CENTER_WINDOW (10) de-magicked. - Documented the implicit recent.len()>=1 divide-safety in the PointAttractor branch (guaranteed by the count < min_observations guard). - analyze_min_observations_boundary pins the off-by-one boundary. Each module gets a *_consts_unchanged_from_literals pin test. Co-Authored-By: claude-flow <ruv@ruv.net> * refactor(signal): de-magic coherence.rs variance floor + default decay (ADR-154 §7.4) Completes the M1 #9 de-magic for coherence.rs: the four bare 1e-6 variance-floor literals (update_reference floor + coherence_score/per_subcarrier_zscores epsilon) collapse to one VARIANCE_FLOOR const, and the inline 0.95 default decay becomes DEFAULT_EMA_DECAY. Values unchanged. Tests: - drift_consts_unchanged_from_literals extended (VARIANCE_FLOOR, DEFAULT_EMA_DECAY) - coherence_score_finite_with_zero_variance pins the floor's effect Co-Authored-By: claude-flow <ruv@ruv.net> * refactor(signal): de-magic calibration.rs thresholds + min-frames default (ADR-154 §7.4 #2) Lift the bare calibration literals into named EMPIRICAL-DEFAULT consts (values unchanged, bit-identical; calibration is off the Python proof path): - DEFAULT_MIN_FRAMES (600) — was repeated across all four tier constructors - AMP_STD_FLOOR (1e-12) z-score divisor floor - MOTION_AMP_Z_THRESHOLD (2.0) / MOTION_PHASE_DRIFT_THRESHOLD (π/6) — the two motion_flagged sites now share one definition - SUBTRACT_MIN_NORM (1e-30) baseline-subtraction guard Test calibration_consts_unchanged_from_literals pins all five and asserts every tier constructor shares DEFAULT_MIN_FRAMES. Co-Authored-By: claude-flow <ruv@ruv.net> * refactor(signal): de-magic fusion_quality + temporal_gesture constants (ADR-154 §7.4) fusion_quality.rs: - CONTRADICTION_PENALTY (0.8) and CONTRADICTION_BOUND_HALFWIDTH (0.1) named. - no_contradiction_is_identity pins the n=0 boundary (penalty 0.8^0 = 1.0, zero-width bounds). temporal_gesture.rs: - CONFIDENCE_SECOND_BEST_EPSILON (1e-10, mirrors gesture.rs) and NORM_QUANTIZATION_SCALE (1000.0) named. Each module gets a *_consts_unchanged_from_literals pin test. Values unchanged. Co-Authored-By: claude-flow <ruv@ruv.net> * docs(adr-154): record Milestone-3 — §7.4 row #21-45 P3 backlog cleared Replace the lumped #21-45 backlog row with the enumerated M3 resolution: 22 magic constants de-magicked into named EMPIRICAL-DEFAULT consts (each pinned == prior literal), 6 boundary/characterization tests, ~4 doc-only, across 11 modules; not-real findings reported + skipped (unreachable attractor_drift div0, non-existent gesture thresholds, proof-path features.rs). Update residual P3 rows #2/#12/#17/#18 to RESOLVED, the deferred count (36 -> 0), the scope field, and the Horizon-ledger one-liner. §7.4 backlog fully cleared across M0-M3. CHANGELOG [Unreleased] entry added. Validation: signal lib --no-default-features 476/0/1; --features cir 476/0; workspace 3,275/0; Python proof PASS, hash f8e76f21...46f7a UNCHANGED. Co-Authored-By: claude-flow <ruv@ruv.net> --------- Co-authored-by: ruv <ruvnet@gmail.com>
471 lines
15 KiB
Rust
471 lines
15 KiB
Rust
//! Multi-Band CSI Frame Fusion (ADR-029 Section 2.3)
|
|
//!
|
|
//! Aggregates per-channel CSI frames from channel-hopping into a wideband
|
|
//! virtual snapshot. An ESP32-S3 cycling through channels 1/6/11 at 50 ms
|
|
//! dwell per channel yields 3 canonical-56 CSI rows per sensing cycle.
|
|
//! This module fuses them into a single `MultiBandCsiFrame` annotated with
|
|
//! center frequencies and cross-channel coherence.
|
|
//!
|
|
//! # RuVector Integration
|
|
//!
|
|
//! - `ruvector-attention` for cross-channel feature weighting (future)
|
|
|
|
use crate::hardware_norm::CanonicalCsiFrame;
|
|
|
|
/// Errors from multi-band frame fusion.
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum MultiBandError {
|
|
/// No channel frames provided.
|
|
#[error("No channel frames provided for multi-band fusion")]
|
|
NoFrames,
|
|
|
|
/// Mismatched subcarrier counts across channels.
|
|
#[error("Subcarrier count mismatch: channel {channel_idx} has {got}, expected {expected}")]
|
|
SubcarrierMismatch {
|
|
channel_idx: usize,
|
|
expected: usize,
|
|
got: usize,
|
|
},
|
|
|
|
/// Frequency list length does not match frame count.
|
|
#[error("Frequency count ({freq_count}) does not match frame count ({frame_count})")]
|
|
FrequencyCountMismatch {
|
|
freq_count: usize,
|
|
frame_count: usize,
|
|
},
|
|
|
|
/// Duplicate frequency in channel list.
|
|
#[error("Duplicate frequency {freq_mhz} MHz at index {idx}")]
|
|
DuplicateFrequency { freq_mhz: u32, idx: usize },
|
|
}
|
|
|
|
/// Fused multi-band CSI from one node at one time slot.
|
|
///
|
|
/// Holds one canonical-56 row per channel, ordered by center frequency.
|
|
/// The `coherence` field quantifies agreement across channels (0.0-1.0).
|
|
#[derive(Debug, Clone)]
|
|
pub struct MultiBandCsiFrame {
|
|
/// Originating node identifier (0-255).
|
|
pub node_id: u8,
|
|
/// Timestamp of the sensing cycle in microseconds.
|
|
pub timestamp_us: u64,
|
|
/// One canonical-56 CSI frame per channel, ordered by center frequency.
|
|
pub channel_frames: Vec<CanonicalCsiFrame>,
|
|
/// Center frequencies (MHz) for each channel row.
|
|
pub frequencies_mhz: Vec<u32>,
|
|
/// Cross-channel coherence score (0.0-1.0).
|
|
pub coherence: f32,
|
|
}
|
|
|
|
/// Configuration for the multi-band fusion process.
|
|
#[derive(Debug, Clone)]
|
|
pub struct MultiBandConfig {
|
|
/// Time window in microseconds within which frames are considered
|
|
/// part of the same sensing cycle.
|
|
pub window_us: u64,
|
|
/// Expected number of channels per cycle.
|
|
pub expected_channels: usize,
|
|
/// Minimum coherence to accept the fused frame.
|
|
pub min_coherence: f32,
|
|
}
|
|
|
|
impl Default for MultiBandConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
window_us: 200_000, // 200 ms default window
|
|
expected_channels: 3,
|
|
min_coherence: 0.3,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Builder for constructing a `MultiBandCsiFrame` from per-channel observations.
|
|
#[derive(Debug)]
|
|
pub struct MultiBandBuilder {
|
|
node_id: u8,
|
|
timestamp_us: u64,
|
|
frames: Vec<CanonicalCsiFrame>,
|
|
frequencies: Vec<u32>,
|
|
}
|
|
|
|
impl MultiBandBuilder {
|
|
/// Create a new builder for the given node and timestamp.
|
|
pub fn new(node_id: u8, timestamp_us: u64) -> Self {
|
|
Self {
|
|
node_id,
|
|
timestamp_us,
|
|
frames: Vec::new(),
|
|
frequencies: Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Add a channel observation at the given center frequency.
|
|
pub fn add_channel(mut self, frame: CanonicalCsiFrame, freq_mhz: u32) -> Self {
|
|
self.frames.push(frame);
|
|
self.frequencies.push(freq_mhz);
|
|
self
|
|
}
|
|
|
|
/// Build the fused multi-band frame.
|
|
///
|
|
/// Validates inputs, sorts by frequency, and computes cross-channel coherence.
|
|
pub fn build(mut self) -> std::result::Result<MultiBandCsiFrame, MultiBandError> {
|
|
if self.frames.is_empty() {
|
|
return Err(MultiBandError::NoFrames);
|
|
}
|
|
|
|
if self.frequencies.len() != self.frames.len() {
|
|
return Err(MultiBandError::FrequencyCountMismatch {
|
|
freq_count: self.frequencies.len(),
|
|
frame_count: self.frames.len(),
|
|
});
|
|
}
|
|
|
|
// Check for duplicate frequencies
|
|
for i in 0..self.frequencies.len() {
|
|
for j in (i + 1)..self.frequencies.len() {
|
|
if self.frequencies[i] == self.frequencies[j] {
|
|
return Err(MultiBandError::DuplicateFrequency {
|
|
freq_mhz: self.frequencies[i],
|
|
idx: j,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate consistent subcarrier counts
|
|
let expected_len = self.frames[0].amplitude.len();
|
|
for (i, frame) in self.frames.iter().enumerate().skip(1) {
|
|
if frame.amplitude.len() != expected_len {
|
|
return Err(MultiBandError::SubcarrierMismatch {
|
|
channel_idx: i,
|
|
expected: expected_len,
|
|
got: frame.amplitude.len(),
|
|
});
|
|
}
|
|
}
|
|
|
|
// Sort frames by frequency
|
|
let mut indices: Vec<usize> = (0..self.frames.len()).collect();
|
|
indices.sort_by_key(|&i| self.frequencies[i]);
|
|
|
|
let sorted_frames: Vec<CanonicalCsiFrame> =
|
|
indices.iter().map(|&i| self.frames[i].clone()).collect();
|
|
let sorted_freqs: Vec<u32> = indices.iter().map(|&i| self.frequencies[i]).collect();
|
|
|
|
self.frames = sorted_frames;
|
|
self.frequencies = sorted_freqs;
|
|
|
|
// Compute cross-channel coherence
|
|
let coherence = compute_cross_channel_coherence(&self.frames);
|
|
|
|
Ok(MultiBandCsiFrame {
|
|
node_id: self.node_id,
|
|
timestamp_us: self.timestamp_us,
|
|
channel_frames: self.frames,
|
|
frequencies_mhz: self.frequencies,
|
|
coherence,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Compute cross-channel coherence as the mean pairwise Pearson correlation
|
|
/// of amplitude vectors across all channel pairs.
|
|
///
|
|
/// Returns a value in [0.0, 1.0] where 1.0 means perfect correlation.
|
|
fn compute_cross_channel_coherence(frames: &[CanonicalCsiFrame]) -> f32 {
|
|
if frames.len() < 2 {
|
|
return 1.0; // single channel is trivially coherent
|
|
}
|
|
|
|
let mut total_corr = 0.0_f64;
|
|
let mut pair_count = 0u32;
|
|
|
|
for i in 0..frames.len() {
|
|
for j in (i + 1)..frames.len() {
|
|
let corr = pearson_correlation_f32(&frames[i].amplitude, &frames[j].amplitude);
|
|
total_corr += corr as f64;
|
|
pair_count += 1;
|
|
}
|
|
}
|
|
|
|
if pair_count == 0 {
|
|
return 1.0;
|
|
}
|
|
|
|
// Map correlation [-1, 1] to coherence [0, 1]
|
|
let mean_corr = total_corr / pair_count as f64;
|
|
((mean_corr + 1.0) / 2.0).clamp(0.0, 1.0) as f32
|
|
}
|
|
|
|
/// Denominator guard for the Pearson correlation (ADR-154 §7.4 — de-magicked):
|
|
/// a product of standard deviations below this is treated as a zero-variance
|
|
/// (constant) input ⇒ correlation 0.0.
|
|
const PEARSON_DENOMINATOR_EPSILON: f32 = 1e-12;
|
|
|
|
/// Pearson correlation coefficient between two f32 slices.
|
|
///
|
|
/// Returns `0.0` for empty inputs or when either slice has (near-)zero
|
|
/// variance (the denominator falls below [`PEARSON_DENOMINATOR_EPSILON`]).
|
|
fn pearson_correlation_f32(a: &[f32], b: &[f32]) -> f32 {
|
|
let n = a.len().min(b.len());
|
|
if n == 0 {
|
|
return 0.0;
|
|
}
|
|
|
|
let n_f = n as f32;
|
|
let mean_a: f32 = a[..n].iter().sum::<f32>() / n_f;
|
|
let mean_b: f32 = b[..n].iter().sum::<f32>() / n_f;
|
|
|
|
let mut cov = 0.0_f32;
|
|
let mut var_a = 0.0_f32;
|
|
let mut var_b = 0.0_f32;
|
|
|
|
for i in 0..n {
|
|
let da = a[i] - mean_a;
|
|
let db = b[i] - mean_b;
|
|
cov += da * db;
|
|
var_a += da * da;
|
|
var_b += db * db;
|
|
}
|
|
|
|
let denom = (var_a * var_b).sqrt();
|
|
if denom < PEARSON_DENOMINATOR_EPSILON {
|
|
return 0.0;
|
|
}
|
|
|
|
(cov / denom).clamp(-1.0, 1.0)
|
|
}
|
|
|
|
/// Concatenate the amplitude vectors from all channels into a single
|
|
/// wideband amplitude vector. Useful for downstream models that expect
|
|
/// a flat feature vector.
|
|
pub fn concatenate_amplitudes(frame: &MultiBandCsiFrame) -> Vec<f32> {
|
|
let total_len: usize = frame.channel_frames.iter().map(|f| f.amplitude.len()).sum();
|
|
let mut out = Vec::with_capacity(total_len);
|
|
for cf in &frame.channel_frames {
|
|
out.extend_from_slice(&cf.amplitude);
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Compute the mean amplitude across all channels, producing a single
|
|
/// canonical-length vector that averages multi-band observations.
|
|
pub fn mean_amplitude(frame: &MultiBandCsiFrame) -> Vec<f32> {
|
|
if frame.channel_frames.is_empty() {
|
|
return Vec::new();
|
|
}
|
|
|
|
let n_sub = frame.channel_frames[0].amplitude.len();
|
|
let n_ch = frame.channel_frames.len() as f32;
|
|
let mut mean = vec![0.0_f32; n_sub];
|
|
|
|
for cf in &frame.channel_frames {
|
|
for (i, &val) in cf.amplitude.iter().enumerate() {
|
|
if i < n_sub {
|
|
mean[i] += val;
|
|
}
|
|
}
|
|
}
|
|
|
|
for v in &mut mean {
|
|
*v /= n_ch;
|
|
}
|
|
|
|
mean
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::hardware_norm::HardwareType;
|
|
|
|
fn make_canonical(amplitude: Vec<f32>, phase: Vec<f32>) -> CanonicalCsiFrame {
|
|
CanonicalCsiFrame {
|
|
amplitude,
|
|
phase,
|
|
hardware_type: HardwareType::Esp32S3,
|
|
}
|
|
}
|
|
|
|
fn make_frame(n_sub: usize, scale: f32) -> CanonicalCsiFrame {
|
|
let amp: Vec<f32> = (0..n_sub).map(|i| scale * (i as f32 * 0.1).sin()).collect();
|
|
let phase: Vec<f32> = (0..n_sub).map(|i| (i as f32 * 0.05).cos()).collect();
|
|
make_canonical(amp, phase)
|
|
}
|
|
|
|
#[test]
|
|
fn build_single_channel() {
|
|
let frame = MultiBandBuilder::new(0, 1000)
|
|
.add_channel(make_frame(56, 1.0), 2412)
|
|
.build()
|
|
.unwrap();
|
|
assert_eq!(frame.node_id, 0);
|
|
assert_eq!(frame.timestamp_us, 1000);
|
|
assert_eq!(frame.channel_frames.len(), 1);
|
|
assert_eq!(frame.frequencies_mhz, vec![2412]);
|
|
assert!((frame.coherence - 1.0).abs() < f32::EPSILON);
|
|
}
|
|
|
|
#[test]
|
|
fn build_three_channels_sorted_by_freq() {
|
|
let frame = MultiBandBuilder::new(1, 2000)
|
|
.add_channel(make_frame(56, 1.0), 2462) // ch 11
|
|
.add_channel(make_frame(56, 1.0), 2412) // ch 1
|
|
.add_channel(make_frame(56, 1.0), 2437) // ch 6
|
|
.build()
|
|
.unwrap();
|
|
assert_eq!(frame.frequencies_mhz, vec![2412, 2437, 2462]);
|
|
assert_eq!(frame.channel_frames.len(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_frames_error() {
|
|
let result = MultiBandBuilder::new(0, 0).build();
|
|
assert!(matches!(result, Err(MultiBandError::NoFrames)));
|
|
}
|
|
|
|
#[test]
|
|
fn subcarrier_mismatch_error() {
|
|
let result = MultiBandBuilder::new(0, 0)
|
|
.add_channel(make_frame(56, 1.0), 2412)
|
|
.add_channel(make_frame(30, 1.0), 2437)
|
|
.build();
|
|
assert!(matches!(
|
|
result,
|
|
Err(MultiBandError::SubcarrierMismatch { .. })
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_frequency_error() {
|
|
let result = MultiBandBuilder::new(0, 0)
|
|
.add_channel(make_frame(56, 1.0), 2412)
|
|
.add_channel(make_frame(56, 1.0), 2412)
|
|
.build();
|
|
assert!(matches!(
|
|
result,
|
|
Err(MultiBandError::DuplicateFrequency { .. })
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn coherence_identical_channels() {
|
|
let f = make_frame(56, 1.0);
|
|
let frame = MultiBandBuilder::new(0, 0)
|
|
.add_channel(f.clone(), 2412)
|
|
.add_channel(f.clone(), 2437)
|
|
.build()
|
|
.unwrap();
|
|
// Identical channels should have coherence == 1.0
|
|
assert!((frame.coherence - 1.0).abs() < 0.01);
|
|
}
|
|
|
|
#[test]
|
|
fn coherence_orthogonal_channels() {
|
|
let n = 56;
|
|
let amp_a: Vec<f32> = (0..n).map(|i| (i as f32 * 0.3).sin()).collect();
|
|
let amp_b: Vec<f32> = (0..n).map(|i| (i as f32 * 0.3).cos()).collect();
|
|
let ph = vec![0.0_f32; n];
|
|
|
|
let frame = MultiBandBuilder::new(0, 0)
|
|
.add_channel(make_canonical(amp_a, ph.clone()), 2412)
|
|
.add_channel(make_canonical(amp_b, ph), 2437)
|
|
.build()
|
|
.unwrap();
|
|
// Orthogonal signals should produce lower coherence
|
|
assert!(frame.coherence < 0.9);
|
|
}
|
|
|
|
#[test]
|
|
fn concatenate_amplitudes_correct_length() {
|
|
let frame = MultiBandBuilder::new(0, 0)
|
|
.add_channel(make_frame(56, 1.0), 2412)
|
|
.add_channel(make_frame(56, 2.0), 2437)
|
|
.add_channel(make_frame(56, 3.0), 2462)
|
|
.build()
|
|
.unwrap();
|
|
let concat = concatenate_amplitudes(&frame);
|
|
assert_eq!(concat.len(), 56 * 3);
|
|
}
|
|
|
|
#[test]
|
|
fn mean_amplitude_correct() {
|
|
let n = 4;
|
|
let f1 = make_canonical(vec![1.0, 2.0, 3.0, 4.0], vec![0.0; n]);
|
|
let f2 = make_canonical(vec![3.0, 4.0, 5.0, 6.0], vec![0.0; n]);
|
|
let frame = MultiBandBuilder::new(0, 0)
|
|
.add_channel(f1, 2412)
|
|
.add_channel(f2, 2437)
|
|
.build()
|
|
.unwrap();
|
|
let m = mean_amplitude(&frame);
|
|
assert_eq!(m.len(), 4);
|
|
assert!((m[0] - 2.0).abs() < 1e-6);
|
|
assert!((m[1] - 3.0).abs() < 1e-6);
|
|
assert!((m[2] - 4.0).abs() < 1e-6);
|
|
assert!((m[3] - 5.0).abs() < 1e-6);
|
|
}
|
|
|
|
#[test]
|
|
fn mean_amplitude_empty() {
|
|
let frame = MultiBandCsiFrame {
|
|
node_id: 0,
|
|
timestamp_us: 0,
|
|
channel_frames: vec![],
|
|
frequencies_mhz: vec![],
|
|
coherence: 1.0,
|
|
};
|
|
assert!(mean_amplitude(&frame).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn pearson_correlation_perfect() {
|
|
let a = vec![1.0_f32, 2.0, 3.0, 4.0, 5.0];
|
|
let b = vec![2.0_f32, 4.0, 6.0, 8.0, 10.0];
|
|
let r = pearson_correlation_f32(&a, &b);
|
|
assert!((r - 1.0).abs() < 1e-5);
|
|
}
|
|
|
|
#[test]
|
|
fn pearson_correlation_negative() {
|
|
let a = vec![1.0_f32, 2.0, 3.0, 4.0, 5.0];
|
|
let b = vec![5.0_f32, 4.0, 3.0, 2.0, 1.0];
|
|
let r = pearson_correlation_f32(&a, &b);
|
|
assert!((r + 1.0).abs() < 1e-5);
|
|
}
|
|
|
|
#[test]
|
|
fn pearson_correlation_empty() {
|
|
assert_eq!(pearson_correlation_f32(&[], &[]), 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn default_config() {
|
|
let cfg = MultiBandConfig::default();
|
|
assert_eq!(cfg.expected_channels, 3);
|
|
assert_eq!(cfg.window_us, 200_000);
|
|
assert!((cfg.min_coherence - 0.3).abs() < f32::EPSILON);
|
|
}
|
|
|
|
// -- ADR-154 §7.4: de-magic-constant + boundary characterization tests.
|
|
|
|
/// De-magicked denominator epsilon must equal the prior literal.
|
|
#[test]
|
|
fn pearson_epsilon_unchanged_from_literal() {
|
|
assert_eq!(PEARSON_DENOMINATOR_EPSILON, 1e-12_f32);
|
|
}
|
|
|
|
/// A constant (zero-variance) input makes the denominator fall below the
|
|
/// epsilon ⇒ correlation 0.0. Previously untested (existing tests use
|
|
/// non-constant inputs).
|
|
#[test]
|
|
fn pearson_correlation_zero_variance() {
|
|
let constant = vec![3.0_f32; 5];
|
|
let varying = vec![1.0_f32, 2.0, 3.0, 4.0, 5.0];
|
|
assert_eq!(pearson_correlation_f32(&constant, &varying), 0.0);
|
|
assert_eq!(pearson_correlation_f32(&varying, &constant), 0.0);
|
|
assert_eq!(pearson_correlation_f32(&constant, &constant), 0.0);
|
|
}
|
|
}
|