Initial commit

This commit is contained in:
ThePetrovich 2026-08-18 22:03:52 +08:00
commit 5303986377
20 changed files with 3195 additions and 0 deletions

98
tests/test_alpha5.py Normal file
View file

@ -0,0 +1,98 @@
"""Tests for the ALPHA-5 NORAD catalog-number encoding."""
from __future__ import annotations
import pytest
from sgp4.api import Satrec
from odm import (
ALPHA5_MAX,
ALPHA5_MIN,
from_alpha5,
tle_catalog_field,
to_alpha5,
)
from odm import parse_tle, tle_from_gp
@pytest.mark.parametrize("value,expected", [
(100000, "A0000"),
(108493, "A8493"),
(180000, "J0000"), # skips I
(234567, "P4567"),
(270354, "T0354"),
(339999, "Z9999"),
])
def test_to_alpha5_known_values(value, expected):
assert to_alpha5(value) == expected
def test_to_alpha5_skips_i_and_o():
encoded = "".join(to_alpha5(v * 10000)[0] for v in range(10, 34))
assert "I" not in encoded and "O" not in encoded
assert len(set(encoded)) == 24 # all distinct
@pytest.mark.parametrize("value", [ALPHA5_MIN, 123456, 234567, ALPHA5_MAX])
def test_alpha5_round_trips(value):
assert from_alpha5(to_alpha5(value)) == value
@pytest.mark.parametrize("value", [99999, ALPHA5_MAX + 1, 0, -1])
def test_to_alpha5_rejects_out_of_range(value):
with pytest.raises(ValueError):
to_alpha5(value)
@pytest.mark.parametrize("field", ["", "ABCDE", "A123", "I0000", "O0000", "12345"])
def test_from_alpha5_rejects_invalid(field):
assert from_alpha5(field) is None
def test_tle_catalog_field_plain_numeric_unchanged():
assert tle_catalog_field("25544", alpha5=True) == "25544"
assert tle_catalog_field("25544", alpha5=False) == "25544"
def test_tle_catalog_field_alpha5_vs_temp():
assert tle_catalog_field("234567", alpha5=True) == "P4567"
temp = tle_catalog_field("234567", alpha5=False)
assert temp.isdigit() and 90000 <= int(temp) <= 99999
def test_tle_catalog_field_alphanumeric_always_pseudo():
for flag in (True, False):
field = tle_catalog_field("A1234", alpha5=flag)
assert field.isdigit() and 90000 <= int(field) <= 99999
def test_tle_from_gp_alpha5_round_trips_through_sgp4():
iss = parse_tle(
"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",
)
gp = dict(iss.omm)
gp["NORAD_CAT_ID"] = "234567"
_, line1, line2 = tle_from_gp(gp, alpha5=True)
assert len(line1) == 69 and len(line2) == 69
assert line1[2:7] == "P4567"
assert line1[2:7] == line2[2:7]
# sgp4 must decode the ALPHA-5 field back to the real catalog number.
sat = Satrec.twoline2rv(line1, line2)
assert sat.satnum == 234567
def test_tle_from_gp_defaults_to_temp_id():
"""Without alpha5, a 6-digit numeric ID must not overflow the 5-char field."""
iss = parse_tle(
"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",
)
gp = dict(iss.omm)
gp["NORAD_CAT_ID"] = "234567"
_, line1, line2 = tle_from_gp(gp)
assert len(line1) == 69 and line1[2:7].isdigit()
assert 90000 <= int(line1[2:7]) <= 99999

View file

