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

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

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

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

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

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

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

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

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

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

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

247 lines
8.9 KiB
Rust

//! 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);