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 whenf_cis 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 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; the composer routes that onto the component’smetadata.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 inscenemode (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_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.chunkedflag is determined by configuration plus a memory-budget heuristic: chunked when predicted IQ tensor exceedschunk_threshold_mb(default 200 MB).
Channel application mode¶
scene_config.channel_application (typed as ChannelApplicationMode) selects where the propagation channel runs:
Mode |
Behavior |
When |
|---|---|---|
|
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 |
|
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 = 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.
drawnever 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 |
|---|---|---|
|
|
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 = 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 |
|---|---|---|---|---|
|
|
|
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)
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 |
|---|---|---|---|
|
|
required |
|
|
|
required |
|
|
|
|
Pareto shape for on-duration |
|
|
|
Pareto shape for off-duration |
|
|
|
Minimum on-duration in milliseconds |
|
|
|
Minimum off-duration in milliseconds |
|
|
|
Maximum on-duration in milliseconds |
|
|
|
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 |
|---|---|---|---|---|
|
|
|
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)
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 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 |
|
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"]:
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_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