Device population

Use rfgen.population before distributed scene generation when several scenes must refer to the same virtual transmitters. It gives each requested device a stable identity, draws its hardware fingerprint through the existing DeviceRegistry, and writes one portable population record. Later scene and waveform components consume that identity and fingerprint; this service does not plan events, resolve trajectories, or generate IQ samples.

Quick start

The following is a complete local example. It creates two drones and one controllers device, then writes the record under an output directory. Use the same run_id, seed, namespace, and group definitions whenever the same population must be reused.

from pathlib import Path

from rfgen.population import DevicePopulationService

service = DevicePopulationService()
population = service.resolve(
    run_id="demo-population",
    seed=1337,
    groups={"drones": 2, "controllers": {"count": 1, "fingerprint_family": "default"}},
)
artifact = service.write_artifact(population, run_id="demo-population", root=Path("output"))

assert artifact == Path("output/artifacts/populations/demo-population/population.json")
assert len(population.devices) == 3

API at a glance

Symbol

Primary behavior

device_population_id(namespace, group_id, index)

Returns the stable SHA-256 identity for one position in a named group.

PopulationGroup

Validates one group name, count, and fingerprint-family name.

PopulationDevice

Holds a stable device ID, its group, and one complete canonical fingerprint.

DevicePopulation

The immutable schema-version-1 record returned by resolution and written to disk.

PopulationShard

One deterministic partition, reassembled into a durable population record.

DevicePopulationService

Resolves the record from request inputs and writes its canonical JSON artifact.

DevicePopulationService

class DevicePopulationService:
    def __init__(self, registries: Mapping[str, DeviceRegistry] | None = None) -> None: ...

    def resolve(
        self,
        *,
        run_id: str,
        seed: int,
        groups: Mapping[str, int | Mapping[str, object] | PopulationGroup]
        | Iterable[PopulationGroup | Mapping[str, object]],
        namespace: str | None = None,
        pool_size: int | None = None,
        reuse_policy: str = "across_scenes",
        shard_count: int = 1,
    ) -> DevicePopulation: ...

    def write_artifact(
        self, population: DevicePopulation, *, run_id: str, root: str | Path = "."
    ) -> Path: ...

    def resolve_shard(
        self,
        *,
        run_id: str,
        seed: int,
        groups: Mapping[str, int | Mapping[str, object] | PopulationGroup]
        | Iterable[PopulationGroup | Mapping[str, object]],
        shard_index: int,
        shard_count: int,
        namespace: str | None = None,
        pool_size: int | None = None,
        reuse_policy: str = "across_scenes",
    ) -> PopulationShard: ...

    @staticmethod
    def reassemble_shards(shards: Iterable[PopulationShard]) -> DevicePopulation: ...

Constructor

registries is an optional mapping from fingerprint_family to an existing DeviceRegistry. When omitted, the service creates one registry named "default". A group whose family has no registry fails with structured fingerprint_incompatible validation information rather than silently substituting a different prior.

resolve

resolve returns a frozen DevicePopulation. It normalizes text to NFC, sorts groups and device records by ID, and uses a CPU-seeded deterministic draw path. Thus the same valid request produces the same device identities and records regardless of shard_count. resolve performs the concrete partition and reassembly path, so that parameter is not advisory.

Argument

Required

Valid values and behavior

run_id

yes

A nonempty NFC string used as one safe filesystem path segment. It cannot be ., .., or contain / or \\.

seed

yes

An integer from 0 through 2^64 - 1; it controls fingerprint draws but not identity derivation. Boolean values are rejected.

groups

yes

