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-emitter ground truth in the scene’s reference frame. The shipped rfgen.scene module is a facade over the implemented scene surface in rfgen.scene_composer, rfgen.placement.base, and rfgen.config.scene, so from rfgen.scene import DefaultSceneComposer is the supported import path for the composer and the scene-specific config helpers.

Warning

Pre-implementation. Class signatures, parameter types, and class-attribute defaults are proposals. Once code lands, this page will be regenerated from docstrings via sphinx.ext.autodoc. The shape below matches what autodoc emits so the swap is mechanical.

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, chunked composition) is specified in Scene Composition Algorithm. The classes on this page are the public surface that algorithm is exposed through.

Class index

Class

Kind

Notes

DefaultSceneComposer

concrete

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

BaseSceneComposer

abc

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

BaseTimePlacement

abc

ABC for time-placement strategies (event timing, hop scheduling, PRI sequences). (stub)

BaseFrequencyPlacement

abc

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

MultiRXConfig

config

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

ReceiverConfig

config

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

GeometryPoseConfig

config

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

RTSolverConfig

config

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

SceneAssetsConfig

config

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

SceneGeometryConfig

config

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

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


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,
    chunk_threshold_mb: float | 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.

Class attributes

Attribute

Type

Value

Purpose

name

str

"default"

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

Method: build

def build(
    self,
    *,
    scene_cfg: SceneConfig,
    emitter_pool: Mapping[str, BaseEmitter],
    channel: BaseChannel | ChannelPipeline,
    rng: torch.Generator,
) -> Signal

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

Parameters

Name

Type

Required

Default

Description

scene_cfg

SceneConfig

yes

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

emitter_pool

Mapping[str, BaseEmitter]

yes

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

channel

BaseChannel | ChannelPipeline

yes

Channel application entry point. Pass either a single BaseChannel implementation or a composed ChannelPipeline spanning Group.TX, Group.CHANNEL, Group.RX_CAPTURE, and Group.RX_HARDWARE. The composer dispatches pre-sum transformations per emitter and post-sum transformations per receiver per the Scene Composition Algorithm

rng

torch.Generator

yes

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

Returns

A scene-level Signal:

  • signal.iq : IQ Shape (2, num_samples) for single-RX, (num_rx, 2, num_samples) for multi-RX, dtype float32. When the predicted tensor exceeds chunk_threshold_mb, the composer still returns the fully materialized IQ tensor and annotates chunk geometry in signal.metadata.extras via {"chunked": true, "chunk_samples": ..., "chunked_signal": ChunkedSignal(...)}.

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

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

Raises

  • SceneError if scene_cfg is not a validated SceneConfig instance.

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

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

Example

import torch
from rfgen.scene import DefaultSceneComposer
from rfgen.config import SceneConfig
from rfgen.emitters import ChirpRadarEmitter
from rfgen.propagation 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 geometry-free path: AWGNChannel has requires_geometry = False, and scene_cfg declares no geometry/assets, so the composer never touches Sionna. 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 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. 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.emitters.torchsig_tone import TorchSigToneEmitter
from rfgen.enums import DensityMode, SceneGeometryBackend
from rfgen.propagation 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,
    emitter_pool={"tone": TorchSigToneEmitter()},
    channel=SionnaRT(),
    rng=torch.Generator().manual_seed(7),
)

center_hz=2.4e9 supplies the 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.

Method: schema

def schema(self) -> type[BaseModel]

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

Notes

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

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

  • Channel application mode. scene_cfg.channel_application selects between ChannelApplicationMode.SCENE (default for statistical propagation; one propagation call per receiver on the summed master IQ) 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. RT backends that declare requires_geometry = True always fan out per (emitter, RX) call even when SCENE is selected, because the solve needs a concrete TX pose.

  • Chunked composition. Triggered automatically when duration_samples * 8 / 1e6 > scene_cfg.chunk_threshold_mb. The current implementation records chunk geometry in metadata for downstream chunk-aware storage, but still materializes the full IQ buffer in memory.

  • component_signals invariant. Each component’s realized_carrier_hz, start_sample, duration_samples are absolute within the scene; the label layer reads these without further transformation.

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

See Also


Compatibility alias: rfgen.scene.OSMSceneBuilder

No shipped rfgen.scene.OSMSceneBuilder exists in this branch. Older docs may still link here; 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

