rfgen.scene

The scene-composition layer. Concrete classes orchestrate emitter sampling, time-frequency placement, channel application, and IQ summation, producing one composite Signal whose component_signals carry per-accepted-event ground truth in the scene’s reference frame. One selected emitter slot can yield no component when an allowed-empty time strategy returns no starts, or multiple components when it returns multiple accepted starts. The shipped rfgen.scene package owns this public surface. Its concrete composition implementation is available at rfgen.scene.composer; receiver frame helpers shared by labels, storage, and consumers are available at rfgen.scene.rf_context. from rfgen.scene import DefaultSceneComposer is the supported concise import path for the composer and scene-specific config helpers.

Module summary

import torch
from rfgen.scene import DefaultSceneComposer
from rfgen.config import SceneConfig

composer = DefaultSceneComposer()
scene_cfg = SceneConfig(
    sample_rate_hz=20_000_000,
    bandwidth_hz=10_000_000,
    duration_s=0.020,
)
scene_signal = composer.build(
    scene_cfg=scene_cfg,
    emitter_pool=emitter_pool,
    channel=channel_stack,
    rng=torch.Generator().manual_seed(0),
)

The composer’s full algorithm (10 steps, frequency / time placement strategies, overlap policy, multi-RX) is specified in Scene Composition Algorithm. The classes on this page are the public surface that algorithm is exposed through.

Class index

Class

Kind

Notes

DefaultSceneComposer

concrete

Reference BaseSceneComposer implementation; samples emitters, places, applies channels, sums. Fully documented below as the canonical example.

ComposerPlan / ComposerEvent

data contracts

Explicit multi-event opt-in that consumes resolved duration/start decisions and records composition provenance.

ScenePlan / SceneClock / PlannedSystem / PlannedEvent / PlannedTarget / PlannedAssetRef

data contracts

The scene-plan vocabulary (in rfgen.scene.plan): one frozen, strictly validated plan of a sample’s geometry, clock, and identity. Its derived time_reference binds the plan’s validated content into a scene identity two independently produced captures can be compared on.

plan_time_reference / canonical_plan_hash

scene identity

The derived identity every plan-driven capture is stamped with (in rfgen.scene.plan_identity). plan_time_reference(plan) returns scene-plan:<scene_id>:<first 16 hex of the plan hash>, hashed from the validated model dump with float serialization pinned so two machines minting one plan agree. The world asset’s uri and content_addressed flag are excluded as provenance-only, so the same geometry re-hosted elsewhere is still one scene, while its content digest stays inside the hash.

BaseSceneRenderer

abc

Renders one scene plan into one domain’s simulated state. Resolved by a projection through rfgen.scene_renderers.

RenderContext

data contract

The seed and sample id one render is given.

ScenePlanProvenance

provenance

The write-once scene-plan artifact (in rfgen.scene.plan_artifact) at artifacts/plans/<scene_id>/scene-plan.json. An equal rewrite is an idempotent success; differing bytes raise.

engine_version

engine identity

Returns an installed engine’s version from distribution metadata (in rfgen.scene.world_support), not from a __version__ attribute. It is load-bearing twice: the world cache key embeds it, so a Sionna or Mitsuba upgrade invalidates cached scenes, and the radar backend stamps it into each record’s backend_version. Read from metadata because __version__ is not part of any package’s contract — Sionna exposes none on any release this project’s pin allows, so reading the attribute silently produced "unknown" provenance and, in the cache-key path, an AttributeError that stopped the shipped radar-response template generating at all. An absent distribution yields "unknown" rather than raising: this feeds a cache key and a diagnostic, and require_engine has already refused a genuinely missing extra by that point.

write_once

provenance mechanism

The one durable write-once artifact writer (in rfgen.scene.artifact_write), used by both ScenePlanProvenance and ComposerProvenance. Writes a temporary file in the destination directory, fsyncs it, then os.links it into place: a reader sees either no file or a complete one, and the link is what enforces write-once rather than merely stating it, because it fails when the artifact exists. Equal bytes are an idempotent success whether they are found before or after the link; differing bytes raise SceneError naming the path. Two implementations of that rule existed and only one had the mechanism, so the other reported a false identity collision whenever it lost a race or met a truncated file.

SceneWorld / PlanContext

shared builder

The one Sionna world both domains build base geometry from (in rfgen.scene.world). Two constructors, from_asset for an explicit asset reference and build for a ScenePlan; a process-local geometry cache keyed on content hashes plus engine versions; use(frequency_hz) for consume-time carrier setting, place_targets for transactional target nodes, and first_hit_distance for the one geometric query it exposes. Sionna and Mitsuba are imported lazily, so the module is importable with no ray tracer installed.

rfgen.core.plan_fields

field primitives

The strict-boundary validators and the plan literals both the plan vocabulary and the authored ScenePlanConfig template are built from. It lives under rfgen.core because rfgen.config cannot import rfgen.scene (the scene package imports the configuration models), so core is the only place one shared implementation can sit.

mint_scene_plan

minting

Turns one authored ScenePlanConfig template plus a run seed and a run-global sample index into one ScenePlan (in rfgen.scene.minting, whose identity, events, and world modules mint the plan, the systems and activities a template’s events become, and the world asset plus scatterers respectively). Pure and deterministic, which is what joins the two runs of a cross-domain pair; targets are drawn under the "targets" leg of the plan’s own seed tree.

geometry_asset_ref_for_uri

asset identity

The one URI-to-GeometryAssetRef rule (in rfgen.scene.asset_refs), shared by the scene composer and scene-plan minting so a plan and a composed scene cannot disagree about one asset’s identity. Which rule applies is decided by the URI’s scheme, never by whether a read succeeded: local file:// bytes are digested, a built-in Sionna scene folds the installed engine version, and a remote gs://, s3://, or https:// URI takes a URI-identity stand-in marked content_addressed=False. A local asset that cannot be read is the one branch a caller chooses: require_readable_local=True (what minting passes) raises a ConfigError naming the resolved path, so a plan identity never depends on which machine held the file; the scene composer leaves it unset and accepts the stand-in. Nothing here is cached: a local asset’s bytes are read and digested on every call, and a run avoids the per-sample cost by minting its world reference once at run start (ScenePlanMintContext) and passing it to every sample.

scene_plan_to_composer_plan / plan_window_arithmetic

projection

The communications projection of a ScenePlan onto ComposerPlan (in rfgen.scene.projection), plus the one shared helper that converts planned seconds into capture-relative samples. An all-radar plan projects to None.

ComposerSampleContext

data contract

Optional stable zero-based sample ordinal for retry- and worker-independent corpus scheduling.

BaseSceneComposer

abc

ABC for scene composers; orchestrates emitter selection, placement, channel application, and IQ summation.

SingleEmitterWindowComposer

abc

ABC (in rfgen.scene.single_window) for the single-emitter, fixed-window classification/pretraining shape: one drawn emitter per record rendered into a fixed complex-baseband window. Owns the generic skeleton; a dataset subclass supplies only its catalog draw + per-record rate policy.

MultiEmitterSceneComposer

abc

ABC (in rfgen.scene.multi_emitter) for the multi-emitter detection/localization shape. Holds a private DefaultSceneComposer and delegates composition to it (byte-identical, no rng of its own); owns active-extent box tightening + role tagging. A scene dataset subclass supplies its params + _role_for.

BaseTimePlacement

abc

ABC for bounded, scene-rate event-start schedules.

BaseFrequencyPlacement

abc

ABC for frequency-placement strategies (uniform, stratified, ISM-realistic, forced overlap).

MultiRXConfig

config

Convenience re-export of rfgen.config.MultiRXConfig for explicit multi-RX layouts or array presets.

ReceiverConfig

config

Convenience re-export of rfgen.config.ReceiverConfig for per-receiver geometry and RF overrides.

GeometryPoseConfig

config

Convenience re-export of the typed scene-frame pose payload used by TX and RX geometry fields.

RTSolverConfig

config

Convenience re-export of RT solver knobs used under SceneGeometryConfig.

SceneAssetsConfig

config

Re-exported scene-asset pointer model (geometry, materials, antenna blobs).

SceneGeometryConfig

config

