Asynchronous symbol/code despreading¶
Status: draft / validated architecture
Scope: the receive-side despreader when the data-symbol rate is on the
order of the code-epoch rate but asynchronous to it. This is theory, the
failure mechanism, and a validated robust architecture that composes existing
doppler.track primitives. The reproducible study is
src/doppler/examples/async_despreader_study.py
(python -m doppler.examples.async_despreader_study).
1. The two-clock problem¶
A DSSS receiver despreads by integrating early/prompt/late correlations over one
code epoch (TE = sf·sps samples) — an integrate-and-dump locked to the
code clock. The data symbols are a separate stream; the despread prompt per
epoch carries the data.
That works when the symbol clock is locked to the code clock at an integer ratio (GPS C/A: 20 code epochs per data bit, bit edges on epoch edges). It breaks when the symbol clock is independent:
T_sym = TE · (1 + delta) # symbol period, samples
# delta = symbol-vs-code rate offset
phi_sym # independent symbol phase
with T_sym ≈ TE (symbol ≈ one epoch). This is the hard regime: ~one symbol per
epoch, a transition roughly every epoch, and — crucially — delta ≠ 0 makes the
symbol boundary slide continuously through the epoch at the beat rate
delta / TE.
2. Why per-epoch despreading fails¶
The coherent prompt over an epoch whose data flips at fraction f ∈ [0,1]:
f → 0, 1(flip at an epoch edge):|P| = A(full despread).f → 0.5(flip mid-epoch):|P| = 0— total coherent cancellation.
Because delta ≠ 0, f sweeps through every value, so ~half of all epochs
straddle a transition and their prompts collapse. The consequences:
- Data: per-epoch decisions floor — the BER plateaus regardless of
Es/N0(the straddle epochs carry no usable energy). Measured floor ≈ 1e-1 even when the bound is < 1e-5. - Code: the early/late discriminator
(|E|−|L|)/(|E|+|L|)collapses to0/0on straddle epochs → the DLL is starved → the code loop wanders.
Root cause: at one prompt per epoch the symbol clock is unobservable (a single sample per symbol cannot drive a timing loop), and the integration window is forced to straddle transitions.
Diagnostic fingerprint¶
The straddle modulation is periodic at the symbol↔epoch beat. The spectrum of
the prompt-magnitude stream |P[n]| shows a tone at |delta| cycles/epoch
(centre panel of the figure). This is the signature to look for when a DSSS link
shows unexplained despread fades — it identifies this failure class directly.
3. Robust architecture¶
The fix gives the symbol clock its own observability and its own matched filter, and makes code tracking insensitive to data sign — composing primitives that already exist.
3.1 Data path — partial correlations + symbol matched filter + SymbolSync¶
- Partial correlations. Split each code epoch into
Ksub-epoch partial prompt correlations (eachTE/Ksamples, known code phase). This yieldsKdespread samples per epoch ≈Ksamples per symbol — the symbol clock is now observable. - Symbol matched filter. A length-
Kboxcar over the partial stream. This is a sliding, symbol-aligned coherent re-integration of the partials — the full-symbol despread the epoch-locked window could not form. It is essential: without it, the rectangular symbol pulse is sampled at one point and only ~1/Kof the symbol energy is captured (the BER floors at ~2e-2). - SymbolSync.
track.SymbolSync(Gardner TED + Farrow interpolator) recovers the independent symbol clock (delta,phi) from the matched-filtered stream and decimates at the symbol-aligned peak.
Result (left panel): the BER follows the BPSK matched-filter bound within ~1–2 dB. A genie reference (coherent symbol-aligned despread with known timing) hits the bound exactly — the loss was only window misalignment, never SNR. The broken per-epoch path floors.
| Es/N0 | bound | genie (known timing) | partial+MF+SymbolSync | broken epoch |
|---|---|---|---|---|
| 6 dB | 2.4e-3 | 2.5e-3 | 4.5e-3 | ~7e-2 |
| 8 dB | 1.9e-4 | 1.5e-4 | 5.8e-4 | ~6e-2 |
| 9.6 dB | 9.7e-6 | 0 | 0 | ~5e-2 |
3.2 Code path — non-coherent partial combining¶
The DLL keeps tracking through data flips by combining the partial correlations
non-coherently: |E| = Σ_k |E_k|, |L| = Σ_k |L_k|. A data flip changes a
partial's sign, not its magnitude, so only the one straddling segment
degrades (~1/K). This roughly halves the discriminator variance versus the
coherent-epoch form (right panel) — keeping the (already validated, smooth
sub-chip) code loop locked. It needs no symbol timing, so it works from cold
start; the bootstrap order stays sequential: DLL (non-coherent) → SymbolSync →
data.
3.3 Choosing K¶
K trades observability and straddle-robustness against the non-coherent
squaring/Rician bias (which erodes the discriminator gain as K grows). The
study shows K = 4 as the sweet spot for T_sym ≈ TE (best discriminator
SNR; K = 8 loses more gain than variance). K must divide TE.
4. Scope: the despreader removes the code and outputs samples¶
The despreader's one job is to remove the PN code and output samples. The
asynchronous symbol clock is merely why it despreads in K partial
correlations (§3) — it is not a reason to recover symbols here. Carrier
recovery and symbol extraction are downstream problems, handled by separate
objects fed from the despreader's output:
┌──────────────── the despreader ───────────────┐
acq seed → Dll(segments=K): E/P/L correlate · partial dump · non-coherent
(code phase) (|E|−|L|) code loop
└───────────────── partial stream out ──────────┘
│ K oversampled async BPSK samples/symbol
│ (PN removed; residual carrier + data still on them)
▼
downstream: Costas (carrier recovery) → SymbolSync (symbol timing) → bits
This is track.Dll(..., segments=K) — no new object. segments=1 is the
classic coherent full-epoch DLL; segments=K>1 is the streaming async
despreader. It composes downstream with Costas and SymbolSync, which already
exist (the data path of §3 is exactly that composition).
Why the carrier belongs downstream¶
The DLL's |E|−|L| discriminator is non-coherent, so code tracking is
carrier-blind — it locks with a residual carrier still on the samples. And
because each output is a partial (a TE/K-sample integrate-and-dump, not a
full epoch), a residual carrier barely dents it. For a ½-Doppler-bin residual
after acquisition the I&D loss is sinc(Δφ/2) with Δφ = π/segments:
| segments | window | Δφ at ½-bin residual | despread loss |
|---|---|---|---|
| 1 | TE |
π |
−3.9 dB |
| 4 | TE/4 |
π/4 |
−0.2 dB |
So short partials make the despread carrier-tolerant: the small residual just
rides out on the output (a ring in the constellation; see the gallery demo), and
a downstream Costas loop removes it at full symbol SNR. Putting a carrier loop
inside the despreader would only matter for long coherent integration — which
partials deliberately avoid.
5. Code-lock detection (always on)¶
A tracking channel must always answer one question: am I locked? The DLL carries an always-on lock detector that reuses acquisition's non-coherent test statistic, so acquire and track agree on what "detected" means.
Statistic. Each emitted look (a partial in segments mode, the full-epoch
prompt when segments=1) contributes its prompt power |P_k|². The detector
sums N = n_looks consecutive looks and forms
which under H0 (noise only) has P(R > η) = marcum_q(N, 0, η) — exactly the
acquisition tail. So a caller sizes the threshold η = det_threshold_noncoherent(pfa, N) and the depth N = det_n_noncoh(snr, …) to
meet a target (Pfa, Pd); configure_lock(pfa, n_looks) does the conversion
(default pfa=1e-3, N=20).
The noise reference E|O|². Instead of a separate noise channel, the loop
correlates each look a second time at a random off-peak code phase — a whole
chip offset re-drawn every epoch and kept clear of the prompt/early/late lobe by
noise_guard chips. For a low-sidelobe code (Gold, long PN) that offset
correlation is signal-free, so |O_k|² is a sample of the per-look noise power.
Cycling the offset and averaging recovers the same noise estimate a bank of
fixed off-peak taps would, with O(1) state.
Why an EMA, and why it must be long. The reference is an EMA of |O_k|²
(E|O|² += α(|O_k|² − E|O|²)), which is adaptive (tracks a drifting noise floor)
and O(1) — matching the Costas lock-metric pattern. The subtlety, found by
Monte-Carlo: the detection integrates a fixed N looks (that sets the χ²(2N)
threshold), but the noise estimate must average many more cells than N,
or its own variance inflates Pfa. One offset cell per look (L=N) drives Pfa
~400× high; 1/α = max(1024, 32·N) (L_eff ≫ N) holds Pfa at target with
Pd ≈ 0.98. So the integration depth and the noise-averaging length are
decoupled: N is the test, 1/α is the reference. The reference uses a
cumulative-mean bootstrap — it is the running average until 1/α looks have
accrued, then relaxes to the fixed-α EMA — so the noise floor is unbiased from
the first look instead of seed-dominated for the ~1/α-look warm-up (otherwise
Pfa runs ~10× high until the EMA settles, ~hundreds of epochs in). Verified
end-to-end: empirical Pfa ≈ 9e-4 against the 1e-3 target right from the
start of a noise stream.
Readouts. Dll.locked (bool, latched each N-look decision), Dll.lock_stat
(the last R), Dll.noise_est (E|O|²). The detector runs inside the normal
steps() — no separate method, no opt-in. The threshold conversion (the one
detection-module call) lives in the binding so dll_core links only -lm.
6. Status¶
- Shipped — the despreader.
Dll(..., segments=K)(the §3 code+symbol path;segments=1= the classic coherent DLL). Validated carrier-present: code lock holds with a residual carrier on the samples, and the partial output is losslessly recoverable by a downstream carrier wipe + symbol despread (test_dll.py::test_segments_carrier_present_*). The streaming binding returns an independent array per call (block-size invariant). - Shipped — the inline symbol-loop primitive.
symsync_step()(the per-sample SymbolSync composition API);symsync_steps()is it in a loop. - Shipped — the always-on code-lock detector (§5).
Dll.locked/lock_stat/noise_est, tuned byconfigure_lock(pfa, n_looks); reuses acquisition's non-coherent statistic with a random off-peak EMA noise reference. Validated signal-vs-noise intest_dll.py/test_dll_core.c. - Downstream, already available:
Costas(carrier recovery) andSymbolSync(Gardner + Farrow symbol timing). A receiver is the pipelineDll(segments) → Costas → SymbolSync; the §3 study and theasync_despread_demogallery example show the composition.
Possible refinements¶
- Symbol MF length. A downstream length-
Kboxcar matched filter follows the BPSK bound within ~1–2 dB; matching it to the tracked symbol period closes the gap. - Closed-loop code-jitter asset. Drive the non-coherent partial code loop
under async data + code Doppler; confirm lock retention and the low-SNR
threshold (
bn≈1e-5held to 4 dB Es/N0;bn≈0.002lost lock at 6 dB).
