Beginner · Lesson 7 of 10

Fading channels

Multipath, Rayleigh and Rician fading, Doppler and coherence time, delay spread and coherence bandwidth, and how two-antenna diversity changes the BER curve, all simulated in numpy against theory

Beginner50 minUpdated 15 Nov 2026Free until 31 Jan 2027

You will learn

  • Compute the maximum Doppler shift for a given speed and carrier and estimate the coherence time from a simulated fading process
  • Generate Rayleigh and Rician fading in numpy and check the fraction of time spent in deep fades against theory
  • Relate RMS delay spread to coherence bandwidth by measuring the frequency correlation of simulated multipath channels
  • Simulate BPSK in AWGN, in flat Rayleigh fading, and with two-branch selection and maximal-ratio combining, and match each curve to its closed-form BER

Before this: Pulse shaping and matched filters

Up to now the channel has been a wire with noise on it. A radio channel is not. The signal reaches the receiver along several paths, the paths change as anything in the scene moves, and the received power can drop by 20 dB or more within a fraction of a wavelength. This lesson is for readers who have built the matched-filter receiver of the previous lesson and want to know why it performs so much worse on a real mobile link. We simulate fading in numpy, measure how fast it changes in time and frequency, and then show the single most effective countermeasure: a second antenna.

Multipath and the two scales of fading

Path loss and shadowing set the average received power over tens of wavelengths. On top of that sits small-scale fading. It comes from multipath: copies of the signal arriving by different routes, with different delays, phases and Doppler shifts. At any instant the receiver sees their phasor sum. When the copies line up the sum is large. When they cancel it is deep in a null. Move a few centimetres at 2 GHz and the phases rearrange.

transmitterbuilding (reflector)ground or wallreceiverdirect path, delay τ0delay τ1delay τ2speed v
Multipath between a transmitter and a moving receiver. A direct path and two reflected paths arrive with different delays and phases. Their sum at the receiver antenna fades as the receiver moves at speed v.

Two numbers describe how the channel varies. In time, the spread of Doppler shifts sets how fast the fading changes. In frequency, the spread of path delays sets how quickly the channel response changes across the band. We take them in turn.

Rayleigh and Rician fading

When there are many scattered paths and none dominates, the central limit theorem makes the complex channel gain h a zero-mean complex Gaussian. Its magnitude follows a Rayleigh distribution and its power |h|² is exponential. This is Rayleigh fading. The exponential gives a clean rule: the probability that the power is below a fraction x of its mean is 1 − exp(−x). For x = 0.1 (10 dB down) that is 9.5 %. For x = 0.01 (20 dB down) it is 1 %. A Rayleigh link spends one per cent of its time at least 20 dB below its average.

If a steady line-of-sight component is present, the gain is that constant plus the scattered part. The magnitude is then Rician. The K-factor is the ratio of line-of-sight power to scattered power. With K = 0 it is Rayleigh, and as K grows the fades get shallower. See Rician fading.

Doppler and coherence time

A receiver moving at speed v sees a path arriving at angle α to its direction of travel shifted in frequency by (v/λ) cos α. The largest shift, for a path straight ahead or behind, is the maximum Doppler shift fD = v fc / c. At 50 km/h and 2 GHz it is about 93 Hz. With scatterers in every direction, each path has a different shift, and the channel changes on a time scale of roughly 1/fD.

For uniform scattering around the receiver, the model usually named after R. H. Clarke, the autocorrelation of the channel gain at lag τ is J0(2π fD τ), a Bessel function of the first kind. The coherence time is the lag over which the channel stays roughly the same. There is no single definition. Taking the lag where the correlation falls to 0.5 gives about 0.24/fD. Textbooks that use other thresholds quote other constants, so always check which one a figure uses.

The block builds this model as a sum of 64 paths with random arrival angles and phases. It checks the fade statistics, adds a line-of-sight component for a Rician channel, and measures the correlation against lag.

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

rng = np.random.default_rng(7)
c = 299_792_458.0
fc = 2.0e9                         # carrier, Hz
v = 50 / 3.6                       # 50 km/h in m/s
fd = v * fc / c                    # maximum Doppler shift, Hz
fs = 2000.0                        # channel sample rate, Hz (well above 2 fd)
t = np.arange(0, 20.0, 1 / fs)     # 20 s of channel

