Python Telemetry API¶
Scalar telemetry taps for running DSP objects, backed by dp_tlm
(native/inc/dp_tlm/dp_tlm_core.h): a named probe registry plus a
lock-free SPSC record ring. Instrumented objects attach a Telemetry
context and publish scalars (loop stress, AGC gain, lock metrics) from
their hot loops at event rate — one predicted-not-taken branch per event
when detached, one 16-byte ring write when attached, drop-and-count on
overrun so a slow reader can never stall the DSP thread. Design:
Telemetry.
Source:
src/doppler/telemetry/__init__.py
Telemetry¶
Telemetry(ring_records=16384) — ring capacity in records, a power of
two; a sub-page request is rounded up, so read the real size back from
.capacity.
| Member | Purpose |
|---|---|
probe(name, decim=1) |
Register (idempotent by name); emit every decim-th event |
probe_id(name) |
Name → id (KeyError if unknown) |
probe_names |
Full name -> id map (a property, not a call) |
emit(id, v) |
Producer-side record (Python events / tests) |
set_now(n) |
Stamp the sample index carried by subsequent records |
read(n=0) |
Drain into a structured array — 0 means everything available; non-blocking, consumer side |
emitted(id) |
Records written for one probe (post-decimation, post-drop) |
dropped |
Ring-overrun count (monotonic) |
capacity, probe_count |
Introspection |
_capsule |
The dp_tlm_t * attach point for instrumented objects |
read() returns a NumPy structured array with dtype
[("n", "<u8"), ("value", "<f4"), ("probe", "<u2"), ("flags", "<u2")] —
16 bytes per row, the exact C record layout.
Capture and MemoryCapture — losslessness you can prove¶
read() drains whatever is currently in the ring, and the ring drops on
overrun so the DSP thread can never stall. That is right for the emit path
and useless as an answer to "did I get everything?". A capture answers it,
and does so by arithmetic rather than by a ring size you were asked to
guess:
No probe emits more than once per input sample, so a block of
Ninputs emits at mostprobe_count * Nrecords. Size the ring to that bound and drain it to empty at every block boundary, and it cannot overflow.
set_now() already sits at the top of your block loop, so an existing
set_now / steps / read loop becomes lossless by opening a capture and
changing nothing else.
| Constructor | Where the records go |
|---|---|
MemoryCapture(tlm, block_samples, clock) |
Accumulated in memory; read back with .records() |
Capture(tlm, block_samples, path, clock) |
Straight to path; the file is the capture |
| Member | Purpose |
|---|---|
records(n=0) |
The captured records as a structured array (MemoryCapture only) |
block() |
Explicit boundary; set_now() does this for you |
close() |
Final drain and verdict — raises if anything was dropped |
count |
Records captured so far |
dropped |
Records lost by this capture (latched at open); non-zero means a hole |
block_samples is the largest number of input samples between two
boundaries — the step of your own block loop, not a buffer size to tune.
Over-stating it costs only memory; under-stating it is the one way to lose
a record.
Three things are worth knowing before you use it:
- Attach every probe first. The ring is sized from the probe table, so
opening before anything is registered gives a bound of zero and a
ValueErrorthat says so. - A hole raises, on every exit path.
close()reports the verdict and so does the destructor, which means awithblock raises rather than swallowing a corrupt capture. A capture with a hole is not a smaller capture, it is a wrong one. Capturehas norecords(). In file mode the file is the capture; anAttributeErrorsays that, where an empty array would read as "nothing was captured".
A file capture writes raw 16-byte records with no framing, so np.fromfile
reads it directly, plus a <path>-meta JSON sidecar carrying the probe
table, the counters and the time base. The time base is borrowed from
the pipeline's wfm.SampleClock rather than restated — two copies of a
time base drift, and the one written into a file is the copy nobody can
correct afterwards.
import numpy as np
from doppler.agc import AGC
from doppler.telemetry import MemoryCapture, Telemetry
from doppler.wfm import SampleClock
BLOCK = 4096
x = (0.05 * np.ones(8 * BLOCK)).astype(np.complex64)
tlm = Telemetry(1 << 12)
agc = AGC()
agc.set_telemetry(tlm, "agc", 1) # probes first: they set the bound
with MemoryCapture(tlm, BLOCK, SampleClock(1e6)) as cap:
for i in range(0, len(x), BLOCK):
tlm.set_now(i) # drains the block just finished
agc.steps(x[i : i + BLOCK])
cap.close() # raises if anything was lost
# Read inside the block: leaving it frees the capture, and touching it
# afterwards is a RuntimeError rather than a stale answer.
recs, dropped = cap.records(), cap.dropped
# Every gain update the AGC made, none missing, each with the sample index
# it happened at — which is what "lossless" buys over a polled read().
assert dropped == 0
assert len(recs) > 0
print(f"{len(recs)} records, gain {recs['value'][0]:.1f} -> "
f"{recs['value'][-1]:.1f} dB")
Instrumented objects¶
Every tracking loop (and the AGC) exposes
set_telemetry(tlm, prefix, decim=1); compositions forward the attach to
their embedded loops under a dotted sub-prefix. None detaches
everything the attach armed.
| Object | Probes under <prefix> |
Event rate |
|---|---|---|
agc.AGC |
.gain_db |
per gain update (amortized) |
track.Costas |
.lock, .e, .freq, .locked |
per dumped symbol |
track.Dll |
.e, .rate, .lock, .locked |
per code epoch |
track.CarrierNda |
.lock, .e, .freq + .agc.gain_db |
per sample — use decim |
track.SymbolSync |
.e, .freq, .rate, .lock, .locked |
per recovered symbol |
track.RateSync |
.e, .ctrl, .rate, .lock, .locked, .mu |
per recovered symbol |
track.MpskReceiver |
.lock, .tracking + .car.* + .sync.* (11 probes) |
per recovered symbol |
track.MpskReceiverR |
.lock, .tracking + .car.* + .sync.* (11 probes) |
per recovered symbol |
dsss.Despreader |
.car.* (Costas) + .code.* (DLL) |
per code period |
The decision probes pair with their statistic probes by design:
Dll's .locked is the verify-counted
lock-detector output next to
the .lock CFAR statistic it is judging, and MpskReceiver's
.tracking is the two-way handover state next to the .lock carrier
metric — plot the pair and you see exactly where the declare/drop rule
fired, without re-deriving thresholds consumer-side.
RateSync's .mu (also MpskReceiver's .sync.mu) is the odd one out:
every other timing probe is an error or a correction, while .mu is
the timing NCO's own phase — the terminal resampler's accumulator, in
[0, 1) output periods, so the polyphase arm the last output read is
mu * num_phases. It answers a question the error signals cannot: a
steady .mu means the loop has settled on a sampling phase, one that
slews and wraps means a residual rate error still unabsorbed (one wrap
is one output period of slip), and hash means the loop is being driven by
something that is not a timing error. Read it alongside .e and .ctrl
and the three give cause, response, and result.
Threading model¶
The ring is single-producer / single-consumer: everything that emits
(attached objects stepping, emit/set_now) stays on one producer
thread; read()/dropped may run on one other thread. Register all
probes before the producer starts.
Example — watching the AGC gain converge¶
Instrumented objects expose set_telemetry(tlm, prefix, decim=1); the
AGC registers "<prefix>.gain_db" and records the loop-filter integrator
once per gain-update event:
import numpy as np
from doppler.agc import AGC
from doppler.telemetry import Telemetry
tlm = Telemetry(1 << 14)
agc = AGC(ref_db=0.0, loop_bw=0.0025, alpha=0.05)
agc.set_telemetry(tlm, "agc", decim=1)
x = np.full(4096, 0.125 + 0j, dtype=np.complex64) # quiet input
agc.steps(x)
recs = tlm.read()
gain = recs[recs["probe"] == tlm.probe_id("agc.gain_db")]["value"]
assert len(gain) == 4096 // agc.decim # one record per control update
assert gain[-1] > gain[0] # commanded gain rises toward the reference
assert tlm.dropped == 0
Over the wire — TLM16 frames¶
Telemetry crosses processes as TLM16 frames on the NATS wire layer: a
C pipeline drains its ring with the dp_tlm_sink_* helper
(stream/tlm_sink.h, in the optional libdoppler_stream
component), and a Python producer publishes read() output directly.
Either way, Subscriber.recv() decodes the frame back into the exact
read() dtype:
from doppler.stream import Publisher, Subscriber, TLM16
pub = Publisher("nats://127.0.0.1:4222/tlm", TLM16)
sub = Subscriber("nats://127.0.0.1:4222/tlm")
pub.send(tlm.read()) # the structured array, verbatim
recs, hdr = sub.recv(timeout_ms=2000)
assert hdr["sample_type"] == TLM16
assert recs.dtype.names == ("n", "value", "probe", "flags")
Example — probes, records, decimation¶
Python-side producers emit named probes through the same ring:
from doppler.telemetry import Telemetry
tlm = Telemetry(1 << 12)
snr = tlm.probe("rx.snr_db", decim=1)
for block in range(4):
tlm.set_now(block * 4096)
tlm.emit(snr, 12.0 + block)
recs = tlm.read()
assert recs["n"].tolist() == [0, 4096, 8192, 12288]
assert recs["value"].tolist() == [12.0, 13.0, 14.0, 15.0]
Related pages¶
Gallery — Gallery, Capturing All Receiver Telemetry, Full-Chain Lock-Up, Telemetry: Many Emitters, One Consumer Design — Design, Telemetry — zero-cost scalar taps for running pipelines