Skip to content

Python Streaming API

doppler.stream moves sample blocks between processes (or hosts) over NATS, in three subject-based patterns that share one wire format — a dp_header_t (sample rate, centre frequency, sample type, an auto timestamp_ns, and a per-socket sequence counter) followed by the raw sample bytes. Because the header is shared, a C transmitter and a Python receiver interoperate freely.

Every pattern needs a nats-server reachable at the endpoint's host:port — plain nats-server is enough for PUB/SUB and REQ/REP; PUSH/PULL rides the JetStream work-queue tier, so start it with nats-server -js. Endpoints are nats://host:port/subject.

Source: src/doppler/stream/__init__.py

For the C side and a runnable end-to-end walk-through, see the Streaming examples.


Socket patterns

Pattern Sender Receiver NATS tier Use
PUB / SUB Publisher Subscriber Core NATS fan-out broadcast; subscribers may drop
PUSH/PULL Push Pull JetStream work-queue load-balanced pipeline; durable, acked
REQ / REP Requester Replier NATS request/reply request/response (lock-step)

Every socket is a context manager and releases the GIL while blocked waiting on NATS, so receive loops thread cleanly. The sender's sample_type fixes the NumPy dtype on both ends:

Constant BLUE code dtype layout
CF32 "CF" numpy.complex64 complex I/Q
CF64 "CD" numpy.complex128 complex I/Q
CI8 "CB" numpy.int8 interleaved I/Q, length 2n
CI16 "CI" numpy.int16 interleaved I/Q, length 2n
CI32 "CL" numpy.int32 interleaved I/Q, length 2n

A format's value is its two-character Midas BLUE code, packed little-endian — the same characters the same samples get in a BLUE file, so nothing translates between the wire and the file. TLM16 is the exception in the same argument slot: it is a frame kind, not a sample format (telemetry records, Publisher only — see Telemetry), because BLUE has no code for a record stream.

recv() returns (samples, header) where header is a dict of the decoded dp_header_t fields: format, kind, version, flags, data_rep, payload_bytes, sequence, timestamp_ns, sample_rate, center_freq and num_samples. The full byte layout, and what a receiver validates before handing anything back, is in Design: Streaming.


PUB / SUB — broadcast

The publisher fans out to every subscriber on the subject; a slow subscriber drops frames rather than back-pressuring the sender. Core NATS has no backlog, so a subscriber must already be listening before the publish — start the Subscriber first (or add a brief warm-up sleep).

import time
import numpy as np
from doppler.stream import Publisher, Subscriber, CF64

# receiver: subscribe first. Core NATS drops a frame sent before the
# subscription reaches the broker, so give it a moment.
with Subscriber("nats://127.0.0.1:4222/iq") as sub:
    time.sleep(0.5)

    # transmitter
    with Publisher("nats://127.0.0.1:4222/iq", CF64) as pub:
        iq = np.exp(2j * np.pi * 1e3 * np.arange(1000) / 1e6)   # complex128
        pub.send(iq, sample_rate=1e6, center_freq=2.4e9)
        # A send returns once the client holds the frame; close() then
        # flushes for at most 500 ms and reports nothing. drain() waits
        # for the server and raises if the tail could not get there.
        pub.drain()

    samples, header = sub.recv(timeout_ms=1000)
    assert len(samples) == 1000
    assert header["sample_rate"] == 1e6
    print(header["sample_rate"], header["sequence"], len(samples))

PUSH / PULL — pipeline

PUSH publishes onto a durable JetStream work-queue subject; JetStream load-balances each frame to exactly one connected PULL worker, giving a back-pressured, at-least-once work queue. Call Pull.ack() once a frame has been fully processed. Requires a JetStream-enabled broker (nats-server -js).

import numpy as np
from doppler.stream import Push, Pull, CF64

with Push("nats://127.0.0.1:4222/work", CF64) as push:
    push.send(np.zeros(4096, dtype=np.complex128), sample_rate=1e6)

with Pull("nats://127.0.0.1:4222/work") as pull:
    samples, header = pull.recv()       # blocks until a frame arrives
    pull.ack(samples)

A worker may hold WORK_QUEUE_MAX_ACK_PENDING frames delivered and not yet acked. Past that, the server hands it no new frames, only redeliveries of the pending ones, each an AckWait after the last, so a worker that never acks stalls at exactly that count. An ack is fire-and-forget, so a lost one is a redelivery too: the header's sequence, which counts each producer's frames from 0, is how a worker can tell one from a new frame.


REQ / REP — request/response

A Requester sends a sample block and blocks for the Replier's response; the two alternate strictly, over NATS's native request/reply.

import numpy as np
from doppler.stream import Requester, Replier, CF64

# server: receive a request, send a reply
with Replier("nats://127.0.0.1:4222/ctrl", CF64) as rep:
    req, header = rep.recv()
    rep.send(req * 2)                   # echo-and-scale, say

# client: send, then receive the reply
with Requester("nats://127.0.0.1:4222/ctrl", CF64) as req:
    req.send(np.ones(256, dtype=np.complex128), sample_rate=1e6)
    resp, header = req.recv()

Timestamps

get_timestamp_ns() returns the same CLOCK_REALTIME nanosecond stamp the senders embed in each header, so a receiver can compute end-to-end latency against header["timestamp_ns"].


PUB / SUB

Publisher

NATS core PUB — one-to-many broadcast of signal frames.

Wraps dp_pub_t. Each :meth:send call stages one [dp_header_t][raw data] buffer and publishes it to the endpoint's iq.<subject>.<type> NATS subject. Multiple :class:Subscriber sockets can subscribe to the same subject; each subscriber receives every frame. NATS core has no persistence or back-pressure: a slow subscriber simply misses frames published while it isn't reading.

