BurstCapture: acquisition's output, turned into bursts¶
Status: shipped, as doppler.acquire.BurstCapture and its file-backed
view PersistentBurstCapture; DsssBurstReceiver composes it. §1–§6 are the
rationale written before it was built, and describe the receiver as it was
then: where they say "today's receiver", read "the receiver before
BurstCapture". The certification is its
validation report.
Scope: a C object — BurstCapture — that sits between a detector
and whatever consumes a burst. It searches, refines, retains, and emits the
burst's SAMPLES. It stops there.
Decision record:
dsss-burst-receiver.md §11 — why this is being
built, including the count it was first declined on.
Tracked as gh-1166.
1. Why — a shipped composition that dead-ends¶
doppler.dsss.orchestrator.Acquirer is a second composition over
BurstAcquisition: K coarse-Doppler channels, each DDC → BurstAcquisition,
fanned across a thread pool, with get_state/set_state per channel so the
bank ships to other pods. It feeds acquisition continuously, which is the
right way to drive it. Its output is Detection records — a Doppler and a
code phase — and there it stops.
A user of that bank who wants the burst has two routes, and both are bad.
- Continue feeding continuously. Anchors are stream-absolute and there is
no sweep — and §3.1 bites in full:
acq_result_t::code_phasemeasuresburst_start mod code_binsexactly, so it fixes the alignment WITHIN a code period and never says WHICH repetition. A burst has a frame that begins in one specific repetition. Resolving that is a refine stage, a history ring to reach back into, a retention rule, and a claim rule — and §3.2 makes getting it wrong a cliff: a window one period out decodes at half the payload in error, which is noise, not degradation. - Sweep acquisition in
reset()-per-dwell dwells. That makespos + code_phasea real position, so refine is not needed — paid for withPRE_LEN/ACQ_HOPoverlapping FFT dwells over the whole stream, two window constants nothing derives, a hand-rolled cluster-and-keep-strongest rule, aconfigure_search_rawpin without which the auto-sizer makes a decision impossible, and the entire capture held in RAM to slice from. That last one means it does not stream.
So the ring, the refine and the claim rule exist exactly once, inside
DsssBurstReceiver — an object that also demodulates. The capability has no
home a caller can reach.
1.1 The criterion¶
A caller holding a detector and a sample stream can get the burst's samples, aligned and never late, without writing epoch arithmetic.
"Never late" is the load-bearing half, and it is §3.2's: the hand-off's obligation is not to be exact, it is to never be late and to say how early it is. A capture that emits a window starting after the preamble has destroyed the burst.
2. Use cases¶
| caller | supplies | wants back |
|---|---|---|
DsssBurstReceiver |
a sample stream | aligned windows to hand to BurstDemod, with no copy |
an Acquirer channel |
a down-mixed sub-band | the bursts that channel found, in that channel's own timebase |
| a recorder / corpus builder | a stream and a burst length | every burst, to disk, with the event that names where it was |
| a second consumer | the same stream | windows to fan out to a despreader as well as a demodulator |
The first row is the demanding one: it is an existing certified object whose behaviour must not change, so the capture has to be reachable without a second copy of every burst.
3. What it owns, and what it does not¶
| owns | does not own |
|---|---|
| the acquisition engine (§4) | the frame — what the symbols after the sync mean |
| the history ring, its span, and the retention rule | demodulation, despreading, or any decision |
| refine: the period-resolving correlation | a CRC, an outer code, a randomiser |
| CLAIM: which detections name one burst | what a burst is FOR |
| the suppression window that follows an emitted burst | telemetry about a consumer's estimates |
burst_len is a construction parameter, and that is the point of the
split. dp_acq_create_burst() takes search parameters only and has no notion of
how long a burst is; teaching acquisition frame geometry to support refine
would be wrong. For a capture the burst length IS the parameter, because it
is what gets captured — and it is what sizes the retention.
4. It owns the engine — a correctness choice, not a convenience¶
gh-1166 drew this
object as push(samples, acq_results): the caller brings their own detector.
That face is rejected on one invariant.
acq_result_t::samples_consumedis stream-absolute only for an engine fed continuously and neverreset(), in the caller's own sample coordinates.
An object taking foreign results has to require that and cannot check it.
The failure is not a slightly wrong window: refine searches the wrong
repetition and §3.2's cliff returns noise. push() defining the coordinate
system makes the invariant internal and unbreakable.
What that costs is a caller with a foreign detector, and the loss is smaller
than it looks — but the first version of this paragraph overstated it, and
the correction is worth keeping. It said "the engine stays reachable as a
child for read-backs and configure_search_raw()". It is not reachable:
there is no child accessor and there is not going to be one. What is true
instead:
BurstAcquisitionis public. A caller who wants the search's own face constructs one with the same parameters and gets all 22 of its read-backs. Nothing was taken away by this object existing, which is what makes the swallowing survivable.- The search this capture will do is visible FROM the capture —
doppler_bins,n_noncoh,code_bins,doppler_span_hz, both detection gates,straddle_loss,pd_predictedandunderpowered(§9.5). Forwarded, not re-derived. - A consumer receiving detections over a transport needs the WINDOW as well, which is what §1.1's sufficiency criterion already requires the event to carry.
The version that shipped first forwarded ONE of the engine's 27 read-backs,
had no noise_mode at all, and let the under-powered warning vanish. An
object that swallows another and republishes a twenty-seventh of its face is
a split in the wrong place — and the fix was not to move the split but to
forward what a capture caller actually needs.
4.1 Any repeated preamble, and one replica of it¶
dp_burst_capture_create() takes the preamble as its samples: one period
of any repeated complex sequence (a chirp, Zadoff-Chu, shaped PSK), at fs
(#1470). A PN code is one
such preamble -- its chips by bin_to_nrz(), each held spc samples, at
fs = chip_rate · spc -- and DsssBurstReceiver builds it that way from the
code it is given. The engine is dp_acq_create_burst(). Nothing downstream of
it reads chips: the ring, CLAIM, refine's Doppler cells and the window all
work in periods and samples.
Refine correlates each candidate position against the engine's own reference row, the preamble at unit RMS. It used to expand PN chips a second time, by its own rule. That copy could drift from the one acquisition searches with, and it could not describe anything but a code. So the engine is built first, and the capture reads its reference.
A preamble repeated every P samples is sampled once a period in slow time,
so its Doppler is defined only modulo 1/P: +span and -span are one
frequency. A burst near the band edge can be reported at the other edge. The
tests read it that way. Refine cannot: it mixes within each period too,
where the two edges are a whole span apart, so it wraps every Doppler cell
into the native span first. Unwrapped, every even depth centred its cells on
the wrong alias (doppler#1502).
5. The surface, and the two faces of one mechanism¶
dp_burst_capture_state_t *dp_burst_capture_create (
const float _Complex *preamble, size_t preamble_len, size_t burst_len,
size_t reps, double fs, double cn0_dbhz, double doppler_uncertainty,
double pfa, double pd, int noise_mode, double doppler_rate,
size_t rate_hypotheses);
size_t dp_burst_capture_push (dp_burst_capture_state_t *, const float complex *x,
size_t x_len, float complex *out, size_t max_out);
size_t dp_burst_capture_events (dp_burst_capture_state_t *, size_t n,
burst_capture_event_t *out, size_t max_out);
/* The C consumer's face: borrow, do not copy. */
size_t dp_burst_capture_ready (const dp_burst_capture_state_t *);
const float complex *dp_burst_capture_window (const dp_burst_capture_state_t *,
size_t i);
push() returns windows concatenated — burst i at i*burst_len — with
events() giving each its own record. Same shape, and the same reasoning, as
DsssBurstReceiver's §8.2/§8.3: returning a list
means a caller cannot fail to drain, and two different output lengths need
two calls because the binding generator emits one variable-length output per
method.
5.1 A window is COPIED out of the ring, not borrowed from it¶
The receiver hands burst_demod a pointer straight into the double-mapped
ring — no copy, no seam. The obvious move is for the capture to keep doing
that and let its consumer borrow.
It does not, and the reason is lifetime. A borrow would have to stay valid
until the caller was done with it, so the retention rule would have to hold
every window emitted by the current call — and one push can complete
several bursts, while the ring's capacity is derived as
2 · (refine_span + burst_len). Retaining an unbounded number of complete
bursts is not something that capacity promises.
So each emitted window is copied once into a grow-on-demand scratch, and both
faces read from there: push() copies scratch → the caller's buffer, and
dp_burst_capture_window() borrows the scratch. A composing object therefore
pays one memcpy per burst and no more.
That is a real cost and it is named here rather than buried: it is one copy per BURST, not per sample, which is a different order of magnitude from the whole-stream copy §6.1 weighs. Whether it is visible at all is §8's first question.
5.2 A partial window is never returned¶
push() truncates to a whole number of bursts. A caller handed 3.5 bursts
cannot tell where the truncation fell, and half a burst is not a burst.
6. The one behaviour that changes¶
DsssBurstReceiver arms its suppression window — the span after a burst in
which detections are the payload firing against the acquisition code, not new
bursts — when dp_burst_demod_demod() returns a non-zero bit count.
§10.3 settled that: what arms it is the burst
having demodulated, which is a physical fact that object owns.
A capture does not demodulate. It arms the window when it emits — refine resolved a start here and the whole span was handed out.
These differ in exactly one case: a full window on which the consumer's
demodulation returns nothing. In burst_demod that is reachable two ways —
an allocation failure, or fewer despread symbols than the frame needs — and
the second cannot happen for a window burst_len long by construction. So
the divergence is confined to allocation failure, where today's receiver
leaves the span unsuppressed and re-detects inside a burst it failed to
demodulate.
It is written down here because it is a behaviour change to a certified object and must be pinned by a test, not argued about. Which behaviour is better is not obvious — re-detecting inside a failed burst produces a second attempt whose refined start lands inside the payload, which is not a recovery — and the honest answer is that no measurement has been asked for one.
7. What DsssBurstReceiver keeps¶
The receiver is not being thinned for its own sake; it keeps everything that is about a decision rather than about finding a burst.
moves to BurstCapture |
stays in DsssBurstReceiver |
|---|---|
the ring, its sizing, chunk_max |
driving BurstDemod, re-seeded per burst |
| refine and its scratch | frame_syms and the payload rows |
| the retention/trim rule | llrs() and the soft bits |
| CLAIM and the detection queue | the demodulator's own estimates |
| the suppression window | events() rows built from both halves |
Its public API does not change. Its state blob does: the capture's blob
nests, and DSSS_BURST_RECEIVER_STATE_VERSION bumps. The format is
unreleased, so a layout change is free.
The gate on the refactor is bit-exactness, not a green suite: the receiver's existing tests and its certification must produce the same numbers, because every line moved is a line whose measurements were taken against the old arrangement.
8. What characterization has to settle¶
Written down first, so a later sweep measures them rather than confirming a decision already made.
-
Does a dwell that runs into the data cost the capture Pd? — MEASURED, and the engine now sizes on the burst. A burst engine's dwells of D repetitions are aligned to the stream, not the burst, so a preamble of R repetitions spans about R/D of them, whole or partial. One aligned dwell's Pd (
pd_predicted) was wrong in both directions: pessimistic at small D, where a burst offers several dwells, and optimistic past D = (R + 1)/2, where the detecting dwell straddles noise or data. Measured on Zadoff-Chu 127 × R = 8 then QPSK data, each depth at the C/N0 where one dwell predicts 0.6, delivered Pd fell from 0.900 at D = 1 to 0.263 at D = 8.pd_burstaverages over the alignment, credits every dwell the preamble spans, and shares one Doppler and code delay across them (drawing them per dwell over-credited small D by up to 0.05). The sizer meetspdwith it, andunderpoweredreads it (#1498). With each depth atpd_burst= 0.6, the engine delivers 0.64–0.70 against it (native/validation/capture_dwell_pd.c): conservative within acq's bounds at every D. D = 1 was 0.045 optimistic until the model priced the CFAR reference, 127 cells there and inflated by the burst itself (#1501). The capture then loses a further 0.00–0.02 after the engine detects, which the model's margin covers but does not price (#1502). The delay must be continuous: a first run put every burst on a whole sample and read up to 0.23 high. -
Does the per-burst copy cost anything? — MEASURED at the test geometry, and no. §5.1 chose a copy over a lifetime contract.
bench_burst_capture_core, 64k blocks, minimum of 30 rounds,ACQ_SF=31,REPS=4,burst_len=2448:row min rate push[quiet]2.242 ms 29.2 MSa/s push[4 bursts]2.276 ms 28.8 MSa/s push[4 bursts, file]2.268 ms 28.9 MSa/s Four bursts — four refines and four window copies — cost 34 µs against a 2.24 ms search floor, so the capture layer is 1.5% on a block that is almost all acquisition. What is NOT settled is the same question at a real link geometry, where a window is 2.5 MB rather than 20 kB: the copy scales with
burst_lenand the search floor does not. If it becomes visible there, the answer is a bounded borrow window, not a bigger ring. -
How much dead air must a caller leave? — SETTLED, and the object derives it (2026-09-01). This section first recorded the answer as measured, 256 samples, against a documented
max(0, refine_span - burst_len)of 32. Both numbers were wrong, in different ways, and the pair is worth keeping:-
The formula omitted the detection lag. A detection's anchor is the code epoch of whichever frame detected, and acquisition's framing is not aligned to the preamble, so the last frame that can detect sits
reps · Ppast the true start — the term §7.1's own table already names. CLAIM merges anchors closer thanrefine_span, so the first burst detected LATE and the second EARLY close by that much: -
The measurement was under-powered. 256 came from 12 trials reading 100%; at 60 trials the same spacing reads 88.8% and the curve reaches 100% at 528 — which is what the derivation gives. A floor measured with too few alignments is not a floor, and this one reached a certified report before the sample size was questioned.
Checked on four geometries, each predicting a different bound (
reps=6→ 1520, a 63-chip code → 3088, a 16-chip data code → 0), each reaching 100% at its own value and short of it below.min_gapis a read-back on both the capture and the receiver, so a caller applies no rule at all (doppler#1172).Zero is a real answer — a burst longer than
refine_span + reps·Pneeds no gap for the claim rule's sake — and it is not advice: at the geometry wheremin_gapreads 0, a touching pair measures 88%, because a zero gap is a continuous stream rather than a burst link (§5.1). -
-
Does the queue depth still hold at a capture-only geometry?
q_capis derived fromburst_len/refine_span(2·(3 + burst_len/refine_span + 1), at least 8), and the receiver only ever exercised it where a burst was also demodulated. A recorder with a very longburst_lenand a short code is a shape nothing has run. -
What is the retention floor for a caller who never consumes?
pendingis the read-back that says "a burst is held". A recorder that stops pushing has no way to flush; whether it needs one — adrain()that emits what it can — is unsettled, and the honest default is not to build it until a caller asks. -
Is
burst_lenthe right parameter, or is it two? Retention isrefine_span + burst_len, and a caller who wants a window WIDER than the burst (guard samples either side, for an offline analyst) currently has to inflateburst_lenand get the suppression window inflated with it. Whether a separate capture span earns its knob is a question for a real recorder, not for this document.
9. Persistence — the ring in a file¶
§8's last question was whether a deployment could hold the look-back in RAM
and in the blob. PersistentBurstCapture is the answer for one that
cannot: the same object, with the history ring's pages backed by a file
instead of anonymous memory.
9.1 There is no write path¶
buffer.h builds its ring by mapping one fd twice at adjacent addresses,
which is what makes a window spanning the wrap contiguous. The mirror does not
care where the fd came from, so a persistent ring is that same double mapping
over an open()ed path — MAP_SHARED, so the ring's samples ARE the file's
contents.
That is the whole mechanism, and it is worth being explicit about what it is
not: there is no mirror buffer, no background flusher, no second copy of the
stream, and no way for the memory and the file to hold different bytes. Writes
land in the page cache and the kernel writes them back; get_state() calls
msync so a checkpoint and the history it names are consistent even across a
crash.
The primitive lives in buffer.h beside the anonymous one
(dp_f32_create_backed, dp_f32_sync), not in this object — a second private
ring is exactly the drift this repo forbids, and every DECLARE_DP_BUFFER
type gets the capability for free.
9.2 What it buys: the blob stops carrying the history¶
§8's measurement was that the look-back IS the blob. Backed, it is not.
Measured 2026-10-10 at ACQ_SF=511, REPS=5, DATA_SF=63, spc=4:
| frame | retain_span |
in-RAM blob | backed blob | ring file |
|---|---|---|---|---|
| 61 sym | 80 780 | 2.18 MB | 82.5 kB | 2.10 MB |
| 1029 sym | 324 716 | 8.47 MB | 83.1 kB | 8.39 MB |
| 8029 sym | 2 088 716 | 33.64 MB | 87.7 kB | 33.55 MB |
A checkpoint at the long geometry drops from 33.64 MB to 88 kB — 383× —
and what is left is the acquisition child plus the detection queue. The
in-RAM blob is the ring's capacity in bytes plus exactly that (here also the
file's size, which rounds up to a page), because it carries the WHOLE ring,
not retain_span of it: a live capture can hold history past
retain_span for a burst release() handed back, and a resume that dropped
it would not be one (§11.4). The ring is twice the retained span rounded up
to a power of two; the file is written once and reused, not accumulated.
(Re-measured 2026-10-10 for #2015, on main after #2042's framer-snapshot
acq blob. The 2026-09-01 table read 0.61 / 2.57 / 16.68 MB in RAM, when the
blob carried only retain_span; retain_span itself and the backed blob
have moved with other changes since.)
The second thing it buys is that the history outlives the process. Point a new capture at the same path and the samples are already there; restore the blob and it reaches back across the restart into a burst that began before it.
And it costs nothing measurable, which is the claim the mechanism has to
earn: push[4 bursts] at 2.276 ms against push[4 bursts, file-backed] at
2.268 ms — the two rows are within each other's spread, because MAP_SHARED
means there is no second write to be slower than. bench_burst_capture keeps
both rows side by side, so the day that stops being true is the day the pair
separates.
9.3 Two refusals, both deliberate¶
- A backed blob does not restore into an in-RAM capture, or the reverse.
state_bytes()differs, so jm's length check rejects it. They are different configurations, and silently accepting one for the other would resume a capture whose history was somewhere else entirely. - A blob claiming retained history the file cannot hold is refused. The
file holds the span a blob names when
create()adopted a ring of exactly this geometry with history in it, or when this capture wrote it itself — its ring head is its own stream position — and in either case only while the span is still inside the ring. Without that check the positions would be perfectly valid and the samples would be zeros or another stream's, so the capture would simply never find another burst — indistinguishable from a quiet stream, which is the failure this object exists to prevent. What is not refused: a live capture restoring a checkpoint it took itself, after any number of pushes (#1190) —set_state → push → get_stateper call is a supported service shape on both flavours.
9.4 Why a view rather than an argument¶
The two constructors differ and nothing else does, so
PersistentBurstCapture is a [[burst_capture.views]] entry over the same
core — the mechanism MatchedDDC established: a difference in CONSTRUCTOR is
a flavour, a difference in METHOD SIGNATURE would be a separate type. The
methods are shared verbatim, so there is one algorithm rather than two.
An optional path=None on the base constructor was the alternative and is
worse twice over: a non-required string init-param renders its own
"create with defaults" doctest as BurstCapture(path=0), which fails the
stub-doctest gate, and a backing file with a default is a capture quietly
persisting somewhere nobody named.
9.5 Minimal where it is REQUIRED, rich everywhere else¶
Two different axes, and pulling on the wrong one makes the object worse.
Required configuration is one parameter: preamble (one period, as
complex samples). Everything else — the geometry, the search targets, the
burst length, the CFAR mode — carries a default, so the smallest thing that
constructs is BurstCapture(preamble). That
is the axis to minimise, because every required parameter is a decision a
caller has to make before they can begin.
Read-backs, optional configuration and diagnostics are rich, deliberately. Twenty-two read-backs is not bloat when each answers a question the object alone can answer:
| group | what it answers |
|---|---|
refine_span, retain_span |
where may the next burst go, and how much trailing context |
pending, dropped, n_bursts |
am I losing bursts, and is one still in flight |
the events() row |
where was THIS burst, how strong, how well resolved |
| the search group | will this configuration find my bursts at all |
The one that earns its place most is underpowered, because its failure
mode is silence: a search that cannot meet the requested pd still builds a
best-effort grid and then captures fewer bursts than arrived, which is
indistinguishable from a stream with nothing in it. It is a declared
warning, gated on a bool field. The sibling BurstAcquisition declares the
same warning the same way: dp_burst_acq_state_t carries its own underpowered
field for it, because jm's condition must be a bare identifier on the
struct.
threshold is not forwarded, though the engine carries it. eta and
eta_nc are the gates, and n_noncoh says which is in force. A burst
engine's n_noncoh is always 1, so eta is the gate and eta_nc reads 0.0.
(The engine zeroes threshold on the non-coherent path, where the
continuous engine runs; a burst capture never takes it.)
11. The sizing contract, and whose verdict a span is (2026-09-01)¶
Two things the bank composition (#1174) and the review it asked for (#1181) settled about this object. Both are gates now, not prose.
11.1 One look, and a design C/N0 that is optional¶
Refine reaches k_lo = 3·reps + 2 code periods back from an anchor. The
anchor is acquisition's: samples_consumed − frame_n + code_phase, the epoch
of the frame a hit was stamped on. frame_n is the engine's whole coherent
dwell natively and one code period in tiled Doppler mode, where backing off
n, W periods, anchored every hit (W − 1) periods early (#2090). On the
non-coherent path that frame is the last of n_noncoh accumulated, so a
burst — whose preamble sits in one frame — gets an anchor up to
n_noncoh · coherent_bins periods late. Sized
at the old default of 50 dB-Hz the burst sizer chose n_noncoh = 6 at the
test geometry, and the same 34 dB scene came back 9 and 3 periods late
— while the refine_margin of the day read 0.68, better than a correct
0.81, which is one of the reasons that read-back was later removed
(doppler#1312): it was most confident exactly where it was most wrong.
The extra looks could not have helped either: the sizer's Pd model assumes signal in every look, which is the continuous engine's situation, not a burst's. So the contract is:
- A burst engine never buys non-coherent looks. When the coherent
ceiling falls short of
pdit isunderpowered, honestly, rather than escalated (acq_auto_config_burst). cn0_dbhzis a design (minimum) C/N0, and optional. NaN (ACQ_CN0_NONE, the default) means none given: the whole preamble is integrated in one look (capped bydoppler_ratewhen one is given) and the threshold ispfa's alone;pdis a target only with a design point, and without onepd_predictedis NaN andunderpowerednever asserts. Every finite value is a design point, negative included: it used to be0, which left normalized units (fs = 1, where C/N0 is the per-sample SNR) no way to state one (#1484). The continuous engine keeps a required, finite C/N0 — looks are its only lever.- The capture refuses a pinned grid past its reach:
configure_search_rawreturnsDP_ERR_INVALIDwhenn_noncoh · doppler_bins > k_lo.
Pinned in C at the default sizing and at a design point the ceiling cannot
meet, and in Python at reps ∈ {3, 6, 10} — reps is a link variable,
never 4.
11.2 Hold, and release¶
An emitted window owns its whole span: detections inside it are the payload firing against the acquisition code. That rule was armed on emission, and the receiver's design says only a decoded burst may own a span (§10.3, #1004). Under the two-look grid the timing hid the difference; under one look a decoy whose window ends just after a real burst begins swallowed that burst's first detections (receiver §2.10, lead 2100).
The capture cannot reach the verdict — it stops at samples; error detection, in whatever form a frame carries it, is the consumer's. So:
- detections inside an owned span are held (
shadowed), not dropped; dp_burst_capture_release(i)gives windowi's span back and the held detections are searched again on the nextpush();- unreleased, they are dropped when the next
push()begins — exactly what a consumer with no verdict always got.pendingnever counts them; - they never hold the history ring. Its tail is kept for the oldest entry that can still be emitted, and a held detection whose history has been trimmed goes early, since no release could refine it again. Only a push long enough to trim past one gets there (#1527).
- they never stall the queue:
emittakes the first entry that is not held. A complete window behind a held head used to wait, pin the history tail, and with bursts longer thanrefine_spanmake one long push drop a chunk and a burst (#1534).
DsssBurstReceiver checks its own trailer in C (frame_valid, scalar and
per event row) and releases every window that fails. Nothing assumes that
trailer beyond this receiver: a link may code, interleave, carry a midamble
or detect errors another way, and the capture's contract is the same.
STATE_VERSION 1 → 2: the pending entry grew a flag. Proven by five
sabotages: the sizer escalating again, the reach refusal removed, a
shadowed hit dropped on arrival, dropped at emission, and the receiver never
releasing — each red at its own pin.
11.3 Stream order¶
What a push returns follows the stream, not the caller's block size. A push
is processed in chunks of up to chunk_max, and each detection is claimed
only after every pending burst whose window had arrived when that
detection was made has been emitted: burst_capture_have() reads the
history up to a horizon, the detection's samples_consumed, while it is
claimed.
Before that, a chunk's detections were all claimed and then drained once.
A burst a small-block caller had already been handed was still pending when
the next burst's detections arrived, and inside refine_span they merged.
Four bursts at a quarter of min_gap came back as one wrong window when
pushed whole, and as four exact ones in 1000-sample blocks (#1527). Fixed,
120 of 120 runs agree across block sizes, gaps from min_gap / 8 up, and
lead offsets; §2.4's pair sweep reaches 100% at 194 samples of dead air,
down from 388. min_gap stays sufficient and now looks conservative
(#1530).
Proven by three sabotages. Removing the drain before a claim turns the
below-min_gap block-size test red, and the chunk-invariance test with
it. Ignoring the horizon turns five refine tests red, because bursts were
emitted before their later phases were scored. Holding history for held
detections stops the receiver's saturation test: it used to refuse a
chunk, and since push() never refuses input (#2015) the held entries pin
the ring until it has no room after trim, and push() aborts rather than
spin.
11.4 Input is never refused (#2015, #2028)¶
push() writes what the history ring has room for and loops, so the
ring's head, what acquisition has absorbed and samples_fed are one
stream position, and every epoch is stream-absolute. An all-or-nothing
write refused whole chunks instead: acquisition never saw them, and every
later epoch came out early by exactly what was refused. At a constant
block size the first refusal was also permanent. emit() swapped the next
entry to the queue head, release() then gave back older ones behind a
newer, unarrived head, and a complete burst pinned the ring: 73728 samples
and 11 of 15 bursts at 8192-sample blocks.
The queue now stays in anchor order, and a detection is judged dead by its
REACH -- refined, its window's first sample; not yet, the lowest position
refine will read across its phases. One whose reach is behind the ring's
tail can never be emitted, and is swept at every trim, wherever it sits;
dropped counts the part of its span that is gone if it was a burst, and
nothing for a shadowed one. On a live stream no burst dies this way, so
dropped stays 0: a moved anchor is a later one, and a re-armed refine
reads no further back than the tail an unarrived entry at or ahead of it
kept. A resume with less look-back, or a forged blob, is what the sweep is
for. Sweep, drain, trim, in that order, is what bounds the ring: the head
is never more than max(burst_len, reps·P) + (2·k_lo + k_hi + 2)·P past the
tail, below 2·retain_span ≤ capacity, so a write always has room. It is
not within retain_span + P: an entry refined at its earliest candidate
behind one with the same anchor refined at its latest pins
max(burst_len, reps·P) − burst_len + (k_lo + 1 − reps)·P beyond it, which
is (k_lo + 1 − reps)·P when burst_len ≥ reps·P and
(k_lo + 1)·P − burst_len otherwise.
A checkpoint carries everything the ring holds -- up to its capacity, a
pure function of configuration -- so a resume emits what the live capture
would have; carrying only retain_span lost a burst release() had handed
back. A blob is checked whole before any of the capture is written, so a
refused one leaves its next get_state() byte-identical. The checks are: a
stream position below 2^63 and a suppression span that ends by it; the
queue's phases within their array, its readings finite, its anchors in order
and none past the stream position, and a refined start within the range
refine chooses from; a look-back no longer than the stream; and an
acquisition child that fits its region and stands at the capture's stream
position. The child is restored to check that, and handed its own blob back
on a mismatch, which resets acq's unserialized dwells and surface_at.
Each check stands between a forged blob and a push() that aborts, reads
out of bounds, shifts every later epoch, or silently stops capturing.
11.5 A sweeping carrier: cap the depth, or compensate the rate¶
doppler_rate alone is a CAP. A carrier that sweeps at a Hz/s drifts
a·D·T over a block of D repetitions (T = P/fs), and the slow-time
transform stays coherent only while that is under half of its row,
1/(2·D·T): D ≤ f_epoch/√(2a). A faster sweep does not break the capture,
it shrinks the block, and the integration gain with it.
rate_hypotheses = n > 1 compensates instead. doppler_rate becomes the SPAN
[-R, +R], and the coherent block is searched at n rates evenly spaced
across it. For each, the slow-time axis is multiplied by exp(-jπ·a_h·t²),
t the epoch's time from the block's middle, before the transform. The cap
then applies to the RESIDUAL, the farthest a true rate can sit from a
hypothesis, R/(n - 1): the depth grows by up to √(n - 1) at a fixed span
(the bound reaches 15 at n = 9; the sizer picks the
smallest depth that meets pd, which at the design point below is 10). One mechanism, both grids: the narrowband engine
multiplies each column before its slow-time FFT, and the tiled one (a
doppler_uncertainty past the native span, PR #2210) multiplies inside every
tile's block.
rate_hypotheses = 0 chooses n. For each n from 1 the sizer is run twice
in effect: D*(n) is the depth it picks with the drift bound OFF, priced with
n looks per cell, and D_cap(n) is the depth the residual R/(n - 1) allows
(n = 1: R itself). The first n with D_cap(n) >= D*(n) is the first at
which the hypotheses stop costing depth, so it is the smallest that does not
trade sensitivity for work; if none does by 64, it is 64. At the design point
below it picks n = 3 at D = 8, where n = 9 is D = 9: a capture Pd of
0.927 (95% 0.909 – 0.942) against n = 9's 0.932 (0.915 – 0.946) at 1000
trials, the same within the intervals, with a third of the hypotheses a
dwell (the table below). A strong design point that a capped depth already serves picks n = 1. The
choice reads back as rate_hypotheses, burst engines only, and like any
n > 1 it needs a doppler_rate to span.
A block of one or two epochs cannot see a rate: at D = 1 the de-chirp is
t = 0, at D = 2 it is t = ±T/2, and either way every hypothesis
multiplies the block by one constant phase, so the n surfaces are the same
surface. An engine sized there (a strong design point makes the sizer pick the
smallest depth) searches ONE look: the gate of an engine without hypotheses,
n = 1's cost, and a reported rate of 0. Without that, a strict > kept
hypothesis 0 and reported -R whatever the carrier did.
The reported rate (doppler_rate_hz_s) is a hypothesis, not a fit, and a
reliable one only from a block WHOLLY inside the preamble and well above the
design point. Adjacent hypotheses differ by at most π/4 of phase at the
block's edges at the depth the residual bound allows, so at the C/N0 the
engine was sized for noise picks among neighbours, and a dwell straddling the
preamble's edge integrates part payload and often names ±R. Measured by
validate_acq_rate_pd's reported-rate rows (n = 9, D = 9, design
−12.75 dB-Hz, 200 seeded bursts a row):
| signal | true rate | all hits naming the nearest | full-block hits naming it |
|---|---|---|---|
| −12.75 dB | 0 | 15.0% | 18.3% (of 142) |
| −12.75 dB | +R | 52.5% | 60.3% (of 151) |
| +10.25 dB | 0 | 39.8% | 100% (of 158) |
| +10.25 dB | +R | 56.1% | 100% (of 165) |
About half the hits at the design point, and seven in ten well above it, come
from straddling dwells. The 23-dB-over rows are
gated (95% of the full-block hits, with at least 40 of them); the others are
printed and not claimed. Detection is unaffected, since the surface that wins
is a good one whichever neighbour it is; treat the value as a seed. With
max_peaks > 1 one hypothesis's surface serves the whole dwell's list, so
every hit of a dwell wears the winner's rate.
What it costs, and where the cost is paid.
- Work is about
ntimes per input sample: each hypothesis is a full slow-time pass, CFAR-tested against its own surface (a reference taken over a max of hypotheses is biased up, and the gate is derived for one). A dwell costs more thanntimes, because the block also gets deeper withn. Measured, one thread, ZC 127 × 16 at fs = 1: 43 µs a dwell uncompensated (D = 5), 418 µs atn = 5(D = 9), 772 µs atn = 9(D = 10), about 85 µs a hypothesis. Refine's own rate grid is paid once a burst, not a dwell: searched with one acquisition hypothesis and a declared rate (nine rows instead of one), it adds about 0.42 ms to a 40,000-sample push carrying one burst, 2.52 ms against 2.93 ms (#2219). - False alarms are priced, not hoped away: the per-cell Pfa is split over
ntimes the cells a dwell's maximum ranges over (every searched row of the interpolated surface, timescode_bins), so the gate rises withn. The hypotheses are strongly correlated, so that is conservative: measured per dwell against a target of 1e-3 (200,000 dwells each),n = 1, 3, 5, 9read 0.95e-3, 0.60e-3, 0.52e-3 and 0.22e-3 (#2287). CI holds them, each not significantly above the target, invalidate_acq_rate_pd_pfa(#2324). - Refine follows. Its coherent sum runs the whole preamble, so a sweep that
the engine's short dwell tolerates collapses it: the capture named the
wrong repetition for about half the bursts at high SNR. Refine tries the
same rates (on a grid denser than the engine's whenever the engine's is
too coarse for
repsperiods, the drift rule at that depth), whenever a rate is DECLARED, so with one acquisition hypothesis too (#2219), and widens its Doppler cells by the shift a rate puts between the detecting dwell's middle and the preamble's. Each of those two is worth about 0.02 of Pd on the validator's trials.
What it buys, measured (native/validation/acq_rate_pd.c; ZC 127 × 16,
fs = 1, rate span ±9e-7 cycles/sample², so a carrier at the span's rate
sweeps 0.0018 of fs across the preamble; C/N0 −12.75 dB, where n = 9 predicts a burst Pd of 0.9;
1000 trials a row; Wilson 95%; the swept carrier is the library's own
rotation, dp_swept_rotate, not a private phase ramp):
| n | D | rate drawn | pd_burst |
engine | capture | capture 95% |
|---|---|---|---|---|---|---|
| 1 | 5 | uniform | 0.848 | 0.958 | 0.905 | 0.885 – 0.922 |
| 3 | 8 | uniform | 0.903 | 0.981 | 0.919 | 0.900 – 0.934 |
| 3 | 8 | midpoint | 0.903 | 0.984 | 0.935 | 0.918 – 0.949 |
| 5 | 9 | uniform | 0.911 | 0.982 | 0.926 | 0.908 – 0.941 |
| 5 | 9 | midpoint | 0.911 | 0.983 | 0.931 | 0.914 – 0.945 |
| 9 | 9 | uniform | 0.900 | 0.978 | 0.932 | 0.915 – 0.946 |
| 9 | 9 | midpoint | 0.900 | 0.984 | 0.913 | 0.894 – 0.929 |
| 3 | 8 | uniform, auto | 0.903 | 0.979 | 0.927 | 0.909 – 0.942 |
midpoint puts every trial's rate exactly half a spacing from the nearest
hypothesis, the worst case the depth bound is derived against; uniform draws
it over the span. pd_burst prices a rate ON a hypothesis, and the table is
the evidence that the half-spacing residual costs nothing beyond what the
bound already allows: the worst case reads the same as the average.
Uncompensated, one hypothesis with the rate declared, the capture delivers
0.905, above its own pd_burst. It used to deliver 0.555: the engine saw the
burst (0.946 then) and refine lost it, because refine searched rates only when the
engine had several hypotheses. Keyed on the declared rate, refine keeps what
the engine finds (#2219). What hypotheses still buy is the engine's depth,
and that shows where the capped depth is underpowered, 3 dB under the design
point: engine Pd 0.542 capped against 0.747 for n = 5 (what auto picks at
that C/N0; at the design point it picks n = 3) and 0.685 for n = 9,
outside the intervals. A capture still places a few percent fewer bursts than
its engine detects, compensated or not (0.905–0.935 against 0.958–0.984
above), which is refine's own cost, open in #2341.
Not a property of the Zadoff-Chu sequence. The validator repeats the
claims on two more preamble families of the same 127 × 16 geometry, each from
the shipped waveform generator at its own design point: a repeated LFM chirp
(−fs/4 to +fs/4 a period) and a PN m-sequence on dp_mls_poly(7). Capped
(n = 1) against n = 5, 1000 trials a row:
| preamble | C/N0 | capped capture (pd_burst) |
n = 5 capture (pd_burst) |
3 dB under, engine: capped vs n = 5 |
|---|---|---|---|---|
| LFM | −14.00 dB | 0.924, 0.906 – 0.939 (0.842) | 0.915, 0.896 – 0.931 (0.917) | 0.471 (0.440 – 0.502) vs 0.760 (0.733 – 0.785) |
| PN | −12.75 dB | 0.879, 0.857 – 0.898 (0.847) | 0.925, 0.907 – 0.940 (0.911) | 0.554 (0.523 – 0.585) vs 0.742 (0.714 – 0.768) |
Both captures deliver their pd_burst within the interval. Compensation's
depth beats the capped depth 3 dB under the design point, outside the
intervals, at the engine and at the capture (LFM 0.362 against 0.603, PN 0.465
against 0.624). Each family's Pfa is held too.
Two things this does not say. The uncompensated row's Pfa was a separate finding, since fixed: the engine read 1.97e-3 a dwell against a target of 1e-3, because its gate was priced over the native cells while its maximum runs over the Doppler-interpolated surface (#2218, the mechanism in DSSS acquisition §9.1). Priced over the surface, it reads 0.95e-3. And the tiled grid is covered by the C tests (rate found, either sign, in a tile off the centre) but not by the Monte-Carlo above.
Hits carry the winning hypothesis as doppler_rate_hz_s (BurstAcquisition's
last field): the detecting dwell's, so reliable only from a block wholly inside
the preamble. A capture's events carry refine's instead (#2219): the
hypothesis whose de-chirp made the whole preamble's coherent sum largest at the
refined start, on refine's own grid, which is at least as dense as the
engine's. It is the better estimate by construction, from the whole preamble
rather than one dwell, and it measures so (validate_acq_rate_pd's
event-rate rows, beside its rows for the hits). At 23 dB over the design
point every captured event names the grid point nearest the truth: 100% at
rate 0, at the span's edge, and off the grid, 0.4 of a step from a
hypothesis. Under the same conditions the engine's hits name it 40% and 56%
of the time (at 0 and the edge), and only the full-block ones, wholly inside
the preamble, reach 100%: the event gets there whichever dwell anchored the
burst. Nearer the design point the off-grid case is the one to read. There
the event is the nearest in 82% and 89% of events on the grid (all the hits
manage 15% and 52%, the full-block hits 18% and 60%), but in only 51% off it:
a rate 0.4 of a step from one hypothesis is 0.6 from the next, and at that
signal level noise picks the neighbour often. 3 dB over, 94% and 96% on the
grid and 65% off it. The queue,
the blob (version 6) and the event record carry it together, and a blob of
the earlier layout (version 5) is refused.
10. See also¶
DsssBurstReceiver— the composition this comes out of; §11 is the decision record, §3 the measurements the refine stage rests on- DSSS acquisition — the detector's own architecture
- Adding an algorithm — the lifecycle — the phase order this document is phase 1 of
- State serialization — the triplet this object owes