Files
ruvnet--RuView/v2/crates/wifi-densepose-sar/tests/physics_validation.rs
T
ruv 895c04747e perf(wifi-densepose-sar): incremental phasor rotation in backprojection (~4.4-4.5x, MEASURED)
focus_at_point called Complex64::from_polar (a sin/cos pair) once per
(pose, frequency) term. FrequencySweep::frequencies() produces evenly
spaced frequencies by construction, so the per-term phase is an
arithmetic progression in the frequency index -- the phasor can be
evaluated once per pose and advanced by a fixed complex-multiply step
per frequency instead, turning K trig evaluations into 2.

focus_at_point's signature changes from a raw &[f64] frequency slice
to &FrequencySweep, so the evenly-spaced-frequencies precondition
this optimization depends on is a type-level invariant rather than a
caller-observed one -- an arbitrary non-uniform frequency list is no
longer constructible through this API at all.

MEASURED (criterion regression detection, p < 0.001): ~4.4-4.5x
faster across 512/4096/32768-voxel grids (300us/1.97ms/14.5ms vs the
prior 1.47ms/10.4ms/73.5ms). Proven equivalent, not just faster: a new
test independently reimplements the direct per-frequency computation
as a reference and checks the optimized path against it across four
sweep sizes (incl. the n_steps=1 degenerate case) and both on-target
and off-target points, to <1e-9 relative error.

25 tests (22 unit + 3 integration), 0 failed, clippy-clean.
2026-07-30 21:09:57 -04:00

216 lines
11 KiB
Rust

