"""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"