DSSS acquisition: stateless, parallel, dynamics-capable¶
Status: draft / for discussion
Scope: the receive-side acquisition architecture — beyond today's
all-coherent streaming BurstAcquisition (paired with a continuous
Acquisition sibling that always window-tiles instead — see §1). This is
theory, trade-space, and a phased roadmap; for how to use the current
engine see the
DSSS Burst Acquisition guide.
1. Context¶
doppler.acquire.BurstAcquisition (native/src/acq/acq_core.c) acquires a burst
that opens with a repeated preamble (here, a DSSS preamble: repeated BPSK PN
epochs; §3.1 covers any repeated complex sequence) under unknown code phase and
Doppler. It frames the stream into (doppler_bins, nx) (one preamble period per
row), FFTs down the columns for Doppler, correlates each row against the
preamble, and CFAR-gates the surface. It is parameterised by physics — the preamble's samples and
their rate fs (a PN code held spc samples a chip, here), cn0_dbhz,
reps, pfa, pd — and sizes its own grid: the coherent depth doppler_bins
is the smallest in [1, reps] whose burst Pd (pd_burst: the preamble landing
at any offset against stream-aligned dwells) meets pd, capped by
doppler_rate when one is given (see
the guide). Below, ny ≡ doppler_bins is the
slow-time / coherent-depth axis. It works, and it is the right tool for a
static, low-Doppler, data-free (preamble) signal. A sibling Acquisition
class (continuous) shares this same C engine but always uses the
window-tiling mechanism of §4 instead — see the warning immediately below.
Only for a data-free coherent window
Everything below about pushing M/doppler_bins up for gain or
resolution assumes the coherent window is genuinely code-only (a
preamble, or a periodic pilot epoch on an otherwise data-bearing stream —
see UC1 in the use-cases doc). On a
continuous, data-modulated signal, growing M past the data-bit
period is not a graceful gain/latency trade — the data's own baseband
spectrum, sampled at close to one sample per symbol, aliases broadband
energy across the entire Doppler-bin axis, and a real, deterministic
mislock (the wrong bin winning outright) results well before "gain
reverses into loss" in §5 sounds like it would. See
Continuous, data-modulated signals
in the guide for the confirmed mechanism and worked proof. The shipped
engine closes
this by construction rather than by pricing it as a tunable trade-off:
the class is now split in two (see §10's "Update — shipped: the
burst/continuous split"), and the continuous Acquisition class always
window-tiles. It combines coherently only inside a tile, and only across
epochs the caller declares code-only (code_only_epochs), so no data
transition can land inside the coherent window. An interim
design tried doppler_resolution on one combined class to force the
coherent depth up; it is gone (see §10). symbol_rate survives as a
diagnostic, and doppler_rate survives as a CAP on the depth, never a
floor.
This design's T_coh-ceiling framing below should eventually be
reconciled with the split, not with the retired parameters.
It also sits in one corner of the acquisition design space, with three gaps:
| Gap | Today | Why it matters |
|---|---|---|
| Stateful | Owns a private ring, a corr2d coherent accumulator (accum/count), and a stream offset. Since shipped: the carry serializes (dp_acq_state_bytes/get_state/set_state, §10 P0) and hands off across pods (§10 P4). |
Cannot fan out across processes/pods; the engine is the unit of parallelism, not the work. |
| All-coherent | The slow-time FFT over the ny epochs is coherent. Non-coherent looks (n_noncoh>1) extend it on the continuous engine only; a burst engine never adds them (§10). |
Coherent time is bounded (below); weak signals beyond that bound need the non-coherent looks. |
| Constant-Doppler | The slow-time FFT assumes a linear phase ramp across segments. Since shipped: doppler_rate caps the depth so the ramp stays linear; the burst engine's rate_hypotheses de-chirps the block at N rates across ±doppler_rate (burst-capture.md §11.5); the acq_caf_tile rate axis (P3) has not. |
Platform dynamics (Doppler rate) make the phase quadratic → the FFT smears → acquisition fails. |
This document defines the problem and terms precisely, frames every acquisition method as one cross-ambiguity function (CAF), lays out the trade space, and proposes a stateless, parallel, dynamics-capable architecture with a phased build. Large Doppler and dynamics are hard requirements.
2. Glossary¶
| Term | Definition (doppler units) |
|---|---|
Chip / chip-rate Rc |
PN code element; Rc in chips/s. |
Code / PN / sf = L |
Spreading code; spreading factor sf = code length L in chips. |
Epoch T_epoch |
One code period = L / Rc s = nx samples. |
spc vs sps |
spc = samples per chip = fs/Rc; sps = samples per symbol (not in the acq grid). nx = sf·spc. |
| Segment | One epoch of fast-time samples = one slow-time row. |
| Sub-block | An epoch chopped into K pieces; segment = epoch/K. |
| Code phase / delay | Circular shift of the code = propagation delay; fast-time axis 0 … nx-1. |
| Doppler | Carrier frequency offset f; slow-time axis. |
| Doppler rate | rdot = df/dt (Hz/s); quadratic carrier phase from acceleration. |
| Code Doppler | Chip-rate dilation Rc·(1+v/c); code phase walks over long integration. |
| Fast-time / slow-time | Within an epoch (code phase) / across epochs (Doppler). |
| CAF / delay-Doppler map | The cross-ambiguity surface over (delay × Doppler [× rate]). |
| Coherent / non-coherent | Sum complex surfaces (gain 10·log10(M), phase-fragile) / sum squared magnitude (robust, squaring loss). |
| Dwell / look | One coherent dump; a "look" is one such dump fed to non-coherent integration. |
T_coh |
Coherent integration time = ny · T_epoch. |
| Pfa / Pd | False-alarm / detection probability; Bonferroni pfa_cell = 1-(1-pfa)^(1/N), N = ny·nx on the native grid — see §9. |
| Processing gain | Coherent 10·log10(M·N); non-coherent adds about 5·log10(N_nc) in the weak limit. |
| Squaring / combining loss | Non-coherent's few-dB shortfall versus ideal coherent. |
| CFAR | Constant-false-alarm-rate gate; test_stat = peak / noise_est. |
3. The cross-ambiguity function — one surface¶
Every method here computes, or approximates, the same object: the CAF, a correlation surface over delay (code phase) and Doppler, extended by a Doppler- rate axis when there are dynamics.
Doppler f
▲
│ ┌───────────────┐
│ ╱ ╱│
rate rdot │ ╱ CAF tile ╱ │ peak = (delay*, f*, rdot*)
(depth) │ ╱ |R(τ, f)|² ╱ │
│ └───────────────┘ │
│ │ │ ╱
└────┼───────────────┼─╱──────► delay τ (code phase)
└───────────────┘
The methods differ only in how they sweep Doppler and how they handle non-constant phase (dynamics). They are not competing algorithms; they are decompositions of one surface, each efficient in a different regime.
3.1 Any repeated preamble, not only a code¶
Nothing in the burst engine's surface is specific to a PN code. It frames the
stream by one period of the reference, correlates each repetition
circularly, transforms the slow-time axis, and tiles wide uncertainty by
whole-bin spectral rolls. All of that holds for any periodic complex
reference. A code is the case whose samples come from chips.
dp_acq_create_burst() takes the samples themselves (#1470, #1478), and
only the samples: a PN code is passed as its chips by bin_to_nrz(), held
spc samples each. One chip is one sample (sf = n, spc = 1,
chip_rate = fs), so every path above runs unchanged, and code_phase is
the delay into the repetition in samples.
What the engine asks of the waveform is two numbers about its
autocorrelation R. A code has both analytically, from the triangle one chip
wide. A template gets both from one FFT at construction (acq_shape_t):
| quantity | used for | a code | a template |
|---|---|---|---|
zone |
the peak list's exclusion and the twin rule's reach | spc |
the first null: the first lag where \|R\| stops falling by more than 1e-6·R(0) |
| delay straddle | the Pd model's half-sample prior | 1 − δ/spc |
band-limited \|R(δ)\|/R(0); an even length's Nyquist bin contributes cos(πδ) |
A burst engine uses the right-hand column for everything, codes included. The "a code" column is an ideal rectangular pulse's: continuous chips correlated against each other. A sampled, band-limited chain never sees that, and against acq §2.6's rectangular-chip Monte-Carlo the triangle's delay straddle under-predicted Pd by 0.03–0.10 at 4 samples a chip and up to 0.40 at 1. The samples model sits at +0.00 to +0.08, never optimistic, over 8000 trials a point. Only the continuous engine, which still takes chips, keeps the analytic column (#1495).
The two agree where they should. A 31-chip code handed over as samples gets
zone 4 = spc. A 7-chip code honestly gets 3, because its sidelobe floor
(1/7) reaches the sampled triangle a lag early. The tolerance exists because
a floor is flat and a perfect sequence's off-peak lags are rounding (≈3e-9 of
R(0) for a float Zadoff-Chu). Without it the minimum lands wherever the
FFT's last digit fell.
The rotation loss keeps its sinc, and for a chirp that is pessimistic.
The Pd model derates the peak for rotation within one repetition by
sinc(u), the ambiguity function's zero-delay cut. For a constant
envelope that cut is exactly the Dirichlet. For a shaped template it was
measured against sinc(u) over u ∈ [0, ½]: within 0.007 dB even for RRC
QPSK at 4.7 dB PAPR, so no rotation table is kept. The detector, however,
takes the maximum over delay. A thumbtack (random-phase QPSK) peaks at
zero delay and the two agree. A chirp's peak slides along its ridge instead
of shrinking, so the zero-delay cut overstates its loss. Measured (below),
that makes the model conservative for a chirp, never optimistic.
Scale. The template is scaled to unit RMS, the scale a code's ±1
reference has, so peak_mag and noise_est are in the signal's units.
cn0_dbhz is the preamble's mean power over fs. At fs = 1, normalized
units, it is the per-sample SNR in dB and Doppler is in cycles/sample.
That per-sample SNR is negative wherever acquisition is hard, and the burst
constructors accept it: any finite cn0_dbhz is a design point, and NaN
(ACQ_CN0_NONE) means none (#1484).
What is not claimed yet:
-
pd_predictedis measured, not just modelled (native/validation/acq_template_pd.c, acq report §2.6's method: Doppler uniform over the span, continuous delay, shipped AWGN, D = 8, one look, 3000 trials a row, measured − predicted at the C/N0 where the model predicts 0.3 / 0.6 / 0.9):preamble Δ Pd mean |delay error| reads as code, 31 chips × 4 (control: §2.6 measured 0.710 ± 0.026, this 0.695) +0.08 0.4 samples the harness reproduces §2.6 random-phase QPSK, 96 +0.04 / +0.05 / +0.04 0.2–0.3 conservative Zadoff-Chu, 127 +0.04 / +0.05 / +0.05 0.7–2.5 conservative chirp, 128 +0.08 / +0.12 / +0.08 0.3 conservative: the ridge slide above QPSK 96, 5-tap low-passed +0.06 / +0.10 / +0.05 0.3–0.4 conservative Every class keeps the "never optimistic" contract. The shaped template broke it (−0.01 to −0.03 at 3σ, #1483) until the model priced the CFAR reference the gate divides by (#1501). That reference is the mean magnitude of the whole surface, peak included, and a template's correlation energy off its peak lands in it. Shaping spreads that energy; a code's sidelobes spread far more.
-
Delay–Doppler coupling is measured, not corrected. A hit's reported delay is off by 0.3 samples on average for a chirp and up to 2.6 for Zadoff-Chu, whose Doppler sensitivity moves the peak by whole samples. It is the capture's refine, not the detector, that owns the exact start.
-
Time compression is not compensated on the native burst path (#1481), and it bites wideband templates hardest.
-
A Doppler rate caps the coherent depth (#1482). The burst constructors take
doppler_rate(Hz/s, 0 = no bound) and size at mostf_epoch / sqrt(2 * doppler_rate)repetitions, the drift rule the continuous engine already applies to its block depth. A depth past it smears the carrier across slow-time rows, which the Pd model does not carry. Measured on 16 repetitions of Zadoff-Chu 127 under a 1.5 MHz/s ramp (native/validation/acq_template_pd.c): an engine told nothing is sized at D = 10, predicts 0.91 and delivers 0.85. Told the rate, it caps at D = 4, reports under-powered, predicts 0.42 and delivers 0.45 (measured 2026-10-15, after #2218 priced the gate over the interpolated surface; 3000 trials). -
Which repetition the burst starts in is the capture's to resolve, not the detector's (
BurstCapture). So is a dwell that runs into the data after the preamble (burst-capture).
4. Computational decompositions — placement verdicts¶
| Method | Wins when | Doppler resolution | Stateless | Verdict |
|---|---|---|---|---|
| Mixer + slow-time FFT (current) | Doppler within one slow-time Nyquist; low dynamics | 1/(ny·nx) (fine) |
after P0 | IN — P0 coherent kernel |
| 2-D spectral roll | single-epoch coarse sweep, no integration | 1/nx (coarse) |
yes | OUT for ny>1 regimes; IN — D=1 async-data widener |
| 2-D spectral roll + slow-time FFT | a pure-code window of ≥ 2D−1 epochs in continuous data |
1/(D·nx) (fine) |
yes | IN — the async searcher's D>1 mode (async-dsss-receiver.md §2.3): the mixer bank with one forward FFT shared across tiles |
| Sub-block chop | wide Doppler with integration intact | 1/(ny·nx) (same) |
yes | IN — P2 widener |
| Direct mix-and-correlate | reference truth | any | yes | OUT — test ground truth |
| Doppler-rate de-chirp grid | acceleration / long T_coh |
adds rate bins | yes | IN — P3 dynamics axis |
Mixer + slow-time FFT — the core. Reframe (ny, nx), FFT down the columns
for Doppler, correlate rows against the code, integrate dwell frames. It is the
most code-correlation-efficient method per Doppler bin: one correlation set yields
all ny Doppler bins via the slow-time FFT. The whole architecture generalizes
around this kernel.
2-D spectral roll — OUT when coherent integration is viable, but IN as the
dedicated D=1 async-data widener. Rolling conj(FFT(code)) by k integer
bins and IFFT-ing against the received spectrum tests Doppler hypothesis k.
Rolling by k bins is exactly mixing by k/nx — the integer-bin special case
of the mixer. Where the mixer bank's ny-epoch (or P2's K-sub-block) coherent
integration is available, it does everything the roll does plus a finer
(half-window) step plus that integration gain, so the roll stays strictly
dominated there — a documented dual, not a kernel.
That domination argument assumes coherent integration across more than one epoch is on the table. It isn't for continuous, asynchronous data-modulated signals: §4's own reasoning depends on a data-free coherent window, and task
67 of the async-receiver story measured this¶
directly — a data toggle rate close to the code-epoch rate aliases the data's
own baseband spectrum across the entire Doppler-bin axis of any ny>1
coherent FFT (slow-time OR a P2 sub-block's longer ny·K window), producing a
structural, deterministic mislock rather than graceful SNR loss. In that
regime ny (and K) are pinned to 1 regardless of method, so the mixer
bank's and P2's entire advantage over the roll — the coherent gain — is
unavailable to either of them; margin instead comes from non-coherent
accumulation (n_noncoh, itself alias-immune) across many D=1 epochs. With
coherent integration off the table for every method, the domination argument
collapses and the roll's one-epoch, 35-FFT-equivalent grid (vs. a D-channel
mixer bank's 2D FFT-equivalents, D = uncertainty / native span — measured
1.2-1.55x, a frequency-bank benchmark) is the right,
adopted mechanism for this regime specifically. It is now wired directly into
Acquisition (continuous)'s C core (native/src/acq/acq_core.c's wideband
mode, the uncertainty tiled with window_bins parallel roll-FFT hypotheses,
and coherent_bins 1 unless the caller declares code-only epochs) rather than
composed externally. There is no free coherent-depth knob for a caller to
tune: the only depth Acquisition (continuous) takes is derived from
code_only_epochs and capped by doppler_rate (see §10). The roll IS the
whole D=1 search, not an
optional front-end layered in front of a richer kernel. (The burst/repeats
regime, where ny>1 coherent integration remains genuinely available —
BurstAcquisition — is unaffected: the mixer bank/P2 stay primary there, per
open question 6 below.)
Sub-block chopping — the wide-Doppler widener. Chop each epoch into K
sub-blocks; the slow-time rate rises to K/T_epoch, so the native span widens to
±K/(2·T_epoch) (one long FFT of length ny·K) at the same resolution
1/(ny·T_epoch), and the within-segment rotation limit loosens K×. The cost is
partial-correlation loss (each sub-block sees 1/K of the code → weaker
autocorrelation and worse cross-correlation sidelobes) plus a larger FFT. For
moderate K (≤ 8) the loss is a few dB. It is the principled alternative to a
very wide mixer bank.
Doppler-rate de-chirp — the dynamics axis. Multiply by exp(-jπ·rdot·t²) per
rate hypothesis before the slow-time FFT to remove the quadratic phase. It wraps
the coherent kernel as an independent outer search axis; grid sizing falls out of
the T_coh budget (§8).
5. The trade space¶
T_coh (coherent integration time) is the master knob: it adds gain linearly
(10·log10(M·N)), but is bounded by four ceilings, whichever bites first —
(a) Doppler uncertainty (f·T_coh < ~1/4 cycle, the within-segment sinc), (b)
data-bit period, (c) oscillator coherence, (d) Doppler rate
(rdot·T_coh² < O(1)). Pushed past the binding ceiling, coherent gain reverses
into loss.
Ceiling (b) fails hard, not gracefully
Ceilings (a)/(c)/(d) do degrade gracefully — smearing that costs gain smoothly as the ceiling is exceeded. Ceiling (b), the data-bit period, is qualitatively different and does not belong in the same "gain reverses into loss" bucket: past it, the data's own spectrum aliases across the whole Doppler axis and a specific, deterministic mislock (wrong bin wins outright) becomes likely, confirmed empirically — see the warning in §1 above.
Non-coherent integration (N_noncoh magnitude-summed looks) picks
up there: robust to inter-look phase (Doppler walk, bit flips, oscillator drift),
gaining about 5·log10(N_nc) in the weak limit — the squaring/combining loss
is the price of phase-robustness.
| Axis | Raises | Costs | Coupled to | Parallel |
|---|---|---|---|---|
T_coh (M) |
gain 10·log10(M·N) |
latency; bounded by f/bit/osc/rate | rate-grid (∝T_coh²), N_nc |
within shard |
N_noncoh |
weak-signal reach | squaring loss (~5·log10) |
the T_coh ceiling |
tree-reduce |
Sub-block K |
Doppler span (K×), rate headroom |
partial-corr loss, FFT ny·K |
within-segment limit | within shard |
| Coarse channels | Doppler span (tiling) | linear compute | step / edge loss | shard axis |
| Rate hypotheses | dynamics tolerance | linear compute | 1/T_coh² |
shard axis |
spc |
code-phase resolution, anti-alias | compute ∝nx |
nx, fs |
— |
Pfa↓ / Pd↑ |
confidence | larger M·N_nc |
threshold eta |
— |
The coherent ↔ non-coherent split is the central design choice: take M
(coherent depth) as large as the binding T_coh ceiling allows, then take N_nc
(looks) to close the remaining Pd gap. The detection module already has the
primitives — det_threshold(pfa) → eta, det_pd(snr, dwell, eta) (coherent,
order 1), and crucially marcum_q(m, a, b) with arbitrary integer order m,
which is the non-coherent detector for m = N_nc looks (native/inc/doppler/detection/).
The packaged det_pd_noncoherent(snr, n_coh, n_noncoh, threshold) (plus its look inverse
det_n_noncoh) lets the engine auto-split (M, N_nc) from (Pfa, Pd, cn0_dbhz) and the ceilings — cn0_dbhz is converted to the per-sample amplitude
snr = sqrt(10^(cn0_dbhz/10)/fs) at construction.
6. Stateless kernel + carry contract¶
The smallest reusable unit is one coherent CAF tile:
compute one coherent CAF surface for one
(coarse-Doppler, rate)shard over one time-block ofnysegments, given carry-in, returning the updated coherent accumulator and count as carry-out.
It is a pure function: no internal ring, no hidden accumulator, no stored offset. Everything that survives a call is carry; everything immutable and shared is config; everything per-shard and recomputable is scratch.
| Class | Contents | Sharing |
|---|---|---|
| Config (immutable) | code/ref replica, ny/nx/sf/spc/K, coarse + rate grids, eta, M, N_nc, CFAR mode/cells, FFT plans (pocketfft plans are read-only). |
build once; broadcast to every shard/pod. |
| Scratch (shard-private) | slow-time FFT buffers, corr2d work buffers, magnitude + noise scratch, de-chirp LUT. |
allocate per shard; cheap to rebuild; never serialized. |
| Carry (explicit in/out) | coherent accumulator + count; non-coherent surface + look_count; leftover samples + stream offset n0. No pointers. |
passed every call; serialize for processes/pods. |
Today's hidden state maps exactly: corr2d.accum/count → carry; the ring leftover
- head/tail → carry; the
corr2d/fftwork buffers → scratch;ref/plans/eta/dwell→ config.
Proposed C shape (pure; all buffers caller-owned):
/* Layer A — pure coherent tile. Returns 1 on a coherent dump, else 0. */
int acq_caf_tile(const acq_config_t *cfg, acq_scratch_t *scr,
const float complex *block, /* ny*nx samples */
double f_coarse, double rate, /* this shard */
float complex *accum, size_t *count, /* CARRY in/out */
float complex *dump_out); /* iff dump */
/* Layer B — non-coherent reduce (associative). */
void acq_nc_accumulate(const float *dump_mag2, size_t n,
float *nc_surface, size_t *look_count);
void acq_nc_merge(float *dst, const float *src, size_t n); /* tree-reduce */
Peak + CFAR run once, after the reduce, reusing today's acq_compute_stat /
det_noise_estimate.
Carry representation — a flat POD, single source of truth. The carry is plain
arrays + scalars behind a dp_header_t-style envelope (reuse the streaming wire
convention). Threads get a zero-copy handle that is just a pointer-view over
that POD (the precedent the ddc_fn handle module set before it became the Ddcr
object: opaque state across free functions, GIL released via nogil); processes and pods serialize the same bytes. One representation,
two faces — no second format to maintain.
The engine becomes a thin wrapper. dp_acq_state_t (shared by both
BurstAcquisition and Acquisition) keeps the ring (leftover + offset) and
the carry buffers; dp_acq_push drains the ring into ny·nx blocks, calls
acq_caf_tile per shard, feeds dumps to acq_nc_accumulate, and gates after
N_nc looks. The hot math lives entirely in the pure kernel; the wrapper owns the
carry and the streaming glue. Today's behavior is the N_nc = 1, single-shard
special case — backward compatible and bit-exact.
7. Parallel fan-out¶
The shard axes are independent — embarrassingly parallel:
shards = coarse_Doppler bins × rate hypotheses × time blocks
│ │ │
└──── each: private scratch + a slice of carry ────┘
│
non-coherent |·|² accumulate (associative + commutative)
│
tree / hierarchical reduce
│
peak + CFAR (once, on the merged surface)
| Substrate | Distribute | Reduce | Precedent |
|---|---|---|---|
| Threads | thread per shard; per-shard partial surfaces | in-RAM acq_nc_merge; GIL released in the kernel |
doppler thread-per-shard + nogil; Ddcr (~5.4× on 8) |
| Processes | fork shards; emit serialized partial surfaces | parent merges deserialized PODs | flat-POD carry (§6) |
| Pods (k8s) | distribute (coarse, rate) shards across pods |
hierarchical merge over the wire envelope | streaming transport seam |
Orchestration across shards is the caller's job. Inside one engine, the
window tiles fan out over the engine's own persistent worker pool
(dp_acq_set_threads() / Acquisition.set_threads(), read back as threads).
Start Python-first — thread-per-shard, mirroring the coarse-bank loop already
in the usage guide — and promote to Rust later (the FFI layer has no acq/detection
today; the C ABI is the parallel substrate regardless).
Compute model — measured (L=1023, spc=2 → nx≈2046, ny=10,
N≈20k; 12-core x86, bench_acq.py). One coherent tile — ring write,
slow-time FFT, single-row corr2d, CFAR — costs ~0.62 ms, i.e. ~33 MSa/s
per core. That cost is almost entirely the corr2d 2-D FFT: a bare
corr2d.execute on the same grid is ~95–100% of the tile (bench_corr2d.py),
so the slow-time FFT, ring, and CFAR are a few-percent tail and the engine is
2-D-FFT bound — optimization effort belongs in the transform, not the glue.
Update — shipped: corr2d now exploits a structural fact this section's
own numbers motivated but didn't yet act on: acq's reference is single-row
(row 0 only), so ref_spec is row-frequency-invariant and the row axis of
dp_corr2d_execute's forward-accumulate-inverse round trip is an exact
identity for any row content (DFT orthogonality) — corr2d was paying for a
full row-axis FFT and IFFT every frame that provably cancelled to a no-op.
A fast path (native/src/corr2d/corr2d_core.c, see
corr2d-interpolated-inverse.md §9) now
skips it, replacing the (ny,nx) 2-D transform pair with ny independent
length-nx 1-D transforms. This is a different lever from P2 below —
P2 targets the forward transform's prime code-length (an FFT-size
problem); the fast path eliminates row-axis work regardless of size (an
FFT-count problem) — the two are independent and compose. Measured
end-to-end: the composed DsssReceiver's acquisition-search throughput
went from ~28-59 MSa/s to ~133 MSa/s, matching its tracking-regime
throughput instead of trailing it 3-5x.
push (shared by both BurstAcquisition and Acquisition) releases the GIL,
so thread-per-shard scaling is
~3.4× on 8 cores, ~4.9× on 12 (memory-bandwidth bound past a few cores, like
ddc_fn) → ~240 MSa/s aggregate on this box. Per-shard memory is tiny
(accum/nc_surface are ny·nx·4 B ≈ 80–160 kB), so thousands of shards fit in
RAM.
Plans and scratch are pre-allocated, but FFT size selection is a ~5×
lever — and for canonical DSSS codes the lever points straight at P2.
corr2d/fft2d/acq build plans and all work buffers (including the 2-D
transpose scratch) at create; nothing in execute/push allocates. The backend is
pffft (pre-allocated SIMD work buffers, no per-call malloc) — but only when
both axes are a multiple of 16 and 5-smooth (factors 2/3/5), N≥16;
otherwise it falls back to vendored pocketfft, which mallocs/frees a work
buffer per 1-D transform and is slow on large primes. Measured: ny=10, nx=2046 runs the fallback at ~21 MSa/s; a pffft-friendly ny=16, nx=2000
(=2⁴·5³) is malloc-free pffft at ~102 MSa/s.
For DSSS the constraint is only on the forward transform. Per frame the
pipeline is S = FFT(signal) → P = S·conj(FFT(code)) (the code FFT is
precomputed and stored) → R = IFFT(P). Two halves with very different freedom:
- Inverse — free. The IFFT length is decoupled from the forward length:
zero-pad the product
P(lengthnx) to anynx'andIFFT_{nx'}gives the band-limited (Dirichlet) interpolation of the circular correlation — peak and value preserved, on a finernx'-point code-phase grid. So picknx'pffft-friendly: the inverse is malloc-free and you get sub-chip code phase for free, with no correlation loss. Same for the slow-time inverse (ny'interpolates Doppler). - Forward — fixed.
S = FFT(signal)must be at the code periodnx = sf·spcfor the correlation to stay circular (zero-padding the signal in time makes it linear, with edge loss near the wrap). The onlynxlever isspc, and that lands smooth only when the code length is 2/3/5-smooth (L=1000,1024). Canonical DSSS lengths are2ⁿ−1(127, 511, 1023, 2047 …), all large-prime, so nospcmakes the forward friendly.
So the IFFT-padding (a clean baseline kernel feature — specced in
Corr2D Interpolated Inverse) removes ~half the
unfriendly work plus the inverse's per-call malloc and adds free resolution; the
remaining forward FFT_nx(signal) at the prime period is what only P2 sub-block
chopping fixes — it makes the forward lengths (sub-block size and ny·K)
free, smooth, pre-allocated, SIMD. Together: both transforms friendly +
interpolated.
The real-time consequence is load-bearing, not a footnote: the worked case needs
fs = 2 MSa/s per coarse channel, so one core sustains ~16 channels and a
12-core box ~120. The 400-channel ±100 kHz search therefore needs ~3–4
such boxes (the pod fan-out, not optional) — or sub-block K≈4 to cut the
bank to ~100 channels and fit one box. This is exactly why P2 (sub-block) and P4
(orchestration) carry real weight.
Note: the GIL release on
pushwas missing in the first cut (jm'smax_resultspush codegen omitsnogil) — found by this benchmark, which measured zero thread scaling until it was hand-added in the binding (todaynogil = trueinobjects/acq.tomlgenerates it).
8. Large Doppler + dynamics — worked case¶
Assume a LEO-like downlink: Rc = 1 Mcps, L = 1000 (T_epoch = 1 ms),
spc = 2 (fs = 2 Msps, nx = 2000), Doppler ±100 kHz, Doppler rate
±500 Hz/s (matches the standard LEO worst-case nadir-pass derivation
f_dot_max = (f_c/c)*(v^2/h) at a representative S-band carrier and
~800 km altitude, ~579 Hz/s -- corrected from an earlier "±5 kHz/s" typo,
which was 10x too high; see SPEC.md's own corrected figure).
Wide Doppler. Native span is ±500 Hz. Either a coarse mixer bank
(primary): 50%-overlap step 500 Hz → 400 channels for the 200 kHz range at
\<1 dB edge loss (or abutting 1 kHz → 200 channels at ~4 dB edge); or
sub-block K = 4 → span ±2 kHz per long FFT (length ny·K = 40), cutting
the bank ~4× to ~100 channels at a few-dB partial-correlation loss. Pick by
the loss budget.
Coherent depth. ny = 10 → T_coh = 10 ms, resolution 100 Hz.
Doppler-rate grid (the dynamics sizing). A residual rate rdot leaves an edge
phase π·rdot·(T_coh/2)². Holding it under 1/4 cycle:
rdot_res ≤ 2 / T_coh² (¼-cycle edge tolerance)
Δrdot ≈ 4 / T_coh² (worst residual = half a grid step)
R = rate_span / Δrdot
ny |
T_coh |
Δrdot |
R for ±500 Hz/s |
|---|---|---|---|
| 10 | 10 ms | 40 kHz/s | 1 (dynamics free) |
| 50 | 50 ms | 1.6 kHz/s | 1 (dynamics free) |
| 100 | 100 ms | 400 Hz/s | ~3 |
The key insight: at the corrected rate, dynamics stay absorbed all the way out
to 50 ms coherent (Δrdot still exceeds the full ±500 Hz/s span) — it takes
pushing T_coh to 100 ms before a rate grid is even needed at all, and even
then it's a small one (~3 tiles, not the ~25 the old mistyped ±5 kHz/s
figure implied). Δrdot's T_coh² scaling still means a rate grid becomes
real again for long enough coherent integration on weak signals, so the
auto-splitter should still cap T_coh at the rate ceiling and spend the rest
on N_nc unless the SNR genuinely demands it — the corrected numbers just
push that ceiling much further out. The worst row above is 400 coarse × 3 rate ≈ 1 200 independent tiles per 100 ms — real, but a much smaller
fan-out burden than previously estimated.
Code Doppler (chip-rate dilation) walks the code phase by (v/c)·Rc·T_coh
chips; for T_coh ≤ 10 ms at modest v/c this is sub-chip and tolerable. It
becomes a real axis (a code-NCO/resample stretch) only for long codes or very high
velocity — deferred (§11).
9. Honest tradeoffs¶
-
Statelessness vs the shipped API. P0 moves the hot path into a pure kernel. We hold the
Acquisition/BurstAcquisitionPython API and bit-exactness as a hard contract, so the refactor is invisible to users — at the cost of carrying the wrapper. -
Squaring loss. Non-coherent integration buys phase-robustness and weak-signal reach, but only
~5·log10(N_nc)versus10·log10coherent. It is a fallback for when coherence runs out, not a free sensitivity dial. -
Sub-block partial-correlation loss. Wider native Doppler for fewer channels, paid in code-correlation gain and sidelobe level — a few dB for
K ≤ 8. -
Mixer-bank channel count. Simple and lossless, but linear in span: ±100 kHz is hundreds of channels. The fan-out makes that cheap; the orchestration is the real work.
-
Doppler interpolation moves the H0 tail, and the threshold prices it (doppler#2218). The full treatment is §9.1 below; the short version is that the quantity that governs the false-alarm rate is a property of the surface, while
pfa_cellis sized from a sample count of it. Counting the interpolated surface's samples is a never-exceed bound on that quantity, a few percent conservative; the measured refinement is #1064.
9.1 What pfa means for a peak detector¶
Sum along one axis, maximum along the other. The two are easy to conflate and the distinction is what the rest of this section turns on:
per CELL sum |dump|^2 over n_noncoh coherent looks (non-coherent gain)
across CELLS take the maximum, normalise by a CFAR reference, gate on eta
So a cell's value is a sum — of n_noncoh independent looks at the same
Doppler/code hypothesis — and the detector's decision is a maximum over
those cells. The sum runs along time; the maximum runs along the surface.
Both matter, and they set different halves of the threshold.
What the sum sets: the per-cell distribution. Under H0 each look
contributes |z|^2 = sigma^2 · chi2(2)/2, so the accumulated cell is
(sigma^2/2)·chi2(2·n_noncoh). The per-cell exceedance probability
p(eta) therefore has a different shape for every n_noncoh, which is why
there are two threshold routines rather than one: det_threshold() for the
coherent case and det_threshold_noncoherent() for the order-N_nc
statistic, whose H0 tail is P(R > b) = Q_{N_nc}(0, b) — Marcum Q, validated
against Monte-Carlo to \<1%.
Non-coherent integration therefore changes p(eta). It does not change the
surface's spatial correlation: summing independent looks of the same
field leaves its correlation structure alone and only concentrates the
marginal. That is why the sum is not what this section is about.
What the maximum sets: how many chances the noise gets. Given p(eta),
if the surface were N independent cells then
which is what acq_commit_thresholds() computed until doppler#2218, with
N = searched_bins · code_bins. For the NATIVE surface of a critically
sampled DFT that is a good model: adjacent bins of a white-noise transform
are very nearly uncorrelated, so the native sample count and its
independent-cell count nearly coincide. A maximum over the native rows only
delivers 0.94–0.99× the target (below).
The gate reads the interpolated maximum; the report reads the native one.
Both acq_compute_stat() and acq_compute_stat_nc() deliberately compute two
maxima. peak ranges over the interpolated surface and sets test_stat —
that is where the scalloping loss is recovered and what decides whether the
gate fires. nat is restricted to native rows and sets the reported
doppler_bin, because every consumer scales that by doppler_res_hz and a
half-bin row would make the estimate jitter by a whole bin on noise.
That split was the defect in one line: the gate was decided by a maximum over the interpolated surface, while the threshold it compared against was sized from the native cell count.
Why that stops being true under interpolation. The sampled surface is not the object being maximised — it is a sampling of a continuous, band-limited function. Zero-padding the slow-time transform does not add information; it samples that same function more finely. Two consequences follow, and only the second is obvious:
- The maximum is monotone in sampling density. Adding samples can only
raise a maximum, never lower it, so
P(max > eta)rises with the padding factorLeven though nothing about the noise changed. - It saturates. As
L -> infinitythe sampled maximum converges to the continuous supremum, so the exceedance probability approaches a finite limit set by the surface's bandwidth, not by how many points were evaluated.
Native sampling therefore does not measure the false-alarm rate of the detector — it measures the rate of a detector that misses every peak falling between bins. That undercount is the same one #1002 fixed on the signal side: worst-case scalloping loss of ~3.9 dB, meaning a peak exactly half a bin off was attenuated to near-invisibility. Interpolation recovers those peaks. It recovers them for noise too.
The invariant, and the right N. Write the tail of the continuous
surface as
N_eff is the effective number of independent looks — a property of the
surface's correlation structure (its Doppler and code bandwidth), not of the
grid it was evaluated on. It is what N in the Bonferroni expression is
trying to approximate. Native sampling gives N_eff_native <= N_eff, and
padding by L walks the estimate toward the true value:
Measured with a scratch harness, not in the tree, around the burst engine
validate_acq_rate_pd builds (Zadoff-Chu 127 × 16 at fs = 1, pfa = 1e-3, no
rate hypotheses), 1e6 noise dwells a row (doppler#2218). With keep_surface
on, each row reads three statistics off the SAME dwells, from the engine's own
surface and CFAR reference, as multiples of the target:
| D | shipped then: interpolated max, native N |
native-only max | interpolated max, N · L (shipped now) |
|---|---|---|---|
| 2 | 1.90× | 0.98× | — |
| 3 | 1.86× | 0.94× | 0.947× |
| 5 | 1.92× | 0.97× | 0.946× |
| 7 | 1.93× | 0.99× | 0.945× |
A PN m-sequence at D = 5 reads 1.88×, 0.98× and 0.969×. Holding the CFAR reference at its long-run mean instead of estimating it per dwell changes none of them by more than the intervals (±0.06–0.09).
So N_eff(2)/N_eff(1) is 1.93–1.98 here: each interpolated row is nearly a
full extra look in the deep tail. A half-bin row's correlation with its native
neighbors is about 0.64, which a cell probability near 1e-6 all but ignores.
The earlier measurement (60,000 frames at pfa = 1e-2, doppler_bins = 8)
read 1.65× delivered and decomposed it as 0.89 × 1.86. Its 0.89 was not cell
correlation but doppler#1183: an even depth never searched its outermost bin,
so 7 of 8 counted bins were searched (0.875). With the band searched to its
edge, the delivered ratio was the interpolation factor alone.
The shipped bound, and the refinement left open. Since doppler#2218 the
split is over the cells the maximum ranges over: acq_band_cells(), every
searched row of the interpolated surface times code_bins, times the rate
hypotheses. It is the one count all three sites that price a gate use.
Counting each interpolated row as an independent look over-counts by exactly
what its correlation is worth, so this is a never-exceed bound: the
delivered rate is at or below the target (0.945–0.969× above). Its cost is the
gate's rise past what the tail requires, Δη/η ≈ ln(1/ratio)/η²: about
0.015 dB of sensitivity at the burst engine's 0.95×, and at most about 0.12 dB
where native cells are themselves correlated (an oversampled code). The
correction that would recover that margin is the measured N_eff(L)/N_eff(1)
per grid geometry. It is bounded above by L and close to it, and it is
#1064, still open.
Rate hypotheses are the same question on another axis. Each one is priced as an independent look at each cell, but neighboring hypotheses are strongly correlated, so the delivered rate falls to 0.60, 0.52 and 0.22 of the target at n = 3, 5 and 9 (#2287, which reported the opposite before the base was priced: these rows now sit under the target, not over it).
Where this is enforced.
test_acq_core.cpinspfa_cellto the band mask's in-band cell count on every grid shape.- The certification measures the delivered rate against the target (§2.5, finding F7, now FIXED) and holds it with a ratchet that may only shrink: 1.4× over 60,000 frames. That is a backstop to the pin above: an on-target detector trips it about 0.1% of the time, and the native pricing, which measures 1.76× on that engine (21 seeds), escapes it about 2% of the time.
validate_acq_rate_pd_pfa, in CI's sweep-validators job, holds the burst engine's per-dwell rows at the design point, each not significantly above the target: n = 1, 3 and auto over 100,000 dwells, and n = 5 and 9 over 20,000 (#2324). Only n = 1 detects a doubled rate (false red 0.76%, escape about 0). On 3 and auto a doubling only just crosses the target, so they are backstops that a doubling escapes 49% and 18% of the time. The exact guards are the pin above andmake lint-acq-pfa-split, which holds everydp_det_pfa_cellcall undernative/src/acq/toacq_band_cells.validate_acq_block_coherent --checkholds the continuous engine's per-block rate at the configured one over 400 blocks, which detects the native pricing rather than pinning one draw: put back, it fired 127 of 400, past the band's edge (104) by about 2.5 of its own sigma.- The characterization predicts its sweep counts from the delivered rate rather than the configured one.
10. Phased roadmap¶
Priority: sensitivity → span → dynamics.
Status. Landed (PR #241): the coherent Acquisition (auto-configured threshold
- dwell), its streaming test + benchmarks, and two
corr2denablers that sit under the roadmap — frequency-domain coherent accumulation (one inverse per dump) and the decoupled interpolated inverse (ny_out/nx_out, spec), which together give the cheap, pffft-friendly, finer-grid inverse that P1/P2 build on. The GIL-release fix (jm 0.19.34) unblocks the P4 thread-per-shard fan-out.
P1 (non-coherent) — landed. doppler.detection gained the order-N_nc
helpers det_threshold_noncoherent / det_pd_noncoherent / det_n_noncoh
(thin wrappers over the existing generalized marcum_q, validated against
Monte-Carlo to \<1%). At the time, Acquisition took max_noncoh (auto-split
cap): the auto-config grew the coherent depth to reps, then added
magnitude-summed looks (up to max_noncoh) to close the Pd gap; the N_nc>1
push path accumulates |·|² and gates the normalized order-N_nc statistic.
The pure-coherent (N_nc=1) path is unchanged. The associative pod-merge
(acq_nc_merge) is left to P4. (max_noncoh itself has since been
removed from both classes — see the Update below.)
Physics-only constructor — landed. The grid-and-SNR API
(sf/ny/min_snr/max_dwell/n_noncoh) was replaced by physics:
reps/spc/chip_rate/cn0_dbhz/doppler_uncertainty. sf is inferred from
len(code); the engine picks the smallest coherent depth doppler_bins ≤ reps
meeting pd; doppler_uncertainty narrows the scanned Doppler band (fewer
Bonferroni cells → lower gate, with a matching masked argmax); an infeasible
operating point builds best-effort, flags underpowered, and warns.
Update — shipped: the burst/continuous split. The single Acquisition
class described above (P1, physics-only constructor) split into two front
doors sharing one engine (see the guide):
BurstAcquisition keeps exactly that coherent-depth behavior (reps-bounded
doppler_bins, unmodulated preamble/burst use only, unchanged), while a new
Acquisition (continuous) class ALWAYS uses the wideband window-tiling
mechanism (§4) instead of coherent multi-epoch combining — closing the
continuous-async-data mislock (§1's warning, task #67) structurally rather
than leaving it to a caller-tuned knob. max_noncoh is gone from both. The continuous engine auto-selects n_noncoh
to meet pd; a burst engine never adds looks (a burst has one preamble, so a
second look adds noise and arrives a frame late, #1181/#1503), and when no
depth meets pd it takes the depth with the most pd_burst and reports
underpowered. The continuous engine's looks are bounded only by an
internal, non-public safety-valve ceiling (ACQ_N_NONCOH_SAFETY_CEILING =
256 looks — the semi-analytical pd_predicted model this engine sizes
against turns unreliable past that many looks, not a physical sensitivity
limit). doppler_resolution, which briefly existed on the combined class to
floor the coherent depth for a downstream tracking loop, is gone: it forced
the depth up, exactly the aliasing risk the continuous class closes by
construction. doppler_rate came back with the opposite sense: on all three
objects it CAPS the coherent depth at f_epoch / sqrt(2 * doppler_rate), so it
can only lower the depth, never raise it.
P0 (stateless kernel) — substantially shipped, via a coarser mechanism
than specified below. dp_acq_run/dp_acq_state_bytes/dp_acq_get_state/
dp_acq_set_state (native/src/acq/acq_core.c,
acq-fn.md) give the shared dp_acq_state_t engine the
pure-transducer / serializable-carry properties P0 asks for, but not via the
acq_caf_tile tile-level kernel extraction this section's table row
describes — that finer decomposition hasn't been built.
2026-09-01 (#1183). The full-band search dropped its outermost native
bin at an even coherent depth (acq_in_doppler_band's fold rule was the
narrowed-prior rule applied to the full band), so 1/D of a uniform Doppler
prior was undetectable at any C/N0 — a Pd ceiling of (D−1)/D, and the
between-channel hole of #1179. Fixed to search to the edge. The Pd model
(acq_mean_pd) now derates slow-time scalloping over the interpolated
bin the peak search samples rather than the native one: at D=8, 50 dB-Hz
it read 0.47 where the engine measured 0.72. It reads 0.61 against 0.71
measured now, with the gate priced over the interpolated surface (#2218), and stays
conservative because the Marcum form does not credit the maximum over an
interpolated surface under H1 (the H1 face of §9.1's maximum).
P4 (orchestration) — shipped. src/doppler/dsss/orchestrator.py
(Acquirer/CoarseChannel) implements thread-pool fan-out over
coarse-Doppler shards with bit-identical get_state/set_state pod
hand-off, matching this phase's acceptance criteria.
D=1 async-data wideband roll — shipped, outside the P0-P4 phase list.
Acquisition (continuous)'s C core ALWAYS engages the 2-D spectral roll (see
§4 above), unconditionally: coherent_bins is 1 unless code_only_epochs allows more, and the
uncertainty is tiled with window_bins parallel roll-FFT hypotheses from one
epoch (or from code_only_epochs declared code-only, block-coherent inside each
tile), sized by non-coherent accumulation (n_noncoh). BurstAcquisition retains the same mechanism only as a conditional
fallback, when doppler_uncertainty exceeds the native span (task #67
doesn't apply to a genuine data-free burst/preamble window, so there's no
reason to force it there). This is a narrower, alias-safe widener than P2 —
it targets specifically the regime where ny>1 coherent integration is
unavailable (continuous async data), not the general wide-Doppler case P2
targets — so it isn't a substitute for P2 and doesn't move P2's status below.
Not yet started: P2 (sub-block), P3 (Doppler-rate).
| Phase | Deliverable | Acceptance criteria |
|---|---|---|
| P0 — stateless kernel + carry | Extract acq_caf_tile pure kernel + flat-POD carry (accum+count+leftover+n0); re-express Acquisition as a thin wrapper owning the carry. |
Refactored Acquisition is bit-identical to today on the existing C + Python tests; a carry round-trip (split a stream, serialize the carry, resume in a fresh kernel) yields identical hits; no ring inside the kernel. |
| P1 — non-coherent + order-M detection | acq_nc_accumulate/acq_nc_merge; det_pd_noncoherent(snr, n_coh, n_noncoh) + inverse in doppler.detection; auto-split (M, N_nc). |
The helper matches a Monte-Carlo Q_{N_nc} reference to \<1%; a weak-signal case unreachable coherent-only is now detected at the target (Pfa, Pd); measured non-coherent gain is within the predicted ~5·log10(N_nc). |
| P2 — sub-block / wide Doppler | The K knob (segment = epoch/K, long ny·K FFT); document the roll as the integer-bin dual. |
Span = ±K/(2·T_epoch) on a swept-Doppler signal; resolution invariant in K; partial-correlation loss matches prediction; channel-count reduction vs the mixer bank demonstrated. |
| P3 — Doppler-rate search | A rate de-chirp axis in acq_caf_tile; grid auto-sizer Δrdot = 4/T_coh². |
A chirped burst that smears at rate = 0 is recovered with the grid; grid size matches the T_coh² table; (coarse × rate) shards are order-independent (any merge order → same surface). |
| P4 — orchestration | Python thread-per-shard (GIL released in the kernel) + process/pod POD-carry fan-out with hierarchical acq_nc_merge; reuse the streaming wire envelope. |
Thread scaling ≥ 4–5× on 8 (the ddc_fn precedent); the process/pod path reduces serialized partial surfaces to a result bit-identical to single-process; the compute model (§7) validated against measured throughput. |
| (P5 — later) | Code-Doppler dilation (code-NCO stretch); Tong / sequential verification dwell (declare → confirm) to cut false alarms. | — |
11. Open questions / risks¶
- POD-first carry as the single source of truth. Recommended (handle = view over the POD), but it locks the carry to pointer-free arrays. Fine for acquisition; confirm before P0 freezes the layout.
- Orchestration layer. Python-first (thread-per-shard, mirrors the guide's coarse-bank loop), Rust later — confirmed direction; no C-level scheduler now.
- Primary wide-Doppler method. Mixer bank primary (in hand, lossless, linear); sub-block as a profile-driven P2 option — confirmed.
det_pd_noncoherenthome. Thedetectionmodule (it composes onlymarcum_q), notacq— recommended.- Code-Doppler scope. Deferred to P5; revisit if a target needs long codes or very high velocity within one coherent window.
- 2-D roll re-litigation. Partially resolved: for the
ny>1-viable (burst/repeats) regime the verdict stands, OUT (dominated) — reopen only if P2/P4 profiling shows a batched roll-IFFT wins at very wide spans on some hardware. For the continuous async-dataD=1-forced regime (coherent integration unavailable to any method, task #67), the roll is now IN, adopted directly inAcquisition(continuous)'s C core — see §4 and §10 above.
12. See also¶
- DSSS Burst Acquisition guide — how to use today's
BurstAcquisition/Acquisitionclasses (parameters, streaming, the coarse-mix widening loop). - Python: Detection Statistics —
det_threshold/det_pd/det_dwell/marcum_q(the order-Mprimitive). - Gallery: 2-D Acquisition — the
CorrDetector2Dmatched-filter surface the coherent kernel builds on. - Streaming roadmap — the transport seam the pod fan-out reuses.
- Pure-functional acquisition kernel — the elastic
(Ddcr, acq_fn)pipeline, config/state/scratch split, and serializable state that makes the pod fan-out above possible.