Implement Galileo E1B I/NAV navigation decoding
This commit is contained in:
@@ -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}
|
||||
@@ -0,0 +1 @@
|
||||
"""Navigation decoder tests."""
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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) == []
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user