Radar response

sionna_rt is the default generation backend (see The sionna_rt backend); radarsimpy is a validation-only oracle selectable only under validation_study: true. Sections describing vendor-probe evidence and license tiers apply to the RadarSimPy adapter alone.

Use rfgen.radar when an active-radar workflow needs a received baseband cube (a rank-three complex array indexed by channel, pulse, and fast-time sample), not merely a transmitted one-dimensional waveform. The request describes the transmitter, receiver array, point targets (idealized single-point reflectors), at most one optional interfering radar, seed, and CPU policy with explicit SI units. The response reports the backend-observed cube and axes.

One configuration produces one radar dataset through the stock CLI: the radar scene renderer reads each sample’s point targets from that sample’s scene plan and simulates one response per sample. The rfgen.radar.receiver projection writes each response’s components as separate native-rank fields. Follow Generate a coupled radar dataset for the runnable path (rfgen init radar-response); the separate chirp-radar Golden Path remains an emitter-branch waveform, not a coupled response; see Cross-Domain Architecture.

RadarSimPy is an optional, separately distributed dependency. Importing rfgen.radar does not import it. Calling RadarSimPyResponse.simulate without an installed authorized runtime raises BackendUnavailableError. The checked 2026-08-13 macOS ARM probe imported a locally supplied RadarSimPy 15.3.0 artifact; the operator additionally observed its free-tier startup message, an observation the probe schema does not record. The probe verified CPU point-target execution, rank-three output, seed behavior, processing calls, and the bounded memory case, but interference contrast failed; its overall state is therefore api_only: the backend’s public API executes, but the runtime acceptance gate below has not passed. That observation makes no legal or deployment conclusion. Interface fakes test only adapter wiring and errors.

Data flow

  1. Build an immutable RadarWaveform, RadarReceiver, and RadarSystem.

  2. Add one or more RadarTarget values to a RadarResponseRequest.

  3. Call a BaseRadarResponse backend.

  4. Apply an optional BaseReceiverFrontend to its ComplexCapture.

  5. Or let the radar scene renderer drive the flow: it builds each sample’s system from that sample’s plan, reads the plan’s targets rather than drawing any, simulates one response, applies the optionally configured frontend stages to every component (recording them in realized["receiver_frontend"]), and hands the result to the rfgen.radar.receiver projection, which persists each component as a separate native field; a capture is not itself a persisted record.

  6. The renderer always receives the sample’s minted ScenePlan as render’s first argument. See Plan-driven radar.

RadarSimPyResponse constructs RadarSimPy’s public Transmitter, Receiver, and Radar objects and calls sim_radar. The adapter itself implements no physics; the vendor engine owns its echo, Doppler, noise, and interference models end to end. The rfgen-owned alternative is the signal chain below, which any path-producing engine can drive. The interf argument is omitted when the request has no interfering system. RadarSimPy v1 accepts at most one interfering system. Each interferer’s radar receives a deterministic derived seed (request.seed + index + 1), recorded in realized["interferer_seeds"]. In the current artifact that seed feeds only phase-noise generation, which the adapter does not configure, so it is inert today; the derivation exists so a future phase-noise-configured path never shares one stream between victim and interferer.

Common receiver frontend

The cross-domain capture container and the receiver-frontend chain both domains run are documented with the stages themselves, on the receiver stages page. The radar path reaches them through the same ReceiverFrontendChain the communications path does; what follows here is what the radar path does around that chain.

Radar signal chain

