mirror of
https://github.com/ruvnet/RuView
synced 2026-07-31 18:51:42 +00:00
e4695d8c68
ADR-283 was already taken by ADR-283-ruview-community-metaharness-flywheel.md, merged to main before this branch's work started -- picked without checking against main's actual current ADR list. Renumbered to ADR-287, the next free slot after ADR-286 (the wifi-densepose-sar-harness ADR, no collision there). Updated every reference across the crate (Cargo.toml description, lib.rs/ geometry.rs/measurement.rs/pointcloud.rs/reconstruct.rs/resolution.rs doc comments, tests/physics_validation.rs), its README, the tutorial doc, CHANGELOG.md, and the workspace Cargo.toml's member comment. 25 tests still pass after the rename (doc-comment-only changes, no logic touched).
216 lines
11 KiB
Rust
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-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<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:?}"
|
|
);
|
|
}
|