use crate::inertial::quantum_imu::CaiAccelerometer;
use crate::mapmatch::{hierarchical_offset_search, map_match_likelihood};
use crate::particle_filter::ParticleFilter;
use rand::SeedableRng;
use rand_chacha::ChaCha8Rng;
use rand_distr::{Distribution, Normal};
use serde::Deserialize;
const GM_EARTH: f64 = 3.986_004_418e14;
const R_EARTH: f64 = 6_378_137.0;
pub(crate) const M_PER_DEG: f64 = 111_319.490_793_27;
pub const MGAL: f64 = 1.0e-5;
#[allow(clippy::needless_range_loop)]
fn normalized_legendre(nmax: usize, t: f64) -> Vec<Vec<f64>> {
let u = (1.0 - t * t).max(0.0).sqrt(); let mut p = vec![vec![0.0_f64; nmax + 1]; nmax + 1];
p[0][0] = 1.0;
for m in 1..=nmax {
p[m][m] = (2 * m - 1) as f64 * u * p[m - 1][m - 1];
}
for m in 0..nmax {
p[m + 1][m] = t * (2 * m + 1) as f64 * p[m][m];
}
for m in 0..=nmax {
for n in (m + 2)..=nmax {
p[n][m] = ((2 * n - 1) as f64 * t * p[n - 1][m] - (n + m - 1) as f64 * p[n - 2][m])
/ (n - m) as f64;
}
}
let mut pbar = vec![vec![0.0_f64; nmax + 1]; nmax + 1];
for n in 0..=nmax {
for m in 0..=n {
let delta = if m == 0 { 1.0 } else { 2.0 };
let mut ratio = 1.0_f64;
for k in (n - m + 1)..=(n + m) {
ratio /= k as f64;
}
let norm = (delta * (2 * n + 1) as f64 * ratio).sqrt();
pbar[n][m] = norm * p[n][m];
}
}
pbar
}
#[derive(Clone, Copy, Debug, Deserialize)]
pub struct Mascon {
pub lat_deg: f64,
pub lon_deg: f64,
pub amp_mgal: f64,
pub sigma_deg: f64,
}
#[derive(Clone, Debug)]
pub struct GravityAnomalyModel {
nmax: usize,
cbar: Vec<Vec<f64>>,
sbar: Vec<Vec<f64>>,
mascons: Vec<Mascon>,
}
impl GravityAnomalyModel {
pub fn new(nmax: usize) -> Self {
Self {
nmax,
cbar: vec![vec![0.0; nmax + 1]; nmax + 1],
sbar: vec![vec![0.0; nmax + 1]; nmax + 1],
mascons: Vec::new(),
}
}
pub fn set_coeff(&mut self, n: usize, m: usize, cbar: f64, sbar: f64) {
if n <= self.nmax && m <= n {
self.cbar[n][m] = cbar;
self.sbar[n][m] = sbar;
}
}
pub fn add_mascon(&mut self, m: Mascon) {
self.mascons.push(m);
}
#[allow(clippy::needless_range_loop)]
pub fn anomaly_mgal(&self, lat_rad: f64, lon_rad: f64) -> f64 {
let t = lat_rad.sin();
let pbar = normalized_legendre(self.nmax, t);
let mut sum = 0.0;
for n in 2..=self.nmax {
let scale = (n as f64) - 1.0;
for m in 0..=n {
let ml = (m as f64) * lon_rad;
let trig = self.cbar[n][m] * ml.cos() + self.sbar[n][m] * ml.sin();
sum += scale * trig * pbar[n][m];
}
}
let mut anom = (GM_EARTH / (R_EARTH * R_EARTH)) * sum / MGAL;
let lat_deg = lat_rad.to_degrees();
let lon_deg = lon_rad.to_degrees();
let cos_lat = lat_rad.cos();
for ms in &self.mascons {
let dlat = lat_deg - ms.lat_deg;
let dlon = (lon_deg - ms.lon_deg) * cos_lat; let r2 = (dlat * dlat + dlon * dlon) / (2.0 * ms.sigma_deg * ms.sigma_deg);
anom += ms.amp_mgal * (-r2).exp();
}
anom
}
pub fn sampler_deg(&self) -> impl Fn(f64, f64) -> f64 + '_ {
move |lat_deg: f64, lon_deg: f64| {
self.anomaly_mgal(lat_deg.to_radians(), lon_deg.to_radians())
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct Gravimeter {
asd_si: f64,
bias_mgal: f64,
}
impl Gravimeter {
pub fn new(asd_si: f64, bias_mgal: f64) -> Self {
Self { asd_si, bias_mgal }
}
pub fn from_cai(sensor: &CaiAccelerometer, bias_mgal: f64) -> Self {
Self::new(sensor.accel_asd(), bias_mgal)
}
pub fn measurement_sigma_mgal(&self, tau_s: f64) -> f64 {
if tau_s <= 0.0 {
return f64::INFINITY;
}
(self.asd_si / tau_s.sqrt()) / MGAL
}
pub fn measure(&self, true_anomaly_mgal: f64, noise_sample_mgal: f64) -> f64 {
true_anomaly_mgal + self.bias_mgal + noise_sample_mgal
}
}
#[derive(Clone, Copy, Debug, Deserialize)]
pub struct CoeffEntry {
pub n: usize,
pub m: usize,
pub cbar: f64,
pub sbar: f64,
}
#[derive(Clone, Debug, Deserialize)]
pub struct GravityMapBenchmarkCfg {
pub nmax: usize,
#[serde(default)]
pub coeffs: Vec<CoeffEntry>,
#[serde(default)]
pub mascons: Vec<Mascon>,
pub start_lat_deg: f64,
pub start_lon_deg: f64,
pub step_lat_deg: f64,
pub step_lon_deg: f64,
pub waypoints: usize,
pub drift_lat_deg: f64,
pub drift_lon_deg: f64,
pub gravimeter_asd: f64,
pub averaging_time_s: f64,
pub map_sigma_mgal: f64,
pub search_half_deg: f64,
pub search_step_deg: f64,
#[serde(default = "default_refine_stages")]
pub refine_stages: usize,
#[serde(default = "default_refine_factor")]
pub refine_factor: f64,
#[serde(default)]
pub noise_seed: u64,
}
fn default_refine_stages() -> usize {
1
}
fn default_refine_factor() -> f64 {
8.0
}
#[derive(Clone, Copy, Debug)]
pub struct GravityMapNavResult {
pub free_inertial_drift_m: f64,
pub map_matched_error_m: f64,
pub measurement_sigma_mgal: f64,
}
pub fn run_gravity_map_benchmark(cfg: &GravityMapBenchmarkCfg) -> GravityMapNavResult {
let mut model = GravityAnomalyModel::new(cfg.nmax);
for c in &cfg.coeffs {
model.set_coeff(c.n, c.m, c.cbar, c.sbar);
}
for m in &cfg.mascons {
model.add_mascon(*m);
}
let field = model.sampler_deg();
let grav = Gravimeter::new(cfg.gravimeter_asd, 0.0);
let sensor_sigma = grav.measurement_sigma_mgal(cfg.averaging_time_s);
let sigma_mgal = (sensor_sigma * sensor_sigma + cfg.map_sigma_mgal * cfg.map_sigma_mgal).sqrt();
let truth: Vec<(f64, f64)> = (0..cfg.waypoints)
.map(|k| {
(
cfg.start_lat_deg + cfg.step_lat_deg * k as f64,
cfg.start_lon_deg + cfg.step_lon_deg * k as f64,
)
})
.collect();
let measured: Vec<f64> = truth.iter().map(|&(la, lo)| field(la, lo)).collect();
let ins: Vec<(f64, f64)> = truth
.iter()
.map(|&(la, lo)| (la + cfg.drift_lat_deg, lo + cfg.drift_lon_deg))
.collect();
let mut particles = Vec::new();
let n_side = (cfg.search_half_deg / cfg.search_step_deg).round() as i64;
for i in -n_side..=n_side {
for j in -n_side..=n_side {
particles.push(vec![
i as f64 * cfg.search_step_deg,
j as f64 * cfg.search_step_deg,
]);
}
}
let mut pf = ParticleFilter::new(particles);
pf.update(|delta| {
let mut like = 1.0;
for (k, &(la, lo)) in ins.iter().enumerate() {
let hlat = la - delta[0];
let hlon = lo - delta[1];
like *= map_match_likelihood(&field, hlat, hlon, measured[k], sigma_mgal);
}
like
});
let est = pf.estimate();
let mid_lat = cfg.start_lat_deg + cfg.step_lat_deg * (cfg.waypoints as f64 - 1.0) / 2.0;
let cos_lat = mid_lat.to_radians().cos();
let to_m = |dlat: f64, dlon: f64| {
let north = dlat * M_PER_DEG;
let east = dlon * M_PER_DEG * cos_lat;
(north * north + east * east).sqrt()
};
let free_inertial_drift_m = to_m(cfg.drift_lat_deg, cfg.drift_lon_deg);
let map_matched_error_m = to_m(cfg.drift_lat_deg - est[0], cfg.drift_lon_deg - est[1]);
GravityMapNavResult {
free_inertial_drift_m,
map_matched_error_m,
measurement_sigma_mgal: sigma_mgal,
}
}
pub fn run_gps_denied_gravity_nav(cfg: &GravityMapBenchmarkCfg) -> GravityMapNavResult {
let mut model = GravityAnomalyModel::new(cfg.nmax);
for c in &cfg.coeffs {
model.set_coeff(c.n, c.m, c.cbar, c.sbar);
}
for m in &cfg.mascons {
model.add_mascon(*m);
}
let field = model.sampler_deg();
let grav = Gravimeter::new(cfg.gravimeter_asd, 0.0);
let sensor_sigma = grav.measurement_sigma_mgal(cfg.averaging_time_s);
let sigma_mgal = (sensor_sigma * sensor_sigma + cfg.map_sigma_mgal * cfg.map_sigma_mgal).sqrt();
let truth: Vec<(f64, f64)> = (0..cfg.waypoints)
.map(|k| {
(
cfg.start_lat_deg + cfg.step_lat_deg * k as f64,
cfg.start_lon_deg + cfg.step_lon_deg * k as f64,
)
})
.collect();
let mut rng = ChaCha8Rng::seed_from_u64(cfg.noise_seed);
let noise = Normal::new(0.0, sensor_sigma.max(f64::MIN_POSITIVE)).unwrap();
let measured: Vec<f64> = truth
.iter()
.map(|&(la, lo)| field(la, lo) + noise.sample(&mut rng))
.collect();
let ins: Vec<(f64, f64)> = truth
.iter()
.map(|&(la, lo)| (la + cfg.drift_lat_deg, lo + cfg.drift_lon_deg))
.collect();
let weigh = |delta: &[f64]| -> f64 {
let mut like = 1.0;
for (k, &(la, lo)) in ins.iter().enumerate() {
like *= map_match_likelihood(
&field,
la - delta[0],
lo - delta[1],
measured[k],
sigma_mgal,
);
}
like
};
let est = hierarchical_offset_search(
weigh,
cfg.search_half_deg,
cfg.search_step_deg,
cfg.refine_stages,
cfg.refine_factor,
);
let mid_lat = cfg.start_lat_deg + cfg.step_lat_deg * (cfg.waypoints as f64 - 1.0) / 2.0;
let cos_lat = mid_lat.to_radians().cos();
let to_m = |dlat: f64, dlon: f64| {
let north = dlat * M_PER_DEG;
let east = dlon * M_PER_DEG * cos_lat;
(north * north + east * east).sqrt()
};
GravityMapNavResult {
free_inertial_drift_m: to_m(cfg.drift_lat_deg, cfg.drift_lon_deg),
map_matched_error_m: to_m(cfg.drift_lat_deg - est[0], cfg.drift_lon_deg - est[1]),
measurement_sigma_mgal: sigma_mgal,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalized_legendre_matches_closed_forms_at_the_equator() {
let p = normalized_legendre(2, 0.0);
assert!((p[0][0] - 1.0).abs() < 1e-12);
assert!((p[1][1] - 3.0_f64.sqrt()).abs() < 1e-12); assert!(p[1][0].abs() < 1e-12); assert!((p[2][0] + (5.0_f64.sqrt() / 2.0)).abs() < 1e-12);
assert!(p[2][1].abs() < 1e-12); assert!((p[2][2] - 15.0_f64.sqrt() / 2.0).abs() < 1e-12);
}
#[test]
fn normalized_legendre_matches_closed_forms_off_equator() {
let phi = 30.0_f64.to_radians();
let (t, u) = (phi.sin(), phi.cos()); let p = normalized_legendre(2, t);
assert!((p[1][0] - 3.0_f64.sqrt() * t).abs() < 1e-12);
assert!((p[1][1] - 3.0_f64.sqrt() * u).abs() < 1e-12);
assert!((p[2][0] - (5.0_f64.sqrt() / 2.0) * (3.0 * t * t - 1.0)).abs() < 1e-12);
assert!((p[2][1] - 15.0_f64.sqrt() * t * u).abs() < 1e-12);
assert!((p[2][2] - (15.0_f64.sqrt() / 2.0) * u * u).abs() < 1e-12);
}
#[test]
fn single_term_anomaly_matches_a_hand_derived_value() {
let mut model = GravityAnomalyModel::new(2);
model.set_coeff(2, 2, 1.0e-6, 0.0);
let dg = model.anomaly_mgal(0.0, 0.0);
let gm_over_r2 = GM_EARTH / (R_EARTH * R_EARTH);
let expected = gm_over_r2 * 1.0e-6 * (15.0_f64.sqrt() / 2.0) / MGAL;
assert!((expected - 1.897_44).abs() < 1e-3, "hand value {expected}");
assert!((dg - expected).abs() < 1e-9, "dg = {dg}");
let dg_quarter = model.anomaly_mgal(0.0, 45.0_f64.to_radians());
assert!(dg_quarter.abs() < 1e-9, "dg(45°) = {dg_quarter}");
}
#[test]
fn mascon_adds_its_peak_at_its_centre() {
let mut model = GravityAnomalyModel::new(2);
model.add_mascon(Mascon {
lat_deg: 10.0,
lon_deg: 20.0,
amp_mgal: 30.0,
sigma_deg: 0.4,
});
let at_centre = model.anomaly_mgal(10.0_f64.to_radians(), 20.0_f64.to_radians());
assert!((at_centre - 30.0).abs() < 1e-9, "centre = {at_centre}");
let one_sigma = model.anomaly_mgal((10.4_f64).to_radians(), 20.0_f64.to_radians());
assert!(
(one_sigma - 30.0 * (-0.5_f64).exp()).abs() < 1e-6,
"1σ = {one_sigma}"
);
}
#[test]
fn gravimeter_white_noise_averages_down_as_asd_over_sqrt_tau() {
let g = Gravimeter::new(1.0e-7, 0.0);
assert!((g.measurement_sigma_mgal(100.0) - 1.0e-3).abs() < 1e-12);
assert!((g.measurement_sigma_mgal(400.0) - 0.5e-3).abs() < 1e-12);
}
#[test]
fn gravimeter_derives_its_floor_from_the_cai_physics() {
let cai = CaiAccelerometer {
wavelength_m: 780.0e-9,
pulse_sep_t: 0.01,
atom_number: 1.0e6,
contrast: 0.5,
cycle_time_s: 0.5,
};
let g = Gravimeter::from_cai(&cai, 0.0);
let tau = 10.0_f64;
let expected = (cai.accel_asd() / tau.sqrt()) / MGAL;
assert!((g.measurement_sigma_mgal(tau) - expected).abs() < 1e-15);
assert!(g.measurement_sigma_mgal(tau) > 0.0);
}
#[test]
fn map_matching_recovers_the_track_far_better_than_free_inertial_drift() {
let cfg = GravityMapBenchmarkCfg {
nmax: 3,
coeffs: vec![
CoeffEntry {
n: 2,
m: 0,
cbar: 4.0e-6,
sbar: 0.0,
},
CoeffEntry {
n: 3,
m: 1,
cbar: 2.0e-6,
sbar: 1.0e-6,
},
],
mascons: vec![
Mascon {
lat_deg: 11.0,
lon_deg: 21.0,
amp_mgal: 30.0,
sigma_deg: 0.45,
},
Mascon {
lat_deg: 10.6,
lon_deg: 20.6,
amp_mgal: -25.0,
sigma_deg: 0.35,
},
],
start_lat_deg: 10.0,
start_lon_deg: 20.0,
step_lat_deg: 0.18,
step_lon_deg: 0.13,
waypoints: 12,
drift_lat_deg: 0.52,
drift_lon_deg: -0.41,
gravimeter_asd: 1.0e-7,
averaging_time_s: 100.0,
map_sigma_mgal: 2.0,
search_half_deg: 1.0,
search_step_deg: 0.05,
refine_stages: 1,
refine_factor: 8.0,
noise_seed: 0,
};
let r = run_gravity_map_benchmark(&cfg);
assert!(
(r.free_inertial_drift_m - 71_000.0).abs() < 3_000.0,
"drift = {} m",
r.free_inertial_drift_m
);
assert!(
r.map_matched_error_m < 0.2 * r.free_inertial_drift_m,
"matched {} m vs drift {} m",
r.map_matched_error_m,
r.free_inertial_drift_m
);
assert!(
r.map_matched_error_m < 8_000.0,
"matched error {} m should be within grid resolution",
r.map_matched_error_m
);
assert!(r.measurement_sigma_mgal > 0.0 && r.measurement_sigma_mgal.is_finite());
}
fn gps_denied_cfg() -> GravityMapBenchmarkCfg {
toml::from_str(include_str!("../scenarios/gps-denied-gravity-nav.toml"))
.expect("60-min GPS-denied scenario parses")
}
#[test]
fn gps_denied_60min_recovers_position_within_500m() {
let r = run_gps_denied_gravity_nav(&gps_denied_cfg());
assert!(
r.free_inertial_drift_m > 10_000.0,
"free-inertial drift {} m must exceed 10 km",
r.free_inertial_drift_m
);
assert!(
r.map_matched_error_m < 500.0,
"matched error {} m must beat the 500 m GPS-denied target",
r.map_matched_error_m
);
assert!(
r.map_matched_error_m < r.free_inertial_drift_m / 100.0,
"matched {} m vs drift {} m",
r.map_matched_error_m,
r.free_inertial_drift_m
);
assert!(r.measurement_sigma_mgal > 0.0 && r.measurement_sigma_mgal.is_finite());
}
#[test]
fn hierarchical_refinement_is_what_breaks_the_500m_barrier() {
let mut single = gps_denied_cfg();
single.refine_stages = 1;
let coarse = run_gps_denied_gravity_nav(&single);
let refined = run_gps_denied_gravity_nav(&gps_denied_cfg());
assert!(
coarse.map_matched_error_m > 500.0,
"single-stage {} m should NOT already meet the target",
coarse.map_matched_error_m
);
assert!(refined.map_matched_error_m < 500.0);
assert!(
refined.map_matched_error_m < coarse.map_matched_error_m / 4.0,
"refinement {} m must sharply beat single-stage {} m",
refined.map_matched_error_m,
coarse.map_matched_error_m
);
}
#[test]
fn gps_denied_recovery_is_stable_across_noise_realisations() {
let mut errs = Vec::new();
for seed in 1..=5u64 {
let mut cfg = gps_denied_cfg();
cfg.noise_seed = seed;
let r = run_gps_denied_gravity_nav(&cfg);
assert!(
r.map_matched_error_m < 500.0,
"seed {seed}: matched {} m",
r.map_matched_error_m
);
errs.push(r.map_matched_error_m);
}
let max = errs.iter().cloned().fold(f64::MIN, f64::max);
let min = errs.iter().cloned().fold(f64::MAX, f64::min);
assert!(max - min < 50.0, "spread {} m across seeds", max - min);
}
#[test]
fn run_is_deterministic_for_a_fixed_seed() {
let a = run_gps_denied_gravity_nav(&gps_denied_cfg());
let b = run_gps_denied_gravity_nav(&gps_denied_cfg());
assert_eq!(
a.map_matched_error_m.to_bits(),
b.map_matched_error_m.to_bits()
);
}
#[test]
fn committed_benchmark_scenario_loads_and_runs() {
let cfg: GravityMapBenchmarkCfg =
toml::from_str(include_str!("../scenarios/gravity-map-nav.toml"))
.expect("benchmark scenario parses");
let r = run_gravity_map_benchmark(&cfg);
assert!(r.map_matched_error_m < 0.25 * r.free_inertial_drift_m);
assert!(r.free_inertial_drift_m > 10_000.0);
}
}