rfgen.propagation

The rfgen.propagation module ships the single Group.CHANNEL slot: the BaseChannelPropagation ABC, the default pure-torch AWGNChannel concrete, and six real Sionna-backed concretes gated behind the rfgen[sionna] extra.

Scientific validation

The AWGN propagation concrete has been scientifically validated against the QPSK BER round-trip from Proakis-Salehi, Digital Communications 5e (eq. 8.2-20). See:

The report covers construct validity, mathematical correctness against cited equations, empirical comparison to published reference numbers, literature grounding, experimental methodology, operating envelope, and documented limitations.

Module surface

import rfgen.propagation as prop

# Default: pure-torch AWGN, always available.
channel = prop.AWGNChannel(snr_db=20.0)

# Sionna-backed concretes: zero-arg construction. Five of the six
# lazy-import the underlying Sionna module at construction and raise
# BackendUnavailableError if `sionna` is not installed.
tdl = prop.SionnaTDL()

# SionnaRT is the one exception: its constructor does NOT import Sionna
# (it validates the typed ChannelContext geometry inside apply() first),
# so constructing it never requires the sionna extra.
rt = prop.SionnaRT()

The five 3GPP concretes (SionnaCDL, SionnaTDL, SionnaUMa, SionnaUMi, SionnaRMa) raise BackendUnavailableError at instantiation when sionna is not installed (NOT at module import), satisfying the cold-import contract: import rfgen.propagation never forces Sionna to load. SionnaRT defers that gate to apply().

Class index

Class

Status

Backend

BaseChannelPropagation

abc

Layer 3 ABC for the Group.CHANNEL slot; pins transformation = Transformation.PROPAGATION

AWGNChannel

concrete

Pure-torch additive Gaussian noise; default backend, always available

SionnaRT

concrete

sionna.rt; differentiable ray tracing

SionnaCDL

concrete

sionna.phy.channel.tr38901.CDL

SionnaTDL

concrete

sionna.phy.channel.tr38901.TDL

SionnaUMa

concrete

sionna.phy.channel.tr38901.UMa

SionnaUMi

concrete

sionna.phy.channel.tr38901.UMi

SionnaRMa

concrete

sionna.phy.channel.tr38901.RMa

An internal helper load_tr_38_901_table(table_name: str) -> numpy.ndarray is used by the TR 38.901 byte-equality contract test. It loads one TR 38.901 parameter table from the checked-in CSV blob shipped at rfgen.propagation.TR_38_901_DATA_DIR and raises FileNotFoundError when a table is missing. It is deliberately excluded from rfgen.propagation.__all__: it is a test-only helper, not part of the public API.


class rfgen.propagation.BaseChannelPropagation

class BaseChannelPropagation(BaseChannel):
    transformation: ClassVar[Transformation] = Transformation.PROPAGATION

    @abstractmethod
    def apply(self, signal: Signal, ctx: ChannelContext) -> Signal: ...

Layer 3 ABC for the single Group.CHANNEL slot. Concrete subclasses model path loss, multipath, shadowing, Doppler, or pure additive noise. The ABC pins transformation = Transformation.PROPAGATION (integer value 21); subclasses inherit the pin.


class rfgen.propagation.AWGNChannel

class AWGNChannel(BaseChannelPropagation):
    def __init__(self, *, snr_db: float = 20.0) -> None: ...

Pure-torch additive white Gaussian noise channel; the default backend that always ships. Adds complex Gaussian noise such that the realised SNR equals the snr_db argument against the input signal’s measured power.

Math

The per-rail variance is sigma**2 = signal_power / (2 * SNR_linear) (Sklar, Digital Communications, 2nd ed., Prentice-Hall, 2001, Ch. 3).

Behaviour

  • The metadata snr_db is updated post-call to the requested value.

  • Signals with mean power below 1e-12 receive noise computed from the floor rather than the actual power; the SNR contract does not hold in that sub-floor regime. The floor exists to prevent division by zero on all-zero inputs.

  • AWGNChannel applies SNR directly; it does NOT implement the Friis cascaded noise-figure equation or antenna-temperature conversion. For Friis-based receiver noise see the LinearLNANoise concrete on the RX Frontend page.

Constructor parameters

Name

Type

