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.dsss.BurstAcquisition (native/src/acq/acq_core.c) acquires a DSSS
burst — repeated BPSK PN epochs — under unknown code phase and Doppler. It
frames the stream into (doppler_bins, nx) (one PN epoch per row), FFTs down
the columns for Doppler, correlates each row against the code, and CFAR-gates
the surface. It is parameterised by physics — chip_rate, cn0_dbhz,
reps, pfa, pd — and sizes its own grid: the coherent depth doppler_bins
is the smallest in [1, reps] that meets pd (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 instead of ever coherently combining, so there is no
coherent-depth knob on it left for a caller to misuse. An interim
design tried symbol_rate/doppler_resolution/doppler_rate
parameters on one combined class (added after this document was
drafted) to price this in instead; those three are gone now (see §10).
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 — none expressible as explicit carry. |
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 (n_noncoh>1, always auto-selected — no caller-facing cap) looks extend it, but the coherent depth is the primary lever. |
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. | 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.
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, coherent_bins pinned to 1 and the uncertainty tiled with window_bins
parallel roll-FFT hypotheses) rather than composed externally, since — unlike
the ny>1 mixer/sub-block case — there is no coherent-depth knob left for a
caller to tune once ny is pinned to 1 (indeed Acquisition (continuous)
never exposes one at all — 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/detection/).
The packaged det_pd_noncoherent(snr, n_coh, n_noncoh) (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 _compute_stat /
_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 ddc_fn precedent: 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. acq_state_t (shared by both
BurstAcquisition and Acquisition) keeps the ring (leftover + offset) and
the carry buffers; 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; ddc_fn (~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 is the caller's job (doppler has no internal thread pool;
nthreads is accepted-but-ignored because the FFT plans are single-threaded).
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
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 indsss_ext_acq.c.
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 does not yet know. The full treatment is §9.1 below; the short version is that
pfa_cellis sized from a sample count where the quantity that governs the false-alarm rate is a property of the surface, and interpolation changes the former without changing the latter. #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 exactly what acq_commit_thresholds() computes, with
N = searched_bins · code_bins. For a critically sampled DFT this is a good
model: adjacent bins of a white-noise transform are very nearly uncorrelated,
so the sample count and the independent-cell count nearly coincide.
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 is the defect in one line: the gate is decided by a maximum over the interpolated surface, while the threshold that gate compares against is 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, 60,000 pure-noise frames at pfa = 1e-2 (the highest-count
point, so the tightest bars), doppler_bins = 8, n_noncoh = 1, identical
build and seed with only ACQ_DOPPLER_INTERP changed:
| quantity | measured | what it says |
|---|---|---|
N_eff(1) / N |
0.89 ± 0.04 | the native model is conservative |
N_eff(2) / N_eff(1) |
1.86 ± 0.09 | what interpolation adds |
N_eff(2) / N |
1.65 ± 0.05 | what a caller actually gets |
Both arms run n_noncoh = 1, so this isolates the maximum-over-cells half
with the per-cell sum reduced to a single look — the cleanest place to see the
effect, and the configuration the characterization pins for its
one-push-one-decision assumption.
Two errors of opposite sign, and they partly cancel. N_eff(1) < N
because adjacent DFT bins of a white-noise transform are not perfectly
independent, so the Šidák count slightly overstates the looks — a safe
direction, delivering fewer false alarms than asked for. Interpolation then
overshoots it. The net is 1.65×, not 1.86×, and the distinction is
load-bearing: 1.86 is the ratio between two builds, not the rate any caller
experiences, and a correction of 1.86 applied to a model already 11%
conservative would land at 0.89 of target.
pd_predicted and underpowered read the same pfa_cell, so they are
optimistic by the same factor.
Only N is wrong — the form is right. The ratio holds across three
decades of target: 1.65 / 1.40 / 1.50 at 1e-2 / 1e-3 / 1e-4 with interpolation
on, and 0.89 / 0.77 / 0.83 with it off. That constancy is the signature of a
multiplicative cell-count error. A miscalibrated per-cell threshold, or an
unaccounted CFAR estimation loss from a finite reference window, would
drift with the target instead. Neither does, which clears
det_threshold(), det_threshold_noncoherent() and the sqrt(2/pi)
mean-estimator conversion.
That N_eff(2)/N_eff(1) sits just under L = 2 is not a coincidence and is
also not a derivation: it says the interstitial samples contribute nearly an
independent look each at this bandwidth. Since that depends on the surface's
correlation, it is a number to measure per grid geometry, not a constant to
hard-code.
Why the obvious correction is wrong. Sizing from N · L treats every
interpolated sample as an independent test. They are not — they are
correlated by construction, since interpolation is a deterministic function of
the native samples. A Bonferroni over N · L therefore over-corrects, raising
eta further than the tail requires and giving away detection sensitivity to
buy a false-alarm rate that was already met. acq_core.c warns about exactly
this failure direction. The correction wanted is N_eff(L) / N_eff(1), which
is bounded above by L and, as measured, close to it here but not equal.
Where this is enforced. The certification measures the realized rate
against the configured target (§2.5), records the gap as finding F7, and
holds it with a ratchet that may only shrink — closing
#1064 is what earns
1.0. The characterization predicts its sweep counts from the delivered
rate rather than the configured one; predicting from the target is what made
it fail against a detector that was obeying its own threshold exactly.
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:
n_noncoh is now always auto-selected to meet pd, 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). Two parameters that briefly existed on the combined class,
doppler_resolution and doppler_rate (sizing a coherent-depth floor/ceiling
for a downstream tracking loop's benefit — see §1's footnote), are also gone:
both worked by forcing the coherent depth up, exactly the aliasing risk the
continuous class now closes by construction, so there is no longer a
parameter left for them to defeat.
P0 (stateless kernel) — substantially shipped, via a coarser mechanism
than specified below. acq_run/acq_state_bytes/acq_get_state/
acq_set_state (native/src/acq/acq_core.c,
acq-fn.md) give the shared 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 measures 0.72; it reads 0.63 now, and stays
conservative because the Marcum form does not credit the maximum over an
interpolated surface under H1 (the H1 face of #1064).
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 pinned to 1 and the
uncertainty is tiled with window_bins parallel roll-FFT hypotheses from one
epoch, sized by non-coherent accumulation (n_noncoh) rather than coherent
depth. 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
(ddc_fn, acq_fn)pipeline, config/state/scratch split, and serializable state that makes the pod fan-out above possible.