//! 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-287 §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 = nominal_poses.iter().map(|_| if sign_rng.gen_bool(0.5) { 1.0 } else { -1.0 }).collect(); let jittered_poses_at = |epsilon: f64| -> Vec { 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 = 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:?}" ); }