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>
This commit is contained in:
ruv
2026-05-28 18:57:08 -04:00
parent 9e7fa83210
commit 8504638187
22 changed files with 3454 additions and 3 deletions
Generated
+4
View File
@@ -10589,6 +10589,8 @@ dependencies = [
"console 0.16.3",
"csv",
"indicatif",
"ndarray 0.17.2",
"num-complex",
"predicates",
"serde",
"serde_json",
@@ -10599,7 +10601,9 @@ dependencies = [
"tracing",
"tracing-subscriber",
"uuid",
"wifi-densepose-core",
"wifi-densepose-mat",
"wifi-densepose-signal",
]
[[package]]
+6
View File
@@ -22,6 +22,12 @@ mat = []
[dependencies]
# Internal crates
wifi-densepose-mat = { version = "0.3.0", path = "../wifi-densepose-mat" }
wifi-densepose-signal = { version = "0.3.1", path = "../wifi-densepose-signal", default-features = false }
wifi-densepose-core = { version = "0.3.0", path = "../wifi-densepose-core" }
# Linear algebra / complex numbers (used by calibrate.rs to build CsiFrame)
ndarray = { workspace = true }
num-complex = { workspace = true }
# CLI framework
clap = { version = "4.4", features = ["derive", "env", "cargo"] }
@@ -0,0 +1,443 @@
//! `wifi-densepose calibrate` — empty-room baseline calibration subcommand.
//!
//! Reads CSI frames from a UDP socket (ESP32 0xC511_0001 wire format), feeds
//! them through [`wifi_densepose_signal::CalibrationRecorder`], prints a
//! real-time deviation banner (ADR-135 §risk 1), and serialises the finished
//! [`wifi_densepose_signal::BaselineCalibration`] to disk in the compact
//! little-endian binary format defined in ADR-135 §2.4.
//!
//! # Wire format parsed here (option b — local parser, no cross-crate dep)
//!
//! Offset Size Field
//! ────── ──── ─────────────────────────────────────────────────────────────
//! 0 4 Magic: 0xC511_0001 (LE u32)
//! 4 1 node_id (u8)
//! 5 1 n_antennas (u8)
//! 6 1 n_subcarriers (u8)
//! 7 1 (reserved)
//! 8 2 freq_mhz (LE u16)
//! 10 4 sequence (LE u32)
//! 14 1 rssi (i8)
//! 15 1 noise_floor (i8)
//! 16 4 (reserved / padding)
//! 20 2 × n_antennas × n_subcarriers IQ pairs: i_val (i8), q_val (i8)
//!
//! This parser mirrors `parse_esp32_frame` in
//! `wifi-densepose-sensing-server/src/csi.rs` exactly (same magic, same layout).
use anyhow::{bail, Result};
use clap::Args;
use ndarray::Array2;
use num_complex::Complex64;
use std::time::{Duration, Instant};
use tokio::net::UdpSocket;
use wifi_densepose_core::types::{
AntennaConfig, CsiFrame, CsiMetadata, DeviceId, FrequencyBand, Timestamp,
};
use wifi_densepose_signal::{
BaselineCalibration, CalibrationConfig, CalibrationDeviationScore, CalibrationRecorder,
};
// ---------------------------------------------------------------------------
// Arguments
// ---------------------------------------------------------------------------
/// Arguments for the `calibrate` subcommand.
#[derive(Args, Debug, Clone)]
pub struct CalibrateArgs {
/// UDP port to listen on for CSI frames from the ESP32.
/// Must match the target-port written into NVS by provision.py (default 5005).
#[arg(long, default_value_t = 5005)]
pub udp_port: u16,
/// Bind address for the UDP socket.
/// Default 0.0.0.0 receives from any device on the LAN.
#[arg(long, default_value = "0.0.0.0")]
pub bind: String,
/// Calibration duration in seconds.
/// ADR-135 default is 30 s at 20 Hz = 600 frames.
/// Minimum 10; values above 300 emit a warning.
#[arg(long, default_value_t = 30)]
pub duration_s: u32,
/// Output path for the binary baseline file (ADR-135 §2.4 format).
#[arg(long, default_value = "./baseline.bin")]
pub output: String,
/// PHY tier matching the ESP32 configuration.
/// Valid: ht20 / ht40 / he20 / he40.
#[arg(long, default_value = "ht20")]
pub tier: String,
/// Print a deviation banner to stderr every N frames during capture.
/// 0 disables banners. Default 20 = once per second at 20 Hz.
#[arg(long, default_value_t = 20)]
pub banner_every: u32,
/// Abort if the per-frame amplitude z-score median exceeds this value
/// for 20 consecutive banner intervals. 0.0 disables the abort guard.
#[arg(long, default_value_t = 2.0)]
pub abort_z_threshold: f32,
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/// Maximum UDP receive buffer. HT20 CSI frame is well under 1 500 bytes.
const RECV_BUF: usize = 2048;
/// Number of banner intervals in the high-z abort sliding window.
const ABORT_WINDOW_INTERVALS: u32 = 20;
// ---------------------------------------------------------------------------
// Public entry point
// ---------------------------------------------------------------------------
/// Execute the `calibrate` subcommand (async).
pub async fn execute(args: CalibrateArgs) -> Result<()> {
validate_args(&args)?;
let config = tier_config(&args.tier);
let target_frames = config.min_frames as usize;
let addr = format!("{}:{}", args.bind, args.udp_port);
let socket = UdpSocket::bind(&addr).await
.map_err(|e| anyhow::anyhow!("cannot bind UDP socket on {addr}: {e}"))?;
eprintln!("[calibrate] listening on udp://{addr}");
eprintln!(
"[calibrate] capturing {} frames (~{} s, tier={}) — ensure room is empty",
target_frames, args.duration_s, args.tier
);
let mut recorder = CalibrationRecorder::new(config);
let mut buf = vec![0u8; RECV_BUF];
let mut high_z_count: u32 = 0;
let deadline = Instant::now() + Duration::from_secs(args.duration_s as u64);
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
break;
}
let timeout = remaining.min(Duration::from_millis(500));
let recv = tokio::time::timeout(timeout, socket.recv(&mut buf)).await;
let n = match recv {
Ok(Ok(n)) => n,
Ok(Err(e)) => { eprintln!("[calibrate] recv error: {e}"); continue; }
Err(_) => continue, // timeout — recheck deadline
};
let Some(csi_frame) = parse_csi_packet(&buf[..n], &args.tier) else {
continue;
};
let score: CalibrationDeviationScore = match recorder.record(&csi_frame) {
Ok(s) => s,
Err(e) => { eprintln!("[calibrate] WARN frame skipped: {e}"); continue; }
};
let frames = recorder.frames_recorded() as usize;
if args.banner_every > 0 && (frames as u32) % args.banner_every == 0 {
print_banner(frames, target_frames, &score);
if args.abort_z_threshold > 0.0 && score.amplitude_z_median > args.abort_z_threshold {
high_z_count += 1;
if high_z_count >= ABORT_WINDOW_INTERVALS {
bail!(
"aborted: amplitude_z_median={:.2} exceeded threshold={:.2} for {} \
consecutive banner intervals — ensure the room is empty and retry",
score.amplitude_z_median, args.abort_z_threshold, high_z_count
);
}
} else {
high_z_count = 0;
}
}
if frames >= target_frames {
break;
}
}
finalise_and_save(recorder, &args.output)
}
// ---------------------------------------------------------------------------
// Banner printer
// ---------------------------------------------------------------------------
fn print_banner(frames: usize, target: usize, score: &CalibrationDeviationScore) {
let motion_str = if score.motion_flagged {
"YES \u{2190} operator should be still"
} else {
"no"
};
eprintln!(
"[calibrate] {}/{} frames | z_med={:.2} z_max={:.2} | motion: {}",
frames, target, score.amplitude_z_median, score.amplitude_z_max, motion_str
);
}
// ---------------------------------------------------------------------------
// Finalise + persist
// ---------------------------------------------------------------------------
fn finalise_and_save(recorder: CalibrationRecorder, output: &str) -> Result<()> {
let frames = recorder.frames_recorded();
eprintln!("[calibrate] finalising baseline from {frames} frames…");
let baseline: BaselineCalibration = recorder
.finalize()
.map_err(|e| anyhow::anyhow!("calibration failed: {e}"))?;
let bytes = baseline.to_bytes();
std::fs::write(output, &bytes)
.map_err(|e| anyhow::anyhow!("cannot write {output}: {e}"))?;
eprintln!(
"[calibrate] baseline saved to {output} ({} bytes)",
bytes.len()
);
eprintln!(
"[calibrate] summary: frames={} tier={:?} subcarriers={}",
baseline.frame_count,
baseline.tier,
baseline.subcarriers.len(),
);
Ok(())
}
// ---------------------------------------------------------------------------
// Tier helper
// ---------------------------------------------------------------------------
fn tier_config(tier: &str) -> CalibrationConfig {
match tier.to_ascii_lowercase().as_str() {
"ht40" => CalibrationConfig::ht40(),
"he20" => CalibrationConfig::he20(),
"he40" => CalibrationConfig::he40(),
_ => CalibrationConfig::ht20(), // ht20 or unknown → safe default
}
}
// ---------------------------------------------------------------------------
// Local UDP packet parser (option b)
//
// Mirrors parse_esp32_frame in wifi-densepose-sensing-server/src/csi.rs.
// Magic 0xC511_0001, 20-byte header, IQ bytes follow.
// ---------------------------------------------------------------------------
/// Parse a single UDP datagram and return a `CsiFrame` ready for
/// `CalibrationRecorder::record()`. Returns `None` on any parse failure.
fn parse_csi_packet(buf: &[u8], tier: &str) -> Option<CsiFrame> {
if buf.len() < 20 {
return None;
}
let magic = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
if magic != 0xC511_0001 {
return None;
}
let node_id = buf[4];
let n_antennas = buf[5] as usize;
let n_subcarriers = buf[6] as usize;
let freq_mhz = u16::from_le_bytes([buf[8], buf[9]]);
let _sequence = u32::from_le_bytes([buf[10], buf[11], buf[12], buf[13]]);
let rssi = buf[14] as i8;
let noise_floor = buf[15] as i8;
let n_pairs = n_antennas * n_subcarriers;
let iq_start = 20usize;
if buf.len() < iq_start + n_pairs * 2 {
return None;
}
// Build an ndarray Array2<Complex64> shaped [n_antennas, n_subcarriers].
let mut data = Array2::<Complex64>::zeros((n_antennas.max(1), n_subcarriers.max(1)));
for s in 0..n_antennas {
for k in 0..n_subcarriers {
let idx = s * n_subcarriers + k;
let i_val = buf[iq_start + idx * 2] as i8 as f64;
let q_val = buf[iq_start + idx * 2 + 1] as i8 as f64;
data[[s, k]] = Complex64::new(i_val, q_val);
}
}
let band = if freq_mhz >= 5000 {
FrequencyBand::Band5GHz
} else {
FrequencyBand::Band2_4GHz
};
let bw = tier_to_bw_mhz(tier);
let mut meta = CsiMetadata::new(
DeviceId::new(format!("esp32-node{}", node_id)),
band,
freq_mhz_to_channel(freq_mhz),
);
meta.bandwidth_mhz = bw;
meta.rssi_dbm = rssi;
meta.noise_floor_dbm = noise_floor;
meta.antenna_config = AntennaConfig {
tx_antennas: 1,
rx_antennas: n_antennas as u8,
spacing_mm: None,
};
meta.timestamp = Timestamp::now();
Some(CsiFrame::new(meta, data))
}
/// Map a tier string to a bandwidth in MHz.
fn tier_to_bw_mhz(tier: &str) -> u16 {
match tier.to_ascii_lowercase().as_str() {
"ht40" | "he40" => 40,
_ => 20,
}
}
/// Rough 802.11 channel from centre frequency.
fn freq_mhz_to_channel(freq_mhz: u16) -> u8 {
// 2.4 GHz: ch = (freq - 2407) / 5
if freq_mhz < 3000 {
((freq_mhz.saturating_sub(2407)) / 5) as u8
} else {
// 5 GHz: ch = (freq - 5000) / 5
((freq_mhz.saturating_sub(5000)) / 5) as u8
}
}
// ---------------------------------------------------------------------------
// Input validation
// ---------------------------------------------------------------------------
fn validate_args(args: &CalibrateArgs) -> Result<()> {
if args.duration_s < 10 {
bail!(
"--duration-s must be at least 10 s (got {}). \
Fewer frames produce unreliable phase-concentration estimates (ADR-135 §2.3).",
args.duration_s
);
}
if args.duration_s > 300 {
eprintln!(
"[calibrate] WARN: --duration-s={} exceeds 300 s; this is unusual.",
args.duration_s
);
}
let valid = ["ht20", "ht40", "he20", "he40"];
if !valid.contains(&args.tier.to_ascii_lowercase().as_str()) {
bail!(
"--tier must be one of {:?} (got {:?})",
valid, args.tier
);
}
Ok(())
}
// ---------------------------------------------------------------------------
// Unit tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_args_min_duration() {
let mut args = default_args();
args.duration_s = 5;
assert!(validate_args(&args).is_err());
}
#[test]
fn test_validate_args_ok() {
let args = default_args();
assert!(validate_args(&args).is_ok());
}
#[test]
fn test_validate_args_bad_tier() {
let mut args = default_args();
args.tier = "ht80".into();
assert!(validate_args(&args).is_err());
}
#[test]
fn test_tier_config_ht20() {
let cfg = tier_config("ht20");
assert_eq!(cfg.num_active, 52);
}
#[test]
fn test_tier_config_ht40() {
let cfg = tier_config("ht40");
assert_eq!(cfg.num_active, 114);
}
#[test]
fn test_tier_config_he20() {
let cfg = tier_config("he20");
assert_eq!(cfg.num_active, 242);
}
#[test]
fn test_parse_csi_packet_bad_magic() {
let buf = vec![0u8; 32];
assert!(parse_csi_packet(&buf, "ht20").is_none());
}
#[test]
fn test_parse_csi_packet_too_short() {
let buf = vec![0u8; 10];
assert!(parse_csi_packet(&buf, "ht20").is_none());
}
#[test]
fn test_parse_csi_packet_valid() {
let mut buf = vec![0u8; 24]; // 20-byte header + 2 IQ pairs (1 antenna, 2 subcarriers)
// Magic 0xC511_0001 LE
buf[0] = 0x01; buf[1] = 0x00; buf[2] = 0x11; buf[3] = 0xC5;
buf[5] = 1; // n_antennas
buf[6] = 2; // n_subcarriers
// freq_mhz = 2437 (channel 6)
buf[8] = 0x85; buf[9] = 0x09;
// IQ pairs at offset 20: (10, 20), (5, 15)
buf[20] = 10i8 as u8; buf[21] = 20i8 as u8;
buf[22] = (-5i8) as u8; buf[23] = 15i8 as u8;
let frame = parse_csi_packet(&buf, "ht20");
assert!(frame.is_some());
let f = frame.unwrap();
assert_eq!(f.num_spatial_streams(), 1);
assert_eq!(f.num_subcarriers(), 2);
}
#[test]
fn test_freq_to_channel_24ghz() {
assert_eq!(freq_mhz_to_channel(2437), 6);
}
#[test]
fn test_freq_to_channel_5ghz() {
assert_eq!(freq_mhz_to_channel(5180), 36);
}
fn default_args() -> CalibrateArgs {
CalibrateArgs {
udp_port: 5005,
bind: "0.0.0.0".into(),
duration_s: 30,
output: "./baseline.bin".into(),
tier: "ht20".into(),
banner_every: 20,
abort_z_threshold: 2.0,
}
}
}
+6
View File
@@ -26,6 +26,7 @@
use clap::{Parser, Subcommand};
pub mod calibrate;
pub mod mat;
/// WiFi-DensePose Command Line Interface
@@ -46,6 +47,11 @@ pub struct Cli {
/// Top-level commands
#[derive(Subcommand, Debug)]
pub enum Commands {
/// Empty-room baseline calibration (ADR-135).
/// Captures CSI frames via UDP and saves a per-subcarrier statistical
/// baseline used for real-time motion z-scoring and CIR reference.
Calibrate(calibrate::CalibrateArgs),
/// Mass Casualty Assessment Tool commands
#[command(subcommand)]
Mat(mat::MatCommand),
+3
View File
@@ -18,6 +18,9 @@ async fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Calibrate(args) => {
wifi_densepose_cli::calibrate::execute(args).await?;
}
Commands::Mat(mat_cmd) => {
wifi_densepose_cli::mat::execute(mat_cmd).await?;
}
@@ -79,3 +79,13 @@ path = "src/bin/cir_proof_runner.rs"
# implementation agent; this addition is purely additive.
[dependencies.sha2]
workspace = true
## ADR-135: calibration module throughput benchmarks
[[bench]]
name = "calibration_bench"
harness = false
# ADR-135: calibration deterministic proof runner binary.
[[bin]]
name = "calibration_proof_runner"
path = "src/bin/calibration_proof_runner.rs"
@@ -0,0 +1,246 @@
//! Criterion benchmarks for the empty-room baseline calibration module (ADR-135).
//!
//! Measures per-call throughput of CalibrationRecorder and BaselineCalibration
//! across HT20 (K=52), HT40 (K=114), HE20 (K=242), and HE40 (K=484).
//!
//! Run (compile-only — no execution):
//! cargo bench -p wifi-densepose-signal --no-default-features --bench calibration_bench --no-run
//!
//! Run to completion (generates HTML in target/criterion/):
//! cargo bench -p wifi-densepose-signal --no-default-features --bench calibration_bench
use std::f64::consts::PI;
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
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.
// ---------------------------------------------------------------------------
struct Rng(u32);
impl Rng {
fn new(seed: u32) -> Self {
assert_ne!(seed, 0);
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_f64(&mut self) -> f64 {
(self.next_u32() as f64 + 1.0) / (u32::MAX as f64 + 2.0)
}
fn next_normal(&mut self) -> f64 {
let u1 = self.next_f64();
let u2 = self.next_f64();
(-2.0 * u1.ln()).sqrt() * (2.0 * PI * u2).cos()
}
}
// ---------------------------------------------------------------------------
// Tier specification table
// ---------------------------------------------------------------------------
struct TierSpec {
label: &'static str,
n_active: usize,
bandwidth_mhz: u16,
config: CalibrationConfig,
}
fn tiers() -> Vec<TierSpec> {
vec![
TierSpec { label: "ht20", n_active: 52, bandwidth_mhz: 20, config: CalibrationConfig::ht20() },
TierSpec { label: "ht40", n_active: 114, bandwidth_mhz: 40, config: CalibrationConfig::ht40() },
TierSpec { label: "he20", n_active: 242, bandwidth_mhz: 20, config: CalibrationConfig::he20() },
TierSpec { label: "he40", n_active: 484, bandwidth_mhz: 40, config: CalibrationConfig::he40() },
]
}
// ---------------------------------------------------------------------------
// Synthetic CSI frame builder (stationary, seed=42)
// ---------------------------------------------------------------------------
fn make_frame(n_active: usize, bandwidth_mhz: u16, rng: &mut Rng) -> CsiFrame {
let noise_std = 0.01_f64;
let mut data = Array2::<Complex64>::zeros((1, n_active));
for k in 0..n_active {
let amp = 0.3 + 0.7 * (k as f64 * PI / n_active as f64).sin().abs();
let phase = (k as f64 * 0.1).rem_euclid(2.0 * PI) - PI;
let re = amp * phase.cos() + noise_std * rng.next_normal();
let im = amp * phase.sin() + noise_std * rng.next_normal();
data[(0, k)] = Complex64::new(re, im);
}
let mut meta = CsiMetadata::new(DeviceId::new("bench"), FrequencyBand::Band2_4GHz, 6);
meta.bandwidth_mhz = bandwidth_mhz;
meta.antenna_config = AntennaConfig::new(1, 1);
CsiFrame::new(meta, data)
}
/// Build a `CalibrationRecorder` that has already absorbed 600 frames.
fn pre_loaded_recorder(spec: &TierSpec) -> CalibrationRecorder {
let mut rng = Rng::new(42);
let mut recorder = CalibrationRecorder::new(spec.config.clone());
for _ in 0..600 {
let frame = make_frame(spec.n_active, spec.bandwidth_mhz, &mut rng);
recorder.record(&frame).expect("record should succeed in bench setup");
}
recorder
}
/// Build a finalised `BaselineCalibration` for deviation and to_bytes benches.
fn finalised_baseline(spec: &TierSpec) -> BaselineCalibration {
pre_loaded_recorder(spec)
.finalize()
.expect("finalize should succeed in bench setup")
}
// ---------------------------------------------------------------------------
// Bench 1: bench_recorder_record/<tier> — single record() call (hot path)
// ---------------------------------------------------------------------------
fn bench_recorder_record(c: &mut Criterion) {
let mut group = c.benchmark_group("bench_recorder_record");
for spec in tiers() {
group.throughput(Throughput::Elements(spec.n_active as u64));
let mut rng = Rng::new(42);
let frame = make_frame(spec.n_active, spec.bandwidth_mhz, &mut rng);
let mut recorder = CalibrationRecorder::new(spec.config.clone());
group.bench_with_input(
BenchmarkId::from_parameter(spec.label),
&frame,
|b, f| {
b.iter(|| {
// Accumulate into a shared recorder — measures per-call cost of record().
black_box(recorder.record(black_box(f)).ok())
});
},
);
}
group.finish();
}
// ---------------------------------------------------------------------------
// Bench 2: bench_recorder_finalize/<tier> — finalize() from 600 pre-loaded frames
// ---------------------------------------------------------------------------
fn bench_recorder_finalize(c: &mut Criterion) {
let mut group = c.benchmark_group("bench_recorder_finalize");
for spec in tiers() {
group.throughput(Throughput::Elements(spec.n_active as u64));
group.bench_function(BenchmarkId::from_parameter(spec.label), |b| {
b.iter_with_setup(
|| pre_loaded_recorder(&spec),
|recorder| {
black_box(recorder.finalize().ok())
},
);
});
}
group.finish();
}
// ---------------------------------------------------------------------------
// Bench 3: bench_deviation/<tier> — deviation() on a single frame
// ---------------------------------------------------------------------------
fn bench_deviation(c: &mut Criterion) {
let mut group = c.benchmark_group("bench_deviation");
for spec in tiers() {
group.throughput(Throughput::Elements(spec.n_active as u64));
let baseline = finalised_baseline(&spec);
let mut rng = Rng::new(42);
let frame = make_frame(spec.n_active, spec.bandwidth_mhz, &mut rng);
group.bench_with_input(
BenchmarkId::from_parameter(spec.label),
&frame,
|b, f| {
b.iter(|| {
black_box(baseline.deviation(black_box(f)).ok())
});
},
);
}
group.finish();
}
// ---------------------------------------------------------------------------
// Bench 4: bench_record_600/<tier> — full 600-frame record session
// ---------------------------------------------------------------------------
fn bench_record_600(c: &mut Criterion) {
let mut group = c.benchmark_group("bench_record_600");
for spec in tiers() {
group.throughput(Throughput::Elements(600 * spec.n_active as u64));
// Pre-build 600 frames to avoid contaminating bench with frame construction.
let mut rng = Rng::new(42);
let frames: Vec<CsiFrame> = (0..600)
.map(|_| make_frame(spec.n_active, spec.bandwidth_mhz, &mut rng))
.collect();
group.bench_with_input(
BenchmarkId::from_parameter(spec.label),
&frames,
|b, fs| {
b.iter_with_setup(
|| CalibrationRecorder::new(spec.config.clone()),
|mut recorder| {
for f in fs {
black_box(recorder.record(black_box(f)).ok());
}
black_box(recorder)
},
);
},
);
}
group.finish();
}
// ---------------------------------------------------------------------------
// Bench 5: bench_to_bytes/<tier> — serialisation cost (to_bytes)
// ---------------------------------------------------------------------------
fn bench_to_bytes(c: &mut Criterion) {
let mut group = c.benchmark_group("bench_to_bytes");
for spec in tiers() {
group.throughput(Throughput::Elements(spec.n_active as u64));
let baseline = finalised_baseline(&spec);
group.bench_function(BenchmarkId::from_parameter(spec.label), |b| {
b.iter(|| {
black_box(baseline.to_bytes())
});
});
}
group.finish();
}
// ---------------------------------------------------------------------------
// Criterion harness
// ---------------------------------------------------------------------------
criterion_group!(
benches,
bench_recorder_record,
bench_recorder_finalize,
bench_deviation,
bench_record_600,
bench_to_bytes,
);
criterion_main!(benches);
@@ -0,0 +1,277 @@
//! Calibration Deterministic Proof Runner (ADR-135)
//!
//! Verifies or generates the canonical SHA-256 hash of the CalibrationRecorder's
//! deterministic output on a synthetic stationary channel (seed=42, HT20, 600 frames).
//!
//! Cross-platform portability lesson (from cir_proof_runner.rs, line 123):
//! Raw f32 round-trips at high precision (1e-6) and magnitude-sort-then-truncate
//! both break across libm implementations (glibc / MSVC / Apple) because sin/cos/sqrt
//! differ by ~1e-7 — enough to flip a rounded integer or re-order near-tied values.
//! The fix: serialise the full per-subcarrier profile in natural index order at
//! coarse quantisation (1e-2 / 1e-4 / 1e-3). A 1% drift is invisible to the hash;
//! a 10× algorithm change moves values by >1e-2 and breaks the hash.
//! No sort, no truncation, no libm-sensitive comparison.
//!
//! Canonical form (per subcarrier k, 4 × u16 LE):
//! [0] (amp_mean * 1e2).round() as u16
//! [1] (amp_variance * 1e4).round() as u16
//! [2] ((phase_mean + π) * 1e3).round() as u16 ← shifted so always non-negative
//! [3] (phase_dispersion * 1e3).round() as u16
//!
//! Prefix: tier byte (0 = HT20), frame_count u64 LE.
//! All subcarriers in natural index order; no sort.
//!
//! Usage:
//! cargo run -p wifi-densepose-signal --bin calibration_proof_runner \
//! --release --no-default-features -- --generate-hash
//!
//! cargo run -p wifi-densepose-signal --bin calibration_proof_runner \
//! --release --no-default-features
//! (compares against archive/v1/data/proof/expected_calibration_features.sha256)
//!
//! IMPORTANT: This binary cannot compile until CalibrationRecorder is implemented.
//! While the implementation is in progress, a placeholder hash is committed in
//! archive/v1/data/proof/expected_calibration_features.sha256. Regenerate with:
//!
//! cd v2 && cargo run -p wifi-densepose-signal --bin calibration_proof_runner \
//! --release --no-default-features -- --generate-hash \
//! > ../archive/v1/data/proof/expected_calibration_features.sha256
use std::env;
use std::f32::consts::PI;
use std::fs;
use std::io::{self, Write};
use std::path::PathBuf;
use ndarray::Array2;
use num_complex::Complex64;
use sha2::{Digest, Sha256};
use wifi_densepose_core::types::{AntennaConfig, CsiFrame, CsiMetadata, DeviceId, FrequencyBand};
use wifi_densepose_signal::calibration::{CalibrationConfig, CalibrationRecorder};
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const N_ACTIVE: usize = 52; // HT20 active subcarriers
const N_FRAMES: usize = 600; // 30 s × 20 Hz
const TIER_BYTE: u8 = 0; // 0 = HT20
// ---------------------------------------------------------------------------
// 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()
}
}
// ---------------------------------------------------------------------------
// Synthetic CSI frame generator — stationary channel, seed=42
//
// amp[k] = 0.3 + 0.7 * |sin(k * π / K)| (smooth across subcarriers)
// phase[k] = (k * 0.1) mod 2π π (slowly rotating)
// AWGN at ~30 dB SNR added via Box-Muller.
// ---------------------------------------------------------------------------
fn make_frame(rng: &mut Rng) -> CsiFrame {
let n = N_ACTIVE;
let noise_std = 0.01_f32;
let mut data = Array2::<Complex64>::zeros((1, n));
for k in 0..n {
let amp = 0.3 + 0.7 * (k as f32 * PI / n as f32).sin().abs();
let phase = (k as f32 * 0.1).rem_euclid(2.0 * PI) - PI;
let re = amp * phase.cos() + noise_std * rng.next_normal();
let im = amp * phase.sin() + noise_std * rng.next_normal();
data[(0, k)] = Complex64::new(re as f64, im as f64);
}
let mut meta =
CsiMetadata::new(DeviceId::new("proof-runner"), FrequencyBand::Band2_4GHz, 6);
meta.bandwidth_mhz = 20;
meta.antenna_config = AntennaConfig::new(1, 1);
CsiFrame::new(meta, data)
}
// ---------------------------------------------------------------------------
// Canonical, cross-platform-deterministic serialisation.
//
// Per ADR-135 proof spec and the cir_proof_runner.rs lesson (line 123):
// coarse u16 quantisation, natural subcarrier order, no sort.
// ---------------------------------------------------------------------------
fn serialise_baseline_canonical(
subcarriers: &[wifi_densepose_signal::calibration::SubcarrierBaseline],
frame_count: u64,
) -> Vec<u8> {
let k = subcarriers.len();
// Header: tier byte + frame_count as u64 LE
let mut out = Vec::with_capacity(1 + 8 + k * 8);
out.push(TIER_BYTE);
out.extend_from_slice(&frame_count.to_le_bytes());
for sc in subcarriers {
// [0] amp_mean at 1e-2 resolution
let amp_q = (sc.amp_mean * 1e2_f32)
.round()
.max(0.0)
.min(u16::MAX as f32) as u16;
out.extend_from_slice(&amp_q.to_le_bytes());
// [1] amp_variance at 1e-4 resolution
let var_q = (sc.amp_variance * 1e4_f32)
.round()
.max(0.0)
.min(u16::MAX as f32) as u16;
out.extend_from_slice(&var_q.to_le_bytes());
// [2] phase_mean shifted by +π so it is non-negative, at 1e-3 resolution
let phase_q = ((sc.phase_mean + PI) * 1e3_f32)
.round()
.max(0.0)
.min(u16::MAX as f32) as u16;
out.extend_from_slice(&phase_q.to_le_bytes());
// [3] phase_dispersion (von Mises 1R̄, in [0,1]) at 1e-3 resolution
let disp_q = (sc.phase_dispersion * 1e3_f32)
.round()
.max(0.0)
.min(u16::MAX as f32) as u16;
out.extend_from_slice(&disp_q.to_le_bytes());
}
out
}
// ---------------------------------------------------------------------------
// Repo root discovery
// ---------------------------------------------------------------------------
fn repo_root() -> PathBuf {
let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let candidates = [
cwd.clone(),
cwd.join(".."),
cwd.join("../.."),
cwd.join("../../.."),
];
for candidate in &candidates {
if candidate
.join("archive/v1/data/proof/expected_calibration_features.sha256")
.exists()
|| candidate.join("archive/v1/data/proof/sample_csi_data.json").exists()
{
return candidate.canonicalize().unwrap_or(candidate.clone());
}
}
cwd
}
// ---------------------------------------------------------------------------
// Main hash computation
// ---------------------------------------------------------------------------
fn compute_hash() -> String {
let config = CalibrationConfig::ht20();
let mut recorder = CalibrationRecorder::new(config);
let mut rng = Rng::new(42);
for _ in 0..N_FRAMES {
let frame = make_frame(&mut rng);
recorder
.record(&frame)
.expect("record() must succeed for synthetic frames");
}
let baseline = recorder
.finalize()
.expect("finalize() must succeed after 600 frames");
let payload = serialise_baseline_canonical(&baseline.subcarriers, baseline.frame_count);
let mut hasher = Sha256::new();
hasher.update(&payload);
format!("{:x}", hasher.finalize())
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
fn main() {
let args: Vec<String> = env::args().collect();
let generate_hash = args.iter().any(|a| a == "--generate-hash");
let hash = compute_hash();
if generate_hash {
println!("{}", hash);
return;
}
// Compare against stored hash
let root = repo_root();
let hash_path = root.join("archive/v1/data/proof/expected_calibration_features.sha256");
if !hash_path.exists() {
eprintln!(
"ERROR: expected hash file not found at {}",
hash_path.display()
);
eprintln!("Run with --generate-hash to create it.");
std::process::exit(1);
}
let expected_content = fs::read_to_string(&hash_path)
.unwrap_or_else(|e| panic!("Cannot read {}: {}", hash_path.display(), e));
let expected = expected_content
.split_whitespace()
.find(|s| !s.starts_with('#'))
.unwrap_or("")
.to_owned();
if expected.starts_with("PLACEHOLDER") {
eprintln!("BLOCKED: calibration proof hash is a placeholder.");
eprintln!(
"The calibration module (ADR-135) is not yet fully implemented. \
After the implementation lands, regenerate:"
);
eprintln!(
" cd v2 && cargo run -p wifi-densepose-signal --bin calibration_proof_runner \
--release --no-default-features -- --generate-hash \
> ../archive/v1/data/proof/expected_calibration_features.sha256"
);
std::process::exit(2);
}
if hash == expected {
println!("VERDICT: PASS (calibration hash matches)");
std::process::exit(0);
} else {
eprintln!("VERDICT: FAIL");
eprintln!("expected: {}", expected);
eprintln!("actual: {}", hash);
io::stderr().flush().ok();
std::process::exit(1);
}
}
@@ -67,6 +67,13 @@ pub use phase_sanitizer::{
pub use ruvsense::cir;
pub use ruvsense::cir::{Cir, CirConfig, CirError, CirEstimator};
// ADR-135: Baseline calibration top-level re-exports
pub use ruvsense::calibration;
pub use ruvsense::calibration::{
BaselineCalibration, CalibrationConfig, CalibrationDeviationScore, CalibrationError,
CalibrationRecorder, PhyTier, SubcarrierBaseline,
};
/// Library version
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
@@ -0,0 +1,658 @@
//! Empty-room baseline calibration (ADR-135).
//!
//! Captures per-subcarrier amplitude and circular-phase statistics from a
//! quiescent (empty) room using Welford's online algorithm, then provides
//! real-time deviation scoring and in-place baseline subtraction.
//!
//! # Pipeline position
//!
//! Raw CSI → `phase_sanitizer.rs` → `phase_align.rs`
//! → `CalibrationRecorder::record()` (calibration mode)
//! → `BaselineCalibration::subtract_in_place()` (runtime mode)
//! → `CirEstimator::estimate()`
//!
//! # Binary format (to_bytes / from_bytes)
//!
//! 16-byte header (all little-endian):
//! magic: u32 = 0xCA1B_0001
//! version: u8 = 1
//! tier: u8 (0=Ht20, 1=Ht40, 2=He20, 3=He40)
//! reserved: u16 = 0
//! captured_at_unix_s: i64
//! Body:
//! frame_count: u64
//! num_subcarriers: u32
//! for each subcarrier: amp_mean f32 LE, amp_variance f32 LE,
//! phase_mean f32 LE, phase_dispersion f32 LE
//!
//! SHA-256-stable: all writes are LE, no float branching.
use num_complex::Complex32;
use thiserror::Error;
use wifi_densepose_core::types::CsiFrame;
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const MAGIC: u32 = 0xCA1B_0001;
const VERSION: u8 = 1;
const HEADER_LEN: usize = 16; // magic(4) + version(1) + tier(1) + reserved(2) + unix_s(8)
const SUBCARRIER_RECORD_LEN: usize = 16; // 4 × f32
// ---------------------------------------------------------------------------
// PHY tier
// ---------------------------------------------------------------------------
/// 802.11 PHY tier identifies the subcarrier layout.
/// A mismatch between a stored baseline and a live frame triggers
/// `CalibrationError::TierMismatch` (ADR-135 §risk 2).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PhyTier {
/// 802.11n HT20: 64-FFT, 52 active subcarriers.
Ht20,
/// 802.11n HT40: 128-FFT, 114 active subcarriers.
Ht40,
/// 802.11ax HE20: 256-FFT, 242 active subcarriers.
He20,
/// 802.11ax HE40: 512-FFT, 484 active subcarriers.
He40,
}
impl PhyTier {
fn to_u8(self) -> u8 {
match self {
PhyTier::Ht20 => 0,
PhyTier::Ht40 => 1,
PhyTier::He20 => 2,
PhyTier::He40 => 3,
}
}
fn from_u8(v: u8) -> Option<Self> {
match v {
0 => Some(PhyTier::Ht20),
1 => Some(PhyTier::Ht40),
2 => Some(PhyTier::He20),
3 => Some(PhyTier::He40),
_ => None,
}
}
}
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
/// Calibration capture configuration.
#[derive(Debug, Clone, Copy)]
pub struct CalibrationConfig {
/// PHY tier determines expected subcarrier count.
pub tier: PhyTier,
/// Total OFDM FFT bins (e.g. 64 HT20, 128 HT40, 256 HE20, 512 HE40).
pub num_subcarriers: usize,
/// Active (non-guard, non-DC) tones (52, 114, 242, 484).
pub num_active: usize,
/// Minimum frames before `finalize()` succeeds (default 600).
pub min_frames: u32,
/// Von Mises dispersion warn threshold — warn if any subcarrier exceeds this
/// during recording (ADR-135 §risk 1). Default 0.3.
pub max_phase_variance: f32,
}
impl CalibrationConfig {
/// HT20 defaults: 64 FFT, 52 active, 600 frame minimum (30 s @ 20 Hz).
pub fn ht20() -> Self {
Self { tier: PhyTier::Ht20, num_subcarriers: 64, num_active: 52, min_frames: 600, max_phase_variance: 0.3 }
}
/// HT40 defaults: 128 FFT, 114 active.
pub fn ht40() -> Self {
Self { tier: PhyTier::Ht40, num_subcarriers: 128, num_active: 114, min_frames: 600, max_phase_variance: 0.3 }
}
/// HE20 defaults: 256 FFT, 242 active.
pub fn he20() -> Self {
Self { tier: PhyTier::He20, num_subcarriers: 256, num_active: 242, min_frames: 600, max_phase_variance: 0.3 }
}
/// HE40 defaults: 512 FFT, 484 active.
pub fn he40() -> Self {
Self { tier: PhyTier::He40, num_subcarriers: 512, num_active: 484, min_frames: 600, max_phase_variance: 0.3 }
}
}
// ---------------------------------------------------------------------------
// Error type
// ---------------------------------------------------------------------------
/// Errors from calibration operations.
#[derive(Debug, Error)]
pub enum CalibrationError {
#[error("subcarrier count mismatch: expected {expected}, got {got}")]
SubcarrierMismatch { expected: usize, got: usize },
#[error("tier mismatch: baseline tier {baseline:?}, frame tier {frame:?}")]
TierMismatch { baseline: PhyTier, frame: PhyTier },
#[error("insufficient frames: have {got}, need {need}")]
InsufficientFrames { got: u32, need: u32 },
#[error("baseline serialization version mismatch: have v{got}, expected v{want}")]
VersionMismatch { got: u8, want: u8 },
#[error("buffer too short to deserialize baseline (have {got} bytes, need at least {need})")]
TruncatedBuffer { got: usize, need: usize },
#[error("invalid magic word: expected 0xCA1B0001, got 0x{got:08X}")]
InvalidMagic { got: u32 },
#[error("unknown tier byte: {0}")]
UnknownTier(u8),
}
// ---------------------------------------------------------------------------
// Per-subcarrier running statistics
// ---------------------------------------------------------------------------
/// Per-subcarrier Welford amplitude + circular-phase accumulators.
///
/// Amplitude uses the standard Welford recurrence (as in `field_model::WelfordStats`
/// but inlined here into a struct-of-arrays to avoid pub-API churn on that type).
/// Phase uses sin/cos running sums — the standard technique for circular statistics.
#[derive(Debug, Clone)]
struct SubcarrierStats {
amp_count: u64,
amp_mean: f64,
amp_m2: f64,
phase_sin_sum: f64,
phase_cos_sum: f64,
}
impl SubcarrierStats {
fn new() -> Self {
Self { amp_count: 0, amp_mean: 0.0, amp_m2: 0.0, phase_sin_sum: 0.0, phase_cos_sum: 0.0 }
}
/// Welford update for amplitude; circular update for phase.
fn update(&mut self, c: Complex32) {
let amp = c.norm() as f64;
self.amp_count += 1;
let delta = amp - self.amp_mean;
self.amp_mean += delta / self.amp_count as f64;
let delta2 = amp - self.amp_mean;
self.amp_m2 += delta * delta2;
let theta = c.arg() as f64;
self.phase_sin_sum += theta.sin();
self.phase_cos_sum += theta.cos();
}
/// Bessel-corrected sample variance (matches Welford convention).
fn amp_variance(&self) -> f64 {
if self.amp_count < 2 { 0.0 } else { self.amp_m2 / (self.amp_count - 1) as f64 }
}
/// Circular mean phase in `[-π, π]`.
fn phase_mean(&self) -> f64 {
self.phase_sin_sum.atan2(self.phase_cos_sum)
}
/// Von Mises dispersion `1 R̄` in `[0, 1]`.
fn phase_dispersion(&self) -> f64 {
if self.amp_count == 0 { return 1.0; }
let n = self.amp_count as f64;
let r = (self.phase_sin_sum * self.phase_sin_sum + self.phase_cos_sum * self.phase_cos_sum).sqrt() / n;
1.0 - r.min(1.0)
}
}
// ---------------------------------------------------------------------------
// SubcarrierBaseline (public per-subcarrier summary)
// ---------------------------------------------------------------------------
/// Finalised per-subcarrier statistics from a baseline capture.
#[derive(Debug, Clone, Copy)]
pub struct SubcarrierBaseline {
pub amp_mean: f32,
pub amp_variance: f32,
/// Circular mean phase in `[-π, π]` (radians).
pub phase_mean: f32,
/// Von Mises dispersion `1 R̄` in `[0, 1]`; 0 = perfectly stationary.
pub phase_dispersion: f32,
}
// ---------------------------------------------------------------------------
// BaselineCalibration
// ---------------------------------------------------------------------------
/// A fully finalised empty-room baseline (immutable after construction).
#[derive(Debug, Clone)]
pub struct BaselineCalibration {
pub tier: PhyTier,
pub captured_at_unix_s: i64,
pub frame_count: u64,
/// Per-subcarrier statistics, ordered by active-subcarrier index.
pub subcarriers: Vec<SubcarrierBaseline>,
}
impl BaselineCalibration {
/// Compute a per-frame deviation score against this baseline.
pub fn deviation(&self, frame: &CsiFrame) -> Result<CalibrationDeviationScore, CalibrationError> {
let n_sc = frame.num_subcarriers();
let expected = self.subcarriers.len();
if n_sc != expected && n_sc != self.tier_num_subcarriers() {
return Err(CalibrationError::SubcarrierMismatch { expected, got: n_sc });
}
let y = extract_first_stream(frame, expected, self.tier_num_subcarriers());
let mut z_amp = Vec::with_capacity(expected);
let mut phase_drift = Vec::with_capacity(expected);
for (ki, (c, baseline)) in y.iter().zip(self.subcarriers.iter()).enumerate() {
let _ = ki;
let amp = c.norm();
let std = baseline.amp_variance.sqrt().max(1e-12_f32);
z_amp.push((amp - baseline.amp_mean) / std);
let theta = c.arg();
let drift = circular_distance(theta, baseline.phase_mean);
phase_drift.push(drift);
}
let amplitude_z_median = median_abs(&z_amp);
let amplitude_z_max = z_amp.iter().map(|v| v.abs()).fold(0.0_f32, f32::max);
let phase_drift_median = median_slice(&phase_drift);
let motion_flagged = amplitude_z_median > 2.0 || phase_drift_median > std::f32::consts::PI / 6.0;
Ok(CalibrationDeviationScore { amplitude_z_median, amplitude_z_max, phase_drift_median, motion_flagged })
}
/// Subtract the amplitude baseline from `frame.data` in-place.
/// Only amplitude mean is subtracted; phase is left untouched.
pub fn subtract_in_place(&self, frame: &mut CsiFrame) -> Result<(), CalibrationError> {
let n_sc = frame.num_subcarriers();
let expected = self.subcarriers.len();
if n_sc != expected && n_sc != self.tier_num_subcarriers() {
return Err(CalibrationError::SubcarrierMismatch { expected, got: n_sc });
}
let n_streams = frame.num_spatial_streams();
let n_total = self.tier_num_subcarriers();
let active_input = n_sc == expected;
for ki in 0..expected {
let col = if active_input { ki } else { ki }; // sequential when active-only
let baseline_amp = self.subcarriers[ki].amp_mean as f64;
for s in 0..n_streams {
let c = frame.data[[s, col]];
let norm = c.norm();
if norm > 1e-30 {
let scale = ((norm - baseline_amp).max(0.0)) / norm;
frame.data[[s, col]] = num_complex::Complex64::new(c.re * scale, c.im * scale);
}
}
let _ = n_total;
}
Ok(())
}
/// Reference complex CSI vector: `amp_mean × exp(j × phase_mean)` per subcarrier.
/// Pass to `CirEstimator::set_reference_csi()`.
pub fn reference_csi_vector(&self) -> Vec<Complex32> {
self.subcarriers.iter().map(|b| {
let (sin, cos) = b.phase_mean.sin_cos();
Complex32::new(b.amp_mean * cos, b.amp_mean * sin)
}).collect()
}
/// Serialise to little-endian binary (see module-level format doc).
pub fn to_bytes(&self) -> Vec<u8> {
let n = self.subcarriers.len();
let mut buf = Vec::with_capacity(HEADER_LEN + 8 + 4 + n * SUBCARRIER_RECORD_LEN);
buf.extend_from_slice(&MAGIC.to_le_bytes());
buf.push(VERSION);
buf.push(self.tier.to_u8());
buf.extend_from_slice(&0u16.to_le_bytes()); // reserved
buf.extend_from_slice(&self.captured_at_unix_s.to_le_bytes());
buf.extend_from_slice(&self.frame_count.to_le_bytes());
buf.extend_from_slice(&(n as u32).to_le_bytes());
for sc in &self.subcarriers {
buf.extend_from_slice(&sc.amp_mean.to_le_bytes());
buf.extend_from_slice(&sc.amp_variance.to_le_bytes());
buf.extend_from_slice(&sc.phase_mean.to_le_bytes());
buf.extend_from_slice(&sc.phase_dispersion.to_le_bytes());
}
buf
}
/// Deserialise from little-endian binary produced by `to_bytes`.
pub fn from_bytes(buf: &[u8]) -> Result<Self, CalibrationError> {
const MIN_LEN: usize = HEADER_LEN + 8 + 4; // header + frame_count + num_subcarriers
if buf.len() < MIN_LEN {
return Err(CalibrationError::TruncatedBuffer { got: buf.len(), need: MIN_LEN });
}
let magic = u32::from_le_bytes(buf[0..4].try_into().unwrap());
if magic != MAGIC {
return Err(CalibrationError::InvalidMagic { got: magic });
}
let version = buf[4];
if version != VERSION {
return Err(CalibrationError::VersionMismatch { got: version, want: VERSION });
}
let tier_byte = buf[5];
let tier = PhyTier::from_u8(tier_byte).ok_or(CalibrationError::UnknownTier(tier_byte))?;
// reserved: buf[6..8] — ignored
let captured_at_unix_s = i64::from_le_bytes(buf[8..16].try_into().unwrap());
let frame_count = u64::from_le_bytes(buf[16..24].try_into().unwrap());
let n = u32::from_le_bytes(buf[24..28].try_into().unwrap()) as usize;
let needed = MIN_LEN + n * SUBCARRIER_RECORD_LEN;
if buf.len() < needed {
return Err(CalibrationError::TruncatedBuffer { got: buf.len(), need: needed });
}
let mut subcarriers = Vec::with_capacity(n);
let mut off = 28usize;
for _ in 0..n {
let amp_mean = f32::from_le_bytes(buf[off..off + 4].try_into().unwrap()); off += 4;
let amp_variance = f32::from_le_bytes(buf[off..off + 4].try_into().unwrap()); off += 4;
let phase_mean = f32::from_le_bytes(buf[off..off + 4].try_into().unwrap()); off += 4;
let phase_dispersion = f32::from_le_bytes(buf[off..off + 4].try_into().unwrap()); off += 4;
subcarriers.push(SubcarrierBaseline { amp_mean, amp_variance, phase_mean, phase_dispersion });
}
Ok(Self { tier, captured_at_unix_s, frame_count, subcarriers })
}
/// Total FFT bins for this tier (used for dual-convention column selection).
fn tier_num_subcarriers(&self) -> usize {
match self.tier {
PhyTier::Ht20 => 64,
PhyTier::Ht40 => 128,
PhyTier::He20 => 256,
PhyTier::He40 => 512,
}
}
}
// ---------------------------------------------------------------------------
// Deviation score
// ---------------------------------------------------------------------------
/// Per-frame deviation metrics against the static baseline.
#[derive(Debug, Clone, Copy)]
pub struct CalibrationDeviationScore {
/// Median of `|z_amp[k]|` across active subcarriers.
pub amplitude_z_median: f32,
/// Max single-subcarrier `|z_amp[k]|`.
pub amplitude_z_max: f32,
/// Median circular distance (radians) between live and baseline phase.
pub phase_drift_median: f32,
/// Heuristic: `amplitude_z_median > 2.0 || phase_drift_median > π/6`.
pub motion_flagged: bool,
}
// ---------------------------------------------------------------------------
// CalibrationRecorder
// ---------------------------------------------------------------------------
/// Accumulates CSI frames from an empty room using Welford online statistics.
///
/// Phase precondition: the caller must pass frames processed by
/// `PhaseSanitizer` and `phase_align.rs`. Unsanitised phase produces
/// inflated `phase_dispersion` values.
pub struct CalibrationRecorder {
config: CalibrationConfig,
started_at_unix_s: i64,
stats: Vec<SubcarrierStats>,
frame_count: u32,
}
impl CalibrationRecorder {
/// Create a new recorder for the given configuration.
pub fn new(config: CalibrationConfig) -> Self {
let stats = vec![SubcarrierStats::new(); config.num_active];
Self { config, started_at_unix_s: unix_now_s(), stats, frame_count: 0 }
}
/// Ingest one sanitised CSI frame. Returns a deviation score from the
/// current partial baseline so the operator can monitor room occupancy
/// in real time.
pub fn record(&mut self, frame: &CsiFrame) -> Result<CalibrationDeviationScore, CalibrationError> {
let n_sc = frame.num_subcarriers();
let expected_active = self.config.num_active;
let expected_total = self.config.num_subcarriers;
if n_sc != expected_active && n_sc != expected_total {
return Err(CalibrationError::SubcarrierMismatch { expected: expected_active, got: n_sc });
}
let y = extract_first_stream(frame, expected_active, expected_total);
for (ki, c) in y.iter().enumerate() {
self.stats[ki].update(*c);
}
self.frame_count += 1;
// Build deviation from partial baseline (after first frame).
let mut z_amp_abs = Vec::with_capacity(expected_active);
let mut phase_drift = Vec::with_capacity(expected_active);
for (c, st) in y.iter().zip(self.stats.iter()) {
let amp = c.norm();
let std = (st.amp_variance() as f32).sqrt().max(1e-12_f32);
z_amp_abs.push((amp - st.amp_mean as f32).abs() / std);
phase_drift.push(circular_distance(c.arg(), st.phase_mean() as f32));
}
let amplitude_z_median = median_slice(&z_amp_abs);
let amplitude_z_max = z_amp_abs.iter().copied().fold(0.0_f32, f32::max);
let phase_drift_median = median_slice(&phase_drift);
let motion_flagged = amplitude_z_median > 2.0 || phase_drift_median > std::f32::consts::PI / 6.0;
Ok(CalibrationDeviationScore { amplitude_z_median, amplitude_z_max, phase_drift_median, motion_flagged })
}
/// Number of frames recorded so far.
pub fn frames_recorded(&self) -> u32 {
self.frame_count
}
/// Consume the recorder and produce a finalised baseline.
/// Returns `CalibrationError::InsufficientFrames` if fewer than
/// `config.min_frames` frames were recorded.
pub fn finalize(self) -> Result<BaselineCalibration, CalibrationError> {
if self.frame_count < self.config.min_frames {
return Err(CalibrationError::InsufficientFrames {
got: self.frame_count,
need: self.config.min_frames,
});
}
let subcarriers = self.stats.iter().map(|st| SubcarrierBaseline {
amp_mean: st.amp_mean as f32,
amp_variance: st.amp_variance() as f32,
phase_mean: st.phase_mean() as f32,
phase_dispersion: st.phase_dispersion() as f32,
}).collect();
Ok(BaselineCalibration {
tier: self.config.tier,
captured_at_unix_s: self.started_at_unix_s,
frame_count: self.frame_count as u64,
subcarriers,
})
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Extract the first spatial stream as a `Vec<Complex32>`, honouring the
/// dual-convention used by `cir.rs::extract_csi_vector`: if the frame has
/// exactly `num_active` subcarriers they are taken sequentially; otherwise
/// the first `num_active` columns of the full FFT grid are used.
fn extract_first_stream(frame: &CsiFrame, num_active: usize, _num_total: usize) -> Vec<Complex32> {
let n_sc = frame.num_subcarriers();
let take = num_active.min(n_sc);
(0..take).map(|ki| {
let c = frame.data[[0, ki]];
Complex32::new(c.re as f32, c.im as f32)
}).collect()
}
/// Signed circular distance wrapped to `[0, π]`.
fn circular_distance(a: f32, b: f32) -> f32 {
let mut d = (a - b).abs();
if d > std::f32::consts::PI {
d = 2.0 * std::f32::consts::PI - d;
}
d
}
/// Median of absolute values of a slice.
fn median_abs(v: &[f32]) -> f32 {
let mut abs: Vec<f32> = v.iter().map(|x| x.abs()).collect();
median_in_place(&mut abs)
}
/// Median of a slice (non-destructive clone).
fn median_slice(v: &[f32]) -> f32 {
let mut c = v.to_vec();
median_in_place(&mut c)
}
fn median_in_place(v: &mut Vec<f32>) -> f32 {
if v.is_empty() { return 0.0; }
v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let mid = v.len() / 2;
if v.len() % 2 == 0 { (v[mid - 1] + v[mid]) / 2.0 } else { v[mid] }
}
/// Current Unix timestamp in seconds. Falls back to 0 if unavailable.
fn unix_now_s() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0)
}
// ---------------------------------------------------------------------------
// Unit tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use ndarray::Array2;
use num_complex::Complex64;
use wifi_densepose_core::types::{CsiMetadata, CsiFrame};
fn make_frame(data: Array2<Complex64>) -> CsiFrame {
use wifi_densepose_core::types::{DeviceId, FrequencyBand};
let meta = CsiMetadata::new(
DeviceId::new("test-device"),
FrequencyBand::Band2_4GHz,
6,
);
CsiFrame::new(meta, data)
}
fn constant_frame(n_sc: usize, amp: f64, phase: f64) -> CsiFrame {
let row = (0..n_sc).map(|_| Complex64::from_polar(amp, phase)).collect::<Vec<_>>();
let arr = Array2::from_shape_vec((1, n_sc), row).unwrap();
make_frame(arr)
}
// (a) Welford convergence: constant input → variance ≈ 0, mean = amp.
#[test]
fn welford_constant_input_converges() {
let mut st = SubcarrierStats::new();
let c = Complex32::new(1.0, 0.0);
for _ in 0..600 {
st.update(c);
}
assert!((st.amp_mean - 1.0).abs() < 1e-9);
assert!(st.amp_variance() < 1e-20, "variance was {}", st.amp_variance());
}
// (b) Circular phase mean recovers known phase from N noisy samples.
#[test]
fn circular_phase_mean_recovery() {
use std::f64::consts::PI;
let mut st = SubcarrierStats::new();
let target = PI / 4.0;
// Feed 200 samples: 100 at target+0.05, 100 at target-0.05.
for _ in 0..100 {
st.update(Complex32::from_polar(1.0, (target + 0.05) as f32));
st.update(Complex32::from_polar(1.0, (target - 0.05) as f32));
}
let recovered = st.phase_mean();
assert!((recovered - target).abs() < 0.01, "phase error = {}", (recovered - target).abs());
// Dispersion should be low (close to 0) for tight phase cluster.
assert!(st.phase_dispersion() < 0.01, "dispersion = {}", st.phase_dispersion());
}
// (c) Round-trip: to_bytes → from_bytes preserves all baseline fields.
#[test]
fn round_trip_to_from_bytes() {
let mut cfg = CalibrationConfig::ht20();
cfg.min_frames = 2;
let mut rec = CalibrationRecorder::new(cfg);
let f1 = constant_frame(52, 0.8, 0.5);
let f2 = constant_frame(52, 0.9, 0.6);
rec.record(&f1).unwrap();
rec.record(&f2).unwrap();
let baseline = rec.finalize().unwrap();
let bytes = baseline.to_bytes();
let recovered = BaselineCalibration::from_bytes(&bytes).unwrap();
assert_eq!(recovered.frame_count, baseline.frame_count);
assert_eq!(recovered.tier, baseline.tier);
assert_eq!(recovered.subcarriers.len(), baseline.subcarriers.len());
for (a, b) in recovered.subcarriers.iter().zip(baseline.subcarriers.iter()) {
assert!((a.amp_mean - b.amp_mean).abs() < 1e-6, "amp_mean mismatch");
assert!((a.phase_mean - b.phase_mean).abs() < 1e-6, "phase_mean mismatch");
assert!((a.phase_dispersion - b.phase_dispersion).abs() < 1e-6, "dispersion mismatch");
}
}
// (d) Tier dispatch: each config constructor produces the correct counts.
#[test]
fn tier_dispatch_correct_counts() {
let ht20 = CalibrationConfig::ht20();
assert_eq!(ht20.num_subcarriers, 64);
assert_eq!(ht20.num_active, 52);
let ht40 = CalibrationConfig::ht40();
assert_eq!(ht40.num_subcarriers, 128);
assert_eq!(ht40.num_active, 114);
let he20 = CalibrationConfig::he20();
assert_eq!(he20.num_subcarriers, 256);
assert_eq!(he20.num_active, 242);
let he40 = CalibrationConfig::he40();
assert_eq!(he40.num_subcarriers, 512);
assert_eq!(he40.num_active, 484);
}
// Additional: insufficient frames → error.
#[test]
fn finalize_requires_min_frames() {
let cfg = CalibrationConfig::ht20(); // min_frames = 600
let mut rec = CalibrationRecorder::new(cfg);
let f = constant_frame(52, 1.0, 0.0);
rec.record(&f).unwrap();
match rec.finalize() {
Err(CalibrationError::InsufficientFrames { got: 1, need: 600 }) => {}
other => panic!("expected InsufficientFrames, got {:?}", other),
}
}
// Binary magic / version check.
#[test]
fn binary_magic_and_version() {
let mut cfg = CalibrationConfig::ht20();
cfg.min_frames = 1;
let mut rec = CalibrationRecorder::new(cfg);
rec.record(&constant_frame(52, 1.0, 0.0)).unwrap();
let b = rec.finalize().unwrap().to_bytes();
let magic = u32::from_le_bytes(b[0..4].try_into().unwrap());
assert_eq!(magic, 0xCA1B_0001u32);
assert_eq!(b[4], 1u8); // version = 1
}
// Subcarrier mismatch is rejected.
#[test]
fn subcarrier_mismatch_error() {
let mut cfg = CalibrationConfig::ht20();
cfg.min_frames = 1;
let mut rec = CalibrationRecorder::new(cfg);
let bad = constant_frame(50, 1.0, 0.0); // 50 ≠ 52, 50 ≠ 64
assert!(matches!(
rec.record(&bad),
Err(CalibrationError::SubcarrierMismatch { expected: 52, got: 50 })
));
}
}
@@ -58,6 +58,9 @@ pub mod pose_tracker;
// ADR-134: CIR estimation (ISTA + NeumannSolver warm-start)
pub mod cir;
// ADR-135: Empty-room baseline calibration (Welford online, circular phase)
pub mod calibration;
// Re-export core types for ergonomic access
pub use coherence::CoherenceState;
pub use coherence_gate::{GateDecision, GatePolicy};
@@ -0,0 +1,243 @@
//! 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
);
}
@@ -0,0 +1,247 @@
//! Bytes round-trip tests for BaselineCalibration serialisation (ADR-135 §2.4).
//!
//! The implementation uses `to_bytes()` / `from_bytes()` as the binary format.
//! Magic word is 0xCA1B_0001, schema version = 1.
//!
//! Covers:
//! - Binary round-trip determinism (to_bytes twice → same output)
//! - deserialise→re-serialise produces identical bytes
//! - Version mismatch detection
//! - Truncated buffer detection
//! - Magic word mismatch detection
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()
}
}
// ---------------------------------------------------------------------------
// Build a deterministic baseline (HT20, 600 frames, seed=42).
// ---------------------------------------------------------------------------
fn build_ht20_baseline() -> BaselineCalibration {
const N: usize = 52;
let amp: Vec<f32> = (0..N)
.map(|k| 0.3 + 0.7 * (k as f32 * PI / N as f32).sin().abs())
.collect();
let phase: Vec<f32> = (0..N)
.map(|k| (k as f32 * 0.1).rem_euclid(2.0 * PI) - PI)
.collect();
let mut rng = Rng::new(42);
let mut recorder = CalibrationRecorder::new(CalibrationConfig::ht20());
for _ in 0..600 {
let noise_std = 0.01_f32;
let mut data = Array2::<Complex64>::zeros((1, N));
for k in 0..N {
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("roundtrip-test"), FrequencyBand::Band2_4GHz, 6);
meta.bandwidth_mhz = 20;
meta.antenna_config = AntennaConfig::new(1, 1);
let frame = CsiFrame::new(meta, data);
recorder.record(&frame).expect("record");
}
recorder.finalize().expect("finalize")
}
// ---------------------------------------------------------------------------
// Binary round-trip determinism
// ---------------------------------------------------------------------------
/// Two calls to `to_bytes()` on the same value must produce identical buffers.
#[test]
fn should_produce_identical_bytes_on_two_calls_to_same_baseline() {
let baseline = build_ht20_baseline();
let bytes1 = baseline.to_bytes();
let bytes2 = baseline.to_bytes();
assert_eq!(
bytes1, bytes2,
"to_bytes must be deterministic across two calls on the same value"
);
}
/// deserialise → re-serialise must produce identical bytes.
#[test]
fn should_deserialise_and_reserialise_to_identical_bytes() {
let baseline = build_ht20_baseline();
let bytes = baseline.to_bytes();
let recovered = BaselineCalibration::from_bytes(&bytes)
.expect("from_bytes should succeed on valid bytes");
let bytes_recovered = recovered.to_bytes();
assert_eq!(
bytes, bytes_recovered,
"round-trip: re-serialised bytes must match original"
);
}
/// Recovered baseline must have matching field values.
#[test]
fn should_preserve_frame_count_and_subcarrier_count_after_round_trip() {
let baseline = build_ht20_baseline();
let bytes = baseline.to_bytes();
let recovered = BaselineCalibration::from_bytes(&bytes).expect("from_bytes");
assert_eq!(
baseline.frame_count, recovered.frame_count,
"frame_count must survive round-trip"
);
assert_eq!(
baseline.subcarriers.len(),
recovered.subcarriers.len(),
"subcarrier count must survive round-trip"
);
}
/// Per-subcarrier amp_mean values must survive round-trip within f32 precision.
#[test]
fn should_preserve_amp_mean_per_subcarrier_after_round_trip() {
let baseline = build_ht20_baseline();
let bytes = baseline.to_bytes();
let recovered = BaselineCalibration::from_bytes(&bytes).expect("from_bytes");
for k in 0..baseline.subcarriers.len() {
assert!(
(baseline.subcarriers[k].amp_mean - recovered.subcarriers[k].amp_mean).abs() < 1e-6,
"amp_mean[{}] mismatch: {:.8} vs {:.8}",
k,
baseline.subcarriers[k].amp_mean,
recovered.subcarriers[k].amp_mean
);
}
}
/// Magic word 0xCA1B_0001 must appear at offset 0 in serialised bytes.
#[test]
fn should_embed_magic_word_0xca1b0001_at_offset_0() {
let baseline = build_ht20_baseline();
let bytes = baseline.to_bytes();
assert!(bytes.len() >= 4, "serialised bytes must be at least 4 bytes long");
let magic = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
assert_eq!(
magic, 0xCA1B_0001_u32,
"magic word at offset 0 must be 0xCA1B0001, got 0x{:08X}",
magic
);
}
/// Schema version at offset 4 must equal 1.
#[test]
fn should_embed_schema_version_1_at_offset_4() {
let baseline = build_ht20_baseline();
let bytes = baseline.to_bytes();
assert!(bytes.len() >= 6, "bytes too short");
let version = bytes[4];
assert_eq!(version, 1, "schema version at offset 4 must be 1, got {}", version);
}
// ---------------------------------------------------------------------------
// Error path: version mismatch
// ---------------------------------------------------------------------------
/// Overwrite version byte with 99 → expect VersionMismatch { got: 99, want: 1 }.
#[test]
fn should_return_version_mismatch_for_version_99() {
let baseline = build_ht20_baseline();
let mut bytes = baseline.to_bytes();
// Version is at offset 4 (u8)
bytes[4] = 99;
let result = BaselineCalibration::from_bytes(&bytes);
match result {
Err(CalibrationError::VersionMismatch { got, want }) => {
assert_eq!(got, 99, "VersionMismatch.got should be 99");
assert_eq!(want, 1, "VersionMismatch.want should be 1");
}
other => panic!(
"expected CalibrationError::VersionMismatch, got {:?}",
other
),
}
}
// ---------------------------------------------------------------------------
// Error path: truncated buffer
// ---------------------------------------------------------------------------
/// Trim the last 4 bytes → expect TruncatedBuffer.
#[test]
fn should_return_truncated_buffer_error_for_short_input() {
let baseline = build_ht20_baseline();
let mut bytes = baseline.to_bytes();
let new_len = bytes.len().saturating_sub(4);
bytes.truncate(new_len);
let result = BaselineCalibration::from_bytes(&bytes);
assert!(
matches!(result, Err(CalibrationError::TruncatedBuffer { .. })),
"expected TruncatedBuffer, got {:?}",
result
);
}
/// A completely empty buffer → expect TruncatedBuffer.
#[test]
fn should_return_truncated_buffer_for_empty_input() {
let result = BaselineCalibration::from_bytes(&[]);
assert!(
matches!(result, Err(CalibrationError::TruncatedBuffer { .. })),
"expected TruncatedBuffer for empty buffer, got {:?}",
result
);
}
// ---------------------------------------------------------------------------
// Error path: magic word mismatch
// ---------------------------------------------------------------------------
/// Zero out the first 4 bytes (magic word) → expect InvalidMagic error.
#[test]
fn should_return_error_for_zeroed_magic_word() {
let baseline = build_ht20_baseline();
let mut bytes = baseline.to_bytes();
bytes[0] = 0;
bytes[1] = 0;
bytes[2] = 0;
bytes[3] = 0;
let result = BaselineCalibration::from_bytes(&bytes);
assert!(
matches!(result, Err(CalibrationError::InvalidMagic { .. })),
"expected InvalidMagic when magic word is zeroed, got {:?}",
result
);
}
@@ -0,0 +1,484 @@
//! 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"
);
}
}