Skip to content

CarrierAcquisition: RRC Pulse Shaping

Measured spectrum vs. matched RRC template, and estimate error at two Es/N0 points

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).