use crate::body::Body;
use crate::chart::y_axis;
use crate::deepspace_od::{
range_observable, range_rate_observable, FusedMeas, FusionConfig, FusionOd, MeasWay,
RadiometricKind, ReducedDynamicConfig,
};
use crate::integrator::Tolerance;
use crate::mars_atmos::{mars_drag_accel, MARS_RE};
use crate::mars_frame::{bodyfixed_to_inertial, iau_mars_rotation, inertial_to_bodyfixed};
use crate::precession::{mat_vec, transpose};
use crate::precise_od::{empirical_accel, propagate, EmpiricalAccel, ForceModel};
use crate::timescales::SECONDS_PER_DAY;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
type Vec3 = [f64; 3];
pub const MODULE_NAME: &str = "mars-pnt";
#[derive(Clone, Debug)]
pub struct MarsForceModel {
body: Body,
epoch_jd_tdb: f64,
cd_area_over_mass: f64,
empirical: Option<EmpiricalAccel>,
}
impl MarsForceModel {
pub fn gmm3(nmax: usize, epoch_jd_tdb: f64) -> Self {
Self {
body: Body::mars_gmm3(nmax),
epoch_jd_tdb,
cd_area_over_mass: 0.0,
empirical: None,
}
}
pub fn with_drag(mut self, cd_area_over_mass: f64) -> Self {
self.cd_area_over_mass = cd_area_over_mass;
self
}
pub fn body(&self) -> &Body {
&self.body
}
pub fn epoch_jd_tdb(&self) -> f64 {
self.epoch_jd_tdb
}
}
impl ForceModel for MarsForceModel {
fn accel_rv(&self, t: f64, r: Vec3, v: Vec3) -> Vec3 {
let jd = self.epoch_jd_tdb + t / SECONDS_PER_DAY;
let field = self
.body
.gravity
.as_ref()
.expect("MarsForceModel.body is private and only ever set to Body::mars_gmm3(..), which unconditionally populates `gravity` to Some");
let r_bf = inertial_to_bodyfixed(r, &self.body, jd);
let a_bf = field.acceleration(r_bf);
let m = iau_mars_rotation(&self.body, jd);
let mut a = mat_vec(&transpose(&m), a_bf);
let mut add = |p: Vec3| a = [a[0] + p[0], a[1] + p[1], a[2] + p[2]];
if self.cd_area_over_mass > 0.0 {
add(mars_drag_accel(r, v, self.cd_area_over_mass));
}
if let Some(emp) = self.empirical {
add(empirical_accel(&emp, r, v));
}
a
}
fn cr(&self) -> f64 {
1.0
}
fn set_cr(&mut self, _cr: f64) {}
fn set_empirical(&mut self, empirical: Option<EmpiricalAccel>) {
self.empirical = empirical;
}
}
#[derive(Clone, Debug)]
pub struct SurfaceForceModel {
omega: Vec3,
empirical: Option<EmpiricalAccel>,
}
impl SurfaceForceModel {
pub fn new(body: &Body, epoch_jd_tdb: f64) -> Self {
let m = iau_mars_rotation(body, epoch_jd_tdb); let omega = mat_vec(&transpose(&m), [0.0, 0.0, body.rotation_rate]); Self {
omega,
empirical: None,
}
}
}
impl ForceModel for SurfaceForceModel {
fn accel_rv(&self, _t: f64, r: Vec3, v: Vec3) -> Vec3 {
let wxr = cross(self.omega, r);
let mut a = cross(self.omega, wxr);
if let Some(emp) = self.empirical {
let p = empirical_accel(&emp, r, v);
a = [a[0] + p[0], a[1] + p[1], a[2] + p[2]];
}
a
}
fn cr(&self) -> f64 {
1.0
}
fn set_cr(&mut self, _cr: f64) {}
fn set_empirical(&mut self, empirical: Option<EmpiricalAccel>) {
self.empirical = empirical;
}
}
#[derive(Clone, Copy, Debug)]
pub struct Relay {
pub name: &'static str,
pub r0: Vec3,
pub v0: Vec3,
}
#[derive(Clone, Debug)]
pub struct MarconiConstellation {
pub relays: Vec<Relay>,
pub body: Body,
pub epoch_jd_tdb: f64,
pub limb_margin_m: f64,
}
pub fn areostationary_radius(body: &Body) -> f64 {
(body.mu / (body.rotation_rate * body.rotation_rate)).cbrt()
}
impl MarconiConstellation {
pub fn default_set(epoch_jd_tdb: f64) -> Self {
let body = Body::mars();
let mut relays = Vec::new();
let r_geo = areostationary_radius(&body);
let names_geo = ["MARCONI-G1", "MARCONI-G2", "MARCONI-G3"];
for (k, name) in names_geo.iter().enumerate() {
let lon = k as f64 * (std::f64::consts::TAU / 3.0);
let inc = 5.0_f64.to_radians();
let (r0, v0) = circular_state(&body, r_geo, lon, inc);
relays.push(Relay { name, r0, v0 });
}
let r_hi = 1.4 * r_geo;
let names_hi = ["MARCONI-H1", "MARCONI-H2"];
for (k, name) in names_hi.iter().enumerate() {
let lon = std::f64::consts::PI * k as f64; let inc = 60.0_f64.to_radians();
let (r0, v0) = circular_state(&body, r_hi, lon, inc);
relays.push(Relay { name, r0, v0 });
}
Self {
relays,
body,
epoch_jd_tdb,
limb_margin_m: 50.0e3, }
}
pub fn states_at(&self, t: f64, nmax: usize, tol: &Tolerance) -> Vec<(Vec3, Vec3)> {
let fm = MarsForceModel::gmm3(nmax, self.epoch_jd_tdb);
self.relays
.iter()
.map(|relay| {
if t <= 0.0 {
(relay.r0, relay.v0)
} else {
propagate(&fm, relay.r0, relay.v0, t, tol)
}
})
.collect()
}
pub fn in_view(&self, r_user: Vec3, r_relay: Vec3) -> bool {
let limb = self.body.re + self.limb_margin_m;
chord_clears_sphere(r_user, r_relay, limb)
}
}
fn circular_state(body: &Body, r: f64, lon: f64, inc: f64) -> (Vec3, Vec3) {
let vc = (body.mu / r).sqrt();
let (sl, cl) = lon.sin_cos();
let (si, ci) = inc.sin_cos();
let r0 = [r * cl, r * sl, 0.0];
let v0 = [vc * (-sl * ci), vc * (cl * ci), vc * si];
(r0, v0)
}
fn chord_clears_sphere(a: Vec3, b: Vec3, radius: f64) -> bool {
let ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
let ab2 = ab[0] * ab[0] + ab[1] * ab[1] + ab[2] * ab[2];
if ab2 <= 0.0 {
return true;
}
let s = -(a[0] * ab[0] + a[1] * ab[1] + a[2] * ab[2]) / ab2;
if !(0.0..=1.0).contains(&s) {
return true;
}
let closest = [a[0] + s * ab[0], a[1] + s * ab[1], a[2] + s * ab[2]];
let d2 = closest[0] * closest[0] + closest[1] * closest[1] + closest[2] * closest[2];
d2 >= radius * radius
}
fn default_station() -> (Vec3, Vec3) {
let d = 3.0 * MARS_RE;
([0.6 * d, -0.7 * d, 0.4 * d], [0.0, 0.0, 0.0])
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum UserKind {
Transfer,
Lmo,
Surface,
}
impl UserKind {
fn as_str(self) -> &'static str {
match self {
UserKind::Transfer => "transfer",
UserKind::Lmo => "lmo",
UserKind::Surface => "surface",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ClockClassCfg {
Csac,
Uso,
Dsac,
}
impl ClockClassCfg {
fn to_clock_class(self) -> crate::clock_state::ClockClass {
match self {
ClockClassCfg::Csac => crate::clock_state::ClockClass::Csac,
ClockClassCfg::Uso => crate::clock_state::ClockClass::Uso,
ClockClassCfg::Dsac => crate::clock_state::ClockClass::Dsac,
}
}
fn as_str(self) -> &'static str {
match self {
ClockClassCfg::Csac => "csac",
ClockClassCfg::Uso => "uso",
ClockClassCfg::Dsac => "dsac",
}
}
}
fn default_user() -> UserKind {
UserKind::Lmo
}
fn default_clock() -> ClockClassCfg {
ClockClassCfg::Uso
}
fn default_step_s() -> f64 {
30.0
}
fn default_duration_s() -> f64 {
7200.0
}
fn default_nmax() -> usize {
4
}
fn default_range_sigma_m() -> f64 {
1.0
}
fn default_doppler_sigma_mps() -> f64 {
1.0e-4
}
fn default_dynamic_tightness() -> f64 {
0.1
}
fn default_two_way_period_s() -> f64 {
1800.0
}
fn default_seed() -> u64 {
0x4D_4152_C0DE }
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct MarsScenario {
#[serde(default = "default_user")]
pub user: UserKind,
#[serde(default = "default_clock")]
pub clock_class: ClockClassCfg,
#[serde(default = "default_step_s")]
pub step_s: f64,
#[serde(default = "default_duration_s")]
pub duration_s: f64,
#[serde(default = "default_nmax")]
pub nmax: usize,
#[serde(default = "default_range_sigma_m")]
pub range_sigma_m: f64,
#[serde(default = "default_doppler_sigma_mps")]
pub doppler_sigma_mps: f64,
#[serde(default = "default_dynamic_tightness")]
pub dynamic_tightness: f64,
#[serde(default = "default_two_way_period_s")]
pub two_way_period_s: f64,
#[serde(default = "default_seed")]
pub seed: u64,
}
impl MarsScenario {
fn user_epoch_state(&self, body: &Body, epoch_jd_tdb: f64) -> (Vec3, Vec3) {
match self.user {
UserKind::Lmo => {
let r = body.re + 400.0e3;
let vc = (body.mu / r).sqrt();
let inc = 60.0_f64.to_radians();
([r, 0.0, 0.0], [0.0, vc * inc.cos(), vc * inc.sin()])
}
UserKind::Transfer => {
let rp = body.re + 500.0e3;
let ra = 6.0 * body.re;
let a = 0.5 * (rp + ra);
let vp = (body.mu * (2.0 / rp - 1.0 / a)).sqrt();
let inc = 30.0_f64.to_radians();
([rp, 0.0, 0.0], [0.0, vp * inc.cos(), vp * inc.sin()])
}
UserKind::Surface => {
let r_bf = [body.re, 0.0, 0.0];
let r0 = bodyfixed_to_inertial(r_bf, body, epoch_jd_tdb);
let omega_bf = [0.0, 0.0, body.rotation_rate];
let m = iau_mars_rotation(body, epoch_jd_tdb); let omega_in = mat_vec(&transpose(&m), omega_bf); let v0 = cross(omega_in, r0);
(r0, v0)
}
}
}
}
fn cross(a: Vec3, b: Vec3) -> Vec3 {
[
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
]
}
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()
}
}
#[derive(Clone, Debug, Serialize)]
pub struct GeometryStep {
pub t: f64,
pub relays_in_view: usize,
pub in_view: Vec<&'static str>,
pub two_way_pass: bool,
}
#[derive(Clone, Copy, Debug, Serialize)]
pub struct EstimationStep {
pub t: f64,
pub pos_error_3d_m: f64,
pub pos_sigma_m: f64,
pub pos_3sigma_m: f64,
pub clock_freq_sigma: f64,
}
#[derive(Clone, Copy, Debug, Serialize)]
pub struct MarsPntFoM {
pub epochs: usize,
pub mean_relays_in_view: f64,
pub converged_pos_rms_m: f64,
pub converged_pos_sigma_m: f64,
pub converged_pos_3sigma_m: f64,
pub final_clock_freq_sigma: f64,
pub initial_pos_error_m: f64,
pub covariance_pd_throughout: bool,
}
#[derive(Clone, Debug, Serialize)]
pub struct MarsPntResult {
pub schema_version: String,
pub engine_version: String,
pub scenario_hash: String,
pub user: String,
pub clock_class: String,
pub n_relays: usize,
pub areostationary_radius_m: f64,
pub fom_note: String,
pub fom: MarsPntFoM,
pub geometry: Vec<GeometryStep>,
pub estimation: Vec<EstimationStep>,
}
fn hash_scenario(scn: &MarsScenario) -> String {
let c = serde_json::to_string(scn).unwrap_or_default();
let mut h = Sha256::new();
h.update(c.as_bytes());
hex::encode(h.finalize())
}
fn tol() -> Tolerance {
Tolerance {
rtol: 1e-12,
atol: 1e-9,
..Tolerance::default()
}
}
pub fn run_mars_pnt(scn: &MarsScenario) -> Result<MarsPntResult, 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 = scn.nmax.min(4);
let epoch = 2_459_580.5;
let body = Body::mars();
let constellation = MarconiConstellation::default_set(epoch);
let (sta_pos, sta_vel) = default_station();
let (r0, v0) = scn.user_epoch_state(&body, epoch);
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();
match scn.user {
UserKind::Surface => {
let fm = SurfaceForceModel::new(&body, epoch);
run_core(
scn,
&body,
&constellation,
sta_pos,
sta_vel,
fm,
r0,
v0,
×,
nmax,
)
}
UserKind::Lmo | UserKind::Transfer => {
let fm = MarsForceModel::gmm3(nmax, epoch);
run_core(
scn,
&body,
&constellation,
sta_pos,
sta_vel,
fm,
r0,
v0,
×,
nmax,
)
}
}
}
#[allow(clippy::too_many_arguments)]
fn run_core<F: ForceModel>(
scn: &MarsScenario,
body: &Body,
constellation: &MarconiConstellation,
sta_pos: Vec3,
sta_vel: Vec3,
fm: F,
r0: Vec3,
v0: Vec3,
times: &[f64],
nmax: usize,
) -> Result<MarsPntResult, String> {
let truth = truth_states(&fm, r0, v0, times);
let class = scn.clock_class.to_clock_class();
let truth_clock_phase = 3.0e-7; let truth_clock_freq = class.adev_1s();
let obs = build_observations(
scn,
constellation,
&truth,
times,
sta_pos,
sta_vel,
truth_clock_phase,
truth_clock_freq,
nmax,
);
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: tol(),
};
let cfg = FusionConfig::from_clock_class(base, class);
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 initial_pos_error_m = norm([
r0_guess[0] - r0[0],
r0_guess[1] - r0[1],
r0_guess[2] - r0[2],
]);
let report = FusionOd::new(fm, cfg)
.run(r0_guess, v0_guess, &obs)
.ok_or_else(|| "fusion OD run produced no steps (too few observations)".to_string())?;
let geometry = build_geometry(scn, constellation, &truth, times, nmax);
let mut estimation = Vec::with_capacity(report.steps.len());
for step in &report.steps {
let idx = times
.iter()
.position(|&t| (t - step.t).abs() <= 0.5 * scn.step_s)
.unwrap_or(0);
let tr = truth[idx.min(truth.len() - 1)].0;
let err = norm([step.r[0] - tr[0], step.r[1] - tr[1], step.r[2] - tr[2]]);
estimation.push(EstimationStep {
t: step.t,
pos_error_3d_m: err,
pos_sigma_m: 0.0, pos_3sigma_m: 0.0,
clock_freq_sigma: step.clock_freq_sigma,
});
}
let pos_var = report.final_cov[0][0] + report.final_cov[1][1] + report.final_cov[2][2];
let pos_sigma_m = pos_var.max(0.0).sqrt();
for e in estimation.iter_mut() {
e.pos_sigma_m = pos_sigma_m;
e.pos_3sigma_m = 3.0 * pos_sigma_m;
}
let m = estimation.len();
let start = m / 2;
let (mut sum_sq, mut cnt) = (0.0_f64, 0usize);
for e in &estimation[start..] {
sum_sq += e.pos_error_3d_m * e.pos_error_3d_m;
cnt += 1;
}
let converged_pos_rms_m = (sum_sq / cnt.max(1) as f64).sqrt();
let mean_relays_in_view = if geometry.is_empty() {
0.0
} else {
geometry
.iter()
.map(|g| g.relays_in_view as f64)
.sum::<f64>()
/ geometry.len() as f64
};
let final_clock_freq_sigma = estimation.last().map_or(0.0, |e| e.clock_freq_sigma);
let fom = MarsPntFoM {
epochs: m,
mean_relays_in_view,
converged_pos_rms_m,
converged_pos_sigma_m: pos_sigma_m,
converged_pos_3sigma_m: 3.0 * pos_sigma_m,
final_clock_freq_sigma,
initial_pos_error_m,
covariance_pd_throughout: report.covariance_pd_throughout,
};
Ok(MarsPntResult {
schema_version: "1.0".into(),
engine_version: env!("CARGO_PKG_VERSION").into(),
scenario_hash: hash_scenario(scn),
user: scn.user.as_str().into(),
clock_class: scn.clock_class.as_str().into(),
n_relays: constellation.relays.len(),
areostationary_radius_m: areostationary_radius(body),
fom_note: "Figures of merit are the estimator's formal covariance bounds (1σ / 3σ \
position) and the achieved RMS vs a synthetic closed-loop truth. These are \
simulated navigation FoM, NOT aviation-certified protection levels: there is no \
certified fault model or integrity monitor here."
.into(),
fom,
geometry,
estimation,
})
}
fn truth_states<F: ForceModel>(fm: &F, r0: Vec3, v0: Vec3, times: &[f64]) -> Vec<(Vec3, Vec3)> {
let mut out = Vec::with_capacity(times.len());
let mut t_prev = 0.0;
let (mut r, mut v) = (r0, v0);
let t = tol();
for &time in times {
if time > t_prev {
let (rf, vf) = propagate(fm, r, v, time - t_prev, &t);
r = rf;
v = vf;
t_prev = time;
}
out.push((r, v));
}
out
}
fn is_two_way_epoch(scn: &MarsScenario, t: f64) -> bool {
if scn.two_way_period_s <= 0.0 {
return false;
}
let phase = t.rem_euclid(scn.two_way_period_s);
phase < scn.step_s || (scn.two_way_period_s - phase) < scn.step_s
}
#[allow(clippy::too_many_arguments)]
fn build_observations(
scn: &MarsScenario,
constellation: &MarconiConstellation,
truth: &[(Vec3, Vec3)],
times: &[f64],
sta_pos: Vec3,
sta_vel: Vec3,
clock_phase: f64,
clock_freq: f64,
nmax: usize,
) -> Vec<FusedMeas> {
let c = crate::timegeo::C_M_PER_S;
let mut rng_range = gaussian_noise(scn.seed ^ 0xA17EC, scn.range_sigma_m);
let mut rng_dopp = gaussian_noise(scn.seed ^ 0xD0FF1E, scn.doppler_sigma_mps);
let t_int = tol();
let mut obs = Vec::new();
for (&t, (r_user, v_user)) in times.iter().zip(truth) {
let relay_states = constellation.states_at(t, nmax, &t_int);
for (r_relay, v_relay) in &relay_states {
if !constellation.in_view(*r_user, *r_relay) {
continue;
}
let rho = range_observable(*r_user, *r_relay).0 + c * clock_phase;
let rho_dot =
range_rate_observable(*r_user, *v_user, *r_relay, *v_relay).0 + c * clock_freq;
obs.push(FusedMeas {
t,
way: MeasWay::OneWay,
kind: RadiometricKind::Range,
station_pos: *r_relay,
station_vel: *v_relay,
value: rho + rng_range(),
sigma: scn.range_sigma_m,
});
obs.push(FusedMeas {
t,
way: MeasWay::OneWay,
kind: RadiometricKind::RangeRate,
station_pos: *r_relay,
station_vel: *v_relay,
value: rho_dot + rng_dopp(),
sigma: scn.doppler_sigma_mps,
});
}
if is_two_way_epoch(scn, t) {
let rho = range_observable(*r_user, sta_pos).0; let rho_dot = range_rate_observable(*r_user, *v_user, sta_pos, sta_vel).0;
obs.push(FusedMeas {
t,
way: MeasWay::TwoWay,
kind: RadiometricKind::Range,
station_pos: sta_pos,
station_vel: sta_vel,
value: rho + rng_range(),
sigma: scn.range_sigma_m,
});
obs.push(FusedMeas {
t,
way: MeasWay::TwoWay,
kind: RadiometricKind::RangeRate,
station_pos: sta_pos,
station_vel: sta_vel,
value: rho_dot + rng_dopp(),
sigma: scn.doppler_sigma_mps,
});
}
}
obs
}
fn build_geometry(
scn: &MarsScenario,
constellation: &MarconiConstellation,
truth: &[(Vec3, Vec3)],
times: &[f64],
nmax: usize,
) -> Vec<GeometryStep> {
let t_int = tol();
let mut out = Vec::with_capacity(times.len());
for (&t, (r_user, _v)) in times.iter().zip(truth) {
let relay_states = constellation.states_at(t, nmax, &t_int);
let mut in_view = Vec::new();
for (relay, (r_relay, _v_relay)) in constellation.relays.iter().zip(&relay_states) {
if constellation.in_view(*r_user, *r_relay) {
in_view.push(relay.name);
}
}
out.push(GeometryStep {
t,
relays_in_view: in_view.len(),
in_view,
two_way_pass: is_two_way_epoch(scn, t),
});
}
out
}
pub fn summary(r: &MarsPntResult) -> String {
format!(
"mars-pnt {} | {} user, {} clock | {} relays (mean {:.1} in view) | converged pos RMS {:.2} m, formal 1σ {:.2} m (3σ {:.2} m — covariance FoM, not a certified PL) | clock-freq σ {:.2e} | cov-PD {}",
&r.scenario_hash[..12.min(r.scenario_hash.len())],
r.user,
r.clock_class,
r.n_relays,
r.fom.mean_relays_in_view,
r.fom.converged_pos_rms_m,
r.fom.converged_pos_sigma_m,
r.fom.converged_pos_3sigma_m,
r.fom.final_clock_freq_sigma,
r.fom.covariance_pd_throughout,
)
}
pub fn to_svg(result: &MarsPntResult) -> String {
let (w, h) = (820.0_f64, 360.0_f64);
let (ml, mr, mt, mb) = (70.0_f64, 20.0_f64, 34.0_f64, 40.0_f64);
let (pw, ph) = (w - ml - mr, h - mt - mb);
let errs: Vec<f64> = result.estimation.iter().map(|e| e.pos_error_3d_m).collect();
let sigma3 = result.fom.converged_pos_3sigma_m;
let conv_max = {
let m = errs.len();
let start = m / 2;
errs[start..]
.iter()
.cloned()
.fold(0.0_f64, f64::max)
.max(sigma3)
};
let y_max = (conv_max * 1.4).max(1.0);
let title = format!(
"Mars-PNT — {} user, {} clock: 3-D position error vs truth (converged RMS {:.2} m)",
result.user, result.clock_class, result.fom.converged_pos_rms_m
);
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=\"18\" font-size=\"14\" font-weight=\"bold\">{title}</text>"
));
svg.push_str(&y_axis(ml, mt, pw, ph, y_max, "position error (m)"));
let y3 = mt + ph - (sigma3.min(y_max) / y_max) * ph;
svg.push_str(&format!(
"<line x1=\"{ml:.0}\" y1=\"{y3:.1}\" x2=\"{:.1}\" y2=\"{y3:.1}\" stroke=\"#c98a3a\" stroke-dasharray=\"6 4\"/>",
ml + pw
));
svg.push_str(&format!(
"<text x=\"{:.1}\" y=\"{:.1}\" text-anchor=\"end\" fill=\"#c98a3a\" font-size=\"10\">formal 3σ (covariance, not a certified PL)</text>",
ml + pw,
y3 - 4.0
));
let n = errs.len().max(1);
let bw = (pw / n as f64).min(20.0);
for (i, &v) in errs.iter().enumerate() {
let x = ml + (i as f64 + 0.5) * (pw / n as f64) - bw / 2.0;
let bh = (v.min(y_max) / y_max) * ph;
let y = mt + ph - bh;
svg.push_str(&format!(
"<rect x=\"{x:.1}\" y=\"{y:.1}\" width=\"{bw:.1}\" height=\"{bh:.1}\" fill=\"#5fb0a8\"/>"
));
}
let axis_y = mt + ph;
svg.push_str(&format!(
"<line x1=\"{ml:.0}\" y1=\"{axis_y:.0}\" x2=\"{:.0}\" y2=\"{axis_y:.0}\" stroke=\"#342c21\"/>",
ml + pw
));
svg.push_str("</svg>");
svg
}
#[cfg(test)]
mod tests {
use super::*;
fn lmo_scenario() -> MarsScenario {
MarsScenario {
user: UserKind::Lmo,
clock_class: ClockClassCfg::Uso,
step_s: 60.0,
duration_s: 7200.0,
nmax: 4,
range_sigma_m: 1.0,
doppler_sigma_mps: 1.0e-4,
dynamic_tightness: 0.1,
two_way_period_s: 1800.0,
seed: 0x4D_4152_C0DE,
}
}
#[test]
fn areostationary_radius_is_mars_synchronous() {
let r = areostationary_radius(&Body::mars());
assert!(
(20_400.0e3..20_500.0e3).contains(&r),
"areostationary radius {r:.0} m out of the published ~20 428 km band"
);
}
#[test]
fn default_constellation_has_five_relays() {
let c = MarconiConstellation::default_set(2_459_580.5);
assert_eq!(c.relays.len(), 5, "three areostationary + two inclined");
for relay in &c.relays {
let r = norm(relay.r0);
let v = norm(relay.v0);
assert!(r > Body::mars().re, "{} below the surface", relay.name);
assert!(v > 0.0 && v.is_finite(), "{} bad speed", relay.name);
}
}
#[test]
fn chord_occultation_test_is_correct() {
let re = Body::mars().re;
let user = [re + 400.0e3, 0.0, 0.0];
let far_side = [-(re + 400.0e3), 0.0, 0.0];
assert!(
!chord_clears_sphere(user, far_side, re),
"a diametrically-opposite relay must be occulted"
);
let overhead = [3.0 * re, 0.0, 0.0];
assert!(
chord_clears_sphere(user, overhead, re),
"an overhead relay must be visible"
);
}
#[test]
fn lmo_user_recovers_and_reports_honest_covariance() {
let scn = lmo_scenario();
let r = run_mars_pnt(&scn).expect("mars-pnt LMO runs");
assert!(
r.fom.converged_pos_rms_m < 100.0,
"converged RMS {:.2} m exceeds the hundred-metre LMO done-criterion",
r.fom.converged_pos_rms_m
);
assert!(
r.fom.converged_pos_rms_m < r.fom.initial_pos_error_m * 1.0e-2,
"filter did not materially improve on the {:.0} m initial guess: RMS {:.2} m",
r.fom.initial_pos_error_m,
r.fom.converged_pos_rms_m
);
assert!(
r.fom.covariance_pd_throughout,
"factored covariance lost positive-definiteness"
);
assert!(
r.fom.converged_pos_sigma_m > 0.0 && r.fom.converged_pos_sigma_m.is_finite(),
"formal 1σ {:.3} m must be finite-positive",
r.fom.converged_pos_sigma_m
);
assert!(
(r.fom.converged_pos_3sigma_m - 3.0 * r.fom.converged_pos_sigma_m).abs() < 1e-9,
"3σ must be 3× 1σ"
);
assert!(
r.fom.mean_relays_in_view > 0.0,
"the LMO user must see relays"
);
assert!(
r.fom_note
.contains("NOT aviation-certified protection levels"),
"the result must label the FoM honestly"
);
}
#[test]
fn transfer_and_surface_users_run() {
for user in [UserKind::Transfer, UserKind::Surface] {
let mut scn = lmo_scenario();
scn.user = user;
scn.duration_s = 7200.0;
let r = run_mars_pnt(&scn).expect("user runs");
assert_eq!(r.user, user.as_str());
assert!(
r.fom.epochs >= 2,
"{} produced too few epochs",
user.as_str()
);
assert!(
r.fom.converged_pos_sigma_m.is_finite(),
"{} formal σ must be finite",
user.as_str()
);
assert!(
r.fom.covariance_pd_throughout,
"{} covariance lost PD-ness",
user.as_str()
);
}
}
#[test]
fn to_svg_emits_a_self_contained_chart() {
let scn = lmo_scenario();
let r = run_mars_pnt(&scn).unwrap();
let svg = to_svg(&r);
assert!(svg.starts_with("<svg"));
assert!(svg.ends_with("</svg>"));
assert!(svg.contains("Mars-PNT"));
assert!(svg.contains("not a certified PL"));
}
#[test]
fn invalid_scenario_is_an_error() {
let mut scn = lmo_scenario();
scn.step_s = 0.0;
assert!(run_mars_pnt(&scn).is_err());
let mut scn2 = lmo_scenario();
scn2.duration_s = 30.0; assert!(run_mars_pnt(&scn2).is_err());
}
#[test]
fn run_summary_is_one_line_and_honest() {
let scn = lmo_scenario();
let r = run_mars_pnt(&scn).unwrap();
let s = summary(&r);
assert!(s.starts_with("mars-pnt "));
assert!(s.contains("covariance FoM, not a certified PL"));
assert!(!s.contains('\n'));
}
}