No shipped rfgen.scene.SceneContext exists in this branch. The composer assembles runtime propagation payloads with ChannelContext and ChannelRxParams from rfgen.channels.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 placing one emitter in time within a scene."""

    name: str

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

Kind. Abstract base class.

Returns one start sample for atomic emitters, or many for periodic / event- timed sources (radar PRI trains, ADS-B 1 Hz beacons, FHSS hop schedules, Wi-Fi TBTT, BLE advertising). Concrete strategies (iid_uniform, event_timed, pri_pulse_train, adsb_cadence, fhss_dwells, wifi_tbtt, ble_advertising) are documented in Scene Composition Algorithm § Time placement strategies. Custom strategies plug in by registering a name against the EntryPointRegistry.

Method: draw

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

Returns one or more start-sample positions (in samples at the emitter’s native sample rate) where the emitter’s burst is placed within the scene duration.

Parameters

Name

Type

Description

signal

Signal

The emitter’s IQ burst; signal.metadata.duration_samples is the burst length in samples at signal.metadata.sample_rate_hz. Used to check fit within scene bounds.

rng

torch.Generator

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

Returns

A list[int] of zero-indexed start samples. Each value s is an absolute sample offset within the scene duration such that s + signal.metadata.duration_samples <= scene_duration_samples. For an atomic emitter (one burst), the list has exactly one element. For periodic emitters (PRI trains, beacons), the list has one element per repetition.

Extension contract

  1. Non-negative. Every returned start sample MUST be >= 0.

  2. In-bounds. Every returned start sample s MUST satisfy s + signal.metadata.duration_samples <= scene_duration_samples (the scene’s total sample count at signal.metadata.sample_rate_hz).

  3. Non-empty. MUST return at least one sample. An empty list is an error; the shipped composer surfaces this as SceneError.

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

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

Design note: why no placed argument

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

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

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

Extension points

Method

Status

draw()

Must override

name

Must set as ClassVar


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: ...

Warning

Pre-implementation. This page describes proposed contracts. Class signatures, parameter types, config field names, and behavior are subject to change before code lands. Once implementation exists, content here will be regenerated from docstrings or sourced from running tests.

Kind. Abstract base class.

Returns the absolute carrier frequency in Hz for one emitter. 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 absolute carrier frequency in Hz at which the emitter’s burst will be placed within the scene.

Parameters

Name

Type

Description

signal

Signal

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

scene_bandwidth_hz

float

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

rng

torch.Generator

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

placed

Sequence[Signal]

Signals already placed in this scene (default: empty). Strategies that enforce minimum spacing (iid_uniform) or target previously placed emitters (forced_overlap) read placed[i].metadata.realized_carrier_hz. Strategies that do not need it (stratified, realistic_density, clustered) ignore it.

Returns

A float absolute carrier frequency in Hz. The returned value is the emitter’s realized_carrier_hz after placement.

Extension contract

  1. In-band. The returned carrier f_c MUST satisfy |f_c| + signal.metadata.bandwidth_hz / 2 <= scene_bandwidth_hz / 2, ensuring the emitter’s occupied band fits within the scene. Strategies that cannot place the emitter in-band (e.g., the scene is too narrow for the emitter) should raise SceneError.

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

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

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

Extension points

Method

Status

draw()

Must override

name

Must set as ClassVar


Compatibility alias: rfgen.scene.SceneOverlapPolicyConfig

No shipped rfgen.scene.SceneOverlapPolicyConfig exists in this branch. Overlap handling is configured by the enum field SceneGeometryConfig.overlap_policy on SceneGeometryConfig. Older docs that mention p_overlap, retry_budget, or margin_hz describe a superseded proposal, not the implemented rfgen.scene facade.


class rfgen.scene.MultiRXConfig

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

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

The implemented contract is narrow:

  • geometry is a closed-enum array preset.

  • receivers is an explicit list of ReceiverConfig entries.

  • The two are mutually exclusive.


class rfgen.scene.ReceiverConfig

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

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


Abstract base classes

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,
    ) -> 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 propagation per (emitter, RX) pair; mix into per-RX buffer.
        8. Apply Group.RX_CAPTURE transformations per receiver on summed IQ
           (RX mixer, IF filter, resampler, LNA noise).
        9. Apply Group.RX_HARDWARE transformations 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 placed emitter (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 partitions the
        normalized pipeline by transformation.value // 10:
          Group.TX (1x) and Group.CHANNEL (2x) run pre-sum per emitter.
          Group.RX_CAPTURE (3x) and Group.RX_HARDWARE (4x) run post-sum per receiver.

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

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

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

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

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

build()

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

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 in this branch.