Skip to content

Lock Detection Across doppler.track

Every continuous tracking loop in this codebase needs to answer one question on every decision: am I still locked? This guide is the map across all of them — which loops carry a lock detector, how each one is sized, and which of two config entry points to reach for.

Two classes of lock detection

This codebase has exactly two designs, and they don't mix:

  • Continuous, verify-counted — a loop that runs indefinitely (code, carrier, or timing tracking) steps a statistic every look and feeds it to the shared primitive lockdet_core.h: level hysteresis (a declare/drop threshold pair) plus time hysteresis (n_up/n_down consecutive looks). See Lock Detection: Verify Counts for how and why the verify counts work.
  • One-shot, statisticalBurstDespreader tests a whole burst at once (det_threshold_f, an F-test over the burst's energy), because a burst has no "next look" to accumulate across. It is not a gap to retrofit with a verify count; there is nothing to verify against.

Everything below is the first class. BurstDespreader's lock_stat / stat_n stay a deliberately different shape — see docs/api/python-dsss.md.

The consistency table

Object Statistic Config method Decision
Dll CFAR ratio R = sqrt(2·Σ\|P\|²/E‖O‖²) over N looks configure_lock(pfa, n_looks, ref_snr_db=0.0) (derived) / configure_lock_raw(...) (raw escape hatch) .locked / .lock_stat
Costas \|Re P\|/\|P\| EMA configure_lock(up_thresh, down_thresh, n_up, n_down) (raw) .locked / .lock_metric
SymbolSync Gardner eye-opening ratio, block-averaged (see design note) configure_lock(rolloff, esno_min_db, pfa, pd) (derived) / configure_lock_raw(...) .locked / .lock_stat
CarrierNda M-th-power arm ratio EMA configure_lock(up_thresh, down_thresh, n_up, n_down) (raw) .locked / .lock
MpskReceiver carrier lock EMA (acquisition ↔ tracking handover) configure_lock(up_thresh, down_thresh, n_up, n_down) (raw) .tracking
Despreader forwards to its embedded Dll (code) and Costas (carrier) configure_code_lock(pfa, n_looks, ref_snr_db=0.0) (derived) / configure_carrier_lock(up_thresh, down_thresh, n_up, n_down) (raw) .code_locked / .carrier_locked

Every row got here the same way: an embedded lockdet_core.h instance, a configure_lock-family setter, and a .locked-family getter that reads the verify-counted decision, not the raw statistic.

Which config method do I call?

Two families, and the difference is whether a closed-form (pfa, pd) derivation exists for that loop's statistic:

  • Derived (pfa-style)Dll, SymbolSync, and Despreader.configure_code_lock (which forwards to Dll's). Both statistics have a documented closed-form sizing: Dll's CFAR ratio from det_threshold_noncoherent, SymbolSync's eye-opening ratio from the Gaussian sizing in docs/design/timing_lock_detector.md. State the operating point you want (pfa, pd or n_looks); the object works out the threshold and averaging depth itself.
  • Raw geometryCostas, CarrierNda, MpskReceiver, and Despreader.configure_carrier_lock (which forwards to Costas's). Their statistics (|Re P|/|P|, the M-th-power arm ratio) don't have a documented closed-form (pfa, pd) sizing yet, so you state the lockdet_core.h geometry directly: up_thresh, down_thresh, n_up, n_down. Every "raw" object also exposes this as the escape hatch under its derived sibling (Dll.configure_lock_raw, SymbolSync.configure_lock_raw) for a caller that wants to size the geometry independently instead of trusting the closed form.
import numpy as np

from doppler.dsss import Despreader
from doppler.track import CarrierNda, SymbolSync

rng = np.random.default_rng(0)
noise = (
    rng.standard_normal(4000) + 1j * rng.standard_normal(4000)
).astype(np.complex64)

# Derived: state the target operating point, the object sizes itself.
ss = SymbolSync(sps=4, bn=0.01, zeta=0.707)
ss.configure_lock(rolloff=0.35, esno_min_db=10.0, pfa=1e-3, pd=0.9)

d = Despreader(code=np.zeros(31, dtype=np.uint8), sps=2)
d.configure_code_lock(pfa=1e-3, n_looks=20)

# Raw geometry: state the lockdet_core.h threshold/verify-count rule
# directly (see the note on n_up below before copying these numbers).
car = CarrierNda(bn=0.01, sps=8, n=4, m=4)
car.configure_lock(up_thresh=0.5, down_thresh=0.4, n_up=64, n_down=32)

ss.steps(noise)
car.steps(noise)
d.steps(noise)
assert ss.locked is False
assert car.locked is False
assert d.code_locked is False

A magic number is not the same as a correctly-sized one

Two findings from building this consistency pass are worth carrying forward as standing principles, not just historical notes:

Verify-count independence can silently fail on a fast, correlated statistic. n_up consecutive above-threshold looks compounding to pfa^n_up (the whole reason a verify count buys cheap false-alarm suppression — see Lock Detection: Verify Counts) assumes independent looks. CarrierNda's lock statistic is a fast EMA, so consecutive samples are highly autocorrelated — borrowing MpskReceiver's own already-shipped n_up=8 for the same statistic gave a real 13% false-lock rate under direct Monte Carlo against noise-only input. n_up=64 was the smallest verify count that reliably eliminated false locks over 300 trials (see carrier_nda_core.c's CARRIER_NDA_LOCK_DEFAULT_* comment for the trial data). The same root cause blocked two earlier SymbolSync statistic designs before the shipped one. The lesson: before reusing a verify count across two different statistics, check whether the new statistic is actually looking at independent samples — a fast loop-filter EMA usually isn't, even if the underlying physical quantity is.

A constant that lands safe by accident is not the same as one that's sized correctly. SymbolSync's avgs sizing formula originally carried a bare, uncalibrated 8 in a variance's place. It happened to be safe (both pfa and pd targets were met, with margin) — but only because 8 was roughly 6× larger than the statistic's real measured variance, not because it was derived from anything. Replacing it required two steps, not one: checking whether 8 had a legitimate theoretical basis before assuming a straight substitution (it didn't — see the design note), and then empirically verifying the replacement rather than trusting the algebra alone — a naive "just use the measured variance" swap turned out to undersize avgs and blow past the pfa target by ~13×, because the formula's use of erfcinv instead of the standard Q⁻¹ silently drops a factor of 2. The lesson: a magic number that happens to work is a debugging lead, not a fix — replace it with something named and measured, and confirm the replacement empirically before shipping it, even when the algebra looks right. Full derivation and validation numbers in the design note.

See also