Re-exported geometry-backend selector and overlap-policy carrier.

ReceiverBackgroundPolicy / BackgroundProvenance

policy + record

Opt-in deterministic receiver thermal background and its immutable scene artifact.

For the conceptual map, see Concepts / Scenes. For the executable spec, see Scene Composition Algorithm.

rfgen.scene.single_window

SingleEmitterWindowComposer is the core ABC for the single-emitter, fixed-window composition shape used by Signal Atlas classification / pretraining datasets: one drawn emitter per record, rendered into a fixed-length complex-baseband window, returned as a one-component scene Signal (a SceneMetadata with num_emitters == 1 and a single component_signals entry) so the stock rfgen generate path and every labeler consume it unchanged. It is selected the same way as any composer, through scene.composer / the rfgen.scene_composers entry-point group.

The base class owns everything generic: the fixed-window contract, an optional per-record int16 quantization round-trip (composer_params.int16_quantize, default off), the scene-Signal assembly, and optional ProvenanceRole manifest validation. A concrete dataset composer implements four hooks: schema (its composer_params model, a subclass of SingleEmitterWindowParams), _load_resources (load its class catalog / sampling axes), _render_record (draw one class + condition, resolve the per-record sample rate, render the emitter, apply the channel), and _scene_id. The per-record rate policy is the one substantive divergence between datasets (e.g. an oversample-of-drawn-bandwidth policy versus a per-class capture-rate policy). Determinism comes from the single torch.Generator core’s SeedSchedule hands to build; the composer draws its own per-record condition and therefore rejects a ComposerPlan. For frequency-placement strategies and occupied-band admission, see the placement strategy selection guide.

rfgen.scene.multi_emitter

MultiEmitterSceneComposer is the core ABC for the multi-emitter (detection / localization) shape, in which several emitters are drawn per record, placed in time and frequency, mixed into one wideband snapshot with a per-emitter time-frequency bounding box. It is the counterpart to SingleEmitterWindowComposer so every Signal Atlas scene dataset is a thin subclass over one shared core foundation.

Unlike the single-emitter ABC (which is self-contained), multi-emitter composition already lives in core as DefaultSceneComposer, so this ABC does not reimplement it: it holds a private DefaultSceneComposer and delegates, forwarding the master rng verbatim so composition is byte-for-byte identical and deterministic (the ABC consumes no draws of its own). What it owns is a light post-processing pass over the composed component_signals: active-extent bounding-box tightening. When a component carries extras["active_duration_fraction"] (a rate-invariant fraction a bursty emitter such as DroneID records for its active frame), its duration_samples is rewritten to that fraction before the labeler runs, so JointLabeler / BBoxLabeler emit a box tight around the burst rather than the zero-padded window, with no post-hoc surgery. It also owns optional role tagging into extras["scene_role"]. A concrete dataset supplies schema() (a MultiEmitterSceneParams subclass) and _load_resources, and optionally _role_for / _scene_id_override; the emitter pool comes from the core emitter_zoo config, which keeps the ABC thin.

Receiver background

ReceiverBackgroundPolicy(config: ReceiverBackgroundConfig, *, scene_bandwidth_hz: float) is applied by DefaultSceneComposer when scene_cfg.receiver_background.enabled is true. Its apply(master_iq: torch.Tensor, *, scene_id: str, rx_params: tuple[ChannelRxParams, ...], rng: torch.Generator) -> tuple[torch.Tensor, BackgroundProvenance] operation uses the existing ThermalNoiseStage receiver stage once per configured receiver. master_iq is torch.complex64 with shape (R, N), containing one master row of N samples for every receiver. rx_params supplies exactly R receiver parameter sets in that same row order, and rng is the caller-owned Torch generator from which the policy deterministically derives per-receiver draws. Enabled calls return the same complex dtype and (R, N) shape plus the measured post-chain provenance. Disabled calls return the supplied tensor unchanged with disabled provenance. A row/receiver-count mismatch raises ValueError; invalid background values detected by the policy raise SceneError with context["code"] == "background_parameter_invalid".

BackgroundProvenance is a strict JSON-object V1 record with the exact fields schema_version, enabled, enabled_default, background_type, noise_figure_db, reference_temperature_k, effective_bandwidth_hz, background_power_w, and background_chain. A disabled record fixes noise_figure_db at numeric 0.0; its temperature, effective bandwidth, measured power, and chain are null.

write(provenance: BackgroundProvenance, *, scene_id: str, root: str | Path = ".") -> Path creates the canonical record at <root>/artifacts/plans/<scene_id>/background.json and returns that path. scene_id must be one nonempty path component: ., .., path separators, and NUL are rejected with ValueError. Writes are immutable: an existing record is not overwritten. read(path: str | Path) -> BackgroundProvenance only accepts that canonical path form, valid JSON, the exact V1 field set, and a record satisfying its enabled or disabled state rules. A noncanonical path or malformed JSON raises ValueError; Pydantic validation of invalid parameters retains its structured background_parameter_invalid context, and invalid configured selectors use background_chain_invalid. The policy adds an independent contribution after the receiver capture plane, and does not replace a configured ThermalNoiseStage; configure both only when the scenario intends both sources.

The composer writes an enabled record at artifacts/plans/<scene_id>/background.json; callers can use ReceiverBackgroundPolicy.read(path) to validate and reopen it without generating new noise. The default configuration does not write an artifact or change zero-emitter IQ. See ReceiverBackgroundConfig for arguments, defaults, validation, and physical limits, and RX capture for the pipeline position.


class rfgen.scene.DefaultSceneComposer

class DefaultSceneComposer(BaseSceneComposer):
    """Reference scene composer.

    Implements the 10-step composition algorithm: per-slot emitter sampling,
    weighted class draw, time / frequency placement via pluggable strategies,
    per-emitter channel application, scene-level propagation channel, RX
    frontend + AWGN, and population of `component_signals` in the scene's
    reference frame.
    """

    name: str = "default"

Constructor

DefaultSceneComposer(
    *,
    freq_planner_factory: Callable[[], BaseFrequencyPlacement] | None = None,
    time_planner_factory: Callable[[], BaseTimePlacement] | None = None,
    resampler_name: str = "scipy_poly_resampler",
    device_pool_size: int = 10,
    scene_cfg: SceneConfig | None = None,
    channel: BaseChannel | ChannelPipeline | None = None,
)

Stateless w.r.t. scene contents; the planner factories are how custom frequency- or time-placement strategies plug in. Both factories are invoked with no arguments. When omitted, the composer resolves the strategies named in scene_cfg against the EntryPointRegistry. resampler_name and device_pool_size are part of the discoverable constructor surface and appear in schema(). The optional scene_cfg + channel pair exists only for construction-time geometry/backend preflight; it is not part of the YAML-facing schema model.

resampler_name accepts a narrower set of values than the rfgen.channels registry offers. Step-7 resampling always constructs the resolved class with (up, down) keywords, so a registry-resolved BaseResamplerStage must accept that constructor; one that does not raises SceneError naming the class and the attempted arguments. The registered linear_sro entry point resolves by name but does not satisfy this, because SampleRateOffsetStage is parameterized in parts per million instead of by integer rate factors. Add that stage explicitly to a ChannelPipeline as a capture-plane receiver stage; its class reference covers composition and scene use.

Class attributes

Attribute

Type

Value

Purpose

name

str

"default"

Registry key. The composer is selected by scene_cfg.composer = "default" (or omitted, since this is the default).

Method: build

def build(
    self,
    *,
    scene_cfg: SceneConfig,
    emitter_pool: Mapping[str, BaseEmitter],
    channel: BaseChannel | ChannelPipeline,
    rng: torch.Generator,
    composer_plan: ComposerPlan | None = None,
    provenance_root: str | Path | None = ".",
) -> Signal

Produces one composite scene. The implementation follows the 10-step algorithm documented in Scene Composition Algorithm; the summary below is the public contract.

Parameters

Name

Type

Required

Default

Description

scene_cfg

SceneConfig

yes

Validated scene configuration: bandwidth, sample rate, duration, density, placement strategies, overlap policy, multi-RX geometry, channel-application mode

emitter_pool

Mapping[str, BaseEmitter]

yes

