Files
galileo/tools/run_cboc_validation.py
T

236 lines
9.7 KiB
Python

#!/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())