Configure Multi-Receiver Output

A scene with several receivers still publishes one record per sample. There is no record-fan-out setting; storage has no record_axis field, and a config that sets one is refused by name. Each receiver appears as a named subtree inside the single record, so you choose the per-receiver view at read time rather than at write time.

Generate a two-receiver dataset

Start from a materialized generation configuration. The narrowband baseline already declares one receiver under scene.multi_rx.receivers; this adds a second at a different position, then validates, generates, and inspects:

rfgen init narrowband-baseline ./multi-rx-config
cd ./multi-rx-config
python - <<'PY'
import copy
from pathlib import Path

import yaml

path = Path("config.yaml")
config = yaml.safe_load(path.read_text())
receivers = config["scene"]["multi_rx"]["receivers"]
second = copy.deepcopy(receivers[0])
second["rx_id"] = "classifier-rx-b"
second["rx_pose"]["position_m"] = [-40.0, 0.0, 1.5]
receivers.append(second)
path.write_text(yaml.safe_dump(config, sort_keys=False))
PY
rfgen validate --config-dir .
rfgen generate --config-dir . --num-samples 2
rfgen inspect ./rfgen-output

scene.multi_rx is mutually exclusive with the scene.rx_array preset: state receivers one way or the other, not both.

What the record holds

inspect reports two records, not four, and one IQ field per receiver:

{
  "fields": {
    "projections/receiver/labels/boxes/extent":    {"axes": [["box", "extent"]],                 "dtypes": ["<f8"], "shapes": [[1, 4]]},
    "projections/receiver/labels/boxes/identity":  {"axes": [["box", "attribute"]],              "dtypes": ["<i4"], "shapes": [[1, 2]]},
    "projections/receiver/labels/segmentation":    {"axes": [["receiver", "frequency", "time"]], "dtypes": ["<i2"], "shapes": [[2, 1024, 8]]},
    "projections/receiver/labels/segmentation/coordinates/time_offset_s":
                                                   {"axes": [["time"]],                          "dtypes": ["<f8"], "shapes": [[8]]},
    "projections/receiver/receivers/rx0/iq":       {"axes": [["time"]],                          "dtypes": ["<c8"], "shapes": [[2000]]},
    "projections/receiver/receivers/rx1/iq":       {"axes": [["time"]],                          "dtypes": ["<c8"], "shapes": [[2000]]}
  },
  "record_count": 2,
  "sample_ids": ["..."]
}

Three things follow from that inventory:

  • IQ is per receiver. Receiver N of the projection receiver is stored at projections/receiver/receivers/rx<N>/iq, in declaration order. Each field is a rank-1 complex time series; no synthetic receiver axis is stacked onto IQ.

  • Segmentation gains a leading receiver axis once a scene has more than one receiver: rank 3 (receiver, frequency, time) for the single-label int16 raster, rank 4 (receiver, class, frequency, time) for the multi-label uint8 raster. A single-receiver scene keeps rank 2 and rank 3.

  • Boxes are declared once per record, in the shared receiver-baseband frame, and are not repeated per receiver. That frame is unambiguous because a scene whose receivers do not agree on a centre frequency is refused with a LabelError naming “heterogeneous receiver center frequencies”.

Verify the layout

Run this from multi-rx-config after the commands above:

import signal_dataset as sd

dataset = sd.open("./rfgen-output")
records = list(dataset)

assert len(records) == 2  # two requested samples, two records

for record in records:
    rx = sorted(name for name in record.keys() if name.endswith("/iq"))
    assert rx == [
        "projections/receiver/receivers/rx0/iq",
        "projections/receiver/receivers/rx1/iq",
    ]
    assert all(record[name].data.ndim == 1 for name in rx)

    segmentation = record["projections/receiver/labels/segmentation"]
    assert segmentation.data.ndim == 3
    assert segmentation.data.shape[0] == len(rx)  # leading receiver axis
    assert [axis.id.rsplit("/", 1)[-1] for axis in segmentation.axes] == [
        "receiver",
        "frequency",
        "time",
    ]

    assert record["projections/receiver/labels/boxes/extent"].data.shape[1] == 4

To train one example per receiver, select the subtree you want and slice the matching index off the segmentation’s leading axis:

record = records[0]
segmentation = record["projections/receiver/labels/segmentation"].data

for index, name in enumerate(["rx0", "rx1"]):
    iq = record[f"projections/receiver/receivers/{name}/iq"].data
    mask = segmentation[index]
    assert iq.shape == (2000,)
    assert mask.shape == (1024, 8)

Receiver and record-layout contracts are documented in Records, Receivers, and Assets. Multi-receiver configuration is available independently of the two local Golden Path qualifications; verify the output shape and labels for your own configuration.