Patrick Lidstone
Self-hosted

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 0Mercury 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, LDPC K/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.mod output 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.

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_link drive 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.py runs ARQ through the genuine OFDM+LDPC PHY: FrameRadio turns frames into audio and back via MercuryModem (matched-filter preamble sync + SNR per frame); PhyLoopback is a SimChannel-compatible channel that actually TXs each frame → AWGN → app-style decimation → RXs it through the modem. run_link over PhyLoopback delivers a full message end-to-end and re-sends frames the real PHY drops (tested clean + 25 % loss). HardwareArqRunner is 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 via PhyLoopback. 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_cfg still 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 actions listen / connect / send / disconnect / stop; events stream back on {"t":"arq"} (state, gearshift, received text). Driven by session.ArqSession + radio.HardwareArqRunner, wired to manager.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).

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).