236 lines
9.7 KiB
Python
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())
|