rfgen.radar.chain is the rfgen-owned half of a radar backend: an engine reduces its world to two-way propagation paths, and the chain owns everything downstream. It exists so ray-traced engines compose with an exactly specified, gate-validated receive path instead of each engine reimplementing one.

  • PropagationPath(delay_s, doppler_hz, gain) is the frozen engine seam: absolute two-way delay at the start of the coherent processing interval, Doppler shift at the carrier (positive closing), and the complex voltage gain of the path as a pure propagation ratio. ChannelPathSet groups paths per (transmit element, receive element) pair, and the chain requires full tx-major channel coverage.

  • paths_for_point_targets(system, targets) is the analytic free-space engine: per-element ranges set the delay, the radial closing rate at the carrier sets the Doppler, and the radar range equation with unit element gains sets the path power. It refuses rotated systems; a directive or rotated system belongs to a geometry-aware engine.

  • dechirped_channel_matrix synthesizes the exact conjugate-mixing product rx * conj(tx) for the fmcw and pulsed_lfm kinds, with the time-varying delay carried through every term: the signed static beat -slope * tau, the range-Doppler coupling (a closing target under an up-chirp appears at shorter apparent range), the residual video phase pi * slope * tau**2, and the 2 * pi * doppler_hz * PRI pulse advance all follow from one phase expression. Each path’s apparent fast-time frequency and time-varying delay are gated at both ends of the coherent processing interval (against half the complex sampling rate and the sweep window), and the fabricated pre-overlap sliver of the fast-time window is bounded by an explicit max_pre_overlap_fraction; violations raise ValidationError. The chain refuses rather than drops: an engine producing many paths owns its own filtering and records what it dropped before calling the chain. The genuine slow-time Doppler ambiguity beyond 1 / (2 * PRI) is the one remaining stated idealization. PMCW code correlation is a recorded follow-up, and the chain rejects pmcw waveforms explicitly.

  • thermal_noise_matrix draws the chain’s explicit, seed-controlled noise: input-referred k * T0 * B * F amplified by the configured gain chain and expressed across the configured load, from one seeded CPU torch generator. The same seed reproduces the same records bit for bit, which the vendor engine’s internal draw does not. That generator is seeded through rfgen.core.rng.seed_generator, the repository-standard seeding, and the change is disclosed here because it re-baselined the radar noise stream. Before it, the generator was seeded with manual_seed, which initializes MT19937 from the low 32 bits of the seed alone; two records whose seeds agreed there received a bit-identical, index-aligned noise realization. By the birthday bound that is about n**2 / 2**33 colliding pairs: roughly 116 pairs per million records, 11.6k at 10M, and about 1.16M pairs involving about 2.30M records (n * (1 - exp(-n / 2**32))) at 100M. seed_generator spends the high bits on a deterministic offset into the stream (STREAM_OFFSET_BITS = 12), lifting the effective stream space from 2**32 to 2**44, so the residual is n**2 / 2**45, about 284 pairs at 100M: a 4096-fold reduction, not an elimination. The claim is scoped. What the fix removes is index-aligned identity. It does not remove shared randomness: two seeds agreeing on the seed word but differing in the offset still draw from one stream at different starting positions, so shifted-copy pairs remain at the pre-fix n**2 / 2**33 rate. The dechirped baseband is untouched, because the baseband synthesis consumes no randomness at all; only the additive noise realization moved. A seed below 2**32 has offset zero and its noise is unchanged, so the change is observable only on the plan-driven and shard-schedule paths, whose seeds come from derive_projection_seed and seed_for and are 62- and 63-bit. The committed cross-backend oracle-probe artifact (use_cases/signal-atlas/radar-v1/docs/validation/results/sionna-rt-oracle-probe.json) runs at seed 7, so it was re-run against the fixed stream and its sionna_rt, reference, and scenario blocks came back byte-identical; it is left as generated rather than re-baselined.

  • ClosedFormRadarSignalChain.synthesize(system, channel_paths, seed=..., plan_context=None) assembles the (frame_tx_rx_channel, pulse, fast_time_sample) cube on the same flattened tx-major channel axis the RadarSimPy adapter records, stamps the shared alignment from rfgen.radar.chain.radar_capture_alignment (both engines share byte-identical geometry, waveform reference, and physical alignment fields; the phase reference and receiver-id prefix name the producing engine, so whole-alignment equality across engines is false by design), and returns the standard baseband plus noise component captures. A plan_context re-stamps that one alignment, which both components share, with the plan’s time_reference; radar_capture_alignment takes that one fact as an optional time_reference parameter. time_origin_s is unconditionally 0.0: where the radar frame sits on the scene clock is the observation projection’s fact, carried as split typed coordinates rather than folded into one float. Omitted, the stamp is the plan-less "radar_frame_start", and the alignment is field for field what it was before the scene planner cycle. The scene offset is metadata: it enters no phase arithmetic, and the chain keeps its CPI-relative internal time base.

