Skip to content

Python Loop Filter API

The doppler.track module provides LoopFilter — a second-order proportional-integral loop filter, the shared engine of every tracking loop (Costas/PLL, DLL, symbol timing). An error e goes in, a control value comes out (control = integ + kp·e), and the integrator advances integ += ki·e, so the integrator holds the running frequency/rate estimate and kp·e is the instantaneous (phase) correction.

Source: src/doppler/track/__init__.py


How it works

The gains are derived from a loop noise bandwidth bn (normalized, cycles/sample), a damping factor zeta (0.707 = critically damped), and the update period t (samples):

wn = 8·zeta·bn / (4·zeta² + 1)
theta = wn·t
kp = 8·zeta·theta / (4 + 4·zeta·theta + theta²)
ki = 4·theta²     / (4 + 4·zeta·theta + theta²)

configure(bn, zeta, t) recomputes the gains while preserving the integrator (so a tracker can retune mid-stream without losing lock); reset() zeroes the integrator. The state struct is public C, so trackers embed it by value and drive it with the same kernel — there is no per-update allocation.


Examples

Drive a loop with a constant error

from doppler.track import LoopFilter

lf = LoopFilter(bn=0.02, zeta=0.707, t=1.0)
lf.step(1.0)                 # integ += ki; returns integ + kp
round(lf.integ, 6)           # == ki (one update of unit error)

Retune without losing the estimate

lf.configure(0.05, 0.707, 1.0)   # wider bandwidth; integ preserved
lf.reset()                       # zero the integrator

Costas — carrier-tracking loop

Costas is the first loop built on LoopFilter: a continuous BPSK carrier-recovery loop. Per sample it de-rotates the input with the integer-phase source.LO NCO (carrier wipe-off); every tsamps samples it dumps the coherent integrate-and-dump accumulator, runs a decision-directed Costas phase discriminator, filters the error through an embedded LoopFilter, and steers the NCO frequency and phase. It tracks the small residual carrier offset left after FFT acquisition removes the bulk Doppler — an offset larger than the per-symbol integration bandwidth must be removed upstream, not by the loop. Because the steering NCO is integer-phase, the carrier phase is bounded and exactly reproducible (no double-accumulator drift).

FLL assist. Setting bn_fll > 0 enables a frequency-lock-loop assist: a data-wiped cross-product frequency discriminator over consecutive prompts whose linear range is far wider than the phase discriminator's. It pulls the loop's frequency integrator onto a large or fast-moving residual the bare PLL cannot acquire, then the PLL refines phase (an FLL-assisted PLL). bn_fll = 0 (the default) is a pure Costas PLL.

See the carrier loop stress gallery page for the bare PLL stalling on a large residual while the FLL assist pulls it in.

from doppler.track import Costas
from doppler.wfm import Synth

rx = Synth(type="qpsk", sps=16, snr=20, freq=0.01).steps(4096)  # received IQ

# bn_fll > 0 adds the FLL assist for large/fast-moving residuals
c = Costas(bn=0.05, zeta=0.707, init_norm_freq=0.0, tsamps=16, bn_fll=0.03)
symbols = c.steps(rx)        # one complex prompt symbol per tsamps samples
f_est   = c.norm_freq        # tracked residual carrier (cycles/sample)
locked  = c.lock_metric      # |Re P|/|P| EMA, ~1.0 when phase-locked

CarrierMpsk — M-PSK carrier-tracking loop

CarrierMpsk is the M-ary generalization of Costas: the same integer-NCO wipe-off, coherent integrate-and-dump, embedded LoopFilter, and FLL assist, but with a decision-directed M-PSK phase discriminator instead of the BPSK one. Each symbol it slices the prompt to the nearest constellation point ahat and forms e = Im(P · conj(ahat)) / |P| (the sine of the residual phase error near lock). m selects the constellation — 2 (BPSK), 4 (QPSK), or 8 (8PSK); at m = 2 it is byte-for-byte the Costas loop (same prompt stream, same tracked frequency), which is the loop's validation anchor.

The loop locks to one of m phases — an M-fold ambiguity on absolute phase. Resolve it downstream with differential demapping (mpsk.mpsk_diff_demap) or a sync word; this loop only recovers the carrier and emits the prompts. The FLL assist (bn_fll > 0) matters more as m grows: 8PSK's phase discriminator is linear only over ±π/8, so a sizeable residual needs the wide cross-product frequency discriminator to pull in before the PLL can refine phase.

from doppler.track import CarrierMpsk

# QPSK carrier loop, 16 samples/symbol, FLL-assisted; all params keyword-capable
c = CarrierMpsk(bn=0.05, zeta=0.707, init_norm_freq=0.0, tsamps=16, bn_fll=0.01, m=4)
symbols = c.steps(rx)        # one complex prompt symbol per tsamps samples
f_est   = c.norm_freq        # tracked residual carrier (cycles/sample)
locked  = c.lock_metric      # Re(P conj ahat)/|P| EMA, ~1.0 when phase-locked
# resolve the M-fold ambiguity downstream, e.g. mpsk_diff_demap(mpsk_demap(...))

CarrierNda — non-data-aided carrier loop

CarrierNda is the non-data-aided carrier-recovery loop — the cold-start counterpart to CarrierMpsk. Per sample it de-rotates with the integer lo NCO; it filters the de-rotated samples through a free-running I/Q boxcar moving average of sps/n samples (one output per input sample — no rate change), and on every sample runs an M-th-power phase discriminator (/z⁴/z⁸ by repeated squaring). Raising the arm sample to the Mth power strips the M-PSK data, so the loop acquires the carrier with no symbol timing and no data present — a bare/unmodulated carrier, or modulated data before timing settles. phase_error = Im(z^M) (gain-normalized to a slope-2 S-curve for every M); lock is the M-th-power lock metric. It locks to one of m phases (M-fold ambiguity, resolved downstream). steps() returns the de-rotated sample stream. See the NDA carrier gallery and the MPSK receiver design.

from doppler.track import CarrierNda

# QPSK NDA loop, 8 samples/symbol, sps/n = 2-sample boxcar arm; keyword-capable
c = CarrierNda(bn=0.01, zeta=0.707, init_norm_freq=0.0, sps=8, n=4, m=4)
derot  = c.steps(rx)         # de-rotated samples (one per input sample)
f_est  = c.norm_freq         # tracked carrier (cycles/sample)
locked = c.lock              # M-th-power lock metric (normalised: ~1.0 at lock)

MpskReceiver — pulse-shaped M-PSK modem

MpskReceiver is a complete M-PSK demodulator that owns no filter, no NCO and no interpolator of its own: it is a matched down-converter with two loops closed around its two control ports. A MatchedDDC mixes, decimates and matched-filters in the dot products it was already doing (pulse="iandd" integrate-and-dump by default, or pulse="rrc" root-raised-cosine for band-limited links) — its terminal polyphase stage's bank is the matched filter and the arm it selects is the fractional symbol-timing delay. A carrier loop steers the LO (freq_ctrl); RateSync's own timing loop, reused rather than copied, steers the terminal accumulator (rate_ctrl).

Carrier recovery follows the project rule — predetection de-rotation (in the LO, at the front of the chain) and postdetection discrimination (on the matched-filtered symbols at the end of it). One discriminator does the work: the NDA M-th-power error on the on-time strobe, needing no data and no symbol timing, running from the first symbol to the last. Nothing gates it — lock and locked are indicators a caller reads, not inputs the loop obeys. The loop locks to one of m phases (M-fold ambiguity); resolve it with bits(..., differential=1) or a sync word.

There used to be a second, decision-directed discriminator behind an opt-in acq_to_track, on the reasoning that 8PSK's ±π/8 margin needs the lower-jitter error. Measured, it was worth 0.09 dB at the 8PSK anchor while moving ~99% of the recovered samples, so it is gone (#877).

Because the front end plans its own cascade, sps is a float — an irrational samples-per-symbol (a free-running ADC clock against the symbol clock) is no harder than an integer one. steps() returns the recovered symbols; bits() returns hard Gray bits (coherent, or rotation-invariant differential). A DSSS-MPSK receiver is Dll(segments) → MpskReceiver. All constructor parameters are keyword-capable with defaults. See the MPSK receiver gallery and the MPSK receiver design.

from doppler.track import MpskReceiver
from doppler.wfm import Synth

iq = Synth(type="qpsk", sps=8, snr=20).steps(4096)  # received IQ

# QPSK, 8 samples/symbol, I&D matched filter; NDA from the first strobe
rx = MpskReceiver(m=4, sps=8, m_out=4, pulse="iandd",
                  bn_carrier=0.005, bn_timing=0.01,
                  lock_thresh=0.4)
sym  = rx.steps(iq)          # recovered symbols (~ len(iq) / sps)
bits = rx.bits(iq)           # hard Gray bits (LSB-first per symbol)
f    = rx.norm_freq          # tracked carrier (cycles/sample)
lk   = rx.lock               # carrier lock metric (-> + at lock, every M)

Two parameters changed meaning in the cascade rebuild

  • n is now m_out, and it means something different. n sized a separate NDA arm (window = sps/n); that arm no longer exists. m_out is the terminal stage's outputs per symbol (even, 2–8, default 8), which sets the Gardner strobe/gate geometry. The default is 8 because that is where an I&D matched filter reaches the coherent bound: measured on QPSK at sps = 8 against EVM_dB = -(Es/N0)_dB, m_out = 8 lands 0.41 dB off the bound at 18 dB Es/N0 where m_out = 4 loses 3.11 dB. Never pair 2 with pulse="iandd" — the rectangle is one symbol wide, so at 2 its matched filter degenerates to a two-tap sum (measured lock statistic −0.34 at 2 against +0.95 at 4) and acquisition itself fails about half the time.
  • bn_carrier is normalised to the symbol rate, like bn_timing, rather than to the input sample rate. At sps = 8 the same number is now an 8× wider loop.

Outputs are also no longer bit-identical to releases before the rebuild (polyphase bank instead of a dense FIR, bank arm instead of a Farrow). Detection performance is unchanged; exact-output pins are not.

Pull-in range — set by the loop, not by a tap

An M-th-power discriminator updating at rate F can only observe |Δf| < F/(2M). It reads the on-time strobe, so F = Rs and the range is Rs/(2M) — measured 0.050·Rs for QPSK at sps=8 with the default m_out=8, and 0.010·Rs at m_out=4.