Named pool of concrete emitters. The shipped composer samples this mapping uniformly by key, then samples uniformly from each emitter’s supported_classes.

channel

BaseChannel | ChannelPipeline

yes

Channel application entry point. Pass either a single BaseChannel implementation or a composed ChannelPipeline spanning Group.TX and Group.CHANNEL plus the receiver stages it holds alongside its chain (the capture plane, then the hardware plane). The composer dispatches pre-sum chain transformations per emitter and the post-sum receiver stages per receiver per the Scene Composition Algorithm

rng

torch.Generator

yes

Master RNG. Per-slot RNGs are derived deterministically per the Determinism reference

composer_plan

ComposerPlan | None

no

None

Explicit planned-event opt-in. Each event’s resolved duration, start, device identity, and derive_rng(run_seed, sample_id, event_id) namespace replace those random draws. A resolved source binding also fixes the configured emitter key/class, placement-support bandwidth, frequency offset, typed TX pose, and finite TX power. None preserves ordinary composition.

provenance_root

str | Path | None

no

"."

Root under which opt-in composition creates immutable artifacts/plans/<scene_id>/ files. None writes none. Generation passes None: a run’s provenance is the scene-plan artifact, which is rooted at the dataset and carries strictly more, and a second per-scene file written relative to the caller’s working directory is both redundant and a collision waiting to happen — scene identity folds the run seed and the sample index, not the plan, so two configurations at one seed name one path. A caller composing one scene directly chooses its own root.

Returns

A scene-level Signal:

  • signal.iq : IQ Shape (2, num_samples) for single-RX, (num_rx, 2, num_samples) for multi-RX, dtype float32. The composer always returns the fully materialized tensor; bound a scene to available memory through scene.duration_s and scene.sample_rate_hz.

  • signal.metadata : SceneMetadata Carries realized_* audit fields (count, SNR percentiles, class histogram, cochannel-overlap rate, occupancy fraction) plus the resolved scene RNG seed. Signal.metadata is typed Union[SignalMetadata, SceneMetadata]; at the scene level it is always SceneMetadata.

  • signal.component_signals : tuple of Signal One per accepted placed/emitted event, in accepted-placement order; each component signal carries its own SignalMetadata with realized_carrier_hz, start_sample, duration_samples, snr_db, extras["sinr_db"], and RT provenance in the scene’s reference frame. For single-RX RT scenes, the typed provenance lives on SignalMetadata.geometry. For joint multi-RX RT scenes, SignalMetadata.geometry still carries a canonical GeometryProvenance object for the RT solve, identified by extras["rt_geometry_provenance_canonical_rx_id"]; receiver-specific pose variants remain under extras["rt_geometry_provenance_by_rx"], and the receiver-invariant shared fields remain mirrored under extras["rt_geometry_provenance_shared"]. For geometry-backed RT scenes, scalar scene-level snr_db / sinr_db labels are marked unavailable unless the propagation backend publishes stable receiver-resolved metrics; the composer will not fabricate physically precise scalar labels from pre-propagation energy.

Raises

  • SceneError if scene_cfg is not a validated SceneConfig instance.

  • SceneError if the propagation backend’s requires_geometry flag disagrees with the presence of a 3D site geometry in scene_cfg, or if an RT scene reaches propagation without the required typed poses or scene-geometry assets.

Zero-emitter scenes are valid. The composer returns a scene-level Signal with empty component_signals and zeroed audit metrics rather than raising.

Explicit planned-event input

Use the concise public imports:

from rfgen.scene import (
    ComposerEvent,
    ComposerPlan,
    ComposerProvenance,
    ComposerProvenanceEvent,
)

ComposerPlan(run_seed: uint64, sample_id: str, scene_id: str, events: tuple[ComposerEvent, ...]) is the optional build() input. It requires a nonblank sample_id, a safe one-component scene_id, and one or more events with unique planned_event_id values. Each ComposerEvent(planned_event_id, system_id, device_id, transmitter_role, link_id, link_direction, event: ResolvedEvent, duration: DurationPlan) requires nonblank identity fields. Its ID must equal event.event_id; its resolved interval length and containment flag must match duration.

For a resolved source binding, supply all of emitter_key, class_label, bandwidth_hz, frequency_offset_hz, tx_pose: GeometryPose, and tx_power_dbm. All are optional together only for the retained version-1 time-only plan; a plan cannot mix time-only and resolved events. The composer checks that the key/class are in the configured pool, that emitted occupied bandwidth does not exceed the declared placement-support bound, and that the declared frequency interval fits the scene bandwidth. It applies the planned time/frequency values without a placement redraw. bandwidth_hz is a configured placement bound, not an empirical occupied-bandwidth measurement. The planned pose and finite power are authoritative and are restored after TX transforms immediately before per-emitter propagation.

For every supplied event, the composer derives derive_rng(run_seed, sample_id, planned_event_id), uses its resolved duration and start without redrawing or clipping them, and retains the existing channel pipeline and multi-RX fanout. The events are atomic TX-side transmissions; this does not define traffic, protocol, or capture-fidelity behavior. With composer_plan=None, DefaultSceneComposer.build() stays on its ordinary distribution-driven path.

ComposerProvenance.from_plan(plan: ComposerPlan, *, realized_carriers_hz: Mapping[str, float] | None = None) -> ComposerProvenance creates the frozen in-memory record. ComposerProvenance.write(plan: ComposerPlan, *, root: str | Path = ".", realized_carriers_hz: Mapping[str, float] | None = None) -> Path writes exactly one immutable document at artifacts/plans/<scene_id>/composer-provenance.json, through the shared write_once described below. Writing the same plan again is an idempotent success; a different composition at one scene identity raises SceneError. Legacy time-only plans write the version-1 JSON schema:

{"schema_version":1,"run_seed":73,"sample_id":"sample-0001","events":[{"planned_event_id":"event-0","seed_key":[73,"sample-0001","event-0"],"system_id":"system-0","device_id":"device-0","transmitter_role":"uplink","link_id":"link-0","link_direction":"device-to-receiver"}]}

Resolved source plans write schema version 2. Each row additionally records the generic emitter_key, class_label, bandwidth_hz, frequency_offset_hz, tx_pose, tx_power_dbm, and the derived absolute realized_carrier_hz. A v2 row must contain the complete binding and finite numeric values; a v1 record remains readable unchanged.

For a resolved source plan, callers of these public helpers must supply realized_carriers_hz with exactly one finite derived absolute-carrier value for every planned_event_id; the helpers reject a missing, incomplete, or extra mapping. The composer supplies that mapping from its validated scene center and planned offsets. All helpers revalidate their ComposerPlan model dump, so an invalid in-memory model_copy cannot bypass the public contract.

ComposerProvenance(schema_version: int = 1 | 2, run_seed: uint64, sample_id: str, events: tuple[ComposerProvenanceEvent, ...]) requires a supported schema version, a uint64 seed, a nonblank sample ID, and one or more rows. Each ComposerProvenanceEvent(planned_event_id: str, seed_key: tuple[uint64, str, str], system_id: str, device_id: str, transmitter_role: str, link_id: str, link_direction: str) has the seven JSON fields above. Its seed_key must be exactly [run_seed, sample_id, planned_event_id], and IDs must be unique. ComposerProvenance.read(path: str | Path) -> ComposerProvenance reopens only that canonical path and returns the strict frozen model. It raises ValueError for a wrong path, malformed JSON, missing or unknown fields, invalid uint64 or identity values, empty or duplicate rows, or mismatched event keys. Pydantic model construction rejects equivalent invalid in-memory inputs.

See Compose an Already-Resolved Event for input construction in the scene-generation workflow.

Illustrative API-signature sketch (non-runnable)

import torch
from rfgen.scene import DefaultSceneComposer
from rfgen.config import SceneConfig
from rfgen.domains.radar.chirp_emitter import ChirpRadarEmitter
from rfgen.engine.propagation_generic import AWGNChannel

