DSSS Burst Acquisition¶
doppler.acquire.BurstAcquisition acquires a direct-sequence spread-spectrum burst —
a run of repeated, BPSK-modulated PN-code segments — arriving with an unknown
code phase and an unknown carrier-frequency (Doppler) offset, buried in
noise. It owns the whole receive-side acquisition pipeline:
raw cf32 → ring → reframe (doppler_bins, code_bins) → slow-time Doppler FFT
→ code correlation (corr2d) → CFAR gate → (Doppler, code-phase) hits
Construction is physics-only: you state the waveform (for BurstAcquisition
the preamble's samples and their rate fs; for Acquisition the code,
chip_rate and spc), the sensitivity (cn0_dbhz), and a target
(Pfa, Pd).
The engine sizes its own search grid — the coherent depth and the threshold
(and, on the continuous Acquisition, the non-coherent looks) — from the detection theory and streams
detections. You never pick a bin count or a threshold.
Two front doors, one engine
BurstAcquisition (this page) is for exactly the case above — an
isolated, data-free run of repeated code (a preamble). For a
continuous, data-modulated signal instead — a beacon/telemetry
stream where the code repeats forever with async data riding on top
— use doppler.acquire.Acquisition instead; see
Continuous, data-modulated signals
below. Both are thin front doors over the same C engine and share the
same push/streaming/property surface — they differ only in how the
Doppler axis is searched and which construction parameters make sense.
This is the usage walk-through. For the matched-filter surface it builds on, see
2-D Acquisition (CorrDetector2D); for what happens
after acquisition, see the DSSS BurstDespreader.
The 30-second version
import numpy as np
from doppler.cvt import bin_to_nrz
from doppler.acquire import BurstAcquisition
from doppler.wfm import PN, mls_poly
code = PN(poly=mls_poly(5), seed=1, length=5).generate(31) # 31-chip PN
# The engine takes the preamble as its SAMPLES, one period. A PN code
# becomes one by the library's chip rule (bin_to_nrz: 0 -> +1, 1 -> -1),
# each chip held for the samples per chip -- here 4.
nrz = np.zeros(code.size, np.float32)
bin_to_nrz(code, nrz)
preamble = np.repeat(nrz, 4).astype(np.complex64) # 124 samples
acq = BurstAcquisition(
preamble, # one period of the preamble's samples
reps=16, # up to 16 coherent repetitions
fs=1.023e6 * 4, # sample rate (Hz): chip_rate · samples/chip
cn0_dbhz=52, # sensitivity (carrier-to-noise density, dB-Hz)
pfa=1e-3, pd=0.9, # target false-alarm / detection rates
)
# The engine sized the grid: doppler_bins=7, code_bins=124, res≈4714 Hz.
# Sizing is honest: pd_burst is the Pd of the whole BURST -- every dwell
# its preamble spans, at an alignment it does not choose -- AVERAGED over
# the straddle priors (random Doppler / code phase across the grid), not
# the on-grid best case.
assert acq.pd_burst >= acq.pd # confirm it can meet the target
# demo capture: a real 31-chip DSSS burst in light noise, split into
# cf32 blocks (any block size works — the engine reframes internally).
capture = np.tile(preamble, 36) * 8.0
capture += (0.1 * (np.random.standard_normal(capture.size)
+ 1j * np.random.standard_normal(capture.size))
).astype(np.complex64)
iq_stream = np.array_split(capture, 8)
for chunk in iq_stream: # any cf32 block size
for dop, phase, peak, noise, stat, cn0, *_rest in acq.push(chunk):
print(f"hit: Doppler bin {dop}, code phase {phase} samples, "
f"C/N0≈{cn0:.1f} dB-Hz")
The acquisition problem¶
A spread-spectrum transmitter sends the same PN segment over and over (the acquisition preamble). By the time it reaches you it has an unknown code phase (propagation delay, a circular shift of the code) and an unknown carrier offset (Doppler), and it is well below the noise floor. Acquisition is the 2-D search that recovers both at once:
- code phase — where in the code period the signal sits (the matched-filter lag), and
- Doppler bin — how far the carrier has shifted.
BurstAcquisition evaluates the entire (Doppler × code-phase) grid per frame and
reports the cells whose detection statistic crosses an automatically set CFAR
gate.
How it works — slow-time / fast-time¶
Acquisition is a delay–Doppler search, and BurstAcquisition factors it into two
FFTs over the repeated-segment structure. It frames the stream into a
(doppler_bins, code_bins) matrix where each row is one PN segment — one code
repetition — and there are doppler_bins of them:
- fast-time — within one segment (
code_bins = len(preamble)samples). A circular correlation against the known preamble → the code-phase (delay) axis, in samples. - slow-time — across the
doppler_binssegments. An FFT along that axis resolves the per-segment carrier phase ramp → the Doppler axis.
fast-time (code_bins = len(preamble) samples, one segment)
┌─────────────────────────────────────────────┐
slow-time │ rep 0 · · · · · · · · · · · · · · · · · · │
(rows = │ rep 1 · · · · · · · · · · · · · · · · · · │ FFT down ──► Doppler
code │ ... │ columns
reps) │ rep d-1 · · · · · · · · · · · · · · · · · · │
└─────────────────────────────────────────────┘
│ circular code correlation
▼ along each row → code phase
The coherent depth doppler_bins is chosen by the engine — the smallest
number of repetitions in [1, reps] that meets pd at your cn0_dbhz (more on
that below). Internally the fast-time correlation is the FFT-domain
Corr2D engine against a single-row reference
(the code in row 0, zeros elsewhere); the slow-time FFT is applied to the data
before that correlation. Because the engine ingests raw samples and does
the slow-time FFT itself, you never pre-transform anything — just push IQ.
Doppler resolution and range (in Hz)
Two independent quantities, both reported as read-only properties:
- resolution
doppler_res_hz = fs / (n · doppler_bins)— set by how many repetitions the engine integrates (deeper → finer bins);nis the preamble's length in samples. (When the search is widened past the span by window tiles,doppler_binscounts tiles and the resolution is one tile's width,fs / n.) - span
doppler_span_hz = ±fs / (2·n)— the slow-time Nyquist, set by the repetition periodn/fsalone, independent ofdoppler_bins.
With a 31-chip code at 1.023 Mcps held 4 samples a chip (n = 124,
fs = 4.092 MHz) and doppler_bins=8: bins are ≈4125 Hz apart, spanning
±16.5 kHz. To search wider than the native span, pass a wider
doppler_uncertainty (the engine tiles it) or sweep a coarse Doppler grid
in front — see Widening the Doppler search.
Auto-configuration from the physics¶
You do not pick a threshold, a coherent depth, or a bin count — you state the operating point and the engine derives the grid with the detection functions.
First it converts your sensitivity to the per-sample amplitude SNR the detection
math uses (noise power = N0·fs over the sampled bandwidth):
Then it picks the smallest coherent depth D ∈ [1, reps] whose burst Pd
meets pd — least latency for a strong signal:
for D in 1 .. reps:
cells = searched_bins(D) · code_bins # Bonferroni population
pfa_cell = 1 - (1 - pfa)**(1/cells)
eta = det_threshold(pfa_cell) # √(-2 ln pfa_cell)
if burst_pd(snr, D, reps, eta) ≥ pd: break
doppler_bins = D # none met: the D with the most burst Pd, underpowered
threshold = eta · √(2/π) # eta in mean-CFAR units
burst_pd is what a caller actually gets from a burst. The engine searches
non-overlapping dwells of D repetitions aligned to the stream, not to the
burst, so a preamble of reps repetitions lands at a uniform offset and spans
about reps/D dwells, some of them only partly. The burst is detected when any
one of them is. A shallow D gets several chances; a deep one straddles the
preamble's edge at most alignments and loses more than its coherence gains, so
the sizer does not simply run to reps on a weak signal (doppler#1498). Each
dwell's Pd is AVERAGED over the straddle priors (quadrature), not taken
on-grid, with one Doppler and one code delay shared by every dwell of a burst,
and against the noise reference the gate actually divides by: the mean
magnitude of the whole surface, which the preamble's own correlation energy off
its peak inflates. A short code, or one with high sidelobes, therefore needs
more depth than its raw coherent gain suggests (doppler#1501).
The chosen grid is exposed as read-only properties:
acq = BurstAcquisition(preamble, reps=16, fs=1.023e6 * 4, cn0_dbhz=52,
pfa=1e-3, pd=0.9)
acq.doppler_bins, acq.code_bins # 7, 124 — the grid the engine chose
acq.doppler_span_hz, acq.doppler_res_hz # ±16500 Hz, 4714 Hz
acq.fs # 4.092e6 — the rate given
acq.pfa_cell # per-cell false-alarm prob (Bonferroni)
acq.eta # raw Rayleigh threshold √(-2 ln pfa_cell)
acq.threshold # the CFAR gate actually applied (eta·√(2/π))
acq.n_noncoh # non-coherent looks (1 = pure coherent)
acq.pd_burst, acq.underpowered # the burst's Pd, and whether it fell short
acq.pd_predicted # one dwell wholly inside the preamble
Check underpowered after construction¶
When the operating point is infeasible — no coherent depth up to reps gives
the burst pd — auto-config does not raise; it builds a best-effort grid
at the depth with the most burst Pd, with pd_burst < pd, sets
acq.underpowered = True, and emits a UserWarning. It does not add
non-coherent looks: a burst has one frame of preamble, so a second look adds
noise and moves the hit a frame late (doppler#1181). Guard against shipping an
under-powered acquirer:
The levers that close a shortfall are a higher cn0_dbhz (if the signal really
is stronger), more reps (a longer preamble), or a tighter
doppler_uncertainty.
cn0_dbhz is the universal sensitivity spec — carrier-to-noise density in
dB-Hz, independent of sample rate. A stronger cn0_dbhz lets the engine reach
pd with fewer repetitions (lower latency, coarser Doppler):
Depths are sized at the average Pd over the straddle priors (random
Doppler and code phase across the grid — see acq.straddle_loss for the
mean amplitude derating), so the engine buys enough integration to meet
pd in operation, not just on-grid:
cn0_dbhz |
chosen doppler_bins (reps=16) |
pd_burst |
pd_predicted (one dwell) |
|---|---|---|---|
| 56 | 2 | 0.95 | 0.60 |
| 54 | 3 | 0.90 | 0.58 |
| 52 | 7 | 0.91 | 0.84 |
noise_mode selects the CFAR estimator ("mean" by default, which is what the
analytic threshold assumes; "median" is more robust but is not analytically
calibrated).
Streaming and reading hits¶
push accepts any-length cf32 blocks, buffers them in a ring, and emits one
detection per coherent frame (or, on the non-coherent path, per n_noncoh
accumulated frames) whose statistic clears the gate. Each hit is a 9-tuple:
for (
dop, phase, peak, noise, stat, cn0, samples_consumed, chip_phase, rate
) in acq.push(chunk):
...
| field | meaning |
|---|---|
doppler_bin |
peak row — slow-time Doppler bin (0 … doppler_bins-1) |
code_phase |
peak column — integer-sample code phase (0 … code_bins-1) |
peak_mag |
peak correlation magnitude over the surface |
noise_est |
CFAR noise estimate |
test_stat |
peak_mag / noise_est (compared against threshold) |
cn0_dbhz_est |
estimated carrier-to-noise density (dB-Hz), comparable to cn0_dbhz |
samples_consumed |
raw sample offset (since this engine's own stream start) this hit's epoch ended at |
chip_phase |
the Dll seed in chips: the code phase inverted, advanced by the dwell's drift when a carrier is set |
Map the integer bins back to physical units:
from doppler.acquire import bin_to_signed
def doppler_hz(dop, acq):
"""Doppler bin → Hz (the library's fold: numpy's fftfreq convention)."""
return bin_to_signed(dop, acq.doppler_bins) * acq.doppler_res_hz
delay_chips = phase / 4 # code phase in samples ÷ samples/chip
reset() drains the ring and the coherent accumulator (use it between
independent captures).
Code phase tracks the stream offset
The code phase is measured against the frame grid, which is anchored at sample
0. Inserting Δ extra samples of lead-in (silence) before the burst shifts
every reported code_phase by Δ mod code_bins — that offset is extra
propagation delay. Frame the same burst at a different stream position and the
Doppler bin is unchanged but the code phase rotates accordingly.
How many hits to expect¶
Acquisition fires once per coherent frame while the burst fills the search
window. The engine frames on a fixed grid anchored at stream sample 0, so a frame
produces full processing gain only when its whole n = doppler_bins·code_bins
window lies inside the burst. For a burst of R segments (R·code_bins samples)
after L lead-in samples, the count of full-gain frames is
On the pure-coherent path each full frame yields one hit. A frame-aligned
lead-in (L a multiple of n) gives exactly F hits at one cell; a non-aligned
lead-in gives F to F+2 (boundary frames that straddle the burst edge may also
fire) and rotates the code phase by L mod code_bins. On the non-coherent path
(n_noncoh > 1) a hit arrives once per n_noncoh accumulated frames. A payload
that follows the preamble on a different code decorrelates from the matched
filter and does not produce burst-cell hits.
Choosing parameters¶
The minimum you must supply¶
Construction is physics-only, and only three arguments have no meaningful default. The smallest robust call is:
acq = BurstAcquisition(
preamble, # one period of the preamble's samples
fs=1.023e6 * 4, # their sample rate (Hz)
cn0_dbhz=66, # your link-budget sensitivity (dB-Hz)
)
assert acq.pd_burst >= acq.pd # confirm the search can meet the target
reps defaults to 1: a preamble of one period, which almost always lands
across two dwells and gives each only part of it. That costs about 3 dB here
— at 61 dB-Hz one aligned dwell would predict 0.81, the burst gets 0.47 — so
state the repetitions your preamble really has.
The tiers:
- Required — no meaningful default:
preamble(one period of its samples: for a PN code,bin_to_nrzheld for the samples per chip) andcn0_dbhz(the sensitivity; without one the engine integrates the whole preamble with no target to size against). - Set for your front end:
fs(the preamble's sample rate; default 1, normalized units) andreps(how many coherent repetitions you can afford — the coherence ceiling and your latency budget; default 1). - Safe defaults — leave unless you have a reason:
pfa=1e-3,pd=0.9,noise_mode="mean",doppler_uncertainty=0(full native span). - No non-coherent looks to opt into: a burst engine never adds them
(
n_noncohstays 1). A burst has one preamble, so a second look only adds noise and arrives late; when no depth up torepsmeetspd, the engine reportsunderpoweredinstead.
The period is not a parameter — it is len(preamble), so the engine and
your replica can never disagree.
Waveform vs. operator knobs¶
Some inputs describe the transmitted waveform — the receiver must match them, they are not knobs:
preamble— one period of the transmitted preamble, as samples at your front end's rate. For a PN code: its chips bybin_to_nrz, each held for the samples per chip (sample_rate / chip_rate; not samples per symbol — that issps). Its length is the periodn.fs— the sample rate (Hz) those samples are at. It sets the Doppler span±fs/(2n). You only move it by resampling the front end.- (
Acquisition, the continuous engine, still takescode,chip_rateandspcand builds the samples itself.)
The genuine receiver / operator knobs:
| Goal | Lever |
|---|---|
| Tighter false-alarm rate | smaller pfa (raises threshold) |
Hold pd at lower C/N0 |
larger reps (deeper coherent integration) |
| More sensitive within a known offset | tighter doppler_uncertainty (fewer cells → lower gate) |
| Robust noise estimate (uncalibrated) | noise_mode="median" |
reps is the preamble's length and the coherent ceiling; the engine uses the
smallest depth whose burst Pd meets pd, so a strong signal resolves in a few
repetitions. A longer preamble helps twice over: deeper dwells are possible, and
every depth gets more dwells to detect in. A burst engine never adds
non-coherent looks (n_noncoh stays 1), and there's no max_noncoh to raise.
reps assumes a data-free coherent window
Deeper coherent integration (a larger reps/doppler_bins) is only safe for
the classic preamble case this section describes — a data-free code
repeated back to back. Raising it on a continuous, data-modulated
signal doesn't just cost some gain, it can produce a deterministic
mislock onto the wrong Doppler bin. See
Continuous, data-modulated signals
below before reaching for reps on that kind of signal.
Narrowing the Doppler search¶
If you already know the carrier offset lies within ±Δf, pass
doppler_uncertainty=Δf (Hz). Within the native span, the engine then scans only
the Doppler bins inside that band, so the CFAR threshold pays a Bonferroni penalty
over fewer cells — a lower gate at the same system pfa, i.e. more
sensitivity for free. A value beyond the native span is accepted: the engine
tiles the range with frequency windows, one epoch each (see
Widening the Doppler search for that and for the
coarse-mix bank).
Continuous, data-modulated signals — the asynchronous-symbol-clock case¶
Everything above assumes the classic preamble case: an isolated run of the
same, data-free code repeated back to back, so any coherent depth up to
reps sums honestly. That assumption breaks for a continuous carrier —
e.g. a beacon or telemetry stream where the spreading code repeats forever and
BPSK data rides on top continuously, with a symbol clock that is not an
integer multiple of the code-epoch clock (chip_rate / symbol_rate not a
whole number — the common case in real hardware, where the two clocks derive
from independent budgets). BurstAcquisition is the wrong tool for this case
full stop, not just a tuning risk — reach for doppler.acquire.Acquisition
instead (below).
Why this changes the sizing decision¶
A data-bit transition landing inside one coherent epoch splits that epoch's contribution into two oppositely-signed halves. This does more than cost some correlation gain at the true code phase — it can produce a genuine, deterministic mislock: the code's off-peak correlation has no bound over an unequal-length partial window the way it does over a full period, so at some other candidate phase the two mis-signed partial sums can happen to add constructively and beat the (weakened) true-phase peak. This is not a noise event: a bad epoch replayed with all injected noise removed reproduces the identical mislock, bit for bit.
The failure only shows up once your coherent window spans more than a small
fraction of a symbol — which is exactly what a naive
BurstAcquisition(preamble, reps=16, ...) call risks on a signal like this: its
whole reason for existing is to greedily grow the coherent depth
(doppler_bins) up to reps to meet pd, with no notion that a
data-modulated symbol clock might be present at all — BurstAcquisition only
ever sees the preamble's samples and their rate. There is no parameter that makes this
class safe here; the fix is to use the other front door.
The robust default: Acquisition, not BurstAcquisition¶
Acquisition (continuous) closes this footgun structurally rather than
pricing it as a tunable trade-off: it always window-tiles the Doppler
search (rolling one epoch's own FFT spectrum across parallel frequency-window
hypotheses — the same mechanism as Widening the Doppler
search below, but built in). It combines
coherently across epochs only inside a tile, and only across epochs you
declare free of data (code_only_epochs, default 1, so by default it never
does): coherent_bins is derived from that and capped by doppler_rate.
There is no reps-like ceiling to raise by mistake. The rest of the
sensitivity margin comes from n_noncoh, auto-selected to meet pd up to an
internal 256-look safety valve — never a caller-tuned cap.
Given the high-level inputs a typical caller actually has — the code, the
chip_rate, a Doppler uncertainty, and a cn0_dbhz sensitivity — construction
looks just like BurstAcquisition's, minus reps:
from doppler.acquire import Acquisition
chip_rate = 1.023e6 # Hz, the waveform (matches the code above)
symbol_rate = 2400.0 # Hz -- present and asynchronous to chip_rate
doppler_uncertainty = 5000.0 # Hz, your link's Doppler budget
acq = Acquisition(
code, chip_rate=chip_rate, cn0_dbhz=52,
spc=4, doppler_uncertainty=doppler_uncertainty,
symbol_rate=symbol_rate, # diagnostic only -- see below
pfa=1e-3, pd=0.9,
)
assert acq.pd_predicted >= acq.pd # a higher cn0_dbhz or tighter
# doppler_uncertainty closes a shortfall
# (there is no reps/max_noncoh to raise)
assert acq.doppler_bins == 1 # doppler_uncertainty (5 kHz) is inside
# the native span here, so one window
# covers it -- widening it further would
# just tile more single-epoch windows,
# never a coherent depth
symbol_rate (and the derived read-only epochs_per_symbol) are
diagnostic only on this class — they don't feed sizing at all, because
sizing never had a coherent-depth axis to protect from the data clock in the
first place. Pass it if you want epochs_per_symbol for your own logging;
omit it and nothing about the search changes.
This is the robust default for a second, independent reason beyond the
mislock: it doesn't require phase coherency across the integration window.
A coherent stack over several epochs (BurstAcquisition's doppler_bins > 1)
is a single-frequency-hypothesis slow-time FFT — it implicitly assumes the
carrier's phase evolves predictably across the entire window. Any unmodeled
dynamics inside that window (residual acceleration, oscillator phase noise, a
Doppler rate finer than the bin grid resolves) bleeds coherent gain away as
the window lengthens, on top of whatever the data-transition mechanism costs.
Acquisition's non-coherent combining is blind to phase between looks by
construction, so it is immune to that failure mode too — a real channel has
these drift sources even when a controlled simulation does not model them.
The one thing you give up is the classic non-coherent combining loss (roughly
1–3 dB versus the same total energy combined ideally coherently, for small
N) — a fair price for robustness on a continuous, data-modulated link.
doppler_resolution was removed, not left as a knob
An earlier iteration of this class had doppler_resolution (floor a
minimum Doppler-bin resolution), to serve a genuine, separate need —
handing a finer Doppler estimate to a downstream tracking loop — but it
worked by forcing the coherent depth up, exactly the mislock risk
this section describes. Confirmed directly on this
project's own continuous receiver: forcing doppler_bins up via
doppler_resolution to shrink a downstream carrier-loop's pull-in range
caused frequent, gross mislocks (the wrong Doppler bin winning
outright, not just reduced accuracy) — the data modulation's own
baseband spectrum, sampled at close to one sample per symbol, is
broadband enough to alias real energy across the entire Doppler-bin
axis once the coherent window spans more than a handful of symbols.
Rather than leave a foot-gun knob on the class with a "use with care"
caveat, it was removed entirely. The only coherent depth Acquisition
(continuous) takes now is the one you declare safe (code_only_epochs),
and doppler_rate exists only to CAP it — it can lower the depth, never
raise it. A
finer Doppler estimate downstream is still a real need; it belongs to a
resolution mechanism that doesn't grow real coherent depth (zero-padding
the Doppler FFT, not yet shipped), not a parameter on this constructor.
When BurstAcquisition is still the right call¶
If there is no continuous data modulation to speak of — a genuine data-free
preamble, the classic case the rest of this guide describes — none of this
applies: use BurstAcquisition and get the coherent sizing (the smallest
depth up to reps whose burst Pd meets pd, with no non-coherent looks),
which pays no combining-loss penalty. Reach for Acquisition (continuous) only when the code carries
continuous data modulation during acquisition itself.
Advanced: pinning the grid directly¶
Both classes' auto-config are convenience layers over the same underlying
knob: doppler_bins and n_noncoh. If you already know the grid you want —
from a prior characterization run, or because you want to A/B two specific
configurations without reconstructing the object — configure_search_raw
pins it directly, bypassing auto-sizing:
It resizes every buffer/plan the grid touches (the slow-time FFT, the code
correlator, every per-frame scratch buffer) and re-derives the threshold
ladder for that exact grid, clearing any in-flight accumulation — call it
between push() calls, never a substitute for one. Bounds are still
enforced (doppler_bins ∈ [1, reps] — on Acquisition (continuous), reps
is internally pinned to 1, so this collapses to doppler_bins == 1 there;
n_noncoh ∈ [1, 256], the same internal safety-valve ceiling as auto-config);
an out-of-range pin raises ValueError and leaves the engine at its prior
grid.
Pinning a large doppler_bins on BurstAcquisition bypasses the mislock protection too
configure_search_raw is a direct pin — it doesn't know or care whether
your signal is continuous and data-modulated. Pinning a large
doppler_bins on BurstAcquisition reintroduces exactly the mislock risk
described in
Continuous, data-modulated signals
above, with no honest Pd pricing to warn you. Only pin a coherent depth
beyond a handful of epochs when you know the window is genuinely
data-free (a preamble) for its whole span — on a continuous signal, pin
Acquisition's n_noncoh instead (its doppler_bins bound collapses to
1, so there's no coherent depth to accidentally pin up). Note that a pin
also drops window tiling back to one native window, so a pinned
Acquisition no longer covers a doppler_uncertainty wider than the
span.
Widening the Doppler search¶
The native search spans only ±fs/(2n) (±chip_rate/(2·sf) for a code) — one
slow-time Nyquist, set by the code period. Past that, the engine can tile the
range itself: pass the wider doppler_uncertainty, and it searches frequency
windows of one epoch each. For a range many times the span, or to keep the
coherent depth in every channel, tile it with a sequence of coarse Doppler
hypotheses instead: mix the raw stream down by each
f_coarse and run BurstAcquisition on the result. The engine's fine FFT then
resolves the residual within the native span, and the absolute Doppler is
f_coarse + the fine bin.
import numpy as np
chip_rate = 1.0e6
fs = chip_rate * 2 # spc = 2
# Half this code's native window (chip_rate / sf), per the step rule below.
coarse_step = chip_rate / (2 * len(nrz))
coarse = np.arange(-100e3, 100e3, coarse_step) # coarse grid (Hz)
pre2 = np.repeat(nrz, 2).astype(np.complex64) # the code at 2 samples/chip
bank = [BurstAcquisition(pre2, reps=10, fs=fs, cn0_dbhz=50, pfa=1e-3, pd=0.9)
for _ in coarse] # one engine per channel (own state)
n0 = 0
for chunk in iq_stream: # any cf32 block
n = n0 + np.arange(len(chunk))
for f_coarse, acq in zip(coarse, bank):
mixed = (chunk * np.exp(-2j * np.pi * f_coarse / fs * n)).astype(np.complex64)
for dop, phase, *_rest in acq.push(mixed):
k = bin_to_signed(dop, acq.doppler_bins)
doppler_hz = f_coarse + k * acq.doppler_res_hz
print(f"hit: {doppler_hz:+.0f} Hz, code phase {phase}")
n0 += len(chunk)
- Coarse step. Within-segment carrier rotation (
residual · code-periodcycles) sinc-rolls the code correlation, so keep the residual under ~0.25 cycle for \<1 dB loss. Stepping by the full native window (chip_rate/sf) abuts the tiles but leaves a half-window residual at each edge — 0.5 cycle, ~4 dB down. Halving the step (50% overlap) drops the residual to 0.25 cycle (\<1 dB) at twice the channel count. - Relation to the roll method. Mixing the input is the continuous-frequency
dual of rolling
conj(FFT(code))by integer bins (each bin = one native window); mixing just lets you choose a finer, half-window step. - Cost scales linearly with the number of coarse channels; the inner fine search is unchanged.
Worked example — 1 Mcps, length-1000 code, ±100 kHz¶
| quantity | value |
|---|---|
chip rate chip_rate |
1 Mcps |
code length sf |
1000 chips |
| code period | sf/chip_rate = 1 ms |
repetitions reps |
10 → up to 10 ms coherent integration |
samples/chip spc |
2 → fs = 2 Msps, code_bins = sf·spc = 2000 |
Fine (native) search — the Doppler figures depend only on the waveform, not
spc (it cancels):
- resolution =
chip_rate / (sf · doppler_bins)=1/(10 ms)= 100 Hz at the fulldoppler_bins = 10 - span =
±chip_rate / (2·sf)= ±500 Hz (10 bins) - code-phase bins =
code_bins= 2000 (half-chip, becausespc = 2)
Reaching ±100 kHz — that is 200× the native ±500 Hz window, so sweep a coarse
grid (200 kHz total to cover):
- abutting tiles: step = native window =
chip_rate/sf= 1 kHz →200 kHz / 1 kHz= 200 channels, but the±500 Hzedge residual costs ~4 dB. - low-loss (50% overlap): step =
chip_rate/(2·sf)= 500 Hz →200 kHz / 500 Hz= 400 channels, residual±250 Hz(≤ 0.25 cycle, \<1 dB) — the same rule the snippet above applies to its own 31-chip code, where it gives a 16 kHz step and 13 channels. - each channel searches up to a
10 × 2000(Doppler × code-phase) surface at the native 100 Hz resolution, with up to 10 ms of coherent gain. The engine uses the smallest depth that meetspd, so a strong link searches fewer rows at a coarser resolution.
So acquisition is 200–400 fine searches, one per coarse mix —
BurstAcquisition runs the inner search and CFAR; your loop sweeps f_coarse.
Halving the requirement
(e.g. ±50 kHz) halves the channel count; a shorter code (smaller sf) widens the
native window and cuts the coarse sweep proportionally.
The DSSS receive chain¶
BurstAcquisition is the front of a two-stage receiver: acquire, then
track. Once it reports a (Doppler bin, code phase), hand the coarse
estimate to the BurstDespreader, which closes a
DLL + Costas loop to track code phase and carrier and recover the payload
bits. Acquisition lives in doppler.acquire, the despreaders in
doppler.dsss:
DsssBurstReceiver composes the whole chain in C — acquisition, a
BurstCapture that turns detections into
windows, and the despreader — so most links never wire these by hand.
See also¶
- Python: Acquire API — full
Acquisition/BurstAcquisition/BurstCapturereference - Python: DSSS API —
BurstDespreaderand the composed receivers - Python: Detection Statistics —
det_threshold/det_pd/det_dwell - Gallery: 2-D Acquisition — the
CorrDetector2Dmatched-filter surface - Design: pure-functional acquisition kernel — the
elastic
(ddc_fn, acq_fn)pipeline design, for pod fan-out and checkpoint/resume