# Clarke's model as a sum of M scattered paths with random arrival angles and phases.
M = 64
alpha = rng.uniform(0, 2 * np.pi, M)
phi = rng.uniform(0, 2 * np.pi, M)
h = np.exp(1j * (2 * np.pi * fd * np.outer(t, np.cos(alpha)) + phi)).sum(axis=1) / np.sqrt(M)
p = np.abs(h) ** 2 / np.mean(np.abs(h) ** 2)       # power normalised to its mean

print(f"max Doppler shift: {fd:.1f} Hz")
for thr_db in (-10, -20):
    thr = 10 ** (thr_db / 10)
    print(f"time below {thr_db} dB: {np.mean(p < thr) * 100:.2f} % (Rayleigh theory {(1 - np.exp(-thr)) * 100:.2f} %)")

# Rician: add a steady line-of-sight component with K = 10 dB.
K = 10 ** (10 / 10)
h_ric = np.sqrt(K / (K + 1)) + np.sqrt(1 / (K + 1)) * h
p_ric = np.abs(h_ric) ** 2 / np.mean(np.abs(h_ric) ** 2)
print(f"Rician K = 10 dB, time below -10 dB: {np.mean(p_ric < 0.1) * 100:.2f} %")

# Time correlation of h against lag, compared with Clarke's J0(2 pi fd tau).
lags = np.arange(0, 41)                              # 0 to 20 ms in 0.5 ms steps
r = np.array([np.mean(h[k:] * np.conj(h[: h.size - k])).real for k in lags]) / np.mean(np.abs(h) ** 2)
tau = lags / fs
k = int(np.argmax(r < 0.5))                         # first lag below 0.5, then interpolate
tau_half = tau[k - 1] + (r[k - 1] - 0.5) / (r[k - 1] - r[k]) * (tau[k] - tau[k - 1])
tc_theory = brentq(lambda x: j0(2 * np.pi * fd * x) - 0.5, 1e-6, 0.383 / fd)
print(f"correlation falls to 0.5 after {tau_half * 1e3:.2f} ms (J0 theory {tc_theory * 1e3:.2f} ms = {tc_theory * fd:.3f}/fd)")
print("lag (ms), simulated, J0")
for k in range(0, 41, 4):
    print(f"{tau[k] * 1e3:.1f}, {r[k]:.3f}, {j0(2 * np.pi * fd * tau[k]):.3f}")
# Power over the first 58 ms, every 1 ms, for plotting.
print("t (ms), Rayleigh dB, Rician dB")
for k in range(0, 117, 2):
    print(f"{t[k] * 1e3:.0f}, {10 * np.log10(p[k]):.1f}, {10 * np.log10(p_ric[k]):.1f}")
