Part 12 of The Operator’s Cookbook, a 14-part series of complete, copy-paste GopherTrunk builds — one working rig per part, antenna to browser. Part 11 put the rig in a closet and taught it to survive unattended. This part is the advanced RF build: two antennas feeding one decoder, phase-coherently combined so a fade on one branch is covered by the other. Diversity is the one upgrade that can silently do nothing — or, before the aligner, actively hurt — so the log lines that prove it’s working matter more here than anywhere else in the series.
TL;DR: Diversity needs one radio with two coherent RX channels — a USRP B210 (shared LO) or an X310 with TwinRX daughterboards (independent PLLs) — mounted over
sdr.soapy_remote[]withdiversity: "mrc"andantenna: [RX1, RX2]. Health is one log line every 30 s:soapyremote: MRC diversity branches, andbranch_phase_degis the instrument: constant ⇒ shared-LO hardware,mrc-staticis fine; walking ⇒ independent PLLs, you want trackingmrc. The honest ceiling: MRC is guaranteed ≥ the best branch alone, but a real gain over it only shows on weak signals — prove yours with adiversity_capturepre-combine dump andTestDiversityCombinerReplay, scored by decode yield, never by dBFS.
Key takeaways
- Diversity is the last lever, not the first. A better antenna, feedline and gain staging (Parts 7–9 of The Analog Edge) buy more margin per dollar. Build this when one antenna genuinely fades.
- Hardware class decides the mode. A shared-LO front end has one fixed
inter-branch phase; independent PLLs lock at a random phase that then
walks.
branch_phase_degtells you which class you own, before any capture. - Coherence, not dBFS, is the verdict on the combine. The coherence figure is independent of RF gain — raising gain to make diversity “engage” is never the answer, and the config file says so in as many words.
- MRC ≥ best branch is a floor, not a promise of gain. On a strong signal every combined arm just matches the better antenna. The gain case lives on weak signals, and the pre-combine capture A/B is how you check yours.
Cheat sheet
| Concern | What it does | Where it lives |
|---|---|---|
| Enable combining | open RX0+RX1, combine into one stream | sdr.soapy_remote[].diversity: "mrc" |
| Freeze the estimate | one-shot calibration, for A/B or shared-LO rigs | diversity: "mrc-static" |
| Antenna ports | per-channel port assignment, validated + read back | antenna: [RX1, RX2] |
| Hardware-class instrument | constant vs walking inter-branch phase | branch_phase_deg in the MRC health line |
| Pre-combine evidence | per-branch IQ dump only the driver can make | diversity_capture, diversity_capture_seconds |
| Offline A/B | replay both branches through every combiner arm | TestDiversityCombinerReplay (GT_DIVERSITY_CAPTURE) |
| The concepts | why MRC works and where it can’t | antenna diversity, MRC gotchas |
In this post
- What you’re building — two antennas, one coherent front end, one combined stream.
- The shopping list — the two hardware classes, honestly priced.
- The config —
diversity: mrcon a SoapyRemote device, every key verified. - First run — what healthy looks like — the MRC health line, field by field.
- The honest ceiling & the pre-combine A/B — what MRC can buy, and how to prove it.
- When it doesn’t work — symptom → cause → fix, then variations.
What you’re building
The Part 8
remote-radio build with a second antenna: a two-channel SDR runs
SoapySDRServer near the antennas, GopherTrunk opens both RX channels,
and the driver phase-aligns and sums them into one maximised-SNR stream
before the decoder ever sees IQ. Everything downstream is unchanged;
diversity is invisible except in the health log and, when the channel fades,
in the calls that keep decoding.
Three driver stages do the work, in order: a pre-combine capture tap
(raw, so evidence stays untouched), an inter-branch aligner that removes
the fractional-sample start skew between the streams (on one field rig,
2.6 samples of skew made the combine decode 22% worse than the best
branch alone — the aligner is why that can’t happen anymore), and the MRC
combiner, which estimates one complex gain per calibration window and
either tracks (mrc) or freezes it (mrc-static). How each stage earned its
place is
Weak-Signal Engineering Parts 10–11;
this recipe is the operator’s side.
The shopping list
The honest first line: most rigs don’t need this. You need one radio with two RX channels sharing a frequency reference — two separate dongles will not do.
| Item | Class | Notes |
|---|---|---|
| USRP B210 (or similar 2-ch shared-LO SDR) | shared LO | one chip, both channels — inter-branch phase is a hardware constant; mrc-static territory |
| USRP X310 + 2× TwinRX | independent PLLs | separate daughterboards (rx_subdev_spec=B:0 A:0): frequency-locked, but the relative phase is random per lock and walks after it — tracking mrc territory (USRP notes) |
| Two antennas, same band + polarisation | — | co-located — a wavelength or two apart, not metres — with decent feedline |
A host for SoapySDRServer |
— | the Part 8 remote pattern; wired network, budget for two channels of CS16 |
No external reference oscillator is required for the X310/TwinRX class: a field capture proved the dual-daughterboard stream coherent as delivered (wideband coherence 0.95+ on a healthy run) — handling the phase is the calibrator’s job.
The config
The Part 8
SoapyRemote block plus three diversity keys, all verified against
config.example.yaml:
sdr:
sample_rate: 250_000 # soapy_remote isn't bound to RTL rate limits
soapy_remote:
- addr: "192.168.1.60:55132"
driver: "uhd"
args: "rx_subdev_spec=B:0 A:0" # X310: one channel per TwinRX
serial: "usrp-attic"
role: control
format: "CS16"
gain: "auto"
diversity: "mrc" # "" | "mrc" | "mrc-static"
antenna: [RX1, RX2] # RX1→channel 0, RX2→channel 1
# diversity_capture: "../iq/mrc/precombine"
# diversity_capture_seconds: 30
trunking:
systems:
- name: "Metro-TETRA"
protocol: tetra
control_channels:
- 467_912_500
Three notes. antenna: is the only correct way to pick ports — names are
device-specific (a B210 has TX/RX and RX2, a TwinRX has RX1 and RX2),
and GopherTrunk validates the list and reads it back, so a config moved
between rigs fails loudly. diversity: "mrc" re-estimates the branch
gain continuously; "mrc-static" freezes it after one estimate — the
first run tells you which to pick. And the combine happens below the protocol
layer, so a
Part 4
TETRA rig or a Part 1 P25 rig rides it identically.
First run — what healthy looks like
Start the daemon and let it lock as usual (tetra cc locked freq=467912500
mcc=… mnc=… here). The diversity heartbeat is one INFO line every 30 s:
INF soapyremote: MRC diversity branches addr=192.168.1.60:55132 branch_dbfs="ch0=-51.2 ch1=-50.6" reference_branch=1 calibrated=true coherence=0.95 branch_gain_db=-1.2 branch_phase_deg=145.3 mode=mrc updates=6682 holds=0
Read it field by field — this line is the whole cockpit:
branch_dbfs— both branches alive and within a few dB. A branch ~10 dB down is a weak antenna or feedline, and MRC on a floor-limited branch is roughly no-gain: fix that branch’s RF first.coherence— the normalised cross-correlation between branches, gain-independent by construction. A healthy co-located pair reads 0.9+ at moderate bandwidth; on wide captures lower numbers are normal and the lock gates scale with the window — coherence-not-dBFS is the full story.branch_phase_deg— the no-capture hardware-class instrument. Constant across lines means shared-LO andmrc-staticwould serve; walking (a TwinRX rig measured about −0.2°/s — a frozen constant decays over minutes) means independent PLLs and trackingmrc.updates/holds— accepted vs rejected calibration windows. Healthy isupdatesclimbing withholdsnear zero;updatesfrozen whileholdsclimbs means coasting on a stale gain, and it WARNs after 90 s.
One more line to know on sight, once per stream or retune — the aligner latching the start skew (per-stream, which is why it’s re-measured every time rather than calibrated once):
INF soapyremote: MRC inter-branch delay measured — delaying the early branch to align the combine delay_samples=0.41 peak_rho=0.94
The honest ceiling — and the A/B that tests it
Now the part a recipe owes you before you spend this money. Post-aligner, MRC is a no-harm combine: every capture A/B run to date scores the combined arms within one decoded frame of the best branch alone. But those captures decode at their ~100% yield ceiling — matching the best branch is the ceiling there — so a real gain over the best branch is still undemonstrated on air. Theory says it shows on weak, fading signals; a weak-signal pre-combine capture where the combined arm beats both branches is genuinely wanted upstream.
Second limit: the combine applies one complex gain to the whole wideband stream. Antennas metres apart give every carrier its own phase difference, and a single scalar can only align one — the signature is coherence stuck around 0.3–0.5 that no tracking improves. Co-locate the antennas; per-channel combining after the DDC is future work, not a config option (MRC gotchas).
The pre-combine A/B
Every other IQ tap —
baseband.auto_record,
the scope feeds — sits downstream of the combiner, so a capture from any of
them has one combiner baked in. diversity_capture is the one tap that
records the branches raw, straight after de-interleave:
INF soapyremote: diversity capture armed — dumping pre-combine per-branch IQ prefix=../iq/mrc/precombine seconds=30 branches=2
INF soapyremote: diversity capture complete — replay it with GT_DIVERSITY_CAPTURE to A/B combiners offline
You get <prefix>.br0.cs16, <prefix>.br1.cs16 and a .diversity.json
sidecar; a datagram that didn’t carry every branch is dropped from both
files, never written short — one short write silently desynchronises the
pair. Then grade every combiner against your own air:
GT_DIVERSITY_CAPTURE=../iq/mrc/precombine.diversity.json \
go test ./cmd/gophertrunk -run TestDiversityCombinerReplay -v
The harness prints a windowed coherence/gain/phase trace with plain-text verdicts (“branch phase is essentially CONSTANT” vs “branch phase WALKS”), measures the inter-branch delay, and decodes the capture through each branch alone plus static, tracking, aligned and narrowband combine arms — scored by CRC-clean decode yield, the one metric that can’t flatter a combiner. That table answers the mode question with evidence instead of folklore.
When it doesn’t work
| Symptom | Likely cause | Fix |
|---|---|---|
WARN MRC diversity got 1 of 2 channels in the stream |
the server never delivered the second RX channel | check args (rx_subdev_spec) and that the device really has two RX channels |
WARN MRC diversity branch is dead |
that antenna’s connection, feedline or per-branch gain | reseat/re-test that branch’s RF path; port assignment is antenna: |
WARN MRC diversity branches are not coherent |
different bands/polarisation, widely separated antennas, or no frequency lock | co-locate, match polarisation, check clock_source/time_source; a far-down branch_dbfs wants its gain staging fixed |
Calibrated once, then WARN has not accepted a calibration window |
the wideband-scalar limitation — one gain can’t align every carrier from separated antennas | move the antennas together, or freeze deliberately with mrc-static |
| Combine decodes worse than one antenna alone | not skew (the aligner handles it) — a branch is dragging the estimate | run the pre-combine A/B; the arm table names the culprit |
| Tempted to raise gain until diversity “engages” | the old absolute-power trap | don’t — coherence is gain-independent (gain staging) |
That last row has history: an earlier build gated calibration on a fixed coherence constant, and its WARN told a bandwidth-diluted operator that raising RF gain would not help — exactly wrong in their regime (their weak branch needed +5 dB of per-branch gain). The gates now bound the estimate’s phase error instead. The series-wide lesson: never trust an absolute-dBFS rule; trust coherence and decode yield.
Variations
diversity: "mrc-static"— one-shot calibration, frozen. Correct for shared-LO hardware, and the standard A/B reference arm on any rig.- Longer evidence —
diversity_capture_secondsaccepts up to 1200 s (a 1 GiB/branch cap always applies); at 200–250 kS/s two CS16 branches are only ~1.6 MB/s total, so take the long capture. - Single-channel, port-pinned —
diversity: ""withantenna: [RX2]: a plain remote SDR with the port explicit and validated — the “each branch alone” baseline before you commit. - Voice under diversity — the combined stream is an ordinary tuner to the
daemon;
role: widebandwithvoice_tapson top works exactly as in Part 1.
Where this goes next
The rig now has every piece of hardware this series will ask you to buy — but every talkgroup and radio is still a number. Part 13 is the zero-hardware recipe: alias CSVs and their real columns, naming things live from the browser, and exporting the result back to files you own.
FAQ
Do I need a diversity setup for a scanner rig? Almost certainly not first. It’s the only lever that costs serious hardware, and it addresses one regime: a signal that fades at your location. Antenna, feedline and gain staging buy more margin per dollar; build this when those are done and a marginal system still swings.
Should I use mrc or mrc-static?
Let branch_phase_deg answer: run mrc and watch the health line for a few
minutes. Constant phase means shared-LO hardware — mrc-static is equivalent
and simpler. Walking phase — TwinRX-style independent PLLs — means stay on
mrc, because a frozen constant decays over minutes.
Can I do diversity with two RTL-SDR dongles?
No. The combine needs two RX channels behind one clock in one sample stream —
diversity: mrc opens RX0+RX1 on a single SoapyRemote device. Separate
dongles have independent oscillators and USB timelines; there is no constant
phase for the calibrator to find.
Why is my coherence only 0.3 when everything decodes fine? Wideband coherence is diluted by every hertz of noise-only bandwidth around your carrier — a clean channel occupying a small fraction of a wide capture reads low even when the branches agree perfectly in-channel, which is why the lock gates scale with the estimation window. Judge the combine by decode yield and the WARN lines.
Series navigation
Part 12 of 14 · ← Part 11: The Closet Appliance — Pi, systemd & Docker · Next → Part 13: Naming Everything — Aliases, Labels & Exports