Initial Galileo receiver and SignalSim integration
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import numpy as np
|
||||
|
||||
from galileo_rx.acquisition.detector import parabolic_offset
|
||||
from galileo_rx.acquisition.metrics import calculate_metrics, circular_guard_mask
|
||||
|
||||
|
||||
def test_circular_peak_exclusion_wraparound() -> None:
|
||||
mask = circular_guard_mask(10, 0, 2)
|
||||
assert not mask[0] and not mask[1] and not mask[2]
|
||||
assert not mask[8] and not mask[9]
|
||||
assert mask[3]
|
||||
|
||||
|
||||
def test_second_peak_ignores_guard() -> None:
|
||||
power = np.ones(20)
|
||||
power[0] = 100
|
||||
power[19] = 80
|
||||
power[8] = 10
|
||||
metrics = calculate_metrics(power, 0, 1)
|
||||
assert metrics["second_peak"] == 10
|
||||
assert metrics["peak_to_second"] == 10
|
||||
|
||||
|
||||
def test_parabolic_peak_interpolation() -> None:
|
||||
assert parabolic_offset(3.0, 4.0, 3.0) == 0.0
|
||||
assert parabolic_offset(2.0, 4.0, 3.0) > 0
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import numpy as np
|
||||
|
||||
from galileo_rx.config import AcquisitionConfig
|
||||
from galileo_rx.acquisition.fft_pcps import PcpsAcquisition
|
||||
from galileo_rx.signal_profiles import galileo_e1_profile
|
||||
from galileo_rx.signals.code_provider import SyntheticCodeProvider
|
||||
from galileo_rx.signals.signal_generator import ChannelModelConfig, SatelliteSignalConfig, SyntheticSignalGenerator
|
||||
|
||||
|
||||
def test_doppler_batched_matches_sequential() -> None:
|
||||
fs = 4.092e6
|
||||
profile, provider = galileo_e1_profile(), SyntheticCodeProvider()
|
||||
generator = SyntheticSignalGenerator(profile, provider, fs, satellites=[SatelliteSignalConfig(1, doppler_hz=500)], channel=ChannelModelConfig(add_awgn=False))
|
||||
samples, _ = generator.generate(int(0.004 * fs))
|
||||
common = dict(doppler_min_hz=0, doppler_max_hz=1000, doppler_step_hz=250, noncoherent_accumulations=1, zero_padding_mode="none")
|
||||
sequential = PcpsAcquisition(profile, provider, fs, AcquisitionConfig(**common, parallelism="sequential")).search(samples, "E1C", 1)
|
||||
batched = PcpsAcquisition(profile, provider, fs, AcquisitionConfig(**common, parallelism="batched", doppler_batch_size=3)).search(samples, "E1C", 1)
|
||||
np.testing.assert_allclose(batched.power, sequential.power, rtol=1e-12, atol=1e-6)
|
||||
@@ -0,0 +1,20 @@
|
||||
import pytest
|
||||
|
||||
from galileo_rx.config import ReceiverConfig, load_config
|
||||
|
||||
|
||||
def test_default_configuration_is_valid() -> None:
|
||||
ReceiverConfig().validate()
|
||||
|
||||
|
||||
def test_e1c_long_integration_requires_secondary_handling() -> None:
|
||||
config = ReceiverConfig()
|
||||
config.acquisition.coherent_integration_ms = 8.0
|
||||
with pytest.raises(ValueError, match="secondary-code"):
|
||||
config.validate()
|
||||
|
||||
|
||||
def test_yaml_load() -> None:
|
||||
config = load_config("configs/e1_default.yaml")
|
||||
assert config.rf.sampling_frequency_hz == 16.368e6
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import numpy as np
|
||||
|
||||
from galileo_rx.signal_profiles import galileo_e1_profile
|
||||
from galileo_rx.signals.code_provider import SyntheticCodeProvider
|
||||
from galileo_rx.signals.signal_generator import ChannelModelConfig, SatelliteSignalConfig, SyntheticSignalGenerator
|
||||
from galileo_rx.tracking.correlator import CorrelatorBank, SampleStreamCorrelator
|
||||
from galileo_rx.tracking.nco import CarrierNco, CodeNco
|
||||
|
||||
|
||||
def make_bank(fs: float, phase: float) -> CorrelatorBank:
|
||||
profile = galileo_e1_profile()
|
||||
return CorrelatorBank(
|
||||
profile,
|
||||
SyntheticCodeProvider(),
|
||||
"E1B",
|
||||
1,
|
||||
CarrierNco(fs, 500.0),
|
||||
CodeNco(fs, 4092, profile.code_rate_hz, phase),
|
||||
0.25,
|
||||
0.75,
|
||||
)
|
||||
|
||||
|
||||
def test_vector_and_sample_stream_correlators_agree() -> None:
|
||||
fs = 4.5e6
|
||||
profile = galileo_e1_profile()
|
||||
generator = SyntheticSignalGenerator(
|
||||
profile,
|
||||
SyntheticCodeProvider(),
|
||||
fs,
|
||||
satellites=[SatelliteSignalConfig(1, "E1B", code_delay_chips=3.2, doppler_hz=500.0)],
|
||||
channel=ChannelModelConfig(add_awgn=False),
|
||||
)
|
||||
samples, _ = generator.generate(500)
|
||||
vector = make_bank(fs, -3.2).correlate(samples)
|
||||
stream = SampleStreamCorrelator(make_bank(fs, -3.2))
|
||||
for sample in samples:
|
||||
stream.accept(sample)
|
||||
cycle = stream.dump()
|
||||
for tap in ("very_early", "early", "prompt", "late", "very_late"):
|
||||
assert abs(getattr(vector, tap) - getattr(cycle, tap)) < 1e-8
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import numpy as np
|
||||
|
||||
from galileo_rx.tracking.discriminators import (
|
||||
dll_noncoherent_power_chips,
|
||||
dll_normalized_envelope_chips,
|
||||
fll_bpsk_phase_difference_hz,
|
||||
fll_phase_difference_hz,
|
||||
pll_four_quadrant_rad,
|
||||
pll_two_quadrant_rad,
|
||||
)
|
||||
|
||||
|
||||
def test_pll_discriminator_sign_and_units() -> None:
|
||||
prompt = np.exp(1j * 0.2)
|
||||
assert abs(pll_four_quadrant_rad(prompt) - 0.2) < 1e-12
|
||||
assert abs(pll_two_quadrant_rad(prompt) - 0.2) < 1e-12
|
||||
|
||||
|
||||
def test_fll_phase_difference_is_hz() -> None:
|
||||
dt = 0.004
|
||||
assert abs(fll_phase_difference_hz(1 + 0j, np.exp(2j * np.pi * 10 * dt), dt) - 10.0) < 1e-12
|
||||
|
||||
|
||||
def test_bpsk_fll_rejects_data_sign_transition() -> None:
|
||||
dt = 0.004
|
||||
prompt = -np.exp(2j * np.pi * 10 * dt)
|
||||
assert abs(fll_bpsk_phase_difference_hz(1 + 0j, prompt, dt) - 10.0) < 1e-12
|
||||
|
||||
|
||||
def test_dll_sign() -> None:
|
||||
assert dll_normalized_envelope_chips(2 + 0j, 1 + 0j, 0.25) > 0
|
||||
assert dll_noncoherent_power_chips(2 + 0j, 1 + 0j, 0.25) > 0
|
||||
@@ -0,0 +1,31 @@
|
||||
import numpy as np
|
||||
|
||||
from galileo_rx.fixed_point.fixed_fft import FixedRadix2Fft
|
||||
from galileo_rx.fixed_point.formats import FixedFormat
|
||||
|
||||
|
||||
def test_fixed_fft_impulse_and_scaling() -> None:
|
||||
values = np.zeros(16, dtype=np.complex128)
|
||||
values[0] = 0.5
|
||||
model = FixedRadix2Fft(FixedFormat(20, 17))
|
||||
result = model.forward(values)
|
||||
np.testing.assert_allclose(result.dequantize(), np.fft.fft(values) / values.size, atol=4 * model.data_format.lsb)
|
||||
assert result.overflow_count == 0
|
||||
|
||||
|
||||
def test_fixed_fft_random_vector_and_inverse() -> None:
|
||||
rng = np.random.default_rng(91)
|
||||
values = 0.1 * (rng.standard_normal(32) + 1j * rng.standard_normal(32))
|
||||
model = FixedRadix2Fft(FixedFormat(24, 19))
|
||||
spectrum = model.forward(values).dequantize()
|
||||
np.testing.assert_allclose(spectrum, np.fft.fft(values) / values.size, atol=2e-5)
|
||||
restored = model.inverse(spectrum).dequantize()
|
||||
# IFFT of the already 1/N-scaled forward spectrum is x/N.
|
||||
np.testing.assert_allclose(restored, values / values.size, atol=2e-5)
|
||||
|
||||
|
||||
def test_fixed_fft_rejects_non_power_of_two() -> None:
|
||||
import pytest
|
||||
|
||||
with pytest.raises(ValueError, match="power of two"):
|
||||
FixedRadix2Fft().forward(np.ones(12))
|
||||
@@ -0,0 +1,59 @@
|
||||
import numpy as np
|
||||
|
||||
from galileo_rx.fixed_point.fixed_correlator import FixedCorrelator
|
||||
from galileo_rx.fixed_point.fixed_nco import FixedNco
|
||||
from galileo_rx.fixed_point.fixed_loop_filter import FixedSecondOrderLoopFilter
|
||||
from galileo_rx.fixed_point.formats import FixedFormat
|
||||
from galileo_rx.fixed_point.quantizer import Quantizer
|
||||
|
||||
|
||||
def test_saturation_and_overflow_counter() -> None:
|
||||
quantizer = Quantizer(FixedFormat(4, 2), overflow="saturate", rounding="nearest")
|
||||
result = quantizer.quantize([-3.0, -1.0, 0.0, 1.9, 3.0])
|
||||
np.testing.assert_array_equal(result, [-8, -4, 0, 7, 7])
|
||||
assert quantizer.statistics.overflow_count == 3
|
||||
|
||||
|
||||
def test_twos_complement_wrap() -> None:
|
||||
quantizer = Quantizer(FixedFormat(4, 0), overflow="wrap", rounding="truncate")
|
||||
np.testing.assert_array_equal(quantizer.quantize([8, 9, -9]), [-8, -7, 7])
|
||||
|
||||
|
||||
def test_convergent_rounding_ties_to_even() -> None:
|
||||
quantizer = Quantizer(FixedFormat(8, 1), rounding="convergent")
|
||||
np.testing.assert_array_equal(quantizer.quantize([0.25, 0.75, 1.25]), [0, 2, 2])
|
||||
|
||||
|
||||
def test_fixed_nco_phase_continuity_and_frequency_word() -> None:
|
||||
whole = FixedNco(10e6, 1e6)
|
||||
split = FixedNco(10e6, 1e6)
|
||||
c1, s1 = whole.generate(50)
|
||||
ca, sa = split.generate(17)
|
||||
cb, sb = split.generate(33)
|
||||
np.testing.assert_array_equal(c1, np.concatenate([ca, cb]))
|
||||
np.testing.assert_array_equal(s1, np.concatenate([sa, sb]))
|
||||
assert abs(whole.frequency_control_word * 10e6 / 2**32 - 1e6) < 10e6 / 2**33
|
||||
|
||||
|
||||
def test_fixed_correlator_close_to_float() -> None:
|
||||
fs = 1e6
|
||||
count = 1000
|
||||
time_s = np.arange(count) / fs
|
||||
replica = np.where(np.arange(count) % 3, 1, -1)
|
||||
samples = 0.5 * replica * np.exp(2j * np.pi * 125e3 * time_s)
|
||||
model = FixedCorrelator(fs, 125e3)
|
||||
result = model.correlate_prompt(samples, replica)
|
||||
assert abs(result.complex_value.real - 500.0) < 5.0
|
||||
assert abs(result.complex_value.imag) < 5.0
|
||||
assert result.overflow_count == 0
|
||||
|
||||
|
||||
def test_fixed_loop_filter_tracks_double_reference() -> None:
|
||||
from galileo_rx.tracking.loop_filters import SecondOrderLoopFilter
|
||||
|
||||
fixed = FixedSecondOrderLoopFilter(2.0, 0.004)
|
||||
reference = SecondOrderLoopFilter(2.0)
|
||||
fixed_output = np.asarray([fixed.update(0.02) for _ in range(100)])
|
||||
reference_output = np.asarray([reference.update(0.02, 0.004) for _ in range(100)])
|
||||
assert np.sqrt(np.mean((fixed_output - reference_output) ** 2)) < 1e-3
|
||||
assert fixed.overflow_count == 0
|
||||
@@ -0,0 +1,32 @@
|
||||
import numpy as np
|
||||
|
||||
from galileo_rx.config import FrontendConfig, RfConfig
|
||||
from galileo_rx.frontend.frontend_chain import FrontendChain
|
||||
from galileo_rx.frontend.mixer import PhaseAccumulatorNco
|
||||
|
||||
|
||||
def test_nco_frequency_word_accuracy_and_block_continuity() -> None:
|
||||
full = PhaseAccumulatorNco(10.0e6, 1.234567e6, phase_width=32)
|
||||
blocked = PhaseAccumulatorNco(10.0e6, 1.234567e6, phase_width=32)
|
||||
expected = full.generate(100)
|
||||
actual = np.concatenate([blocked.generate(37), blocked.generate(63)])
|
||||
np.testing.assert_allclose(actual, expected, atol=1e-14)
|
||||
assert abs(full.actual_frequency_hz - 1.234567e6) <= 10.0e6 / 2**33
|
||||
|
||||
|
||||
def test_real_if_mixer_sign_and_phase_continuity() -> None:
|
||||
fs = 8.0e6
|
||||
rf = RfConfig(sample_format="float64", input_is_real_if=True, sampling_frequency_hz=fs, intermediate_frequency_hz=2.0e6)
|
||||
chain = FrontendChain(rf, FrontendConfig())
|
||||
time_s = np.arange(160) / fs
|
||||
real_if = np.cos(2 * np.pi * 2.0e6 * time_s)
|
||||
result = np.concatenate([chain.process(real_if[:61]), chain.process(real_if[61:])])
|
||||
# A real mixer produces desired DC plus the unfiltered negative-frequency image.
|
||||
assert abs(np.mean(result) - 0.5) < 0.02
|
||||
|
||||
|
||||
def test_signed_sample_conversion() -> None:
|
||||
rf = RfConfig(sample_format="int8", sampling_frequency_hz=4.0e6, intermediate_frequency_hz=0.0, sample_bit_width=8)
|
||||
chain = FrontendChain(rf, FrontendConfig())
|
||||
result = chain.process(np.array([-128, 0, 127], dtype=np.int8))
|
||||
np.testing.assert_allclose(result.real, [-1.0, 0.0, 127 / 128])
|
||||
@@ -0,0 +1,23 @@
|
||||
import numpy as np
|
||||
|
||||
from galileo_rx.tracking.loop_filters import SecondOrderLoopFilter, ThirdOrderLoopFilter, second_order_natural_frequency_rad_s
|
||||
|
||||
|
||||
def test_loop_coefficients_are_physical_and_positive() -> None:
|
||||
wn = second_order_natural_frequency_rad_s(15.0, 0.707)
|
||||
loop = SecondOrderLoopFilter(15.0, 0.707)
|
||||
assert wn > 0 and loop.proportional_gain > 0 and loop.integral_gain > 0
|
||||
|
||||
|
||||
def test_second_order_step_response_is_finite_and_monotonic_initially() -> None:
|
||||
loop = SecondOrderLoopFilter(2.0)
|
||||
output = np.asarray([loop.update(0.1, 0.004) for _ in range(100)])
|
||||
assert np.all(np.isfinite(output))
|
||||
assert output[-1] > output[0] > 0
|
||||
|
||||
|
||||
def test_third_order_constant_error_has_finite_response() -> None:
|
||||
loop = ThirdOrderLoopFilter(5.0)
|
||||
output = [loop.update(0.01, 0.004) for _ in range(100)]
|
||||
assert np.all(np.isfinite(output)) and output[-1] > output[0]
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
from galileo_rx.rtl_model.cycle_model import AcquisitionArchitectureEstimate, TrackingScheduler
|
||||
|
||||
|
||||
def test_acquisition_sizing_formulas() -> None:
|
||||
estimate = AcquisitionArchitectureEstimate(16.368e6, 0.004, 5, 36, 81, 65536, 200e6, 4)
|
||||
assert estimate.samples_per_coherent_interval == 65472
|
||||
assert estimate.fft_operations_per_prn == 810
|
||||
assert estimate.sample_buffer_complex_words == 327360
|
||||
assert estimate.acquisition_time_s > 0
|
||||
|
||||
|
||||
def test_tracking_scheduler_condition() -> None:
|
||||
good = TrackingScheduler(16.368e6, 200e6, 8, 2, 2)
|
||||
bad = TrackingScheduler(16.368e6, 50e6, 16, 1, 2)
|
||||
assert good.schedulable_without_loss
|
||||
assert not bad.schedulable_without_loss
|
||||
@@ -0,0 +1,58 @@
|
||||
import numpy as np
|
||||
|
||||
from galileo_rx.signal_profiles import galileo_e1_profile
|
||||
from galileo_rx.signals.boc import boc_subcarrier, sample_primary_code
|
||||
from galileo_rx.signals.code_provider import SyntheticCodeProvider
|
||||
from galileo_rx.signals.signal_generator import ChannelModelConfig, SatelliteSignalConfig, SyntheticSignalGenerator
|
||||
|
||||
|
||||
def test_code_sampling_wrap_and_noninteger_rate() -> None:
|
||||
code = np.array([1, -1, 1, 1], dtype=np.int8)
|
||||
sampled, phase = sample_primary_code(code, 11, 2.5, 1.0, 3.5)
|
||||
assert sampled.shape == (11,)
|
||||
assert 0 <= phase < 4
|
||||
assert set(sampled) == {-1.0, 1.0}
|
||||
|
||||
|
||||
def test_boc_phase_continuity_between_blocks() -> None:
|
||||
whole, _ = boc_subcarrier(31, 7.3e6, 1.023e6, 0.17)
|
||||
first, phase = boc_subcarrier(13, 7.3e6, 1.023e6, 0.17)
|
||||
second, _ = boc_subcarrier(18, 7.3e6, 1.023e6, phase)
|
||||
np.testing.assert_array_equal(whole, np.concatenate([first, second]))
|
||||
|
||||
|
||||
def test_synthetic_provider_is_deterministic_and_component_specific() -> None:
|
||||
profile = galileo_e1_profile()
|
||||
provider = SyntheticCodeProvider()
|
||||
a = provider.primary_code(profile, "E1C", 1)
|
||||
np.testing.assert_array_equal(a, provider.primary_code(profile, "E1C", 1))
|
||||
assert not np.array_equal(a, provider.primary_code(profile, "E1B", 1))
|
||||
|
||||
|
||||
def test_generator_streaming_matches_single_block_without_noise() -> None:
|
||||
profile = galileo_e1_profile()
|
||||
provider = SyntheticCodeProvider()
|
||||
satellite = SatelliteSignalConfig(prn=3, doppler_hz=1234.0, code_delay_chips=17.25)
|
||||
channel = ChannelModelConfig(add_awgn=False)
|
||||
whole_generator = SyntheticSignalGenerator(profile, provider, 4.5e6, satellites=[satellite], channel=channel)
|
||||
block_generator = SyntheticSignalGenerator(profile, provider, 4.5e6, satellites=[satellite], channel=channel)
|
||||
whole, _ = whole_generator.generate(1001)
|
||||
a, _ = block_generator.generate(317)
|
||||
b, _ = block_generator.generate(684)
|
||||
np.testing.assert_allclose(whole, np.concatenate([a, b]), atol=2e-6)
|
||||
|
||||
|
||||
def test_real_if_and_truth_metadata() -> None:
|
||||
profile = galileo_e1_profile()
|
||||
generator = SyntheticSignalGenerator(
|
||||
profile,
|
||||
SyntheticCodeProvider(),
|
||||
8.0e6,
|
||||
2.0e6,
|
||||
True,
|
||||
[SatelliteSignalConfig(prn=7, component="E1B", code_delay_chips=2.5)],
|
||||
ChannelModelConfig(add_awgn=False),
|
||||
)
|
||||
samples, truth = generator.generate(80)
|
||||
assert samples.dtype == np.float64 and not np.iscomplexobj(samples)
|
||||
assert truth.satellites[0].initial_code_delay_samples == 2.5 * 8.0e6 / 1.023e6
|
||||
@@ -0,0 +1,31 @@
|
||||
import numpy as np
|
||||
|
||||
from galileo_rx.tracking.symbol_sync import (
|
||||
compare_symbol_streams,
|
||||
recover_bpsk_symbols,
|
||||
synchronize_secondary_code,
|
||||
)
|
||||
|
||||
|
||||
def test_raw_symbol_recovery_and_bounded_sign_alignment() -> None:
|
||||
truth = np.asarray([1, -1, -1, 1, 1, -1], dtype=np.int8)
|
||||
prompts = np.repeat(-truth, 2).astype(np.complex128) * (10 + 1j)
|
||||
recovered, values = recover_bpsk_symbols(prompts, 2)
|
||||
assert values.dtype == np.float64
|
||||
result = compare_symbol_streams(recovered, truth, max_alignment_offset_symbols=1)
|
||||
assert result.sign_inverted
|
||||
assert result.symbol_errors == 0
|
||||
|
||||
|
||||
def test_joint_secondary_phase_and_frequency_sync() -> None:
|
||||
secondary = np.asarray([1, 1, -1, 1, -1], dtype=np.int8)
|
||||
phase = 3
|
||||
residual_hz = 17.0
|
||||
dt = 0.004
|
||||
epochs = np.arange(40)
|
||||
prompts = secondary[(phase + epochs) % secondary.size] * np.exp(1j * (0.7 + 2 * np.pi * residual_hz * dt * epochs))
|
||||
result = synchronize_secondary_code(prompts, secondary, dt, residual_frequency_min_hz=-30, residual_frequency_max_hz=30)
|
||||
assert result.phase == phase
|
||||
assert result.residual_frequency_hz == residual_hz
|
||||
assert result.normalized_correlation > 0.999999
|
||||
assert result.sign_errors == 0
|
||||
@@ -0,0 +1,26 @@
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from galileo_rx.fixed_point.formats import FixedFormat
|
||||
from galileo_rx.rtl_model.vector_export import export_complex_hex, export_timed_complex_hex
|
||||
|
||||
|
||||
def test_exported_vector_is_twos_complement(tmp_path: Path) -> None:
|
||||
manifest = export_complex_hex(tmp_path, "samples", np.asarray([-1 - 0.5j, 0.5 + 0.25j]), FixedFormat(8, 7))
|
||||
assert (tmp_path / "samples_i.hex").read_text().splitlines() == ["80", "40"]
|
||||
assert (tmp_path / "samples_q.hex").read_text().splitlines() == ["c0", "20"]
|
||||
assert manifest["sample_count"] == 2
|
||||
|
||||
|
||||
def test_exported_timestamps_and_register_metadata(tmp_path: Path) -> None:
|
||||
manifest = export_timed_complex_hex(
|
||||
tmp_path,
|
||||
"timed",
|
||||
np.asarray([1 + 2j, 3 + 4j]),
|
||||
FixedFormat(8, 0),
|
||||
8.0,
|
||||
{"ACQ_COHERENT_SAMPLES": 2},
|
||||
)
|
||||
assert (tmp_path / "timed_timestamps.csv").read_text().splitlines() == ["sample_index,time_s", "0,0", "1,0.125"]
|
||||
assert manifest["register_values"]["ACQ_COHERENT_SAMPLES"] == 2
|
||||
Reference in New Issue
Block a user