Skip to content

Async DSSS Receiver: the SPEC waveform through coupled Doppler

Constellation, loop-1 carrier-ramp tracking, windowed correctness, and binary symbol lock

Where DsssReceiver — the Composed Continuous DSSS Receiver (Stage 3) composes the receive chain — AcquisitionDllRateConverterMpskReceiver — to show the mechanics, this page drives the single packaged object that wraps that whole chain, dsss.AsyncDsssReceiver, against the literal waveform from the async DSSS receiver spec:

  • CCSDS 415.0-G-1 command-link Gold code, 1023 chips, repeating
  • 3.069 Mcps chip rate, asynchronous BPSK at 2700 bps (chips/symbol = 1136.67, non-integer — the asynchronicity is the point)
  • 2.5 GHz nominal carrier, ±50 kHz frequency uncertainty, \<500 Hz/s rate of change

What makes this the hard, honest case

Every prior page in this series injected the residual carrier as a phase multiply on a nominally-clocked code: the carrier moved, the chip clock did not. Real Doppler moves both coherently — the same v/c dilates the code rate and shifts the carrier. Here impairment.DopplerChannel imposes exactly that coupled impairment (it resamples to dilate the clock and applies the coherent carrier), so this is the first end-to-end exercise in the repo of the receiver's carrier→code aiding (carrier_freq_hz=): the tracked carrier offset is fed back into the code loop as a rate bias the code discriminator alone cannot pull in at this offset and SNR.

The two SPEC Doppler regimes never coexist. On a real pass the carrier Doppler is an S-curve: the frequency extremum (±50 kHz) occurs at the pass edges where the rate is ~0, and the maximum rate (500 Hz/s) occurs at the closest-approach zero-crossing where the offset is ~0. They are 90° out of phase — you never see max offset and max rate at the same instant. So a single capture is stressed in exactly one of two ways, and this example exercises both, with noise added after Doppler at a true 10 dB Es/N0:

  • TCA crossing (the figure): Doppler ramping through zero at +500 Hz/s — the maximum-rate, ~zero-offset regime that stresses the carrier dynamics.
  • Offset extremum (asserted, not plotted): +50 kHz static, ~zero rate — stresses acquisition and the coupled code-rate offset.

Both decode cleanly (BER ≈ 10⁻³, verified robust across seeds).

Two carrier loops, and which one does the work

The receiver runs a pre-despread Costas (loop 1) that de-rotates every input sample before the code loop, then a post-despread MpskReceiver carrier loop (loop 2) that mops up a small residual. The architecture is coarse → freeze → refine → unfreeze & track (loop 1) → despread → loop 2 mop-up: loop 1 owns the Doppler dynamics, so on a ramp it is loop 1, not loop 2, whose loop-filter output rides the sweep.

This matters because a Type-II loop nulls a frequency step to zero phase error but tracks a frequency ramp with a constant steady-state phase error θ ∝ (df/dt)/ωₙ². When loop 1 was inadvertently frozen at the refined seed, loop 2 silently absorbed the entire ramp and held that constant phase error, rotating the whole constellation by a fixed few degrees. Putting the ramp back on loop 1 (a non-data-aided squaring discriminator fed from the transition-free coherent-integration windows the refine stage already produces, with a pull-in bandwidth wide enough for the post-refine residual) is what un-rotates it — the fix is to put the ramp on the right loop, not to null the phase error on the wrong one.

Two honesty notes, both load-bearing

  • 10 dB, not SPEC's 5 dB. The AWGN-only decode floor is ~5 dB (the C test _test_awgn_esn0_floor proves it decodes at 5 dB and fails at 4), but under the full coupled ±50 kHz + ramp the carrier loop's pull-in margin puts the reliable point at ~10 dB. SPEC's 5 dB floor under the full Doppler envelope is aspirational for the current receiver.
  • BER alone is not trusted. At this SNR a narrow lag search reports false floors and a wide one can find lucky alignments, so decode is corroborated by two truth-free validators that need no reference symbols and no lag: self-referenced EVM (each symbol against its own hard decision) and blind M2M4 SNR (snr.snr_m2m4_db). A real lock shows a tight EVM (≈ −Es/N0) and a positive M2M4 SNR; both are plotted (bottom right), and all three metrics must agree before the decode is claimed.

How it works

The clean signal is synthesised by wfm.Synth in continuous DSSS mode (symbol_rate>0): the Gold code repeats forever and a known random payload rides on it at the symbol rate with non-integer chips/symbol. DopplerChannel then dilates the clock and applies the coherent carrier, and Gaussian noise is added last at the Es/N0 referenced to the outer data symbol.

