Skip to content

Python Waveform Generator API — Synth / PN

Everything in the doppler.wfm package imports from one place — from doppler.wfm import …. The two low-level generators are:

Class Output Use when
Synth CF32 — the nine-type waveform engine Generate tone / noise / PN / BPSK / QPSK / chirp / bits / symbols / DSSS, with optional LO offset and AWGN
PN uint8 — raw LFSR chips (0/1) Spreading / ranging codes, scrambling, test vectors

Synth is also the unit of composition — pass synths into Segment.sum to mix them (see compose below).

Source: src/doppler/wfm/__init__.py

These same C cores back the one command-line tool, wfmgen — see the Waveform Generator guide.

Reading or writing a capture file is on its own page — Python: Capture I/O covers Reader, Writer and write_blue_header, with the Capture I/O guide as the narrative version.


Synth — the nine-type waveform engine

One declarative engine produces every waveform type, selected by the string type (tone, noise, pn, bpsk, qpsk, chirp, bits, symbols, dsss). Construction takes keyword arguments mirroring the generator flags; sensible defaults mean a bare Synth() is a clean, unit-power baseband tone.

from doppler.wfm import Synth
import numpy as np

# Bare construct → clean baseband tone, unit power, no noise
x = Synth().steps(4096)            # complex64

# Tone at Fs/10 with 20 dB SNR
tone = Synth(type="tone", fs=1e6, freq=100_000, snr=20).steps(4096)

# Complex AWGN (unit power)
noise = Synth(type="noise", seed=7).steps(8192)

# PN / BPSK / QPSK — sps samples per chip/symbol
pn   = Synth(type="pn",   pn_length=7, sps=1).steps(127)
bpsk = Synth(type="bpsk", sps=8, snr=10).steps(8192)
qpsk = Synth(type="qpsk", sps=8, snr=10).steps(8192)

# Scalar (one sample at a time)
s = Synth(type="tone", freq=1000, fs=1e6).step()

Multiplying by a waveform — prod() and rotate()

A synth is also an operator: Synth.prod(x) returns x times the synth's next len(x) samples, advancing the synth exactly as steps(len(x)) would (split calls equal one call; x is not modified). A tone makes it a rotation, a chirp a swept rotation, noise a noise multiplier, PN/BPSK a modulation. rotate(x, freq, rate, fs, center) is the swept-carrier special case: it multiplies x by exp(j 2 pi (freq u + rate u^2 / 2)) about a stated center sample (default the middle of x), to a constant phase. The chirp's start and end frequencies come from the one C home of that arithmetic, dp_wfm_synth_sweep_ends, which Python reaches through the private _sweep_ends; rotate is the supported way in, and it refuses an fs that is not positive and finite.

import numpy as np
from doppler.wfm import rotate

x = np.ones(1001, np.complex64)
y = rotate(x, freq=0.0, rate=1e-4)       # cycles/sample, cycles/sample^2
inst = np.angle(y[1:] * np.conj(y[:-1])) / (2 * np.pi)
assert abs(inst[0] + 0.05) < 1e-4 and abs(inst[-1] - 0.05) < 1e-4

Bits (user-defined pattern)

A bits waveform sends a specific bit sequence — preambles, sync words, test vectors, exact packet structures — from its data source, data=. The data is bits: a uint8 array, bytes or any sequence of 0/1. Text becomes bits through field_bits() — binary, 0x hex (MSB first), or a generated field such as pn:31:5 — and a str passed directly is refused, naming it. modulation maps the bits to symbols ("none" → 0/1 amplitude, "bpsk" → ±1, "qpsk" → two bits per symbol, Gray-coded). Each bit is held sps samples and the bits are sent once, then silence. A data source is a frame's payload and is sent exactly as given; a CRC-16 trailer, a sync word or a coding stage is part of a frame description, passed as frame= (see Data).

from doppler.wfm import Synth, bits, field_bits

# 8-bit preamble, BPSK, 4 samples/bit → 32 samples, sent once
s = bits(data=field_bits("10110101"), sps=4, modulation="bpsk")
preamble = s.steps(32)  # 8 bits * 4 sps

# Hex sync word, unmodulated 0/1; direct construction is equivalent
sync = Synth(
    type="bits", data=field_bits("0xAA55"), modulation="none", sps=8,
)

# From a numpy array
import numpy as np
payload = bits(
    data=np.array([1, 0, 1, 1, 0, 1, 0, 1], np.uint8), modulation="qpsk",
)

Symbols (arbitrary constellation)

Where bits maps data through a fixed modulation, type="symbols" skips the map entirely: you supply a complex64 constellation stream and each element is an output point directly, oversampled by sps, cycled to fill the request, and RRC-shaped through the same matched-FIR path (pulse="rrc"). That expresses any modulation an enum doesn't — pi/4-QPSK, QAM, APSK — by computing the points yourself and passing them. On the composer Synth the stream is the symbols= keyword; on the low-level _SynthEngine it is attached with set_symbols() after construction. A complex128 array is accepted (force-cast to complex64).

import numpy as np
from doppler.wfm import Synth

# pi/4-QPSK: rotate every other QPSK symbol by pi/4, then pass the stream
qpsk = np.array([1 + 1j, -1 + 1j, -1 - 1j, 1 - 1j], np.complex64) / np.sqrt(2)
stream = np.array(
    [qpsk[i % 4] * (np.exp(1j * np.pi / 4) if i % 2 else 1) for i in range(64)],
    np.complex64,
)
iq = Synth(type="symbols", symbols=stream, sps=8, pulse="rrc").steps(64 * 8)

See the Symbols gallery walkthrough for worked pi/4-QPSK and 16-QAM constellations, rect vs RRC pulses, and the envelope floor behind pi/4-QPSK's lower PAPR.

Chirp (LFM sweep)

A chirp is a linear-FM sweep: its instantaneous frequency ramps from freq (the start, also spellable f_start=) to f_end over span samples, then holds at f_end. Its phase is one running accumulator, so a Synth read in pieces joins seamlessly: the pieces are byte for byte one read (the second example below). Pulse-compression, SAR, sonar and frequency-response test signals all fall out of this one type. A composer Segment is different: each starts its sources afresh, so a sweep meant to run unbroken is one segment. f_end < freq is a down-chirp; snr adds AWGN exactly as for a tone.

from doppler.wfm import Synth, chirp

# Up-chirp 100 kHz → 300 kHz over 10000 samples at 1 MS/s
up = chirp(f_start=100e3, f_end=300e3, fs=1e6, span=10000).steps(10000)

# Down-chirp (equivalent direct construction; freq IS the start frequency)
down = Synth(type="chirp", freq=1e6, f_end=500e3, fs=2e6, span=50000)
x = down.steps(50000)
import numpy as np

# Four reads of one chirp are one read, byte for byte.
s = chirp(f_start=100e3, f_end=300e3, fs=1e6, span=2044)
pieces = np.concatenate([s.steps(511) for _ in range(4)])
whole = chirp(f_start=100e3, f_end=300e3, fs=1e6, span=2044).steps(2044)
assert pieces.tobytes() == whole.tobytes()

The span is declared, never inferred from a read: step(), steps(N) and any chunking of reads give the same waveform. A standalone sweeping chirp without span raises when it first generates. In a Segment the span defaults to the segment's num_samples. Sample n steps by freq + n·k, with k = (f_end − freq) / span, so the span's last sample steps at f_end − k, and f_end is held from the sample after.

Clean vs noisy, baseband vs offset

snr is in dB. snr >= 100 (the default) is clean — no AWGN is generated at all, so a clean waveform pays no noise cost. Lower it to add noise. freq = 0 (the default) is baseband — the LO is skipped entirely.

clean   = Synth(type="qpsk", sps=8, snr=100).steps(8192)   # no AWGN
noisy   = Synth(type="qpsk", sps=8, snr=12).steps(8192)     # Es/No 12 dB
offset  = Synth(type="pn", pn_length=9, sps=1, freq=2.5e5, fs=1e6).steps(511)

snr_mode ("auto", "fs", "ebno", "esno") sets how snr is interpreted. Which types "auto" sends to which reference is written out once, in the guide: Levels & SNR.

RRC pulse shaping (band-limited carriers)

By default the modulated types (pn / bpsk / qpsk) emit rectangular sample-and-hold chips — a wide sinc² spectrum. Set pulse="rrc" for root-raised-cosine pulse shaping: the symbol stream is filtered to a band-limited channel, so a realistic carrier (e.g. WCDMA QPSK, RRC roll-off 0.22) comes straight from the generator. rrc_beta is the roll-off and rrc_span the filter support in symbols. The taps are unit-transmit-power scaled, so the output stays at unit average power.

from doppler.wfm import qpsk

shaped = qpsk(sps=8, pulse="rrc", rrc_beta=0.22, rrc_span=8).steps(1 << 16)
# band-limited: its occupied bandwidth is ~(1+beta)/sps, far below the rect sinc²

PN modulation: length, polynomial, realization

# Auto-pick the maximum-length polynomial for the register length (2..64)
Synth(type="pn", pn_length=23, sps=1).steps(8192)

# Explicit 64-bit polynomial
Synth(type="pn", pn_length=40, pn_poly=0x800000001C, sps=1).steps(8192)

# Fibonacci realization (same polynomial/period, different chip ordering)
Synth(type="pn", pn_length=9, sps=1, lfsr="fibonacci").steps(511)

Determinism

s = Synth(type="qpsk", sps=4, seed=11)
a = s.steps(512)
s.reset()
assert np.array_equal(a, s.steps(512))   # same seed → identical stream

Synth

One additive source within a segment: a synth config + its level.

