use crate::frames::{
arcsec, ecef_to_geodetic, ecef_to_teme, geodetic_to_ecef, look_angles, teme_to_itrf, Geodetic,
};
use crate::orbit::{OrbitCfg, Propagator};
use crate::rinex::EpochUtc;
use crate::timescales::{julian_date, utc_to_tt, utc_to_ut1};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
type Vec3 = [f64; 3];
const C_M_S: f64 = 299_792_458.0;
const GPS_L1_HZ: f64 = 1_575_420_000.0;
const GMST_RATE_RAD_S: f64 = 7.292_115_855_3e-5;
const JD_1950_ORIGIN: f64 = 2_433_281.5;
#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
pub struct StationCfg {
pub lat_deg: f64,
pub lon_deg: f64,
#[serde(default)]
pub alt_m: f64,
}
impl StationCfg {
fn geodetic(&self) -> Geodetic {
Geodetic {
lat_rad: self.lat_deg.to_radians(),
lon_rad: self.lon_deg.to_radians(),
alt_m: self.alt_m,
}
}
}
fn default_step_s() -> f64 {
60.0
}
fn default_duration_s() -> f64 {
5_400.0
}
fn default_carrier_hz() -> f64 {
GPS_L1_HZ
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct EphemerisScenario {
#[serde(default)]
pub kind: Option<String>,
#[serde(default)]
pub tle: Option<String>,
#[serde(default)]
pub orbit: Option<OrbitCfg>,
#[serde(default)]
pub epoch: Option<EpochUtc>,
#[serde(default = "default_step_s")]
pub step_s: f64,
#[serde(default = "default_duration_s")]
pub duration_s: f64,
#[serde(default)]
pub station: Option<StationCfg>,
#[serde(default)]
pub dut1_s: f64,
#[serde(default)]
pub xp_arcsec: f64,
#[serde(default)]
pub yp_arcsec: f64,
#[serde(default = "default_carrier_hz")]
pub carrier_hz: f64,
}
#[derive(Clone, Copy, Debug, Serialize)]
pub struct StationView {
pub az_deg: f64,
pub el_deg: f64,
pub range_km: f64,
pub range_rate_m_s: f64,
pub doppler_hz: f64,
pub visible: bool,
}
#[derive(Clone, Debug, Serialize)]
pub struct EphemSample {
pub t_s: f64,
pub jd_utc: f64,
pub teme_r_m: Vec3,
pub teme_v_m_s: Vec3,
pub gcrs_r_m: Vec3,
pub gcrs_v_m_s: Vec3,
pub ecef_r_m: Vec3,
pub lat_deg: f64,
pub lon_deg: f64,
pub alt_km: f64,
pub speed_m_s: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub station_view: Option<StationView>,
}
#[derive(Clone, Debug, Serialize)]
pub struct EphemerisResult {
pub scenario_hash: String,
pub source: String,
pub jd_utc0: f64,
pub n_samples: usize,
pub lat_min_deg: f64,
pub lat_max_deg: f64,
pub alt_min_km: f64,
pub alt_max_km: f64,
pub speed_min_m_s: f64,
pub speed_max_m_s: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_elevation_deg: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub peak_doppler_hz: Option<f64>,
pub samples: Vec<EphemSample>,
}
fn norm(v: Vec3) -> f64 {
(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt()
}
fn build(scn: &EphemerisScenario) -> Result<(Propagator, f64, String), String> {
if let Some(tle) = &scn.tle {
let lines: Vec<&str> = tle
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.collect();
let l1 = lines
.iter()
.find(|l| l.starts_with("1 "))
.ok_or("TLE is missing line 1 (a line starting `1 `)")?;
let l2 = lines
.iter()
.find(|l| l.starts_with("2 "))
.ok_or("TLE is missing line 2 (a line starting `2 `)")?;
let t = crate::tle::parse_tle(l1, l2)?;
let jd0 = match &scn.epoch {
Some(e) => julian_date(e.year, e.month, e.day, e.hour, e.minute, e.second),
None => t.epoch_days_1950 + JD_1950_ORIGIN,
};
let prop = Propagator::Sgp4(Box::new(t.to_sgp4(crate::sgp4::wgs72(), false)));
Ok((prop, jd0, "sgp4 (TLE)".to_string()))
} else if let Some(o) = &scn.orbit {
let e = scn
.epoch
.as_ref()
.ok_or("an analytic `orbit` ephemeris requires an `epoch`")?;
let jd0 = julian_date(e.year, e.month, e.day, e.hour, e.minute, e.second);
Ok((Propagator::Kepler(o.to_orbit()), jd0, "kepler".to_string()))
} else {
Err("ephemeris scenario needs either a `tle` or an `orbit`".to_string())
}
}
pub fn run_ephemeris(scn: &EphemerisScenario) -> Result<EphemerisResult, String> {
if !scn.step_s.is_finite() || scn.step_s <= 0.0 {
return Err("step_s must be positive".to_string());
}
if scn.duration_s < 0.0 {
return Err("duration_s must be non-negative".to_string());
}
let (prop, jd_utc0, source) = build(scn)?;
let station = scn.station;
let lambda_m = C_M_S / scn.carrier_hz;
let (xp, yp) = (arcsec(scn.xp_arcsec), arcsec(scn.yp_arcsec));
let n = (scn.duration_s / scn.step_s).round() as usize;
let mut samples = Vec::with_capacity(n + 1);
let (mut lat_min, mut lat_max) = (f64::INFINITY, f64::NEG_INFINITY);
let (mut alt_min, mut alt_max) = (f64::INFINITY, f64::NEG_INFINITY);
let (mut spd_min, mut spd_max) = (f64::INFINITY, f64::NEG_INFINITY);
let mut max_el: Option<f64> = None;
let mut peak_dopp: Option<f64> = None;
for i in 0..=n {
let t = i as f64 * scn.step_s;
let jd_utc = jd_utc0 + t / 86_400.0;
let jd_ut1 = utc_to_ut1(jd_utc, scn.dut1_s);
let jd_tt = utc_to_tt(jd_utc);
let st = prop.state_eci(t); let gcrs = prop.state_gcrs(t, jd_tt); let ecef = teme_to_itrf(st.r_m, jd_ut1, xp, yp, jd_tt);
let geod = ecef_to_geodetic(ecef);
let (lat_deg, lon_deg, alt_km) = (
geod.lat_rad.to_degrees(),
geod.lon_rad.to_degrees(),
geod.alt_m / 1000.0,
);
let speed = norm(st.v_m_s);
let station_view = station.map(|s| {
let st_geod = s.geodetic();
let look = look_angles(st_geod, ecef);
let s_teme = ecef_to_teme(geodetic_to_ecef(st_geod), jd_ut1);
let v_station = [
-GMST_RATE_RAD_S * s_teme[1],
GMST_RATE_RAD_S * s_teme[0],
0.0,
];
let d = [
st.r_m[0] - s_teme[0],
st.r_m[1] - s_teme[1],
st.r_m[2] - s_teme[2],
];
let rng = norm(d);
let v_rel = [
st.v_m_s[0] - v_station[0],
st.v_m_s[1] - v_station[1],
st.v_m_s[2] - v_station[2],
];
let range_rate = if rng > 0.0 {
(v_rel[0] * d[0] + v_rel[1] * d[1] + v_rel[2] * d[2]) / rng
} else {
0.0
};
let doppler = -range_rate / lambda_m;
let el_deg = look.el_rad.to_degrees();
if el_deg >= 0.0 {
max_el = Some(max_el.map_or(el_deg, |m: f64| m.max(el_deg)));
let ad = doppler.abs();
peak_dopp = Some(peak_dopp.map_or(ad, |m: f64| m.max(ad)));
}
StationView {
az_deg: look.az_rad.to_degrees(),
el_deg,
range_km: look.range_m / 1000.0,
range_rate_m_s: range_rate,
doppler_hz: doppler,
visible: el_deg >= 0.0,
}
});
lat_min = lat_min.min(lat_deg);
lat_max = lat_max.max(lat_deg);
alt_min = alt_min.min(alt_km);
alt_max = alt_max.max(alt_km);
spd_min = spd_min.min(speed);
spd_max = spd_max.max(speed);
samples.push(EphemSample {
t_s: t,
jd_utc,
teme_r_m: st.r_m,
teme_v_m_s: st.v_m_s,
gcrs_r_m: gcrs.r_m,
gcrs_v_m_s: gcrs.v_m_s,
ecef_r_m: ecef,
lat_deg,
lon_deg,
alt_km,
speed_m_s: speed,
station_view,
});
}
let mut hasher = Sha256::new();
hasher.update(serde_json::to_string(scn).unwrap_or_default().as_bytes());
let scenario_hash = format!("{:x}", hasher.finalize());
Ok(EphemerisResult {
scenario_hash,
source,
jd_utc0,
n_samples: samples.len(),
lat_min_deg: lat_min,
lat_max_deg: lat_max,
alt_min_km: alt_min,
alt_max_km: alt_max,
speed_min_m_s: spd_min,
speed_max_m_s: spd_max,
max_elevation_deg: max_el,
peak_doppler_hz: peak_dopp,
samples,
})
}
fn esc(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
pub fn to_svg(r: &EphemerisResult) -> String {
let (w, h) = (720.0, 360.0);
let proj = |lon: f64, lat: f64| -> (f64, f64) {
((lon + 180.0) / 360.0 * w, (90.0 - lat) / 180.0 * h)
};
let mut svg = format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{w}\" height=\"{h}\" viewBox=\"0 0 {w} {h}\">\
<rect width=\"{w}\" height=\"{h}\" fill=\"#0c0b08\"/>"
);
for ring in crate::worldmap::LAND {
let mut d = String::new();
let mut prev_lon = f64::NAN;
for &(rlon, rlat) in ring.iter() {
let (rlon, rlat) = (rlon as f64, rlat as f64);
let (x, y) = proj(rlon, rlat);
let cmd = if !prev_lon.is_finite() || (rlon - prev_lon).abs() > 180.0 {
'M'
} else {
'L'
};
d.push_str(&format!("{cmd}{x:.1} {y:.1}"));
prev_lon = rlon;
}
d.push('Z');
svg.push_str(&format!(
"<path d=\"{d}\" fill=\"#201a11\" stroke=\"#39301f\" stroke-width=\"0.5\"/>"
));
}
let mut lon = -180.0;
while lon <= 180.0 {
let (x, _) = proj(lon, 0.0);
svg.push_str(&format!(
"<line x1=\"{x:.1}\" y1=\"0\" x2=\"{x:.1}\" y2=\"{h}\" stroke=\"#262019\" stroke-width=\"1\"/>"
));
lon += 30.0;
}
let mut lat = -90.0;
while lat <= 90.0 {
let (_, y) = proj(0.0, lat);
let col = if lat == 0.0 { "#342c21" } else { "#262019" };
svg.push_str(&format!(
"<line x1=\"0\" y1=\"{y:.1}\" x2=\"{w}\" y2=\"{y:.1}\" stroke=\"{col}\" stroke-width=\"1\"/>"
));
lat += 30.0;
}
let mut seg = String::new();
let mut prev_lon = f64::NAN;
let flush = |svg: &mut String, seg: &mut String| {
if seg.split(' ').filter(|s| !s.is_empty()).count() >= 2 {
svg.push_str(&format!(
"<polyline points=\"{}\" fill=\"none\" stroke=\"#e0bd84\" stroke-width=\"1.8\"/>",
seg.trim()
));
}
seg.clear();
};
for s in &r.samples {
if prev_lon.is_finite() && (s.lon_deg - prev_lon).abs() > 180.0 {
flush(&mut svg, &mut seg);
}
let (x, y) = proj(s.lon_deg, s.lat_deg);
seg.push_str(&format!("{x:.1},{y:.1} "));
prev_lon = s.lon_deg;
}
flush(&mut svg, &mut seg);
if let Some(s0) = r.samples.first() {
let (x, y) = proj(s0.lon_deg, s0.lat_deg);
svg.push_str(&format!(
"<circle cx=\"{x:.1}\" cy=\"{y:.1}\" r=\"3.5\" fill=\"#f1ece2\" stroke=\"#0c0b08\" stroke-width=\"0.8\"/>"
));
}
let title = format!(
"{} ground track · {} samples · alt {:.0}–{:.0} km · |lat| ≤ {:.1}°",
r.source,
r.n_samples,
r.alt_min_km,
r.alt_max_km,
r.lat_max_deg.abs().max(r.lat_min_deg.abs()),
);
svg.push_str(&format!(
"<text x=\"10\" y=\"20\" fill=\"#e6edf3\" font-family=\"sans-serif\" font-size=\"13\">{}</text>",
esc(&title)
));
svg.push_str("</svg>");
svg
}
#[cfg(test)]
mod tests {
use super::*;
const ISS_TLE: &str = "\
1 25544U 98067A 20045.18587073 .00000950 00000-0 25302-4 0 9990
2 25544 51.6443 242.0161 0004885 264.6060 207.3845 15.49165514212791";
fn iss_scenario() -> EphemerisScenario {
EphemerisScenario {
kind: Some("ephemeris".into()),
tle: Some(ISS_TLE.into()),
orbit: None,
epoch: None,
step_s: 30.0,
duration_s: 5_580.0, station: None,
dut1_s: 0.0,
xp_arcsec: 0.0,
yp_arcsec: 0.0,
carrier_hz: GPS_L1_HZ,
}
}
#[test]
fn ground_track_latitude_stays_within_inclination_band() {
let r = run_ephemeris(&iss_scenario()).unwrap();
let max_abs_lat = r.lat_max_deg.abs().max(r.lat_min_deg.abs());
assert!(
max_abs_lat <= 52.0,
"max |lat| {max_abs_lat:.2}° exceeds the 51.64° ISS inclination band"
);
assert!(
max_abs_lat > 50.0,
"ground track only reached |lat| {max_abs_lat:.2}°"
);
}
#[test]
fn altitude_and_speed_are_in_the_iss_leo_regime() {
let r = run_ephemeris(&iss_scenario()).unwrap();
assert!(
r.alt_min_km > 350.0 && r.alt_max_km < 470.0,
"alt {:.0}–{:.0} km",
r.alt_min_km,
r.alt_max_km
);
assert!(
r.speed_min_m_s > 7_400.0 && r.speed_max_m_s < 7_900.0,
"speed {:.0}–{:.0} m/s",
r.speed_min_m_s,
r.speed_max_m_s
);
}
#[test]
fn frames_preserve_magnitude_and_velocity_is_nonzero() {
let r = run_ephemeris(&iss_scenario()).unwrap();
for s in &r.samples {
let rt = norm(s.teme_r_m);
assert!((norm(s.gcrs_r_m) - rt).abs() < 1.0, "|r| GCRS vs TEME");
assert!((norm(s.ecef_r_m) - rt).abs() < 1.0, "|r| ECEF vs TEME");
assert!(
(norm(s.gcrs_v_m_s) - norm(s.teme_v_m_s)).abs() < 1e-3,
"|v| GCRS vs TEME"
);
assert!(
norm(s.teme_v_m_s) > 1_000.0,
"velocity must be exposed and non-zero"
);
}
}
#[test]
fn ground_track_longitude_regresses_westward_over_a_revolution() {
let r = run_ephemeris(&iss_scenario()).unwrap();
let spread = r
.samples
.windows(2)
.map(|w| (w[1].lon_deg - w[0].lon_deg).abs())
.filter(|d| *d < 180.0) .fold(0.0_f64, f64::max);
assert!(spread > 0.1, "ground track longitude is not advancing");
}
fn worst_range_rate_vs_fd(scn: &EphemerisScenario) -> (f64, usize) {
let r = run_ephemeris(scn).unwrap();
let st = scn.station.unwrap().geodetic();
let (prop, _, _) = build(scn).unwrap();
let range_at = |t: f64| -> f64 {
let jd_utc = r.jd_utc0 + t / 86_400.0;
let jd_ut1 = utc_to_ut1(jd_utc, scn.dut1_s);
let jd_tt = utc_to_tt(jd_utc);
let ecef = teme_to_itrf(prop.state_eci(t).r_m, jd_ut1, 0.0, 0.0, jd_tt);
look_angles(st, ecef).range_m
};
let h = 0.25_f64;
let deriv = |t: f64| -> f64 {
let d1 = (range_at(t + h) - range_at(t - h)) / (2.0 * h);
let d2 = (range_at(t + 2.0 * h) - range_at(t - 2.0 * h)) / (4.0 * h);
(4.0 * d1 - d2) / 3.0
};
let (mut worst, mut checked) = (0.0_f64, 0usize);
for s in &r.samples {
if s.t_s < 2.0 * h || s.t_s > scn.duration_s - 2.0 * h {
continue;
}
worst = worst.max((deriv(s.t_s) - s.station_view.unwrap().range_rate_m_s).abs());
checked += 1;
}
(worst, checked)
}
#[test]
fn exposed_velocity_is_the_true_position_derivative_kepler() {
let scn = EphemerisScenario {
kind: Some("ephemeris".into()),
tle: None,
orbit: Some(OrbitCfg {
altitude_km: 550.0,
inclination_deg: 53.0,
raan_deg: 0.0,
u0_deg: 0.0,
eccentricity: 0.0,
argp_deg: 0.0,
j2: false,
}),
epoch: Some(EpochUtc {
year: 2024,
month: 1,
day: 1,
hour: 0,
minute: 0,
second: 0.0,
}),
step_s: 30.0,
duration_s: 3_000.0,
station: None,
dut1_s: 0.0,
xp_arcsec: 0.0,
yp_arcsec: 0.0,
carrier_hz: GPS_L1_HZ,
};
let r = run_ephemeris(&scn).unwrap();
let (prop, _, _) = build(&scn).unwrap();
let rad_at = |t: f64| norm(prop.state_eci(t).r_m);
let h = 0.25_f64;
let deriv = |t: f64| -> f64 {
let d1 = (rad_at(t + h) - rad_at(t - h)) / (2.0 * h);
let d2 = (rad_at(t + 2.0 * h) - rad_at(t - 2.0 * h)) / (4.0 * h);
(4.0 * d1 - d2) / 3.0
};
let (mut worst, mut checked) = (0.0_f64, 0usize);
for s in &r.samples {
if s.t_s < 2.0 * h || s.t_s > scn.duration_s - 2.0 * h {
continue;
}
let rmag = norm(s.teme_r_m);
let pred = (s.teme_v_m_s[0] * s.teme_r_m[0]
+ s.teme_v_m_s[1] * s.teme_r_m[1]
+ s.teme_v_m_s[2] * s.teme_r_m[2])
/ rmag; worst = worst.max((pred - deriv(s.t_s)).abs());
checked += 1;
}
assert!(checked > 50, "not enough interior samples ({checked})");
eprintln!("kepler worst |v·r̂ − d|r|/dt| = {worst:.2e} m/s over {checked} samples");
assert!(
worst < 2e-3,
"exposed velocity ≠ position derivative: {worst:.3e} m/s (> 2 mm/s)"
);
}
#[test]
fn station_range_rate_matches_range_derivative_within_jd_resolution() {
let kepler = EphemerisScenario {
kind: Some("ephemeris".into()),
tle: None,
orbit: Some(OrbitCfg {
altitude_km: 550.0,
inclination_deg: 53.0,
raan_deg: 0.0,
u0_deg: 0.0,
eccentricity: 0.0,
argp_deg: 0.0,
j2: false,
}),
epoch: Some(EpochUtc {
year: 2024,
month: 1,
day: 1,
hour: 0,
minute: 0,
second: 0.0,
}),
step_s: 30.0,
duration_s: 3_000.0,
station: Some(StationCfg {
lat_deg: 20.0,
lon_deg: 10.0,
alt_m: 100.0,
}),
dut1_s: 0.0,
xp_arcsec: 0.0,
yp_arcsec: 0.0,
carrier_hz: GPS_L1_HZ,
};
let (wk, ck) = worst_range_rate_vs_fd(&kepler);
eprintln!("kepler station worst |range_rate − FD| = {wk:.2e} m/s over {ck} samples");
assert!(
ck > 50 && wk < 0.06,
"kepler station range-rate vs FD: {wk:.3e} m/s"
);
let mut sgp4 = iss_scenario();
let probe = run_ephemeris(&sgp4).unwrap();
let mid = &probe.samples[probe.samples.len() / 2];
let st_lat = (mid.lat_deg + 25.0).clamp(-80.0, 80.0);
sgp4.station = Some(StationCfg {
lat_deg: st_lat,
lon_deg: mid.lon_deg,
alt_m: 100.0,
});
sgp4.step_s = 30.0;
sgp4.duration_s = 3_000.0;
let (ws, cs) = worst_range_rate_vs_fd(&sgp4);
eprintln!("sgp4 station worst |range_rate − FD| = {ws:.2e} m/s over {cs} samples");
assert!(
cs > 50 && ws < 0.06,
"sgp4 station range-rate vs FD: {ws:.3e} m/s"
);
}
#[test]
fn doppler_sign_and_magnitude_are_physical() {
let mut scn = iss_scenario();
let probe = run_ephemeris(&scn).unwrap();
let mid = &probe.samples[probe.samples.len() / 2];
scn.station = Some(StationCfg {
lat_deg: mid.lat_deg,
lon_deg: mid.lon_deg,
alt_m: 0.0,
});
scn.step_s = 5.0;
let r = run_ephemeris(&scn).unwrap();
let dopps: Vec<f64> = r
.samples
.iter()
.filter_map(|s| s.station_view)
.filter(|v| v.visible)
.map(|v| v.doppler_hz)
.collect();
assert!(!dopps.is_empty(), "no visible pass found");
let peak = dopps.iter().fold(0.0_f64, |m, d| m.max(d.abs()));
assert!(
(8_000.0..70_000.0).contains(&peak),
"peak Doppler {peak:.0} Hz out of LEO L1 band"
);
assert!(
dopps.iter().any(|d| *d > 0.0) && dopps.iter().any(|d| *d < 0.0),
"Doppler should change sign across TCA"
);
}
#[test]
fn analytic_orbit_path_runs_with_an_explicit_epoch() {
let scn = EphemerisScenario {
kind: Some("ephemeris".into()),
tle: None,
orbit: Some(OrbitCfg {
altitude_km: 550.0,
inclination_deg: 53.0,
raan_deg: 0.0,
u0_deg: 0.0,
eccentricity: 0.0,
argp_deg: 0.0,
j2: true,
}),
epoch: Some(EpochUtc {
year: 2024,
month: 1,
day: 1,
hour: 0,
minute: 0,
second: 0.0,
}),
step_s: 60.0,
duration_s: 5_700.0,
station: None,
dut1_s: 0.0,
xp_arcsec: 0.0,
yp_arcsec: 0.0,
carrier_hz: GPS_L1_HZ,
};
let r = run_ephemeris(&scn).unwrap();
assert_eq!(r.source, "kepler");
assert!(r.lat_max_deg.abs().max(r.lat_min_deg.abs()) > 50.0);
assert!(r.speed_min_m_s > 7_000.0);
}
#[test]
fn analytic_orbit_without_epoch_is_an_error() {
let mut scn = iss_scenario();
scn.tle = None;
scn.orbit = Some(OrbitCfg {
altitude_km: 500.0,
inclination_deg: 45.0,
raan_deg: 0.0,
u0_deg: 0.0,
eccentricity: 0.0,
argp_deg: 0.0,
j2: false,
});
scn.epoch = None;
assert!(run_ephemeris(&scn).is_err());
}
#[test]
fn svg_renders_and_hash_is_stable() {
let r = run_ephemeris(&iss_scenario()).unwrap();
let svg = to_svg(&r);
assert!(svg.starts_with("<svg") && svg.trim_end().ends_with("</svg>"));
assert!(svg.contains("polyline"), "ground track polyline present");
let land_paths = svg.matches("<path").count();
assert!(
land_paths >= 50,
"expected the world landmasses to be drawn, found only {land_paths} <path> elements"
);
assert_eq!(
land_paths,
crate::worldmap::LAND.len(),
"one path per land ring"
);
assert!(!svg.contains("kshana.dev"));
assert_eq!(
r.scenario_hash,
run_ephemeris(&iss_scenario()).unwrap().scenario_hash
);
}
}