Part 10 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 9 closed out the single-antenna levers with the architecture that let them land safely. This part adds hardware: a second receive branch, combined coherently so the SNRs add. But combining two streams that are *not seeing the same signal is worse than useless — it cancels — so the real engineering is in the gate that decides when calibration can be trusted. That gate is a normalised cross-correlation, it is scale-invariant by design, and getting there meant deleting an absolute-power threshold that had already pushed one operator into a 17 dB gain hike to satisfy a software constant.*
TL;DR: GopherTrunk’s
diversity.MRCcombines branches asy = Σ conj(h_k)·x_k / Σ|h_k|²— in pilot mode the complex gainsh_kcome from a calibrator, and the output SNR of a correct combine is the sum of the branch SNRs. The calibration question — “are these two receivers seeing the same signal through a constant complex gain?” — is answered bydiversity.CrossStats: a single-pass, multi-chunk accumulator whoseCoherence()is the normalised cross-correlation |rho| and whoseGain()is the least-squaresh = cov(x,ref)/var(ref). |rho| = γ/(1+γ) at equal per-branch SNR γ makes thresholds interpretable (0.5 ≈ 0 dB, 0.35 ≈ −2.7 dB) against a noise-only floor near sqrt(π/4N). Every statistic is a DC-removed covariance — an uncentred correlator on two noise branches sharing LO-leakage DC reports |rho| → 1 and calibrates confidently on nothing (TestCrossStatsRejectsCommonDCOffset). Absolute power survives only as a −100 dBFS digitally-dead-branch reject.
Key takeaways
- MRC is optimal only when the gains are right — and destructive when they are wrong. Two equal-power branches combined in anti-phase cancel. The combiner is the easy part; the calibration gate is the product.
- Gate on coherence, never on absolute power. An operator raised front-end gain 65.0 → 82.0 dB to clear a −40 dBFS calibration gate by 0.2 dB. Any dBFS threshold is a gain-staging trap that re-fires on the next front end; |rho| asks the actual question and is immune to gain.
- DC removal in the correlator is load-bearing, not hygiene. Shared LO leakage is perfectly common-mode; uncentred, it makes independent noise look perfectly correlated exactly in the weak-signal regime the gate protects.
- One scalar gain serves the whole wideband stream — know the caveat. Antennas metres apart give each carrier its own phase; a scalar aligns the loudest and can partially cancel others. Coherence stuck around 0.3–0.5 is that signature, and per-channel combining is honestly not built.
Cheat sheet
| Concern | What it does | Where it lives |
|---|---|---|
| Combiner | y = Σ conj(h_k)x_k / Σ|h_k|², power or pilot mode |
internal/dsp/diversity/mrc.go (MRC, SetGain) |
| Statistics | single-pass DC-removed covariances across chunks | internal/dsp/diversity/crossstats.go (CrossStats.Accumulate) |
| Quality gate | normalised cross-correlation |rho| in [0,1] | crossstats.go (Coherence) |
| Gain estimate | least-squares h = cov(x,ref)/var(ref) |
crossstats.go (Gain) |
| Dead-branch reject | −100 dBFS linear AC-power floor | internal/dsp/diversity/tracking.go (TrackingOptions.MinBranchPower) |
| DC-offset pin | shared DC over independent noise ⇒ |rho| ≤ 0.05 | crossstats_test.go (TestCrossStatsRejectsCommonDCOffset) |
| Operator surface | per-branch health line, WARNs that name the fix | internal/sdr/soapyremote/mrc.go (diversityReporter.observe) |
In this post
- Why a second antenna helps — and how MRC spends it.
- The wideband-scalar caveat — the honest bound on one complex gain.
-
Coherence, the scale-invariant gate — rho , its SNR mapping, its floor. - The gain-staging trap — the −40 dBFS gate and why it had to die.
- DC removal is load-bearing — the failure the centred correlator prevents.
Why a second antenna helps — and how MRC spends it
Fading is not uniform in space. Two antennas a few wavelengths apart see
partially independent channels: when one sits in a multipath null, the other
usually doesn’t. A selection combiner (diversity.Selection) picks the
stronger branch and buys you the better of two rolls. Maximal-ratio combining
does better: weight each branch by the conjugate of its complex channel gain,
sum, and — in AWGN with correct gains — the output SNR is the sum of the
branch SNRs. Two equal branches: +3 dB. On the yield cliff of
Part 1,
3 dB is a different regime.
// internal/dsp/diversity/mrc.go (shape) — pilot-mode combine
// y[i] = ( sum_k conj(h_k) · x_k[i] ) / sum_k |h_k|^2
if m.usePilot {
for k := 0; k < m.branches; k++ {
h := m.gains[k]
weights[k] = complex(real(h), -imag(h)) // conj(h)
denom += float64(real(h))*float64(real(h)) + float64(imag(h))*float64(imag(h))
}
}
The conjugate is the whole trick: multiplying branch k by conj(h_k) rotates
its signal component back into phase alignment with every other branch, so
signals add coherently (amplitudes sum) while the independent noises add
incoherently (powers sum). That is also the failure mode in one sentence:
get the phase wrong by 180° and the same machinery subtracts your signal.
The docstring in mrc.go is explicit that the combiner is “sensitive to
phase mis-alignment: with two equal-power branches in anti-phase, MRC cancels
the signal.” Everything else in this post exists to make sure the h_k
handed to SetGain deserve to be trusted.
The wideband-scalar caveat
Before the math, the honest scope. GopherTrunk combines the wideband stream — the full multi-megahertz capture, before any per-channel DDC — so one complex scalar per branch has to serve every carrier in the band at once. That is exact only if the branches differ by a frequency-flat constant: same antenna feed split two ways, or co-located antennas into a frequency-locked front end.
Two antennas metres apart break the assumption. Each carrier arrives from its
own direction, so each gets its own inter-antenna phase difference set by
geometry; a single scalar aligns whichever carrier dominates the estimate and
partially cancels others. The signature is a coherence that no amount of
tracking improves — stuck around 0.3–0.5 while both branches are healthy.
The fix for that regime would be combining after the per-channel DDC, one
gain per narrowband channel — a much larger change, and not built. The
diversityReporter health line says as much in its WARN text: widely
separated antennas give each carrier its own phase, which one wideband
complex gain cannot align. Surfacing that number instead of silently
underperforming is arguably the bigger contribution — the operator-seat view
of these log lines lives in the concurrent
Analog Edge series.
Coherence: the scale-invariant gate
The question a calibrator must answer is not “is the signal strong?” but “are these two branches related by a constant complex gain?” The statistic that answers it is the normalised cross-correlation
| rho | = | Σ x·conj(ref) | / sqrt(Σ | ref | ² · Σ | x | ²) |
computed over DC-removed samples. CrossStats accumulates the six needed
sums in a single pass across arbitrarily many stream chunks, so a
calibration window can span many datagrams:
// internal/dsp/diversity/crossstats.go (shape)
type CrossStats struct {
n int
sumRefR, sumRefI float64 // Σ ref
sumXR, sumXI float64 // Σ x
sumRefRef float64 // Σ |ref|²
sumXX float64 // Σ |x|²
sumXRefR float64 // Re Σ x·conj(ref)
sumXRefI float64 // Im Σ x·conj(ref)
}
func (s *CrossStats) Coherence() float64 // |rho| in [0,1]
func (s *CrossStats) Gain() (complex64, bool) // h = cov(x,ref)/var(ref)
Three properties make |rho| the right gate. It is scale-invariant —
multiply either branch by any gain and it does not move, so no front-end
knob can game it. It maps to SNR: for a common signal in independent
noise at equal per-branch SNR γ, |rho| = γ/(1+γ), so a threshold of 0.5
means “0 dB per branch” and 0.35 means “about −2.7 dB” — thresholds you can
reason about instead of tune (TestCrossStatsCoherenceTracksSNR pins the
mapping). It has a computable floor: two branches of pure independent
noise over N samples sit near sqrt(π/4N) — about 0.03 at N = 1000 — so you
know how much coherence is “none” for any window length
(TestCrossStatsIndependentNoiseFloor). And the same accumulation yields the
least-squares gain h = cov(x,ref)/var(ref), so the gate and the estimate it
gates come from one measurement of one window.
Update (18 Aug): the thresholds themselves are no longer fixed constants. Wideband |rho| is diluted by noise-only bandwidth around the coherent carrier, so a fixed 0.5 turned out to be a bandwidth-staging trap of the same species as the −40 dBFS gate below — an operator raised RF gain 5 dB purely to clear it. The gates now bound the estimate’s projected phase error
sqrt((1−ρ²)/(2Nρ²)), which puts the minimum |rho| at ~8× (lock) / ~5× (track) the sqrt(π/4N) floor for whatever window length the stream runs. The figure below still shows the γ/(1+γ) mapping with the original fixed thresholds for illustration.
The gain-staging trap the gate replaced
The gate was not always coherence. An earlier design refused to calibrate until the reference branch cleared −40 dBFS — a sane-sounding “make sure there’s signal” check. Then a field report showed what any absolute-power gate eventually does: an operator raised front-end gain from 65.0 to 82.0 dB — a 17 dB hike chosen not for RF reasons but to push a number past a software constant — and landed at −39.8 dBFS, clearing the gate by 0.2 dB. The radio was fine; the threshold was the problem, and it would have re-fired on the next front end with a different full-scale mapping. This is the same lesson the Analog Edge series teaches from the operator’s seat: never chase a software threshold with a gain knob.
Absolute power survives in exactly one place, doing the one job it is fit
for: MinBranchPower defaults to 1e-10 — −100 dBFS, where one CS16 LSB
is −90.3 dBFS — and rejects only a digitally dead branch: all zeros, a
receiver that never digitised, a server that delivered one channel of a
two-channel request. There |rho| is 0/0 and genuinely has no opinion. The
docstring is explicit that this is deliberately far below any signal level,
“not a signal-presence test, because an absolute power threshold is exactly
the gain-staging trap this design exists to remove.”
DC removal is load-bearing
The subtlest line in crossstats.go is the one that makes every statistic a
centred covariance rather than a raw sum. It reads like numerical hygiene.
It is not — it is the difference between a gate and a hazard.
Both receivers of a shared front end carry LO leakage and converter DC
offset, and that offset is perfectly common-mode: identical on both
branches, always. Feed two branches of pure independent noise plus a common
DC into an uncentred correlator and the DC term dominates every sum — the
correlator reports |rho| → 1 and hands back h = dc1/dc0. The system would
conclude the branches are perfectly coherent, “calibrate” on the ratio of
two leakage terms, and start coherently combining noise — precisely in the
weak-signal regime where the signal is too small to out-vote the DC, which
is precisely the regime the gate exists to protect.
TestCrossStatsRejectsCommonDCOffset pins the defence with a hostile
fixture: two branches of independent noise sharing a DC offset 20× the
noise amplitude must read |rho| ≤ 0.05, and the branch AC power must read
~the noise power, not the ~544× figure the DC would contribute. The cost of
the fix is two extra running sums per branch (the per-branch means), folded
into the same single pass. RefPower and BranchPower are AC powers for
the same reason: a branch carrying nothing but DC reads zero, “which is the
correct reading for a receiver that is not delivering signal.”
How that principle shaped the Go code
- One accumulator answers both questions.
Gain()andCoherence()derive from the same six sums over the same window, so the estimate can never be graded by a different measurement than produced it. - Chunk-tolerant by construction.
Accumulatefolds arbitrary-length chunks and truncates a ragged pair to the shorter length, so the window schedule is owned by the caller and a torn final datagram skews nothing. - The thresholds carry their own justification. 0.5 and 0.35 are not tuned magic — they are γ/(1+γ) statements pinned by a test, which is why they can be documented as dB and reasoned about at 2 a.m.
Where this goes next
CrossStats grades one window. The harder question is time: on front ends
with independent PLLs the true branch phase walks, so a constant measured
once decays — but Part 5 taught that a continuously-adapting filter ahead of
a differential decoder is fatal.
Part 11
resolves the apparent contradiction: why TrackingCalibrator is structurally
safe where CMA was not, the hold-don’t-fallback rule, the step clamp, and the
capture A/B — four decode arms scored by CRC-clean BSCH — that decides
whether tracking earns default-on.
FAQ
How much does a second branch actually buy? With correct gains in AWGN, the combined SNR is the sum of branch SNRs — +3 dB for two equal branches, more when the branches fade independently and one would otherwise be in a null. With wrong gains it can cost everything: MRC in anti-phase cancels. That asymmetry is why the gate is conservative and why an uncalibrated combiner passes the reference branch through untouched.
Why does GopherTrunk combine wideband instead of per channel? The combiner lives in the SDR driver, ahead of channelisation, so every downstream consumer benefits without knowing diversity exists. The price is the frequency-flat assumption — one scalar per branch. Co-located antennas on a locked front end fit it; widely separated antennas don’t, and the coherence figure is what tells you which situation you are in.
What does a coherence stuck at 0.3–0.5 mean when both branches look healthy? Usually the wideband-scalar caveat: the branches see the same band but with per-carrier phase differences one scalar cannot align — separated antennas, or mixed polarisations. It is not a gain problem; the health-line WARN says so explicitly, because raising RF gain is the reflex that never helps here.
Could the DC problem be solved by a DC-blocking filter before the correlator?
A high-pass helps the stream, but the correlator cannot assume every caller
runs one, and residual DC after an imperfect block still biases a raw
correlator. Centring inside CrossStats costs two sums and makes the
statistic correct regardless of what upstream did — an invariant, not a
configuration.
Is selection diversity ever the better choice?
Selection (diversity.Selection) needs no phase calibration at all, so it is
immune to every failure mode in this post — at the cost of the coherent gain.
When coherence gates keep holding on your hardware, the better of two
branches beats a combine you cannot trust.
Series navigation
Part 10 of 14 · ← Part 9: Parallel Buffers — SymbolSink, SoftSink & Opt-In Soft Paths · Next → Part 11: Diversity II — Tracking Without Breaking the Differential