The chain’s voltage calibration convention is stated exactly in rfgen.radar.chain.synthesis: a path’s complex amplitude is sqrt(P_tx_watts * load_resistor_ohm) * chain_gain * path.gain. The sionna_rt qualification experiments hold realized cube power to this convention end to end; cross-backend amplitude comparisons against the vendor oracle remain unqualified because the two engines’ absolute scales have not been reconciled.

The analytic gates in tests/unit/test_radar_signal_chain.py hold the chain to physical identities rather than golden values: the signed static-target beat -slope * 2 * R / c, the range-Doppler coupling shift, the residual-video-phase inter-target structure, the moving-target pulse-to-pulse phase increment 4 * pi * v * PRI / lambda, the Doppler sign convention, the noise power against the configured noise figure, rejection of unrepresentable paths, and bit-exact seeded reproducibility.

Plan-driven radar

A scene plan is the single source of truth for what it covers, and for radar that is the system pose, the transmit and receive element geometry, the pulse-repetition schedule, and every target. Two pure projections in rfgen.radar.plan_projection compose those plan facts with the private configuration blocks that stay in configuration (waveform shape, receiver rates, gains, noise figure, load):

  • scene_plan_to_radar_system(plan, planned_event_id, waveform, receiver) resolves the owning system from the named event, injects that event’s pulse_repetition_intervals_s into the waveform block, converts the planned orientation to rotation_deg, and validates the pulse-fit rule (pulse_duration_s <= min(PRI)) here, which is the first place both halves are in hand and therefore the only place the error can name the responsible plan field. The receiver block’s own element_locations_m is not read: the plan owns both arrays.

  • scene_plan_to_radar_targets(plan, planned_event_id) maps the plan’s targets in plan order, propagating each from the plan epoch (clock.time_origin_s) to that event’s start as location_m + velocity_m_s * (start_s - time_origin_s). Velocity, radar cross section, and scattering phase pass through unchanged.

RadarRenderer.render(plan, context) is the only entry point, and it is always plan-driven: there is no params-only branch and no targets block on RadarRendererParams. The plan owns the system geometry, the pulse schedule, and the targets; the renderer contributes the engine, the waveform and receiver hardware, and the optional frontend.

plan_request(plan, request_seed=...) builds that sample’s request from the plan and returns it with a PlanContext. The request seed is context.seed, which the observation runtime derived from the sample seed through the projection namespace — derive_projection_seed(sample_seed, projection_id) in rfgen.core.observation_seed — so two projections of one sample draw from independent streams while both remain reproducible from that sample’s seed.

The renderer then builds the plan’s SceneWorld, hands the backend a PlanContext carrying the plan’s time_reference and that world, and refuses a backend whose runtime_qualified lacks "shared_world" before any engine work happens. Plan-driven radar runs on sionna_rt only this cycle: it is the only backend with a world consumer, so no plan-driven cross-backend oracle comparison exists and the analytic closed-form gates carry the qualification alone.

The sionna_rt backend

sionna_rt is the ray-traced radar response backend (rfgen.radar.backends.sionna_rt.SionnaRTRadarResponse, requires the sionna extra). It divides the physics between two owners. Ray tracing answers the geometric questions: each requested point target is realized as a sphere occluder (its radius is the backend’s one parameter, occlusion_sphere_radius_m) in a Sionna scene, and one deterministic Mitsuba ray cast per target decides visibility, so a target shadowed by another is absent from the response and recorded as occluded. The point-target return itself is closed-form: visible targets synthesize through the shared analytic path engine and ClosedFormRadarSignalChain, with delay to the target center, Doppler at the carrier, the radar-equation amplitude for the configured rcs_dbsm exactly (rcs_exact: true in provenance), and chain-owned seeded noise. No direct transmitter-receiver path is synthesized, so leakage cannot masquerade as a zero-range return. Full responses and datasets reproduce bit for bit from the request seed. A qualification round on the previous realization (stochastic diffuse scattering off a fixed sphere) measured fully developed Rayleigh speckle, a 22 dB realized-amplitude spread for one configured target; that evidence forced this design. World geometry supplied through a plan context decides visibility and nothing else: it contributes no scattering return, no clutter, no ground bounce, and no multipath, and extended-target scattering is not part of this cycle.

