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 relativef_offset_hzis 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 withtx_pose,rx_params,geometry_asset_refs, andrt_solver_paramspopulated 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 isscene.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_signalscarries metadata in the scene’s reference frame. Each component’srealized_carrier_hz,start_sample,duration_samplesare 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 |
|---|---|---|
|
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 |
|
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 = 64iterations.If no valid candidate is found within the budget, the strategy raises
PlacementErrorwhose message names the strategy, the exhausted budget (_MAX_RETRIES), and the configuredmin_spacing_hz.There is no nearest-feasible fallback.
drawreturns 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 |
|---|---|---|
|
|
non-empty |
|
|
ascending by |
|
|
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 |
|---|---|---|
|
14 channels (5 MHz spacing); canonical non-overlapping set {1, 6, 11} receive higher weights |
IEEE 802.11-2020 Table 17-9 |
|
UNII-1/2A/2C/3 channel sets |
IEEE 802.11-2020 Tables 17-12 / 17-13 |
|
40 channels (2 MHz spacing), channels 0 / 12 / 39 are advertising channels |
Bluetooth Core Specification 5.4 Vol 6 Part B §1.4 |
|
US 915 MHz sub-band channels |
LoRa Alliance RP002-1.0.5 (April 2024) Table 2-3 |
|
EU 868 MHz channels |
LoRa Alliance RP002-1.0.5 (April 2024) Table 2-7 |
|
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 |
|---|---|---|---|---|
|
|
|
required |
Surveillance: 1 ms; tracking: 100 µs; LPI: 10 µs |
|
|
|
required |
|
|
|
|
required |
|
|
|
|
|
Per-pulse Gaussian jitter std in seconds |
|
|
|
Fixed phase offset; random uniform over |
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 |
|---|---|---|---|
|
|
required |
|
|
|
required |
|
|
|
|
Pareto shape for the first cadence increment (historic name) |
|
|
|
Pareto shape for the second cadence increment (historic name) |
|
|
|
Minimum first cadence increment in milliseconds |
|
|
|
Minimum second cadence increment in milliseconds |
|
|
|
Cap on the first cadence increment in milliseconds |
|
|
|
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 |
|---|---|---|---|---|
|
|
|
required |
μ; mean event rate absent excitation |
|
|
|
required |
Branching ratio |
|
|
|
required |
β; exponential decay of self-excitation |
|
|
|
required |
|
|
|
|
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 inmetadata.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 inmetadata.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 any draw whose TF rectangle overlaps an existing emitter. After 10× retry budget, accept the smallest-overlap draw. |
|
Allow overlap with probability |
|
Force at least one cochannel overlap per scene with probability |
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 |
|---|---|
|
|
explicit list |
|
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_emittersvs 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¶
Concepts / Scenes: conceptual overview
API Reference / BaseSceneComposer: ABC contract
Determinism:
derive_rngper-slotMetrics: what the realized stats audit
Concepts / Channels: what step 8 / 9 do