//! Common utility functions for the WiFi-DensePose system. //! //! This module provides helper functions used throughout the crate. use ndarray::{Array1, Array2}; use num_complex::Complex64; /// Computes the magnitude (absolute value) of complex numbers. #[must_use] pub fn complex_magnitude(data: &Array2) -> Array2 { data.mapv(num_complex::Complex::norm) } /// Computes the phase (argument) of complex numbers in radians. #[must_use] pub fn complex_phase(data: &Array2) -> Array2 { data.mapv(num_complex::Complex::arg) } /// Unwraps phase values to remove discontinuities. /// /// Phase unwrapping corrects for the 2*pi jumps that occur when phase /// values wrap around from pi to -pi. #[must_use] pub fn unwrap_phase(phase: &Array1) -> Array1 { let mut unwrapped = phase.clone(); let pi = std::f64::consts::PI; let two_pi = 2.0 * pi; for i in 1..unwrapped.len() { let diff = unwrapped[i] - unwrapped[i - 1]; if diff > pi { for j in i..unwrapped.len() { unwrapped[j] -= two_pi; } } else if diff < -pi { for j in i..unwrapped.len() { unwrapped[j] += two_pi; } } } unwrapped } /// Normalizes values to the range [0, 1]. #[must_use] pub fn normalize_min_max(data: &Array1) -> Array1 { let min = data.iter().copied().fold(f64::INFINITY, f64::min); let max = data.iter().copied().fold(f64::NEG_INFINITY, f64::max); if (max - min).abs() < f64::EPSILON { return Array1::zeros(data.len()); } data.mapv(|x| (x - min) / (max - min)) } /// Normalizes values using z-score normalization. #[must_use] pub fn normalize_zscore(data: &Array1) -> Array1 { let mean = data.mean().unwrap_or(0.0); let std = data.std(0.0); if std.abs() < f64::EPSILON { return Array1::zeros(data.len()); } data.mapv(|x| (x - mean) / std) } /// Calculates the Signal-to-Noise Ratio in dB. #[must_use] #[allow(clippy::cast_precision_loss)] pub fn calculate_snr_db(signal: &Array1, noise: &Array1) -> f64 { let signal_power: f64 = signal.iter().map(|x| x * x).sum::() / signal.len() as f64; let noise_power: f64 = noise.iter().map(|x| x * x).sum::() / noise.len() as f64; if noise_power.abs() < f64::EPSILON { return f64::INFINITY; } 10.0 * (signal_power / noise_power).log10() } /// Applies a moving average filter. /// /// # Panics /// /// Panics if the data array is not contiguous in memory. #[must_use] #[allow(clippy::cast_precision_loss)] pub fn moving_average(data: &Array1, window_size: usize) -> Array1 { if window_size == 0 || window_size > data.len() { return data.clone(); } let mut result = Array1::zeros(data.len()); let half_window = window_size / 2; // ndarray Array1 is always contiguous, but handle gracefully if not let Some(slice) = data.as_slice() else { return data.clone(); }; for i in 0..data.len() { let start = i.saturating_sub(half_window); let end = (i + half_window + 1).min(data.len()); let window = &slice[start..end]; result[i] = window.iter().sum::() / window.len() as f64; } result } /// Clamps a value to a range. #[must_use] pub fn clamp(value: T, min: T, max: T) -> T { if value < min { min } else if value > max { max } else { value } } /// Linearly interpolates between two values. #[must_use] pub fn lerp(a: f64, b: f64, t: f64) -> f64 { (b - a).mul_add(t, a) } /// Converts degrees to radians. #[must_use] pub fn deg_to_rad(degrees: f64) -> f64 { degrees.to_radians() } /// Converts radians to degrees. #[must_use] pub fn rad_to_deg(radians: f64) -> f64 { radians.to_degrees() } /// Calculates the Euclidean distance between two points. #[must_use] pub fn euclidean_distance(p1: (f64, f64), p2: (f64, f64)) -> f64 { let dx = p2.0 - p1.0; let dy = p2.1 - p1.1; dx.hypot(dy) } /// Calculates the Euclidean distance in 3D. #[must_use] pub fn euclidean_distance_3d(p1: (f64, f64, f64), p2: (f64, f64, f64)) -> f64 { let dx = p2.0 - p1.0; let dy = p2.1 - p1.1; let dz = p2.2 - p1.2; (dx.mul_add(dx, dy.mul_add(dy, dz * dz))).sqrt() } #[cfg(test)] mod tests { use super::*; use ndarray::array; #[test] fn test_normalize_min_max() { let data = array![0.0, 5.0, 10.0]; let normalized = normalize_min_max(&data); assert!((normalized[0] - 0.0).abs() < 1e-10); assert!((normalized[1] - 0.5).abs() < 1e-10); assert!((normalized[2] - 1.0).abs() < 1e-10); } #[test] fn test_normalize_zscore() { let data = array![1.0, 2.0, 3.0, 4.0, 5.0]; let normalized = normalize_zscore(&data); // Mean should be approximately 0 assert!(normalized.mean().unwrap().abs() < 1e-10); } #[test] fn test_moving_average() { let data = array![1.0, 2.0, 3.0, 4.0, 5.0]; let smoothed = moving_average(&data, 3); // Middle value should be average of 2, 3, 4 assert!((smoothed[2] - 3.0).abs() < 1e-10); } #[test] fn test_clamp() { assert_eq!(clamp(5, 0, 10), 5); assert_eq!(clamp(-5, 0, 10), 0); assert_eq!(clamp(15, 0, 10), 10); } #[test] fn test_lerp() { assert!((lerp(0.0, 10.0, 0.5) - 5.0).abs() < 1e-10); assert!((lerp(0.0, 10.0, 0.0) - 0.0).abs() < 1e-10); assert!((lerp(0.0, 10.0, 1.0) - 10.0).abs() < 1e-10); } #[test] fn test_deg_rad_conversion() { let degrees = 180.0; let radians = deg_to_rad(degrees); assert!((radians - std::f64::consts::PI).abs() < 1e-10); let back = rad_to_deg(radians); assert!((back - degrees).abs() < 1e-10); } #[test] fn test_euclidean_distance() { let dist = euclidean_distance((0.0, 0.0), (3.0, 4.0)); assert!((dist - 5.0).abs() < 1e-10); } #[test] fn test_unwrap_phase() { let pi = std::f64::consts::PI; // Simulate a phase wrap let phase = array![0.0, pi / 2.0, pi, -pi + 0.1, -pi / 2.0]; let unwrapped = unwrap_phase(&phase); // After unwrapping, the phase should be monotonically increasing for i in 1..unwrapped.len() { // Allow some tolerance for the discontinuity correction assert!( unwrapped[i] >= unwrapped[i - 1] - 0.5, "Phase should be mostly increasing after unwrapping" ); } } #[test] fn test_snr_calculation() { let signal = array![1.0, 1.0, 1.0, 1.0]; let noise = array![0.1, 0.1, 0.1, 0.1]; let snr = calculate_snr_db(&signal, &noise); // SNR should be 20 dB (10 * log10(1/0.01) = 10 * log10(100) = 20) assert!((snr - 20.0).abs() < 1e-10); } }