Implement Galileo E1B I/NAV navigation decoding

This commit is contained in:
2026-07-31 00:15:34 +03:00
parent 2a6ccfc23d
commit 814a788b36
104 changed files with 6351 additions and 10 deletions
+1
View File
@@ -0,0 +1 @@
"""Galileo receiver test package."""
@@ -0,0 +1,22 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
@pytest.mark.signalsim
def test_four_satellites_are_navigation_ready_without_truth_inputs() -> None:
path = Path("reports/navigation_run/inav_collection_report.json")
if not path.is_file():
pytest.skip("SignalSim multi-satellite navigation collection report is unavailable")
report = json.loads(path.read_text(encoding="utf-8"))
assert report["decoder_truth_inputs"] == []
ready = [
item for item in report["satellites"].values()
if item["complete_ephemeris"] and item["clock_parameters"] and item["health_valid"] and item["data_valid"]
]
assert len(ready) >= 4
assert report["galileo_time"]["valid"] is True
assert report["navigation_ready"] is True
@@ -0,0 +1,20 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
@pytest.mark.signalsim
def test_signalsim_decoded_ephemerides_match_rinex_truth_within_quantization() -> None:
path = Path("reports/inav_truth_comparison.json")
if not path.is_file():
pytest.skip("post-decode SignalSim truth-comparison report is unavailable")
report = json.loads(path.read_text(encoding="utf-8"))
assert report["truth_injected_into_decoder"] is False
assert report["overall_pass"] is True
assert len(report["satellites"]) >= 4
assert all(item["pass"] for item in report["satellites"].values())
assert report["galileo_time"]["pass"] is True
assert report["galileo_time"]["time_of_week_absolute_error_s"] <= 1
@@ -0,0 +1,19 @@
from __future__ import annotations
from pathlib import Path
import pytest
from galileo_rx.navigation.decoder_diagnostics import load_prompt_epochs
from galileo_rx.navigation.inav_pipeline import InavNavigationPipeline
@pytest.mark.signalsim
def test_signalsim_prompts_produce_crc_valid_inav_pages() -> None:
path = Path("verification/navigation/signalsim_navigation_multisat/prn_01/prompt_epochs.npz")
if not path.is_file():
pytest.skip("SignalSim navigation prompt artifact is unavailable; run collect-navigation first")
decoded = InavNavigationPipeline(1).process(load_prompt_epochs(path, 1))
assert len(decoded.pages) == 15
assert all(page.sync_valid and page.fec_valid and page.crc_valid for page in decoded.pages)
assert {word.word_type for word in decoded.words} == {1, 2, 3, 4, 5, 6}
@@ -0,0 +1,23 @@
from __future__ import annotations
from pathlib import Path
import numpy as np
import pytest
from galileo_rx.navigation.decoder_diagnostics import load_prompt_epochs
from galileo_rx.navigation.e1b_symbol_sync import E1BSymbolSynchronizer
@pytest.mark.signalsim
def test_signalsim_prompt_artifact_recovers_soft_and_hard_symbols() -> None:
path = Path("verification/navigation/signalsim_navigation_multisat/prn_01/prompt_epochs.npz")
if not path.is_file():
pytest.skip("SignalSim navigation prompt artifact is unavailable; run collect-navigation first")
prompts = load_prompt_epochs(path, 1)
symbols = E1BSymbolSynchronizer(8, 0.1).process_many(prompts)
soft = np.asarray([item.soft_value for item in symbols], dtype=np.float64)
assert soft.dtype == np.float64
assert len(symbols) == 8044
assert np.count_nonzero([item.valid for item in symbols]) > 7500
assert {item.hard_value for item in symbols} == {0, 1}
+1
View File
@@ -0,0 +1 @@
"""Navigation decoder tests."""
+19
View File
@@ -0,0 +1,19 @@
from __future__ import annotations
import numpy as np
from galileo_rx.navigation.inav_crc import crc24q, validate_crc24q
def test_crc24q_independent_check_value() -> None:
bits = np.unpackbits(np.frombuffer(b"123456789", dtype=np.uint8), bitorder="big")
assert crc24q(bits) == 0xCDE703
def test_crc_rejects_one_bit_error() -> None:
protected = np.asarray([0, 1, 1, 0, 1] * 39 + [1], dtype=np.int8)
value = crc24q(protected)
crc_bits = np.asarray([(value >> shift) & 1 for shift in range(23, -1, -1)], dtype=np.int8)
assert validate_crc24q(protected, crc_bits)
protected[17] ^= 1
assert not validate_crc24q(protected, crc_bits)
+25
View File
@@ -0,0 +1,25 @@
from __future__ import annotations
import numpy as np
from galileo_rx.navigation.inav_deinterleaver import deinterleave_inav
# Independent numerical example from OS SIS ICD 2.2 Annex D.2.
ENCODED = (
"1000110000011010101010100111001100110001010110100110111101011001"
"0111100010010101010101011000110011001110101001011001000010100110"
"1000010000000010000000100001101110011001111010110101110000011000"
"101110111111110111111101111001000101001100100010"
)
INTERLEAVED = (
"1010000001011111100011001111000001011110101000000001100111100011"
"1010100001010000010011110101011101111000100001101111101011100111"
"1001100000011111111000100000100111110110000010011100011101100000"
"100101110100100011000110110110010000011100111010"
)
def test_icd_annex_d2_deinterleaving_vector() -> None:
output = deinterleave_inav(np.fromiter(map(int, INTERLEAVED), dtype=np.int8))
assert "".join(map(str, output)) == ENCODED
@@ -0,0 +1,60 @@
from __future__ import annotations
from galileo_rx.navigation.inav_ephemeris_assembler import InavEphemerisAssembler
from galileo_rx.navigation.inav_word_parser import (
EphemerisClockWord4, EphemerisWord1, EphemerisWord2, EphemerisWord3,
IonosphereHealthTimeWord5, ParsedInavWord,
)
def _word(prn: int, kind: int, iod: int, timestamp: int) -> ParsedInavWord:
if kind == 1:
payload = EphemerisWord1(iod, 1, 60.0, 1, 0.1, 1, 0.01, 1, 5000.0)
elif kind == 2:
payload = EphemerisWord2(iod, 1, 0.2, 1, 0.3, 1, 0.4, 1, 0.01)
elif kind == 3:
payload = EphemerisWord3(iod, 1, 0.01, 1, 0.01, 1, 0.01, 1, 0.01, 1, 1.0, 1, 1.0, 10)
else:
payload = EphemerisClockWord4(iod, prn, 1, 0.01, 1, 0.01, 1, 60.0, 1, 0.1, 1, 0.01, 1, 0.001)
return ParsedInavWord(prn, kind, timestamp, iod, payload, [], True, True)
def _health(prn: int) -> ParsedInavWord:
payload = IonosphereHealthTimeWord5(0, 0.0, 0, 0.0, 0, 0.0, (False,) * 5, 0, 0.0, 0, 0.0, 0, 0, 0, 0, 10, 20)
return ParsedInavWord(prn, 5, 0, None, payload, [], True, True)
def test_assembler_rejects_mixed_iod_and_commits_atomically() -> None:
assembler = InavEphemerisAssembler(3)
assembler.update(_health(3))
for kind in (1, 2, 3):
assert assembler.update(_word(3, kind, 5, kind)) is None
assert assembler.update(_word(3, 4, 6, 4)) is None
assert assembler.current is None
complete = assembler.update(_word(3, 4, 5, 5))
assert complete is not None and complete.iod_nav == 5
assert complete.health_valid and complete.data_valid
assert assembler.missing_word_types() == set()
def test_previous_ephemeris_survives_incomplete_update() -> None:
assembler = InavEphemerisAssembler(3)
assembler.update(_health(3))
for kind in (1, 2, 3, 4):
assembler.update(_word(3, kind, 5, kind))
current = assembler.current
assembler.update(_word(3, 1, 6, 10))
assert assembler.current is current
def test_stale_partial_set_is_expired_without_replacing_valid_ephemeris() -> None:
assembler = InavEphemerisAssembler(3, max_partial_age_samples=10)
assembler.update(_health(3))
for kind in (1, 2, 3, 4):
assembler.update(_word(3, kind, 5, kind))
current = assembler.current
assembler.update(_word(3, 1, 6, 20))
assembler.update(_word(3, 2, 7, 40))
assert assembler.stale_partial_sets == 1
assert assembler.current is current
assert assembler.missing_word_types() == set()
+17
View File
@@ -0,0 +1,17 @@
from __future__ import annotations
from galileo_rx.navigation.navigation_store import NavigationStore
from tests.navigation.test_ephemeris_assembler import _health, _word
def test_navigation_readiness_requires_four_complete_healthy_satellites() -> None:
store = NavigationStore()
for prn in range(1, 5):
store.update_word(_health(prn))
for kind in (1, 2, 3, 4):
store.update_word(_word(prn, kind, prn, prn * 10 + kind))
assert store.navigation_ready(minimum_satellites=4, require_time=True)
assert store.navigation_ready(minimum_satellites=4, require_time=False)
assert store.get_time_solution() is not None
assert store.has_complete_ephemeris(4)
assert store.get_clock_parameters(2) is not None
+54
View File
@@ -0,0 +1,54 @@
from __future__ import annotations
import numpy as np
from galileo_rx.navigation.e1b_symbol_sync import NavigationSymbol
from galileo_rx.navigation.inav_page_sync import INAV_SYNC_BITS, InavPageSynchronizer
def _symbols(bits: np.ndarray, *, invert: bool = False, prefix: int = 17) -> list[NavigationSymbol]:
raw = np.concatenate((np.zeros(prefix, dtype=np.int8), bits))
if invert:
raw = 1 - raw
return [NavigationSymbol(1, i, i * 4, (i + 1) * 4, 1.0 if bit else -1.0, int(bit), 1.0, False, True) for i, bit in enumerate(raw)]
def test_page_sync_rejects_wrong_boundary_and_requires_repetition() -> None:
rng = np.random.default_rng(4)
part = np.concatenate((INAV_SYNC_BITS, rng.integers(0, 2, 240, dtype=np.int8)))
stream = _symbols(np.tile(part, 3))
sync = InavPageSynchronizer(required_confirmations=2)
first = sync.process_many(stream[:267])
assert first == []
output = sync.process_many(stream[267:])
assert len(output) == 3
assert all(item.sync_valid for item in output)
def test_page_sync_records_global_inversion() -> None:
body = np.zeros(240, dtype=np.int8)
parts = InavPageSynchronizer().process_many(_symbols(np.tile(np.concatenate((INAV_SYNC_BITS, body)), 3), invert=True))
assert len(parts) == 3
assert all(item.polarity_inverted for item in parts)
assert np.array_equal(parts[0].hard_symbols[:10], INAV_SYNC_BITS)
def test_page_sync_loses_and_reacquires_after_corrupted_sync() -> None:
body = np.zeros(240, dtype=np.int8)
part = np.concatenate((INAV_SYNC_BITS, body))
bits = np.tile(part, 6)
bits[3 * 250] ^= 1
output = InavPageSynchronizer().process_many(_symbols(bits, prefix=0))
assert len(output) == 5
assert [item.receive_sample_timestamp // (250 * 4) for item in output] == [0, 1, 2, 4, 5]
def test_page_sync_does_not_lock_through_symbol_erasure() -> None:
body = np.zeros(240, dtype=np.int8)
symbols = _symbols(np.tile(np.concatenate((INAV_SYNC_BITS, body)), 3), prefix=0)
erased = symbols[250]
symbols[250] = NavigationSymbol(
erased.prn, erased.symbol_index, erased.start_sample_timestamp,
erased.end_sample_timestamp, 0.0, 0, 0.0, False, False,
)
assert InavPageSynchronizer().process_many(symbols) == []
+35
View File
@@ -0,0 +1,35 @@
from __future__ import annotations
import numpy as np
from galileo_rx.navigation.e1b_prompt_stream import E1BPromptEpoch
from galileo_rx.navigation.e1b_symbol_sync import E1BSymbolSynchronizer
def _epoch(index: int, sign: int, *, slip: bool = False) -> E1BPromptEpoch:
start = index * 16
prompt = sign * np.exp(1j * 0.37)
return E1BPromptEpoch(
1, start, start + 16, float(prompt.real), float(prompt.imag), 0.0, 1000.0, 0.0,
50.0, True, True, slip, False, True,
)
def test_symbol_sync_preserves_global_polarity_ambiguity() -> None:
truth = np.asarray([1, -1] * 12, dtype=np.int8)
synchronizer = E1BSymbolSynchronizer(minimum_axis_epochs=4, minimum_confidence=0.9)
symbols = synchronizer.process_many([_epoch(i, int(-value)) for i, value in enumerate(truth)])
hard = np.asarray([item.hard_value for item in symbols[4:]], dtype=np.int8)
# Either polarity is valid here; page synchronization resolves it later.
expected_bits = (truth[4:] > 0).astype(np.int8)
assert np.array_equal(hard, expected_bits) or np.array_equal(hard, 1 - expected_bits)
assert all(not item.inverted for item in symbols)
def test_cycle_slip_invalidates_and_restarts_axis_lock() -> None:
sync = E1BSymbolSynchronizer(minimum_axis_epochs=3, minimum_confidence=0.1)
symbols = [sync.process(_epoch(i, 1, slip=i == 5)) for i in range(10)]
assert symbols[5].valid is False
assert symbols[6].valid is False
assert symbols[7].valid is True
assert sync.loss_count == 1
+13
View File
@@ -0,0 +1,13 @@
from __future__ import annotations
from galileo_rx.navigation.galileo_time import GalileoTimeRecovery
from galileo_rx.navigation.inav_word_parser import IonosphereHealthTimeWord5, ParsedInavWord
def test_gst_week_and_tow_recovery_from_word5() -> None:
payload = IonosphereHealthTimeWord5(0, 0.0, 0, 0.0, 0, 0.0, (False,) * 5, 0, 0.0, 0, 0.0, 0, 0, 0, 0, 1138, 554763)
result = GalileoTimeRecovery().update(ParsedInavWord(1, 5, 1234, None, payload, [], True, True))
assert result is not None
assert result.week_number == 1138
assert result.time_of_week_s == 554763
assert result.page_start_sample_timestamp == 1234
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
import numpy as np
from galileo_rx.navigation.inav_fec import InavViterbiDecoder
from tests.navigation.test_deinterleaver import ENCODED
SOURCE = (
"1111111111110000110011001010101000000000000011110011001101010101"
"11100011111011001101111110001010000111000001001101000000"
)
def test_hard_and_soft_viterbi_against_independent_icd_vector() -> None:
coded = np.fromiter(map(int, ENCODED), dtype=np.int8)
decoder = InavViterbiDecoder(traceback_depth=35)
hard = decoder.decode(coded, mode="hard")
soft = decoder.decode(np.where(coded, 4.0, -4.0), mode="soft")
assert hard.valid and soft.valid
assert "".join(map(str, hard.decoded_bits)) == SOURCE
assert hard.decoded_bits == soft.decoded_bits
def test_soft_viterbi_corrects_individual_and_burst_errors() -> None:
coded = np.fromiter(map(int, ENCODED), dtype=np.int8)
soft = np.where(coded, 5.0, -5.0)
soft[[20, 81, 177]] *= -1.0
soft[120:124] *= -1.0
result = InavViterbiDecoder().decode(soft, mode="soft")
assert result.valid
assert "".join(map(str, result.decoded_bits)) == SOURCE
assert result.corrected_error_metric == 7.0
+25
View File
@@ -0,0 +1,25 @@
from __future__ import annotations
from galileo_rx.navigation.inav_page_decoder import InavPage
from galileo_rx.navigation.inav_word_parser import EphemerisClockWord4, InavWordParser
def _put(bits: list[int], start: int, width: int, value: int) -> None:
if value < 0:
value += 1 << width
for index in range(width):
bits[start + index] = (value >> (width - 1 - index)) & 1
def test_word4_signed_scaling_and_units() -> None:
bits = [0] * 128
for start, width, value in [(0, 6, 4), (6, 10, 77), (16, 6, 12), (22, 16, -3), (38, 16, 5), (54, 14, 9000), (68, 31, -100), (99, 21, 200), (120, 6, -2)]:
_put(bits, start, width, value)
page = InavPage(12, 0, 100, [], [], 4, True, True, True, False, 0.0, 0, bits, 200)
parsed = InavWordParser().parse(page)
assert parsed.iod_nav == 77
assert isinstance(parsed.payload, EphemerisClockWord4)
assert parsed.payload.svid == 12
assert parsed.payload.cic_raw == -3
assert parsed.payload.toc_s == 540000.0
assert parsed.payload.clock_bias_s == -100 * 2.0**-34
+18
View File
@@ -0,0 +1,18 @@
from __future__ import annotations
import numpy as np
from galileo_rx.tracking.lock_detector import LockDetector
from galileo_rx.types import CorrelatorOutputs
def _correlators(phase_rad: float) -> CorrelatorOutputs:
prompt = complex(np.exp(1j * phase_rad))
return CorrelatorOutputs(0.2 * prompt, 0.5 * prompt, prompt, 0.5 * prompt, 0.2 * prompt)
def test_data_lock_detector_ignores_bpsk_transition_but_detects_quadrature_jump() -> None:
detector = LockDetector(data_sign_insensitive=True)
detector.update(_correlators(0.0))
assert detector.update(_correlators(np.pi)).cycle_slip is False
assert detector.update(_correlators(np.pi + 0.49 * np.pi)).cycle_slip is True