import numpy as np

from doppler.impairment import DopplerChannel
from doppler.wfm import Gold, Synth

SF = 1023  # CCSDS 415.0-G-1 command-link Gold code period (2**10 - 1)
CHIP_RATE = 3.069e6  # Mcps -- SPEC's exact chip rate
SPC = 2  # samples/chip front-end oversample
FS = CHIP_RATE * SPC  # 6.138 MHz
SYM_RATE = 2700.0  # bps -- chips/symbol = 1136.67, non-integer (async)
CARRIER = 2.5e9  # 2.5 GHz nominal RF

# TCA crossing (the figure's operating point): the maximum-rate regime.
# Doppler ramps through zero at +500 Hz/s -- start below zero so the
# crossing lands mid-capture (offset ~0 where the rate is maximal, exactly
# the pass geometry). The offset extremum (+/-50 kHz, ~0 rate) is the OTHER
# regime, asserted separately -- the two never coexist physically.
TCA_START_HZ = -370.0  # ~ -N_SYM/2/SYM_RATE * 500, so it crosses 0 mid-run
DOPPLER_RATE_HZ_S = 500.0  # SPEC's maximum rate of change
OFFSET_EXTREMUM_HZ = 50e3  # the +/-50 kHz frequency-uncertainty edge

ESN0_DB = 10.0  # true Es/N0 at the receiver input (noise added after Doppler)
N_SYM = 4000
SEED = 1
PRE = SF * SPC * 20 + 737  # pre-signal silence (not a whole # of epochs)

# CCSDS Gold code: real, cross-checked reference (3-valued sidelobes).
CODE = Gold().generate(SF)


def make_capture(start_hz: float, rate_hz_s: float, seed: int):
    """SPEC's continuous async DSSS waveform through coupled clock Doppler,
    with AWGN added *after* the channel at a true 10 dB Es/N0.

    The clean signal is synthesised by :class:`~doppler.wfm.Synth` in
    continuous DSSS mode (``symbol_rate>0``): the Gold code repeats forever
    and a known random payload rides on it at ``SYM_RATE`` with non-integer
    chips/symbol. :class:`~doppler.impairment.DopplerChannel` then dilates
    the clock and applies the coherent carrier for a Doppler that starts at
    ``start_hz`` and ramps at ``rate_hz_s`` (both in Hz and Hz/s, converted
    here to the ppm of carrier the channel is parameterised in, via
    ``f / CARRIER``); noise is added last at the Es/N0 referenced to the outer
    data symbol -- ``cn0 = Es/N0 + 10log10(symbol_rate)``.

    Returns
    -------
    x : NDArray[np.complex64]
        The full capture (pre-silence + coupled-Doppler signal + noise).
    data : NDArray[np.float64]
        The transmitted BPSK symbols (+/-1), ground truth for BER.
    """
    rng = np.random.default_rng(seed)
    payload = rng.integers(0, 2, N_SYM + 8).astype(np.uint8)
    n = int(N_SYM * FS / SYM_RATE) + 4 * SF * SPC
    syn = Synth(
        type="dsss",
        data_code=bytes(CODE.tolist()),
        symbol_rate=SYM_RATE,
        sps=SPC,
        snr=100.0,  # clean -- noise is added below, after Doppler
        fs=FS,
        bits=bytes(payload.tolist()),  # known payload = BER ground truth
        seed=seed,
    )
    clean = syn.steps(n).astype(np.complex64)

    channel = DopplerChannel(
        fs=FS,
        carrier_hz=CARRIER,
        doppler_ppm=start_hz / CARRIER * 1e6,
        doppler_rate_ppm_s=rate_hz_s / CARRIER * 1e6,
    )
    signal = channel.execute(clean)

    x = np.concatenate([np.zeros(PRE, np.complex64), signal])
    cn0 = ESN0_DB + 10.0 * np.log10(SYM_RATE)
    sigma = 1.0 / np.sqrt(10.0 ** (cn0 / 10.0) / FS)
    noise = (sigma / np.sqrt(2.0)) * (
        rng.standard_normal(len(x)) + 1j * rng.standard_normal(len(x))
    )
    x = (x + noise).astype(np.complex64)
    return x, np.where(payload > 0, 1.0, -1.0)

One packaged receiver streams the whole capture. carrier_freq_hz=CARRIER turns on the carrier→code aiding; steps() accepts any block size (state carries across calls), so streaming one epoch per call is equivalent to one big call — it just lets us sample the per-epoch telemetry: loop 1's car_nco_freq (its absolute carrier estimate, in cycles/sample of the front-end rate) and the binary locked / continuous lock_metric symbol lock.

