External Scene Seams¶
rfgen generates radio data. Other tools render pictures of places: NVIDIA Omniverse, Blender, a game engine, a robotics simulator. This page is about the four places where those two worlds touch, what crosses each one, and what deliberately does not.
If you have never worked with 3D scene formats, one term is worth having up
front. USD (Universal Scene Description) is an open file format for
describing a 3D scene: what objects exist, where they are, how they move over
time. Pixar wrote it, and it is what Omniverse reads. A .usda file is USD in
plain text, so you can open one in a text editor and read it. That readability
is why this repository writes .usda and not the binary form: a text file can
be compared line by line in a test, and a difference in it is something a human
can look at.
Two more USD words, because everything below uses them. A prim is USD’s
node in the scene tree. It has a path such as /World/Systems/system_000, a
type, and a set of named attributes, and prims nest inside each other to make
the hierarchy a viewer shows you. A stage is the composed scene a USD file
opens as: the tree of prims plus the file-level settings that describe the whole
scene, such as its units, its up direction, and its time axis.
There are four seams, and they run in two directions.
Seam |
Which way it runs |
What crosses it |
What ships |
|---|---|---|---|
The asset layer |
Naming |
A 3D file’s identity: its kind and its digest |
Yes |
The ingestion boundary |
Inward |
A 3D file becoming a scene the propagation engine solves in |
A refusal, not a converter |
The clock mapping |
Shared |
The scene clock as a stage time axis |
Yes |
The exporter |
Outward |
A scene plan becoming a |
Yes |
The short version: rfgen can name any of these formats, can write USD, and cannot read USD. That asymmetry is deliberate and the sections below each say why.
If you only want the runnable half, it is one command. rfgen export-plan
writes a scene plan as a .usda stage, either from a configuration it mints
the plan from or from a plan artifact a generation run already wrote:
pip install 'rfgen[usd]'
rfgen export-plan --config-dir conf --run-seed 42 --output scene.usda
What you run below has the rest, and the option list is in Reference / Command-Line Interface.
The asset layer: naming a file is not loading it¶
A scene that uses ray tracing needs an actual 3D model of a place, and that model lives in a file somewhere. rfgen records which file, by a rule that never depends on whether the machine doing the recording could open it. The rule has two parts.
The kind is what sort of file it is, derived from the URI’s suffix. A
.usd, .usda, or .usdc is a USD stage; a .usdz is a USD package; an
.xml is a Mitsuba bundle, which is what the shipped ray tracer reads; and
anything the table does not recognize falls through to a Mitsuba bundle too,
which is what keeps configurations written before the USD rows existed on the
kind they already had. The .xml row and the fall-through reach the same kind
by different routes, and the difference matters for the explicit kind field
below: .xml is a suffix the table recognizes, so a declared kind that
contradicts it is refused. Suffix sniffing is
a weak rule and the reference page says so, but reading the bytes is not
available for a gs:// or https:// URI at all, so the suffix is what there
is. An explicit kind field exists for the case the suffix cannot decide, that
is, a URI carrying no recognized geometry suffix. It may only fill that gap: a
declared kind that contradicts a suffix the table does recognize is refused
rather than honoured, because a configuration that disagrees with itself about
what a file is would send the digest down the wrong branch silently.
The digest is a content hash, and what it covers is decided by the URI’s
scheme first and only then by the kind. For a gs://, s3://, or https://
URI no bytes are read at all, so the digest stands in for the asset by covering
the URI and the kind, and the reference is marked as not content-addressed:
its digest identifies the name it was given, not the bytes it points at. For
a built-in Sionna scene it covers the installed Sionna version, because that is
what decides the geometry. Only for a local file does the kind decide, and then
a self-contained file is hashed whole while a file that references other
files, which both a Mitsuba bundle and a USD stage do, is hashed over its root
file only. The reference page states that last limitation plainly rather than
implying a stronger guarantee: editing a referenced mesh without touching the
root produces a different world under an unchanged digest.
What naming a USD asset gets you today is a correct kind and a correct digest in the provenance of every record generated from that scene. It does not get you a loaded scene. That is the next seam.
The ingestion boundary: a refusal is a contract¶
To ray-trace radio propagation through a place, the geometry has to reach the solver. The shipped path is Sionna RT on top of Mitsuba, and the installed Mitsuba cannot read USD in any spelling. So a USD asset handed to the shipped ingest is refused, by name, before any engine work begins.
The refusal is the deliverable. It could have been a converter, and the reason it is not is worth understanding because it is a physics argument rather than an engineering one.
Converting a USD stage into something a radio solver can use means two jobs. The first is geometry: turning surfaces into triangles, which OpenUSD largely does for you. The second is materials, and it has no answer. A radio material in Sionna is described by an ITU material type, meaning one of a small standardized catalogue of building materials with tabulated electrical properties, plus a thickness. From those the solver derives how radio waves reflect off the surface and how much passes through it. A USD stage carries visual materials instead: colour, roughness, metallicity, the parameters a renderer needs to make a picture. There is no function from one to the other. A wall that looks like concrete in a render might be plasterboard, and the two have very different permittivity, which is how strongly a material stores and redirects an electric field and therefore how much of a wave bounces off rather than passing through.
A converter would therefore have to invent a radio material for every surface, and an invented material silently changes every reflection coefficient, every path loss, and every delay spread in the generated corpus: how much of a wave bounces, how much of it is lost on the way, and how smeared in time the arrivals are. Nothing downstream would report an error. The dataset would simply be wrong in a way that looks fine.
So the boundary ships as an extension point with a precise refusal: a named contract, a registry group a third-party ingest can register into, and an error that names the ingest, the kind it was handed, the URI, and the kinds that ingest does read. When someone does the materials work and can defend it, it plugs in here. Until then, the honest answer is a refusal that says which question is unanswered.
The clock mapping: two ways of saying when¶
A scene plan carries a clock: an origin in seconds since the Unix epoch, and a
duration. A USD stage carries a time axis made of time codes, which are
just numbers, plus a timeCodesPerSecond rate that says how many of them make
a second. Animation in a stage is expressed in time codes.
Mapping one onto the other is arithmetic, and the only interesting decision is
where to put zero. rfgen puts startTimeCode at 0.0 and carries the epoch
separately, as a string, in the stage’s own metadata under rfgen:timeOriginS.
Two consequences follow, and both matter to anyone joining a rendered frame back to a radio capture:
A time code is an offset from the start of the scene, not an absolute instant. Reading one back to a wall-clock time needs the origin from the metadata as well.
The epoch is a string on purpose. An epoch-scale timestamp in a double-precision float has roughly a quarter-microsecond of resolution left, and a formatter that rounds it is a formatter that moves the whole capture. Storing Python’s own exact representation of the number and parsing it back makes the round trip exact by construction rather than by the good behaviour of a formatter this repository does not own.
The rate itself is a knob, not a constant. The default makes one time code one
millisecond. A plan whose instants are binary fractions of a second wants a
different one: an event boundary at 0.0625 s is a whole number of time codes at
1024.0 (code 64) and is not one at 1000.0 (code 62.5). That is a real choice for a real plan, so
the exporter takes the rate as a parameter and the command line exposes it.
An event boundary that does not land exactly on a whole time code is neither
rounded nor refused. Rounding would move an event; refusing would reject a
legitimate plan over a cosmetic property of a viewer’s scrub bar. Instead the
stage reports the worst-case gap it contains, in seconds, under
rfgen:maxIntegralTimeCodeResidualS, so a consumer can decide for itself
whether that gap matters.
The exporter: a plan becomes a stage¶
This is the seam that runs outward, and it is the one that works end to end. A
scene plan (see Scenes for what one is and where it comes
from) becomes a .usda file that Omniverse, or anything else that reads USD,
can open. “Minted” is this repository’s word for realizing one: a plan is
derived deterministically from an authored template and a seed, so the same
template and seed always give the same plan.
Two of the plan’s own terms appear on the stage and are worth having. A system is one radio installation the plan places: a pose, and the transmit and receive antenna element positions that go with it. A target is a moving point the plan places for a radar system to see.
The stage is a projection of the plan. It is not the world. This is the sentence to carry away from this page, because the artifact invites the opposite reading. Open one of these files in a viewer and you see prims with transforms on them, arranged in a hierarchy, some of them moving. That looks like a scene. It is not one. What the stage contains is:
One transform per planned system, at its pose, with a
ReceiverPosechild transform always authored. The plan currently requires that pose to equal the system’s own, so the child looks redundant today; it exists so that the day the two differ is a change of values rather than a change of encoding, and it resets the transform stack because the plan stores that pose absolute.One transform per planned target, with exactly two position samples, at the start and end of the clock, because a target moves at constant velocity and two samples on a straight line is the whole trajectory. That is exact only if the reader honours linear interpolation, and a file cannot enforce it: interpolation is chosen when a stage is opened. So the stage declares the requirement in
rfgen:trajectoryInterpolationrather than claiming a property it does not own.Stage metadata: the plan’s hash, the schema version, the stage convention with its version number, the rotation convention, the time origin, the declared interpolation requirement described just above, the event identifiers and their system ids with their start and stop time codes, and the residual above.
And what it does not contain is longer and more important. No geometry: not one mesh, not one bounding volume, not one primitive shape. Every prim that carries data is a bare transform, so a system is a coordinate frame rather than an antenna, and a target is a moving point rather than a scatterer with a size. No materials, no cameras, no lights, so opening the file in a viewer shows an empty viewport with a handful of transform gizmos, which is exactly the right picture of what a plan knows. It does not pull in the world asset from the first seam. And it carries no radio quantity at all: no path, no delay, no power, no capture.
Those live in the generated record, and rfgen:planHash is the join between
the two. Given a stage and a dataset, that one string tells you which records
came from this plan.
Two per-target numbers do appear on the stage and are worth a warning, because
they are the ones most likely to be misread. rfgen:rcsDbsm is the plan’s
constant radar cross-section parameter: how strongly a target reflects
radar energy, in decibels relative to one square metre. For a real object that
number varies strongly with viewing angle and with frequency, which is exactly
why a single constant is a modelling input rather than a measurement.
rfgen:phaseDeg is the plan’s constant scattering-phase parameter and
carries the identical warning. Both are modelling inputs the plan was
authored with. Neither is measured, angle-resolved, or frequency-dependent, and
there is no frequency anywhere in the stage to resolve them against.
Two conventions are pinned, and both are stamped¶
Sionna works in a Z-up world measured in metres. USD has no such default: a stage that does not say otherwise is read as Y-up in centimetres. Writing a stage without stating the convention would therefore hand a consumer a scene lying on its side at a hundredth of its size. Every exported stage states both explicitly, and stamps the convention it used, with a version number, so a consumer can check rather than assume.
Rotations are the same shape of problem one level down. There are many conventions for turning three angles into an orientation, they disagree, and the disagreement is invisible until something points the wrong way. The stage names the one it used.
What you run¶
The exporter is reachable from the command line as rfgen export-plan, and it
takes exactly one plan source. --config-dir composes a configuration and mints
its plan template, which is the path to take when the plan does not exist yet:
rfgen export-plan --config-dir conf --run-seed 42 --output scene.usda
--plan-file reads back the artifact a generation run already wrote, which is
the path to take when you want the stage for a dataset you have:
rfgen export-plan --plan-file artifacts/plans/scene-000/scene-plan.json \
--output scene.usda
Naming both is refused, and so is naming neither. The full option list and the exit codes live in Reference / Command-Line Interface; the contract, the refusals, and the full list of what a stage carries live in Reference / API / Scene.
Writing USD needs the optional usd extra, which pulls in OpenUSD itself.
Without it the command refuses and names the extra. That refusal is a declared
boundary rather than a bug: OpenUSD publishes no wheel for 64-bit ARM Linux or
for musl-based distributions, so a worker on one of those cannot install the
extra without building OpenUSD from source. The full platform table is in
the reference page’s platform note.
See Also¶
Scenes for what a scene plan is and where one comes from.
Scene Geometry for what geometry does in a scene at all.
Records, Receivers, and Assets for how asset identity reaches a record’s provenance.
Reference / API / Scene for the exporter, the clock mapping, and the stage contract.
Reference / API / Engine for the ingestion boundary and its refusals.