Current section
Files
Jump to
Current section
Files
lib/sidereon/gnss/signal/correlator.ex
defmodule Sidereon.GNSS.Signal.Correlator do
@moduledoc """
Baseband simulation, correlation, and acquisition for the GPS L1 C/A signal.
This module builds the "generate a replica, correlate, and acquire a PRN"
layer on top of `Sidereon.GNSS.Signal.CA`. It works entirely in complex baseband
(the carrier has already been removed down to a residual Doppler), using the
standard time-domain signal-processing model described in the GNSS
acquisition literature:
* Kaplan & Hegarty, *Understanding GPS/GNSS: Principles and Applications*
(3rd ed.), Ch. 5-8 (signal acquisition, coherent correlation, the
sinc Doppler-mismatch loss, and the C/N0 to post-correlation SNR
relation).
* Misra & Enge, *Global Positioning System: Signals, Measurements, and
Performance* (2nd ed.), Ch. 10-11.
* Borre, Akos, Bertelsen, Rinder & Jensen, *A Software-Defined GPS and
Galileo Receiver* (the 2D code-phase by Doppler search and the
peak-to-noise acquisition metric).
## Sampled-code replica (zero-order-hold / nearest-chip)
The 1023-chip C/A code is sampled to a sampling rate `fs` over an integration
time `T` (so `N = round(fs * T)` samples). At sample `n` the code phase, in
chips, advances at the code rate:
code_rate = f_chip * (1 + fd_code / f_L1)
sampled[n] = chip(prn, floor((code_phase + n * code_rate / fs) mod 1023))
with `f_chip = 1_023_000` cps and `f_L1 = 1_575_420_000` Hz. `code_phase` is
the initial offset in chips and `fd_code` is an optional code-rate Doppler
scaling (defaults to `0.0`; code Doppler is negligible over one 1 ms period
but the parameter is exposed). This is a clean nearest-chip (zero-order-hold)
sampler: it picks the chip the sample instant falls within. `CA.chip/2`
already wraps its index modulo 1023, so the sampler wraps for free.
## Coherent correlation
For a complex baseband record `x[n] = xI[n] + j*xQ[n]` (a real-valued test
signal has `xQ = 0`), correlation against a local carrier wipe-off
`exp(-j 2*pi*f_d*n/fs)` times the real bipolar code `c[n]` is the coherent
sum over the integration window:
S = sum_{n=0}^{N-1} x[n] * c[n] * exp(-j 2*pi*f_d*n/fs)
I = sum ( xI[n]*c[n]*cos(th_n) + xQ[n]*c[n]*sin(th_n) )
Q = sum ( xQ[n]*c[n]*cos(th_n) - xI[n]*c[n]*sin(th_n) ), th_n = 2*pi*f_d*n/fs
power = I^2 + Q^2
The amplitude is recovered as `sqrt(I^2 + Q^2)`; for a real input the carrier
wipe-off still spreads energy across both I and Q so the magnitude is the
meaningful quantity.
## Acquisition metric
`acquire/3` performs a 2D search over code-phase bins (at sample resolution)
and Doppler bins, computing the correlator `power` on each cell. The detection
metric is the standard peak-to-noise ratio used in software receivers:
metric = peak_power / mean(off_peak_powers)
where the off-peak set excludes the peak cell and an exclusion zone of one
code-phase bin on either side of the peak (at the peak's Doppler bin), so the
main correlation lobe is not mistaken for the noise floor. For a clean signal
this metric is large (order of the number of samples); for noise or the wrong
PRN it is close to one. The alternative peak-to-second-peak ratio is also a
standard choice; this module exposes peak-to-mean-off-peak as `metric`.
## Coherent integration loss and post-correlation SNR
A residual frequency error `f` over a coherent integration time `T` attenuates
the correlation by the sinc-squared Doppler-mismatch loss (Kaplan & Hegarty,
the residual-carrier loss whose discrete form
`|sin(N*pi*df*Ts) / (N*sin(pi*df*Ts))|` tends to `sinc(pi*f*T)` in the
continuous limit):
coherent_loss(f, T) = sinc^2(pi*f*T) = ( sin(pi*f*T) / (pi*f*T) )^2
which is `1` (0 dB) at `f = 0` and has its first null at `f = 1/T`. The
correlation *amplitude* scales as `|sinc(pi*f*T)| = sqrt(coherent_loss)`.
For the post-correlation signal-to-noise ratio this module exposes only the
relation it can state cleanly from a standard reference: coherent integration
over `T` seconds gives the predetection SNR
snr_post_db(cn0_dbhz, T) = cn0_dbhz + 10*log10(T)
i.e. the carrier-to-noise-density ratio C/N0 (dB-Hz) plus the processing gain
`10*log10(T)` corresponding to a `1/T` effective noise bandwidth (Kaplan &
Hegarty; Misra & Enge). The exact noise-bandwidth convention (`1/T` versus
`1/(2T)`) differs between texts; this module uses the `1/T` form. No second,
competing gain formula is exposed.
"""
alias Sidereon.NIF
@default_doppler_min_hz -2500.0
@default_doppler_max_hz 2500.0
@default_doppler_step_hz 500.0
@default_sample_rate_hz 2.046e6
@doc """
Builds a sampled `±1` C/A code replica.
Options:
* `:sample_rate_hz` - sampling rate in Hz (default `2.046e6`, 2 samples/chip).
* `:integration_time_s` - integration time in seconds (default one code
period, `1023 / 1_023_000` = 1 ms). Determines `N` together with the
sample rate, unless `:num_samples` is given.
* `:num_samples` - explicit sample count `N` (overrides `:integration_time_s`).
* `:code_phase_chips` - initial code phase offset in chips (default `0.0`).
* `:code_doppler_hz` - code-rate Doppler scaling in Hz (default `0.0`).
Returns `{:ok, samples}` with `samples` a list of `±1` integers of length `N`,
or propagates `{:error, {:unsupported_prn, prn}}` from `Sidereon.GNSS.Signal.CA`.
## Examples
iex> {:ok, s} = Sidereon.GNSS.Signal.Correlator.replica(1, num_samples: 4, sample_rate_hz: 1.023e6)
iex> s
[-1, -1, 1, 1]
"""
@spec replica(integer(), keyword()) ::
{:ok, [integer()]} | {:error, {:unsupported_prn, integer()}}
def replica(prn, opts \\ [])
def replica(prn, _opts) when not is_integer(prn), do: {:error, {:unsupported_prn, prn}}
def replica(prn, opts) do
fs = Keyword.get(opts, :sample_rate_hz, @default_sample_rate_hz)
code_phase = Keyword.get(opts, :code_phase_chips, 0.0) * 1.0
code_doppler = Keyword.get(opts, :code_doppler_hz, 0.0) * 1.0
n = num_samples(opts, fs)
NIF.signal_correlator_replica(prn, n, fs, code_phase, code_doppler)
end
@doc """
Coherently correlates a complex baseband record against a PRN replica.
`iq` is a list of samples, each either a `{i, q}` tuple or a bare real number
(interpreted as `i` with `q = 0`). Options:
* `:sample_rate_hz` - default `2.046e6`.
* `:doppler_hz` - residual carrier Doppler to wipe off (default `0.0`).
* `:code_phase_chips` - replica code phase offset in chips (default `0.0`).
* `:code_doppler_hz` - replica code-rate Doppler (default `0.0`).
Returns `{:ok, %{i: i, q: q, power: i*i + q*q}}`, or propagates the PRN error.
The replica is generated at `length(iq)` samples to match the record.
"""
@spec correlate(list(), integer(), keyword()) ::
{:ok, %{i: float(), q: float(), power: float()}}
| {:error, {:unsupported_prn, integer()} | :empty_samples}
def correlate(iq, prn, opts \\ [])
def correlate([], _prn, _opts), do: {:error, :empty_samples}
def correlate(_iq, prn, _opts) when not is_integer(prn), do: {:error, {:unsupported_prn, prn}}
def correlate(iq, prn, opts) when is_list(iq) do
fs = Keyword.get(opts, :sample_rate_hz, @default_sample_rate_hz)
doppler = Keyword.get(opts, :doppler_hz, 0.0) * 1.0
code_phase = Keyword.get(opts, :code_phase_chips, 0.0) * 1.0
code_doppler = Keyword.get(opts, :code_doppler_hz, 0.0) * 1.0
case NIF.signal_correlator_correlate(
normalize_iq(iq),
prn,
fs,
doppler,
code_phase,
code_doppler
) do
{:ok, {i, q, power}} -> {:ok, %{i: i, q: q, power: power}}
{:error, _reason} = err -> err
end
end
@doc """
Low-level coherent correlation of a baseband record against an explicit
sampled `±1` code.
`iq` is a list of `{i, q}` tuples or bare reals; `code` is the sampled `±1`
vector (same length); `fs` is the sample rate and `doppler_hz` the residual
carrier to wipe off. Returns `{i, q}`, the real and imaginary parts of the
coherent sum.
"""
@spec correlate_against(list(), [integer()], number(), number()) :: {float(), float()}
def correlate_against(iq, code, fs, doppler_hz) when is_list(iq) and is_list(code) do
NIF.signal_correlator_correlate_against(normalize_iq(iq), code, fs / 1.0, doppler_hz / 1.0)
end
@doc """
Acquires a PRN by a 2D search over code phase and Doppler.
`samples` is the complex baseband record (list of `{i, q}` tuples or bare
reals). Options:
* `:sample_rate_hz` - default `2.046e6`.
* `:doppler_min_hz` / `:doppler_max_hz` / `:doppler_step_hz` - the Doppler
search grid (defaults `-2500`, `2500`, `500` Hz).
The code-phase axis is searched at sample resolution over one code period.
Returns
{:ok, %{
code_phase_chips: float,
doppler_hz: float,
peak_metric: float,
metric: float,
peak_power: float,
grid: %{
doppler_hz: [float],
code_phase_bins: integer,
doppler_step_hz: float,
samples_per_chip: float
}
}}
`metric` (and its alias `peak_metric`) is the peak-to-mean-off-peak power
ratio described in the module docs. Errors:
* `{:error, :empty_samples}` for an empty record,
* `{:error, :too_short}` if the record is shorter than one code period,
* `{:error, {:unsupported_prn, prn}}` propagated from the code generator.
"""
@spec acquire(list(), integer(), keyword()) ::
{:ok, map()}
| {:error, :empty_samples | :too_short | {:unsupported_prn, integer()}}
def acquire(samples, prn, opts \\ [])
def acquire([], _prn, _opts), do: {:error, :empty_samples}
def acquire(_samples, prn, _opts) when not is_integer(prn), do: {:error, {:unsupported_prn, prn}}
def acquire(samples, prn, opts) when is_list(samples) do
fs = Keyword.get(opts, :sample_rate_hz, @default_sample_rate_hz)
dmin = Keyword.get(opts, :doppler_min_hz, @default_doppler_min_hz) * 1.0
dmax = Keyword.get(opts, :doppler_max_hz, @default_doppler_max_hz) * 1.0
dstep = Keyword.get(opts, :doppler_step_hz, @default_doppler_step_hz) * 1.0
case NIF.signal_correlator_acquire(normalize_iq(samples), prn, fs, dmin, dmax, dstep) do
{:ok, {code_phase_chips, doppler_hz, metric, peak_power, grid}} ->
{doppler_bins, code_phase_bins, doppler_step_hz, samples_per_chip} = grid
{:ok,
%{
code_phase_chips: code_phase_chips,
doppler_hz: doppler_hz,
peak_metric: metric,
metric: metric,
peak_power: peak_power,
grid: %{
doppler_hz: doppler_bins,
code_phase_bins: code_phase_bins,
doppler_step_hz: doppler_step_hz,
samples_per_chip: samples_per_chip
}
}}
{:error, _reason} = err ->
err
end
end
@doc """
Coherent integration loss from a residual frequency error.
Returns the linear loss `sinc^2(pi*f*T) = (sin(pi*f*T)/(pi*f*T))^2` in
`[0, 1]`: `1.0` at `f = 0`, with its first null at `f = 1/T`. The correlation
amplitude scales as `sqrt` of this value.
## Examples
iex> Sidereon.GNSS.Signal.Correlator.coherent_loss(0.0, 1.0e-3)
1.0
"""
@spec coherent_loss(number(), number()) :: float()
def coherent_loss(freq_error_hz, integration_time_s) do
NIF.signal_coherent_loss(freq_error_hz / 1.0, integration_time_s / 1.0)
end
@doc """
Coherent integration loss in decibels, `10*log10(coherent_loss(f, T))`.
Returns `:neg_infinity` at an exact null (loss of zero).
"""
@spec coherent_loss_db(number(), number()) :: float() | :neg_infinity
def coherent_loss_db(freq_error_hz, integration_time_s) do
NIF.signal_coherent_loss_db(freq_error_hz / 1.0, integration_time_s / 1.0)
end
@doc """
Post-correlation (predetection) SNR in dB from C/N0 and integration time.
Uses the standard relation `snr_post_db = cn0_dbhz + 10*log10(T)` (Kaplan &
Hegarty; Misra & Enge), corresponding to a `1/T` effective noise bandwidth.
## Examples
iex> Float.round(Sidereon.GNSS.Signal.Correlator.snr_post_db(40.0, 1.0e-3), 6)
10.0
"""
@spec snr_post_db(number(), number()) :: float()
def snr_post_db(cn0_dbhz, integration_time_s) when integration_time_s > 0 do
NIF.signal_snr_post_db(cn0_dbhz / 1.0, integration_time_s / 1.0)
end
# --- internal helpers ---
defp num_samples(opts, fs) do
case Keyword.get(opts, :num_samples) do
nil ->
t =
Keyword.get(
opts,
:integration_time_s,
NIF.signal_ca_code_length() / NIF.signal_ca_chip_rate_hz()
)
round(fs * t)
n when is_integer(n) and n > 0 ->
n
end
end
defp normalize_iq(iq), do: Enum.map(iq, &to_iq/1)
defp to_iq({i, q}), do: {i / 1.0, q / 1.0}
defp to_iq(i) when is_number(i), do: {i / 1.0, 0.0}
end