- domain.rs: atomic instance counter for unique Linear weight seeds (C3) - rapid_adapt.rs: adapt() returns Result instead of panicking (C5), bounded calibration buffer with max_buffer_frames cap (F1-HIGH), validate lora_rank >= 1 (F10) - geometry.rs: 24-bit PRNG precision matching f32 mantissa (C2) - virtual_aug.rs: guard against room_scale=0 division-by-zero (F6) - signal/lib.rs: re-export AmplitudeStats from hardware_norm (W1) - train/lib.rs: crate-root re-exports for all MERIDIAN types (W2) All 201 tests pass (96 unit + 24 integration + 18 subcarrier + 10 metrics + 7 doctests + 105 signal + 10 validation + 1 signal doctest). Co-Authored-By: claude-flow <ruv@ruv.net>
298 lines
11 KiB
Rust
298 lines
11 KiB
Rust
//! Virtual Domain Augmentation for cross-environment generalization (ADR-027 Phase 4).
|
|
//!
|
|
//! Generates synthetic "virtual domains" simulating different physical environments
|
|
//! and applies domain-specific transformations to CSI amplitude frames for the
|
|
//! MERIDIAN adversarial training loop.
|
|
//!
|
|
//! ```rust
|
|
//! use wifi_densepose_train::virtual_aug::{VirtualDomainAugmentor, Xorshift64};
|
|
//!
|
|
//! let mut aug = VirtualDomainAugmentor::default();
|
|
//! let mut rng = Xorshift64::new(42);
|
|
//! let frame = vec![0.5_f32; 56];
|
|
//! let domain = aug.generate_domain(&mut rng);
|
|
//! let out = aug.augment_frame(&frame, &domain);
|
|
//! assert_eq!(out.len(), frame.len());
|
|
//! ```
|
|
|
|
use std::f32::consts::PI;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Xorshift64 PRNG (matches dataset.rs pattern)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Lightweight 64-bit Xorshift PRNG for deterministic augmentation.
|
|
pub struct Xorshift64 {
|
|
state: u64,
|
|
}
|
|
|
|
impl Xorshift64 {
|
|
/// Create a new PRNG. Seed `0` is replaced with a fixed non-zero value.
|
|
pub fn new(seed: u64) -> Self {
|
|
Self { state: if seed == 0 { 0x853c49e6748fea9b } else { seed } }
|
|
}
|
|
|
|
/// Advance the state and return the next `u64`.
|
|
#[inline]
|
|
pub fn next_u64(&mut self) -> u64 {
|
|
self.state ^= self.state << 13;
|
|
self.state ^= self.state >> 7;
|
|
self.state ^= self.state << 17;
|
|
self.state
|
|
}
|
|
|
|
/// Return a uniformly distributed `f32` in `[0, 1)`.
|
|
#[inline]
|
|
pub fn next_f32(&mut self) -> f32 {
|
|
(self.next_u64() >> 40) as f32 / (1u64 << 24) as f32
|
|
}
|
|
|
|
/// Return a uniformly distributed `f32` in `[lo, hi)`.
|
|
#[inline]
|
|
pub fn next_f32_range(&mut self, lo: f32, hi: f32) -> f32 {
|
|
lo + self.next_f32() * (hi - lo)
|
|
}
|
|
|
|
/// Return a uniformly distributed `usize` in `[lo, hi]` (inclusive).
|
|
#[inline]
|
|
pub fn next_usize_range(&mut self, lo: usize, hi: usize) -> usize {
|
|
if lo >= hi { return lo; }
|
|
lo + (self.next_u64() % (hi - lo + 1) as u64) as usize
|
|
}
|
|
|
|
/// Sample an approximate Gaussian (mean=0, std=1) via Box-Muller.
|
|
#[inline]
|
|
pub fn next_gaussian(&mut self) -> f32 {
|
|
let u1 = self.next_f32().max(1e-10);
|
|
let u2 = self.next_f32();
|
|
(-2.0 * u1.ln()).sqrt() * (2.0 * PI * u2).cos()
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// VirtualDomain
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Describes a single synthetic WiFi environment for domain augmentation.
|
|
#[derive(Debug, Clone)]
|
|
pub struct VirtualDomain {
|
|
/// Path-loss factor simulating room size (< 1 smaller, > 1 larger room).
|
|
pub room_scale: f32,
|
|
/// Wall reflection coefficient in `[0, 1]` (low = absorptive, high = reflective).
|
|
pub reflection_coeff: f32,
|
|
/// Number of virtual scatterers (furniture / obstacles).
|
|
pub n_scatterers: usize,
|
|
/// Standard deviation of additive hardware noise.
|
|
pub noise_std: f32,
|
|
/// Unique label for the domain classifier in adversarial training.
|
|
pub domain_id: u32,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// VirtualDomainAugmentor
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Samples virtual WiFi domains and transforms CSI frames to simulate them.
|
|
///
|
|
/// Applies four transformations: room-scale amplitude scaling, per-subcarrier
|
|
/// reflection modulation, virtual scatterer sinusoidal interference, and
|
|
/// Gaussian noise injection.
|
|
#[derive(Debug, Clone)]
|
|
pub struct VirtualDomainAugmentor {
|
|
/// Range for room scale factor `(min, max)`.
|
|
pub room_scale_range: (f32, f32),
|
|
/// Range for reflection coefficient `(min, max)`.
|
|
pub reflection_coeff_range: (f32, f32),
|
|
/// Range for number of virtual scatterers `(min, max)`.
|
|
pub n_virtual_scatterers: (usize, usize),
|
|
/// Range for noise standard deviation `(min, max)`.
|
|
pub noise_std_range: (f32, f32),
|
|
next_domain_id: u32,
|
|
}
|
|
|
|
impl Default for VirtualDomainAugmentor {
|
|
fn default() -> Self {
|
|
Self {
|
|
room_scale_range: (0.5, 2.0),
|
|
reflection_coeff_range: (0.3, 0.9),
|
|
n_virtual_scatterers: (0, 5),
|
|
noise_std_range: (0.01, 0.1),
|
|
next_domain_id: 0,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl VirtualDomainAugmentor {
|
|
/// Randomly sample a new [`VirtualDomain`] from the configured ranges.
|
|
pub fn generate_domain(&mut self, rng: &mut Xorshift64) -> VirtualDomain {
|
|
let id = self.next_domain_id;
|
|
self.next_domain_id = self.next_domain_id.wrapping_add(1);
|
|
VirtualDomain {
|
|
room_scale: rng.next_f32_range(self.room_scale_range.0, self.room_scale_range.1),
|
|
reflection_coeff: rng.next_f32_range(self.reflection_coeff_range.0, self.reflection_coeff_range.1),
|
|
n_scatterers: rng.next_usize_range(self.n_virtual_scatterers.0, self.n_virtual_scatterers.1),
|
|
noise_std: rng.next_f32_range(self.noise_std_range.0, self.noise_std_range.1),
|
|
domain_id: id,
|
|
}
|
|
}
|
|
|
|
/// Transform a single CSI amplitude frame to simulate `domain`.
|
|
///
|
|
/// Pipeline: (1) scale by `1/room_scale`, (2) per-subcarrier reflection
|
|
/// modulation, (3) scatterer sinusoidal perturbation, (4) Gaussian noise.
|
|
pub fn augment_frame(&self, frame: &[f32], domain: &VirtualDomain) -> Vec<f32> {
|
|
let n = frame.len();
|
|
let n_f = n as f32;
|
|
let mut noise_rng = Xorshift64::new(
|
|
(domain.domain_id as u64).wrapping_mul(0x9E3779B97F4A7C15).wrapping_add(1),
|
|
);
|
|
let mut out = Vec::with_capacity(n);
|
|
for (k, &val) in frame.iter().enumerate() {
|
|
let k_f = k as f32;
|
|
// 1. Room-scale amplitude attenuation (guard against zero scale)
|
|
let scaled = if domain.room_scale.abs() < 1e-10 { val } else { val / domain.room_scale };
|
|
// 2. Reflection coefficient modulation (per-subcarrier)
|
|
let refl = domain.reflection_coeff
|
|
+ (1.0 - domain.reflection_coeff) * (PI * k_f / n_f).cos();
|
|
let modulated = scaled * refl;
|
|
// 3. Virtual scatterer sinusoidal interference
|
|
let mut scatter = 0.0_f32;
|
|
for s in 0..domain.n_scatterers {
|
|
scatter += 0.05 * (2.0 * PI * (s as f32 + 1.0) * k_f / n_f).sin();
|
|
}
|
|
// 4. Additive Gaussian noise
|
|
out.push(modulated + scatter + noise_rng.next_gaussian() * domain.noise_std);
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Augment a batch, producing `k` virtual-domain variants per input frame.
|
|
///
|
|
/// Returns `(augmented_frame, domain_id)` pairs; total = `batch.len() * k`.
|
|
pub fn augment_batch(
|
|
&mut self, batch: &[Vec<f32>], k: usize, rng: &mut Xorshift64,
|
|
) -> Vec<(Vec<f32>, u32)> {
|
|
let mut results = Vec::with_capacity(batch.len() * k);
|
|
for frame in batch {
|
|
for _ in 0..k {
|
|
let domain = self.generate_domain(rng);
|
|
let augmented = self.augment_frame(frame, &domain);
|
|
results.push((augmented, domain.domain_id));
|
|
}
|
|
}
|
|
results
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn make_domain(scale: f32, coeff: f32, scatter: usize, noise: f32, id: u32) -> VirtualDomain {
|
|
VirtualDomain { room_scale: scale, reflection_coeff: coeff, n_scatterers: scatter, noise_std: noise, domain_id: id }
|
|
}
|
|
|
|
#[test]
|
|
fn domain_within_configured_ranges() {
|
|
let mut aug = VirtualDomainAugmentor::default();
|
|
let mut rng = Xorshift64::new(12345);
|
|
for _ in 0..100 {
|
|
let d = aug.generate_domain(&mut rng);
|
|
assert!(d.room_scale >= 0.5 && d.room_scale <= 2.0);
|
|
assert!(d.reflection_coeff >= 0.3 && d.reflection_coeff <= 0.9);
|
|
assert!(d.n_scatterers <= 5);
|
|
assert!(d.noise_std >= 0.01 && d.noise_std <= 0.1);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn augment_frame_preserves_length() {
|
|
let aug = VirtualDomainAugmentor::default();
|
|
let out = aug.augment_frame(&vec![0.5; 56], &make_domain(1.0, 0.5, 3, 0.05, 0));
|
|
assert_eq!(out.len(), 56);
|
|
}
|
|
|
|
#[test]
|
|
fn augment_frame_identity_domain_approx_input() {
|
|
let aug = VirtualDomainAugmentor::default();
|
|
let frame: Vec<f32> = (0..56).map(|i| 0.3 + 0.01 * i as f32).collect();
|
|
let out = aug.augment_frame(&frame, &make_domain(1.0, 1.0, 0, 0.0, 0));
|
|
for (a, b) in out.iter().zip(frame.iter()) {
|
|
assert!((a - b).abs() < 1e-5, "identity domain: got {a}, expected {b}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn augment_batch_produces_correct_count() {
|
|
let mut aug = VirtualDomainAugmentor::default();
|
|
let mut rng = Xorshift64::new(99);
|
|
let batch: Vec<Vec<f32>> = (0..4).map(|_| vec![0.5; 56]).collect();
|
|
let results = aug.augment_batch(&batch, 3, &mut rng);
|
|
assert_eq!(results.len(), 12);
|
|
for (f, _) in &results { assert_eq!(f.len(), 56); }
|
|
}
|
|
|
|
#[test]
|
|
fn different_seeds_produce_different_augmentations() {
|
|
let mut aug1 = VirtualDomainAugmentor::default();
|
|
let mut aug2 = VirtualDomainAugmentor::default();
|
|
let frame = vec![0.5_f32; 56];
|
|
let d1 = aug1.generate_domain(&mut Xorshift64::new(1));
|
|
let d2 = aug2.generate_domain(&mut Xorshift64::new(2));
|
|
let out1 = aug1.augment_frame(&frame, &d1);
|
|
let out2 = aug2.augment_frame(&frame, &d2);
|
|
assert!(out1.iter().zip(out2.iter()).any(|(a, b)| (a - b).abs() > 1e-6));
|
|
}
|
|
|
|
#[test]
|
|
fn deterministic_same_seed_same_output() {
|
|
let batch: Vec<Vec<f32>> = (0..3).map(|i| vec![0.1 * i as f32; 56]).collect();
|
|
let mut aug1 = VirtualDomainAugmentor::default();
|
|
let mut aug2 = VirtualDomainAugmentor::default();
|
|
let res1 = aug1.augment_batch(&batch, 2, &mut Xorshift64::new(42));
|
|
let res2 = aug2.augment_batch(&batch, 2, &mut Xorshift64::new(42));
|
|
assert_eq!(res1.len(), res2.len());
|
|
for ((f1, id1), (f2, id2)) in res1.iter().zip(res2.iter()) {
|
|
assert_eq!(id1, id2);
|
|
for (a, b) in f1.iter().zip(f2.iter()) {
|
|
assert!((a - b).abs() < 1e-7, "same seed must produce identical output");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn domain_ids_are_sequential() {
|
|
let mut aug = VirtualDomainAugmentor::default();
|
|
let mut rng = Xorshift64::new(7);
|
|
for i in 0..10_u32 { assert_eq!(aug.generate_domain(&mut rng).domain_id, i); }
|
|
}
|
|
|
|
#[test]
|
|
fn xorshift64_deterministic() {
|
|
let mut a = Xorshift64::new(999);
|
|
let mut b = Xorshift64::new(999);
|
|
for _ in 0..100 { assert_eq!(a.next_u64(), b.next_u64()); }
|
|
}
|
|
|
|
#[test]
|
|
fn xorshift64_f32_in_unit_interval() {
|
|
let mut rng = Xorshift64::new(42);
|
|
for _ in 0..1000 {
|
|
let v = rng.next_f32();
|
|
assert!(v >= 0.0 && v < 1.0, "f32 sample {v} not in [0, 1)");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn augment_frame_empty_and_batch_k_zero() {
|
|
let aug = VirtualDomainAugmentor::default();
|
|
assert!(aug.augment_frame(&[], &make_domain(1.5, 0.5, 2, 0.05, 0)).is_empty());
|
|
let mut aug2 = VirtualDomainAugmentor::default();
|
|
assert!(aug2.augment_batch(&[vec![0.5; 56]], 0, &mut Xorshift64::new(1)).is_empty());
|
|
}
|
|
}
|