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. No grid of "reasonable" cases drew it.

That case has since been traced and fixed — the R2C halfband returned half the amplitude the analytic-signal convention requires, so the real path ran 6 dB down and the timing loop 4× under-driven, and the −4 dB was the under-drive rather than the placement. It now measures −20 dB with zero SER, and the placement tolerance is documented as the geometric rule it always was (1/sps < fc < 0.5 − 1/sps; see the design note). The draw is what found it, and that is the part worth keeping.

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.2 dB.

Some trials carry no measurement, and the demo says so

A trial whose timing rate is still slewing at the end of its record has no window in which one detected alignment is valid — the receiver is emitting at slightly the wrong cadence, so the rx-to-truth lag moves inside the window. BerMeter scores a detected lag verbatim, so such a window reads correct at its start and chance after it: measured, lag 18 becoming 19, and an SER of 0.483 on a receiver whose settled SER over the same capture is 0.

That is not a lock failure and must not be scored as one. Those trials are refused and counted in the output rather than dropped, because a refusal nobody counts is indistinguishable from a measurement nobody made. The underlying cause — 5/Bn is a phase-settling budget and does not cover slewing out a clock offset, measured 12.7× short — is doppler#1060.

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 the receiver carried at the time: with acq_to_track enabled it fired on carrier lock plus a warmup, later than the analytic 5/Bn budget and later than every lock indicator, and the decision-directed loop then had its own transient. It landed at symbol 2525 against a 2000-symbol budget; measuring from 2000 read 5.95× the bound where the settled answer is 1.68×. The handover is gone (#877) and this particular trap with it, but the lesson is not about handovers: 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.