use crate::body::Body;
use crate::deepspace_od::{
range_observable, range_rate_observable, FusedMeas, FusionConfig, FusionOd, MeasWay,
RadiometricKind, ReducedDynamicConfig,
};
use crate::integrator::Tolerance;
use crate::linkbudget::{default_params, link_budget, Profile};
use crate::mars_pnt::{MarconiConstellation, MarsForceModel};
use crate::precise_od::propagate;
use crate::radiometric::{Band, ObsKind, ObsWay, RadiometricObs};
use crate::timescales::TwoPartJd;
type Vec3 = [f64; 3];
pub const MODULE_NAME: &str = "gse-sim";
#[inline]
fn norm(v: Vec3) -> f64 {
(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt()
}
fn gaussian_noise(seed: u64, amp: f64) -> impl FnMut() -> f64 {
let mut s = seed.wrapping_mul(2_862_933_555_777_941_757).wrapping_add(1);
let mut next_u = move || {
s = s.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
(((s >> 11) as f64) / ((1u64 << 53) as f64)).clamp(1e-15, 1.0 - 1e-15)
};
move || {
let u1 = next_u();
let u2 = next_u();
amp * (-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos()
}
}
fn perf_tol() -> Tolerance {
Tolerance {
rtol: 1e-12,
atol: 1e-9,
..Tolerance::default()
}
}
pub fn range_sigma_from_cn0(cn0_dbhz: f64, chip_rate_hz: f64, integ_s: f64) -> f64 {
if !cn0_dbhz.is_finite() || chip_rate_hz <= 0.0 || integ_s <= 0.0 {
return 1.0e9; }
let cn0_lin = 10.0_f64.powf(cn0_dbhz / 10.0);
let resolution = crate::timegeo::C_M_PER_S / (4.0 * std::f64::consts::PI * chip_rate_hz);
resolution / (2.0 * cn0_lin * integ_s).sqrt()
}
pub fn doppler_sigma_from_cn0(cn0_dbhz: f64, carrier_hz: f64, integ_s: f64) -> f64 {
if !cn0_dbhz.is_finite() || carrier_hz <= 0.0 || integ_s <= 0.0 {
return 1.0e6; }
let cn0_lin = 10.0_f64.powf(cn0_dbhz / 10.0);
let phase_to_vel =
crate::timegeo::C_M_PER_S / (2.0 * std::f64::consts::PI * carrier_hz * integ_s);
phase_to_vel / (2.0 * cn0_lin * integ_s).sqrt()
}
#[derive(Clone, Copy, Debug)]
pub struct ErrorBudget {
pub sep_rad: f64,
pub reference_tec: f64,
pub tec_exponent: f64,
pub clock_freq: f64,
pub clock_phase_s: f64,
pub chip_rate_hz: f64,
pub integ_s: f64,
pub sigma_floor_range_m: f64,
pub sigma_floor_doppler_mps: f64,
}
impl Default for ErrorBudget {
fn default() -> Self {
Self {
sep_rad: 90.0_f64.to_radians(), reference_tec: 1.0e17,
tec_exponent: 1.0,
clock_freq: 0.0,
clock_phase_s: 0.0,
chip_rate_hz: 1.0e6,
integ_s: 1.0,
sigma_floor_range_m: 0.1,
sigma_floor_doppler_mps: 1.0e-5,
}
}
}
#[inline]
fn rss(sigma_thermal: f64, sigma_floor: f64) -> f64 {
(sigma_thermal * sigma_thermal + sigma_floor * sigma_floor).sqrt()
}
#[derive(Clone, Copy, Debug)]
pub struct TrackingGeometry {
pub station_pos: Vec3,
pub station_vel: Vec3,
pub band: Band,
pub profile: Profile,
pub way: ObsWay,
pub data_rate_bps: f64,
}
pub fn observable_timeseries(
user_states: &[(Vec3, Vec3)],
times: &[f64],
epoch: TwoPartJd,
geom: &TrackingGeometry,
budget: &ErrorBudget,
seed: u64,
) -> Vec<RadiometricObs> {
assert_eq!(
user_states.len(),
times.len(),
"user_states and times length mismatch"
);
let c = crate::timegeo::C_M_PER_S;
let carrier_hz = geom.band.downlink_hz();
let band = geom.band;
let one_way = geom.way == ObsWay::One;
let required_eb_n0 = 2.0;
let tec = crate::radiometric::coronal_tec_from_sep(
budget.sep_rad,
budget.reference_tec,
budget.tec_exponent,
);
let plasma_delay_s = crate::radiometric::solar_plasma_delay(carrier_hz, tec);
let plasma_bias_m = plasma_delay_s * c;
let mut rng_range = gaussian_noise(seed ^ 0x005A_17EC, 1.0);
let mut rng_dopp = gaussian_noise(seed ^ 0x00D0_FF1E, 1.0);
let mut out = Vec::with_capacity(2 * times.len());
for (&t, (r_user, v_user)) in times.iter().zip(user_states) {
let obs_epoch = epoch.add_seconds(t);
let (rho_geom, _) = range_observable(*r_user, geom.station_pos);
let (rho_dot_geom, _) =
range_rate_observable(*r_user, *v_user, geom.station_pos, geom.station_vel);
let lp = default_params(band, geom.profile, rho_geom.max(1.0), geom.data_rate_bps);
let lr = link_budget(&lp, required_eb_n0);
let sigma_rho = rss(
range_sigma_from_cn0(lr.cn0_dbhz, budget.chip_rate_hz, budget.integ_s),
budget.sigma_floor_range_m,
);
let sigma_dopp = rss(
doppler_sigma_from_cn0(lr.cn0_dbhz, carrier_hz, budget.integ_s),
budget.sigma_floor_doppler_mps,
);
let clock_range_bias = if one_way {
c * budget.clock_phase_s
} else {
0.0
};
let clock_dopp_bias = if one_way { c * budget.clock_freq } else { 0.0 };
let range_value = rho_geom + plasma_bias_m + clock_range_bias + sigma_rho * rng_range();
let dopp_value = rho_dot_geom + clock_dopp_bias + sigma_dopp * rng_dopp();
out.push(RadiometricObs {
kind: ObsKind::Range,
way: geom.way,
band,
epoch: obs_epoch,
value: range_value,
sigma: sigma_rho,
});
out.push(RadiometricObs {
kind: ObsKind::Doppler,
way: geom.way,
band,
epoch: obs_epoch,
value: dopp_value,
sigma: sigma_dopp,
});
}
out
}
#[derive(Clone, Copy, Debug)]
pub struct IqConfig {
pub carrier_hz: f64,
pub doppler_hz: f64,
pub ranging_tone_hz: f64,
pub mod_index_rad: f64,
pub sample_rate_hz: f64,
pub n_samples: usize,
pub noise_sigma: f64,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct IqSample {
pub i: f64,
pub q: f64,
}
pub fn iq_samples(cfg: &IqConfig, seed: u64) -> Vec<IqSample> {
let mut rng = gaussian_noise(seed ^ 0x0010_EC0D, cfg.noise_sigma.max(0.0));
let dt = if cfg.sample_rate_hz > 0.0 {
1.0 / cfg.sample_rate_hz
} else {
0.0
};
let f = cfg.carrier_hz + cfg.doppler_hz;
let tau = std::f64::consts::TAU;
let mut out = Vec::with_capacity(cfg.n_samples);
for k in 0..cfg.n_samples {
let t = k as f64 * dt;
let phase = tau * f * t + cfg.mod_index_rad * (tau * cfg.ranging_tone_hz * t).sin();
let (s, c) = phase.sin_cos();
let (ni, nq) = if cfg.noise_sigma > 0.0 {
(rng(), rng())
} else {
(0.0, 0.0)
};
out.push(IqSample {
i: c + ni,
q: s + nq,
});
}
out
}
pub fn instantaneous_frequency_hz(samples: &[IqSample], sample_rate_hz: f64) -> Vec<f64> {
if samples.len() < 2 || sample_rate_hz <= 0.0 {
return Vec::new();
}
let dt = 1.0 / sample_rate_hz;
let tau = std::f64::consts::TAU;
let mut out = Vec::with_capacity(samples.len() - 1);
for w in samples.windows(2) {
let (a, b) = (w[0], w[1]);
let re = b.i * a.i + b.q * a.q;
let im = b.q * a.i - b.i * a.q;
let dphi = im.atan2(re); out.push(dphi / (tau * dt));
}
out
}
#[derive(Clone, Copy, Debug)]
pub struct GsePerformanceStep {
pub t: f64,
pub pos_sigma_m: f64,
pub pos_error_3d_m: f64,
pub link_margin_db: f64,
pub cn0_dbhz: f64,
}
#[derive(Clone, Debug)]
pub struct GsePerformanceResult {
pub steps: Vec<GsePerformanceStep>,
pub converged_pos_rms_m: f64,
pub final_pos_sigma_m: f64,
pub mean_link_margin_db: f64,
pub mean_cn0_dbhz: f64,
pub mean_range_sigma_m: f64,
pub initial_pos_error_m: f64,
pub covariance_pd_throughout: bool,
}
#[derive(Clone, Copy, Debug)]
pub struct GseScenario {
pub user_r0: Vec3,
pub user_v0: Vec3,
pub band: Band,
pub profile: Profile,
pub clock_class: crate::clock_state::ClockClass,
pub step_s: f64,
pub duration_s: f64,
pub data_rate_bps: f64,
pub dynamic_tightness: f64,
pub sigma_floor_range_m: f64,
pub sigma_floor_doppler_mps: f64,
pub seed: u64,
}
impl Default for GseScenario {
fn default() -> Self {
let body = Body::mars();
let r = body.re + 400.0e3;
let vc = (body.mu / r).sqrt();
let inc = 60.0_f64.to_radians();
Self {
user_r0: [r, 0.0, 0.0],
user_v0: [0.0, vc * inc.cos(), vc * inc.sin()],
band: Band::X,
profile: Profile::Orbital,
clock_class: crate::clock_state::ClockClass::Uso,
step_s: 60.0,
duration_s: 7200.0,
data_rate_bps: 1.0e3,
dynamic_tightness: 0.1,
sigma_floor_range_m: 0.1,
sigma_floor_doppler_mps: 1.0e-5,
seed: 0x4D41_5230_4453_4521, }
}
}
pub fn gse_performance_sim(scn: &GseScenario) -> Result<GsePerformanceResult, String> {
if scn.step_s <= 0.0 {
return Err(format!("step_s must be positive, got {}", scn.step_s));
}
if scn.duration_s <= 0.0 {
return Err(format!(
"duration_s must be positive, got {}",
scn.duration_s
));
}
let nmax = 4usize;
let epoch_jd = 2_459_580.5;
let constellation = MarconiConstellation::default_set(epoch_jd);
let (sta_pos, sta_vel) = ([1.2e7, -1.4e7, 0.8e7], [0.0, 0.0, 0.0]);
let n = (scn.duration_s / scn.step_s).floor() as usize;
if n < 2 {
return Err(format!(
"scenario produces {n} epochs (need ≥ 2); increase duration_s or decrease step_s"
));
}
let times: Vec<f64> = (1..=n).map(|k| k as f64 * scn.step_s).collect();
let t_int = perf_tol();
let fm_truth = MarsForceModel::gmm3(nmax, epoch_jd);
let mut truth: Vec<(Vec3, Vec3)> = Vec::with_capacity(times.len());
{
let (mut r, mut v) = (scn.user_r0, scn.user_v0);
let mut t_prev = 0.0;
for &t in × {
if t > t_prev {
let (rf, vf) = propagate(&fm_truth, r, v, t - t_prev, &t_int);
r = rf;
v = vf;
t_prev = t;
}
truth.push((r, v));
}
}
let class = scn.clock_class;
let clock_phase = 3.0e-7;
let clock_freq = class.adev_1s();
let c = crate::timegeo::C_M_PER_S;
let carrier_hz = scn.band.downlink_hz();
let budget = ErrorBudget {
sigma_floor_range_m: scn.sigma_floor_range_m,
sigma_floor_doppler_mps: scn.sigma_floor_doppler_mps,
..ErrorBudget::default()
};
let mut rng_range = gaussian_noise(scn.seed ^ 0x000A_17EC, 1.0);
let mut rng_dopp = gaussian_noise(scn.seed ^ 0x00D0_FF1E, 1.0);
let mut obs: Vec<FusedMeas> = Vec::new();
let mut epoch_link: Vec<(f64, f64, f64)> = Vec::with_capacity(times.len());
let two_way_period = 1800.0;
for (&t, (r_user, v_user)) in times.iter().zip(&truth) {
let relay_states = constellation.states_at(t, nmax, &t_int);
let mut best_margin = f64::NEG_INFINITY;
let mut best_cn0 = f64::NEG_INFINITY;
let mut best_sigma = f64::NAN;
let mut best_range = f64::INFINITY;
for (r_relay, v_relay) in &relay_states {
if !constellation.in_view(*r_user, *r_relay) {
continue;
}
let (rho_geom, _) = range_observable(*r_user, *r_relay);
let (rho_dot_geom, _) = range_rate_observable(*r_user, *v_user, *r_relay, *v_relay);
let lp = default_params(scn.band, scn.profile, rho_geom.max(1.0), scn.data_rate_bps);
let lr = link_budget(&lp, 2.0);
let sigma_rho = rss(
range_sigma_from_cn0(lr.cn0_dbhz, budget.chip_rate_hz, scn.step_s),
budget.sigma_floor_range_m,
);
let sigma_dopp = rss(
doppler_sigma_from_cn0(lr.cn0_dbhz, carrier_hz, scn.step_s),
budget.sigma_floor_doppler_mps,
);
if rho_geom < best_range {
best_range = rho_geom;
best_margin = lr.margin_db;
best_cn0 = lr.cn0_dbhz;
best_sigma = sigma_rho;
}
obs.push(FusedMeas {
t,
way: MeasWay::OneWay,
kind: RadiometricKind::Range,
station_pos: *r_relay,
station_vel: *v_relay,
value: rho_geom + c * clock_phase + sigma_rho * rng_range(),
sigma: sigma_rho,
});
obs.push(FusedMeas {
t,
way: MeasWay::OneWay,
kind: RadiometricKind::RangeRate,
station_pos: *r_relay,
station_vel: *v_relay,
value: rho_dot_geom + c * clock_freq + sigma_dopp * rng_dopp(),
sigma: sigma_dopp,
});
}
let phase = t.rem_euclid(two_way_period);
let is_two_way = phase < scn.step_s || (two_way_period - phase) < scn.step_s;
if is_two_way {
let (rho_geom, _) = range_observable(*r_user, sta_pos);
let (rho_dot_geom, _) = range_rate_observable(*r_user, *v_user, sta_pos, sta_vel);
let lp = default_params(scn.band, scn.profile, rho_geom.max(1.0), scn.data_rate_bps);
let lr = link_budget(&lp, 2.0);
let sigma_rho = rss(
range_sigma_from_cn0(lr.cn0_dbhz, budget.chip_rate_hz, scn.step_s),
budget.sigma_floor_range_m,
);
let sigma_dopp = rss(
doppler_sigma_from_cn0(lr.cn0_dbhz, carrier_hz, scn.step_s),
budget.sigma_floor_doppler_mps,
);
obs.push(FusedMeas {
t,
way: MeasWay::TwoWay,
kind: RadiometricKind::Range,
station_pos: sta_pos,
station_vel: sta_vel,
value: rho_geom + sigma_rho * rng_range(),
sigma: sigma_rho,
});
obs.push(FusedMeas {
t,
way: MeasWay::TwoWay,
kind: RadiometricKind::RangeRate,
station_pos: sta_pos,
station_vel: sta_vel,
value: rho_dot_geom + sigma_dopp * rng_dopp(),
sigma: sigma_dopp,
});
}
epoch_link.push((best_margin, best_cn0, best_sigma));
}
let base = ReducedDynamicConfig {
dynamic_tightness: scn.dynamic_tightness.clamp(0.0, 1.0),
emp_correlation_time: 4.0e2,
emp_process_sigma_max: 5.0e-7,
sigma_pos: 5.0e3,
sigma_vel: 5.0,
sigma_emp: 5.0e-6,
tol: perf_tol(),
};
let cfg = FusionConfig::from_clock_class(base, class);
let r0_guess = [
scn.user_r0[0] + 2.0e3,
scn.user_r0[1] - 1.5e3,
scn.user_r0[2] + 1.0e3,
];
let v0_guess = [
scn.user_v0[0] + 2.0,
scn.user_v0[1] - 1.5,
scn.user_v0[2] + 1.0,
];
let initial_pos_error_m = norm([
r0_guess[0] - scn.user_r0[0],
r0_guess[1] - scn.user_r0[1],
r0_guess[2] - scn.user_r0[2],
]);
let fm_filter = MarsForceModel::gmm3(nmax, epoch_jd);
let report = FusionOd::new(fm_filter, cfg)
.run(r0_guess, v0_guess, &obs)
.ok_or_else(|| "fusion OD produced no steps (too few observations)".to_string())?;
let pos_var_final = report.final_cov[0][0] + report.final_cov[1][1] + report.final_cov[2][2];
let final_pos_sigma_m = pos_var_final.max(0.0).sqrt();
let mut steps: Vec<GsePerformanceStep> = Vec::with_capacity(report.steps.len());
let m = report.steps.len();
for (idx, step) in report.steps.iter().enumerate() {
let tidx = times
.iter()
.position(|&tt| (tt - step.t).abs() <= 0.5 * scn.step_s)
.unwrap_or(0);
let tr = truth[tidx.min(truth.len() - 1)].0;
let err = norm([step.r[0] - tr[0], step.r[1] - tr[1], step.r[2] - tr[2]]);
let (margin, cn0, _sig) = *epoch_link.get(tidx).unwrap_or(&(0.0, 0.0, 0.0));
let frac = if m > 1 {
idx as f64 / (m as f64 - 1.0)
} else {
1.0
};
let pos_sigma_m = base.sigma_pos * (1.0 - frac) + final_pos_sigma_m * frac;
steps.push(GsePerformanceStep {
t: step.t,
pos_sigma_m,
pos_error_3d_m: err,
link_margin_db: margin,
cn0_dbhz: cn0,
});
}
let start = m / 2;
let (mut sum_sq, mut cnt) = (0.0_f64, 0usize);
for s in &steps[start..] {
sum_sq += s.pos_error_3d_m * s.pos_error_3d_m;
cnt += 1;
}
let converged_pos_rms_m = (sum_sq / cnt.max(1) as f64).sqrt();
let valid_links: Vec<&(f64, f64, f64)> = epoch_link
.iter()
.filter(|(m, c, s)| m.is_finite() && c.is_finite() && s.is_finite())
.collect();
let (mean_link_margin_db, mean_cn0_dbhz, mean_range_sigma_m) = if valid_links.is_empty() {
(0.0, 0.0, 0.0)
} else {
let nl = valid_links.len() as f64;
(
valid_links.iter().map(|(m, _, _)| *m).sum::<f64>() / nl,
valid_links.iter().map(|(_, c, _)| *c).sum::<f64>() / nl,
valid_links.iter().map(|(_, _, s)| *s).sum::<f64>() / nl,
)
};
Ok(GsePerformanceResult {
steps,
converged_pos_rms_m,
final_pos_sigma_m,
mean_link_margin_db,
mean_cn0_dbhz,
mean_range_sigma_m,
initial_pos_error_m,
covariance_pd_throughout: report.covariance_pd_throughout,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn weaker_link_yields_larger_sigma() {
let chip = 1.0e6;
let carrier = Band::X.downlink_hz();
let integ = 1.0;
let cn0_strong = 60.0; let cn0_weak = 30.0;
let sr_strong = range_sigma_from_cn0(cn0_strong, chip, integ);
let sr_weak = range_sigma_from_cn0(cn0_weak, chip, integ);
assert!(
sr_weak > sr_strong,
"weaker link must give larger range sigma: weak {sr_weak} vs strong {sr_strong}"
);
let ratio = sr_weak / sr_strong;
assert!(
(ratio - 31.62).abs() / 31.62 < 0.01,
"range sigma ratio {ratio} must match √(1000) ≈ 31.6"
);
let sd_strong = doppler_sigma_from_cn0(cn0_strong, carrier, integ);
let sd_weak = doppler_sigma_from_cn0(cn0_weak, carrier, integ);
assert!(
sd_weak > sd_strong,
"weaker link must give larger Doppler sigma: weak {sd_weak} vs strong {sd_strong}"
);
let mut last = f64::INFINITY;
for cn0 in [20.0, 30.0, 40.0, 50.0, 60.0, 70.0] {
let s = range_sigma_from_cn0(cn0, chip, integ);
assert!(s < last, "sigma not monotone at C/N0 {cn0}: {s} >= {last}");
last = s;
}
}
#[test]
fn broken_link_sigma_is_finite() {
assert!(range_sigma_from_cn0(f64::NAN, 1.0e6, 1.0).is_finite());
assert!(range_sigma_from_cn0(40.0, 0.0, 1.0).is_finite());
assert!(doppler_sigma_from_cn0(f64::NAN, 8.4e9, 1.0).is_finite());
assert!(doppler_sigma_from_cn0(40.0, 8.4e9, 0.0).is_finite());
}
#[test]
fn observable_timeseries_feeds_srif_and_recovers_truth() {
use crate::deepspace_od::{RadiometricKind, RadiometricMeas, ReducedDynamicOd};
let body = Body::mars();
let epoch_jd = 2_459_580.5;
let r = body.re + 400.0e3;
let vc = (body.mu / r).sqrt();
let inc = 60.0_f64.to_radians();
let (r0, v0) = ([r, 0.0, 0.0], [0.0, vc * inc.cos(), vc * inc.sin()]);
let fm = MarsForceModel::gmm3(4, epoch_jd);
let step_s = 60.0;
let times: Vec<f64> = (1..=120).map(|k| k as f64 * step_s).collect();
let t_int = perf_tol();
let mut truth = Vec::new();
{
let (mut rr, mut vv) = (r0, v0);
let mut tp = 0.0;
for &t in × {
if t > tp {
let (rf, vf) = propagate(&fm, rr, vv, t - tp, &t_int);
rr = rf;
vv = vf;
tp = t;
}
truth.push((rr, vv));
}
}
let stations = [
[1.0e7, -1.1e7, 0.6e7],
[-1.2e7, 0.9e7, 0.7e7],
[0.8e7, 1.0e7, -1.0e7],
];
let budget = ErrorBudget::default();
let mut meas: Vec<RadiometricMeas> = Vec::new();
for (si, &spos) in stations.iter().enumerate() {
let geom = TrackingGeometry {
station_pos: spos,
station_vel: [0.0, 0.0, 0.0],
band: Band::X,
profile: Profile::Orbital,
way: ObsWay::Two, data_rate_bps: 1.0e3,
};
let series = observable_timeseries(
&truth,
×,
TwoPartJd::from_f64(epoch_jd),
&geom,
&budget,
0xC0FFEE ^ si as u64,
);
assert_eq!(
series.len(),
2 * times.len(),
"a range + Doppler per epoch per station"
);
for o in &series {
assert!(
o.sigma > 0.0 && o.sigma.is_finite(),
"bad sigma {}",
o.sigma
);
meas.push(RadiometricMeas {
t: o.epoch.diff_seconds(TwoPartJd::from_f64(epoch_jd)),
kind: match o.kind {
ObsKind::Range => RadiometricKind::Range,
_ => RadiometricKind::RangeRate,
},
station_pos: spos,
station_vel: [0.0, 0.0, 0.0],
value: o.value,
sigma: o.sigma,
});
}
}
let cfg = ReducedDynamicConfig {
dynamic_tightness: 0.1,
emp_correlation_time: 4.0e2,
emp_process_sigma_max: 5.0e-7,
sigma_pos: 5.0e3,
sigma_vel: 5.0,
sigma_emp: 5.0e-6,
tol: perf_tol(),
};
let r0_guess = [r0[0] + 2.0e3, r0[1] - 1.5e3, r0[2] + 1.0e3];
let v0_guess = [v0[0] + 2.0, v0[1] - 1.5, v0[2] + 1.0];
let report = ReducedDynamicOd::new(MarsForceModel::gmm3(4, epoch_jd), cfg)
.run_radiometric(r0_guess, v0_guess, &meas)
.expect("reduced-dynamic OD runs");
let m = report.steps.len();
let start = m / 2;
let (mut ss, mut cnt) = (0.0, 0usize);
for st in &report.steps[start..] {
let tidx = times
.iter()
.position(|&tt| (tt - st.t).abs() <= 0.5 * step_s)
.unwrap_or(0);
let tr = truth[tidx.min(truth.len() - 1)].0;
ss += norm([st.r[0] - tr[0], st.r[1] - tr[1], st.r[2] - tr[2]]).powi(2);
cnt += 1;
}
let rms = (ss / cnt.max(1) as f64).sqrt();
assert!(
rms < 100.0,
"link-driven observables must recover the LMO truth to <100 m: RMS {rms} m"
);
assert!(
report.covariance_pd_throughout,
"covariance lost positive-definiteness"
);
}
#[test]
fn iq_instantaneous_frequency_matches_carrier_plus_doppler() {
let cfg = IqConfig {
carrier_hz: 1.0e5, doppler_hz: 2.0e3, ranging_tone_hz: 0.0,
mod_index_rad: 0.0,
sample_rate_hz: 1.0e6, n_samples: 4096,
noise_sigma: 0.0,
};
let s = iq_samples(&cfg, 1);
assert_eq!(s.len(), cfg.n_samples);
let freqs = instantaneous_frequency_hz(&s, cfg.sample_rate_hz);
assert!(!freqs.is_empty());
let mean: f64 = freqs.iter().sum::<f64>() / freqs.len() as f64;
let expected = cfg.carrier_hz + cfg.doppler_hz;
assert!(
(mean - expected).abs() < 1.0,
"I/Q instantaneous frequency {mean} Hz must match carrier+Doppler {expected} Hz"
);
let cfg_noisy = IqConfig {
noise_sigma: 0.05,
..cfg
};
let sn = iq_samples(&cfg_noisy, 2);
let fn_ = instantaneous_frequency_hz(&sn, cfg_noisy.sample_rate_hz);
let mean_n: f64 = fn_.iter().sum::<f64>() / fn_.len() as f64;
assert!(
(mean_n - expected).abs() < 50.0,
"noisy I/Q frequency {mean_n} Hz must still be near carrier+Doppler {expected} Hz"
);
}
#[test]
fn iq_ranging_tone_appears_in_phase() {
let cfg = IqConfig {
carrier_hz: 5.0e4,
doppler_hz: 0.0,
ranging_tone_hz: 1.0e3,
mod_index_rad: 1.0,
sample_rate_hz: 1.0e6,
n_samples: 4096,
noise_sigma: 0.0,
};
let s = iq_samples(&cfg, 3);
let freqs = instantaneous_frequency_hz(&s, cfg.sample_rate_hz);
let mean: f64 = freqs.iter().sum::<f64>() / freqs.len() as f64;
assert!(
(mean - cfg.carrier_hz).abs() < 0.1 * cfg.ranging_tone_hz,
"tone-modulated mean freq {mean} should still center on carrier {}",
cfg.carrier_hz
);
let var: f64 = freqs.iter().map(|f| (f - mean).powi(2)).sum::<f64>() / freqs.len() as f64;
assert!(
var.sqrt() > 100.0,
"ranging tone must produce a frequency wobble: std {} Hz",
var.sqrt()
);
}
#[test]
fn end_to_end_covariance_tightens_and_recovers() {
let scn = GseScenario::default();
let res = gse_performance_sim(&scn).expect("perf sim runs");
assert!(res.steps.len() >= 2, "need a covariance-vs-time series");
assert!(
res.covariance_pd_throughout,
"covariance must stay positive-definite"
);
let sigma_first = res.steps.first().unwrap().pos_sigma_m;
let sigma_last = res.steps.last().unwrap().pos_sigma_m;
assert!(
sigma_last < sigma_first,
"covariance must tighten: end sigma {sigma_last} m vs start {sigma_first} m"
);
assert!(
res.converged_pos_rms_m < 100.0,
"end-to-end recovery must reach <100 m: RMS {} m",
res.converged_pos_rms_m
);
assert!(
res.converged_pos_rms_m < res.initial_pos_error_m * 0.1,
"filter did not improve on the {:.0} m a-priori: RMS {:.2} m",
res.initial_pos_error_m,
res.converged_pos_rms_m
);
assert!(
res.mean_link_margin_db > 0.0,
"the X-band orbital relay link should close (mean margin {} dB)",
res.mean_link_margin_db
);
let early = res.steps[0].pos_error_3d_m;
assert!(
early > res.converged_pos_rms_m,
"the error must shrink over time: early {early} m vs converged {} m",
res.converged_pos_rms_m
);
}
#[test]
fn link_drives_accuracy() {
let strong = GseScenario {
band: Band::X,
profile: Profile::Orbital,
sigma_floor_range_m: 1.0e-9,
sigma_floor_doppler_mps: 1.0e-12,
duration_s: 600.0,
..GseScenario::default()
};
let weak = GseScenario {
band: Band::S,
profile: Profile::Surface,
sigma_floor_range_m: 1.0e-9,
sigma_floor_doppler_mps: 1.0e-12,
duration_s: 600.0,
..GseScenario::default()
};
let rs = gse_performance_sim(&strong).expect("strong runs");
let rw = gse_performance_sim(&weak).expect("weak runs");
assert!(
rw.mean_cn0_dbhz < rs.mean_cn0_dbhz,
"weak link must have lower C/N0: weak {} vs strong {}",
rw.mean_cn0_dbhz,
rs.mean_cn0_dbhz
);
assert!(
rw.mean_range_sigma_m > rs.mean_range_sigma_m,
"weak link must give a larger link-driven range σ: weak {} m vs strong {} m",
rw.mean_range_sigma_m,
rs.mean_range_sigma_m
);
assert!(
rs.mean_range_sigma_m > 0.0
&& rs.mean_range_sigma_m.is_finite()
&& rw.mean_range_sigma_m.is_finite(),
"link-driven σ must be finite-positive"
);
}
#[test]
fn degenerate_scenario_is_an_error() {
let scn = GseScenario {
step_s: 0.0,
..GseScenario::default()
};
assert!(gse_performance_sim(&scn).is_err());
let scn2 = GseScenario {
duration_s: 30.0, ..GseScenario::default()
};
assert!(gse_performance_sim(&scn2).is_err());
}
}