Initial commit
This commit is contained in:
commit
5303986377
20 changed files with 3195 additions and 0 deletions
288
tests/test_formats.py
Normal file
288
tests/test_formats.py
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
"""TLE / OMM format conversion tests.
|
||||
|
||||
Round-trip checks: ``parse_tle -> tle_from_gp`` should reproduce the underlying
|
||||
orbit (compared via sgp4) to within float tolerance. OMM XML rendering must
|
||||
emit every CCSDS-mandatory field, and the parser must handle the real-world
|
||||
``<ndm>`` wrapper Space-Track ships in its archive exports.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
from sgp4.api import Satrec
|
||||
|
||||
from odm import (
|
||||
OmmRecord,
|
||||
as_omm_xml,
|
||||
as_txt_txt,
|
||||
as_plaintext,
|
||||
omm_xml_to_records,
|
||||
parse_tle,
|
||||
tle_from_gp,
|
||||
)
|
||||
|
||||
|
||||
ISS = (
|
||||
"ISS (ZARYA)",
|
||||
"1 25544U 98067A 24070.50000000 .00012345 00000-0 22000-3 0 9991",
|
||||
"2 25544 51.6400 200.0000 0001234 90.0000 270.0000 15.50000000400000",
|
||||
)
|
||||
|
||||
NOAA15 = (
|
||||
"NOAA 15",
|
||||
"1 25338U 98030A 24070.20000000 .00000050 00000-0 31000-4 0 9990",
|
||||
"2 25338 98.7000 100.0000 0010000 10.0000 350.0000 14.26000000900000",
|
||||
)
|
||||
|
||||
|
||||
# Real-world Space-Track archive sample (verbatim from user-supplied issue),
|
||||
# trimmed to two records to keep the test fast.
|
||||
SPACETRACK_ARCHIVE_XML = """\
|
||||
<ndm xsi:noNamespaceSchemaLocation="https://sanaregistry.org/r/ndmxml_unqualified/ndmxml-3.0.0-master-3.0.xsd">
|
||||
<omm id="CCSDS_OMM_VERS" version="3.0">
|
||||
<header>
|
||||
<COMMENT>GENERATED VIA SPACE-TRACK.ORG API</COMMENT>
|
||||
<CREATION_DATE>2026-01-08T05:20:27</CREATION_DATE>
|
||||
<ORIGINATOR>18 SPCS</ORIGINATOR>
|
||||
</header>
|
||||
<body>
|
||||
<segment>
|
||||
<metadata>
|
||||
<OBJECT_NAME>TBA - TO BE ASSIGNED</OBJECT_NAME>
|
||||
<OBJECT_ID>2025-313AU</OBJECT_ID>
|
||||
<CENTER_NAME>EARTH</CENTER_NAME>
|
||||
<REF_FRAME>TEME</REF_FRAME>
|
||||
<TIME_SYSTEM>UTC</TIME_SYSTEM>
|
||||
<MEAN_ELEMENT_THEORY>SGP4</MEAN_ELEMENT_THEORY>
|
||||
</metadata>
|
||||
<data>
|
||||
<meanElements>
|
||||
<EPOCH>2026-01-06T22:50:56.049504</EPOCH>
|
||||
<MEAN_MOTION>15.21286088</MEAN_MOTION>
|
||||
<ECCENTRICITY>0.00095778</ECCENTRICITY>
|
||||
<INCLINATION>97.4119</INCLINATION>
|
||||
<RA_OF_ASC_NODE>84.2715</RA_OF_ASC_NODE>
|
||||
<ARG_OF_PERICENTER>195.9225</ARG_OF_PERICENTER>
|
||||
<MEAN_ANOMALY>164.1711</MEAN_ANOMALY>
|
||||
</meanElements>
|
||||
<tleParameters>
|
||||
<EPHEMERIS_TYPE>0</EPHEMERIS_TYPE>
|
||||
<CLASSIFICATION_TYPE>U</CLASSIFICATION_TYPE>
|
||||
<NORAD_CAT_ID>67290</NORAD_CAT_ID>
|
||||
<ELEMENT_SET_NO>999</ELEMENT_SET_NO>
|
||||
<REV_AT_EPOCH>143</REV_AT_EPOCH>
|
||||
<BSTAR>0.00053078883000</BSTAR>
|
||||
<MEAN_MOTION_DOT>0.00011823</MEAN_MOTION_DOT>
|
||||
<MEAN_MOTION_DDOT>0.0000000000000</MEAN_MOTION_DDOT>
|
||||
</tleParameters>
|
||||
<userDefinedParameters>
|
||||
<USER_DEFINED parameter="SEMIMAJOR_AXIS">6880.097</USER_DEFINED>
|
||||
<USER_DEFINED parameter="PERIOD">94.657</USER_DEFINED>
|
||||
<USER_DEFINED parameter="APOAPSIS">508.552</USER_DEFINED>
|
||||
<USER_DEFINED parameter="PERIAPSIS">495.373</USER_DEFINED>
|
||||
<USER_DEFINED parameter="OBJECT_TYPE">UNKNOWN</USER_DEFINED>
|
||||
<USER_DEFINED parameter="RCS_SIZE"/>
|
||||
<USER_DEFINED parameter="COUNTRY_CODE"/>
|
||||
<USER_DEFINED parameter="LAUNCH_DATE"/>
|
||||
<USER_DEFINED parameter="SITE"/>
|
||||
<USER_DEFINED parameter="DECAY_DATE"/>
|
||||
<USER_DEFINED parameter="FILE">4962752</USER_DEFINED>
|
||||
<USER_DEFINED parameter="GP_ID">308346717</USER_DEFINED>
|
||||
</userDefinedParameters>
|
||||
</data>
|
||||
</segment>
|
||||
</body>
|
||||
</omm>
|
||||
<omm id="CCSDS_OMM_VERS" version="3.0">
|
||||
<header>
|
||||
<COMMENT>GENERATED VIA SPACE-TRACK.ORG API</COMMENT>
|
||||
<CREATION_DATE>2026-01-08T15:46:24</CREATION_DATE>
|
||||
<ORIGINATOR>18 SPCS</ORIGINATOR>
|
||||
</header>
|
||||
<body>
|
||||
<segment>
|
||||
<metadata>
|
||||
<OBJECT_NAME>TBA - TO BE ASSIGNED</OBJECT_NAME>
|
||||
<OBJECT_ID>2025-313AU</OBJECT_ID>
|
||||
<CENTER_NAME>EARTH</CENTER_NAME>
|
||||
<REF_FRAME>TEME</REF_FRAME>
|
||||
<TIME_SYSTEM>UTC</TIME_SYSTEM>
|
||||
<MEAN_ELEMENT_THEORY>SGP4</MEAN_ELEMENT_THEORY>
|
||||
</metadata>
|
||||
<data>
|
||||
<meanElements>
|
||||
<EPOCH>2026-01-08T11:09:24.647040</EPOCH>
|
||||
<MEAN_MOTION>15.21321390</MEAN_MOTION>
|
||||
<ECCENTRICITY>0.00096980</ECCENTRICITY>
|
||||
<INCLINATION>97.4116</INCLINATION>
|
||||
<RA_OF_ASC_NODE>85.7604</RA_OF_ASC_NODE>
|
||||
<ARG_OF_PERICENTER>190.1005</ARG_OF_PERICENTER>
|
||||
<MEAN_ANOMALY>170.0038</MEAN_ANOMALY>
|
||||
</meanElements>
|
||||
<tleParameters>
|
||||
<EPHEMERIS_TYPE>0</EPHEMERIS_TYPE>
|
||||
<CLASSIFICATION_TYPE>U</CLASSIFICATION_TYPE>
|
||||
<NORAD_CAT_ID>67290</NORAD_CAT_ID>
|
||||
<ELEMENT_SET_NO>999</ELEMENT_SET_NO>
|
||||
<REV_AT_EPOCH>166</REV_AT_EPOCH>
|
||||
<BSTAR>0.00055242000000</BSTAR>
|
||||
<MEAN_MOTION_DOT>0.00012321</MEAN_MOTION_DOT>
|
||||
<MEAN_MOTION_DDOT>0.0000000000000</MEAN_MOTION_DDOT>
|
||||
</tleParameters>
|
||||
</data>
|
||||
</segment>
|
||||
</body>
|
||||
</omm>
|
||||
</ndm>
|
||||
"""
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,l1,l2", [ISS, NOAA15])
|
||||
def test_parse_tle_yields_expected_omm(name, l1, l2):
|
||||
parsed = parse_tle(name, l1, l2)
|
||||
assert parsed.omm["NORAD_CAT_ID"] == int(l1[2:7])
|
||||
assert math.isclose(parsed.omm["INCLINATION"], float(l2[8:16]), abs_tol=1e-4)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,l1,l2", [ISS, NOAA15])
|
||||
def test_round_trip_preserves_orbit(name, l1, l2):
|
||||
parsed = parse_tle(name, l1, l2)
|
||||
name_out, l1_out, l2_out = tle_from_gp(parsed.omm)
|
||||
assert l1_out.startswith("1 ")
|
||||
assert l2_out.startswith("2 ")
|
||||
assert len(l1_out) == 69
|
||||
assert len(l2_out) == 69
|
||||
sat_a = Satrec.twoline2rv(l1, l2)
|
||||
sat_b = Satrec.twoline2rv(l1_out, l2_out)
|
||||
assert sat_a.satnum == sat_b.satnum
|
||||
assert math.isclose(sat_a.inclo, sat_b.inclo, abs_tol=1e-5)
|
||||
assert math.isclose(sat_a.nodeo, sat_b.nodeo, abs_tol=1e-5)
|
||||
assert math.isclose(sat_a.ecco, sat_b.ecco, abs_tol=1e-7)
|
||||
assert math.isclose(sat_a.argpo, sat_b.argpo, abs_tol=1e-5)
|
||||
assert math.isclose(sat_a.mo, sat_b.mo, abs_tol=1e-5)
|
||||
assert math.isclose(sat_a.no_kozai, sat_b.no_kozai, abs_tol=1e-7)
|
||||
|
||||
|
||||
def test_txt_txt_format():
|
||||
name, l1, l2 = ISS
|
||||
parsed = parse_tle(name, l1, l2)
|
||||
out = as_txt_txt([(parsed.name, parsed.line1, parsed.line2)])
|
||||
lines = out.split("\r\n")
|
||||
assert lines[0] == parsed.name[:24].ljust(24)
|
||||
assert lines[1] == parsed.line1
|
||||
assert lines[2] == parsed.line2
|
||||
assert lines[3] == ""
|
||||
|
||||
|
||||
def test_plaintext_format_has_trailing_newline():
|
||||
out = as_plaintext(*ISS)
|
||||
assert out.endswith("\n")
|
||||
assert out.count("\n") == 3
|
||||
|
||||
|
||||
def test_omm_xml_contains_all_mandatory_fields():
|
||||
parsed = parse_tle(*ISS)
|
||||
rec = OmmRecord(
|
||||
omm=parsed.omm,
|
||||
object_name=parsed.name,
|
||||
object_id=parsed.omm["OBJECT_ID"],
|
||||
originator="YKSA",
|
||||
mean_element_theory="SGP4",
|
||||
)
|
||||
xml = as_omm_xml([rec], originator="YKSA")
|
||||
|
||||
assert xml.startswith("<?xml")
|
||||
assert "<omm" in xml and 'id="CCSDS_OMM_VERS"' in xml and 'version="3.0"' in xml
|
||||
|
||||
assert "<CREATION_DATE>" in xml
|
||||
assert "<ORIGINATOR>YKSA</ORIGINATOR>" in xml
|
||||
|
||||
for field in (
|
||||
"OBJECT_NAME", "OBJECT_ID", "CENTER_NAME", "REF_FRAME",
|
||||
"TIME_SYSTEM", "MEAN_ELEMENT_THEORY",
|
||||
):
|
||||
assert f"<{field}>" in xml, f"missing mandatory metadata field {field}"
|
||||
|
||||
for field in (
|
||||
"EPOCH", "MEAN_MOTION", "ECCENTRICITY", "INCLINATION",
|
||||
"RA_OF_ASC_NODE", "ARG_OF_PERICENTER", "MEAN_ANOMALY",
|
||||
):
|
||||
assert f"<{field}>" in xml, f"missing mandatory meanElements field {field}"
|
||||
|
||||
assert "<tleParameters>" in xml
|
||||
for field in ("NORAD_CAT_ID", "BSTAR", "MEAN_MOTION_DOT"):
|
||||
assert f"<{field}>" in xml
|
||||
|
||||
|
||||
def test_omm_xml_round_trip_preserves_orbit():
|
||||
parsed = parse_tle(*ISS)
|
||||
rec = OmmRecord(
|
||||
omm=parsed.omm,
|
||||
object_name=parsed.name,
|
||||
object_id=parsed.omm["OBJECT_ID"],
|
||||
mean_element_theory="SGP4",
|
||||
)
|
||||
xml = as_omm_xml([rec])
|
||||
parsed_back = list(omm_xml_to_records(xml))
|
||||
assert len(parsed_back) == 1
|
||||
rt = parsed_back[0]
|
||||
assert rt.object_name == parsed.name
|
||||
assert rt.mean_element_theory == "SGP4"
|
||||
assert rt.ref_frame == "TEME"
|
||||
assert math.isclose(rt.omm["INCLINATION"], parsed.omm["INCLINATION"], abs_tol=1e-9)
|
||||
assert math.isclose(rt.omm["MEAN_MOTION"], parsed.omm["MEAN_MOTION"], abs_tol=1e-12)
|
||||
# NORAD_CAT_ID round-trips through OMM XML as a string (to accommodate the
|
||||
# future alphanumeric Space-Track catalog IDs).
|
||||
assert str(rt.omm["NORAD_CAT_ID"]) == str(parsed.omm["NORAD_CAT_ID"])
|
||||
|
||||
|
||||
def test_omm_xml_omits_tle_params_for_non_sgp_theories():
|
||||
parsed = parse_tle(*ISS)
|
||||
rec = OmmRecord(
|
||||
omm=parsed.omm,
|
||||
object_name=parsed.name,
|
||||
object_id=parsed.omm["OBJECT_ID"],
|
||||
mean_element_theory="DSST",
|
||||
)
|
||||
xml = as_omm_xml([rec])
|
||||
assert "<tleParameters>" not in xml
|
||||
assert "<MEAN_ELEMENT_THEORY>DSST</MEAN_ELEMENT_THEORY>" in xml
|
||||
|
||||
|
||||
def test_omm_xml_parses_spacetrack_archive_format():
|
||||
"""The Space-Track archive ships <ndm>-wrapped multi-document OMM with an
|
||||
undeclared xsi: prefix and a vendor-specific <userDefinedParameters> block.
|
||||
All three must be tolerated without losing structured data.
|
||||
"""
|
||||
records = list(omm_xml_to_records(SPACETRACK_ARCHIVE_XML))
|
||||
assert len(records) == 2
|
||||
|
||||
a, b = records
|
||||
# First record's precise numeric fields must survive verbatim -- this is
|
||||
# the exact reason we treat OMM as canonical instead of round-tripping
|
||||
# through TLE line 1.
|
||||
assert a.omm["NORAD_CAT_ID"] == "67290"
|
||||
assert math.isclose(a.omm["MEAN_MOTION_DOT"], 0.00011823, abs_tol=1e-12)
|
||||
assert math.isclose(a.omm["BSTAR"], 0.00053078883, abs_tol=1e-14)
|
||||
assert math.isclose(a.omm["MEAN_MOTION"], 15.21286088, abs_tol=1e-12)
|
||||
|
||||
# Second record carries its own creation date -- header context must be
|
||||
# per-OMM, not shared across the whole <ndm>.
|
||||
assert b.omm["NORAD_CAT_ID"] == "67290"
|
||||
assert math.isclose(b.omm["BSTAR"], 0.00055242, abs_tol=1e-14)
|
||||
|
||||
# Metadata from the segment must be threaded through correctly.
|
||||
assert a.mean_element_theory == "SGP4"
|
||||
assert a.ref_frame == "TEME"
|
||||
assert a.originator == "18 SPCS"
|
||||
assert a.object_id == "2025-313AU"
|
||||
|
||||
# The original <omm> chunk must be preserved verbatim so we can hand it
|
||||
# back to API consumers unchanged. The userDefinedParameters block must
|
||||
# still be present in the stored XML even though we discard it from the
|
||||
# structured dict.
|
||||
assert "<omm" in a.omm_xml and "</omm>" in a.omm_xml
|
||||
assert "userDefinedParameters" in a.omm_xml
|
||||
assert "SEMIMAJOR_AXIS" in a.omm_xml
|
||||
Loading…
Add table
Add a link
Reference in a new issue