Scene Composition Algorithm

The 10-step algorithm executed by DefaultSceneComposer plus per-strategy specifications for frequency placement, time placement, SNR, overlap, and multi-RX. This page is the implementation spec for BaseSceneComposer.build() (API Reference).

The 10-step algorithm

The channel argument is a union of BaseChannel and ChannelPipeline. DefaultSceneComposer normalizes this union before execution: if the caller passes a bare BaseChannel, the composer wraps it as a one-transformation ChannelPipeline; if the caller already passes a pipeline, the composer uses it as-is. All later steps on this page refer to that normalized pipeline. The normalized pipeline spans all 14 transformations, and the composer splits it by group tag at runtime: members whose transformation.value // 10 in (1, 2) are pre-sum (TX impairments and channel propagation); members whose transformation.value // 10 in (3, 4) are post-sum (RX capture + RX hardware). Each transformation receives a ChannelContext carrying the per-call emitter metadata, receiver parameters, scene ID, sample index, and RNG.

def build(
    self,
    *,
    scene_cfg: SceneConfig,
    emitter_pool: Mapping[str, BaseEmitter],
    channel: BaseChannel | ChannelPipeline,
    rng: torch.Generator,
) -> Signal:
    # Step 1: scene-level parameter draw
    bw_hz       = scene_cfg.bandwidth_hz
    fs_hz       = scene_cfg.sample_rate_hz             # >= 2 * bw_hz
    duration    = scene_cfg.duration_samples
    receivers   = resolve_receivers(scene_cfg)         # explicit list, geometry preset, or 1 default RX
    num_rx      = len(receivers)
    overlap_pol = scene_cfg.overlap_policy
    snr_dist    = scene_cfg.snr_distribution
    chunked     = (duration * 8 / 1e6 > scene_cfg.chunk_threshold_mb)
    scene_id    = derive_scene_id(scene_cfg, rng)

    # Normalize a bare BaseChannel to a one-transformation pipeline, then
    # partition the normalized pipeline once and apply each partition at the right stage.
    # Transformation.value // 10 maps to Group (1=TX, 2=CHANNEL, 3=RX_CAPTURE, 4=RX_HARDWARE).
    pipeline = normalize_channel_pipeline(channel)
    tx_transforms   = [t for t in pipeline.chain if t.transformation.value // 10 == 1]
    prop_transform  = next(t for t in pipeline.chain if t.transformation.value // 10 == 2)
    rx_transforms   = [t for t in pipeline.chain if t.transformation.value // 10 in (3, 4)]

    # Step 2: number of emitters
    density = scene_cfg.signal_density              # emitters per MHz
    if scene_cfg.num_signals_mode == "fixed":
        n = rng.integers(scene_cfg.num_signals_min, scene_cfg.num_signals_max + 1)
    elif scene_cfg.num_signals_mode == "poisson":
        n = max(1, int(rng.poisson(density * (bw_hz / 1e6))))

    # Steps 3-7: per-slot composition
    component_signals = []
    master_iq = torch.zeros((num_rx, duration), dtype=torch.complex64)
    # If chunked is True, the implementation later annotates metadata with a
    # ChunkedSignal geometry descriptor but still materializes this full buffer.

    placed = []
    f_planner = make_freq_planner(scene_cfg.freq_placement_strategy, scene_cfg, rng)
    t_planner = make_time_planner(scene_cfg.time_placement_strategy, scene_cfg, rng)

    for slot_idx in range(n):
        slot_rng = derive_rng(rng, "slot", slot_idx)   # per Determinism reference

        # Step 3: sample emitter class + per-emitter config from weighted pool
        emitter, class_label, params = sample_weighted(emitter_pool, scene_cfg, slot_rng)

        # Step 4: invoke emitter, producing clean baseband Signal
        sig = emitter.generate(
            class_label=class_label,
            sample_rate=fs_hz,
            duration_s=scene_cfg.duration_s,
            f_offset_hz=0.0,
            rng=derive_rng(slot_rng, "emit"),
            params=params,
        )

        # Step 5: apply Group.TX transformations per emitter
        # (DAC quantization → PA nonlinearity → TX phase noise → TX IQ imbalance → CFO)
        # Each must run before frequency placement so spectral regrowth is measured
        # at baseband before the emitter is shifted to its scene carrier.
        ctx_tx = ChannelContext(
            emitter_meta=sig.metadata,
            rx_params=ChannelRxParams(            # TX stage; rx fields are unused
                center_freq_hz=0.0, bandwidth_hz=fs_hz,
                sample_rate_hz=fs_hz, noise_figure_db=0.0,
            ),
            scene_id=scene_id,
            sample_idx=slot_idx,
            rng=derive_rng(slot_rng, "tx"),
        )
        for tx_t in tx_transforms:
            sig = tx_t.apply(sig, ctx_tx)

        # Step 6: choose placement: f_c, time, SNR
        f_c      = f_planner.draw(sig, scene_cfg.scene_bandwidth_hz, derive_rng(slot_rng, "freq"), placed=placed)
        t_starts = t_planner.draw(sig, derive_rng(slot_rng, "time"))    # 1 entry, or many for events
        snr_db   = snr_dist.draw(slot_rng)

        # Step 7: upsample to scene SR, freq-shift, check overlap, mix per RX
        sig_shifted = frequency_shift(upsample_to(sig, fs_hz), f_c, fs_hz)
        sig_shifted = sig_shifted.replace(
            metadata=sig_shifted.metadata.replace(
                realized_carrier_hz=f_c, snr_db=snr_db,
            )
        )
        accepted = False
        for t_start in t_starts:
            ok = check_overlap_policy(sig_shifted, t_start, placed, overlap_pol, slot_rng)
            if not ok:
                continue
            accepted = True

            # Apply Group.CHANNEL propagation per (emitter, RX) pair.
            # The propagation transform (Sionna RT/CDL/TDL/…) reads
            # ctx.emitter_meta.realized_carrier_hz and ctx.rx_params for its model.
            for rx_idx in range(num_rx):
                ctx_prop = ChannelContext(
                    emitter_meta=sig_shifted.metadata,
                    rx_params=rx_params_for(scene_cfg, rx_idx),
                    scene_id=scene_id,
                    sample_idx=slot_idx,
                    rng=derive_rng(slot_rng, "channel", rx_idx),
                )
                sig_propagated = prop_transform.apply(sig_shifted, ctx_prop)
                mix_into(master_iq, sig_propagated, t_start, rx_idx)

            placed.append(sig_shifted.replace(
                metadata=sig_shifted.metadata.replace(start_sample=t_start)
            ))

        if accepted:
            component_signals.append(sig_shifted.replace(
                metadata=sig_shifted.metadata.replace(
                    start_sample=t_starts[0],
                    duration_samples=sig_shifted.iq.shape[-1],
                )
            ))

    # Step 8: apply Group.RX_CAPTURE transformations per receiver
    # (RX mixer → IF filter → resampler → LNA noise)
    # Runs on each receiver's summed IQ after all emitters have been mixed in.
    # Step 9: apply Group.RX_HARDWARE transformations per receiver
    # (ADC quantization → RX phase noise → RX IQ imbalance → AGC)
    for rx_idx in range(num_rx):
        rx_sig = Signal(
            iq=master_iq[rx_idx],
            metadata=build_rx_metadata(scene_cfg, rx_idx),
        )
        ctx_rx = ChannelContext(
            emitter_meta=rx_sig.metadata,
            rx_params=rx_params_for(scene_cfg, rx_idx),
            scene_id=scene_id,
            sample_idx=0,
            rng=derive_rng(derive_rng(rng, "rx", rx_idx), "post_sum"),
        )
        for rx_t in rx_transforms:
            rx_sig = rx_t.apply(rx_sig, ctx_rx)
        master_iq[rx_idx] = rx_sig.iq

    # Step 10: emit
    return Signal(
        iq=master_iq.squeeze(0) if num_rx == 1 else master_iq,
        metadata=SceneMetadata(
            duration_s=scene_cfg.duration_s,
            num_emitters=len(component_signals),
            num_rx=num_rx,
            scene_id=scene_id,
            seed=rng_seed_used,
            # ... realized_* audit fields populated from placed/component_signals
        ),
        component_signals=tuple(component_signals),
    )

Algorithm invariants

These are enforced by the algorithm and audited by Test Contracts:

  • Step 5 (Group.TX) runs before step 6 (placement). TX impairments are imprinted at baseband before the emitter is frequency-shifted to its scene carrier. Spectral regrowth from PA nonlinearity is measured at baseband; reversing the order makes the fingerprint frequency-dependent in a non-physical way.

  • Step 7’s upsample preserves spectrum. The emitter typically emits at its native sample rate (e.g., LoRa at 1 Msps for bw=125 kHz). Before frequency-shifting into the scene buffer at scene SR, polyphase-resample to match. Skipping aliases the spectrum and produces wraparound artifacts when f_c is near band edge.

  • Geometry-backed Group.CHANNEL propagation runs per (emitter, RX) pair, never on a summed fictitious transmitter. Each propagation call receives a fresh ChannelContext with tx_pose, rx_params, geometry_asset_refs, and rt_solver_params populated for the target receiver. The backend resolves the real scene/arrays during its solve and returns the authoritative GeometryProvenance on the propagated signal’s metadata; the composer routes that onto the component’s metadata.geometry (falling back to a pre-solve stand-in only when the backend attached none). Statistical propagation may still run on the summed per-RX IQ in scene mode (next section).

  • Steps 8–9 (Group.RX_CAPTURE + Group.RX_HARDWARE) run per receiver on summed IQ. LNA noise is injected once per receiver after all emitter components are mixed in, not once per emitter. This is enforced by the post-sum partition check on ChannelPipeline.

  • component_signals carries metadata in the scene’s reference frame. Each component’s realized_carrier_hz, start_sample, duration_samples are absolute within the scene. The label layer reads these directly.

  • chunked flag is determined by configuration plus a memory-budget heuristic: chunked when predicted IQ tensor exceeds chunk_threshold_mb (default 200 MB).

Channel application mode

scene_config.channel_application (typed as ChannelApplicationMode) selects where the propagation channel runs:

Mode

Behavior

When

scene (default)

One statistical channel realization applied once per receiver to summed master IQ; RT backends still fan out per emitter because they require a concrete TX pose

Deliberate shared-channel approximation or controlled ablation; faster because it makes one statistical-channel call per receiver

per_emitter

Channel realized independently per emitter, applied per-emitter at scene SR, summed after

Emitters with distinct positions, velocities, carrier-dependent parameters, or independently drawn fading; slower because it makes N channel calls per scene

scene mode assigns the same impulse response, including the same multipath and Doppler realization, to every component in the sum. A single receiver does not make that assumption physically valid when the emitters represent distinct links. Use scene only when the experiment intentionally models a common channel response, such as co-located components with the same carrier and motion parameters, or when shared propagation is the controlled approximation being studied. Use per_emitter for deployment-like scenes with independently located or moving transmitters.

This restriction follows the library boundary. Sionna’s time-channel operator filters one input with one time-varying channel response, and its CDL model is explicitly a single-transmitter, single-receiver link parameterized by carrier frequency and user-terminal velocity. As a limiting-case check, the two modes must agree, up to numerical tolerance, when every emitter is forced to reuse the same deterministic linear channel response; they are not expected to agree for independently realized links.

per_emitter mode + SionnaRT + cache_geometry=True benefits hugely from caching: scene geometry is fixed, only emitter carrier varies, the cache holds N solves per band.


Frequency placement strategies

Selected by scene_config.freq_placement_strategy. Each strategy is a concrete subclass of BaseFrequencyPlacement and implements:

def draw(
    self,
    signal: Signal,
    scene_bandwidth_hz: float,
    rng: torch.Generator,
    *,
    placed: Sequence[Signal] = (),
) -> float:
    ...

The placed argument carries Signal objects for previously placed emitters; strategies that need minimum-spacing or forced-overlap logic read placed[i].metadata.realized_carrier_hz from it.

Rejection-sampling contract

All five frequency strategies share this contract:

  • The rejection loop runs at most _MAX_RETRIES = 64 iterations.

  • If no valid candidate is found within the budget, the strategy raises PlacementError whose message names the strategy, the exhausted budget (_MAX_RETRIES), and the configured min_spacing_hz.

  • There is no nearest-feasible fallback.

  • draw never returns a frequency outside [scene.freq_min_hz, scene.freq_max_hz].

IIDUniformFreq

Registered as FrequencyPlacementStrategy.IID_UNIFORM.

def draw(self, signal, scene_bandwidth_hz, rng, *, placed=()) -> float:
    np_rng = numpy_rng_from_torch(rng)
    low, high = _scene_freq_window(scene_bandwidth_hz, self.freq_min_hz, self.freq_max_hz)
    if self.min_spacing_hz == 0.0:
        return float(np_rng.uniform(low, high))
    placed_carriers = [float(p.metadata.realized_carrier_hz) for p in placed]
    for _ in range(_MAX_RETRIES):
        f_c = float(np_rng.uniform(low, high))
        if all(abs(f_c - q) >= self.min_spacing_hz for q in placed_carriers):
            return f_c
    raise PlacementError(...)

Parameters. freq_min_hz (optional), freq_max_hz (optional), min_spacing_hz (default 0.0). Library primitive. numpy.random.Generator.uniform. When. Synthetic-only datasets where realistic spectrum statistics are not the goal; reproduces TorchSig’s default behavior. Evidence tier: TEXTBOOK_STANDARD (Cover & Thomas, Elements of Information Theory Ch 12; maximum-entropy baseline).

StratifiedFreq

Registered as FrequencyPlacementStrategy.STRATIFIED.

def draw(self, signal, scene_bandwidth_hz, rng, *, placed=()) -> float:
    np_rng = numpy_rng_from_torch(rng)
    low, high = _scene_freq_window(scene_bandwidth_hz, self.freq_min_hz, self.freq_max_hz)
    bin_width = (high - low) / float(self.num_bins)
    bin_idx = int(np_rng.choice(self.num_bins, p=self._probabilities))  # uniform if no weights
    bin_low = low + bin_idx * bin_width
    return float(np_rng.uniform(bin_low, bin_low + bin_width))

Parameters. num_bins (default 8), freq_min_hz (optional), freq_max_hz (optional), weights (optional; length must match num_bins). Library primitive. numpy.random.Generator.choice for bin selection, numpy.random.Generator.uniform for within-bin draw. When. Guaranteed spectral spread across the band; benchmarking detectors that struggle with sparse regions. Evidence tier: ENGINEERING_PRIOR (standard variance-reduction convention).

RealisticDensityFreq / ISMRealistic

Registered as FrequencyPlacementStrategy.REALISTIC_DENSITY. ISMRealistic is a documented alias that maps to the same class.

def draw(self, signal, scene_bandwidth_hz, rng, *, placed=()) -> float:
    np_rng = numpy_rng_from_torch(rng)
    taxonomy = signal.metadata.class_taxonomy
    plan, fell_back = self._load_with_fallback(taxonomy)
    # Unknown taxonomy: delegate to IID-uniform fallback; plan.taxonomy == "default".
    if fell_back and plan.taxonomy == "default" and len(plan.channels) <= 1:
        return self._uniform_fallback.draw(signal, scene_bandwidth_hz, rng, placed=placed)
    centers = self._centers_for(plan.taxonomy, plan)
    probs = self._probs_for(plan.taxonomy, plan)
    low, high = _scene_freq_window(scene_bandwidth_hz, None, None)
    for _ in range(_MAX_RETRIES):
        chosen = float(np_rng.choice(centers, p=probs))
        if self.jitter_hz > 0:
            chosen += float(stats.norm.rvs(loc=0.0, scale=self.jitter_hz, random_state=np_rng))
        if low <= chosen <= high:
            return chosen
    raise PlacementError(...)

Parameters. channel_plan_source (optional BaseChannelPlanSource; defaults to JsonChannelPlanSource), jitter_hz (default 0.0). Library primitives. numpy.random.Generator.choice for channel-center selection, scipy.stats.norm.rvs for optional per-draw jitter. Unknown taxonomy. When signal.metadata.class_taxonomy does not match any loaded channel plan, the strategy calls channel_plan_source.load("default") and emits a WARNING-level log entry with event = "placement_unknown_taxonomy" and attributes {"taxonomy": <value>, "strategy": "RealisticDensityFreq"}. It never raises on a missing taxonomy. Evidence tier: REGULATORY_FACT for channel centers (IEEE 802.11-2020, Bluetooth Core 5.4, LoRa Alliance RP002-1.0.5 (April 2024), ICAO Annex 10 Vol IV); ENGINEERING_PRIOR for the shipped per-channel weights.

Channel-plan source plug-in mechanism

The channel-plan source is an extension point. The framework resolves it through EntryPointRegistry under the entry-point group rfgen.channel_plan_sources.

The shipped default is JsonChannelPlanSource, which loads JSON files from rfgen/data/channel_plans/<taxonomy>.json via importlib.resources and parses each file with ChannelPlan.model_validate_json. A per-instance _cache dict avoids re-reading the same file on every draw.

BaseChannelPlanSource.load(taxonomy: str) -> ChannelPlan is the abstract method. Subclasses return a fully validated ChannelPlan instance. If the channel-plan file is missing, JsonChannelPlanSource raises rfgen.errors.IOError with context fields {"taxonomy": ..., "filename": ...}.

ChannelPlan is a frozen Pydantic v2 model with fields:

Field

Type

Constraint

taxonomy

str

non-empty

channels

tuple[Channel, ...]

ascending by center_freq_hz, len >= 1

source

str

non-empty; cites the spec section

Channel is a frozen Pydantic v2 model with fields center_freq_hz: float, bandwidth_hz: float, prior_weight: float. Locking these three values together in one record, rather than three parallel arrays, makes the index-locked-sampling contract visible directly in the schema: one channel is one (center, bandwidth, weight) tuple, never mixed across indices.

A model_validator enforces that channels is ascending by center_freq_hz and that sum(channel.prior_weight for channel in channels) > 0.

Shipped channel plans

Taxonomy

Channels

Source

wifi-2.4ghz

14 channels (5 MHz spacing); canonical non-overlapping set {1, 6, 11} receive higher weights

IEEE 802.11-2020 Table 17-9

wifi-5ghz

UNII-1/2A/2C/3 channel sets

IEEE 802.11-2020 Tables 17-12 / 17-13

ble

40 channels (2 MHz spacing), channels 0 / 12 / 39 are advertising channels

Bluetooth Core Specification 5.4 Vol 6 Part B §1.4

lora-us915

US 915 MHz sub-band channels

LoRa Alliance RP002-1.0.5 (April 2024) Table 2-3

lora-eu868

EU 868 MHz channels

LoRa Alliance RP002-1.0.5 (April 2024) Table 2-7

adsb-1090mhz

Single center at 1090 MHz

ICAO Annex 10 Volume IV Chapter 3.1.2.8.1.1

ClusteredFreq

Registered as FrequencyPlacementStrategy.CLUSTERED.

def draw(self, signal, scene_bandwidth_hz, rng, *, placed=()) -> float:
    np_rng = numpy_rng_from_torch(rng)
    anchor = float(np_rng.choice(self._anchors_np, p=self._probabilities))
    jitter_std = self.jitter_hz if self.jitter_hz is not None else 0.05 * signal.metadata.bandwidth_hz
    candidate = anchor + float(stats.norm.rvs(loc=0.0, scale=jitter_std, random_state=np_rng))
    # ... rejection-sample against scene window up to _MAX_RETRIES
    return candidate

Parameters. anchors_hz (sequence of floats; required), anchor_weights (optional; same length as anchors_hz), jitter_hz (optional; defaults to 5 % of emitter bandwidth_hz). Library primitives. numpy.random.Generator.choice for anchor selection, scipy.stats.norm.rvs for jitter. When. Mimics commercial deployments where emitters cluster at LTE bands, Wi-Fi channels, or ADS-B 1090 MHz. Evidence tier: ENGINEERING_PRIOR (anchor-plus-jitter is a canonical convention; specific anchor values come from standards but the placement model is conventional).

ForcedOverlap

Registered as FrequencyPlacementStrategy.FORCED_OVERLAP.

def draw(self, signal, scene_bandwidth_hz, rng, *, placed=()) -> float:
    np_rng = numpy_rng_from_torch(rng)
    if placed and float(np_rng.uniform()) < self.p_force:
        target = placed[-1]
        target_carrier = float(target.metadata.realized_carrier_hz)
        half = target.metadata.bandwidth_hz / 2.0
        # Constrain to intersection of target's occupied band and scene window.
        low = max(target_carrier - half, scene_low)
        high = min(target_carrier + half, scene_high)
        # ... rejection-sample up to _MAX_RETRIES; raise PlacementError on exhaustion
        return float(np_rng.uniform(low, high))
    return self._fallback.draw(signal, scene_bandwidth_hz, rng, placed=placed)

Parameters. p_force ([0.0, 1.0]; default 1.0), freq_min_hz (optional), freq_max_hz (optional). Library primitive. numpy.random.Generator.uniform. When. Stress-testing detectors and segmenters; producing deliberate cochannel collision training examples. Evidence tier: ENGINEERING_PRIOR (adversarial-training convention; no external citation).


Time placement strategies

Selected by scene_config.time_placement_strategy. Each strategy is a concrete subclass of BaseTimePlacement and implements:

def draw(self, signal: Signal, rng: torch.Generator) -> list[int]:
    ...

Returns one start-sample index for atomic emitters, or many for periodic events. Indices are zero-based, counted in the scene’s reference sample rate.

IIDUniformTime

Registered as TimePlacementStrategy.IID_UNIFORM.

def draw(self, signal, rng) -> list[int]:
    np_rng = numpy_rng_from_torch(rng)
    duration_samples = int(signal.metadata.duration_samples)
    max_start = max(0, self.scene_duration_samples - duration_samples)
    start = int(np_rng.uniform(0, max_start + 1))
    return [min(start, max_start)]

Parameters. scene_duration_samples (required). Library primitive. numpy.random.Generator.uniform. Evidence tier: TEXTBOOK_STANDARD (Cover & Thomas, Elements of Information Theory Ch 12; maximum-entropy onset).

EventRadarPRI

Registered as TimePlacementStrategy.EVENT_RADAR_PRI.

def draw(self, signal, rng) -> list[int]:
    np_rng = numpy_rng_from_torch(rng)
    pri_n = max(1, int(self.pri_seconds * self.sample_rate_hz))
    t = self.start_offset_samples if self.start_offset_samples is not None \
        else int(np_rng.uniform(0, pri_n))
    starts = []
    while t + duration_samples < self.scene_duration_samples:
        jit = int(stats.norm.rvs(0.0, self.jitter_s * self.sample_rate_hz, random_state=np_rng)) \
              if self.jitter_s > 0 else 0
        starts.append(max(0, t + jit))
        t += pri_n
    return starts if starts else [max(0, min(t, self.scene_duration_samples - 1))]

Parameters.

Parameter

Type

Range

Default

Notes

pri_seconds

float

> 0

required

Surveillance: 1 ms; tracking: 100 µs; LPI: 10 µs

sample_rate_hz

float

> 0

required

scene_duration_samples

int

>= 1

required

jitter_s

float

>= 0

0.0

Per-pulse Gaussian jitter std in seconds

start_offset_samples

int | None

None

Fixed phase offset; random uniform over [0, pri_n) if unset

Library primitive. scipy.stats.norm.rvs for per-pulse jitter. Evidence tier: TEXTBOOK_STANDARD (Richards, Fundamentals of Radar Signal Processing Ch 1.4; Skolnik, Introduction to Radar Systems § 3.6).

EventPeriodicBeacon

Registered as TimePlacementStrategy.EVENT_PERIODIC_BEACON.

def draw(self, signal, rng) -> list[int]:
    np_rng = numpy_rng_from_torch(rng)
    period_n = max(1, int(self.period_seconds * self.sample_rate_hz))
    phase = int(np_rng.uniform(0, period_n))
    starts = []
    t = phase
    while t + duration_samples < self.scene_duration_samples:
        starts.append(t)
        t += period_n
    return starts if starts else [min(phase, max(0, self.scene_duration_samples - 1))]

Parameters. period_seconds (> 0; required), sample_rate_hz (> 0; required), scene_duration_samples (>= 1; required). Library primitive. numpy.random.Generator.uniform for phase selection.

Reference cadences.

Protocol

Typical period

Reference

Wi-Fi beacon (TBTT)

102.4 ms

IEEE 802.11-2020 §11.1.3

BLE advertising

20 ms – 10.24 s

Bluetooth Core Specification 5.4 Vol 6 Part B §4.4.2.2

ADS-B Mode-S squitter

~0.5 – 1 s

ICAO Annex 10 Volume IV Chapter 3.1.2.8.1.1

Cellular SSB

5 / 10 / 20 / 40 / 80 / 160 ms

3GPP TS 38.213

Evidence tier: REGULATORY_FACT (IEEE 802.11-2020 § 11.1.3 for Wi-Fi TBTT; Bluetooth Core 5.4 Vol 6 § 4.4.2 for BLE advertising; 3GPP TS 38.213 § 4.1 for NR SSB; ICAO Annex 10 Vol IV § 3.1.2.8 for ADS-B).

EventBurst

Registered as TimePlacementStrategy.EVENT_BURST.

def draw(self, signal, rng) -> list[int]:
    np_rng = numpy_rng_from_torch(rng)
    starts, t = [], 0
    while t < self.scene_duration_samples:
        on_ms = min(self.on_max_ms, self.on_min_ms * float(stats.pareto.rvs(self.on_alpha, random_state=np_rng)))
        off_ms = min(self.off_max_ms, self.off_min_ms * float(stats.pareto.rvs(self.off_alpha, random_state=np_rng)))
        on_n = int(on_ms * 1e-3 * self.sample_rate_hz)
        off_n = int(off_ms * 1e-3 * self.sample_rate_hz)
        if t + on_n < self.scene_duration_samples:
            starts.append(t)
        t += on_n + off_n
    return starts

Parameters.

Parameter

Type

Default

Notes

sample_rate_hz

float > 0

required

scene_duration_samples

int >= 1

required

on_alpha

float > 0

1.5

Pareto shape for on-duration

off_alpha

float > 0

1.2

Pareto shape for off-duration

on_min_ms

float > 0

5.0

Minimum on-duration in milliseconds

off_min_ms

float > 0

10.0

Minimum off-duration in milliseconds

on_max_ms

float > 0

500.0

Maximum on-duration in milliseconds

off_max_ms

float > 0

5000.0

Maximum off-duration in milliseconds

Library primitive. scipy.stats.pareto.rvs for both on- and off-duration draws. Evidence tier: PEER_REVIEWED_RESEARCH (Willinger, Taqqu, Sherman, Wilson 1997, IEEE/ACM Transactions on Networking 5(1), 71–86, doi:10.1109/90.554723; heavy-tailed on/off traffic).

EventBurstSelfExciting

Registered as TimePlacementStrategy.EVENT_BURST_SELF_EXCITING; entry-point key event_burst_self_exciting. Self-exciting point process: prior events increase the probability of subsequent events, capturing temporal correlation (packet cascades, retransmit storms) that heavy-tailed marginals alone miss.

def draw(self, signal, rng) -> list[int]:
    # Ogata thinning for a Hawkes process with exponential kernel:
    #     λ(t) = μ + α · Σ_{t_i < t} exp(-β · (t − t_i))
    # `excitation_strength` is n = α / β, so stationarity requires n < 1.
    mu, n, beta = self.baseline_rate_hz, self.excitation_strength, self.decay_rate_hz
    alpha = n * beta
    starts, excitation, t = [], 0.0, 0.0
    scene_duration_s = self.scene_duration_samples / self.sample_rate_hz
    while t < scene_duration_s:
        lam_max = mu + excitation
        candidate = t + float(np_rng.exponential(1.0 / lam_max))
        if candidate >= scene_duration_s:
            break
        excitation *= math.exp(-beta * (candidate - t))
        if float(np_rng.uniform()) <= (mu + excitation) / lam_max:
            starts.append(int(candidate * self.sample_rate_hz))
            excitation += alpha
        t = candidate
    return starts if starts else [0]

Parameters.

Parameter

Type

Range

Default

Notes

baseline_rate_hz

float

> 0

required

μ; mean event rate absent excitation

excitation_strength

float

[0, 1)

required

Branching ratio n = α / β; must be < 1 for stationarity

decay_rate_hz

float

> 0

required

β; exponential decay of self-excitation

sample_rate_hz

float

> 0

required

scene_duration_samples

int

>= 1

required

Library primitive. Hand-rolled Ogata thinning (Ogata 1981); no direct SciPy equivalent for the exact Hawkes process with exponential kernel. When. Correlated packet bursts, retransmit storms, hierarchical device chattiness where events cluster in time beyond what heavy-tailed marginals produce. Evidence tier: PEER_REVIEWED_RESEARCH (Hawkes 1971, Biometrika 58(1), 83–90, doi:10.1093/biomet/58.1.83).

Custom logic: verify as follows. The scheduler is framework-specific glue around Ogata thinning, because the Python scientific stack used here has no direct sampler for this exact process. test_event_burst_self_exciting_mean_count_matches_exponential_hawkes_known_answer compares the mean event count over fixed independent seeds with the finite-horizon known answer for an empty-history exponential Hawkes process:

[ \mathbb{E}[N(T)] = \frac{\mu}{1-n}\left[T - \frac{n\left(1-e^{-\beta(1-n)T}\right)}{\beta(1-n)}\right], \qquad n=\alpha/\beta. ]

At n = 0, this reduces to the homogeneous Poisson result μT. The test checks both that limiting case and a self-exciting case, so it detects an incorrect branching-ratio conversion or thinning acceptance rule without depending on the implementation’s internal state.

EventFhssHop

Registered as TimePlacementStrategy.EVENT_FHSS_HOP.

def draw(self, signal, rng) -> list[int]:
    np_rng = numpy_rng_from_torch(rng)
    starts, t = [], 0
    while t < self.scene_duration_samples:
        dwell_n = int(np_rng.choice(self.dwell_set))
        if t + dwell_n <= self.scene_duration_samples:
            starts.append(t)
        t += dwell_n
    return starts if starts else [0]

Parameters. dwell_set (tuple of positive ints; required), scene_duration_samples (>= 1; required). Library primitive. numpy.random.Generator.choice over dwell_set.

Reference dwells.

Protocol

Dwell

Hop set

Bluetooth Classic AFH

625 µs

79 × 1 MHz

BLE 5 Coded PHY

1.25 ms per CE

37 channels

Drone OcuSync 2/3

~1 ms

80+ channels in 2.4 / 5.8 GHz

FH radar

10 µs – 1 ms

configurable

Evidence tier: TEXTBOOK_STANDARD (Simon, Omura, Scholtz, Levitt 2001, Spread Spectrum Communications Handbook Ch 4).


SNR and power

Per-emitter SNR distribution

Default: log-uniform in dB across [snr_min_db, snr_max_db] (matches RadioML 2018.01a -20..30 dB):

snr_db ~ Uniform(snr_min_db, snr_max_db)        # already log-scale by definition

Optional heavy-tailed mode adds log-normal shadow-fading:

snr_db = uniform(snr_min, snr_max) - shadow
shadow ~ Normal(0, sigma_shadow_db)             # default 6 (3GPP TR 38.901 macro NLoS)

Noise floor and SNR ↔ absolute-power reconciliation

The composer hands the per-emitter snr_db target to the channel layer. Two reconciliation modes:

  • AWGN(mode="snr_db") scales noise to hit the requested SNR per emitter. Used for RadioML-style target-SNR sanity configs.

  • AWGN(mode="noise_power_dbm") sets noise floor in absolute dBm; Sionna propagation gain plus emitter TX power determine realized SNR. Used when received power should be physically meaningful.

For Sionna RT scenes specifically:

P_tx_watts = 10 ** ((scene.tx_power_dbm - 30) / 10)
h[n, l]    = sum_i a_i(n / W) * sinc(l - W * tau_i)
y[n]       = sqrt(P_tx_watts) * sum_l x[n - l] * h[n, l]
P_rx_watts = mean_n(abs(y[n]) ** 2)
P_rx_dbm   = 10 * log10(P_rx_watts / 1e-3)
SNR_db     = P_rx_dbm - scene.noise_power_dbm

Here a_i and tau_i are Sionna RT’s complex coefficient and delay for path i, W is the channel-tap bandwidth, and x is the emitter IQ normalized to the framework’s unit-power convention. rfgen obtains (a, tau) from Paths.cir(...), calls Sionna’s cir_to_time_channel(...) and ApplyTimeChannel, takes the configured causal output window, and applies the sqrt(P_tx_watts) amplitude scale. The received-power value is therefore measured from the filtered waveform. It is not computed by adding or summing scalar per-ray powers.

Sionna’s Paths contract defines the discrete tap as the coherent sum of delayed complex path coefficients. Its time-channel operator then convolves those taps with the input waveform. The Sionna RT technical report separately defines the path-summary channel gain as sum_i abs(a_i) ** 2. That noncoherent summary is useful for path diagnostics, but it is not a substitute for mean(abs(y) ** 2) when delays, waveform spectrum, and coherent path combination affect the finite output record.

The composer’s per-emitter snr_db target is converted to a TX-power offset that yields the requested SNR on average. Both target and realized are logged.

Per-emitter SNR vs scene SINR

The composer records both:

  • metadata.snr_db: per-emitter SNR scalar. In single-RX scenes it is that receiver’s SNR; in multi-RX scenes it is the worst-case scalar across receivers, with the full per-RX breakdown stored in metadata.extras["snr_db_per_rx"].

  • metadata.extras["sinr_db"]: per-emitter SINR scalar. In single-RX scenes it is that receiver’s SINR; in multi-RX scenes it is the worst-case scalar across receivers, with the full per-RX breakdown stored in metadata.extras["sinr_db_per_rx"]. Measured after composition.

Clean-band emitters: SNR == SINR. Cochannel-overlapped: SINR ≪ SNR. The label layer stores both.


Overlap policy

scene_config.overlap_policy selects:

Mode

Behavior

reject

Reject any draw whose TF rectangle overlaps an existing emitter. After 10× retry budget, accept the smallest-overlap draw.

allow

Allow overlap with probability p_overlap (default 0.3); reject otherwise. Equivalent to TorchSig’s cochannel_overlap_probability.

force

Force at least one cochannel overlap per scene with probability p_force (default 1.0 in forced_overlap configs).

Enforced inside step 7 of the algorithm. Rectangle overlap uses TorchSig’s is_rectangle_overlap helper plus a configurable overlap_margin_hz for near-misses.

scene:
  overlap_policy:
    mode: allow                    # reject | allow | force
    p_overlap: 0.3
    p_force: 0.0
    retry_budget: 10
    margin_hz: 0
    overlap_target_strategy: closest_freq    # for force mode

When the retry budget is exhausted, the composer logs a warning and emits the realized n (which may be less than requested density). The validation harness audits realized vs requested density per shard.


Multi-RX / array geometry

Multi-RX is opt-in via scene.multi_rx. When it is unset, the composer resolves one default receiver and emits single-RX IQ. When scene.multi_rx.receivers is non-empty, those explicit receivers define the per-RX solves. When scene.multi_rx.geometry is set, the shipped composer derives the receiver list from scene.rx_array.

scene:
  multi_rx:
    geometry: ula
  rx_array:
    num_rx: 4
    spacing_lambda: 0.5            # half-wavelength at the scene center frequency

Geometry

Parameters

ula

scene.multi_rx.geometry = ula plus scene.rx_array.num_rx and scene.rx_array.spacing_lambda

explicit list

scene.multi_rx.receivers

Geometry is conveyed to the channel layer via SceneContext.rx_positions. RT consumes it directly; CDL reads it for the antenna-array model.

The multi-RX composer overrides the propagation loop in step 7: instead of a single call, it iterates over receivers and constructs a separate ChannelContext per RX index. The ctx.rx_params carries the per-receiver RF parameters (ChannelRxParams); the propagation transform reads rx_params.position_m for geometry-based models (Sionna RT/CDL).

# Inside step 7, per-emitter propagation loop (multi-RX override)
for rx_idx, rx_params in enumerate(rx_params_list):
    ctx_prop = ChannelContext(
        emitter_meta=sig_shifted.metadata,
        rx_params=rx_params,
        scene_id=scene_id,
        sample_idx=slot_idx,
        rng=derive_rng(slot_rng, "channel", rx_idx),
    )
    sig_propagated = prop_transform.apply(sig_shifted, ctx_prop)
    mix_into(master_iq, sig_propagated, t_start, rx_idx)

AoA labeling

component_signals[i].metadata.extras["aoa_deg"]:

  • SionnaRT: angle of the dominant ray (highest-power ray’s theta, phi).

  • SionnaCDL: configured cluster centroid per profile.

  • iid_uniform AoA in test scenarios: drawn uniformly from [-180°, 180°] and applied as a uniform-array steering vector.


Chunked composition for large scenes

A 1-second wideband recording at 200 Msps is 1.6 GB of complex64. A 5-second recording is 8 GB. Spark workers with 16 GB RAM OOM at this scale.

ChunkedSignal wraps iq as an iterator producing (chunk_offset, chunk_iq):

@dataclass
class ChunkedSignal:
    metadata: SignalMetadata
    component_signals: list[Signal]
    num_rx: int
    duration_samples: int
    chunk_samples: int = 4_194_304        # 4 Msamples ≈ 32 MB at complex64

    def __iter__(self) -> Iterator[tuple[int, torch.Tensor]]: ...
    def materialize(self) -> torch.Tensor: ...   # only safe if total fits in memory

Triggered automatically when duration * 8 / 1e6 > scene.chunk_threshold_mb (default 200 MB). The current implementation records chunk geometry for downstream chunk-aware storage, but still materializes the full IQ buffer in memory.


Validation harness hooks

The composer emits per-scene metrics consumed by Reference / Metrics § Statistical audit:

  • Realized emitter count vs requested (scene.num_emitters vs target)

  • Realized SNR distribution {min, max, mean, p10, p50, p90}

  • Realized class-balance histogram

  • Realized cochannel-overlap rate

  • Realized spectral occupancy fraction

These are recorded in scene.realized_* fields in scene metadata so the audit can run by scanning scene records alone, without touching IQ.

See Also