Skip to content

Code Loop Tracking

DLL code-loop tracking demo

A track.Dll delay-lock loop tracking the code phase of a continuous PN-spread BPSK signal. The replica starts a half-chip off and the incoming chip clock runs slightly fast (a 2e-4 code Doppler); the loop pulls its early/prompt/late replica onto the code and then holds it. Code length SF = 127 chips, 8 samples/chip, on a carrier-wiped stream.

What you're seeing

Top — Code-rate tracking. The loop's chip-rate estimate (blue) rings in with the classic damped 2nd-order transient and settles exactly onto the true incoming rate (black dashed) — the loop has matched the code Doppler, so its replica neither leads nor lags.

Bottom — Loop stress vs time. The sliding-RMS of the non-coherent early-minus-late discriminator (|E| - |L|) / (|E| + |L|) — the stress on the code loop. The half-chip misalignment drives a large transient that decays to a low locked floor as the replica aligns.

How it works

Dll embeds the same track.LoopFilter PI engine as the carrier loop. Per sample it correlates the carrier-wiped input against three taps of the local code — early (+spacing chips), prompt, late (-spacing chips) — accumulating an integrate-and-dump over one code period. Per period it forms the non-coherent envelope discriminator, filters it, and steers the code rate (and a proportional phase nudge):

import numpy as np

from doppler.track import Dll
from doppler.wfm import PN

SF, SPS = 127, 8  # code length (chips), samples per chip
NPER = 1000  # code periods
OFFSET = 0.5  # initial replica offset, chips
DELTA = 2e-4  # code Doppler (chip rate error)
BN = 0.004  # loop noise bandwidth

# The code is a MAXIMAL-LENGTH SEQUENCE from the library, not a coin flip.
# SF = 127 is 2^7 - 1, so `PN(length=7)` fills the period exactly, and the
# difference is the property a delay lock loop runs on: an m-sequence's
# off-peak autocorrelation is a flat -1 at every non-zero lag, while the
# `default_rng(1).integers(0, 2, 127)` this replaces wandered between -13
# and +3. A DLL discriminator reads that sidelobe structure directly, so a
# random code was quietly handing this demo a worse S-curve than any real
# spreading code would have.
code = np.asarray(PN(length=7, seed=1).generate(SF)).astype(np.uint8)
csign = np.where(code & 1, -1.0, 1.0)

# Carrier-wiped PN-spread BPSK whose chip clock runs (1 + DELTA) fast, with
# one BPSK data sign per code period — also from the library's generator, so
# nothing in this file invents a sequence.
#
# The CHIP CLOCK OFFSET stays hand-built, and deliberately: `Segment.sps` is
# an integer, so a composer scene cannot say "the chip clock runs 1.0002x
# fast and the replica starts half a chip late". That offset is the whole
# question a `Dll` answers, so it is the one thing here that is not a
# wfmgen call.
data = np.asarray(PN(length=13, seed=9).generate(NPER)).astype(int) * 2 - 1
cph = np.arange(SF * SPS * NPER) * (1 + DELTA) / SPS  # running chip phase
rx = (np.repeat(data, SF * SPS) * csign[(cph % SF).astype(int)]).astype(
    np.complex64
)

# The replica starts OFFSET chips off; the loop pulls in, then tracks.
d = Dll(code, sps=SPS, init_chip=OFFSET, bn=BN, zeta=0.707, spacing=0.5)
symbols = d.steps(rx)  # one prompt symbol per code period
rate = d.code_rate  # tracked chip rate (1.0 + code Doppler)
phase = d.code_phase  # tracked code phase (chips)

The discriminator works on envelopes, so it is insensitive to the BPSK data riding on the code. The half-chip discriminator is steep, so the loop bandwidth is kept small (a few thousandths of the code-period rate). Dll pairs with track.Costas: the carrier loop wipes the carrier, the DLL wipes the code; a receiver channel composes the two.

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