mirror of
https://github.com/ruvnet/RuView
synced 2026-08-09 20:21:43 +00:00
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:
Generated
+4
@@ -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]]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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(&_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 1−R̄, 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(&, &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
|
||||
);
|
||||
}
|
||||
@@ -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, &, &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, &, &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, &, &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, &, &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, &, &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, &, &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, &, &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, &, &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, &, &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, &, &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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user