Ryan Malloy 1d36729bed Implement pg-orrery-catalog: TLE catalog builder for pg_orrery
Core modules:
- tle.py: NORAD decoding (Alpha-5 + Super-5, matching get_el.c),
  3LE/2LE parsing, TLERecord dataclass with epoch-based dedup
- config.py: TOML config + env var overlay (XDG-compliant paths)
- cache.py: File-based cache with staleness checking
- catalog.py: Multi-source merge with MergeStats tracking
- regime.py: LEO/MEO/GEO/HEO classification by mean motion

Source downloaders (httpx):
- celestrak.py: Active catalog + supplemental GP groups
- satnogs.py: JSON API with 3LE conversion
- spacetrack.py: POST auth flow, bulk GP download

Output formatters:
- sql.py: pg_orrery-compatible INSERT generation (E'' strings)
- tle_file.py: Standard 3LE text output
- json_out.py: JSON with orbital metadata and regime

CLI (Click + Rich):
- download: Cache TLEs from all sources
- build: Merge + output SQL/3LE/JSON (pipes to psql)
- load: Direct DB load via psycopg (optional [pg] extra)
- info: Cache stats and configuration display

58 tests covering NORAD decoding (all 4 encoding cases),
parsing, merge/dedup, SQL escaping, regime classification.
2026-02-18 00:31:46 -07:00

235 lines
7.9 KiB
Python