def receive(x: np.ndarray):
    """Stream the whole capture through one packaged
    :class:`~doppler.dsss.AsyncDsssReceiver`, epoch by epoch, collecting the
    demodulated symbols and the per-epoch carrier telemetry in one pass.

    ``carrier_freq_hz=CARRIER`` turns on carrier->code aiding: the tracked
    carrier offset is fed to the code loop as a rate bias, without which the
    coupled code-rate error outruns the code discriminator's pull-in at this
    SNR. ``steps()`` accepts any block size (state carries across calls), so
    one epoch per call is equivalent to one big call -- it just lets us
    sample the telemetry.

    The telemetry, indexed by INPUT sample position (the receiver's own
    sample clock -- the correct time base; the output-symbol index lags it by
    the acquisition/refine settling delay), is:

    - ``doppler_hz``    : the frozen post-refine coarse estimate (a DDC offset)
    - ``car_nco_freq``  : loop 1's (pre-despread Costas) loop-filter OUTPUT =
      NCO command at the front-end rate -- its mean rides the Doppler ramp,
      its variance is the loop stress
    - ``lock_metric``   : SNR-weighted cos(2*phi) symbol-lock metric (locked
      -> ~+1), with ``lock_threshold`` the declare level
    - ``locked``        : the binary hysteretic symbol-lock flag
    """
    with warnings.catch_warnings():
        warnings.simplefilter("ignore", UserWarning)
        rx = AsyncDsssReceiver(
            code=CODE,
            chip_rate=CHIP_RATE,
            symbol_rate=SYM_RATE,
            spc=SPC,
            cn0_dbhz=ESN0_DB + 10.0 * np.log10(SYM_RATE),
            doppler_uncertainty=60e3,  # cover the +/-50 kHz uncertainty
            carrier_freq_hz=CARRIER,  # enable carrier->code aiding
        )
    syms: list[np.ndarray] = []
    trace: list[tuple[int, float, float, float, float]] = []
    for pos in range(0, len(x) - TE, TE):
        block = rx.steps(x[pos : pos + TE])
        if len(block):
            syms.append(block)
        if rx.tracking:
            trace.append(
                (
                    pos,
                    rx.doppler_hz,
                    rx.car_nco_freq,
                    rx.lock_metric,
                    float(rx.locked),
                )
            )
    return (
        rx,
        np.concatenate(syms) if syms else np.empty(0, np.complex64),
        (np.array(trace) if trace else np.zeros((0, 5))),
    )

Decode is validated three ways — a wide lag+polarity BER search for the headline number, then the two truth-free metrics that make the lock claim trustworthy:

def best_ber(syms: np.ndarray, data: np.ndarray, lo: int | None = None):
    """Wide lag + polarity search of the settled hard decisions against the
    known payload. The lag absorbs the acquisition/refine settling delay
    (which grows as Es/N0 drops); over a several-hundred-symbol window a
    spurious sub-0.05 alignment is statistically impossible, so a wide search
    stays honest. ``lo`` is where the settled window starts (default: the back
    half) -- a lag search cannot rescue a window that still contains pull-in,
    because the errors there are real, so the caller picks it per regime (see
    decode_and_check). Returns ``(bits, ber, lag, inverted)``."""
    bits = np.where(syms.real > 0, 1.0, -1.0)
    hi = len(bits)
    lo = len(bits) // 2 if lo is None else lo
    best = (1.0, 0, False)
    for lag in range(-300, 301):
        idx = np.arange(lo, hi) + lag
        ok = (idx >= 0) & (idx < len(data))
        if ok.sum() < 40:
            continue
        truth = data[idx[ok]]
        e = float(np.mean(bits[lo:hi][ok] != truth))
        if e < best[0]:
            best = (e, lag, False)
        if 1.0 - e < best[0]:
            best = (1.0 - e, lag, True)
    return bits, best[0], best[1], best[2]


def self_evm_db(z: np.ndarray) -> float:
    """Self-referenced EVM (dB): de-rotate a symbol block by the BPSK
    squaring angle, unit-power normalise, hard-decide +/-1, RMS error to the
    nearest. No truth symbols, no lag -- a locked constellation sits near
    -Es/N0, a scattered one near 0 dB."""
    if len(z) < 16:
        return 0.0
    z = z.astype(np.complex128)
    z = z * np.exp(-1j * 0.5 * np.angle(np.sum(z * z)))
    z = z / np.sqrt(np.mean(np.abs(z) ** 2))
    dec = np.where(z.real > 0, 1.0, -1.0)
    return 20.0 * np.log10(np.sqrt(np.mean(np.abs(z - dec) ** 2)) + 1e-12)


