commit 2a6ccfc23db493c26653a742a9896f07dc1a0174 Author: onur Date: Thu Jul 30 23:26:03 2026 +0300 Initial Galileo receiver and SignalSim integration diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3bbd880 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +.coverage +htmlcov/ +build/ +*.bin +*.csv +*.npz +*.hex +e1_synthetic.c128 + +# External simulator checkout discovered locally; rebuild/integration scripts +# consume it by path, but it is maintained as a separate Git repository. +/signalsim/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..a06f635 --- /dev/null +++ b/README.md @@ -0,0 +1,126 @@ +# Galileo receiver baseband model + +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, +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; +single-precision formats are accepted only when explicitly reading legacy files. + +The repository intentionally contains **no fabricated Galileo PRN sequences**. +`SyntheticCodeProvider` creates deterministic non-ICD codes for closed-loop +verification. These codes cannot receive a live Galileo signal. Validated +official tables can be installed under `data/` and loaded through +`FileCodeProvider` without changing acquisition or tracking APIs. + +An external `globsky/SignalSim` checkout is now validated as a second code and +sample source. `SignalSimCodeProvider` parses that checkout at run time without +copying its tables. The installed commit generates combined E1B/E1C BOC(1,1) +complex IQ; both acquisition and tracking pass on its IQ8 output. See the +[measured SignalSim integration report](docs/signalsim_integration.md). + +## 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. +- E1B bit-transition hypotheses and E1C secondary wipeoff/search are available + for acquisition. E1C tracking supports known-phase wipeoff. +- The code-table provider, not the DSP engine, determines whether codes are + synthetic or official. +- The floating-point model is executable. The fixed PCPS model is bit-true at + I/O, FFT output, product, power, and accumulator boundaries; an FFT + vendor/butterfly schedule must be selected before claiming bit identity with + a particular RTL FFT core. +- Channel impairments include Doppler rate, sampling-clock offset, carrier + offset, AWGN, clipping, and quantization. Multipath, jerk, oscillator phase + noise, and interference are future channel-model plug-ins. +- Combined E1B/E1C tracking is represented by separate manager channels; a + weighted combining policy is intentionally not assumed. + +## Install and test + +Python 3.11 or newer is required. + +```bash +python -m pip install -e '.[test]' +python -m pytest +``` + +All automated tests use deterministic seeds and run headlessly. Plots import +Matplotlib only when explicitly requested. + +## Configuration and CLI + +YAML keys map to strict dataclasses in `galileo_rx.config`; unknown keys and +unsafe long integrations raise descriptive errors. Physical quantities carry +unit suffixes. See [configuration details](docs/configuration.md). + +```bash +python -m galileo_rx.cli generate --config configs/acquisition_example.yaml --output samples.bin +python -m galileo_rx.cli acquire --config configs/acquisition_example.yaml --input samples.bin --output acquisition.json +python -m galileo_rx.cli track --config configs/tracking_example.yaml --input samples.bin --acquisition-result acquisition.json --output tracking.csv +python -m galileo_rx.cli run --config configs/acquisition_example.yaml --input samples.bin +python -m galileo_rx.cli compare-fixed --config configs/acquisition_example.yaml --input samples.bin +python -m galileo_rx.cli validate-signalsim --signalsim-path signalsim/SignalSim --config configs/signalsim_validation.yaml --output-dir reports/signalsim_validation +``` + +The CLI uses synthetic codes by design. Replace the provider with +`FileCodeProvider` after installing independently validated tables for live +signal work. + +`validate-signalsim` is the exception: it explicitly uses the detected external +SignalSim table and IQ conventions and writes quantitative JSON/Markdown/plot +artifacts. Its quick regression is: + +```bash +MPLBACKEND=Agg pytest -m signalsim -v +``` + +Runnable source examples are in `examples/`: + +```bash +python examples/generate_e1_signal.py +python examples/run_acquisition.py +python examples/run_tracking.py +python examples/run_complete_receiver.py +``` + +## Architecture + +The generic acquisition and tracking engines depend on `SignalProfile` and +`CodeProvider`, not E1 constants. Adding E5a/E5b therefore means defining a +profile, modulation replica, and validated code provider; engine interfaces do +not change. + +```text +sample source -> streaming front end -> sample buffer -> PCPS acquisition + | | + +---- tracking manager + +-- E1B channel + +-- E1C channel + +float blocks <-> fixed block boundaries <-> cycle transactions <-> RTL +``` + +The floating algorithms and equations are in [algorithms.md](docs/algorithms.md), +fixed-point assumptions and starting widths in +[fixed_point_plan.md](docs/fixed_point_plan.md), and the two acquisition plus two +tracking hardware options in [rtl_mapping.md](docs/rtl_mapping.md). The +module-level contract is duplicated close to the code in +`src/galileo_rx/rtl_model/architecture.md`. + +Exact phase-gate test and example commands are recorded in +[development_phases.md](docs/development_phases.md). + +## Phase completion record + +1. Profiles, typed configuration, code providers, BOC, generator, and I/O. +2. Streaming front end, FFT PCPS, detector metrics, long integration, plots. +3. Stateful EPL/VEVL tracking, FLL/PLL/DLL, locks, C/N0. +4. Multi-channel manager and separate E1B/E1C handling. +5. Fixed formats, NCO, mixer/correlator, PCPS boundary model, comparison/sweep. +6. Cycle transaction types, scheduler/resource equations, RTL documentation. + +Assumptions and unresolved implementation choices are called out in the linked +documents rather than hidden in constants. diff --git a/configs/acquisition_example.yaml b/configs/acquisition_example.yaml new file mode 100644 index 0000000..ef94b06 --- /dev/null +++ b/configs/acquisition_example.yaml @@ -0,0 +1,31 @@ +signal_profile: GALILEO_E1 +component: E1C +prns: [1, 2, 3] +rf: + sample_format: complex128 + input_is_real_if: false + sampling_frequency_hz: 4092000.0 + intermediate_frequency_hz: 0.0 + sample_bit_width: 8 + sample_endianness: little +acquisition: + doppler_min_hz: -5000.0 + doppler_max_hz: 5000.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: + 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 new file mode 100644 index 0000000..15c4fd5 --- /dev/null +++ b/configs/e1_default.yaml @@ -0,0 +1,32 @@ +signal_profile: GALILEO_E1 +component: E1C +prns: [1] +rf: + sample_format: complex128 + input_is_real_if: false + sampling_frequency_hz: 16368000.0 + intermediate_frequency_hz: 4092000.0 + sample_bit_width: 8 + sample_endianness: little +acquisition: + doppler_min_hz: -10000.0 + doppler_max_hz: 10000.0 + doppler_step_hz: 250.0 + coherent_integration_ms: 4.0 + noncoherent_accumulations: 5 + detection_threshold: 2.5 + peak_exclusion_chips: 1.0 + fft_length: null + zero_padding_mode: next_power_of_two +tracking: + 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: 500.0 + secondary_code_mode: wipeoff diff --git a/configs/signalsim_detected_format.yaml b/configs/signalsim_detected_format.yaml new file mode 100644 index 0000000..d417c9c --- /dev/null +++ b/configs/signalsim_detected_format.yaml @@ -0,0 +1,44 @@ +simulator: + name: SignalSim + repository: https://github.com/globsky/SignalSim + commit: 80b262113e77c4482ca9f5cbfef9bcada402ed28 + +signal: + constellation: Galileo + band: E1 + components: [E1B, E1C] + component_selection: combined_only + modulation: BOC(1,1)_approximation_of_E1_OS_CBOC + data_pilot_amplitude: 0.7071067811865476 + primary_code_length_chips: 4092 + primary_code_rate_hz: 1023000 + primary_code_period_ms: 4 + navigation_data_present: true + navigation_data_layer: complete_INAV_symbols_with_CRC_convolutional_encoding_and_interleaving + secondary_code_present: true + secondary_code_length_symbols: 25 + +output: + representation: complex_iq + real_if_supported: false + sampling_frequency_hz: 8184000 + center_frequency_hz: 1573374000 + intermediate_frequency_hz: 2046000 + sample_type: int8 + channels: 1 + scalar_components_per_sample: 2 + byte_order: not_applicable_one_byte_scalars + iq_order: IQ + scaling: truncation_toward_zero_of_25_times_AGC_gain_times_complex_amplitude + saturation: [-128, 127] + supported_native_formats: [IQ16, IQ8, IQ4, IQ2] + iq2_status: broken_at_detected_commit_quantizer_does_not_store_output_byte + +conventions: + prn_offset: 0 + code_phase_offset_chips: 0.0 + table_bit_order: MSB_first_1023_bits_per_1024_bit_sector + table_bit_mapping: 0_to_plus_1_1_to_minus_1 + boc_first_half_sign: 1 + doppler_sign: positive_output_carrier_frequency_increase + carrier_wipeoff_sign: -1 diff --git a/configs/signalsim_validation.yaml b/configs/signalsim_validation.yaml new file mode 100644 index 0000000..a25fcaa --- /dev/null +++ b/configs/signalsim_validation.yaml @@ -0,0 +1,41 @@ +mode: quick +vector: + name: high_cn0_single_prn + samples: verification/vectors/signalsim/high_cn0_single_prn/high_cn0_single_prn_iq8.bin + generator_config: verification/vectors/signalsim/high_cn0_single_prn/config.json + sample_format: IQ8 + sampling_frequency_hz: 8184000.0 + intermediate_frequency_hz: 2046000.0 + duration_s: 2.2 + prn: 1 + expected_doppler_hz: 1828.0 + expected_doppler_source: SignalSim_validate_only_summary_rounded_to_integer_hz + expected_code_phase_samples: null + +acquisition: + doppler_min_hz: 1000.0 + doppler_max_hz: 2500.0 + doppler_step_hz: 250.0 + coherent_integration_ms: 4.0 + noncoherent_accumulations: 5 + detection_threshold: 2.5 + zero_padding_mode: none + +tracking: + e1b_integration_ms: 2.0 + e1b_pull_in_ms: 300.0 + e1c_integration_ms: 4.0 + e1c_pull_in_ms: 200.0 + minimum_locked_duration_s: 1.0 + pll_noise_bandwidth_hz: 10.0 + fll_noise_bandwidth_hz: 20.0 + dll_noise_bandwidth_hz: 1.0 + +pass_criteria: + maximum_raw_doppler_error_hz: 250.0 + maximum_tracking_doppler_error_hz: 10.0 + maximum_tracking_doppler_jitter_hz: 5.0 + maximum_dll_rms_chips: 0.15 + minimum_secondary_correlation: 0.9 + maximum_secondary_sign_errors: 0 + minimum_inav_sync_words: 2 diff --git a/configs/tracking_example.yaml b/configs/tracking_example.yaml new file mode 100644 index 0000000..6b1cfce --- /dev/null +++ b/configs/tracking_example.yaml @@ -0,0 +1,22 @@ +signal_profile: GALILEO_E1 +component: E1C +prns: [1] +rf: + sample_format: complex128 + input_is_real_if: false + sampling_frequency_hz: 4092000.0 + intermediate_frequency_hz: 0.0 + sample_bit_width: 8 + sample_endianness: little +tracking: + 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: 100.0 + secondary_code_mode: wipeoff diff --git a/data/galileo_e1_primary_codes/README.md b/data/galileo_e1_primary_codes/README.md new file mode 100644 index 0000000..045056c --- /dev/null +++ b/data/galileo_e1_primary_codes/README.md @@ -0,0 +1,6 @@ +# Galileo E1 primary-code table integration point + +No official sequences are bundled. Install independently verified ICD-derived +tables using the naming and encoding documented by `FileCodeProvider`. The +built-in `SyntheticCodeProvider` is intentionally non-Galileo and test-only. + diff --git a/data/galileo_e1_secondary_codes/README.md b/data/galileo_e1_secondary_codes/README.md new file mode 100644 index 0000000..b3a7766 --- /dev/null +++ b/data/galileo_e1_secondary_codes/README.md @@ -0,0 +1,4 @@ +# Galileo E1 secondary-code table integration point + +No official sequence is fabricated or bundled. Validated tables may be added +here and loaded with `FileCodeProvider`. diff --git a/docs/algorithms.md b/docs/algorithms.md new file mode 100644 index 0000000..170bcbe --- /dev/null +++ b/docs/algorithms.md @@ -0,0 +1,119 @@ +# Algorithms and conventions + +## Signal and downconversion + +The analytic sampled signal uses + +\[ +x[n]=A\,c(\tau_n)b(\tau_n)s_{BOC}(\tau_n) +e^{j(\phi_0+2\pi(f_{IF}+f_D)n/f_s+\pi\dot f_D(n/f_s)^2)}+w[n]. +\] + +The generator defines a positive Doppler as positive complex phase rotation. +Digital downconversion and a candidate Doppler wipeoff therefore use + +\[ +y[n]=x[n]e^{-j2\pi(f_{IF}+f_{bin})n/f_s}. +\] + +The phase-accumulator NCO has modulus \(2^P\), frequency-control word +\(FCW=\operatorname{round}(f_o2^P/f_s)\), and update +\(phase_{n+1}=(phase_n+FCW)\bmod 2^P\). + +## Code and FFT acquisition + +Fractional code phase advances by \(f_c/f_s\) chips per sample and indexes the +chip with `floor(phase) mod code_length`. The BOC(1,1) sine-phased square wave +is +1 for the first half of a chip and -1 for the second half. This convention +is shared by generation, acquisition, and tracking. + +The circular correlation is + +\[ +R_f[k]=\operatorname{IFFT}\{\operatorname{FFT}(y_f[n]) +\operatorname{FFT}(c[n])^*\}[k]. +\] + +For coherent interval \(T_c\), the complex correlator output is retained before +power formation. Noncoherent accumulation is explicitly + +\[ +P_f[k]=\sum_{m=0}^{M-1}|R_{f,m}[k]|^2, +\quad T_{obs}=M T_c. +\] + +Differential combining uses adjacent complex results before magnitude. E1B +hypothesis mode enumerates primary-epoch signs (limited to eight periods to +bound exponential work). E1C search enumerates secondary phase; known-phase +wipeoff uses phase zero at the first observation epoch. Delay results are +reported over one 4 ms primary period even for longer coherent integrations. + +Zero-padding can provide a convenient FFT size or a denser interpolation of a +particular numerical representation, but it does not increase signal +bandwidth, physical information, or fundamental delay resolution. + +## Detection + +After circularly excluding a guard interval around the main peak, the model +computes + +\[ +M_2=P_{max}/P_{second},\qquad M_\mu=P_{max}/\bar P_N, +\] + +plus `(peak-noise_mean)/(sidelobe-noise_mean)` and a CFAR-style +`(peak-noise_median)/noise_std`. The acquisition C/N0 diagnostic is + +\[ +\widehat{C/N_0}=10\log_{10}\left((P_{max}-P_N)/(P_NT_c)\right). +\] + +It is an estimator, not a calibrated RF measurement. Half-chip competitive BOC +neighbors set a side-peak flag. `flag` is the implemented mitigation; an +unambiguous BOC/CBOC strategy plugs into the detector without changing results. +Parabolic interpolation preserves raw Doppler and sample bins. + +## Tracking + +Each component channel owns carrier/code NCO phase, loop states, correlation +history, lock counters, C/N0 history, and state-machine state. Phases advance at +every accepted sample and never reset on Python block boundaries. + +Carrier discriminators are + +\[ +e_{PLL,2}=\tan^{-1}(Q/I),\qquad e_{PLL,4}=\operatorname{atan2}(Q,I) +\] + +in radians. The cross/phase-difference FLL is + +\[ +e_{FLL}=\angle(P_kP_{k-1}^*)/(2\pi T)\quad[Hz]. +\] + +The normalized envelope DLL reports a locally calibrated chip error + +\[ +e_{DLL}=d\frac{|E|-|L|}{|E|+|L|}\quad[chips], +\] + +with power and dot-product alternatives. A BOC false-lock flag compares VEVL +and prompt magnitudes. + +For a second-order filter, noise bandwidth and damping generate + +\[ +\omega_n=\frac{8\zeta B_n}{4\zeta^2+1},\quad +k_p=2\zeta\omega_n,\quad k_i=\omega_n^2. +\] + +The third-order starting model places three repeated stable poles derived from +the same \(\omega_n\): correction +\(3\omega_ne+3\omega_n^2\int e+\omega_n^3\iint e\). These are physical +starting coefficients, not final optimized receiver-loop values. + +Carrier NCO frequency is corrected in hertz; code NCO frequency is nominal code +rate plus DLL correction and optional carrier aiding +\(f_{code,aid}=f_{carrier}f_c/f_{RF}\). Prompt C/N0 uses coherent versus total +prompt moments over a sliding window and divides the ratio by integration time. + diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..1d6edd9 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,28 @@ +# Configuration + +`ReceiverConfig` owns RF, acquisition, tracking, front-end, and fixed-point +dataclasses. YAML loading is strict: a misspelled field is an error. + +Important validation rules include positive sampling/loop rates, a real IF +strictly below Nyquist, integer sample counts per integration interval, +ordered correlator tap spacing, explicit FFT length when requested, E1B +bit-transition protection beyond 4 ms, and E1C secondary-code handling beyond +4 ms. E1B tracking is conservatively limited to 4 ms until navigation-symbol +synchronization is implemented. + +`intermediate_frequency_hz` may be nonzero for either real or analytic input. +The front end always outputs complex baseband and divides the effective sample +rate by the integer decimation factor. Acquisition/tracking must use that output +rate. + +Acquisition parallelism fields expose future hardware/software scheduling but +do not change numerical results. `zero_padding_mode: none` provides the exact +N-sample circular correlation. A longer FFT changes the sampled numerical +correlation/edge convention; it is not additional physical delay information. + +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. + diff --git a/docs/development_phases.md b/docs/development_phases.md new file mode 100644 index 0000000..4ef32ee --- /dev/null +++ b/docs/development_phases.md @@ -0,0 +1,50 @@ +# Phase gates and reproducible commands + +The project was implemented in six gates. These are the exact command shapes +used to validate each gate; the final regression reruns their union. + +## Phase 1 + +```bash +python -m pytest tests/unit/test_config.py tests/unit/test_signals.py +python examples/generate_e1_signal.py +``` + +## Phase 2 + +```bash +python -m pytest tests/unit/test_frontend.py tests/unit/test_acquisition_metrics.py tests/unit/test_acquisition_parallelism.py tests/integration/test_acquisition.py tests/integration/test_long_integration.py +python examples/run_acquisition.py +``` + +## Phases 3 and 4 + +```bash +python -m pytest tests/unit/test_discriminators.py tests/unit/test_loop_filters.py tests/unit/test_correlator.py tests/integration/test_tracking.py +python examples/run_tracking.py +python examples/run_complete_receiver.py +``` + +## Phase 5 + +```bash +python -m pytest tests/unit/test_fixed_point.py tests/unit/test_fixed_fft.py tests/integration/test_fixed_acquisition.py tests/regression/test_complete_receiver.py +python -m galileo_rx.cli compare-fixed --config configs/acquisition_example.yaml --input samples.bin +``` + +## Phase 6 and final regression + +```bash +python -m pytest tests/unit/test_rtl_model.py tests/unit/test_vector_export.py +python -m pytest +``` + +The CLI smoke sequence is: + +```bash +python -m galileo_rx.cli generate --config configs/acquisition_example.yaml --duration-ms 60 --prn 1 --output samples.bin +python -m galileo_rx.cli acquire --config configs/acquisition_example.yaml --input samples.bin --output acquisition.json +python -m galileo_rx.cli track --config configs/tracking_example.yaml --input samples.bin --acquisition-result acquisition.json --output tracking.csv +python -m galileo_rx.cli run --config configs/acquisition_example.yaml --input samples.bin +python -m galileo_rx.cli compare-fixed --config configs/acquisition_example.yaml --input samples.bin +``` diff --git a/docs/fixed_point_plan.md b/docs/fixed_point_plan.md new file mode 100644 index 0000000..f7bd64d --- /dev/null +++ b/docs/fixed_point_plan.md @@ -0,0 +1,45 @@ +# Fixed-point plan + +The fixed framework represents signed two's-complement integers with declared +total/fractional widths. Every quantizer selects saturation or wrap and nearest, +truncate, or convergent ties-to-even rounding. Quantizers count overflow and +accumulate RMS/peak quantization error. + +Proposed starting points (not final widths): + +| Stage | Width | Fraction | Policy | +|---|---:|---:|---| +| input sample | 8 | 7 | saturate | +| sine/cosine | 16 | 15 | saturate | +| mixer output | 18 | 15 | saturate | +| FFT input | 18 | 15 | saturate | +| FFT twiddle | 16 | 15 | convergent | +| FFT stage | 20 | 15 | configured scaling | +| correlation product | 36 | 30 | full precision then requantize | +| IFFT output | 28 | 15 | saturate | +| coherent accumulator | 40 | 15 | saturate | +| noncoherent accumulator | unsigned 56 | 20 | saturate | +| carrier phase | unsigned 32 | 0 | natural wrap | +| code phase | unsigned 48 | 32 | natural wrap | +| loop state | 32 | 20 | saturate | +| FCW | signed 32 | 0 | wrap in phase accumulator | +| discriminator | 24 | 18 | saturate | + +`FixedNco` and `FixedCorrelator` are integer bit-true models. Fixed PCPS models +declared quantization at input, normalized FFT output, pointwise product, IFFT +output/power, and accumulation. Exact butterfly-level identity depends on the +chosen streaming FFT IP scaling schedule and is explicitly unresolved until an +RTL FFT architecture is selected. + +`FixedRadix2Fft` supplies a fully stage-quantized power-of-two oracle with +quantized twiddles, convergent butterfly rounding, saturation counters, and one +bit of scaling per stage. It is used for standalone RTL FFT vectors. The +runtime PCPS boundary model remains able to use arbitrary transform lengths; +matching a vendor FFT at those lengths requires configuring that core's exact +schedule. + +The sweep utility varies input/FFT widths and reports mean absolute/relative, +RMS, peak error, acquisition decision, Doppler bin, code phase, and overflows. +Tracking qualification additionally compares convergence, steady-state jitter, +and loss-of-lock decisions; these metrics must become release gates once loop +state formats are frozen. diff --git a/docs/rtl_mapping.md b/docs/rtl_mapping.md new file mode 100644 index 0000000..5cc6158 --- /dev/null +++ b/docs/rtl_mapping.md @@ -0,0 +1,104 @@ +# RTL mapping and architecture tradeoffs + +All streaming datapaths use `data`, `valid`, `ready`, optional `first/last`, +`channel_id`, and sideband metadata. State advances only on `valid && ready`. +Backpressure must propagate or terminate in a sized sample FIFO. Reset clears +valid/state/overflow counters and sets NCO phases to software-programmed start +values; it must not occur between arbitrary host blocks. + +## Acquisition formulas + +Let \(N_s=\operatorname{round}(f_sT_c)\), FFT size \(N\ge N_s\), PRNs \(P\), +Doppler bins \(D\), noncoherent intervals \(M\), Doppler lanes \(L\), FFT +latency/interval \(C_{FFT}\), and clock \(f_{clk}\). + +- observation buffer: \(MN_s\) complex samples; +- transforms per PRN: \(2DM\) when code FFTs are precomputed; +- code FFT store: \(PN\) complex words (or \(N\) for on-demand PRN loading); +- pointwise complex multiplications: \(PDMN\); +- approximate single-engine search cycles: + \(P\lceil D/L\rceil M(2C_{FFT}+N)\); +- acquisition time is those cycles divided by \(f_{clk}\), plus fill and control + overhead. + +### A — buffered FFT PCPS + +One coherent block is buffered. A mixer iterates Doppler bins, a forward FFT is +multiplied by conjugated code FFT memory, the same or a second engine performs +IFFT, magnitude squared accumulates in Doppler/code RAM, and peak search scans +the result. It minimizes mixers/FFT engines/DSPs and maximizes search latency. +Ping-pong buffers hide capture behind search. One FFT engine needs arbitration +and doubles transform serialization; two remove that conflict at extra DSP/BRAM. + +### B — parallel Doppler lanes + +`NUM_DOPPLER_LANES=L` replicates carrier mixers and either feeds batched FFTs or +replicates FFT engines. With one reused FFT, lanes improve mixing but not the FFT +bottleneck. With L FFT/IFFT paths, ideal Doppler latency approaches 1/L while +DSP, twiddle ROM, transpose memory, and noncoherent RAM ports grow roughly with +L. Intermediate choices use L mixers, a smaller number of FFT engines, and +FIFOs. Resource budgets and required reacquisition time select L; neither +architecture is universally preferred. + +## Tracking + +Fully parallel mode instantiates carrier/code NCOs and VE/E/P/L/VL complex +MACs per channel. It provides one-sample-per-clock latency independence and +uses the most DSPs and accumulators. + +Time-multiplexed mode stores phase words, FCWs, five complex accumulators, loop +states, counters, and secondary phase per channel in `channel_state_ram`. +`NUM_CORRELATOR_LANES` engines visit all active channels for every input sample. +The no-loss condition is + +\[ +\left\lceil\frac{NUM\_TRACKING\_CHANNELS}{NUM\_CORRELATOR\_LANES}\right\rceil +C_{channel}\le f_{clk}/f_s. +\] + +If it fails, more lanes, a faster clock, a lower post-decimation sample rate, or +fewer active channels are required. A sample FIFO cannot repair a sustained +rate violation. + +Parameters are `NUM_TRACKING_CHANNELS`, `NUM_CORRELATOR_LANES`, `SAMPLE_WIDTH`, +`NCO_PHASE_WIDTH`, `CODE_PHASE_WIDTH`, `ACCUMULATOR_WIDTH`, and +`MAX_INTEGRATION_SAMPLES`. The Python `TrackingScheduler` evaluates this exact +condition. + +The detailed per-module signal, width, latency, storage, reset, saturation, and +verification contract is in `src/galileo_rx/rtl_model/architecture.md`. + +## SignalSim gate before Acquisition IP / Tracking IP implementation + +The validated external stimulus is complex IQ8 at 8.184 MHz with +2.046 MHz +IF. `validate-signalsim` exports the 20 ms acquisition observation as separate +two's-complement I/Q hex files, a per-sample timestamp CSV, and symbolic +register values under `reports/signalsim_validation/rtl_vectors/`. + +The fixed acquisition boundary model agrees with the float64/complex128 +reference on Doppler bin, code-phase sample, and acquisition decision with zero +overflow. Its normalized correlation-map RMS error is 0.00219 and peak error is +0.01996 for the proposed comparison formats. This is an input/FFT/product/power +boundary result; it does not yet include a selected vendor FFT butterfly shift +schedule. + +Measured requirements feeding the RTL plan are: + +- complex IQ input and `IQ` ordering must be a first-class sample-input mode; +- code ROM import must discard one padding bit per 1024-bit SignalSim E1 table + sector—no unexplained chip offset is permitted; +- acquisition-to-tracking handoff carries IF center separately from Doppler so + code aiding never uses the IF frequency; +- E1B pull-in supports 2 ms dumps and data-insensitive FLL, followed by paired + 4 ms raw-symbol outputs; +- E1C channel state includes secondary phase (measured phase 10 for this + vector) and supports wipeoff before PLL/discriminator processing; +- tracking measurement/APB visibility includes prompt I/Q, carrier/code NCOs, + state, lock metrics, secondary phase, and symbol-valid/data; +- APB addresses are still unassigned. The exported metadata uses symbolic + names and therefore blocks a bus-level RTL run until the register map exists. + +Native real-IF SignalSim stimulus is unavailable, so the real-input DDC path +remains covered only by internal Python vectors. Full CBOC, IQ2, full I/NAV +decoding, nominal/weak C/N0, signed-Doppler, and multi-PRN external regressions +remain gates before claiming broad Galileo/RTL compatibility. diff --git a/docs/signalsim_integration.md b/docs/signalsim_integration.md new file mode 100644 index 0000000..613ebd2 --- /dev/null +++ b/docs/signalsim_integration.md @@ -0,0 +1,241 @@ +# SignalSim integration and measured compatibility + +## Repository identity and immutable baseline + +The detected project is the GNSS simulator by Jun Mo (`globsky/SignalSim`), not +another repository with a similar name. + +| Item | Detected value | +|---|---| +| Local path | `/home/onur/Desktop/galileo/signalsim/SignalSim` | +| Remote | `https://github.com/globsky/SignalSim` | +| Commit | `80b262113e77c4482ca9f5cbfef9bcada402ed28` | +| Branch/status before integration | `main...origin/main`, clean | +| License evidence | No LICENSE/COPYING file; source headers say “All rights reserved”; README identifies some commercially licensed features | + +SignalSim itself was not modified. The receiver parses its code tables from the +external checkout at run time, so the SignalSim commit remains part of the test +vector provenance and its copyrighted tables are not copied into this project. + +The identity commands were: + +```bash +git -C signalsim/SignalSim remote -v +git -C signalsim/SignalSim rev-parse HEAD +git -C signalsim/SignalSim status --short --branch +``` + +## Reproducible build + +```bash +tools/build_signalsim.sh /home/onur/Desktop/galileo/signalsim/SignalSim +ctest --test-dir build/signalsim --output-on-failure +``` + +The build used CMake 3.31.9, GCC 11.4.0, Ninja 1.11.1, C++20, Release mode, +OpenMP, and `USE_NATIVE_OPT=OFF`. It produced `build/signalsim/IFdataGen`. +CTest reported `No tests were found!!!`; the repository does not define +IFdataGen CTest tests at this commit. The documented `-v`/`--version` options do +not print a version and instead fall through to configuration handling. + +## Source-to-sample trace + +The executed Galileo E1 path is: + +```text +JSON output.systemSelect "Galileo"/"E1" + -> IFdataGen frequency selection + -> PrnGenerate(GalileoSystem, SIGNAL_INDEX_E1, SVID) + E1B = E1MemoryCode[(SVID-1)*128] + E1C = E1MemoryCode[(SVID+49)*128] + -> SatelliteSignal + E1B = -I/NAV symbol / sqrt(2) + E1C = 25-symbol pilot sign / sqrt(2) + -> SatIfSignal BOC half-chip sign and carrier rotation + -> complex Gaussian noise + AGC + -> IQ8/IQ16/IQ4/IQ2 quantizer + -> binary file, I before Q +``` + +`GetMemorySequence()` reads four storage sectors per E1 code. Within each +32-word/1024-bit sector it consumes 1023 bits MSB first and discards the last +padding bit. The receiver adapter mirrors that exact behavior. Treating the +table as 4092 contiguous bits was experimentally shown to reduce peak/second +from about 7 to about 1; the sector-aware implementation restores acquisition. + +SignalSim models E1 as equal-amplitude E1B and E1C on the same real baseband +axis. It flips every second 2.046-Mchip/s half-chip, which is BOC(1,1). It does +not synthesize the BOC(6,1) term or full Galileo E1 OS CBOC in this path. E1B +and E1C cannot be selected separately by the JSON/CLI; `E1` always generates +the combined signal. + +Source-inspected Galileo selection (only E1 was generated in this validation): + +| Band selector | Components implemented in open source path | Data layer / pilot overlay | Validation status | +|---|---|---|---| +| E1 | E1B data + E1C pilot, inseparable | I/NAV / common CS25 | Generated and received | +| E5a | data I + pilot Q codes | F/NAV / PRN-specific CS100 | Source inspected only | +| E5b | data I + pilot Q codes | I/NAV / PRN-specific CS100 | Source inspected only | +| E6 | pilot output (data amplitude set to zero) | no open data path / CS100 | Source inspected only | +| E5 AltBOC | Listed as commercially licensed | N/A | Not available/tested | + +### Navigation and secondary data + +E1B is not a fixed or random placeholder bit stream. `INavBit::GetFrameData()` +constructs Galileo I/NAV words from the selected RINEX ephemeris/almanac/UTC +data, adds CRC-24Q, convolutionally encodes, interleaves, inserts the 10-symbol +sync pattern, and emits 500 encoded symbols per 2-second page. Word types above +20 are forced to dummy word 63; reserved/SAR/spare fields include zero-filled +placeholders. Reed-Solomon ephemeris parity words 17–20 are implemented. + +E1C uses a common 25-symbol value held in `PilotBit.cpp` as `0x9b501c`, addressed +LSB first. This work verifies the sequence SignalSim actually emits; it does +not independently certify that constant against a particular Galileo ICD +revision. + +See [the bit-layer report](../reports/signalsim_bit_generation_report.md) for a +stage-by-stage distinction between navigation fields, channel symbols, +spreading chips, and samples. + +## Output formats and controls + +`sampleFreq` and `centerFreq` are configurable JSON values in MHz and are +converted internally to integer kHz. A signal's complex IF is +`RF carrier - centerFreq`; out-of-band signals are disabled. Native output is +complex IQ only—there is no real-only IF binary mode. + +| Format | Exact packing | Quantization at AGC gain `G` | Status | +|---|---|---|---| +| IQ16 | little-endian signed I16, signed Q16 | truncate `3277*G*x`, saturate −32768…32767 | usable | +| IQ8 | signed I8, signed Q8 | truncate `25*G*x`, saturate −128…127 | validated | +| IQ4 | one byte; I high nibble, Q low; sign-magnitude | truncate `3*G*abs(x)`, magnitude saturates at 7 | adapter tested | +| IQ2 | two complex samples/byte, low then high nibble; sign/magnitude | threshold levels | broken in this commit: computed byte is never assigned to output | + +The adapter in `galileo_rx.io.signalsim_reader` validates file length, signed +conversion, byte order, IQ order, offsets, counts, memory mapping, and streaming. +All decoded samples are promoted to `numpy.complex128`; quantized integer widths +remain explicit input boundaries. + +## Generated vector and executable results + +The deterministic vector configuration is +`verification/vectors/signalsim/high_cn0_single_prn/config.json`. It requests +one visible Galileo PRN 1, combined E1B/E1C, 55 dB-Hz configured C/N0, 8.184 +MHz complex IQ8, center 1573.374 MHz (IF +2.046 MHz), static receiver, bundled +RINEX navigation data, and 2.2 seconds duration. + +```bash +build/signalsim/IFdataGen \ + -c verification/vectors/signalsim/high_cn0_single_prn/config.json \ + -o verification/vectors/signalsim/high_cn0_single_prn/high_cn0_single_prn_iq8.bin \ + -t -st + +MPLBACKEND=Agg python -m galileo_rx.cli validate-signalsim \ + --signalsim-path signalsim/SignalSim \ + --config configs/signalsim_validation.yaml \ + --output-dir reports/signalsim_validation \ + --mode quick +``` + +The sample file is 36,009,600 bytes (18,004,800 complex samples) with SHA-256 +`3e72a5ad2d35ee68273b77587a66847b0fb328650b39c9e39272f0dc54e39e3c`. +SignalSim's validation summary reports PRN 1 Doppler `+1828 Hz`; this is rounded +to integer hertz. SignalSim does not emit exact sample-side code-delay truth. + +Measured quick-mode results: + +| Quantity | E1B | E1C | +|---|---:|---:| +| PCPS detected PRN | 1 | 1 | +| Raw Doppler bin | +1750 Hz | +1750 Hz | +| Interpolated Doppler | +1766.36 Hz | +1776.64 Hz | +| Code phase | 18309.410 samples | 18309.256 samples | +| Peak/second metric | 7.511 | 6.903 | +| Final tracking Doppler | +1828.251 Hz | +1828.284 Hz | +| Tracking Doppler jitter | 1.326 Hz RMS | 1.082 Hz RMS | +| DLL error | 0.0963 chips RMS | 0.0849 chips RMS | +| Final state | PLL_TRACK | PLL_TRACK | +| Reacquisition epochs | 0 | 0 | + +E1B uses 2 ms pull-in integrations. A 4 ms BPSK-squared FLL has an unambiguous +range of only ±62.5 Hz, almost exactly the residual between the interpolated +estimate and the +1828 Hz truth. Two milliseconds expands that range to ±125 +Hz, after which the channel converges and 2 ms prompt pairs form one 4 ms +symbol. Two exact I/NAV sync patterns were recovered 250 symbols (one second) +apart after a 300 ms tracking transient. This proves raw symbol recovery and +page-boundary synchronization, not FEC/page/CRC decoding. + +E1C synchronization jointly searched secondary phase and residual carrier +frequency. It found phase 10 and +52.0 Hz residual, correlation 0.9830, and +0/100 sign errors. Wiping the secondary code yielded 13.72 dB of 25-period +coherent gain versus a 4 ms period, and 14.58 dB improvement relative to the +unwiped 25-period sum. + +Full mode adds three generated vectors. The 50 dB-Hz configured nominal vector +passes E1C acquisition (metric 12.49), secondary synchronization (correlation +0.9947, 0/100 errors), and pilot tracking at +1828.672 Hz with 0.817 Hz jitter. +The negative-Doppler PRN 27 vector passes +acquisition at −2750 Hz versus −2637 Hz truth and, after pull-in, tracks at +−2637.776 Hz with 0.531 Hz jitter and 0.0615-chip DLL RMS. Five-PRN acquisition +detects PRNs 1, 4, 19, 21, and 27; every raw Doppler result is within one 250 Hz +bin. The negative channel raised five reacquisition flags during initial pull-in +but none after epoch 500, and finished in `PLL_TRACK`; that transient is retained +in the report rather than hidden. + +Detailed machine results and plots are in +`reports/signalsim_validation/`, with acquisition-only results in +`reports/signalsim_acquisition_report.{json,md}`. + +## Direct answers and limitations + +1. Galileo generation: **yes**. +2. Galileo bands: E1, E5a, E5b, E6; E5 AltBOC is not in the open path. +3. E1B: **yes, only as part of combined E1**. +4. E1C: **yes, only as part of combined E1**. +5. Modulation: BOC(1,1) approximation, not CBOC. +6. Navigation data: complete encoded/interleaved I/NAV symbols are generated, + with the field limitations described above. +7. E1C secondary symbols: **yes**, 25 symbols. +8. Configurable IF: **yes**, through center frequency, kHz granularity. +9. Configurable sample rate: **yes**, kHz granularity. +10. Output: complex IQ only; no native real IF. +11. Binary formats: exact table above; IQ8 is the validated integration format. +12. PCPS acquisition: **pass** for E1B and E1C. +13. Carrier/code tracking: **pass** for E1B and E1C over the available vector. +14. E1B symbols: **raw symbols and sync recovered**; navigation decoder absent. +15. E1C secondary: **phase synchronized and long-coherent gain measured**. +16. Failures: no component-only E1, no CBOC, no real IF, broken IQ2 writer, + no exported exact delay/symbol truth, no receiver I/NAV FEC/page decoder, + and the remaining signal-format/component limitations listed above. + +## Compatibility and RTL readiness + +“Ready” below means the external stimulus, convention adapter, Python golden +result, and automated check exist. It does not claim the RTL IP itself is built. + +| Capability | SignalSim support | Python receiver result | RTL readiness | +|---|---:|---:|---:| +| Galileo E1B generation | Partial (combined E1 only) | Pass | Ready | +| Galileo E1C generation | Partial (combined E1 only) | Pass | Ready | +| Real IF | No | Not tested | Blocked | +| Complex IQ | Yes | Pass | Ready | +| Configurable sample rate | Yes | Pass at 8.184 MHz | Ready | +| Configurable IF | Yes | Pass at +2.046 MHz | Ready | +| PCPS acquisition | N/A | Pass | Ready | +| Carrier tracking | N/A | Pass | Ready | +| Code tracking | N/A | Pass | Ready | +| E1B symbol recovery | Generated | Pass (raw + sync) | Ready | +| Full I/NAV decoding | Generated | Not implemented | Blocked | +| E1C secondary-code synchronization | Generated | Pass | Ready | + +## Regression commands + +```bash +pytest -m signalsim -v +pytest tests/integration/test_signalsim_acquisition.py -v +pytest tests/integration/test_signalsim_tracking.py -v +``` + +`--mode full` additionally regenerates/validates nominal-C/N0 acquisition, +negative-Doppler acquisition and tracking, and five-PRN acquisition. E1B-only +and E1C-only cases remain blocked because SignalSim cannot generate them. diff --git a/examples/generate_e1_signal.py b/examples/generate_e1_signal.py new file mode 100644 index 0000000..c7414d2 --- /dev/null +++ b/examples/generate_e1_signal.py @@ -0,0 +1,16 @@ +"""Generate a deterministic, synthetic (not ICD-code) E1C recording.""" + +from galileo_rx.io.sample_writer import write_samples +from galileo_rx.signal_profiles import galileo_e1_profile +from galileo_rx.signals.code_provider import SyntheticCodeProvider +from galileo_rx.signals.signal_generator import ChannelModelConfig, SatelliteSignalConfig, SyntheticSignalGenerator + +fs = 4.092e6 +generator = SyntheticSignalGenerator( + galileo_e1_profile(), SyntheticCodeProvider(), fs, + satellites=[SatelliteSignalConfig(1, "E1C", code_delay_chips=120.0, doppler_hz=1250.0, cn0_db_hz=48.0)], + channel=ChannelModelConfig(seed=2026), +) +samples, truth = generator.generate(int(0.1 * fs)) +write_samples("e1_synthetic.c128", samples) +print(truth) diff --git a/examples/run_acquisition.py b/examples/run_acquisition.py new file mode 100644 index 0000000..72df3b3 --- /dev/null +++ b/examples/run_acquisition.py @@ -0,0 +1,16 @@ +"""Generate and acquire one synthetic E1C satellite.""" + +from galileo_rx.acquisition.acquisition_manager import AcquisitionManager +from galileo_rx.config import AcquisitionConfig +from galileo_rx.signal_profiles import galileo_e1_profile +from galileo_rx.signals.code_provider import SyntheticCodeProvider +from galileo_rx.signals.signal_generator import ChannelModelConfig, SatelliteSignalConfig, SyntheticSignalGenerator + +fs = 4.092e6 +profile, provider = galileo_e1_profile(), SyntheticCodeProvider() +generator = SyntheticSignalGenerator(profile, provider, fs, satellites=[SatelliteSignalConfig(1, code_delay_chips=120, doppler_hz=1250, cn0_db_hz=48)], channel=ChannelModelConfig(seed=11)) +samples, _ = generator.generate(int(0.008 * fs)) +config = AcquisitionConfig(doppler_min_hz=-2500, doppler_max_hz=2500, noncoherent_accumulations=2, zero_padding_mode="none") +result, _ = AcquisitionManager(profile, provider, fs, config).acquire_one(samples, "E1C", 1) +print(result) + diff --git a/examples/run_complete_receiver.py b/examples/run_complete_receiver.py new file mode 100644 index 0000000..af90695 --- /dev/null +++ b/examples/run_complete_receiver.py @@ -0,0 +1,5 @@ +"""Complete synthetic generate/acquire/track flow.""" + +from run_tracking import measurements, result + +print(f"detected={result.detected}, Doppler={result.doppler_hz:.1f} Hz, epochs={len(measurements)}") diff --git a/examples/run_tracking.py b/examples/run_tracking.py new file mode 100644 index 0000000..d6207e4 --- /dev/null +++ b/examples/run_tracking.py @@ -0,0 +1,19 @@ +"""Acquire then track a synthetic pilot.""" + +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.signal_generator import ChannelModelConfig, SatelliteSignalConfig, SyntheticSignalGenerator +from galileo_rx.tracking.tracking_channel import TrackingChannel + +fs = 4.092e6 +profile, provider = galileo_e1_profile(), SyntheticCodeProvider() +generator = SyntheticSignalGenerator(profile, provider, fs, satellites=[SatelliteSignalConfig(1, code_delay_chips=40, doppler_hz=750, cn0_db_hz=50)], channel=ChannelModelConfig(seed=5)) +samples, _ = generator.generate(int(0.12 * fs)) +acq_config = AcquisitionConfig(doppler_min_hz=-2000, doppler_max_hz=2000, noncoherent_accumulations=2, zero_padding_mode="none") +result, _ = AcquisitionManager(profile, provider, fs, acq_config).acquire_one(samples, "E1C", 1) +channel = TrackingChannel(profile, provider, fs, TrackingConfig(secondary_code_mode="wipeoff", pull_in_duration_ms=20), result) +measurements = channel.process(samples) +print(measurements[-1]) + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..66d1d63 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "galileo-receiver" +version = "0.1.0" +description = "Profile-driven Galileo receiver baseband reference and bit-true models" +readme = "README.md" +requires-python = ">=3.11" +dependencies = ["numpy>=1.24", "scipy>=1.10", "PyYAML>=6.0", "matplotlib>=3.7"] + +[project.optional-dependencies] +test = ["pytest>=7.4", "pytest-cov>=4.1"] + +[project.scripts] +galileo-rx = "galileo_rx.cli:main" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" +markers = ["signalsim: requires the external local SignalSim checkout and/or generated vectors"] diff --git a/reports/signalsim_acquisition_report.json b/reports/signalsim_acquisition_report.json new file mode 100644 index 0000000..818da72 --- /dev/null +++ b/reports/signalsim_acquisition_report.json @@ -0,0 +1,103 @@ +{ + "simulator": { + "name": "SignalSim", + "path": "/home/onur/Desktop/galileo/signalsim/SignalSim", + "remote": "https://github.com/globsky/SignalSim", + "commit": "80b262113e77c4482ca9f5cbfef9bcada402ed28", + "git_status": "## main...origin/main" + }, + "vector": { + "name": "high_cn0_single_prn", + "samples": "verification/vectors/signalsim/high_cn0_single_prn/high_cn0_single_prn_iq8.bin", + "generator_config": "verification/vectors/signalsim/high_cn0_single_prn/config.json", + "sample_format": "IQ8", + "sampling_frequency_hz": 8184000.0, + "intermediate_frequency_hz": 2046000.0, + "duration_s": 2.2, + "prn": 1, + "expected_doppler_hz": 1828.0, + "expected_doppler_source": "SignalSim_validate_only_summary_rounded_to_integer_hz", + "expected_code_phase_samples": null, + "path": "/home/onur/Desktop/galileo/verification/vectors/signalsim/high_cn0_single_prn/high_cn0_single_prn_iq8.bin", + "size_bytes": 36009600, + "complex_sample_count": 18004800, + "sha256": "3e72a5ad2d35ee68273b77587a66847b0fb328650b39c9e39272f0dc54e39e3c", + "internal_dtype": "complex128" + }, + "acquisition": { + "E1B": { + "detected": true, + "prn": 1, + "component": "E1B", + "code_phase_samples": 18309.41036755577, + "code_phase_chips": 2288.6762959444713, + "doppler_hz": 1766.3593126265423, + "carrier_frequency_hz": 2047766.3593126265, + "peak_power": 5441370632.908489, + "second_peak_power": 724471887.0812238, + "detection_metric": 7.510809915386589, + "estimated_cn0_db_hz": 38.1685032317465, + "coherent_integration_ms": 4.0, + "noncoherent_accumulations": 5, + "raw_code_phase_samples": 18309, + "raw_doppler_hz": 1750.0, + "metrics": { + "peak_to_second": 7.510809915386589, + "peak_to_mean": 27.236766759293843, + "peak_to_sidelobe": 9.98985090978008, + "cfar": 59.10585279263198, + "noise_mean": 199780344.01061067, + "noise_median": 186242777.1487124, + "noise_std": 88910448.07689284, + "second_peak": 724471887.0812238 + }, + "side_peak_suspected": false, + "expected_prn": 1, + "expected_doppler_hz": 1828.0, + "raw_doppler_error_hz": -78.0, + "interpolated_doppler_error_hz": -61.640687373457695, + "expected_code_phase_samples": null, + "code_phase_error_samples": null, + "processing_time_s": 0.06212109600164695, + "overflow_count": 0 + }, + "E1C": { + "detected": true, + "prn": 1, + "component": "E1C", + "code_phase_samples": 18309.255563640225, + "code_phase_chips": 2288.656945455028, + "doppler_hz": 1776.6435168702901, + "carrier_frequency_hz": 2047776.6435168702, + "peak_power": 5496116005.536917, + "second_peak_power": 796144387.0872731, + "detection_metric": 6.90341613239865, + "estimated_cn0_db_hz": 38.21910556645223, + "coherent_integration_ms": 4.0, + "noncoherent_accumulations": 5, + "raw_code_phase_samples": 18309, + "raw_doppler_hz": 1750.0, + "metrics": { + "peak_to_second": 6.90341613239865, + "peak_to_mean": 27.54425543885342, + "peak_to_sidelobe": 8.877838662393293, + "cfar": 59.373205541870576, + "noise_mean": 199537650.1549647, + "noise_median": 185090556.71506774, + "noise_std": 89451553.10970132, + "second_peak": 796144387.0872731 + }, + "side_peak_suspected": false, + "expected_prn": 1, + "expected_doppler_hz": 1828.0, + "raw_doppler_error_hz": -78.0, + "interpolated_doppler_error_hz": -51.356483129709886, + "expected_code_phase_samples": null, + "code_phase_error_samples": null, + "processing_time_s": 0.052345254000101704, + "overflow_count": 0 + } + }, + "pass": true, + "truth_limitations": "SignalSim CLI logs rounded Doppler but does not export exact code delay" +} diff --git a/reports/signalsim_acquisition_report.md b/reports/signalsim_acquisition_report.md new file mode 100644 index 0000000..3dbe11d --- /dev/null +++ b/reports/signalsim_acquisition_report.md @@ -0,0 +1,8 @@ +# SignalSim acquisition report + +| Component | Detected | Raw Doppler (Hz) | Truth (Hz) | Code phase (samples) | Peak/second | +|---|---:|---:|---:|---:|---:| +| E1B | True | 1750.0 | 1828.0 | 18309.410 | 7.511 | +| E1C | True | 1750.0 | 1828.0 | 18309.256 | 6.903 | + +SignalSim does not export exact code-delay truth. Agreement between independently acquired E1B and E1C code phases is used as a cross-component consistency check; no code-phase error is fabricated. diff --git a/reports/signalsim_bit_generation_report.md b/reports/signalsim_bit_generation_report.md new file mode 100644 index 0000000..d8a0e91 --- /dev/null +++ b/reports/signalsim_bit_generation_report.md @@ -0,0 +1,34 @@ +# SignalSim Galileo E1 bit-generation trace + +Inspected project: `globsky/SignalSim`, commit +`80b262113e77c4482ca9f5cbfef9bcada402ed28`. + +| Layer | E1B status | E1C status | Evidence/qualification | +|---|---|---|---| +| Navigation fields | Generated from RINEX ephemeris, almanac, UTC/iono | N/A pilot | `INavBit::GetWordData`, setters called by IFdataGen | +| Navigation page | 500-symbol, 2-second I/NAV page | N/A | `INavBit::GetFrameData` | +| CRC | CRC-24Q over 196 bits | N/A | `Crc24qEncode` | +| Channel encoding | Galileo convolutional encoder with tail bits | N/A | `GalConvolutionEncode` | +| Interleaving | 8 by 30 traversal for both page halves | N/A | final loops in `GetFrameData` | +| Sync | 10 symbols `0101100000` before each 240-symbol half | N/A | `SyncPattern` | +| Symbols | One encoded E1B symbol per 4 ms | 25-symbol overlay sequence | `SignalAttributes[9]`; `SecondaryCodeE1` | +| Spreading | 4092-chip E1B memory code | 4092-chip E1C memory code | `E1MemoryCode`, `GetMemorySequence` | +| Modulation | BOC(1,1), amplitude `-DataBit/sqrt(2)` | BOC(1,1), amplitude `PilotBit/sqrt(2)` | `SatelliteSignal.cpp`, `SatIfSignal.cpp` | +| IF samples | Combined complex IQ at RF-center offset | Combined complex IQ | IFdataGen quantizer/output path | + +The I/NAV stream is not random dummy data. It includes CRC, convolutional +encoding, interleaving, sync, page timing, TOW/WN fields where applicable, and +ephemeris/almanac content. Limitations are also explicit: unsupported word types +above 20 become dummy word 63, and reserved/SAR/spare content includes fixed +zero placeholders. + +Receiver-side evidence recovered 469 raw E1B symbols after the tracking +transient and found two exact `0101100000` sync words 250 symbols apart with +zero errors over the 20 known sync symbols. Receiver-side deinterleaving, FEC +decoding, page parsing, and CRC validation are not implemented, so this result +must not be described as decoded navigation bits or decoded I/NAV pages. + +E1C evidence found secondary phase 10, normalized correlation 0.9830, and zero +sign errors over 100 primary-code periods. The implemented 25-symbol constant +was verified against generated samples but was not independently certified +against an external ICD table in this work. diff --git a/reports/signalsim_e1b_symbol_comparison.json b/reports/signalsim_e1b_symbol_comparison.json new file mode 100644 index 0000000..2c34245 --- /dev/null +++ b/reports/signalsim_e1b_symbol_comparison.json @@ -0,0 +1,27 @@ +{ + "status": "raw_symbols_recovered_page_decoding_not_implemented", + "comparison_scope": "known_10_symbol_INAV_sync_words_only", + "recovered_symbols": 469, + "compared_symbols": 20, + "initial_ignored_tracking_epochs": 150, + "initial_ignored_time_s": 0.3, + "bounded_alignment": "code boundary from PCPS; sync search within remaining 1.876 s", + "sync_matches": [ + { + "symbol_index": 190, + "sign_inverted": false, + "errors": 0 + }, + { + "symbol_index": 440, + "sign_inverted": false, + "errors": 0 + } + ], + "symbol_errors": 0, + "symbol_error_rate": 0.0, + "median_soft_symbol_magnitude": 43846.682818616624, + "frame_or_page_sync": true, + "fec_decoding": "not_implemented", + "crc_validation": "not_implemented" +} diff --git a/reports/signalsim_validation/acquisition_map_e1b.png b/reports/signalsim_validation/acquisition_map_e1b.png new file mode 100644 index 0000000..d80b1f9 Binary files /dev/null and b/reports/signalsim_validation/acquisition_map_e1b.png differ diff --git a/reports/signalsim_validation/acquisition_map_e1c.png b/reports/signalsim_validation/acquisition_map_e1c.png new file mode 100644 index 0000000..2ee848b Binary files /dev/null and b/reports/signalsim_validation/acquisition_map_e1c.png differ diff --git a/reports/signalsim_validation/acquisition_peak_neighborhood_e1b.png b/reports/signalsim_validation/acquisition_peak_neighborhood_e1b.png new file mode 100644 index 0000000..d8d71c1 Binary files /dev/null and b/reports/signalsim_validation/acquisition_peak_neighborhood_e1b.png differ diff --git a/reports/signalsim_validation/acquisition_peak_neighborhood_e1c.png b/reports/signalsim_validation/acquisition_peak_neighborhood_e1c.png new file mode 100644 index 0000000..6241663 Binary files /dev/null and b/reports/signalsim_validation/acquisition_peak_neighborhood_e1c.png differ diff --git a/reports/signalsim_validation/acquisition_profiles_e1b.png b/reports/signalsim_validation/acquisition_profiles_e1b.png new file mode 100644 index 0000000..7e63906 Binary files /dev/null and b/reports/signalsim_validation/acquisition_profiles_e1b.png differ diff --git a/reports/signalsim_validation/acquisition_profiles_e1c.png b/reports/signalsim_validation/acquisition_profiles_e1c.png new file mode 100644 index 0000000..533144a Binary files /dev/null and b/reports/signalsim_validation/acquisition_profiles_e1c.png differ diff --git a/reports/signalsim_validation/prompt_constellation_e1b.png b/reports/signalsim_validation/prompt_constellation_e1b.png new file mode 100644 index 0000000..167674a Binary files /dev/null and b/reports/signalsim_validation/prompt_constellation_e1b.png differ diff --git a/reports/signalsim_validation/prompt_constellation_e1c.png b/reports/signalsim_validation/prompt_constellation_e1c.png new file mode 100644 index 0000000..ceae330 Binary files /dev/null and b/reports/signalsim_validation/prompt_constellation_e1c.png differ diff --git a/reports/signalsim_validation/rtl_vectors/signalsim_acquisition_input_iq8.json b/reports/signalsim_validation/rtl_vectors/signalsim_acquisition_input_iq8.json new file mode 100644 index 0000000..2aa868d --- /dev/null +++ b/reports/signalsim_validation/rtl_vectors/signalsim_acquisition_input_iq8.json @@ -0,0 +1,40 @@ +{ + "stem": "signalsim_acquisition_input_iq8", + "sample_count": 163680, + "total_bits": 8, + "fractional_bits": 0, + "signed": true, + "encoding": "two_complement_hex", + "i_file": "signalsim_acquisition_input_iq8_i.hex", + "q_file": "signalsim_acquisition_input_iq8_q.hex", + "overflow_count": 0, + "sampling_frequency_hz": 8184000.0, + "sample_period_s": 1.2218963831867057e-07, + "timestamp_file": "signalsim_acquisition_input_iq8_timestamps.csv", + "register_values": { + "SAMPLE_WIDTH": 8, + "SAMPLE_FRACTIONAL_BITS": 0, + "INPUT_REPRESENTATION": "COMPLEX_IQ", + "IQ_ORDER": "IQ", + "SAMPLING_FREQUENCY_HZ": 8184000.0, + "INTERMEDIATE_FREQUENCY_HZ": 2046000.0, + "PRN": 1, + "COMPONENT": "E1C", + "ACQ_DOPPLER_MIN_HZ": 1000.0, + "ACQ_DOPPLER_MAX_HZ": 2500.0, + "ACQ_DOPPLER_STEP_HZ": 250.0, + "ACQ_COHERENT_SAMPLES": 32736, + "ACQ_NONCOHERENT_COUNT": 5, + "ACQ_FFT_LENGTH": 32736, + "TRACK_E1B_INTEGRATION_SAMPLES": 16368, + "TRACK_E1C_INTEGRATION_SAMPLES": 32736, + "CARRIER_NCO_PHASE_WIDTH": 32, + "CARRIER_NCO_FCW": 1074701159, + "CODE_NCO_PHASE_WIDTH": 48, + "CODE_NCO_FCW": 35184412914156, + "E1C_SECONDARY_PHASE": 10, + "ACQ_TO_TRACK_CODE_PHASE_SAMPLES": 18309.255563640225, + "ACQ_TO_TRACK_DOPPLER_HZ": 1828.0, + "NOTE": "Symbolic names; APB addresses remain to be assigned by the RTL IP register map" + } +} \ No newline at end of file diff --git a/reports/signalsim_validation/signalsim_e1b_symbol_comparison.json b/reports/signalsim_validation/signalsim_e1b_symbol_comparison.json new file mode 100644 index 0000000..2c34245 --- /dev/null +++ b/reports/signalsim_validation/signalsim_e1b_symbol_comparison.json @@ -0,0 +1,27 @@ +{ + "status": "raw_symbols_recovered_page_decoding_not_implemented", + "comparison_scope": "known_10_symbol_INAV_sync_words_only", + "recovered_symbols": 469, + "compared_symbols": 20, + "initial_ignored_tracking_epochs": 150, + "initial_ignored_time_s": 0.3, + "bounded_alignment": "code boundary from PCPS; sync search within remaining 1.876 s", + "sync_matches": [ + { + "symbol_index": 190, + "sign_inverted": false, + "errors": 0 + }, + { + "symbol_index": 440, + "sign_inverted": false, + "errors": 0 + } + ], + "symbol_errors": 0, + "symbol_error_rate": 0.0, + "median_soft_symbol_magnitude": 43846.682818616624, + "frame_or_page_sync": true, + "fec_decoding": "not_implemented", + "crc_validation": "not_implemented" +} diff --git a/reports/signalsim_validation/signalsim_validation_report.json b/reports/signalsim_validation/signalsim_validation_report.json new file mode 100644 index 0000000..e7acb0a --- /dev/null +++ b/reports/signalsim_validation/signalsim_validation_report.json @@ -0,0 +1,267 @@ +{ + "simulator": { + "name": "SignalSim", + "path": "/home/onur/Desktop/galileo/signalsim/SignalSim", + "remote": "https://github.com/globsky/SignalSim", + "commit": "80b262113e77c4482ca9f5cbfef9bcada402ed28", + "git_status": "## main...origin/main" + }, + "vector": { + "name": "high_cn0_single_prn", + "samples": "verification/vectors/signalsim/high_cn0_single_prn/high_cn0_single_prn_iq8.bin", + "generator_config": "verification/vectors/signalsim/high_cn0_single_prn/config.json", + "sample_format": "IQ8", + "sampling_frequency_hz": 8184000.0, + "intermediate_frequency_hz": 2046000.0, + "duration_s": 2.2, + "prn": 1, + "expected_doppler_hz": 1828.0, + "expected_doppler_source": "SignalSim_validate_only_summary_rounded_to_integer_hz", + "expected_code_phase_samples": null, + "path": "/home/onur/Desktop/galileo/verification/vectors/signalsim/high_cn0_single_prn/high_cn0_single_prn_iq8.bin", + "size_bytes": 36009600, + "complex_sample_count": 18004800, + "sha256": "3e72a5ad2d35ee68273b77587a66847b0fb328650b39c9e39272f0dc54e39e3c", + "internal_dtype": "complex128" + }, + "mode": "quick", + "acquisition": { + "E1B": { + "detected": true, + "prn": 1, + "component": "E1B", + "code_phase_samples": 18309.41036755577, + "code_phase_chips": 2288.6762959444713, + "doppler_hz": 1766.3593126265423, + "carrier_frequency_hz": 2047766.3593126265, + "peak_power": 5441370632.908489, + "second_peak_power": 724471887.0812238, + "detection_metric": 7.510809915386589, + "estimated_cn0_db_hz": 38.1685032317465, + "coherent_integration_ms": 4.0, + "noncoherent_accumulations": 5, + "raw_code_phase_samples": 18309, + "raw_doppler_hz": 1750.0, + "metrics": { + "peak_to_second": 7.510809915386589, + "peak_to_mean": 27.236766759293843, + "peak_to_sidelobe": 9.98985090978008, + "cfar": 59.10585279263198, + "noise_mean": 199780344.01061067, + "noise_median": 186242777.1487124, + "noise_std": 88910448.07689284, + "second_peak": 724471887.0812238 + }, + "side_peak_suspected": false, + "expected_prn": 1, + "expected_doppler_hz": 1828.0, + "raw_doppler_error_hz": -78.0, + "interpolated_doppler_error_hz": -61.640687373457695, + "expected_code_phase_samples": null, + "code_phase_error_samples": null, + "processing_time_s": 0.06577738200576277, + "overflow_count": 0 + }, + "E1C": { + "detected": true, + "prn": 1, + "component": "E1C", + "code_phase_samples": 18309.255563640225, + "code_phase_chips": 2288.656945455028, + "doppler_hz": 1776.6435168702901, + "carrier_frequency_hz": 2047776.6435168702, + "peak_power": 5496116005.536917, + "second_peak_power": 796144387.0872731, + "detection_metric": 6.90341613239865, + "estimated_cn0_db_hz": 38.21910556645223, + "coherent_integration_ms": 4.0, + "noncoherent_accumulations": 5, + "raw_code_phase_samples": 18309, + "raw_doppler_hz": 1750.0, + "metrics": { + "peak_to_second": 6.90341613239865, + "peak_to_mean": 27.54425543885342, + "peak_to_sidelobe": 8.877838662393293, + "cfar": 59.373205541870576, + "noise_mean": 199537650.1549647, + "noise_median": 185090556.71506774, + "noise_std": 89451553.10970132, + "second_peak": 796144387.0872731 + }, + "side_peak_suspected": false, + "expected_prn": 1, + "expected_doppler_hz": 1828.0, + "raw_doppler_error_hz": -78.0, + "interpolated_doppler_error_hz": -51.356483129709886, + "expected_code_phase_samples": null, + "code_phase_error_samples": null, + "processing_time_s": 0.0624623160038027, + "overflow_count": 0 + } + }, + "tracking": { + "E1B": { + "epoch_count": 1088, + "tail_epoch_count": 200, + "final_state": "PLL_TRACK", + "reacquisition_epoch_count": 0, + "loss_of_lock_flag_count": 0, + "estimated_doppler_hz": 1828.251096939249, + "doppler_error_hz": 0.25109693924900967, + "doppler_jitter_hz_rms": 1.3255296914423045, + "dll_error_chips_rms": 0.09628370185540092, + "mean_prompt_magnitude": 22399.083674143894, + "mean_carrier_lock_metric": 0.9896417407083584, + "mean_code_lock_metric": 12.739000577793442, + "median_cn0_db_hz": 40.7319852224026 + }, + "E1C": { + "epoch_count": 544, + "tail_epoch_count": 200, + "final_state": "PLL_TRACK", + "reacquisition_epoch_count": 0, + "loss_of_lock_flag_count": 0, + "estimated_doppler_hz": 1828.2835216359945, + "doppler_error_hz": 0.2835216359944752, + "doppler_jitter_hz_rms": 1.0823023869896848, + "dll_error_chips_rms": 0.08491496617672437, + "mean_prompt_magnitude": 43800.06510755679, + "mean_carrier_lock_metric": 0.9933108801959688, + "mean_code_lock_metric": 12.868774234261869, + "median_cn0_db_hz": 39.005575872111315 + } + }, + "e1b_symbols": { + "status": "raw_symbols_recovered_page_decoding_not_implemented", + "comparison_scope": "known_10_symbol_INAV_sync_words_only", + "recovered_symbols": 469, + "compared_symbols": 20, + "initial_ignored_tracking_epochs": 150, + "initial_ignored_time_s": 0.3, + "bounded_alignment": "code boundary from PCPS; sync search within remaining 1.876 s", + "sync_matches": [ + { + "symbol_index": 190, + "sign_inverted": false, + "errors": 0 + }, + { + "symbol_index": 440, + "sign_inverted": false, + "errors": 0 + } + ], + "symbol_errors": 0, + "symbol_error_rate": 0.0, + "median_soft_symbol_magnitude": 43846.682818616624, + "frame_or_page_sync": true, + "fec_decoding": "not_implemented", + "crc_validation": "not_implemented" + }, + "e1c_secondary": { + "phase": 10, + "residual_frequency_hz": 52.0, + "normalized_correlation": 0.9830279116196488, + "compared_periods": 100, + "sign_errors": 0, + "sequence_source": "/home/onur/Desktop/galileo/signalsim/SignalSim/src/PilotBit.cpp", + "sequence_classification": "SignalSim_implemented_sequence_not_independently_certified_against_ICD", + "wiped_25_period_coherent_gain_db": 13.719295519016415, + "unwiped_25_period_coherent_gain_db": -0.863467276962604, + "wipeoff_gain_improvement_db": 14.582762795979018 + }, + "fixed_point_acquisition": { + "reference_dtype": "complex128/float64", + "input_format": { + "total_bits": 10, + "fractional_bits": 8 + }, + "fft_boundary_format": { + "total_bits": 32, + "fractional_bits": 20 + }, + "power_format": { + "total_bits": 64, + "fractional_bits": 32, + "signed": false + }, + "reference_doppler_bin_hz": 2000.0, + "fixed_doppler_bin_hz": 2000.0, + "reference_code_phase_samples": 18310, + "fixed_code_phase_samples": 18310, + "doppler_bin_agreement": true, + "code_phase_agreement": true, + "acquisition_decision_agreement": true, + "normalized_absolute_error_mean": 0.0015404047604332905, + "normalized_rms_error": 0.0021864998265414934, + "normalized_peak_error": 0.01995720714695684, + "overflow_count": 0 + }, + "rtl_vector_manifest": { + "stem": "signalsim_acquisition_input_iq8", + "sample_count": 163680, + "total_bits": 8, + "fractional_bits": 0, + "signed": true, + "encoding": "two_complement_hex", + "i_file": "signalsim_acquisition_input_iq8_i.hex", + "q_file": "signalsim_acquisition_input_iq8_q.hex", + "overflow_count": 0, + "sampling_frequency_hz": 8184000.0, + "sample_period_s": 1.2218963831867057e-07, + "timestamp_file": "signalsim_acquisition_input_iq8_timestamps.csv", + "register_values": { + "SAMPLE_WIDTH": 8, + "SAMPLE_FRACTIONAL_BITS": 0, + "INPUT_REPRESENTATION": "COMPLEX_IQ", + "IQ_ORDER": "IQ", + "SAMPLING_FREQUENCY_HZ": 8184000.0, + "INTERMEDIATE_FREQUENCY_HZ": 2046000.0, + "PRN": 1, + "COMPONENT": "E1C", + "ACQ_DOPPLER_MIN_HZ": 1000.0, + "ACQ_DOPPLER_MAX_HZ": 2500.0, + "ACQ_DOPPLER_STEP_HZ": 250.0, + "ACQ_COHERENT_SAMPLES": 32736, + "ACQ_NONCOHERENT_COUNT": 5, + "ACQ_FFT_LENGTH": 32736, + "TRACK_E1B_INTEGRATION_SAMPLES": 16368, + "TRACK_E1C_INTEGRATION_SAMPLES": 32736, + "CARRIER_NCO_PHASE_WIDTH": 32, + "CARRIER_NCO_FCW": 1074701159, + "CODE_NCO_PHASE_WIDTH": 48, + "CODE_NCO_FCW": 35184412914156, + "E1C_SECONDARY_PHASE": 10, + "ACQ_TO_TRACK_CODE_PHASE_SAMPLES": 18309.255563640225, + "ACQ_TO_TRACK_DOPPLER_HZ": 1828.0, + "NOTE": "Symbolic names; APB addresses remain to be assigned by the RTL IP register map" + } + }, + "pass_criteria": { + "maximum_raw_doppler_error_hz": 250.0, + "maximum_tracking_doppler_error_hz": 10.0, + "maximum_tracking_doppler_jitter_hz": 5.0, + "maximum_dll_rms_chips": 0.15, + "minimum_secondary_correlation": 0.9, + "maximum_secondary_sign_errors": 0, + "minimum_inav_sync_words": 2 + }, + "passes": { + "acquisition": true, + "e1b_tracking": true, + "e1b_raw_symbol_sync": true, + "e1c_secondary_sync": true, + "e1c_tracking": true, + "fixed_point_acquisition": true + }, + "overall_pass": true, + "known_failures": [ + "SignalSim E1 output cannot select E1B-only or E1C-only", + "SignalSim E1 uses BOC(1,1), not full CBOC", + "SignalSim has no native real-IF output", + "SignalSim IQ2 writer omits the output-byte assignment at this commit", + "Full I/NAV receiver-side FEC/page/CRC decoding is not implemented", + "SignalSim does not emit exact sample-side code-delay or navigation-symbol truth", + "Full multi-vector regression is not generated yet" + ] +} diff --git a/reports/signalsim_validation/signalsim_validation_report.md b/reports/signalsim_validation/signalsim_validation_report.md new file mode 100644 index 0000000..9da9805 --- /dev/null +++ b/reports/signalsim_validation/signalsim_validation_report.md @@ -0,0 +1,17 @@ +# SignalSim acquisition and tracking validation + +Overall quick-mode result: **PASS** + +| Check | Result | +|---|---:| +| acquisition | PASS | +| e1b_tracking | PASS | +| e1b_raw_symbol_sync | PASS | +| e1c_secondary_sync | PASS | +| e1c_tracking | PASS | +| fixed_point_acquisition | PASS | + +E1B tracking Doppler: 1828.251 Hz (error +0.251 Hz). +E1C tracking Doppler: 1828.284 Hz (error +0.284 Hz). +E1C secondary phase: 10; normalized correlation 0.9830; sign errors 0/100. +E1B exact I/NAV sync words: 2. Full page decoding is not implemented. diff --git a/reports/signalsim_validation/tracking_e1b.png b/reports/signalsim_validation/tracking_e1b.png new file mode 100644 index 0000000..549af7d Binary files /dev/null and b/reports/signalsim_validation/tracking_e1b.png differ diff --git a/reports/signalsim_validation/tracking_e1c.png b/reports/signalsim_validation/tracking_e1c.png new file mode 100644 index 0000000..5e94b52 Binary files /dev/null and b/reports/signalsim_validation/tracking_e1c.png differ diff --git a/reports/signalsim_validation_full/acquisition_map_e1b.png b/reports/signalsim_validation_full/acquisition_map_e1b.png new file mode 100644 index 0000000..d80b1f9 Binary files /dev/null and b/reports/signalsim_validation_full/acquisition_map_e1b.png differ diff --git a/reports/signalsim_validation_full/acquisition_map_e1c.png b/reports/signalsim_validation_full/acquisition_map_e1c.png new file mode 100644 index 0000000..2ee848b Binary files /dev/null and b/reports/signalsim_validation_full/acquisition_map_e1c.png differ diff --git a/reports/signalsim_validation_full/acquisition_peak_neighborhood_e1b.png b/reports/signalsim_validation_full/acquisition_peak_neighborhood_e1b.png new file mode 100644 index 0000000..d8d71c1 Binary files /dev/null and b/reports/signalsim_validation_full/acquisition_peak_neighborhood_e1b.png differ diff --git a/reports/signalsim_validation_full/acquisition_peak_neighborhood_e1c.png b/reports/signalsim_validation_full/acquisition_peak_neighborhood_e1c.png new file mode 100644 index 0000000..6241663 Binary files /dev/null and b/reports/signalsim_validation_full/acquisition_peak_neighborhood_e1c.png differ diff --git a/reports/signalsim_validation_full/acquisition_profiles_e1b.png b/reports/signalsim_validation_full/acquisition_profiles_e1b.png new file mode 100644 index 0000000..7e63906 Binary files /dev/null and b/reports/signalsim_validation_full/acquisition_profiles_e1b.png differ diff --git a/reports/signalsim_validation_full/acquisition_profiles_e1c.png b/reports/signalsim_validation_full/acquisition_profiles_e1c.png new file mode 100644 index 0000000..533144a Binary files /dev/null and b/reports/signalsim_validation_full/acquisition_profiles_e1c.png differ diff --git a/reports/signalsim_validation_full/prompt_constellation_e1b.png b/reports/signalsim_validation_full/prompt_constellation_e1b.png new file mode 100644 index 0000000..167674a Binary files /dev/null and b/reports/signalsim_validation_full/prompt_constellation_e1b.png differ diff --git a/reports/signalsim_validation_full/prompt_constellation_e1c.png b/reports/signalsim_validation_full/prompt_constellation_e1c.png new file mode 100644 index 0000000..ceae330 Binary files /dev/null and b/reports/signalsim_validation_full/prompt_constellation_e1c.png differ diff --git a/reports/signalsim_validation_full/rtl_vectors/signalsim_acquisition_input_iq8.json b/reports/signalsim_validation_full/rtl_vectors/signalsim_acquisition_input_iq8.json new file mode 100644 index 0000000..2aa868d --- /dev/null +++ b/reports/signalsim_validation_full/rtl_vectors/signalsim_acquisition_input_iq8.json @@ -0,0 +1,40 @@ +{ + "stem": "signalsim_acquisition_input_iq8", + "sample_count": 163680, + "total_bits": 8, + "fractional_bits": 0, + "signed": true, + "encoding": "two_complement_hex", + "i_file": "signalsim_acquisition_input_iq8_i.hex", + "q_file": "signalsim_acquisition_input_iq8_q.hex", + "overflow_count": 0, + "sampling_frequency_hz": 8184000.0, + "sample_period_s": 1.2218963831867057e-07, + "timestamp_file": "signalsim_acquisition_input_iq8_timestamps.csv", + "register_values": { + "SAMPLE_WIDTH": 8, + "SAMPLE_FRACTIONAL_BITS": 0, + "INPUT_REPRESENTATION": "COMPLEX_IQ", + "IQ_ORDER": "IQ", + "SAMPLING_FREQUENCY_HZ": 8184000.0, + "INTERMEDIATE_FREQUENCY_HZ": 2046000.0, + "PRN": 1, + "COMPONENT": "E1C", + "ACQ_DOPPLER_MIN_HZ": 1000.0, + "ACQ_DOPPLER_MAX_HZ": 2500.0, + "ACQ_DOPPLER_STEP_HZ": 250.0, + "ACQ_COHERENT_SAMPLES": 32736, + "ACQ_NONCOHERENT_COUNT": 5, + "ACQ_FFT_LENGTH": 32736, + "TRACK_E1B_INTEGRATION_SAMPLES": 16368, + "TRACK_E1C_INTEGRATION_SAMPLES": 32736, + "CARRIER_NCO_PHASE_WIDTH": 32, + "CARRIER_NCO_FCW": 1074701159, + "CODE_NCO_PHASE_WIDTH": 48, + "CODE_NCO_FCW": 35184412914156, + "E1C_SECONDARY_PHASE": 10, + "ACQ_TO_TRACK_CODE_PHASE_SAMPLES": 18309.255563640225, + "ACQ_TO_TRACK_DOPPLER_HZ": 1828.0, + "NOTE": "Symbolic names; APB addresses remain to be assigned by the RTL IP register map" + } +} \ No newline at end of file diff --git a/reports/signalsim_validation_full/signalsim_e1b_symbol_comparison.json b/reports/signalsim_validation_full/signalsim_e1b_symbol_comparison.json new file mode 100644 index 0000000..2c34245 --- /dev/null +++ b/reports/signalsim_validation_full/signalsim_e1b_symbol_comparison.json @@ -0,0 +1,27 @@ +{ + "status": "raw_symbols_recovered_page_decoding_not_implemented", + "comparison_scope": "known_10_symbol_INAV_sync_words_only", + "recovered_symbols": 469, + "compared_symbols": 20, + "initial_ignored_tracking_epochs": 150, + "initial_ignored_time_s": 0.3, + "bounded_alignment": "code boundary from PCPS; sync search within remaining 1.876 s", + "sync_matches": [ + { + "symbol_index": 190, + "sign_inverted": false, + "errors": 0 + }, + { + "symbol_index": 440, + "sign_inverted": false, + "errors": 0 + } + ], + "symbol_errors": 0, + "symbol_error_rate": 0.0, + "median_soft_symbol_magnitude": 43846.682818616624, + "frame_or_page_sync": true, + "fec_decoding": "not_implemented", + "crc_validation": "not_implemented" +} diff --git a/reports/signalsim_validation_full/signalsim_validation_report.json b/reports/signalsim_validation_full/signalsim_validation_report.json new file mode 100644 index 0000000..e5a0f81 --- /dev/null +++ b/reports/signalsim_validation_full/signalsim_validation_report.json @@ -0,0 +1,545 @@ +{ + "simulator": { + "name": "SignalSim", + "path": "/home/onur/Desktop/galileo/signalsim/SignalSim", + "remote": "https://github.com/globsky/SignalSim", + "commit": "80b262113e77c4482ca9f5cbfef9bcada402ed28", + "git_status": "## main...origin/main" + }, + "vector": { + "name": "high_cn0_single_prn", + "samples": "verification/vectors/signalsim/high_cn0_single_prn/high_cn0_single_prn_iq8.bin", + "generator_config": "verification/vectors/signalsim/high_cn0_single_prn/config.json", + "sample_format": "IQ8", + "sampling_frequency_hz": 8184000.0, + "intermediate_frequency_hz": 2046000.0, + "duration_s": 2.2, + "prn": 1, + "expected_doppler_hz": 1828.0, + "expected_doppler_source": "SignalSim_validate_only_summary_rounded_to_integer_hz", + "expected_code_phase_samples": null, + "path": "/home/onur/Desktop/galileo/verification/vectors/signalsim/high_cn0_single_prn/high_cn0_single_prn_iq8.bin", + "size_bytes": 36009600, + "complex_sample_count": 18004800, + "sha256": "3e72a5ad2d35ee68273b77587a66847b0fb328650b39c9e39272f0dc54e39e3c", + "internal_dtype": "complex128" + }, + "mode": "full", + "acquisition": { + "E1B": { + "detected": true, + "prn": 1, + "component": "E1B", + "code_phase_samples": 18309.41036755577, + "code_phase_chips": 2288.6762959444713, + "doppler_hz": 1766.3593126265423, + "carrier_frequency_hz": 2047766.3593126265, + "peak_power": 5441370632.908489, + "second_peak_power": 724471887.0812238, + "detection_metric": 7.510809915386589, + "estimated_cn0_db_hz": 38.1685032317465, + "coherent_integration_ms": 4.0, + "noncoherent_accumulations": 5, + "raw_code_phase_samples": 18309, + "raw_doppler_hz": 1750.0, + "metrics": { + "peak_to_second": 7.510809915386589, + "peak_to_mean": 27.236766759293843, + "peak_to_sidelobe": 9.98985090978008, + "cfar": 59.10585279263198, + "noise_mean": 199780344.01061067, + "noise_median": 186242777.1487124, + "noise_std": 88910448.07689284, + "second_peak": 724471887.0812238 + }, + "side_peak_suspected": false, + "expected_prn": 1, + "expected_doppler_hz": 1828.0, + "raw_doppler_error_hz": -78.0, + "interpolated_doppler_error_hz": -61.640687373457695, + "expected_code_phase_samples": null, + "code_phase_error_samples": null, + "processing_time_s": 0.06212109600164695, + "overflow_count": 0 + }, + "E1C": { + "detected": true, + "prn": 1, + "component": "E1C", + "code_phase_samples": 18309.255563640225, + "code_phase_chips": 2288.656945455028, + "doppler_hz": 1776.6435168702901, + "carrier_frequency_hz": 2047776.6435168702, + "peak_power": 5496116005.536917, + "second_peak_power": 796144387.0872731, + "detection_metric": 6.90341613239865, + "estimated_cn0_db_hz": 38.21910556645223, + "coherent_integration_ms": 4.0, + "noncoherent_accumulations": 5, + "raw_code_phase_samples": 18309, + "raw_doppler_hz": 1750.0, + "metrics": { + "peak_to_second": 6.90341613239865, + "peak_to_mean": 27.54425543885342, + "peak_to_sidelobe": 8.877838662393293, + "cfar": 59.373205541870576, + "noise_mean": 199537650.1549647, + "noise_median": 185090556.71506774, + "noise_std": 89451553.10970132, + "second_peak": 796144387.0872731 + }, + "side_peak_suspected": false, + "expected_prn": 1, + "expected_doppler_hz": 1828.0, + "raw_doppler_error_hz": -78.0, + "interpolated_doppler_error_hz": -51.356483129709886, + "expected_code_phase_samples": null, + "code_phase_error_samples": null, + "processing_time_s": 0.052345254000101704, + "overflow_count": 0 + } + }, + "tracking": { + "E1B": { + "epoch_count": 1088, + "tail_epoch_count": 200, + "final_state": "PLL_TRACK", + "reacquisition_epoch_count": 0, + "loss_of_lock_flag_count": 0, + "estimated_doppler_hz": 1828.251096939249, + "doppler_error_hz": 0.25109693924900967, + "doppler_jitter_hz_rms": 1.3255296914423045, + "dll_error_chips_rms": 0.09628370185540092, + "mean_prompt_magnitude": 22399.083674143894, + "mean_carrier_lock_metric": 0.9896417407083584, + "mean_code_lock_metric": 12.739000577793442, + "median_cn0_db_hz": 40.7319852224026 + }, + "E1C": { + "epoch_count": 544, + "tail_epoch_count": 200, + "final_state": "PLL_TRACK", + "reacquisition_epoch_count": 0, + "loss_of_lock_flag_count": 0, + "estimated_doppler_hz": 1828.2835216359945, + "doppler_error_hz": 0.2835216359944752, + "doppler_jitter_hz_rms": 1.0823023869896848, + "dll_error_chips_rms": 0.08491496617672437, + "mean_prompt_magnitude": 43800.06510755679, + "mean_carrier_lock_metric": 0.9933108801959688, + "mean_code_lock_metric": 12.868774234261869, + "median_cn0_db_hz": 39.005575872111315 + } + }, + "e1b_symbols": { + "status": "raw_symbols_recovered_page_decoding_not_implemented", + "comparison_scope": "known_10_symbol_INAV_sync_words_only", + "recovered_symbols": 469, + "compared_symbols": 20, + "initial_ignored_tracking_epochs": 150, + "initial_ignored_time_s": 0.3, + "bounded_alignment": "code boundary from PCPS; sync search within remaining 1.876 s", + "sync_matches": [ + { + "symbol_index": 190, + "sign_inverted": false, + "errors": 0 + }, + { + "symbol_index": 440, + "sign_inverted": false, + "errors": 0 + } + ], + "symbol_errors": 0, + "symbol_error_rate": 0.0, + "median_soft_symbol_magnitude": 43846.682818616624, + "frame_or_page_sync": true, + "fec_decoding": "not_implemented", + "crc_validation": "not_implemented" + }, + "e1c_secondary": { + "phase": 10, + "residual_frequency_hz": 52.0, + "normalized_correlation": 0.9830279116196488, + "compared_periods": 100, + "sign_errors": 0, + "sequence_source": "/home/onur/Desktop/galileo/signalsim/SignalSim/src/PilotBit.cpp", + "sequence_classification": "SignalSim_implemented_sequence_not_independently_certified_against_ICD", + "wiped_25_period_coherent_gain_db": 13.719295519016415, + "unwiped_25_period_coherent_gain_db": -0.863467276962604, + "wipeoff_gain_improvement_db": 14.582762795979018 + }, + "fixed_point_acquisition": { + "reference_dtype": "complex128/float64", + "input_format": { + "total_bits": 10, + "fractional_bits": 8 + }, + "fft_boundary_format": { + "total_bits": 32, + "fractional_bits": 20 + }, + "power_format": { + "total_bits": 64, + "fractional_bits": 32, + "signed": false + }, + "reference_doppler_bin_hz": 2000.0, + "fixed_doppler_bin_hz": 2000.0, + "reference_code_phase_samples": 18310, + "fixed_code_phase_samples": 18310, + "doppler_bin_agreement": true, + "code_phase_agreement": true, + "acquisition_decision_agreement": true, + "normalized_absolute_error_mean": 0.0015404047604332905, + "normalized_rms_error": 0.0021864998265414934, + "normalized_peak_error": 0.01995720714695684, + "overflow_count": 0 + }, + "rtl_vector_manifest": { + "stem": "signalsim_acquisition_input_iq8", + "sample_count": 163680, + "total_bits": 8, + "fractional_bits": 0, + "signed": true, + "encoding": "two_complement_hex", + "i_file": "signalsim_acquisition_input_iq8_i.hex", + "q_file": "signalsim_acquisition_input_iq8_q.hex", + "overflow_count": 0, + "sampling_frequency_hz": 8184000.0, + "sample_period_s": 1.2218963831867057e-07, + "timestamp_file": "signalsim_acquisition_input_iq8_timestamps.csv", + "register_values": { + "SAMPLE_WIDTH": 8, + "SAMPLE_FRACTIONAL_BITS": 0, + "INPUT_REPRESENTATION": "COMPLEX_IQ", + "IQ_ORDER": "IQ", + "SAMPLING_FREQUENCY_HZ": 8184000.0, + "INTERMEDIATE_FREQUENCY_HZ": 2046000.0, + "PRN": 1, + "COMPONENT": "E1C", + "ACQ_DOPPLER_MIN_HZ": 1000.0, + "ACQ_DOPPLER_MAX_HZ": 2500.0, + "ACQ_DOPPLER_STEP_HZ": 250.0, + "ACQ_COHERENT_SAMPLES": 32736, + "ACQ_NONCOHERENT_COUNT": 5, + "ACQ_FFT_LENGTH": 32736, + "TRACK_E1B_INTEGRATION_SAMPLES": 16368, + "TRACK_E1C_INTEGRATION_SAMPLES": 32736, + "CARRIER_NCO_PHASE_WIDTH": 32, + "CARRIER_NCO_FCW": 1074701159, + "CODE_NCO_PHASE_WIDTH": 48, + "CODE_NCO_FCW": 35184412914156, + "E1C_SECONDARY_PHASE": 10, + "ACQ_TO_TRACK_CODE_PHASE_SAMPLES": 18309.255563640225, + "ACQ_TO_TRACK_DOPPLER_HZ": 1828.0, + "NOTE": "Symbolic names; APB addresses remain to be assigned by the RTL IP register map" + } + }, + "full_vectors": { + "nominal_cn0_single_prn": { + "sha256": "ffbcb2cfedc6142ca3064514b4d2c10045abf0221462996a5fc10a49213bf88e", + "size_bytes": 9804432, + "acquisition": { + "1": { + "detected": true, + "prn": 1, + "component": "E1C", + "code_phase_samples": 18309.2493035726, + "code_phase_chips": 2288.656162946575, + "doppler_hz": 1773.307913756068, + "carrier_frequency_hz": 2047773.307913756, + "peak_power": 10585746841.368246, + "second_peak_power": 847781679.7040988, + "detection_metric": 12.48640669501491, + "estimated_cn0_db_hz": 41.10297101242409, + "coherent_integration_ms": 4.0, + "noncoherent_accumulations": 5, + "raw_code_phase_samples": 18309, + "raw_doppler_hz": 1750.0, + "metrics": { + "peak_to_second": 12.48640669501491, + "peak_to_mean": 52.56524583502863, + "peak_to_sidelobe": 16.06495209542566, + "cfar": 114.99687091212033, + "noise_mean": 201382998.8466272, + "noise_median": 186498684.67146492, + "noise_std": 90430705.41148725, + "second_peak": 847781679.7040988 + }, + "side_peak_suspected": false, + "expected_doppler_hz": 1828.0, + "raw_doppler_error_hz": -78.0, + "pass": true + } + }, + "tracking_e1c": { + "epoch_count": 144, + "tail_epoch_count": 144, + "final_state": "PLL_TRACK", + "reacquisition_epoch_count": 0, + "loss_of_lock_flag_count": 0, + "estimated_doppler_hz": 1828.0419570441315, + "doppler_error_hz": 0.04195704413154999, + "doppler_jitter_hz_rms": 3.917722043209542, + "dll_error_chips_rms": 0.08564197894938912, + "mean_prompt_magnitude": 60049.986617079274, + "mean_carrier_lock_metric": 0.8929102312320804, + "mean_code_lock_metric": 13.315864057778144, + "median_cn0_db_hz": 36.15381627805871, + "secondary_sync": { + "phase": 10, + "residual_frequency_hz": 55.5, + "normalized_correlation": 0.9946712168819579, + "compared_periods": 100, + "sign_errors": 0 + }, + "post_pull_in_reacquisition_epoch_count": 0, + "pass": true + } + }, + "negative_doppler": { + "sha256": "b17d2d1f180122041b2688f9a8b1facfc73ab99b521187908c88450d65ddc1ad", + "size_bytes": 36009600, + "acquisition": { + "27": { + "detected": true, + "prn": 27, + "component": "E1C", + "code_phase_samples": 12348.267448186096, + "code_phase_chips": 1543.533431023262, + "doppler_hz": -2674.9276245321194, + "carrier_frequency_hz": 2043325.072375468, + "peak_power": 24352646750.750618, + "second_peak_power": 945164158.8151873, + "detection_metric": 25.765520754910906, + "estimated_cn0_db_hz": 44.58325935097769, + "coherent_integration_ms": 4.0, + "noncoherent_accumulations": 5, + "raw_code_phase_samples": 12348, + "raw_doppler_hz": -2750.0, + "metrics": { + "peak_to_second": 25.765520754910906, + "peak_to_mean": 115.91743569126469, + "peak_to_sidelobe": 32.843535009708745, + "cfar": 254.8907938083333, + "noise_mean": 210086141.10142696, + "noise_median": 196188871.44995743, + "noise_std": 94771794.29817012, + "second_peak": 945164158.8151873 + }, + "side_peak_suspected": false, + "expected_doppler_hz": -2637.0, + "raw_doppler_error_hz": -113.0, + "pass": true + } + }, + "tracking_e1b": { + "epoch_count": 1089, + "tail_epoch_count": 200, + "final_state": "PLL_TRACK", + "reacquisition_epoch_count": 5, + "loss_of_lock_flag_count": 5, + "estimated_doppler_hz": -2637.776190851375, + "doppler_error_hz": -0.7761908513748494, + "doppler_jitter_hz_rms": 0.5305994248096336, + "dll_error_chips_rms": 0.06150522766847954, + "mean_prompt_magnitude": 55240.73688530836, + "mean_carrier_lock_metric": 0.9983333662736632, + "mean_code_lock_metric": 14.362780122257186, + "median_cn0_db_hz": 45.81367632112874, + "post_pull_in_reacquisition_epoch_count": 0, + "pass": true + } + }, + "multiple_prns": { + "sha256": "7c6f60e63e2574a4f79034d5a6e16ae954de49502df25b0b532090517ed434d8", + "size_bytes": 1947792, + "acquisition": { + "1": { + "detected": true, + "prn": 1, + "component": "E1C", + "code_phase_samples": 18309.253639126782, + "code_phase_chips": 2288.656704890848, + "doppler_hz": 1769.2706088921507, + "carrier_frequency_hz": 2047769.2706088922, + "peak_power": 32096057962.95762, + "second_peak_power": 1209538637.8968797, + "detection_metric": 26.53578559405557, + "estimated_cn0_db_hz": 44.99077757327253, + "coherent_integration_ms": 4.0, + "noncoherent_accumulations": 5, + "raw_code_phase_samples": 18309, + "raw_doppler_hz": 1750.0, + "metrics": { + "peak_to_second": 26.53578559405557, + "peak_to_mean": 127.22278220023618, + "peak_to_sidelobe": 33.26567281226302, + "cfar": 270.10885652568476, + "noise_mean": 252282314.5970945, + "noise_median": 232987645.09787288, + "noise_std": 117963811.80426, + "second_peak": 1209538637.8968797 + }, + "side_peak_suspected": false, + "expected_doppler_hz": 1828.0, + "raw_doppler_error_hz": -78.0, + "pass": true + }, + "4": { + "detected": true, + "prn": 4, + "component": "E1C", + "code_phase_samples": 9956.98938014865, + "code_phase_chips": 1244.6236725185813, + "doppler_hz": 2230.0939309134383, + "carrier_frequency_hz": 2048230.0939309134, + "peak_power": 37308607171.82941, + "second_peak_power": 1174375756.8562684, + "detection_metric": 31.768883982842297, + "estimated_cn0_db_hz": 45.62044968201952, + "coherent_integration_ms": 4.0, + "noncoherent_accumulations": 5, + "raw_code_phase_samples": 9957, + "raw_doppler_hz": 2250.0, + "metrics": { + "peak_to_second": 31.768883982842297, + "peak_to_mean": 146.9166866558231, + "peak_to_sidelobe": 40.25791358515722, + "cfar": 310.4480527804397, + "noise_mean": 253943973.42509538, + "noise_median": 234535851.2409831, + "noise_std": 119421175.26119119, + "second_peak": 1174375756.8562684 + }, + "side_peak_suspected": false, + "expected_doppler_hz": 2171.0, + "raw_doppler_error_hz": 79.0, + "pass": true + }, + "19": { + "detected": true, + "prn": 19, + "component": "E1C", + "code_phase_samples": 11452.996654723802, + "code_phase_chips": 1431.6245818404752, + "doppler_hz": -321.2206563080019, + "carrier_frequency_hz": 2045678.779343692, + "peak_power": 30643770880.483227, + "second_peak_power": 1130106003.827365, + "detection_metric": 27.115837608773887, + "estimated_cn0_db_hz": 44.810366937653406, + "coherent_integration_ms": 4.0, + "noncoherent_accumulations": 5, + "raw_code_phase_samples": 11453, + "raw_doppler_hz": -250.0, + "metrics": { + "peak_to_second": 27.115837608773887, + "peak_to_mean": 122.08676737545365, + "peak_to_sidelobe": 34.57235944504926, + "cfar": 257.90378471314983, + "noise_mean": 250999936.6781855, + "noise_median": 231773952.64053702, + "noise_std": 117919932.66662621, + "second_peak": 1130106003.827365 + }, + "side_peak_suspected": false, + "expected_doppler_hz": -367.0, + "raw_doppler_error_hz": 117.0, + "pass": true + }, + "21": { + "detected": true, + "prn": 21, + "component": "E1C", + "code_phase_samples": 23576.992428350735, + "code_phase_chips": 2947.124053543842, + "doppler_hz": -749.4303755817893, + "carrier_frequency_hz": 2045250.5696244182, + "peak_power": 61578536111.72763, + "second_peak_power": 1115177497.25106, + "detection_metric": 55.218596379069915, + "estimated_cn0_db_hz": 47.83859101459858, + "coherent_integration_ms": 4.0, + "noncoherent_accumulations": 5, + "raw_code_phase_samples": 23577, + "raw_doppler_hz": -750.0, + "metrics": { + "peak_to_second": 55.218596379069915, + "peak_to_mean": 244.175094203, + "peak_to_sidelobe": 71.06285060781445, + "cfar": 522.8149246569416, + "noise_mean": 252190078.24169424, + "noise_median": 233744033.19949394, + "noise_std": 117335579.35206439, + "second_peak": 1115177497.25106 + }, + "side_peak_suspected": false, + "expected_doppler_hz": -712.0, + "raw_doppler_error_hz": -38.0, + "pass": true + }, + "27": { + "detected": true, + "prn": 27, + "component": "E1C", + "code_phase_samples": 12348.261457074981, + "code_phase_chips": 1543.5326821343726, + "doppler_hz": -2667.442432329403, + "carrier_frequency_hz": 2043332.5575676707, + "peak_power": 23965115133.30752, + "second_peak_power": 1209176598.921569, + "detection_metric": 19.81936729066816, + "estimated_cn0_db_hz": 43.66095463640046, + "coherent_integration_ms": 4.0, + "noncoherent_accumulations": 5, + "raw_code_phase_samples": 12348, + "raw_doppler_hz": -2750.0, + "metrics": { + "peak_to_second": 19.81936729066816, + "peak_to_mean": 93.92989682313109, + "peak_to_sidelobe": 24.852227733911352, + "cfar": 198.45743284172485, + "noise_mean": 255138309.99339387, + "noise_median": 236239222.55910087, + "noise_std": 119566576.92772253, + "second_peak": 1209176598.921569 + }, + "side_peak_suspected": false, + "expected_doppler_hz": -2637.0, + "raw_doppler_error_hz": -113.0, + "pass": true + } + } + } + }, + "pass_criteria": { + "maximum_raw_doppler_error_hz": 250.0, + "maximum_tracking_doppler_error_hz": 10.0, + "maximum_tracking_doppler_jitter_hz": 5.0, + "maximum_dll_rms_chips": 0.15, + "minimum_secondary_correlation": 0.9, + "maximum_secondary_sign_errors": 0, + "minimum_inav_sync_words": 2 + }, + "passes": { + "acquisition": true, + "e1b_tracking": true, + "e1b_raw_symbol_sync": true, + "e1c_secondary_sync": true, + "e1c_tracking": true, + "fixed_point_acquisition": true, + "full_vector_set": true + }, + "overall_pass": true, + "known_failures": [ + "SignalSim E1 output cannot select E1B-only or E1C-only", + "SignalSim E1 uses BOC(1,1), not full CBOC", + "SignalSim has no native real-IF output", + "SignalSim IQ2 writer omits the output-byte assignment at this commit", + "Full I/NAV receiver-side FEC/page/CRC decoding is not implemented", + "SignalSim does not emit exact sample-side code-delay or navigation-symbol truth" + ] +} diff --git a/reports/signalsim_validation_full/signalsim_validation_report.md b/reports/signalsim_validation_full/signalsim_validation_report.md new file mode 100644 index 0000000..a9e734f --- /dev/null +++ b/reports/signalsim_validation_full/signalsim_validation_report.md @@ -0,0 +1,18 @@ +# SignalSim acquisition and tracking validation + +Overall full-mode result: **PASS** + +| Check | Result | +|---|---:| +| acquisition | PASS | +| e1b_tracking | PASS | +| e1b_raw_symbol_sync | PASS | +| e1c_secondary_sync | PASS | +| e1c_tracking | PASS | +| fixed_point_acquisition | PASS | +| full_vector_set | PASS | + +E1B tracking Doppler: 1828.251 Hz (error +0.251 Hz). +E1C tracking Doppler: 1828.284 Hz (error +0.284 Hz). +E1C secondary phase: 10; normalized correlation 0.9830; sign errors 0/100. +E1B exact I/NAV sync words: 2. Full page decoding is not implemented. diff --git a/reports/signalsim_validation_full/tracking_e1b.png b/reports/signalsim_validation_full/tracking_e1b.png new file mode 100644 index 0000000..549af7d Binary files /dev/null and b/reports/signalsim_validation_full/tracking_e1b.png differ diff --git a/reports/signalsim_validation_full/tracking_e1c.png b/reports/signalsim_validation_full/tracking_e1c.png new file mode 100644 index 0000000..5e94b52 Binary files /dev/null and b/reports/signalsim_validation_full/tracking_e1c.png differ diff --git a/src/galileo_rx/__init__.py b/src/galileo_rx/__init__.py new file mode 100644 index 0000000..3485c9b --- /dev/null +++ b/src/galileo_rx/__init__.py @@ -0,0 +1,8 @@ +"""Galileo receiver baseband reference model.""" + +from .config import ReceiverConfig, load_config +from .signal_profiles import SignalProfile, galileo_e1_profile + +__all__ = ["ReceiverConfig", "SignalProfile", "galileo_e1_profile", "load_config"] +__version__ = "0.1.0" + diff --git a/src/galileo_rx/acquisition/__init__.py b/src/galileo_rx/acquisition/__init__.py new file mode 100644 index 0000000..d52a009 --- /dev/null +++ b/src/galileo_rx/acquisition/__init__.py @@ -0,0 +1,7 @@ +"""FFT parallel code-phase acquisition.""" + +from .acquisition_manager import AcquisitionManager +from .fft_pcps import PcpsAcquisition, PcpsSearchGrid + +__all__ = ["AcquisitionManager", "PcpsAcquisition", "PcpsSearchGrid"] + diff --git a/src/galileo_rx/acquisition/acquisition_manager.py b/src/galileo_rx/acquisition/acquisition_manager.py new file mode 100644 index 0000000..0e4ffe8 --- /dev/null +++ b/src/galileo_rx/acquisition/acquisition_manager.py @@ -0,0 +1,67 @@ +"""PRN search orchestration and structured results.""" + +from __future__ import annotations + +import numpy as np +import numpy.typing as npt + +from galileo_rx.config import AcquisitionConfig +from galileo_rx.signal_profiles import SignalProfile +from galileo_rx.signals.code_provider import CodeProvider +from galileo_rx.types import AcquisitionResult +from .detector import boc_side_peak_suspected, parabolic_offset +from .fft_pcps import PcpsAcquisition, PcpsSearchGrid +from .metrics import calculate_metrics, estimate_acquisition_cn0_db_hz + + +class AcquisitionManager: + def __init__(self, profile: SignalProfile, provider: CodeProvider, sampling_frequency_hz: float, config: AcquisitionConfig) -> None: + self.profile = profile + self.sampling_frequency_hz = sampling_frequency_hz + self.config = config + self.engine = PcpsAcquisition(profile, provider, sampling_frequency_hz, config) + + def acquire_one(self, samples: npt.NDArray[np.generic], component: str, prn: int, carrier_center_hz: float = 0.0) -> tuple[AcquisitionResult, PcpsSearchGrid]: + grid = self.engine.search(samples, component, prn, carrier_center_hz) + raw_doppler_index, raw_code_index = np.unravel_index(int(np.argmax(grid.power)), grid.power.shape) + profile = grid.power[raw_doppler_index] + samples_per_chip = self.sampling_frequency_hz / self.profile.code_rate_hz + guard = max(1, int(np.ceil(self.config.peak_exclusion_chips * samples_per_chip))) + metrics = calculate_metrics(profile, raw_code_index, guard) + selected_metric = metrics[self.config.metric] + code_offset = 0.0 + doppler_offset = 0.0 + if self.config.parabolic_interpolation: + code_offset = parabolic_offset(profile[(raw_code_index - 1) % profile.size], profile[raw_code_index], profile[(raw_code_index + 1) % profile.size]) + if 0 < raw_doppler_index < grid.doppler_bins_hz.size - 1: + doppler_offset = parabolic_offset( + grid.power[raw_doppler_index - 1, raw_code_index], + grid.power[raw_doppler_index, raw_code_index], + grid.power[raw_doppler_index + 1, raw_code_index], + ) + code_samples = float((raw_code_index + code_offset) % profile.size) + doppler_hz = float(grid.doppler_bins_hz[raw_doppler_index] + doppler_offset * self.config.doppler_step_hz) + cn0 = estimate_acquisition_cn0_db_hz(float(profile[raw_code_index]), metrics["noise_mean"], self.config.coherent_integration_ms * 1e-3) + result = AcquisitionResult( + detected=selected_metric >= self.config.detection_threshold, + prn=prn, + component=component, + code_phase_samples=code_samples, + code_phase_chips=code_samples / samples_per_chip, + doppler_hz=doppler_hz, + carrier_frequency_hz=carrier_center_hz + doppler_hz, + peak_power=float(profile[raw_code_index]), + second_peak_power=metrics["second_peak"], + detection_metric=selected_metric, + estimated_cn0_db_hz=cn0, + coherent_integration_ms=self.config.coherent_integration_ms, + noncoherent_accumulations=self.config.noncoherent_accumulations, + raw_code_phase_samples=int(raw_code_index), + raw_doppler_hz=float(grid.doppler_bins_hz[raw_doppler_index]), + metrics=metrics, + side_peak_suspected=boc_side_peak_suspected(profile, raw_code_index, samples_per_chip), + ) + return result, grid + + def acquire(self, samples: npt.NDArray[np.generic], component: str, prns: list[int], carrier_center_hz: float = 0.0) -> list[AcquisitionResult]: + return [self.acquire_one(samples, component, prn, carrier_center_hz)[0] for prn in prns] diff --git a/src/galileo_rx/acquisition/detector.py b/src/galileo_rx/acquisition/detector.py new file mode 100644 index 0000000..99d0541 --- /dev/null +++ b/src/galileo_rx/acquisition/detector.py @@ -0,0 +1,21 @@ +"""Detection decision and sub-bin interpolation.""" + +from __future__ import annotations + +import numpy as np + + +def parabolic_offset(left: float, center: float, right: float) -> float: + denominator = left - 2.0 * center + right + if denominator == 0: + return 0.0 + return float(np.clip(0.5 * (left - right) / denominator, -0.5, 0.5)) + + +def boc_side_peak_suspected(profile: np.ndarray, peak_index: int, samples_per_chip: float) -> bool: + """Flag a competitive BOC(1,1) half-chip neighbor; no false unambiguous claim.""" + offset = max(1, int(round(0.5 * samples_per_chip))) + peak = profile[peak_index] + neighbors = [profile[(peak_index - offset) % profile.size], profile[(peak_index + offset) % profile.size]] + return bool(max(neighbors) > 0.75 * peak) + diff --git a/src/galileo_rx/acquisition/fft_pcps.py b/src/galileo_rx/acquisition/fft_pcps.py new file mode 100644 index 0000000..2342d47 --- /dev/null +++ b/src/galileo_rx/acquisition/fft_pcps.py @@ -0,0 +1,138 @@ +"""Readable floating-point FFT parallel code-phase search.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np +import numpy.typing as npt + +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 + + +@dataclass(slots=True) +class PcpsSearchGrid: + doppler_bins_hz: npt.NDArray[np.float64] + power: npt.NDArray[np.float64] + complex_sums: npt.NDArray[np.complex128] | None + coherent_samples: int + fft_length: int + + +class PcpsAcquisition: + def __init__(self, profile: SignalProfile, provider: CodeProvider, sampling_frequency_hz: float, config: AcquisitionConfig) -> None: + self.profile = profile + self.provider = provider + 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]] = {} + + @property + def coherent_samples(self) -> int: + exact = self.sampling_frequency_hz * self.config.coherent_integration_ms * 1e-3 + rounded = int(round(exact)) + if not np.isclose(exact, rounded, atol=1e-6): + raise ValueError("coherent integration time is not an integer number of samples") + return rounded + + def _fft_length(self, sample_count: int) -> int: + if self.config.fft_length is not None: + if self.config.fft_length < sample_count: + raise ValueError("fft_length cannot be shorter than coherent block") + return self.config.fft_length + if self.config.zero_padding_mode == "next_power_of_two": + return 1 << (sample_count - 1).bit_length() + return sample_count + + def _replica_fft(self, component: str, prn: int, sample_count: int, fft_length: int, primary_signs: tuple[int, ...], doppler_hz: float) -> npt.NDArray[np.complex128]: + 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) + 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, + ) + if primary_signs != (1,): + primary_samples = int(round(self.profile.component(component).primary_code_period_s * self.sampling_frequency_hz)) + signs = np.repeat(np.asarray(primary_signs, dtype=np.float64), primary_samples) + replica = replica * np.resize(signs, sample_count) + self._code_fft_cache[key] = np.conjugate(np.fft.fft(replica, fft_length)) + return self._code_fft_cache[key] + + def _sign_hypotheses(self, component: str, prn: int, primary_periods: int, first_primary_epoch: int) -> list[tuple[int, ...]]: + spec = self.profile.component(component) + if primary_periods <= 1: + return [(1,)] + if spec.channel_type == "pilot" and self.config.secondary_code_mode in {"wipeoff", "search"}: + secondary = self.provider.secondary_code(self.profile, component, prn) + if secondary is None: + raise ValueError("secondary-code handling requested but provider returned no secondary code") + phases = range(secondary.size) if self.config.secondary_code_mode == "search" else range(1) + return [tuple(int(secondary[(phase + first_primary_epoch + epoch) % secondary.size]) for epoch in range(primary_periods)) for phase in phases] + if spec.channel_type == "data" and self.config.bit_transition_mode == "hypothesis": + if primary_periods > 8: + raise ValueError("bit-transition hypothesis search is limited to 8 primary periods") + return [tuple([1] + [1 if (mask >> bit) & 1 else -1 for bit in range(primary_periods - 1)]) for mask in range(1 << (primary_periods - 1))] + return [(1,) * primary_periods] + + def search(self, samples: npt.NDArray[np.generic], component: str, prn: int, carrier_center_hz: float = 0.0) -> PcpsSearchGrid: + array = np.asarray(samples, dtype=np.complex128) + coherent_samples = self.coherent_samples + required = coherent_samples * self.config.noncoherent_accumulations + if array.size < required: + raise ValueError(f"acquisition needs {required} samples, received {array.size}") + fft_length = self._fft_length(coherent_samples) + primary_samples = int(round(self.profile.component(component).primary_code_period_s * self.sampling_frequency_hz)) + primary_periods_exact = coherent_samples / primary_samples + primary_periods = int(round(primary_periods_exact)) + if not np.isclose(primary_periods_exact, primary_periods, atol=1e-9): + raise ValueError("coherent integration must contain a whole number of primary-code periods") + bins = np.arange( + self.config.doppler_min_hz, + self.config.doppler_max_hz + 0.5 * self.config.doppler_step_hz, + self.config.doppler_step_hz, + dtype=np.float64, + ) + # Code delay is periodic over one primary code even when coherent time + # spans several signed primary epochs. Keeping one period prevents an + # artificial duplicate peak from corrupting the second-peak metric. + code_phase_bins = primary_samples + power = np.zeros((bins.size, code_phase_bins), dtype=np.float64) + complex_sums = np.zeros((bins.size, code_phase_bins), dtype=np.complex128) if self.config.preserve_complex_sums else None + time_s = np.arange(coherent_samples, dtype=np.float64) / self.sampling_frequency_hz + batch_size = 1 if self.config.parallelism == "sequential" else max(1, self.config.doppler_batch_size) + for first_bin in range(0, bins.size, batch_size): + batch_bins = bins[first_bin : first_bin + batch_size] + carrier = np.exp(-2j * np.pi * (carrier_center_hz + batch_bins[:, None]) * time_s[None, :]) + previous_correlation: npt.NDArray[np.complex128] | None = None + for accumulation in range(self.config.noncoherent_accumulations): + start = accumulation * coherent_samples + # Absolute sample time contributes only a common phase per coherent block, + # which magnitude-squared noncoherent accumulation intentionally discards. + mixed = array[start : start + coherent_samples][None, :] * carrier + sample_fft = np.fft.fft(mixed, fft_length, axis=1) + hypotheses = self._sign_hypotheses(component, prn, primary_periods, accumulation * primary_periods) + code_ffts = np.asarray([[ + self._replica_fft(component, prn, coherent_samples, fft_length, signs, float(doppler_hz)) + for signs in hypotheses + ] for doppler_hz in batch_bins]) + stack = np.fft.ifft(sample_fft[:, None, :] * code_ffts, axis=-1)[:, :, :code_phase_bins] + if stack.shape[1] == 1: + correlation = stack[:, 0, :] + else: + winners = np.argmax(np.abs(stack) ** 2, axis=1) + correlation = np.take_along_axis(stack, winners[:, None, :], axis=1)[:, 0, :] + if self.config.bit_transition_mode == "differential" and previous_correlation is not None: + power[first_bin : first_bin + batch_bins.size] += np.abs(correlation * np.conjugate(previous_correlation)) + else: + power[first_bin : first_bin + batch_bins.size] += np.abs(correlation) ** 2 + if complex_sums is not None: + complex_sums[first_bin : first_bin + batch_bins.size] += correlation + previous_correlation = correlation + return PcpsSearchGrid(bins, power, complex_sums, coherent_samples, fft_length) diff --git a/src/galileo_rx/acquisition/metrics.py b/src/galileo_rx/acquisition/metrics.py new file mode 100644 index 0000000..c596350 --- /dev/null +++ b/src/galileo_rx/acquisition/metrics.py @@ -0,0 +1,41 @@ +"""Acquisition peak and noise metrics.""" + +from __future__ import annotations + +import numpy as np +import numpy.typing as npt + + +def circular_guard_mask(length: int, peak_index: int, guard_samples: int) -> npt.NDArray[np.bool_]: + indices = np.arange(length) + circular_distance = np.minimum((indices - peak_index) % length, (peak_index - indices) % length) + return circular_distance > guard_samples + + +def calculate_metrics(power: npt.NDArray[np.float64], peak_index: int, guard_samples: int) -> dict[str, float]: + valid = circular_guard_mask(power.size, peak_index, guard_samples) + noise = power[valid] + peak = float(power[peak_index]) + second = float(np.max(noise)) if noise.size else 0.0 + mean = float(np.mean(noise)) if noise.size else 0.0 + median = float(np.median(noise)) if noise.size else 0.0 + std = float(np.std(noise)) if noise.size else 0.0 + epsilon = np.finfo(np.float64).tiny + return { + "peak_to_second": peak / max(second, epsilon), + "peak_to_mean": peak / max(mean, epsilon), + "peak_to_sidelobe": (peak - mean) / max(second - mean, epsilon), + "cfar": (peak - median) / max(std, epsilon), + "noise_mean": mean, + "noise_median": median, + "noise_std": std, + "second_peak": second, + } + + +def estimate_acquisition_cn0_db_hz(peak_power: float, noise_power: float, coherent_time_s: float) -> float | None: + signal_power = peak_power - noise_power + if signal_power <= 0 or noise_power <= 0 or coherent_time_s <= 0: + return None + return float(10.0 * np.log10(signal_power / noise_power / coherent_time_s)) + diff --git a/src/galileo_rx/cli.py b/src/galileo_rx/cli.py new file mode 100644 index 0000000..231a3ba --- /dev/null +++ b/src/galileo_rx/cli.py @@ -0,0 +1,189 @@ +"""Command-line entry points for generation, acquisition, tracking, and comparison.""" + +from __future__ import annotations + +import argparse +import json +import logging +from dataclasses import asdict +from pathlib import Path + +import numpy as np + +from galileo_rx.acquisition.acquisition_manager import AcquisitionManager +from galileo_rx.config import load_config +from galileo_rx.diagnostics.reports import export_json, export_tracking_csv +from galileo_rx.fixed_point.bittrue_pipeline import compare_acquisition +from galileo_rx.fixed_point.fixed_acquisition import FixedPcpsAcquisition +from galileo_rx.frontend.frontend_chain import FrontendChain +from galileo_rx.io.sample_reader import read_samples +from galileo_rx.io.sample_writer import write_samples +from galileo_rx.signal_profiles import get_signal_profile +from galileo_rx.signals.code_provider import SyntheticCodeProvider +from galileo_rx.signals.galileo_e1 import e1_local_replica +from galileo_rx.signals.signal_generator import ChannelModelConfig, SatelliteSignalConfig, SyntheticSignalGenerator +from galileo_rx.tracking.tracking_channel import TrackingChannel +from galileo_rx.types import AcquisitionResult +from galileo_rx.verification.signalsim_validation import locate_signalsim, validate_signalsim + +LOGGER = logging.getLogger("galileo_rx") + + +def _load_and_frontend(config_path: str, input_path: str): + config = load_config(config_path) + raw = read_samples(input_path, config.rf.sample_format, config.rf.sample_endianness) + frontend = FrontendChain(config.rf, config.frontend) + return config, frontend.process(raw), frontend.output_sampling_frequency_hz + + +def _result_from_json(path: str) -> AcquisitionResult: + raw = json.loads(Path(path).read_text(encoding="utf-8")) + if isinstance(raw, list): + detected = [item for item in raw if item.get("detected")] + if not detected: + raise ValueError("acquisition result file contains no detected result") + raw = detected[0] + return AcquisitionResult(**raw) + + +def command_generate(args: argparse.Namespace) -> None: + config = load_config(args.config) + profile = get_signal_profile(config.signal_profile) + provider = SyntheticCodeProvider() + satellite = SatelliteSignalConfig( + prn=args.prn or config.prns[0], component=config.component, code_delay_chips=args.code_delay_chips, + doppler_hz=args.doppler_hz, doppler_rate_hz_s=args.doppler_rate_hz_s, + carrier_phase_rad=args.carrier_phase_rad, amplitude=args.amplitude, cn0_db_hz=args.cn0_db_hz, + ) + channel = ChannelModelConfig(seed=args.seed, quantization_bits=args.quantization_bits, clip_amplitude=args.clip_amplitude) + generator = SyntheticSignalGenerator( + profile, provider, config.rf.sampling_frequency_hz, config.rf.intermediate_frequency_hz, + config.rf.input_is_real_if, [satellite], channel, + ) + sample_count = int(round(args.duration_ms * 1e-3 * config.rf.sampling_frequency_hz)) + samples, truth = generator.generate(sample_count) + write_samples(args.output, samples, config.rf.sample_endianness) + export_json(str(args.output) + ".truth.json", truth) + LOGGER.info("wrote %d samples and truth metadata", sample_count) + + +def command_acquire(args: argparse.Namespace) -> None: + config, samples, fs = _load_and_frontend(args.config, args.input) + profile = get_signal_profile(config.signal_profile) + manager = AcquisitionManager(profile, SyntheticCodeProvider(), fs, config.acquisition) + results = manager.acquire(samples, config.component, config.prns) + payload = [asdict(result) for result in results] + text = json.dumps(payload, indent=2) + print(text) + if args.output: + Path(args.output).write_text(text, encoding="utf-8") + + +def command_track(args: argparse.Namespace) -> None: + config, samples, fs = _load_and_frontend(args.config, args.input) + acquisition = _result_from_json(args.acquisition_result) + channel = TrackingChannel(get_signal_profile(config.signal_profile), SyntheticCodeProvider(), fs, config.tracking, acquisition, args.start_sample) + measurements = channel.process(samples[args.start_sample :]) + if args.output: + export_tracking_csv(args.output, measurements) + if measurements: + print(json.dumps(asdict(measurements[-1]), indent=2, default=str)) + + +def command_run(args: argparse.Namespace) -> None: + config, samples, fs = _load_and_frontend(args.config, args.input) + profile = get_signal_profile(config.signal_profile) + provider = SyntheticCodeProvider() + acquisition_manager = AcquisitionManager(profile, provider, fs, config.acquisition) + results = acquisition_manager.acquire(samples, config.component, config.prns) + detected = [result for result in results if result.detected] + if not detected: + raise SystemExit("no configured PRN passed the acquisition threshold") + consumed = acquisition_manager.engine.coherent_samples * config.acquisition.noncoherent_accumulations + summary = [] + for result in detected: + channel = TrackingChannel(profile, provider, fs, config.tracking, result, consumed) + measurements = channel.process(samples[consumed:]) + summary.append({"acquisition": asdict(result), "last_tracking": asdict(measurements[-1]) if measurements else None}) + print(json.dumps(summary, indent=2, default=str)) + + +def command_compare_fixed(args: argparse.Namespace) -> None: + config, samples, fs = _load_and_frontend(args.config, args.input) + profile = get_signal_profile(config.signal_profile) + provider = SyntheticCodeProvider() + engine = AcquisitionManager(profile, provider, fs, config.acquisition) + result, grid = engine.acquire_one(samples, config.component, config.prns[0]) + count = engine.engine.coherent_samples + replica = e1_local_replica(profile, provider, config.component, config.prns[0], count, fs) + fixed = FixedPcpsAcquisition().search(samples[:count], replica, fs, grid.doppler_bins_hz) + reference = grid.power / config.acquisition.noncoherent_accumulations / count**2 + comparison = compare_acquisition(reference, fixed, config.acquisition.detection_threshold) + print(json.dumps({"float_result": asdict(result), "comparison": comparison.to_dict()}, indent=2)) + + +def command_validate_signalsim(args: argparse.Namespace) -> None: + source = Path(args.signalsim_path).resolve() if args.signalsim_path else locate_signalsim(Path.cwd()) + report, passed = validate_signalsim(source, args.config, args.output_dir, mode=args.mode) + print(json.dumps({"overall_pass": passed, "passes": report["passes"], "report": str(Path(args.output_dir).resolve() / "signalsim_validation_report.json")}, indent=2)) + if not passed: + raise SystemExit(1) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="galileo-rx") + parser.add_argument("--log-level", default="INFO") + commands = parser.add_subparsers(dest="command", required=True) + generate = commands.add_parser("generate") + generate.add_argument("--config", required=True) + generate.add_argument("--output", required=True) + generate.add_argument("--duration-ms", type=float, default=100.0) + generate.add_argument("--prn", type=int) + generate.add_argument("--code-delay-chips", type=float, default=100.0) + generate.add_argument("--doppler-hz", type=float, default=1500.0) + generate.add_argument("--doppler-rate-hz-s", type=float, default=0.0) + generate.add_argument("--carrier-phase-rad", type=float, default=0.0) + generate.add_argument("--amplitude", type=float, default=0.5) + generate.add_argument("--cn0-db-hz", type=float, default=48.0) + generate.add_argument("--seed", type=int, default=1) + generate.add_argument("--quantization-bits", type=int) + generate.add_argument("--clip-amplitude", type=float) + generate.set_defaults(handler=command_generate) + acquire = commands.add_parser("acquire") + acquire.add_argument("--config", required=True) + acquire.add_argument("--input", required=True) + acquire.add_argument("--output") + acquire.set_defaults(handler=command_acquire) + track = commands.add_parser("track") + track.add_argument("--config", required=True) + track.add_argument("--input", required=True) + track.add_argument("--acquisition-result", required=True) + track.add_argument("--start-sample", type=int, default=0) + track.add_argument("--output") + track.set_defaults(handler=command_track) + run = commands.add_parser("run") + run.add_argument("--config", required=True) + run.add_argument("--input", required=True) + run.set_defaults(handler=command_run) + compare = commands.add_parser("compare-fixed") + compare.add_argument("--config", required=True) + compare.add_argument("--input", required=True) + compare.set_defaults(handler=command_compare_fixed) + validate = commands.add_parser("validate-signalsim") + validate.add_argument("--signalsim-path") + validate.add_argument("--config", default="configs/signalsim_validation.yaml") + validate.add_argument("--output-dir", default="reports/signalsim_validation") + validate.add_argument("--mode", choices=["quick", "full"]) + validate.set_defaults(handler=command_validate_signalsim) + return parser + + +def main(argv: list[str] | None = None) -> None: + parser = build_parser() + args = parser.parse_args(argv) + logging.basicConfig(level=getattr(logging, args.log_level.upper()), format="%(levelname)s %(name)s: %(message)s") + args.handler(args) + + +if __name__ == "__main__": + main() diff --git a/src/galileo_rx/config.py b/src/galileo_rx/config.py new file mode 100644 index 0000000..98dbfd7 --- /dev/null +++ b/src/galileo_rx/config.py @@ -0,0 +1,177 @@ +"""Typed configuration and strict YAML loading.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field, fields +from pathlib import Path +from typing import Any, Literal, TypeVar + +import yaml + +T = TypeVar("T") + + +@dataclass(slots=True) +class RfConfig: + sample_format: str = "complex128" + input_is_real_if: bool = False + sampling_frequency_hz: float = 16.368e6 + intermediate_frequency_hz: float = 4.092e6 + sample_bit_width: int = 8 + sample_endianness: Literal["little", "big"] = "little" + + def validate(self) -> None: + if self.sampling_frequency_hz <= 0: + raise ValueError("sampling_frequency_hz must be positive") + if self.input_is_real_if and not 0 < self.intermediate_frequency_hz < self.sampling_frequency_hz / 2: + raise ValueError("real-IF intermediate_frequency_hz must lie between DC and Nyquist") + if not 2 <= self.sample_bit_width <= 32: + raise ValueError("sample_bit_width must be in [2, 32]") + + +@dataclass(slots=True) +class AcquisitionConfig: + doppler_min_hz: float = -10_000.0 + doppler_max_hz: float = 10_000.0 + doppler_step_hz: float = 250.0 + coherent_integration_ms: float = 4.0 + noncoherent_accumulations: int = 5 + detection_threshold: float = 2.5 + peak_exclusion_chips: float = 1.0 + fft_length: int | None = None + zero_padding_mode: Literal["none", "next_power_of_two", "explicit"] = "next_power_of_two" + metric: Literal["peak_to_second", "peak_to_mean", "cfar"] = "peak_to_second" + parallelism: Literal["sequential", "doppler_vectorized", "prn_vectorized", "batched"] = "sequential" + doppler_batch_size: int = 8 + preserve_complex_sums: bool = False + bit_transition_mode: Literal["none", "hypothesis", "differential"] = "none" + secondary_code_mode: Literal["none", "wipeoff", "search"] = "none" + parabolic_interpolation: bool = True + boc_side_peak_strategy: Literal["flag", "prefer_main", "none"] = "flag" + code_doppler_aiding: bool = True + + def validate(self) -> None: + 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: + raise ValueError("integration settings must be positive") + if self.peak_exclusion_chips <= 0 or self.detection_threshold <= 0: + raise ValueError("detector parameters must be positive") + if self.zero_padding_mode == "explicit" and self.fft_length is None: + raise ValueError("explicit zero padding requires fft_length") + + +@dataclass(slots=True) +class TrackingConfig: + integration_ms: float = 4.0 + early_late_spacing_chips: float = 0.25 + very_early_late_spacing_chips: float = 0.75 + dll_noise_bandwidth_hz: float = 1.0 + pll_noise_bandwidth_hz: float = 15.0 + fll_noise_bandwidth_hz: float = 20.0 + pll_order: int = 3 + dll_order: int = 2 + use_fll_assist: bool = True + pull_in_duration_ms: float = 500.0 + damping_ratio: float = 0.707 + carrier_aiding: bool = True + secondary_code_mode: Literal["none", "wipeoff", "search"] = "wipeoff" + loss_of_lock_epochs: int = 10 + weak_signal_cn0_db_hz: float = 25.0 + + def validate(self) -> None: + 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: + raise ValueError("tap spacings must satisfy 0 < E/L < VE/VL") + if self.pll_order not in (2, 3) or self.dll_order not in (1, 2): + raise ValueError("supported PLL orders are 2/3 and DLL orders are 1/2") + if min(self.dll_noise_bandwidth_hz, self.pll_noise_bandwidth_hz, self.fll_noise_bandwidth_hz) <= 0: + raise ValueError("loop noise bandwidths must be positive") + + +@dataclass(slots=True) +class FrontendConfig: + remove_dc: bool = False + dc_alpha: float = 0.999 + agc_enabled: bool = False + agc_target_rms: float = 0.5 + fir_taps: list[float] = field(default_factory=list) + decimation: int = 1 + nco_phase_width: int = 32 + nco_lut_bits: int = 12 + + +@dataclass(slots=True) +class FixedPointConfig: + enabled: bool = False + input_total_bits: int = 8 + input_fractional_bits: int = 7 + coefficient_total_bits: int = 16 + coefficient_fractional_bits: int = 15 + accumulator_total_bits: int = 32 + accumulator_fractional_bits: int = 20 + overflow: Literal["saturate", "wrap"] = "saturate" + rounding: Literal["nearest", "truncate", "convergent"] = "nearest" + + +@dataclass(slots=True) +class ReceiverConfig: + signal_profile: str = "GALILEO_E1" + component: Literal["E1B", "E1C"] = "E1C" + prns: list[int] = field(default_factory=lambda: [1]) + rf: RfConfig = field(default_factory=RfConfig) + acquisition: AcquisitionConfig = field(default_factory=AcquisitionConfig) + tracking: TrackingConfig = field(default_factory=TrackingConfig) + frontend: FrontendConfig = field(default_factory=FrontendConfig) + fixed_point: FixedPointConfig = field(default_factory=FixedPointConfig) + + def validate(self) -> None: + 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": + raise ValueError("E1B coherent integration beyond 4 ms requires a bit-transition mode") + if self.component == "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": + 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") + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +def _construct(cls: type[T], values: dict[str, Any] | None) -> T: + values = values or {} + known = {item.name for item in fields(cls)} + unknown = set(values) - known + if unknown: + raise ValueError(f"unknown {cls.__name__} fields: {sorted(unknown)}") + return cls(**values) + + +def load_config(path: str | Path) -> ReceiverConfig: + with Path(path).open("r", encoding="utf-8") as stream: + raw = yaml.safe_load(stream) or {} + known = {item.name for item in fields(ReceiverConfig)} + unknown = set(raw) - known + if unknown: + raise ValueError(f"unknown ReceiverConfig fields: {sorted(unknown)}") + config = ReceiverConfig( + signal_profile=raw.get("signal_profile", "GALILEO_E1"), + component=raw.get("component", "E1C"), + prns=raw.get("prns", [1]), + rf=_construct(RfConfig, raw.get("rf")), + acquisition=_construct(AcquisitionConfig, raw.get("acquisition")), + tracking=_construct(TrackingConfig, raw.get("tracking")), + frontend=_construct(FrontendConfig, raw.get("frontend")), + fixed_point=_construct(FixedPointConfig, raw.get("fixed_point")), + ) + config.validate() + return config diff --git a/src/galileo_rx/diagnostics/__init__.py b/src/galileo_rx/diagnostics/__init__.py new file mode 100644 index 0000000..43aea21 --- /dev/null +++ b/src/galileo_rx/diagnostics/__init__.py @@ -0,0 +1,2 @@ +"""Optional plots and machine-readable reports.""" + diff --git a/src/galileo_rx/diagnostics/plots.py b/src/galileo_rx/diagnostics/plots.py new file mode 100644 index 0000000..2be1c29 --- /dev/null +++ b/src/galileo_rx/diagnostics/plots.py @@ -0,0 +1,96 @@ +"""Headless-safe diagnostics; matplotlib is imported only on demand.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + + +def acquisition_map(power: np.ndarray, doppler_bins_hz: np.ndarray, title: str = "Acquisition map") -> Any: + import matplotlib.pyplot as plt + + figure, axis = plt.subplots() + image = axis.imshow(power, aspect="auto", origin="lower", extent=[0, power.shape[1], doppler_bins_hz[0], doppler_bins_hz[-1]]) + axis.set(xlabel="Code phase (samples)", ylabel="Doppler (Hz)", title=title) + figure.colorbar(image, ax=axis, label="Correlation power") + return figure + + +def acquisition_profiles(power: np.ndarray, doppler_bins_hz: np.ndarray) -> Any: + import matplotlib.pyplot as plt + + doppler_index, code_index = np.unravel_index(int(np.argmax(power)), power.shape) + figure, axes = plt.subplots(1, 2) + axes[0].plot(power[doppler_index]) + axes[0].set(xlabel="Code phase (samples)", ylabel="Power") + axes[1].plot(doppler_bins_hz, power[:, code_index]) + axes[1].set(xlabel="Doppler (Hz)", ylabel="Power") + return figure + + +def acquisition_peak_neighborhood(power: np.ndarray, samples_per_chip: float, span_chips: float = 2.0) -> Any: + """Circular peak neighborhood including the BOC half-chip region.""" + import matplotlib.pyplot as plt + + doppler_index, code_index = np.unravel_index(int(np.argmax(power)), power.shape) + radius = max(1, int(np.ceil(span_chips * samples_per_chip))) + offsets = np.arange(-radius, radius + 1) + values = power[doppler_index, (code_index + offsets) % power.shape[1]] + figure, axis = plt.subplots() + axis.plot(offsets / samples_per_chip, values / np.max(values), marker=".") + axis.axvline(-0.5, color="tab:red", linestyle="--", alpha=0.5) + axis.axvline(0.5, color="tab:red", linestyle="--", alpha=0.5) + axis.set(xlabel="Offset from peak (chips)", ylabel="Normalized power", title="Peak and BOC side-peak neighborhood") + return figure + + +def prompt_constellation(measurements: list[Any], ignored_epochs: int = 0) -> Any: + import matplotlib.pyplot as plt + + selected = measurements[ignored_epochs:] + figure, axis = plt.subplots() + axis.scatter([item.prompt_i for item in selected], [item.prompt_q for item in selected], s=5, alpha=0.5) + axis.set(xlabel="Prompt I", ylabel="Prompt Q", title="Prompt correlator constellation") + axis.set_aspect("equal", adjustable="datalim") + return figure + + +def tracking_diagnostics(measurements: list[Any]) -> Any: + import matplotlib.pyplot as plt + + time_s = [item.time_s for item in measurements] + figure, axes = plt.subplots(4, 2, sharex=True) + series = [ + ("Prompt I/Q", [item.prompt_i for item in measurements], [item.prompt_q for item in measurements]), + ("Early/Prompt/Late magnitudes", [item.early_magnitude for item in measurements], [np.hypot(item.prompt_i, item.prompt_q) for item in measurements], [item.late_magnitude for item in measurements]), + ("PLL (rad)", [item.pll_error_rad for item in measurements], None), + ("FLL (Hz)", [item.fll_error_hz for item in measurements], None), + ("DLL (chips)", [item.dll_error_chips for item in measurements], None), + ("Carrier frequency (Hz)", [item.carrier_frequency_hz for item in measurements], None), + ("C/N0 (dB-Hz)", [np.nan if item.cn0_db_hz is None else item.cn0_db_hz for item in measurements], None), + ("Lock metrics", [item.carrier_lock_metric for item in measurements], [item.code_lock_metric for item in measurements]), + ] + for axis, item in zip(axes.flat, series): + title, first, *additional = item + axis.plot(time_s, first) + for second in additional: + if second is not None: + axis.plot(time_s, second) + axis.set_title(title) + axes[-1, 0].set_xlabel("Time (s)") + axes[-1, 1].set_xlabel("Time (s)") + figure.tight_layout() + return figure + + +def fixed_error(reference: np.ndarray, fixed: np.ndarray, overflow_indices: np.ndarray | None = None) -> Any: + import matplotlib.pyplot as plt + + figure, axis = plt.subplots() + axis.plot(np.asarray(fixed) - np.asarray(reference), label="fixed - float") + if overflow_indices is not None and overflow_indices.size: + axis.scatter(overflow_indices, np.zeros_like(overflow_indices), marker="x", label="overflow") + axis.set(xlabel="Sample", ylabel="Error", title="Fixed-point error") + axis.legend() + return figure diff --git a/src/galileo_rx/diagnostics/reports.py b/src/galileo_rx/diagnostics/reports.py new file mode 100644 index 0000000..5208514 --- /dev/null +++ b/src/galileo_rx/diagnostics/reports.py @@ -0,0 +1,32 @@ +"""Diagnostics export without a plotting dependency.""" + +from __future__ import annotations + +import csv +import json +from dataclasses import asdict, is_dataclass +from pathlib import Path +from typing import Any + +import numpy as np + + +def export_json(path: str | Path, value: Any) -> None: + payload = asdict(value) if is_dataclass(value) else value + Path(path).write_text(json.dumps(payload, indent=2, default=str), encoding="utf-8") + + +def export_tracking_csv(path: str | Path, measurements: list[Any]) -> None: + if not measurements: + Path(path).write_text("", encoding="utf-8") + return + rows = [asdict(item) for item in measurements] + with Path(path).open("w", newline="", encoding="utf-8") as stream: + writer = csv.DictWriter(stream, fieldnames=list(rows[0])) + writer.writeheader() + writer.writerows(rows) + + +def export_numpy(path: str | Path, **arrays: np.ndarray) -> None: + np.savez(Path(path), **arrays) + diff --git a/src/galileo_rx/fixed_point/__init__.py b/src/galileo_rx/fixed_point/__init__.py new file mode 100644 index 0000000..1a2967c --- /dev/null +++ b/src/galileo_rx/fixed_point/__init__.py @@ -0,0 +1,7 @@ +"""Bit-true signed fixed-point models.""" + +from .formats import FixedFormat, OverflowMode, RoundingMode +from .fixed_fft import FixedRadix2Fft +from .quantizer import Quantizer + +__all__ = ["FixedFormat", "OverflowMode", "RoundingMode", "Quantizer", "FixedRadix2Fft"] diff --git a/src/galileo_rx/fixed_point/bittrue_pipeline.py b/src/galileo_rx/fixed_point/bittrue_pipeline.py new file mode 100644 index 0000000..fd65923 --- /dev/null +++ b/src/galileo_rx/fixed_point/bittrue_pipeline.py @@ -0,0 +1,65 @@ +"""Numerical comparison and word-length sweep utilities.""" + +from __future__ import annotations + +from dataclasses import dataclass, asdict + +import numpy as np +import numpy.typing as npt + +from .fixed_acquisition import FixedPcpsAcquisition +from .formats import FixedFormat + + +@dataclass(slots=True) +class NumericComparison: + absolute_error_mean: float + relative_error_mean: float + rms_error: float + peak_error: float + acquisition_decision_agreement: bool | None = None + doppler_bin_agreement: bool | None = None + code_phase_agreement: bool | None = None + overflow_count: int = 0 + + def to_dict(self) -> dict[str, float | bool | None]: + return asdict(self) + + +def compare_arrays(reference: npt.ArrayLike, fixed: npt.ArrayLike, overflow_count: int = 0) -> NumericComparison: + reference_array = np.asarray(reference, dtype=np.float64) + fixed_array = np.asarray(fixed, dtype=np.float64) + error = fixed_array - reference_array + absolute = np.abs(error) + relative = absolute / np.maximum(np.abs(reference_array), np.finfo(float).eps) + return NumericComparison(float(np.mean(absolute)), float(np.mean(relative)), float(np.sqrt(np.mean(error**2))), float(np.max(absolute)), overflow_count=overflow_count) + + +def compare_acquisition(reference_power: npt.ArrayLike, fixed_grid, threshold: float) -> NumericComparison: + comparison = compare_arrays(reference_power, fixed_grid.power, fixed_grid.overflow_count) + reference_peak = np.unravel_index(int(np.argmax(reference_power)), np.asarray(reference_power).shape) + reference_metric = float(np.max(reference_power) / max(np.mean(reference_power), np.finfo(float).tiny)) + fixed_metric = float(np.max(fixed_grid.power) / max(np.mean(fixed_grid.power), np.finfo(float).tiny)) + comparison.acquisition_decision_agreement = bool((reference_metric >= threshold) == (fixed_metric >= threshold)) + comparison.doppler_bin_agreement = bool(reference_peak[0] == fixed_grid.peak_doppler_index) + comparison.code_phase_agreement = bool(reference_peak[1] == fixed_grid.peak_code_index) + return comparison + + +def sweep_acquisition_word_lengths(samples: npt.ArrayLike, replica: npt.ArrayLike, sampling_frequency_hz: float, doppler_bins_hz: npt.ArrayLike, bit_widths: list[int]) -> list[dict[str, float | int | bool | None]]: + array = np.asarray(samples, dtype=np.complex128) + code = np.asarray(replica) + time_s = np.arange(array.size) / sampling_frequency_hz + bins = np.asarray(doppler_bins_hz) + reference = np.asarray([ + np.abs(np.fft.ifft(np.fft.fft(array * np.exp(-2j * np.pi * doppler * time_s)) * np.conjugate(np.fft.fft(code)))) ** 2 + for doppler in bins + ]) + reports = [] + for bits in bit_widths: + model = FixedPcpsAcquisition(FixedFormat(bits, bits - 2), FixedFormat(bits + 8, bits), FixedFormat(48, 16, signed=False)) + grid = model.search(array, code, sampling_frequency_hz, bins) + report = compare_acquisition(reference, grid, 2.5).to_dict() + report["input_bits"] = bits + reports.append(report) + return reports diff --git a/src/galileo_rx/fixed_point/fixed_acquisition.py b/src/galileo_rx/fixed_point/fixed_acquisition.py new file mode 100644 index 0000000..ffa0e69 --- /dev/null +++ b/src/galileo_rx/fixed_point/fixed_acquisition.py @@ -0,0 +1,69 @@ +"""Quantized PCPS boundary model with explicit FFT scaling.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np +import numpy.typing as npt + +from .formats import FixedFormat, OverflowMode, RoundingMode +from .quantizer import Quantizer, dequantize_complex, quantize_complex + + +@dataclass(slots=True) +class FixedAcquisitionGrid: + power: npt.NDArray[np.float64] + peak_doppler_index: int + peak_code_index: int + overflow_count: int + + +class FixedPcpsAcquisition: + """Bit-true I/O/product/accumulator model around a normalized FFT primitive. + + The transform primitive models an FFT IP configured with deterministic 1/N + scaling. Vendor-specific butterfly truncation is intentionally delegated to + the cycle/RTL FFT model selected during implementation. + """ + + def __init__( + self, + sample_format: FixedFormat = FixedFormat(10, 8), + fft_format: FixedFormat = FixedFormat(20, 12), + power_format: FixedFormat = FixedFormat(48, 16, signed=False), + overflow: OverflowMode | str = OverflowMode.SATURATE, + rounding: RoundingMode | str = RoundingMode.NEAREST, + ) -> None: + self.input_q = Quantizer(sample_format, overflow, rounding) + self.fft_q = Quantizer(fft_format, overflow, rounding) + self.power_q = Quantizer(power_format, overflow, rounding) + self.sample_format = sample_format + self.fft_format = fft_format + + def search(self, samples: npt.ArrayLike, local_replica: npt.ArrayLike, sampling_frequency_hz: float, doppler_bins_hz: npt.ArrayLike) -> FixedAcquisitionGrid: + array = np.asarray(samples, dtype=np.complex128) + replica = np.asarray(local_replica, dtype=np.float64) + if array.size != replica.size: + raise ValueError("fixed acquisition samples and replica must have equal length") + in_r, in_i = quantize_complex(array, self.input_q) + quantized_samples = dequantize_complex(in_r, in_i, self.sample_format) + code_i = self.input_q.quantize(replica) + code = self.input_q.dequantize(code_i) + code_spectrum = np.conjugate(np.fft.fft(code) / array.size) + time_s = np.arange(array.size) / sampling_frequency_hz + bins = np.asarray(doppler_bins_hz, dtype=np.float64) + power = np.empty((bins.size, array.size), dtype=np.float64) + for index, doppler_hz in enumerate(bins): + mixed = quantized_samples * np.exp(-2j * np.pi * doppler_hz * time_s) + spectrum = np.fft.fft(mixed) / array.size + sr, si = quantize_complex(spectrum, self.fft_q) + spectrum_q = dequantize_complex(sr, si, self.fft_format) + product = spectrum_q * code_spectrum + pr, pi = quantize_complex(product, self.fft_q) + correlation = np.fft.ifft(dequantize_complex(pr, pi, self.fft_format)) * array.size + power[index] = self.power_q.dequantize(self.power_q.quantize(np.abs(correlation) ** 2)) + peak = np.unravel_index(int(np.argmax(power)), power.shape) + overflow_count = self.input_q.statistics.overflow_count + self.fft_q.statistics.overflow_count + self.power_q.statistics.overflow_count + return FixedAcquisitionGrid(power, int(peak[0]), int(peak[1]), overflow_count) + diff --git a/src/galileo_rx/fixed_point/fixed_correlator.py b/src/galileo_rx/fixed_point/fixed_correlator.py new file mode 100644 index 0000000..468368f --- /dev/null +++ b/src/galileo_rx/fixed_point/fixed_correlator.py @@ -0,0 +1,62 @@ +"""Integer mixer and correlator accumulation model.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np +import numpy.typing as npt + +from .fixed_nco import FixedNco +from .fixed_mixer import complex_mix_down +from .formats import FixedFormat, OverflowMode, RoundingMode +from .quantizer import Quantizer, quantize_complex + + +@dataclass(slots=True) +class FixedCorrelationResult: + prompt_real: int + prompt_imag: int + fractional_bits: int + overflow_count: int + + @property + def complex_value(self) -> complex: + return complex(self.prompt_real, self.prompt_imag) / (1 << self.fractional_bits) + + +class FixedCorrelator: + def __init__( + self, + sampling_frequency_hz: float, + carrier_frequency_hz: float, + sample_format: FixedFormat = FixedFormat(8, 7), + nco_format: FixedFormat = FixedFormat(16, 15), + accumulator_format: FixedFormat = FixedFormat(40, 22), + overflow: OverflowMode | str = OverflowMode.SATURATE, + rounding: RoundingMode | str = RoundingMode.NEAREST, + ) -> None: + self.sample_quantizer = Quantizer(sample_format, overflow, rounding) + self.nco = FixedNco(sampling_frequency_hz, carrier_frequency_hz, output_format=nco_format) + self.accumulator_quantizer = Quantizer(accumulator_format, overflow, rounding) + self.sample_format = sample_format + self.nco_format = nco_format + self.accumulator_format = accumulator_format + + def correlate_prompt(self, samples: npt.ArrayLike, replica: npt.ArrayLike) -> FixedCorrelationResult: + array = np.asarray(samples, dtype=np.complex128) + local = np.asarray(replica, dtype=np.int64) + if array.size != local.size or not set(np.unique(local).tolist()) <= {-1, 1}: + raise ValueError("replica must be a same-length +/-1 sequence") + sample_real, sample_imag = quantize_complex(array, self.sample_quantizer) + cosine, sine = self.nco.generate(array.size) + mixer_format = FixedFormat( + min(62, self.sample_format.total_bits + self.nco_format.total_bits + 1), + self.sample_format.fractional_bits + self.nco_format.fractional_bits, + ) + mixed = complex_mix_down(sample_real, sample_imag, cosine, sine, self.sample_format, self.nco_format, mixer_format) + real_sum = int(np.sum(mixed.real * local, dtype=object)) + imag_sum = int(np.sum(mixed.imag * local, dtype=object)) + real_q = int(self.accumulator_quantizer.requantize_integer([real_sum], mixer_format.fractional_bits)[0]) + imag_q = int(self.accumulator_quantizer.requantize_integer([imag_sum], mixer_format.fractional_bits)[0]) + return FixedCorrelationResult(real_q, imag_q, self.accumulator_format.fractional_bits, self.accumulator_quantizer.statistics.overflow_count) diff --git a/src/galileo_rx/fixed_point/fixed_fft.py b/src/galileo_rx/fixed_point/fixed_fft.py new file mode 100644 index 0000000..631a9dd --- /dev/null +++ b/src/galileo_rx/fixed_point/fixed_fft.py @@ -0,0 +1,100 @@ +"""Radix-2 stage-quantized FFT/IFFT oracle for RTL verification.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np +import numpy.typing as npt + +from .formats import FixedFormat, OverflowMode, RoundingMode +from .quantizer import Quantizer, dequantize_complex, quantize_complex + + +@dataclass(slots=True) +class FixedFftResult: + real: npt.NDArray[np.int64] + imag: npt.NDArray[np.int64] + fixed_format: FixedFormat + overflow_count: int + + def dequantize(self) -> npt.NDArray[np.complex128]: + return dequantize_complex(self.real, self.imag, self.fixed_format) + + +class FixedRadix2Fft: + """Decimation-in-time FFT with one-bit scaling at every stage. + + Forward output equals ``numpy.fft.fft(x) / N`` apart from declared + coefficient and butterfly quantization. The inverse uses the conjugation + identity, so it has NumPy IFFT scaling. + """ + + def __init__( + self, + data_format: FixedFormat = FixedFormat(20, 15), + twiddle_format: FixedFormat = FixedFormat(16, 15), + overflow: OverflowMode | str = OverflowMode.SATURATE, + rounding: RoundingMode | str = RoundingMode.CONVERGENT, + ) -> None: + self.data_format = data_format + self.twiddle_format = twiddle_format + self.overflow = overflow + self.rounding = rounding + self.data_quantizer = Quantizer(data_format, overflow, rounding) + self.twiddle_quantizer = Quantizer(twiddle_format, overflow, rounding) + + @staticmethod + def _bit_reversed_indices(length: int) -> npt.NDArray[np.int64]: + bits = length.bit_length() - 1 + return np.asarray([int(f"{index:0{bits}b}"[::-1], 2) for index in range(length)], dtype=np.int64) + + def _forward_integers(self, real: npt.NDArray[np.int64], imag: npt.NDArray[np.int64]) -> FixedFftResult: + length = real.size + if length == 0 or length & (length - 1): + raise ValueError("fixed radix-2 FFT length must be a nonzero power of two") + order = self._bit_reversed_indices(length) + out_real = real[order].astype(np.int64, copy=True) + out_imag = imag[order].astype(np.int64, copy=True) + butterfly_q = Quantizer(self.data_format, self.overflow, self.rounding) + size = 2 + while size <= length: + half = size // 2 + angle = -2.0 * np.pi * np.arange(half) / size + twiddle_real = self.twiddle_quantizer.quantize(np.cos(angle)) + twiddle_imag = self.twiddle_quantizer.quantize(np.sin(angle)) + product_fraction = self.data_format.fractional_bits + self.twiddle_format.fractional_bits + for first in range(0, length, size): + upper = slice(first, first + half) + lower = slice(first + half, first + size) + u_real = out_real[upper].copy() + u_imag = out_imag[upper].copy() + lower_real = out_real[lower].copy() + lower_imag = out_imag[lower].copy() + t_real = butterfly_q.requantize_integer(lower_real * twiddle_real - lower_imag * twiddle_imag, product_fraction) + t_imag = butterfly_q.requantize_integer(lower_real * twiddle_imag + lower_imag * twiddle_real, product_fraction) + # One-bit scale per stage bounds growth; source fractional+1 + # makes the integer requantizer perform a rounded divide by two. + out_real[upper] = butterfly_q.requantize_integer(u_real + t_real, self.data_format.fractional_bits + 1) + out_imag[upper] = butterfly_q.requantize_integer(u_imag + t_imag, self.data_format.fractional_bits + 1) + out_real[lower] = butterfly_q.requantize_integer(u_real - t_real, self.data_format.fractional_bits + 1) + out_imag[lower] = butterfly_q.requantize_integer(u_imag - t_imag, self.data_format.fractional_bits + 1) + size *= 2 + # Q1.(W-1) maps the mathematical +1 twiddle to its maximum positive + # code at table-build time; that coefficient approximation is not a + # runtime datapath overflow. + overflow_count = ( + self.data_quantizer.statistics.overflow_count + + butterfly_q.statistics.overflow_count + ) + return FixedFftResult(out_real, out_imag, self.data_format, overflow_count) + + def forward(self, values: npt.ArrayLike) -> FixedFftResult: + real, imag = quantize_complex(values, self.data_quantizer) + return self._forward_integers(real, imag) + + def inverse(self, values: npt.ArrayLike) -> FixedFftResult: + array = np.asarray(values, dtype=np.complex128) + real, imag = quantize_complex(np.conjugate(array), self.data_quantizer) + forward = self._forward_integers(real, imag) + return FixedFftResult(forward.real, -forward.imag, forward.fixed_format, forward.overflow_count) diff --git a/src/galileo_rx/fixed_point/fixed_loop_filter.py b/src/galileo_rx/fixed_point/fixed_loop_filter.py new file mode 100644 index 0000000..ec46540 --- /dev/null +++ b/src/galileo_rx/fixed_point/fixed_loop_filter.py @@ -0,0 +1,59 @@ +"""Bit-true tracking loop filter states and coefficients.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from .formats import FixedFormat, OverflowMode, RoundingMode +from .quantizer import Quantizer +from galileo_rx.tracking.loop_filters import second_order_natural_frequency_rad_s + + +@dataclass(slots=True) +class FixedSecondOrderLoopFilter: + noise_bandwidth_hz: float + integration_time_s: float + damping_ratio: float = 0.707 + error_format: FixedFormat = FixedFormat(24, 18) + coefficient_format: FixedFormat = FixedFormat(24, 16) + state_format: FixedFormat = FixedFormat(32, 20) + overflow: OverflowMode | str = OverflowMode.SATURATE + rounding: RoundingMode | str = RoundingMode.CONVERGENT + integrator_integer: int = 0 + overflow_count: int = 0 + _error_q: Quantizer = field(init=False, repr=False) + _coefficient_q: Quantizer = field(init=False, repr=False) + _state_q: Quantizer = field(init=False, repr=False) + proportional_gain_integer: int = field(init=False) + integral_step_gain_integer: int = field(init=False) + + def __post_init__(self) -> None: + if self.integration_time_s <= 0: + raise ValueError("integration_time_s must be positive") + self._error_q = Quantizer(self.error_format, self.overflow, self.rounding) + self._coefficient_q = Quantizer(self.coefficient_format, self.overflow, self.rounding) + self._state_q = Quantizer(self.state_format, self.overflow, self.rounding) + wn = second_order_natural_frequency_rad_s(self.noise_bandwidth_hz, self.damping_ratio) + self.proportional_gain_integer = int(self._coefficient_q.quantize([2 * self.damping_ratio * wn])[0]) + self.integral_step_gain_integer = int(self._coefficient_q.quantize([wn**2 * self.integration_time_s])[0]) + + def update(self, error: float) -> float: + error_integer = int(self._error_q.quantize([error])[0]) + product_fraction = self.error_format.fractional_bits + self.coefficient_format.fractional_bits + integral_delta = int(self._state_q.requantize_integer([error_integer * self.integral_step_gain_integer], product_fraction)[0]) + self.integrator_integer = int(self._state_q.requantize_integer( + [self.integrator_integer + integral_delta], self.state_format.fractional_bits + )[0]) + proportional = int(self._state_q.requantize_integer( + [error_integer * self.proportional_gain_integer], product_fraction + )[0]) + output_integer = int(self._state_q.requantize_integer( + [proportional + self.integrator_integer], self.state_format.fractional_bits + )[0]) + self.overflow_count = ( + self._error_q.statistics.overflow_count + + self._coefficient_q.statistics.overflow_count + + self._state_q.statistics.overflow_count + ) + return output_integer / self.state_format.scale + diff --git a/src/galileo_rx/fixed_point/fixed_mixer.py b/src/galileo_rx/fixed_point/fixed_mixer.py new file mode 100644 index 0000000..b963cdf --- /dev/null +++ b/src/galileo_rx/fixed_point/fixed_mixer.py @@ -0,0 +1,48 @@ +"""Standalone bit-true complex mixer primitive.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np +import numpy.typing as npt + +from .formats import FixedFormat, OverflowMode, RoundingMode +from .quantizer import Quantizer + + +@dataclass(slots=True) +class FixedComplex: + real: npt.NDArray[np.int64] + imag: npt.NDArray[np.int64] + fixed_format: FixedFormat + overflow_count: int + + def dequantize(self) -> npt.NDArray[np.complex128]: + return (self.real.astype(np.float64) + 1j * self.imag.astype(np.float64)) / self.fixed_format.scale + + +def complex_mix_down( + sample_real: npt.ArrayLike, + sample_imag: npt.ArrayLike, + carrier_cos: npt.ArrayLike, + carrier_sin: npt.ArrayLike, + sample_format: FixedFormat, + carrier_format: FixedFormat, + output_format: FixedFormat, + overflow: OverflowMode | str = OverflowMode.SATURATE, + rounding: RoundingMode | str = RoundingMode.NEAREST, +) -> FixedComplex: + """Compute ``(I+jQ)*(cos-j*sin)`` from integer operands.""" + i = np.asarray(sample_real, dtype=np.int64) + q = np.asarray(sample_imag, dtype=np.int64) + c = np.asarray(carrier_cos, dtype=np.int64) + s = np.asarray(carrier_sin, dtype=np.int64) + if not (i.shape == q.shape == c.shape == s.shape): + raise ValueError("fixed mixer operands must have equal shapes") + product_fractional_bits = sample_format.fractional_bits + carrier_format.fractional_bits + quantizer = Quantizer(output_format, overflow, rounding) + real = quantizer.requantize_integer(i * c + q * s, product_fractional_bits) + imag = quantizer.requantize_integer(q * c - i * s, product_fractional_bits) + return FixedComplex(real, imag, output_format, quantizer.statistics.overflow_count) + diff --git a/src/galileo_rx/fixed_point/fixed_nco.py b/src/galileo_rx/fixed_point/fixed_nco.py new file mode 100644 index 0000000..7d6943b --- /dev/null +++ b/src/galileo_rx/fixed_point/fixed_nco.py @@ -0,0 +1,44 @@ +"""Bit-true phase word and quantized carrier LUT model.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +import numpy as np +import numpy.typing as npt + +from .formats import FixedFormat +from .quantizer import Quantizer + + +@dataclass(slots=True) +class FixedNco: + sampling_frequency_hz: float + frequency_hz: float + phase_width: int = 32 + lut_bits: int = 10 + output_format: FixedFormat = FixedFormat(16, 15) + phase_word: int = 0 + _cos_lut: npt.NDArray[np.int64] = field(init=False, repr=False) + _sin_lut: npt.NDArray[np.int64] = field(init=False, repr=False) + + def __post_init__(self) -> None: + if self.sampling_frequency_hz <= 0 or self.lut_bits > self.phase_width: + raise ValueError("invalid fixed NCO configuration") + phase = 2 * np.pi * np.arange(1 << self.lut_bits) / (1 << self.lut_bits) + quantizer = Quantizer(self.output_format) + self._cos_lut = quantizer.quantize(np.cos(phase)) + self._sin_lut = quantizer.quantize(np.sin(phase)) + + @property + def frequency_control_word(self) -> int: + return int(round(self.frequency_hz / self.sampling_frequency_hz * (1 << self.phase_width))) + + def generate(self, sample_count: int) -> tuple[npt.NDArray[np.int64], npt.NDArray[np.int64]]: + modulus = 1 << self.phase_width + fcw = self.frequency_control_word + words = np.asarray([(self.phase_word + index * fcw) % modulus for index in range(sample_count)], dtype=np.uint64) + address = words >> (self.phase_width - self.lut_bits) + self.phase_word = (self.phase_word + sample_count * fcw) % modulus + return self._cos_lut[address], self._sin_lut[address] + diff --git a/src/galileo_rx/fixed_point/formats.py b/src/galileo_rx/fixed_point/formats.py new file mode 100644 index 0000000..0f01756 --- /dev/null +++ b/src/galileo_rx/fixed_point/formats.py @@ -0,0 +1,62 @@ +"""Fixed-point format declarations.""" + +from dataclasses import dataclass +from enum import Enum + + +class OverflowMode(str, Enum): + SATURATE = "saturate" + WRAP = "wrap" + + +class RoundingMode(str, Enum): + NEAREST = "nearest" + TRUNCATE = "truncate" + CONVERGENT = "convergent" + + +@dataclass(frozen=True, slots=True) +class FixedFormat: + total_bits: int + fractional_bits: int + signed: bool = True + + def __post_init__(self) -> None: + if self.total_bits < 1 or self.fractional_bits < 0 or self.fractional_bits >= self.total_bits: + raise ValueError("fixed format requires total_bits >= 1 and 0 <= fractional_bits < total_bits") + + @property + def minimum_integer(self) -> int: + return -(1 << (self.total_bits - 1)) if self.signed else 0 + + @property + def maximum_integer(self) -> int: + return (1 << (self.total_bits - int(self.signed))) - 1 + + @property + def scale(self) -> int: + return 1 << self.fractional_bits + + @property + def lsb(self) -> float: + return 1.0 / self.scale + + +STARTING_WORD_LENGTHS: dict[str, FixedFormat] = { + "input_samples": FixedFormat(8, 7), + "mixer_sine_cosine": FixedFormat(16, 15), + "mixer_output": FixedFormat(18, 15), + "fft_input": FixedFormat(18, 15), + "fft_twiddle": FixedFormat(16, 15), + "fft_stage_output": FixedFormat(20, 15), + "correlation_product": FixedFormat(36, 30), + "ifft_output": FixedFormat(28, 15), + "coherent_accumulator": FixedFormat(40, 15), + "noncoherent_accumulator": FixedFormat(56, 20, signed=False), + "carrier_phase_accumulator": FixedFormat(32, 0, signed=False), + "code_phase_accumulator": FixedFormat(48, 32, signed=False), + "loop_filter_state": FixedFormat(32, 20), + "frequency_control_word": FixedFormat(32, 0), + "discriminator_output": FixedFormat(24, 18), +} + diff --git a/src/galileo_rx/fixed_point/quantizer.py b/src/galileo_rx/fixed_point/quantizer.py new file mode 100644 index 0000000..95f8e1a --- /dev/null +++ b/src/galileo_rx/fixed_point/quantizer.py @@ -0,0 +1,71 @@ +"""Two's-complement quantization with observable overflow and error.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np +import numpy.typing as npt + +from .formats import FixedFormat, OverflowMode, RoundingMode + + +@dataclass(slots=True) +class QuantizationStatistics: + sample_count: int = 0 + overflow_count: int = 0 + squared_error_sum: float = 0.0 + peak_error: float = 0.0 + + @property + def rms_error(self) -> float: + return float(np.sqrt(self.squared_error_sum / self.sample_count)) if self.sample_count else 0.0 + + +class Quantizer: + def __init__(self, fixed_format: FixedFormat, overflow: OverflowMode | str = OverflowMode.SATURATE, rounding: RoundingMode | str = RoundingMode.NEAREST) -> None: + self.format = fixed_format + self.overflow = OverflowMode(overflow) + self.rounding = RoundingMode(rounding) + self.statistics = QuantizationStatistics() + + def _round(self, scaled: np.ndarray) -> np.ndarray: + if self.rounding == RoundingMode.NEAREST: + return np.floor(scaled + 0.5) + if self.rounding == RoundingMode.TRUNCATE: + return np.trunc(scaled) + return np.rint(scaled) # IEEE ties-to-even is convergent rounding. + + def quantize(self, values: npt.ArrayLike) -> npt.NDArray[np.int64]: + array = np.asarray(values, dtype=np.float64) + rounded = self._round(array * self.format.scale).astype(np.int64) + overflow_mask = (rounded < self.format.minimum_integer) | (rounded > self.format.maximum_integer) + self.statistics.overflow_count += int(np.count_nonzero(overflow_mask)) + if self.overflow == OverflowMode.SATURATE: + quantized = np.clip(rounded, self.format.minimum_integer, self.format.maximum_integer) + else: + modulus = 1 << self.format.total_bits + quantized = (rounded - self.format.minimum_integer) % modulus + self.format.minimum_integer + restored = quantized.astype(np.float64) / self.format.scale + error = restored - array + self.statistics.sample_count += array.size + self.statistics.squared_error_sum += float(np.sum(error**2)) + self.statistics.peak_error = max(self.statistics.peak_error, float(np.max(np.abs(error))) if error.size else 0.0) + return quantized.astype(np.int64) + + def dequantize(self, integers: npt.ArrayLike) -> npt.NDArray[np.float64]: + return np.asarray(integers, dtype=np.float64) / self.format.scale + + def requantize_integer(self, integers: npt.ArrayLike, source_fractional_bits: int) -> npt.NDArray[np.int64]: + values = np.asarray(integers, dtype=np.float64) / float(1 << source_fractional_bits) + return self.quantize(values) + + +def quantize_complex(values: npt.ArrayLike, quantizer: Quantizer) -> tuple[npt.NDArray[np.int64], npt.NDArray[np.int64]]: + array = np.asarray(values, dtype=np.complex128) + return quantizer.quantize(array.real), quantizer.quantize(array.imag) + + +def dequantize_complex(real: npt.ArrayLike, imag: npt.ArrayLike, fixed_format: FixedFormat) -> npt.NDArray[np.complex128]: + return (np.asarray(real, dtype=np.float64) + 1j * np.asarray(imag, dtype=np.float64)) / fixed_format.scale + diff --git a/src/galileo_rx/frontend/__init__.py b/src/galileo_rx/frontend/__init__.py new file mode 100644 index 0000000..10b8793 --- /dev/null +++ b/src/galileo_rx/frontend/__init__.py @@ -0,0 +1,7 @@ +"""Streaming digital front-end blocks.""" + +from .frontend_chain import FrontendChain +from .mixer import PhaseAccumulatorNco + +__all__ = ["FrontendChain", "PhaseAccumulatorNco"] + diff --git a/src/galileo_rx/frontend/agc.py b/src/galileo_rx/frontend/agc.py new file mode 100644 index 0000000..35ef7e4 --- /dev/null +++ b/src/galileo_rx/frontend/agc.py @@ -0,0 +1,24 @@ +"""Block RMS normalization with bounded gain.""" + +import numpy as np +import numpy.typing as npt + + +class BlockAgc: + def __init__(self, target_rms: float = 0.5, smoothing: float = 0.9, min_gain: float = 1e-3, max_gain: float = 1e3) -> None: + if target_rms <= 0 or not 0 <= smoothing < 1: + raise ValueError("invalid AGC parameters") + self.target_rms = target_rms + self.smoothing = smoothing + self.min_gain = min_gain + self.max_gain = max_gain + self.gain = 1.0 + + def process(self, samples: npt.NDArray[np.generic]) -> npt.NDArray[np.complex128]: + array = np.asarray(samples, dtype=np.complex128) + rms = float(np.sqrt(np.mean(np.abs(array) ** 2))) if array.size else 0.0 + desired = self.target_rms / max(rms, 1e-15) + desired = float(np.clip(desired, self.min_gain, self.max_gain)) + self.gain = self.smoothing * self.gain + (1.0 - self.smoothing) * desired + return array * self.gain + diff --git a/src/galileo_rx/frontend/dc_blocker.py b/src/galileo_rx/frontend/dc_blocker.py new file mode 100644 index 0000000..1fb66b5 --- /dev/null +++ b/src/galileo_rx/frontend/dc_blocker.py @@ -0,0 +1,25 @@ +"""First-order streaming DC blocker.""" + +from __future__ import annotations + +import numpy as np +import numpy.typing as npt + + +class DcBlocker: + def __init__(self, alpha: float = 0.999) -> None: + if not 0 <= alpha < 1: + raise ValueError("DC blocker alpha must be in [0, 1)") + self.alpha = alpha + self.previous_input = 0j + self.previous_output = 0j + + def process(self, samples: npt.NDArray[np.generic]) -> npt.NDArray[np.complex128]: + output = np.empty(samples.size, dtype=np.complex128) + for index, value in enumerate(np.asarray(samples, dtype=np.complex128)): + current = value - self.previous_input + self.alpha * self.previous_output + output[index] = current + self.previous_input = value + self.previous_output = current + return output + diff --git a/src/galileo_rx/frontend/decimator.py b/src/galileo_rx/frontend/decimator.py new file mode 100644 index 0000000..fba24b2 --- /dev/null +++ b/src/galileo_rx/frontend/decimator.py @@ -0,0 +1,29 @@ +"""Stateful FIR and integer decimation.""" + +from __future__ import annotations + +import numpy as np +import numpy.typing as npt +from scipy.signal import lfilter + + +class FirDecimator: + def __init__(self, taps: list[float] | npt.NDArray[np.float64] | None = None, factor: int = 1) -> None: + if factor < 1: + raise ValueError("decimation factor must be >= 1") + self.taps = np.asarray(taps if taps is not None and len(taps) else [1.0], dtype=np.float64) + self.factor = factor + self._state = np.zeros(max(0, self.taps.size - 1), dtype=np.complex128) + self._decimation_phase = 0 + + def process(self, samples: npt.NDArray[np.generic]) -> npt.NDArray[np.complex128]: + array = np.asarray(samples, dtype=np.complex128) + if self.taps.size == 1: + filtered = array * self.taps[0] + else: + filtered, self._state = lfilter(self.taps, [1.0], array, zi=self._state) + start = (-self._decimation_phase) % self.factor + result = filtered[start::self.factor] + self._decimation_phase = (self._decimation_phase + array.size) % self.factor + return result + diff --git a/src/galileo_rx/frontend/frontend_chain.py b/src/galileo_rx/frontend/frontend_chain.py new file mode 100644 index 0000000..7d5d9f3 --- /dev/null +++ b/src/galileo_rx/frontend/frontend_chain.py @@ -0,0 +1,42 @@ +"""Composable streaming RF-to-complex-baseband chain.""" + +from __future__ import annotations + +import numpy as np +import numpy.typing as npt + +from galileo_rx.config import FrontendConfig, RfConfig +from .agc import BlockAgc +from .dc_blocker import DcBlocker +from .decimator import FirDecimator +from .mixer import ComplexMixer, PhaseAccumulatorNco + + +class FrontendChain: + def __init__(self, rf: RfConfig, config: FrontendConfig) -> None: + self.rf = rf + self.config = config + self.dc = DcBlocker(config.dc_alpha) if config.remove_dc else None + self.agc = BlockAgc(config.agc_target_rms) if config.agc_enabled else None + self.mixer = ComplexMixer( + PhaseAccumulatorNco(rf.sampling_frequency_hz, rf.intermediate_frequency_hz, config.nco_phase_width, config.nco_lut_bits) + ) + self.decimator = FirDecimator(config.fir_taps, config.decimation) + + @property + def output_sampling_frequency_hz(self) -> float: + return self.rf.sampling_frequency_hz / self.config.decimation + + def process(self, samples: npt.NDArray[np.generic]) -> npt.NDArray[np.complex128]: + array = np.asarray(samples) + if np.issubdtype(array.dtype, np.integer): + array = array.astype(np.float64) / float(1 << (self.rf.sample_bit_width - 1)) + array = array.astype(np.complex128) + if self.dc is not None: + array = self.dc.process(array) + if self.agc is not None: + array = self.agc.process(array) + if self.rf.input_is_real_if or self.rf.intermediate_frequency_hz != 0: + array = self.mixer.process(array, downconvert=True) + return self.decimator.process(array) + diff --git a/src/galileo_rx/frontend/mixer.py b/src/galileo_rx/frontend/mixer.py new file mode 100644 index 0000000..7cbd37d --- /dev/null +++ b/src/galileo_rx/frontend/mixer.py @@ -0,0 +1,66 @@ +"""Floating and LUT phase-accumulator carrier NCO.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +import numpy as np +import numpy.typing as npt + + +@dataclass(slots=True) +class PhaseAccumulatorNco: + sampling_frequency_hz: float + frequency_hz: float + phase_width: int = 32 + lut_bits: int = 12 + phase_word: int = 0 + _lut: npt.NDArray[np.complex128] = field(init=False, repr=False) + + def __post_init__(self) -> None: + if self.sampling_frequency_hz <= 0 or not 8 <= self.phase_width <= 64: + raise ValueError("invalid NCO sampling frequency or phase width") + if not 4 <= self.lut_bits <= min(self.phase_width, 20): + raise ValueError("invalid LUT address width") + size = 1 << self.lut_bits + self._lut = np.exp(2j * np.pi * np.arange(size) / size) + + @property + def modulus(self) -> int: + return 1 << self.phase_width + + @property + def frequency_control_word(self) -> int: + return int(round(self.frequency_hz / self.sampling_frequency_hz * self.modulus)) + + @property + def actual_frequency_hz(self) -> float: + return self.frequency_control_word * self.sampling_frequency_hz / self.modulus + + def set_frequency(self, frequency_hz: float) -> None: + self.frequency_hz = frequency_hz + + def generate(self, sample_count: int, conjugate: bool = False, use_lut: bool = False) -> npt.NDArray[np.complex128]: + if sample_count < 0: + raise ValueError("sample_count must be nonnegative") + fcw = self.frequency_control_word + words = (self.phase_word + np.arange(sample_count, dtype=object) * fcw) + if use_lut: + shift = self.phase_width - self.lut_bits + addresses = np.asarray([(int(word) % self.modulus) >> shift for word in words], dtype=np.int64) + values = self._lut[addresses] + else: + phases = np.asarray([int(word) % self.modulus for word in words], dtype=np.float64) / self.modulus + values = np.exp(2j * np.pi * phases) + self.phase_word = int((self.phase_word + sample_count * fcw) % self.modulus) + return np.conjugate(values) if conjugate else values + + +class ComplexMixer: + def __init__(self, nco: PhaseAccumulatorNco, use_lut: bool = False) -> None: + self.nco = nco + self.use_lut = use_lut + + def process(self, samples: npt.NDArray[np.generic], downconvert: bool = True) -> npt.NDArray[np.complex128]: + array = np.asarray(samples, dtype=np.complex128) + return array * self.nco.generate(array.size, conjugate=downconvert, use_lut=self.use_lut) diff --git a/src/galileo_rx/io/__init__.py b/src/galileo_rx/io/__init__.py new file mode 100644 index 0000000..5244b0c --- /dev/null +++ b/src/galileo_rx/io/__init__.py @@ -0,0 +1,14 @@ +"""Sample file I/O.""" + +from .sample_reader import SampleReader, read_samples +from .sample_writer import write_samples +from .signalsim_reader import SignalSimReader, SignalSimSampleFormat, SignalSimSampleSpec + +__all__ = [ + "SampleReader", + "SignalSimReader", + "SignalSimSampleFormat", + "SignalSimSampleSpec", + "read_samples", + "write_samples", +] diff --git a/src/galileo_rx/io/sample_reader.py b/src/galileo_rx/io/sample_reader.py new file mode 100644 index 0000000..f1a32ee --- /dev/null +++ b/src/galileo_rx/io/sample_reader.py @@ -0,0 +1,35 @@ +"""Block-streaming raw sample input.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Iterator + +import numpy as np +import numpy.typing as npt + + +def _dtype(sample_format: str, endianness: str) -> np.dtype: + mapping = {"complex128": "c16", "float64": "f8", "complex64": "c8", "float32": "f4", "int8": "i1", "int16": "i2"} + if sample_format not in mapping: + raise ValueError(f"unsupported sample format {sample_format!r}") + prefix = "<" if endianness == "little" else ">" + return np.dtype(prefix + mapping[sample_format]) + + +def read_samples(path: str | Path, sample_format: str = "complex128", endianness: str = "little", count: int = -1, offset_samples: int = 0) -> npt.NDArray[np.generic]: + dtype = _dtype(sample_format, endianness) + return np.fromfile(Path(path), dtype=dtype, count=count, offset=offset_samples * dtype.itemsize) + + +class SampleReader: + def __init__(self, path: str | Path, sample_format: str = "complex128", endianness: str = "little") -> None: + self.path = Path(path) + self.dtype = _dtype(sample_format, endianness) + + def blocks(self, block_samples: int) -> Iterator[npt.NDArray[np.generic]]: + if block_samples <= 0: + raise ValueError("block_samples must be positive") + with self.path.open("rb") as stream: + while block := np.fromfile(stream, dtype=self.dtype, count=block_samples): + yield block diff --git a/src/galileo_rx/io/sample_writer.py b/src/galileo_rx/io/sample_writer.py new file mode 100644 index 0000000..3c349c8 --- /dev/null +++ b/src/galileo_rx/io/sample_writer.py @@ -0,0 +1,13 @@ +"""Raw sample output.""" + +from pathlib import Path + +import numpy as np +import numpy.typing as npt + + +def write_samples(path: str | Path, samples: npt.NDArray[np.generic], endianness: str = "little") -> None: + array = np.asarray(samples) + byte_order = "<" if endianness == "little" else ">" + array.astype(array.dtype.newbyteorder(byte_order), copy=False).tofile(Path(path)) + diff --git a/src/galileo_rx/io/signalsim_reader.py b/src/galileo_rx/io/signalsim_reader.py new file mode 100644 index 0000000..6434b55 --- /dev/null +++ b/src/galileo_rx/io/signalsim_reader.py @@ -0,0 +1,154 @@ +"""Strict adapters for globsky/SignalSim complex-IQ files. + +All decoded samples are promoted to ``complex128``. ``scale_counts_per_unit`` +is explicit: use 1.0 for raw ADC counts, 25.0 for IQ8 physical amplitude at +AGC gain 1, 3277.0 for IQ16, or the actual final AGC-adjusted scale. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from typing import Iterator, Literal + +import numpy as np +import numpy.typing as npt + + +class SignalSimSampleFormat(str, Enum): + IQ8 = "IQ8" + IQ16 = "IQ16" + IQ4 = "IQ4" + IQ2 = "IQ2" + FLOAT32_IQ = "FLOAT32_IQ" + FLOAT64_IQ = "FLOAT64_IQ" + + +@dataclass(frozen=True, slots=True) +class SignalSimSampleSpec: + sample_format: SignalSimSampleFormat | str = SignalSimSampleFormat.IQ8 + byte_order: Literal["little", "big"] = "little" + iq_order: Literal["IQ", "QI"] = "IQ" + scale_counts_per_unit: float = 1.0 + + def __post_init__(self) -> None: + object.__setattr__(self, "sample_format", SignalSimSampleFormat(self.sample_format)) + if self.scale_counts_per_unit <= 0.0 or not np.isfinite(self.scale_counts_per_unit): + raise ValueError("scale_counts_per_unit must be finite and positive") + if self.sample_format == SignalSimSampleFormat.IQ16 and self.byte_order != "little": + # This is allowed for hand-crafted/converted data, but is not native SignalSim. + pass + + +def _decode_sign_magnitude(values: np.ndarray, sign_mask: int, magnitude_mask: int) -> np.ndarray: + magnitude = (values & magnitude_mask).astype(np.float64) + return np.where((values & sign_mask) != 0, -magnitude, magnitude) + + +class SignalSimReader: + """Random-access and block-streaming SignalSim sample reader.""" + + def __init__(self, path: str | Path, spec: SignalSimSampleSpec | None = None, *, memory_map: bool = False) -> None: + self.path = Path(path) + self.spec = spec or SignalSimSampleSpec() + self.memory_map = memory_map + if not self.path.is_file(): + raise FileNotFoundError(self.path) + + @property + def sample_count(self) -> int: + byte_count = self.path.stat().st_size + fmt = self.spec.sample_format + divisor = { + SignalSimSampleFormat.IQ8: 2, + SignalSimSampleFormat.IQ16: 4, + SignalSimSampleFormat.IQ4: 1, + SignalSimSampleFormat.FLOAT32_IQ: 8, + SignalSimSampleFormat.FLOAT64_IQ: 16, + }.get(fmt) + if fmt == SignalSimSampleFormat.IQ2: + return byte_count * 2 + assert divisor is not None + if byte_count % divisor: + raise ValueError(f"truncated {fmt.value} file: {byte_count} bytes is not a whole complex sample") + return byte_count // divisor + + def _raw_bytes(self) -> npt.NDArray[np.uint8]: + if self.memory_map: + return np.memmap(self.path, dtype=np.uint8, mode="r") + return np.fromfile(self.path, dtype=np.uint8) + + def read(self, count: int = -1, offset_samples: int = 0, *, allow_truncated: bool = False) -> npt.NDArray[np.complex128]: + available = self.sample_count + if offset_samples < 0 or offset_samples > available: + raise ValueError(f"offset_samples must be within 0..{available}") + requested = available - offset_samples if count < 0 else count + if requested < 0: + raise ValueError("count must be -1 or nonnegative") + remaining = available - offset_samples + if requested > remaining: + if not allow_truncated: + raise EOFError(f"requested {requested} samples at offset {offset_samples}, only {remaining} remain") + requested = remaining + if requested == 0: + return np.empty(0, dtype=np.complex128) + + fmt = self.spec.sample_format + if fmt == SignalSimSampleFormat.IQ2: + decoded = self._decode_iq2(offset_samples, requested) + elif fmt == SignalSimSampleFormat.IQ4: + raw = self._raw_bytes()[offset_samples : offset_samples + requested] + i_values = _decode_sign_magnitude(raw >> 4, 0x8, 0x7) + q_values = _decode_sign_magnitude(raw & 0xF, 0x8, 0x7) + decoded = i_values + 1j * q_values + else: + scalar_dtype = { + SignalSimSampleFormat.IQ8: np.dtype("i1"), + SignalSimSampleFormat.IQ16: np.dtype(("<" if self.spec.byte_order == "little" else ">") + "i2"), + SignalSimSampleFormat.FLOAT32_IQ: np.dtype(("<" if self.spec.byte_order == "little" else ">") + "f4"), + SignalSimSampleFormat.FLOAT64_IQ: np.dtype(("<" if self.spec.byte_order == "little" else ">") + "f8"), + }[fmt] + scalar_offset = offset_samples * 2 + if self.memory_map: + scalars = np.memmap(self.path, dtype=scalar_dtype, mode="r", offset=scalar_offset * scalar_dtype.itemsize, shape=(requested * 2,)) + else: + scalars = np.fromfile(self.path, dtype=scalar_dtype, count=requested * 2, offset=scalar_offset * scalar_dtype.itemsize) + first = scalars[0::2].astype(np.float64) + second = scalars[1::2].astype(np.float64) + decoded = first + 1j * second + if self.spec.iq_order == "QI": + decoded = decoded.imag + 1j * decoded.real + return np.asarray(decoded / np.float64(self.spec.scale_counts_per_unit), dtype=np.complex128) + + def _decode_iq2(self, offset_samples: int, count: int) -> npt.NDArray[np.complex128]: + first_byte = offset_samples // 2 + last_sample = offset_samples + count + last_byte = (last_sample + 1) // 2 + raw = self._raw_bytes()[first_byte:last_byte] + output = np.empty(raw.size * 2, dtype=np.complex128) + # Each 2-bit sign/magnitude value has levels 0 and +/-1. Samples 0/1 + # occupy the low/high nibbles respectively (I then Q within a nibble). + for lane, shift in enumerate((0, 4)): + nibble = raw >> shift + i_values = _decode_sign_magnitude(nibble, 0x2, 0x1) + q_values = _decode_sign_magnitude(nibble, 0x8, 0x4) / 4.0 + output[lane::2] = i_values + 1j * q_values + begin = offset_samples & 1 + return output[begin : begin + count] + + def blocks(self, block_samples: int, *, offset_samples: int = 0, count: int = -1) -> Iterator[npt.NDArray[np.complex128]]: + if block_samples <= 0: + raise ValueError("block_samples must be positive") + remaining = self.sample_count - offset_samples if count < 0 else count + cursor = offset_samples + while remaining > 0: + block_count = min(block_samples, remaining) + block = self.read(block_count, cursor, allow_truncated=True) + if block.size == 0: + break + yield block + cursor += block.size + remaining -= block.size + if block.size < block_count: + break diff --git a/src/galileo_rx/rtl_model/__init__.py b/src/galileo_rx/rtl_model/__init__.py new file mode 100644 index 0000000..56e9ef3 --- /dev/null +++ b/src/galileo_rx/rtl_model/__init__.py @@ -0,0 +1,6 @@ +"""Cycle-oriented interfaces and hardware scheduling estimates.""" + +from .cycle_model import AcquisitionArchitectureEstimate, TrackingScheduler + +__all__ = ["AcquisitionArchitectureEstimate", "TrackingScheduler"] + diff --git a/src/galileo_rx/rtl_model/architecture.md b/src/galileo_rx/rtl_model/architecture.md new file mode 100644 index 0000000..625f398 --- /dev/null +++ b/src/galileo_rx/rtl_model/architecture.md @@ -0,0 +1,71 @@ +# Receiver RTL module contract + +Widths below are proposed starting points and must be confirmed by the sweep. +Every stream transfers only on `valid && ready`; `last` marks an integration or +FFT frame. Configuration registers are stable during a frame. Arithmetic data +saturates and raises a sticky overflow flag unless natural NCO wrap is stated. + +| Module | Input → output | Width / binary point | Latency & throughput | State / memory | Reset and verification | +|---|---|---|---|---|---| +| `sample_input` | ADC/file words → signed I/Q stream | 8–16 integer or Q1.(W-1) | 1, one sample/clock | optional CDC FIFO | clear valid/FIFO; replay exported vectors | +| `dc_blocker` | I/Q → high-pass I/Q | 18/Q15 internal | 2–4, one/clock | previous x/y per component | zero state; impulse/DC tests | +| `agc_or_scaler` | I/Q, gain → scaled I/Q | gain 18/Q16, out 18/Q15 | 2, one/clock | gain/RMS window | unity gain; float/fixed block comparison | +| `ddc_nco` | signed FCW, phase load → cos/sin | phase 32 unsigned, LUT 16/Q15 | 1–3, one/clock | phase accumulator, LUT BRAM | programmed phase; FCW/continuity tests | +| `complex_mixer` | sample, cos/sin → baseband | 8×16, out 18/Q15 | 3–5, one/clock | pipeline only | clear valid; tone/sign test | +| `fir_decimator` | baseband → filtered/decimated | coeff 18/Q17, accum 36+ | tap dependent | delay line/coeff ROM/phase | clear delay/phase; SciPy impulse comparison | +| `sample_buffer` | stream → framed random read | 18-bit I + 18-bit Q | one write/clock, read architecture dependent | `M*N_s` complex words, ping-pong | pointers zero; boundary/wrap tests | +| `code_rom` | PRN/component/chip addr → +/-1 | 1 sign bit | 1 | validated code ROM only | no invented defaults; checksum against provider | +| `code_nco` | FCW/phase → chip address/fraction | 48, 32 fractional | 1, one/sample | phase and epoch counter | phase load; noninteger-rate test | +| `boc_subcarrier_nco` | code fraction → sign | phase fraction MSB | combinational/1 | none or phase state | defined sine-phase polarity test | +| `fft_controller` | frame/config → FFT commands | address `ceil(log2 N)` | control dependent | frame/bin/PRN counters | idle reset; transaction assertions | +| `streaming_fft` | complex frame → spectrum | 18/Q15 in, 20/Q15 stage, configurable shifts | IP-specific `C_FFT`, initiation one/clock | twiddle ROM, delay/transpose RAM | frame reset only; impulse/tone/random bit vectors | +| `complex_multiplier` | spectra → product | 20/Q15 × 16/Q15, 36/Q30 full | 3–6, one/clock | pipeline | clear valid; exhaustive small-width test | +| `code_fft_memory` | PRN/address → conjugated FFT | 20/Q15 complex | 1–2 | `P*N*40` bits or banked subset | contents retained; CRC and software comparison | +| `correlation_ifft` | product frame → correlation | 20/Q15 → 28/Q15 | IP-specific | FFT RAM/twiddles | scaling-vector test | +| `magnitude_squared` | complex → unsigned power | 28/Q15 → 56/Q30, then configured 48/Q16 | 4, one/clock | pipeline | saturation sticky; corner tests | +| `noncoherent_accumulator` | power/address → sum | unsigned 56/Q20 | read-modify-write 2–3 | `D*N_s` words per active PRN/lane | clear at search; coherent/noncoherent vectors | +| `peak_search` | power stream → top/second/noise | 56-bit power, wide mean accumulator | one scan, one/clock | top values, guard FIFO/circular rescan | clear per map; wraparound guard tests | +| `acquisition_controller` | command → result stream | register/control | search latency | PRN/bin/epoch/hypothesis FSM | idle/abort; end-to-end generated vectors | +| `tracking_correlator_bank` | wiped sample, five signs → products | 18/Q15 sample, sign code | 2–5, channel/sample throughput parameter | pipeline | valid reset; vector/cycle correlator equality | +| `accumulate_and_dump` | tap products → five sums | 40/Q15 complex | one/sample, dump at epoch | ten accumulators/channel | zero on dump/reset; exact integer sums | +| `tracking_discriminators` | tap sums → PLL/FLL/DLL | input 40/Q15, output 24/Q18 | LUT/CORDIC dependent | previous prompt per channel | zero history; signs/units/corner quadrants | +| `loop_filter` | errors/config → corrections | 32/Q20 state, 24/Q18 coeff | 3–10 per update | integrators/channel | zero or software load; coefficient/step vectors | +| `carrier_nco` | correction/FCW → carrier | phase 32 unsigned | one/sample | phase/FCW/channel | natural wrap; continuity and pull-in | +| `tracking_channel_scheduler` | sample + state RAM → lane jobs | IDs/addresses | must meet scheduling inequality | active bitmap, round-robin pointer | idle; assertions for deadline/no collision | +| `channel_state_ram` | scheduler read/write | implementation record ~512 bits/channel | dual-port 1–2 | all NCO/loop/lock/secondary state | configurable preserve/clear; context-switch test | +| `measurement_output` | epoch state → host stream | packed 32-bit words | burst per epoch | small FIFO | clear FIFO; schema/golden CSV comparison | + +`fir_decimator` is bypassable. `ddc_nco` can expose `(x,y,z,valid,ready)` instead +of LUT output for a later CORDIC without changing mixer control. FFT RAM banking, +scaling schedule, and vendor IP latency are deliberately parameters, not hidden +assumptions. + +For side-peak/unambiguous extensions, the acquisition controller accepts a +replica-strategy identifier and the tracking bank already supplies VEVL. CBOC +adds a modulation generator/combiner behind the same sign-replica interface. + +## External-vector handoff contract + +The SignalSim quick vector is the first external RTL oracle. The generated +manifest contains raw IQ8 hex, sample timestamps, search limits, coherent and +noncoherent counts, FFT length, carrier/code FCWs, acquired code phase and +Doppler, E1B/E1C dump lengths, and E1C secondary phase. Symbolic keys become +APB fields once the register address map is frozen. + +The software handoff must program two independent quantities: + +```text +carrier_nco_frequency = configured_IF_center + acquired_Doppler +code_aiding_Doppler = carrier_nco_frequency - configured_IF_center +``` + +Using the full carrier NCO frequency for code aiding was observed to drive the +code NCO about 1330 Hz fast at 2.046 MHz IF and lose lock after one epoch. RTL +and firmware assertions should check that the aiding value lies within the +configured Doppler search range, not the sampled IF range. + +The E1B channel uses 16,368-sample (2 ms) pull-in dumps at 8.184 MHz so its +BPSK-squared FLL has ±125 Hz unambiguous range. Two dumps are paired for a raw +4 ms symbol. E1C uses 32,736-sample dumps plus a 25-state secondary counter. +These measured modes must be representable by `MAX_INTEGRATION_SAMPLES`, the +channel state RAM record, the scheduler, and measurement output. diff --git a/src/galileo_rx/rtl_model/cycle_model.py b/src/galileo_rx/rtl_model/cycle_model.py new file mode 100644 index 0000000..d5640b5 --- /dev/null +++ b/src/galileo_rx/rtl_model/cycle_model.py @@ -0,0 +1,74 @@ +"""Architecture sizing formulas and lossless scheduling checks.""" + +from __future__ import annotations + +from dataclasses import dataclass +from math import ceil, log2 + + +@dataclass(frozen=True, slots=True) +class AcquisitionArchitectureEstimate: + sampling_frequency_hz: float + coherent_integration_s: float + noncoherent_accumulations: int + number_of_prns: int + number_of_doppler_bins: int + fft_length: int + clock_frequency_hz: float + num_doppler_lanes: int = 1 + fft_cycles_per_transform: int | None = None + + @property + def samples_per_coherent_interval(self) -> int: + return int(round(self.sampling_frequency_hz * self.coherent_integration_s)) + + @property + def fft_operations_per_prn(self) -> int: + # One local-code FFT may be precomputed; every bin/accumulation needs a + # sample FFT and correlation IFFT. + return 2 * self.number_of_doppler_bins * self.noncoherent_accumulations + + @property + def sample_buffer_complex_words(self) -> int: + return self.samples_per_coherent_interval * self.noncoherent_accumulations + + @property + def code_fft_complex_words(self) -> int: + return self.number_of_prns * self.fft_length + + @property + def complex_pointwise_multiplications(self) -> int: + return self.number_of_prns * self.number_of_doppler_bins * self.noncoherent_accumulations * self.fft_length + + @property + def acquisition_time_s(self) -> float: + fft_cycles = self.fft_cycles_per_transform or (self.fft_length + int(log2(self.fft_length)) + 16) + bin_groups = ceil(self.number_of_doppler_bins / self.num_doppler_lanes) + cycles = self.number_of_prns * bin_groups * self.noncoherent_accumulations * (2 * fft_cycles + self.fft_length) + return cycles / self.clock_frequency_hz + + +@dataclass(frozen=True, slots=True) +class TrackingScheduler: + sampling_frequency_hz: float + clock_frequency_hz: float + num_tracking_channels: int + num_correlator_lanes: int + cycles_per_channel_sample: int + + @property + def available_cycles_per_input_sample(self) -> float: + return self.clock_frequency_hz / self.sampling_frequency_hz + + @property + def required_cycles_per_input_sample(self) -> int: + return ceil(self.num_tracking_channels / self.num_correlator_lanes) * self.cycles_per_channel_sample + + @property + def schedulable_without_loss(self) -> bool: + return self.required_cycles_per_input_sample <= self.available_cycles_per_input_sample + + @property + def utilization(self) -> float: + return self.required_cycles_per_input_sample / self.available_cycles_per_input_sample + diff --git a/src/galileo_rx/rtl_model/interfaces.py b/src/galileo_rx/rtl_model/interfaces.py new file mode 100644 index 0000000..0693647 --- /dev/null +++ b/src/galileo_rx/rtl_model/interfaces.py @@ -0,0 +1,34 @@ +"""Vendor-neutral valid/ready transaction types.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(slots=True) +class StreamTransaction: + data: int | complex + valid: bool = True + ready: bool = True + first: bool = False + last: bool = False + channel_id: int = 0 + user: int = 0 + + @property + def transferred(self) -> bool: + return self.valid and self.ready + + +@dataclass(slots=True) +class CorrelatorTransaction: + sample_i: int + sample_q: int + carrier_cos: int + carrier_sin: int + code_taps: tuple[int, ...] + channel_id: int + epoch_last: bool + valid: bool = True + ready: bool = True + diff --git a/src/galileo_rx/rtl_model/vector_export.py b/src/galileo_rx/rtl_model/vector_export.py new file mode 100644 index 0000000..5f21259 --- /dev/null +++ b/src/galileo_rx/rtl_model/vector_export.py @@ -0,0 +1,76 @@ +"""Export integer transactions and manifests for HDL testbenches.""" + +from __future__ import annotations + +import json +from pathlib import Path +import csv + +import numpy as np +import numpy.typing as npt + +from galileo_rx.fixed_point.formats import FixedFormat +from galileo_rx.fixed_point.quantizer import Quantizer, quantize_complex + + +def export_complex_hex( + directory: str | Path, + stem: str, + values: npt.ArrayLike, + fixed_format: FixedFormat, +) -> dict[str, object]: + """Write two's-complement I/Q hex files plus a machine-readable manifest.""" + output_directory = Path(directory) + output_directory.mkdir(parents=True, exist_ok=True) + quantizer = Quantizer(fixed_format) + real, imag = quantize_complex(values, quantizer) + mask = (1 << fixed_format.total_bits) - 1 + digits = (fixed_format.total_bits + 3) // 4 + real_path = output_directory / f"{stem}_i.hex" + imag_path = output_directory / f"{stem}_q.hex" + real_path.write_text("\n".join(f"{int(item) & mask:0{digits}x}" for item in real) + "\n", encoding="ascii") + imag_path.write_text("\n".join(f"{int(item) & mask:0{digits}x}" for item in imag) + "\n", encoding="ascii") + manifest = { + "stem": stem, + "sample_count": int(real.size), + "total_bits": fixed_format.total_bits, + "fractional_bits": fixed_format.fractional_bits, + "signed": fixed_format.signed, + "encoding": "two_complement_hex", + "i_file": real_path.name, + "q_file": imag_path.name, + "overflow_count": quantizer.statistics.overflow_count, + } + (output_directory / f"{stem}.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8") + return manifest + + +def export_timed_complex_hex( + directory: str | Path, + stem: str, + values: npt.ArrayLike, + fixed_format: FixedFormat, + sampling_frequency_hz: float, + register_values: dict[str, object], +) -> dict[str, object]: + """Export RTL samples, explicit timestamps, and configuration metadata.""" + if sampling_frequency_hz <= 0.0: + raise ValueError("sampling_frequency_hz must be positive") + output_directory = Path(directory) + manifest = export_complex_hex(output_directory, stem, values, fixed_format) + timestamp_path = output_directory / f"{stem}_timestamps.csv" + with timestamp_path.open("w", newline="", encoding="ascii") as stream: + writer = csv.writer(stream) + writer.writerow(["sample_index", "time_s"]) + for index in range(int(manifest["sample_count"])): + writer.writerow([index, f"{index / sampling_frequency_hz:.17g}"]) + manifest.update( + { + "sampling_frequency_hz": sampling_frequency_hz, + "sample_period_s": 1.0 / sampling_frequency_hz, + "timestamp_file": timestamp_path.name, + "register_values": register_values, + } + ) + (output_directory / f"{stem}.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8") + return manifest diff --git a/src/galileo_rx/signal_profiles.py b/src/galileo_rx/signal_profiles.py new file mode 100644 index 0000000..af6b561 --- /dev/null +++ b/src/galileo_rx/signal_profiles.py @@ -0,0 +1,58 @@ +"""Signal definitions consumed by generic acquisition and tracking engines.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + + +@dataclass(frozen=True, slots=True) +class ComponentProfile: + name: str + channel_type: Literal["data", "pilot"] + primary_code_length_chips: int + primary_code_period_s: float + secondary_code_length: int | None + navigation_symbol_period_s: float | None + + +@dataclass(frozen=True, slots=True) +class SignalProfile: + name: str + carrier_frequency_hz: float + code_rate_hz: float + modulation: str + boc_subcarrier_rate_hz: float + components: dict[str, ComponentProfile] + + def component(self, name: str) -> ComponentProfile: + try: + return self.components[name] + except KeyError as exc: + raise ValueError(f"component {name!r} is not supported by profile {self.name}") from exc + + +def galileo_e1_profile() -> SignalProfile: + """Galileo E1 parameters; code chips themselves come from a code provider.""" + return SignalProfile( + name="GALILEO_E1", + carrier_frequency_hz=1_575_420_000.0, + code_rate_hz=1_023_000.0, + modulation="BOC(1,1)", + boc_subcarrier_rate_hz=1_023_000.0, + components={ + "E1B": ComponentProfile("E1B", "data", 4092, 0.004, None, 0.004), + "E1C": ComponentProfile("E1C", "pilot", 4092, 0.004, 25, None), + }, + ) + + +PROFILES = {"GALILEO_E1": galileo_e1_profile} + + +def get_signal_profile(name: str) -> SignalProfile: + try: + 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 new file mode 100644 index 0000000..375b634 --- /dev/null +++ b/src/galileo_rx/signals/__init__.py @@ -0,0 +1,7 @@ +"""Signal code, modulation, and generation.""" + +from .code_provider import CodeProvider, FileCodeProvider, SyntheticCodeProvider +from .signal_generator import SatelliteSignalConfig, SyntheticSignalGenerator + +__all__ = ["CodeProvider", "FileCodeProvider", "SyntheticCodeProvider", "SatelliteSignalConfig", "SyntheticSignalGenerator"] + diff --git a/src/galileo_rx/signals/boc.py b/src/galileo_rx/signals/boc.py new file mode 100644 index 0000000..49f40c7 --- /dev/null +++ b/src/galileo_rx/signals/boc.py @@ -0,0 +1,38 @@ +"""BOC modulation helpers with sample-continuous phase.""" + +from __future__ import annotations + +import numpy as np +import numpy.typing as npt + + +def boc_subcarrier( + sample_count: int, + sampling_frequency_hz: float, + subcarrier_frequency_hz: float, + initial_phase_cycles: float = 0.0, +) -> tuple[npt.NDArray[np.float64], float]: + """Generate sine-phased square-wave BOC subcarrier and ending phase in cycles.""" + 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) + ending_phase = float((initial_phase_cycles + sample_count * subcarrier_frequency_hz / sampling_frequency_hz) % 1.0) + return wave, ending_phase + + +def sample_primary_code( + code: npt.NDArray[np.int8], + sample_count: int, + sampling_frequency_hz: float, + code_rate_hz: float, + initial_code_phase_chips: float = 0.0, +) -> tuple[npt.NDArray[np.float64], float]: + """Nearest-chip code sampling supporting noninteger samples per chip.""" + 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) + 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/code_provider.py b/src/galileo_rx/signals/code_provider.py new file mode 100644 index 0000000..75a8de1 --- /dev/null +++ b/src/galileo_rx/signals/code_provider.py @@ -0,0 +1,82 @@ +"""Official-table integration point and deterministic synthetic test codes.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from pathlib import Path + +import numpy as np +import numpy.typing as npt + +from galileo_rx.signal_profiles import SignalProfile + +ChipArray = npt.NDArray[np.int8] + + +class CodeProvider(ABC): + @abstractmethod + def primary_code(self, profile: SignalProfile, component: str, prn: int) -> ChipArray: + """Return chips as +/-1 int8 values.""" + + @abstractmethod + def secondary_code(self, profile: SignalProfile, component: str, prn: int) -> ChipArray | None: + """Return one secondary-code period as +/-1, or None.""" + + +class SyntheticCodeProvider(CodeProvider): + """Deterministic random codes exclusively for simulation and verification. + + These sequences are not Galileo ICD codes and cannot acquire live signals. + """ + + @staticmethod + def _chips(length: int, profile: str, component: str, prn: int, kind: str) -> ChipArray: + seed = sum((index + 1) * ord(char) for index, char in enumerate(f"{profile}:{component}:{prn}:{kind}")) + rng = np.random.default_rng(seed & 0xFFFFFFFF) + return np.where(rng.integers(0, 2, length, dtype=np.int8) == 0, -1, 1).astype(np.int8) + + def primary_code(self, profile: SignalProfile, component: str, prn: int) -> ChipArray: + spec = profile.component(component) + return self._chips(spec.primary_code_length_chips, profile.name, component, prn, "primary") + + def secondary_code(self, profile: SignalProfile, component: str, prn: int) -> ChipArray | None: + length = profile.component(component).secondary_code_length + return None if length is None else self._chips(length, profile.name, component, prn, "secondary") + + +class FileCodeProvider(CodeProvider): + """Load version-controlled code tables without embedding guessed sequences. + + Files are ``__PRN.txt`` and contain exactly one + whitespace-separated row of either 0/1 or -1/+1 chips. + """ + + def __init__(self, primary_directory: str | Path, secondary_directory: str | Path) -> None: + self.primary_directory = Path(primary_directory) + self.secondary_directory = Path(secondary_directory) + + @staticmethod + def _load(path: Path, expected_length: int) -> ChipArray: + if not path.exists(): + raise FileNotFoundError(f"official code table is not installed: {path}") + raw = np.loadtxt(path, dtype=np.int8).reshape(-1) + if raw.size != expected_length: + raise ValueError(f"{path} has {raw.size} chips; expected {expected_length}") + unique = set(raw.tolist()) + if unique <= {0, 1}: + raw = 2 * raw - 1 + elif not unique <= {-1, 1}: + raise ValueError(f"{path} must contain only 0/1 or -1/+1") + return raw.astype(np.int8) + + @staticmethod + def _name(profile: SignalProfile, component: str, prn: int) -> str: + return f"{profile.name.lower()}_{component.lower()}_PRN{prn:03d}.txt" + + def primary_code(self, profile: SignalProfile, component: str, prn: int) -> ChipArray: + return self._load(self.primary_directory / self._name(profile, component, prn), profile.component(component).primary_code_length_chips) + + def secondary_code(self, profile: SignalProfile, component: str, prn: int) -> ChipArray | None: + length = profile.component(component).secondary_code_length + return None if length is None else self._load(self.secondary_directory / self._name(profile, component, prn), length) + diff --git a/src/galileo_rx/signals/galileo_e1.py b/src/galileo_rx/signals/galileo_e1.py new file mode 100644 index 0000000..9f829fb --- /dev/null +++ b/src/galileo_rx/signals/galileo_e1.py @@ -0,0 +1,31 @@ +"""Profile-driven Galileo E1 local replica generation.""" + +from __future__ import annotations + +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 .code_provider import CodeProvider + + +def e1_local_replica( + profile: SignalProfile, + provider: CodeProvider, + component: str, + prn: int, + sample_count: int, + sampling_frequency_hz: float, + initial_code_phase_chips: float = 0.0, + boc_enabled: bool = True, + code_rate_hz: float | None = None, +) -> 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) + return code * subcarrier diff --git a/src/galileo_rx/signals/signal_generator.py b/src/galileo_rx/signals/signal_generator.py new file mode 100644 index 0000000..35180da --- /dev/null +++ b/src/galileo_rx/signals/signal_generator.py @@ -0,0 +1,182 @@ +"""Streaming synthetic signal generator with exact state metadata.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Literal + +import numpy as np +import numpy.typing as npt + +from galileo_rx.signal_profiles import SignalProfile +from galileo_rx.types import GenerationTruth, SatelliteTruth +from .code_provider import CodeProvider + + +@dataclass(slots=True) +class SatelliteSignalConfig: + prn: int + component: Literal["E1B", "E1C"] = "E1C" + code_delay_chips: float = 0.0 + doppler_hz: float = 0.0 + doppler_rate_hz_s: float = 0.0 + carrier_phase_rad: float = 0.0 + amplitude: float = 1.0 + cn0_db_hz: float | None = None + navigation_bits: list[int] = field(default_factory=lambda: [1]) + navigation_bit_period_s: float = 0.004 + secondary_code_phase: int = 0 + + def validate(self, profile: SignalProfile) -> None: + profile.component(self.component) + if self.prn <= 0 or self.amplitude < 0: + 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") + + +@dataclass(slots=True) +class ChannelModelConfig: + sampling_clock_offset_ppm: float = 0.0 + carrier_frequency_offset_hz: float = 0.0 + add_awgn: bool = True + noise_std: float | None = None + clip_amplitude: float | None = None + quantization_bits: int | None = None + seed: int = 1 + + +class SyntheticSignalGenerator: + """Generate real IF or analytic IQ while preserving time across blocks.""" + + def __init__( + self, + profile: SignalProfile, + code_provider: CodeProvider, + sampling_frequency_hz: float, + intermediate_frequency_hz: float = 0.0, + output_is_real_if: bool = False, + satellites: list[SatelliteSignalConfig] | None = None, + channel: ChannelModelConfig | None = None, + ) -> None: + if sampling_frequency_hz <= 0: + raise ValueError("sampling_frequency_hz must be positive") + if output_is_real_if and not 0 < intermediate_frequency_hz < sampling_frequency_hz / 2: + raise ValueError("real IF must lie strictly between DC and Nyquist") + self.profile = profile + self.code_provider = code_provider + self.sampling_frequency_hz = sampling_frequency_hz + self.intermediate_frequency_hz = intermediate_frequency_hz + self.output_is_real_if = output_is_real_if + self.satellites = satellites or [] + self.channel = channel or ChannelModelConfig() + for satellite in self.satellites: + satellite.validate(profile) + self._sample_index = 0 + self._rng = np.random.default_rng(self.channel.seed) + + @property + def sample_index(self) -> int: + return self._sample_index + + def reset(self) -> None: + self._sample_index = 0 + self._rng = np.random.default_rng(self.channel.seed) + + @staticmethod + def _sign_sequence(sequence: npt.NDArray[np.int8], indices: npt.NDArray[np.int64]) -> npt.NDArray[np.float64]: + return sequence[np.mod(indices, sequence.size)].astype(np.float64) + + 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") + start = self._sample_index + nominal_t_s = (start + np.arange(sample_count, dtype=np.float64)) / self.sampling_frequency_hz + signal_t_s = nominal_t_s * (1.0 + self.channel.sampling_clock_offset_ppm * 1e-6) + output = np.zeros(sample_count, dtype=np.complex128) + truths: list[SatelliteTruth] = [] + equivalent_noise_variance = 0.0 + + 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. + 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 + + 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) + 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) + + component_spec = self.profile.component(satellite.component) + if component_spec.channel_type == "data": + bit_indices = np.floor(signal_t_s / satellite.navigation_bit_period_s).astype(np.int64) + spreading *= self._sign_sequence(np.asarray(satellite.navigation_bits, dtype=np.int8), bit_indices) + else: + secondary = self.code_provider.secondary_code(self.profile, satellite.component, satellite.prn) + if secondary is not None: + epoch_indices = np.floor(signal_t_s / component_spec.primary_code_period_s).astype(np.int64) + spreading *= self._sign_sequence(secondary, epoch_indices + satellite.secondary_code_phase) + + phase_rad = ( + satellite.carrier_phase_rad + + 2.0 * np.pi * (self.intermediate_frequency_hz + satellite.doppler_hz + self.channel.carrier_frequency_offset_hz) * nominal_t_s + + np.pi * satellite.doppler_rate_hz_s * nominal_t_s**2 + ) + output += satellite.amplitude * spreading * subcarrier * np.exp(1j * phase_rad) + if satellite.cn0_db_hz is not None: + equivalent_noise_variance += satellite.amplitude**2 * self.sampling_frequency_hz / (10.0 ** (satellite.cn0_db_hz / 10.0)) + truths.append( + SatelliteTruth( + prn=satellite.prn, + component=satellite.component, + initial_code_delay_chips=satellite.code_delay_chips, + initial_code_delay_samples=satellite.code_delay_chips * self.sampling_frequency_hz / self.profile.code_rate_hz, + initial_doppler_hz=satellite.doppler_hz, + doppler_rate_hz_s=satellite.doppler_rate_hz_s, + initial_carrier_phase_rad=satellite.carrier_phase_rad, + amplitude=satellite.amplitude, + cn0_db_hz=satellite.cn0_db_hz, + ) + ) + + if self.channel.add_awgn and sample_count: + variance = self.channel.noise_std**2 if self.channel.noise_std is not None else equivalent_noise_variance + if variance > 0: + output += np.sqrt(variance / 2.0) * ( + self._rng.standard_normal(sample_count) + 1j * self._rng.standard_normal(sample_count) + ) + result: npt.NDArray[np.complex128] | npt.NDArray[np.float64] + if self.output_is_real_if: + result = (np.sqrt(2.0) * output.real).astype(np.float64) + else: + result = output.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) + self._sample_index += sample_count + truth = GenerationTruth(self.sampling_frequency_hz, self.intermediate_frequency_hz, self.output_is_real_if, start, sample_count, truths) + return result, truth + + +def quantize_normalized(samples: npt.NDArray[np.generic], bits: int) -> npt.NDArray[np.generic]: + """Quantize normalized signed samples to a symmetric fractional grid.""" + if not 2 <= bits <= 32: + raise ValueError("quantization bits must be in [2, 32]") + scale = float(1 << (bits - 1)) + maximum = 1.0 - 1.0 / scale + if np.iscomplexobj(samples): + real = np.round(np.clip(samples.real, -1.0, maximum) * scale) / scale + imag = np.round(np.clip(samples.imag, -1.0, maximum) * scale) / scale + return (real + 1j * imag).astype(samples.dtype) + return (np.round(np.clip(samples, -1.0, maximum) * scale) / scale).astype(samples.dtype) diff --git a/src/galileo_rx/signals/signalsim_codes.py b/src/galileo_rx/signals/signalsim_codes.py new file mode 100644 index 0000000..14c4df0 --- /dev/null +++ b/src/galileo_rx/signals/signalsim_codes.py @@ -0,0 +1,121 @@ +"""Code provider backed by an unmodified local SignalSim checkout. + +The provider deliberately parses SignalSim's source at run time. It does not +copy, guess, or silently replace Galileo code tables. This also makes the +checkout commit part of the reproducibility boundary. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from functools import cached_property +from pathlib import Path +import re + +import numpy as np + +from galileo_rx.signal_profiles import SignalProfile +from .code_provider import ChipArray, CodeProvider + + +@dataclass(frozen=True, slots=True) +class SignalSimConvention: + """Observed conventions for globsky/SignalSim commit 80b2621. + + ``code_sign`` describes table bit mapping (0 -> +1, 1 -> -1). SignalSim + additionally multiplies E1B by -DataBit; that data-channel sign is kept out + of the spreading-code provider and is reported separately. + """ + + prn_offset: int = 0 + code_phase_offset_chips: float = 0.0 + code_sign: int = 1 + boc_sign: int = 1 + doppler_sign: int = 1 + iq_order: str = "IQ" + carrier_mixing_sign: int = -1 + + +class SignalSimCodeProvider(CodeProvider): + """Load Galileo E1B/E1C codes from a local SignalSim source tree.""" + + _E1_WORDS_PER_CODE = 128 + _E1_CODE_COUNT = 100 + _E1_CHIPS = 4092 + + def __init__(self, signalsim_path: str | Path) -> None: + self.signalsim_path = Path(signalsim_path).expanduser().resolve() + self.memory_code_path = self.signalsim_path / "src" / "MemoryCode.dat" + self.pilot_bit_path = self.signalsim_path / "src" / "PilotBit.cpp" + if not self.memory_code_path.is_file(): + raise FileNotFoundError(f"SignalSim E1 code table not found: {self.memory_code_path}") + if not self.pilot_bit_path.is_file(): + raise FileNotFoundError(f"SignalSim pilot source not found: {self.pilot_bit_path}") + + @cached_property + def _e1_words(self) -> np.ndarray: + text = self.memory_code_path.read_text(encoding="utf-8") + match = re.search( + r"E1MemoryCode\s*\[\s*100\s*\*\s*128\s*\]\s*=\s*\{(?P.*?)\};", + text, + flags=re.DOTALL, + ) + if match is None: + raise ValueError(f"could not locate E1MemoryCode in {self.memory_code_path}") + words = np.asarray( + [int(token, 16) for token in re.findall(r"0x[0-9a-fA-F]+", match.group("body"))], + dtype=np.uint32, + ) + expected = self._E1_WORDS_PER_CODE * self._E1_CODE_COUNT + if words.size != expected: + raise ValueError(f"SignalSim E1 table has {words.size} words; expected {expected}") + return words.reshape(self._E1_CODE_COUNT, self._E1_WORDS_PER_CODE) + + @staticmethod + def _unpack_msb_first(words: np.ndarray) -> ChipArray: + # GetMemorySequence() divides a code into four 32-word sectors. It + # consumes 1023 MSB-first bits from each 1024-bit sector and deliberately + # skips the last padding bit before advancing to the next sector. + sectors = words.reshape(4, 32) + sector_indices = np.arange(1023, dtype=np.int64) + bits = np.concatenate( + [(sector[sector_indices >> 5] >> (31 - (sector_indices & 31))) & 1 for sector in sectors] + ) + # SignalSim's modulator maps a stored 0 to +1 and stored 1 to -1. + return np.where(bits == 0, 1, -1).astype(np.int8) + + def primary_code(self, profile: SignalProfile, component: str, prn: int) -> ChipArray: + if profile.name != "GALILEO_E1": + raise ValueError(f"SignalSim E1 provider cannot serve profile {profile.name!r}") + if not 1 <= prn <= 50: + raise ValueError("SignalSim Galileo E1 PRN must be in 1..50") + normalized = component.upper() + if normalized == "E1B": + table_index = prn - 1 + elif normalized == "E1C": + table_index = prn + 49 + else: + raise ValueError(f"SignalSim E1 provider does not support component {component!r}") + expected = profile.component(normalized).primary_code_length_chips + if expected != self._E1_CHIPS: + raise ValueError(f"profile requests {expected} E1 chips; SignalSim table contains {self._E1_CHIPS}") + return self._unpack_msb_first(self._e1_words[table_index]) + + def secondary_code(self, profile: SignalProfile, component: str, prn: int) -> ChipArray | None: + del prn # SignalSim uses one common 25-symbol E1C sequence. + normalized = component.upper() + if normalized == "E1B": + return None + if normalized != "E1C": + raise ValueError(f"SignalSim E1 provider does not support component {component!r}") + length = profile.component(normalized).secondary_code_length + text = self.pilot_bit_path.read_text(encoding="utf-8") + match = re.search(r"SecondaryCodeE1\s*\[\s*1\s*\]\s*=\s*\{\s*(0x[0-9a-fA-F]+)\s*\}", text) + if match is None: + raise ValueError(f"could not locate SecondaryCodeE1 in {self.pilot_bit_path}") + if length != 25: + raise ValueError(f"profile requests secondary length {length}; SignalSim implements 25") + word = int(match.group(1), 16) + # PilotBit.cpp addresses the word LSB first by secondary-code position. + bits = np.asarray([(word >> index) & 1 for index in range(length)], dtype=np.int8) + return np.where(bits == 0, 1, -1).astype(np.int8) diff --git a/src/galileo_rx/tracking/__init__.py b/src/galileo_rx/tracking/__init__.py new file mode 100644 index 0000000..80960ba --- /dev/null +++ b/src/galileo_rx/tracking/__init__.py @@ -0,0 +1,7 @@ +"""Stateful single- and multi-channel tracking.""" + +from .tracking_channel import TrackingChannel +from .tracking_manager import TrackingManager + +__all__ = ["TrackingChannel", "TrackingManager"] + diff --git a/src/galileo_rx/tracking/cn0_estimator.py b/src/galileo_rx/tracking/cn0_estimator.py new file mode 100644 index 0000000..85eae47 --- /dev/null +++ b/src/galileo_rx/tracking/cn0_estimator.py @@ -0,0 +1,31 @@ +"""Moment-based prompt C/N0 estimator.""" + +from __future__ import annotations + +from collections import deque + +import numpy as np + + +class Cn0Estimator: + def __init__(self, integration_time_s: float, window_epochs: int = 50, *, data_sign_insensitive: bool = False) -> None: + self.integration_time_s = integration_time_s + self.data_sign_insensitive = data_sign_insensitive + self.values: deque[complex] = deque(maxlen=window_epochs) + + def update(self, prompt: complex) -> float | None: + self.values.append(prompt) + if len(self.values) < max(5, self.values.maxlen // 4): + return None + values = np.asarray(self.values) + if self.data_sign_insensitive: + # A locked BPSK data channel lies on the I axis. Decision-directed + # polarity wipeoff retains the carrier phasor while removing data. + decisions = np.where(values.real >= 0.0, 1.0, -1.0) + values = values * decisions + mean_power = float(np.mean(np.abs(values) ** 2)) + coherent_power = abs(np.mean(values)) ** 2 + noise_power = mean_power - coherent_power + if coherent_power <= 0 or noise_power <= 0: + return None + return float(10 * np.log10(coherent_power / noise_power / self.integration_time_s)) diff --git a/src/galileo_rx/tracking/correlator.py b/src/galileo_rx/tracking/correlator.py new file mode 100644 index 0000000..cb33ad2 --- /dev/null +++ b/src/galileo_rx/tracking/correlator.py @@ -0,0 +1,75 @@ +"""Vector and sample-oriented Galileo correlator banks.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +import numpy as np +import numpy.typing as npt + +from galileo_rx.signal_profiles import SignalProfile +from galileo_rx.signals.code_provider import CodeProvider +from galileo_rx.types import CorrelatorOutputs +from .nco import CarrierNco, CodeNco + + +@dataclass(slots=True) +class CorrelatorBank: + profile: SignalProfile + provider: CodeProvider + component: str + prn: int + carrier_nco: CarrierNco + code_nco: CodeNco + early_late_spacing_chips: float + very_early_late_spacing_chips: float + _code: npt.NDArray[np.int8] = field(init=False, repr=False) + + def __post_init__(self) -> None: + self._code = self.provider.primary_code(self.profile, self.component, self.prn) + + def _replica(self, phases_chips: np.ndarray, offset_chips: float) -> np.ndarray: + 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 + + 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) + wiped = array * np.exp(-1j * carrier_phase) * secondary_sign + taps = { + "very_early": -self.very_early_late_spacing_chips, + "early": -self.early_late_spacing_chips, + "prompt": 0.0, + "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()} + return CorrelatorOutputs(**values) + + +@dataclass(slots=True) +class CycleCorrelatorState: + """One-sample transaction model used as an RTL comparison oracle.""" + + accumulators: dict[str, complex] + sample_count: int = 0 + + +class SampleStreamCorrelator: + def __init__(self, bank: CorrelatorBank) -> None: + self.bank = bank + self.state = CycleCorrelatorState({name: 0j for name in ("very_early", "early", "prompt", "late", "very_late")}) + + def accept(self, sample: complex, secondary_sign: int = 1) -> None: + outputs = self.bank.correlate(np.asarray([sample]), secondary_sign) + for name in self.state.accumulators: + self.state.accumulators[name] += getattr(outputs, name) + self.state.sample_count += 1 + + def dump(self) -> CorrelatorOutputs: + result = CorrelatorOutputs(**self.state.accumulators) + self.state = CycleCorrelatorState({name: 0j for name in self.state.accumulators}) + return result diff --git a/src/galileo_rx/tracking/discriminators.py b/src/galileo_rx/tracking/discriminators.py new file mode 100644 index 0000000..ecfe083 --- /dev/null +++ b/src/galileo_rx/tracking/discriminators.py @@ -0,0 +1,51 @@ +"""Tracking discriminators with explicit output units.""" + +from __future__ import annotations + +import numpy as np + + +def pll_two_quadrant_rad(prompt: complex) -> float: + """Decision-directed carrier phase error in radians, range +/-pi/2.""" + return float(np.arctan(prompt.imag / prompt.real)) if prompt.real != 0 else float(np.sign(prompt.imag) * np.pi / 2) + + +def pll_four_quadrant_rad(prompt: complex) -> float: + """Pilot carrier phase error in radians, range +/-pi.""" + return float(np.arctan2(prompt.imag, prompt.real)) + + +def fll_phase_difference_hz(previous_prompt: complex | None, prompt: complex, integration_time_s: float) -> float: + """Cross-product/phase-difference FLL error in hertz.""" + if previous_prompt is None or integration_time_s <= 0 or previous_prompt == 0 or prompt == 0: + return 0.0 + return float(np.angle(prompt * np.conjugate(previous_prompt)) / (2.0 * np.pi * integration_time_s)) + + +def fll_bpsk_phase_difference_hz(previous_prompt: complex | None, prompt: complex, integration_time_s: float) -> float: + """BPSK-data-insensitive phase-difference FLL error in hertz. + + Squaring the inter-epoch phasor removes a possible pi-radian data-symbol + transition. The unambiguous range is consequently +/-1/(4*T). + """ + if previous_prompt is None or integration_time_s <= 0 or previous_prompt == 0 or prompt == 0: + return 0.0 + difference = prompt * np.conjugate(previous_prompt) + return float(np.angle(difference * difference) / (4.0 * np.pi * integration_time_s)) + + +def dll_normalized_envelope_chips(early: complex, late: complex, spacing_chips: float) -> float: + """Normalized E-minus-L envelope, calibrated to chip units near lock.""" + early_mag, late_mag = abs(early), abs(late) + return float(spacing_chips * (early_mag - late_mag) / max(early_mag + late_mag, np.finfo(float).tiny)) + + +def dll_noncoherent_power_chips(early: complex, late: complex, spacing_chips: float) -> float: + early_power, late_power = abs(early) ** 2, abs(late) ** 2 + return float(spacing_chips * (early_power - late_power) / max(early_power + late_power, np.finfo(float).tiny)) + + +def dll_dot_product_chips(early: complex, late: complex, prompt: complex, spacing_chips: float) -> float: + numerator = np.real((early - late) * np.conjugate(prompt)) + denominator = max(abs(prompt) ** 2, np.finfo(float).tiny) + return float(0.5 * spacing_chips * numerator / denominator) diff --git a/src/galileo_rx/tracking/lock_detector.py b/src/galileo_rx/tracking/lock_detector.py new file mode 100644 index 0000000..fb1c885 --- /dev/null +++ b/src/galileo_rx/tracking/lock_detector.py @@ -0,0 +1,39 @@ +"""Lock metrics and hysteretic failure counting.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + +from galileo_rx.types import CorrelatorOutputs + + +@dataclass(slots=True) +class LockStatus: + carrier_metric: float + code_metric: float + locked: bool + cycle_slip: bool + side_peak_suspected: bool + + +class LockDetector: + def __init__(self, failure_limit: int = 10) -> None: + self.failure_limit = failure_limit + self.failures = 0 + self.previous_phase: float | None = None + + def update(self, correlators: CorrelatorOutputs) -> LockStatus: + prompt_power = abs(correlators.prompt) ** 2 + carrier_metric = float(abs(correlators.prompt.real) / max(abs(correlators.prompt), np.finfo(float).tiny)) + code_metric = float(prompt_power / max(0.5 * (abs(correlators.early) ** 2 + abs(correlators.late) ** 2), np.finfo(float).tiny)) + phase = float(np.angle(correlators.prompt)) + cycle_slip = self.previous_phase is not None and abs(float(np.angle(np.exp(1j * (phase - self.previous_phase))))) > 0.9 * np.pi + self.previous_phase = phase + locked = carrier_metric > 0.55 and code_metric > 0.5 + self.failures = 0 if locked else self.failures + 1 + vevl = max(abs(correlators.very_early), abs(correlators.very_late)) + side_peak = vevl > 1.25 * max(abs(correlators.prompt), np.finfo(float).tiny) + return LockStatus(carrier_metric, code_metric, locked, cycle_slip, side_peak) + diff --git a/src/galileo_rx/tracking/loop_filters.py b/src/galileo_rx/tracking/loop_filters.py new file mode 100644 index 0000000..d971692 --- /dev/null +++ b/src/galileo_rx/tracking/loop_filters.py @@ -0,0 +1,70 @@ +"""Loop filters derived from noise bandwidth and damping parameters.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + + +def second_order_natural_frequency_rad_s(noise_bandwidth_hz: float, damping_ratio: float) -> float: + """Convert one-sided equivalent noise bandwidth to natural radian frequency.""" + if noise_bandwidth_hz <= 0 or damping_ratio <= 0: + raise ValueError("bandwidth and damping ratio must be positive") + return 8.0 * damping_ratio * noise_bandwidth_hz / (4.0 * damping_ratio**2 + 1.0) + + +@dataclass(slots=True) +class SecondOrderLoopFilter: + noise_bandwidth_hz: float + damping_ratio: float = 0.707 + integrator: float = 0.0 + proportional_gain: float = field(init=False) + integral_gain: float = field(init=False) + + def __post_init__(self) -> None: + wn = second_order_natural_frequency_rad_s(self.noise_bandwidth_hz, self.damping_ratio) + self.proportional_gain = 2.0 * self.damping_ratio * wn + self.integral_gain = wn**2 + + def update(self, error: float, integration_time_s: float) -> float: + if integration_time_s <= 0: + raise ValueError("integration_time_s must be positive") + self.integrator += self.integral_gain * error * integration_time_s + return self.proportional_gain * error + self.integrator + + +@dataclass(slots=True) +class ThirdOrderLoopFilter: + """Type-III filter using a repeated stable pole derived from Bn. + + The continuous correction is ``3*w*e + 3*w^2*integral(e) + + w^3*double_integral(e)``. This explicit pole placement provides a + deterministic starting point for later loop optimization. + """ + + noise_bandwidth_hz: float + damping_ratio: float = 0.707 + first_integrator: float = 0.0 + second_integrator: float = 0.0 + omega_rad_s: float = field(init=False) + + def __post_init__(self) -> None: + self.omega_rad_s = second_order_natural_frequency_rad_s(self.noise_bandwidth_hz, self.damping_ratio) + + def update(self, error: float, integration_time_s: float) -> float: + if integration_time_s <= 0: + raise ValueError("integration_time_s must be positive") + self.first_integrator += error * integration_time_s + self.second_integrator += self.first_integrator * integration_time_s + w = self.omega_rad_s + return 3.0 * w * error + 3.0 * w**2 * self.first_integrator + w**3 * self.second_integrator + + +@dataclass(slots=True) +class FirstOrderFllFilter: + noise_bandwidth_hz: float + output_hz: float = 0.0 + + def update(self, error_hz: float, integration_time_s: float) -> float: + alpha = min(1.0, 2.0 * self.noise_bandwidth_hz * integration_time_s) + self.output_hz += alpha * (error_hz - self.output_hz) + return self.output_hz diff --git a/src/galileo_rx/tracking/nco.py b/src/galileo_rx/tracking/nco.py new file mode 100644 index 0000000..9ac9232 --- /dev/null +++ b/src/galileo_rx/tracking/nco.py @@ -0,0 +1,33 @@ +"""Carrier and fractional-chip code NCO states.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + + +@dataclass(slots=True) +class CarrierNco: + sampling_frequency_hz: float + frequency_hz: float + phase_rad: float = 0.0 + + def phases(self, sample_count: int) -> np.ndarray: + phase = self.phase_rad + 2.0 * np.pi * self.frequency_hz / self.sampling_frequency_hz * np.arange(sample_count) + self.phase_rad = float((self.phase_rad + 2.0 * np.pi * self.frequency_hz / self.sampling_frequency_hz * sample_count) % (2.0 * np.pi)) + return phase + + +@dataclass(slots=True) +class CodeNco: + sampling_frequency_hz: float + code_length_chips: int + frequency_hz: float + phase_chips: float = 0.0 + + def phases(self, sample_count: int) -> np.ndarray: + phase = self.phase_chips + self.frequency_hz / self.sampling_frequency_hz * np.arange(sample_count) + self.phase_chips = float((self.phase_chips + self.frequency_hz / self.sampling_frequency_hz * sample_count) % self.code_length_chips) + return phase + diff --git a/src/galileo_rx/tracking/symbol_sync.py b/src/galileo_rx/tracking/symbol_sync.py new file mode 100644 index 0000000..338fd32 --- /dev/null +++ b/src/galileo_rx/tracking/symbol_sync.py @@ -0,0 +1,133 @@ +"""Navigation-symbol recovery and pilot secondary-code synchronization.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np +import numpy.typing as npt + + +@dataclass(frozen=True, slots=True) +class SymbolComparison: + recovered_symbols: int + compared_symbols: int + alignment_offset_symbols: int + sign_inverted: bool + symbol_errors: int + symbol_error_rate: float + + +@dataclass(frozen=True, slots=True) +class SecondaryCodeSyncResult: + phase: int + residual_frequency_hz: float + normalized_correlation: float + compared_periods: int + sign_errors: int + + +def recover_bpsk_symbols( + prompt_values: npt.ArrayLike, + epochs_per_symbol: int, + *, + ignored_epochs: int = 0, +) -> tuple[npt.NDArray[np.int8], npt.NDArray[np.float64]]: + """Accumulate decision-directed prompt I and return +/-1 raw symbols.""" + prompts = np.asarray(prompt_values, dtype=np.complex128).reshape(-1) + if epochs_per_symbol <= 0 or ignored_epochs < 0: + raise ValueError("epochs_per_symbol must be positive and ignored_epochs nonnegative") + usable = prompts[ignored_epochs:] + symbol_count = usable.size // epochs_per_symbol + coherent_i = usable[: symbol_count * epochs_per_symbol].real.reshape(symbol_count, epochs_per_symbol).sum(axis=1) + symbols = np.where(coherent_i >= 0.0, 1, -1).astype(np.int8) + return symbols, np.asarray(coherent_i, dtype=np.float64) + + +def compare_symbol_streams( + recovered: npt.ArrayLike, + expected: npt.ArrayLike, + *, + max_alignment_offset_symbols: int, + minimum_compared_symbols: int = 1, +) -> SymbolComparison: + """Compare +/-1 streams with bounded offset and one global sign ambiguity.""" + rx = np.asarray(recovered, dtype=np.int8).reshape(-1) + truth = np.asarray(expected, dtype=np.int8).reshape(-1) + if max_alignment_offset_symbols < 0 or minimum_compared_symbols <= 0: + raise ValueError("alignment bound must be nonnegative and comparison length positive") + if not set(rx.tolist()) <= {-1, 1} or not set(truth.tolist()) <= {-1, 1}: + raise ValueError("symbol streams must contain only +/-1") + candidates: list[tuple[int, int, int, bool, int]] = [] + for offset in range(-max_alignment_offset_symbols, max_alignment_offset_symbols + 1): + rx_start = max(0, -offset) + truth_start = max(0, offset) + count = min(rx.size - rx_start, truth.size - truth_start) + if count < minimum_compared_symbols: + continue + lhs = rx[rx_start : rx_start + count] + rhs = truth[truth_start : truth_start + count] + for inverted in (False, True): + errors = int(np.count_nonzero(lhs != (-rhs if inverted else rhs))) + candidates.append((errors, -count, offset, inverted, count)) + if not candidates: + raise ValueError("streams do not overlap for the requested bounded alignment") + errors, negative_count, offset, inverted, count = min(candidates) + del negative_count + return SymbolComparison(rx.size, count, offset, inverted, errors, errors / count) + + +def synchronize_secondary_code( + prompt_values: npt.ArrayLike, + secondary_code: npt.ArrayLike, + integration_time_s: float, + *, + residual_frequency_min_hz: float = -125.0, + residual_frequency_max_hz: float = 125.0, + residual_frequency_step_hz: float = 1.0, +) -> SecondaryCodeSyncResult: + """Jointly search secondary phase and residual carrier frequency. + + The correlation is complex, so an unknown constant carrier phase does not + affect the metric. Frequency search prevents residual phase rotation from + destroying the 25-period coherent sum. + """ + prompts = np.asarray(prompt_values, dtype=np.complex128).reshape(-1) + secondary = np.asarray(secondary_code, dtype=np.int8).reshape(-1) + if prompts.size == 0 or secondary.size == 0 or integration_time_s <= 0: + raise ValueError("nonempty prompts/secondary and positive integration time are required") + if not set(secondary.tolist()) <= {-1, 1}: + raise ValueError("secondary code must contain only +/-1") + frequencies = np.arange( + residual_frequency_min_hz, + residual_frequency_max_hz + 0.5 * residual_frequency_step_hz, + residual_frequency_step_hz, + dtype=np.float64, + ) + if residual_frequency_step_hz <= 0 or frequencies.size == 0: + raise ValueError("invalid residual-frequency search") + epochs = np.arange(prompts.size, dtype=np.float64) + carrier = np.exp(-2j * np.pi * frequencies[:, None] * integration_time_s * epochs[None, :]) + derotated = carrier * prompts[None, :] + denominator = max(float(np.sum(np.abs(prompts))), np.finfo(np.float64).tiny) + best_metric = -1.0 + best_phase = 0 + best_frequency_index = 0 + best_sum = 0j + for phase in range(secondary.size): + signs = secondary[(phase + np.arange(prompts.size)) % secondary.size] + sums = derotated @ signs.astype(np.float64) + index = int(np.argmax(np.abs(sums))) + metric = float(abs(sums[index]) / denominator) + if metric > best_metric: + best_metric = metric + best_phase = phase + best_frequency_index = index + best_sum = complex(sums[index]) + best_frequency = float(frequencies[best_frequency_index]) + signs = secondary[(best_phase + np.arange(prompts.size)) % secondary.size] + corrected = prompts * np.exp(-2j * np.pi * best_frequency * integration_time_s * epochs) + corrected *= np.exp(-1j * np.angle(best_sum)) + decisions = np.where(corrected.real >= 0.0, 1, -1) + errors = int(np.count_nonzero(decisions != signs)) + return SecondaryCodeSyncResult(best_phase, best_frequency, best_metric, prompts.size, errors) diff --git a/src/galileo_rx/tracking/tracking_channel.py b/src/galileo_rx/tracking/tracking_channel.py new file mode 100644 index 0000000..5f3c03c --- /dev/null +++ b/src/galileo_rx/tracking/tracking_channel.py @@ -0,0 +1,174 @@ +"""Independent Galileo component tracking channel state machine.""" + +from __future__ import annotations + +import numpy as np +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.types import AcquisitionResult, CorrelatorOutputs, TrackingMeasurement, TrackingState +from .cn0_estimator import Cn0Estimator +from .correlator import CorrelatorBank +from .discriminators import ( + dll_normalized_envelope_chips, + fll_bpsk_phase_difference_hz, + fll_phase_difference_hz, + pll_four_quadrant_rad, + pll_two_quadrant_rad, +) +from .lock_detector import LockDetector +from .loop_filters import FirstOrderFllFilter, SecondOrderLoopFilter, ThirdOrderLoopFilter +from .nco import CarrierNco, CodeNco + + +class TrackingChannel: + def __init__( + self, + profile: SignalProfile, + provider: CodeProvider, + sampling_frequency_hz: float, + config: TrackingConfig, + acquisition: AcquisitionResult, + start_sample_index: int = 0, + secondary_code_phase_at_start: int | None = None, + ) -> None: + config.validate() + self.profile = profile + self.provider = provider + self.sampling_frequency_hz = sampling_frequency_hz + self.config = config + self.acquisition = acquisition + self.component_spec = profile.component(acquisition.component) + self.integration_samples = int(round(config.integration_ms * 1e-3 * sampling_frequency_hz)) + if not np.isclose(self.integration_samples, config.integration_ms * 1e-3 * sampling_frequency_hz, atol=1e-6): + raise ValueError("tracking integration is not an integer number of samples") + initial_code_phase = ( + -acquisition.code_phase_chips + start_sample_index * profile.code_rate_hz / sampling_frequency_hz + ) % self.component_spec.primary_code_length_chips + initial_carrier_phase = 2.0 * np.pi * acquisition.carrier_frequency_hz * start_sample_index / sampling_frequency_hz + self.carrier_nco = CarrierNco(sampling_frequency_hz, acquisition.carrier_frequency_hz, initial_carrier_phase % (2 * np.pi)) + initial_code_frequency_hz = profile.code_rate_hz * ( + 1.0 + acquisition.doppler_hz / profile.carrier_frequency_hz + ) + self.code_nco = CodeNco( + sampling_frequency_hz, + self.component_spec.primary_code_length_chips, + initial_code_frequency_hz, + initial_code_phase, + ) + 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, + ) + self.pll_filter = ThirdOrderLoopFilter(config.pll_noise_bandwidth_hz, config.damping_ratio) if config.pll_order == 3 else SecondOrderLoopFilter(config.pll_noise_bandwidth_hz, config.damping_ratio) + self.dll_filter = SecondOrderLoopFilter(config.dll_noise_bandwidth_hz, config.damping_ratio) + self.fll_filter = FirstOrderFllFilter(config.fll_noise_bandwidth_hz) + self.lock_detector = LockDetector(config.loss_of_lock_epochs) + self.cn0_estimator = Cn0Estimator( + config.integration_ms * 1e-3, + data_sign_insensitive=self.component_spec.channel_type == "data", + ) + self.state = TrackingState.ACQUIRED + self.epoch = 0 + self.previous_prompt: complex | None = None + self.measurements: list[TrackingMeasurement] = [] + self.last_correlators = CorrelatorOutputs() + self.nominal_carrier_hz = acquisition.carrier_frequency_hz + # Acquisition reports the NCO frequency as IF center plus Doppler. Code + # aiding must use range-rate Doppler only; feeding the IF itself into + # f_code/f_carrier would be a MHz-scale unit-boundary error. + self.carrier_center_hz = acquisition.carrier_frequency_hz - acquisition.doppler_hz + self._pll_reference_frequency_hz = acquisition.carrier_frequency_hz + self._pll_active = False + self.secondary_epoch_offset = ( + int(secondary_code_phase_at_start) + if secondary_code_phase_at_start is not None + else int(np.floor(start_sample_index / (self.component_spec.primary_code_period_s * sampling_frequency_hz))) + ) + + def _secondary_sign(self) -> int: + if self.component_spec.channel_type != "pilot" or self.config.secondary_code_mode == "none": + return 1 + secondary = self.provider.secondary_code(self.profile, self.acquisition.component, self.acquisition.prn) + if secondary is None: + return 1 + return int(secondary[(self.secondary_epoch_offset + self.epoch) % secondary.size]) + + def process_epoch(self, samples: npt.NDArray[np.generic]) -> TrackingMeasurement: + if len(samples) != self.integration_samples: + raise ValueError(f"tracking epoch requires {self.integration_samples} samples") + dt_s = self.config.integration_ms * 1e-3 + correlators = self.correlator.correlate(samples, self._secondary_sign()) + self.last_correlators = correlators + pll_error = pll_two_quadrant_rad(correlators.prompt) if self.component_spec.channel_type == "data" else pll_four_quadrant_rad(correlators.prompt) + fll_error = ( + fll_bpsk_phase_difference_hz(self.previous_prompt, correlators.prompt, dt_s) + if self.component_spec.channel_type == "data" + else fll_phase_difference_hz(self.previous_prompt, correlators.prompt, dt_s) + ) + dll_error = dll_normalized_envelope_chips(correlators.early, correlators.late, self.config.early_late_spacing_chips) + elapsed_ms = (self.epoch + 1) * self.config.integration_ms + if self.state == TrackingState.ACQUIRED: + self.state = TrackingState.FLL_PULL_IN if self.config.use_fll_assist else TrackingState.PLL_TRACK + use_fll = self.config.use_fll_assist and elapsed_ms <= self.config.pull_in_duration_ms + if use_fll: + correction_hz = self.fll_filter.update(fll_error, dt_s) + self.carrier_nco.frequency_hz += correction_hz + self.state = TrackingState.FLL_PULL_IN + else: + if not self._pll_active: + self._pll_reference_frequency_hz = self.carrier_nco.frequency_hz + self._pll_active = True + correction_hz = self.pll_filter.update(pll_error, dt_s) / (2.0 * np.pi) + self.carrier_nco.frequency_hz = self._pll_reference_frequency_hz + correction_hz + self.state = TrackingState.PLL_TRACK + code_correction_hz = self.dll_filter.update(dll_error, dt_s) + carrier_doppler_hz = self.carrier_nco.frequency_hz - self.carrier_center_hz + aiding_hz = ( + carrier_doppler_hz / self.profile.carrier_frequency_hz * self.profile.code_rate_hz + if self.config.carrier_aiding + else 0.0 + ) + # Positive E-L means the local phase must be retarded for the phase + # convention used by CodeNco, hence the negative actuator sign. + self.code_nco.frequency_hz = self.profile.code_rate_hz + aiding_hz - code_correction_hz + cn0 = self.cn0_estimator.update(correlators.prompt) + lock = self.lock_detector.update(correlators) + reacquire = self.lock_detector.failures >= self.config.loss_of_lock_epochs + if reacquire: + self.state = TrackingState.REACQUISITION + elif cn0 is not None and cn0 < self.config.weak_signal_cn0_db_hz: + self.state = TrackingState.WEAK_SIGNAL + measurement = TrackingMeasurement( + epoch=self.epoch, + time_s=(self.epoch + 1) * dt_s, + state=self.state, + prompt_i=float(correlators.prompt.real), + prompt_q=float(correlators.prompt.imag), + early_magnitude=float(abs(correlators.early)), + late_magnitude=float(abs(correlators.late)), + pll_error_rad=pll_error, + fll_error_hz=fll_error, + dll_error_chips=dll_error, + carrier_frequency_hz=self.carrier_nco.frequency_hz, + code_frequency_hz=self.code_nco.frequency_hz, + carrier_phase_rad=self.carrier_nco.phase_rad, + code_phase_chips=self.code_nco.phase_chips, + cn0_db_hz=cn0, + carrier_lock_metric=lock.carrier_metric, + code_lock_metric=lock.code_metric, + cycle_slip=lock.cycle_slip, + side_peak_suspected=lock.side_peak_suspected, + reacquisition_requested=reacquire, + ) + self.previous_prompt = correlators.prompt + self.measurements.append(measurement) + self.epoch += 1 + return measurement + + def process(self, samples: npt.NDArray[np.generic]) -> list[TrackingMeasurement]: + array = np.asarray(samples) + epochs = array.size // self.integration_samples + return [self.process_epoch(array[index * self.integration_samples : (index + 1) * self.integration_samples]) for index in range(epochs)] diff --git a/src/galileo_rx/tracking/tracking_manager.py b/src/galileo_rx/tracking/tracking_manager.py new file mode 100644 index 0000000..9e2a2d3 --- /dev/null +++ b/src/galileo_rx/tracking/tracking_manager.py @@ -0,0 +1,30 @@ +"""Multi-channel scheduler with separate E1B/E1C logical channels.""" + +from __future__ import annotations + +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.types import AcquisitionResult, TrackingMeasurement +from .tracking_channel import TrackingChannel + + +class TrackingManager: + def __init__(self, profile: SignalProfile, provider: CodeProvider, sampling_frequency_hz: float, config: TrackingConfig) -> None: + self.profile = profile + self.provider = provider + self.sampling_frequency_hz = sampling_frequency_hz + self.config = config + self.channels: dict[tuple[int, str], TrackingChannel] = {} + + def add_channel(self, acquisition: AcquisitionResult, start_sample_index: int = 0) -> TrackingChannel: + key = (acquisition.prn, acquisition.component) + channel = TrackingChannel(self.profile, self.provider, self.sampling_frequency_hz, self.config, acquisition, start_sample_index) + self.channels[key] = channel + return channel + + def process_epoch(self, samples: npt.NDArray, keys: list[tuple[int, str]] | None = None) -> dict[tuple[int, str], TrackingMeasurement]: + selected = keys if keys is not None else list(self.channels) + return {key: self.channels[key].process_epoch(samples) for key in selected} diff --git a/src/galileo_rx/types.py b/src/galileo_rx/types.py new file mode 100644 index 0000000..c16297e --- /dev/null +++ b/src/galileo_rx/types.py @@ -0,0 +1,102 @@ +"""Shared, unit-explicit data transfer objects.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + +import numpy as np +import numpy.typing as npt + +ComplexArray = npt.NDArray[np.complexfloating[Any, Any]] +FloatArray = npt.NDArray[np.floating[Any]] + + +class TrackingState(str, Enum): + IDLE = "IDLE" + ACQUIRED = "ACQUIRED" + FLL_PULL_IN = "FLL_PULL_IN" + FLL_PLL_TRANSITION = "FLL_PLL_TRANSITION" + PLL_TRACK = "PLL_TRACK" + WEAK_SIGNAL = "WEAK_SIGNAL" + LOSS_OF_LOCK = "LOSS_OF_LOCK" + REACQUISITION = "REACQUISITION" + + +@dataclass(slots=True) +class AcquisitionResult: + detected: bool + prn: int + component: str + code_phase_samples: float + code_phase_chips: float + doppler_hz: float + carrier_frequency_hz: float + peak_power: float + second_peak_power: float + detection_metric: float + estimated_cn0_db_hz: float | None + coherent_integration_ms: float + noncoherent_accumulations: int + raw_code_phase_samples: int = 0 + raw_doppler_hz: float = 0.0 + metrics: dict[str, float] = field(default_factory=dict) + side_peak_suspected: bool = False + + +@dataclass(slots=True) +class CorrelatorOutputs: + very_early: complex = 0j + early: complex = 0j + prompt: complex = 0j + late: complex = 0j + very_late: complex = 0j + + +@dataclass(slots=True) +class TrackingMeasurement: + epoch: int + time_s: float + state: TrackingState + prompt_i: float + prompt_q: float + early_magnitude: float + late_magnitude: float + pll_error_rad: float + fll_error_hz: float + dll_error_chips: float + carrier_frequency_hz: float + code_frequency_hz: float + carrier_phase_rad: float + code_phase_chips: float + cn0_db_hz: float | None + carrier_lock_metric: float + code_lock_metric: float + cycle_slip: bool = False + side_peak_suspected: bool = False + reacquisition_requested: bool = False + + +@dataclass(slots=True) +class SatelliteTruth: + prn: int + component: str + initial_code_delay_chips: float + initial_code_delay_samples: float + initial_doppler_hz: float + doppler_rate_hz_s: float + initial_carrier_phase_rad: float + amplitude: float + cn0_db_hz: float | None + + +@dataclass(slots=True) +class GenerationTruth: + sampling_frequency_hz: float + intermediate_frequency_hz: float + input_is_real_if: bool + start_sample: int + sample_count: int + satellites: list[SatelliteTruth] + diff --git a/src/galileo_rx/verification/__init__.py b/src/galileo_rx/verification/__init__.py new file mode 100644 index 0000000..6186e04 --- /dev/null +++ b/src/galileo_rx/verification/__init__.py @@ -0,0 +1 @@ +"""External-vector verification flows.""" diff --git a/src/galileo_rx/verification/signalsim_validation.py b/src/galileo_rx/verification/signalsim_validation.py new file mode 100644 index 0000000..b4008ae --- /dev/null +++ b/src/galileo_rx/verification/signalsim_validation.py @@ -0,0 +1,591 @@ +"""Executable end-to-end validation against a local globsky/SignalSim tree.""" + +from __future__ import annotations + +from dataclasses import asdict, replace +from hashlib import sha256 +import json +import math +from pathlib import Path +import subprocess +import time +from typing import Any + +import numpy as np +import yaml + +from galileo_rx.acquisition.acquisition_manager import AcquisitionManager +from galileo_rx.config import AcquisitionConfig, TrackingConfig +from galileo_rx.diagnostics.plots import ( + acquisition_map, + acquisition_peak_neighborhood, + acquisition_profiles, + prompt_constellation, + tracking_diagnostics, +) +from galileo_rx.diagnostics.reports import export_tracking_csv +from galileo_rx.fixed_point.fixed_acquisition import FixedPcpsAcquisition +from galileo_rx.fixed_point.formats import FixedFormat +from galileo_rx.io.signalsim_reader import SignalSimReader, SignalSimSampleSpec +from galileo_rx.rtl_model.vector_export import export_timed_complex_hex +from galileo_rx.signal_profiles import galileo_e1_profile +from galileo_rx.signals.galileo_e1 import e1_local_replica +from galileo_rx.signals.signalsim_codes import SignalSimCodeProvider +from galileo_rx.tracking.correlator import CorrelatorBank +from galileo_rx.tracking.nco import CarrierNco, CodeNco +from galileo_rx.tracking.symbol_sync import recover_bpsk_symbols, synchronize_secondary_code +from galileo_rx.tracking.tracking_channel import TrackingChannel + + +def locate_signalsim(start: str | Path) -> Path: + start_path = Path(start).resolve() + roots = [start_path, *start_path.parents[:3], Path.home() / "Desktop", Path.home() / "projects", Path.home() / "src"] + names = ("SignalSim", "signalsim", "signal-sim") + candidates: list[Path] = [] + for root in roots: + for name in names: + candidates.extend((root / name, root / "signalsim" / name)) + for candidate in candidates: + if (candidate / "IFdataGen" / "CMakeLists.txt").is_file() and (candidate / "src" / "PrnGenerate.cpp").is_file(): + return candidate.resolve() + raise FileNotFoundError("could not locate a SignalSim source tree from the workspace or common project directories") + + +def _git(source: Path, *args: str) -> str: + return subprocess.check_output(["git", "-C", str(source), *args], text=True).strip() + + +def _hash_file(path: Path) -> str: + digest = sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def _save_figure(figure: Any, path: Path) -> None: + figure.savefig(path, dpi=140, bbox_inches="tight") + import matplotlib.pyplot as plt + plt.close(figure) + + +def _tracking_summary(measurements: list[Any], if_hz: float, expected_doppler_hz: float) -> dict[str, Any]: + tail_count = min(200, len(measurements)) + tail = measurements[-tail_count:] + doppler = np.asarray([item.carrier_frequency_hz - if_hz for item in tail], dtype=np.float64) + dll = np.asarray([item.dll_error_chips for item in tail], dtype=np.float64) + prompt = np.asarray([complex(item.prompt_i, item.prompt_q) for item in tail], dtype=np.complex128) + return { + "epoch_count": len(measurements), + "tail_epoch_count": tail_count, + "final_state": measurements[-1].state.value, + "reacquisition_epoch_count": sum(item.reacquisition_requested for item in measurements), + "loss_of_lock_flag_count": sum(item.state.value in {"LOSS_OF_LOCK", "REACQUISITION"} for item in measurements), + "estimated_doppler_hz": float(np.mean(doppler)), + "doppler_error_hz": float(np.mean(doppler) - expected_doppler_hz), + "doppler_jitter_hz_rms": float(np.std(doppler)), + "dll_error_chips_rms": float(np.sqrt(np.mean(dll * dll))), + "mean_prompt_magnitude": float(np.mean(np.abs(prompt))), + "mean_carrier_lock_metric": float(np.mean([item.carrier_lock_metric for item in tail])), + "mean_code_lock_metric": float(np.mean([item.code_lock_metric for item in tail])), + "median_cn0_db_hz": float(np.nanmedian([np.nan if item.cn0_db_hz is None else item.cn0_db_hz for item in tail])), + } + + +def validate_signalsim(source_path: str | Path, config_path: str | Path, output_dir: str | Path, *, mode: str | None = None) -> tuple[dict[str, Any], bool]: + source = Path(source_path).resolve() + config_file = Path(config_path).resolve() + project_root = config_file.parent.parent + cfg = yaml.safe_load(config_file.read_text(encoding="utf-8")) + selected_mode = mode or cfg.get("mode", "quick") + if selected_mode not in {"quick", "full"}: + raise ValueError("SignalSim validation mode must be quick or full") + out = Path(output_dir).resolve() + out.mkdir(parents=True, exist_ok=True) + vector_cfg = cfg["vector"] + samples_path = project_root / vector_cfg["samples"] + if not samples_path.is_file(): + generator = project_root / "tools" / "generate_signalsim_vectors.py" + subprocess.run([str(generator), "--signalsim-path", str(source), "--project-root", str(project_root)], check=True) + fs = float(vector_cfg["sampling_frequency_hz"]) + if_hz = float(vector_cfg["intermediate_frequency_hz"]) + expected_doppler_hz = float(vector_cfg["expected_doppler_hz"]) + prn = int(vector_cfg["prn"]) + reader = SignalSimReader(samples_path, SignalSimSampleSpec(vector_cfg["sample_format"]), memory_map=True) + samples = reader.read() + profile = galileo_e1_profile() + provider = SignalSimCodeProvider(source) + acq_cfg = AcquisitionConfig(**cfg["acquisition"], parallelism="doppler_vectorized") + manager = AcquisitionManager(profile, provider, fs, acq_cfg) + acquisition_samples = manager.engine.coherent_samples * acq_cfg.noncoherent_accumulations + acquisition: dict[str, Any] = {} + grids: dict[str, Any] = {} + for component in ("E1B", "E1C"): + started = time.perf_counter() + result, grid = manager.acquire_one(samples[:acquisition_samples], component, prn, if_hz) + elapsed = time.perf_counter() - started + item = asdict(result) + item.update({ + "expected_prn": prn, + "expected_doppler_hz": expected_doppler_hz, + "raw_doppler_error_hz": result.raw_doppler_hz - expected_doppler_hz, + "interpolated_doppler_error_hz": result.doppler_hz - expected_doppler_hz, + "expected_code_phase_samples": vector_cfg.get("expected_code_phase_samples"), + "code_phase_error_samples": None, + "processing_time_s": elapsed, + "overflow_count": 0, + }) + acquisition[component] = item + grids[component] = grid + _save_figure(acquisition_map(grid.power, grid.doppler_bins_hz, f"SignalSim {component} PRN {prn}"), out / f"acquisition_map_{component.lower()}.png") + _save_figure(acquisition_profiles(grid.power, grid.doppler_bins_hz), out / f"acquisition_profiles_{component.lower()}.png") + _save_figure(acquisition_peak_neighborhood(grid.power, fs / profile.code_rate_hz), out / f"acquisition_peak_neighborhood_{component.lower()}.png") + + e1b_result = manager.acquire_one(samples[:acquisition_samples], "E1B", prn, if_hz)[0] + primary_samples = int(round(fs * profile.component("E1B").primary_code_period_s)) + boundary_start = int(round(e1b_result.code_phase_samples + math.ceil((acquisition_samples - e1b_result.code_phase_samples) / primary_samples) * primary_samples)) + tracking_cfg = cfg["tracking"] + e1b_channel = TrackingChannel( + profile, + provider, + fs, + TrackingConfig( + integration_ms=float(tracking_cfg["e1b_integration_ms"]), + pull_in_duration_ms=float(tracking_cfg["e1b_pull_in_ms"]), + pll_noise_bandwidth_hz=float(tracking_cfg["pll_noise_bandwidth_hz"]), + fll_noise_bandwidth_hz=float(tracking_cfg["fll_noise_bandwidth_hz"]), + dll_noise_bandwidth_hz=float(tracking_cfg["dll_noise_bandwidth_hz"]), + secondary_code_mode="none", + loss_of_lock_epochs=40, + ), + e1b_result, + boundary_start, + ) + e1b_measurements = e1b_channel.process(samples[boundary_start:]) + e1b_summary = _tracking_summary(e1b_measurements, if_hz, expected_doppler_hz) + export_tracking_csv(out / "tracking_e1b.csv", e1b_measurements) + _save_figure(tracking_diagnostics(e1b_measurements), out / "tracking_e1b.png") + _save_figure(prompt_constellation(e1b_measurements, ignored_epochs=150), out / "prompt_constellation_e1b.png") + + ignored_epochs = int(round(0.300 / (float(tracking_cfg["e1b_integration_ms"]) * 1e-3))) + prompt_e1b = np.asarray([complex(item.prompt_i, item.prompt_q) for item in e1b_measurements]) + symbols, soft_symbols = recover_bpsk_symbols(prompt_e1b, 2, ignored_epochs=ignored_epochs) + sync_bits = np.asarray([0, 1, 0, 1, 1, 0, 0, 0, 0, 0], dtype=np.int8) + sync_signs = (2 * sync_bits - 1).astype(np.int8) + sync_matches: list[dict[str, Any]] = [] + for inversion in (1, -1): + for index in range(max(0, symbols.size - sync_signs.size + 1)): + errors = int(np.count_nonzero(inversion * symbols[index : index + 10] != sync_signs)) + if errors == 0: + sync_matches.append({"symbol_index": index, "sign_inverted": inversion == -1, "errors": errors}) + symbol_report = { + "status": "raw_symbols_recovered_page_decoding_not_implemented", + "comparison_scope": "known_10_symbol_INAV_sync_words_only", + "recovered_symbols": int(symbols.size), + "compared_symbols": len(sync_matches) * 10, + "initial_ignored_tracking_epochs": ignored_epochs, + "initial_ignored_time_s": ignored_epochs * float(tracking_cfg["e1b_integration_ms"]) * 1e-3, + "bounded_alignment": "code boundary from PCPS; sync search within remaining 1.876 s", + "sync_matches": sync_matches, + "symbol_errors": 0 if sync_matches else None, + "symbol_error_rate": 0.0 if sync_matches else None, + "median_soft_symbol_magnitude": float(np.median(np.abs(soft_symbols))), + "frame_or_page_sync": len(sync_matches) >= 2, + "fec_decoding": "not_implemented", + "crc_validation": "not_implemented", + } + + e1c_result = manager.acquire_one(samples[:acquisition_samples], "E1C", prn, if_hz)[0] + e1c_boundary = int(round(e1c_result.code_phase_samples + math.ceil((acquisition_samples - e1c_result.code_phase_samples) / primary_samples) * primary_samples)) + open_epoch_count = min(100, (samples.size - e1c_boundary) // primary_samples) + carrier = CarrierNco(fs, e1c_result.carrier_frequency_hz, 2 * np.pi * e1c_result.carrier_frequency_hz * e1c_boundary / fs % (2 * np.pi)) + code = CodeNco( + fs, + profile.component("E1C").primary_code_length_chips, + profile.code_rate_hz * (1 + e1c_result.doppler_hz / profile.carrier_frequency_hz), + (-e1c_result.code_phase_chips + e1c_boundary * profile.code_rate_hz / fs) % 4092, + ) + bank = CorrelatorBank(profile, provider, "E1C", prn, carrier, code, 0.25, 0.75) + open_prompts = np.asarray([ + bank.correlate(samples[e1c_boundary + epoch * primary_samples : e1c_boundary + (epoch + 1) * primary_samples]).prompt + for epoch in range(open_epoch_count) + ], dtype=np.complex128) + secondary = provider.secondary_code(profile, "E1C", prn) + assert secondary is not None + sync = synchronize_secondary_code(open_prompts, secondary, 0.004, residual_frequency_step_hz=0.5) + epochs = np.arange(min(25, open_prompts.size), dtype=np.float64) + corrected = open_prompts[: epochs.size] * np.exp(-2j * np.pi * sync.residual_frequency_hz * 0.004 * epochs) + wipe = secondary[(sync.phase + np.arange(epochs.size)) % secondary.size] + wiped_gain_db = float(10 * np.log10(abs(np.sum(corrected * wipe)) ** 2 / (epochs.size * np.mean(np.abs(corrected) ** 2)))) + unwiped_gain_db = float(10 * np.log10(max(abs(np.sum(corrected)) ** 2, np.finfo(np.float64).tiny) / (epochs.size * np.mean(np.abs(corrected) ** 2)))) + adjusted = replace( + e1c_result, + doppler_hz=e1c_result.doppler_hz + sync.residual_frequency_hz, + carrier_frequency_hz=e1c_result.carrier_frequency_hz + sync.residual_frequency_hz, + ) + e1c_channel = TrackingChannel( + profile, + provider, + fs, + TrackingConfig( + integration_ms=float(tracking_cfg["e1c_integration_ms"]), + pull_in_duration_ms=float(tracking_cfg["e1c_pull_in_ms"]), + pll_noise_bandwidth_hz=float(tracking_cfg["pll_noise_bandwidth_hz"]), + fll_noise_bandwidth_hz=float(tracking_cfg["fll_noise_bandwidth_hz"]), + dll_noise_bandwidth_hz=float(tracking_cfg["dll_noise_bandwidth_hz"]), + secondary_code_mode="wipeoff", + loss_of_lock_epochs=20, + ), + adjusted, + e1c_boundary, + secondary_code_phase_at_start=sync.phase, + ) + e1c_measurements = e1c_channel.process(samples[e1c_boundary:]) + e1c_summary = _tracking_summary(e1c_measurements, if_hz, expected_doppler_hz) + export_tracking_csv(out / "tracking_e1c.csv", e1c_measurements) + _save_figure(tracking_diagnostics(e1c_measurements), out / "tracking_e1c.png") + _save_figure(prompt_constellation(e1c_measurements, ignored_epochs=75), out / "prompt_constellation_e1c.png") + secondary_report = asdict(sync) | { + "sequence_source": str(provider.pilot_bit_path), + "sequence_classification": "SignalSim_implemented_sequence_not_independently_certified_against_ICD", + "wiped_25_period_coherent_gain_db": wiped_gain_db, + "unwiped_25_period_coherent_gain_db": unwiped_gain_db, + "wipeoff_gain_improvement_db": wiped_gain_db - unwiped_gain_db, + } + + # Bit-true boundary comparison on the same external IQ8 coherent block. + # The floating reference remains float64/complex128; only the explicit + # input/FFT/power boundaries are quantized. + fixed_samples = samples[:primary_samples] / np.float64(128.0) + fixed_bins_hz = np.arange(1500.0, 2125.0 + 0.5 * 125.0, 125.0, dtype=np.float64) + fixed_replica = e1_local_replica( + profile, + provider, + "E1C", + prn, + primary_samples, + fs, + code_rate_hz=profile.code_rate_hz * (1.0 + 1750.0 / profile.carrier_frequency_hz), + ) + fixed_grid = FixedPcpsAcquisition( + FixedFormat(10, 8), + FixedFormat(32, 20), + FixedFormat(64, 32, signed=False), + ).search(fixed_samples, fixed_replica, fs, if_hz + fixed_bins_hz) + time_axis_s = np.arange(primary_samples, dtype=np.float64) / fs + reference_power = np.asarray([ + np.abs( + np.fft.ifft( + np.fft.fft(fixed_samples * np.exp(-2j * np.pi * (if_hz + doppler_hz) * time_axis_s)) + * np.conjugate(np.fft.fft(fixed_replica)) + ) + ) ** 2 + for doppler_hz in fixed_bins_hz + ], dtype=np.float64) + reference_peak = np.unravel_index(int(np.argmax(reference_power)), reference_power.shape) + reference_normalized = reference_power / np.max(reference_power) + fixed_normalized = fixed_grid.power / np.max(fixed_grid.power) + fixed_error = fixed_normalized - reference_normalized + fixed_report = { + "reference_dtype": "complex128/float64", + "input_format": {"total_bits": 10, "fractional_bits": 8}, + "fft_boundary_format": {"total_bits": 32, "fractional_bits": 20}, + "power_format": {"total_bits": 64, "fractional_bits": 32, "signed": False}, + "reference_doppler_bin_hz": float(fixed_bins_hz[reference_peak[0]]), + "fixed_doppler_bin_hz": float(fixed_bins_hz[fixed_grid.peak_doppler_index]), + "reference_code_phase_samples": int(reference_peak[1]), + "fixed_code_phase_samples": fixed_grid.peak_code_index, + "doppler_bin_agreement": bool(fixed_grid.peak_doppler_index == reference_peak[0]), + "code_phase_agreement": bool(fixed_grid.peak_code_index == reference_peak[1]), + "acquisition_decision_agreement": bool((np.max(reference_power) / np.mean(reference_power) >= 2.5) == (np.max(fixed_grid.power) / np.mean(fixed_grid.power) >= 2.5)), + "normalized_absolute_error_mean": float(np.mean(np.abs(fixed_error))), + "normalized_rms_error": float(np.sqrt(np.mean(fixed_error * fixed_error))), + "normalized_peak_error": float(np.max(np.abs(fixed_error))), + "overflow_count": fixed_grid.overflow_count, + } + + carrier_fcw = int(round((if_hz + expected_doppler_hz) / fs * 2**32)) & 0xFFFFFFFF + code_fcw = int(round(profile.code_rate_hz * (1 + expected_doppler_hz / profile.carrier_frequency_hz) / fs * 2**48)) + rtl_registers = { + "SAMPLE_WIDTH": 8, + "SAMPLE_FRACTIONAL_BITS": 0, + "INPUT_REPRESENTATION": "COMPLEX_IQ", + "IQ_ORDER": "IQ", + "SAMPLING_FREQUENCY_HZ": fs, + "INTERMEDIATE_FREQUENCY_HZ": if_hz, + "PRN": prn, + "COMPONENT": "E1C", + "ACQ_DOPPLER_MIN_HZ": acq_cfg.doppler_min_hz, + "ACQ_DOPPLER_MAX_HZ": acq_cfg.doppler_max_hz, + "ACQ_DOPPLER_STEP_HZ": acq_cfg.doppler_step_hz, + "ACQ_COHERENT_SAMPLES": manager.engine.coherent_samples, + "ACQ_NONCOHERENT_COUNT": acq_cfg.noncoherent_accumulations, + "ACQ_FFT_LENGTH": manager.engine.coherent_samples, + "TRACK_E1B_INTEGRATION_SAMPLES": int(round(fs * float(tracking_cfg["e1b_integration_ms"]) * 1e-3)), + "TRACK_E1C_INTEGRATION_SAMPLES": primary_samples, + "CARRIER_NCO_PHASE_WIDTH": 32, + "CARRIER_NCO_FCW": carrier_fcw, + "CODE_NCO_PHASE_WIDTH": 48, + "CODE_NCO_FCW": code_fcw, + "E1C_SECONDARY_PHASE": sync.phase, + "ACQ_TO_TRACK_CODE_PHASE_SAMPLES": acquisition["E1C"]["code_phase_samples"], + "ACQ_TO_TRACK_DOPPLER_HZ": expected_doppler_hz, + "NOTE": "Symbolic names; APB addresses remain to be assigned by the RTL IP register map", + } + rtl_manifest = export_timed_complex_hex( + out / "rtl_vectors", + "signalsim_acquisition_input_iq8", + samples[:acquisition_samples], + FixedFormat(8, 0), + fs, + rtl_registers, + ) + + criteria = cfg["pass_criteria"] + acquisition_pass = all( + acquisition[component]["detected"] + and acquisition[component]["prn"] == prn + and abs(acquisition[component]["raw_doppler_error_hz"]) <= criteria["maximum_raw_doppler_error_hz"] + for component in ("E1B", "E1C") + ) + def tracking_pass(summary: dict[str, Any]) -> bool: + return ( + summary["final_state"] == "PLL_TRACK" + and summary["reacquisition_epoch_count"] == 0 + and abs(summary["doppler_error_hz"]) <= criteria["maximum_tracking_doppler_error_hz"] + and summary["doppler_jitter_hz_rms"] <= criteria["maximum_tracking_doppler_jitter_hz"] + and summary["dll_error_chips_rms"] <= criteria["maximum_dll_rms_chips"] + ) + full_vector_report: dict[str, Any] = {} + full_vector_pass = True + if selected_mode == "full": + definitions = { + "nominal_cn0_single_prn": {1: 1828.0}, + "negative_doppler": {27: -2637.0}, + "multiple_prns": {1: 1828.0, 4: 2171.0, 19: -367.0, 21: -712.0, 27: -2637.0}, + } + generator = project_root / "tools" / "generate_signalsim_vectors.py" + for vector_name, expected_by_prn in definitions.items(): + metadata_path = project_root / "verification" / "vectors" / "signalsim" / vector_name / "metadata.yaml" + if not metadata_path.is_file(): + subprocess.run([str(generator), "--signalsim-path", str(source), "--project-root", str(project_root), "--vector", vector_name], check=True) + metadata = yaml.safe_load(metadata_path.read_text(encoding="utf-8")) + path = project_root / metadata["output"] + vector_samples = SignalSimReader(path, SignalSimSampleSpec("IQ8"), memory_map=True).read(acquisition_samples) + broad_cfg = AcquisitionConfig( + doppler_min_hz=-4000.0, + doppler_max_hz=3000.0, + doppler_step_hz=250.0, + coherent_integration_ms=4.0, + noncoherent_accumulations=5, + detection_threshold=2.5, + zero_padding_mode="none", + parallelism="doppler_vectorized", + ) + broad_manager = AcquisitionManager(profile, provider, fs, broad_cfg) + vector_results: dict[int, Any] = {} + for vector_prn, vector_truth_hz in expected_by_prn.items(): + vector_result, _ = broad_manager.acquire_one(vector_samples, "E1C", vector_prn, if_hz) + item = asdict(vector_result) | { + "expected_doppler_hz": vector_truth_hz, + "raw_doppler_error_hz": vector_result.raw_doppler_hz - vector_truth_hz, + } + item["pass"] = bool(vector_result.detected and abs(item["raw_doppler_error_hz"]) <= 250.0) + full_vector_pass &= item["pass"] + vector_results[vector_prn] = item + full_vector_report[vector_name] = { + "sha256": _hash_file(path), + "size_bytes": path.stat().st_size, + "acquisition": vector_results, + } + + # Negative-Doppler tracking is checked over the long vector. Transient + # lock-detector assertions during pull-in are reported, while the pass + # condition applies after convergence. + negative_path = project_root / yaml.safe_load( + (project_root / "verification" / "vectors" / "signalsim" / "negative_doppler" / "metadata.yaml").read_text(encoding="utf-8") + )["output"] + negative_samples = SignalSimReader(negative_path, SignalSimSampleSpec("IQ8"), memory_map=True).read() + negative_acq_manager = AcquisitionManager( + profile, + provider, + fs, + AcquisitionConfig(doppler_min_hz=-4000.0, doppler_max_hz=-1500.0, doppler_step_hz=250.0, noncoherent_accumulations=5, zero_padding_mode="none"), + ) + negative_acq, _ = negative_acq_manager.acquire_one(negative_samples[:acquisition_samples], "E1B", 27, if_hz) + negative_start = int(round(negative_acq.code_phase_samples + math.ceil((acquisition_samples - negative_acq.code_phase_samples) / primary_samples) * primary_samples)) + negative_channel = TrackingChannel( + profile, + provider, + fs, + TrackingConfig(integration_ms=2.0, pull_in_duration_ms=300.0, pll_noise_bandwidth_hz=10.0, fll_noise_bandwidth_hz=20.0, dll_noise_bandwidth_hz=1.0, secondary_code_mode="none", loss_of_lock_epochs=40), + negative_acq, + negative_start, + ) + negative_measurements = negative_channel.process(negative_samples[negative_start:]) + negative_summary = _tracking_summary(negative_measurements, if_hz, -2637.0) + post_pull_in = negative_measurements[500:] + negative_summary["post_pull_in_reacquisition_epoch_count"] = sum(item.reacquisition_requested for item in post_pull_in) + negative_tracking_pass = bool( + negative_summary["final_state"] == "PLL_TRACK" + and negative_summary["post_pull_in_reacquisition_epoch_count"] == 0 + and abs(negative_summary["doppler_error_hz"]) <= criteria["maximum_tracking_doppler_error_hz"] + and negative_summary["doppler_jitter_hz_rms"] <= criteria["maximum_tracking_doppler_jitter_hz"] + and negative_summary["dll_error_chips_rms"] <= criteria["maximum_dll_rms_chips"] + ) + negative_summary["pass"] = negative_tracking_pass + full_vector_report["negative_doppler"]["tracking_e1b"] = negative_summary + full_vector_pass &= negative_tracking_pass + + # Nominal-C/N0 uses the pilot because secondary wipeoff removes the + # 100 ms overlay transition from the carrier loop. + nominal_path = project_root / yaml.safe_load( + (project_root / "verification" / "vectors" / "signalsim" / "nominal_cn0_single_prn" / "metadata.yaml").read_text(encoding="utf-8") + )["output"] + nominal_samples = SignalSimReader(nominal_path, SignalSimSampleSpec("IQ8"), memory_map=True).read() + nominal_acq_manager = AcquisitionManager( + profile, + provider, + fs, + AcquisitionConfig(doppler_min_hz=1000.0, doppler_max_hz=2500.0, doppler_step_hz=250.0, noncoherent_accumulations=5, zero_padding_mode="none"), + ) + nominal_acq, _ = nominal_acq_manager.acquire_one(nominal_samples[:acquisition_samples], "E1C", 1, if_hz) + nominal_start = int(round(nominal_acq.code_phase_samples + math.ceil((acquisition_samples - nominal_acq.code_phase_samples) / primary_samples) * primary_samples)) + nominal_open_count = min(100, (nominal_samples.size - nominal_start) // primary_samples) + nominal_carrier = CarrierNco(fs, nominal_acq.carrier_frequency_hz, 2 * np.pi * nominal_acq.carrier_frequency_hz * nominal_start / fs % (2 * np.pi)) + nominal_code = CodeNco( + fs, + 4092, + profile.code_rate_hz * (1 + nominal_acq.doppler_hz / profile.carrier_frequency_hz), + (-nominal_acq.code_phase_chips + nominal_start * profile.code_rate_hz / fs) % 4092, + ) + nominal_bank = CorrelatorBank(profile, provider, "E1C", 1, nominal_carrier, nominal_code, 0.25, 0.75) + nominal_prompts = np.asarray([ + nominal_bank.correlate(nominal_samples[nominal_start + epoch * primary_samples : nominal_start + (epoch + 1) * primary_samples]).prompt + for epoch in range(nominal_open_count) + ], dtype=np.complex128) + nominal_sync = synchronize_secondary_code(nominal_prompts, secondary, 0.004, residual_frequency_step_hz=0.5) + nominal_adjusted = replace( + nominal_acq, + doppler_hz=nominal_acq.doppler_hz + nominal_sync.residual_frequency_hz, + carrier_frequency_hz=nominal_acq.carrier_frequency_hz + nominal_sync.residual_frequency_hz, + ) + nominal_channel = TrackingChannel( + profile, + provider, + fs, + TrackingConfig(integration_ms=4.0, pull_in_duration_ms=200.0, pll_noise_bandwidth_hz=10.0, fll_noise_bandwidth_hz=10.0, dll_noise_bandwidth_hz=1.0, secondary_code_mode="wipeoff", loss_of_lock_epochs=20), + nominal_adjusted, + nominal_start, + secondary_code_phase_at_start=nominal_sync.phase, + ) + nominal_measurements = nominal_channel.process(nominal_samples[nominal_start:]) + nominal_summary = _tracking_summary(nominal_measurements, if_hz, 1828.0) + nominal_summary["secondary_sync"] = asdict(nominal_sync) + nominal_summary["post_pull_in_reacquisition_epoch_count"] = sum(item.reacquisition_requested for item in nominal_measurements[75:]) + nominal_tracking_pass = bool( + nominal_summary["final_state"] == "PLL_TRACK" + and nominal_summary["post_pull_in_reacquisition_epoch_count"] == 0 + and abs(nominal_summary["doppler_error_hz"]) <= criteria["maximum_tracking_doppler_error_hz"] + and nominal_summary["doppler_jitter_hz_rms"] <= criteria["maximum_tracking_doppler_jitter_hz"] + and nominal_summary["dll_error_chips_rms"] <= criteria["maximum_dll_rms_chips"] + and nominal_sync.normalized_correlation >= criteria["minimum_secondary_correlation"] + and nominal_sync.sign_errors == 0 + ) + nominal_summary["pass"] = nominal_tracking_pass + full_vector_report["nominal_cn0_single_prn"]["tracking_e1c"] = nominal_summary + full_vector_pass &= nominal_tracking_pass + + passes = { + "acquisition": acquisition_pass, + "e1b_tracking": tracking_pass(e1b_summary), + "e1b_raw_symbol_sync": len(sync_matches) >= int(criteria["minimum_inav_sync_words"]), + "e1c_secondary_sync": sync.normalized_correlation >= criteria["minimum_secondary_correlation"] and sync.sign_errors <= criteria["maximum_secondary_sign_errors"], + "e1c_tracking": tracking_pass(e1c_summary), + "fixed_point_acquisition": bool( + fixed_report["doppler_bin_agreement"] + and fixed_report["code_phase_agreement"] + and fixed_report["acquisition_decision_agreement"] + and fixed_report["overflow_count"] == 0 + ), + } + if selected_mode == "full": + passes["full_vector_set"] = full_vector_pass + report = { + "simulator": { + "name": "SignalSim", + "path": str(source), + "remote": _git(source, "remote", "get-url", "origin"), + "commit": _git(source, "rev-parse", "HEAD"), + "git_status": _git(source, "status", "--short", "--branch"), + }, + "vector": { + **vector_cfg, + "path": str(samples_path), + "size_bytes": samples_path.stat().st_size, + "complex_sample_count": reader.sample_count, + "sha256": _hash_file(samples_path), + "internal_dtype": "complex128", + }, + "mode": selected_mode, + "acquisition": acquisition, + "tracking": {"E1B": e1b_summary, "E1C": e1c_summary}, + "e1b_symbols": symbol_report, + "e1c_secondary": secondary_report, + "fixed_point_acquisition": fixed_report, + "rtl_vector_manifest": rtl_manifest, + "full_vectors": full_vector_report, + "pass_criteria": criteria, + "passes": passes, + "overall_pass": all(passes.values()), + "known_failures": [ + "SignalSim E1 output cannot select E1B-only or E1C-only", + "SignalSim E1 uses BOC(1,1), not full CBOC", + "SignalSim has no native real-IF output", + "SignalSim IQ2 writer omits the output-byte assignment at this commit", + "Full I/NAV receiver-side FEC/page/CRC decoding is not implemented", + "SignalSim does not emit exact sample-side code-delay or navigation-symbol truth", + ], + } + (out / "signalsim_validation_report.json").write_text(json.dumps(report, indent=2, default=str) + "\n", encoding="utf-8") + (out / "signalsim_e1b_symbol_comparison.json").write_text(json.dumps(symbol_report, indent=2) + "\n", encoding="utf-8") + reports_root = project_root / "reports" + reports_root.mkdir(parents=True, exist_ok=True) + acquisition_report = { + "simulator": report["simulator"], + "vector": report["vector"], + "acquisition": acquisition, + "pass": acquisition_pass, + "truth_limitations": "SignalSim CLI logs rounded Doppler but does not export exact code delay", + } + (reports_root / "signalsim_acquisition_report.json").write_text(json.dumps(acquisition_report, indent=2, default=str) + "\n", encoding="utf-8") + (reports_root / "signalsim_e1b_symbol_comparison.json").write_text(json.dumps(symbol_report, indent=2) + "\n", encoding="utf-8") + markdown = [ + "# SignalSim acquisition and tracking validation", + "", + f"Overall {selected_mode}-mode result: **{'PASS' if report['overall_pass'] else 'FAIL'}**", + "", + "| Check | Result |", + "|---|---:|", + *[f"| {name} | {'PASS' if value else 'FAIL'} |" for name, value in passes.items()], + "", + f"E1B tracking Doppler: {e1b_summary['estimated_doppler_hz']:.3f} Hz (error {e1b_summary['doppler_error_hz']:+.3f} Hz).", + f"E1C tracking Doppler: {e1c_summary['estimated_doppler_hz']:.3f} Hz (error {e1c_summary['doppler_error_hz']:+.3f} Hz).", + f"E1C secondary phase: {sync.phase}; normalized correlation {sync.normalized_correlation:.4f}; sign errors {sync.sign_errors}/{sync.compared_periods}.", + f"E1B exact I/NAV sync words: {len(sync_matches)}. Full page decoding is not implemented.", + ] + (out / "signalsim_validation_report.md").write_text("\n".join(markdown) + "\n", encoding="utf-8") + acquisition_markdown = [ + "# SignalSim acquisition report", + "", + "| Component | Detected | Raw Doppler (Hz) | Truth (Hz) | Code phase (samples) | Peak/second |", + "|---|---:|---:|---:|---:|---:|", + *[ + f"| {component} | {item['detected']} | {item['raw_doppler_hz']:.1f} | {expected_doppler_hz:.1f} | {item['code_phase_samples']:.3f} | {item['detection_metric']:.3f} |" + for component, item in acquisition.items() + ], + "", + "SignalSim does not export exact code-delay truth. Agreement between independently acquired E1B and E1C code phases is used as a cross-component consistency check; no code-phase error is fabricated.", + ] + (reports_root / "signalsim_acquisition_report.md").write_text("\n".join(acquisition_markdown) + "\n", encoding="utf-8") + return report, bool(report["overall_pass"]) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..0d2ecaf --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + + +@pytest.fixture(scope="session") +def signalsim_validation_result(tmp_path_factory: pytest.TempPathFactory): + source_candidates = [Path("signalsim/SignalSim"), Path("../SignalSim"), Path("../signalsim/SignalSim")] + source = next((path.resolve() for path in source_candidates if (path / "IFdataGen" / "CMakeLists.txt").is_file()), None) + if source is None: + pytest.skip("local SignalSim checkout with IFdataGen/CMakeLists.txt is genuinely unavailable") + from galileo_rx.verification.signalsim_validation import validate_signalsim + + output = tmp_path_factory.mktemp("signalsim_validation") + report, passed = validate_signalsim(source, "configs/signalsim_validation.yaml", output, mode="full") + return report, passed, output diff --git a/tests/integration/test_acquisition.py b/tests/integration/test_acquisition.py new file mode 100644 index 0000000..562e953 --- /dev/null +++ b/tests/integration/test_acquisition.py @@ -0,0 +1,86 @@ +import numpy as np +import pytest + +from galileo_rx.acquisition.acquisition_manager import AcquisitionManager +from galileo_rx.config import AcquisitionConfig, FrontendConfig, RfConfig +from galileo_rx.frontend.frontend_chain import FrontendChain +from galileo_rx.signal_profiles import galileo_e1_profile +from galileo_rx.signals.code_provider import SyntheticCodeProvider +from galileo_rx.signals.signal_generator import ChannelModelConfig, SatelliteSignalConfig, SyntheticSignalGenerator + + +def make_signal(fs: float, delay_chips: float, doppler_hz: float, periods: int = 2, real_if: bool = False): + profile = galileo_e1_profile() + provider = SyntheticCodeProvider() + if_hz = 2.0e6 if real_if else 0.0 + generator = SyntheticSignalGenerator( + profile, + provider, + fs, + if_hz, + real_if, + [SatelliteSignalConfig(prn=5, component="E1C", code_delay_chips=delay_chips, doppler_hz=doppler_hz, cn0_db_hz=48.0)], + ChannelModelConfig(seed=22), + ) + samples, _ = generator.generate(int(round(periods * 0.004 * fs))) + if real_if: + rf = RfConfig(sample_format="float64", input_is_real_if=True, sampling_frequency_hz=fs, intermediate_frequency_hz=if_hz) + samples = FrontendChain(rf, FrontendConfig()).process(samples) + return samples, profile, provider + + +@pytest.mark.parametrize("doppler_hz", [-1250.0, 1750.0]) +def test_pcps_detects_doppler_and_code(doppler_hz: float) -> None: + fs = 4.092e6 + delay_chips = 37.0 + samples, profile, provider = make_signal(fs, delay_chips, doppler_hz) + config = AcquisitionConfig( + doppler_min_hz=-2500, + doppler_max_hz=2500, + doppler_step_hz=250, + coherent_integration_ms=4, + noncoherent_accumulations=2, + detection_threshold=2.0, + zero_padding_mode="none", + parabolic_interpolation=False, + ) + result, _ = AcquisitionManager(profile, provider, fs, config).acquire_one(samples, "E1C", 5) + assert result.detected + assert result.raw_doppler_hz == doppler_hz + assert abs(result.code_phase_chips - delay_chips) <= 0.26 + + +def test_pcps_noninteger_samples_per_chip_and_boundary() -> None: + fs = 4.5e6 + delay_chips = 4091.2 + samples, profile, provider = make_signal(fs, delay_chips, 500.0) + config = AcquisitionConfig( + doppler_min_hz=0, + doppler_max_hz=1000, + doppler_step_hz=250, + coherent_integration_ms=4, + noncoherent_accumulations=2, + detection_threshold=1.8, + zero_padding_mode="none", + parabolic_interpolation=False, + ) + result, _ = AcquisitionManager(profile, provider, fs, config).acquire_one(samples, "E1C", 5) + circular_error = min(abs(result.code_phase_chips - delay_chips), 4092 - abs(result.code_phase_chips - delay_chips)) + assert result.detected and circular_error < 0.6 + + +def test_real_if_acquisition() -> None: + fs = 8.0e6 + samples, profile, provider = make_signal(fs, 21.0, 750.0, real_if=True) + config = AcquisitionConfig( + doppler_min_hz=250, + doppler_max_hz=1250, + doppler_step_hz=250, + coherent_integration_ms=4, + noncoherent_accumulations=2, + detection_threshold=1.7, + zero_padding_mode="none", + parabolic_interpolation=False, + ) + result, _ = AcquisitionManager(profile, provider, fs, config).acquire_one(samples, "E1C", 5) + assert result.detected and result.raw_doppler_hz == 750.0 diff --git a/tests/integration/test_detection_and_lock_loss.py b/tests/integration/test_detection_and_lock_loss.py new file mode 100644 index 0000000..d0ddf89 --- /dev/null +++ b/tests/integration/test_detection_and_lock_loss.py @@ -0,0 +1,33 @@ +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.tracking.tracking_channel import TrackingChannel +from galileo_rx.types import AcquisitionResult, TrackingState + + +def test_noise_only_does_not_pass_peak_ratio_threshold() -> None: + fs = 4.092e6 + rng = np.random.default_rng(345) + samples = rng.standard_normal(int(0.008 * fs)) + 1j * rng.standard_normal(int(0.008 * fs)) + config = AcquisitionConfig( + doppler_min_hz=-500, doppler_max_hz=500, doppler_step_hz=250, + noncoherent_accumulations=2, detection_threshold=2.5, zero_padding_mode="none", + ) + result, _ = AcquisitionManager(galileo_e1_profile(), SyntheticCodeProvider(), fs, config).acquire_one(samples, "E1C", 1) + assert not result.detected + + +def test_consecutive_failures_request_reacquisition() -> None: + fs = 4.092e6 + acquisition = AcquisitionResult( + True, 1, "E1B", 0.0, 0.0, 0.0, 0.0, 1.0, 0.1, 10.0, None, 4.0, 1 + ) + config = TrackingConfig(use_fll_assist=False, loss_of_lock_epochs=3) + channel = TrackingChannel(galileo_e1_profile(), SyntheticCodeProvider(), fs, config, acquisition) + zero_epoch = np.zeros(channel.integration_samples, dtype=np.complex128) + measurements = [channel.process_epoch(zero_epoch) for _ in range(3)] + assert measurements[-1].reacquisition_requested + assert measurements[-1].state == TrackingState.REACQUISITION diff --git a/tests/integration/test_fixed_acquisition.py b/tests/integration/test_fixed_acquisition.py new file mode 100644 index 0000000..e9f7649 --- /dev/null +++ b/tests/integration/test_fixed_acquisition.py @@ -0,0 +1,33 @@ +import numpy as np + +from galileo_rx.fixed_point.bittrue_pipeline import compare_acquisition, sweep_acquisition_word_lengths +from galileo_rx.fixed_point.fixed_acquisition import FixedPcpsAcquisition + + +def test_fixed_acquisition_peak_agrees_with_float() -> None: + rng = np.random.default_rng(4) + count = 256 + fs = 1.024e6 + replica = np.where(rng.integers(0, 2, count), 1.0, -1.0) + delay = 73 + doppler = 4000.0 + time_s = np.arange(count) / fs + samples = np.roll(replica, delay) * np.exp(2j * np.pi * doppler * time_s) + bins = np.asarray([-4000.0, 0.0, 4000.0]) + reference = np.asarray([ + np.abs(np.fft.ifft(np.fft.fft(samples * np.exp(-2j * np.pi * frequency * time_s)) * np.conjugate(np.fft.fft(replica)))) ** 2 + for frequency in bins + ]) + fixed = FixedPcpsAcquisition().search(samples, replica, fs, bins) + comparison = compare_acquisition(reference / count**2, fixed, threshold=2.5) + assert comparison.doppler_bin_agreement + assert comparison.code_phase_agreement + assert comparison.acquisition_decision_agreement + + +def test_word_length_sweep_reports_each_width() -> None: + rng = np.random.default_rng(7) + replica = np.where(rng.integers(0, 2, 64), 1.0, -1.0) + reports = sweep_acquisition_word_lengths(np.roll(replica, 9).astype(complex), replica, 1e6, np.asarray([0.0]), [6, 8]) + assert [item["input_bits"] for item in reports] == [6, 8] + assert all(item["code_phase_agreement"] for item in reports) diff --git a/tests/integration/test_long_integration.py b/tests/integration/test_long_integration.py new file mode 100644 index 0000000..cbd8c39 --- /dev/null +++ b/tests/integration/test_long_integration.py @@ -0,0 +1,57 @@ +import numpy as np + +from galileo_rx.acquisition.acquisition_manager import AcquisitionManager +from galileo_rx.config import AcquisitionConfig +from galileo_rx.signal_profiles import galileo_e1_profile +from galileo_rx.signals.code_provider import SyntheticCodeProvider +from galileo_rx.signals.signal_generator import ChannelModelConfig, SatelliteSignalConfig, SyntheticSignalGenerator + + +def test_e1c_secondary_wipeoff_for_eight_ms() -> None: + fs = 4.092e6 + profile = galileo_e1_profile() + provider = SyntheticCodeProvider() + generator = SyntheticSignalGenerator( + profile, provider, fs, + satellites=[SatelliteSignalConfig(8, "E1C", code_delay_chips=12.0, doppler_hz=500.0)], + channel=ChannelModelConfig(add_awgn=False), + ) + samples, _ = generator.generate(int(0.008 * fs)) + config = AcquisitionConfig( + doppler_min_hz=500, + doppler_max_hz=500, + doppler_step_hz=250, + coherent_integration_ms=8, + noncoherent_accumulations=1, + detection_threshold=1.5, + zero_padding_mode="none", + secondary_code_mode="wipeoff", + parabolic_interpolation=False, + ) + result, _ = AcquisitionManager(profile, provider, fs, config).acquire_one(samples, "E1C", 8) + assert result.detected and abs(result.code_phase_chips - 12.0) < 0.3 + + +def test_e1b_bit_transition_hypothesis() -> None: + fs = 4.092e6 + profile = galileo_e1_profile() + provider = SyntheticCodeProvider() + generator = SyntheticSignalGenerator( + profile, provider, fs, + satellites=[SatelliteSignalConfig(9, "E1B", code_delay_chips=20.0, navigation_bits=[1, -1])], + channel=ChannelModelConfig(add_awgn=False), + ) + samples, _ = generator.generate(int(0.008 * fs)) + config = AcquisitionConfig( + doppler_min_hz=0, + doppler_max_hz=0, + doppler_step_hz=250, + coherent_integration_ms=8, + noncoherent_accumulations=1, + detection_threshold=1.5, + zero_padding_mode="none", + bit_transition_mode="hypothesis", + parabolic_interpolation=False, + ) + result, _ = AcquisitionManager(profile, provider, fs, config).acquire_one(samples, "E1B", 9) + assert result.detected and abs(result.code_phase_chips - 20.0) < 0.3 diff --git a/tests/integration/test_signalsim_acquisition.py b/tests/integration/test_signalsim_acquisition.py new file mode 100644 index 0000000..d6a727d --- /dev/null +++ b/tests/integration/test_signalsim_acquisition.py @@ -0,0 +1,25 @@ +import pytest + + +@pytest.mark.signalsim +def test_signalsim_pcps_detects_e1b_and_e1c(signalsim_validation_result) -> None: + report, _, output = signalsim_validation_result + for component in ("E1B", "E1C"): + result = report["acquisition"][component] + assert result["detected"] + assert result["prn"] == 1 + assert abs(result["raw_doppler_error_hz"]) <= 250.0 + assert result["detection_metric"] >= 2.5 + assert result["overflow_count"] == 0 + assert (output / f"acquisition_map_{component.lower()}.png").is_file() + # SignalSim does not export code-delay truth, but the independently acquired + # E1B and E1C components share the same propagation delay. + delta = abs(report["acquisition"]["E1B"]["code_phase_samples"] - report["acquisition"]["E1C"]["code_phase_samples"]) + assert delta < 1.0 + fixed = report["fixed_point_acquisition"] + assert fixed["doppler_bin_agreement"] + assert fixed["code_phase_agreement"] + assert fixed["acquisition_decision_agreement"] + assert fixed["overflow_count"] == 0 + assert fixed["normalized_rms_error"] < 0.005 + assert report["rtl_vector_manifest"]["sample_count"] == 5 * 32736 diff --git a/tests/integration/test_signalsim_code_conventions.py b/tests/integration/test_signalsim_code_conventions.py new file mode 100644 index 0000000..6b16f26 --- /dev/null +++ b/tests/integration/test_signalsim_code_conventions.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest + +from galileo_rx.signal_profiles import galileo_e1_profile +from galileo_rx.signals.signalsim_codes import SignalSimCodeProvider + + +def _checkout() -> Path: + candidates = [Path("signalsim/SignalSim"), Path("../SignalSim"), Path("../signalsim/SignalSim")] + for candidate in candidates: + if (candidate / "src" / "MemoryCode.dat").is_file(): + return candidate.resolve() + pytest.skip("local globsky/SignalSim checkout with src/MemoryCode.dat is unavailable") + + +@pytest.mark.signalsim +def test_signalsim_e1_code_table_unpacking_and_components() -> None: + provider = SignalSimCodeProvider(_checkout()) + profile = galileo_e1_profile() + e1b = provider.primary_code(profile, "E1B", 1) + e1c = provider.primary_code(profile, "E1C", 1) + assert e1b.dtype == np.int8 and e1b.size == 4092 + assert e1c.dtype == np.int8 and e1c.size == 4092 + assert set(e1b.tolist()) == {-1, 1} + assert not np.array_equal(e1b, e1c) + # First table word is 0xf5d71013 and SignalSim reads it MSB first. + np.testing.assert_array_equal(e1b[:8], [-1, -1, -1, -1, 1, -1, 1, -1]) + # Every 32-word storage sector has one discarded padding bit. Chip 1023 + # is therefore word 32 bit 31, not word 31 bit 0. + words = provider._e1_words[0] # executable check of the external table layout + expected_chip_1023 = -1 if (int(words[32]) >> 31) & 1 else 1 + assert int(e1b[1023]) == expected_chip_1023 + + +@pytest.mark.signalsim +def test_signalsim_e1c_secondary_is_25_symbols_lsb_first() -> None: + provider = SignalSimCodeProvider(_checkout()) + secondary = provider.secondary_code(galileo_e1_profile(), "E1C", 1) + assert secondary is not None and secondary.size == 25 + # 0x9b501c positions are addressed least-significant bit first. + expected_bits = np.asarray([(0x9B501C >> index) & 1 for index in range(25)]) + np.testing.assert_array_equal(secondary, np.where(expected_bits == 0, 1, -1)) + assert provider.secondary_code(galileo_e1_profile(), "E1B", 1) is None diff --git a/tests/integration/test_signalsim_e1b_symbols.py b/tests/integration/test_signalsim_e1b_symbols.py new file mode 100644 index 0000000..9fa3ce4 --- /dev/null +++ b/tests/integration/test_signalsim_e1b_symbols.py @@ -0,0 +1,15 @@ +import pytest + + +@pytest.mark.signalsim +def test_signalsim_e1b_raw_symbols_contain_periodic_inav_sync(signalsim_validation_result) -> None: + report, _, _ = signalsim_validation_result + symbols = report["e1b_symbols"] + assert symbols["recovered_symbols"] >= 450 + assert symbols["comparison_scope"] == "known_10_symbol_INAV_sync_words_only" + assert symbols["symbol_errors"] == 0 + assert symbols["frame_or_page_sync"] + indices = [match["symbol_index"] for match in symbols["sync_matches"]] + # I/NAV even and odd parts each begin with sync, 250 symbols (1 s) apart. + assert any(right - left == 250 for left, right in zip(indices, indices[1:])) + assert symbols["fec_decoding"] == "not_implemented" diff --git a/tests/integration/test_signalsim_e1c_secondary_code.py b/tests/integration/test_signalsim_e1c_secondary_code.py new file mode 100644 index 0000000..007765e --- /dev/null +++ b/tests/integration/test_signalsim_e1c_secondary_code.py @@ -0,0 +1,13 @@ +import pytest + + +@pytest.mark.signalsim +def test_signalsim_e1c_secondary_sync_and_coherent_gain(signalsim_validation_result) -> None: + report, _, _ = signalsim_validation_result + sync = report["e1c_secondary"] + assert sync["phase"] == 10 + assert sync["normalized_correlation"] > 0.9 + assert sync["sign_errors"] == 0 + assert sync["compared_periods"] >= 100 + assert sync["wiped_25_period_coherent_gain_db"] > 10.0 + assert sync["wipeoff_gain_improvement_db"] > 10.0 diff --git a/tests/integration/test_signalsim_full_vectors.py b/tests/integration/test_signalsim_full_vectors.py new file mode 100644 index 0000000..94cf9ec --- /dev/null +++ b/tests/integration/test_signalsim_full_vectors.py @@ -0,0 +1,19 @@ +import pytest + + +@pytest.mark.signalsim +def test_signalsim_full_multi_vector_regression(signalsim_validation_result) -> None: + report, passed, _ = signalsim_validation_result + assert passed + assert report["passes"]["full_vector_set"] + vectors = report["full_vectors"] + assert vectors["nominal_cn0_single_prn"]["acquisition"][1]["pass"] + assert vectors["nominal_cn0_single_prn"]["tracking_e1c"]["pass"] + assert vectors["negative_doppler"]["acquisition"][27]["pass"] + negative_tracking = vectors["negative_doppler"]["tracking_e1b"] + assert negative_tracking["pass"] + assert negative_tracking["post_pull_in_reacquisition_epoch_count"] == 0 + assert negative_tracking["estimated_doppler_hz"] < 0.0 + multiple = vectors["multiple_prns"]["acquisition"] + assert set(multiple) == {1, 4, 19, 21, 27} + assert all(result["pass"] for result in multiple.values()) diff --git a/tests/integration/test_signalsim_reader.py b/tests/integration/test_signalsim_reader.py new file mode 100644 index 0000000..afaf068 --- /dev/null +++ b/tests/integration/test_signalsim_reader.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import numpy as np +import pytest + +from galileo_rx.io.signalsim_reader import SignalSimReader, SignalSimSampleSpec + + +def test_iq8_signed_i_then_q_and_double_precision(tmp_path) -> None: + path = tmp_path / "iq8.bin" + path.write_bytes(bytes([0x80, 0x7F, 0xFF, 0x01, 0x02, 0xFE])) + samples = SignalSimReader(path, SignalSimSampleSpec("IQ8")).read() + assert samples.dtype == np.complex128 + np.testing.assert_array_equal(samples, [-128 + 127j, -1 + 1j, 2 - 2j]) + + +def test_iq16_little_endian_scaling_and_qi_order(tmp_path) -> None: + path = tmp_path / "iq16.bin" + np.asarray([100, -200, -300, 400], dtype=" None: + path = tmp_path / "iq4.bin" + path.write_bytes(bytes([0x17, 0xA9, 0x80])) + np.testing.assert_array_equal(SignalSimReader(path, SignalSimSampleSpec("IQ4")).read(), [1 + 7j, -2 - 1j, 0 + 0j]) + + +def test_iq2_packed_order_and_offset(tmp_path) -> None: + path = tmp_path / "iq2.bin" + # sample 0: I=+1,Q=-1 -> 0xd; sample 1: I=-1,Q=+1 -> 0x7 + path.write_bytes(bytes([0x7D])) + reader = SignalSimReader(path, SignalSimSampleSpec("IQ2")) + np.testing.assert_array_equal(reader.read(), [1 - 1j, -1 + 1j]) + np.testing.assert_array_equal(reader.read(1, 1), [-1 + 1j]) + + +def test_truncation_odd_iq_and_block_streaming(tmp_path) -> None: + path = tmp_path / "odd.bin" + path.write_bytes(b"\x01\x02\x03") + reader = SignalSimReader(path, SignalSimSampleSpec("IQ8")) + with pytest.raises(ValueError, match="truncated"): + _ = reader.sample_count + + complete = tmp_path / "complete.bin" + complete.write_bytes(bytes(range(12))) + reader = SignalSimReader(complete, SignalSimSampleSpec("IQ8"), memory_map=True) + blocks = list(reader.blocks(2, offset_samples=1, count=4)) + assert [block.size for block in blocks] == [2, 2] + np.testing.assert_array_equal(np.concatenate(blocks), [2 + 3j, 4 + 5j, 6 + 7j, 8 + 9j]) + with pytest.raises(EOFError, match="only 1 remain"): + reader.read(2, 5) diff --git a/tests/integration/test_signalsim_tracking.py b/tests/integration/test_signalsim_tracking.py new file mode 100644 index 0000000..6cfbdb1 --- /dev/null +++ b/tests/integration/test_signalsim_tracking.py @@ -0,0 +1,15 @@ +import pytest + + +@pytest.mark.signalsim +def test_signalsim_e1b_and_e1c_tracking_lock(signalsim_validation_result) -> None: + report, _, output = signalsim_validation_result + for component in ("E1B", "E1C"): + result = report["tracking"][component] + assert result["final_state"] == "PLL_TRACK" + assert result["reacquisition_epoch_count"] == 0 + assert abs(result["doppler_error_hz"]) < 10.0 + assert result["doppler_jitter_hz_rms"] < 5.0 + assert result["dll_error_chips_rms"] < 0.15 + assert result["mean_code_lock_metric"] > 1.0 + assert (output / f"tracking_{component.lower()}.csv").is_file() diff --git a/tests/integration/test_tracking.py b/tests/integration/test_tracking.py new file mode 100644 index 0000000..449152d --- /dev/null +++ b/tests/integration/test_tracking.py @@ -0,0 +1,103 @@ +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.signal_generator import ChannelModelConfig, SatelliteSignalConfig, SyntheticSignalGenerator +from galileo_rx.tracking.tracking_channel import TrackingChannel +from galileo_rx.tracking.tracking_manager import TrackingManager +from galileo_rx.types import AcquisitionResult, TrackingState + + +def test_acquisition_to_tracking_and_multichannel_manager() -> None: + fs = 4.092e6 + profile = galileo_e1_profile() + provider = SyntheticCodeProvider() + satellites = [ + SatelliteSignalConfig(2, "E1C", code_delay_chips=81.0, doppler_hz=1000.0, carrier_phase_rad=0.1, cn0_db_hz=52.0), + ] + generator = SyntheticSignalGenerator(profile, provider, fs, satellites=satellites, channel=ChannelModelConfig(seed=9)) + samples, _ = generator.generate(int(0.12 * fs)) + acquisition_config = AcquisitionConfig( + doppler_min_hz=0, + doppler_max_hz=2000, + doppler_step_hz=250, + coherent_integration_ms=4, + noncoherent_accumulations=2, + detection_threshold=2.0, + zero_padding_mode="none", + parabolic_interpolation=False, + ) + acquisition, _ = AcquisitionManager(profile, provider, fs, acquisition_config).acquire_one(samples, "E1C", 2) + assert acquisition.detected + tracking_config = TrackingConfig( + integration_ms=4.0, + use_fll_assist=True, + pull_in_duration_ms=20.0, + secondary_code_mode="wipeoff", + loss_of_lock_epochs=20, + ) + manager = TrackingManager(profile, provider, fs, tracking_config) + channel = manager.add_channel(acquisition) + measurements = channel.process(samples[: 25 * channel.integration_samples]) + assert len(measurements) == 25 + assert not measurements[-1].reacquisition_requested + assert measurements[-1].state in {TrackingState.PLL_TRACK, TrackingState.WEAK_SIGNAL} + assert abs(measurements[-1].carrier_frequency_hz - 1000.0) < 100.0 + assert np.median([abs(item.dll_error_chips) for item in measurements[-10:]]) < 0.1 + + +def test_tracking_under_doppler_rate() -> None: + fs = 4.092e6 + profile = galileo_e1_profile() + provider = SyntheticCodeProvider() + generator = SyntheticSignalGenerator( + profile, + provider, + fs, + satellites=[SatelliteSignalConfig(4, "E1B", code_delay_chips=11.0, doppler_hz=-500.0, doppler_rate_hz_s=100.0)], + channel=ChannelModelConfig(add_awgn=False), + ) + samples, _ = generator.generate(int(0.08 * fs)) + acquisition_config = AcquisitionConfig( + doppler_min_hz=-1000, + doppler_max_hz=0, + doppler_step_hz=250, + coherent_integration_ms=4, + noncoherent_accumulations=1, + detection_threshold=1.5, + zero_padding_mode="none", + ) + acquisition, _ = AcquisitionManager(profile, provider, fs, acquisition_config).acquire_one(samples, "E1B", 4) + channel = TrackingChannel(profile, provider, fs, TrackingConfig(pull_in_duration_ms=40.0), acquisition) + measurements = channel.process(samples) + expected_hz = -500.0 + 100.0 * measurements[-1].time_s + assert abs(measurements[-1].carrier_frequency_hz - expected_hz) < 100.0 + + +def test_code_aiding_uses_doppler_not_nonzero_if_center() -> None: + fs = 8.184e6 + profile = galileo_e1_profile() + provider = SyntheticCodeProvider() + if_hz = 2.046e6 + doppler_hz = 1800.0 + acquisition = AcquisitionResult( + detected=True, + prn=1, + component="E1B", + code_phase_samples=0.0, + code_phase_chips=0.0, + doppler_hz=doppler_hz, + carrier_frequency_hz=if_hz + doppler_hz, + peak_power=1.0, + second_peak_power=0.1, + detection_metric=10.0, + estimated_cn0_db_hz=None, + coherent_integration_ms=4.0, + noncoherent_accumulations=1, + ) + channel = TrackingChannel(profile, provider, fs, TrackingConfig(use_fll_assist=False), acquisition) + expected_hz = profile.code_rate_hz * (1.0 + doppler_hz / profile.carrier_frequency_hz) + assert abs(channel.code_nco.frequency_hz - expected_hz) < 1e-9 + assert channel.carrier_center_hz == if_hz diff --git a/tests/regression/test_complete_receiver.py b/tests/regression/test_complete_receiver.py new file mode 100644 index 0000000..ecd4c67 --- /dev/null +++ b/tests/regression/test_complete_receiver.py @@ -0,0 +1,42 @@ +import numpy as np + +from galileo_rx.acquisition.acquisition_manager import AcquisitionManager +from galileo_rx.config import AcquisitionConfig, TrackingConfig +from galileo_rx.fixed_point.fixed_correlator import FixedCorrelator +from galileo_rx.fixed_point.formats import FixedFormat +from galileo_rx.signal_profiles import galileo_e1_profile +from galileo_rx.signals.code_provider import SyntheticCodeProvider +from galileo_rx.signals.signal_generator import ChannelModelConfig, SatelliteSignalConfig, SyntheticSignalGenerator +from galileo_rx.tracking.tracking_channel import TrackingChannel + + +def test_complete_float_and_fixed_prompt_regression() -> None: + fs = 4.092e6 + profile, provider = galileo_e1_profile(), SyntheticCodeProvider() + generator = SyntheticSignalGenerator( + profile, + provider, + fs, + satellites=[SatelliteSignalConfig(11, "E1B", code_delay_chips=24.0, doppler_hz=-750.0, carrier_phase_rad=0.05, cn0_db_hz=50.0)], + channel=ChannelModelConfig(seed=123), + ) + samples, _ = generator.generate(int(0.06 * fs)) + acquisition_config = AcquisitionConfig( + doppler_min_hz=-1500, doppler_max_hz=0, doppler_step_hz=250, + noncoherent_accumulations=2, detection_threshold=2.0, zero_padding_mode="none", parabolic_interpolation=False, + ) + acquisition, _ = AcquisitionManager(profile, provider, fs, acquisition_config).acquire_one(samples, "E1B", 11) + assert acquisition.detected and acquisition.raw_doppler_hz == -750.0 + channel = TrackingChannel(profile, provider, fs, TrackingConfig(pull_in_duration_ms=12.0), acquisition) + measurements = channel.process(samples) + assert len(measurements) == 15 and not measurements[-1].reacquisition_requested + + count = channel.integration_samples + phases = (-acquisition.code_phase_chips + np.arange(count) * profile.code_rate_hz / fs) + code = provider.primary_code(profile, "E1B", 11) + replica = code[np.floor(phases).astype(np.int64) % code.size] * np.where(np.mod(phases, 1.0) < 0.5, 1, -1) + # This vector is intentionally unnormalized AWGN, so use an input format + # with integer headroom rather than the post-AGC Q1.7 default. + fixed_prompt = FixedCorrelator(fs, acquisition.carrier_frequency_hz, sample_format=FixedFormat(12, 4)).correlate_prompt(samples[:count], replica) + float_prompt = np.sum(samples[:count] * np.exp(-2j * np.pi * acquisition.carrier_frequency_hz * np.arange(count) / fs) * replica) + assert abs(fixed_prompt.complex_value - float_prompt) / abs(float_prompt) < 0.03 diff --git a/tests/unit/test_acquisition_metrics.py b/tests/unit/test_acquisition_metrics.py new file mode 100644 index 0000000..850068f --- /dev/null +++ b/tests/unit/test_acquisition_metrics.py @@ -0,0 +1,27 @@ +import numpy as np + +from galileo_rx.acquisition.detector import parabolic_offset +from galileo_rx.acquisition.metrics import calculate_metrics, circular_guard_mask + + +def test_circular_peak_exclusion_wraparound() -> None: + mask = circular_guard_mask(10, 0, 2) + assert not mask[0] and not mask[1] and not mask[2] + assert not mask[8] and not mask[9] + assert mask[3] + + +def test_second_peak_ignores_guard() -> None: + power = np.ones(20) + power[0] = 100 + power[19] = 80 + power[8] = 10 + metrics = calculate_metrics(power, 0, 1) + assert metrics["second_peak"] == 10 + assert metrics["peak_to_second"] == 10 + + +def test_parabolic_peak_interpolation() -> None: + assert parabolic_offset(3.0, 4.0, 3.0) == 0.0 + assert parabolic_offset(2.0, 4.0, 3.0) > 0 + diff --git a/tests/unit/test_acquisition_parallelism.py b/tests/unit/test_acquisition_parallelism.py new file mode 100644 index 0000000..0278676 --- /dev/null +++ b/tests/unit/test_acquisition_parallelism.py @@ -0,0 +1,18 @@ +import numpy as np + +from galileo_rx.config import AcquisitionConfig +from galileo_rx.acquisition.fft_pcps import PcpsAcquisition +from galileo_rx.signal_profiles import galileo_e1_profile +from galileo_rx.signals.code_provider import SyntheticCodeProvider +from galileo_rx.signals.signal_generator import ChannelModelConfig, SatelliteSignalConfig, SyntheticSignalGenerator + + +def test_doppler_batched_matches_sequential() -> None: + fs = 4.092e6 + profile, provider = galileo_e1_profile(), SyntheticCodeProvider() + generator = SyntheticSignalGenerator(profile, provider, fs, satellites=[SatelliteSignalConfig(1, doppler_hz=500)], channel=ChannelModelConfig(add_awgn=False)) + samples, _ = generator.generate(int(0.004 * fs)) + common = dict(doppler_min_hz=0, doppler_max_hz=1000, doppler_step_hz=250, noncoherent_accumulations=1, zero_padding_mode="none") + sequential = PcpsAcquisition(profile, provider, fs, AcquisitionConfig(**common, parallelism="sequential")).search(samples, "E1C", 1) + batched = PcpsAcquisition(profile, provider, fs, AcquisitionConfig(**common, parallelism="batched", doppler_batch_size=3)).search(samples, "E1C", 1) + np.testing.assert_allclose(batched.power, sequential.power, rtol=1e-12, atol=1e-6) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py new file mode 100644 index 0000000..06fac3f --- /dev/null +++ b/tests/unit/test_config.py @@ -0,0 +1,20 @@ +import pytest + +from galileo_rx.config import ReceiverConfig, load_config + + +def test_default_configuration_is_valid() -> None: + ReceiverConfig().validate() + + +def test_e1c_long_integration_requires_secondary_handling() -> None: + config = ReceiverConfig() + config.acquisition.coherent_integration_ms = 8.0 + with pytest.raises(ValueError, match="secondary-code"): + config.validate() + + +def test_yaml_load() -> None: + config = load_config("configs/e1_default.yaml") + assert config.rf.sampling_frequency_hz == 16.368e6 + diff --git a/tests/unit/test_correlator.py b/tests/unit/test_correlator.py new file mode 100644 index 0000000..1f472c8 --- /dev/null +++ b/tests/unit/test_correlator.py @@ -0,0 +1,42 @@ +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.signal_generator import ChannelModelConfig, SatelliteSignalConfig, SyntheticSignalGenerator +from galileo_rx.tracking.correlator import CorrelatorBank, SampleStreamCorrelator +from galileo_rx.tracking.nco import CarrierNco, CodeNco + + +def make_bank(fs: float, phase: float) -> CorrelatorBank: + profile = galileo_e1_profile() + return CorrelatorBank( + profile, + SyntheticCodeProvider(), + "E1B", + 1, + CarrierNco(fs, 500.0), + CodeNco(fs, 4092, profile.code_rate_hz, phase), + 0.25, + 0.75, + ) + + +def test_vector_and_sample_stream_correlators_agree() -> None: + fs = 4.5e6 + profile = galileo_e1_profile() + generator = SyntheticSignalGenerator( + profile, + SyntheticCodeProvider(), + fs, + satellites=[SatelliteSignalConfig(1, "E1B", code_delay_chips=3.2, doppler_hz=500.0)], + channel=ChannelModelConfig(add_awgn=False), + ) + samples, _ = generator.generate(500) + vector = make_bank(fs, -3.2).correlate(samples) + stream = SampleStreamCorrelator(make_bank(fs, -3.2)) + for sample in samples: + stream.accept(sample) + cycle = stream.dump() + for tap in ("very_early", "early", "prompt", "late", "very_late"): + assert abs(getattr(vector, tap) - getattr(cycle, tap)) < 1e-8 + diff --git a/tests/unit/test_discriminators.py b/tests/unit/test_discriminators.py new file mode 100644 index 0000000..f2a031f --- /dev/null +++ b/tests/unit/test_discriminators.py @@ -0,0 +1,32 @@ +import numpy as np + +from galileo_rx.tracking.discriminators import ( + dll_noncoherent_power_chips, + dll_normalized_envelope_chips, + fll_bpsk_phase_difference_hz, + fll_phase_difference_hz, + pll_four_quadrant_rad, + pll_two_quadrant_rad, +) + + +def test_pll_discriminator_sign_and_units() -> None: + prompt = np.exp(1j * 0.2) + assert abs(pll_four_quadrant_rad(prompt) - 0.2) < 1e-12 + assert abs(pll_two_quadrant_rad(prompt) - 0.2) < 1e-12 + + +def test_fll_phase_difference_is_hz() -> None: + dt = 0.004 + assert abs(fll_phase_difference_hz(1 + 0j, np.exp(2j * np.pi * 10 * dt), dt) - 10.0) < 1e-12 + + +def test_bpsk_fll_rejects_data_sign_transition() -> None: + dt = 0.004 + prompt = -np.exp(2j * np.pi * 10 * dt) + assert abs(fll_bpsk_phase_difference_hz(1 + 0j, prompt, dt) - 10.0) < 1e-12 + + +def test_dll_sign() -> None: + assert dll_normalized_envelope_chips(2 + 0j, 1 + 0j, 0.25) > 0 + assert dll_noncoherent_power_chips(2 + 0j, 1 + 0j, 0.25) > 0 diff --git a/tests/unit/test_fixed_fft.py b/tests/unit/test_fixed_fft.py new file mode 100644 index 0000000..71555c1 --- /dev/null +++ b/tests/unit/test_fixed_fft.py @@ -0,0 +1,31 @@ +import numpy as np + +from galileo_rx.fixed_point.fixed_fft import FixedRadix2Fft +from galileo_rx.fixed_point.formats import FixedFormat + + +def test_fixed_fft_impulse_and_scaling() -> None: + values = np.zeros(16, dtype=np.complex128) + values[0] = 0.5 + model = FixedRadix2Fft(FixedFormat(20, 17)) + result = model.forward(values) + np.testing.assert_allclose(result.dequantize(), np.fft.fft(values) / values.size, atol=4 * model.data_format.lsb) + assert result.overflow_count == 0 + + +def test_fixed_fft_random_vector_and_inverse() -> None: + rng = np.random.default_rng(91) + values = 0.1 * (rng.standard_normal(32) + 1j * rng.standard_normal(32)) + model = FixedRadix2Fft(FixedFormat(24, 19)) + spectrum = model.forward(values).dequantize() + np.testing.assert_allclose(spectrum, np.fft.fft(values) / values.size, atol=2e-5) + restored = model.inverse(spectrum).dequantize() + # IFFT of the already 1/N-scaled forward spectrum is x/N. + np.testing.assert_allclose(restored, values / values.size, atol=2e-5) + + +def test_fixed_fft_rejects_non_power_of_two() -> None: + import pytest + + with pytest.raises(ValueError, match="power of two"): + FixedRadix2Fft().forward(np.ones(12)) diff --git a/tests/unit/test_fixed_point.py b/tests/unit/test_fixed_point.py new file mode 100644 index 0000000..a17e4ff --- /dev/null +++ b/tests/unit/test_fixed_point.py @@ -0,0 +1,59 @@ +import numpy as np + +from galileo_rx.fixed_point.fixed_correlator import FixedCorrelator +from galileo_rx.fixed_point.fixed_nco import FixedNco +from galileo_rx.fixed_point.fixed_loop_filter import FixedSecondOrderLoopFilter +from galileo_rx.fixed_point.formats import FixedFormat +from galileo_rx.fixed_point.quantizer import Quantizer + + +def test_saturation_and_overflow_counter() -> None: + quantizer = Quantizer(FixedFormat(4, 2), overflow="saturate", rounding="nearest") + result = quantizer.quantize([-3.0, -1.0, 0.0, 1.9, 3.0]) + np.testing.assert_array_equal(result, [-8, -4, 0, 7, 7]) + assert quantizer.statistics.overflow_count == 3 + + +def test_twos_complement_wrap() -> None: + quantizer = Quantizer(FixedFormat(4, 0), overflow="wrap", rounding="truncate") + np.testing.assert_array_equal(quantizer.quantize([8, 9, -9]), [-8, -7, 7]) + + +def test_convergent_rounding_ties_to_even() -> None: + quantizer = Quantizer(FixedFormat(8, 1), rounding="convergent") + np.testing.assert_array_equal(quantizer.quantize([0.25, 0.75, 1.25]), [0, 2, 2]) + + +def test_fixed_nco_phase_continuity_and_frequency_word() -> None: + whole = FixedNco(10e6, 1e6) + split = FixedNco(10e6, 1e6) + c1, s1 = whole.generate(50) + ca, sa = split.generate(17) + cb, sb = split.generate(33) + np.testing.assert_array_equal(c1, np.concatenate([ca, cb])) + np.testing.assert_array_equal(s1, np.concatenate([sa, sb])) + assert abs(whole.frequency_control_word * 10e6 / 2**32 - 1e6) < 10e6 / 2**33 + + +def test_fixed_correlator_close_to_float() -> None: + fs = 1e6 + count = 1000 + time_s = np.arange(count) / fs + replica = np.where(np.arange(count) % 3, 1, -1) + samples = 0.5 * replica * np.exp(2j * np.pi * 125e3 * time_s) + model = FixedCorrelator(fs, 125e3) + result = model.correlate_prompt(samples, replica) + assert abs(result.complex_value.real - 500.0) < 5.0 + assert abs(result.complex_value.imag) < 5.0 + assert result.overflow_count == 0 + + +def test_fixed_loop_filter_tracks_double_reference() -> None: + from galileo_rx.tracking.loop_filters import SecondOrderLoopFilter + + fixed = FixedSecondOrderLoopFilter(2.0, 0.004) + reference = SecondOrderLoopFilter(2.0) + fixed_output = np.asarray([fixed.update(0.02) for _ in range(100)]) + reference_output = np.asarray([reference.update(0.02, 0.004) for _ in range(100)]) + assert np.sqrt(np.mean((fixed_output - reference_output) ** 2)) < 1e-3 + assert fixed.overflow_count == 0 diff --git a/tests/unit/test_frontend.py b/tests/unit/test_frontend.py new file mode 100644 index 0000000..78f3fb7 --- /dev/null +++ b/tests/unit/test_frontend.py @@ -0,0 +1,32 @@ +import numpy as np + +from galileo_rx.config import FrontendConfig, RfConfig +from galileo_rx.frontend.frontend_chain import FrontendChain +from galileo_rx.frontend.mixer import PhaseAccumulatorNco + + +def test_nco_frequency_word_accuracy_and_block_continuity() -> None: + full = PhaseAccumulatorNco(10.0e6, 1.234567e6, phase_width=32) + blocked = PhaseAccumulatorNco(10.0e6, 1.234567e6, phase_width=32) + expected = full.generate(100) + actual = np.concatenate([blocked.generate(37), blocked.generate(63)]) + np.testing.assert_allclose(actual, expected, atol=1e-14) + assert abs(full.actual_frequency_hz - 1.234567e6) <= 10.0e6 / 2**33 + + +def test_real_if_mixer_sign_and_phase_continuity() -> None: + fs = 8.0e6 + rf = RfConfig(sample_format="float64", input_is_real_if=True, sampling_frequency_hz=fs, intermediate_frequency_hz=2.0e6) + chain = FrontendChain(rf, FrontendConfig()) + time_s = np.arange(160) / fs + real_if = np.cos(2 * np.pi * 2.0e6 * time_s) + result = np.concatenate([chain.process(real_if[:61]), chain.process(real_if[61:])]) + # A real mixer produces desired DC plus the unfiltered negative-frequency image. + assert abs(np.mean(result) - 0.5) < 0.02 + + +def test_signed_sample_conversion() -> None: + rf = RfConfig(sample_format="int8", sampling_frequency_hz=4.0e6, intermediate_frequency_hz=0.0, sample_bit_width=8) + chain = FrontendChain(rf, FrontendConfig()) + result = chain.process(np.array([-128, 0, 127], dtype=np.int8)) + np.testing.assert_allclose(result.real, [-1.0, 0.0, 127 / 128]) diff --git a/tests/unit/test_loop_filters.py b/tests/unit/test_loop_filters.py new file mode 100644 index 0000000..a9d16a2 --- /dev/null +++ b/tests/unit/test_loop_filters.py @@ -0,0 +1,23 @@ +import numpy as np + +from galileo_rx.tracking.loop_filters import SecondOrderLoopFilter, ThirdOrderLoopFilter, second_order_natural_frequency_rad_s + + +def test_loop_coefficients_are_physical_and_positive() -> None: + wn = second_order_natural_frequency_rad_s(15.0, 0.707) + loop = SecondOrderLoopFilter(15.0, 0.707) + assert wn > 0 and loop.proportional_gain > 0 and loop.integral_gain > 0 + + +def test_second_order_step_response_is_finite_and_monotonic_initially() -> None: + loop = SecondOrderLoopFilter(2.0) + output = np.asarray([loop.update(0.1, 0.004) for _ in range(100)]) + assert np.all(np.isfinite(output)) + assert output[-1] > output[0] > 0 + + +def test_third_order_constant_error_has_finite_response() -> None: + loop = ThirdOrderLoopFilter(5.0) + output = [loop.update(0.01, 0.004) for _ in range(100)] + assert np.all(np.isfinite(output)) and output[-1] > output[0] + diff --git a/tests/unit/test_rtl_model.py b/tests/unit/test_rtl_model.py new file mode 100644 index 0000000..ca63610 --- /dev/null +++ b/tests/unit/test_rtl_model.py @@ -0,0 +1,16 @@ +from galileo_rx.rtl_model.cycle_model import AcquisitionArchitectureEstimate, TrackingScheduler + + +def test_acquisition_sizing_formulas() -> None: + estimate = AcquisitionArchitectureEstimate(16.368e6, 0.004, 5, 36, 81, 65536, 200e6, 4) + assert estimate.samples_per_coherent_interval == 65472 + assert estimate.fft_operations_per_prn == 810 + assert estimate.sample_buffer_complex_words == 327360 + assert estimate.acquisition_time_s > 0 + + +def test_tracking_scheduler_condition() -> None: + good = TrackingScheduler(16.368e6, 200e6, 8, 2, 2) + bad = TrackingScheduler(16.368e6, 50e6, 16, 1, 2) + assert good.schedulable_without_loss + assert not bad.schedulable_without_loss diff --git a/tests/unit/test_signals.py b/tests/unit/test_signals.py new file mode 100644 index 0000000..79a6ed8 --- /dev/null +++ b/tests/unit/test_signals.py @@ -0,0 +1,58 @@ +import numpy as np + +from galileo_rx.signal_profiles import galileo_e1_profile +from galileo_rx.signals.boc import boc_subcarrier, sample_primary_code +from galileo_rx.signals.code_provider import SyntheticCodeProvider +from galileo_rx.signals.signal_generator import ChannelModelConfig, SatelliteSignalConfig, SyntheticSignalGenerator + + +def test_code_sampling_wrap_and_noninteger_rate() -> None: + code = np.array([1, -1, 1, 1], dtype=np.int8) + sampled, phase = sample_primary_code(code, 11, 2.5, 1.0, 3.5) + assert sampled.shape == (11,) + assert 0 <= phase < 4 + assert set(sampled) == {-1.0, 1.0} + + +def test_boc_phase_continuity_between_blocks() -> None: + whole, _ = boc_subcarrier(31, 7.3e6, 1.023e6, 0.17) + first, phase = boc_subcarrier(13, 7.3e6, 1.023e6, 0.17) + second, _ = boc_subcarrier(18, 7.3e6, 1.023e6, phase) + np.testing.assert_array_equal(whole, np.concatenate([first, second])) + + +def test_synthetic_provider_is_deterministic_and_component_specific() -> None: + profile = galileo_e1_profile() + provider = SyntheticCodeProvider() + a = provider.primary_code(profile, "E1C", 1) + np.testing.assert_array_equal(a, provider.primary_code(profile, "E1C", 1)) + assert not np.array_equal(a, provider.primary_code(profile, "E1B", 1)) + + +def test_generator_streaming_matches_single_block_without_noise() -> None: + profile = galileo_e1_profile() + provider = SyntheticCodeProvider() + satellite = SatelliteSignalConfig(prn=3, doppler_hz=1234.0, code_delay_chips=17.25) + channel = ChannelModelConfig(add_awgn=False) + whole_generator = SyntheticSignalGenerator(profile, provider, 4.5e6, satellites=[satellite], channel=channel) + block_generator = SyntheticSignalGenerator(profile, provider, 4.5e6, satellites=[satellite], channel=channel) + whole, _ = whole_generator.generate(1001) + a, _ = block_generator.generate(317) + b, _ = block_generator.generate(684) + np.testing.assert_allclose(whole, np.concatenate([a, b]), atol=2e-6) + + +def test_real_if_and_truth_metadata() -> None: + profile = galileo_e1_profile() + generator = SyntheticSignalGenerator( + profile, + SyntheticCodeProvider(), + 8.0e6, + 2.0e6, + True, + [SatelliteSignalConfig(prn=7, component="E1B", code_delay_chips=2.5)], + ChannelModelConfig(add_awgn=False), + ) + samples, truth = generator.generate(80) + assert samples.dtype == np.float64 and not np.iscomplexobj(samples) + assert truth.satellites[0].initial_code_delay_samples == 2.5 * 8.0e6 / 1.023e6 diff --git a/tests/unit/test_symbol_sync.py b/tests/unit/test_symbol_sync.py new file mode 100644 index 0000000..8c51903 --- /dev/null +++ b/tests/unit/test_symbol_sync.py @@ -0,0 +1,31 @@ +import numpy as np + +from galileo_rx.tracking.symbol_sync import ( + compare_symbol_streams, + recover_bpsk_symbols, + synchronize_secondary_code, +) + + +def test_raw_symbol_recovery_and_bounded_sign_alignment() -> None: + truth = np.asarray([1, -1, -1, 1, 1, -1], dtype=np.int8) + prompts = np.repeat(-truth, 2).astype(np.complex128) * (10 + 1j) + recovered, values = recover_bpsk_symbols(prompts, 2) + assert values.dtype == np.float64 + result = compare_symbol_streams(recovered, truth, max_alignment_offset_symbols=1) + assert result.sign_inverted + assert result.symbol_errors == 0 + + +def test_joint_secondary_phase_and_frequency_sync() -> None: + secondary = np.asarray([1, 1, -1, 1, -1], dtype=np.int8) + phase = 3 + residual_hz = 17.0 + dt = 0.004 + epochs = np.arange(40) + prompts = secondary[(phase + epochs) % secondary.size] * np.exp(1j * (0.7 + 2 * np.pi * residual_hz * dt * epochs)) + result = synchronize_secondary_code(prompts, secondary, dt, residual_frequency_min_hz=-30, residual_frequency_max_hz=30) + assert result.phase == phase + assert result.residual_frequency_hz == residual_hz + assert result.normalized_correlation > 0.999999 + assert result.sign_errors == 0 diff --git a/tests/unit/test_vector_export.py b/tests/unit/test_vector_export.py new file mode 100644 index 0000000..b90d718 --- /dev/null +++ b/tests/unit/test_vector_export.py @@ -0,0 +1,26 @@ +from pathlib import Path + +import numpy as np + +from galileo_rx.fixed_point.formats import FixedFormat +from galileo_rx.rtl_model.vector_export import export_complex_hex, export_timed_complex_hex + + +def test_exported_vector_is_twos_complement(tmp_path: Path) -> None: + manifest = export_complex_hex(tmp_path, "samples", np.asarray([-1 - 0.5j, 0.5 + 0.25j]), FixedFormat(8, 7)) + assert (tmp_path / "samples_i.hex").read_text().splitlines() == ["80", "40"] + assert (tmp_path / "samples_q.hex").read_text().splitlines() == ["c0", "20"] + assert manifest["sample_count"] == 2 + + +def test_exported_timestamps_and_register_metadata(tmp_path: Path) -> None: + manifest = export_timed_complex_hex( + tmp_path, + "timed", + np.asarray([1 + 2j, 3 + 4j]), + FixedFormat(8, 0), + 8.0, + {"ACQ_COHERENT_SAMPLES": 2}, + ) + assert (tmp_path / "timed_timestamps.csv").read_text().splitlines() == ["sample_index,time_s", "0,0", "1,0.125"] + assert manifest["register_values"]["ACQ_COHERENT_SAMPLES"] == 2 diff --git a/tools/build_signalsim.sh b/tools/build_signalsim.sh new file mode 100755 index 0000000..559709b --- /dev/null +++ b/tools/build_signalsim.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 1 || $# -gt 2 ]]; then + echo "usage: $0 /path/to/SignalSim [build-directory]" >&2 + exit 2 +fi + +signalsim_source=$(realpath "$1") +project_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +build_directory=${2:-"${project_root}/build/signalsim"} + +if [[ ! -f "${signalsim_source}/IFdataGen/CMakeLists.txt" ]]; then + echo "error: ${signalsim_source} is not a SignalSim tree with IFdataGen/CMakeLists.txt" >&2 + exit 3 +fi + +for dependency in cmake c++; do + if ! command -v "${dependency}" >/dev/null 2>&1; then + echo "error: required build dependency '${dependency}' is missing" >&2 + exit 4 + fi +done + +generator=() +if command -v ninja >/dev/null 2>&1; then + generator=(-G Ninja) +fi + +echo "SignalSim source: ${signalsim_source}" +echo "Build directory: ${build_directory}" +echo "CMake: $(cmake --version | head -n 1)" +echo "Compiler: $(c++ --version | head -n 1)" +if command -v ninja >/dev/null 2>&1; then + echo "Ninja: $(ninja --version)" +else + echo "Ninja: unavailable; using CMake default generator" +fi + +cmake -S "${signalsim_source}/IFdataGen" -B "${build_directory}" "${generator[@]}" \ + -DCMAKE_BUILD_TYPE=Release -DUSE_NATIVE_OPT=OFF +cmake --build "${build_directory}" --parallel + +executable="${build_directory}/IFdataGen" +if [[ ! -x "${executable}" ]]; then + echo "error: build completed without expected executable ${executable}" >&2 + exit 5 +fi + +echo "SIGNALSIM_EXECUTABLE=${executable}" diff --git a/tools/generate_signalsim_vectors.py b/tools/generate_signalsim_vectors.py new file mode 100755 index 0000000..ae54955 --- /dev/null +++ b/tools/generate_signalsim_vectors.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Regenerate deterministic SignalSim vectors without modifying SignalSim.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +import subprocess +import sys +from copy import deepcopy + +import yaml + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--signalsim-path", required=True, type=Path) + parser.add_argument("--executable", type=Path) + vector_names = ["high_cn0_single_prn", "nominal_cn0_single_prn", "negative_doppler", "multiple_prns"] + parser.add_argument("--vector", choices=[*vector_names, "all"], default="high_cn0_single_prn") + parser.add_argument("--project-root", type=Path, default=Path(__file__).resolve().parents[1]) + args = parser.parse_args() + source = args.signalsim_path.resolve() + root = args.project_root.resolve() + executable = args.executable.resolve() if args.executable else root / "build" / "signalsim" / "IFdataGen" + if not executable.is_file(): + subprocess.run([str(root / "tools" / "build_signalsim.sh"), str(source)], check=True) + template_path = root / "verification" / "vectors" / "signalsim" / "high_cn0_single_prn" / "config.json" + template = json.loads(template_path.read_text(encoding="utf-8")) + rinex = source / "EphData" / "BRDC00IGS_R_20211700000_01D_MN.rnx" + if not rinex.is_file(): + raise FileNotFoundError(f"bundled RINEX input is missing: {rinex}") + commit = subprocess.check_output(["git", "-C", str(source), "rev-parse", "HEAD"], text=True).strip() + variants = { + "high_cn0_single_prn": {"duration_s": 2.2, "cn0_db_hz": 55.0, "visible_prns": [1], "expected_doppler_hz": {1: 1828.0}}, + "nominal_cn0_single_prn": {"duration_s": 0.6, "cn0_db_hz": 50.0, "visible_prns": [1], "expected_doppler_hz": {1: 1828.0}}, + "negative_doppler": {"duration_s": 2.2, "cn0_db_hz": 55.0, "visible_prns": [27], "expected_doppler_hz": {27: -2637.0}}, + "multiple_prns": {"duration_s": 0.12, "cn0_db_hz": 55.0, "visible_prns": [1, 4, 19, 21, 27], "expected_doppler_hz": {1: 1828.0, 4: 2171.0, 19: -367.0, 21: -712.0, 27: -2637.0}}, + } + selected = vector_names if args.vector == "all" else [args.vector] + for vector_name in selected: + parameters = variants[vector_name] + vector_dir = root / "verification" / "vectors" / "signalsim" / vector_name + vector_dir.mkdir(parents=True, exist_ok=True) + config = deepcopy(template) + config["description"] = f"SignalSim validation vector: {vector_name}" + config["ephemeris"]["name"] = str(rinex) + config["trajectory"]["trajectoryList"][0]["time"] = parameters["duration_s"] + config["power"]["initPower"]["value"] = parameters["cn0_db_hz"] + visible = set(parameters["visible_prns"]) + config["output"]["config"]["maskOut"] = [{"system": "Galileo", "svid": [prn for prn in range(1, 37) if prn not in visible]}] + output_name = f"{vector_name}_iq8.bin" + config["output"]["name"] = output_name + generated_config = vector_dir / "generated_config.json" + generated_config.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8") + output = vector_dir / output_name + command = [str(executable), "-c", str(generated_config), "-o", str(output), "-t", "-st"] + completed = subprocess.run(command, check=True, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + (vector_dir / "generation.log").write_text(completed.stdout, encoding="utf-8") + metadata = { + "name": vector_name, + "signalsim_path": str(source), + "signalsim_commit": commit, + "command": command, + "output": str(output.relative_to(root)), + "size_bytes": output.stat().st_size, + "sha256": sha256(output), + "complex_sample_count": output.stat().st_size // 2, + "sampling_frequency_hz": 8_184_000.0, + "intermediate_frequency_hz": 2_046_000.0, + "sample_format": "IQ8_interleaved_signed_I_then_Q", + "duration_s": parameters["duration_s"], + "prns": parameters["visible_prns"], + "components": ["E1B", "E1C"], + "expected_doppler_hz": parameters["expected_doppler_hz"], + "expected_code_delay": None, + "navigation_truth": "INavBit generated from bundled RINEX; no sample-side truth file", + "secondary_truth": "PilotBit.cpp SecondaryCodeE1, phase must be synchronized", + } + (vector_dir / "metadata.yaml").write_text(yaml.safe_dump(metadata, sort_keys=False), encoding="utf-8") + print(completed.stdout, end="") + print(yaml.safe_dump(metadata, sort_keys=False), end="") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/verification/vectors/signalsim/high_cn0_single_prn/config.json b/verification/vectors/signalsim/high_cn0_single_prn/config.json new file mode 100644 index 0000000..65a8398 --- /dev/null +++ b/verification/vectors/signalsim/high_cn0_single_prn/config.json @@ -0,0 +1,68 @@ +{ + "version": 1.0, + "description": "SignalSim Galileo E1 combined E1B/E1C, single visible PRN 1, high C/N0", + "time": { + "type": "UTC", + "year": 2021, + "month": 6, + "day": 19, + "hour": 10, + "minute": 5, + "second": 30 + }, + "trajectory": { + "name": "static single-PRN integration baseline", + "initPosition": { + "type": "LLA", + "format": "d", + "longitude": -121.915773, + "latitude": 37.352721, + "altitude": 20 + }, + "initVelocity": { + "type": "SCU", + "speed": 0, + "course": 0 + }, + "trajectoryList": [ + { + "type": "Const", + "time": 2.2 + } + ] + }, + "ephemeris": { + "type": "RINEX", + "name": "/home/onur/Desktop/galileo/signalsim/SignalSim/EphData/BRDC00IGS_R_20211700000_01D_MN.rnx" + }, + "output": { + "type": "IFdata", + "format": "IQ8", + "sampleFreq": 8.184, + "centerFreq": 1573.374, + "name": "high_cn0_single_prn_iq8.bin", + "config": { + "elevationMask": 3, + "maskOut": [ + { + "system": "Galileo", + "svid": [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36] + } + ] + }, + "systemSelect": [ + {"system": "GPS", "signal": "L1CA", "enable": false}, + {"system": "BDS", "signal": "B1C", "enable": false}, + {"system": "Galileo", "signal": "E1", "enable": true}, + {"system": "GLONASS", "signal": "G1", "enable": false} + ] + }, + "power": { + "noiseFloor": -172, + "initPower": { + "unit": "dBHz", + "value": 55 + }, + "elevationAdjust": false + } +} diff --git a/verification/vectors/signalsim/high_cn0_single_prn/high_cn0_single_prn_iq8.bin.tag b/verification/vectors/signalsim/high_cn0_single_prn/high_cn0_single_prn_iq8.bin.tag new file mode 100644 index 0000000..1af0745 --- /dev/null +++ b/verification/vectors/signalsim/high_cn0_single_prn/high_cn0_single_prn_iq8.bin.tag @@ -0,0 +1,8 @@ +PROG = IFDataGen +TIME = 2026/07/30 19:48:58.623 +FMT = INT8X2 +F_S = 8.184000 +F_LO = 1573.374000 +IQ = 1 +BITS = 8 +SCALE = 1.0 diff --git a/verification/vectors/signalsim/high_cn0_single_prn/metadata.yaml b/verification/vectors/signalsim/high_cn0_single_prn/metadata.yaml new file mode 100644 index 0000000..bebed8a --- /dev/null +++ b/verification/vectors/signalsim/high_cn0_single_prn/metadata.yaml @@ -0,0 +1,29 @@ +name: high_cn0_single_prn +signalsim_path: /home/onur/Desktop/galileo/signalsim/SignalSim +signalsim_remote: https://github.com/globsky/SignalSim +signalsim_commit: 80b262113e77c4482ca9f5cbfef9bcada402ed28 +command: + - /home/onur/Desktop/galileo/build/signalsim/IFdataGen + - -c + - /home/onur/Desktop/galileo/verification/vectors/signalsim/high_cn0_single_prn/config.json + - -o + - /home/onur/Desktop/galileo/verification/vectors/signalsim/high_cn0_single_prn/high_cn0_single_prn_iq8.bin + - -t + - -st +output: verification/vectors/signalsim/high_cn0_single_prn/high_cn0_single_prn_iq8.bin +size_bytes: 36009600 +complex_sample_count: 18004800 +sha256: 3e72a5ad2d35ee68273b77587a66847b0fb328650b39c9e39272f0dc54e39e3c +sampling_frequency_hz: 8184000.0 +center_frequency_hz: 1573374000.0 +intermediate_frequency_hz: 2046000.0 +sample_format: IQ8_interleaved_signed_I_then_Q +duration_s: 2.2 +prn: 1 +components: [E1B, E1C] +expected_doppler_hz: 1828.0 +expected_doppler_source: SignalSim_validate_only_summary_rounded_to_integer_hz +expected_code_delay: unavailable_from_SignalSim +navigation_source: INavBit_generated_from_bundled_RINEX +secondary_source: PilotBit.cpp_SecondaryCodeE1 +sample_file_committed: false diff --git a/verification/vectors/signalsim/manifest.yaml b/verification/vectors/signalsim/manifest.yaml new file mode 100644 index 0000000..aee1b1a --- /dev/null +++ b/verification/vectors/signalsim/manifest.yaml @@ -0,0 +1,58 @@ +simulator: + name: SignalSim + remote: https://github.com/globsky/SignalSim + commit: 80b262113e77c4482ca9f5cbfef9bcada402ed28 + source_path_detected: /home/onur/Desktop/galileo/signalsim/SignalSim + +vectors: + high_cn0_single_prn: + status: generated_and_validated + config: high_cn0_single_prn/config.json + output: high_cn0_single_prn/high_cn0_single_prn_iq8.bin + representation: complex_iq + sample_format: interleaved_twos_complement_int8_iq + sample_rate_hz: 8184000 + center_frequency_hz: 1573374000 + galileo_e1_if_hz: 2046000 + duration_s: 2.2 + prn: 1 + components: [E1B, E1C] + cn0_db_hz: 55 + expected_doppler_source: SignalSim generation log + expected_doppler_hz: 1828 + expected_code_delay: unavailable_from_SignalSim_cli + navigation_source: SignalSim INavBit generated from bundled RINEX ephemeris + secondary_code_source: SignalSim PilotBit.cpp SecondaryCodeE1 + generated_file_committed: false + output_size_bytes: 36009600 + complex_sample_count: 18004800 + sha256: 3e72a5ad2d35ee68273b77587a66847b0fb328650b39c9e39272f0dc54e39e3c + python_validation: pass + + nominal_cn0_single_prn: + status: generated_acquisition_and_e1c_tracking_pass + config: nominal_cn0_single_prn/generated_config.json + output: nominal_cn0_single_prn/nominal_cn0_single_prn_iq8.bin + duration_s: 0.6 + prn: 1 + expected_doppler_hz: 1828 + sha256: ffbcb2cfedc6142ca3064514b4d2c10045abf0221462996a5fc10a49213bf88e + positive_doppler: {status: covered_by_high_cn0_geometry_if_positive} + negative_doppler: + status: generated_acquisition_and_tracking_pass + config: negative_doppler/generated_config.json + output: negative_doppler/negative_doppler_iq8.bin + duration_s: 2.2 + prn: 27 + expected_doppler_hz: -2637 + sha256: b17d2d1f180122041b2688f9a8b1facfc73ab99b521187908c88450d65ddc1ad + multiple_prns: + status: generated_acquisition_pass_all_five + config: multiple_prns/generated_config.json + output: multiple_prns/multiple_prns_iq8.bin + duration_s: 0.12 + prns: [1, 4, 19, 21, 27] + sha256: 7c6f60e63e2574a4f79034d5a6e16ae954de49502df25b0b532090517ed434d8 + e1b_only: {status: blocked_SignalSim_E1_is_combined} + e1c_only: {status: blocked_SignalSim_E1_is_combined} + e1b_e1c_combined: {status: alias_of_high_cn0_single_prn} diff --git a/verification/vectors/signalsim/multiple_prns/generated_config.json b/verification/vectors/signalsim/multiple_prns/generated_config.json new file mode 100644 index 0000000..1bef90a --- /dev/null +++ b/verification/vectors/signalsim/multiple_prns/generated_config.json @@ -0,0 +1,116 @@ +{ + "version": 1.0, + "description": "SignalSim validation vector: multiple_prns", + "time": { + "type": "UTC", + "year": 2021, + "month": 6, + "day": 19, + "hour": 10, + "minute": 5, + "second": 30 + }, + "trajectory": { + "name": "static single-PRN integration baseline", + "initPosition": { + "type": "LLA", + "format": "d", + "longitude": -121.915773, + "latitude": 37.352721, + "altitude": 20 + }, + "initVelocity": { + "type": "SCU", + "speed": 0, + "course": 0 + }, + "trajectoryList": [ + { + "type": "Const", + "time": 0.12 + } + ] + }, + "ephemeris": { + "type": "RINEX", + "name": "/home/onur/Desktop/galileo/signalsim/SignalSim/EphData/BRDC00IGS_R_20211700000_01D_MN.rnx" + }, + "output": { + "type": "IFdata", + "format": "IQ8", + "sampleFreq": 8.184, + "centerFreq": 1573.374, + "name": "multiple_prns_iq8.bin", + "config": { + "elevationMask": 3, + "maskOut": [ + { + "system": "Galileo", + "svid": [ + 2, + 3, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 20, + 22, + 23, + 24, + 25, + 26, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36 + ] + } + ] + }, + "systemSelect": [ + { + "system": "GPS", + "signal": "L1CA", + "enable": false + }, + { + "system": "BDS", + "signal": "B1C", + "enable": false + }, + { + "system": "Galileo", + "signal": "E1", + "enable": true + }, + { + "system": "GLONASS", + "signal": "G1", + "enable": false + } + ] + }, + "power": { + "noiseFloor": -172, + "initPower": { + "unit": "dBHz", + "value": 55.0 + }, + "elevationAdjust": false + } +} diff --git a/verification/vectors/signalsim/multiple_prns/generation.log b/verification/vectors/signalsim/multiple_prns/generation.log new file mode 100644 index 0000000..bc459a7 --- /dev/null +++ b/verification/vectors/signalsim/multiple_prns/generation.log @@ -0,0 +1,62 @@ + +================================================================================ + IF SIGNAL GENERATION +================================================================================ +[INFO] Loading JSON file: /home/onur/Desktop/galileo/verification/vectors/signalsim/multiple_prns/generated_config.json +[INFO] JSON file read successfully: /home/onur/Desktop/galileo/verification/vectors/signalsim/multiple_prns/generated_config.json +[INFO] Using output file from command line: /home/onur/Desktop/galileo/verification/vectors/signalsim/multiple_prns/multiple_prns_iq8.bin +[INFO] Opening output file: /home/onur/Desktop/galileo/verification/vectors/signalsim/multiple_prns/multiple_prns_iq8.bin +[INFO] Output file opened successfully. +[INFO] Creating tag file: /home/onur/Desktop/galileo/verification/vectors/signalsim/multiple_prns/multiple_prns_iq8.bin.tag +[INFO] Tag file created: /home/onur/Desktop/galileo/verification/vectors/signalsim/multiple_prns/multiple_prns_iq8.bin.tag +[INFO] OpenMP configured for SERIAL execution (1 thread) +[INFO] Generating IF data with following satellite signals: + +[INFO] Enabled Signals: + GAL : [ E1 ] + +Signals Summary Table: ++---------------+-------------+--------------+------------------------------+ +| Constellation | Visible SVs | Signals / SV | Total Signals / Visible SVs | ++---------------+-------------+--------------+------------------------------+ +| GPS | 0 | 0 | 0 | +| BeiDou | 0 | 0 | 0 | +| Galileo | 5 | 1 | 5 | +| GLONASS | 0 | 0 | 0 | ++---------------+-------------+--------------+------------------------------+ +Total Visible SVs = 5, Total channels = 5 + +Galileo E1 with IF +2046kHz: ++----+--------------+----+--------------+----+--------------+----+--------------+ +| SV | Doppler (Hz) | SV | Doppler (Hz) | SV | Doppler (Hz) | SV | Doppler (Hz) | ++----+--------------+----+--------------+----+--------------+----+--------------+ +| 01 | +1828 | 04 | +2171 | 19 | -367 | 21 | -712 | +| 27 | -2637 | | | | | | | ++----+--------------+----+--------------+----+--------------+----+--------------+ + +Total channels: 5 + +[INFO] Signal Duration: 0.12 s +[INFO] Signal Size: 1.87 MB +[INFO] Signal Data format: IQ8 +[INFO] Signal Center freq: 1573.3740 MHz +[INFO] Signal Sample rate: 8.1840 MHz + +[INFO] Starting signal generation loop... + +[==========> 20.8% ] 25/120 ms | 0.39/1.87 MB @ 30.02 MB/s | ETA: 00s +[====================> 41.7% ] 50/120 ms | 0.78/1.87 MB @ 30.02 MB/s | ETA: 00s +[======================62.5%====> ] 75/120 ms | 1.17/1.87 MB @ 30.81 MB/s | ETA: 00s +[======================83.3%==============> ] 100/120 ms | 1.56/1.87 MB @ 30.61 MB/s | ETA: 00s +[======================100.0%======================] 120/120 ms | 1.87/1.87 MB | COMPLETED + +[INFO] IF Signal generation completed! +------------------------------------------------------------------ +[INFO] Total samples: 1947792 +[INFO] Clipped samples: 1 (0.0001%) +[INFO] Final AGC gain: 1.000 +[INFO] Total time taken: 0.06 s +[INFO] Data generated: 1.86 MB +[INFO] Average rate: 30.96 MB/s +------------------------------------------------------------------ + diff --git a/verification/vectors/signalsim/multiple_prns/metadata.yaml b/verification/vectors/signalsim/multiple_prns/metadata.yaml new file mode 100644 index 0000000..bbf9434 --- /dev/null +++ b/verification/vectors/signalsim/multiple_prns/metadata.yaml @@ -0,0 +1,37 @@ +name: multiple_prns +signalsim_path: /home/onur/Desktop/galileo/signalsim/SignalSim +signalsim_commit: 80b262113e77c4482ca9f5cbfef9bcada402ed28 +command: +- /home/onur/Desktop/galileo/build/signalsim/IFdataGen +- -c +- /home/onur/Desktop/galileo/verification/vectors/signalsim/multiple_prns/generated_config.json +- -o +- /home/onur/Desktop/galileo/verification/vectors/signalsim/multiple_prns/multiple_prns_iq8.bin +- -t +- -st +output: verification/vectors/signalsim/multiple_prns/multiple_prns_iq8.bin +size_bytes: 1947792 +sha256: 7c6f60e63e2574a4f79034d5a6e16ae954de49502df25b0b532090517ed434d8 +complex_sample_count: 973896 +sampling_frequency_hz: 8184000.0 +intermediate_frequency_hz: 2046000.0 +sample_format: IQ8_interleaved_signed_I_then_Q +duration_s: 0.12 +prns: +- 1 +- 4 +- 19 +- 21 +- 27 +components: +- E1B +- E1C +expected_doppler_hz: + 1: 1828.0 + 4: 2171.0 + 19: -367.0 + 21: -712.0 + 27: -2637.0 +expected_code_delay: null +navigation_truth: INavBit generated from bundled RINEX; no sample-side truth file +secondary_truth: PilotBit.cpp SecondaryCodeE1, phase must be synchronized diff --git a/verification/vectors/signalsim/multiple_prns/multiple_prns_iq8.bin.tag b/verification/vectors/signalsim/multiple_prns/multiple_prns_iq8.bin.tag new file mode 100644 index 0000000..7e0846c --- /dev/null +++ b/verification/vectors/signalsim/multiple_prns/multiple_prns_iq8.bin.tag @@ -0,0 +1,8 @@ +PROG = IFDataGen +TIME = 2026/07/30 20:17:06.965 +FMT = INT8X2 +F_S = 8.184000 +F_LO = 1573.374000 +IQ = 1 +BITS = 8 +SCALE = 1.0 diff --git a/verification/vectors/signalsim/negative_doppler/generated_config.json b/verification/vectors/signalsim/negative_doppler/generated_config.json new file mode 100644 index 0000000..7953dd6 --- /dev/null +++ b/verification/vectors/signalsim/negative_doppler/generated_config.json @@ -0,0 +1,120 @@ +{ + "version": 1.0, + "description": "SignalSim validation vector: negative_doppler", + "time": { + "type": "UTC", + "year": 2021, + "month": 6, + "day": 19, + "hour": 10, + "minute": 5, + "second": 30 + }, + "trajectory": { + "name": "static single-PRN integration baseline", + "initPosition": { + "type": "LLA", + "format": "d", + "longitude": -121.915773, + "latitude": 37.352721, + "altitude": 20 + }, + "initVelocity": { + "type": "SCU", + "speed": 0, + "course": 0 + }, + "trajectoryList": [ + { + "type": "Const", + "time": 2.2 + } + ] + }, + "ephemeris": { + "type": "RINEX", + "name": "/home/onur/Desktop/galileo/signalsim/SignalSim/EphData/BRDC00IGS_R_20211700000_01D_MN.rnx" + }, + "output": { + "type": "IFdata", + "format": "IQ8", + "sampleFreq": 8.184, + "centerFreq": 1573.374, + "name": "negative_doppler_iq8.bin", + "config": { + "elevationMask": 3, + "maskOut": [ + { + "system": "Galileo", + "svid": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36 + ] + } + ] + }, + "systemSelect": [ + { + "system": "GPS", + "signal": "L1CA", + "enable": false + }, + { + "system": "BDS", + "signal": "B1C", + "enable": false + }, + { + "system": "Galileo", + "signal": "E1", + "enable": true + }, + { + "system": "GLONASS", + "signal": "G1", + "enable": false + } + ] + }, + "power": { + "noiseFloor": -172, + "initPower": { + "unit": "dBHz", + "value": 55.0 + }, + "elevationAdjust": false + } +} diff --git a/verification/vectors/signalsim/negative_doppler/generation.log b/verification/vectors/signalsim/negative_doppler/generation.log new file mode 100644 index 0000000..bc88048 --- /dev/null +++ b/verification/vectors/signalsim/negative_doppler/generation.log @@ -0,0 +1,145 @@ + +================================================================================ + IF SIGNAL GENERATION +================================================================================ +[INFO] Loading JSON file: /home/onur/Desktop/galileo/verification/vectors/signalsim/negative_doppler/generated_config.json +[INFO] JSON file read successfully: /home/onur/Desktop/galileo/verification/vectors/signalsim/negative_doppler/generated_config.json +[INFO] Using output file from command line: /home/onur/Desktop/galileo/verification/vectors/signalsim/negative_doppler/negative_doppler_iq8.bin +[INFO] Opening output file: /home/onur/Desktop/galileo/verification/vectors/signalsim/negative_doppler/negative_doppler_iq8.bin +[INFO] Output file opened successfully. +[INFO] Creating tag file: /home/onur/Desktop/galileo/verification/vectors/signalsim/negative_doppler/negative_doppler_iq8.bin.tag +[INFO] Tag file created: /home/onur/Desktop/galileo/verification/vectors/signalsim/negative_doppler/negative_doppler_iq8.bin.tag +[INFO] OpenMP configured for SERIAL execution (1 thread) +[INFO] Generating IF data with following satellite signals: + +[INFO] Enabled Signals: + GAL : [ E1 ] + +Signals Summary Table: ++---------------+-------------+--------------+------------------------------+ +| Constellation | Visible SVs | Signals / SV | Total Signals / Visible SVs | ++---------------+-------------+--------------+------------------------------+ +| GPS | 0 | 0 | 0 | +| BeiDou | 0 | 0 | 0 | +| Galileo | 1 | 1 | 1 | +| GLONASS | 0 | 0 | 0 | ++---------------+-------------+--------------+------------------------------+ +Total Visible SVs = 1, Total channels = 1 + +Galileo E1 with IF +2046kHz: ++----+--------------+----+--------------+----+--------------+----+--------------+ +| SV | Doppler (Hz) | SV | Doppler (Hz) | SV | Doppler (Hz) | SV | Doppler (Hz) | ++----+--------------+----+--------------+----+--------------+----+--------------+ +| 27 | -2637 | | | | | | | ++----+--------------+----+--------------+----+--------------+----+--------------+ + +Total channels: 1 + +[INFO] Signal Duration: 2.20 s +[INFO] Signal Size: 34.34 MB +[INFO] Signal Data format: IQ8 +[INFO] Signal Center freq: 1573.3740 MHz +[INFO] Signal Sample rate: 8.1840 MHz + +[INFO] Starting signal generation loop... + +[> 1.1% ] 25/2200 ms | 0.39/34.34 MB @ 48.78 MB/s | ETA: 00s +[=> 2.3% ] 50/2200 ms | 0.78/34.34 MB @ 48.78 MB/s | ETA: 00s +[=> 3.4% ] 75/2200 ms | 1.17/34.34 MB @ 46.83 MB/s | ETA: 00s +[==> 4.5% ] 100/2200 ms | 1.56/34.34 MB @ 47.30 MB/s | ETA: 00s +[==> 5.7% ] 125/2200 ms | 1.95/34.34 MB @ 47.59 MB/s | ETA: 00s +[===> 6.8% ] 150/2200 ms | 2.34/34.34 MB @ 47.78 MB/s | ETA: 00s +[===> 8.0% ] 175/2200 ms | 2.73/34.34 MB @ 48.78 MB/s | ETA: 00s +[====> 9.1% ] 200/2200 ms | 3.12/34.34 MB @ 49.55 MB/s | ETA: 00s +[=====> 10.2% ] 225/2200 ms | 3.51/34.34 MB @ 49.47 MB/s | ETA: 00s +[=====> 11.4% ] 250/2200 ms | 3.90/34.34 MB @ 50.03 MB/s | ETA: 00s +[======> 12.5% ] 275/2200 ms | 4.29/34.34 MB @ 50.50 MB/s | ETA: 00s +[======> 13.6% ] 300/2200 ms | 4.68/34.34 MB @ 50.35 MB/s | ETA: 00s +[=======> 14.8% ] 325/2200 ms | 5.07/34.34 MB @ 50.73 MB/s | ETA: 00s +[=======> 15.9% ] 350/2200 ms | 5.46/34.34 MB @ 50.12 MB/s | ETA: 00s +[========> 17.0% ] 375/2200 ms | 5.85/34.34 MB @ 50.46 MB/s | ETA: 00s +[=========> 18.2% ] 400/2200 ms | 6.24/34.34 MB @ 50.76 MB/s | ETA: 00s +[=========> 19.3% ] 425/2200 ms | 6.63/34.34 MB @ 51.03 MB/s | ETA: 00s +[==========> 20.5% ] 450/2200 ms | 7.02/34.34 MB @ 50.90 MB/s | ETA: 00s +[==========> 21.6% ] 475/2200 ms | 7.41/34.34 MB @ 51.14 MB/s | ETA: 00s +[===========> 22.7% ] 500/2200 ms | 7.80/34.34 MB @ 51.35 MB/s | ETA: 00s +[===========> 23.9% ] 525/2200 ms | 8.20/34.34 MB @ 51.22 MB/s | ETA: 00s +[============> 25.0% ] 550/2200 ms | 8.59/34.34 MB @ 51.41 MB/s | ETA: 00s +[=============> 26.1% ] 575/2200 ms | 8.98/34.34 MB @ 51.58 MB/s | ETA: 00s +[=============> 27.3% ] 600/2200 ms | 9.37/34.34 MB @ 51.74 MB/s | ETA: 00s +[==============> 28.4% ] 625/2200 ms | 9.76/34.34 MB @ 51.62 MB/s | ETA: 00s +[==============> 29.5% ] 650/2200 ms | 10.15/34.34 MB @ 51.50 MB/s | ETA: 00s +[===============> 30.7% ] 675/2200 ms | 10.54/34.34 MB @ 51.65 MB/s | ETA: 00s +[===============> 31.8% ] 700/2200 ms | 10.93/34.34 MB @ 51.79 MB/s | ETA: 00s +[================> 33.0% ] 725/2200 ms | 11.32/34.34 MB @ 51.44 MB/s | ETA: 00s +[=================> 34.1% ] 750/2200 ms | 11.71/34.34 MB @ 51.35 MB/s | ETA: 00s +[=================> 35.2% ] 775/2200 ms | 12.10/34.34 MB @ 51.48 MB/s | ETA: 00s +[==================> 36.4% ] 800/2200 ms | 12.49/34.34 MB @ 51.39 MB/s | ETA: 00s +[==================> 37.5% ] 825/2200 ms | 12.88/34.34 MB @ 51.51 MB/s | ETA: 00s +[===================> 38.6% ] 850/2200 ms | 13.27/34.34 MB @ 51.43 MB/s | ETA: 00s +[===================> 39.8% ] 875/2200 ms | 13.66/34.34 MB @ 51.35 MB/s | ETA: 00s +[====================> 40.9% ] 900/2200 ms | 14.05/34.34 MB @ 51.27 MB/s | ETA: 00s +[=====================>42.0% ] 925/2200 ms | 14.44/34.34 MB @ 51.20 MB/s | ETA: 00s +[=====================>43.2% ] 950/2200 ms | 14.83/34.34 MB @ 51.14 MB/s | ETA: 00s +[======================44.3% ] 975/2200 ms | 15.22/34.34 MB @ 51.07 MB/s | ETA: 00s +[======================45.5% ] 1000/2200 ms | 15.61/34.34 MB @ 51.18 MB/s | ETA: 00s +[======================46.6% ] 1025/2200 ms | 16.00/34.34 MB @ 51.12 MB/s | ETA: 00s +[======================47.7% ] 1050/2200 ms | 16.39/34.34 MB @ 51.22 MB/s | ETA: 00s +[======================48.9% ] 1075/2200 ms | 16.78/34.34 MB @ 51.16 MB/s | ETA: 00s +[======================50.0% ] 1100/2200 ms | 17.17/34.34 MB @ 50.95 MB/s | ETA: 00s +[======================51.1% ] 1125/2200 ms | 17.56/34.34 MB @ 50.90 MB/s | ETA: 00s +[======================52.3% ] 1150/2200 ms | 17.95/34.34 MB @ 50.85 MB/s | ETA: 00s +[======================53.4% ] 1175/2200 ms | 18.34/34.34 MB @ 50.95 MB/s | ETA: 00s +[======================54.5%> ] 1200/2200 ms | 18.73/34.34 MB @ 50.90 MB/s | ETA: 00s +[======================55.7%> ] 1225/2200 ms | 19.12/34.34 MB @ 50.99 MB/s | ETA: 00s +[======================56.8%=> ] 1250/2200 ms | 19.51/34.34 MB @ 50.95 MB/s | ETA: 00s +[======================58.0%=> ] 1275/2200 ms | 19.90/34.34 MB @ 50.90 MB/s | ETA: 00s +[======================59.1%==> ] 1300/2200 ms | 20.29/34.34 MB @ 50.86 MB/s | ETA: 00s +[======================60.2%===> ] 1325/2200 ms | 20.68/34.34 MB @ 50.94 MB/s | ETA: 00s +[======================61.4%===> ] 1350/2200 ms | 21.07/34.34 MB @ 51.02 MB/s | ETA: 00s +[======================62.5%====> ] 1375/2200 ms | 21.46/34.34 MB @ 50.98 MB/s | ETA: 00s +[======================63.6%====> ] 1400/2200 ms | 21.85/34.34 MB @ 51.06 MB/s | ETA: 00s +[======================64.8%=====> ] 1425/2200 ms | 22.24/34.34 MB @ 51.14 MB/s | ETA: 00s +[======================65.9%=====> ] 1450/2200 ms | 22.63/34.34 MB @ 51.09 MB/s | ETA: 00s +[======================67.0%======> ] 1475/2200 ms | 23.02/34.34 MB @ 51.17 MB/s | ETA: 00s +[======================68.2%=======> ] 1500/2200 ms | 23.41/34.34 MB @ 51.12 MB/s | ETA: 00s +[======================69.3%=======> ] 1525/2200 ms | 23.80/34.34 MB @ 51.19 MB/s | ETA: 00s +[======================70.5%========> ] 1550/2200 ms | 24.20/34.34 MB @ 51.26 MB/s | ETA: 00s +[======================71.6%========> ] 1575/2200 ms | 24.59/34.34 MB @ 51.22 MB/s | ETA: 00s +[======================72.7%=========> ] 1600/2200 ms | 24.98/34.34 MB @ 51.28 MB/s | ETA: 00s +[======================73.9%=========> ] 1625/2200 ms | 25.37/34.34 MB @ 51.24 MB/s | ETA: 00s +[======================75.0%==========> ] 1650/2200 ms | 25.76/34.34 MB @ 51.31 MB/s | ETA: 00s +[======================76.1%===========> ] 1675/2200 ms | 26.15/34.34 MB @ 51.27 MB/s | ETA: 00s +[======================77.3%===========> ] 1700/2200 ms | 26.54/34.34 MB @ 51.33 MB/s | ETA: 00s +[======================78.4%============> ] 1725/2200 ms | 26.93/34.34 MB @ 51.29 MB/s | ETA: 00s +[======================79.5%============> ] 1750/2200 ms | 27.32/34.34 MB @ 51.35 MB/s | ETA: 00s +[======================80.7%=============> ] 1775/2200 ms | 27.71/34.34 MB @ 51.31 MB/s | ETA: 00s +[======================81.8%=============> ] 1800/2200 ms | 28.10/34.34 MB @ 51.37 MB/s | ETA: 00s +[======================83.0%==============> ] 1825/2200 ms | 28.49/34.34 MB @ 51.33 MB/s | ETA: 00s +[======================84.1%===============> ] 1850/2200 ms | 28.88/34.34 MB @ 51.38 MB/s | ETA: 00s +[======================85.2%===============> ] 1875/2200 ms | 29.27/34.34 MB @ 51.35 MB/s | ETA: 00s +[======================86.4%================> ] 1900/2200 ms | 29.66/34.34 MB @ 51.31 MB/s | ETA: 00s +[======================87.5%================> ] 1925/2200 ms | 30.05/34.34 MB @ 51.19 MB/s | ETA: 00s +[======================88.6%=================> ] 1950/2200 ms | 30.44/34.34 MB @ 51.24 MB/s | ETA: 00s +[======================89.8%=================> ] 1975/2200 ms | 30.83/34.34 MB @ 51.30 MB/s | ETA: 00s +[======================90.9%==================> ] 2000/2200 ms | 31.22/34.34 MB @ 51.18 MB/s | ETA: 00s +[======================92.0%===================> ] 2025/2200 ms | 31.61/34.34 MB @ 51.23 MB/s | ETA: 00s +[======================93.2%===================> ] 2050/2200 ms | 32.00/34.34 MB @ 51.12 MB/s | ETA: 00s +[======================94.3%====================> ] 2075/2200 ms | 32.39/34.34 MB @ 51.09 MB/s | ETA: 00s +[======================95.5%====================> ] 2100/2200 ms | 32.78/34.34 MB @ 50.98 MB/s | ETA: 00s +[======================96.6%=====================> ] 2125/2200 ms | 33.17/34.34 MB @ 50.95 MB/s | ETA: 00s +[======================97.7%=====================> ] 2150/2200 ms | 33.56/34.34 MB @ 51.00 MB/s | ETA: 00s +[======================98.9%======================>] 2175/2200 ms | 33.95/34.34 MB @ 50.90 MB/s | ETA: 00s +[======================100.0%======================] 2200/2200 ms | 34.34/34.34 MB @ 50.80 MB/s | ETA: -- +[======================100.0%======================] 2200/2200 ms | 34.34/34.34 MB | COMPLETED + +[INFO] IF Signal generation completed! +------------------------------------------------------------------ +[INFO] Total samples: 36009600 +[INFO] Clipped samples: 10 (0.0000%) +[INFO] Final AGC gain: 1.000 +[INFO] Total time taken: 0.68 s +[INFO] Data generated: 34.34 MB +[INFO] Average rate: 50.80 MB/s +------------------------------------------------------------------ + diff --git a/verification/vectors/signalsim/negative_doppler/metadata.yaml b/verification/vectors/signalsim/negative_doppler/metadata.yaml new file mode 100644 index 0000000..a6543ce --- /dev/null +++ b/verification/vectors/signalsim/negative_doppler/metadata.yaml @@ -0,0 +1,29 @@ +name: negative_doppler +signalsim_path: /home/onur/Desktop/galileo/signalsim/SignalSim +signalsim_commit: 80b262113e77c4482ca9f5cbfef9bcada402ed28 +command: +- /home/onur/Desktop/galileo/build/signalsim/IFdataGen +- -c +- /home/onur/Desktop/galileo/verification/vectors/signalsim/negative_doppler/generated_config.json +- -o +- /home/onur/Desktop/galileo/verification/vectors/signalsim/negative_doppler/negative_doppler_iq8.bin +- -t +- -st +output: verification/vectors/signalsim/negative_doppler/negative_doppler_iq8.bin +size_bytes: 36009600 +sha256: b17d2d1f180122041b2688f9a8b1facfc73ab99b521187908c88450d65ddc1ad +complex_sample_count: 18004800 +sampling_frequency_hz: 8184000.0 +intermediate_frequency_hz: 2046000.0 +sample_format: IQ8_interleaved_signed_I_then_Q +duration_s: 2.2 +prns: +- 27 +components: +- E1B +- E1C +expected_doppler_hz: + 27: -2637.0 +expected_code_delay: null +navigation_truth: INavBit generated from bundled RINEX; no sample-side truth file +secondary_truth: PilotBit.cpp SecondaryCodeE1, phase must be synchronized diff --git a/verification/vectors/signalsim/negative_doppler/negative_doppler_iq8.bin.tag b/verification/vectors/signalsim/negative_doppler/negative_doppler_iq8.bin.tag new file mode 100644 index 0000000..c9bc476 --- /dev/null +++ b/verification/vectors/signalsim/negative_doppler/negative_doppler_iq8.bin.tag @@ -0,0 +1,8 @@ +PROG = IFDataGen +TIME = 2026/07/30 20:17:48.609 +FMT = INT8X2 +F_S = 8.184000 +F_LO = 1573.374000 +IQ = 1 +BITS = 8 +SCALE = 1.0 diff --git a/verification/vectors/signalsim/nominal_cn0_single_prn/generated_config.json b/verification/vectors/signalsim/nominal_cn0_single_prn/generated_config.json new file mode 100644 index 0000000..e48b82e --- /dev/null +++ b/verification/vectors/signalsim/nominal_cn0_single_prn/generated_config.json @@ -0,0 +1,120 @@ +{ + "version": 1.0, + "description": "SignalSim validation vector: nominal_cn0_single_prn", + "time": { + "type": "UTC", + "year": 2021, + "month": 6, + "day": 19, + "hour": 10, + "minute": 5, + "second": 30 + }, + "trajectory": { + "name": "static single-PRN integration baseline", + "initPosition": { + "type": "LLA", + "format": "d", + "longitude": -121.915773, + "latitude": 37.352721, + "altitude": 20 + }, + "initVelocity": { + "type": "SCU", + "speed": 0, + "course": 0 + }, + "trajectoryList": [ + { + "type": "Const", + "time": 0.6 + } + ] + }, + "ephemeris": { + "type": "RINEX", + "name": "/home/onur/Desktop/galileo/signalsim/SignalSim/EphData/BRDC00IGS_R_20211700000_01D_MN.rnx" + }, + "output": { + "type": "IFdata", + "format": "IQ8", + "sampleFreq": 8.184, + "centerFreq": 1573.374, + "name": "nominal_cn0_single_prn_iq8.bin", + "config": { + "elevationMask": 3, + "maskOut": [ + { + "system": "Galileo", + "svid": [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36 + ] + } + ] + }, + "systemSelect": [ + { + "system": "GPS", + "signal": "L1CA", + "enable": false + }, + { + "system": "BDS", + "signal": "B1C", + "enable": false + }, + { + "system": "Galileo", + "signal": "E1", + "enable": true + }, + { + "system": "GLONASS", + "signal": "G1", + "enable": false + } + ] + }, + "power": { + "noiseFloor": -172, + "initPower": { + "unit": "dBHz", + "value": 50.0 + }, + "elevationAdjust": false + } +} diff --git a/verification/vectors/signalsim/nominal_cn0_single_prn/generation.log b/verification/vectors/signalsim/nominal_cn0_single_prn/generation.log new file mode 100644 index 0000000..a77295e --- /dev/null +++ b/verification/vectors/signalsim/nominal_cn0_single_prn/generation.log @@ -0,0 +1,80 @@ + +================================================================================ + IF SIGNAL GENERATION +================================================================================ +[INFO] Loading JSON file: /home/onur/Desktop/galileo/verification/vectors/signalsim/nominal_cn0_single_prn/generated_config.json +[INFO] JSON file read successfully: /home/onur/Desktop/galileo/verification/vectors/signalsim/nominal_cn0_single_prn/generated_config.json +[INFO] Using output file from command line: /home/onur/Desktop/galileo/verification/vectors/signalsim/nominal_cn0_single_prn/nominal_cn0_single_prn_iq8.bin +[INFO] Opening output file: /home/onur/Desktop/galileo/verification/vectors/signalsim/nominal_cn0_single_prn/nominal_cn0_single_prn_iq8.bin +[INFO] Output file opened successfully. +[INFO] Creating tag file: /home/onur/Desktop/galileo/verification/vectors/signalsim/nominal_cn0_single_prn/nominal_cn0_single_prn_iq8.bin.tag +[INFO] Tag file created: /home/onur/Desktop/galileo/verification/vectors/signalsim/nominal_cn0_single_prn/nominal_cn0_single_prn_iq8.bin.tag +[INFO] OpenMP configured for SERIAL execution (1 thread) +[INFO] Generating IF data with following satellite signals: + +[INFO] Enabled Signals: + GAL : [ E1 ] + +Signals Summary Table: ++---------------+-------------+--------------+------------------------------+ +| Constellation | Visible SVs | Signals / SV | Total Signals / Visible SVs | ++---------------+-------------+--------------+------------------------------+ +| GPS | 0 | 0 | 0 | +| BeiDou | 0 | 0 | 0 | +| Galileo | 1 | 1 | 1 | +| GLONASS | 0 | 0 | 0 | ++---------------+-------------+--------------+------------------------------+ +Total Visible SVs = 1, Total channels = 1 + +Galileo E1 with IF +2046kHz: ++----+--------------+----+--------------+----+--------------+----+--------------+ +| SV | Doppler (Hz) | SV | Doppler (Hz) | SV | Doppler (Hz) | SV | Doppler (Hz) | ++----+--------------+----+--------------+----+--------------+----+--------------+ +| 01 | +1828 | | | | | | | ++----+--------------+----+--------------+----+--------------+----+--------------+ + +Total channels: 1 + +[INFO] Signal Duration: 0.60 s +[INFO] Signal Size: 9.37 MB +[INFO] Signal Data format: IQ8 +[INFO] Signal Center freq: 1573.3740 MHz +[INFO] Signal Sample rate: 8.1840 MHz + +[INFO] Starting signal generation loop... + +[==> 4.2% ] 25/600 ms | 0.39/9.37 MB @ 55.75 MB/s | ETA: 00s +[====> 8.3% ] 50/600 ms | 0.78/9.37 MB @ 52.03 MB/s | ETA: 00s +[======> 12.5% ] 75/600 ms | 1.17/9.37 MB @ 53.22 MB/s | ETA: 00s +[========> 16.7% ] 100/600 ms | 1.56/9.37 MB @ 52.03 MB/s | ETA: 00s +[==========> 20.8% ] 125/600 ms | 1.95/9.37 MB @ 51.35 MB/s | ETA: 00s +[============> 25.0% ] 150/600 ms | 2.34/9.37 MB @ 49.82 MB/s | ETA: 00s +[==============> 29.2% ] 175/600 ms | 2.73/9.37 MB @ 50.59 MB/s | ETA: 00s +[================> 33.3% ] 200/600 ms | 3.12/9.37 MB @ 51.18 MB/s | ETA: 00s +[==================> 37.5% ] 225/600 ms | 3.51/9.37 MB @ 50.90 MB/s | ETA: 00s +[====================> 41.7% ] 250/600 ms | 3.90/9.37 MB @ 50.68 MB/s | ETA: 00s +[======================45.8% ] 275/600 ms | 4.29/9.37 MB @ 50.50 MB/s | ETA: 00s +[======================50.0% ] 300/600 ms | 4.68/9.37 MB @ 50.35 MB/s | ETA: 00s +[======================54.2%> ] 325/600 ms | 5.07/9.37 MB @ 50.73 MB/s | ETA: 00s +[======================58.3%==> ] 350/600 ms | 5.46/9.37 MB @ 50.12 MB/s | ETA: 00s +[======================62.5%====> ] 375/600 ms | 5.85/9.37 MB @ 49.61 MB/s | ETA: 00s +[======================66.7%======> ] 400/600 ms | 6.24/9.37 MB @ 49.55 MB/s | ETA: 00s +[======================70.8%========> ] 425/600 ms | 6.63/9.37 MB @ 49.51 MB/s | ETA: 00s +[======================75.0%==========> ] 450/600 ms | 7.02/9.37 MB @ 49.12 MB/s | ETA: 00s +[======================79.2%============> ] 475/600 ms | 7.41/9.37 MB @ 49.10 MB/s | ETA: 00s +[======================83.3%==============> ] 500/600 ms | 7.80/9.37 MB @ 49.09 MB/s | ETA: 00s +[======================87.5%================> ] 525/600 ms | 8.20/9.37 MB @ 48.78 MB/s | ETA: 00s +[======================91.7%==================> ] 550/600 ms | 8.59/9.37 MB @ 48.50 MB/s | ETA: 00s +[======================95.8%====================> ] 575/600 ms | 8.98/9.37 MB @ 48.52 MB/s | ETA: 00s +[======================100.0%======================] 600/600 ms | 9.37/9.37 MB | COMPLETED + +[INFO] IF Signal generation completed! +------------------------------------------------------------------ +[INFO] Total samples: 9804432 +[INFO] Clipped samples: 1 (0.0000%) +[INFO] Final AGC gain: 1.000 +[INFO] Total time taken: 0.19 s +[INFO] Data generated: 9.35 MB +[INFO] Average rate: 48.45 MB/s +------------------------------------------------------------------ + diff --git a/verification/vectors/signalsim/nominal_cn0_single_prn/metadata.yaml b/verification/vectors/signalsim/nominal_cn0_single_prn/metadata.yaml new file mode 100644 index 0000000..48aeeee --- /dev/null +++ b/verification/vectors/signalsim/nominal_cn0_single_prn/metadata.yaml @@ -0,0 +1,29 @@ +name: nominal_cn0_single_prn +signalsim_path: /home/onur/Desktop/galileo/signalsim/SignalSim +signalsim_commit: 80b262113e77c4482ca9f5cbfef9bcada402ed28 +command: +- /home/onur/Desktop/galileo/build/signalsim/IFdataGen +- -c +- /home/onur/Desktop/galileo/verification/vectors/signalsim/nominal_cn0_single_prn/generated_config.json +- -o +- /home/onur/Desktop/galileo/verification/vectors/signalsim/nominal_cn0_single_prn/nominal_cn0_single_prn_iq8.bin +- -t +- -st +output: verification/vectors/signalsim/nominal_cn0_single_prn/nominal_cn0_single_prn_iq8.bin +size_bytes: 9804432 +sha256: ffbcb2cfedc6142ca3064514b4d2c10045abf0221462996a5fc10a49213bf88e +complex_sample_count: 4902216 +sampling_frequency_hz: 8184000.0 +intermediate_frequency_hz: 2046000.0 +sample_format: IQ8_interleaved_signed_I_then_Q +duration_s: 0.6 +prns: +- 1 +components: +- E1B +- E1C +expected_doppler_hz: + 1: 1828.0 +expected_code_delay: null +navigation_truth: INavBit generated from bundled RINEX; no sample-side truth file +secondary_truth: PilotBit.cpp SecondaryCodeE1, phase must be synchronized diff --git a/verification/vectors/signalsim/nominal_cn0_single_prn/nominal_cn0_single_prn_iq8.bin.tag b/verification/vectors/signalsim/nominal_cn0_single_prn/nominal_cn0_single_prn_iq8.bin.tag new file mode 100644 index 0000000..2e6082f --- /dev/null +++ b/verification/vectors/signalsim/nominal_cn0_single_prn/nominal_cn0_single_prn_iq8.bin.tag @@ -0,0 +1,8 @@ +PROG = IFDataGen +TIME = 2026/07/30 20:17:06.376 +FMT = INT8X2 +F_S = 8.184000 +F_LO = 1573.374000 +IQ = 1 +BITS = 8 +SCALE = 1.0