DsssReceiver — the Composed Continuous DSSS Receiver¶
The composed form of Acquisition -> Dll(segments) -> RateConverter -> MpskReceiver, the four-object continuous async-DSSS receive chain, now
composed into one C object,
DsssReceiver. Same CCSDS Gold-code signal and
operating point (CN0=97 dB-Hz, SEED=6) as that page — this page isn't a new
finding, it's everything that page hand-composed across four objects
(the _new_acq()/_new_chain()/_receive() helpers, the phase-inversion
hand-off, the RateConverter bridge) collapsed into one object and one
steps() call.
A simple API, with escape hatches¶
Only code/chip_rate/symbol_rate are required — everything else is a
physically-motivated default, the same "easy path derives, raw path pins"
shape Acquisition/Dll already use:
def _new_receiver() -> DsssReceiver:
"""The "just works" call: only the signal's own physical parameters
are required. segments/sps default to Stage 2/3's own validated
values (4, 8) -- this page passes them explicitly for clarity, not
because they're required."""
with warnings.catch_warnings():
warnings.simplefilter("ignore", UserWarning)
return DsssReceiver(
CODE,
chip_rate=CHIP_RATE,
symbol_rate=SYM_RATE,
cn0_dbhz=55.0,
doppler_uncertainty=100.0,
segments=4,
sps=8,
)
segments/sps default to the receiver's own validated values (4 and 8);
this page passes them explicitly for clarity, not because they're
required. Three escape hatches cover the power-user surface:
configure_search_raw(doppler_bins, n_noncoh)— pins the embedded Acquisition's search grid directly, forwarding toAcquisition.configure_search_raw.configure_lock_raw(...)— re-tunes the embedded Dll's code-lock detector directly, forwarding toDll's own raw lock configuration.configure_chain_raw(segments, sps, n)— pins the despread/resample/ demod grid directly, bypassing the create-timesegments/spsdefaults, still bridged by a freshly-sizedRateConverter— the one composition-specific knob this object adds beyond its children's own.
configure_search_raw bypasses the mislock-avoiding auto-sizer
DsssReceiver always has a symbol_rate (it's required), so its
embedded Acquisition always runs the data-modulation-aware joint
search — which naturally lands on a short coherent depth plus
non-coherent looks specifically to avoid a real, confirmed mislock
failure mode (see
Continuous, data-modulated signals).
Pinning a large doppler_bins directly via configure_search_raw
bypasses that protection entirely, with no Pd-honest pricing to warn
you. Only pin a coherent depth beyond a handful of epochs if you know
the signal is genuinely data-free for that whole window.
How it works¶
import numpy as np
from doppler.wfm import Gold
SF = 1023 # 2**10 - 1: the CCSDS 415.0-G-1 command-link Gold code period
CHIP_RATE = 3.0e6 # Hz
SYM_RATE = 2100.0 # Hz -- chips/symbol = 1428.6, non-integer (asynchronous)
SPC = 2 # samples/chip (front-end oversample)
FS = CHIP_RATE * SPC
TE = SF * SPC # samples per code epoch
TSYM = FS / SYM_RATE # samples per symbol ~= 1.4 code epochs
DOPPLER_HZ = 50.0
N_SYM = 3500
PRE_SILENCE = TE * 20 + 737 # deliberately not a whole number of epochs
CODE = Gold().generate(SF)
_CSIGN = np.where(CODE & 1, -1.0, 1.0)
def make_signal(cn0_dbhz: float, seed: int):
"""Identical construction to Stage 1-3's ``make_signal``."""
rng = np.random.default_rng(seed)
n = int(N_SYM * TSYM) + 2 * TE
idx = np.arange(n)
data = (rng.integers(0, 2, N_SYM + 4) * 2 - 1).astype(float)
si = np.clip(np.floor(idx / TSYM).astype(int), 0, len(data) - 1)
cph = (idx / SPC).astype(int) % SF
sig = data[si] * _CSIGN[cph] * np.exp(2j * np.pi * (DOPPLER_HZ / FS) * idx)
amp_snr = np.sqrt(10.0 ** (cn0_dbhz / 10.0) / FS)
sigma = 1.0 / amp_snr
total_n = int(PRE_SILENCE) + n
noise = (sigma / np.sqrt(2.0)) * (
rng.standard_normal(total_n) + 1j * rng.standard_normal(total_n)
)
x = np.concatenate([np.zeros(int(PRE_SILENCE)), sig]).astype(
np.complex64
) + noise.astype(np.complex64)
return x, data
def _stream(rx: DsssReceiver, x: np.ndarray):
"""Feed ``x`` through ``rx`` one code epoch at a time, collecting every
emitted symbol and a per-epoch ``norm_freq`` trace."""
syms_parts = []
nf_trace = []
for pos in range(0, len(x) - TE, TE):
out = rx.steps(x[pos : pos + TE])
if len(out):
syms_parts.append(out)
nf_trace.append(rx.norm_freq)
syms = np.concatenate(syms_parts)
return syms, nf_trace
steps() streams raw samples through whichever child is currently
active: 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 exact phase-inversion hand-off and rate-bridging the
hand-composed receiver validated — and the unconsumed tail of that 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 come back. steps() accepts any block
size; state carries across calls.
What you're seeing¶
Same four panels as the hand-composed receiver, now produced by one
object: decoded BPSK
constellation (settled window), running BER (settled window), windowed
decode correctness across the full run, and DsssReceiver.norm_freq vs.
epoch — flat at zero through the searching phase (the placeholder chain's
own untracked value), then pulling in from the Acquisition-quantized seed
to the true residual Doppler the moment the receiver locks.
Also serializable, like every stateful object in this codebase¶
DsssReceiver composes four children's state into one blob
(state_bytes()/get_state()/set_state()), a fixed shape whether
searching or tracking — the layout doesn't change, only the values do,
so a receiver mid-search and one already locked serialize and restore
through the identical interface.
Source: src/doppler/examples/dsss_receiver_demo.py. See also
AsyncDsssReceiver: the SPEC Waveform
(the same chain packaged for a coupled-Doppler SPEC waveform).
