Files
ruvnet--RuView/v2/crates/wifi-densepose-signal/tests/calibration_drift.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

244 lines
9.0 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.
//! Drift-triggered recalibration scenario tests (ADR-135 §2.5 and §2.6).
//!
//! Validates that the deviation z-score escalates correctly under sustained
//! amplitude drift, and stays suppressed for a stable stationary channel.
//!
//! Tests are seeded with literal `42` and are fully deterministic.
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, CalibrationError, CalibrationRecorder,
};
// ---------------------------------------------------------------------------
// Deterministic PRNG (xorshift32, seed=42) — duplicated locally.
// ---------------------------------------------------------------------------
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
}
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()
}
}
// ---------------------------------------------------------------------------
// Constants and helpers
// ---------------------------------------------------------------------------
const N_ACTIVE: usize = 52; // HT20
fn base_amp() -> Vec<f32> {
(0..N_ACTIVE)
.map(|k| 0.3 + 0.7 * (k as f32 * PI / N_ACTIVE as f32).sin().abs())
.collect()
}
fn base_phase() -> Vec<f32> {
(0..N_ACTIVE)
.map(|k| (k as f32 * 0.1).rem_euclid(2.0 * PI) - PI)
.collect()
}
fn make_frame_with_amp(amp_vals: &[f32], phase: &[f32], rng: &mut Rng) -> CsiFrame {
let n = amp_vals.len();
let noise_std = 0.005_f32; // very low noise for clean drift detection
let mut data = Array2::<Complex64>::zeros((1, n));
for k in 0..n {
let re = amp_vals[k] * phase[k].cos() + noise_std * rng.next_normal();
let im = amp_vals[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("drift-test"), FrequencyBand::Band2_4GHz, 6);
meta.bandwidth_mhz = 20;
meta.antenna_config = AntennaConfig::new(1, 1);
CsiFrame::new(meta, data)
}
fn build_baseline() -> BaselineCalibration {
let amp = base_amp();
let phase = base_phase();
let mut rng = Rng::new(42);
let mut recorder = CalibrationRecorder::new(CalibrationConfig::ht20());
for _ in 0..600 {
let frame = make_frame_with_amp(&amp, &phase, &mut rng);
recorder.record(&frame).expect("record");
}
recorder.finalize().expect("finalize")
}
// ---------------------------------------------------------------------------
// Test 1: slow amplitude drift causes z-score to escalate above 4.0 by frame 900
// ---------------------------------------------------------------------------
/// ADR-135 §2.5: drift_score > 4.0 is the recalibration threshold.
/// With amplitude growing +0.01/frame, the squared z-score (relative to baseline
/// variance) must exceed 4.0 on average over the last 100 of 900 frames.
#[test]
fn should_exceed_drift_threshold_when_amplitude_drifts_slowly() {
let baseline = build_baseline();
let base = base_amp();
let phase = base_phase();
let mut rng = Rng::new(42);
let mut last_100_mean_sq_z: Vec<f32> = Vec::new();
for t in 0..900usize {
// Each frame has amplitudes drifted up by +0.01 per frame step
let amp: Vec<f32> = base.iter().map(|a| a + 0.01 * t as f32).collect();
let frame = make_frame_with_amp(&amp, &phase, &mut rng);
let score = baseline.deviation(&frame).expect("deviation");
if t >= 800 {
// amplitude_z_median is the median absolute z. drift_score in ADR-135 is
// mean over k of median squared z over a window. We approximate here
// by squaring the amplitude_z_median.
let approx_drift_score = score.amplitude_z_median * score.amplitude_z_median;
last_100_mean_sq_z.push(approx_drift_score);
}
}
let avg_drift_score: f32 =
last_100_mean_sq_z.iter().sum::<f32>() / last_100_mean_sq_z.len() as f32;
assert!(
avg_drift_score > 4.0,
"drift scenario: approx drift score over last 100 frames = {:.3} must exceed 4.0 \
(ADR-135 drift threshold)",
avg_drift_score
);
}
// ---------------------------------------------------------------------------
// Test 2: 900 stationary frames keep z-score below 2.0
// ---------------------------------------------------------------------------
#[test]
fn should_stay_below_drift_threshold_for_stable_channel() {
let baseline = build_baseline();
let base = base_amp();
let phase = base_phase();
let mut rng = Rng::new(42);
let mut last_100_mean_sq_z: Vec<f32> = Vec::new();
for t in 0..900usize {
let _ = t;
let frame = make_frame_with_amp(&base, &phase, &mut rng);
let score = baseline.deviation(&frame).expect("deviation");
if last_100_mean_sq_z.len() < 100 || t >= 800 {
let approx_drift = score.amplitude_z_median * score.amplitude_z_median;
if t >= 800 {
last_100_mean_sq_z.push(approx_drift);
}
}
}
let avg_drift_score: f32 =
last_100_mean_sq_z.iter().sum::<f32>() / last_100_mean_sq_z.len() as f32;
assert!(
avg_drift_score < 2.0,
"stable scenario: approx drift score over last 100 frames = {:.3} must be < 2.0",
avg_drift_score
);
}
// ---------------------------------------------------------------------------
// Test 3: is_complete() reflects target_frames boundary
// ---------------------------------------------------------------------------
#[test]
fn should_report_not_complete_before_target_frames() {
let base = base_amp();
let phase = base_phase();
let mut rng = Rng::new(42);
// min_frames=600 means recorder needs at least 600 frames before finalize succeeds.
// is_complete() is defined as frames_recorded() >= config.min_frames.
let config = CalibrationConfig::ht20(); // min_frames = 600
let mut recorder = CalibrationRecorder::new(config);
for _ in 0..10 {
let frame = make_frame_with_amp(&base, &phase, &mut rng);
recorder.record(&frame).expect("record");
}
assert_eq!(recorder.frames_recorded(), 10, "frames_recorded should be 10");
// finalize should fail with InsufficientFrames
let result = recorder.finalize();
assert!(
matches!(result, Err(CalibrationError::InsufficientFrames { .. })),
"expected InsufficientFrames after 10 frames, got {:?}", result
);
}
// ---------------------------------------------------------------------------
// Test 4: finalize() returns InsufficientFrames with correct counts
// ---------------------------------------------------------------------------
#[test]
fn should_error_on_finalize_with_insufficient_frames() {
let base = base_amp();
let phase = base_phase();
let mut rng = Rng::new(42);
let mut recorder = CalibrationRecorder::new(CalibrationConfig::ht20()); // min=600
for _ in 0..50 {
let frame = make_frame_with_amp(&base, &phase, &mut rng);
recorder.record(&frame).expect("record");
}
match recorder.finalize() {
Err(CalibrationError::InsufficientFrames { got, need }) => {
assert_eq!(got, 50, "got should be 50");
assert_eq!(need, 600, "need should be 600 (min_frames)");
}
other => panic!("expected InsufficientFrames, got {:?}", other),
}
}
// ---------------------------------------------------------------------------
// Test 5: motion_flagged flips when amplitude jumps substantially
// ---------------------------------------------------------------------------
#[test]
fn should_flag_motion_when_amplitude_jumps_by_many_sigma() {
let baseline = build_baseline();
let phase = base_phase();
// Compute a meaningful sigma: mean amp_variance across subcarriers
let mean_sigma: f32 = baseline
.subcarriers
.iter()
.map(|sc| sc.amp_variance.sqrt())
.sum::<f32>()
/ N_ACTIVE as f32;
// Build a frame with all amplitudes shifted up by 5σ
let base = base_amp();
let shifted_amp: Vec<f32> = base.iter().map(|a| a + 5.0 * mean_sigma).collect();
let mut rng = Rng::new(77);
let frame = make_frame_with_amp(&shifted_amp, &phase, &mut rng);
let score = baseline.deviation(&frame).expect("deviation");
assert!(
score.motion_flagged,
"motion must be flagged when amplitude is shifted by 5σ; \
amplitude_z_median={:.3}",
score.amplitude_z_median
);
}