Lock Detection: Verify Counts + Hysteresis¶
What you're seeing¶
Top — a per-look envelope statistic through signal-off →
marginal-signal → signal-off. During the signal segment the per-look
detection probability is deliberately marginal (pd ≈ 0.87): most
looks clear the declare threshold η, but one look in seven misses.
The shaded band between the declare threshold and the 0.8η drop
threshold is the level hysteresis — a metric inside it advances
neither a declare nor a drop.
Middle — the two flags. The naive single-comparison flag
(metric > η) — how every loop's lock indicator starts life —
transitions 92 times on this trace: it chatters on every marginal
miss and false-alarms in the noise segments. The
LockDet flag,
driven by the same per-look statistic, transitions exactly
twice: one declare, one drop.
Bottom — the price, predicted. Time hysteresis is not free: a
declare needs n_up consecutive hits, which costs latency. The
Monte-Carlo looks-to-declare distribution (4 000 trials) has mean
4.00 looks against the closed-form
det_verify_delay(pd, n_up) prediction of 4.02 — the cost of the
chatter-free flag is known before you run anything.
How it works¶
Consecutive independent looks compound: n looks at per-look
probability p reach ≈ p^n. That single fact turns both verify
counts into derived quantities instead of tuned magic numbers.
(Both the ≈ and the word independent are load-bearing — the
approximation errs toward over-provisioning n, and the independence
is a real precondition rather than a formality. Measured comparison
and the two ways it has bitten:
Lock Detection.)
- Declare side — at a per-look false-alarm rate of
1e-2, three consecutive hits compound to1e-6: a loose (cheap, fast) per-look threshold plus a verify count of 3 buys the same false-declare protection as a much higher single-look threshold, at a fraction of the missed-detection cost. - Drop side — while locked, a drop needs
n_downconsecutive looks below the 0.8η band. Atpd ≈ 0.87the probability of a look falling below 0.8η is ~0.05, so ten straight misses is a ~1e-13event per window — the lock survives any realistic fade wobble, yet a true signal loss (where that probability jumps to ~0.95 per look) drops the flag within ~15 looks. - A non-finite look is a miss on both sides — it never advances a
declare, and while locked it advances the drop run like any other
miss, so a statistic that goes NaN drops the lock after
n_downrather than holding it lit. An unknown lock is not a lock. Only NaN is unordered:+infis an ordinary hit,-infan ordinary miss, and the exclusive band edges are untouched. The rule is not implemented in the detector — the look passes throughutil'ssaturate(), whosenan_toargument is where "which end is safe" is stated, once, for the whole library.
The whole rule is sized from budgets, end to end:
from doppler.detection import (
LockDet,
det_threshold,
det_verify_count,
det_verify_delay,
)
# per-look threshold from the per-look false-alarm rate
eta = det_threshold(1e-2)
assert round(eta, 3) == 3.035
# declare count from the compound false-declare budget: (1e-2)^3 = 1e-6
n_up = det_verify_count(1e-2, 1e-6)
assert n_up == 3
# the latency that buys, at a per-look pd of 0.9: ~3.7 looks on average
assert round(det_verify_delay(0.9, n_up), 2) == 3.72
# the rule itself: level hysteresis (two thresholds) + time hysteresis
d = LockDet(up_thresh=eta, down_thresh=0.8 * eta, n_up=n_up, n_down=10)
assert [d.step(5.0), d.step(5.0), d.step(5.0)] == [0, 0, 1] # 3rd hit locks
assert d.step(2.8) == 1 # inside the band: sticky, no drop progress
# A non-finite look is a miss in BOTH states. With n_down = 1 the drop is
# immediate; against the n_down = 10 above it would take ten straight NaNs,
# exactly like any other miss.
nd = LockDet(up_thresh=eta, down_thresh=0.8 * eta, n_up=1, n_down=1)
assert nd.step(5.0) == 1 # one hit declares
assert nd.step(float("nan")) == 0 # the metric died -> so does the lock
LockDet is the Python face of the embeddable C leaf
(native/inc/doppler/lockdet/lockdet_core.h): a pointer-free POD with a
force-inline step, designed to live inside a tracking loop's state
struct. Two shipped loops already run on it:
- the DLL's always-on code-lock detector steps it on the
CFAR statistic at each N-look decision
(
Dll.configure_lockderivesn_upfrom the pfa exactly as above), and - the M-PSK receiver's acquisition↔tracking handover steps it on the carrier lock metric each recovered symbol — two-way: 8 straight above-threshold symbols hand over, 32 straight below the drop threshold fall back to NDA acquisition.
Run it¶
See the detection API for the full
derived chain — C/N0 → (pd, pfa) → thresholds
(det_threshold_*), verify counts (det_verify_count), declare
latency (det_verify_delay), and smoothing bandwidth
(det_ema_alpha).
