Skip to content

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_predicted is 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 most f_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 of ny segments, 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/fft work 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 (length nx) to any nx' and IFFT_{nx'} gives the band-limited (Dirichlet) interpolation of the circular correlation — peak and value preserved, on a finer nx'-point code-phase grid. So pick nx' 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 period nx = sf·spc for the correlation to stay circular (zero-padding the signal in time makes it linear, with edge loss near the wrap). The only nx lever is spc, and that lands smooth only when the code length is 2/3/5-smooth (L=1000, 1024). Canonical DSSS lengths are 2ⁿ−1 (127, 511, 1023, 2047 …), all large-prime, so no spc makes 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 push was missing in the first cut (jm's max_results push codegen omits nogil) — found by this benchmark, which measured zero thread scaling until it was hand-added in the binding (today nogil = true in objects/acq.toml generates 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/BurstAcquisition Python 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) versus 10·log10 coherent. 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_cell is 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

pfa = 1 - (1 - p)^N        ->        p = 1 - (1 - pfa)^(1/N)

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:

  1. 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 factor L even though nothing about the noise changed.
  2. It saturates. As L -> infinity the 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

P( sup > eta )  ~=  N_eff · p(eta)          for small p

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:

realized_pfa / target  ~=  N_eff(L) / N_eff(1)

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.c pins pfa_cell to 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 and make lint-acq-pfa-split, which holds every dp_det_pfa_cell call under native/src/acq/ to acq_band_cells.
  • validate_acq_block_coherent --check holds 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 corr2d enablers 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

  1. 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.
  2. Orchestration layer. Python-first (thread-per-shard, mirrors the guide's coarse-bank loop), Rust later — confirmed direction; no C-level scheduler now.
  3. Primary wide-Doppler method. Mixer bank primary (in hand, lossless, linear); sub-block as a profile-driven P2 option — confirmed.
  4. det_pd_noncoherent home. The detection module (it composes only marcum_q), not acq — recommended.
  5. Code-Doppler scope. Deferred to P5; revisit if a target needs long codes or very high velocity within one coherent window.
  6. 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-data D=1-forced regime (coherent integration unavailable to any method, task #67), the roll is now IN, adopted directly in Acquisition (continuous)'s C core — see §4 and §10 above.

12. See also