Required

Default

Description

snr_db

float

no

20.0

Target post-noise SNR in dB. The realised noise variance is computed from the input signal’s mean power against this target.

Method: apply

apply(signal, ctx) reads ctx.rng for the noise draws (single fused torch.randn(2, n, ...) call) and returns a new Signal with noise-augmented IQ and metadata.snr_db = self.snr_db. Appends a TransformationLogEntry with name="AWGNChannel", group=Group.CHANNEL.value, transformation=Transformation.PROPAGATION.value, params={"snr_db": self.snr_db}.


Sionna-backed propagation backends

The six Sionna concretes share a common shape: each has a zero-argument constructor. schema() reports EmptyParams (no discoverable config leaves), so the constructors accept no **kwargs; a typo’d config key fails loudly at Hydra/YAML validation rather than being silently swallowed. Solver knobs are carried through ChannelContext.rt_solver_params, not through constructor arguments: SionnaRT reads a RTSolverConfig, the other five read a StatisticalSolverConfig.

All six run a real Sionna solve on apply() – none are stubs. SionnaCDL, SionnaTDL, SionnaUMa, SionnaUMi, SionnaRMa lazy-import sionna at construction via the internal _SionnaPropagationBase.__init__. SionnaRT is the one exception: its constructor does NOT import Sionna (it validates the typed ChannelContext geometry inside apply() first).

The Sionna shim layer is _SionnaPropagationBase, an internal abstract subclass not listed in __all__. SionnaUMa/SionnaUMi/SionnaRMa additionally share _SionnaSystemLevelBase (topology-based, no scene); SionnaTDL/SionnaCDL share _SionnaLinkLevelBase (no topology at all). Every one of the six reuses the SAME discrete-time channel conversion helper (_synthesize_time_domain_iq, internal), so the CIR-to-IQ DSP is written and tested once, not once per backend.

class rfgen.propagation.SionnaRT

class SionnaRT(_SionnaPropagationBase):
    _sionna_path = "sionna.rt"
    requires_geometry: ClassVar[bool] = True
    solver_backend: ClassVar[str] = "sionna-rt"

    def __init__(self) -> None: ...

Ray-traced propagation backed by sionna.rt, including distance attenuation, reflection, diffraction, scattering, delays, and angles. Requires the rfgen[sionna] extra to run apply(). Unlike the five 3GPP concretes, SionnaRT.__init__ does NOT import Sionna: it validates the typed ChannelContext geometry inside apply() before importing the backend, so malformed scene wiring fails without the optional extra installed. The apply() solve returns the authoritative GeometryProvenance (real tx_array_id / rx_array_id, asset hashes, backend versions) on the propagated signal’s metadata.

class rfgen.propagation.SionnaUMa

class SionnaUMa(_SionnaSystemLevelBase):
    _sionna_path = "sionna.phy.channel.tr38901.UMa"
    _model_name = "UMa"
    _requires_o2i = True
    solver_backend: ClassVar[str] = "sionna-uma"

    def __init__(self) -> None: ...

3GPP TR 38.901 Urban Macro scenario. Real statistical propagation: builds a one-UT/one-BS network topology from ctx.tx_pose/ctx.rx_params.rx_pose and Sionna’s PanelArray (from StatisticalSolverConfig.ut_array/bs_array), then calls sionna.phy.channel.tr38901.UMa for a real channel realization.

from rfgen.config.scene import PanelArraySpec, StatisticalSolverConfig
from rfgen.propagation import SionnaUMa

solver = StatisticalSolverConfig(
    direction="downlink",
    o2i_model="low",
    bs_array=PanelArraySpec(num_rows_per_panel=4, num_cols_per_panel=4),
)
# ctx.tx_pose / ctx.rx_params.rx_pose come from the scene composer's placed
# TX/RX poses; solver is threaded through ctx.rt_solver_params.
out = SionnaUMa().apply(signal, ctx)
out.metadata.extras["statistical_channel"]
# {"num_paths": 24, "carrier_frequency_hz": 3.5e9, "direction": "downlink",
#  "model": None, "o2i_model": "low", "cir_a_shape": (1, 1, 1, 1, 24, 1),
#  "cir_tau_shape": (1, 1, 24), "tap_l_min": -6, "tap_l_max": 9,
#  "dominant_path_delay_s": 2.37e-07, "dominant_path_gain_linear": 3.82e-06}

