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