|
| 1 | +//! Hardware Normalizer — ADR-027 MERIDIAN Phase 1 |
| 2 | +//! |
| 3 | +//! Cross-hardware CSI normalization so models trained on one WiFi chipset |
| 4 | +//! generalize to others. The normalizer detects hardware from subcarrier |
| 5 | +//! count, resamples to a canonical grid (default 56) via Catmull-Rom cubic |
| 6 | +//! interpolation, z-score normalizes amplitude, and sanitizes phase |
| 7 | +//! (unwrap + linear-trend removal). |
| 8 | +
|
| 9 | +use std::collections::HashMap; |
| 10 | +use std::f64::consts::PI; |
| 11 | +use thiserror::Error; |
| 12 | + |
| 13 | +/// Errors from hardware normalization. |
| 14 | +#[derive(Debug, Error)] |
| 15 | +pub enum HardwareNormError { |
| 16 | + #[error("Empty CSI frame (amplitude len={amp}, phase len={phase})")] |
| 17 | + EmptyFrame { amp: usize, phase: usize }, |
| 18 | + #[error("Amplitude/phase length mismatch ({amp} vs {phase})")] |
| 19 | + LengthMismatch { amp: usize, phase: usize }, |
| 20 | + #[error("Unknown hardware for subcarrier count {0}")] |
| 21 | + UnknownHardware(usize), |
| 22 | + #[error("Invalid canonical subcarrier count: {0}")] |
| 23 | + InvalidCanonical(usize), |
| 24 | +} |
| 25 | + |
| 26 | +/// Known WiFi chipset families with their subcarrier counts and MIMO configs. |
| 27 | +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] |
| 28 | +pub enum HardwareType { |
| 29 | + /// ESP32-S3 with LWIP CSI: 64 subcarriers, 1x1 SISO |
| 30 | + Esp32S3, |
| 31 | + /// Intel 5300 NIC: 30 subcarriers, up to 3x3 MIMO |
| 32 | + Intel5300, |
| 33 | + /// Atheros (ath9k/ath10k): 56 subcarriers, up to 3x3 MIMO |
| 34 | + Atheros, |
| 35 | + /// Generic / unknown hardware |
| 36 | + Generic, |
| 37 | +} |
| 38 | + |
| 39 | +impl HardwareType { |
| 40 | + /// Expected subcarrier count for this hardware. |
| 41 | + pub fn subcarrier_count(&self) -> usize { |
| 42 | + match self { |
| 43 | + Self::Esp32S3 => 64, |
| 44 | + Self::Intel5300 => 30, |
| 45 | + Self::Atheros => 56, |
| 46 | + Self::Generic => 56, |
| 47 | + } |
| 48 | + } |
| 49 | + |
| 50 | + /// Maximum MIMO spatial streams. |
| 51 | + pub fn mimo_streams(&self) -> usize { |
| 52 | + match self { |
| 53 | + Self::Esp32S3 => 1, |
| 54 | + Self::Intel5300 => 3, |
| 55 | + Self::Atheros => 3, |
| 56 | + Self::Generic => 1, |
| 57 | + } |
| 58 | + } |
| 59 | +} |
| 60 | + |
| 61 | +/// Per-hardware amplitude statistics for z-score normalization. |
| 62 | +#[derive(Debug, Clone)] |
| 63 | +pub struct AmplitudeStats { |
| 64 | + pub mean: f64, |
| 65 | + pub std: f64, |
| 66 | +} |
| 67 | + |
| 68 | +impl Default for AmplitudeStats { |
| 69 | + fn default() -> Self { |
| 70 | + Self { mean: 0.0, std: 1.0 } |
| 71 | + } |
| 72 | +} |
| 73 | + |
| 74 | +/// A CSI frame normalized to a canonical representation. |
| 75 | +#[derive(Debug, Clone)] |
| 76 | +pub struct CanonicalCsiFrame { |
| 77 | + /// Z-score normalized amplitude (length = canonical_subcarriers). |
| 78 | + pub amplitude: Vec<f32>, |
| 79 | + /// Sanitized phase: unwrapped, linear trend removed (length = canonical_subcarriers). |
| 80 | + pub phase: Vec<f32>, |
| 81 | + /// Hardware type that produced the original frame. |
| 82 | + pub hardware_type: HardwareType, |
| 83 | +} |
| 84 | + |
| 85 | +/// Normalizes CSI frames from heterogeneous hardware into a canonical form. |
| 86 | +#[derive(Debug)] |
| 87 | +pub struct HardwareNormalizer { |
| 88 | + canonical_subcarriers: usize, |
| 89 | + hw_stats: HashMap<HardwareType, AmplitudeStats>, |
| 90 | +} |
| 91 | + |
| 92 | +impl HardwareNormalizer { |
| 93 | + /// Create a normalizer with default canonical subcarrier count (56). |
| 94 | + pub fn new() -> Self { |
| 95 | + Self { canonical_subcarriers: 56, hw_stats: HashMap::new() } |
| 96 | + } |
| 97 | + |
| 98 | + /// Create a normalizer with a custom canonical subcarrier count. |
| 99 | + pub fn with_canonical_subcarriers(count: usize) -> Result<Self, HardwareNormError> { |
| 100 | + if count == 0 { |
| 101 | + return Err(HardwareNormError::InvalidCanonical(count)); |
| 102 | + } |
| 103 | + Ok(Self { canonical_subcarriers: count, hw_stats: HashMap::new() }) |
| 104 | + } |
| 105 | + |
| 106 | + /// Register amplitude statistics for a specific hardware type. |
| 107 | + pub fn set_hw_stats(&mut self, hw: HardwareType, stats: AmplitudeStats) { |
| 108 | + self.hw_stats.insert(hw, stats); |
| 109 | + } |
| 110 | + |
| 111 | + /// Return the canonical subcarrier count. |
| 112 | + pub fn canonical_subcarriers(&self) -> usize { |
| 113 | + self.canonical_subcarriers |
| 114 | + } |
| 115 | + |
| 116 | + /// Detect hardware type from subcarrier count. |
| 117 | + pub fn detect_hardware(subcarrier_count: usize) -> HardwareType { |
| 118 | + match subcarrier_count { |
| 119 | + 64 => HardwareType::Esp32S3, |
| 120 | + 30 => HardwareType::Intel5300, |
| 121 | + 56 => HardwareType::Atheros, |
| 122 | + _ => HardwareType::Generic, |
| 123 | + } |
| 124 | + } |
| 125 | + |
| 126 | + /// Normalize a raw CSI frame into canonical form. |
| 127 | + /// |
| 128 | + /// 1. Resample subcarriers to `canonical_subcarriers` via cubic interpolation |
| 129 | + /// 2. Z-score normalize amplitude (mean=0, std=1) |
| 130 | + /// 3. Sanitize phase: unwrap + remove linear trend |
| 131 | + pub fn normalize( |
| 132 | + &self, |
| 133 | + raw_amplitude: &[f64], |
| 134 | + raw_phase: &[f64], |
| 135 | + hw: HardwareType, |
| 136 | + ) -> Result<CanonicalCsiFrame, HardwareNormError> { |
| 137 | + if raw_amplitude.is_empty() || raw_phase.is_empty() { |
| 138 | + return Err(HardwareNormError::EmptyFrame { |
| 139 | + amp: raw_amplitude.len(), |
| 140 | + phase: raw_phase.len(), |
| 141 | + }); |
| 142 | + } |
| 143 | + if raw_amplitude.len() != raw_phase.len() { |
| 144 | + return Err(HardwareNormError::LengthMismatch { |
| 145 | + amp: raw_amplitude.len(), |
| 146 | + phase: raw_phase.len(), |
| 147 | + }); |
| 148 | + } |
| 149 | + |
| 150 | + let amp_resampled = resample_cubic(raw_amplitude, self.canonical_subcarriers); |
| 151 | + let phase_resampled = resample_cubic(raw_phase, self.canonical_subcarriers); |
| 152 | + let amp_normalized = zscore_normalize(&_resampled, self.hw_stats.get(&hw)); |
| 153 | + let phase_sanitized = sanitize_phase(&phase_resampled); |
| 154 | + |
| 155 | + Ok(CanonicalCsiFrame { |
| 156 | + amplitude: amp_normalized.iter().map(|&v| v as f32).collect(), |
| 157 | + phase: phase_sanitized.iter().map(|&v| v as f32).collect(), |
| 158 | + hardware_type: hw, |
| 159 | + }) |
| 160 | + } |
| 161 | +} |
| 162 | + |
| 163 | +impl Default for HardwareNormalizer { |
| 164 | + fn default() -> Self { Self::new() } |
| 165 | +} |
| 166 | + |
| 167 | +/// Resample a 1-D signal to `dst_len` using Catmull-Rom cubic interpolation. |
| 168 | +/// Identity passthrough when `src.len() == dst_len`. |
| 169 | +fn resample_cubic(src: &[f64], dst_len: usize) -> Vec<f64> { |
| 170 | + let n = src.len(); |
| 171 | + if n == dst_len { return src.to_vec(); } |
| 172 | + if n == 0 || dst_len == 0 { return vec![0.0; dst_len]; } |
| 173 | + if n == 1 { return vec![src[0]; dst_len]; } |
| 174 | + |
| 175 | + let ratio = (n - 1) as f64 / (dst_len - 1).max(1) as f64; |
| 176 | + (0..dst_len) |
| 177 | + .map(|i| { |
| 178 | + let x = i as f64 * ratio; |
| 179 | + let idx = x.floor() as isize; |
| 180 | + let t = x - idx as f64; |
| 181 | + let p0 = src[clamp_idx(idx - 1, n)]; |
| 182 | + let p1 = src[clamp_idx(idx, n)]; |
| 183 | + let p2 = src[clamp_idx(idx + 1, n)]; |
| 184 | + let p3 = src[clamp_idx(idx + 2, n)]; |
| 185 | + let a = -0.5 * p0 + 1.5 * p1 - 1.5 * p2 + 0.5 * p3; |
| 186 | + let b = p0 - 2.5 * p1 + 2.0 * p2 - 0.5 * p3; |
| 187 | + let c = -0.5 * p0 + 0.5 * p2; |
| 188 | + a * t * t * t + b * t * t + c * t + p1 |
| 189 | + }) |
| 190 | + .collect() |
| 191 | +} |
| 192 | + |
| 193 | +fn clamp_idx(idx: isize, len: usize) -> usize { |
| 194 | + idx.max(0).min(len as isize - 1) as usize |
| 195 | +} |
| 196 | + |
| 197 | +/// Z-score normalize to mean=0, std=1. Uses per-hardware stats if available. |
| 198 | +fn zscore_normalize(data: &[f64], hw_stats: Option<&AmplitudeStats>) -> Vec<f64> { |
| 199 | + let (mean, std) = match hw_stats { |
| 200 | + Some(s) => (s.mean, s.std), |
| 201 | + None => compute_mean_std(data), |
| 202 | + }; |
| 203 | + let safe_std = if std.abs() < 1e-12 { 1.0 } else { std }; |
| 204 | + data.iter().map(|&v| (v - mean) / safe_std).collect() |
| 205 | +} |
| 206 | + |
| 207 | +fn compute_mean_std(data: &[f64]) -> (f64, f64) { |
| 208 | + let n = data.len() as f64; |
| 209 | + if n < 1.0 { return (0.0, 1.0); } |
| 210 | + let mean = data.iter().sum::<f64>() / n; |
| 211 | + if n < 2.0 { return (mean, 1.0); } |
| 212 | + let var = data.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / (n - 1.0); |
| 213 | + (mean, var.sqrt()) |
| 214 | +} |
| 215 | + |
| 216 | +/// Sanitize phase: unwrap 2-pi discontinuities then remove linear trend. |
| 217 | +/// Mirrors `PhaseSanitizer::unwrap_1d` logic, adds least-squares detrend. |
| 218 | +fn sanitize_phase(phase: &[f64]) -> Vec<f64> { |
| 219 | + if phase.is_empty() { return Vec::new(); } |
| 220 | + |
| 221 | + // Unwrap |
| 222 | + let mut uw = phase.to_vec(); |
| 223 | + let mut correction = 0.0; |
| 224 | + let mut prev = uw[0]; |
| 225 | + for i in 1..uw.len() { |
| 226 | + let diff = phase[i] - prev; |
| 227 | + if diff > PI { correction -= 2.0 * PI; } |
| 228 | + else if diff < -PI { correction += 2.0 * PI; } |
| 229 | + uw[i] = phase[i] + correction; |
| 230 | + prev = phase[i]; |
| 231 | + } |
| 232 | + |
| 233 | + // Remove linear trend: y = slope*x + intercept |
| 234 | + let n = uw.len() as f64; |
| 235 | + let xm = (n - 1.0) / 2.0; |
| 236 | + let ym = uw.iter().sum::<f64>() / n; |
| 237 | + let (mut num, mut den) = (0.0, 0.0); |
| 238 | + for (i, &y) in uw.iter().enumerate() { |
| 239 | + let dx = i as f64 - xm; |
| 240 | + num += dx * (y - ym); |
| 241 | + den += dx * dx; |
| 242 | + } |
| 243 | + let slope = if den.abs() > 1e-12 { num / den } else { 0.0 }; |
| 244 | + let intercept = ym - slope * xm; |
| 245 | + uw.iter().enumerate().map(|(i, &y)| y - (slope * i as f64 + intercept)).collect() |
| 246 | +} |
| 247 | + |
| 248 | +#[cfg(test)] |
| 249 | +mod tests { |
| 250 | + use super::*; |
| 251 | + |
| 252 | + #[test] |
| 253 | + fn detect_hardware_and_properties() { |
| 254 | + assert_eq!(HardwareNormalizer::detect_hardware(64), HardwareType::Esp32S3); |
| 255 | + assert_eq!(HardwareNormalizer::detect_hardware(30), HardwareType::Intel5300); |
| 256 | + assert_eq!(HardwareNormalizer::detect_hardware(56), HardwareType::Atheros); |
| 257 | + assert_eq!(HardwareNormalizer::detect_hardware(128), HardwareType::Generic); |
| 258 | + assert_eq!(HardwareType::Esp32S3.subcarrier_count(), 64); |
| 259 | + assert_eq!(HardwareType::Esp32S3.mimo_streams(), 1); |
| 260 | + assert_eq!(HardwareType::Intel5300.subcarrier_count(), 30); |
| 261 | + assert_eq!(HardwareType::Intel5300.mimo_streams(), 3); |
| 262 | + assert_eq!(HardwareType::Atheros.subcarrier_count(), 56); |
| 263 | + assert_eq!(HardwareType::Atheros.mimo_streams(), 3); |
| 264 | + assert_eq!(HardwareType::Generic.subcarrier_count(), 56); |
| 265 | + assert_eq!(HardwareType::Generic.mimo_streams(), 1); |
| 266 | + } |
| 267 | + |
| 268 | + #[test] |
| 269 | + fn resample_identity_56_to_56() { |
| 270 | + let input: Vec<f64> = (0..56).map(|i| i as f64 * 0.1).collect(); |
| 271 | + let output = resample_cubic(&input, 56); |
| 272 | + for (a, b) in input.iter().zip(output.iter()) { |
| 273 | + assert!((a - b).abs() < 1e-12, "Identity resampling must be passthrough"); |
| 274 | + } |
| 275 | + } |
| 276 | + |
| 277 | + #[test] |
| 278 | + fn resample_64_to_56() { |
| 279 | + let input: Vec<f64> = (0..64).map(|i| (i as f64 * 0.1).sin()).collect(); |
| 280 | + let out = resample_cubic(&input, 56); |
| 281 | + assert_eq!(out.len(), 56); |
| 282 | + assert!((out[0] - input[0]).abs() < 1e-6); |
| 283 | + assert!((out[55] - input[63]).abs() < 0.1); |
| 284 | + } |
| 285 | + |
| 286 | + #[test] |
| 287 | + fn resample_30_to_56() { |
| 288 | + let input: Vec<f64> = (0..30).map(|i| (i as f64 * 0.2).cos()).collect(); |
| 289 | + let out = resample_cubic(&input, 56); |
| 290 | + assert_eq!(out.len(), 56); |
| 291 | + assert!((out[0] - input[0]).abs() < 1e-6); |
| 292 | + assert!((out[55] - input[29]).abs() < 0.1); |
| 293 | + } |
| 294 | + |
| 295 | + #[test] |
| 296 | + fn resample_preserves_constant() { |
| 297 | + for &v in &resample_cubic(&vec![3.14; 64], 56) { |
| 298 | + assert!((v - 3.14).abs() < 1e-10); |
| 299 | + } |
| 300 | + } |
| 301 | + |
| 302 | + #[test] |
| 303 | + fn zscore_produces_zero_mean_unit_std() { |
| 304 | + let data: Vec<f64> = (0..100).map(|i| 50.0 + 10.0 * (i as f64 * 0.1).sin()).collect(); |
| 305 | + let z = zscore_normalize(&data, None); |
| 306 | + let n = z.len() as f64; |
| 307 | + let mean = z.iter().sum::<f64>() / n; |
| 308 | + let std = (z.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / (n - 1.0)).sqrt(); |
| 309 | + assert!(mean.abs() < 1e-10, "Mean should be ~0, got {mean}"); |
| 310 | + assert!((std - 1.0).abs() < 1e-10, "Std should be ~1, got {std}"); |
| 311 | + } |
| 312 | + |
| 313 | + #[test] |
| 314 | + fn zscore_with_hw_stats_and_constant() { |
| 315 | + let z = zscore_normalize(&[10.0, 20.0, 30.0], Some(&AmplitudeStats { mean: 20.0, std: 10.0 })); |
| 316 | + assert!((z[0] + 1.0).abs() < 1e-12); |
| 317 | + assert!(z[1].abs() < 1e-12); |
| 318 | + assert!((z[2] - 1.0).abs() < 1e-12); |
| 319 | + // Constant signal: std=0 => safe fallback, all zeros |
| 320 | + for &v in &zscore_normalize(&vec![5.0; 50], None) { assert!(v.abs() < 1e-12); } |
| 321 | + } |
| 322 | + |
| 323 | + #[test] |
| 324 | + fn phase_sanitize_removes_linear_trend() { |
| 325 | + let san = sanitize_phase(&(0..56).map(|i| 0.5 * i as f64).collect::<Vec<_>>()); |
| 326 | + assert_eq!(san.len(), 56); |
| 327 | + for &v in &san { assert!(v.abs() < 1e-10, "Detrended should be ~0, got {v}"); } |
| 328 | + } |
| 329 | + |
| 330 | + #[test] |
| 331 | + fn phase_sanitize_unwrap() { |
| 332 | + let raw: Vec<f64> = (0..40).map(|i| { |
| 333 | + let mut w = (i as f64 * 0.4) % (2.0 * PI); |
| 334 | + if w > PI { w -= 2.0 * PI; } |
| 335 | + w |
| 336 | + }).collect(); |
| 337 | + let san = sanitize_phase(&raw); |
| 338 | + for i in 1..san.len() { |
| 339 | + assert!((san[i] - san[i - 1]).abs() < 1.0, "Phase jump at {i}"); |
| 340 | + } |
| 341 | + } |
| 342 | + |
| 343 | + #[test] |
| 344 | + fn phase_sanitize_edge_cases() { |
| 345 | + assert!(sanitize_phase(&[]).is_empty()); |
| 346 | + assert!(sanitize_phase(&[1.5])[0].abs() < 1e-12); |
| 347 | + } |
| 348 | + |
| 349 | + #[test] |
| 350 | + fn normalize_esp32_64_to_56() { |
| 351 | + let norm = HardwareNormalizer::new(); |
| 352 | + let amp: Vec<f64> = (0..64).map(|i| 20.0 + 5.0 * (i as f64 * 0.1).sin()).collect(); |
| 353 | + let ph: Vec<f64> = (0..64).map(|i| (i as f64 * 0.05).sin() * 0.5).collect(); |
| 354 | + let r = norm.normalize(&, &ph, HardwareType::Esp32S3).unwrap(); |
| 355 | + assert_eq!(r.amplitude.len(), 56); |
| 356 | + assert_eq!(r.phase.len(), 56); |
| 357 | + assert_eq!(r.hardware_type, HardwareType::Esp32S3); |
| 358 | + let mean: f64 = r.amplitude.iter().map(|&v| v as f64).sum::<f64>() / 56.0; |
| 359 | + assert!(mean.abs() < 0.1, "Mean should be ~0, got {mean}"); |
| 360 | + } |
| 361 | + |
| 362 | + #[test] |
| 363 | + fn normalize_intel5300_30_to_56() { |
| 364 | + let r = HardwareNormalizer::new().normalize( |
| 365 | + &(0..30).map(|i| 15.0 + 3.0 * (i as f64 * 0.2).cos()).collect::<Vec<_>>(), |
| 366 | + &(0..30).map(|i| (i as f64 * 0.1).sin() * 0.3).collect::<Vec<_>>(), |
| 367 | + HardwareType::Intel5300, |
| 368 | + ).unwrap(); |
| 369 | + assert_eq!(r.amplitude.len(), 56); |
| 370 | + assert_eq!(r.hardware_type, HardwareType::Intel5300); |
| 371 | + } |
| 372 | + |
| 373 | + #[test] |
| 374 | + fn normalize_atheros_passthrough_count() { |
| 375 | + let r = HardwareNormalizer::new().normalize( |
| 376 | + &(0..56).map(|i| 10.0 + 2.0 * i as f64).collect::<Vec<_>>(), |
| 377 | + &(0..56).map(|i| (i as f64 * 0.05).sin()).collect::<Vec<_>>(), |
| 378 | + HardwareType::Atheros, |
| 379 | + ).unwrap(); |
| 380 | + assert_eq!(r.amplitude.len(), 56); |
| 381 | + } |
| 382 | + |
| 383 | + #[test] |
| 384 | + fn normalize_errors_and_custom_canonical() { |
| 385 | + let n = HardwareNormalizer::new(); |
| 386 | + assert!(n.normalize(&[], &[], HardwareType::Generic).is_err()); |
| 387 | + assert!(matches!(n.normalize(&[1.0, 2.0], &[1.0], HardwareType::Generic), |
| 388 | + Err(HardwareNormError::LengthMismatch { .. }))); |
| 389 | + assert!(matches!(HardwareNormalizer::with_canonical_subcarriers(0), |
| 390 | + Err(HardwareNormError::InvalidCanonical(0)))); |
| 391 | + let c = HardwareNormalizer::with_canonical_subcarriers(32).unwrap(); |
| 392 | + let r = c.normalize( |
| 393 | + &(0..64).map(|i| i as f64).collect::<Vec<_>>(), |
| 394 | + &(0..64).map(|i| (i as f64 * 0.1).sin()).collect::<Vec<_>>(), |
| 395 | + HardwareType::Esp32S3, |
| 396 | + ).unwrap(); |
| 397 | + assert_eq!(r.amplitude.len(), 32); |
| 398 | + } |
| 399 | +} |
0 commit comments