Digital modulation and bit error rate
Map bits to BPSK, QPSK and 16-QAM symbols with Gray coding, detect them in noise, and measure bit error rate against Eb/N0 by Monte Carlo simulation compared with theory
You will learn
- Implement BPSK, QPSK and Gray-mapped 16-QAM mappers with unit average symbol energy, using the 16QAM rule from 3GPP TS 38.211
- Verify in code that Gray mapping makes every pair of nearest neighbours differ in exactly one bit
- Simulate an AWGN channel at a chosen Eb/N0 and measure bit error rate by Monte Carlo
- Compare simulated BER with the closed-form expressions and read off the Eb/N0 each scheme needs for a BER of 1e-5
Modulation is the step that turns bits into the complex symbols a radio transmits. This lesson builds the three schemes every engineer meets first: BPSK, QPSK and 16-QAM. It shows how to assign bits to constellation points so that the commonest errors cost only one bit, and then measures how often each scheme gets bits wrong in noise. The measurement is a Monte Carlo simulation: send millions of random bits through a noisy channel and count the mistakes. Theory predicts the same curves, and comparing the two is how you know a simulator is right.
The prerequisites are complex baseband from lesson 02 and Eb/N0 from lesson 04.
Symbols and constellations
A digital modulator takes k bits at a time and outputs one of M = 2^k complex symbols. Plotted on the I-Q plane, the set of possible symbols is the constellation diagram.
- BPSK carries 1 bit per symbol. The two points are +1 and −1 on the real axis. The bit decides the sign of the carrier.
- QPSK carries 2 bits per symbol. The four points sit at 45°, 135°, 225° and 315°. One bit sets the sign of I and the other the sign of Q, so QPSK is two BPSK signals on the two quadrature carriers.
- 16-QAM carries 4 bits per symbol. The 16 points form a 4 × 4 grid with levels −3, −1, +1 and +3 on each axis.
For a fair comparison every constellation is scaled to an average symbol energy of 1. QPSK points are therefore divided by √2 and 16-QAM points by √10, since the mean of I² + Q² over the 16-QAM grid is 10.
5G NR defines its mappers in 3GPP TS 38.211 (NR physical channels and modulation). In the Release 19 version, its modulation mapper covers π/2-BPSK, BPSK, QPSK, 16QAM, 64QAM, 256QAM and 1024QAM. Its 16QAM rule uses the first and third bit of each group of four to set I and the second and fourth to set Q, with the same √10 scaling. The code below uses that rule. NR’s BPSK is our BPSK rotated by 45°, which changes nothing about its error rate.
Gray mapping
Which bit pattern goes on which point matters. In noise, the most likely error is to mistake a symbol for one of its nearest neighbours. If those neighbours differ from it in only one bit, a symbol error costs one bit error rather than several. A labelling with this property is a Gray mapping. The TS 38.211 rule has it, as the diagram shows.
The first block builds the three mappers, checks that each has unit average energy, measures the minimum distance between points, and checks the Gray property by brute force over every pair of nearest neighbours.
import itertools
import numpy as np
def map_bpsk(b: np.ndarray) -> np.ndarray:
return (1 - 2 * b).astype(np.complex128)
def map_qpsk(b: np.ndarray) -> np.ndarray:
b = b.reshape(-1, 2)
return ((1 - 2 * b[:, 0]) + 1j * (1 - 2 * b[:, 1])) / np.sqrt(2)
def map_16qam(b: np.ndarray) -> np.ndarray:
"""Same rule as the 16QAM mapper in 3GPP TS 38.211: bits 0 and 2 set I, bits 1 and 3 set Q."""
b = b.reshape(-1, 4)
i = (1 - 2 * b[:, 0]) * (2 - (1 - 2 * b[:, 2]))
q = (1 - 2 * b[:, 1]) * (2 - (1 - 2 * b[:, 3]))
return (i + 1j * q) / np.sqrt(10)
for name, mapper, k in [("BPSK", map_bpsk, 1), ("QPSK", map_qpsk, 2), ("16-QAM", map_16qam, 4)]:
labels = np.array(list(itertools.product([0, 1], repeat=k)))
points = mapper(labels.ravel())
energy = np.mean(np.abs(points) ** 2)
dist = np.abs(points[:, None] - points[None, :])
d_min = dist[dist > 1e-9].min()
# Gray check: every pair of nearest neighbours differs in exactly one bit
worst = 0
for a in range(len(points)):
for c in range(len(points)):
if a != c and abs(dist[a, c] - d_min) < 1e-9:
worst = max(worst, int(np.sum(labels[a] != labels[c])))
print(f"{name:>6}: {len(points):2d} points, mean energy {energy:.3f}, "
f"d_min {d_min:.3f}, most bits changed between neighbours: {worst}")
print("16-QAM labels in the first quadrant:")
for lab in itertools.product([0, 1], repeat=4):
p = map_16qam(np.array(lab)) * np.sqrt(10)
if p.real > 0 and p.imag > 0:
print(f" {''.join(map(str, lab))} -> {p[0].real:+.0f}{p[0].imag:+.0f}j (times 1/sqrt(10))")
BPSK: 2 points, mean energy 1.000, d_min 2.000, most bits changed between neighbours: 1
QPSK: 4 points, mean energy 1.000, d_min 1.414, most bits changed between neighbours: 1
16-QAM: 16 points, mean energy 1.000, d_min 0.632, most bits changed between neighbours: 1
16-QAM labels in the first quadrant:
0000 -> +1+1j (times 1/sqrt(10))
0001 -> +1+3j (times 1/sqrt(10))
0010 -> +3+1j (times 1/sqrt(10))
0011 -> +3+3j (times 1/sqrt(10))
All three constellations have a mean energy of 1.000. No nearest-neighbour pair differs in more than one bit, so all three are Gray mapped. The minimum distance shrinks from 2.000 for BPSK to 1.414 for QPSK and 0.632 for 16-QAM. That shrinking distance is the price of packing more bits into each symbol at the same energy.
Detection in Gaussian noise
The receiver sees r = s + n, where n is complex Gaussian noise with variance N0, half in I and half in Q, as generated in lesson 04. With every symbol equally likely, the decision that minimises symbol errors is to pick the constellation point closest to r. For these square constellations the closest-point decision separates into one decision per axis. For 16-QAM, the sign of I gives b0, and whether |I| exceeds the boundary at 2/√10 gives b2. Q gives b1 and b3 the same way. These boundaries are the dashed lines in the diagram.
To compare schemes that carry different numbers of bits, error rates are plotted against Eb/N0, the energy per bit over the noise density. With unit symbol energy and k bits per symbol, Eb = 1/k. So for a target Eb/N0 the simulation sets N0 = (1/k) / (Eb/N0). The relation to SNR is SNR = (Eb/N0) · k when the noise bandwidth equals the symbol rate.
Theory
For BPSK the error occurs when the noise pushes the real part across zero. The distance to the boundary is 1 and the noise standard deviation per axis is √(N0/2), which gives
Pb(BPSK) = Q(√(2 Eb/N0)) = ½ erfc(√(Eb/N0))
where Q is the Gaussian tail function. Gray-mapped QPSK is two independent BPSK channels, each with half the symbol energy and one bit, so its bit error rate per Eb/N0 is exactly the same.
For 16-QAM, each axis is a 4-level signal. The two outer levels have one neighbouring boundary and the two inner levels have two, so an axis decision errs with probability about 1.5 Q(d/2σ), where d/2 = 1/√10 is half the minimum distance. With Gray mapping each such error flips one of the two bits on that axis. Substituting Eb = 1/4 gives the nearest-neighbour approximation
Pb(16-QAM) ≈ 3/8 · erfc(√(2 Eb / (5 N0)))
It ignores errors that jump two levels, which matter only at very low Eb/N0. scipy.special.erfc evaluates both expressions.
Bit error rate by Monte Carlo
The second block sends 10 million random bits per point through the channel at Eb/N0 from 0 dB to 12 dB, for each scheme, in chunks of 1 million to keep memory use small. It counts bit errors and prints them next to theory. Only points with at least 20 errors go into the chart. With n errors counted, the relative standard deviation of the estimate is about 1/√n, so 20 errors give roughly ±22 % and 100 errors ±10 %.
import time
import numpy as np
from scipy.special import erfc
rng = np.random.default_rng(2026)
N_BITS = 10_000_000 # bits per Eb/N0 point
SCALE_16 = 1 / np.sqrt(10)
def modulate(b: np.ndarray, k: int) -> np.ndarray:
"""Unit-energy symbols: BPSK (k=1), QPSK (k=2), Gray 16-QAM as in TS 38.211 (k=4)."""
if k == 1:
return (1 - 2 * b).astype(np.complex128)
if k == 2:
b = b.reshape(-1, 2)
return ((1 - 2 * b[:, 0]) + 1j * (1 - 2 * b[:, 1])) / np.sqrt(2)
b = b.reshape(-1, 4)
i = (1 - 2 * b[:, 0]) * (2 - (1 - 2 * b[:, 2]))
q = (1 - 2 * b[:, 1]) * (2 - (1 - 2 * b[:, 3]))
return (i + 1j * q) * SCALE_16
def demodulate(r: np.ndarray, k: int) -> np.ndarray:
"""Minimum-distance decisions, done per axis because the constellations are square."""
if k == 1:
return (r.real < 0).astype(np.int8)
if k == 2:
return np.stack([r.real < 0, r.imag < 0], axis=1).astype(np.int8).ravel()
thr = 2 * SCALE_16 # boundary between the inner and outer levels
return np.stack([r.real < 0, r.imag < 0, np.abs(r.real) > thr, np.abs(r.imag) > thr],
axis=1).astype(np.int8).ravel()
def simulate_ber(k: int, ebn0_db: float, chunk: int = 1_000_000) -> tuple[float, int]:
n0 = (1 / k) / 10 ** (ebn0_db / 10) # Es = 1, so Eb = 1/k
errors = 0
for _ in range(N_BITS // chunk): # chunks keep memory use small
bits = rng.integers(0, 2, chunk, dtype=np.int8)
s = modulate(bits, k)
noise = np.sqrt(n0 / 2) * (rng.standard_normal(s.size) + 1j * rng.standard_normal(s.size))
errors += int(np.count_nonzero(demodulate(s + noise, k) != bits))
return errors / N_BITS, errors
def theory(k: int, ebn0_db: float) -> float:
g = 10 ** (ebn0_db / 10)
if k in (1, 2):
return 0.5 * erfc(np.sqrt(g)) # exact for BPSK and Gray QPSK
return 3 / 8 * erfc(np.sqrt(2 * g / 5)) # nearest-neighbour approximation
start = time.time()
results = {}
print(" Eb/N0 | BPSK sim theory | QPSK sim | 16-QAM sim approx")
for e in range(0, 13):
row = [simulate_ber(k, e) for k in (1, 2, 4)]
results[e] = row
print(f"{e:5d} | {row[0][0]:9.2e} {theory(1, e):9.2e} | {row[1][0]:9.2e} "
f"| {row[2][0]:9.2e} {theory(4, e):9.2e}")
def series(k_index: int, k: int, sim: bool) -> str:
pts = []
for e, row in results.items():
ber, errs = row[k_index]
if sim and errs >= 20: # only points with enough errors to trust
pts.append(f"[{e}, {ber:.3g}]")
if not sim and theory(k, e) > 1e-7:
pts.append(f"[{e}, {theory(k, e):.3g}]")
return ", ".join(pts)
print("BPSK sim:", series(0, 1, True))
print("QPSK sim:", series(1, 2, True))
print("16-QAM sim:", series(2, 4, True))
print("BPSK theory:", series(0, 1, False))
print("16-QAM theory:", series(2, 4, False))
print(f"Eb/N0 for BER 1e-5 (theory): BPSK "
f"{min(e / 10 for e in range(0, 200) if theory(1, e / 10) < 1e-5):.1f} dB, 16-QAM "
f"{min(e / 10 for e in range(0, 200) if theory(4, e / 10) < 1e-5):.1f} dB")
print(f"run time {time.time() - start:.0f} s")
Eb/N0 | BPSK sim theory | QPSK sim | 16-QAM sim approx
0 | 7.87e-02 7.86e-02 | 7.86e-02 | 1.41e-01 1.39e-01
1 | 5.62e-02 5.63e-02 | 5.63e-02 | 1.19e-01 1.18e-01
2 | 3.75e-02 3.75e-02 | 3.75e-02 | 9.77e-02 9.76e-02
3 | 2.29e-02 2.29e-02 | 2.29e-02 | 7.75e-02 7.74e-02
4 | 1.25e-02 1.25e-02 | 1.24e-02 | 5.86e-02 5.86e-02
5 | 5.96e-03 5.95e-03 | 5.99e-03 | 4.20e-02 4.19e-02
6 | 2.39e-03 2.39e-03 | 2.39e-03 | 2.79e-02 2.79e-02
7 | 7.81e-04 7.73e-04 | 7.77e-04 | 1.69e-02 1.70e-02
8 | 1.99e-04 1.91e-04 | 1.92e-04 | 9.25e-03 9.25e-03
9 | 3.16e-05 3.36e-05 | 3.55e-05 | 4.41e-03 4.39e-03
10 | 3.90e-06 3.87e-06 | 4.40e-06 | 1.75e-03 1.75e-03
11 | 3.00e-07 2.61e-07 | 5.00e-07 | 5.58e-04 5.65e-04
12 | 0.00e+00 9.01e-09 | 0.00e+00 | 1.36e-04 1.39e-04
BPSK sim: [0, 0.0787], [1, 0.0562], [2, 0.0375], [3, 0.0229], [4, 0.0125], [5, 0.00596], [6, 0.00239], [7, 0.000781], [8, 0.000199], [9, 3.16e-05], [10, 3.9e-06]
QPSK sim: [0, 0.0786], [1, 0.0563], [2, 0.0375], [3, 0.0229], [4, 0.0124], [5, 0.00599], [6, 0.00239], [7, 0.000777], [8, 0.000192], [9, 3.55e-05], [10, 4.4e-06]
16-QAM sim: [0, 0.141], [1, 0.119], [2, 0.0977], [3, 0.0775], [4, 0.0586], [5, 0.042], [6, 0.0279], [7, 0.0169], [8, 0.00925], [9, 0.00441], [10, 0.00175], [11, 0.000558], [12, 0.000136]
BPSK theory: [0, 0.0786], [1, 0.0563], [2, 0.0375], [3, 0.0229], [4, 0.0125], [5, 0.00595], [6, 0.00239], [7, 0.000773], [8, 0.000191], [9, 3.36e-05], [10, 3.87e-06], [11, 2.61e-07]
16-QAM theory: [0, 0.139], [1, 0.118], [2, 0.0976], [3, 0.0774], [4, 0.0586], [5, 0.0419], [6, 0.0279], [7, 0.017], [8, 0.00925], [9, 0.00439], [10, 0.00175], [11, 0.000565], [12, 0.000139]
Eb/N0 for BER 1e-5 (theory): BPSK 9.6 dB, 16-QAM 13.5 dB
run time 4 s
Simulation and theory agree. For BPSK at 6 dB the simulation gives 2.39e-03 against a theoretical 2.39e-03. QPSK tracks BPSK point for point, as predicted. The 16-QAM approximation sits slightly below the simulation at 0 dB (1.39e-01 against 1.41e-01) because it ignores double-level errors, and the two agree to within the Monte Carlo scatter from about 2 dB upwards. At the high-Eb/N0 end the counts become small: BPSK at 11 dB rests on 3 errors, which is why the chart leaves it out.
Reading the curves
Three lessons sit in this chart.
QPSK is free. It doubles the bits per symbol of BPSK at the same Eb/N0 and the same bit error rate. It does so by using the quadrature carrier that BPSK leaves empty.
16-QAM costs energy. By theory, BPSK and QPSK need 9.6 dB for a BER of 1e-5 and 16-QAM needs 13.5 dB, 3.9 dB more. In exchange it carries 4 bits per symbol, twice as many as QPSK in the same bandwidth. Higher orders continue the trade: each step from 16-QAM to 64-QAM to 256-QAM packs more bits per symbol and needs more SNR. Adaptive modulation picks the highest order the current SNR supports. The modulation and BER cheat sheet is the quick reference for these expressions.
Uncoded modulation is far from the limit. Lesson 04 showed that reliable transmission is possible down to an Eb/N0 of −1.59 dB. Uncoded BPSK needs 9.6 dB for a BER of 1e-5, roughly 11 dB more. Channel coding closes most of that gap, which is why practical systems pair these modulations with strong codes. The channel coding hub picks up from here.
Real modems also need pulse shaping, which lesson 06 introduces, and the QPSK modem project takes the simulation further. The bit error rate curves here are the yardstick that every later receiver block is measured against.
Exercise
- Replace the 16-QAM Gray mapping with natural binary labels: levels −3, −1, +1, +3 from the bit pairs 00, 01, 10, 11 on each axis. Rerun the Gray check and the BER simulation. How much worse is the BER at 10 dB Eb/N0?
- Add 8-PSK (3 bits per symbol on a circle) with a Gray mapping. Where does its curve fall between QPSK and 16-QAM?
- Rotate every received QPSK symbol by a fixed phase error of 10° before detection. How many dB of Eb/N0 does the error cost at a BER of 1e-3?
- Compute the symbol error rate for 16-QAM as well as the bit error rate. What is their ratio at high Eb/N0, and why is it close to 4?
- Change
N_BITSto 100 000 and rerun. At which Eb/N0 does each simulated curve stop being trustworthy?
Summary
- A modulator maps k bits to one of 2^k complex symbols. BPSK, QPSK and 16-QAM carry 1, 2 and 4 bits per symbol and, at unit energy, have minimum distances of 2.000, 1.414 and 0.632.
- Gray mapping gives nearest neighbours labels that differ in one bit, so the commonest symbol error costs one bit. The 16QAM rule in 3GPP TS 38.211 is Gray mapped.
- In AWGN, Pb = ½ erfc(√(Eb/N0)) for BPSK and Gray QPSK, and about 3/8 erfc(√(2Eb/5N0)) for Gray 16-QAM. Monte Carlo simulation with 10 million bits per point matches both.
- For a BER of 1e-5, BPSK and QPSK need 9.6 dB of Eb/N0 and 16-QAM 13.5 dB. Uncoded BPSK sits about 11 dB from the Shannon limit, which coding recovers.
Further reading
- 3GPP TS 38.211, NR physical channels and modulation: the modulation mapper section defines every NR constellation from π/2-BPSK to 1024QAM.
- J. G. Proakis and M. Salehi, Digital Communications, 5th edition, McGraw-Hill, 2008: chapter 3, “Digital Modulation Schemes”, and chapter 4, “Optimum Receivers for AWGN Channels”, cover these constellations and their error probabilities.
- A. Goldsmith, Wireless Communications, Cambridge University Press, 2005: chapter 5, “Digital Modulation and Detection”, and chapter 6, “Performance of Digital Modulation over Wireless Channels”, take the same analysis into fading channels.