Skip to content

NDA Carrier Loop — Theory Validation

NDA carrier loop theory validation

track.CarrierNda is the non-data-aided (NDA) carrier-recovery loop — the cold-start counterpart to the decision-directed CarrierMpsk. Per sample it de-rotates with the integer lo NCO; it filters the de-rotated samples through a free-running I/Q boxcar moving average of sps/n samples (one output per input sample — no rate change), and on every sample runs an M-th-power phase discriminator. Raising the arm sample to the Mth power strips the M-PSK data, so the loop locks with no symbol timing and no data present — a bare carrier, or a modulated carrier before timing settles. It is the robust acquisition aid the MPSK receiver hands over from (see the design note).

Left — Discriminator S-curve. Driving the discriminator open-loop traces the scaled M-th-power detector phase_error = Im(z^M)·{1, ½, ¼} for M = 2 / 4 / 8: a sawtooth of period 2π/M (the M-fold phase ambiguity). The {1, ½, ¼} scale is not cosmetic — it makes the S-curve slope at lock equal 2 for every M, so one loop bn behaves identically across BPSK / QPSK / 8PSK. The measured curve matches the analytic M-th power to ~9e-8. The lock signal (not plotted) is Re(z^M)·lock_scale — exact for M ≤ 4, a faithful monotone lock detector for M = 8 — and its EMA is the carrier lock metric that drives handover.

Right — Cold-start acquisition. A carrier step f0 = 0.0015 cycles/sample on an unmodulated carrier (no data, no timing): the tracked frequency snaps onto the truth (black dashed) for all three orders. Because the M-th power is data-blind it acquires modulated data with no timing just as well (validated in the tests).

Computation — repeated squaring

The M-th power is built by repeatedly squaring the arm sample z = i + jq — driven to unit average power by an internal AGC so the loop gain is amplitude-invariant: strips BPSK, z⁴ QPSK, z⁸ 8PSK. Each level yields a phase error and a lock signal at one complex multiply — no atan2, no pow. The discriminator is the raw M-th-power form (best squaring loss for constant-modulus signals like DSSS), not a per-sample magnitude limiter. See docs/design/mpsk.md §2.3 for the derivation and the squaring-loss equations.

import numpy as np
from doppler.track import CarrierNda

# A QPSK signal at 8 samples/symbol with a residual carrier offset to track.
rng = np.random.default_rng(0)
idx = rng.integers(0, 4, 2000)
tx  = np.exp(1j * (2 * np.pi * idx / 4 + np.pi / 4)).astype(np.complex64)
tx  = np.repeat(tx, 8).astype(np.complex64)
k   = np.arange(tx.size)
rx  = (tx * np.exp(2j * np.pi * 0.0015 * k)).astype(np.complex64)

# QPSK NDA loop, 8 samples/symbol, sps/n = 2-sample boxcar arm; cold start
c = CarrierNda(bn=0.01, zeta=0.707, init_norm_freq=0.0, sps=8, n=4, m=4)
derot = c.steps(rx)        # de-rotated samples (one per input sample)
f_est = c.norm_freq        # tracked carrier (cycles/sample)
locked = c.lock            # M-th-power lock metric (→ lock_scale when locked)

Rigorous bounds

The C harnesses native/validation/carrier_nda_scurve.c, carrier_nda_pullin.c, and carrier_nda_step_response.c (ctest --check) prove: phase_error = Im(z^M)·{1,½,¼} and slope 2 for all M (to ~1e-7); lock_signal = Re(z^M)·lock_scale for M ≤ 4; cold-start frequency pull-in on an unmodulated carrier per M; lock on modulated M-PSK data with no symbol timing; closed-loop frequency jitter that grows with bn; and a closed-loop step response that locks on both constant-modulus and pulse-shaped (RRC) inputs.

Source: src/doppler/examples/mpsk_nda_theory_demo.py; tests in src/doppler/track/tests/test_theory_carrier_nda.py and the C harnesses above.