mirror of
https://github.com/ruvnet/RuView
synced 2026-06-09 10:13:17 +00:00
8504638187
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>
244 lines
9.0 KiB
Rust
244 lines
9.0 KiB
Rust
//! 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(&, &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(&, &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
|
||
);
|
||
}
|