use crate::frames::Geodetic;
use crate::lunar::Selenographic;
use crate::palette::chart::{AXIS, BG, CYAN, FONT_SANS, INK_2};
use crate::precession::{mat_vec, transpose, Vec3};
fn sub(a: Vec3, b: Vec3) -> Vec3 {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
fn add(a: Vec3, b: Vec3) -> Vec3 {
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
}
fn norm(v: Vec3) -> f64 {
(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt()
}
const C: f64 = crate::timegeo::C_M_PER_S;
pub fn station_inertial_position(g: Geodetic, jd_tt: f64, jd_ut1: f64) -> Vec3 {
let r_ecef = crate::frames::geodetic_to_ecef(g);
let m = crate::cio::gcrs_to_itrs_matrix(jd_tt, jd_ut1, 0.0, 0.0);
mat_vec(&transpose(&m), r_ecef)
}
pub fn station_inertial_position_itrs(
r_itrs: Vec3,
jd_tt: f64,
jd_ut1: f64,
xp_rad: f64,
yp_rad: f64,
) -> Vec3 {
let m = crate::cio::gcrs_to_itrs_matrix(jd_tt, jd_ut1, xp_rad, yp_rad);
mat_vec(&transpose(&m), r_itrs)
}
pub fn beacon_inertial_position(sel: Selenographic, jd_tt: f64) -> Vec3 {
let t_tt_jc = (jd_tt - crate::timescales::JD_J2000) / 36_525.0;
let moon_geo = crate::ephem::moon_position(t_tt_jc);
let r_body = crate::lunar::selenographic_to_mcmf(sel);
let m = crate::lunar_frame::icrf_to_iau_moon(jd_tt);
let r_inertial_offset = mat_vec(&transpose(&m), r_body);
add(moon_geo, r_inertial_offset)
}
pub fn geometric_delay_s(r1: Vec3, r2: Vec3, r_beacon: Vec3) -> f64 {
(norm(sub(r2, r_beacon)) - norm(sub(r1, r_beacon))) / C
}
pub fn vlbi_delay_s(
r1: Vec3,
r2: Vec3,
r_beacon: Vec3,
clk1_s: f64,
clk2_s: f64,
with_shapiro: bool,
) -> f64 {
let mut tau = geometric_delay_s(r1, r2, r_beacon) + (clk2_s - clk1_s);
if with_shapiro {
let sh2 = crate::radiometric::shapiro_delay(r_beacon, r2, crate::forces::MU_EARTH);
let sh1 = crate::radiometric::shapiro_delay(r_beacon, r1, crate::forces::MU_EARTH);
tau += sh2 - sh1;
}
tau
}
pub fn delay_partials_beacon(r1: Vec3, r2: Vec3, r_beacon: Vec3) -> Vec3 {
let d2 = sub(r_beacon, r2);
let d1 = sub(r_beacon, r1);
let n2 = norm(d2);
let n1 = norm(d1);
[
(d2[0] / n2 - d1[0] / n1) / C,
(d2[1] / n2 - d1[1] / n1) / C,
(d2[2] / n2 - d1[2] / n1) / C,
]
}
pub fn delay_partials_station1(r1: Vec3, r_beacon: Vec3) -> Vec3 {
let d = sub(r1, r_beacon);
let n = norm(d);
[-d[0] / (n * C), -d[1] / (n * C), -d[2] / (n * C)]
}
pub fn delay_partials_station2(r2: Vec3, r_beacon: Vec3) -> Vec3 {
let d = sub(r2, r_beacon);
let n = norm(d);
[d[0] / (n * C), d[1] / (n * C), d[2] / (n * C)]
}
pub fn near_field_correction_s(r1: Vec3, r2: Vec3, r_beacon: Vec3) -> f64 {
let baseline = sub(r2, r1);
let far_field = crate::radiometric::delta_dor(r_beacon, [0.0, 0.0, 0.0], baseline);
geometric_delay_s(r1, r2, r_beacon) - far_field
}
const NAIF_EARTH_MOON: (i32, i32) = (399, 301);
pub const FRAME_ITRF93: i32 = 3000;
pub const FRAME_MOON_PA_DE440: i32 = 31008;
#[derive(Clone, Debug)]
pub struct KernelGeometry {
spk: crate::naif_kernel::SpkKernel,
earth: crate::naif_kernel::PckKernel,
moon: crate::naif_kernel::PckKernel,
}
#[derive(Clone, Copy, Debug)]
pub struct LightTimeSolution {
pub light_time_s: f64,
pub unit_beacon_to_station: Vec3,
pub beacon_velocity_m_s: Vec3,
pub iterations: usize,
}
impl KernelGeometry {
pub fn new(
spk: crate::naif_kernel::SpkKernel,
earth_orientation: crate::naif_kernel::PckKernel,
moon_orientation: crate::naif_kernel::PckKernel,
) -> Self {
Self {
spk,
earth: earth_orientation,
moon: moon_orientation,
}
}
pub fn open(
spk: &std::path::Path,
earth_orientation: &std::path::Path,
moon_orientation: &std::path::Path,
) -> Result<Self, String> {
Ok(Self::new(
crate::naif_kernel::SpkKernel::open(spk)?,
crate::naif_kernel::PckKernel::open(earth_orientation)?,
crate::naif_kernel::PckKernel::open(moon_orientation)?,
))
}
pub fn station_j2000(&self, ecef: Vec3, t_hi: f64, t_lo: f64) -> Result<Vec3, String> {
let (r, _) = self.earth.rotation_from_j2000(FRAME_ITRF93, t_hi, t_lo)?;
Ok(crate::naif_kernel::mat_vec(
&crate::naif_kernel::mat_t(&r),
ecef,
))
}
pub fn beacon_geocentric(
&self,
body: Vec3,
t_hi: f64,
t_lo: f64,
) -> Result<(Vec3, Vec3), String> {
let (earth, moon) = NAIF_EARTH_MOON;
let m = self.spk.state(moon, earth, t_hi, t_lo)?;
let (r, dr) = self
.moon
.rotation_from_j2000(FRAME_MOON_PA_DE440, t_hi, t_lo)?;
let off = crate::naif_kernel::mat_vec(&crate::naif_kernel::mat_t(&r), body);
let doff = crate::naif_kernel::mat_vec(&crate::naif_kernel::mat_t(&dr), body);
Ok((add(m[0], off), add(m[1], doff)))
}
pub fn light_time(
&self,
ecef: Vec3,
body: Vec3,
t_hi: f64,
t_lo: f64,
offset: Vec3,
) -> Result<LightTimeSolution, String> {
let s = self.station_j2000(ecef, t_hi, t_lo)?;
let e = self.spk.state(NAIF_EARTH_MOON.0, 0, t_hi, t_lo)?;
let (v_e, a_e) = (e[1], e[2]);
let mut lt = {
let (g, _) = self.beacon_geocentric(body, t_hi, t_lo)?;
norm(sub(s, add(g, offset))) / C
};
for it in 1..=30 {
let d_e = [
v_e[0] * lt - 0.5 * a_e[0] * lt * lt,
v_e[1] * lt - 0.5 * a_e[1] * lt * lt,
v_e[2] * lt - 0.5 * a_e[2] * lt * lt,
];
let (g, g_dot) = self.beacon_geocentric(body, t_hi, t_lo - lt)?;
let rho = sub(add(s, d_e), add(g, offset));
let r = norm(rho);
let next = r / C;
let done = (next - lt).abs() <= 1e-15;
lt = next;
if done {
let v_earth_te = [
v_e[0] - a_e[0] * lt,
v_e[1] - a_e[1] * lt,
v_e[2] - a_e[2] * lt,
];
return Ok(LightTimeSolution {
light_time_s: lt,
unit_beacon_to_station: [rho[0] / r, rho[1] / r, rho[2] / r],
beacon_velocity_m_s: add(v_earth_te, g_dot),
iterations: it,
});
}
}
Err("light time did not converge to 1e-15 s in 30 iterations".into())
}
pub fn delay_s(
&self,
ecef1: Vec3,
ecef2: Vec3,
body: Vec3,
t_hi: f64,
t_lo: f64,
) -> Result<f64, String> {
let z = [0.0; 3];
Ok(self.light_time(ecef2, body, t_hi, t_lo, z)?.light_time_s
- self.light_time(ecef1, body, t_hi, t_lo, z)?.light_time_s)
}
pub fn delay_partials_beacon(
&self,
ecef1: Vec3,
ecef2: Vec3,
body: Vec3,
t_hi: f64,
t_lo: f64,
) -> Result<Vec3, String> {
let z = [0.0; 3];
let part = |ecef: Vec3| -> Result<Vec3, String> {
let sol = self.light_time(ecef, body, t_hi, t_lo, z)?;
let u = sol.unit_beacon_to_station;
let v = sol.beacon_velocity_m_s;
let den = C - (u[0] * v[0] + u[1] * v[1] + u[2] * v[2]);
Ok([-u[0] / den, -u[1] / den, -u[2] / den])
};
let (p1, p2) = (part(ecef1)?, part(ecef2)?);
Ok(sub(p2, p1))
}
pub fn delay_partials_beacon_body(
&self,
ecef1: Vec3,
ecef2: Vec3,
body: Vec3,
t_hi: f64,
t_lo: f64,
) -> Result<Vec3, String> {
let z = [0.0; 3];
let part = |ecef: Vec3| -> Result<Vec3, String> {
let sol = self.light_time(ecef, body, t_hi, t_lo, z)?;
let u = sol.unit_beacon_to_station;
let v = sol.beacon_velocity_m_s;
let den = C - (u[0] * v[0] + u[1] * v[1] + u[2] * v[2]);
let (r, _) = self.moon.rotation_from_j2000(
FRAME_MOON_PA_DE440,
t_hi,
t_lo - sol.light_time_s,
)?;
let ub = crate::naif_kernel::mat_vec(&r, u);
Ok([-ub[0] / den, -ub[1] / den, -ub[2] / den])
};
let (p1, p2) = (part(ecef1)?, part(ecef2)?);
Ok(sub(p2, p1))
}
pub fn delay_partials_stations(
&self,
ecef1: Vec3,
ecef2: Vec3,
body: Vec3,
t_hi: f64,
t_lo: f64,
) -> Result<(Vec3, Vec3), String> {
let z = [0.0; 3];
let (r_e, _) = self.earth.rotation_from_j2000(FRAME_ITRF93, t_hi, t_lo)?;
let part = |ecef: Vec3| -> Result<Vec3, String> {
let sol = self.light_time(ecef, body, t_hi, t_lo, z)?;
let u = sol.unit_beacon_to_station;
let v = sol.beacon_velocity_m_s;
let den = C - (u[0] * v[0] + u[1] * v[1] + u[2] * v[2]);
let ue = crate::naif_kernel::mat_vec(&r_e, u);
Ok([ue[0] / den, ue[1] / den, ue[2] / den])
};
let p1 = part(ecef1)?;
let p2 = part(ecef2)?;
Ok(([-p1[0], -p1[1], -p1[2]], p2))
}
}
fn d_st1_lat() -> f64 {
40.4256 }
fn d_st1_lon() -> f64 {
-116.8893
}
fn d_st1_alt() -> f64 {
1000.0
}
fn d_st2_lat() -> f64 {
-35.4014 }
fn d_st2_lon() -> f64 {
148.9819
}
fn d_st2_alt() -> f64 {
688.0
}
fn d_beacon_lat() -> f64 {
0.0 }
fn d_beacon_lon() -> f64 {
0.0
}
fn d_beacon_alt() -> f64 {
0.0
}
fn d_epoch_year() -> i32 {
2024
}
fn d_epoch_month() -> u32 {
1
}
fn d_epoch_day() -> u32 {
1
}
fn d_horizon_hours() -> f64 {
6.0
}
fn d_step_min() -> f64 {
30.0
}
#[derive(Clone, Debug, serde::Deserialize)]
pub struct LunarVlbiScenario {
#[serde(default = "d_st1_lat")]
pub station1_lat_deg: f64,
#[serde(default = "d_st1_lon")]
pub station1_lon_deg: f64,
#[serde(default = "d_st1_alt")]
pub station1_alt_m: f64,
#[serde(default = "d_st2_lat")]
pub station2_lat_deg: f64,
#[serde(default = "d_st2_lon")]
pub station2_lon_deg: f64,
#[serde(default = "d_st2_alt")]
pub station2_alt_m: f64,
#[serde(default = "d_beacon_lat")]
pub beacon_lat_deg: f64,
#[serde(default = "d_beacon_lon")]
pub beacon_lon_deg: f64,
#[serde(default = "d_beacon_alt")]
pub beacon_alt_m: f64,
#[serde(default = "d_epoch_year")]
pub epoch_year: i32,
#[serde(default = "d_epoch_month")]
pub epoch_month: u32,
#[serde(default = "d_epoch_day")]
pub epoch_day: u32,
#[serde(default = "d_horizon_hours")]
pub horizon_hours: f64,
#[serde(default = "d_step_min")]
pub step_min: f64,
#[serde(default)]
pub planetary_kernel_path: Option<String>,
#[serde(default)]
pub earth_orientation_kernel_path: Option<String>,
#[serde(default)]
pub moon_orientation_kernel_path: Option<String>,
}
impl Default for LunarVlbiScenario {
fn default() -> Self {
LunarVlbiScenario {
station1_lat_deg: d_st1_lat(),
station1_lon_deg: d_st1_lon(),
station1_alt_m: d_st1_alt(),
station2_lat_deg: d_st2_lat(),
station2_lon_deg: d_st2_lon(),
station2_alt_m: d_st2_alt(),
beacon_lat_deg: d_beacon_lat(),
beacon_lon_deg: d_beacon_lon(),
beacon_alt_m: d_beacon_alt(),
epoch_year: d_epoch_year(),
epoch_month: d_epoch_month(),
epoch_day: d_epoch_day(),
horizon_hours: d_horizon_hours(),
step_min: d_step_min(),
planetary_kernel_path: None,
earth_orientation_kernel_path: None,
moon_orientation_kernel_path: None,
}
}
}
pub const UNITS: &[crate::field_schema::FieldUnit] = {
use crate::field_schema::{FieldUnit, ProvenanceClass::*};
&[
FieldUnit {
path: "baseline_km",
unit: "km",
provenance: Computed,
definition: "Earth baseline length |r2 - r1| at the epoch, the two configured \
stations reduced to geocentric inertial (GCRS) coordinates",
},
FieldUnit {
path: "beacon_range_km",
unit: "km",
provenance: Computed,
definition: "geocentric range |r_B| of the lunar-surface beacon at the epoch",
},
FieldUnit {
path: "delay_s",
unit: "s",
provenance: Computed,
definition: "full VLBI delay at the epoch: the near-field two-range difference \
(|r2 - r_B| - |r1 - r_B|)/c plus the differenced Shapiro term, with \
both station clock offsets zero",
},
FieldUnit {
path: "delay_rate_s_per_s",
unit: "s/s",
provenance: Computed,
definition: "one-step forward finite difference of the full delay, \
(delay(t0 + dt) - delay(t0)) / dt; seconds of delay per second of \
time, hence dimensionless",
},
FieldUnit {
path: "near_field_correction_us",
unit: "us",
provenance: Computed,
definition: "wavefront-curvature term at the epoch: the near-field geometric \
delay minus the far-field plane-wave delay -(B.s_B)/c, in \
microseconds",
},
FieldUnit {
path: "samples",
unit: "count",
provenance: Computed,
definition: "epochs in `series`, sampled at `step_min` out to `horizon_hours`",
},
FieldUnit {
path: "min_delay_s",
unit: "s",
provenance: Computed,
definition: "smallest full VLBI delay over the sampled horizon",
},
FieldUnit {
path: "max_delay_s",
unit: "s",
provenance: Computed,
definition: "largest full VLBI delay over the sampled horizon",
},
FieldUnit {
path: "horizon_hours",
unit: "hr",
provenance: Input,
definition: "length of the observed pass",
},
FieldUnit {
path: "series[].t_hours",
unit: "hr",
provenance: Computed,
definition: "offset of this sample from the scenario epoch",
},
FieldUnit {
path: "series[].delay_s",
unit: "s",
provenance: Computed,
definition: "full VLBI delay at this sample, as `delay_s` at the epoch",
},
FieldUnit {
path: "series[].geometric_delay_s",
unit: "s",
provenance: Computed,
definition: "geometric (near-field) two-range-difference delay at this sample, \
(|r2 - r_B| - |r1 - r_B|)/c, without the Shapiro or clock terms",
},
FieldUnit {
path: "series[].near_field_correction_us",
unit: "us",
provenance: Computed,
definition: "wavefront-curvature correction at this sample, in microseconds",
},
FieldUnit {
path: "series[].beacon_range_km",
unit: "km",
provenance: Computed,
definition: "geocentric beacon range at this sample",
},
FieldUnit {
path: "epoch_partials.beacon_body_s_per_m[]",
unit: "s/m",
provenance: Computed,
definition: "kernel path only: partial of the light-time delay at the epoch with \
respect to the beacon's body-fixed position, components along the \
DE440 lunar principal axes",
},
FieldUnit {
path: "epoch_partials.station1_itrf93_s_per_m[]",
unit: "s/m",
provenance: Computed,
definition: "kernel path only: partial of the light-time delay at the epoch with \
respect to station 1's Earth-fixed (ITRF93) position",
},
FieldUnit {
path: "epoch_partials.station2_itrf93_s_per_m[]",
unit: "s/m",
provenance: Computed,
definition: "kernel path only: partial of the light-time delay at the epoch with \
respect to station 2's Earth-fixed (ITRF93) position",
},
]
};
#[derive(Clone, Copy, Debug, serde::Serialize)]
pub struct LunarVlbiSample {
pub t_hours: f64,
pub delay_s: f64,
pub geometric_delay_s: f64,
pub near_field_correction_us: f64,
pub beacon_range_km: f64,
}
#[derive(Clone, Debug, serde::Serialize)]
pub struct LunarVlbiReport {
pub baseline_km: f64,
pub beacon_range_km: f64,
pub delay_s: f64,
pub delay_rate_s_per_s: f64,
pub near_field_correction_us: f64,
pub samples: usize,
pub min_delay_s: f64,
pub max_delay_s: f64,
pub horizon_hours: f64,
pub series: Vec<LunarVlbiSample>,
pub geometry_path: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub epoch_partials: Option<LunarVlbiEpochPartials>,
}
#[derive(Clone, Copy, Debug, serde::Serialize)]
pub struct LunarVlbiEpochPartials {
pub beacon_body_s_per_m: Vec3,
pub station1_itrf93_s_per_m: Vec3,
pub station2_itrf93_s_per_m: Vec3,
}
type SampleGeometry = (f64, f64, f64, f64, f64);
impl LunarVlbiScenario {
fn geodetic1(&self) -> Geodetic {
Geodetic {
lat_rad: self.station1_lat_deg.to_radians(),
lon_rad: self.station1_lon_deg.to_radians(),
alt_m: self.station1_alt_m,
}
}
fn geodetic2(&self) -> Geodetic {
Geodetic {
lat_rad: self.station2_lat_deg.to_radians(),
lon_rad: self.station2_lon_deg.to_radians(),
alt_m: self.station2_alt_m,
}
}
fn beacon_sel(&self) -> Selenographic {
Selenographic {
lat_rad: self.beacon_lat_deg.to_radians(),
lon_rad: self.beacon_lon_deg.to_radians(),
alt_m: self.beacon_alt_m,
}
}
fn geometry_at(&self, t_hours: f64) -> (Vec3, Vec3, Vec3) {
let jd_utc = crate::timescales::julian_date(
self.epoch_year,
self.epoch_month,
self.epoch_day,
0,
0,
0.0,
) + t_hours / 24.0;
let jd_tt = crate::timescales::utc_to_tt(jd_utc);
let jd_ut1 = crate::timescales::utc_to_ut1(jd_utc, 0.0);
let r1 = station_inertial_position(self.geodetic1(), jd_tt, jd_ut1);
let r2 = station_inertial_position(self.geodetic2(), jd_tt, jd_ut1);
let r_b = beacon_inertial_position(self.beacon_sel(), jd_tt);
(r1, r2, r_b)
}
fn kernel_geometry(&self) -> Result<Option<KernelGeometry>, String> {
match (
self.planetary_kernel_path.as_deref(),
self.earth_orientation_kernel_path.as_deref(),
self.moon_orientation_kernel_path.as_deref(),
) {
(None, None, None) => Ok(None),
(Some(spk), Some(earth), Some(moon)) => Ok(Some(KernelGeometry::open(
std::path::Path::new(spk),
std::path::Path::new(earth),
std::path::Path::new(moon),
)?)),
_ => Err("the kernel path needs planetary_kernel_path, \
earth_orientation_kernel_path and moon_orientation_kernel_path together"
.to_string()),
}
}
fn et_at(&self, t_hours: f64) -> (f64, f64) {
let jd_day = crate::timescales::julian_date(
self.epoch_year,
self.epoch_month,
self.epoch_day,
0,
0,
0.0,
);
crate::naif_kernel::naif_et_from_utc(jd_day, t_hours * 3_600.0)
}
fn kernel_points(&self) -> (Vec3, Vec3, Vec3) {
(
crate::frames::geodetic_to_ecef(self.geodetic1()),
crate::frames::geodetic_to_ecef(self.geodetic2()),
crate::lunar::selenographic_to_mcmf(self.beacon_sel()),
)
}
fn sample_at(
&self,
kernel: Option<&KernelGeometry>,
t_hours: f64,
) -> Result<SampleGeometry, String> {
let (r1, r2, r_b, geom) = match kernel {
None => {
let (r1, r2, r_b) = self.geometry_at(t_hours);
(r1, r2, r_b, geometric_delay_s(r1, r2, r_b))
}
Some(k) => {
let (s1, s2, body) = self.kernel_points();
let (hi, lo) = self.et_at(t_hours);
let r1 = k.station_j2000(s1, hi, lo)?;
let r2 = k.station_j2000(s2, hi, lo)?;
let (r_b, _) = k.beacon_geocentric(body, hi, lo)?;
(r1, r2, r_b, k.delay_s(s1, s2, body, hi, lo)?)
}
};
let full = match kernel {
None => vlbi_delay_s(r1, r2, r_b, 0.0, 0.0, true),
Some(_) => {
geom + (vlbi_delay_s(r1, r2, r_b, 0.0, 0.0, true) - geometric_delay_s(r1, r2, r_b))
}
};
let far_field = crate::radiometric::delta_dor(r_b, [0.0, 0.0, 0.0], sub(r2, r1));
Ok((geom, full, geom - far_field, norm(r_b), norm(sub(r2, r1))))
}
pub fn run(&self) -> LunarVlbiReport {
self.try_run().expect("lunar-vlbi scenario")
}
pub fn try_run(&self) -> Result<LunarVlbiReport, String> {
let kernel = self.kernel_geometry()?;
let k = kernel.as_ref();
let step_h = (self.step_min / 60.0).max(1e-6);
let n = (self.horizon_hours / step_h).floor() as usize;
let mut series: Vec<LunarVlbiSample> = Vec::with_capacity(n + 1);
let mut min_delay = f64::INFINITY;
let mut max_delay = f64::NEG_INFINITY;
for i in 0..=n {
let t = i as f64 * step_h;
let (geom, delay, nfc, range_m, _) = self.sample_at(k, t)?;
min_delay = min_delay.min(delay);
max_delay = max_delay.max(delay);
series.push(LunarVlbiSample {
t_hours: t,
delay_s: delay,
geometric_delay_s: geom,
near_field_correction_us: nfc * 1e6,
beacon_range_km: range_m / 1e3,
});
}
let (geom0, delay0, nfc0, range0_m, baseline0_m) = self.sample_at(k, 0.0)?;
let baseline_km = baseline0_m / 1e3;
let beacon_range_km = range0_m / 1e3;
let nfc_us0 = nfc0 * 1e6;
let dt_h = step_h.min(self.horizon_hours.max(step_h));
let (_, delay1, _, _, _) = self.sample_at(k, dt_h)?;
let dt_s = dt_h * 3600.0;
let delay_rate = if dt_s > 0.0 {
(delay1 - delay0) / dt_s
} else {
0.0
};
if series.is_empty() {
min_delay = delay0;
max_delay = delay0;
series.push(LunarVlbiSample {
t_hours: 0.0,
delay_s: delay0,
geometric_delay_s: geom0,
near_field_correction_us: nfc_us0,
beacon_range_km,
});
}
let epoch_partials = match k {
None => None,
Some(k) => {
let (s1, s2, body) = self.kernel_points();
let (hi, lo) = self.et_at(0.0);
let (p1, p2) = k.delay_partials_stations(s1, s2, body, hi, lo)?;
Some(LunarVlbiEpochPartials {
beacon_body_s_per_m: k.delay_partials_beacon_body(s1, s2, body, hi, lo)?,
station1_itrf93_s_per_m: p1,
station2_itrf93_s_per_m: p2,
})
}
};
Ok(LunarVlbiReport {
baseline_km,
beacon_range_km,
delay_s: delay0,
delay_rate_s_per_s: delay_rate,
near_field_correction_us: nfc_us0,
samples: series.len(),
min_delay_s: min_delay,
max_delay_s: max_delay,
horizon_hours: self.horizon_hours,
series,
geometry_path: if k.is_some() { "kernel" } else { "analytic" },
epoch_partials,
})
}
}
pub fn lunar_vlbi_svg(r: &LunarVlbiReport) -> String {
let (w, h) = (820.0_f64, 360.0_f64);
let (ml, mr, mt, mb) = (70.0_f64, 20.0_f64, 36.0_f64, 50.0_f64);
let (pw, ph) = (w - ml - mr, h - mt - mb);
let t_max = r.horizon_hours.max(1e-9);
let y_lo = (r.min_delay_s * 1e6).min(0.0);
let y_hi = (r.max_delay_s * 1e6).max(0.0);
let span = (y_hi - y_lo).max(1e-9);
let xof = |t: f64| ml + (t / t_max) * pw;
let yof = |v_us: f64| mt + ph - ((v_us - y_lo) / span) * 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=\"{FONT_SANS}\" font-size=\"12\" fill=\"{INK_2}\">"
));
svg.push_str(&format!(
"<rect width=\"{w:.0}\" height=\"{h:.0}\" fill=\"{BG}\"/>"
));
svg.push_str(&format!(
"<text x=\"{ml:.0}\" y=\"18\" font-size=\"15\" font-weight=\"bold\">Lunar VLBI delay (baseline {:.0} km, beacon range {:.0} km, near-field {:.1} µs)</text>",
r.baseline_km, r.beacon_range_km, r.near_field_correction_us
));
if r.series.len() >= 2 {
let pts: Vec<String> = r
.series
.iter()
.map(|s| format!("{:.1},{:.1}", xof(s.t_hours), yof(s.delay_s * 1e6)))
.collect();
svg.push_str(&format!(
"<polyline fill=\"none\" stroke=\"{CYAN}\" points=\"{}\"/>",
pts.join(" ")
));
}
let axis_y = mt + ph;
svg.push_str(&format!(
"<line x1=\"{ml:.0}\" y1=\"{mt:.0}\" x2=\"{ml:.0}\" y2=\"{axis_y:.0}\" stroke=\"{AXIS}\"/>"
));
svg.push_str(&format!(
"<line x1=\"{ml:.0}\" y1=\"{axis_y:.0}\" x2=\"{:.0}\" y2=\"{axis_y:.0}\" stroke=\"{AXIS}\"/>",
ml + pw
));
svg.push_str(&format!(
"<text x=\"{ml:.0}\" y=\"{:.0}\" font-size=\"11\">delay {:.3} µs at epoch · {} samples over {:.1} h</text>",
h - 18.0,
r.delay_s * 1e6,
r.samples,
r.horizon_hours
));
svg.push_str("</svg>");
svg
}
#[cfg(test)]
mod tests {
use super::*;
fn jd_tt_2024() -> f64 {
let jd_utc = crate::timescales::julian_date(2024, 1, 1, 0, 0, 0.0);
crate::timescales::utc_to_tt(jd_utc)
}
fn jd_ut1_2024() -> f64 {
let jd_utc = crate::timescales::julian_date(2024, 1, 1, 0, 0, 0.0);
crate::timescales::utc_to_ut1(jd_utc, 0.0)
}
#[test]
fn station_position_is_about_earth_radius() {
let g = Geodetic {
lat_rad: 40.0_f64.to_radians(),
lon_rad: -116.0_f64.to_radians(),
alt_m: 1000.0,
};
let r = station_inertial_position(g, jd_tt_2024(), jd_ut1_2024());
let mag_km = norm(r) / 1e3;
assert!(
(6356.0..6380.0).contains(&mag_km),
"station magnitude {mag_km} km not within Earth-radius band"
);
}
#[test]
fn beacon_range_is_lunar_distance() {
for k in 0..8 {
let jd_tt = jd_tt_2024() + (k as f64) * 3.7;
let sel = Selenographic {
lat_rad: 0.0,
lon_rad: 0.0,
alt_m: 0.0,
};
let r_b = beacon_inertial_position(sel, jd_tt);
let range_km = norm(r_b) / 1e3;
assert!(
(354_000.0..409_000.0).contains(&range_km),
"beacon range {range_km} km at sample {k} not at lunar distance"
);
}
}
#[test]
fn far_field_matches_delta_dor() {
let r1 = [4.0e6, 1.0e6, 4.5e6];
let r2 = [-3.5e6, 2.0e6, -4.0e6];
let r_b = [1.0e15, 0.0, 0.0];
let geom = geometric_delay_s(r1, r2, r_b);
let baseline = sub(r2, r1);
let dor = crate::radiometric::delta_dor(r_b, [0.0, 0.0, 0.0], baseline);
assert!(
(geom - dor).abs() < 1e-9,
"far-field geometric delay {geom} vs delta_dor {dor} differ by {}",
(geom - dor).abs()
);
}
#[test]
fn near_field_correction_has_lunar_magnitude() {
let r1 = station_inertial_position(
Geodetic {
lat_rad: 40.0_f64.to_radians(),
lon_rad: -116.0_f64.to_radians(),
alt_m: 1000.0,
},
jd_tt_2024(),
jd_ut1_2024(),
);
let r2 = station_inertial_position(
Geodetic {
lat_rad: -35.0_f64.to_radians(),
lon_rad: 149.0_f64.to_radians(),
alt_m: 700.0,
},
jd_tt_2024(),
jd_ut1_2024(),
);
let r_b = beacon_inertial_position(
Selenographic {
lat_rad: 0.0,
lon_rad: 0.0,
alt_m: 0.0,
},
jd_tt_2024(),
);
let nfc = near_field_correction_s(r1, r2, r_b);
let nfc_abs_us = nfc.abs() * 1e6;
assert!(
(1.0..2000.0).contains(&nfc_abs_us),
"near-field correction {nfc_abs_us} µs outside [1, 2000] µs"
);
}
#[test]
fn beacon_partials_match_finite_difference() {
let r1 = station_inertial_position(
Geodetic {
lat_rad: 40.0_f64.to_radians(),
lon_rad: -116.0_f64.to_radians(),
alt_m: 1000.0,
},
jd_tt_2024(),
jd_ut1_2024(),
);
let r2 = station_inertial_position(
Geodetic {
lat_rad: -35.0_f64.to_radians(),
lon_rad: 149.0_f64.to_radians(),
alt_m: 700.0,
},
jd_tt_2024(),
jd_ut1_2024(),
);
let r_b = beacon_inertial_position(
Selenographic {
lat_rad: 10.0_f64.to_radians(),
lon_rad: 20.0_f64.to_radians(),
alt_m: 0.0,
},
jd_tt_2024(),
);
let analytic = delay_partials_beacon(r1, r2, r_b);
let dx = 1.0e3;
for axis in 0..3 {
let mut rp = r_b;
let mut rm = r_b;
rp[axis] += dx;
rm[axis] -= dx;
let fd = (geometric_delay_s(r1, r2, rp) - geometric_delay_s(r1, r2, rm)) / (2.0 * dx);
let rel = (analytic[axis] - fd).abs() / fd.abs().max(1e-30);
assert!(
rel < 1e-5,
"beacon partial axis {axis}: analytic {} vs FD {} rel-err {rel}",
analytic[axis],
fd
);
}
}
#[test]
fn station_partials_match_finite_difference() {
let r1 = [4.0e6, 1.0e6, 4.5e6];
let r2 = [-3.5e6, 2.0e6, -4.0e6];
let r_b = beacon_inertial_position(
Selenographic {
lat_rad: 5.0_f64.to_radians(),
lon_rad: -10.0_f64.to_radians(),
alt_m: 0.0,
},
jd_tt_2024(),
);
let p1 = delay_partials_station1(r1, r_b);
let p2 = delay_partials_station2(r2, r_b);
let dx = 1.0e3;
for axis in 0..3 {
let mut r1p = r1;
let mut r1m = r1;
r1p[axis] += dx;
r1m[axis] -= dx;
let fd1 =
(geometric_delay_s(r1p, r2, r_b) - geometric_delay_s(r1m, r2, r_b)) / (2.0 * dx);
let rel1 = (p1[axis] - fd1).abs() / fd1.abs().max(1e-30);
assert!(rel1 < 1e-5, "station1 partial axis {axis} rel-err {rel1}");
let mut r2p = r2;
let mut r2m = r2;
r2p[axis] += dx;
r2m[axis] -= dx;
let fd2 =
(geometric_delay_s(r1, r2p, r_b) - geometric_delay_s(r1, r2m, r_b)) / (2.0 * dx);
let rel2 = (p2[axis] - fd2).abs() / fd2.abs().max(1e-30);
assert!(rel2 < 1e-5, "station2 partial axis {axis} rel-err {rel2}");
}
}
#[test]
fn clock_term_adds_exactly() {
let r1 = [4.0e6, 1.0e6, 4.5e6];
let r2 = [4.0e6, -1.0e6, 4.5e6];
let r_b = [3.0e8, 0.0, 2.0e8];
let base = vlbi_delay_s(r1, r2, r_b, 0.0, 0.0, false);
assert_eq!(
base, 0.0,
"symmetric geometry should give zero geometric delay"
);
let with_clk = vlbi_delay_s(r1, r2, r_b, 0.0, 1.0e-6, false);
assert_eq!(
with_clk - base,
1.0e-6,
"clock term {} did not add exactly 1e-6 s",
with_clk - base
);
}
#[test]
fn scenario_run_is_finite_and_at_lunar_distance() {
let r = LunarVlbiScenario::default().run();
assert!(r.delay_s.is_finite());
assert!(r.delay_rate_s_per_s.is_finite());
assert!(
r.baseline_km > 0.0,
"baseline {} km not positive",
r.baseline_km
);
assert!(
(354_000.0..409_000.0).contains(&r.beacon_range_km),
"scenario beacon range {} km not at lunar distance",
r.beacon_range_km
);
assert!(r.samples >= 1);
assert!(r.min_delay_s.is_finite() && r.max_delay_s.is_finite());
assert!(r.min_delay_s <= r.delay_s && r.delay_s <= r.max_delay_s);
for s in &r.series {
assert!(s.delay_s.is_finite());
assert!((354_000.0..409_000.0).contains(&s.beacon_range_km));
}
}
#[test]
fn svg_is_self_contained() {
let r = LunarVlbiScenario::default().run();
let svg = lunar_vlbi_svg(&r);
assert!(svg.starts_with("<svg"));
assert!(svg.ends_with("</svg>"));
assert!(svg.contains("Lunar VLBI"));
}
fn fixture_kernels() -> (String, String, String) {
let d = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/lunar_vlbi_anise_oracle/kernels/"
);
(
format!("{d}de440s_2024-01-01.bsp"),
format!("{d}earth_itrf93_2024-01-01.bpc"),
format!("{d}moon_pa_de440_2024-01-01.bpc"),
)
}
#[test]
fn kernel_partials_match_a_central_difference_of_the_kernel_delay() {
let (spk, earth, moon) = fixture_kernels();
let k = KernelGeometry::open(
std::path::Path::new(&spk),
std::path::Path::new(&earth),
std::path::Path::new(&moon),
)
.unwrap();
let g = |lat: f64, lon: f64, h: f64| {
crate::frames::geodetic_to_ecef(Geodetic {
lat_rad: lat.to_radians(),
lon_rad: lon.to_radians(),
alt_m: h,
})
};
let s1 = g(40.4256, -116.8893, 1000.0);
let s2 = g(-35.4014, 148.9819, 688.0);
let body = [1_737_400.0, 0.0, 0.0];
let (hi, lo) = crate::naif_kernel::naif_et_from_utc(2_460_310.5, 5.0 * 3_600.0);
let h = 2_000.0;
let fd = |f: &dyn Fn(Vec3) -> f64| -> Vec3 {
let mut out = [0.0; 3];
for (i, o) in out.iter_mut().enumerate() {
let mut e = [0.0; 3];
e[i] = h;
let m = [-e[0], -e[1], -e[2]];
*o = (f(e) - f(m)) / (2.0 * h);
}
out
};
let rel = |a: Vec3, b: Vec3| norm(sub(a, b)) / norm(b);
let pb = k.delay_partials_beacon_body(s1, s2, body, hi, lo).unwrap();
let fb = fd(&|e| k.delay_s(s1, s2, add(body, e), hi, lo).unwrap());
assert!(rel(pb, fb) < 1e-6, "beacon body partials {}", rel(pb, fb));
let (p1, p2) = k.delay_partials_stations(s1, s2, body, hi, lo).unwrap();
let f1 = fd(&|e| k.delay_s(add(s1, e), s2, body, hi, lo).unwrap());
let f2 = fd(&|e| k.delay_s(s1, add(s2, e), body, hi, lo).unwrap());
assert!(rel(p1, f1) < 1e-8, "station 1 partials {}", rel(p1, f1));
assert!(rel(p2, f2) < 1e-8, "station 2 partials {}", rel(p2, f2));
}
#[test]
fn scenario_kernel_path_emits_the_kernel_delay() {
let (spk, earth, moon) = fixture_kernels();
let scn = LunarVlbiScenario {
planetary_kernel_path: Some(spk.clone()),
earth_orientation_kernel_path: Some(earth.clone()),
moon_orientation_kernel_path: Some(moon.clone()),
..LunarVlbiScenario::default()
};
let r = scn.try_run().unwrap();
assert_eq!(r.geometry_path, "kernel");
let k = KernelGeometry::open(
std::path::Path::new(&spk),
std::path::Path::new(&earth),
std::path::Path::new(&moon),
)
.unwrap();
let (s1, s2, body) = scn.kernel_points();
assert_eq!(body, [1_737_400.0, 0.0, 0.0]);
for s in &r.series {
let (hi, lo) = crate::naif_kernel::naif_et_from_utc(2_460_310.5, s.t_hours * 3_600.0);
let want = k.delay_s(s1, s2, body, hi, lo).unwrap();
assert_eq!(s.geometric_delay_s, want);
assert!(
(s.delay_s - s.geometric_delay_s).abs() < 1e-9,
"Shapiro is sub-ns"
);
}
let p = r.epoch_partials.expect("kernel path partials");
let (hi, lo) = crate::naif_kernel::naif_et_from_utc(2_460_310.5, 0.0);
assert_eq!(
p.beacon_body_s_per_m,
k.delay_partials_beacon_body(s1, s2, body, hi, lo).unwrap()
);
let a = LunarVlbiScenario::default().try_run().unwrap();
assert_eq!(a.geometry_path, "analytic");
assert!(a.epoch_partials.is_none());
let gap = (a.series[0].geometric_delay_s - r.series[0].geometric_delay_s).abs();
assert!((1e-7..1e-4).contains(&gap), "analytic vs kernel {gap:e} s");
let partial = LunarVlbiScenario {
moon_orientation_kernel_path: None,
..scn
};
assert!(partial.try_run().unwrap_err().contains("together"));
}
#[test]
fn run_toml_lunar_vlbi_dispatches() {
let out = crate::api::run_toml("kind=\"lunar-vlbi\"\n").unwrap();
assert!(
out.summary.contains("lunar-vlbi"),
"summary missing kind: {}",
out.summary
);
let j: serde_json::Value = serde_json::from_str(&out.json).unwrap();
assert!(j["beacon_range_km"].as_f64().unwrap() > 300_000.0);
assert!(out.svg.starts_with("<svg"));
}
}