Add a custom placement strategy

Warning

Pre-implementation. APIs describe the target plugin surface.

Goal

Add a Python placement-strategy subclass that controls when (start sample) or where in frequency (absolute carrier frequency) one emitter lands inside a scene, beyond the strategies shipped with DefaultSceneComposer.

When to use this

Use this when the shipped time-placement or frequency-placement strategies do not cover the cadence or carrier distribution your dataset needs.

Time-placement strategies decide start samples:

Value

Human label

iid_uniform

Independent uniform start time

event_timed

Protocol-aware event timing from the active emitter or preset

pri_pulse_train

Radar pulse-repetition-interval (PRI) train

adsb_cadence

ADS-B aircraft surveillance beacon cadence

fhss_dwells

Frequency-hopping dwell schedule

wifi_tbtt

Wi-Fi target beacon transmission time (TBTT)

ble_advertising

Bluetooth Low Energy advertising interval

Frequency-placement strategies decide absolute carrier frequencies:

Value

Human label

iid_uniform

Independent uniform carrier draw

stratified

Spread carriers across configured frequency bins

clustered

Draw near configured carrier anchors

ism_realistic

Industrial, scientific, and medical (ISM) band channel grids

forced_overlap

Deliberately place emitters in overlapping time-frequency regions

Typical reasons to add a custom strategy:

  • A protocol with a documented event cadence not in the Scene Composition Algorithm § Time placement strategies reference cadences (e.g. a custom radar pulse-repetition-interval (PRI) ladder, a proprietary beacon schedule, a machine-learning (ML) traffic model that predicts event times).

  • A carrier-distribution prior pulled from a measured occupancy dataset (for example, National Telecommunications and Information Administration (NTIA) surveys or Aalborg Denmark (DK) measurement campaigns) that the shipped ism_realistic channel-grid table does not cover. These datasets provide measured priors over occupied carrier frequencies.

  • A research baseline that requires reproducing one paper’s exact placement prior.

If you only want to switch among the shipped strategies, set SceneConfig.time_placement.strategy or SceneConfig.frequency_placement.strategy instead; subclassing is not required. If you need a fundamentally different scene-population engine (not just a placement strategy), see Concepts / Scenes / Composition § Available Implementations and subclass BaseSceneComposer instead.

Prerequisites

Read Concepts / Scenes / Composition, particularly the placement-strategy section: the composer calls one BaseTimePlacement.draw and one BaseFrequencyPlacement.draw per emitter during step 6 of the Scene Composition Algorithm. The strategy must be deterministic given its rng argument; the framework seeds rng per-emitter for reproducible composition.

The two ABCs are:

  • BaseTimePlacement: returns a list of start samples for one emitter (one entry for atomic emitters, many for periodic events such as ADS-B beacons or radar PRI trains).

  • BaseFrequencyPlacement: returns one absolute carrier frequency in hertz (Hz) for one emitter.

The required method signatures are BaseTimePlacement.draw(signal: Signal, rng: torch.Generator) -> list[int] and BaseFrequencyPlacement.draw(signal: Signal, scene_bandwidth_hz: float, rng: torch.Generator, *, placed: Sequence[Signal] = ()) -> float.

Both ABCs require a name: str class attribute that the registry uses as the lookup key.

Minimal implementation

Time placement

import torch

from rfgen.core.registry import register_time_placement
from rfgen.core.types import Signal
from rfgen.scene import BaseTimePlacement


@register_time_placement(name="event_lora_class_a")
class LoRaClassATimePlacement(BaseTimePlacement):
    """Class-A LoRaWAN uplink: one emission, two RX windows skipped."""

    name = "event_lora_class_a"

    def __init__(
        self,
        *,
        scene_duration_samples: int,
        sample_rate_hz: float,
        mean_arrival_seconds: float = 60.0,
    ) -> None:
        self.scene_duration = scene_duration_samples
        self.sample_rate_hz = sample_rate_hz
        self.mean_arrival_seconds = mean_arrival_seconds

    def draw(
        self, signal: Signal, rng: torch.Generator
    ) -> list[int]:
        period_n = int(self.mean_arrival_seconds * self.sample_rate_hz)
        starts: list[int] = []
        t = int(torch.randint(0, period_n, (1,), generator=rng).item())
        while t + signal.metadata.duration_samples < self.scene_duration:
            starts.append(t)
            # Poisson-style next interval: -ln(U) * mean
            u = torch.rand((1,), generator=rng).item()
            t += max(1, int(-torch.log(torch.tensor(u)).item() * period_n))
        return starts

Frequency placement

import torch

from rfgen.core.registry import register_freq_placement
from rfgen.core.types import Signal
from rfgen.scene import BaseFrequencyPlacement


@register_freq_placement(name="measured_occupancy")
class MeasuredOccupancyFrequencyPlacement(BaseFrequencyPlacement):
    """Sample carriers from a measured-occupancy histogram per emitter family."""

    name = "measured_occupancy"

    def __init__(
        self,
        *,
        histograms: dict[str, list[tuple[float, float]]],
    ) -> None:
        # histograms[family] = [(f_c_hz, weight), ...]
        self.histograms = histograms

    def draw(
        self,
        signal: Signal,
        scene_bandwidth_hz: float,
        rng: torch.Generator,
        *,
        placed: Sequence[Signal] = (),
    ) -> float:
        family = signal.metadata.class_taxonomy[0]
        bins = self.histograms.get(family) or self.histograms["default"]
        carriers = torch.tensor([b[0] for b in bins])
        weights = torch.tensor([b[1] for b in bins])
        idx = int(
            torch.multinomial(weights, num_samples=1, generator=rng).item()
        )
        return float(carriers[idx])

