Add optional full-band Galileo E1 CBOC support
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run and report the 32.736 Msps BOC11/CBOC receiver matrix."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
from galileo_rx.acquisition.acquisition_manager import AcquisitionManager
|
||||
from galileo_rx.config import AcquisitionConfig, TrackingConfig
|
||||
from galileo_rx.signal_profiles import galileo_e1_profile
|
||||
from galileo_rx.signals.code_provider import SyntheticCodeProvider
|
||||
from galileo_rx.signals.e1_modulation import E1Modulation
|
||||
from galileo_rx.signals.fullband_e1_generator import FullBandE1SignalGenerator
|
||||
from galileo_rx.signals.signal_generator import ChannelModelConfig, SatelliteSignalConfig
|
||||
from galileo_rx.tracking.pilot_aided_e1 import synchronize_e1c_for_tracking
|
||||
from galileo_rx.tracking.tracking_channel import TrackingChannel
|
||||
|
||||
|
||||
FS_HZ = 32.736e6
|
||||
IF_HZ = 2.25e6
|
||||
DOPPLER_HZ = 1250.0
|
||||
DELAY_CHIPS = 80.25
|
||||
PRN = 1
|
||||
|
||||
|
||||
def _rms(values: list[float]) -> float:
|
||||
return float(np.sqrt(np.mean(np.square(np.asarray(values, dtype=np.float64)))))
|
||||
|
||||
|
||||
def _longest_true(values: list[bool]) -> int:
|
||||
longest = current = 0
|
||||
for value in values:
|
||||
current = current + 1 if value else 0
|
||||
longest = max(longest, current)
|
||||
return longest
|
||||
|
||||
|
||||
def run_case(transmit: E1Modulation, receive: E1Modulation, duration_s: float) -> dict[str, object]:
|
||||
profile = galileo_e1_profile()
|
||||
provider = SyntheticCodeProvider()
|
||||
started = time.perf_counter()
|
||||
generator = FullBandE1SignalGenerator(
|
||||
profile,
|
||||
provider,
|
||||
FS_HZ,
|
||||
IF_HZ,
|
||||
satellites=[
|
||||
SatelliteSignalConfig(
|
||||
PRN,
|
||||
"E1C",
|
||||
code_delay_chips=DELAY_CHIPS,
|
||||
doppler_hz=DOPPLER_HZ,
|
||||
carrier_phase_rad=0.3,
|
||||
cn0_db_hz=55.0,
|
||||
modulation=transmit,
|
||||
)
|
||||
],
|
||||
channel=ChannelModelConfig(seed=2026),
|
||||
)
|
||||
samples, truth = generator.generate(round(duration_s * FS_HZ))
|
||||
acquisition_config = AcquisitionConfig(
|
||||
modulation=receive,
|
||||
doppler_min_hz=1000.0,
|
||||
doppler_max_hz=1500.0,
|
||||
doppler_step_hz=250.0,
|
||||
coherent_integration_ms=4.0,
|
||||
noncoherent_accumulations=2,
|
||||
detection_threshold=2.0,
|
||||
zero_padding_mode="none",
|
||||
parabolic_interpolation=False,
|
||||
)
|
||||
acquisition, _ = AcquisitionManager(
|
||||
profile, provider, FS_HZ, acquisition_config
|
||||
).acquire_one(samples, "E1C", PRN, IF_HZ)
|
||||
synchronization = synchronize_e1c_for_tracking(
|
||||
samples,
|
||||
profile,
|
||||
provider,
|
||||
FS_HZ,
|
||||
acquisition,
|
||||
round(0.008 * FS_HZ),
|
||||
search_primary_epochs=min(30, int(duration_s / 0.004) - 3),
|
||||
residual_frequency_step_hz=1.0,
|
||||
modulation=receive,
|
||||
)
|
||||
channel = TrackingChannel(
|
||||
profile,
|
||||
provider,
|
||||
FS_HZ,
|
||||
TrackingConfig(
|
||||
modulation=receive,
|
||||
pull_in_duration_ms=20.0,
|
||||
loss_of_lock_epochs=20,
|
||||
),
|
||||
synchronization.acquisition,
|
||||
synchronization.tracking_start_sample,
|
||||
synchronization.sync.phase,
|
||||
)
|
||||
measurements = channel.process(samples[synchronization.tracking_start_sample :])
|
||||
tail = measurements[-min(10, len(measurements)) :]
|
||||
effective_code_rate_hz = profile.code_rate_hz * (
|
||||
1.0 + DOPPLER_HZ / profile.carrier_frequency_hz
|
||||
)
|
||||
truth_delay_chips = truth.satellites[0].initial_code_delay_chips
|
||||
phase_errors_chips = []
|
||||
for measurement in tail:
|
||||
expected_phase = (
|
||||
measurement.end_sample_timestamp / FS_HZ * effective_code_rate_hz
|
||||
- truth_delay_chips
|
||||
) % profile.component("E1C").primary_code_length_chips
|
||||
phase_errors_chips.append(
|
||||
float(
|
||||
(
|
||||
measurement.code_phase_chips
|
||||
- expected_phase
|
||||
+ profile.component("E1C").primary_code_length_chips / 2.0
|
||||
)
|
||||
% profile.component("E1C").primary_code_length_chips
|
||||
- profile.component("E1C").primary_code_length_chips / 2.0
|
||||
)
|
||||
)
|
||||
joint_lock = [item.carrier_lock and item.code_lock for item in measurements]
|
||||
cn0_values = [item.cn0_db_hz for item in tail if item.cn0_db_hz is not None]
|
||||
return {
|
||||
"case": f"generated_{transmit.value}__receiver_{receive.value}",
|
||||
"transmit_modulation": transmit.value,
|
||||
"receiver_modulation": receive.value,
|
||||
"sample_format": "complex128",
|
||||
"sampling_frequency_hz": FS_HZ,
|
||||
"internal_sampling_frequency_hz": generator.internal_sampling_frequency_hz,
|
||||
"intermediate_frequency_hz": IF_HZ,
|
||||
"frontend_one_sided_bandwidth_hz": generator.frontend.frontend_one_sided_bandwidth_hz,
|
||||
"frontend_fir_taps": int(generator.fir_taps.size),
|
||||
"filter_group_delay_samples": generator.group_delay_output_samples,
|
||||
"acquisition_detected": acquisition.detected,
|
||||
"acquisition_peak_metric": acquisition.detection_metric,
|
||||
"acquisition_peak_power": acquisition.peak_power,
|
||||
"estimated_code_delay_chips": acquisition.code_phase_chips,
|
||||
"truth_code_delay_chips_including_filter": truth_delay_chips,
|
||||
"code_delay_error_chips": acquisition.code_phase_chips - truth_delay_chips,
|
||||
"estimated_carrier_frequency_hz": acquisition.carrier_frequency_hz,
|
||||
"estimated_doppler_hz": acquisition.doppler_hz,
|
||||
"doppler_error_hz": acquisition.doppler_hz - DOPPLER_HZ,
|
||||
"secondary_code_phase": synchronization.sync.phase,
|
||||
"secondary_code_correlation": synchronization.sync.normalized_correlation,
|
||||
"secondary_code_sign_errors": synchronization.sync.sign_errors,
|
||||
"tracking_epochs": len(measurements),
|
||||
"tracking_duration_s": len(measurements) * 0.004,
|
||||
"longest_joint_lock_duration_s": _longest_true(joint_lock) * 0.004,
|
||||
"final_carrier_lock": measurements[-1].carrier_lock,
|
||||
"final_code_lock": measurements[-1].code_lock,
|
||||
"pll_error_rms_rad": _rms([item.pll_error_rad for item in tail]),
|
||||
"dll_error_rms_chips": _rms([item.dll_error_chips for item in tail]),
|
||||
"code_tracking_bias_chips": float(np.mean(phase_errors_chips)),
|
||||
"code_tracking_error_rms_chips": _rms(phase_errors_chips),
|
||||
"median_cn0_estimate_db_hz": float(np.median(cn0_values)) if cn0_values else None,
|
||||
"cycle_slip_count": sum(item.cycle_slip for item in measurements),
|
||||
"sample_discontinuity_count": sum(item.sample_discontinuity for item in measurements),
|
||||
"side_peak_suspected_epochs": sum(item.side_peak_suspected for item in measurements),
|
||||
"tail_smoothed_code_lock_metric_median": float(
|
||||
np.median([item.smoothed_code_lock_metric for item in tail])
|
||||
),
|
||||
"tail_boc_main_peak_fraction_minimum": float(
|
||||
min(item.boc_main_peak_fraction for item in tail)
|
||||
),
|
||||
"tail_boc_side_peak_metric_maximum": float(
|
||||
max(item.boc_side_peak_metric for item in tail)
|
||||
),
|
||||
"e1b_page_decode_count": None,
|
||||
"e1b_navigation_data_enabled": False,
|
||||
"processing_time_s": time.perf_counter() - started,
|
||||
}
|
||||
|
||||
|
||||
def markdown_report(report: dict[str, object]) -> str:
|
||||
rows = []
|
||||
for case in report["cases"]: # type: ignore[index]
|
||||
rows.append(
|
||||
"| {case} | {acquisition_peak_metric:.3f} | {estimated_code_delay_chips:.6f} | "
|
||||
"{estimated_doppler_hz:.1f} | {longest_joint_lock_duration_s:.3f} | "
|
||||
"{pll_error_rms_rad:.6f} | {dll_error_rms_chips:.6f} | "
|
||||
"{code_tracking_bias_chips:.6f} | {median_cn0_estimate_db_hz:.3f} | "
|
||||
"{secondary_code_correlation:.6f} |".format(**case)
|
||||
)
|
||||
return "\n".join(
|
||||
[
|
||||
"# Galileo E1 full-band CBOC validation",
|
||||
"",
|
||||
"Generated at 65.472 Msps, passed through a 129-tap Kaiser FIR, and decimated to "
|
||||
"32.736 Msps. All receiver arrays use float64/complex128.",
|
||||
"",
|
||||
"| Case | Acquisition metric | Code delay (chips) | Doppler (Hz) | Longest lock (s) | PLL RMS (rad) | DLL RMS (chips) | Code bias (chips) | C/N0 estimate | E1C secondary corr. |",
|
||||
"| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
|
||||
*rows,
|
||||
"",
|
||||
"E1B navigation data was not enabled in this focused E1C-pilot matrix; page counts are therefore null, not zero-result decoder failures. The independent SignalSim regression remains the E1B BOC(1,1) I/NAV proof.",
|
||||
]
|
||||
) + "\n"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--output-dir", type=Path, default=Path("reports"))
|
||||
parser.add_argument("--duration-s", type=float, default=0.30)
|
||||
arguments = parser.parse_args()
|
||||
if arguments.duration_s < 0.24:
|
||||
raise SystemExit("duration must be at least 0.24 s for lock and C/N0 settling")
|
||||
cases = [
|
||||
run_case(E1Modulation.BOC11, E1Modulation.BOC11, arguments.duration_s),
|
||||
run_case(E1Modulation.CBOC, E1Modulation.BOC11, arguments.duration_s),
|
||||
run_case(E1Modulation.CBOC, E1Modulation.CBOC, arguments.duration_s),
|
||||
]
|
||||
report: dict[str, object] = {
|
||||
"generator": "independent band-limited Python Galileo E1 generator",
|
||||
"truth_usage": "post-receiver error measurement only",
|
||||
"cases": cases,
|
||||
}
|
||||
arguments.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
(arguments.output_dir / "cboc_validation_report.json").write_text(
|
||||
json.dumps(report, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
(arguments.output_dir / "cboc_validation_report.md").write_text(
|
||||
markdown_report(report), encoding="utf-8"
|
||||
)
|
||||
print(json.dumps(report, indent=2))
|
||||
return 0 if all(case["acquisition_detected"] and case["final_code_lock"] for case in cases) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user