When a plan_context carries a SceneWorld, that world is what visibility is resolved against. The backend holds it at the waveform carrier, places one occluder node per target inside it for exactly the resolution, and reads each first-hit distance through the world’s own first_hit_distance query, so a target behind a building is occluded by the building as well as by another target. The occlusion arithmetic, its guard band, and the verdict are unchanged and stay in this backend. The world’s cache key is stamped into realized provenance as world_key, with world_content_addressed recording how strong that trace is (a non-content-addressed digest identifies the reference that was loaded rather than the bytes that came back). Neither field is present when the context carries no world, so the presence of world_key is itself the record of which kind of solve ran. With no world the backend builds its own throwaway scene exactly as before, which is what keeps a plan-less run byte-identical. A plan-driven radar run always carries a world, so it always stamps both fields: a template that names no world asset still builds one, empty, and its key records that emptiness rather than being absent.

Runtime qualification covers point_targets, cpu, fmcw, and shared_world. The first three are evidence-backed by the six qualification experiments in tests/integration/sionna_rt/test_radar_backend_qualification.py (range causality in the exact center bin, Doppler causality, an end-to-end amplitude oracle holding realized cube power to the radar equation across ranges and RCS values with the exact -40 dB per decade slope, chain-owned seeded noise, bit-exact determinism, and occlusion) and the cross-backend oracle artifact (use_cases/signal-atlas/radar-v1/docs/validation/results/sionna-rt-oracle-probe.json). The experiments measure the noise-free plane (training field minus the recorded noise component) where they isolate the target model, and say so. Rotated systems are rejected at simulate (scene-geometry cycles own them), and visibility is evaluated from the system location, with sub-meter element offsets an accepted approximation. shared_world is earned by the live plan-context experiments in tests/integration/sionna_rt/test_radar_plan_context.py, whose headline case runs the whole backend against real geometry: one target occluded by a reflector plate and one clear target of equal radar cross section at the same range produce a response whose power is exactly the one-return radar-equation value, while the same pair against the empty world produces two returns. It is not a string the backend awards itself for accepting a world. The oracle establishes two facts to keep in mind when comparing engines: the two backends sit in exactly conjugate fast-time mixing conventions (both engines’ beat magnitudes now match the analytic oracle to the same sub-bin residual), and their baseband components differ in noise content (the chain’s training field includes its seeded noise, the vendor’s baseband is noise-free with noise separate).

Validation boundary (RadarSimPy adapter)

Construction rejects non-finite or non-positive unit values, malformed timing, empty arrays or targets, pulse overlap, and any device other than cpu. Kind-specific waveform rules enforced at construction: FMCW and pulsed-LFM require exactly two frequency endpoints; frequency_times_s starts at zero and increases strictly; carrier_hz must lie within the waveform RF edges; PMCW requires a constant carrier and one phase row per transmitter element; pulse duration must not exceed the smallest PRI; and a request carries at most one interfering system. Before loading RadarSimPy or allocating output, the adapter estimates bytes for every required returned component (baseband, noise, and optional interference) from pulse count, flattened TX/RX channel count, fast-time samples, and complex64 size. It checks actual component-array bytes again after readback. max_output_bytes does not bound RadarSimPy’s internal working memory or process peak; the isolated-process probe owns that qualification.

For the dechirped waveform kinds, receiver sample rate is the beat-signal sampling rate: dechirping compresses each echo to a low-frequency beat far below the transmitted sweep bandwidth, so RFGen does not compare the two directly. PMCW baseband bandwidth is instead set by the code chip rate; its sampling adequacy is part of PMCW runtime qualification. Range/Doppler Nyquist limits depend on the later variant’s slope, timing, and scene envelope; that validation remains with the configured radar plugin rather than inventing an unresolved core default.

RadarTarget.rcs_dbsm is the target’s radar cross section (RCS, its effective reflective area) in dBsm, meaning decibels relative to one square metre, and is passed unchanged to RadarSimPy; it may be negative for targets smaller than one square metre. rcs_dbsm_to_m2 provides the explicitly named conversion when square metres are required. RadarWaveform validates kind-specific fields: PMCW uses RadarSimPy channel mod_t/phs fast-time modulation, while FMCW and pulsed-LFM forbid those fields. Capabilities separately report implementation support and runtime qualification. runtime_qualified names exactly the capability tokens whose execution the checked-in probe exercised: point_targets, cpu, and fmcw. The probe also verified same-seed determinism, which is evidence rather than a token. PMCW, pulsed-LFM, interference, transmit-array, and receive-array requests, including any carried by an interfering system, are therefore rejected at simulate until each is runtime-qualified. Same-seed determinism is execution-verified for the baseband component only: the current RadarSimPy artifact’s noise draw is not controlled by the request seed, an observed vendor limitation recorded in the delivery evidence. Execution evidence is a weaker claim than the runtime acceptance gate below, which remains the bar for dataset claims.

