use crate::gravimeter::{GravityAnomalyModel, Mascon, M_PER_DEG};
use crate::igrf::magnetic_field;
use crate::ionex::cell;
use crate::mapmatch::{field_likelihood, hierarchical_offset_search};
use rand::SeedableRng;
use rand_chacha::ChaCha8Rng;
use rand_distr::{Distribution, Normal};
use serde::{Deserialize, Serialize};
pub const SRTM_VOID: f64 = -32768.0;
#[derive(Clone, Debug)]
pub struct DemGrid {
pub lat0_deg: f64,
pub lon0_deg: f64,
pub dlat_deg: f64,
pub dlon_deg: f64,
pub n_lat: usize,
pub n_lon: usize,
pub elev_m: Vec<f64>,
pub void_value: Option<f64>,
}
impl DemGrid {
pub fn node(&self, i: usize, j: usize) -> f64 {
self.elev_m[i * self.n_lon + j]
}
fn is_void(&self, v: f64) -> bool {
match self.void_value {
Some(s) => v == s,
None => false,
}
}
pub fn elevation_at(&self, lat_deg: f64, lon_deg: f64) -> f64 {
let (i0, fi) = cell(lat_deg, self.lat0_deg, self.dlat_deg, self.n_lat);
let (j0, fj) = cell(lon_deg, self.lon0_deg, self.dlon_deg, self.n_lon);
let v00 = self.node(i0, j0);
let v01 = self.node(i0, j0 + 1);
let v10 = self.node(i0 + 1, j0);
let v11 = self.node(i0 + 1, j0 + 1);
if self.is_void(v00) || self.is_void(v01) || self.is_void(v10) || self.is_void(v11) {
return f64::NAN;
}
(1.0 - fi) * (1.0 - fj) * v00
+ (1.0 - fi) * fj * v01
+ fi * (1.0 - fj) * v10
+ fi * fj * v11
}
pub fn synthetic_fixture(seed: u64) -> Self {
let lat0_deg = 12.0;
let lon0_deg = 20.0;
let dlat_deg = 0.005;
let dlon_deg = 0.005;
let n_lat = 101usize;
let n_lon = 101usize;
let jit = |k: u64| -> f64 {
let x = seed
.wrapping_mul(0x9E37_79B9_7F4A_7C15)
.wrapping_add(k.wrapping_mul(0xD1B5_4A32_D192_ED03));
let u = ((x >> 11) as f64) / ((1u64 << 53) as f64); (u - 0.5) * 0.08
};
let feats = [
(12.12 + jit(1), 20.10 + jit(2), 420.0, 0.06),
(12.30 + jit(3), 20.34 + jit(4), -260.0, 0.05),
(12.18 + jit(5), 20.40 + jit(6), 360.0, 0.045),
(12.40 + jit(7), 20.14 + jit(8), 300.0, 0.07),
(12.25 + jit(9), 20.25 + jit(10), -180.0, 0.04),
];
let mut elev_m = vec![0.0_f64; n_lat * n_lon];
for i in 0..n_lat {
let lat = lat0_deg + dlat_deg * i as f64;
let cos_lat = lat.to_radians().cos();
for j in 0..n_lon {
let lon = lon0_deg + dlon_deg * j as f64;
let mut h = 600.0 + 800.0 * (lat - 12.0) - 500.0 * (lon - 20.0);
for &(flat, flon, amp, sig) in &feats {
let dlat = lat - flat;
let dlon = (lon - flon) * cos_lat;
let r2 = (dlat * dlat + dlon * dlon) / (2.0 * sig * sig);
h += amp * (-r2).exp();
}
elev_m[i * n_lon + j] = h;
}
}
DemGrid {
lat0_deg,
lon0_deg,
dlat_deg,
dlon_deg,
n_lat,
n_lon,
elev_m,
void_value: Some(SRTM_VOID),
}
}
pub fn from_srtm_hgt(
bytes: &[u8],
samples_per_side: usize,
ll_lat_deg: f64,
ll_lon_deg: f64,
) -> Result<Self, String> {
let n = samples_per_side;
if n < 2 {
return Err(format!("samples_per_side must be >= 2, got {n}"));
}
if bytes.len() != 2 * n * n {
return Err(format!(
"SRTM .hgt length {} != 2*{n}*{n} = {}",
bytes.len(),
2 * n * n
));
}
let step = 1.0 / (n - 1) as f64;
let mut elev_m = vec![0.0_f64; n * n];
for r in 0..n {
let stored_i = n - 1 - r;
for j in 0..n {
let off = 2 * (r * n + j);
let v = i16::from_be_bytes([bytes[off], bytes[off + 1]]) as f64;
elev_m[stored_i * n + j] = v;
}
}
Ok(DemGrid {
lat0_deg: ll_lat_deg,
lon0_deg: ll_lon_deg,
dlat_deg: step,
dlon_deg: step,
n_lat: n,
n_lon: n,
elev_m,
void_value: Some(SRTM_VOID),
})
}
pub fn sampler_deg(&self) -> impl Fn(f64, f64) -> f64 + '_ {
move |lat_deg: f64, lon_deg: f64| self.elevation_at(lat_deg, lon_deg)
}
pub fn relief_std_m(&self) -> f64 {
let vals: Vec<f64> = self
.elev_m
.iter()
.copied()
.filter(|v| !self.is_void(*v))
.collect();
if vals.is_empty() {
return 0.0;
}
let mean = vals.iter().sum::<f64>() / vals.len() as f64;
let var = vals.iter().map(|v| (v - mean) * (v - mean)).sum::<f64>() / vals.len() as f64;
var.sqrt()
}
}
#[derive(Clone, Copy, Debug)]
pub struct Altimeter {
pub sigma_m: f64,
}
impl Altimeter {
pub fn measure(&self, true_ground_elev_m: f64, noise_sample_m: f64) -> f64 {
true_ground_elev_m + noise_sample_m
}
}
fn default_refine_stages() -> usize {
3
}
fn default_refine_factor() -> f64 {
8.0
}
#[derive(Clone, Debug, Deserialize)]
pub struct TerrainNavCfg {
pub dem_seed: u64,
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 altimeter_sigma_m: f64,
pub map_sigma_m: 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,
}
#[derive(Clone, Copy, Debug, Serialize)]
pub struct TerrainNavResult {
pub free_inertial_drift_m: f64,
pub matched_error_m: f64,
pub measurement_sigma_m: f64,
}
fn mid_lat(start_lat: f64, step_lat: f64, n: usize) -> f64 {
start_lat + step_lat * (n as f64 - 1.0) / 2.0
}
fn deg_offset_to_m(dlat: f64, dlon: f64, ref_lat_deg: f64) -> f64 {
let cos_lat = ref_lat_deg.to_radians().cos();
let north = dlat * M_PER_DEG;
let east = dlon * M_PER_DEG * cos_lat;
(north * north + east * east).sqrt()
}
pub fn run_terrain_nav(cfg: &TerrainNavCfg) -> TerrainNavResult {
let dem = DemGrid::synthetic_fixture(cfg.dem_seed);
let field = dem.sampler_deg();
let alt = Altimeter {
sigma_m: cfg.altimeter_sigma_m,
};
let sigma_m =
(cfg.altimeter_sigma_m * cfg.altimeter_sigma_m + cfg.map_sigma_m * cfg.map_sigma_m).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, cfg.altimeter_sigma_m.max(f64::MIN_POSITIVE)).unwrap();
let measured: Vec<f64> = truth
.iter()
.map(|&(la, lo)| alt.measure(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() {
let predicted = field(la - delta[0], lo - delta[1]);
let m = measured[k];
if predicted.is_nan() || m.is_nan() {
continue;
}
like *= field_likelihood(predicted, m, sigma_m);
}
like
};
let est = hierarchical_offset_search(
weigh,
cfg.search_half_deg,
cfg.search_step_deg,
cfg.refine_stages,
cfg.refine_factor,
);
let ref_lat = mid_lat(cfg.start_lat_deg, cfg.step_lat_deg, cfg.waypoints);
TerrainNavResult {
free_inertial_drift_m: deg_offset_to_m(cfg.drift_lat_deg, cfg.drift_lon_deg, ref_lat),
matched_error_m: deg_offset_to_m(
cfg.drift_lat_deg - est[0],
cfg.drift_lon_deg - est[1],
ref_lat,
),
measurement_sigma_m: sigma_m,
}
}
#[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 CombinedAltPntCfg {
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 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,
pub nmax: usize,
#[serde(default)]
pub coeffs: Vec<CoeffEntry>,
#[serde(default)]
pub mascons: Vec<Mascon>,
pub gravity_sigma_mgal: f64,
pub igrf_year: f64,
#[serde(default)]
pub igrf_alt_km: f64,
#[serde(default)]
pub magnetic_mascons: Vec<Mascon>,
pub magnetic_sigma_nt: f64,
pub dem_seed: u64,
pub terrain_sigma_m: f64,
}
#[derive(Clone, Copy, Debug, Serialize)]
pub struct CombinedAltPntResult {
pub free_inertial_drift_m: f64,
pub gravity_only_m: f64,
pub magnetic_only_m: f64,
pub terrain_only_m: f64,
pub combined_m: f64,
}
fn magnetic_anomaly_field(
mascons: &[Mascon],
year: f64,
alt_km: f64,
regional_mean_nt: f64,
) -> impl Fn(f64, f64) -> f64 + '_ {
move |lat_deg: f64, lon_deg: f64| {
let base = magnetic_field(lat_deg, lon_deg, alt_km, year).total_nt - regional_mean_nt;
let cos_lat = lat_deg.to_radians().cos();
let mut a = base;
for ms in 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);
a += ms.amp_mgal * (-r2).exp();
}
a
}
}
pub fn run_combined_altpnt(cfg: &CombinedAltPntCfg) -> CombinedAltPntResult {
let mut gmodel = GravityAnomalyModel::new(cfg.nmax);
for c in &cfg.coeffs {
gmodel.set_coeff(c.n, c.m, c.cbar, c.sbar);
}
for m in &cfg.mascons {
gmodel.add_mascon(*m);
}
let gfield = gmodel.sampler_deg();
let dem = DemGrid::synthetic_fixture(cfg.dem_seed);
let tfield = dem.sampler_deg();
let ref_lat = mid_lat(cfg.start_lat_deg, cfg.step_lat_deg, cfg.waypoints);
let ref_lon = cfg.start_lon_deg + cfg.step_lon_deg * (cfg.waypoints as f64 - 1.0) / 2.0;
let regional_mean_nt =
magnetic_field(ref_lat, ref_lon, cfg.igrf_alt_km, cfg.igrf_year).total_nt;
let bfield = magnetic_anomaly_field(
&cfg.magnetic_mascons,
cfg.igrf_year,
cfg.igrf_alt_km,
regional_mean_nt,
);
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_g = ChaCha8Rng::seed_from_u64(cfg.noise_seed.wrapping_add(0x6772_6176)); let mut rng_b = ChaCha8Rng::seed_from_u64(cfg.noise_seed.wrapping_add(0x6D61_6700)); let mut rng_t = ChaCha8Rng::seed_from_u64(cfg.noise_seed.wrapping_add(0x7465_7272)); let ng = Normal::new(0.0, cfg.gravity_sigma_mgal.max(f64::MIN_POSITIVE)).unwrap();
let nb = Normal::new(0.0, cfg.magnetic_sigma_nt.max(f64::MIN_POSITIVE)).unwrap();
let nt = Normal::new(0.0, cfg.terrain_sigma_m.max(f64::MIN_POSITIVE)).unwrap();
let meas_g: Vec<f64> = truth
.iter()
.map(|&(la, lo)| gfield(la, lo) + ng.sample(&mut rng_g))
.collect();
let meas_b: Vec<f64> = truth
.iter()
.map(|&(la, lo)| bfield(la, lo) + nb.sample(&mut rng_b))
.collect();
let meas_t: Vec<f64> = truth
.iter()
.map(|&(la, lo)| tfield(la, lo) + nt.sample(&mut rng_t))
.collect();
let ins: Vec<(f64, f64)> = truth
.iter()
.map(|&(la, lo)| (la + cfg.drift_lat_deg, lo + cfg.drift_lon_deg))
.collect();
let channel_like =
|delta: &[f64], field: &dyn Fn(f64, f64) -> f64, meas: &[f64], sigma: f64| -> f64 {
let mut like = 1.0;
for (k, &(la, lo)) in ins.iter().enumerate() {
let predicted = field(la - delta[0], lo - delta[1]);
let m = meas[k];
if predicted.is_nan() || m.is_nan() {
continue;
}
like *= field_likelihood(predicted, m, sigma);
}
like
};
let weigh_g = |d: &[f64]| channel_like(d, &gfield, &meas_g, cfg.gravity_sigma_mgal);
let weigh_b = |d: &[f64]| channel_like(d, &bfield, &meas_b, cfg.magnetic_sigma_nt);
let weigh_t = |d: &[f64]| channel_like(d, &tfield, &meas_t, cfg.terrain_sigma_m);
let weigh_joint = |d: &[f64]| weigh_g(d) * weigh_b(d) * weigh_t(d);
let solve = |w: &dyn Fn(&[f64]) -> f64| -> f64 {
let est = hierarchical_offset_search(
w,
cfg.search_half_deg,
cfg.search_step_deg,
cfg.refine_stages,
cfg.refine_factor,
);
deg_offset_to_m(
cfg.drift_lat_deg - est[0],
cfg.drift_lon_deg - est[1],
ref_lat,
)
};
CombinedAltPntResult {
free_inertial_drift_m: deg_offset_to_m(cfg.drift_lat_deg, cfg.drift_lon_deg, ref_lat),
gravity_only_m: solve(&weigh_g),
magnetic_only_m: solve(&weigh_b),
terrain_only_m: solve(&weigh_t),
combined_m: solve(&weigh_joint),
}
}
pub fn terrain_nav_svg(r: &TerrainNavResult) -> String {
bars_svg(
"Terrain-referenced navigation (TERCOM/SITAN)",
&[
("free-inertial drift", r.free_inertial_drift_m),
("terrain-matched", r.matched_error_m),
],
)
}
pub fn gravity_nav_svg(free_inertial_drift_m: f64, gravity_matched_m: f64) -> String {
bars_svg(
"Gravity-map matched navigation",
&[
("free-inertial drift", free_inertial_drift_m),
("gravity-matched", gravity_matched_m),
],
)
}
pub fn combined_altpnt_svg(r: &CombinedAltPntResult) -> String {
bars_svg(
"Combined gravity + magnetic + terrain alt-PNT",
&[
("free-inertial drift", r.free_inertial_drift_m),
("gravity only", r.gravity_only_m),
("magnetic only", r.magnetic_only_m),
("terrain only", r.terrain_only_m),
("combined (fused)", r.combined_m),
],
)
}
fn bars_svg(title: &str, rows: &[(&str, f64)]) -> String {
let w = 720.0;
let h = 60.0 + rows.len() as f64 * 34.0;
let x0 = 220.0;
let bar_w = w - x0 - 90.0;
let max = rows
.iter()
.map(|&(_, v)| v.max(1.0))
.fold(1.0_f64, f64::max);
let lmax = (max).log10();
let mut s = String::new();
s.push_str(&format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{w}\" height=\"{h}\" \
viewBox=\"0 0 {w} {h}\" font-family=\"sans-serif\">"
));
s.push_str(&format!(
"<text x=\"16\" y=\"28\" font-size=\"16\" font-weight=\"bold\">{title}</text>"
));
for (i, &(label, v)) in rows.iter().enumerate() {
let y = 50.0 + i as f64 * 34.0;
let frac = if v <= 1.0 {
0.0
} else {
(v.log10() / lmax).clamp(0.0, 1.0)
};
let len = (bar_w * frac).max(2.0);
s.push_str(&format!(
"<text x=\"16\" y=\"{:.0}\" font-size=\"12\">{label}</text>",
y + 14.0
));
s.push_str(&format!(
"<rect x=\"{x0}\" y=\"{y:.0}\" width=\"{len:.0}\" height=\"20\" \
fill=\"#3b6ea5\" rx=\"3\"/>"
));
s.push_str(&format!(
"<text x=\"{:.0}\" y=\"{:.0}\" font-size=\"12\">{:.0} m</text>",
x0 + len + 6.0,
y + 14.0,
v
));
}
s.push_str("</svg>");
s
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bilinear_midpoint_is_exact() {
let dem = DemGrid {
lat0_deg: 0.0,
lon0_deg: 0.0,
dlat_deg: 1.0,
dlon_deg: 1.0,
n_lat: 2,
n_lon: 2,
elev_m: vec![100.0, 200.0, 300.0, 400.0],
void_value: Some(SRTM_VOID),
};
assert!((dem.elevation_at(0.5, 0.5) - 250.0).abs() < 1e-12);
assert!((dem.elevation_at(0.0, 0.0) - 100.0).abs() < 1e-12);
assert!((dem.elevation_at(1.0, 1.0) - 400.0).abs() < 1e-12);
assert!((dem.elevation_at(0.0, 0.5) - 150.0).abs() < 1e-12);
}
#[test]
fn srtm_hgt_roundtrip_big_endian() {
let mut bytes = Vec::new();
for &v in &[100i16, 200, 300, 400] {
bytes.extend_from_slice(&v.to_be_bytes());
}
let dem = DemGrid::from_srtm_hgt(&bytes, 2, 36.0, -119.0).expect("parses");
assert_eq!((dem.n_lat, dem.n_lon), (2, 2));
assert_eq!(dem.node(1, 0), 100.0); assert_eq!(dem.node(1, 1), 200.0); assert_eq!(dem.node(0, 0), 300.0); assert_eq!(dem.node(0, 1), 400.0); let lat_north = dem.lat0_deg + dem.dlat_deg * (dem.n_lat as f64 - 1.0);
assert!((lat_north - 37.0).abs() < 1e-12);
assert!((dem.elevation_at(37.0, -119.0) - 100.0).abs() < 1e-12);
assert!((dem.elevation_at(36.0, -119.0) - 300.0).abs() < 1e-12);
assert_eq!(i16::from_be_bytes([0x00, 0x64]), 100);
assert_ne!(i16::from_le_bytes([0x00, 0x64]), 100);
assert!(DemGrid::from_srtm_hgt(&bytes[..7], 2, 0.0, 0.0).is_err());
}
#[test]
fn void_sentinel_propagates_nan() {
let dem = DemGrid {
lat0_deg: 0.0,
lon0_deg: 0.0,
dlat_deg: 1.0,
dlon_deg: 1.0,
n_lat: 2,
n_lon: 2,
elev_m: vec![SRTM_VOID, 200.0, 300.0, 400.0],
void_value: Some(SRTM_VOID),
};
assert!(dem.elevation_at(0.5, 0.5).is_nan());
assert!(dem.elevation_at(0.0, 0.0).is_nan());
let mut d2 = dem.clone();
d2.void_value = None;
assert!(d2.elevation_at(0.5, 0.5).is_finite());
}
#[test]
fn altimeter_measures_truth_plus_noise() {
let alt = Altimeter { sigma_m: 5.0 };
assert!((alt.measure(500.0, 3.0) - 503.0).abs() < 1e-12);
assert!((alt.measure(500.0, 0.0) - 500.0).abs() < 1e-12);
}
#[test]
fn synthetic_fixture_is_deterministic_and_distinctive() {
let a = DemGrid::synthetic_fixture(7);
let b = DemGrid::synthetic_fixture(7);
assert_eq!(a.elev_m.len(), b.elev_m.len());
for (x, y) in a.elev_m.iter().zip(&b.elev_m) {
assert_eq!(x.to_bits(), y.to_bits());
}
assert!(
a.relief_std_m() > 50.0,
"relief std = {} m",
a.relief_std_m()
);
let c = DemGrid::synthetic_fixture(8);
assert!(a.elev_m.iter().zip(&c.elev_m).any(|(x, y)| x != y));
}
fn terrain_cfg() -> TerrainNavCfg {
TerrainNavCfg {
dem_seed: 1,
start_lat_deg: 12.05,
start_lon_deg: 20.05,
step_lat_deg: 0.004,
step_lon_deg: 0.003,
waypoints: 60,
drift_lat_deg: 0.5,
drift_lon_deg: -0.4,
altimeter_sigma_m: 8.0,
map_sigma_m: 15.0,
search_half_deg: 0.8,
search_step_deg: 0.08,
refine_stages: 3,
refine_factor: 8.0,
noise_seed: 1,
}
}
#[test]
fn terrain_match_recovers_known_offset() {
let r = run_terrain_nav(&terrain_cfg());
assert!(
(60_000.0..80_000.0).contains(&r.free_inertial_drift_m),
"drift = {} m",
r.free_inertial_drift_m
);
assert!(
r.matched_error_m < 500.0,
"matched {} m must beat 500 m",
r.matched_error_m
);
assert!(
r.matched_error_m < r.free_inertial_drift_m / 100.0,
"matched {} m vs drift {} m",
r.matched_error_m,
r.free_inertial_drift_m
);
assert!(r.measurement_sigma_m > 0.0 && r.measurement_sigma_m.is_finite());
}
#[test]
fn hierarchical_refinement_breaks_the_500m_barrier() {
let mut single = terrain_cfg();
single.refine_stages = 1;
let coarse = run_terrain_nav(&single);
let refined = run_terrain_nav(&terrain_cfg());
assert!(
coarse.matched_error_m > 500.0,
"single-stage {} m should NOT already meet target",
coarse.matched_error_m
);
assert!(refined.matched_error_m < 500.0);
assert!(
refined.matched_error_m < coarse.matched_error_m / 4.0,
"refinement {} m must sharply beat single-stage {} m",
refined.matched_error_m,
coarse.matched_error_m
);
}
#[test]
fn terrain_recovery_stable_across_noise_seeds() {
let mut errs = Vec::new();
for seed in 1..=5u64 {
let mut cfg = terrain_cfg();
cfg.noise_seed = seed;
let r = run_terrain_nav(&cfg);
assert!(
r.matched_error_m < 500.0,
"seed {seed}: {} m",
r.matched_error_m
);
errs.push(r.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_fixed_seed() {
let a = run_terrain_nav(&terrain_cfg());
let b = run_terrain_nav(&terrain_cfg());
assert_eq!(a.matched_error_m.to_bits(), b.matched_error_m.to_bits());
}
fn combined_cfg() -> CombinedAltPntCfg {
CombinedAltPntCfg {
start_lat_deg: 12.05,
start_lon_deg: 20.05,
step_lat_deg: 0.004,
step_lon_deg: 0.003,
waypoints: 60,
drift_lat_deg: 0.5,
drift_lon_deg: -0.4,
search_half_deg: 0.8,
search_step_deg: 0.08,
refine_stages: 3,
refine_factor: 8.0,
noise_seed: 1,
nmax: 3,
coeffs: vec![
CoeffEntry {
n: 2,
m: 0,
cbar: 6.0e-6,
sbar: 0.0,
},
CoeffEntry {
n: 3,
m: 1,
cbar: 3.0e-6,
sbar: 2.0e-6,
},
],
mascons: vec![
Mascon {
lat_deg: 12.18,
lon_deg: 20.16,
amp_mgal: 45.0,
sigma_deg: 0.05,
},
Mascon {
lat_deg: 12.26,
lon_deg: 20.22,
amp_mgal: -38.0,
sigma_deg: 0.045,
},
],
gravity_sigma_mgal: 3.0,
igrf_year: 2025.0,
igrf_alt_km: 0.0,
magnetic_mascons: vec![
Mascon {
lat_deg: 12.20,
lon_deg: 20.18,
amp_mgal: 250.0,
sigma_deg: 0.05,
},
Mascon {
lat_deg: 12.24,
lon_deg: 20.20,
amp_mgal: -200.0,
sigma_deg: 0.045,
},
],
magnetic_sigma_nt: 30.0,
dem_seed: 1,
terrain_sigma_m: 40.0,
}
}
#[test]
fn combined_filter_bounded_and_beats_each_single_field() {
let mut drift = 0.0;
let (mut sg, mut sb, mut st, mut sc) = (0.0_f64, 0.0_f64, 0.0_f64, 0.0_f64);
let seeds = 1..=5u64;
let n = 5.0;
for seed in seeds {
let mut cfg = combined_cfg();
cfg.noise_seed = seed;
let r = run_combined_altpnt(&cfg);
drift = r.free_inertial_drift_m;
assert!(
(60_000.0..80_000.0).contains(&r.free_inertial_drift_m),
"seed {seed}: drift = {} m",
r.free_inertial_drift_m
);
assert!(
r.combined_m < 500.0,
"seed {seed}: combined {} m must beat 500 m",
r.combined_m
);
assert!(
r.combined_m < r.free_inertial_drift_m / 100.0,
"seed {seed}: combined {} m vs drift {} m",
r.combined_m,
r.free_inertial_drift_m
);
assert!(r.gravity_only_m < r.free_inertial_drift_m);
assert!(r.magnetic_only_m < r.free_inertial_drift_m);
assert!(r.terrain_only_m < r.free_inertial_drift_m);
sg += r.gravity_only_m;
sb += r.magnetic_only_m;
st += r.terrain_only_m;
sc += r.combined_m;
}
let (mg, mb, mt, mc) = (sg / n, sb / n, st / n, sc / n);
let best_single_mean = mg.min(mb).min(mt);
assert!(
mc <= best_single_mean,
"mean combined {mc} m must be <= mean best single {best_single_mean} m (g {mg}, b {mb}, t {mt})"
);
assert!(mc < 500.0, "mean combined {mc} m");
assert!(
mc < drift / 100.0,
"mean combined {mc} m vs drift {drift} m"
);
}
#[test]
fn combined_run_is_deterministic() {
let a = run_combined_altpnt(&combined_cfg());
let b = run_combined_altpnt(&combined_cfg());
assert_eq!(a.combined_m.to_bits(), b.combined_m.to_bits());
}
#[test]
fn committed_terrain_scenario_loads_and_runs() {
let cfg: TerrainNavCfg = toml::from_str(include_str!("../../scenarios/terrain-nav.toml"))
.expect("terrain-nav scenario parses");
let r = run_terrain_nav(&cfg);
assert!(r.free_inertial_drift_m > 10_000.0);
assert!(r.matched_error_m < 500.0, "matched {} m", r.matched_error_m);
assert!(r.matched_error_m < r.free_inertial_drift_m / 100.0);
let ccfg: CombinedAltPntCfg =
toml::from_str(include_str!("../../scenarios/combined-altpnt.toml"))
.expect("combined-altpnt scenario parses");
let cr = run_combined_altpnt(&ccfg);
assert!(cr.combined_m < 500.0, "combined {} m", cr.combined_m);
assert!(cr.free_inertial_drift_m > 10_000.0);
assert!(cr.combined_m < cr.free_inertial_drift_m / 100.0);
let best = cr
.gravity_only_m
.min(cr.magnetic_only_m)
.min(cr.terrain_only_m);
let grid_floor_m = ccfg.search_step_deg / ccfg.refine_factor.powi(2) * M_PER_DEG;
assert!(
cr.combined_m <= best + grid_floor_m,
"combined {} m vs best single {} m (floor {} m)",
cr.combined_m,
best,
grid_floor_m
);
}
}