The endpoint identifies a subject on a NATS broker; both Publisher and Subscriber connect to the same nats-server (no bind/connect distinction — the broker mediates fan-out).

Parameters:

Name Type Description Default
endpoint str

NATS endpoint, e.g. "nats://127.0.0.1:4222/iq" (host:port/subject; the subject defaults to "default" if omitted). Requires a running nats-server.

required
sample_type int

Wire encoding. One of :data:CI8, :data:CI16, :data:CI32, :data:CF32, :data:CF64 (default), or (on a :class:Publisher) :data:TLM16. Raises :exc:ValueError for any other value.

...

Raises:

Type Description
ValueError

If sample_type is not one of the three supported values.

RuntimeError

If nats-server isn't reachable at endpoint.

Examples:

Construct, use as a context manager, and verify the type:

>>> from doppler.stream import Publisher, CF64
>>> pub = Publisher("nats://127.0.0.1:4222/t19100", CF64)
>>> type(pub).__name__
'Publisher'
>>> pub.close()

Context-manager form (preferred — ensures close on exception):

>>> with Publisher("nats://127.0.0.1:4222/t19101", CF64) as pub:
...     type(pub).__name__
'Publisher'

Full send round-trip with a :class:Subscriber (requires a live nats-server and a brief warm-up sleep so the subscription is established before the first publish):

>>> import numpy as np, time
>>> from doppler.stream import Subscriber
>>> pub = Publisher("nats://127.0.0.1:4222/iq", CF64)
>>> sub = Subscriber("nats://127.0.0.1:4222/iq")
>>> time.sleep(0.1)
>>> pub.send(np.array([1+2j, 3+4j],
...          dtype=np.complex128),
...          sample_rate=int(1e6),
...          center_freq=int(2.4e9))
>>> samples, hdr = sub.recv(timeout_ms=2000)
>>> pub.close(); sub.close()

__init__

__init__(endpoint: str, sample_type: int = ...) -> None

Create a Publisher and connect to endpoint's NATS broker.

Parameters:

Name Type Description Default
endpoint str

NATS endpoint, e.g. "nats://127.0.0.1:4222/iq".

required
sample_type int

Wire encoding: :data:CI8, :data:CI16, :data:CI32, :data:CF32, or :data:CF64 (default).

...

__enter__

__enter__() -> Publisher

Return self for use as a context manager.

Examples:

>>> from doppler.stream import Publisher, CF64
>>> with Publisher("nats://127.0.0.1:4222/t19102", CF64) as pub:
...     type(pub).__name__
'Publisher'

__exit__

__exit__(*args: object) -> None

Call :meth:close on context-manager exit.

send

send(
    samples: NDArray[Any],
    sample_rate: float = 0,
    center_freq: float = 0,
    timestamp_ns: int | None = None,
) -> None

Broadcast one block of samples to all connected Subscribers.

Constructs a dp_header_t with the supplied metadata (plus a per-socket monotonically increasing sequence number), stages one [header][raw sample bytes] buffer (NATS has no scatter/ gather send, so header and data are copied into one contiguous buffer first), and publishes it. The call releases the GIL while blocked in the underlying NATS client, so other Python threads can run concurrently.

Parameters:

Name Type Description Default
samples ndarray

C-contiguous array whose dtype must match the socket's sample_type: numpy.complex64 for :data:CF32, numpy.complex128 for :data:CF64, or numpy.int8 / numpy.int16 / numpy.int32 for :data:CI8 / :data:CI16 / :data:CI32 (interleaved I/Q, length 2*n_samples).

required
sample_rate float

Samples per second written into the header (default 0).

0
center_freq float

Centre frequency in Hz written into the header (default 0).

0
timestamp_ns int

UNIX nanoseconds to stamp on timestamp_ns instead of a fresh CLOCK_REALTIME read -- propagate an upstream message's own origin timestamp (or one derived from :class:~doppler.wfm.SampleClock) through this hop instead of overwriting it with "now" (default None: auto-stamp).

None

Raises:

Type Description
TypeError

If samples.dtype does not match the socket's sample_type.

ValueError

If samples is not C-contiguous.

RuntimeError

If the publish fails (e.g. the broker connection dropped).

Examples:

>>> from doppler.stream import Publisher, CF64
>>> import numpy as np, time
>>> pub = Publisher("nats://127.0.0.1:4222/iq", CF64)
>>> pub.send(np.ones(4, dtype=np.complex128),
...          sample_rate=int(48000),
...          center_freq=int(433e6))
>>> pub.close()

drain

drain(timeout_ms: int = 5000) -> None

Shut down gracefully: stop new work, finish, flush, close.

This waits for the connection to close, which is the part worth having: the underlying drain returns immediately and finishes in the background, so a process that exits when it returns abandons exactly the work the drain was for.

Drain last, after you have stopped producing. It cannot be reversed, and a send issued while one is in progress races its phases -- it may slip through, or be refused. Because this waits, a single-threaded caller need not reason about that: afterwards a send raises RuntimeError deterministically.

Against :meth:flush: flush asks whether the server has what you published and leaves the publisher usable; drain ends it. A drained shutdown needs no flush of its own -- the drain's final phase is that flush.

Size timeout_ms to the slowest thing the drain waits for, with margin. doppler's receive is synchronous, so there is no handler to finish and the wait is dominated by flushing what is buffered; the default is generous for a link that is keeping up.

Parameters:

Name Type Description Default
timeout_ms int

How long to wait for the close (default 5000).

5000

Raises:

Type Description
TimeoutError

If the drain did not complete in the budget.

RuntimeError

If the publisher is already closed, or the drain failed.

Examples:

>>> from doppler.stream import Publisher, CF64
>>> pub = Publisher("nats://127.0.0.1:4222/iq", CF64)
>>> pub.drain()      # stopped producing, so shut down
>>> pub.close()

