Mercury — clean-room OFDM + LDPC HF data modem
A native Rafe codec that reimplements the physical layer of Fadi Jerji's Mercury modem — a VARA/PACTOR-class OFDM + LDPC HF data modem — as a self-registering digital mode. It appears in the mode picker as 17 selectable "robustness" configs (Mercury 0 … Mercury 16).
⚠️ NOT bit-compatible / not interoperable with the reference Mercury. These modes do not decode a signal from — or produce a signal decodable by — Fadi Jerji's Mercury binary. Rafe uses its own LDPC parity-check matrices and its own pseudo-random (scrambler/pilot/preamble) sequences, because Mercury's are AGPL creative data we do not copy; and the ARQ data-link below is our own protocol, not Mercury's wire format. This is Mercury-family (same PHY design and parameters), not a Mercury clone. The parts that are genuine functional facts — constellation geometry and frame sizing — were bit-checked against a locally-built Mercury (see "Validated against a locally-built reference"), but the on-air bitstream as a whole is not compatible.
Licensing — why this is a clean-room port
Mercury is AGPL-3.0; Rafe is PolyForm Noncommercial 1.0.0. Those licences are incompatible, so no Mercury source is copied into Rafe. This modem is written from Mercury's functional parameters only — the same "studied as a reference" basis Rafe uses for its other modems (see NOTICE.md):
- OFDM sizing (Nfft, carriers, cyclic prefix, pilot spacing), the constellation set, LDPC code rates and frame size, and the CRC choice are functional facts, reimplemented directly.
- The LDPC parity-check matrices and the scrambler/pilot/preamble PN sequences are Mercury's own AGPL creative data — those are not used. Rafe builds its own LDPC codes (at the same rates) and its own PN.
Consequently this is interoperable in design but not guaranteed bit-compatible with the reference binary — exactly as loracss is a LoRa-family mode that is "not Semtech-interoperable".
Validated against a locally-built reference
A real Mercury binary was compiled (unmodified, on a Linux host) and used only as an external validation oracle — never linked, never distributed:
- Frame geometry (
nData,nBits, LDPCK/P, pilot counts) for all 17 configs matches the binary exactly (a param-dump harness). - Constellation points (BPSK/QPSK/8PSK/16QAM/32QAM, Gray labels, unit-power normalisation) match the binary's own
psk.modoutput to 1.7e-8 (62/62 points; float-dump rounding).
See test_mercury.py for the regressions that survive without the binary.
Design (matching Mercury's parameters)
| Layer | Detail |
|---|---|
| OFDM | Nfft=256, 50 used carriers (FFT bins 1..25, 231..255; DC + centre null), 1/16 cyclic prefix (Ngi=16, Nofdm=272). IFFT unscaled / FFT ÷N. |
| Pilots | Dx=1 / Dy=3 lattice (cell (r,c) is a pilot iff r%Dy==c%Dy); real ±1.33. Per-carrier least-squares linear channel estimate; phase-only equalise for the PSK configs (amplitude restoration). |
| Preamble | QPSK on even FFT bins only → two identical time halves. Timing sync by a matched filter against the known preamble; frequency offset (Moose) available. |
| Modulation | BPSK / QPSK / 8PSK / 16QAM / 32QAM (Gray, unit power), max-log soft LLRs. |
| FEC | Clean-room LDPC (N=1600) at rates 1/16 … 14/16, systematic IRA/staircase encoding, sum-product (SPA) + gradient-bit-flip (GBF) decoders. CRC-16/MODBUS outer code. LFSR energy-dispersal scrambler. |
The 17 robustness configs
Config 0 is the most robust (heaviest coding, simplest modulation); 16 is the fastest. Only modulation, LDPC rate, preamble length and channel estimator vary.
| cfg | mod | LDPC | payload/frame | cfg | mod | LDPC | payload/frame | |
|---|---|---|---|---|---|---|---|---|
| 0 | BPSK | 1/16 | 10 B | 9 | QPSK | 8/16 | 98 B | |
| 1 | BPSK | 2/16 | 23 B | 10 | 8PSK | 6/16 | 72 B | |
| 2 | BPSK | 3/16 | 35 B | 11 | 8PSK | 8/16 | 97 B | |
| 3 | BPSK | 4/16 | 48 B | 12 | QPSK | 14/16 | 173 B | |
| 4 | BPSK | 5/16 | 60 B | 13 | 16QAM | 8/16 | 98 B | |
| 5 | BPSK | 6/16 | 73 B | 14 | 8PSK | 14/16 | 172 B | |
| 6 | BPSK | 8/16 | 98 B | 15 | 16QAM | 14/16 | 173 B | |
| 7 | QPSK | 5/16 | 60 B | 16 | 32QAM | 14/16 | 160 B | |
| 8 | QPSK | 6/16 | 73 B |
Audio re-targeting
Mercury places its 12 kHz OFDM baseband on a 15 kHz IF at 48 kHz (an SDR/transverter chain). Rafe re-targets the carrier to 1500 Hz so the ~2.34 kHz-wide signal sits inside an SSB passband (~328..2672 Hz) and keys straight through the radio's audio path. The baseband rate (12 kHz) and passband rate (48 kHz) line up exactly with the SDK's rx_rate/tx_rate.
Usage
Registered automatically — pick Mercury 0..Mercury 16 in the keyboard-mode picker. Standalone:
from app.radio.mercury import codec
pcm = codec.modulate("CQ CQ de M0SUP", 48000, config=6) # s16le @ 48 kHz
out = []
dec = codec.MercuryDecoder(out.append, 12000, config=6) # feed 12 kHz s16le
dec.feed(pcm12k)
The frame-level API (app.radio.mercury.telecom.MercuryModem) and the individual layers (constellation, ldpc, ofdm, interleaver, crc, passband) are importable independently; they depend only on numpy/scipy.
ARQ data-link (reliable delivery)
The codec modes above are one-way FEC text — each frame decodes cleanly or is dropped. For reliable, retransmitted delivery (a connected byte pipe between two stations, Winlink-style) app/radio/mercury/arq.py layers an ARQ protocol on the PHY. This ARQ is our own design — not Mercury's wire format — and does not interoperate with the reference binary's ARQ.
- Half-duplex commander/responder roles (like PACTOR/VARA/ARDOP): the commander sends a window of frames, keys down, listens for an ACK, retransmits only the gaps.
- Selective-repeat windowing (16-frame window + 16-bit selective-ACK bitmap).
- Gearshift — the responder measures SNR and dictates the data config for the next over; control frames and ACKs always ride a robust config so the link can always negotiate even when data can't get through.
- Connection setup/teardown with retries; honest failure below the noise floor (it reports FAILED rather than hanging).
ArqLink is transport-agnostic — it emits (payload, config) frames and consumes (payload, snr) — so the same state machine runs against a simulated channel, the real modem, or a radio.
- Phase 1 (done) —
SimChannel+run_linkdrive it entirely off-air through a lossy, SNR-tagged erasure channel (a frame drops if its config is too fast for the SNR). Tests: clean transfer, 30 % loss, gearshift adaptation, sub-floor failure. - Phase 2 (done) —
radio.pyruns ARQ through the genuine OFDM+LDPC PHY:FrameRadioturns frames into audio and back viaMercuryModem(matched-filter preamble sync + SNR per frame);PhyLoopbackis aSimChannel-compatible channel that actually TXs each frame → AWGN → app-style decimation → RXs it through the modem.run_linkoverPhyLoopbackdelivers a full message end-to-end and re-sends frames the real PHY drops (tested clean + 25 % loss).HardwareArqRunneris the async half-duplex driver that keys PTT and plays/ captures audio via injected callables (manager.set_ptt,manager.audio.stream_tx, the 12 kHz RX feed) — wired but validated only on a real radio. - Phase 3 (done) — calibrated gearshift, aggregation, and app integration:
- Gearshift calibrated to our PHY (
arq._LADDER): per-config decode floors in our measured-SNR metric were measured empirically viaPhyLoopback. The responder recommends a config from its (EWMA) measured SNR; the commander steps up one rung per good over and backs off on stalls (pin_cfgstill forces a fixed config). Tested converging + delivering through the real PHY. - Aggregation: a small logical chunk (valid at any config in the band) is packed several-per-frame at higher configs, so fast configs aren't wasted (e.g. 600 B → 5 PHY frames at cfg12 vs ~46 before). This resolves the Phase-2 fixed-chunk limitation.
- WebSocket API + UI:
cmd:"arq"with actionslisten/connect/send/disconnect/stop; events stream back on{"t":"arq"}(state, gearshift, received text). Driven bysession.ArqSession+radio.HardwareArqRunner, wired tomanager.set_ptt/audio.stream_tx/ the 12 kHz RX tap, with a MERCURY ARQ panel in the web UI. - On-radio: the live half-duplex driver is wired end-to-end and keys a real radio; that path is exercised only on hardware (the protocol and the PHY audio path are both covered off-air).
- Gearshift calibrated to our PHY (
Limitations
- Not bit-compatible / not interoperable with the Mercury C++ binary — own LDPC matrices, own PN, own ARQ protocol (see the banner at the top).
- Each config has its own SNR floor; below it, frames simply fail CRC and are dropped. The extreme-robust config 0 (K=100 LDPC) is a short block — real gain, but not capacity-approaching.
- Both ends must select the same config (there is no in-band config signalling for the keyboard modes; each picker entry is a fixed config — the ARQ layer, by contrast, negotiates the config via gearshift).