Part 11 of Weak-Signal Engineering, a 14-part series on decoding the marginal regime — where the receiver locks but only a fraction of frames survive. Part 10 built the coherence-gated calibration that decides when two branches can be combined at all. It left one assumption standing: that the branch gain, once measured, stays put. On some hardware it does. On a USRP with two TwinRX daughterboards it does not — the relative phase is random at every LO lock and walks afterwards. So the calibration must track. But Part 5 spent an entire post on why a continuously-adapting filter ahead of a differential decoder corrupts every dibit. This part is about why tracking MRC survives that rule — and the claim is measured, not asserted.
TL;DR:
diversity.TrackingCalibratorre-estimates the branch gain from each ~2 msCrossStatswindow and smooths one-pole with α = 0.01 (τ ≈ 200 ms). It is safe ahead of a differential decoder for a structural reason CMA lacked: the reference branch’s weight is pinned to exactly1+0j, so the output phase is ANCHORED to the reference branch’s own — only the estimate error can move it, andarg(y/x0) ≈ −ε·|h|²/(1+|h|²), zero at convergence.TestTrackingCalibratorIsDifferentialSafebounds the per-window output phase step against the 45° π/4-DQPSK decision spacing. A rejected window holds the previous gains — never falls back to passthrough, because a mid-stream fallback is itself a phase step.mrc-static(α = 0) is the one-shot escape hatch: the first accepted window is snapped, not smoothed, bit-identical to a one-shot least-squares calibration. The verdict instrument isTestDiversityCombinerReplay: four decode arms scored by CRC-clean BSCH — and tracking-as-default is not yet verified on air.
Key takeaways
- Hardware class decides whether a constant is a lie. Shared-LO front ends (one AD9361, a B210) have genuinely fixed branch phase; separate daughterboards with independent PLLs are frequency-locked but phase-random per lock, walking after. Tracking is required for the second class and a variance-reducing no-op for the first.
- The CMA lesson does not transfer — structurally. CMA’s cost is
rotation-invariant, so nothing constrains its output phase. Here
h₀ ≡ 1+0janchors the output to the reference branch; only estimate error perturbs it, and that error is gated, damped by α, and hard-clamped. - Hold, never fall back. Dropping to passthrough mid-stream is a large phase discontinuity — the exact failure class being avoided. A rejected window keeps the previous gains; a clamped |h| cannot diverge the way CMA taps can, so holding is safe where CMA needed a re-seed.
- Yield is the verdict, and the on-air verdict is still open. The replay harness scores each-branch-alone, static, and tracking by CRC-clean BSCH. Until an operator’s own capture shows tracking winning, it is a lever, not a default.
Cheat sheet
| Concern | What it does | Where it lives | ||
|---|---|---|---|---|
| Tracking loop | window estimate → gate → one-pole → clamp | internal/dsp/diversity/tracking.go (TrackingCalibrator.Observe) |
||
| Phase anchor | reference weight pinned to 1+0j |
tracking.go (estimate, applied[0]) |
||
| Gates | phase-error bounds 0.10/0.16 rad ( | rho | ≥ 1/sqrt(1+2Nσ²)), dead branch −100 dBFS | tracking.go (TrackingOptions.LockGate/TrackGate) |
| Step clamp | ≤ 0.05 rad (~2.9°) phase, ≤ 1.25× magnitude per update | tracking.go (clampStep, trackingMaxStepRad) |
||
| Differential safety | per-window output phase step ≤ 1° measured | tracking_test.go (TestTrackingCalibratorIsDifferentialSafe) |
||
| One-shot mode | α = 0 snaps the first window, then freezes | tracking.go (estimate), mrc.go in internal/sdr/soapyremote (mrc-static) |
||
| Capture A/B | four arms scored by CRC-clean BSCH | cmd/gophertrunk/diversity_replay_test.go (TestDiversityCombinerReplay) |
||
| Pre-combine tap | per-branch cs16 files, alignment invariant | internal/sdr/soapyremote/branchcapture.go (branchRecorder) |
In this post
- Why a frozen constant decays — two front-end classes, one config knob.
- Anchored, not rotation-invariant — the structural difference from CMA.
- The window loop — estimate, gate, smooth, clamp.
- Holds, not fallbacks — and the reference-flapping fix.
- The four-arm A/B — how the verdict gets decided, and what’s still open.
Why a frozen constant decays
A one-shot calibration measures h once and applies it forever. Whether that
is correct is a property of the front end, not the algorithm. On a
shared-LO, single-chip design — an AD9361-class device, both receive chains
clocked and mixed from one synthesizer — the branch-to-branch phase is fixed
trace skew plus a power-up divider phase, and it genuinely does not move
while the synthesizer stays locked. Freeze away.
Now put the branches on separate daughterboards with independent PLLs — the
reported configuration was rx_subdev_spec=B:0 A:0 on a TwinRX pair. The
PLLs are frequency-locked to a common reference, so there is no beat note;
but the relative phase is random at each lock and walks afterwards. A
constant measured at t=0 is a little wrong at t=10 s and arbitrarily wrong
eventually. The tracking calibrator’s docstring compresses it: “a constant
measured once decays.”
Because the hardware class decides, the config exposes both:
diversity: mrc tracks; diversity: mrc-static (α = 0) snaps the first
accepted window and freezes — and the field instrument for choosing is
branch_phase_deg in the periodic MRC health line
(internal/sdr/soapyremote/mrc.go): constant ⇒ shared-LO, walking ⇒
independent PLLs. The operator-seat reading of that line is covered in
The Analog Edge, Part 11;
this post owns the algorithm.
Anchored, not rotation-invariant
Part 5’s rule was absolute: never feed a continuously-adapting filter to a
differential decoder, because a time-varying output phase does not cancel in
s·conj(prev). Streaming-adaptive CMA ahead of a differential decode
measured CRC 0 — not degraded, zero. So why is a calibrator that updates
every 2 ms window acceptable?
Because the failure was never “adaptation” — it was unconstrained phase.
CMA’s constant-modulus cost is rotation-invariant: rotate every tap by any
angle and the cost is identical, so the output phase is a free parameter that
wanders as the taps adapt. The tracking combiner has no such freedom. The
reference branch’s weight is pinned to exactly 1+0j, so the combined output
phase is anchored to the reference branch’s own phase. Write the second
branch as x1 = h·x0 + n and let the estimate carry an error,
ĥ = h·e^{jε}. Then
| y = (x0 + conj(ĥ)·x1) / (1+ | ĥ | ²) ⇒ arg(y/x0) ≈ −ε· | h | ²/(1+ | h | ²) |
— at most about ε/2, and exactly zero when the estimate is right. The
only phase motion the differential decoder can ever see is the motion of the
estimate error, and three mechanisms bound it: the coherence gate keeps
garbage windows out, the one-pole scales each accepted step by α = 0.01, and
clampStep hard-limits any single update to 0.05 rad (~2.9°) of phase and
1.25× of magnitude regardless of what slipped through.
And per this series’ discipline, the claim is measured, not argued:
// internal/dsp/diversity/tracking_test.go (shape) — TestTrackingCalibratorIsDifferentialSafe
// Per-window residual phase relative to the reference branch. The step
// between consecutive windows is what a differential decode actually sees.
for i := window * 2; i+window <= n; i += window { // skip the lock window
cur := residualPhaseDeg(y[i:i+window], refN[i:i+window])
/* … track the max step between consecutive windows … */
}
// pi/4-DQPSK decisions are 45° apart. Anything approaching a degree here
// would already be suspicious; the design budget is hundredths.
if maxStep > 1.0 {
t.Errorf("max per-window output phase step %.3f°, want <=1° (45° decision spacing)", maxStep)
}
At a 2 ms window and α = 0.01 the per-burst perturbation is a hundredth of a degree against a 45° decision spacing — four orders of magnitude of margin.
The window loop: estimate, gate, smooth, clamp
Observe accumulates branch chunks into CrossStats until WindowSamples
(default 8192) is reached, then runs one decision:
// internal/dsp/diversity/tracking.go (shape) — estimate
n := c.stats[0].Samples()
gate := coherenceGateFor(c.opts.TrackPhaseSigmaRad, n) // once calibrated…
if !c.calibrated {
gate = coherenceGateFor(c.opts.LockPhaseSigmaRad, n) // …stricter FIRST
}
/* … per branch: reject dead power, reject non-finite / out-of-bounds h,
take worst-branch coherence … */
if worst < gate {
c.hold(worst)
return false
}
for k := 1; k < c.branches; k++ {
if !c.calibrated {
// First accepted window: snap. No smoothing and no clamp, so this
// is bit-identical to a one-shot least-squares calibration on the
// same window.
c.applied[k] = proposed[k]
continue
}
target := smoothGain(c.applied[k], proposed[k], c.opts.Alpha)
c.applied[k] = clampStep(c.applied[k], target)
}
Every constant earns its value. The gates bound the estimate’s projected
phase error sqrt((1−ρ²)/(2Nρ²)) rather than |rho| itself — wideband
|rho| is diluted by noise-only bandwidth around the carrier, and an 18 Aug
operator A/B showed a fixed |rho| constant forcing gain-staging games the
same way the old −40 dBFS gate did — so the minimum |rho| falls as
1/sqrt(N) while staying ~8× (lock) / ~5× (track) above the sqrt(π/4N)
noise-only floor. The lock bound is stricter than the track bound
(0.10 vs 0.16 rad) because the first estimate is what a one-shot calibration
lives with forever, while a tracking update’s error averages away over ~1/α
windows. The window is 8192 samples, not one datagram, because a
184-sample window estimates phase to about 9.5° at |rho| = 0.35 while an
8192-sample window gets ~1.2°. The time constant τ ≈ 200 ms sits far
above one TETRA burst (14 ms, so intra-burst phase is effectively constant)
and far below the drift rate of two independently-locked PLLs. The
divergence bounds on |h|² (1/1024 … 1024, i.e. ±30 dB) reject estimates
past the point where the weaker branch contributes nothing anyway — far more
likely numerical garbage than measurement. And the first accepted window is
snapped, not smoothed, so mrc and mrc-static start bit-identically —
TestTrackingFirstUpdateMatchesStaticCalibrate pins it.
Holds, not fallbacks — and the flapping fix
What happens when a window fails a gate? The obvious answer — drop back to
reference-only passthrough until coherence returns — is wrong, and the
docstring says why: “falling back mid-stream is itself a large phase step,
the exact failure class being avoided.” A stale-but-bounded weight is
strictly better than a step. So a rejected window holds: gains stay,
holds increments, and TestTrackingCalibratorHoldsOnIncoherentWindow
pins that pure-noise windows neither move the gains nor drop the combiner to
passthrough. This is a deliberate divergence from SnapshotCMA’s
re-seed-to-passthrough guard
(Part 6),
and the difference is principled: CMA’s taps can diverge without bound, so a
poisoned filter must be reset; a clamped |h| cannot, so it is safe to keep.
The Counters() pair makes the state legible — a climbing hold count against
a flat update count is the signature of two receivers not seeing the same
signal.
A related fix from the same review: while uncalibrated, the reference branch
was re-chosen by argmax of branch power on every datagram. Two healthy
receivers routinely cross within ~1 dB, so the phase anchor itself swapped
branches mid-stream — a discontinuity injected by the very machinery meant to
prevent one. The reference is now chosen stably rather than per-datagram.
The four-arm A/B — and what is still open
Every claim above is synthetic-pinned, and this series has a name for
trusting that alone: the self-consistent-synthetic trap. The on-air verdict
needs pre-combine truth, which is why the capture tap exists where it does —
branchRecorder (internal/sdr/soapyremote/branchcapture.go) dumps each
branch to its own headerless cs16 file before the combiner, with an
alignment invariant: a datagram that did not carry every branch is dropped
from both files and counted, never written short, because one short
write silently desynchronises the pair and invalidates every later
conclusion. Every other IQ tap in GopherTrunk sits downstream of the
combiner and can answer nothing about it.
TestDiversityCombinerReplay (cmd/gophertrunk/diversity_replay_test.go,
driven by GT_DIVERSITY_CAPTURE=<prefix>.diversity.json) then does two
things. It prints a windowed coherence/gain/phase trace with an explicit
verdict line — flat phase ⇒ “a frozen calibration is sufficient here”;
walking ⇒ “tracking is doing real work”, in degrees per second. And it
decodes four arms through the identical TETRA BSCH scorer:
// cmd/gophertrunk/diversity_replay_test.go (shape) — the four arms
arms := []struct{ name string; iq []complex64 }{
{"branch0-only", br0},
{"branch1-only", br1},
{"static-combine", combineWith(t, br0, br1,
diversity.NewTrackingCalibrator(2, diversity.TrackingOptions{WindowSamples: window, Alpha: 0}))},
{"tracking-combine", combineWith(t, br0, br1,
diversity.NewTrackingCalibrator(2, diversity.TrackingOptions{WindowSamples: window}))},
}
// "CRC-clean BSCH count is the verdict. Do NOT conclude anything from EVM…"
Each branch alone, one-shot static, and tracking — scored by CRC-clean BSCH, per Part 2’s rule that a combiner can improve every intermediate metric while decoding nothing. The honest status line: tracking-as-default is not yet verified on air. The harness, the tap, and the verdict criteria are all in the tree; the gate is an operator’s own capture showing the tracking arm winning.
Where this goes next
Diversity closes the lever list. What remains is the discipline that decides which lever a symptom actually calls for — and the sharpest example in the project’s history is a signal that decoded at one capture rate and not another. Part 12 turns #764 into a worked example of experimental design: find the invariant your DSP guarantees, isolate it with an independent implementation, and prove the deficit lives in the samples before you touch the code.
FAQ
If tracking is safe and degrades to static behaviour on stable hardware, why keep mrc-static at all?
As the explicit escape hatch and the A/B control. α = 0 freezes the first
accepted window — bit-identical to a one-shot least-squares calibration — so
an operator on shared-LO hardware can rule the tracking loop out of any
comparison, and the replay harness gets its static arm from the same code
path rather than a second implementation.
Doesn’t holding stale gains during a long fade apply a wrong calibration? It applies a bounded one. |h| is clamped within ±30 dB and the phase error grows only as fast as the hardware drifts — while the alternative, a passthrough fallback, injects an immediate large phase step into a live differential decode. When the fade lifts, gated windows resume and the one-pole walks the estimate back.
How do I know which front-end class I have without capturing anything?
Watch branch_phase_deg in the periodic soapyremote: MRC diversity
branches log line. Essentially constant across minutes ⇒ shared LO, and
mrc-static is sufficient; walking ⇒ independent PLLs, and tracking is doing
real work. The replay harness prints the same verdict from a capture.
Why is the window’s coherence taken from the worst branch? One incoherent branch is enough to make the joint estimate untrustworthy — combining a good branch with a garbage-calibrated one can cancel signal. The gate is only as strong as its weakest input, so that is what it measures.
What would change if the branches sat on separate dongles with free-running clocks? Everything — free-running LOs differ in frequency, so the branch phase spins continuously rather than walking, and no per-window scalar tracker holds. This design assumes a common reference (frequency lock); without one you need per-sample frequency alignment first, which is a different machine.
Series navigation
Part 11 of 14 · ← Part 10: Diversity I — MRC & Coherence-Gated Calibration · Next → Part 12: Proving It’s the Signal — Rate Invariance & Independent Resamplers