send_eos

send_eos() -> None

Tell subscribers the stream has ended.

A subscriber's :meth:Subscriber.recv raises :class:EOFError instead of waiting out a timeout -- which means only "nothing yet", and cannot be told from "nothing ever" without this.

Send it before :meth:drain, not after: a drain cannot be reversed and refuses sends once it reaches its publish-flushing phase. The order is stop producing, send_eos(), drain(), close().

Notes

PUB/SUB is at-most-once, so this frame can be dropped like any other. It makes the common case end promptly, not reliably, and a subscriber that must not hang on a lost marker still needs a timeout. PUSH/PULL delivers it at-least-once, so handling it must be idempotent.

Examples:

>>> from doppler.stream import Publisher, CF32
>>> pub = Publisher("nats://127.0.0.1:4222/demo", CF32)
>>> pub.send_eos()
>>> pub.drain()

flush

flush(timeout_ms: int = 2000) -> None

Wait until the server has everything published so far.

:meth:send hands the frame to the client and returns; the client writes it in the background, so "the send returned" is not "the server has it". This waits for a round trip.

You do not need it before :meth:close: the NATS client flushes what is buffered when the connection closes. But it does so best-effort with a 500 ms cap and no way to report failure, so a backlog that cannot drain in half a second is dropped silently -- and on a link slower than loopback that is not a large backlog. Call this when losing the tail would matter, and you get a budget you chose and an answer you can act on. It is also the only way to ask the question without closing.

Parameters:

Name Type Description Default
timeout_ms int

How long to wait (default 2000).

2000

Raises:

Type Description
TimeoutError

If the budget ran out with data still buffered.

RuntimeError

If the publisher is closed, or the flush failed outright.

Examples:

>>> import numpy as np
>>> from doppler.stream import Publisher, CF64
>>> with Publisher("nats://127.0.0.1:4222/iq", CF64) as pub:
...     pub.send(np.zeros(8, dtype=np.complex128))
...     pub.flush()

close

close() -> None

Destroy the underlying NATS handle and release all resources.

Calls dp_pub_destroy(). Safe to call multiple times — subsequent calls are no-ops. After close() the object must not be used for sending.

Examples:

>>> from doppler.stream import Publisher, CF64
>>> pub = Publisher("nats://127.0.0.1:4222/t19103", CF64)
>>> pub.close()
>>> pub.close()  # idempotent — no error

Subscriber

NATS core SUB — receives signal frames from one or more Publishers.

Wraps dp_sub_t. Subscribes to the endpoint's iq.<subject>.> NATS subject, so it receives every frame any :class:Publisher bound to that subject sends (fan-out: every Subscriber on the subject gets every frame). There is no "connect to exactly one Publisher" concept — the broker mediates delivery by subject, not by socket.

For load-balanced consumption (each frame delivered to exactly one of several workers, with durable redelivery) use :class:Pull instead, which is backed by NATS JetStream rather than NATS core.

The recv path is zero-copy: the returned NumPy array's memory is owned by an internal dp_msg_t handle that points into the received NATS message; the buffer is freed only when the array (and all views of it) are garbage-collected.

Parameters:

Name Type Description Default
endpoint str

NATS endpoint, e.g. "nats://127.0.0.1:4222/iq" (host:port/subject; the subject defaults to "default" if omitted). Requires a running nats-server.

required

Raises:

Type Description
RuntimeError

If nats-server isn't reachable at endpoint.

Examples:

>>> from doppler.stream import Subscriber
>>> sub = Subscriber("nats://127.0.0.1:4222/t19104")
>>> type(sub).__name__
'Subscriber'
>>> sub.close()

Context-manager form:

>>> with Subscriber("nats://127.0.0.1:4222/t19105") as sub:
...     type(sub).__name__
'Subscriber'

Receive one frame (requires a live :class:Publisher):

>>> sub.recv(timeout_ms=500)
(array([1.+2.j, 3.+4.j]), {'sample_rate': 1000000.0, ...})

__init__

__init__(endpoint: str) -> None

Create a Subscriber socket and connect to endpoint.

Parameters:

Name Type Description Default
endpoint str

NATS endpoint, e.g. "nats://127.0.0.1:4222/iq".

required

__enter__

__enter__() -> Subscriber

Return self for use as a context manager.

Examples:

>>> from doppler.stream import Subscriber
>>> with Subscriber("nats://127.0.0.1:4222/t19106") as sub:
...     type(sub).__name__
'Subscriber'

__exit__

__exit__(*args: object) -> None

Call :meth:close on context-manager exit.

recv

recv(
    timeout_ms: int = -1,
) -> Tuple[NDArray[Any], Dict[str, Any]]

Receive one signal frame from the connected Publisher.

Blocks until a frame arrives or the optional timeout expires. The returned NumPy array is a zero-copy view into the received NATS message buffer; the buffer is freed when the array is garbage-collected.

Parameters:

Name Type Description Default
timeout_ms int

Milliseconds to wait before raising :exc:TimeoutError. -1 (default) blocks indefinitely; 0 returns immediately if no frame is available.

-1

Returns:

Name Type Description
samples ndarray

Decoded sample data. dtype follows the frame's own sample_type: numpy.complex64 / numpy.complex128 for :data:CF32 / :data:CF64, or numpy.int8 / numpy.int16 / numpy.int32 flat interleaved I/Q for :data:CI8 / :data:CI16 / :data:CI32.

header dict

Decoded dp_header_t fields:

