open-standards/map/map-rs/crates/map-orbital/src/sgp4.rs
Transaction Science ba1212361f Add aac, map, sandbox standards; catalog the standards family
Commit the previously-untracked standard directories and catalog them in the
root README alongside proof:

- aac/    — communication cargo class: portable boards + a ten-level
            offline-first cascade (Apache-2.0 code / CC-BY-4.0 spec).
- map/    — geospatial cargo class: range-requestable signed tilesets +
            an F0–F4 fidelity cascade (Apache-2.0 code / CC-BY-4.0 spec).
- sandbox/— isolated, metered, attested execution across the heterogeneous
            compute fabric (Apache-2.0 code / CC-BY-4.0 spec).
- jouleclaw-otel-authz, jouleclaw-trust — new jouleclaw reference crates.

Root README now lists aac, map, sandbox, and proof in the catalog, the tree,
and the contribution guide.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-07 17:04:36 -04:00

679 lines
23 KiB
Rust

//! SGP4 orbital propagator for near-Earth satellites.
//!
//! Implements the Simplified General Perturbations 4 (SGP4) model from
//! Vallado et al., "Revisiting Spacetrack Report #3" (2006). This is the
//! standard propagator for NORAD Two-Line Element sets.
//!
//! For deep-space satellites (period >= 225 min), see [`crate::sdp4`].
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::constants::*;
use crate::error::{OrbitalError, OrbitalResult};
use crate::julian::{datetime_to_jd, epoch_to_jd};
use crate::sdp4::DeepSpaceState;
use crate::tle::TleElements;
/// Position and velocity in the True Equator Mean Equinox (TEME) frame.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct EciPosition {
/// X position (km).
pub x: f64,
/// Y position (km).
pub y: f64,
/// Z position (km).
pub z: f64,
/// X velocity (km/s).
pub vx: f64,
/// Y velocity (km/s).
pub vy: f64,
/// Z velocity (km/s).
pub vz: f64,
}
impl EciPosition {
/// Distance from Earth center (km).
pub fn radius(&self) -> f64 {
(self.x * self.x + self.y * self.y + self.z * self.z).sqrt()
}
/// Speed (km/s).
pub fn speed(&self) -> f64 {
(self.vx * self.vx + self.vy * self.vy + self.vz * self.vz).sqrt()
}
}
/// Propagation method: near-Earth (SGP4) or deep-space (SDP4).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) enum PropagationMethod {
Near,
Deep,
}
/// Initialized SGP4/SDP4 state, ready for propagation.
///
/// Created from a TLE via [`Sgp4State::initialize`]. All the expensive
/// precomputation happens once at initialization. Propagation to any
/// time is then a cheap arithmetic operation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Sgp4State {
/// Original TLE elements.
pub tle: TleElements,
/// Near-earth or deep-space method.
pub(crate) method: PropagationMethod,
// ── Precomputed orbital elements (Kozai) ─────────────────
pub(crate) no_kozai: f64,
pub(crate) a0: f64,
pub(crate) ecco: f64,
pub(crate) inclo: f64,
pub(crate) nodeo: f64,
pub(crate) argpo: f64,
pub(crate) mo: f64,
pub(crate) bstar: f64,
// ── Secular rate coefficients ────────────────────────────
pub(crate) mdot: f64,
pub(crate) argpdot: f64,
pub(crate) nodedot: f64,
pub(crate) nodecf: f64,
// ── Near-earth drag coefficients ─────────────────────────
pub(crate) cc1: f64,
pub(crate) cc4: f64,
pub(crate) cc5: f64,
pub(crate) d2: f64,
pub(crate) d3: f64,
pub(crate) d4: f64,
pub(crate) eta: f64,
pub(crate) omgcof: f64,
pub(crate) sinmao: f64,
pub(crate) t2cof: f64,
pub(crate) t3cof: f64,
pub(crate) t4cof: f64,
pub(crate) t5cof: f64,
pub(crate) x1mth2: f64,
pub(crate) x7thm1: f64,
pub(crate) xlcof: f64,
pub(crate) xmcof: f64,
pub(crate) xnodcf: f64,
pub(crate) con41: f64,
// ── Perigee flags ────────────────────────────────────────
pub(crate) isimp: bool,
// ── Epoch JD for time calculation ────────────────────────
pub(crate) epoch_jd: f64,
// ── Deep-space state (only for SDP4) ─────────────────────
pub(crate) deep: Option<DeepSpaceState>,
}
impl Sgp4State {
/// Initialize the SGP4/SDP4 propagator from a TLE.
///
/// This performs the equivalent of `sgp4init` from the Vallado reference
/// implementation. All secular and periodic perturbation terms are
/// precomputed for fast propagation.
pub fn initialize(tle: &TleElements) -> OrbitalResult<Self> {
// Validate eccentricity
if tle.eccentricity < 0.0 || tle.eccentricity >= 1.0 {
return Err(OrbitalError::InvalidEccentricity(tle.eccentricity));
}
let ecco = tle.eccentricity;
let inclo = tle.inclination;
let nodeo = tle.raan;
let argpo = tle.arg_perigee;
let mo = tle.mean_anomaly;
let bstar = tle.bstar;
// Convert mean motion from rev/day to rad/min
let no_unkozai = tle.mean_motion * TWOPI / MINUTES_PER_DAY;
let epoch_jd = epoch_to_jd(tle.epoch_year, tle.epoch_day);
// ── Recover original mean motion (no_kozai) and semi-major axis ──
let cosio = inclo.cos();
let sinio = inclo.sin();
let cosio2 = cosio * cosio;
let x1mth2 = 1.0 - cosio2;
let x7thm1 = 7.0 * cosio2 - 1.0;
let _con41 = -con41_calc(cosio2);
// Intermediate values for Kozai correction
let a1 = (XKE / no_unkozai).powf(2.0 / 3.0);
let d1 = 0.75 * J2 * (3.0 * cosio2 - 1.0) / ((1.0 - ecco * ecco).sqrt().powi(3));
let del_ = d1 / (a1 * a1);
let adel = a1
* (1.0 - del_ * del_ - del_ * (1.0 / 3.0 + 134.0 * del_ * del_ / 81.0));
let del_ = d1 / (adel * adel);
let no_kozai = no_unkozai / (1.0 + del_);
let ao = (XKE / no_kozai).powf(2.0 / 3.0);
let po = ao * (1.0 - ecco * ecco);
// Perigee and apogee altitudes
let rp = ao * (1.0 - ecco);
if rp < 1.0 {
return Err(OrbitalError::SatelliteDecayed);
}
let perige = (rp - 1.0) * EARTH_RADIUS_KM;
// Determine near-earth vs deep-space
let period = TWOPI / no_kozai; // minutes
let method = if period >= DEEP_SPACE_THRESHOLD_MIN {
PropagationMethod::Deep
} else {
PropagationMethod::Near
};
// ── SGP4 atmospheric drag and gravitational coefficients ──
// s and qzms constants based on perigee altitude
let mut sfour = 78.0 / EARTH_RADIUS_KM + 1.0;
let mut qzms24 = ((120.0 - 78.0) / EARTH_RADIUS_KM).powi(4);
if perige < 156.0 {
sfour = if perige < 98.0 {
20.0 / EARTH_RADIUS_KM + 1.0
} else {
perige / EARTH_RADIUS_KM + 1.0
};
let qzms24temp = ((120.0 - (sfour - 1.0) * EARTH_RADIUS_KM) / EARTH_RADIUS_KM).powi(4);
qzms24 = qzms24temp;
}
let pinvsq = 1.0 / (po * po);
let tsi = 1.0 / (ao - sfour);
let eta = ao * ecco * tsi;
let etasq = eta * eta;
let eeta = ecco * eta;
let psisq = (1.0 - etasq).abs();
let coef = qzms24 * tsi.powi(4);
let coef1 = coef / psisq.powf(3.5);
let cc2 = coef1
* no_kozai
* (ao * (1.0 + 1.5 * etasq + eeta * (4.0 + etasq))
+ 0.375 * J2 * tsi / psisq * (3.0 * (1.0 - 3.0 * cosio2) + 1.0)
* (8.0 + 3.0 * etasq * (8.0 + etasq)));
let cc1 = bstar * cc2;
let cc3 = if ecco > 1.0e-4 {
-2.0 * coef * tsi * J3OJ2 * no_kozai * sinio / ecco
} else {
0.0
};
let cc4 = 2.0
* no_kozai
* coef1
* ao
* po.recip()
* (eta * (2.0 + 0.5 * etasq)
+ ecco * (0.5 + 2.0 * etasq)
- J2 * tsi / (ao * psisq)
* (-3.0 * (1.0 - 3.0 * cosio2)
* (1.0 - 2.0 * eeta + etasq * (1.5 - 0.5 * eeta))
+ 0.75
* x1mth2
* (2.0 * etasq - eeta * (1.0 + etasq))
* (2.0 * argpo).cos()));
let cc5 = 2.0 * coef1 * ao * po.recip() * (1.0 + 2.75 * (etasq + eeta) + eeta * etasq);
let temp1 = 1.5 * J2 * pinvsq * no_kozai;
let temp2 = 0.5 * temp1 * J2 * pinvsq;
let temp3 = -0.46875 * J4 * pinvsq * pinvsq * no_kozai;
// Vallado's exact formulation for mdot
let beta_sq = 1.0 - ecco * ecco;
let beta = beta_sq.sqrt();
let mdot = no_kozai
+ 0.5 * temp1 * beta * (3.0 * cosio2 - 1.0) / beta_sq
+ 0.0625 * temp2 * beta
* (13.0 - 78.0 * cosio2 + 137.0 * cosio2 * cosio2)
/ (beta_sq * beta_sq);
let argpdot = -0.5 * temp1 * (1.0 - 5.0 * cosio2)
+ 0.0625 * temp2 * (7.0 - 114.0 * cosio2 + 395.0 * cosio2 * cosio2)
+ temp3 * (3.0 - 36.0 * cosio2 + 49.0 * cosio2 * cosio2);
let xhdot1 = -temp1 * cosio;
let nodedot = xhdot1
+ (0.5 * temp2 * (4.0 - 19.0 * cosio2)
+ 2.0 * temp3 * (3.0 - 7.0 * cosio2))
* cosio;
let nodecf = 3.5 * po.recip().powi(2) * xhdot1 * cc1;
let xmcof = if ecco > 1.0e-4 {
-TWOPI / MINUTES_PER_DAY * coef / (3.0 * eeta) * bstar
} else {
0.0
};
let omgcof = bstar * cc3 * argpo.cos();
let sinmao = mo.sin();
let xlcof = if inclo.abs() > 1.0e-12 {
-0.25 * J3OJ2 * sinio * (3.0 + 5.0 * cosio) / (1.0 + cosio)
} else {
-0.25 * J3OJ2 * sinio * (3.0 + 5.0 * cosio) / (2.0 * 1.0e-12)
};
let xnodcf = nodecf;
let t2cof = 1.5 * cc1;
// Simplified flag: skip higher-order drag for high perigee or tiny ecc
let isimp = perige < 220.0;
let (d2, d3, d4, t3cof, t4cof, t5cof) = if !isimp {
let c1sq = cc1 * cc1;
let d2 = 4.0 * ao * tsi * c1sq;
let temp = d2 * tsi * cc1 / 3.0;
let d3 = (17.0 * ao + sfour) * temp;
let d4 = 0.5 * temp * ao * tsi * (221.0 * ao + 31.0 * sfour) * cc1;
let t3cof = d2 + 2.0 * c1sq;
let t4cof = 0.25 * (3.0 * d3 + cc1 * (12.0 * d2 + 10.0 * c1sq));
let t5cof = 0.2
* (3.0 * d4 + 12.0 * cc1 * d3 + 6.0 * d2 * d2 + 15.0 * c1sq * (2.0 * d2 + c1sq));
(d2, d3, d4, t3cof, t4cof, t5cof)
} else {
(0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
};
// ── Deep-space initialization ────────────────────────
let deep = if method == PropagationMethod::Deep {
Some(DeepSpaceState::initialize(
epoch_jd, ecco, inclo, nodeo, argpo, mo, no_kozai, bstar,
))
} else {
None
};
Ok(Sgp4State {
tle: tle.clone(),
method,
no_kozai,
a0: ao,
ecco,
inclo,
nodeo,
argpo,
mo,
bstar,
mdot,
argpdot,
nodedot,
nodecf,
cc1,
cc4,
cc5,
d2,
d3,
d4,
eta,
omgcof,
sinmao,
t2cof,
t3cof,
t4cof,
t5cof,
x1mth2,
x7thm1,
xlcof,
xmcof,
xnodcf,
con41: -con41_calc(cosio2),
isimp,
epoch_jd,
deep,
})
}
/// Propagate to a given number of minutes since TLE epoch.
///
/// Returns the satellite position and velocity in the TEME
/// (True Equator Mean Equinox) reference frame.
pub fn propagate(&self, tsince: f64) -> OrbitalResult<EciPosition> {
// ── Secular effects of atmospheric drag and gravitation ──
let xmdf = self.mo + self.mdot * tsince;
let argpdf = self.argpo + self.argpdot * tsince;
let nodedf = self.nodeo + self.nodedot * tsince;
let mut mp = xmdf;
let tsq = tsince * tsince;
let node = nodedf + self.xnodcf * tsq;
let argp;
let mut tempa = 1.0 - self.cc1 * tsince;
let mut tempe = self.bstar * self.cc4 * tsince;
let mut templ = self.t2cof * tsq;
if !self.isimp {
let delomg = self.omgcof * tsince;
let dmp = if self.ecco > 1.0e-4 {
self.xmcof
* ((1.0 + self.eta * xmdf.cos()).powi(3) - (1.0 + self.eta * self.mo.cos()).powi(3))
} else {
0.0
};
mp = xmdf + delomg + dmp;
argp = argpdf - delomg - dmp;
let tcube = tsq * tsince;
let tfour = tsince * tcube;
tempa = tempa - self.d2 * tsq - self.d3 * tcube - self.d4 * tfour;
tempe += self.bstar * self.cc5 * (mp.sin() - self.sinmao);
templ = templ + self.t3cof * tcube + self.t4cof * tfour + self.t5cof * tfour * tsince;
} else {
argp = argpdf;
}
// Apply deep-space secular effects if SDP4
let (mp, node, argp, eccentricity, inclination, no_kozai_adj) = if self.method == PropagationMethod::Deep {
if let Some(ref deep) = self.deep {
let (mp2, node2, argp2, ecc2, inc2, nm2) =
deep.secular(tsince, mp, node, argp, self.ecco, self.inclo, self.no_kozai);
(mp2, node2, argp2, ecc2, inc2, nm2)
} else {
(mp, node, argp, self.ecco, self.inclo, self.no_kozai)
}
} else {
(mp, node, argp, self.ecco, self.inclo, self.no_kozai)
};
let mut nm = no_kozai_adj;
let mut em = eccentricity;
let mut xinc = inclination;
if self.method == PropagationMethod::Near {
nm = self.no_kozai;
em = self.ecco;
xinc = self.inclo;
}
if tempa <= 0.0 {
return Err(OrbitalError::SatelliteDecayed);
}
if self.method == PropagationMethod::Near {
nm = self.no_kozai;
}
let am = (XKE / (tempa * (if self.method == PropagationMethod::Near { self.no_kozai } else { nm }))).powf(2.0 / 3.0) * tempa * tempa;
if self.method == PropagationMethod::Near {
nm = XKE / am.powf(1.5);
}
if self.method == PropagationMethod::Near {
em = self.ecco - tempe;
}
// Fix eccentricity
if !(-0.001..1.0).contains(&em) {
return Err(OrbitalError::InvalidEccentricity(em));
}
if em < 1.0e-6 {
em = 1.0e-6;
}
let mut mp = mp;
if self.method == PropagationMethod::Near {
mp += self.no_kozai * templ;
}
let _xlm = mp + argp + node;
let mut ep = em;
let mut xincp = xinc;
let mut argpp = argp;
let mut nodep = node;
// Deep-space periodic effects
if self.method == PropagationMethod::Deep {
if let Some(ref deep) = self.deep {
let (ep2, xincp2, argpp2, nodep2, mp2) =
deep.periodics(tsince, ep, xincp, argpp, nodep, mp);
ep = ep2;
xincp = xincp2;
argpp = argpp2;
nodep = nodep2;
mp = mp2;
}
}
// ── Long-period periodic terms ──────────────────────
let sinip = xincp.sin();
let cosip = xincp.cos();
let axnl = ep * argpp.cos();
let aynl = ep * argpp.sin() + if xincp.abs() > 1.0e-12 {
-0.5 * J3OJ2 * sinip / (1.0 + cosip)
} else {
-0.5 * J3OJ2 * sinip / (2.0 * 1.0e-12)
};
let xl = mp + argpp + nodep
+ (-0.25 * J3OJ2 * sinip * (3.0 + 5.0 * cosip) / (if (1.0 + cosip).abs() > 1.0e-12 { 1.0 + cosip } else { 2.0 * 1.0e-12 }))
* axnl;
// ── Solve Kepler's equation ─────────────────────────
let u = (xl - nodep).rem_euclid(TWOPI);
let mut eo1 = u;
let mut ktr: u32 = 0;
let mut sineo1;
let mut coseo1;
loop {
sineo1 = eo1.sin();
coseo1 = eo1.cos();
let tem5 = 1.0 - coseo1 * axnl - sineo1 * aynl;
if tem5.abs() < SMALL {
break;
}
let tem5 = (u - aynl * coseo1 + axnl * sineo1 - eo1) / tem5;
let delta = tem5.clamp(-0.95, 0.95);
eo1 += delta;
ktr += 1;
if ktr >= MAX_KEPLER_ITERATIONS || delta.abs() < 1.0e-12 {
break;
}
}
// ── Short-period periodic terms ─────────────────────
let ecose = axnl * coseo1 + aynl * sineo1;
let esine = axnl * sineo1 - aynl * coseo1;
let el2 = axnl * axnl + aynl * aynl;
let pl = am * (1.0 - el2);
if pl < 0.0 {
return Err(OrbitalError::PropagationError(
"semi-latus rectum is negative".into(),
));
}
let rl = am * (1.0 - ecose);
let rdotl = am.sqrt() * esine / rl;
let rvdotl = pl.sqrt() / rl;
let betal = (1.0 - el2).sqrt();
let sinu = am / rl * (sineo1 - aynl - axnl * esine / (1.0 + betal));
let cosu = am / rl * (coseo1 - axnl + aynl * esine / (1.0 + betal));
let su = sinu.atan2(cosu);
let sin2u = (cosu + cosu) * sinu;
let cos2u = 1.0 - 2.0 * sinu * sinu;
let temp1 = 0.5 * J2 / pl;
let temp2 = temp1 / pl;
// SGP4 short-period corrections
let cosip2 = cosip * cosip;
let mrt = rl * (1.0 - 1.5 * temp2 * betal * (3.0 * cosip2 - 1.0))
+ 0.5 * temp1 * (1.0 - cosip2) * cos2u;
let su_corr = su
- 0.25 * temp2 * (7.0 * cosip2 - 1.0) * sin2u;
let xnode = nodep + 1.5 * temp2 * cosip * sin2u;
let xinc_corr = xincp + 1.5 * temp2 * cosip * sinip * cos2u;
let rdot = rdotl - nm * temp1 * (1.0 - cosip2) * sin2u;
let rvdot = rvdotl + nm * temp1 * ((1.0 - cosip2) * cos2u + 1.5 * (1.0 - 3.0 * cosip2));
if mrt < 1.0 {
return Err(OrbitalError::SatelliteDecayed);
}
// ── Convert to position/velocity in TEME frame ──────
let sinsu = su_corr.sin();
let cossu = su_corr.cos();
let snod = xnode.sin();
let cnod = xnode.cos();
let sini = xinc_corr.sin();
let cosi = xinc_corr.cos();
let xmx = -snod * cosi;
let xmy = cnod * cosi;
let ux = xmx * sinsu + cnod * cossu;
let uy = xmy * sinsu + snod * cossu;
let uz = sini * sinsu;
let vx = xmx * cossu - cnod * sinsu;
let vy = xmy * cossu - snod * sinsu;
let vz = sini * cossu;
// Position in km, velocity in km/s
let r_km = mrt * EARTH_RADIUS_KM;
let rdot_kmps = rdot * VKMPERSEC;
let rvdot_kmps = rvdot * VKMPERSEC;
Ok(EciPosition {
x: r_km * ux,
y: r_km * uy,
z: r_km * uz,
vx: rdot_kmps * ux + rvdot_kmps * vx,
vy: rdot_kmps * uy + rvdot_kmps * vy,
vz: rdot_kmps * uz + rvdot_kmps * vz,
})
}
/// Propagate to a specific `DateTime<Utc>`.
pub fn propagate_to(&self, datetime: &DateTime<Utc>) -> OrbitalResult<EciPosition> {
let jd = datetime_to_jd(datetime);
let minutes_since_epoch = (jd - self.epoch_jd) * MINUTES_PER_DAY;
self.propagate(minutes_since_epoch)
}
/// Orbital period in minutes.
pub fn orbital_period_minutes(&self) -> f64 {
TWOPI / self.no_kozai
}
/// Orbital period in seconds.
pub fn orbital_period_seconds(&self) -> f64 {
self.orbital_period_minutes() * 60.0
}
}
/// Compute con41 = 3*cos^2(i) - 1 (negated because of how it's used in the propagator).
fn con41_calc(cosio2: f64) -> f64 {
3.0 * cosio2 - 1.0
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tle::parse_tle;
const ISS_NAME: &str = "ISS (ZARYA)";
const ISS_LINE1: &str =
"1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9009";
const ISS_LINE2: &str =
"2 25544 51.6416 247.4627 0006703 30.0000 10.0000 15.49560000000007";
#[test]
fn test_initialize_iss() {
let tle = parse_tle(ISS_NAME, ISS_LINE1, ISS_LINE2).unwrap();
let state = Sgp4State::initialize(&tle).unwrap();
assert_eq!(state.method, PropagationMethod::Near);
assert!(state.no_kozai > 0.0);
assert!(state.orbital_period_minutes() < 225.0);
assert!((state.orbital_period_minutes() - 92.9).abs() < 1.0);
}
#[test]
fn test_propagate_epoch() {
let tle = parse_tle(ISS_NAME, ISS_LINE1, ISS_LINE2).unwrap();
let state = Sgp4State::initialize(&tle).unwrap();
let pos = state.propagate(0.0).unwrap();
// At epoch, ISS should be at roughly LEO altitude (400 km)
let alt_km = pos.radius() - EARTH_RADIUS_KM;
assert!(
alt_km > 300.0 && alt_km < 500.0,
"ISS altitude at epoch: {alt_km:.1} km"
);
// Velocity should be roughly 7.7 km/s for LEO
let speed = pos.speed();
assert!(
speed > 7.0 && speed < 8.5,
"ISS speed at epoch: {speed:.2} km/s"
);
}
#[test]
fn test_propagate_multiple_times() {
let tle = parse_tle(ISS_NAME, ISS_LINE1, ISS_LINE2).unwrap();
let state = Sgp4State::initialize(&tle).unwrap();
// Propagate at multiple times and verify orbit stays reasonable
for minutes in [0.0, 10.0, 30.0, 60.0, 120.0, 360.0, 720.0, 1440.0] {
let pos = state.propagate(minutes).unwrap();
let alt_km = pos.radius() - EARTH_RADIUS_KM;
let speed = pos.speed();
assert!(
alt_km > 200.0 && alt_km < 600.0,
"ISS altitude at t={minutes}min: {alt_km:.1} km"
);
assert!(
speed > 7.0 && speed < 8.5,
"ISS speed at t={minutes}min: {speed:.2} km/s"
);
}
}
#[test]
fn test_invalid_eccentricity() {
let mut tle = parse_tle(ISS_NAME, ISS_LINE1, ISS_LINE2).unwrap();
tle.eccentricity = 1.5;
let result = Sgp4State::initialize(&tle);
assert!(matches!(result, Err(OrbitalError::InvalidEccentricity(_))));
}
#[test]
fn test_propagate_to_datetime() {
let tle = parse_tle(ISS_NAME, ISS_LINE1, ISS_LINE2).unwrap();
let state = Sgp4State::initialize(&tle).unwrap();
// Propagate to epoch should give same result as propagate(0)
let pos_epoch = state.propagate(0.0).unwrap();
let pos_dt = state.propagate_to(&tle.epoch).unwrap();
assert!((pos_epoch.x - pos_dt.x).abs() < 0.001);
assert!((pos_epoch.y - pos_dt.y).abs() < 0.001);
assert!((pos_epoch.z - pos_dt.z).abs() < 0.001);
}
#[test]
fn test_orbital_period() {
let tle = parse_tle(ISS_NAME, ISS_LINE1, ISS_LINE2).unwrap();
let state = Sgp4State::initialize(&tle).unwrap();
// ISS mean motion is ~15.5 rev/day -> period ~92.9 min
let period = state.orbital_period_minutes();
let expected = MINUTES_PER_DAY / tle.mean_motion;
assert!(
(period - expected).abs() < 1.0,
"Period: {period:.2} min, expected ~{expected:.2} min"
);
}
}