Channels#

class AWGN(*, snr_dB: float | None = None, sigma2: float | None = None, seed: int | None = None, name: str = 'awgn')[source]#

Additive white Gaussian noise channel.

Signal Model#

\[y[n] = x[n] + b[n], \qquad b[n] \sim \mathcal{CN}\left(0, \sigma^2\right)\]

When parameterized by snr_dB, the variance is derived from the measured input power \(P_x = \mathbb{E}\left[|x[n]|^2\right]\):

\[\sigma^2 = P_x \, 10^{-\mathrm{SNR_{dB}}/10}\]

For real-valued inputs, a real Gaussian noise of variance \(\sigma^2\) is applied instead of a circular complex one.

Axes: element-wise – applied pointwise, shape-agnostic. One caveat under a batch (D51): with snr_dB= the signal power is measured over the whole input, batch rows included, so rows of unequal power share one variance rather than each receiving the requested SNR; sigma2= is batch-neutral.

param snr_dB:

Signal-to-noise ratio \(\mathrm{SNR_{dB}}\) in decibels, relative to the measured input power. Mutually exclusive with sigma2.

type snr_dB:

float, keyword-only

param sigma2:

Absolute noise variance \(\sigma^2\). Mutually exclusive with snr_dB.

type sigma2:

float, keyword-only

param seed:

Local RNG seed.

type seed:

int, optional, keyword-only

param name:

Name of the channel instance. Default is "awgn".

type name:

str, optional, keyword-only

sigma2_#

Variance actually applied. Estimated from the input power when parameterized by snr_dB (data-dependent, hence the trailing underscore); equal to sigma2 otherwise.

Type:

float

References

J. G. Proakis, M. Salehi, Digital Communications, 5th ed., McGraw-Hill, 2008, Section 4.2.

Examples

>>> x = np.zeros(10_000, dtype=complex)
>>> y = AWGN(sigma2=0.01, seed=42)(x)
>>> print(round(float(np.var(y)), 3))
0.01
class FIRChannel(h: ndarray, mode: Literal['full', 'same', 'valid'] = 'full', name: str = 'fir')[source]#

Finite impulse response (FIR) channel with a given impulse response.

Signal Model#

The output \(y[n]\) is the convolution of the input \(x[n]\) with the impulse response \(h[l]\) of length \(L\):

\[y[n] = \sum_{l=0}^{L-1} h[l] \, x[n-l]\]

Axes: axis -1 – the convolution runs along the last axis (scipy.signal.convolve semantics; output length depends on mode); leading axes are batch, every row through the same taps.

param h:

The impulse response \(h[l]\) of the channel, a 1D array of length \(L\).

type h:

np.ndarray

param mode:

Convolution mode passed to scipy.signal.convolve. Default is "full" (output length \(N + L - 1\)).

type mode:

Literal[“full”, “same”, “valid”], optional

param name:

The name of the channel instance. Default is "fir".

type name:

str, optional

References

J. G. Proakis, M. Salehi, Digital Communications, 5th ed., McGraw-Hill, 2008, Section 9.3 (channels with ISI).

Examples

>>> channel = FIRChannel(np.array([1.0, 0.5]))
>>> print(channel(np.array([1.0, 0.0, 2.0])))
[1.  0.5 2.  1. ]
info() → dict[str, Any][source]#

What this channel is, as a dictionary.

The figures an engineer reads off a tap vector before deciding anything: how long it is, how much of the energy sits in the first path, and how far the frequency response swings between its best and its worst point – the number that says whether one equalizer tap will do.

Returns:

dict of str to object

Examples

>>> info = FIRChannel(np.array([1.0, 0.0, 0.5])).info()
>>> info["n_taps"], round(info["peak_to_notch_dB"], 2)
(3, 9.54)
plot(domain: Literal['impulse', 'frequency'] = 'impulse', *, scale: Literal['linear', 'dB'] = 'linear', fs: float | None = None, ax: Axes | None = None)[source]#

Draw the impulse response, or the frequency response of it.

See plot_channel_response().

class TappedDelayLineChannel(profile: PowerDelayProfile, *, fs: float, f_doppler: float = 0.0, seed: int | None = None, name: str = 'fading')[source]#

Time-varying multipath fading channel (decision D43).

Signal Model#

The received signal is the sum of the \(L\) resolvable paths of a PowerDelayProfile, each weighted by its own fading process:

\[y[n] = \sum_{l=0}^{L-1} a_l[n] \, x[n - d_l], \qquad \mathbb{E}\left[|a_l[n]|^2\right] = \gamma_l\]

where \(d_l = \mathrm{round}(\tau_l f_s)\) is the path delay in samples and \(\gamma_l\) its normalized power. Each \(a_l[n]\) is an independent Rayleigh process with the profile’s Doppler spectrum (rayleigh_process()), so the channel varies within a single call: this is a time-selective as well as frequency-selective model, not block fading. Setting f_doppler=0 recovers block fading, one draw per call.

