use crate::clock_specs::{x_clock_s, LunarClock};
use crate::lunar::{lunar_look_angle, selenographic_to_mcmf, Selenographic, R_MOON_M};
use crate::lunar_service::{LunarConstellation, LunarSat};
use crate::sbas::{sbas_protection_level, SbasErrorModel, SbasMode, SbasProtectionLevel, SbasSat};
use crate::timegeo::C_M_PER_S;
use rand::SeedableRng;
use rand_chacha::ChaCha8Rng;
use rand_distr::{Distribution, Normal};
use serde::{Deserialize, Serialize};
type Vec3 = [f64; 3];
fn sub(a: Vec3, b: Vec3) -> Vec3 {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
fn dot(a: Vec3, b: Vec3) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
fn norm(a: Vec3) -> f64 {
dot(a, a).sqrt()
}
fn los_unit(observer: Vec3, sat: Vec3) -> Vec3 {
let d = sub(sat, observer);
let n = norm(d);
if n == 0.0 {
[0.0, 0.0, 0.0]
} else {
[d[0] / n, d[1] / n, d[2] / n]
}
}
pub fn differential_corrections(
ref_mcmf: Vec3,
sats_mcmf: &[Vec3],
orbit_err: &[Vec3],
clock_err_m: &[f64],
) -> Vec<f64> {
sats_mcmf
.iter()
.enumerate()
.map(|(i, &s)| {
let u = los_unit(ref_mcmf, s);
-dot(orbit_err[i], u) + clock_err_m[i]
})
.collect()
}
pub fn corrected_user_range_errors(
user_mcmf: Vec3,
_ref_mcmf: Vec3,
sats_mcmf: &[Vec3],
orbit_err: &[Vec3],
clock_err_m: &[f64],
corrections: &[f64],
) -> Vec<f64> {
sats_mcmf
.iter()
.enumerate()
.map(|(i, &s)| {
let u = los_unit(user_mcmf, s);
let user_raw = -dot(orbit_err[i], u) + clock_err_m[i];
user_raw - corrections[i]
})
.collect()
}
pub fn raw_user_range_errors(
user_mcmf: Vec3,
sats_mcmf: &[Vec3],
orbit_err: &[Vec3],
clock_err_m: &[f64],
) -> Vec<f64> {
sats_mcmf
.iter()
.enumerate()
.map(|(i, &s)| {
let u = los_unit(user_mcmf, s);
-dot(orbit_err[i], u) + clock_err_m[i]
})
.collect()
}
fn position_error_from_range_errors(
user_mcmf: Vec3,
sats_mcmf: &[Vec3],
range_errors: &[f64],
) -> Option<f64> {
if sats_mcmf.len() < 4 {
return None;
}
let mut a = [[0.0_f64; 4]; 4];
let mut b = [0.0_f64; 4];
for (i, &s) in sats_mcmf.iter().enumerate() {
let u = los_unit(user_mcmf, s);
let g = [-u[0], -u[1], -u[2], 1.0];
for p in 0..4 {
b[p] += g[p] * range_errors[i];
for q in 0..4 {
a[p][q] += g[p] * g[q];
}
}
}
let a_inv = crate::orbit::invert4(a)?;
let dx: [f64; 4] = std::array::from_fn(|p| (0..4).map(|q| a_inv[p][q] * b[q]).sum());
if dx.iter().any(|v| !v.is_finite()) {
return None;
}
Some((dx[0] * dx[0] + dx[1] * dx[1] + dx[2] * dx[2]).sqrt())
}
pub fn user_position_error_m(
user_mcmf: Vec3,
ref_mcmf: Vec3,
sats_mcmf: &[Vec3],
orbit_err: &[Vec3],
clock_err_m: &[f64],
apply_corrections: bool,
) -> Option<f64> {
let range_errors = if apply_corrections {
let corr = differential_corrections(ref_mcmf, sats_mcmf, orbit_err, clock_err_m);
corrected_user_range_errors(
user_mcmf,
ref_mcmf,
sats_mcmf,
orbit_err,
clock_err_m,
&corr,
)
} else {
raw_user_range_errors(user_mcmf, sats_mcmf, orbit_err, clock_err_m)
};
position_error_from_range_errors(user_mcmf, sats_mcmf, &range_errors)
}
pub fn noisy_corrected_position_error_m(
user_mcmf: Vec3,
ref_mcmf: Vec3,
sats_mcmf: &[Vec3],
orbit_err: &[Vec3],
clock_err_m: &[f64],
noise_sigma_m: f64,
rng: &mut ChaCha8Rng,
) -> Option<f64> {
let corr = differential_corrections(ref_mcmf, sats_mcmf, orbit_err, clock_err_m);
let clean = corrected_user_range_errors(
user_mcmf,
ref_mcmf,
sats_mcmf,
orbit_err,
clock_err_m,
&corr,
);
let range_errors: Vec<f64> = if noise_sigma_m > 0.0 {
let sigma = if noise_sigma_m.is_finite() {
noise_sigma_m
} else {
f64::MIN_POSITIVE
};
let g = Normal::new(0.0, sigma)
.expect("sigma is finite and strictly positive, which Normal::new always accepts");
clean
.iter()
.map(|&e| e + g.sample(rng) - g.sample(rng))
.collect()
} else {
clean
};
position_error_from_range_errors(user_mcmf, sats_mcmf, &range_errors)
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
pub struct ProtLevel {
pub hpl_m: f64,
pub vpl_m: f64,
pub n_used: usize,
pub residual_sigma_m: f64,
}
fn sbas_sats_for_user(user_mcmf: Vec3, sats_mcmf: &[Vec3], residual_sigma_m: f64) -> Vec<SbasSat> {
sats_mcmf
.iter()
.map(|&s| {
let look = lunar_look_angle(user_mcmf, s);
SbasSat {
el_rad: look.el_deg.to_radians(),
az_rad: look.az_deg.to_radians(),
err: SbasErrorModel::uniform(residual_sigma_m),
}
})
.collect()
}
pub fn lunar_dgnss_protection_level(
user_mcmf: Vec3,
sats_mcmf: &[Vec3],
residual_sigma_m: f64,
_budget: crate::raim::IntegrityBudget,
) -> Option<ProtLevel> {
let sats = sbas_sats_for_user(user_mcmf, sats_mcmf, residual_sigma_m);
let pl: SbasProtectionLevel = sbas_protection_level(&sats, SbasMode::PrecisionApproach)?;
Some(ProtLevel {
hpl_m: pl.hpl_m,
vpl_m: pl.vpl_m.unwrap_or(0.0),
n_used: pl.n_used,
residual_sigma_m,
})
}
pub const AGEING_CLOCK: LunarClock = LunarClock::Rafs;
pub const MAX_QUANTIZATION_BITS: u32 = 64;
fn isotropic_projection() -> f64 {
1.0 / 3.0_f64.sqrt()
}
pub fn survey_biased_corrections(
ref_true_mcmf: Vec3,
ref_assumed_mcmf: Vec3,
sats_mcmf: &[Vec3],
orbit_err: &[Vec3],
clock_err_m: &[f64],
) -> Vec<f64> {
sats_mcmf
.iter()
.enumerate()
.map(|(i, &s)| {
let u = los_unit(ref_true_mcmf, s);
let survey = norm(sub(s, ref_true_mcmf)) - norm(sub(s, ref_assumed_mcmf));
-dot(orbit_err[i], u) + clock_err_m[i] + survey
})
.collect()
}
fn user_normal_inverse(user_mcmf: Vec3, sats_mcmf: &[Vec3]) -> Option<[[f64; 4]; 4]> {
if sats_mcmf.len() < 4 {
return None;
}
let mut a = [[0.0_f64; 4]; 4];
for &s in sats_mcmf {
let u = los_unit(user_mcmf, s);
let g = [-u[0], -u[1], -u[2], 1.0];
for p in 0..4 {
for q in 0..4 {
a[p][q] += g[p] * g[q];
}
}
}
crate::orbit::invert4(a)
}
pub fn user_pdop(user_mcmf: Vec3, sats_mcmf: &[Vec3]) -> Option<f64> {
let q = user_normal_inverse(user_mcmf, sats_mcmf)?;
let p2 = q[0][0] + q[1][1] + q[2][2];
if !p2.is_finite() || p2 < 0.0 {
return None;
}
Some(p2.sqrt())
}
pub fn position_sigma_from_range_sigmas(
user_mcmf: Vec3,
sats_mcmf: &[Vec3],
range_sigmas: &[f64],
) -> Option<f64> {
if sats_mcmf.len() != range_sigmas.len() {
return None;
}
let q = user_normal_inverse(user_mcmf, sats_mcmf)?;
let mut var = 0.0_f64;
for (i, &s) in sats_mcmf.iter().enumerate() {
let u = los_unit(user_mcmf, s);
let g = [-u[0], -u[1], -u[2], 1.0];
let w: [f64; 4] = std::array::from_fn(|p| (0..4).map(|k| q[p][k] * g[k]).sum());
var += range_sigmas[i] * range_sigmas[i] * (w[0] * w[0] + w[1] * w[1] + w[2] * w[2]);
}
if !var.is_finite() || var < 0.0 {
return None;
}
Some(var.sqrt())
}
pub fn survey_position_transfer(
user_mcmf: Vec3,
ref_mcmf: Vec3,
sats_mcmf: &[Vec3],
) -> Option<f64> {
let zero_orbit = vec![[0.0_f64; 3]; sats_mcmf.len()];
let zero_clock = vec![0.0_f64; sats_mcmf.len()];
let mut sum = 0.0_f64;
for axis in 0..3 {
let mut assumed = ref_mcmf;
assumed[axis] += 1.0;
let corr =
survey_biased_corrections(ref_mcmf, assumed, sats_mcmf, &zero_orbit, &zero_clock);
let range_errors: Vec<f64> = corr.iter().map(|&c| -c).collect();
let r = position_error_from_range_errors(user_mcmf, sats_mcmf, &range_errors)?;
sum += r * r;
}
Some(sum.sqrt())
}
pub fn correction_ageing_orbit_range_sigmas(
ref_mcmf: Vec3,
sats_now: &[Vec3],
sats_aged: &[Vec3],
orbit_err_m: f64,
) -> Vec<f64> {
sats_now
.iter()
.zip(sats_aged)
.map(|(&now, &aged)| {
let d = sub(los_unit(ref_mcmf, aged), los_unit(ref_mcmf, now));
orbit_err_m.abs() * norm(d) * isotropic_projection()
})
.collect()
}
pub fn correction_ageing_clock_range_sigma_m(latency_s: f64) -> f64 {
if latency_s <= 0.0 || !latency_s.is_finite() {
return 0.0;
}
C_M_PER_S * x_clock_s(&AGEING_CLOCK.powerlaw(), latency_s)
}
pub fn correction_quantization_step_m(full_scale_m: f64, bits: u32) -> f64 {
if bits == 0 || full_scale_m <= 0.0 || !full_scale_m.is_finite() {
return 0.0;
}
let bits = bits.min(MAX_QUANTIZATION_BITS);
2.0 * full_scale_m / 2.0_f64.powi(bits as i32)
}
pub fn uniform_quantization_sigma_m(step_m: f64) -> f64 {
step_m / 12.0_f64.sqrt()
}
#[derive(Clone, Copy, Debug)]
struct LinkFixedTerms {
pdop: f64,
survey_position_sigma_m: f64,
quantization_position_sigma_m: f64,
}
fn rms(v: &[f64]) -> f64 {
if v.is_empty() {
return 0.0;
}
(v.iter().map(|x| x * x).sum::<f64>() / v.len() as f64).sqrt()
}
#[derive(Clone, Debug, Serialize)]
pub struct CorrectionLinkBudget {
pub survey_sigma_m: f64,
pub survey_sigma_per_axis_m: f64,
pub survey_range_sigma_m: f64,
pub survey_transfer: f64,
pub survey_position_sigma_m: f64,
pub latency_s: f64,
pub ageing_law: &'static str,
pub ageing_clock_class: &'static str,
pub ageing_clock_time_error_s: f64,
pub ageing_orbit_rate_m_per_s: f64,
pub latency_orbit_range_sigma_m: f64,
pub latency_clock_range_sigma_m: f64,
pub latency_range_sigma_m: f64,
pub latency_orbit_position_sigma_m: f64,
pub latency_clock_position_sigma_m: f64,
pub latency_position_sigma_m: f64,
pub quantization_bits: u32,
pub quantization_full_scale_m: f64,
pub quantization_step_m: f64,
pub quantization_range_sigma_m: f64,
pub quantization_position_sigma_m: f64,
pub pdop: f64,
pub total_range_sigma_m: f64,
pub total_position_sigma_m: f64,
pub residual_sigma_m: f64,
pub total_with_residual_range_sigma_m: f64,
pub protection_level_with_link_m: f64,
pub vpl_with_link_m: f64,
pub latency_curve: Vec<(f64, f64)>,
pub note: &'static str,
}
fn top_level_units() -> serde_json::Value {
serde_json::json!({
"n_sats": {
"unit": "count", "provenance": "computed",
"note": "satellites in the illustrative LCNS-class constellation placed at t_s, from the n_sats input"
},
"baseline_km": {
"unit": "km", "provenance": "input",
"note": "separation of the user from the reference station; the spatial-decorrelation lever arm"
},
"user_error_uncorrected_m": {
"unit": "m", "provenance": "computed",
"note": "user 3-D position error from the broadcast ephemeris alone, no differential corrections applied"
},
"user_error_corrected_m": {
"unit": "m", "provenance": "computed",
"note": "user 3-D position error after the differential corrections, including the noise_m per-receiver noise draw"
},
"reduction_factor": {
"unit": "1", "provenance": "computed",
"note": "user_error_uncorrected_m / user_error_corrected_m; infinite when the corrected error underflows 1e-12 m"
},
"protection_level_m": {
"unit": "m", "provenance": "computed",
"note": "DO-229E HORIZONTAL protection level at the user, computed at the differential residual sigma"
},
"vpl_m": {
"unit": "m", "provenance": "computed",
"note": "DO-229E vertical protection level at the same residual sigma"
},
"residual_sigma_m": {
"unit": "m", "provenance": "input",
"note": "differential residual 1-sigma the protection levels scale with"
},
"noise_m": {
"unit": "m", "provenance": "input",
"note": "per-receiver measurement-noise 1-sigma added to the corrected error; 0 gives the exact noise-free residual"
},
"clock_err_ns": {
"unit": "ns", "provenance": "computed",
"note": "the injected per-satellite clock-error magnitude clock_err_m read in the timing domain, clock_err_m / c * 1e9"
},
"baseline_curve": {
"unit": "(km, m)", "provenance": "computed",
"note": "noise-free corrected-error sweep, each row the pair (baseline_km, corrected user 3-D position error in m)"
},
})
}
fn correction_link_units() -> serde_json::Value {
serde_json::json!({
"correction_link.survey_sigma_m": {
"unit": "m", "provenance": "input",
"note": "reference-station 3-D coordinate 1-sigma, isotropic"
},
"correction_link.survey_sigma_per_axis_m": {
"unit": "m", "provenance": "closed-form", "note": "survey_sigma_m / sqrt(3)"
},
"correction_link.survey_range_sigma_m": {
"unit": "m", "provenance": "closed-form",
"note": "per-satellite 1-sigma; CORRELATED across satellites"
},
"correction_link.survey_transfer": {
"unit": "1", "provenance": "computed",
"note": "3-D position error per metre of per-axis station error, RSS over the three MCMF axes"
},
"correction_link.survey_position_sigma_m": {
"unit": "m", "provenance": "computed", "note": "independent of baseline"
},
"correction_link.latency_s": { "unit": "s", "provenance": "input" },
"correction_link.ageing_law": { "unit": "text", "provenance": "modelled" },
"correction_link.ageing_clock_class": { "unit": "text", "provenance": "spec" },
"correction_link.ageing_clock_time_error_s": {
"unit": "s", "provenance": "spec",
"note": "sigma_y(tau)*tau for the AGEING_CLOCK power law in crate::clock_specs"
},
"correction_link.ageing_orbit_rate_m_per_s": {
"unit": "m/s", "provenance": "computed",
"note": "from the crate's own propagator; no assumed orbit-error rate"
},
"correction_link.latency_orbit_range_sigma_m": { "unit": "m", "provenance": "computed" },
"correction_link.latency_clock_range_sigma_m": { "unit": "m", "provenance": "spec" },
"correction_link.latency_range_sigma_m": { "unit": "m", "provenance": "computed" },
"correction_link.latency_orbit_position_sigma_m": { "unit": "m", "provenance": "computed" },
"correction_link.latency_clock_position_sigma_m": { "unit": "m", "provenance": "computed" },
"correction_link.latency_position_sigma_m": { "unit": "m", "provenance": "computed" },
"correction_link.quantization_bits": { "unit": "bit", "provenance": "input" },
"correction_link.quantization_full_scale_m": {
"unit": "m", "provenance": "closed-form",
"note": "half-range orbit_err_m + clock_err_m; an exact bound on |-e.u + c|"
},
"correction_link.quantization_step_m": {
"unit": "m", "provenance": "closed-form", "note": "2*full_scale / 2^bits"
},
"correction_link.quantization_range_sigma_m": {
"unit": "m", "provenance": "closed-form", "note": "step/sqrt(12)"
},
"correction_link.quantization_position_sigma_m": { "unit": "m", "provenance": "computed" },
"correction_link.pdop": { "unit": "1", "provenance": "computed" },
"correction_link.total_range_sigma_m": {
"unit": "m", "provenance": "computed",
"note": "RSS of the three range-domain terms; approximate, because the survey term is correlated across satellites"
},
"correction_link.total_position_sigma_m": {
"unit": "m", "provenance": "computed",
"note": "RSS of the three position-domain terms; the correlation-respecting total"
},
"correction_link.residual_sigma_m": { "unit": "m", "provenance": "input" },
"correction_link.total_with_residual_range_sigma_m": { "unit": "m", "provenance": "computed" },
"correction_link.protection_level_with_link_m": {
"unit": "m", "provenance": "computed",
"note": "DO-229E HPL at the augmented sigma; reported beside, never in place of, protection_level_m"
},
"correction_link.vpl_with_link_m": { "unit": "m", "provenance": "computed" },
"correction_link.latency_curve": {
"unit": "(s, m)", "provenance": "computed",
"note": "latency_s vs total_position_sigma_m"
},
"correction_link.note": { "unit": "text", "provenance": "modelled" }
})
}
fn report_units() -> serde_json::Value {
let mut out = serde_json::Map::new();
for table in [top_level_units(), correction_link_units()] {
if let serde_json::Value::Object(m) = table {
out.extend(m);
}
}
serde_json::Value::Object(out)
}
fn d_n_sats() -> usize {
8
}
fn d_sma_km() -> f64 {
R_MOON_M / 1000.0 + 8_000.0
}
fn d_ecc() -> f64 {
0.6
}
fn d_inc_deg() -> f64 {
57.7
}
fn d_argp_deg() -> f64 {
90.0
}
fn d_ref_lat_deg() -> f64 {
-89.0
}
fn d_ref_lon_deg() -> f64 {
0.0
}
fn d_baseline_km() -> f64 {
50.0
}
fn d_orbit_err_m() -> f64 {
100.0
}
fn d_clock_err_m() -> f64 {
30.0
}
fn d_noise_m() -> f64 {
0.0
}
fn d_seed() -> u64 {
42
}
fn d_t_s() -> f64 {
0.0
}
fn d_residual_sigma_m() -> f64 {
5.0
}
fn d_p_hmi() -> f64 {
1e-4
}
fn d_survey_sigma_m() -> f64 {
0.30
}
fn d_latency_s() -> f64 {
10.0
}
fn d_quantization_bits() -> u32 {
8
}
#[derive(Clone, Copy, Debug, Deserialize)]
pub struct LunarDpntScenario {
#[serde(default = "d_n_sats")]
pub n_sats: usize,
#[serde(default = "d_sma_km")]
pub sma_km: f64,
#[serde(default = "d_ecc")]
pub eccentricity: f64,
#[serde(default = "d_inc_deg")]
pub inc_deg: f64,
#[serde(default = "d_argp_deg")]
pub argp_deg: f64,
#[serde(default = "d_ref_lat_deg")]
pub ref_lat_deg: f64,
#[serde(default = "d_ref_lon_deg")]
pub ref_lon_deg: f64,
#[serde(default = "d_baseline_km")]
pub baseline_km: f64,
#[serde(default = "d_orbit_err_m")]
pub orbit_err_m: f64,
#[serde(default = "d_clock_err_m")]
pub clock_err_m: f64,
#[serde(default = "d_noise_m")]
pub noise_m: f64,
#[serde(default = "d_seed")]
pub seed: u64,
#[serde(default = "d_t_s")]
pub t_s: f64,
#[serde(default = "d_residual_sigma_m")]
pub residual_sigma_m: f64,
#[serde(default = "d_p_hmi")]
pub p_hmi: f64,
#[serde(default = "d_survey_sigma_m")]
pub survey_sigma_m: f64,
#[serde(default = "d_latency_s")]
pub latency_s: f64,
#[serde(default = "d_quantization_bits")]
pub quantization_bits: u32,
}
impl Default for LunarDpntScenario {
fn default() -> Self {
Self {
n_sats: d_n_sats(),
sma_km: d_sma_km(),
eccentricity: d_ecc(),
inc_deg: d_inc_deg(),
argp_deg: d_argp_deg(),
ref_lat_deg: d_ref_lat_deg(),
ref_lon_deg: d_ref_lon_deg(),
baseline_km: d_baseline_km(),
orbit_err_m: d_orbit_err_m(),
clock_err_m: d_clock_err_m(),
noise_m: d_noise_m(),
seed: d_seed(),
t_s: d_t_s(),
residual_sigma_m: d_residual_sigma_m(),
p_hmi: d_p_hmi(),
survey_sigma_m: d_survey_sigma_m(),
latency_s: d_latency_s(),
quantization_bits: d_quantization_bits(),
}
}
}
#[derive(Clone, Debug, Serialize)]
pub struct LunarDpntReport {
pub n_sats: usize,
pub baseline_km: f64,
pub user_error_uncorrected_m: f64,
pub user_error_corrected_m: f64,
pub reduction_factor: f64,
pub protection_level_m: f64,
pub vpl_m: f64,
pub residual_sigma_m: f64,
pub noise_m: f64,
pub clock_err_ns: f64,
pub baseline_curve: Vec<(f64, f64)>,
pub note: &'static str,
pub correction_link: CorrectionLinkBudget,
pub units: serde_json::Value,
}
impl LunarDpntScenario {
fn constellation(&self) -> LunarConstellation {
let sma_m = self.sma_km * 1000.0;
let n = self.n_sats.clamp(1, 24);
let sats = (0..n)
.map(|k| LunarSat {
sma_m,
eccentricity: self.eccentricity,
inc_deg: self.inc_deg,
raan_deg: 360.0 * (k as f64) / (n as f64),
argp_deg: self.argp_deg,
mean_anom_deg: 360.0 * (k as f64) / (n as f64),
})
.collect();
LunarConstellation::new(sats)
}
fn ref_mcmf(&self) -> Vec3 {
selenographic_to_mcmf(Selenographic {
lat_rad: self.ref_lat_deg.to_radians(),
lon_rad: self.ref_lon_deg.to_radians(),
alt_m: 0.0,
})
}
fn user_mcmf(&self, baseline_km: f64) -> Vec3 {
let d_ang = (baseline_km * 1000.0) / R_MOON_M;
selenographic_to_mcmf(Selenographic {
lat_rad: self.ref_lat_deg.to_radians(),
lon_rad: self.ref_lon_deg.to_radians() + d_ang,
alt_m: 0.0,
})
}
fn inject_errors(&self, n: usize) -> (Vec<Vec3>, Vec<f64>) {
let mut rng = ChaCha8Rng::seed_from_u64(self.seed);
let g = Normal::new(0.0, 1.0)
.expect("std_dev is the finite literal 1.0, which Normal::new always accepts");
let mut orbit_err = Vec::with_capacity(n);
let mut clock_err = Vec::with_capacity(n);
for _ in 0..n {
let v = [g.sample(&mut rng), g.sample(&mut rng), g.sample(&mut rng)];
let vn = norm(v).max(1e-12);
orbit_err.push([
v[0] / vn * self.orbit_err_m,
v[1] / vn * self.orbit_err_m,
v[2] / vn * self.orbit_err_m,
]);
let sign = if g.sample(&mut rng) >= 0.0 { 1.0 } else { -1.0 };
clock_err.push(sign * self.clock_err_m);
}
(orbit_err, clock_err)
}
fn link_total_position_sigma_m(
&self,
user_mcmf: Vec3,
ref_mcmf: Vec3,
sats_now: &[Vec3],
constellation: &LunarConstellation,
latency_s: f64,
fixed: LinkFixedTerms,
) -> f64 {
let sats_aged = constellation.positions_mcmf(self.t_s + latency_s);
let orbit_sig =
correction_ageing_orbit_range_sigmas(ref_mcmf, sats_now, &sats_aged, self.orbit_err_m);
let orbit_pos =
position_sigma_from_range_sigmas(user_mcmf, sats_now, &orbit_sig).unwrap_or(0.0);
let clock_pos = correction_ageing_clock_range_sigma_m(latency_s) * fixed.pdop;
(fixed.survey_position_sigma_m * fixed.survey_position_sigma_m
+ orbit_pos * orbit_pos
+ clock_pos * clock_pos
+ fixed.quantization_position_sigma_m * fixed.quantization_position_sigma_m)
.sqrt()
}
fn correction_link_budget(
&self,
user_mcmf: Vec3,
ref_mcmf: Vec3,
sats_now: &[Vec3],
constellation: &LunarConstellation,
) -> CorrectionLinkBudget {
let pdop = user_pdop(user_mcmf, sats_now).unwrap_or(0.0);
let survey_sigma_m = self.survey_sigma_m.max(0.0);
let survey_sigma_per_axis_m = survey_sigma_m / 3.0_f64.sqrt();
let survey_transfer =
survey_position_transfer(user_mcmf, ref_mcmf, sats_now).unwrap_or(0.0);
let survey_range_sigma_m = survey_sigma_per_axis_m;
let survey_position_sigma_m = survey_sigma_per_axis_m * survey_transfer;
let latency_s = if self.latency_s.is_finite() {
self.latency_s.max(0.0)
} else {
0.0
};
let sats_aged = constellation.positions_mcmf(self.t_s + latency_s);
let orbit_sigmas =
correction_ageing_orbit_range_sigmas(ref_mcmf, sats_now, &sats_aged, self.orbit_err_m);
let latency_orbit_range_sigma_m = rms(&orbit_sigmas);
let latency_clock_range_sigma_m = correction_ageing_clock_range_sigma_m(latency_s);
let latency_range_sigma_m = (latency_orbit_range_sigma_m * latency_orbit_range_sigma_m
+ latency_clock_range_sigma_m * latency_clock_range_sigma_m)
.sqrt();
let latency_orbit_position_sigma_m =
position_sigma_from_range_sigmas(user_mcmf, sats_now, &orbit_sigmas).unwrap_or(0.0);
let latency_clock_position_sigma_m = latency_clock_range_sigma_m * pdop;
let latency_position_sigma_m = (latency_orbit_position_sigma_m
* latency_orbit_position_sigma_m
+ latency_clock_position_sigma_m * latency_clock_position_sigma_m)
.sqrt();
let ageing_orbit_rate_m_per_s = if latency_s > 0.0 {
latency_orbit_range_sigma_m / latency_s
} else {
0.0
};
let quantization_full_scale_m = self.orbit_err_m.abs() + self.clock_err_m.abs();
let quantization_step_m =
correction_quantization_step_m(quantization_full_scale_m, self.quantization_bits);
let quantization_range_sigma_m = uniform_quantization_sigma_m(quantization_step_m);
let quantization_position_sigma_m = quantization_range_sigma_m * pdop;
let total_range_sigma_m = (survey_range_sigma_m * survey_range_sigma_m
+ latency_range_sigma_m * latency_range_sigma_m
+ quantization_range_sigma_m * quantization_range_sigma_m)
.sqrt();
let total_position_sigma_m = (survey_position_sigma_m * survey_position_sigma_m
+ latency_position_sigma_m * latency_position_sigma_m
+ quantization_position_sigma_m * quantization_position_sigma_m)
.sqrt();
let total_with_residual_range_sigma_m = (self.residual_sigma_m * self.residual_sigma_m
+ total_range_sigma_m * total_range_sigma_m)
.sqrt();
let budget = crate::raim::IntegrityBudget {
p_hmi_vert: self.p_hmi,
p_hmi_horz: self.p_hmi,
p_fa: 1e-5,
};
let (protection_level_with_link_m, vpl_with_link_m) = match lunar_dgnss_protection_level(
user_mcmf,
sats_now,
total_with_residual_range_sigma_m,
budget,
) {
Some(pl) => (pl.hpl_m, pl.vpl_m),
None => (0.0, 0.0),
};
let fixed = LinkFixedTerms {
pdop,
survey_position_sigma_m,
quantization_position_sigma_m,
};
let latency_curve = [0.0_f64, 1.0, 5.0, 10.0, 30.0, 60.0, 300.0]
.iter()
.map(|&t| {
(
t,
self.link_total_position_sigma_m(
user_mcmf,
ref_mcmf,
sats_now,
constellation,
t,
fixed,
),
)
})
.collect();
CorrectionLinkBudget {
survey_sigma_m,
survey_sigma_per_axis_m,
survey_range_sigma_m,
survey_transfer,
survey_position_sigma_m,
latency_s,
ageing_law: "correction ageing = orbit + clock. Orbit: the frozen per-satellite \
orbit-error vector re-projected onto the line of sight the crate's own \
Keplerian propagator puts the satellite on at t + latency, so no \
orbit-error rate is assumed; growth of the ephemeris error VECTOR \
itself is NOT modelled (that needs a real fit-interval prediction \
model). Clock: c.sigma_y(tau).tau for the AGEING_CLOCK power law in \
crate::clock_specs, calibrated there to a published one-day spec row.",
ageing_clock_class: AGEING_CLOCK.name(),
ageing_clock_time_error_s: if latency_s > 0.0 {
x_clock_s(&AGEING_CLOCK.powerlaw(), latency_s)
} else {
0.0
},
ageing_orbit_rate_m_per_s,
latency_orbit_range_sigma_m,
latency_clock_range_sigma_m,
latency_range_sigma_m,
latency_orbit_position_sigma_m,
latency_clock_position_sigma_m,
latency_position_sigma_m,
quantization_bits: self.quantization_bits,
quantization_full_scale_m,
quantization_step_m,
quantization_range_sigma_m,
quantization_position_sigma_m,
pdop,
total_range_sigma_m,
total_position_sigma_m,
residual_sigma_m: self.residual_sigma_m,
total_with_residual_range_sigma_m,
protection_level_with_link_m,
vpl_with_link_m,
latency_curve,
note: "MODELLED. The survey default is the crate's own lunar frame-realisation \
allocation (itself Modelled, not a measured station); the latency and bit \
count are ILLUSTRATIVE inputs, which is why the latency curve is reported \
beside the single figure. The range-domain total RSSs a survey term that is \
CORRELATED across satellites with two that are not, so the position-domain \
total is the one that respects the correlation structure. No real-data \
validation; no TRL/heritage/agency endorsement.",
}
}
pub fn run(&self) -> LunarDpntReport {
let constellation = self.constellation();
let sats = constellation.positions_mcmf(self.t_s);
let n = sats.len();
let ref_mcmf = self.ref_mcmf();
let (orbit_err, clock_err) = self.inject_errors(n);
let user = self.user_mcmf(self.baseline_km);
let uncorr = user_position_error_m(user, ref_mcmf, &sats, &orbit_err, &clock_err, false)
.unwrap_or(0.0);
let mut noise_rng = ChaCha8Rng::seed_from_u64(self.seed ^ 0x9E37_79B9_7F4A_7C15);
let corr = noisy_corrected_position_error_m(
user,
ref_mcmf,
&sats,
&orbit_err,
&clock_err,
self.noise_m,
&mut noise_rng,
)
.unwrap_or(0.0);
let reduction = if corr > 1e-12 {
uncorr / corr
} else {
f64::INFINITY
};
let budget = crate::raim::IntegrityBudget {
p_hmi_vert: self.p_hmi,
p_hmi_horz: self.p_hmi,
p_fa: 1e-5,
};
let (pl_h, pl_v) =
match lunar_dgnss_protection_level(user, &sats, self.residual_sigma_m, budget) {
Some(pl) => (pl.hpl_m, pl.vpl_m),
None => (0.0, 0.0),
};
let curve_baselines = [0.0_f64, 1.0, 10.0, 50.0, 100.0, 250.0, 500.0];
let baseline_curve = curve_baselines
.iter()
.map(|&b| {
let u = self.user_mcmf(b);
let e = user_position_error_m(u, ref_mcmf, &sats, &orbit_err, &clock_err, true)
.unwrap_or(0.0);
(b, e)
})
.collect();
let correction_link = self.correction_link_budget(user, ref_mcmf, &sats, &constellation);
LunarDpntReport {
n_sats: n,
baseline_km: self.baseline_km,
user_error_uncorrected_m: uncorr,
user_error_corrected_m: corr,
reduction_factor: reduction,
protection_level_m: pl_h,
vpl_m: pl_v,
residual_sigma_m: self.residual_sigma_m,
noise_m: self.noise_m,
clock_err_ns: self.clock_err_m / C_M_PER_S * 1.0e9,
baseline_curve,
note: "Illustrative, public-source LCNS-class constellation; NovaMoon referenced only \
as a system class (not affiliated with ESA). Common-mode cancellation is an \
exact identity; the spatial-decorrelation residual is a first-order geometric \
model. Protection level REUSES the DO-229E SBAS machinery (crate::sbas). \
MODELLED; not real-data validated; no TRL/heritage/agency endorsement.",
correction_link,
units: report_units(),
}
}
}
pub fn lunar_dpnt_svg(r: &LunarDpntReport) -> String {
let (w, h) = (820.0_f64, 360.0_f64);
let (ml, mr, mt, mb) = (70.0_f64, 20.0_f64, 40.0_f64, 50.0_f64);
let (pw, ph) = (w - ml - mr, h - mt - mb);
let xs: Vec<f64> = r.baseline_curve.iter().map(|&(b, _)| b).collect();
let ys: Vec<f64> = r.baseline_curve.iter().map(|&(_, e)| e).collect();
let x_max = xs.iter().cloned().fold(1.0_f64, f64::max);
let y_max = ys
.iter()
.cloned()
.fold(0.0_f64, f64::max)
.max(r.user_error_uncorrected_m)
.max(1e-6);
let xof = |x: f64| ml + (x / x_max) * pw;
let yof = |y: f64| mt + ph - (y / y_max) * 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=\"18\" font-size=\"15\" font-weight=\"bold\">Lunar differential PNT — {} sats: corrected error vs baseline (× {:.0} reduction at {:.0} km)</text>",
r.n_sats, r.reduction_factor, r.baseline_km
));
svg.push_str(&format!(
"<text x=\"{ml:.0}\" y=\"34\" font-size=\"11\">uncorrected {:.1} m | corrected {:.2} m | HPL {:.1} m (σ_resid {:.1} m) | MODELLED</text>",
r.user_error_uncorrected_m, r.user_error_corrected_m, r.protection_level_m, r.residual_sigma_m
));
svg.push_str(&format!(
"<line x1=\"{:.1}\" y1=\"{:.1}\" x2=\"{:.1}\" y2=\"{:.1}\" stroke=\"#e5645a\" stroke-dasharray=\"5 3\"/>",
ml,
yof(r.user_error_uncorrected_m),
ml + pw,
yof(r.user_error_uncorrected_m)
));
svg.push_str(&format!(
"<text x=\"{:.1}\" y=\"{:.1}\" font-size=\"10\" fill=\"#e5645a\">uncorrected (standalone)</text>",
ml + pw - 150.0,
yof(r.user_error_uncorrected_m) - 4.0
));
let mut path = String::new();
for (k, (&x, &y)) in xs.iter().zip(&ys).enumerate() {
path.push_str(&format!(
"{}{:.1},{:.1}",
if k == 0 { "M" } else { " L" },
xof(x),
yof(y)
));
}
svg.push_str(&format!(
"<path d=\"{path}\" fill=\"none\" stroke=\"#e0bd84\" stroke-width=\"2\"/>"
));
for (&x, &y) in xs.iter().zip(&ys) {
svg.push_str(&format!(
"<circle cx=\"{:.1}\" cy=\"{:.1}\" r=\"3\" fill=\"#e0bd84\"/>",
xof(x),
yof(y)
));
}
let axis_y = mt + ph;
svg.push_str(&format!(
"<line x1=\"{ml:.0}\" y1=\"{mt:.0}\" x2=\"{ml:.0}\" y2=\"{axis_y:.0}\" stroke=\"#342c21\"/>"
));
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(&format!(
"<text x=\"{:.0}\" y=\"{:.0}\" font-size=\"11\" text-anchor=\"middle\">baseline (km)</text>",
ml + pw / 2.0,
h - 14.0
));
svg.push_str(&format!(
"<text x=\"{:.0}\" y=\"{:.0}\" font-size=\"11\">err (m)</text>",
6.0,
mt + 4.0
));
svg.push_str("</svg>");
svg
}
#[cfg(test)]
mod tests {
use super::*;
fn budget() -> crate::raim::IntegrityBudget {
crate::raim::IntegrityBudget {
p_hmi_vert: 1e-4,
p_hmi_horz: 1e-4,
p_fa: 1e-5,
}
}
fn sky(user: Vec3) -> Vec<Vec3> {
let azels = [
(10.0_f64, 70.0_f64),
(70.0, 35.0),
(140.0, 55.0),
(210.0, 28.0),
(280.0, 60.0),
(330.0, 40.0),
];
crate::lunar::lunar_sky_geometry(user, 8.0e6, &azels)
}
#[test]
fn corrections_cancel_common_mode_at_zero_baseline() {
let ref_mcmf = selenographic_to_mcmf(Selenographic {
lat_rad: (-89.0_f64).to_radians(),
lon_rad: 0.0,
alt_m: 0.0,
});
let sats = sky(ref_mcmf);
let n = sats.len();
let orbit_err: Vec<Vec3> = (0..n)
.map(|i| {
let s = (i as f64 + 1.0) * 17.0;
[40.0 + s, -25.0 + s, 60.0 - s]
})
.collect();
let clock_err: Vec<f64> = (0..n)
.map(|i| if i % 2 == 0 { 30.0 } else { -30.0 })
.collect();
let user = ref_mcmf;
let corr =
user_position_error_m(user, ref_mcmf, &sats, &orbit_err, &clock_err, true).unwrap();
let uncorr =
user_position_error_m(user, ref_mcmf, &sats, &orbit_err, &clock_err, false).unwrap();
let corrections = differential_corrections(ref_mcmf, &sats, &orbit_err, &clock_err);
let corr_range = corrected_user_range_errors(
user,
ref_mcmf,
&sats,
&orbit_err,
&clock_err,
&corrections,
);
for (i, &e) in corr_range.iter().enumerate() {
assert!(e.abs() < 1e-6, "sat {i} corrected range error {e} not ~0");
}
assert!(
corr < 1e-6,
"corrected position error must be ~0 at zero baseline, got {corr}"
);
assert!(
uncorr > 1.0,
"uncorrected error must be substantial (got {uncorr})"
);
assert!(
corr < uncorr,
"corrected {corr} must be ≪ uncorrected {uncorr}"
);
}
#[test]
fn clock_error_cancels_exactly_at_any_baseline() {
let scn = LunarDpntScenario {
orbit_err_m: 0.0, clock_err_m: 75.0,
noise_m: 0.0,
..Default::default()
};
let constellation = scn.constellation();
let sats = constellation.positions_mcmf(0.0);
let n = sats.len();
let ref_mcmf = scn.ref_mcmf();
let (orbit_err, clock_err) = scn.inject_errors(n);
let corrections = differential_corrections(ref_mcmf, &sats, &orbit_err, &clock_err);
for &baseline in &[0.0, 50.0, 200.0, 500.0] {
let user = scn.user_mcmf(baseline);
let corr_range = corrected_user_range_errors(
user,
ref_mcmf,
&sats,
&orbit_err,
&clock_err,
&corrections,
);
for (i, &e) in corr_range.iter().enumerate() {
assert!(
e.abs() < 1e-6,
"baseline {baseline} km, sat {i}: clock-only corrected error {e} must cancel"
);
}
}
}
#[test]
fn residual_grows_with_baseline() {
let scn = LunarDpntScenario {
orbit_err_m: 150.0,
clock_err_m: 40.0,
noise_m: 0.0,
..Default::default()
};
let constellation = scn.constellation();
let sats = constellation.positions_mcmf(0.0);
let n = sats.len();
let ref_mcmf = scn.ref_mcmf();
let (orbit_err, clock_err) = scn.inject_errors(n);
let baselines = [1.0_f64, 10.0, 50.0, 100.0, 250.0, 500.0];
let errs: Vec<f64> = baselines
.iter()
.map(|&b| {
let u = scn.user_mcmf(b);
user_position_error_m(u, ref_mcmf, &sats, &orbit_err, &clock_err, true).unwrap()
})
.collect();
for w in errs.windows(2) {
assert!(
w[1] >= w[0] - 1e-9,
"corrected error must grow with baseline: {:?}",
errs
);
}
assert!(
*errs.last().unwrap() > errs[0] + 1e-6,
"far-baseline residual must exceed near-baseline: {:?}",
errs
);
let u50 = scn.user_mcmf(50.0);
let uncorr =
user_position_error_m(u50, ref_mcmf, &sats, &orbit_err, &clock_err, false).unwrap();
let corr50 = errs[2]; assert!(
corr50 < 0.5 * uncorr,
"at 50 km corrected {corr50} must be ≪ uncorrected {uncorr}"
);
}
#[test]
fn differential_beats_standalone() {
let scn = LunarDpntScenario::default();
let r = scn.run();
assert!(
r.user_error_corrected_m < r.user_error_uncorrected_m,
"corrected {} must beat uncorrected {}",
r.user_error_corrected_m,
r.user_error_uncorrected_m
);
assert!(
r.reduction_factor > 2.0,
"differential should reduce error by a clear margin (>2×), got {}×",
r.reduction_factor
);
}
#[test]
fn measurement_noise_raises_the_corrected_floor() {
let quiet = LunarDpntScenario {
noise_m: 0.0,
..Default::default()
}
.run();
let noisy = LunarDpntScenario {
noise_m: 2.0,
..Default::default()
}
.run();
assert!(
noisy.user_error_corrected_m > quiet.user_error_corrected_m,
"noise must raise the corrected floor: quiet {} noisy {}",
quiet.user_error_corrected_m,
noisy.user_error_corrected_m
);
assert!(noisy.user_error_corrected_m < noisy.user_error_uncorrected_m);
let expect_ns = LunarDpntScenario::default().clock_err_m / C_M_PER_S * 1.0e9;
assert!(
(quiet.clock_err_ns - expect_ns).abs() < 1e-9 && quiet.clock_err_ns > 0.0,
"clock_err_ns must equal clock_err_m / c (got {})",
quiet.clock_err_ns
);
}
#[test]
fn protection_level_reuses_sbas_machinery() {
let ref_mcmf = selenographic_to_mcmf(Selenographic {
lat_rad: (-89.0_f64).to_radians(),
lon_rad: 0.0,
alt_m: 0.0,
});
let sats = sky(ref_mcmf);
let sigma = 5.0;
let pl = lunar_dgnss_protection_level(ref_mcmf, &sats, sigma, budget()).expect("PL");
let sbas_sats: Vec<SbasSat> = sats
.iter()
.map(|&s| {
let look = lunar_look_angle(ref_mcmf, s);
SbasSat {
el_rad: look.el_deg.to_radians(),
az_rad: look.az_deg.to_radians(),
err: SbasErrorModel::uniform(sigma),
}
})
.collect();
let direct = sbas_protection_level(&sbas_sats, SbasMode::PrecisionApproach).unwrap();
assert!(
(pl.hpl_m - direct.hpl_m).abs() < 1e-12,
"HPL must match SBAS"
);
assert!(
(pl.vpl_m - direct.vpl_m.unwrap()).abs() < 1e-12,
"VPL must match SBAS"
);
assert_eq!(pl.n_used, direct.n_used);
let pl_small = lunar_dgnss_protection_level(ref_mcmf, &sats, 1.0, budget()).unwrap();
assert!(pl_small.hpl_m < pl.hpl_m, "smaller σ ⇒ smaller HPL");
}
#[test]
fn under_determined_geometry_returns_none() {
let ref_mcmf = selenographic_to_mcmf(Selenographic {
lat_rad: (-89.0_f64).to_radians(),
lon_rad: 0.0,
alt_m: 0.0,
});
let sats = crate::lunar::lunar_sky_geometry(ref_mcmf, 8.0e6, &[(0.0, 70.0), (90.0, 50.0)]);
assert!(lunar_dgnss_protection_level(ref_mcmf, &sats, 5.0, budget()).is_none());
let orbit_err = vec![[10.0, 0.0, 0.0]; sats.len()];
let clock_err = vec![5.0; sats.len()];
assert!(
user_position_error_m(ref_mcmf, ref_mcmf, &sats, &orbit_err, &clock_err, true)
.is_none()
);
}
#[test]
fn scenario_is_deterministic() {
let a = LunarDpntScenario::default().run();
let b = LunarDpntScenario::default().run();
assert_eq!(
serde_json::to_string(&a).unwrap(),
serde_json::to_string(&b).unwrap()
);
let c = LunarDpntScenario {
seed: 7,
..Default::default()
}
.run();
assert!(
(a.user_error_uncorrected_m - c.user_error_uncorrected_m).abs() > 1e-9
|| (a.reduction_factor - c.reduction_factor).abs() > 1e-9,
"different seed should change the realisation"
);
}
#[test]
fn satellite_count_is_honoured_up_to_the_builder_limit() {
let at = |n: usize| {
LunarDpntScenario {
n_sats: n,
..LunarDpntScenario::default()
}
.run()
};
let (a, b, c) = (at(12), at(16), at(24));
assert_eq!((a.n_sats, b.n_sats, c.n_sats), (12, 16, 24));
assert!(
b.protection_level_m < a.protection_level_m,
"16 satellites must improve on 12, got {} vs {}",
b.protection_level_m,
a.protection_level_m
);
assert!(
c.protection_level_m < b.protection_level_m,
"24 satellites must improve on 16, got {} vs {}",
c.protection_level_m,
b.protection_level_m
);
assert_eq!(at(64).n_sats, 24);
}
#[test]
fn scenario_report_self_consistent() {
let scn = LunarDpntScenario::default();
let r = scn.run();
assert_eq!(r.n_sats, scn.n_sats.clamp(1, 24));
assert!(r.user_error_uncorrected_m > 0.0);
assert!(r.user_error_corrected_m >= 0.0);
assert!(r.reduction_factor.is_finite() && r.reduction_factor > 1.0);
assert!(r.protection_level_m > 0.0 && r.vpl_m > 0.0);
assert!(
r.baseline_curve.first().unwrap().1 < 1e-3,
"curve starts ~0"
);
assert!(
r.baseline_curve.last().unwrap().1 >= r.baseline_curve.first().unwrap().1,
"curve grows"
);
let svg = lunar_dpnt_svg(&r);
assert!(svg.starts_with("<svg") && svg.ends_with("</svg>"));
let json = serde_json::to_string(&r).unwrap();
assert!(json.contains("not affiliated with ESA"));
assert!(json.contains("MODELLED"));
}
fn pre_link_budget_default_report() -> serde_json::Value {
serde_json::json!({
"n_sats": 8,
"baseline_km": 50.0_f64,
"user_error_uncorrected_m": 24.460_652_495_293_342_f64,
"user_error_corrected_m": 0.007_676_508_483_045_71_f64,
"reduction_factor": 3_186.429_422_870_696_f64,
"protection_level_m": 23.133_803_613_253_427_f64,
"vpl_m": 17.227_221_314_344_384_f64,
"residual_sigma_m": 5.0_f64,
"noise_m": 0.0_f64,
"clock_err_ns": 100.069_228_559_445_6_f64,
"baseline_curve": [
[0.0_f64, 0.0_f64],
[1.0_f64, 0.000_153_931_654_819_086_65_f64],
[10.0_f64, 0.001_538_599_186_168_784_5_f64],
[50.0_f64, 0.007_676_508_483_045_71_f64],
[100.0_f64, 0.015_309_308_830_229_898_f64],
[250.0_f64, 0.037_904_748_016_440_955_f64],
[500.0_f64, 0.074_320_702_306_343_22_f64]
],
"note": "Illustrative, public-source LCNS-class constellation; NovaMoon referenced only as a system class (not affiliated with ESA). Common-mode cancellation is an exact identity; the spatial-decorrelation residual is a first-order geometric model. Protection level REUSES the DO-229E SBAS machinery (crate::sbas). MODELLED; not real-data validated; no TRL/heritage/agency endorsement."
})
}
#[test]
fn the_link_budget_is_purely_additive_with_every_new_input_at_its_default() {
let mut v = serde_json::to_value(LunarDpntScenario::default().run()).unwrap();
let obj = v.as_object_mut().expect("the report is a JSON object");
let before = pre_link_budget_default_report();
let before_keys: std::collections::BTreeSet<String> =
before.as_object().unwrap().keys().cloned().collect();
let after_keys: std::collections::BTreeSet<String> = obj.keys().cloned().collect();
let added: Vec<&String> = after_keys.difference(&before_keys).collect();
let removed: Vec<&String> = before_keys.difference(&after_keys).collect();
assert!(removed.is_empty(), "the budget REMOVED fields: {removed:?}");
assert_eq!(
added,
vec![&"correction_link".to_string(), &"units".to_string()],
"unexpected new top-level fields"
);
obj.remove("correction_link");
obj.remove("units");
let moved = crate::test_support::json_diff(&v, &before);
assert!(
moved.is_empty(),
"a pre-existing value moved; the budget must be purely additive:\n{}",
moved.join("\n")
);
if crate::test_support::ON_BASELINE_HOST {
assert_eq!(
v, before,
"a pre-existing value moved in the last bit; the budget must be purely additive"
);
}
let r = LunarDpntScenario::default().run();
for (name, got, want) in [
(
"user_error_uncorrected_m",
r.user_error_uncorrected_m,
24.460_652_495_293_342_f64,
),
(
"user_error_corrected_m",
r.user_error_corrected_m,
0.007_676_508_483_045_71_f64,
),
(
"reduction_factor",
r.reduction_factor,
3_186.429_422_870_696_f64,
),
(
"protection_level_m",
r.protection_level_m,
23.133_803_613_253_427_f64,
),
("vpl_m", r.vpl_m, 17.227_221_314_344_384_f64),
("clock_err_ns", r.clock_err_ns, 100.069_228_559_445_6_f64),
] {
assert!(
crate::test_support::close(got, want),
"{name} moved: {got} vs the pre-budget {want}"
);
if crate::test_support::ON_BASELINE_HOST {
assert_eq!(
got.to_bits(),
want.to_bits(),
"{name} moved in the last bit: {got} vs the pre-budget {want}"
);
}
}
}
#[test]
fn survey_error_does_not_decorrelate_with_baseline_but_orbit_error_does() {
let scn = LunarDpntScenario::default();
let constellation = scn.constellation();
let sats = constellation.positions_mcmf(scn.t_s);
let ref_mcmf = scn.ref_mcmf();
let (orbit_err, clock_err) = scn.inject_errors(sats.len());
let baselines = [0.0_f64, 1.0, 10.0, 50.0, 250.0, 500.0];
let survey: Vec<f64> = baselines
.iter()
.map(|&b| {
let u = scn.user_mcmf(b);
scn.correction_link_budget(u, ref_mcmf, &sats, &constellation)
.survey_position_sigma_m
})
.collect();
let orbit: Vec<f64> = baselines
.iter()
.map(|&b| {
let u = scn.user_mcmf(b);
user_position_error_m(u, ref_mcmf, &sats, &orbit_err, &clock_err, true).unwrap()
})
.collect();
assert!(
survey[0] > 0.25,
"survey term must survive a zero baseline, got {}",
survey[0]
);
assert!(
orbit[0] < 1e-9,
"the orbit term must cancel at zero baseline, got {}",
orbit[0]
);
let lo = survey.iter().cloned().fold(f64::INFINITY, f64::min);
let hi = survey.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
assert!(
(hi - lo) / lo < 1e-2,
"survey term must not decorrelate with baseline: {survey:?}"
);
assert!(
orbit[5] > 100.0 * orbit[1],
"the orbit term must decorrelate with baseline: {orbit:?}"
);
assert!(
survey[5] > orbit[5],
"survey {} vs orbit {} at 500 km",
survey[5],
orbit[5]
);
}
#[test]
fn a_one_metre_station_survey_error_transfers_one_for_one_into_the_user() {
let scn = LunarDpntScenario::default();
let sats = scn.constellation().positions_mcmf(scn.t_s);
let ref_mcmf = scn.ref_mcmf();
let user = scn.user_mcmf(scn.baseline_km);
let transfer = survey_position_transfer(user, ref_mcmf, &sats).unwrap();
assert!(
(transfer - 3.0_f64.sqrt()).abs() < 1e-3,
"three-axis transfer must be ≈ √3 = {:.6}, got {transfer}",
3.0_f64.sqrt()
);
let pdop = user_pdop(user, &sats).unwrap();
assert!(
(transfer - 3.0_f64.sqrt() * pdop).abs() > 1e-3,
"transfer must not equal √3·PDOP (pdop {pdop}, transfer {transfer})"
);
}
#[test]
fn zero_latency_reproduces_the_unaged_residual_exactly() {
let aged = LunarDpntScenario::default().run().correction_link;
let fresh = LunarDpntScenario {
latency_s: 0.0,
..Default::default()
}
.run()
.correction_link;
assert_eq!(fresh.latency_orbit_range_sigma_m, 0.0);
assert_eq!(fresh.latency_clock_range_sigma_m, 0.0);
assert_eq!(fresh.latency_range_sigma_m, 0.0);
assert_eq!(fresh.latency_position_sigma_m, 0.0);
assert_eq!(fresh.ageing_clock_time_error_s, 0.0);
assert_eq!(fresh.ageing_orbit_rate_m_per_s, 0.0);
assert_eq!(
fresh.survey_position_sigma_m.to_bits(),
aged.survey_position_sigma_m.to_bits()
);
assert_eq!(
fresh.quantization_position_sigma_m.to_bits(),
aged.quantization_position_sigma_m.to_bits()
);
let expect = (fresh.survey_position_sigma_m * fresh.survey_position_sigma_m
+ fresh.quantization_position_sigma_m * fresh.quantization_position_sigma_m)
.sqrt();
assert_eq!(fresh.total_position_sigma_m.to_bits(), expect.to_bits());
assert_eq!(aged.latency_curve[0].0, 0.0);
assert_eq!(
aged.latency_curve[0].1.to_bits(),
fresh.total_position_sigma_m.to_bits()
);
}
#[test]
fn the_residual_grows_monotonically_with_latency() {
let at = |t: f64| {
LunarDpntScenario {
latency_s: t,
..Default::default()
}
.run()
.correction_link
};
let taus = [0.0_f64, 1.0, 5.0, 10.0, 30.0, 60.0, 120.0, 300.0];
let totals: Vec<f64> = taus.iter().map(|&t| at(t).total_position_sigma_m).collect();
for w in totals.windows(2) {
assert!(
w[1] >= w[0],
"the residual must grow with correction age: {totals:?}"
);
}
assert!(
*totals.last().unwrap() > totals[0] * 1.5,
"a 300 s-old correction must be materially worse than a fresh one: {totals:?}"
);
let curve = at(10.0).latency_curve;
for (t, y) in &curve {
let direct = at(*t).total_position_sigma_m;
assert!(
(y - direct).abs() <= 1e-12 * direct.max(1.0),
"latency_curve at {t} s says {y}, a direct run says {direct}"
);
}
}
#[test]
fn quantization_scales_as_two_to_the_minus_bits_and_step_squared_over_twelve() {
let full_scale = 130.0_f64;
for bits in 1_u32..=30 {
let s = correction_quantization_step_m(full_scale, bits);
let s_next = correction_quantization_step_m(full_scale, bits + 1);
assert_eq!(
(2.0 * s_next).to_bits(),
s.to_bits(),
"step must halve exactly from {bits} to {} bits",
bits + 1
);
assert_eq!(
s.to_bits(),
(2.0 * full_scale / 2.0_f64.powi(bits as i32)).to_bits(),
"step must be 2·full_scale·2^(−bits)"
);
let sigma = uniform_quantization_sigma_m(s);
assert_eq!(sigma.to_bits(), (s / 12.0_f64.sqrt()).to_bits());
let var = sigma * sigma;
let want = s * s / 12.0;
assert!(
(var - want).abs() <= 1e-15 * want,
"variance {var} must be step²/12 = {want}"
);
}
assert_eq!(correction_quantization_step_m(full_scale, 0), 0.0);
assert_eq!(uniform_quantization_sigma_m(0.0), 0.0);
let a = LunarDpntScenario {
quantization_bits: 8,
..Default::default()
}
.run()
.correction_link;
let b = LunarDpntScenario {
quantization_bits: 9,
..Default::default()
}
.run()
.correction_link;
assert_eq!(a.quantization_full_scale_m, 130.0, "orbit_err + clock_err");
assert_eq!(
(2.0 * b.quantization_step_m).to_bits(),
a.quantization_step_m.to_bits()
);
assert_eq!(
(2.0 * b.quantization_range_sigma_m).to_bits(),
a.quantization_range_sigma_m.to_bits()
);
assert_eq!(
(2.0 * b.quantization_position_sigma_m).to_bits(),
a.quantization_position_sigma_m.to_bits()
);
let wide = LunarDpntScenario {
orbit_err_m: 200.0,
clock_err_m: 60.0,
..Default::default()
}
.run()
.correction_link;
assert_eq!(wide.quantization_full_scale_m, 260.0);
assert_eq!(
wide.quantization_step_m.to_bits(),
(2.0 * a.quantization_step_m).to_bits()
);
}
#[test]
fn each_term_switched_off_recovers_the_remaining_total_in_quadrature() {
let full = LunarDpntScenario::default().run().correction_link;
let hypot2 = |a: f64, b: f64| (a * a + b * b).sqrt();
let close = |a: f64, b: f64| (a - b).abs() <= 1e-12 * a.abs().max(1.0);
assert!(
close(
full.total_position_sigma_m,
(full.survey_position_sigma_m * full.survey_position_sigma_m
+ full.latency_position_sigma_m * full.latency_position_sigma_m
+ full.quantization_position_sigma_m * full.quantization_position_sigma_m)
.sqrt()
),
"total {} is not the RSS of {} / {} / {}",
full.total_position_sigma_m,
full.survey_position_sigma_m,
full.latency_position_sigma_m,
full.quantization_position_sigma_m
);
let no_survey = LunarDpntScenario {
survey_sigma_m: 0.0,
..Default::default()
}
.run()
.correction_link;
assert_eq!(no_survey.survey_position_sigma_m, 0.0);
assert!(close(
no_survey.total_position_sigma_m,
hypot2(
full.latency_position_sigma_m,
full.quantization_position_sigma_m
)
));
let no_latency = LunarDpntScenario {
latency_s: 0.0,
..Default::default()
}
.run()
.correction_link;
assert_eq!(no_latency.latency_position_sigma_m, 0.0);
assert!(close(
no_latency.total_position_sigma_m,
hypot2(
full.survey_position_sigma_m,
full.quantization_position_sigma_m
)
));
let no_quant = LunarDpntScenario {
quantization_bits: 0,
..Default::default()
}
.run()
.correction_link;
assert_eq!(no_quant.quantization_position_sigma_m, 0.0);
assert!(close(
no_quant.total_position_sigma_m,
hypot2(full.survey_position_sigma_m, full.latency_position_sigma_m)
));
let none = LunarDpntScenario {
survey_sigma_m: 0.0,
latency_s: 0.0,
quantization_bits: 0,
..Default::default()
}
.run()
.correction_link;
assert_eq!(none.total_position_sigma_m, 0.0);
assert_eq!(none.total_range_sigma_m, 0.0);
assert_eq!(
none.total_with_residual_range_sigma_m.to_bits(),
5.0_f64.to_bits(),
"with the link switched off the augmented σ is exactly residual_sigma_m"
);
}
#[test]
fn independent_range_sigmas_propagate_as_sigma_times_pdop() {
let scn = LunarDpntScenario::default();
let sats = scn.constellation().positions_mcmf(scn.t_s);
let user = scn.user_mcmf(scn.baseline_km);
let mine = user_pdop(user, &sats).expect("pdop");
let theirs = crate::orbit::dop(user, &sats).expect("orbit::dop").pdop;
assert!(
(mine - theirs).abs() < 1e-9,
"user_pdop {mine} vs orbit::dop {theirs}"
);
for sigma in [0.25_f64, 1.0, 7.5] {
let sigmas = vec![sigma; sats.len()];
let pos = position_sigma_from_range_sigmas(user, &sats, &sigmas).unwrap();
assert!(
(pos - sigma * mine).abs() <= 1e-12 * (sigma * mine),
"equal σ={sigma} must give σ·PDOP = {}, got {pos}",
sigma * mine
);
}
let mut uneven = vec![0.0; sats.len()];
uneven[0] = 10.0;
let uneven_pos = position_sigma_from_range_sigmas(user, &sats, &uneven).unwrap();
assert!(uneven_pos > 0.0 && uneven_pos < 10.0 * mine);
assert!(position_sigma_from_range_sigmas(user, &sats, &[1.0]).is_none());
assert!(user_pdop(user, &sats[..3]).is_none());
}
#[test]
fn every_correction_link_field_carries_a_unit_and_a_provenance_class() {
let v = serde_json::to_value(LunarDpntScenario::default().run()).unwrap();
let units = v["units"].as_object().expect("a units block");
assert!(!units.is_empty());
for field in v["correction_link"].as_object().unwrap().keys() {
let key = format!("correction_link.{field}");
assert!(
units.contains_key(&key),
"{key} is emitted but carries no units entry"
);
}
for (field, meta) in units {
assert!(meta["unit"].is_string(), "{field} has no unit");
assert!(
meta["provenance"].is_string(),
"{field} has no provenance class"
);
}
for field in units.keys() {
let mut cur = &v;
for seg in field.split('.') {
cur = &cur[seg];
assert!(
!cur.is_null(),
"units names {field}, which the report does not emit"
);
}
}
assert_eq!(units["correction_link.total_position_sigma_m"]["unit"], "m");
assert_eq!(units["correction_link.pdop"]["unit"], "1");
assert_eq!(units["correction_link.quantization_bits"]["unit"], "bit");
assert_eq!(
units["correction_link.ageing_orbit_rate_m_per_s"]["unit"],
"m/s"
);
}
#[test]
fn the_link_budget_dominates_the_decorrelation_residual_and_underfills_nine_metres() {
let r = LunarDpntScenario::default().run();
let b = &r.correction_link;
assert!(
b.total_position_sigma_m > 10.0 * r.user_error_corrected_m,
"link budget {} vs decorrelation residual {}",
b.total_position_sigma_m,
r.user_error_corrected_m
);
assert!(
b.total_position_sigma_m < 9.12,
"the three modelled terms total {} m; if this ever exceeds 9.12 m the finding \
reported alongside this work has changed and must be restated, NOT retuned",
b.total_position_sigma_m
);
assert!(b.survey_position_sigma_m > 0.0);
assert!(b.latency_position_sigma_m > 0.0);
assert!(b.quantization_position_sigma_m > 0.0);
assert!(b.protection_level_with_link_m > r.protection_level_m);
assert!(b.vpl_with_link_m > r.vpl_m);
assert!(b.total_with_residual_range_sigma_m > r.residual_sigma_m);
}
#[test]
fn survey_biased_corrections_reduce_to_the_unbiased_ones_and_project_on_the_station_los() {
let scn = LunarDpntScenario::default();
let sats = scn.constellation().positions_mcmf(scn.t_s);
let ref_mcmf = scn.ref_mcmf();
let (orbit_err, clock_err) = scn.inject_errors(sats.len());
let plain = differential_corrections(ref_mcmf, &sats, &orbit_err, &clock_err);
let same = survey_biased_corrections(ref_mcmf, ref_mcmf, &sats, &orbit_err, &clock_err);
for (a, b) in plain.iter().zip(&same) {
assert_eq!(
a.to_bits(),
b.to_bits(),
"a zero survey error must change nothing"
);
}
let delta = [3.0, 0.0, 0.0];
let assumed = [
ref_mcmf[0] + delta[0],
ref_mcmf[1] + delta[1],
ref_mcmf[2] + delta[2],
];
let biased = survey_biased_corrections(ref_mcmf, assumed, &sats, &orbit_err, &clock_err);
for (i, &s) in sats.iter().enumerate() {
let expect = dot(delta, los_unit(ref_mcmf, s));
let got = biased[i] - plain[i];
assert!(
(got - expect).abs() < 1e-5,
"sat {i}: survey bias {got} must be δ·û_ref = {expect}"
);
}
}
}