Streaming Async Despreader¶
A track.Dll in segments mode — the streaming
DSSS despreader. Its one job is to remove the PN code and output samples.
Two things make this the asynchronous case, and both are why segments mode
exists:
- the data symbols ride a clock asynchronous to the code epoch, so the DLL
despreads in
Kcoherent sub-epoch integrate-and-dump segments — a segment that straddles a mid-symbol data flip loses amplitude, not phase, where a single full-epoch I&D would be corrupted; - the code Doppler dilates the chip clock, and the power discriminator can't
pull that rate in on its own at low SNR, so the code rate is supplied by
carrier→code aiding (
Dll.set_rate_aid).
The DLL runs on a carrier-wiped stream: the carrier loop is upstream (here
a genie de-rotate stands in for the Costas loop that
AsyncDsssReceiver runs before its DLL). This is
the SPEC geometry: CCSDS Gold-1023, 8 samples/chip, code Doppler 2e‑4, data
clock offset 4e‑3 from the code epoch. K = 11 is
dll_lookback_segments(1023, 0.5 dB) — the number of transition-free coherent
segments the 1023 code splits into at the SPEC's tolerable correlation loss, the
same value the receiver's refine/track stages use.
What you're seeing¶
Left — Oversampled asynchronous BPSK out. The despread segment stream at
K = 11 samples/symbol, with the |despread| envelope (gray) drawn so the
amplitude is explicit. The symbol edges (red, dashed) slide through the
segment grid because the symbol clock is independent of the code clock — that is
the "async" in the name. The envelope sits at ≈ 1 except for the per-symbol
dips: each async symbol transition lands inside exactly one segment per
symbol, which then integrates two opposite-sign data halves and is scaled by
|2f−1|. The other K−1 segments are full. This is the two-clock collapse,
confined to 1-of-K instead of wiping a whole epoch — exactly what
segmented coherent correlation buys you.
Middle — Clean despread BPSK. The same segments in the complex plane: two tight BPSK clusters at ±1, not a smeared ring. The carrier was removed upstream and the code rate is aided, so nothing rotates within the correlation — the despread is coherent. (An uncorrected residual carrier is what would smear these onto a ring; that is the carrier loop's job, done before the DLL, not after it.)
Right — Code rate comes from aiding, not the DLL's own loop. code_rate
(the loop's own observable, 1 + integrator) sits at ≈ 1.0 while the aid
supplies the true 1 + 2e-4 code-rate dilation. The DLL isn't rate-tracking the
Doppler — it is being handed the rate by the upstream carrier estimate, and
its loop only mops up the residual. (This is the same mechanism
AsyncDsssReceiver uses: the pre-despread Costas tracks the full carrier and
refreshes set_rate_aid every period.)
(All three panels above are noiseless so the envelope and constellation stay legible — they are one and the same signal.)
Always-on lock detector¶
A tracking channel must always know whether it is locked, so the DLL carries a lock detector that reuses acquisition's non-coherent statistic. This is a separate, noisy experiment (the despread figure above is noiseless; a lock statistic is only meaningful against noise), so it gets its own figure on its own signal — the same carrier-wiped, rate-aided DLL.
It forms R = √(2·Σ|P|²/E|O|²) over N looks — prompt power over a CFAR noise
reference taken from a random off-peak correlation (re-drawn each epoch,
EMA-averaged) — and latches Dll.locked when R crosses
det_threshold_noncoherent(pfa, N). Left: R per epoch at several SNRs; with
Gold-1023's ~30 dB despread gain the signal traces sit far above the threshold
even when very weak, while the noise-only trace hugs √(2N) ≈ 6.3 below it.
Right: the noisy despread output behind the "weak" trace — the BPSK is still
recoverable and the detector reports lock on it. Because the noise reference rides
an EMA much longer than the N-look test (and is cumulative-mean-bootstrapped so
it is unbiased from the first look), the false-alarm rate holds at the target
pfa (default 1e-3) from the start. The statistic and threshold are the same
ones the FFT acquisition uses, so acquire and track agree on "detected".
How it works¶
Dll(segments=K) splits each code epoch into K coherent segment
integrate-and-dumps. Each segment despreads TE/K samples against the local
code; the K segments per epoch are an oversampled view of the symbol (≈ K
samples/symbol when the symbol rate is near the code rate). Code tracking folds
each segment's early/late envelopes into a non-coherent epoch sum Σ|E_k|,
Σ|L_k| — a data flip changes a segment's sign, not its magnitude, so only
the one straddling segment degrades. The code-rate dilation, which the
discriminator alone can't pull in, arrives as a per-epoch rate_aid bias from
the upstream carrier loop.
import numpy as np
from doppler.track import Dll
from doppler.wfm import Gold
# CCSDS Gold-1023 at the SPEC geometry. K = 11 coherent segments per epoch is
# dll_lookback_segments(1023, 0.5 dB) -- the transition-free coherent windows
# the 1023 code splits into at the SPEC's tolerable correlation-power loss, and
# what AsyncDsssReceiver's refine/track stages use.
SF, SPS, K = 1023, 8, 11 # Gold-1023 chips, samples/chip, coherent segments
TE = SF * SPS # code-epoch length, samples
F0 = 3e-4 # residual carrier, cyc/sample (removed upstream before the DLL)
DCODE = 2e-4 # code Doppler (chip-rate offset), supplied to the DLL as aid
DSYM = 4e-3 # symbol-vs-code rate offset (async)
PHI = 0.37 * TE # symbol-clock phase, samples
NSYM = 300
def make_signal(code, seed=9):
"""Async-data PN-spread BPSK with a residual carrier left on it."""
rng = np.random.default_rng(seed)
csign = np.where(code & 1, -1.0, 1.0)
tsym = TE * (1.0 + DSYM)
n = int(NSYM * tsym) + 2 * TE
idx = np.arange(n)
data = (rng.integers(0, 2, NSYM + 6) * 2 - 1).astype(float)
si = np.clip(np.floor((idx - PHI) / tsym).astype(int), 0, len(data) - 1)
cph = (idx * (1.0 + DCODE) / SPS).astype(int) % SF
rx = data[si] * csign[cph] * np.exp(2j * np.pi * F0 * idx)
return rx.astype(np.complex64), tsym
def carrier_wipe(rx):
"""Stand-in for the upstream carrier loop: de-rotate the residual carrier
off the stream so the DLL sees a carrier-wiped signal (its documented
input contract — the carrier loop wipes the carrier, the DLL wipes the
code). AsyncDsssReceiver does this with a live Costas loop; here we
de-rotate by the known F0."""
idx = np.arange(len(rx))
return (rx * np.exp(-2j * np.pi * F0 * idx)).astype(np.complex64)
# CCSDS Gold-1023; sps samples/chip; segments = K coherent windows per epoch
code = np.asarray(Gold().generate(SF)).astype(np.uint8)
rxw = carrier_wipe(make_signal(code)[0]) # carrier removed upstream
d = Dll(code, sps=SPS, init_chip=0.0, bn=0.002, zeta=0.707, spacing=0.5,
segments=K)
d.set_rate_aid(DCODE) # carrier->code aiding supplies the code-rate dilation
part = d.steps(rxw) # oversampled async BPSK out (PN removed), clean BPSK
rate = d.code_rate # the loop's own observable (~1.0; the aid does the rate)
# always-on lock detector (acquisition's non-coherent test):
d.configure_lock(pfa=1e-3, n_looks=20) # size n_looks via detection.det_n_noncoh
if d.locked: # latched each n_looks-look decision
print(d.lock_stat, d.noise_est) # statistic R and the CFAR noise ref
# downstream — symbol-timing recovery on the already-carrier-clean segments:
from doppler.track import SymbolSync
ss = SymbolSync(sps=K, bn=0.02, zeta=0.707)
syms = ss.steps(part) # -> one decision per recovered symbol
bits = np.where(syms.real >= 0, 1, -1)
assert bits.shape == syms.shape
The short coherent segment window is also what keeps the despread robust to the
residual the upstream carrier loop hasn't yet nulled: for a ½-Doppler-bin
residual the integrate-and-dump loss is small at K = 11 (versus a full-epoch
segments = 1 prompt, which a mid-epoch data flip corrupts outright).
steps() is block-size invariant and returns an independent array per call, so a
receiver can stream blocks and keep every one.
Source: src/doppler/examples/async_despread_demo.py.
See also the design note Async Symbol Despreader, the
AsyncDsssReceiver — the SPEC Waveform gallery page
for the full carrier + code + aiding chain end to end, the
Full-Chain Lock-Up page for the
Dll -> Costas -> SymbolSync chain converged and lock-observed via telemetry,
and the lock-detection guide for how Dll's lock
detector here fits alongside every other loop's.