composer = DefaultSceneComposer()
scene_cfg = SceneConfig(
    sample_rate_hz=25_000_000,
    bandwidth_hz=10_000_000,
    duration_s=0.020,
)
scene_signal = composer.build(
    scene_cfg=scene_cfg,
    emitter_pool={"radar.lfm": ChirpRadarEmitter()},
    channel=AWGNChannel(),
    rng=torch.Generator().manual_seed(42),
)
scene_meta = scene_signal.metadata
component_metas = [c.metadata for c in scene_signal.component_signals]
assert scene_signal.iq.shape[-1] == int(round(scene_meta.duration_s * scene_cfg.sample_rate_hz))
assert len(component_metas) == scene_meta.realized_emitter_count
assert all(
    m.start_sample + m.duration_samples <= scene_signal.iq.shape[-1]
    for m in component_metas
)

This is the current geometry-free lightweight baseline: AWGNChannel has requires_geometry = False, and scene_cfg declares no geometry/assets, so the composer never touches Sionna. It is useful for a controlled sanity test, not the canonical physical-propagation route; use the Sionna integration when selecting a realistic propagation model. ChirpRadarEmitter’s default bandwidth_hz is 10 MHz, so sample_rate_hz must clear Nyquist with margin (25 MHz here, not 20 MHz: 10 MHz is not strictly less than 20e6 / 2). See the SionnaRT / 3D geometry example below for the ray-traced path with a real scene asset.

Record-length rule: the shipped composer sizes the initial master buffer and, when the post-sum chain preserves duration, the final scene IQ with int(round(sample_rate_hz * duration_s)). Python’s round() is the contract, so exact .5 ties go to the nearest even integer.

Example: SionnaRT / 3D geometry

Ray tracing needs rfgen[sionna], a 3D scene asset, a positive absolute RF frequency, and typed TX/RX poses: all optional for the geometry-free path above, all required here. The example deliberately uses the core SciPy-backed ChirpRadarEmitter, so it does not install or import TorchSig. This available integration is not a production-qualified Golden Path. SionnaRT.requires_geometry == True, so scene_cfg.geometry must declare SceneGeometryBackend.SIONNA_RT and scene_cfg.assets must resolve to a loadable scene:

import torch
from rfgen.scene import DefaultSceneComposer
from rfgen.config import SceneConfig
from rfgen.config.scene import (
    GeometryPoseConfig,
    MultiRXConfig,
    ReceiverConfig,
    RTSolverConfig,
    SceneAssetsConfig,
    SceneGeometryConfig,
)
from rfgen.domains.radar.chirp_emitter import ChirpRadarEmitter
from rfgen.core.enums import DensityMode, SceneGeometryBackend
from rfgen.engine.propagation_sionna_rt import SionnaRT

composer = DefaultSceneComposer()
scene_cfg = SceneConfig(
    sample_rate_hz=2_000_000.0,
    bandwidth_hz=1_000_000.0,
    duration_s=0.010,
    center_hz=2.4e9,  # RT needs a positive absolute carrier, not baseband
    tx_pose=GeometryPoseConfig(
        position_m=(0.0, 0.0, 1.5),
        orientation_rad=(0.0, 0.0, 0.0),
    ),
    multi_rx=MultiRXConfig(
        receivers=[
            ReceiverConfig(
                rx_id="rx0",
                rx_pose=GeometryPoseConfig(
                    position_m=(10.0, 0.0, 1.5),
                    orientation_rad=(0.0, 0.0, 3.14159),
                ),
            )
        ]
    ),
    geometry=SceneGeometryConfig(
        backend=SceneGeometryBackend.SIONNA_RT,
        rt_solver=RTSolverConfig(
            max_depth=3,
            tx_array={"num_rows": 1, "num_cols": 1, "pattern": "iso", "polarization": "V"},
        ),
    ),
    assets=SceneAssetsConfig(scene_geometry_uri="sionna://builtin/munich"),
)

scene_signal = composer.build(
    scene_cfg=scene_cfg,
    # A core emitter keeps the Sionna propagation example independent of
    # optional TorchSig generation adapters. Its 100 kHz occupied bandwidth
    # fits strictly below the 1 MHz Nyquist limit at this 2 MHz scene rate.
    emitter_pool={"radar.lfm": ChirpRadarEmitter(bandwidth_hz=100_000.0)},
    channel=SionnaRT(),
    rng=torch.Generator().manual_seed(7),
)

center_hz=2.4e9 supplies the finite positive absolute frequency SionnaRT needs for scene.frequency; alternatively, set rt_solver.scene_frequency_hz explicitly and leave center_hz at its baseband default. Each component signal’s metadata carries the resolved GeometryProvenance and the extras["rt_channel"] dict (num_paths, dominant_path_gain_linear, dominant_path_delay_s, and related fields) documented on SionnaRT in Reference / API / Propagation.

The per-path CIR column retains the vector kind PathDelay. The scalar dominant_path_delay_s is instead DominantPathDelay: the delay coordinate at the first maximum-|gain| coefficient (stable first-index tie behavior). delay_reference is closed to absolute_propagation and first_arrival_excess; normalized statistical-model CIRs use the latter, while ray tracing follows its explicit normalize_delays setting. When ray tracing reports no paths, dominant_path_status = "absent" makes the stored zero a non-physical persistence placeholder.

Method: schema

def schema(self) -> type[BaseModel]

Returns the Pydantic model for the composer’s stable constructor kwargs. Today that surface is resampler_name and device_pool_size; build() still consumes a separate validated SceneConfig.

Notes

  • Determinism. Same (rng_seed, scene_cfg) produces byte-identical IQ. Per-slot RNGs are derived from the master rng per Determinism; multi-RX RNGs are derived from (scene_seed, rx_idx).

  • Schema validation. schema() returns the constructor model, not SceneConfig; build expects an already-validated SceneConfig.

  • Channel application mode. scene_cfg.channel_application selects between ChannelApplicationMode.SCENE (the configuration default; one propagation call per receiver on the summed master IQ only when the selected backend supports scene mode) and ChannelApplicationMode.PER_EMITTER (one call per emitter, summed after). The latter is required for distributed-RX V2X scenes and benefits from RT geometry caching. Configure every Sionna scenario with PER_EMITTER. The current runtime does not fail fast for SCENE: RT nevertheless fans out per (emitter, RX) call because requires_geometry=True, while the statistical Sionna backends reach propagation without a TX pose and raise. See Sionna integration and the Scene Composition Algorithm.

  • component_signals invariant. Each component’s realized_carrier_hz, start_sample, duration_samples are absolute within the scene; the label layer reads these without further transformation. Each component represents one accepted placed/emitted event; a selected emitter slot may produce zero components with an allowed-empty strategy or multiple components for multiple accepted starts.

  • Geometry / propagation cross-validation. At build call time, the composer reads the channel-propagation backend’s (the single slot in Group.CHANNEL) requires_geometry: ClassVar[bool] flag and compares it against the presence of a 3D site geometry block in scene_cfg. Mismatch raises SceneError before any IQ generation. This catches both failure modes: SionnaRT without geometry (hard failure), and a statistical backend with geometry present (silent misconfiguration, also rejected). The shipped implementation has no allow_unused_geometry downgrade path.

See Also


Compatibility alias: rfgen.scene.OSMSceneBuilder

rfgen.scene.OSMSceneBuilder is not an exported symbol. The supported surface is SceneAssetsConfig plus SceneGeometryConfig, which let callers point the scene composer and SionnaRT at prebuilt geometry assets.


Compatibility alias: rfgen.scene.SceneContext

rfgen.scene.SceneContext is not an exported symbol. The composer assembles runtime propagation payloads with ChannelContext and ChannelRxParams from rfgen.core.protocols; those types carry the typed TX/RX poses, geometry asset refs, and solver params consumed by the channel layer.


class rfgen.scene.BaseTimePlacement

class BaseTimePlacement(ABC):
    """Strategy for scheduling bounded event starts in a scene."""

    may_return_empty: ClassVar[bool] = False

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

    @abstractmethod
    def schema(self) -> type[BaseModel]: ...

Kind. Abstract base class.

draw() receives the post-resampling, frequency-shifted emitter signal at the scene sample rate: its IQ extent and metadata.duration_samples are therefore the exact event footprint that will be mixed and labelled. It returns one start for a single-event template or many starts for a multi-event template. Time placement is generic RF scheduling; it does not model packet framing, protocol state machines, channel maps, or the fidelity of a named wireless standard.