The nine synth fields mirror dp_wfm_synth_create() (minus fs, which is the segment's — one receiver, one sample rate). level is the source's average power in dBFS (≤0); the segment sums its sources, each scaled by 10^(level/20).

Any of freq/snr/level/f_end may be a per-repeat uniform draw: set the matching WFM_RANGE_* bit in ranged, leave the scalar as lo, and put hi in the *_hi companion (see the ranged enum).

Parameters:

Name Type Description Default
type str

Waveform type. tone: complex sinusoid. noise: AWGN. pn: PN sequence (LFSR). bpsk/qpsk: PN-driven modulation. chirp: linear-FM sweep. bits: a caller's bit pattern, with selectable modulation. symbols: a caller's complex constellation stream. dsss: spread spectrum -- a two-code burst (repeated preamble + data-code-spread frame) by default, or a continuous asynchronous stream when symbol_rate is set. One of "tone", "noise", "pn", "bpsk", "qpsk", "chirp", "bits", "symbols", "dsss".

``"tone"``
freq float | tuple[float, float]

Carrier or offset frequency in Hz; for chirp, the sweep start. With fs = 1 it is in normalised cycles per sample.

0.0
snr float | tuple[float, float]

Signal-to-noise ratio in dB, interpreted per snr_mode. 100 or more is clean: no AWGN is added.

100.0
snr_mode str

How snr is interpreted. auto: Es/N0 for bpsk/qpsk/dsss, and relative to full scale for tone/noise/pn/chirp/bits/symbols -- bits included, because a bits frame has no symbol rate the engine can infer. fs: dB relative to full scale. ebno: Eb/N0, per bit. esno: Es/N0, per symbol; for a dsss burst the outer data symbol of len(data_code) chips x sps samples, for a continuous dsss stream the fs/symbol_rate samples the async symbol spans. One of "auto", "fs", "ebno", "esno".

``"auto"``
seed int

PRNG and LFSR seed for the noise and PN streams. Deterministic: vary it for run-to-run change. For a PN-sourced type it starts the pn_length-bit register, masked: a seed whose low pn_length bits are zero starts the register at 1, as seed 0 does.

0
sps int

Samples per symbol (PSK) or per chip (PN): the oversampling factor. Unused by noise, which records it as 0.

1
pn_length int

PN LFSR register length in bits, 2 to 64 for the PN-bearing types; the sequence period is 2^pn_length - 1. Unused by noise, which records it as 0.

15
pn_poly int

PN generator polynomial, in the Galois bit-vector convention; 0 selects a maximal-length (MLS) polynomial for pn_length. It must fit the pn_length-bit register: a bit at or above it is refused, not masked. A polynomial above 2^53 does not survive a JSON number, so a scene file needs 0 (auto) for such a register.

0
lfsr str

PN LFSR realisation. Both give the same period; fibonacci's chips are galois's in reverse order. One of "galois", "fibonacci".

``"galois"``
level float | tuple[float, float]

Source power in dBFS (<= 0; 0 is unit power). Applies when summed in a Segment or Composer, as a gain of 10^(level/20); a standalone Synth.steps() ignores it.

0.0
background int

Mark this source as part of the static background field (0/1). Plan.prepare() folds a contiguous leading run of background sources into ONE pre-summed cache entry instead of caching each separately, so a scene of many fixed emitters costs one buffer rather than hundreds. The composite is overridable as a unit: it takes a single slot in gains/phases/enable and counts as one in n_sources(), so scaling it trims the whole field while its members keep their relative levels. Background sources must come first in the segment (a non-prefix ordering is rejected by prepare, since the fold would no longer reproduce compose bit-for-bit). Ignored by compose() and by standalone Synth.steps().

0
f_end float | tuple[float, float]

Chirp end frequency in Hz; ignored by other types.

0.0
span int

Chirp sweep length in samples: the frequency ramps from freq to f_end over this many samples, then holds at f_end. 0 means the enclosing Segment's num_samples. A standalone chirp (f_end != freq) must declare it, so step(), steps(N) and any chunking of reads produce the same waveform; generating one without it raises. Ignored by non-chirp types.

0
doppler float | tuple[float, float]

Clock Doppler in ppm: the received time base is rescaled by 1 + doppler*1e-6, so the symbol and chip rates move with the carrier and a timing loop sees the error a carrier-only freq offset hides. Accepts a (lo, hi) tuple drawn uniformly per repeat, like freq/snr. Zero doppler AND zero doppler_rate means no channel is built at all, so a source that does not ask for Doppler renders exactly as it always did.

0.0
doppler_rate float | tuple[float, float]

Linear ramp on doppler, in ppm per second of elapsed stream time. The channel runs through a segment's gaps as well as its on-time -- an emitter does not stop moving because its burst ended -- so this is per second, not per second of on-time. Accepts a (lo, hi) tuple drawn uniformly per repeat.

0.0
carrier_hz float

RF carrier in Hz that the ppm figures are referred to. It gives the coherent carrier rotation that accompanies the time-base warp; 0 warps the clock alone, with no carrier rotation -- a legitimate scene, not an unset field. Independent of doppler/doppler_rate.

0.0
doppler_lifetime str

How long this source's Doppler channel lives. per_instance: the channel dies with each repeats instance, so the geometry restarts -- the repeated-trial shape, which composes with a ranged doppler re-drawn per instance. persist: one continuous pass carries across the segment's gaps and repeat instances, keyed by (segment, source) position -- the only lifetime under which doppler_rate accumulates across a multi-burst scene. compose(), stream() and Plan.prepare() all honour both. One of "per_instance", "persist".

``"per_instance"``
modulation str

Symbol mapping of a bits pattern. none: the pattern shaped and output as-is (NRZ). bpsk: +/-1 symbols. qpsk: Gray-coded symbols from pairs of bits. One of "none", "bpsk", "qpsk".

``"bpsk"``
pulse str

Pulse shape per symbol or chip, for pn/bpsk/qpsk/bits/symbols/dsss. rect: rectangular, no ISI filtering. rrc: root-raised cosine; see rrc_beta and rrc_span. One of "rect", "rrc".

``"rect"``
rrc_beta float

RRC roll-off factor, in (0, 1], when pulse=rrc.

0.35
rrc_span int

RRC filter support in symbols when pulse=rrc, ONE-SIDED: the filter has 2rrc_spansps + 1 taps, unit energy (sum of h^2 = 1).

8
symbols NDArray[complex64] | None

For type=symbols: a complex constellation stream. Each element is the output point itself, oversampled by sps, cycled, and RRC-shaped with pulse=rrc, which generalises any modulation (pi/4-QPSK, QAM, ...).

None
acq_code bytes | None

The preamble code, sent acq_reps times at the head of the frame (a Field's *REPS on the command line and in a scene) -- the coherent pull-in target BurstDespreader.set_acq and BurstDemod.set_preamble lock to. For type=dsss it is unmodulated chips ahead of the spread frame; for type=bits it is the head of the bit pattern. Setting it is what makes a source FRAMED.

None
acq_reps int

Preamble repetitions: periods of acq_code before the sync word. On the command line and in a scene it is acq_code's *REPS.

1
data_code bytes | None

For type=dsss: the payload spreading code, a second code distinct from acq_code. Every frame bit (sync, payload, crc) is XOR-spread across its full length, so len(data_code) is the spreading factor.

None
sync bytes | None

RETIRED (doppler#1617): nothing reads it but the refusal. The frame-sync word is a field of the frame DESCRIPTION, so sync= is refused naming frame=; the CLI's --sync builds that description for you, and a scene refuses the "sync" key.

None
crc bytes | None

RETIRED (doppler#1617): nothing reads it but the refusal. A CRC is a stage of the frame DESCRIPTION, so crc= is refused naming frame=, whatever its value (crc="none" included); the CLI's --crc builds that description for you, and a scene refuses the "crc" key.

None
symbol_rate float

For type=dsss: > 0 selects CONTINUOUS asynchronous mode. The spreading code repeats endlessly and data rides on it at this symbol rate (Hz), independent of the code-epoch rate (chips/symbol = fs/sps/symbol_rate, non-integer). No preamble/sync/CRC frame; data comes from the payload when supplied, else a seeded PN a receiver regenerates. Absent/0 = burst.

0.0
dsss_code_only int

Continuous dsss data source: 1 = code-only (the pure spreading code, no data modulation); 0 = data-modulated (the payload when supplied, else the seeded PN). Ignored for burst dsss and non-dsss types.

0
frame FrameDesc | str | None

A frame DESCRIPTION, the whole frame: fields in wire order, and stages that each name the span they cover (crc16, rs, randomise, interleave, conv, or a kind of your own). It is the only way a source says anything but the common frame: a coding stage, a field of the caller's own bits at a position of their choosing, a stage covering a span they name. wfmgen --frame FILE, a scene's frame key and Python's frame= (a FrameDesc or a Frame) all land here. When it is set it IS the frame, and an unspread acq_code beside it is refused. NULL means the common frame, [preamble x reps | data], which dp_wfm_frame_fixed() builds from acq_code and the data source; a sync word or a CRC is a field or a stage of a description, never a flat field. A C caller's description is borrowed, exactly as wfm_seq_t is borrowed elsewhere here, so it must outlive the source. The composer and a Python source hold their own copy (dp_wfm_frame_copy()), so a later change to the FrameDesc does not reach them. On the Python face frame= is an input: read it back from the composer's JSON (its getter is jm's, pending removal: doppler#1694). KERNELS stay in C by design. A description names a stage's KIND; the code that runs it is a wfm_frame_ops_t entry, and a caller adding a genuinely new transform (convolutional interleaving, say) writes that kernel in C and hands it to dp_wfm_frame_assemble directly.

None
bits bytes | None

RETIRED (doppler#1718): nothing reads it but the refusal. A payload is drawn from a data source, so bits=, and its aliases payload= and pattern=, are refused naming data=; the CLI and a scene refuse --bits and "payload" the same way.

None
data bytes | None

A frame's payload drawn from a data source: a Field on the command line and in a scene, a bit array in Python. The source is split into data_len-bit frames, one chunk per frame, and its last chunk is padded from fill. For type=bits, bpsk/qpsk/pn framed, a dsss burst (one burst per frame), and continuous dsss (one bit per data symbol, no frame); not with data_from_file.

None
data_len int

Bits of the data source per frame: the data:LEN field of the common frame (preamble x reps, sync, data:LEN, crc). 0 takes a finite source whole, as one frame. A carried frame names its own data field, and this is then 0 or that field's LEN. Continuous dsss has no frame, and refuses it.

0
fill bytes | None

The bits that pad a data source's last frame when it does not divide into data_len-bit frames, tiled from their first bit; stdin on a framed source always needs them. Without them such a source is refused before the first sample. A Field on the command line and in a scene, a bit array in Python. Continuous dsss has no frame to pad, and refuses it.

None
code_only_symbols int

Continuous dsss: pure-code symbols opening each frame of frame_symbols symbols. The data pauses through them and runs on across frames, so a searcher can integrate coherently over the window; once a payload ends, no window is sent. At most frame_symbols, and fewer when a data source is set; 0 with frame_symbols 0 = no window.

0
frame_symbols int

Continuous dsss: the frame, in symbols, that code_only_symbols opens; give it with code_only_symbols, or both 0 for no window. The frame runs on the stream's own symbol clock, so its edges land at any chip phase.

0
fs float

Sample rate in Hz, one per segment and shared by all its sources. At the default 1.0 every frequency is normalised (cycles per sample); state it whenever a scene is in real Hz.

1.0

steps

steps(n: int) -> NDArray[np.complex64]

Generate the next n samples of this source on its own.

The first call builds the generator from this configuration, through dp_wfm_source_to_synth; later calls continue it.

Parameters:

Name Type Description Default
n int

How many samples; 0 returns an empty array.

required

Returns:

Type Description
NDArray[complex64]

The samples, in order.

Raises:

Type Description
ValueError

If n is negative. If dp_wfm_source_to_synth refuses this configuration; the message is its reason.

RuntimeError

If dp_wfm_source_to_synth fails and gives no reason.

step

step() -> complex

Generate the next sample of this source on its own.

The first call builds the generator from this configuration, through dp_wfm_source_to_synth; later calls continue it.

Returns:

Type Description
complex

The sample.

Raises:

Type Description
ValueError

If dp_wfm_source_to_synth refuses this configuration; the message is its reason.

RuntimeError

If dp_wfm_source_to_synth fails and gives no reason.

reset

reset() -> None

Rewind the generator to sample 0.

A no-op before the first steps()/step(), which starts from sample 0 anyway.

prod

prod(
    x: NDArray[complex64],
    out: NDArray[complex64] | None = None,
) -> NDArray[np.complex64]

Multiply a block by the synth's next output samples.

out[i] = x[i] * y[i], where y is what steps(len(x)) would emit next. The synth advances exactly as steps(len(x)) does, so split calls leave the same synth state as one call and an empty x does not advance it; the product itself agrees across such call sequences to rounding (a few ulp of |x| * |y|), not to the bit, because the compiler may contract the multiply differently on different paths (arm64 under -ffast-math). x is not modified.

x must be a 1-D C-contiguous complex64 array: a strided, non-complex64 or non-1-D block is refused (ValueError / TypeError), never copied or cast.

This is the general waveform-multiplication operator: with a tone synth it is a rotation, with a chirp synth a swept rotation, with noise a noise multiplier, with PN/BPSK a modulation.

Parameters:

Name Type Description Default
x NDArray[complex64]

Input block of n cf32 samples.

required
out NDArray[complex64] | None

Output block of n cf32 samples (may be the input block itself).

None

Returns:

Type Description
NDArray[complex64]

Output.

Raises:

Type Description
ValueError

If dp_wfm_source_to_synth refuses this configuration; the message is its reason.

RuntimeError

If dp_wfm_source_to_synth fails and gives no reason.

Examples:

>>> from doppler.wfm import Synth
>>> import numpy as np
>>> s = Synth(type="tone", fs=1.0, freq=0.25)
>>> y = s.prod(np.ones(4, np.complex64))
>>> bool(np.allclose(y, [1, 1j, -1, -1j], atol=1e-5))
True

PN — raw LFSR m-sequence

A right-shift LFSR producing one bit (0/1) per call. With a primitive polynomial it is a maximum-length sequence: period 2**n - 1 with 2**(n-1) ones per period. Registers up to 64 bits are supported, in either the Galois (internal-XOR, default) or Fibonacci (external-XOR) realization — both realize the same polynomial and period.

from doppler.wfm import PN
import numpy as np

# Length-7 MLS (primitive polynomial 0x41), one full period
chips = np.asarray(PN(0x41, 1, 7).generate(127))   # uint8, 64 ones / 63 zeros

# Fibonacci realization of the same polynomial
fib = np.asarray(PN(0x41, 1, 7, lfsr="fibonacci").generate(127))

# 64-bit register
big = np.asarray(PN(0x800000001C, 1, 40).generate(50_000))

# Deterministic replay
p = PN(0x41, 1, 7)
a = np.asarray(p.generate(127)).copy()
p.reset()
assert np.array_equal(a, np.asarray(p.generate(127)))

The constructor is PN(poly, seed, length, lfsr="galois"). seed must be non-zero (the all-zero register is a fixed point). To map chips to ±1 BPSK symbols, use Synth(type="pn", ...) instead, which also handles oversampling, the LO, and AWGN.


PN

Allocate and initialise a maximal-length-sequence LFSR. The register is seeded from seed and will produce a pseudo-random binary sequence with period 2^length - 1 for any primitive poly. Both Galois and Fibonacci realizations share the same primitive polynomial and therefore the same period; they differ only in chip ordering/phase. Leave poly at 0 and the maximal-length polynomial for length is used, so PN(seed=1, length=7) is the 127-chip m-sequence.

Parameters:

Name Type Description Default
poly int

Galois feedback tap polynomial (right-shift convention). The LSB is the tap at position 0 (always 1 for a primitive poly); bit k=1 means tap at position k. 0 (the default) selects the maximal-length polynomial for length. The Fibonacci taps are derived automatically so you only supply one value.

0
seed int

Initial LFSR register state; must be non-zero WITHIN the register, seed & pn_register_mask (length) -- the all-zero state is a fixed point, and a seed that masks to it (128 on 7 bits) is refused like 0.

0
length int

Register width in bits, 1..64. The sequence period is 2^length - 1 for a primitive polynomial.

0
lfsr Literal['galois', 'fibonacci']

One of "galois", "fibonacci".

"galois"

Examples:

>>> from doppler.wfm import PN
>>> p = PN(poly=96, seed=1, length=7)
>>> chips = p.generate(127)
>>> chips.dtype
dtype('uint8')
>>> int(chips.sum())   # 64 ones per MLS period (2^(n-1))
64
>>> int(PN(seed=1, length=7).generate(127).sum())
64

reset

reset() -> None

Reset PN to its post-create state. Reloads the LFSR register from the original seed so the sequence restarts from chip 0. Useful for reproducible captures without re-allocating.

Examples:

>>> from doppler.wfm import PN
>>> import numpy as np
>>> p = PN(poly=96, seed=1, length=7)
>>> a = p.generate(8).copy()
>>> p.reset()
>>> np.array_equal(a, p.generate(8))
True

generate

generate(
    count: int = 1, out: NDArray[uint8] | None = None
) -> NDArray[np.uint8]

Generate n chips into out and advance the LFSR by n positions. Each element of out is 0 or 1. Requesting more than one MLS period is valid — the sequence simply wraps around. The Python binding returns a zero-copy NumPy uint8 view over a pre-allocated buffer; copy the result before calling generate again if you need a snapshot.

Parameters:

Name Type Description Default
count int

How many output samples to ask for. The call may return fewer; size an out= buffer with the matching _max_out() when you need the worst case.

1
out NDArray[uint8] | None

Output buffer of at least n uint8 elements; each element receives 0 or 1.

None

Returns:

Type Description
NDArray[uint8]

min(n, max_out) chips.

Examples:

>>> from doppler.wfm import PN
>>> import numpy as np
>>> p = PN(poly=96, seed=1, length=7)
>>> chips = p.generate(127)
>>> chips[:8].tolist()
[1, 0, 0, 0, 0, 0, 1, 1]
>>> int(chips.sum())   # 64 ones per MLS period
64

generate_max_out

generate_max_out() -> int

Output capacity hint for dp_pn_generate(): always 0.

The chip count is the caller's n, not a function of the state, so there is no state-derived bound; size out to n.

Returns:

Type Description
int

0.

state_bytes

state_bytes() -> int

Size in bytes of this object's serialized state.

The exact length get_state returns and set_state requires. It depends on how the object was constructed (state arrays are sized at construction), so read it from the instance rather than assuming a constant.

Raises RuntimeError if the PN has already been destroyed.

Returns:

Type Description
int

Byte length of one serialized state blob.

get_state

get_state() -> bytes

Serialize this object's mutable state to bytes.

Captures the state that evolves as the object runs, including any value a setter changed after construction, so a blob taken now and restored later resumes from this point. Construction parameters are not restored: restore into an object built the same way. A core may check some of them and refuse a blob built differently.

The blob is opaque and always state_bytes() long. Its layout is an implementation detail of the C core and is not a stable format across builds.

Raises RuntimeError if the PN has already been destroyed.

Returns:

Type Description
bytes

Opaque snapshot, state_bytes() bytes long.

set_state

set_state(blob: bytes) -> None

Restore mutable state from a get_state() blob.

Overwrites the live state in place, including any value a setter changed after construction. Construction parameters are not restored: restore into an object built the same way. Length is validated against state_bytes() before the blob is handed to the C core, which may reject it too: a core may check some construction parameters and refuse a blob built differently.

Raises TypeError if blob is not bytes, ValueError if its length differs from state_bytes() or the core rejects it, and RuntimeError if the PN has already been destroyed.

Parameters:

Name Type Description Default
blob bytes

A get_state() blob from this type, exactly state_bytes() long.

required

destroy

destroy() -> None

Release the underlying C resources immediately.

Ordinarily unnecessary: the resources are freed when the object is garbage-collected. Call this to release them at a definite point instead, or use the object as a context manager, which calls it on exit.

Idempotent: calling it again on an already-released object does nothing. Every other method raises RuntimeError once it has run.

__enter__

__enter__() -> PN

Enter a context manager, returning this object.

Lets a PN be used in a with statement so its C resources are released deterministically on exit rather than at collection time.

Returns:

Type Description
PN

This same object, not a copy.

__exit__

__exit__(
    exc_type: object | None = ...,
    exc: object | None = ...,
    tb: object | None = ...,
) -> None

Exit a context manager, releasing the PN.

Equivalent to calling destroy(). Returns None, so an exception raised inside the with body propagates normally; this never suppresses one.

Parameters:

Name Type Description Default
exc_type object | None

Exception class, or None. Ignored.

...
exc object | None

Exception instance, or None. Ignored.

...
tb object | None

Traceback object, or None. Ignored.

...

Gold — a CCSDS Gold-code generator

Gold XORs two same-length LFSRs that free-run in lock-step, so its period is 2**length - 1 and varying seed_a walks the members of the Gold family while register B stays fixed. Use it when a code needs the bounded cross-correlation of a Gold family, as in DSSS; PN above is the single m-sequence.

Gold

Allocate and initialise a CCSDS-style Gold code generator. Two independent Fibonacci LFSRs of the same length free-run in lock-step; each output chip is the XOR of both registers' current top-bit (stage length, i.e. bit length-1). Both registers shift left one bit per chip: the new bit (parity of the tapped stages, read before the shift) enters at stage 1 (bit 0), and the old stage-length bit is discarded after being XORed into the output. The sequence period is 2^length - 1 for primitive taps_a/taps_b. With the CCSDS default polynomials the two m-sequences form a genuine "preferred pair" — their XOR family has a strict three-valued periodic autocorrelation/cross-correlation set {-1, -65, 63} — so varying seed_a (User dependent per the standard) walks the 2**length-1 XOR members of the Gold-code family while Register B stays fixed.

Parameters:

Name Type Description Default
taps_a int

Register A feedback-tap mask; bit k set means stage k+1 is XORed into the feedback. Default 934 (stages 2,3,6,8,9,10 — the CCSDS-fixed Register A polynomial x^10+x^9+x^8+x^6+x^3+x^2+1).

934
seed_a int

Register A initial value; must be non-zero within the register (seed_a & pn_register_mask (length): a multiple of 2^length is refused like 0). Per CCSDS this is "User dependent" — each of the 2^length-1 nonzero values selects a different member of the family (1023 distinct codes at length=10, verified in test_gold_core.c). Default 350 is the worked example from CCSDS 415.0-G-1 Figure 5-2 (PN Code Library Table 1, Code Number 365).

350
taps_b int

Register B feedback-tap mask, same bit convention as taps_a. Default 567 (stages 1,2,3,5,6,10 — the CCSDS-fixed Register B polynomial).

567
seed_b int

Register B initial value; must be non-zero within the register, as seed_a. Default 73 (stages 1,4,7 — CCSDS's fixed Register B initial value 1001001000, unique per the standard, not user-selectable).

73
length int

Register width in bits, 1..64. CCSDS command link uses 10 (period 1023). Default 10.

10

Examples:

>>> from doppler.wfm import Gold
>>> import numpy as np
>>> g = Gold()
>>> chips = g.generate(1023)
>>> chips.dtype
dtype('uint8')
>>> chips[:15].tolist()   # CCSDS Code #365 worked example
[0, 1, 0, 0, 0, 1, 0, 1, 1, 1, 0, 1, 1, 1, 1]
>>> int(chips.sum()), int((1 - chips).sum())   # 512 ones, 511 zeros
(512, 511)

reset

reset() -> None

Reset Gold to its post-create state. Reloads both LFSR registers from their original seeds so the sequence restarts from chip 0. Useful for reproducible captures without re-allocating.

Examples:

>>> from doppler.wfm import Gold
>>> import numpy as np
>>> g = Gold()
>>> a = g.generate(8).copy()
>>> g.reset()
>>> np.array_equal(a, g.generate(8))
True

generate

generate(
    count: int = 1, out: NDArray[uint8] | None = None
) -> NDArray[np.uint8]

Generate n chips into out and advance both LFSRs by n positions. Each element of out is 0 or 1. Requesting more than one period is valid — the sequence simply wraps around. The Python binding returns a zero-copy NumPy uint8 view over a pre-allocated buffer; copy the result before calling generate again if you need a snapshot.

Parameters:

Name Type Description Default
count int

How many output samples to ask for. The call may return fewer; size an out= buffer with the matching _max_out() when you need the worst case.

1
out NDArray[uint8] | None

Output buffer of at least n uint8 elements; each element receives 0 or 1.

None

Returns:

Type Description
NDArray[uint8]

min(n, max_out) chips.

Examples:

>>> from doppler.wfm import Gold
>>> import numpy as np
>>> g = Gold()
>>> chips = g.generate(1023)
>>> len(chips)
1023

generate_max_out

generate_max_out() -> int

Output capacity hint for dp_gold_generate(): always 0.

The chip count is the caller-requested n of dp_gold_generate(), so there is no fixed per-call maximum; size the output buffer to n.

Returns:

Type Description
int

0, meaning "no fixed bound; the output length is n".

state_bytes

state_bytes() -> int

Size in bytes of this object's serialized state.

The exact length get_state returns and set_state requires. It depends on how the object was constructed (state arrays are sized at construction), so read it from the instance rather than assuming a constant.

Raises RuntimeError if the Gold has already been destroyed.

Returns:

Type Description
int

Byte length of one serialized state blob.

get_state

get_state() -> bytes

Serialize this object's mutable state to bytes.

Captures the state that evolves as the object runs, including any value a setter changed after construction, so a blob taken now and restored later resumes from this point. Construction parameters are not restored: restore into an object built the same way. A core may check some of them and refuse a blob built differently.

The blob is opaque and always state_bytes() long. Its layout is an implementation detail of the C core and is not a stable format across builds.

Raises RuntimeError if the Gold has already been destroyed.

Returns:

Type Description
bytes

Opaque snapshot, state_bytes() bytes long.

set_state

set_state(blob: bytes) -> None

Restore mutable state from a get_state() blob.

Overwrites the live state in place, including any value a setter changed after construction. Construction parameters are not restored: restore into an object built the same way. Length is validated against state_bytes() before the blob is handed to the C core, which may reject it too: a core may check some construction parameters and refuse a blob built differently.

Raises TypeError if blob is not bytes, ValueError if its length differs from state_bytes() or the core rejects it, and RuntimeError if the Gold has already been destroyed.

Parameters:

Name Type Description Default
blob bytes

A get_state() blob from this type, exactly state_bytes() long.

required

destroy

destroy() -> None

Release the underlying C resources immediately.

Ordinarily unnecessary: the resources are freed when the object is garbage-collected. Call this to release them at a definite point instead, or use the object as a context manager, which calls it on exit.

Idempotent: calling it again on an already-released object does nothing. Every other method raises RuntimeError once it has run.

__enter__

__enter__() -> Gold

Enter a context manager, returning this object.

Lets a Gold be used in a with statement so its C resources are released deterministically on exit rather than at collection time.

Returns:

Type Description
Gold

This same object, not a copy.

__exit__

__exit__(
    exc_type: object | None = ...,
    exc: object | None = ...,
    tb: object | None = ...,
) -> None

Exit a context manager, releasing the Gold.

Equivalent to calling destroy(). Returns None, so an exception raised inside the with body propagates normally; this never suppresses one.

Parameters:

Name Type Description Default
exc_type object | None

Exception class, or None. Ignored.

...
exc object | None

Exception instance, or None. Ignored.

...
tb object | None

Traceback object, or None. Ignored.

...

compose — multi-segment composition, writers, and a NATS sink

The composition layer is the Python face of the C wfmgen composer subsystem — the same engine behind the wfmgen CLI, output byte-identical for the same parameters. There are two composition verbs:

  • Segment.sum(*synths, num_samples=…) mixes synths at the same time over one resolved noise floor (a multi-source scene);
  • Segment.add(*segments) sequences segments in time (a timeline).

The ladder is Synth → (.sum) → Segment → (.add) → Timeline → Composer → samples: .sum stacks synths in the same time window (one column), .add lays segments out along time (one row).

flowchart LR
    subgraph SEG["Segment — .sum() mixes at the SAME time, one noise floor"]
        direction TB
        y1["Synth qpsk · level −10 dBFS"]
        y2["Synth tone · level −3 dBFS"]
        y3["Synth noise · the floor"]
    end
    subgraph TL["Timeline — .add() sequences in TIME ▶"]
        direction LR
        sA["Segment A"] --> sB["Segment B<br/>(+ trailing gap)"] --> sC["…"]
    end
    SEG -- ".add(B, …)" --> sA
    TL --> COMP["Composer(…).compose()"] --> IQ[("complex64 I/Q")]

    classDef syn stroke-width:3px;
    classDef seg stroke-width:3px,stroke-dasharray:4 2;
    class y1,y2,y3 syn;
    class sA,sB,sC seg;

A Composer turns a Segment / Timeline / segment-list into samples, optionally looping (repeat) or running forever (continuous); Writer serialises to the four file types (raw / CSV / BLUE type-1000 / SigMF), and StreamSink publishes over NATS (requires a nats-server reachable at the endpoint). The resolved spec round-trips through JSON, so a capture is fully reproducible.

import numpy as np
from doppler.wfm import Composer, Reader, Segment, Writer, mls_poly, qpsk, tone

# Mix: a QPSK signal of interest under a CW interferer, one noise floor.
scene = Segment.sum(
    qpsk(snr=15, sps=8, level=-10),       # builders return Synth
    tone(freq=2e5, level=-3),
    num_samples=65536,
)

# Sequence: a PN preamble, then the scene, back-to-back in time.
timeline = Segment("pn", num_samples=127, pn_length=7).add(scene)
iq = Composer(timeline).compose()         # one complex64 array

# Or stream block-by-block (an empty block marks the end):
c = Composer(timeline)
with Writer("frame.cf32", fs=1e6, sample_type="cf32") as w:
    while len(blk := c.execute(4096)):
        w.write(blk)
with Reader("frame.cf32", sample_type="cf32") as r:  # C reader → complex64
    back = r.read(r.num_samples)

# Reproducible: the resolved spec serialises to JSON and back.
j = Composer(timeline).to_json()
assert np.array_equal(Composer.from_json(j).compose(), iq)

# Utilities
mls_poly(7)                               # 0x41 — the length-7 MLS polynomial

The builders tone() / bpsk() / qpsk() / pn() / noise() / chirp(f_start=…, f_end=…, span=…) / bits(data=…, modulation=…) each return a Synth (a noise(level=…) is a bare AWGN floor at that level in dBFS; a chirp is an LFM sweep; a bits(...) sends its data source's bits); or construct Synth(...) directly. In a Segment.sum the per-synth snr resolves into one shared noise floor, and each synth's level (dBFS) sets its share.

Getting samples into and out of a file is its own topic — Writer, its dual Reader, content-based file-type detection, and what metadata each file type survives are all on Python: Capture I/O. For SigMF, pair a Writer(..., file_type="sigmf") data file with Composer(...).to_sigmf(...), which is the piece that knows the scene and can therefore annotate it.

DSP helpers rrc_taps(beta, sps, span) and dsss_spread(syms, code, sf) expose the pulse-shaping and spreading primitives.

Taps or pulse? rrc_taps returns taps on an integer sample grid, which is what a filter needs. rrc_h(t, beta) and rc_h(t, beta) evaluate the same pulses analytically at arbitrary times in symbol periods — use those when a stimulus has no grid to sample, which is any non-integer samples-per-symbol or any fractional timing offset. rrc_h is the root raised cosine, the transmit half of a matched-filter pair; rc_h is the full raised cosine, already the Nyquist response that pair produces, so it models the matched-filter output directly (a timing-detector S-curve reference, say).

SampleClock (POSIX) paces and timestamps a stream against an ideal fs-Hz clock — the same C core behind the wfmgen --realtime CLI flag. Use it to throttle a producer to real time and to tag blocks with their ideal timestamp:

from doppler.wfm import Composer, SampleClock, StreamSink

# Stream at the true 1 MS/s instead of as fast as possible. Requires a
# nats-server reachable at the endpoint.
comp = Composer(type="qpsk", sps=8, continuous=True)
clk = SampleClock(fs=1e6)
with StreamSink("nats://127.0.0.1:4222/iq") as sink:
    while True:
        blk = comp.execute(4096)
        ts = clk.stamp()              # ideal ns timestamp of this block
        sink.send(blk, fs=1e6, fc=0.0)
        clk.pace(len(blk))            # sleep to epoch + n/fs (GIL released)

The schedule is drift-free (deadlines come from the cumulative sample count, not summed sleeps); underruns are counted in clk.underruns / clk.max_lateness, and SampleClock(fs, resync=True) re-anchors to "now" on each underrun.

Classes

Composer

Composer.

Parameters:

Name Type Description Default
segments Segment | Timeline | list[Segment] | None

Initial segment list.

None
repeat bool

Loop the sequence after the last segment.

False
continuous bool

Never finish; execute always returns the requested count.

False

execute

execute(n: int) -> NDArray[np.complex64]

Execute for the next n samples.

Reads the Composer's one cursor, shared with compose() and stream(): it continues where the last read stopped, and a drained Composer yields nothing. Build a new Composer to start over. A short or empty array means the sequence ended.

compose

compose(block: int = ...) -> NDArray[np.complex64]

Compose what remains of the sequence into one array.

On a fresh Composer that is the full sequence; after execute(n) it is the rest. Reads the Composer's one cursor, shared with execute() and stream(): it continues where the last read stopped, and a drained Composer yields nothing. Build a new Composer to start over.

Raises:

Type Description
ValueError

On a continuous spec, which never ends; use execute() or stream().

stream

stream(
    block: int = ..., realtime: float = ...
) -> Iterator[NDArray[np.complex64]]

Iterate the sequence in blocks.

Reads the Composer's one cursor, shared with execute() and compose(): it continues where the last read stopped, and a drained Composer yields nothing. Build a new Composer to start over. Two live iterators interleave over one sequence.

Parameters:

Name Type Description Default
block int

Samples per yielded array.

...
realtime float

Sample rate in Hz to pace against — pass your fs (e.g. realtime=10e6), not a speed multiplier. 0 (the default) streams as fast as possible.

...

to_dict

to_dict() -> dict

Serialise the resolved composition (repeat, continuous, segments) to a dict.

to_sigmf

to_sigmf(
    sample_type: str = ...,
    endian: str = ...,
    fs: float = ...,
    fc: float = ...,
    t0: float = ...,
) -> str

Serialise as to_sigmf.

close

close() -> None

Release native resources.

from_json classmethod

from_json(json: str) -> Composer

Build a Composer from a JSON spec.

from_file classmethod

from_file(path: str) -> Composer

Build a Composer from a JSON spec file.

to_json

to_json() -> str

Serialise the spec as JSON.

Segment

One composer segment: one or more sources summed over the same span, then a trailing off-time gap.

A 1-source segment is byte-identical to driving that source's synth directly. num_samples is the on-time; off_samples is a trailing gap of zeros. Durations in seconds are round(duration * fs) — the caller resolves.

Parameters:

Name Type Description Default
type str

Waveform type. tone: complex sinusoid. noise: AWGN. pn: PN sequence (LFSR). bpsk/qpsk: PN-driven modulation. chirp: linear-FM sweep. bits: a caller's bit pattern, with selectable modulation. symbols: a caller's complex constellation stream. dsss: spread spectrum -- a two-code burst (repeated preamble + data-code-spread frame) by default, or a continuous asynchronous stream when symbol_rate is set. One of "tone", "noise", "pn", "bpsk", "qpsk", "chirp", "bits", "symbols", "dsss".

``"tone"``
freq float | tuple[float, float]

Carrier or offset frequency in Hz; for chirp, the sweep start. With fs = 1 it is in normalised cycles per sample.

0.0
snr float | tuple[float, float]

Signal-to-noise ratio in dB, interpreted per snr_mode. 100 or more is clean: no AWGN is added.

100.0
snr_mode str

How snr is interpreted. auto: Es/N0 for bpsk/qpsk/dsss, and relative to full scale for tone/noise/pn/chirp/bits/symbols -- bits included, because a bits frame has no symbol rate the engine can infer. fs: dB relative to full scale. ebno: Eb/N0, per bit. esno: Es/N0, per symbol; for a dsss burst the outer data symbol of len(data_code) chips x sps samples, for a continuous dsss stream the fs/symbol_rate samples the async symbol spans. One of "auto", "fs", "ebno", "esno".

``"auto"``
seed int

PRNG and LFSR seed for the noise and PN streams. Deterministic: vary it for run-to-run change. For a PN-sourced type it starts the pn_length-bit register, masked: a seed whose low pn_length bits are zero starts the register at 1, as seed 0 does.

0
sps int

Samples per symbol (PSK) or per chip (PN): the oversampling factor. Unused by noise, which records it as 0.

1
pn_length int

PN LFSR register length in bits, 2 to 64 for the PN-bearing types; the sequence period is 2^pn_length - 1. Unused by noise, which records it as 0.

15
pn_poly int

PN generator polynomial, in the Galois bit-vector convention; 0 selects a maximal-length (MLS) polynomial for pn_length. It must fit the pn_length-bit register: a bit at or above it is refused, not masked. A polynomial above 2^53 does not survive a JSON number, so a scene file needs 0 (auto) for such a register.

0
lfsr str

PN LFSR realisation. Both give the same period; fibonacci's chips are galois's in reverse order. One of "galois", "fibonacci".

``"galois"``
level float | tuple[float, float]

Source power in dBFS (<= 0; 0 is unit power). Applies when summed in a Segment or Composer, as a gain of 10^(level/20); a standalone Synth.steps() ignores it.

0.0
background int

Mark this source as part of the static background field (0/1). Plan.prepare() folds a contiguous leading run of background sources into ONE pre-summed cache entry instead of caching each separately, so a scene of many fixed emitters costs one buffer rather than hundreds. The composite is overridable as a unit: it takes a single slot in gains/phases/enable and counts as one in n_sources(), so scaling it trims the whole field while its members keep their relative levels. Background sources must come first in the segment (a non-prefix ordering is rejected by prepare, since the fold would no longer reproduce compose bit-for-bit). Ignored by compose() and by standalone Synth.steps().

0
f_end float | tuple[float, float]

Chirp end frequency in Hz; ignored by other types.

0.0
span int

Chirp sweep length in samples: the frequency ramps from freq to f_end over this many samples, then holds at f_end. 0 means the enclosing Segment's num_samples. A standalone chirp (f_end != freq) must declare it, so step(), steps(N) and any chunking of reads produce the same waveform; generating one without it raises. Ignored by non-chirp types.

0
doppler float | tuple[float, float]

Clock Doppler in ppm: the received time base is rescaled by 1 + doppler*1e-6, so the symbol and chip rates move with the carrier and a timing loop sees the error a carrier-only freq offset hides. Accepts a (lo, hi) tuple drawn uniformly per repeat, like freq/snr. Zero doppler AND zero doppler_rate means no channel is built at all, so a source that does not ask for Doppler renders exactly as it always did.

0.0
doppler_rate float | tuple[float, float]

Linear ramp on doppler, in ppm per second of elapsed stream time. The channel runs through a segment's gaps as well as its on-time -- an emitter does not stop moving because its burst ended -- so this is per second, not per second of on-time. Accepts a (lo, hi) tuple drawn uniformly per repeat.

0.0
carrier_hz float

RF carrier in Hz that the ppm figures are referred to. It gives the coherent carrier rotation that accompanies the time-base warp; 0 warps the clock alone, with no carrier rotation -- a legitimate scene, not an unset field. Independent of doppler/doppler_rate.

0.0
doppler_lifetime str

How long this source's Doppler channel lives. per_instance: the channel dies with each repeats instance, so the geometry restarts -- the repeated-trial shape, which composes with a ranged doppler re-drawn per instance. persist: one continuous pass carries across the segment's gaps and repeat instances, keyed by (segment, source) position -- the only lifetime under which doppler_rate accumulates across a multi-burst scene. compose(), stream() and Plan.prepare() all honour both. One of "per_instance", "persist".

``"per_instance"``
modulation str

Symbol mapping of a bits pattern. none: the pattern shaped and output as-is (NRZ). bpsk: +/-1 symbols. qpsk: Gray-coded symbols from pairs of bits. One of "none", "bpsk", "qpsk".

``"bpsk"``
pulse str

Pulse shape per symbol or chip, for pn/bpsk/qpsk/bits/symbols/dsss. rect: rectangular, no ISI filtering. rrc: root-raised cosine; see rrc_beta and rrc_span. One of "rect", "rrc".

``"rect"``
rrc_beta float

RRC roll-off factor, in (0, 1], when pulse=rrc.

0.35
rrc_span int

RRC filter support in symbols when pulse=rrc, ONE-SIDED: the filter has 2rrc_spansps + 1 taps, unit energy (sum of h^2 = 1).

8
symbols NDArray[complex64] | None

For type=symbols: a complex constellation stream. Each element is the output point itself, oversampled by sps, cycled, and RRC-shaped with pulse=rrc, which generalises any modulation (pi/4-QPSK, QAM, ...).

None
acq_code bytes | None

The preamble code, sent acq_reps times at the head of the frame (a Field's *REPS on the command line and in a scene) -- the coherent pull-in target BurstDespreader.set_acq and BurstDemod.set_preamble lock to. For type=dsss it is unmodulated chips ahead of the spread frame; for type=bits it is the head of the bit pattern. Setting it is what makes a source FRAMED.

None
acq_reps int

Preamble repetitions: periods of acq_code before the sync word. On the command line and in a scene it is acq_code's *REPS.

1
data_code bytes | None

For type=dsss: the payload spreading code, a second code distinct from acq_code. Every frame bit (sync, payload, crc) is XOR-spread across its full length, so len(data_code) is the spreading factor.

None
sync bytes | None

RETIRED (doppler#1617): nothing reads it but the refusal. The frame-sync word is a field of the frame DESCRIPTION, so sync= is refused naming frame=; the CLI's --sync builds that description for you, and a scene refuses the "sync" key.

None
crc bytes | None

RETIRED (doppler#1617): nothing reads it but the refusal. A CRC is a stage of the frame DESCRIPTION, so crc= is refused naming frame=, whatever its value (crc="none" included); the CLI's --crc builds that description for you, and a scene refuses the "crc" key.

None
symbol_rate float

For type=dsss: > 0 selects CONTINUOUS asynchronous mode. The spreading code repeats endlessly and data rides on it at this symbol rate (Hz), independent of the code-epoch rate (chips/symbol = fs/sps/symbol_rate, non-integer). No preamble/sync/CRC frame; data comes from the payload when supplied, else a seeded PN a receiver regenerates. Absent/0 = burst.

0.0
dsss_code_only int

Continuous dsss data source: 1 = code-only (the pure spreading code, no data modulation); 0 = data-modulated (the payload when supplied, else the seeded PN). Ignored for burst dsss and non-dsss types.

0
frame FrameDesc | str | None

A frame DESCRIPTION, the whole frame: fields in wire order, and stages that each name the span they cover (crc16, rs, randomise, interleave, conv, or a kind of your own). It is the only way a source says anything but the common frame: a coding stage, a field of the caller's own bits at a position of their choosing, a stage covering a span they name. wfmgen --frame FILE, a scene's frame key and Python's frame= (a FrameDesc or a Frame) all land here. When it is set it IS the frame, and an unspread acq_code beside it is refused. NULL means the common frame, [preamble x reps | data], which dp_wfm_frame_fixed() builds from acq_code and the data source; a sync word or a CRC is a field or a stage of a description, never a flat field. A C caller's description is borrowed, exactly as wfm_seq_t is borrowed elsewhere here, so it must outlive the source. The composer and a Python source hold their own copy (dp_wfm_frame_copy()), so a later change to the FrameDesc does not reach them. On the Python face frame= is an input: read it back from the composer's JSON (its getter is jm's, pending removal: doppler#1694). KERNELS stay in C by design. A description names a stage's KIND; the code that runs it is a wfm_frame_ops_t entry, and a caller adding a genuinely new transform (convolutional interleaving, say) writes that kernel in C and hands it to dp_wfm_frame_assemble directly.

None
bits bytes | None

RETIRED (doppler#1718): nothing reads it but the refusal. A payload is drawn from a data source, so bits=, and its aliases payload= and pattern=, are refused naming data=; the CLI and a scene refuse --bits and "payload" the same way.

None
data bytes | None

A frame's payload drawn from a data source: a Field on the command line and in a scene, a bit array in Python. The source is split into data_len-bit frames, one chunk per frame, and its last chunk is padded from fill. For type=bits, bpsk/qpsk/pn framed, a dsss burst (one burst per frame), and continuous dsss (one bit per data symbol, no frame); not with data_from_file.

None
data_len int

Bits of the data source per frame: the data:LEN field of the common frame (preamble x reps, sync, data:LEN, crc). 0 takes a finite source whole, as one frame. A carried frame names its own data field, and this is then 0 or that field's LEN. Continuous dsss has no frame, and refuses it.

0
fill bytes | None

The bits that pad a data source's last frame when it does not divide into data_len-bit frames, tiled from their first bit; stdin on a framed source always needs them. Without them such a source is refused before the first sample. A Field on the command line and in a scene, a bit array in Python. Continuous dsss has no frame to pad, and refuses it.

None
code_only_symbols int

Continuous dsss: pure-code symbols opening each frame of frame_symbols symbols. The data pauses through them and runs on across frames, so a searcher can integrate coherently over the window; once a payload ends, no window is sent. At most frame_symbols, and fewer when a data source is set; 0 with frame_symbols 0 = no window.

0
frame_symbols int

Continuous dsss: the frame, in symbols, that code_only_symbols opens; give it with code_only_symbols, or both 0 for no window. The frame runs on the stream's own symbol clock, so its edges land at any chip phase.

0
fs float

Sample rate in Hz, one per segment and shared by all its sources. At the default 1.0 every frequency is normalised (cycles per sample); state it whenever a scene is in real Hz.

1.0
num_samples int | tuple[int, int]

Segment on-time in samples, before the trailing gap: 0 derives it from the sources, or 1024 when they set none. A finite data source sets its frames, a lone dsss burst one burst, and a stream runs to its end. A count beside a finite data source or a lone dsss burst is refused, since they set the length; give repeats for more.

0
off_samples int | tuple[int, int]

Trailing gap after the on-time, in samples. It carries the noise floor or hard zeros, per gap_noise.

0
repeats int

Play the segment this many times back-to-back (each instance = delay + on-time + trailing gap) before advancing. Ranged fields re-draw and the AWGN is fresh per instance; the signal (codes, payload, PN phase) stays fixed. 0 and 1 both mean one instance.

1
delay_samples int | tuple[int, int]

Leading gap before the on-time, in samples: the burst arrives after this delay, and the gap carries the noise floor like off_samples. Ranged like off_samples and re-drawn per repeats instance, so a (lo, hi) delay is per-burst arrival jitter. Use off_samples for inter-burst spacing, delay_samples for arrival jitter.

0
gap_noise str

Gap policy for this segment's delay and trailing gap. auto: gaps carry the segment's noise floor -- the sources' AWGN keeps running while the signal stops (clean scenes still get exact-zero gaps). off: gaps are hard zeros. One of "auto", "off".

``"auto"``

sum classmethod

sum(
    *sources: Synth,
    fs: float = ...,
    num_samples: int | tuple[int, int] = ...,
    off_samples: int | tuple[int, int] = ...,
    repeats: int = ...,
    delay_samples: int | tuple[int, int] = ...,
    gap_noise: str = ...,
) -> Segment

Combine sources into a single Segment.

add

add(*others: Segment) -> Timeline

Append segments; return a Timeline.

Timeline

Timeline.

add

add(*segments: Segment) -> Timeline

Append and return self.

StreamSink

Open a stream sink (PUB) bound to a NATS subject.

Parameters:

Name Type Description Default
endpoint int

Endpoint, e.g. "nats://127.0.0.1:4222/iq".

required
sample_type str

The wire type. The integer types use full-scale +/-1.0. One of "cf32", "cf64", "ci32", "ci16", "ci8", "f32", "f64", "i32", "i16", "i8".

``"cf32"``

clip_fraction property

clip_fraction: float

clip_fraction (float).

peak_dbfs property

peak_dbfs: float

peak_dbfs (float).

clipped property

clipped: bool

clipped (bool).

send

send(iq: NDArray[complex64], fs: float, fc: float) -> None

Convert a cf32 block to the wire type and publish it.

A refused block never left this process: the client refuses publishes while a reconnect has 8 MiB pending, and a drained sink refuses outright. The stream goes on, so the next block may go; a caller that keeps sending counts the refusals and reports them, as wfmgen does (#2011).

Parameters:

Name Type Description Default
iq NDArray[complex64]

Complex-float samples.

required
fs float

Sample rate (Hz), carried in the wire header.

required
fc float

Center frequency (Hz), carried in the wire header.

required

Raises:

Type Description
OSError

If the C call returns a non-zero status. The exception message is the stream refused the block; it never left this process, with the return code appended (gh-869).

send_eos

send_eos() -> None

Tell subscribers this stream has ended.

Publishes an end-of-stream frame, so a consumer learns the sender finished rather than inferring it from silence. Send it BEFORE draining: a drain cannot be reversed and refuses sends once it reaches its publish-flushing phase.

Raises:

Type Description
OSError

If the C call returns a non-zero status. The exception message is dp_wfm_stream_sink_send_eos failed, with the return code appended (gh-869).

drain

drain(timeout_ms: int = ...) -> None

Let everything already sent reach the server, then stop.

A send hands a block to the NATS client and returns; the client writes it in the background. So "send returned" is not "the server has it", and closing without asking relies on the client's own best-effort flush -- capped at 500 ms, with no way to report failure, so a backlog that cannot clear in half a second is dropped silently.

Call this before closing the sink on any run whose tail matters. After it returns the sink is finished: close it next, which is then just the free.

Parameters:

Name Type Description Default
timeout_ms int

Budget; <= 0 uses the stream layer's 5 s default.

...

Raises:

Type Description
OSError

If the C call returns a non-zero status. The exception message is dp_wfm_stream_sink_drain failed, with the return code appended (gh-869).

track_clipping

track_clipping(on: int = ...) -> None

Enable or disable the per-component clip counter.

Off by default; the peak is tracked regardless. Only components sent while tracking is on are counted, in both the clips and the dp_wfm_stream_sink_clip_fraction() denominator, so enabling it mid-stream measures only what follows.

Parameters:

Name Type Description Default
on int

Non-zero to count clips, 0 to stop counting.

...

close

close() -> None

Release the handle and free resources.

__enter__

__enter__() -> StreamSink

Enter context; return self.

__exit__

__exit__(*exc: Any) -> None

Exit context and close the handle.

SampleClock

SampleClock handle.

Parameters:

Name Type Description Default
fs float
required
resync int
0

samples property

samples: int

samples (int).

underruns property

underruns: int

underruns (int).

max_lateness property

max_lateness: float

max_lateness (float).

pace

pace(count: int) -> float

Advance by count samples and sleep until that block's deadline (epoch + n/fs). Returns the slack in seconds measured before sleeping: > 0 means early (and it slept that long); <= 0 means it arrived late — an underrun, which is counted (and the epoch re-anchored when resync is set), with no sleep.

Parameters:

Name Type Description Default
count int

Samples in the block being paced; n grows by this much before the deadline is computed.

required

Returns:

Type Description
float

Seconds from now to the deadline, read before any sleep. A deadline reached exactly (slack 0) also counts as an underrun. The sleep itself returns early if dp_interrupted() is set, so it may sleep less than a positive slack.

stamp

stamp() -> int

Ideal wall-clock timestamp (ns since the UNIX epoch) of the next sample to be produced — sample index n. Call it before pace() to tag the block you are about to emit, or after to tag the following block. Equivalent to dp_sample_clock_stamp_at(c, c->n).

Returns:

Type Description
int

epoch_real_ns plus n/fs seconds, in ns, rounded to the nearest ns.

stamp_at

stamp_at(n: int) -> int

Ideal wall-clock timestamp (ns since the UNIX epoch) of an ARBITRARY sample index n — past, present, or future, not just the clock's own live position. The receive-side counterpart of dp_sample_clock_stamp(): a block emitting several per-record outputs from one buffered input (e.g. several detections spanning different epochs from one streamed message) stamps each at its own historical sample offset instead of reusing the whole buffer's single arrival time.

Parameters:

Name Type Description Default
n int

Sample index, counted on the clock's current timeline.

required

Returns:

Type Description
int

epoch_real_ns plus n/fs seconds, in ns, rounded to the nearest ns.

track

track(
    observed_timestamp_ns: int,
    n_at_observation: int,
    tolerance_ns: int,
) -> int

Reconcile c's epoch_real_ns against one OBSERVED (timestamp, sample index) pair read off an incoming stream header — the receive-side dual of pace()'s resync: instead of sleeping toward a deadline, this adopts or corrects the epoch from ground truth the sender already stamped.

The FIRST call always adopts observed_timestamp_ns as the epoch (has_anchor starts false — a fresh clock has no real observation yet, so there is nothing to compare against). Every later call only re-anchors if the discrepancy between the observation and what the clock's current model predicts exceeds tolerance_ns (same step-correction semantics as pace()'s own resync, applied to tracking instead of sleeping) — this corrects accumulated epoch OFFSET only, it does not model sample-rate SKEW, exactly like pace()'s resync.

Rejects (no-op, returns 0) any observation with n_at_observation less than the clock's current n outright: a stale, out-of-order, or redelivered header must never walk the epoch backward. Never treat two reconciled observations as literal replay-safe state — always resync from an ARRIVING message, not a cached one.

An accepted observation, whether or not it re-anchors, also moves n to n_at_observation.

Parameters:

Name Type Description Default
observed_timestamp_ns int

Wall-clock time, ns since the UNIX epoch, the sender stamped on sample n_at_observation.

required
n_at_observation int

Sample index that timestamp belongs to.

required
tolerance_ns int

Largest predicted-vs-observed gap, ns, accepted without re-anchoring.

required

Returns:

Type Description
int

Nonzero if this call adopted or re-anchored the epoch; 0 if it was accepted as already consistent, or rejected as stale.

reset

reset() -> None

Re-capture both epochs and zero the counters — a fresh clock at n=0.

resync

resync() -> None

Re-anchor the pacing epoch to "now" without clearing n or counters, dropping any accumulated lateness so future blocks pace forward from the present. (pace() does this automatically when resync is set.)

close

close() -> None

Release the handle and free resources. Once its _capsule has been taken, close() only marks it closed: the teardown waits until the last borrower lets go (gh-2346), so a file or socket it holds is not released here, and a status the close function would report is not raised.

Module-level helpers

The SigMF sidecar is now Composer(...).to_sigmf(...) — see the Composer class above. write_blue_header (detached BLUE headers) is on Python: Capture I/O.

rrc_taps

rrc_taps(
    beta: float, sps: int, span: int
) -> NDArray[np.float32]

Root-raised-cosine pulse-shaping taps (2spansps+1 unit-energy real float32 taps).

The wfm-namespace alias of dp_wfm_rrc_taps(): fills out with 2*span*sps + 1 real taps, symmetric about the center tap and scaled so the sum of squares is 1 (cascading TX and RX gives a Nyquist raised cosine). If sps or span is below 1 the filter is refused and the 2*span*sps + 1 output slots are zeroed instead; no error is raised.

Parameters:

Name Type Description Default
beta float

Roll-off factor, 0 to 1.

required
sps int

Samples per symbol (>= 1).

required
span int

One-sided filter span in symbols (>= 1).

required

Returns:

Type Description
NDArray[float32]

Output.

rrc_h

rrc_h(
    t: NDArray[float64], beta: float
) -> NDArray[np.float64]

Analytic root-raised-cosine pulse at arbitrary (non-grid) times t, in symbol periods. The transmit half of a matched-filter pair. Use this, not a transcription of the formula, whenever a stimulus needs the pulse off the integer sample grid — a non-integer samples-per-symbol or a fractional timing offset has no grid to sample. rrc_taps remains the right call for filter taps.

Evaluates the header-inline wfm_rrc_h() at each t[i]: the unnormalized RRC impulse response (peak 1 - beta + 4*beta/pi at t = 0), with the t = 0 and t = +-1/(4*beta) singularities replaced by their closed-form limits. Use it when the pulse is needed off the integer sample grid; dp_rrc_taps() remains the call for filter taps. If beta is outside 0 to 1 (or NaN) the output is zeroed instead; no error is raised.

Parameters:

Name Type Description Default
t NDArray[float64]

Times in symbol periods, relative to the pulse center.

required
beta float

Roll-off factor, 0 to 1.

required

Returns:

Type Description
NDArray[float64]

Output.

rc_h

rc_h(
    t: NDArray[float64], beta: float
) -> NDArray[np.float64]

Analytic full raised-cosine pulse at arbitrary (non-grid) times t, in symbol periods. Already the Nyquist response a matched TX/RX pair produces, so this is what models the matched-filter OUTPUT directly — a timing-detector S-curve reference, or a receiver test with its front end collapsed away.

Evaluates the header-inline wfm_rc_h() at each t[i]: the full raised cosine, normalized to g(0) = 1 and zero at every nonzero integer t, i.e. the matched-filter output a TX/RX RRC pair delivers at timing offset t. The t = +-1/(2*beta) singularity uses its closed-form limit. If beta is outside 0 to 1 (or NaN) the output is zeroed instead; no error is raised.

Parameters:

Name Type Description Default
t NDArray[float64]

Times in symbol periods, relative to the pulse center.

required
beta float

Roll-off factor, 0 to 1.

required

Returns:

Type Description
NDArray[float64]

Output.

dsss_spread

dsss_spread(
    syms: NDArray[complex64],
    code: NDArray[uint8] | bytes | bytearray | memoryview,
    sf: int,
) -> NDArray[np.complex64]

Direct-sequence spread syms by the ±1 chip code; yields len(syms)*sf chips.

The wfm-namespace alias of dp_wfm_dsss_spread(): out[i*sf + j] = syms[i] * (code[j] ? -1 : +1), so each symbol is repeated over sf chips with a per-chip sign flip. Only the first sf chips of code are used. If sf < 1 or code_len < sf the spread is refused and the output is zeroed instead (syms_len * sf slots, none when sf < 1); no error is raised.

Parameters:

Name Type Description Default
syms NDArray[complex64]

Complex data symbols.

required
code NDArray[uint8] | bytes | bytearray | memoryview

Chip code, one chip per byte, 0 or 1.

required
sf int

Spreading factor, chips per symbol (>= 1).

required

Returns:

Type Description
NDArray[complex64]

Output.

crc16

crc16(
    bits: NDArray[uint8] | bytes | bytearray | memoryview,
) -> int

CRC-16-CCITT (poly 0x1021, init 0xFFFF) over an unpacked 0/1 bit array, MSB-first — the DSSS burst frame trailer wfmgen appends and BurstDemod validates.

Parameters:

Name Type Description Default
bits NDArray[uint8] | bytes | bytearray | memoryview

Array of 0/1 bit values (one per byte).

required

Returns:

Type Description
int

The 16-bit CRC.

Examples:

>>> import numpy as np
>>> from doppler.wfm import crc16
>>> ascii_bits = np.unpackbits(np.frombuffer(b"123456789", np.uint8))
>>> hex(crc16(ascii_bits))   # the standard CCITT check vector
'0x29b1'

mls_poly

mls_poly(n: int) -> int

Maximal-length-sequence primitive polynomial for an LFSR of length n.

Parameters:

Name Type Description Default
n int

LFSR length in stages (2..64).

required

Returns:

Type Description
int

Primitive-polynomial tap mask, or 0 if n is out of range.

Examples:

>>> from doppler.wfm import mls_poly
>>> hex(mls_poly(7))
'0x41'

Modulation & SNR helpers

Low-level primitives behind the modulated types and the SNR model, exposed for callers who build their own symbol streams or noise budgets: bpsk_map / qpsk_map map bits (and Gray-coded symbol indices) to unit-energy constellation points, wfm_ebno_to_snr_db converts an Eb/No target to the over-fs SNR the generator actually places, and wfm_awgn_amplitude returns the noise amplitude for a given SNR.

bpsk_map

bpsk_map(
    bits: NDArray[uint8] | bytes | bytearray | memoryview,
) -> NDArray[np.complex64]

Map bits {0,1} to BPSK symbols {+1,-1} (cf32).

Parameters:

Name Type Description Default
bits NDArray[uint8] | bytes | bytearray | memoryview

Array of uint8 values; only the LSB of each byte is used.

required

Returns:

Type Description
NDArray[complex64]

Output.

Examples:

>>> from doppler.wfm import bpsk_map
>>> import numpy as np
>>> bits = np.array([0, 1, 0, 1], dtype=np.uint8)
>>> bpsk_map(bits).tolist()
[(1+0j), (-1+0j), (1+0j), (-1+0j)]

qpsk_map

qpsk_map(
    syms: NDArray[uint8] | bytes | bytearray | memoryview,
) -> NDArray[np.complex64]

Map QPSK symbol indices {0,1,2,3} to Gray-coded symbols (cf32).

Parameters:

Name Type Description Default
syms NDArray[uint8] | bytes | bytearray | memoryview

Array of uint8 symbol indices; values must be in {0,1,2,3}. Bits above position 1 are ignored.

required

Returns:

Type Description
NDArray[complex64]

Output.

Examples:

>>> from doppler.wfm import qpsk_map
>>> import numpy as np
>>> idx = np.array([0, 1, 2, 3], dtype=np.uint8)
>>> out = qpsk_map(idx)
>>> [round(float(v.real), 4) for v in out]
[0.7071, -0.7071, 0.7071, -0.7071]
>>> [round(float(v.imag), 4) for v in out]
[0.7071, 0.7071, -0.7071, -0.7071]

wfm_ebno_to_snr_db

wfm_ebno_to_snr_db(
    ebno_db: float,
    bits_per_symbol: int,
    samples_per_symbol: float,
) -> float

Convert Eb/No (dB) to SNR (dB over fs).

Parameters:

Name Type Description Default
ebno_db float

Eb/No in dB (energy per bit over noise spectral density).

required
bits_per_symbol int

Bits carried per modulation symbol: 1 for BPSK, 2 for QPSK.

required
samples_per_symbol float

Oversampling ratio (sps), e.g. 8.0.

required

Returns:

Type Description
float

SNR in dB measured over the full sample-rate bandwidth.

Examples:

>>> from doppler.wfm import wfm_ebno_to_snr_db
>>> round(float(wfm_ebno_to_snr_db(10.0, 2, 8.0)), 4)
3.9794
>>> round(float(wfm_ebno_to_snr_db(10.0, 1, 8.0)), 4)
0.9691

wfm_awgn_amplitude

wfm_awgn_amplitude(
    snr_db: float, signal_power: float
) -> float

AWGN amplitude for a target SNR (dB, over fs) given signal power.

Parameters:

Name Type Description Default
snr_db float

Target SNR in dB, referenced to the full sample rate.

required
signal_power float

RMS power of the signal (e.g. 1.0 for unit-power complex tones or unit-energy BPSK/QPSK symbols).

required

Returns:

Type Description
float

Per-component AWGN amplitude (sigma for one I or Q channel).

Examples:

>>> from doppler.wfm import wfm_awgn_amplitude
>>> round(float(wfm_awgn_amplitude(10.0, 1.0)), 6)
0.223607
>>> round(float(wfm_awgn_amplitude(0.0, 1.0)), 6)
0.707107

draws() — what a ranged scene actually drew

A ranged field is re-picked at random for every repeat instance, so the scene declares a span while each burst flies one value out of it. Scoring a receiver means knowing which value, and the span cannot tell you — read the range's lo instead and you are wrong by up to its width.

draws(scene) returns one record per source per instance: where it sits (seg, instance, src, start, delay, on, off) and what it drew (freq, f_end, snr, level, doppler, doppler_rate). These are the same rows a capture's SigMF metadata is built from — both read through dp_wfm_compose_draws() in C — so an in-process render and a written capture cannot disagree about what was generated.

draws

draws(scene: Composer) -> list[dict]

What each instance of a scene actually drew — its ground truth.

A ranged field is re-picked at random for every instance, so the scene declares a span while each burst flies one value out of it. Scoring a receiver means knowing which value, and the span cannot tell you: read the range's lo instead and you are wrong by up to its width, which is the defect measured at 1224 Hz and 6.0 dB in doppler#1086.

One row per source per instance, in stream order. The timing keys (seg, instance, src, start, delay, on, off) say where the row sits; the rest (freq, f_end, snr, level, doppler, doppler_rate) are the drawn values.

These are the same rows the SigMF metadata is built from — both read through dp_wfm_compose_draws() in C — so a capture and an in-process render cannot disagree about what was generated.

Parameters:

Name Type Description Default
scene Composer

The composer to interrogate. Fixed fields report their scalar, so a scene with no ranged field is still described row by row.

required

Returns:

Type Description
list of dict

One record per source per instance.

Examples:

>>> from doppler.wfm.compose import Composer, Segment, draws
>>> scene = Composer(
...     [
...         Segment(
...             "bpsk",
...             fs=1e6,
...             sps=4,
...             num_samples=1024,
...             repeats=3,
...             seed=5,
...             freq=(9000.0, 14000.0),
...         )
...     ]
... )
>>> rows = draws(scene)
>>> len(rows)
3
>>> [r["instance"] for r in rows]
[0, 1, 2]
>>> all(9000.0 <= r["freq"] <= 14000.0 for r in rows)
True
>>> len({r["freq"] for r in rows}) == 3  # each burst drew its own
True

Plan — prepare-once stimulus engine

A composed scene is a linear form Σ gainₖ·signalₖ + noise, and the expensive DSP lives entirely in the signal terms — invariant across a parameter sweep. prepare(scene) renders and caches each source once; Plan.render / Plan.at then re-materialize any variation (per-source gains / phases / enable, global snr, Monte-Carlo seed) as a cheap re-weighted sum, bit-for-bit identical to a full compose. The stimulus for a detection/BER sweep or a Monte-Carlo campaign that re-runs one scene at many operating points — see the guide, and the DSSS BER Monte Carlo for one driving a receiver.

Plan

A prepared scene that re-materializes parameter variations cheaply.

A composed scene is a sequence of segments, each a linear form Σ gainₖ·signalₖ + noise; the expensive DSP (spreading, pulse shaping, the LO) lives entirely in the signal terms, which do not change when you sweep a level, a phase, the SNR, or the noise seed — nor across a segment's repeats instances (only the AWGN, and any ranged gap length, vary per instance). :class:Plan renders each segment's signal once (bit-identically to a full compose), caches it, and then serves every variation as a cheap re-weighted sum plus a regenerated noise synth per instance — so a BER/Pd curve or a Monte-Carlo campaign that re-runs one scene hundreds of times pays the DSP cost once.

Construct it from anything :func:_spec_json accepts — most often a :class:Composer (or call :func:prepare). The baseline render() (no overrides) is bit-for-bit identical to Composer(scene).compose().

Parameters:

Name Type Description Default
scene Composer or str or bytes

The scene to prepare — a :class:Composer (or any object with a to_json()) or a spec JSON string.

required
Notes

Any number of finite segments (no continuous/repeat scene — that has no fixed capacity); each segment may declare repeats and ranged off_samples/delay_samples (redrawn per instance, deterministic from the Plan's seed). A lone bundled noisy source (one source carrying its own SNR) is supported: its AWGN is reconstructed via a per-instance noise synth rather than an external multiply. Still out of scope, and raising ValueError at construction: a ranged on-time (num_samples — it would invalidate the fixed-length signal cache) or any ranged per-source field but the Doppler pair (freq/snr/level/f_end — redrawing one would invalidate its cached render). The overridable axes are per-source gains (dBFS levels), phases (radians), enable (drop a source), the global snr (the noise floor), and the Monte-Carlo seed (also redraws any ranged gap length and any ranged Doppler) — applied uniformly across every segment/instance that carries noise.

A source with clock doppler / doppler_rate is served, and the render is :meth:Composer.compose to the bit: both lifetimes, the gap ring-out, a leading delay, and the AWGN of a bundled source (which sits inside the channel). The cache holds the signal before the channel and the channel runs at render time over it, so a render of a Doppler scene is no longer a pure re-weight: each Doppler source costs one resampler pass over its timeline, on top of the (still cached) synthesis. That pass is parallelised — a segment's repeats render concurrently, or its sources do when a persist channel chains the repeats — so it costs wall-clock mainly for a single burst from a single source. The one exception is a background=True source with Doppler, which is refused — the background fold sums those into one composite before any channel could run, and a channel is per source. Multipath delay is a planned follow-up.

Examples:

>>> import numpy as np
>>> from doppler.wfm.compose import Composer, Segment, qpsk, tone, prepare
>>> scene = Composer(
...     Segment.sum(
...         qpsk(snr=12.0, seed=7, sps=8, pn_length=7),
...         tone(freq=1e5, seed=3, sps=8),
...         fs=1e6,
...         num_samples=4096,
...     )
... )
>>> plan = prepare(scene)
>>> len(plan), plan.n_sources
(4096, 2)
>>> # baseline reproduces a full compose exactly
>>> np.array_equal(plan.render(), scene.compose())
True
>>> # sweep SNR with no re-synthesis; each point is a cheap re-weight
>>> curve = {s: plan.at(s) for s in (0.0, 3.0, 6.0, 9.0)}
>>> {s: v.shape for s, v in curve.items()}
{0.0: (4096,), 3.0: (4096,), 6.0: (4096,), 9.0: (4096,)}

n_sources property

n_sources: int

Number of cached signal sources (excludes the noise floor).

anchor_seed property

anchor_seed: int

The noise seed that reproduces a full compose at the base SNR.

render

render(
    *,
    gains: Sequence[float] | None = None,
    phases: Sequence[float] | None = None,
    enable: Sequence[bool] | None = None,
    snr: float | None = None,
    seed: int | None = None,
) -> NDArray[np.complex64]

Materialize the scene with per-axis overrides applied.

Every argument is optional; omit them all for the baseline (identical to Composer(scene).compose()). gains/phases/enable are per signal source (length :attr:n_sources, in scene order).

Parameters:

Name Type Description Default
gains sequence of float

Absolute source levels in dBFS (0 = unit power). Replaces each source's resolved level; does not move the noise floor.

None
phases sequence of float

Per-source phase rotations in radians (0 = identity).

None
enable sequence of bool

False drops a source (an exact gain=0 term).

None
snr float

Global SNR in dB — moves only the noise floor (its convention is the anchor source's snr_mode). A scene with no noisy source has no floor to move, and snr raises ValueError.

None
seed int

Noise seed for this realization (defaults to the scene's, i.e. the value that reproduces a full compose), in 0 .. 2**32 - 1.

None

Raises:

Type Description
ValueError

When gains/phases/enable is not :attr:n_sources long, seed is outside 0 .. 2**32 - 1, or a gains, phases or snr value is NaN or infinite (the overrides travel as JSON, which cannot carry one): the C render refuses rather than falling back to the baseline.

Returns:

Type Description
ndarray

complex64 samples, length :func:len.

at

at(
    snr: float, seed: int | None = None
) -> NDArray[np.complex64]

Scalar fast path: render at one (snr, seed) (no JSON parse).

The hot loop of an SNR sweep or Monte-Carlo run. seed defaults to :attr:anchor_seed (which reproduces a full compose at the scene's base SNR). A scene with no noisy source has no floor for snr to move, so it raises ValueError rather than returning the clean signal at every SNR. seed is 32-bit (0 .. 2**32 - 1); a larger one raises OverflowError rather than being cut to its low bits.

sweep

sweep(
    snrs: Sequence[float], *, seed: int | None = None
) -> Iterator[tuple[float, NDArray[np.complex64]]]

Yield (snr, samples) across an SNR list at a fixed noise seed.

A held seed isolates the SNR axis (same noise realization, only the floor moves) — the natural stimulus for a Pd/BER-vs-SNR curve.

monte_carlo

monte_carlo(
    snr: float, n: int, *, seed0: int = 0
) -> Iterator[NDArray[np.complex64]]

Yield n independent noise realizations at a fixed SNR.

Seeds run seed0 … seed0 + n − 1; the signal is identical across draws, only the noise differs.

save

save() -> bytes

Serialize this prepared Plan to a bytes blob.

The blob embeds the scene spec, a build-time fingerprint of the DSP source, and the cached per-source signal buffers, so :func:PlanFromBlob reconstructs an equivalent Plan without re-running prepare()'s DSP. A fingerprint mismatch (the library was rebuilt with different DSP) rebuilds transparently — never silently wrong. All of it happens in C.

dump

dump(path: str | PathLike[str]) -> None

Save this prepared Plan to path (the file form of :meth:save).

Raises :class:OSError if the file cannot be written. The write is done in C, not via Python I/O.

prepare

prepare(scene: Composer | str | bytes) -> Plan

Prepare a scene into a reusable :class:Plan (Plan(scene)).

Examples:

>>> from doppler.wfm.compose import Composer, Segment, qpsk, tone, prepare
>>> plan = prepare(
...     Composer(
...         Segment.sum(
...             qpsk(snr=10.0, seed=1),
...             tone(freq=2e5, seed=2),
...             fs=1e6,
...             num_samples=1024,
...         )
...     )
... )
>>> len(plan)
1024

A prepared Plan can be saved and restored: plan.save() returns the cache as bytes and plan.dump(path) writes it to a file; PlanFromBlob(blob) and PlanFromFile(path) reconstruct a Plan without re-running prepare() (the blob carries a DSP-source fingerprint, so a stale cache transparently rebuilds rather than returning wrong samples).

Reach for it to checkpoint or resume a live Plan, not to move one between processes — the blob is the whole rendered cache, so for a hand-off the spec JSON is the thing to ship. The reasoning, with the sizes: Prepare Once, Sweep Many.

PlanFromBlob

PlanFromBlob(blob: bytes) -> Plan

Reconstruct a :class:Plan from a blob produced by :meth:Plan.save.

Skips prepare()'s DSP by copying the cached signal buffers out of the blob (rebuilding transparently on a DSP-fingerprint mismatch). The blob's reconstruction is entirely in C.

Examples:

>>> from doppler.wfm import prepare, Composer, Segment, qpsk, PlanFromBlob
>>> scene = Composer(Segment.sum(qpsk(seed=1), fs=1e6, num_samples=1024))
>>> plan = prepare(scene)
>>> restored = PlanFromBlob(plan.save())
>>> import numpy as np
>>> np.array_equal(restored.render(), plan.render())
True

PlanFromFile

PlanFromFile(path: str | PathLike[str]) -> Plan

Reconstruct a :class:Plan from a file produced by :meth:Plan.dump.

The file form of :func:PlanFromBlob; the read is done in C.

field_bits — a Field, from its text

The one door from text to the bits every frame object takes. A literal is written as its bits (1101) or in hex (0x1ACFFC1D); a generated field names its generator (pn:1023:10, gold:…, dotted:16); any of them may repeat (pn:31:5*4). The grammar is Fields: bits as text, read by one C function, and text outside it raises rather than coming back empty.

field_bits

field_bits(spec: str) -> NDArray[np.uint8]

A Field's bits from its text form: 0101, 0x1ACFFC1D, pn:LEN:REG[:SEED[:POLY]][:galois|fibonacci], gold:LEN:REG:TA:SA:TB:SB, dotted:LEN, each optionally *REPS. The one door from text to the bits every frame object takes, over the one C parser (docs/design/frame-description.md §F.1). Text outside the grammar raises.

The grammar is docs/design/frame-description.md §F.1, read once by dp_wfm_field_parse and rendered once by dp_wfm_field_render, both inside dp_wfm_field_bits; this is that call with the output sized to fit. 0101 and 0x1ACFFC1D are literals, pn:LEN:REG[:SEED [:POLY]][:galois|fibonacci], gold:LEN:REG:TA:SA:TB:SB and dotted:LEN are generated, and any of them takes *REPS. Text outside the grammar is refused, never repaired: pn::10, 12abc and 0102 all raise.

Parameters:

Name Type Description Default
spec str

NUL-terminated Field text.

required

Returns:

Type Description
NDArray[uint8]

the bits written, or 0 on refusal (the binding raises).

Examples:

>>> from doppler.wfm import field_bits
>>> field_bits("0x1A").tolist()          # hex, MSB first
[0, 0, 0, 1, 1, 0, 1, 0]
>>> field_bits("dotted:4*2").tolist()    # a repeat is the same bits again
[1, 0, 1, 0, 1, 0, 1, 0]
>>> len(field_bits("pn:1023:10"))        # an m-sequence, 10-bit register
1023
>>> field_bits("pn::10")
Traceback (most recent call last):
    ...
ValueError: LEN, the output length in bits, must be a number > 0

Frame — the bit layout, held by both ends

A frame is [preamble × reps | sync | payload | CRC], and the point of describing it as an object is that the transmitter and the receiver hold the same description. The generator takes the frame as a description too (Segment(frame=…), or wfmgen --sync … / --frame FILE); Frame is the same layout on the analysis side, so a capture is scored against the frame that was actually sent rather than one reconstructed from parts.

Each of the three fields is bits — one per element, each 0 or 1 — and is omitted when absent, the convention wfm_seq_t itself uses (a zero length). A field a receiver can regenerate — a PN or Gold sequence — is written as text and turned into bits by field_bits, so its truth is a handful of numbers (field_bits("pn:1023:10")) rather than a stored array, and a capture stays reproducible from its metadata alone. An element that is not a bit is refused, not masked.

crc_ok is the one that earns its place. It needs no payload truth at all, so it works on a real capture, and unlike a self-referenced EVM or a blind M2M4 it still catches a false lock — a rotated constellation fails the check rather than looking clean. Paired with FrameMeter, that is a frame error rate measured on a signal nobody knows the contents of:

import numpy as np

from doppler.ber import FrameMeter
from doppler.wfm import Frame

sync = np.array([1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 1, 0, 1], np.uint8)  # Barker-13
payload = np.array([0, 1, 1, 0, 1, 0, 0, 1, 1, 1, 0, 0, 0, 1, 0, 1], np.uint8)

f = Frame(sync=sync, payload=payload, crc="crc16")   # no preamble: omitted

# Stand-in for a capture: the frame as sent, and the same frame with one
# payload bit knocked over. On a real record these come from a demodulator.
clean = f.bits()
damaged = clean.copy()
damaged[f.field_off(f.field_index("payload"))] ^= 1
captured_frames = [clean, clean, damaged, clean, damaged]

m = FrameMeter(target_errors=2)
for rx_bits in captured_frames:        # one frame's bits per iteration
    m.add(sync_ok=1, crc=f.crc_ok(rx_bits))
    if m.enough:                        # stop on error count, not frame count
        break

assert m.errors == 2 and m.crc_passed == 3
print(m.fer().lo)                      # assert on `lo`, never on `p_hat`

A frame is read by field NAME: field_index("payload") finds the field, and field_off(i) / field_bits(i) give where it sits and how long it is, in bits from the start of the frame. A Frame holds only the fields it was given, so an omitted preamble takes no index. The CRC field is 16 bits, or 0 when the payload is empty: a CRC over nothing protects nothing, so it is dropped rather than carried as a trailer over no data.

Frame

Create a frame instance.

Parameters:

Name Type Description Default
preamble NDArray[uint8]

Preamble bits, one per element, each 0 or 1. Omitted, there is no preamble. A repeated preamble is repeated in its bits: field_bits("pn:31:5*4").

...
sync NDArray[uint8]

Sync-word bits, one per element, each 0 or 1. Omitted, the frame is unsynced.

...
payload NDArray[uint8]

Payload bits, one per element, each 0 or 1. Omitted, the frame carries none.

...
crc Literal['none', 'crc16']

crc16 covers the payload with a CRC-16; none adds no check. One of "none", "crc16".

"none"

Raises:

Type Description
ValueError

If construction fails. The exception message is frame geometry is empty, or a field holds an element that is not a bit (0 or 1).

Examples:

>>> import numpy as np
>>> from doppler.wfm import Frame
>>> sync = np.array([1,1,1,1,1,0,0,1,1,0,1,0,1], np.uint8)   # Barker-13
>>> payload = np.array([0,1,1,0,1,0,0,1,1,1,0,0,0,1,0,1], np.uint8)
>>> f = Frame(sync=sync, payload=payload, crc="crc16")
>>> f.nbits                                          # 13 + 16 + 16
45
>>> f.field_off(f.field_index("payload"))
13
>>> f.crc_ok(f.bits())        # its own bits are its own truth
1

rx_ok property

rx_ok: int

Checks that came out good in the last deframe() -- one per CRC, one per outer-code codeword. rx_ok == rx_units is the verdict.

rx_units property

rx_units: int

Checks the last deframe() performed across every stage it reversed.

rx_checked property

rx_checked: int

Stages the last deframe() actually reversed. 0 means the description carries no reversible stage at all -- which is why rx_ok is 0 too, and is a different fact from a check that failed. An FER conflating them scores every unprotected frame as an error.

rx_symbols property

rx_symbols: int

Symbol errors the last deframe() repaired. Margin being spent, visible before it is lost -- what an outer code reports and a CRC cannot.

nbits property

nbits: int

Bits in one frame as transmitted: the assembled frame's length for dp_frame_create(), and the last emitting stage's output length (out_bits) after dp_frame_build(). 0 until a description is built, or if building fails.

bits

bits(
    count: int = 1, out: NDArray[uint8] | None = None
) -> NDArray[np.uint8]

Materialise n consecutive frames, one bit per byte.

n counts FRAMES, not bits: a descriptor describes one frame, and a capture holds many. It is the truth for a transmitter that sends the same frame n times -- a data source of n copies of the payload, one chunk a frame -- so a stream compared against this lines up with the one that was transmitted.

Parameters:

Name Type Description Default
count int

How many output samples to ask for. The call may return fewer; size an out= buffer with the matching _max_out() when you need the worst case.

1
out NDArray[uint8] | None

Output, one bit per byte.

None

Returns:

Type Description
NDArray[uint8]

Bits written.

Examples:

>>> import numpy as np
>>> from doppler.wfm import FrameDesc
>>> sync = np.array([1,1,1,1,1,0,0,1,1,0,1,0,1], np.uint8)
>>> payload = np.array([0,1,1,0,1,0,0,1,1,1,0,0,0,1,0,1], np.uint8)
>>> d = FrameDesc(sync=sync, payload=payload, crc="crc16")
>>> d.build()
>>> len(d.bits())        # one frame: 13 + 16 + 16
45
>>> len(d.bits(2))       # n counts FRAMES, tiled the way a capture is
90

bits_max_out

bits_max_out(n: int) -> int

Bits dp_frame_bits will write for n frames — n * nbits.

Parameters:

Name Type Description Default
n int

Frame repetitions.

required

Returns:

Type Description
int

n * nbits, the output capacity in bits (one per byte) that dp_frame_bits() needs; 0 for a NULL state or an unbuilt description.

crc_ok

crc_ok(
    rx_bits: NDArray[uint8]
    | bytes
    | bytearray
    | memoryview,
) -> int

Check one received frame's CRC.

This is what makes a truth-free frame error rate possible. It needs no payload truth at all, so it works on a real capture, and unlike a self-referenced EVM or a blind M2M4 it still catches a false lock — a rotated constellation fails the check rather than looking clean.

Parameters:

Name Type Description Default
rx_bits NDArray[uint8] | bytes | bytearray | memoryview

Received bits, one per byte.

required

Returns:

Type Description
int

1 pass, 0 fail, -1 if the frame carries no CRC or rx_bits is shorter than one frame.

Examples:

>>> import numpy as np
>>> from doppler.wfm import FrameDesc
>>> sync = np.array([1,1,1,1,1,0,0,1,1,0,1,0,1], np.uint8)
>>> payload = np.array([0,1,1,0,1,0,0,1,1,1,0,0,0,1,0,1], np.uint8)
>>> d = FrameDesc(sync=sync, payload=payload, crc="crc16")
>>> d.build()
>>> d.crc_ok(d.bits())           # its own bits are its own truth
1
>>> rx = np.asarray(d.bits()).copy()
>>> rx[d.field_off(d.field_index("payload"))] ^= 1   # one payload bit
>>> d.crc_ok(rx)
0

add_field

add_field(
    name: str,
    bits: NDArray[uint8] | bytes | bytearray | memoryview,
) -> int

Append one named field to a description (see FrameDesc): bits and nothing else, one per element, each 0 or 1. Text reaches it through field_bits(), hex and packed octets through cvt. A field a STAGE fills is add_derived instead, because the caller has no bits for it. name may be empty for an anonymous field; a name another field carries is refused. Returns the new field's index. Refuses once the frame is built.

The field is bits and nothing else, copied here so the description outlives the call. A field a STAGE fills is appended with dp_frame_add_derived instead, because the caller has no bits for it.

Parameters:

Name Type Description Default
name str

The field's name, or NULL/"" for anonymous; a name another field carries is refused.

required
bits NDArray[uint8] | bytes | bytearray | memoryview

The bits, one per element, each 0 or 1.

required

Returns:

Type Description
int

The new field's index, or -1 if the description is full or already built, the name is taken, or an element is not a bit.

Raises:

Type Description
ValueError

If the C call returns a negative value. The exception message is cannot append a field: the description is full or already built, the name is taken, the bits are empty, or an element is not a bit, with the return code appended (gh-869).

Examples:

>>> import numpy as np
>>> from doppler.wfm import FrameDesc
>>> from doppler.ccsds import asm_bits
>>> octets = np.array([(i * 29 + 5) & 0xFF for i in range(223)],
...                   np.uint8)
>>> d = FrameDesc()                      # begin from nothing
>>> d.add_field("asm", asm_bits())       # the attached sync marker
0
>>> d.add_field("data", np.unpackbits(octets))   # the transfer frame
1
>>> d.field_index("data")
1

add_stage

add_stage(
    kind: int = 0,
    first_field: int = 0,
    n_fields: int = 0,
    depth: int = 0,
    emit_num: int = 0,
    emit_den: int = 0,
    unit_bits: int = 0,
) -> int

Append one transform and -- the load-bearing part -- the span of fields it covers. kind is a stage kind: STAGE_CRC16, STAGE_RS, STAGE_RANDOMISE, STAGE_CONV, STAGE_INTERLEAVE from doppler.wfm, or a caller's own from STAGE_USER up. It stays an INT rather than a name because the kind is an open uint32_t a caller extends -- the constants are generated from the C enum, so there is nothing to transcribe. n_fields = 0 means the stage does not run. A stage that inherited whatever ran before it is the representation that cannot express a CCSDS CADU, where the marker is covered by the inner code and by neither the outer code nor the randomiser. unit_bits applies to interleave alone and is the bits per permuted unit (0 reads as 1); its ROW count is depth and its column count is derived from the span the stage covers.

n_fields is the load-bearing part and 0 means the stage does not run. A stage that inherited "everything before me" instead of declaring its cover is the representation that cannot express a CCSDS CADU — see wfm/wfm_frame.h.

Parameters:

Name Type Description Default
kind int

stage kind: a wfm_stage_kind_t value (0=crc16…4=interleave), or a caller's own from WFM_STAGE_USER (0x1000) up, whose kernel then has to reach the assembler through its ops table.

0
first_field int

First field covered.

0
n_fields int

Fields covered; 0 = the stage does not run.

0
depth int

Interleaving depth, for an outer code.

0
emit_num int

Expansion numerator for a stage that emits a NEW stream; 0 when the stage stays inside the frame.

0
emit_den int

Expansion denominator.

0
unit_bits int

INTERLEAVE only: bits per interleaved unit; 0 reads as 1. Match it to the outer code's symbol — permuting octets is what spreads a burst across the codewords of a code over GF(256), and permuting bits inside one spreads a burst within a symbol that is already wrong.

0

Returns:

Type Description
int

The new stage's index, or -1 if the description is full or already built. The Python binding raises ValueError rather than handing back the -1.

Raises:

Type Description
ValueError

If the C call returns a negative value. The exception message is cannot append a stage: the description is full or already built, with the return code appended (gh-869).

Examples:

>>> import numpy as np
>>> from doppler.wfm import FrameDesc
>>> from doppler.ccsds import asm_bits
>>> octets = np.array([(i * 29 + 5) & 0xFF for i in range(223)],
...                   np.uint8)
>>> d = FrameDesc()
>>> _ = d.add_field("asm", asm_bits())
>>> _ = d.add_field("data", np.unpackbits(octets))
>>> _ = d.add_derived("parity", 32 * 8)   # the outer code fills it
>>> d.add_stage(1, first_field=1, n_fields=2, depth=1)   # RS(255,223)
0
>>> d.add_stage(2, first_field=1, n_fields=2)            # randomiser
1

Both start at field 1, so both skip the marker -- the cover is DECLARED, which is the whole reason a CADU is describable here:

>>> d.build()
>>> d.stage_first(0), d.stage_bits(0)
(32, 2040)

field_index

field_index(name: str) -> int

Index of the field called name, or -1 -- the one verb whose sentinel survives into Python, because a name that matches nothing is an ANSWER rather than a refusal. The one lookup that resolves a name, so every index-taking method keeps working and a rename can only be wrong once. An unnamed field is ANONYMOUS rather than named "", so the empty name matches nothing.

The one lookup that resolves a name, so every index-taking entry point keeps working unchanged and a rename can only be wrong once. An unnamed field is ANONYMOUS rather than named "", so the empty name matches nothing — including a field that has no name.

Parameters:

Name Type Description Default
name str

the field name.

required

Returns:

Type Description
int

the index, or -1 on NULL or a name no field carries. This is the one verb whose -1 survives into Python: a name that matches nothing is an ANSWER, not a refusal, so there is nothing to raise about.

Examples:

>>> import numpy as np
>>> from doppler.wfm import FrameDesc
>>> d = FrameDesc()
>>> d.add_field("sync", np.array([1,0,1,0,1,0,1,1,1,1,0,0], np.uint8))
0
>>> d.field_index("sync")
0
>>> d.field_index("absent")
-1

name_field

name_field(index: int, name: str) -> None

Give an already-appended field a name, or clear it with "". Refuses a name another field already carries, because field_index would then answer with whichever it reached first. Refuses once the frame is built.

Parameters:

Name Type Description Default
index int

the field to name.

required
name str

the new name; truncated at WFM_FRAME_NAME_MAX - 1.

required

Raises:

Type Description
ValueError

If the C call returns a non-zero status. The exception message is cannot name a field: the index is out of range, another field already carries the name, or the frame is already built, with the return code appended (gh-869).

Examples:

>>> import numpy as np
>>> from doppler.wfm import FrameDesc
>>> d = FrameDesc()
>>> d.add_field("", np.array([1, 0, 1, 0], np.uint8))   # anonymous
0
>>> d.name_field(0, "payload")
>>> d.field_index("payload")
0

add_derived

add_derived(name: str, bits: int) -> int

Append a named field a STAGE will fill -- a CRC trailer, a block of check symbols. Its producer is not named here because no stage exists yet when the field it derives is appended; add_stage_over wires it. Returns the new field's index; a refusal raises ValueError.

A field with a declared length and no source: a CRC trailer, a block of check symbols. Its producer is wired by dp_frame_add_stage_over rather than named here, because no stage exists yet when the field it derives is appended — fields are ordered by POSITION and stages by APPLICATION.

Parameters:

Name Type Description Default
name str

the field's name, or NULL for anonymous.

required
bits int

its length, which its stage decides and the caller states.

required

Returns:

Type Description
int

The new field's index, or -1 on NULL state, a built frame, a zero bits, a full description (WFM_FRAME_MAX_FIELDS) or a name another field already carries.

Raises:

Type Description
ValueError

If the C call returns a negative value. The exception message is cannot append a derived field: the description is full or already built, with the return code appended (gh-869).

Examples:

>>> import numpy as np
>>> from doppler.wfm import FrameDesc
>>> d = FrameDesc()
>>> d.add_field("payload", np.array([1, 0, 1, 0], np.uint8))
0
>>> d.add_derived("crc", 16)          # a stage will fill it
1

add_data

add_data(name: str, len: int) -> int

Append a named DATA field: len bits a data source fills, one chunk per frame. Returns its index; -1 in C, ValueError from Python.

The object spelling of the Field text data:LEN, and the same field: a WFM_SEQ_DATA sequence of length len, which is exactly what dp_wfm_field_parse("data:LEN") produces for a scene's or the CLI's frame. The description knows the field's length and never its bits: a transmitter draws them from its data source at each frame (a source's data=), and a CRC or outer code covering the field covers that frame's chunk. It is a method rather than Field text in dp_frame_add_field because an object takes bits, and a data field has none (rx-frame-description.md, D5).

A frame draws from one data source, so a description carries at most one data field; a second is refused where geometry is judged, by the layout, as it is for every other face.

Parameters:

Name Type Description Default
name str

The field's name, or NULL/"" for anonymous; a name another field carries is refused.

required
len int

Bits per frame, LEN in data:LEN: from 1 to WFM_FIELD_MAX_BITS, the bound the Field grammar puts on the text form.

required

Returns:

Type Description
int

The new field's index, or -1 if len is out of range, the description is full or already built, or the name is taken.

Raises:

Type Description
ValueError

If the C call returns a negative value. The exception message is cannot append a data field: len is 0 or past the Field grammar's bound (WFM_FIELD_MAX_BITS), or the description is full, already built, or already carries the name, with the return code appended (gh-869).

Examples:

>>> import numpy as np
>>> from doppler.wfm import FrameDesc, STAGE_CRC16, Segment, Composer
>>> d = FrameDesc()
>>> d.add_field("sync", np.array([1, 1, 1, 0, 0, 1, 0], np.uint8))
0
>>> d.add_data("payload", 8)          # 8 bits of the data source a frame
1
>>> d.add_derived("crc", 16)
2
>>> d.add_stage_over(STAGE_CRC16, "payload", "crc")
0
>>> seg = Segment(type="bits", sps=1, modulation="bpsk", frame=d,
...               data=np.unpackbits(np.array([0xA5, 0x3C], np.uint8)))
>>> len(Composer([seg]).compose())    # two frames of 7 + 8 + 16 bits
62

add_stage_over

add_stage_over(
    kind: int,
    first: str,
    last: str,
    depth: int = 0,
    unit_bits: int = 0,
) -> int

Append a stage covering [first .. last] BY NAME -- add_stage_over(STAGE_CRC16, "payload", "crc") says what three integers used to. It wires a derived field's producer for you, which applies the invariant the layout already enforces: a field with a declared length and no source sitting at the end of a cover has exactly one possible producer. kind is a stage kind, as for add_stage. Returns the new stage's index; a refusal raises ValueError.

The cover is the load-bearing part of the representation and this is the form that reads. It wires a derived field's producer for you, which applies the invariant the layout already enforces rather than adding one.

Parameters:

Name Type Description Default
kind int

a stage kind — doppler.wfm.STAGE_CRC16 and its siblings, or a caller's own from STAGE_USER up.

required
first str

name of the first field covered.

required
last str

name of the last field covered; may equal first.

required
depth int

RS / interleave depth; 0 when unused.

0
unit_bits int

interleave unit; 0 reads as 1.

0

Returns:

Type Description
int

the new stage's index, or -1 on NULL, a full description, a name neither field carries, last before first, or once built. The Python binding raises ValueError rather than handing back the -1.

Raises:

Type Description
ValueError

If the C call returns a negative value. The exception message is cannot append a stage: the description is full, already built, or names a field the description does not carry (and `last` must not precede `first`), with the return code appended (gh-869).

Examples:

>>> import numpy as np
>>> from doppler.wfm import FrameDesc
>>> d = FrameDesc()
>>> d.add_field("payload", np.array([0, 1, 1, 0, 1, 0, 0, 1], np.uint8))
0
>>> d.add_derived("crc", 16)
1
>>> d.add_stage_over(0, "payload", "crc")   # 0 = crc16
0
>>> d.build()
>>> d.crc_ok(d.bits())                # its own bits are its own truth
1

build

build() -> None

Lay out and materialise a description. Where a description is checked: one that cannot produce its own bits is not a frame. Separate from the constructor only because the description arrives over several calls and there is no earlier moment at which it is complete. A description with a data field builds too, laid out from its lengths with its stages proved runnable over a discarded chunk: it has no single frame, so bits() returns none, while deframe() and check() work. Raises if it is empty, unbuildable, names a stage no kernel here covers, or was already built.

The point at which a description is checked, which for dp_frame_create happens inside the constructor: a description that cannot produce its own bits is not a frame. It is separate here only because the description arrives over several calls and there is no earlier moment at which it is complete.

The CRC, the outer code, the randomiser and the inner code are all runnable: ccsds_tm has no Python binding and is not getting one, so this object is where a caller meets them. A stage naming a kernel nothing here carries is refused rather than skipped, because a stage that quietly did not run produces a frame that still assembles and syncs to nothing.

A description with a data field (dp_frame_add_data) builds too: the field has no bits until a source draws them, so the description is laid out from its lengths and its stages are proved runnable over a chunk that is thrown away. It then has no single frame, so dp_frame_bits writes none, while dp_frame_deframe and dp_frame_check work as for any other -- a receiver needs the data field's length and nothing else.

The inner encoder starts from the all-zero register on every build: a description describes ONE frame. A stream of CADUs sharing one register is a transmitter's job and lives in dp_ccsds_tm_frame_encode.

Raises:

Type Description
ValueError

If the C call returns a non-zero status. The exception message is cannot build: the description is empty, unbuildable, names a stage no kernel here covers, or was already built, with the return code appended (gh-869).

Examples:

>>> import numpy as np
>>> from doppler.wfm import FrameDesc
>>> sync = np.array([1,1,1,1,1,0,0,1,1,0,1,0,1], np.uint8)
>>> payload = np.array([0,1,1,0,1,0,0,1,1,1,0,0,0,1,0,1], np.uint8)
>>> d = FrameDesc(sync=sync, payload=payload, crc="crc16")
>>> d.build()
>>> d.nbits                     # 13 + 16 + 16, laid out by build()
45

A description that cannot produce bits is not a frame, and is refused rather than half-built:

>>> FrameDesc().build()
Traceback (most recent call last):
    ...
ValueError: cannot build: the description is empty, unbuildable, ...

deframe

deframe(
    rx_bits: NDArray[uint8]
    | bytes
    | bytearray
    | memoryview,
    out: NDArray[uint8] | None = None,
) -> NDArray[np.uint8]

Undo the description's stages over a received frame and hand back the CORRECTED bits — the layer a receiver stops short of (doppler#1022).

The receive counterpart of building one, and the layer a receiver stops short of: DsssBurstReceiver and friends hand back hard and soft decisions for a frame's symbols and make no claim about what they mean, because knowing that needs a description — this one (doppler#1022).

Returns the frame with every reversible stage undone, in place order: a randomiser XORed back, an outer code's repairs APPLIED, a CRC checked. The payload is then a slice, at dp_frame_field_off of the payload field — which is the caller's arithmetic because a description does not privilege one field over another.

The verdict comes back as read-backs (ok, units, checked, symbols), not as a return value, since the return is the bits. Read them exactly as frame_check_t's, including the distinction that matters most: checked == 0 says the description carries no reversible stage at all, which is a different fact from a check that failed.

A stage with no undo kernel — a convolutional inner code, which a receiver cannot even frame-sync through — is reported as not checked rather than as passed.

Parameters:

Name Type Description Default
rx_bits NDArray[uint8] | bytes | bytearray | memoryview

Received bits, frame_bits of them; treated as a capture and never modified.

required
out NDArray[uint8] | None

Receives the corrected frame.

None

Returns:

Type Description
NDArray[uint8]

Bits written — the frame's length — or 0 if the description is empty or either buffer is too small.

Examples:

>>> import numpy as np
>>> from doppler.wfm import Frame
>>> sync = np.array([1,1,1,1,1,0,0,1,1,0,1,0,1], dtype=np.uint8)
>>> payload = np.array([0,1,1,0,1,0,0,1,1,1,0,0,0,1,0,1], dtype=np.uint8)
>>> f = Frame(sync=sync, payload=payload, crc="crc16")
>>> rx = np.asarray(f.bits())          # a clean capture of its own frame
>>> got = np.asarray(f.deframe(rx))
>>> f.rx_ok, f.rx_units, f.rx_checked  # one CRC, and it passed
(1, 1, 1)
>>> off = f.field_off(f.field_index("payload"))   # a SLICE
>>> bool(np.array_equal(got[off:off + 16], payload))
True
>>> rx[off] ^= 1                       # one bit flipped in flight
>>> _ = f.deframe(rx)
>>> f.rx_ok, f.rx_units                # the check notices
(0, 1)

deframe_max_out

deframe_max_out(rx_bits_len: int) -> int

Max bits dp_frame_deframe() writes: the frame's own length.

Size a deframe() buffer with this. The bound is the DESCRIPTION's, not the input's: a frame is as long as its fields say, so how many bits were received does not change how many come back.

Parameters:

Name Type Description Default
rx_bits_len int

How many bits are on offer. Ignored, for the reason above; it is in the signature because the binding's capacity call passes the input's length.

required

Returns:

Type Description
int

The frame's length in bits, or 0 for an empty description.

check

check(
    rx_bits: NDArray[uint8]
    | bytes
    | bytearray
    | memoryview,
) -> FrameCheck

Undo the description's stages over a received frame and report what was found -- the receive mirror of bits(), reading the same description, so a transmitter and a receiver holding the same Frame cannot disagree about which stage covered what. This is the truth-free frame error rate on a CODED link: it needs no payload truth, so it works on a real capture, and an outer code is a strictly better detector than a CRC because it reports how much repair it took rather than one bit of right-or-wrong. checked is smaller than stages when the description names a stage the receiver does not reverse here -- the inner code is the case, being undone before frame synchronisation -- and such a stage is reported as not checked, never as passed.

The receive mirror of dp_frame_bits, reading the same description — so a transmitter and a receiver holding the same Frame cannot disagree about which stage covered what.

This is the truth-free frame error rate on a coded link. It needs the description and the received bits and no payload truth at all, so it works on a real capture, and unlike a self-referenced EVM it still catches a false lock.

checked is smaller than stages when the description names a stage the receiver does not reverse here — the inner code is the case, since it is undone before frame synchronisation and a frame checker never sees channel symbols. Such a stage is reported as not checked, never as passed.

Parameters:

Name Type Description Default
rx_bits NDArray[uint8] | bytes | bytearray | memoryview

Received bits, one per byte. Copied, not modified.

required

Returns:

Type Description
FrameCheck

The outcome. passed is 0 and checked is 0 when the description carries no reversible stage at all — "carries no check" is not "the check passed", and an FER conflating them would score every unprotected frame as perfect.

Examples:

>>> import numpy as np
>>> from doppler.wfm import FrameDesc
>>> sync = np.array([1,1,1,1,1,0,0,1,1,0,1,0,1], np.uint8)
>>> payload = np.array([0,1,1,0,1,0,0,1,1,1,0,0,0,1,0,1], np.uint8)
>>> d = FrameDesc(sync=sync, payload=payload, crc="crc16")
>>> d.build()
>>> r = d.check(d.bits(1))
>>> r.passed, r.ok, r.units
(1, 1, 1)

Flip a bit the CRC covers and the verdict turns over:

>>> rx = np.asarray(d.bits(1)).copy()
>>> rx[d.field_off(d.field_index("payload"))] ^= 1
>>> d.check(rx).passed
0

Carrying no check is NOT passing one -- both are reported, separately:

>>> n = FrameDesc(sync=sync, payload=payload, crc="none")
>>> n.build()
>>> c = n.check(n.bits(1))
>>> c.passed, c.checked
(0, 0)

n_fields

n_fields() -> int

Fields in the description. A Frame counts only the fields it was given -- Frame(sync=..., payload=..., crc="crc16") is 3 -- so read a field by name with field_index, not by position.

Returns:

Type Description
int

How many fields the description carries.

Examples:

>>> import numpy as np
>>> from doppler.wfm import FrameDesc
>>> sync = np.array([1,1,1,1,1,0,0,1,1,0,1,0,1], np.uint8)
>>> payload = np.array([0,1,1,0,1,0,0,1,1,1,0,0,0,1,0,1], np.uint8)
>>> d = FrameDesc(sync=sync, payload=payload, crc="crc16")
>>> d.n_fields()          # sync, payload, crc -- no preamble was given
3

n_stages

n_stages() -> int

Stages in the description.

Returns:

Type Description
int

How many stages the description carries.

Examples:

>>> import numpy as np
>>> from doppler.wfm import FrameDesc
>>> sync = np.array([1,1,1,1,1,0,0,1,1,0,1,0,1], np.uint8)
>>> payload = np.array([0,1,1,0,1,0,0,1,1,1,0,0,0,1,0,1], np.uint8)
>>> d = FrameDesc(sync=sync, payload=payload, crc="crc16")
>>> d.build()
>>> d.n_stages()         # the CRC is a stage like any other
1

field_off

field_off(i: int) -> int

Bit offset of field i, or 0 if there is no such field.

Parameters:

Name Type Description Default
i int

Field index.

required

Returns:

Type Description
int

Bits from the start of the frame.

Examples:

>>> import numpy as np
>>> from doppler.wfm import FrameDesc
>>> sync = np.array([1,1,1,1,1,0,0,1,1,0,1,0,1], np.uint8)
>>> payload = np.array([0,1,1,0,1,0,0,1,1,1,0,0,0,1,0,1], np.uint8)
>>> d = FrameDesc(sync=sync, payload=payload, crc="crc16")
>>> d.build()
>>> d.field_off(0), d.field_off(1), d.field_off(2)
(0, 13, 29)

An absent field has no index: no preamble was given, so field 0 is the sync word. Ask for a field by name rather than by position, and an index past the end is 0.

>>> d.field_off(d.field_index("crc")), d.field_off(7)
(29, 0)

field_bits

field_bits(i: int) -> int

Bits in field i, or 0 if there is no such field.

Parameters:

Name Type Description Default
i int

Field index.

required

Returns:

Type Description
int

The field's length in bits.

Examples:

>>> import numpy as np
>>> from doppler.wfm import FrameDesc
>>> sync = np.array([1,1,1,1,1,0,0,1,1,0,1,0,1], np.uint8)
>>> payload = np.array([0,1,1,0,1,0,0,1,1,1,0,0,0,1,0,1], np.uint8)
>>> d = FrameDesc(sync=sync, payload=payload, crc="crc16")
>>> d.build()
>>> d.field_bits(0), d.field_bits(1), d.field_bits(2)
(13, 16, 16)

stage_first

stage_first(i: int) -> int

First frame bit stage i covers; 0 for a stage that did not run.

Parameters:

Name Type Description Default
i int

Stage index.

required

Returns:

Type Description
int

Bits from the start of the frame.

Examples:

>>> import numpy as np
>>> from doppler.wfm import FrameDesc
>>> sync = np.array([1,1,1,1,1,0,0,1,1,0,1,0,1], np.uint8)
>>> payload = np.array([0,1,1,0,1,0,0,1,1,1,0,0,0,1,0,1], np.uint8)
>>> d = FrameDesc(sync=sync, payload=payload, crc="crc16")
>>> d.build()
>>> d.stage_first(0)     # the CRC starts at the payload, not at bit 0
13

stage_bits

stage_bits(i: int) -> int

Bits stage i covers; 0 for a stage that did not run -- which is how an optional stage is spelled, and why first is 0 there too.

Parameters:

Name Type Description Default
i int

Stage index.

required

Returns:

Type Description
int

The covered span, in bits.

Examples:

>>> import numpy as np
>>> from doppler.wfm import FrameDesc
>>> sync = np.array([1,1,1,1,1,0,0,1,1,0,1,0,1], np.uint8)
>>> payload = np.array([0,1,1,0,1,0,0,1,1,1,0,0,0,1,0,1], np.uint8)
>>> d = FrameDesc(sync=sync, payload=payload, crc="crc16")
>>> d.build()
>>> d.stage_bits(0)      # payload+CRC: what crc16 covered
32

destroy

destroy() -> None

Close the object; the memory goes with its last user.

Ordinarily unnecessary: the resources are freed when the object is garbage-collected. Call this, or use the object as a context manager, to end its use at a definite point.

A consumer this object's _capsule was handed to may still hold its pointer, and keeps this object alive while it does: the memory is freed when the last of them, and this object, are gone, not here (gh-2346). Idempotent: calling it again does nothing. Every other method raises RuntimeError once it has run.

__enter__

__enter__() -> Frame

Enter a context manager, returning this object.

Lets a Frame be used in a with statement so its C resources are released deterministically on exit rather than at collection time.

Returns:

Type Description
Frame

This same object, not a copy.

__exit__

__exit__(
    exc_type: object | None = ...,
    exc: object | None = ...,
    tb: object | None = ...,
) -> None

Exit a context manager, releasing the Frame.

Equivalent to calling destroy(). Returns None, so an exception raised inside the with body propagates normally; this never suppresses one.

Parameters:

Name Type Description Default
exc_type object | None

Exception class, or None. Ignored.

...
exc object | None

Exception instance, or None. Ignored.

...
tb object | None

Traceback object, or None. Ignored.

...

FrameCheck

Bases: tuple[int, int, int, int, int, int, int]

What checking one received frame found. ok == units is the verdict; symbols is what it cost, which is margin being spent and is visible before it is lost.

Attributes:

Name Type Description
passed int

Every check good: 1 yes, 0 no. Also 0 when nothing was checked -- see checked. Named passed rather than pass because the obvious name is a Python keyword and r.pass will not parse.

stages int

Stages in the description.

checked int

How many were reversed here. 0 means the description carries no reversible stage, which is why pass is 0: carrying no check is not the same answer as passing one.

units int

Checks performed: one for a CRC, one per codeword for an interleaved outer code.

ok int

How many came out good -- clean or repaired.

corrected int

How many needed and received repair.

symbols int

Symbol errors repaired across the frame.

passed property

passed: int

Every check good: 1 yes, 0 no. Also 0 when nothing was checked -- see checked. Named passed rather than pass because the obvious name is a Python keyword and r.pass will not parse.

stages property

stages: int

Stages in the description.

checked property

checked: int

How many were reversed here. 0 means the description carries no reversible stage, which is why pass is 0: carrying no check is not the same answer as passing one.

units property

units: int

Checks performed: one for a CRC, one per codeword for an interleaved outer code.

ok property

ok: int

How many came out good -- clean or repaired.

corrected property

corrected: int

How many needed and received repair.

symbols property

symbols: int

Symbol errors repaired across the frame.

FrameDesc — the same frame, deferred

Frame names three fields and a CRC and materialises them in the constructor. FrameDesc takes the same arguments and stops before laying anything out, so they are a starting point a caller extends with add_field(), add_derived() and add_stage() before calling build(). Omit them all to begin from nothing. Every field is bits; text reaches it through field_bits.

That is what makes a frame doppler has never heard of describable from Python. The two axes it adds over the named layout are:

  • A field is anything on the wire, whether the caller supplies it or a stage produces it. A parity block nobody passes in is still a field — declare it with add_derived(name, bits); the stage whose cover ends on it fills it.
  • A stage declares the span it covers, as first_field / n_fields, rather than inheriting "everything before me". A stage covering no fields does not run. This is the part a pipeline representation cannot express, and it is exactly what a CCSDS CADU needs: the inner code covers the attached sync marker, while the outer code and the randomiser start behind it.

A CADU — attached sync marker, RS(255,223) parity, randomiser — described end to end, with no ccsds_tm binding involved:

import numpy as np

from doppler.ccsds import asm_bits
from doppler.wfm import STAGE_RANDOMISE, STAGE_RS, STAGE_USER, FrameDesc

K, E2 = 223, 32                      # RS(255,223): 223 data, 32 parity octets

asm = asm_bits()                     # 0x1ACFFC1D, never transcribed by hand
octets = np.array([(i * 29 + 5) & 0xFF for i in range(K)], np.uint8)
data = np.unpackbits(octets).astype(np.uint8)

d = FrameDesc()                                  # begin from nothing
assert d.add_field("asm", asm) == 0              # attached sync marker
assert d.add_field("data", data) == 1            # the transfer frame
assert d.add_derived("parity", E2 * 8) == 2      # the outer code fills it

# Both stages start at field 1, so both skip the marker -- declared, not
# inherited.
assert d.add_stage(STAGE_RS, first_field=1, n_fields=2, depth=1) == 0
assert d.add_stage(STAGE_RANDOMISE, first_field=1, n_fields=2) == 1
d.build()

assert d.nbits == 32 + 255 * 8                   # marker + one RS codeblock
assert (d.stage_first(0), d.stage_bits(0)) == (32, 2040)

r = d.check(d.bits(1))
assert (r.passed, r.ok, r.units) == (1, 2, 2)   # one check per stage reversed

check() is the receive mirror of bits(), reading the same description — so a transmitter and a receiver holding one FrameDesc cannot disagree about which stage covered what. On a coded link it reports more than a verdict: an outer code says how much repair a frame took, and that is margin being spent while it is still there to spend. A CRC cannot report it at all, which is why an outer code is a strictly better detector and not merely a stronger one:

rx = np.asarray(d.bits(1)).copy()
rx[100] ^= 1                            # two bit errors inside one RS symbol
rx[101] ^= 1

r = d.check(rx)
assert r.passed == 1                    # still good ...
assert (r.corrected, r.symbols) == (1, 1)   # ... and it cost one symbol

Read a built description with n_fields() / n_stages() and the indexed field_off(), field_bits(), stage_first(), stage_bits(). layout() is the named view and reports zeros for a description assembled this way; a Frame is one configuration of the same object, so the indexed accessors read it too.

Stage kinds

add_stage() takes the kind as a number, and doppler.wfm exports the names for it:

constant what the stage does
STAGE_CRC16 CRC-16-CCITT over the covered bits, into a derived field
STAGE_RS a Reed-Solomon code, interleaved to depth
STAGE_RANDOMISE XOR a pseudo-random sequence, in place
STAGE_CONV a convolutional code, expanding by emit_num / emit_den
STAGE_INTERLEAVE a block interleaver: permute in place, length unchanged
STAGE_USER the first kind reserved for callers

These are generated from wfm_stage_kind_t by scripts/gen_stage_kinds.py, so they cannot drift from the C the way five hand-written copies of CRC16, RS, RANDOMISE, CONV = 0, 1, 2, 3 had begun to (#1223).

The kind stays a number on purpose. It is an open uint32_t, not a menu: doppler promises never to allocate at or above STAGE_USER, so a kind you choose today cannot collide with a built-in added later. Take STAGE_USER + n, supply the kernel through the ops table, and the description carries it:

# A fresh description under its own name: this page is one namespace, and
# `d` above is the CADU the rest of it goes on to check.
mine = FrameDesc()
mine.add_field("bits", np.ones(8, np.uint8))
assert mine.add_stage(STAGE_USER + 1, first_field=0, n_fields=1) == 0

A named choice list would read better and would refuse exactly that call, which is why the nicer spelling was measured and rejected.

Supplying the kernel is a C contract, not a Python one — a per-stage Python callback would make the one surface that must stay fast the one that cannot be (#1125). So build() refuses a kind it has no kernel for, and a Python caller reaches the same end by running the transform itself and handing the wire the result: src/doppler/examples/frame_own_stage_demo.py does exactly that, end to end, and native/examples/wfmgen_frame_demo.c §6 does it the C way with an ops table. Both are self-validating.

FrameDesc

The same frame, DEFERRED — a description a caller can extend.

Parameters:

Name Type Description Default
preamble NDArray[uint8]

Preamble bits, one per element, each 0 or 1. Omitted, there is no preamble. A repeated preamble is repeated in its bits: field_bits("pn:31:5*4").

...
sync NDArray[uint8]

Sync-word bits, one per element, each 0 or 1. Omitted, the frame is unsynced.

...
payload NDArray[uint8]

Payload bits, one per element, each 0 or 1. Omitted, the frame carries none.

...
crc Literal['none', 'crc16']

crc16 covers the payload with a CRC-16; none adds no check. One of "none", "crc16".

"none"

Raises:

Type Description
ValueError

If construction fails. The exception message is frame geometry is empty, or a field holds an element that is not a bit (0 or 1).

Examples:

>>> import numpy as np
>>> from doppler.wfm import FrameDesc, STAGE_CRC16
>>> d = FrameDesc()                             # begin from nothing
>>> sync = np.array([1,1,1,1,1,0,0,1,1,0,1,0,1], np.uint8)  # Barker-13
>>> payload = np.array([0,1,1,0,1,0,0,1,1,1,0,0,0,1,0,1], np.uint8)
>>> d.add_field("sync", sync)                   # returns its index
0
>>> d.add_field("payload", payload)
1
>>> d.add_derived("crc", 16)                    # a stage will fill it
2
>>> d.add_stage_over(STAGE_CRC16, "payload", "crc")
0
>>> d.build()
>>> d.nbits                                     # 13 + 16 + 16
45
>>> d.crc_ok(d.bits())        # its own bits are its own truth
1

rx_ok property

rx_ok: int

Checks that came out good in the last deframe() -- one per CRC, one per outer-code codeword. rx_ok == rx_units is the verdict.

rx_units property

rx_units: int

Checks the last deframe() performed across every stage it reversed.

rx_checked property

rx_checked: int

Stages the last deframe() actually reversed. 0 means the description carries no reversible stage at all -- which is why rx_ok is 0 too, and is a different fact from a check that failed. An FER conflating them scores every unprotected frame as an error.

rx_symbols property

rx_symbols: int

Symbol errors the last deframe() repaired. Margin being spent, visible before it is lost -- what an outer code reports and a CRC cannot.

nbits property

nbits: int

Bits in one frame as transmitted: the assembled frame's length for dp_frame_create(), and the last emitting stage's output length (out_bits) after dp_frame_build(). 0 until a description is built, or if building fails.

bits

bits(
    count: int = 1, out: NDArray[uint8] | None = None
) -> NDArray[np.uint8]

Materialise n consecutive frames, one bit per byte.

n counts FRAMES, not bits: a descriptor describes one frame, and a capture holds many. It is the truth for a transmitter that sends the same frame n times -- a data source of n copies of the payload, one chunk a frame -- so a stream compared against this lines up with the one that was transmitted.

Parameters:

Name Type Description Default
count int

How many output samples to ask for. The call may return fewer; size an out= buffer with the matching _max_out() when you need the worst case.

1
out NDArray[uint8] | None

Output, one bit per byte.

None

Returns:

Type Description
NDArray[uint8]

Bits written.

Examples:

>>> import numpy as np
>>> from doppler.wfm import FrameDesc
>>> sync = np.array([1,1,1,1,1,0,0,1,1,0,1,0,1], np.uint8)
>>> payload = np.array([0,1,1,0,1,0,0,1,1,1,0,0,0,1,0,1], np.uint8)
>>> d = FrameDesc(sync=sync, payload=payload, crc="crc16")
>>> d.build()
>>> len(d.bits())        # one frame: 13 + 16 + 16
45
>>> len(d.bits(2))       # n counts FRAMES, tiled the way a capture is
90

bits_max_out

bits_max_out(n: int) -> int

Bits dp_frame_bits will write for n frames — n * nbits.

Parameters:

Name Type Description Default
n int

Frame repetitions.

required

Returns:

Type Description
int

n * nbits, the output capacity in bits (one per byte) that dp_frame_bits() needs; 0 for a NULL state or an unbuilt description.

crc_ok

crc_ok(
    rx_bits: NDArray[uint8]
    | bytes
    | bytearray
    | memoryview,
) -> int

Check one received frame's CRC.

This is what makes a truth-free frame error rate possible. It needs no payload truth at all, so it works on a real capture, and unlike a self-referenced EVM or a blind M2M4 it still catches a false lock — a rotated constellation fails the check rather than looking clean.

Parameters:

Name Type Description Default
rx_bits NDArray[uint8] | bytes | bytearray | memoryview

Received bits, one per byte.

required

Returns:

Type Description
int

1 pass, 0 fail, -1 if the frame carries no CRC or rx_bits is shorter than one frame.

Examples:

>>> import numpy as np
>>> from doppler.wfm import FrameDesc
>>> sync = np.array([1,1,1,1,1,0,0,1,1,0,1,0,1], np.uint8)
>>> payload = np.array([0,1,1,0,1,0,0,1,1,1,0,0,0,1,0,1], np.uint8)
>>> d = FrameDesc(sync=sync, payload=payload, crc="crc16")
>>> d.build()
>>> d.crc_ok(d.bits())           # its own bits are its own truth
1
>>> rx = np.asarray(d.bits()).copy()
>>> rx[d.field_off(d.field_index("payload"))] ^= 1   # one payload bit
>>> d.crc_ok(rx)
0

add_field

add_field(
    name: str,
    bits: NDArray[uint8] | bytes | bytearray | memoryview,
) -> int

Append one named field to a description (see FrameDesc): bits and nothing else, one per element, each 0 or 1. Text reaches it through field_bits(), hex and packed octets through cvt. A field a STAGE fills is add_derived instead, because the caller has no bits for it. name may be empty for an anonymous field; a name another field carries is refused. Returns the new field's index. Refuses once the frame is built.

The field is bits and nothing else, copied here so the description outlives the call. A field a STAGE fills is appended with dp_frame_add_derived instead, because the caller has no bits for it.

Parameters:

Name Type Description Default
name str

The field's name, or NULL/"" for anonymous; a name another field carries is refused.

required
bits NDArray[uint8] | bytes | bytearray | memoryview

The bits, one per element, each 0 or 1.

required

Returns:

Type Description
int

The new field's index, or -1 if the description is full or already built, the name is taken, or an element is not a bit.

Raises:

Type Description
ValueError

If the C call returns a negative value. The exception message is cannot append a field: the description is full or already built, the name is taken, the bits are empty, or an element is not a bit, with the return code appended (gh-869).

Examples:

>>> import numpy as np
>>> from doppler.wfm import FrameDesc
>>> from doppler.ccsds import asm_bits
>>> octets = np.array([(i * 29 + 5) & 0xFF for i in range(223)],
...                   np.uint8)
>>> d = FrameDesc()                      # begin from nothing
>>> d.add_field("asm", asm_bits())       # the attached sync marker
0
>>> d.add_field("data", np.unpackbits(octets))   # the transfer frame
1
>>> d.field_index("data")
1

add_stage

add_stage(
    kind: int = 0,
    first_field: int = 0,
    n_fields: int = 0,
    depth: int = 0,
    emit_num: int = 0,
    emit_den: int = 0,
    unit_bits: int = 0,
) -> int

Append one transform and -- the load-bearing part -- the span of fields it covers. kind is a stage kind: STAGE_CRC16, STAGE_RS, STAGE_RANDOMISE, STAGE_CONV, STAGE_INTERLEAVE from doppler.wfm, or a caller's own from STAGE_USER up. It stays an INT rather than a name because the kind is an open uint32_t a caller extends -- the constants are generated from the C enum, so there is nothing to transcribe. n_fields = 0 means the stage does not run. A stage that inherited whatever ran before it is the representation that cannot express a CCSDS CADU, where the marker is covered by the inner code and by neither the outer code nor the randomiser. unit_bits applies to interleave alone and is the bits per permuted unit (0 reads as 1); its ROW count is depth and its column count is derived from the span the stage covers.

n_fields is the load-bearing part and 0 means the stage does not run. A stage that inherited "everything before me" instead of declaring its cover is the representation that cannot express a CCSDS CADU — see wfm/wfm_frame.h.

Parameters:

Name Type Description Default
kind int

stage kind: a wfm_stage_kind_t value (0=crc16…4=interleave), or a caller's own from WFM_STAGE_USER (0x1000) up, whose kernel then has to reach the assembler through its ops table.

0
first_field int

First field covered.

0
n_fields int

Fields covered; 0 = the stage does not run.

0
depth int

Interleaving depth, for an outer code.

0
emit_num int

Expansion numerator for a stage that emits a NEW stream; 0 when the stage stays inside the frame.

0
emit_den int

Expansion denominator.

0
unit_bits int

INTERLEAVE only: bits per interleaved unit; 0 reads as 1. Match it to the outer code's symbol — permuting octets is what spreads a burst across the codewords of a code over GF(256), and permuting bits inside one spreads a burst within a symbol that is already wrong.

0

Returns:

Type Description
int

The new stage's index, or -1 if the description is full or already built. The Python binding raises ValueError rather than handing back the -1.

Raises:

Type Description
ValueError

If the C call returns a negative value. The exception message is cannot append a stage: the description is full or already built, with the return code appended (gh-869).

Examples:

>>> import numpy as np
>>> from doppler.wfm import FrameDesc
>>> from doppler.ccsds import asm_bits
>>> octets = np.array([(i * 29 + 5) & 0xFF for i in range(223)],
...                   np.uint8)
>>> d = FrameDesc()
>>> _ = d.add_field("asm", asm_bits())
>>> _ = d.add_field("data", np.unpackbits(octets))
>>> _ = d.add_derived("parity", 32 * 8)   # the outer code fills it
>>> d.add_stage(1, first_field=1, n_fields=2, depth=1)   # RS(255,223)
0
>>> d.add_stage(2, first_field=1, n_fields=2)            # randomiser
1

Both start at field 1, so both skip the marker -- the cover is DECLARED, which is the whole reason a CADU is describable here:

>>> d.build()
>>> d.stage_first(0), d.stage_bits(0)
(32, 2040)

field_index

field_index(name: str) -> int

Index of the field called name, or -1 -- the one verb whose sentinel survives into Python, because a name that matches nothing is an ANSWER rather than a refusal. The one lookup that resolves a name, so every index-taking method keeps working and a rename can only be wrong once. An unnamed field is ANONYMOUS rather than named "", so the empty name matches nothing.

The one lookup that resolves a name, so every index-taking entry point keeps working unchanged and a rename can only be wrong once. An unnamed field is ANONYMOUS rather than named "", so the empty name matches nothing — including a field that has no name.

Parameters:

Name Type Description Default
name str

the field name.

required

Returns:

Type Description
int

the index, or -1 on NULL or a name no field carries. This is the one verb whose -1 survives into Python: a name that matches nothing is an ANSWER, not a refusal, so there is nothing to raise about.

Examples:

>>> import numpy as np
>>> from doppler.wfm import FrameDesc
>>> d = FrameDesc()
>>> d.add_field("sync", np.array([1,0,1,0,1,0,1,1,1,1,0,0], np.uint8))
0
>>> d.field_index("sync")
0
>>> d.field_index("absent")
-1

name_field

name_field(index: int, name: str) -> None

Give an already-appended field a name, or clear it with "". Refuses a name another field already carries, because field_index would then answer with whichever it reached first. Refuses once the frame is built.

Parameters:

Name Type Description Default
index int

the field to name.

required
name str

the new name; truncated at WFM_FRAME_NAME_MAX - 1.

required

Raises:

Type Description
ValueError

If the C call returns a non-zero status. The exception message is cannot name a field: the index is out of range, another field already carries the name, or the frame is already built, with the return code appended (gh-869).

Examples:

>>> import numpy as np
>>> from doppler.wfm import FrameDesc
>>> d = FrameDesc()
>>> d.add_field("", np.array([1, 0, 1, 0], np.uint8))   # anonymous
0
>>> d.name_field(0, "payload")
>>> d.field_index("payload")
0

add_derived

add_derived(name: str, bits: int) -> int

Append a named field a STAGE will fill -- a CRC trailer, a block of check symbols. Its producer is not named here because no stage exists yet when the field it derives is appended; add_stage_over wires it. Returns the new field's index; a refusal raises ValueError.

A field with a declared length and no source: a CRC trailer, a block of check symbols. Its producer is wired by dp_frame_add_stage_over rather than named here, because no stage exists yet when the field it derives is appended — fields are ordered by POSITION and stages by APPLICATION.

Parameters:

Name Type Description Default
name str

the field's name, or NULL for anonymous.

required
bits int

its length, which its stage decides and the caller states.

required

Returns:

Type Description
int

The new field's index, or -1 on NULL state, a built frame, a zero bits, a full description (WFM_FRAME_MAX_FIELDS) or a name another field already carries.

Raises:

Type Description
ValueError

If the C call returns a negative value. The exception message is cannot append a derived field: the description is full or already built, with the return code appended (gh-869).

Examples:

>>> import numpy as np
>>> from doppler.wfm import FrameDesc
>>> d = FrameDesc()
>>> d.add_field("payload", np.array([1, 0, 1, 0], np.uint8))
0
>>> d.add_derived("crc", 16)          # a stage will fill it
1

add_data

add_data(name: str, len: int) -> int

Append a named DATA field: len bits a data source fills, one chunk per frame. Returns its index; -1 in C, ValueError from Python.

The object spelling of the Field text data:LEN, and the same field: a WFM_SEQ_DATA sequence of length len, which is exactly what dp_wfm_field_parse("data:LEN") produces for a scene's or the CLI's frame. The description knows the field's length and never its bits: a transmitter draws them from its data source at each frame (a source's data=), and a CRC or outer code covering the field covers that frame's chunk. It is a method rather than Field text in dp_frame_add_field because an object takes bits, and a data field has none (rx-frame-description.md, D5).

A frame draws from one data source, so a description carries at most one data field; a second is refused where geometry is judged, by the layout, as it is for every other face.

Parameters:

Name Type Description Default
name str

The field's name, or NULL/"" for anonymous; a name another field carries is refused.

required
len int

Bits per frame, LEN in data:LEN: from 1 to WFM_FIELD_MAX_BITS, the bound the Field grammar puts on the text form.

required

Returns:

Type Description
int

The new field's index, or -1 if len is out of range, the description is full or already built, or the name is taken.

Raises:

Type Description
ValueError

If the C call returns a negative value. The exception message is cannot append a data field: len is 0 or past the Field grammar's bound (WFM_FIELD_MAX_BITS), or the description is full, already built, or already carries the name, with the return code appended (gh-869).

Examples:

>>> import numpy as np
>>> from doppler.wfm import FrameDesc, STAGE_CRC16, Segment, Composer
>>> d = FrameDesc()
>>> d.add_field("sync", np.array([1, 1, 1, 0, 0, 1, 0], np.uint8))
0
>>> d.add_data("payload", 8)          # 8 bits of the data source a frame
1
>>> d.add_derived("crc", 16)
2
>>> d.add_stage_over(STAGE_CRC16, "payload", "crc")
0
>>> seg = Segment(type="bits", sps=1, modulation="bpsk", frame=d,
...               data=np.unpackbits(np.array([0xA5, 0x3C], np.uint8)))
>>> len(Composer([seg]).compose())    # two frames of 7 + 8 + 16 bits
62

add_stage_over

add_stage_over(
    kind: int,
    first: str,
    last: str,
    depth: int = 0,
    unit_bits: int = 0,
) -> int

Append a stage covering [first .. last] BY NAME -- add_stage_over(STAGE_CRC16, "payload", "crc") says what three integers used to. It wires a derived field's producer for you, which applies the invariant the layout already enforces: a field with a declared length and no source sitting at the end of a cover has exactly one possible producer. kind is a stage kind, as for add_stage. Returns the new stage's index; a refusal raises ValueError.

The cover is the load-bearing part of the representation and this is the form that reads. It wires a derived field's producer for you, which applies the invariant the layout already enforces rather than adding one.

Parameters:

Name Type Description Default
kind int

a stage kind — doppler.wfm.STAGE_CRC16 and its siblings, or a caller's own from STAGE_USER up.

required
first str

name of the first field covered.

required
last str

name of the last field covered; may equal first.

required
depth int

RS / interleave depth; 0 when unused.

0
unit_bits int

interleave unit; 0 reads as 1.

0

Returns:

Type Description
int

the new stage's index, or -1 on NULL, a full description, a name neither field carries, last before first, or once built. The Python binding raises ValueError rather than handing back the -1.

Raises:

Type Description
ValueError

If the C call returns a negative value. The exception message is cannot append a stage: the description is full, already built, or names a field the description does not carry (and `last` must not precede `first`), with the return code appended (gh-869).

Examples:

>>> import numpy as np
>>> from doppler.wfm import FrameDesc
>>> d = FrameDesc()
>>> d.add_field("payload", np.array([0, 1, 1, 0, 1, 0, 0, 1], np.uint8))
0
>>> d.add_derived("crc", 16)
1
>>> d.add_stage_over(0, "payload", "crc")   # 0 = crc16
0
>>> d.build()
>>> d.crc_ok(d.bits())                # its own bits are its own truth
1

build

build() -> None

Lay out and materialise a description. Where a description is checked: one that cannot produce its own bits is not a frame. Separate from the constructor only because the description arrives over several calls and there is no earlier moment at which it is complete. A description with a data field builds too, laid out from its lengths with its stages proved runnable over a discarded chunk: it has no single frame, so bits() returns none, while deframe() and check() work. Raises if it is empty, unbuildable, names a stage no kernel here covers, or was already built.

The point at which a description is checked, which for dp_frame_create happens inside the constructor: a description that cannot produce its own bits is not a frame. It is separate here only because the description arrives over several calls and there is no earlier moment at which it is complete.

The CRC, the outer code, the randomiser and the inner code are all runnable: ccsds_tm has no Python binding and is not getting one, so this object is where a caller meets them. A stage naming a kernel nothing here carries is refused rather than skipped, because a stage that quietly did not run produces a frame that still assembles and syncs to nothing.

A description with a data field (dp_frame_add_data) builds too: the field has no bits until a source draws them, so the description is laid out from its lengths and its stages are proved runnable over a chunk that is thrown away. It then has no single frame, so dp_frame_bits writes none, while dp_frame_deframe and dp_frame_check work as for any other -- a receiver needs the data field's length and nothing else.

The inner encoder starts from the all-zero register on every build: a description describes ONE frame. A stream of CADUs sharing one register is a transmitter's job and lives in dp_ccsds_tm_frame_encode.

Raises:

Type Description
ValueError

If the C call returns a non-zero status. The exception message is cannot build: the description is empty, unbuildable, names a stage no kernel here covers, or was already built, with the return code appended (gh-869).

Examples:

>>> import numpy as np
>>> from doppler.wfm import FrameDesc
>>> sync = np.array([1,1,1,1,1,0,0,1,1,0,1,0,1], np.uint8)
>>> payload = np.array([0,1,1,0,1,0,0,1,1,1,0,0,0,1,0,1], np.uint8)
>>> d = FrameDesc(sync=sync, payload=payload, crc="crc16")
>>> d.build()
>>> d.nbits                     # 13 + 16 + 16, laid out by build()
45

A description that cannot produce bits is not a frame, and is refused rather than half-built:

>>> FrameDesc().build()
Traceback (most recent call last):
    ...
ValueError: cannot build: the description is empty, unbuildable, ...

deframe

deframe(
    rx_bits: NDArray[uint8]
    | bytes
    | bytearray
    | memoryview,
    out: NDArray[uint8] | None = None,
) -> NDArray[np.uint8]

Undo the description's stages over a received frame and hand back the CORRECTED bits — the layer a receiver stops short of (doppler#1022).

The receive counterpart of building one, and the layer a receiver stops short of: DsssBurstReceiver and friends hand back hard and soft decisions for a frame's symbols and make no claim about what they mean, because knowing that needs a description — this one (doppler#1022).

Returns the frame with every reversible stage undone, in place order: a randomiser XORed back, an outer code's repairs APPLIED, a CRC checked. The payload is then a slice, at dp_frame_field_off of the payload field — which is the caller's arithmetic because a description does not privilege one field over another.

The verdict comes back as read-backs (ok, units, checked, symbols), not as a return value, since the return is the bits. Read them exactly as frame_check_t's, including the distinction that matters most: checked == 0 says the description carries no reversible stage at all, which is a different fact from a check that failed.

A stage with no undo kernel — a convolutional inner code, which a receiver cannot even frame-sync through — is reported as not checked rather than as passed.

Parameters:

Name Type Description Default
rx_bits NDArray[uint8] | bytes | bytearray | memoryview

Received bits, frame_bits of them; treated as a capture and never modified.

required
out NDArray[uint8] | None

Receives the corrected frame.

None

Returns:

Type Description
NDArray[uint8]

Bits written — the frame's length — or 0 if the description is empty or either buffer is too small.

Examples:

>>> import numpy as np
>>> from doppler.wfm import Frame
>>> sync = np.array([1,1,1,1,1,0,0,1,1,0,1,0,1], dtype=np.uint8)
>>> payload = np.array([0,1,1,0,1,0,0,1,1,1,0,0,0,1,0,1], dtype=np.uint8)
>>> f = Frame(sync=sync, payload=payload, crc="crc16")
>>> rx = np.asarray(f.bits())          # a clean capture of its own frame
>>> got = np.asarray(f.deframe(rx))
>>> f.rx_ok, f.rx_units, f.rx_checked  # one CRC, and it passed
(1, 1, 1)
>>> off = f.field_off(f.field_index("payload"))   # a SLICE
>>> bool(np.array_equal(got[off:off + 16], payload))
True
>>> rx[off] ^= 1                       # one bit flipped in flight
>>> _ = f.deframe(rx)
>>> f.rx_ok, f.rx_units                # the check notices
(0, 1)

deframe_max_out

deframe_max_out(rx_bits_len: int) -> int

Max bits dp_frame_deframe() writes: the frame's own length.

Size a deframe() buffer with this. The bound is the DESCRIPTION's, not the input's: a frame is as long as its fields say, so how many bits were received does not change how many come back.

Parameters:

Name Type Description Default
rx_bits_len int

How many bits are on offer. Ignored, for the reason above; it is in the signature because the binding's capacity call passes the input's length.

required

Returns:

Type Description
int

The frame's length in bits, or 0 for an empty description.

check

check(
    rx_bits: NDArray[uint8]
    | bytes
    | bytearray
    | memoryview,
) -> FrameCheck

Undo the description's stages over a received frame and report what was found -- the receive mirror of bits(), reading the same description, so a transmitter and a receiver holding the same Frame cannot disagree about which stage covered what. This is the truth-free frame error rate on a CODED link: it needs no payload truth, so it works on a real capture, and an outer code is a strictly better detector than a CRC because it reports how much repair it took rather than one bit of right-or-wrong. checked is smaller than stages when the description names a stage the receiver does not reverse here -- the inner code is the case, being undone before frame synchronisation -- and such a stage is reported as not checked, never as passed.

The receive mirror of dp_frame_bits, reading the same description — so a transmitter and a receiver holding the same Frame cannot disagree about which stage covered what.

This is the truth-free frame error rate on a coded link. It needs the description and the received bits and no payload truth at all, so it works on a real capture, and unlike a self-referenced EVM it still catches a false lock.

checked is smaller than stages when the description names a stage the receiver does not reverse here — the inner code is the case, since it is undone before frame synchronisation and a frame checker never sees channel symbols. Such a stage is reported as not checked, never as passed.

Parameters:

Name Type Description Default
rx_bits NDArray[uint8] | bytes | bytearray | memoryview

Received bits, one per byte. Copied, not modified.

required

Returns:

Type Description
FrameCheck

The outcome. passed is 0 and checked is 0 when the description carries no reversible stage at all — "carries no check" is not "the check passed", and an FER conflating them would score every unprotected frame as perfect.

Examples:

>>> import numpy as np
>>> from doppler.wfm import FrameDesc
>>> sync = np.array([1,1,1,1,1,0,0,1,1,0,1,0,1], np.uint8)
>>> payload = np.array([0,1,1,0,1,0,0,1,1,1,0,0,0,1,0,1], np.uint8)
>>> d = FrameDesc(sync=sync, payload=payload, crc="crc16")
>>> d.build()
>>> r = d.check(d.bits(1))
>>> r.passed, r.ok, r.units
(1, 1, 1)

Flip a bit the CRC covers and the verdict turns over:

>>> rx = np.asarray(d.bits(1)).copy()
>>> rx[d.field_off(d.field_index("payload"))] ^= 1
>>> d.check(rx).passed
0

Carrying no check is NOT passing one -- both are reported, separately:

>>> n = FrameDesc(sync=sync, payload=payload, crc="none")
>>> n.build()
>>> c = n.check(n.bits(1))
>>> c.passed, c.checked
(0, 0)

n_fields

n_fields() -> int

Fields in the description. A Frame counts only the fields it was given -- Frame(sync=..., payload=..., crc="crc16") is 3 -- so read a field by name with field_index, not by position.

Returns:

Type Description
int

How many fields the description carries.

Examples:

>>> import numpy as np
>>> from doppler.wfm import FrameDesc
>>> sync = np.array([1,1,1,1,1,0,0,1,1,0,1,0,1], np.uint8)
>>> payload = np.array([0,1,1,0,1,0,0,1,1,1,0,0,0,1,0,1], np.uint8)
>>> d = FrameDesc(sync=sync, payload=payload, crc="crc16")
>>> d.n_fields()          # sync, payload, crc -- no preamble was given
3

n_stages

n_stages() -> int

Stages in the description.

Returns:

Type Description
int

How many stages the description carries.

Examples:

>>> import numpy as np
>>> from doppler.wfm import FrameDesc
>>> sync = np.array([1,1,1,1,1,0,0,1,1,0,1,0,1], np.uint8)
>>> payload = np.array([0,1,1,0,1,0,0,1,1,1,0,0,0,1,0,1], np.uint8)
>>> d = FrameDesc(sync=sync, payload=payload, crc="crc16")
>>> d.build()
>>> d.n_stages()         # the CRC is a stage like any other
1

field_off

field_off(i: int) -> int

Bit offset of field i, or 0 if there is no such field.

Parameters:

Name Type Description Default
i int

Field index.

required

Returns:

Type Description
int

Bits from the start of the frame.

Examples:

>>> import numpy as np
>>> from doppler.wfm import FrameDesc
>>> sync = np.array([1,1,1,1,1,0,0,1,1,0,1,0,1], np.uint8)
>>> payload = np.array([0,1,1,0,1,0,0,1,1,1,0,0,0,1,0,1], np.uint8)
>>> d = FrameDesc(sync=sync, payload=payload, crc="crc16")
>>> d.build()
>>> d.field_off(0), d.field_off(1), d.field_off(2)
(0, 13, 29)

An absent field has no index: no preamble was given, so field 0 is the sync word. Ask for a field by name rather than by position, and an index past the end is 0.

>>> d.field_off(d.field_index("crc")), d.field_off(7)
(29, 0)

field_bits

field_bits(i: int) -> int

Bits in field i, or 0 if there is no such field.

Parameters:

Name Type Description Default
i int

Field index.

required

Returns:

Type Description
int

The field's length in bits.

Examples:

>>> import numpy as np
>>> from doppler.wfm import FrameDesc
>>> sync = np.array([1,1,1,1,1,0,0,1,1,0,1,0,1], np.uint8)
>>> payload = np.array([0,1,1,0,1,0,0,1,1,1,0,0,0,1,0,1], np.uint8)
>>> d = FrameDesc(sync=sync, payload=payload, crc="crc16")
>>> d.build()
>>> d.field_bits(0), d.field_bits(1), d.field_bits(2)
(13, 16, 16)

stage_first

stage_first(i: int) -> int

First frame bit stage i covers; 0 for a stage that did not run.

Parameters:

Name Type Description Default
i int

Stage index.

required

Returns:

Type Description
int

Bits from the start of the frame.

Examples:

>>> import numpy as np
>>> from doppler.wfm import FrameDesc
>>> sync = np.array([1,1,1,1,1,0,0,1,1,0,1,0,1], np.uint8)
>>> payload = np.array([0,1,1,0,1,0,0,1,1,1,0,0,0,1,0,1], np.uint8)
>>> d = FrameDesc(sync=sync, payload=payload, crc="crc16")
>>> d.build()
>>> d.stage_first(0)     # the CRC starts at the payload, not at bit 0
13

stage_bits

stage_bits(i: int) -> int

Bits stage i covers; 0 for a stage that did not run -- which is how an optional stage is spelled, and why first is 0 there too.

Parameters:

Name Type Description Default
i int

Stage index.

required

Returns:

Type Description
int

The covered span, in bits.

Examples:

>>> import numpy as np
>>> from doppler.wfm import FrameDesc
>>> sync = np.array([1,1,1,1,1,0,0,1,1,0,1,0,1], np.uint8)
>>> payload = np.array([0,1,1,0,1,0,0,1,1,1,0,0,0,1,0,1], np.uint8)
>>> d = FrameDesc(sync=sync, payload=payload, crc="crc16")
>>> d.build()
>>> d.stage_bits(0)      # payload+CRC: what crc16 covered
32

destroy

destroy() -> None

Close the object; the memory goes with its last user.

Ordinarily unnecessary: the resources are freed when the object is garbage-collected. Call this, or use the object as a context manager, to end its use at a definite point.

A consumer this object's _capsule was handed to may still hold its pointer, and keeps this object alive while it does: the memory is freed when the last of them, and this object, are gone, not here (gh-2346). Idempotent: calling it again does nothing. Every other method raises RuntimeError once it has run.

__enter__

__enter__() -> FrameDesc

Enter a context manager, returning this object.

Lets a FrameDesc be used in a with statement so its C resources are released deterministically on exit rather than at collection time.

Returns:

Type Description
FrameDesc

This same object, not a copy.

__exit__

__exit__(
    exc_type: object | None = ...,
    exc: object | None = ...,
    tb: object | None = ...,
) -> None

Exit a context manager, releasing the FrameDesc.

Equivalent to calling destroy(). Returns None, so an exception raised inside the with body propagates normally; this never suppresses one.

Parameters:

Name Type Description Default
exc_type object | None

Exception class, or None. Ignored.

...
exc object | None

Exception instance, or None. Ignored.

...
tb object | None

Traceback object, or None. Ignored.

...

Acquiring one — where a received frame starts

check() scores a frame you were handed. Finding one in a bit stream is the other half, and it lives in doppler.detection: SyncFinder correlates a known marker against every offset, in both polarities. asm_bits() is what gives it the CCSDS marker without anyone transcribing 0x1ACFFC1D a second time — an MSB-first expansion written out twice is one that can disagree with itself, and a receiver that disagrees with the assembler about the marker syncs to nothing.

from doppler.detection import SyncFinder

rng = np.random.default_rng(7)
lead = rng.integers(0, 2, 96).astype(np.uint8)      # stream before the frame
stream = np.concatenate([lead, np.asarray(d.bits(1))])
stream = (stream ^ 1).astype(np.uint8)             # ...and a 180-degree flip

f = SyncFinder(asm_bits())
hit = f.find(stream, max_errors=f.max_errors_for(96, pfa=1e-3))

assert (hit.found, hit.offset, hit.inverted) == (1, 96, 1)

rx = stream[hit.offset : hit.offset + d.nbits] ^ 1  # undo what it reported
assert d.check(rx).passed == 1

The polarity flag is not a convenience. A complemented CADU passes its own outer code: Reed-Solomon is linear and the all-ones vector is itself a full-length codeword, so a global flip lands on another codeword and the decoder has nothing to object to. A receiver that acquired at the right offset and ignored inverted would score a clean PASS on a frame whose every payload bit is wrong. The marker is what can see it, because no randomiser covers it.

The marker itself is documented with the rest of the standard's literals: Python CCSDS API.

Gallery — Streaming Async Despreader, AsyncDsssPool: emitters that come and go, AsyncDsssPool: ten emitters through six receivers, AsyncDsssReceiver: a signal's lifecycle, AsyncDsssReceiver: the specification link, CarrierAcquisition: RRC Pulse Shaping, A CCSDS CADU, as a Frame Description, Name Your Own Code — and What Happens Past the Radius, A Crowded Band — Many Signals, One Parallel prepare, A Message Over DSSS, Received and Deframed, DSSS Acquisition — Pd / Pfa vs Es/N0, DsssBurstReceiver — the Composed Burst Chain, A Framed Link, Checked by Its Description, Gallery, One Cache Slot for a Whole Background Field, type="symbols" — Bring Your Own Constellation, Composing a Scene — .sum(), .add(), and Headroom, Waveform I/O — One Capture, Four File Types, wfmgen — One Engine, Every Waveform Guides — Real-Time Pacing & Timestamping, Reading captures, Writing captures — output & file types, Fields — bits, written as text, Waveform Generator — wfmgen, Scenes — composing in time, Waveforms — what you can generate Design — API taxonomy: the DSP building-block hierarchy and its naming axis, AsyncDsssReceiver and AsyncDsssPool — the measurement record, BurstBank — the coarse-Doppler bank as one C object, The Doppler channel — the measurement record, The Doppler channel — one parameter, both clocks and the carrier, DsssBurstReceiver: the burst chain, composed in C, A Frame as a Description — the measurement record, A Frame as a Description, Design, MPSK Receiver, The Payload as a Data Source — the measurement record, The Payload as a Data Source, A Receiver Built from a Frame Description, Receiver Test Harness, Telemetry — zero-cost scalar taps for running pipelines, One home for the waveform enum tables, Waveform amplitude & composition, wfmgen — the waveform generator Contributing — Adding an algorithm — the lifecycle, Measuring a receiver, Validation log