Cheat sheet

Modulation and bit error rate cheat sheet

Bits per symbol, AWGN and Rayleigh BER formulas for BPSK, QPSK and square QAM, the Eb/N0 each needs for a target BER, and a simulation that checks the formulas.

Last verified 15 Nov 2026· NumPy 2.5, SciPy 1.18

This sheet is for anyone sizing a link or checking a modem against theory. It gives the uncoded bit error rate (BER) formulas you will use most, the Eb/N0 they need, and a short simulation that confirms them. The background is in the digital modulation lesson and the fading channels lesson.

Notation

Symbol Meaning
M Constellation size
k = log2 M Bits per symbol
Es/N0 Symbol energy over noise density. Equal to SNR when the noise bandwidth equals the symbol rate
Eb/N0 Bit energy over noise density, Es/N0 = k Eb/N0, or in dB, Es/N0 = Eb/N0 + 10 log10 k
Q(x) Gaussian tail probability, Q(x) = 0.5 erfc(x / √2)

The schemes

Scheme Bits per symbol Where you meet it
BPSK 1 Low-rate links that need the most margin per bit
π/2-BPSK 1 NR PUSCH with transform precoding enabled (TS 38.211)
QPSK 2 The lowest-rate entries of the NR CQI tables in TS 38.214, many satellite links
16-QAM 4 Mid-range LTE, NR and Wi-Fi rates
64-QAM 6 Good channels
256-QAM 8 Short, clean links. In the NR CQI Table 2 of TS 38.214
1024-QAM 10 NR FR1 downlink from Release 17, per the TS 38.214 change history

3GPP TS 38.211 (NR physical channels and modulation) defines the bit-to-symbol mapping for π/2-BPSK, BPSK, QPSK and 16, 64, 256 and 1024 QAM, each scaled to unit average energy. See the 3GPP spec map for where it sits.

BER over AWGN

Gray coding is assumed throughout, so a symbol error to a neighbour costs one bit.

Scheme BER in terms of Eb/N0 Status
BPSK Q(√(2 Eb/N0)) Exact
QPSK Q(√(2 Eb/N0)) Exact. I and Q are two independent BPSK channels
Square M-QAM (4/k)(1 − 1/√M) Q(√(3k Eb/N0 / (M − 1))) Nearest-neighbour approximation. Exact for M = 4, tight below BER 10⁻²
16-QAM (3/4) Q(√(4 Eb/N0 / 5)) From the line above
64-QAM (7/12) Q(√(2 Eb/N0 / 7)) From the line above

Tse and Viswanath give the BPSK and QPSK forms in chapter 3 of Fundamentals of Wireless Communication. The M-QAM line follows from treating each axis as √M-level PAM with Gray labels. The simulation below agrees with it to within Monte Carlo noise.

Eb/N0 needed over AWGN

Solved from the formulas above by the code at the end of this page.

Scheme BER 10⁻³ BER 10⁻⁵ BER 10⁻⁶
BPSK and QPSK 6.8 dB 9.6 dB 10.5 dB
16-QAM 10.5 dB 13.4 dB 14.4 dB
64-QAM 14.8 dB 17.8 dB 18.8 dB
256-QAM 19.4 dB 22.5 dB 23.5 dB

Each step from 16 to 64 to 256 points costs about 4 to 5 dB in Eb/N0 and gives 2 more bits per symbol. In Es/N0 (add 10 log10 k), 256-QAM at 10⁻⁶ needs about 32.5 dB, which few real transmitters reach because of phase noise and PA distortion.

BER over Rayleigh fading

With coherent detection and perfect channel knowledge, and SNR the average received SNR per symbol, Tse and Viswanath (eqs. 3.19 and 3.21) give for BPSK:

BPSK, Rayleigh:   p_e = 0.5 (1 - sqrt(SNR / (1 + SNR)))   ~ 1 / (4 SNR) at high SNR

Their Table 3.1 lists the high-SNR BER for other schemes:

Scheme High-SNR BER, Rayleigh Bits per symbol
Coherent BPSK 1/(4 SNR) 1
Coherent QPSK 1/(2 SNR) 2
Coherent 16-QAM 5/(2 SNR) 4
Differential BPSK 1/(2 SNR) 1
Differential QPSK 1/SNR 2

The error rate now falls only tenfold per 10 dB. At BER 10⁻³, Tse and Viswanath note a 17 dB gap between BPSK over AWGN and BPSK over Rayleigh fading. Diversity and coding close that gap, not extra power. With L independent branches the error rate falls as SNR to the power −L.

1.00e-81.00e-70.0000010.000010.00010.0010.010.105101520BPSK and QPSK, AWGN16-QAM, AWGN64-QAM, AWGNBPSK, Rayleighbit error rateEb/N0 (dB)
Uncoded bit error rate against Eb/N0 for Gray-coded BPSK and QPSK, 16-QAM and 64-QAM over AWGN, and coherent BPSK over Rayleigh fading. Values come from the formulas on this page, printed by the second code block.

Code: check the formulas by simulation

The block simulates Gray-coded square QAM over AWGN and BPSK over Rayleigh fading, with complex128 baseband samples at one sample per symbol. It then solves for the Eb/N0 each scheme needs. It runs in well under a second.

import numpy as np
from scipy.special import erfc
from scipy.optimize import brentq

def qfunc(x):
    return 0.5 * erfc(x / np.sqrt(2))

def ber_mqam_theory(ebn0_db: float, m: int) -> float:
    """Gray-coded square M-QAM, nearest-neighbour approximation (exact for M = 4)."""
    k = np.log2(m)
    ebn0 = 10 ** (ebn0_db / 10)
    return (4 / k) * (1 - 1 / np.sqrt(m)) * qfunc(np.sqrt(3 * k * ebn0 / (m - 1)))