The six shipped strategies are:

Selector

Strategy

Schedule model

iid_uniform

IIDUniformTime

One uniformly drawn start.

event_radar_pri

EventRadarPRI

PRI-style cadence with optional Gaussian start jitter.

event_periodic_beacon

EventPeriodicBeacon

Fixed-cadence starts with a uniformly sampled phase.

event_burst

EventBurst

Generic capped-Pareto-derived cadence increments.

event_burst_self_exciting

EventBurstSelfExciting

Exponential-kernel Hawkes point-process schedule.

event_fhss_hop

EventFhssHop

Generic discrete-dwell cadence.

Their parameters and pseudocode are documented in Scene Composition Algorithm § Time placement strategies. Custom strategies plug in by registering a selector against the EntryPointRegistry.

Registry construction and capture context

For a strategy selected through the rfgen.time_placement registry, the composer constructs the class with SceneConfig.time_placement_params plus the capture context it owns: scene_duration_samples is reserved and always supplied, and sample_rate_hz is supplied when the constructor declares that keyword. A length-aware plugin therefore accepts and uses scene_duration_samples; a cadence plugin that works in seconds also accepts and uses sample_rate_hz. These values describe the actual scene being built, not user-tunable strategy parameters. Supplying either reserved keyword in time_placement_params with a conflicting value is an error; it is never a way to make the composer crop an event.

DefaultSceneComposer(time_planner_factory=...) is a separate dependency- injection path. Its zero-argument factory receives no composer-injected kwargs, so it must construct a planner with all required context itself. In both paths, the composer is the final authority: it validates every returned start against the actual event and scene lengths. See the length-aware placement contract.

Method: draw

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

Returns one or more integral, zero-indexed start-sample positions in the scene’s reference sample rate. The composer calls it only after resampling, so the supplied signal is not at an emitter-native rate.

Parameters

Name

Type

Description

signal

Signal

Post-resampling scene-rate event. signal.metadata.duration_samples is its actual IQ extent in scene samples and is used to check fit.

rng

torch.Generator

All randomness for this placement is drawn from rng. Same state produces the same placements.

Returns

A list[int] of zero-indexed scene-sample starts. With scene_samples equal to the scene capture length and event_samples equal to the supplied signal’s IQ extent, every result must satisfy the inclusive bound 0 <= start <= scene_samples - event_samples. A valid result therefore keeps the complete emitted event inside the capture; the composer rejects invalid or non-integral results rather than cropping them. Single-event strategies normally return one start; multi-event strategies may return several.

Extension contract

  1. Integral and bounded. Every start MUST be an integer satisfying 0 <= start <= scene_samples - event_samples; both endpoints are allowed.

  2. No silent cropping. A result outside that inclusive range is invalid. The composer raises SceneError rather than shortening or moving the event.

  3. Empty only by opt-in. The default is non-empty. A strategy may return [] only when it declares may_return_empty = True, to represent a valid stochastic no-event realization. The composer rejects empty results from all other strategies.

  4. Deterministic. Same (signal, rng) state MUST produce the same list. All randomness MUST come from rng.

  5. No in-place mutation. MUST NOT modify signal or its metadata.

  6. Constructor schema. schema(self) -> type[BaseModel] MUST return the Pydantic model that validates the strategy’s constructor parameters. The registry uses the strategy constructor for instantiation; schema() makes that constructor contract inspectable to configuration tooling.

Design note: why no placed argument

draw() does not receive a placed list of already-scheduled emitters. Time placement strategies draw each emitter’s position independently. Time overlap detection is the scene composer’s job: after time placement, the composer filters candidate starts under scene_cfg.geometry.overlap_policy. Strategies are not responsible for avoiding time conflicts.

This differs from BaseFrequencyPlacement.draw(), which does receive placed because some frequency strategies need minimum-spacing awareness or forced-overlap targeting at draw time (the composer cannot retry frequency placement without this information). The asymmetry is intentional: frequency placement is context-aware by design; time placement is always independent.

Custom time strategies that want to avoid specific time positions should model that constraint internally (e.g., by using a deterministic grid rather than rejection sampling against placed).

Extension points

Method

Status

draw()

Must override

schema()

Must override; return the constructor-parameter BaseModel type

may_return_empty

Set to True only for a valid stochastic no-event realization


class rfgen.scene.BaseFrequencyPlacement

class BaseFrequencyPlacement(ABC):
    """Strategy for placing one emitter in frequency within a scene."""

    name: str

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

Kind. Abstract base class.

Returns a scene-relative frequency offset in Hz for one emitter. The composer mixes the channel-rate IQ by that offset, then records the absolute carrier as scene_cfg.center_hz + f_offset_hz in realized_carrier_hz. Concrete strategies (iid_uniform, stratified, clustered, ism_realistic, forced_overlap) are documented in Scene Composition Algorithm § Frequency placement strategies. Custom strategies plug in by registering a name against the EntryPointRegistry under the rfgen.freq_placement group.

Method: draw

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

Returns the scene-relative frequency offset in Hz by which the composer shifts the emitter’s burst within the scene buffer.

Parameters

Name

Type

Description

signal

Signal

The emitter’s IQ burst; signal.metadata.bandwidth_hz is the emitter’s occupied bandwidth used to check fit within scene bandwidth.

scene_bandwidth_hz

float

Total scene bandwidth in Hz, centered at 0 Hz (baseband). The strategy uses this to enforce the in-band invariant.

rng

torch.Generator

All randomness for this placement is drawn from rng. Same state produces the same offset.

placed

Sequence[Signal]

Signals already placed in this scene (default: empty). Their metadata carriers are absolute; current spacing/forced-overlap strategies receive them while returning relative offsets, a documented coordinate-conversion implementation gap. Strategies that do not need placed (stratified, realistic_density, clustered) ignore it.

Returns

A float scene-relative offset in Hz. The returned value is not itself realized_carrier_hz; the composer records realized_carrier_hz = scene_cfg.center_hz + returned_offset after placement.

Extension contract

  1. Candidate-only contract. A strategy returns a finite candidate offset. Use strict occupied-band placement when acceptance must be based on the waveform’s measured occupied interval rather than its requested bandwidth_hz.

  2. Finite. The returned value MUST be finite (not nan, not inf).

  3. Deterministic. Same (signal, scene_bandwidth_hz, rng, placed) state MUST produce the same offset. All randomness MUST come from rng.

  4. No in-place mutation. MUST NOT modify signal, its metadata, or any element of placed.

Compatibility alias: rfgen.scene.SceneOverlapPolicyConfig

rfgen.scene.SceneOverlapPolicyConfig is not an exported symbol. Overlap handling is configured by the enum field SceneGeometryConfig.overlap_policy on SceneGeometryConfig. Use that documented field rather than unsupported parameters such as p_overlap, retry_budget, or margin_hz.


class rfgen.scene.MultiRXConfig

class MultiRXConfig(BaseModel):
    geometry: ArrayGeometry | None = None
    receivers: list[ReceiverConfig] = Field(default_factory=list)

Convenience re-export of rfgen.config.MultiRXConfig. The canonical field contract lives on the config page; the rfgen.scene facade exports the same model so scene-building code can import the composer and its receiver-layout helpers from one module.

The implemented contract is narrow:

  • geometry is a closed-enum array preset.

  • receivers is an explicit list of ReceiverConfig entries.

  • The two are mutually exclusive.


class rfgen.scene.ReceiverConfig

class ReceiverConfig(BaseModel):
    rx_id: str
    position_m: tuple[float, float, float] = (0.0, 0.0, 0.0)
    orientation: tuple[float, float, float, float] = (1.0, 0.0, 0.0, 0.0)
    rx_pose: GeometryPoseConfig | None = None
    antenna_id: str | None = None
    center_freq_hz: float | None = None
    bandwidth_hz: float | None = None
    sample_rate_hz: float | None = None
    noise_figure_db: float = 0.0

Convenience re-export of rfgen.config.ReceiverConfig. The canonical field contract lives on the config page. The implemented model stores a stable rx_id, legacy position_m / quaternion orientation, the derived typed rx_pose, optional RF overrides, and noise_figure_db.


Abstract base classes

The extension points a use-case package implements. Each is resolved from configuration through its own entry-point group, so a third-party implementation is reachable without a change here.

class rfgen.scene.BaseSceneComposer

Orchestrates emitter selection, placement, channel application, and IQ summation. This is the layer that replaces TorchSig’s rectangle-overlap loop.

class BaseSceneComposer(ABC):
    """Composes a multi-emitter scene end to end."""

    name: str

    @abstractmethod
    def build(
        self,
        *,
        scene_cfg: SceneConfig,
        emitter_pool: Mapping[str, BaseEmitter],
        channel: BaseChannel | ChannelPipeline,
        rng: torch.Generator,
        composer_plan: ComposerPlan | None = None,
        provenance_root: str | Path | None = ".",
    ) -> Signal:
        """Produce one composite scene.

        Steps the implementation MUST perform, in this order
        (see Scene Composition Algorithm for the full 10-step spec):

        1. Draw scene-level parameters (bandwidth, sample rate, duration, density).
        2. Sample the number of emitters from scene_cfg.density.
        3. For each emitter slot, sample a class from scene_cfg.emitter_zoo.
        4. Sample SNR from scene_cfg.power.
        5. Call emitter.generate(...) to get a Signal carrying baseband iq and metadata.
        6. Apply Group.TX transformations per emitter via ChannelContext (DAC, PA,
           TX phase noise, TX IQ imbalance, CFO).
        7. Draw time/frequency placement (BaseTimePlacement, BaseFrequencyPlacement);
           apply Group.CHANNEL according to scene_cfg.channel_application, then
           mix into the per-RX buffer.
        8. Run the receiver capture plane per receiver on summed IQ
           (RX LO frequency error, RX mixer, IF filter, resampler, LNA noise),
           then the composer-owned joint receiver-background injection.
        9. Run the receiver hardware plane per receiver
           (ADC, RX phase noise, RX IQ imbalance, AGC).
        10. Return a scene-level Signal whose iq is the per-RX output,
            whose metadata is a SceneMetadata, and whose component_signals
            tuple carries one Signal per accepted placed/emitted event (each
            with its own SignalMetadata in the scene's reference frame).

        The channel argument is normalized before execution. A bare BaseChannel
        is wrapped as a one-transformation ChannelPipeline, and an existing
        ChannelPipeline is used as-is. The implementation then reads the
        normalized pipeline's partitions:
          Group.TX (1x) and Group.CHANNEL (2x) chain entries run pre-sum per emitter.
          The receiver stages held alongside the chain run post-sum per receiver,
          capture plane first, then hardware plane.

        Implementations MAY parallelize within a scene but MUST be
        deterministic given the supplied rng.
        """

    @abstractmethod
    def schema(self) -> type[BaseModel]:
        """Return this composer's constructor-parameter model."""

Geometry / propagation cross-validation contract. Any BaseSceneComposer implementation MUST cross-validate the propagation backend against the scene geometry config before generating IQ. DefaultSceneComposer performs this check after normalizing channel to a ChannelPipeline, so both sides of the public BaseChannel | ChannelPipeline union follow the same path. The check then reads the normalized pipeline’s channel-propagation backend, that is, the single slot in Group.CHANNEL, and compares its requires_geometry: ClassVar[bool] flag against the presence of a 3D site geometry block in scene_cfg:

  • requires_geometry=True with no 3D geometry block: raise SceneError.

  • requires_geometry=False with a 3D geometry block present: raise SceneError.

This check fires before any IQ generation, not at the first generation call. See Concepts / Scenes / Compatibility for the full table.

build()

Abstract method on BaseSceneComposer. Produces one composite scene by sampling emitters, placing them in time and frequency, applying channels, and summing into the scene IQ buffer. See the class block above for the full step-by-step contract.

build_with_context(..., sample_context=ComposerSampleContext(...)) is the optional corpus-scheduling seam. The context contains only a stable, zero-based composition-sample ordinal; it carries no executor, storage, shard, or full configuration state. The inherited implementation ignores the ordinal and delegates to build, preserving existing composer behavior. A specialized composer may override it when a retry- and worker-independent corpus schedule depends on the ordinal.

The plan clock in USD time codes

Module: rfgen.core.usd_time

SceneClock is two floats and the world layer is rate-free. USD expresses animation on a stage-level axis with timeCodesPerSecond, startTimeCode, and endTimeCode, and authors attribute values at time codes, which are doubles and need not be integral. This module is the mapping between the two, and only that: it is pure, it imports no pxr, and it reads a clock for its two floats.

start_time_code = 0.0
end_time_code   = duration_s * time_codes_per_second
time_code_for(t) = (t - time_origin_s) * time_codes_per_second
scene_time_for(c) = time_origin_s + c / time_codes_per_second

Name

What it is

UsdTimeMapping

The axis one clock denotes: the rate, the two codes, and the epoch

usd_time_mapping(clock, *, time_codes_per_second=1000.0)

Builds it; refuses a rate that is not finite and positive

time_code_for / scene_time_for

The mapping and its inverse

integral_time_code_residual_s

How far an instant sits from the nearest integral code, in seconds

time_origin_metadata_value / time_origin_from_metadata

The epoch as the rfgen:timeOriginS string, and back

Time code zero is the plan epoch. The alternative, absolute codes on the plan’s own axis, is numerically unsafe rather than merely awkward: a plan at a Unix-epoch origin of 1.7e9 would author codes near 1.7e12, where a double carries about 2e-4 of resolution in time codes and a viewer’s playback controls quantize to integers, so every authored sample would land on a non-representable code. Discarding the epoch instead would make scene_time_for non-invertible and break the round trip a consumer needs to correlate a rendered frame with an RF capture. So codes are relative and the epoch travels as stage metadata, which is consistent with what time_origin_s already means everywhere else here: metadata that never enters phase arithmetic.

The epoch travels as a string. rfgen:timeOriginS holds repr(time_origin_s), Python’s shortest round-tripping decimal, rather than a double written by OpenUSD’s own float formatter. It is the one number in the stage whose loss would be silent and unrecoverable. It is the key joining a rendered frame back to an RF capture, and at epoch scale one unit in the last place is 238 nanoseconds of correlation error, so the guarantee moves to the standard library, where it is a documented property of the language. There is a precedent rather than a novelty: canonical_floats renders every float in the plan payload through repr before hashing.

The round trip is bounded, not exact, and the bound is stated as measured. Over 700,000 draws spanning seven origins, five durations, and five rates:

abs(scene_time_for(m, time_code_for(m, t)) - t) <= math.ulp(abs(time_origin_s) + duration_s)

with zero violations and a worst observed ratio of exactly 1.0: tight rather than slack. Two things about that are easy to get wrong. It is one ulp of the clock’s magnitude, not of t: measured in ulps of t itself the same sweep reaches four, when the origin and the instant have opposite signs and the final addition cancels. And the inexactness lives in the scaling pair (x * r) / r rather than in the subtraction, so a zero origin is not the exact case; at r = 1000.0 that pair is inexact for about two percent of draws whatever the origin. The case that is exact is the epoch-scale one, and it needs two facts: Sterbenz’s lemma makes the subtraction exact when t and the origin lie within a factor of two, and the final addition rounds to a grid whose spacing, math.ulp(1.7e9) = 2.38e-07, is nine orders of magnitude coarser than the scaling residual it carries. The useful inversion for a reader: exactness is a property of a coarse grid, so the origins a small test is most likely to use, near zero, are precisely the ones that are not exact.

The default rate is 1000.0, so one time code is one millisecond. A power-of-two rate is strictly better numerically, since 1024.0 measures zero inexact scalings in 200,000 draws against 8,223 at 1000.0, and is rejected for readability: at 1000.0 an event at 1.5 s is code 1500 and an operator reads milliseconds off a frame counter, while at 1024.0 it is code 1536 and the error that follows is a silent misreading. The residual 1024.0 removes is bounded, measured, and gated; the misreading is not. A caller who wants the exactness passes time_codes_per_second=1024.0, because the rate is a parameter.

