Fading channels#

Multipath fading: delay profiles and Doppler processes (decision D43).

The delay axis gets the treatment decisions D15/D17/D20 gave the frequency axis: a frozen value object carrying the numbers and their provenance, a registry-backed catalog of standard models, and a self-check at construction against the figures published alongside the table. CarrierType types the frequency axis, FieldRole the frame axis, and PowerDelayProfile now describes the delay axis.

A standardized channel model is a power delay profile plus a Doppler spectrum; both live here. The taps are generated by rayleigh_process(), and applied to a signal by TappedDelayLineChannel.

class DopplerSpectrum(*values)[source]#

Shape of the Doppler power spectrum of a tap.

class PowerDelayProfile(delays_ns: ndarray, powers_dB: ndarray, *, doppler: DopplerSpectrum = DopplerSpectrum.CLASSICAL, rice_k_dB: float | None = None, standard: str = 'custom', reference: str = '')[source]#

Tapped-delay-line model of a multipath channel (decision D43).

Signal Model#

The channel is a sum of \(L\) resolvable paths, path \(l\) arriving with delay \(\tau_l\) and average power \(P_l\):

\[h(t, \tau) = \sum_{l=0}^{L-1} a_l(t) \, \delta(\tau - \tau_l), \qquad \mathbb{E}\left[|a_l(t)|^2\right] = \gamma_l\]

The powers are normalized so that \(\sum_l \gamma_l = 1\): the delay spread then changes the frequency selectivity without changing the operating SNR, which is what makes two profiles comparable. Each \(a_l(t)\) is a Rayleigh process with the model’s Doppler spectrum (rayleigh_process()), or Rician when the tap carries a specular component.

The summary figure of a profile is its RMS delay spread

\[\sigma_\tau = \sqrt{\sum_l \gamma_l \tau_l^2 - \left(\sum_l \gamma_l \tau_l\right)^2}\]

which sets the coherence bandwidth, roughly \(B_c \approx 1 / (5 \sigma_\tau)\).

param delays_ns:

Path delays \(\tau_l\) in nanoseconds, ascending.

type delays_ns:

sequence of float

param powers_dB:

Average path powers \(P_l\) in dB, relative – the profile is normalized to unit total power.

type powers_dB:

sequence of float

param doppler:

Doppler spectrum shared by the taps. Default CLASSICAL.

type doppler:

DopplerSpectrum, optional, keyword-only

param rice_k_dB:

Rice factor \(K\) in dB of the first tap when the model has a line of sight (TDL-D/E of TR 38.901); None for a fully Rayleigh profile.

type rice_k_dB:

float, optional, keyword-only

param standard:

Standard this profile comes from. Default "custom".

type standard:

str, keyword-only

param reference:

Clause the numbers were copied from – same provenance requirement as decision D20 for carrier allocations.

type reference:

str, keyword-only

raises ValueError:

If the two sequences differ in length, if the delays are not strictly ascending, or if the profile carries no power.

References

J. G. Proakis, M. Salehi, Digital Communications, 5th ed., McGraw-Hill, 2008, Section 13.1 (multipath fading channels); 3GPP TS 36.101 Annex B.2 and 3GPP TR 38.901 Section 7.7 for the catalog entries.

Examples

>>> profile = PowerDelayProfile([0, 100, 300], [0.0, -3.0, -6.0])
>>> profile.n_taps, round(profile.rms_delay_spread_ns, 1)
(3, 103.1)
>>> print(np.round(profile.powers_lin, 4))
[0.5707 0.286  0.1433]
property n_taps: int#

Number \(L\) of resolvable paths.

property powers_lin: ndarray#

Per-path powers \(\gamma_l\), normalized to unit sum.

property mean_delay_ns: float#

Power-weighted mean delay \(\sum_l \gamma_l \tau_l\), in ns.

property rms_delay_spread_ns: float#

RMS delay spread \(\sigma_\tau\), in nanoseconds.

property coherence_bandwidth_hz: float#

Coherence bandwidth \(B_c \approx 1 / (5 \sigma_\tau)\), in Hz.

The 0.5-correlation rule of thumb; a conservative estimate, and the one Proakis quotes.

to_taps(fs: float) → tuple[ndarray, ndarray][source]#

Resample the profile onto the sample grid of a signal.

Path delays are rounded to the nearest sample, and paths that land on the same sample are summed in power – the standard practice, and the reason a model is only meaningful above a minimum sampling rate.

Parameters:

fs (float) – Sampling frequency in Hz.

Returns:

tuple of numpy.ndarray – (sample_delays, powers): integer delays in samples, and the matching normalized powers summing to 1.

Examples

