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 |
|---|---|
|
Independent uniform start time |
|
Protocol-aware event timing from the active emitter or preset |
|
Radar pulse-repetition-interval (PRI) train |
|
ADS-B aircraft surveillance beacon cadence |
|
Frequency-hopping dwell schedule |
|
Wi-Fi target beacon transmission time (TBTT) |
|
Bluetooth Low Energy advertising interval |
Frequency-placement strategies decide absolute carrier frequencies:
Value |
Human label |
|---|---|
|
Independent uniform carrier draw |
|
Spread carriers across configured frequency bins |
|
Draw near configured carrier anchors |
|
Industrial, scientific, and medical (ISM) band channel grids |
|
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_realisticchannel-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_hzwith widthReceiverConfig.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 inspectdistributions 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 |
|---|---|
|
Check the |
|
Same as above for |
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 |
|
Time-placement |
Per-family carrier distribution comes out uniform |
The frequency strategy reads |
Next steps¶
Configure multi-RX output: per-receiver passbands interact with frequency-placement bounds; out-of-band offsets are dropped per receiver.
Generate a dense urban dataset: the worked example uses
ism_realisticfrequency placement andevent_timedtime placement; swap in a custom strategy by replacing those names.Reference / Scene Composition Algorithm: full per-strategy specs for the shipped strategies plus the composer’s step-6 dispatch.
Reference /
rfgen.scenefor the full BaseTimePlacement and BaseFrequencyPlacement contracts.
See Also¶
Concepts / Scenes / Composition: placement strategies in context, custom-composer alternative.
Reference / Plugin Metadata:
PLUGINdeclaration, entry-point groups, plugin-card convention.How-to / Add a custom emitter: adjacent extension surface; emitters define what an emitter is, placement strategies define where it lands in the scene.