Skip to content

M-PSK Receiver — Pull-in, Lock, and BER

MpskReceiver constellation pull-in, carrier acquisition, and BER

track.MpskReceiver is a complete per-sample M-PSK modem. It composes the tracking primitives on one shared sample loop: a CarrierNda carrier loop (per-sample integer-NCO wipe-off + non-data-aided M-th-power acquisition), an owned matched filter on the de-rotated stream (integrate-and-dump boxcar by default, or root-raised-cosine for band-limited links), and a SymbolSync Gardner timing loop. Carrier recovery follows the project rule: predetection de-rotation (always) and postdetection discrimination. See the design note.

Left — Constellation pull-in. A QPSK signal with a 0.0015 cycles/sample carrier offset, with the loop seeded at zero. During acquisition (red) the de-rotated symbols sweep a ring — the residual carrier is still rotating them; once the non-data-aided loop pulls the NCO onto the offset, the symbols (blue) collapse onto the four QPSK clusters. No data aiding and no symbol timing are required to acquire — that is what the M-th-power arm buys.

Middle — Carrier acquisition + lock. The tracked frequency (green) snaps from zero onto the true offset (black dashed) within tens of symbols, and the lock metric (purple) rises and holds. The lock metric is orientation-normalised, so it reads ~+ at lock for every M (BPSK ≈ 1, QPSK ≈ 0.62, 8PSK ≈ 0.41) and is what the opt-in auto_handover thresholds on.

Right — BER vs Es/N0. Bit error rate against the coherent M-PSK bound, using NDA acquisition followed by decision-directed handover (auto_handover=1). BPSK and QPSK track the bound within ~1–2 dB. 8PSK shows an acquisition threshold: the 8th-power discriminator's phase noise is large at low SNR, so the loop does not pull in until ~13–14 dB — above which decision-directed tracking takes over and it falls to the bound. This is the fundamental cost of non-data-aided 8PSK acquisition; a known preamble or external frequency aid removes the threshold.

Carrier handover — acquire blind, track clean

By default (auto_handover=0) the receiver stays in robust NDA tracking the whole time. Enabling handover hands the shared NCO from the M-th-power discriminator to a lower-jitter decision-directed error e = Im(y·conj(â))/|y| on the recovered symbols once the loop has locked and a warmup has elapsed — essential for 8PSK, whose M-th-power phase noise would otherwise cross the ±π/8 decision margins. The decision-directed loop steers at symbol rate (a naturally lower loop bandwidth), which is exactly what low-jitter steady-state tracking wants; the NCO frequency estimate carries across handover.

import numpy as np

from doppler.track import MpskReceiver

# A QPSK signal at 8 samples/symbol with a residual carrier offset to recover.
rng = np.random.default_rng(0)
idx = rng.integers(0, 4, 4000)
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)
iq  = (tx * np.exp(2j * np.pi * 0.0015 * k)).astype(np.complex64)

rx = MpskReceiver(m=4, sps=8, n=4, pulse="iandd",
                  bn_carrier=0.01, bn_timing=0.01,
                  auto_handover=1, lock_thresh=0.4, warmup_syms=200)
sym  = rx.steps(iq)          # recovered symbols (~ len(iq) / sps)
bits = rx.bits(iq)           # hard Gray bits, LSB-first per symbol
assert rx.tracking == 1      # handed over to decision-directed tracking

Resolving the M-fold ambiguity — differential bits

The carrier loop locks to one of m phases, so the absolute constellation orientation is ambiguous. bits(..., differential=1) decodes each symbol from the phase difference between consecutive symbols, which is invariant to an unknown constant carrier phase:

rx = MpskReceiver(m=8, sps=8, differential=1)
bits = rx.bits(iq)           # rotation-invariant; survives any fixed phase slip

DSSS-MPSK — chain after a despreader

A spread-spectrum M-PSK receiver is just a despreader feeding this modem: the Dll(segments) streaming despreader collapses each PN epoch to one symbol-rate soft chip, and MpskReceiver recovers carrier, timing, and bits on that stream — Dll(segments) → MpskReceiver.

Reproduce

python src/doppler/examples/mpsk_receiver_demo.py   # → mpsk_receiver_demo.png