Python DSSS API¶
The doppler.dsss module provides a full DSSS receiver: Acquisition — the
streaming burst-acquisition engine that finds an unknown code phase and
Doppler — and two despreaders that track and despread once acquired:
Despreader, the continuous DLL+Costas receiver (GPS-like, always
tracking), and BurstDespreader, the preamble-aware payload tracker for
latency-bound bursts.
Source:
src/doppler/dsss/__init__.py
Acquisition — streaming burst acquisition¶
Acquisition searches a streamed cf32 signal for a repeated BPSK PN burst over the
joint (Doppler × code-phase) grid, sizing its own search grid — coherent depth,
CFAR threshold, non-coherent looks — from the physics (chip_rate, cn0_dbhz, pfa, pd) using doppler.detection. Push
arbitrary-length blocks; it yields one record per detection — (doppler_bin, code_phase, peak_mag, noise_est, test_stat, snr_est) — whose (doppler_bin, code_phase) seed the BurstDespreader. See the
DSSS Burst Acquisition guide for the search-space
sizing and a worked example.
Acquisition
¶
Create a continuous-mode acquisition engine: always wideband window-tiling, allowing a block-coherent depth inside the tiles to accommodate waveforms with code-only windows.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
code
|
NDArray[uint8]
|
PN chips (0/1), length code_len. |
required |
spc
|
int
|
Samples per chip (>= 1). |
4
|
chip_rate
|
float
|
Chip rate in Hz (> 0). |
1000000.0
|
symbol_rate
|
float
|
Continuous data-symbol rate in Hz; <= 0 means no known clock. Diagnostic only (exposed via acq_state_t::epochs_per_symbol), doesn't feed sizing: this engine never coherently combines regardless of the data-modulation clock. |
1000.0
|
cn0_dbhz
|
float
|
Carrier-to-noise density in dB-Hz (> 0). |
50.0
|
doppler_uncertainty
|
float
|
One-sided Doppler search half-range in Hz; 0 uses the full native span +/- chip_rate/(2*sf) (still window-tiled, at window_bins=1). |
0.0
|
pfa
|
float
|
Target system (max-of-N) false-alarm probability (0,1). |
1e-3
|
pd
|
float
|
Target detection probability (0,1). |
0.9
|
noise_mode
|
Literal['mean', 'median', 'min', 'max']
|
CFAR mode index: 0=mean, 1=median, 2=min, 3=max. |
"mean"
|
code_only_epochs
|
int
|
Whole code-only epochs a waveform's code-only window holds at any chip phase (floor(W_symbols * chips_per_symbol / sf) - 1; design §2.1) -- the engine allows a coherent depth to accommodate such waveforms. 1 = no window: a coherent depth of 1. |
1
|
doppler_rate
|
float
|
Doppler rate in Hz/s the coherent depth is bounded against (the drift over one block stays inside half a slow-time row); 0 leaves the window as the only bound. |
0.0
|
Warns:
| Type | Description |
|---|---|
UserWarning
|
Emitted after construction when |
Examples:
>>> import numpy as np
>>> from doppler.dsss import Acquisition
>>> from doppler.wfm import PN, mls_poly
>>> code = np.asarray(PN(poly=mls_poly(5), seed=1,
... length=5).generate(31)).astype(np.uint8)
>>> s0 = np.repeat(np.where(code & 1, -1.0, 1.0), 4).astype(
... np.complex64)
>>> burst = np.tile(np.roll(s0, 17), 23).astype(np.complex64)
>>> a = Acquisition(code, spc=4, chip_rate=1e6, cn0_dbhz=50.0)
>>> a.push(burst)[0][:2] # detects (Doppler-window bin, code phase)
(0, 17)
>>> a.coherent_bins # no window given: one epoch
1
>>> b = Acquisition(code, spc=4, chip_rate=1e6, cn0_dbhz=50.0,
... code_only_epochs=7)
>>> b.coherent_bins # (7 + 1) // 2: a whole block fits
4
max_peaks
property
¶
The peak list's capacity per dwell (1 = the classic gated maximum); set with set_max_peaks().
carrier_freq_hz
property
¶
RF carrier the Doppler is physically coupled to, Hz (0.0 = uncoupled); set with set_carrier_freq_hz().
doppler_bins
property
¶
Native Doppler bins this engine searches: the window-tile count
times the block-coherent depth inside each tile (coherent_bins), one
uniform grid of doppler_res_hz over the tiled span in FFT-bin order
-- what a hit's doppler_bin indexes.
coherent_bins
property
¶
The block-coherent depth D inside every window tile (design §2.3): epochs per block and Doppler rows per tile. 1 without a code-only window.
pfa_cell
property
¶
Bonferroni per-cell false-alarm probability over the searched cells.
pd_predicted
property
¶
Predicted Pd at cn0_dbhz and the chosen grid: the average Pd over the straddle priors (slow-time scalloping, intra-segment rotation, code-phase sample offset - quadrature over uniform priors), matching what the Monte-Carlo characterization measures rather than the on-grid best case.
straddle_loss
property
¶
Mean amplitude derating of the correlation peak from grid straddle (slow-time Doppler scalloping x intra-segment rotation x code-phase sample offset, each averaged over a uniform prior) - a diagnostic summary; 20*log10(straddle_loss) is the loss in dB. Sizing and pd_predicted average Pd itself over the priors (Pd at this mean amplitude would overstate the mean Pd).
doppler_span_hz
property
¶
Native unambiguous Doppler half-range = +/- chip_rate/(2*sf) Hz.
underpowered
property
¶
True when pd_predicted < pd -- the search cannot meet the target pd at this cn0_dbhz and geometry. The engine still builds a best-effort grid rather than failing; because C cannot raise a Python warning from a successful create, construction also emits a UserWarning in this case.
symbol_rate
property
¶
Continuous data-symbol rate (Hz) this engine was built with -- diagnostic only, doesn't feed sizing (this engine never coherently combines regardless).
epochs_per_symbol
property
¶
(chip_rate/sf)/symbol_rate -- code epochs per data symbol; 0 when symbol_rate is 0.
threads
property
¶
Workers the searcher fans its tiles across, the calling thread included (design §2.3); 1 = serial. Set with set_threads(); a continuous engine with more than one tile starts at the machine's online core count.
keep_surface
property
writable
¶
1 keeps every decided dwell's surface for surface() (normalised
into the gate's units at each decision); 0 (the default) costs nothing.
set_surface_sink() in C sets it.
surface_rows
property
¶
Rows of the surface surface() returns: the Doppler axis in surface
units (tiles x interpolated slow-time rows); its columns are
code_bins.
surface_at
property
¶
samples_consumed of the dwell whose surface surface() returns (0
until one has been captured).
n_held
property
¶
Picks of the last decided dwell held as same-code-phase twins rather than listed (design §7.1).
peak_conc
property
¶
Concentration of the last dwell's strongest peak: the power of its main lobe (its row and one either side, the exclusion zone's width) over the total power of its code-phase column across every Doppler row and tile. Near 1 for a clean single emitter, even one straddling two tiles; about 0.5 when a data transition splits it into twins two or more tiles away; lower when a coherent block straddles data (design §2.4).
reset
¶
Drain the input ring and reset the coherent accumulator.
Discards any buffered samples that have not yet completed a frame and clears the non-coherent power accumulator and dwell bookkeeping, so the next push() begins a fresh search from an empty ring. The construction parameters — grid, thresholds, and PN reference — are untouched; only the in-flight streaming state is dropped.
Examples:
>>> import numpy as np
>>> from doppler.dsss import Acquisition
>>> from doppler.wfm import PN, mls_poly
>>> code = np.asarray(PN(poly=mls_poly(5), seed=1,
... length=5).generate(31)).astype(np.uint8)
>>> s0 = np.repeat(np.where(code & 1, -1.0, 1.0), 4).astype(
... np.complex64)
>>> burst = np.tile(np.roll(s0, 17), 23).astype(np.complex64)
>>> a = Acquisition(code, spc=4, chip_rate=1e6, cn0_dbhz=50.0)
>>> _ = a.push(burst[:100]) # a partial frame, buffered mid-stream
>>> a.reset() # drop it before it can bias a detection
>>> a.push(burst)[0][:2] # (Doppler bin, code phase)
(0, 17)
push
¶
Stream raw samples; emit one event per CFAR dump above threshold.
Buffers x, then for every complete frame applies the slow-time Doppler FFT, correlates against the PN reference, dumps the coherent surface (or, when n_noncoh > 1, accumulates |·|² over n_noncoh looks first), gates the peak on the auto-configured threshold, and appends an acq_result_t. Each event carries the peak's Doppler bin and code phase (the two search axes), its CFAR statistic, and an estimated C/N0 — see acq_result_t.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x
|
complex
|
Raw input, interleaved CF32, n_in complex samples. |
required |
Returns:
| Type | Description |
|---|---|
list[tuple[int, int, float, float, float, float, int]]
|
Number of events written (0 … max_results). |
Examples:
>>> import numpy as np
>>> from doppler.dsss import Acquisition
>>> from doppler.wfm import PN, mls_poly
>>> code = np.asarray(PN(poly=mls_poly(5), seed=1,
... length=5).generate(31)).astype(np.uint8)
>>> s0 = np.repeat(np.where(code & 1, -1.0, 1.0), 4).astype(
... np.complex64)
>>> a = Acquisition(code, spc=4, chip_rate=1e6, cn0_dbhz=50.0,
... doppler_uncertainty=40e3)
>>> fs = 1e6 * 4 # sample rate = chip_rate * spc
>>> t = np.arange(a.code_bins * a.n_noncoh)
>>> carrier = np.exp(2j * np.pi * (a.doppler_res_hz / fs) * t)
>>> sig = (np.tile(np.roll(s0, 17), a.n_noncoh)
... * carrier).astype(np.complex64)
>>> a.push(sig)[0][:2] # (Doppler-window bin, code phase)
(1, 17)
configure_search_raw
¶
Pin the search grid directly, bypassing both auto-sizing searches -- the advanced escape hatch (mirrors Dll.configure_lock_raw/Costas.configure_lock). Resizes every buffer/plan that depends on the grid (the slow-time FFT, the code correlator, the reference, and every per-frame scratch buffer), re-derives the threshold ladder for the pinned grid from the same physics init used, and clears in-flight accumulation (ring contents, the non-coherent power accumulator, dwell bookkeeping) -- call between push() calls, never a substitute for one. Raises ValueError if doppler_bins is outside [1, reps] or n_noncoh is outside [1, 256] (the internal non-coherent-look safety-valve ceiling).
Resizes every buffer/plan that depends on the grid (the slow-time FFT, the code correlator, the reference, and every per-frame scratch buffer), re-derives the threshold ladder for the pinned grid from the same physics acq_create_burst()/acq_create_continuous() used, and clears in-flight accumulation (ring contents, the non-coherent power accumulator, dwell bookkeeping) — call between push() calls, never a substitute for one.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
doppler_bins
|
int
|
Coherent depth to pin, in |
required |
n_noncoh
|
int
|
Non-coherent look count to pin, in |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the C call returns a non-zero status. The exception message is
|
Examples:
>>> import numpy as np
>>> from doppler.dsss import Acquisition
>>> from doppler.wfm import PN, mls_poly
>>> code = np.asarray(PN(poly=mls_poly(5), seed=1,
... length=5).generate(31)).astype(np.uint8)
>>> s0 = np.repeat(np.where(code & 1, -1.0, 1.0), 4).astype(
... np.complex64)
>>> a = Acquisition(code, spc=4, chip_rate=1e6, cn0_dbhz=50.0)
>>> a.configure_search_raw(doppler_bins=1, n_noncoh=4) # pin the grid
>>> a.doppler_bins, a.n_noncoh
(1, 4)
>>> burst = np.tile(np.roll(s0, 17), 4).astype(np.complex64)
>>> a.push(burst)[0][:2] # detects at the pinned grid
(0, 17)
set_max_peaks
¶
How many peaks a dwell may report -- the peak list's capacity (docs/design/async-dsss-receiver.md section 7.1). One (the default) is the classic gated maximum. More lists every peak above the same gate, strongest first, with an exclusion zone of one Doppler bin by one chip around each (one emitter's main lobe, so its own shoulders are not the next peak) and the two-epoch rule for a peak at an already-listed code phase (a data transition inside the epoch splits one emitter into twins at its own code phase on other tiles; such a peak is held for one dwell and listed only if it is still there, at the same tile, on the next). Each listed peak is one record from push(), all of a dwell's sharing samples_consumed and noise_est; a held twin takes one of the n slots that dwell but is not reported. The threshold does not change with n. Raises ValueError outside 1..64. Clears the held candidates.
One (the default) is the classic detector -- the maximum of the
surface, gated. More is the list of docs/design/async-dsss-receiver.md
§7.1: every peak above the same gate, strongest first, each with an
exclusion zone of one Doppler bin by one chip around it (one emitter's
main lobe, so its own shoulders are not the next peak), and the
two-epoch rule for a peak at an already-listed code phase -- a data
transition inside the epoch splits one emitter into twins at its own
code phase on other tiles, so such a peak is held for one dwell and
listed only if it was there, at the same tile, on the previous one.
Each listed peak is one acq_result_t from acq_push(), all of a dwell's
sharing its samples_consumed and noise_est. A held twin takes a
slot of the n for that dwell but is not reported. The threshold does
not change: a second peak is another draw from the same cells against
the same union bound. Clears the held candidates.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
n
|
int
|
1 … ACQ_MAX_PEAKS. |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the C call returns a non-zero status. The exception message is
|
Examples:
set_carrier_freq_hz
¶
Couple the code clock to the carrier: the chip rate dilates by doppler_hz / carrier_freq_hz, and the engine accounts for it -- every window tile's epochs are shifted along the code axis by the drift the tile's own frequency implies before the slow-time transform (inside a coherent block of D epochs), and the hand-off advances the hit's code phase by the drift over half the dwell (design section 12.11, 12.12). Config, not running state: not in the state blob, so a resumed engine wants it set again. 0.0 (the default) = uncoupled, the engine as it ran without it. Raises ValueError for a negative or non-finite value.
A physically-coupled Doppler moves the code as well as the carrier -- 100 chips/s at 20 ppm of 5 Mcps -- and the engine's two long integrations both smear over it (doppler#1256, #1254):
- Inside a coherent block of D epochs every tile's epoch
correlations are shifted along the code axis by the drift the tile's
own frequency implies,
f_tile / carrierchips per chip, aligned to the block's middle, before the slow-time transform (a linear phase on each epoch's product, exact to a fraction of a sample). Measured at SPEC's 20 ppm with D = 154 (3.1 chips of drift across the block): without it the block's peak is 13 dB down and 3 chips wide and the depth detects nothing at 34 dB-Hz; with it the block reads as a still one. - The hand-off (acq_build_handoff()) advances the hit's code phase by the drift over half the dwell -- the non-coherent sum's peak is the phase at the dwell's middle, the seed is wanted at its end: 0.9 chip at the 40 dB-Hz floor, past a refine loop's pull-in.
Config, not running state: it is not in the state blob, so a resumed engine wants it set again by its holder, as at create. Default 0.0 (uncoupled) is the engine exactly as it ran without it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
carrier_freq_hz
|
float
|
RF carrier, Hz; 0.0 = uncoupled. |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the C call returns a non-zero status. The exception message is
|
Examples:
>>> import numpy as np
>>> from doppler.dsss import Acquisition
>>> from doppler.wfm import Gold
>>> code = np.asarray(Gold().generate(1023)).astype(np.uint8)
>>> a = Acquisition(code, spc=2, chip_rate=5e6, symbol_rate=2700.0,
... cn0_dbhz=45.0, doppler_uncertainty=50e3)
>>> a.carrier_freq_hz
0.0
>>> a.set_carrier_freq_hz(2.5e9)
>>> a.carrier_freq_hz
2500000000.0
set_threads
¶
Set how many threads the searcher fans its tiles across (design §2.3: a roll per thread on persistent workers).
A continuous engine is created with a pool of the machine's online cores when it has more than one tile; a burst engine, and a single-tile one, run serially. This sets the count: 0 auto-selects the online core count, 1 runs everything on the calling thread, n runs on n workers (the caller included). The workers are created here, once, and parked between pushes; nothing is created per push. The surface is bit-identical at every count -- the tiles are independent after the one forward transform and each writes its own rows -- so this changes the cost of a push and nothing about its result. Setup path, never hot; not while another thread is inside push().
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
n
|
int
|
Thread count; 0 = online cores, 1 = serial. |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the C call returns a non-zero status. The exception message is
|
Examples:
>>> import numpy as np
>>> from doppler.dsss import Acquisition
>>> from doppler.wfm import PN, mls_poly
>>> code = np.asarray(
... PN(poly=mls_poly(9), seed=1, length=9).generate(511), np.uint8)
>>> a = Acquisition(code, spc=2, chip_rate=1e6, cn0_dbhz=50.0,
... doppler_uncertainty=4000.0)
>>> a.threads >= 1 # a pool, sized to the machine
True
>>> a.set_threads(1)
>>> a.threads
1
set_telemetry
¶
Attach (or detach) a telemetry context and register the engine's probes on it (design §2.4).
Registers ten probes, emitted once per DECIDED dwell (a coherent dump,
or the dwell that completes n_noncoh looks) and further thinned by
decim: "threshold on the coherent path, eta_nc
on the non-coherent one — plotted together they show exactly where a
hit fired), "noise_est),
"peak_conc: its main lobe's power
over its whole column's, near 1 for one clean emitter even when it
straddles two tiles, about 0.5 when a data transition splits it into
twins two or more tiles away, lower still when a coherent block
straddles data — the discriminator between one emitter's splatter and a
second emitter) and "
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
tlm
|
object | None
|
Telemetry context to attach, or NULL to detach. |
required |
prefix
|
str
|
Probe-name prefix, e.g. "acq" or "ch0.acq". |
required |
decim
|
int
|
Emit every decim-th decided dwell; >= 1. |
1
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If the C call returns a non-zero status. The exception message is
|
Examples:
>>> import numpy as np
>>> from doppler.dsss import Acquisition
>>> from doppler.telemetry import Telemetry
>>> from doppler.wfm import PN, mls_poly
>>> code = np.asarray(
... PN(poly=mls_poly(9), seed=1, length=9).generate(511), np.uint8)
>>> a = Acquisition(code, spc=2, chip_rate=1e6, cn0_dbhz=50.0)
>>> tlm = Telemetry(1 << 12)
>>> a.set_telemetry(tlm, "acq")
>>> sorted(tlm.probe_names)[:3]
['acq.col', 'acq.conc', 'acq.gate']
>>> x = np.zeros(a.n_noncoh * 511 * 2 * 3, dtype=np.complex64)
>>> _ = a.push(x)
>>> len(tlm.read()) % 10 # ten records per decided dwell
0
surface
¶
The last decided dwell's surface, in the gate's own units.
Copies the surface the last dwell was decided on into out, row-major
surface_rows (Doppler: tiles, or interpolated slow-time rows) by
code_bins (code phase), every cell divided by the same CFAR reference
the gate used — so a cell reads as its own test statistic, to a float
rounding (the SIMD build's fast-math may take a reciprocal in this loop
and a divide in the gate's), and the gate (threshold, or eta_nc on
the non-coherent path) is a flat plane on a plot. The engine keeps this
only while keep_surface is set (a caller sets it, or
acq_set_surface_sink() does): set it, push, then read. surface_at
says which dwell it is; a time-decimated record is the caller reading
every k-th dwell, or a sink with decim.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
out
|
NDArray[float32]
|
At least |
required |
Returns:
| Type | Description |
|---|---|
int
|
Cells written ( |
Examples:
>>> import numpy as np
>>> from doppler.dsss import Acquisition
>>> from doppler.wfm import PN, mls_poly
>>> code = np.asarray(
... PN(poly=mls_poly(9), seed=1, length=9).generate(511), np.uint8)
>>> a = Acquisition(code, spc=2, chip_rate=1e6, cn0_dbhz=50.0)
>>> a.keep_surface = 1
>>> x = np.zeros(a.n_noncoh * 511 * 2, dtype=np.complex64)
>>> _ = a.push(x)
>>> s = np.empty(a.surface_rows * a.code_bins, dtype=np.float32)
>>> a.surface(s) == s.size
True
>>> s.reshape(a.surface_rows, a.code_bins).shape == (a.surface_rows, 1022)
True
surface_doppler_hz
¶
The surface's Doppler axis: the frequency of each row, in Hz.
One value per surface row, the fold and scale a hit's doppler_hz_est
uses (dp_fftfreq_index() times doppler_res_hz, on the interpolated
grid where the slow-time axis is interpolated), so a plot of
acq_surface() carries the same axis a DetectionEvent reports on.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
out
|
NDArray[float64]
|
At least |
required |
Returns:
| Type | Description |
|---|---|
int
|
Values written ( |
Examples:
>>> import numpy as np
>>> from doppler.dsss import Acquisition
>>> from doppler.wfm import PN, mls_poly
>>> code = np.asarray(
... PN(poly=mls_poly(9), seed=1, length=9).generate(511), np.uint8)
>>> a = Acquisition(code, spc=2, chip_rate=1e6, cn0_dbhz=50.0,
... doppler_uncertainty=4000.0)
>>> f = np.empty(a.surface_rows, dtype=np.float64)
>>> a.surface_doppler_hz(f) == a.surface_rows
True
>>> bool(f[0] == 0.0 and f.min() < 0.0 < f.max())
True
surface_chip_phase
¶
The surface's code-phase axis: the chip phase of each column.
One value per surface column, in chips, the same mapping
acq_build_handoff() applies to a hit's code_phase — so a plotted peak
sits at the chip phase the DetectionEvent would carry.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
out
|
NDArray[float64]
|
At least |
required |
Returns:
| Type | Description |
|---|---|
int
|
Values written ( |
Examples:
>>> import numpy as np
>>> from doppler.dsss import Acquisition
>>> from doppler.wfm import PN, mls_poly
>>> code = np.asarray(
... PN(poly=mls_poly(9), seed=1, length=9).generate(511), np.uint8)
>>> a = Acquisition(code, spc=2, chip_rate=1e6, cn0_dbhz=50.0)
>>> c = np.empty(a.code_bins, dtype=np.float64)
>>> a.surface_chip_phase(c) == a.code_bins
True
>>> bool(c[0] == 0.0 and c[1] == 510.5)
True
state_bytes
¶
Size in bytes of this object's serialized state.
The exact length get_state returns and set_state requires. It
depends on how the object was constructed (state arrays are sized at
construction), so read it from the instance rather than assuming a
constant.
Raises RuntimeError if the Acquisition has already been destroyed.
Returns:
| Type | Description |
|---|---|
int
|
Byte length of one serialized state blob. |
get_state
¶
Serialize this object's mutable state to bytes.
Captures exactly the state that evolves as the object runs, so a blob taken now and restored later resumes from this point. Construction parameters are not included: restore into an object built the same way.
The blob is opaque and always state_bytes() long. Its layout is an
implementation detail of the C core and is not a stable format across
builds.
Raises RuntimeError if the Acquisition has already been destroyed.
Returns:
| Type | Description |
|---|---|
bytes
|
Opaque snapshot, |
set_state
¶
Restore mutable state from a get_state() blob.
Overwrites the live state in place; the object keeps the parameters it
was constructed with. Length is validated against state_bytes()
before the blob is handed to the C core, and the core may reject it as
well.
Raises TypeError if blob is not bytes, ValueError if its
length differs from state_bytes() or the core rejects it, and
RuntimeError if the Acquisition has already been destroyed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
blob
|
bytes
|
A |
required |
destroy
¶
Release the underlying C resources immediately.
Ordinarily unnecessary: the resources are freed when the object is garbage-collected. Call this to release them at a definite point instead, or use the object as a context manager, which calls it on exit.
Idempotent: calling it again on an already-released object does
nothing. Every other method raises RuntimeError once it has run.
__enter__
¶
Enter a context manager, returning this object.
Lets a Acquisition be used in a with statement so its C resources are
released deterministically on exit rather than at collection time.
Returns:
| Type | Description |
|---|---|
Acquisition
|
This same object, not a copy. |
__exit__
¶
__exit__(
exc_type: object | None = ...,
exc: object | None = ...,
tb: object | None = ...,
) -> None
Exit a context manager, releasing the Acquisition.
Equivalent to calling destroy(). Returns None, so an exception
raised inside the with body propagates normally; this never
suppresses one.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
exc_type
|
object | None
|
Exception class, or None. Ignored. |
...
|
exc
|
object | None
|
Exception instance, or None. Ignored. |
...
|
tb
|
object | None
|
Traceback object, or None. Ignored. |
...
|
PolynomialPhaseEstimator — feedforward frequency + chirp-rate estimator¶
PolynomialPhaseEstimator recovers the frequency and chirp rate (Doppler and
Doppler rate) of a complex sequence in one shot — no tracking loop — via a
coherent (chirp-rate × frequency) matched-filter surface: for each rate
hypothesis it dechirps the sequence and FFTs it, and the surface's global peak
(parabola-interpolated in both axes) gives (r, f). Being fully coherent it is
the matched-filter-optimal estimator, so it holds at low SNR. The single
max_rate knob spans both regimes: max_rate = 0 collapses to one FFT —
pure Doppler, near-static — while max_rate > 0 searches a ±max_rate
dechirp bank for a severe LEO chirp (cost scales with the rate span). The caller
strips modulation first (data-aided wipe, or square an M-PSK stream for the
non-data-aided case). estimate(x) returns a
PolynomialPhaseEstimate(freq_norm, rate_norm, snr_db) record in normalized units
(cycles/sample and cycles/sample²); scale by the sequence's sample rate for Hz.
It is the feedforward front-end for chirping-burst demodulation.
PolynomialPhaseEstimator
¶
Create a polynomial-phase estimator.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
max_len
|
int
|
Maximum input sequence length (>= 4). |
4096
|
max_rate
|
float
|
Chirp-rate search half-span (cycles/sample^2); 0 searches frequency only (a single FFT — near-static Doppler). |
0.0
|
Examples:
Create with defaults:
>>> from doppler.dsss import PolynomialPhaseEstimator
>>> obj = PolynomialPhaseEstimator(max_len=4096, max_rate=0.0)
nfft
property
¶
zero-padded transform length: 4 * next_pow2 (max_len). The 4x is
deliberate -- a finer frequency grid before the parabolic peak
refinement, which matters because the input is often short (preamble
partials, symbol streams). It also sizes buf, spec and mag, so
the footprint is 4x what a bare next-pow2 would suggest.
reset
¶
Do nothing — the estimator keeps no running state between calls.
A feedforward analyzer computes each estimate purely from the segment it is handed, so there is nothing to clear. The method exists only to satisfy the common object protocol; calling it is always safe and has no effect.
Examples:
estimate
¶
Estimate (freq, chirp-rate) of a complex sequence via the 2-lag HAF.
Runs the full 2-D matched-filter search in one shot: for each chirp-rate hypothesis the segment is dechirped and FFT-ed, and the peak of the resulting surface — refined sub-bin by parabolic interpolation on both axes — gives the estimate. With max_rate = 0 the rate axis collapses to a single FFT (pure Doppler) and the returned rate is forced to exactly 0.
Feed a segment whose modulation has already been stripped (data-aided by the known symbols, or non-data-aided by the M-th-power trick — remembering that raising to the M-th power scales both returned values by M). The result carries freq_norm (cycles/sample), rate_norm (cycles/sample^2), and snr_db (a rough peak-to-mean confidence).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x
|
complex
|
Complex segment (modulation already stripped by the caller). |
required |
Returns:
| Type | Description |
|---|---|
PolynomialPhaseEstimate
|
The estimate; all fields are zeroed if n_in is out of range. |
Examples:
>>> import numpy as np
>>> from doppler.dsss import PolynomialPhaseEstimator
>>> m = np.arange(512)
>>> f, r = 0.05, 1e-5 # true Doppler + chirp rate
>>> x = np.exp(2j*np.pi*(f*m + 0.5*r*m*m)).astype(np.complex64)
>>> p = PolynomialPhaseEstimator(max_len=512, max_rate=5e-5)
>>> e = p.estimate(x) # one-shot coherent search
>>> round(e.freq_norm, 4), round(e.rate_norm, 7)
(0.0501, 1e-05)
destroy
¶
Release the underlying C resources immediately.
Ordinarily unnecessary: the resources are freed when the object is garbage-collected. Call this to release them at a definite point instead, or use the object as a context manager, which calls it on exit.
Idempotent: calling it again on an already-released object does
nothing. Every other method raises RuntimeError once it has run.
__enter__
¶
Enter a context manager, returning this object.
Lets a PolynomialPhaseEstimator be used in a with statement so its C
resources are released deterministically on exit rather than at
collection time.
Returns:
| Type | Description |
|---|---|
PolynomialPhaseEstimator
|
This same object, not a copy. |
__exit__
¶
__exit__(
exc_type: object | None = ...,
exc: object | None = ...,
tb: object | None = ...,
) -> None
Exit a context manager, releasing the PolynomialPhaseEstimator.
Equivalent to calling destroy(). Returns None, so an exception
raised inside the with body propagates normally; this never
suppresses one.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
exc_type
|
object | None
|
Exception class, or None. Ignored. |
...
|
exc
|
object | None
|
Exception instance, or None. Ignored. |
...
|
tb
|
object | None
|
Traceback object, or None. Ignored. |
...
|
BurstDemod — feedforward DSSS frame demodulator¶
BurstDemod is the whole post-acquisition payload chain, in C, with no
tracking loops: it estimates the residual Doppler and Doppler rate
feedforward (composing PolynomialPhaseEstimator over the unmodulated preamble),
dechirps the burst at sample rate, despreads the short data code to soft BPSK
symbols, frame-syncs against a known word, and checks a CRC-16 trailer. The one
max_rate knob spans both operating points: near-static Doppler (0, a
single-FFT estimate) and a severe LEO chirp (> 0, the coherent rate
search). It is one-shot per burst — seed it from acquisition and call demod.
The frame is [sync header][payload][CRC-16 trailer] in BPSK symbols (no FEC).
demod(x) returns the payload bits; the read-back properties report
frame_valid (CRC), est_freq_hz, est_rate_hz, frame_offset, and
n_symbols.
import numpy as np
from doppler.dsss import BurstDemod
# Build a burst: 5x acquisition preamble, then a spread frame
# [Barker-13 sync | payload | CRC-16]. A real receiver takes (f0, code
# phase) from `Acquisition`; here we seed a known prior so the block runs.
acq_code = ((np.arange(500) * 2654435761 >> 13) & 1).astype(np.uint8)
data_code = ((np.arange(50) * 40503 >> 7) & 1).astype(np.uint8)
sync_word = np.array([0, 0, 0, 0, 0, 1, 1, 0, 0, 1, 0, 1, 0], np.uint8)
payload = ((np.arange(64) * 7 + 3) & 1).astype(np.uint8)
def _crc16(bits): # CRC-16/CCITT, MSB-first
c = 0xFFFF
for b in bits:
c ^= (int(b) & 1) << 15
c = ((c << 1) ^ 0x1021) & 0xFFFF if c & 0x8000 else (c << 1) & 0xFFFF
return c
def _sign(b): # 0/1 chips -> +1/-1 BPSK
return np.where(np.asarray(b) & 1, -1.0, 1.0)
crc = _crc16(payload)
crc_bits = np.array([(crc >> (15 - j)) & 1 for j in range(16)], np.uint8)
frame = np.concatenate([sync_word, payload, crc_bits])
chips = [np.tile(_sign(acq_code), 5)] # unmodulated preamble
chips += [_sign(b) * _sign(data_code) for b in frame]
f0, preamble_start = 0.012, 0 # cyc/sample; from acquisition
bb = np.repeat(np.concatenate(chips), 4).astype(np.complex64)
nn = np.arange(len(bb))
rx = (bb * np.exp(2j * np.pi * f0 * nn)).astype(np.complex64)
d = BurstDemod(data_code, spc=4, chip_rate=1e6, carrier_hz=0.0,
max_rate=0.0, frame_syms=13 + 64 + 16, est_segments=10)
d.set_preamble(acq_code, reps=5)
d.set_sync(sync_word) # 0/1 BPSK sync header
d.set_prior(f0, preamble_start)
frame = d.demod(rx) # the FRAME's bits, sync word first
# The demodulator stops at decisions; the frame is undone one layer up.
from doppler.wfm import crc16
payload = frame[13:13 + 64]
rx_crc = 0
for b in frame[13 + 64:][:16]:
rx_crc = (rx_crc << 1) | int(b)
assert rx_crc == int(crc16(payload)) # the CHECK is the caller's
BurstDemod
¶
Create a feedforward BPSK DSSS burst demodulator.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data_code
|
NDArray[uint8]
|
Data spreading code, one 0/1 chip per element; copied into the object (its length is the data spreading factor, chips/symbol). |
required |
spc
|
int
|
Samples per chip (front-end oversample). |
4
|
chip_rate
|
float
|
Chip rate (Hz); sets the sample rate as spc*chip_rate. |
1.0e6
|
carrier_hz
|
float
|
RF carrier (Hz) for code-Doppler scaling; 0 = ignore. |
0.0
|
max_rate
|
float
|
Chirp-rate search half-span (cycles/sample^2 at the input rate); 0 = Doppler only (no rate search). |
0.0
|
frame_syms
|
int
|
Symbols the frame occupies after the sync word — how many bits demod() hands back per burst. What they mean is a frame description's business. |
0
|
est_segments
|
int
|
Partial correlations per acq period (segmentation for the feedforward estimate; larger tolerates more rate). |
10
|
Examples:
>>> import numpy as np
>>> from doppler.dsss import BurstDemod
>>> spc, acq_sf, reps, data_sf = 4, 500, 5, 50
>>> sync = np.array([0, 0, 0, 0, 0, 1, 1, 0, 0, 1, 0, 1, 0], np.uint8)
>>> acode = ((np.arange(acq_sf) * 2654435761 >> 13) & 1).astype(
... np.uint8)
>>> dcode = ((np.arange(data_sf) * 40503 >> 7) & 1).astype(np.uint8)
>>> payload = ((np.arange(64) * 7 + 3) & 1).astype(np.uint8)
>>> def crc16(bits):
... c = 0xFFFF
... for b in bits:
... c ^= (int(b) & 1) << 15
... c = (((c << 1) ^ 0x1021) & 0xFFFF
... if c & 0x8000 else (c << 1) & 0xFFFF)
... return c
>>> crc = crc16(payload)
>>> crc_bits = np.array(
... [(crc >> (15 - j)) & 1 for j in range(16)], np.uint8)
>>> frame = np.concatenate([sync, payload, crc_bits])
>>> csign = lambda b: np.where(np.asarray(b) & 1, -1.0, 1.0)
>>> chips = ([np.tile(csign(acode), reps)]
... + [csign(b) * csign(dcode) for b in frame])
>>> bb = np.repeat(np.concatenate(chips), spc).astype(np.complex64)
>>> n = np.arange(len(bb))
>>> f0 = 0.012
>>> x = (bb * np.exp(2j * np.pi * f0 * n)).astype(np.complex64)
>>> d = BurstDemod(dcode, spc=spc, chip_rate=1e6, frame_syms=len(frame))
>>> d.set_preamble(acode, reps) # unmodulated (f0, rate) preamble
>>> d.set_sync(sync) # Barker-13: frame align + sign fix
>>> d.set_prior(f0, 0) # coarse Doppler + preamble start
>>> bits = d.demod(x) # estimate -> dechirp -> despread -> slice
>>> bool(np.array_equal(bits, frame)) # the FRAME, not the payload
True
frame_syms
property
¶
symbols the frame occupies AFTER the sync word — a number the caller states. What they MEAN is the frame description's business, one layer up.
est_n0
property
¶
Noise power the LLRs are scaled by, referred to unit symbol
amplitude — 2·var(Im)/mean|Re|² over the derotated frame, floored at
1e-12 so a noiseless capture stays finite. llrs() is divided by this,
so multiplying back recovers the raw projection, and two bursts are
comparable only because both were scaled by their own estimate. Reading
it beside symbols() is what turns the constellation into an absolute
measurement rather than a picture.
reset
¶
Clear the per-burst read-backs, leaving the configuration intact.
Zeros the after-demod fields (frame_offset, n_symbols, and the est_* estimates) so a stale result cannot be mistaken for a fresh one. The spreading codes, sync word, and prior set up before the first burst are preserved, so the object is immediately ready to demodulate the next burst.
Examples:
set_preamble
¶
Set the (unmodulated) acquisition preamble code + repetition count used for the feedforward (f0, rate) estimate.
The preamble is the acq spreading code transmitted reps times with no data modulation; demod() segment-despreads it into partial correlations and feeds those to the polynomial-phase estimator to recover the coarse (frequency, chirp-rate). Call once after construction; the code is copied.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
acq_code
|
NDArray[uint8]
|
Acq preamble spreading code, one 0/1 chip per element; copied into the object. |
required |
reps
|
int
|
Number of preamble repetitions in the burst. |
required |
Examples:
>>> import numpy as np
>>> from doppler.dsss import BurstDemod
>>> dcode = (np.arange(50) & 1).astype(np.uint8)
>>> d = BurstDemod(dcode, spc=4, chip_rate=1e6, frame_syms=93)
>>> acode = (np.arange(500) & 1).astype(np.uint8) # unmodulated
>>> d.set_preamble(acode, reps=5) # 5 reps drive the (f0, rate) fit
set_sync
¶
Set the known frame-sync word (0/1 BPSK symbols) used for frame alignment and phase/sign resolution. The ONLY thing this object is told about the frame's content, and for a physical-layer reason: without the sign the slicer would be a coin toss. Where the payload sits, which stages cover what and whether a check passed all need the frame's description and belong one layer up (doppler#1022).
After the data section is despread to soft BPSK symbols, demod() correlates them against this word; the complex correlation peak locates the frame (its frame_offset) and its phase resolves the residual carrier rotation and the BPSK sign ambiguity before slicing. Pass the word as 0/1 symbols; it is copied and stored internally as +/-1.
This is the ONLY thing this object is told about the frame's content, and it is told it for a physical-layer reason: without the sign the slicer would be a coin toss. Everything else — where the payload sits, which stages cover what, whether a check passed — needs the frame's description and belongs one layer up (doppler#1022).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
sync
|
NDArray[uint8]
|
Frame-sync word, one 0/1 symbol per element; copied. |
required |
Examples:
>>> import numpy as np
>>> from doppler.dsss import BurstDemod
>>> dcode = (np.arange(50) & 1).astype(np.uint8)
>>> d = BurstDemod(dcode, spc=4, chip_rate=1e6, frame_syms=93)
>>> sync = np.array([0, 0, 0, 0, 0, 1, 1, 0, 0, 1, 0, 1, 0], np.uint8)
>>> d.set_sync(sync) # Barker-13: frame align + phase/sign fix
llrs
¶
The soft bits of the last demod() — one LLR per FRAME bit, in
mpsk_soft_demap's convention: positive means bit 0, so L < 0
reproduces exactly the bits demod() returned. Re(sym * derot) IS the
log-likelihood ratio up to a scale and used to be computed, sliced to
one bit and freed; a hard decision costs roughly 2 dB of the coding
gain a soft-input decoder exists to deliver. Spans the whole frame
rather than the payload alone, because a code covers what its
description says it covers. Scaled by est_n0, the burst's own noise
estimate, so LLRs from different bursts are comparable — a Viterbi
would not care, but combining across bursts does.
crealf(sym * derot) IS the log-likelihood ratio up to a scale, and it
was computed, sliced to one bit and freed on every burst. A hard
decision throws away roughly 2 dB of the coding gain a soft-input
decoder exists to deliver (mpsk_soft_demap's own docstring), so this
is what makes a coded burst worth coding.
The convention is not a new one: mpsk_soft_demap's, which is
mpsk_demap's decision rule seen a second way. Positive means bit 0,
so L < 0 reproduces exactly the bits demod() returned — asserted in
the tests rather than assumed.
Spans the WHOLE frame, not just the payload, because a code covers what
its description says it covers and a decoder needs the bits the code
protects. The payload's own span is field_off/field_bits of the
layout.
Scaled by est_n0 rather than left raw: a Viterbi is invariant to a positive scale, but LLRs from different bursts are not comparable without one, and combining across bursts needs them to be.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
count
|
int
|
How many output samples to ask for. The call may return fewer; size
an |
1
|
out
|
NDArray[float32] | None
|
Receives the LLRs, one per frame bit. |
None
|
Returns:
| Type | Description |
|---|---|
NDArray[float32]
|
LLRs written — |
Examples:
llrs_max_out
¶
Max LLRs burst_demod_llrs() writes: the frame's length in bits.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
n
|
int
|
Ignored — the count is the last demod()'s frame. |
required |
Returns:
| Type | Description |
|---|---|
int
|
Output. |
symbols
¶
The DEROTATED complex symbols of the last demod() — the
constellation llrs() is the real part of. Same span and
normalisation: the whole frame, scaled to unit mean-|Re|, so
symbols.real is llrs() up to est_n0. The quadrature is why this
exists: after derotation the real axis carries the signal and the
imaginary axis carries noise alone, so a residual phase error — which
scales Re by cos(phi) without adding noise — is indistinguishable
from a genuine amplitude or SNR loss in mean |LLR|, in LLR spread and
in BER alike. Measured over 20000 BPSK symbols, a 30° phase error and
an amplitude loss of cos(30°) agreed to three decimals in all three
and differed only in Q/I energy, 0.386 against 0.077. That is a
pointing problem against a link-budget one, on a burst this object
already characterised well enough to know. It was built either way and
freed unread (doppler#1087).
Same span and same normalisation as burst_demod_llrs(): the whole
frame, scaled to unit mean-|Re| by the burst's own estimate, so
crealf(symbols[k]) is that bit's LLR up to est_n0.
The quadrature is why this exists. After derotation the real axis
carries the signal and the imaginary axis carries noise alone, so Q is
diagnostic: a residual phase error scales Re by cos(phi) WITHOUT
adding noise, which makes it indistinguishable from a genuine amplitude
or SNR loss in mean |LLR|, in LLR spread and in BER alike. Measured
over 20000 BPSK symbols, a 30 degree phase error and an amplitude loss
of cos(30 deg) agreed to three decimals in all three, and differed
only in Q/I energy — 0.386 against 0.077 (doppler#1087). That is the
difference between a pointing problem and a link-budget one, on a burst
this object already characterised well enough to know.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
count
|
int
|
How many output samples to ask for. The call may return fewer; size
an |
1
|
out
|
NDArray[complex64] | None
|
Receives the symbols, one per frame bit. |
None
|
Returns:
| Type | Description |
|---|---|
NDArray[complex64]
|
Symbols written — |
Examples:
symbols_max_out
¶
Max symbols burst_demod_symbols() writes: the frame's length.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
n
|
int
|
Ignored — the count is the last demod()'s frame. |
required |
Returns:
| Type | Description |
|---|---|
int
|
Output. |
set_prior
¶
Seed from acquisition: coarse Doppler (cycles/sample at the input rate) and the preamble start sample.
These come from the upstream acquisition stage: f0_coarse centres the feedforward frequency search near the true Doppler, and start tells demod() where the preamble begins within the burst so it despreads the right samples. Call once per burst before demod().
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
f0_coarse
|
float
|
Coarse Doppler prior (cycles/sample at the input rate). |
required |
start
|
int
|
Preamble start sample index within the burst. |
required |
Examples:
demod
¶
Demodulate a burst (preamble + frame); return the payload bits. Read-back properties report the estimates + CRC validity.
Without out=, the returned array is a view into a buffer reused on the next call (see demod_max_out(), or payload_len, to size an out= buffer for an independent, alias-free result).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x
|
NDArray[complex64]
|
Input. |
required |
out
|
NDArray[uint8]
|
Caller-provided output buffer, at least max(demod_max_out(), len(x)) elements. |
...
|
Returns:
| Type | Description |
|---|---|
NDArray[uint8]
|
Number of bits written (0 on failure / too-short burst). The read-back fields (frame_valid, est_*, frame_offset) are updated. |
demod_max_out
¶
Max output length demod() can produce for the current state. Use to size the out= buffer.
destroy
¶
Release the underlying C resources immediately.
Ordinarily unnecessary: the resources are freed when the object is garbage-collected. Call this to release them at a definite point instead, or use the object as a context manager, which calls it on exit.
Idempotent: calling it again on an already-released object does
nothing. Every other method raises RuntimeError once it has run.
__enter__
¶
Enter a context manager, returning this object.
Lets a BurstDemod be used in a with statement so its C resources are
released deterministically on exit rather than at collection time.
Returns:
| Type | Description |
|---|---|
BurstDemod
|
This same object, not a copy. |
__exit__
¶
__exit__(
exc_type: object | None = ...,
exc: object | None = ...,
tb: object | None = ...,
) -> None
Exit a context manager, releasing the BurstDemod.
Equivalent to calling destroy(). Returns None, so an exception
raised inside the with body propagates normally; this never
suppresses one.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
exc_type
|
object | None
|
Exception class, or None. Ignored. |
...
|
exc
|
object | None
|
Exception instance, or None. Ignored. |
...
|
tb
|
object | None
|
Traceback object, or None. Ignored. |
...
|
Despreader — continuous tracking receiver¶
A complete continuous DSSS-BPSK receiver in one object: Despreader composes
a carrier loop (Costas, FLL-assisted) and a code loop (Dll) on a single
shared per-sample integrate-and-dump. Per sample it wipes the carrier
(integer-NCO) and feeds the de-rotated sample to the DLL's early/prompt/late
correlators; per code period it dumps the prompt and updates both loops — the
code loop on the early/late envelopes, the carrier loop on the same prompt.
steps() emits one despread prompt symbol per code period; bits() turns the
prompts into hard data bits.
bn_fll > 0 enables FLL-assisted carrier pull-in. When a data bit spans
periods_per_bit code periods (GPS C/A: 20), bits() bit-syncs — it
histograms the prompt sign-flip positions to find the bit boundary
(bit_phase), then coherently sums periods_per_bit prompts per bit. The
despreader is seeded by acquisition (coarse carrier frequency + code phase)
and tracks the residual.
import numpy as np
from doppler.dsss import Despreader
from doppler.wfm import Synth
code = np.random.default_rng(1).integers(0, 2, 127).astype(np.uint8)
rx = Synth(type="pn", pn_length=7, sps=8).steps(127 * 8 * 4) # PN-spread IQ
d = Despreader(code, sps=8, init_norm_freq=0.0, init_chip=0.0,
bn_carrier=0.05, bn_code=0.005, bn_fll=0.03,
zeta=0.707, spacing=0.5, periods_per_bit=1)
symbols = d.steps(rx) # one despread prompt per code period
bits = d.bits(rx) # hard data bits (bit-synced when periods_per_bit > 1)
Despreader
¶
Create a continuous DSSS despreader (COPIES code).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
code
|
NDArray[uint8]
|
Spreading code (0/1 chips), one period; copied. |
required |
sps
|
int
|
Samples per chip. |
4
|
init_norm_freq
|
float
|
Seed carrier frequency, cycles/sample (the acquisition estimate). |
0.0
|
init_chip
|
float
|
Seed code phase, chips (the acquisition estimate). |
0.0
|
bn_carrier
|
float
|
Carrier loop noise bandwidth, normalized to the code-period (symbol) rate. |
0.05
|
bn_code
|
float
|
Code loop noise bandwidth, normalized to the code-period rate. |
0.005
|
bn_fll
|
float
|
Carrier FLL-assist bandwidth (0 = pure PLL); set > 0 for FLL-assisted carrier pull-in. |
0.0
|
zeta
|
float
|
Damping factor shared by both second-order loops. |
0.707
|
spacing
|
float
|
DLL early/late correlator tap offset, chips. |
0.5
|
periods_per_bit
|
int
|
Code periods per data bit (1 = one bit per period). |
1
|
Examples:
>>> import numpy as np
>>> from doppler.dsss import Despreader
>>> rng = np.random.default_rng(3)
>>> code = rng.integers(0, 2, 31).astype(np.uint8) # one code period
>>> chips = np.where(code & 1, -1.0, 1.0) # 0 -> +1, 1 -> -1
>>> bits = rng.integers(0, 2, 40).astype(np.uint8) # 1 bit/period
>>> syms = np.where(bits == 1, -1.0, 1.0)
>>> rx = np.concatenate(
... [s * np.repeat(chips, 4) for s in syms]).astype(np.complex64)
>>> d = Despreader(code, sps=4) # seed a fresh tracking loop
>>> data = d.bits(rx) # hard data bits, 1/period
>>> e = np.mean(data != bits[:data.size]) # up to a global BPSK flip
>>> round(float(min(e, 1.0 - e)), 4)
0.0
carrier_locked
property
¶
Carrier lock decision: the embedded Costas loop's verify-counted detector on its lock-metric EMA (True = locked; see Costas.configure_lock).
code_locked
property
¶
Code lock decision: the embedded DLL's verify-counted CFAR detector (True = locked; see Dll.configure_lock). Live in composition — the despreader runs the same always-on detector Dll.steps does.
steps
¶
Track carrier + code and despread a cf32 block: per sample wipe the carrier (Costas) and correlate early/prompt/late against the code (DLL), update both loops each code period, and emit one complex prompt symbol per period.
Without out=, the returned array is a view into a buffer reused on the next call (see steps_max_out() to size an out= buffer for an independent, alias-free result).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x
|
NDArray[complex64]
|
Input. |
required |
out
|
NDArray[complex64]
|
Caller-provided output buffer, at least max(steps_max_out(), len(x)) elements. |
...
|
Returns:
| Type | Description |
|---|---|
NDArray[complex64]
|
Output. |
steps_max_out
¶
Max output length steps() can produce for the current state. Use to size the out= buffer.
bits
¶
Same tracking kernel as steps(), but bit-sync the per-period prompts into hard data bits: periods_per_bit prompts are coherently summed across each detected bit boundary and one 0/1 bit is emitted per data bit.
Without out=, the returned array is a view into a buffer reused on the next call (see bits_max_out() to size an out= buffer for an independent, alias-free result).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x
|
NDArray[complex64]
|
Input. |
required |
out
|
NDArray[uint8]
|
Caller-provided output buffer, at least max(bits_max_out(), len(x)) elements. |
...
|
Returns:
| Type | Description |
|---|---|
NDArray[uint8]
|
Output. |
bits_max_out
¶
Max output length bits() can produce for the current state. Use to size the out= buffer.
set_telemetry
¶
Attach (or detach) a telemetry context across the despreader. Pure
forwarder — the despreader registers no probes of its own: the carrier
loop registers "
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
tlm
|
object | None
|
Telemetry context to attach, or NULL to detach. |
required |
prefix
|
str
|
Probe-name prefix, e.g. "ch0". |
required |
decim
|
int
|
Emit every decim-th code period; >= 1. |
1
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If the C call returns a non-zero status. The exception message is
|
Examples:
>>> import numpy as np
>>> from doppler.dsss import Despreader
>>> from doppler.telemetry import Telemetry
>>> tlm = Telemetry(1 << 12)
>>> code = (np.arange(31) % 2).astype(np.uint8)
>>> ch = Despreader(code=code, sps=4)
>>> ch.set_telemetry(tlm, "ch0")
>>> names = sorted(tlm.probe_names)
>>> names[:4]
['ch0.car.e', 'ch0.car.freq', 'ch0.car.lock', 'ch0.car.locked']
>>> names[4:]
['ch0.code.e', 'ch0.code.lock', 'ch0.code.locked', 'ch0.code.rate']
>>> chips = 1.0 - 2.0 * (np.arange(31) % 2)
>>> x = np.tile(np.repeat(chips, 4), 40).astype(np.complex64)
>>> _ = ch.steps(x)
>>> recs = tlm.read() # eight records per code period
>>> len(recs) > 0 and len(recs) % 8 == 0
True
configure_carrier_lock
¶
Re-tune the embedded carrier loop's lock detector directly: forwards to the Costas loop's configure_lock (locked flips up after n_up consecutive symbols with the lock-metric EMA above up_thresh, and drops after n_down consecutive symbols below down_thresh; see Costas.configure_lock). Symmetric with the carrier_locked state property: state is readable, so config should be writable too, rather than forcing a caller who needs this control to drop to raw Dll+Costas composition.
Thin forwarder to costas_configure_lock() on the embedded Costas loop — symmetric with despreader_get_carrier_locked() exposing its state: state is readable, so config should be writable too, rather than forcing a caller who needs this control to drop to raw Dll+Costas composition instead of Despreader. See costas_configure_lock() for the parameter semantics.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
up_thresh
|
float
|
Declare threshold on the lock-metric EMA. |
required |
down_thresh
|
float
|
Drop threshold (<= up_thresh for level hysteresis). |
required |
n_up
|
int
|
Consecutive above-threshold symbols to declare. |
required |
n_down
|
int
|
Consecutive below-threshold symbols to drop. |
required |
Examples:
configure_code_lock
¶
Re-tune the embedded code loop's lock detector: forwards to the DLL's configure_lock (see Dll.configure_lock) -- the derived (pfa-style) entry point, matching Despreader's role as the easy composed API (Dll's raw escape hatch, configure_lock_raw, stays a Dll-only control for a caller that composes Dll+Costas directly). Raises ValueError for pfa outside (0, 1).
Thin forwarder to dll_configure_lock() on the embedded DLL — the derived (pfa-style) entry point, matching Despreader's role as the "easy" composed API (Dll's raw escape hatch, dll_configure_lock_raw(), stays a Dll-only control for a caller that composes Dll+Costas directly). See dll_configure_lock() for the parameter semantics.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
pfa
|
float
|
Per-decision false-alarm probability, in (0, 1). |
required |
n_looks
|
int
|
Non-coherent integration depth N (looks); clamped >= 1. |
required |
ref_snr_db
|
float
|
Noise-reference estimator SNR in dB (> 0), or 0 to derive from n_looks (see dll_configure_lock()). |
0.0
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If the C call returns a non-zero status. The exception message is
|
Examples:
>>> import numpy as np
>>> from doppler.dsss import Despreader
>>> d = Despreader(code=np.zeros(31, dtype=np.uint8), sps=2)
>>> d.configure_code_lock(1e-3, 20)
>>> d.code_locked
False
>>> d.configure_code_lock(2.0, 20)
Traceback (most recent call last):
...
ValueError: configure_code_lock failed (rc=-4)
reset
¶
Re-seed both loops to the create-time frequency/phase; preserve config.
Restores the carrier NCO to init_norm_freq and the code phase to init_chip, zeroes the loop-filter accumulators and the bit-sync histogram, and clears the lock detectors — the spreading code and every configured bandwidth are preserved. Use it to re-run the same despreader over an independent stream and get a fresh instance's result.
Examples:
>>> import numpy as np
>>> from doppler.dsss import Despreader
>>> rng = np.random.default_rng(3)
>>> code = rng.integers(0, 2, 31).astype(np.uint8)
>>> chips = np.where(code & 1, -1.0, 1.0)
>>> syms = np.where(rng.integers(0, 2, 40) == 1, -1.0, 1.0)
>>> rx = np.concatenate(
... [s * np.repeat(chips, 4) for s in syms]).astype(np.complex64)
>>> d = Despreader(code=code, sps=4)
>>> first = d.bits(rx)
>>> d.reset() # re-seed to acquisition
>>> np.array_equal(first, d.bits(rx)) # same result as a fresh object
True
state_bytes
¶
Size in bytes of this object's serialized state.
The exact length get_state returns and set_state requires. It
depends on how the object was constructed (state arrays are sized at
construction), so read it from the instance rather than assuming a
constant.
Raises RuntimeError if the Despreader has already been destroyed.
Returns:
| Type | Description |
|---|---|
int
|
Byte length of one serialized state blob. |
get_state
¶
Serialize this object's mutable state to bytes.
Captures exactly the state that evolves as the object runs, so a blob taken now and restored later resumes from this point. Construction parameters are not included: restore into an object built the same way.
The blob is opaque and always state_bytes() long. Its layout is an
implementation detail of the C core and is not a stable format across
builds.
Raises RuntimeError if the Despreader has already been destroyed.
Returns:
| Type | Description |
|---|---|
bytes
|
Opaque snapshot, |
set_state
¶
Restore mutable state from a get_state() blob.
Overwrites the live state in place; the object keeps the parameters it
was constructed with. Length is validated against state_bytes()
before the blob is handed to the C core, and the core may reject it as
well.
Raises TypeError if blob is not bytes, ValueError if its
length differs from state_bytes() or the core rejects it, and
RuntimeError if the Despreader has already been destroyed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
blob
|
bytes
|
A |
required |
destroy
¶
Release the underlying C resources immediately.
Ordinarily unnecessary: the resources are freed when the object is garbage-collected. Call this to release them at a definite point instead, or use the object as a context manager, which calls it on exit.
Idempotent: calling it again on an already-released object does
nothing. Every other method raises RuntimeError once it has run.
__enter__
¶
Enter a context manager, returning this object.
Lets a Despreader be used in a with statement so its C resources are
released deterministically on exit rather than at collection time.
Returns:
| Type | Description |
|---|---|
Despreader
|
This same object, not a copy. |
__exit__
¶
__exit__(
exc_type: object | None = ...,
exc: object | None = ...,
tb: object | None = ...,
) -> None
Exit a context manager, releasing the Despreader.
Equivalent to calling destroy(). Returns None, so an exception
raised inside the with body propagates normally; this never
suppresses one.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
exc_type
|
object | None
|
Exception class, or None. Ignored. |
...
|
exc
|
object | None
|
Exception instance, or None. Ignored. |
...
|
tb
|
object | None
|
Traceback object, or None. Ignored. |
...
|
BurstDespreader — tracking receiver¶
Seeded with a coarse frequency and code-phase estimate (from the
Corr2D/CorrDetector2D acquisition engine or Acquisition), the BurstDespreader locks
the signal with a code-tracking delay-locked loop and a carrier-tracking
Costas loop, despreads the payload, and emits symbols.
How it works¶
Every dimension is a run-time parameter — spreading code, spreading factor
(sf), samples-per-chip (sps), loop bandwidths. Per input sample the
despreader wipes the carrier (an inline NCO driven by the Costas loop), then
correlates against early / prompt / late replicas of the code. Once per code
period it dumps the three accumulators:
- the prompt accumulator is the despread symbol — its sign is the BPSK decision, its phase/magnitude the soft information;
- the non-coherent early-minus-late envelope drives the DLL
(
track.LoopFilter) → code phase/rate; - the decision-directed product drives the Costas loop → carrier frequency/phase.
Seeding from acquisition. init_norm_freq is the carrier frequency in
cycles/sample and init_chip_phase the code phase in chips; the caller converts
the detector's (Doppler bin, code-phase chip) into those units (the bin→Hz map
depends on the search grid, so it stays application-side).
Distinct acquisition vs data codes. Real bursts use a long acquisition code
for the preamble and a different (often shorter) data code for the payload.
set_acq(acq_code, acq_reps) enables preamble-aided pull-in — track the
unmodulated, repeated acquisition preamble coherently (a full ±π discriminator,
so even a wide residual pulls in), then switch to the data code at the payload.
Omit it for payload-only operation (seeded from acquisition).
Tracking state is readable: norm_freq (carrier estimate), code_phase,
lock_metric (0–1), snr_est. The cf32 symbol output chains over the stream
module's dp_header_t framing like any other DSP block.
Examples¶
Despread a payload seeded from acquisition¶
import numpy as np
from doppler.dsss import BurstDespreader
# data_code: 0/1 spreading chips; seed from the acquisition peak.
# rx is the received capture (reuse the burst built above).
data_code = ((np.arange(32) * 40503 >> 7) & 1).astype(np.uint8)
acq_freq, acq_chip = 0.012, 0.0
d = BurstDespreader(data_code, sf=32, sps=2,
init_norm_freq=acq_freq, init_chip_phase=acq_chip)
symbols = d.steps(rx) # complex64 prompt symbols
bits = d.bits(rx) # or hard BPSK bits (0/1)
round(d.lock_metric, 2) # ~1.0 once locked
Preamble-aided pull-in with a distinct acquisition code¶
burst = rx # a received capture (from above)
d = BurstDespreader(data_code, sf=32, sps=2)
d.set_acq(acq_code, acq_reps=5) # 5-rep preamble pulls the loops in
symbols = d.steps(burst) # preamble emits nothing; payload follows
BurstDespreader
¶
Create a burst despreader instance.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
code
|
NDArray[uint8]
|
Data spreading code (0/1 chips), length code_len; copied. |
required |
sf
|
int
|
Spreading factor: chips integrated per prompt symbol (default: 1). |
1
|
sps
|
int
|
Samples per chip (default: 2). |
2
|
init_norm_freq
|
float
|
Seed carrier frequency, cycles/sample — the acquisition estimate (default: 0.0). |
0.0
|
init_chip_phase
|
float
|
Seed code phase, chips (default: 0.0). |
0.0
|
bn_carrier
|
float
|
Carrier (Costas) loop noise bandwidth, normalized to the symbol rate (default: 0.05). |
0.05
|
bn_code
|
float
|
Code (DLL) loop noise bandwidth, normalized to the symbol rate (default: 0.01). |
0.01
|
Examples:
>>> import numpy as np
>>> from doppler.dsss import BurstDespreader
>>> rng = np.random.default_rng(1)
>>> code = rng.integers(0, 2, 31).astype(np.uint8) # length-31 code
>>> chips = np.where(code & 1, -1.0, 1.0) # 0 -> +1, 1 -> -1
>>> bits = rng.integers(0, 2, 30).astype(np.uint8) # payload bits
>>> syms = np.where(bits == 1, -1.0, 1.0) # BPSK symbols
>>> tx = np.concatenate(
... [np.repeat(s * chips, 4) for s in syms]).astype(np.complex64)
>>> b = BurstDespreader(code, sf=31, sps=4) # 31 chips/symbol
>>> sym = b.steps(tx) # one prompt/symbol
>>> sym.shape
(30,)
>>> hard = (sym.real < 0).astype(np.uint8) # BPSK decision
>>> float(np.mean(hard != bits)) # payload recovered
0.0
bn_carrier
property
writable
¶
Carrier (Costas) loop noise bandwidth, normalized to the symbol rate.
bn_code
property
writable
¶
Code (DLL) loop noise bandwidth, normalized to the symbol rate.
lock_metric
property
¶
Lock indicator in [0,1]: the mean of |Re prompt|/|prompt| over every prompt of the burst (cumulative, not EMA). ~1 when phase-locked; ~2/pi (0.637) with no carrier.
snr_est
property
¶
Post-despread SNR estimate over the burst, accumulate-then-ratio: (sum Re^2 - sum Im^2)/sum Im^2, clamped >= 0. This is the effective post-loop SNR (residual tracking jitter included) - the quantity that predicts demodulation performance; it converges to the AWGN-only A^2/sigma^2 as the loop bandwidths shrink.
lock_stat
property
¶
Calibrated whole-burst lock statistic R = sqrt(stat_n * sum Re^2 / sum Im^2) — the one-shot analog of the tracking loops' verify-counted detectors. Because the noise reference is estimated from as many samples as the signal sum, the exact H0 law is R^2 = stat_n * F(stat_n, stat_n): gate with R > sqrt(stat_n * det_threshold_f(pfa, stat_n)) — exact for every stat_n (a chi-square gate would realize tens of times the priced pfa). Payload prompts only; reset() re-arms.
steps
¶
Despread a cf32 block; emit one complex prompt symbol per code period.
Streams: a partial symbol is carried in state across calls. Each emitted
symbol is the complex prompt integrate-and-dump (carrier-wiped,
code-stripped) — its sign is the BPSK decision, its phase/magnitude the
soft information. During a burst_despreader_set_acq preamble no symbols are
emitted (the loops are pulling in); payload symbols follow.
Without out=, the returned array is a view into a buffer reused on the next call (see steps_max_out() to size an out= buffer for an independent, alias-free result).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x
|
NDArray[complex64]
|
Input CF32 samples, length x_len. |
required |
out
|
NDArray[complex64]
|
Caller-provided output buffer, at least max(steps_max_out(), len(x)) elements. |
...
|
Returns:
| Type | Description |
|---|---|
NDArray[complex64]
|
Number of symbols written. |
Examples:
>>> import numpy as np
>>> from doppler.dsss import BurstDespreader
>>> rng = np.random.default_rng(1)
>>> code = rng.integers(0, 2, 31).astype(np.uint8) # length-31 code
>>> bits = rng.integers(0, 2, 30).astype(np.uint8) # payload bits
>>> chips = np.where(code & 1, -1.0, 1.0) # 0 -> +1, 1 -> -1
>>> syms = np.where(bits == 1, -1.0, 1.0) # BPSK symbols
>>> tx = np.concatenate(
... [np.repeat(s * chips, 4) for s in syms]).astype(np.complex64)
>>> d = BurstDespreader(code, sf=31, sps=4)
>>> sym = d.steps(tx) # one prompt/symbol
>>> sym.shape
(30,)
>>> hard = (sym.real < 0).astype(np.uint8) # BPSK decision
>>> float(np.mean(hard != bits)) # payload recovered
0.0
steps_max_out
¶
Max output length steps() can produce for the current state. Use to size the out= buffer.
bits
¶
Despread a cf32 block; emit one hard BPSK bit per code period.
Same streaming kernel as burst_despreader_steps(), but emits the hard decision
crealf(prompt) >= 0 instead of the complex symbol.
Without out=, the returned array is a view into a buffer reused on the next call (see bits_max_out() to size an out= buffer for an independent, alias-free result).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x
|
NDArray[complex64]
|
Input CF32 samples, length x_len. |
required |
out
|
NDArray[uint8]
|
Caller-provided output buffer, at least max(bits_max_out(), len(x)) elements. |
...
|
Returns:
| Type | Description |
|---|---|
NDArray[uint8]
|
Number of bits written. |
bits_max_out
¶
Max output length bits() can produce for the current state. Use to size the out= buffer.
set_acq
¶
Enable preamble-aided pull-in: track acq_reps periods of the (distinct) acq_code coherently before despreading the payload with the data code. Call before feeding the burst; clears when the preamble is consumed.
Track acq_reps periods of acq_code coherently (the unmodulated, repeated acquisition preamble — a full ±pi phase discriminator, so the loops pull in even a wide residual) before switching to the data code for the payload. Call before feeding the burst; the acq mode clears automatically once the preamble is consumed, and re-arms on burst_despreader_reset(). NB: set_acq re-arms the PREAMBLE only — the cumulative burst statistics (lock_metric / snr_est / lock_stat / stat_n) are re-armed by burst_despreader_reset(); call it between bursts.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
acq_code
|
NDArray[uint8]
|
Acquisition code (0/1), length acq_code_len; copied. |
required |
acq_reps
|
int
|
Number of acq-code periods in the preamble. |
required |
Examples:
>>> import numpy as np
>>> from doppler.dsss import BurstDespreader
>>> rng = np.random.default_rng(5)
>>> acq = rng.integers(0, 2, 128).astype(np.uint8) # long acq code
>>> data_code = rng.integers(0, 2, 32).astype(np.uint8)
>>> pbits = rng.integers(0, 2, 40).astype(np.uint8)
>>> asig = np.where(acq & 1, -1.0, 1.0)
>>> dch = np.where(data_code & 1, -1.0, 1.0)
>>> psyms = np.where(pbits == 1, -1.0, 1.0)
>>> pre = np.concatenate([np.repeat(asig, 4) for _ in range(4)])
>>> pay = np.concatenate([np.repeat(s * dch, 4) for s in psyms])
>>> burst = np.concatenate([pre, pay]).astype(np.complex64)
>>> d = BurstDespreader(data_code, sf=32, sps=4)
>>> d.set_acq(acq, 4) # 4 preamble reps, pulls loops in
>>> out = d.bits(burst) # preamble emits nothing
>>> out.shape # only the payload symbols come out
(40,)
>>> e = np.mean(out != pbits)
>>> round(float(min(e, 1.0 - e)), 4)
0.0
reset
¶
Re-seed the loops to the create-time phase/frequency; preserve config.
Restores the carrier NCO to the seed frequency and the code phase to the seed chip, zeroes the loop accumulators, and clears the cumulative burst read-backs (lock_metric / snr_est / lock_stat / stat_n) — the spreading code and bandwidths are kept. Call it between bursts so each burst's statistics start clean; a prior burst_despreader_set_acq() preamble is also re-armed.
Examples:
>>> import numpy as np
>>> from doppler.dsss import BurstDespreader
>>> rng = np.random.default_rng(1)
>>> code = rng.integers(0, 2, 31).astype(np.uint8)
>>> chips = np.where(code & 1, -1.0, 1.0)
>>> syms = np.where(rng.integers(0, 2, 30) == 1, -1.0, 1.0)
>>> tx = np.concatenate(
... [np.repeat(s * chips, 4) for s in syms]).astype(np.complex64)
>>> d = BurstDespreader(code, sf=31, sps=4)
>>> first = d.bits(tx)
>>> d.reset() # re-arm for a new burst
>>> np.array_equal(first, d.bits(tx)) # same as a fresh object
True
state_bytes
¶
Size in bytes of this object's serialized state.
The exact length get_state returns and set_state requires. It
depends on how the object was constructed (state arrays are sized at
construction), so read it from the instance rather than assuming a
constant.
Raises RuntimeError if the BurstDespreader has already been
destroyed.
Returns:
| Type | Description |
|---|---|
int
|
Byte length of one serialized state blob. |
get_state
¶
Serialize this object's mutable state to bytes.
Captures exactly the state that evolves as the object runs, so a blob taken now and restored later resumes from this point. Construction parameters are not included: restore into an object built the same way.
The blob is opaque and always state_bytes() long. Its layout is an
implementation detail of the C core and is not a stable format across
builds.
Raises RuntimeError if the BurstDespreader has already been
destroyed.
Returns:
| Type | Description |
|---|---|
bytes
|
Opaque snapshot, |
set_state
¶
Restore mutable state from a get_state() blob.
Overwrites the live state in place; the object keeps the parameters it
was constructed with. Length is validated against state_bytes()
before the blob is handed to the C core, and the core may reject it as
well.
Raises TypeError if blob is not bytes, ValueError if its
length differs from state_bytes() or the core rejects it, and
RuntimeError if the BurstDespreader has already been destroyed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
blob
|
bytes
|
A |
required |
destroy
¶
Release the underlying C resources immediately.
Ordinarily unnecessary: the resources are freed when the object is garbage-collected. Call this to release them at a definite point instead, or use the object as a context manager, which calls it on exit.
Idempotent: calling it again on an already-released object does
nothing. Every other method raises RuntimeError once it has run.
__enter__
¶
Enter a context manager, returning this object.
Lets a BurstDespreader be used in a with statement so its C resources
are released deterministically on exit rather than at collection time.
Returns:
| Type | Description |
|---|---|
BurstDespreader
|
This same object, not a copy. |
__exit__
¶
__exit__(
exc_type: object | None = ...,
exc: object | None = ...,
tb: object | None = ...,
) -> None
Exit a context manager, releasing the BurstDespreader.
Equivalent to calling destroy(). Returns None, so an exception
raised inside the with body propagates normally; this never
suppresses one.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
exc_type
|
object | None
|
Exception class, or None. Ignored. |
...
|
exc
|
object | None
|
Exception instance, or None. Ignored. |
...
|
tb
|
object | None
|
Traceback object, or None. Ignored. |
...
|
DsssReceiver — the composed continuous receiver¶
The single-object form of Acquisition -> Dll(segments) -> RateConverter -> MpskReceiver: only code/chip_rate/symbol_rate are required,
everything else defaults to this project's own validated values, with
configure_search_raw/configure_lock_raw/configure_chain_raw as the
power-user escape hatches. See the
DsssReceiver gallery page for the full
story this composes.
configure_search_raw bypasses the mislock-avoiding auto-sizer
See the gallery page's own warning before pinning a large doppler_bins
directly — DsssReceiver always has symbol_rate set, so the default
auto-sizing exists specifically to avoid a confirmed mislock failure
mode that a raw pin bypasses.
import numpy as np
from doppler.dsss import DsssReceiver
code = np.random.default_rng(1).integers(0, 2, 127).astype(np.uint8)
rx = DsssReceiver(code, chip_rate=3.0e6, symbol_rate=2100.0)
x = np.zeros(1024, dtype=np.complex64) # no real signal here -- just show the call
syms = rx.steps(x) # empty while searching; demodulated symbols once locked
rx.tracking # 0 = searching, 1 = locked and demodulating
DsssReceiver
¶
Create a DSSS receiver in the searching state.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
code
|
NDArray[uint8]
|
Spreading code, one 0/1 chip per element (0 -> +1, 1 -> -1 BPSK; only the low bit is used, so pass 0/1, not +/-1). |
required |
chip_rate
|
float
|
Chip rate, Hz. Required. |
1000000.0
|
symbol_rate
|
float
|
Data-symbol rate, Hz. Required — passed straight to the embedded
Acquisition's own |
1000.0
|
spc
|
int
|
Samples/chip (front-end oversample); default 2 (fs = 2x chip_rate). |
2
|
m
|
int
|
PSK order, 2/4/8; default 2 (BPSK). |
2
|
cn0_dbhz
|
float
|
Design C/N0 for acquisition sizing, dB-Hz; default 55.0. |
55.0
|
pfa
|
float
|
Acquisition false-alarm target; default 1e-3. |
1e-3
|
pd
|
float
|
Acquisition detection-probability target; default 0.9. |
0.9
|
doppler_uncertainty
|
float
|
One-sided Doppler search half-range, Hz; default 100.0. |
100.0
|
segments
|
int
|
Dll's own non-coherent partial-correlation count per code epoch — its
tracking- robustness parameter, independent of |
4
|
sps
|
int
|
MpskReceiver's samples/symbol, reached by an internal RateConverter bridging the despreader's own partial rate to this rate; default 8, MpskReceiver's own constructor default. |
8
|
differential
|
int
|
MpskReceiver's differential (rotation- invariant) demap; default 0 (coherent). |
0
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If construction fails. The exception message is ``DsssReceiver: invalid parameter (need a non-empty code, chip_rate > 0, symbol_rate > 0, spc
|
Examples:
>>> import numpy as np
>>> from doppler.dsss import DsssReceiver
>>> from doppler.wfm import Gold
>>> sf, chip, sym, spc = 1023, 3.0e6, 2100.0, 2
>>> fs, te, tsym = chip * spc, sf * spc, chip * spc / sym
>>> code = np.asarray(Gold().generate(sf)).astype(np.uint8)
>>> csign = np.where(code & 1, -1.0, 1.0)
>>> rng = np.random.default_rng(6)
>>> n = int(400 * tsym) + 2 * te # 400 BPSK data symbols
>>> idx = np.arange(n)
>>> data = (rng.integers(0, 2, 404) * 2 - 1).astype(float)
>>> si = np.clip((idx / tsym).astype(int), 0, 403)
>>> spread = data[si] * csign[(idx // spc) % sf] # DSSS chips
>>> sig = spread * np.exp(2j * np.pi * (50.0 / fs) * idx) # +50 Hz
>>> pre = 3 * te # noise-only lead-in, pre-signal
>>> sigma = np.sqrt(fs / 10 ** (90.0 / 10)) # ~90 dB-Hz C/N0
>>> noise = (sigma / np.sqrt(2)) * (rng.standard_normal(pre + n)
... + 1j * rng.standard_normal(pre + n))
>>> x = (np.concatenate([np.zeros(pre), sig]).astype(np.complex64)
... + noise.astype(np.complex64))
>>> rx = DsssReceiver(code, chip_rate=chip, symbol_rate=sym, spc=spc,
... cn0_dbhz=55.0, doppler_uncertainty=100.0)
>>> syms = [rx.steps(x[p:p + te]) for p in range(0, len(x) - te, te)]
>>> syms = np.concatenate([s for s in syms if len(s)])
>>> rx.tracking # acquired, now demodulating
1
>>> len(syms) > 300 # a few hundred symbols recovered
True
Nearly all the energy lands on I, so the BPSK phase is resolved:
norm_freq
property
¶
MpskReceiver's tracked carrier frequency; 0.0 while searching.
steps
¶
Stream raw cf32 samples through the receiver. While searching, samples feed the embedded Acquisition and nothing is emitted (an empty array is normal, not an error). The moment a hit fires, Dll/RateConverter/MpskReceiver are built and seeded from it -- the same phase-inversion hand-off and rate-bridging this project's async-DSSS-receiver gallery story validated by hand -- and the unconsumed tail of this same call is handed straight to them, so no samples are dropped at the transition. While tracking, samples feed Dll -> RateConverter -> MpskReceiver in sequence and demodulated symbols are returned. Accepts any block size; state carries across calls.
While searching, samples feed the embedded Acquisition and nothing is
emitted (0 return is normal, not an error). The moment a hit fires,
Dll/RateConverter/MpskReceiver are built and seeded from it, and
the unconsumed tail of THIS call — computed exactly from
acq->samples_consumed, no samples dropped or double-fed — is handed
straight to them in the same call. While tracking, samples feed Dll ->
RateConverter -> MpskReceiver in sequence. Accepts any block size;
state carries across calls (Acquisition/Dll/
RateConverter/MpskReceiver are all already block-size invariant, so
this object needs no ring-buffering of its own).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x
|
NDArray[complex64]
|
Input cf32 samples. |
required |
out
|
NDArray[complex64] | None
|
Output symbols; caller provides max_out capacity. |
None
|
Returns:
| Type | Description |
|---|---|
NDArray[complex64]
|
Number of symbols written (0 while searching, or while tracking with not yet a full symbol's worth of input). |
Examples:
>>> import numpy as np
>>> from doppler.dsss import DsssReceiver
>>> from doppler.wfm import Gold
>>> sf, chip, sym, spc = 1023, 3.0e6, 2100.0, 2
>>> fs, te, tsym = chip * spc, sf * spc, chip * spc / sym
>>> code = np.asarray(Gold().generate(sf)).astype(np.uint8)
>>> csign = np.where(code & 1, -1.0, 1.0)
>>> rng = np.random.default_rng(6)
>>> n = int(400 * tsym) + 2 * te # 400 BPSK data symbols
>>> idx = np.arange(n)
>>> data = (rng.integers(0, 2, 404) * 2 - 1).astype(float)
>>> si = np.clip((idx / tsym).astype(int), 0, 403)
>>> spread = data[si] * csign[(idx // spc) % sf] # DSSS chips
>>> sig = spread * np.exp(2j * np.pi * (50.0 / fs) * idx) # +50 Hz
>>> pre = 3 * te # noise-only lead-in, pre-signal
>>> sigma = np.sqrt(fs / 10 ** (90.0 / 10)) # ~90 dB-Hz C/N0
>>> noise = (sigma / np.sqrt(2)) * (rng.standard_normal(pre + n)
... + 1j * rng.standard_normal(pre + n))
>>> x = (np.concatenate([np.zeros(pre), sig]).astype(np.complex64)
... + noise.astype(np.complex64))
>>> rx = DsssReceiver(code, chip_rate=chip, symbol_rate=sym, spc=spc,
... cn0_dbhz=55.0, doppler_uncertainty=100.0)
>>> syms = [rx.steps(x[p:p + te]) for p in range(0, len(x) - te, te)]
>>> syms = np.concatenate([s for s in syms if len(s)])
>>> rx.tracking # acquired and now demodulating
1
>>> len(syms) > 300 # a few hundred symbols recovered
True
Nearly all the energy lands on I, so the BPSK phase is resolved:
steps_max_out
¶
Largest number of samples steps() can return in the current state.
Size an out= buffer with this before calling steps(), or use it to
allocate one up front. The bound is this object's own: what it depends
on is a property of the algorithm, so a header block on steps_max_out()
replaces this text.
Returns:
| Type | Description |
|---|---|
int
|
Upper bound on the output length; the actual call may return fewer. |
configure_search_raw
¶
Pin the embedded Acquisition's search grid directly, bypassing the symbol_rate-driven auto-sizing -- the escape hatch for a power user who wants a specific (doppler_bins, n_noncoh). Only meaningful while searching.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
doppler_bins
|
int
|
Number of Doppler window tiles to search (>= 1); capped by the
create-time |
required |
n_noncoh
|
int
|
Non-coherent looks accumulated per grid cell (1..256); more looks buys sensitivity at the cost of dwell, replacing the auto-sized count. |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the C call returns a non-zero status. The exception message is
|
Examples:
>>> import numpy as np
>>> from doppler.dsss import DsssReceiver
>>> from doppler.wfm import Gold
>>> code = np.asarray(Gold().generate(1023)).astype(np.uint8)
>>> rx = DsssReceiver(code, chip_rate=3.0e6, symbol_rate=2100.0, spc=2)
>>> rx.configure_search_raw(doppler_bins=1, n_noncoh=16) # pin it
>>> rx.tracking # still searching, on the pinned grid
0
configure_lock_raw
¶
configure_lock_raw(
up_thresh: float,
down_thresh: float,
n_looks: int,
alpha: float,
n_up: int,
n_down: int,
) -> None
Re-tune the embedded Dll's code-lock detector directly. Only meaningful once tracking has begun; a no-op while searching.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
up_thresh
|
float
|
CFAR-statistic level to declare code lock (hit when the statistic exceeds it). |
required |
down_thresh
|
float
|
Level below which a look is a miss; choose <= up_thresh for level hysteresis. |
required |
n_looks
|
int
|
Looks per decision — the DLL's non-coherent integration depth feeding one statistic. |
required |
alpha
|
float
|
EMA smoothing coefficient on the lock statistic (0..1); smaller is smoother/slower. |
required |
n_up
|
int
|
Consecutive hits required to declare lock. |
required |
n_down
|
int
|
Consecutive misses required to drop lock. |
required |
Examples:
>>> import numpy as np
>>> from doppler.dsss import DsssReceiver
>>> from doppler.wfm import Gold
>>> code = np.asarray(Gold().generate(1023)).astype(np.uint8)
>>> rx = DsssReceiver(code, chip_rate=3.0e6, symbol_rate=2100.0, spc=2)
>>> rx.configure_lock_raw(up_thresh=0.4, down_thresh=0.2, n_looks=20,
... alpha=0.1, n_up=5, n_down=3)
>>> rx.tracking # a no-op until a hit builds the Dll
0
configure_chain_raw
¶
Pin the despread/resample/demod grid directly, bypassing the create-time segments/sps defaults -- segments (Dll's tracking parameter) and sps/n (MpskReceiver's rate/carrier-arm parameters) stay independently overridable here, still bridged by a freshly-sized RateConverter, never coupled to each other. Only meaningful once tracking; rebuilds the chain with every replacement allocated first, so a failed pin leaves the receiver on its prior grid.
The escape hatch for the one composition-specific knob this object adds
beyond its children's own: segments (Dll's tracking parameter) and
sps/n (MpskReceiver's sample-rate/carrier-arm parameters) are
independently overridable here, still bridged by a freshly-sized
RateConverter — never coupled to each other (see the module
docstring). Rebuilds dll/rc/rx with every replacement allocated
first, only freeing and adopting the old ones once every allocation has
succeeded (mirrors Acquisition's own acq_regrid() discipline) — a
failed pin leaves the receiver tracking on its prior grid, not
half-destroyed. Only meaningful once tracking (the grid defaults still
apply to create-time auto-sizing for the next hit while searching; call
dsss_receiver_create() with different segments/sps for that, or
re-pin here again after the next hit).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
segments
|
int
|
Dll tracking segments per code period. |
required |
sps
|
int
|
MpskReceiver samples per symbol (the resample target). |
required |
n
|
int
|
MpskReceiver's carrier-arm count; must divide sps. |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the C call returns a non-zero status. The exception message is
|
Examples:
>>> import numpy as np
>>> from doppler.dsss import DsssReceiver
>>> from doppler.wfm import Gold
>>> code = np.asarray(Gold().generate(1023)).astype(np.uint8)
>>> rx = DsssReceiver(code, chip_rate=3.0e6, symbol_rate=2100.0, spc=2)
>>> rx.configure_chain_raw(segments=6, sps=8, n=8) # re-pin the chain
>>> rx.segments # tracking grid updated in place
6
reset
¶
Return to the searching state: resets the embedded Acquisition and frees Dll/RateConverter/MpskReceiver (rebuilt from scratch on the next hit).
Examples:
>>> import numpy as np
>>> from doppler.dsss import DsssReceiver
>>> from doppler.wfm import Gold
>>> code = np.asarray(Gold().generate(1023)).astype(np.uint8)
>>> rx = DsssReceiver(code, chip_rate=3.0e6, symbol_rate=2100.0, spc=2)
>>> rx.reset() # abort any lock, hunt from scratch
>>> (rx.tracking, rx.chip_phase) # back to searching, all cleared
(0, 0.0)
state_bytes
¶
Size in bytes of this object's serialized state.
The exact length get_state returns and set_state requires. It
depends on how the object was constructed (state arrays are sized at
construction), so read it from the instance rather than assuming a
constant.
Raises RuntimeError if the DsssReceiver has already been destroyed.
Returns:
| Type | Description |
|---|---|
int
|
Byte length of one serialized state blob. |
get_state
¶
Serialize this object's mutable state to bytes.
Captures exactly the state that evolves as the object runs, so a blob taken now and restored later resumes from this point. Construction parameters are not included: restore into an object built the same way.
The blob is opaque and always state_bytes() long. Its layout is an
implementation detail of the C core and is not a stable format across
builds.
Raises RuntimeError if the DsssReceiver has already been destroyed.
Returns:
| Type | Description |
|---|---|
bytes
|
Opaque snapshot, |
set_state
¶
Restore mutable state from a get_state() blob.
Overwrites the live state in place; the object keeps the parameters it
was constructed with. Length is validated against state_bytes()
before the blob is handed to the C core, and the core may reject it as
well.
Raises TypeError if blob is not bytes, ValueError if its
length differs from state_bytes() or the core rejects it, and
RuntimeError if the DsssReceiver has already been destroyed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
blob
|
bytes
|
A |
required |
destroy
¶
Release the underlying C resources immediately.
Ordinarily unnecessary: the resources are freed when the object is garbage-collected. Call this to release them at a definite point instead, or use the object as a context manager, which calls it on exit.
Idempotent: calling it again on an already-released object does
nothing. Every other method raises RuntimeError once it has run.
__enter__
¶
Enter a context manager, returning this object.
Lets a DsssReceiver be used in a with statement so its C resources
are released deterministically on exit rather than at collection time.
Returns:
| Type | Description |
|---|---|
DsssReceiver
|
This same object, not a copy. |
__exit__
¶
__exit__(
exc_type: object | None = ...,
exc: object | None = ...,
tb: object | None = ...,
) -> None
Exit a context manager, releasing the DsssReceiver.
Equivalent to calling destroy(). Returns None, so an exception
raised inside the with body propagates normally; this never
suppresses one.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
exc_type
|
object | None
|
Exception class, or None. Ignored. |
...
|
exc
|
object | None
|
Exception instance, or None. Ignored. |
...
|
tb
|
object | None
|
Traceback object, or None. Ignored. |
...
|
BurstAcquisition — the burst front door to acquisition¶
BurstAcquisition is the burst-oriented front door to the shared
acquisition engine: a bounded preamble is searched over a (Doppler, code
phase) grid and the peak is reported once, rather than the continuous
streaming push of Acquisition.
Both wrap the same stateless kernel; the two front doors differ only in how
the capture is fed and when the estimate is emitted.
BurstAcquisition
¶
Create a burst-mode acquisition engine (forwards to acq_create_burst() -- see its doc comment in acq_core.h for the full physics).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
code
|
NDArray[uint8]
|
PN chips (0/1), length code_len. |
required |
reps
|
int
|
Max coherent code repetitions (>= 1). |
1
|
spc
|
int
|
Samples per chip (>= 1). |
4
|
chip_rate
|
float
|
Chip rate in Hz (> 0). |
1000000.0
|
cn0_dbhz
|
float
|
Carrier-to-noise density in dB-Hz (> 0). |
0.0
|
doppler_uncertainty
|
float
|
One-sided Doppler search half-range in Hz. |
0.0
|
pfa
|
float
|
Target system false-alarm probability (0,1). |
1e-3
|
pd
|
float
|
Target detection probability (0,1). |
0.9
|
noise_mode
|
Literal['mean', 'median', 'min', 'max']
|
CFAR mode index: 0=mean, 1=median, 2=min, 3=max. |
"mean"
|
Examples:
>>> import numpy as np
>>> from doppler.dsss import BurstAcquisition
>>> from doppler.wfm import PN, mls_poly
>>> code = np.asarray(PN(poly=mls_poly(5), seed=1,
... length=5).generate(31)).astype(np.uint8)
>>> s0 = np.repeat(np.where(code & 1, -1.0, 1.0), 4).astype(
... np.complex64)
>>> burst = np.tile(np.roll(s0, 17), 24).astype(np.complex64)
>>> b = BurstAcquisition(code, reps=8, spc=4, chip_rate=1e6,
... cn0_dbhz=50.0)
>>> b.push(burst)[0][:2] # detects (Doppler bin, code phase)
(0, 17)
max_peaks
property
¶
The peak list's capacity per dwell (1 = the classic gated maximum); set with set_max_peaks().
doppler_bins
property
¶
Coherent depth chosen: the slow-time FFT length in code reps (<= reps), unless doppler_uncertainty exceeds the native span, in which case this reports the wideband window-tile count instead (coherent depth forced to 1 -- see acq_core.h's file doc comment).
pfa_cell
property
¶
Bonferroni per-cell false-alarm probability over the searched cells.
pd_predicted
property
¶
Predicted Pd at cn0_dbhz and the chosen grid: the average Pd over the straddle priors (slow-time scalloping, intra-segment rotation, code-phase sample offset - quadrature over uniform priors), matching what the Monte-Carlo characterization measures rather than the on-grid best case.
straddle_loss
property
¶
Mean amplitude derating of the correlation peak from grid straddle (slow-time Doppler scalloping x intra-segment rotation x code-phase sample offset, each averaged over a uniform prior) - a diagnostic summary; 20*log10(straddle_loss) is the loss in dB. Sizing and pd_predicted average Pd itself over the priors (Pd at this mean amplitude would overstate the mean Pd).
doppler_span_hz
property
¶
Native unambiguous Doppler half-range = +/- chip_rate/(2*sf) Hz.
underpowered
property
¶
True when pd_predicted < pd -- the search cannot meet the target pd at this cn0_dbhz and geometry. The engine still builds a best-effort grid rather than failing; because C cannot raise a Python warning from a successful create, construction also emits a UserWarning in this case.
reset
¶
Drain the input ring and reset the coherent accumulator.
Forwards to acq_reset() on the embedded engine: discards any buffered samples that have not yet completed a frame and clears the non-coherent power accumulator and dwell bookkeeping, so the next push() begins a fresh search from an empty ring. Construction parameters are untouched.
Examples:
>>> import numpy as np
>>> from doppler.dsss import BurstAcquisition
>>> from doppler.wfm import PN, mls_poly
>>> code = np.asarray(PN(poly=mls_poly(5), seed=1,
... length=5).generate(31)).astype(np.uint8)
>>> s0 = np.repeat(np.where(code & 1, -1.0, 1.0), 4).astype(
... np.complex64)
>>> burst = np.tile(np.roll(s0, 17), 24).astype(np.complex64)
>>> b = BurstAcquisition(code, reps=8, spc=4, chip_rate=1e6,
... cn0_dbhz=50.0)
>>> _ = b.push(burst[:100]) # a partial frame, buffered mid-stream
>>> b.reset() # drop it before it can bias a detection
>>> b.push(burst)[0][:2] # (Doppler bin, code phase)
(0, 17)
push
¶
Stream raw samples; emit one event per CFAR dump above threshold.
Forwards to acq_push() on the embedded engine (see its doc comment in acq_core.h for the framing/CFAR mechanics). Each event carries the peak's Doppler bin and code phase (the two search axes), its CFAR statistic, and an estimated C/N0 — see acq_result_t.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x
|
complex
|
Raw input, interleaved CF32, n_in complex samples. |
required |
Returns:
| Type | Description |
|---|---|
list[tuple[int, int, float, float, float, float, int]]
|
Number of events written (0 … max_results). |
Examples:
>>> import numpy as np
>>> from doppler.dsss import BurstAcquisition
>>> from doppler.wfm import PN, mls_poly
>>> code = np.asarray(PN(poly=mls_poly(5), seed=1,
... length=5).generate(31)).astype(np.uint8)
>>> s0 = np.repeat(np.where(code & 1, -1.0, 1.0), 4).astype(
... np.complex64)
>>> burst = np.tile(np.roll(s0, 17), 24).astype(np.complex64)
>>> b = BurstAcquisition(code, reps=8, spc=4, chip_rate=1e6,
... cn0_dbhz=50.0)
>>> b.push(burst)[0][:2] # (Doppler bin, code phase)
(0, 17)
configure_search_raw
¶
Pin the search grid directly, bypassing both auto-sizing searches -- the advanced escape hatch (mirrors Dll.configure_lock_raw/Costas.configure_lock). Resizes every buffer/plan that depends on the grid (the slow-time FFT, the code correlator, the reference, and every per-frame scratch buffer), re-derives the threshold ladder for the pinned grid from the same physics init used, and clears in-flight accumulation (ring contents, the non-coherent power accumulator, dwell bookkeeping) -- call between push() calls, never a substitute for one. Raises ValueError if doppler_bins is outside [1, reps] or n_noncoh is outside [1, 256] (the internal non-coherent-look safety-valve ceiling).
Forwards to acq_configure_search_raw() on the embedded engine (see its doc comment in acq_core.h): resizes every grid-dependent buffer/plan, re-derives the threshold ladder for the pinned grid, and clears in-flight accumulation — call between push() calls, never a substitute for one.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
doppler_bins
|
int
|
Coherent depth to pin, in |
required |
n_noncoh
|
int
|
Non-coherent look count to pin, in |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the C call returns a non-zero status. The exception message is
|
Examples:
>>> import numpy as np
>>> from doppler.dsss import BurstAcquisition
>>> from doppler.wfm import PN, mls_poly
>>> code = np.asarray(PN(poly=mls_poly(5), seed=1,
... length=5).generate(31)).astype(np.uint8)
>>> s0 = np.repeat(np.where(code & 1, -1.0, 1.0), 4).astype(
... np.complex64)
>>> b = BurstAcquisition(code, reps=8, spc=4, chip_rate=1e6,
... cn0_dbhz=50.0)
>>> b.configure_search_raw(doppler_bins=4, n_noncoh=2) # pin the grid
>>> b.doppler_bins, b.n_noncoh
(4, 2)
>>> burst = np.tile(np.roll(s0, 17), 8).astype(np.complex64)
>>> b.push(burst)[0][:2] # detects at the pinned grid
(0, 17)
set_max_peaks
¶
How many peaks a dwell may report -- the peak list's capacity (docs/design/async-dsss-receiver.md section 7.1). One (the default) is the classic gated maximum. More lists every peak above the same gate, strongest first, with an exclusion zone of one Doppler bin by one chip around each (one emitter's main lobe, so its own shoulders are not the next peak) and the two-epoch rule for a peak at an already-listed code phase (a data transition inside the epoch splits one emitter into twins at its own code phase on other tiles; such a peak is held for one dwell and listed only if it is still there, at the same tile, on the next). Each listed peak is one record from push(), all of a dwell's sharing samples_consumed and noise_est; a held twin takes one of the n slots that dwell but is not reported. The threshold does not change with n. Raises ValueError outside 1..64. Clears the held candidates.
Forwards to acq_set_max_peaks() on the embedded engine (see its doc comment in acq_core.h): one is the classic gated maximum; more is the list of docs/design/async-dsss-receiver.md §7.1 -- every peak above the same gate, strongest first, 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. Each listed peak is one result from push().
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
n
|
int
|
1 … ACQ_MAX_PEAKS. |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the C call returns a non-zero status. The exception message is
|
Examples:
state_bytes
¶
Size in bytes of this object's serialized state.
The exact length get_state returns and set_state requires. It
depends on how the object was constructed (state arrays are sized at
construction), so read it from the instance rather than assuming a
constant.
Raises RuntimeError if the BurstAcquisition has already been
destroyed.
Returns:
| Type | Description |
|---|---|
int
|
Byte length of one serialized state blob. |
get_state
¶
Serialize this object's mutable state to bytes.
Captures exactly the state that evolves as the object runs, so a blob taken now and restored later resumes from this point. Construction parameters are not included: restore into an object built the same way.
The blob is opaque and always state_bytes() long. Its layout is an
implementation detail of the C core and is not a stable format across
builds.
Raises RuntimeError if the BurstAcquisition has already been
destroyed.
Returns:
| Type | Description |
|---|---|
bytes
|
Opaque snapshot, |
set_state
¶
Restore mutable state from a get_state() blob.
Overwrites the live state in place; the object keeps the parameters it
was constructed with. Length is validated against state_bytes()
before the blob is handed to the C core, and the core may reject it as
well.
Raises TypeError if blob is not bytes, ValueError if its
length differs from state_bytes() or the core rejects it, and
RuntimeError if the BurstAcquisition has already been destroyed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
blob
|
bytes
|
A |
required |
destroy
¶
Release the underlying C resources immediately.
Ordinarily unnecessary: the resources are freed when the object is garbage-collected. Call this to release them at a definite point instead, or use the object as a context manager, which calls it on exit.
Idempotent: calling it again on an already-released object does
nothing. Every other method raises RuntimeError once it has run.
__enter__
¶
Enter a context manager, returning this object.
Lets a BurstAcquisition be used in a with statement so its C
resources are released deterministically on exit rather than at
collection time.
Returns:
| Type | Description |
|---|---|
BurstAcquisition
|
This same object, not a copy. |
__exit__
¶
__exit__(
exc_type: object | None = ...,
exc: object | None = ...,
tb: object | None = ...,
) -> None
Exit a context manager, releasing the BurstAcquisition.
Equivalent to calling destroy(). Returns None, so an exception
raised inside the with body propagates normally; this never
suppresses one.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
exc_type
|
object | None
|
Exception class, or None. Ignored. |
...
|
exc
|
object | None
|
Exception instance, or None. Ignored. |
...
|
tb
|
object | None
|
Traceback object, or None. Ignored. |
...
|
BurstCapture — acquisition's output, turned into bursts¶
BurstCapture is the stage between a detector and whatever consumes a burst.
BurstAcquisition
reports an END anchor and a code phase that is a lag modulo one code
period, so it names the alignment within a preamble repetition and never
which one — and a burst has a frame that begins in one specific repetition.
BurstCapture resolves that (the refine stage), keeps the look-back needed to
reach a start that has already gone past, and emits the burst's samples.
It stops there. Demodulating is
BurstDemod's job, and
DsssBurstReceiver is this
plus that. Reach for BurstCapture directly when you want the bursts
themselves: a recorder, an offline corpus, or a second consumer fanned out
from one stream.
push() returns windows concatenated — burst i occupies burst_len
samples at i*burst_len — and events() returns the matching record for
each. It owns its acquisition engine rather than accepting detections from
elsewhere, because a hit's samples_consumed is stream-absolute only for an
engine fed continuously and never reset; see
the design note for why that invariant cannot
be delegated to a caller.
BurstCapture
¶
Create a burst capture: acquisition, refine and retention behind one push().
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
acq_code
|
NDArray[uint8]
|
Preamble PN chips (0/1), length acq_code_len. |
required |
burst_len
|
int
|
Samples in one burst -- what gets captured. |
8192
|
reps
|
int
|
Preamble code repetitions. |
5
|
spc
|
int
|
Samples per chip. |
4
|
chip_rate
|
float
|
Chip rate, Hz. |
1000000.0
|
cn0_dbhz
|
float
|
C/N0 the search is sized for, dB-Hz. |
0.0
|
doppler_uncertainty
|
float
|
Doppler search half-range, Hz (0 = native). |
0.0
|
pfa
|
float
|
Target false-alarm probability, in (0, 1). |
1e-3
|
pd
|
float
|
Target detection probability, in (0, 1). |
0.9
|
noise_mode
|
Literal['mean', 'median', 'min', 'max']
|
CFAR reference: 0=mean, 1=median, 2=min, 3=max. |
"mean"
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If construction fails. The exception message is |
Warns:
| Type | Description |
|---|---|
UserWarning
|
Emitted after construction when |
Examples:
>>> import numpy as np
>>> from doppler.dsss import BurstCapture
>>> code = np.array([1, 1, 1, 0, 1, 0, 0], dtype=np.uint8)
>>> cap = BurstCapture(code, burst_len=512, reps=4, spc=2)
>>> cap.burst_len
512
>>> cap.retain_span == cap.refine_span + cap.burst_len
True
preamble_start
property
¶
Stream-absolute sample index the most recent window's preamble starts at. NEVER LATE: a window that began after the preamble has destroyed the burst, so the refine stage's obligation is to be early-or-exact and to say how early.
doppler_hz_est
property
¶
Folded/signed coarse Doppler estimate of the most recent window, Hz. Acquisition's own bin, mapped through dp_fftfreq -- the ONE home for that fold, because a consumer seeded on the wrong side of it is off by the full search span.
doppler_res_hz
property
¶
Acquisition's native Doppler bin width = chip_rate/(sf*coherent_bins), Hz. The width of doppler_hz_est: the estimate is that value +/- half of this.
cn0_dbhz_est
property
¶
Estimated carrier-to-noise density of the most recent window (dB-Hz), backed out of the hit's test statistic. A LOWER BOUND: it tracks the true C/N0 while receiver noise dominates the CFAR estimate, then saturates at the code's own autocorrelation-sidelobe floor once the true C/N0 exceeds what this code and geometry can resolve -- a real ceiling, not a fault.
refine_margin
property
¶
The refine stage's own confidence: the best rival code period's score over the winner's. The envelope is (reps-1)/reps when the right repetition wins, so compare against THAT and never a constant -- the floor rises with depth (0.55 at reps=2, 0.77 at 4, 0.94 at 16). Near 1 means the period was not resolved, which nothing else in the chain can see.
burst_len
property
¶
Samples in one emitted window -- the burst length this capture was built for, and the stride of a row in push()'s return.
refine_span
property
¶
Coalescing window, in samples -- the reach over which two detections
are ONE preamble. Both sides of that test are burst STARTS (resolved
code epochs), so this bounds start-to-start separation, NOT the dead
air between bursts. The two differ by a whole burst, and reading it as
dead air costs a caller real airtime for nothing: the gap actually
required is max(0, refine_span - burst_len), which is 0 whenever a
burst is longer than the refine reach (doppler#1085).
min_gap
property
¶
Dead air to leave BETWEEN bursts, in samples — edge to edge, not
start to start. Derived rather than documented as a rule the caller has
to apply: 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 up to reps * code_period past the
true start. CLAIM merges two anchors closer than refine_span, so a
pair survives only when gap >= refine_span + reps*code_period -
burst_len. Zero is a real answer — a burst longer than
refine_span + reps*P needs no gap for the claim rule's sake — but it
does not mean zero is wise: a zero gap is a continuous stream rather
than a burst link, and it measures 88% at a geometry where this reads
0. Replaces the prose max(0, refine_span - burst_len), which was
short by the whole detection-lag term: 32 samples against 528 at the C
suite's geometry (doppler#1172).
retain_span
property
¶
History kept per anchor, in samples -- the MINIMUM TRAILING CONTEXT.
refine_span plus one whole burst. A burst closer than this to the end
of what has been pushed is held rather than emitted, because refine
cannot yet see the samples it needs. Feed at least this many more, or
the last burst of a capture never comes out.
underpowered
property
¶
True when the search cannot meet the requested pd at this
cn0_dbhz and geometry — pd_predicted < pd. The grid is still built,
best-effort, so the symptom is bursts that are never captured rather
than a failure. Construction also emits a UserWarning; this is the same
fact as a value, for a caller that would rather ask than catch.
pd_predicted
property
¶
Detection probability the sized grid actually predicts at
cn0_dbhz. The number behind underpowered, and the one to compare
against the pd that was asked for.
eta
property
¶
Coherent detection gate: the normalised statistic a single-look
decision must clear, from pfa spread across the search surface. In
force when n_noncoh == 1.
eta_nc
property
¶
Non-coherent detection gate — the one in force when n_noncoh > 1,
which is the usual case. Higher than eta for the same pfa, because
combining looks costs the threshold what it buys in sensitivity.
straddle_loss
property
¶
Correlation kept, worst case, by a burst landing BETWEEN grid points
rather than on one. The search is a finite grid in Doppler and code
phase, so a real burst almost never sits on a hypothesis exactly; this
is what that costs, and it is already priced into pd_predicted.
doppler_bins
property
¶
Doppler hypotheses searched — the coherent depth the sizer chose,
bounded by reps. configure_search_raw is what pins it.
n_noncoh
property
¶
Non-coherent looks combined per decision. Above 1 the object needs that many frames before it can decide at all, which is why a caller sweeping in short dwells has to pin it.
code_bins
property
¶
Code-phase hypotheses per Doppler row: one segment in samples, sf *
spc.
doppler_span_hz
property
¶
Unambiguous Doppler half-range, ± this. Beyond it the per-segment integrate-and-dump's sinc rolloff suppresses the correlation, so a burst outside the span is not merely harder to find — it is nulled.
pending
property
¶
Detections held because their burst window has NOT fully arrived. push() deliberately emits nothing for these: a window is returned when it is complete, not when it is guessed at. What this exists for is the other end -- a caller closing a file or a socket while this is non-zero is discarding a burst that would have been captured, and every other read-back looks identical to "nothing was ever there".
dropped
property
¶
Samples the history ring refused, lifetime. A LOST BURST each, not a statistic -- it survives reset().
push
¶
Stream raw cf32 samples and get back the SAMPLES of every burst whose window has fully arrived, concatenated: burst i occupies burst_len samples starting at i*burst_len, and events() returns the matching record for each. Samples feed the embedded BurstAcquisition and are retained in a history ring; when a detection fires, the refine stage correlates one code period at each preamble position to recover the exact preamble start -- the one quantity acquisition structurally cannot report, since its code_phase is a lag modulo one code period -- and the window is emitted the moment its last sample has arrived. It stops there: what to DO with a burst (demodulate it, write it to a file, ship it to another process) is the caller's. An empty return is normal, not an error: it means no burst completed in this call. Accepts any block size -- the history ring is a contiguous window over the stream and is never reset between bursts, so a burst whose tail falls outside one call is completed by a later one.
Windows are concatenated: burst i occupies burst_len samples
starting at i*burst_len, and events() returns the matching record for
each. Every sample of x is consumed. An empty return is normal -- it
means no burst completed in this call.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x
|
NDArray[complex64]
|
Input samples, x_len long. |
required |
out
|
NDArray[complex64] | None
|
Written with the completed windows; may be NULL to drop. |
None
|
Returns:
| Type | Description |
|---|---|
NDArray[complex64]
|
Samples written -- always a multiple of |
Examples:
>>> import numpy as np
>>> from doppler.dsss import BurstCapture
>>> code = np.array([1, 1, 1, 0, 1, 0, 0], dtype=np.uint8)
>>> cap = BurstCapture(code, burst_len=512, reps=4, spc=2)
>>> win = cap.push(np.zeros(4096, dtype=np.complex64))
>>> win.size % cap.burst_len # whole windows, never a partial
0
>>> win.size # silence, so no burst completed
0
push_max_out
¶
Upper bound on samples push() can return for x_len input.
Distinct bursts cannot overlap, so x_len samples complete at most
x_len/burst_len + 1 of them, plus whatever is already queued.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x_len
|
int
|
Input. |
required |
Returns:
| Type | Description |
|---|---|
int
|
Output. |
detections
¶
Every hit the search made in the last push(), unfiltered — before
the claim rule coalesced the several detections of one preamble, and
before the suppression window dropped the ones inside a burst already
captured. So several rows can name one burst and a row can be a false
alarm; that is the point. Each carries the STREAM-ABSOLUTE code epoch,
which acquisition's own code_phase is not (it is a lag modulo one
code period), plus the folded Doppler, the C/N0 lower bound and the
CFAR statistic that gated it. Read events() instead for the bursts
that survived and whose windows arrived. Valid until the next push(),
reset() or set_state().
BEFORE the claim rule and the suppression window: several rows can name
one preamble, and a row can be a false alarm. That is the point -- this
is what acquisition FOUND, and events() is what survived. Valid until
the next push(), reset() or set_state().
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
count
|
int
|
How many output samples to ask for. The call may return fewer; size
an |
1
|
out
|
NDArray[Any] | None
|
Optional pre-allocated output buffer. When given, the result is written into it and the returned array is a view of exactly the samples produced; when omitted, a fresh array is allocated. |
None
|
Returns:
| Type | Description |
|---|---|
NDArray[Any]
|
Output. |
Examples:
>>> import numpy as np
>>> from doppler.dsss import BurstCapture
>>> code = np.array([1, 1, 1, 0, 1, 0, 0], dtype=np.uint8)
>>> cap = BurstCapture(code, burst_len=512, reps=4, spc=2)
>>> _ = cap.push(np.zeros(4096, dtype=np.complex64))
>>> # what the search found, against what became a burst
>>> len(cap.detections()) >= len(cap.events())
True
detections_max_out
¶
Raw detections available from the last push(). n is ignored.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
n
|
int
|
Input. |
required |
Returns:
| Type | Description |
|---|---|
int
|
Output. |
events
¶
The event record for each burst the last push() returned. Row i describes the window at samples[i*burst_len ...] of that push. Valid until the next push(), reset() or set_state().
Row i describes the window at i*burst_len. Valid until the next
push(), reset() or set_state().
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
count
|
int
|
How many output samples to ask for. The call may return fewer; size
an |
1
|
out
|
NDArray[Any] | None
|
Optional pre-allocated output buffer. When given, the result is written into it and the returned array is a view of exactly the samples produced; when omitted, a fresh array is allocated. |
None
|
Returns:
| Type | Description |
|---|---|
NDArray[Any]
|
Output. |
Examples:
events_max_out
¶
Records available from the last push(). n is ignored.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
n
|
int
|
Input. |
required |
Returns:
| Type | Description |
|---|---|
int
|
Output. |
configure_search_raw
¶
Pin the embedded BurstAcquisition's search grid directly, bypassing the auto-sizing -- the escape hatch for a caller who wants a specific (doppler_bins, n_noncoh). Forwards to the engine unchanged.
The escape hatch for a caller who wants a specific (doppler_bins,
n_noncoh). Forwards to the engine, with one refusal of this object's
own: a grid whose anchor can lag the preamble by more than refine
reaches -- n_noncoh * doppler_bins code periods against k_lo -- is
rejected rather than accepted and silently mis-refined. Acquisition
stamps a hit at the end of the LAST accumulated look, so every look
past the one holding the preamble moves the anchor a whole frame later;
a burst has one frame of preamble, so n_noncoh = 1 is the grid a
capture wants and the sizer now always picks (doppler#1181).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
doppler_bins
|
int
|
Input. |
required |
n_noncoh
|
int
|
Input. |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the C call returns a non-zero status. The exception message is
|
Examples:
release
¶
Give back the span window i of the last push() claimed. An emitted
window owns its whole span: detections inside it are the payload firing
against the acquisition code, so they are HELD rather than reported. A
consumer that knows better -- a demodulator whose CRC failed -- calls
this for that window, and the held detections are searched again on the
next push(). Unreleased, they are dropped when the next push() begins.
Raises ValueError if i is not a window of the last push().
An emitted window owns its whole span: a detection inside it is the payload firing against the acquisition code, not a new burst, so it is HELD rather than reported. Whether the window WAS a burst is a verdict this object cannot reach -- it stops at samples; error detection, whatever form the frame gives it, is the consumer's -- so a consumer that knows better calls this for that window, and the held detections are searched again on the next push(). Unreleased, they are dropped when the next push() begins, which is exactly the behaviour a consumer with no verdict always had.
What it prevents (doppler#1181): a spurious window ending just after a real burst begins used to swallow that burst's first detections -- the receiver's own design says only a DECODED burst may own a span (§10.3, doppler#1004), and the capture underneath had been owning it on emission.
Must be called BEFORE the next push(): i indexes THIS push's windows.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
i
|
int
|
Input. |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the C call returns a non-zero status. The exception message is
|
Examples:
>>> import numpy as np
>>> from doppler.dsss import BurstCapture
>>> code = np.array([1, 1, 1, 0, 1, 0, 0], dtype=np.uint8)
>>> cap = BurstCapture(code, burst_len=512, reps=4, spc=2)
>>> _ = cap.push(np.zeros(4096, dtype=np.complex64))
>>> cap.release(0) # no window 0 in a quiet push
Traceback (most recent call last):
...
ValueError: release failed (rc=-4)
reset
¶
Return to the searching state: resets the embedded acquisition, drops the history ring's contents, clears every queued detection and every read-back, so a fresh stream cannot inherit the previous one's position. Construction parameters are untouched.
Resets the embedded acquisition, rewinds the history ring, clears every
queued detection and every read-back. Construction parameters are
untouched; dropped deliberately survives, because a lost burst stays
lost.
Examples:
state_bytes
¶
Size in bytes of this object's serialized state.
The exact length get_state returns and set_state requires. It
depends on how the object was constructed (state arrays are sized at
construction), so read it from the instance rather than assuming a
constant.
Raises RuntimeError if the BurstCapture has already been destroyed.
Returns:
| Type | Description |
|---|---|
int
|
Byte length of one serialized state blob. |
get_state
¶
Serialize this object's mutable state to bytes.
Captures exactly the state that evolves as the object runs, so a blob taken now and restored later resumes from this point. Construction parameters are not included: restore into an object built the same way.
The blob is opaque and always state_bytes() long. Its layout is an
implementation detail of the C core and is not a stable format across
builds.
Raises RuntimeError if the BurstCapture has already been destroyed.
Returns:
| Type | Description |
|---|---|
bytes
|
Opaque snapshot, |
set_state
¶
Restore mutable state from a get_state() blob.
Overwrites the live state in place; the object keeps the parameters it
was constructed with. Length is validated against state_bytes()
before the blob is handed to the C core, and the core may reject it as
well.
Raises TypeError if blob is not bytes, ValueError if its
length differs from state_bytes() or the core rejects it, and
RuntimeError if the BurstCapture has already been destroyed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
blob
|
bytes
|
A |
required |
destroy
¶
Release the underlying C resources immediately.
Ordinarily unnecessary: the resources are freed when the object is garbage-collected. Call this to release them at a definite point instead, or use the object as a context manager, which calls it on exit.
Idempotent: calling it again on an already-released object does
nothing. Every other method raises RuntimeError once it has run.
__enter__
¶
Enter a context manager, returning this object.
Lets a BurstCapture be used in a with statement so its C resources
are released deterministically on exit rather than at collection time.
Returns:
| Type | Description |
|---|---|
BurstCapture
|
This same object, not a copy. |
__exit__
¶
__exit__(
exc_type: object | None = ...,
exc: object | None = ...,
tb: object | None = ...,
) -> None
Exit a context manager, releasing the BurstCapture.
Equivalent to calling destroy(). Returns None, so an exception
raised inside the with body propagates normally; this never
suppresses one.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
exc_type
|
object | None
|
Exception class, or None. Ignored. |
...
|
exc
|
object | None
|
Exception instance, or None. Ignored. |
...
|
tb
|
object | None
|
Traceback object, or None. Ignored. |
...
|
PersistentBurstCapture — the same capture, with the ring in a file¶
The look-back is essentially the whole checkpoint: at a 511-chip code, 5
repetitions and an 8029-symbol frame, BurstCapture.state_bytes() is
16.68 MB and all but ~20 kB of it is retained history.
PersistentBurstCapture takes a path and backs the ring's pages with that
file (MAP_SHARED), so the samples are the file's contents — there is no
mirror buffer and no flush path. Two things follow: the blob drops to 21.6 kB
because the history is already durable, and the history outlives the process,
so a capture restored over the same file reaches back across a restart into a
burst that began before it.
It is a view over the same core — the constructor differs and nothing else
does — so every method and read-back is shared verbatim. A blob does not
travel between the two flavours in either direction: state_bytes() differs,
and accepting one for the other would resume a capture whose history was
somewhere else.
PersistentBurstCapture
¶
Create a capture whose look-back lives in a FILE.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str | PathLike
|
File to back the ring with; not NULL and not empty. |
required |
acq_code
|
NDArray[uint8]
|
Preamble PN chips (0/1), length acq_code_len. |
required |
burst_len
|
int
|
Samples in one burst -- what gets captured. |
8192
|
reps
|
int
|
Preamble code repetitions. |
5
|
spc
|
int
|
Samples per chip. |
4
|
chip_rate
|
float
|
Chip rate, Hz. |
1000000.0
|
cn0_dbhz
|
float
|
C/N0 the search is sized for, dB-Hz. |
0.0
|
doppler_uncertainty
|
float
|
Doppler search half-range, Hz (0 = native). |
0.0
|
pfa
|
float
|
Target false-alarm probability, in (0, 1). |
1e-3
|
pd
|
float
|
Target detection probability, in (0, 1). |
0.9
|
noise_mode
|
Literal['mean', 'median', 'min', 'max']
|
CFAR reference: 0=mean, 1=median, 2=min, 3=max. |
"mean"
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If construction fails. The exception message is |
Warns:
| Type | Description |
|---|---|
UserWarning
|
Emitted after construction when |
Examples:
>>> import numpy as np, tempfile, os
>>> from doppler.dsss import BurstCapture, PersistentBurstCapture
>>> code = np.array([1, 1, 1, 0, 1, 0, 0], dtype=np.uint8)
>>> path = os.path.join(tempfile.mkdtemp(), "ring.cf32")
>>> cap = PersistentBurstCapture(path, code, burst_len=512,
... reps=4, spc=2)
>>> ram = BurstCapture(code, burst_len=512, reps=4, spc=2)
>>> _ = cap.push(np.zeros(4096, dtype=np.complex64))
>>> # the look-back is in the file, so the blob stops carrying it
>>> ram.state_bytes() - cap.state_bytes() == ram.retain_span * 8
True
>>> os.path.getsize(path) > 0
True
preamble_start
property
¶
Stream-absolute sample index the most recent window's preamble starts at. NEVER LATE: a window that began after the preamble has destroyed the burst, so the refine stage's obligation is to be early-or-exact and to say how early.
doppler_hz_est
property
¶
Folded/signed coarse Doppler estimate of the most recent window, Hz. Acquisition's own bin, mapped through dp_fftfreq -- the ONE home for that fold, because a consumer seeded on the wrong side of it is off by the full search span.
doppler_res_hz
property
¶
Acquisition's native Doppler bin width = chip_rate/(sf*coherent_bins), Hz. The width of doppler_hz_est: the estimate is that value +/- half of this.
cn0_dbhz_est
property
¶
Estimated carrier-to-noise density of the most recent window (dB-Hz), backed out of the hit's test statistic. A LOWER BOUND: it tracks the true C/N0 while receiver noise dominates the CFAR estimate, then saturates at the code's own autocorrelation-sidelobe floor once the true C/N0 exceeds what this code and geometry can resolve -- a real ceiling, not a fault.
refine_margin
property
¶
The refine stage's own confidence: the best rival code period's score over the winner's. The envelope is (reps-1)/reps when the right repetition wins, so compare against THAT and never a constant -- the floor rises with depth (0.55 at reps=2, 0.77 at 4, 0.94 at 16). Near 1 means the period was not resolved, which nothing else in the chain can see.
burst_len
property
¶
Samples in one emitted window -- the burst length this capture was built for, and the stride of a row in push()'s return.
refine_span
property
¶
Coalescing window, in samples -- the reach over which two detections
are ONE preamble. Both sides of that test are burst STARTS (resolved
code epochs), so this bounds start-to-start separation, NOT the dead
air between bursts. The two differ by a whole burst, and reading it as
dead air costs a caller real airtime for nothing: the gap actually
required is max(0, refine_span - burst_len), which is 0 whenever a
burst is longer than the refine reach (doppler#1085).
min_gap
property
¶
Dead air to leave BETWEEN bursts, in samples — edge to edge, not
start to start. Derived rather than documented as a rule the caller has
to apply: 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 up to reps * code_period past the
true start. CLAIM merges two anchors closer than refine_span, so a
pair survives only when gap >= refine_span + reps*code_period -
burst_len. Zero is a real answer — a burst longer than
refine_span + reps*P needs no gap for the claim rule's sake — but it
does not mean zero is wise: a zero gap is a continuous stream rather
than a burst link, and it measures 88% at a geometry where this reads
0. Replaces the prose max(0, refine_span - burst_len), which was
short by the whole detection-lag term: 32 samples against 528 at the C
suite's geometry (doppler#1172).
retain_span
property
¶
History kept per anchor, in samples -- the MINIMUM TRAILING CONTEXT.
refine_span plus one whole burst. A burst closer than this to the end
of what has been pushed is held rather than emitted, because refine
cannot yet see the samples it needs. Feed at least this many more, or
the last burst of a capture never comes out.
underpowered
property
¶
True when the search cannot meet the requested pd at this
cn0_dbhz and geometry — pd_predicted < pd. The grid is still built,
best-effort, so the symptom is bursts that are never captured rather
than a failure. Construction also emits a UserWarning; this is the same
fact as a value, for a caller that would rather ask than catch.
pd_predicted
property
¶
Detection probability the sized grid actually predicts at
cn0_dbhz. The number behind underpowered, and the one to compare
against the pd that was asked for.
eta
property
¶
Coherent detection gate: the normalised statistic a single-look
decision must clear, from pfa spread across the search surface. In
force when n_noncoh == 1.
eta_nc
property
¶
Non-coherent detection gate — the one in force when n_noncoh > 1,
which is the usual case. Higher than eta for the same pfa, because
combining looks costs the threshold what it buys in sensitivity.
straddle_loss
property
¶
Correlation kept, worst case, by a burst landing BETWEEN grid points
rather than on one. The search is a finite grid in Doppler and code
phase, so a real burst almost never sits on a hypothesis exactly; this
is what that costs, and it is already priced into pd_predicted.
doppler_bins
property
¶
Doppler hypotheses searched — the coherent depth the sizer chose,
bounded by reps. configure_search_raw is what pins it.
n_noncoh
property
¶
Non-coherent looks combined per decision. Above 1 the object needs that many frames before it can decide at all, which is why a caller sweeping in short dwells has to pin it.
code_bins
property
¶
Code-phase hypotheses per Doppler row: one segment in samples, sf *
spc.
doppler_span_hz
property
¶
Unambiguous Doppler half-range, ± this. Beyond it the per-segment integrate-and-dump's sinc rolloff suppresses the correlation, so a burst outside the span is not merely harder to find — it is nulled.
pending
property
¶
Detections held because their burst window has NOT fully arrived. push() deliberately emits nothing for these: a window is returned when it is complete, not when it is guessed at. What this exists for is the other end -- a caller closing a file or a socket while this is non-zero is discarding a burst that would have been captured, and every other read-back looks identical to "nothing was ever there".
dropped
property
¶
Samples the history ring refused, lifetime. A LOST BURST each, not a statistic -- it survives reset().
push
¶
Stream raw cf32 samples and get back the SAMPLES of every burst whose window has fully arrived, concatenated: burst i occupies burst_len samples starting at i*burst_len, and events() returns the matching record for each. Samples feed the embedded BurstAcquisition and are retained in a history ring; when a detection fires, the refine stage correlates one code period at each preamble position to recover the exact preamble start -- the one quantity acquisition structurally cannot report, since its code_phase is a lag modulo one code period -- and the window is emitted the moment its last sample has arrived. It stops there: what to DO with a burst (demodulate it, write it to a file, ship it to another process) is the caller's. An empty return is normal, not an error: it means no burst completed in this call. Accepts any block size -- the history ring is a contiguous window over the stream and is never reset between bursts, so a burst whose tail falls outside one call is completed by a later one.
Windows are concatenated: burst i occupies burst_len samples
starting at i*burst_len, and events() returns the matching record for
each. Every sample of x is consumed. An empty return is normal -- it
means no burst completed in this call.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x
|
NDArray[complex64]
|
Input samples, x_len long. |
required |
out
|
NDArray[complex64] | None
|
Written with the completed windows; may be NULL to drop. |
None
|
Returns:
| Type | Description |
|---|---|
NDArray[complex64]
|
Samples written -- always a multiple of |
Examples:
>>> import numpy as np
>>> from doppler.dsss import BurstCapture
>>> code = np.array([1, 1, 1, 0, 1, 0, 0], dtype=np.uint8)
>>> cap = BurstCapture(code, burst_len=512, reps=4, spc=2)
>>> win = cap.push(np.zeros(4096, dtype=np.complex64))
>>> win.size % cap.burst_len # whole windows, never a partial
0
>>> win.size # silence, so no burst completed
0
push_max_out
¶
Upper bound on samples push() can return for x_len input.
Distinct bursts cannot overlap, so x_len samples complete at most
x_len/burst_len + 1 of them, plus whatever is already queued.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x_len
|
int
|
Input. |
required |
Returns:
| Type | Description |
|---|---|
int
|
Output. |
detections
¶
Every hit the search made in the last push(), unfiltered — before
the claim rule coalesced the several detections of one preamble, and
before the suppression window dropped the ones inside a burst already
captured. So several rows can name one burst and a row can be a false
alarm; that is the point. Each carries the STREAM-ABSOLUTE code epoch,
which acquisition's own code_phase is not (it is a lag modulo one
code period), plus the folded Doppler, the C/N0 lower bound and the
CFAR statistic that gated it. Read events() instead for the bursts
that survived and whose windows arrived. Valid until the next push(),
reset() or set_state().
BEFORE the claim rule and the suppression window: several rows can name
one preamble, and a row can be a false alarm. That is the point -- this
is what acquisition FOUND, and events() is what survived. Valid until
the next push(), reset() or set_state().
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
count
|
int
|
How many output samples to ask for. The call may return fewer; size
an |
1
|
out
|
NDArray[Any] | None
|
Optional pre-allocated output buffer. When given, the result is written into it and the returned array is a view of exactly the samples produced; when omitted, a fresh array is allocated. |
None
|
Returns:
| Type | Description |
|---|---|
NDArray[Any]
|
Output. |
Examples:
>>> import numpy as np
>>> from doppler.dsss import BurstCapture
>>> code = np.array([1, 1, 1, 0, 1, 0, 0], dtype=np.uint8)
>>> cap = BurstCapture(code, burst_len=512, reps=4, spc=2)
>>> _ = cap.push(np.zeros(4096, dtype=np.complex64))
>>> # what the search found, against what became a burst
>>> len(cap.detections()) >= len(cap.events())
True
detections_max_out
¶
Raw detections available from the last push(). n is ignored.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
n
|
int
|
Input. |
required |
Returns:
| Type | Description |
|---|---|
int
|
Output. |
events
¶
The event record for each burst the last push() returned. Row i describes the window at samples[i*burst_len ...] of that push. Valid until the next push(), reset() or set_state().
Row i describes the window at i*burst_len. Valid until the next
push(), reset() or set_state().
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
count
|
int
|
How many output samples to ask for. The call may return fewer; size
an |
1
|
out
|
NDArray[Any] | None
|
Optional pre-allocated output buffer. When given, the result is written into it and the returned array is a view of exactly the samples produced; when omitted, a fresh array is allocated. |
None
|
Returns:
| Type | Description |
|---|---|
NDArray[Any]
|
Output. |
Examples:
events_max_out
¶
Records available from the last push(). n is ignored.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
n
|
int
|
Input. |
required |
Returns:
| Type | Description |
|---|---|
int
|
Output. |
configure_search_raw
¶
Pin the embedded BurstAcquisition's search grid directly, bypassing the auto-sizing -- the escape hatch for a caller who wants a specific (doppler_bins, n_noncoh). Forwards to the engine unchanged.
The escape hatch for a caller who wants a specific (doppler_bins,
n_noncoh). Forwards to the engine, with one refusal of this object's
own: a grid whose anchor can lag the preamble by more than refine
reaches -- n_noncoh * doppler_bins code periods against k_lo -- is
rejected rather than accepted and silently mis-refined. Acquisition
stamps a hit at the end of the LAST accumulated look, so every look
past the one holding the preamble moves the anchor a whole frame later;
a burst has one frame of preamble, so n_noncoh = 1 is the grid a
capture wants and the sizer now always picks (doppler#1181).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
doppler_bins
|
int
|
Input. |
required |
n_noncoh
|
int
|
Input. |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the C call returns a non-zero status. The exception message is
|
Examples:
release
¶
Give back the span window i of the last push() claimed. An emitted
window owns its whole span: detections inside it are the payload firing
against the acquisition code, so they are HELD rather than reported. A
consumer that knows better -- a demodulator whose CRC failed -- calls
this for that window, and the held detections are searched again on the
next push(). Unreleased, they are dropped when the next push() begins.
Raises ValueError if i is not a window of the last push().
An emitted window owns its whole span: a detection inside it is the payload firing against the acquisition code, not a new burst, so it is HELD rather than reported. Whether the window WAS a burst is a verdict this object cannot reach -- it stops at samples; error detection, whatever form the frame gives it, is the consumer's -- so a consumer that knows better calls this for that window, and the held detections are searched again on the next push(). Unreleased, they are dropped when the next push() begins, which is exactly the behaviour a consumer with no verdict always had.
What it prevents (doppler#1181): a spurious window ending just after a real burst begins used to swallow that burst's first detections -- the receiver's own design says only a DECODED burst may own a span (§10.3, doppler#1004), and the capture underneath had been owning it on emission.
Must be called BEFORE the next push(): i indexes THIS push's windows.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
i
|
int
|
Input. |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the C call returns a non-zero status. The exception message is
|
Examples:
>>> import numpy as np
>>> from doppler.dsss import BurstCapture
>>> code = np.array([1, 1, 1, 0, 1, 0, 0], dtype=np.uint8)
>>> cap = BurstCapture(code, burst_len=512, reps=4, spc=2)
>>> _ = cap.push(np.zeros(4096, dtype=np.complex64))
>>> cap.release(0) # no window 0 in a quiet push
Traceback (most recent call last):
...
ValueError: release failed (rc=-4)
reset
¶
Return to the searching state: resets the embedded acquisition, drops the history ring's contents, clears every queued detection and every read-back, so a fresh stream cannot inherit the previous one's position. Construction parameters are untouched.
Resets the embedded acquisition, rewinds the history ring, clears every
queued detection and every read-back. Construction parameters are
untouched; dropped deliberately survives, because a lost burst stays
lost.
Examples:
state_bytes
¶
Size in bytes of this object's serialized state.
The exact length get_state returns and set_state requires. It
depends on how the object was constructed (state arrays are sized at
construction), so read it from the instance rather than assuming a
constant.
Raises RuntimeError if the PersistentBurstCapture has already been
destroyed.
Returns:
| Type | Description |
|---|---|
int
|
Byte length of one serialized state blob. |
get_state
¶
Serialize this object's mutable state to bytes.
Captures exactly the state that evolves as the object runs, so a blob taken now and restored later resumes from this point. Construction parameters are not included: restore into an object built the same way.
The blob is opaque and always state_bytes() long. Its layout is an
implementation detail of the C core and is not a stable format across
builds.
Raises RuntimeError if the PersistentBurstCapture has already been
destroyed.
Returns:
| Type | Description |
|---|---|
bytes
|
Opaque snapshot, |
set_state
¶
Restore mutable state from a get_state() blob.
Overwrites the live state in place; the object keeps the parameters it
was constructed with. Length is validated against state_bytes()
before the blob is handed to the C core, and the core may reject it as
well.
Raises TypeError if blob is not bytes, ValueError if its
length differs from state_bytes() or the core rejects it, and
RuntimeError if the PersistentBurstCapture has already been
destroyed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
blob
|
bytes
|
A |
required |
destroy
¶
Release the underlying C resources immediately.
Ordinarily unnecessary: the resources are freed when the object is garbage-collected. Call this to release them at a definite point instead, or use the object as a context manager, which calls it on exit.
Idempotent: calling it again on an already-released object does
nothing. Every other method raises RuntimeError once it has run.
__enter__
¶
Enter a context manager, returning this object.
Lets a PersistentBurstCapture be used in a with statement so its C
resources are released deterministically on exit rather than at
collection time.
Returns:
| Type | Description |
|---|---|
PersistentBurstCapture
|
This same object, not a copy. |
__exit__
¶
__exit__(
exc_type: object | None = ...,
exc: object | None = ...,
tb: object | None = ...,
) -> None
Exit a context manager, releasing the PersistentBurstCapture.
Equivalent to calling destroy(). Returns None, so an exception
raised inside the with body propagates normally; this never
suppresses one.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
exc_type
|
object | None
|
Exception class, or None. Ignored. |
...
|
exc
|
object | None
|
Exception instance, or None. Ignored. |
...
|
tb
|
object | None
|
Traceback object, or None. Ignored. |
...
|
AsyncDsssReceiver — the packaged continuous async receiver¶
AsyncDsssReceiver wraps the whole acquire → carrier-refine → track chain
behind one steps() call for the continuous asynchronous waveform
(non-integer chips/symbol). It carries two carrier loops — a pre-despread
Costas that tracks the Doppler dynamics before the code loop, and a
post-despread mop-up loop — plus carrier→code aiding for coupled clock
Doppler and a binary symbol-lock indicator. See the gallery page
AsyncDsssReceiver: the SPEC Waveform
for an end-to-end decode through physically-coupled Doppler.
AsyncDsssReceiver
¶
Create an AsyncDsssReceiver in the searching state.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
code
|
NDArray[uint8]
|
Spreading code, one 0/1 chip per element (0 -> +1, 1 -> -1 BPSK; only the low bit is used, so pass 0/1, not +/-1). |
required |
chip_rate
|
float
|
Chip rate, Hz. Required. |
1000000.0
|
symbol_rate
|
float
|
Data-symbol rate, Hz. Required. |
1000.0
|
spc
|
int
|
Samples/chip; default 2. |
2
|
m
|
int
|
PSK order, 2/4/8; default 2 (BPSK). |
2
|
cn0_dbhz
|
float
|
Design C/N0, dB-Hz; default 55.0 -- feeds BOTH the embedded
Acquisition's own sizing AND (derated by |
55.0
|
pfa
|
float
|
Acquisition false-alarm target; default 1e-3. Also CarrierAcquisition's
own |
1e-3
|
pd
|
float
|
Acquisition detection-probability target; default 0.9. Also
CarrierAcquisition's own |
0.9
|
doppler_uncertainty
|
float
|
One-sided Doppler search half-range, Hz; default 100.0. |
100.0
|
segments
|
int
|
Live-tracking Dll's own segments; default 4. |
4
|
sps
|
int
|
MpskReceiver's samples/symbol; default 8. |
8
|
differential
|
int
|
MpskReceiver's differential demap; default 0 (coherent). |
0
|
refine_max_error_db
|
float
|
Max tolerable async-lookback correlation-power loss driving the refine-stage collection Dll's coherent-I&D window count via dll_lookback_segments(). Oversampling the epoch is required for the asynchronous data: the residual carrier rides a ~symbol_rate-wide data-modulated spectrum, so segments>1 (default yields 11 at tsamps=2046) samples it above Nyquist; segments=1 undersamples and aliases it. Default 0.5. |
0.5
|
refine_samples_per_symbol
|
int
|
CarrierAcquisition's own operating rate = this * symbol_rate; default 4. |
4
|
refine_design_margin_db
|
float
|
Empirical derating of cn0_dbhz before CarrierAcquisition's design_snr; default 14.0. |
14.0
|
refine_n_fft
|
int
|
CarrierAcquisition's own block size; default 64. |
64
|
refine_zero_pad
|
int
|
CarrierAcquisition's own zero_pad; default 8. |
8
|
refine_sequential
|
bool
|
CarrierAcquisition's own sequential mode; default false -- sequential mode's early per-block test fires on far too little averaging at SPEC's own Es/N0 floor (confirmed: as few as 4 blocks, 150-200+ Hz off); false waits the full design_snr-derived dwell_target, matching freq_refine.refine_seed_ carrier_acq()'s own validated default. |
False
|
refine_max_n_blocks
|
int
|
CarrierAcquisition's own give-up cap in sequential mode; default 100000. |
100000
|
carrier_freq_hz
|
float
|
Nominal RF carrier frequency, Hz, enabling carrier->code aiding; 0.0 (default) = off. When > 0, the coupled code-rate Doppler (carrier_offset/carrier_freq) is fed to the tracking Dll via dll_set_rate_aid() so the code loop rides a dilated clock the discriminator alone can't pull in at low SNR. Set to the receiver's own downlink RF frequency for a physically-coupled Doppler capture. |
0.0
|
lost_confirm_s
|
float
|
Release rule: both lock flags down, continuously, for longer than this many seconds puts the receiver in the lost state (see get_lost()). Size it past the longest fade the link must ride. The clock also runs from the first tracking sample, when neither flag is up yet, so a hand-off that never locks within the interval is released the same way as an emitter that leaves. Default 0.0 = never -- the searching flavor's exit is reset(), as before. |
0.0
|
Examples:
>>> import numpy as np
>>> from doppler.dsss import AsyncDsssReceiver
>>> from doppler.wfm import Gold
>>> sf, chip, sym, spc = 1023, 3.069e6, 2700.0, 2
>>> fs, te, tsym = chip * spc, sf * spc, chip * spc / sym
>>> code = np.asarray(Gold().generate(sf)).astype(np.uint8)
>>> csign = np.where(code & 1, -1.0, 1.0)
>>> rng = np.random.default_rng(21)
>>> n = int(600 * tsym) + 4 * te # 600 async BPSK symbols
>>> idx = np.arange(n)
>>> data = (rng.integers(0, 2, 604) * 2 - 1).astype(float)
>>> si = np.clip((idx / tsym).astype(int), 0, 603)
>>> t = idx / fs
DSSS chips on a carrier sweeping at 500 Hz/s — the ramp the async receiver has to track:
>>> sig = (data[si] * csign[(idx // spc) % sf]
... * np.exp(1j * 2 * np.pi * 0.5 * 500.0 * t * t))
>>> cn0 = 20.0 + 10 * np.log10(sym) # Es/N0 = 20 dB
>>> sigma = np.sqrt(fs / 10 ** (cn0 / 10))
>>> pre = 5 * te # noise-only lead-in
>>> noise = (sigma / np.sqrt(2)) * (rng.standard_normal(pre + n)
... + 1j * rng.standard_normal(pre + n))
>>> x = (np.concatenate([np.zeros(pre), sig]).astype(np.complex64)
... + noise.astype(np.complex64))
>>> rx = AsyncDsssReceiver(
... code, chip_rate=chip, symbol_rate=sym, spc=spc,
... cn0_dbhz=cn0, doppler_uncertainty=500.0)
>>> syms = [rx.steps(x[p:p + te]) for p in range(0, len(x) - te, te)]
>>> syms = np.concatenate([s for s in syms if len(s)])
>>> rx.tracking # searched, refined, now tracking
1
>>> len(syms) > 300 # symbols recovered under the ramp
True
Nearly all the energy lands on I, so the BPSK phase is resolved:
refine_min_blocks
property
¶
Floor on the refine's dwell, blocks (default 7); set with set_refine_min_blocks().
tracking
property
¶
1 once the live tracking chain is built and demodulating; 0 while searching or refining.
refining
property
¶
1 while the refine stage (CarrierAcquisition collection) is active; 0 while searching or tracking.
idle
property
¶
1 while waiting for a seed (the hand-off flavor before seed() or after reset()); 0 in every other state.
lost
property
¶
1 once the release rule has fired: both lock flags were down, continuously, for longer than lost_confirm_s while tracking. The loops have stopped and samples are discarded; the holder releases the assignment and calls reset(). Always 0 with lost_confirm_s = 0.
doppler_hz
property
¶
The current best Doppler estimate: the coarse handoff value while refining, the CarrierAcquisition-refined value once tracking.
segments
property
¶
Live-tracking Dll's own segments -- distinct from refine_segments above (see the module docstring / dll_lookback_segments()'s own doc on the WINDOWS vs TRACK_WINDOWS split).
chip_phase
property
¶
Live Dll code phase in chips, Dll's own instantaneous-phase convention (the mirror image of acq_result_t::code_phase's correlation-lag convention -- see acq_build_handoff()'s doc comment).
lock
property
¶
decision rule on lock_metric: thresholds + verify counters, stepped per symbol.
norm_freq
property
¶
Smoothed carrier estimate (integrator only, cycles/sample of the MpskReceiver output rate); lags a Doppler ramp by the constant Type-II ramp error.
nco_freq
property
¶
Live carrier loop-filter output = NCO frequency command (cycles/sample of the MpskReceiver output rate): its mean tracks a Doppler ramp with no lag, its variance is the carrier loop stress.
locked
property
¶
Binary receiver lock: the hysteretic (up/down verify-counted) lock detector on the emitted symbols -- declared when lock_metric stays >= lock_threshold for the up-count and dropped below it for the down-count.
lock_metric
property
¶
Symbol-lock metric: SNR-weighted running mean of the BPSK lock
signal (I^2-Q^2)/(I^2+Q^2) = cos(2*phi) over the emitted symbols
(locked -> ~+1). Drives locked; exposed for engineering debug.
lock_threshold
property
¶
The lock_metric declare threshold locked latches above (the
lockdet up_thresh); exposed alongside lock_metric for engineering
debug.
car_last_error
property
¶
Pre-despread Costas phase discriminator (rad): the residual carrier phase loop 1 (de-rotates before the Dll) is not nulling. Engineering debug.
car_nco_freq
property
¶
Loop 1 (pre-despread Costas) loop-filter output = NCO frequency command, cycles/sample of the front-end (chip_rate*spc) rate. Engineering debug.
mpsk_last_error
property
¶
MpskReceiver carrier phase discriminator (rad): the residual carrier phase loop 2 (post-despread) is not nulling. Engineering debug.
code_locked
property
¶
Binary code-lock flag from the live tracking Dll's own verify-counted (pfa-tuned) lock detector -- the fundamental DSSS "am I despreading" lock, de-chattered by up/down hysteresis.
steps
¶
Stream raw cf32 samples through the receiver. While searching, samples feed the embedded Acquisition and nothing is emitted. On a hit, the refine stage (a frozen-carrier Dll collection feeding CarrierAcquisition) is built and seeded from it, and the unconsumed tail of this call is handed straight to it -- no samples dropped. Once CarrierAcquisition reports ready (or its own give-up cap is reached), the live tracking chain (Dll + per-partial Costas + RateConverter + MpskReceiver) is built fresh, seeded from the ORIGINAL handoff chip phase and the refined-or-unrefined Doppler estimate, and demodulated symbols are returned from then on. Accepts any block size; state carries across calls.
Drives the search -> refine -> track state machine. While searching or
refining, nothing is emitted (an empty return is normal, not an error):
a hit seeds the frozen-carrier refine chain, CarrierAcquisition
sharpens the coarse Doppler estimate, and only once it is ready (or
gives up) is the live tracking chain built and demodulation begins.
Accepts any block size; state carries across calls, so a capture can be
fed in frames of any length with no seam. Idle (hand-off mode, before a
seed) and lost (after the release rule fires) consume the samples and
emit nothing, so the feeding loop is the same in every state; while
tracking, the release clock runs on the two lock flags after every call
(see lost_confirm_s). Under SPEC's coupled offset + 500 Hz/s Doppler
ramp the pre-despread Costas removes the full carrier dynamics before
the code loop, so the recovered constellation lands cleanly on the BPSK
real axis.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x
|
NDArray[complex64]
|
Input cf32 samples. |
required |
out
|
NDArray[complex64] | None
|
Output symbols; caller provides max_out capacity. |
None
|
Returns:
| Type | Description |
|---|---|
NDArray[complex64]
|
Number of symbols written (0 while searching/refining, or while tracking with not yet a full symbol's worth of input). |
Examples:
>>> import numpy as np
>>> from doppler.dsss import AsyncDsssReceiver
>>> from doppler.wfm import Gold
>>> sf, chip, sym, spc = 1023, 3.069e6, 2700.0, 2
>>> fs, te, tsym = chip * spc, sf * spc, chip * spc / sym
>>> code = np.asarray(Gold().generate(sf)).astype(np.uint8)
>>> csign = np.where(code & 1, -1.0, 1.0)
>>> rng = np.random.default_rng(21)
>>> n = int(600 * tsym) + 4 * te # 600 async BPSK symbols
>>> idx = np.arange(n)
>>> data = (rng.integers(0, 2, 604) * 2 - 1).astype(float)
>>> si = np.clip((idx / tsym).astype(int), 0, 603)
>>> t = idx / fs
DSSS chips on a carrier sweeping at 500 Hz/s — the ramp the async receiver has to track:
>>> sig = (data[si] * csign[(idx // spc) % sf]
... * np.exp(1j * 2 * np.pi * 0.5 * 500.0 * t * t))
>>> cn0 = 20.0 + 10 * np.log10(sym) # Es/N0 = 20 dB
>>> sigma = np.sqrt(fs / 10 ** (cn0 / 10))
>>> pre = 5 * te # noise-only lead-in
>>> noise = (sigma / np.sqrt(2)) * (rng.standard_normal(pre + n)
... + 1j * rng.standard_normal(pre + n))
>>> x = (np.concatenate([np.zeros(pre), sig]).astype(np.complex64)
... + noise.astype(np.complex64))
>>> rx = AsyncDsssReceiver(
... code, chip_rate=chip, symbol_rate=sym, spc=spc,
... cn0_dbhz=cn0, doppler_uncertainty=500.0)
>>> syms = [rx.steps(x[p:p + te]) for p in range(0, len(x) - te, te)]
>>> syms = np.concatenate([s for s in syms if len(s)])
>>> rx.tracking # searched, refined, now tracking
1
>>> len(syms) > 300 # symbols recovered under the ramp
True
Nearly all the energy lands on I, so the BPSK phase is resolved:
steps_max_out
¶
Largest number of samples steps() can return in the current state.
Size an out= buffer with this before calling steps(), or use it to
allocate one up front. The bound is this object's own: what it depends
on is a property of the algorithm, so a header block on steps_max_out()
replaces this text.
Returns:
| Type | Description |
|---|---|
int
|
Upper bound on the output length; the actual call may return fewer. |
seed
¶
Take a detection from outside and start refining from it: the hit's chip phase (Dll's instantaneous convention, at the next sample fed), coarse Doppler estimate and C/N0 estimate -- exactly what the searching flavor's own hit produces. Accepted while idle (hand-off flavor) or searching; refused on a receiver that already holds a seed (refining, tracking or lost -- reset() releases it) and for a chip_phase outside [0, code_len).
The hand-off of docs/design/async-dsss-receiver.md section 11.1: the
three numbers a searcher's hit carries that this receiver uses --
acq_handoff_t's chip_phase, doppler_hz_est and cn0_dbhz_est --
exactly as its own hit would have produced them (the searching flavor's
steps() calls this on its own hit). chip_phase is the code's
instantaneous phase in chips, Dll's convention, at the FIRST sample of
the next steps() call; the Python-side conversion from a lag is
doppler.dsss.handoff. The refine chain is rebuilt from the seed and
the state becomes refining; the unconsumed tail is the caller's to
feed.
Refused (DP_ERR_INVALID, nothing changes) on a receiver that is not
waiting for one -- refining, tracking or lost -- because "assigned
once" is a property of the object, not of the caller's bookkeeping;
reset() releases it. Accepted while idle (hand-off mode) or searching
(the searching flavor: an outside hit simply beats its own). Also
refused for a chip_phase outside [0, code_len) or a non-finite
value.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
chip_phase
|
float
|
Code phase at the next sample, chips, in |
required |
doppler_hz_est
|
float
|
Coarse Doppler estimate, Hz (the refine stage sharpens it). |
required |
cn0_dbhz_est
|
float
|
The hit's C/N0 estimate, dB-Hz; reported back by get_cn0_dbhz_est() until tracking refreshes it. |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the C call returns a non-zero status. The exception message is
|
Examples:
>>> import numpy as np
>>> from doppler.dsss import HandoffAsyncDsssReceiver
>>> from doppler.wfm import Gold
>>> code = np.asarray(Gold().generate(1023)).astype(np.uint8)
>>> rx = HandoffAsyncDsssReceiver(code, chip_rate=3.069e6,
... symbol_rate=2700.0, spc=2)
>>> rx.seed(chip_phase=512.25, doppler_hz_est=-1500.0,
... cn0_dbhz_est=48.0)
>>> (rx.idle, rx.refining, rx.doppler_hz, rx.cn0_dbhz_est)
(0, 1, -1500.0, 48.0)
Already assigned -- refused until reset():
>>> rx.seed(0.0, 0.0, 48.0)
Traceback (most recent call last):
...
ValueError: seed refused: ...
>>> rx.reset()
A chip phase must be inside the code, [0, code_len):
status
¶
One consistent picture of the receiver, by value (design section
11.3): state, where the emitter is now (the whole carrier estimate --
loop 1's plus what loop 2 took up beyond it -- only as good as
locked; chip phase, code rate, C/N0), both lock flags with the
symbol-lock metric and threshold, both residual carrier errors, and the
two clocks in input samples (since the state was entered; both flags
down without a break). Read on demand by the holder of a pool -- the
one-at-a-time properties are the same fields' other face. No timestamp:
the holder owns the sample clock and stamps it.
Cheap and allocation-free: every field is a read of live state. The one-at-a-time getters below report the same fields; this is the face a pool holder uses.
Returns:
| Type | Description |
|---|---|
ReceiverStatus
|
The record, by value. |
Examples:
>>> import numpy as np
>>> from doppler.dsss import HandoffAsyncDsssReceiver
>>> from doppler.wfm import Gold
>>> code = np.asarray(Gold().generate(1023)).astype(np.uint8)
>>> rx = HandoffAsyncDsssReceiver(code, chip_rate=3.069e6,
... symbol_rate=2700.0, spc=2)
>>> st = rx.status()
>>> (st.state, st.doppler_hz, st.code_locked, st.locked) # idle
(3, 0.0, 0, 0)
>>> rx.seed(chip_phase=100.0, doppler_hz_est=-250.0, cn0_dbhz_est=50.0)
>>> st = rx.status()
>>> (st.state, round(st.doppler_hz, 6), st.cn0_dbhz_est) # refining
(1, -250.0, 50.0)
>>> _ = rx.steps(np.zeros(2046, np.complex64))
>>> rx.status().state_samples # since seed
2046
configure_search_raw
¶
Pin the embedded Acquisition's search grid directly, bypassing the symbol_rate-driven auto-sizing. Only meaningful while searching.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
doppler_bins
|
int
|
Number of Doppler window tiles to search (>= 1); capped by the
create-time |
required |
n_noncoh
|
int
|
Non-coherent looks accumulated per grid cell (1..256); more looks buys sensitivity at the cost of dwell, replacing the auto-sized count. |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the C call returns a non-zero status. The exception message is
|
Examples:
>>> import numpy as np
>>> from doppler.dsss import AsyncDsssReceiver
>>> from doppler.wfm import Gold
>>> code = np.asarray(Gold().generate(1023)).astype(np.uint8)
>>> rx = AsyncDsssReceiver(code, chip_rate=3.069e6, symbol_rate=2700.0,
... spc=2, doppler_uncertainty=500.0)
>>> rx.configure_search_raw(doppler_bins=1, n_noncoh=16) # pin it
>>> rx.refining # still searching, on the pinned grid
0
set_refine_min_blocks
¶
Floor the refine's dwell at n_blocks whatever the detection sizing asks (design section 12.16, #1265): CarrierAcquisition's dwell is sized for detection at the derated C/N0 and shortens as the C/N0 rises -- two blocks at 45 dB-Hz with the shipped margin -- while the noise of the estimate it hands the tracking chain does not shorten with it (210 Hz at two blocks against a chain that pulls in from a few hundred). The default of 7 blocks (42 ms) holds it to 77 Hz. Applied to the next refine chain built; 0 removes the floor; clamped by refine_max_n_blocks. Config, not running state.
CarrierAcquisition's dwell is sized for DETECTION at the derated C/N0
(cn0_dbhz - refine_design_margin_db), so it shortens as the C/N0
rises -- two blocks at 45 dB-Hz with the shipped margin -- while the
noise of the estimate it hands the tracking chain does not shorten with
it: 210 Hz at two blocks against a chain that pulls in from a few
hundred, so one hand-over in sixty landed outside and tracked the code
with the carrier never locked. Seven blocks (42 ms, the default and
section 12.10's floor dwell) hold the estimate to 77 Hz. Applied to the
next refine chain built -- a receiver already refining keeps its dwell.
Config, not running state: not in the blob. n_blocks of 0 removes the
floor; the value is clamped by refine_max_n_blocks where that cap is
lower.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
n_blocks
|
int
|
The floor, blocks. |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the C call returns a non-zero status. The exception message is
|
Examples:
configure_lock_raw
¶
configure_lock_raw(
up_thresh: float,
down_thresh: float,
n_looks: int,
alpha: float,
n_up: int,
n_down: int,
) -> None
Re-tune the live-tracking Dll's code-lock detector directly. Only meaningful once tracking has begun; a no-op while searching or refining.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
up_thresh
|
float
|
CFAR-statistic level to declare code lock (hit when the statistic exceeds it). |
required |
down_thresh
|
float
|
Level below which a look is a miss; choose <= up_thresh for level hysteresis. |
required |
n_looks
|
int
|
Looks per decision — the DLL's non-coherent integration depth feeding one statistic. |
required |
alpha
|
float
|
EMA smoothing coefficient on the lock statistic (0..1); smaller is smoother/slower. |
required |
n_up
|
int
|
Consecutive hits required to declare lock. |
required |
n_down
|
int
|
Consecutive misses required to drop lock. |
required |
Examples:
>>> import numpy as np
>>> from doppler.dsss import AsyncDsssReceiver
>>> from doppler.wfm import Gold
>>> code = np.asarray(Gold().generate(1023)).astype(np.uint8)
>>> rx = AsyncDsssReceiver(code, chip_rate=3.069e6, symbol_rate=2700.0,
... spc=2, doppler_uncertainty=500.0)
>>> rx.configure_lock_raw(up_thresh=0.4, down_thresh=0.2, n_looks=20,
... alpha=0.1, n_up=5, n_down=3)
>>> rx.tracking # a no-op until tracking begins
0
configure_chain_raw
¶
Pin the live-tracking despread/resample/demod grid directly, bypassing the create-time segments/sps defaults. Only meaningful once tracking; rebuilds the chain with every replacement allocated first, so a failed pin leaves the receiver on its prior grid.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
segments
|
int
|
Live-tracking Dll segments per code period. |
required |
sps
|
int
|
MpskReceiver samples per symbol (the resample target). |
required |
n
|
int
|
MpskReceiver's carrier-arm count; must divide sps. |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the C call returns a non-zero status. The exception message is
|
Examples:
>>> import numpy as np
>>> from doppler.dsss import AsyncDsssReceiver
>>> from doppler.wfm import Gold
>>> code = np.asarray(Gold().generate(1023)).astype(np.uint8)
>>> rx = AsyncDsssReceiver(code, chip_rate=3.069e6, symbol_rate=2700.0,
... spc=2, doppler_uncertainty=500.0)
>>> rx.configure_chain_raw(segments=6, sps=8, n=8) # re-pin the chain
>>> rx.segments # tracking grid updated in place
6
reset
¶
Return to the searching state: resets the embedded Acquisition and frees every refine-stage/track-stage child (rebuilt from scratch on the next hit).
Examples:
>>> import numpy as np
>>> from doppler.dsss import AsyncDsssReceiver
>>> from doppler.wfm import Gold
>>> code = np.asarray(Gold().generate(1023)).astype(np.uint8)
>>> rx = AsyncDsssReceiver(code, chip_rate=3.069e6, symbol_rate=2700.0,
... spc=2, doppler_uncertainty=500.0)
>>> rx.reset() # abort any lock, hunt from scratch
>>> (rx.tracking, rx.refining, rx.chip_phase) # all cleared
(0, 0, 0.0)
state_bytes
¶
Size in bytes of this object's serialized state.
The exact length get_state returns and set_state requires. It
depends on how the object was constructed (state arrays are sized at
construction), so read it from the instance rather than assuming a
constant.
Raises RuntimeError if the AsyncDsssReceiver has already been
destroyed.
Returns:
| Type | Description |
|---|---|
int
|
Byte length of one serialized state blob. |
get_state
¶
Serialize this object's mutable state to bytes.
Captures exactly the state that evolves as the object runs, so a blob taken now and restored later resumes from this point. Construction parameters are not included: restore into an object built the same way.
The blob is opaque and always state_bytes() long. Its layout is an
implementation detail of the C core and is not a stable format across
builds.
Raises RuntimeError if the AsyncDsssReceiver has already been
destroyed.
Returns:
| Type | Description |
|---|---|
bytes
|
Opaque snapshot, |
set_state
¶
Restore mutable state from a get_state() blob.
Overwrites the live state in place; the object keeps the parameters it
was constructed with. Length is validated against state_bytes()
before the blob is handed to the C core, and the core may reject it as
well.
Raises TypeError if blob is not bytes, ValueError if its
length differs from state_bytes() or the core rejects it, and
RuntimeError if the AsyncDsssReceiver has already been destroyed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
blob
|
bytes
|
A |
required |
destroy
¶
Release the underlying C resources immediately.
Ordinarily unnecessary: the resources are freed when the object is garbage-collected. Call this to release them at a definite point instead, or use the object as a context manager, which calls it on exit.
Idempotent: calling it again on an already-released object does
nothing. Every other method raises RuntimeError once it has run.
__enter__
¶
Enter a context manager, returning this object.
Lets a AsyncDsssReceiver be used in a with statement so its C
resources are released deterministically on exit rather than at
collection time.
Returns:
| Type | Description |
|---|---|
AsyncDsssReceiver
|
This same object, not a copy. |
__exit__
¶
__exit__(
exc_type: object | None = ...,
exc: object | None = ...,
tb: object | None = ...,
) -> None
Exit a context manager, releasing the AsyncDsssReceiver.
Equivalent to calling destroy(). Returns None, so an exception
raised inside the with body propagates normally; this never
suppresses one.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
exc_type
|
object | None
|
Exception class, or None. Ignored. |
...
|
exc
|
object | None
|
Exception instance, or None. Ignored. |
...
|
tb
|
object | None
|
Traceback object, or None. Ignored. |
...
|
HandoffAsyncDsssReceiver — the same receiver, seeded from outside¶
The pool shape of the
continuous multi-emitter design (§11):
one searcher finds every emitter on a channel, and one of these per emitter
tracks it. It is AsyncDsssReceiver with no search of its own — a view over
the same core — so it starts idle, takes the searcher's detection through
seed(chip_phase, doppler_hz_est, cn0_dbhz_est) (assigned once: a second
seed is refused until reset(), which returns to idle), and runs the same
refine → track chain. Its release rule is the design's §11.2: both lock flags
down, continuously, for longer than lost_confirm_s sets lost, stops the
loops, and leaves the holder to reset() it for its next seed. seed(),
idle and lost exist on AsyncDsssReceiver too (an outside hit beats its
own search); only the constructor differs.
HandoffAsyncDsssReceiver
¶
Create a receiver in hand-off mode: idle, with no search of its own.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
code
|
NDArray[uint8]
|
Spreading code, 0/1 chips (see async_dsss_receiver_create()). |
required |
chip_rate
|
float
|
Chip rate, Hz. Required. |
1000000.0
|
symbol_rate
|
float
|
Data-symbol rate, Hz. Required. |
1000.0
|
spc
|
int
|
Samples/chip; default 2. |
2
|
m
|
int
|
PSK order, 2/4/8; default 2. |
2
|
cn0_dbhz
|
float
|
Design C/N0, dB-Hz; default 55.0 (derated by |
55.0
|
pfa
|
float
|
CarrierAcquisition's false-alarm target; default 1e-3. |
1e-3
|
pd
|
float
|
CarrierAcquisition's detection target; default 0.9. |
0.9
|
segments
|
int
|
Live-tracking Dll's segments; default 4. |
4
|
sps
|
int
|
MpskReceiver's samples/symbol; default 8. |
8
|
differential
|
int
|
MpskReceiver's differential demap; default 0. |
0
|
refine_max_error_db
|
float
|
As async_dsss_receiver_create(). |
0.5
|
refine_samples_per_symbol
|
int
|
As async_dsss_receiver_create(). |
4
|
refine_design_margin_db
|
float
|
As async_dsss_receiver_create(). |
14.0
|
refine_n_fft
|
int
|
As async_dsss_receiver_create(). |
64
|
refine_zero_pad
|
int
|
As async_dsss_receiver_create(). |
8
|
refine_sequential
|
bool
|
As async_dsss_receiver_create(). |
False
|
refine_max_n_blocks
|
int
|
As async_dsss_receiver_create(). |
100000
|
carrier_freq_hz
|
float
|
Nominal RF carrier for carrier->code aiding; 0.0 (default) = off. |
0.0
|
lost_confirm_s
|
float
|
Release rule, seconds of both flags down; default 2.0. 0 = never lost. |
2.0
|
Examples:
>>> import numpy as np
>>> from doppler.dsss import Acquisition, HandoffAsyncDsssReceiver
>>> from doppler.dsss import bin_to_signed
>>> from doppler.dsss.handoff import dll_init_chip_from_acq
>>> from doppler.wfm import Gold
>>> sf, chip, sym, spc = 1023, 3.069e6, 2700.0, 2
>>> fs, te, tsym = chip * spc, sf * spc, chip * spc / sym
>>> code = np.asarray(Gold().generate(sf)).astype(np.uint8)
>>> csign = np.where(code & 1, -1.0, 1.0)
>>> rng = np.random.default_rng(21)
>>> n = int(600 * tsym) + 4 * te # 600 async BPSK symbols
>>> idx = np.arange(n)
>>> data = (rng.integers(0, 2, 604) * 2 - 1).astype(float)
>>> si = np.clip((idx / tsym).astype(int), 0, 603)
>>> t = idx / fs
>>> sig = (data[si] * csign[(idx // spc) % sf]
... * np.exp(1j * 2 * np.pi * 0.5 * 500.0 * t * t))
>>> cn0 = 20.0 + 10 * np.log10(sym) # Es/N0 = 20 dB
>>> sigma = np.sqrt(fs / 10 ** (cn0 / 10))
>>> pre = 5 * te # noise-only lead-in
>>> noise = (sigma / np.sqrt(2)) * (rng.standard_normal(pre + n)
... + 1j * rng.standard_normal(pre + n))
>>> x = (np.concatenate([np.zeros(pre), sig]).astype(np.complex64)
... + noise.astype(np.complex64))
The search is a separate object -- in a pool, one searcher per channel serves every receiver on it. Its hit is a correlation lag and a Doppler bin; the two documented helpers turn those into the seed:
>>> acq = Acquisition(code, spc=spc, chip_rate=chip, symbol_rate=sym,
... cn0_dbhz=cn0, doppler_uncertainty=500.0)
>>> for p in range(0, len(x) - te, te):
... hits = acq.push(x[p:p + te])
... if hits:
... break
>>> d_bin, lag, _, _, _, cn0_est, consumed = hits[0]
>>> chip_phase = dll_init_chip_from_acq(lag, spc, sf)
>>> res_hz = acq.doppler_res_hz
>>> doppler_hz = bin_to_signed(d_bin, acq.doppler_bins) * res_hz
The receiver never searched: it waits idle, takes the seed, and the samples from the hit onwards go to it.
>>> rx = HandoffAsyncDsssReceiver(
... code, chip_rate=chip, symbol_rate=sym, spc=spc, cn0_dbhz=cn0)
>>> rx.idle
1
>>> rx.seed(chip_phase, doppler_hz, cn0_est)
>>> (rx.idle, rx.refining)
(0, 1)
>>> syms = [rx.steps(x[p:p + te])
... for p in range(int(consumed), len(x) - te, te)]
>>> syms = np.concatenate([s for s in syms if len(s)])
>>> rx.tracking # refined and tracking, no search
1
>>> len(syms) > 300
True
>>> bool(np.mean(syms.real**2) > 10 * np.mean(syms.imag**2))
True
Assigned once: a second seed is refused until reset(), which in this mode returns to idle, not to searching.
>>> rx.seed(0.0, 0.0, cn0)
Traceback (most recent call last):
...
ValueError: seed refused: ...
>>> rx.reset()
>>> rx.idle
1
refine_min_blocks
property
¶
Floor on the refine's dwell, blocks (default 7); set with set_refine_min_blocks().
tracking
property
¶
1 once the live tracking chain is built and demodulating; 0 while searching or refining.
refining
property
¶
1 while the refine stage (CarrierAcquisition collection) is active; 0 while searching or tracking.
idle
property
¶
1 while waiting for a seed (the hand-off flavor before seed() or after reset()); 0 in every other state.
lost
property
¶
1 once the release rule has fired: both lock flags were down, continuously, for longer than lost_confirm_s while tracking. The loops have stopped and samples are discarded; the holder releases the assignment and calls reset(). Always 0 with lost_confirm_s = 0.
doppler_hz
property
¶
The current best Doppler estimate: the coarse handoff value while refining, the CarrierAcquisition-refined value once tracking.
segments
property
¶
Live-tracking Dll's own segments -- distinct from refine_segments above (see the module docstring / dll_lookback_segments()'s own doc on the WINDOWS vs TRACK_WINDOWS split).
chip_phase
property
¶
Live Dll code phase in chips, Dll's own instantaneous-phase convention (the mirror image of acq_result_t::code_phase's correlation-lag convention -- see acq_build_handoff()'s doc comment).
lock
property
¶
decision rule on lock_metric: thresholds + verify counters, stepped per symbol.
norm_freq
property
¶
Smoothed carrier estimate (integrator only, cycles/sample of the MpskReceiver output rate); lags a Doppler ramp by the constant Type-II ramp error.
nco_freq
property
¶
Live carrier loop-filter output = NCO frequency command (cycles/sample of the MpskReceiver output rate): its mean tracks a Doppler ramp with no lag, its variance is the carrier loop stress.
locked
property
¶
Binary receiver lock: the hysteretic (up/down verify-counted) lock detector on the emitted symbols -- declared when lock_metric stays >= lock_threshold for the up-count and dropped below it for the down-count.
lock_metric
property
¶
Symbol-lock metric: SNR-weighted running mean of the BPSK lock
signal (I^2-Q^2)/(I^2+Q^2) = cos(2*phi) over the emitted symbols
(locked -> ~+1). Drives locked; exposed for engineering debug.
lock_threshold
property
¶
The lock_metric declare threshold locked latches above (the
lockdet up_thresh); exposed alongside lock_metric for engineering
debug.
car_last_error
property
¶
Pre-despread Costas phase discriminator (rad): the residual carrier phase loop 1 (de-rotates before the Dll) is not nulling. Engineering debug.
car_nco_freq
property
¶
Loop 1 (pre-despread Costas) loop-filter output = NCO frequency command, cycles/sample of the front-end (chip_rate*spc) rate. Engineering debug.
mpsk_last_error
property
¶
MpskReceiver carrier phase discriminator (rad): the residual carrier phase loop 2 (post-despread) is not nulling. Engineering debug.
code_locked
property
¶
Binary code-lock flag from the live tracking Dll's own verify-counted (pfa-tuned) lock detector -- the fundamental DSSS "am I despreading" lock, de-chattered by up/down hysteresis.
steps
¶
Stream raw cf32 samples through the receiver. While searching, samples feed the embedded Acquisition and nothing is emitted. On a hit, the refine stage (a frozen-carrier Dll collection feeding CarrierAcquisition) is built and seeded from it, and the unconsumed tail of this call is handed straight to it -- no samples dropped. Once CarrierAcquisition reports ready (or its own give-up cap is reached), the live tracking chain (Dll + per-partial Costas + RateConverter + MpskReceiver) is built fresh, seeded from the ORIGINAL handoff chip phase and the refined-or-unrefined Doppler estimate, and demodulated symbols are returned from then on. Accepts any block size; state carries across calls.
Drives the search -> refine -> track state machine. While searching or
refining, nothing is emitted (an empty return is normal, not an error):
a hit seeds the frozen-carrier refine chain, CarrierAcquisition
sharpens the coarse Doppler estimate, and only once it is ready (or
gives up) is the live tracking chain built and demodulation begins.
Accepts any block size; state carries across calls, so a capture can be
fed in frames of any length with no seam. Idle (hand-off mode, before a
seed) and lost (after the release rule fires) consume the samples and
emit nothing, so the feeding loop is the same in every state; while
tracking, the release clock runs on the two lock flags after every call
(see lost_confirm_s). Under SPEC's coupled offset + 500 Hz/s Doppler
ramp the pre-despread Costas removes the full carrier dynamics before
the code loop, so the recovered constellation lands cleanly on the BPSK
real axis.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x
|
NDArray[complex64]
|
Input cf32 samples. |
required |
out
|
NDArray[complex64] | None
|
Output symbols; caller provides max_out capacity. |
None
|
Returns:
| Type | Description |
|---|---|
NDArray[complex64]
|
Number of symbols written (0 while searching/refining, or while tracking with not yet a full symbol's worth of input). |
Examples:
>>> import numpy as np
>>> from doppler.dsss import AsyncDsssReceiver
>>> from doppler.wfm import Gold
>>> sf, chip, sym, spc = 1023, 3.069e6, 2700.0, 2
>>> fs, te, tsym = chip * spc, sf * spc, chip * spc / sym
>>> code = np.asarray(Gold().generate(sf)).astype(np.uint8)
>>> csign = np.where(code & 1, -1.0, 1.0)
>>> rng = np.random.default_rng(21)
>>> n = int(600 * tsym) + 4 * te # 600 async BPSK symbols
>>> idx = np.arange(n)
>>> data = (rng.integers(0, 2, 604) * 2 - 1).astype(float)
>>> si = np.clip((idx / tsym).astype(int), 0, 603)
>>> t = idx / fs
DSSS chips on a carrier sweeping at 500 Hz/s — the ramp the async receiver has to track:
>>> sig = (data[si] * csign[(idx // spc) % sf]
... * np.exp(1j * 2 * np.pi * 0.5 * 500.0 * t * t))
>>> cn0 = 20.0 + 10 * np.log10(sym) # Es/N0 = 20 dB
>>> sigma = np.sqrt(fs / 10 ** (cn0 / 10))
>>> pre = 5 * te # noise-only lead-in
>>> noise = (sigma / np.sqrt(2)) * (rng.standard_normal(pre + n)
... + 1j * rng.standard_normal(pre + n))
>>> x = (np.concatenate([np.zeros(pre), sig]).astype(np.complex64)
... + noise.astype(np.complex64))
>>> rx = AsyncDsssReceiver(
... code, chip_rate=chip, symbol_rate=sym, spc=spc,
... cn0_dbhz=cn0, doppler_uncertainty=500.0)
>>> syms = [rx.steps(x[p:p + te]) for p in range(0, len(x) - te, te)]
>>> syms = np.concatenate([s for s in syms if len(s)])
>>> rx.tracking # searched, refined, now tracking
1
>>> len(syms) > 300 # symbols recovered under the ramp
True
Nearly all the energy lands on I, so the BPSK phase is resolved:
steps_max_out
¶
Largest number of samples steps() can return in the current state.
Size an out= buffer with this before calling steps(), or use it to
allocate one up front. The bound is this object's own: what it depends
on is a property of the algorithm, so a header block on steps_max_out()
replaces this text.
Returns:
| Type | Description |
|---|---|
int
|
Upper bound on the output length; the actual call may return fewer. |
seed
¶
Take a detection from outside and start refining from it: the hit's chip phase (Dll's instantaneous convention, at the next sample fed), coarse Doppler estimate and C/N0 estimate -- exactly what the searching flavor's own hit produces. Accepted while idle (hand-off flavor) or searching; refused on a receiver that already holds a seed (refining, tracking or lost -- reset() releases it) and for a chip_phase outside [0, code_len).
The hand-off of docs/design/async-dsss-receiver.md section 11.1: the
three numbers a searcher's hit carries that this receiver uses --
acq_handoff_t's chip_phase, doppler_hz_est and cn0_dbhz_est --
exactly as its own hit would have produced them (the searching flavor's
steps() calls this on its own hit). chip_phase is the code's
instantaneous phase in chips, Dll's convention, at the FIRST sample of
the next steps() call; the Python-side conversion from a lag is
doppler.dsss.handoff. The refine chain is rebuilt from the seed and
the state becomes refining; the unconsumed tail is the caller's to
feed.
Refused (DP_ERR_INVALID, nothing changes) on a receiver that is not
waiting for one -- refining, tracking or lost -- because "assigned
once" is a property of the object, not of the caller's bookkeeping;
reset() releases it. Accepted while idle (hand-off mode) or searching
(the searching flavor: an outside hit simply beats its own). Also
refused for a chip_phase outside [0, code_len) or a non-finite
value.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
chip_phase
|
float
|
Code phase at the next sample, chips, in |
required |
doppler_hz_est
|
float
|
Coarse Doppler estimate, Hz (the refine stage sharpens it). |
required |
cn0_dbhz_est
|
float
|
The hit's C/N0 estimate, dB-Hz; reported back by get_cn0_dbhz_est() until tracking refreshes it. |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the C call returns a non-zero status. The exception message is
|
Examples:
>>> import numpy as np
>>> from doppler.dsss import HandoffAsyncDsssReceiver
>>> from doppler.wfm import Gold
>>> code = np.asarray(Gold().generate(1023)).astype(np.uint8)
>>> rx = HandoffAsyncDsssReceiver(code, chip_rate=3.069e6,
... symbol_rate=2700.0, spc=2)
>>> rx.seed(chip_phase=512.25, doppler_hz_est=-1500.0,
... cn0_dbhz_est=48.0)
>>> (rx.idle, rx.refining, rx.doppler_hz, rx.cn0_dbhz_est)
(0, 1, -1500.0, 48.0)
Already assigned -- refused until reset():
>>> rx.seed(0.0, 0.0, 48.0)
Traceback (most recent call last):
...
ValueError: seed refused: ...
>>> rx.reset()
A chip phase must be inside the code, [0, code_len):
status
¶
One consistent picture of the receiver, by value (design section
11.3): state, where the emitter is now (the whole carrier estimate --
loop 1's plus what loop 2 took up beyond it -- only as good as
locked; chip phase, code rate, C/N0), both lock flags with the
symbol-lock metric and threshold, both residual carrier errors, and the
two clocks in input samples (since the state was entered; both flags
down without a break). Read on demand by the holder of a pool -- the
one-at-a-time properties are the same fields' other face. No timestamp:
the holder owns the sample clock and stamps it.
Cheap and allocation-free: every field is a read of live state. The one-at-a-time getters below report the same fields; this is the face a pool holder uses.
Returns:
| Type | Description |
|---|---|
ReceiverStatus
|
The record, by value. |
Examples:
>>> import numpy as np
>>> from doppler.dsss import HandoffAsyncDsssReceiver
>>> from doppler.wfm import Gold
>>> code = np.asarray(Gold().generate(1023)).astype(np.uint8)
>>> rx = HandoffAsyncDsssReceiver(code, chip_rate=3.069e6,
... symbol_rate=2700.0, spc=2)
>>> st = rx.status()
>>> (st.state, st.doppler_hz, st.code_locked, st.locked) # idle
(3, 0.0, 0, 0)
>>> rx.seed(chip_phase=100.0, doppler_hz_est=-250.0, cn0_dbhz_est=50.0)
>>> st = rx.status()
>>> (st.state, round(st.doppler_hz, 6), st.cn0_dbhz_est) # refining
(1, -250.0, 50.0)
>>> _ = rx.steps(np.zeros(2046, np.complex64))
>>> rx.status().state_samples # since seed
2046
set_refine_min_blocks
¶
Floor the refine's dwell at n_blocks whatever the detection sizing asks (design section 12.16, #1265): CarrierAcquisition's dwell is sized for detection at the derated C/N0 and shortens as the C/N0 rises -- two blocks at 45 dB-Hz with the shipped margin -- while the noise of the estimate it hands the tracking chain does not shorten with it (210 Hz at two blocks against a chain that pulls in from a few hundred). The default of 7 blocks (42 ms) holds it to 77 Hz. Applied to the next refine chain built; 0 removes the floor; clamped by refine_max_n_blocks. Config, not running state.
CarrierAcquisition's dwell is sized for DETECTION at the derated C/N0
(cn0_dbhz - refine_design_margin_db), so it shortens as the C/N0
rises -- two blocks at 45 dB-Hz with the shipped margin -- while the
noise of the estimate it hands the tracking chain does not shorten with
it: 210 Hz at two blocks against a chain that pulls in from a few
hundred, so one hand-over in sixty landed outside and tracked the code
with the carrier never locked. Seven blocks (42 ms, the default and
section 12.10's floor dwell) hold the estimate to 77 Hz. Applied to the
next refine chain built -- a receiver already refining keeps its dwell.
Config, not running state: not in the blob. n_blocks of 0 removes the
floor; the value is clamped by refine_max_n_blocks where that cap is
lower.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
n_blocks
|
int
|
The floor, blocks. |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the C call returns a non-zero status. The exception message is
|
Examples:
configure_lock_raw
¶
configure_lock_raw(
up_thresh: float,
down_thresh: float,
n_looks: int,
alpha: float,
n_up: int,
n_down: int,
) -> None
Re-tune the live-tracking Dll's code-lock detector directly. Only meaningful once tracking has begun; a no-op while searching or refining.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
up_thresh
|
float
|
CFAR-statistic level to declare code lock (hit when the statistic exceeds it). |
required |
down_thresh
|
float
|
Level below which a look is a miss; choose <= up_thresh for level hysteresis. |
required |
n_looks
|
int
|
Looks per decision — the DLL's non-coherent integration depth feeding one statistic. |
required |
alpha
|
float
|
EMA smoothing coefficient on the lock statistic (0..1); smaller is smoother/slower. |
required |
n_up
|
int
|
Consecutive hits required to declare lock. |
required |
n_down
|
int
|
Consecutive misses required to drop lock. |
required |
Examples:
>>> import numpy as np
>>> from doppler.dsss import AsyncDsssReceiver
>>> from doppler.wfm import Gold
>>> code = np.asarray(Gold().generate(1023)).astype(np.uint8)
>>> rx = AsyncDsssReceiver(code, chip_rate=3.069e6, symbol_rate=2700.0,
... spc=2, doppler_uncertainty=500.0)
>>> rx.configure_lock_raw(up_thresh=0.4, down_thresh=0.2, n_looks=20,
... alpha=0.1, n_up=5, n_down=3)
>>> rx.tracking # a no-op until tracking begins
0
configure_chain_raw
¶
Pin the live-tracking despread/resample/demod grid directly, bypassing the create-time segments/sps defaults. Only meaningful once tracking; rebuilds the chain with every replacement allocated first, so a failed pin leaves the receiver on its prior grid.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
segments
|
int
|
Live-tracking Dll segments per code period. |
required |
sps
|
int
|
MpskReceiver samples per symbol (the resample target). |
required |
n
|
int
|
MpskReceiver's carrier-arm count; must divide sps. |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the C call returns a non-zero status. The exception message is
|
Examples:
>>> import numpy as np
>>> from doppler.dsss import AsyncDsssReceiver
>>> from doppler.wfm import Gold
>>> code = np.asarray(Gold().generate(1023)).astype(np.uint8)
>>> rx = AsyncDsssReceiver(code, chip_rate=3.069e6, symbol_rate=2700.0,
... spc=2, doppler_uncertainty=500.0)
>>> rx.configure_chain_raw(segments=6, sps=8, n=8) # re-pin the chain
>>> rx.segments # tracking grid updated in place
6
reset
¶
Return to the searching state: resets the embedded Acquisition and frees every refine-stage/track-stage child (rebuilt from scratch on the next hit).
Examples:
>>> import numpy as np
>>> from doppler.dsss import AsyncDsssReceiver
>>> from doppler.wfm import Gold
>>> code = np.asarray(Gold().generate(1023)).astype(np.uint8)
>>> rx = AsyncDsssReceiver(code, chip_rate=3.069e6, symbol_rate=2700.0,
... spc=2, doppler_uncertainty=500.0)
>>> rx.reset() # abort any lock, hunt from scratch
>>> (rx.tracking, rx.refining, rx.chip_phase) # all cleared
(0, 0, 0.0)
state_bytes
¶
Size in bytes of this object's serialized state.
The exact length get_state returns and set_state requires. It
depends on how the object was constructed (state arrays are sized at
construction), so read it from the instance rather than assuming a
constant.
Raises RuntimeError if the HandoffAsyncDsssReceiver has already
been destroyed.
Returns:
| Type | Description |
|---|---|
int
|
Byte length of one serialized state blob. |
get_state
¶
Serialize this object's mutable state to bytes.
Captures exactly the state that evolves as the object runs, so a blob taken now and restored later resumes from this point. Construction parameters are not included: restore into an object built the same way.
The blob is opaque and always state_bytes() long. Its layout is an
implementation detail of the C core and is not a stable format across
builds.
Raises RuntimeError if the HandoffAsyncDsssReceiver has already
been destroyed.
Returns:
| Type | Description |
|---|---|
bytes
|
Opaque snapshot, |
set_state
¶
Restore mutable state from a get_state() blob.
Overwrites the live state in place; the object keeps the parameters it
was constructed with. Length is validated against state_bytes()
before the blob is handed to the C core, and the core may reject it as
well.
Raises TypeError if blob is not bytes, ValueError if its
length differs from state_bytes() or the core rejects it, and
RuntimeError if the HandoffAsyncDsssReceiver has already been
destroyed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
blob
|
bytes
|
A |
required |
destroy
¶
Release the underlying C resources immediately.
Ordinarily unnecessary: the resources are freed when the object is garbage-collected. Call this to release them at a definite point instead, or use the object as a context manager, which calls it on exit.
Idempotent: calling it again on an already-released object does
nothing. Every other method raises RuntimeError once it has run.
__enter__
¶
Enter a context manager, returning this object.
Lets a HandoffAsyncDsssReceiver be used in a with statement so its C
resources are released deterministically on exit rather than at
collection time.
Returns:
| Type | Description |
|---|---|
HandoffAsyncDsssReceiver
|
This same object, not a copy. |
__exit__
¶
__exit__(
exc_type: object | None = ...,
exc: object | None = ...,
tb: object | None = ...,
) -> None
Exit a context manager, releasing the HandoffAsyncDsssReceiver.
Equivalent to calling destroy(). Returns None, so an exception
raised inside the with body propagates normally; this never
suppresses one.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
exc_type
|
object | None
|
Exception class, or None. Ignored. |
...
|
exc
|
object | None
|
Exception instance, or None. Ignored. |
...
|
tb
|
object | None
|
Traceback object, or None. Ignored. |
...
|
Both flavors report one consistent picture by value through status(). It
carries no timestamp: the holder owns the sample clock and stamps it (design
§8.1).
ReceiverStatus
¶
Bases: tuple[int, float, float, float, float, int, int, float, float, float, float, int, int]
AsyncDsssReceiver's status record: state (0 searching, 1 refining, 2 tracking, 3 idle, 4 lost), the live estimates, both lock flags, and the two clocks in input samples.
Attributes:
| Name | Type | Description |
|---|---|---|
state |
int
|
The receiver's ASYNC_DSSS_RX_* state; -1 for a slot that does not exist. |
doppler_hz |
float
|
Signed coarse Doppler, folded, Hz. |
chip_phase |
float
|
Chips, Dll's own instantaneous-phase convention (the mirror image of acq_result_t::code_phase's correlation-lag convention -- see acq_build_handoff()'s doc comment). |
code_rate |
float
|
chips advanced per nominal chip (~1.0). |
cn0_dbhz_est |
float
|
C/N0 lower bound from the hit, dB-Hz. |
code_locked |
int
|
Presence flag. |
locked |
int
|
Health flag (symbol lock). |
lock_metric |
float
|
mean of |Re P|/|P| over the burst (~1 locked, ~2/pi with no carrier). |
lock_threshold |
float
|
|
car_last_error |
float
|
Pre-despread Costas residual, rad. |
mpsk_last_error |
float
|
Post-despread carrier residual, rad. |
state_samples |
int
|
Samples since the receiver's state was entered. |
both_down_samples |
int
|
The release clock, samples. |
state
property
¶
The receiver's ASYNC_DSSS_RX_* state; -1 for a slot that does not exist.
chip_phase
property
¶
Chips, Dll's own instantaneous-phase convention (the mirror image of acq_result_t::code_phase's correlation-lag convention -- see acq_build_handoff()'s doc comment).
lock_metric
property
¶
mean of |Re P|/|P| over the burst (~1 locked, ~2/pi with no carrier).
AsyncDsssPool — one object holds the population¶
The holder of the
continuous multi-emitter design (§8.2):
one searcher (Acquisition in continuous mode with the block coherence of
§2.3 and a peak list), n_slots HandoffAsyncDsssReceivers created idle,
the assigned table, and the run's EventLog attached through
set_event_log(). One push() per block feeds the searcher, drops every
peak inside one exclusion zone of a live row as that emitter's own, seeds
each survivor into a free slot or counts it dropped, feeds every receiver
across the threads the pool is given, and releases every receiver that
reports lost or has held its slot past max_emitter_on_time_secs. Every
transition — seeded, tracking, degrade, lost, released, dropped — is an
event at the sample it happened. Per slot and by index: status(slot)
by value and symbols(slot), the last push's symbols. Nothing about the
waveform or the population is baked in; every number is a constructor
parameter whose default is the operating point of §6.1.
AsyncDsssPool
¶
Create a async_dsss_pool instance.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
code
|
NDArray[uint8]
|
Spreading code, one 0/1 chip per element. |
required |
chip_rate
|
float
|
Chip rate, Hz (default: 1000000.0). |
1000000.0
|
symbol_rate
|
float
|
Data-symbol rate, Hz (default: 1000.0). |
1000.0
|
spc
|
int
|
Samples per chip (default: 2). |
2
|
m
|
int
|
PSK order of the receivers (default: 2). |
2
|
cn0_dbhz
|
float
|
Design C/N0 for the searcher's sizing and the receivers' (default: 55.0). |
55.0
|
pfa
|
float
|
False-alarm target, the searcher's and the refine's (default: 1e-3). |
1e-3
|
pd
|
float
|
Detection-probability target (default: 0.9). |
0.9
|
doppler_uncertainty
|
float
|
The searcher's one-sided span, Hz (default: 100.0). |
100.0
|
code_only_epochs
|
int
|
Whole code-only epochs the waveform's window holds at any chip phase -- the block depth of section 2.3; 1 = no window (default: 1). |
1
|
doppler_rate
|
float
|
Doppler rate the depth is bounded against, Hz/s; 0 leaves the window as the only bound (default: 0.0). |
0.0
|
max_peaks
|
int
|
The searcher's list capacity per dwell (default: 16). |
16
|
n_slots
|
int
|
Receivers held (default: 12). |
12
|
threads
|
int
|
Threads the receivers and the searcher's fan run across; <= 0 picks the online core count, 1 is serial (default: 1). |
1
|
carrier_freq_hz
|
float
|
RF carrier the Doppler is physically coupled to, Hz, told to the searcher and every receiver; 0.0 = uncoupled (default: 0.0). |
0.0
|
lost_confirm_s
|
float
|
The release rule's interval, seconds (section 10) (default: 2.0). |
2.0
|
max_emitter_on_time_secs
|
float
|
Maximum on-air time of one emitter, seconds: a slot held longer is
released ( |
900.0
|
segments
|
int
|
The receivers' live Dll segments (default: 4). |
4
|
sps
|
int
|
The receivers' samples per symbol (default: 8). |
8
|
differential
|
int
|
The receivers' differential demap (default: 0). |
0
|
refine_max_error_db
|
float
|
As async_dsss_receiver_create() (default: 0.5). |
0.5
|
refine_samples_per_symbol
|
int
|
As async_dsss_receiver_create() (default: 4). |
4
|
refine_design_margin_db
|
float
|
As async_dsss_receiver_create() (default: 14.0). |
14.0
|
refine_n_fft
|
int
|
As async_dsss_receiver_create() (default: 64). |
64
|
refine_zero_pad
|
int
|
As async_dsss_receiver_create() (default: 8). |
8
|
refine_sequential
|
bool
|
As async_dsss_receiver_create() (default: false). |
False
|
refine_max_n_blocks
|
int
|
As async_dsss_receiver_create() (default: 100000). |
100000
|
Examples:
>>> import numpy as np
>>> from doppler.dsss import AsyncDsssPool
>>> from doppler.wfm import Gold
>>> code = np.asarray(Gold().generate(1023)).astype(np.uint8)
>>> pool = AsyncDsssPool(code, chip_rate=5e6, symbol_rate=2700.0,
... spc=2, cn0_dbhz=45.0, doppler_uncertainty=5e3,
... n_slots=4, lost_confirm_s=0.5)
>>> (pool.n_slots, pool.n_assigned, pool.coherent_bins)
(4, 0, 1)
>>> round(pool.doppler_res_hz) # one Doppler row of the searcher
4888
refine_min_blocks
property
¶
The receivers' floor on the refine dwell, blocks (default 7); set with set_refine_min_blocks().
samples_consumed
property
¶
Input samples pushed since create or reset -- the stream position every event is stamped at.
doppler_res_hz
property
¶
The searcher's Doppler row, Hz -- the resolution a seed's Doppler is reported at.
coherent_bins
property
¶
The searcher's block-coherent depth D, from code_only_epochs and doppler_rate (section 2.3).
reset
¶
Release every slot and start over: the searcher reset, every receiver back to idle, the table cleared, the counters zeroed.
The attached log stays attached and nothing is logged -- a reset is the holder's decision, not an emitter's transition.
Examples:
>>> import numpy as np
>>> from doppler.dsss import AsyncDsssPool
>>> from doppler.wfm import Gold
>>> code = np.asarray(Gold().generate(1023)).astype(np.uint8)
>>> pool = AsyncDsssPool(code, chip_rate=5e6, symbol_rate=2700.0,
... spc=2, cn0_dbhz=45.0, n_slots=2)
>>> _ = pool.push(np.zeros(2046, np.complex64))
>>> pool.samples_consumed
2046
>>> pool.reset()
>>> (pool.samples_consumed, pool.n_assigned, pool.events)
(0, 0, 0)
push
¶
One block of raw cf32 samples through the population (design section 8.2), in order: the searcher; the table refreshed from every live receiver's status(); every peak within one chip of a live row's code phase, at any Doppler, dropped as that emitter's own (the zone is the code axis alone: a tracked emitter's data blocks put smeared copies of it at its own phase rows away, section 12.14); each survivor seeded into a free slot, or counted dropped when there is none; every receiver fed (an idle or lost one consumes and discards, so the feed has no per-state branch), across the threads the pool was given; then every receiver that reports lost, or has held its slot past max_emitter_on_time_secs, released -- the row cleared, the receiver reset to idle. Every transition -- seeded, tracking, degrade, lost, released, dropped -- goes to the attached event log at the sample it happened. Accepts any block size; a searcher dwell is decided when its samples arrive. Returns the receivers assigned after this push.
In order: the searcher; the table refreshed; every peak within one chip of a live row's code phase, at any Doppler, dropped as that emitter's own; each survivor seeded into a free slot or counted dropped; every receiver fed, across the pool's threads; every receiver that reports lost, or has held its slot past the maximum on-air time, released. Every transition goes to the attached log at the sample it happened. Accepts any block size: a hit decided inside the block is referred to the block's start before it seeds (the receiver is fed the whole block), on the dilated clock when the carrier is known.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x
|
NDArray[complex64]
|
Input samples. |
required |
Returns:
| Type | Description |
|---|---|
int
|
Receivers assigned after this push. |
Examples:
>>> import numpy as np
>>> from doppler.dsss import AsyncDsssPool
>>> from doppler.wfm import Gold
>>> code = np.asarray(Gold().generate(1023)).astype(np.uint8)
>>> pool = AsyncDsssPool(code, chip_rate=5e6, symbol_rate=2700.0,
... spc=2, cn0_dbhz=45.0, n_slots=2)
>>> int(pool.push(np.zeros(4 * 2046, np.complex64))) # silence: no one
0
>>> pool.samples_consumed # the stream position
8184
status
¶
One slot's picture, by value: whether it is assigned, the seed it was assigned from (sample, chip phase, Doppler, C/N0 -- the searcher's hand-off record, verbatim), the receiver's own status record (state, the live Doppler, chip phase, code rate and C/N0, both lock flags, the symbol-lock metric, the two clocks), and the samples since the assignment. Raises ValueError for a slot outside [0, n_slots).
Allocation-free: the row plus the receiver's own status record. A slot
outside [0, n_slots) returns a zero record with state -1.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
slot
|
int
|
The slot. |
required |
Returns:
| Type | Description |
|---|---|
PoolSlot
|
The record. |
Examples:
>>> import numpy as np
>>> from doppler.dsss import AsyncDsssPool
>>> from doppler.wfm import Gold
>>> code = np.asarray(Gold().generate(1023)).astype(np.uint8)
>>> pool = AsyncDsssPool(code, chip_rate=5e6, symbol_rate=2700.0,
... spc=2, cn0_dbhz=45.0, n_slots=2)
>>> r = pool.status(1)
>>> (r.slot, r.assigned, r.state) # idle: 3, nothing assigned
(1, 0, 3)
>>> pool.status(2).state # no such slot
-1
symbols
¶
The symbols slot slot's receiver decided on the last push(),
borrowed from the pool's own buffer (sized once at create by the
receiver's steps_max_out()): empty while the slot is idle, refining or
lost. Valid until the next push(), reset() or set_state(). Raises
ValueError for a slot outside [0, n_slots).
Copied from the pool's own buffer, which the next push() overwrites.
Empty while the slot is idle, refining or lost, and for a slot outside
[0, n_slots).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
slot
|
int
|
The slot. |
required |
out
|
NDArray[complex64] | None
|
Caller buffer. |
None
|
Returns:
| Type | Description |
|---|---|
NDArray[complex64]
|
Symbols written. |
Examples:
>>> import numpy as np
>>> from doppler.dsss import AsyncDsssPool
>>> from doppler.wfm import Gold
>>> code = np.asarray(Gold().generate(1023)).astype(np.uint8)
>>> pool = AsyncDsssPool(code, chip_rate=5e6, symbol_rate=2700.0,
... spc=2, cn0_dbhz=45.0, n_slots=2)
>>> _ = pool.push(np.zeros(2046, np.complex64))
>>> pool.symbols(0).shape # idle: nothing decided
(0,)
symbols_max_out
¶
The per-slot symbol capacity symbols() can return -- grown with
the largest block pushed so far (0 before the first push).
Returns:
| Type | Description |
|---|---|
int
|
Output. |
set_event_log
¶
Attach the run's event log (design section 8.1): from now on every
transition -- seeded, tracking, degrade, lost, released, dropped -- is
appended at the sample it happened, with the slot, the receiver's
state, the Doppler, the chip phase and the C/N0 staged as
doppler:
Borrowed, never owned: the holder opens, finalizes and closes it. From
now on every transition is appended at the sample it happened, with
slot, state, doppler_hz, chip_phase and cn0_dbhz staged as
doppler:<name> fields beside the label (core:label) and, on
released, reason (lost or on_time). A log that has already
failed keeps failing (its error is sticky); the pool counts the
transition either way.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
log
|
object | None
|
The log, or NULL. |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the C call returns a non-zero status. The exception message is
|
Examples:
>>> import os, tempfile
>>> import numpy as np
>>> from doppler.dsss import AsyncDsssPool
>>> from doppler.telemetry import EventLog
>>> from doppler.wfm import Gold
>>> code = np.asarray(Gold().generate(1023)).astype(np.uint8)
>>> pool = AsyncDsssPool(code, chip_rate=5e6, symbol_rate=2700.0,
... spc=2, cn0_dbhz=45.0, n_slots=2)
>>> log = EventLog(os.path.join(tempfile.mkdtemp(), "run.events"))
>>> pool.set_event_log(log) # attached: transitions go here
>>> _ = pool.push(np.zeros(2046, np.complex64))
>>> pool.set_event_log(None) # detached
>>> log.close()
set_refine_min_blocks
¶
Floor every receiver's refine dwell at n_blocks (AsyncDsssReceiver.set_refine_min_blocks(); design section 12.16,
1265): forwarded to all n_slots receivers, each applying it to the¶
next refine chain it builds. The receivers' default is 7 blocks; 0 removes the floor. Config, not running state.
Forwarded to all n_slots receivers; each applies it to the next
refine chain it builds, so a slot already refining keeps its dwell. The
receivers' default is 7 blocks. Config, not running state.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
n_blocks
|
int
|
The floor, blocks; 0 removes it. |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the C call returns a non-zero status. The exception message is
|
Examples:
>>> import numpy as np
>>> from doppler.dsss import AsyncDsssPool
>>> from doppler.wfm import Gold
>>> code = np.asarray(Gold().generate(1023)).astype(np.uint8)
>>> pool = AsyncDsssPool(code, chip_rate=5e6, symbol_rate=2700.0,
... spc=2, cn0_dbhz=45.0, n_slots=2)
>>> pool.refine_min_blocks
7
>>> pool.set_refine_min_blocks(12)
>>> pool.refine_min_blocks
12
state_bytes
¶
Size in bytes of this object's serialized state.
The exact length get_state returns and set_state requires. It
depends on how the object was constructed (state arrays are sized at
construction), so read it from the instance rather than assuming a
constant.
Raises RuntimeError if the AsyncDsssPool has already been
destroyed.
Returns:
| Type | Description |
|---|---|
int
|
Byte length of one serialized state blob. |
get_state
¶
Serialize this object's mutable state to bytes.
Captures exactly the state that evolves as the object runs, so a blob taken now and restored later resumes from this point. Construction parameters are not included: restore into an object built the same way.
The blob is opaque and always state_bytes() long. Its layout is an
implementation detail of the C core and is not a stable format across
builds.
Raises RuntimeError if the AsyncDsssPool has already been
destroyed.
Returns:
| Type | Description |
|---|---|
bytes
|
Opaque snapshot, |
set_state
¶
Restore mutable state from a get_state() blob.
Overwrites the live state in place; the object keeps the parameters it
was constructed with. Length is validated against state_bytes()
before the blob is handed to the C core, and the core may reject it as
well.
Raises TypeError if blob is not bytes, ValueError if its
length differs from state_bytes() or the core rejects it, and
RuntimeError if the AsyncDsssPool has already been destroyed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
blob
|
bytes
|
A |
required |
destroy
¶
Release the underlying C resources immediately.
Ordinarily unnecessary: the resources are freed when the object is garbage-collected. Call this to release them at a definite point instead, or use the object as a context manager, which calls it on exit.
Idempotent: calling it again on an already-released object does
nothing. Every other method raises RuntimeError once it has run.
__enter__
¶
Enter a context manager, returning this object.
Lets a AsyncDsssPool be used in a with statement so its C resources
are released deterministically on exit rather than at collection time.
Returns:
| Type | Description |
|---|---|
AsyncDsssPool
|
This same object, not a copy. |
__exit__
¶
__exit__(
exc_type: object | None = ...,
exc: object | None = ...,
tb: object | None = ...,
) -> None
Exit a context manager, releasing the AsyncDsssPool.
Equivalent to calling destroy(). Returns None, so an exception
raised inside the with body propagates normally; this never
suppresses one.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
exc_type
|
object | None
|
Exception class, or None. Ignored. |
...
|
exc
|
object | None
|
Exception instance, or None. Ignored. |
...
|
tb
|
object | None
|
Traceback object, or None. Ignored. |
...
|
PoolSlot
¶
Bases: tuple[int, int, int, int, float, float, float, float, float, float, float, int, int, float, int, int, int]
AsyncDsssPool's slot record: whether the slot is assigned, the seed it was assigned from, the receiver's live status (state 1 refining, 2 tracking, 3 idle, 4 lost), and the samples since the assignment.
Attributes:
| Name | Type | Description |
|---|---|---|
slot |
int
|
The slot asked for. |
assigned |
int
|
1 while a receiver holds an emitter. |
state |
int
|
The receiver's ASYNC_DSSS_RX_* state; -1 for a slot that does not exist. |
seed_sample |
int
|
Stream position the row was assigned at. |
seed_chip_phase |
float
|
The seed's code phase, chips. |
seed_doppler_hz |
float
|
The seed's Doppler, Hz. |
seed_cn0_dbhz |
float
|
The seed's C/N0 estimate, dB-Hz. |
doppler_hz |
float
|
Signed coarse Doppler, folded, Hz. |
chip_phase |
float
|
Chips, Dll's own instantaneous-phase convention (the mirror image of acq_result_t::code_phase's correlation-lag convention -- see acq_build_handoff()'s doc comment). |
code_rate |
float
|
chips advanced per nominal chip (~1.0). |
cn0_dbhz_est |
float
|
C/N0 lower bound from the hit, dB-Hz. |
code_locked |
int
|
Presence flag. |
locked |
int
|
Health flag (symbol lock). |
lock_metric |
float
|
mean of |Re P|/|P| over the burst (~1 locked, ~2/pi with no carrier). |
state_samples |
int
|
Samples since the receiver's state was entered. |
both_down_samples |
int
|
The release clock, samples. |
assigned_samples |
int
|
Samples since the row was assigned. |
state
property
¶
The receiver's ASYNC_DSSS_RX_* state; -1 for a slot that does not exist.
chip_phase
property
¶
Chips, Dll's own instantaneous-phase convention (the mirror image of acq_result_t::code_phase's correlation-lag convention -- see acq_build_handoff()'s doc comment).
lock_metric
property
¶
mean of |Re P|/|P| over the burst (~1 locked, ~2/pi with no carrier).
bin_to_signed — read an FFT grid the way numpy does¶
Maps a reported Doppler bin index to its signed frequency index —
numpy.fft.fftfreq(n) * n, exactly. Multiply by doppler_res_hz for Hz:
import numpy as np
from doppler.dsss import BurstAcquisition, bin_to_signed
from doppler.wfm import PN, mls_poly
code = np.asarray(
PN(poly=mls_poly(5), seed=1, length=5).generate(31)
).astype(np.uint8)
acq = BurstAcquisition(code, reps=4, spc=4, chip_rate=1e6, cn0_dbhz=55.0)
# One repeated-code burst, so push() reports a hit to read the bin off.
chips = np.where(code & 1, -1.0, 1.0)
burst = np.tile(np.repeat(chips, 4), 8).astype(np.complex64)
hit_bin = acq.push(burst)[0][0]
f0_hz = bin_to_signed(hit_bin, acq.doppler_bins) * acq.doppler_res_hz
print(f"bin {hit_bin} -> {f0_hz:+.0f} Hz")
Call it rather than writing the fold out. The search and its hand-off must
agree on the convention, and a consumer seeded on the wrong side of it is off
by the full search span — a failure that once surfaced here as a receiver
reporting tracking == 1 while decoding noise. It is a thin wrapper over
dp_fftfreq_index() in clib_common.h, so C callers inline the same code.
Two things worth knowing. An even grid's Nyquist bin is -n/2, following
numpy; this engine reported +n/2 there until the burst-chain certification,
so a formula ported in from numpy now agrees with it. And the C companion
dp_fftfreq(bin, n, fs) returns the bin's frequency directly, taking the
sample rate where numpy takes the sample spacing.
bin_to_signed
¶
Map an FFT bin index to its SIGNED frequency index -- numpy.fft.fftfreq(n) * n, exactly: 0 = DC, ascending positive to (n-1)/2, then wrapping negative, so an even grid's Nyquist bin is -n/2. Multiply by doppler_res_hz for Hz. Call this rather than writing the fold out: the search and its hand-off must agree on the convention, and a consumer seeded on the wrong side of it is off by the full search span -- a failure that once surfaced here as a receiver reporting tracking while decoding noise. A thin wrapper over dp_fftfreq_index() in clib_common.h, so C callers inline the same code.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
bin
|
int
|
Bin index in |
required |
n_bins
|
int
|
Grid size. |
required |
Returns:
| Type | Description |
|---|---|
int
|
Signed index in |
Examples:
Related pages¶
Gallery — Streaming Async Despreader, Async DSSS Receiver: the SPEC waveform through coupled Doppler, CarrierAcquisition: RRC Pulse Shaping, Correlation and Detection, DSSS Acquisition — Pd / Pfa vs Es/N0, A 5-Burst DSSS Link — wfmgen's Three Faces, the Full Receiver Chain, DsssBurstReceiver — the Composed Burst Chain, DsssReceiver — the Composed Continuous DSSS Receiver, Gallery, Full-Chain Lock-Up
Guides — DSSS Burst Acquisition, Guides, Lock Detection Across doppler.track, Checkpoint & Resume, Waveforms — what you can generate
Design — Design — pure-functional acquisition kernel (elastic fleet), API taxonomy: the DSP building-block hierarchy and its naming axis, AsyncDsssReceiver — the continuous DSSS receiver, from spec to object, BurstBank — the coarse-Doppler bank as one C object, BurstCapture: acquisition's output, turned into bursts, CoarseChannel — is a channel an object, or a slice of the bank?, Corr2D: decoupled (interpolated) inverse length, Detection Sizing — the four laws behind one prefix, DSSS acquisition: stateless, parallel, dynamics-capable, DsssBurstReceiver: the burst chain, composed in C, Design, MPSK Receiver, The Polynomial-Phase Estimator — the reasoning, State Serialization — the standard bytes interface
Contributing — DSSS Primary Use Cases for Code Acquisition Design, Validation log, Contributing