nda_tap used to select among three nodes and is gone (#832); the strobe won on every axis measured on the receiver's own waveform. See the design for the numbers and for the measurement trap that nearly enshrined the wrong answer.

One discriminator, and nothing waits

There is no handover, no warmup, no lock gate and no timing gate. The NDA M-th-power error steers the LO from the first output to the last. That is a reliability argument rather than a simplicity one: there is no state in which the receiver can be wrong about which mode it is in, because there is one — no declaring on garbage, no drop-back that never fires, and no metric that has to be trusted before the loop is allowed to act. See MPSK Receiver §2.1.

ContinuousMpskReceiver used to be a separate view here, existing only to pin acq_to_track = 0. With the handover deleted it pinned nothing and was a duplicate of MpskReceiver, so it is gone (#877) — as is configure_lock, which retuned the handover's detector and never the lock indicator's, so it desynced the two detectors it appeared to configure.

from doppler.track import MpskReceiver

# Continuous BPSK at 8 samples/symbol. The caller states the link,
# not the loops.
rx = MpskReceiver(m=2, sps=8.0, bn_carrier=0.02, bn_timing=0.01)
sym = rx.steps(iq)          # recovered symbols
f = rx.norm_freq            # tracked carrier (cycles/sample)

lock and locked are indicators, not gates. lock is the M-th-power statistic Re((z/|z|)^M) smoothed by an EMA; locked is a threshold test on it with hysteresis — 8 consecutive symbols above lock_thresh to declare, 32 below lock_drop_thresh to withdraw. Neither steers a loop or gates an output, so a wrong reading costs a caller their measurement window and costs the demodulator nothing.

That statistic measures phase coherence, not frequency error, so the instant locked declares says nothing about how converged the carrier estimate is — do not read it as "the estimate is good now". lock_time (the symbol of the first declaration) plus a settling budget is the question that actually asks about convergence.

rx.m_out, rx.num_phases, rx.lock_thresh   # 8, 64, 0.4999 — all derived

The M-fold phase ambiguity is permanent — no decision-directed stage anywhere pins the absolute phase — so a caller wanting bits rather than symbols needs either bits(..., differential=1) or a downstream sync word. Coherent demapping with neither is a misconfiguration, not a choice.


BpskReceiver — stated in the units a capture comes with

BpskReceiver is a view over the same core, and what makes it worth having is what it does not ask for. A caller holding a capture knows its sample rate, its symbol rate and its carrier frequency, in Hz. They do not know sps: that is fs / Rs, a ratio the library computes for its own use in planning a cascade. Requiring it makes the caller derive an internal quantity — and it does not stop at one parameter, because with sps in the constructor init_norm_freq has to be cycles per sample, so stating a carrier offset needs sps and fs together while the loop bandwidth on the next line is normalised to the symbol rate. One constructor, two normalisations, and the conversion between them is the caller's problem.

from doppler.track import BpskReceiver

rx = BpskReceiver(sample_rate_hz=8e6, symbol_rate_hz=1e6)
rx.m        # 2   — the type says it, so it is not a parameter
rx.sps      # 8.0 — computed from the two rates
rx.m_out    # 8   — derived, not chosen

Two required arguments against MpskReceiver's seventeen. m is carried by the class name; sps, m_out, num_phases and bn_agc_ratio are internal choices the object makes for itself; and carrier_freq_hz defaults to 0 for complex baseband. Everything a caller has a real reason to pin — the pulse, both loop bandwidths, differential, agc — is still there as a keyword.

m_out deriving rather than defaulting is not cosmetic. Pinning m_out=4 against the default I&D pulse is measured 3.11 dB off the coherent bound where the derived 8 is 0.41 dB off — the rectangle's matched filter is an m_out-tap sum, and four taps sample that integral too coarsely. A parameter nobody needed was a way to lose most of the link's margin quietly.

An impossible geometry is refused at construction rather than approximated: a non-positive rate makes sps meaningless, and a carrier outside Nyquist is a mis-stated capture rather than a tuning request. Both raise ValueError.

from doppler.track import BpskReceiver

# The carrier is past Nyquist for that sample rate.
BpskReceiver(sample_rate_hz=8e6, symbol_rate_hz=1e6, carrier_freq_hz=9e6)

MpskReceiverR — the real-input face

MpskReceiverR is MpskReceiver for a real IF: steps() and bits() take float32 samples of a real bandpass signal instead of complex baseband, and a MatchedDdcr front end tunes and converts it to complex internally. Every loop, discriminator, handover rule and demapper is the same implementation shared with the complex face — only the front end and the two rate conversions its halfband forces differ.

It is a view over MpskReceiver, like BpskReceiver above: one core, one state, one set of loops, reached through a second constructor. It was a separate class until just-makeit#1012, because a view shared its parent's methods verbatim and steps() takes a different dtype here; a view may now bind its own C symbol under the parent's Python name and declare its own signature. So the type/flavor rule is unchanged — a difference in constructor is a flavor, a difference in method signature is a separate type — and the dtype difference now sits on the flavor side of it because jm can express it.

Its one extra constraint is sps > 2 * m_out — the cascade behind the R2C halfband runs at twice the overall rate. init_norm_freq and norm_freq are both in cycles/sample at the real input rate; the tuning law and the intermediate-rate conversion are handled internally.

import numpy as np
from doppler.track import MpskReceiverR

fc = 0.10                                    # real IF, cycles/sample
rng = np.random.default_rng(3)
syms = np.exp(2j * np.pi * rng.integers(0, 4, 2000) / 4)
bb = np.repeat(syms, 24)                     # QPSK, 24 samples/symbol
n = np.arange(len(bb))
rf = (bb * np.exp(2j * np.pi * fc * n)).real.astype(np.float32) * 0.5

rx = MpskReceiverR(m=4, sps=24.0, m_out=4, init_norm_freq=fc, bn_carrier=0.002)
sym = rx.steps(rf)           # recovered symbols
lk  = rx.lock                # carrier lock metric

Dll — code-tracking loop

Dll is the code-loop counterpart to Costas: a delay-lock loop that tracks the phase of a continuous, repeating spreading code (PN / Gold sequence) on a carrier-wiped sample stream. Per sample it correlates the input against three taps of the local code — early (+spacing chips), prompt, late (-spacing chips) — accumulating an integrate-and-dump over one code period; per period it runs the non-coherent envelope discriminator (|E| - |L|) / (|E| + |L|), filters it through an embedded LoopFilter, and steers the code rate and phase. The half-chip discriminator is steep, so the loop bandwidth is small (a few thousandths); Dll is data-insensitive (it works on envelopes, so BPSK data flips don't matter).

In a full receiver the carrier loop (Costas) wipes the carrier and the Dll wipes the code; dsss.Despreader composes the two.

import numpy as np
from doppler.track import Dll
from doppler.wfm import Synth

code = np.random.default_rng(1).integers(0, 2, 127).astype(np.uint8)
rx = Synth(type="pn", pn_length=7, sps=8).steps(127 * 8 * 4)  # PN-spread IQ

# code: 0/1 chips for one period; sps samples per chip
d = Dll(code, sps=4, init_chip=0.0, bn=0.005, zeta=0.707, spacing=0.5)
symbols = d.steps(rx)        # one prompt symbol per code period
phase   = d.code_phase       # tracked code phase (chips)
rate    = d.code_rate        # tracked chip rate (~1.0 + code Doppler)

Sub-epoch partials for an asynchronous symbol clock (segments). When the data-symbol rate is on the order of the code-epoch rate but asynchronous to it, a coherent full-epoch despread straddles data transitions and collapses. Set segments > 1 to split each epoch into that many sub-epoch partial correlations: steps() then emits segments partial prompts per period — a stream at ~segments samples/symbol (since symbol ≈ epoch) for a downstream symbol matched filter + SymbolSync — and the code is tracked non-coherently across the partials ((Σ|E| − Σ|L|)/(Σ|E| + Σ|L|)), which a data flip cannot collapse. segments=1 (default) is the plain coherent DLL above; choose ≥ 2 for symbol-timing recovery. This segments mode is the streaming despreader: its job is to remove the PN code and output samples. Because the code loop is non-coherent it is carrier-blind — it locks with a residual carrier still on the samples, and (a short partial window being carrier-tolerant) the residual just rides out on the partials. Carrier recovery (Costas) and symbol extraction (SymbolSync) are downstream, fed from this output. See the streaming async despreader gallery and the async despreader design §3.

# 4 partial correlations per epoch -> non-coherent (carrier-blind) code tracking
# + an oversampled async-BPSK stream; carrier + symbol recovery are downstream.
d = Dll(code, sps=8, bn=0.002, zeta=0.707, spacing=0.5, segments=4)
partials = d.steps(rx)       # 4 partial prompts per code epoch (PN removed)
# downstream: Costas(...).steps(partials) -> SymbolSync(...).steps(...) -> bits

SymbolSync — symbol timing recovery

SymbolSync recovers the symbol clock of an asynchronous data stream (a symbol rate not locked to the sample clock). It is a Gardner timing-error detector closing a PI loop around an integer timing NCO and a Farrow interpolator: the NCO's post-wrap value is the interpolation fraction µ (free, no floating-point timing phase), so timing stays exact while only the interpolation is floating point. Two interpolants per symbol (on-time + mid) are derived from the phase value, and the loop steers the NCO frequency only — slip-free, so the strobe count never drifts.

steps() emits one timing-corrected symbol per recovered instant; rate is the tracked samples/symbol; order picks the Farrow interpolator. See the symbol-timing gallery page for the loop locking and tracking an asynchronous clock end to end.

from doppler.track import SymbolSync

ss = SymbolSync(sps=4, bn=0.01, zeta=0.707, order="cubic")
symbols = ss.steps(rx)   # timing-corrected symbols
ss.rate                  # recovered samples/symbol

RateSync — matched filtering and timing in one dot product

RateSync solves the same problem as SymbolSync and shares its detectors and loop, but it is built the other way round. Instead of a matched FIR followed by a Farrow interpolator steered by a timing NCO, it owns a MatchedRateConverter whose terminal stage carries the pulse — so the cascade's last dot product is the matched filter, and the polyphase arm that dot product selects is the fractional timing delay. One filter, no Farrow, no separate matched-filtering pass.

Two things follow from that, and they are the reason to reach for it:

  • sps is a double. 4, 17.33389, an irrational ratio, or a slowly drifting clock all work by construction, because the terminal stage's accumulator is a double and the loop only has to steer the strobe. That is the real case whenever the ADC clock free-runs against the symbol clock.
  • A high input rate is nearly free. The cascade's HB/CIC stages do the bulk decimation at no multiplies, so the matched-filter bank is sized by the post-decimation rate. The bank is the same size at 4 samples per symbol and at 256 — where filtering at the input rate would need thousands of taps per arm.
from doppler.track import RateSync

rx_sync = RateSync(sps=17.33389, pulse="rrc", beta=0.35, span=8, m=2, bn=0.01)
symbols = rx_sync.steps(rx)   # one symbol per recovered instant
rx_sync.rate                  # tracked samples/symbol -- the clock estimate
rx_sync.locked                # verify-counted timing-lock decision

Judge lock by lock_stat / locked rather than by an error-vector magnitude: a single cycle slip during acquisition drags a windowed EVM by 20 dB while the eye is wide open. And check clipped at least once against real input — the cascade inherits its CIC's ±1.0 input bound, and overdriving it costs ~25 dB with a perfectly healthy lock.

Use m >= 4 with pulse="iandd": the rectangle is one symbol wide, so at m = 2 its matched filter is a two-tap sum and the eye barely opens. The RRC spans many symbols and is unaffected.

SymbolSync remains the answer when the matched filter is one this family does not build, or when the front end is already at a small integer sps and a Farrow interpolator is the cheaper shape.

LoopFilter

Create a loop_filter instance, validating its arguments.

Parameters:

Name Type Description Default
bn float

Loop noise bandwidth, normalized cycles/sample; >= 0 and finite (default 0.01).

0.01
zeta float

Damping factor; > 0 and finite (default 0.707).

0.707
t float

Update period in samples; > 0 and finite (default 1.0).

1.0

Raises:

Type Description
ValueError

If construction fails. The exception message is ``bn must be >= 0, zeta

0 and t > 0, and all three finite``.

Examples:

Create with defaults:

>>> from doppler.track import LoopFilter
>>> obj = LoopFilter(bn=0.01, zeta=0.707, t=1.0)

kp property

kp: float

proportional gain (derived from bn, zeta, t).

ki property

ki: float

integral gain (derived from bn, zeta, t).

integ property writable

integ: float

integrator memory = running rate/freq estimate.

bn property

bn: float

loop noise bandwidth, normalized cycles/sample.

zeta property

zeta: float

damping factor (0.707 = critically damped).

t property

t: float

update period in samples.

step

step(x: float) -> float

Advance the loop one update with error x and return the control value the tracker should apply.

The PI recurrence is integ += ki*x; control = integ + kp*x: the integrator accumulates the running frequency/rate estimate while the proportional term kp*x is the instantaneous phase nudge.

Fed a constant error with nothing closing the loop, the integrator — and therefore the control — ramps without bound; measured at 1.84x between updates 200 and 400 at bn = 0.02. That is the accumulation working, not a defect, and it is what pulls a Costas/DLL/timing loop into lock once the loop IS closed, because a converging loop is one whose error is being driven to zero by the correction. Convergence is a property of the closed loop; this function is one term in it.

Parameters:

Name Type Description Default
x float

Loop error (discriminator output) for this update.

required

Returns:

Type Description
float

Control value integ+kp*x to drive the NCO / interpolator.

Examples:

>>> from doppler.track import LoopFilter
>>> lf = LoopFilter(bn=0.02, zeta=0.707, t=1.0)
>>> round(lf.step(1.0), 6)   # unit error: control = ki + kp
0.05331
>>> round(lf.integ, 6)       # integrator now holds ki
0.001385

steps

steps(
    x: NDArray[float64], out: NDArray[float64] | None = None
) -> NDArray[np.float64]

Filter a whole block of loop errors, returning the control value for each update.

Equivalent to calling loop_filter_step() once per element of x in order, carrying the integrator across the block, so the loop's memory and lock state persist from one call to the next. This is the block path used to run a captured error sequence through the filter in one shot — a plain per-element loop, not a vectorized one: the recurrence is sequential, so each update depends on the one before it.

Parameters:

Name Type Description Default
x NDArray[float64]

Loop-error array, one discriminator sample per update.

required

Returns:

Type Description
NDArray[float64]

Output.

Examples:

>>> import numpy as np
>>> from doppler.track import LoopFilter
>>> lf = LoopFilter(bn=0.05, zeta=0.707, t=1.0)
>>> ctl = lf.steps(np.full(50, 0.1))   # constant error into the loop
>>> round(float(ctl[0]), 4)            # first control nudge
0.0133
>>> round(float(ctl[-1]), 4)           # open loop: ramping
0.0541

configure

configure(bn: float, zeta: float, t: float) -> None

Recompute the loop gains for a new (bn, zeta, t); preserves the integrator.

Recomputes the proportional and integral gains from the standard 2nd-order form but leaves integ untouched, so a loop can be widened for fast acquisition and then narrowed for steady-state tracking while holding its accumulated frequency/rate estimate — the retune preserves lock.

Parameters:

Name Type Description Default
bn float

Loop noise bandwidth, normalized cycles/sample (>= 0).

required
zeta float

Damping factor (typically 0.707).

required
t float

Update period in samples (> 0).

required

Examples:

>>> from doppler.track import LoopFilter
>>> lf = LoopFilter(bn=0.01, zeta=0.707, t=1.0)
>>> _ = lf.step(1.0)
>>> before = round(lf.integ, 6)
>>> lf.configure(0.05, 0.707, 1.0)   # widen the loop, keep lock
>>> round(lf.integ, 6) == before     # integrator preserved
True
>>> round(lf.kp, 6)                  # proportional gain rose
0.124728

reset

reset() -> None

Zero the integrator; keep the configured gains.

Clears the accumulated frequency/rate estimate (integ) back to zero but leaves kp / ki as configured, so the loop reacquires from a clean slate at its current bandwidth — the right thing when a tracker drops lock and must restart, without re-deriving gains.

Examples:

>>> from doppler.track import LoopFilter
>>> lf = LoopFilter(bn=0.02, zeta=0.707, t=1.0)
>>> for _ in range(10):
...     _ = lf.step(1.0)             # ramp the integrator
>>> round(lf.integ, 6)
0.013849
>>> lf.reset()
>>> lf.integ                          # integrator cleared, gains kept
0.0

state_bytes

state_bytes() -> int

Size in bytes of this object's serialized state.

The exact length get_state returns and set_state requires. It depends on how the object was constructed (state arrays are sized at construction), so read it from the instance rather than assuming a constant.

Raises RuntimeError if the LoopFilter has already been destroyed.

Returns:

Type Description
int

Byte length of one serialized state blob.

get_state

get_state() -> bytes

Serialize this object's mutable state to bytes.

Captures exactly the state that evolves as the object runs, so a blob taken now and restored later resumes from this point. Construction parameters are not included: restore into an object built the same way.

The blob is opaque and always state_bytes() long. Its layout is an implementation detail of the C core and is not a stable format across builds.

Raises RuntimeError if the LoopFilter has already been destroyed.

Returns:

Type Description
bytes

Opaque snapshot, state_bytes() bytes long.

set_state

set_state(blob: bytes) -> None

Restore mutable state from a get_state() blob.

Overwrites the live state in place; the object keeps the parameters it was constructed with. Length is validated against state_bytes() before the blob is handed to the C core, and the core may reject it as well.

Raises TypeError if blob is not bytes, ValueError if its length differs from state_bytes() or the core rejects it, and RuntimeError if the LoopFilter has already been destroyed.

Parameters:

Name Type Description Default
blob bytes

A get_state() blob from this type, exactly state_bytes() long.

required

destroy

destroy() -> None

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__() -> LoopFilter

Enter a context manager, returning this object.

Lets a LoopFilter be used in a with statement so its C resources are released deterministically on exit rather than at collection time.

Returns:

Type Description
LoopFilter

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 LoopFilter.

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.

...

Costas

Costas component.

Parameters:

Name Type Description Default
bn float

bn constructor parameter.

0.05
zeta float

zeta constructor parameter.

0.707
init_norm_freq float

init_norm_freq constructor parameter.

0.0
tsamps int

tsamps constructor parameter.

64
bn_fll float

bn_fll constructor parameter.

0.0

Examples:

Create with defaults:

>>> from doppler.track import Costas
>>> obj = Costas(
...     bn=0.05,
...     zeta=0.707,
...     init_norm_freq=0.0,
...     tsamps=64,
...     bn_fll=0.0,
... )

bn property writable

bn: float

PLL loop noise bandwidth (retained).

norm_freq property writable

norm_freq: float

Norm freq.

lock_metric property

lock_metric: float

EMA of |Re P|/|P| (1 = locked).

locked property

locked: bool

Current carrier lock decision: True after the verify count of consecutive above-threshold symbols, False again after the drop count of consecutive below-threshold ones (see configure_lock).

last_error property

last_error: float

last PLL discriminator (loop stress).

bn_fll property writable

bn_fll: float

FLL-assist bandwidth (0 = pure PLL).

steps

steps(
    x: NDArray[complex64],
    out: NDArray[complex64] | None = None,
) -> NDArray[np.complex64]

De-rotate a cf32 block with the integer-NCO carrier, coherently integrate over each tsamps-sample symbol, run the decision-directed Costas discriminator, and emit one complex prompt symbol per symbol.

The streaming Python face of the loop. For every input sample it wipes the (tracked) carrier off x with the integer-phase NCO, sums the result into the coherent integrate-and-dump accumulator, and on each symbol boundary (one every tsamps samples) dumps the accumulator as the prompt, runs the BPSK Costas discriminator to steer the NCO frequency and phase, and appends the mean-scaled prompt to the output. Loop state carries across calls, so a long capture can be fed block by block; exactly one prompt symbol comes out per tsamps input samples.

Parameters:

Name Type Description Default
x NDArray[complex64]

Input samples, one complex baseband sample each.

required
out NDArray[complex64] | None

Prompt-symbol output buffer.

None

Returns:

Type Description
NDArray[complex64]

Number of prompt symbols written to out (one per tsamps input samples). On the Python face this is the recovered-symbol array.

Examples:

>>> import numpy as np
>>> from doppler.track import Costas
>>> tsamps = 16
>>> rng = np.random.default_rng(1)
>>> bits = rng.integers(0, 2, 4000) * 2 - 1
>>> sig = np.repeat(bits.astype(np.complex64), tsamps)
>>> k = np.arange(len(sig))
>>> rx = (sig * np.exp(2j * np.pi * 0.003 * k)).astype(np.complex64)
>>> c = Costas(bn=0.05, zeta=0.707, tsamps=tsamps)
>>> sym = c.steps(rx)             # one prompt per tsamps samples
>>> sym.shape
(4000,)
>>> round(c.norm_freq, 4)         # pulled onto the 0.003 residual
0.003
>>> c.lock_metric > 0.9
True

steps_max_out

steps_max_out() -> int

Largest number of samples steps() can return in the current state.

Size an out= buffer with this before calling steps(), 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 steps_max_out() replaces this text.

Returns:

Type Description
int

Upper bound on the output length; the actual call may return fewer.

set_telemetry

set_telemetry(
    tlm: object | None, prefix: str, decim: int = 1
) -> None

Attach (or detach) a telemetry context and register the carrier loop's probes on it. Registers four probes, emitted once per dumped symbol and further thinned by decim: ".lock" (the |Re P|/|P| lock-metric EMA, 1 = phase-locked), ".e" (the PLL discriminator output — the loop stress), ".freq" (the tracked NCO frequency, cycles/sample) and ".locked" (the verify-counted lock decision, 0/1 — see costas_configure_lock). Passing NULL detaches. Setup path, never hot: call before the producer thread starts stepping; the context is borrowed and must outlive the attachment (SPSC rules in dp_tlm/dp_tlm_core.h).

Parameters:

Name Type Description Default
tlm object | None

Telemetry context to attach, or NULL to detach.

required
prefix str

Probe-name prefix, e.g. "car" or "ch0.car".

required
decim int

Emit every decim-th symbol; >= 1.

1

Raises:

Type Description
ValueError

If the C call returns a non-zero status. The exception message is set_telemetry failed, with the return code appended (gh-869).

Examples:

>>> import numpy as np
>>> from doppler.track import Costas
>>> from doppler.telemetry import Telemetry
>>> tlm = Telemetry(1 << 12)
>>> c = Costas(bn=0.05, zeta=0.707, tsamps=64)
>>> c.set_telemetry(tlm, "car")
>>> sorted(tlm.probe_names)
['car.e', 'car.freq', 'car.lock', 'car.locked']
>>> x = np.ones(64 * 100, dtype=np.complex64)
>>> _ = c.steps(x)
>>> recs = tlm.read()   # four records per dumped symbol
>>> len(recs) == 4 * 100
True

configure

configure(bn: float, zeta: float) -> None

Recompute the loop gains for a new (bn, zeta); preserves the frequency/phase estimate.

Re-derives the PI coefficients from the loop bandwidth and damping and installs them live. The NCO frequency, phase and loop integrator are left untouched, so a converged loop keeps tracking straight through the re-tune — narrow the bandwidth once pulled in for lower phase jitter, or widen it to chase a faster-moving residual.

Parameters:

Name Type Description Default
bn float

Loop noise bandwidth, normalised to the symbol rate.

required
zeta float

Damping factor (0.707 = critically damped).

required

Examples:

>>> from doppler.track import Costas
>>> c = Costas(bn=0.05, zeta=0.707, init_norm_freq=0.01, tsamps=16)
>>> c.configure(0.02, 1.0)              # narrow the loop, over-damp
>>> (round(c.bn, 3), round(c.norm_freq, 3))  # new gains, est kept
(0.02, 0.01)

configure_lock

configure_lock(
    up_thresh: float,
    down_thresh: float,
    n_up: int,
    n_down: int,
) -> None

Re-tune the carrier lock detector: locked flips up after n_up consecutive dumped symbols with the lock-metric EMA above up_thresh, and drops after n_down consecutive symbols below down_thresh (level + time hysteresis; see detection.LockDet). The defaults (0.85/0.78, 8 up / 32 down) derive from the metric's no-carrier statistics: |Re P|/|P| averages 2/pi (~0.64) under H0 with an EMA-smoothed std of ~0.07, so the declare threshold sits ~3 sigma above the no-carrier mean. A live lock survives the re-tune; the in-flight verify run restarts.

The always-on lock decision steps a verify-counted detector (lockdet_core.h) on the |Re P|/|P| lock-metric EMA once per dumped symbol: locked flips up after n_up consecutive symbols with the metric above up_thresh and drops after n_down consecutive symbols below down_thresh. The defaults derive from the metric's own H0 statistics — with no carrier, |Re P|/|P| = |cos(theta)| for a uniform theta, whose mean is 2/pi (~0.637) and per-symbol std ~0.31; the COSTAS_LOCK_ALPHA = 0.1 EMA reduces that to ~0.071, so the default declare threshold 0.85 sits ~3 sigma above the no-carrier mean, with the drop threshold at 0.78 for level hysteresis and 8-up/32-down verify counts for time hysteresis (declare fast, drop reluctantly — the EMA already correlates adjacent looks, so the counts guard against band-edge dwell rather than compounding i.i.d. probabilities). A live lock survives the re-tune; the in-flight verify run restarts.

Parameters:

Name Type Description Default
up_thresh float

Declare threshold on the lock-metric EMA.

required
down_thresh float

Drop threshold (<= up_thresh for level hysteresis).

required
n_up int

Consecutive above-threshold symbols to declare; clamped to >= 1.

required
n_down int

Consecutive below-threshold symbols to drop; clamped to >= 1.

required

Examples:

>>> from doppler.track import Costas
>>> c = Costas(bn=0.05, zeta=0.707, tsamps=64)
>>> c.locked
False
>>> c.configure_lock(0.9, 0.8, 4, 16)   # tighter declare, faster drop

reset

reset() -> None

Re-seed the loop to the create-time frequency/phase; preserve config.

Drops the lock and rewinds the NCO, loop integrator and integrate-and-dump accumulators to the create-time seed frequency, while retaining the configured loop bandwidth, damping and lock-detector thresholds. Reprocess the same input after a reset and the output is bit-identical.

Examples:

>>> import numpy as np
>>> from doppler.track import Costas
>>> tsamps = 16
>>> rng = np.random.default_rng(3)
>>> bits = rng.integers(0, 2, 1500) * 2 - 1
>>> sig = np.repeat(bits.astype(np.complex64), tsamps)
>>> k = np.arange(len(sig))
>>> rx = (sig * np.exp(2j * np.pi * 0.002 * k)).astype(np.complex64)
>>> c = Costas(bn=0.05, zeta=0.707, tsamps=tsamps)
>>> _ = c.steps(rx)
>>> round(c.norm_freq, 4) != 0.0     # loop pulled onto the residual
True
>>> c.reset()
>>> c.norm_freq                       # back to the create-time seed
0.0
>>> c.lock_metric
0.0

state_bytes

state_bytes() -> int

Size in bytes of this object's serialized state.

The exact length get_state returns and set_state requires. It depends on how the object was constructed (state arrays are sized at construction), so read it from the instance rather than assuming a constant.

Raises RuntimeError if the Costas has already been destroyed.

Returns:

Type Description
int

Byte length of one serialized state blob.

get_state

get_state() -> bytes

Serialize this object's mutable state to bytes.

Captures exactly the state that evolves as the object runs, so a blob taken now and restored later resumes from this point. Construction parameters are not included: restore into an object built the same way.

The blob is opaque and always state_bytes() long. Its layout is an implementation detail of the C core and is not a stable format across builds.

Raises RuntimeError if the Costas has already been destroyed.

Returns:

Type Description
bytes

Opaque snapshot, state_bytes() bytes long.

set_state

set_state(blob: bytes) -> None

Restore mutable state from a get_state() blob.

Overwrites the live state in place; the object keeps the parameters it was constructed with. Length is validated against state_bytes() before the blob is handed to the C core, and the core may reject it as well.

Raises TypeError if blob is not bytes, ValueError if its length differs from state_bytes() or the core rejects it, and RuntimeError if the Costas has already been destroyed.

Parameters:

Name Type Description Default
blob bytes

A get_state() blob from this type, exactly state_bytes() long.

required

destroy

destroy() -> None

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__() -> Costas

Enter a context manager, returning this object.

Lets a Costas be used in a with statement so its C resources are released deterministically on exit rather than at collection time.

Returns:

Type Description
Costas

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 Costas.

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.

...

CarrierMpsk

Create an M-PSK carrier loop instance.

Parameters:

Name Type Description Default
bn float

Loop noise bandwidth (default 0.05).

0.05
zeta float

Damping factor (default 0.707).

0.707
init_norm_freq float

Seed carrier frequency, cycles/sample (default 0.0).

0.0
tsamps int

Samples per symbol (default 64).

64
bn_fll float

FLL-assist bandwidth (default 0.0 = pure PLL).

0.0
m int

Constellation order M, 2/4/8 (default 4 = QPSK).

4

Examples:

Create with defaults:

>>> from doppler.track import CarrierMpsk
>>> obj = CarrierMpsk(
...     bn=0.05,
...     zeta=0.707,
...     init_norm_freq=0.0,
...     tsamps=64,
...     bn_fll=0.0,
...     m=4,
... )

bn property writable

bn: float

PLL loop noise bandwidth (retained).

norm_freq property writable

norm_freq: float

Norm freq.

lock_metric property

lock_metric: float

EMA of Re(P conj a)/|P| (1 = locked).

last_error property

last_error: float

last PLL discriminator (loop stress).

bn_fll property writable

bn_fll: float

FLL-assist bandwidth (0 = pure PLL).

m property

m: int

constellation order M (2, 4, 8).

steps

steps(
    x: NDArray[complex64],
    out: NDArray[complex64] | None = None,
) -> NDArray[np.complex64]

De-rotate a cf32 block with the integer-NCO carrier, coherently integrate over each tsamps-sample symbol, run the decision-directed M-PSK discriminator (slice to the nearest constellation point, error Im(P*conj(ahat))/|P|), and emit one complex prompt symbol per symbol. The loop tracks a small residual carrier (bulk Doppler removed upstream); it locks to one of m phases, so resolve the M-fold ambiguity downstream (mpsk_diff_demap or a sync word). At m=2 this is exactly the BPSK Costas loop.

The block form of the inline wipeoff/update pair: for each input sample it de-rotates by the carrier NCO and accumulates the coherent integrate-and-dump; every tsamps samples it dumps the prompt, runs the decision-directed M-PSK discriminator (slice to the nearest constellation point, error Im(P conj(ahat))/|P|, plus the optional cross-product FLL assist), filters the error, and steers the NCO frequency and phase. Exactly one de-rotated prompt is emitted per completed symbol; a trailing partial symbol is carried in the accumulator to the next call, so a stream can be fed in blocks of any length with no seam.

The loop locks to one of m carrier phases — an M-fold ambiguity on the absolute constellation orientation. Resolve it downstream (differential demapping or a sync word); this call only recovers the carrier and returns the prompts. At m = 2 it is exactly the BPSK Costas loop.

Parameters:

Name Type Description Default
x NDArray[complex64]

Input block, one complex baseband sample per element.

required
out NDArray[complex64] | None

Prompt output buffer written by the binding.

None

Returns:

Type Description
NDArray[complex64]

One de-rotated prompt symbol per completed integrate-and-dump period; the count is x_len / tsamps.

Examples:

>>> import numpy as np
>>> from doppler.mpsk import mpsk_map
>>> from doppler.track import CarrierMpsk
>>> rng = np.random.default_rng(0)
>>> sps = 16
>>> labels = rng.integers(0, 4, 400).astype(np.uint8)
>>> sig = np.repeat(mpsk_map(labels, 4), sps).astype(np.complex64)
>>> k = np.arange(len(sig))
>>> rx = (sig * np.exp(2j * np.pi * 0.002 * k)).astype(np.complex64)
>>> c = CarrierMpsk(bn=0.04, zeta=0.707, init_norm_freq=0.0,
...                 tsamps=sps, bn_fll=0.02, m=4)
>>> prompts = c.steps(rx)          # one prompt per symbol
>>> prompts.shape
(400,)
>>> round(c.norm_freq, 4)       # tracked the residual carrier 0.002
0.002
>>> round(c.lock_metric, 2)        # decision-aligned lock metric -> 1
1.0

steps_max_out

steps_max_out() -> int

Largest number of samples steps() can return in the current state.

Size an out= buffer with this before calling steps(), 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 steps_max_out() replaces this text.

Returns:

Type Description
int

Upper bound on the output length; the actual call may return fewer.

configure

configure(bn: float, zeta: float) -> None

Recompute the loop gains for a new (bn, zeta); preserves the frequency/phase estimate.

Re-derives the proportional/integral gains of the embedded 2nd-order loop filter for the new noise bandwidth and damping, leaving the running frequency and phase estimate (the NCO and the loop integrator) untouched — a live lock survives a re-tune. Use it to widen the loop for fast pull-in and then narrow it for low-jitter tracking, mid-stream.

Parameters:

Name Type Description Default
bn float

Loop noise bandwidth, normalised to the symbol rate.

required
zeta float

Damping factor (0.707 = critically damped).

required

Examples:

>>> from doppler.track import CarrierMpsk
>>> c = CarrierMpsk(bn=0.02, zeta=0.707, init_norm_freq=0.01,
...                 tsamps=16, bn_fll=0.0, m=4)
>>> round(c.bn, 3)
0.02
>>> c.configure(bn=0.05, zeta=1.0)   # widen the loop mid-stream
>>> round(c.bn, 3)
0.05
>>> round(c.norm_freq, 3)            # frequency estimate preserved
0.01

reset

reset() -> None

Re-seed the loop to the create-time frequency/phase; preserve config.

Returns the NCO to the seed carrier passed at construction, zeroes the integrate-and-dump accumulator, the FLL history, and the lock/error diagnostics, and re-primes the loop integrator to the matching per-symbol frequency — the exact state a fresh carrier_mpsk_create() leaves. The tuning (bn, zeta, bn_fll, tsamps, m) is untouched. Call it at a capture boundary so a lock reached on one segment does not bias an unrelated next one.

Examples:

>>> import numpy as np
>>> from doppler.mpsk import mpsk_map
>>> from doppler.track import CarrierMpsk
>>> rng = np.random.default_rng(1)
>>> sig = np.repeat(
...     mpsk_map(rng.integers(0, 4, 100).astype(np.uint8), 4),
...                 16).astype(np.complex64)
>>> rx = (sig * np.exp(2j * np.pi * 0.003 * np.arange(len(sig)))
...       ).astype(np.complex64)
>>> c = CarrierMpsk(bn=0.04, zeta=0.707, init_norm_freq=0.0,
...                 tsamps=16, bn_fll=0.02, m=4)
>>> _ = c.steps(rx)
>>> round(c.norm_freq, 3)   # loop pulled onto the residual carrier
0.003
>>> c.reset()               # back to the create-time seed
>>> round(c.norm_freq, 3)
0.0

state_bytes

state_bytes() -> int

Size in bytes of this object's serialized state.

The exact length get_state returns and set_state requires. It depends on how the object was constructed (state arrays are sized at construction), so read it from the instance rather than assuming a constant.

Raises RuntimeError if the CarrierMpsk has already been destroyed.

Returns:

Type Description
int

Byte length of one serialized state blob.

get_state

get_state() -> bytes

Serialize this object's mutable state to bytes.

Captures exactly the state that evolves as the object runs, so a blob taken now and restored later resumes from this point. Construction parameters are not included: restore into an object built the same way.

The blob is opaque and always state_bytes() long. Its layout is an implementation detail of the C core and is not a stable format across builds.

Raises RuntimeError if the CarrierMpsk has already been destroyed.

Returns:

Type Description
bytes

Opaque snapshot, state_bytes() bytes long.

set_state

set_state(blob: bytes) -> None

Restore mutable state from a get_state() blob.

Overwrites the live state in place; the object keeps the parameters it was constructed with. Length is validated against state_bytes() before the blob is handed to the C core, and the core may reject it as well.

Raises TypeError if blob is not bytes, ValueError if its length differs from state_bytes() or the core rejects it, and RuntimeError if the CarrierMpsk has already been destroyed.

Parameters:

Name Type Description Default
blob bytes

A get_state() blob from this type, exactly state_bytes() long.

required

destroy

destroy() -> None

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__() -> CarrierMpsk

Enter a context manager, returning this object.

Lets a CarrierMpsk be used in a with statement so its C resources are released deterministically on exit rather than at collection time.

Returns:

Type Description
CarrierMpsk

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 CarrierMpsk.

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.

...

CarrierNda

Create an NDA carrier loop instance.

Parameters:

Name Type Description Default
bn float

Loop noise bandwidth (default 0.01).

0.01
zeta float

Damping factor (default 0.707).

0.707
init_norm_freq float

Seed carrier frequency, cycles/sample (default 0.0).

0.0
sps int

Samples per symbol (default 8).

8
n int

MA window divisor: window = sps/n (default 4; sps%n==0).

4
m int

Constellation order M, 2/4/8 (default 4 = QPSK).

4

Examples:

Create with defaults:

>>> from doppler.track import CarrierNda
>>> obj = CarrierNda(
...     bn=0.01,
...     zeta=0.707,
...     init_norm_freq=0.0,
...     sps=8,
...     n=4,
...     m=4,
... )

norm_freq property writable

norm_freq: float

Norm freq.

lock property

lock: float

EMA of the lock signal (1 = locked).

locked property

locked: bool

Current lock decision: True after the verify count of consecutive above-threshold samples, False again after the drop count of consecutive below-threshold ones (see configure_lock).

last_error property

last_error: float

last phase discriminator (loop stress).

bn property writable

bn: float

PLL loop noise bandwidth (retained).

m property

m: int

constellation order M (2, 4, 8).

n property

n: int

sets the MA window (= a 1/n-symbol box).

sps property

sps: int

samples per symbol.

steps

steps(
    x: NDArray[complex64],
    out: NDArray[complex64] | None = None,
) -> NDArray[np.complex64]

De-rotate a cf32 block with the integer-NCO carrier and return the de-rotated samples (one per input sample). Internally the loop runs a non-data-aided M-th-power discriminator on an I/Q arm integrate-and-dump at n dumps per symbol and steers the NCO, so it acquires the carrier with no symbol timing and no data present (it strips the M-PSK modulation by raising the arm sample to the Mth power). It locks to one of m phases (M-fold ambiguity), resolved downstream. Read norm_freq for the tracked carrier and lock for the carrier lock metric.

Runs the non-data-aided carrier loop over the block: each sample is wiped off by the integer-phase NCO, the de-rotated sample slides the I/Q moving-average arm, and the M-th-power discriminator (which strips the M-PSK data modulation) steers the NCO frequency and phase. Because the discriminator is data- and timing-independent, this acquires the carrier with no symbol timing and no data present — a bare carrier, or a modulated carrier before timing lock. It resolves to one of m carrier phases (M-fold ambiguity, resolved downstream). Read norm_freq for the tracked carrier (cycles/sample) and lock for the carrier lock metric.

Parameters:

Name Type Description Default
x NDArray[complex64]

Input samples (average power at or below unity).

required
out NDArray[complex64] | None

De-rotated samples, one per input.

None

Returns:

Type Description
NDArray[complex64]

Number of de-rotated samples written to out (equals x_len).

Examples:

>>> import numpy as np
>>> from doppler.track import CarrierNda
>>> c = CarrierNda(bn=0.01, zeta=0.707, init_norm_freq=0.0,
...                sps=8, n=4, m=4)
>>> rng = np.random.default_rng(0)
>>> k = np.arange(40000)
>>> x = (np.exp(2j * np.pi * 0.001 * k) + 0.05 * (
...      rng.standard_normal(k.size)
...      + 1j * rng.standard_normal(k.size))).astype(np.complex64)
>>> y = c.steps(x)                 # de-rotated toward DC
>>> y.shape[0]
40000
>>> round(c.norm_freq, 4)          # tracked carrier, cycles/sample
0.001
>>> c.lock > 0.5                    # carrier lock metric, ~1 at lock
True

steps_max_out

steps_max_out() -> int

Largest number of samples steps() can return in the current state.

Size an out= buffer with this before calling steps(), 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 steps_max_out() replaces this text.

Returns:

Type Description
int

Upper bound on the output length; the actual call may return fewer.

set_telemetry

set_telemetry(
    tlm: object | None, prefix: str, decim: int = 1
) -> None

Attach (or detach) a telemetry context and register the carrier loop's probes on it. Registers four probes, emitted once per input sample (this is a sample-rate loop — use decim to thin the stream): ".lock" (the lock-signal EMA, ~1 when phase-locked), ".e" (the M-th-power phase discriminator — the loop stress), ".freq" (the tracked carrier frequency, cycles/sample) and ".locked" (the verify-counted lockdet decision, 0/1). Passing NULL detaches. Setup path, never hot: call before the producer thread starts stepping; the context is borrowed and must outlive the attachment (SPSC rules in dp_tlm/dp_tlm_core.h).

Parameters:

Name Type Description Default
tlm object | None

Telemetry context to attach, or NULL to detach.

required
prefix str

Probe-name prefix, e.g. "car" or "rx.car".

required
decim int

Emit every decim-th sample; >= 1.

1

Raises:

Type Description
ValueError

If the C call returns a non-zero status. The exception message is set_telemetry failed, with the return code appended (gh-869).

Examples:

>>> import numpy as np
>>> from doppler.track import CarrierNda
>>> from doppler.telemetry import Telemetry
>>> tlm = Telemetry(1 << 14)
>>> c = CarrierNda(bn=0.01, sps=8, n=4, m=4)
>>> c.set_telemetry(tlm, "car", decim=8)
>>> sorted(tlm.probe_names)
['car.e', 'car.freq', 'car.lock', 'car.locked']
>>> x = np.exp(2j * np.pi * 0.005 * np.arange(4096)).astype(
...     np.complex64)
>>> _ = c.steps(x)
>>> recs = tlm.read()
>>> len(recs[recs["probe"] == tlm.probe_id("car.e")]) == 4096 // 8
True

configure_lock

configure_lock(
    up_thresh: float,
    down_thresh: float,
    n_up: int,
    n_down: int,
) -> None

Re-tune the carrier lock detector: locked flips up after n_up consecutive samples with the lock-signal EMA above up_thresh, and drops after n_down consecutive samples below down_thresh (level + time hysteresis; see detection.LockDet). Defaults are 0.5/0.4 with 64 up / 32 down. The THRESHOLDS are Pfa-derived: 0.5 is 4.416 sigma on the statistic's H0 spread, a per-look false-alarm rate of 5e-6, and it means that at every M because the limited statistic's H0 variance is 1/2 for all of them. The VERIFY COUNT is not derived that way and must not be. Compounding a per-look Pfa over n_up assumes successive looks are independent; this detector steps once per sample and its lock EMA stays correlated for roughly 39 samples, so a shorter count is counting one look several times. Measured against noise-only input, n_up=8 -- the value MpskReceiver uses on this same statistic -- false-locked 4 trials in 30, while 64 was the smallest count clean over 300. Raise n_up rather than lower it unless you have re-measured. A live lock survives the re-tune; the in-flight verify run restarts.

Full lockdet control, mirroring costas_configure_lock(): a split declare/drop threshold pair on the lock-signal EMA (level hysteresis) and both verify counts (time hysteresis). Defaults (0.5/0.4, 64 up / 32 down) start from MpskReceiver's own pre-existing acquisition<-> tracking handover thresholds, but size n_up independently: lock is a fast per-sample EMA, so consecutive looks are highly autocorrelated and MpskReceiver's own n_up=8 does not compound the false-declare rate the way it would for independent looks (direct Monte Carlo against a noise-only, no-carrier input found real false locks at n_up=8; n_up=64 was the smallest verify count that reliably eliminated them -- see carrier_nda_core.c's CARRIER_NDA_LOCK_DEFAULT_* comment for the exact trial data). A live lock survives the re-tune; the in-flight verify run restarts.

Parameters:

Name Type Description Default
up_thresh float

Declare threshold on the lock-signal EMA.

required
down_thresh float

Drop threshold; choose <= up_thresh for level hysteresis.

required
n_up int

Consecutive above-threshold samples to declare; clamped >= 1.

required
n_down int

Consecutive below-threshold samples to drop; clamped >= 1.

required

Examples:

>>> from doppler.track import CarrierNda
>>> c = CarrierNda(bn=0.01, sps=8, n=4, m=4)
>>> c.locked
False
>>> c.configure_lock(0.6, 0.5, 16, 64)   # tighter declare, slower drop

reset

reset() -> None

Re-seed the loop to the create-time frequency/phase; preserve config.

Restores the object to its post-create state: the carrier NCO is reset to the seed frequency it was constructed with (init_norm_freq) with zero phase, the moving-average arm, the loop-filter integrator and the lock EMA are cleared, and the lock detector is dropped. The configured (bn, zeta), the arm geometry (sps, n) and the constellation order m are preserved, so the same object can re-acquire a fresh capture.

Examples:

>>> import numpy as np
>>> from doppler.track import CarrierNda
>>> c = CarrierNda(bn=0.01, zeta=0.707, init_norm_freq=0.0,
...                sps=8, n=4, m=4)
>>> rng = np.random.default_rng(0)
>>> k = np.arange(40000)
>>> x = (np.exp(2j * np.pi * 0.001 * k) + 0.05 * (
...      rng.standard_normal(k.size)
...      + 1j * rng.standard_normal(k.size))).astype(np.complex64)
>>> _ = c.steps(x)
>>> round(c.norm_freq, 4), round(c.lock, 2)   # acquired the carrier
(0.001, 0.99)
>>> c.reset()
>>> round(c.norm_freq, 4), round(c.lock, 2)   # back to seed, unlocked
(0.0, 0.0)

state_bytes

state_bytes() -> int

Size in bytes of this object's serialized state.

The exact length get_state returns and set_state requires. It depends on how the object was constructed (state arrays are sized at construction), so read it from the instance rather than assuming a constant.

Raises RuntimeError if the CarrierNda has already been destroyed.

Returns:

Type Description
int

Byte length of one serialized state blob.

get_state

get_state() -> bytes

Serialize this object's mutable state to bytes.

Captures exactly the state that evolves as the object runs, so a blob taken now and restored later resumes from this point. Construction parameters are not included: restore into an object built the same way.

The blob is opaque and always state_bytes() long. Its layout is an implementation detail of the C core and is not a stable format across builds.

Raises RuntimeError if the CarrierNda has already been destroyed.

Returns:

Type Description
bytes

Opaque snapshot, state_bytes() bytes long.

set_state

set_state(blob: bytes) -> None

Restore mutable state from a get_state() blob.

Overwrites the live state in place; the object keeps the parameters it was constructed with. Length is validated against state_bytes() before the blob is handed to the C core, and the core may reject it as well.

Raises TypeError if blob is not bytes, ValueError if its length differs from state_bytes() or the core rejects it, and RuntimeError if the CarrierNda has already been destroyed.

Parameters:

Name Type Description Default
blob bytes

A get_state() blob from this type, exactly state_bytes() long.

required

destroy

destroy() -> None

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__() -> CarrierNda

Enter a context manager, returning this object.

Lets a CarrierNda be used in a with statement so its C resources are released deterministically on exit rather than at collection time.

Returns:

Type Description
CarrierNda

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 CarrierNda.

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.

...

Dll

Create a code/timing delay-locked loop over a spreading code.

Parameters:

Name Type Description Default
code NDArray[uint8]

Spreading code (0/1 chips), one period; copied internally.

required
sps int

Samples per chip (default 2).

2
init_chip float

Seed code phase, chips (default 0.0).

0.0
bn float

Loop noise bandwidth (default 0.01).

0.01
zeta float

Damping factor (default 0.707).

0.707
spacing float

Early/late tap offset, chips (default 0.5).

0.5
segments int

Partial correlations per code epoch (default 1). 1 = a coherent full-epoch integrate-and-dump (one prompt/period). >1 splits each epoch into that many sub-epoch partials: it emits that many partial prompts/period and tracks the code non-coherently across them (robust to an asynchronous data-symbol clock). segments/epoch ~ samples/symbol at a downstream SymbolSync when the symbol rate is near the code rate, so choose >= 2 for symbol-timing recovery.

1

Examples:

>>> import numpy as np
>>> from doppler.track import Dll
>>> rng = np.random.default_rng(1)
>>> code = rng.integers(0, 2, 31).astype(np.uint8)  # a 31-chip PN code
>>> chip = np.where(code & 1, -1.0, 1.0)    # BPSK spreading code
>>> x = np.tile(np.repeat(chip, 2), 60).astype(np.complex64)
>>> d = Dll(code=code, sps=2)               # 2 samples/chip loop
>>> sym = d.steps(x)                        # one prompt per period
>>> sym.shape                               # 60 despread symbols
(60,)
>>> round(float(np.mean(sym.real[-10:])), 1)  # despread to a clean +1
1.0
>>> round(d.code_rate, 3)                   # code NCO at nominal rate
1.0

bn property writable

bn: float

loop noise bandwidth (retained).

code_phase property

code_phase: float

Code phase.

code_rate property

code_rate: float

chips advanced per nominal chip (~1.0).

last_error property

last_error: float

last discriminator output (loop stress).

segments property

segments: int

partial correlations per epoch (1 = full).

symbol_window property

symbol_window: int

The lock detector's coherent window in partials when set_symbol_period is on (0 = off) -- size n_looks from it.

locked property

locked: bool

Current lock decision: True after the verify count of consecutive above-threshold N-look decisions, False again after the drop count of consecutive below-threshold ones (see configure_lock).

lock_stat property

lock_stat: float

Last code-lock test statistic R = sqrt(2*sum|P|^2 / E|O|^2); compare against det_threshold_noncoherent(pfa, n_looks).

noise_est property

noise_est: float

Current CFAR noise-power estimate E|O|^2 from the off-peak (noise) tap EMA.

steps

steps(
    x: NDArray[complex64],
    out: NDArray[complex64] | None = None,
) -> NDArray[np.complex64]

Correlate a cf32 block against the local code with early/prompt/late taps and steer the code NCO each code period on the non-coherent (sum|E|-sum|L|)/(sum|E|+sum|L|) discriminator. With segments=1 (default) this is a coherent full-epoch integrate-and-dump: one prompt symbol per period. With segments>1 each epoch is split into that many sub-epoch partial correlations: it emits that many partial prompts per period (a stream at ~segments samples/symbol when the symbol rate is near the code rate) and tracks the code non-coherently across the partials, which a data flip cannot collapse (robust to an asynchronous data-symbol clock). segments>1 is the streaming despreader: it removes the PN code and outputs samples. The non-coherent loop is carrier-blind, so it tracks with a residual carrier still on the input; carrier recovery (Costas) and symbol-timing recovery (SymbolSync) are downstream stages fed from the partial output. Returned blocks are safe to keep across calls (block-size invariant): a block whose array is still referenced is never overwritten by a later call (jm gh-437).

The Python face of the loop. Each code period the early/prompt/late correlators dump, the power-domain non-coherent early-minus-late discriminator runs, and the fixed-point code-phase NCO is re-steered; the prompt correlator value is emitted as one output symbol per period (or segments partial prompts per period when segments > 1). The loop is carrier-blind — it tracks with a residual carrier still on the input, so carrier recovery (Costas) and symbol-timing recovery are downstream stages fed from this output. Returned blocks are block-size invariant and safe to keep across calls (a block still referenced is never overwritten, jm gh-437).

Parameters:

Name Type Description Default
x NDArray[complex64]

Carrier-wiped input samples (one contiguous block).

required
out NDArray[complex64] | None

Output buffer for the emitted prompt symbols.

None

Returns:

Type Description
NDArray[complex64]

Number of prompt symbols written — one per completed code period (segments per period when segments > 1) — up to max_out.

Examples:

>>> import numpy as np
>>> from doppler.track import Dll
>>> rng = np.random.default_rng(1)
>>> code = rng.integers(0, 2, 31).astype(np.uint8)
>>> chip = np.where(code & 1, -1.0, 1.0)    # BPSK spreading code
>>> x = np.tile(np.repeat(chip, 2), 40).astype(np.complex64)
>>> d = Dll(code=code, sps=2)
>>> sym = d.steps(x)                        # one prompt per period
>>> sym.dtype
dtype('complex64')
>>> round(float(np.mean(sym.real[-10:])), 1)  # despread to a clean +1
1.0
>>> round(d.code_rate, 3)                   # locked at nominal rate
1.0

steps_max_out

steps_max_out() -> int

Largest number of samples steps() can return in the current state.

Size an out= buffer with this before calling steps(), 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 steps_max_out() replaces this text.

Returns:

Type Description
int

Upper bound on the output length; the actual call may return fewer.

set_telemetry

set_telemetry(
    tlm: object | None, prefix: str, decim: int = 1
) -> None

Attach (or detach) a telemetry context and register the code loop's probes on it. Registers four probes, emitted once per code epoch (period) and further thinned by decim: ".e" (the early-minus-late envelope discriminator — the loop stress), ".rate" (the tracked code rate, chips advanced per nominal chip, ~1.0 at lock), ".lock" (the CFAR lock statistic R; compare against the configured threshold) and ".locked" (the verify-counted lock decision, 0/1 — the lockdet output, so a consumer sees where the declare/drop rule fired without re-deriving it from the statistic). Passing NULL detaches. Setup path, never hot: call before the producer thread starts stepping; the context is borrowed and must outlive the attachment (SPSC rules in dp_tlm/dp_tlm_core.h).

Parameters:

Name Type Description Default
tlm object | None

Telemetry context to attach, or NULL to detach.

required
prefix str

Probe-name prefix, e.g. "code" or "ch0.code".

required
decim int

Emit every decim-th epoch; >= 1.

1

Raises:

Type Description
ValueError

If the C call returns a non-zero status. The exception message is set_telemetry failed, with the return code appended (gh-869).

Examples:

>>> import numpy as np
>>> from doppler.track import Dll
>>> from doppler.telemetry import Telemetry
>>> tlm = Telemetry(1 << 12)
>>> code = np.zeros(31, dtype=np.uint8)
>>> d = Dll(code=code, sps=2)
>>> d.set_telemetry(tlm, "code")
>>> sorted(tlm.probe_names)
['code.e', 'code.lock', 'code.locked', 'code.rate']
>>> x = np.ones(31 * 2 * 50, dtype=np.complex64)
>>> _ = d.steps(x)
>>> recs = tlm.read()   # four records per code epoch
>>> len(recs) > 0 and len(recs) % 4 == 0
True

configure

configure(bn: float, zeta: float) -> None

Recompute the loop gains for a new (bn, zeta); preserves the code phase/rate.

Re-derives the 2nd-order loop filter's proportional and integral gains for a new noise bandwidth and damping, leaving the tracked code phase, code rate and correlator accumulators untouched — retune the loop mid-run (e.g. narrow the bandwidth once pulled in) without dropping lock.

Parameters:

Name Type Description Default
bn float

Loop noise bandwidth, normalised to the code-period rate.

required
zeta float

Damping factor (0.707 = critically damped).

required

Examples:

>>> import numpy as np
>>> from doppler.track import Dll
>>> rng = np.random.default_rng(1)
>>> code = rng.integers(0, 2, 31).astype(np.uint8)
>>> d = Dll(code=code, sps=2, bn=0.01)
>>> d.configure(bn=0.02, zeta=0.707)   # widen the bandwidth mid-run
>>> round(d.bn, 3)
0.02

set_rate_aid

set_rate_aid(rate_aid: float) -> None

Set the carrier-aiding code-rate deviation (ratio; 0 = off): a fixed fractional rate bias summed into the code NCO's phase_inc every epoch, on top of the loop's own control. For physically-coupled Doppler, pass carrier_offset_hz / carrier_freq_hz so the code NCO rides the code-rate dilation the discriminator alone can't pull in at low SNR. Applied continuously across the epoch (not a phase pulse), and nudges the current phase_inc so the aid takes effect before the first period update. code_rate stays the loop's own observable and is unaffected.

A fixed fractional rate bias summed into the sample-and-hold phase_inc on top of the loop's own control every epoch -- for physically-coupled Doppler, carrier_offset_hz / carrier_freq_hz, so the code NCO rides the code-rate dilation the discriminator alone can't pull in at low SNR. Applied continuously across the epoch (via phase_inc), not as a phase pulse. Also nudges the current phase_inc so the aid takes effect before the first period update. code_rate stays the loop's own observable and is unaffected.

Parameters:

Name Type Description Default
rate_aid float

Fractional code-rate deviation (e.g. 8e-6). 0 disables.

required

Examples:

>>> import numpy as np
>>> from doppler.track import Dll
>>> rng = np.random.default_rng(11)
>>> code = rng.integers(0, 2, 63).astype(np.uint8)
>>> delta = 5e-4                                   # code-rate Doppler
>>> idx = (np.arange(63 * 4 * 300) * (1 + delta) / 4).astype(
...     np.int64) % 63
>>> x = np.where(code[idx] & 1, -1.0, 1.0).astype(np.complex64)
>>> plain = Dll(code, sps=4, bn=0.005)
>>> _ = plain.steps(x)
>>> round(plain.code_rate, 4)      # loop had to pull the whole Doppler
1.0005
>>> aided = Dll(code, sps=4, bn=0.005)
>>> aided.set_rate_aid(delta)      # feed the Doppler forward instead
>>> _ = aided.steps(x)
>>> round(aided.code_rate, 4)      # loop integrator stays at nominal
1.0

set_symbol_period

set_symbol_period(partials_per_symbol: float) -> None

Give the code-lock detector the data-symbol period in partials (segments * chip_rate / (sf * symbol_rate); 0 = off), so its looks are coherent over a symbol instead of a quarter-epoch partial. The per-epoch max-power look-back lifted to the symbol scale: ceil(period) boundary-phase hypotheses each own a transition-free window of L = min(floor(period) - 1, 4 * segments) partials per symbol, the hypothesis whose windows carry the most power (an EMA over ~32 symbols) is the symbol timing, and its windows become the detector's looks -- 7.8 dB more per look at 1.8 epochs per symbol and four partials per epoch, and never across a transition. Size n_looks with detection.det_n_noncoh over L * (sf * sps / segments) samples. Only the detector's looks change: the discriminator keeps its per-epoch window and the emitted partial stream is untouched. Raises ValueError when segments <= 1 or the period is in (0, 2).

In segments > 1 mode every partial is a look for the code-lock detector (dll_configure_lock()) and the discriminator sees one epoch through the per-epoch look-back: the smallest integrations the asynchronous data allows when nothing is known about where its transitions fall, and therefore the weakest. This is the same max-power search the per-epoch look-back already runs, lifted to the symbol scale once the symbol PERIOD is known: with P = partials_per_symbol the transitions recur every P partials, so ceil(P) boundary-phase hypotheses each define a transition-free window of L = min(floor(P) - 1, 4 * segments) partials per symbol. Each hypothesis accumulates the power of its coherently summed windows (an EMA over the last ~32 symbols); the one with the most power IS the symbol timing, and its windows become the detector's looks and the discriminator's windows. A look then integrates L partials coherently -- at SPEC's 1.8 epochs per symbol and four partials per epoch, six partials instead of one, 7.8 dB more per look -- and never straddles a transition. Size n_looks for it with detection.det_n_noncoh() over L * (sf * sps / segments) samples.

The code loop steers once per symbol, on the early/prompt/late sums over the winning window, instead of once per epoch on the look-back's: the same discriminator on a window half again as long and never across a transition. The loop filter is re-timed to the symbol interval, so bn keeps its per-epoch meaning and the tracked rate is continuous across the switch either way -- a loop that has pulled in a code Doppler keeps it when the aid is turned on or off. Measured against the per-epoch loop at the operating point (docs/design/async-dsss-receiver.md §12.5, validate_dll_aid_jitter): pull-in about 20% faster, and a code jitter 0.8x the per-epoch loop's above 45 dB-Hz -- where the look-back's own handling of the data transitions sets it -- and 1.2-1.4x at the 40 dB-Hz floor, where the noise sets it and the window's unused partials cost more than its coherence buys; hundredths of a chip either way. The emitted partial stream is untouched: the look-back still supplies its normalisation. The search is blind to WHICH hypothesis is right on any one symbol -- it needs no decision and no external timing -- so it costs nothing at cold start and follows a slowly drifting symbol clock by itself. L is capped at four epochs of partials so a long symbol (a low data rate) does not ask for coherence across more carrier than the wipe-off holds.

Parameters:

Name Type Description Default
partials_per_symbol float

Data-symbol period in emitted partials, segments * chip_rate / (sf * symbol_rate); >= 2. 0 disables (per-partial looks again).

required

Raises:

Type Description
ValueError

If the C call returns a non-zero status. The exception message is set_symbol_period failed, with the return code appended (gh-869).

Examples:

>>> import numpy as np
>>> from doppler.track import Dll
>>> code = (np.arange(63) * 7 % 2).astype(np.uint8)
>>> d = Dll(code, sps=2, segments=4)
>>> d.set_symbol_period(7.24)      # 1.81 epochs per symbol, 4 partials/epoch
>>> d.symbol_window                # coherent partials per look
6
>>> d.set_symbol_period(0.0)       # back to per-partial looks
>>> d.symbol_window
0

set_lock_verify

set_lock_verify(n_up: int, n_down: int) -> None

Set the lock detector's verify counts, keeping its thresholds and noise reference: n_up consecutive above-threshold decisions to declare, n_down consecutive below-threshold decisions to drop. configure_lock derives n_up from pfa and fixes n_down at 2; a caller that sized n_looks for a target Pd knows the per-decision miss probability 1 - pd, and detection.det_verify_count(1 - pd, budget) is the drop count that holds the false-drop rate under a budget (3 for pd = 0.99 at 1e-6 per decision). The running verify counter and the flag restart. Raises ValueError when either count is 0.

dll_configure_lock() derives the declare count from pfa and fixes the drop count at 2. A caller that has sized n_looks for a target Pd knows the per-decision miss probability 1 - pd, and det_verify_count(1 - pd, budget) is the drop count that holds the false-drop rate under a budget -- three consecutive misses for pd = 0.99 at 1e-6 per decision. This sets both counts without touching the thresholds or the noise reference; the running verify counter and the flag restart.

Parameters:

Name Type Description Default
n_up int

Consecutive above-threshold decisions to declare (>= 1).

required
n_down int

Consecutive below-threshold decisions to drop (>= 1).

required

Raises:

Type Description
ValueError

If the C call returns a non-zero status. The exception message is set_lock_verify failed, with the return code appended (gh-869).

Examples:

>>> import numpy as np
>>> from doppler.track import Dll
>>> from doppler.detection import det_verify_count
>>> d = Dll(np.zeros(31, dtype=np.uint8), sps=2, segments=4)
>>> d.set_lock_verify(2, det_verify_count(0.01, 1e-6))
>>> d.locked
False

configure_lock

configure_lock(
    pfa: float, n_looks: int, ref_snr_db: float = 0.0
) -> None

Tune the always-on code-lock detector to a target (pfa, n_looks). The detector reuses acquisition's non-coherent statistic R = sqrt(2sum|P|^2 / E|O|^2), where the prompt powers of n_looks consecutive looks are summed and E|O|^2 is an EMA of a random off-peak (noise) correlation re-drawn each epoch; a decision compares R against det_threshold_noncoherent(pfa, n_looks). Size n_looks with detection.det_n_noncoh(snr, ...) for your operating C/N0. The EMA bandwidth is sized probabilistically (detection.det_ema_alpha): ref_snr_db sets the noise reference's estimator SNR (mean^2/variance of the EMA output); the default 0.0 derives it from n_looks so the reference's std stays an eighth of the statistic's intrinsic H0 spread, floored at ~33 dB. Decisions feed a verify-counted lock detector rather than a single-comparison latch: locked flips up only after det_verify_count(pfa, pfa1e-3) consecutive above-threshold decisions (2 for the default pfa=1e-3, compounding the false-declare rate three decades under pfa) and drops only after 2 consecutive below-threshold decisions, so a statistic grazing the threshold cannot chatter the flag. The default config is pfa=1e-3 over 20 looks. Raises ValueError for pfa outside (0, 1). Read the result from the locked / lock_stat / noise_est properties.

The DLL carries a lock detector that reuses acquisition's non-coherent test statistic. Every emitted look (a partial in segments mode, or the full-epoch prompt when segments == 1) is also correlated at a random off-peak code phase — re-drawn each epoch and kept noise_guard chips clear of the prompt/early/late lobe — to give a signal-free CFAR noise sample (valid for a low-sidelobe code, e.g. Gold). The offset power feeds an EMA reference E|O|^2; the prompt powers of n_looks consecutive looks are summed into S = sum|P_k|^2, and the detector declares lock when

R = sqrt(2 * S / E|O|^2) > det_threshold_noncoherent(pfa, n_looks)

which under H0 has P(R > eta) = marcum_q(n_looks, 0, eta). Size n_looks with det_n_noncoh(snr, ...) for the operating C/N0.

The noise-reference EMA bandwidth is sized probabilistically via det_ema_alpha(): the signal-free |O|^2 samples are exponential (0 dB estimator SNR per sample — a DC level in fluctuation of equal power), and ref_snr_db chooses the EMA output's estimator SNR (mean^2/variance). Passing 0 derives it from n_looks: the reference's relative std is held to an eighth of the statistic's intrinsic H0 spread (1/sqrt(N)), floored at ~33 dB — which reproduces the classic 1/alpha = max(1024, 32*N) sizing exactly, now as a consequence instead of a constant.

The detector needs an off-peak code phase to sample noise from: with a very short code (fewer than ~2*(spacing+2)+1 chips, i.e. sf <= 6 at the default spacing) no offset clears the prompt/early/late lobe, the noise tap aliases the prompt, and the statistic pins below threshold — locked stays 0 (fail-closed) no matter the signal. Use a code of >= 7 chips (real spreading codes are far longer) for a meaningful lock decision.

The decision itself runs through an embedded lock detector (lockdet_core.h) rather than a single-comparison latch: locked flips up only after det_verify_count(pfa, pfa*1e-3) CONSECUTIVE above-threshold decisions (the false-declare budget held three decades under the per-decision pfa — 2 straight for the default 1e-3), and drops only after 2 straight below-threshold decisions, so a statistic grazing the threshold cannot chatter the flag. Full control of the verify counts and a split declare/drop threshold pair is C-only via dll_configure_lock_raw().

Parameters:

Name Type Description Default
pfa float

Per-decision false-alarm probability, in (0, 1).

required
n_looks int

Non-coherent integration depth N (looks); clamped >= 1.

required
ref_snr_db float

Noise-reference estimator SNR in dB (> 0), or 0 to derive from n_looks as above.

0.0

Raises:

Type Description
ValueError

If the C call returns a non-zero status. The exception message is configure_lock failed, with the return code appended (gh-869).

Examples:

>>> import numpy as np
>>> from doppler.track import Dll
>>> d = Dll(code=np.zeros(31, dtype=np.uint8), sps=2)
>>> d.configure_lock(1e-3, 20)
>>> d.locked
False
>>> d.configure_lock(1e-3, 20, ref_snr_db=20.0)   # ~50-look reference
>>> d.configure_lock(2.0, 20)
Traceback (most recent call last):
    ...
ValueError: configure_lock failed (rc=-4)

configure_lock_raw

configure_lock_raw(
    up_thresh: float,
    down_thresh: float,
    n_looks: int,
    alpha: float,
    n_up: int,
    n_down: int,
) -> None

Escape hatch under configure_lock() for direct control of the lock detector's geometry: a split declare/drop threshold pair on the statistic R (level hysteresis), the noise-EMA coefficient alpha, and both verify counts n_up/n_down (time hysteresis) independently -- configure_lock() only ever derives a symmetric threshold (up_thresh == down_thresh) and a fixed n_down=2. Re-tuning clears the in-flight statistic and drops the lock so the next decision uses only looks gathered under the new config. Size up_thresh/down_thresh with detection.det_threshold_noncoherent(pfa, n_looks), alpha with detection.det_ema_alpha, and n_up/n_down with detection.det_verify_count. Read the result from the locked / lock_stat / noise_est properties.

The escape hatch under dll_configure_lock() for a composing C caller that derives its own threshold/EMA/hysteresis geometry — the full lockdet decision rule is exposed: a split declare/drop threshold pair (level hysteresis) and both verify counts (time hysteresis; size them with det_verify_count()). Re-tuning clears the in-flight statistic and drops the lock so the next decision uses only looks gathered under the new config.

Parameters:

Name Type Description Default
up_thresh float

Declare threshold on the statistic R (e.g. the CFAR eta from det_threshold_noncoherent()).

required
down_thresh float

Drop threshold on R; choose <= up_thresh for level hysteresis.

required
n_looks int

Non-coherent integration depth N (looks); clamped >= 1.

required
alpha float

EMA coefficient for the noise reference, in (0, 1].

required
n_up int

Consecutive above-threshold decisions to declare lock; clamped to

= 1.

required
n_down int

Consecutive below-threshold decisions to drop it; clamped to >= 1.

required

Examples:

>>> import numpy as np
>>> from doppler.track import Dll
>>> rng = np.random.default_rng(1)
>>> # >= 7 chips gives a usable lock statistic
>>> code = rng.integers(0, 2, 63).astype(np.uint8)
>>> chip = np.where(code & 1, -1.0, 1.0)
>>> x = np.tile(np.repeat(chip, 4), 400).astype(np.complex64)
>>> d = Dll(code, sps=4, bn=0.005)
>>> # raw geometry: declare at R>3, drop at R<2.5, 8-look,
>>> # 2-of-2 hysteresis
>>> d.configure_lock_raw(3.0, 2.5, 8, 1.0 / 1024, 2, 2)
>>> _ = d.steps(x)
>>> d.locked                       # cleared the declare threshold
True
>>> bool(d.lock_stat > 3.0)
True

reset

reset() -> None

Re-seed the loop to the create-time code phase; preserve config.

Restores the code phase, loop filter, correlator accumulators and lock detector to their post-construction state while preserving the tuned configuration (bn/zeta, spacing, segments, lock geometry). Re-running the same input after a reset therefore reproduces the same tracked state bit-for-bit — the basis of a deterministic replay.

Examples:

>>> import numpy as np
>>> from doppler.track import Dll
>>> rng = np.random.default_rng(21)
>>> code = rng.integers(0, 2, 63).astype(np.uint8)
>>> idx = (np.arange(63 * 4 * 300) * (1 + 3e-4) / 4).astype(
...     np.int64) % 63
>>> x = np.where(code[idx] & 1, -1.0, 1.0).astype(np.complex64)
>>> d = Dll(code, sps=4, bn=0.005)
>>> _ = d.steps(x)
>>> first = round(d.code_rate, 6)
>>> d.reset()                     # back to the create-time code phase
>>> _ = d.steps(x)                # same input -> same tracked rate
>>> round(d.code_rate, 6) == first
True

state_bytes

state_bytes() -> int

Size in bytes of this object's serialized state.

The exact length get_state returns and set_state requires. It depends on how the object was constructed (state arrays are sized at construction), so read it from the instance rather than assuming a constant.

Raises RuntimeError if the Dll has already been destroyed.

Returns:

Type Description
int

Byte length of one serialized state blob.

get_state

get_state() -> bytes

Serialize this object's mutable state to bytes.

Captures exactly the state that evolves as the object runs, so a blob taken now and restored later resumes from this point. Construction parameters are not included: restore into an object built the same way.

The blob is opaque and always state_bytes() long. Its layout is an implementation detail of the C core and is not a stable format across builds.

Raises RuntimeError if the Dll has already been destroyed.

Returns:

Type Description
bytes

Opaque snapshot, state_bytes() bytes long.

set_state

set_state(blob: bytes) -> None

Restore mutable state from a get_state() blob.

Overwrites the live state in place; the object keeps the parameters it was constructed with. Length is validated against state_bytes() before the blob is handed to the C core, and the core may reject it as well.

Raises TypeError if blob is not bytes, ValueError if its length differs from state_bytes() or the core rejects it, and RuntimeError if the Dll has already been destroyed.

Parameters:

Name Type Description Default
blob bytes

A get_state() blob from this type, exactly state_bytes() long.

required

destroy

destroy() -> None

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__() -> Dll

Enter a context manager, returning this object.

Lets a Dll be used in a with statement so its C resources are released deterministically on exit rather than at collection time.

Returns:

Type Description
Dll

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 Dll.

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.

...

SymbolSync

SymbolSync component.

Parameters:

Name Type Description Default
sps int

sps constructor parameter.

4
bn float

bn constructor parameter.

0.01
zeta float

zeta constructor parameter.

0.707
order Literal['linear', 'parabolic', 'cubic']

order constructor parameter.

"cubic"
ted Literal['gardner', 'dttl']

Timing-error detector: "gardner" (blind, works for any constellation) or "dttl" (decision-directed sign-sign Data Transition Tracking Loop; lower self-noise near lock but degrades faster at low SNR. BPSK/QPSK only -- invalid for 8PSK/QAM).

"gardner"

Examples:

Create with defaults:

>>> from doppler.track import SymbolSync
>>> obj = SymbolSync(
...     sps=4,
...     bn=0.01,
...     zeta=0.707,
...     order="cubic",
...     ted="gardner",
... )

bn property writable

bn: float

loop noise bandwidth (retained).

timing_error property

timing_error: float

Timing error.

rate property

rate: float

Rate.

lock_stat property

lock_stat: float

Last block-averaged lock statistic: mean(2*(|on-time|^2-|mid-symbol|^2)/(|on-time|^2+|mid-symbol|^2)) over the configured avgs looks; compare against the configured threshold (see configure_lock).

locked property

locked: bool

Current timing-lock decision: True after the verify count of consecutive above-threshold decisions, False again after the drop count of consecutive below-threshold ones (see configure_lock).

steps

steps(
    x: NDArray[complex64],
    out: NDArray[complex64] | None = None,
) -> NDArray[np.complex64]

Recover symbol timing from an oversampled cf32 baseband block: a timing-error detector (Gardner or DTTL, see the ted param) drives an integer timing NCO whose post-wrap value gives the interpolation fraction for free, and a Farrow interpolator emits one symbol-rate sample per recovered symbol instant.

symsync_step() in a loop, with the TED specialised per detector. Each input sample feeds the Farrow interpolator and advances the integer timing NCO; on a mid-symbol crossing the transition-gate interpolant is stored, and on a wrap the on-time interpolant is formed, the selected TED (Gardner or DTTL) measures the timing error, the PI loop steers the NCO rate, and one symbol-rate sample is emitted at the recovered instant. State carries across calls, so contiguous blocks give the same symbols as one large block.

Parameters:

Name Type Description Default
x NDArray[complex64]

Oversampled input samples (~sps samples per symbol).

required
out NDArray[complex64] | None

Recovered symbol-rate samples.

None

Returns:

Type Description
NDArray[complex64]

Number of recovered symbols written to out.

Examples:

>>> import numpy as np
>>> from doppler.track import SymbolSync
>>> ss = SymbolSync(sps=4, bn=0.02, zeta=0.707)
>>> x = np.repeat([1.0, -1.0, 1.0, -1.0], 4 * 32).astype(np.complex64)
>>> y = ss.steps(x)             # oversampled -> one sample/symbol
>>> y.shape[0]
127
>>> sorted(set(np.where(y.real >= 0, 1, -1).tolist()))  # got +/-1
[-1, 1]
>>> round(ss.rate, 1)              # tracked samples/symbol
4.0

steps_max_out

steps_max_out() -> int

Largest number of samples steps() can return in the current state.

Size an out= buffer with this before calling steps(), 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 steps_max_out() replaces this text.

Returns:

Type Description
int

Upper bound on the output length; the actual call may return fewer.

set_telemetry

set_telemetry(
    tlm: object | None, prefix: str, decim: int = 1
) -> None

Attach (or detach) a telemetry context and register the timing loop's probes on it. Registers five probes, emitted once per recovered symbol and further thinned by decim: ".e" (the normalised TED error — the loop stress), ".freq" (the loop-filter control steering the timing NCO, fractional rate offset), ".rate" (the smoothed tracked samples/symbol), ".lock" (the last block-averaged lock_signal, held between avgs-look updates) and ".locked" (the verify-counted lockdet decision, 0/1). Passing NULL detaches. Setup path, never hot: call before the producer thread starts stepping; the context is borrowed and must outlive the attachment (SPSC rules in dp_tlm/dp_tlm_core.h).

Parameters:

Name Type Description Default
tlm object | None

Telemetry context to attach, or NULL to detach.

required
prefix str

Probe-name prefix, e.g. "sync" or "rx.sync".

required
decim int

Emit every decim-th symbol; >= 1.

1

Raises:

Type Description
ValueError

If the C call returns a non-zero status. The exception message is set_telemetry failed, with the return code appended (gh-869).

Examples:

>>> import numpy as np
>>> from doppler.track import SymbolSync
>>> from doppler.telemetry import Telemetry
>>> tlm = Telemetry(1 << 12)
>>> ss = SymbolSync(sps=4, bn=0.01, zeta=0.707)
>>> ss.set_telemetry(tlm, "sync")
>>> sorted(tlm.probe_names)
['sync.e', 'sync.freq', 'sync.lock', 'sync.locked', 'sync.rate']
>>> x = np.repeat([1 + 1j, -1 - 1j], 4 * 64).astype(np.complex64)
>>> _ = ss.steps(x)
>>> recs = tlm.read()   # five records per recovered symbol
>>> len(recs) > 0 and len(recs) % 5 == 0
True

configure

configure(bn: float, zeta: float) -> None

Recompute the loop gains for a new (bn, zeta); preserve the timing estimate.

Retunes the PI timing loop in place: the proportional/integral gains are recomputed from the new noise bandwidth and damping, while the NCO phase, tracked rate and loop-filter integrator carry over — so a locked loop is re-bandwidthed (e.g. narrowed after acquisition) without losing lock.

Parameters:

Name Type Description Default
bn float

Loop noise bandwidth, normalised to the symbol rate (>= 0).

required
zeta float

Damping factor (0.707 = critically damped).

required

Examples:

>>> from doppler.track import SymbolSync
>>> ss = SymbolSync(sps=4, bn=0.01, zeta=0.707)
>>> ss.configure(bn=0.05, zeta=1.0)   # widen + over-damp, to acquire
>>> round(ss.bn, 3)
0.05

configure_lock

configure_lock(
    rolloff: float,
    esno_min_db: float,
    pfa: float,
    pd: float,
) -> None

Tune the always-on timing-lock detector to a target (pfa, pd) at a given link operating point. The statistic is a Gardner-style eye-opening ratio, lock_signal = 2(|on-time|^2-|mid-symbol|^2)/(|on-time|^2+|mid-symbol|^2), non-coherently block-averaged over avgs looks before each decision (mirroring Dll's tumbling-window CFAR pattern). avgs and the declare threshold are sized from a Gaussian approximation: a per-look mean is estimated from rolloff and esno_min_db, then the classic N = variance((Q^-1(pfa)-Q^-1(pd))/mean)^2 / threshold = Q^-1(pfa)*mean/(Q^-1(pfa)-Q^-1(pd)) derivation gives (avgs, threshold). No level hysteresis by default (up=down=threshold, matching Dll.configure_lock's shape); n_up=1, n_down=8. Raises ValueError if pfa/pd are outside (0, 1) or pd does not exceed pfa. Read the result from the locked / lock_stat properties.

Sizes the non-coherent block size (avgs) and declare threshold from a Gaussian sizing of the eye-opening statistic lock_signal = 2(|on-time|^2-|mid|^2)/(|on-time|^2+|mid|^2): a per-look mean (mean_lock_detect, from rolloff and the minimum operating Es/N0) drives the classic N = variance((Q^-1(pfa)-Q^-1(pd))/mean)^2 / threshold = Q^-1(pfa)*mean/(Q^-1(pfa)-Q^-1(pd)) derivation, implemented directly from a formula supplied by a doppler user (not re-derived against a primary source), with "variance" set from a direct measurement of lock_signal's real per-look variance under noise (~1.343, 5,000,000-sample Monte Carlo) rather than the placeholder "8" this API originally shipped with -- see symsync_core.c's SYMSYNC_LOCK_STAT_VARIANCE comment for the full derivation (a factor-of-2 correction for the erfcinv-vs-Q^-1 convention applies on top of the measured variance; the two hypotheses were empirically compared before picking one). Empirically validated at the default operating point (avgs=133, threshold=0.311): 429 false declares over 500,000 independent noise-only blocks against a nominal pfa=1e-3 (8.58e-4, correctly sized with safe margin, not accidentally oversized); 2000/2000 true declares at the esno_min design SNR against a nominal pd=0.9 -- see native/validation/symsync_lock.c for the harness. No level hysteresis by default (up = down = threshold, matching dll_configure_lock's shape); the raw escape hatch (symsync_configure_lock_raw) exposes split thresholds, an explicit avgs, and independent n_up/n_down.

Parameters:

Name Type Description Default
rolloff float

Matched-filter excess bandwidth (e.g. 0.35 for a typical RRC system).

required
esno_min_db float

Minimum operating Es/N0, dB -- the worst-case link point the detector must still declare lock at.

required
pfa float

Target false-alarm probability per decision, in (0, 1).

required
pd float

Target detection probability per decision, in (0, 1); must exceed pfa.

required

Raises:

Type Description
ValueError

If the C call returns a non-zero status. The exception message is configure_lock failed, with the return code appended (gh-869).

Examples:

>>> from doppler.track import SymbolSync
>>> ss = SymbolSync(sps=4, bn=0.01, zeta=0.707)
>>> ss.configure_lock(rolloff=0.35, esno_min_db=10.0, pfa=1e-3, pd=0.9)
>>> ss.locked
False
>>> ss.configure_lock(rolloff=0.35, esno_min_db=10.0, pfa=0.9, pd=0.9)
Traceback (most recent call last):
    ...
ValueError: configure_lock failed (rc=-4)

configure_lock_raw

configure_lock_raw(
    avgs: int,
    up_thresh: float,
    down_thresh: float,
    n_up: int,
    n_down: int,
) -> None

Escape hatch under configure_lock() for direct control of the lock detector's geometry: an explicit non-coherent block size (avgs), a split declare/drop threshold pair on lock_stat (level hysteresis), and both verify counts (time hysteresis) independently. Re-tuning clears the in-flight block sum and drops the lock so the next decision uses only looks gathered under the new config.

The escape hatch under symsync_configure_lock() for a caller that derives its own averaging/threshold geometry: the block size (avgs), a split declare/drop threshold pair on lock_stat (level hysteresis), and both verify counts (time hysteresis). Re-tuning clears the in-flight block sum and drops the lock so the next decision uses only looks gathered under the new config.

Parameters:

Name Type Description Default
avgs int

Non-coherent block size (looks/decision); clamped >= 1.

required
up_thresh float

Declare threshold on lock_stat.

required
down_thresh float

Drop threshold; choose <= up_thresh for level hysteresis.

required
n_up int

Consecutive above-threshold decisions to declare; clamped >= 1.

required
n_down int

Consecutive below-threshold decisions to drop; clamped >= 1.

required

Examples:

>>> from doppler.track import SymbolSync
>>> ss = SymbolSync(sps=4, bn=0.01, zeta=0.707)
>>> ss.configure_lock_raw(64, 0.3, 0.3, 1, 8)   # 64-look block, 8-drop
>>> ss.locked
False
>>> round(ss.lock_stat, 3)
0.0

reset

reset() -> None

Re-seed the timing loop to its nominal rate and zero phase.

Restores the object to its post-create state: the timing NCO is zeroed to the nominal one-wrap-per-symbol rate, the Farrow history and TED state are cleared, the loop-filter integrator is emptied and the lock detector is dropped. The configured (bn, zeta), TED selection and any lock geometry are preserved, so the same object can be re-run on a fresh stream.

Examples:

>>> import numpy as np
>>> from doppler.track import SymbolSync
>>> ss = SymbolSync(sps=4, bn=0.02, zeta=0.707)
>>> _ = ss.steps(np.repeat([1.0, -1.0], 4 * 40).astype(np.complex64))
>>> ss.reset()
>>> round(ss.rate, 1)              # back to the nominal sps
4.0
>>> round(ss.timing_error, 3)      # loop stress cleared
0.0

state_bytes

state_bytes() -> int

Size in bytes of this object's serialized state.

The exact length get_state returns and set_state requires. It depends on how the object was constructed (state arrays are sized at construction), so read it from the instance rather than assuming a constant.

Raises RuntimeError if the SymbolSync has already been destroyed.

Returns:

Type Description
int

Byte length of one serialized state blob.

get_state

get_state() -> bytes

Serialize this object's mutable state to bytes.

Captures exactly the state that evolves as the object runs, so a blob taken now and restored later resumes from this point. Construction parameters are not included: restore into an object built the same way.

The blob is opaque and always state_bytes() long. Its layout is an implementation detail of the C core and is not a stable format across builds.

Raises RuntimeError if the SymbolSync has already been destroyed.

Returns:

Type Description
bytes

Opaque snapshot, state_bytes() bytes long.

set_state

set_state(blob: bytes) -> None

Restore mutable state from a get_state() blob.

Overwrites the live state in place; the object keeps the parameters it was constructed with. Length is validated against state_bytes() before the blob is handed to the C core, and the core may reject it as well.

Raises TypeError if blob is not bytes, ValueError if its length differs from state_bytes() or the core rejects it, and RuntimeError if the SymbolSync has already been destroyed.

Parameters:

Name Type Description Default
blob bytes

A get_state() blob from this type, exactly state_bytes() long.

required

destroy

destroy() -> None

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__() -> SymbolSync

Enter a context manager, returning this object.

Lets a SymbolSync be used in a with statement so its C resources are released deterministically on exit rather than at collection time.

Returns:

Type Description
SymbolSync

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 SymbolSync.

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.

...

RateSync

Create a RateSync instance.

Parameters:

Name Type Description Default
sps float

Nominal samples per symbol. Any double >= m -- 17.33389 is as valid as 4, because the terminal stage's accumulator is a double and the loop only has to steer the strobe. That is the real-world case whenever the ADC clock is free-running against the symbol clock.

4.0
pulse Literal['iandd', 'rrc']

Matched-filter pulse shape: "rrc" (root-raised cosine, roll-off beta) or "iandd" (unit rectangle one symbol wide -- the matched filter for a rectangular symbol, and exactly what an integrate-and-dump computes). The rectangle needs far fewer taps, so an NRZ link's matched filter is cheaper.

"rrc"
beta float

RRC roll-off in [0, 1] (ignored for the rectangle).

0.35
span int

One-sided RRC span in symbols (ignored for the rectangle, whose support is always one symbol).

8
m int

Terminal outputs per symbol: even, 2 <= m <= 8. Gardner needs a transition gate half a symbol from the on-time strobe, which is why m must be even and at least 2. The oversampled stream is a by-product of the same dot products, not an extra cost. Use m >= 4 with pulse="iandd": the rectangle is one symbol wide, so at m=2 its matched filter is a 2-tap sum and the eye statistic barely opens. Measured on an NRZ stream, m=2 does not clear the lock detector's own declare threshold while m=4 clears it comfortably, with tens of dB of EVM between them. The rule rests on that separation, not on a particular pair of lock_stat values -- those move with sps and with the stream. The RRC spans many symbols and is unaffected.

2
num_phases int

Matched-filter arms; a power of two. Sets the fractional-timing resolution to 1/num_phases of an output period.

1024
bn float

Loop noise bandwidth, normalised to the symbol rate.

0.01
zeta float

Damping factor (0.707 = critically damped).

0.707
ted Literal['gardner', 'dttl']

Timing-error detector: "gardner" (blind, works for any constellation) or "dttl" (decision-directed sign-sign Data Transition Tracking Loop; lower self-noise near lock but degrades faster at low SNR. BPSK/QPSK only -- invalid for 8PSK/QAM).

"gardner"

Raises:

Type Description
ValueError

If construction fails. The exception message is RateSync: invalid parameter (need sps >= m, 0 <= beta <= 1, span >= 1, m even in [2, 8], num_phases a power of two >= 2, bn >= 0, zeta > 0).

Examples:

Create with defaults:

>>> from doppler.track import RateSync
>>> obj = RateSync(
...     sps=4.0,
...     pulse="rrc",
...     beta=0.35,
...     span=8,
...     m=2,
...     num_phases=1024,
...     bn=0.01,
...     zeta=0.707,
...     ted="gardner",
... )

bn property writable

bn: float

loop noise bandwidth (retained).

timing_error property

timing_error: float

Last normalised TED error — the loop stress.

rate property

rate: float

Smoothed tracked samples per symbol. Departs from the nominal sps by exactly the sample-clock offset being tracked, so it is the estimator a rate-disciplining caller reads.

ctrl property

ctrl: float

Current per-input rate deviation steering the terminal stage's accumulator.

lock_stat property

lock_stat: float

Last block-averaged lock statistic: mean(2*(|on-time|^2-|mid|^2)/(|on-time|^2+|mid|^2)) over the configured avgs looks. This, not an error-vector magnitude, is the honest lock indicator -- a single cycle slip during acquisition drags a windowed EVM by 20 dB while the eye stays wide open at +0.75.

locked property

locked: bool

Current timing-lock decision: True after the verify count of consecutive above-threshold decisions, False again after the drop count of consecutive below-threshold ones.

clipped property

clipped: bool

True if the cascade's CIC stage has clipped its input since the last reset(). A CIC bounds its input to +-1.0 and clips silently past that, which no timing metric reveals -- an overdriven front end degrades EVM by 25 dB with a perfectly healthy lock. Always False when the plan contains no CIC stage.

steps

steps(
    x: NDArray[complex64],
    out: NDArray[complex64] | None = None,
) -> NDArray[np.complex64]

Recover symbols from an oversampled cf32 baseband block. The owned RateConverter's terminal stage IS the matched filter, and the polyphase arm its accumulator selects IS the fractional timing delay, so one dot product does the rate conversion, the matched filtering and the interpolation. Every m-th output is an on-time strobe and the output m/2 back is the transition gate; a Gardner or DTTL detector drives a PI loop that steers the terminal stage's control port. State carries across calls, so contiguous blocks give the same symbols as one large block.

ratesync_step() in a loop, with the TED specialised per detector; state carries across calls, so contiguous blocks give the same symbols as one large block.

Parameters:

Name Type Description Default
x NDArray[complex64]

Input samples.

required
out NDArray[complex64] | None

Recovered symbols.

None

Returns:

Type Description
NDArray[complex64]

Symbols written to out.

Examples:

>>> import numpy as np
>>> from doppler.track import RateSync
>>> syms = np.where(np.random.default_rng(3).integers(0, 2, 3000) > 0,
...                 1.0, -1.0)
>>> x = (0.25 * np.repeat(syms, 8)).astype(np.complex64)  # 8 samp/sym
>>> rs = RateSync(sps=8.0, pulse="iandd", m=4, bn=0.01)
>>> y = rs.steps(x)             # one symbol per transmitted symbol
>>> round(rs.rate, 2)           # tracked samples per symbol
8.0
>>> bool(rs.lock_stat > 0.55)   # the timing loop has locked
True

steps_max_out

steps_max_out() -> int

Output-buffer hint for the generated binding; 0 means "the input length is already a safe bound" — with sps >= m >= 2 a block can never yield more symbols than it has samples (mirrors symsync).

Returns:

Type Description
int

Output.

set_telemetry

set_telemetry(
    tlm: object | None, prefix: str, decim: int = 1
) -> None

Attach (or detach) a telemetry context and register the probes.

Registers six probes, emitted once per recovered symbol and further thinned by decim: ".e" (normalised TED error), ".ctrl" (the per-input control steering the strobe), ".rate" (tracked samples/symbol), ".lock" (last block-averaged lock_signal), ".locked" (0/1) and ".mu" (the timing NCO's fractional phase — see resamp_get_ctrl_acc()). Passing NULL detaches. Setup path, never hot: the context is borrowed and must outlive the attachment (SPSC rules in dp_tlm/dp_tlm_core.h).

The three form one readable picture of the loop: e is what the detector saw, ctrl is what the filter did about it, and mu is where the sampling instant ended up as a result — the only one of the three that is a physical position rather than a correction.

Parameters:

Name Type Description Default
tlm object | None

Telemetry context to attach, or NULL to detach.

required
prefix str

Probe-name prefix, e.g. "sync".

required
decim int

Emit every decim-th symbol; >= 1.

1

Raises:

Type Description
ValueError

If the C call returns a non-zero status. The exception message is set_telemetry failed, with the return code appended (gh-869).

Examples:

>>> from doppler.track import RateSync
>>> from doppler.telemetry import Telemetry
>>> tlm = Telemetry(1 << 14)
>>> rs = RateSync(sps=8.0, pulse="iandd", m=4, bn=0.01)
>>> rs.set_telemetry(tlm, "sync")   # register the six timing probes
>>> tlm.probe_count
6
>>> "sync.rate" in tlm.probe_names   # tracked samples/symbol
True

configure

configure(bn: float, zeta: float) -> None

Recompute the loop gains for a new (bn, zeta); preserve the timing estimate.

Only the PI coefficients change; the integrator, and therefore the tracked rate and the lock, carries through untouched. Use it to narrow the loop after acquisition (a wide bn pulls in fast, a narrow one tracks with less jitter) without forcing a re-acquire.

Parameters:

Name Type Description Default
bn float

Loop noise bandwidth, normalised to the symbol rate.

required
zeta float

Damping factor (0.707 = critically damped).

required

Examples:

>>> import numpy as np
>>> from doppler.track import RateSync
>>> syms = np.where(np.random.default_rng(3).integers(0, 2, 3000) > 0,
...                 1.0, -1.0)
>>> x = (0.25 * np.repeat(syms, 8)).astype(np.complex64)  # 8 samp/sym
>>> rs = RateSync(sps=8.0, pulse="iandd", m=4, bn=0.01)
>>> _ = rs.steps(x)              # acquire and lock
>>> rs.locked
True
>>> rs.configure(0.002, 0.707)   # narrow the loop; lock is kept
>>> round(rs.bn, 3)
0.002
>>> rs.locked
True

configure_lock_raw

configure_lock_raw(
    avgs: int,
    up_thresh: float,
    down_thresh: float,
    n_up: int,
    n_down: int,
) -> None

Direct control of the lock detector's geometry: an explicit non-coherent block size (avgs), a split declare/drop threshold pair on lock_stat (level hysteresis), and both verify counts (time hysteresis) independently. Re-tuning clears the in-flight block sum and drops the lock so the next decision uses only looks gathered under the new config. The (pfa, pd) sizing entry point symsync exposes is deliberately not mirrored here: its constants were calibrated against symsync's own geometry by Monte Carlo, and re-exposing the formula for a different front end without repeating that validation would assert a calibration nobody measured.

The block size (avgs), a split declare/drop threshold pair on lock_stat (level hysteresis) and both verify counts (time hysteresis). Re-tuning clears the in-flight block sum and drops the lock, so the next decision uses only looks gathered under the new config.

Parameters:

Name Type Description Default
avgs int

Looks per decision; clamped >= 1.

required
up_thresh float

Declare threshold on lock_stat.

required
down_thresh float

Drop threshold; <= up_thresh for level hysteresis.

required
n_up int

Consecutive above-threshold decisions to declare.

required
n_down int

Consecutive below-threshold decisions to drop.

required

Examples:

>>> import numpy as np
>>> from doppler.track import RateSync
>>> syms = np.where(np.random.default_rng(3).integers(0, 2, 3000) > 0,
...                 1.0, -1.0)
>>> x = (0.25 * np.repeat(syms, 8)).astype(np.complex64)
>>> rs = RateSync(sps=8.0, pulse="iandd", m=4, bn=0.01)
>>> _ = rs.steps(x)
>>> rs.locked
True
>>> rs.configure_lock_raw(64, 0.5, 0.4, 2, 4)  # drops the lock
>>> rs.locked
False
>>> rs.lock_stat                 # the in-flight block was cleared
0.0

reset

reset() -> None

Re-seed the timing loop, the cascade's filter memories, the strobe ring and the prime countdown.

Configuration (sps, pulse, bank, bn, zeta, ted, lock geometry) is kept; only the running state is cleared, so a re-run of the same stream from a reset object reproduces its first-run symbols bit for bit.

Examples:

>>> import numpy as np
>>> from doppler.track import RateSync
>>> syms = np.where(np.random.default_rng(3).integers(0, 2, 3000) > 0,
...                 1.0, -1.0)
>>> x = (0.25 * np.repeat(syms, 8)).astype(np.complex64)
>>> rs = RateSync(sps=8.0, pulse="iandd", m=4, bn=0.01)
>>> first = np.array(rs.steps(x))
>>> rs.reset()
>>> rs.ctrl, rs.locked           # back to the post-create state
(0.0, False)
>>> bool(np.array_equal(first, np.array(rs.steps(x))))  # reproducible
True

state_bytes

state_bytes() -> int

Size in bytes of this object's serialized state.

The exact length get_state returns and set_state requires. It depends on how the object was constructed (state arrays are sized at construction), so read it from the instance rather than assuming a constant.

Raises RuntimeError if the RateSync has already been destroyed.

Returns:

Type Description
int

Byte length of one serialized state blob.

get_state

get_state() -> bytes

Serialize this object's mutable state to bytes.

Captures exactly the state that evolves as the object runs, so a blob taken now and restored later resumes from this point. Construction parameters are not included: restore into an object built the same way.

The blob is opaque and always state_bytes() long. Its layout is an implementation detail of the C core and is not a stable format across builds.

Raises RuntimeError if the RateSync has already been destroyed.

Returns:

Type Description
bytes

Opaque snapshot, state_bytes() bytes long.

set_state

set_state(blob: bytes) -> None

Restore mutable state from a get_state() blob.

Overwrites the live state in place; the object keeps the parameters it was constructed with. Length is validated against state_bytes() before the blob is handed to the C core, and the core may reject it as well.

Raises TypeError if blob is not bytes, ValueError if its length differs from state_bytes() or the core rejects it, and RuntimeError if the RateSync has already been destroyed.

Parameters:

Name Type Description Default
blob bytes

A get_state() blob from this type, exactly state_bytes() long.

required

destroy

destroy() -> None

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__() -> RateSync

Enter a context manager, returning this object.

Lets a RateSync be used in a with statement so its C resources are released deterministically on exit rather than at collection time.

Returns:

Type Description
RateSync

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 RateSync.

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.

...

GalleryStreaming Async Despreader, Async DSSS Receiver: the SPEC waveform through coupled Doppler, M-PSK Carrier Loop — Theory Validation, NDA Carrier Loop — Theory Validation, Costas Loop — Theory Validation, Carrier Loop Stress, DLL Code Loop — Theory Validation, Code Loop Tracking, DsssReceiver — the Composed Continuous DSSS Receiver, Gallery, Lock Detection: Verify Counts + Hysteresis, M-PSK Receiver — Pull-in, Lock, and BER, Arbitrary-Rate Symbol Recovery, Full-Chain Lock-Up, Timing Loop — Theory Validation, Symbol Timing Recovery GuidesLock Detection Across doppler.track DesignAPI taxonomy: the DSP building-block hierarchy and its naming axis, AsyncDsssReceiver — the continuous DSSS receiver, from spec to object, Detection Sizing — the four laws behind one prefix, Lock Detection — the reasoning, The Loop Filter, MPSK Receiver, The NCO, Symbol Timing on a Rate Cascade, SymbolSync Timing Lock Detector, Waveform amplitude & composition ContributingAdding a New C Extension Module, Docs Conventions — what's generated, what's hand-owned, and what not to edit, Validation log