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 replaces the auto-sized grid
DsssReceiver's embedded Acquisition is the continuous engine: it
window-tiles the Doppler search at a coherent depth of one epoch and
adds non-coherent looks, specifically to avoid a real, confirmed
mislock failure mode (see
Continuous, data-modulated signals).
configure_search_raw cannot pin a deeper coherent depth on it —
doppler_bins above 1 is refused with ValueError — but a pin does
replace the auto-sized grid: it drops the window tiling back to one
native window, so the search no longer covers a doppler_uncertainty
wider than the span, and the looks become whatever you pinned.
How it works¶
import numpy as np
from doppler.wfm import Composer, Gold, Segment, bpsk_map
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)
def make_signal(cn0_dbhz: float, seed: int):
"""The same signal Stage 1-3 used, described rather than assembled.
:class:`~doppler.wfm.Synth` in continuous DSSS mode (``symbol_rate > 0``)
is the transmitter: the Gold code repeats forever and a known random
payload rides on it at ``SYM_RATE``, with non-integer chips/symbol --
the asynchronicity this whole chain exists to handle. ``freq`` carries
the static Doppler. Noise is added here rather than by the source so the
pre-signal silence carries the same floor as the signal itself (the
receiver sweeps that silence before the burst arrives).
Ground truth comes from :func:`~doppler.wfm.bpsk_map`, the same C kernel
the source maps bits with, so the returned symbols are what was
transmitted -- not a sign convention restated here.
"""
payload = (
np.random.default_rng(seed).integers(0, 2, N_SYM + 4).astype(np.uint8)
)
capture = Segment(
type="dsss",
fs=FS,
sps=SPC,
freq=DOPPLER_HZ, # the static Doppler offset
# C/N0 in dB-Hz is the link's own figure; referred to the full
# sample-rate band it is the segment's `snr`. The engine resolves the
# AWGN amplitude from there -- an invented sigma convention is the
# single most common way a stimulus goes quietly wrong.
snr=cn0_dbhz - 10.0 * np.log10(FS),
snr_mode="fs",
seed=seed,
data_code=bytes(CODE.tolist()),
symbol_rate=SYM_RATE, # > 0 selects continuous async DSSS
payload=bytes(payload.tolist()),
num_samples=int(N_SYM * TSYM) + 2 * TE,
# The pre-signal silence is the segment's own LEADING gap, and
# `gap_noise="auto"` runs the noise floor through it -- so the
# receiver sweeps real noise before the signal starts, with no
# second noise realisation to keep consistent by hand.
delay_samples=int(PRE_SILENCE),
gap_noise="auto",
)
return (
Composer([capture]).compose(),
bpsk_map(payload).real.astype(float),
)
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).
