OFDM from scratch
Build a complete OFDM link in numpy, with IFFT modulation, a cyclic prefix, a multipath channel, pilot-based channel estimation and one-tap equalisation, and measure its bit error rate against theory
You will learn
- Explain why an IFFT at the transmitter and an FFT at the receiver turn a multipath channel into one complex gain per subcarrier
- Implement an OFDM transmitter and receiver in numpy with a cyclic prefix, least-squares channel estimation and a one-tap equaliser
- Measure the BER of the link with and without a cyclic prefix and explain the error floor that appears without it
- Compute the 5G NR subcarrier spacing and normal cyclic-prefix duration for each numerology from the TS 38.211 formulas
The previous lesson ended with a problem. A wideband signal on a multipath channel sees a frequency response that varies by 20 dB or more across its band, which in time is intersymbol interference. A single-carrier receiver needs an equaliser with many taps to undo it. Orthogonal frequency-division multiplexing (OFDM) takes a different route. It splits the band into many narrow subcarriers, each so narrow that it sees flat fading, and it makes the split with one FFT. The LTE downlink (3GPP TS 36.211) and 5G NR (TS 38.211) both use it. This lesson builds a complete OFDM link in about 60 lines of numpy, so you can see each step and break it on purpose.
The idea in one equation
Take N complex symbols X0 to X(N−1), one per subcarrier. The transmitter computes their inverse DFT and sends the N time samples one after another. Each symbol now modulates a complex exponential at frequency k/N cycles per sample, and over N samples those exponentials are orthogonal. The receiver takes N samples and computes the DFT. Without a channel it gets the symbols back exactly.
Now add a multipath channel with impulse response h of length L. The received samples are the linear convolution of the transmitted block with h. The DFT turns circular convolution into multiplication, but a linear convolution is not circular, and the tail of each block spills into the next. The trick is the cyclic prefix (CP). Before sending each block, copy its last Ncp samples to its front. If Ncp is at least L − 1, the receiver can drop the first Ncp samples of each received block and what remains equals the circular convolution of the original block with h. After the FFT, subcarrier k carries
Y_k = H_k X_k + W_k
where H_k is the channel frequency response at subcarrier k and W_k is noise. A channel that smeared symbols across many samples has become N independent flat channels. The receiver divides by H_k on each one. That is the one-tap equaliser, and it is the whole reason OFDM took over broadband radio.
The idea of using the DFT to build a frequency-division multiplex dates back at least to Weinstein and Ebert’s 1971 paper, listed under Further reading.
The link in numpy
The parameters are deliberately small. The FFT has 64 points and every subcarrier carries a QPSK symbol. The sample rate is 1 MHz, so the subcarrier spacing is 1 MHz / 64 = 15.625 kHz and each OFDM symbol lasts 64 µs before the prefix. The cyclic prefix is 16 samples, or 16 µs. The channel has a direct path and echoes at 3, 8 and 12 µs, so it spans 13 samples and fits inside the prefix.
Each frame starts with 4 known training symbols. The receiver estimates H_k on each subcarrier by dividing what it received by what it knows was sent, and averages the 4 estimates. This is the least-squares estimate, the simplest form of channel estimation. Then 200 data symbols follow. The block runs 40 frames per Eb/N0 point, which is 1 024 000 bits, for four receivers: CP with the true channel, CP with the estimated channel, no CP with the true channel, and the theoretical BER.
Eb includes the energy spent on the prefix, because the transmitter pays for it. The theory curve is the QPSK error rate averaged over the 64 subcarriers, each with its own gain |H_k|², reduced by the factor 64/80 for the prefix energy that the receiver discards.
import numpy as np
from scipy.special import erfc
rng = np.random.default_rng(8)
n_sc = 64 # subcarriers (FFT size)
n_cp = 16 # cyclic prefix, samples
n_sym = 200 # data OFDM symbols per frame
n_frames = 40 # frames per Eb/N0 point (1 024 000 bits)
n_train = 4 # training symbols for the channel estimate
fs = 1.0e6 # sample rate 1 MHz, so subcarrier spacing = fs / n_sc = 15.625 kHz
# A fixed multipath channel (complex128): echoes at 3, 8 and 12 samples (3, 8 and 12 us), unit total power.
h = np.zeros(13, dtype=complex)
h[[0, 3, 8, 12]] = [1.0, 0.45 * np.exp(0.8j), 0.25 * np.exp(-1.2j), 0.15j]
h /= np.linalg.norm(h)
H = np.fft.fft(h, n_sc) # true frequency response on each subcarrier
def qpsk(bits: np.ndarray) -> np.ndarray:
"""Gray-mapped QPSK with unit average energy."""
b = bits.reshape(-1, 2)
return ((1 - 2 * b[:, 0]) + 1j * (1 - 2 * b[:, 1])) / np.sqrt(2)
def ofdm_frame(ebn0_db: float, cp: int, estimate: bool) -> float:
"""One frame: n_train training symbols then n_sym data symbols. Returns the bit error rate."""
bits = rng.integers(0, 2, 2 * n_sc * n_sym)
X = qpsk(bits).reshape(n_sym, n_sc) # one row per OFDM symbol
x = np.fft.ifft(X, axis=1) * np.sqrt(n_sc) # IFFT: unit power per sample
x = np.hstack([x[:, n_sc - cp:], x]).ravel() # prepend the cyclic prefix, serialise
pilots = qpsk(rng.integers(0, 2, 2 * n_sc)) # known training symbol, sent n_train times
p = np.fft.ifft(pilots) * np.sqrt(n_sc)
tx = np.concatenate([np.tile(np.concatenate([p[n_sc - cp:], p]), n_train), x])
y = np.convolve(tx, h)[: tx.size] # multipath channel
eb = (n_sc + cp) / (2 * n_sc) # energy per bit, CP included
n0 = eb / 10 ** (ebn0_db / 10)
y = y + np.sqrt(n0 / 2) * (rng.normal(size=y.size) + 1j * rng.normal(size=y.size))
blocks = y.reshape(n_train + n_sym, n_sc + cp)[:, cp:] # drop the CP of every symbol
Y = np.fft.fft(blocks, axis=1) / np.sqrt(n_sc) # FFT back to subcarriers
H_hat = (Y[:n_train] / pilots).mean(axis=0) if estimate else H # least-squares estimate, averaged
X_hat = Y[n_train:] / H_hat # one-tap equaliser per subcarrier
bits_hat = np.empty(bits.size, dtype=int)
bits_hat[0::2] = (X_hat.real < 0).ravel()
bits_hat[1::2] = (X_hat.imag < 0).ravel()
return float(np.mean(bits_hat != bits))
def ofdm_link(ebn0_db: float, cp: int, estimate: bool) -> float:
return float(np.mean([ofdm_frame(ebn0_db, cp, estimate) for _ in range(n_frames)]))
def ber_theory(ebn0_db: float, cp: int) -> float:
g = 10 ** (ebn0_db / 10) * n_sc / (n_sc + cp) # CP energy is not used for detection
return float(np.mean(0.5 * erfc(np.sqrt(g * np.abs(H) ** 2))))
g_db = 20 * np.log10(np.abs(H))
print(f"subcarrier spacing {fs / n_sc / 1e3:.3f} kHz, CP {n_cp / fs * 1e6:.0f} us, channel spans {h.size} samples ({h.size / fs * 1e6:.0f} us)")
print(f"channel gain across subcarriers: {g_db.min():.1f} dB to {g_db.max():.1f} dB")
print("Eb/N0 dB, CP known H, CP pilot LS, no CP known H, theory CP, AWGN")
for e in range(0, 21, 2):
awgn = 0.5 * erfc(np.sqrt(10 ** (e / 10)))
print(f"{e:2d}, {ofdm_link(e, n_cp, False):.2e}, {ofdm_link(e, n_cp, True):.2e}, "
f"{ofdm_link(e, 0, False):.2e}, {ber_theory(e, n_cp):.2e}, {awgn:.2e}")
subcarrier spacing 15.625 kHz, CP 16 us, channel spans 13 samples (13 us)
channel gain across subcarriers: -12.6 dB to 4.0 dB
Eb/N0 dB, CP known H, CP pilot LS, no CP known H, theory CP, AWGN
0, 1.34e-01, 1.63e-01, 1.18e-01, 1.34e-01, 7.86e-02
2, 9.08e-02, 1.12e-01, 7.94e-02, 9.07e-02, 3.75e-02
4, 5.65e-02, 7.08e-02, 5.05e-02, 5.64e-02, 1.25e-02
6, 3.25e-02, 4.16e-02, 3.04e-02, 3.25e-02, 2.39e-03
8, 1.74e-02, 2.29e-02, 1.94e-02, 1.75e-02, 1.91e-04
10, 8.97e-03, 1.17e-02, 1.28e-02, 8.86e-03, 3.87e-06
12, 4.25e-03, 5.52e-03, 9.32e-03, 4.19e-03, 9.01e-09
14, 1.80e-03, 2.35e-03, 7.34e-03, 1.78e-03, 6.81e-13
16, 6.42e-04, 8.89e-04, 6.10e-03, 6.26e-04, 2.27e-19
18, 1.39e-04, 2.43e-04, 5.30e-03, 1.61e-04, 1.40e-29
20, 2.64e-05, 4.69e-05, 4.89e-03, 2.47e-05, 1.04e-45
Reading the results
The simulated BER with the true channel follows the theory curve closely: 8.97 × 10⁻³ against 8.86 × 10⁻³ at 10 dB, and 2.64 × 10⁻⁵ against 2.47 × 10⁻⁵ at 20 dB. The one-tap equaliser is doing exactly what the equation promised. Every subcarrier behaves like a flat channel with its own SNR.
The curves show three effects.
Frequency selectivity costs SNR. The channel gain ranges from −12.6 dB to +4.0 dB across the 64 subcarriers. The worst subcarriers dominate the error count, so the OFDM link needs far more Eb/N0 than a flat AWGN channel for the same BER. At 10 dB it gets 8.97 × 10⁻³, where flat QPSK would get 3.87 × 10⁻⁶. Real systems recover most of this with channel coding across subcarriers, so that bits on faded subcarriers are rebuilt from bits on strong ones. Without coding, OFDM on a frequency-selective channel behaves much like the faded channels of the previous lesson.
Without the prefix there is an error floor. At low SNR the no-CP receiver is slightly better, because it does not spend 20 % of its energy on the prefix. That is a 10·log10(80/64) = 0.97 dB saving. Above about 8 dB the picture flips. The tail of each symbol leaks into the next (intersymbol interference), and the convolution is no longer circular, so subcarriers leak into each other (inter-carrier interference). Neither scales down with noise. The BER flattens near 5 × 10⁻³ and stays there: 4.89 × 10⁻³ at 20 dB, against 2.64 × 10⁻⁵ with the prefix.
Channel estimation costs a little. The least-squares estimate carries noise of its own. Averaging 4 training symbols makes its noise variance a quarter of the data noise, which costs about 1 dB. At 10 dB the estimated-channel receiver gets 1.17 × 10⁻² against 8.97 × 10⁻³ with the true channel. Real systems scatter pilots across time and frequency and smooth the estimates between them, as covered in the OFDM channel estimation project.
Choosing the numbers
The design rules follow from lesson 7. The prefix must be longer than the channel’s delay span, not only its RMS delay spread: an exponential profile with 300 ns RMS spread still has noticeable energy beyond 1 µs. The subcarrier spacing must be well below the coherence bandwidth so each subcarrier sees flat fading. And the OFDM symbol must be short compared with the coherence time, or the channel changes during the symbol and the subcarriers stop being orthogonal. A Doppler shift that is a noticeable fraction of the subcarrier spacing causes inter-carrier interference, which is why wide subcarrier spacings suit fast-moving users and high carrier frequencies.
The prefix is pure overhead. A longer one tolerates more delay spread but wastes more time and energy. With 16 samples on 64 our toy link spends 20 %. Real systems use a smaller fraction and more subcarriers.
5G NR numerology
5G NR makes the trade-off adjustable. 3GPP TS 38.211 (NR physical channels and modulation) defines a set of numerologies indexed by μ, with subcarrier spacing 15 × 2^μ kHz. In the Release 19 version of the specification (V19.5.0), Table 4.2-1 lists μ from 0 to 6, that is 15, 30, 60, 120, 240, 480 and 960 kHz. All use the normal cyclic prefix, and μ = 2 (60 kHz) can also use an extended prefix. With the normal prefix a slot holds 14 OFDM symbols, and a 1 ms subframe holds 2^μ slots.
The specification gives symbol and prefix lengths in units of a basic time unit Tc = 1/(480 000 × 4096) s, with κ = 64. The useful symbol is 2048κ × 2^−μ Tc and the normal prefix is 144κ × 2^−μ Tc, with 16κ Tc added to the first symbol in every half-subframe (0.5 ms). The block evaluates those formulas.
# NR normal-CP numerology from the formulas in 3GPP TS 38.211 V19.5.0 (Release 19), clauses 4.1 to 4.3.
tc = 1 / (480e3 * 4096) # basic time unit Tc, seconds
kappa = 64
print("mu, SCS kHz, useful symbol us, normal CP us, CP of first symbol in each half-subframe us, slot ms")
for mu in range(7):
scs = 15 * 2**mu
n_u = 2048 * kappa * 2.0**-mu
n_cp = 144 * kappa * 2.0**-mu
n_cp_long = n_cp + 16 * kappa
print(f"{mu}, {scs}, {n_u * tc * 1e6:.2f}, {n_cp * tc * 1e6:.3f}, {n_cp_long * tc * 1e6:.3f}, {1 / 2**mu:.4g}")
mu, SCS kHz, useful symbol us, normal CP us, CP of first symbol in each half-subframe us, slot ms
0, 15, 66.67, 4.688, 5.208, 1
1, 30, 33.33, 2.344, 2.865, 0.5
2, 60, 16.67, 1.172, 1.693, 0.25
3, 120, 8.33, 0.586, 1.107, 0.125
4, 240, 4.17, 0.293, 0.814, 0.0625
5, 480, 2.08, 0.146, 0.667, 0.03125
6, 960, 1.04, 0.073, 0.594, 0.01562
At 15 kHz the normal prefix is 4.688 µs and the useful symbol 66.67 µs, so the prefix is about 7 % of the symbol. At 30 kHz the prefix halves to 2.344 µs, which still exceeds the 1000 ns “very long” delay-spread example in TR 38.901 by a wide margin. At 120 kHz, a spacing specified in TS 38.101-2 for FR2 (24.25 GHz to 71 GHz), the prefix is 0.586 µs. A network picks its numerology per carrier and bandwidth part. Which spacings a band supports is not in TS 38.211: for FR1 (410 MHz to 7.125 GHz) it is Table 5.3.5-1 of TS 38.101-1 (Release 19, V19.7.0), which lists the channel bandwidths and spacings for each band. Our OFDM and NR numerology cheat sheet collects the values in one table.
Our toy link differs from NR in ways the next lessons and projects pick up. NR leaves subcarriers at the band edges empty as a guard band, places reference signals for channel estimation on a sparse grid, codes its data channels with LDPC codes (TS 38.212), and uses OFDMA to share subcarriers among users. The peak-to-average power ratio (PAPR) of OFDM is also high, which matters for the power amplifier.
Exercise
- Set
n_cp = 8so the 13-sample channel no longer fits. Run the block and describe where the BER curve flattens compared with the no-CP case. - Replace the fixed channel with a random one drawn from an exponential power delay profile (reuse the function from lesson 7) and average the BER over 100 channel draws. Compare the result with the Rayleigh curve from lesson 7.
- Change
n_trainfrom 4 to 1 and to 16. Measure the BER at 14 dB for each and relate the change to the noise in the estimate. - Using the second block, compute the prefix overhead as a percentage of the total symbol for each μ. Explain why it is the same for all μ apart from the longer first symbol.
Summary
- OFDM places one symbol on each subcarrier with an IFFT and recovers them with an FFT. A cyclic prefix longer than the channel makes the channel act as one complex gain per subcarrier.
- The one-tap equaliser divides each subcarrier by its channel gain. Our simulation matched theory to within counting error.
- Without the prefix the link hits an error floor near 5 × 10⁻³ from inter-symbol and inter-carrier interference.
- The prefix costs time and energy: 20 % and 0.97 dB in our 64 plus 16 sample design.
- 5G NR scales the subcarrier spacing as 15 × 2^μ kHz, with μ from 0 to 6 in Release 19, and the normal prefix shrinks in proportion: 4.688 µs at 15 kHz, 2.344 µs at 30 kHz.
Further reading
- S. Weinstein and P. Ebert, “Data transmission by frequency-division multiplexing using the discrete Fourier transform”, IEEE Transactions on Communication Technology, vol. 19, no. 5, 1971: the early paper on generating a frequency-division multiplex with the DFT.
- 3GPP TS 38.211, NR; Physical channels and modulation: the numerology, frame structure and OFDM signal generation for 5G NR.
- J. G. Proakis and M. Salehi, Digital Communications, 5th edition, McGraw-Hill, 2008: see the chapter “Multichannel and Multicarrier Systems”.
- A. Goldsmith, Wireless Communications, Cambridge University Press, 2005: see the chapter “Multicarrier Modulation”.