When the profile declares a Rice factor \(K\), the first path carries a specular component:

\[a_0[n] = \sqrt{\gamma_0}\left(\sqrt{\frac{K}{K+1}} \, e^{j \phi} + \sqrt{\frac{1}{K+1}} \, g[n]\right)\]

where \(g[n]\) is the unit-power Rayleigh process of the first path, so that \(\mathbb{E}[|a_0[n]|^2] = \gamma_0\) still holds.

The output keeps the input length: the delayed copies are zero-padded at the start, i.e. the channel is causal and the transient is kept.

Axes: axis -1 – expects a 1D serial signal (N,).

param profile:

Delay profile, from the catalog or hand-built.

type profile:

PowerDelayProfile

param fs:

Sampling frequency \(f_s\) in Hz; sets the mapping from the profile’s delays in ns onto the sample grid.

type fs:

float, keyword-only

param f_doppler:

Maximum Doppler \(f_D\) in Hz. Default 0.0 (block fading).

type f_doppler:

float, optional, keyword-only

param seed:

Local RNG seed; overridden by chain seeding (Sequential.seed).

type seed:

int, optional, keyword-only

param name:

Name of the channel instance. Default "fading".

type name:

str, optional, keyword-only

h_#

Realized time-varying impulse response of the last run, shape (L, N) – data-dependent, hence the trailing underscore (D23). Row l is \(a_l[n]\); the matching delays are delays_.

Type:

np.ndarray

delays_#

Path delays in samples used for the last run.

Type:

np.ndarray

raises ShapeError:

If the input is not 1D, or is shorter than the profile’s longest path at this sampling rate.

References

J. G. Proakis, M. Salehi, Digital Communications, 5th ed., McGraw-Hill, 2008, Section 13.1; W. C. Jakes, Microwave Mobile Communications, Wiley, 1974, Chapter 1.

Examples

>>> from comnumpy.core.fading import get_delay_profile
>>> channel = TappedDelayLineChannel(get_delay_profile("EVA"),
...                                  fs=15.36e6, seed=1)
>>> y = channel(np.ones(1000, dtype=complex))
>>> y.shape, channel.delays_.tolist()[:4]
((1000,), [0, 2, 5, 6])
>>> channel.h_.shape          # one fading process per resolvable path
(8, 1000)
impulse_response(n_samples: int | None = None) → ndarray[source]#

Draw one realization and return it as a tap vector.

Signal Model#

Sounding a channel with an impulse is how its response is measured, and it is what a script needs whenever a known channel is wanted: the equalizers of LinearEqualizer and FrequencyDomainEqualizer take a tap vector, not a fading process. This does exactly that – feeds \(\delta[n]\) through forward() and returns the first \(L\) samples, with \(L\) one more than the longest path in samples.

The result is dense: paths that fall between the resolvable delays of this sampling rate appear as zeros, so the vector can be handed straight to a convolution.

This draws a new realization and advances the generator, the way calling the block does. Seed it (or the chain) to get the same channel twice.

Axes: returns 1D (L,).

param n_samples:

Length of the sounding record. Defaults to eight times the longest path, which is enough for the profile to fit and for a Doppler process to be sampled meaningfully. Only the first \(L\) samples are returned either way.

type n_samples:

int, optional

returns:

np.ndarray – Complex tap vector of length delays_[-1] + 1.

Examples

>>> from comnumpy.core.fading import get_delay_profile
>>> channel = TappedDelayLineChannel(get_delay_profile("EPA"),
...                                  fs=7.68e6, seed=18)
>>> h = channel.impulse_response()
>>> h.shape                  # 410 ns of spread at 7.68 MHz
(4,)
>>> bool(np.all(h[channel.delays_] == channel.h_[:, 0]))
True
info() → dict[str, Any][source]#

What this channel is, as a dictionary.

Everything the profile fixes, plus what this sampling rate makes of it: a delay spread in nanoseconds becomes a number of taps, and a coherence bandwidth is what decides whether a subcarrier sees a flat channel.

Returns:

dict of str to object

Examples

>>> from comnumpy.core.fading import get_delay_profile
>>> info = TappedDelayLineChannel(get_delay_profile("EPA"),
...                               fs=7.68e6).info()
>>> info["standard"], info["n_paths"], info["n_taps"]
('EPA', 7, 4)
>>> round(info["rms_delay_spread_ns"]), round(info["fs_Hz"] / 1e6, 2)
(43, 7.68)
plot(domain: Literal['impulse', 'frequency'] = 'impulse', *, scale: Literal['linear', 'dB'] = 'linear', ax: Axes | None = None)[source]#

Draw one realization, in delay or in frequency.

A fading channel has no single impulse response, so this draws the last one realized if the block has been run, and a fresh one otherwise – which advances the generator, exactly as impulse_response() does.

See plot_channel_response().