"""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 ```` 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 = """\
GENERATED VIA SPACE-TRACK.ORG API 2026-01-08T05:20:27 18 SPCS
TBA - TO BE ASSIGNED 2025-313AU EARTH TEME UTC SGP4 2026-01-06T22:50:56.049504 15.21286088 0.00095778 97.4119 84.2715 195.9225 164.1711 0 U 67290 999 143 0.00053078883000 0.00011823 0.0000000000000 6880.097 94.657 508.552 495.373 UNKNOWN 4962752 308346717
GENERATED VIA SPACE-TRACK.ORG API 2026-01-08T15:46:24 18 SPCS
TBA - TO BE ASSIGNED 2025-313AU EARTH TEME UTC SGP4 2026-01-08T11:09:24.647040 15.21321390 0.00096980 97.4116 85.7604 190.1005 170.0038 0 U 67290 999 166 0.00055242000000 0.00012321 0.0000000000000
""" @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("" in xml assert "YKSA" 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 "" 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 "" not in xml assert "DSST" in xml def test_omm_xml_parses_spacetrack_archive_format(): """The Space-Track archive ships -wrapped multi-document OMM with an undeclared xsi: prefix and a vendor-specific 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 . 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 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 "" in a.omm_xml assert "userDefinedParameters" in a.omm_xml assert "SEMIMAJOR_AXIS" in a.omm_xml