max Doppler shift: 92.7 Hz
time below -10 dB: 9.25 % (Rayleigh theory 9.52 %)
time below -20 dB: 0.90 % (Rayleigh theory 1.00 %)
Rician K = 10 dB, time below -10 dB: 0.02 %
correlation falls to 0.5 after 2.67 ms (J0 theory 2.61 ms = 0.242/fd)
lag (ms), simulated, J0
0.0, 1.000, 1.000
2.0, 0.701, 0.689
4.0, 0.068, 0.040
6.0, -0.367, -0.379
8.0, -0.322, -0.281
10.0, 0.007, 0.098
12.0, 0.215, 0.300
14.0, 0.124, 0.135
16.0, -0.084, -0.161
18.0, -0.144, -0.238
20.0, -0.020, -0.034
t (ms), Rayleigh dB, Rician dB
0, -15.3, 0.0
1, -11.4, 0.3
2, -7.0, 0.6
3, -4.2, 1.0
4, -2.5, 1.4
5, -1.3, 1.6
6, -0.4, 1.8
7, 0.6, 1.8
8, 1.4, 1.8
9, 2.0, 1.6
10, 2.1, 1.3
11, 1.5, 0.7
12, 0.4, -0.2
13, -0.7, -1.4
14, -0.3, -2.8
15, 1.8, -4.4
16, 3.8, -5.7
17, 5.1, -6.3
18, 5.4, -6.1
19, 4.7, -5.3
20, 2.6, -4.2
21, -1.4, -2.8
22, -8.1, -1.4
23, -4.9, -0.1
24, -0.6, 0.6
25, 0.8, 0.9
26, -0.1, 0.6
27, -3.6, -0.3
28, -7.7, -1.4
29, -2.3, -2.3
30, 1.8, -2.5
31, 3.5, -2.1
32, 3.3, -1.4
33, 1.0, -0.7
34, -4.5, 0.1
35, -4.9, 1.0
36, 1.7, 1.8
37, 5.1, 2.4
38, 6.5, 2.6
39, 6.7, 2.3
40, 5.6, 1.6
41, 3.5, 0.5
42, 0.4, -0.7
43, -3.0, -1.6
44, -5.4, -1.9
45, -7.3, -1.6
46, -10.6, -1.0
47, -14.6, -0.3
48, -10.2, 0.3
49, -6.0, 0.8
50, -3.4, 1.1
51, -1.3, 1.2
52, 0.4, 1.3
53, 1.5, 1.3
54, 1.6, 1.2
55, 0.2, 0.9
56, -3.6, 0.4
57, -15.1, -0.1
58, -9.3, -0.5

The simulated channel spends 9.25 % of its time 10 dB or more below the mean and 0.90 % at 20 dB or more below. Theory gives 9.52 % and 1.00 %. The small gaps come from a finite 20 s record and a finite number of paths. The Rician channel with K = 10 dB drops 10 dB below its mean only 0.02 % of the time.

The correlation falls to 0.5 after 2.67 ms, close to the Bessel prediction of 2.61 ms. The simulated correlation tracks J0 well for the first few milliseconds and then wanders from it. Sixty-four paths give a good approximation to uniform scattering near zero lag but not in the tails.

-15-10-50501020304050RayleighRician, K 10 dBpower (dB)time (ms)
Received power relative to its mean over the first 58 ms of the simulated channel at 50 km/h and 2 GHz, sampled every 1 ms. The Rayleigh channel shows fades of more than 15 dB a few milliseconds apart. The Rician channel with K = 10 dB stays within a few dB of its mean. Values from the first code block.

Coherence time matters for receiver design. If a packet is shorter than the coherence time, one channel estimate at the start holds to the end. If not, the receiver must track the channel during the packet, which is why longer frames carry pilots spread through them. At 93 Hz Doppler a 1 ms block is well inside the 2.6 ms coherence time. At 500 km/h it would not be.

Delay spread and coherence bandwidth

The second variation is across frequency. If paths arrive with delays spread over στ seconds, the channel response H(f) changes over frequency spans of roughly 1/στ. The RMS delay spread is the standard deviation of the delays weighted by their power. The coherence bandwidth is the frequency separation over which H(f) stays correlated.

For scale, 3GPP TR 38.901 (Study on channel model for frequencies from 0.5 to 100 GHz) gives example RMS delay spreads for scaling its tapped-delay-line and clustered-delay-line models. In the Release 19 version (V19.5.0), Table 7.7.3-1 lists 10 ns, 30 ns, 100 ns, 300 ns and 1000 ns, from “very short” to “very long”. The report states that these values span the range seen in measurements for typical 5G evaluation scenarios.

The block draws random multipath channels with an exponential power delay profile, which is a common simple model. For that profile the correlation magnitude between H(f) and H(f + Δf) is 1/√(1 + (2π Δf στ)²). It falls to 0.5 at Δf = √3/(2π στ), about 0.28/στ. The code measures the correlation from 1000 random channels for three of the TR 38.901 delay spreads and compares.

import numpy as np

rng = np.random.default_rng(7)
ts = 10e-9                                   # tap spacing 10 ns (100 MHz sample rate)
n_fft = 2048                                 # frequency grid spacing 100 MHz / 2048 = 48.8 kHz
df = 1 / (ts * n_fft)