def decode_and_check(
    x: np.ndarray,
    data: np.ndarray,
    label: str,
    ber_max: float = 0.02,
    settle_frac: float = 0.5,
):
    """Receive, decode, and validate one capture three ways -- a wide-lag BER
    for the headline, plus the two truth-free metrics (self-EVM, blind M2M4
    SNR) that make the lock claim trustworthy. Returns everything the plot
    needs; asserts a clean, corroborated decode.

    ``ber_max`` is the wide-lag BER bar. The two truth-free metrics (EVM,
    M2M4) are the *trustworthy* lock proof -- a wide-lag BER can flatter or
    (near an operating edge) flag a genuinely-locked decode -- so the BER
    bar is kept consistent with the EVM gate rather than tighter than it:
    the code NCO advances by a truncated (toward-zero, standard fixed-point
    DDS) phase increment, and at the +/-50 kHz frequency-uncertainty edge
    the carrier->code aiding rides that increment, so its ~20 ppm code-rate
    aid carries a small, honest quantization loss the aligned/TCA case does
    not. An EVM gate of -6 dB corresponds to ~2.3% BPSK BER, so the offset
    extremum takes ber_max=0.03 (its EVM/M2M4 still pass with margin); the
    TCA crossing keeps the strict default.

    ``settle_frac`` is where the measurement window starts, as a fraction of
    the recovered stream. **The two physical regimes settle at very different
    times and the window has to follow**, or every metric reports a transient
    instead of the steady state. Measured per eighth of the stream, with a
    per-block lag search so a settling lag is not counted as errors:

        TCA             clean from symbol ~500  (BER 0.0000, EVM ~-10 dB)
        +50 kHz static  clean from symbol ~2950 (BER 0.0000, EVM ~-8.5 dB),
                        and BER 0.38-0.42 / EVM ~-1.5 dB before ~2460

    The offset extremum is not failing early on, it is still pulling in: its
    carrier has 50 kHz to walk off. The rotation-blind M2M4 shows that
    directly by reaching +10 dB by symbol ~980 while EVM stays at -1.5 dB
    until ~2460 -- amplitude and timing lock early, carrier PHASE is the slow
    one. So that case measures its last quarter, where it is genuinely
    settled; the half-stream window straddled ~1000 symbols of pull-in and
    reported BER 0.143 for a receiver that decodes perfectly once locked."""
    rx, syms, trace = receive(x)
    assert len(syms) > 1000, f"[{label}] receiver never reached tracking"
    k0 = int(len(syms) * settle_frac)
    bits, ber, lag, inv = best_ber(syms, data, lo=k0)
    settled = syms[k0:]
    evm = self_evm_db(settled)
    m2m4 = float(snr_m2m4_db(settled.astype(np.complex64)))
    print(
        f"[{label}] {len(syms)} syms  ber={ber:.4f}  evm={evm:.1f} dB  "
        f"m2m4={m2m4:.1f} dB  locked={rx.locked}  "
        f"lock_metric={rx.lock_metric:.3f}"
    )
    assert ber < ber_max, f"[{label}] failed to decode cleanly (ber={ber:.3f})"
    assert evm < -6.0, f"[{label}] constellation scattered (evm={evm:.1f})"
    assert m2m4 > 7.0, f"[{label}] not locked (m2m4={m2m4:.1f})"
    return rx, syms, trace, bits, ber, lag, inv

What you're seeing

  1. Decoded BPSK constellation, all symbols coloured by time: the early settling-transient symbols are visibly scattered and distinct from the tight converged clusters at ±1.
  2. Carrier tracking: loop 1's car_nco_freq (its loop-filter output = NCO command) rides the Doppler ramping through zero at 500 Hz/s, matching the true offset to within the loop's phase-noise variance — the honest picture of a loop tracking dynamics.
  3. Windowed decode correctness (50-symbol windows): chance during the acquisition/refine settling transient, then flat at zero through the entire crossing.
  4. Symbol lock: lock_metric — the SNR-weighted running mean of the BPSK lock signal (I²−Q²)/(I²+Q²) = cos(2φ) — climbs past lock_threshold and the binary locked flag latches (shaded) once the constellation converges, then holds through the crossing.

Reproduce

python -m doppler.examples.async_dsss_receiver_spec_demo out.png

Source: async_dsss_receiver_spec_demo.py.