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 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).