An event boundary need not land on an integral time code, and nothing rounds or refuses. Rounding would silently move an event boundary, which is the class of error this repository converts into refusals; refusing would reject legitimate plans over a cosmetic property of a viewer’s scrub bar. Reporting is the honest third option, and integral_time_code_residual_s is it, in seconds, so the number reads against the plan’s own quantities.

Exporting a plan as a USD stage

Modules: rfgen.scene.usd_export (pure), rfgen.scene.usd_stage_writer (every pxr call)

UsdaScenePlanExporter turns a minted ScenePlan into .usda text. It is the seam an external scene runtime opens, whether Omniverse or anything else that reads USD, and it is deliberately the only thing this repository ships toward one: there is no live runtime here, no converter, and no round trip.

from rfgen.scene.usd_export import UsdaScenePlanExporter

text = UsdaScenePlanExporter().export(plan, time_codes_per_second=1000.0)

export returns text rather than writing a file, so a caller can write it to any supported URI scheme and a test needs no filesystem.

Name

What it is

ScenePlanExporter

The ABC: name, suffix, and export(plan, *, time_codes_per_second)

UsdaScenePlanExporter

The in-repo .usda exporter, registered as usda

scene_plan_stage_description(plan, *, time_codes_per_second)

The pure projection, testable with no USD runtime. It returns StageDescription, whose fields are the .usda writer’s own interface and are not a documented contract: a second exporter implements export from the plan rather than consuming this

is_valid_usd_identifier(name: str) -> bool

Whether a string is already a legal USD identifier

encode_prim_name(plan_id: str, *, id_field: str) -> str

The prim-naming rule; id_field names the plan field in the refusal

rfgen.scene.usd_stage_writer.export_stage_description

The vendor half: description in, .usda text out

What the stage does and does not contain

This is the part a reader is most likely to get wrong from the artifact alone. The stage is a projection of the plan, joined to a record by planHash. It is not the world. A reader who opens one sees geometry-shaped prims and will naturally read them as a scene. They are not one. Concretely, the stage contains no:

  • geometry. Not one mesh, not one bounding volume, not one primitive shape. Every prim that carries data is a bare Xform, a transform with nothing under it. A system is a coordinate frame, not an antenna; a target is a moving point, not a scatterer with an extent. (Two Scope prims, /World/Systems and /World/Targets, are organizational containers: not transformable, carrying no authored attribute, and present so a consumer can address the two families separately.)

  • materials. No UsdPreviewSurface, no radio material, no permittivity. Whatever a renderer shows is its own fallback.

  • cameras, lights, or renderable content of any kind. Opening this stage in a viewer shows an empty viewport with a handful of transform gizmos, and that is the correct picture of what a ScenePlan knows.

  • world asset content. plan.world_asset names geometry an engine loads; the stage does not reference, inline, or resolve it.

  • any RF quantity beyond the plan’s own scalars. No path, no delay, no power, no capture. Those live in the record, and rfgen:planHash joins the two.

rfgen:rcsDbsm and rfgen:phaseDeg are the two scalars, and both are the plan’s parameters rather than physics. PlannedTarget.rcs_dbsm is one finite float with no aspect-angle, frequency, or polarization dependence, so the stage records a constant-RCS modelling input; a consumer reading it as a measured or angle-resolved RCS is reading in a claim the plan never made. phase_deg carries the identical sentence: it is the plan’s constant scattering-phase parameter, not a measured or dispersive phase. The asymmetry matters more there, because a per-target phase in degrees on a geometry-shaped prim invites being read as a physical phase at some frequency, and there is no frequency in the stage at all.

Each system prim additionally carries rfgen:planDomain, a string holding that PlannedSystem’s domain verbatim ("communications" or "active_radar"). It is the only thing on the stage that separates a communications installation from a radar one: both are bare Xform prims under /World/Systems and nothing about their geometry distinguishes them.

The pose encoding, and the two reversals that cancel

Each system is an Xform carrying xformOp:translate then xformOp:rotateXYZ, both double3, with rotation components degrees(roll, pitch, yaw) unreordered and unnegated. That the components need no rearranging is measured, not assumed: authoring degrees(0.3, -0.7, 1.1) into a double-precision rotateXYZ op gives a local transformation whose upper-left block, transposed, differs from rotation_matrix_from_rpy by 2.220446049250313e-16, one ulp of a float64.

The reason is that the two conventions differ in two ways that cancel exactly. USD’s rotateXYZ means “rotate about X, then Y, then Z” applied to a row vector, which is the product Rx Ry Rz in row-vector layout; transposing that to reach column-vector layout reverses the product and transposes each factor, giving Rz Ry Rx, which is rfgen’s composition letter for letter. A reader who applies one of the two corrections and not the other gets the inverse rotation, and every zero-rotation configuration in this repository, which is all of them, passes under that error.

Two precision facts are load-bearing. AddRotateXYZOp() with no argument defaults to float3, at which the same residual is 2.1846e-08; the exporter passes PrecisionDouble at every op. And UsdGeom.XformCommonAPI is not a shortcut for this: its SetRotate accepts only Gf.Vec3f, so it silently downgrades exactly the op whose precision matters.

receiver_pose is a child Xform named ReceiverPose, not an attribute triple, because a pose is a frame and USD’s word for a frame is a prim, so the encoding is still correct on the day receiver_pose and pose differ. Its xformOpOrder opens with !resetXformStack!, because the plan stores that pose absolute; without the token the two transforms compose and every receiver sits at a doubled pose.

Prim names, and why the join is by attribute

USD prim names must be valid identifiers and every minted target id contains a hyphen (target-000), so a refuse-on-hyphen exporter would refuse every plan rfgen mints that has any target. Tf.MakeValidIdentifier is the other easy answer and it is lossy: it maps 000target to _00target, deleting a character, so two ids can collide and a prim-name collision is a silently merged prim.

So: a reversible encoding, - to _, with usd_prim_name_invalid for anything the replacement does not rescue and usd_prim_name_collision for two ids that encode to one name within a scope. And, decisively, rfgen:planSystemId and rfgen:planTargetId carry the id character for character, unencoded. The join from stage back to plan is therefore by attribute, not by prim name, and it is exact even where the encoding is not injective, which it is not in general, since three of the four id families are validated only for non-emptiness, so a-b and a_b are both legal and both encode to a_b.

Time, trajectories, and what the file cannot promise

Systems are static; targets are animated. A target’s trajectory is exactly two translate time samples, at startTimeCode and endTimeCode, holding location_m and location_m + velocity_m_s * duration_s. Authoring one sample per frame would be a denser encoding of the same line.

That encoding is exact only under linear interpolation, and interpolation is not authorable in a layer at all: it is set per stage by Usd.Stage.SetInterpolationType, and a consumer who opens the stage with Usd.InterpolationTypeHeld gets a target that teleports at the end code instead of moving. Nothing in the file can prevent that, so the stage carries rfgen:trajectoryInterpolation = "linear" as a declared reader requirement and says so rather than claiming a property it cannot own.

customLayerData carries the plan’s schedule and identity:

Key

Type

Meaning

rfgen:eventIds, rfgen:eventSystemIds

string[]

Every PlannedEvent, in plan order

rfgen:eventStartTimeCodes, rfgen:eventStopTimeCodes

double[]

The same events’ bounds as time codes

rfgen:maxIntegralTimeCodeResidualS

double

How far the worst instant sits from an integral code

rfgen:planHash

string

canonical_plan_hash, a bare 64-character digest with no sha256: prefix

rfgen:timeOriginS

string

The epoch as repr, not as a double

rfgen:rotationConvention, rfgen:stageConvention, rfgen:stageConventionVersion

string, string, int

The pins

rfgen:planSchemaVersion, rfgen:trajectoryInterpolation

int, string

The plan schema, and the reader requirement

Four parallel arrays rather than a nested dictionary, because a dictionary inside customLayerData is emitted with its keys sorted, which would destroy plan order, and plan order is the order the seed tree and every downstream projection use.

