Skip to content

Python M-PSK Constellation API

The doppler.mpsk module is the M-ary PSK constellation layer over the C mpsk core: Gray-coded map / demap for BPSK, QPSK, and 8PSK. It is the decision primitive (and its transmit inverse) that the track.Carrier.Mpsk carrier loop and the MPSK receiver compose; the inline mpsk_slice() / mpsk_constellation() helpers in mpsk_core.h are the C composition API those loops inline per symbol.

A symbol carries log2(M) bits packed LSB-first into one uint8 (0..M−1); that byte is the Gray-coded label, so a slip to an adjacent constellation point flips exactly one bit. Constellations are unit amplitude: BPSK {+1, −1}, QPSK (±1 ± j)/√2 (axis-separable, at π/4), 8PSK exp(j·k·π/4). m defaults to QPSK and is keyword-capable.

See the M-PSK gallery page for the constellations and the BER-vs-Eb/N0 validation against theory.

Memoryless map / demap

mpsk_map and mpsk_demap are element-wise (one label byte ↔ one cf32 point), absolute-phase. mpsk_demap is a hard decision (nearest point by phase; amplitude-invariant).

mpsk_map

mpsk_map(
    sym: NDArray[uint8], m: int = 4
) -> NDArray[np.complex64]

Map Gray-coded M-PSK labels to unit-amplitude constellation points.

Element-wise inverse of mpsk_demap(): each input byte is one symbol's log2(M) Gray-coded bits (0..M-1), each output is its cf32 point. Memoryless (absolute phase). out must hold sym_len points.

Parameters:

Name Type Description Default
sym NDArray[uint8]

Gray label bytes (0..M-1), one per symbol.

required
m int

M in {2,4,8}.

4

Returns:

Type Description
NDArray[complex64]

Output.

Examples:

>>> import numpy as np
>>> from doppler.mpsk import mpsk_map, mpsk_demap
>>> sym = np.array([0, 1, 2, 3], dtype=np.uint8)   # QPSK labels
>>> pts = mpsk_map(sym, 4)
>>> np.round(np.abs(pts), 5)
array([1., 1., 1., 1.], dtype=float32)
>>> np.array_equal(mpsk_demap(pts, 4), sym)
True

mpsk_demap

mpsk_demap(
    x: NDArray[complex64], m: int = 4
) -> NDArray[np.uint8]

Hard-decide M-PSK symbols to their Gray-coded label bytes.

Element-wise inverse of mpsk_map(): each cf32 symbol is sliced to the nearest constellation point and its Gray label (0..M-1) is written out. A slip to an adjacent point flips exactly one bit (Gray). out must hold x_len bytes.

Parameters:

Name Type Description Default
x NDArray[complex64]

Received symbols (any amplitude; phase only).

required
m int

M in {2,4,8}.

4

Returns:

Type Description
NDArray[uint8]

Output.

Examples:

>>> import numpy as np
>>> from doppler.mpsk import mpsk_demap
>>> x = np.array([1+0j, 1j, -1+0j, -1j], dtype=np.complex64)  # 8PSK
>>> mpsk_demap(x, 8).tolist()   # Gray labels of indices 0, 2, 4, 6
[0, 3, 6, 5]

Soft demapping

mpsk_soft_demap is the same decision seen differently: instead of one label byte per symbol it writes log2(M) log-likelihood ratios, one per bit, which is what a soft-input decoder (a Viterbi, for a convolutional code) needs — a hard decision throws away most of the coding gain such a decoder exists to deliver.

L = log(P(bit = 0) / P(bit = 1)), so positive means bit 0 and the hard decision is L < 0. That is not a second decision rule: its sign reproduces mpsk_demap's label at every M and every SNR, which is asserted rather than assumed. Bits are LSB-first within a symbol and symbols run in order, so llr[i * log2(M) + b] is bit b of symbol i.

llr is a caller-provided output array rather than a returned one, because the output expands by log2(M) — size it as len(x) * mpsk_bits_per_symbol(m). n0 scales the result exactly and a Viterbi is invariant to it, so a caller with no SNR estimate may pass 1.0.

See Soft Decisions for M-PSK for the derivation, the closed forms BPSK and QPSK turn out to have, and what max-log costs at 8PSK.

mpsk_soft_demap

mpsk_soft_demap(
    x: NDArray[complex64],
    llr: NDArray[float32],
    m: int = 4,
    n0: float = 1.0,
) -> None

Soft-demap M-PSK symbols to per-bit log-likelihood ratios.

The soft counterpart of mpsk_demap(): instead of one label byte per symbol it writes log2(M) LLRs, one per bit, which is what a soft-input decoder (a Viterbi, for the CCSDS inner code) needs. A hard decision throws away roughly 2 dB of the coding gain such a decoder exists to deliver.

The convention, which every consumer has to agree with:

L_i = log( P(bit i = 0 | y) / P(bit i = 1 | y) )

so positive means bit 0 and the hard decision is L < 0. That is not a separate rule: mpsk_demap() is what this reproduces, and the sign agreeing with it at every M and every SNR is asserted in test_mpsk_core.c rather than assumed. The repository has ONE decision rule; this is a second view of it, not a second copy.

Bits are LSB-first within a symbol, matching how the Gray label packs them, and symbols run in order: llr[i * log2(M) + b] is bit b of symbol i.

