diff --git a/README.md b/README.md index 329f8e2..ab8996f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ This repository is a modular Python reference model for a Galileo receiver 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 tracking channels, fixed-point boundary models, and hardware sizing models. 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 - 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 for acquisition. E1C tracking jointly searches the 25-symbol phase and 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_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/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 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 @@ -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 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 -five tracked satellites produced complete ephemerides, clocks, healthy status, -and GST; PRN 19 correctly remained incomplete because word 1 was outside the -observation. Post-decode comparison against SignalSim's RINEX source passed at +The post-CBOC 32.2 s raw-IQ SignalSim regression decoded 75/75 CRC-valid pages. +All five tracked satellites produced complete ephemerides, clocks, healthy +status, and GST. Post-decode comparison against SignalSim's RINEX source passed at the broadcast quantization resolution. No simulator truth is accepted by the normal decoder API. diff --git a/configs/acquisition_example.yaml b/configs/acquisition_example.yaml index ef94b06..0f715c4 100644 --- a/configs/acquisition_example.yaml +++ b/configs/acquisition_example.yaml @@ -9,6 +9,7 @@ rf: sample_bit_width: 8 sample_endianness: little acquisition: + modulation: BOC11 doppler_min_hz: -5000.0 doppler_max_hz: 5000.0 doppler_step_hz: 250.0 @@ -18,6 +19,7 @@ acquisition: peak_exclusion_chips: 1.0 zero_padding_mode: none tracking: + modulation: BOC11 integration_ms: 4.0 early_late_spacing_chips: 0.25 very_early_late_spacing_chips: 0.75 diff --git a/configs/e1_cboc.yaml b/configs/e1_cboc.yaml new file mode 100644 index 0000000..fb9f622 --- /dev/null +++ b/configs/e1_cboc.yaml @@ -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 diff --git a/configs/e1_default.yaml b/configs/e1_default.yaml index 15c4fd5..7fdf34a 100644 --- a/configs/e1_default.yaml +++ b/configs/e1_default.yaml @@ -9,6 +9,7 @@ rf: sample_bit_width: 8 sample_endianness: little acquisition: + modulation: BOC11 doppler_min_hz: -10000.0 doppler_max_hz: 10000.0 doppler_step_hz: 250.0 @@ -19,6 +20,7 @@ acquisition: fft_length: null zero_padding_mode: next_power_of_two tracking: + modulation: BOC11 integration_ms: 4.0 early_late_spacing_chips: 0.25 very_early_late_spacing_chips: 0.75 diff --git a/configs/tracking_example.yaml b/configs/tracking_example.yaml index 6b1cfce..82bc307 100644 --- a/configs/tracking_example.yaml +++ b/configs/tracking_example.yaml @@ -9,6 +9,7 @@ rf: sample_bit_width: 8 sample_endianness: little tracking: + modulation: BOC11 integration_ms: 4.0 early_late_spacing_chips: 0.25 very_early_late_spacing_chips: 0.75 diff --git a/docs/cboc_support.md b/docs/cboc_support.md new file mode 100644 index 0000000..abbb2c6 --- /dev/null +++ b/docs/cboc_support.md @@ -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. diff --git a/docs/configuration.md b/docs/configuration.md index 07af35d..b932b5e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -25,10 +25,17 @@ Default YAML files: - `e1_default.yaml`: requested 16.368 MHz nominal defaults. - `acquisition_example.yaml`: faster 4.092 MHz executable search. - `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 `tracking.architecture` is `e1c_pilot_aided_e1b`; the CLI can select `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 25 noncoherent power epochs, three confirmations, and the configured `loss_of_lock_epochs` for sustained failure. Carrier and BOC central/outer diff --git a/reports/cboc_validation_report.json b/reports/cboc_validation_report.json new file mode 100644 index 0000000..3bce2f0 --- /dev/null +++ b/reports/cboc_validation_report.json @@ -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 + } + ] +} diff --git a/reports/cboc_validation_report.md b/reports/cboc_validation_report.md new file mode 100644 index 0000000..9cabffe --- /dev/null +++ b/reports/cboc_validation_report.md @@ -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. diff --git a/reports/navigation_run/inav_collection_report.json b/reports/navigation_run/inav_collection_report.json index 74779a0..e8a9759 100644 --- a/reports/navigation_run/inav_collection_report.json +++ b/reports/navigation_run/inav_collection_report.json @@ -1,5 +1,6 @@ { "decoder_truth_inputs": [], + "tracking_architecture": "e1c_pilot_aided_e1b", "icd": "Galileo OS SIS ICD Issue 2.2, November 2025", "signalsim_commit": "80b262113e77c4482ca9f5cbfef9bcada402ed28", "sample_file": "/home/onur/Desktop/galileo/verification/vectors/signalsim/navigation_multisat/navigation_multisat_iq8.bin", @@ -10,17 +11,24 @@ "satellites": { "1": { "prn": 1, + "tracking_architecture": "e1c_pilot_aided_e1b", + "acquisition_component": "E1C", "acquisition_detected": true, - "acquisition_time_s": 0.1004119270000956, + "acquisition_time_s": 0.10627389200089965, "raw_doppler_hz": 1750.0, "expected_doppler_hz": 1828.0, "start_sample_timestamp": 90995, "tracking_epoch_count": 16088, - "tracking_time_s": 13.650105177999649, + "tracking_time_s": 30.29040445300052, "tracking_final_state": "PLL_TRACK", - "tracking_reacquisition_requests": 10, - "tracking_lock_timestamp": 107363, + "tracking_reacquisition_requests": 0, + "tracking_lock_timestamp": 311963, "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_page_sync_timestamp": 4428515, "first_crc_valid_page_timestamp": 4428515, @@ -44,27 +52,151 @@ "clock_parameters": true, "health_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": { "prn": 4, + "tracking_architecture": "e1c_pilot_aided_e1b", + "acquisition_component": "E1C", "acquisition_detected": true, - "acquisition_time_s": 0.08580062900000485, + "acquisition_time_s": 0.09288738900067983, "raw_doppler_hz": 2250.0, "expected_doppler_hz": 2171.0, "start_sample_timestamp": 86819, "tracking_epoch_count": 16088, - "tracking_time_s": 13.877967087995785, + "tracking_time_s": 30.40403210999648, "tracking_final_state": "PLL_TRACK", - "tracking_reacquisition_requests": 61, - "tracking_lock_timestamp": 103187, + "tracking_reacquisition_requests": 0, + "tracking_lock_timestamp": 324155, "prompt_epoch_count": 8044, - "first_synchronized_symbol_timestamp": 12624707, - "first_page_sync_timestamp": 12624707, - "first_crc_valid_page_timestamp": 12624707, - "page_parts": 29, - "pages": 14, - "crc_passes": 14, + "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": 4440707, + "first_page_sync_timestamp": 4440707, + "first_crc_valid_page_timestamp": 4440707, + "page_parts": 31, + "pages": 15, + "crc_passes": 15, "crc_failures": 0, "fec_failures": 0, "received_word_types": [ @@ -82,66 +214,313 @@ "clock_parameters": true, "health_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": { "prn": 19, + "tracking_architecture": "e1c_pilot_aided_e1b", + "acquisition_component": "E1C", "acquisition_detected": true, - "acquisition_time_s": 0.0836479790013982, + "acquisition_time_s": 0.09294268899975577, "raw_doppler_hz": -250.0, "expected_doppler_hz": -367.0, "start_sample_timestamp": 87566, "tracking_epoch_count": 16088, - "tracking_time_s": 13.9836710640011, + "tracking_time_s": 30.35874111299927, "tracking_final_state": "PLL_TRACK", - "tracking_reacquisition_requests": 406, - "tracking_lock_timestamp": 103934, + "tracking_reacquisition_requests": 0, + "tracking_lock_timestamp": 734102, "prompt_epoch_count": 8044, - "first_synchronized_symbol_timestamp": 16684718, - "first_page_sync_timestamp": 16684718, - "first_crc_valid_page_timestamp": 20776718, - "page_parts": 28, - "pages": 13, - "crc_passes": 13, + "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": 4408718, + "first_page_sync_timestamp": 4408718, + "first_crc_valid_page_timestamp": 4408718, + "page_parts": 31, + "pages": 15, + "crc_passes": 15, "crc_failures": 0, "fec_failures": 0, "received_word_types": [ + 1, 2, 3, 4, 5, 6 ], - "missing_ephemeris_word_types": [ - 1 - ], - "complete_ephemeris": false, - "ephemeris_iod_nav": null, - "first_complete_ephemeris_timestamp": null, + "missing_ephemeris_word_types": [], + "complete_ephemeris": true, + "ephemeris_iod_nav": 29, + "first_complete_ephemeris_timestamp": 61696718, "clock_parameters": true, - "health_valid": false, - "data_valid": false, - "decoding_time_s": 1.2081766550036264 + "health_valid": true, + "data_valid": true, + "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": { "prn": 21, + "tracking_architecture": "e1c_pilot_aided_e1b", + "acquisition_component": "E1C", "acquisition_detected": true, - "acquisition_time_s": 0.08741291100159287, + "acquisition_time_s": 0.08547867099696305, "raw_doppler_hz": -750.0, "expected_doppler_hz": -712.0, "start_sample_timestamp": 93628, "tracking_epoch_count": 16088, - "tracking_time_s": 13.982213923998643, + "tracking_time_s": 30.185307627005386, "tracking_final_state": "PLL_TRACK", - "tracking_reacquisition_requests": 259, - "tracking_lock_timestamp": 101812, + "tracking_reacquisition_requests": 0, + "tracking_lock_timestamp": 339148, "prompt_epoch_count": 8044, - "first_synchronized_symbol_timestamp": 12598780, - "first_page_sync_timestamp": 12598780, - "first_crc_valid_page_timestamp": 12598780, - "page_parts": 29, - "pages": 14, - "crc_passes": 14, + "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": 4414780, + "first_page_sync_timestamp": 4414780, + "first_crc_valid_page_timestamp": 4414780, + "page_parts": 31, + "pages": 15, + "crc_passes": 15, "crc_failures": 0, "fec_failures": 0, "received_word_types": [ @@ -159,27 +538,151 @@ "clock_parameters": true, "health_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": { "prn": 27, + "tracking_architecture": "e1c_pilot_aided_e1b", + "acquisition_component": "E1C", "acquisition_detected": true, - "acquisition_time_s": 0.08157938700605882, + "acquisition_time_s": 0.09328085999732139, "raw_doppler_hz": -2750.0, "expected_doppler_hz": -2637.0, "start_sample_timestamp": 88014, "tracking_epoch_count": 16088, - "tracking_time_s": 13.978430488001322, + "tracking_time_s": 30.066800435000914, "tracking_final_state": "PLL_TRACK", "tracking_reacquisition_requests": 0, - "tracking_lock_timestamp": 96198, + "tracking_lock_timestamp": 1381086, "prompt_epoch_count": 8044, - "first_synchronized_symbol_timestamp": 12642270, - "first_page_sync_timestamp": 12642270, - "first_crc_valid_page_timestamp": 12642270, - "page_parts": 29, - "pages": 14, - "crc_passes": 14, + "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": 4458270, + "first_page_sync_timestamp": 4458270, + "first_crc_valid_page_timestamp": 4458270, + "page_parts": 31, + "pages": 15, + "crc_passes": 15, "crc_failures": 0, "fec_failures": 0, "received_word_types": [ @@ -197,7 +700,124 @@ "clock_parameters": true, "health_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": { diff --git a/reports/navigation_run/inav_collection_report.md b/reports/navigation_run/inav_collection_report.md index 2c06b73..4e7e648 100644 --- a/reports/navigation_run/inav_collection_report.md +++ b/reports/navigation_run/inav_collection_report.md @@ -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 | | --: | -------: | ----------: | --------: | --------: | -----------------: | ----: | ---: | ----: | | 1 | PLL_TRACK | True | 31 | 15 | True | True | True | True | -| 4 | PLL_TRACK | True | 29 | 14 | True | True | True | True | -| 19 | PLL_TRACK | True | 28 | 13 | False | True | True | False | -| 21 | PLL_TRACK | True | 29 | 14 | True | True | True | True | -| 27 | PLL_TRACK | True | 29 | 14 | True | True | True | True | +| 4 | PLL_TRACK | True | 31 | 15 | True | True | True | True | +| 19 | PLL_TRACK | True | 31 | 15 | True | True | True | True | +| 21 | PLL_TRACK | True | 31 | 15 | True | True | True | True | +| 27 | PLL_TRACK | True | 31 | 15 | True | True | True | True | diff --git a/src/galileo_rx/acquisition/fft_pcps.py b/src/galileo_rx/acquisition/fft_pcps.py index 2342d47..5d0f264 100644 --- a/src/galileo_rx/acquisition/fft_pcps.py +++ b/src/galileo_rx/acquisition/fft_pcps.py @@ -11,6 +11,7 @@ from galileo_rx.config import AcquisitionConfig from galileo_rx.signal_profiles import SignalProfile from galileo_rx.signals.code_provider import CodeProvider from galileo_rx.signals.galileo_e1 import e1_local_replica +from galileo_rx.signals.e1_modulation import E1Modulation, normalize_e1_modulation @dataclass(slots=True) @@ -29,7 +30,8 @@ class PcpsAcquisition: self.sampling_frequency_hz = sampling_frequency_hz self.config = config 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 def coherent_samples(self) -> int: @@ -52,11 +54,12 @@ class PcpsAcquisition: effective_code_rate_hz = self.profile.code_rate_hz if self.config.code_doppler_aiding: 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: replica = e1_local_replica( self.profile, self.provider, component, prn, sample_count, self.sampling_frequency_hz, code_rate_hz=effective_code_rate_hz, + modulation=self.modulation, ) if primary_signs != (1,): primary_samples = int(round(self.profile.component(component).primary_code_period_s * self.sampling_frequency_hz)) diff --git a/src/galileo_rx/config.py b/src/galileo_rx/config.py index f38138d..92fa4e9 100644 --- a/src/galileo_rx/config.py +++ b/src/galileo_rx/config.py @@ -8,6 +8,8 @@ from typing import Any, Literal, TypeVar import yaml +from galileo_rx.signals.e1_modulation import E1Component, E1Modulation, normalize_e1_component, normalize_e1_modulation + T = TypeVar("T") @@ -31,6 +33,7 @@ class RfConfig: @dataclass(slots=True) class AcquisitionConfig: + modulation: E1Modulation | str = E1Modulation.BOC11 doppler_min_hz: float = -10_000.0 doppler_max_hz: float = 10_000.0 doppler_step_hz: float = 250.0 @@ -51,6 +54,7 @@ class AcquisitionConfig: code_doppler_aiding: bool = True def validate(self) -> None: + normalize_e1_modulation(self.modulation) if self.doppler_max_hz < self.doppler_min_hz or self.doppler_step_hz <= 0: raise ValueError("invalid Doppler search range or step") if self.coherent_integration_ms <= 0 or self.noncoherent_accumulations < 1: @@ -63,6 +67,7 @@ class AcquisitionConfig: @dataclass(slots=True) class TrackingConfig: + modulation: E1Modulation | str = E1Modulation.BOC11 integration_ms: float = 4.0 early_late_spacing_chips: float = 0.25 very_early_late_spacing_chips: float = 0.75 @@ -92,6 +97,7 @@ class TrackingConfig: lock_signal_loss_power_ratio: float = 0.02 def validate(self) -> None: + normalize_e1_modulation(self.modulation) if self.integration_ms <= 0: raise ValueError("tracking integration_ms must be positive") if not 0 < self.early_late_spacing_chips < self.very_early_late_spacing_chips: @@ -132,7 +138,7 @@ class FixedPointConfig: @dataclass(slots=True) class ReceiverConfig: signal_profile: str = "GALILEO_E1" - component: Literal["E1B", "E1C"] = "E1C" + component: E1Component | str = E1Component.E1C prns: list[int] = field(default_factory=lambda: [1]) rf: RfConfig = field(default_factory=RfConfig) acquisition: AcquisitionConfig = field(default_factory=AcquisitionConfig) @@ -141,18 +147,19 @@ class ReceiverConfig: fixed_point: FixedPointConfig = field(default_factory=FixedPointConfig) def validate(self) -> None: + component = normalize_e1_component(self.component) self.rf.validate() self.acquisition.validate() self.tracking.validate() if not self.prns or any(prn <= 0 for prn in self.prns): raise ValueError("prns must contain positive identifiers") 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") - 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") 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") if self.tracking.secondary_code_mode == "none": raise ValueError("E1C tracking beyond 4 ms requires secondary-code handling") diff --git a/src/galileo_rx/signal_profiles.py b/src/galileo_rx/signal_profiles.py index af6b561..f127d5a 100644 --- a/src/galileo_rx/signal_profiles.py +++ b/src/galileo_rx/signal_profiles.py @@ -24,6 +24,7 @@ class SignalProfile: modulation: str boc_subcarrier_rate_hz: float components: dict[str, ComponentProfile] + supported_modulations: tuple[str, ...] = ("BOC11",) def component(self, name: str) -> ComponentProfile: try: @@ -44,6 +45,7 @@ def galileo_e1_profile() -> SignalProfile: "E1B": ComponentProfile("E1B", "data", 4092, 0.004, None, 0.004), "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]() except KeyError as exc: raise ValueError(f"unknown signal profile {name!r}; available: {sorted(PROFILES)}") from exc - diff --git a/src/galileo_rx/signals/__init__.py b/src/galileo_rx/signals/__init__.py index 375b634..29e42ae 100644 --- a/src/galileo_rx/signals/__init__.py +++ b/src/galileo_rx/signals/__init__.py @@ -1,7 +1,18 @@ """Signal code, modulation, and generation.""" 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 -__all__ = ["CodeProvider", "FileCodeProvider", "SyntheticCodeProvider", "SatelliteSignalConfig", "SyntheticSignalGenerator"] - +__all__ = [ + "CodeProvider", + "E1Component", + "E1Modulation", + "FileCodeProvider", + "FullBandE1SignalGenerator", + "FullBandFrontendConfig", + "SatelliteSignalConfig", + "SyntheticCodeProvider", + "SyntheticSignalGenerator", +] diff --git a/src/galileo_rx/signals/boc.py b/src/galileo_rx/signals/boc.py index 49f40c7..c12f114 100644 --- a/src/galileo_rx/signals/boc.py +++ b/src/galileo_rx/signals/boc.py @@ -2,10 +2,55 @@ from __future__ import annotations +from dataclasses import dataclass + import numpy as np 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( sample_count: int, 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: raise ValueError("invalid BOC generator parameters") 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) 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: raise ValueError("invalid code sampling parameters") 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) return values, ending_phase - diff --git a/src/galileo_rx/signals/e1_modulation.py b/src/galileo_rx/signals/e1_modulation.py new file mode 100644 index 0000000..1644819 --- /dev/null +++ b/src/galileo_rx/signals/e1_modulation.py @@ -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) diff --git a/src/galileo_rx/signals/fullband_e1_generator.py b/src/galileo_rx/signals/fullband_e1_generator.py new file mode 100644 index 0000000..aac7064 --- /dev/null +++ b/src/galileo_rx/signals/fullband_e1_generator.py @@ -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 diff --git a/src/galileo_rx/signals/galileo_e1.py b/src/galileo_rx/signals/galileo_e1.py index 9f829fb..eb0e94c 100644 --- a/src/galileo_rx/signals/galileo_e1.py +++ b/src/galileo_rx/signals/galileo_e1.py @@ -6,8 +6,9 @@ import numpy as np import numpy.typing as npt 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 .e1_modulation import E1Modulation, e1_subcarrier_replica def e1_local_replica( @@ -20,12 +21,19 @@ def e1_local_replica( initial_code_phase_chips: float = 0.0, boc_enabled: bool = True, code_rate_hz: float | None = None, + modulation: E1Modulation | str = E1Modulation.BOC11, ) -> npt.NDArray[np.float64]: 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 code, _ = sample_primary_code(primary, sample_count, sampling_frequency_hz, effective_code_rate_hz, initial_code_phase_chips) if not boc_enabled: return code - rate_scale = effective_code_rate_hz / profile.code_rate_hz - subcarrier, _ = boc_subcarrier(sample_count, sampling_frequency_hz, profile.boc_subcarrier_rate_hz * rate_scale, initial_code_phase_chips % 1.0) + sample_indices = np.arange(sample_count, dtype=np.float64) + 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 diff --git a/src/galileo_rx/signals/signal_generator.py b/src/galileo_rx/signals/signal_generator.py index 35180da..6c5f5b3 100644 --- a/src/galileo_rx/signals/signal_generator.py +++ b/src/galileo_rx/signals/signal_generator.py @@ -11,6 +11,11 @@ import numpy.typing as npt from galileo_rx.signal_profiles import SignalProfile from galileo_rx.types import GenerationTruth, SatelliteTruth from .code_provider import CodeProvider +from .e1_modulation import ( + E1Modulation, + e1_transmitted_subcarrier_replica, + normalize_e1_modulation, +) @dataclass(slots=True) @@ -26,6 +31,8 @@ class SatelliteSignalConfig: navigation_bits: list[int] = field(default_factory=lambda: [1]) navigation_bit_period_s: float = 0.004 secondary_code_phase: int = 0 + modulation: E1Modulation | str = E1Modulation.BOC11 + code_doppler_hz: float | None = None def validate(self, profile: SignalProfile) -> None: profile.component(self.component) @@ -33,6 +40,7 @@ class SatelliteSignalConfig: raise ValueError("PRN must be positive and amplitude nonnegative") if not self.navigation_bits or not set(self.navigation_bits) <= {-1, 1}: raise ValueError("navigation_bits must be a nonempty +/-1 list") + normalize_e1_modulation(self.modulation) @dataclass(slots=True) @@ -99,19 +107,28 @@ class SyntheticSignalGenerator: for satellite in self.satellites: primary = self.code_provider.primary_code(self.profile, satellite.component, satellite.prn) - # Common line-of-sight dynamics scale carrier and spreading-code - # frequencies by the same fractional Doppler. Keeping this coupling - # makes carrier-aided code tracking physically self-consistent. + # Common line-of-sight dynamics normally scale carrier and code by + # the same fraction. An explicit code_doppler_hz overrides that + # 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 = ( 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 - 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) - subcarrier_phase = code_phase_chips - subcarrier = np.where(np.mod(subcarrier_phase, 1.0) < 0.5, 1.0, -1.0) + subcarrier = e1_transmitted_subcarrier_replica( + 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) if component_spec.channel_type == "data": @@ -142,6 +159,7 @@ class SyntheticSignalGenerator: initial_carrier_phase_rad=satellite.carrier_phase_rad, amplitude=satellite.amplitude, cn0_db_hz=satellite.cn0_db_hz, + initial_code_doppler_hz=code_doppler_hz, ) ) diff --git a/src/galileo_rx/tracking/correlator.py b/src/galileo_rx/tracking/correlator.py index 6a2def8..5835675 100644 --- a/src/galileo_rx/tracking/correlator.py +++ b/src/galileo_rx/tracking/correlator.py @@ -8,7 +8,9 @@ import numpy as np import numpy.typing as npt 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.e1_modulation import E1Modulation, e1_subcarrier_replica, normalize_e1_modulation from galileo_rx.types import CorrelatorOutputs from .nco import CarrierNco, CodeNco @@ -23,22 +25,74 @@ class CorrelatorBank: code_nco: CodeNco 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) + _boc11_nco: SquareSubcarrierNco = field(init=False, repr=False) + _boc61_nco: SquareSubcarrierNco = field(init=False, repr=False) def __post_init__(self) -> None: 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 - code = self._code[np.floor(tap_phase).astype(np.int64) % self._code.size] - boc = np.where(np.mod(tap_phase, 1.0) < 0.5, 1.0, -1.0) - return code * boc + code = self._code[np.floor(tap_phase + 1e-12).astype(np.int64) % self._code.size] + subcarrier = e1_subcarrier_replica( + 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: array = np.asarray(samples, dtype=np.complex128) - carrier_phase = self.carrier_nco.phases(array.size) - code_phase = self.code_nco.phases(array.size) - return self.correlate_with_phases(array, carrier_phase, code_phase, secondary_sign) + carrier_phase, code_phase, boc11_phase, boc61_phase = self.phase_vectors(array.size) + return self.correlate_with_phases( + array, + carrier_phase, + code_phase, + secondary_sign, + boc11_phase, + boc61_phase, + ) def correlate_with_phases( self, @@ -46,6 +100,8 @@ class CorrelatorBank: carrier_phase_rad: npt.NDArray[np.float64], code_phase_chips: npt.NDArray[np.float64], secondary_sign: int = 1, + boc11_phase_cycles: npt.NDArray[np.float64] | None = None, + boc61_phase_cycles: npt.NDArray[np.float64] | None = None, ) -> CorrelatorOutputs: """Correlate using shared phases without advancing either NCO. @@ -58,6 +114,16 @@ class CorrelatorBank: code_phase = np.asarray(code_phase_chips, dtype=np.float64) 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") + 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 taps = { "very_early": -self.very_early_late_spacing_chips, @@ -66,7 +132,10 @@ class CorrelatorBank: "late": self.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) diff --git a/src/galileo_rx/tracking/pilot_aided_e1.py b/src/galileo_rx/tracking/pilot_aided_e1.py index b340328..bd97ede 100644 --- a/src/galileo_rx/tracking/pilot_aided_e1.py +++ b/src/galileo_rx/tracking/pilot_aided_e1.py @@ -11,6 +11,7 @@ import numpy.typing as npt from galileo_rx.config import TrackingConfig from galileo_rx.signal_profiles import SignalProfile 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 .correlator import CorrelatorBank from .nco import CarrierNco, CodeNco @@ -41,6 +42,7 @@ def synchronize_e1c_for_tracking( *, search_primary_epochs: int = 100, residual_frequency_step_hz: float = 0.5, + modulation: E1Modulation | str = E1Modulation.BOC11, ) -> E1CSecondarySynchronization: """Synchronize E1C secondary phase before closing the pilot loops.""" 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) % 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([ bank.correlate( np.asarray(samples)[start + index * primary_samples : start + (index + 1) * primary_samples] @@ -130,6 +135,7 @@ class E1CPilotAidedE1TrackingChannel(TrackingChannel): self.code_nco, config.early_late_spacing_chips, config.very_early_late_spacing_chips, + config.modulation, ) self.last_e1b_correlators = CorrelatorOutputs() @@ -139,13 +145,12 @@ class E1CPilotAidedE1TrackingChannel(TrackingChannel): secondary_sign: int, ) -> CorrelatorOutputs: array = np.asarray(samples, dtype=np.complex128) - carrier_phase = self.carrier_nco.phases(array.size) - code_phase = self.code_nco.phases(array.size) + carrier_phase, code_phase, boc11_phase, boc61_phase = self.correlator.phase_vectors(array.size) 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( - array, carrier_phase, code_phase, 1 + array, carrier_phase, code_phase, 1, boc11_phase, boc61_phase ) return pilot diff --git a/src/galileo_rx/tracking/tracking_channel.py b/src/galileo_rx/tracking/tracking_channel.py index 2e82849..f44a1c6 100644 --- a/src/galileo_rx/tracking/tracking_channel.py +++ b/src/galileo_rx/tracking/tracking_channel.py @@ -61,6 +61,7 @@ class TrackingChannel: self.correlator = CorrelatorBank( profile, provider, acquisition.component, acquisition.prn, self.carrier_nco, self.code_nco, 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.dll_filter = SecondOrderLoopFilter(config.dll_noise_bandwidth_hz, config.damping_ratio) diff --git a/src/galileo_rx/types.py b/src/galileo_rx/types.py index 9b2741b..539bc03 100644 --- a/src/galileo_rx/types.py +++ b/src/galileo_rx/types.py @@ -104,6 +104,7 @@ class SatelliteTruth: initial_carrier_phase_rad: float amplitude: float cn0_db_hz: float | None + initial_code_doppler_hz: float = 0.0 @dataclass(slots=True) diff --git a/tests/integration/test_cboc_end_to_end.py b/tests/integration/test_cboc_end_to_end.py new file mode 100644 index 0000000..d9c4fd7 --- /dev/null +++ b/tests/integration/test_cboc_end_to_end.py @@ -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 diff --git a/tests/unit/test_e1_cboc.py b/tests/unit/test_e1_cboc.py new file mode 100644 index 0000000..5eabff1 --- /dev/null +++ b/tests/unit/test_e1_cboc.py @@ -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 diff --git a/tests/unit/test_fullband_e1_generator.py b/tests/unit/test_fullband_e1_generator.py new file mode 100644 index 0000000..ceb89f6 --- /dev/null +++ b/tests/unit/test_fullband_e1_generator.py @@ -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 diff --git a/tools/run_cboc_validation.py b/tools/run_cboc_validation.py new file mode 100644 index 0000000..8cb1f0e --- /dev/null +++ b/tools/run_cboc_validation.py @@ -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())