Both degenerate ends of the rate range usd_time_mapping accepts are refused rather than authored, because that range is arithmetic and a stage is narrower. A rate large enough that duration_s * rate overflows, which 1e308 is against any clock longer than about 1.8 seconds, is refused with usd_end_time_code_not_finite; that end is plan-dependent, which is why it can only be caught once the clock is in hand. A subnormal rate such as 5e-324 leaves endTimeCode finite but makes the inverse map return inf for one ordinary time code, and is refused with usd_time_mapping_degenerate. Either one would author a stage whose time axis no consumer can correlate. usd_time_mapping itself keeps accepting both rates and keeps computing what the numbers say; the projection is where a stage’s narrower rule lives.

Three orderings in the emitted text are OpenUSD’s

All three are easy to mis-review, so they are named here rather than left to be rediscovered. Stage metadata is emitted sorted, so the customLayerData block appears first, above doc. Keys inside customLayerData are emitted sorted. Properties inside each prim are emitted sorted, so xformOp:rotateXYZ stands above xformOp:translate in the text while xformOpOrder still applies translate first. A reviewer who reads the text as authoring order will conclude the exporter has the ops backwards, and it does not. Prim order, by contrast, is the exporter’s, and it is plan order.

The extra, the platforms, and the two gates

The exporter lives behind the optional usd extra, pinned exactly:

pip install 'rfgen[usd]'   # usd-core==26.8

The pin is exact rather than a range because a checked-in byte golden is one of the two gates, and any release inside a range could change metadata ordering or float formatting on a transitive resolve nobody authored. pxr is imported lazily, so a worker without the extra meets a BackendUnavailableError naming rfgen[usd] rather than an import-time crash, and no generation path imports the exporter at all.

Platform note, because an operator should read it before meeting it. usd-core 26.8 publishes wheels for macOS (both architectures), Linux x86_64 glibc, and Windows x86_64, and for no aarch64 Linux target and no musl target at any Python version. An ARM Linux worker (a Graviton or Ampere runner, an aarch64 container, an Alpine base) has no wheel and would have to build OpenUSD from source. That bounds the exporter to a workstation-and-CI tool on the platforms above rather than a guaranteed capability of every worker; it costs nothing else, because nothing in generation imports it.

Two gates cover the output, and only one is authoritative. The semantic gate re-opens the exported text and asserts it against the plan: prim paths, transposed transforms against rotation_matrix_from_rpy, two-sample trajectories, the event arrays against time_code_for, every metadata value. It is robust to any formatting change and may never be waived. The byte golden is a change detector pinned at the recorded usd-core version and may be re-blessed when that version moves, with a provenance note naming the old and new versions, and never while the semantic gate is failing. It is asserted with no platform scoping, because every number reaching the text arrives through IEEE-754 basic operations only, plus the single multiply inside math.degrees; no transcendental function, and therefore no libm, appears in the authoring path.

The invocation path: rfgen export-plan

Module: rfgen.scene.export_command

ExportPlanCommand is the rfgen.commands entry the CLI dispatches rfgen export-plan to. It takes one plan, hands it to one registered exporter, and writes the returned text to a path. Everything else in this seam is a library call; this is the front door. The option list and its exit codes live in Reference / Command-Line Interface, and the reason this seam runs outward only is in Concepts / External Scene Seams.

Name

What it is

ExportPlanCommand

The BaseCommand implementation, registered as export-plan

ExportPlanParams

The validated parameter surface, extra="forbid"

Exactly one plan source. A plan exists in exactly two forms, so the command accepts exactly two:

  • plan_file reads back a written artifacts/plans/<scene_id>/scene-plan.json through ScenePlanProvenance.read, which rechecks the stored time reference against the stored plan. The three trailing path components are enforced, not conventional: the file must be named scene-plan.json, its grandparent directory must be named plans, and its great-grandparent artifacts. A valid artifact copied elsewhere is refused, because the directory name is where the scene_id is read from and cross-checked against the plan.

  • config is a composed GenerationConfig whose plan template is minted at run_seed and sample_index.

Naming both is refused, and so is naming neither: a request carrying a configuration and an artifact has two plans in it and no rule says which one the file describes. run_seed and sample_index are minting parameters and are refused beside plan_file when either is explicitly set, because a written artifact already carries the identity they would decide. The command line refuses them itself rather than relying on this model, because it builds the model from its own options and would otherwise have dropped both before the model saw them; --config-name and --override are refused there on the same grounds, having no counterpart on this model at all. A minting request with no run_seed falls back to run.seed, and refuses with plan_run_seed_absent when the configuration declares neither: the seed is part of the plan identity, so it is never defaulted to a value nobody chose.

The rate is the operator’s, and it is bounded twice. timeCodesPerSecond is a property of the stage rather than of the plan, so it is an option. Making it operator-supplied is what puts the whole rate range usd_time_mapping accepts within reach of a shell, so the CLI refuses the plan-independent degenerate case (a rate too small to invert) as an option-parse error naming the rate, the reason, and what to do about it, and the projection refuses both degenerate cases for every other caller. Neither refusal widens usd_time_mapping, which is arithmetic and right to compute what the numbers say.

The exporter is resolved, not imported. exporter names an entry in rfgen.plan_exporters, so an out-of-tree .usdc or glTF writer is reachable from this command the day its distribution is installed. The resolved class’s declared suffix is checked against the output path and a mismatch is refused with plan_export_suffix_mismatch, because a .usda payload under a .usdc name is a file every consumer opens wrong.

On success the command prints one JSON object carrying output, bytes, exporter, time_codes_per_second, scene_id, and plan_hash. The last is the join back to the generated records: given a stage and a dataset, that one string says which records came from this plan.

The refusal sentences below reach a library caller, not a shell. The CLI maps every ValidationError to {"error": "validation_error", "message": "validation failed"} and exit 3, suppressing both the message and the context code, because a generation-path exception can carry user data. So the coded refusals plan_artifact_absent, plan_template_absent, plan_run_seed_absent, and plan_export_suffix_mismatch, together with the projection’s own usd_prim_name_invalid, usd_prim_name_collision, usd_end_time_code_not_finite, and usd_time_mapping_degenerate, are read in full by code calling ExportPlanCommand.run or the exporter directly, and are read as validation failed by an operator. ExportPlanParams’s own model validators, which cover the plan-source rule, the minting-parameter rule, and the remote-output rule, raise plain ValueError and so reach a library caller as a pydantic.ValidationError carrying no rfgen context code at all; the operator-facing exit code is the same 3. The refusals an operator does get in full are the parse-time ones the CLI raises itself: the plan-source rule, the composition-option rules, a rate that is not finite and strictly positive, and a rate too small to invert.

A written artifact that exists but does not parse is the one exception, and it is exit 2 rather than 3: ScenePlanProvenance.read raises a bare ValueError, which is neither ValidationError nor a pydantic one, so the CLI falls through to the generic branch and reports runtime command failed.

Error surface

The shipped scene-composition error surface is SceneError. The implemented composer uses it for invalid scene/config/channel combinations and does not publish a second scene-specific exception type.

class rfgen.scene.rendering.BaseSceneRenderer

class BaseSceneRenderer(ABC, Generic[RenderResultT]):
    name: ClassVar[str]
    ParamsModel: ClassVar[type[BaseModel]]

    @classmethod
    @abstractmethod
    def from_params(cls, params: BaseModel) -> Self: ...

    def dependency_identity(self) -> str: ...

    @abstractmethod
    def render(self, plan: ScenePlan, context: RenderContext) -> RenderResultT: ...

A projection names what it observes; a renderer produces what is observed. Registering under rfgen.scene_renderers and naming that selector as a projection’s renderer_selector is how a renderer shipped in another distribution is reached without changing Core.

ParamsModel is strict and frozen, and a renderer is built only through from_params, so an invalid parameter is refused before any engine work rather than part-way through a simulation. A projection’s renderer block is a raw mapping validated against this model, not against Core’s — that is what makes the selector load-bearing rather than decorative.

render reads the plan; it does not redraw from it. A fact redrawn here would disagree with the plan that was already minted, digested, and written into the record’s provenance.

dependency_identity names any plugin the renderer resolves for itself — an engine, a solver. Two runs configured identically but resolving different implementations do not produce the same samples and must not claim one identity. The default is empty, meaning the parameters fully describe the behaviour.

The shipped renderers are communications and radar. The rfgen.communications.receiver projection additionally requires generation_config(), which it reads its label grid and receiver background from.