Files
ruvnet--RuView/v2/crates/wifi-densepose-signal/tests/calibration_synthetic.rs
T
ruv 8504638187 feat(signal): ADR-135 — empty-room baseline calibration
Operator-initiated calibration that records 30 s of stationary CSI,
emits a per-subcarrier baseline (amplitude mean+variance via Welford,
phase via circular sin/cos sums with von Mises dispersion), and gates
downstream stages on a deviation z-score. Plugs into multistatic
coherence gating, motion/presence detection, and the new ADR-134 CIR
estimator as a reference-subtracted input.

API surface (under wifi_densepose_signal):
  CalibrationConfig::{ht20, ht40, he20, he40}
  CalibrationRecorder { record(), finalize(), frames_recorded() }
  BaselineCalibration {
    subcarriers: Vec<SubcarrierBaseline>,
    deviation(&CsiFrame), subtract_in_place(&mut CsiFrame),
    to_bytes(), from_bytes()
  }
  CalibrationDeviationScore { amplitude_z_median, amplitude_z_max,
                              phase_drift_median, motion_flagged }
  CalibrationError { SubcarrierMismatch, TierMismatch,
                     InsufficientFrames, VersionMismatch, TruncatedBuffer }

Binary baseline format: magic 0xCA1B_0001 + u8 version=1 + u8 tier +
captured_at_unix_s (i64) + frame_count (u64) + num_subcarriers (u32) +
[SubcarrierBaseline; N] as 16 bytes each (amp_mean, amp_variance,
phase_mean, phase_dispersion as f32 LE). Hand-written serialisation so
the format is stable across Rust toolchain versions without serde drift.

CLI: new `wifi-densepose calibrate` subcommand binds a UDP listener
(0xC511_0001 frames), streams them through CalibrationRecorder, prints
a real-time z-score banner per ADR-135 §risk 1 (operator-may-be-moving),
aborts on sustained high deviation, and writes the binary baseline to
disk. Local UDP packet parser duplicated from sensing-server (per ADR
discussion — avoids cross-crate API churn).

Witness: cross-platform-deterministic SHA-256 over the per-subcarrier
quantised baseline profile (u16 LE at 1e-2/1e-4/1e-3, no sort) using
the lesson learnt from the CIR PR #837 libm-jitter fix. Hash:
d6bce07ecb1648e6936561df44bf4a3bfc17bb0ba5f692646b2301d105b52f67

CI guard: new "ADR-135 calibration witness proof (determinism guard)"
step under the Rust Workspace Tests job, adjacent to the existing
ADR-134 CIR guard. Regressions are unambiguously attributable.

Hardware-in-loop validation: full 600-frame capture exercised via the
new scripts/synth-csi-udp.py emitter targeting 127.0.0.1:5005. The CLI
binary received 600 frames at 20 Hz, z_med stable at ~0.7, motion
correctly NOT flagged, finalised baseline written to baseline.bin (860
bytes) with correct magic + version + timestamp in the header. Live
ESP32 capture from COM9 is operator follow-up — requires provisioning
the firmware's UDP target IP to match the host running the CLI.

Test results (cargo test -p wifi-densepose-signal --no-default-features):
  lib:                    382 pass / 0 fail / 1 ignored
  calibration_synthetic:   17 pass / 0 fail
  calibration_drift:        5 pass / 0 fail
  calibration_roundtrip:   10 pass / 0 fail
  cir_*:                    9 pass + 6 documented P2 ignores
  doctest:                 10 pass

Bench: 20 Criterion combinations registered
(recorder_record / recorder_finalize / deviation / record_600 /
to_bytes across HT20/HT40/HE20/HE40 tiers).

Witness: bash scripts/verify-calibration-proof.sh → VERDICT: PASS

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-28 18:57:08 -04:00

