Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 0a30f79

Browse files
committed
feat: ADR-027 MERIDIAN — all 6 phases implemented (1,858 lines, 72 tests)
Phase 1: HardwareNormalizer (hardware_norm.rs, 399 lines, 14 tests) - Catmull-Rom cubic interpolation: any subcarrier count → canonical 56 - Z-score normalization, phase unwrap + linear detrend - Hardware detection: ESP32-S3, Intel 5300, Atheros, Generic Phase 2: DomainFactorizer + GRL (domain.rs, 392 lines, 20 tests) - PoseEncoder: Linear→LayerNorm→GELU→Linear (environment-invariant) - EnvEncoder: GlobalMeanPool→Linear (environment-specific, discarded) - GradientReversalLayer: identity forward, -lambda*grad backward - AdversarialSchedule: sigmoidal lambda annealing 0→1 Phase 3: GeometryEncoder + FiLM (geometry.rs, 364 lines, 14 tests) - FourierPositionalEncoding: 3D coords → 64-dim - DeepSets: permutation-invariant AP position aggregation - FilmLayer: Feature-wise Linear Modulation for zero-shot deployment Phase 4: VirtualDomainAugmentor (virtual_aug.rs, 297 lines, 10 tests) - Room scale, reflection coeff, virtual scatterers, noise injection - Deterministic Xorshift64 RNG, 4x effective training diversity Phase 5: RapidAdaptation (rapid_adapt.rs, 255 lines, 7 tests) - 10-second unsupervised calibration via contrastive TTT + entropy min - LoRA weight generation without pose labels Phase 6: CrossDomainEvaluator (eval.rs, 151 lines, 7 tests) - 6 metrics: in-domain/cross-domain/few-shot/cross-hw MPJPE, domain gap ratio, adaptation speedup All 72 MERIDIAN tests pass. Full workspace compiles clean. Co-Authored-By: claude-flow <[email protected]>
1 parent b078190 commit 0a30f79

8 files changed

Lines changed: 1867 additions & 0 deletions

File tree

Lines changed: 399 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,399 @@
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(&amp_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(&amp, &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

Comments
 (0)