Add optional full-band Galileo E1 CBOC support

This commit is contained in:
2026-07-31 02:56:04 +03:00
parent 9db955d02e
commit 3662ba7e53
28 changed files with 2065 additions and 101 deletions
+9 -6
View File
@@ -2,7 +2,8 @@
This repository is a modular Python reference model for a Galileo receiver This repository is a modular Python reference model for a Galileo receiver
baseband and a starting point for bit-true/RTL verification. It currently baseband and a starting point for bit-true/RTL verification. It currently
implements E1B data and E1C pilot processing with a BOC(1,1) local replica, implements E1B data and E1C pilot processing with selectable BOC(1,1) or
full-band CBOC(6,1,1/11) local replicas,
streaming real-IF or complex-IQ front ends, FFT PCPS acquisition, independent streaming real-IF or complex-IQ front ends, FFT PCPS acquisition, independent
tracking channels, fixed-point boundary models, and hardware sizing models. tracking channels, fixed-point boundary models, and hardware sizing models.
The floating reference uses `numpy.float64`/`numpy.complex128` throughout; The floating reference uses `numpy.float64`/`numpy.complex128` throughout;
@@ -23,7 +24,9 @@ complex IQ; both acquisition and tracking pass on its IQ8 output. See the
## Status and limitations ## Status and limitations
- E1 profile: 1.023 Mcps, 4092 chips, 4 ms, 1575.42 MHz. - E1 profile: 1.023 Mcps, 4092 chips, 4 ms, 1575.42 MHz.
- BOC(1,1) is implemented; CBOC and unambiguous BOC are API extension points. - BOC(1,1) remains the default regression mode. Full-band CBOC is enabled
explicitly per acquisition/tracking configuration. Unambiguous BOC remains
an API extension point.
- E1B bit-transition hypotheses and E1C secondary wipeoff/search are available - E1B bit-transition hypotheses and E1C secondary wipeoff/search are available
for acquisition. E1C tracking jointly searches the 25-symbol phase and for acquisition. E1C tracking jointly searches the 25-symbol phase and
residual carrier frequency before continuous secondary-code wipeoff. residual carrier frequency before continuous secondary-code wipeoff.
@@ -74,6 +77,7 @@ python -m galileo_rx.cli validate-signalsim --signalsim-path signalsim/SignalSim
python -m galileo_rx.cli collect-navigation --config configs/navigation_collection.yaml --output-dir reports/navigation_run python -m galileo_rx.cli collect-navigation --config configs/navigation_collection.yaml --output-dir reports/navigation_run
python -m galileo_rx.cli collect-navigation --config configs/navigation_collection.yaml --output-dir reports/navigation_e1b --tracking-mode e1b_only python -m galileo_rx.cli collect-navigation --config configs/navigation_collection.yaml --output-dir reports/navigation_e1b --tracking-mode e1b_only
python tools/compare_e1_tracking_modes.py --config configs/navigation_collection.yaml --output-dir reports/e1_tracking_mode_comparison python tools/compare_e1_tracking_modes.py --config configs/navigation_collection.yaml --output-dir reports/e1_tracking_mode_comparison
python tools/run_cboc_validation.py --output-dir reports --duration-s 0.30
python -m galileo_rx.cli prompts-to-symbols --input verification/navigation/signalsim_navigation_multisat_e1c_pilot_aided_e1b/prn_01/prompt_epochs.npz --prn 1 --output-dir reports/navigation_replay/prn_01 python -m galileo_rx.cli prompts-to-symbols --input verification/navigation/signalsim_navigation_multisat_e1c_pilot_aided_e1b/prn_01/prompt_epochs.npz --prn 1 --output-dir reports/navigation_replay/prn_01
python -m galileo_rx.cli decode-inav --input verification/navigation/signalsim_navigation_multisat_e1c_pilot_aided_e1b/prn_01/prompt_epochs.npz --prn 1 --output-dir reports/navigation_replay/prn_01 python -m galileo_rx.cli decode-inav --input verification/navigation/signalsim_navigation_multisat_e1c_pilot_aided_e1b/prn_01/prompt_epochs.npz --prn 1 --output-dir reports/navigation_replay/prn_01
python -m galileo_rx.cli inspect-navigation --input verification/navigation/signalsim_navigation_multisat_e1c_pilot_aided_e1b/prn_01 python -m galileo_rx.cli inspect-navigation --input verification/navigation/signalsim_navigation_multisat_e1c_pilot_aided_e1b/prn_01
@@ -136,10 +140,9 @@ the official 30×8 deinterleaver and soft Viterbi decoder, validates CRC-24Q, an
parses typed word 1–6 data. Ephemerides are committed atomically only when words parses typed word 1–6 data. Ephemerides are committed atomically only when words
1–4 have matching IODnav; the previous valid set survives incomplete updates. 1–4 have matching IODnav; the previous valid set survives incomplete updates.
The measured 32.2 s SignalSim regression decoded 70/70 CRC-valid pages. Four of The post-CBOC 32.2 s raw-IQ SignalSim regression decoded 75/75 CRC-valid pages.
five tracked satellites produced complete ephemerides, clocks, healthy status, All five tracked satellites produced complete ephemerides, clocks, healthy
and GST; PRN 19 correctly remained incomplete because word 1 was outside the status, and GST. Post-decode comparison against SignalSim's RINEX source passed at
observation. Post-decode comparison against SignalSim's RINEX source passed at
the broadcast quantization resolution. No simulator truth is accepted by the the broadcast quantization resolution. No simulator truth is accepted by the
normal decoder API. normal decoder API.
+2
View File
@@ -9,6 +9,7 @@ rf:
sample_bit_width: 8 sample_bit_width: 8
sample_endianness: little sample_endianness: little
acquisition: acquisition:
modulation: BOC11
doppler_min_hz: -5000.0 doppler_min_hz: -5000.0
doppler_max_hz: 5000.0 doppler_max_hz: 5000.0
doppler_step_hz: 250.0 doppler_step_hz: 250.0
@@ -18,6 +19,7 @@ acquisition:
peak_exclusion_chips: 1.0 peak_exclusion_chips: 1.0
zero_padding_mode: none zero_padding_mode: none
tracking: tracking:
modulation: BOC11
integration_ms: 4.0 integration_ms: 4.0
early_late_spacing_chips: 0.25 early_late_spacing_chips: 0.25
very_early_late_spacing_chips: 0.75 very_early_late_spacing_chips: 0.75
+33
View File
@@ -0,0 +1,33 @@
signal_profile: GALILEO_E1
component: E1C
prns: [1]
rf:
sample_format: complex128
input_is_real_if: false
sampling_frequency_hz: 32736000.0
intermediate_frequency_hz: 2250000.0
sample_bit_width: 8
sample_endianness: little
acquisition:
modulation: CBOC
doppler_min_hz: -10000.0
doppler_max_hz: 10000.0
doppler_step_hz: 250.0
coherent_integration_ms: 4.0
noncoherent_accumulations: 2
detection_threshold: 2.5
peak_exclusion_chips: 1.0
zero_padding_mode: none
tracking:
modulation: CBOC
integration_ms: 4.0
early_late_spacing_chips: 0.25
very_early_late_spacing_chips: 0.75
dll_noise_bandwidth_hz: 1.0
pll_noise_bandwidth_hz: 15.0
fll_noise_bandwidth_hz: 20.0
pll_order: 3
dll_order: 2
use_fll_assist: true
pull_in_duration_ms: 20.0
secondary_code_mode: wipeoff
+2
View File
@@ -9,6 +9,7 @@ rf:
sample_bit_width: 8 sample_bit_width: 8
sample_endianness: little sample_endianness: little
acquisition: acquisition:
modulation: BOC11
doppler_min_hz: -10000.0 doppler_min_hz: -10000.0
doppler_max_hz: 10000.0 doppler_max_hz: 10000.0
doppler_step_hz: 250.0 doppler_step_hz: 250.0
@@ -19,6 +20,7 @@ acquisition:
fft_length: null fft_length: null
zero_padding_mode: next_power_of_two zero_padding_mode: next_power_of_two
tracking: tracking:
modulation: BOC11
integration_ms: 4.0 integration_ms: 4.0
early_late_spacing_chips: 0.25 early_late_spacing_chips: 0.25
very_early_late_spacing_chips: 0.75 very_early_late_spacing_chips: 0.75
+1
View File
@@ -9,6 +9,7 @@ rf:
sample_bit_width: 8 sample_bit_width: 8
sample_endianness: little sample_endianness: little
tracking: tracking:
modulation: BOC11
integration_ms: 4.0 integration_ms: 4.0
early_late_spacing_chips: 0.25 early_late_spacing_chips: 0.25
very_early_late_spacing_chips: 0.75 very_early_late_spacing_chips: 0.75
+114
View File
@@ -0,0 +1,114 @@
# Galileo E1 CBOC support
## Definition and convention
The implementation follows *Galileo Open Service Signal-in-Space Interface
Control Document*, Issue 2.2 (November 2025), section 2.3.3, Table 10 and
equation (12). With sine-phased square subcarriers
```text
sc_n(t) = sgn(sin(2 pi n R_c t)), R_c = 1.023 MHz
alpha = sqrt(10/11)
beta = sqrt(1/11)
```
the complete transmitted multiplex and its component replicas are
```text
s_E1(t) = (1/sqrt(2)) {
e_E1B(t) [alpha sc_1(t) + beta sc_6(t)]
- e_E1C(t) [alpha sc_1(t) - beta sc_6(t)]
}
CBOC_E1B(t) = alpha sc_1(t) + beta sc_6(t)
CBOC_E1C(t) = alpha sc_1(t) - beta sc_6(t)
```
The complete transmitted E1 equation contains a common `1/sqrt(2)` power split
and an additional outer minus sign on the E1C component. The outer sign is a
constant carrier-phase ambiguity for an individual component receiver; it is
not folded into the internal CBOC(-) weighting above. E1B and E1C occupy the
same RF carrier and are not modeled as independent RF I/Q branches.
At an exact subcarrier transition, the discrete convention selects the new
half-cycle (`+1` at integer phase, `-1` at half-integer phase). A `1e-12`-cycle
boundary tolerance makes vector and arbitrarily chunked float64 phase
accumulators deterministic.
## Architecture boundaries
- Acquisition PRN/subcarrier replica: `signals/galileo_e1.py`, called and FFT
cached by `acquisition/fft_pcps.py`.
- Acquisition carrier wipeoff: `fft_pcps.py`; it remains
`input * exp(-j 2 pi (IF + Doppler) n/Fs)` and is independent of modulation.
- Tracking replicas: `tracking/correlator.py`; one reusable bank creates
VE/E/P/L/VL replicas for either modulation.
- Tracking carrier wipeoff and coherent sums: `CorrelatorBank.correlate_with_phases`.
- Physical parameters: `RfConfig` owns sampling rate and IF, `SignalProfile`
owns carrier/code rates, and `AcquisitionConfig`/`TrackingConfig` own the
explicit modulation choice.
The tracking correlator carries independent float64 state for carrier phase,
code phase, BOC(1,1) phase, and BOC(6,1) phase. The subcarrier frequency words
follow the code-NCO frequency. The E1C pilot-aided E1B class shares those phase
vectors between its pilot and data correlator branches; only E1B Prompt I/Q is
sent to I/NAV recovery.
## Full-band generator
`FullBandE1SignalGenerator` defaults to 65.472 Msps internal generation, a
129-tap Kaiser-window FIR with 12 MHz one-sided bandwidth, and integer
decimation to 32.736 Msps. Filter coefficients, bandwidth, tap count, and
oversampling are configurable. Its complex FIR state and all carrier/code
states persist between calls. The causal FIR group delay is included in
`GenerationTruth`; simulator truth is used only after receiver processing.
This is intentionally distinct from SignalSim commit
`80b262113e77c4482ca9f5cbfef9bcada402ed28`, whose Galileo E1 vector is a
BOC(1,1) approximation and remains labeled only as BOC(1,1) validation.
## Measured 32.736 Msps matrix
The checked-in [JSON report](../reports/cboc_validation_report.json) and
[Markdown report](../reports/cboc_validation_report.md) are produced by:
```bash
python tools/run_cboc_validation.py --output-dir reports --duration-s 0.30
```
All three cases acquired the correct 1250 Hz Doppler and 81.25-chip filtered
delay, synchronized the E1C secondary code with correlation above 0.9997 and
zero sign errors, and ended with carrier and code lock. No cycle slips,
discontinuities, or side-peak indications occurred.
Using a BOC11 replica on the CBOC vector reduced acquisition peak power by
0.317 dB relative to the matched CBOC replica. It produced no measurable delay
bias at the 1/32-chip acquisition grid. Tail code biases were -0.00085 chips
(compatibility) and -0.00087 chips (matched). The existing lock thresholds were
not changed: all modes had the same 184 ms confirmed-lock interval after the
configured 25-epoch smoother and three-epoch confirmation delay.
The matched CBOC DLL discriminator RMS was 0.0527 chips versus 0.0351 chips in
compatibility mode for this short run. The NCO state and code bias remained
bounded, so this is recorded as a correlator-shape/discriminator calibration
observation, not silently corrected by threshold or loop retuning.
For the configured 55 dB-Hz source, the existing moment estimator reported
47.88 dB-Hz for filtered BOC11 and approximately 42.31 dB-Hz for both CBOC
receiver modes. Since matched and compatibility CBOC estimates agree while
their lock/NCO states remain stable, this is retained as a front-end/CBOC C/N0
calibration limitation rather than used to retune the loops or lock thresholds.
The matched CBOC tail main-peak fraction remained above 0.915, its side-peak
metric below 0.045, and it produced no side-peak flags.
## Limitations
- The focused CBOC matrix uses deterministic synthetic codes, not fabricated
Galileo code tables, and E1C pilot only. E1B page count is therefore reported
as not enabled. Official-code BOC11 E1B I/NAV/ephemeris coverage remains the
independent SignalSim regression.
- The generator models a configurable linear front-end FIR, AWGN, Doppler,
code Doppler, clipping, and I/Q quantization. It does not yet model analog
group-delay ripple, oscillator phase noise, multipath, or interference.
- No unambiguous CBOC acquisition discriminator is claimed. Side-peak
monitoring remains explicit.
+7
View File
@@ -25,10 +25,17 @@ Default YAML files:
- `e1_default.yaml`: requested 16.368 MHz nominal defaults. - `e1_default.yaml`: requested 16.368 MHz nominal defaults.
- `acquisition_example.yaml`: faster 4.092 MHz executable search. - `acquisition_example.yaml`: faster 4.092 MHz executable search.
- `tracking_example.yaml`: 4.092 MHz tracking defaults. - `tracking_example.yaml`: 4.092 MHz tracking defaults.
- `e1_cboc.yaml`: explicit full-band CBOC selection at 32.736 Msps and a
2.25 MHz analytic low IF.
- `navigation_collection.yaml`: SignalSim IQ-to-I/NAV regression. Its default - `navigation_collection.yaml`: SignalSim IQ-to-I/NAV regression. Its default
`tracking.architecture` is `e1c_pilot_aided_e1b`; the CLI can select `tracking.architecture` is `e1c_pilot_aided_e1b`; the CLI can select
`e1b_only` for an A/B replay. `e1b_only` for an A/B replay.
`AcquisitionConfig.modulation` and `TrackingConfig.modulation` independently
select `BOC11` or `CBOC`. Both default to `BOC11`, so existing SignalSim and
low-bandwidth configurations retain their measured behavior. A CBOC vector
must set both fields to `CBOC` for a matched acquisition/tracking replica.
The BOC lock detector parameters are part of `TrackingConfig`. The defaults use The BOC lock detector parameters are part of `TrackingConfig`. The defaults use
25 noncoherent power epochs, three confirmations, and the configured 25 noncoherent power epochs, three confirmations, and the configured
`loss_of_lock_epochs` for sustained failure. Carrier and BOC central/outer `loss_of_lock_epochs` for sustained failure. Carrier and BOC central/outer
+135
View File
@@ -0,0 +1,135 @@
{
"generator": "independent band-limited Python Galileo E1 generator",
"truth_usage": "post-receiver error measurement only",
"cases": [
{
"case": "generated_BOC11__receiver_BOC11",
"transmit_modulation": "BOC11",
"receiver_modulation": "BOC11",
"sample_format": "complex128",
"sampling_frequency_hz": 32736000.0,
"internal_sampling_frequency_hz": 65472000.0,
"intermediate_frequency_hz": 2250000.0,
"frontend_one_sided_bandwidth_hz": 12000000.0,
"frontend_fir_taps": 129,
"filter_group_delay_samples": 32.0,
"acquisition_detected": true,
"acquisition_peak_metric": 111.59981066286072,
"acquisition_peak_power": 34317234269.486805,
"estimated_code_delay_chips": 81.25,
"truth_code_delay_chips_including_filter": 81.25,
"code_delay_error_chips": 0.0,
"estimated_carrier_frequency_hz": 2251250.0,
"estimated_doppler_hz": 1250.0,
"doppler_error_hz": 0.0,
"secondary_code_phase": 2,
"secondary_code_correlation": 0.999801055475596,
"secondary_code_sign_errors": 0,
"tracking_epochs": 72,
"tracking_duration_s": 0.28800000000000003,
"longest_joint_lock_duration_s": 0.184,
"final_carrier_lock": true,
"final_code_lock": true,
"pll_error_rms_rad": 0.029726592473925416,
"dll_error_rms_chips": 0.02297667464497775,
"code_tracking_bias_chips": 1.5854226921874216e-05,
"code_tracking_error_rms_chips": 0.00025151975426697875,
"median_cn0_estimate_db_hz": 47.880388780252005,
"cycle_slip_count": 0,
"sample_discontinuity_count": 0,
"side_peak_suspected_epochs": 0,
"tail_smoothed_code_lock_metric_median": 15.4703736731197,
"tail_boc_main_peak_fraction_minimum": 0.8977679303792508,
"tail_boc_side_peak_metric_maximum": 0.0575388903784898,
"e1b_page_decode_count": null,
"e1b_navigation_data_enabled": false,
"processing_time_s": 4.839926584994828
},
{
"case": "generated_CBOC__receiver_BOC11",
"transmit_modulation": "CBOC",
"receiver_modulation": "BOC11",
"sample_format": "complex128",
"sampling_frequency_hz": 32736000.0,
"internal_sampling_frequency_hz": 65472000.0,
"intermediate_frequency_hz": 2250000.0,
"frontend_one_sided_bandwidth_hz": 12000000.0,
"frontend_fir_taps": 129,
"filter_group_delay_samples": 32.0,
"acquisition_detected": true,
"acquisition_peak_metric": 115.61214182481689,
"acquisition_peak_power": 28044260424.17594,
"estimated_code_delay_chips": 81.25,
"truth_code_delay_chips_including_filter": 81.25,
"code_delay_error_chips": 0.0,
"estimated_carrier_frequency_hz": 2251250.0,
"estimated_doppler_hz": 1250.0,
"doppler_error_hz": 0.0,
"secondary_code_phase": 2,
"secondary_code_correlation": 0.9997971190771551,
"secondary_code_sign_errors": 0,
"tracking_epochs": 72,
"tracking_duration_s": 0.28800000000000003,
"longest_joint_lock_duration_s": 0.184,
"final_carrier_lock": true,
"final_code_lock": true,
"pll_error_rms_rad": 0.030845816038584276,
"dll_error_rms_chips": 0.03505125628223577,
"code_tracking_bias_chips": -0.0008470738236610487,
"code_tracking_error_rms_chips": 0.0009952893933792282,
"median_cn0_estimate_db_hz": 42.31146354235335,
"cycle_slip_count": 0,
"sample_discontinuity_count": 0,
"side_peak_suspected_epochs": 0,
"tail_smoothed_code_lock_metric_median": 17.865460658014058,
"tail_boc_main_peak_fraction_minimum": 0.9061270492837439,
"tail_boc_side_peak_metric_maximum": 0.05289988968883203,
"e1b_page_decode_count": null,
"e1b_navigation_data_enabled": false,
"processing_time_s": 5.107619269001589
},
{
"case": "generated_CBOC__receiver_CBOC",
"transmit_modulation": "CBOC",
"receiver_modulation": "CBOC",
"sample_format": "complex128",
"sampling_frequency_hz": 32736000.0,
"internal_sampling_frequency_hz": 65472000.0,
"intermediate_frequency_hz": 2250000.0,
"frontend_one_sided_bandwidth_hz": 12000000.0,
"frontend_fir_taps": 129,
"filter_group_delay_samples": 32.0,
"acquisition_detected": true,
"acquisition_peak_metric": 118.51359102364712,
"acquisition_peak_power": 30169065694.99317,
"estimated_code_delay_chips": 81.25,
"truth_code_delay_chips_including_filter": 81.25,
"code_delay_error_chips": 0.0,
"estimated_carrier_frequency_hz": 2251250.0,
"estimated_doppler_hz": 1250.0,
"doppler_error_hz": 0.0,
"secondary_code_phase": 2,
"secondary_code_correlation": 0.9997767185931239,
"secondary_code_sign_errors": 0,
"tracking_epochs": 72,
"tracking_duration_s": 0.28800000000000003,
"longest_joint_lock_duration_s": 0.184,
"final_carrier_lock": true,
"final_code_lock": true,
"pll_error_rms_rad": 0.0272969018394947,
"dll_error_rms_chips": 0.052729413588935264,
"code_tracking_bias_chips": -0.0008745638097479968,
"code_tracking_error_rms_chips": 0.0011967645110863632,
"median_cn0_estimate_db_hz": 42.30148722049758,
"cycle_slip_count": 0,
"sample_discontinuity_count": 0,
"side_peak_suspected_epochs": 0,
"tail_smoothed_code_lock_metric_median": 50.858434903966014,
"tail_boc_main_peak_fraction_minimum": 0.9155160124924913,
"tail_boc_side_peak_metric_maximum": 0.04623326118514734,
"e1b_page_decode_count": null,
"e1b_navigation_data_enabled": false,
"processing_time_s": 5.774479466002958
}
]
}
+11
View File
@@ -0,0 +1,11 @@
# 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. |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| generated_BOC11__receiver_BOC11 | 111.600 | 81.250000 | 1250.0 | 0.184 | 0.029727 | 0.022977 | 0.000016 | 47.880 | 0.999801 |
| generated_CBOC__receiver_BOC11 | 115.612 | 81.250000 | 1250.0 | 0.184 | 0.030846 | 0.035051 | -0.000847 | 42.311 | 0.999797 |
| generated_CBOC__receiver_CBOC | 118.514 | 81.250000 | 1250.0 | 0.184 | 0.027297 | 0.052729 | -0.000875 | 42.301 | 0.999777 |
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.
@@ -1,5 +1,6 @@
{ {
"decoder_truth_inputs": [], "decoder_truth_inputs": [],
"tracking_architecture": "e1c_pilot_aided_e1b",
"icd": "Galileo OS SIS ICD Issue 2.2, November 2025", "icd": "Galileo OS SIS ICD Issue 2.2, November 2025",
"signalsim_commit": "80b262113e77c4482ca9f5cbfef9bcada402ed28", "signalsim_commit": "80b262113e77c4482ca9f5cbfef9bcada402ed28",
"sample_file": "/home/onur/Desktop/galileo/verification/vectors/signalsim/navigation_multisat/navigation_multisat_iq8.bin", "sample_file": "/home/onur/Desktop/galileo/verification/vectors/signalsim/navigation_multisat/navigation_multisat_iq8.bin",
@@ -10,17 +11,24 @@
"satellites": { "satellites": {
"1": { "1": {
"prn": 1, "prn": 1,
"tracking_architecture": "e1c_pilot_aided_e1b",
"acquisition_component": "E1C",
"acquisition_detected": true, "acquisition_detected": true,
"acquisition_time_s": 0.1004119270000956, "acquisition_time_s": 0.10627389200089965,
"raw_doppler_hz": 1750.0, "raw_doppler_hz": 1750.0,
"expected_doppler_hz": 1828.0, "expected_doppler_hz": 1828.0,
"start_sample_timestamp": 90995, "start_sample_timestamp": 90995,
"tracking_epoch_count": 16088, "tracking_epoch_count": 16088,
"tracking_time_s": 13.650105177999649, "tracking_time_s": 30.29040445300052,
"tracking_final_state": "PLL_TRACK", "tracking_final_state": "PLL_TRACK",
"tracking_reacquisition_requests": 10, "tracking_reacquisition_requests": 0,
"tracking_lock_timestamp": 107363, "tracking_lock_timestamp": 311963,
"prompt_epoch_count": 8044, "prompt_epoch_count": 8044,
"valid_prompt_epoch_count": 8044,
"invalid_prompt_epoch_count": 0,
"cycle_slips": 0,
"sample_discontinuities": 0,
"raw_navigation_symbol_count": 8044,
"first_synchronized_symbol_timestamp": 4428515, "first_synchronized_symbol_timestamp": 4428515,
"first_page_sync_timestamp": 4428515, "first_page_sync_timestamp": 4428515,
"first_crc_valid_page_timestamp": 4428515, "first_crc_valid_page_timestamp": 4428515,
@@ -44,27 +52,151 @@
"clock_parameters": true, "clock_parameters": true,
"health_valid": true, "health_valid": true,
"data_valid": true, "data_valid": true,
"decoding_time_s": 1.260811995001859 "decoding_time_s": 1.2302890369974193,
"secondary_code_sync": {
"phase": 10,
"residual_frequency_hz": 59.5,
"normalized_correlation": 0.9941599620460754,
"compared_periods": 100,
"sign_errors": 0
},
"lock_analysis": {
"prn": 1,
"epochs": 16088,
"integration_ms": 2.0,
"start_sample_timestamp": 90995,
"end_sample_timestamp": 131755187,
"raw_carrier_lock_pass_epochs": 16078,
"raw_code_lock_pass_epochs": 15617,
"raw_combined_lock_pass_epochs": 15617,
"raw_combined_lock_fail_epochs": 471,
"false_transition_count": 102,
"legacy_code_lock_falling_edges": 102,
"boc_aware_code_lock_falling_edges": 0,
"boc_aware_metric_valid_epochs": 16064,
"boc_aware_code_lock_pass_epochs": 16062,
"boc_aware_combined_lock_pass_epochs": 16062,
"boc_aware_longest_code_lock_s": 32.124,
"boc_side_peak_suspected_epochs": 0,
"carrier_metric_caused_transitions": 0,
"code_metric_caused_transitions": 102,
"symbol_transition_coincident_false_transitions": 0,
"cycle_slips": 0,
"sample_discontinuities": 0,
"cn0_unavailable_false_transitions": 0,
"false_run_count": 102,
"single_epoch_false_runs": 45,
"maximum_false_run_epochs": 49,
"maximum_false_run_ms": 98.0,
"maximum_continuous_raw_lock_epochs": 632,
"maximum_continuous_raw_lock_s": 1.264,
"carrier_metric_percentiles": {
"1": 0.926123934471329,
"5": 0.9887965609469924,
"50": 0.9988996165475693,
"95": 0.9999914964295384,
"99": 0.9999996897371967
},
"code_metric_percentiles": {
"1": 0.09265723002677347,
"5": 9.240924942018399,
"50": 14.358082125149325,
"95": 20.787106913901518,
"99": 24.857166929725683
},
"boc_main_peak_fraction_percentiles": {
"1": 0.8475615556941004,
"5": 0.8752438026290285,
"50": 0.8897649686117325,
"95": 0.899335220269324,
"99": 0.9027108176195193
},
"boc_side_peak_metric_percentiles": {
"1": 0.055600833755340986,
"5": 0.05813312240679952,
"50": 0.06516955958934093,
"95": 0.08724568096229031,
"99": 0.15809803712879886
},
"boc_main_peak_coherence_percentiles": {
"1": 0.5909426356047754,
"5": 0.8795912735269322,
"50": 0.9687487070428722,
"95": 0.9810030499701589,
"99": 0.9846774791776529
},
"legacy_code_metric_failure_behavior": {
"epochs": 383,
"dll_error_rms_chips": 0.08574237706359694,
"dll_error_p99_abs_chips": 0.1203286532395151,
"code_phase_residual_rms_chips": 14.049922537033622,
"code_phase_residual_p99_abs_chips": 18.527759786491757,
"prompt_power_median": 110703392.74175157,
"early_power_median": 1367287690.10518,
"late_power_median": 486504907.02446777,
"very_early_power_median": 294439962.72082204,
"very_late_power_median": 28908749.984774385,
"boc_main_peak_fraction_min": 0.8290411435832495,
"boc_side_peak_metric_max": 0.1881158513724548,
"boc_main_peak_coherence_min": 0.3224312067233396
},
"legacy_code_metric_pass_behavior": {
"epochs": 15456,
"dll_error_rms_chips": 0.05564808250796124,
"dll_error_p99_abs_chips": 0.1566524337921172,
"code_phase_residual_rms_chips": 10.738451874002433,
"code_phase_residual_p99_abs_chips": 18.667906550937186,
"prompt_power_median": 1558915633.5385687,
"early_power_median": 103260909.49179389,
"late_power_median": 102910301.02848622,
"very_early_power_median": 103411517.73663911,
"very_late_power_median": 103558607.31775787,
"boc_main_peak_fraction_min": 0.8293685997191675,
"boc_side_peak_metric_max": 0.19289641323661258,
"boc_main_peak_coherence_min": 0.34335711247300654
},
"post_pull_in": {
"pll_error_rms_rad": 0.07859984972937414,
"pll_error_p99_abs_rad": 0.240824657200552,
"dll_error_rms_chips": 0.056565003221219566,
"dll_error_p99_abs_chips": 0.1551231594917256,
"carrier_frequency_min_hz": 1024814.8951608158,
"carrier_frequency_max_hz": 1024835.3125753923,
"carrier_frequency_last_minus_first_hz": -10.013514776015654,
"code_frequency_min_hz": 1023000.5447241135,
"code_frequency_max_hz": 1023001.8192012197,
"code_frequency_last_minus_first_hz": -0.18815856706351042,
"cn0_median_db_hz": 46.94012968974618,
"cn0_min_db_hz": 30.850429544319983
}
}
}, },
"4": { "4": {
"prn": 4, "prn": 4,
"tracking_architecture": "e1c_pilot_aided_e1b",
"acquisition_component": "E1C",
"acquisition_detected": true, "acquisition_detected": true,
"acquisition_time_s": 0.08580062900000485, "acquisition_time_s": 0.09288738900067983,
"raw_doppler_hz": 2250.0, "raw_doppler_hz": 2250.0,
"expected_doppler_hz": 2171.0, "expected_doppler_hz": 2171.0,
"start_sample_timestamp": 86819, "start_sample_timestamp": 86819,
"tracking_epoch_count": 16088, "tracking_epoch_count": 16088,
"tracking_time_s": 13.877967087995785, "tracking_time_s": 30.40403210999648,
"tracking_final_state": "PLL_TRACK", "tracking_final_state": "PLL_TRACK",
"tracking_reacquisition_requests": 61, "tracking_reacquisition_requests": 0,
"tracking_lock_timestamp": 103187, "tracking_lock_timestamp": 324155,
"prompt_epoch_count": 8044, "prompt_epoch_count": 8044,
"first_synchronized_symbol_timestamp": 12624707, "valid_prompt_epoch_count": 8044,
"first_page_sync_timestamp": 12624707, "invalid_prompt_epoch_count": 0,
"first_crc_valid_page_timestamp": 12624707, "cycle_slips": 0,
"page_parts": 29, "sample_discontinuities": 0,
"pages": 14, "raw_navigation_symbol_count": 8044,
"crc_passes": 14, "first_synchronized_symbol_timestamp": 4440707,
"first_page_sync_timestamp": 4440707,
"first_crc_valid_page_timestamp": 4440707,
"page_parts": 31,
"pages": 15,
"crc_passes": 15,
"crc_failures": 0, "crc_failures": 0,
"fec_failures": 0, "fec_failures": 0,
"received_word_types": [ "received_word_types": [
@@ -82,66 +214,313 @@
"clock_parameters": true, "clock_parameters": true,
"health_valid": true, "health_valid": true,
"data_valid": true, "data_valid": true,
"decoding_time_s": 1.3411525530027575 "decoding_time_s": 1.2405268940055976,
"secondary_code_sync": {
"phase": 9,
"residual_frequency_hz": -62.5,
"normalized_correlation": 0.9860075818874949,
"compared_periods": 100,
"sign_errors": 0
},
"lock_analysis": {
"prn": 4,
"epochs": 16088,
"integration_ms": 2.0,
"start_sample_timestamp": 86819,
"end_sample_timestamp": 131751011,
"raw_carrier_lock_pass_epochs": 15999,
"raw_code_lock_pass_epochs": 15416,
"raw_combined_lock_pass_epochs": 15400,
"raw_combined_lock_fail_epochs": 688,
"false_transition_count": 110,
"legacy_code_lock_falling_edges": 110,
"boc_aware_code_lock_falling_edges": 0,
"boc_aware_metric_valid_epochs": 16064,
"boc_aware_code_lock_pass_epochs": 16062,
"boc_aware_combined_lock_pass_epochs": 16060,
"boc_aware_longest_code_lock_s": 32.124,
"boc_side_peak_suspected_epochs": 0,
"carrier_metric_caused_transitions": 2,
"code_metric_caused_transitions": 108,
"symbol_transition_coincident_false_transitions": 0,
"cycle_slips": 0,
"sample_discontinuities": 0,
"cn0_unavailable_false_transitions": 1,
"false_run_count": 111,
"single_epoch_false_runs": 44,
"maximum_false_run_epochs": 83,
"maximum_false_run_ms": 166.0,
"maximum_continuous_raw_lock_epochs": 1332,
"maximum_continuous_raw_lock_s": 2.664,
"carrier_metric_percentiles": {
"1": 0.7792907543694391,
"5": 0.986886910203348,
"50": 0.9988692376063891,
"95": 0.9999904419181908,
"99": 0.9999995892743159
},
"code_metric_percentiles": {
"1": 0.07231780273965693,
"5": 4.680507563993592,
"50": 14.135902184076237,
"95": 20.836601051980484,
"99": 24.70518847538168
},
"boc_main_peak_fraction_percentiles": {
"1": 0.8383947757378236,
"5": 0.8717822698761091,
"50": 0.888551897894365,
"95": 0.8981109549819073,
"99": 0.9024539160758924
},
"boc_side_peak_metric_percentiles": {
"1": 0.05606138409551904,
"5": 0.058899453610220434,
"50": 0.06631402357636437,
"95": 0.09431335702543445,
"99": 0.182588937217342
},
"boc_main_peak_coherence_percentiles": {
"1": 0.3941670522754639,
"5": 0.8633412765595349,
"50": 0.9665765778788711,
"95": 0.9798465018068564,
"99": 0.9838025458145414
},
"legacy_code_metric_failure_behavior": {
"epochs": 495,
"dll_error_rms_chips": 0.08511890392660415,
"dll_error_p99_abs_chips": 0.11897485396810367,
"code_phase_residual_rms_chips": 17.39502421893431,
"code_phase_residual_p99_abs_chips": 22.010231444418938,
"prompt_power_median": 110387217.19998863,
"early_power_median": 1414197765.1115377,
"late_power_median": 455470385.12999207,
"very_early_power_median": 327808041.9437474,
"very_late_power_median": 17217167.445349354,
"boc_main_peak_fraction_min": 0.8208748889416547,
"boc_side_peak_metric_max": 0.2108131397387269,
"boc_main_peak_coherence_min": 0.3186205201893039
},
"legacy_code_metric_pass_behavior": {
"epochs": 15344,
"dll_error_rms_chips": 0.05701516121119478,
"dll_error_p99_abs_chips": 0.15711356864497972,
"code_phase_residual_rms_chips": 12.712359749555613,
"code_phase_residual_p99_abs_chips": 22.22504688444559,
"prompt_power_median": 1557607909.2091415,
"early_power_median": 104464066.41081777,
"late_power_median": 103832797.49488433,
"very_early_power_median": 104021624.65023576,
"very_late_power_median": 104184118.93725646,
"boc_main_peak_fraction_min": 0.825903881328457,
"boc_side_peak_metric_max": 0.20186498523859175,
"boc_main_peak_coherence_min": 0.3410282015915683
},
"post_pull_in": {
"pll_error_rms_rad": 0.08218993802219834,
"pll_error_p99_abs_rad": 0.2578335220481495,
"dll_error_rms_chips": 0.05809960919771263,
"dll_error_p99_abs_chips": 0.1559104269486663,
"carrier_frequency_min_hz": 1025162.5739244855,
"carrier_frequency_max_hz": 1025179.2658243881,
"carrier_frequency_last_minus_first_hz": -4.755713645135984,
"code_frequency_min_hz": 1023000.7767271643,
"code_frequency_max_hz": 1023002.1806047091,
"code_frequency_last_minus_first_hz": -0.02835774887353182,
"cn0_median_db_hz": 46.40741068588617,
"cn0_min_db_hz": 29.42711443027489
}
}
}, },
"19": { "19": {
"prn": 19, "prn": 19,
"tracking_architecture": "e1c_pilot_aided_e1b",
"acquisition_component": "E1C",
"acquisition_detected": true, "acquisition_detected": true,
"acquisition_time_s": 0.0836479790013982, "acquisition_time_s": 0.09294268899975577,
"raw_doppler_hz": -250.0, "raw_doppler_hz": -250.0,
"expected_doppler_hz": -367.0, "expected_doppler_hz": -367.0,
"start_sample_timestamp": 87566, "start_sample_timestamp": 87566,
"tracking_epoch_count": 16088, "tracking_epoch_count": 16088,
"tracking_time_s": 13.9836710640011, "tracking_time_s": 30.35874111299927,
"tracking_final_state": "PLL_TRACK", "tracking_final_state": "PLL_TRACK",
"tracking_reacquisition_requests": 406, "tracking_reacquisition_requests": 0,
"tracking_lock_timestamp": 103934, "tracking_lock_timestamp": 734102,
"prompt_epoch_count": 8044, "prompt_epoch_count": 8044,
"first_synchronized_symbol_timestamp": 16684718, "valid_prompt_epoch_count": 8044,
"first_page_sync_timestamp": 16684718, "invalid_prompt_epoch_count": 0,
"first_crc_valid_page_timestamp": 20776718, "cycle_slips": 0,
"page_parts": 28, "sample_discontinuities": 0,
"pages": 13, "raw_navigation_symbol_count": 8044,
"crc_passes": 13, "first_synchronized_symbol_timestamp": 4408718,
"first_page_sync_timestamp": 4408718,
"first_crc_valid_page_timestamp": 4408718,
"page_parts": 31,
"pages": 15,
"crc_passes": 15,
"crc_failures": 0, "crc_failures": 0,
"fec_failures": 0, "fec_failures": 0,
"received_word_types": [ "received_word_types": [
1,
2, 2,
3, 3,
4, 4,
5, 5,
6 6
], ],
"missing_ephemeris_word_types": [ "missing_ephemeris_word_types": [],
1 "complete_ephemeris": true,
], "ephemeris_iod_nav": 29,
"complete_ephemeris": false, "first_complete_ephemeris_timestamp": 61696718,
"ephemeris_iod_nav": null,
"first_complete_ephemeris_timestamp": null,
"clock_parameters": true, "clock_parameters": true,
"health_valid": false, "health_valid": true,
"data_valid": false, "data_valid": true,
"decoding_time_s": 1.2081766550036264 "decoding_time_s": 1.3045363380006165,
"secondary_code_sync": {
"phase": 11,
"residual_frequency_hz": -38.5,
"normalized_correlation": 0.9953855496694146,
"compared_periods": 100,
"sign_errors": 0
},
"lock_analysis": {
"prn": 19,
"epochs": 16088,
"integration_ms": 2.0,
"start_sample_timestamp": 87566,
"end_sample_timestamp": 131751758,
"raw_carrier_lock_pass_epochs": 15949,
"raw_code_lock_pass_epochs": 15183,
"raw_combined_lock_pass_epochs": 15111,
"raw_combined_lock_fail_epochs": 977,
"false_transition_count": 30,
"legacy_code_lock_falling_edges": 31,
"boc_aware_code_lock_falling_edges": 0,
"boc_aware_metric_valid_epochs": 16064,
"boc_aware_code_lock_pass_epochs": 16062,
"boc_aware_combined_lock_pass_epochs": 16010,
"boc_aware_longest_code_lock_s": 32.124,
"boc_side_peak_suspected_epochs": 0,
"carrier_metric_caused_transitions": 0,
"code_metric_caused_transitions": 30,
"symbol_transition_coincident_false_transitions": 0,
"cycle_slips": 0,
"sample_discontinuities": 0,
"cn0_unavailable_false_transitions": 0,
"false_run_count": 31,
"single_epoch_false_runs": 2,
"maximum_false_run_epochs": 279,
"maximum_false_run_ms": 558.0,
"maximum_continuous_raw_lock_epochs": 523,
"maximum_continuous_raw_lock_s": 1.046,
"carrier_metric_percentiles": {
"1": 0.7348107538430972,
"5": 0.9860941032539999,
"50": 0.9988355432993672,
"95": 0.9999895126224544,
"99": 0.9999995737755365
},
"code_metric_percentiles": {
"1": 0.06685317434009187,
"5": 0.18474101219165578,
"50": 14.188567533729127,
"95": 20.853641899942982,
"99": 24.900783119459
},
"boc_main_peak_fraction_percentiles": {
"1": 0.8331077551592363,
"5": 0.8556563253779765,
"50": 0.8898638926439826,
"95": 0.8995272735597457,
"99": 0.9034500829578935
},
"boc_side_peak_metric_percentiles": {
"1": 0.05517877177836974,
"5": 0.057838743800940465,
"50": 0.06512106911574578,
"95": 0.13637269202092908,
"99": 0.19432472241219176
},
"boc_main_peak_coherence_percentiles": {
"1": 0.3285514186082982,
"5": 0.6780597999663746,
"50": 0.9697975202111472,
"95": 0.9804065041471643,
"99": 0.98403198606922
},
"legacy_code_metric_failure_behavior": {
"epochs": 728,
"dll_error_rms_chips": 0.08437694864253784,
"dll_error_p99_abs_chips": 0.11420464213225176,
"code_phase_residual_rms_chips": 2.68046518243341,
"code_phase_residual_p99_abs_chips": 3.8578061850383074,
"prompt_power_median": 107058906.74306475,
"early_power_median": 589909306.6672449,
"late_power_median": 1246213886.4347653,
"very_early_power_median": 42916283.52768795,
"very_late_power_median": 242361314.9778272,
"boc_main_peak_fraction_min": 0.8262777121115853,
"boc_side_peak_metric_max": 0.2062479029943051,
"boc_main_peak_coherence_min": 0.3152967643499338
},
"legacy_code_metric_pass_behavior": {
"epochs": 15111,
"dll_error_rms_chips": 0.05496916685224953,
"dll_error_p99_abs_chips": 0.14811405306726963,
"code_phase_residual_rms_chips": 2.1905835998108185,
"code_phase_residual_p99_abs_chips": 3.853546458312098,
"prompt_power_median": 1572867668.3084347,
"early_power_median": 104616403.78707226,
"late_power_median": 105273180.84896234,
"very_early_power_median": 104208400.61576208,
"very_late_power_median": 104196595.71394174,
"boc_main_peak_fraction_min": 0.8244860197459938,
"boc_side_peak_metric_max": 0.20227746225808624,
"boc_main_peak_coherence_min": 0.34505028117893527
},
"post_pull_in": {
"pll_error_rms_rad": 0.08798351512883444,
"pll_error_p99_abs_rad": 0.33384337105425854,
"dll_error_rms_chips": 0.05665648411462819,
"dll_error_p99_abs_chips": 0.14671966000911835,
"carrier_frequency_min_hz": 1022617.3013250588,
"carrier_frequency_max_hz": 1022640.4852699582,
"carrier_frequency_last_minus_first_hz": -12.029085914371535,
"code_frequency_min_hz": 1022999.1296675402,
"code_frequency_max_hz": 1023000.3486243897,
"code_frequency_last_minus_first_hz": 0.4431725781178102,
"cn0_median_db_hz": 47.306377947072626,
"cn0_min_db_hz": 28.833883219972133
}
}
}, },
"21": { "21": {
"prn": 21, "prn": 21,
"tracking_architecture": "e1c_pilot_aided_e1b",
"acquisition_component": "E1C",
"acquisition_detected": true, "acquisition_detected": true,
"acquisition_time_s": 0.08741291100159287, "acquisition_time_s": 0.08547867099696305,
"raw_doppler_hz": -750.0, "raw_doppler_hz": -750.0,
"expected_doppler_hz": -712.0, "expected_doppler_hz": -712.0,
"start_sample_timestamp": 93628, "start_sample_timestamp": 93628,
"tracking_epoch_count": 16088, "tracking_epoch_count": 16088,
"tracking_time_s": 13.982213923998643, "tracking_time_s": 30.185307627005386,
"tracking_final_state": "PLL_TRACK", "tracking_final_state": "PLL_TRACK",
"tracking_reacquisition_requests": 259, "tracking_reacquisition_requests": 0,
"tracking_lock_timestamp": 101812, "tracking_lock_timestamp": 339148,
"prompt_epoch_count": 8044, "prompt_epoch_count": 8044,
"first_synchronized_symbol_timestamp": 12598780, "valid_prompt_epoch_count": 8044,
"first_page_sync_timestamp": 12598780, "invalid_prompt_epoch_count": 0,
"first_crc_valid_page_timestamp": 12598780, "cycle_slips": 0,
"page_parts": 29, "sample_discontinuities": 0,
"pages": 14, "raw_navigation_symbol_count": 8044,
"crc_passes": 14, "first_synchronized_symbol_timestamp": 4414780,
"first_page_sync_timestamp": 4414780,
"first_crc_valid_page_timestamp": 4414780,
"page_parts": 31,
"pages": 15,
"crc_passes": 15,
"crc_failures": 0, "crc_failures": 0,
"fec_failures": 0, "fec_failures": 0,
"received_word_types": [ "received_word_types": [
@@ -159,27 +538,151 @@
"clock_parameters": true, "clock_parameters": true,
"health_valid": true, "health_valid": true,
"data_valid": true, "data_valid": true,
"decoding_time_s": 1.231179278001946 "decoding_time_s": 1.3240309810062172,
"secondary_code_sync": {
"phase": 11,
"residual_frequency_hz": 37.5,
"normalized_correlation": 0.9856821042522145,
"compared_periods": 100,
"sign_errors": 0
},
"lock_analysis": {
"prn": 21,
"epochs": 16088,
"integration_ms": 2.0,
"start_sample_timestamp": 93628,
"end_sample_timestamp": 131757820,
"raw_carrier_lock_pass_epochs": 16031,
"raw_code_lock_pass_epochs": 15249,
"raw_combined_lock_pass_epochs": 15249,
"raw_combined_lock_fail_epochs": 839,
"false_transition_count": 51,
"legacy_code_lock_falling_edges": 51,
"boc_aware_code_lock_falling_edges": 0,
"boc_aware_metric_valid_epochs": 16064,
"boc_aware_code_lock_pass_epochs": 16062,
"boc_aware_combined_lock_pass_epochs": 16059,
"boc_aware_longest_code_lock_s": 32.124,
"boc_side_peak_suspected_epochs": 0,
"carrier_metric_caused_transitions": 0,
"code_metric_caused_transitions": 51,
"symbol_transition_coincident_false_transitions": 0,
"cycle_slips": 0,
"sample_discontinuities": 0,
"cn0_unavailable_false_transitions": 0,
"false_run_count": 51,
"single_epoch_false_runs": 4,
"maximum_false_run_epochs": 157,
"maximum_false_run_ms": 314.0,
"maximum_continuous_raw_lock_epochs": 793,
"maximum_continuous_raw_lock_s": 1.586,
"carrier_metric_percentiles": {
"1": 0.7939514446493192,
"5": 0.985632857889365,
"50": 0.9988523785765591,
"95": 0.9999895032338855,
"99": 0.9999995002250147
},
"code_metric_percentiles": {
"1": 0.06616391188826821,
"5": 0.2581154135198231,
"50": 14.22150508819822,
"95": 21.041903291304745,
"99": 25.29011298405983
},
"boc_main_peak_fraction_percentiles": {
"1": 0.8328410713646706,
"5": 0.862839862016292,
"50": 0.8894454265519192,
"95": 0.899005820753033,
"99": 0.902725891411603
},
"boc_side_peak_metric_percentiles": {
"1": 0.05581216956145967,
"5": 0.0581020070281789,
"50": 0.06537705791281424,
"95": 0.12414647994867094,
"99": 0.19464888907499955
},
"boc_main_peak_coherence_percentiles": {
"1": 0.32651431878020987,
"5": 0.7456684567711471,
"50": 0.9698504431158739,
"95": 0.9804936771751901,
"99": 0.9837504008164109
},
"legacy_code_metric_failure_behavior": {
"epochs": 682,
"dll_error_rms_chips": 0.08442597625870917,
"dll_error_p99_abs_chips": 0.11360544478812587,
"code_phase_residual_rms_chips": 5.679331578203824,
"code_phase_residual_p99_abs_chips": 7.326207964266705,
"prompt_power_median": 104189541.93969142,
"early_power_median": 503192371.15070486,
"late_power_median": 1403405103.7404156,
"very_early_power_median": 22039174.93790304,
"very_late_power_median": 317213067.5344795,
"boc_main_peak_fraction_min": 0.8192520869211634,
"boc_side_peak_metric_max": 0.21126892991099358,
"boc_main_peak_coherence_min": 0.3116800253668606
},
"legacy_code_metric_pass_behavior": {
"epochs": 15157,
"dll_error_rms_chips": 0.05453659411635098,
"dll_error_p99_abs_chips": 0.1449088577056262,
"code_phase_residual_rms_chips": 4.188063318602212,
"code_phase_residual_p99_abs_chips": 7.374789512632724,
"prompt_power_median": 1569583185.8754468,
"early_power_median": 103618886.08421741,
"late_power_median": 103783265.51861823,
"very_early_power_median": 104017003.65515691,
"very_late_power_median": 104035110.46635103,
"boc_main_peak_fraction_min": 0.8344860559063105,
"boc_side_peak_metric_max": 0.19273063690182018,
"boc_main_peak_coherence_min": 0.33714214192677894
},
"post_pull_in": {
"pll_error_rms_rad": 0.08950722912725978,
"pll_error_p99_abs_rad": 0.34532589523816604,
"dll_error_rms_chips": 0.056152318470159265,
"dll_error_p99_abs_chips": 0.14402921672670457,
"carrier_frequency_min_hz": 1022268.5496190831,
"carrier_frequency_max_hz": 1022294.5921728422,
"carrier_frequency_last_minus_first_hz": -11.265239437692799,
"code_frequency_min_hz": 1022998.8592261485,
"code_frequency_max_hz": 1023000.1806048183,
"code_frequency_last_minus_first_hz": -0.1938835997134447,
"cn0_median_db_hz": 47.64003907257697,
"cn0_min_db_hz": 28.276106571211788
}
}
}, },
"27": { "27": {
"prn": 27, "prn": 27,
"tracking_architecture": "e1c_pilot_aided_e1b",
"acquisition_component": "E1C",
"acquisition_detected": true, "acquisition_detected": true,
"acquisition_time_s": 0.08157938700605882, "acquisition_time_s": 0.09328085999732139,
"raw_doppler_hz": -2750.0, "raw_doppler_hz": -2750.0,
"expected_doppler_hz": -2637.0, "expected_doppler_hz": -2637.0,
"start_sample_timestamp": 88014, "start_sample_timestamp": 88014,
"tracking_epoch_count": 16088, "tracking_epoch_count": 16088,
"tracking_time_s": 13.978430488001322, "tracking_time_s": 30.066800435000914,
"tracking_final_state": "PLL_TRACK", "tracking_final_state": "PLL_TRACK",
"tracking_reacquisition_requests": 0, "tracking_reacquisition_requests": 0,
"tracking_lock_timestamp": 96198, "tracking_lock_timestamp": 1381086,
"prompt_epoch_count": 8044, "prompt_epoch_count": 8044,
"first_synchronized_symbol_timestamp": 12642270, "valid_prompt_epoch_count": 8044,
"first_page_sync_timestamp": 12642270, "invalid_prompt_epoch_count": 0,
"first_crc_valid_page_timestamp": 12642270, "cycle_slips": 0,
"page_parts": 29, "sample_discontinuities": 0,
"pages": 14, "raw_navigation_symbol_count": 8044,
"crc_passes": 14, "first_synchronized_symbol_timestamp": 4458270,
"first_page_sync_timestamp": 4458270,
"first_crc_valid_page_timestamp": 4458270,
"page_parts": 31,
"pages": 15,
"crc_passes": 15,
"crc_failures": 0, "crc_failures": 0,
"fec_failures": 0, "fec_failures": 0,
"received_word_types": [ "received_word_types": [
@@ -197,7 +700,124 @@
"clock_parameters": true, "clock_parameters": true,
"health_valid": true, "health_valid": true,
"data_valid": true, "data_valid": true,
"decoding_time_s": 1.2002744679994066 "decoding_time_s": 1.2845996300020488,
"secondary_code_sync": {
"phase": 8,
"residual_frequency_hz": 36.5,
"normalized_correlation": 0.996052910867296,
"compared_periods": 100,
"sign_errors": 0
},
"lock_analysis": {
"prn": 27,
"epochs": 16088,
"integration_ms": 2.0,
"start_sample_timestamp": 88014,
"end_sample_timestamp": 131752206,
"raw_carrier_lock_pass_epochs": 15936,
"raw_code_lock_pass_epochs": 15647,
"raw_combined_lock_pass_epochs": 15556,
"raw_combined_lock_fail_epochs": 532,
"false_transition_count": 120,
"legacy_code_lock_falling_edges": 122,
"boc_aware_code_lock_falling_edges": 0,
"boc_aware_metric_valid_epochs": 16064,
"boc_aware_code_lock_pass_epochs": 16062,
"boc_aware_combined_lock_pass_epochs": 15931,
"boc_aware_longest_code_lock_s": 32.124,
"boc_side_peak_suspected_epochs": 0,
"carrier_metric_caused_transitions": 0,
"code_metric_caused_transitions": 120,
"symbol_transition_coincident_false_transitions": 0,
"cycle_slips": 0,
"sample_discontinuities": 0,
"cn0_unavailable_false_transitions": 0,
"false_run_count": 121,
"single_epoch_false_runs": 58,
"maximum_false_run_epochs": 155,
"maximum_false_run_ms": 310.0,
"maximum_continuous_raw_lock_epochs": 1095,
"maximum_continuous_raw_lock_s": 2.19,
"carrier_metric_percentiles": {
"1": 0.7206432555049623,
"5": 0.988611380400891,
"50": 0.9988895300080884,
"95": 0.9999910174511765,
"99": 0.9999996463442009
},
"code_metric_percentiles": {
"1": 0.0963945544624243,
"5": 9.022094975682883,
"50": 14.336371788159624,
"95": 21.01430502486875,
"99": 25.223778227213373
},
"boc_main_peak_fraction_percentiles": {
"1": 0.8563053481254727,
"5": 0.8761216856241046,
"50": 0.8897298346486104,
"95": 0.8989983348293147,
"99": 0.9027659012322223
},
"boc_side_peak_metric_percentiles": {
"1": 0.055865232111185666,
"5": 0.05827544455675619,
"50": 0.06545776761892909,
"95": 0.08721718306155764,
"99": 0.13554248142317915
},
"boc_main_peak_coherence_percentiles": {
"1": 0.6963850681372143,
"5": 0.8864149571238227,
"50": 0.9668775731513674,
"95": 0.9797144500640755,
"99": 0.9833546124056554
},
"legacy_code_metric_failure_behavior": {
"epochs": 353,
"dll_error_rms_chips": 0.08692287486188564,
"dll_error_p99_abs_chips": 0.12211690739906192,
"code_phase_residual_rms_chips": 20.83535121208654,
"code_phase_residual_p99_abs_chips": 26.7740789856638,
"prompt_power_median": 113099284.09916593,
"early_power_median": 496253334.48126817,
"late_power_median": 1219569538.2556756,
"very_early_power_median": 22055861.48928148,
"very_late_power_median": 272969579.3795633,
"boc_main_peak_fraction_min": 0.8211892376838884,
"boc_side_peak_metric_max": 0.20314364512635572,
"boc_main_peak_coherence_min": 0.47009464074954066
},
"legacy_code_metric_pass_behavior": {
"epochs": 15486,
"dll_error_rms_chips": 0.05696868079676946,
"dll_error_p99_abs_chips": 0.15879673488599466,
"code_phase_residual_rms_chips": 15.537475523073843,
"code_phase_residual_p99_abs_chips": 27.042686964558467,
"prompt_power_median": 1549780078.7121413,
"early_power_median": 102583529.59728253,
"late_power_median": 102880717.96247455,
"very_early_power_median": 101579724.68762413,
"very_late_power_median": 103913161.92134063,
"boc_main_peak_fraction_min": 0.8199308789902463,
"boc_side_peak_metric_max": 0.20370582760979403,
"boc_main_peak_coherence_min": 0.46610449527987624
},
"post_pull_in": {
"pll_error_rms_rad": 0.0768043956210642,
"pll_error_p99_abs_rad": 0.21602664107569355,
"dll_error_rms_chips": 0.057805622538207146,
"dll_error_p99_abs_chips": 0.15780488364179804,
"carrier_frequency_min_hz": 1020353.622410044,
"carrier_frequency_max_hz": 1020369.6683440519,
"carrier_frequency_last_minus_first_hz": -2.8634769301861525,
"code_frequency_min_hz": 1022997.605345076,
"code_frequency_max_hz": 1022998.939424797,
"code_frequency_last_minus_first_hz": -0.08650950586888939,
"cn0_median_db_hz": 45.94570799038448,
"cn0_min_db_hz": 32.27042575677653
}
}
} }
}, },
"galileo_time": { "galileo_time": {
@@ -5,7 +5,7 @@ The standalone decoder consumed tracked E1B prompts only; simulator ephemeris an
| PRN | Tracking | Symbol sync | Page sync | CRC pages | Complete ephemeris | Clock | Time | Ready | | PRN | Tracking | Symbol sync | Page sync | CRC pages | Complete ephemeris | Clock | Time | Ready |
| --: | -------: | ----------: | --------: | --------: | -----------------: | ----: | ---: | ----: | | --: | -------: | ----------: | --------: | --------: | -----------------: | ----: | ---: | ----: |
| 1 | PLL_TRACK | True | 31 | 15 | True | True | True | True | | 1 | PLL_TRACK | True | 31 | 15 | True | True | True | True |
| 4 | PLL_TRACK | True | 29 | 14 | True | True | True | True | | 4 | PLL_TRACK | True | 31 | 15 | True | True | True | True |
| 19 | PLL_TRACK | True | 28 | 13 | False | True | True | False | | 19 | PLL_TRACK | True | 31 | 15 | True | True | True | True |
| 21 | PLL_TRACK | True | 29 | 14 | True | True | True | True | | 21 | PLL_TRACK | True | 31 | 15 | True | True | True | True |
| 27 | PLL_TRACK | True | 29 | 14 | True | True | True | True | | 27 | PLL_TRACK | True | 31 | 15 | True | True | True | True |
+5 -2
View File
@@ -11,6 +11,7 @@ from galileo_rx.config import AcquisitionConfig
from galileo_rx.signal_profiles import SignalProfile from galileo_rx.signal_profiles import SignalProfile
from galileo_rx.signals.code_provider import CodeProvider from galileo_rx.signals.code_provider import CodeProvider
from galileo_rx.signals.galileo_e1 import e1_local_replica from galileo_rx.signals.galileo_e1 import e1_local_replica
from galileo_rx.signals.e1_modulation import E1Modulation, normalize_e1_modulation
@dataclass(slots=True) @dataclass(slots=True)
@@ -29,7 +30,8 @@ class PcpsAcquisition:
self.sampling_frequency_hz = sampling_frequency_hz self.sampling_frequency_hz = sampling_frequency_hz
self.config = config self.config = config
config.validate() config.validate()
self._code_fft_cache: dict[tuple[str, int, int, int, tuple[int, ...], float], npt.NDArray[np.complex128]] = {} self.modulation = normalize_e1_modulation(config.modulation)
self._code_fft_cache: dict[tuple[str, E1Modulation, int, int, int, tuple[int, ...], float], npt.NDArray[np.complex128]] = {}
@property @property
def coherent_samples(self) -> int: def coherent_samples(self) -> int:
@@ -52,11 +54,12 @@ class PcpsAcquisition:
effective_code_rate_hz = self.profile.code_rate_hz effective_code_rate_hz = self.profile.code_rate_hz
if self.config.code_doppler_aiding: if self.config.code_doppler_aiding:
effective_code_rate_hz *= 1.0 + doppler_hz / self.profile.carrier_frequency_hz effective_code_rate_hz *= 1.0 + doppler_hz / self.profile.carrier_frequency_hz
key = (component, prn, sample_count, fft_length, primary_signs, effective_code_rate_hz) key = (component, self.modulation, prn, sample_count, fft_length, primary_signs, effective_code_rate_hz)
if key not in self._code_fft_cache: if key not in self._code_fft_cache:
replica = e1_local_replica( replica = e1_local_replica(
self.profile, self.provider, component, prn, sample_count, self.profile, self.provider, component, prn, sample_count,
self.sampling_frequency_hz, code_rate_hz=effective_code_rate_hz, self.sampling_frequency_hz, code_rate_hz=effective_code_rate_hz,
modulation=self.modulation,
) )
if primary_signs != (1,): if primary_signs != (1,):
primary_samples = int(round(self.profile.component(component).primary_code_period_s * self.sampling_frequency_hz)) primary_samples = int(round(self.profile.component(component).primary_code_period_s * self.sampling_frequency_hz))
+11 -4
View File
@@ -8,6 +8,8 @@ from typing import Any, Literal, TypeVar
import yaml import yaml
from galileo_rx.signals.e1_modulation import E1Component, E1Modulation, normalize_e1_component, normalize_e1_modulation
T = TypeVar("T") T = TypeVar("T")
@@ -31,6 +33,7 @@ class RfConfig:
@dataclass(slots=True) @dataclass(slots=True)
class AcquisitionConfig: class AcquisitionConfig:
modulation: E1Modulation | str = E1Modulation.BOC11
doppler_min_hz: float = -10_000.0 doppler_min_hz: float = -10_000.0
doppler_max_hz: float = 10_000.0 doppler_max_hz: float = 10_000.0
doppler_step_hz: float = 250.0 doppler_step_hz: float = 250.0
@@ -51,6 +54,7 @@ class AcquisitionConfig:
code_doppler_aiding: bool = True code_doppler_aiding: bool = True
def validate(self) -> None: def validate(self) -> None:
normalize_e1_modulation(self.modulation)
if self.doppler_max_hz < self.doppler_min_hz or self.doppler_step_hz <= 0: if self.doppler_max_hz < self.doppler_min_hz or self.doppler_step_hz <= 0:
raise ValueError("invalid Doppler search range or step") raise ValueError("invalid Doppler search range or step")
if self.coherent_integration_ms <= 0 or self.noncoherent_accumulations < 1: if self.coherent_integration_ms <= 0 or self.noncoherent_accumulations < 1:
@@ -63,6 +67,7 @@ class AcquisitionConfig:
@dataclass(slots=True) @dataclass(slots=True)
class TrackingConfig: class TrackingConfig:
modulation: E1Modulation | str = E1Modulation.BOC11
integration_ms: float = 4.0 integration_ms: float = 4.0
early_late_spacing_chips: float = 0.25 early_late_spacing_chips: float = 0.25
very_early_late_spacing_chips: float = 0.75 very_early_late_spacing_chips: float = 0.75
@@ -92,6 +97,7 @@ class TrackingConfig:
lock_signal_loss_power_ratio: float = 0.02 lock_signal_loss_power_ratio: float = 0.02
def validate(self) -> None: def validate(self) -> None:
normalize_e1_modulation(self.modulation)
if self.integration_ms <= 0: if self.integration_ms <= 0:
raise ValueError("tracking integration_ms must be positive") raise ValueError("tracking integration_ms must be positive")
if not 0 < self.early_late_spacing_chips < self.very_early_late_spacing_chips: if not 0 < self.early_late_spacing_chips < self.very_early_late_spacing_chips:
@@ -132,7 +138,7 @@ class FixedPointConfig:
@dataclass(slots=True) @dataclass(slots=True)
class ReceiverConfig: class ReceiverConfig:
signal_profile: str = "GALILEO_E1" signal_profile: str = "GALILEO_E1"
component: Literal["E1B", "E1C"] = "E1C" component: E1Component | str = E1Component.E1C
prns: list[int] = field(default_factory=lambda: [1]) prns: list[int] = field(default_factory=lambda: [1])
rf: RfConfig = field(default_factory=RfConfig) rf: RfConfig = field(default_factory=RfConfig)
acquisition: AcquisitionConfig = field(default_factory=AcquisitionConfig) acquisition: AcquisitionConfig = field(default_factory=AcquisitionConfig)
@@ -141,18 +147,19 @@ class ReceiverConfig:
fixed_point: FixedPointConfig = field(default_factory=FixedPointConfig) fixed_point: FixedPointConfig = field(default_factory=FixedPointConfig)
def validate(self) -> None: def validate(self) -> None:
component = normalize_e1_component(self.component)
self.rf.validate() self.rf.validate()
self.acquisition.validate() self.acquisition.validate()
self.tracking.validate() self.tracking.validate()
if not self.prns or any(prn <= 0 for prn in self.prns): if not self.prns or any(prn <= 0 for prn in self.prns):
raise ValueError("prns must contain positive identifiers") raise ValueError("prns must contain positive identifiers")
if self.acquisition.coherent_integration_ms > 4.0: if self.acquisition.coherent_integration_ms > 4.0:
if self.component == "E1B" and self.acquisition.bit_transition_mode == "none": if component is E1Component.E1B and self.acquisition.bit_transition_mode == "none":
raise ValueError("E1B coherent integration beyond 4 ms requires a bit-transition mode") raise ValueError("E1B coherent integration beyond 4 ms requires a bit-transition mode")
if self.component == "E1C" and self.acquisition.secondary_code_mode == "none": if component is E1Component.E1C and self.acquisition.secondary_code_mode == "none":
raise ValueError("E1C coherent integration beyond 4 ms requires secondary-code handling") raise ValueError("E1C coherent integration beyond 4 ms requires secondary-code handling")
if self.tracking.integration_ms > 4.0: if self.tracking.integration_ms > 4.0:
if self.component == "E1B": if component is E1Component.E1B:
raise ValueError("E1B tracking integration beyond 4 ms is unsafe without symbol synchronization") raise ValueError("E1B tracking integration beyond 4 ms is unsafe without symbol synchronization")
if self.tracking.secondary_code_mode == "none": if self.tracking.secondary_code_mode == "none":
raise ValueError("E1C tracking beyond 4 ms requires secondary-code handling") raise ValueError("E1C tracking beyond 4 ms requires secondary-code handling")
+2 -1
View File
@@ -24,6 +24,7 @@ class SignalProfile:
modulation: str modulation: str
boc_subcarrier_rate_hz: float boc_subcarrier_rate_hz: float
components: dict[str, ComponentProfile] components: dict[str, ComponentProfile]
supported_modulations: tuple[str, ...] = ("BOC11",)
def component(self, name: str) -> ComponentProfile: def component(self, name: str) -> ComponentProfile:
try: try:
@@ -44,6 +45,7 @@ def galileo_e1_profile() -> SignalProfile:
"E1B": ComponentProfile("E1B", "data", 4092, 0.004, None, 0.004), "E1B": ComponentProfile("E1B", "data", 4092, 0.004, None, 0.004),
"E1C": ComponentProfile("E1C", "pilot", 4092, 0.004, 25, None), "E1C": ComponentProfile("E1C", "pilot", 4092, 0.004, 25, None),
}, },
supported_modulations=("BOC11", "CBOC"),
) )
@@ -55,4 +57,3 @@ def get_signal_profile(name: str) -> SignalProfile:
return PROFILES[name]() return PROFILES[name]()
except KeyError as exc: except KeyError as exc:
raise ValueError(f"unknown signal profile {name!r}; available: {sorted(PROFILES)}") from exc raise ValueError(f"unknown signal profile {name!r}; available: {sorted(PROFILES)}") from exc
+13 -2
View File
@@ -1,7 +1,18 @@
"""Signal code, modulation, and generation.""" """Signal code, modulation, and generation."""
from .code_provider import CodeProvider, FileCodeProvider, SyntheticCodeProvider from .code_provider import CodeProvider, FileCodeProvider, SyntheticCodeProvider
from .e1_modulation import E1Component, E1Modulation
from .fullband_e1_generator import FullBandE1SignalGenerator, FullBandFrontendConfig
from .signal_generator import SatelliteSignalConfig, SyntheticSignalGenerator from .signal_generator import SatelliteSignalConfig, SyntheticSignalGenerator
__all__ = ["CodeProvider", "FileCodeProvider", "SyntheticCodeProvider", "SatelliteSignalConfig", "SyntheticSignalGenerator"] __all__ = [
"CodeProvider",
"E1Component",
"E1Modulation",
"FileCodeProvider",
"FullBandE1SignalGenerator",
"FullBandFrontendConfig",
"SatelliteSignalConfig",
"SyntheticCodeProvider",
"SyntheticSignalGenerator",
]
+47 -3
View File
@@ -2,10 +2,55 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
import numpy as np import numpy as np
import numpy.typing as npt import numpy.typing as npt
def square_subcarrier_from_phase(
phase_cycles: npt.ArrayLike,
) -> npt.NDArray[np.float64]:
"""Return a sine-phased square subcarrier from phase in cycles.
The value at an exact integer-cycle boundary is defined as +1. This is
the existing receiver convention and removes the otherwise undefined
``sgn(sin(0))`` sample without affecting continuous-time signal power.
"""
phase = np.mod(np.asarray(phase_cycles, dtype=np.float64), 1.0)
# A tiny boundary tolerance makes vector and arbitrarily chunked NCO runs
# choose the same half-cycle when roundoff lands immediately below an
# analytically exact transition.
half_cycle = np.floor(2.0 * phase + 1e-12).astype(np.int64) & 1
return np.where(half_cycle == 0, 1.0, -1.0).astype(np.float64)
@dataclass(slots=True)
class SquareSubcarrierNco:
"""Phase-continuous square-wave NCO suitable for BOC subcarriers."""
sampling_frequency_hz: float
frequency_hz: float
phase_cycles: float = 0.0
def __post_init__(self) -> None:
if self.sampling_frequency_hz <= 0.0 or self.frequency_hz <= 0.0:
raise ValueError("subcarrier sampling and NCO frequencies must be positive")
self.phase_cycles %= 1.0
def phases(self, sample_count: int) -> npt.NDArray[np.float64]:
"""Return current-sample phases and advance the accumulator."""
if sample_count < 0:
raise ValueError("sample_count must be nonnegative")
increment = self.frequency_hz / self.sampling_frequency_hz
phase = self.phase_cycles + np.arange(sample_count, dtype=np.float64) * increment
self.phase_cycles = float((self.phase_cycles + sample_count * increment) % 1.0)
return np.mod(phase, 1.0).astype(np.float64)
def samples(self, sample_count: int) -> npt.NDArray[np.float64]:
return square_subcarrier_from_phase(self.phases(sample_count))
def boc_subcarrier( def boc_subcarrier(
sample_count: int, sample_count: int,
sampling_frequency_hz: float, sampling_frequency_hz: float,
@@ -16,7 +61,7 @@ def boc_subcarrier(
if sample_count < 0 or sampling_frequency_hz <= 0 or subcarrier_frequency_hz <= 0: if sample_count < 0 or sampling_frequency_hz <= 0 or subcarrier_frequency_hz <= 0:
raise ValueError("invalid BOC generator parameters") raise ValueError("invalid BOC generator parameters")
phase = initial_phase_cycles + np.arange(sample_count, dtype=np.float64) * subcarrier_frequency_hz / sampling_frequency_hz phase = initial_phase_cycles + np.arange(sample_count, dtype=np.float64) * subcarrier_frequency_hz / sampling_frequency_hz
wave = np.where(np.mod(phase, 1.0) < 0.5, 1.0, -1.0) wave = square_subcarrier_from_phase(phase)
ending_phase = float((initial_phase_cycles + sample_count * subcarrier_frequency_hz / sampling_frequency_hz) % 1.0) ending_phase = float((initial_phase_cycles + sample_count * subcarrier_frequency_hz / sampling_frequency_hz) % 1.0)
return wave, ending_phase return wave, ending_phase
@@ -32,7 +77,6 @@ def sample_primary_code(
if code.ndim != 1 or code.size == 0 or sampling_frequency_hz <= 0 or code_rate_hz <= 0: if code.ndim != 1 or code.size == 0 or sampling_frequency_hz <= 0 or code_rate_hz <= 0:
raise ValueError("invalid code sampling parameters") raise ValueError("invalid code sampling parameters")
phases = initial_code_phase_chips + np.arange(sample_count, dtype=np.float64) * code_rate_hz / sampling_frequency_hz phases = initial_code_phase_chips + np.arange(sample_count, dtype=np.float64) * code_rate_hz / sampling_frequency_hz
values = code[np.floor(phases).astype(np.int64) % code.size].astype(np.float64) values = code[np.floor(phases + 1e-12).astype(np.int64) % code.size].astype(np.float64)
ending_phase = float((initial_code_phase_chips + sample_count * code_rate_hz / sampling_frequency_hz) % code.size) ending_phase = float((initial_code_phase_chips + sample_count * code_rate_hz / sampling_frequency_hz) % code.size)
return values, ending_phase return values, ending_phase
+109
View File
@@ -0,0 +1,109 @@
"""Galileo E1 BOC/CBOC component-replica definitions.
The full-band convention follows Galileo OS SIS ICD Issue 2.2, equation (12):
* E1B component bracket: ``alpha * sc_1 + beta * sc_6``
* E1C component bracket: ``alpha * sc_1 - beta * sc_6``
where ``alpha=sqrt(10/11)``, ``beta=sqrt(1/11)``, and each ``sc_n`` is a
sine-phased square wave ``sgn(sin(2*pi*n*R_c*t))``. Equation (12) also has an
outer minus sign on the transmitted E1C term. That constant sign is a carrier
phase ambiguity for a component receiver and is deliberately not folded into
the CBOC(-) replica definition here.
"""
from __future__ import annotations
from enum import Enum
import numpy as np
import numpy.typing as npt
from .boc import square_subcarrier_from_phase
class E1Modulation(str, Enum):
BOC11 = "BOC11"
CBOC = "CBOC"
class E1Component(str, Enum):
E1B = "E1B"
E1C = "E1C"
CBOC_ALPHA = float(np.sqrt(10.0 / 11.0))
CBOC_BETA = float(np.sqrt(1.0 / 11.0))
def normalize_e1_modulation(value: E1Modulation | str) -> E1Modulation:
if isinstance(value, E1Modulation):
return value
normalized = str(value).strip().upper().replace("(", "").replace(")", "").replace(",", "")
aliases = {
"BOC11": E1Modulation.BOC11,
"BOC1 1": E1Modulation.BOC11,
"CBOC": E1Modulation.CBOC,
"CBOC611/11": E1Modulation.CBOC,
}
try:
return aliases[normalized]
except KeyError as exc:
raise ValueError(f"unsupported Galileo E1 modulation {value!r}") from exc
def normalize_e1_component(value: E1Component | str) -> E1Component:
try:
return value if isinstance(value, E1Component) else E1Component(str(value).upper())
except ValueError as exc:
raise ValueError(f"unsupported Galileo E1 component {value!r}") from exc
def component_cboc_boc61_sign(component: E1Component | str) -> int:
"""Return the internal high-rate CBOC weight sign (+ for B, - for C)."""
return 1 if normalize_e1_component(component) is E1Component.E1B else -1
def e1_transmitted_component_sign(component: E1Component | str) -> int:
"""Return equation (12)'s outer E1 component sign (+B, -C)."""
return 1 if normalize_e1_component(component) is E1Component.E1B else -1
def e1_subcarrier_replica(
component: E1Component | str,
modulation: E1Modulation | str,
boc11_phase_cycles: npt.ArrayLike,
boc61_phase_cycles: npt.ArrayLike | None = None,
) -> npt.NDArray[np.float64]:
"""Generate the normalized real E1 component subcarrier replica."""
selected = normalize_e1_modulation(modulation)
sc11 = square_subcarrier_from_phase(boc11_phase_cycles)
if selected is E1Modulation.BOC11:
return sc11
if boc61_phase_cycles is None:
raise ValueError("CBOC generation requires BOC(6,1) phase")
sc61 = square_subcarrier_from_phase(boc61_phase_cycles)
return (
CBOC_ALPHA * sc11
+ component_cboc_boc61_sign(component) * CBOC_BETA * sc61
).astype(np.float64)
def e1_transmitted_subcarrier_replica(
component: E1Component | str,
modulation: E1Modulation | str,
boc11_phase_cycles: npt.ArrayLike,
boc61_phase_cycles: npt.ArrayLike | None = None,
) -> npt.NDArray[np.float64]:
"""Generate a transmitted component, including the CBOC E1C outer sign.
BOC11 remains the historical compatibility approximation and therefore
preserves its previous component sign. Full CBOC applies equation (12).
"""
selected = normalize_e1_modulation(modulation)
replica = e1_subcarrier_replica(
component, selected, boc11_phase_cycles, boc61_phase_cycles
)
if selected is E1Modulation.CBOC:
replica = replica * e1_transmitted_component_sign(component)
return np.asarray(replica, dtype=np.float64)
@@ -0,0 +1,184 @@
"""Band-limited full-band Galileo E1 BOC/CBOC test-vector generator."""
from __future__ import annotations
from dataclasses import dataclass, replace
import numpy as np
import numpy.typing as npt
from scipy.signal import firwin, lfilter
from galileo_rx.signal_profiles import SignalProfile
from galileo_rx.types import GenerationTruth, SatelliteTruth
from .code_provider import CodeProvider
from .signal_generator import (
ChannelModelConfig,
SatelliteSignalConfig,
SyntheticSignalGenerator,
quantize_normalized,
)
@dataclass(frozen=True, slots=True)
class FullBandFrontendConfig:
"""Oversampling and realizable anti-alias/front-end filter settings."""
internal_oversampling: int = 2
frontend_one_sided_bandwidth_hz: float = 12.0e6
fir_tap_count: int = 129
fir_taps: tuple[float, ...] | None = None
def validate(self, output_sampling_frequency_hz: float) -> None:
if self.internal_oversampling < 2:
raise ValueError("full-band generation requires internal_oversampling >= 2")
if not 0.0 < self.frontend_one_sided_bandwidth_hz < output_sampling_frequency_hz / 2.0:
raise ValueError("front-end one-sided bandwidth must lie below output Nyquist")
if self.fir_taps is None and (self.fir_tap_count < 3 or self.fir_tap_count % 2 == 0):
raise ValueError("designed full-band FIR must have an odd tap count >= 3")
if self.fir_taps is not None and len(self.fir_taps) < 2:
raise ValueError("explicit full-band FIR requires at least two taps")
class FullBandE1SignalGenerator:
"""Generate CBOC at a higher rate, FIR filter, and integer-decimate.
The FIR and decimation states persist across calls. No assumption is made
about integral samples per BOC(6,1) sign interval. A causal FIR is used,
so ``GenerationTruth`` includes its deterministic group delay in the
reported code delay.
"""
def __init__(
self,
profile: SignalProfile,
code_provider: CodeProvider,
sampling_frequency_hz: float = 32.736e6,
intermediate_frequency_hz: float = 0.0,
output_is_real_if: bool = False,
satellites: list[SatelliteSignalConfig] | None = None,
channel: ChannelModelConfig | None = None,
frontend: FullBandFrontendConfig | None = None,
) -> None:
if sampling_frequency_hz <= 0.0:
raise ValueError("sampling_frequency_hz must be positive")
if abs(intermediate_frequency_hz) >= sampling_frequency_hz / 2.0:
raise ValueError("output IF must lie strictly inside Nyquist")
self.profile = profile
self.code_provider = code_provider
self.sampling_frequency_hz = float(sampling_frequency_hz)
self.intermediate_frequency_hz = float(intermediate_frequency_hz)
self.output_is_real_if = bool(output_is_real_if)
self.satellites = satellites or []
self.channel = channel or ChannelModelConfig()
self.frontend = frontend or FullBandFrontendConfig()
self.frontend.validate(self.sampling_frequency_hz)
self.internal_sampling_frequency_hz = (
self.sampling_frequency_hz * self.frontend.internal_oversampling
)
for satellite in self.satellites:
satellite.validate(profile)
if self.frontend.fir_taps is None:
taps = firwin(
self.frontend.fir_tap_count,
self.frontend.frontend_one_sided_bandwidth_hz,
fs=self.internal_sampling_frequency_hz,
window=("kaiser", 8.0),
)
else:
taps = np.asarray(self.frontend.fir_taps, dtype=np.float64)
self.fir_taps = np.asarray(taps, dtype=np.float64)
self._filter_state = np.zeros(self.fir_taps.size - 1, dtype=np.complex128)
inner_channel = replace(
self.channel,
clip_amplitude=None,
quantization_bits=None,
)
self._inner = SyntheticSignalGenerator(
profile,
code_provider,
self.internal_sampling_frequency_hz,
intermediate_frequency_hz,
False,
self.satellites,
inner_channel,
)
self._output_sample_index = 0
@property
def group_delay_output_samples(self) -> float:
return (self.fir_taps.size - 1) / (2.0 * self.frontend.internal_oversampling)
@property
def sample_index(self) -> int:
return self._output_sample_index
def reset(self) -> None:
self._inner.reset()
self._filter_state.fill(0.0)
self._output_sample_index = 0
def generate(
self,
sample_count: int,
) -> tuple[npt.NDArray[np.complex128] | npt.NDArray[np.float64], GenerationTruth]:
if sample_count < 0:
raise ValueError("sample_count must be nonnegative")
decimation = self.frontend.internal_oversampling
high_rate, inner_truth = self._inner.generate(sample_count * decimation)
filtered, self._filter_state = lfilter(
self.fir_taps,
np.asarray([1.0], dtype=np.float64),
np.asarray(high_rate, dtype=np.complex128),
zi=self._filter_state,
)
baseband = np.asarray(filtered[::decimation], dtype=np.complex128)
if baseband.size != sample_count:
raise RuntimeError("internal decimator produced an inconsistent sample count")
result: npt.NDArray[np.complex128] | npt.NDArray[np.float64]
if self.output_is_real_if:
result = (np.sqrt(2.0) * baseband.real).astype(np.float64)
else:
result = baseband.astype(np.complex128)
if self.channel.clip_amplitude is not None:
limit = self.channel.clip_amplitude
if np.iscomplexobj(result):
result = (
np.clip(result.real, -limit, limit)
+ 1j * np.clip(result.imag, -limit, limit)
).astype(np.complex128)
else:
result = np.clip(result, -limit, limit).astype(np.float64)
if self.channel.quantization_bits is not None:
result = quantize_normalized(result, self.channel.quantization_bits)
delay_samples = self.group_delay_output_samples
delay_chips = delay_samples * self.profile.code_rate_hz / self.sampling_frequency_hz
satellites = [
SatelliteTruth(
prn=truth.prn,
component=truth.component,
initial_code_delay_chips=truth.initial_code_delay_chips + delay_chips,
initial_code_delay_samples=(
truth.initial_code_delay_chips
* self.sampling_frequency_hz
/ self.profile.code_rate_hz
+ delay_samples
),
initial_doppler_hz=truth.initial_doppler_hz,
doppler_rate_hz_s=truth.doppler_rate_hz_s,
initial_carrier_phase_rad=truth.initial_carrier_phase_rad,
amplitude=truth.amplitude,
cn0_db_hz=truth.cn0_db_hz,
initial_code_doppler_hz=truth.initial_code_doppler_hz,
)
for truth in inner_truth.satellites
]
truth = GenerationTruth(
self.sampling_frequency_hz,
self.intermediate_frequency_hz,
self.output_is_real_if,
self._output_sample_index,
sample_count,
satellites,
)
self._output_sample_index += sample_count
return result, truth
+11 -3
View File
@@ -6,8 +6,9 @@ import numpy as np
import numpy.typing as npt import numpy.typing as npt
from galileo_rx.signal_profiles import SignalProfile from galileo_rx.signal_profiles import SignalProfile
from .boc import boc_subcarrier, sample_primary_code from .boc import sample_primary_code
from .code_provider import CodeProvider from .code_provider import CodeProvider
from .e1_modulation import E1Modulation, e1_subcarrier_replica
def e1_local_replica( def e1_local_replica(
@@ -20,12 +21,19 @@ def e1_local_replica(
initial_code_phase_chips: float = 0.0, initial_code_phase_chips: float = 0.0,
boc_enabled: bool = True, boc_enabled: bool = True,
code_rate_hz: float | None = None, code_rate_hz: float | None = None,
modulation: E1Modulation | str = E1Modulation.BOC11,
) -> npt.NDArray[np.float64]: ) -> npt.NDArray[np.float64]:
primary = provider.primary_code(profile, component, prn) primary = provider.primary_code(profile, component, prn)
effective_code_rate_hz = profile.code_rate_hz if code_rate_hz is None else code_rate_hz effective_code_rate_hz = profile.code_rate_hz if code_rate_hz is None else code_rate_hz
code, _ = sample_primary_code(primary, sample_count, sampling_frequency_hz, effective_code_rate_hz, initial_code_phase_chips) code, _ = sample_primary_code(primary, sample_count, sampling_frequency_hz, effective_code_rate_hz, initial_code_phase_chips)
if not boc_enabled: if not boc_enabled:
return code return code
rate_scale = effective_code_rate_hz / profile.code_rate_hz sample_indices = np.arange(sample_count, dtype=np.float64)
subcarrier, _ = boc_subcarrier(sample_count, sampling_frequency_hz, profile.boc_subcarrier_rate_hz * rate_scale, initial_code_phase_chips % 1.0) code_phase_chips = initial_code_phase_chips + sample_indices * effective_code_rate_hz / sampling_frequency_hz
subcarrier = e1_subcarrier_replica(
component,
modulation,
np.mod(code_phase_chips, 1.0),
np.mod(6.0 * code_phase_chips, 1.0),
)
return code * subcarrier return code * subcarrier
+25 -7
View File
@@ -11,6 +11,11 @@ import numpy.typing as npt
from galileo_rx.signal_profiles import SignalProfile from galileo_rx.signal_profiles import SignalProfile
from galileo_rx.types import GenerationTruth, SatelliteTruth from galileo_rx.types import GenerationTruth, SatelliteTruth
from .code_provider import CodeProvider from .code_provider import CodeProvider
from .e1_modulation import (
E1Modulation,
e1_transmitted_subcarrier_replica,
normalize_e1_modulation,
)
@dataclass(slots=True) @dataclass(slots=True)
@@ -26,6 +31,8 @@ class SatelliteSignalConfig:
navigation_bits: list[int] = field(default_factory=lambda: [1]) navigation_bits: list[int] = field(default_factory=lambda: [1])
navigation_bit_period_s: float = 0.004 navigation_bit_period_s: float = 0.004
secondary_code_phase: int = 0 secondary_code_phase: int = 0
modulation: E1Modulation | str = E1Modulation.BOC11
code_doppler_hz: float | None = None
def validate(self, profile: SignalProfile) -> None: def validate(self, profile: SignalProfile) -> None:
profile.component(self.component) profile.component(self.component)
@@ -33,6 +40,7 @@ class SatelliteSignalConfig:
raise ValueError("PRN must be positive and amplitude nonnegative") raise ValueError("PRN must be positive and amplitude nonnegative")
if not self.navigation_bits or not set(self.navigation_bits) <= {-1, 1}: if not self.navigation_bits or not set(self.navigation_bits) <= {-1, 1}:
raise ValueError("navigation_bits must be a nonempty +/-1 list") raise ValueError("navigation_bits must be a nonempty +/-1 list")
normalize_e1_modulation(self.modulation)
@dataclass(slots=True) @dataclass(slots=True)
@@ -99,19 +107,28 @@ class SyntheticSignalGenerator:
for satellite in self.satellites: for satellite in self.satellites:
primary = self.code_provider.primary_code(self.profile, satellite.component, satellite.prn) primary = self.code_provider.primary_code(self.profile, satellite.component, satellite.prn)
# Common line-of-sight dynamics scale carrier and spreading-code # Common line-of-sight dynamics normally scale carrier and code by
# frequencies by the same fractional Doppler. Keeping this coupling # the same fraction. An explicit code_doppler_hz overrides that
# makes carrier-aided code tracking physically self-consistent. # coupling for clock-error and stress-test vectors.
code_doppler_hz = (
satellite.doppler_hz * self.profile.code_rate_hz / self.profile.carrier_frequency_hz
if satellite.code_doppler_hz is None
else satellite.code_doppler_hz
)
code_phase_chips = ( code_phase_chips = (
signal_t_s * self.profile.code_rate_hz signal_t_s * self.profile.code_rate_hz
+ signal_t_s * satellite.doppler_hz * self.profile.code_rate_hz / self.profile.carrier_frequency_hz + signal_t_s * code_doppler_hz
+ 0.5 * signal_t_s**2 * satellite.doppler_rate_hz_s * self.profile.code_rate_hz / self.profile.carrier_frequency_hz + 0.5 * signal_t_s**2 * satellite.doppler_rate_hz_s * self.profile.code_rate_hz / self.profile.carrier_frequency_hz
- satellite.code_delay_chips - satellite.code_delay_chips
) )
chip_indices = np.floor(code_phase_chips).astype(np.int64) chip_indices = np.floor(code_phase_chips + 1e-12).astype(np.int64)
spreading = self._sign_sequence(primary, chip_indices) spreading = self._sign_sequence(primary, chip_indices)
subcarrier_phase = code_phase_chips subcarrier = e1_transmitted_subcarrier_replica(
subcarrier = np.where(np.mod(subcarrier_phase, 1.0) < 0.5, 1.0, -1.0) satellite.component,
satellite.modulation,
np.mod(code_phase_chips, 1.0),
np.mod(6.0 * code_phase_chips, 1.0),
)
component_spec = self.profile.component(satellite.component) component_spec = self.profile.component(satellite.component)
if component_spec.channel_type == "data": if component_spec.channel_type == "data":
@@ -142,6 +159,7 @@ class SyntheticSignalGenerator:
initial_carrier_phase_rad=satellite.carrier_phase_rad, initial_carrier_phase_rad=satellite.carrier_phase_rad,
amplitude=satellite.amplitude, amplitude=satellite.amplitude,
cn0_db_hz=satellite.cn0_db_hz, cn0_db_hz=satellite.cn0_db_hz,
initial_code_doppler_hz=code_doppler_hz,
) )
) )
+77 -8
View File
@@ -8,7 +8,9 @@ import numpy as np
import numpy.typing as npt import numpy.typing as npt
from galileo_rx.signal_profiles import SignalProfile from galileo_rx.signal_profiles import SignalProfile
from galileo_rx.signals.boc import SquareSubcarrierNco
from galileo_rx.signals.code_provider import CodeProvider from galileo_rx.signals.code_provider import CodeProvider
from galileo_rx.signals.e1_modulation import E1Modulation, e1_subcarrier_replica, normalize_e1_modulation
from galileo_rx.types import CorrelatorOutputs from galileo_rx.types import CorrelatorOutputs
from .nco import CarrierNco, CodeNco from .nco import CarrierNco, CodeNco
@@ -23,22 +25,74 @@ class CorrelatorBank:
code_nco: CodeNco code_nco: CodeNco
early_late_spacing_chips: float early_late_spacing_chips: float
very_early_late_spacing_chips: float very_early_late_spacing_chips: float
modulation: E1Modulation | str = E1Modulation.BOC11
_code: npt.NDArray[np.int8] = field(init=False, repr=False) _code: npt.NDArray[np.int8] = field(init=False, repr=False)
_boc11_nco: SquareSubcarrierNco = field(init=False, repr=False)
_boc61_nco: SquareSubcarrierNco = field(init=False, repr=False)
def __post_init__(self) -> None: def __post_init__(self) -> None:
self._code = self.provider.primary_code(self.profile, self.component, self.prn) self._code = self.provider.primary_code(self.profile, self.component, self.prn)
self.modulation = normalize_e1_modulation(self.modulation)
self._boc11_nco = SquareSubcarrierNco(
self.code_nco.sampling_frequency_hz,
self.code_nco.frequency_hz,
self.code_nco.phase_chips % 1.0,
)
self._boc61_nco = SquareSubcarrierNco(
self.code_nco.sampling_frequency_hz,
6.0 * self.code_nco.frequency_hz,
(6.0 * self.code_nco.phase_chips) % 1.0,
)
def _replica(self, phases_chips: np.ndarray, offset_chips: float) -> np.ndarray: def _replica(
self,
phases_chips: npt.NDArray[np.float64],
boc11_phase_cycles: npt.NDArray[np.float64],
boc61_phase_cycles: npt.NDArray[np.float64],
offset_chips: float,
) -> npt.NDArray[np.float64]:
tap_phase = phases_chips + offset_chips tap_phase = phases_chips + offset_chips
code = self._code[np.floor(tap_phase).astype(np.int64) % self._code.size] code = self._code[np.floor(tap_phase + 1e-12).astype(np.int64) % self._code.size]
boc = np.where(np.mod(tap_phase, 1.0) < 0.5, 1.0, -1.0) subcarrier = e1_subcarrier_replica(
return code * boc self.component,
self.modulation,
boc11_phase_cycles + offset_chips,
boc61_phase_cycles + 6.0 * offset_chips,
)
return code.astype(np.float64) * subcarrier
def phase_vectors(
self,
sample_count: int,
) -> tuple[
npt.NDArray[np.float64],
npt.NDArray[np.float64],
npt.NDArray[np.float64],
npt.NDArray[np.float64],
]:
"""Advance and return independent carrier/code/BOC11/BOC61 phases."""
carrier_phase = self.carrier_nco.phases(sample_count)
code_phase = self.code_nco.phases(sample_count)
# The two subcarrier accumulators are distinct state elements, but
# their frequencies are code-aided so loop updates preserve the CBOC
# phase relationship without assuming integral samples/half-cycle.
self._boc11_nco.frequency_hz = self.code_nco.frequency_hz
self._boc61_nco.frequency_hz = 6.0 * self.code_nco.frequency_hz
boc11_phase = self._boc11_nco.phases(sample_count)
boc61_phase = self._boc61_nco.phases(sample_count)
return carrier_phase, code_phase, boc11_phase, boc61_phase
def correlate(self, samples: npt.NDArray[np.generic], secondary_sign: int = 1) -> CorrelatorOutputs: def correlate(self, samples: npt.NDArray[np.generic], secondary_sign: int = 1) -> CorrelatorOutputs:
array = np.asarray(samples, dtype=np.complex128) array = np.asarray(samples, dtype=np.complex128)
carrier_phase = self.carrier_nco.phases(array.size) carrier_phase, code_phase, boc11_phase, boc61_phase = self.phase_vectors(array.size)
code_phase = self.code_nco.phases(array.size) return self.correlate_with_phases(
return self.correlate_with_phases(array, carrier_phase, code_phase, secondary_sign) array,
carrier_phase,
code_phase,
secondary_sign,
boc11_phase,
boc61_phase,
)
def correlate_with_phases( def correlate_with_phases(
self, self,
@@ -46,6 +100,8 @@ class CorrelatorBank:
carrier_phase_rad: npt.NDArray[np.float64], carrier_phase_rad: npt.NDArray[np.float64],
code_phase_chips: npt.NDArray[np.float64], code_phase_chips: npt.NDArray[np.float64],
secondary_sign: int = 1, secondary_sign: int = 1,
boc11_phase_cycles: npt.NDArray[np.float64] | None = None,
boc61_phase_cycles: npt.NDArray[np.float64] | None = None,
) -> CorrelatorOutputs: ) -> CorrelatorOutputs:
"""Correlate using shared phases without advancing either NCO. """Correlate using shared phases without advancing either NCO.
@@ -58,6 +114,16 @@ class CorrelatorBank:
code_phase = np.asarray(code_phase_chips, dtype=np.float64) code_phase = np.asarray(code_phase_chips, dtype=np.float64)
if carrier_phase.shape != array.shape or code_phase.shape != array.shape: if carrier_phase.shape != array.shape or code_phase.shape != array.shape:
raise ValueError("sample, carrier-phase, and code-phase vectors must have equal shapes") raise ValueError("sample, carrier-phase, and code-phase vectors must have equal shapes")
if boc11_phase_cycles is None:
boc11_phase = np.mod(code_phase, 1.0)
else:
boc11_phase = np.asarray(boc11_phase_cycles, dtype=np.float64)
if boc61_phase_cycles is None:
boc61_phase = np.mod(6.0 * code_phase, 1.0)
else:
boc61_phase = np.asarray(boc61_phase_cycles, dtype=np.float64)
if boc11_phase.shape != array.shape or boc61_phase.shape != array.shape:
raise ValueError("sample and subcarrier-phase vectors must have equal shapes")
wiped = array * np.exp(-1j * carrier_phase) * secondary_sign wiped = array * np.exp(-1j * carrier_phase) * secondary_sign
taps = { taps = {
"very_early": -self.very_early_late_spacing_chips, "very_early": -self.very_early_late_spacing_chips,
@@ -66,7 +132,10 @@ class CorrelatorBank:
"late": self.early_late_spacing_chips, "late": self.early_late_spacing_chips,
"very_late": self.very_early_late_spacing_chips, "very_late": self.very_early_late_spacing_chips,
} }
values = {name: complex(np.sum(wiped * self._replica(code_phase, offset))) for name, offset in taps.items()} values = {
name: complex(np.sum(wiped * self._replica(code_phase, boc11_phase, boc61_phase, offset)))
for name, offset in taps.items()
}
return CorrelatorOutputs(**values) return CorrelatorOutputs(**values)
+10 -5
View File
@@ -11,6 +11,7 @@ import numpy.typing as npt
from galileo_rx.config import TrackingConfig from galileo_rx.config import TrackingConfig
from galileo_rx.signal_profiles import SignalProfile from galileo_rx.signal_profiles import SignalProfile
from galileo_rx.signals.code_provider import CodeProvider from galileo_rx.signals.code_provider import CodeProvider
from galileo_rx.signals.e1_modulation import E1Modulation
from galileo_rx.types import AcquisitionResult, CorrelatorOutputs, TrackingMeasurement from galileo_rx.types import AcquisitionResult, CorrelatorOutputs, TrackingMeasurement
from .correlator import CorrelatorBank from .correlator import CorrelatorBank
from .nco import CarrierNco, CodeNco from .nco import CarrierNco, CodeNco
@@ -41,6 +42,7 @@ def synchronize_e1c_for_tracking(
*, *,
search_primary_epochs: int = 100, search_primary_epochs: int = 100,
residual_frequency_step_hz: float = 0.5, residual_frequency_step_hz: float = 0.5,
modulation: E1Modulation | str = E1Modulation.BOC11,
) -> E1CSecondarySynchronization: ) -> E1CSecondarySynchronization:
"""Synchronize E1C secondary phase before closing the pilot loops.""" """Synchronize E1C secondary phase before closing the pilot loops."""
if acquisition.component != "E1C": if acquisition.component != "E1C":
@@ -69,7 +71,10 @@ def synchronize_e1c_for_tracking(
(-acquisition.code_phase_chips + start * profile.code_rate_hz / sampling_frequency_hz) (-acquisition.code_phase_chips + start * profile.code_rate_hz / sampling_frequency_hz)
% component.primary_code_length_chips, % component.primary_code_length_chips,
) )
bank = CorrelatorBank(profile, provider, "E1C", acquisition.prn, carrier, code, 0.25, 0.75) bank = CorrelatorBank(
profile, provider, "E1C", acquisition.prn, carrier, code, 0.25, 0.75,
modulation,
)
prompts = np.asarray([ prompts = np.asarray([
bank.correlate( bank.correlate(
np.asarray(samples)[start + index * primary_samples : start + (index + 1) * primary_samples] np.asarray(samples)[start + index * primary_samples : start + (index + 1) * primary_samples]
@@ -130,6 +135,7 @@ class E1CPilotAidedE1TrackingChannel(TrackingChannel):
self.code_nco, self.code_nco,
config.early_late_spacing_chips, config.early_late_spacing_chips,
config.very_early_late_spacing_chips, config.very_early_late_spacing_chips,
config.modulation,
) )
self.last_e1b_correlators = CorrelatorOutputs() self.last_e1b_correlators = CorrelatorOutputs()
@@ -139,13 +145,12 @@ class E1CPilotAidedE1TrackingChannel(TrackingChannel):
secondary_sign: int, secondary_sign: int,
) -> CorrelatorOutputs: ) -> CorrelatorOutputs:
array = np.asarray(samples, dtype=np.complex128) array = np.asarray(samples, dtype=np.complex128)
carrier_phase = self.carrier_nco.phases(array.size) carrier_phase, code_phase, boc11_phase, boc61_phase = self.correlator.phase_vectors(array.size)
code_phase = self.code_nco.phases(array.size)
pilot = self.correlator.correlate_with_phases( pilot = self.correlator.correlate_with_phases(
array, carrier_phase, code_phase, secondary_sign array, carrier_phase, code_phase, secondary_sign, boc11_phase, boc61_phase
) )
self.last_e1b_correlators = self.e1b_correlator.correlate_with_phases( self.last_e1b_correlators = self.e1b_correlator.correlate_with_phases(
array, carrier_phase, code_phase, 1 array, carrier_phase, code_phase, 1, boc11_phase, boc61_phase
) )
return pilot return pilot
@@ -61,6 +61,7 @@ class TrackingChannel:
self.correlator = CorrelatorBank( self.correlator = CorrelatorBank(
profile, provider, acquisition.component, acquisition.prn, self.carrier_nco, self.code_nco, profile, provider, acquisition.component, acquisition.prn, self.carrier_nco, self.code_nco,
config.early_late_spacing_chips, config.very_early_late_spacing_chips, config.early_late_spacing_chips, config.very_early_late_spacing_chips,
config.modulation,
) )
self.pll_filter = ThirdOrderLoopFilter(config.pll_noise_bandwidth_hz, config.damping_ratio) if config.pll_order == 3 else SecondOrderLoopFilter(config.pll_noise_bandwidth_hz, config.damping_ratio) self.pll_filter = ThirdOrderLoopFilter(config.pll_noise_bandwidth_hz, config.damping_ratio) if config.pll_order == 3 else SecondOrderLoopFilter(config.pll_noise_bandwidth_hz, config.damping_ratio)
self.dll_filter = SecondOrderLoopFilter(config.dll_noise_bandwidth_hz, config.damping_ratio) self.dll_filter = SecondOrderLoopFilter(config.dll_noise_bandwidth_hz, config.damping_ratio)
+1
View File
@@ -104,6 +104,7 @@ class SatelliteTruth:
initial_carrier_phase_rad: float initial_carrier_phase_rad: float
amplitude: float amplitude: float
cn0_db_hz: float | None cn0_db_hz: float | None
initial_code_doppler_hz: float = 0.0
@dataclass(slots=True) @dataclass(slots=True)
+160
View File
@@ -0,0 +1,160 @@
"""Full-band CBOC acquisition/tracking evidence at 32.736 Msps."""
from __future__ import annotations
import numpy as np
import pytest
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
def _run_case(transmit_modulation: E1Modulation, receiver_modulation: E1Modulation):
profile = galileo_e1_profile()
provider = SyntheticCodeProvider()
generator = FullBandE1SignalGenerator(
profile,
provider,
FS_HZ,
IF_HZ,
satellites=[
SatelliteSignalConfig(
1,
"E1C",
code_delay_chips=DELAY_CHIPS,
doppler_hz=DOPPLER_HZ,
carrier_phase_rad=0.3,
cn0_db_hz=55.0,
modulation=transmit_modulation,
)
],
channel=ChannelModelConfig(seed=2026),
)
samples, truth = generator.generate(round(0.16 * FS_HZ))
acquisition_config = AcquisitionConfig(
modulation=receiver_modulation,
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", 1, IF_HZ)
synchronization = synchronize_e1c_for_tracking(
samples,
profile,
provider,
FS_HZ,
acquisition,
round(0.008 * FS_HZ),
search_primary_epochs=30,
residual_frequency_step_hz=1.0,
modulation=receiver_modulation,
)
tracking = TrackingChannel(
profile,
provider,
FS_HZ,
TrackingConfig(
modulation=receiver_modulation,
pull_in_duration_ms=20.0,
loss_of_lock_epochs=20,
),
synchronization.acquisition,
synchronization.tracking_start_sample,
synchronization.sync.phase,
)
measurements = tracking.process(samples[synchronization.tracking_start_sample :])
return acquisition, synchronization.sync, measurements, truth
@pytest.mark.parametrize(
("transmit_modulation", "receiver_modulation"),
[
(E1Modulation.BOC11, E1Modulation.BOC11),
(E1Modulation.CBOC, E1Modulation.BOC11),
(E1Modulation.CBOC, E1Modulation.CBOC),
],
)
def test_fullband_modulation_matrix_acquires_and_tracks(
transmit_modulation: E1Modulation,
receiver_modulation: E1Modulation,
) -> None:
acquisition, secondary, measurements, truth = _run_case(
transmit_modulation, receiver_modulation
)
expected = truth.satellites[0]
assert acquisition.detected
assert acquisition.raw_doppler_hz == DOPPLER_HZ
assert abs(acquisition.code_phase_chips - expected.initial_code_delay_chips) <= 1.0 / 32.0
assert secondary.normalized_correlation > 0.98
assert secondary.sign_errors == 0
assert len(measurements) >= 35
assert measurements[-1].carrier_lock
assert measurements[-1].code_lock
assert not any(item.cycle_slip for item in measurements)
assert not any(item.sample_discontinuity for item in measurements)
tail = measurements[-10:]
assert np.sqrt(np.mean([item.pll_error_rad**2 for item in tail])) < 0.12
assert np.sqrt(np.mean([item.dll_error_chips**2 for item in tail])) < 0.12
@pytest.mark.parametrize("intermediate_frequency_hz", [0.0, 2.25e6])
def test_cboc_pcps_carrier_wipeoff_at_zero_and_nonzero_if(
intermediate_frequency_hz: float,
) -> None:
profile = galileo_e1_profile()
provider = SyntheticCodeProvider()
samples, _ = FullBandE1SignalGenerator(
profile,
provider,
FS_HZ,
intermediate_frequency_hz,
satellites=[
SatelliteSignalConfig(
5,
"E1B",
code_delay_chips=19.5,
doppler_hz=-750.0,
modulation=E1Modulation.CBOC,
)
],
channel=ChannelModelConfig(add_awgn=False),
).generate(round(0.008 * FS_HZ))
result, _ = AcquisitionManager(
profile,
provider,
FS_HZ,
AcquisitionConfig(
modulation=E1Modulation.CBOC,
doppler_min_hz=-1000.0,
doppler_max_hz=-500.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,
),
).acquire_one(samples, "E1B", 5, intermediate_frequency_hz)
assert result.detected
assert result.raw_doppler_hz == -750.0
assert abs(result.code_phase_chips - 20.5) <= 1.0 / 32.0
+108
View File
@@ -0,0 +1,108 @@
"""Unit evidence for phase-continuous Galileo E1 CBOC replicas."""
from __future__ import annotations
import numpy as np
from galileo_rx.signals.boc import SquareSubcarrierNco, boc_subcarrier
from galileo_rx.signals.e1_modulation import (
CBOC_ALPHA,
CBOC_BETA,
E1Modulation,
e1_subcarrier_replica,
e1_transmitted_subcarrier_replica,
)
def _phases(sample_count: int, sampling_frequency_hz: float) -> np.ndarray:
return np.arange(sample_count, dtype=np.float64) * 1.023e6 / sampling_frequency_hz
def test_boc11_subcarrier_sequence() -> None:
wave, ending = boc_subcarrier(8, 8.0, 1.0)
np.testing.assert_array_equal(wave, [1.0, 1.0, 1.0, 1.0, -1.0, -1.0, -1.0, -1.0])
assert ending == 0.0
def test_boc61_subcarrier_sequence() -> None:
wave, ending = boc_subcarrier(24, 24.0, 6.0)
np.testing.assert_array_equal(wave, np.tile([1.0, 1.0, -1.0, -1.0], 6))
assert ending == 0.0
def test_subcarrier_phase_continuity_across_arbitrary_chunks() -> None:
fs = 31.25e6 # deliberately nonintegral samples per BOC(6,1) sign interval
total = 10_003
reference = SquareSubcarrierNco(fs, 6.138e6, 0.137).samples(total)
chunked_nco = SquareSubcarrierNco(fs, 6.138e6, 0.137)
chunks = [1, 7, 31, 2, 997, 4093, 3, 4869]
chunked = np.concatenate([chunked_nco.samples(count) for count in chunks])
np.testing.assert_array_equal(chunked, reference)
def test_cboc_power_normalization_and_component_sign() -> None:
phase = _phases(4092 * 32, 32.736e6)
sc11 = e1_subcarrier_replica("E1B", E1Modulation.BOC11, phase)
sc61 = np.where((np.floor(2.0 * np.mod(6.0 * phase, 1.0) + 1e-12).astype(int) & 1) == 0, 1.0, -1.0)
e1b = e1_subcarrier_replica("E1B", E1Modulation.CBOC, phase, 6.0 * phase)
e1c = e1_subcarrier_replica("E1C", E1Modulation.CBOC, phase, 6.0 * phase)
assert abs(float(np.mean(e1b**2)) - 1.0) < 1e-12
assert abs(float(np.mean(e1c**2)) - 1.0) < 1e-12
np.testing.assert_allclose((e1b + e1c) / 2.0, CBOC_ALPHA * sc11, atol=1e-15)
np.testing.assert_allclose((e1b - e1c) / 2.0, CBOC_BETA * sc61, atol=1e-15)
transmitted_e1b = e1_transmitted_subcarrier_replica(
"E1B", E1Modulation.CBOC, phase, 6.0 * phase
)
transmitted_e1c = e1_transmitted_subcarrier_replica(
"E1C", E1Modulation.CBOC, phase, 6.0 * phase
)
# Equation (12): low-rate terms oppose, high-rate terms have equal sign.
np.testing.assert_allclose((transmitted_e1b - transmitted_e1c) / 2.0, CBOC_ALPHA * sc11, atol=1e-15)
np.testing.assert_allclose((transmitted_e1b + transmitted_e1c) / 2.0, CBOC_BETA * sc61, atol=1e-15)
def test_cboc_spectrum_contains_the_boc61_lobe() -> None:
fs = 32.736e6
phase = _phases(4092 * 32, fs)
boc11 = e1_subcarrier_replica("E1B", E1Modulation.BOC11, phase)
cboc = e1_subcarrier_replica("E1B", E1Modulation.CBOC, phase, 6.0 * phase)
frequencies = np.fft.rfftfreq(phase.size, 1.0 / fs)
boc_psd = np.abs(np.fft.rfft(boc11)) ** 2
cboc_psd = np.abs(np.fft.rfft(cboc)) ** 2
high_lobe = (frequencies > 5.8e6) & (frequencies < 6.5e6)
fundamental = (frequencies > 0.8e6) & (frequencies < 1.25e6)
assert cboc_psd[high_lobe].sum() > 1_000.0 * boc_psd[high_lobe].sum()
assert cboc_psd[fundamental].sum() > cboc_psd[high_lobe].sum()
def test_cboc_autocorrelation_side_structure_and_epl_symmetry() -> None:
fs = 32.736e6
samples_per_chip = 32
phase = _phases(4092 * samples_per_chip, fs)
cboc = e1_subcarrier_replica("E1B", E1Modulation.CBOC, phase, 6.0 * phase)
def circular_correlation(offset_samples: int) -> float:
return float(np.mean(cboc * np.roll(cboc, offset_samples)))
assert circular_correlation(0) > 0.999999
assert circular_correlation(samples_per_chip // 2) < -0.80
for offset in (2, 4, 6, 8, 10, 12):
assert abs(circular_correlation(offset) - circular_correlation(-offset)) < 2e-4
# E and L at +/-0.25 chip see equal magnitude at the correct prompt.
assert abs(abs(circular_correlation(8)) - abs(circular_correlation(-8))) < 2e-4
def test_cboc_sample_rate_independence() -> None:
powers = []
quarter_chip_correlations = []
for fs in (32.736e6, 30.0e6, 27.5e6):
count = int(round(4092 * fs / 1.023e6))
phase = _phases(count, fs)
cboc = e1_subcarrier_replica("E1C", E1Modulation.CBOC, phase, 6.0 * phase)
powers.append(float(np.mean(cboc**2)))
quarter_late = e1_subcarrier_replica(
"E1C", E1Modulation.CBOC, phase + 0.25, 6.0 * (phase + 0.25)
)
quarter_chip_correlations.append(float(np.mean(cboc * quarter_late)))
assert max(abs(power - 1.0) for power in powers) < 2e-3
assert np.ptp(quarter_chip_correlations) < 0.04
+72
View File
@@ -0,0 +1,72 @@
from __future__ import annotations
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.e1_modulation import E1Modulation
from galileo_rx.signals.fullband_e1_generator import FullBandE1SignalGenerator
from galileo_rx.signals.signal_generator import ChannelModelConfig, SatelliteSignalConfig
def _generator() -> FullBandE1SignalGenerator:
return FullBandE1SignalGenerator(
galileo_e1_profile(),
SyntheticCodeProvider(),
32.736e6,
2.25e6,
satellites=[
SatelliteSignalConfig(
3,
"E1C",
code_delay_chips=7.25,
doppler_hz=1375.0,
modulation=E1Modulation.CBOC,
)
],
channel=ChannelModelConfig(add_awgn=False),
)
def test_fullband_generator_is_streaming_and_double_precision() -> None:
one_shot, truth = _generator().generate(4099)
chunked_generator = _generator()
chunked = np.concatenate([chunked_generator.generate(count)[0] for count in (1, 17, 1000, 3, 3078)])
assert one_shot.dtype == np.complex128
np.testing.assert_allclose(chunked, one_shot, atol=2e-13, rtol=0.0)
assert truth.satellites[0].initial_code_delay_samples == 7.25 * 32.0 + 32.0
def test_fullband_generator_supports_real_if_quantization() -> None:
generator = FullBandE1SignalGenerator(
galileo_e1_profile(),
SyntheticCodeProvider(),
32.736e6,
4.092e6,
True,
[SatelliteSignalConfig(1, "E1B", modulation=E1Modulation.CBOC)],
ChannelModelConfig(add_awgn=False, quantization_bits=8),
)
samples, _ = generator.generate(2048)
assert samples.dtype == np.float64
assert np.max(samples) <= 1.0 - 1.0 / 128.0
np.testing.assert_allclose(samples * 128.0, np.round(samples * 128.0), atol=1e-12)
def test_fullband_generator_exposes_independent_code_doppler_truth() -> None:
generator = FullBandE1SignalGenerator(
galileo_e1_profile(),
SyntheticCodeProvider(),
satellites=[
SatelliteSignalConfig(
1,
"E1C",
doppler_hz=1000.0,
code_doppler_hz=0.75,
modulation=E1Modulation.CBOC,
)
],
channel=ChannelModelConfig(add_awgn=False),
)
_, truth = generator.generate(128)
assert truth.satellites[0].initial_code_doppler_hz == 0.75
+235
View File
@@ -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())