diff --git a/CHANGELOG.md b/CHANGELOG.md index e2e18169..0319512a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) diff --git a/docs/guide/tle.md b/docs/guide/tle.md index 86d08242..460c644d 100644 --- a/docs/guide/tle.md +++ b/docs/guide/tle.md @@ -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 diff --git a/python/satkit/satkit.pyi b/python/satkit/satkit.pyi index f95f5a4f..2fbead5a 100644 --- a/python/satkit/satkit.pyi +++ b/python/satkit/satkit.pyi @@ -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 diff --git a/python/src/pysgp4.rs b/python/src/pysgp4.rs index 748f8ef9..db2ccc24 100644 --- a/python/src/pysgp4.rs +++ b/python/src/pysgp4.rs @@ -111,6 +111,19 @@ fn float_from_py(val: &Bound<'_, PyAny>) -> Result { } } +/// EPHEMERIS_TYPE arrives as an int from JSON and as a string from XML. +fn ephemeris_type_from_py(val: &Bound<'_, PyAny>) -> Result { + if let Ok(v) = val.extract::() { + return Ok(v); + } + let s: String = val + .extract() + .map_err(|e| anyhow::anyhow!("Invalid EPHEMERIS_TYPE: {}", e))?; + s.trim() + .parse::() + .map_err(|e| anyhow::anyhow!("Invalid EPHEMERIS_TYPE {:?}: {}", s, e)) +} + fn omm_from_pydict(dict: &Bound<'_, PyDict>) -> Result { let mut omm = satkit::omm::OMM::default(); @@ -152,6 +165,9 @@ fn omm_from_pydict(dict: &Bound<'_, PyDict>) -> Result { 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::().map_err(|e| { pyo3::exceptions::PyValueError::new_err(format!( @@ -197,6 +213,9 @@ fn omm_from_pydict(dict: &Bound<'_, PyDict>) -> Result { 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"); diff --git a/python/test/test_sgp4.py b/python/test/test_sgp4.py index 1f412cdc..87e217d6 100644 --- a/python/test/test_sgp4.py +++ b/python/test/test_sgp4.py @@ -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)) diff --git a/src/omm/error.rs b/src/omm/error.rs index ea75a266..6c798914 100644 --- a/src/omm/error.rs +++ b/src/omm/error.rs @@ -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), diff --git a/src/omm/mod.rs b/src/omm/mod.rs index e405dbf0..382cec6e 100644 --- a/src/omm/mod.rs +++ b/src/omm/mod.rs @@ -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( @@ -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"); diff --git a/src/tle/error.rs b/src/tle/error.rs index d22723b2..728a95b2 100644 --- a/src/tle/error.rs +++ b/src/tle/error.rs @@ -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), diff --git a/src/tle/mod.rs b/src/tle/mod.rs index 6b4e5fbe..aca60528 100644 --- a/src/tle/mod.rs +++ b/src/tle/mod.rs @@ -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, @@ -126,6 +129,13 @@ impl SGP4Source for TLE { } fn sgp4_init_args(&self) -> crate::sgp4::Result { + // 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), @@ -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(&[