Skip to content

M-PSK Receiver — Performance Characterisation

Eight-panel Monte-Carlo characterisation of MpskReceiver and MpskReceiverR

This is the receiver's characterisation, not a demo of it. Everything on the page is measured over randomly drawn geometries rather than a fixed grid, on both track.MpskReceiver (complex baseband) and track.MpskReceiverR (real IF) — see the walkthrough page for what they are and the design note for how they work.

Each trial independently draws the constellation order, m_out, a non-integer samples-per-symbol, the IF placement, both loop bandwidths, the frequency and clock offsets, the absolute level, and the data. That is the point:

Why random draws, not a grid

A grid measures the geometries you thought of. Random draws over the documented input domain either show you a clean distribution or hand you the outlier — and that is exactly how a real one hid here for weeks: at sps = 10 with m_out = 4 and an IF at 0.10, the occupied band reaches DC, where the real front end's image rejection collapses, and EVM falls to −4 dB. No grid of "reasonable" cases drew it.

The measurement rules the panels obey

These are not incidental — get any of them wrong and the numbers look like DSP defects. They are the same rules the design note and the receiver's test harness encode.

Rule Why
Settling = 2·(5/bn_t + 5/bn_c) 5/Bn per loop; the two add because they are cascaded (the carrier discriminator reads the on-time strobe, so it cannot converge until timing has); then double for joint tracking, where each loop sees the other's transient. Measuring from 5/bn alone reads −9.0 dB where the settled answer is −23.2 dB.
Offsets INSIDE the loop bandwidth Seeded on truth, the loop never leaves its initial state and any lock time is meaningless. Asserted outside Bn, the test measures luck. Measured: carrier lock in 39 symbols at 0.25·Bn, 1376 at 1·Bn, never at 2·Bn.
Loop SNR ≈ 20 dB, bn ≤ 0.01 ρ_L = Es/N0 + squaring_loss − 10log₁₀(bn). Below ~20 dB the loop is driven by noise: the estimate random-walks and the receiver declares lock while producing chance-level symbols. If you are short of it, narrow bn — never accept less.
Never widen bn for a shorter record bn is normalised to the symbol rate, so settling is a fixed number of symbols at any sample rate. Widening does not buy samples, it changes the receiver. A heavily oversampled case simply needs millions of samples — that is what wfmgen is for.
Anchor at SER = 1e-3 Per-M that is 6.8 / 10.3 / 15.7 dB, which asks "does it meet its bound" at the same place on the curve for every order, instead of at one arbitrary Es/N0.

The two panels

EVM vs the coherent bound. Self-referenced EVM — each symbol against its own hard decision — against EVM_dB = −(Es/N0)_dB. EVM is an I/Q-plane quantity, so there is no factor of two, and an EVM beating the bound means the measurement is wrong. Median margin over the whole random sweep: +0.1 dB.

Lock time against each trial's own budget. Not an absolute symbol count — a fraction of that trial's 2·(5/bn_t + 5/bn_c), which is the only way to compare trials with different loop bandwidths.

Six more panels exist, on demand

--only ber,falsealarm,level,invariance,chunking,telemetry, or --only all. They are not in the committed figure because each answers a PASS/FAIL question, and a plot is the wrong shape for one: level and sample-rate invariance are flat lines, false alarm is a count of zero, chunking is a bar chart of exact zeros, telemetry is two bars, and ber restates evm on a log axis.

All six are assertions instead, which is where a pass/fail property belongs — it then runs on every commit rather than sitting in a PNG: src/doppler/track/tests/test_mpsk_receiver_performance.py covers false alarm, level invariance and the coherent bound, and the bench_mpsk_receiver*.py pair covers the telemetry cost (+10.5% complex, +11.6% real).

How the SER is measured, since it is easy to get wrong

The bound test stops on a fixed number of errors, not symbols. Under inverse binomial sampling the symbol count is the random variable (negative-binomially distributed) and the relative standard error is 1/√r — dependent only on the error count, so the target error count is the precision. 200 errors gives ~7% relative, ±14% at 95%. A fixed 20 000-symbol burst at SER 1e-3 yields ~20 errors and ~22% relative error, which reads as real seed-to-seed variation in the receiver.

