Skip to content

Full-Chain Lock-Up

Three loops' lock decisions on one shared timeline

A real closed-loop async DSSS receiver — Dll(segments=K) -> Costas -> SymbolSync — cold-started with no code, carrier, or timing knowledge, watched with a single Telemetry context attached to all three loops. This is the payoff of the lock-detector consistency pass: one shared lockdet_core.h decision rule and one shared telemetry bus across three independently-tracking loops, so their .locked traces read as one story instead of three unrelated ones.

What you're seeing

Each loop's .locked probe, stamped against the raw input sample index (not each loop's own internal look count — see How it works) and stacked so the three step traces don't overlap:

  • code.locked (bottom, blue) — the Dll's CFAR code-lock detector. It declares first: despreading gain means the code statistic clears its threshold almost immediately.
  • car.locked (middle, orange) — the Costas carrier loop. It declares next, once the code is removed and the residual carrier is a clean tone for the PLL discriminator to pull in on.
  • sync.locked (top, green) — SymbolSync's timing loop. It declares last: its Gardner statistic needs a de-rotated, despread symbol stream before the eye-opening ratio it measures means anything, so it cannot even start converging until the other two are already tracking.

This ordering — code, then carrier, then timing — is not a coincidence of this particular run; it is the real acquisition dependency chain a cold-start receiver climbs, made visible because every loop reports .locked the same way.

How it works

Each loop runs at its own internal rate: the Dll decides once per code epoch, Costas once per partial, SymbolSync once per recovered symbol. Rather than plot three unrelated indices, every probe is stamped with the raw input sample index at the start of the block that produced it (tlm.set_now(i) before each block's three .steps() calls, the same pattern Many Emitters, One Consumer uses for unrelated objects) — so all three land on one shared timeline even though they never see the same sample count.

import numpy as np

from doppler.telemetry import Telemetry
from doppler.track import Costas, Dll, SymbolSync

SF, SPS, K = 127, 2, 8  # code chips, samples/chip, partials/epoch
TE = SF * SPS  # code-epoch length, samples
DSYM = 4e-3  # symbol-vs-code rate offset (independent, async clock)
F0 = 3e-4  # residual carrier after acquisition, cycles/sample
NSYM = 600


def make_signal(seed=7):
    """Async-data DSSS-BPSK, cold start: no code/carrier/timing knowledge."""
    rng = np.random.default_rng(seed)
    code = rng.integers(0, 2, SF).astype(np.uint8)
    csign = np.where(code & 1, -1.0, 1.0)
    tsym = TE * (1.0 + DSYM)
    n = int(NSYM * tsym) + 2 * TE
    data = (rng.integers(0, 2, NSYM + 6) * 2 - 1).astype(float)
    idx = np.arange(n)
    si = np.clip(
        np.floor((idx - 0.37 * TE) / tsym).astype(int), 0, len(data) - 1
    )
    cph = (idx // SPS) % SF
    rx = (data[si] * csign[cph] * np.exp(2j * np.pi * F0 * idx)).astype(
        np.complex64
    )
    return code, rx


def run_chain(code, rx, tlm=None, block=2 * TE):
    """Dll(segments=K) -> Costas -> boxcar MF -> SymbolSync, block-wise.

    Telemetry (if attached) is stamped with the RAW input sample index at
    the start of each block, so all three loops' probes -- despite running
    at different internal rates (epochs, partials, symbols) -- land on one
    shared timeline.
    """
    d = Dll(code, SPS, 0.0, 0.002, 0.707, 0.5, segments=K)
    cos = Costas(bn=0.02, zeta=0.707, tsamps=1)
    ss = SymbolSync(sps=round(K * (1.0 + DSYM)), bn=0.02, zeta=0.707)
    if tlm is not None:
        d.set_telemetry(tlm, "code")
        cos.set_telemetry(tlm, "car")
        ss.set_telemetry(tlm, "sync")
    for i in range(0, rx.size, block):
        if tlm is not None:
            tlm.set_now(i)
        part = d.steps(rx[i : i + block]).astype(np.complex64)
        wiped = cos.steps(part).astype(np.complex64)
        mf = np.convolve(wiped, np.ones(K), mode="same").astype(np.complex64)
        ss.steps(mf)
    return d, cos, ss
import numpy as np

from doppler.telemetry import Telemetry

code, rx = make_signal()
tlm = Telemetry(1 << 16)
d, cos, ss = run_chain(code, rx, tlm)
assert d.locked and cos.locked and ss.locked  # all three pulled in cold

recs = tlm.read()
assert tlm.dropped == 0

# demux by probe id, exactly as in the telemetry fan-in pattern:
code_locked = recs[recs["probe"] == tlm.probe_id("code.locked")]
sync_locked = recs[recs["probe"] == tlm.probe_id("sync.locked")]
# code declares lock at (or before) the sample index sync does --
# the dependency chain the figure above shows.
assert code_locked["n"][code_locked["value"] > 0][0] <= (
    sync_locked["n"][sync_locked["value"] > 0][0]
)

Costas(tsamps=1) de-rotates per-partial rather than per-symbol because the true symbol boundary is not yet known — see Streaming Async Despreader for why Dll(segments=K) outputs partials in the first place, and the async receiver's own test suite for the full link-budget validation (BER, code/timing-rate tracking, and independent symbol-clock drift) this composition is proven against.

Source: src/doppler/examples/receiver_lock_demo.py.