def exp_pdp_channels(ds: float, n_real: int) -> np.ndarray:
    """Random tapped-delay-line channels with an exponential power delay profile, RMS delay spread ds."""
    tau = np.arange(0, 10 * ds + ts, ts)
    pdp = np.exp(-tau / ds)
    pdp /= pdp.sum()
    g = (rng.normal(size=(n_real, tau.size)) + 1j * rng.normal(size=(n_real, tau.size))) / np.sqrt(2)
    return g * np.sqrt(pdp)

for ds in (30e-9, 100e-9, 300e-9):
    taps = exp_pdp_channels(ds, 1000)
    # RMS delay spread of the profile actually used (it is discretised and truncated)
    tau = np.arange(taps.shape[1]) * ts
    pw = np.mean(np.abs(taps) ** 2, axis=0)
    mean_tau = np.sum(pw * tau) / pw.sum()
    rms = np.sqrt(np.sum(pw * (tau - mean_tau) ** 2) / pw.sum())
    H = np.fft.fft(taps, n_fft, axis=1)          # frequency response of each realisation
    # Frequency correlation |E[H(f) H*(f + k df)]| averaged over f and realisations
    corr = np.array([np.abs(np.mean(H[:, : n_fft - k] * np.conj(H[:, k:]))) for k in range(400)])
    corr /= corr[0]
    k = int(np.argmax(corr < 0.5))
    bc = (k - 1 + (corr[k - 1] - 0.5) / (corr[k - 1] - corr[k])) * df
    bc_theory = np.sqrt(3) / (2 * np.pi * ds)
    print(f"delay spread {rms * 1e9:5.1f} ns: 50 % coherence bandwidth {bc / 1e6:6.2f} MHz "
          f"(exponential-profile theory {bc_theory / 1e6:6.2f} MHz)")