def gray_pam_levels(bits_per_axis: int) -> tuple[np.ndarray, np.ndarray]:
    """Return PAM amplitudes and the Gray label of each amplitude index."""
    n = 2 ** bits_per_axis
    levels = np.arange(-(n - 1), n, 2, dtype=float)
    gray = np.arange(n) ^ (np.arange(n) >> 1)
    return levels, gray

def simulate_mqam(ebn0_db: float, m: int, n_sym: int, rng) -> float:
    k = int(np.log2(m))
    kb = k // 2
    levels, gray = gray_pam_levels(kb)
    idx_i = rng.integers(0, 2 ** kb, n_sym)
    idx_q = rng.integers(0, 2 ** kb, n_sym)
    s = levels[idx_i] + 1j * levels[idx_q]
    es = np.mean(np.abs(levels[:, None] + 1j * levels[None, :]) ** 2)
    n0 = es / (k * 10 ** (ebn0_db / 10))
    r = s + np.sqrt(n0 / 2) * (rng.standard_normal(n_sym) + 1j * rng.standard_normal(n_sym))
    def slice_axis(x):
        return np.clip(np.round((x + (2 ** kb - 1)) / 2), 0, 2 ** kb - 1).astype(int)
    det_i, det_q = slice_axis(r.real), slice_axis(r.imag)
    errs = 0
    for tx, rx in [(idx_i, det_i), (idx_q, det_q)]:
        diff = gray[tx] ^ gray[rx]
        errs += sum(np.count_nonzero(diff & (1 << b)) for b in range(kb))
    return errs / (n_sym * k)

rng = np.random.default_rng(1)
n_sym = 400_000
print("Eb/N0  M   theory      simulated")
for m, ebn0_db in [(4, 6), (16, 10), (64, 14)]:
    print(f"{ebn0_db:4d} dB {m:3d}  {ber_mqam_theory(ebn0_db, m):.3e}   {simulate_mqam(ebn0_db, m, n_sym, rng):.3e}")

# BPSK over Rayleigh fading with coherent detection (Tse and Viswanath eq. 3.19)
snr = 10 ** (20 / 10)
h = (rng.standard_normal(n_sym) + 1j * rng.standard_normal(n_sym)) / np.sqrt(2)
b = rng.integers(0, 2, n_sym)
x = 2.0 * b - 1
y = h * x + np.sqrt(1 / (2 * snr)) * (rng.standard_normal(n_sym) + 1j * rng.standard_normal(n_sym))
ber_sim = np.mean(((np.conj(h) * y).real > 0) != (b == 1))
ber_th = 0.5 * (1 - np.sqrt(snr / (1 + snr)))
print(f"Rayleigh BPSK at 20 dB: theory {ber_th:.3e}, simulated {ber_sim:.3e}, 1/(4 SNR) = {1/(4*snr):.3e}")

# Eb/N0 needed for a target BER over AWGN
print("Required Eb/N0 (dB) for BER 1e-3, 1e-5, 1e-6")
for m in [4, 16, 64, 256]:
    req = [brentq(lambda e: np.log10(ber_mqam_theory(e, m)) - np.log10(t), -5, 30) for t in (1e-3, 1e-5, 1e-6)]
    print(f"M = {m:3d}: " + ", ".join(f"{v:.1f}" for v in req))

Expected output:

Eb/N0  M   theory      simulated
   6 dB   4  2.388e-03   2.376e-03
  10 dB  16  1.754e-03   1.752e-03
  14 dB  64  2.154e-03   2.165e-03
Rayleigh BPSK at 20 dB: theory 2.481e-03, simulated 2.480e-03, 1/(4 SNR) = 2.500e-03
Required Eb/N0 (dB) for BER 1e-3, 1e-5, 1e-6
M =   4: 6.8, 9.6, 10.5
M =  16: 10.5, 13.4, 14.4
M =  64: 14.8, 17.8, 18.8
M = 256: 19.4, 22.5, 23.5

The chart values come from this block:

import numpy as np
from scipy.special import erfc

def qfunc(x):
    return 0.5 * erfc(x / np.sqrt(2))

ebn0_db = np.arange(0, 22, 2)
g = 10 ** (ebn0_db / 10)
curves = {
    "BPSK and QPSK, AWGN": qfunc(np.sqrt(2 * g)),
    "16-QAM, AWGN": 0.75 * qfunc(np.sqrt(4 * g / 5)),
    "64-QAM, AWGN": (7 / 12) * qfunc(np.sqrt(18 * g / 63)),
    "BPSK, Rayleigh": 0.5 * (1 - np.sqrt(g / (1 + g))),
}
for name, ber in curves.items():
    pts = [[int(e), float(f"{b:.3g}")] for e, b in zip(ebn0_db, ber) if b > 1e-9]
    print(name, pts)

Pitfalls

  • Mixing Es/N0, Eb/N0 and SNR. State which one every axis uses. A 64-QAM curve plotted against Es/N0 sits 7.8 dB to the right of the same curve against Eb/N0.
  • Measuring SNR in a bandwidth wider than the signal. Noise measured through a wide channel filter is not the noise the slicer sees after the matched filter.
  • Natural (binary) labelling instead of Gray. Neighbouring errors then cost more than one bit.
  • Comparing an uncoded curve with a coded system’s specification. LDPC or polar coding moves the waterfall by several dB.
  • Simulating too few bits. To trust a BER of 10⁻⁵, count at least 100 errors, which needs at least 10⁷ bits.

Sources