Two consequences a fixed-N measurement misses: the naive r/N is biased (the unbiased estimator is (r−1)/(N−1)), and the interval comes from the negative binomial rather than a binomial on a fixed N.

Also compare like with like: a differential SER is rotation-free, and therefore convenient, but a differential decision fails if either of its two symbols is wrong — so it reads ~2× a coherent SER (measured 1.88–2.11 here). Comparing one against a coherent curve invents a factor of two of "implementation loss". Measured coherently, both receivers sit 1.2–2.4× the bound, i.e. 0.3–1.0 dB, with 8PSK on the real IF the only cell above 2×.

A rotation-blind metric cannot check your measurement window

Self-referenced EVM and the hard-decision phase error both estimate the constellation rotation from the data, so a constellation that is still rotating reads clean on both while decoding to the wrong symbols. Measured on 8PSK: EVM within 0.3 dB of the bound, phase-error mean −0.0002 rad, and not one symbol beyond the ±π/8 decision boundary — beside an SER six times the bound. Two independent truth-free validators agreed with each other and both were blind to it.

The cause was the window, and specifically the handover: with acq_to_track enabled it fires on carrier lock plus a warmup, which is later than the analytic 5/Bn budget and later than every lock indicator, and the decision-directed loop then has its own transient. The handover landed at symbol 2525 against a 2000-symbol budget; measuring from 2000 read 5.95× the bound where the settled answer is 1.68×. Localise a suspected window fault by asking where the errors are — an error rate per block across the record — not by adding another truth-free metric.

Streaming a real capture in

The receivers are streaming objects: state carries across calls, so a capture arrives in whatever blocks your transport hands you.

# Feeding a stream in chunks: the receiver is a streaming object, so `steps()`
# may be called with whatever block the source hands you. It keeps every piece
# of state that spans a call -- the LO phase, the cascade's delay lines, the
# timing accumulator, both loop integrators, and (on the real path) the R2C
# halfband's orphan sample when a chunk has odd length -- so a chunked run is
# BIT-IDENTICAL to one big call. Chunk size is a buffering decision, not a
# signal-processing one.
import numpy as np  # noqa: E402  (region must be self-contained)


def demod_stream(rx, blocks):
    """Feed an iterable of blocks; yield the symbols each call produced.

    The output count per call VARIES -- a chunk of N samples yields about
    N/sps symbols, but which side of the boundary a symbol lands on depends
    on the timing accumulator's phase, so never assume a fixed ratio.
    """
    for block in blocks:
        symbols = rx.steps(block)  # may be empty for a short block
        if len(symbols):
            yield symbols


def chunk_randomly(x, rng, lo=997, hi=9973):
    """Split `x` into chunks of random length -- deliberately including ODD
    lengths, which is what exercises the real path's even/odd halfband pairing
    across a call boundary."""
    i = 0
    while i < x.size:
        n = int(rng.integers(lo, hi))
        yield x[i : i + n]
        i += n

Reproduce

Every panel is independently selectable, so a single question is cheap to ask:

# the two panels above (the default, and what the figure is)
python src/doppler/examples/mpsk_receiver_performance_demo.py

# every panel, including the six pass/fail ones
python src/doppler/examples/mpsk_receiver_performance_demo.py --only all

# one question at a time
python src/doppler/examples/mpsk_receiver_performance_demo.py --only falsealarm

# more draws for a tighter distribution
python src/doppler/examples/mpsk_receiver_performance_demo.py --trials 400

Not run by the examples gate

This script is listed in src/doppler/examples/.examples-skip. Its Monte Carlo asserts the coherent bound, and 8PSK at m_out < 8 genuinely misses it (measured 8.1 dB of loss at m_out = 4, sps = 10.73), so pass/fail depends on the draw. Everything it measures about the shipped defaults is covered by src/doppler/track/tests/, which does run in CI.