Python Capture I/O API — Reader / Writer¶
Reader and Writer are duals: one turns complex64 samples into a capture
file, the other turns a capture file back into complex64 samples. Both are
CPython extension types over the C cores, so all parsing, deinterleaving and
rescaling happens in C — there is no Python fast path to fall off.
| Symbol | Direction | Use when |
|---|---|---|
Writer |
out | Serialise samples to raw / CSV / BLUE type-1000 / SigMF |
Reader |
in | Open a capture — yours or someone else's — and stream unit-scale I/Q |
write_blue_header |
out | Write a detached BLUE header beside a payload written separately |
Source:
src/doppler/wfm/__init__.py
The narrative versions of both sides are Writing captures and Reading captures; for generating the samples in the first place see Python: Waveform Generator.
A round trip¶
import pathlib
import tempfile
from doppler.wfm import Composer, Reader, Segment, Writer
tmpdir = tempfile.TemporaryDirectory()
tmp = pathlib.Path(tmpdir.name)
x = Composer([Segment("qpsk", sps=8, snr=15, fs=2.4e6,
num_samples=8192)]).compose()
with Writer(tmp / "capture.blue", file_type="blue", sample_type="ci16",
fs=2.4e6, fc=1.2e9) as w:
w.write(x)
with Reader(tmp / "capture.blue") as r:
assert (r.file_type, r.sample_type) == ("blue", "ci16")
assert (r.fs, r.fc) == (2.4e6, 1.2e9)
back = r.read(r.num_samples)
assert len(back) == len(x)
Both types are context managers, and both also expose close() (aliased
destroy()) for callers that manage the lifetime themselves. Closing a
Writer is what finalises the file — a BLUE header's sample count and a SigMF
sidecar are both written at close, so a Writer that is never closed leaves an
incomplete capture.
Writer¶
Writer(path, file_type="raw", sample_type="cf32", endian="le", fs=1e6, fc=0.0, total=0, headroom=0.0)
The file type decides how much metadata the result can carry. raw and csv
are headerless — fs, fc and the sample type are written nowhere, so a reader
has to be told them. blue and sigmf are self-describing.
# headerless: fs/fc are accepted but cannot be stored
with Writer(tmp / "capture.raw", fs=1e6, file_type="raw",
sample_type="ci16") as w:
w.write(x)
# self-describing: fs and fc survive the round trip
with Writer(tmp / "capture.blue", file_type="blue", sample_type="ci16",
fs=2.4e6, fc=1.2e9) as w:
w.write(x)
A sigmf writer needs a path ending in .sigmf-data — the two halves of a
SigMF capture are found by name, so the name is part of the format. Anything
else is refused with a message saying so, rather than emitting a pair no SigMF
reader will locate:
with Writer(tmp / "cap.sigmf-data", file_type="sigmf", sample_type="ci16",
fs=2e6, fc=1.2e9) as w:
w.write(x)
assert (tmp / "cap.sigmf-meta").exists() # sidecar written at close
Clipping¶
Integer sample types map ±1.0 to ±max-code, so content with PAPR above 0 dBFS
clips. track_clipping() turns on the counters, and clip_fraction /
peak_dbfs / clipped report what happened — measured on the way out, at no
cost when tracking is off:
with Writer(tmp / "clip.raw", fs=1e6, file_type="raw", sample_type="ci8") as w:
w.track_clipping()
w.write(x * 4.0) # deliberately over full scale
assert w.clipped and w.clip_fraction > 0.0
headroom (dB) attenuates on the way out instead, so a scene with known peaks
lands under full scale without rescaling it yourself. See Levels &
SNR.
BLUE keywords¶
add_keyword(tag, type, value) appends a keyword to the extended header. The
type is the BLUE type code (A for ASCII, B/I/L integer, F/D
floating point, and so on), and the value may be a scalar or a sequence:
with Writer(tmp / "kw.blue", file_type="blue", fs=1e6) as w:
w.add_keyword("MISSION", "A", "doppler-demo")
w.add_keyword("GAIN_DB", "D", -3.5)
w.write(x[:64])
with Reader(tmp / "kw.blue") as r:
assert r.keywords["MISSION"] == "doppler-demo"
assert r.keywords["GAIN_DB"] == -3.5
Writer
¶
Open a capture for writing.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str | PathLike
|
where to write -- a |
required |
fs
|
float
|
sample rate (Hz), and REQUIRED -- there is no default. BLUE stores it
as |
required |
file_type
|
Literal['raw', 'csv', 'blue', 'sigmf']
|
|
"raw"
|
sample_type
|
Literal['cf32', 'cf64', 'ci32', 'ci16', 'ci8']
|
wire type: |
"cf32"
|
endian
|
Literal['le', 'be']
|
|
"le"
|
fc
|
float
|
centre frequency (Hz). BLUE records it as a |
0.0
|
total
|
int
|
expected sample count, for the BLUE header; close() patches the real count, so 0 is fine when unknown. |
0
|
headroom
|
float
|
dB of output backoff (gain = 10^(-H/20)) applied before quantisation. A single scale, so it does not change any power ratio -- only the absolute level. 0 is a bit-exact no-op. |
0.0
|
t0
|
float
|
capture start, seconds since the UNIX epoch. Optional where |
0.0
|
sidecar
|
bool
|
write a |
True
|
Examples:
>>> import pathlib, tempfile
>>> import numpy as np
>>> from doppler.wfm import Reader, Writer
>>> tmp = tempfile.TemporaryDirectory()
>>> p = pathlib.Path(tmp.name) / "capture.blue"
>>> x = np.arange(1024, dtype=np.complex64) / 1024.0
>>> with Writer(p, file_type="blue", sample_type="cf32",
... fs=2.4e6, fc=1.2e9) as w:
... w.write(x) # samples in
... w.add_keyword("COMMENT", "A", "demo") # tag the header
1024
>>> p.exists()
True
>>> with Reader(p) as r: # everything round-trips
... back = r.read(len(x))
... r.fs, r.fc, r.num_samples, r.keywords["COMMENT"]
(2400000.0, 1200000000.0, 1024, 'demo')
>>> bool(np.array_equal(back, x))
True
A raw capture has nowhere to put fs/fc, so they go beside it:
>>> q = pathlib.Path(tmp.name) / "capture.raw"
>>> with Writer(q, fs=2.4e6, fc=1.2e9) as w:
... w.write(x)
1024
>>> (q.parent / "capture.raw.sigmf-meta").exists()
True
>>> tmp.cleanup()
clip_fraction
property
¶
Fraction (0..1) of I/Q components that saturated. Always 0.0 unless
track_clipping() was enabled before writing -- the counter is the one
extra per-sample compare, so it is opt-in. peak_dbfs is always
tracked and is enough to tell you clipping happened; this tells you how
much.
peak_dbfs
property
¶
Largest per-axis magnitude written so far, in dBFS (full scale = 0
dBFS, so a value above 0 means an integer capture clipped). Always
tracked. It is also the remedy: back off by ceil(peak_dbfs) dB of
headroom and the capture fits. Float wire types never clip but still
report a peak. -inf before anything is written.
clipped
property
¶
True if an integer capture saturated -- peak_dbfs > 0 and the wire
type is one of ci32/ci16/ci8. Always False for cf32/cf64,
which cannot clip: a float sample above full scale is merely loud.
write
¶
Convert and write a block of samples.
Takes complex64 at unit scale and emits it in the writer's wire type.
Call as many times as you like; the capture is the concatenation.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x
|
NDArray[complex64]
|
Input. |
required |
Returns:
| Type | Description |
|---|---|
int
|
the number of samples that actually landed — equal to what you passed on success, fewer if the write was short (a full disk, a quota). A short return is the per-block signal; close() reports the same failure for the capture as a whole. |
Examples:
>>> import pathlib, tempfile
>>> from doppler.wfm import Composer, Reader, Segment, Writer
>>> tmp = tempfile.TemporaryDirectory()
>>> p = pathlib.Path(tmp.name) / "capture.blue"
>>> x = Composer([Segment("qpsk", sps=8, num_samples=1024)]).compose()
>>> with Writer(p, file_type="blue", sample_type="ci16",
... fs=2.4e6, fc=1.2e9) as w:
... w.write(x)
1024
>>> r = Reader(p)
>>> r.fs, r.fc, r.num_samples
(2400000.0, 1200000000.0, 1024)
>>> r.close()
>>> tmp.cleanup() # directory and contents removed
track_clipping
¶
Enable the per-component clip counter (off by default; peak is always on).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
on
|
int
|
Input. |
1
|
add_keyword
¶
add_keyword(
tag: str,
type: str,
value: str
| int
| float
| Sequence[int]
| Sequence[float],
) -> None
Attach a BLUE extended-header keyword (BLUE captures only). type
is a single character (B/I/L/X int, F/D float, A string, T deprecated
int); value is a str for A, a single int/float, or a sequence of
them. Keywords are buffered and written at close(). The read side is
Reader.keywords.
close
¶
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.
Raises:
| Type | Description |
|---|---|
OSError
|
If the C destructor reports failure. Raised from an explicit call
and from |
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.
Raises:
| Type | Description |
|---|---|
OSError
|
If the C destructor reports failure. Raised from an explicit call
and from |
__enter__
¶
Enter a context manager, returning this object.
Lets a Writer be used in a with statement so its C resources are
released deterministically on exit rather than at collection time.
Returns:
| Type | Description |
|---|---|
Writer
|
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 Writer.
Equivalent to calling close(). 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. |
...
|
Reader¶
Reader(path, sample_type="cf32", endian="le")
The constructor arguments are hints, used only for a headerless file type.
The actual file type comes from the file's content — BLUE magic at byte 0, a
.sigmf-data name with its required sidecar, a first line that scans as I,Q,
otherwise raw. So a CSV named capture.dat reads as CSV, and a BLUE file named
capture.csv reads as BLUE.
read(count) returns up to count samples as complex64 at unit scale
whatever the wire type was, and an empty array at end of file — which makes the
block loop a while:
with Reader(tmp / "capture.blue") as r:
total = 0
while len(block := r.read(4096)):
total += len(block)
assert total == len(x)
Pass out= to read into a buffer you own instead of allocating per call.
Two properties that exist because the obvious answer is ambiguous¶
fc == 0.0 does not mean baseband — it also means "nothing in this file
declared a centre frequency". fc_source is what separates them, reporting
the tag that answered ("FREQ", "RF_FREQ", "CENTER_FREQ", "F_C",
"core:frequency") or "none":
with Reader(tmp / "capture.blue") as r:
assert r.fc_source == "FREQ" # not a default — the file says 1.2 GHz
with Reader(tmp / "capture.raw", sample_type="ci16") as r:
assert r.fc_source == "none" # headerless: fc is a default, not a reading
trailing_bytes is the payload left over after the last whole sample. A
wrong sample_type on a headerless capture cannot fail — nothing in the file
can contradict it — so a non-zero remainder is the only signal that the hint is
wrong or the capture is truncated:
with Writer(tmp / "short.raw", fs=1e6, file_type="raw",
sample_type="ci8") as w:
w.write(x[:5]) # 5 samples x 2 bytes = 10 bytes
with Reader(tmp / "short.raw", sample_type="cf32") as r:
assert r.trailing_bytes == 2 # 10 bytes = one cf32 + 2 over
It cannot catch a wrong hint that happens to divide evenly (a ci16 file read
as ci8), and it never false-alarms.
BLUE introspection¶
header is the whole 512-byte header control block as a dict under the format's
own field names; keywords merges both keyword blocks into one {tag: value}
dict. Both are empty for a non-BLUE file type.
with Reader(tmp / "capture.blue") as r:
assert r.header["type"] == 1000 # type-1000, the I/Q workhorse
assert r.header["xdelta"] == 1.0 / 2.4e6 # xdelta is 1/fs
tmpdir.cleanup()
Reader
¶
Open a capture, auto-detecting its file type from its content.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str | PathLike
|
file to read -- a |
required |
sample_type
|
Literal['cf32', 'cf64', 'ci32', 'ci16', 'ci8']
|
the wire sample type, used only as a HINT for the headerless file types
(raw, CSV) -- BLUE and SigMF carry their own and ignore it. |
"cf32"
|
endian
|
Literal['le', 'be']
|
byte order, likewise a hint that only headerless raw uses; |
"le"
|
Examples:
>>> import pathlib, tempfile
>>> from doppler.wfm import Composer, Reader, Segment, Writer
>>> tmp = tempfile.TemporaryDirectory()
>>> p = pathlib.Path(tmp.name) / "capture.blue"
>>> x = Composer([Segment("qpsk", sps=8, num_samples=1024)]).compose()
>>> w = Writer(p, file_type="blue", sample_type="ci16", fs=2.4e6)
>>> w.add_keyword("NAME", "A", "demo") # tag the header
>>> _ = w.write(x)
>>> w.close()
>>> r = Reader(p) # file type auto-detected
>>> r.file_type, r.sample_type, r.fs
('blue', 'ci16', 2400000.0)
>>> r.keywords["NAME"] # keyword round-trips
'demo'
>>> total = 0
>>> while len(block := r.read(256)): # read returns 0 at EOF
... total += len(block)
>>> total == r.num_samples == 1024
True
>>> r.close()
>>> tmp.cleanup()
file_type
property
¶
Which file type the capture turned out to be -- "raw", "csv",
"blue" or "sigmf". Detected from the file's CONTENT, not its name,
so a CSV called capture.dat reports "csv" and a BLUE file called
capture.csv reports "blue". "raw" is also the fallback for a file
nothing else recognised, so it means "headerless interleaved I/Q at the
sample_type you passed" rather than a positive identification.
sample_type
property
¶
The wire sample type the samples are being decoded FROM -- "cf32",
"cf64", "ci32", "ci16" or "ci8". For BLUE and SigMF this was
read from the file's metadata and is authoritative; for raw and CSV it
is simply the hint passed to the constructor, echoed back. read()
returns complex64 at unit scale regardless.
mode
property
¶
Components per wire sample: "complex" for interleaved I/Q,
"scalar" for a real capture. Only BLUE carries this (its format
field's mode designator, C or S); every other file type is complex.
A scalar capture still reads back as complex64 -- the imaginary part
is exactly 0, so a real signal lands on the real axis.
endian
property
¶
Byte order of the samples on the wire, "le" or "be". Read from
the metadata for BLUE (the HCB's head_rep) and SigMF (the _be/_le
datatype suffix); for raw it is the constructor hint echoed back. CSV
is text and ignores it.
fs
property
¶
Sample rate in Hz, or 0.0 when the file type does not carry one.
BLUE derives it from the header's xdelta (fs = 1/xdelta); SigMF reads
core:sample_rate. Raw and CSV have nowhere to record a rate, so they
always report 0.0 -- whatever rate the capture was taken at has to
travel with it by other means.
fc
property
¶
Centre frequency in Hz, or 0.0 when nothing in the capture declares
one. 0.0 is ambiguous on its own -- a genuine baseband capture and
a capture whose frequency could not be found report the same number --
so read fc_source alongside it: "none" there is what distinguishes
them. SigMF takes it from captures[0]["core:frequency"]; BLUE from a
FREQ keyword (see fc_source for the tags tried), in either the
ASCII HCB keyword area or the typed extended header. Raw and CSV carry
no metadata at all.
fs_source
property
¶
Which metadata fs was read from -- "xdelta" for BLUE (the
type-1000 adjunct, as 1/xdelta), "core:sample_rate" for SigMF, or
"none" when nothing carried a rate. Raw and CSV always report
"none": they have nowhere to record one, so whatever rate the capture
was taken at has to travel with it by other means.
t0
property
¶
Capture start time in seconds since the UNIX epoch, or 0.0 when the
capture does not declare one. 0.0 does not mean 1970 -- read
t0_source alongside it, exactly as with fc/fc_source. This is the
t0 of t = t0 + n/fs: hand it to a SampleClock via track() and a
replayed capture's timeline lands where the samples were taken, not
where they are being replayed. BLUE carries it as a J1950 timecode in
the header, converted here to the UNIX epoch.
t0_source
property
¶
Where t0 was read from -- "timecode" for a BLUE header that
declares one, or "none". "none" is the common answer and the one
that matters: a zero BLUE timecode means the field was never set, not
1950-01-01, and doppler's own writer leaves it zero -- so a caller that
skips this check dates every doppler-written capture to 1950. SigMF's
core:datetime is an ISO 8601 string this reader does not parse yet,
so a SigMF capture also reports "none" rather than a guess.
num_samples
property
¶
Total samples in the capture, or 0 when the file type cannot say.
BLUE takes it from the header's data_size; raw and SigMF divide the
file length by the sample stride. A CSV has to be counted, so the first
read of this property scans the file once (the scan is exact -- it
parses rows the same way read does -- and leaves the read position
alone); every later read is free.
fc_source
property
¶
Which piece of metadata fc was read from -- the keyword's own tag
("FREQ", "RF_FREQ", "CENTER_FREQ", "F_C"), "core:frequency"
for SigMF, or "none" when nothing carried it. Check this before
trusting fc == 0.0: "none" means not found, anything else means the
capture really does say 0 Hz. BLUE type-1000 has no header field for
centre frequency, so an RF capture conveys it as a keyword; FREQ in
the HCB keyword area is the X-Midas convention and is tried first.
trailing_bytes
property
¶
Payload bytes left over after the last whole sample; 0 for a capture
whose declared sample type and mode match its content, and always 0 for
CSV. Non-zero means either the sample_type/endian hint is wrong for
a headerless file type or the capture is truncated -- the reader cannot
tell which, and stops at the last complete sample either way. This is
the only signal available for a raw file: a wrong hint does not fail,
it returns plausible garbage at the wrong stride.
keywords
property
¶
The BLUE extended header as a {tag: value} dict, in file order; empty when the capture carries no extended header. Values follow the keyword type: a str for A, an int/float for a single-element numeric keyword, a list for a multi-element one. For a detached capture these come from the HEADER file.
header
property
¶
The BLUE header control block as a {field: value} dict, under the
names the format itself uses -- version, head_rep, data_rep,
detached, protected, pipe, ext_start, ext_size, data_start,
data_size, type, format, flagmask, timecode, inlet,
outlets, outmask, pipeloc, pipesize, in_byte, out_byte,
outbytes, keylength, and the type-1000 adjunct xstart, xdelta,
xunits. Empty for a non-BLUE file type. Nothing is renamed or
omitted, so what you see is what the file holds; the decoded keywords
are in keywords.
reset
¶
Rewind to the first sample of the capture.
Seeks back to where the payload starts — 512 bytes into an attached
BLUE file, byte 0 of a .det or a raw/SigMF payload — and restores the
remaining-sample count, so the capture reads again from the top. The
file's metadata and decoded keywords are unaffected: they came from the
header and do not change.
read
¶
Read up to count samples, returning them as complex64.
Samples come out at unit scale whatever the wire type was: a float type
is reinterpreted, an integer type is divided by its full scale. Returns
fewer than asked at the end of the capture, and 0 once it is exhausted,
so a while over the result terminates. Never returns more than the
file's declared payload — trailing bytes past data_size (an extended
header, X-Midas slack) are not samples.
Returns:
| Type | Description |
|---|---|
NDArray[complex64]
|
Output. |
Examples:
>>> import pathlib, tempfile
>>> from doppler.wfm import Composer, Reader, Segment, Writer
>>> tmp = tempfile.TemporaryDirectory()
>>> p = pathlib.Path(tmp.name) / "capture.blue"
>>> x = Composer([Segment("qpsk", sps=8, num_samples=1024)]).compose()
>>> with Writer(p, file_type="blue", sample_type="ci16",
... fs=2.4e6, fc=1.2e9) as w:
... _ = w.write(x)
>>> r = Reader(p)
>>> r.file_type, r.sample_type, r.endian
('blue', 'ci16', 'le')
>>> r.fs, r.fc, r.fc_source
(2400000.0, 1200000000.0, 'FREQ')
>>> total = 0
>>> while len(block := r.read(256)):
... total += len(block)
>>> total
1024
>>> r.close()
>>> tmp.cleanup() # directory and contents removed
read_max_out
¶
Maximum samples one read(n) yields: n (fewer at EOF).
A reader streams, so a read of n produces at most n samples; the binding
sizes its buffer to this per-call bound (gh-607) and resizes down to the
actual count, never pre-allocating the whole capture.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
n
|
int
|
Input. |
required |
Returns:
| Type | Description |
|---|---|
int
|
Output. |
close
¶
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.
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 Reader be used in a with statement so its C resources are
released deterministically on exit rather than at collection time.
Returns:
| Type | Description |
|---|---|
Reader
|
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 Reader.
Equivalent to calling close(). 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. |
...
|
Module-level helpers¶
write_blue_header writes a standalone BLUE header, for a detached capture
whose samples were written separately (a .det payload beside a .hdr
header). Reader opens either half and resolves the other.
write_blue_header
¶
write_blue_header(
path: str | PathLike,
fs: float,
sample_type: str = "cf32",
endian: str = "le",
fc: float = 0.0,
data_start: float = 0.0,
total: int = 0,
detached: int = 1,
t0: float = 0.0,
) -> None
Write a standalone BLUE type-1000 HCB header (the detached .hdr): 512 bytes carrying the BLUE magic, byte order, data_size (total x bytes-per-sample), the type-1000 tag and xdelta = 1/fs. Pair it with a detached .det body of raw interleaved I/Q. Raises on a failed write.
Related pages¶
Gallery — 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 — Capture I/O, Reading captures, Writing captures — output & file types, Levels & SNR, Python API
Design — Telemetry — zero-cost scalar taps for running pipelines
Contributing — Release Checklist