direction ("downlink" default or "uplink") decides which pose plays UT vs. BS. o2i_model (outdoor-to-indoor loss) is forwarded unchanged; RMa (below) has no such knob. aoa_deg/aod_deg are deliberately absent from extras["statistical_channel"]: Sionna’s per-path Rays cluster/sub-ray axes do not decompose 1:1 onto cir_a’s num_paths axis, so there is no clean dominant-path angle the way SionnaRT’s rt_channel.aoa_deg provides.

class rfgen.propagation.SionnaUMi

class SionnaUMi(_SionnaSystemLevelBase):
    _sionna_path = "sionna.phy.channel.tr38901.UMi"
    _model_name = "UMi"
    _requires_o2i = True
    solver_backend: ClassVar[str] = "sionna-umi"

    def __init__(self) -> None: ...

3GPP TR 38.901 Urban Micro / street-canyon scenario. Same topology/config contract as SionnaUMa above.

class rfgen.propagation.SionnaRMa

class SionnaRMa(_SionnaSystemLevelBase):
    _sionna_path = "sionna.phy.channel.tr38901.RMa"
    _model_name = "RMa"
    _requires_o2i = False
    solver_backend: ClassVar[str] = "sionna-rma"

    def __init__(self) -> None: ...

3GPP TR 38.901 Rural Macro scenario. Same topology/config contract as SionnaUMa, except RMa has no outdoor-to-indoor loss model: StatisticalSolverConfig.o2i_model is ignored.

class rfgen.propagation.SionnaTDL

class SionnaTDL(_SionnaLinkLevelBase):
    _sionna_path = "sionna.phy.channel.tr38901.TDL"
    _model_name = "TDL"
    solver_backend: ClassVar[str] = "sionna-tdl"

    def __init__(self) -> None: ...

3GPP TR 38.901 TDL (tapped-delay-line) model. Real statistical propagation with no network topology at all – only a scenario letter, a delay spread, and a carrier frequency.

from rfgen.config.scene import StatisticalSolverConfig
from rfgen.propagation import SionnaTDL

solver = StatisticalSolverConfig(model="A", delay_spread_s=100e-9)
out = SionnaTDL().apply(signal, ctx)  # ctx.rt_solver_params = solver
out.metadata.extras["statistical_channel"]["model"]  # "A"

TDL has no antenna-array or direction concept (single-antenna, direction-agnostic by construction); StatisticalSolverConfig.ut_array/bs_array/direction are ignored.

class rfgen.propagation.SionnaCDL

class SionnaCDL(_SionnaLinkLevelBase):
    _sionna_path = "sionna.phy.channel.tr38901.CDL"
    _model_name = "CDL"
    solver_backend: ClassVar[str] = "sionna-cdl"

    def __init__(self) -> None: ...

3GPP TR 38.901 CDL (clustered-delay-line) model. Same no-topology contract as SionnaTDL, but accepts direction and reuses StatisticalSolverConfig.ut_array/bs_array for its antenna arrays – Sionna’s own ut_array=None/bs_array=None CDL defaults do not work against the installed Sionna version (tx_array.num_ant raises on None internally), so real arrays are always supplied.


TR 38.901 data helper

rfgen.propagation.TR_38_901_DATA_DIR: pathlib.Path
rfgen.propagation.load_tr_38_901_table(table_name: str) -> numpy.ndarray

TR_38_901_DATA_DIR (exported in __all__) points at the checked-in CSV blob (under src/rfgen/data/tr_38_901/). load_tr_38_901_table(name) is an internal, test-only helper (not in __all__) that loads one parameter table from that directory using numpy.loadtxt(..., delimiter=",", dtype=numpy.float64). Raises FileNotFoundError when a table is missing so the contract test can pytest.skip cleanly.


See Also

  • Channels: Layer 2 ABC, the Transformation enum, the per-call ChannelContext.

  • RX Frontend: the receiver-side concretes (including LinearLNANoise, which implements thermal noise via the kTBF formula for an integrated receiver-noise model).

  • Concepts / Channels: mental model and data flow for the 4-group pipeline.