Skip to content

Carrier Loop Stress

Costas carrier-loop stress demo

A track.Costas carrier-tracking loop acquiring a large residual carrier offset — ~0.9 rad/symbol, at SNR = 15 dB. This is bigger than a bare Costas PLL can pull in at any loop bandwidth: the phase discriminator's linear range is far narrower than the residual. The figure contrasts three configurations — a narrow PLL (Bn = 0.01), a wide PLL (Bn = 0.10), and an FLL-assisted PLL (Bn = 0.01, bn_fll = 0.03) — to show why the FLL assist exists.

What you're seeing

Top — Frequency tracking. The integer-NCO frequency estimate vs the true residual (black dashed). Both bare PLLs stall near zero — neither bandwidth can acquire the offset. The FLL-assisted loop snaps straight onto it.

Middle — Loop stress vs time. The sliding-RMS of the Costas phase discriminator error (degrees) — the stress on the loop. The bare PLLs sit pinned at maximum stress (~40°) forever, because they never lock. The FLL-assisted loop's wide cross-product frequency discriminator pulls the loop's integrator onto the residual, and the stress decays to a low locked floor.

Bottom — Lock metric vs time. |Re P| / |P|: stuck near 0.6 (no lock) for the bare PLLs, ramping to 1 for the FLL-assisted loop.

How it works

The carrier loop is one small primitive composed from two others:

  • source.LO — an integer-phase NCO (uint32 accumulator + LUT → cf32). The phase wraps at 2³² by construction, so it is bounded and exactly reproducible — no double-accumulator drift over long runs. The loop de-rotates the input one sample at a time with the inline lo_step() (carrier wipe-off).
  • track.LoopFilter — the 2nd-order PI loop filter that turns the per-symbol phase error into a frequency + phase steer.

Each tsamps-sample symbol is coherently integrated (integrate-and-dump), a decision-directed BPSK discriminator measures the residual phase, the loop filter updates, and the new frequency/phase is written straight into the NCO.

FLL assist (bn_fll > 0) adds a second, frequency discriminator: the data-wiped cross product of consecutive prompts, Im(conj(P_prev)·P_curr). Its linear range is far wider than the phase discriminator's, so it pulls the loop's frequency integrator onto a large or fast-moving residual the bare PLL would miss; the PLL then refines phase. bn_fll = 0 is a pure Costas PLL.

import numpy as np

from doppler.track import Costas
from doppler.wfm import Composer, Segment

TSAMPS = 16  # samples per symbol (integrate-and-dump period)
NSYM = 4000  # symbols
F0 = 0.009  # large residual carrier offset, cycles/sample (~0.9 rad/symbol)
SNR_DB = 15.0  # per-sample SNR

# Continuous BPSK carrying the residual carrier offset F0 + AWGN, from
# wfmgen's built-in PSK source.
#
# `snr_mode="fs"` is the point of interest here, and it is a DIFFERENT
# question from the `esno` the M-PSK receiver demo asks. SNR_DB is stated
# per SAMPLE -- referred to the full sample-rate band -- because a Costas
# loop's discriminator sees samples, not symbols, and its own noise
# bandwidth is what the loop design trades against. Es/N0 would be
# `SNR_DB + 10*log10(TSAMPS)`, and picking the wrong one of the two is a
# 12 dB error at this oversampling.
#
# It replaces `sigma = sqrt(10**(-SNR_DB/10) / 2)`, where the `2` splits the
# power across I and Q. Verified equal, not assumed: the same declaration
# measures 15.0 dB per-sample SNR and the noise variances agree to five
# digits.
rx = np.asarray(
    Composer(
        [
            Segment(
                type="bpsk",  # real +-1, from the seeded PN
                sps=TSAMPS,
                fs=1.0,  # normalised: F0 is cycles/sample
                freq=F0,
                snr=SNR_DB,
                snr_mode="fs",  # per SAMPLE, not per symbol
                num_samples=NSYM * TSAMPS,
                seed=0,
            )
        ]
    ).compose()
).astype(np.complex64)

# FLL-assisted PLL: the wide cross-product frequency discriminator pulls
# the loop's integrator onto the large residual; the PLL refines phase.
c = Costas(bn=0.01, zeta=0.707, init_norm_freq=0.0, tsamps=TSAMPS, bn_fll=0.03)
symbols = c.steps(rx)  # one complex prompt per symbol
f_est = c.norm_freq  # tracked residual frequency (cycles/sample)
locked = c.lock_metric  # |Re P|/|P| EMA, ~1.0 when phase-locked

Costas tracks only the residual left after acquisition; an offset larger than the per-symbol integration bandwidth must be removed upstream by the FFT acquisition search, not by the loop. The FLL assist's own unambiguous range is about ±¼ of the symbol rate (the cross product is monotonic to ±π/2 per symbol).

Source: src/doppler/examples/costas_demo.py.