"""Tests for TLE parsing and NORAD ID decoding.
decode_norad() must match get_norad_number() in pg_orrery's src/sgp4/get_el.c.
Test vectors verified against the C implementation.
"""
import textwrap
import pytest
from pg_orrery_catalog.tle import (
_base64_to_int,
decode_norad,
parse_3le_file,
parse_3le_text,
)
# ── base64_to_int ────────────────────────────────────────────
class TestBase64ToInt:
def test_digits(self):
for i in range(10):
assert _base64_to_int(str(i)) == i
def test_uppercase(self):
assert _base64_to_int("A") == 10
assert _base64_to_int("Z") == 35
def test_lowercase(self):
assert _base64_to_int("a") == 36
assert _base64_to_int("z") == 61
def test_special(self):
assert _base64_to_int(" ") == 0
assert _base64_to_int("+") == 62
assert _base64_to_int("-") == 63
def test_invalid(self):
assert _base64_to_int("!") == -1
assert _base64_to_int("@") == -1
# ── decode_norad: Case (1) traditional 5-digit ──────────────
class TestDecodeNoradTraditional:
def test_simple(self):
assert decode_norad("00001") == 1
assert decode_norad("25544") == 25544
assert decode_norad("99999") == 99999
def test_with_spaces(self):
assert decode_norad(" 5") == 5
assert decode_norad(" 405") == 405
def test_zero(self):
assert decode_norad("00000") == 0
# ── decode_norad: Case (2) Alpha-5 ──────────────────────────
class TestDecodeNoradAlpha5:
"""Alpha-5 encoding: letter + 4 digits, I and O skipped.
A=10, B=11, ..., H=17, J=18 (I skipped), ..., N=22, P=23 (O skipped), ..., Z=33
value = (letter_value - 10) * 10000 + digits + 100000
But the skip logic means: A→0, B→1, ..., H→7, J→8, K→9, ..., N→12, P→13, ..., Z→23
So: A0000 = 100000, Z9999 = 339999
"""
def test_a0001(self):
# A → val=0, result = 0*10000 + 1 + 100000 = 100001
assert decode_norad("A0001") == 100001
def test_a0000(self):
assert decode_norad("A0000") == 100000
def test_t0002(self):
# T → ord('T')-ord('A') = 19, minus 1 for I, minus 1 for O = 17
# 17 * 10000 + 2 + 100000 = 270002
assert decode_norad("T0002") == 270002
def test_z9999(self):
# Z → ord('Z')-ord('A') = 25, minus 1 for I, minus 1 for O = 23
# 23 * 10000 + 9999 + 100000 = 339999
assert decode_norad("Z9999") == 339999
def test_b0000(self):
assert decode_norad("B0000") == 110000
def test_h9999(self):
# H → 7, skip nothing before I
assert decode_norad("H9999") == 179999
def test_j0000(self):
# J → ord('J')-ord('A')=9, minus 1 for I = 8
assert decode_norad("J0000") == 180000
def test_p0000(self):
# P → ord('P')-ord('A')=15, minus 1 for I, minus 1 for O = 13
assert decode_norad("P0000") == 230000
# ── decode_norad: Case (3) Super-5 (last char uppercase) ────
class TestDecodeNoradSuper5Case3:
"""Case (3): xxxxX — last character is non-digit base64.
rval = 340000 + (digits[4] - 10) + 54 * (d3 + d2*64 + d1*64^2 + d0*64^3)
"""
def test_0000A(self):
# digits = [0, 0, 0, 0, 10]
# rval = 340000 + (10-10) + 54*(0 + 0 + 0 + 0) = 340000
assert decode_norad("0000A") == 340000
def test_0000B(self):
# digits = [0, 0, 0, 0, 11]
# rval = 340000 + 1 = 340001
assert decode_norad("0000B") == 340001
def test_0001A(self):
# digits = [0, 0, 0, 1, 10]
# rval = 340000 + 0 + 54*(1) = 340054
assert decode_norad("0001A") == 340054
# ── decode_norad: Case (4) Super-5 (4th char non-digit) ─────
class TestDecodeNoradSuper5Case4:
"""Case (4): xxxXd — 4th character is non-digit.
rval = 340000 + 905969664 + d4 + (d3-10)*10 + 540*(d2 + d1*64 + d0*64^2)
"""
def test_000A0(self):
# digits = [0, 0, 0, 10, 0]
# rval = 340000 + 905969664 + 0 + 0 + 0 = 906309664
assert decode_norad("000A0") == 906309664
# ── decode_norad: edge cases ────────────────────────────────
class TestDecodeNoradEdgeCases:
def test_empty(self):
assert decode_norad("") is None
assert decode_norad(" ") is None
def test_short(self):
assert decode_norad("123") is None
def test_invalid_chars(self):
assert decode_norad("!@#$%") is None
# ── parse_3le_text ──────────────────────────────────────────
class TestParse3LE:
SAMPLE_3LE = textwrap.dedent("""\
ISS (ZARYA)
1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9018
2 25544 51.6400 100.0000 0007417 30.0000 330.1234 15.49456789999990
STARLINK-1234
1 T0002U 20001A 24002.50000000 .00001234 00000-0 12345-4 0 9990
2 T0002 53.0000 200.0000 0001234 45.0000 315.0000 15.12345678123456
""")
def test_parses_standard_norad(self):
records = parse_3le_text(self.SAMPLE_3LE)
assert 25544 in records
assert records[25544].name == "ISS (ZARYA)"
def test_parses_alpha5_norad(self):
records = parse_3le_text(self.SAMPLE_3LE)
# T0002 → 270002
assert 270002 in records
assert records[270002].name == "STARLINK-1234"
def test_epoch_extraction(self):
records = parse_3le_text(self.SAMPLE_3LE)
assert records[25544].epoch == pytest.approx(24001.5, rel=1e-6)
def test_record_properties(self):
records = parse_3le_text(self.SAMPLE_3LE)
iss = records[25544]
assert iss.mean_motion == pytest.approx(15.49456789, rel=1e-6)
assert iss.inclination == pytest.approx(51.64, rel=1e-2)
def test_2le_format(self):
"""2LE format (no name lines) should still parse."""
tle_2le = textwrap.dedent("""\
1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9018
2 25544 51.6400 100.0000 0007417 30.0000 330.1234 15.49456789999990
""")
records = parse_3le_text(tle_2le)
assert 25544 in records
assert records[25544].name == ""
def test_epoch_dedup(self):
"""When same NORAD ID appears twice, newest epoch wins."""
tle = textwrap.dedent("""\
OLD
1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9018
2 25544 51.6400 100.0000 0007417 30.0000 330.1234 15.49456789999990
NEW
1 25544U 98067A 24005.50000000 .00016717 00000-0 10270-3 0 9018
2 25544 51.6400 100.0000 0007417 30.0000 330.1234 15.49456789999990
""")
records = parse_3le_text(tle)
assert records[25544].name == "NEW"
assert records[25544].epoch == pytest.approx(24005.5, rel=1e-6)
def test_source_propagation(self):
records = parse_3le_text(self.SAMPLE_3LE, source="test-source")
assert records[25544].source == "test-source"
# ── parse_3le_file ──────────────────────────────────────────
class TestParse3LEFile:
def test_missing_file(self, capsys):
records = parse_3le_file("/nonexistent/file.tle")
assert records == {}
def test_file_roundtrip(self, tmp_path):
tle_content = textwrap.dedent("""\
TEST SAT
1 00001U 58001A 24001.50000000 .00000000 00000-0 00000-0 0 9990
2 00001 30.0000 100.0000 0010000 45.0000 315.0000 15.00000000999990
""")
p = tmp_path / "test.tle"
p.write_text(tle_content)
records = parse_3le_file(p)
assert 1 in records
assert records[1].name == "TEST SAT"