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 seven-type waveform engine | Generate tone / noise / PN / BPSK / QPSK / chirp / bits, 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.
Synth — the six-type waveform engine¶
One declarative engine produces every waveform type, selected by the string
type. 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("10110101", sps=4, modulation="bpsk")
preamble = s.steps(s.n_samples)
# 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(np.array([1, 0, 1, 1, 0, 1, 0, 1], np.uint8), modulation="qpsk")
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 the generated
length, 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).steps(10000)
# Down-chirp (equivalent direct construction; freq IS the start frequency)
down = Synth(type="chirp", freq=1e6, f_end=500e3, fs=2e6).steps(50000)
The sweep span is the length you ask for: steps(N) sweeps over exactly
N samples standalone, and in a Segment the sweep fills the segment's
num_samples — so f_end is reached at the last sample either way.
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; "auto" uses over-fs for tone/noise/PN and Es/No for BPSK/QPSK.
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
dataclass
¶
One waveform — the single object that both generates and composes.
A Synth is the configuration of a waveform (tone / noise / PN / BPSK /
QPSK). Use it two ways with the same object:
- Generate now: :meth:
steps/ :meth:steplazily spin the C engine and return samples. :meth:resetrewinds it. - Compose: pass one or more into :meth:
Segment.sumto mix them over a shared noise floor. Composition reads the config only — it spins no engine, so summing N synths allocates nothing extra.
Build with the :func:tone / :func:qpsk / :func:bpsk / :func:pn /
:func:noise helpers, or construct directly.
snr is per-synth because it is self-referential — snr_mode's Eb/No
needs this waveform's bits/symbol and samples/symbol. In a multi-source
:meth:Segment.sum the composer resolves the per-synth SNRs into one shared
floor; a lone synth keeps its bundled AWGN.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
type
|
('tone', 'noise', 'pn', 'bpsk', 'qpsk', 'chirp')
|
Waveform kind ( |
"tone"
|
fs
|
float
|
Sample rate (Hz) for standalone generation. Ignored when the synth is
used in a :class: |
1000000.0
|
freq
|
float
|
Carrier/offset frequency (Hz). For a |
0.0
|
f_end
|
float
|
Chirp end frequency (Hz); used only when |
0.0
|
f_start
|
float
|
Readable alias for |
None
|
snr
|
float
|
SNR in dB under |
100.0
|
snr_mode
|
('auto', 'fs', 'ebno', 'esno')
|
How |
"auto"
|
seed
|
int
|
PRNG / LFSR seed. |
1
|
sps
|
int
|
PN/PSK data-source parameters (see :class: |
8
|
pn_length
|
int
|
PN/PSK data-source parameters (see :class: |
8
|
pn_poly
|
int
|
PN/PSK data-source parameters (see :class: |
8
|
lfsr
|
int
|
PN/PSK data-source parameters (see :class: |
8
|
level
|
float
|
Level in dBFS ( |
0.0
|
Examples:
>>> from doppler.wfm import Synth
>>> Synth(type="tone", fs=1.0, freq=0.0).steps(4).tolist()
[(1+0j), (1+0j), (1+0j), (1+0j)]
>>> # an up-chirp sweeping 100 kHz → 300 kHz over 4096 samples
>>> Synth(type="chirp", fs=1e6, f_start=1e5, f_end=3e5).steps(4096).shape
(4096,)
n_samples
property
¶
One full pass of a bits pattern, in samples (n_bits*sps,
halved for qpsk). 0 for the streaming types (no natural length).
steps
¶
Generate n cf32 samples (spins the engine on first use).
reset
¶
Rewind the engine so generation repeats from sample 0 (no-op if it has not generated yet).
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
|
poly constructor parameter. |
96
|
seed
|
int
|
seed constructor parameter. |
1
|
length
|
int
|
length constructor parameter. |
7
|
lfsr
|
Literal['galois', 'fibonacci']
|
lfsr constructor parameter. |
"galois"
|
Examples:
Create with defaults:
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.
Returns:
| Type | Description |
|---|---|
NDArray[uint8]
|
|
Examples:
compose — multi-segment composition, writers, and a ZMQ 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 fill:#ede7f6,stroke:#5e35b1,color:#000;
classDef seg fill:#e3f2fd,stroke:#1565c0,color:#000;
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 containers (raw / CSV / BLUE type-1000 / SigMF), and
ZmqSink publishes over ZeroMQ. The resolved spec round-trips through JSON, so a
capture is fully reproducible.
import numpy as np
from doppler.wfm import Composer, Segment, Writer, mls_poly, qpsk, tone
from doppler.wfm import read_iq
# 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", sample_type="cf32") as w:
while len(blk := c.execute(4096)):
w.write(blk)
back = read_iq("frame.cf32", "cf32") # zero-copy complex64 view
# 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) / 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.
Reader is the dual of Writer — it reads a capture back to complex64,
auto-detecting the container (BLUE magic / .sigmf-meta sidecar / .csv / raw)
and recovering fs/fc/sample type from BLUE and SigMF metadata. All parsing
and conversion is in C:
from doppler.wfm import Composer, Writer, Reader
Composer(type="qpsk", sps=8, num_samples=4096).compose() # ... write it ...
with Reader("capture.blue") as r: # container auto-detected
print(r.file_type, r.fs, r.num_samples)
x = r.read_all() # or block-wise: r.read(4096)
For a quick raw-only read with no object, read_iq still works;
Reader is the full container-aware dual. Writer pairs with read_iq or
Reader; for SigMF, pair a Writer(..., file_type="sigmf") data file with
sigmf_meta(...), and for detached BLUE use write_blue_header(...). The
ZmqSink is POSIX-only. DSP
helpers rrc_taps(beta, sps, span) and dsss_spread(syms, code, sf) expose the
pulse-shaping and spreading primitives.
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, ZmqSink
# Stream at the true 1 MS/s instead of as fast as possible.
comp = Composer(type="qpsk", sps=8, continuous=True)
clk = SampleClock(fs=1e6)
with ZmqSink("tcp://0.0.0.0:5555") 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
¶
A multi-segment waveform generator over a list of :class:Segment.
Construct from an explicit segment list, from a single segment's keyword
arguments, or from a JSON spec (:meth:from_json / :meth:from_file). Pull
samples a block at a time with :meth:execute, or drain a finite spec to one
array with :meth:compose. Usable as a context manager.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
segments
|
Sequence[Segment] or None
|
The segments to compose. If |
None
|
repeat
|
bool
|
Loop the whole sequence after the last segment. |
False
|
continuous
|
bool
|
Never finish (implies |
False
|
**segment_kwargs
|
object
|
Used to build a single :class: |
{}
|
Examples:
>>> from doppler.wfm.compose import Composer
>>> with Composer(type="noise", snr=10.0, num_samples=4096) as c:
... blk = c.execute(1024)
>>> blk.dtype, len(blk)
(dtype('complex64'), 1024)
from_json
classmethod
¶
Build a composer from a JSON spec string (the --from-file schema).
from_file
classmethod
¶
Build a composer from a JSON spec file.
execute
¶
Generate up to n samples; a shorter (or empty) array marks the end.
compose
¶
Drain a finite spec into a single array (raises if continuous).
stream
¶
Yield successive blocks — a generator over :meth:execute.
Turns the while len(b := c.execute(n)): boilerplate into
for b in c.stream(n):. A finite spec ends when execute drains;
a continuous spec streams forever.
This is the Python equivalent of the wfmgen --realtime flag: pass
realtime to pace each block to real time (the same timing_core
clock the CLI uses). realtime=True paces at the first segment's
fs; pass a float to override the rate. Each block is emitted, then
the generator sleeps until the next block's deadline before producing
it — so the first block is immediate and the long-run rate is exactly
fs (POSIX only; raises NotImplementedError off-platform, like
:class:SampleClock).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
block
|
int
|
Samples per yielded array (the last finite block may be shorter). |
4096
|
realtime
|
bool or float
|
Pace to real time. |
False
|
Yields:
| Type | Description |
|---|---|
NDArray[complex64]
|
One block per iteration. |
Examples:
Segment
dataclass
¶
One waveform segment of a composed stream.
Fields mirror the C wfm_segment_t and the wfmgen CLI flags; the
string fields (type, snr_mode, lfsr) are mapped to the C enums.
Defaults match the CLI: a clean 1024-sample baseband tone at 1 MS/s.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
type
|
('tone', 'noise', 'pn', 'bpsk', 'qpsk', 'chirp')
|
Waveform kind. |
"tone"
|
fs
|
float
|
Sample rate (Hz). |
1000000.0
|
freq
|
float
|
Carrier/offset frequency (Hz); normalised by |
0.0
|
f_end
|
float
|
Chirp end frequency (Hz); used only when |
0.0
|
snr
|
float
|
SNR in dB under |
100.0
|
snr_mode
|
('auto', 'fs', 'ebno', 'esno')
|
How |
"auto"
|
seed
|
int
|
PRNG / LFSR seed (reproducible by default). |
1
|
sps
|
int
|
Samples per symbol (PSK) or per chip (PN). |
8
|
pn_length
|
int
|
LFSR register length (PN/PSK data source). |
7
|
pn_poly
|
int
|
LFSR polynomial; |
0
|
lfsr
|
('galois', 'fibonacci')
|
LFSR topology. |
"galois"
|
num_samples
|
int
|
On-time length of the segment (samples). |
1024
|
off_samples
|
int
|
Trailing zero gap after the segment (samples). |
0
|
level
|
float
|
Segment level in dBFS ( |
0.0
|
sum
classmethod
¶
Mix several :class:Synth together over the same num_samples.
The synths mix at the same time (one receiver), so they share one
sample rate fs and one noise floor: per-synth snr resolves to a
single AWGN source in C (see :class:Synth). off is the trailing
gap. This is the multi-source counterpart of the single-source
:class:Segment constructor.
Examples:
add
¶
Sequence this segment then others in time → a :class:Timeline.
The time-sequence counterpart of :meth:sum (which mixes sources at the
same time): a.add(b) plays a then b back-to-back. Chain
with :meth:Timeline.add, then hand the result to :class:Composer.
Examples:
Timeline
dataclass
¶
An ordered run of :class:Segment played back-to-back in time.
The composer already sequences a segment list; :class:Timeline is the
fluent face of that list, built by :meth:Segment.add / :meth:add. It is
a plain sequence — iterate it, index it, or pass it straight to
:class:Composer.
add
¶
Append more segments to the end of the timeline (chainable).
Writer
¶
Stream complex64 samples to a container file.
Wraps the C writer for the four containers the CLI supports. For "sigmf"
the samples land in path (use <name>.sigmf-data) and the companion
metadata is produced by :func:sigmf_meta; for detached BLUE use
:func:write_blue_header.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str or PathLike
|
Output file (opened in binary mode; truncated). |
required |
file_type
|
('raw', 'csv', 'blue', 'sigmf')
|
Output container. |
"raw"
|
sample_type
|
('cf32', 'cf64', 'ci32', 'ci16', 'ci8')
|
On-disk element type; integer types are quantised from unit-scale. |
"cf32"
|
endian
|
('le', 'be')
|
Byte order for multi-byte integer types (ignored for CSV). |
"le"
|
fs
|
float
|
Sample rate and centre frequency (Hz), recorded in BLUE/SigMF metadata. |
1000000.0
|
fc
|
float
|
Sample rate and centre frequency (Hz), recorded in BLUE/SigMF metadata. |
1000000.0
|
total
|
int
|
Expected sample count (lets seekable BLUE headers be patched on close). |
0
|
Examples:
>>> import tempfile, os
>>> from doppler.wfm.compose import Composer, Writer
>>> from doppler.wfm.readback import read_iq
>>> x = Composer(type="tone", freq=1e5, num_samples=512).compose()
>>> p = os.path.join(tempfile.mkdtemp(), "cap.cf32")
>>> with Writer(p, sample_type="cf32") as w:
... _ = w.write(x)
>>> import numpy as np
>>> bool(np.allclose(read_iq(p, "cf32"), x))
True
peak_dbfs
property
¶
Largest sample magnitude written, in dBFS (0 dB = full scale).
Positive means an integer wire type clipped; the value is the headroom
it would need. Readable while open or after :meth:close.
clip_fraction
property
¶
Fraction (0..1) of I/Q components that saturated. Always 0 unless
:meth:track_clipping was enabled; only meaningful for integer types.
track_clipping
¶
Enable the per-component clip counter (off by default; the peak is
always tracked). Call before writing if you want :attr:clip_fraction.
Reader
¶
Read a capture back to complex64 — the dual of :class:Writer.
The container is auto-detected from the file (BLUE "BLUE" magic, a
.sigmf-meta sidecar, the .csv extension, else raw), and
self-describing containers (BLUE, SigMF) recover the sample type, byte
order, sample rate and centre frequency from their metadata. Headerless raw
/ CSV take the sample_type / endian hints. All detection, header
parsing and wire→unit conversion happen in C; this class is thin glue.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str or PathLike
|
Capture to read. A BLUE |
required |
sample_type
|
('cf32', 'cf64', 'ci32', 'ci16', 'ci8')
|
Wire type for headerless raw / CSV (ignored once BLUE/SigMF metadata is parsed). |
"cf32"
|
endian
|
('le', 'be')
|
Byte order for headerless raw. |
"le"
|
Examples:
>>> import tempfile, os, numpy as np
>>> from doppler.wfm.compose import Composer, Writer, Reader
>>> x = Composer(type="tone", freq=1e5, num_samples=512).compose()
>>> p = os.path.join(tempfile.mkdtemp(), "cap.blue")
>>> with Writer(p, file_type="blue", fs=1e6) as w:
... _ = w.write(x)
>>> with Reader(p) as r: # BLUE self-describes — no hints needed
... y = r.read_all()
... print(r.file_type, int(r.fs), bool(np.allclose(y, x)))
blue 1000000 True
ZmqSink
¶
Publish complex64 samples over a ZeroMQ PUB socket (POSIX only).
Each :meth:send frames the block with its sample rate and centre frequency
and the chosen sample_type wire format — the same framing the C CLI's
--output zmq://… uses.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
endpoint
|
str
|
ZeroMQ endpoint, e.g. |
required |
sample_type
|
('cf32', 'cf64', 'ci32', 'ci16', 'ci8')
|
Wire element type. |
"cf32"
|
SampleClock
¶
Pace and timestamp a stream against an ideal fs-Hz clock (POSIX).
A :class:SampleClock mimics a hardware sample clock in software. Off one
drift-free timeline anchored at construction it does two things:
- :meth:
pace— sleep so each block leaves at its real-time deadlineepoch + n/fs, throttling a producer (e.g. a :class:Composerfeeding a :class:ZmqSink) to real time. - :meth:
stamp— return the ideal UNIX-epoch-ns time of the next sample, for reproducible capture metadata (SigMFcore:datetime, records).
The schedule is anchored, not incremental: every deadline is recomputed
from the cumulative sample count against a fixed epoch, so an over- or
under-sleep on one block is corrected on the next — the long-run rate is
exactly fs, with only bounded per-block jitter. Software on a
non-realtime OS gives drift-free average rate, never true sample-clock
fidelity; keep blocks large enough (period N/fs ≫ scheduler jitter,
say ≥ 1 ms) and let the consumer's buffer absorb the jitter.
When the producer can't keep up — a block takes longer than its N/fs
period — that's an underrun: :meth:pace doesn't sleep (it's already
behind), counts it (:attr:underruns, :attr:max_lateness), and, if
resync=True, re-anchors the timeline to now instead of keeping the
(now unreachable) absolute schedule.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
fs
|
float
|
Sample rate (Hz). Must be > 0. |
required |
resync
|
bool
|
Re-anchor the timeline to "now" on each underrun (default: keep the absolute schedule and let the average rate self-heal if it catches up). |
False
|
Examples:
>>> from doppler.wfm.compose import SampleClock
>>> clk = SampleClock(fs=1e6)
>>> slack = clk.pace(1000) # advance 1000 samples (~1 ms) and wait
>>> clk.samples
1000
>>> isinstance(clk.stamp(), int) # ideal ns timestamp of the next sample
True
pace
¶
Advance count samples and sleep to that block's deadline.
Returns the slack in seconds measured before sleeping: >= 0 means
the block was early (and it slept that long); < 0 means it arrived
late — an underrun (no sleep, counted).
stamp
¶
Ideal UNIX-epoch-ns timestamp of the next sample (index n).
Call before :meth:pace to tag the block you're about to emit, or
after to tag the following one.
Module-level helpers¶
sigmf_meta
¶
sigmf_meta(*, sample_type: str = 'cf32', endian: str = 'le', fs: float = 1000000.0, fc: float = 0.0, segments: Sequence[Segment]) -> str
Build the SigMF .sigmf-meta JSON for a composed capture.
The capture's segments become per-segment SigMF annotations; pair this with
a Writer(..., file_type="sigmf") writing the .sigmf-data companion.
write_blue_header
¶
write_blue_header(path: str | PathLike, *, sample_type: str = 'cf32', endian: str = 'le', fs: float = 1000000.0, fc: float = 0.0, total: int, data_start: float = 0.0, detached: bool = True) -> None
Write a standalone BLUE type-1000 HCB header (the detached .hdr).
The 512-byte header carries the "BLUE" magic, byte order, data_size
(total × bytes-per-sample), the type-1000 tag and xdelta = 1/fs;
pair it with a detached .det body of raw interleaved I/Q.
Examples:
rrc_taps
¶
dsss_spread
¶
Direct-sequence spread syms by the ±1 chip code (length ≥ sf).
Each symbol is repeated against the first sf chips, yielding
len(syms) * sf output chips.
Examples:
mls_poly
¶
read_iq
¶
read_iq(path: str, sample_type: SampleType = 'cf32', endian: Literal['le', 'be'] = 'le', *, raw: bool = False) -> NDArray
Read an interleaved-I/Q capture into a NumPy array.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str
|
File written by the |
required |
sample_type
|
('cf32', 'cf64', 'ci32', 'ci16', 'ci8')
|
The |
"cf32"
|
endian
|
('le', 'be')
|
The |
"le"
|
raw
|
(bool, keyword - only)
|
If true, return the zero-copy on-disk samples as an |
False
|
Returns:
| Type | Description |
|---|---|
NDArray
|
With |
Examples: