Files
gps/soc/software/gps_measurement_python/rtl_dll_model.py
T

201 lines
6.7 KiB
Python

"""Small executable model of the RTL code-tracking observables.
This is deliberately scoped to the parts that matter to the navigation
measurement study:
* the normalized early-minus-late magnitude discriminator;
* its signed Q2.13 output;
* the fixed C1/C2 code-loop filter and Q20 NCO command;
* the 20 one-millisecond discriminator samples available per NAV bit.
It is not an RF waveform simulator. The C/N0-to-discriminator-noise mapping
is an explicit calibration assumption until real correlator/DLL telemetry is
recorded from the board.
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
DLL_OUTPUT_FRACTION_BITS = 13
DLL_OUTPUT_WORD_BITS = 15
DLL_SPACING_CHIPS = 0.5
CODE_LOOP_C1 = 21.25
CODE_LOOP_C2 = 0.05712890625
CODE_LOOP_OUTPUT_FRACTION_BITS = 20
CODE_LOOP_OUTPUT_WORD_BITS = 28
DLL_SAMPLES_PER_NAV_BIT = 20
CODE_FREQUENCY_BASIS_HZ = 1.023e6
ABS_SAMPLE_COUNTER_MODULUS = 1 << 32
# Synthetic calibration point. It only fixes the scale of the Monte Carlo
# discriminator telemetry. Dynamic weighting uses a measured/expected ratio,
# so the absolute choice cancels in the correctly modelled case.
DLL_STD_AT_45_DBHZ = 0.020
def _signed_saturate(value: int, word_bits: int) -> int:
minimum = -(1 << (word_bits - 1))
maximum = (1 << (word_bits - 1)) - 1
return min(maximum, max(minimum, value))
def quantize_signed(value: float, word_bits: int, fraction_bits: int) -> int:
scaled = int(np.rint(value * (1 << fraction_bits)))
return _signed_saturate(scaled, word_bits)
def dequantize_signed(value: int, fraction_bits: int) -> float:
return float(value) / float(1 << fraction_bits)
def triangular_correlation(code_offset_chips: float) -> float:
"""Ideal one-chip triangular GPS C/A autocorrelation main lobe."""
return max(0.0, 1.0 - abs(code_offset_chips))
def ideal_early_late_magnitudes(
code_error_chips: float,
spacing_chips: float = DLL_SPACING_CHIPS,
) -> tuple[float, float]:
half_spacing = 0.5 * spacing_chips
early = triangular_correlation(code_error_chips + half_spacing)
late = triangular_correlation(code_error_chips - half_spacing)
return early, late
def normalized_early_minus_late(early_magnitude: float, late_magnitude: float) -> float:
denominator = early_magnitude + late_magnitude
if denominator <= 0.0:
return 0.0
return (early_magnitude - late_magnitude) / denominator
def rtl_discriminator(code_error_chips: float) -> tuple[int, float]:
"""Return the RTL-like Q2.13 discriminator code and dequantized value."""
early, late = ideal_early_late_magnitudes(code_error_chips)
value = normalized_early_minus_late(early, late)
code = quantize_signed(
value,
DLL_OUTPUT_WORD_BITS,
DLL_OUTPUT_FRACTION_BITS,
)
return code, dequantize_signed(code, DLL_OUTPUT_FRACTION_BITS)
def expected_discriminator_std(cn0_dbhz: float) -> float:
"""Expected normalized DLL jitter used by the synthetic calibration.
The 20 dB amplitude-domain law mirrors the firmware's code-sigma model.
The result is bounded because the normalized discriminator is finite.
"""
value = DLL_STD_AT_45_DBHZ * 10.0 ** ((45.0 - cn0_dbhz) / 20.0)
return float(np.clip(value, 0.002, 0.35))
def quantized_code_epoch_measurement(
true_abs_sample: float,
) -> tuple[int, int, float]:
"""Model ABS_SAMPLE + REM_CODE_PHASE and firmware reconstruction.
``true_abs_sample`` is the ideal code boundary in RF-sample units. The
hardware captures the following integer sample and reports the overshoot
with the remainder scale used by the C correction formula.
"""
raw_unwrapped = int(np.ceil(true_abs_sample))
overshoot_samples = raw_unwrapped - true_abs_sample
remainder = int(np.rint(overshoot_samples * CODE_FREQUENCY_BASIS_HZ))
raw_register = raw_unwrapped % ABS_SAMPLE_COUNTER_MODULUS
corrected = raw_unwrapped - remainder / CODE_FREQUENCY_BASIS_HZ
return raw_register, remainder, corrected
@dataclass(frozen=True)
class DllWindow:
samples: np.ndarray
mean: float
variance: float
expected_variance: float
variance_ratio: float
mean_z_score: float
def synthesize_dll_window(
rng: np.random.Generator,
*,
cn0_dbhz: float,
jitter_multiplier: float = 1.0,
discriminator_bias: float = 0.0,
sample_count: int = DLL_SAMPLES_PER_NAV_BIT,
) -> DllWindow:
"""Generate and Q2.13-quantize one NAV-bit DLL telemetry window."""
if sample_count < 2:
raise ValueError("DLL variance needs at least two samples")
expected_std = expected_discriminator_std(cn0_dbhz)
actual_std = expected_std * max(0.0, jitter_multiplier)
raw = discriminator_bias + rng.normal(0.0, actual_std, sample_count)
minimum = -(1 << (DLL_OUTPUT_WORD_BITS - 1))
maximum = (1 << (DLL_OUTPUT_WORD_BITS - 1)) - 1
quantized_codes = np.clip(
np.rint(raw * (1 << DLL_OUTPUT_FRACTION_BITS)),
minimum,
maximum,
).astype(np.int32)
samples = quantized_codes.astype(float) / float(1 << DLL_OUTPUT_FRACTION_BITS)
mean = float(np.mean(samples))
variance = float(np.var(samples, ddof=1))
expected_variance = expected_std * expected_std
ratio = variance / max(expected_variance, 1.0e-15)
standard_error = np.sqrt(max(variance, expected_variance) / sample_count)
mean_z = abs(mean) / max(float(standard_error), 1.0e-12)
return DllWindow(
samples=samples,
mean=mean,
variance=variance,
expected_variance=expected_variance,
variance_ratio=float(ratio),
mean_z_score=float(mean_z),
)
@dataclass
class CodeLoopFilterRtl:
"""Update-rate model of LF_code.sv, including coefficient quantization."""
integrator_q20: int = 0
c1_integer: int = int(CODE_LOOP_C1 * (1 << 2))
c2_integer: int = int(CODE_LOOP_C2 * (1 << 11))
def update_code(self, discriminator_q13: int) -> int:
# C1 product has Q15 and is zero-padded to Q20 in LF_code.sv.
proportional_q20 = (self.c1_integer * discriminator_q13) << 5
# C2 product has Q24 and LF_code.sv slices it back to Q20.
integral_increment_q20 = (self.c2_integer * discriminator_q13) >> 4
self.integrator_q20 = _signed_saturate(
self.integrator_q20 + integral_increment_q20,
CODE_LOOP_OUTPUT_WORD_BITS,
)
return _signed_saturate(
self.integrator_q20 + proportional_q20,
CODE_LOOP_OUTPUT_WORD_BITS,
)
def update(self, discriminator: float) -> float:
discriminator_q13 = quantize_signed(
discriminator,
DLL_OUTPUT_WORD_BITS,
DLL_OUTPUT_FRACTION_BITS,
)
command_q20 = self.update_code(discriminator_q13)
return dequantize_signed(command_q20, CODE_LOOP_OUTPUT_FRACTION_BITS)