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).
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()
Bits (user-defined pattern)¶
A bits waveform plays back a specific bit sequence — preambles, sync
words, test vectors, exact packet structures. The pattern is a binary string
("10110101"), a hex string ("0xAA55", MSB first), or any array-like of 0/1;
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 pattern cycles to fill the requested length, so one pass is
Synth.n_samples.
from doppler.wfm import Synth, bits
# 8-bit preamble, BPSK, 4 samples/bit → 32 samples for one pass
s = bits(pattern="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", pattern="0xAA55", modulation="none", sps=8)
# From a numpy array
import numpy as np
payload = bits(
pattern=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. The phase is continuous, so multi-segment chirps
join seamlessly — pulse-compression, SAR, sonar, and frequency-response test
signals all fall out of this one type. 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)
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, so f_end is reached at its
last sample.
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
¶
Synth.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
type
|
str
|
Waveform type.
One of |
``"tone"``
|
freq
|
float | tuple[float, float]
|
Carrier/offset frequency in Hz (normalised cycles/sample when fs=1); for chirp it is the start frequency. |
0.0
|
snr
|
float | tuple[float, float]
|
Signal-to-noise ratio in dB, interpreted per snr_mode; >=100 is treated as clean (no AWGN). |
100.0
|
snr_mode
|
str
|
How snr is interpreted: auto picks fs for tone/pn/chirp/bits and Es/No
for bpsk/qpsk.
One of |
``"auto"``
|
seed
|
int
|
PRNG/LFSR seed for the noise and PN streams. |
0
|
sps
|
int
|
Samples per symbol (PSK) or per chip (PN); the oversampling factor. |
1
|
pn_length
|
int
|
PN LFSR register length; the sequence period is 2^pn_length - 1. |
15
|
pn_poly
|
int
|
PN generator polynomial; 0 auto-selects a maximal-length (MLS) polynomial for pn_length. |
0
|
lfsr
|
str
|
PN LFSR realization (galois or fibonacci); same period, different chip
order.
One of |
``"galois"``
|
level
|
float | tuple[float, float]
|
Source power in dBFS (<=0; 0 = unit power). Applies only when summed in a Segment/Composer (gain 10^(level/20)); ignored by standalone Synth.steps(). |
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 non-chirp 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 |
0.0
|
doppler_rate
|
float | tuple[float, float]
|
Linear ramp on |
0.0
|
carrier_hz
|
float
|
RF carrier in Hz that the ppm figures are referred to, giving the coherent carrier rotation that accompanies the time-base warp. 0 (the default) 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 (default):
the channel dies with each |
``"per_instance"``
|
bits
|
bytes | None
|
For type=bits: the 0/1 pattern, oversampled by sps and cycled to fill
the request. For type=dsss (as |
None
|
modulation
|
str
|
For type=bits: symbol mapping of the pattern (none=0/1 amplitude, bpsk,
qpsk).
One of |
``"bpsk"``
|
pulse
|
str
|
Pulse shape for the symbol stream (pn/bpsk/qpsk/bits): rect
sample-and-hold or rrc matched filter.
One of |
``"rect"``
|
rrc_beta
|
float
|
RRC roll-off factor in (0, 1] when pulse=rrc. |
0.35
|
rrc_span
|
int
|
RRC filter span in symbols when pulse=rrc (taps = 2spansps + 1). |
8
|
symbols
|
NDArray[complex64] | None
|
For type=symbols: a complex64 constellation stream — each element is the output point itself, oversampled by sps, cycled, and RRC-shaped with pulse=rrc. Generalises any modulation (pi/4-QPSK, QAM, ...). |
None
|
acq_code
|
bytes | None
|
The acquisition/preamble code (0/1), repeated acq_reps times at the head of the frame — the coherent pull-in target BurstDespreader.set_acq/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 (or sync) is what makes a source FRAMED. |
None
|
acq_reps
|
int
|
Preamble repetitions (periods of acq_code before the sync word). |
1
|
data_code
|
bytes | None
|
For type=dsss: the payload spreading code (0/1 chips) — 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
|
The frame-sync word bits (e.g. Barker-13) between the preamble and the payload — what BurstDemod.set_frame correlates to resolve frame position and BPSK polarity, and what a BER alignment detects against. Optional; setting it (or acq_code) is what makes a source FRAMED. |
None
|
crc
|
str
|
The frame trailer — crc16 appends a CRC-16-CCITT over the payload bits
(what BurstDemod validates as frame_valid, and what makes a truth-free
frame error rate possible); none omits it. Applies only to a FRAMED
source: it defaults to crc16, so it alone never frames an otherwise
plain pattern.
One of |
``"crc16"``
|
rs_depth
|
int
|
Reed-Solomon (255,223) E=16 over the data group, interleaved this many
codewords deep; 0 = no outer code. CCSDS 131.0-B-3 4.3.5.1 allows 1, 2,
3, 4, 5 or 8, and the payload plus its CRC must be exactly 223*depth
octets — virtual fill is not implemented, so any other length is
REFUSED rather than padded. The wfmgen scene and CLI spell it
|
0
|
randomise
|
int
|
XOR a CCSDS section-10 pseudo-randomiser over the data group — the
payload, its CRC and the outer code's parity, but never a marker or a
preamble, which have to read the same in every frame to be findable. 0
= off, 1 = 131.0-B-6 10.4.1's 131071-bit sequence (the |
0
|
attach_asm
|
int
|
Prepend the CCSDS Attached Sync Marker (0x1ACFFC1D) as the frame's
first field — what a receiver correlates to find a frame in a bit
stream. Not covered by the randomiser, and covered by the inner code,
which is the coverage rule the description carries. The wfmgen scene
and CLI spell it |
0
|
convolutional
|
int
|
Inner code: CCSDS K=7 rate-1/2 convolutional, over the WHOLE frame
including the marker, doubling its bit count. For a |
0
|
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
|
fs
|
float
|
Sample rate in Hz — one per segment (all sources share it). |
1.0
|
steps
¶
Generate the next n samples of this source on its own.
The first call builds the generator from this configuration, through
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 |
RuntimeError
|
If |
step
¶
Generate the next sample of this source on its own.
The first call builds the generator from this configuration, through
wfm_source_to_synth; later calls continue it.
Returns:
| Type | Description |
|---|---|
complex
|
The sample. |
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If |
reset
¶
Rewind the generator to sample 0.
A no-op before the first steps()/step(), which starts from sample 0
anyway.
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.
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. Default 96 (0x60) is primitive for length=7, giving period 127. The Fibonacci taps are derived automatically so you only supply one value. |
0
|
seed
|
int
|
Initial LFSR register state; must be non-zero (the all-zero state is a fixed point). Default 1. |
0
|
length
|
int
|
Register width in bits, 1..64. The sequence period is 2^length - 1 for a primitive polynomial. Default 7. |
0
|
lfsr
|
Literal['galois', 'fibonacci']
|
Realization: PN_GALOIS (0, default) or PN_FIBONACCI (1). |
"galois"
|
Examples:
>>> from doppler.wfm import PN
>>> import numpy as np
>>> 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
reset
¶
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:
generate
¶
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 |
1
|
out
|
NDArray[uint8] | None
|
Output buffer of at least |
None
|
Returns:
| Type | Description |
|---|---|
NDArray[uint8]
|
min(n, max_out) chips. |
Examples:
generate_max_out
¶
Largest number of samples generate() can return in the current state.
Size an out= buffer with this before calling generate(), or use it to
allocate one up front. The bound is this object's own: what it depends
on is a property of the algorithm, so a header block on
generate_max_out() replaces this text.
Returns:
| Type | Description |
|---|---|
int
|
Upper bound on the output length; the actual call may return fewer. |
state_bytes
¶
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
¶
Serialize this object's mutable state to bytes.
Captures exactly the state that evolves as the object runs, so a blob taken now and restored later resumes from this point. Construction parameters are not included: restore into an object built the same way.
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, |
set_state
¶
Restore mutable state from a get_state() blob.
Overwrites the live state in place; the object keeps the parameters it
was constructed with. Length is validated against state_bytes()
before the blob is handed to the C core, and the core may reject it as
well.
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 |
required |
destroy
¶
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 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. |
...
|
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(pattern=…, 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(...) plays a user pattern); 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.
The StreamSink is POSIX-only. 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
|
compose
¶
Compose the full sequence into one array.
stream
¶
Iterate the sequence in blocks.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
block
|
int
|
Samples per yielded array. |
...
|
realtime
|
float
|
Sample rate in Hz to pace against — pass
your |
...
|
to_sigmf
¶
to_sigmf(
sample_type: str = ...,
endian: str = ...,
fs: float = ...,
fc: float = ...,
t0: float = ...,
) -> str
Serialise as to_sigmf.
Segment
¶
Segment.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
type
|
str
|
Waveform type.
One of |
``"tone"``
|
freq
|
float | tuple[float, float]
|
Carrier/offset frequency in Hz (normalised cycles/sample when fs=1); for chirp it is the start frequency. |
0.0
|
snr
|
float | tuple[float, float]
|
Signal-to-noise ratio in dB, interpreted per snr_mode; >=100 is treated as clean (no AWGN). |
100.0
|
snr_mode
|
str
|
How snr is interpreted: auto picks fs for tone/pn/chirp/bits and Es/No
for bpsk/qpsk.
One of |
``"auto"``
|
seed
|
int
|
PRNG/LFSR seed for the noise and PN streams. |
0
|
sps
|
int
|
Samples per symbol (PSK) or per chip (PN); the oversampling factor. |
1
|
pn_length
|
int
|
PN LFSR register length; the sequence period is 2^pn_length - 1. |
15
|
pn_poly
|
int
|
PN generator polynomial; 0 auto-selects a maximal-length (MLS) polynomial for pn_length. |
0
|
lfsr
|
str
|
PN LFSR realization (galois or fibonacci); same period, different chip
order.
One of |
``"galois"``
|
level
|
float | tuple[float, float]
|
Source power in dBFS (<=0; 0 = unit power). Applies only when summed in a Segment/Composer (gain 10^(level/20)); ignored by standalone Synth.steps(). |
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 non-chirp 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 |
0.0
|
doppler_rate
|
float | tuple[float, float]
|
Linear ramp on |
0.0
|
carrier_hz
|
float
|
RF carrier in Hz that the ppm figures are referred to, giving the coherent carrier rotation that accompanies the time-base warp. 0 (the default) 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 (default):
the channel dies with each |
``"per_instance"``
|
bits
|
bytes | None
|
For type=bits: the 0/1 pattern, oversampled by sps and cycled to fill
the request. For type=dsss (as |
None
|
modulation
|
str
|
For type=bits: symbol mapping of the pattern (none=0/1 amplitude, bpsk,
qpsk).
One of |
``"bpsk"``
|
pulse
|
str
|
Pulse shape for the symbol stream (pn/bpsk/qpsk/bits): rect
sample-and-hold or rrc matched filter.
One of |
``"rect"``
|
rrc_beta
|
float
|
RRC roll-off factor in (0, 1] when pulse=rrc. |
0.35
|
rrc_span
|
int
|
RRC filter span in symbols when pulse=rrc (taps = 2spansps + 1). |
8
|
symbols
|
NDArray[complex64] | None
|
For type=symbols: a complex64 constellation stream — each element is the output point itself, oversampled by sps, cycled, and RRC-shaped with pulse=rrc. Generalises any modulation (pi/4-QPSK, QAM, ...). |
None
|
acq_code
|
bytes | None
|
The acquisition/preamble code (0/1), repeated acq_reps times at the head of the frame — the coherent pull-in target BurstDespreader.set_acq/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 (or sync) is what makes a source FRAMED. |
None
|
acq_reps
|
int
|
Preamble repetitions (periods of acq_code before the sync word). |
1
|
data_code
|
bytes | None
|
For type=dsss: the payload spreading code (0/1 chips) — 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
|
The frame-sync word bits (e.g. Barker-13) between the preamble and the payload — what BurstDemod.set_frame correlates to resolve frame position and BPSK polarity, and what a BER alignment detects against. Optional; setting it (or acq_code) is what makes a source FRAMED. |
None
|
crc
|
str
|
The frame trailer — crc16 appends a CRC-16-CCITT over the payload bits
(what BurstDemod validates as frame_valid, and what makes a truth-free
frame error rate possible); none omits it. Applies only to a FRAMED
source: it defaults to crc16, so it alone never frames an otherwise
plain pattern.
One of |
``"crc16"``
|
rs_depth
|
int
|
Reed-Solomon (255,223) E=16 over the data group, interleaved this many
codewords deep; 0 = no outer code. CCSDS 131.0-B-3 4.3.5.1 allows 1, 2,
3, 4, 5 or 8, and the payload plus its CRC must be exactly 223*depth
octets — virtual fill is not implemented, so any other length is
REFUSED rather than padded. The wfmgen scene and CLI spell it
|
0
|
randomise
|
int
|
XOR a CCSDS section-10 pseudo-randomiser over the data group — the
payload, its CRC and the outer code's parity, but never a marker or a
preamble, which have to read the same in every frame to be findable. 0
= off, 1 = 131.0-B-6 10.4.1's 131071-bit sequence (the |
0
|
attach_asm
|
int
|
Prepend the CCSDS Attached Sync Marker (0x1ACFFC1D) as the frame's
first field — what a receiver correlates to find a frame in a bit
stream. Not covered by the randomiser, and covered by the inner code,
which is the coverage rule the description carries. The wfmgen scene
and CLI spell it |
0
|
convolutional
|
int
|
Inner code: CCSDS K=7 rate-1/2 convolutional, over the WHOLE frame
including the marker, doubling its bit count. For a |
0
|
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
|
fs
|
float
|
Sample rate in Hz — one per segment (all sources share it). |
1.0
|
num_samples
|
int | tuple[int, int]
|
Segment on-time in samples (the active span). |
1024
|
off_samples
|
int | tuple[int, int]
|
Trailing off-time gap in samples (zeros) appended after the segment. |
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. |
1
|
delay_samples
|
int | tuple[int, int]
|
Leading gap before the on-time (samples) — the burst arrives after this delay. 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 (default):
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"``
|
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.
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
|
Wire type (wavegen order): 0 cf32, 1 cf64, 2 ci32, 3 ci16, 4 ci8.
Integer types use full-scale ±1.0.
One of |
``"cf32"``
|
send
¶
Convert a cf32 block to the wire type and publish it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
iq
|
NDArray[Any]
|
Complex-float samples; @param n complex sample count. |
required |
fs
|
float
|
sample rate (Hz); @param fc center frequency (Hz) — wire header. |
required |
fc
|
float
|
the sink handle. |
required |
Returns:
| Type | Description |
|---|---|
int
|
0 on success, non-zero on a send/allocation error. |
send_eos
¶
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
|
drain
¶
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
|
track_clipping
¶
Enable the per-component clip counter (off by default; peak always on).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
on
|
int
|
Input. |
...
|
SampleClock
¶
SampleClock handle.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
fs
|
float
|
|
required |
resync
|
int
|
|
0
|
pace
¶
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
|
Input. |
required |
Returns:
| Type | Description |
|---|---|
float
|
Output. |
stamp
¶
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
|
Output. |
stamp_at
¶
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
|
Input. |
required |
Returns:
| Type | Description |
|---|---|
int
|
Output. |
track
¶
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.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
observed_timestamp_ns
|
int
|
Input. |
required |
n_at_observation
|
int
|
Input. |
required |
tolerance_ns
|
int
|
Input. |
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. |
resync
¶
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.)
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
¶
Root-raised-cosine pulse-shaping taps (2spansps+1 unit-energy cf32 taps).
rrc_h
¶
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.
rc_h
¶
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.
dsss_spread
¶
Direct-sequence spread syms by the ±1 chip code; yields len(syms)*sf chips.
crc16
¶
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]
|
Array of 0/1 bit values (one per byte). |
required |
Returns:
| Type | Description |
|---|---|
int
|
The 16-bit CRC. |
Examples:
mls_poly
¶
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:
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
¶
Map bits {0,1} to BPSK symbols {+1,-1} (cf32).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
bits
|
NDArray[uint8]
|
Array of uint8 values; only the LSB of each byte is used. |
required |
Returns:
| Type | Description |
|---|---|
NDArray[complex64]
|
Output. |
Examples:
qpsk_map
¶
Map QPSK symbol indices {0,1,2,3} to Gray-coded symbols (cf32).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
syms
|
NDArray[uint8]
|
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:
wfm_ebno_to_snr_db
¶
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:
wfm_awgn_amplitude
¶
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:
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
wfm_compose_draws() in C — so an in-process render and a written capture
cannot disagree about what was generated.
draws
¶
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 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 gallery walkthrough.
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: |
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 (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) — applied
uniformly across every segment/instance that carries noise. Frequency
(Doppler) and multipath delay are planned follow-ups.
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,)}
anchor_seed
property
¶
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 ( |
None
|
phases
|
sequence of float
|
Per-source phase rotations in radians ( |
None
|
enable
|
sequence of bool
|
|
None
|
snr
|
float
|
Global SNR in dB — moves only the noise floor (its convention is
the anchor source's |
None
|
seed
|
int
|
Noise seed for this realization (defaults to the scene's, i.e. the value that reproduces a full compose). |
None
|
Returns:
| Type | Description |
|---|---|
ndarray
|
|
at
¶
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).
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
¶
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
¶
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
¶
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 a scene into a reusable :class:Plan (Plan(scene)).
Examples:
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
¶
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:
PlanFromFile
¶
Reconstruct a :class:Plan from a file produced by :meth:Plan.dump.
The file form of :func:PlanFromBlob; the read is done in C.
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 already took these fields as flags
(Segment(sync=…, acq_code=…, crc=…), wfmgen --sync …); 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 either a literal array or a handful of numbers
a receiver can regenerate — a PN or Gold descriptor — which is what makes a
long record's truth practical: a million-symbol reference without a
million-symbol array, and a capture reproducible from its metadata alone. The
three cannot nest as structs across the C ABI, so they are flattened with a name
prefix (preamble_*, sync_*, payload_*); a field with an empty array and
zero length is absent, which is the convention wfm_seq_t itself uses.
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
empty = np.empty(0, np.uint8) # an absent field
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(empty, sync, payload, crc="crc16")
# 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.layout().payload_off] ^= 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`
layout() returns a FrameLayout record with fields preamble_off,
preamble_bits, sync_off, sync_bits, payload_off, payload_bits,
crc_off, crc_bits and total_bits — all in bits from the start of the
frame. crc_bits is 16, 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_kind
|
Literal['literal', 'pn', 'gold', 'dotted']
|
Enum index; 0=literal…3=dotted. |
"literal"
|
preamble
|
NDArray[uint8]
|
Literal preamble bits, one per element. Pass an EMPTY array when the
field is absent or generated -- |
required |
preamble_nbits
|
int
|
Output bits for a GENERATED preamble kind. A literal takes its length
from the |
0
|
preamble_reps
|
int
|
Repetitions of the preamble; 0 = no preamble (default: 0). |
0
|
preamble_poly
|
int
|
PN feedback polynomial; 0 selects the maximal-length one (default: 0). |
0
|
preamble_seed
|
int
|
PN seed; 0 selects 1, since an all-zero register is a fixed point (default: 0). |
0
|
preamble_reg_bits
|
int
|
PN/Gold register width, 1..64 (default: 0). |
0
|
preamble_lfsr
|
Literal['galois', 'fibonacci']
|
Enum index; 0=galois…1=fibonacci. |
"galois"
|
preamble_taps_a
|
int
|
Gold: first register's taps (default: 0). |
0
|
preamble_seed_a
|
int
|
Gold: first register's seed (default: 0). |
0
|
preamble_taps_b
|
int
|
Gold: second register's taps (default: 0). |
0
|
preamble_seed_b
|
int
|
Gold: second register's seed (default: 0). |
0
|
sync_kind
|
Literal['literal', 'pn', 'gold', 'dotted']
|
Enum index; 0=literal…3=dotted. |
"literal"
|
sync
|
NDArray[uint8]
|
Literal sync word bits, one per element. Pass an EMPTY array when the
field is absent or generated -- |
required |
sync_nbits
|
int
|
Output bits for a GENERATED sync kind (default: 0). |
0
|
sync_poly
|
int
|
PN feedback polynomial; 0 selects the maximal-length one (default: 0). |
0
|
sync_seed
|
int
|
PN seed; 0 selects 1 (default: 0). |
0
|
sync_reg_bits
|
int
|
PN/Gold register width, 1..64 (default: 0). |
0
|
sync_lfsr
|
Literal['galois', 'fibonacci']
|
Enum index; 0=galois…1=fibonacci. |
"galois"
|
sync_taps_a
|
int
|
Gold: first register's taps (default: 0). |
0
|
sync_seed_a
|
int
|
Gold: first register's seed (default: 0). |
0
|
sync_taps_b
|
int
|
Gold: second register's taps (default: 0). |
0
|
sync_seed_b
|
int
|
Gold: second register's seed (default: 0). |
0
|
payload_kind
|
Literal['literal', 'pn', 'gold', 'dotted']
|
Enum index; 0=literal…3=dotted. |
"literal"
|
payload
|
NDArray[uint8]
|
Literal payload bits, one per element. Pass an EMPTY array when the
field is absent or generated -- |
required |
payload_nbits
|
int
|
Output bits for a GENERATED payload kind (default: 0). |
0
|
payload_poly
|
int
|
PN feedback polynomial; 0 selects the maximal-length one (default: 0). |
0
|
payload_seed
|
int
|
PN seed; 0 selects 1 (default: 0). |
0
|
payload_reg_bits
|
int
|
PN/Gold register width, 1..64 (default: 0). |
0
|
payload_lfsr
|
Literal['galois', 'fibonacci']
|
Enum index; 0=galois…1=fibonacci. |
"galois"
|
payload_taps_a
|
int
|
Gold: first register's taps (default: 0). |
0
|
payload_seed_a
|
int
|
Gold: first register's seed (default: 0). |
0
|
payload_taps_b
|
int
|
Gold: second register's taps (default: 0). |
0
|
payload_seed_b
|
int
|
Gold: second register's seed (default: 0). |
0
|
crc
|
Literal['none', 'crc16']
|
Enum index; 0=none…1=crc16. |
"none"
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If construction fails. The exception message is |
Examples:
>>> import numpy as np
>>> from doppler.wfm import Frame
>>> empty = np.empty(0, np.uint8) # an absent field
>>> 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(empty, sync, payload, crc="crc16")
>>> f.nbits # 13 + 16 + 16
45
>>> f.layout().payload_off
13
>>> f.crc_ok(f.bits()) # its own bits are its own truth
1
A payload a receiver can REGENERATE, rather than one it must be handed:
>>> g = Frame(empty, sync, empty, payload_kind="pn",
... payload_nbits=1024, payload_reg_bits=10, crc="crc16")
>>> g.nbits
1053
rx_ok
property
¶
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
¶
Checks the last deframe() performed across every stage it reversed.
rx_checked
property
¶
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
¶
Symbol errors the last deframe() repaired. Margin being spent, visible before it is lost -- what an outer code reports and a CRC cannot.
bits
¶
Materialise n consecutive frames, one bit per byte.
n counts FRAMES, not bits: a descriptor describes one frame, and a capture holds many. Repeating here rather than making the caller tile it is what matches the generator, whose framed source cycles the same frame to fill whatever length was asked for — 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 |
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
>>> empty = np.empty(0, np.uint8)
>>> 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(empty, sync, 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 frame_bits will write for n frames — n * nbits.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
n
|
int
|
Frame repetitions. |
required |
Returns:
| Type | Description |
|---|---|
int
|
Output. |
layout
¶
Where each field lands, in bits from the start of the frame.
The offsets a receiver needs to slice a capture, computed by the same code the generator laid the frame out with.
Returns:
| Type | Description |
|---|---|
FrameLayout
|
Where each named field lands. |
Examples:
>>> import numpy as np
>>> from doppler.wfm import Frame
>>> empty = np.empty(0, np.uint8)
>>> 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)
>>> lay = Frame(empty, sync, payload, crc="crc16").layout()
>>> lay.sync_off, lay.payload_off, lay.crc_off
(0, 13, 29)
>>> lay.total_bits
45
This is the NAMED view, so it reports the four fields a Frame is built
from. A description assembled with add_field reports zeros here and is
read with field_off() / field_bits() instead.
crc_ok
¶
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]
|
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
>>> empty = np.empty(0, np.uint8)
>>> 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(empty, sync, 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(2)] ^= 1 # flip one payload bit
>>> d.crc_ok(rx)
0
add_field
¶
add_field(
lit: NDArray[uint8],
kind: str = "literal",
gen_len: int = 0,
reps: int = 0,
poly: int = 0,
seed: int = 0,
reg_bits: int = 0,
lfsr: str = "galois",
taps_a: int = 0,
seed_a: int = 0,
taps_b: int = 0,
seed_b: int = 0,
derived_by: int = 0,
derived_bits: int = 0,
) -> int
Append one field to a description (see FrameDesc). kind names
where the bits come from -- literal, pn, gold, dotted -- and
lfsr is galois or fibonacci, the same spellings the constructor
takes, from the one [[enum]] the C enum backs. Either the caller
supplies the bits (lit, or a generated kind) or a stage derives
them (derived_by non-zero) -- both are fields, because both are on
the wire. Returns the new field's index, which is what derived_by and
a stage's first_field are counted in. Refuses once the frame is
built.
Either the caller supplies the bits (lit, or a generated kind) or a stage derives them (derived_by non-zero). Both are fields, because both are on the wire.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
lit
|
NDArray[uint8]
|
Literal bits, copied here so the description outlives the call; may be NULL. |
required |
kind
|
str
|
wfm_seq_kind_t index; 0=literal…3=dotted. |
'literal'
|
gen_len
|
int
|
Output bits for a GENERATED kind. |
0
|
reps
|
int
|
Repetitions of the field, verbatim; 0 means one. |
0
|
poly
|
int
|
PN feedback polynomial; 0 selects the maximal-length. |
0
|
seed
|
int
|
PN seed; 0 selects 1. |
0
|
reg_bits
|
int
|
PN/Gold register width. |
0
|
lfsr
|
str
|
0=galois, 1=fibonacci. |
'galois'
|
taps_a
|
int
|
Gold: first register's taps. |
0
|
seed_a
|
int
|
Gold: first register's seed. |
0
|
taps_b
|
int
|
Gold: second register's taps. |
0
|
seed_b
|
int
|
Gold: second register's seed. |
0
|
derived_by
|
int
|
0 when the caller supplies this field; otherwise the index of the producing stage, PLUS ONE. |
0
|
derived_bits
|
int
|
Length of a derived field, in bits. |
0
|
Returns:
| Type | Description |
|---|---|
int
|
The new field's index, or -1 if the description is full, already
built, or the literal could not be copied. The Python binding
raises |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the C call returns a negative value. The exception message is
|
Examples:
>>> import numpy as np
>>> from doppler.wfm import FrameDesc
>>> from doppler.ccsds import asm_bits
>>> empty = np.empty(0, np.uint8)
>>> asm = asm_bits()
>>> octets = np.array([(i * 29 + 5) & 0xFF for i in range(223)],
... np.uint8)
>>> data = np.unpackbits(octets).astype(np.uint8)
>>> d = FrameDesc(empty, empty, empty) # begin from nothing
>>> d.add_field(asm) # the attached sync marker
0
>>> d.add_field(data) # the transfer frame
1
A field the CALLER does not supply is still a field, because it is still
on the wire -- derived_by names the stage that fills it, PLUS ONE:
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 |
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 |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the C call returns a negative value. The exception message is
|
Examples:
>>> import numpy as np
>>> from doppler.wfm import FrameDesc
>>> from doppler.ccsds import asm_bits
>>> empty = np.empty(0, np.uint8)
>>> asm = asm_bits()
>>> octets = np.array([(i * 29 + 5) & 0xFF for i in range(223)],
... np.uint8)
>>> data = np.unpackbits(octets).astype(np.uint8)
>>> d = FrameDesc(empty, empty, empty)
>>> _ = d.add_field(asm), d.add_field(data)
>>> _ = d.add_field(empty, derived_by=1, derived_bits=32 * 8)
>>> 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:
field_index
¶
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:
name_field
¶
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 |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the C call returns a non-zero status. The exception message is
|
Examples:
add_hex
¶
Append a named field from a hex literal, MSB-first --
add_hex("asm", "1ACFFC1D") is 32 bits. Four bits per digit, so an odd
number of digits gives a 4-bit tail. The expansion is cvt's
hex_to_bin, not a second parser, so a bad digit is refused there.
Returns the new field's index; a refusal raises ValueError.
Four bits per digit, MSB-first, so an odd number of digits gives a
4-bit tail. The expansion is cvt's hex_to_bin rather than a second
parser here, so a bad digit is a refusal there and the two cannot
disagree about what a marker expands to.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
the field's name, or NULL for anonymous. |
required |
hex
|
str
|
NUL-terminated hex digits; no |
required |
reps
|
int
|
repetitions; 0 means one. |
0
|
Returns:
| Type | Description |
|---|---|
int
|
Output. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the C call returns a negative value. The exception message is
|
Examples:
add_value
¶
Append a named field from an integer, bits wide, MSB-first. The
form to reach for when a literal fits in 64 bits: exact, and with no
failure mode a typo can reach. Wider literals want add_hex or
add_field. Returns the new field's index; a refusal raises
ValueError.
The form to reach for when a literal fits in 64 bits: exact, and with no failure mode a typo can reach. Wider ones want frame_add_hex.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
the field's name, or NULL for anonymous. |
required |
value
|
int
|
the value; only the low bits are read. |
required |
bits
|
int
|
1..64, MSB first. |
required |
reps
|
int
|
repetitions; 0 means one. |
0
|
Returns:
| Type | Description |
|---|---|
int
|
Output. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the C call returns a negative value. The exception message is
|
Examples:
add_derived
¶
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 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
|
Output. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the C call returns a negative value. The exception message is
|
Examples:
add_stage_over
¶
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 — |
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 |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the C call returns a negative value. The exception message is
|
Examples:
>>> import numpy as np
>>> from doppler.wfm import FrameDesc
>>> e = np.empty(0, np.uint8)
>>> d = FrameDesc(e, e, e)
>>> d.add_field(np.array([0, 1, 1, 0, 1, 0, 0, 1], np.uint8))
0
>>> d.name_field(0, "payload")
>>> 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
¶
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. 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 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.
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 ccsds_tm_frame_encode.
Raises:
| Type | Description |
|---|---|
ValueError
|
If the C call returns a non-zero status. The exception message is
|
Examples:
>>> import numpy as np
>>> from doppler.wfm import FrameDesc
>>> empty = np.empty(0, np.uint8)
>>> 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(empty, sync, 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:
deframe
¶
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 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]
|
Received bits, |
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
>>> empty = np.zeros(0, dtype=np.uint8)
>>> 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(empty, sync, 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.layout().payload_off # the payload is 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
¶
Max bits 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
¶
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 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]
|
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
>>> empty = np.empty(0, np.uint8)
>>> 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(empty, sync, 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:
Carrying no check is NOT passing one -- both are reported, separately:
n_fields
¶
Fields in the description. A Frame built the four-field way
reports 4 -- wfm_frame_t IS a configuration of the general
description, so the indexed view below reads it too.
Returns:
| Type | Description |
|---|---|
int
|
How many fields the description carries. |
Examples:
>>> import numpy as np
>>> from doppler.wfm import FrameDesc
>>> empty = np.empty(0, np.uint8)
>>> 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(empty, sync, payload, crc="crc16")
>>> d.n_fields() # the four named fields, absent ones included
4
n_stages
¶
Stages in the description.
Returns:
| Type | Description |
|---|---|
int
|
How many stages the description carries. |
Examples:
>>> import numpy as np
>>> from doppler.wfm import FrameDesc
>>> empty = np.empty(0, np.uint8)
>>> 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(empty, sync, payload, crc="crc16")
>>> d.build()
>>> d.n_stages() # the CRC is a stage like any other
1
field_off
¶
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
>>> empty = np.empty(0, np.uint8)
>>> 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(empty, sync, payload, crc="crc16")
>>> d.build()
>>> d.field_off(1), d.field_off(2), d.field_off(3)
(0, 13, 29)
Field 0 is the absent preamble: an empty field still HAS an index, so the
indices a caller passed to add_field keep meaning what they meant.
field_bits
¶
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
>>> empty = np.empty(0, np.uint8)
>>> 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(empty, sync, payload, crc="crc16")
>>> d.build()
>>> d.field_bits(1), d.field_bits(2), d.field_bits(3)
(13, 16, 16)
stage_first
¶
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
>>> empty = np.empty(0, np.uint8)
>>> 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(empty, sync, payload, crc="crc16")
>>> d.build()
>>> d.stage_first(0) # the CRC starts at the payload, not at bit 0
13
stage_bits
¶
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
>>> empty = np.empty(0, np.uint8)
>>> 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(empty, sync, payload, crc="crc16")
>>> d.build()
>>> d.stage_bits(0) # payload+CRC: what crc16 covered
32
destroy
¶
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 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 |
stages |
int
|
Stages in the description. |
checked |
int
|
How many were reversed here. 0 means the description carries no reversible stage, which is why |
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
¶
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.
checked
property
¶
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
¶
Checks performed: one for a CRC, one per codeword for an interleaved outer code.
FrameDesc — the same frame, deferred¶
Frame names four fields and materialises them in the constructor. FrameDesc
takes the same arguments and stops before laying anything out, so those four
are a starting point a caller extends with add_field() and add_stage()
before calling build(). Pass empty arrays for all three to begin from nothing.
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
derived_by, which names the producing stage plus one. - 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
empty = np.empty(0, np.uint8)
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(empty, empty, empty) # begin from nothing
assert d.add_field(asm) == 0 # attached sync marker
assert d.add_field(data) == 1 # the transfer frame
assert d.add_field(empty, derived_by=1, derived_bits=E2 * 8) == 2 # parity
# 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(empty, empty, empty)
mine.add_field(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_kind
|
Literal['literal', 'pn', 'gold', 'dotted']
|
Enum index; 0=literal…3=dotted. |
"literal"
|
preamble
|
NDArray[uint8]
|
Literal preamble bits, one per element. Pass an EMPTY array when the
field is absent or generated -- |
required |
preamble_nbits
|
int
|
Output bits for a GENERATED preamble kind. A literal takes its length
from the |
0
|
preamble_reps
|
int
|
Repetitions of the preamble; 0 = no preamble (default: 0). |
0
|
preamble_poly
|
int
|
PN feedback polynomial; 0 selects the maximal-length one (default: 0). |
0
|
preamble_seed
|
int
|
PN seed; 0 selects 1, since an all-zero register is a fixed point (default: 0). |
0
|
preamble_reg_bits
|
int
|
PN/Gold register width, 1..64 (default: 0). |
0
|
preamble_lfsr
|
Literal['galois', 'fibonacci']
|
Enum index; 0=galois…1=fibonacci. |
"galois"
|
preamble_taps_a
|
int
|
Gold: first register's taps (default: 0). |
0
|
preamble_seed_a
|
int
|
Gold: first register's seed (default: 0). |
0
|
preamble_taps_b
|
int
|
Gold: second register's taps (default: 0). |
0
|
preamble_seed_b
|
int
|
Gold: second register's seed (default: 0). |
0
|
sync_kind
|
Literal['literal', 'pn', 'gold', 'dotted']
|
Enum index; 0=literal…3=dotted. |
"literal"
|
sync
|
NDArray[uint8]
|
Literal sync word bits, one per element. Pass an EMPTY array when the
field is absent or generated -- |
required |
sync_nbits
|
int
|
Output bits for a GENERATED sync kind (default: 0). |
0
|
sync_poly
|
int
|
PN feedback polynomial; 0 selects the maximal-length one (default: 0). |
0
|
sync_seed
|
int
|
PN seed; 0 selects 1 (default: 0). |
0
|
sync_reg_bits
|
int
|
PN/Gold register width, 1..64 (default: 0). |
0
|
sync_lfsr
|
Literal['galois', 'fibonacci']
|
Enum index; 0=galois…1=fibonacci. |
"galois"
|
sync_taps_a
|
int
|
Gold: first register's taps (default: 0). |
0
|
sync_seed_a
|
int
|
Gold: first register's seed (default: 0). |
0
|
sync_taps_b
|
int
|
Gold: second register's taps (default: 0). |
0
|
sync_seed_b
|
int
|
Gold: second register's seed (default: 0). |
0
|
payload_kind
|
Literal['literal', 'pn', 'gold', 'dotted']
|
Enum index; 0=literal…3=dotted. |
"literal"
|
payload
|
NDArray[uint8]
|
Literal payload bits, one per element. Pass an EMPTY array when the
field is absent or generated -- |
required |
payload_nbits
|
int
|
Output bits for a GENERATED payload kind (default: 0). |
0
|
payload_poly
|
int
|
PN feedback polynomial; 0 selects the maximal-length one (default: 0). |
0
|
payload_seed
|
int
|
PN seed; 0 selects 1 (default: 0). |
0
|
payload_reg_bits
|
int
|
PN/Gold register width, 1..64 (default: 0). |
0
|
payload_lfsr
|
Literal['galois', 'fibonacci']
|
Enum index; 0=galois…1=fibonacci. |
"galois"
|
payload_taps_a
|
int
|
Gold: first register's taps (default: 0). |
0
|
payload_seed_a
|
int
|
Gold: first register's seed (default: 0). |
0
|
payload_taps_b
|
int
|
Gold: second register's taps (default: 0). |
0
|
payload_seed_b
|
int
|
Gold: second register's seed (default: 0). |
0
|
crc
|
Literal['none', 'crc16']
|
Enum index; 0=none…1=crc16. |
"none"
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If construction fails. The exception message is |
Examples:
>>> import numpy as np
>>> from doppler.wfm import FrameDesc
>>> empty = np.empty(0, np.uint8)
>>> d = FrameDesc(empty, empty, empty) # 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) # returns its index
0
>>> d.add_field(payload)
1
>>> d.add_field(empty, derived_by=1, derived_bits=16) # stage 0, PLUS ONE
2
>>> d.add_stage(kind=0, first_field=1, n_fields=2) # crc16 over 1..2
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
¶
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
¶
Checks the last deframe() performed across every stage it reversed.
rx_checked
property
¶
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
¶
Symbol errors the last deframe() repaired. Margin being spent, visible before it is lost -- what an outer code reports and a CRC cannot.
bits
¶
Materialise n consecutive frames, one bit per byte.
n counts FRAMES, not bits: a descriptor describes one frame, and a capture holds many. Repeating here rather than making the caller tile it is what matches the generator, whose framed source cycles the same frame to fill whatever length was asked for — 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 |
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
>>> empty = np.empty(0, np.uint8)
>>> 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(empty, sync, 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 frame_bits will write for n frames — n * nbits.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
n
|
int
|
Frame repetitions. |
required |
Returns:
| Type | Description |
|---|---|
int
|
Output. |
layout
¶
Where each field lands, in bits from the start of the frame.
The offsets a receiver needs to slice a capture, computed by the same code the generator laid the frame out with.
Returns:
| Type | Description |
|---|---|
FrameLayout
|
Where each named field lands. |
Examples:
>>> import numpy as np
>>> from doppler.wfm import Frame
>>> empty = np.empty(0, np.uint8)
>>> 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)
>>> lay = Frame(empty, sync, payload, crc="crc16").layout()
>>> lay.sync_off, lay.payload_off, lay.crc_off
(0, 13, 29)
>>> lay.total_bits
45
This is the NAMED view, so it reports the four fields a Frame is built
from. A description assembled with add_field reports zeros here and is
read with field_off() / field_bits() instead.
crc_ok
¶
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]
|
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
>>> empty = np.empty(0, np.uint8)
>>> 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(empty, sync, 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(2)] ^= 1 # flip one payload bit
>>> d.crc_ok(rx)
0
add_field
¶
add_field(
lit: NDArray[uint8],
kind: str = "literal",
gen_len: int = 0,
reps: int = 0,
poly: int = 0,
seed: int = 0,
reg_bits: int = 0,
lfsr: str = "galois",
taps_a: int = 0,
seed_a: int = 0,
taps_b: int = 0,
seed_b: int = 0,
derived_by: int = 0,
derived_bits: int = 0,
) -> int
Append one field to a description (see FrameDesc). kind names
where the bits come from -- literal, pn, gold, dotted -- and
lfsr is galois or fibonacci, the same spellings the constructor
takes, from the one [[enum]] the C enum backs. Either the caller
supplies the bits (lit, or a generated kind) or a stage derives
them (derived_by non-zero) -- both are fields, because both are on
the wire. Returns the new field's index, which is what derived_by and
a stage's first_field are counted in. Refuses once the frame is
built.
Either the caller supplies the bits (lit, or a generated kind) or a stage derives them (derived_by non-zero). Both are fields, because both are on the wire.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
lit
|
NDArray[uint8]
|
Literal bits, copied here so the description outlives the call; may be NULL. |
required |
kind
|
str
|
wfm_seq_kind_t index; 0=literal…3=dotted. |
'literal'
|
gen_len
|
int
|
Output bits for a GENERATED kind. |
0
|
reps
|
int
|
Repetitions of the field, verbatim; 0 means one. |
0
|
poly
|
int
|
PN feedback polynomial; 0 selects the maximal-length. |
0
|
seed
|
int
|
PN seed; 0 selects 1. |
0
|
reg_bits
|
int
|
PN/Gold register width. |
0
|
lfsr
|
str
|
0=galois, 1=fibonacci. |
'galois'
|
taps_a
|
int
|
Gold: first register's taps. |
0
|
seed_a
|
int
|
Gold: first register's seed. |
0
|
taps_b
|
int
|
Gold: second register's taps. |
0
|
seed_b
|
int
|
Gold: second register's seed. |
0
|
derived_by
|
int
|
0 when the caller supplies this field; otherwise the index of the producing stage, PLUS ONE. |
0
|
derived_bits
|
int
|
Length of a derived field, in bits. |
0
|
Returns:
| Type | Description |
|---|---|
int
|
The new field's index, or -1 if the description is full, already
built, or the literal could not be copied. The Python binding
raises |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the C call returns a negative value. The exception message is
|
Examples:
>>> import numpy as np
>>> from doppler.wfm import FrameDesc
>>> from doppler.ccsds import asm_bits
>>> empty = np.empty(0, np.uint8)
>>> asm = asm_bits()
>>> octets = np.array([(i * 29 + 5) & 0xFF for i in range(223)],
... np.uint8)
>>> data = np.unpackbits(octets).astype(np.uint8)
>>> d = FrameDesc(empty, empty, empty) # begin from nothing
>>> d.add_field(asm) # the attached sync marker
0
>>> d.add_field(data) # the transfer frame
1
A field the CALLER does not supply is still a field, because it is still
on the wire -- derived_by names the stage that fills it, PLUS ONE:
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 |
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 |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the C call returns a negative value. The exception message is
|
Examples:
>>> import numpy as np
>>> from doppler.wfm import FrameDesc
>>> from doppler.ccsds import asm_bits
>>> empty = np.empty(0, np.uint8)
>>> asm = asm_bits()
>>> octets = np.array([(i * 29 + 5) & 0xFF for i in range(223)],
... np.uint8)
>>> data = np.unpackbits(octets).astype(np.uint8)
>>> d = FrameDesc(empty, empty, empty)
>>> _ = d.add_field(asm), d.add_field(data)
>>> _ = d.add_field(empty, derived_by=1, derived_bits=32 * 8)
>>> 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:
field_index
¶
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:
name_field
¶
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 |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the C call returns a non-zero status. The exception message is
|
Examples:
add_hex
¶
Append a named field from a hex literal, MSB-first --
add_hex("asm", "1ACFFC1D") is 32 bits. Four bits per digit, so an odd
number of digits gives a 4-bit tail. The expansion is cvt's
hex_to_bin, not a second parser, so a bad digit is refused there.
Returns the new field's index; a refusal raises ValueError.
Four bits per digit, MSB-first, so an odd number of digits gives a
4-bit tail. The expansion is cvt's hex_to_bin rather than a second
parser here, so a bad digit is a refusal there and the two cannot
disagree about what a marker expands to.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
the field's name, or NULL for anonymous. |
required |
hex
|
str
|
NUL-terminated hex digits; no |
required |
reps
|
int
|
repetitions; 0 means one. |
0
|
Returns:
| Type | Description |
|---|---|
int
|
Output. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the C call returns a negative value. The exception message is
|
Examples:
add_value
¶
Append a named field from an integer, bits wide, MSB-first. The
form to reach for when a literal fits in 64 bits: exact, and with no
failure mode a typo can reach. Wider literals want add_hex or
add_field. Returns the new field's index; a refusal raises
ValueError.
The form to reach for when a literal fits in 64 bits: exact, and with no failure mode a typo can reach. Wider ones want frame_add_hex.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
the field's name, or NULL for anonymous. |
required |
value
|
int
|
the value; only the low bits are read. |
required |
bits
|
int
|
1..64, MSB first. |
required |
reps
|
int
|
repetitions; 0 means one. |
0
|
Returns:
| Type | Description |
|---|---|
int
|
Output. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the C call returns a negative value. The exception message is
|
Examples:
add_derived
¶
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 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
|
Output. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the C call returns a negative value. The exception message is
|
Examples:
add_stage_over
¶
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 — |
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 |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the C call returns a negative value. The exception message is
|
Examples:
>>> import numpy as np
>>> from doppler.wfm import FrameDesc
>>> e = np.empty(0, np.uint8)
>>> d = FrameDesc(e, e, e)
>>> d.add_field(np.array([0, 1, 1, 0, 1, 0, 0, 1], np.uint8))
0
>>> d.name_field(0, "payload")
>>> 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
¶
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. 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 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.
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 ccsds_tm_frame_encode.
Raises:
| Type | Description |
|---|---|
ValueError
|
If the C call returns a non-zero status. The exception message is
|
Examples:
>>> import numpy as np
>>> from doppler.wfm import FrameDesc
>>> empty = np.empty(0, np.uint8)
>>> 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(empty, sync, 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:
deframe
¶
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 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]
|
Received bits, |
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
>>> empty = np.zeros(0, dtype=np.uint8)
>>> 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(empty, sync, 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.layout().payload_off # the payload is 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
¶
Max bits 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
¶
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 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]
|
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
>>> empty = np.empty(0, np.uint8)
>>> 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(empty, sync, 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:
Carrying no check is NOT passing one -- both are reported, separately:
n_fields
¶
Fields in the description. A Frame built the four-field way
reports 4 -- wfm_frame_t IS a configuration of the general
description, so the indexed view below reads it too.
Returns:
| Type | Description |
|---|---|
int
|
How many fields the description carries. |
Examples:
>>> import numpy as np
>>> from doppler.wfm import FrameDesc
>>> empty = np.empty(0, np.uint8)
>>> 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(empty, sync, payload, crc="crc16")
>>> d.n_fields() # the four named fields, absent ones included
4
n_stages
¶
Stages in the description.
Returns:
| Type | Description |
|---|---|
int
|
How many stages the description carries. |
Examples:
>>> import numpy as np
>>> from doppler.wfm import FrameDesc
>>> empty = np.empty(0, np.uint8)
>>> 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(empty, sync, payload, crc="crc16")
>>> d.build()
>>> d.n_stages() # the CRC is a stage like any other
1
field_off
¶
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
>>> empty = np.empty(0, np.uint8)
>>> 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(empty, sync, payload, crc="crc16")
>>> d.build()
>>> d.field_off(1), d.field_off(2), d.field_off(3)
(0, 13, 29)
Field 0 is the absent preamble: an empty field still HAS an index, so the
indices a caller passed to add_field keep meaning what they meant.
field_bits
¶
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
>>> empty = np.empty(0, np.uint8)
>>> 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(empty, sync, payload, crc="crc16")
>>> d.build()
>>> d.field_bits(1), d.field_bits(2), d.field_bits(3)
(13, 16, 16)
stage_first
¶
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
>>> empty = np.empty(0, np.uint8)
>>> 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(empty, sync, payload, crc="crc16")
>>> d.build()
>>> d.stage_first(0) # the CRC starts at the payload, not at bit 0
13
stage_bits
¶
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
>>> empty = np.empty(0, np.uint8)
>>> 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(empty, sync, payload, crc="crc16")
>>> d.build()
>>> d.stage_bits(0) # payload+CRC: what crc16 covered
32
destroy
¶
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 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.
Related pages¶
Gallery — AsyncDsssPool: the population's lifecycle, Async DSSS Receiver: the SPEC waveform through coupled Doppler, 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, DSSS Acquisition — Pd / Pfa vs Es/N0, A 5-Burst DSSS Link — wfmgen's Three Faces, the Full Receiver Chain, DsssBurstReceiver — the Composed Burst Chain, Gallery, One Cache Slot for a Whole Background Field, Prepare Once, Sweep Many — the Plan stimulus engine, type="symbols" — Bring Your Own Constellation, Composing a Scene — .sum(), .add(), and Headroom, Waveform I/O — One Capture, Four File Types, Waveform Write — Compose, Write, Read Back, wfmgen — One Engine, Every Waveform
Guides — Real-Time Pacing & Timestamping, Reading captures, Writing captures — output & file types, Waveform Generator — wfmgen, Python API, Scenes — composing in time, Waveforms — what you can generate
Design — API taxonomy: the DSP building-block hierarchy and its naming axis, AsyncDsssReceiver — the measurement record, BurstBank — the coarse-Doppler bank as one C object, DsssBurstReceiver: the burst chain, composed in C, A Frame as a Description, Design, MPSK Receiver, 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, Validation log