@ -0,0 +1,66 @@
"""Tests for the CSV and KVN export renderers added for the ODMS service."""
from __future__ import annotations
import csv
import io
from odm import GP_FIELDS, OmmRecord, as_celestrak_csv, as_omm_kvn
def _record() -> OmmRecord:
omm = {
"OBJECT_NAME": "ISS (ZARYA)",
"OBJECT_ID": "1998-067A",
"EPOCH": "2026-05-13T22:14:00",
"MEAN_MOTION": 15.498,
"ECCENTRICITY": 0.000284,
"INCLINATION": 51.64,
"RA_OF_ASC_NODE": 20.23,
"ARG_OF_PERICENTER": 74.12,
"MEAN_ANOMALY": 10.2,
"EPHEMERIS_TYPE": 0,
"CLASSIFICATION_TYPE": "U",
"NORAD_CAT_ID": "25544",
"ELEMENT_SET_NO": 999,
"REV_AT_EPOCH": 100,
"BSTAR": 0.00012345,
"MEAN_MOTION_DOT": 7.2e-05,
"MEAN_MOTION_DDOT": 0.0,
}
return OmmRecord(omm=omm, object_name="ISS (ZARYA)", object_id="1998-067A", originator="YKSA")
def test_celestrak_csv_header_matches_gp_field_order():
text = as_celestrak_csv([_record()])
rows = list(csv.reader(io.StringIO(text)))
assert tuple(rows[0]) == GP_FIELDS
# One data row, NORAD in the right column.
assert rows[1][GP_FIELDS.index("NORAD_CAT_ID")] == "25544"
assert rows[1][GP_FIELDS.index("OBJECT_NAME")] == "ISS (ZARYA)"
def test_celestrak_csv_multiple_records():
text = as_celestrak_csv([_record(), _record()])
rows = list(csv.reader(io.StringIO(text)))
assert len(rows) == 3 # header + 2
def test_omm_kvn_has_version_and_key_value_lines():
text = as_omm_kvn([_record()], originator="YKSA")
assert text.startswith("CCSDS_OMM_VERS = 3.0")
kv = dict(
line.split(" = ", 1)
for line in text.splitlines()
if " = " in line
)
assert kv["ORIGINATOR"] == "YKSA"
assert kv["NORAD_CAT_ID"] == "25544"
assert kv["MEAN_ELEMENT_THEORY"] == "SGP4"
assert kv["INCLINATION"] == "51.64"
def test_omm_kvn_separates_records_with_blank_line():
text = as_omm_kvn([_record(), _record()])
assert text.count("CCSDS_OMM_VERS = 3.0") == 2
assert "\n\n" in text

288
tests/test_formats.py Normal file
View 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

24
tests/test_norad.py Normal file
View file

@ -0,0 +1,24 @@
"""Tests for the NORAD ID normaliser."""
from __future__ import annotations
import pytest
from odm import normalise_norad
@pytest.mark.parametrize("raw,expected", [
(None, None),
("", None),
(" ", None),
(25544, "25544"),
("25544", "25544"),
(" 25544 ", "25544"),
("123", "00123"), # zero-padded to 5
(b"42", "00042"), # bytes accepted, zero-padded
("A1234", "A1234"), # alphanumeric preserved verbatim
("1998-067A", "1998-067A"), # hyphenated preserved verbatim
("12345678", "12345678"), # >5 digits left intact
])
def test_normalise_norad(raw, expected):
assert normalise_norad(raw) == expected

53
tests/test_temp_norad.py Normal file
View file

@ -0,0 +1,53 @@
"""Tests for the alphanumeric -> numeric TLE fallback."""
from __future__ import annotations
from odm import (
TEMP_NORAD_MAX,
TEMP_NORAD_MIN,
coerce_norad_to_int,
temp_tle_norad,
)
def test_temp_norad_stays_in_reserved_range():
for ident in ("A1234", "ZZ-9999", "1998-067A", "X", ""):
n = temp_tle_norad(ident)
assert TEMP_NORAD_MIN <= n <= TEMP_NORAD_MAX
def test_temp_norad_is_deterministic():
assert temp_tle_norad("A1234") == temp_tle_norad("A1234")
assert temp_tle_norad("A1234") != temp_tle_norad("A1235")
def test_coerce_norad_to_int_passes_through_numerics():
assert coerce_norad_to_int(25544) == 25544
assert coerce_norad_to_int("25544") == 25544
assert coerce_norad_to_int(" 25544 ") == 25544
def test_coerce_norad_to_int_substitutes_for_alphanumeric():
n = coerce_norad_to_int("A1234")
assert TEMP_NORAD_MIN <= n <= TEMP_NORAD_MAX
# Stable: second call returns the same number.
assert coerce_norad_to_int("A1234") == n
def test_tle_from_gp_renders_for_alphanumeric_norad():
"""The TLE rendering must succeed even when NORAD_CAT_ID is non-numeric."""
from odm import parse_tle, tle_from_gp
iss = parse_tle(
"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",
)
gp = dict(iss.omm)
gp["NORAD_CAT_ID"] = "A1234" # alphanumeric future ID
_, line1, line2 = tle_from_gp(gp)
assert line1.startswith("1 ") and line2.startswith("2 ")
# The 5-digit slot at chars 2-7 must contain the synthetic 9xxxx number.
assert line1[2:7].isdigit()
assert 90000 <= int(line1[2:7]) <= 99999
assert line1[2:7] == line2[2:7] # consistent across lines