sample_rate : float Samples per second as reported by the sender. center_freq : float Centre frequency in Hz as reported by the sender. sample_type : int Wire sample type (one of :data:CI8, :data:CI16, :data:CI32, :data:CF32, :data:CF64, :data:TLM16). timestamp_ns : int Frame timestamp (CLOCK_REALTIME nanoseconds) set by the sender at the moment of the send call. sequence : int Monotonically increasing per-sender frame counter; gaps indicate dropped frames. num_samples : int Number of IQ samples in the frame (len(samples) for CF32/CF64; len(samples)//2 for the interleaved integer types). protocol : int Wire protocol (0 = SIGS, 1 = DIFI/VITA 49). stream_id : int Opaque stream identifier (0 for SIGS protocol).

Raises:

Type Description
TimeoutError

If timeout_ms elapses before a frame arrives.

RuntimeError

If the underlying NATS recv fails for any other reason.

Examples:

>>> from doppler.stream import Subscriber
>>> with Subscriber("nats://127.0.0.1:4222/iq") as sub:
...     samples, hdr = sub.recv(timeout_ms=1000)
...     print(samples.dtype, hdr["sample_rate"])

reasm_stats

reasm_stats() -> dict[str, int]

What reassembling chunked frames has given up on this subscriber.

A frame above the server's max_payload (1 MiB by default) arrives as chunks, and recv() rebuilds one frame at a time. A frame it can no longer complete is given up and counted here rather than hidden: the next frame still arrives intact. A frame recv() DOES return is complete and exact. The counts run from the subscriber's creation and are never reset. Read them from the thread that calls recv(): a recv() on another thread releases the GIL and updates them without a lock.

Returns:

Type Description
dict

abandoned: partial assemblies given up -- a count of assemblies, not of frames lost (a late joiner abandons a frame it never had). Given up when a chunk of a different frame arrives, or an unchunked frame of the same stream, or an end-of-stream, which under one publisher per subject prove the frame in progress lost a chunk. rejected: chunks no frame could hold, dropped (off the chunk grid, out of range, or overlapping). mid_frame_timeouts: receives that timed out with a frame part-assembled; the frame is kept, and the next recv() resumes it.

Raises:

Type Description
RuntimeError

If the subscriber is closed.

Examples:

>>> from doppler.stream import Subscriber
>>> sub = Subscriber("nats://127.0.0.1:4222/t19124")
>>> sub.reasm_stats()
{'abandoned': 0, 'rejected': 0, 'mid_frame_timeouts': 0}
>>> sub.close()

close

close() -> None

Destroy the underlying NATS handle and release all resources.

Calls dp_sub_destroy(). Safe to call multiple times.

Examples:

>>> from doppler.stream import Subscriber
>>> sub = Subscriber("nats://127.0.0.1:4222/t19107")
>>> sub.close()
>>> sub.close()  # idempotent — no error

PUSH / PULL

Push

NATS JetStream work-queue producer — durable pipeline sender.

Wraps dp_push_t. Each :meth:send is a synchronous, server- acked JetStream publish: the frame is persisted (and replicated, on a clustered broker) before the call returns, so a producer crash never silently drops a frame. Each persisted frame is later delivered to exactly one :class:Pull worker (competing-consumers distribution), unlike :class:Publisher which fans out to every Subscriber.

Use the PUSH/PULL pattern when you have a pool of workers pulling from a shared, durable queue and want at-least-once delivery with redelivery on worker crash (see :meth:Pull.ack).

Both ends address the same NATS work-queue subject; there is no bind/connect distinction (the broker mediates delivery).

Parameters:

Name Type Description Default
endpoint str

NATS endpoint, e.g. "nats://127.0.0.1:4222/work".

required
sample_type int

Wire encoding. One of :data:CI8, :data:CI16, :data:CI32, :data:CF32, :data:CF64 (default), or (on a :class:Publisher) :data:TLM16. Raises :exc:ValueError for any other value.

...

Raises:

Type Description
ValueError

If sample_type is not one of the three supported values.

RuntimeError

If nats-server isn't reachable at endpoint.

Examples:

>>> from doppler.stream import Push, CF64
>>> push = Push("nats://127.0.0.1:4222/t19108", CF64)
>>> type(push).__name__
'Push'
>>> push.close()

Context-manager form:

>>> with Push("nats://127.0.0.1:4222/t19109", CF64) as push:
...     type(push).__name__
'Push'

Round-trip with a :class:Pull worker (requires a live connection):

>>> import numpy as np, time
>>> from doppler.stream import Pull
>>> push = Push("nats://127.0.0.1:4222/work", CF64)
>>> pull = Pull("nats://127.0.0.1:4222/work")
>>> time.sleep(0.05)
>>> push.send(np.ones(4, dtype=np.complex128))
>>> samples, hdr = pull.recv(timeout_ms=2000)
>>> push.close(); pull.close()

__init__

__init__(endpoint: str, sample_type: int = ...) -> None

Create a Push socket and bind to endpoint.

Parameters:

Name Type Description Default
endpoint str

NATS endpoint, e.g. "nats://127.0.0.1:4222/work".

required
sample_type int

Wire encoding: :data:CI8, :data:CI16, :data:CI32, :data:CF32, or :data:CF64 (default).

...

__enter__

__enter__() -> Push

Return self for use as a context manager.

Examples:

>>> from doppler.stream import Push, CF64
>>> with Push("nats://127.0.0.1:4222/t19110", CF64) as push:
...     type(push).__name__
'Push'

__exit__

__exit__(*args: object) -> None

Call :meth:close on context-manager exit.

send

send(
    samples: NDArray[Any],
    sample_rate: float = 0,
    center_freq: float = 0,
    timestamp_ns: int | None = None,
) -> None

Durably publish one block of samples to the work-queue.

Blocks until the broker acks that the frame is persisted (and replicated, on a clustered broker) — not until a Pull worker is ready; the frame waits in the queue for the next available worker. Releases the GIL while blocked on the broker round trip.

Parameters:

Name Type Description Default
samples ndarray

C-contiguous array whose dtype must match the socket's sample_type: numpy.complex64 for :data:CF32, numpy.complex128 for :data:CF64, or numpy.int8 / numpy.int16 / numpy.int32 for :data:CI8 / :data:CI16 / :data:CI32 (interleaved I/Q, length 2*n).

required
sample_rate float

Samples per second written into the header (default 0).

0
center_freq float

Centre frequency in Hz written into the header (default 0).

0
timestamp_ns int

UNIX nanoseconds to stamp on timestamp_ns instead of a fresh CLOCK_REALTIME read -- propagate an upstream message's own origin timestamp through this hop instead of overwriting it with "now" (default None: auto-stamp).

None

Raises:

Type Description
TypeError

If samples.dtype does not match the socket's sample_type.

ValueError

If samples is not C-contiguous.

RuntimeError

If the publish fails (e.g. the broker connection dropped).

Examples:

>>> from doppler.stream import Push, Pull, CF64
>>> import numpy as np, time
>>> push = Push("nats://127.0.0.1:4222/work", CF64)
>>> pull = Pull("nats://127.0.0.1:4222/work")
>>> time.sleep(0.05)
>>> push.send(np.array([1+2j, 3+4j],
...           dtype=np.complex128),
...           sample_rate=int(48000))
>>> samples, hdr = pull.recv(timeout_ms=2000)
>>> push.close(); pull.close()

delete_stream

delete_stream() -> None

Destroy the work-queue stream backing this endpoint.

Every frame still in the queue goes with it, acked or not.

Administrative, and deliberately never automatic: a work queue is shared infrastructure and outliving any one producer is the feature, so closing a Push must not end it. Call this only when you own the queue's lifetime — a test that invented the subject, or a tool tearing down what it provisioned. A work queue drops a frame only when a consumer acks it, so frames nobody consumed are kept forever otherwise (doppler#1136).

Only a Push provisions the stream, so a Pull cannot attach to the endpoint again until another Push re-creates it.

Raises:

Type Description
ValueError

If the push is already closed.

RuntimeError

If the broker refuses; the message carries what it said.

Examples:

>>> from doppler.stream import Push, CF64
>>> push = Push("nats://127.0.0.1:4222/t19112", CF64)
>>> push.delete_stream()
>>> push.close()

close

close() -> None

Destroy the underlying NATS handle and release all resources.

Calls dp_push_destroy(). Safe to call multiple times.

Examples:

>>> from doppler.stream import Push, CF64
>>> push = Push("nats://127.0.0.1:4222/t19111", CF64)
>>> push.close()
>>> push.close()  # idempotent — no error

Pull

NATS JetStream work-queue consumer — durable pipeline receiver.

Wraps dp_pull_t. Consumes frames persisted by one or more :class:Push producers. Multiple Pull workers can share the same durable consumer group; each frame is delivered to exactly one worker. Call :meth:ack once a frame is fully processed — an un-acked frame is redelivered if the worker dies first.

The recv path is zero-copy: see :class:Subscriber for the buffer lifetime semantics.

Both ends address the same NATS work-queue subject; there is no bind/connect distinction (the broker mediates delivery).

Parameters:

Name Type Description Default
endpoint str

NATS endpoint, e.g. "nats://127.0.0.1:4222/work". Requires a running nats-server -js (JetStream enabled).

required

Raises:

Type Description
RuntimeError

If nats-server isn't reachable at endpoint.

Examples:

>>> from doppler.stream import Push, Pull, CF64
>>> _ = Push("nats://127.0.0.1:4222/t19112", CF64)  # provisions the queue
>>> pull = Pull("nats://127.0.0.1:4222/t19112")
>>> type(pull).__name__
'Pull'
>>> pull.close()

Context-manager form:

>>> _ = Push("nats://127.0.0.1:4222/t19113", CF64)  # provisions the queue
>>> with Pull("nats://127.0.0.1:4222/t19113") as pull:
...     type(pull).__name__
'Pull'

Receive one frame (requires a live :class:Push):

>>> pull.recv(timeout_ms=500)
(array([1.+2.j, ...]), {'sample_rate': ..., ...})

__init__

__init__(endpoint: str) -> None

Create a Pull socket and connect to endpoint.

Parameters:

Name Type Description Default
endpoint str

NATS endpoint, e.g. "nats://127.0.0.1:4222/work".

required

__enter__

__enter__() -> Pull

Return self for use as a context manager.

Examples:

>>> from doppler.stream import Push, Pull, CF64
>>> _ = Push("nats://127.0.0.1:4222/t19114", CF64)  # provisions queue
>>> with Pull("nats://127.0.0.1:4222/t19114") as pull:
...     type(pull).__name__
'Pull'

__exit__

__exit__(*args: object) -> None

Call :meth:close on context-manager exit.

recv

recv(
    timeout_ms: int = -1,
) -> Tuple[NDArray[Any], Dict[str, Any]]

Receive one signal frame from the connected Push socket.

Blocks until a frame arrives or the optional timeout expires. The returned NumPy array is a zero-copy view into the received NATS message buffer; the buffer is freed when the array is garbage-collected.

Parameters:

Name Type Description Default
timeout_ms int

Milliseconds to wait before raising :exc:TimeoutError. -1 (default) blocks indefinitely; 0 returns immediately if no frame is available.

-1

Returns:

Name Type Description
samples ndarray

Decoded sample data. dtype follows the frame's own sample_type: numpy.complex64 / numpy.complex128 for :data:CF32 / :data:CF64, or numpy.int8 / numpy.int16 / numpy.int32 flat interleaved I/Q for :data:CI8 / :data:CI16 / :data:CI32.

header dict

Decoded dp_header_t fields — see :meth:Subscriber.recv for the full key list.

Raises:

Type Description
TimeoutError

If timeout_ms elapses before a frame arrives.

RuntimeError

If the underlying NATS recv fails for any other reason.

Examples:

>>> from doppler.stream import Pull
>>> with Pull("nats://127.0.0.1:4222/work") as pull:
...     samples, hdr = pull.recv(timeout_ms=1000)
...     print(samples.dtype, hdr["num_samples"])

ack

ack(samples: NDArray[Any]) -> None

Acknowledge a frame consumed from the JetStream work-queue.

Delivery is at-least-once: a frame stays pending until acked and is redelivered to another worker if this one dies first. Pass the array returned by :meth:recv once it has been fully processed, then drop the array. Ack before :meth:close: once the Pull that received the frame is closed, nothing can be sent.

A frame that did not come from a Pull -- a :class:Subscriber's, say -- has nothing to acknowledge: its ack is a no-op that never raises, whether or not its receiver is closed.

Parameters:

Name Type Description Default
samples ndarray

The samples array returned by :meth:recv (its buffer still alive — do not ack after the array is garbage-collected).

required

Raises:

Type Description
ValueError

If samples is not an un-freed :meth:recv result, or if the Pull that received it is closed. Nothing is sent then, and the broker redelivers the frame.

RuntimeError

If the broker could not be told.

Examples:

>>> from doppler.stream import Pull
>>> pull = Pull("nats://localhost:4222/work")
>>> samples, hdr = pull.recv(timeout_ms=1000)
>>> pull.ack(samples)

close

close() -> None

Destroy the underlying NATS handle and release all resources.

Calls dp_pull_destroy(). Safe to call multiple times.

Examples:

>>> from doppler.stream import Push, Pull, CF64
>>> _ = Push("nats://127.0.0.1:4222/t19115", CF64)  # provisions queue
>>> pull = Pull("nats://127.0.0.1:4222/t19115")
>>> pull.close()
>>> pull.close()  # idempotent — no error

REQ / REP

Requester

NATS request/reply — sends a request frame, then waits for a reply.

Wraps dp_req_t. Built on a NATS request: :meth:send publishes to the endpoint's subject with a reply-to address (a dedicated inbox created for this Requester), and :meth:recv waits on that same inbox for the :class:Replier's answer. Use this pattern strictly alternating (send, then recv, then send again) — unlike ZMQ's REQ socket there is no enforced state machine, but calling recv before a matching send simply blocks/times out on an empty inbox rather than raising immediately.

Complements :class:Replier. Use this pattern for control-plane messages (tuning commands, metadata queries) or synchronous signal-frame RPC where one peer processes each frame and returns a result.

Both ends address the same NATS subject; there is no bind/connect distinction (the broker mediates delivery via the reply-to inbox).

Parameters:

Name Type Description Default
endpoint str

NATS endpoint, e.g. "nats://127.0.0.1:4222/ctrl".

required
sample_type int

Wire encoding of frames sent by this socket. One of :data:CI8, :data:CI16, :data:CI32, :data:CF32, or :data:CF64 (default). The reply frame's type is determined by the :class:Replier.

...

Raises:

Type Description
ValueError

If sample_type is not one of the three supported values.

RuntimeError

If nats-server isn't reachable at endpoint.

Examples:

>>> from doppler.stream import Requester, CF64
>>> req = Requester("nats://127.0.0.1:4222/t19116", CF64)
>>> type(req).__name__
'Requester'
>>> req.close()

Context-manager form:

>>> with Requester("nats://127.0.0.1:4222/t19117", CF64) as req:
...     type(req).__name__
'Requester'

Full REQ/REP round-trip (requires a live :class:Replier):

>>> import numpy as np, time
>>> from doppler.stream import Replier
>>> rep = Replier("nats://127.0.0.1:4222/ctrl", CF64)
>>> req = Requester("nats://127.0.0.1:4222/ctrl", CF64)
>>> time.sleep(0.05)
>>> req.send(np.ones(4, dtype=np.complex128),
...          sample_rate=int(1e6))
>>> request, hdr = rep.recv(timeout_ms=2000)
>>> rep.send(request)
>>> reply, hdr2 = req.recv(timeout_ms=2000)
>>> req.close(); rep.close()

__init__

__init__(endpoint: str, sample_type: int = ...) -> None

Create a Requester socket and connect to endpoint.

Parameters:

Name Type Description Default
endpoint str

NATS endpoint, e.g. "nats://127.0.0.1:4222/ctrl".

required
sample_type int

Wire encoding of frames sent by this socket: :data:CI8, :data:CI16, :data:CI32, :data:CF32, or :data:CF64 (default).

...

__enter__

__enter__() -> Requester

Return self for use as a context manager.

Examples:

>>> from doppler.stream import Requester, CF64
>>> with Requester("nats://127.0.0.1:4222/t19118", CF64) as req:
...     type(req).__name__
'Requester'

__exit__

__exit__(*args: object) -> None

Call :meth:close on context-manager exit.

send

send(
    samples: NDArray[Any],
    sample_rate: float = 0,
    center_freq: float = 0,
    timestamp_ns: int | None = None,
) -> None

Publish a request frame with this Requester's reply-to inbox.

Use strictly alternating with :meth:recv (send, recv, send, recv, ...) — sending again before consuming the previous reply works mechanically but leaves an unread message in the inbox for the next :meth:recv to pick up, which will desync the request/reply pairing.

Parameters:

Name Type Description Default
samples ndarray

C-contiguous array whose dtype must match the socket's sample_type: numpy.complex64 for :data:CF32, numpy.complex128 for :data:CF64, or numpy.int8 / numpy.int16 / numpy.int32 for :data:CI8 / :data:CI16 / :data:CI32.

required
sample_rate float

Samples per second written into the header (default 0).

0
center_freq float

Centre frequency in Hz written into the header (default 0).

0
timestamp_ns int

UNIX nanoseconds to stamp on timestamp_ns instead of a fresh CLOCK_REALTIME read (default None: auto-stamp).

None

Raises:

Type Description
TypeError

If samples.dtype does not match the socket's sample_type.

ValueError

If samples is not C-contiguous.

RuntimeError

If the publish fails (e.g. the broker connection dropped).

Examples:

>>> from doppler.stream import Requester, CF64
>>> import numpy as np
>>> req = Requester("nats://127.0.0.1:4222/ctrl", CF64)
>>> req.send(np.ones(4, dtype=np.complex128),
...          sample_rate=int(1e6))

recv

recv(
    timeout_ms: int = -1,
) -> Tuple[NDArray[Any], Dict[str, Any]]

Receive the reply frame from the :class:Replier.

Waits on this Requester's reply-to inbox. Call after :meth:send; calling without a prior send just blocks (or times out) on an empty inbox rather than raising immediately.

Parameters:

Name Type Description Default
timeout_ms int

Milliseconds to wait before raising :exc:TimeoutError. -1 (default) blocks indefinitely.

-1

Returns:

Name Type Description
samples ndarray

Decoded reply data. dtype mirrors the :class:Replier's sample_type.

header dict

Decoded dp_header_t fields — see :meth:Subscriber.recv for the full key list.

Raises:

Type Description
TimeoutError

If timeout_ms elapses before a reply arrives (including calling recv without a prior send).

Examples:

>>> from doppler.stream import Requester, CF64
>>> req = Requester("nats://127.0.0.1:4222/ctrl", CF64)
>>> req.send(np.ones(4, dtype=np.complex128))
>>> reply, hdr = req.recv(timeout_ms=2000)

close

close() -> None

Destroy the underlying NATS handle and release all resources.

Calls dp_req_destroy(). Safe to call multiple times.

Examples:

>>> from doppler.stream import Requester, CF64
>>> req = Requester("nats://127.0.0.1:4222/t19119", CF64)
>>> req.close()
>>> req.close()  # idempotent — no error

Replier

NATS request/reply — receives a request frame, then sends a reply.

Wraps dp_rep_t. Subscribes to the endpoint's subject; each :meth:recv captures the request's reply-to inbox, and the next :meth:send publishes the reply directly to that inbox. Calling send before recv has captured a request raises :exc:RuntimeError immediately (there is no reply-to target yet) — unlike ZMQ's REP FSM, recv itself has no such restriction and simply waits for the next request.

Complements :class:Requester. Use for control-plane responses or signal-frame RPC where the Replier processes each frame and returns a result synchronously.

Both ends address the same NATS subject; there is no bind/connect distinction (the broker mediates delivery via the reply-to inbox).

Parameters:

Name Type Description Default
endpoint str

NATS endpoint, e.g. "nats://127.0.0.1:4222/ctrl".

required
sample_type int

Wire encoding of frames sent by this socket (the reply). One of :data:CI8, :data:CI16, :data:CI32, :data:CF32, or :data:CF64 (default). The request frame's type is determined by the :class:Requester.

...

Raises:

Type Description
ValueError

If sample_type is not one of the three supported values.

RuntimeError

If nats-server isn't reachable at endpoint.

Examples:

>>> from doppler.stream import Replier, CF64
>>> rep = Replier("nats://127.0.0.1:4222/t19120", CF64)
>>> type(rep).__name__
'Replier'
>>> rep.close()

Context-manager form:

>>> with Replier("nats://127.0.0.1:4222/t19121", CF64) as rep:
...     type(rep).__name__
'Replier'

Full REQ/REP server loop (requires a live :class:Requester):

>>> from doppler.stream import Requester
>>> import numpy as np, time
>>> rep = Replier("nats://127.0.0.1:4222/ctrl", CF64)
>>> req = Requester("nats://127.0.0.1:4222/ctrl", CF64)
>>> time.sleep(0.05)
>>> req.send(np.ones(4, dtype=np.complex128))
>>> request, hdr = rep.recv(timeout_ms=2000)
>>> rep.send(request, sample_rate=hdr["sample_rate"])
>>> reply, _ = req.recv(timeout_ms=2000)
>>> req.close(); rep.close()

__init__

__init__(endpoint: str, sample_type: int = ...) -> None

Create a Replier socket and bind to endpoint.

Parameters:

Name Type Description Default
endpoint str

NATS endpoint, e.g. "nats://127.0.0.1:4222/ctrl".

required
sample_type int

Wire encoding of reply frames sent by this socket: :data:CI8, :data:CI16, :data:CI32, :data:CF32, or :data:CF64 (default).

...

__enter__

__enter__() -> Replier

Return self for use as a context manager.

Examples:

>>> from doppler.stream import Replier, CF64
>>> with Replier("nats://127.0.0.1:4222/t19122", CF64) as rep:
...     type(rep).__name__
'Replier'

__exit__

__exit__(*args: object) -> None

Call :meth:close on context-manager exit.

recv

recv(
    timeout_ms: int = -1,
) -> Tuple[NDArray[Any], Dict[str, Any]]

Receive one request frame from a :class:Requester.

Blocks until a request arrives or the timeout expires. Captures the request's reply-to inbox so the next :meth:send reaches the right Requester.

Parameters:

Name Type Description Default
timeout_ms int

Milliseconds to wait before raising :exc:TimeoutError. -1 (default) blocks indefinitely.

-1

Returns:

Name Type Description
samples ndarray

Decoded request data. dtype mirrors the :class:Requester's sample_type.

header dict

Decoded dp_header_t fields — see :meth:Subscriber.recv for the full key list.

Raises:

Type Description
TimeoutError

If timeout_ms elapses before a request arrives.

RuntimeError

If the underlying NATS recv fails for any other reason.

Examples:

>>> from doppler.stream import Replier, CF64
>>> rep = Replier("nats://127.0.0.1:4222/ctrl", CF64)
>>> request, hdr = rep.recv(timeout_ms=5000)
>>> rep.send(request,
...          sample_rate=hdr["sample_rate"])

send

send(
    samples: NDArray[Any],
    sample_rate: float = 0,
    center_freq: float = 0,
    timestamp_ns: int | None = None,
) -> None

Publish the reply to the request's captured reply-to inbox.

Must be called after :meth:recv; calling without a prior recv raises :exc:RuntimeError immediately (there is no reply-to target yet). After this call returns, the Replier is ready to recv the next request.

Parameters:

Name Type Description Default
samples ndarray

C-contiguous array whose dtype must match the socket's sample_type: numpy.complex64 for :data:CF32, numpy.complex128 for :data:CF64, or numpy.int8 / numpy.int16 / numpy.int32 for :data:CI8 / :data:CI16 / :data:CI32.

required
sample_rate float

Samples per second written into the reply header (default 0).

0
center_freq float

Centre frequency in Hz written into the reply header (default 0).

0
timestamp_ns int

UNIX nanoseconds to stamp on timestamp_ns instead of a fresh CLOCK_REALTIME read (default None: auto-stamp).

None

Raises:

Type Description
TypeError

If samples.dtype does not match the socket's sample_type.

ValueError

If samples is not C-contiguous.

RuntimeError

If send is called before recv has captured a request, or the publish otherwise fails.

Examples:

>>> from doppler.stream import Replier, CF64
>>> import numpy as np
>>> rep = Replier("nats://127.0.0.1:4222/ctrl", CF64)
>>> request, hdr = rep.recv(timeout_ms=5000)
>>> rep.send(np.zeros_like(request),
...          sample_rate=hdr["sample_rate"])

close

close() -> None

Destroy the underlying NATS handle and release all resources.

Calls dp_rep_destroy(). Safe to call multiple times.

Examples:

>>> from doppler.stream import Replier, CF64
>>> rep = Replier("nats://127.0.0.1:4222/t19123", CF64)
>>> rep.close()
>>> rep.close()  # idempotent — no error

Helpers

get_timestamp_ns

get_timestamp_ns() -> int

Current wall-clock time in nanoseconds since the UNIX epoch.

Calls clock_gettime(CLOCK_REALTIME) in the C layer. Useful for stamping outgoing frames when the caller does not supply its own timestamp_ns, or for computing round-trip latency from the value returned in the received header dict.

Returns:

Type Description
int

Non-negative nanosecond timestamp. Guaranteed to be monotonically non-decreasing within a single process on any POSIX system that supports CLOCK_REALTIME.

Examples:

>>> from doppler.stream import get_timestamp_ns
>>> t = get_timestamp_ns()
>>> isinstance(t, int)
True
>>> t > 0
True

Stopping a blocked receive

The interrupt is no longer part of this module. It was never a streaming feature — the same flag ends a ring wait and a capture read — so it now lives on its own, as an object rather than as six free functions:

import numpy as np

from doppler.interrupt import Interrupt

it = Interrupt(np.array([], dtype=np.int32))

# Requested from another thread, or from a SIGINT handler this guard
# installed. A Subscriber.recv() blocked in the NATS client raises
# KeyboardInterrupt; so does a ring wait, and so does a capture read.
it.interrupt()
assert it.interrupted()
it.resume()

doppler.stream.interrupt_on_sigint(), interrupt(), resume(), interrupted(), set_interrupt_latency_ms() and interrupt_latency_ms() are gone, not deprecated. See Interrupt for the object that replaces them and Ending a wait for the contract it serves on all three transports.

mean_power

mean_power(samples: NDArray[Any]) -> float

Mean power of a complex sample block, normalised to full scale.

mean(|x|**2), with the integer formats divided by their full scale first, so the answer means the same thing whatever the wire carried and 10*log10() of it is dBFS in every case.

This is the same dp_mean_power() the C examples call -- one implementation, so the Python and C receivers cannot report different numbers for one frame, and neither has to spell the loop out again.

Parameters:

Name Type Description Default
samples ndarray

C-contiguous block: complex64/complex128 for :data:CF32/:data:CF64, or int8/int16/int32 interleaved I/Q (length 2*n) for :data:CI8/:data:CI16/ :data:CI32.

required

Returns:

Type Description
float

Mean power, 0.0 for an empty block.

Raises:

Type Description
TypeError

If the dtype is not one of the wire formats.

ValueError

If samples is not C-contiguous.

Examples:

>>> import numpy as np
>>> from doppler.stream import mean_power
>>> mean_power(np.ones(4, dtype=np.complex64))
1.0
>>> round(mean_power(np.full(8, 16384, dtype=np.int16)), 4)
0.5

format_name

format_name(code: int) -> str

The name of a wire format code.

The same dp_sample_type_str() the C face prints, so a Python receiver names a format exactly as the C one does rather than carrying a private code-to-name table.

Parameters:

Name Type Description Default
code int

A wire format, e.g. :data:CF64.

required

Returns:

Type Description
str

The format's name, or "UNKNOWN" for a code this build does not know.

Examples:

>>> from doppler.stream import format_name, CF64, CI8
>>> format_name(CF64), format_name(CI8)
('CF64', 'CI8')

Gallery — Waveform I/O — One Capture, Four File Types Design — Spectrum Analyzer, The Spectrogram — the measurement record, Streaming — one envelope, six roles, two planes, Telemetry — zero-cost scalar taps for running pipelines