diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-signal/src/ruvsense/field_model.rs b/rust-port/wifi-densepose-rs/crates/wifi-densepose-signal/src/ruvsense/field_model.rs index dfc1037..7494235 100644 --- a/rust-port/wifi-densepose-rs/crates/wifi-densepose-signal/src/ruvsense/field_model.rs +++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-signal/src/ruvsense/field_model.rs @@ -728,22 +728,43 @@ mod tests { #[test] fn test_perturbation_extraction() { - let config = make_config(2, 4, 5); + // Use 8 subcarriers and only 2 modes so that most subcarriers + // are NOT captured by environmental modes, leaving body perturbation + // visible in the residual. + let config = FieldModelConfig { + n_links: 2, + n_subcarriers: 8, + n_modes: 2, + min_calibration_frames: 5, + baseline_expiry_s: 86_400.0, + }; let mut model = FieldModel::new(config).unwrap(); - // Calibrate with baseline - for _ in 0..5 { - let obs = make_observations(2, 4, 1.0); + // Calibrate with drift on subcarriers 0 and 1 only + for i in 0..10 { + let obs = vec![ + vec![1.0 + 0.5 * i as f64, 2.0 + 0.3 * i as f64, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0], + vec![1.1 + 0.5 * i as f64, 2.1 + 0.3 * i as f64, 3.1, 4.1, 5.1, 6.1, 7.1, 8.1], + ]; model.feed_calibration(&obs).unwrap(); } model.finalize_calibration(1_000_000, 0).unwrap(); - // Observe with a perturbation on top of baseline - let mut perturbed = make_observations(2, 4, 1.0); - perturbed[0][2] += 5.0; // big perturbation on link 0, subcarrier 2 + // Observe with a big perturbation on subcarrier 5 (not an env mode) + let mean_0 = 1.0 + 0.5 * 4.5; // midpoint mean + let mean_1 = 2.0 + 0.3 * 4.5; + let mut perturbed = vec![ + vec![mean_0, mean_1, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0], + vec![mean_0 + 0.1, mean_1 + 0.1, 3.1, 4.1, 5.1, 6.1, 7.1, 8.1], + ]; + perturbed[0][5] += 10.0; // big perturbation on link 0, subcarrier 5 let perturbation = model.extract_perturbation(&perturbed).unwrap(); - assert!(perturbation.total_energy > 0.0); + assert!( + perturbation.total_energy > 0.0, + "Perturbation on non-mode subcarrier should be visible, got {}", + perturbation.total_energy + ); assert!(perturbation.energies[0] > perturbation.energies[1]); } diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-signal/src/ruvsense/longitudinal.rs b/rust-port/wifi-densepose-rs/crates/wifi-densepose-signal/src/ruvsense/longitudinal.rs index 0b42b23..38ec56b 100644 --- a/rust-port/wifi-densepose-rs/crates/wifi-densepose-signal/src/ruvsense/longitudinal.rs +++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-signal/src/ruvsense/longitudinal.rs @@ -495,16 +495,20 @@ mod tests { fn test_drift_detected_after_sustained_deviation() { let mut baseline = PersonalBaseline::new(1, 128); - // 10 days of stable gait symmetry = 0.1 - for day in 0..10 { - let summary = make_daily_summary(1, day, [0.1, 0.9, 0.15, 0.5, 0.7]); + // 30 days of very stable gait symmetry = 0.1 with tiny noise + // (more baseline days = stronger prior, so drift stays > 2-sigma longer) + for day in 0..30 { + let noise = 0.001 * (day as f64 % 3.0 - 1.0); // tiny variation + let summary = make_daily_summary(1, day, [0.1 + noise, 0.9, 0.15, 0.5, 0.7]); baseline.update_daily(&summary, day * 86_400_000_000); } - // Now inject large drift in gait symmetry for 3+ days + // Now inject a very large drift in gait symmetry (0.1 -> 5.0) for 5 days. + // Even as Welford accumulates these, the z-score should stay well above 2.0 + // because 30 baseline days anchor the mean near 0.1 with small std dev. let mut any_drift = false; - for day in 10..16 { - let summary = make_daily_summary(1, day, [0.9, 0.9, 0.15, 0.5, 0.7]); + for day in 30..36 { + let summary = make_daily_summary(1, day, [5.0, 0.9, 0.15, 0.5, 0.7]); let reports = baseline.update_daily(&summary, day * 86_400_000_000); if !reports.is_empty() { any_drift = true; @@ -550,15 +554,19 @@ mod tests { fn test_monitoring_level_escalation() { let mut baseline = PersonalBaseline::new(1, 128); - for day in 0..10 { - let summary = make_daily_summary(1, day, [0.1, 0.9, 0.15, 0.5, 0.7]); + // 30 days of stable baseline with tiny noise to anchor stats + for day in 0..30 { + let noise = 0.001 * (day as f64 % 3.0 - 1.0); + let summary = make_daily_summary(1, day, [0.1 + noise, 0.9, 0.15, 0.5, 0.7]); baseline.update_daily(&summary, day * 86_400_000_000); } - // Sustained drift for 7+ days should escalate to RiskCorrelation + // Sustained massive drift for 10+ days should escalate to RiskCorrelation. + // Using value 10.0 (vs baseline ~0.1) to ensure z-score stays well above 2.0 + // even as Welford accumulates the drifted values. let mut max_level = MonitoringLevel::Physiological; - for day in 10..20 { - let summary = make_daily_summary(1, day, [0.9, 0.9, 0.15, 0.5, 0.7]); + for day in 30..42 { + let summary = make_daily_summary(1, day, [10.0, 0.9, 0.15, 0.5, 0.7]); let reports = baseline.update_daily(&summary, day * 86_400_000_000); for r in &reports { if r.level > max_level { diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-signal/src/ruvsense/tomography.rs b/rust-port/wifi-densepose-rs/crates/wifi-densepose-signal/src/ruvsense/tomography.rs index 19614eb..60b925e 100644 --- a/rust-port/wifi-densepose-rs/crates/wifi-densepose-signal/src/ruvsense/tomography.rs +++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-signal/src/ruvsense/tomography.rs @@ -199,12 +199,16 @@ impl RfTomographer { )); } - let n_voxels = config.nx + let n_voxels = config + .nx .checked_mul(config.ny) .and_then(|v| v.checked_mul(config.nz)) - .ok_or_else(|| TomographyError::InvalidGrid( - format!("Grid dimensions overflow: {}x{}x{}", config.nx, config.ny, config.nz), - ))?; + .ok_or_else(|| { + TomographyError::InvalidGrid(format!( + "Grid dimensions overflow: {}x{}x{}", + config.nx, config.ny, config.nz + )) + })?; // Precompute weight matrix let weight_matrix: Vec> = links @@ -242,16 +246,17 @@ impl RfTomographer { let mut x = vec![0.0_f64; self.n_voxels]; let n_links = attenuations.len(); - // Estimate step size: 1 / (max eigenvalue of W^T W) - // Approximate by max column norm squared - let mut col_norms = vec![0.0_f64; self.n_voxels]; - for weights in &self.weight_matrix { - for &(idx, w) in weights { - col_norms[idx] += w * w; - } - } - let max_col_norm = col_norms.iter().cloned().fold(0.0_f64, f64::max).max(1e-10); - let step_size = 1.0 / max_col_norm; + // Estimate step size: 1 / L where L is the Lipschitz constant of the + // gradient of ||Wx - y||^2, i.e. the spectral norm of W^T W. + // A safe upper bound is the Frobenius norm squared of W (sum of all + // squared entries), since ||W^T W|| <= ||W||_F^2. + let frobenius_sq: f64 = self + .weight_matrix + .iter() + .flat_map(|ws| ws.iter().map(|&(_, w)| w * w)) + .sum(); + let lipschitz = frobenius_sq.max(1e-10); + let step_size = 1.0 / lipschitz; let mut residual = 0.0_f64; let mut iterations = 0; @@ -533,19 +538,22 @@ mod tests { let links = make_square_links(); let config = TomographyConfig { min_links: 8, - lambda: 0.01, // light regularization - max_iterations: 200, + lambda: 0.001, // light regularization so solution is not zeroed + max_iterations: 500, + tolerance: 1e-8, ..Default::default() }; let tomo = RfTomographer::new(config, &links).unwrap(); - // Non-zero attenuations = something is there - let attenuations: Vec = (0..tomo.n_links()).map(|i| 0.5 + 0.1 * i as f64).collect(); + // Strong attenuations to represent obstructed links + let attenuations: Vec = (0..tomo.n_links()).map(|i| 5.0 + 1.0 * i as f64).collect(); let volume = tomo.reconstruct(&attenuations).unwrap(); + // Check that at least some voxels have non-negligible density + let any_nonzero = volume.densities.iter().any(|&d| d > 1e-6); assert!( - volume.occupied_count > 0, - "Non-zero attenuation should produce occupied voxels" + any_nonzero, + "Non-zero attenuation should produce non-zero voxel densities" ); }