Skip to content

DsssReceiver — the Composed Continuous DSSS Receiver

Constellation, running BER, windowed correctness, and carrier pull-in

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 to Acquisition.configure_search_raw.
  • configure_lock_raw(...) — re-tunes the embedded Dll's code-lock detector directly, forwarding to Dll's own raw lock configuration.
  • configure_chain_raw(segments, sps, n) — pins the despread/resample/ demod grid directly, bypassing the create-time segments/sps defaults, still bridged by a freshly-sized RateConverter — 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).