CarrierAcquisition: RRC Pulse Shaping¶
CarrierAcquisition is a PSDMF (power-spectral-density
matched-filter) residual-carrier estimator: it non-coherently averages the
incoming stream's power spectrum (PSD), then circularly
correlates that average against a known power spectrum shape to find the
residual carrier offset. It runs as a one-shot refinement stage after
Acquisition's own coarse Doppler search — see
DSSS Acquisition: Pd/Pfa for that stage.
The template is a property of the pulse shape, not a universal constant¶
The default known shape (psd_template left empty) is the average PSD of a
random rectangular-pulse (plain NRZ) BPSK stream — a sinc². An RRC
(root-raised-cosine) pulse-shaped stream's average PSD is a different
shape entirely: the squared magnitude of the RRC filter's own frequency
response, a raised-cosine roll-off with no sidelobes past (1+beta)/(2*sps)
of the symbol rate. psd_template exists precisely so a caller running a
different pulse shape or modulation can supply the correct known shape —
mirroring ~/legacy-commz's own FrequencyAcquisition.power_spectrum
override, the reference this object's design descends from.
How it works¶
import numpy as np
from doppler.wfm import rrc_taps
SYM_RATE_HZ = 1000.0
SPS = 8 # samples/symbol
SAMPLE_RATE_HZ = SYM_RATE_HZ * SPS
BETA = 0.35 # RRC roll-off
SPAN = 6 # RRC one-sided support, symbols
TRUE_RESIDUAL_HZ = 137.0
N_SYM = 20000
SEED = 1
def make_signal(esn0_db: float, seed: int = SEED):
"""A random BPSK stream, RRC pulse-shaped, carrying a known residual
carrier and AWGN at the given per-symbol Es/N0. Returns the complex
baseband capture."""
rng = np.random.default_rng(seed)
bits = np.where(rng.integers(0, 2, N_SYM), 1.0, -1.0)
upsampled = np.zeros(N_SYM * SPS)
upsampled[::SPS] = bits
taps = rrc_taps(BETA, SPS, SPAN)
shaped = np.convolve(upsampled, taps, mode="same")
sig_power = np.mean(shaped**2)
noise_power = sig_power / (10.0 ** (esn0_db / 10.0))
noise = np.sqrt(noise_power / 2.0) * (
rng.standard_normal(len(shaped))
+ 1j * rng.standard_normal(len(shaped))
)
t = np.arange(len(shaped))
tone = np.exp(2j * np.pi * TRUE_RESIDUAL_HZ * t / SAMPLE_RATE_HZ)
return (shaped * tone + noise).astype(np.complex64)
def rrc_template_for(nfft: int) -> np.ndarray:
"""The known average PSD shape of an RRC-shaped random BPSK stream:
the squared magnitude of the RRC filter's own frequency response,
DC-centred to match CarrierAcquisition's own bin convention -- a
linear filter applied to a white bipolar sequence has average PSD
proportional to |H(f)|^2, the direct RRC analogue of the default
template's rectangular-pulse sinc^2."""
taps = rrc_taps(BETA, SPS, SPAN)
padded = np.zeros(nfft)
padded[: len(taps)] = taps
h = np.fft.fftshift(np.fft.fft(padded))
template = (np.abs(h) ** 2).astype(np.float32)
return template / template.max()
def estimate(x: np.ndarray, template: np.ndarray) -> CarrierAcquisition:
ca = CarrierAcquisition(
SAMPLE_RATE_HZ, SYM_RATE_HZ, psd_template=template, **CA_KWARGS
)
ca.steps(x)
return ca
def run_condition(esn0_db: float):
"""Estimate the residual with both the default (wrong-shape) and the
RRC-matched template, at one Es/N0. Returns a dict of results."""
x = make_signal(esn0_db)
probe = CarrierAcquisition(SAMPLE_RATE_HZ, SYM_RATE_HZ)
template = rrc_template_for(probe.nfft)
default = estimate(x, NO_TEMPLATE)
rrc = estimate(x, template)
return {"x": x, "default": default, "rrc": rrc}
A linear filter applied to a white bipolar sequence has average PSD
proportional to |H(f)|^2 — the direct RRC analogue of the default
template's rectangular-pulse sinc². doppler.wfm.rrc_taps
already generates the filter; the template is just its own zero-padded,
DC-centred squared frequency response.
What you're seeing¶
Same RRC-shaped BPSK stream, same true 137 Hz residual, two Es/N0 points:
Left — measured power spectrum vs. the matched RRC template, at 0 dB Es/N0. The measured spectrum's own roll-off (blue) lines up with the template's shape (dashed green) — this is the shape the correlation is actually matching against, not an arbitrary constant.
Right — estimate error, wrong vs. matched template, at two Es/N0 points. At 10 dB both templates land within a few Hz of the true residual — at generous margin, template shape barely matters. At 0 dB both templates still confidently detect (the CFAR gate fires for both), but the default (rectangular-pulse) template's own estimate is roughly 2x worse than the matched RRC template's. The wrong shape doesn't cost the detection outright here — it systematically biases the estimate, and that bias grows as SNR degrades.
A calibration fix, mid-story¶
CarrierAcquisition's detection gate originally reused doppler.detection's
det_threshold_noncoherent/det_n_noncoh — the same Pfa/Pd statistics
Acquisition itself is built on, derived for classic complex-correlator
(Rayleigh/Rician) detection. That model does NOT transfer to gating a
power-spectrum-vs-template correlation — confirmed via Monte Carlo (see
FINISHING_PLAN.md's CarrierAcquisition section): the borrowed threshold
was roughly 5x too conservative, which is why an earlier version of
this very page showed the default template failing to detect outright at
5 dB. carrier_acq_core.c's _ratio_threshold() now uses a threshold
derived from this object's own real statistic (an exact Gamma-sum H0 model)
plus one empirically-calibrated constant — not yet a fully general closed
form (see the source comment for what's still open), but a large,
measured improvement over the borrowed model.
Source: src/doppler/examples/carrier_acq_rrc_demo.py. See also
Correlation (the PSD/Corr/CorrDetector primitives this
object composes), 2-D Acquisition (the same Pfa/Pd
statistics applied to Acquisition's own code-phase × Doppler search), and
wfm I/O (rrc_taps and pulse shaping in general).
