AsyncDsssReceiver — the continuous DSSS receiver, from spec to object¶
One page for continuous asynchronous DSSS, consolidated 2026-09-02 from
five: the receiver specification (async-dsss-spec.md, §1–§2), the
asynchronous despreader (async-symbol-despreader.md, §3) and its original
working design (async-despreader-working-design.md, §3.6), the continuous
use case that had grown inside burst-bank.md as its §11
(§5), and the searcher design written for it (acq-multi-peak.md, §6–§10,
§12–§13). §4 is the receiver as built; §11 is what it must gain for the
multi-emitter, always-searching use case. What the spec said about bursts
and about fleet service boundaries is not this receiver's concern and was
cut (git has it); burst-bank.md is bursts only.
1. The specification¶
The waveform and the receiver requirements, as given:
- Level: Any
- Nominal frequency: 2.5 GHz
- Frequency uncertainty: +/- 50 kHz
- Frequency rate of change: < 500 Hz/s (1)
- Waveform: Continuous DSSS BPSK
- Waveform exemplary use-case:
- Code: CCSDS Command link Gold Code 1023 chips repeating
- Chip rate: 3.069 Mcps
- Modulation: Asynchronous Rectangular BPSK @ 2700 bps
- Es/N0 >= 5 dB (2)
1.1 Target implementations¶
- Complete C receiver in
libdoppler.{a,so}, to compile into C/C++ applications. - Complete Python receiver, the same object through the binding.
What the application wants from it: continuous
reception — tracking loops that run for the life of a pass, not bounded
bursts — and parallelism on one server, not a cluster. The server has
many cores, and the design should use as many processes and threads as
the work needs: the searcher on its own, each assigned receiver on its
own, all fed from one stream on one machine (§5, §11). What is ruled out
is the fleet — pods, a scheduler, state hopping between nodes. The
receiver's get_state/set_state remain for a checkpoint and restart
mid-pass and for handing a receiver between processes on the same box,
not for scaling across machines. The fleet and per-burst service shapes
the original spec also described belong to the burst chain and are not
this page's concern.
1.2 Notes on the specification¶
(1) The rate bound is the standard LEO worst-case
nadir-pass figure, f_dot_max = (f_c/c)·(v²/h): at 2.5 GHz and a
representative 800 km altitude it is ~579 Hz/s, so 500 Hz/s is that bound
with a small margin.
(2) The Es/N0 floor is measured, not chosen: the
receiver's characterization
(#99) found a hard
pull-in cliff between 4 and 5 dB — 3 and 4 dB never lock (BER near
chance), 5 dB locks cleanly (BER matching theory) — independent of loop
bandwidth (bn_car 0.005–0.02) and of Doppler rate (0–500 Hz/s). That
cliff was measured on the coarse-hand-off pipeline, before the refining
stage of §4 existed; it has not been re-measured with it, and may sit
lower now. Treat 5 dB as the current floor, not a settled limit.
1.3 Derived: tracking loop bandwidths¶
Every tracking loop — the code DLL and the Costas carrier loop; there is
no FLL — is sized to
a loop SNR rho ≥ 20 dB at the Es/N0 floor, using the PLL relation
rho(dB) = Es/N0(dB) − 10·log10(2·bn), where bn is the loop's noise
bandwidth normalised to its own update rate (doppler.track.LoopFilter's
convention, so the update rate cancels). At the floor,
bn ≤ 10^((5 − 20)/10) / 2 ≈ 0.0158; the shipped rule is bn ≤ 0.01
for every loop, inside that bound. The code loop's per-epoch SNR is
Es/N0 scaled by 1/epochs_per_symbol, which at this waveform is
3000/2700 ≈ 1.11 — within 0.5 dB — so the same bound applies to it
without a separate derivation.
bn is not what sets the pull-in cliff of note (2): sweeping bn_car
across 0.005–0.02 left it unchanged. The loop-SNR derivation sizes
steady-state jitter once locked; pull-in below the floor is a separate
behaviour, and the refining stage of §4 is what addresses it.
1.4 A second operating point¶
The C++ application's continuous waveform is the same shape at different
numbers — a 1023-chip Gold code at 2 to 5 Mcps, a DDC from 13 MSa/s to
twice the chip rate, D = 1, ±50 kHz to start and likely ±5 kHz after
Doppler pre-compensation, up to ten emitters on one code at once — and a
throughput floor of 30 MSa/s, comfortably. Those numbers, and what they do to
the search and the receiver pool, are worked in §6.1 and §6.4; §11 is the
receiver's side of them.
2. Acquisition¶
2.1 User-facing API¶
Two classes, Acquisition (continuous) and BurstAcquisition, over one
C engine. Rather than one class with a mode and per-parameter "ignored
in this mode" caveats, each exposes only the parameters that mean
something for it. Both are thin front doors onto the same acq_state_t /
acq_core.c — state, auto-sizing, push() and serialization shared —
through two public constructors calling one internal builder with the
mode fixed, the secondary-constructor idiom dll_core.h also uses.
One public name for the Doppler axis: doppler_bins. Rolling the
shared epoch FFT by k bins produces a Doppler hypothesis exactly as a
slow-time FFT row does. Internally the engine keeps two fields for the
two mechanisms, only one of which is ever active: coherent_bins (the
slow-time FFT depth from coherent multi-epoch integration —
BurstAcquisition's axis) and window_bins (roll-tiled frequency
windows, each a single-epoch FFT rolled to another hypothesis —
Acquisition's axis). They are named for mechanism, not regime: the
roll-tiled axis is not computed non-coherently, and "non-coherent" here
means n_noncoh — repeated dwells accumulated for SNR at a fixed
hypothesis set, an axis that composes with either mechanism.
Two sizing inputs for the coherent depth: code_only_epochs and
doppler_rate. The continuous class runs a coherent depth D in blocks
inside the waveform's pure-code window (§2.3), and D is auto-sized as
the smaller of two bounds: ⌊(code_only_epochs + 1)/2⌋, so a whole block
always fits in the window, and f_epoch/√1000 from doppler_rate, so
the drift over one block stays inside half a slow-time bin.
code_only_epochs is the count of whole epochs a window holds at any
chip phase: the window is W symbols on the data clock (§5.4), which has
no fixed relation to the code clock, so a partial epoch is lost at each
edge and code_only_epochs = ⌊W · cps / L⌋ − 1 — 813 at 5 Mcps, 324 at
2, for the 450-symbol window. It defaults to 1, which is D = 1 and
exactly the engine as it ran before — a waveform with no window loses
nothing and sets nothing. Nothing else sizes it — no doppler_resolution, no
max_noncoh. n_noncoh
is auto-selected to meet pd at pfa and exposed read-only; its only
bound is an internal safety valve (ACQ_N_NONCOH_SAFETY_CEILING, 256
looks) because the semi-analytical pd_predicted model turns
non-monotonic past that — a modelling limit, not a sensitivity one.
Acquisition (continuous)¶
doppler_bins here is the window_bins mechanism (roll-tiled) for the
span, with the slow-time coherent_bins axis inside each tile for the
resolution and the gain — the two mechanisms together, which is what the
window of §2.3 makes sound. Coherent combining across data is a
structural mislock (task #67), and the block stride of §2.3 is what keeps
the combined epochs inside the window.
| Parameter | Type | Default | Description |
|---|---|---|---|
code |
NDArray[uint8] |
(required) | Binary (0/1) code, segment, or preamble chips to search for; sets sf = len(code). |
spc |
int |
4 |
Samples per chip (>= 1). |
chip_rate |
float |
1e6 |
Chip rate in Hz (> 0). |
symbol_rate |
float |
1000.0 |
Continuous data-symbol rate in Hz (> 0). |
cn0_dbhz |
float |
50.0 |
Carrier-to-noise density in dB-Hz (> 0) -- the sensitivity used to size the search. |
doppler_uncertainty |
float |
0.0 |
One-sided Doppler search half-range in Hz; 0 = full native span (one doppler_bin). Tiles into doppler_bins windows whenever it exceeds one native span. |
pfa |
float |
1e-3 |
Target system (max-of-N) false-alarm probability, in (0,1). |
pd |
float |
0.9 |
Target detection probability, in (0,1). |
noise_mode |
Literal["mean","median","min","max"] |
"mean" |
CFAR reference-cell aggregation mode. |
BurstAcquisition¶
The burst front door over the same engine — doppler_bins there is the
coherent_bins mechanism, auto-sized in [1, reps] for coherent gain over
an unmodulated preamble. It is not this receiver's concern; its parameters
and the burst chain are in
dsss-burst-receiver.md.
2.2 Output data structure: DetectionEvent (the acquisition handoff)¶
DetectionEvent is the DATA -- the acquisition handoff is the ACTION
(the process of converting a raw push() hit into this record and
handing it to the next block/service); the two aren't the same thing,
naming them separately on purpose.
The detection output has to be consumable by another thread or process
— the orchestrator of §5 and §11, a C++ application — not just another
Python object in the same interpreter, so it can't be the raw
grid-relative indices
(doppler_bin, code_phase) alone, since those are meaningless
without also shipping the emitting object's own config (spc,
doppler_res_hz, ...) alongside. Every field below is already
converted to a physical unit, so the record is self-contained: a flat,
pointer-free POD, safe to serialize across a thread or process boundary.
In C it is what acq_build_handoff() produces from a hit and what seeds
the receiver of §4.
One DetectionEvent record is emitted per detection event (i.e. once
per push() hit, on both classes -- same shape, since both share the
underlying engine):
| Field | Type | Description |
|---|---|---|
timestamp_ns |
uint64_t |
UNIX time (ns) this detection's samples occurred, per the codebase's existing dp_sample_clock_t convention (native/inc/timing/timing_core.h): epoch_real_ns + samples_consumed/fs, NOT a fresh syscall timestamp at emit time -- reproducible, and already how dp_header_t/SigMF-metadata timestamps are derived elsewhere in this project. |
samples_consumed |
uint64_t |
The raw sample offset (since this engine's own stream start) this detection's epoch ended at -- the n that timestamp_ns above was derived from. Kept alongside timestamp_ns, not instead of it: replay-safe (no wall-clock dependency) and lets a consumer re-derive/cross-check the time against its own clock anchor. |
chip_phase |
float |
Code phase in CHIPS (not raw samples) -- the code-tracking seed for the next stage. |
doppler_hz_est |
float |
Coarse Doppler estimate in Hz, already folded/signed/scaled from the raw doppler_bin index. |
doppler_res_hz |
float |
Width of that estimate -- the remaining uncertainty (±doppler_res_hz/2) a downstream refine/tracking stage still has to close. |
cn0_dbhz_est |
float |
Estimated carrier-to-noise density (dB-Hz) -- informs downstream loop-bandwidth and dwell sizing. |
peak_mag |
float |
Raw CFAR peak magnitude -- diagnostic/observability passthrough, not needed for tracking math. |
noise_est |
float |
Raw CFAR noise-floor estimate -- diagnostic passthrough. |
test_stat |
float |
Raw CFAR gating statistic -- diagnostic passthrough. |
Timing. acq_result_t carries samples_consumed; the timestamp is
dp_sample_clock_t's stamp_at(samples_consumed), and the stream layer
carries an origin timestamp hop to hop rather than re-reading a clock. The
engines themselves are clock-agnostic — pure sample-domain, no I/O — so
the anchor comes from whatever feeds them samples and is threaded through
by the composing layer (the receiver, or the orchestrator of §5).
No carrier_freq parameter on either class. The engine works in
baseband Doppler Hz throughout; the carrier-aiding scale
(doppler_hz_est · chip_rate / carrier_freq) is computed by the component
that knows the carrier — the tracker takes carrier_freq_hz itself. That
keeps the engine usable by a baseband-only caller with no carrier at all.
2.3 The wideband search, as settled¶
- The native span is one epoch's bin. A
D-point slow-time FFT sampled at the epoch rate has a fixed±epoch_rate/2range whateverDis — more bins subdivide the same range, they never widen it. At 3.069 Mcps and 1023 chips that ischip_rate/sf= 3.0 kHz per bin, a half-span of 1.5 kHz; the spec's ±50 kHz is 33 of them. D > 1, in blocks, inside the pure-code window (decided 2026-09-03). Coherent multi-epoch combining across data aliases the data's own spectrum across the Doppler axis and mislocks structurally (dsss-acquisition.md). The waveform carries a 450-symbol pure-code window every 4950 symbols on the data clock (§5.4) — 813 whole epochs at 5 Mcps, 324 at 2, at any chip phase — and inside it there is nothing to alias. The searcher does not know any emitter's window phase, so it sums non-overlapping blocks ofDepochs, one coherent surface per block, detected per block: theWwhole epochs a window holds hold a whole block whatever the block's phase onceW ≥ 2D − 1, and hold⌊W/D⌋of them in a row forn_noncohto accumulate —Wis the engine'scode_only_epochs(§2.1), andDnever exceeds what it holds. A block that straddles data spreads that emitter over itsDrows, about10·log10 Dbelow an aligned block's peak at the same code phase — a weaker copy of an emitter the assigned table already excludes, not a mislock.Dis bounded by the Doppler rate (§2.1): under 500 Hz/s the drift over a block stays inside half a bin whileD ≤ f_epoch/√1000— 61 at 2 Mcps, 154 at 5 — which is a bin of 32 Hz and a gain of 18–22 dB at either end of the range, 8 and 3 aligned blocks per window, and the same 3.2 k Doppler hypotheses at both rates. The floor inside an aligned block is the transition-free one (§12.2's −21 dB), not the −13 the data case measured.- The uncertainty is tiled by rolling one spectrum, not by a mixer
bank. One forward FFT of the epoch, then the spectrum rolled by
kbins per hypothesis against one precomputed replica spectrum: one forward plus one inverse per tile, against a forward and an inverse per tile for a bank of down-converters. Measured in the prototype at 1.2–1.55× faster; adopted as the engine's wideband mode (acq_core.c), so all tiles come from one object's per-epoch loop. The engine sizes the tile count itself, odd and symmetric (acq_cover_window_bins): 35 at 3.069 Mcps over ±50 kHz, 21 at 5 Mcps, 53 at 2. - What it costs is measured, per tile.
bench_acq_core.ctimes a realacq_push()per dwell on this waveform and on the operating point of §6.1; the number is about 10 ns per tile per output sample (§12.1), which is what makes the searcher's cost the same at 2 and 5 Mcps and over a core at ±50 kHz. - Why the roll still carries the tiles at
D > 1.dsss-acquisition.md§4 marks the roll OUT wherever coherent integration is viable, because a mixer bank with the slow-time transform does everything a roll without it does, plus the gain and a finer step. That compared the roll bare. Rolling bykbins is mixing byk/nx(the same page), so the roll with the slow-time transform inside each tile is the mixer bank with one forward transform shared across the tiles instead of one per tile — the 1.2–1.55× it measured atD = 1— and theDrows per tile are the fine step. A bank of DDC-fed engines is not a third option on this signal: a tile is a Doppler hypothesis on a spread signal 2–5 MHz wide against 50 kHz of uncertainty, so a per-tile DDC cannot decimate and only adds a mixer per tile (§6.4's 14–33× real time). The comparison is a count until §12 step 14 makes it a number. - A roll per thread (decided 2026-09-03). The tiles are independent
after the one forward transform: each reads the shared spectrum and
writes its own rows of the surface, so the tile loop is a
dp_parallel_forover tiles, one inverse transform and, atD > 1, one slow-time transform per tile on whichever thread takes it. The plan carries scratch, so each thread owns an inverse plan and a product buffer — a few KB — and nothing else is shared. This keeps the one forward transform the slice across engines repeated (§12.1's 6–11%), needs no LO in front of a slice, and keeps the peak list and the twin rule (§7.1) on one surface, where a slice boundary would have cut an exclusion zone in two. The workers are persistent — pthreads created once atcreate()and parked between pushes, a persistent form ofdp_parallel.h's bounded parallel-for beside the per-call one its two callers use — so the fan costs a hand-off per push, not a thread creation per worker, and the granularity of a push is the coherence's choice (§2.3), not the threading's. Thread count is the engine's parameter, default the core count. The per-cell passes that decide a surface — the magnitude, the CFAR reference, the working mask and every scan of the peak list — run per tile as well, each into a slot of its own, and merge serially in tile order (a mean of the tiles' means over equal cells, the first of their first maxima), so what stays serial is per tile, not per cell (#1243). - What it costs, before it is measured. The slow-time transform
runs once per block per tile, so per epoch it is of the order of the
epoch transform it sits behind; the searcher's cost stays near §12.1's
number until §12 step 14 says otherwise. The state is
Depochs per tile: 53 MB per channel at either end of the range.n_noncohacross a window edge accumulates data blocks — a graceful loss, bounded by the10·log10 D, not a mislock.
2.4 Observability — the searcher is watched, not trusted (decided 2026-09-05)¶
The searcher is the one stage nobody can check by its output alone: a
hit says where a peak was, and nothing about what else stood on the
surface, how close the gate came on the dwells that fired nothing, or
whether one emitter's splatter was about to be listed as two. So the
engine carries its own instruments, attach-on-demand like every other
object's (Dll.set_telemetry): detached, a decided dwell costs three
predicted-not-taken branches; nothing rides in a state blob.
- Ten probes per decided dwell,
set_telemetry(tlm, prefix, decim): the test statistic and the gate it was held to (threshold, oreta_ncon the non-coherent path — plotted together they show exactly where a hit fired, and how close the misses came), the CFAR reference, the strongest cell's value and its native row and column, the picks in the dwell and how many were held as same-code-phase twins (§7.1), the strongest pick's concentration, and whether the gate fired.decimthins by dwells. - The surface itself,
keep_surfacethensurface(out): the dwell's whole surface,surface_rows × code_bins, every cell divided by the reference the gate used — so a cell reads as its own test statistic and the gate is a flat plane on a plot.surface_doppler_hz()andsurface_chip_phase()are its axes, from the same fold and the same chip-phase mapping aDetectionEventcarries, so a plotted peak sits where the hand-off says. In C,acq_set_surface_sink(fn, ctx, decim)hands everydecim-th dwell's surface to a callback on the pushing thread — a run of hours records the surface decimated in time without a copy per dwell it does not keep. The surface is normalised only while a reader is armed. - The concentration is the splatter discriminator. One emitter does
not make one peak: a data transition inside the epoch splits it into
equal twins on other tiles (§12.2), and at
D > 1a block that straddles a transition — or the edge of the pure-code window, which falls at no particular chip phase (§5.4) — spreads it over its slow-time rows,10·log10 Ddown and smeared across the data's spectrum. All of that is at the emitter's own code phase; a second emitter is a second column. So the probe is the strongest pick's main-lobe power — its row and one either side, the exclusion zone's width, so an emitter halfway between two tiles is not charged for its own scalloping — over the total power of its column across every tile and row: near 1 for one clean emitter, about 0.5 for a transition's twins two or more tiles away, lower for a straddling block. Beside the two-epoch rule it is the number that separates one emitter's splatter from two emitters, and the surface tap shows the same thing in two dimensions. §12 step 11 measures it on aligned and straddling blocks.
3. The asynchronous despreader¶
Scope: the receive-side despreader when the data-symbol rate is on the
order of the code-epoch rate but asynchronous to it. This is theory, the
failure mechanism, and a validated robust architecture that composes existing
doppler.track primitives. The reproducible study is
src/doppler/examples/async_despreader_study.py
(python -m doppler.examples.async_despreader_study).
3.1 The two-clock problem¶
A DSSS receiver despreads by integrating early/prompt/late correlations over one
code epoch (TE = sf·sps samples) — an integrate-and-dump locked to the
code clock. The data symbols are a separate stream; the despread prompt per
epoch carries the data.
That works when the symbol clock is locked to the code clock at an integer ratio (GPS C/A: 20 code epochs per data bit, bit edges on epoch edges). It breaks when the symbol clock is independent:
T_sym = TE · (1 + delta) # symbol period, samples
# delta = symbol-vs-code rate offset
phi_sym # independent symbol phase
with T_sym ≈ TE (symbol ≈ one epoch). This is the hard regime: ~one symbol per
epoch, a transition roughly every epoch, and — crucially — delta ≠ 0 makes the
symbol boundary slide continuously through the epoch at the beat rate
delta / TE.
3.2 Why per-epoch despreading fails¶
The coherent prompt over an epoch whose data flips at fraction f ∈ [0,1]:
f → 0, 1(flip at an epoch edge):|P| = A(full despread).f → 0.5(flip mid-epoch):|P| = 0— total coherent cancellation.
Because delta ≠ 0, f sweeps through every value, so ~half of all epochs
straddle a transition and their prompts collapse. The consequences:
- Data: per-epoch decisions floor — the BER plateaus regardless of
Es/N0(the straddle epochs carry no usable energy). Measured floor ≈ 1e-1 even when the bound is < 1e-5. - Code: the early/late discriminator
(|E|−|L|)/(|E|+|L|)collapses to0/0on straddle epochs → the DLL is starved → the code loop wanders.
Root cause: at one prompt per epoch the symbol clock is unobservable (a single sample per symbol cannot drive a timing loop), and the integration window is forced to straddle transitions.
Diagnostic fingerprint¶
The straddle modulation is periodic at the symbol↔epoch beat. The spectrum of
the prompt-magnitude stream |P[n]| shows a tone at |delta| cycles/epoch
(centre panel of the figure). This is the signature to look for when a DSSS link
shows unexplained despread fades — it identifies this failure class directly.
3.3 Robust architecture¶
The fix gives the symbol clock its own observability and its own matched filter, and makes code tracking insensitive to data sign — composing primitives that already exist.
3.3.1 Data path — partial correlations + symbol matched filter + SymbolSync¶
- Partial correlations. Split each code epoch into
Ksub-epoch partial prompt correlations (eachTE/Ksamples, known code phase). This yieldsKdespread samples per epoch ≈Ksamples per symbol — the symbol clock is now observable. - Symbol matched filter. A length-
Kboxcar over the partial stream. This is a sliding, symbol-aligned coherent re-integration of the partials — the full-symbol despread the epoch-locked window could not form. It is essential: without it, the rectangular symbol pulse is sampled at one point and only ~1/Kof the symbol energy is captured (the BER floors at ~2e-2). - SymbolSync.
track.SymbolSync(Gardner TED + Farrow interpolator) recovers the independent symbol clock (delta,phi) from the matched-filtered stream and decimates at the symbol-aligned peak.
Result (left panel): the BER follows the BPSK matched-filter bound within ~1–2 dB. A genie reference (coherent symbol-aligned despread with known timing) hits the bound exactly — the loss was only window misalignment, never SNR. The broken per-epoch path floors.
| Es/N0 | bound | genie (known timing) | partial+MF+SymbolSync | broken epoch |
|---|---|---|---|---|
| 6 dB | 2.4e-3 | 2.5e-3 | 4.5e-3 | ~7e-2 |
| 8 dB | 1.9e-4 | 1.5e-4 | 5.8e-4 | ~6e-2 |
| 9.6 dB | 9.7e-6 | 0 | 0 | ~5e-2 |
3.3.2 Code path — non-coherent partial combining¶
The DLL keeps tracking through data flips by combining the partial correlations
non-coherently: |E| = Σ_k |E_k|, |L| = Σ_k |L_k|. A data flip changes a
partial's sign, not its magnitude, so only the one straddling segment
degrades (~1/K). This roughly halves the discriminator variance versus the
coherent-epoch form (right panel) — keeping the (already validated, smooth
sub-chip) code loop locked. It needs no symbol timing, so it works from cold
start; the bootstrap order stays sequential: DLL (non-coherent) → SymbolSync →
data.
3.3.3 Choosing K¶
K trades observability and straddle-robustness against the non-coherent
squaring/Rician bias (which erodes the discriminator gain as K grows). The
study shows K = 4 as the sweet spot for T_sym ≈ TE (best discriminator
SNR; K = 8 loses more gain than variance). K must divide TE.
3.4 Scope: the despreader removes the code and outputs samples¶
The despreader's one job is to remove the PN code and output samples. The
asynchronous symbol clock is merely why it despreads in K partial
correlations (§3.3) — it is not a reason to recover symbols here. Carrier
recovery and symbol extraction are downstream problems, handled by separate
objects fed from the despreader's output:
┌──────────────── the despreader ───────────────┐
acq seed → Dll(segments=K): E/P/L correlate · partial dump · non-coherent
(code phase) (|E|−|L|) code loop
└───────────────── partial stream out ──────────┘
│ K oversampled async BPSK samples/symbol
│ (PN removed; residual carrier + data still on them)
▼
downstream: Costas (carrier recovery) → SymbolSync (symbol timing) → bits
This is track.Dll(..., segments=K) — no new object. segments=1 is the
classic coherent full-epoch DLL; segments=K>1 is the streaming async
despreader. It composes downstream with Costas and SymbolSync, which already
exist (the data path of §3 is exactly that composition).
Why the carrier belongs downstream¶
The DLL's |E|−|L| discriminator is non-coherent, so code tracking is
carrier-blind — it locks with a residual carrier still on the samples. And
because each output is a partial (a TE/K-sample integrate-and-dump, not a
full epoch), a residual carrier barely dents it. For a ½-Doppler-bin residual
after acquisition the I&D loss is sinc(Δφ/2) with Δφ = π/segments:
| segments | window | Δφ at ½-bin residual | despread loss |
|---|---|---|---|
| 1 | TE |
π |
−3.9 dB |
| 4 | TE/4 |
π/4 |
−0.2 dB |
So short partials make the despread carrier-tolerant: the small residual just
rides out on the output (a ring in the constellation; see the gallery demo), and
a downstream Costas loop removes it at full symbol SNR. Putting a carrier loop
inside the despreader would only matter for long coherent integration — which
partials deliberately avoid.
The same scope rule applies to the DSSS-MPSK composition¶
Dll(segments=K) -> MpskReceiver (docs/gallery/dsss-receiver.md) is
the other downstream composition, and the same rule bites the same way: the
despreader's partial-correlation output rate is whatever K*chip_rate/SF
comes out to — a sub-multiple of the chip rate, not chosen with
MpskReceiver's sps in mind. An early version of that gallery page
violated its own §3.4 by picking K specifically so
round(K*T_sym/T_epoch) landed on an integer, coupling Dll's own
tracking parameter to MpskReceiver's sample-rate requirement. That made a
perfectly good Dll tuning look downstream-broken. The fix is
doppler.resample.RateConverter between the two — an explicit, arbitrary-
ratio resample stage, the same category of fix as Costas/SymbolSync
being separate objects from Dll here. Choose segments for the
despreader's own tracking quality; choose the demodulator's sps for its
own reasons; bridge the two with a resampler, never by coupling the
parameters directly.
3.5 Code-lock detection (always on)¶
A tracking channel must always answer one question: am I locked? The DLL carries an always-on lock detector that reuses acquisition's non-coherent test statistic, so acquire and track agree on what "detected" means.
Statistic. Each emitted look (a partial in segments mode, the full-epoch
prompt when segments=1) contributes its prompt power |P_k|². The detector
sums N = n_looks consecutive looks and forms
which under H0 (noise only) has P(R > η) = marcum_q(N, 0, η) — exactly the
acquisition tail. So a caller sizes the threshold η = det_threshold_noncoherent(pfa, N) and the depth N = det_n_noncoh(snr, …) to
meet a target (Pfa, Pd); configure_lock(pfa, n_looks) does the conversion
(default pfa=1e-3, N=20).
The noise reference E|O|². Instead of a separate noise channel, the loop
correlates each look a second time at a random off-peak code phase — a whole
chip offset re-drawn every epoch and kept clear of the prompt/early/late lobe by
noise_guard chips. For a low-sidelobe code (Gold, long PN) that offset
correlation is signal-free, so |O_k|² is a sample of the per-look noise power.
Cycling the offset and averaging recovers the same noise estimate a bank of
fixed off-peak taps would, with O(1) state.
Why an EMA, and why it must be long. The reference is an EMA of |O_k|²
(E|O|² += α(|O_k|² − E|O|²)), which is adaptive (tracks a drifting noise floor)
and O(1) — matching the Costas lock-metric pattern. The subtlety, found by
Monte-Carlo: the detection integrates a fixed N looks (that sets the χ²(2N)
threshold), but the noise estimate must average many more cells than N,
or its own variance inflates Pfa. One offset cell per look (L=N) drives Pfa
~400× high; 1/α = max(1024, 32·N) (L_eff ≫ N) holds Pfa at target with
Pd ≈ 0.98. So the integration depth and the noise-averaging length are
decoupled: N is the test, 1/α is the reference. The reference uses a
cumulative-mean bootstrap — it is the running average until 1/α looks have
accrued, then relaxes to the fixed-α EMA — so the noise floor is unbiased from
the first look instead of seed-dominated for the ~1/α-look warm-up (otherwise
Pfa runs ~10× high until the EMA settles, ~hundreds of epochs in). Verified
end-to-end: empirical Pfa ≈ 9e-4 against the 1e-3 target right from the
start of a noise stream.
Readouts. Dll.locked (bool, latched each N-look decision), Dll.lock_stat
(the last R), Dll.noise_est (E|O|²). The detector runs inside the normal
steps() — no separate method, no opt-in. The threshold conversion (the one
detection-module call) lives in the binding so dll_core links only -lm.
3.6 The look-back window — the original working design¶
The note the C Dll's dwell-integral look-back was built from
(native/inc/dll/dll_core.h cites it as its reference); kept verbatim, in
NumPy, as the algorithm's own statement.
Important: This assumes at most one data symbol transition per code epoch
flowchart LR
subgraph TED
end
LUT["LOCAL CODE \n INTERPOLATED LUT"]
TED --> LF
RX["RX CODE"] --> TED
LF["LOOP FILTER"] --> SCALE["SCALE BY \n EPOCHS / SAMPLE"]
SCALE --> SH["SAMPLE\nAND\nHOLD"]
SH --> NCO["U32 NCO\n MAX = SAMPLES / EPOCH"]
NCO --$$i + \mu$$--> LUT
LUT --E / P / L--> TED
-
TED generates one error per epoch using the signal power formed by correlating the rx signal with local code replicas E, P, and L over a window which maximizes power of the prompt correlation and forms the error:
-
This requires storing a buffer of the last received samples to "look back" in the case where a transition occurs in the current sample buffer so a transition free epoch may be obtained
-
This is scaled down and repeated driving the NCO at 2x chip rate
-
Local code is 2 samples per chip and linear interpolation is used to compute fractional samples
-
LUT outputs early, prompt, and late codes offset by 1/2 chip (1 sample)
# Init
code_size = 1023
samples_per_chip = 2
max_error = 0.5 # dB async correlation loss
phases = code_size * samples_per_chip
phase_resolution = 1 - 10 ** (-max_error / 10)
phase_step = int(np.ceil(phases * phase_resolution))
factors = [i for i in range(1, phases + 1) if phases % i == 0]
phase_step = factors[np.abs(np.array(factors) - phase_step).argmin()]
windows, window_size = int(phases / phase_step), phase_step
last_backard_sums = np.zeros(windows, np.complex128)
last_early_sums = np.zeros_like(last_backward_sums)
last_late_sums = np.zeros_like(last_backward_sums)
def find_max_power(x, windows, step_size, last_backward_sums):
"""Find max correlation over different output phase offsets."""
# First compute the partial sums of the current correlation
partial_sums = x.reshape(windows, step_size).sum(axis=1)
# Now sum up the portions of the windows this epoch contributes
sums = partial_sums.cumsum()
backward_sums = partial_sums[::-1].cumsum()
# Use the last epochs backward looking sums and the current
# epochs forward looking sums to comput the overlapping correlation
# at each phase across the two epochs and keep the maximum
correlations = np.zeros(sums.size)
correlations[-1] = np.abs(sums[-1] / (code_size * samples_per_chip))
correlations[:-1] = (
np.abs(sums[:-1] + last_backward_sums[::-1][1:])
/ (code_size * samples_per_chip)
)
max_window = correlations.argmax()
max_abs = correlations[max_window]
max_power = max_abs ** 2
# Use partial sums as integrate and dump downsampled output
integrate_and_dump = partial_sums / (step_size * max_abs)
# Compute window index. This is the offset from the end of the last
# correlation window that is the start of the max power correlation
# window.
window_index = (windows - 1 - max_window) * step_size
return (
max_power,
max_window,
backward_sums,
integrate_and_dump,
window_index
)
def get_window(x_window, x, last_x, index):
if index:
x_window[:index] = last_x[-index:]
x_window[index:] = x[:-index]
else
x_window = x[:]
return x_window
# In your loop
while signal_buffer,more_data:
# NCO + interpolated LUT
early, prompt, late = pn_gen.steps(
pn_control
)
b = signal_buffer.get()
x = b * prompt
power, window, last_backward_sums, integrate_and_dump,window_index = find_max_power(
x, windows, window_size, last_backward_sums
)
signal_plus_noise_power = power
b_win = get_window(b_window, b, last_b, window_index)
last_b = b[:]
early_win = get_window(early_window, early, last_early, window_index)
last_early = early[:]
late_win = get_window(late_window, late, last_late, window_index)
last_late = late[:]
early_power = np.mean(b_win * early_win) ** 2
late_power = np.mean(b_win * late_win) ** 2
code_phase_error = 0.5 * (early_power - late_power) / signal_plus_noise_power
loop_filter.step(code_phase_error)
pn_control = np.full(loop_filter.out / (code_size * samples_per_chip))
3.7 Symbol-timing-aided lock looks — the max-power search at symbol scale¶
Designed and built 2026-09-02, after §12.3 measured the code-lock flag reading "unlocked" 96% of the time at Es/N0 5.7 dB on a loop that never lost the code, and the telemetry showed why (§12.4).
The partial-and-non-coherent form of §3.3 is forced by the data: a full-epoch coherent look collapses on a transition, so the code-lock detector's look was the quarter-epoch partial, the smallest integration the asynchronous data allows when nothing is known about where its transitions fall. That is also the weakest look. At the operating point a partial carries −2.9 dB per look at Es/N0 5.7 dB, and the detector's default 20 looks, sized for nothing in particular, sat below threshold.
The look-back of §3.6 already knows how to find a transition-free window:
it picks, per epoch, the one-epoch window with the most power. What it
does not know is the symbol period, and the receiver does — it is
segments · chip_rate / (sf · symbol_rate) partials, 7.24 here. With the
period the same search lifts to the symbol scale:
ceil(P)boundary-phase hypotheses, each placing a boundary everyPpartials and owning a window ofL = min(floor(P) − 1, 4 · segments)partials after it — short enough to sit inside one symbol under the hypothesis's quantisation, capped so a slow data clock never asks for coherence across more carrier than the wipe-off holds;- each hypothesis sums its window coherently and keeps an EMA of the window's power over ~32 symbols; the hypothesis with the most power is the symbol timing, and its windows are the detector's looks.
A look then integrates L partials coherently and never straddles a
transition: six instead of one here, 7.8 dB more per look, and
det_n_noncoh sizes the detector at 10 looks for Pd 0.99 at the floor
instead of 161. The search needs no decision and no external timing, so it
costs nothing at cold start and follows a drifting symbol clock by itself.
An external phase from the demodulator can be accepted later as an
additive hook; it was not needed to reach the result.
The code discriminator runs on the same window. The loop steers once per
symbol on the early/prompt/late sums over the winning window, its filter
re-timed to the symbol interval so bn keeps its per-epoch meaning and
the tracked rate is continuous when the aid is switched on or off. What
that buys and costs is measured in §12.5: a loop about 20% faster to pull
in and tighter above 45 dB-Hz, and 1.2–1.4× the jitter at the floor,
where the noise sets it and the window's unused partials cost more than
its coherence buys — hundredths of a chip either way. The emitted partial
stream is untouched: the look-back still supplies its normalisation.
The receiver applies it at chain build: dll_set_symbol_period from its
configuration, n_looks from det_n_noncoh over the window at its
cn0_dbhz, and the drop count from det_verify_count(1 − pd, 1e-6) —
three consecutive misses, against the DLL's fixed two — so the verify
hysteresis is a budget, not a constant. Pinned by test_dll_core.c §6b
(per-partial looks up 35% of the time, aided 100%, the chosen phase within
one partial of the truth) and §6c (the loop steers on the window; the two
modes' step transients agree, which a filter left at its per-epoch gains
fails; the rate is continuous across the switch), both sabotage-proven,
and measured in §12.4 and §12.5.
4. The receiver as built¶
AsyncDsssReceiver (native/inc/async_dsss_receiver/async_dsss_receiver_core.h)
is the composed continuous receiver, one C object, the production port of the
validated Python search → refine → track prototypes. It has three states,
read back through get_refining()/get_tracking():
- searching — samples feed an embedded continuous
Acquisition(§2, window-tiled overdoppler_uncertainty,D = 1). A hit becomes a hand-off throughacq_build_handoff(), which seeds the refine stage; the unconsumed tail of the same call is handed straight to it. - refining — a frozen-carrier derotation at the coarse estimate feeds a
collection
Dllwhose look-back segments oversample each epoch, then aRateConvertertoCarrierAcquisition's own rate, thenCarrierAcquisitionitself. When it reports ready or gives up, the live tracking chain is built fresh from the original hand-off chip phase and the refined (or, on give-up, unrefined) Doppler. - tracking — the refined carrier is unfrozen into a live pre-despread
Costas loop (
costas_update()once per code period, driven by a non-data-aided squaring discriminator over the period's coherent partials) →Dll(§3,segments = K) →RateConverter→MpskReceiver. Two lock detectors run: theDll's own CFAR-based code lock (get_code_locked(), §3.5) and a hysteretic symbol lock on the emitted symbols (get_locked(), thecos(2φ)statistic over a 30-symbol dwell, declared after 30 consecutive symbols at or above 0.5 and dropped after 15 below 0.3).
DsssReceiver is the same object without the refining stage — a hit's coarse
Doppler goes straight to tracking — and §1.2's note (2) is why the refine
exists: the 4–5 dB pull-in cliff the coarse-only hand-off left. reset() on
either returns to searching: a receiver that has locked cannot be reset back
onto the same signal, only back to the hunt. Both are serializable
(state_bytes/get_state/set_state), every child included.
4.1 Status¶
- Shipped — the despreader.
Dll(..., segments=K)(the §3.3 code+symbol path;segments=1= the classic coherent DLL). Validated carrier-present: code lock holds with a residual carrier on the samples, and the partial output is losslessly recoverable by a downstream carrier wipe + symbol despread (test_dll.py::test_segments_carrier_present_*). The streaming binding returns an independent array per call (block-size invariant). - Shipped — the inline symbol-loop primitive.
symsync_step()(the per-sample SymbolSync composition API);symsync_steps()is it in a loop. - Shipped — the always-on code-lock detector (§3.5).
Dll.locked/lock_stat/noise_est, tuned byconfigure_lock(pfa, n_looks); reuses acquisition's non-coherent statistic with a random off-peak EMA noise reference. Validated signal-vs-noise intest_dll.py/test_dll_core.c. - Downstream, already available:
Costas(carrier recovery) andSymbolSync(Gardner + Farrow symbol timing). A receiver is the pipelineDll(segments) → Costas → SymbolSync; the §3.3 study and theasync_despread_demogallery example show the composition. - End-to-end validated with a real acquisition front end.
Dll(segments=K) → MpskReceiver(MpskReceiveralready fuses matched filter + NDA carrier acquisition + Gardner/Farrow timing + acq↔track handover into one object — its own docstring names this exact composition) is now proven at real physical parameters — a continuous 1023-chip code at 3 Mchips/s, async 2100 sym/s BPSK data, with a genuineAcquisitionsearch in front (see the DsssReceiver gallery page,src/doppler/examples/dsss_receiver_demo.py). Note thatK=4(§3.3.3) is tuned for the DLL's own code-discriminator variance, not for feeding a downstream matched filter — each partial isK-times weaker than a full coherent epoch, so a downstream receiver needs a much largerK(34, in the validated example) to reconstruct real coherent gain before its own carrier/timing loops can converge. The acquisition hand-off also needs two non-obvious unit conversions (Dll'sinit_chipis phase-inverted relative toAcquisition'scode_phase;MpskReceiver'sinit_norm_freqis cycles per its own partial-rate input, not per raw ADC sample) — see the example's docstring for the exact formulas. - Shipped — hand-off mode and the release (§11.1, §11.2).
HandoffAsyncDsssReceiver, a[[views]]entry over the same core with no embeddedAcquisition: starts idle,seed(chip_phase, doppler_hz_est, cn0_dbhz_est)starts the refine → track chain (a method of both flavors; refused on a receiver that already holds one),reset()returns to idle. The lost state: both flags down, continuously, for longer thanlost_confirm_s(a constructor parameter; 2 s on the hand-off flavor, 0 = never on the searching one) stops the loops and setslostuntilreset(); one flag down is a degrade and does not run the clock. The clock also runs from the first tracking sample, so a seed that never locks within the interval is released the same way. Pinned intest_async_dsss_receiver_core.c(hand-off decode, refusals, the rule firing not a sample early, one-flag-down never firing, the flavor-keyed blob) andtest_async_dsss_receiver.py. - Shipped — the status record (§11.3).
status()on both flavors, a by-valueReceiverStatusrecord (thesingle = trueshapeToneMetricsuses): state, the live Doppler (the carrier loop's estimate once tracking, the seed while refining, frozen in lost), chip phase, code rate, C/N0, both flags with the symbol-lock metric and threshold, both residual carrier errors, and the two clocks in input samples. No timestamp, by §8.1's rule. Pinned field-by-field against the getters in every state intest_async_dsss_receiver_core.c.
Possible refinements¶
- Symbol MF length. A downstream length-
Kboxcar matched filter follows the BPSK bound within ~1–2 dB; matching it to the tracked symbol period closes the gap. - Closed-loop code-jitter asset. Drive the non-coherent partial code loop
under async data + code Doppler; confirm lock retention and the low-SNR
threshold (
bn≈1e-5held to 4 dB Es/N0;bn≈0.002lost lock at 6 dB).
5. The continuous case — the C++ application's waveform¶
The use case as the maintainer described it, 2026-09-02, and every
"settled" or "answered" item on this page below traces to that description;
the numbers are derived from §1's waveform and the measurements in
burst-bank.md §10.4, and the questions at the end are open or answered
in the sections that follow.
The C++ application does not receive bursts. It receives continuous
DSSS with asynchronous data — the CCSDS command-link shape
async-dsss-receiver.md already specifies (a 1023-chip
Gold code, 3.069 Mcps, ±50 kHz) — and the stream carries a data-free
period of one code period just before each frame sequence. Several
emitters are in the air at once on the same Gold code, and what tells
them apart is Doppler: each emitter's frequency difference is its
Doppler. There is one frequency channel: every emitter is in the same
band on the same code, and what distinguishes them is code phase, power
and Doppler.
5.1 What the data-free window changes¶
Everything the burst family assumes about a preamble holds for that window and for nothing else in the stream:
- There is no coherent gain to buy. The data-free window is one code
period, so
reps = 1and the coherent depth is one epoch — exactly the continuousAcquisitionengine's search (D = 1, sensitivity from non-coherent looks,dsss-acquisition.md's warning). The window buys one clean epoch without a data transition inside it, which the continuous engine already prices as a straddle loss and survives. The bank's reason to exist in this use case is therefore not gain — §5.3 says what it is. - The hand-off is to a tracking receiver, not to a frame demodulator.
A burst ends; a continuous signal is tracked from the seed onward
(
carrier_acq → Dll + Costas, the monolithic C receiver). So the channel's product is theDetectionEventthe async spec defines — Doppler, code epoch, C/N0 — and the window copyBurstCapturemakes is not needed for the signal's sake. What may still be needed is the capture's refine: the frame begins where the data-free window ends, so which code period the window ended on is the frame epoch, and acquisition alone cannot say (§3.1 of the receiver design). Whether the tracking receiver's own frame sync makes that redundant is question 3 below. - The channel repeats. A burst is acquired once; a continuous signal is re-acquired at every data-free window, and between windows it drifts (< 500 Hz/s in the spec). The claim rule across windows is then "same signal, next frame", not "same preamble".
- Emitters come and go, at their own frequencies, and the bank is
always on the air. An emitter comes into view in the band at some Doppler,
is acquired at its next data-free window, is handed to a tracker, keeps
transmitting while others come into and leave view around it, and eventually
leaves. The bank never stops searching: a channel that has handed one
emitter off must go on watching its band for the next, and an emitter
that drops out must be noticed and re-acquired when it returns. That is
a lifecycle — searching → acquired → tracked → lost → searching —
the burst family has no state for; a
BurstCaptureis done when the window is out. It is also a duration requirement: the process runs for hours or days, so nothing in the bank may grow with time (samples_fedis 64-bit; the per-push scratch reaches its high-water mark and stays; the rings are fixed) and a checkpoint is for a restart mid-pass, taken while everything is live.
5.2 The numbers, from the spec and burst-bank.md §10.4¶
- Native span
3.069e6 / (2·1023)= 1.5 kHz; channel spacing 3.0 kHz; covering ±50 kHz takes2·ceil(50/3)+1= 35 channels — one bank, since there is one code. - At
spc = 2the source is 6.14 MSa/s; atburst-bank.md§10.4's 48 ns/sample a channel is 0.29× real time, so the bank is ~10× real time — eight cores at the measured 5.8× pool speedup do not keep up. Two things follow: the C++ application's own threads (burst-bank.md§10.1, the primary path) are not optional, and the per-channel cost is the number to attack first — 48 ns/sample was measured forDDC → BurstCapture, and a channel that hands off aDetectionEventrather than a window needs neither the capture's ring nor its refine. - The continuous engine's own
window_binstiling covers ±50 kHz in one engine at the sameD = 1— the same tiling this bank does with DDCs, at the same sensitivity. What the single engine cannot do is §5.3's first item, and that, not gain, is what theK-fold cost buys.
5.3 The async tools, and what the bank adds to them¶
The continuous chain exists and is the thing to compose, not to rebuild.
AsyncDsssReceiver is one object with a three-state machine — searching
(the continuous Acquisition, window-tiled over the uncertainty),
refining (acq_build_handoff → a frozen-carrier Dll →
CarrierAcquisition), tracking (Costas → Dll → RateConverter →
MpskReceiver) — and it is the validated C port of the search → refine →
track prototypes. DsssReceiver is the same without the refining stage.
Both cover the whole ±50 kHz in one engine at D = 1.
So the C++ application's channel is not DDC → BurstCapture. Against what
already exists, the bank adds exactly three things, and each is a design
decision rather than a given:
- Resolution on the (Doppler × code phase) surface. Every emitter is a peak on the same 2-D surface a channel already computes, at its own Doppler bin and code phase, with its own power. A Doppler bank partitions one axis of that surface: emitters more than a span apart land in different channels and are found independently, with independent CFAR references. But emitters within a span — the normal case, since there is one band and only Doppler separates them — share a surface, and a detector that takes the maximum of it reports one of them per dwell, the strongest, and masks the rest. So the channel's detector must report every peak above threshold in a dwell, each with an exclusion zone around it (a bin in Doppler, a chip in code phase) so one emitter is not reported as several — a multi-peak report the engine does not make today. Then power: a 1023-chip Gold code's cross-correlation floor is about −24 dB — on the searcher's actual surface, with data and a Doppler straddle, −13 to −16 dB (§12.2) — so an emitter that much weaker than the strongest in the same surface sits under the strongest one's sidelobes and is found only by cancelling the strong one first (successive interference cancellation) — and two emitters at the same Doppler and code phase within a chip are one peak, distinguishable by nothing. Question 7 is therefore answered: emitters do share a span, and the bank's channel count buys parallel surfaces and independent references but not resolution; the resolution is the detector's, per surface, and it is the piece to design.
- Many emitters, one band. One
AsyncDsssReceivertracks one signal; its state machine has no "lost" state and no second emitter. The bank is what holds the pool: which emitters are up, which channel each is in, which tracker it went to, and when it stopped being heard. That is §5.3's question 5, and the tools do not answer it today. - The frame epoch. The refining stage recovers carrier, not which code period the frame started on; if the application needs that from the bank, it is the capture's refine, transplanted.
Everything else — the DDC, the tiling rule, the tracker, the hand-off record — is already there.
Two rules from the maintainer (2026-09-02) fix the channel's shape:
- It always has to be searching. A channel never stops acquiring: the
emitter it just handed off keeps transmitting in its band while a
second one comes into view beside it, and the first one's loss has to be noticed
by something that is still looking. That rules out
AsyncDsssReceiveras the channel — its state machine replaces the search with refining and then tracking, feeding every sample to the tracker. In the bank, search and track are concurrent per channel: the search engine runs on every block, and each hand-off spawns a consumer that is fed the same samples beside it. Two things follow. A channel that keeps searching re-detects the emitter it handed off at every data-free window, so something must recognise "that one is already handed off" — a suppression keyed by emitter (its Doppler and code phase), the analogue of the capture'ssuppress_untilkeyed by time — and that is the bank's, which settles the minimum of question 5. And the per-channel cost in §5.2 is the search alone; each tracked emitter adds a tracker's cost on top, on the application's threads. - The hand-off logic is selectable. What a detection becomes is a
policy, not a property of the channel: hand a
DetectionEventto a tracker (this use case), capture a window for a frame demodulator (the burst use case), or report and do nothing (surveillance). The channel owns the search and the event; the policy owns what happens next and is chosen per bank, possibly per channel. This answers question 1 — the channel isDDC → search, andBurstCapture's ring and refine are one policy's apparatus, attached only when that policy is selected.
5.4 Questions this raises (open)¶
- ~~Hand-off target.~~ Answered: selectable — a policy on the
detection (track / capture a window / report), not a property of the
channel. The channel is
DDC → search, always searching. - ~~One Gold code per signal.~~ Answered: one Gold code, shared; emitters differ by Doppler. One bank; the multi-signal case is within it, across channels.
- The frame epoch. Partly answered: the block that detects an
emitter lies inside its window, which locates the window to within
Depochs; the exact boundary is the tracking chain's to find, and the receiver is not told it (§8.2). As built (2026-09-05), the roll per thread:dp_pool_*indp_parallel.his the persistent form of the parallel-for — the same contract over helpers created once and parked between runs — and a tiled continuous engine creates one with itself, the online cores by default (set_threads, 0 = cores, 1 = serial). The per-epoch tile loop and the block-end column loop run through it; the scratch is per tile rather than per thread (a pocketfft plan carries its own work buffers, and a tile lands on whichever worker takes it), so the serial and fanned paths run the same code and the surface is byte-identical at any count — pinned at 1, 2, 4 and 8 intest_acq_core.c, under TSan in the C suite. Burst and single-tile engines never fan. §12.8 has the cost. As built (2026-09-05): the engine now allows a coherent depth to accommodate waveforms with code-only windows:Acquisition(code_only_epochs, doppler_rate)sizesDexactly so; the per-tile epoch rows are gathered forDepochs and a zero-padded slow-time FFT per code-phase column turns each tile intoDrows, scattered onto one uniform Doppler grid ofwindow_bins · Dnative bins ofchip_rate/(sf·D)over the tiled span, in FFT-bin order — sodoppler_bin, the exclusion zone, the native-row report, the hand-off's fold and the surface axis are all the onedp_fftfreq_indexover that count. The CFAR counts every row of every tile; the C/N0 estimate counts the block. The block accumulator rides in the state blob (v3), so a mid-block split resumes bit-for-bit.code_only_epochs = 1isD = 1and the engine byte-for-byte as before. Pinned intest_acq_core.c: the two bounds, an emitter one row above tile +1 reported at bin1·D + 1and handed off at its frequency, the cell count, the mid-block split, and a block straddling a data transition reading weaker and spread at the same code phase — the §2.4 concentration at work. - ~~The data-free window's length.~~ Answered (2026-09-03, in symbols 2026-09-05): 450 symbols of code only, then 4500 of data — a frame of 4950 symbols on the data clock. There is no fixed relation between the chip and data clocks, and a frame edge falls at no particular chip phase — never on a code epoch (maintainer, 2026-09-05); 500/5500 epochs was this frame at the spec's 3.069 Mcps. So one (re)acquisition opportunity every 1.83 s at any chip rate, and up to 0.92 kHz of drift between them at 500 Hz/s; in whole epochs the window is 813 at 5 Mcps and 324 at 2, long enough for any coherent depth the Doppler rate allows (§2.3), and it is why the searcher has one.
- ~~Who owns the lifecycle.~~ Answered (2026-09-03): a C object in doppler, the pool of §8.2, owns the receivers and the assigned table; the receiver's half — how "gone" is decided and what it releases — is §10.
- ~~How many emitters at once, and how long an emitter is typically in view.~~ Answered: at least one always on, up to 10 at once, each on for 5 to 15 minutes and never more than 15 (maintainer, 2026-09-05) — a bound, and an adjustable one. The pool and the soak follow in §6.1, §5 and §6.
- ~~Can two emitters sit within one span of each other?~~ Answered: yes — one frequency channel, one code; emitters are separated by code phase, power and Doppler on one surface. A channel therefore needs a multi-peak report per dwell with exclusion zones, and the engine has none. Open in its place: the power spread between emitters that are up at once — inside the floor a multi-peak report suffices; beyond it the weak ones need the strong ones cancelled first, which is a different object. The floor is measured: −13 dB in the operating case, not the Gold bound's −24 (§12.2), and −21 inside the pure-code window the searcher now detects in. The spread is 10 dB (maintainer, 2026-09-03): inside the floor, so the list branch ships and no cancellation object is built (§9).
6. The searcher — every emitter on one surface¶
The searcher's design, written 2026-09-02 as its own page and folded in here the same day. Nothing in §6–§10 and §12 is implemented, and nothing has been measured; §12 is the work that would measure it. Follow adding an algorithm.
6.1 What is settled, and what the page is for¶
The C++ application's waveform fixes the frame this page works in, and none of it is re-derived here (§5):
- One Gold code, one frequency channel. Every emitter is on the same 1023-chip code in the same band; what tells them apart is Doppler, code phase and power — three coordinates on one (Doppler × code phase) surface, the surface a channel already computes.
- A 450-symbol pure-code window every 4950 symbols. The search is the continuous engine's, run in coherent blocks inside that window (§2.3): 18–22 dB of coherent gain, a 32 Hz Doppler bin, and the transition-free floor, at a cost the engine already pays per tile.
- The channel always searches. It never hands its samples over to a tracker and stops; search and track are concurrent.
- The hand-off is a policy — track, capture a window, or report — chosen per bank, and not a property of the channel.
- The population: at least one emitter is
always on, there may be up to 10 at once, and each is on for 5
to 15 minutes — 15 minutes is the maximum on-air time of a single
emitter (maintainer, 2026-09-05), and it is adjustable: the
pool's
max_emitter_on_time_secs(§8.2), the soak's draw (§12 step 7) and the false-release budget (§10) all take it as a parameter, whose default is the one constantMAX_EMITTER_ON_TIME_SECS = 15*60; nothing else bakes it in. So the surface never has fewer than one peak, has up to ten, and an emitter comes into or leaves view about once a minute at the full population. An emitter transmits continuously, and coming into view is not powering up (maintainer, 2026-09-03): it appears at whatever point of its frame it has reached, mid-payload as often as not, and its first window arrives at its own phase, uniformly within one frame — so the acquisition latency after an emitter appears is bounded by a frame (1.83 s) and averages half of one. Every window of every emitter is a re-acquisition opportunity, and an emitter appearing between two of them is the normal event the searcher exists for. This answersburst-bank.md§11.4's question 6: the receiver pool is sized at ten plus release headroom (§10), and the soak's population is known (§12 step 7). - The rate: all of it — the front end, the searcher, every receiver, and the cancellation if it is built — must run comfortably at 30 MSa/s or more, and running at exactly 30 MSa/s counts as slow. That is the machinery's floor; the waveform's own operating point is below it (13 MSa/s in, next table), and the page prices every option at both (§6.4), not as a benchmark to run at the end.
The numbers the page is worked at — these supersede §5.2's, which were the async spec's waveform:
| quantity | value | from |
|---|---|---|
| chip rate | 2 to 5 Mcps — design to the worst case, which is per quantity: 5 Mcps for anything priced per sample, 2 Mcps for anything priced per tile | given |
| code | 1023 chips → one epoch is 204.6 µs at 5 Mcps, 511.5 µs at 2 | given |
| pure-code window / frame | 450 / 4950 symbols on the data clock — 167 ms / 1.83 s at any chip rate; 813 / 8960 whole epochs at 5 Mcps, 324 / 3584 at 2; a frame edge at no chip phase | given (2026-09-03; in symbols, no epoch alignment, 2026-09-05) |
| coherent depth | D ≤ f_epoch/√1000 in non-overlapping blocks: 154 at 5 Mcps, 61 at 2 — a 32 Hz bin, 3 and 8 aligned blocks per window |
the Doppler rate (< 500 Hz/s) over one block, §2.3 |
| DDC input | 13 MSa/s | given — chosen to force the arbitrary-ratio path (§6.4) |
| DDC output | 2× chip rate: 10 MSa/s at 5 Mcps, 4 at 2 (spc = 2) |
given; the ratios 1.3 and 3.25 both lack an integer factor |
| samples per epoch | 2046, at every rate | 1023 · spc |
| chip pulse | rectangular — no pulse shaping on the chips | given (2026-09-03); every §12 harness renders rect chips and correlates against a rect replica |
| Doppler tile | 1/T_epoch = 4.89 kHz at 5 Mcps, 1.96 kHz at 2; a tile spans ± half that, subdivided into D rows of 32 Hz |
the window_bins tile index × the slow-time row |
| uncertainty | ±50 kHz to start; Doppler pre-compensation will likely bring it to ±5 kHz | given — design at the full width, and record what the narrow one saves |
| tiles over ±50 kHz | 21 at 5 Mcps, 53 at 2 | the engine's own rule, acq_cover_window_bins: 2·ceil((U − span)/(2·span)) + 1, measured in §12.1; the searcher's worst case is the low rate |
| tiles over ±5 kHz | 3 at 5 Mcps, 7 at 2 | same rule, after pre-compensation |
| cores | at least 48 on the one server | given (2026-09-03) — the population's ~7.6 cores at the operating point and ~17 at the floor (§12.1) are a third of the box, not a fit |
| budget, one core, operating | 77 ns per input sample; per output sample 100 ns at 5 Mcps, 250 at 2 | 1/13e6, 1/10e6, 1/4e6 |
| budget, one core, at the floor | 33 ns per input sample; 43 per output at 5 Mcps | 1/30e6, same ratio |
The maintainer's description of the running system (2026-09-02) adds the lifecycle the policy serves, and it is the shape everything below is fitted to:
The acquisition part continuously looks for signals, and async receivers track them as they are found, until they are gone. A receiver does not stop tracking once it has been assigned.
So there are two kinds of thing on the air side of the bank. A searcher
per channel (DDC → search), which runs on every block for the whole life
of the process. And a pool of async receivers, one per emitter, each
spawned by the track policy from one detection, fed the same samples as the
searcher, and living from that hand-off until its own loss decision — the
searcher never stops one, never re-seeds one, and never assigns a second
receiver to an emitter that already has one. The searcher's product is
therefore not "the strongest signal present"; it is every emitter present
that is not yet assigned, per dwell.
The receiver is the object that exists. AsyncDsssReceiver
(native/inc/async_dsss_receiver/async_dsss_receiver_core.h) is the
validated search → refine → track chain in one C object: its searching
stage feeds an embedded Acquisition, a hit is turned into a hand-off by
acq_build_handoff(), and that hand-off seeds the refine stage. What the
lifecycle needs from it is two things and no new receiver
: an acquisition input — the searcher's
detection arrives from outside as the hand-off — and an internal
acquisition bypass for that mode, so the object starts in refining from
the given seed and its own Acquisition never runs. That is a difference
in constructor, not in method, so it is the ddc/MatchedDDC shape: a
second create over the same state, a view in the manifest, the chain
past the seed shared verbatim. The receiver already carries a symbol lock
detector (lockdet, hysteretic, on the emitted symbols), which is where
"until they are gone" is decided — what it lacks is the transition that
decision drives (§10).
6.2 What one maximum per dwell loses¶
The classic detector reports one cell: the maximum of the surface, gated
— det_result2d_t on the burst detector, and on the acquisition engine
the two maxima dsss-acquisition.md §9.1
describes, the interpolated one to gate and the native one to report.
That is still what both do at max_peaks = 1, the default, and it is the
gap this section is about; §7.1 is the list that closes it, and §8 (a) is
where it lives — one det_peak_list beside det_noise_estimate in
det_private.h, under both detectors, with Acquisition.set_max_peaks
as the engine's face of it (§12.6 measures it).
With K emitters up, the surface has K peaks, and a maximum reports the
strongest. The rest are not below threshold; they are simply not looked
at. In the burst use case that costs little — bursts are short and rarely
overlap in one channel. In the continuous case the strongest emitter is up
for hours, and every dwell for those hours reports it and nothing else, so
a second emitter appearing beside it is never acquired while the first is
on the air. Nor does hand-off help: the assigned receiver goes on tracking
the first emitter, the searcher goes on re-detecting it at every data-free
window (the suppression-by-emitter §5.3 asks the bank
for), and after the suppression drops that re-detection the dwell has
reported nothing at all. The single maximum is the gap, and it is the
searcher's, not the bank's — the bank's channel count partitions Doppler
into spans, but emitters within one span share a surface, and that is the
normal case here.
6.3 What the power spread decides¶
Two emitters at different Dopplers or code phases are two peaks on the surface, and a detector that reports every peak above threshold finds both — provided the second is a peak above threshold. A strong emitter does not only put one peak on the surface: a 1023-chip Gold code's cross-correlation with itself at every other lag is not zero, and the maintainer's figure for that floor is about −24 dB below the peak (§5.3), and §12.2 measured it on the engine's own surface: exactly that where the bound applies, and −13 dB once the emitter carries data and sits off its tile's centre — the operating case. That floor lies across the whole surface — every Doppler bin, every code phase — so an emitter weaker than the strongest by more than the floor plus the detection margin is under the strongest one's sidelobes: it is not a peak, and no peak detector reports it.
Two things follow, and they are why the mechanism forks on the spread:
- The CFAR reference is right to rise.
det_noise_estimatemeasures the surface's floor, and with a strong emitter present that floor is the strong emitter's sidelobes. The threshold moves up with it, which is what CFAR means — the weak emitter is genuinely below the floor of the surface as it stands. - Only removing the strong emitter lowers that floor. A peak list cannot; that needs cancellation, and cancellation needs a replica of the strong emitter — which is a different object with a different information source (§7.2).
So the decision is the emitters' power spread, §5.4's question 7, and it is open. Inside the floor a peak list suffices; beyond it the weak emitters need the strong ones cancelled first. This page covers both branches (§9), so that whichever way the number falls the page already says what to build.
The −24 dB is the three-valued bound for a full-period, zero-Doppler cross-correlation, and §12.2 shows why it is not the design number: a data transition inside the epoch or a half-tile Doppler offset — the searcher's normal case — raises the worst cell at another code phase to −16 dB, and both together to −13. The fork below is at −13 dB.
6.4 The throughput floor¶
At the operating point one core has 100 ns per DDC-output sample for everything after the front end, and 77 ns per input sample for the front end itself; at the 30 MSa/s floor those are 43 and 33 ns. "Comfortably" means a margin under that, and this page takes half as the working target — the whole population inside 50 ns per output sample per core at the operating point, 21 at the floor, across the cores the application gives it — with the margin a number the benchmark reports, not one it assumes. Equality with the budget is a failure by the requirement's own words.
The decimation is only 1.3× at the top of the rate range, and that is
the fact that shapes the cost: nothing runs at a fraction of the input
rate. At 5 Mcps the searcher and every receiver run at 10 MSa/s,
three-quarters of what the front end sees, so the population's cost is
(searcher + 12 receivers + 10 replicas) per output sample, not that
divided by anything. The rate range splits the worst case in two. Every
receiver and every replica is priced per output sample, so their worst
case is 5 Mcps. The searcher is priced per tile per output sample,
and tiles go up as the rate comes down — 21 at 5 Mcps, 53 at 2 — so its
tile-samples per second are nearly the same at both ends (210 M against
212 M over ±50 kHz) and its worst case is the low rate, by a small
margin, at the full uncertainty. Doppler pre-compensation to ±5 kHz
takes the searcher to 3 or 7 tiles, an eightfold cut in its cost and none
in anyone else's; the page designs at ±50 kHz and step 8 records both.
The per-stage numbers are measured (§12.1): the searcher over ±50 kHz is 2.1× real time on one core at either chip rate, one tracking receiver is 0.44 of a core, and the arbitrary-ratio front end is 0.18 — so the chain is over the budget on one core before the population is on it, and the population is about 7.6 cores at the operating point. Three things follow for the shapes, and the first two are now requirements rather than expectations:
- One front-end DDC, shared, on its slowest path — on purpose. There
is one frequency channel, so the only stage at the input rate is one
conversion, 13 to 10 MSa/s. That ratio was chosen for the budget, not
the radio: the
DDC'sRateConverterbuilds the cheapest cascade the ratio allows — CIC, halfband, then a polyphase resampler — and 1.3 has no integer factor, so no CIC or halfband stage exists and the whole conversion runs through the polyphase arbitrary resampler, the most expensive sample the front end can produce. The budget is therefore priced with the slow path baked in; a deployment whose rate happens to give an integer factor can only be cheaper, and a bench that ran at a convenient ratio would have measured the wrong front end. The receivers take chip-rate input already (AsyncDsssReceiveringests atchip_rate · spc), so they share this one front end rather than each owning one. - The searcher is one window-tiled engine, not a DDC bank. A bank of
21 to 53
DDC → searchchannels at anything like 48 ns each is 14 to 33× real time at the operating point on one core and fits on no node; the continuous engine's ownwindow_binstiling covers the uncertainty in one engine at the sameD = 1sensitivity (burst-bank.md§11.2), and with the peak list inside it (§8 (a)) it lacks nothing the bank had for this use case. That is a change to what §11.2 assumed, and the throughput floor is what forces it. - The receivers are the population's cost, and they parallelize; the cancellation does not. Twelve receivers at 10 MSa/s on the application's threads scale across cores; the replicas on the strong branch are subtracted on the searcher's path, serially, ten of them per block — so (iii)'s coupling has a per-sample price on one thread, and it is the searcher's.
What is not known is every per-stage number at this rate: the front-end DDC per input sample; the searcher per output sample with the list at both ends of the rate range; one receiver per output sample; one replica per output sample. §12 step 8 measures them, and the bench that does it must count what it acquired and tracked beside the rate — a throughput that was reached by missing an emitter is not a throughput.
7. The two mechanisms¶
7.1 The peak list with exclusion zones¶
The list is the maximum, iterated:
repeat up to max_peaks times
take the maximum of the surface
if it is below eta · noise_est: stop
if it is within ±1 chip of a listed peak's code phase, at any tile:
hold it as that emitter's twin; list it only if it is still there,
at the same tile, on the next epoch
report it (at its native row where the surface is interpolated)
exclude ±1 Doppler bin × ±1 chip around it
The second rule was added after §12.2 measured that one emitter makes more than one peak: a data transition inside the epoch splits it into equal twins two or more tiles apart, and a half-tile Doppler offset throws a −9.5 dB sidelobe two tiles away — every one at the emitter's own code phase. A twin moves with the transition's position from epoch to epoch and is absent in the emitter's data-free window; a real second emitter at the same code phase stays at its tile. So the rule holds a same-phase peak for one epoch rather than dropping it, and costs no resolution at other code phases, where the adjacent tiles remain candidates.
The Doppler axis is the window_bins tile index, 1/T_epoch apart —
4.89 kHz at 5 Mcps, 1.96 at 2 — with D slow-time rows inside each tile
(§2.3), so the interpolated-vs-native split of dsss-acquisition.md §9.1
applies as on the burst engine: the gate reads the interpolated slow-time
axis and the report is the native row. (§12.6 measured the list at
D = 1, where the two collapse.)
Why one bin and one chip. They are the widths of one emitter's main
lobe: an epoch's frequency response is the sinc of a one-epoch
rectangle, whose first nulls fall one tile (1/T_epoch) either side, and
the code's autocorrelation triangle reaches zero one chip either side of
its apex. Inside that zone the surface belongs to the emitter just reported —
its own shoulders would otherwise be the next "peak" — and outside it a
second emitter has its own maximum. The zone is therefore also the
detector's resolution: two emitters within one bin and one chip of
each other are one peak, distinguishable by nothing on this surface
(§5.3), and that is a property of the code and the dwell,
not of the detector. In surface units the zone is ±interp rows (one
row at D = 1) and ±spc columns — two, here — circular in code phase;
on the native report it is ±1
and ±spc.
The threshold does not change. eta is sized from N = searched_bins · code_bins cells (dsss-acquisition.md §9.1); it counts the noise's
chances over the surface, and a second reported peak is another draw
from the same cells against the same gate, so the per-dwell false-alarm
event — any reported peak is false — is bounded by the same union.
Exclusion zones remove a few cells from the count, in the safe direction
and negligibly. What does change is the floor under a strong emitter
(§6.3): the reference rises, so does eta·noise_est, and false peaks in
the strong emitter's sidelobes are what §12 step 4 measures.
Fixed size. max_peaks is configuration; a dwell's list is up to
that many acq_result_t records from push(), strongest first, sharing
the dwell's samples_consumed and noise_est; nothing allocates per
dwell and nothing grows with time — the duration rule of §5.1. The
classic single-peak result is the same list at max_peaks = 1, the
default. A held twin takes one of the slots that dwell without being
reported. The population sizes it: on the branch where the searcher sees
every emitter (§9) the list must hold all ten plus the false peaks the
gate admits, so max_peaks is of order 16; on the branch where assigned
emitters are cancelled it holds only what rose since the last window, a
few.
As built. det_peak_list (native/inc/detector/det_private.h) is
the iterated maximum with the zone, circular on both axes, over a
caller-initialised mask; the engine seeds the mask with the cells outside
its searched band, sets the gate in the surface's own units (eta · noise_est on the coherent surface, eta_nc² · noise_pow / 2N on the
non-coherent one), maps each pick to its native row within its own zone,
and applies the two-epoch rule with the held candidates carried in the
state blob (v2). Acquisition.set_max_peaks(n) /
BurstAcquisition.set_max_peaks(n) set the capacity, 1 to 64. Pinned by
test_acq_core.c (the primitive on a synthetic surface; the API and the
blob) and validate_acq_peak_list --check (two emitters, the split twin
held then listed, twins under PRBS data, the rate under noise), measured
in §12.6.
7.2 Cancellation¶
Cancellation subtracts a replica of a strong emitter so the surface underneath it can be searched. The replica needs the emitter's code phase, Doppler, amplitude and carrier phase — and, for any epoch that is not that emitter's own data-free window, its data. That last item decides the shape, because emitters' frames are not aligned: while emitter A is in its data-free window, emitter B is carrying data, and B's contribution to A's dwell is a data-modulated, straddle-lossed correlation whose sign flips at a place the searcher does not know.
Where the replica's information comes from is therefore the design axis:
- From the peak (acquisition-side). The detection gives code phase and Doppler to within a cell; amplitude and phase must be estimated from the complex peak; the data is unknown. Exact only in the strong emitter's own data-free epoch — which is not, in general, the epoch being searched.
- From the assigned receiver (decision-directed). The receiver already tracking the strong emitter knows its chips, its carrier, its amplitude, and its decided bits, block by block, and refines all of them continuously. Its replica is exact to the tracker's own error, data included.
And where the subtraction happens is the second axis: on the surface
(subtract the emitter's known response, the code's autocorrelation across
lag times a sinc across Doppler, scaled by the complex peak — the radio
astronomer's CLEAN) or on the samples (regenerate the chip stream,
subtract, correlate again).
8. The shapes — where each piece lives¶
The air side of the bank, end to end, as built — every box is a shipped object and every number the operating point of §6.1:
flowchart LR
E["up to ten emitters on ONE Gold-1023 code<br/>5 Mcps, 2700 sym/s, 450 code-only symbols of every 4950<br/>each at its own Doppler within ±50 kHz (§6.1)"]
E --> DDC["DDC: 13 MSa/s → 2 samples per chip (§6.4)"]
DDC --> X["one block per push,<br/>stamped by the feeder's clock (§8.1)"]
subgraph POOL["AsyncDsssPool (§8.2) — one push(), in this order"]
direction LR
X --> S["the searcher: Acquisition, continuous<br/>D = 154 epochs coherent per window tile (§2.3)<br/>21 tiles, a roll per thread; a list of 16 peaks (§7.1)"]
S -->|every peak| Z{"within a chip of a<br/>live row's code phase?"}
Z -->|yes| OWN["that emitter's own:<br/>nothing"]
Z -->|no, a slot free| SEED["acq_build_handoff() → seed()<br/>«seeded»"]
Z -->|no slot free| DROP["«dropped»"]
SEED --> RX["12 hand-off receivers, idle until seeded<br/>refine → track (§4, §11); every one fed every block,<br/>across the threads"]
X --> RX
RX -->|"status(): Doppler, chip phase, flags"| T["the assigned table:<br/>one row per slot, keyed on locked loops"]
T --> Z
RX -->|"both flags down for 2 s (§10),<br/>or held past the on-time"| REL["release: row cleared, reset() to idle<br/>«lost», «released»"]
REL --> RX
end
RX --> OUT["per slot: status() by value,<br/>symbols() borrowed"]
POOL --> LOG["event log (§8.1): every transition<br/>a sample-stamped SigMF annotation"]
S -.->|"acq.* (§2.4)"| TLM["telemetry"]
No replica leaves a receiver and nothing is subtracted before the searcher: the operating spread is inside the knee (§9, §12.6), so branch one is what shipped and §11.4 is not built. The lifecycle of one slot is §8.2's state diagram; the measurement that certifies the whole is §12.14.
The peak list has one place it belongs and two it could be put:
| mechanism | fits | cost | |
|---|---|---|---|
| (a) one primitive under both detectors | a peak-list function beside det_noise_estimate in det_private.h: (mag, ny, nx, gate, excl_rows, excl_cols, mask, out[], max_peaks) → count; both callers use it, the burst detector at max_peaks = 1 |
one argmax instead of the two private copies; CorrDetector2D can gain the list when it needs it; the interpolated/native split stays where it is, in the caller |
acq_result_t is unchanged — a dwell is up to max_peaks records sharing samples_consumed — and det_result2d_t is untouched; the cost is the mask and the held table, fixed-size |
(b) inside acq_compute_stat only |
the engine's loop iterates with exclusion; detector2d stays single-peak |
the engine alone changes | a third private copy of the pick, and the two detectors' behaviours diverge on the same surface |
| (c) a second pass over the surface, outside | the bank asks the engine for its surface and picks peaks itself | no engine change | the surface is the engine's scratch, not a product — exporting it is a copy of ny·nx·interp floats per dwell, and the gate's eta leaves the engine |
(a) is the repository's rule applied — fix it where the primitive is defined, once — and the only one under which the burst detector and the acquisition engine keep agreeing. It is what shipped (§7.1, as built).
Cancellation is a separate object, and its shape follows its information source:
| mechanism | fits | cost | |
|---|---|---|---|
| (i) surface CLEAN, from the peak | subtract A·acf(τ − τ_i)·sinc(f − f_i) from the complex surface for each strong peak, then re-pick |
no second correlation; stays inside the engine | needs the complex surface where the engine keeps \|·\|; the response is exact only in the strong emitter's own data-free epoch, and a data transition inside the dwell leaves a residual the model does not have |
| (ii) sample SIC, from the peak | regenerate the strong emitter from its detection, subtract from the epoch, correlate again | one object, no dependency on the tracker pool | one extra correlation per cancelled emitter per dwell; the same unknown-data residual as (i); amplitude and phase from a single cell's estimate |
| (iii) sample cancellation fed by the assigned receivers | DDC → cancel(assigned) → search: each assigned receiver publishes its replica for the block (or the estimates that make one: code phase, Doppler, amplitude, phase, decided chips); the searcher subtracts every replica before it correlates |
the only replica that is right through data; makes the searcher see exactly what is not assigned, which retires the suppress-by-emitter table (§5.3) — an assigned emitter is not re-detected because it is not there | couples the searcher to the receiver pool on the push path; a receiver that has lost lock publishes a wrong replica, so the subtraction must be lock-gated; one replica per assigned emitter per block; and the receivers still see the raw stream |
(iii) is the shape the lifecycle already asks for. The receivers own the
emitters and keep tracking them regardless of what the searcher does; the
searcher wants to see only what they do not own; and only they know the
data — AsyncDsssReceiver's track stage holds exactly the replica's
ingredients per block: the live carrier loop's phase and frequency, the
Dll's code phase, the despreader's amplitude, and the decided symbols.
It is also the option that makes the two branches of §9 one mechanism at
two settings. Its cost is a real coupling — whoever holds the receiver
pool must also stand on the searcher's push path — which is why
§5.4's question 5 (who owns the lifecycle) becomes
load-bearing the moment the strong branch is chosen, and not before.
A refinement (iii) opens but this page does not take: a receiver can be fed the stream with every other assigned emitter cancelled, which lowers its own floor as well. That is the receivers' concern, on their own path, and it changes nothing about the searcher.
8.1 The holder — time, events, telemetry (decided 2026-09-02)¶
The searcher, the receivers and their records are sample-domain and clock-agnostic (§2.2): every record carries a stream position, never a time. The holder of the pool — the orchestrator, whichever language it is written in — is the one component that owns a clock, and it is fed by one of two sources. The decisions, and the reasons:
- The feeder owns the clock; nothing below it sees a timestamp. One
dp_sample_clock_tper stream, anchored from the source's own metadata and stamping every record asstamp_at(n). A live BLUE file (the reader'sread_follow()) anchors from the header'stimecodeandxdeltawhen they are present, and from the wall clock at open — flagged as such through the reader's provenance enums — when they are not. A NATS stream anchors from the first frame that carries atimestamp_nsand then counts samples; asequencegap is an event, not a re-anchor, because the sample count is what the DSP consumed and a per-frame re-anchor would move every record under it. One replay and one live run then produce identical records, and stamping is one function at the edge. - Events are SigMF annotations, appended live, finalized at close. A
transition — seeded, tracking, degrade, lost, released, a sequence gap —
is an annotation: sample-indexed (
core:sample_start,core:sample_count), which is exactly the rule above, with the receiver's fields under adoppler:namespace. A.sigmf-metais one JSON document, which a streaming writer cannot keep rewriting, so the run appends annotation objects to a flat, tail-able, crash-safe file, and a finalize step writes the proper sidecar —global,captures,annotations— the same way the writer already produces its sidecar at close. For a BLUE input the sidecar names that file as the dataset; for NATS it names whatever the recorder wrote, or is metadata-only.core:freq_lower_edge/upper_edgeneed the channel'sfc, which a BLUE header carries and a NATS frame does not: omitted when unknown, never guessed. - Telemetry stays the flat record file, and the sidecar points at it.
dp_tlmrecords are a time series at thousands per second, stamped by the same clock; that is the wrong shape for annotations and the right one fornp.fromfile. Adoppler:telemetryglobal field carries the path and the record dtype, so one sidecar indexes the dataset, the events and the telemetry, each in the format that suits its rate. - C first, one emitter. The event log is a C object — append an annotation, finalize to SigMF — over the writer's existing JSON emitter, not a second one; the holder calls it from Python today and from the application's C++ tomorrow.
As built. dp_event_log
(native/inc/dp_event_log/dp_event_log_core.h, telemetry.EventLog) is
that object. append() renders one annotation as a line of JSON and
flushes it, so the file is tail-able live and a kill costs at most the
event being written; finalize() collects the lines into
wfm_sigmf_meta_json_ex() — the writer's emitter, extended to take extra
global members and extra annotations, with wfm_sigmf_meta_json() now
the call with both absent, so there is still exactly one place that
spells global and captures. The doppler: fields are staged before
an append from a fixed table, which is what keeps the object ignorant of
any particular receiver's record and allocation-free per event. A run's
flat file can also be rendered afterwards, by another process, with
dp_event_log_write_meta() — which is what the crash-safe half is for.
What this asks of the receiver is what §11.3 built: a record with the
state and the clocks in samples and no time in it. The gap the first
decision opened on the reader's side is closed: dp_isotime_parse() — the
inverse of the formatter that header already owns — reads SigMF's
core:datetime, so a SigMF capture anchors on its own timeline as a BLUE
one does and reports t0_source "sigmf" where it used to report
"none". A stamp carrying no timezone is refused rather than read as UTC:
being wrong by hours looks authoritative in a way that reporting nothing
does not.
8.2 The pool — one object holds the population (decided 2026-09-03)¶
Decided by the maintainer 2026-09-03; the surfaces it composes are the
shipped ones of §4.1, §7.1 and §8.1. Built 2026-09-06 as async_dsss_pool
(AsyncDsssPool), §12.13.
The other half of §5.4's question 5 is answered the way this library
answers it: the holder is a C object, async_dsss_pool, and the Python
face is glue. It is the one composition on the air side of the bank, and
nothing about this waveform or this population is baked into it:
every number below — the maximum on-air time of an emitter among them,
max_emitter_on_time_secs, default MAX_EMITTER_ON_TIME_SECS = 15*60
(§6.1) — is a create parameter whose default is the operating
point of §6.1, the searcher's and the receivers' own parameters pass
through it untouched, and the pool knows only what it was given —
another code, another frame, another population is another create().
Everything it holds is sized once, at create:
- One searcher —
Acquisitionin continuous mode with the block coherence of §2.3 andmax_peaksof order 16 (§7.1) — its tiles fanned a roll per thread across the threads the pool is given, the forward transform and the list on the calling thread. n_slotshand-off receivers — twelve here, §10's ten plus release headroom — created idle. An idle or lost receiver consumes and discards what it is fed, so every receiver is fed every block and the feed has no per-state branch; the receivers run underdp_parallel.hacross the thread count the application gives (§6.4: they are the population's cost, and they parallelize).- The assigned table: one row per slot — the seed's coordinates, and
the receiver's current Doppler and chip phase, refreshed from
status()before every dwell is read (§9: an emitter drifts up to 0.92 kHz between windows, so the seed is the wrong key). - The clock and the event log, borrowed at create (the
dp_tlm_captureshape): the pool is the one component that stamps, and it stages the slot, the Doppler, the chip phase and the C/N0 on every transition it logs.
One push() per block does, in order: feed the searcher; refresh the
table; drop every peak within one chip of a live row's code phase, at
any Doppler, as that emitter's own (the code axis alone, not §7.1's one
row by one chip: a tracked emitter's data blocks put smeared copies of it
at its own phase rows away, §12.14); for each survivor, acq_build_handoff() and seed() into a free slot,
or count it dropped when there is none; feed every receiver; then, for
each slot whose receiver reports lost, clear the row, reset() the
receiver to idle, and log released. seed()'s own refusal while a
receiver is live is the second guard behind the table (§11.1), so a
bookkeeping error cannot become a double assignment. The transitions —
seeded, tracking, degrade, lost, released, dropped — are the
event log's annotations, at the sample the receiver's record reports.
One slot, as the pool drives it — the receiver's own states, the pool's transitions between them, and the label each one writes to the log:
stateDiagram-v2
direction LR
[*] --> idle : create(), reset()
idle --> refining : a peak at no live row's code phase, a free slot — seed(), «seeded»
state assigned {
refining --> tracking : the refine hands over — «tracking»
tracking --> tracking : one flag down — «degrade»
tracking --> lost : both flags down longer than lost_confirm_s — «lost»
}
lost --> idle : the row cleared, reset() — «released» (lost)
assigned --> idle : held past max_emitter_on_time_secs — «released» (on_time)
note right of idle
A peak within a chip of a live row's code phase is that
emitter's own: nothing happens and nothing is logged.
A peak with no free slot is counted and logged «dropped»
and moves no slot.
end note
The receiver decides lost (§10) and the pool acts on it; the pool alone
decides the on-time release, and nothing else takes a slot from a live
receiver. idle is the hand-off flavor's resting state — waiting for a
seed, never searching — and a released emitter still on the air re-enters
at its next window as a new detection.
What comes out, per slot and by index, the burst_capture shape: the
status record by value, and the symbols the receiver decided on this
push, borrowed by pointer from a buffer sized at create by
steps_max_out(). Nothing allocates per push or per transition, the
pool never exceeds n_slots, and a released emitter still on the air is a
new detection at its next window into whichever slot is free — the one
re-assignment the lifecycle permits. Replay and live runs produce the
same records, because nothing below the pool sees a time.
The pool is off the searcher's push path: the spread is 10 dB (§5.4), inside the floor, so no replica is subtracted and §11.4 is not built.
9. The two branches¶
Both branches share the peak list (a) and the assigned-emitter table the bank keeps in any case: which emitters have a receiver, at what Doppler and code phase now (the receiver's estimate, since an emitter drifts at up to 500 Hz/s between windows, not the detection's). The branches differ in what the searcher is allowed to see.
Spread inside the floor — the list is enough. The searcher sees every
emitter, assigned or not, and reports every peak above eta. The bank
drops any peak within one exclusion zone of an assigned emitter's current
estimate and hands the rest to the policy. An assigned receiver is never
touched by a re-detection of its own emitter. What has to hold: no
unassigned emitter above the floor is missed while a stronger one is up
(§12 steps 2–3), and the re-detection of an assigned emitter never becomes
a second receiver (§12 step 7).
Spread beyond the floor — cancel, then list. The searcher's input has every lock-gated assigned replica subtracted (iii), and then runs the same list. The assigned table does the same job as before, now only as a guard against the residual: a cancelled emitter that is imperfectly cancelled leaves a peak at its own coordinates, and the zone around the receiver's estimate is what keeps that residual from becoming a detection. What has to hold: the residual after cancellation sits below the unassigned emitters the application needs to find (§12 step 5).
The branch is chosen by one number — the application's operating spread
against the knee §12 step 3 measures — and it is chosen: 10 dB, inside
the 18–21 dB knee measured at D = 1 (§12.6) and further inside it in
the coherent blocks, where the floor is −21. The list branch ships; the
cancellation branch strictly contains it and stays designed here (§7.2,
§11.4) for a waveform whose spread is not this one.
10. The release — the lock detector decides "gone"¶
"Until they are gone" is a decision the receiver makes about itself, and
the pieces of it exist. AsyncDsssReceiver carries two de-chattered lock
flags, each a lockdet — level hysteresis between a declare and a drop
threshold, time hysteresis of consecutive looks either way, a NaN look
counted as a miss (native/inc/lockdet/lockdet_core.h):
- Code lock,
get_code_locked(): the liveDll's own CFAR-based, verify-counted detector — "am I despreading". This is the fundamental DSSS lock: an emitter that leaves takes its code with it, and the correlation at the tracked code phase and Doppler falls to the floor. - Symbol lock,
get_locked(): the BPSK statisticcos(2φ)over the emitted symbols, SNR-weighted over a 30-symbol dwell, declared after 30 consecutive symbols at or above 0.5 and dropped after 15 consecutive below 0.3 (ASYNC_DSSS_RX_LOCK_*). This is the health of the carrier leg: a cycle slip or a deep fade drops it while the code is still being despread.
What is missing is the transition. Today a receiver whose flags fall keeps
running its loops on noise, and the only exit is reset(), which returns
to searching — a state the hand-off mode of §6.1 does not have.
The rule. An emitter is gone when both flags are down, continuously, for longer than the longest fade the link must ride. §12.3 first measured code lock chattering three times a second on a healthy signal and off 96% of the time at the floor, which looked like the wrong flag; §12.4 traced that to the detector's looks — 20 quarter-epoch partials, sized for nothing — and §3.7 fixed it: sized for the C/N0 and coherent over a symbol, code lock drops within 4–12 ms of a real loss, holds through a phase step, and never dips on a healthy signal. It is the presence flag. Symbol lock, a 30-symbol dwell with hysteresis, is the carrier leg's health. Both are still CFAR flags on power, so a fade takes both down for its duration and brings both back — which is why the release is both down, for longer than the fade, and why the confirm interval is set by the fade the link must ride, not by the detectors.
The transition. Hand-off mode adds a fourth state, lost, beside searching / refining / tracking, and the receiver enters it on the rule above. In it the loops stop updating, the replica (§8 (iii)) is no longer published — its gate is code lock, which after §3.7 drops within milliseconds of a real loss and not otherwise, so publication stops at that drop, before the confirm interval has run — and the receiver reports lost to whoever holds the pool. The holder then releases the assignment: the emitter leaves the assigned table, so the searcher may report those coordinates again, and the receiver is reset to the hand-off mode's idle — waiting for a seed, not searching — for the pool to reuse. Nothing else moves: the searcher was never told to stop looking there and the other receivers are untouched. The one re-assignment the lifecycle permits is this one: an emitter released while in fact still present is re-detected at its next data-free window and seeded into a fresh receiver, which is a recovery, not a hand-back.
What the interval costs, and what it buys. Against on-times of 5 to
15 minutes — 15 the maximum, adjustable (§6.1) — release latency is
nothing: both flags are down within 25 ms
of a switch-off (§12.3), and a confirm interval of even two seconds —
longer than the one-second fades measured — is under 1% of the shortest
on-time. The number that matters
is the other one, the false release. A receiver that releases an
emitter still on the air loses that emitter's data until the next
data-free window plus a refine (the cadence of §5.4
question 4), and on the cancellation branch its replica leaves the
searcher's input for the same interval, so the floor rises under every
weaker emitter for a frame. The confirm interval is therefore sized from
a false-release budget — far rarer than once per on-time, per receiver,
the on-time being the 15-minute maximum, not a typical one —
in exactly the vocabulary lockdet documents: at the per-look miss
probability the tracked C/N0 gives, n_down consecutive misses set the
false-drop rate, and det_verify_count() sizes n_down against the
budget. Both the miss probability and the resulting interval are
measurements (§12 step 6).
The pool. Ten emitters at once plus the receivers still inside a
confirm interval on emitters that have just left: at one departure a
minute and a confirm interval of a second, the headroom is one. A pool of
about twelve hand-off-mode receivers, each a tracker chain on the
application's threads beside the searcher's own cost (burst-bank.md
§11.2), is the whole population.
The read-back. Whoever holds the pool needs to know, for each receiver, which signal it is tracking, for how long, and in what condition. The facts have two owners, and the split falls out of who produced each one:
- The orchestrator owns the assignment. It handed the seed to the
receiver, so it holds the
DetectionEventverbatim —timestamp_ns,samples_consumed,chip_phase,doppler_hz_est,cn0_dbhz_est— beside the receiver it went to. It fed every sample since, so it holds the sample count at assignment and the count now; duration is their difference over the rate, the repository'sdp_sample_clock_tarithmetic, replay-safe. And it recorded the state changes it was told about — refining to tracking, tracking to lost — with the sample count at each. Nothing here needs the receiver to remember its own history, which keeps the receiver thin: it tracks; the orchestrator keeps the books. This is the assigned table of §9 with three more columns, and it is what question 5's holder holds. - The receiver owns its condition. Only it knows where the emitter
is now — the live carrier loop's Doppler, the
Dll's code phase, the C/N0 the despreader sees (the drift since the seed is that against the orchestrator's row) — and its health: the state it is in, both lock flags, the symbol-lock metric against its declare threshold, the residual carrier errors the header already exposes, and, in lost, the samples since the code flag dropped. Today that is a scatter of getters —get_locked,get_code_locked,get_lock_metric,get_car_nco_freq, and the rest — each a separate call, so a reader that wants one consistent picture across apushon another thread cannot get one. The shape that fits is one status record, returned by value — themeasureobjects'singlerecord (ToneMetrics), a jm-generated structseq over a C struct — read on demand and never pushed.
The orchestrator's now columns are refreshed from the receiver's record at whatever cadence it reads, and the exclusion zone of §9 is keyed on those, not on the seed. So one read per receiver per window is the minimum, and the table is the join of the two owners' facts.
The record is a read of live state, distinct from get_state(): the
bytes triplet is for resuming the receiver elsewhere, the record is for
describing it here, and the two must not be confused — a record that
tried to be both would be a serialized blob a human cannot read. On the
cancellation branch the replica output is a third thing again, per block
and on the push path, and rides neither.
11. What the multi-emitter use case needs from the tracking receiver¶
§6–§10 fix the lifecycle: a searcher
per channel that never stops, and one AsyncDsssReceiver per emitter,
assigned once from a detection and tracking until its own loss decision
— never stopped, never re-seeded, never doubled up by the searcher. Up to ten
emitters at once, each on the air for 5 to 15 minutes, on one Gold code, on a
stream the whole pool must consume at 30 MSa/s comfortably. The receiver of §4
is the right object for that and needs five things,
none of which is a new receiver. Each is a Phase-1 design here and an
implementation item in
adding an algorithm's order; the
measurements that size them are §12.
11.1 The hand-off mode: an acquisition input, and an internal bypass¶
Today the only way in is the receiver's own search. In the pool the search is
the searcher's, so the receiver needs to take a detection from outside —
the DetectionEvent of §2.2, exactly as its own acq_build_handoff() would
have produced it — and to skip its own acquisition entirely in that mode:
no embedded Acquisition is built (a 23-to-53-tile engine per receiver, twelve
times over, is memory and work nothing uses), the searching branch of push()
is unreachable, and the object starts in refining from the given seed.
That is a difference in constructor, not in method, so it is the
ddc/MatchedDDC shape: a second create over the same state, declared as a
[[async_dsss_receiver.views]] entry in the manifest, the chain past the seed
shared verbatim. Two consequences follow:
seed(event)is a method on the base type, not the view's alone. The base receiver's own hit already takes this path internally — a hit is a seed the object made for itself — so exposing it is honest on both flavors, and a view shares methods verbatim in any case. On a receiver that is not idle it refuses: "assigned once" is enforced by the object, not by the orchestrator's discipline.reset()in hand-off mode returns to idle — waiting for a seed — not to searching, because there is no search to return to. Samples pushed in idle are consumed and discarded, so the feeding loop has no special case, and the pool reuses the object without reallocating.
11.2 The lost state, and the release¶
"Until they are gone" is the receiver's decision, and §4's two lock flags are
the pieces of it; what is missing is the transition. Today a receiver whose
flags fall keeps running its loops on noise, and the only exit is reset().
The rule, argued in §10 and measured in §12.3: an emitter is gone when both flags are down continuously for longer than the longest fade the link must ride. With the detector sized and symbol-aided (§3.7), code lock is the presence flag — off within milliseconds of a real loss, held through a carrier disturbance, never dipping on a healthy signal — and symbol lock the carrier leg's health; but a fade takes both down for its duration and returns both, so neither alone is the release. One flag down is a degrade, reported and not acted on; both down is the clock starting.
Hand-off mode adds a fourth state, lost, beside searching / refining /
tracking. On the rule above the receiver enters it: the loops stop updating,
the replica of §11.4 stops being published — at the code-lock drop, before
the confirm interval has run — and get_lost() reports it. The holder
of the pool then releases the assignment and calls reset(), which in this
mode goes to idle. The confirm interval is a time, not a verify count:
the measured fades take both flags down for their whole duration and bring
them back after, so the interval must exceed the longest fade the
application wants ridden, and against 5-to-15-minute on-times two seconds
costs nothing. What a false release costs is a frame of that emitter's data
plus, on the cancellation branch, a frame of raised floor under every weaker
emitter; §12.3's on-time run puts the both-down rate on a healthy signal at
zero in thirty seconds, and a longer run is what bounds it.
11.3 The status record¶
The holder of the pool needs to ask each receiver what it is doing. The facts
split by who owns them (§10): the orchestrator made the
assignment and fed the samples, so it holds the seed event verbatim, the sample
counts at assignment and at each state change, and the duration they give by
the dp_sample_clock_t arithmetic — nothing the receiver has to remember. The
receiver owns only what it alone knows, and today that is a scatter of
getters (§4's get_* family, one call each), which a reader on another thread
cannot assemble into one consistent picture across a push().
So the receiver gains one status record, returned by value — the measure
objects' single = true record (ToneMetrics is the model), a jm-generated
structseq over a C struct, read on demand and never pushed — carrying: the
state (idle / searching / refining / tracking / lost); where the emitter is
now — the live carrier loop's Doppler, the Dll's chip phase and code
rate, the despreader's C/N0 estimate; both lock flags, the symbol-lock metric
and its threshold, the two residual carrier errors; and the samples since the
state was entered and, in lost, since the code flag dropped. The existing
getters stay as the same fields' other face. The orchestrator refreshes its
now columns from this record at whatever cadence it reads — once per
data-free window is the minimum, because the searcher's exclusion zone is keyed
on the receiver's current estimate, not the seed. The record is a read of live
state and is not get_state(): the bytes triplet resumes the receiver
elsewhere, the record describes it here.
11.4 The replica output¶
On the strong branch of §9 — emitters more than the measured floor
(−13 dB, §12.2) apart in power — the searcher cancels
every assigned emitter from its input before it correlates, and the only
replica that is right through data modulation is the assigned receiver's: it
holds the live carrier's phase and frequency, the Dll's code phase, the
despreader's amplitude and the decided symbols, block by block. So the receiver
gains a replica output: after a push(), the reconstructed chip stream of
the samples just consumed — code at the tracked phase and rate, carrier at the
tracked phase and frequency, amplitude from the prompt, data from the
decisions — into a caller buffer, for the searcher to subtract.
Three things about it are design, not detail:
- It is lock-gated on code lock. A receiver whose code lock is down publishes nothing, so a wrong replica is never subtracted. That is safe only because §3.7 made the flag honest: before the fix it dipped a few times a second on a healthy signal, and a gate on it would have dropped the replica, and raised the searcher's floor, that often for nothing (§12.3, §12.4).
- It lags by the decision latency. The data on a block's chips is known
only once the matched filter and the symbol timing have decided the
symbols under it, some symbols after the block was pushed. The replica for
block
kis therefore complete only later, and the searcher's input is a delayed copy of the raw stream — a ring of the raw samples sized by that latency, which the holder owns. The receivers themselves always see the raw stream, live. - It is per output sample, on the searcher's thread. Ten replicas subtracted serially per block is the cancellation's price, priced in §6.4, and the reason the pool's holder stands on the searcher's push path on this branch and not otherwise.
The replica is not needed on the weak-spread branch, which is the branch the 10 dB spread picks (§9); it is not built.
11.5 The cost¶
Twelve receivers at twice the chip rate — 10 MSa/s at the top of the range —
on the application's threads, beside one searcher and one front-end DDC; the
budget is 100 ns per output sample per core at the operating point and 43 at
the 30 MSa/s floor, half of that as the working margin (§6.4). What that asks of the receiver: nothing allocates per push() or per
state change (the pool runs for hours), the replica writes into a caller
buffer, the status record is by value, and one receiver's cost per output
sample is a number the bench of §12 step 8 reports beside
the count of emitters it kept.
12. The work that answers it¶
- Measure the floor one emitter puts on the surface. One emitter,
no noise, design C/N0; tabulate the surface's maximum and RMS relative
to the peak over
(Δf, Δτ)— at zero Doppler across every lag, and at Doppler offsets of 0.5, 1, 2, 4 bins — with and without a data transition inside the epoch. Beside it,noise_estwith and without the emitter present: how far the CFAR reference rises. Expected: the three-valued −24 dB at zero Doppler, something between that and −30 dB elsewhere. This is the number the branch decision uses, and it is the engine's, so it belongs inacq's characterization. Done (§12.2): the Gold bound exactly where it applies; −16 dB with a data transition or a half-tile offset, −13 with both; the reference does not rise. The Doppler-offset axis was taken at 0 and 0.5 tile, not the four values planned — the twin peaks a transition makes settled the question before the rest of the sweep was needed. - Separability of two equal emitters. Two emitters at the design
C/N0 separated by
Δf ∈ {0.5, 1, 2, 4}bins andΔτ ∈ {0.5, 1, 2, 4}chips, 200 trials per cell: Pd of both under the list, and the coordinates each is reported at. Expected: both found outside the exclusion zone, one found inside it, and no cell where the second is reported off its own coordinates by more than a cell. This pins the zone's edges as the resolution. - The power-spread knee, list only. Strong emitter fixed at the
design C/N0, weak stepped from 0 to −40 dB below it in 3 dB, 200
trials each, at a
Δf/Δτwell outside the zone: Pd(weak). Expected a knee at the floor plus the detection margin. The knee is the decision — an operating spread inside it means branch one and no cancellation object. - Pfa under the list. Pure noise,
max_peaks ∈ {1, 4, 8}, the same frame countdsss-acquisition.md§9.1 used: realized per-dwell Pfa against configured. Expected unchanged, sinceNcounts cells, not peaks. Then with one strong emitter present: the rate of false peaks in its sidelobes — if it is not the configured rate, the reference is not tracking the raised floor and that is a CFAR finding, not a list finding. - Cancellation depth, (iii). An assigned receiver locked on the strong emitter; measure the residual after subtraction, relative to the strong peak, against C/N0 and against the receiver's steady-state phase and timing error; then re-run step 3 with cancellation on. The residual is the new floor and the distance it moves the knee is what the object buys. Run it once with (ii) as the control: the gap between the two is the price of not knowing the data.
- The release. A hand-off-mode receiver locked on one emitter at the
design C/N0; the emitter is switched off mid-track, faded 10 and 20 dB
for a second, and given one carrier cycle slip, 100 trials each:
the time from the event to code-lock drop and to symbol-lock drop, and
whether the code flag survives the fade and the slip. Then, with the
emitter left on for the length of an on-time, the per-look miss
probability of each flag — the number
det_verify_count()turns into the confirm interval for a false-release budget. Expected: the code flag rides through the slip, both flags drop within tens of milliseconds of switch-off, and the deep fade is the case that decides the interval. Done (§12.3), and the expectation was wrong on the code flag: it rides nothing — it drops on a fade as fast as on a switch-off and dips a few times a second on a healthy signal. The rule in §10 was rewritten to both flags down for longer than the fade. - The lifecycle soak. The population of §6.1 — one emitter always on, up to ten, on-times drawn around 5 to 15 minutes and capped at the adjustable maximum (§6.1) — at random Dopplers within one span and a spread on each side of the knee. Each emitter's synth runs for the whole soak and visibility is a gain of 1 or 0 at the sum, so an emitter appears at a random frame phase and nothing in the source restarts (§6.1); the phase at first sight is a random burn-in, the way the harnesses already set code phase. Each is acquired once, assigned once, tracked by the same receiver until it leaves, released by the rule of §10, and re-acquired on return; no receiver is ever assigned twice to a live emitter, no emitter above the floor is missed while others are up, and the pool never exceeds twelve. An hour sees about sixty arrivals at the full population, enough to count misses and false releases; the hours-long form with the memory and scratch checks is §5.1's duration requirement and runs once the bank exists. Done (§12.14), scaled to what a harness runs: ten emitters over two minutes at each C/N0, nothing missed, every arrival held within 0.2 s at 45 dB-Hz and 1.3 s at 40; the pool's zone was the wrong shape for its own depth (fixed), and the release fires one to three intervals late because the code flag comes back on noise (open).
- The budget, per stage. Its own bench target, on one core,
minimum of runs, at the operating point's numbers (§6.1): the
front-end DDC in ns per input sample at 13 MSa/s — confirmed to be on
the polyphase arbitrary path, not a cascade the bench's ratio let it
shortcut (§6.4); then, in ns per
output sample, the window-tiled searcher with
max_peaks = 16at both ends of the rate range — 21 tiles at 5 Mcps and 53 at 2 over ±50 kHz, then 3 and 7 over the ±5 kHz pre-compensation leaves — one hand-off-mode receiver tracking at 5 Mcps, and one replica subtraction. Then the whole population — the front end, the searcher, ten receivers, and on the strong branch ten replicas — on the 48 cores the application gives (§6.1), reported as the fraction of real time beside the count of emitters acquired and tracked in the same run, twice: at the operating point and at the 30 MSa/s floor (the same chain fed 2.3× faster). Target: under 0.5 at both. At 1.0 the requirement is missed by its own words, and the stage that owns the excess is the next thing to attack — §6.4's channel number says today's chain is already priced near it. Done, per stage (§12.1): DDC 0.18 of a core, searcher 2.1 at ±50 kHz, one receiver 0.44, slicing +6–11%, receivers add linearly. Not done: the whole population as one run with its detection count beside the rate, which needs the orchestrator. - Decide by the spread. The application's operating spread (§5.4 question 7) against step 3's knee: inside, branch one ships and (iii) is not built; beyond, (iii) is built and step 5's residual is the number its characterization pins. Done (2026-09-03): 10 dB, inside; branch one, no replica.
- The stimulus with the window.
wfm_synth's continuous DSSS gains the frame of §5.4 as two parameters,code_only_symbolsandframe_symbols:Wsymbols of pure code everyFsymbols on the data clock, the symbol clock free-running through the window and a frame edge at no particular chip phase, at each emitter's own frame phase — 450 and 4950 here, and 0 is today's waveform, no window at all. C first, in the synth, so every harness below renders the same waveform the application sends. - The block-coherent searcher. The continuous engine with
coherent_bins = Dinside the roll-tiled span, sized fromdoppler_rateas §2.1 says, summed in non-overlapping blocks and detected per block (§2.3). Then steps 1, 3 and 4 again on it: the floor in an aligned block (expected −21 dB, the transition-free number), the knee, and Pfa per block; and the sensitivity againstDat the design C/N0 — the gain is 18–22 dB on paper and a number here. **Engine built (2026-09-05, §2.3 "as built"), with §2.4's instruments first; the characterization — steps 1, 3 and 4 on the block engine and the sensitivity againstD— is the next PR. - The tracker through the window. A hand-off receiver locked at the design C/N0 across ten frames: the symbol-lock flag through 500 epochs without a transition (the symbol clock is unobservable there and coasts), the code flag, and the pull-in after the data resumes. Expected: code lock holds, the symbol flag may drop and recovers within its dwell, and no release fires — one flag down is a degrade. If the flag reads a pure-code stretch as unhealthy, that is a detector finding to fix, not a rule to loosen. Done (§12.9): both flags hold through every window at both C/N0s — code lock never drops, the symbol flag's only dips are its own chatter at the floor and no worse in a window than in the data — and the release never fires. The ramp found a pull-in defect from the searcher's seed instead (#1249).
- The searcher's cost with
D, and its scaling across threads. Step 8's per-tile number again with the block transform in it, at both ends of the rate range and both uncertainties, beside the memory per channel; then the same push at 1, 2, 4 and 8 threads with a roll per thread — the fraction of the tiles' cost that scales, and the persistent pool's hand-off per push beside the per-call creation it replaces. Done (§12.8): 624 ns per sample serially at the operating point (3.6× the epoch-by-epoch searcher), 288 on four threads; the fan was Amdahl's, 88% fanned atD = 1and 72% atD = 154, because the per-cell passes after the fan grew with the surface. #1243 fanned those passes per tile and chunked the block-end gather: 523 serially, 164 on four threads (92% fanned), 125 on eight. Memory per channel atD = 154: the block (53 MB), the surface (106 MB complex + 53 MB magnitude), and the kept surface when read.
Steps 1–4 are Python over the shipped engine plus the peak-list primitive,
and are the same harness the burst characterization already runs. Steps
5–6 need the hand-off-mode AsyncDsssReceiver (§6.1) with the lost state
of §10 and, for step 5, a replica output it does not have today. Steps 7–8
need the pool of §8.2, which needs steps 10–11 first: the population it
holds is acquired in the window.
12.1 What was measured (2026-09-02) — step 8, the budget¶
Three component benches gained operating-point rows and were run on an
8-core build box, one core, minimum of rounds (bench_ddc_core,
bench_acq_core, bench_async_dsss_receiver_core; make bench runs all
three; the rows are rate=0.77, op5M_*/op2M_* and *,op5M):
| stage | ns per sample | of one core, operating | of one core, 30 MSa/s floor |
|---|---|---|---|
| front-end DDC, 13 → 10 MSa/s | 13.6 per input | 0.18 | 0.41 |
| searcher, 5 Mcps, ±50 kHz, 21 tiles | 214 per output | 2.14 | 4.9 |
| searcher, 2 Mcps, ±50 kHz, 53 tiles | 523 per output | 2.09 | 4.8 |
| searcher, 5 Mcps, ±5 kHz, 3 tiles | 36 per output | 0.36 | 0.83 |
| searcher, 2 Mcps, ±5 kHz, 7 tiles | 75 per output | 0.30 | 0.69 |
| one receiver, tracking (warm), 5 Mcps | 44 per output | 0.44 | 1.0 |
| one receiver, cold (search + refine) | 89 per output | — | — |
Seven things this settles, and one it corrects:
- The arbitrary-ratio front end is 4.2× the integer cascades — 13.6 ns against 2.4–4.0 for rates 0.05–0.5 — so choosing 13 MSa/s to force it (§6.4) priced the front end at its real cost; it is still under a fifth of a core at the operating point.
- The searcher over ±50 kHz does not fit on one core at any chip rate:
2.1× real time at both ends of the range. Its cost is ~10 ns per tile
per output sample, and the tile count rises exactly as the rate falls,
so the tile-samples per second — and the core count — are the same at 2
and 5 Mcps. That confirms §6.4's arithmetic and turns its third bullet
into a requirement: the tiles must be partitioned across cores,
three at the operating point, five at the floor, before any margin.
The tiles are independent inverse FFTs off one shared forward FFT, so
the split is either inside the engine (a parallel-for over tiles per
epoch, which keeps one forward FFT) or across engines each given a slice
of the uncertainty (which repeats the forward FFT per slice but needs
no threading inside the engine and matches the "processes as needed"
shape of §1.1). Measured, the same day: one engine over a third of
±50 kHz (
op5M_U17k, 7 tiles;op2M_U17k, 19) costs 75 and 192 ns per output sample, so three of them are 225 and 576 against the single engine's 213 and 520 — 6% and 11% for the slice, the forward FFT repeated per slice being worth about one tile. Superseded (2026-09-03): the split is a roll per thread inside the engine, on persistent workers — see the bullet below §2.3 and §8.2. The slice was the shape to take while the only parallel-for created its workers per call at ~15 µs each (burst-bank.md§10.4) against a 205–512 µs epoch — 25–60% of the work for eight workers. A persistent pool pays that once. - Doppler pre-compensation is worth 6–7× on the searcher — 0.36 and 0.30 of a core over ±5 kHz — and nothing on anyone else. With it the searcher fits on one core with room; without it the partition above is mandatory.
- One tracking receiver is 0.44 of a core, and receivers add. The pool of twelve is 5.3 cores at the operating point and 12 at the floor — the largest single line in the budget. Run as concurrent processes on the 8-core box, the warm row went from 43 ns alone to 46 with four running and 46–55 with eight (the top of that spread is the core the operating system was also using), so cores add nearly linearly for the receivers and the memory system is not the limit at this scale.
- The population, one process, ±50 kHz, 5 Mcps: about 7.6 cores at the operating point (0.18 + 2.14 + 12 × 0.44), 17.5 at the floor; with pre-compensation 5.8 and 13.4. At the 2× margin §6.4 asks for, that is 15 and 35 cores without pre-compensation, 12 and 27 with. The server "will have a lot"; this is what a lot means.
- Correction: the engine's tile rule gives 21 tiles at 5 Mcps over
±50 kHz, not the 23 this page derived from
burst-bank.md's channel formula; the table in §6.1 now carries the engine's number. Nothing else moved.
Not measured here: the searcher with the peak list (it does not exist; the pick is one pass over the surface and will not move the ~10 ns per tile), a replica subtraction (no replica output yet), and the whole population as one run with its detection count beside the rate — that needs the orchestrator, and is what step 8 still owes.
12.2 What was measured (2026-09-02) — step 1, the floor¶
native/validation/acq_emitter_floor.c (make validate-c; its --check
is in the C suite): one emitter rendered by the shipped continuous-DSSS
synth (wfm_synth, the generator wfmgen uses) on the engine's own
single-look surface at the operating point, ±50 kHz, Gold-1023 (CCSDS
365), read back from mag_buf after the dwell and binned outside the¶
one-tile × one-chip exclusion zone. Everything in dB below the emitter's peak:
| emitter | same tile, other lags | worst cell, any tile | worst cell at another code phase | CFAR reference |
|---|---|---|---|---|
| tile-centred, no data transition | −23.9 (the Gold bound, exactly) | −21.0 (far tiles) | −21.0 | −32.9 |
| centred, a transition in the epoch | −18.7 | 0.0 — an equal twin two tiles away; the reported tile is one off | −16.0 | −28.8 |
| half a tile off centre, no data | −18.2 | −9.5 (two tiles away) | −16.1 | −28.8 |
| half a tile off, a transition | −14.6 | 0.0 — twins two and three-plus tiles away | −12.8 (5 Mcps), −11.9 (2 Mcps) | −25.2 |
The two chip rates agree to 0.1 dB except in the last row, where the lower rate's narrower tiles spread the split emitter further. With noise, one strong emitter moves the CFAR reference by 0.18 dB at 55 dB-Hz and by nothing measurable at 45 and 40.
Four things this settles:
- The design number is −13 dB, not −24. The Gold bound holds exactly where it applies — full period, zero Doppler, no data — and that is the spot check. But the searcher looks at every epoch, an emitter's data puts a transition in 55% of them at 1.8 epochs per symbol, and it sits anywhere in its tile; in those cases the worst cell at another code phase is 16 dB down, and with both at once 12 to 13 dB. So a second emitter more than about 13 dB weaker than the strongest, less the detection margin, is under the strong one's floor and is the cancellation branch's (§9); §6.3's fork is at −13 dB.
- One emitter can make more than one peak, and tile distance does not bound it. A transition in the epoch splits an emitter into equal twins, two tiles apart when centred and three or more when it is also off centre; a half-tile offset alone puts a −9.5 dB sidelobe two tiles away. Every one of them is at the emitter's own code phase. The peak list therefore needs a rule beside the zone that keys on code phase, not tile distance: a peak within one chip of an already-listed peak's code phase is a candidate twin. Two real emitters can share a code phase at different Dopplers, so the twin is not dropped on one epoch — it is held, and the next epochs decide: a twin moves with the transition's position and vanishes in the emitter's data-free window, a real emitter stays put. That is a two-epoch rule, and it belongs in §7.1.
- The reference does not hide the weak emitter; the sidelobes do. A strong emitter leaves the CFAR reference where the noise put it, so a weak emitter's gate is unchanged; what stops it being a peak is the strong one's cells standing over it. That is why removing the strong emitter (cancellation) is the only fix on that branch, as §6.3 argued.
- Where the peak list is taken matters. In an emitter's own data-free window the other emitters are still carrying data, so −13 to −16 dB is the operating floor everywhere; the data-free window buys the emitter its own clean, single peak, not a clean surface.
12.3 What was measured (2026-09-02) — step 6, the release¶
native/validation/async_dsss_receiver_release.c (make validate-c; its
--check is in the C suite): the receiver as built, tracking one emitter
from the shipped continuous-DSSS synth at the operating point (5 Mcps,
2700 sym/s asynchronous BPSK, PRBS data) with the shipped awgn at two
C/N0s, fed one epoch (0.2 ms) at a time with both lock flags read after
every block. Once tracking with symbol lock held for 200 blocks, one event
per trial; 30 trials per event, 10 of 3 s for the on-time.
| C/N0 (Es/N0) | event | code lock off | symbol lock off | both off | back by 1.5 s (code / symbol) |
|---|---|---|---|---|---|
| 45 dB-Hz (10.7 dB) | switch-off | 1.8 ms | 25 ms (max 38) | 25 ms, stays off | 0 / 0 of 30 |
| 10 dB fade, 1 s | 1.8 ms | 44 ms (max 83) | for 0.99 s | 30 / 29 | |
| 20 dB fade, 1 s | 1.8 ms | 26 ms | for 1.47 s | 29 / 27 | |
| π/2 phase step | 164 ms median, 783 max | held in 29 of 30 | never | 29 / 30 | |
| nothing, 30 s | off 0.6% of blocks, 79 dips | never | never | ||
| 40 dB-Hz (5.7 dB) | switch-off | already off | 20 ms (max 34) | 20 ms, stays off | 0 / 0 |
| 10 dB fade, 1 s | already off | 22 ms | for 1.09 s | 0 / 23 | |
| 20 dB fade, 1 s | already off | 20 ms | for 1.48 s | 2 / 17 | |
| π/2 phase step | already off | 9 ms, held in 17 of 30 | ≤ 52 ms | 2 / 30 | |
| nothing, 27 s | off 96% of blocks, 466 dips | 0.5% of blocks, 4 episodes | 0.5%, longest run 36 ms |
What it settles, and what it overturned:
- Code lock is not a presence flag. At Es/N0 10.7 dB it dips for a
block or two three times a second on a healthy signal; at 5.7 dB it is
off 96% of the time while the receiver is tracking and decoding. It
drops on a 10 dB fade in the same 2 ms as on a switch-off, and a phase
step that symbol lock rides takes it down 160 ms later. It is the
Dll's per-decision CFAR flag on prompt power, and it does exactly that. The page's original rule — release on code lock — would have released on every fade and, near the floor, continuously. - Symbol lock is the stable one. Never a dip in 30 s at 10.7 dB; four episodes in 27 s at 5.7 dB, the longest 36 ms. It drops 20–45 ms after a switch-off or the start of a fade, and stays down for the fade's length.
- The rule is both flags down for longer than the fade. A switch-off holds both down indefinitely; a 1 s fade holds both down for 1.0–1.5 s and then brings them back at 10.7 dB (less reliably at 5.7 dB, where symbol lock returned in 17–23 of 30 within the watch); a healthy signal's longest both-down run is 36–52 ms. Two seconds separates those with a margin of forty on the healthy side and two on the fade side, and costs under 1% of the shortest on-time. The confirm interval is a time, and the fade sets it — not a verify count.
- The replica's gate is symbol lock. A gate on code lock would drop the replica, and raise the searcher's floor, three times a second.
- A recovered receiver is the same assignment. After a fade both flags return on the same receiver with the same code phase — the emitter never restarted — so a release that fires during a fade would hand a fresh receiver an emitter one is already tracking. That is the false release the interval is sized against.
After the fix (§3.7, §12.4) — the same sweep, the receiver's detector sized and symbol-aided:
| C/N0 (Es/N0) | event | code lock off | symbol lock off | both off | back by 1.5 s (code / symbol) |
|---|---|---|---|---|---|
| 45 dB-Hz (10.7 dB) | switch-off | 3.5 ms (max 4.3) | 25 ms | 25 ms, stays off | 0 / 0 of 30 |
| 10 dB fade, 1 s | 3.7 ms | 44 ms | for 0.56 s | 30 / 29 | |
| 20 dB fade, 1 s | 3.5 ms | 26 ms | for 1.12 s | 29 / 27 | |
| π/2 phase step | held, 30 of 30 | held in 29 of 30 | never | 30 / 30 | |
| nothing, 30 s | never | never | never | ||
| 40 dB-Hz (5.7 dB) | switch-off | 11.5 ms (max 15) | 20 ms | 20 ms, stays off | 0 / 0 |
| 10 dB fade, 1 s | 12 ms | 22 ms | for 0.99 s | 30 / 23 | |
| 20 dB fade, 1 s | 11.5 ms | 20 ms | for 1.48 s | 24 / 17 | |
| π/2 phase step | held, 30 of 30 | 9 ms, held in 17 of 30 | never | 30 / 30 | |
| nothing, 27 s | never | 0.5% of blocks, 4 episodes | never |
Code lock is the presence flag the page first wanted, once its looks are sized and symbol-aligned: off within 4 ms of a switch-off at 10.7 dB and 12 ms at the floor, held through a phase step in every trial, back after every fade at 10.7 dB and after 24 of 30 deep fades at the floor, and not one dip in 57 s of on-time across both C/N0s. Symbol lock is now the one that moves on a carrier disturbance. The rule of §10 keeps its shape — both flags down for longer than the fade — because a fade still takes both down for its duration; what the fix buys is a clock that starts within milliseconds of a real loss and never starts on a healthy signal.
Not measured yet: the false-release rate over a whole on-time — the 15-minute maximum of §6.1 — rather than half a minute (the both-down rate at 5.7 dB is 0.5% of blocks in runs of tens of milliseconds; whether a run ever reaches seconds is what fifteen minutes would say), and any of this on the hand-off-mode receiver, which does not exist.
12.4 What was measured (2026-09-02) — the DLL's telemetry, and the aid¶
The receiver's DLL alone (bn 0.002, segments 4), fed the shipped synth's
continuous DSSS at the operating point with a Telemetry context attached
(receiver_lock_demo.py's pattern), 2 s per run; code.lock against its
threshold, code.locked, the discriminator and the tracked rate, for three
detectors on the same signal
(src/doppler/dsss/tests/characterization/dll_lock/):
| Es/N0 | detector | per-look Es/N0 | looks | R vs eta | miss per decision | off | drops per s | code rate |
|---|---|---|---|---|---|---|---|---|
| 10.7 dB | 20 partials (default) | 2.1 dB | 20 | 9.5 vs 8.7 | 5.1% | 1.4% | 4.5 | 1.000000 |
partials sized (det_n_noncoh) |
2.1 dB | 25 | above | 1.7% | 0.4% | 1.0 | 1.000000 | |
| symbol-aided, sized | 10.7 dB | 3 | well above | 0.0% | 0.1% | 0 | 1.000000 | |
| 5.7 dB | 20 partials (default) | −2.9 dB | 20 | 7.5 vs 8.7 | 86% | 97% | 11 | 1.000000 |
| partials sized | −2.9 dB | 161 | 22 vs 20 | 2.2% | 1.6% | 0.5 | 1.000000 | |
| symbol-aided, sized | 5.7 dB | 10 | well above | 0.4% | 0.2% | 0 | 1.000000 |
What it settles:
- The loop was never the problem. In every run the tracked code rate is 1.000000 within 3 ppm and the discriminator is zero-mean with no drift, including the run where the flag read "unlocked" 97% of the time. §12.3's chatter and 96% were the detector's default integration — 20 quarter-epoch partials, 1 ms, sized for nothing — sitting under its own threshold at the floor and grazing it at 10.7 dB.
- Sizing alone fixes the 96%; the aid fixes the margin. Sized partials need 161 looks at the floor and still miss 2% of decisions; the symbol-aided look needs 10 and misses 0.4%, with the statistic well clear of its threshold at both C/N0s and no drop in 2 s.
- The hysteresis is now a budget. At the aided miss rate,
det_verify_count(0.01, 1e-6)gives three consecutive misses to drop, which the receiver sets; at two, the floor's 0.4% would have produced a false drop about every four minutes of decisions. - What this does to the release rule (§10). Code lock is a usable presence flag again — §12.3's post-fix sweep shows it off within 4–12 ms of a switch-off, held through a phase step in every trial, and not dipping once in 57 s of on-time. The both-flags-down rule stands because a fade still takes any CFAR flag down for its duration; what changes is that the "both down" clock now starts within milliseconds of a real loss and never on a healthy signal.
12.5 What was measured (2026-09-02) — the discriminator on the aided window¶
native/validation/dll_aid_jitter.c (make validate-c; its --check is
in the C suite): the receiver's DLL alone (bn 0.002, half-chip spacing,
four partials per epoch) fed the shipped synth's continuous DSSS at the
operating point with the shipped awgn, one epoch per call, its tracked
code phase against the generator's after every block; the per-epoch
look-back and the symbol-aided window on the same stream. One seed per
cell, 12 000 epochs measured after 3 000 settling, so a ratio is good to
about 8%.
| C/N0 (Es/N0) | jitter, per-epoch | jitter, aided | ratio | pull-in from 0.25 / 0.5 / 0.75 chip, per-epoch | aided |
|---|---|---|---|---|---|
| 50 (15.7 dB) | 0.0078 chips | 0.0062 | 0.79 | ||
| 45 (10.7 dB) | 0.0132 | 0.0136 | 1.03 | 179 / 234 / 251 ms, 10 of 10 each | 145 / 202 / 222 |
| 42 (7.7 dB) | 0.0173 | 0.0225 | 1.30 | ||
| 40 (5.7 dB) | 0.0208 | 0.0289 | 1.39 | 249 / 298 / 328 ms, 10 of 10 each | 204 / 238 / 258 |
| 38 (3.7 dB) | 0.0341 | 0.0371 | 1.09 | ||
| 36 (1.7 dB) | 0.0421 | 0.0524 | 1.25 | ||
| 34 (−0.3 dB) | 0.0606 | 0.0650 | 1.07 |
Neither loop lost the code in any cell, and both read a code rate of 1.000000. Both discriminators zero at the same code phase (−0.004 chips, clean, both).
What it settles:
- Above 45 dB-Hz the aided loop is tighter, and the reason is the look-back. On a data-free stream the per-epoch loop reads 0.0060 chips at 50 dB-Hz; with data, 0.0088. Its handling of the transitions — a window borrowed from the previous epoch at the previous phase, and a transition in the first partial that no candidate can exclude — is what sets its jitter there. The aided window pays nothing for the data.
- At the floor the noise sets the jitter, and the aided window's unused partials cost. The window is six of the 7.24 partials a symbol spans; the transition partial and the slack are left out. With the hypothesis pinned at the truth the aided loop reads 0.025 chips at 40 dB-Hz, with its own argmax 0.027–0.030, the per-epoch loop 0.022. A power EMA four times longer, or a window one partial shorter, moves it by less than the trial spread; a hypothesis a partial off reads 0.04–0.06. So the loss is the window, not its choice.
- Pull-in is 15–20% faster in every cell, and the loop gain is the
same: under a 100 ppm code-rate step the two modes' integrators agree
to under 1% of the step mid-transient (
test_dll_core.c§6c), where a filter left at its per-epoch gains reads 1.8× slower. - What it means for the receiver: hundredths of a chip either way. A 0.03-chip RMS code error is under 0.1 dB of despreading loss. The receiver keeps the one declaration — the symbol period aids the looks and the loop — and the number to beat, should this be revisited, is 0.022 chips at 40 dB-Hz.
12.6 What was measured (2026-09-02) — steps 2–4, the peak list¶
native/validation/acq_peak_list.c (make validate-c; its --check is
in the C suite): the continuous engine at 5 Mcps, ±50 kHz (21 tiles),
sized by its own physics at a design C/N0 (15 looks per dwell at 45
dB-Hz), one shipped synth per emitter with PRBS data at 2700 sym/s, the
shipped awgn at the strong emitter's C/N0, max_peaks = 4; 200 scenes
per cell, two dwells each and the second scored, since the two-epoch rule
lists a same-code-phase emitter from the second dwell on.
Step 2, separability (two equal emitters at 45 dB-Hz; P(both listed)):
| Δτ \ Δf | 0.5 tile | 1 tile | 2 tiles | 4 tiles |
|---|---|---|---|---|
| 0.5 chip | 0.00 | 0.00 | 1.00 | 1.00 |
| 1 chip | 0.00 | 0.07 | 1.00 | 1.00 |
| 2 chips | 0.95 | 1.00 | 1.00 | 1.00 |
| 4 chips | 0.94 | 1.00 | 1.00 | 1.00 |
Inside one tile and one chip the two are one peak, as §7.1 says the zone makes them; outside it both are listed in every dwell, including the same-code-phase pairs the twin rule holds for one dwell. Half a tile off costs 5%, the straddle. Every listed peak was on its emitter's tile and within a chip of its code phase; no false peak in 3 200 dwells.
Step 3, the knee (strong emitter fixed, weak stepped down 4 tiles and 100 chips away, the engine sized at the weak emitter's C/N0 so the floor decides rather than the sizing; the weak emitter alone as the control):
| strong | spread | weak C/N0 | looks | P(weak, with strong) | P(weak alone) |
|---|---|---|---|---|---|
| 55 dB-Hz | 3–15 | 52–40 | 2–88 | 0.98–1.00 | 1.00 |
| 18 | 37 | 256 | 1.00 | 1.00 | |
| 21 | 34 | 256 | 0.12 | 0.66 | |
| 24 | 31 | 256 | 0.00 | 0.03 | |
| 45 dB-Hz | 0–9 | 45–36 | 15–256 | 1.00 | 0.99–1.00 |
| 12 | 33 | 256 | 0.24 | 0.27 | |
| 15 | 30 | 256 | 0.01 | 0.00 |
The knee is where the two curves part: at a 55 dB-Hz strong emitter, between 18 and 21 dB of spread — deeper than §12.2's −13 to −16 dB single-look floor, because the non-coherent sum favours the weak emitter's consistent peak over the strong one's data-dependent sidelobes. At 45 dB-Hz the weak emitter is noise-limited before the floor reaches it: the two curves fall together from 12 dB, and no floor-limited miss is seen down to 33 dB-Hz. The fork of §6.3 stays at −13 dB as the single-look worst case; a receiver that integrates buys a few dB past it.
Step 4, pfa under the list (pure noise, engine sized at 45 dB-Hz,
configured pfa 1e-2, 20 000 dwells): reported dwells 0.0091 / 0.0102 /
0.0085 at max_peaks 1 / 4 / 8, one peak per reported dwell — the list
does not change the false-alarm rate, and on the same noise the same
dwells report at 1 and at 4 (the --check pins that). With one strong
emitter present at 45 dB-Hz, false peaks at other code phases run at
0.0005 per dwell, the configured 1e-3 pfa or under; the emitter is listed
in 2 000 of 2 000 dwells with no twin listed.
What it settles, and the one thing it raised:
- The zone is the resolution and costs nothing outside it; the twin rule costs one dwell for a real same-code-phase emitter and nothing else.
- The rule's table must carry every pick of the previous dwell, listed or held: two equal emitters at one code phase swap places as the strongest, and holding only the held ones listed both in 30% of dwells (measured before the fix; 100% after).
- Under long non-coherent integration a strong emitter's same-code-phase sidelobes persist and pass the rule. At 256 looks (52 ms) a 55 dB-Hz emitter lists 1.8 twins per dwell at its own code phase on other tiles: the sum averages the data-free window away, so "still there next dwell" no longer separates a twin from a second emitter. A power rule would — a same-code-phase peak more than the floor below its parent is the parent's, and an emitter that far under is the cancellation branch's anyway (§6.3) — and it is open (#1190). At the operating point's 15 looks no twin was listed in 2 000 dwells.
12.7 What was measured (2026-09-05) — step 11, the block-coherent searcher¶
native/validation/acq_block_coherent.c (make validate-c; its --check
is in the C suite): the continuous engine with the depth its window buys —
813 whole code-only epochs at 5 Mcps and 324 at 2 (§2.1), the rate bound
of 500 Hz/s deciding: D = 154 and 61, a 31.7 Hz row — over ±50 kHz
(21 and 53 tiles), one emitter from the shipped synth at tile 5 plus a
quarter row, sized for one look, the surface read back through §2.4's
tap in the gate's own units. Everything in dB below the emitter's peak,
cells outside the exclusion zone, by Doppler-row distance; other is
the worst cell at another code phase.
The floor and the straddles (clean, 5 Mcps; 2 Mcps agrees to 1 dB):
| block | peak/gate | conc |
row 1 | row 2 | row 3+ | other |
|---|---|---|---|---|---|---|
| aligned — pure code | 1874 / 5.4 | 0.92 | −14.0 | −16.9 | −20.8 | −21.0 |
| one transition mid-block | 1121 / 5.4 | 0.49 | −5.8 | −0.0 | −11.4 | −20.9 |
| PRBS data, the whole block | 106 / 5.4 | 0.04 | −1.1 | −2.9 | −0.0 | −20.5 |
| the window's edge mid-block | 420 / 5.4 | 0.43 | −10.1 | −8.2 | −8.2 | −20.9 |
Five things this settles:
- The aligned block gives the transition-free floor, −21 dB at another code phase — the number §12 step 11 expected, and 8 dB below the −13 the single-look surface has under data (§12.2). Inside the emitter's own column the slow-time transform's rectangular window puts its first sidelobe at −14 dB one row out; a taper would trade that for a wider main lobe, and nothing here needs it.
- A block that straddles a data transition splits the emitter into twins. One transition mid-block halves the peak and puts an equal copy two rows away at the same code phase; the reported row is one off the truth (32 Hz). That is §12.2's twin rule again at the row scale — at the emitter's own code phase, so the two-epoch rule and the concentration see it, and the floor at other code phases is untouched.
- A block inside the data section is a weak, smeared copy, still at its
code phase. PRBS data through the whole block spreads the emitter over
every row of its column at −1 to −3 dB of the peak, which itself is
25 dB below the aligned block's (about
10·log10 Dand the data's spectrum) — a copy a real C/N0 leaves under the floor, and the assigned table excludes in any case (§2.3). - The window's edge mid-block — the maintainer's case — is half of each. Half pure code, half data: the peak is 13 dB down, the column spread at −8 to −10 dB, and the peak is exactly at the emitter's code phase (47 for that block's chip offset). The edge falls at no chip phase (§5.4), so one block per window sees this at each end.
- The concentration is the discriminator. 0.92 aligned, 0.49 for the
twins, 0.43 at the edge, 0.04 under data: a second emitter is a second
column and leaves its neighbour's column alone, so a low
concat one code phase is one emitter's splatter and never two emitters. The engine emits it asacq.conc(§2.4), for the strongest pick only; the pool does not read it — the list carries no per-peak concentration, and the pool keys its zone on the code axis alone (§8.2, §12.14).
Pfa per block (pure noise, D = 16, 21 tiles, 300 blocks): configured
0.10, realized 64 of 300 = 0.21; configured 0.20 over 100 blocks, 37.
That is 1 − (1 − pfa)^2 to within a sigma both times: the slow-time axis is
interpolated twofold and the maximum runs over the interpolated surface
while the threshold's N counts native cells — doppler#1064, the open
finding on the burst engine, which the continuous engine inherits with its
slow-time axis. The CFAR counts every row of every tile (the cell count is
pinned in test_acq_core.c); the factor is #1064's, and the --check
pins the realized rate against 1 − (1 − pfa)^interp so the finding cannot
be mistaken for a regression, or a fix for one.
Sensitivity (5 Mcps, one look, 20 trials, realized Pd and the mean peak-to-gate ratio):
| D | 30 dB-Hz | 34 dB-Hz | 38 dB-Hz | 42 dB-Hz |
|---|---|---|---|---|
| 1 | 0.00 (0.8) | 0.00 (0.8) | 0.00 (0.8) | 0.00 (0.8) |
| 16 | 0.00 (0.9) | 0.00 (0.9) | 0.35 (1.0) | 1.00 (1.5) |
| 154 | 0.90 (1.2) | 1.00 (1.7) | 1.00 (2.7) | 1.00 (4.3) |
A single epoch over ±50 kHz detects nothing to 42 dB-Hz with one look; D = 16 turns on between 38 and 42; D = 154 is on at 30. From 16 to 154 the knee moves about 9 dB for 9.8 dB of depth — the depth buys what it says, less the straddle. What the operating point buys against the epoch-by-epoch searcher of §12.1 is therefore not one number but the whole gap between "never" and 30 dB-Hz at one look; and sized at the 45 dB-Hz of §12.6, the engine's own sizer buys 15 non-coherent looks at D = 1 and one at D = 154 (measured on the same code and span). Its cost per epoch, and how the fan across threads takes it, is §12 step 13.
12.8 What was measured (2026-09-05) — step 13, the searcher's cost with D, and the fan¶
bench_acq_core (make bench), on a 20-core Ryzen AI 9 465, minimum of
15 pushes; the searcher's cost in ns per output sample and as a multiple
of real time, the roll per thread (§2.3) at 1, 2, 4 and 8 threads:
| row (±50 kHz) | 1 thread | 2 | 4 | 8 |
|---|---|---|---|---|
| 5 Mcps, 21 tiles, D = 1 | 174 (1.75×) | 98 (0.98×) | 58 (0.58×) | 50–60 |
| 5 Mcps, 21 tiles, D = 154 | 624 (6.2×) | 398 (4.0×) | 288 (2.9×) | 258 (2.6×) |
| 5 Mcps, ±5 kHz, 3 tiles, D = 154 | 95 (0.95×) | 53 (0.53×) | ||
| 2 Mcps, 53 tiles, D = 61 | 2293 (9.2×) | 892 (3.6×) |
Four things this settles:
- The fan works, and it is Amdahl's. At
D = 1four threads buy 3.0× — about 88% of the work is in the tiles — and eight buy little more; the searcher over ±50 kHz that was 1.75× real time on one core is 0.58 on four. The pool's hand-off per push is not visible at this granularity (an epoch is 0.2 ms; the D = 1 rows are 254-epoch pushes). - The depth costs 3.6× per sample serially, and the transforms are not
why. The design estimated the slow-time transform "of the order of
the epoch transform it sits behind" — it is, but the per-cell passes
around it are not: the magnitude of every cell, the CFAR reference over
the whole surface, the mask copy and the list's scans grow with the
surface, 13.2 M cells per block at the operating point against 43 k per
epoch at
D = 1, and they run after the fan, serially. That is why four threads buy only 2.2× atD = 154(72% fanned) — and inside the fanned block-end loop the scatter folds a row index per cell and the column gather strides bycode_bins. Both are named in #1243 with the fix: the magnitude, the reference and the list per tile with a serial merge; a per-tile row table; a chunked gather. - Pre-compensation is worth what §12.1 said. Over ±5 kHz the
D = 154searcher is 0.95× real time on one thread and 0.53 on four. - The low chip rate is the worst case, by more than before. 53 tiles
of
D = 61rows is the same 13 M cells per block for 61 epochs instead of 154, so the per-cell passes cost 2.5× more per sample: 9.2× real time on one thread, 3.6× on four. The fix above is what brings it in.
What the budget said (§6.4: 100 ns per output sample per core, half as the margin): at the operating point the block searcher on four threads was 288 ns of wall per sample, about 1150 core-ns — a quarter of a 48-core server's budget, beside twelve receivers at 44 each (§12.1). It fit; it was not comfortable, and #1243 was the next thing to attack.
#1243, measured the same day, same box, same rows. The per-cell passes now run per tile on the pool and merge serially in tile order; the block-end scatter reads a per-tile row table instead of folding a row index per cell; the column gather goes 32 columns at a time so a cache line of the block serves eight columns instead of one. The surface and the hits are byte-identical at any thread count, as before, with a second emitter in the comparison so the list's second scan is part of it.
| row (±50 kHz) | 1 thread | 2 | 4 | 8 |
|---|---|---|---|---|
| 5 Mcps, 21 tiles, D = 1 | 174 (1.74×) | 97 (0.97×) | 59 (0.59×) | 55–62 |
| 5 Mcps, 21 tiles, D = 154 | 523 (5.2×) | 283 (2.8×) | 164 (1.6×) | 125–138 (1.3×) |
| 5 Mcps, ±5 kHz, 3 tiles, D = 154 | 80 (0.80×) | 35 (0.35×) | ||
| 2 Mcps, 53 tiles, D = 61 | 2010 (8.1×) | 580 (2.3×) |
- The fan is now 92% of the work at
D = 154(four threads buy 3.2×, eight 4.2×), up from 72%; the serial cost fell 16% (the row table and the chunked gather), the four-thread cost 43%.D = 1is unchanged to the nanosecond: its passes were 43 k cells per epoch, never the cost. - What is left is the transforms. At the operating point the block
is 21 tiles × 2046 columns of a 308-point slow-time transform plus
154 × 21 inverse transforms of 2046 points; the passes around them
are now a fraction of that on any thread count. 308 = 4·7·11 is not
a smooth length; bounding
Dto a 5-smooth number below the window's is the one lever left in the engine, unmeasured. - The budget: four threads at 164 ns of wall per sample is about
656 core-ns — 14% of a 48-core server's budget at the operating point,
beside twelve receivers at 44 each; over ±5 kHz, 0.35× real time on
four threads. Comfortable. Bit-identity across thread counts is
pinned in
test_acq_core.cand the C suite runs under TSan.
12.9 What was measured (2026-09-06) — step 12, the tracker through the window¶
native/validation/tracker_through_window.c (make validate-c; its
--check is in the C suite): the hand-off receiver, seeded by the shipped
searcher's first hit on the same received blocks (the pool's own path,
§8.2), tracking one emitter from the shipped synth with its own window
(450 code-only symbols of every 4950 on the data clock: a 167 ms window in
a 1.83 s frame) at the operating point and the shipped awgn, fed one
epoch (0.2 ms) at a time, both lock flags read after every block, the
release clock at the design's 2 s. Once tracking with symbol lock held for
200 blocks, ten windows per trial, three trials per C/N0; per window the
fraction of blocks with each flag off, the longest both-off run, whether
lost fired, and the pull-in after the data resumes. Three
conditions: no Doppler, and SPEC's two worst cases through the shipped
doppler_channel, apart, since they do not coincide on a pass — the
offset (20 ppm of a 2.5 GHz carrier, 50 kHz, the chip clock dilated with
it) and the rate (500 Hz/s from zero).
| condition, C/N0 (Es/N0) | settled | window blocks | code lock off | symbol lock off | both off | release | pull-in after the window |
|---|---|---|---|---|---|---|---|
| static, 45 dB-Hz (10.7) | 3 of 3, 68 ms | 24 435 | 0 | 0 | 0 | never | none needed |
| static, 40 dB-Hz (5.7) | 3 of 3, 110–163 ms | 24 435 | 0 | 1.5% (two windows) | 0 | never | 6 ms |
| offset, 45 dB-Hz | 3 of 3, 76–81 ms | 24 435 | 0 | 0 | 0 | never | none needed |
| offset, 40 dB-Hz | 3 of 3, 0.30–2.8 s | 24 436 | 0 | 0 | 0 | never | none needed |
| rate, 45 dB-Hz | 3 of 3, 68–70 ms | 24 435 | 0 | 0 | 0 | never | none needed |
| rate, 40 dB-Hz | 3 of 3, 145–172 ms | 24 435 | 0 | 0 | 0 | never | none needed |
(Measured with the hand-over fix of #1249 below and the object's default refine length. The first run took the offset and the rate together and, before the fix, settled 2 of 3 at 45 dB-Hz and 0 of 3 at 40.)
What it settles:
- The window costs the tracker nothing. Code lock never drops in a
window, at either C/N0, with or without the ramp: the symbol-aided
detector's looks are as good on a constant symbol as on data. Symbol
lock holds too — the phase-lock statistic
cos(2φ)reads a constant symbol as locked, and the symbol clock coasts 167 ms without a transition and picks the data up with no pull-in at all (the one dip, at the floor, is the flag's own chatter: 3.6% of that frame's data blocks were off too, §12.3's 0.5% under a different seed). The release never fires. The expectation that the symbol flag "may drop and recover within its dwell" was pessimistic; nothing here needs a rule loosened or a detector fixed. - The pool must seed from the searcher, not from the truth. The
harness first seeded the hand-off receiver with the stimulus's own
chip phase; through the channel the received code is five chips late
(the resampler's delay), the code loop sat outside its pull-in and
nothing downstream locked.
acq_build_handoff()of the searcher's hit — measured on the received stream — is the seed, and with it the same trials lock in 70–280 ms. - What the ramp found was a hand-over defect, not a window one
(#1249). From
the searcher's seed at 20 ppm with the 500 Hz/s ramp on top (the first
run took SPEC's two worst cases together, which a pass never does) the
chain settled in 2 of 3 trials at 45 dB-Hz and 0 of 3 at 40, the
searching flavor alike. The
refine → track hand-over re-seeded the live chain with the seed's code
phase, rounded to whole code periods — zero net advance only on an
undilated clock. At 20 ppm the code runs 100 chips/s ahead: 1.2 chips
over the 12 ms refine at 45 dB-Hz, 5 over the 53 ms refine at 40, and a
Dll seeded 1.5 chips off never pulls in; the trial that lost at 45
dB-Hz was 1.45 chips off, the one that won 1.13. (The refine-stage
Dll's own tracked phase is not the answer either: at the floor it
wanders 13 chips over the same 53 ms.) The hand-over now advances the
seed's phase by the refined Doppler's dilation over the refine's whole
periods: 45 dB-Hz under the ramp settles 10 of 10 seeds in 70 ms, and
test_async_dsss_receiver_corepins it at SPEC's 20 ppm with the floor's refine length, where the old hand-over was 5 chips off. - What remains at the floor is the offset case, and it is the estimate,
not the window (#1252).
SPEC's two worst cases do not coincide — the largest Doppler is at the
horizon where the rate is nil, the largest rate at closest approach
where the Doppler is nil — so they are measured apart. The rate (500
Hz/s from zero) is no problem: 10 of 10 seeds settle at both C/N0s,
113–432 ms at the floor. The offset (50 kHz) settles 10 of 10 in 80 ms
at 45 dB-Hz but 9 of 10 at 40, in 0.3–3.6 s, several past the 2 s
release clock — the code loop locked from the first block, the carrier
slow to follow. The refine lands the Doppler 200–460 Hz low on every
seed with the floor's dwell (a bias, worse than the shortest dwell's
±20 Hz), and loop 1 —
bn0.04, 195 Hz at the code-period cadence, a measured pull-in bound of 60 Hz — then acquires it slowly or not at all, at 17 dB of loop SNR (15.5 after the squaring loss) where the rule wants 20. Narrowing the loop toward the rule is measured to cost the floor entirely (under the combined stress:bn0.02, 10 of 10 at 45 dB-Hz and 0 of 10 at 40; 0.01, 6 and 0), so the two rules conflict as built and the way out is the estimate the loop starts from. (This paragraph called the floor's dwell "18 blocks"; it is 7 — §12.10 measured the margin → dwell table. And §12.10 found the bias was the harness's own configuration, not the estimator's.)
12.10 What was measured (2026-09-06) — the refine's Doppler on its own¶
Harness: native/validation/refine_bias.c (validate_refine_bias;
--check is a ctest entry). A static capture from the shipped C stimulus
(dp_dsss_capture: Gold-1023 at 5 Mcps, 2700 sym/s async BPSK, a fixed
carrier offset of ±1500 Hz, AWGN from the C/N0), the hand-off receiver
seeded with the stimulus's chip phase and the truth plus a chosen error,
fed one epoch at a time until get_tracking() first reads 1, and
get_doppler_hz() read there. The error of that reading against the
truth, over 10 noise seeds per point: the mean is the bias, the standard
deviation the noise. Three axes — the seed's error (0, ±500, ±1100,
±2000 Hz) at the floor's dwell, the dwell through the design margin at a
fixed +1100 Hz, and the despread stream the refine estimates on:
refine_max_error_db sets the collection Dll's dumps per epoch through
dll_lookback_segments(), and the shipped default (0.5 dB, eleven dumps,
53.8 kHz) was compared with 100 dB (one dump, the 4.9 kHz epoch rate),
which objects/async_dsss_receiver.toml records as retired because a
stream below the 2700-baud data lobe's own width aliases any residual.
The finding before the measurement: every C harness and test of this
receiver — tracker_through_window.c, the hand-over test, all fourteen
create() calls in test_async_dsss_receiver_core.c — passed the retired
100 dB, positionally, and had since the default moved. The measurement
behind #1252 was taken on that stream. Both are now on 0.5 dB.
Bias vs the seed's error, at 45 dB-Hz, margin 19 dB (7 blocks, 42 ms), truth +1500 Hz (mean ± sd over 10 seeds; the −1500 Hz rows are alike):
| seed error | shipped (11 dumps) | retired (1 dump) |
|---|---|---|
| 0 | −11 ± 77 Hz | −17 ± 45 Hz |
| +500 | −16 ± 77 | +140 ± 47 |
| −500 | −10 ± 76 | −150 ± 61 |
| +1100 | −26 ± 77 | +351 ± 115 |
| −1100 | +14 ± 90 | −367 ± 125 |
| +2000 | −53 ± 79 | +752 ± 91 |
| −2000 | +26 ± 70 | −787 ± 99 |
Bias vs dwell, at +1100 Hz (the margin → dwell table is the same on
both streams; det_n_noncoh() sizes it from the derated C/N0 alone):
| margin | dwell | shipped | retired |
|---|---|---|---|
| 14 dB | 2 blocks | −31 ± 210 Hz | +341 ± 184 Hz |
| 17 | 4 | −17 ± 117 | +357 ± 189 |
| 19 | 7 | −26 ± 77 | +351 ± 115 |
| 22 | 18 | −21 ± 36 | +329 ± 87 |
| 25 | 55 | −20 ± 26 | +368 ± 95 |
- On the retired stream the bias is a third of the seed's error, in the seed's direction, at every dwell. ±0.35 × the error, the same with the truth at −1500 Hz, the same at 2 blocks and at 55: the refine removes two thirds of the residual and hands over the rest. That is the aliasing the manifest describes, not the template's edge and not the accumulation; and it is #1252's 200–460 Hz at the searcher's ~1.1 kHz seed. A two-pass refine on this stream would converge geometrically; the shipped stream does not need it.
- On the shipped stream the estimate is unbiased — within ±60 Hz at every seed error out to ±2000 Hz at the floor's dwell, inside loop 1's 60 Hz pull-in — and its noise falls with the dwell as an average should (77 Hz at 7 blocks, 36 at 18, 26 at 55); at 40 dB-Hz the 7-block point is −74 ± 152 Hz and 18 blocks give −59 ± 71. A residual −20 Hz survives long dwells; nothing downstream notices.
- The floor's dwell is 7 blocks, not 18. Margin 19 dB at 45 dB-Hz (the hand-over test's stand-in for 14 at 40) sizes 7; 18 needs 22 dB. The test's comment, #1252 and §12.9 all said 18.
- What the shipped stream did at the floor was the searcher's seed, not the refine (#1254, §12.11). With the window harness on 0.5 dB, the offset case at 45 dB-Hz settled 10 of 10 in 76–81 ms, as before; at 40 dB-Hz it settled 2 of 10 at the shipped margin, 4 at 17 dB, 6 at 19 and 6 at 22 (122 blocks, 0.7 s) — every failure a give-up, the refine's detector not firing within the dwell and the hand-over the unrefined seed, 1.1 kHz off, which loop 1 can never acquire. The same seeds failed at every dwell, so it was not the averaging; the frame's window was not it either (disabled: 1 of 10). On the retired stream the detector always fired, because the aliasing folds the whole data lobe into the band — which is why #1252 saw slow locks and not give-ups.
12.11 What was measured (2026-09-06) — the searcher's seed under the dilated clock¶
Where the give-up came from. The refine's detector statistic at the
floor's hand-over was noise-like on every failing seed (1.15–1.30 against
a 1.69 gate, the peak at a random lag), while at 45 dB-Hz it was 8.3 at
the right lag after two blocks. A static capture at 40 dB-Hz with an exact
seed fires 10 of 10 at 7 blocks, so two variables separated the stimuli:
the seed's chip phase and the dilation. The seed's phase alone, on the
static capture (validate_refine_bias with the seed offset, 40 dB-Hz, the
shipped stream): 0 and 0.25 chip off fire 10 of 10; 0.5 chip, 5 of 10;
0.75 chip and beyond, none — the refine Dll's pull-in is under half a
chip, and from a chip off it sits where it was seeded (its own lock
statistic reading 5–8 regardless). The searcher's seed's error through
the channel, measured at 45 dB-Hz where the live chain converges to the
truth: +0.05 chip on every seed; at 40 dB-Hz, on the one seed that
eventually settled, +0.91 chip.
Why: the dwell's centroid. The continuous searcher decides a hit on a
non-coherent sum over n_noncoh epochs — 15 at 45 dB-Hz (3.1 ms), 88 at
40 (18 ms) — and the code phase it reports is that sum's peak, the phase
at the middle of the dwell. The hand-off applies it at the dwell's
end. Under 20 ppm the code moves 100 chips/s, so the seed is late by
the drift over half the dwell: 0.15 chip at 45 dB-Hz (inside the
pull-in), 0.9 at the floor (past it, and exactly the +0.91 measured). The
same class as #1249, one hand-over earlier; and it is why the retired
1-dump refine "worked" — its Dll wanders chips across the code and crosses
the truth, and its aliasing folds the whole lobe into the band, so its
detector fired on a seed a chip off and handed over a biased estimate
instead of none.
The fix. acq_build_handoff() takes the RF carrier (0.0 = no
coupling) and advances the hit's phase by doppler_hz_est / carrier_freq_hz × n_noncoh × coherent_bins × code_len / 2 chips — the
drift over half the dwell — folded with dp_fmod_pos(). The searching
receiver passes its own carrier_freq_hz; the hand-off flavor's holder
passes the same carrier it gives the receiver. The formula and its sign
are pinned in test_acq_core (positive Doppler, fast chip clock,
advances). Measured after it, the window harness's offset case at 40
dB-Hz on the shipped stream and margin:
| before | after | |
|---|---|---|
| seed error (chips) | +0.91 | +0.16, −0.30, +0.29, −0.06 |
| settled, of 10 | 2 | 10, in 114–205 ms |
| 45 dB-Hz, of 10 | 10, 76–81 ms | 10, unchanged |
test_async_dsss_receiver_core's hand-over test now runs both operating
points — 45 dB-Hz with margin 19 and the 40 dB-Hz floor with the shipped
14 — and asks every seed to lock the code and decode; with the carrier
zeroed at the receiver's call site the floor's seeds go red and the 45
dB-Hz ones survive, which is why the floor is in the test. The rate case
and the static condition are unchanged.
What this left open. The block-coherent searcher (§12.7) had been
measured with the Doppler rate but never through a dilated chip clock:
at 20 ppm its D = 154 epochs (31 ms) coherent sum spans 3 chips of code
drift — §12.12 measured it and gave the engine the code-rate hypothesis
(#1256); the carrier
now lives on the engine (set_carrier_freq_hz), one declaration for the
hand-off's advance and the block's alignment. And
doppler.dsss.handoff.dll_init_chip_from_acq, the Python lag → phase
helper for a hand-built Acquisition → Dll chain, restates
acq_build_handoff()'s fold and does not carry the advance —
#1257.
12.12 What was measured (2026-09-06) — the block-coherent searcher under the dilated clock¶
Harness: the dilated section of native/validation/acq_block_coherent.c
(--check on ctest). One aligned block of a clean emitter at SPEC's
Doppler, 20 ppm of 2.5 GHz = 50 kHz, three ways: the synth's own carrier
offset with the code standing still; the synth at baseband through the
shipped doppler_channel, the chips dilated with it (100 chips/s at
5 Mcps); and the same, the engine told the carrier. Per depth D of 1, 16
and the window's 154: the peak against the gate, conc, the peak's
width along the code axis, and the hand-off's chip phase raw and with
the half-dwell advance. Then the depth's realized Pd, 20 trials per
C/N0, noise from the shipped awgn after the channel.
| D | code | peak/gate | conc |
width | drift/block |
|---|---|---|---|---|---|
| 1 | still | 40 / 4.7 | 0.97 | 0.5 chip | 0 |
| 1 | dilated | 34 / 4.7 | 0.98 | 1.0 | 0.02 chip |
| 16 | still | 273 / 5.1 | 0.86 | 0.5 | 0 |
| 16 | dilated | 256 / 5.1 | 0.87 | 0.5 | 0.33 |
| 16 | dilated, carrier told | 262 / 5.1 | 0.86 | 0.5 | 0.33 |
| 154 | still | 2065 / 5.4 | 0.85 | 0.5 | 0 |
| 154 | dilated | 459 / 5.4 | 0.72 | 3.0 | 3.15 |
| 154 | dilated, carrier told | 1462 / 5.4 | 0.85 | 1.0 | 3.15 |
| D = 154, realized Pd (mean peak/gate) | 34 dB-Hz | 38 dB-Hz | 42 dB-Hz |
|---|---|---|---|
| still | 1.00 (1.7) | 1.00 (2.7) | 1.00 (4.2) |
| dilated | 0.00 (0.9) | 0.65 (1.0) | 1.00 (1.4) |
| dilated, carrier told | 1.00 (1.5) | 1.00 (2.5) | 1.00 (3.9) |
- Told nothing, the depth is gone at 20 ppm. Across a D = 154 block the code drifts 3.15 chips; the coherent sum at any fixed lag sees the emitter for a third of the block, so the peak is 13 dB down and three chips wide, and at 34 dB-Hz — where §12.7's aligned floor detects every block — it detects none. D = 16 (a third of a chip) loses 0.6 dB; a single epoch nothing. The pool's searcher, as measured in §12.7, had this loss hidden in it.
- The code-rate hypothesis is the tile's own frequency. A window
tile at
signed_rbins offs/nximplies a chip clock dilated byf_tile / carrier,signed_r × fs / carriersamples of drift per epoch (0.041 at 50 kHz).acq_tile_epochshifts each epoch's correlation along the code axis to the block's middle — a linear phase over the signed frequency index on the tile's product before its inverse transform, exact for a fractional shift, one complex multiply per bin — so the slow-time transform sums a standing peak. The sign was measured, not derived: the other one doubles the smear (6 chips, 247). No new search dimension: the hypothesis rides the tile. - Told the carrier, the block reads as a still one. 1462 against
2065 is 3.0 dB, of which 1.4 dB is the channel's own resampler
(the D = 1 row shows it, and the peak's width of a chip instead of
half), so the alignment leaves about 1.6 dB — the emitter sits a
quarter row off its tile's centre, and a tile's hypothesis is one
number for its ±2.4 kHz.
concis the still block's; Pd at 34 dB-Hz is 20 of 20 at 1.5× the gate against the still 1.7×. - The hand-off is the block's end within a tenth of a chip. With the
epochs aligned to the block's middle the peak IS the middle (raw
385.00 against the D = 1 start of 383.50 plus 1.58), and the
half-dwell advance of #1254 —
coherent_binswas already in its formula — lands 386.57 against a truth of 386.65 at the block's end. Told nothing, the smeared peak's argmax wandered (385.50) and the advanced seed was 0.4 chip off, inside the refine's pull-in only by luck. - One declaration. The carrier moved from
acq_build_handoff()'s argument (#1258, the previous PR) to the engine:acq_set_carrier_freq_hz(), a jm method with a read-back property, drives both the block's alignment and the hand-off's advance. Config, not running state: it is not in the blob, so a resumed engine wants it set again by its holder. The searching receiver sets it from its owncarrier_freq_hz; the hand-off flavor's holder sets it on the searcher it seeds from, as the window harness does.
12.13 What was built (2026-09-06) — the pool, §8.2¶
native/src/async_dsss_pool/ (AsyncDsssPool, Python glue only): one
searcher, n_slots hand-off receivers created idle, the assigned table,
the event log by attachment, exactly the object of §8.2 — and nothing
about the waveform or the population baked in: 28 create parameters, every
default the operating point of §6.1, the searcher's and the receivers' own
passed through untouched. The carrier is told to the searcher as well as
the receivers (§12.11, §12.12), and the table's rows advance by the same
dilation while a receiver still refines, so the exclusion zone is keyed on
where the emitter IS. The composition serializes as a whole (its own
counters and the table, then the searcher's blob and every receiver's).
What test_async_dsss_pool_core pins, on the shipped C stimulus (one
capture per emitter, summed; the continuous engine at D = 1 over ±6 kHz;
pfa 1e-3; a 0.3 s release interval): one emitter takes exactly one slot
however many dwells hit it — the zone drops its own — with the seed at
its row and within the searcher's half-chip cell; it tracks with code
lock, the live Doppler converged within 100 Hz, the symbols readable by
slot; off the air it is lost and released, and back on the air it is a
new detection into a free slot; two emitters two rows apart hold one slot
each, both locked, with nothing dropped; a one-slot pool holds one of them
and counts the other dropped every dwell; every counted transition
reaches the log, seeded → tracking → lost → released in order; across
threads the assignments, the counts and every receiver's symbols are
bit-identical; and a mid-stream split resumes bit for bit in a fresh pool,
the envelope and a foreign slot count rejected.
Two things the build found:
- The searcher's false alarms are part of the lifecycle. At pfa 1e-3
a noise peak seeds a free slot every few hundred milliseconds, the
receiver refines to nothing, reports tracking on noise, and is
released one interval later: a false alarm costs a slot for
lost_confirm_s, which is the release headroom of §8.2's twelve slots for ten emitters. Every expectation is therefore about the emitter's slot, never an exact count of slots. - An unlocked carrier loop free-runs, and the table must not key on
it (#1261,
fixed). With the refine's shortest dwell (2 blocks at 47 dB-Hz and
the shipped margin, ±200 Hz) a hand-over past loop 1's pull-in
(measured: 1847 Hz for a 1500 Hz emitter) leaves the receiver
code-locked — the code loop is non-coherent — with the carrier
unlocked on every block (symbol lock down,
lock_metricat zero) and loop 1 wandering 800 Hz in a second. That is not loop 2 decoding, as first read: it is a degraded receiver, anddegradeis the event it logs. At D = 1 the zone is 4.9 kHz wide and nothing notices; at the pool's D = 154 it is 31.7 Hz, and a row keyed on the wander would let the searcher's next hit on the same emitter look new. Two fixes: the receiver'sstatus().doppler_hzis the whole carrier estimate — loop 1 plus what loop 2 has taken up beyond it, the sumconfigure_chain_raw()already re-seeds from — and the pool refreshes a row's Doppler only whilelockedholds and its chip phase only whilecode_lockeddoes, keeping the last locked value (the seed's row, until the first lock) otherwise.test_async_dsss_pool_corepins it on the reproducing stimulus: the status wanders 843 Hz off with the carrier unlocked on 4858 of 4858 tracking blocks and the row stays the seed's; at pfa 1e-3 the same emitter locks and the row follows within 100 Hz.
Next: the lifecycle soak (§12 step 7) — the shipped synth with the window, the block-coherent searcher at D = 154, several emitters at random Dopplers through the channel, arrivals and departures — is the measurement that certifies the pool. Done, §12.14.
12.14 What was measured (2026-09-06) — step 7, the lifecycle soak¶
native/validation/async_dsss_pool_soak.c (make validate-c; its
--check is in the C suite): per emitter one shipped continuous-DSSS
synth with the window (450 of 4950 symbols, Gold-1023 at 5 Mcps, 2700
sym/s PRBS BPSK) through the shipped doppler_channel at its own Doppler
drawn within ±20 ppm of 2.5 GHz, no rate, from a random burn-in of up to a
frame; visibility a gain of 1 or 0 at the sum; the shipped awgn at 45 and
40 dB-Hz. One AsyncDsssPool at the operating point — code_only_epochs
813 so D = 154 (31.7 Hz rows), 500 Hz/s, ±50 kHz, max_peaks 16, twelve
slots, the carrier told, a 2 s release interval, the machine's 20
threads — fed one epoch at a time with the event log attached. Emitter 0
always on; nine more with on-times uniform in 15–30 s and off-times in
4–8 s (the design's 5–15 minutes scaled thirty-fold; the pool's maximum
on-air time 35 s, so the always-on emitter is released for its on-time and
re-acquired). An emitter's slot is the one whose seed is at its code phase
within a chip of the synth's own clock through the channel's documented
mapping (k(1+d) − delay, plus the burn-in) and within one native tile of
its Doppler — a tile, not a row, for the first reason below. The --check
is two emitters for 16 s at 45 dB-Hz: the always-on one released for its
on-time and re-acquired, the other leaving, released, returning and
re-acquired.
What the soak found before it could run — the zone (fixed). The first
run filled all twelve slots in 0.4 s with one emitter on the air. A
tracked emitter's data blocks are §12.7's smeared copy — at 45 dB-Hz
still over the gate, C/N0 estimated at 30–33 dB-Hz, at the emitter's own
code phase within 0.3 chip and hundreds of Hz off in Doppler, a different
row every block — and the zone of §7.1, one row by one chip, is the
width of one emitter's main lobe, 31.7 Hz at this depth. Every such hit
looked new and seeded a fresh receiver onto the same emitter (each pulled
in and reported tracking); the true aligned hit, when its window came,
found no slot. The searcher's list carries no per-peak concentration to
tell the copy from a whole emitter, and a strong emitter's coherent tile
sidelobes at its own phase would read concentrated anyway; so the pool's
zone is now the code axis alone — a hit within one chip of a live
row's code phase is that emitter's own at any Doppler. Pinned in
test_async_dsss_pool_core with the cost stated: a second emitter within
a chip of a live one is not seen until the first leaves (a pair the
surface could not tell apart within a tile in any case, and the
searcher's own twin rule already holds a same-phase peak at any tile as
suspect). After the fix every seed of the two-minute runs was an
emitter's own or a noise false alarm; none a duplicate of a healthy
receiver's emitter.
The run, 43 stints per C/N0 (38 scored; the five cut by the run's end before the design's own acquisition bound are not):
| 45 dB-Hz | 40 dB-Hz | |
|---|---|---|
| stints missed | 0 | 0 |
| arrival → held, min / mean / max | 0.00 / 0.04 / 0.17 s | 0.00 / 0.24 / 1.24 s |
| arrival → tracking, mean / max | 0.05 / 0.18 s | 0.29 / 1.30 s |
| seed error, worst | 1296 Hz, 0.30 chip | 4877 Hz, 0.29 chip |
| held, of on-air blocks after first tracking | 0.9965 | 0.9994 |
| tracking with code lock, of held blocks | 0.9908 | 0.9957 |
| departure → release, min / mean / max | 2.02 / 3.07 / 6.81 s (24) | 2.01 / 2.71 / 5.44 s (30) |
| released later than the interval + 0.5 s, or not at all | 23 of 38 | 18 of 38 |
| stints re-locked by their own receiver on return | 7 | 1 |
false releases (lost while on the air) |
1 | 1 |
| double assignments (two receivers code-locked on one emitter) | 1, for 3.0 s | 0 |
| seeds matching no emitter | 1 | 11 |
| hits dropped for want of a slot | 2 | 0 |
| most slots assigned | 12 | 12 |
| log: seeded / tracking / degrade / lost / released (lost + on-time) | 49 / 49 / 47 / 33 / 33 + 9 | 57 / 57 / 255 / 43 / 43 + 6 |
- Nothing is missed and the assignment is fast. At 45 dB-Hz the emitter's own data-block copy seeds it in the first block or two (0.04 s mean, 0.17 s worst) — the window is not waited for; a receiver seeded from a copy hundreds of Hz off pulls in through the refine's range. At 40 dB-Hz the copy is under the gate more often than not and the window matters: 0.24 s mean, 1.24 s worst, under the frame the design allows. A seed at the tile's edge (4.9 kHz) still pulled in.
- The release fires late — the open finding. After a departure both
flags drop within milliseconds, exactly as §12.3 measured, and then
the code flag comes back for a block on noise about once a second
(103 restarts of the release clock in the 45 dB-Hz run, 63 at 40,
every one
code 1 sym 0);adr_release_clock()restarts on either flag, so the interval runs from the last flicker and the release comes at 2.0–6.8 s for a 2 s interval, more than half of them past the interval plus half a second, and seven emitters at 45 dB-Hz came back inside their own receiver's overrun interval and were re-locked by it (the recovered assignment of §12.3, only unintended). §12.3's harness watched 1.5 s after switch-off and could not see it. The mechanism, read and not yet measured on its own: the Dll's lock look is the max over the symbol-scale windows of §3.7, and its threshold is sized for one cell at pfa 1e-3 with two verifies, so the false-lock rate on noise is the max's, not the cell's. The fix is the detector's, proven first inasync_dsss_receiver_release.cwith a longer watch on noise (#1264). Fixed, §12.15: the looks overlapped — a decision read the same noisentimes; the threshold was right. - Two receiver failures in 76 stints. At each C/N0 one receiver lost
a healthy emitter and was released
lost— a false release; at 45 dB-Hz that receiver's carrier loop wandered 1.3 then 2.7 kHz off while its code flag stayed up and its Dll walked 150 chips off the code under the wrong aid, so the emitter's next aligned hit was a new seed and two receivers reported code lock on one emitter for 3 s until the first was released. The second seed is the recovery §10 describes; the first receiver's code flag on a walked-off code is the same false-lock finding as above, seen with the emitter present (#1265; fixed, §12.16 — the hand-over, not the receiver). - The pool holds. Never past twelve; at 40 dB-Hz eleven noise seeds
(0.09 per second against the configured 0.03 and #1064's 0.06), each
refining to nothing, "tracking" with both flags down and released an
interval later, as §12.13 said; two hits dropped at 45 dB-Hz while
twelve slots held ten emitters and two departed ones inside their
overrun intervals — this soak's churn is a departure every few
seconds against the design's one a minute, and the headroom is the
design's. The two hundred and fifty-five
degradeevents at 40 dB-Hz are the flags' chatter at the floor, none of it a release.
12.15 What was measured (2026-09-06) — the code flag on noise, #1264¶
The mechanism was not the one §12.14 read. The Dll's symbol-aided
detector (§3.7) keeps Q = ⌈P⌉ timing hypotheses, eight here, each an
EMA of its own windows' power, and takes its looks from the best one. The
guess was that a threshold sized for one cell could not carry the max of
eight. Measured on pure noise with the Dll alone, configured exactly as
the receiver configures it (pfa 1e-3, n_looks from det_n_noncoh at
the design C/N0), the max costs almost nothing — the mean statistic is 8%
above √(2n) — and the tail is what breaks: 1.7e-2 exceedances per
decision at 45 dB-Hz and 4.2e-2 at 40 for a configured 1e-3, 0.65 and 0.5
false locks per second, against 1.9e-3 and none for the unaided
detector on the same noise. The cause is the looks, not the threshold. On
a signal the best hypothesis holds and its windows are a symbol apart;
on noise it flips between neighbours whose windows share five of six
partials, and a decision's three looks then read the same noise three
times — a χ² of two degrees of freedom scaled by three against a gate
sized for six, whose tail at the gate is 2.4e-2. The receiver's rule
restarts its release clock on either flag (§10), so every such lock cost
an interval.
The fix is one comparison in aid_look(): a window that overlaps the
last look's is not a look (aid_last_end, running state, blob v10).
Nothing changes with a signal present. On noise: 2.1e-3 per decision
at 45 dB-Hz, 2.5e-3 at 40, 2.9e-4 at pfa 1e-4, and no lock in twenty
seconds at any of them — the unaided detector's own realized rate,
which is #1064's factor of two over the configured one. Pinned twice:
test_dll_core 6b′ feeds the aided detector forty thousand epochs of
noise and asks the exceedance rate within four times pfa and no lock
(without the guard: 1.6e-2 and six locks);
validate_async_dsss_receiver_release --check now watches eight seconds
of noise after the switch-off — four release intervals — and asks that
neither flag return (without the guard: 6 and 11 returns in the two
trials). §12.3's table gains the row, 30 trials per C/N0:
| C/N0 | over 240 s of noise after the switch-off | code lock returned | symbol lock returned |
|---|---|---|---|
| 45 dB-Hz | 8 s × 30 | once (0.004 per s) | never |
| 40 dB-Hz | 8 s × 30 | never | never |
The healthy-signal rows are unchanged: code lock off in no block of the on-time at either C/N0, drop time 3.5 and 10.8 ms after a switch-off.
The soak, re-run on it (§12.14's stimulus and scoring, ten emitters, 120 s at each C/N0): departure → release 2.02 / 2.03 / 2.04 s at 45 dB-Hz and 2.01 / 2.02 / 2.04 at 40 (was 2.0–6.8 s), no release late, no false release, no emitter re-locked by a receiver that should have let it go, one restart of a release clock in four minutes of noise (was 166), and the check pins the interval plus half a second on every host again. Nothing else moved: nothing missed, arrival → held 0.05 s mean at 45 dB-Hz and 0.30 at 40, tracking with code lock on 99.6% and 99.7% of held blocks. One event is left, and it is #1265's: at 45 dB-Hz one receiver still walks off its emitter mid-stint while its code flag holds, a second is seeded, and both report code lock for 1.6 s — understood and fixed in §12.16.
12.16 What was measured (2026-09-06) — the hand-over that never pulled in, #1265¶
Interference first, and ruled out. The soak's trace now logs every crossing of two on-air emitters' code phases within two chips, with their Doppler difference and relative chip rate — the one way one emitter's full peak reaches another's prompt correlator. Around the failing stint (emitter 1's third, 55.7–77.5 s at 45 dB-Hz) every crossing was fast, 30 to 127 chips per second and under 0.3 s, with the other emitter at least 6 kHz away, none at the seed's moment; and no slow crossing (under 5 chips per second) happened anywhere in four minutes at either C/N0.
The hand-over. The event log had the number: seeded from a data-block
hit +594 Hz off, the receiver reported tracking 12 ms later with its
carrier estimate −506 Hz off the truth; symbol lock came and went,
the estimate wandered to −1.4 kHz by 61 s and −2.7 kHz by 75 s, while the
code loop stayed on the emitter's phase throughout (its chip rate was
emitter 1's 37.3 chips per second), and a second receiver was seeded
when a code-flag dip left the row's phase stale. §12.10 had measured the
refine at the pool's shipped margin — 14 dB at 45 dB-Hz sizes a
two-block dwell — at −31 ± 210 Hz: −506 is a 2.4σ draw, and one
such draw in 49 hand-overs is the 1.6% it predicts. The dwell is sized
by det_n_noncoh for detection at the derated C/N0, which needs fewer
blocks the higher the C/N0, while the estimate's noise the tracking
chain has to pull in from — a few hundred Hz — does not shrink with it.
Confirmed by the wrong fix. The soak with --refine-margin 22 (18
blocks, 36 Hz): at 45 dB-Hz the double is gone and every stint holds
symbol lock (0.9925 of held blocks), tracking 0.14 s later. At 40 dB-Hz
the same margin derates to 18 dB-Hz and sizes one to four seconds of
dwell, during which the row's phase — advanced on the seed's Doppler
error, hundreds of Hz for a data-block seed, a chip a second — drifts
out of the zone; the next hit seeds a second receiver and 661 545
blocks read two receivers code-locked on one emitter. The lever is a
floor on the dwell in blocks, not a detection margin.
The fix: refine_min_blocks, default 7 (42 ms, §12.10's floor
dwell, 77 Hz), applied when the refine chain is built and clamped by the
give-up cap; set_refine_min_blocks() on the receiver and on the pool.
Pinned in test_async_dsss_receiver_core: the default receiver's dwell
at 45 dB-Hz reads 7, 2 with the floor removed (proven red without it),
the cap with a floor above it. The pool's #1261 reproduction removes the
floor explicitly, because with it the wander it pins never happens.
The soak on it, ten emitters, 120 s at each C/N0:
| 45 dB-Hz | 40 dB-Hz | |
|---|---|---|
| double assignments | 0 (was 1, 1.6 s) | 0 |
| false releases | 0 | 0 |
| tracking with symbol lock, of held blocks | 0.9968 (was 0.9910) | 0.9841 |
| arrival → tracking, mean / max | 0.09 / 0.21 s (was 0.06 / 0.18) | 0.35 / 1.30 s (unchanged) |
| departure → release, min / mean / max | 2.02 / 2.09 / 3.82 s | 2.01 / 2.02 / 2.04 s |
| stints missed | 0 | 0 |
The one 3.82 s release is §12.15's residual: the code flag still returns on noise at 0.004 per second, and one return inside the interval restarts the clock once — the rule's own worst case at that rate, two intervals, which is what the soak now bounds (two returns inside one interval is a 1e-4 event per departure). The sweep is green at both C/N0s.
13. What this page does not settle¶
Of §5.4's open questions, one is still open: the frame epoch (3) — a
detecting block locates the window to within D epochs, and the exact
boundary is the tracker's to find. Everything else is answered: the
window and the cadence (4), the holder (5, the pool of §8.2) and the
spread (7, 10 dB — branch one). Of what the receiver lacked, the
hand-off-mode constructor, seed(), the lost state, the idle it resets
to and the status record are shipped (§4.1); still implementation, not
design: the stimulus with the window, the block-coherent searcher, the
pool (§12 steps 10–14).
Of the numbers, the budget (§12.1), the floor (§12.2), the release
(§12.3) and the knee (§12.6) are measured on the shipped objects at
D = 1, and the coherent blocks move three of them: the floor, the
knee and the searcher's cost are re-measured by §12 steps 11 and 14
before anything is priced on them. Two things the measurements raised
and this page only names: the peak list's same-code-phase rule (§7.1)
passes a strong emitter's persistent sidelobes under long non-coherent
integration (§12.6, #1191), and the false-release rate of the
both-flags-down rule is bounded only over half a minute, not the
15-minute maximum on-time (§6.1) it must hold for — and, from the soak
(§12.14, #1264), the code flag's false re-locks on noise made the rule
fire one to three intervals late — fixed at the Dll's looks (§12.15),
leaving one return per 240 s that restarts the clock once in a hundred
departures; and the hand-over that never pulled in (#1265) — fixed at
the refine's dwell (§12.16).
14. See also¶
burst-bank.md— the burst bank this page's continuous case was split from; §9–§10 there for the fold and the parallelism measurements §5 and §6 lean on.coarse-channel.md— the channel as an object, which is what carries the searcher.dsss-acquisition.md— the acquisition engine under §2 and §6: the tiling, the CFAR vocabulary (dsss-acquisition.md§9.1, which §7 uses), the roadmap.dsss-burst-receiver.md— the burst chain, which shares §2.2'sDetectionEvent.- AsyncDsssReceiver: the SPEC waveform and Streaming Async Despreader — the gallery demonstrations of §4 and §3.
