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 covers all 15 transformations, but not all of them are chain entries. The six whose transformation.group is Group.TX or Group.CHANNEL are the pre-sum chain (TX impairments and channel propagation). The nine receiver transformations (those for which Transformation.is_receiver is true) are not chain entries at all: they are receiver-frontend stages held alongside the chain in receiver_stages, and they run post-sum, first on the receiver’s capture plane (ReceiverStagePlane.CAPTURE) and then on its hardware plane (ReceiverStagePlane.HARDWARE). Each chain transformation receives a ChannelContext carrying the per-call emitter metadata, receiver parameters, scene ID, sample index, and RNG; each receiver stage instead receives a ReceiverStageContext built by the shared ReceiverFrontendChain from the running sample rate and realized carrier.

def build(
    self,
    *,
    scene_cfg: SceneConfig,
    emitter_pool: Mapping[str, BaseEmitter],
    channel: BaseChannel | ChannelPipeline,
    rng: torch.Generator,
) -> Signal:
    # Step 1: validated scene-level parameters.
    bw_hz       = float(scene_cfg.bandwidth_hz)
    fs_hz       = float(scene_cfg.sample_rate_hz)      # >= 2 * bw_hz
    duration_s  = float(scene_cfg.duration_s)
    scene_samples = int(round(fs_hz * duration_s))
    event_duration_s = (
        scene_cfg.event_duration.duration_s
        if scene_cfg.event_duration is not None
        else scene_cfg.duration_s
    )
    receivers   = resolve_receivers(scene_cfg)         # explicit list, geometry preset, or 1 default RX
    num_rx      = len(receivers)
    scene_id    = derive_scene_id(rng)

    # Normalize a bare BaseChannel to a one-entry pipeline.  Its public chain
    # partitions are tx_transforms and propagation; its receiver stages are
    # receiver_stages, split by capture_plane_stages / hardware_plane_stages.
    pipeline = normalize_channel_pipeline(channel)
    mode = scene_cfg.channel_application

    # Step 2: choose a count from DensityConfig.  FIXED uses min_emitters,
    # RANGE is inclusive [min_emitters, max_emitters], and POISSON draws at
    # poisson_rate then clips to those same bounds.
    n_emitters = draw_emitter_count(scene_cfg.density, rng)

    # Steps 3-7: per-slot composition.
    component_signals = []
    master_iq = torch.zeros((num_rx, scene_samples), dtype=torch.complex64)

    placed = []
    f_planner = make_freq_planner(
        scene_cfg.frequency_placement, scene_cfg.frequency_placement_params
    )
    t_planner = make_time_planner(
        scene_cfg.time_placement, scene_cfg.time_placement_params,
        sample_rate_hz=fs_hz, scene_duration_samples=scene_samples,
    )

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

        # Step 3: sample an emitter and one of its supported class labels.
        # The shipped SceneConfig has no emitter-weight distribution.
        emitter_key, emitter, class_label = sample_emitter(emitter_pool, slot_rng)

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

        # 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 pipeline.tx_transforms:
            sig = tx_t.apply(sig, ctx_tx)

        # Step 6: choose a scene-relative frequency offset.  snr_db is an
        # audit label drawn from the selected propagation contract; it is not
        # a SceneConfig distribution field.
        f_offset_hz = f_planner.draw(
            sig, bw_hz, derive_rng(slot_rng, "freq"),
            placed=placed,
        )
        snr_db = draw_snr(pipeline, mode)

        # Step 7a: resample, shift, and stamp realized carrier/SNR *before*
        # propagation preparation: propagation may consume the realized carrier.
        abs_carrier_hz = scene_cfg.center_hz + f_offset_hz
        sig_shifted = frequency_shift(upsample_to_scene_rate(sig, fs_hz), f_offset_hz, fs_hz)
        sig_shifted = stamp_metadata(
            sig_shifted, realized_carrier_hz=abs_carrier_hz, snr_db=snr_db
        )

        # Step 7b: prepare per-emitter/geometry propagation before selecting
        # time starts.  Statistical SCENE propagation remains post-sum.
        prepared = prepare_per_emitter_propagation_if_required(
            sig_shifted, receivers, pipeline, scene_cfg, slot_rng
        )

        # Step 7c: draw starts after resampling.  Strict containment applies
        # to emitted IQ, not any later channel-induced tail.
        t_starts = t_planner.draw(sig_shifted, derive_rng(slot_rng, "time"))
        validate_time_starts(
            t_starts,
            event_samples=sig_shifted.iq.shape[-1],
            scene_samples=scene_samples,
            allow_empty=t_planner.may_return_empty,
        )
        accepted_starts = accepted_time_starts(
            sig_shifted, t_starts, placed, scene_cfg
        )
        for t_start in accepted_starts:
            # Mix propagation results.  A post-propagation tail is clipped at
            # the fixed capture edge; a valid emitted event is never cropped.
            _mix_prepared_propagation(master_iq, prepared, t_start)

            placed_signal = stamp_metadata(sig_shifted, start_sample=t_start)
            placed.append(placed_signal)
            # Components keep pre-propagation IQ and emitted-event duration.
            component_signals.append(stamp_metadata(
                placed_signal,
                start_sample=t_start,
                duration_samples=sig_shifted.iq.shape[-1],
            ))

    # Component labels are finalized from the summed pre-RX buffer.  Then
    # SCENE propagation (when applicable) and the receiver capture and
    # hardware planes run once per receiver, with the joint receiver-background
    # injection between the two planes.  Post-sum propagation tails are
    # clipped to capture.
    component_signals = finalize_component_labels(component_signals, master_iq, pipeline, mode)
    master_iq, realized_rx_metadata = apply_rx_chain(
        master_iq, receivers, pipeline, scene_cfg, scene_id, rng
    )

    # Step 10: emit
    return Signal(
        iq=master_iq.squeeze(0) if num_rx == 1 else master_iq,
        metadata=build_scene_metadata(
            scene_cfg, scene_id, receivers, realized_rx_metadata,
            component_signals, duration_samples=master_iq.shape[-1],
        ),
        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 the relative f_offset_hz is near a scene-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. SionnaRT takes this path even if its configured application mode is scene.

  • Steps 8–9 (the receiver capture plane, then the receiver hardware plane) 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 ChannelPipeline holding the receiver stages outside the pre-sum chain entirely, so no receiver stage can be reached per emitter.

  • 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.

Channel application mode

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

Mode

Behavior

When

scene (default)

A non-geometry backend is applied once per receiver to summed master IQ; geometry-backed propagation is currently fanned out per emitter instead

Default AWGN operation or a future/third-party backend that declares scene-mode support

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

For the intended Sionna configuration, use per_emitter so every realization has a typed TX/RX provenance pair. Current runtime behavior is more specific than the configuration name: SionnaRT declares requires_geometry=True, so the composer silently fans it out per emitter even when configured as scene, and skips the post-sum propagation call. The statistical Sionna backends (UMa, UMi, RMa, TDL, CDL) do not have that flag; configured as scene, they reach application without the required TX pose and raise. This is not validated before generation. SionnaTDL and SionnaCDL do not consume topology in their upstream link-level mathematics; their poses serve rfgen’s provenance contract. Use scene only with a backend that documents scene-mode support; AWGN is the current shipped example.

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.frequency_placement. 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:
    ...

draw returns a scene-relative frequency offset in Hz. With no explicit window bounds, that offset lies in the centred [-scene_bandwidth_hz / 2, +scene_bandwidth_hz / 2] interval. The composer shifts IQ by this offset and records the absolute carrier as scene_cfg.center_hz + f_offset_hz in SignalMetadata.realized_carrier_hz. Thus propagation, component overlap checks, labels, and stored metadata use the absolute-carrier frame.

placed carries previously placed signals, whose realized_carrier_hz is absolute. The current strategy implementations therefore receive absolute metadata while returning relative offsets. Do not treat their use of placed as a frame-consistent minimum-spacing or forced-overlap guarantee: that coordinate conversion is an implementation gap. The composer’s subsequent time-frequency overlap policy is evaluated after the absolute carrier has been recorded and therefore uses a consistent frame.

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 returns an offset inside its configured relative window; with no explicit bounds that window is [-scene_bandwidth_hz / 2, +scene_bandwidth_hz / 2].

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` metadata is absolute while f_offset_hz is relative; see the
    # coordinate-frame limitation above.
    placed_carriers = [float(p.metadata.realized_carrier_hz) for p in placed]
    for _ in range(_MAX_RETRIES):
        f_offset_hz = float(np_rng.uniform(low, high))
        if all(abs(f_offset_hz - q) >= self.min_spacing_hz for q in placed_carriers):
            return f_offset_hz
    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.core.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 is absolute while this method returns a relative
        # offset; this is the current frame mismatch described above.
        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. Each strategy is a concrete subclass of BaseTimePlacement and implements:

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

Atomic emitters normally return one start-sample index and periodic emitters can return many. Indices are zero-based and counted in the scene’s reference sample rate; explicitly empty-capable stochastic schedulers can return no indices as described below.

Length-aware placement contract

Time placement runs after the emitter has been resampled to the scene sample rate. Let event_samples be the resulting IQ length and let scene_samples be the capture length. Every returned start must satisfy:

max_start = scene_samples - event_samples
0 <= start <= max_start

Thus the full generated event is mixed and labelled; scene composition never silently crops an emitter at the capture edge. Built-in strategies use this same max_start bound. A third-party rfgen.time_placement strategy is validated at the composer boundary, and an empty result, a non-integer result, or an out-of-range result raises SceneError rather than being coerced or cropped. The only exception is a strategy that explicitly advertises may_return_empty = True: an empty list is then a valid no-event stochastic realization and that emitter slot contributes no component.

The strategies are custom orchestration glue, not a replacement for a radio protocol stack: they turn a generated signal’s actual scene-rate length into a bounded start-sample schedule. The composer enforces this plug-in boundary; tests/unit/test_scene_composer.py::test_composer_rejects_out_of_bounds_third_party_time_placement and tests/unit/test_scene_composer.py::test_fixed_short_multistart_strategies_keep_bounded_overlapping_cadence verify invalid third-party output and the shipped strategy bounds.

The bound controls where an event may start, not a separate collision policy. Cadence strategies retain all of their valid in-window occurrences, including occurrences whose event footprints overlap. The configured scene overlap policy subsequently decides which candidates are accepted. In particular, the duration policy does not rewrite PRI, beacon, burst, hop, or Hawkes schedules merely to make their events non-overlapping.

For a registry-selected plugin, the composer supplies the scene-owned scene_duration_samples constructor argument and, only when its constructor declares it, sample_rate_hz. Plugins that need the fit bound accept and use scene_duration_samples; users cannot override either injected capture value through time_placement_params. A custom time_planner_factory is instead called without injected arguments and must provide its planner’s context itself. In either case the composer performs the final bound validation. The full plugin interface, including schema() -> type[BaseModel], is documented at BaseTimePlacement.

This is a generic-RF timing capability. A nominal Wi-Fi, BLE, ADS-B, or other standard cadence can parameterize a scheduler, but it does not make the emitted waveform a faithful implementation of that protocol: packet formats, state machines, hopping, retries, and calibrated captures remain outside this contract.

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 = 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)
    duration_samples = int(signal.metadata.duration_samples)
    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 = []
    max_start = self.scene_duration_samples - duration_samples
    while t <= max_start:
        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
        candidate = t + jit
        if 0 <= candidate <= max_start:
            starts.append(candidate)
        t += pri_n
    return starts

A sampled phase (or jitter realization) with no occurrence that wholly fits in the capture returns an empty schedule. EventRadarPRI explicitly opts into that valid no-event result; it does not fabricate a boundary pulse.

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)
    duration_samples = int(signal.metadata.duration_samples)
    period_n = max(1, int(self.period_seconds * self.sample_rate_hz))
    phase = int(np_rng.uniform(0, period_n))
    starts = []
    t = phase
    max_start = self.scene_duration_samples - duration_samples
    while t <= max_start:
        starts.append(t)
        t += period_n
    return starts

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

A sampled phase with no full in-capture occurrence returns an empty schedule. EventPeriodicBeacon explicitly opts into that result; it does not synthesize a boundary beacon.

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]:
    max_start = self.scene_duration_samples - signal.metadata.duration_samples
    np_rng = numpy_rng_from_torch(rng)
    starts, t = [], 0
    while t < self.scene_duration_samples:
        first_increment_ms = min(self.on_max_ms, self.on_min_ms * float(stats.pareto.rvs(self.on_alpha, random_state=np_rng)))
        second_increment_ms = min(self.off_max_ms, self.off_min_ms * float(stats.pareto.rvs(self.off_alpha, random_state=np_rng)))
        first_increment_n = int(first_increment_ms * 1e-3 * self.sample_rate_hz)
        second_increment_n = int(second_increment_ms * 1e-3 * self.sample_rate_hz)
        if t <= max_start:
            starts.append(t)
        t += first_increment_n + second_increment_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 the first cadence increment (historic name)

off_alpha

float > 0

1.2

Pareto shape for the second cadence increment (historic name)

on_min_ms

float > 0

5.0

Minimum first cadence increment in milliseconds

off_min_ms

float > 0

10.0

Minimum second cadence increment in milliseconds

on_max_ms

float > 0

500.0

Cap on the first cadence increment in milliseconds

off_max_ms

float > 0

5000.0

Cap on the second cadence increment in milliseconds

The two increments are independently sampled from Pareto-derived values and capped at their configured maxima. They therefore provide a bounded synthetic cadence prior; they are neither emitted occupancy nor physical on/off duration.

Library primitive. scipy.stats.pareto.rvs for both cadence-increment 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; an input motivation for variable traffic intervals, not a claim that this capped scheduler realizes 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
    max_start = self.scene_duration_samples - signal.metadata.duration_samples
    return [start for start in starts if 0 <= start <= max_start]

A finite capture can contain no Hawkes arrivals, or only arrivals too late for the complete event footprint. EventBurstSelfExciting explicitly permits the resulting empty schedule; it does not insert a synthetic event at sample zero or at the scene boundary.

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)
    event_samples = int(signal.metadata.duration_samples)
    max_start = self.scene_duration_samples - event_samples
    starts, t = [], 0
    while t < self.scene_duration_samples:
        dwell_n = int(np_rng.choice(self.dwell_set))
        if t <= max_start:
            starts.append(t)
        t += dwell_n
    return starts if starts else [max_start]

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

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]:

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.

  • 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

The composer resolves each receiver into ChannelRxParams and its typed RX pose. SionnaRT consumes the TX/RX spatial geometry directly when it traces paths. The topology-based SionnaUMa, SionnaUMi, and SionnaRMa models consume poses to construct their link topology. TDL/CDL do not consume TX/RX positions or scene topology: their poses are retained by rfgen for GeometryProvenance. CDL can use the configured ut_array/bs_array and uplink/downlink direction from StatisticalSolverConfig, but those configuration choices do not make receiver-list positions an array model.

The multi-RX composer prepares propagation in step 7 for every receiver before the time planner draws a start: 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 the typed RX pose for spatial models (SionnaRT and the topology-based UMa/UMi/RMa models). TDL/CDL receive the pose for rfgen provenance, not to parameterize their link-level channel mathematics.

# Inside step 7, per-emitter propagation preparation (multi-RX)
prepared_per_rx = []
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)
    prepared_per_rx.append(sig_propagated)

# The time planner validates the unpropagated emitted footprint. Each accepted
# start then determines where the prepared per-RX IQ is mixed.
for t_start in accepted_starts:
    _mix_prepared_propagation(master_iq, prepared_per_rx, t_start)

_mix_prepared_propagation selects the strict raw mixer for an unlengthened result and the propagated mixer only when per-emitter propagation produced a channel tail. The event was already accepted under the strict event-fit rule: the propagated mixer retains the in-capture part of any tail and clips only the remainder beyond the fixed receiver capture. It is not permission to crop a raw emitted event. Together these rules preserve the placement coordinate contract in Coordinate Systems and the receiver-window/tail distinction in Scene Capture Boundaries.

AoA labeling

component_signals[i].metadata.extras["aoa_deg"] is populated by SionnaRT when a dominant traced path has available receive angles. TDL/CDL do not currently expose an rfgen AoA-label contract: CDL’s configured arrays and direction are channel-model inputs, not an emitted per-component angle label. Any synthetic AoA test fixture must declare its own label contract rather than implying that the production CDL backend supplies one.


Large scenes are materialized whole

A 1-second wideband recording at 200 Msps is 1.6 GB of complex64. A 5-second recording is 8 GB, so local-memory limits bound the scene sizes this composer can build: it computes the full IQ buffer in RAM and returns it.

A ChunkedSignal wrapper and a scene.chunk_threshold_mb knob stood here. They recorded chunk geometry in metadata for a chunk-aware storage backend to consume, and the composer materialized the whole buffer anyway — the streaming iterator the geometry described was never implemented, and the store it was written for was retired with the legacy record path. Both are removed rather than left describing a path that did not exist. Bounding a scene to available memory is the caller’s decision, through scene.duration_s and scene.sample_rate_hz.


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