Waveforms — what you can generate¶
Every --type the engine offers, the knobs that shape it, how loud it
comes out, and the three things you can wrap around it: a frame, a
channel code, and a moving clock.
For putting waveforms in time — scenes, sweeps, streaming — see Scenes. The object model is on the guide index.
--type selects the waveform; every type shares the same parameter set. The
engine produces nine types from one declarative core.
--type |
What it is | Key parameters |
|---|---|---|
tone |
a complex sinusoid at --freq |
--freq |
noise |
complex AWGN (unit power) | --snr (ignored — it is noise) |
pn |
a maximum-length sequence (±1 chips), --sps samples/chip |
--pn-length, --pn-poly, --sps |
bpsk |
BPSK symbols (PN-sourced data), --sps samples/symbol |
--sps, --snr |
qpsk |
Gray-coded QPSK symbols (PN-sourced data) | --sps, --snr |
chirp |
linear-FM sweep --freq → --f-end over --count |
--freq, --f-end |
bits |
your bits (a data source), oversampled --sps, sent once |
--data, --modulation, --sps |
symbols |
your complex constellation, oversampled --sps and cycled |
--symbols-file, --sps |
The data bits for bpsk/qpsk come from a deterministic PN sequence (seeded by
--seed), so output is reproducible and receiver-correlatable.
wfmgen --type tone --freq 1e5 --fs 1e6 --count 4096 -o tone.cf32
wfmgen --type qpsk --sps 8 --snr 12 --count 100000 -o qpsk.cf32
wfmgen --type chirp --freq 100e3 --f-end 300e3 --fs 1e6 --count 10000 -o chirp.cf32
Which waveforms have a receiver¶
wfmgen is a transmitter. Some of what it sends has a receiver in doppler and some is stimulus only, and it is better to know which before building a test around one.
| You send | Received by |
|---|---|
bpsk / qpsk / bits (a BPSK or QPSK mapping) as a continuous stream, framed (preamble, sync word, data, CRC) or not |
MpskReceiver, with FrameDesc.check scoring each frame |
a dsss burst (--acq-code, --data-code, a frame) |
DsssBurstReceiver (or BurstDemod once a burst is located) |
continuous dsss (--symbol-rate) |
DsssReceiver |
an unspread burst: bits / bpsk / qpsk frames with silence or noise between them (--off, --repeats, --delay), or one frame alone |
stimulus only: it must be located before it can be demodulated, and nothing here does both |
chirp |
stimulus only: no demodulator |
symbols (QAM, APSK, pi/4-QPSK, anything you supply) |
stimulus only: no demodulator for the constellation |
tone, noise, pn |
measurement and correlation inputs, not links |
What makes a burst stimulus-only is not its preamble. A preamble is part of a
continuously framed stream, and MpskReceiver receives that stream and scores
its frames; it is the schedule (a frame, then silence, then another) that
leaves no receiver, because a tracking loop has nothing to track in the gaps.
"Stimulus only" is a statement about demodulating, not about finding the
signal. A burst that starts with a complex preamble can still be detected and
located with
BurstAcquisition,
and BurstCapture
cuts the located bursts out of a stream; what you do with their samples next
is yours. doppler does not ship a receiver for these: they exist to drive your
own receiver, an acquisition test, or a plot.
Chirp — linear-FM sweep¶
A chirp sweeps its instantaneous frequency linearly from --freq (the start)
to --f-end over its span, then holds at --f-end; --f-end < --freq is a
down-chirp. Its phase is one running accumulator, so a chirp read in pieces --
one block or many, step() or steps(n) -- joins seamlessly: the pieces are
the same samples, byte for byte, as one read. Radar pulse compression, SAR,
sonar and frequency-response test signals all fall out of this one type. A
scene's segments are different: each segment starts its sources afresh, so a
sweep meant to run unbroken is one segment.
On the CLI and in a Segment the span is the on-time (--count,
num_samples); a span on the source overrides it. Sample n steps by
--freq + n·k, with k = (--f-end − --freq) / span, so the span's last
sample steps at --f-end − k, and --f-end is held from the sample after.
A standalone Python Synth/chirp() has no segment to lend a span, so a
sweeping one must declare span=. It raises on first generation otherwise,
rather than guessing one from the size of the first read, which would make
step(), steps(n) and block reads three different waveforms
(#1115).
Bits — your bit pattern, mapped¶
A bits waveform sends your bits — a preamble, sync word, or test
vector — from a data source: a
Field — a 0/1 string
(--data 10110101), hex (--data 0xAA55, MSB first) or a generated sequence
(--data pn:1024:15) — or a binary file whose bytes are the bits, MSB
first per byte (--data-from-file frame.bin), which is how a real transfer
frame reaches the tool. --modulation (none / bpsk / qpsk) maps the
bits to symbols (none → 0/1 amplitude, bpsk → ±1, qpsk → two bits per
symbol, Gray-coded). Each bit is held --sps samples, and the bits are sent
once: the run is the data. A source is a frame's payload, so the default
--crc crc16 appends a CRC-16; --crc none sends the bits exactly as given.
wfmgen --type bits --data 10110101 --crc none --modulation bpsk --sps 8 -o sync.cf32
wfmgen --type bits --data 0xAA55 --crc none --modulation none --sps 4 -o preamble.cf32
# a frame on disk: 0xB2 0x5A is the same 16 bits as --data 0xb25a
printf '\262\132' > frame.bin
wfmgen --type bits --data-from-file frame.bin --crc none --modulation bpsk \
--sps 1 -o from-file.cf32
Data — a payload drawn frame by frame¶
A data source fills a frame's payload a chunk at a time: --data-len
bits per frame, each frame carrying the next chunk with its own CRC,
until the source ends. This is how a message, a file or a pipe is sent as a
sequence of frames rather than as one pattern cycled. It works on the framed
types (bits, bpsk, qpsk, pn) and on a DSSS burst, where each burst
is the preamble then the next chunk's frame, spread. Continuous DSSS has
no frame: each data symbol carries the next bit, so --data-len and
--fill do not apply and are refused, a finite source ends the run with its
last data symbol, and --realtime over stdin is refused (there is no idle
frame to send while the pipe pauses). With no data source it sends its
seeded PRBS, as it always has.
A paced producer must stay ahead. Under --realtime, a frame's data has
to be in the pipe when its block is computed, up to one 4096-sample block
(4096 / fs seconds) early. A producer that is ahead of the run, as cat
always is, never trips this. A producer that paces itself to the frame rate
gets idle frames at the start until it is one block ahead, and that holds at
any frame period.
| flag (scene key) | what it is |
|---|---|
--data FIELD ("data") |
the source's bits, as a Field: 0x…, 0101, or a finite pn:/gold: |
--data-from-file PATH ("data_from_file") |
a file of packed octets, MSB first; - is stdin (the command line, and its record) |
--data-len BITS ("data_len") |
bits per frame; 0 takes a finite source whole, as one frame |
--fill FIELD ("fill") |
pads the last frame when the data does not divide into frames, and an idle frame |
--data and --data-from-file are one exclusive pair. What each kind of
source is -- finite or a stream, and how many frames it makes -- is the
table in wfm/wfm_data.h, the one home of it.
- A finite source sets the run's length:
ceil(bits / data_len)frames, so--countbeside it is refused, and a--recordreplays it without one. - A remainder needs a fill, refused before the first sample otherwise, naming the fix.
- stdin is a stream: it always needs
--fill, ends the run on the frame boundary where the input ends, and cannot be repeated (--repeat,--repeats,--continuous) or written into a scene by hand. Under--realtime, a pause in the input sends idle frames, so the carrier and the frame timing never break.
An idle frame is defined by the fill. It is a data frame whose data
field is the declared --fill Field, tiled from its first bit across
--data-len bits, and it is framed exactly like any other frame: the same
preamble, sync word or ASM, the CRC computed over it, and every coding stage
of the frame description applied. Only the data field differs. A receiver
recognises an idle frame by comparing its decoded data field with the fill.
Bits that arrived before the pause wait for the next data frame, and are
never mixed into an idle frame. Only a paced (--realtime) stream has idle
frames: unpaced, the writer waits for the input, as cat does.
Recording and replaying a data source¶
--record stores the data source as it was given: a --data Field inline,
and a --data-from-file by its path. Once the run ends it adds
"data_sent", what the source sent, which is the truth for scoring:
| key | what it counts |
|---|---|
frames |
data frames sent, the padded last included |
pad_bits |
fill bits padding the last frame (0 when the data divides) |
idle_frames |
idle frames sent; only a paced stream has any |
bits |
source bits read, fill excluded |
hash |
a file or stdin only: dp_hash64 (FNV-1a 64) of octets read |
The counts are per instance of the segment: a finite source sends the same
frames in every instance, and a stream has only one. A SigMF capture
carries the same truth on each annotation, as wfmgen:frames,
wfmgen:pad_bits, wfmgen:idle_frames, wfmgen:data_bits and
wfmgen:data_hash. Both are written when the run ends, so the idle count
is final.
A replay identifies a file by its content, not by its name. A path alone would replay whatever the file holds on the day, and a length alone misses an edit of the same size. So, before the first sample:
- a recorded file whose length in bits or hash differs is refused, and the error names the file, the record's hash and the file's;
- a run read from stdin is recorded as
"data_from_file": "-"with the hash of what was read. Its octets are gone, so it replays only when--data-from-file FILEis given again, naming a file that holds them, which is checked the same way.-again is refused, because a pipe could only be checked after it had been sent; - a
--dataField replays from the record itself, andpn:0from its seed.
# Record a run from a file: the record names the file, its length in bits
# and its hash, and what was sent. The replay sends the same samples.
printf 'F7F7' > msg.bin
wfmgen --type bpsk --data-from-file msg.bin --data-len 16 --sps 4 \
--record msg.json -o a.cf32
wfmgen --from-file msg.json -o b.cf32
# The same length, edited in place: the replay is refused before it writes
# anything, naming the record's hash and the file's.
printf 'F7F8' > msg.bin
! wfmgen --from-file msg.json -o c.cf32 # refused: exits non-zero
# From stdin: recorded as "-", replayed from the file given again.
printf 'F7F7' > msg.bin
wfmgen --type bpsk --data-from-file - --data-len 16 --fill 0 --sps 4 \
--record piped.json -o d.cf32 < msg.bin
wfmgen --from-file piped.json --data-from-file msg.bin -o e.cf32
python3 -c "import filecmp as f; assert f.cmp('a.cf32', 'b.cf32', 0)"
python3 -c "import filecmp as f; assert f.cmp('d.cf32', 'e.cf32', 0)"
python3 -c "import os; assert not os.path.exists('c.cf32')"
# A message, three 16-bit frames, each with a CRC-16 over its own chunk.
wfmgen --type bpsk --data 0x0123456789AB --data-len 16 --sps 4 -o frames.cf32
# The same bytes from a file, then from stdin: the same waveform.
printf '\001\043\105\147\211\253' > six.bin
wfmgen --type bpsk --data-from-file six.bin --data-len 16 --sps 4 -o file.cf32
printf '\001\043\105\147\211\253' \
| wfmgen --type bpsk --data-from-file - --data-len 16 --fill 0 --sps 4 \
-o stdin.cf32
cmp frames.cf32 file.cf32
cmp frames.cf32 stdin.cf32
# A DSSS burst per frame: the same three chunks, three bursts.
wfmgen --type dsss --acq-code 0x9 --data-code 0xd --data 0x0123456789AB \
--data-len 16 --sps 1 -o bursts.cf32
# Continuous DSSS: the same 48 bits, one per data symbol, then the run ends.
wfmgen --type dsss --data-code 0x1d --symbol-rate 2700 --sps 2 \
--fs 6138000 --data 0x0123456789AB -o cont.cf32
From Python, data= takes the bits -- a file's bytes become bits through
cvt.bytes_to_bin -- and there is no file face of its own. The message comes
back frame by frame, each with its own CRC
(wfmgen_data_source_demo.py,
whose C twin is native/examples/wfmgen_data_source_demo.c):
import numpy as np
from doppler.cvt import bytes_to_bin
from doppler.wfm import STAGE_CRC16, Composer, FrameDesc, Segment
message = b"a payload drawn from a data source, frame by frame"
octets = np.frombuffer(message, np.uint8)
bits = np.zeros(8 * octets.size, np.uint8)
bytes_to_bin(octets, bits, 0) # MSB first, as a file is read
SPS = 4
d = FrameDesc()
PAYLOAD = 96 # bits of the source per frame
d.add_data("payload", PAYLOAD)
d.add_derived("crc", 16)
d.add_stage_over(STAGE_CRC16, "payload", "crc") # a CRC-16 over its chunk
d.build() # lay it out: the receive side (check, deframe) reads the layout
seg = Segment(
type="bpsk",
fs=1e6,
sps=SPS,
snr=100.0,
snr_mode="fs",
data=bits, # the source: its bits
frame=d, # each frame: its chunk, then a CRC-16 over it
fill=np.array([0, 1], np.uint8), # pads the short last frame
)
x = np.asarray(Composer([seg]).compose())
Symbols — bring your own constellation¶
Where bits maps data through a fixed modulation, symbols skips the map
entirely: --symbols-file iq.cf32 supplies a raw interleaved-I/Q complex64
constellation stream and each element becomes an output point directly,
oversampled by --sps and cycled. That expresses any modulation an enum doesn't
— pi/4-QPSK, QAM, APSK — since you compute the constellation yourself and pass
it. symbols honours --pulse rrc like the built-in modulations.
In Python the constellation is the symbols= keyword on the composer Synth
(Synth(type="symbols", symbols=iq, sps=8)); on the low-level _SynthEngine
it is attached with set_symbols() after construction. See the
Python API for a worked pi/4-QPSK example, and the
Symbols gallery walkthrough for pi/4-QPSK and
16-QAM constellations with rect vs RRC pulses.
Framing — preamble, sync word, CRC¶
--acq-code / --sync / --crc describe a frame's bit layout:
Setting --acq-code or --sync is what makes a source framed. --crc on
its own does not — it defaults to crc16, so treating it as intent would put a
trailer on every plain bit pattern ever generated.
One Field per sequence¶
--acq-code, --data-code, --sync, --data and --fill each take one Field —
literal bits, hex, or a generated sequence — in the one grammar every face
reads. Its forms, numbers, limits and refusals are
Fields: bits as text.
A *REPS suffix repeats the preamble — --acq-code 'pn:127:7*4' — and only
the preamble. Quote it, or the shell globs the *.
wfmgen --type bits --data 10110010 --sync pn:1023:10 \
--sps 4 --record run.json -o framed.cf32
# A DSSS burst with BOTH of its codes generated: a 127-chip preamble a
# receiver correlates against, and a 31-chip code spreading the payload.
wfmgen --type dsss --acq-code 'pn:127:7*4' \
--data-code pn:31:5 --sync 1111100110101 --data 0xa5c3 \
--sps 2 --snr 8 --snr-mode esno -o burst.cf32
Every number takes hex or decimal, so a tap mask reads as 0x409 the way it
does in the literature.
Why generated Fields exist. --sync 1111100110101 is fine for a Barker-13. A
1023-chip sync word is not, and a --record of one is a 1023-character
string that says nothing about what produced it. --record stores the Field
itself, so a capture is reproducible from its metadata — which is the point of
the kinds, not a shorthand for them:
The payload is a data source¶
The payload is --data — a Field like the other three — or
--data-from-file, sent frame by frame as Data
describes: each frame carries the next --data-len bits, so a long source is
a multi-frame record whose length is the data's, and nothing is cycled.
wfmgen --type bits --modulation bpsk --data pn:65535:16 --data-len 4096 \
--fill 0 --sync 1111100110101 --sps 4 -o framed.cf32
--type bpsk/qpsk/pn frame the same data and map its bits as the type
names, so a framed --type qpsk is Gray-coded QPSK over the frame and there is
no --modulation to disagree with it; a coded frame
reaches them too. For --type bits, --modulation maps the frame to BPSK or
QPSK, so a framed unspread waveform is one command:
wfmgen --type bits --modulation bpsk --data 10110010 \
--acq-code '10101010*4' --sync 1111100110101 --crc crc16 \
--sps 4 -o framed.cf32
A frame needs a data source: the framing flags without one are refused,
naming --data, rather than writing an unframed waveform at exit 0. A type
that carries no bit stream at all — tone, noise, chirp, symbols —
cannot be framed, with or without one.
The Python Synth and Segment take the frame as a description, frame=
(a FrameDesc), with acq_code= and acq_reps= for a DSSS preamble;
--sync and --crc build that same description for you, and --record
stores it, so --from-file rebuilds the framed waveform byte for byte. A
scene's old "sync" and "crc" keys are refused, naming "frame".
The layout is one C descriptor (native/inc/doppler/wfm/wfm_frame.h) read by the
generator that builds it and the measurer that scores it — see
Receiver Test Harness §7.
PN sequences & MLS¶
--type pn emits a maximum-length sequence; --pn-length n sets the LFSR
register length (2 to 64, period 2ⁿ−1). The register, polynomial, and
--pn-poly are full 64-bit. Leave --pn-poly 0 and the engine selects a
primitive polynomial that yields a true MLS for that length (a built-in table of
verified primitive polynomials for every length 2..64) — verified by period,
balance, and the thumbtack autocorrelation. Supply --pn-poly only to force a
specific tap set.
wfmgen --type pn --pn-length 7 --sps 1 --count 127 # one full period (2⁷−1)
wfmgen --type pn --pn-length 11 --sps 4 # length-11 MLS, 4× oversampled
wfmgen --type pn --pn-length 7 --lfsr fibonacci # Fibonacci realization
# Force a specific tap set instead of the auto-selected one. 0x41 is the
# table's own entry for length 7 (x⁷+x⁶+1), so this is byte-identical to
# --pn-poly 0 above -- which is how you check a polynomial you were handed.
wfmgen --type pn --pn-length 7 --pn-poly 0x41 --sps 1 --count 127 -o pn7.cf32
--lfsr selects the LFSR realization: galois (default, internal XOR
feedback) or fibonacci (external XOR of the tapped bits). Both use the same
primitive polynomial and have the same period 2ⁿ−1; they differ only in the
chip sequence/phase. The Fibonacci taps are derived from the same polynomial, so
--pn-poly 0 still auto-selects the MLS for either mode.
RRC pulse shaping — band-limited carriers¶
The modulated types (pn / bpsk / qpsk / bits / symbols) default to
rectangular sample-and-hold chips — a wide sinc² spectrum. Set
--pulse rrc for root-raised-cosine shaping: the symbol stream is filtered
to a band-limited channel, so a realistic carrier (e.g. a WCDMA QPSK downlink at
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.
wfmgen --type qpsk --sps 8 --pulse rrc --rrc-beta 0.22 --count 100000 -o wcdma.cf32
# A longer span truncates the RRC tails less, so the stopband is deeper --
# at the cost of `--rrc-span * --sps` more samples of filter delay.
wfmgen --type qpsk --sps 8 --pulse rrc --rrc-beta 0.22 --rrc-span 16 \
--count 100000 -o wcdma-span16.cf32
Engine parameter reference¶
| Flag | Type | Default | Meaning |
|---|---|---|---|
--type |
tone noise pn bpsk qpsk chirp bits symbols dsss |
tone |
waveform |
--fs |
float (Hz) | 1.0 |
sample rate (default 1.0 ⇒ --freq/--f-end are normalised, cycles/sample) |
--freq |
float (Hz) | 0 |
frequency offset from baseband (mixed by the LO); chirp start |
--f-end |
float (Hz) | 0 |
chirp end frequency (--type chirp only) |
--snr |
float (dB) | 100 |
SNR; metric chosen by --snr-mode (≈clean at 100) — see Levels & SNR |
--snr-mode |
auto fs ebno esno |
auto |
how --snr is interpreted |
--seed |
uint32 | 0 |
PRNG / LFSR seed (deterministic) |
--sps |
int | 1 |
samples per symbol (*psk/bits/symbols) / per chip (pn) |
--pn-length |
int (2..64) | 15 |
LFSR register length → period 2ⁿ−1 |
--pn-poly |
uint64 | 0 |
LFSR polynomial; 0 ⇒ auto-pick the MLS polynomial |
--lfsr |
galois fibonacci |
galois |
LFSR realization (same polynomial/period, different sequence) |
--data |
Field | — | the payload's data source, e.g. 10110101, 0xAA55, pn:1024:15 (or --data-from-file) |
--modulation |
none bpsk qpsk |
bpsk |
bits: how the pattern maps to symbols |
--symbols-file |
path (cf32) | — | symbols: raw interleaved-I/Q complex64 constellation stream |
--acq-code |
Field | — | preamble code; *REPS repeats it, e.g. 'pn:127:7*4' |
--data-code |
Field | — | dsss: payload spreading code |
--sync |
Field | — | frame-sync word (optional) |
--crc |
none crc16 |
crc16 |
dsss: CRC-16 trailer over the payload bits |
--pulse |
rect rrc |
rect |
pulse shape; rrc = band-limited RRC shaping |
--rrc-beta |
float | 0.35 |
RRC roll-off (--pulse rrc) |
--rrc-span |
int | 8 |
RRC filter support in symbols (--pulse rrc) |
--count |
int | 0 |
on-time in samples; 0 derives it (the sources' length, else 1024) |
Levels & SNR¶
Amplitude & full-scale¶
The amplitude invariant is unit average power: every waveform is normalised
so its mean power is 1.0. That — not a constant envelope — is what the rest
of the system is built on. It is the reference the SNR math uses (signal power
≡ 1, so the noise σ falls straight out of the target SNR), and the level you
control is the SNR, not a signal gain. The I/Q full-scale is ±1.0 per axis
(→ the largest integer code).
Today's built-in types all happen to be constant-envelope, so for them the peak equals the average and they sit exactly at ±1.0 — but that is a property of the current set, not a design assumption:
--type |
Sample values | Envelope | Avg. power |
|---|---|---|---|
tone |
exp(j·2πft) |
constant, mag 1 | 1.0 |
bpsk / pn |
±1 (real axis) |
constant, mag 1 | 1.0 |
qpsk |
(±1/√2, ±1/√2) |
constant, mag 1 | 1.0 |
chirp |
exp(j·φ(t)), φ′ ramps freq→f_end |
constant, mag 1 | 1.0 |
noise |
complex Gaussian, σ = 1/√2 per axis |
Gaussian, PAPR > 0 | 1.0 |
symbols |
whatever you supply (e.g. 16-QAM) | user-defined | user-defined |
Don't rely on |z| = 1. A pulse-shaped (RRC), QAM, or OFDM waveform has a
peak-to-average power ratio (PAPR) above 0 dB: at unit average power its
peaks run well past ±1.0. noise, and any signal-plus-noise sum, already do.
Scaling to the wire, and headroom¶
cf32 / cf64 carry samples verbatim and never clip — peaks past ±1.0 are
preserved. The integer types scale by 2^(N-1), round to nearest, and
saturate to the type's range — the cvt converters do it, so a capture's codes
are exactly what doppler.cvt.F32ToI16().steps() produces:
--sample-type |
Map | -1.0 → |
+1.0 → |
|---|---|---|---|
ci32 |
round(v·2³¹) |
-2147483648 |
2147483647 |
ci16 |
round(v·2¹⁵) |
-32768 |
32767 |
ci8 |
round(v·2⁷) |
-128 |
127 |
-1.0 lands exactly on the most negative code; +1.0 scales to 2^(N-1),
one past the maximum by construction, and saturates. That asymmetry belongs to
two's complement, not to the scaling — and saturating is what makes it safe.
So clipping is governed by PAPR, not by something being "signal" vs "noise":
- A constant-envelope, clean signal (a tone/PSK/PN at
--snr 100) fills the integer range, and the only samples that saturate are the ones that reach exactly+1.0— which lose half an LSB, not a peak. - Any PAPR > 0 dB content clips at the rails — added noise (at
--snr 0, noise power = signal power, ~⅓ of integer I/Q components already saturate) and any pulse-shaped / QAM / OFDM mode. Such a signal needs headroom:--headroom <dB>(andWriter(headroom=…)in Python) scales the whole output down to−HdBFS so the peaks fit. It is a single common gain, so it is SNR-invariant — it moves only the absolute level, not any power ratio — and0dB (the default) is a bit-exact no-op. An integer capture that clips reports the exact backoff to use (remedy: --headroom N). You can also just carry envelope-varying signals as a float type (cf32/cf64), which never clips.
Reader (see Output & file types) inverts the same map, so a float
round-trip is exact, and an integer round-trip is exact wherever the value
lands on a code and did not clip — which, because full scale is a power of
two, includes every dyadic value: 0.5 comes back as 0.5.
Clipping is observable, not silent. Two flags turn "the capture looks
wrong" into a number, and they are what to reach for before anything else:
--clip-report prints the clipped fraction and the peak to stderr, and
--clip-error makes clipping an exit status — which is what you want in
a script that generates a capture and must not hand on a broken one.
# What does this shaped, noisy QPSK actually do to a 16-bit integer capture?
wfmgen --type qpsk --sps 8 --pulse rrc --snr 6 --count 20000 \
--sample-type ci16 --clip-report -o shaped.ci16
# wfmgen: warning: ci16 output clipped — peak is +13.4 dB over full scale.
# remedy: --headroom 14, or --sample-type cf32.
# clipped 42.41% of I/Q components
# Refuse to write a clipped capture at all: this one EXITS NON-ZERO.
! wfmgen --type qpsk --sps 8 --pulse rrc --snr 6 --count 20000 \
--sample-type ci16 --clip-error -o shaped.ci16 # clipped: exits non-zero
# Take the remedy the report named, and it passes.
wfmgen --type qpsk --sps 8 --pulse rrc --snr 6 --count 20000 \
--sample-type ci16 --headroom 14 --clip-error -o clean.ci16
# --level places a source in dBFS. Backing a source off is how a scene puts
# one signal below another; --headroom scales the composite AFTER summing.
wfmgen --type tone --freq 1e5 --fs 1e6 --level -10 --count 4096 -o quiet.cf32
--level is per-source and --headroom is per-output: a level difference is
part of the scene (and survives into every sweep point a
Plan renders), while
headroom is a single common gain applied on the way to the wire.
>>> import numpy as np
>>> from doppler.wfm import Synth
>>> # the invariant is unit *average* power (here a clean, constant-envelope QPSK)
>>> x = Synth(type="qpsk", sps=1, snr=100.0).steps(4096)
>>> bool(np.allclose(np.mean(np.abs(x) ** 2), 1.0))
True
>>> # add noise (or pulse-shaping / QAM) and peaks exceed full-scale:
>>> y = Synth(type="qpsk", sps=1, snr=0.0).steps(100000)
>>> float(np.mean(np.abs(y.real) > 1.0)) > 0.1 # many samples clip in ci*
True
SNR & noise¶
--snr is applied as AWGN; --snr-mode chooses the reference:
| Mode | --snr means |
Use for |
|---|---|---|
fs |
SNR over the full sample rate (in-band power / noise power) | tones, wideband |
esno |
Es/No — energy per symbol over noise PSD | modulated (*psk) |
ebno |
Eb/No — energy per bit over noise PSD | link-budget work |
auto |
esno for bpsk/qpsk/dsss, fs for the other six |
the sensible default |
auto splits the nine --types exactly once, and this is the one place that
split is written out — the schema and the API page defer here rather than
carry their own copy:
auto resolves to |
types |
|---|---|
esno (Es/N0) |
bpsk · qpsk · dsss |
fs (over full scale) |
tone · noise · pn · chirp · bits · symbols |
bits is on the fs side, which surprises people: a bits waveform is
modulated (--modulation bpsk|qpsk), but its symbol rate is whatever the
frame and --sps make it, so there is no symbol energy the engine can refer
to without being told. Ask for one explicitly with --snr-mode esno if that
is what you want. Verified by construction: --type bits renders
byte-identically under --snr-mode auto and --snr-mode fs, and differently
under esno.
--snr 100 (the default) is clean — snr ≥ 100 dB generates no AWGN at
all, so a clean waveform pays no noise cost. Lower --snr to add noise; the
signal stays at unit average power, so the per-axis noise σ is
σ = sqrt(1 / (2·10^(snr_fs/10))), where Es/No and Eb/No are first converted to
an over-fs SNR using 10·log10(sps) (and, for Eb/No, the bits/symbol: 1 for
BPSK/PN, 2 for QPSK). (--type noise always generates AWGN.) Likewise
--freq 0 skips the LO — the carrier is a constant 1 — so a clean baseband
waveform is pure signal generation.
dsss: the symbol is the outer data symbol
For type="dsss" the Es/N0 reference is the outer data symbol
(len(data_code) chips × sps samples) — what a despreader's
data-aided estimator measures — not the chip a hand-spread bits
pattern would get. Full semantics: DSSS bursts.
Same QPSK at three references
DSSS bursts¶
The canonical spread-spectrum test capture — N bursts at a specific
data-symbol Es/N0, each a repeated PN preamble followed by a payload spread
with a second code, randomly placed with a minimum gap, over a continuous
noise floor — is one Segment:
import numpy as np
from doppler.wfm import STAGE_CRC16, Composer, FrameDesc, Segment
rng = np.random.default_rng(0)
acq = rng.integers(0, 2, 128, dtype=np.uint8) # preamble code A
dat = rng.integers(0, 2, 25, dtype=np.uint8) # data-spreading code B
pay = rng.integers(0, 2, 200, dtype=np.uint8) # payload bits
BARKER13 = np.array([1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 1, 0, 1], np.uint8)
# The frame is a description: Barker-13 | the payload | a CRC-16 over it
frame = FrameDesc()
frame.add_field("sync", BARKER13)
frame.add_data("payload", len(pay))
frame.add_derived("crc", 16)
frame.add_stage_over(STAGE_CRC16, "payload", "crc")
burst = Segment(
type="dsss", fs=4e6, sps=4, seed=1,
snr=10.0, snr_mode="esno", # data-symbol Es/N0 (see below)
acq_code=acq, acq_reps=4, # preamble: code A x 4
data_code=dat, # payload spread: code B
frame=frame, data=pay, # one burst of the data source
delay_samples=(2_000, 10_000), # arrival jitter before each burst
off_samples=(4_000, 12_000), # trailing gap, min 4k samples
repeats=5, # -> a 5-burst train
)
comp = Composer([burst])
x = comp.compose()
This page is the one home for everything type="dsss" means; the other
guide pages carry a summary and link here.
The frame — acq_code / acq_reps / sync / crc — is not DSSS-specific:
the same four describe an unspread type="bits" waveform, and the layout comes
from one descriptor either way. See
Framing.
What is DSSS's own is everything below: the second code, the chip clock, and
the Es/N0 that refers to the outer data symbol.
The burst anatomy¶
A dsss segment is one complete burst honouring the
BurstDemod/BurstDespreader frame contract:
[ acq_code x acq_reps | (sync | payload | crc16(payload)) ^ data_code ]
unmodulated preamble every frame bit spread by code B
- The preamble is
acq_coderepeatedacq_repstimes, transmitted unmodulated — the coherent pull-in target the receiver's acquisition correlates against. - The frame is
sync | payload | CRC-16(CCITT, over the payload bits — the samedoppler.wfm.crc16kernel the demod validates), each bit XOR-spread by the fulldata_code. The sync word and the CRC are optional fields of the frame description; a frame with neither is the payload alone. - Codes are plain 0/1 arrays of any length — no
2^n - 1restriction — so the geometry matches whatever the receiver expects. The payload is a data source (keyworddata=; JSON key"data"): one burst perdata_lenchunk, the whole of it whendata_lenis 0. spsis samples per chip; the burst's span is intrinsic (n_chips x spssamples a burst), sonum_samplesis derived, a count given beside it is refused, and a replay derives it again. (A dsss source inside a multi-sourcesum()keeps the segment's explicitnum_samples.)
Either half may be absent, not both. A preamble alone (acq_code, no
sync, no payload) is a valid burst, the stimulus for an acquisition test;
a frame with no preamble is valid too. A frame with no data_code to spread
it, a burst with neither half, and a continuous stream with no data_code
are refused, each naming the code to give:
>>> from doppler.wfm import Composer, field_bits
>>> pre = Composer(type="dsss", sps=2, acq_code=field_bits("pn:31:5"),
... acq_reps=3)
>>> len(pre.compose()) == 3 * 31 * 2 # the preamble, and only it
True
>>> try:
... Composer(type="dsss", sps=2, num_samples=64)
... except ValueError as exc:
... print(str(exc).split(":")[0])
a dsss burst has nothing to send
Es/N0 that means what the receiver measures¶
snr_mode="esno" (and auto) targets the Es/N0 of the outer data
symbol — len(data_code) chips × sps samples — which is the number a
despreader's data-aided estimator recovers. The 10*log10(sf*sps)
spreading conversion happens internally. This matters because a hand-spread
bits pattern gets the chip as its symbol: the same --snr 10 would
land 10*log10(sf) dB apart between the two spellings. The payload is
BPSK, so ebno and esno coincide. (General SNR model:
Levels & SNR.)
Random placement, deterministically¶
repeats=5 plays the segment five times back-to-back; each instance is
delay | burst | gap (see Scenes):
delay_samples=(lo, hi)re-draws per instance — per-burst arrival jitter.off_samples=(lo, hi)re-draws per instance — inter-burst spacing, withlothe guaranteed minimum gap. (Spacing between bursts composes asoff(k) + delay(k+1).)- The AWGN is fresh per instance (bursts never share a noise realization) while the signal — codes, payload, code phase — is fixed.
- Every draw is a deterministic hash, so
to_json()/--recordstores the ranges and a replay is byte-identical.
Gaps carry the noise floor¶
By default the delay and trailing gaps are not silent: every source's
additive-AWGN term keeps running through them — the same stream, the same
power — so the inter-burst region is the channel, exactly what an
acquisition/CFAR front-end needs to see for honest threshold tuning
(gh-409). A clean scene (no AWGN anywhere) still renders exact-zero gaps,
and gap_noise="off" restores hard zeros per segment:
floor = 10 ** (-(10.0 - 10 * np.log10(25 * 4)) / 10) # esno → over-fs power
gap = x[-4000:] # inside the last gap
assert abs(float(np.mean(np.abs(gap) ** 2)) - floor) / floor < 0.2
On the CLI that switch is --gap-noise, and the difference is measurable
in the gap itself — the noise floor at 10 dB SNR, or exact zeros:
wfmgen --type bpsk --snr 10 --count 2048 --off 1024 -o gap-auto.cf32
wfmgen --type bpsk --snr 10 --count 2048 --off 1024 --gap-noise off -o gap-off.cf32
python3 -c "
import numpy as np
for f in ('gap-auto.cf32', 'gap-off.cf32'):
g = np.fromfile(f, dtype=np.complex64)[2048:] # the trailing gap only
print(f, 'gap power', round(float(np.mean(np.abs(g) ** 2)), 4))"
# gap-auto.cf32 gap power 0.1036
# gap-off.cf32 gap power 0.0
Use off when a downstream tool defines a burst by "where the samples are
non-zero"; leave it on auto for anything that has to pick the burst out of
a channel, which is the case the default is built for.
Ground truth for free¶
The engine knows every drawn instance timing, and the SigMF sidecar emits one annotation per burst instance at the exact rendered position — so a detector can be scored against the capture without ever walking it:
import json
meta = json.loads(comp.to_sigmf(sample_type="cf32", fs=4e6))
starts = [int(a["core:sample_start"]) for a in meta["annotations"]]
assert len(starts) == 5 # one per instance, in order
Decoding the train back — acquisition over the whole capture, the burst held across block boundaries, every frame checked — is the DsssBurstReceiver walkthrough.
The same burst on the other two faces¶
The JSON scene carries the same keys (codes as Field strings, ranges as
pairs, "repeats": 5; "gap_noise"/"delay_samples" only when
non-default), and on the command line a train of the same shape is one
command. Here every run of bits is a Field rather than the
random arrays above:
wfmgen --type dsss --fs 4e6 --sps 4 --seed 1 --snr 10 --snr-mode esno \
--acq-code 'pn:127:7*4' --data-code pn:31:5 \
--sync 1111100110101 --data pn:200:9 \
--delay 2000:10000 --off 4000:12000 --repeats 5 \
--record train.json -o train.cf32
# the record is the resolved scene, and it replays byte for byte
wfmgen --from-file train.json -o replay.cf32
python3 - <<'EOF'
import pathlib
import sys
a = pathlib.Path("train.cf32").read_bytes()
b = pathlib.Path("replay.cf32").read_bytes()
sys.exit(a != b)
EOF
--file-type sigmf writes the annotated sidecar. The three faces render
byte-identically, and the
DsssBurstReceiver walkthrough
asserts it on a train sent from a frame description: the command line, a
scene and Python objects, then decoded and checked against that
description.
Continuous asynchronous DSSS¶
A burst is a bounded frame with an integer number of chips per data symbol (one bit spread by exactly one code period). Some links instead run a continuous stream: the spreading code repeats forever and the data rides it at a symbol rate that is independent of the code epoch — so the chips per symbol are non-integer and symbol edges land mid-code:
Adding symbol_rate= (Hz) to a dsss source selects this mode. There is no
preamble/sync/CRC frame — only the spreading code and the outer data clock:
import numpy as np
from doppler.wfm import Composer, Segment
fs, spc, chip_rate = 6.138e6, 2, 3.069e6
code = np.array([1, 1, 1, 0, 0, 1, 0, 1, 0, 1, 1, 0, 0, 1, 1], np.uint8)
stream = Segment(
type="dsss", fs=fs, sps=spc, seed=1,
symbol_rate=2700.0, # data clock, independent of the chip clock
data_code=code, # spreading code; repeats forever
snr=10.0, snr_mode="esno", # Es/N0 of the 2700 bps data symbol
num_samples=40_000,
)
x = Composer([stream]).compose()
cps = (fs / spc) / 2700.0 # chips per symbol
assert abs(cps - 1136.667) < 1e-3 # non-integer: the asynchronicity
Here sps is samples per chip (as for a burst), so the chip rate is
fs / sps and chips_per_symbol = (fs / sps) / symbol_rate — 1136.667 at
these numbers, spanning 1137, 1137, 1136 chips in a repeating cycle with
boundaries falling inside a code period. Unlike a burst, the stream has no
intrinsic length, so num_samples (--count) is honoured verbatim.
Three data sources¶
The data modulating the code has three legitimate origins, selected the same way on every face:
| source | selector | what it emits |
|---|---|---|
| PRBS (default) | — | bits from the source's seeded PN — endless, and a receiver regenerates them to score BER |
| code-only | dsss_code_only=True (--code-only) |
constant bit 0 → the pure spreading code, +code polarity, no data transitions |
| data | data= (--data, --data-from-file) |
a data source, one bit per data symbol, sent once |
The default PRBS is the useful one for a stream with no finite truth
array: the data is a pure function of (pn_poly, seed, pn_length, lfsr), so a
scorer regenerates exactly the transmitted bits — chip i carries data symbol
floor(i / chips_per_symbol), and that bit is the floor(i/cps)-th PN output.
On a clean capture the chip-centre sample signs match bit-for-bit:
from doppler.wfm import PN, mls_poly
clean = Segment(
type="dsss", fs=fs, sps=spc, seed=1, symbol_rate=2700.0,
data_code=code, snr=100.0, snr_mode="fs", num_samples=40_000,
)
xc = Composer([clean]).compose()
L = 15 # default --pn-length (register size)
bits = PN(poly=mls_poly(L), seed=1, length=L).generate(64)
i = np.arange(len(xc) // spc) # one entry per chip
sym = np.floor(i / cps).astype(int)
chip = code.astype(int)[i % len(code)] ^ bits[sym] # transmitted chip bit
centres = i * spc + spc // 2
assert np.array_equal(np.sign(xc[centres].real).astype(int), 1 - 2 * chip)
(--seed-advance all makes the per-epoch signal seed seed + epoch, so a
long multi-epoch scorer must track the epoch; the safe scoring modes are the
default none and noise. Code-only and payload are seed-independent.)
Es/N0 references the data symbol, correctly¶
snr_mode="esno"/auto targets the outer data symbol, which spans
fs / symbol_rate samples — not sf * sps. The referencing SSOT takes
fs so a non-integer symbol span is exact (a truncated "effective spreading
factor" would silently misplace the noise floor). Nothing to set; it is
picked up from symbol_rate.
The CLI face¶
wfmgen --type dsss --fs 6138000 --sps 2 --seed 1 \
--symbol-rate 2700 --data-code 111001010110011 \
--snr 10 --snr-mode esno --count 40000 \
--record cont.json -o cont.cf32
# The same stream with the data switched OFF -- the pure repeating code,
# which is what you correlate against when bringing a receiver up.
wfmgen --type dsss --fs 6138000 --sps 2 --seed 1 \
--symbol-rate 2700 --data-code 111001010110011 --code-only \
--snr 10 --snr-mode esno --count 40000 -o code-only.cf32
--code-only selects code-only (a scene's "code_only": true); a supplied
--data/--data-from-file selects a data source. Incompatible combinations
are rejected (exit 2), not silently ignored: --symbol-rate with the
burst-frame flags (--acq-code, --sync, --crc), --code-only together
with a data source, --symbol-rate without --data-code, a non-positive
--symbol-rate, and a chip rate fs / sps below --symbol-rate. A stream
sends at least one chip per data symbol, so the default --fs 1.0 (a
normalised rate) with a --symbol-rate in Hz is refused, naming the three
values; give the real --fs. The old spelling --data none is refused naming
--code-only: --data is now a frame's data source.
SigMF distinguishes the two modes¶
Both modes carry the "dsss" core:label, so the sidecar adds a
wfmgen:symbol_rate key for a continuous source (a burst omits it), and
wfmgen:data: "none" for a code-only stream — enough for a scorer to know the
outer symbol clock and how the code is (un)modulated:
import json
meta = json.loads(Composer([stream]).to_sigmf(sample_type="cf32", fs=fs))
assert meta["annotations"][0]["wfmgen:symbol_rate"] == 2700
A continuous stream has no defined end, so --file-type sigmf (whose sidecar
is written after the capture) requires a finite --count, not --continuous.
A burst train in a Plan¶
A burst train prepares: repeats, ranged gaps and delay, and a bundled noisy
source all go into a Plan. A ranged per-source freq, snr, level or
f_end, or a ranged num_samples, raises ValueError at prepare(), because
each would invalidate the cached render. The
DSSS BER Monte Carlo
sweeps one.
Coded frames — --frame FILE¶
A coded frame is a description, and --frame FILE is how wfmgen takes
one. --acq-code, --sync and --crc spell one frame, the common
[preamble × reps | sync | payload | crc]; anything else — a marker, an
outer code, a randomiser, an interleaver, an inner code, a field of your own
— is a list of fields in wire order and a list of stages, each naming
the span of fields it covers.
The spans are the point. They are not a chain of filters applied to "the frame": a marker, a preamble and a sync word are things a receiver finds by correlation, so they must look the same in every frame, and the stages that scramble or code the data have to reach past them. A chain gets three of those boundaries right and the fourth wrong, in the direction that still decodes against a receiver of your own construction and syncs to nothing.
The file is exactly what a scene's "frame" key holds, and --record
writes it there, so --from-file rebuilds a coded capture byte for byte.
This is block interleaving, not I/Q interleaving
Everywhere else in these guides, interleaved describes the sample
layout on disk — raw files store I, Q, I, Q, … and --symbols-file
reads that layout back. That is a memory format and has nothing to do
with this section.
An interleave stage is a block interleaver over bits: it permutes
the frame's data group before transmission so that a burst of channel
errors arrives spread across the outer code's codewords instead of
destroying one of them. It changes where errors land and never
touches the sample layout. The two meanings share a word and no code.
A CCSDS CADU¶
A 223-octet Transfer Frame behind the 0x1ACFFC1D marker, with the outer
code, the randomiser and the inner code — no preamble and no sync word — is
a CADU. It is a configuration, not a mode wfmgen switches into:
{
"fields": [
{"name": "asm", "spec": "0x1ACFFC1D"},
{"name": "payload", "spec": "pn:1784:11"},
{"name": "rs_parity", "bits": 256, "derived_by": 1}
],
"stages": [
{"kind": "rs", "first_field": 1, "n_fields": 2, "depth": 1},
{"kind": "randomise", "first_field": 1, "n_fields": 2, "depth": 1},
{"kind": "conv", "first_field": 0, "n_fields": 3,
"emit_num": 2, "emit_den": 1}
]
}
wfmgen --type bits --modulation bpsk --frame cadu.json \
--sps 1 --record cadu-run.json -o cadu.cf32
# the record carries the description, and replays it byte for byte
wfmgen --from-file cadu-run.json -o replay.cf32
python3 - <<'EOF'
import pathlib
import sys
a = pathlib.Path("cadu.cf32").read_bytes()
b = pathlib.Path("replay.cf32").read_bytes()
sys.exit(a != b)
EOF
The run is exactly one frame, 4144 samples: 32 marker bits plus a 2040-bit
data group (223 + 32 octets under RS(255,223)) is 2072, doubled by the
inner code, at one sample per symbol. A frame of fixed bits is a finite
source of one frame, sent once: it sets the run's length, so --count
beside it is refused. The same frame again is --repeats N, a gap after it
--off, and different frames are more data: give the description a
data:LEN field and a data source
fills it, frame by frame.
The gallery page renders what this repairs and what it refuses: A CCSDS CADU, as a Frame Description.
The file, key by key¶
A field is its bits or a stage's output:
| Key | Meaning |
|---|---|
name |
What a receiver asks for when it slices a capture; optional |
spec |
Its bits, as a Field: 0x1ACFFC1D, 10110, pn:1784:11, *REPS |
bits |
A DERIVED field's length — a CRC trailer, the outer code's parity |
derived_by |
The stage that fills a derived field: that stage's index plus one |
A stage is a kind and the fields it covers, listed in the order the stages are applied:
| Key | Meaning |
|---|---|
kind |
crc16, rs, randomise, interleave, conv — or a number from 0x1000 up, your own kind |
first_field, n_fields |
The span it covers, by index; a derived field it fills is the last field of that span |
depth |
rs: codewords interleaved in the codeblock (1 2 3 4 5 8). randomise: which generator — 1 is 131.0-B-6 §10.4.1's 131071-bit sequence, 2 §10.4.2's legacy 255-bit one. interleave: rows |
unit_bits |
interleave: bits per permuted unit — 8 behind an rs stage, because RS is a code over GF(256) |
emit_num, emit_den |
A stage that emits a new stream (conv is 2, 1). It must cover the whole frame, and there is at most one |
The two randomisers are not interchangeable on the air: only the
matching receiver derandomises a given waveform, which is why the stage
carries which one. An rs stage's depth is intrinsic to the codeblock
and is not an interleave stage, which is a permutation applied over
whatever span it is given — the two share a name and no implementation. See
Interleaving for the transform and the
frame-error rates that put a number on it, and
The FEC Receive Half for the decoder.
What is refused¶
A description is the whole frame, so the flags that spell the common
frame are refused beside it rather than silently dropped: --sync,
--crc and — on an unspread type — --acq-code. A data source is not one
of them: --data fills the description's own data:LEN field.
A frame whose stages cannot run over the spans they are given is refused
before anything is generated, with the reason, never padded: an rs stage
needs exactly 223 × depth octets (virtual fill is not implemented), and an
interleave stage a whole number of depth × unit_bits units.
# 5 rows over 1784 + 256 = 2040 bits, 8 bits a unit: 2040 / 40 divides
python3 - <<'PY'
import json
frame = json.load(open("cadu.json"))
frame["stages"].insert(2, {"kind": "interleave", "first_field": 1,
"n_fields": 2, "depth": 5, "unit_bits": 8})
json.dump(frame, open("cadu-ilv.json", "w"))
frame["stages"][2]["depth"] = 2 # 2040 / 16 does not divide
json.dump(frame, open("bad.json", "w"))
PY
wfmgen --type bits --modulation bpsk --frame cadu-ilv.json \
--sps 1 -o cadu-ilv.cf32
! wfmgen --type bits --frame bad.json -o bad.cf32 # refused, as documented
In a scene, and from Python¶
A scene carries the same description under a source's frame key, and
--frame FILE reads exactly that value. The description is the whole frame,
so the common frame's keys do not belong beside it: a sync or an
unspread preamble is refused on every face. A data source ("data",
data=) is not a second spelling of anything in it — it fills the
description's data:LEN field, when it has one.
{
"version": 1,
"segments": [
{
"type": "bits", "fs": 1e6, "sps": 4, "modulation": "bpsk",
"snr": 100.0, "snr_mode": "fs",
"frame": {
"fields": [
{ "name": "hdr", "spec": "0101110001011100" },
{ "name": "payload", "spec": "101010101010101010101010" },
{ "name": "crc", "bits": 16, "derived_by": 1 }
],
"stages": [
{ "kind": "crc16", "first_field": 1, "n_fields": 2 }
]
}
}
]
}
hdr is a field of the caller's own bits at a position the caller chose,
outside the CRC's cover. Spelled as the common frame instead — hdr as its
sync word — it is the same frame, and the two are byte-identical, because
--sync/--crc build their frame through dp_wfm_frame_fixed(), which is a
description too:
wfmgen --from-file frame.json -o from_desc.cf32
wfmgen --type bits --data 101010101010101010101010 \
--sync 0101110001011100 --crc crc16 \
--fs 1e6 --sps 4 --snr 100 -o from_flags.cf32
python3 - <<'EOF'
import pathlib
import sys
a = pathlib.Path("from_desc.cf32").read_bytes()
b = pathlib.Path("from_flags.cf32").read_bytes()
print("byte-identical" if a == b else "DIFFER")
sys.exit(0 if a == b else 1)
EOF
A derived field must name its producer: derived_by
A field with a declared length and no bits — a CRC trailer, a block of
check symbols — is derived, and in JSON it must say which stage
produces it: derived_by is the stage's index plus one, so 1 means
stage 0. The +1 is what makes a zero mean "the caller supplies this
field" rather than "the output of stage 0".
Omit it and the scene is refused, naming what is missing — the geometry is decided in one place, so the CLI, the C API and Python all say the same thing:
error: this frame description does not lay out: a field that declares a
length but supplies no bits is DERIVED and must name the stage that
fills it (`derived_by` = the stage's index plus one), …
The C builder cannot make this mistake: dp_wfm_frame_add_stage() takes
the cover by name and wires the producer itself.
From C, wfm_source_t.frame takes a wfm_frame_desc_t *, built by name
with dp_wfm_frame_add_field() / dp_wfm_frame_add_derived() /
dp_wfm_frame_add_stage(); the
gallery page is a whole program.
From Python, a source's frame= takes the description as a FrameDesc
(or a Frame), built by name as in C. A str is refused: a description
written as JSON is the scene's "frame" key, which Composer.from_json()
reads with the same C code wfmgen --from-file runs. The source keeps its
own copy, and frame= is an input: read it back from Composer.to_json().
import numpy as np
from doppler.wfm import STAGE_CRC16, Composer, FrameDesc, Segment, field_bits
d = FrameDesc() # fields in wire order, by name
d.add_field("hdr", field_bits("0101110001011100")) # a header of your own
d.add_field("payload", field_bits("101010101010101010101010"))
d.add_derived("crc", 16) # 16 bits a stage will fill
d.add_stage_over(STAGE_CRC16, "payload", "crc") # the span it covers
common = {
"type": "bits",
"fs": 1e6,
"sps": 4,
"modulation": "bpsk",
"snr": 100.0,
"snr_mode": "fs",
}
framed = Segment(**common, frame=d)
x = np.asarray(Composer([framed]).compose())
The whole script, src/doppler/examples/wfmgen_frame_demo.py, checks that
the frame reaches the samples and that the record rebuilds them; its C twin
is native/examples/wfmgen_frame_demo.c.
A data field from Python is add_data(name, LEN): the field the text
data:LEN describes, whose bits the source's data= supplies one
LEN-bit chunk per frame, padded from fill= at the end. add_field takes
bits, and a data field has none until it is sent, so it has its own method.
In C it is dp_frame_add_data().
import numpy as np
from doppler.wfm import STAGE_CRC16, Composer, FrameDesc, Segment
barker13 = np.array([1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 1, 0, 1], np.uint8)
desc = FrameDesc()
desc.add_field("sync", barker13)
desc.add_data("payload", 24) # data:24, filled per frame
desc.add_derived("crc", 16)
desc.add_stage_over(STAGE_CRC16, "payload", "crc")
payload = np.random.default_rng(0).integers(0, 2, 60, dtype=np.uint8)
seg = Segment(type="bits", sps=1, modulation="bpsk", frame=desc,
data=payload, fill=np.array([1, 0], np.uint8))
x = Composer([seg]).compose()
# 60 bits are two 24-bit frames and a third padded with fill
assert len(x) == 3 * (13 + 24 + 16)
A description goes back out through to_json() and --record, so a
scene read and re-written keeps its frame. A stage whose kind has no
kernel — a number from WFM_STAGE_USER (4096) up with no wfm_frame_ops_t
entry — is refused, never skipped.
On --type dsss¶
The description is the spread frame. The acquisition preamble stays on the command line, unspread and outside every stage, because it is what a receiver correlates raw chips against:
{
"fields": [
{"name": "sync", "spec": "10110"},
{"name": "payload", "spec": "0xb25a"},
{"name": "crc", "bits": 16, "derived_by": 1}
],
"stages": [
{"kind": "crc16", "first_field": 1, "n_fields": 2},
{"kind": "randomise", "first_field": 1, "n_fields": 2, "depth": 1},
{"kind": "interleave", "first_field": 1, "n_fields": 2, "depth": 4}
]
}
wfmgen --type dsss --acq-code '1101100*4' --data-code 10110 \
--frame spread.json --sps 2 -o dsss.cf32
A train sent from a description on all three faces, decoded by
DsssBurstReceiver and checked against the same description, is the
DsssBurstReceiver walkthrough.
Everything else type="dsss" means — the second code, the chip clock, the
Es/N0 that refers to the outer data symbol — is in
DSSS bursts.
Clock Doppler — a source that is moving¶
A carrier offset moves the carrier. Doppler moves the clock.
That distinction is the whole page. --freq 12000 puts a signal 12 kHz up
the band and leaves its symbol rate exactly where it was. A real emitter
closing at 7.5 km/s does something else: it rescales the received time base,
so the carrier, the symbol rate and the chip rate all move together, by the
same fraction. A receiver's timing loop sees an error that a carrier-only
offset never produces — which is precisely the error you wanted to test.
--doppler is that second thing. It is given in ppm — parts per million
of time-base scale — because that one number is what moves every clock at
once:
The four flags¶
| Flag | Unit | What it sets |
|---|---|---|
--doppler PPM[:PPM] |
ppm | Time-base scale. 0 (default) builds no channel at all. |
--doppler-rate PPM_S[:PPM_S] |
ppm/s | Linear ramp on the above — an accelerating pass. |
--carrier-hz HZ |
Hz | The RF carrier the ppm is referred to, which is what turns a time-base scale into a coherent carrier rotation. |
--doppler-lifetime L |
— | per_instance (default) or persist. See below. |
A LEO pass at S-band is a couple of tens of ppm:
# 25 ppm closing on a 2.4 GHz carrier ≈ 60 kHz of carrier shift, and a
# symbol rate 25 ppm fast along with it.
wfmgen --type qpsk --fs 1e6 --sps 4 --snr 12 --count 20000 \
--doppler 25 --carrier-hz 2.4e9 -o pass.cf32
ls -l pass.cf32
--carrier-hz is independent of the two ppm figures, and 0 is a real
answer rather than an unset field: it warps the clock and applies no carrier
rotation, which is the right model for a baseband recording whose carrier was
already stripped. Set it when you want the rotation that accompanies the warp.
--doppler-rate ramps the offset as the pass progresses:
# A pass that sweeps through zero: -20 ppm, closing at 4 ppm/s.
wfmgen --type bpsk --fs 1e6 --sps 8 --count 20000 \
--doppler -20 --doppler-rate 4 --carrier-hz 2.2e9 -o accel.cf32
ls -l accel.cf32
The rate is per second of stream, not per second of on-time
The channel keeps running through a segment's gaps, on the noise floor
the gap already carries. An emitter does not stop moving because its
burst ended, so a two-burst scene with a 10 ms gap resumes 10 ms further
along the pass — not where it left off. Skip the gaps and
doppler_rate would quietly mean "ppm per second of transmission",
which is not a geometry anything flies.
Ranged, like freq and snr¶
A pass is a distribution, not a number. Both ppm fields take the same
LO:HI form as --freq/--snr/--level, drawn uniformly on each repeat
instance:
# Eight bursts, eight geometries — a Monte Carlo over the pass.
wfmgen --type qpsk --fs 1e6 --sps 4 --snr 10 --count 4096 --off 2000 \
--repeats 8 --doppler 2:9 --doppler-rate 0.1:0.5 \
--carrier-hz 1.5e9 --file-type sigmf -o sweep
ls sweep.sigmf-meta
The draw is a hash of (seed, repeat index, segment, source, field), so
--record stores the span and --from-file replays the identical
sequence of draws. What each instance actually flew is recorded separately,
per annotation, in the SigMF sidecar:
{
"core:sample_start": 6096,
"core:sample_count": 4096,
"wfmgen:doppler_ppm": 2.1624832257080335,
"wfmgen:doppler_rate_ppm_s": 0.4429440692793868,
"wfmgen:carrier_hz": 1500000000,
"wfmgen:doppler_lifetime": "per_instance"
}
Those keys appear only when a channel was actually built, so a scene without Doppler writes exactly the sidecar it always did.
The lifetime is a choice¶
--repeats 8 plays one segment eight times. Does the emitter restart its
pass on each burst, or fly straight through?
per_instance(default) — the channel dies with each instance and the geometry restarts. This is the repeated-trial shape: eight independent looks at a target, which is what composes with a ranged--dopplerre-drawn per instance.persist— one continuous pass carries across the segment's gaps and repeat instances. This is the only lifetime under which--doppler-rateaccumulates over a multi-burst scene: burst 8 is further along than burst 1 by the full elapsed stream time.
# One pass, eight bursts of it — the offset has ramped by the last one.
wfmgen --type bpsk --fs 1e6 --sps 8 --count 4096 --off 4000 --repeats 8 \
--doppler 5 --doppler-rate 200 --carrier-hz 2e9 \
--doppler-lifetime persist -o onepass.cf32
ls -l onepass.cf32
A persisting channel is keyed by (segment, source) position, which is the only source identity a scene file carries. So it persists across a segment's repeat instances and their gaps, and two different segments each get their own pass — sharing one pass between segments would need a declared source id the format does not have.
In JSON and in Python¶
Both fields are ordinary source keys, scalar or [lo, hi]:
{
"version": 1,
"segments": [
{
"type": "qpsk", "fs": 1e6, "sps": 4, "snr": 12,
"num_samples": 20000,
"doppler": [2, 9],
"doppler_rate": 0.4,
"carrier_hz": 2.4e9,
"doppler_lifetime": "persist"
}
]
}
and the same names are Segment/Synth keyword arguments, with a ranged
field spelled as a tuple:
from doppler.wfm.compose import Composer, Segment
scene = Composer(
[
Segment(
"qpsk",
fs=1e6,
sps=4,
snr=12.0,
num_samples=20000,
doppler=(2.0, 9.0),
doppler_rate=0.4,
carrier_hz=2.4e9,
doppler_lifetime="persist",
)
]
)
x = scene.compose()
print(x.shape, x.dtype)
Plan serves a Doppler source¶
Plan caches each source's clean
on-time once and re-weights it. A Doppler channel cannot live in that
cache, for two reasons that were measured against compose():
- A burst depends on what came before it. The channel is a stateful
resampler and it runs through the gaps, so a trailing gap carries the
burst's ring-out and a leading
delay_samplesadvances the geometry before the burst starts. - The noise sits inside the channel.
compose()puts a bundled source's AWGN inside the synth, so the channel resamples it too.
So the cache holds the signal before the channel, and render() runs the
channel over it at render time, through the composer's own renderer. The
delay, the gaps, the noise and (for persist) the earlier segments are all in
hand when it runs, and the result is compose() to the bit:
import numpy as np
from doppler.wfm import prepare
from doppler.wfm.compose import Composer, Segment
kw = dict(fs=1e6, sps=4, num_samples=4096, carrier_hz=2.2e9)
scene = Composer(
[
Segment(
"bpsk",
snr=12.0,
doppler=(2.0, 9.0), # a ranged Doppler, drawn per repeat
off_samples=512,
delay_samples=128,
repeats=3,
**kw,
)
]
)
plan = prepare(scene.to_json())
assert np.array_equal(plan.render(), scene.compose())
# A Monte-Carlo trial is a seed: it redraws the Doppler as well as the noise,
# and each seed is exactly the scene composed with that seed.
print(len(plan.render(seed=101)), len(plan.render(seed=202)))
Two things are worth knowing. A render of a Doppler scene is no longer a pure
re-weight of the cache: each Doppler source runs one resampler pass over its
timeline, on top of the synthesis the cache still saves. That pass is what
render() parallelises: a segment's repeats render concurrently, or its sources
do when a persist channel chains the repeats, so it costs wall-clock mainly
for a single burst from a single source. And one shape is still
refused, with a ValueError: a background=True source with Doppler, because
the background fold sums those sources into one composite before any channel
could run, and a channel is per source.
See also¶
- Scenes — putting these waveforms in time.
- Gallery: wfmgen — the spectra and constellations behind each type.
- Writing captures — sample types, file types, endianness, output targets.