Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Only recent releases are listed. Older entries are in this file's git history (`

### Fixed

- `sgp4()` rejects SGP4-XP element sets (TLE ephemeris type 4, OMM `EPHEMERIS_TYPE` 4) with a clear error instead of propagating them: an SGP4-XP line 1 stores agom and a B term in the columns a classic TLE uses for nddot and B*, so the old behaviour ran classic SGP4 on the wrong inputs and returned a plausible but wrong state ([#174](https://github.com/ssmichael1/satkit/pull/174))
- `satkit.density.nrlmsise(altitude_m, latitude_rad, longitude_rad, time)` converts its radian latitude/longitude to the degrees the model takes; the values were passed through unchanged, so a caller following the stub at 60° N was evaluated at 1.05° N (the `itrfcoord` overload and `nrlmsise00(latitude_deg=...)` were already correct) ([#171](https://github.com/ssmichael1/satkit/pull/171))
- A corrupt or truncated `tab5.2*.txt` in a data directory no longer panics the first frame transform: satkit warns and uses the compiled-in copy of the same IERS table (exact, not an approximation), and the parser now rejects text with no table header or fewer rows than declared — an HTML notice page saved under the table's name previously loaded as six empty series and silently dropped the nutation terms ([#166](https://github.com/ssmichael1/satkit/pull/166))

Expand Down
2 changes: 2 additions & 0 deletions docs/guide/tle.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ There are multiple places to access the catalog, including:

It was developed to support U.S. space surveillance as a fast, closed-form alternative to numerical integration, modeling Earth's oblateness (J2-J4), atmospheric drag via the TLE *B\** term, and key secular and periodic perturbations. The model is documented in Spacetrack Report No. 3 ([Hoots & Roehrich 1980](references.md#hoots1980)) and, in its modern reference form with test cases, in [Vallado et al. (2006)](references.md#vallado2006); satkit's implementation is a line-by-line Rust port of the C++ code accompanying the latter and is verified against its test vectors.

satkit implements classic SGP4 only. Element sets flagged as **SGP4-XP** (ephemeris type 4 in TLE line 1, column 63, or `EPHEMERIS_TYPE: 4` in an OMM) parse normally, but `sgp4()` raises an error rather than propagating them: SGP4-XP is a different theory whose line 1 stores agom and a B term where a classic TLE stores nddot and $B^*$, and its reference implementation is distributed by the U.S. Space Force as binaries only. Such sets are rare in public catalogs.

Today, SGP4 is the standard propagator for TLEs published by organizations like NORAD and CelesTrak, and is widely used for satellite tracking, visualization, conjunction screening, and mission planning — though its accuracy is fundamentally limited by TLE quality and simplifying assumptions.

## Ephemeris Representation
Expand Down
2 changes: 1 addition & 1 deletion python/satkit/satkit.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ class TLE:
def desig_piece(self, value: str) -> None: ...
@property
def ephem_type(self) -> int:
"""Ephemeris type (usually 0)"""
"""Ephemeris type (usually 0). A value of 4 marks an SGP4-XP element set, which ``sgp4`` rejects: satkit implements classic SGP4 only."""
...

@ephem_type.setter
Expand Down
19 changes: 19 additions & 0 deletions python/src/pysgp4.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,19 @@ fn float_from_py(val: &Bound<'_, PyAny>) -> Result<f64> {
}
}

/// EPHEMERIS_TYPE arrives as an int from JSON and as a string from XML.
fn ephemeris_type_from_py(val: &Bound<'_, PyAny>) -> Result<u8> {
if let Ok(v) = val.extract::<u8>() {
return Ok(v);
}
let s: String = val
.extract()
.map_err(|e| anyhow::anyhow!("Invalid EPHEMERIS_TYPE: {}", e))?;
s.trim()
.parse::<u8>()
.map_err(|e| anyhow::anyhow!("Invalid EPHEMERIS_TYPE {:?}: {}", s, e))
}

fn omm_from_pydict(dict: &Bound<'_, PyDict>) -> Result<satkit::omm::OMM> {
let mut omm = satkit::omm::OMM::default();

Expand Down Expand Up @@ -152,6 +165,9 @@ fn omm_from_pydict(dict: &Bound<'_, PyDict>) -> Result<satkit::omm::OMM> {
if let Some(v) = dict.get_item("MEAN_MOTION_DDOT")? {
omm.mean_motion_ddot = Some(float_from_py(&v)?);
}
if let Some(v) = dict.get_item("EPHEMERIS_TYPE")? {
omm.ephemeris_type = Some(ephemeris_type_from_py(&v)?);
}
if let Some(d) = dict.get_item("meanElements")? {
let d = d.cast::<PyDict>().map_err(|e| {
pyo3::exceptions::PyValueError::new_err(format!(
Expand Down Expand Up @@ -197,6 +213,9 @@ fn omm_from_pydict(dict: &Bound<'_, PyDict>) -> Result<satkit::omm::OMM> {
if let Some(v) = d.get_item("MEAN_MOTION_DDOT")? {
omm.mean_motion_ddot = Some(float_from_py(&v)?);
}
if let Some(v) = d.get_item("EPHEMERIS_TYPE")? {
omm.ephemeris_type = Some(ephemeris_type_from_py(&v)?);
}
}
if omm.epoch.is_empty() {
bail!("OMM epoch is required");
Expand Down
31 changes: 31 additions & 0 deletions python/test/test_sgp4.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,3 +296,34 @@ def one(tles):
# And wgs84 must actually differ from the default wgs72
p_72, _ = sk.sgp4(one(sk.TLE.from_lines([line1, line2])), t)
assert not np.allclose(p_72, p_single, rtol=0, atol=1e-3)


class TestSGP4XP:
def test_type4_tle_rejected(self):
"""An SGP4-XP element set (ephemeris type 4) parses but cannot be propagated."""
line1 = "1 00011U 59001A 23060.12028874 +.00002871 89876-2 73526-1 4 00010"
line2 = "2 00011 32.8652 309.4507 1466152 63.9843 312.2337 11.85947359392148"
tle = sk.TLE.from_lines([line1, line2])
if isinstance(tle, list):
tle = tle[0]
assert tle.ephem_type == 4
with pytest.raises(RuntimeError, match="SGP4-XP"):
sk.sgp4(tle, tle.epoch)

def test_type4_omm_rejected(self):
"""EPHEMERIS_TYPE 4 in an OMM is rejected the same way."""
omm = {
"OBJECT_NAME": "VANGUARD 1",
"OBJECT_ID": "1958-002B",
"EPOCH": "2023-03-01T02:53:12.947",
"MEAN_MOTION": 10.84532081,
"ECCENTRICITY": 0.1849434,
"INCLINATION": 34.2437,
"RA_OF_ASC_NODE": 69.7250,
"ARG_OF_PERICENTER": 110.9844,
"MEAN_ANOMALY": 271.4417,
"EPHEMERIS_TYPE": 4,
"NORAD_CAT_ID": 5,
}
with pytest.raises(RuntimeError, match="SGP4-XP"):
sk.sgp4(omm, sk.time(2023, 3, 1, 3, 0, 0))
5 changes: 5 additions & 0 deletions src/omm/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ pub enum Error {
#[error("Unsupported TIME_SYSTEM for SGP4: {0}")]
UnsupportedTimeSystem(String),

/// `EPHEMERIS_TYPE` selects a propagator satkit does not implement
/// (4 is SGP4-XP).
#[error("Unsupported EPHEMERIS_TYPE {0} (SGP4-XP): satkit implements classic SGP4 only")]
UnsupportedEphemerisType(u8),

#[error(transparent)]
Json(#[from] serde_json::Error),

Expand Down
40 changes: 40 additions & 0 deletions src/omm/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,12 @@ impl SGP4Source for OMM {
}
}

if let Some(4) = self.ephemeris_type {
return Err(crate::sgp4::Error::source(Error::UnsupportedEphemerisType(
4,
)));
}

let epoch = self.epoch_instant().map_err(crate::sgp4::Error::source)?;

Ok(SGP4InitArgs::from_mean_elements(
Expand Down Expand Up @@ -498,6 +504,40 @@ mod tests {
}
}

/// `EPHEMERIS_TYPE: 4` (SGP4-XP) parses but cannot be propagated.
#[test]
fn test_sgp4xp_type4_rejected() {
let json = r#"{
"OBJECT_NAME": "VANGUARD 1",
"OBJECT_ID": "1958-002B",
"EPOCH": "2023-03-01T02:53:12.947",
"MEAN_MOTION": 10.84532081,
"ECCENTRICITY": 0.1849434,
"INCLINATION": 34.2437,
"RA_OF_ASC_NODE": 69.7250,
"ARG_OF_PERICENTER": 110.9844,
"MEAN_ANOMALY": 271.4417,
"EPHEMERIS_TYPE": 4,
"NORAD_CAT_ID": 5
}"#;
let mut omm = OMM::from_json_string(&format!("[{json}]"))
.unwrap()
.pop()
.unwrap();
assert_eq!(omm.ephemeris_type, Some(4));
let epoch = omm.epoch_instant().unwrap();
let err = match crate::sgp4::sgp4(&mut omm, &[epoch]) {
Ok(_) => panic!("type-4 element set must not propagate"),
Err(e) => e,
};
let msg = err.to_string();
assert!(msg.contains("SGP4-XP"), "unexpected message: {msg}");
assert!(
msg.contains("EPHEMERIS_TYPE 4"),
"unexpected message: {msg}"
);
}

#[test]
fn test_parse_omm_celestrak_json() {
let filename = get_testvec_dir().unwrap().join("omm/celestrak_omm.json");
Expand Down
7 changes: 7 additions & 0 deletions src/tle/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,13 @@ pub enum Error {
#[error("SGP4 evaluation failed: {0}")]
Sgp4(String),

/// The element set's ephemeris type selects a propagator satkit does
/// not implement. Type 4 is SGP4-XP: its line 1 carries agom and a
/// B term in the columns a classic TLE uses for nddot and B*, so
/// running classic SGP4 on it would return a plausible but wrong state.
#[error("Ephemeris type {0} (SGP4-XP) is not supported: satkit implements classic SGP4 only, and an SGP4-XP element set stores agom and a B term where a classic TLE stores nddot and B*")]
UnsupportedEphemerisType(u8),

#[error("Normal equations are singular: {0}")]
SingularNormalEquations(String),

Expand Down
35 changes: 34 additions & 1 deletion src/tle/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,10 @@ pub struct TLE {
pub mean_motion_dot_dot: f64,
/// Starred ballistic coefficient, in units of inverse Earth radii
pub bstar: f64,
/// Usually 0
/// Ephemeris type (line 1, column 63). Usually 0. A value of 4 marks
/// an SGP4-XP element set, which [`sgp4`](crate::sgp4::sgp4) rejects:
/// its line 1 stores agom and a B term where a classic TLE stores
/// nddot and B*, and satkit implements classic SGP4 only.
pub ephem_type: u8,
/// Bulliten number
pub element_num: i32,
Expand Down Expand Up @@ -126,6 +129,13 @@ impl SGP4Source for TLE {
}

fn sgp4_init_args(&self) -> crate::sgp4::Result<SGP4InitArgs> {
// An SGP4-XP line 1 puts agom and a B term where a classic TLE has
// nddot and B*; classic SGP4 would run on them and be silently wrong.
if self.ephem_type == 4 {
return Err(crate::sgp4::Error::source(Error::UnsupportedEphemerisType(
self.ephem_type,
)));
}
Ok(SGP4InitArgs::from_mean_elements(
// Vallado expects JD UTC and then subtracts 2433281.5 inside the legacy interface.
self.epoch.as_jd_with_scale(TimeScale::UTC),
Expand Down Expand Up @@ -1070,6 +1080,29 @@ mod tests {
Ok(())
}

/// An SGP4-XP element set (ephemeris type 4) parses, but SGP4 refuses
/// to propagate it: its drag columns do not mean what classic SGP4
/// expects. The lines are from the Astro Standards sample XP catalog,
/// with checksums appended.
#[test]
fn test_sgp4xp_type4_rejected() -> Result<()> {
let line1 = "1 00011U 59001A 23060.12028874 +.00002871 89876-2 73526-1 4 00010";
let line2 = "2 00011 32.8652 309.4507 1466152 63.9843 312.2337 11.85947359392148";
let mut tle = TLE::from_lines(&[line1.to_string(), line2.to_string()])?
.pop()
.unwrap();
assert_eq!(tle.ephem_type, 4);
let epoch = tle.epoch;
let err = match crate::sgp4::sgp4(&mut tle, &[epoch]) {
Ok(_) => panic!("type-4 element set must not propagate"),
Err(e) => e,
};
let msg = err.to_string();
assert!(msg.contains("SGP4-XP"), "unexpected message: {msg}");
assert!(msg.contains("type 4"), "unexpected message: {msg}");
Ok(())
}

#[test]
fn test_from_invalid_from_lines() -> Result<()> {
let res = TLE::from_lines(&[
Expand Down