Carrier Loop Stress¶
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 — nodouble-accumulator drift over long runs. The loop de-rotates the input one sample at a time with the inlinelo_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.
