Full-Chain Lock-Up¶
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) — theDll's CFAR code-lock detector. It declares first: despreading gain means the code statistic clears its threshold almost immediately.car.locked(middle, orange) — theCostascarrier 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.
