Add BOC-aware lock detection and E1C-aided tracking

This commit is contained in:
2026-07-31 01:13:35 +03:00
parent 814a788b36
commit 9db955d02e
133 changed files with 6536 additions and 42 deletions
@@ -0,0 +1,78 @@
from __future__ import annotations
from pathlib import Path
import numpy as np
import pytest
import yaml
from galileo_rx.acquisition.acquisition_manager import AcquisitionManager
from galileo_rx.config import AcquisitionConfig, TrackingConfig
from galileo_rx.io.signalsim_reader import SignalSimReader, SignalSimSampleSpec
from galileo_rx.signal_profiles import galileo_e1_profile
from galileo_rx.signals.signalsim_codes import SignalSimCodeProvider
from galileo_rx.tracking.pilot_aided_e1 import (
E1CPilotAidedE1TrackingChannel,
synchronize_e1c_for_tracking,
)
@pytest.mark.signalsim
def test_e1c_secondary_sync_and_pilot_aided_e1b_branch() -> None:
cfg = yaml.safe_load(Path("configs/navigation_collection.yaml").read_text(encoding="utf-8"))
source = Path(cfg["signalsim"]["path"])
vector = Path(cfg["signalsim"]["samples"])
if not source.is_dir() or not vector.is_file():
pytest.skip("SignalSim checkout or 32.2-second navigation IQ8 vector is unavailable")
fs = float(cfg["rf"]["sampling_frequency_hz"])
samples = SignalSimReader(
vector, SignalSimSampleSpec(cfg["signalsim"]["sample_format"]), memory_map=True
).read()
profile = galileo_e1_profile()
provider = SignalSimCodeProvider(source)
acquisition_config = AcquisitionConfig(
**cfg["acquisition"], parallelism="doppler_vectorized"
)
manager = AcquisitionManager(profile, provider, fs, acquisition_config)
observation_samples = (
manager.engine.coherent_samples * acquisition_config.noncoherent_accumulations
)
acquisition, _ = manager.acquire_one(
samples[:observation_samples], "E1C", 1, float(cfg["rf"]["intermediate_frequency_hz"])
)
assert acquisition.detected
synchronized = synchronize_e1c_for_tracking(
samples, profile, provider, fs, acquisition, observation_samples
)
assert synchronized.sync.normalized_correlation > 0.98
assert synchronized.sync.sign_errors == 0
channel = E1CPilotAidedE1TrackingChannel(
profile,
provider,
fs,
TrackingConfig(
integration_ms=2.0,
pull_in_duration_ms=300.0,
pll_noise_bandwidth_hz=10.0,
fll_noise_bandwidth_hz=20.0,
dll_noise_bandwidth_hz=1.0,
secondary_code_mode="wipeoff",
loss_of_lock_epochs=40,
),
synchronized.acquisition,
synchronized.tracking_start_sample,
synchronized.sync.phase,
)
count = int(round(fs * 1.0))
epochs = channel.process_pilot_aided(
samples[synchronized.tracking_start_sample : synchronized.tracking_start_sample + count]
)
pilot = [item.pilot for item in epochs]
assert len(pilot) == 500
assert sum(item.code_lock and item.carrier_lock for item in pilot[30:]) > 460
assert not any(item.cycle_slip or item.sample_discontinuity for item in pilot)
assert sum(pilot[index - 1].code_lock and not pilot[index].code_lock for index in range(1, len(pilot))) == 0
assert np.median(
np.asarray([abs(item.e1b_correlators.prompt) for item in epochs[150:]], dtype=np.float64)
) > 1.0e4
+24
View File
@@ -0,0 +1,24 @@
from __future__ import annotations
import numpy as np
from galileo_rx.diagnostics.boc_lock_validation import (
boc11_autocorrelation,
boc11_five_tap_shape,
)
from galileo_rx.signal_profiles import galileo_e1_profile
from galileo_rx.signals.code_provider import SyntheticCodeProvider
def test_ideal_boc11_shoulder_and_negative_sidelobe_metrics() -> None:
profile = galileo_e1_profile()
code = SyntheticCodeProvider().primary_code(profile, "E1B", 1)
shape = boc11_autocorrelation(code, samples_per_chip=32)
taps = boc11_five_tap_shape(shape, np.asarray([0.0, 0.25, 0.5], dtype=np.float64))
# Finite-code adjacent-chip cross terms perturb the ideal 0.1/0.84/1/3
# values slightly; the classification must remain unchanged.
assert 0.08 < taps["legacy_prompt_el_ratio"][1] < 0.12
assert 0.80 < taps["main_peak_fraction"][1] < 0.88
assert 0.28 < taps["main_peak_coherence"][1] < 0.38
assert -0.55 < taps["prompt_amplitude"][2] < -0.45
assert abs(taps["main_peak_coherence"][2]) < 1e-12
+87 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import numpy as np
from galileo_rx.tracking.lock_detector import LockDetector
from galileo_rx.tracking.lock_detector import BocLockDetectorConfig, LockDetector
from galileo_rx.types import CorrelatorOutputs
@@ -16,3 +16,89 @@ def test_data_lock_detector_ignores_bpsk_transition_but_detects_quadrature_jump(
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
def _detector() -> LockDetector:
return LockDetector(
data_sign_insensitive=True,
config=BocLockDetectorConfig(
smoothing_epochs=4,
confirmation_epochs=2,
failure_epochs=3,
),
)
def _repeat(detector: LockDetector, values: CorrelatorOutputs, count: int):
return [detector.update(values) for _ in range(count)]
def test_boc_lock_metric_validity_is_separate_from_lock_state() -> None:
detector = _detector()
statuses = _repeat(detector, _correlators(0.0), 5)
assert [status.metric_valid for status in statuses] == [False, False, False, True, True]
assert statuses[3].code_locked is False
assert statuses[4].code_locked is True
def test_boc_quarter_chip_shoulder_does_not_cause_false_unlock() -> None:
detector = _detector()
centered = CorrelatorOutputs(0.25 + 0j, 0.25 + 0j, 1.0 + 0j, 0.25 + 0j, 0.25 + 0j)
_repeat(detector, centered, 8)
# Ideal BOC(1,1) at +0.25 chip: one E/L tap is at the main peak,
# while P is only 0.25 in amplitude. The legacy P/E/L ratio is 0.1.
shoulder = CorrelatorOutputs(-0.5 + 0j, 1.0 + 0j, 0.25 + 0j, -0.5 + 0j, 0j)
statuses = _repeat(detector, shoulder, 12)
assert min(status.code_metric for status in statuses) < 0.11
assert all(status.code_locked for status in statuses)
assert not any(status.side_peak_suspected for status in statuses)
def test_boc_outer_tap_side_peak_and_signal_loss_are_detected_after_debounce() -> None:
detector = _detector()
centered = CorrelatorOutputs(0.25 + 0j, 0.25 + 0j, 1.0 + 0j, 0.25 + 0j, 0.25 + 0j)
_repeat(detector, centered, 8)
side_peak = CorrelatorOutputs(1.0 + 0j, 0.5 + 0j, 0.25 + 0j, 0j, 0j)
side_status = _repeat(detector, side_peak, 8)
assert any(status.side_peak_suspected for status in side_status)
assert side_status[-1].code_locked is False
detector = _detector()
_repeat(detector, centered, 8)
lost = _repeat(detector, CorrelatorOutputs(), 8)
assert lost[-1].signal_present is False
assert lost[-1].code_locked is False
def test_boc_negative_half_chip_prompt_side_peak_is_detected_by_tap_coherence() -> None:
detector = _detector()
centered = CorrelatorOutputs(-0.25 + 0j, 0.25 + 0j, 1.0 + 0j, 0.25 + 0j, -0.25 + 0j)
_repeat(detector, centered, 8)
# At +0.5 chip the Prompt is the negative BOC sidelobe. E and L have
# opposing signs, so powers alone are ambiguous but Re{(E+L)P*} is zero.
half_chip_side = CorrelatorOutputs(0.25 + 0j, 0.25 + 0j, -0.5 + 0j, -0.25 + 0j, 0j)
statuses = _repeat(detector, half_chip_side, 8)
assert statuses[-1].boc_main_peak_coherence <= 0.15
assert statuses[-1].side_peak_suspected is True
assert statuses[-1].code_locked is False
def test_single_bad_boc_epoch_is_debounced() -> None:
detector = _detector()
centered = CorrelatorOutputs(0.25 + 0j, 0.25 + 0j, 1.0 + 0j, 0.25 + 0j, 0.25 + 0j)
_repeat(detector, centered, 8)
side_peak = CorrelatorOutputs(1.0 + 0j, 0.5 + 0j, 0.25 + 0j, 0j, 0j)
assert detector.update(side_peak).code_locked is True
assert detector.update(centered).code_locked is True
def test_invalid_integration_does_not_enter_power_smoother() -> None:
detector = _detector()
centered = CorrelatorOutputs(0.25 + 0j, 0.25 + 0j, 1.0 + 0j, 0.25 + 0j, 0.25 + 0j)
for _ in range(10):
status = detector.update(centered, metric_input_valid=False)
assert status.metric_valid is False
assert status.code_locked is False
statuses = _repeat(detector, centered, 5)
assert statuses[-1].metric_valid is True
assert statuses[-1].code_locked is True