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 |
|---|---|
|
Returns the stable SHA-256 identity for one position in a named group. |
|
Validates one group name, count, and fingerprint-family name. |
|
Holds a stable device ID, its group, and one complete canonical fingerprint. |
|
The immutable schema-version-1 record returned by resolution and written to disk. |
|
One deterministic partition, reassembled into a durable population record. |
|
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 |
|---|---|---|
|
yes |
A nonempty NFC string used as one safe filesystem path segment. It cannot be |
|
yes |
An integer from |
|
yes |
Either shorthand such as |
|
no |
Nonempty NFC string; defaults to |
|
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 |
|
no |
Must be exactly |
|
no |
Positive non-boolean integer, default |
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 |
|---|---|---|---|
|
|
– |
Nonempty NFC text without NUL. A request cannot repeat a group ID. |
|
|
– |
Non-boolean integer greater than or equal to zero. |
|
|
|
Nonempty NFC registry-family name. It must select a registry supplied to |
PopulationDevice¶
Field |
Type |
Contract |
|---|---|---|
|
|
Lowercase 64-hex SHA-256 ID. For a durable record it must equal |
|
|
Nonempty NFC group identifier declared by the containing request. |
|
|
Complete canonical JSON serialization of |
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 |
|---|---|---|
|
literal |
Version of this artifact schema. |
|
string |
Stable-ID namespace used for every device. |
|
unsigned 64-bit integer |
Fingerprint-draw seed. |
|
literal |
Reuse statement for this population. |
|
array of |
Each item has |
|
array of |
Each item has |
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 |
|---|---|
|
Carrier-frequency offset, Hz. |
|
Sample-frequency offset, parts per million. |
|
IQ amplitude imbalance, dB. |
|
IQ phase imbalance, radians. |
|
Rapp power-amplifier shape and saturation parameters. |
|
Phase-noise power spectral density in dBc/Hz: decibels relative to the carrier per hertz. |
|
Closed power-amplifier model selector. |
|
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 |
|---|---|
|
More than one supplied group has the same |
|
Two resolved entries have the same derived ID. |
|
A fingerprint family is unavailable or incompatible, or the requested count exceeds the current |
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.