Computed by the max-log rule over the constellation L_i = (min_{b_i=1} |y-a|^2 - min_{b_i=0} |y-a|^2) / n0. For BPSK and QPSK this is EXACT — QPSK's phi0 = pi/4 grid is axis-separable, so its two bits are independent BPSK decisions and each subset holds one point. Only 8PSK is an approximation; what that costs in dB is not measured yet and is therefore not claimed here (docs/design/mpsk.md §9.7).

n0 is the noise power E[|n|^2] for unit-amplitude symbols, and it scales the output exactly: L(n0) = L(1) / n0. A Viterbi is invariant to it, since scaling every branch metric by a positive constant cannot move the maximum-likelihood path — so a caller with no SNR estimate may pass 1.0 and get correctly ordered, unscaled soft values.

Parameters:

Name Type Description Default
x NDArray[complex64]

Received symbols (amplitude matters here — unlike the hard path, which uses phase only).

required
llr NDArray[float32]

Out: x_len * log2(M) LLRs.

required
m int

M in {2,4,8}.

4
n0 float

Noise power E[|n|^2]; must be positive.

1.0

Examples:

>>> import numpy as np
>>> from doppler.mpsk import mpsk_soft_demap, mpsk_demap
>>> x = np.array([0.9+0.1j, -0.8-0.2j], dtype=np.complex64)   # BPSK
>>> llr = np.empty(2, dtype=np.float32)
>>> mpsk_soft_demap(x, llr, 2, 1.0)
>>> np.round(llr, 3)                       # 4*Re(y)/n0
array([ 3.6, -3.2], dtype=float32)
>>> np.array_equal((llr < 0).astype(np.uint8), mpsk_demap(x, 2))
True

Differential map / demap

The differential variants carry phase state across the array — information rides on phase differences, so an unknown constant carrier rotation cancels at the receiver. This resolves the M-fold phase ambiguity a decision-directed carrier loop leaves, at ~2× the symbol-error rate. Every symbol after the implicit zero-phase reference (the first) is rotation-invariant.

mpsk_diff_map

mpsk_diff_map(
    sym: NDArray[uint8], m: int = 4
) -> NDArray[np.complex64]

Differential M-PSK map: the label selects a phase INCREMENT.

Information rides on phase differences: the running constellation index accumulates gray_decode(label) each symbol (starting from an implicit zero-phase reference), so an unknown constant carrier phase cancels at the receiver (mpsk_diff_demap) — resolving the M-fold ambiguity. Sequential over the array.

The cost is up to 2x the symbol-error rate of coherent map(). That factor is a high-SNR asymptote, not a constant: measured, BPSK and QPSK reach it by ~8 dB Es/N0 while 8PSK pays only 1.44x at 4 dB and 2.03x by 14 dB. A caller sizing a link at low Es/N0 is charged less than the round number suggests (native/validation/mpsk_diff_penalty.c).

Parameters:

Name Type Description Default
sym NDArray[uint8]

Gray label bytes (0..M-1), one per symbol.

required
m int

M in {2,4,8}.

4

Returns:

Type Description
NDArray[complex64]

Output.

Examples:

>>> import numpy as np
>>> from doppler.mpsk import mpsk_diff_map, mpsk_diff_demap
>>> sym = np.array([1, 0, 3, 2, 1], dtype=np.uint8)
>>> pts = mpsk_diff_map(sym, 4)
>>> np.array_equal(mpsk_diff_demap(pts, 4), sym)   # exact round-trip
True
>>> rot = (pts * np.exp(1j * np.pi / 2)).astype(np.complex64)  # slip
>>> np.array_equal(mpsk_diff_demap(rot, 4)[1:], sym[1:])  # invariant
True

mpsk_diff_demap

mpsk_diff_demap(
    x: NDArray[complex64], m: int = 4
) -> NDArray[np.uint8]

Differential M-PSK demap: decide from the phase DIFFERENCE.

Inverse of mpsk_diff_map(): the Gray label of each symbol is decided from the phase difference between consecutive sliced indices (the first references an implicit zero-phase start). Invariant to an unknown constant carrier phase.

Parameters:

Name Type Description Default
x NDArray[complex64]

Received symbols (any amplitude; phase only).

required
m int

M in {2,4,8}.

4

Returns:

Type Description
NDArray[uint8]

Output.

Examples:

>>> import numpy as np
>>> from doppler.mpsk import mpsk_diff_demap, mpsk_diff_map
>>> sym = np.array([2, 2, 1, 0], dtype=np.uint8)
>>> np.array_equal(mpsk_diff_demap(mpsk_diff_map(sym, 8), 8), sym)
True

Helpers

mpsk_bits_per_symbol

mpsk_bits_per_symbol(m: int = 4) -> int

Bits per M-PSK symbol = log2(M).

Parameters:

Name Type Description Default
m int

M in {2,4,8}.

4

Returns:

Type Description
int

1, 2, or 3 (0 for an unsupported M).

Examples:

>>> from doppler.mpsk import mpsk_bits_per_symbol
>>> [mpsk_bits_per_symbol(m) for m in (2, 4, 8)]
[1, 2, 3]

GalleryM-PSK Carrier Loop — Theory Validation, DsssBurstReceiver — the Composed Burst Chain, M-PSK constellation (Gray-coded map / demap) DesignThe FEC Receive Half, MPSK Receiver, The Viterbi Decoder