Either shorthand such as {\"drones\": 2}, explicit mappings such as {\"drones\": {\"count\": 2, \"fingerprint_family\": \"default\"}}, or PopulationGroup-compatible entries. Group IDs must be unique; count is a non-boolean integer at least zero.

namespace

no

Nonempty NFC string; defaults to run_id. It separates the stable ID namespace.

pool_size

no

A non-boolean integer at least zero. In the current v1 contract it is an upper bound on the sum of group counts: exceeding it raises fingerprint_incompatible. It does not select, allocate, or recycle a device pool.

reuse_policy

no

Must be exactly "across_scenes"; this is the only supported v1 policy.

shard_count

no

Positive non-boolean integer, default 1. It must not change the returned record for the same request.

resolve_shard and reassemble_shards

Distributed callers can use resolve_shard to resolve one partition at a time. The canonical global position of a device is its sorted group/index position; it belongs to position % shard_count. A PopulationShard retains the complete request group declaration while containing only that partition’s devices. Pass every index from 0 through shard_count - 1 exactly once to reassemble_shards before writing an artifact. Reassembly checks matching request metadata, complete partition coverage, derived IDs, and partition membership, then returns the same sorted DevicePopulation as one-shard resolution.

from rfgen.population import DevicePopulationService

service = DevicePopulationService()
shards = [
    service.resolve_shard(
        run_id="demo-population",
        seed=1337,
        groups={"drones": 2, "controllers": 1},
        shard_index=index,
        shard_count=2,
    )
    for index in range(2)
]
population = DevicePopulationService.reassemble_shards(shards)
assert len(population.devices) == 3

The ID for group index i is the lowercase SHA-256 digest of NFC UTF-8 bytes for namespace + "\\0" + group_id + "\\0" + i. A NUL is a zero-byte separator, which makes the three components unambiguous. The seed is kept separate deliberately: changing it changes the fingerprint draw while keeping the same named device identity.

write_artifact

write_artifact returns the Path it wrote. It creates <root>/artifacts/populations/<run_id>/population.json and writes UTF-8 JSON with sorted keys and compact separators, followed by one newline. run_id has the same safe-segment requirements as resolve; root defaults to the current directory. Rewriting an unchanged population at the same path yields the same bytes.

Public record models

All four models are frozen Pydantic v2 models with extra="forbid". They reject unknown fields and cannot be changed in place after validation. Use DevicePopulation as the durable artifact; use PopulationShard only while workers are resolving and combining one request.

class PopulationGroup(BaseModel):
    group_id: str
    count: int
    fingerprint_family: str = "default"

class PopulationDevice(BaseModel):
    device_id: str
    group_id: str
    fingerprint_params: dict[str, object]

class DevicePopulation(BaseModel):
    schema_version: Literal[1] = 1
    namespace: str
    seed: int
    reuse_policy: Literal["across_scenes"] = "across_scenes"
    groups: tuple[PopulationGroup, ...]
    devices: tuple[PopulationDevice, ...]

class PopulationShard(BaseModel):
    schema_version: Literal[1] = 1
    shard_index: int
    shard_count: int
    namespace: str
    seed: int
    reuse_policy: Literal["across_scenes"] = "across_scenes"
    groups: tuple[PopulationGroup, ...]
    devices: tuple[PopulationDevice, ...]

PopulationGroup

Field

Type

Default

Contract

group_id

str

Nonempty NFC text without NUL. A request cannot repeat a group ID.

count

int

Non-boolean integer greater than or equal to zero.

fingerprint_family

str

"default"

Nonempty NFC registry-family name. It must select a registry supplied to DevicePopulationService.

PopulationDevice

Field

Type

Contract

device_id

str

Lowercase 64-hex SHA-256 ID. For a durable record it must equal device_population_id(namespace, group_id, index) for exactly one declared group/index position.

group_id

str

Nonempty NFC group identifier declared by the containing request.

fingerprint_params

dict[str, object]

Complete canonical JSON serialization of FingerprintParams; see the field table below. Partial or coercive fingerprint payloads are rejected.

DevicePopulation

This is the only record accepted by write_artifact. Its groups and devices tuples are both sorted by the service. schema_version is always 1; seed is a non-boolean integer in [0, 2^64 - 1]; and reuse_policy is always "across_scenes". Validation requires every declared group/index position to occur exactly once in devices, with its generated ID and matching group. A partial record, duplicate ID, unfamiliar group, or wrong generated ID raises Pydantic validation before persistence.

PopulationShard

PopulationShard is an in-memory partition, not a durable population artifact. It repeats the request metadata and full groups declaration but contains only the devices whose sorted global position satisfies position % shard_count == shard_index. shard_index is a non-boolean integer in [0, shard_count), and shard_count is a positive non-boolean integer. Its device rows must be generated IDs for declared groups, but they need not cover every group position; reassemble_shards performs that complete coverage check. Passing a shard directly to write_artifact is unsupported: reassemble all shards first.

device_population_id

def device_population_id(namespace: str, group_id: str, index: int) -> str: ...

Returns a lowercase 64-hex SHA-256 device ID. namespace and group_id must be nonempty NFC text without NUL; index must be a non-boolean integer greater than or equal to zero. TypeError is raised for non-text namespace/group inputs and ValueError for invalid text or index values.

Record and fingerprint schema

DevicePopulation is a frozen Pydantic model with extra="forbid". Its JSON artifact contains these fields:

Field

Type

Meaning

schema_version

literal 1

Version of this artifact schema.

namespace

string

Stable-ID namespace used for every device.

seed

unsigned 64-bit integer

Fingerprint-draw seed.

reuse_policy

literal "across_scenes"

Reuse statement for this population.

groups

array of PopulationGroup

Each item has group_id, nonnegative count, and fingerprint_family.

devices

array of PopulationDevice

Each item has device_id, group_id, and fingerprint_params; records are sorted by device_id.

fingerprint_params is not a reduced population-specific projection. It is the complete JSON-mode serialization of the versioned FingerprintParams model, and must contain exactly these keys:

Key

Unit or meaning

cfo_hz

Carrier-frequency offset, Hz.

sfo_ppm

Sample-frequency offset, parts per million.

iq_imbalance_db

IQ amplitude imbalance, dB.

iq_imbalance_rad

IQ phase imbalance, radians.

pa_p, pa_a

Rapp power-amplifier shape and saturation parameters.

phase_noise_dbc_hz

Phase-noise power spectral density in dBc/Hz: decibels relative to the carrier per hertz.

pa_model

Closed power-amplifier model selector.

alpha_a, beta_a, alpha_phi, beta_phi

Saleh AM/AM and AM/PM parameters.

See Device fingerprint for the fingerprint model’s defaults, physical bounds, and prior distributions.

The record is closed before it can be persisted: namespace, group_id, device_id, and seed reject coercion from other scalar types; every device ID must be the documented NFC UTF-8 hash for exactly one declared group/index position; and every declared position must appear exactly once. This prevents a hand-built or programmatically altered DevicePopulation from writing a partial population or mismatched fingerprint binding.

Errors and exclusions

Invalid scalar values, malformed groups, incomplete persisted fingerprints, and artifacts whose device rows do not exactly close over their declared groups raise ValueError or Pydantic validation errors before a record is returned or written. Cross-record and registry-binding failures raise ValidationError with this machine-readable context["code"] value:

Code

Condition

population_group_overlap

More than one supplied group has the same group_id.

population_id_collision

Two resolved entries have the same derived ID.

fingerprint_incompatible

A fingerprint family is unavailable or incompatible, or the requested count exceeds the current pool_size bound.

This service owns only deterministic population identity, fingerprint binding, and persistence. It does not claim physical-device provenance, validate that a fingerprint distribution matches a deployment, assign devices to events, or generate and validate signals. For the validation evidence and current limits, see Device-population service validation.

API reference