AsyncDsssReceiver — the continuous DSSS receiver, from spec to object¶
AsyncDsssReceiver is one C object that searches for a continuous DSSS
emitter, refines the detection, and tracks it until its own lock detectors
say it is gone. Its design of record is
async-dsss-parallel.md: N of these receivers,
always on, each searching and tracking by itself under a controller that only
watches, excludes and reports. This page holds what that design builds on:
the specification, the acquisition engine's hand-off, the asynchronous
despreader, the receiver's states, its release, and what it hands a holder.
§5–§9 and §11.1 described the pool as built, which the parallel design
replaces; they are retired to the page as it stood. Bursts are
burst-bank.md's. The runs behind the numbers here are
the measurement record, and a cost is
a published benchmark row.
1. The specification¶
| quantity | value |
|---|---|
| nominal frequency | 2.5 GHz, uncertainty ±50 kHz |
| frequency rate of change | < 500 Hz/s |
| waveform | continuous DSSS BPSK, asynchronous rectangular data |
| exemplary use case | CCSDS command-link Gold code, 1023 chips, 3.069 Mcps |
| data rate | 2700 bps, on no fixed relation to the chip clock |
| sensitivity | Es/N0 ≥ 5 dB |
| deliverable | one C receiver in libdoppler.{a,so}, the same via Python |
Two of those are derived. 500 Hz/s is set against the LEO nadir-pass
figure f_dot_max = (f_c/c)·(v²/h), about 579 Hz/s at 2.5 GHz and 800 km;
the engine's coherent depth is sized from the specification's bound
(§2.1). 5 dB is the floor the receiver is held to at the specification's
geometry: its BER is under 5% at 5 dB and over 10% at 4 dB, with the
self-referenced EVM and the blind M2M4 SNR agreeing (_test_awgn_esn0_floor
in native/tests/test_async_dsss_receiver_core.c). §3.3 gives how far that
BER sits from the bound.
The code loop and the demodulator's carrier loop are sized to a loop SNR
of at least 20 dB at that floor. rho(dB) = Es/N0(dB) − 10·log10(2·bn),
with bn normalized to the loop's own update rate so the rate cancels,
gives bn ≤ 0.0158 at 5 dB. The Dll ships at bn 0.002
(ASYNC_DSSS_RX_DLL_BN) and MpskReceiver's carrier loop at 0.01. The code
loop's per-epoch SNR is Es/N0 over epochs_per_symbol, 1.11 here, inside
the same bound. The pre-despread Costas loop is wider, 0.04
(ASYNC_DSSS_RX_BN_CARRIER): pull-in and the 500 Hz/s ramp set its width,
not jitter (§4). There is no FLL. bn does not set the floor: sizing a
loop for steady-state jitter and pulling one in from a coarse estimate are
different problems, and §4's refining stage answers the second.
2. Acquisition¶
2.1 Two front doors, one engine¶
Acquisition (continuous) and BurstAcquisition are two public
constructors over one dp_acq_state_t
(native/inc/doppler/acq/acq_core.h), each exposing only the parameters
that mean something for it rather than one class with a mode and
per-parameter "ignored here" caveats. State, auto-sizing, push() and
serialization are shared. Acquisition takes the code, spc, chip_rate,
symbol_rate, a design cn0_dbhz, a one-sided doppler_uncertainty,
pfa, pd, a CFAR noise_mode, and the two inputs that size its coherent
depth, code_only_epochs and doppler_rate. Everything else it derives;
there is no doppler_resolution and no max_noncoh.
doppler_bins is the whole Doppler grid a hit's doppler_bin indexes:
the coherent depth coherent_bins times the frequency windows the engine
rolls one epoch's spectrum across when the uncertainty is wider than one
epoch's span (dsss-acquisition.md §4).
Non-coherent looks are a separate axis. On the continuous door n_noncoh
is auto-selected to meet pd at pfa and read-only, bounded only by a
safety valve (ACQ_N_NONCOH_SAFETY_CEILING, 256 looks) because the
semi-analytical pd_predicted model turns non-monotonic past it. A burst
engine never adds looks: it sizes on the burst and reports underpowered.
Two inputs size the coherent depth D: code_only_epochs and
doppler_rate. It is the smaller of ⌊(code_only_epochs + 1)/2⌋, so a
whole block always fits inside the pure-code window, and
f_epoch/√(2·doppler_rate), which is f_epoch/√1000 at 500 Hz/s, so the
drift over one block stays inside half a slow-time bin. code_only_epochs
counts the whole epochs a window holds at any chip phase: the window is
W symbols on the data clock, with no fixed relation to the code clock, so
a partial epoch is lost at each edge and
code_only_epochs = ⌊W · cps / L⌋ − 1. It defaults to 1, which is D = 1:
the epoch-by-epoch search. AsyncDsssReceiver takes the window as a time,
code_only_s, and derives code_only_epochs from it (§2.3).
2.2 A detection is a physical record, never a grid index¶
acq_handoff_t (native/inc/doppler/acq/acq_core.h) is the data; the
hand-off is the action that turns a raw push() hit into it and gives it
to the next stage. The record crosses a thread or process boundary, so it
cannot be the grid-relative (doppler_bin, code_phase), which mean nothing
without also shipping the emitting engine's spc and doppler_res_hz.
Every field is in a physical unit and the record is a flat, pointer-free
POD. dp_acq_build_handoff() builds one from each hit of a continuous
engine. AsyncDsssReceiver's seed() takes three of its fields,
chip_phase, doppler_hz_est and cn0_dbhz_est, and the receiver's own
hit takes that same path. A hit whose Doppler is at or past half the sample
rate is outside the seed domain (dp_async_dsss_receiver_seed_ok()): it is
refused as a seed and counted in refused_hits, and the search goes on.
| Field | Type | Description |
|---|---|---|
samples_consumed |
uint64_t |
Sample offset since this engine's stream start that the detection's epoch ended at — replay-safe |
chip_phase |
double |
Code phase in CHIPS, the code-tracking seed for the next stage |
doppler_hz_est |
double |
Coarse Doppler in Hz, already folded, signed and scaled from the raw bin index |
doppler_res_hz |
double |
Width of that estimate — the ±doppler_res_hz/2 a refine or tracking stage still has to close |
cn0_dbhz_est |
double |
C/N0 estimate, dB-Hz — sizes downstream loop bandwidth and dwell |
peak_mag |
float |
Raw CFAR peak magnitude — observability passthrough |
noise_est |
float |
Raw CFAR noise-floor estimate — observability passthrough |
test_stat |
float |
Raw CFAR gating statistic — observability passthrough |
The record carries no timestamp. The engines are pure sample-domain,
with no I/O; whatever feeds them samples owns the clock and stamps a record
at its samples_consumed (§11.3). The field list is the header's own.
chip_phase is where the code will be, not where it was. A hit is
decided at its dwell's end and the seed is wanted at the next sample, so
dp_acq_build_handoff() advances the phase by the drift over half the
dwell on the hit's own Doppler. Neither constructor takes a
carrier_freq: the engine works in baseband Doppler Hz, and a
baseband-only caller never has to name one. A caller that knows the carrier
tells the engine afterwards (dp_acq_set_carrier_freq_hz() /
set_carrier_freq_hz), which turns on that advance and, inside a coherent
block of D > 1 epochs, aligns the block's epochs on the dilated code
clock. AsyncDsssReceiver passes its own carrier_freq_hz on.
2.3 The receiver's search: a slow-time depth from the code-only window, dwells on the shared sample count¶
The receiver's search is the continuous door, sized from two physical
create parameters: code_only_s, the code-only window in seconds, which
the receiver turns into code_only_epochs = ⌊code_only_s · chip_rate / L⌋ − 1, and doppler_rate in Hz/s, passed through. Both default to 0, which is
D = 1: one-epoch looks over its one-sided doppler_uncertainty. The
depth it runs is the read-only coherent_bins. n_noncoh is sized to pd
at pfa for its cn0_dbhz. The engine reports up to 16 peaks a dwell
(max_peaks); the receiver drops those within a chip of an excluded code
phase (set_exclusions()) or of a stronger peak, and takes the survivor at
its rank (set_rank(), 0 = the strongest, the default), or nothing that
dwell. With no exclusions at rank 0 that is the one-peak detector's pick.
An uncertainty wider than one epoch's span, ±1/(2·T_E),
is covered by the engine's roll across frequency windows, which it fans
across every online core unless set_threads() says otherwise; a pool
that steps its receivers in parallel sets each to 1. The search is
bit-identical at any thread count. What searching costs is
async-dsss-parallel.md §6.
Every dwell starts on the block grid of the receiver's own input count.
The receiver counts every sample fed since create, and every receiver of a
pool is fed every sample from create, so that count is the pool's shared
one. A search restarted by reset(), a release, a refused hand-over or
configure_search_raw() first discards input up to the next multiple of
D · L · spc samples, at most one block (30 ms at D = 90), so searchers
re-armed at different times still decide on the same blocks. The count and
the alignment owed travel in the state blob. At D = 1 the grid is one
code epoch.
The slow-time depth D > 1 is safe only inside a pure-code window.
Coherent combining across data aliases the data's own spectrum across the
Doppler axis and mislocks structurally
(dsss-acquisition.md). Inside a window of code
alone there is nothing to alias. The engine does not know any emitter's
window phase, so it sums non-overlapping blocks of D epochs, one
coherent surface per block, detected per block. A whole block always lands
inside a window once W ≥ 2D − 1, and ⌊W/D⌋ land in a row for
n_noncoh to accumulate. A D-point slow-time FFT at the epoch rate
subdivides one epoch's span into D rows; it never widens it. At the
specification's geometry a 450-symbol code-only window holds 499 whole
epochs, and the 500 Hz/s bound gives 94, which the engine runs at the
7-smooth D = 90 (a slow-time FFT length, #1247): a 33.3 Hz row and 19.5 dB
of coherent gain. A block that straddles data spreads its emitter over the D
rows, about 10·log10 D down at its own code phase: a weaker copy, not
a mislock. Pinned by native/validation/acq_block_coherent.c. The receiver
runs this depth when given its window: at 32 dB-Hz, D = 90 finds a signal
inside one 170-symbol window that D = 1, needing 256 looks, cannot decide
there (test_async_dsss_receiver_core.c,
_test_depth_finds_what_one_epoch_misses).
The engine's windowing ships. Its roll of one epoch's spectrum across
frequency windows, the alias check a pick near a window's edge gets
(acq_resolve_tile_alias), and the roll per thread on persistent workers
are current engine behavior, and the parallel design's search over
±1/T_E runs on them. Their design text is §2.3 at
4a4c437d0
until it moves to dsss-acquisition.md
(#2500).
2.4 The searcher is watched, not trusted¶
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: detached, a decided dwell
costs three predicted-not-taken branches, and nothing rides in a state
blob. AsyncDsssReceiver.set_telemetry forwards them as <prefix>.acq.
- Ten probes per decided dwell,
set_telemetry(tlm, prefix, decim): the test statistic and the gate it was held to (plotted together they show 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, the strongest pick's concentration, and whether the gate fired. - The surface and the complex intermediates under it.
keep_surfacethensurface(out)gives 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, withsurface_doppler_hz()/surface_chip_phase()as its axes, from the same mapping anacq_handoff_tcarries.dp_acq_set_surface_sink(fn, ctx, decim)hands everydecim-th dwell's surface to a callback on the pushing thread. Underneath,surface_complex(out)is the coherent dump the last dwell was decided on,block_prompt(tile, col, out)one cell'sDper-epoch correlations (the despread stream at epoch rate), andblock_raw(out)the block's epochs as pushed, for a re-correlation at any phase, rate or symbol boundary the grid lacks. - The concentration is the splatter discriminator. One emitter does not make one peak, and every extra copy sits at its own code phase, while 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) over the total power of its whole column: near 1 for one clean emitter, about 0.5 when a data transition splits it into twins at other Doppler, lower for a block that straddles data.
3. The asynchronous despreader¶
The receive-side despreader when the data-symbol rate is on the order of the
code-epoch rate but asynchronous to it. Its shipped form is
track.Dll(..., segments=K) (§3.4).
3.1 An independent symbol clock slides through the epoch¶
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. That works when the symbol clock is locked to it at an integer
ratio (GPS C/A: 20 epochs per bit, edges on epoch edges). It breaks when the
symbol clock is independent: with T_sym = TE · (1 + delta), an independent
symbol phase and T_sym ≈ TE, there is about one symbol per epoch, a transition
roughly every epoch, and delta ≠ 0 slides the symbol boundary continuously
through the epoch at the beat rate delta / TE.
3.2 Per-epoch despreading cancels itself half the time¶
The coherent prompt over an epoch whose data flips at fraction f ∈ [0,1] is
P(f) = A·[f·d1 + (1−f)·d2] = A·d1·(2f−1): full despread at an epoch edge, and
zero for a flip mid-epoch. Because f sweeps through every value, about half
of all epochs straddle a transition and their prompts collapse. Both
consequences follow from P(f) alone. Per-epoch data decisions floor, because
P(f) near f = 1/2 is near zero at any Es/N0, and (|E|−|L|)/(|E|+|L|)
collapses to 0/0 on those epochs, starving the DLL. At one prompt per epoch
the symbol clock is unobservable, and the integration window is forced to
straddle transitions. And the failure has a fingerprint: the flip's fraction
f advances by delta each epoch, so the envelope A·|2f−1| of the
prompt-magnitude stream |P[n]| repeats every 1/|delta| epochs and its
spectrum carries a tone at |delta| cycles per epoch — what to look for
when a DSSS link shows unexplained despread fades.
3.3 Partials give the symbol clock its own observability¶
Each code epoch is split into K sub-epoch partial prompt correlations of
TE/K samples at known code phase — K despread samples per epoch, so the
symbol clock is observable. The full-symbol despread the epoch-locked window
could not form is then a matched filter over the partial stream, run on the
symbol clock instead of the code clock. In AsyncDsssReceiver that is
MpskReceiver's integrate-and-dump matched filter (MPSK_RX_PULSE_IANDD, the
rectangular symbol's own), reached through a RateConverter that takes the
partial rate to sps samples a symbol (§3.4), with MpskReceiver's timing
loop placing the dump on the symbol boundaries. Without a matched filter a
rectangular symbol is sampled at one partial, which carries about 1/P of
its energy, P the partials a symbol spans (4.44 at the specification's
geometry, §3.7).
At the specification's geometry (Gold-1023, 3.069 Mcps, 2,700 sym/s
asynchronous BPSK, two samples a chip, no Doppler) the receiver holds a BER
under 5% at Es/N0 10, 8 and 5 dB, the specification's floor, and exceeds 10% at
4 dB, with the self-referenced EVM and the blind M2M4 SNR corroborating:
_test_awgn_esn0_floor in native/tests/test_async_dsss_receiver_core.c.
Against the bound, Q(sqrt(2 Es/N0)), the BER of a receiver that has settled
sits 0.65 to 0.89 dB from it from 5 to 8 dB, and every burst of the sweep
settled. A hand-over can still miss (F3), and a burst that did not settle
would be counted beside the loss. Both are measured, with the loss's
interval, in the
certification record
(§2.6). Where the loss goes is the record's §2.7, measured by
validate_async_dsss_receiver_loss
(native/validation/async_dsss_receiver_loss.c): the specification's
stimulus at known truth, through more and more of the shipped chain, each
stage scored as a BER-equivalent loss. At 5 dB, over 16 bursts:
| stage | its cost at 5 dB |
|---|---|
the cut into K = 4 partials a code period |
0.41 dB |
the Dll's output, held at truth |
none measurable (−0.01 dB) |
the RateConverter |
none measurable (+0.01 dB) |
| the demodulator's timing and carrier loops | 0.23 dB |
| the front end's tracking: the pre-despread carrier loop, the code loop and the refine hand-off | 0.20 dB (over its 15 settled bursts of 16) |
The cut is geometry. At 4.44 partials a symbol a partial straddles a
symbol edge, and the matched filter cannot weigh its two halves apart; ideal
sums at known timing lose the same. Against K, the ideal sums' loss on the
same bursts (the cut above is that less S0's 0.02 dB): 3.79 dB at 1, 1.17 at
2, 0.43 at 4, 0.18 at 8, 0.09 at 16. The receiver does not
run at K = 8 today
(#2506).
The code path sums the partials coherently over a one-period window and
takes the powers of the sums. A data flip changes the sign of every partial after
it, so a window across a flip cancels in part (§3.2); the look-back (§3.6) picks,
each period, the one-period window with the most prompt power, the one with the
least of a flip in it, and the discriminator runs on that window's sums. It needs
no symbol timing, so it works from cold start: the bootstrap order is code
(the DLL) → symbol timing → data. Given the symbol period, §3.7 lifts the same
search to the symbol scale.
K sets two things: the output rate, K despread samples per period, and the
look-back's grain, since a candidate window starts only on a partial boundary.
3.4 The despreader removes the code and outputs samples¶
Its one job is to remove the PN code and emit samples. The asynchronous symbol
clock is merely why it despreads in K partials; symbol extraction is
downstream, in separate objects fed from its output. This is
track.Dll(..., segments=K) and no new object: segments=1 is the classic
coherent full-epoch DLL, segments=K>1 is the streaming async despreader, and
what follows it is symbol timing, then bits.
The carrier is wiped before it. The code loop's sums are coherent over a
code period (§3.3), so it is blind to the carrier's phase but not to a residual
frequency: a residual Δf costs a window of T seconds sinc²(Δf·T) of its
power, −3.9 dB at half a 1/T bin, and a whole cycle per period nulls it
(native/tests/test_dll_core.c §6d). So the receiver runs its carrier loop
upstream. Its Costas loop wipes the carrier from the input, is steered from the
Dll's partials, once per code period on a squaring combine of that period's
partials, and hands the same estimate to the code loop as its rate aid,
carrier_offset / carrier_freq, because the motion that shifts the carrier
dilates the code. Each emitted partial is a TE/K-sample integrate-and-dump, so
whatever residual the carrier loop has not yet nulled barely dents the output:
sinc(Δφ/2) with Δφ = π/K at half a bin, −0.2 dB at K = 4. That
residual rides out to the demodulator's own carrier loop.
Its one job also forbids coupling segments to a demodulator's sps. The
partial rate is K·chip_rate/SF, chosen for the code loop's own variance and
nothing else; a demodulator's sps is chosen for its own reasons, and
doppler.resample.RateConverter bridges them. Picking K so that
round(K·T_sym/T_epoch) lands on an integer makes a perfectly good Dll
tuning look downstream-broken.
3.5 Code lock reuses acquisition's own statistic¶
A tracking channel must always answer am I locked?, so the DLL carries an
always-on lock detector built on acquisition's non-coherent test
statistic — acquire and track then agree on what "detected" means. Each look
contributes its prompt power: a partial in segments mode, the full-epoch
prompt at segments=1, and the symbol window once the symbol period is
given (§3.7). N = n_looks consecutive looks give
R = sqrt(2·Σ|P_k|² / E|O|²), which under H0 has
P(R > η) = marcum_q(N, 0, η), exactly the acquisition tail; a caller sizes
η = det_threshold_noncoherent(pfa, N) and N = det_n_noncoh(snr, …) for a
target (Pfa, Pd).
The noise reference is an off-peak tap, not a second channel. Each look
is correlated a second time at a random whole-chip offset, re-drawn every
epoch and kept noise_guard chips, the spacing plus two, clear of the
prompt/early/late lobe; for a low-sidelobe code that correlation is
signal-free, so |O_k|² samples the per-look noise power. The test depth
and the noise-averaging length are decoupled: N sets the χ²(2N)
threshold, but the reference must average many more cells or its own
variance inflates Pfa. dp_dll_configure_lock() sizes the reference's EMA
for an estimator SNR (dp_det_ema_alpha()); left to derive it from N, it
holds the reference's spread to an eighth of the statistic's own
1/sqrt(N), floored at about 33 dB, which is 1/α = max(1024, 32·N)
(native/tests/test_dll_core.c §7). It is a cumulative-mean bootstrap until
1/α looks have accrued and a fixed-α EMA after, so the floor is unbiased
from the first look rather than seed-dominated through a warm-up of 1/α
looks. The decision is verify-counted (lockdet): declared after
det_verify_count(pfa, pfa·1e-3) consecutive decisions over the gate, two
at 1e-3, and dropped after two under it. On noise its realized
per-decision rate runs above the configured pfa (the record's §12.15
measured about twice it;
#2503).
Dll.locked, Dll.lock_stat and Dll.noise_est read
it back, inside the normal steps(), with no opt-in.
3.6 The look-back window finds the transition-free epoch¶
The code loop's error comes from the one-epoch window with the most prompt
power, found per epoch across the current and the previous epoch, on the
assumption of at most one data transition per epoch.
native/inc/doppler/dll/dll_core.h is the C port of this design and names
its artifacts after it, so the two can be read against each other.
flowchart LR
RX["RX SAMPLES<br/>carrier wiped"] --> COR
NCO["U32 CODE NCO<br/>2^32 = ONE CODE PERIOD"] -->|"PHASE WORD + TAP WORDS"| GRID["× 2·sf<br/>GRID POINT i, WEIGHT μ"]
GRID -->|"i + μ"| REP["LOCAL CODE<br/>2 SAMPLES/CHIP, INTERPOLATED"]
REP -->|"E / P / L"| COR["PARTIAL SUMS<br/>K PER EPOCH"]
COR --> LB["LOOK-BACK<br/>MOST-POWER WINDOW"]
LB --> TED["TED<br/>0.5·(Ep − Lp) / Pp"]
TED --> LF["LOOP FILTER"]
LF --> SCALE["SCALE TO<br/>CYCLES / SAMPLE"]
SCALE --> SH["SAMPLE<br/>AND HOLD"]
AID["RATE AID"] --> SH
SH -->|"PHASE INCREMENT"| NCO
The epoch is cut into windows = segments partials, each closing when
the code phase crosses the next sf/segments chips, so the count need not
divide the epoch's samples: the receiver's tracking Dll takes 4, partials
of 511.5 samples on average at the specification's 2046 (§3.4, §4.1). A
count can also be derived from a tolerable async correlation loss
max_error in dB: dp_dll_lookback_segments() takes the partial length as
the divisor of sf·spc nearest the coverage 1 − 10^(−max_error/10) asks
for, 11 partials at 2046 samples and 0.5 dB, which is how the receiver
sizes its refine-stage Dll (refine_max_error_db). Per epoch, over the
prompt product x, schematically (the reshape is exact only when
windows divides the epoch's samples):
partial_sums = x.reshape(windows, window_size).sum(axis=1)
sums = partial_sums.cumsum() # this epoch, forward
backward_sums = partial_sums[::-1].cumsum() # this epoch, reversed
correlations[k] = |sums[k] + last_backward_sums[::-1][k+1]| / (sf·spc)
correlations[-1]= |sums[-1]| / (sf·spc)
max_window = argmax(correlations)
window_index = (windows − 1 − max_window) · window_size
max_window is the transition-free window, window_index its start measured
back from the end of the previous correlation, and correlations[max_window]²
the signal-plus-noise power the discriminator normalizes by. Early and late are
summed over the same window, and the error is the power-domain,
prompt-normalized form
0.5 · (early_power − late_power) / signal_plus_noise_power — not a
magnitude-domain (|E|−|L|)/(|E|+|L|) ratio.
The partial sums double as the integrate-and-dump output, each divided by
the samples it spans and by a slowly tracked level: the running mean of
the winner's magnitude, max_abs = correlations[max_window], over
DLL_OUT_LEVEL_PERIODS = 32 periods, its plain mean until 32 have accrued. The output then sits near unit amplitude at any input gain,
which the timing detectors downstream assume. Never by the period's own
max_abs: that divisor carries the period's own noise, so it compressed the
in-phase error against the quadrature and coupled the symbols that share a
period, and the receiver lost 0.2 dB of error rate while its EVM read better
than the error rate allowed
(#2495). The header
(dll_core.h) sets the length inside a band, 16 to 40: at least 16 holds
the compression, measured, under 0.15 dB, and at most 40 settles a step in
level to 1% inside a third of the data-free block. At 32 the settle is 146
periods, 49 ms at the specification, and the output's scale follows a step
until then. Over the first periods after create or reset the level is their
plain mean, so it is not yet slow.
The replica the loop steers is the code held at 2 samples per chip and
linearly interpolated at any fractional position, sampled at
chip-relative {0.25, 0.75} rather than {0, 0.5}, which centers the
interpolation transition zone symmetrically on each chip boundary and keeps
early and late symmetric about prompt. Every tap is read at a phase
word: the code NCO's 32-bit phase, in which 2^32 is one code period,
taken at the sample's dwell center and moved to the grid's quarter-chip
origin, plus one constant word per tap — the spacing either side for early
and late, the offset for §3.5's noise tap. One 32×32→64-bit multiply by
2·sf gives the grid point and the interpolation weight, the weight
truncated to 24 bits, so a tap costs no division, no fmod and no double,
and the period's wrap is the word's own overflow (dll_replica_word(),
dll_tap_base(); nco_word_to_grid() and nco_frac_to_weight() in
nco_core.h). The loop filter's output is scaled to cycles per sample,
summed with the nominal rate and the rate aid, and sampled and held into
the NCO's increment, never kicked into its phase.
The loop can be held (dp_dll_set_coast()). On entry the filter
returns to the last dp_dll_hold_here(), the point the caller marked while
the loop was on its signal, and the NCO runs at that filter's integrator
alone: the proportional term is a phase correction meant for one interval,
and held as a rate it walks the phase off at chips per second. Held, the
discriminator still reads (dp_dll_take_error() sums it), the filter takes
no update, the lock detector keeps looking, a new rate aid takes effect at
once, and code_rate, the .rate probe and every status record that
carries it report the rate the held NCO runs at
(native/tests/test_dll_core.c, coast_reports_the_held_rate, at one and
four segments). When the receiver holds it is §10's.
3.7 The symbol period lifts the look to symbol scale¶
The partial 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 is the quarter-epoch partial — the smallest integration the asynchronous data allows when nothing is known about where its transitions fall, and therefore the weakest. At the specification's geometry a symbol spans 4.44 partials, so at the 5 dB floor one partial carries −1.5 dB per look.
§3.6's search already finds a transition-free window; what it does not know
is the symbol period, and the receiver does —
P = segments · chip_rate / (sf · symbol_rate) partials, 4.44 at the
specification's geometry. With the period the same search lifts to the
symbol scale: ceil(P) boundary-phase hypotheses, five here, each placing a
boundary every P partials and owning a window of
L = min(floor(P) − 1, 4 · segments) partials after it — short enough to
sit inside one symbol under the hypothesis's quantization, 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 its
power over about 32 symbols; the one 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: three instead of
one here, 4.8 dB more per look, so det_n_noncoh sizes the detector at 17
looks for Pd 0.99 at Pfa 1e-3 at the floor, against 92 per-partial looks.
The search needs no decision and no external timing, so it costs nothing at
cold start and follows a drifting symbol clock by itself. A look that
overlaps the previous one is not a look and is not counted: overlapping
looks read the same noise several times, and on noise they returned the
code flag about once a second after a departure, restarting §10's clock
(native/tests/test_dll_core.c §6b′; the record's §12.15).
The code discriminator runs on the same window, steering once per
symbol on the early/prompt/late sums over the winner, its filter re-timed to
the symbol interval so bn keeps its per-epoch meaning and the tracked rate
is continuous whether the aid is on or off (native/tests/test_dll_core.c
§6c). Against the per-epoch loop it pulls in faster and is tighter at high
C/N0, and at the 40 dB-Hz floor, where the noise sets the jitter and the
window's unused partials cost more than its coherence buys, it costs a
little more — hundredths of a chip either way (the record's §12.5). CI holds
the relation: validate_dll_aid_jitter --check keeps the aided jitter
within 1.25× the per-epoch loop's at 45 dB-Hz and 1.6× at 40, and its
pull-in no slower than 1.1× (native/validation/dll_aid_jitter.c). The
emitted partial stream is untouched by the aid: the look-back still
supplies, averaged slowly, the output's level (§3.6).
The receiver applies it at chain build: dp_dll_set_symbol_period from its
configuration, n_looks from det_n_noncoh for Pd 0.99 at Pfa 1e-3 over
the window at its cn0_dbhz, and the drop count from
det_verify_count(0.01, 1e-6) — three consecutive misses against the DLL's
fixed two — so the verify hysteresis is a budget rather than a constant.
Pinned by native/tests/test_dll_core.c §6b (the default per-partial
detector up under half the time, the aided one over 95%, the chosen phase
within one partial of the truth) and §6c (the loop steers on the window,
the two modes' step transients agree, the rate is continuous across the
switch).
4. The receiver is one object with five states¶
AsyncDsssReceiver
(native/inc/doppler/async_dsss_receiver/async_dsss_receiver_core.h) is
the composed continuous receiver, one C object, read back through the
get_*() family, the lifecycle property and §11.3's status record. Its
design of record is async-dsss-parallel.md §3;
this section is the chain under it.
- searching — samples feed an embedded continuous
Acquisition(§2) over±doppler_uncertainty, one epoch a look (D = 1). A hit becomes a seed throughdp_acq_build_handoff()andseed(), and the unconsumed tail of the same call goes straight on. A hit at or past half the sample rate is outside the seed domain: it is refused, counted inrefused_hits, and the search goes on over its tail. - refining — a frozen-carrier derotation at the coarse estimate
(
costas_wipeoffwithcostas_updatenever called) feeds a collectionDllwhose look-back segments oversample each epoch, then aRateConvertertoCarrierAcquisition's own rate, thenCarrierAcquisition. Oversampling is required rather than tidy: the asynchronous data's residual carrier rides a spectrum aboutsymbol_ratewide, which one dump per epoch aliases. When the estimator reports ready or gives up, the live chain is built fresh from the seed's chip phase, advanced by the clock dilation over the refine (doppler#1249) — not wherever the refineDlldrifted to — and the refined Doppler, or the seed's on a give-up. A refined Doppler outside the seed domain is refused like a hit, and the receiver searches again. - tracking — the refined carrier is unfrozen into a live pre-despread
Costas →
Dll(§3) →RateConverter→MpskReceiver.costas_update()runs once per code period on a non-data-aided (squaring) discriminator over that period's coherent partials: a code period spans about 0.9 data symbols at the specification's ratio, so a transition lands inside nearly every period, and a decision-directed, sign-aligned combine averages to zero. With that clean error andASYNC_DSSS_RX_BN_CARRIER = 0.04the loop pulls the refined seed in and rides the 500 Hz/s ramp, so despreading is coherent andMpskReceiveris left a small residual. It is a pure PLL: the FLL cross-product discriminator is far too noisy on this input and is not exposed at all. - lost — the release verdict without
reacquire(§10). The loops stop updating and samples are discarded untilreset(). Withreacquirea release returns the receiver to searching, and it never reads lost.
Two lock detectors run while tracking: the Dll's CFAR-based code lock
(§3.5) and a hysteretic symbol lock on the emitted symbols — the BPSK
cos(2φ) statistic as an SNR-weighted EMA over a 30-symbol dwell, declared
after 30 consecutive symbols above 0.5 and dropped after 15 below 0.3.
reset() returns the receiver to searching: a receiver that has locked
cannot be reset back onto the same signal.
The receiver carries the standard state_bytes/get_state/set_state
triplet (native/inc/doppler/dp_state.h), every child included. The blob's
layout key is segments/sps/n/refine_segments, and a reacquire
receiver refuses a blob in the lost state.
DsssReceiver is the composition without a refine: a hit's coarse Doppler
goes straight to tracking, its pre-despread carrier loop is the
decision-directed, FLL-assisted one, and it has no symbol lock and no
release. A coarse-only hand-off leaves a pull-in gap at low Es/N0 and large
static Doppler, and the refine is what closes it.
4.1 The parts¶
- The despreader is
Dll(..., segments=K), 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 and symbol despread. Itsbnis 0.002 per epoch, whatever the steer rate (§3.7), notdp_dll_create()'s 0.01 default, and its early-late spacing is 0.5 chips, so the normalized power discriminator reads2 − spacing = 1.5units per chip of offset. - Around it: a pre-despread
Costascarrier loop wipes the carrier ahead of the Dll, andRateConverter→MpskReceiverfollow it, whose timing loop is RateSync's. TheRateConverterbridges the partial rate to the demodulator'sspsand never couples the two (§3.4):K = 4is the code loop's choice, while a downstream demodulator wants a much largerKfor coherent gain. carrier_freq_hzcouples the code rate to the carrier. The coupled code-rate Dopplercarrier_offset/carrier_freqis fed to the trackingDllthroughdp_dll_set_rate_aid()and refreshed every code period from the live carrier loop, so the code loop rides a dilated clock the discriminator alone cannot pull in at low SNR.0.0turns it off.- The escape hatches are separate knobs:
configure_search_rawpins the embedded search grid,configure_lock_rawre-tunes the liveDll's code detector,configure_chain_rawre-pinssegments/sps/n(allocating every replacement before adopting it, so a refused grid leaves the receiver usable on its prior grid; annoutsidedp_ratesync_m_ok()is refused (doppler#2421), and only an allocation failure still aborts (#2112)), andset_refine_min_blocksfloors the refine's dwell. That floor is not cosmetic:CarrierAcquisition's dwell is sized for detection at the derated C/N0, so it shortens as C/N0 rises while the noise of the estimate it hands the tracking chain does not, and too short a dwell hands the chain an estimate it cannot pull in. The default, seven blocks (42 ms at 2,700 sym/s), is the floor the record measured (§12.10, §12.16);_test_refine_dwell_floorpins that it binds. - Telemetry is attached on demand:
set_telemetryinasync_dsss_receiver_core.h. Telemetry is single-producer, so each receiver steps on one thread with its own context (async-dsss-parallel.md §4). - What it costs: tracking at the specification's geometry is the
published row
async_dsss_receiver::steps[track,sf=1023,3.069Mcps]in the benchmarks, and searching is thesearchrow beside it. Each state against real time is async-dsss-parallel.md §6. - Steady tracking allocates nothing (§11.5).
Every part is pinned in native/tests/test_async_dsss_receiver_core.c,
src/doppler/dsss/tests/test_async_dsss_receiver.py,
native/tests/test_dll_core.c and src/doppler/track/tests/test_dll.py.
5. One code, one channel, many emitters — retired¶
§5 to §9 described AsyncDsssPool as built: one central searcher over the
whole channel, an assigned table, and cell receivers seeded from it. That
pool will never work, and async-dsss-parallel.md
replaces it with receivers that each search for themselves. Each section
below says where its subject went and links its text as it stood; the dated
runs behind them stay in
the measurement record. The signals
§5 described, many emitters on one code with a periodic data-free block,
are async-dsss-parallel.md §1. Its text:
§5 at 4a4c437d0.
6. The searcher — retired¶
The central searcher is gone: each receiver searches for itself
(async-dsss-parallel.md §3), and what that costs is its §6. Its text:
§6 at 4a4c437d0.
6.1 The operating point — retired¶
The pool's operating point (2 to 5 Mcps, ±50 kHz, twelve slots, a per-core
budget). The parallel design's parameters are its §1 and §2. Its text:
§6.1 at 4a4c437d0.
6.2 One maximum per dwell — retired¶
Why one gated maximum masks every emitter but the strongest. The answer
ships as the peak list (§7.1), and the parallel design's ranks use it. Its
text:
§6.2 at 4a4c437d0.
6.3 The power spread — retired¶
The surface floor the searcher's two branches forked on; the record's
§12.2 holds its runs. Its text:
§6.3 at 4a4c437d0.
6.4 The throughput floor — retired¶
The pool's cost floor and the shapes it argued for. The parallel design's
costs are its §6, and why the pool as built is replaced is its §7. Its
text:
§6.4 at 4a4c437d0.
7. The two mechanisms — retired¶
The list against cancellation; only the list was built (§7.1, §9). Its
text:
§7 at 4a4c437d0.
7.1 The peak list — current, shipped behavior¶
The peak list is not part of what retired. Acquisition.set_max_peaks and
BurstAcquisition.set_max_peaks list every peak above the dwell's gate,
strongest first, with an exclusion zone of one Doppler bin by one chip
around each and the two-epoch rule for a peak at an already-listed code
phase. det_peak_list (native/inc/doppler/detector/det_private.h) is the
one primitive under both detectors, and async-dsss-parallel.md §3's ranks
take the r-th peak it lists. Its design is §7.1 at
4a4c437d0
until it moves to dsss-acquisition.md
(#2500).
8. The shapes — retired¶
Where each piece of the pool lived. The parallel design's shape is its §2,
and its controller is §4. Its text:
§8 at 4a4c437d0.
8.1 The holder owns the clock; events are SigMF annotations — current, shipped behavior¶
The clock and the event log are not part of what retired. Whatever feeds
the samples owns the clock: one dp_sample_clock_t per stream
(native/inc/doppler/timing/timing_core.h) stamps a record as
dp_sample_clock_stamp_at(n). A run's events are SigMF annotations,
appended live and finalized at close (dp_event_log,
native/inc/doppler/dp_event_log/dp_event_log_core.h), while telemetry stays
a flat record file the sidecar points at, each in the format that suits its
rate. The clock rule the receiver obeys is §11.3. Its text:
§8.1 at 4a4c437d0.
8.2 The pool holds the population in one object — retired¶
The pool as built: its assigned table, its zone and its lifecycle.
The object was deleted, its header and pages with it; the parallel design's
controller (its §4) replaces the table. Its text:
§8.2 at 4a4c437d0.
9. The branch is chosen by the spread — retired¶
The list branch shipped and cancellation was never built (§11.4). Its text:
§9 at 4a4c437d0.
10. The release — the lock flags decide "gone"¶
AsyncDsssReceiver carries two de-chattered lock flags, each a lockdet
(native/inc/doppler/lockdet/lockdet_core.h: level hysteresis between a
declare and a drop threshold, time hysteresis of consecutive looks either
way, a NaN look counted as a miss). Code lock (get_code_locked()) is
the live Dll's CFAR-based, verify-counted detector — "am I despreading" —
and, sized for the C/N0 and coherent over a symbol (§3.7), it is the
presence flag: an emitter that leaves takes its code with it. Symbol
lock (get_locked()) is the BPSK cos(2φ) statistic over the emitted
symbols, the health of the carrier leg: a cycle slip or a deep fade drops
it while the code is still being despread.
The rule is symbol lock alone, the parallel design's
(async-dsss-parallel.md §3), with reacquire:
once symbol lock has been down without a break for longer than
lost_confirm_s, the receiver resets itself to searching, and nobody calls
reset(). Without reacquire the rule is both flags down for longer than
lost_confirm_s, and the receiver enters lost until reset(); one flag
down is a degrade, reported and not acted on. Either way the clock runs
from the first tracking sample, when no flag is up yet, so a hand-off that
never locks is released the same way as a signal that leaves, and a flag
the rule counts coming back restarts it. lost_confirm_s = 0, the
default, disables the clock; reacquire refuses a
confirm time under one input sample. Size it past the longest fade the
link must ride, and with reacquire past the pull-in to symbol lock.
Pinned by
native/tests/test_async_dsss_receiver_core.c
(_test_lost_after_switch_off, _test_reacquire_after_switch_off,
_test_reacquire_is_symbol_lock_alone,
_test_reacquire_clock_restarts_on_lock,
_test_a_handoff_that_never_locks_is_released).
The drop times after a switch-off, the flags through a fade and through a
phase step are the record's §12.3. CI's
validate_async_dsss_receiver_release --check holds both drops under
0.5 s and the flags to at most two returns on noise.
While both flags are down after the first lock, the loops hold at what
they settled on with both flags up, their lock detectors still looking at
the held replica (§3.6). Left running on noise, a departed receiver's code
loop free-runs at whatever its filter holds, sweeps its phase through every
live emitter's and can capture one crossing slowly enough (the record's
§12.19). Held, the receiver stands where its emitter left it: a genuine
return lands on the replica, lights a flag, and the loops run again on it;
a neighbor passing through lights the code flag for the crossing, steers
for the blip, and is dropped on re-entry, because the hold point is marked
with both flags up. Holding on one flag down was measured and rejected:
two live emitters crossing each other's code phase degrade both symbol
flags, and a code loop held through that cannot re-center on its own
emitter afterwards, while a running one rides the crossing out. Before the
first lock the loops must run — that is the pull-in. The hold is the same
with or without reacquire; with it, the release ends the hold. Pinned by
the release validator's --check, the crossing and the live crossing
included. A neighbor within about a kilohertz crossing at a chip or two a
second is, to a receiver on its own, a return: the parallel design's
controller, which knows every receiver's code phase, is where it is told
apart (#1275).
11. What a holder needs from the receiver¶
11.1 The cell flavor — deleted with the pool as built¶
CellAsyncDsssReceiver (dp_async_dsss_receiver_create_cell()), the
receiver with no search of its own that the pool as built seeded from its
searcher, was deleted with that pool: the parallel design seeds no receiver
from outside (doppler#2409).
seed() stays, a searching receiver's own. The state value its idle used,
3, stays reserved (ASYNC_DSSS_RX_RESERVED), because the values are
persisted. Its design as it stood is §11.1 at
4a4c437d0,
its code at
35d8049a2.
11.2 Lost is a state of the receiver¶
Without reacquire the receiver enters lost on §10's both-flags rule.
In it the loops stop updating, samples are consumed and discarded, and
get_lost() reports it until reset(). lost_confirm_s = 0 never enters
it. With reacquire the receiver releases itself back to searching and is
never lost. The parallel design keeps this state, and its self-release
replaces a holder's
(async-dsss-parallel.md §3).
11.3 The status record is one consistent picture, by value¶
The receiver's getters are one call each, so a reader on another thread
cannot assemble a consistent picture across a steps() call. status()
returns that picture as one record by value, a jm-generated structseq whose
fields are the header's (async_dsss_receiver_status_t in
native/inc/doppler/async_dsss_receiver/async_dsss_receiver_core.h). It is
allocation-free: every field is a read of live state. The parallel design's
controller keys its table and its exclusions on it, never on the seed
(async-dsss-parallel.md §4).
It carries no timestamp. The receiver, its engine and every record they
produce are sample-domain: each carries a stream position, never a time.
Whatever feeds the samples owns the clock, one dp_sample_clock_t per
stream (native/inc/doppler/timing/timing_core.h), and stamps a record as
dp_sample_clock_stamp_at(n); telemetry takes the same stamp through
dp_tlm_set_now(). And it is not get_state(): the bytes triplet
resumes the receiver elsewhere, this record describes it here.
11.4 The replica output is not built¶
It belonged to the retired pool's cancellation branch (§5–§9).
11.5 Nothing grows with time¶
Steady tracking allocates nothing: steps() with no transition runs on
scratch sized at the last chain build, grow-only, never in the hot path
(#1192). A chain build allocates, and frees the chain it replaces. A seed
builds the refine chain (Dll, RateConverter, CarrierAcquisition). The
hand-over and configure_chain_raw() build the track chain (Dll,
RateConverter, MpskReceiver), and reset() builds both. The status record is returned by value.
What this is not¶
- Not a burst receiver. No preamble, no
reps, no capture ring, no frame demodulator. The data-free block is a coherence opportunity, not a preamble, and the search's product is anacq_handoff_t. Bursts areburst-bank.md's anddsss-burst-receiver.md's. - No clock inside. No engine, receiver or record here reads a time; every one carries a stream position (§2.2, §11.3).
- No carrier recovery or symbol extraction inside the despreader, and
no coupling of
segmentsto a demodulator'ssps(§3.4). - The frame epoch is not recovered. Nothing in the receiver finds where a data-free block starts, and its search needs no frame timing (async-dsss-parallel.md §3).
See also¶
- async-dsss-parallel.md — the receiver's design of record: N receivers, always on, under a controller that only watches.
- the measurement record — every run behind the numbers here, under its own section numbers, the pool as built's included.
dsss-acquisition.md— the acquisition engine under §2: the windowing, the CFAR vocabulary, the roadmap.dsss-burst-receiver.md— the burst chain, which shares §2.2'sacq_handoff_t.burst-bank.md— the burst bank this page's continuous case was split from.- AsyncDsssReceiver: the SPEC waveform and Streaming Async Despreader — the gallery demonstrations of §4 and §3.