485 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Deterministic synthetic channel tests for the empty-room baseline calibration
//! module (ADR-135).
//!
//! Validates Welford online statistics, deviation scoring, and per-PHY-tier
//! subcarrier counts. Tests are seeded with literal `42` via xorshift32 and are
//! fully deterministic.
//!
//! Run (compile-only):
//! cargo test -p wifi-densepose-signal --no-default-features --tests --no-run
use std::f32::consts::PI;
use ndarray::Array2;
use num_complex::Complex64;
use wifi_densepose_core::types::{AntennaConfig, CsiFrame, CsiMetadata, DeviceId, FrequencyBand};
use wifi_densepose_signal::calibration::{
BaselineCalibration, CalibrationConfig, CalibrationRecorder,
};
// ---------------------------------------------------------------------------
// Deterministic PRNG (xorshift32, seed=42) — duplicated locally per ADR-135
// constraint: do not refactor existing test helpers.
// ---------------------------------------------------------------------------
struct Rng(u32);
impl Rng {
fn new(seed: u32) -> Self {
assert_ne!(seed, 0, "xorshift seed must be non-zero");
Self(seed)
}
fn next_u32(&mut self) -> u32 {
let mut x = self.0;
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
self.0 = x;
x
}
/// Sample N(0,1) via Box-Muller (always consumes two draws).
fn next_normal(&mut self) -> f32 {
let u1 = (self.next_u32() as f32 + 1.0) / (u32::MAX as f32 + 2.0);
let u2 = (self.next_u32() as f32 + 1.0) / (u32::MAX as f32 + 2.0);
let r = (-2.0 * u1.ln()).sqrt();
let theta = 2.0 * PI * u2;
r * theta.cos()
}
}
// ---------------------------------------------------------------------------
// Tier parameters
// ---------------------------------------------------------------------------
struct TierSpec {
label: &'static str,
n_active: usize, // active (non-pilot) subcarriers passed in frame
bandwidth_mhz: u16,
config: CalibrationConfig,
}
fn ht20_spec() -> TierSpec {
TierSpec { label: "HT20", n_active: 52, bandwidth_mhz: 20, config: CalibrationConfig::ht20() }
}
fn ht40_spec() -> TierSpec {
TierSpec { label: "HT40", n_active: 114, bandwidth_mhz: 40, config: CalibrationConfig::ht40() }
}
fn he20_spec() -> TierSpec {
TierSpec { label: "HE20", n_active: 242, bandwidth_mhz: 20, config: CalibrationConfig::he20() }
}
// ---------------------------------------------------------------------------
// Ground-truth per-subcarrier channel parameters
// ---------------------------------------------------------------------------
fn ground_truth_amp(n: usize) -> Vec<f32> {
(0..n).map(|k| 0.3 + 0.7 * (k as f32 * PI / n as f32).sin().abs()).collect()
}
fn ground_truth_phase(n: usize) -> Vec<f32> {
(0..n).map(|k| (k as f32 * 0.1).rem_euclid(2.0 * PI) - PI).collect()
}
// ---------------------------------------------------------------------------
// CSI frame builder helpers
// ---------------------------------------------------------------------------
fn make_stationary_frame(
bandwidth_mhz: u16,
n_active: usize,
amp: &[f32],
phase: &[f32],
snr_db: f32,
rng: &mut Rng,
) -> CsiFrame {
assert_eq!(amp.len(), n_active);
let signal_power: f32 = amp.iter().map(|a| a * a).sum::<f32>() / n_active as f32;
let noise_power = signal_power / 10_f32.powf(snr_db / 10.0);
let noise_std = (noise_power / 2.0).sqrt();
let mut data = Array2::<Complex64>::zeros((1, n_active));
for k in 0..n_active {
let re = amp[k] * phase[k].cos() + noise_std * rng.next_normal();
let im = amp[k] * phase[k].sin() + noise_std * rng.next_normal();
data[(0, k)] = Complex64::new(re as f64, im as f64);
}
let mut meta = CsiMetadata::new(DeviceId::new("test"), FrequencyBand::Band2_4GHz, 6);
meta.bandwidth_mhz = bandwidth_mhz;
meta.antenna_config = AntennaConfig::new(1, 1);
CsiFrame::new(meta, data)
}
/// Build a frame where subcarrier amplitudes are shifted up by `shift_sigma * sigma`.
fn make_perturbed_frame(
bandwidth_mhz: u16,
n_active: usize,
amp: &[f32],
phase: &[f32],
amp_sigma: f32,
perturb_indices: &[usize],
shift_sigma: f32,
rng: &mut Rng,
) -> CsiFrame {
let noise_std = 0.001_f32;
let mut data = Array2::<Complex64>::zeros((1, n_active));
for k in 0..n_active {
let extra = if perturb_indices.contains(&k) { shift_sigma * amp_sigma } else { 0.0 };
let a = amp[k] + extra;
let re = a * phase[k].cos() + noise_std * rng.next_normal();
let im = a * phase[k].sin() + noise_std * rng.next_normal();
data[(0, k)] = Complex64::new(re as f64, im as f64);
}
let mut meta = CsiMetadata::new(DeviceId::new("test"), FrequencyBand::Band2_4GHz, 6);
meta.bandwidth_mhz = bandwidth_mhz;
meta.antenna_config = AntennaConfig::new(1, 1);
CsiFrame::new(meta, data)
}
// ---------------------------------------------------------------------------
// Helper: build a finalised baseline from 600 stationary frames at SNR=30 dB
// ---------------------------------------------------------------------------
fn build_baseline(spec: &TierSpec) -> BaselineCalibration {
let amp = ground_truth_amp(spec.n_active);
let phase = ground_truth_phase(spec.n_active);
let mut rng = Rng::new(42);
let mut recorder = CalibrationRecorder::new(spec.config.clone());
for _ in 0..600 {
let frame = make_stationary_frame(
spec.bandwidth_mhz, spec.n_active, &amp, &phase, 30.0, &mut rng,
);
recorder.record(&frame).expect("record should succeed");
}
recorder.finalize().expect("finalize should succeed with 600 frames")
}
// ---------------------------------------------------------------------------
// Tests — HT20
// ---------------------------------------------------------------------------
mod ht20 {
use super::*;
#[test]
fn should_record_600_frames_when_600_fed() {
let spec = ht20_spec();
let amp = ground_truth_amp(spec.n_active);
let phase = ground_truth_phase(spec.n_active);
let mut rng = Rng::new(42);
let mut recorder = CalibrationRecorder::new(spec.config.clone());
for _ in 0..600 {
let frame = make_stationary_frame(
spec.bandwidth_mhz, spec.n_active, &amp, &phase, 30.0, &mut rng,
);
recorder.record(&frame).expect("record should succeed");
}
assert_eq!(
recorder.frames_recorded(), 600,
"HT20: frames_recorded() should equal 600"
);
}
#[test]
fn should_finalize_with_amp_mean_within_tolerance_of_ground_truth() {
let spec = ht20_spec();
let amp = ground_truth_amp(spec.n_active);
let baseline = build_baseline(&spec);
let tol = 0.05_f32;
for k in 0..spec.n_active {
let got = baseline.subcarriers[k].amp_mean;
let expected = amp[k];
assert!(
(got - expected).abs() < tol,
"HT20 amp_mean[{}]: got={:.4} expected={:.4} tol={:.4}",
k, got, expected, tol
);
}
}
#[test]
fn should_have_positive_amp_variance_after_finalize() {
let spec = ht20_spec();
let baseline = build_baseline(&spec);
for k in 0..spec.n_active {
assert!(
baseline.subcarriers[k].amp_variance > 0.0,
"HT20 amp_variance[{}] must be positive",
k
);
}
}
#[test]
fn should_have_small_amp_variance_for_stationary_channel() {
let spec = ht20_spec();
let baseline = build_baseline(&spec);
for k in 0..spec.n_active {
assert!(
baseline.subcarriers[k].amp_variance < 0.1,
"HT20 amp_variance[{}]={:.6} must be < 0.1",
k, baseline.subcarriers[k].amp_variance
);
}
}
#[test]
fn should_have_tight_phase_dispersion_for_stationary_channel() {
let spec = ht20_spec();
let baseline = build_baseline(&spec);
for k in 0..spec.n_active {
assert!(
baseline.subcarriers[k].phase_dispersion < 0.05,
"HT20 phase_dispersion[{}]={:.6} must be < 0.05",
k, baseline.subcarriers[k].phase_dispersion
);
}
}
#[test]
fn should_not_flag_motion_for_stationary_frame() {
let spec = ht20_spec();
let amp = ground_truth_amp(spec.n_active);
let phase = ground_truth_phase(spec.n_active);
let baseline = build_baseline(&spec);
let mut rng = Rng::new(999);
let frame = make_stationary_frame(
spec.bandwidth_mhz, spec.n_active, &amp, &phase, 30.0, &mut rng,
);
let score = baseline.deviation(&frame).expect("deviation should succeed");
assert!(
score.amplitude_z_median < 1.5,
"HT20 stationary: amplitude_z_median={:.3} must be < 1.5",
score.amplitude_z_median
);
assert!(
!score.motion_flagged,
"HT20 stationary: motion_flagged must be false"
);
}
#[test]
fn should_flag_motion_for_3sigma_perturbed_frame() {
let spec = ht20_spec();
let amp = ground_truth_amp(spec.n_active);
let phase = ground_truth_phase(spec.n_active);
let baseline = build_baseline(&spec);
// Use mean amp_variance as the sigma estimate
let amp_sigma: f32 = baseline
.subcarriers
.iter()
.map(|sc| sc.amp_variance.sqrt())
.sum::<f32>()
/ spec.n_active as f32;
let perturb_indices: Vec<usize> = (0..spec.n_active).collect();
let mut rng = Rng::new(999);
let frame = make_perturbed_frame(
spec.bandwidth_mhz, spec.n_active, &amp, &phase, amp_sigma,
&perturb_indices, 3.0, &mut rng,
);
let score = baseline.deviation(&frame).expect("deviation should succeed");
assert!(
score.amplitude_z_median > 2.5,
"HT20 perturbed: amplitude_z_median={:.3} must be > 2.5",
score.amplitude_z_median
);
assert!(
score.motion_flagged,
"HT20 perturbed: motion_flagged must be true for 3σ perturbation"
);
}
}
// ---------------------------------------------------------------------------
// Tests — HT40
// ---------------------------------------------------------------------------
mod ht40 {
use super::*;
#[test]
fn should_record_600_frames_when_600_fed() {
let spec = ht40_spec();
let amp = ground_truth_amp(spec.n_active);
let phase = ground_truth_phase(spec.n_active);
let mut rng = Rng::new(42);
let mut recorder = CalibrationRecorder::new(spec.config.clone());
for _ in 0..600 {
let frame = make_stationary_frame(
spec.bandwidth_mhz, spec.n_active, &amp, &phase, 30.0, &mut rng,
);
recorder.record(&frame).expect("record should succeed");
}
assert_eq!(recorder.frames_recorded(), 600, "HT40: frames_recorded() should equal 600");
}
#[test]
fn should_finalize_with_amp_mean_within_tolerance() {
let spec = ht40_spec();
let amp = ground_truth_amp(spec.n_active);
let baseline = build_baseline(&spec);
let tol = 0.05_f32;
for k in 0..spec.n_active {
let got = baseline.subcarriers[k].amp_mean;
let expected = amp[k];
assert!(
(got - expected).abs() < tol,
"HT40 amp_mean[{}]: got={:.4} expected={:.4} tol={:.4}",
k, got, expected, tol
);
}
}
#[test]
fn should_have_tight_phase_dispersion_for_stationary_channel() {
let spec = ht40_spec();
let baseline = build_baseline(&spec);
for k in 0..spec.n_active {
assert!(
baseline.subcarriers[k].phase_dispersion < 0.05,
"HT40 phase_dispersion[{}]={:.6} must be < 0.05",
k, baseline.subcarriers[k].phase_dispersion
);
}
}
#[test]
fn should_not_flag_motion_for_stationary_frame() {
let spec = ht40_spec();
let amp = ground_truth_amp(spec.n_active);
let phase = ground_truth_phase(spec.n_active);
let baseline = build_baseline(&spec);
let mut rng = Rng::new(999);
let frame = make_stationary_frame(
spec.bandwidth_mhz, spec.n_active, &amp, &phase, 30.0, &mut rng,
);
let score = baseline.deviation(&frame).expect("deviation should succeed");
assert!(
!score.motion_flagged,
"HT40 stationary: motion_flagged must be false"
);
}
#[test]
fn should_flag_motion_for_3sigma_perturbed_frame() {
let spec = ht40_spec();
let amp = ground_truth_amp(spec.n_active);
let phase = ground_truth_phase(spec.n_active);
let baseline = build_baseline(&spec);
let amp_sigma: f32 = baseline
.subcarriers
.iter()
.map(|sc| sc.amp_variance.sqrt())
.sum::<f32>()
/ spec.n_active as f32;
let perturb_indices: Vec<usize> = (0..spec.n_active).collect();
let mut rng = Rng::new(999);
let frame = make_perturbed_frame(
spec.bandwidth_mhz, spec.n_active, &amp, &phase, amp_sigma,
&perturb_indices, 3.0, &mut rng,
);
let score = baseline.deviation(&frame).expect("deviation should succeed");
assert!(
score.motion_flagged,
"HT40 perturbed: motion_flagged must be true for 3σ perturbation"
);
}
}
// ---------------------------------------------------------------------------
// Tests — HE20
// ---------------------------------------------------------------------------
mod he20 {
use super::*;
#[test]
fn should_record_600_frames_when_600_fed() {
let spec = he20_spec();
let amp = ground_truth_amp(spec.n_active);
let phase = ground_truth_phase(spec.n_active);
let mut rng = Rng::new(42);
let mut recorder = CalibrationRecorder::new(spec.config.clone());
for _ in 0..600 {
let frame = make_stationary_frame(
spec.bandwidth_mhz, spec.n_active, &amp, &phase, 30.0, &mut rng,
);
recorder.record(&frame).expect("record should succeed");
}
assert_eq!(recorder.frames_recorded(), 600, "HE20: frames_recorded() should equal 600");
}
#[test]
fn should_finalize_with_amp_mean_within_tolerance() {
let spec = he20_spec();
let amp = ground_truth_amp(spec.n_active);
let baseline = build_baseline(&spec);
let tol = 0.05_f32;
for k in 0..spec.n_active {
let got = baseline.subcarriers[k].amp_mean;
let expected = amp[k];
assert!(
(got - expected).abs() < tol,
"HE20 amp_mean[{}]: got={:.4} expected={:.4} tol={:.4}",
k, got, expected, tol
);
}
}
#[test]
fn should_have_tight_phase_dispersion_for_stationary_channel() {
let spec = he20_spec();
let baseline = build_baseline(&spec);
for k in 0..spec.n_active {
assert!(
baseline.subcarriers[k].phase_dispersion < 0.05,
"HE20 phase_dispersion[{}]={:.6} must be < 0.05",
k, baseline.subcarriers[k].phase_dispersion
);
}
}
#[test]
fn should_not_flag_motion_for_stationary_frame() {
let spec = he20_spec();
let amp = ground_truth_amp(spec.n_active);
let phase = ground_truth_phase(spec.n_active);
let baseline = build_baseline(&spec);
let mut rng = Rng::new(999);
let frame = make_stationary_frame(
spec.bandwidth_mhz, spec.n_active, &amp, &phase, 30.0, &mut rng,
);
let score = baseline.deviation(&frame).expect("deviation should succeed");
assert!(
!score.motion_flagged,
"HE20 stationary: motion_flagged must be false"
);
}
#[test]
fn should_flag_motion_for_3sigma_perturbed_frame() {
let spec = he20_spec();
let amp = ground_truth_amp(spec.n_active);
let phase = ground_truth_phase(spec.n_active);
let baseline = build_baseline(&spec);
let amp_sigma: f32 = baseline
.subcarriers
.iter()
.map(|sc| sc.amp_variance.sqrt())
.sum::<f32>()
/ spec.n_active as f32;
let perturb_indices: Vec<usize> = (0..spec.n_active).collect();
let mut rng = Rng::new(999);
let frame = make_perturbed_frame(
spec.bandwidth_mhz, spec.n_active, &amp, &phase, amp_sigma,
&perturb_indices, 3.0, &mut rng,
);
let score = baseline.deviation(&frame).expect("deviation should succeed");
assert!(
score.motion_flagged,
"HE20 perturbed: motion_flagged must be true for 3σ perturbation"
);
}
}