SymbolSync Timing Lock Detector¶
track.SymbolSync (Gardner/DTTL symbol-timing recovery) had no lock concept
at all until this design landed — the last stage before bit decisions in a
tracking chain, with zero trust signal. The statistic and sizing formula
below were supplied by a doppler user from their own operational experience,
not derived from a paper this codebase carries; this page records the
formula as given, what it means, and the empirical validation that confirms
it actually hits its claimed operating point.
The statistic¶
lock_signal = 2 * (
(symbol.real**2 - mid_symbol.real**2)
+ (symbol.imag**2 - mid_symbol.imag**2)
) / (
(symbol.real**2 + mid_symbol.real**2)
+ (symbol.imag**2 + mid_symbol.imag**2)
)
A Gardner-style eye-opening ratio: symbol is the on-time interpolant (the
Farrow-evaluated sample at the recovered symbol strobe) and mid_symbol is
the mid-symbol (transition-gate) interpolant already formed by the Gardner
TED. At correct timing, a Nyquist (ISI-free) pulse's on-time sample depends
only on the current symbol, so it sits at the eye's peak while the
mid-symbol sample sits closer to a zero crossing — lock_signal is positive
and grows with pulse rolloff and Es/N0. Under noise or wrong timing it
hovers near zero.
An earlier design attempt in this codebase used the algebraically
equivalent ratio q = symbol_pwr / (symbol_pwr + mid_pwr) (lock_signal = 4q - 2) and appeared to invert sign between raised-cosine and rectangular
pulses under a first Monte Carlo pass — that turned out to be a test-harness
bug (a signal generator silently truncating the last ~30 symbols of every
"clean" trial, not a real property of the statistic). Once fixed, both
pulse shapes gave the same, correctly-signed result.
Sizing: (pfa, pd) → (avgs, threshold)¶
mean_lock_detect = (0.6 * rolloff + 0.26) * (
1 - exp(-0.275 * 10 ** (esno_min / 10))
)
avgs = 2 * var * ((erfcinv(2 * pfa) - erfcinv(2 * pd)) / mean_lock_detect) ** 2
threshold = erfcinv(2 * pfa) * mean_lock_detect / (
erfcinv(2 * pfa) - erfcinv(2 * pd)
)
lock_signal is non-coherently block-averaged over avgs looks before each
decision (a tumbling window, mirroring Dll's CFAR pattern — a sliding
window would break the verify-count independence assumption the same way it
would for the DLL). mean_lock_detect estimates the per-look mean from the
matched-filter rolloff and the minimum operating Es/N0; the classic Gaussian
test-statistic sizing (N = variance * ((Q⁻¹(pfa) - Q⁻¹(pd)) / mean)²,
threshold = Q⁻¹(pfa) * mean / (Q⁻¹(pfa) - Q⁻¹(pd))) gives avgs and the
declare threshold. erfcinv is used directly (not the √2·erfcinv(2p) = Q⁻¹(p) conversion a Gaussian Q-function derivation would normally use) —
implemented literally as given, with one correction: the source formula's
avgs used a bare 8 in var's place, an uncalibrated placeholder rather
than a derived constant. var is now SYMSYNC_LOCK_STAT_VARIANCE, the real
per-look variance of lock_signal under noise-only input, measured directly
(5,000,000-sample Monte Carlo: mean ≈0, variance ≈1.343) rather than assumed.
The leading 2 was the convention, not a factor — a distinction this
note got wrong until the formula moved into shared code. Since
Q⁻¹(p) = √2·erfcinv(2p), the √2 cancels in threshold and its square
absorbs into avgs, so the erfcinv-direct form with a leading 2 and the
Q⁻¹ form with none are the same formula. The empirical finding behind the
old wording still stands and is what made it convincing — the measured
variance alone, in the erfcinv form, undersizes avgs and blows past the
pfa target by ~13× — but the cause was the missing convention change, not a
derivation-specific constant that had to be kept apart from the variance.
symsync_configure_lock now calls det_dwell_gauss() / det_threshold_gauss()
in Q⁻¹ form, where SYMSYNC_LOCK_STAT_VARIANCE is passed straight through
as the variance it is. The derived values are unchanged, pinned at
avgs = 133 / threshold = 0.311 in test_symsync_core.c, and the
equivalence of the two conventions is pinned in test_detection_core.c. The
shared chain is documented in Lock Detection. No down-threshold or verify-count
derivation is implied by the source formula, so those default to the same
shape Dll.configure_lock uses (see
native/src/symsync/symsync_core.c's
SYMSYNC_LOCK_STAT_VARIANCE comment for the exact defaults and full
derivation).
No standard C library function computes erfcinv. symsync_core.c carried a
private Winitzki-initial-guess-plus-Newton-refinement implementation, kept
local pending a second consumer; the carrier lock detector became that
consumer, so it now lives in detection behind det_q_inv() and this file
has no private copy.
Empirical validation — does it actually hit (pfa, pd)?¶
Since the formula wasn't derived here, it was validated by direct Monte
Carlo against the real object rather than trusted on faith
(native/validation/symsync_lock.c, gated in CI via ctest -R validate_symsync_lock):
| Check | Method | Nominal target | Measured |
|---|---|---|---|
| False-alarm rate | 500,000 independent noise-only avgs-symbol blocks |
pfa = 1e-3 (~500 expected) | 429 false declares (8.58e-4) |
| Detection probability | 2,000 independent raised-cosine BPSK blocks at exactly the esno_min design SNR |
pd = 0.9 | 1.0000 true declares |
At the default operating point (rolloff=0.35, esno_min=10dB, pfa=1e-3,
pd=0.9) this gives avgs=133, threshold=0.311 (threshold is
var-independent, so unchanged from the original placeholder). Both targets
land correctly sized rather than accidentally oversized: avgs shrank from
395 (under the original bare 8) to 133 — about 3×, not the ~6× a naive
"replace 8 with the measured 1.343" substitution would give. That naive
substitution was tried first and rejected: it undersizes avgs (drops the
implicit factor of 2 from the erfcinv-vs-Q⁻¹ convention) and blows past
the pfa target by ~13× (empirical 1.31e-2 against a 1e-3 nominal) —
confirming the factor-of-2 correction above is required, not optional.
Usage¶
from doppler.track import SymbolSync
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)
assert ss.locked is False # no signal yet
# ... ss.steps(x) on a real oversampled baseband block ...
# ss.locked / ss.lock_stat report the verify-counted decision and the
# last block-averaged lock_signal.
# The raw escape hatch for direct control of avgs/thresholds/verify counts:
ss.configure_lock_raw(
avgs=200, up_thresh=0.3, down_thresh=0.25, n_up=1, n_down=8
)
See also: Lock Detection: Verify Counts for the
shared lockdet_core.h primitive every continuous tracking loop in this
codebase uses, and Dll's CFAR lock detector for the
closed-form-derived counterpart this design mirrors in shape.