Initial Galileo receiver and SignalSim integration

This commit is contained in:
2026-07-30 23:26:03 +03:00
commit 2a6ccfc23d
153 changed files with 8080 additions and 0 deletions
+16
View File
@@ -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/
+126
View File
@@ -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.
+31
View File
@@ -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
+32
View File
@@ -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
+44
View File
@@ -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
+41
View File
@@ -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
+22
View File
@@ -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
+6
View File
@@ -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`.
+119
View File
@@ -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.
+28
View File
@@ -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.
+50
View File
@@ -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
```
+45
View File
@@ -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.
+104
View File
@@ -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.
+241
View File
@@ -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.
+16
View File
@@ -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)
+16
View File
@@ -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)
+5
View File
@@ -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)}")
+19
View File
@@ -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])
+25
View File
@@ -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"]
+103
View File
@@ -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"
}
+8
View File
@@ -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"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

+8
View File
@@ -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"
+7
View File
@@ -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]
+21
View File
@@ -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)
+138
View File
@@ -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)
+41
View File
@@ -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))
+189
View File
@@ -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()
+177
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
"""Optional plots and machine-readable reports."""
+96
View File
@@ -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
+32
View File
@@ -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)
+7
View File
@@ -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)
+100
View File
@@ -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
+48
View File
@@ -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)
+44
View File
@@ -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]
+62
View File
@@ -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),
}
+71
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
"""Streaming digital front-end blocks."""
from .frontend_chain import FrontendChain
from .mixer import PhaseAccumulatorNco
__all__ = ["FrontendChain", "PhaseAccumulatorNco"]
+24
View File
@@ -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
+25
View File
@@ -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
+29
View File
@@ -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
+42
View File
@@ -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)
+66
View File
@@ -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)
+14
View File
@@ -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",
]
+35
View File
@@ -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
+13
View File
@@ -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))
+154
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
"""Cycle-oriented interfaces and hardware scheduling estimates."""
from .cycle_model import AcquisitionArchitectureEstimate, TrackingScheduler
__all__ = ["AcquisitionArchitectureEstimate", "TrackingScheduler"]
+71
View File
@@ -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.
+74
View File
@@ -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
+34
View File
@@ -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
+76
View File
@@ -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
+58
View File
@@ -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
+7
View File
@@ -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"]
+38
View File
@@ -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
+82
View File
@@ -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)
+31
View File
@@ -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
+182
View File
@@ -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)
+121
View File
@@ -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)
+7
View File
@@ -0,0 +1,7 @@
"""Stateful single- and multi-channel tracking."""
from .tracking_channel import TrackingChannel
from .tracking_manager import TrackingManager
__all__ = ["TrackingChannel", "TrackingManager"]
+31
View File
@@ -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))
+75
View File
@@ -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
+51
View File
@@ -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)
+39
View File
@@ -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)

Some files were not shown because too many files have changed in this diff Show More