Python Ring Buffer API¶
Lock-free SPSC ring buffers backed by dp_buffer_*. Uses virtual-memory
double-mapping so the consumer always sees a contiguous window across the
wrap boundary — zero-copy, branch-free.
Source:
src/doppler/buffer/__init__.py
Buffer types¶
| Class | NumPy dtype | Bytes/sample |
|---|---|---|
F32Buffer |
complex64 |
8 |
F64Buffer |
complex128 |
16 |
I16Buffer |
[('i','<i2'),('q','<i2')] |
4 |
Any capacity from 1 up, and capacity is exactly the number you passed —
on every machine. It need not be a power of two.
What is rounded is the mapping behind the ring: up to a power of two, because indexing is a mask, and up to a whole number of pages, because the double mapping is built from them (4 KiB on Linux x86-64, 16 KiB on macOS arm64, 64 KiB allocation granularity on Windows). That is address space, not room — a ring of 1,000 holds 1,000 and refuses the next sample — and it costs nothing per call. A capacity just past a power of two maps nearly twice what it holds; one that is a power of two and spans a page maps exactly itself.
Two surfaces, one ring¶
| You are | Read with | Write with |
|---|---|---|
| a consumer thread, producer apart | wait(n) — blocks |
write(arr) |
| one thread doing both | peek(n) — never |
write_some(arr) |
Threaded. One producer thread calls write; one consumer thread calls
wait / consume. write is non-blocking and refuses the whole call if
the ring has no room — it copies nothing and leaves your array untouched, so
you still hold the data and can retry. Nothing is dropped unless you discard
it; dropped counts refused calls, not lost samples. wait blocks the
consumer and releases the GIL so the producer can run concurrently.
Single-threaded. wait would deadlock a caller that is its own
producer, so peek(n) returns the same zero-copy view when n samples are
there and None when they are not yet. None means not yet and nothing
else: a closed ring with fewer than n left raises EOFError, and
n > capacity raises ValueError, exactly as wait does. write_some
takes what fits and returns the count, never refusing and never touching
dropped — the only way to feed a chunk larger than the ring. space is
the room a write is guaranteed to find; reset() empties and reopens
the ring (both sides must be idle).
Both surfaces release with consume(k); k < n keeps the overlap, which is
how overlapped frames are read.
Examples¶
Producer / consumer (threaded)¶
From src/doppler/examples/buffers_demo.py. write() never blocks, so the
producer waits for space; close() is how the consumer learns there is no
more, and it arrives as EOFError once the ring is drained.
import threading
import numpy as np
from doppler.buffer import F32Buffer, F64Buffer, I16Buffer
IQ16 = np.dtype([("i", "<i2"), ("q", "<i2")])
# A ramp in each width's own element, so a sample says where it came from.
def ramp_complex(dtype):
return lambda n: np.arange(n).astype(dtype)
def ramp_iq16(n):
x = np.zeros(n, dtype=IQ16)
x["i"] = np.arange(n)
return x
# Two threads. wait() releases the GIL while it spins, so the producer
# runs; close() is how the consumer learns there is no more.
buf = F64Buffer(4096)
got = []
def producer():
for k in range(8):
while buf.space < 512: # write() never blocks: wait for room
pass
buf.write(np.full(512, k, dtype=np.complex128))
buf.close()
t = threading.Thread(target=producer)
t.start()
try:
while True:
got.append(int(buf.wait(512)[0].real))
buf.consume()
except EOFError:
pass # closed and drained: the normal end of a stream
t.join()
assert got == list(range(8)), "every block, once, in order"
assert buf.dropped == 0
buf.destroy()
One thread: any block in, exact frames out¶
wait(n) blocks until a producer on another thread delivers, so a caller
that is its own producer would wait forever. peek(n) is the same zero-copy
view without the wait — the frame, or None for "not yet" — and write_some
takes what fits. Together they are the whole single-threaded pattern: feed,
take every whole frame, repeat until the block is gone.
import threading
import numpy as np
from doppler.buffer import F32Buffer
from doppler.spectral import FFT
# A ramp, so every sample says where in the stream it came from: a frame
# that is off by one, duplicated or dropped shows up as a break in the
# sequence rather than as a plausible-looking block of signal.
def stream(start: int, n: int) -> np.ndarray:
idx = np.arange(start, start + n)
return (idx + 1j * idx).astype(np.complex64)
FFT_N = 1024
ring = F32Buffer(4096) # four frames. SMALLER than the largest block below.
# What the device hands over: nothing is a multiple of FFT_N, and two of
# them are larger than the whole ring.
DRIVER_BLOCKS = [3000, 5000, 1700, 4096, 777, 2048, 6000, 1234]
fft = FFT(FFT_N, -1)
spectra: list[np.ndarray] = []
frames: list[np.ndarray] = []
produced = 0
for _ in range(6):
for n in DRIVER_BLOCKS:
block = stream(produced, n)
produced += n
# THE PATTERN. Feed what fits, take every whole frame, repeat until
# the block is gone. `write_some` never refuses, so there is no
# room to make first and no block too large; `peek` never blocks,
# so one thread can do both jobs.
fed = 0
while fed < len(block):
fed += ring.write_some(block[fed:])
while (frame := ring.peek(FFT_N)) is not None:
spectra.append(fft.execute_cf32(frame)) # straight in: no copy
frames.append(frame.copy()) # kept past consume(): copied
ring.consume() # releases the frame that was lent
One thread: a chunk larger than the ring, overlapped frames out¶
From src/doppler/examples/ring_nonblocking_demo.py, which checks every
frame against the input:
import numpy as np
from doppler.buffer import F32Buffer
def stream(start: int, n: int) -> np.ndarray:
idx = np.arange(start, start + n)
return (idx + 1j * idx).astype(np.complex64)
ring = F32Buffer(4096)
CAP = ring.capacity
ring.reset()
NFFT, HOP = 1000, 250
assert CAP % NFFT, "frames must straddle the wrap for this to show anything"
chunk = stream(0, 5 * CAP)
assert not ring.write(chunk[: CAP + 1]), "write() refuses what cannot fit"
refused = ring.dropped
fed = taken = 0
while fed < len(chunk):
k = ring.write_some(chunk[fed:])
fed += k
drained = 0
while (frame := ring.peek(NFFT)) is not None:
assert frame.flags["C_CONTIGUOUS"], "the double-mapping's promise"
assert np.array_equal(frame, chunk[taken * HOP :][:NFFT]), (
f"frame {taken} is not the stream at hop {taken * HOP}"
)
ring.consume(HOP) # release a hop, keep the overlap
taken += 1
drained += 1
# A full ring holds a whole frame, so one side always moves. Without
# this, a regression in either call is a hang rather than a failure.
assert k or drained, "neither side made progress"
End of stream, then the same ring again¶
ring.close()
tail = ring.available
assert 0 < tail < NFFT
try:
ring.peek(NFFT)
raise AssertionError("a closed ring answered 'not yet'")
except EOFError:
pass # the rest of that frame is never coming: not None, an error
last = ring.peek(tail) # what IS there can still be read
assert np.array_equal(last, chunk[-tail:])
ring.consume()
ring.reset()
assert not ring.closed and ring.available == 0 and ring.space == CAP
assert ring.write_some(stream(0, 8)) == 8
assert np.array_equal(ring.peek(8), stream(0, 8))
I16Buffer — raw ADC samples¶
numpy has no complex-integer dtype, so one q15 sample is a record,
[("i", "<i2"), ("q", "<i2")]. Both faces speak it, which keeps the ring 1-D
with one element per sample like its float siblings. The storage underneath
is still interleaved int16, so .view() converts either way with no copy.
from doppler.buffer import I16Buffer
import numpy as np
IQ16 = np.dtype([("i", "<i2"), ("q", "<i2")])
buf = I16Buffer(4096)
adc_bytes = np.zeros(2048 * 2, dtype=np.int16).tobytes() # ADC byte stream
raw = np.frombuffer(adc_bytes, dtype=IQ16) # zero-copy
buf.write(raw)
view = buf.wait(1024) # shape (1024,), dtype IQ16
I = view["i"] # strided int16 views, no copy
Q = view["q"]
flat = view.view(np.int16) # interleaved I, Q, I, Q, ... — also no copy
buf.consume()
A bare int16 array — flat or (n, 2) — is refused with TypeError: it is
not an array of samples. And a record refuses arithmetic (view + 1 raises)
where a packed int32 would carry across the I/Q boundary and corrupt I
silently, which is why it is a record.
A full ring¶
A full ring is not an error: write() refuses the block and returns False — you still
hold it — and dropped counts what was refused.
buf = F32Buffer(1000) # any size; not a power of two here
cap = buf.capacity # 1000
buf.write(np.ones(cap, dtype=np.complex64)) # True
buf.write(np.ones(1, dtype=np.complex64)) # False: no room
buf.dropped # 1 refused, none lost
The full walk through every refusal is
src/doppler/examples/ring_refusals_demo.py; the rest of the suite is indexed
on Ring Buffers.
F32Buffer
¶
Lock-free SPSC ring buffer for complex64 (CF32) samples.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
capacity
|
int
|
How many samples the ring holds: any size from 1 up, and
:attr: |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
If construction fails. The exception message is |
Examples:
>>> from doppler.buffer import F32Buffer
>>> import numpy as np
>>> buf = F32Buffer(1024)
>>> buf.capacity
1024
>>> buf.write(np.ones(512, dtype=np.complex64))
True
write
¶
Write samples into the buffer without blocking.
Copies the complex64 array into the ring buffer in a single memcpy.
If there is not enough free space for all len(x) samples the call
is refused entirely — nothing is copied and x is untouched, so
you still hold every sample and may retry once the consumer has made
room. Nothing is dropped unless you discard it; the refusal is counted
in :attr:dropped, which is not a loss count. The array must be 1-D
and C-contiguous.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x
|
NDArray[complex64]
|
Samples to write. Must be 1-D and C-contiguous. |
required |
Returns:
| Type | Description |
|---|---|
bool
|
|
Examples:
write_some
¶
Write as much of x as fits and say how much that was.
The partial-write twin of :meth:write. Where :meth:write refuses a
block that does not fit whole, this takes the leading samples that do
and returns their count -- 0 when the ring is full. It never
refuses, so it never touches :attr:dropped. It is the only way to
feed a chunk larger than the ring: loop, advancing by the return value,
draining in between.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x
|
NDArray[complex64]
|
Samples to write. Must be 1-D and C-contiguous. |
required |
Returns:
| Type | Description |
|---|---|
int
|
Samples accepted, |
Examples:
A chunk three times the size of the ring, fed by looping:
>>> from doppler.buffer import F32Buffer
>>> import numpy as np
>>> buf = F32Buffer(1024)
>>> cap = buf.capacity
>>> chunk = np.ones(3 * cap, dtype=np.complex64)
>>> fed = 0
>>> while fed < len(chunk):
... fed += buf.write_some(chunk[fed:])
... _ = buf.peek(buf.available); buf.consume()
>>> fed == 3 * cap, buf.dropped
(True, 0)
wait
¶
Block until n samples are available, then lend a zero-copy view.
Spins (releasing the GIL so a producer thread can run concurrently)
until at least n samples have been written by the producer. Returns
a 1-D complex64 NumPy array that is a direct view into the
double-mapped ring buffer — no data is copied. Because of the
double-mapping, the view is always contiguous even when the requested
range wraps around the physical end of the ring.
The caller must call :meth:consume before the next call to
wait. Using the returned array after consume is undefined
behaviour; the producer may overwrite it at any time.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
n
|
int
|
Number of complex samples to wait for. Must be positive and not
larger than :attr: |
required |
Returns:
| Type | Description |
|---|---|
NDArray[complex64]
|
Zero-copy view of the next |
Raises:
| Type | Description |
|---|---|
EOFError
|
The producer called :meth: |
KeyboardInterrupt
|
Somebody asked this process to stop, through a
:class: |
Examples:
peek
¶
:meth:wait that never blocks: a view, or None for not yet.
The single-threaded consumer's read. :meth:wait spins until a
producer on another thread delivers, so a caller that is its own
producer would deadlock in it; peek answers at once instead. When
n samples are buffered it returns the same zero-copy,
always-contiguous view :meth:wait would (1-D complex64); otherwise it
returns None.
None means not yet and nothing else. The two conditions no
amount of waiting can cure are raised, exactly as :meth:wait raises
them, so a poll loop cannot mistake either for a slow producer.
Peeking does not consume. Follow it with :meth:consume; a
consume(k) with k < n advances by a hop smaller than the frame,
which is how overlapped frames are read.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
n
|
int
|
Number of samples wanted. Must be positive and not larger than
:attr: |
required |
Returns:
| Type | Description |
|---|---|
NDArray[complex64] | None
|
Zero-copy view of the next |
Raises:
| Type | Description |
|---|---|
EOFError
|
The ring is closed and fewer than |
ValueError
|
|
Examples:
>>> from doppler.buffer import F32Buffer
>>> import numpy as np
>>> buf = F32Buffer(1024)
>>> buf.peek(4) is None
True
>>> buf.write_some(np.ones(8, dtype=np.complex64))
8
>>> buf.peek(4).shape
(4,)
>>> buf.consume(2)
>>> buf.available
6
>>> buf.close()
>>> buf.peek(8)
Traceback (most recent call last):
...
EOFError: end of stream: the producer closed the ring
consume
¶
Release n samples back to the producer.
Advances the consumer tail pointer by n, making that space
available for the producer to overwrite, and ends the loan: the view a
:meth:wait or :meth:peek lent must not be used afterwards. If n
is omitted it is the count of that outstanding view, so the number is
written once. n smaller than the view is how overlapped frames are
read: release a hop, keep the rest.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
n
|
int
|
Number of samples to release. Defaults to the count of the
outstanding :meth: |
...
|
Raises:
| Type | Description |
|---|---|
ValueError
|
|
RuntimeError
|
|
ValueError
|
If the C call returns a non-zero status. The exception message is
|
Examples:
close
¶
Say that no more data is coming.
The producer's half of end of stream. Until this exists a consumer
cannot tell a slow producer from a finished one -- both look like an
empty ring -- so :meth:wait had nothing to do but spin. Call it once,
after the last write.
Release ordering: every sample written before this is visible to a
consumer that observes the flag. Closing does not discard what was
already written; :meth:wait keeps returning batches until the ring is
drained, and only then raises EOFError.
See docs/design/io-termination.md for the one termination contract
shared with the network and disk transports.
Examples:
reset
¶
Empty the ring and reopen it.
Discards everything buffered, and clears :attr:closed so the same
ring can carry a second stream -- without it, reuse after :meth:close
means destroying and re-mapping. :attr:dropped is a lifetime count
and is kept.
Not safe against a concurrent producer or consumer: it moves both ends of the ring. Call it only when both sides are idle.
Examples:
destroy
¶
Close the object; the memory goes with its last user.
Ordinarily unnecessary: the resources are freed when the object is garbage-collected. Call this, or use the object as a context manager, to end its use at a definite point.
Views this object handed out keep the memory they point into, as does a
call still running on another thread: it is freed when the last of
them, and this object, are gone, not here (gh-2187). Idempotent:
calling it again does nothing. Every other method raises
RuntimeError once it has run.
__enter__
¶
Enter a context manager, returning this object.
Lets a F32Buffer be used in a with statement so its C resources are
released deterministically on exit rather than at collection time.
Returns:
| Type | Description |
|---|---|
F32Buffer
|
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 F32Buffer.
Equivalent to calling destroy(). Returns None, so an exception
raised inside the with body propagates normally; this never
suppresses one.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
exc_type
|
object | None
|
Exception class, or None. Ignored. |
...
|
exc
|
object | None
|
Exception instance, or None. Ignored. |
...
|
tb
|
object | None
|
Traceback object, or None. Ignored. |
...
|
F64Buffer
¶
Lock-free SPSC ring buffer for complex128 (CF64) samples.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
capacity
|
int
|
How many samples the ring holds: any size from 1 up, and
:attr: |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
If construction fails. The exception message is |
Examples:
>>> from doppler.buffer import F64Buffer
>>> import numpy as np
>>> buf = F64Buffer(512)
>>> buf.capacity >= 512
True
>>> buf.write(np.ones(256, dtype=np.complex128))
True
write
¶
Write complex128 samples into the buffer without blocking.
Copies the entire array in a single memcpy. Rejects the write
atomically if there is insufficient free space; the call is refused
whole -- nothing copied, x untouched -- and :attr:dropped grows
by len(x). The array must be 1-D and C-contiguous.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x
|
NDArray[complex128]
|
Samples to write. Must be 1-D and C-contiguous. |
required |
Returns:
| Type | Description |
|---|---|
bool
|
|
Examples:
write_some
¶
Write as much of x as fits and say how much that was.
The partial-write twin of :meth:write. Where :meth:write refuses a
block that does not fit whole, this takes the leading samples that do
and returns their count -- 0 when the ring is full. It never
refuses, so it never touches :attr:dropped. It is the only way to
feed a chunk larger than the ring: loop, advancing by the return value,
draining in between.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x
|
NDArray[complex128]
|
Samples to write. Must be 1-D and C-contiguous. |
required |
Returns:
| Type | Description |
|---|---|
int
|
Samples accepted, |
Examples:
A chunk three times the size of the ring, fed by looping:
>>> from doppler.buffer import F64Buffer
>>> import numpy as np
>>> buf = F64Buffer(1024)
>>> cap = buf.capacity
>>> chunk = np.ones(3 * cap, dtype=np.complex128)
>>> fed = 0
>>> while fed < len(chunk):
... fed += buf.write_some(chunk[fed:])
... _ = buf.peek(buf.available); buf.consume()
>>> fed == 3 * cap, buf.dropped
(True, 0)
wait
¶
Block until n samples are available; return zero-copy view.
Spins with the GIL released until the producer has written at least
n samples. Returns a zero-copy 1-D complex128 view directly into
the ring buffer. Caller must call :meth:consume before the next
wait.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
n
|
int
|
Number of complex samples to wait for. |
required |
Returns:
| Type | Description |
|---|---|
NDArray[complex128]
|
Zero-copy view into the ring buffer. |
Raises:
| Type | Description |
|---|---|
EOFError
|
The producer called :meth: |
KeyboardInterrupt
|
Somebody asked this process to stop, through a
:class: |
Examples:
peek
¶
:meth:wait that never blocks: a view, or None for not yet.
The single-threaded consumer's read. :meth:wait spins until a
producer on another thread delivers, so a caller that is its own
producer would deadlock in it; peek answers at once instead. When
n samples are buffered it returns the same zero-copy,
always-contiguous view :meth:wait would (1-D complex128); otherwise
it returns None.
None means not yet and nothing else. The two conditions no
amount of waiting can cure are raised, exactly as :meth:wait raises
them, so a poll loop cannot mistake either for a slow producer.
Peeking does not consume. Follow it with :meth:consume; a
consume(k) with k < n advances by a hop smaller than the frame,
which is how overlapped frames are read.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
n
|
int
|
Number of samples wanted. Must be positive and not larger than
:attr: |
required |
Returns:
| Type | Description |
|---|---|
NDArray[complex128] | None
|
Zero-copy view of the next |
Raises:
| Type | Description |
|---|---|
EOFError
|
The ring is closed and fewer than |
ValueError
|
|
Examples:
>>> from doppler.buffer import F64Buffer
>>> import numpy as np
>>> buf = F64Buffer(1024)
>>> buf.peek(4) is None
True
>>> buf.write_some(np.ones(8, dtype=np.complex128))
8
>>> buf.peek(4).shape
(4,)
>>> buf.consume(2)
>>> buf.available
6
>>> buf.close()
>>> buf.peek(8)
Traceback (most recent call last):
...
EOFError: end of stream: the producer closed the ring
consume
¶
Release n samples back to the producer.
Advances the consumer tail pointer by n, making that space
available for the producer to overwrite, and ends the loan: the view a
:meth:wait or :meth:peek lent must not be used afterwards. If n
is omitted it is the count of that outstanding view, so the number is
written once. n smaller than the view is how overlapped frames are
read: release a hop, keep the rest.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
n
|
int
|
Number of samples to release. Defaults to the count of the
outstanding :meth: |
...
|
Raises:
| Type | Description |
|---|---|
ValueError
|
|
RuntimeError
|
|
ValueError
|
If the C call returns a non-zero status. The exception message is
|
Examples:
close
¶
Say that no more data is coming.
The producer's half of end of stream. Until this exists a consumer
cannot tell a slow producer from a finished one -- both look like an
empty ring -- so :meth:wait had nothing to do but spin. Call it once,
after the last write.
Release ordering: every sample written before this is visible to a
consumer that observes the flag. Closing does not discard what was
already written; :meth:wait keeps returning batches until the ring is
drained, and only then raises EOFError.
See docs/design/io-termination.md for the one termination contract
shared with the network and disk transports.
Examples:
reset
¶
Empty the ring and reopen it.
Discards everything buffered, and clears :attr:closed so the same
ring can carry a second stream -- without it, reuse after :meth:close
means destroying and re-mapping. :attr:dropped is a lifetime count
and is kept.
Not safe against a concurrent producer or consumer: it moves both ends of the ring. Call it only when both sides are idle.
Examples:
destroy
¶
Close the object; the memory goes with its last user.
Ordinarily unnecessary: the resources are freed when the object is garbage-collected. Call this, or use the object as a context manager, to end its use at a definite point.
Views this object handed out keep the memory they point into, as does a
call still running on another thread: it is freed when the last of
them, and this object, are gone, not here (gh-2187). Idempotent:
calling it again does nothing. Every other method raises
RuntimeError once it has run.
__enter__
¶
Enter a context manager, returning this object.
Lets a F64Buffer be used in a with statement so its C resources are
released deterministically on exit rather than at collection time.
Returns:
| Type | Description |
|---|---|
F64Buffer
|
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 F64Buffer.
Equivalent to calling destroy(). Returns None, so an exception
raised inside the with body propagates normally; this never
suppresses one.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
exc_type
|
object | None
|
Exception class, or None. Ignored. |
...
|
exc
|
object | None
|
Exception instance, or None. Ignored. |
...
|
tb
|
object | None
|
Traceback object, or None. Ignored. |
...
|
I16Buffer
¶
Lock-free SPSC ring buffer for interleaved int16 IQ pairs.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
capacity
|
int
|
How many samples the ring holds: any size from 1 up, and
:attr: |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
If construction fails. The exception message is |
Examples:
>>> from doppler.buffer import I16Buffer
>>> import numpy as np
>>> buf = I16Buffer(1024)
>>> buf.capacity
1024
>>> IQ16 = np.dtype([("i", "<i2"), ("q", "<i2")])
>>> adc = np.array([10, 20, 30, 40], dtype=np.int16) # I, Q, I, Q
>>> buf.write(adc.view(IQ16))
True
>>> buf.wait(2)["q"].tolist()
[20, 40]
write
¶
Write IQ samples into the buffer without blocking.
Copies the record array into the ring in a single memcpy. With
fewer than len(x) free slots the call is refused entirely --
nothing copied, x untouched -- and :attr:dropped grows by
len(x), which is not a loss count: you still hold every sample.
A bare int16 array is refused with TypeError, flat or (n, 2):
it is not an array of samples. flat.view(IQ16) makes it one, with
no copy.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x
|
NDArray[Any]
|
IQ samples to write: 1-D, C-contiguous, dtype |
required |
Returns:
| Type | Description |
|---|---|
bool
|
|
Examples:
>>> from doppler.buffer import I16Buffer
>>> import numpy as np
>>> buf = I16Buffer(1024)
>>> IQ16 = np.dtype([("i", "<i2"), ("q", "<i2")])
>>> buf.write(np.array([10, 20, 30, 40], dtype=np.int16).view(IQ16))
True
>>> buf2 = I16Buffer(1024)
>>> buf2.write(np.zeros(1024, dtype=IQ16))
True
>>> buf2.write(np.zeros(1, dtype=IQ16))
False
write_some
¶
Write as much of x as fits and say how much that was.
The partial-write twin of :meth:write. Where :meth:write refuses a
block that does not fit whole, this takes the leading samples that do
and returns their count -- 0 when the ring is full. It never
refuses, so it never touches :attr:dropped. It is the only way to
feed a chunk larger than the ring: loop, advancing by the return value,
draining in between.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x
|
NDArray[Any]
|
Samples to write: 1-D, C-contiguous, dtype |
required |
Returns:
| Type | Description |
|---|---|
int
|
Samples accepted, |
Examples:
A chunk three times the size of the ring, fed by looping:
>>> from doppler.buffer import I16Buffer
>>> import numpy as np
>>> IQ16 = np.dtype([("i", "<i2"), ("q", "<i2")])
>>> buf = I16Buffer(1024)
>>> cap = buf.capacity
>>> chunk = np.ones(3 * cap, dtype=IQ16)
>>> fed = 0
>>> while fed < len(chunk):
... fed += buf.write_some(chunk[fed:])
... _ = buf.peek(buf.available); buf.consume()
>>> fed == 3 * cap, buf.dropped
(True, 0)
wait
¶
Block until n samples are available, then lend a zero-copy view.
Spins with the GIL released until the producer has written at least
n samples. Returns a 1-D record array directly into the
double-mapped ring: view["i"] is the I channel, view["q"] the Q
channel, each a strided int16 view with no copy. Caller must call
:meth:consume before the next wait.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
n
|
int
|
Number of IQ sample pairs to wait for. |
required |
Returns:
| Type | Description |
|---|---|
NDArray[Any]
|
Zero-copy view of the next |
Raises:
| Type | Description |
|---|---|
EOFError
|
The producer called :meth: |
KeyboardInterrupt
|
Somebody asked this process to stop, through a
:class: |
Examples:
>>> from doppler.buffer import I16Buffer
>>> import numpy as np
>>> buf = I16Buffer(1024)
>>> IQ16 = np.dtype([("i", "<i2"), ("q", "<i2")])
>>> buf.write(np.array([10, 20, 30, 40], dtype=np.int16).view(IQ16))
True
>>> view = buf.wait(2)
>>> view.dtype
dtype([('i', '<i2'), ('q', '<i2')])
>>> view.shape
(2,)
>>> view.tolist()
[(10, 20), (30, 40)]
>>> buf.consume()
peek
¶
:meth:wait that never blocks: a view, or None for not yet.
The single-threaded consumer's read. :meth:wait spins until a
producer on another thread delivers, so a caller that is its own
producer would deadlock in it; peek answers at once instead. When
n samples are buffered it returns the same zero-copy,
always-contiguous view :meth:wait would (1-D, one (i, q) record
per sample); otherwise it returns None.
None means not yet and nothing else. The two conditions no
amount of waiting can cure are raised, exactly as :meth:wait raises
them, so a poll loop cannot mistake either for a slow producer.
Peeking does not consume. Follow it with :meth:consume; a
consume(k) with k < n advances by a hop smaller than the frame,
which is how overlapped frames are read.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
n
|
int
|
Number of samples wanted. Must be positive and not larger than
:attr: |
required |
Returns:
| Type | Description |
|---|---|
NDArray[Any] | None
|
Zero-copy view of the next |
Raises:
| Type | Description |
|---|---|
EOFError
|
The ring is closed and fewer than |
ValueError
|
|
Examples:
>>> from doppler.buffer import I16Buffer
>>> import numpy as np
>>> IQ16 = np.dtype([("i", "<i2"), ("q", "<i2")])
>>> buf = I16Buffer(1024)
>>> buf.peek(4) is None
True
>>> buf.write_some(np.ones(8, dtype=IQ16))
8
>>> buf.peek(4).shape
(4,)
>>> buf.consume(2)
>>> buf.available
6
>>> buf.close()
>>> buf.peek(8)
Traceback (most recent call last):
...
EOFError: end of stream: the producer closed the ring
consume
¶
Release n samples back to the producer.
Advances the consumer tail pointer by n, making that space
available for the producer to overwrite, and ends the loan: the view a
:meth:wait or :meth:peek lent must not be used afterwards. If n
is omitted it is the count of that outstanding view, so the number is
written once. n smaller than the view is how overlapped frames are
read: release a hop, keep the rest.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
n
|
int
|
Number of samples to release. Defaults to the count of the
outstanding :meth: |
...
|
Raises:
| Type | Description |
|---|---|
ValueError
|
|
RuntimeError
|
|
ValueError
|
If the C call returns a non-zero status. The exception message is
|
Examples:
close
¶
Say that no more data is coming.
The producer's half of end of stream. Until this exists a consumer
cannot tell a slow producer from a finished one -- both look like an
empty ring -- so :meth:wait had nothing to do but spin. Call it once,
after the last write.
Release ordering: every sample written before this is visible to a
consumer that observes the flag. Closing does not discard what was
already written; :meth:wait keeps returning batches until the ring is
drained, and only then raises EOFError.
See docs/design/io-termination.md for the one termination contract
shared with the network and disk transports.
Examples:
reset
¶
Empty the ring and reopen it.
Discards everything buffered, and clears :attr:closed so the same
ring can carry a second stream -- without it, reuse after :meth:close
means destroying and re-mapping. :attr:dropped is a lifetime count
and is kept.
Not safe against a concurrent producer or consumer: it moves both ends of the ring. Call it only when both sides are idle.
Examples:
destroy
¶
Close the object; the memory goes with its last user.
Ordinarily unnecessary: the resources are freed when the object is garbage-collected. Call this, or use the object as a context manager, to end its use at a definite point.
Views this object handed out keep the memory they point into, as does a
call still running on another thread: it is freed when the last of
them, and this object, are gone, not here (gh-2187). Idempotent:
calling it again does nothing. Every other method raises
RuntimeError once it has run.
__enter__
¶
Enter a context manager, returning this object.
Lets a I16Buffer be used in a with statement so its C resources are
released deterministically on exit rather than at collection time.
Returns:
| Type | Description |
|---|---|
I16Buffer
|
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 I16Buffer.
Equivalent to calling destroy(). Returns None, so an exception
raised inside the with body propagates normally; this never
suppresses one.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
exc_type
|
object | None
|
Exception class, or None. Ignored. |
...
|
exc
|
object | None
|
Exception instance, or None. Ignored. |
...
|
tb
|
object | None
|
Traceback object, or None. Ignored. |
...
|
Related pages¶
Guides — Real-Time Pacing & Timestamping Design — The ring buffer — one contiguous view of a stream, Spectrum Analyzer