Source code for chaotic_pfc.comms.channel

"""
channel.py
==========
Transmission-channel models sitting between the chaotic transmitter and
the receiver.

Eight channels are provided:

* :func:`ideal_channel` — noiseless pass-through used to establish a
  best-case baseline for chaos synchronisation.
* :func:`fir_channel` — a symmetric FIR low-pass filter built on top of
  :func:`scipy.signal.firwin`, representing a band-limited physical
  link.
* :func:`awgn` — additive white Gaussian noise.
* :func:`channel_impulsive` — AWGN with Middleton Class-A impulsive noise.
* :func:`channel_multipath` — multipath channel with configurable tap
  delays and gains.
* :func:`channel_interferers` — composite channel (AWGN + DCSK interferer
  + narrow-band WiFi-like interferer), lives in :mod:`chaotic_pfc.comms.dcsk`.
* :func:`channel_urban` — combined urban channel with impulsive noise,
  multipath, and interferers, lives in :mod:`chaotic_pfc.comms.dcsk`.

All accept the transmitted signal ``s`` as a 1-D array and return the
received signal ``r`` of the same length. The FIR channel additionally
returns its filter coefficients so they can be overlaid on the PSD
plots produced by :mod:`chaotic_pfc.plotting`.
"""

from functools import lru_cache

import numpy as np
from numpy.typing import NDArray
from scipy.signal import firwin, lfilter


[docs] def ideal_channel(s: NDArray) -> NDArray: """Transmit ``s`` through a noiseless, distortion-free channel. This is literally a copy of the input. It is still defined as a function (rather than aliased to ``numpy.ndarray.copy``) so that the receiver-side code can always call ``ideal_channel(s)`` consistently regardless of the channel configuration chosen by the caller. Parameters ---------- s Transmitted signal, shape ``(N,)``. Returns ------- ndarray, shape (N,) An independent copy of ``s``. Mutating the output does not affect the input. Implements: :class:`~chaotic_pfc.comms.protocols.Channel`.""" return s.copy()
[docs] def fir_channel( s: NDArray, cutoff: float = 0.99, num_taps: int = 201, window: str = "hamming", ) -> tuple[NDArray, NDArray]: """Transmit ``s`` through a symmetric FIR low-pass channel. The channel is built with :func:`scipy.signal.firwin` using ``pass_zero=True`` (true low-pass) and unit sampling frequency normalisation (``fs=2.0``). The DC gain of the resulting filter is normalised to ``1.0``. Parameters ---------- s Transmitted signal, shape ``(N,)``. cutoff Normalised cutoff frequency ``ω_c / π ∈ (0, 1)``. Defaults to ``0.99`` — i.e. the channel passes almost everything, mimicking a very lightly band-limited link. num_taps Length of the FIR filter in samples. Must be a positive integer. window Window function passed through to ``firwin``. Any name accepted by :func:`scipy.signal.get_window` is valid — common choices are ``"hamming"``, ``"hann"``, ``"blackman"``. Returns ------- r : ndarray, shape (N,) Received signal, ``s`` filtered through the FIR coefficients. h : ndarray, shape (num_taps,) Filter coefficients, useful for overlaying the channel response on PSD plots. Implements: :class:`~chaotic_pfc.comms.protocols.Channel`.""" h = firwin(numtaps=num_taps, cutoff=cutoff, window=window, pass_zero=True, fs=2.0) r = lfilter(h, [1.0], s) return r, h
# ── Channel models ───────────────────────────────────────────────────────────
[docs] def awgn(sig: NDArray, snr_db: float, rng: np.random.Generator | None = None) -> NDArray: """Add white Gaussian noise to *sig* for a given SNR in dB. Parameters ---------- sig Signal samples. snr_db Signal-to-noise ratio in dB. rng Random generator (uses ``np.random.default_rng`` if None). """ if rng is None: rng = np.random.default_rng() p = float(np.mean(sig**2)) / 10 ** (snr_db / 10) return sig + rng.normal(0.0, float(np.sqrt(p)), sig.shape)
[docs] def channel_impulsive( sig: NDArray, snr_db: float, prob_impulso: float = 0.01, amp_fator: float = 10.0, rng: np.random.Generator | None = None, ) -> NDArray: """AWGN channel with Middleton Class-A impulsive noise. Parameters ---------- prob_impulso Probability a sample is hit by an impulse (e.g. 0.01 = 1 %). amp_fator Impulse amplitude in multiples of the signal std. """ if rng is None: rng = np.random.default_rng() rx = awgn(sig, snr_db, rng) std = float(np.std(sig)) mask = rng.random(len(sig)) < prob_impulso rx[mask] += amp_fator * std * rng.choice(np.array([-1.0, 1.0]), mask.sum()) return rx
[docs] def channel_multipath( sig: NDArray, snr_db: float, delays: list[int] | None = None, gains: list[float] | None = None, rng: np.random.Generator | None = None, ) -> NDArray: """Multipath channel with configurable tap delays and gains. Parameters ---------- delays Delay of each path in samples (default: ``[0, 3, 7, 15]``). gains Attenuation per path (default: ``[1.0, 0.6, 0.4, 0.2]``). """ if delays is None: delays = [0, 3, 7, 15] if gains is None: gains = [1.0, 0.6, 0.4, 0.2] N = len(sig) out = np.zeros(N) for d, g in zip(delays, gains, strict=True): if d == 0: out += g * sig else: out[d:] += g * sig[: N - d] out *= float(np.sqrt(np.mean(sig**2))) / (float(np.std(out)) + 1e-12) return awgn(out, snr_db, rng)
def _wifi_interferer( N: int, fc: float = 0.2, bw: float = 0.08, rng: np.random.Generator | None = None ) -> NDArray: """Synthetic narrow-band interferer (noise filtered in a sub-band).""" if rng is None: rng = np.random.default_rng() noise = rng.normal(0, 1, N) h_bp = _wifi_fir(fc, bw) return lfilter(h_bp, 1.0, noise) @lru_cache(maxsize=4) def _wifi_fir(fc: float, bw: float) -> NDArray: """Cached FIR band-pass filter for the WiFi-like interferer.""" return firwin(101, [fc - bw / 2, fc + bw / 2], pass_zero=False)