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
¶
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:
mpsk_demap
¶
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:
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 |
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
¶
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
¶
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:
Helpers¶
mpsk_bits_per_symbol
¶
Related pages¶
Gallery — M-PSK Carrier Loop — Theory Validation, DsssBurstReceiver — the Composed Burst Chain, M-PSK constellation (Gray-coded map / demap) Design — The FEC Receive Half, MPSK Receiver, The Viterbi Decoder