The signal: Signal argument carries the candidate emitter’s SignalMetadata (bandwidth, duration, taxonomy); the strategy reads what it needs from there.

The placed: Sequence[Signal] argument carries the Signal objects for emitters already placed in the current scene. Each entry’s metadata.realized_carrier_hz is the carrier already committed. Strategies that enforce minimum spacing or target existing emitters read this list; history-independent strategies (like measured_occupancy above) accept the argument and ignore it. The list is empty when the first emitter is placed, and the shipped composer passes that empty history through unchanged, so always return a valid carrier even when placed is empty.

Register

In-tree:

@register_time_placement(name="event_lora_class_a")
class LoRaClassATimePlacement(BaseTimePlacement):
    ...

@register_freq_placement(name="measured_occupancy")
class MeasuredOccupancyFrequencyPlacement(BaseFrequencyPlacement):
    ...

Third-party package: declare the entry-point groups rfgen.time_placement and rfgen.freq_placement in your package metadata. The placement plugin kinds are proposed contracts in this pre-implementation repository and are listed with the other plugin surfaces in Reference / Plugin Metadata § Entry-point group names .

[project.entry-points."rfgen.time_placement"]
event_lora_class_a = "my_pkg.placement:LoRaClassATimePlacement"

[project.entry-points."rfgen.freq_placement"]
measured_occupancy = "my_pkg.placement:MeasuredOccupancyFrequencyPlacement"

The third-party package also declares a top-level PLUGIN: PluginMetadata attribute with plugin_kind="time_placement" (or "freq_placement"); see Reference / Plugin Metadata § Plugin registration for the full schema.

Configure

The composer reads two top-level scene config fields:

scene:
  time_placement:
    strategy: event_lora_class_a
    params:
      mean_arrival_seconds: 30.0
  frequency_placement:
    strategy: measured_occupancy
    params:
      histograms:
        "comms.lora.us915": [[902.3e6, 0.4], [903.9e6, 0.3], [904.6e6, 0.3]]
        default: [[915.0e6, 1.0]]

The framework wires scene.time_placement.params and scene.frequency_placement.params into the strategy constructor; field names must match the strategy’s __init__ keywords. The composer also injects scene_duration_samples=int(SceneConfig.sample_rate_hz * SceneConfig.duration_s) and sample_rate_hz=SceneConfig.sample_rate_hz; these are framework-provided constructor arguments, not user-provided YAML keys.

Verify

rfgen list-time-placements | grep event_lora_class_a
rfgen list-freq-placements | grep measured_occupancy
rfgen generate ./out/local-smoke \
    scene.time_placement.strategy=event_lora_class_a \
    scene.frequency_placement.strategy=measured_occupancy
rfgen inspect ./out/local-smoke distributions \
    --field realized_carrier_hz \
    --field event_starts_samples

Confirm:

  • Determinism: re-running with the same scene seed produces identical start-sample lists and identical carrier offsets per emitter.

  • Time-placement bounds: every returned start sample satisfies 0 <= t < scene_duration_samples - signal.metadata.duration_samples. The composer rejects scenes that violate this bound.

  • Frequency-placement bounds: the returned carrier frequency is compared with each receiver’s passband. Receiver passband means the frequency range centered on ReceiverConfig.center_freq_hz with width ReceiverConfig.bandwidth_hz. The intermediate-frequency (IF) filter in RX capture decides which in-band components remain in each receiver record; out-of-band emitters are dropped for that receiver and logged in scene metadata.

  • Statistics: the rfgen inspect distributions histogram should match the strategy’s expected prior; deviation greater than a few percent on a 10k-sample run usually indicates a seeding bug.

Add contract tests for determinism (same seed -> same output), bound respect, and per-family routing (frequency strategy). See Reference / Contract Tests for the placement-strategy fixtures.

Troubleshoot

Symptom

Fix

rfgen list-time-placements does not show the strategy

Check the register_time_placement decorator name and, for third-party packages, the rfgen.time_placement entry point.

rfgen list-freq-placements does not show the strategy

Same as above for register_freq_placement and rfgen.freq_placement.

Composer raises SceneError for strategy output

The strategy returned an invalid runtime value, such as an empty time-placement list for a sampled emitter or an out-of-bounds carrier/start sample. Add an in-strategy bounds check with a clear retry.

Same emitter, different runs, different placement

The strategy ignored the rng argument or used a global RNG. Always thread rng through every random call.

event_* strategy returns one start sample instead of many

Time-placement draw must return a list; even atomic emitters return [t] rather than t. The composer iterates the list.

Per-family carrier distribution comes out uniform

The frequency strategy reads signal.metadata.class_taxonomy[0] but the active emitters do not set class_taxonomy. Audit the emitter’s metadata, or fall back to a default histogram key.

Next steps

See Also