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¶
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⁴/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
nis nowm_out, and it means something different.nsized a separate NDA arm (window =sps/n); that arm no longer exists.m_outis 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 atsps = 8againstEVM_dB = -(Es/N0)_dB,m_out = 8lands 0.41 dB off the bound at 18 dB Es/N0 wherem_out = 4loses 3.11 dB. Never pair 2 withpulse="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_carrieris normalised to the symbol rate, likebn_timing, rather than to the input sample rate. Atsps = 8the 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.
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:
spsis adouble. 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
|
Examples:
Create with defaults:
step
¶
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:
steps
¶
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:
configure
¶
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
¶
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:
state_bytes
¶
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
¶
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, |
set_state
¶
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 |
required |
destroy
¶
Release the underlying C resources immediately.
Ordinarily unnecessary: the resources are freed when the object is garbage-collected. Call this to release them at a definite point instead, or use the object as a context manager, which calls it on exit.
Idempotent: calling it again on an already-released object does
nothing. Every other method raises RuntimeError once it has run.
__enter__
¶
Enter a context manager, returning this object.
Lets a 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,
... )
locked
property
¶
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).
steps
¶
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
¶
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
¶
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: "
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
|
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
¶
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:
configure_lock
¶
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:
reset
¶
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
¶
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
¶
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, |
set_state
¶
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 |
required |
destroy
¶
Release the underlying C resources immediately.
Ordinarily unnecessary: the resources are freed when the object is garbage-collected. Call this to release them at a definite point instead, or use the object as a context manager, which calls it on exit.
Idempotent: calling it again on an already-released object does
nothing. Every other method raises RuntimeError once it has run.
__enter__
¶
Enter a context manager, returning this object.
Lets a 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,
... )
steps
¶
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 |
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
¶
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
¶
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
¶
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
¶
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
¶
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, |
set_state
¶
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 |
required |
destroy
¶
Release the underlying C resources immediately.
Ordinarily unnecessary: the resources are freed when the object is garbage-collected. Call this to release them at a definite point instead, or use the object as a context manager, which calls it on exit.
Idempotent: calling it again on an already-released object does
nothing. Every other method raises RuntimeError once it has run.
__enter__
¶
Enter a context manager, returning this object.
Lets a 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,
... )
locked
property
¶
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).
steps
¶
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
¶
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
¶
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):
"
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
|
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
¶
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:
reset
¶
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
¶
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
¶
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, |
set_state
¶
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 |
required |
destroy
¶
Release the underlying C resources immediately.
Ordinarily unnecessary: the resources are freed when the object is garbage-collected. Call this to release them at a definite point instead, or use the object as a context manager, which calls it on exit.
Idempotent: calling it again on an already-released object does
nothing. Every other method raises RuntimeError once it has run.
__enter__
¶
Enter a context manager, returning this object.
Lets a 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
symbol_window
property
¶
The lock detector's coherent window in partials when set_symbol_period is on (0 = off) -- size n_looks from it.
locked
property
¶
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
¶
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
¶
Current CFAR noise-power estimate E|O|^2 from the off-peak (noise) tap EMA.
steps
¶
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
( |
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
¶
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
¶
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: "
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
|
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
¶
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:
set_rate_aid
¶
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
¶
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, |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the C call returns a non-zero status. The exception message is
|
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 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
|
Examples:
configure_lock
¶
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
|
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
|
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
¶
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
¶
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
¶
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, |
set_state
¶
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 |
required |
destroy
¶
Release the underlying C resources immediately.
Ordinarily unnecessary: the resources are freed when the object is garbage-collected. Call this to release them at a definite point instead, or use the object as a context manager, which calls it on exit.
Idempotent: calling it again on an already-released object does
nothing. Every other method raises RuntimeError once it has run.
__enter__
¶
Enter a context manager, returning this object.
Lets a 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",
... )
lock_stat
property
¶
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
¶
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
¶
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
¶
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
¶
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: "
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
|
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
¶
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:
configure_lock
¶
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
|
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:
reset
¶
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
¶
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
¶
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, |
set_state
¶
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 |
required |
destroy
¶
Release the underlying C resources immediately.
Ordinarily unnecessary: the resources are freed when the object is garbage-collected. Call this to release them at a definite point instead, or use the object as a context manager, which calls it on exit.
Idempotent: calling it again on an already-released object does
nothing. Every other method raises RuntimeError once it has run.
__enter__
¶
Enter a context manager, returning this object.
Lets a 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 >= |
4.0
|
pulse
|
Literal['iandd', 'rrc']
|
Matched-filter pulse shape: "rrc" (root-raised cosine, roll-off |
"rrc"
|
beta
|
float
|
RRC roll-off in |
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 |
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",
... )
rate
property
¶
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
¶
Current per-input rate deviation steering the terminal stage's accumulator.
lock_stat
property
¶
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
¶
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
¶
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
¶
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
¶
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
¶
Attach (or detach) a telemetry context and register the probes.
Registers six probes, emitted once per recovered symbol and further
thinned by decim: "
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
|
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
¶
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
¶
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
¶
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
¶
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, |
set_state
¶
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 |
required |
destroy
¶
Release the underlying C resources immediately.
Ordinarily unnecessary: the resources are freed when the object is garbage-collected. Call this to release them at a definite point instead, or use the object as a context manager, which calls it on exit.
Idempotent: calling it again on an already-released object does
nothing. Every other method raises RuntimeError once it has run.
__enter__
¶
Enter a context manager, returning this object.
Lets a 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. |
...
|
Related pages¶
Gallery — Streaming 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
Guides — Lock Detection Across doppler.track
Design — API 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
Contributing — Adding a New C Extension Module, Docs Conventions — what's generated, what's hand-owned, and what not to edit, Validation log