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 |
|---|---|---|
concrete |
Reference BaseSceneComposer implementation; samples emitters, places, applies channels, sums. Fully documented below as the canonical example. |
|
|
data contracts |
Explicit multi-event opt-in that consumes resolved duration/start decisions and records composition provenance. |
|
data contracts |
The scene-plan vocabulary (in |
|
scene identity |
The derived identity every plan-driven capture is stamped with (in |
abc |
Renders one scene plan into one domain’s simulated state. Resolved by a projection through |
|
|
data contract |
The seed and sample id one render is given. |
|
provenance |
The write-once scene-plan artifact (in |
|
engine identity |
Returns an installed engine’s version from distribution metadata (in |
|
provenance mechanism |
The one durable write-once artifact writer (in |
|
shared builder |
The one Sionna world both domains build base geometry from (in |
|
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 |
|
minting |
Turns one authored ScenePlanConfig template plus a run seed and a run-global sample index into one |
|
asset identity |
The one URI-to- |
|
projection |
The communications projection of a |
|
data contract |
Optional stable zero-based sample ordinal for retry- and worker-independent corpus scheduling. |
abc |
ABC for scene composers; orchestrates emitter selection, placement, channel application, and IQ summation. |
|
|
abc |
ABC (in |
|
abc |
ABC (in |
abc |
ABC for bounded, scene-rate event-start schedules. |
|
abc |
ABC for frequency-placement strategies (uniform, stratified, ISM-realistic, forced overlap). |
|
config |
Convenience re-export of rfgen.config.MultiRXConfig for explicit multi-RX layouts or array presets. |
|
config |
Convenience re-export of rfgen.config.ReceiverConfig for per-receiver geometry and RF overrides. |
|
config |
Convenience re-export of the typed scene-frame pose payload used by TX and RX geometry fields. |
|
config |
Convenience re-export of RT solver knobs used under SceneGeometryConfig. |
|
config |
Re-exported scene-asset pointer model (geometry, materials, antenna blobs). |
|
config |
Re-exported geometry-backend selector and overlap-policy carrier. |
|
|
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 |
|---|---|---|---|
|
str |
|
Registry key. The composer is selected by |
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 |
|---|---|---|---|---|
|
yes |
– |
Validated scene configuration: bandwidth, sample rate, duration, density, placement strategies, overlap policy, multi-RX geometry, channel-application mode |
|
|
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 |
|
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 |
|
|
torch.Generator |
yes |
– |
Master RNG. Per-slot RNGs are derived deterministically per the Determinism reference |
|
|
no |
|
Explicit planned-event opt-in. Each event’s resolved duration, start, device identity, and |
|
|
no |
|
Root under which opt-in composition creates immutable |
Returns¶
A scene-level Signal:
signal.iq: IQ Shape(2, num_samples)for single-RX,(num_rx, 2, num_samples)for multi-RX, dtypefloat32. The composer always returns the fully materialized tensor; bound a scene to available memory throughscene.duration_sandscene.sample_rate_hz.signal.metadata: SceneMetadata Carriesrealized_*audit fields (count, SNR percentiles, class histogram, cochannel-overlap rate, occupancy fraction) plus the resolved scene RNG seed.Signal.metadatais typedUnion[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 withrealized_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.geometrystill carries a canonical GeometryProvenance object for the RT solve, identified byextras["rt_geometry_provenance_canonical_rx_id"]; receiver-specific pose variants remain underextras["rt_geometry_provenance_by_rx"], and the receiver-invariant shared fields remain mirrored underextras["rt_geometry_provenance_shared"]. For geometry-backed RT scenes, scalar scene-levelsnr_db/sinr_dblabels 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_cfgis not a validated SceneConfig instance.SceneError if the propagation backend’s
requires_geometryflag disagrees with the presence of a 3D site geometry inscene_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 masterrngper Determinism; multi-RX RNGs are derived from(scene_seed, rx_idx).Schema validation.
schema()returns the constructor model, notSceneConfig;buildexpects an already-validated SceneConfig.Channel application mode.
scene_cfg.channel_applicationselects 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 withPER_EMITTER. The current runtime does not fail fast forSCENE: RT nevertheless fans out per(emitter, RX)call becauserequires_geometry=True, while the statistical Sionna backends reach propagation without a TX pose and raise. See Sionna integration and the Scene Composition Algorithm.component_signalsinvariant. Each component’srealized_carrier_hz,start_sample,duration_samplesare 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
buildcall 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 inscene_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 noallow_unused_geometrydowngrade path.
See Also¶
Concepts / Scenes - conceptual framing
Scene Composition Algorithm - full 10-step spec
API Reference / BaseSceneComposer - ABC contract
Config Schema § SceneConfig - full parameter surface
Determinism - per-slot RNG derivation
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 |
|---|---|---|
|
|
One uniformly drawn start. |
|
|
PRI-style cadence with optional Gaussian start jitter. |
|
|
Fixed-cadence starts with a uniformly sampled phase. |
|
|
Generic capped-Pareto-derived cadence increments. |
|
|
Exponential-kernel Hawkes point-process schedule. |
|
|
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 |
|---|---|---|
|
Post-resampling scene-rate event. |
|
|
|
All randomness for this placement is drawn from |
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¶
Integral and bounded. Every start MUST be an integer satisfying
0 <= start <= scene_samples - event_samples; both endpoints are allowed.No silent cropping. A result outside that inclusive range is invalid. The composer raises SceneError rather than shortening or moving the event.
Empty only by opt-in. The default is non-empty. A strategy may return
[]only when it declaresmay_return_empty = True, to represent a valid stochastic no-event realization. The composer rejects empty results from all other strategies.Deterministic. Same
(signal, rng)state MUST produce the same list. All randomness MUST come fromrng.No in-place mutation. MUST NOT modify
signalor its metadata.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 |
|---|---|
|
Must override |
|
Must override; return the constructor-parameter |
|
Set to |
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 |
|---|---|---|
|
The emitter’s IQ burst; |
|
|
float |
Total scene bandwidth in Hz, centered at 0 Hz (baseband). The strategy uses this to enforce the in-band invariant. |
|
|
All randomness for this placement is drawn from |
|
|
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 |
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¶
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.Finite. The returned value MUST be finite (not
nan, notinf).Deterministic. Same
(signal, scene_bandwidth_hz, rng, placed)state MUST produce the same offset. All randomness MUST come fromrng.No in-place mutation. MUST NOT modify
signal, its metadata, or any element ofplaced.
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:
geometryis a closed-enum array preset.receiversis 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=Truewith no 3D geometry block: raise SceneError.requires_geometry=Falsewith 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 |
|---|---|
|
The axis one clock denotes: the rate, the two codes, and the epoch |
|
Builds it; refuses a rate that is not finite and positive |
|
The mapping and its inverse |
|
How far an instant sits from the nearest integral code, in seconds |
|
The epoch as the |
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 |
|---|---|
|
The ABC: |
|
The in-repo |
|
The pure projection, testable with no USD runtime. It returns |
|
Whether a string is already a legal USD identifier |
|
The prim-naming rule; |
|
The vendor half: description in, |
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. (TwoScopeprims,/World/Systemsand/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
ScenePlanknows.world asset content.
plan.world_assetnames 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:planHashjoins 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 |
|---|---|---|
|
|
Every |
|
|
The same events’ bounds as time codes |
|
|
How far the worst instant sits from an integral code |
|
|
|
|
|
The epoch as |
|
|
The pins |
|
|
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 |
|---|---|
|
The |
|
The validated parameter surface, |
Exactly one plan source. A plan exists in exactly two forms, so the command accepts exactly two:
plan_filereads back a writtenartifacts/plans/<scene_id>/scene-plan.jsonthroughScenePlanProvenance.read, which rechecks the stored time reference against the stored plan. The three trailing path components are enforced, not conventional: the file must be namedscene-plan.json, its grandparent directory must be namedplans, and its great-grandparentartifacts. A valid artifact copied elsewhere is refused, because the directory name is where thescene_idis read from and cross-checked against the plan.configis a composedGenerationConfigwhoseplantemplate is minted atrun_seedandsample_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.