>>> profile = get_delay_profile("EVA")
>>> delays, powers = profile.to_taps(fs=15.36e6)
>>> delays.tolist()
[0, 2, 5, 6, 11, 17, 27, 39]
>>> round(float(powers.sum()), 12)
1.0
rayleigh_process(n_samples: int, fs: float, f_doppler: float, *, spectrum: DopplerSpectrum = DopplerSpectrum.CLASSICAL, rng: Generator | None = None) → ndarray[source]#

Generate one complex Gaussian fading tap with a given Doppler spectrum.

Signal Model#

The tap gain \(h[n]\) is a zero-mean complex Gaussian process, \(h[n] \sim \mathcal{CN}(0, 1)\), whose power spectral density is the Doppler spectrum of the model. For isotropic scattering (CLASSICAL, the Clarke/Jakes model) that density is

\[S(f) = \frac{1}{\pi f_D \sqrt{1 - \left(f / f_D\right)^2}}, \qquad |f| < f_D\]

whose inverse Fourier transform gives the reference autocorrelation

\[R(\tau) = \mathbb{E}\left[h[n] h^*[n + \tau f_s]\right] = J_0\!\left(2 \pi f_D \tau\right)\]

with \(J_0\) the Bessel function of the first kind, order zero – the quantity validation/fading_doppler.py checks.

The process is synthesized directly on the output FFT grid: every bin overlapping the band is filled with a complex Gaussian shaped by the square root of the exact power it covers, and one inverse transform gives the samples. Weighting by the covered fraction rather than testing bin centres matters near \(\pm f_D\), where the density is singular and one bin carries several percent of the band. Nothing is resampled or interpolated, so the realization is band-limited by construction. The known cost of the spectral method is periodicity: the realization repeats with period n_samples / fs.

When the observation window is shorter than one Doppler period (\(n\_samples / f_s < 1 / f_D\)), no bin fits under \(f_D\) and the tap is constant over the window – which is the physically correct answer, not a degenerate case: the channel simply has no time to vary.

Axes: returns a 1D array of length n_samples.

param n_samples:

Number \(N\) of samples to generate.

type n_samples:

int

param fs:

Sampling frequency \(f_s\) of the signal, in Hz.

type fs:

float

param f_doppler:

Maximum Doppler frequency \(f_D\) in Hz, i.e. \(v / \lambda\). Zero gives a constant tap.

type f_doppler:

float

param spectrum:

Shape of \(S(f)\). Default CLASSICAL.

type spectrum:

DopplerSpectrum, optional, keyword-only

param rng:

Random generator; a fresh default one when omitted.

type rng:

numpy.random.Generator, optional, keyword-only

returns:

numpy.ndarray – Complex tap gains of length n_samples and unit variance.

References

R. H. Clarke, “A statistical theory of mobile-radio reception”, Bell System Technical Journal 47(6), 1968; W. C. Jakes, Microwave Mobile Communications, Wiley, 1974, Chapter 1.

Notes

The average power of a single realization is itself random: the process has about \(2 N f_D / f_s\) degrees of freedom over the window, so a short look at a slowly fading channel does not average its own power out (standard deviation 0.16 for 260 ms at \(f_D = 100\) Hz). The ensemble mean is exactly one; the realization is deliberately not renormalized, which would break the Gaussian statistics.

Examples

>>> rng = np.random.default_rng(0)
>>> h = rayleigh_process(1024, fs=1e3, f_doppler=10.0, rng=rng)
>>> h.shape, bool(np.std(np.abs(h)) > 0)      # the tap does vary
((1024,), True)
>>> static = rayleigh_process(64, fs=1e3, f_doppler=0.0, rng=rng)
>>> bool(np.allclose(static, static[0]))      # no Doppler, no variation
True
get_delay_profile(standard: str, **kwargs: Any) → PowerDelayProfile[source]#

Return a catalog delay profile by name.

Parameters:
  • standard (str) – Entry name, e.g. "EVA"; see available_delay_profiles().

  • **kwargs – Forwarded to the entry factory (e.g. doppler).

Raises:

KeyError – If the name is not in the catalog.

Examples

>>> profile = get_delay_profile("ETU")
>>> profile.n_taps, round(profile.rms_delay_spread_ns)
(9, 991)
register_delay_profile(name: str)[source]#

Register a catalog entry; users can add their own models.

Parameters:

name (str) – Name the factory answers to in get_delay_profile(). An existing name is replaced, which is what lets a user override a catalog model with their own measurement.

Examples

>>> @register_delay_profile("LAB")
... def _lab():
...     return PowerDelayProfile(delays_ns=(0.0, 100.0),
...                              powers_dB=(0.0, -3.0),
...                              standard="LAB", reference="bench")
>>> get_delay_profile("LAB").n_taps
2
available_delay_profiles() → list[str][source]#

Names accepted by get_delay_profile().

Returns:

list of str – Registered names, sorted. Includes any model the user added through register_delay_profile().

Examples

>>> [name for name in available_delay_profiles() if name.startswith("E")]
['EPA', 'ETU', 'EVA']