//! Checks the reconstruction's *actual* behavior against the closed-form
//! predictions in `wifi_densepose_sar::resolution`, rather than merely
//! asserting the formulas in documentation (ADR-283 §3, the
//! ruview-unified "proven, not asserted" discipline).
//!
//! Three physical claims are validated end-to-end (forward-simulate ->
//! backproject -> measure the reconstruction's behavior):
//!
//! 1. Two point targets separated along *range* resolve into two distinct
//! peaks only once their separation exceeds `range_resolution_m`.
//! 2. Two point targets separated along *cross-range* (same range,
//! different bearing) resolve only once the synthetic-aperture length
//! is long enough per `cross_range_resolution_m` -- a single short
//! aperture cannot resolve them no matter how much bandwidth is used.
//! 3. Antenna-position error decoheres the reconstruction: coherent focus
//! at the true target location trends downward as position error
//! grows past the `max_coherent_pose_error_m` (`λ/8`) budget, and has
//! collapsed toward the incoherent background by an order of
//! magnitude beyond it.
use wifi_densepose_sar::geometry::linear_aperture;
use wifi_densepose_sar::measurement::{simulate_measurement, FrequencySweep, ScatteringTarget};
use wifi_densepose_sar::reconstruct::{backproject, focus_at_point, VoxelGrid};
use wifi_densepose_sar::resolution::{
cross_range_resolution_m, max_coherent_pose_error_m, range_resolution_m, wavelength_m,
};
use wifi_densepose_sar::{AntennaPose, Point3};
/// Count local maxima at or above `threshold_fraction` of the profile's
/// peak, in a 1D magnitude profile. Adjacent samples above threshold count
/// as one maximum (a plateau/peak region), not one-per-sample.
fn count_resolved_peaks(profile: &[f64], threshold_fraction: f64) -> usize {
let peak = profile.iter().cloned().fold(0.0_f64, f64::max);
let threshold = peak * threshold_fraction;
let mut count = 0;
let mut in_peak = false;
for &v in profile {
if v >= threshold {
if !in_peak {
count += 1;
in_peak = true;
}
} else {
in_peak = false;
}
}
count
}
#[test]
fn range_separated_targets_resolve_only_beyond_range_resolution() {
let poses = linear_aperture(Point3::new(-0.5, 0.0, 0.0), Point3::new(0.5, 0.0, 0.0), 21);
let sweep = FrequencySweep::new(2.0e9, 6.0e9, 64); // 4 GHz bandwidth
let dr = range_resolution_m(sweep.bandwidth_hz());
// A 1D range profile: fixed cross-range (x=0, z=0), fine steps in y.
let profile_grid = |center_y: f64, half_span: f64| {
VoxelGrid::new(Point3::new(0.0, center_y - half_span, 0.0), dr / 6.0, 1, (2.0 * half_span / (dr / 6.0)) as usize, 1)
};
// Case A: well separated (4x the theoretical resolution) -> two peaks.
let sep_resolved = 4.0 * dr;
let targets_a = vec![
ScatteringTarget::new(Point3::new(0.0, 2.0 - sep_resolved / 2.0, 0.0), 1.0),
ScatteringTarget::new(Point3::new(0.0, 2.0 + sep_resolved / 2.0, 0.0), 1.0),
];
let meas_a = simulate_measurement(&poses, &sweep, &targets_a, 0.0, 10);
let grid_a = profile_grid(2.0, sep_resolved * 1.5);
let image_a = backproject(&meas_a, &poses, &sweep, &grid_a);
let peaks_a = count_resolved_peaks(&image_a.magnitude, 0.7);
assert_eq!(
peaks_a, 2,
"targets separated by 4x the range resolution ({sep_resolved:.4} m vs dr={dr:.4} m) must resolve into 2 peaks, got {peaks_a}"
);
// Case B: too close (0.25x the theoretical resolution) -> one merged peak.
let sep_unresolved = 0.25 * dr;
let targets_b = vec![
ScatteringTarget::new(Point3::new(0.0, 2.0 - sep_unresolved / 2.0, 0.0), 1.0),
ScatteringTarget::new(Point3::new(0.0, 2.0 + sep_unresolved / 2.0, 0.0), 1.0),
];
let meas_b = simulate_measurement(&poses, &sweep, &targets_b, 0.0, 11);
let grid_b = profile_grid(2.0, dr * 2.0);
let image_b = backproject(&meas_b, &poses, &sweep, &grid_b);
let peaks_b = count_resolved_peaks(&image_b.magnitude, 0.7);
assert_eq!(
peaks_b, 1,
"targets separated by only 0.25x the range resolution must merge into 1 peak, got {peaks_b}"
);
}
#[test]
fn cross_range_separated_targets_resolve_only_with_long_enough_aperture() {
let sweep = FrequencySweep::new(3.0e9, 5.0e9, 32); // center 4 GHz
let range_m = 2.0;
let lambda = wavelength_m(sweep.center_freq_hz());
let long_aperture_len = 1.0;
let short_aperture_len = 0.05;
let res_long = cross_range_resolution_m(sweep.center_freq_hz(), long_aperture_len, range_m);
let res_short = cross_range_resolution_m(sweep.center_freq_hz(), short_aperture_len, range_m);
assert!(res_long < res_short, "a longer aperture must predict finer cross-range resolution");
// Pick a separation that is resolvable with the long aperture (well
// above its predicted resolution) but not with the short one (well
// below its much coarser predicted resolution).
let separation = 5.0 * res_long;
assert!(separation < res_short, "test setup: separation must sit inside the short aperture's blind spot (lambda={lambda:.4})");
let targets = vec![
ScatteringTarget::new(Point3::new(-separation / 2.0, range_m, 0.0), 1.0),
ScatteringTarget::new(Point3::new(separation / 2.0, range_m, 0.0), 1.0),
];
let half_span = separation * 1.5;
let cross_range_grid = || VoxelGrid::new(Point3::new(-half_span, range_m, 0.0), separation / 20.0, (2.0 * half_span / (separation / 20.0)) as usize, 1, 1);
// Long aperture: must resolve into two peaks.
let poses_long = linear_aperture(
Point3::new(-long_aperture_len / 2.0, 0.0, 0.0),
Point3::new(long_aperture_len / 2.0, 0.0, 0.0),
41,
);
let meas_long = simulate_measurement(&poses_long, &sweep, &targets, 0.0, 20);
let grid_long = cross_range_grid();
let image_long = backproject(&meas_long, &poses_long, &sweep, &grid_long);
let peaks_long = count_resolved_peaks(&image_long.magnitude, 0.7);
assert_eq!(peaks_long, 2, "a {long_aperture_len} m synthetic aperture must resolve cross-range-separated targets {separation:.4} m apart, got {peaks_long} peak(s)");
// Short aperture: must NOT resolve (collapses to one blob/ridge).
let poses_short = linear_aperture(
Point3::new(-short_aperture_len / 2.0, 0.0, 0.0),
Point3::new(short_aperture_len / 2.0, 0.0, 0.0),
41,
);
let meas_short = simulate_measurement(&poses_short, &sweep, &targets, 0.0, 21);
let grid_short = cross_range_grid();
let image_short = backproject(&meas_short, &poses_short, &sweep, &grid_short);
let peaks_short = count_resolved_peaks(&image_short.magnitude, 0.7);
assert_eq!(peaks_short, 1, "a {short_aperture_len} m synthetic aperture (far below the required {res_short:.4} m cross-range resolution) must NOT resolve the same targets, got {peaks_short} peak(s)");
}
#[test]
fn phase_error_from_pose_jitter_degrades_focus_beyond_pose_budget() {
use rand::{Rng, SeedableRng};
use rand_chacha::ChaCha20Rng;
let center_freq = 4.0e9;
let sweep = FrequencySweep::new(3.0e9, 5.0e9, 32);
let target = ScatteringTarget::new(Point3::new(0.0, 2.0, 0.0), 1.0);
let nominal_poses = linear_aperture(Point3::new(-0.5, 0.0, 0.0), Point3::new(0.5, 0.0, 0.0), 21);
let budget = max_coherent_pose_error_m(center_freq);
let lambda = wavelength_m(center_freq);
// A single FIXED per-position error *direction* (random sign along
// each antenna's boresight to the target, drawn once), then scaled by
// a growing `epsilon`. This isolates "how does focus respond as
// position-error magnitude grows" from "which specific random error
// pattern did we happen to draw" -- redrawing a fresh random pattern
// at every epsilon level (tried first) makes neighboring levels
// statistically incomparable and the trend noisy enough to need heavy
// Monte Carlo averaging. Levels are kept within half a wavelength
// (4x budget = lambda/2): beyond that, per-position phase error wraps
// past 2*pi and can partially and coincidentally realign at specific
// epsilon values (a real grating/aliasing effect, not a test bug) --
// an honest reason to keep this test inside the regime the lambda/8
// budget is actually about, rather than claiming a monotonic trend
// the underlying physics doesn't guarantee once error exceeds ~lambda.
let mut sign_rng = ChaCha20Rng::seed_from_u64(99);
let signs: Vec<f64> = nominal_poses.iter().map(|_| if sign_rng.gen_bool(0.5) { 1.0 } else { -1.0 }).collect();
let jittered_poses_at = |epsilon: f64| -> Vec<AntennaPose> {
nominal_poses
.iter()
.zip(&signs)
.map(|(p, &sign)| {
let dir = p.position.direction_to(&target.position).expect("pose must not coincide with target");
AntennaPose::new(p.position.translated(dir, sign * epsilon))
})
.collect()
};
let focus_at_epsilon = |epsilon: f64| -> f64 {
let true_poses = jittered_poses_at(epsilon);
// The measurement is recorded at the (jittered) TRUE antenna
// positions, but reconstruction always assumes the NOMINAL
// (design) positions -- the real-world scenario of an
// uncalibrated / imperfectly tracked antenna trajectory.
// Evaluate focus exactly AT the true target location (not a
// grid-wide peak search, which can hop to a nearby voxel that
// happens to focus slightly better and mask the coherence loss
// this test is measuring).
let measurement = simulate_measurement(&true_poses, &sweep, &[target], 0.0, 1);
focus_at_point(&measurement, &nominal_poses, &sweep, &target.position)
};
let levels = [0.0, 0.5 * budget, budget, 2.0 * budget, 4.0 * budget];
assert!(4.0 * budget < lambda / 2.0 + 1e-12, "test setup: must stay within half a wavelength to avoid phase-wrap aliasing");
let focus: Vec<f64> = levels.iter().map(|&eps| focus_at_epsilon(eps)).collect();
assert!(
focus[0] == focus.iter().cloned().fold(0.0, f64::max),
"perfect pose knowledge (zero jitter) must give the best focus of the sweep: {focus:?} at levels {levels:?}"
);
assert!(
focus[4] < 0.85 * focus[0],
"position error at 4x the lambda/8 budget ({:.4} m, still under half a wavelength) must measurably degrade focus: {:.4} vs zero-jitter {:.4}",
4.0 * budget,
focus[4],
focus[0]
);
assert!(
focus[4] <= focus[1] + 1e-9,
"focus at 4x the budget should be no better than focus at 0.5x the budget: {focus:?} at levels {levels:?}"
);
}