pub mod fit;
#[allow(
dead_code,
unused_variables,
unused_assignments,
unused_mut,
non_snake_case,
non_camel_case_types,
clippy::approx_constant,
clippy::excessive_precision,
clippy::too_many_arguments,
clippy::needless_return,
clippy::assign_op_pattern,
clippy::manual_range_contains,
clippy::collapsible_if,
clippy::collapsible_else_if,
clippy::float_cmp,
clippy::needless_late_init,
clippy::field_reassign_with_default
)]
mod vallado;
use crate::astro::tle;
use crate::validate::{self, FieldError};
use thiserror::Error;
pub use fit::{
fit_tle, FitConfig, FitEpoch, FitSample, FitStatistics, Loss, TleFit, TleFitError, TleMetadata,
XScale,
};
const MAX_VALLADO_SATNUM: u32 = 99_999;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Sgp4InputErrorKind {
NonFinite,
NotPositive,
Negative,
OutOfRange,
Missing,
FloatParse,
IntParse,
InvalidCivilDate,
InvalidCivilTime,
}
impl core::fmt::Display for Sgp4InputErrorKind {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
let label = match self {
Self::NonFinite => "not finite",
Self::NotPositive => "not positive",
Self::Negative => "negative",
Self::OutOfRange => "out of range",
Self::Missing => "missing",
Self::FloatParse => "invalid float",
Self::IntParse => "invalid integer",
Self::InvalidCivilDate => "invalid civil date",
Self::InvalidCivilTime => "invalid civil time",
};
f.write_str(label)
}
}
impl From<&FieldError> for Sgp4InputErrorKind {
fn from(error: &FieldError) -> Self {
match error {
FieldError::Missing { .. } => Self::Missing,
FieldError::NonFinite { .. } => Self::NonFinite,
FieldError::NotPositive { .. } => Self::NotPositive,
FieldError::Negative { .. } => Self::Negative,
FieldError::OutOfRange { .. } => Self::OutOfRange,
FieldError::FloatParse { .. } => Self::FloatParse,
FieldError::IntParse { .. } => Self::IntParse,
FieldError::InvalidCivilDate { .. } => Self::InvalidCivilDate,
FieldError::InvalidCivilTime { .. } => Self::InvalidCivilTime,
}
}
}
#[derive(Error, Debug, Clone, PartialEq)]
pub enum Error {
#[error("invalid SGP4 input {field}: {kind}")]
InvalidInput {
field: &'static str,
kind: Sgp4InputErrorKind,
},
#[error("SGP4 returned non-finite {field}")]
NonFiniteOutput {
field: &'static str,
},
#[error("invalid TLE: {0}")]
InvalidTle(String),
#[error("SGP4 error code {code}")]
Sgp4 { code: i32 },
}
const MAX_MINUTES_SINCE_EPOCH: f64 = 10_000_000.0;
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct MinutesSinceEpoch(pub f64);
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Prediction {
pub position: [f64; 3],
pub velocity: [f64; 3],
}
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct JulianDate(pub f64, pub f64);
pub(crate) fn sgp4_julian_date_from_calendar(
year: i32,
mon: i32,
day: i32,
hr: i32,
minute: i32,
sec: f64,
) -> JulianDate {
let (jd, jdfrac) = vallado::jday_SGP4(year, mon, day, hr, minute, sec);
JulianDate(jd, jdfrac)
}
pub(crate) fn sgp4_julian_date_from_day_of_year(year: i32, days: f64) -> JulianDate {
let (mon, day, hr, minute, sec) = vallado::days2mdhms_SGP4(year, days);
let JulianDate(jd, jdfrac_raw) =
sgp4_julian_date_from_calendar(year, mon, day, hr, minute, sec);
let jdfrac = (jdfrac_raw * 100_000_000.0).round() / 100_000_000.0;
JulianDate(jd, jdfrac)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
pub enum OpsMode {
#[default]
Improved,
Afspc,
}
impl OpsMode {
fn as_char(self) -> char {
match self {
OpsMode::Improved => 'i',
OpsMode::Afspc => 'a',
}
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ElementSet {
pub epoch: JulianDate,
pub bstar: f64,
pub mean_motion_dot: f64,
pub mean_motion_double_dot: f64,
pub eccentricity: f64,
pub argument_of_perigee_deg: f64,
pub inclination_deg: f64,
pub mean_anomaly_deg: f64,
pub mean_motion_rev_per_day: f64,
pub right_ascension_deg: f64,
pub catalog_number: u32,
}
#[derive(Clone)]
pub struct Satellite {
line1: String,
line2: String,
elements: ElementSet,
opsmode: OpsMode,
satrec: Box<vallado::ElsetRec>,
}
impl std::fmt::Debug for Satellite {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Satellite")
.field("line1", &self.line1)
.field("line2", &self.line2)
.field("elements", &self.elements)
.field("opsmode", &self.opsmode)
.finish_non_exhaustive()
}
}
impl Satellite {
pub fn from_tle(line1: &str, line2: &str) -> Result<Self, Error> {
Self::from_tle_with_opsmode(line1, line2, OpsMode::Improved)
}
pub fn from_tle_with_opsmode(
line1: &str,
line2: &str,
opsmode: OpsMode,
) -> Result<Self, Error> {
let l1 = line1.trim();
let l2 = line2.trim();
let parsed = tle::parse(l1, l2).map_err(|e| Error::InvalidTle(e.to_string()))?;
let elements = parsed
.elements
.to_element_set()
.map_err(map_tle_bridge_error)?;
let satrec = init_satrec_from_elements(&elements, opsmode)?;
Ok(Satellite {
line1: l1.to_string(),
line2: l2.to_string(),
elements,
opsmode,
satrec: Box::new(satrec),
})
}
pub fn from_3line(block: &str) -> Result<Self, Error> {
Self::from_3line_with_opsmode(block, OpsMode::Improved)
}
pub fn from_3line_with_opsmode(block: &str, opsmode: OpsMode) -> Result<Self, Error> {
let mut l1 = None;
let mut l2 = None;
for line in block.lines() {
let line = line.trim();
if l1.is_none() && line.starts_with("1 ") {
l1 = Some(line.to_string());
} else if l2.is_none() && line.starts_with("2 ") {
l2 = Some(line.to_string());
}
}
let l1 = l1.ok_or_else(|| Error::InvalidTle("no line 1 in TLE block".into()))?;
let l2 = l2.ok_or_else(|| Error::InvalidTle("no line 2 in TLE block".into()))?;
Self::from_tle_with_opsmode(&l1, &l2, opsmode)
}
pub fn from_elements(elements: &ElementSet) -> Result<Self, Error> {
Self::from_elements_with_opsmode(elements, OpsMode::Improved)
}
pub fn from_elements_with_opsmode(
elements: &ElementSet,
opsmode: OpsMode,
) -> Result<Self, Error> {
let satrec = init_satrec_from_elements(elements, opsmode)?;
Ok(Satellite {
line1: String::new(),
line2: String::new(),
elements: elements.clone(),
opsmode,
satrec: Box::new(satrec),
})
}
pub fn propagate(&self, t: MinutesSinceEpoch) -> Result<Prediction, Error> {
propagate_satrec((*self.satrec).clone(), t)
}
pub fn propagate_jd(&self, jd: JulianDate) -> Result<Prediction, Error> {
validate::finite(jd.0, "julian_date.whole").map_err(map_input_error)?;
validate::finite_in_range_exclusive_upper(jd.1, 0.0, 1.0, "julian_date.fraction")
.map_err(map_input_error)?;
let tsince =
(jd.0 - self.satrec.jdsatepoch) * 1440.0 + (jd.1 - self.satrec.jdsatepochF) * 1440.0;
validate::finite(tsince, "minutes_since_epoch").map_err(map_input_error)?;
self.propagate(MinutesSinceEpoch(tsince))
}
pub(crate) fn mean_motion_rad_per_min(&self) -> f64 {
self.satrec.no_kozai
}
pub(crate) fn eccentricity(&self) -> f64 {
self.satrec.ecco
}
pub fn line1(&self) -> &str {
&self.line1
}
pub fn line2(&self) -> &str {
&self.line2
}
pub fn epoch_jd(&self) -> JulianDate {
JulianDate(self.satrec.jdsatepoch, self.satrec.jdsatepochF)
}
fn has_source_tle(&self) -> bool {
!self.line1.is_empty() && !self.line2.is_empty()
}
}
pub fn propagate_elements(
elements: &ElementSet,
t: MinutesSinceEpoch,
) -> Result<Prediction, Error> {
propagate_elements_with_opsmode(elements, t, OpsMode::Improved)
}
pub fn propagate_elements_with_opsmode(
elements: &ElementSet,
t: MinutesSinceEpoch,
opsmode: OpsMode,
) -> Result<Prediction, Error> {
let satrec = init_satrec_from_elements(elements, opsmode)?;
propagate_satrec(satrec, t)
}
fn propagate_arc(
satellite: &Satellite,
times: &[MinutesSinceEpoch],
) -> Result<Vec<Prediction>, Error> {
times.iter().map(|&t| satellite.propagate(t)).collect()
}
pub fn propagate_batch(
satellites: &[Satellite],
times: &[MinutesSinceEpoch],
) -> Vec<Result<Vec<Prediction>, Error>> {
satellites
.iter()
.map(|satellite| propagate_arc(satellite, times))
.collect()
}
pub fn propagate_batch_parallel(
satellites: &[Satellite],
times: &[MinutesSinceEpoch],
) -> Vec<Result<Vec<Prediction>, Error>> {
use rayon::prelude::*;
satellites
.par_iter()
.map(|satellite| propagate_arc(satellite, times))
.collect()
}
impl serde::Serialize for Satellite {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeStruct;
let mut st = s.serialize_struct("Satellite", 2)?;
if self.has_source_tle() {
st.serialize_field("line1", &self.line1)?;
st.serialize_field("line2", &self.line2)?;
} else {
st.serialize_field("elements", &self.elements)?;
st.serialize_field("opsmode", &self.opsmode)?;
}
st.end()
}
}
impl<'de> serde::Deserialize<'de> for Satellite {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
#[derive(serde::Deserialize)]
struct Wire {
line1: Option<String>,
line2: Option<String>,
elements: Option<ElementSet>,
opsmode: Option<OpsMode>,
}
let w = Wire::deserialize(d)?;
let opsmode = w.opsmode.unwrap_or_default();
let has_tle_line = w
.line1
.as_deref()
.is_some_and(|line| !line.trim().is_empty())
|| w.line2
.as_deref()
.is_some_and(|line| !line.trim().is_empty());
if let Some(elements) = w.elements {
if has_tle_line {
Err(serde::de::Error::custom(
"ambiguous Satellite wire format: use either TLE lines or elements",
))
} else {
Satellite::from_elements_with_opsmode(&elements, opsmode)
.map_err(serde::de::Error::custom)
}
} else if let (Some(line1), Some(line2)) = (w.line1, w.line2) {
if line1.trim().is_empty() || line2.trim().is_empty() {
Err(serde::de::Error::custom(
"Satellite wire format requires non-empty line1/line2 or elements",
))
} else {
Satellite::from_tle_with_opsmode(&line1, &line2, opsmode)
.map_err(serde::de::Error::custom)
}
} else {
Err(serde::de::Error::custom(
"Satellite wire format requires non-empty line1/line2 or elements",
))
}
}
}
fn propagate_satrec(
mut satrec: vallado::ElsetRec,
t: MinutesSinceEpoch,
) -> Result<Prediction, Error> {
validate::finite(t.0, "minutes_since_epoch").map_err(map_input_error)?;
if t.0.abs() > MAX_MINUTES_SINCE_EPOCH {
return Err(invalid_domain("minutes_since_epoch"));
}
let mut r = [0.0_f64; 3];
let mut v = [0.0_f64; 3];
let ok = vallado::sgp4(&mut satrec, t.0, &mut r, &mut v);
if !ok || satrec.error != 0 {
return Err(Error::Sgp4 { code: satrec.error });
}
validate_prediction(r, v)?;
Ok(Prediction {
position: r,
velocity: v,
})
}
fn validate_prediction(position: [f64; 3], velocity: [f64; 3]) -> Result<(), Error> {
validate::finite_vec3(position, "position_km").map_err(map_output_error)?;
validate::finite_vec3(velocity, "velocity_km_s").map_err(map_output_error)?;
Ok(())
}
fn init_satrec_from_elements(
elements: &ElementSet,
opsmode: OpsMode,
) -> Result<vallado::ElsetRec, Error> {
validate_elements(elements)?;
let deg2rad = std::f64::consts::PI / 180.0;
let xpdotp = 1440.0 / (2.0 * std::f64::consts::PI);
let inclo = elements.inclination_deg * deg2rad;
let nodeo = elements.right_ascension_deg * deg2rad;
let argpo = elements.argument_of_perigee_deg * deg2rad;
let mo = elements.mean_anomaly_deg * deg2rad;
let no_kozai = elements.mean_motion_rev_per_day / xpdotp;
let ndot = elements.mean_motion_dot / (xpdotp * 1440.0);
let nddot = elements.mean_motion_double_dot / (xpdotp * 1440.0 * 1440.0);
let JulianDate(jd, jdfrac) = elements.epoch;
let epoch_sgp4 = jd + jdfrac - 2433281.5;
let satnum_str = format!("{:>5}", elements.catalog_number);
let mut satrec = vallado::ElsetRec {
jdsatepoch: jd,
jdsatepochF: jdfrac,
..vallado::ElsetRec::default()
};
vallado::sgp4init(
vallado::GravConstType::Wgs72,
opsmode.as_char(),
&satnum_str,
epoch_sgp4,
elements.bstar,
ndot,
nddot,
elements.eccentricity,
argpo,
inclo,
mo,
no_kozai,
nodeo,
&mut satrec,
);
satrec.jdsatepoch = jd;
satrec.jdsatepochF = jdfrac;
Ok(satrec)
}
fn validate_elements(elements: &ElementSet) -> Result<(), Error> {
if elements.catalog_number > MAX_VALLADO_SATNUM {
return Err(invalid_domain("element.catalog_number"));
}
validate_epoch(elements.epoch)?;
validate::finite(elements.bstar, "element.bstar").map_err(map_input_error)?;
validate::finite(elements.mean_motion_dot, "element.mean_motion_dot")
.map_err(map_input_error)?;
validate::finite(
elements.mean_motion_double_dot,
"element.mean_motion_double_dot",
)
.map_err(map_input_error)?;
validate::finite_in_range_exclusive_upper(
elements.eccentricity,
0.0,
1.0,
"element.eccentricity",
)
.map_err(map_input_error)?;
validate::finite(
elements.argument_of_perigee_deg,
"element.argument_of_perigee_deg",
)
.map_err(map_input_error)?;
validate::finite(elements.inclination_deg, "element.inclination_deg")
.map_err(map_input_error)?;
validate::finite(elements.mean_anomaly_deg, "element.mean_anomaly_deg")
.map_err(map_input_error)?;
validate::finite_positive(
elements.mean_motion_rev_per_day,
"element.mean_motion_rev_per_day",
)
.map_err(map_input_error)?;
validate::finite(elements.right_ascension_deg, "element.right_ascension_deg")
.map_err(map_input_error)?;
Ok(())
}
fn validate_epoch(epoch: JulianDate) -> Result<(), Error> {
validate::finite(epoch.0, "element.epoch.whole").map_err(map_input_error)?;
validate::finite(epoch.1, "element.epoch.fraction").map_err(map_input_error)?;
let total = epoch.0 + epoch.1;
validate::finite(total, "element.epoch").map_err(map_input_error)?;
if !(0.0..=5_000_000.0).contains(&total) {
return Err(invalid_domain("element.epoch"));
}
Ok(())
}
fn map_input_error(error: FieldError) -> Error {
Error::InvalidInput {
field: error.field(),
kind: Sgp4InputErrorKind::from(&error),
}
}
fn invalid_domain(field: &'static str) -> Error {
Error::InvalidInput {
field,
kind: Sgp4InputErrorKind::OutOfRange,
}
}
fn map_tle_bridge_error(error: tle::TleError) -> Error {
match error {
tle::TleError::InvalidField { field, reason } => Error::InvalidInput {
field,
kind: match reason {
"not finite" => Sgp4InputErrorKind::NonFinite,
"not positive" => Sgp4InputErrorKind::NotPositive,
"negative" => Sgp4InputErrorKind::Negative,
"out of range" => Sgp4InputErrorKind::OutOfRange,
_ => Sgp4InputErrorKind::OutOfRange,
},
},
other => Error::InvalidTle(other.to_string()),
}
}
fn map_output_error(error: FieldError) -> Error {
Error::NonFiniteOutput {
field: error.field(),
}
}
#[derive(Debug)]
pub struct NamedSatellite {
pub name: String,
pub satellite: Satellite,
}
#[derive(Debug)]
pub struct TleFile {
pub satellites: Vec<NamedSatellite>,
pub skipped: usize,
}
pub fn parse_tle_file(text: &str) -> TleFile {
parse_tle_file_with_opsmode(text, OpsMode::Improved)
}
pub fn parse_tle_file_with_opsmode(text: &str, opsmode: OpsMode) -> TleFile {
let lines: Vec<&str> = text.lines().map(str::trim).collect();
let mut satellites = Vec::new();
let mut skipped = 0usize;
let mut pending_name = String::new();
let mut i = 0;
while i < lines.len() {
let line = lines[i];
if line.is_empty() {
i += 1;
continue;
}
if line.starts_with("1 ") {
let mut j = i + 1;
while j < lines.len() && lines[j].is_empty() {
j += 1;
}
if j < lines.len() && lines[j].starts_with("2 ") {
if let Ok(satellite) = Satellite::from_tle_with_opsmode(line, lines[j], opsmode) {
satellites.push(NamedSatellite {
name: std::mem::take(&mut pending_name),
satellite,
});
} else {
skipped += 1;
pending_name.clear();
}
i = j + 1;
continue;
}
pending_name.clear();
i += 1;
continue;
}
if line.starts_with("2 ") {
pending_name.clear();
i += 1;
continue;
}
pending_name = line.strip_prefix("0 ").unwrap_or(line).trim().to_string();
i += 1;
}
TleFile {
satellites,
skipped,
}
}
#[cfg(test)]
mod tests {
use super::{
parse_tle_file, propagate_batch, propagate_batch_parallel, propagate_elements, ElementSet,
Error, JulianDate, MinutesSinceEpoch, Satellite, Sgp4InputErrorKind,
MAX_MINUTES_SINCE_EPOCH,
};
#[test]
fn non_ascii_tle_returns_invalid_tle_not_panic() {
let line1 = "1 25544U 98067A 18184.80969102 .00001614 00000-0 31745-4 0 9993";
let line2 = "2 25544 51.6414 295.8524 0003435 262.6267 204.2868 15.54005638121106";
assert!(
Satellite::from_tle(line1, line2).is_ok(),
"clean ASCII TLE must still parse"
);
let mut bad1 = String::from(&line1[..18]);
bad1.push('\u{20ac}');
bad1.push_str(&line1[19..]);
assert!(
!bad1.is_char_boundary(20),
"corruption must straddle byte 20"
);
let err = Satellite::from_tle(&bad1, line2).expect_err("non-ASCII TLE must not parse");
assert!(
matches!(err, Error::InvalidTle(_)),
"expected a typed InvalidTle error, got: {err:?}"
);
}
const ISS_L1: &str = "1 25544U 98067A 18184.80969102 .00001614 00000-0 31745-4 0 9993";
const ISS_L2: &str = "2 25544 51.6414 295.8524 0003435 262.6267 204.2868 15.54005638121106";
#[test]
fn parse_tle_file_three_line_captures_names() {
let text = format!("ISS (ZARYA)\n{ISS_L1}\n{ISS_L2}\nSECOND SAT\n{ISS_L1}\n{ISS_L2}\n");
let f = parse_tle_file(&text);
assert_eq!(f.satellites.len(), 2);
assert_eq!(f.skipped, 0);
assert_eq!(f.satellites[0].name, "ISS (ZARYA)");
assert_eq!(f.satellites[1].name, "SECOND SAT");
assert_eq!(f.satellites[0].satellite.line1(), ISS_L1);
assert_eq!(f.satellites[0].satellite.line2(), ISS_L2);
}
#[test]
fn parse_tle_file_bare_two_line_has_empty_name() {
let f = parse_tle_file(&format!("{ISS_L1}\n{ISS_L2}"));
assert_eq!(f.satellites.len(), 1);
assert_eq!(f.satellites[0].name, "");
assert_eq!(f.satellites[0].satellite.line1(), ISS_L1);
}
#[test]
fn parse_tle_file_strips_celestrak_zero_name_marker() {
let f = parse_tle_file(&format!("0 ISS (ZARYA)\n{ISS_L1}\n{ISS_L2}"));
assert_eq!(f.satellites.len(), 1);
assert_eq!(f.satellites[0].name, "ISS (ZARYA)");
}
#[test]
fn parse_tle_file_tolerates_crlf_blanks_and_whitespace() {
let text = format!("\r\n ISS (ZARYA) \r\n{ISS_L1}\r\n\r\n{ISS_L2}\r\n\r\n");
let f = parse_tle_file(&text);
assert_eq!(f.satellites.len(), 1);
assert_eq!(f.satellites[0].name, "ISS (ZARYA)");
assert_eq!(f.satellites[0].satellite.line1(), ISS_L1);
}
#[test]
fn parse_tle_file_skips_malformed_record_and_counts_it() {
let text = format!(
"GOOD ONE\n{ISS_L1}\n{ISS_L2}\nBAD ONE\n1 not a real line\n2 not a real line\nGOOD TWO\n{ISS_L1}\n{ISS_L2}\n"
);
let f = parse_tle_file(&text);
assert_eq!(
f.satellites.len(),
2,
"the malformed record must be skipped"
);
assert_eq!(f.skipped, 1, "the skipped record must be counted");
assert_eq!(f.satellites[0].name, "GOOD ONE");
assert_eq!(f.satellites[1].name, "GOOD TWO");
}
#[test]
fn parse_tle_file_stray_line2_does_not_leak_name() {
let text = format!("ORPHAN NAME\n2 stray line two\n{ISS_L1}\n{ISS_L2}\n");
let f = parse_tle_file(&text);
assert_eq!(f.satellites.len(), 1);
assert_eq!(f.satellites[0].name, "", "stray name must not leak forward");
}
fn iss_elements() -> ElementSet {
crate::astro::tle::parse(ISS_L1, ISS_L2)
.unwrap()
.elements
.to_element_set()
.expect("valid TLE bridge")
}
fn assert_invalid_input<T>(
result: Result<T, Error>,
field: &'static str,
kind: Sgp4InputErrorKind,
) {
match result {
Err(Error::InvalidInput {
field: actual_field,
kind: actual_kind,
}) => {
assert_eq!(actual_field, field);
assert_eq!(actual_kind, kind);
}
Err(err) => panic!("expected InvalidInput({field}, {kind}), got {err:?}"),
Ok(_) => panic!("expected InvalidInput({field}, {kind}), got Ok"),
}
}
fn assert_same(a: &Satellite, b: &Satellite) {
let (ea, eb) = (a.epoch_jd(), b.epoch_jd());
assert_eq!(
(ea.0.to_bits(), ea.1.to_bits()),
(eb.0.to_bits(), eb.1.to_bits()),
"epoch JD differs"
);
for &t in &[0.0, 100.0, 1440.0] {
let pa = a.propagate(MinutesSinceEpoch(t)).unwrap();
let pb = b.propagate(MinutesSinceEpoch(t)).unwrap();
for axis in 0..3 {
assert_eq!(
pa.position[axis].to_bits(),
pb.position[axis].to_bits(),
"position[{axis}] differs at t={t}"
);
assert_eq!(
pa.velocity[axis].to_bits(),
pb.velocity[axis].to_bits(),
"velocity[{axis}] differs at t={t}"
);
}
}
}
#[test]
fn serde_round_trips_tle_satellites() {
let sat = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
let encoded = serde_json::to_string(&sat).unwrap();
assert!(encoded.contains("\"line1\""));
assert!(encoded.contains("\"line2\""));
assert!(!encoded.contains("\"elements\""));
let decoded: Satellite = serde_json::from_str(&encoded).unwrap();
assert_eq!(decoded.line1(), ISS_L1);
assert_eq!(decoded.line2(), ISS_L2);
assert_same(&sat, &decoded);
}
#[test]
fn serde_round_trips_element_built_satellites() {
let elements = iss_elements();
let sat = Satellite::from_elements(&elements).unwrap();
let encoded = serde_json::to_string(&sat).unwrap();
assert!(encoded.contains("\"elements\""));
assert!(encoded.contains("\"opsmode\""));
assert!(!encoded.contains("\"line1\""));
assert!(!encoded.contains("\"line2\""));
let decoded: Satellite = serde_json::from_str(&encoded).unwrap();
assert!(decoded.line1().is_empty());
assert!(decoded.line2().is_empty());
assert_same(&sat, &decoded);
}
#[test]
fn from_elements_rejects_non_finite_fields_before_sgp4init() {
let mut elements = iss_elements();
elements.bstar = f64::NAN;
assert_invalid_input(
Satellite::from_elements(&elements),
"element.bstar",
Sgp4InputErrorKind::NonFinite,
);
}
#[test]
fn from_elements_rejects_sgp4_domain_before_sgp4init() {
let mut elements = iss_elements();
elements.mean_motion_rev_per_day = 0.0;
assert_invalid_input(
Satellite::from_elements(&elements),
"element.mean_motion_rev_per_day",
Sgp4InputErrorKind::NotPositive,
);
let mut elements = iss_elements();
elements.eccentricity = -0.1;
assert_invalid_input(
Satellite::from_elements(&elements),
"element.eccentricity",
Sgp4InputErrorKind::OutOfRange,
);
let mut elements = iss_elements();
elements.eccentricity = 1.0;
assert_invalid_input(
Satellite::from_elements(&elements),
"element.eccentricity",
Sgp4InputErrorKind::OutOfRange,
);
let mut elements = iss_elements();
elements.catalog_number = 100_000;
assert_invalid_input(
Satellite::from_elements(&elements),
"element.catalog_number",
Sgp4InputErrorKind::OutOfRange,
);
}
#[test]
fn from_elements_rejects_invalid_epoch() {
let mut elements = iss_elements();
elements.epoch = JulianDate(f64::NAN, 0.0);
assert_invalid_input(
Satellite::from_elements(&elements),
"element.epoch.whole",
Sgp4InputErrorKind::NonFinite,
);
let mut elements = iss_elements();
elements.epoch = JulianDate(9_000_000.0, 0.0);
assert_invalid_input(
Satellite::from_elements(&elements),
"element.epoch",
Sgp4InputErrorKind::OutOfRange,
);
}
#[test]
fn from_elements_accepts_full_julian_epoch() {
let mut elements = iss_elements();
elements.epoch = super::sgp4_julian_date_from_calendar(2057, 1, 1, 0, 0, 0.0);
Satellite::from_elements(&elements).expect("full 2057 epoch is valid");
}
#[test]
fn from_tle_accepts_epoch_after_parser_conversion_to_full_jd() {
let mut line1 = ISS_L1.to_string();
line1.replace_range(18..32, "19366.00000000");
Satellite::from_tle(&line1, ISS_L2).expect("TLE epoch is converted to full JD");
}
#[test]
fn propagation_rejects_non_finite_time_inputs() {
let sat = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
assert_invalid_input(
sat.propagate(MinutesSinceEpoch(f64::NAN)),
"minutes_since_epoch",
Sgp4InputErrorKind::NonFinite,
);
assert_invalid_input(
sat.propagate_jd(JulianDate(f64::INFINITY, 0.0)),
"julian_date.whole",
Sgp4InputErrorKind::NonFinite,
);
let elements = iss_elements();
assert_invalid_input(
propagate_elements(&elements, MinutesSinceEpoch(f64::INFINITY)),
"minutes_since_epoch",
Sgp4InputErrorKind::NonFinite,
);
}
#[test]
fn propagation_rejects_out_of_domain_time_inputs() {
let sat = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
assert_invalid_input(
sat.propagate(MinutesSinceEpoch(MAX_MINUTES_SINCE_EPOCH.next_up())),
"minutes_since_epoch",
Sgp4InputErrorKind::OutOfRange,
);
assert_invalid_input(
sat.propagate_jd(JulianDate(2_458_304.0, 1.0)),
"julian_date.fraction",
Sgp4InputErrorKind::OutOfRange,
);
}
#[test]
fn lenient_trailing_whitespace_and_content_past_col_69() {
let clean = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
let pad = Satellite::from_tle(&format!("{ISS_L1} "), &format!("{ISS_L2}\t ")).unwrap();
assert_same(&clean, &pad);
let extra =
Satellite::from_tle(&format!("{ISS_L1} EXTRA-JUNK"), &format!("{ISS_L2} 999999"))
.unwrap();
assert_same(&clean, &extra);
}
#[test]
fn lenient_leading_dot_and_assumed_decimal_fields() {
let sat = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
let p = sat.propagate(MinutesSinceEpoch(0.0)).unwrap();
let r = (p.position[0].powi(2) + p.position[1].powi(2) + p.position[2].powi(2)).sqrt();
assert!(
(6500.0..=7200.0).contains(&r),
"ISS radius {r} km outside LEO"
);
}
#[test]
fn lenient_missing_optional_bookkeeping_fields() {
let l1: String = ISS_L1
.char_indices()
.map(|(i, c)| {
if i == 62 || (64..=67).contains(&i) {
' '
} else {
c
}
})
.collect();
let l2: String = ISS_L2
.char_indices()
.map(|(i, c)| if (63..=67).contains(&i) { ' ' } else { c })
.collect();
let clean = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
let blanked = Satellite::from_tle(&l1, &l2).unwrap();
assert_same(&clean, &blanked);
}
#[test]
fn three_line_form_strips_name_line() {
let clean = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
let block = format!("ISS (ZARYA)\n{ISS_L1}\n{ISS_L2}\n");
let three = Satellite::from_3line(&block).unwrap();
assert_same(&clean, &three);
let two = Satellite::from_3line(&format!("{ISS_L1}\n{ISS_L2}")).unwrap();
assert_same(&clean, &two);
}
#[test]
fn three_line_form_rejects_block_without_element_lines() {
assert!(Satellite::from_3line("just a name\nand some text").is_err());
assert!(Satellite::from_3line("").is_err());
}
const CSS_L1: &str = "1 48274U 21035A 24001.50000000 .00015000 00000-0 18000-3 0 9990";
const CSS_L2: &str = "2 48274 41.4700 100.0000 0006000 90.0000 270.0000 15.61000000 10000";
const DECAY_L1: &str = "1 28872U 05037B 05333.02012661 .25992681 00000-0 24476-3 0 1534";
const DECAY_L2: &str = "2 28872 96.4736 157.9986 0303955 244.0492 110.6523 16.46015938 10708";
fn batch_times() -> Vec<MinutesSinceEpoch> {
(0..33)
.map(|i| MinutesSinceEpoch(i as f64 * 45.0))
.collect()
}
#[test]
fn batch_is_bit_identical_to_per_satellite_propagate() {
let satellites = [
Satellite::from_tle(ISS_L1, ISS_L2).unwrap(),
Satellite::from_tle(CSS_L1, CSS_L2).unwrap(),
];
let times = batch_times();
let batch = propagate_batch(&satellites, ×);
assert_eq!(batch.len(), satellites.len());
for (sat_idx, satellite) in satellites.iter().enumerate() {
let arc = batch[sat_idx]
.as_ref()
.expect("clean satellite arc must be Ok");
assert_eq!(arc.len(), times.len());
for (epoch_idx, &t) in times.iter().enumerate() {
let reference = satellite.propagate(t).expect("per-sat propagate ok");
for axis in 0..3 {
assert_eq!(
arc[epoch_idx].position[axis].to_bits(),
reference.position[axis].to_bits(),
"position bits sat {sat_idx} epoch {epoch_idx} axis {axis}"
);
assert_eq!(
arc[epoch_idx].velocity[axis].to_bits(),
reference.velocity[axis].to_bits(),
"velocity bits sat {sat_idx} epoch {epoch_idx} axis {axis}"
);
}
}
}
}
#[test]
fn parallel_batch_is_bit_identical_to_serial() {
let satellites = [
Satellite::from_tle(ISS_L1, ISS_L2).unwrap(),
Satellite::from_tle(CSS_L1, CSS_L2).unwrap(),
Satellite::from_tle(ISS_L1, ISS_L2).unwrap(),
];
let times = batch_times();
let serial = propagate_batch(&satellites, ×);
let parallel = propagate_batch_parallel(&satellites, ×);
assert_eq!(serial.len(), parallel.len());
for sat_idx in 0..satellites.len() {
let s = serial[sat_idx].as_ref().expect("serial arc ok");
let p = parallel[sat_idx].as_ref().expect("parallel arc ok");
assert_eq!(s.len(), p.len());
for epoch_idx in 0..times.len() {
for axis in 0..3 {
assert_eq!(
s[epoch_idx].position[axis].to_bits(),
p[epoch_idx].position[axis].to_bits(),
"position bits sat {sat_idx} epoch {epoch_idx} axis {axis}"
);
assert_eq!(
s[epoch_idx].velocity[axis].to_bits(),
p[epoch_idx].velocity[axis].to_bits(),
"velocity bits sat {sat_idx} epoch {epoch_idx} axis {axis}"
);
}
}
}
}
#[test]
fn failing_satellite_yields_per_item_error_without_poisoning_batch() {
let clean_a = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
let decay = Satellite::from_tle(DECAY_L1, DECAY_L2).unwrap();
let clean_b = Satellite::from_tle(CSS_L1, CSS_L2).unwrap();
let times: Vec<MinutesSinceEpoch> = (0..=24)
.map(|i| MinutesSinceEpoch(i as f64 * 120.0))
.collect();
assert!(
times.iter().any(|&t| decay.propagate(t).is_err()),
"decaying fixture must error on the grid"
);
assert!(
times.iter().all(|&t| clean_a.propagate(t).is_ok()),
"clean fixture must span the grid"
);
let satellites = [clean_a, decay, clean_b];
for batch in [
propagate_batch(&satellites, ×),
propagate_batch_parallel(&satellites, ×),
] {
assert_eq!(batch.len(), 3);
assert!(batch[0].is_ok(), "clean satellite 0 must survive");
assert_eq!(batch[0].as_ref().unwrap().len(), times.len());
assert!(batch[2].is_ok(), "clean satellite 2 must survive");
assert_eq!(batch[2].as_ref().unwrap().len(), times.len());
assert!(
matches!(batch[1], Err(Error::Sgp4 { .. })),
"decaying satellite must yield an SGP4 error, got {:?}",
batch[1]
);
}
}
#[test]
fn batch_handles_empty_inputs() {
let sat = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
let times = batch_times();
assert!(propagate_batch(&[], ×).is_empty());
assert!(propagate_batch_parallel(&[], ×).is_empty());
let no_times = propagate_batch(std::slice::from_ref(&sat), &[]);
assert_eq!(no_times.len(), 1);
assert!(no_times[0].as_ref().unwrap().is_empty());
}
#[test]
fn rejects_genuine_corruption() {
assert!(Satellite::from_tle("", "").is_err());
assert!(Satellite::from_tle("hello world", "goodbye world").is_err());
assert!(Satellite::from_tle(ISS_L2, ISS_L1).is_err());
let l2_wrong = "2 25545 51.6414 295.8524 0003435 262.6267 204.2868 15.54005638121106";
assert!(matches!(
Satellite::from_tle(ISS_L1, l2_wrong),
Err(Error::InvalidTle(_))
));
}
}