Simulate 5G NR Numerology in Python (OFDM)
5G NR OFDM Python simulation in NumPy: build CP-OFDM symbols for 15 to 120 kHz, check slot timing, and measure how cyclic prefix length limits echoes.
By BitForBytes Editorial & Hardware Research Team, Official BitForBytes Hardware Publication · · 10 min read
⚡ Quick Summary
5G NR numerology can be simulated in about 60 lines of NumPy: fix the sample rate at 61.44 MHz, set the FFT size to 4096 divided by 2^μ, and add a cyclic prefix of 288 divided by 2^μ samples. The demo shows that an echo shorter than the cyclic prefix causes 0% EVM, while a longer echo causes inter-symbol interference.
1. Introduction & Core Motivation
Every explanation of 5G NR numerology says the same two things: wider subcarrier spacing gives shorter symbols, and shorter symbols have a shorter cyclic prefix, which tolerates less multipath. Both statements are easy to repeat and hard to feel. Until you have watched a constellation fall apart because an echo arrived 3 µs late, "the cyclic prefix must exceed the delay spread" is a sentence, not knowledge.
The usual route to a waveform is a commercial 5G toolbox, which most students do not have at home, or a full open-source stack that takes days to build. Neither is needed for the core idea. An OFDM symbol is an inverse FFT, a cyclic prefix is a copy of its tail, and a multipath channel is a delayed addition. NumPy does all three.
The script in this article builds CP-OFDM symbols for numerologies 0 to 3 using the sample counts from 3GPP TS 38.211, checks that they add up to exactly 0.5 ms, and then measures error vector magnitude (EVM) after a two-path channel. It makes a good base for a mini project. After running it, you will be able to explain with your own numbers why a 15 kHz carrier survives a 3 µs echo and a 120 kHz carrier does not.
2. Detailed Technical Breakdown
How Do NR Numerology Parameters Map to Samples?
NR numerology maps to samples through one choice: a common sample rate at which every numerology has a power-of-two FFT size. With f_s = 61.44 MHz,
N_FFT = (f_s) / (Δ f) = (4096) / (2^(μ)), N_CP = (288) / (2^(μ))
where f_s is the sample rate in Hz, Δ f = 15 × 2^(μ) kHz is the subcarrier spacing, N_FFT is the FFT size and N_CP is the normal cyclic-prefix length in samples. The 288 comes from TS 38.211, which sets the normal prefix to $144/2048$ of the useful symbol: 4096 × 144/2048 = 288.
Worked example. For μ = 1: N_FFT = 2048 and N_CP = 144. The useful symbol lasts 2048 / 61.44 MHz = 33.33 µs and the prefix lasts 144 / 61.44 MHz = 2.34 µs. Half a millisecond holds 14 symbols of $2048 + 144$ samples, which is 30,688 samples. The specification adds 32 samples to the first prefix in each half-millisecond, giving 30,720, exactly 61.44 × 10⁶ × 0.5 × 10⁻³. The script asserts this for every numerology.
What Does the Simulation Chain Look Like?
The simulation chain is a transmitter, a two-path channel and a receiver, with EVM measured at the end.
random bits
|
v
+--------+ +-----------+ +--------+ +----------+
| QPSK |-->| map to |-->| IFFT |-->| add CP |--+
| mapper | | 288 sub- | | N_FFT | | (copy | |
+--------+ | carriers | +--------+ | tail) | |
+-----------+ +----------+ |
v
+---------------------------+
| channel: |
| y[n] = x[n] + 0.5 x[n-d] |
+-------------+-------------+
|
+--------+ +-----------+ +--------+ +-----v----+
| EVM |<--| one-tap |<--| FFT |<--| drop CP |
| vs TX | | equaliser | | N_FFT | | |
+--------+ +-----------+ +--------+ +----------+
The channel adds one echo at half amplitude, delayed by d samples. Two symbols are sent back to back so that the echo of the first symbol can spill into the second, which is how inter-symbol interference happens.
How Is the Result Measured?
The result is measured as error vector magnitude, the RMS distance between received and transmitted constellation points:
EVM (%) = 100 × sqrt( Σ |ŝ_k - s_k|² / Σ |s_k|² ), sums over k = 1 … N
where s_k is the transmitted QPSK symbol on subcarrier k, ŝ_k is the equalised received symbol and N is the number of active subcarriers. The script normalises QPSK to unit power, so the denominator is 1.
A known two-path channel has frequency response H_k = 1 + 0.5 e^(-j 2π k d / N_FFT) on subcarrier k. If the echo fits inside the prefix, dividing each subcarrier by H_k recovers the data exactly and EVM is 0%. If it does not fit, part of the previous symbol leaks into the FFT window and no one-tap equaliser can remove it.
What Is the Complete Script?
The complete script is below; it needs only NumPy and runs in under a second.
import numpy as np
FS = 61.44e6 # sample rate (Hz): a 4096-point FFT at 15 kHz
N_RB = 24 # 24 resource blocks = 288 active subcarriers
rng = np.random.default_rng(seed=7)
def numerology(mu):
"""FFT size and normal-CP length in samples at FS (TS 38.211)."""
scs = 15e3 * 2**mu
n_fft = int(FS / scs) # 4096, 2048, 1024, 512
n_cp = 288 // 2**mu # 144 * kappa * 2^-mu * Tc, in samples
return scs, n_fft, n_cp
def ofdm_symbol(mu):
"""One CP-OFDM symbol of random QPSK on 12 * N_RB subcarriers."""
_, n_fft, n_cp = numerology(mu)
n_sc = 12 * N_RB
bits = rng.integers(0, 2, size=(n_sc, 2))
qpsk = ((1 - 2 * bits[:, 0]) + 1j * (1 - 2 * bits[:, 1]))
qpsk = qpsk / np.sqrt(2)
k = np.arange(-n_sc // 2, n_sc // 2) # centred on DC
grid = np.zeros(n_fft, dtype=complex)
grid[k % n_fft] = qpsk
x = np.fft.ifft(grid) * np.sqrt(n_fft)
return np.concatenate((x[-n_cp:], x)), qpsk, k
def evm_after_echo(mu, delay_s):
"""EVM (%) after a two-path channel (direct path + echo)."""
_, n_fft, n_cp = numerology(mu)
d = int(round(delay_s * FS)) # echo delay in samples
prev_sym, _, _ = ofdm_symbol(mu) # earlier symbol: ISI source
this_sym, qpsk, k = ofdm_symbol(mu)
tx = np.concatenate((prev_sym, this_sym))
rx = tx.copy()
rx[d:] += 0.5 * tx[:-d] # add the delayed echo
start = len(prev_sym) + n_cp # drop CP of 2nd symbol
y = np.fft.fft(rx[start:start + n_fft]) / np.sqrt(n_fft)
h = 1 + 0.5 * np.exp(-2j * np.pi * k * d / n_fft) # channel
eq = y[k % n_fft] / h # one-tap equaliser
return 100 * np.sqrt(np.mean(np.abs(eq - qpsk) ** 2))
print(" mu SCS(kHz) N_fft useful(us) CP(us) slots/ms")
for mu in range(4):
scs, n_fft, n_cp = numerology(mu)
# 0.5 ms = 7 * 2^mu symbols plus 32 extra CP samples
half_ms = (7 * 2**mu) * (n_fft + n_cp) + 32
assert half_ms == FS * 0.5e-3 # 30720 samples
print(f"{mu:3d} {scs / 1e3:9.0f} {n_fft:6d} "
f"{n_fft / FS * 1e6:11.2f} {n_cp / FS * 1e6:7.2f} "
f"{2**mu:9d}")
print("\nEVM (%) with a half-amplitude echo")
print(" mu CP(us) echo 1.0 us echo 3.0 us")
for mu in range(4):
_, _, n_cp = numerology(mu)
print(f"{mu:3d} {n_cp / FS * 1e6:8.2f} "
f"{evm_after_echo(mu, 1.0e-6):13.2f} "
f"{evm_after_echo(mu, 3.0e-6):13.2f}")
Three details are worth reading closely. grid[k % n_fft] places negative-frequency subcarriers at the top of the FFT array, which is how NumPy orders bins. x[-n_cp:] copies the tail of the symbol to its front; that copy is the whole cyclic prefix. And rx[d:] += 0.5 * tx[:-d] is the multipath channel: the same signal, later and weaker.
What Does the Output Show?
The output shows that EVM stays at 0% whenever the echo delay is shorter than the cyclic prefix and jumps as soon as it is longer.
mu SCS(kHz) N_fft useful(us) CP(us) slots/ms
0 15 4096 66.67 4.69 1
1 30 2048 33.33 2.34 2
2 60 1024 16.67 1.17 4
3 120 512 8.33 0.59 8
EVM (%) with a half-amplitude echo
mu CP(us) echo 1.0 us echo 3.0 us
0 4.69 0.00 0.00
1 2.34 0.00 11.15
2 1.17 0.00 25.28
3 0.59 18.52 42.52
Read the second table against the prefix column. A 1.0 µs echo is inside the prefix for 15, 30 and 60 kHz, so all three recover the data perfectly; at 120 kHz the prefix is only 0.59 µs and EVM is 18.52%. A 3.0 µs echo is inside the prefix only at 15 kHz. The further the echo overshoots the prefix, the larger the share of the FFT window it corrupts, which is why the error grows from 11.15% to 42.52% down the last column.
| μ | Normal CP | Longest extra path the CP absorbs | 1.0 µs echo (300 m) | 3.0 µs echo (900 m) |
|---|---|---|---|---|
| 0 | 4.69 µs | about 1,400 m | Clean | Clean |
| 1 | 2.34 µs | about 700 m | Clean | 11.15% EVM |
| 2 | 1.17 µs | about 350 m | Clean | 25.28% EVM |
| 3 | 0.59 µs | about 175 m | 18.52% EVM | 42.52% EVM |
The path column is the prefix multiplied by the speed of light, 3 × 10⁸ m/s. It explains deployment practice: 120 kHz is used at millimetre-wave frequencies, where cells are small and reflected paths are short, while 15 kHz serves large low-band cells where echoes from distant buildings and hills arrive microseconds late.
What Are the Limits of This Model?
This model is limited to showing prefix behaviour; it is not a standards-compliant NR waveform. Four simplifications matter.
- There is no noise, so 0.00% EVM means "no interference", not "perfect link".
- The receiver knows the channel exactly. A real receiver estimates it from DMRS reference signals.
- The echo delay is rounded to a whole sample: 1.0 µs becomes 61 samples and 3.0 µs becomes 184.
- Only QPSK on 24 resource blocks is sent, with no reference signals, control channels or channel coding.
Each one is a natural extension. Add complex Gaussian noise and sweep SNR; swap QPSK for 16-QAM and watch the same EVM cause bit errors sooner; add the 60 kHz extended prefix (256 samples at this sample rate) and confirm it survives the 3.0 µs echo.
3. Practical Takeaways for Students
- Exam angle: The script is a worked proof of two exam statements: symbol duration is 1/Δ f, and the cyclic prefix must be at least as long as the channel delay spread. Quote one row, for example 30 kHz: 33.33 µs symbol, 2.34 µs prefix, so echoes up to about 700 m of extra path are absorbed.
- Interview angle: "What happens if delay spread exceeds the cyclic prefix?" Inter-symbol and inter-carrier interference that a one-tap equaliser cannot remove. "Why does FR2 get away with a 0.59 µs prefix?" Short range and narrow beams keep delay spread small.
- Common mistake: Forgetting the scaling on the FFT. Without
* np.sqrt(n_fft)on the transmit side and/ np.sqrt(n_fft)on the receive side, constellation points come back at the wrong amplitude and EVM looks enormous for no physical reason. - Try it yourself: Change the echo amplitude from 0.5 to 0.1 and predict the new EVM before running; then plot the constellation with Matplotlib. For the theory behind the IFFT step, revise OFDM on the BitForBytes Roadmaps.
4. Frequently Asked Questions
Why does the script use a 61.44 MHz sample rate?
A rate of 61.44 MHz makes the FFT size a power of two for every numerology in the demo: 4096 at 15 kHz, 2048 at 30 kHz, 1024 at 60 kHz and 512 at 120 kHz. A common sample rate also lets the same echo delay in samples mean the same time for all four cases.
Real equipment picks the rate from the carrier bandwidth. A 100 MHz carrier at 30 kHz commonly uses a 4096-point FFT, which corresponds to 122.88 MHz.
Do I need MATLAB or a 5G toolbox to simulate OFDM?
No. The physical idea needs only an inverse FFT, a prefix copy and an FFT, which NumPy provides. The script here has no dependency beyond NumPy and reproduces the timing figures of 3GPP TS 38.211 for normal cyclic prefix exactly.
A toolbox becomes useful when you need standards-compliant channels such as PDSCH with DMRS, LDPC coding and 3GPP channel models. For learning numerology and cyclic-prefix behaviour, a short script is faster and easier to inspect.
Why is EVM exactly zero when the echo is shorter than the cyclic prefix?
EVM is zero because the prefix turns the channel's linear convolution into a circular one over the FFT window. A circular convolution in time is a plain multiplication in frequency, so each subcarrier is scaled by one complex number, and dividing by that number restores the transmitted symbol.
The demo has no noise and perfect channel knowledge. With noise, subcarriers where the two paths cancel are amplified by the equaliser and show higher error.
What are the extra 32 samples in every half millisecond?
The extra 32 samples are a longer cyclic prefix on the first OFDM symbol of every 0.5 ms. Fourteen symbols of useful part plus normal prefix fall 32 samples short of a half-millisecond at 61.44 MHz, so the specification lengthens one prefix to make the timing exact.
In time this is 0.52 µs, the same for every numerology. It is why slot durations at 60 kHz and above are equal only on average.
5. References & Further Reading
- 3GPP TS 38.211, "NR; Physical channels and modulation" (clause 4.1 time units, clause 4.2 numerologies, clause 5.3.1 OFDM baseband signal generation).
- 3GPP TS 38.104, "NR; Base Station (BS) radio transmission and reception" (EVM requirements for transmitted signals).
- NumPy documentation,
numpy.fft(FFT bin ordering and normalisation). - E. Dahlman, S. Parkvall and J. Sköld, "5G NR: The Next Generation Wireless Access Technology", Academic Press.
Frequently Asked Questions
Why does the script use a 61.44 MHz sample rate?
A rate of 61.44 MHz makes the FFT size a power of two for every numerology in the demo: 4096 at 15 kHz, 2048 at 30 kHz, 1024 at 60 kHz and 512 at 120 kHz. A common sample rate also lets the same echo delay in samples mean the same time for all four cases.
Do I need MATLAB or a 5G toolbox to simulate OFDM?
No. The physical idea needs only an inverse FFT, a prefix copy and an FFT, which NumPy provides. The script here has no dependency beyond NumPy and reproduces the timing figures of 3GPP TS 38.211 for normal cyclic prefix exactly.
Why is EVM exactly zero when the echo is shorter than the cyclic prefix?
EVM is zero because the prefix turns the channel's linear convolution into a circular one over the FFT window. A circular convolution in time is a plain multiplication in frequency, so each subcarrier is scaled by one complex number, and dividing by that number restores the transmitted symbol.
What are the extra 32 samples in every half millisecond?
The extra 32 samples are a longer cyclic prefix on the first OFDM symbol of every 0.5 ms. Fourteen symbols of useful part plus normal prefix fall 32 samples short of a half-millisecond at 61.44 MHz, so the specification lengthens one prefix to make the timing exact.