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
|
Raises:
| Type | Description |
|---|---|
OSError
|
If construction fails. The exception message is |
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
flush
¶
Make every sample written so far durable and observable to a concurrent reader, without ending the capture. Leaves the file on a sample boundary, which is what lets a follower read it without meeting a partial sample. Raises OSError if this or any earlier write failed; a capture is not finished until close().
Leaves the file on a sample boundary -- write() emits whole samples, so
a flush BETWEEN write calls is what lets a follower read the capture
without meeting a partial one. Raises OSError if this or any earlier
write failed; a capture is not complete until close().
Raises:
| Type | Description |
|---|---|
OSError
|
If the C call returns a non-zero status. The exception message is
|
Examples:
>>> import pathlib, tempfile
>>> import numpy as np
>>> from doppler.wfm import Reader, Writer
>>> tmp = tempfile.TemporaryDirectory()
>>> p = pathlib.Path(tmp.name) / "live.blue"
>>> w = Writer(p, file_type="blue", sample_type="ci16", fs=2.4e6)
>>> _ = w.write(np.zeros(16, dtype=np.complex64))
>>> w.flush() # the samples are on disk now
>>> Reader(p).read_follow(16).size
16
>>> w.close()
>>> tmp.cleanup()
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. |
...
|
Raises:
| Type | Description |
|---|---|
OSError
|
If the C destructor reports failure. Raised from an explicit call
and from |
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.
Random access¶
seek(index) moves the read position to a sample — absolute, no whence —
and position reports where it is. reset() is seek(0). One seek for the
strided containers; a scan for CSV, which is delimited. Past the end and below
zero raise, and a refused seek does not move the position:
with Reader(tmp / "capture.blue") as r:
r.seek(4096)
assert r.position == 4096
assert len(r.read(1024)) == 1024
assert r.position == 5120
seek_time(seconds) is seek(round(seconds * fs)), seconds counted from the
capture's first sample rather than the UNIX epoch. It raises on a capture
whose fs_source is "none" — every raw and csv capture — which is the
point of it: the same arithmetic written out by hand lands on sample 0 for
every time, silently. See Random access.
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['auto', '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. The five
complex names |
"auto"
|
endian
|
Literal['le', 'be']
|
byte order, likewise a hint that only headerless raw uses; |
"le"
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If construction fails. The exception message is |
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()
position
property
¶
How far into the capture the reader is, in samples from the first
one: 0 at open and after reset(), num_samples once the capture is
exhausted, and whatever seek() was last given. Every read advances it
-- read() and read_follow() alike -- so it is also how a following
reader reports where the stream it is draining has got to.
follow_timeout_ms
property
writable
¶
How long read_follow() waits for samples to arrive, in
milliseconds; 0 (the default) waits forever, which is the right
answer for a stream with no end -- any finite budget fires during an
ordinary quiet patch and reports an ending that has not happened. Set
it only if the caller has its own reason to give up. A bounded wait
that expires leaves ending at "timeout".
follow_grace_ms
property
writable
¶
How long read_follow() keeps waiting for the writer's
end-of-capture marker after a stop has been requested, in milliseconds;
0 (the default) waits forever. The writer needs a moment to flush
and patch its header, and expiring this budget before it does costs you
the tail -- so the default trades an unlikely hang against a certain
loss. A bounded grace that expires leaves ending at
"interrupted".
ending
property
¶
Why the last read_follow() came back empty -- "none" while the
capture is still live, "eof" once the writer closed and said so,
"timeout" if a bounded timeout_ms expired, "interrupted" if a
stop was requested and a bounded grace_ms expired before the writer's
marker arrived. With the default unbounded budgets only "none" and
"eof" are reachable, which is why an empty result needs no check in
the common case. The values are doppler's own return codes (DP_OK,
DP_ERR_EOF, DP_ERR_TIMEOUT, DP_ERR_INTERRUPTED), so a C caller
reads the same vocabulary every other transport uses.
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; SigMF carries it as an
ISO 8601 core:datetime string in captures[0].
t0_source
property
¶
Where t0 was read from -- "timecode" for a BLUE header that
declares one, "sigmf" for a sidecar's captures[0]."core:datetime",
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. A SigMF
stamp that is malformed, or that carries no timezone, also reads as
"none": taking a zone-less local time for UTC is wrong by hours and
looks authoritative, where reporting nothing does not.
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.
seek() at index 0, and nothing else — one implementation of "put the
read position at sample k" rather than two that can drift. It lands
where the payload starts (512 bytes into an attached BLUE file, byte 0
of a .det or a raw/SigMF payload), 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.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
count
|
int
|
How many output samples to ask for. The call may return fewer; size
an |
1
|
out
|
NDArray[complex64] | None
|
destination, at least max_out samples. |
None
|
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. |
read_follow
¶
Read whatever whole samples have arrived in a capture that is still
being written, blocking until at least one does. Unlike read(), a
short or empty result does not mean end-of-file: the reader waits. 0
means wait forever for both budgets, which is the default and the
right answer for a stream with no end -- any finite budget fires during
an ordinary quiet patch. An empty result therefore means the capture
ENDED; read ending for which way. timeout_ms bounds the wait for
data; grace_ms bounds how long to keep waiting for the writer's
marker after a stop has been requested, and expiring it may cost you
the tail. Never consumes a partial sample, so a writer flushing
mid-sample cannot desynchronise the stream.
Blocks until whole samples arrive. A short or empty result does not mean end-of-file the way ::wfm_reader_read's does -- the reader waits. Zero means the capture ENDED, because with the default unbounded budgets the call does not come back for "not yet"; ::wfm_reader_get_ending says which way it ended.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
count
|
int
|
How many output samples to ask for. The call may return fewer; size
an |
1
|
out
|
NDArray[complex64] | None
|
Optional pre-allocated output buffer. When given, the result is written into it and the returned array is a view of exactly the samples produced; when omitted, a fresh array is allocated. |
None
|
Returns:
| Type | Description |
|---|---|
NDArray[complex64]
|
Output. |
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.zeros(8, dtype=np.complex64)
>>> with Writer(p, file_type="blue", sample_type="ci16", fs=2.4e6) as w:
... _ = w.write(x)
>>> r = Reader(p)
>>> total = 0
>>> while len(block := r.read_follow(4)): # 0 only when the capture ends
... total += len(block)
>>> total, r.ending
(8, 'eof')
>>> r.close()
>>> tmp.cleanup()
read_follow_max_out
¶
Largest number of samples read_follow() can return for n inputs.
Size an out= buffer with this before calling read_follow(), or use it
to allocate one up front. The bound is this object's own: what it
depends on is a property of the algorithm, so a header block on
read_follow_max_out() replaces this text.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
n
|
int
|
Number of input samples read_follow() will be given. |
required |
Returns:
| Type | Description |
|---|---|
int
|
Upper bound on the output length; the actual call may return fewer. |
seek
¶
Move the read position to sample index.
Random access, in the timebase the data owns. The index is absolute
— 0 is the first sample, there is no whence — and it is in SAMPLES,
which is the only unit every container can answer: fs is 0.0 on a
headerless capture, so a time would mean nothing there — see
seek_time(), which refuses rather than pretend.
Cost follows the container. Raw, BLUE and SigMF are strided, so this is
one fseek to data_start + index * bytes_per_sample. CSV is
delimited rather than strided and has no byte arithmetic at all, so
it is a scan: forward from the current position when seeking forward,
from the first sample when seeking back. A loop that seeks forward
monotonically therefore walks the file once, not once per seek.
Seeking to num_samples exactly is legal and lands at the end, where
read() returns 0. Past it is refused, as is a negative index: that is
a caller's arithmetic gone wrong, and a silent empty read would hide
it. A refused seek does not move the read position — the bound is
checked before anything moves, and the CSV scan puts the position back
if it runs out.
On a capture still being written, the bound is what is on disk right
now, measured at the call. A BLUE capture whose writer has not closed
yet still carries the placeholder data_size it opened with, so its
declared length is not used until the writer patches it in;
read_follow() is the read that works on such a capture, and seeking
does not change that.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
index
|
int
|
sample to move to; 0 to |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the C call returns a non-zero status. The exception message is
|
Examples:
>>> import pathlib, tempfile
>>> import numpy as np
>>> 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="cf32", fs=2.4e6)
>>> _ = w.write(x)
>>> w.close()
>>> r = Reader(p)
>>> r.seek(600) # straight there, no decode first
>>> np.array_equal(r.read(424), x[600:])
True
>>> r.position # the read left us at the end
1024
>>> try: # past the end is a refusal...
... r.seek(1025)
... except ValueError:
... print("refused")
refused
>>> r.position # ...that did not move us
1024
>>> r.close()
>>> tmp.cleanup()
seek_time
¶
Move the read position to seconds into the capture.
seek() over index = round(seconds * fs), and the rounding is to
nearest. The conversion is the whole method; the REFUSAL is the point
of having it.
A capture that declares no sample rate reports fs == 0.0, and that is
raw and CSV always — neither container has anywhere to record one. A
caller computing round(t * r.fs) for itself gets sample 0 for every
time, silently. So this refuses when fs_source says nothing declared
a rate, rather than convert through one — the same reason the
provenance accessors exist at all.
Seconds are measured from the FIRST SAMPLE of the capture, never from
the UNIX epoch. A capture's absolute start is t0, and t0_source
is none on every capture doppler itself writes — so an absolute face
would be unusable by default. Converting is the caller's, and it is
r.seek_time(t_unix - r.t0) once t0_source says there is a t0 to
subtract.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
seconds
|
float
|
offset from the first sample; 0 to |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the C call returns a non-zero status. The exception message is
|
Examples:
>>> import pathlib, tempfile
>>> import numpy as np
>>> 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="cf32", fs=1e6)
>>> _ = w.write(x)
>>> w.close()
>>> r = Reader(p)
>>> r.fs, r.fs_source # the rate it converts through
(1000000.0, 'xdelta')
>>> r.seek_time(250e-6) # 250 us at 1 MHz is sample 250
>>> r.position
250
>>> np.array_equal(r.read(774), x[250:])
True
>>> r.close()
>>> raw = pathlib.Path(tmp.name) / "capture.raw"
>>> w = Writer(raw, 0.0, file_type="raw", sample_type="cf32",
... sidecar=False) # no sidecar: nothing records a rate
>>> _ = w.write(x)
>>> w.close()
>>> h = Reader(raw, sample_type="cf32")
>>> h.fs, h.fs_source # headerless: no rate declared
(0.0, 'none')
>>> try: # so a time cannot mean anything
... h.seek_time(250e-6)
... except ValueError:
... print("refused")
refused
>>> h.seek(250) # ...the sample index still does
>>> h.position
250
>>> h.close()
>>> tmp.cleanup()
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, Python API, Waveforms — what you can generate
Design — Capture files — one reader, one writer, four containers, Design, Telemetry — zero-cost scalar taps for running pipelines, wfmgen — the waveform generator
Contributing — Validation log, Release Checklist