Public contract

Implementing BaseRadarResponse

BaseRadarResponse is the dependency-injection boundary. A concrete class must define the ParamsModel, implementation_supported, and runtime_qualified class attributes and implement from_params and simulate; nothing else is overridable, because the ABC has no other members. ParamsModel is a Pydantic v2 model; runtime_qualified must be strictly evidence-backed. from_params(params) accepts an already validated exact model and returns a reusable instance. simulate(request, *, plan_context=None) has no persistence side effects, must be deterministic for a fixed request.seed (the contract’s end state; the current RadarSimPy artifact deviates for the noise component; see the determinism scope above, a recorded contract exception), and returns one RadarResponse whose components mapping contains a "baseband" key holding the same object as capture (object identity, not equality).

plan_context is keyword-only and optional, and carries what a scene plan owns: the plan’s derived time_reference and optionally the shared SceneWorld. It deliberately does not carry the scene clock – a backend handed an epoch could only fold it into a float that loses the offset it was meant to preserve. A backend receiving plan_context=None behaves exactly as it does with no plan, field for field, including the "radar_frame_start" and 0.0 alignment stamps. A backend receiving a context must stamp the plan’s time_reference on the alignment it returns, and must query the world when one is present; a backend that cannot consume a world must refuse a context carrying one rather than accept it and ignore it, because a capture whose verdicts never saw the world it names is indistinguishable downstream from one whose did. The caller checks qualification, not the callee, and only for the world: a context whose world is not None may go only to a backend whose runtime_qualified contains "shared_world", which RadarRenderer enforces before any engine work. The parameter is required in the signature even for a backend that never accepts a world, which such a backend signals by omitting that token and refusing. This is a break for an already installed third-party backend written against the earlier one-argument signature.

Framework code calls build_radar_response(params, backend=...) once while constructing a source, then injects the returned ABC instance. Backends are discovered through the public rfgen.radar_responses entry-point group; the entry-point target must be the BaseRadarResponse subclass itself (a class, not a zero-argument factory function; this group is stricter than the generic pattern in Registry). A new backend package registers itself without framework changes:

[project.entry-points."rfgen.radar_responses"]
my_backend = "my_package.backends:MyRadarResponse"

The renderer’s backend_params mapping is validated against the selected backend’s ParamsModel exactly; this is how a registered backend receives its parameters from configuration. An unknown backend name raises PluginNotFoundError; unknown parameters raise ConfigError. The adapter delays its optional import until simulate; a missing or unloadable artifact raises BackendUnavailableError. Invalid units, timing, capability requests, memory bounds, or backend output raise ValidationError. Variant, composer, storage, and CLI modules never import RadarSimPy.

The skeleton below illustrates the contract shape; it is not a runnable example. RadarSimPyResponse is the complete reference implementation.

class Response(BaseRadarResponse):
    ParamsModel = Params
    implementation_supported = frozenset({"cpu"})
    runtime_qualified = frozenset({"cpu"})

    @classmethod
    def from_params(cls, params: BaseModel) -> "Response":
        if not isinstance(params, Params):
            raise TypeError("params must be Params")
        return cls()

    def simulate(
        self,
        request: RadarResponseRequest,
        *,
        plan_context: PlanContext | None = None,
    ) -> RadarResponse:
        ...  # delegate domain physics and normalize to ComplexCapture

Runtime acceptance gate

The runtime acceptance gate is the single scientific bar for dataset claims; runtime_qualified execution evidence never substitutes for it. The gated suite must use an authorized RadarSimPy artifact and establish all of the following on CPU: observed rank-three shape and axes, same-seed determinism (scoped to the baseband component while the recorded vendor noise exception stands), different-seed noise behavior, interf contrast, isolated peak resident set size (process memory), target-range and velocity causality, and receiver-array phase/angle causality. None is inferred from mocked tests.