Initial Galileo receiver and SignalSim integration
@@ -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/
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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)}")
|
||||
@@ -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])
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 45 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 45 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 127 KiB |
@@ -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"
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"""FFT parallel code-phase acquisition."""
|
||||
|
||||
from .acquisition_manager import AcquisitionManager
|
||||
from .fft_pcps import PcpsAcquisition, PcpsSearchGrid
|
||||
|
||||
__all__ = ["AcquisitionManager", "PcpsAcquisition", "PcpsSearchGrid"]
|
||||
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Optional plots and machine-readable reports."""
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Streaming digital front-end blocks."""
|
||||
|
||||
from .frontend_chain import FrontendChain
|
||||
from .mixer import PhaseAccumulatorNco
|
||||
|
||||
__all__ = ["FrontendChain", "PhaseAccumulatorNco"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Cycle-oriented interfaces and hardware scheduling estimates."""
|
||||
|
||||
from .cycle_model import AcquisitionArchitectureEstimate, TrackingScheduler
|
||||
|
||||
__all__ = ["AcquisitionArchitectureEstimate", "TrackingScheduler"]
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ``<profile>_<component>_PRN<nnn>.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)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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<body>.*?)\};",
|
||||
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)
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Stateful single- and multi-channel tracking."""
|
||||
|
||||
from .tracking_channel import TrackingChannel
|
||||
from .tracking_manager import TrackingManager
|
||||
|
||||
__all__ = ["TrackingChannel", "TrackingManager"]
|
||||
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||