use crate::body::Body;
use crate::ephem_provider::EphemerisProvider;
use crate::timegeo::C_M_PER_S;
use crate::timescales::TwoPartJd;
type Vec3 = [f64; 3];
const LIGHT_TIME_TOL_S: f64 = 1e-12;
const LIGHT_TIME_MAX_ITERS: usize = 50;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LightTimeDirection {
Retarded,
Advanced,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LightTime {
pub tau_s: f64,
pub tx_epoch: TwoPartJd,
pub tx_pos: Vec3,
}
#[inline]
fn norm(v: Vec3) -> f64 {
(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt()
}
pub fn light_time_solution<E: EphemerisProvider>(
rx_pos: Vec3,
t_rx: TwoPartJd,
target: &Body,
center: &Body,
ephem: &E,
) -> Option<LightTime> {
solve_light_time(
rx_pos,
t_rx,
target,
center,
ephem,
LightTimeDirection::Retarded,
)
}
pub fn light_time_solution_advanced<E: EphemerisProvider>(
tx_pos: Vec3,
t_tx: TwoPartJd,
target: &Body,
center: &Body,
ephem: &E,
) -> Option<LightTime> {
solve_light_time(
tx_pos,
t_tx,
target,
center,
ephem,
LightTimeDirection::Advanced,
)
}
fn solve_light_time<E: EphemerisProvider>(
fixed_pos: Vec3,
fixed_epoch: TwoPartJd,
target: &Body,
center: &Body,
ephem: &E,
direction: LightTimeDirection,
) -> Option<LightTime> {
let sign = match direction {
LightTimeDirection::Retarded => -1.0,
LightTimeDirection::Advanced => 1.0,
};
let mut tau = 0.0_f64;
let mut body_epoch = fixed_epoch;
let mut body_pos = ephem.relative_position(target, center, fixed_epoch.to_f64())?;
for _ in 0..LIGHT_TIME_MAX_ITERS {
let sep = [
fixed_pos[0] - body_pos[0],
fixed_pos[1] - body_pos[1],
fixed_pos[2] - body_pos[2],
];
let next_tau = norm(sep) / C_M_PER_S;
if (next_tau - tau).abs() < LIGHT_TIME_TOL_S {
tau = next_tau;
break;
}
tau = next_tau;
body_epoch = fixed_epoch.add_seconds(sign * tau);
body_pos = ephem.relative_position(target, center, body_epoch.to_f64())?;
}
Some(LightTime {
tau_s: tau,
tx_epoch: body_epoch,
tx_pos: body_pos,
})
}
pub fn shapiro_delay(r_tx: Vec3, r_rx: Vec3, mu: f64) -> f64 {
let r1 = norm(r_tx);
let r2 = norm(r_rx);
let r12 = norm([r_tx[0] - r_rx[0], r_tx[1] - r_rx[1], r_tx[2] - r_rx[2]]);
let c = C_M_PER_S;
(2.0 * mu / (c * c * c)) * ((r1 + r2 + r12) / (r1 + r2 - r12)).ln()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Band {
S,
X,
Ka,
}
impl Band {
pub fn uplink_hz(self) -> f64 {
match self {
Band::S => 2.115e9,
Band::X => 7.179e9,
Band::Ka => 34.4e9,
}
}
pub fn downlink_hz(self) -> f64 {
match self {
Band::S => 2.295e9,
Band::X => 8.420e9,
Band::Ka => 32.0e9,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ObsWay {
One,
Two,
Three,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ObsKind {
Range,
Doppler,
DeltaDor,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RadiometricObs {
pub kind: ObsKind,
pub way: ObsWay,
pub band: Band,
pub epoch: TwoPartJd,
pub value: f64,
pub sigma: f64,
}
pub fn one_way_range<E: EphemerisProvider>(
station_pos: Vec3,
sc_body: &Body,
center: &Body,
t_rx: TwoPartJd,
ephem: &E,
) -> Option<f64> {
let down = light_time_solution(station_pos, t_rx, sc_body, center, ephem)?;
Some(down.tau_s * C_M_PER_S)
}
pub fn two_way_range<E: EphemerisProvider>(
station_pos: Vec3,
sc_body: &Body,
center: &Body,
t_rx: TwoPartJd,
ephem: &E,
) -> Option<f64> {
let down = light_time_solution(station_pos, t_rx, sc_body, center, ephem)?;
let up = light_time_solution_advanced(station_pos, down.tx_epoch, sc_body, center, ephem)?;
Some((up.tau_s + down.tau_s) * C_M_PER_S)
}
pub fn one_way_range_rate<E: EphemerisProvider>(
station_pos: Vec3,
sc_body: &Body,
center: &Body,
t_rx: TwoPartJd,
dt_s: f64,
ephem: &E,
) -> Option<f64> {
let r_plus = one_way_range(station_pos, sc_body, center, t_rx.add_seconds(dt_s), ephem)?;
let r_minus = one_way_range(station_pos, sc_body, center, t_rx.add_seconds(-dt_s), ephem)?;
Some((r_plus - r_minus) / (2.0 * dt_s))
}
pub fn two_way_range_rate<E: EphemerisProvider>(
station_pos: Vec3,
sc_body: &Body,
center: &Body,
t_rx: TwoPartJd,
dt_s: f64,
ephem: &E,
) -> Option<f64> {
let r_plus = two_way_range(station_pos, sc_body, center, t_rx.add_seconds(dt_s), ephem)?;
let r_minus = two_way_range(station_pos, sc_body, center, t_rx.add_seconds(-dt_s), ephem)?;
Some((r_plus - r_minus) / (2.0 * dt_s) / 2.0)
}
pub const DOPPLER_DERIV_STEP_S: f64 = 1.0;
pub fn one_way_doppler<E: EphemerisProvider>(
station_pos: Vec3,
sc_body: &Body,
center: &Body,
t_rx: TwoPartJd,
downlink_hz: f64,
ephem: &E,
) -> Option<f64> {
let rdot = one_way_range_rate(
station_pos,
sc_body,
center,
t_rx,
DOPPLER_DERIV_STEP_S,
ephem,
)?;
Some(-(downlink_hz / C_M_PER_S) * rdot)
}
pub fn two_way_doppler<E: EphemerisProvider>(
station_pos: Vec3,
sc_body: &Body,
center: &Body,
t_rx: TwoPartJd,
uplink_hz: f64,
turnaround: f64,
ephem: &E,
) -> Option<f64> {
three_way_doppler(
station_pos,
station_pos,
sc_body,
center,
t_rx,
uplink_hz,
turnaround,
ephem,
)
}
#[allow(clippy::too_many_arguments)]
pub fn three_way_doppler<E: EphemerisProvider>(
tx_station_pos: Vec3,
rx_station_pos: Vec3,
sc_body: &Body,
center: &Body,
t_rx: TwoPartJd,
uplink_hz: f64,
turnaround: f64,
ephem: &E,
) -> Option<f64> {
let down_rate = one_way_range_rate(
rx_station_pos,
sc_body,
center,
t_rx,
DOPPLER_DERIV_STEP_S,
ephem,
)?;
let up_rate = one_way_range_rate(
tx_station_pos,
sc_body,
center,
t_rx,
DOPPLER_DERIV_STEP_S,
ephem,
)?;
Some(-(turnaround * uplink_hz / C_M_PER_S) * (up_rate + down_rate))
}
pub fn turnaround_ratio(up: Band, down: Band) -> f64 {
match (up, down) {
(Band::S, Band::S) => 240.0 / 221.0,
(Band::S, Band::X) => 880.0 / 221.0,
(Band::X, Band::S) => 240.0 / 749.0,
(Band::X, Band::X) => 880.0 / 749.0,
(Band::X, Band::Ka) => 3344.0 / 749.0,
(Band::Ka, Band::Ka) => 3360.0 / 3599.0,
_ => {
panic!("no standard DSN coherent turn-around ratio for the {up:?} → {down:?} band pair")
}
}
}
pub fn two_way_doppler_coherent<E: EphemerisProvider>(
station_pos: Vec3,
sc_body: &Body,
center: &Body,
t_rx: TwoPartJd,
up: Band,
down: Band,
ephem: &E,
) -> Option<f64> {
two_way_doppler(
station_pos,
sc_body,
center,
t_rx,
up.uplink_hz(),
turnaround_ratio(up, down),
ephem,
)
}
pub fn regenerative_range_ambiguity(chip_rate_hz: f64) -> f64 {
C_M_PER_S / (2.0 * chip_rate_hz)
}
pub fn pn_range_ambiguity(chip_rate_hz: f64, code_length_chips: f64) -> f64 {
regenerative_range_ambiguity(chip_rate_hz) * code_length_chips
}
pub const K_PLASMA_M_HZ2_PER_TECU_SI: f64 = 40.3;
pub fn delta_dor(sc_pos: Vec3, quasar_unit: Vec3, baseline_vec: Vec3) -> f64 {
let s = norm(sc_pos);
let sc_unit = if s > 0.0 {
[sc_pos[0] / s, sc_pos[1] / s, sc_pos[2] / s]
} else {
[0.0, 0.0, 0.0]
};
let ds = [
sc_unit[0] - quasar_unit[0],
sc_unit[1] - quasar_unit[1],
sc_unit[2] - quasar_unit[2],
];
let b_dot_ds = baseline_vec[0] * ds[0] + baseline_vec[1] * ds[1] + baseline_vec[2] * ds[2];
-b_dot_ds / C_M_PER_S
}
pub fn solar_plasma_delay(freq_hz: f64, tec_el_per_m2: f64) -> f64 {
K_PLASMA_M_HZ2_PER_TECU_SI * tec_el_per_m2 / (C_M_PER_S * freq_hz * freq_hz)
}
pub fn coronal_tec_from_sep(sep_rad: f64, reference_tec: f64, exponent: f64) -> f64 {
let min_sep = 0.27_f64.to_radians();
let s = sep_rad.max(min_sep).sin();
reference_tec / s.powf(exponent)
}
pub fn dual_freq_plasma_calibration(obs_x: f64, obs_ka: f64, f_x_hz: f64, f_ka_hz: f64) -> f64 {
let inv_fx2 = 1.0 / (f_x_hz * f_x_hz);
let inv_fka2 = 1.0 / (f_ka_hz * f_ka_hz);
let k_disp = (obs_x - obs_ka) / (inv_fx2 - inv_fka2);
k_disp * inv_fx2
}
pub fn tropo_delay(lat_rad: f64, h_m: f64, el_rad: f64, doy: f64) -> f64 {
let meteo = crate::gnss_sim::Meteo::default();
crate::gnss_sim::tropo_delay_m(&meteo, lat_rad, h_m, el_rad, doy)
}
pub fn iono_delay(lat_rad: f64, lon_rad: f64, el_rad: f64, az_rad: f64, gps_sod: f64) -> f64 {
let coeffs = crate::gnss_sim::KlobucharCoeffs::default();
crate::gnss_sim::klobuchar_delay_m(&coeffs, lat_rad, lon_rad, el_rad, az_rad, gps_sod)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ephem_provider::BuiltinEphemeris;
use crate::forces::MU_SUN;
const PROBE_JD: f64 = 2_459_580.5;
const AU_M: f64 = 1.495_978_707e11;
#[derive(Debug)]
struct ConstantVelocityEphemeris {
t0_jd: f64,
r0: Vec3,
v: Vec3,
}
impl EphemerisProvider for ConstantVelocityEphemeris {
fn relative_position(&self, _target: &Body, _center: &Body, jd_tdb: f64) -> Option<Vec3> {
let dt_s = (jd_tdb - self.t0_jd) * crate::timescales::SECONDS_PER_DAY;
Some([
self.r0[0] + self.v[0] * dt_s,
self.r0[1] + self.v[1] * dt_s,
self.r0[2] + self.v[2] * dt_s,
])
}
}
#[test]
fn earth_sun_light_time() {
let ephem = BuiltinEphemeris;
let t_rx = TwoPartJd::from_f64(PROBE_JD);
let lt = light_time_solution([0.0, 0.0, 0.0], t_rx, &Body::sun(), &Body::earth(), &ephem)
.expect("builtin supplies Sun relative to Earth");
assert!(
(480.0..=520.0).contains(<.tau_s),
"Earth–Sun light time {} s outside the 1 AU/c band",
lt.tau_s
);
let sun_at_rx = ephem
.relative_position(&Body::sun(), &Body::earth(), t_rx.to_f64())
.unwrap();
let tau0 = norm(sun_at_rx) / C_M_PER_S;
assert!(
(lt.tau_s - tau0).abs() > 0.0,
"retarded correction should move τ off the receive-epoch value"
);
let range_to_retarded = norm(lt.tx_pos) / C_M_PER_S;
assert!(
(range_to_retarded - lt.tau_s).abs() < 1e-9,
"retarded geometry inconsistent: |tx_pos|/c = {range_to_retarded} s, τ = {} s",
lt.tau_s
);
let back_dt = t_rx.diff_seconds(lt.tx_epoch);
assert!(
(back_dt - lt.tau_s).abs() < 1e-6,
"tx_epoch must be t_rx − τ: t_rx − tx_epoch = {back_dt} s, τ = {} s",
lt.tau_s
);
let range_au = norm(lt.tx_pos) / AU_M;
assert!(
(0.95..=1.05).contains(&range_au),
"retarded Earth–Sun range {range_au} AU not near 1 AU"
);
}
#[test]
fn earth_moon_light_time() {
let ephem = BuiltinEphemeris;
let t_rx = TwoPartJd::from_f64(PROBE_JD);
let lt = light_time_solution([0.0, 0.0, 0.0], t_rx, &Body::moon(), &Body::earth(), &ephem)
.expect("builtin supplies Moon relative to Earth");
assert!(
(1.15..=1.40).contains(<.tau_s),
"Earth–Moon light time {} s outside the ~1.19–1.36 s perigee–apogee band",
lt.tau_s
);
let range_to_retarded = norm(lt.tx_pos) / C_M_PER_S;
assert!(
(range_to_retarded - lt.tau_s).abs() < 1e-9,
"retarded Moon geometry inconsistent"
);
}
#[test]
fn light_time_solver_recovers_known_retardation() {
let t0_jd = PROBE_JD;
let r0 = [2.0e11, 5.0e10, -3.0e10];
let v = [12_000.0, -8_000.0, 4_000.0];
let ephem = ConstantVelocityEphemeris { t0_jd, r0, v };
let rx_pos = [0.0, 0.0, 0.0];
let t_rx = TwoPartJd::from_f64(t0_jd).add_seconds(1234.567);
let dt_rx_s = t_rx.diff_seconds(TwoPartJd::from_f64(t0_jd));
let p = [
r0[0] + v[0] * dt_rx_s,
r0[1] + v[1] * dt_rx_s,
r0[2] + v[2] * dt_rx_s,
];
let c = C_M_PER_S;
let d = [rx_pos[0] - p[0], rx_pos[1] - p[1], rx_pos[2] - p[2]];
let dv = d[0] * v[0] + d[1] * v[1] + d[2] * v[2];
let d2 = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
let v2 = v[0] * v[0] + v[1] * v[1] + v[2] * v[2];
let qa = c * c - v2;
let qb = -2.0 * dv;
let qc = -d2;
let disc = qb * qb - 4.0 * qa * qc;
let tau_analytic = (-qb + disc.sqrt()) / (2.0 * qa);
let lt = light_time_solution(rx_pos, t_rx, &Body::mars(), &Body::sun(), &ephem)
.expect("synthetic provider supplies any pair");
assert!(
(lt.tau_s - tau_analytic).abs() < 1e-9,
"solver τ = {} s vs analytic {} s (Δ = {} s)",
lt.tau_s,
tau_analytic,
(lt.tau_s - tau_analytic).abs()
);
let retarded = [
p[0] - v[0] * tau_analytic,
p[1] - v[1] * tau_analytic,
p[2] - v[2] * tau_analytic,
];
for (k, &want) in retarded.iter().enumerate() {
assert!(
(lt.tx_pos[k] - want).abs() < 1.0,
"retarded position component {k}: solver {} vs analytic {} (Δ = {} m)",
lt.tx_pos[k],
want,
(lt.tx_pos[k] - want).abs()
);
}
let dt = t_rx.diff_seconds(lt.tx_epoch);
assert!(
(dt - tau_analytic).abs() < 1e-6,
"emission epoch wrong: t_rx − tx_epoch = {dt} s, τ = {tau_analytic} s"
);
}
#[test]
fn advanced_light_time_epoch_is_later() {
let t0_jd = PROBE_JD;
let ephem = ConstantVelocityEphemeris {
t0_jd,
r0: [2.0e11, 5.0e10, -3.0e10],
v: [12_000.0, -8_000.0, 4_000.0],
};
let tx_pos = [0.0, 0.0, 0.0];
let t_tx = TwoPartJd::from_f64(t0_jd).add_seconds(1234.567);
let lt = light_time_solution_advanced(tx_pos, t_tx, &Body::mars(), &Body::sun(), &ephem)
.expect("synthetic provider supplies any pair");
let dt = lt.tx_epoch.diff_seconds(t_tx);
assert!(
(dt - lt.tau_s).abs() < 1e-6,
"advanced epoch must be t_tx + τ: tx_epoch − t_tx = {dt} s, τ = {} s",
lt.tau_s
);
let sep = norm([
tx_pos[0] - lt.tx_pos[0],
tx_pos[1] - lt.tx_pos[1],
tx_pos[2] - lt.tx_pos[2],
]) / C_M_PER_S;
assert!(
(sep - lt.tau_s).abs() < 1e-9,
"advanced geometry inconsistent: range/c = {sep} s, τ = {} s",
lt.tau_s
);
}
#[test]
fn mars_light_time_none() {
let ephem = BuiltinEphemeris;
let t_rx = TwoPartJd::from_f64(PROBE_JD);
assert!(
light_time_solution([0.0, 0.0, 0.0], t_rx, &Body::mars(), &Body::earth(), &ephem)
.is_none(),
"the kernel-free builtin cannot supply a Mars light time"
);
}
#[test]
fn shapiro_matches_reference() {
let r_tx = [3.0e11, 0.0, 0.0];
let x: f64 = 2.0e22 / 6.0e11;
let y = (9.0e22 - x * x).sqrt();
let r_rx = [x, y, 0.0];
let mu = 1.5e20;
let c = C_M_PER_S;
let expected = (2.0 * mu / (c * c * c)) * 5.0_f64.ln();
let got = shapiro_delay(r_tx, r_rx, mu);
assert!(
(got - expected).abs() < 1e-12,
"Shapiro closed form mismatch: got {got} s, expected {expected} s"
);
assert!(
(got - 1.791_980_887_809_346e-5).abs() < 1e-15,
"Shapiro hand value drifted: {got} s"
);
let r_sun = 6.957e8; let b = 3.0 * r_sun; let earth = [-AU_M, b, 0.0];
let mars = [1.524 * AU_M, b, 0.0];
let one_way = shapiro_delay(earth, mars, MU_SUN);
let round_trip = 2.0 * one_way;
assert!(
(50e-6..=200e-6).contains(&one_way),
"Earth–Mars one-way Shapiro {} µs not in the ~50–200 µs band",
one_way * 1e6
);
assert!(
(100e-6..=250e-6).contains(&round_trip),
"Earth–Mars round-trip Shapiro {} µs not in the published ~100–250 µs band",
round_trip * 1e6
);
}
#[test]
fn shapiro_zero_for_coincident_endpoints() {
let p = [1.2e11, -3.4e10, 5.6e9];
assert_eq!(shapiro_delay(p, p, MU_SUN), 0.0);
}
#[test]
fn two_way_range_is_sum_of_legs() {
let ephem = BuiltinEphemeris;
let t_rx = TwoPartJd::from_f64(PROBE_JD);
let station = [0.0, 0.0, 0.0];
let two_way = two_way_range(station, &Body::sun(), &Body::earth(), t_rx, &ephem)
.expect("builtin supplies Sun relative to Earth");
let down =
light_time_solution(station, t_rx, &Body::sun(), &Body::earth(), &ephem).unwrap();
let up = light_time_solution_advanced(
station,
down.tx_epoch,
&Body::sun(),
&Body::earth(),
&ephem,
)
.unwrap();
let expected = (up.tau_s + down.tau_s) * C_M_PER_S;
assert!(
(two_way - expected).abs() < 1e-3,
"two-way range {two_way} m differs from c·(τ_up+τ_down) {expected} m by {} m",
(two_way - expected).abs()
);
let round_trip_au = two_way / AU_M;
assert!(
(1.9..=2.1).contains(&round_trip_au),
"Earth–Sun round-trip range {round_trip_au} AU not near 2 AU"
);
}
#[test]
fn one_way_range_matches_light_time() {
let ephem = BuiltinEphemeris;
let t_rx = TwoPartJd::from_f64(PROBE_JD);
let station = [0.0, 0.0, 0.0];
let r = one_way_range(station, &Body::moon(), &Body::earth(), t_rx, &ephem).unwrap();
let lt = light_time_solution(station, t_rx, &Body::moon(), &Body::earth(), &ephem).unwrap();
assert!((r - lt.tau_s * C_M_PER_S).abs() < 1e-6);
let km = r / 1000.0;
assert!(
(350_000.0..=410_000.0).contains(&km),
"Earth–Moon one-way range {km} km outside the perigee–apogee band"
);
}
#[test]
fn one_way_doppler_constant_velocity_radial() {
let t0_jd = PROBE_JD;
let speed = 9_000.0_f64; let ephem = ConstantVelocityEphemeris {
t0_jd,
r0: [2.0e11, 0.0, 0.0],
v: [speed, 0.0, 0.0],
};
let station = [0.0, 0.0, 0.0];
let t_rx = TwoPartJd::from_f64(t0_jd);
let f_dl = 8.42e9;
let rdot =
one_way_range_rate(station, &Body::mars(), &Body::sun(), t_rx, 1.0, &ephem).unwrap();
assert!(
(rdot - speed).abs() / speed < 1e-3,
"radial range rate {rdot} m/s should be ≈ {speed} m/s"
);
let f_d =
one_way_doppler(station, &Body::mars(), &Body::sun(), t_rx, f_dl, &ephem).unwrap();
let expected = -(f_dl / C_M_PER_S) * speed;
assert!(
(f_d - expected).abs() / expected.abs() < 1e-3,
"one-way Doppler {f_d} Hz vs analytic {expected} Hz"
);
assert!(
f_d < 0.0,
"a receding emitter must give a negative (red) shift"
);
}
#[test]
fn two_way_doppler_is_twice_one_way_at_unit_turnaround() {
let t0_jd = PROBE_JD;
let speed = -7_500.0_f64; let ephem = ConstantVelocityEphemeris {
t0_jd,
r0: [1.5e11, 0.0, 0.0],
v: [speed, 0.0, 0.0],
};
let station = [0.0, 0.0, 0.0];
let t_rx = TwoPartJd::from_f64(t0_jd);
let f = 7.179e9;
let one_way =
one_way_doppler(station, &Body::mars(), &Body::sun(), t_rx, f, &ephem).unwrap();
let two_way =
two_way_doppler(station, &Body::mars(), &Body::sun(), t_rx, f, 1.0, &ephem).unwrap();
assert!(
one_way > 0.0,
"an approaching emitter must give a positive shift"
);
assert!(
(two_way - 2.0 * one_way).abs() / two_way.abs() < 5e-3,
"two-way Doppler {two_way} Hz should be ≈ 2× one-way {one_way} Hz"
);
}
#[test]
fn three_way_collapses_to_two_way_for_colocated_stations() {
let t0_jd = PROBE_JD;
let ephem = ConstantVelocityEphemeris {
t0_jd,
r0: [1.8e11, 3.0e10, 0.0],
v: [6_000.0, -2_000.0, 1_000.0],
};
let station = [0.0, 0.0, 0.0];
let t_rx = TwoPartJd::from_f64(t0_jd);
let f = 7.179e9;
let m = 880.0 / 749.0;
let two_way =
two_way_doppler(station, &Body::mars(), &Body::sun(), t_rx, f, m, &ephem).unwrap();
let three_way = three_way_doppler(
station,
station,
&Body::mars(),
&Body::sun(),
t_rx,
f,
m,
&ephem,
)
.unwrap();
assert!(
(two_way - three_way).abs() / two_way.abs() < 1e-6,
"three-way with colocated stations {three_way} Hz must equal two-way {two_way} Hz"
);
}
#[test]
fn radiometric_obs_record_fields() {
let obs = RadiometricObs {
kind: ObsKind::Range,
way: ObsWay::Two,
band: Band::X,
epoch: TwoPartJd::from_f64(PROBE_JD),
value: 4.2e11,
sigma: 1.0,
};
assert_eq!(obs.kind, ObsKind::Range);
assert_eq!(obs.way, ObsWay::Two);
assert_eq!(obs.band, Band::X);
assert_eq!(obs.value, 4.2e11);
}
#[test]
fn turnaround_ratios_are_exact() {
assert_eq!(turnaround_ratio(Band::X, Band::X), 880.0 / 749.0);
assert_eq!(turnaround_ratio(Band::X, Band::Ka), 3344.0 / 749.0);
assert_eq!(turnaround_ratio(Band::S, Band::X), 880.0 / 221.0);
assert_eq!(turnaround_ratio(Band::S, Band::S), 240.0 / 221.0);
assert_eq!(turnaround_ratio(Band::X, Band::S), 240.0 / 749.0);
assert_eq!(turnaround_ratio(Band::Ka, Band::Ka), 3360.0 / 3599.0);
assert!((turnaround_ratio(Band::X, Band::X) - 1.174_899_866).abs() < 1e-9);
}
#[test]
#[should_panic(expected = "no standard DSN coherent turn-around ratio")]
fn turnaround_ratio_panics_for_undefined_pair() {
let _ = turnaround_ratio(Band::Ka, Band::S);
}
#[test]
fn coherent_two_way_matches_explicit_turnaround() {
let t0_jd = PROBE_JD;
let ephem = ConstantVelocityEphemeris {
t0_jd,
r0: [2.0e11, 4.0e10, -1.0e10],
v: [5_000.0, -3_000.0, 2_000.0],
};
let station = [0.0, 0.0, 0.0];
let t_rx = TwoPartJd::from_f64(t0_jd);
let coherent = two_way_doppler_coherent(
station,
&Body::mars(),
&Body::sun(),
t_rx,
Band::X,
Band::X,
&ephem,
)
.unwrap();
let explicit = two_way_doppler(
station,
&Body::mars(),
&Body::sun(),
t_rx,
Band::X.uplink_hz(),
turnaround_ratio(Band::X, Band::X),
&ephem,
)
.unwrap();
assert_eq!(
coherent, explicit,
"the coherent wrapper must apply the band carrier + ratio"
);
let unit = two_way_doppler(
station,
&Body::mars(),
&Body::sun(),
t_rx,
Band::X.uplink_hz(),
1.0,
&ephem,
)
.unwrap();
let m = turnaround_ratio(Band::X, Band::X);
assert!(
(coherent - m * unit).abs() / coherent.abs() < 1e-12,
"coherent Doppler must be the turn-around-ratio-scaled unit Doppler"
);
}
#[test]
fn regenerative_ambiguity_is_half_chip_light_distance() {
let chip = 1.0e6; let amb = regenerative_range_ambiguity(chip);
let expected = C_M_PER_S / (2.0 * chip);
assert_eq!(amb, expected);
assert!(
(amb - 149.896_229).abs() < 1e-3,
"1 MHz chip ambiguity {amb} m"
);
assert!((regenerative_range_ambiguity(2.0e6) - amb / 2.0).abs() < 1e-9);
}
#[test]
fn pn_code_unambiguous_range_spans_the_link() {
let chip = 1.0e6;
let code_len = 1_009_470.0; let du = pn_range_ambiguity(chip, code_len);
let expected = regenerative_range_ambiguity(chip) * code_len;
assert_eq!(du, expected);
let du_km = du / 1000.0;
assert!(
(140_000.0..=160_000.0).contains(&du_km),
"CCSDS-414.1 PN unambiguous range {du_km} km not in the expected ~150 000 km band"
);
}
#[test]
fn delta_dor_magnitude_for_known_offset() {
let baseline = [8.0e6, 0.0, 0.0];
let quasar = [0.0, 0.0, 1.0];
let dtheta = 1.0e-3_f64; let sc_unit = [dtheta.sin(), 0.0, dtheta.cos()];
let r = 2.0e11;
let sc_pos = [sc_unit[0] * r, sc_unit[1] * r, sc_unit[2] * r];
let dtau = delta_dor(sc_pos, quasar, baseline);
let expected = -(baseline[0] * dtheta.sin()) / C_M_PER_S;
assert!(
(dtau - expected).abs() < 1e-15,
"Δ-DOR {dtau} s vs analytic projection {expected} s"
);
let approx = baseline[0] * dtheta / C_M_PER_S;
assert!(
(dtau.abs() - approx).abs() / approx < 1e-3,
"Δ-DOR magnitude {} s not ≈ B·Δθ/c = {approx} s",
dtau.abs()
);
}
#[test]
fn delta_dor_zero_when_aligned_with_quasar() {
let quasar = [0.0, 0.6, 0.8];
let baseline = [5.0e6, -2.0e6, 1.0e6];
let sc_pos = [quasar[0] * 3.0e11, quasar[1] * 3.0e11, quasar[2] * 3.0e11];
let dtau = delta_dor(sc_pos, quasar, baseline);
assert!(
dtau.abs() < 1e-15,
"aligned Δ-DOR should be ~0, got {dtau} s"
);
}
#[test]
fn plasma_delay_scales_as_inverse_frequency_squared() {
let tec = 1.0e18; let f_x = 8.42e9;
let f_ka = 32.0e9;
let dx = solar_plasma_delay(f_x, tec);
let dka = solar_plasma_delay(f_ka, tec);
assert!(
dx > 0.0 && dka > 0.0,
"plasma delay is a positive group delay"
);
let ratio = dx / dka;
let expected = (f_ka / f_x).powi(2);
assert!(
(ratio - expected).abs() / expected < 1e-9,
"plasma X/Ka delay ratio {ratio} should be (f_Ka/f_X)² = {expected}"
);
let dl_x = K_PLASMA_M_HZ2_PER_TECU_SI * tec / (f_x * f_x);
assert!((dx - dl_x / C_M_PER_S).abs() < 1e-18);
}
#[test]
fn dual_frequency_recovers_injected_plasma() {
let f_x = 8.42e9;
let f_ka = 32.0e9;
let tec = 2.5e18; let true_x = solar_plasma_delay(f_x, tec);
let true_ka = solar_plasma_delay(f_ka, tec);
let recovered = dual_freq_plasma_calibration(true_x, true_ka, f_x, f_ka);
assert!(
(recovered - true_x).abs() / true_x < 1e-2,
"dual-freq recovered X-band plasma {recovered} s vs truth {true_x} s (>1% error)"
);
assert!((recovered - true_x).abs() / true_x < 1e-9);
}
#[test]
fn coronal_tec_rises_toward_conjunction() {
let a = 1.0e17;
let q = 1.0;
let tec_near = coronal_tec_from_sep(2.0_f64.to_radians(), a, q); let tec_far = coronal_tec_from_sep(30.0_f64.to_radians(), a, q); assert!(
tec_near > tec_far,
"coronal TEC must rise toward conjunction: {tec_near} (2°) vs {tec_far} (30°)"
);
let dl_x = solar_plasma_delay(8.42e9, tec_near) * C_M_PER_S;
assert!(
(0.1..=1000.0).contains(&dl_x),
"2° SEP X-band plasma range delay {dl_x} m outside the plausible band"
);
assert!(dl_x.is_finite());
}
#[test]
fn media_wrappers_are_wired_and_signed() {
let lat = 35.0_f64.to_radians();
let lon = (-116.0_f64).to_radians(); let h = 1000.0;
let doy = 180.0;
let t_zenith = tropo_delay(lat, h, 89.0_f64.to_radians(), doy);
let t_low = tropo_delay(lat, h, 10.0_f64.to_radians(), doy);
assert!(t_zenith > 0.0, "zenith tropo delay must be positive");
assert!(
t_low > t_zenith,
"low-elevation tropo {t_low} m must exceed zenith {t_zenith} m"
);
let i = iono_delay(lat, lon, 30.0_f64.to_radians(), 1.5, 50_400.0);
assert!(i >= 0.0, "iono slant delay must be non-negative, got {i} m");
}
}