# One 300 ns channel across a 20 MHz band: how deep do the notches go?
H1 = np.fft.fftshift(np.fft.fft(exp_pdp_channels(300e-9, 1)[0], n_fft))
band = np.abs(np.arange(n_fft) - n_fft // 2) * df <= 10e6
g_db = 20 * np.log10(np.abs(H1[band]))
print(f"300 ns channel over 20 MHz: gain spread {g_db.max() - g_db.min():.1f} dB")
delay spread  29.7 ns: 50 % coherence bandwidth   9.62 MHz (exponential-profile theory   9.19 MHz)
delay spread  99.9 ns: 50 % coherence bandwidth   2.76 MHz (exponential-profile theory   2.76 MHz)
delay spread 299.0 ns: 50 % coherence bandwidth   0.92 MHz (exponential-profile theory   0.92 MHz)
300 ns channel over 20 MHz: gain spread 26.7 dB

The measured coherence bandwidths match the formula: 2.76 MHz at 100 ns and 0.92 MHz at 300 ns. At 30 ns the simulation gives 9.62 MHz against 9.19 MHz, because a 10 ns tap spacing is coarse for a profile that short. The last line shows the practical consequence. One 300 ns channel varies by 26.7 dB across a 20 MHz band.

This sets the line between flat and frequency-selective fading. A signal much narrower than the coherence bandwidth sees one complex gain across its band: flat fading. A signal much wider sees different gains on different frequencies: in time, that is intersymbol interference of the kind the eye diagram in the last lesson would show. A 20 MHz single-carrier signal on that 300 ns channel would need an equaliser. OFDM avoids the problem by splitting the band into subcarriers narrow enough that each one sees flat fading.

Why fading hurts: BER in Rayleigh fading

In AWGN, BPSK has a bit error rate of Q(√(2 Eb/N0)), which falls faster than exponentially with SNR. In flat Rayleigh fading the instantaneous SNR is the average times an exponential random variable. Averaging the AWGN BER over that distribution gives (1 − √(γ/(1 + γ)))/2, where γ is the average Eb/N0. At high SNR this tends to 1/(4γ): the BER falls by only a factor of 10 for each 10 dB. The cause is the deep fades. Most errors happen during the small fraction of time the channel is 20 dB or more down, and extra transmit power barely helps there.

Diversity

Diversity gives the receiver more than one independently faded copy of the signal. The chance that all copies fade at once is much smaller than the chance that one does. Copies can come from different antennas (space), different times separated by more than the coherence time, or different frequencies separated by more than the coherence bandwidth. With uniform scattering the correlation between the channel gains of two antennas a distance d apart follows the same Bessel function, J0(2π d/λ). At half a wavelength it is J0(π), about −0.30. The correlation of the two powers is its square, about 0.09, so the two fades are close to independent. This section assumes independent branches.

Two classic ways to combine two antennas are:

  • Selection combining keeps whichever antenna currently has the stronger channel and ignores the other.
  • Maximal-ratio combining (MRC) weights each antenna by the complex conjugate of its channel gain and adds them. This co-phases the copies and weights each by its strength, and it maximises the output SNR.

With L independent Rayleigh branches and MRC, the BPSK error rate has a closed form, coded below. At high SNR it falls as the L-th power of 1/γ. With two antennas the BER drops by close to a factor of 100 for each 10 dB. The block simulates AWGN, one antenna, two-antenna selection and two-antenna MRC, counting errors until it has 200 or has sent 20 million bits.

import numpy as np
from math import comb
from scipy.special import erfc

rng = np.random.default_rng(7)
chunk = 1_000_000                       # bits per simulation chunk
ebn0_db = np.arange(0, 21, 2)

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

def ber_rayleigh_mrc(g: float, L: int) -> float:
    """BPSK with L-branch maximal-ratio combining in independent Rayleigh fading. g = mean Eb/N0 per branch."""
    mu = np.sqrt(g / (1 + g))
    return ((1 - mu) / 2) ** L * sum(comb(L - 1 + k, k) * ((1 + mu) / 2) ** k for k in range(L))

def errors_in_chunk(ebn0: float, L: int, combine: str) -> int:
    bits = rng.integers(0, 2, chunk)
    s = 1.0 - 2.0 * bits                                 # BPSK, Eb = 1
    n0 = 1 / ebn0
    if L == 0:                                           # AWGN, no fading
        y = s + rng.normal(0, np.sqrt(n0 / 2), chunk)
        return int(np.sum((y < 0) != bits))
    h = (rng.normal(size=(L, chunk)) + 1j * rng.normal(size=(L, chunk))) / np.sqrt(2)
    w = (rng.normal(size=(L, chunk)) + 1j * rng.normal(size=(L, chunk))) * np.sqrt(n0 / 2)
    r = h * s + w                                        # each antenna sees its own fade and noise
    if combine == "mrc":                                 # weight each branch by its conjugate channel
        z = np.sum(np.conj(h) * r, axis=0).real
    else:                                                # selection: keep the strongest branch only
        best = np.argmax(np.abs(h), axis=0)
        idx = np.arange(chunk)
        z = (np.conj(h[best, idx]) * r[best, idx]).real
    return int(np.sum((z < 0) != bits))

def simulate(ebn0: float, L: int, combine: str = "mrc", min_err: int = 200, max_bits: int = 20_000_000) -> float:
    """Count errors chunk by chunk until min_err errors or max_bits bits."""
    err = n = 0
    while err < min_err and n < max_bits:
        err += errors_in_chunk(ebn0, L, combine)
        n += chunk
    return err / n

print("Eb/N0 dB, AWGN sim, AWGN th, Ray sim, Ray th, SC2 sim, MRC2 sim, MRC2 th")
for e_db in ebn0_db:
    g = 10 ** (e_db / 10)
    awgn_sim = simulate(g, 0) if e_db <= 10 else float("nan")   # too few errors to count above 10 dB
    row = [awgn_sim, q_func(np.sqrt(2 * g)),
           simulate(g, 1), ber_rayleigh_mrc(g, 1),
           simulate(g, 2, "sel"), simulate(g, 2), ber_rayleigh_mrc(g, 2)]
    print(f"{e_db:2d}, " + ", ".join(f"{v:.2e}" for v in row))
Eb/N0 dB, AWGN sim, AWGN th, Ray sim, Ray th, SC2 sim, MRC2 sim, MRC2 th
 0, 7.92e-02, 7.86e-02, 1.47e-01, 1.46e-01, 8.14e-02, 5.84e-02, 5.81e-02
 2, 3.70e-02, 3.75e-02, 1.08e-01, 1.08e-01, 4.97e-02, 3.30e-02, 3.28e-02
 4, 1.25e-02, 1.25e-02, 7.72e-02, 7.71e-02, 2.74e-02, 1.69e-02, 1.69e-02
 6, 2.44e-03, 2.39e-03, 5.26e-02, 5.30e-02, 1.39e-02, 8.11e-03, 8.13e-03
 8, 1.99e-04, 1.91e-04, 3.52e-02, 3.55e-02, 6.65e-03, 3.57e-03, 3.68e-03
10, 4.25e-06, 3.87e-06, 2.32e-02, 2.33e-02, 2.90e-03, 1.60e-03, 1.60e-03
12, nan, 9.01e-09, 1.51e-02, 1.51e-02, 1.28e-03, 6.45e-04, 6.74e-04
14, nan, 6.81e-13, 9.84e-03, 9.67e-03, 5.10e-04, 2.81e-04, 2.78e-04
16, nan, 2.27e-19, 6.02e-03, 6.16e-03, 2.02e-04, 1.24e-04, 1.14e-04
18, nan, 1.40e-29, 4.09e-03, 3.92e-03, 1.01e-04, 4.90e-05, 4.59e-05
20, nan, 1.04e-45, 2.50e-03, 2.48e-03, 3.77e-05, 2.09e-05, 1.84e-05
0.0000010.000010.00010.0010.010.105101520AWGN1 antenna1 ant. theory2-ant. MRCMRC theorybit error rateEb/N0 per antenna (dB)
Bit error rate of BPSK against Eb/N0 per antenna: AWGN theory, flat Rayleigh fading with one antenna, and two-antenna maximal-ratio combining (MRC), each simulated and compared with its closed-form expression. Values from the third code block.

Every simulated point lies close to its theoretical curve. The differences at the right-hand end come from stopping at about 200 errors per point. The one-antenna Rayleigh link reaches only 2.50 × 10⁻³ at 20 dB, worse than the AWGN link at 6 dB. Two-antenna MRC brings that down to 2.09 × 10⁻⁵ in the simulation and 1.84 × 10⁻⁵ in theory, about two orders of magnitude better at the same per-antenna SNR. Between 10 dB and 20 dB the MRC theory curve falls by a factor of 87, approaching the asymptotic factor of 100 per 10 dB. Selection combining sits between the two, at 3.77 × 10⁻⁵ at 20 dB. It has the same diversity slope, but at high SNR its BER is about twice that of MRC, because it throws away the energy collected by the weaker antenna.

Modern systems use all three forms of diversity at once. Multiple receive antennas give space diversity. Channel coding and interleaving spread a code word over time and frequency, so a deep fade wipes out only part of it. Multiple transmit antennas, the subject of the MIMO hub, add space-time diversity and spatial multiplexing on top.

Exercise

  1. In the first block, change the speed to 120 km/h and the carrier to 3.5 GHz. Report the new Doppler shift and the lag where the correlation falls to 0.5, and check it against 0.242/fD.
  2. In the second block, add the 1000 ns value from TR 38.901 and report its coherence bandwidth. What does it imply for a 30 kHz subcarrier and for a 20 MHz single-carrier signal?
  3. Extend the third block to L = 4 with MRC, using ber_rayleigh_mrc(g, 4) for theory. At what Eb/N0 does it reach 10⁻⁴, and how does that compare with one antenna?

Summary

  • Multipath makes the channel gain a sum of phasors. With many paths and no dominant one it is Rayleigh distributed, and the power is 20 dB or more below its mean 1 % of the time.
  • The maximum Doppler shift fD = v fc / c sets the coherence time. The channel correlation fell to 0.5 at 2.67 ms for 93 Hz Doppler, matching the J0 model.
  • The RMS delay spread sets the coherence bandwidth. For an exponential profile the 50 % correlation point is 0.28/στ, about 0.92 MHz at 300 ns.
  • In Rayleigh fading the BPSK BER falls only 10 times per 10 dB. Two-antenna MRC makes it fall close to 100 times per 10 dB.

Further reading