Symbol Timing Recovery¶
track.SymbolSync recovers the symbol clock of an
asynchronous data stream — one whose symbol rate is not locked to (and here
runs 0.4 % fast of) the receiver's sample clock. A selectable timing-error
detector (ted) closes a PI loop around an integer timing NCO and a
Farrow interpolator: the NCO's post-wrap accumulator value is the
interpolation fraction µ — free, with no floating-point timing phase — so the
timing accumulation stays exact while only the interpolation is floating point.
The signal is an RC-shaped BPSK stream with a static fractional-sample offset, a 0.4 % clock-rate offset, and AWGN at 14 dB.
What you're seeing¶
Top — Recovered symbols. The interpolated symbol's real part per symbol: a brief pull-in, then clean ±1 once timing locks — the data is recovered with zero bit errors (a global inversion is don't-care).
Middle — Tracked clock. The recovered samples/symbol converging onto the true offset rate (dashed). The loop tracks the asynchronous clock — a moving rate, not just a static phase.
Bottom — Input eye. The raw oversampled input folded to two symbols: the eye is open, but with an async clock the optimal sampling instant drifts — which is exactly why a fixed sampler fails and a tracking interpolator is needed.
How it works¶
One per-sample integer-NCO loop produces two interpolants per symbol — derived from the phase value (not a parity counter, so a loop correction can never desync them) — that both timing-error detectors share:
per sample: push x[n] into the Farrow; advance the integer timing NCO
half-scale crossing -> mid-symbol (transition-gate) interpolant
full-scale wrap -> on-time interpolant (µ from the NCO)
per symbol: e = TED(mid, on_time, prev_on_time) # ted="gardner" or "dttl"
PI loop -> adjust the NCO frequency (slip-free) # no phase nudge
emit the on-time interpolant
Two detectors are available via ted:
"gardner"(default) —e = Re{ conj(mid) * (on_time - prev_on_time) }. Blind (non-data-aided): works for any constellation and at any SNR, at the cost of a non-transition-symbol self-noise floor."dttl"— the sign-sign Data Transition Tracking Loop (M.K. Simon):eis nonzero only when a hard decision onon_timeactually flips relative toprev_on_time, gated by the same transition-gate sample Gardner uses. Decision-directed, so it's valid only for BPSK/QPSK (independent, rectangular I/Q decision boundaries — not 8PSK/QAM), and degrades faster than Gardner at low SNR (wrong decisions corrupt the gating).
Steering the NCO through its frequency (folding the proportional term into the rate rather than nudging the phase) keeps the strobe count smooth — a direct phase nudge near a wrap boundary would insert or delete a symbol (a cycle slip).
import numpy as np
from doppler.track import SymbolSync
from doppler.wfm import rc_h, wfm_awgn_amplitude
SPS = 4
BETA = 0.35
NSYM = 2500
CLOCK_RATE = 1.004 # 0.4% fast symbol clock (asynchronous)
OFFSET = 1.7 # static fractional-sample timing offset
SNR_DB = 14.0
def make_signal(seed=7):
"""RC-shaped BPSK whose symbol clock runs CLOCK_RATE fast, offset by
OFFSET samples, at SNR_DB — asynchronous to the sample clock.
**This is the one stimulus in the gallery `wfmgen` cannot build**, and
the reason is the thing being demonstrated: `Segment.sps` is an integer,
so a composer scene has no way to say "the symbol clock runs 1.004x fast
and starts 1.7 samples late". That offset IS the question a
`SymbolSync` answers, so it is placed here by hand.
What does NOT get written by hand is the pulse or the level. Both are
library primitives and both were transcribed here before:
- `rc_h` is the analytic raised cosine at **arbitrary, non-grid times**,
which is precisely the shape a drifting clock needs — its docstring
says to use it rather than a transcription of the formula, and the
private `rc_pulse` this replaces was that transcription. It takes `t`
in SYMBOL periods where the old one took samples, hence the `/ SPS`.
- `wfm_awgn_amplitude` is the per-component sigma for a target SNR over
fs, replacing `sqrt(10 ** (-SNR_DB / 10)) * p / sqrt(2)`.
"""
rng = np.random.default_rng(seed)
a = rng.integers(0, 2, NSYM) * 2 - 1
n = NSYM * SPS
s = np.zeros(n)
span = 8 * SPS
for k, ak in enumerate(a):
c = k * SPS * CLOCK_RATE + OFFSET
if c + span >= n:
break
idx = np.arange(max(0, int(c - span)), min(n, int(c + span)))
s[idx] += ak * rc_h((idx - c) / SPS, BETA)
s = s.astype(np.complex64)
std = wfm_awgn_amplitude(SNR_DB, np.mean(np.abs(s) ** 2))
s = s + (rng.normal(0, std, n) + 1j * rng.normal(0, std, n)).astype(
np.complex64
)
return s, a
rx, sent = make_signal()
ss = SymbolSync(sps=SPS, bn=0.01, zeta=0.707, order="cubic")
symbols = ss.steps(rx) # one timing-corrected symbol per recovered instant
assert abs(ss.rate - SPS * CLOCK_RATE) < 0.01 # recovered the async clock
order selects the Farrow interpolator (linear / parabolic / cubic);
ted selects the timing-error detector (gardner / dttl); bn / zeta
set the loop bandwidth and damping. The synchronizer tracks residual clock
offsets up to roughly ±1 % cleanly — it follows acquisition, just as the
carrier loops track the residual after the FFT search.
Source: src/doppler/examples/symsync_demo.py.
