use crate::batch_ls::{gauss_newton, LsqResult};
use crate::fusion::ukf::inverse;
use crate::lunar::mcmf_to_selenographic;
use crate::lunar_vlbi::{beacon_inertial_position, geometric_delay_s, station_inertial_position};
use rand::SeedableRng;
use rand_chacha::ChaCha8Rng;
use rand_distr::{Distribution, Normal};
const C: f64 = crate::timegeo::C_M_PER_S;
type Vec3 = [f64; 3];
fn finite_std_dev(sigma: f64) -> f64 {
if sigma.is_finite() {
sigma
} else {
0.0
}
}
fn d_n_sat() -> usize {
3
}
fn d_n_earth() -> usize {
6
}
fn d_seed() -> u64 {
42
}
fn d_sigma_vlbi_s() -> f64 {
1.0e-11 }
fn d_sigma_range_m() -> f64 {
0.1 }
fn d_sigma_isl_m() -> f64 {
0.1 }
fn d_sigma_clock_s() -> f64 {
1.0e-9 }
fn d_with_vlbi() -> bool {
true
}
fn d_epoch_year() -> i32 {
2024
}
fn d_epoch_month() -> u32 {
1
}
fn d_epoch_day() -> u32 {
1
}
fn d_station_lat_deg() -> f64 {
-88.0 }
fn d_station_lon_deg() -> f64 {
23.0
}
fn d_station_alt_m() -> f64 {
0.0
}
fn d_orbit_radius_km() -> f64 {
6000.0
}
#[derive(Clone, Copy, Debug, serde::Deserialize)]
pub struct LunarNetworkConfig {
#[serde(default = "d_n_sat")]
pub n_sat: usize,
#[serde(default = "d_n_earth")]
pub n_earth: usize,
#[serde(default = "d_seed")]
pub seed: u64,
#[serde(default = "d_sigma_vlbi_s")]
pub sigma_vlbi_s: f64,
#[serde(default = "d_sigma_range_m")]
pub sigma_range_m: f64,
#[serde(default = "d_sigma_isl_m")]
pub sigma_isl_m: f64,
#[serde(default = "d_sigma_clock_s")]
pub sigma_clock_s: f64,
#[serde(default = "d_with_vlbi")]
pub with_vlbi: bool,
#[serde(default = "d_epoch_year")]
pub epoch_year: i32,
#[serde(default = "d_epoch_month")]
pub epoch_month: u32,
#[serde(default = "d_epoch_day")]
pub epoch_day: u32,
#[serde(default = "d_station_lat_deg")]
pub station_lat_deg: f64,
#[serde(default = "d_station_lon_deg")]
pub station_lon_deg: f64,
#[serde(default = "d_station_alt_m")]
pub station_alt_m: f64,
#[serde(default = "d_orbit_radius_km")]
pub orbit_radius_km: f64,
}
impl Default for LunarNetworkConfig {
fn default() -> Self {
LunarNetworkConfig {
n_sat: d_n_sat(),
n_earth: d_n_earth(),
seed: d_seed(),
sigma_vlbi_s: d_sigma_vlbi_s(),
sigma_range_m: d_sigma_range_m(),
sigma_isl_m: d_sigma_isl_m(),
sigma_clock_s: d_sigma_clock_s(),
with_vlbi: d_with_vlbi(),
epoch_year: d_epoch_year(),
epoch_month: d_epoch_month(),
epoch_day: d_epoch_day(),
station_lat_deg: d_station_lat_deg(),
station_lon_deg: d_station_lon_deg(),
station_alt_m: d_station_alt_m(),
orbit_radius_km: d_orbit_radius_km(),
}
}
}
#[derive(Clone, Copy, Debug, serde::Serialize)]
pub struct JointSolution {
pub station_pos_err_m: f64,
pub sat_pos_rms_m: f64,
pub station_clock_err_s: f64,
pub sat_clock_rms_s: f64,
pub converged: bool,
pub iterations: usize,
pub rms_residual: f64,
pub n_obs: usize,
pub n_params: usize,
}
fn sub(a: Vec3, b: Vec3) -> Vec3 {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
fn add(a: Vec3, b: Vec3) -> Vec3 {
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
}
fn norm(v: Vec3) -> f64 {
(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt()
}
fn n_params(cfg: &LunarNetworkConfig) -> usize {
3 + 3 * cfg.n_sat + 1 + cfg.n_sat
}
struct Network {
station_nom_mci: Vec3,
sat_nom_mci: Vec<Vec3>,
earth_stations: Vec<Vec3>,
jd_tt: f64,
station_vlbi_nominal: Vec<f64>,
sat_range_nominal: Vec<Vec<f64>>,
}
impl Network {
fn build(cfg: &LunarNetworkConfig) -> Network {
let jd_utc = crate::timescales::julian_date(
cfg.epoch_year,
cfg.epoch_month,
cfg.epoch_day,
0,
0,
0.0,
);
let jd_tt = crate::timescales::utc_to_tt(jd_utc);
let jd_ut1 = crate::timescales::utc_to_ut1(jd_utc, 0.0);
let station_sel = crate::lunar::Selenographic {
lat_rad: cfg.station_lat_deg.to_radians(),
lon_rad: cfg.station_lon_deg.to_radians(),
alt_m: cfg.station_alt_m,
};
let station_nom_mci = crate::lunar::selenographic_to_mcmf(station_sel);
let r = cfg.orbit_radius_km * 1.0e3;
let sat_nom_mci: Vec<Vec3> = (0..cfg.n_sat)
.map(|k| {
let frac = if cfg.n_sat > 1 {
k as f64 / cfg.n_sat as f64
} else {
0.0
};
let nu = (-55.0 + 110.0 * frac).to_radians();
let inc = (60.0 + 8.0 * (k as f64).sin()).to_radians();
[
r * nu.cos() * inc.sin(),
r * nu.sin() * inc.sin(),
r * inc.cos(),
]
})
.collect();
let earth_geodetics = earth_station_geodetics(cfg.n_earth);
let earth_stations: Vec<Vec3> = earth_geodetics
.into_iter()
.map(|g| station_inertial_position(g, jd_tt, jd_ut1))
.collect();
let pairs = baseline_pairs(cfg.n_earth);
let geocentric = |mci: Vec3| beacon_inertial_position(mcmf_to_selenographic(mci), jd_tt);
let r_st_b = geocentric(station_nom_mci);
let station_vlbi_nominal: Vec<f64> = pairs
.iter()
.map(|&(i, j)| geometric_delay_s(earth_stations[i], earth_stations[j], r_st_b))
.collect();
let sat_range_nominal: Vec<Vec<f64>> = sat_nom_mci
.iter()
.map(|&s| {
let r_s = geocentric(s);
earth_stations.iter().map(|&e| norm(sub(r_s, e))).collect()
})
.collect();
Network {
station_nom_mci,
sat_nom_mci,
earth_stations,
jd_tt,
station_vlbi_nominal,
sat_range_nominal,
}
}
fn station_geocentric(&self, station_mci: Vec3) -> Vec3 {
let sel = mcmf_to_selenographic(station_mci);
beacon_inertial_position(sel, self.jd_tt)
}
}
fn earth_station_geodetics(n: usize) -> Vec<crate::frames::Geodetic> {
let table: [(f64, f64, f64); 6] = [
(40.4256, -116.8893, 1000.0), (-35.4014, 148.9819, 688.0), (40.4314, -4.2481, 837.0), (78.2300, 15.4000, 80.0), (-25.8870, 27.7070, 1400.0), (19.8000, -155.5000, 3700.0), ];
(0..n.max(1))
.map(|k| {
let (lat, lon, alt) = table[k % table.len()];
crate::frames::Geodetic {
lat_rad: lat.to_radians(),
lon_rad: lon.to_radians(),
alt_m: alt,
}
})
.collect()
}
fn baseline_pairs(n_earth: usize) -> Vec<(usize, usize)> {
let mut v = Vec::new();
for i in 0..n_earth {
for j in (i + 1)..n_earth {
v.push((i, j));
}
}
v
}
const PARAM_SCALE: f64 = 1.0e6;
fn station_pos_corr(x: &[f64]) -> Vec3 {
[x[0] * PARAM_SCALE, x[1] * PARAM_SCALE, x[2] * PARAM_SCALE]
}
fn sat_pos_corr(x: &[f64], k: usize) -> Vec3 {
let b = 3 + 3 * k;
[
x[b] * PARAM_SCALE,
x[b + 1] * PARAM_SCALE,
x[b + 2] * PARAM_SCALE,
]
}
fn station_clk_m(x: &[f64], n_sat: usize) -> f64 {
x[3 + 3 * n_sat] * PARAM_SCALE
}
fn sat_clk_m(x: &[f64], n_sat: usize, k: usize) -> f64 {
x[3 + 3 * n_sat + 1 + k] * PARAM_SCALE
}
fn forward(net: &Network, cfg: &LunarNetworkConfig, x: &[f64]) -> Vec<f64> {
let n_sat = cfg.n_sat;
let station_mci = add(net.station_nom_mci, station_pos_corr(x));
let sats_mci: Vec<Vec3> = (0..n_sat)
.map(|k| add(net.sat_nom_mci[k], sat_pos_corr(x, k)))
.collect();
let clk_st = station_clk_m(x, n_sat);
let clk_sat: Vec<f64> = (0..n_sat).map(|k| sat_clk_m(x, n_sat, k)).collect();
let mut z = Vec::new();
z.push(clk_st);
if cfg.with_vlbi {
let pairs = baseline_pairs(cfg.n_earth);
let r_station_beacon = net.station_geocentric(station_mci);
for (b, &(i, j)) in pairs.iter().enumerate() {
let d = geometric_delay_s(
net.earth_stations[i],
net.earth_stations[j],
r_station_beacon,
);
z.push(d - net.station_vlbi_nominal[b]);
}
}
for (k, sat) in sats_mci.iter().enumerate().take(n_sat) {
let r_sat = net.station_geocentric(*sat);
for (e, &r_e) in net.earth_stations.iter().enumerate() {
let geo = norm(sub(r_sat, r_e));
z.push((geo - net.sat_range_nominal[k][e]) + clk_sat[k]);
}
}
for k in 0..n_sat {
let geo = norm(sub(sats_mci[k], station_mci));
z.push(geo + (clk_sat[k] - clk_st));
}
for i in 0..n_sat {
for j in (i + 1)..n_sat {
let geo = norm(sub(sats_mci[i], sats_mci[j]));
z.push(geo + (clk_sat[i] - clk_sat[j]));
}
}
z
}
fn sigmas(cfg: &LunarNetworkConfig) -> Vec<f64> {
let mut s = Vec::new();
s.push((C * cfg.sigma_clock_s).max(1e-12));
if cfg.with_vlbi {
for _ in baseline_pairs(cfg.n_earth) {
s.push(cfg.sigma_vlbi_s.max(1e-30));
}
}
for _ in 0..(cfg.n_sat * cfg.n_earth) {
s.push(cfg.sigma_range_m.max(1e-9));
}
for _ in 0..cfg.n_sat {
s.push(cfg.sigma_range_m.max(1e-9));
}
for _ in baseline_pairs(cfg.n_sat) {
s.push(cfg.sigma_isl_m.max(1e-9));
}
s
}
fn truth_state(cfg: &LunarNetworkConfig, rng: &mut ChaCha8Rng) -> Vec<f64> {
let n_sat = cfg.n_sat;
let mut x = vec![0.0; n_params(cfg)];
let jit = Normal::new(0.0, 1.0)
.expect("std_dev is the finite literal 1.0, which Normal::new always accepts");
for a in 0..3 {
x[a] = 50.0 * [1.0, -1.0, 0.8][a] + 5.0 * jit.sample(rng);
}
for k in 0..n_sat {
let b = 3 + 3 * k;
for a in 0..3 {
let sign = if (k + a) % 2 == 0 { 1.0 } else { -1.0 };
x[b + a] = 30.0 * sign + 3.0 * jit.sample(rng);
}
}
x[3 + 3 * n_sat] = C * (1.0e-7 + 1.0e-8 * jit.sample(rng));
for k in 0..n_sat {
let sign = if k % 2 == 0 { 1.0 } else { -1.0 };
x[3 + 3 * n_sat + 1 + k] = C * (sign * 1.0e-7 + 1.0e-8 * jit.sample(rng));
}
for v in x.iter_mut() {
*v /= PARAM_SCALE;
}
x
}
pub fn estimate(cfg: &LunarNetworkConfig) -> JointSolution {
let net = Network::build(cfg);
let mut rng = ChaCha8Rng::seed_from_u64(cfg.seed);
let x_true = truth_state(cfg, &mut rng);
let sig = sigmas(cfg);
let z_clean = forward(&net, cfg, &x_true);
let z: Vec<f64> = z_clean
.iter()
.zip(&sig)
.map(|(&zc, &s)| {
zc + Normal::new(0.0, finite_std_dev(s))
.expect("finite_std_dev returns a finite std_dev, which Normal::new always accepts")
.sample(&mut rng)
})
.collect();
let weights: Vec<f64> = sig.iter().map(|&s| 1.0 / (s * s)).collect();
let np = n_params(cfg);
let x0 = vec![0.0; np];
let net_ref = &net;
let cfg_ref = cfg;
let h = move |x: &[f64]| forward(net_ref, cfg_ref, x);
let result = gauss_newton(h, &z, &weights, &x0, 100, 1e-6);
summarize_solution(cfg, &x_true, result, z.len(), np)
}
fn summarize_solution(
cfg: &LunarNetworkConfig,
x_true: &[f64],
result: Option<LsqResult>,
n_obs: usize,
np: usize,
) -> JointSolution {
let n_sat = cfg.n_sat;
let big = 1.0e9_f64;
let (x_hat, converged, iterations, rms) = match result {
Some(r) if r.x.iter().all(|v| v.is_finite()) => {
(r.x, r.converged, r.iterations, r.rms_residual)
}
_ => (vec![f64::NAN; np], false, 0, big),
};
let err = |i: usize| -> f64 {
let e = (x_hat[i] - x_true[i]) * PARAM_SCALE;
if e.is_finite() {
e
} else {
big
}
};
let station_pos_err_m = (err(0).powi(2) + err(1).powi(2) + err(2).powi(2))
.sqrt()
.min(big);
let sat_pos_rms_m = if n_sat > 0 {
let mut acc = 0.0;
for k in 0..n_sat {
let b = 3 + 3 * k;
acc += err(b).powi(2) + err(b + 1).powi(2) + err(b + 2).powi(2);
}
((acc / (3.0 * n_sat as f64)).sqrt()).min(big)
} else {
0.0
};
let station_clock_err_s = (err(3 + 3 * n_sat).abs() / C).min(big);
let sat_clock_rms_s = if n_sat > 0 {
let mut acc = 0.0;
for k in 0..n_sat {
acc += err(3 + 3 * n_sat + 1 + k).powi(2);
}
(((acc / n_sat as f64).sqrt()) / C).min(big)
} else {
0.0
};
let rms_residual = if rms.is_finite() { rms } else { big };
JointSolution {
station_pos_err_m,
sat_pos_rms_m,
station_clock_err_s,
sat_clock_rms_s,
converged,
iterations,
rms_residual,
n_obs,
n_params: np,
}
}
fn fd_jacobian(net: &Network, cfg: &LunarNetworkConfig, x: &[f64]) -> Vec<Vec<f64>> {
let m = forward(net, cfg, x).len();
let n = x.len();
let mut jac = vec![vec![0.0; n]; m];
for (p, &xp_val) in x.iter().enumerate() {
let step = 1e-6 * xp_val.abs().max(1.0);
let mut xp = x.to_vec();
let mut xm = x.to_vec();
xp[p] += step;
xm[p] -= step;
let hp = forward(net, cfg, &xp);
let hm = forward(net, cfg, &xm);
for (i, jr) in jac.iter_mut().enumerate() {
jr[p] = (hp[i] - hm[i]) / (2.0 * step);
}
}
jac
}
pub fn formal_covariance_nees(cfg: &LunarNetworkConfig) -> Option<f64> {
let net = Network::build(cfg);
let mut rng = ChaCha8Rng::seed_from_u64(cfg.seed);
let x_true = truth_state(cfg, &mut rng);
let sig = sigmas(cfg);
let z_clean = forward(&net, cfg, &x_true);
let z: Vec<f64> = z_clean
.iter()
.zip(&sig)
.map(|(&zc, &s)| {
zc + Normal::new(0.0, finite_std_dev(s))
.expect("finite_std_dev returns a finite std_dev, which Normal::new always accepts")
.sample(&mut rng)
})
.collect();
let weights: Vec<f64> = sig.iter().map(|&s| 1.0 / (s * s)).collect();
let np = n_params(cfg);
let x0 = vec![0.0; np];
let h = {
let net_ref = &net;
move |x: &[f64]| forward(net_ref, cfg, x)
};
let r = gauss_newton(h, &z, &weights, &x0, 50, 1e-10)?;
if !r.x.iter().all(|v| v.is_finite()) {
return None;
}
let jac = fd_jacobian(&net, cfg, &r.x);
let m = jac.len();
let n = np;
let mut info = vec![vec![0.0; n]; n];
for i in 0..m {
let w = weights[i];
for p in 0..n {
for q in 0..n {
info[p][q] += jac[i][p] * w * jac[i][q];
}
}
}
inverse(&info)?;
let dx: Vec<f64> = (0..n).map(|i| r.x[i] - x_true[i]).collect();
let mut nees = 0.0;
for p in 0..n {
let mut row = 0.0;
for q in 0..n {
row += info[p][q] * dx[q];
}
nees += dx[p] * row;
}
if nees.is_finite() {
Some(nees)
} else {
None
}
}
#[derive(Clone, Copy, Debug, serde::Deserialize)]
pub struct LunarCombinationScenario {
#[serde(default = "d_n_sat")]
pub n_sat: usize,
#[serde(default = "d_n_earth")]
pub n_earth: usize,
#[serde(default = "d_seed")]
pub seed: u64,
#[serde(default = "d_sigma_vlbi_s")]
pub sigma_vlbi_s: f64,
#[serde(default = "d_sigma_range_m")]
pub sigma_range_m: f64,
#[serde(default = "d_sigma_isl_m")]
pub sigma_isl_m: f64,
#[serde(default = "d_sigma_clock_s")]
pub sigma_clock_s: f64,
#[serde(default = "d_station_lat_deg")]
pub station_lat_deg: f64,
#[serde(default = "d_station_lon_deg")]
pub station_lon_deg: f64,
#[serde(default = "d_station_alt_m")]
pub station_alt_m: f64,
#[serde(default = "d_orbit_radius_km")]
pub orbit_radius_km: f64,
#[serde(default = "d_epoch_year")]
pub epoch_year: i32,
#[serde(default = "d_epoch_month")]
pub epoch_month: u32,
#[serde(default = "d_epoch_day")]
pub epoch_day: u32,
}
impl Default for LunarCombinationScenario {
fn default() -> Self {
let c = LunarNetworkConfig::default();
LunarCombinationScenario {
n_sat: c.n_sat,
n_earth: c.n_earth,
seed: c.seed,
sigma_vlbi_s: c.sigma_vlbi_s,
sigma_range_m: c.sigma_range_m,
sigma_isl_m: c.sigma_isl_m,
sigma_clock_s: c.sigma_clock_s,
station_lat_deg: c.station_lat_deg,
station_lon_deg: c.station_lon_deg,
station_alt_m: c.station_alt_m,
orbit_radius_km: c.orbit_radius_km,
epoch_year: c.epoch_year,
epoch_month: c.epoch_month,
epoch_day: c.epoch_day,
}
}
}
impl LunarCombinationScenario {
fn config(&self, with_vlbi: bool) -> LunarNetworkConfig {
LunarNetworkConfig {
n_sat: self.n_sat,
n_earth: self.n_earth,
seed: self.seed,
sigma_vlbi_s: self.sigma_vlbi_s,
sigma_range_m: self.sigma_range_m,
sigma_isl_m: self.sigma_isl_m,
sigma_clock_s: self.sigma_clock_s,
with_vlbi,
epoch_year: self.epoch_year,
epoch_month: self.epoch_month,
epoch_day: self.epoch_day,
station_lat_deg: self.station_lat_deg,
station_lon_deg: self.station_lon_deg,
station_alt_m: self.station_alt_m,
orbit_radius_km: self.orbit_radius_km,
}
}
pub fn run(&self) -> LunarCombinationReport {
let with = estimate(&self.config(true));
let without = estimate(&self.config(false));
let improvement = if with.station_pos_err_m > 0.0 {
without.station_pos_err_m / with.station_pos_err_m
} else {
f64::INFINITY
};
LunarCombinationReport {
with_vlbi: with,
without_vlbi: without,
station_observability_improvement_factor: improvement,
n_sat: self.n_sat,
n_earth: self.n_earth,
}
}
}
#[derive(Clone, Copy, Debug, serde::Serialize)]
pub struct LunarCombinationReport {
pub with_vlbi: JointSolution,
pub without_vlbi: JointSolution,
pub station_observability_improvement_factor: f64,
pub n_sat: usize,
pub n_earth: usize,
}
pub fn lunar_combination_svg(r: &LunarCombinationReport) -> String {
let (w, h) = (820.0_f64, 360.0_f64);
let (ml, mr, mt, mb) = (70.0_f64, 20.0_f64, 50.0_f64, 56.0_f64);
let (pw, ph) = (w - ml - mr, h - mt - mb);
let log10 = |x: f64| (x.max(1e-6)).log10();
let with = log10(r.with_vlbi.station_pos_err_m);
let without = log10(r.without_vlbi.station_pos_err_m);
let y_lo = with.min(without).min(0.0) - 0.5;
let y_hi = with.max(without).max(1.0) + 0.5;
let span = (y_hi - y_lo).max(1e-9);
let yof = |v: f64| mt + ph - ((v - y_lo) / span) * ph;
let bar_w = pw / 4.0;
let x_with = ml + pw * 0.25 - bar_w / 2.0;
let x_without = ml + pw * 0.75 - bar_w / 2.0;
let base_y = mt + ph;
let mut svg = String::new();
svg.push_str(&format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{w:.0}\" height=\"{h:.0}\" font-family=\"sans-serif\" font-size=\"12\" fill=\"#bcb3a3\">"
));
svg.push_str(&format!(
"<rect width=\"{w:.0}\" height=\"{h:.0}\" fill=\"#0c0b08\"/>"
));
svg.push_str(&format!(
"<text x=\"{ml:.0}\" y=\"20\" font-size=\"15\" font-weight=\"bold\">Lunar joint OD + clock — station 3-D position error (VLBI restores observability)</text>"
));
svg.push_str(&format!(
"<text x=\"{ml:.0}\" y=\"38\" font-size=\"11\">{} sats, {} Earth stations · improvement factor {:.1}×</text>",
r.n_sat, r.n_earth, r.station_observability_improvement_factor
));
let yw = yof(with);
svg.push_str(&format!(
"<rect x=\"{x_with:.1}\" y=\"{yw:.1}\" width=\"{bar_w:.1}\" height=\"{:.1}\" fill=\"#7fbf7f\"/>",
(base_y - yw).max(0.0)
));
svg.push_str(&format!(
"<text x=\"{:.1}\" y=\"{:.1}\" text-anchor=\"middle\" font-size=\"11\">with VLBI: {:.2} m</text>",
x_with + bar_w / 2.0,
base_y + 16.0,
r.with_vlbi.station_pos_err_m
));
let ywo = yof(without);
svg.push_str(&format!(
"<rect x=\"{x_without:.1}\" y=\"{ywo:.1}\" width=\"{bar_w:.1}\" height=\"{:.1}\" fill=\"#bf7f7f\"/>",
(base_y - ywo).max(0.0)
));
svg.push_str(&format!(
"<text x=\"{:.1}\" y=\"{:.1}\" text-anchor=\"middle\" font-size=\"11\">range-only: {:.2} m</text>",
x_without + bar_w / 2.0,
base_y + 16.0,
r.without_vlbi.station_pos_err_m
));
svg.push_str(&format!(
"<line x1=\"{ml:.0}\" y1=\"{base_y:.0}\" x2=\"{:.0}\" y2=\"{base_y:.0}\" stroke=\"#342c21\"/>",
ml + pw
));
svg.push_str(&format!(
"<text x=\"{ml:.0}\" y=\"{:.0}\" font-size=\"11\">log10 station error (m); converged with/without = {}/{}</text>",
h - 12.0,
r.with_vlbi.converged,
r.without_vlbi.converged
));
svg.push_str("</svg>");
svg
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parameter_count_matches_the_layout() {
let cfg = LunarNetworkConfig::default();
assert_eq!(n_params(&cfg), 3 + 3 * 3 + 1 + 3);
assert_eq!(estimate(&cfg).n_params, 3 + 3 * 3 + 1 + 3);
}
#[test]
fn observable_count_is_consistent() {
let cfg = LunarNetworkConfig::default();
let n_base = cfg.n_earth * (cfg.n_earth - 1) / 2;
let n_isl = cfg.n_sat * (cfg.n_sat - 1) / 2;
let base = 1 + cfg.n_sat * cfg.n_earth + cfg.n_sat + n_isl;
let with = estimate(&cfg);
assert_eq!(with.n_obs, base + n_base);
let mut cfg_no = cfg;
cfg_no.with_vlbi = false;
let without = estimate(&cfg_no);
assert_eq!(without.n_obs, base);
assert_eq!(sigmas(&cfg).len(), with.n_obs);
assert_eq!(sigmas(&cfg_no).len(), without.n_obs);
}
#[test]
fn recovers_truth_with_full_fusion() {
let cfg = LunarNetworkConfig::default();
let s = estimate(&cfg);
assert!(s.converged, "full-fusion solve did not converge");
assert!(s.station_pos_err_m.is_finite() && s.sat_pos_rms_m.is_finite());
assert!(s.station_clock_err_s.is_finite() && s.sat_clock_rms_s.is_finite());
assert!(
s.station_pos_err_m < 8.0,
"station pos err {} m too large",
s.station_pos_err_m
);
assert!(
s.sat_pos_rms_m < 8.0,
"sat pos rms {} m too large",
s.sat_pos_rms_m
);
assert!(
s.station_clock_err_s < 1.0e-8,
"station clock err {} s too large",
s.station_clock_err_s
);
assert!(
s.sat_clock_rms_s < 2.0e-8,
"sat clock rms {} s too large",
s.sat_clock_rms_s
);
}
#[test]
fn vlbi_restores_station_observability() {
let cfg = LunarNetworkConfig::default();
let with = estimate(&cfg);
let mut cfg_no = cfg;
cfg_no.with_vlbi = false;
let without = estimate(&cfg_no);
assert!(with.station_pos_err_m.is_finite() && without.station_pos_err_m.is_finite());
assert!(
without.station_pos_err_m > 5.0 * with.station_pos_err_m,
"VLBI should sharpen station 3-D recovery by ≥5×: with={} m without={} m",
with.station_pos_err_m,
without.station_pos_err_m
);
assert!(
with.station_pos_err_m < 8.0,
"with-VLBI station error {} m not metre-level",
with.station_pos_err_m
);
}
#[test]
fn deterministic_same_seed() {
let cfg = LunarNetworkConfig::default();
let a = estimate(&cfg);
let b = estimate(&cfg);
assert_eq!(a.station_pos_err_m, b.station_pos_err_m);
assert_eq!(a.sat_pos_rms_m, b.sat_pos_rms_m);
assert_eq!(a.station_clock_err_s, b.station_clock_err_s);
assert_eq!(a.sat_clock_rms_s, b.sat_clock_rms_s);
assert_eq!(a.iterations, b.iterations);
let mut cfg2 = cfg;
cfg2.seed = 7;
let c = estimate(&cfg2);
assert_ne!(a.station_pos_err_m, c.station_pos_err_m);
assert!(c.converged && c.station_pos_err_m < 8.0 && c.sat_pos_rms_m < 8.0);
}
#[test]
fn nees_is_consistent() {
let base = LunarNetworkConfig::default();
let np = n_params(&base) as f64;
let mut acc = 0.0;
let mut count = 0usize;
for seed in 0..40u64 {
let mut cfg = base;
cfg.seed = seed;
if let Some(nees) = formal_covariance_nees(&cfg) {
assert!(nees.is_finite() && nees > 0.0, "NEES seed {seed}: {nees}");
acc += nees;
count += 1;
}
}
assert!(count >= 30, "too few finite NEES samples: {count}");
let mean = acc / count as f64;
assert!(
mean > 0.4 * np && mean < 2.5 * np,
"mean NEES {mean} not within a loose band around n_params {np}"
);
}
#[test]
fn without_vlbi_numbers_are_finite() {
let cfg = LunarNetworkConfig {
with_vlbi: false,
..LunarNetworkConfig::default()
};
let s = estimate(&cfg);
assert!(s.station_pos_err_m.is_finite());
assert!(s.sat_pos_rms_m.is_finite());
assert!(s.station_clock_err_s.is_finite());
assert!(s.sat_clock_rms_s.is_finite());
assert!(s.rms_residual.is_finite());
}
#[test]
fn scenario_run_carries_the_contrast() {
let r = LunarCombinationScenario::default().run();
assert!(r.with_vlbi.converged);
assert!(r.with_vlbi.station_pos_err_m < 8.0);
assert!(
r.station_observability_improvement_factor > 5.0,
"improvement factor {} too small",
r.station_observability_improvement_factor
);
}
#[test]
fn svg_is_self_contained() {
let r = LunarCombinationScenario::default().run();
let svg = lunar_combination_svg(&r);
assert!(svg.starts_with("<svg"));
assert!(svg.ends_with("</svg>"));
assert!(svg.contains("Lunar joint OD"));
}
#[test]
fn run_toml_lunar_combination_dispatches() {
let out = crate::api::run_toml("kind=\"lunar-joint-od-clock\"\n").unwrap();
assert!(
out.summary.contains("lunar-joint-od-clock"),
"summary missing kind: {}",
out.summary
);
let j: serde_json::Value = serde_json::from_str(&out.json).unwrap();
assert!(j["with_vlbi"]["station_pos_err_m"].as_f64().unwrap() < 8.0);
assert!(
j["station_observability_improvement_factor"]
.as_f64()
.unwrap()
> 5.0
);
assert!(out.svg.starts_with("<svg"));
}
#[test]
fn station_nominal_is_at_lunar_surface() {
let cfg = LunarNetworkConfig::default();
let net = Network::build(&cfg);
let mag = norm(net.station_nom_mci);
let r_moon = crate::lunar::R_MOON_M;
assert!(
(r_moon - 1.0..r_moon + 1.0).contains(&mag),
"station magnitude {mag} m not at lunar surface"
);
let geo = net.station_geocentric(net.station_nom_mci);
let range_km = norm(geo) / 1e3;
assert!(
(354_000.0..409_000.0).contains(&range_km),
"station geocentric range {range_km} km not at lunar distance"
);
}
}