Patrick Lidstone
Self-hosted

DATV: native DVB-S and DVB-S2 for amateur digital television

A from-scratch, dependency-free implementation of the two satellite digital-TV physical layers — MPEG-TS in, QPSK baseband IQ out, and all the way back — covering the reduced-bandwidth (RB-DATV / QO-100-style) amateur configurations.

Rafe project · app/radio/datv/dvbs.py, dvbs2.py, dvbs2_tables.py, app/radio/datv_mode.py · experimental


Abstract

DATV (Digital Amateur Television) rides the same two ETSI physical layers that broadcast satellite TV uses: DVB-S (EN 300 421, 1997) and its successor DVB-S2 (EN 302 307, 2005). Both carry an MPEG-2 Transport Stream — a train of fixed 188-byte packets — through a concatenated forward-error-correction (FEC) stack and out onto a single-carrier PSK waveform. Rafe implements both, natively, in pure NumPy: no gnuradio, no leandvb, no external DSP library at runtime. The DVB-S chain (dvbs.py) is the classic 1997 design — energy-dispersal randomiser, Reed–Solomon(204,188), a Forney convolutional interleaver, and a punctured constraint-length-7 convolutional code, Gray-mapped onto QPSK. The DVB-S2 chain (dvbs2.py, dvbs2_tables.py) is the 2005 redesign — a Base-Band Frame with a CRC-protected header, a BCH + LDPC capacity-approaching concatenation over a 16 200-bit short FECFRAME, QPSK, and a Physical-Layer frame whose header is a Reed–Muller-coded, Walsh–Hadamard-style MODCOD field read before the receiver knows the code rate. The receiver runs batch over captured SDR IQ: root-raised-cosine matched filtering, fourth-power coarse frequency recovery, polyphase timing, a decision-directed phase tracker, then a brute-force FEC-sync search (DVB-S) or a SOF-correlating Physical-Layer parser (DVB-S2). Both directions are validated by in-repo loopback tests, and DVB-S additionally against the leansdr/leandvb reference transmitter as an interoperability oracle. The result is a complete, self-contained DATV modem that reduces to the two amateur essentials — receive a QO-100 DATV beacon down to MPEG-TS on a Raspberry-Pi-class box, or generate a standards-compliant IQ stream to transmit — with every constant, polynomial, and table either given here in full or reproduced by an exact recipe.


1. Motivation

Amateur DATV is one of the few ham modes where the "protocol" is a full broadcast standard. On the QO-100 geostationary transponder and on 70 cm / 23 cm terrestrial links, operators send live video as reduced-bandwidth DVB-S/S2: the same ETSI waveforms broadcasters use, but at symbol rates of a few hundred kilobaud (the app default is 333 kS/s — see datv_mode.py) instead of tens of megabaud, so the signal fits an amateur channel. Decoding it has historically meant a hardware set-top demodulator or a heavyweight GNU Radio / leandvb install.

Rafe's design rule is that every native mode is in-repo, pure-NumPy, and reproducible from its own documentation. DATV is the most ambitious application of that rule: two complete concatenated-FEC physical layers, each with GF arithmetic, large machine-transcribed code tables, and a full synchroniser. This document is the reproduction contract — a reader with a DSP background should be able to rebuild both chains, bit-exact where it matters, from what is written here.


2. Background

2.1 The two standards, and why there are two

DVB-S (EN 300 421, 1994/97) DVB-S2 (EN 302 307, 2005)
Outer code Reed–Solomon (204,188), t = 8 BCH (n, k), t = 8/10/12
Inner code Convolutional K = 7, punctured 1/2…7/8 LDPC, rates 1/4…9/10
Interleaver Forney convolutional, I = 12, M = 17 LDPC bit interleaver (parity structure)
Randomiser PRBS x¹⁵+x¹⁴+1, per 8-packet group BB scrambler x¹⁵+x¹⁴+1, per BBFRAME
Framing continuous TS, sync-byte cadence BBFRAME + PLFRAME with signalled MODCOD
Modulation QPSK only QPSK / 8PSK / 16APSK / 32APSK
Gap to Shannon ≈ 2–3 dB ≈ 0.7–1 dB

DVB-S concatenates an algebraic outer code (Reed–Solomon, which cleans up residual bursts) with a inner convolutional code (which handles the raw channel Gaussian noise via soft-decision Viterbi). That RS + convolutional pairing was the state of the art in the early 1990s and gets to within ~2–3 dB of the Shannon limit. DVB-S2 replaced the inner code with a long LDPC code (near-capacity under iterative belief propagation) and the outer RS with a BCH code (a light, high-rate algebraic clean-up of the LDPC's rare residual errors), gaining roughly 30 % more throughput in the same bandwidth. It also made the modulation and code rate self-describing: every physical-layer frame begins with a header that names its own MODCOD, so a receiver can lock without prior configuration.

2.2 The MPEG-TS payload

Both layers are payload-agnostic transports for an MPEG-2 Transport Stream. A TS is an unending sequence of 188-byte packets, each beginning with the sync byte 0x47 followed by a 3-byte header (PID, continuity counter, flags) and 184 bytes of payload. The FEC operates on these packets as opaque bytes; the only place the value 0x47 matters to the modem is (a) as the byte the energy-dispersal stage inverts to 0xB8 to mark a group boundary, and (b) as the sync the receiver hunts for to confirm it has recovered the stream. Everything above the TS — PES, video (H.262/H.264/H.265), audio — is opaque; in Rafe the recovered TS is written to a .ts file and handed to an external ffmpeg only for optional JPEG snapshots (datv_mode.py), never for decode.

Rafe's TS packet count per unit is small (amateur RB-DATV), but the chain is the full standard chain; nothing is a toy subset except the deliberately limited modulation/frame-length coverage documented in §8.


3. Frame structure — exact numbers

3.1 DVB-S: the byte-domain frame

DVB-S has no explicit frame header. Its "frame" is the RS packet and the 8-packet randomiser group; synchronisation is purely by the sync-byte cadence.

 One TS packet             188 bytes   (sync 0x47 + 187)
 After energy dispersal    188 bytes   (sync byte -> 0x47 or 0xB8, rest XORed)
 After RS(204,188)         204 bytes   (188 data + 16 parity)      <- the RS frame
 After interleaving        204 bytes   (byte-permuted, length preserved)
 Randomiser super-frame    8 x 188     (first packet's sync inverted to 0xB8)

Exact structural constants (dvbs.py):

Quantity Value Source
TS packet length 188 bytes universal
RS codeword 204 bytes (204,188), t = 8, 16 parity dvbs.py
RS field GF(2⁸), primitive poly 0x11D dvbs.py
RS generator roots α⁰ … α¹⁵ (b₀ = 0) dvbs.py
Interleaver Forney, I = 12 branches, M = 17 cells/step dvbs.py
Interleaver delay (end-to-end) 2244 bytes = 12·17·11 dvbs.py
Convolutional code K = 7, 64 states, G1 = 0171₈, G2 = 0133₈ dvbs.py
Puncture rates 1/2, 2/3, 3/4, 5/6, 7/8 dvbs.py
Randomiser PRBS x¹⁵+x¹⁴+1, group of 8 packets dvbs.py
Group sync mark first packet sync 0x47 → 0xB8 dvbs.py
Modulation Gray QPSK, 2 bit/symbol dvbs.py
Pulse shape RRC, roll-off α = 0.35, span 10 sym dvbs.py

At rate 1/2 one 204-byte RS packet is 1632 information bits → 3264 coded bits → 1632 QPSK symbols; at 7/8 the same packet is 3264·(1/2)/(7/8) ≈ 1866 coded bits → 933 symbols. (Rafe's implementation punctures a continuous convolutional stream over the whole TS, not per packet, so these are per-packet averages.)

3.2 DVB-S2: FECFRAME, BBFRAME, PLFRAME

DVB-S2 is explicitly framed at three nested levels. Rafe implements the short FECFRAME (16 200 bits) with QPSK only (dvbs2.py); the normal FECFRAME (64 800 bits) and the higher constellations are not built (§8).

FECFRAME (n_ldpc)  = BCHFRAME + LDPC parity
  short FECFRAME    n_ldpc = 16200 bits          (normal would be 64800)
  BCHFRAME (kldpc) = BBFRAME(kbch) + BCH parity(168)
    BBFRAME (kbch)  = BBHEADER(80 bits) + DATA FIELD(dfl = kbch-80)

PLFRAME            = PLHEADER(90 symbols) + payload(+ pilots)
  PLHEADER          = SOF(26 sym) + PLSC(64 sym)      pi/2-BPSK
  payload           = FECFRAME mapped to QPSK  = n_ldpc/2 = 8100 symbols
  pilot block       = 36 symbols, inserted every 16 slots (1 slot = 90 sym)

The full short-frame parameter set, per code rate, from dvbs2_tables.py (LDPC_SHORT) — every value below is a protocol constant:

Rate N (FECFRAME) K (LDPC info = BCHFRAME) kbch (BBFRAME) BCH parity q = (N−K)/360 QPSK payload syms
1/4 16200 3240 3072 168 36 8100
1/3 16200 5400 5232 168 30 8100
2/5 16200 6480 6312 168 27 8100
1/2 16200 7200 7032 168 25 8100
3/5 16200 9720 9552 168 18 8100
2/3 16200 10800 10632 168 15 8100
3/4 16200 11880 11712 168 12 8100
4/5 16200 12600 12432 168 10 8100
5/6 16200 13320 13152 168 8 8100
8/9 16200 14400 14232 168 5 8100

kbch = K − 168 always (BCH t = 12 over GF(2¹⁴) ⇒ 12·14 = 168 parity bits, dvbs2.py). The LDPC parity is a staircase (accumulator) of length N − K, so the codeword is [info | parity] (dvbs2.py).

BBHEADER (10 bytes / 80 bits) as built at dvbs2.py:

Byte Field Value in Rafe Meaning
0 MATYPE-1 0xF0 = 1111 0000 TS(11), single(1), CCM(1), ISSYI 0, NPD 0, roll-off 00 = 0.35
1 MATYPE-2 0x00 input stream id (unused, CCM)
2–3 UPL 0x05E0 = 1504 user-packet length in bits = 188·8
4–5 DFL dfl = kbch − 80 data-field length in bits
6 SYNC 0x47 the TS sync byte value being replaced
7–8 SYNCD (running) bit distance to first sync in the data field
9 CRC-8 crc8(hdr[0:9]) header integrity, poly x⁸+x⁷+x⁶+x⁴+x²+1

PLHEADER — 90 pi/2-BPSK symbols = 26-symbol SOF (Start Of Frame, SOF = 0x18D2E82, 26 bits, dvbs2_tables.py) + 64-symbol PLSC (the coded MODCOD). PL constants (dvbs2_tables.py):

Constant Value
SOF 0x18D2E82 (26 bits, MSB first)
PLSC_SCRAMBLER 0x719d83c953422dfa (64 bits)
RM_G (0x55555555, 0x33333333, 0x0F0F0F0F, 0x00FF00FF, 0x0000FFFF, 0xFFFFFFFF)
SLOT_LEN 90 symbols
PILOT_BLK_LEN 36 symbols
SLOTS_PER_PILOT_BLK 16
BCH_PRIM_POLY_SHORT 0b100000000101011 (GF(2¹⁴))

A short-frame QPSK payload is 8100 symbols = 90 slots. With pilots on, _payload_layout (dvbs2.py) inserts (90−1)//16 = 5 pilot blocks of 36 symbols ⇒ 8280 payload symbols; the PLFRAME is then 90 + 8280 = 8370 symbols.


4. Encode / TX — step by step

4.1 DVB-S transmit (modulate_ts, dvbs.py)

Input: TS bytes, length a multiple of 188. Output: complex baseband IQ at sps samples/symbol.

Step 1 — Energy dispersal (randomise), randomize dvbs.py. The randomiser removes long runs (DC content) so the demodulator's clock/carrier loops stay locked. It is a 15-bit LFSR, polynomial x¹⁵ + x¹⁴ + 1:

b   = ((reg >> 13) ^ (reg >> 14)) & 1          # dvbs.py
reg = ((reg << 1) | b) & 0x7FFF
  • The register is initialised reg = 0o251 (= 169 = 000000010101001, the standard seed 100101010000000 loaded bit-reversed — the leansdr convention, dvbs.py). The first eight PRBS output bits are 0,0,0,0,0,0,1,1 = the canonical 0x03 (asserted at test_datv.py).
  • Packets are grouped in 8. Within a group, packet 0's sync byte is not randomised but is inverted 0x47 → 0xB8 (dvbs.py) to mark the group start; packets 1–7 keep their 0x47 sync un-randomised. All 187 non-sync bytes of every packet are XORed with the PRBS, the LFSR running continuously across the group (dvbs.py, using PRBS byte index gpos·188 + i − 1). The stage is involutive: derandomize just re-applies randomize and restores the 0x47 syncs (dvbs.py).

Step 2 — Reed–Solomon(204,188), rs204_encode dvbs.py. A shortened RS(255,239) over GF(2⁸): 51 leading zero symbols are prepended conceptually, giving (204,188) with 16 parity bytes, correcting t = 8 byte errors. The field uses primitive polynomial 0x11D (α = 2, dvbs.py). The generator polynomial has roots α⁰ … α¹⁵ (first consecutive root b₀ = 0, _rs_genpoly dvbs.py):

g(x) = ∏_{i=0}^{15} (x − α^i)                  # 16 roots

Encoding computes the systematic remainder of the message shifted by 16, appending the 16 parity bytes: pkt188 || rem[0:16] (dvbs.py).

Step 3 — Convolutional (Forney) interleaver, interleave dvbs.py. I = 12 branches, branch i a FIFO of M·i = 17·i bytes. Byte k enters branch k mod 12: branch 0 passes through undelayed, branch 11 is delayed 187 bytes. This spreads a channel burst across many RS codewords so the RS's per-codeword t = 8 budget is enough. The de-interleaver (dvbs.py) uses the complementary delays M·(I−1−i), so the total interleaver + de-interleaver delay is constant at DEINT_DELAY = 12·17·11 = 2244 bytes (dvbs.py). Length is preserved and the 0x47/0xB8 sync bytes ride branch 0 undelayed, which is what lets the receiver find the cadence before de-interleaving.

Step 4 — Convolutional code + puncturing, conv_encode_punctured dvbs.py. The rate-1/2, K = 7, 64-state code with the standard DVB generators, expressed in this LFSR's bit order as G1 = 0o117, G2 = 0o155 (the bit-reversals of the octal 0171/0133 because the newest bit is kept in the LSB, dvbs.py). Two lookup tables _OUT1, _OUT2 give the parity of reg & G1, reg & G2 over all 128 register values (dvbs.py). For each input bit:

reg = ((reg << 1) | b) & 0x7F                  # 7-bit shift register
# emit X = _OUT1[reg] then Y = _OUT2[reg], each gated by the puncture mask

Puncturing drops selected coded bits to raise the rate. The masks (X, Y) per rate (dvbs.py) are:

1/2: X=[1]              Y=[1]
2/3: X=[1,0]            Y=[1,1]
3/4: X=[1,0,1]          Y=[1,1,0]
5/6: X=[1,0,1,0,1]      Y=[1,1,0,1,0]
7/8: X=[1,0,0,0,1,0,1]  Y=[1,1,1,1,0,1,0]

The kept bits form one serial stream (X before Y within each trellis step) that is then split alternately into I-bits and Q-bits (dvbs.py).

Step 5 — QPSK map, qpsk_map dvbs.py. Symbol index (X << 1) | Y selects from _QPSK = [1+1j, 1−1j, −1+1j, −1−1j]/√2 (Gray, dvbs.py): the X bit sets the I sign, the Y bit the Q sign.

Step 6 — Pulse shaping, rrc_taps dvbs.py. Symbols are up-sampled by sps (zero-stuffed) and convolved with a unit-energy root-raised-cosine filter, roll-off α = 0.35, span ±10 symbols (dvbs.py). The closed-form taps (with the two removable singularities handled at t = 0 and |4αt| = 1) are normalised h ← h/√Σh².

4.2 DVB-S2 transmit (Dvbs2Modulator, dvbs2.py)

Input: TS bytes; construction parameters rate (∈ the 10 short-frame rates) and pilots (bool). Output: a list of PLFRAME symbol arrays (frames_for_ts dvbs2.py), or continuous IQ (iq dvbs2.py, default sps = 2).

Step 1 — Mode adaptation → BBFRAME, build_bbframe dvbs2.py. TS packets are turned into a continuous stream in which each packet's sync byte 0x47 is replaced by the CRC-8 of the preceding packet's 187 bytes (dvbs2.py; crc8_bb poly x⁸+x⁷+x⁶+x⁴+x²+1, feedback constant 0xD5, dvbs2.py). A dfl = kbch − 80-bit slice of this stream becomes the DATA FIELD. The 10-byte BBHEADER of §3.2 is prepended (with its own CRC-8 over the first 9 bytes, dvbs2.py), giving exactly kbch bits.

Step 2 — BB scrambling, bb_scramble dvbs2.py. The whole BBFRAME is XORed with a PRBS from a 15-bit LFSR, register seeded all-ones 0x7FFF, same polynomial family 1 + x¹⁴ + x¹⁵ as DVB-S (dvbs2.py). This stage is also involutive (used to de-scramble on RX).

Step 3 — BCH encode, bch_encode dvbs2.py. A t = 12 binary BCH code over GF(2¹⁴) with primitive polynomial BCH_PRIM_POLY_SHORT = 0b100000000101011 (dvbs2_tables.py). The generator polynomial _BCH_G is built once at import (_bch_genpoly dvbs2.py) as the product over GF(2) of the distinct minimal polynomials of α¹ … α²⁴ — walking each conjugacy class {j, 2j, 4j, …} mod (2¹⁴−1) and multiplying the resulting binary-coefficient minimal polynomials together. The result is asserted to have degree exactly 168 (dvbs2.py). Encoding is systematic: shift the kbch info bits up by 168, take the remainder mod _BCH_G, append (dvbs2.py). Output length = kbch + 168 = K.

Step 4 — LDPC encode, Ldpc.encode dvbs2.py. The DVB-S2 irregular repeat-accumulate LDPC. The parity-check structure is the Annex-C parity-address tables in dvbs2_tables.py: for each group of 360 information bits, one tuple of "addresses" says which parity accumulators that group's bits toggle, the address of bit j in the group being (a + j·q) mod (N−K) (dvbs2.py). After all information bits are accumulated, a running XOR (np.bitwise_xor.accumulate, dvbs2.py) forms the staircase parity. Codeword = [info | parity], length N = 16200.

Step 5 — QPSK map. Coded bits are paired (even, odd) into indices and mapped through the same Gray _QPSK_S2 = [1+1j, 1−1j, −1+1j, −1−1j]/√2 (dvbs2.py): 8100 symbols.

Step 6 — Physical-Layer framing, frames_for_ts dvbs2.py.

  • PLHEADER (_plheader dvbs2.py): the 26-bit SOF and a 7-bit PLS field PLS = (MODCOD << 2) | 0b10 | pilots (dvbs2.py) — MODCOD is the standard QPSK numbering 1/4→1 … 8/9→10 (_MODCOD dvbs2.py), the 10 marks a short frame, the low bit signals pilots. PLS is Reed–Muller/Walsh coded to 64 bits (_rm_encode, §6.4) and the 90 header bits (26 SOF + 64 PLSC) are mapped pi/2-BPSK (_pi2_bpsk dvbs2.py: even index → ±(1+j)/√2, odd → ±(−1+j)/√2).
  • Pilots (if enabled): the 8100-symbol payload is chopped into runs of SLOTS_PER_PILOT_BLK·SLOT_LEN = 16·90 = 1440 symbols, and a 36-symbol pilot block of the constant (1+j)/√2 is inserted between runs (dvbs2.py).
  • PL scrambling (_scramble_seq dvbs2.py): the payload (header excluded) is multiplied by exp(j·rₙ·π/2), where rₙ ∈ {0,1,2,3} comes from the DVB-S2 Gold-code generator _gold_sequence (dvbs2.py) with two 18-bit LFSRs x/y (taps 0x8050/0x0081 and 0x04A1/0xFF60, dvbs2.py), gold code index 0.
  • Final PLFRAME = [PLHEADER | scrambled payload] (dvbs2.py). Concatenated frames are optionally RRC-shaped exactly as DVB-S (iq reuses dvbs.rrc_taps, dvbs2.py).

5. Decode / RX — step by step

Both demodulators run batch over a captured IQ chunk (datv_mode.py feeds CHUNK_S = 2 s blocks). The front end is shared.

5.1 Shared front end (dvbs.py)

  1. RRC matched filter — convolve the IQ with the same rrc_taps (dvbs.py).
  2. Coarse frequency, _coarse_freq dvbs.py — QPSK has a 4-fold phase ambiguity, so raise the signal to the 4th power (which collapses the four constellation points onto one tone at 4·f_offset), FFT up to 2¹⁸ samples, take the peak, divide by 4 → cycles/sample; de-rotate (dvbs.py).
  3. Symbol timing — pick the polyphase sub-stream z[p::sps] of maximum mean power (dvbs.py); normalise to unit average magnitude.
  4. Phase tracking, _track_phase dvbs.py — a decision-directed 2nd-order QPSK PLL: hard-decide each symbol to the nearest QPSK point, error = angle(v·conj(dec)), loop gains α = 0.05, β = 0.002 (dvbs.py).

5.2 DVB-S receive (demodulate_iq, dvbs.py)

Because DVB-S carries no header, the receiver brute-forces the FEC sync (dvbs.py). It tries every combination of

  • conjugation ∈ {no, yes} (spectral-inversion / I-Q swap ambiguity),
  • carrier rotation ∈ {0, 90, 180, 270°} (QPSK phase ambiguity),
  • serial puncture offset0 … period−1 (align to the puncture pattern), and
  • byte offset0 … 7,

and for each runs the Viterbi decoder, then looks for a valid TS. Per candidate:

  1. Viterbi depuncture + decode, viterbi_depunctured dvbs.py — the soft I/Q bit metrics are re-interleaved into the punctured serial stream, zeros inserted at punctured positions (erasures), and a full 64-state soft-decision Viterbi with per-step traceback recovers the information bits. Branch metrics use the sign-mapped generator tables sgn1/sgn2 (dvbs.py); survivors are normalised each step to avoid overflow (dvbs.py).
  2. Sync search + FEC, _sync_and_decode dvbs.py — scan for the 0x47/0xB8 byte appearing at 204-byte spacing (≥ 8 of 10 hits, dvbs.py); the sync bytes survive at this cadence because they ride the undelayed interleaver branch. De-interleave (dvbs.py), skip the first DEINT_DELAY-rounded 204-byte block of garbage (dvbs.py), then RS-decode each 204-byte codeword.
  3. RS decode, rs204_decode dvbs.py — syndromes over the 16 roots → Berlekamp–Massey (dvbs.py) → Chien search for error locations (dvbs.py) → Forney magnitudes with the b₀ = 0 correction (dvbs.py). Returns (188 bytes, n_corrected) or (None, −1) if it detects more than t = 8 errors.
  4. De-randomise, derandomize dvbs.py — re-align the 8-packet group on the first 0xB8 sync (dvbs.py) and undo the PRBS, restoring plain TS.

The first candidate that yields ≥ 2 packets of valid TS wins and is returned.

5.3 DVB-S2 receive (Dvbs2Demodulator.demod_symbols, dvbs2.py)

DVB-S2 is self-describing, so instead of brute force it hunts the SOF:

  1. SOF correlation (dvbs2.py) — slide the known 26-symbol SOF reference over the stream; a magnitude ≥ 0.75·26 is a hit, and the correlation's phase angle(c) is the coherent reference used to de-rotate the header (this is what disambiguates the two complementary PLSC codewords).
  2. PLSC decode (dvbs2.py) — de-rotate the 64 PLSC symbols and correlate against all 128 possible Reed–Muller codewords (_rm_encode(pls)); the best match's pls gives MODCOD, the short-frame flag, and the pilot flag.
  3. Layout & de-scramble (dvbs2.py) — from MODCOD pick the LDPC rate, compute payload length via _payload_layout, de-rotate and multiply by the conjugate Gold scrambling sequence, and strip pilot blocks if present.
  4. LDPC decode, Ldpc.decode dvbs2.pynormalised min-sum belief propagation. QPSK LLRs are 2·Re and 2·Im (positive = bit 0, dvbs2.py). Each of up to 30 iterations does a variable→check pass, a min-sum check update (product of signs × the two smallest magnitudes, scaled 0.75, dvbs2.py), then a hard decision; it stops early when all parity checks pass (_check dvbs2.py).
  5. BCH decode, bch_decode dvbs2.py — syndromes over α¹…α²⁴, Berlekamp–Massey over GF(2¹⁴) (dvbs2.py), vectorised Chien search (dvbs2.py), corrects ≤ 12 bit errors → the kbch-bit BBFRAME.
  6. BB de-scramble + parse, bb_scramble / parse_bbframe (dvbs2.py) — undo the BB PRBS, verify the header CRC-8, read DFL and SYNCD.
  7. TS reassembly, TsExtractor.feed dvbs2.py — use SYNCD to find the first packet boundary in the data field, then emit 188-byte packets with the 0x47 sync byte restored (dvbs2.py).

demod_iq (dvbs2.py) wraps this with the shared RRC/coarse-freq/polyphase front end.


6. Constants & tables

6.1 The two LFSRs (DVB-S randomiser & DVB-S2 BB scrambler)

Both are 15-bit, polynomial x¹⁵ + x¹⁴ + 1, differing only in seed:

seed reset file
DVB-S energy dispersal 0o251 (= 100101010000000 reversed) per 8-packet group dvbs.py
DVB-S2 BB scrambler 0x7FFF (all ones) per BBFRAME dvbs2.py

Bit update (identical): b = ((reg>>13) ^ (reg>>14)) & 1; reg = ((reg<<1)|b) & 0x7FFF.

6.2 Reed–Solomon(204,188) — the DVB-S outer code

  • Field GF(2⁸), primitive polynomial 0x11D = x⁸+x⁴+x³+x²+1, α = 2 (dvbs.py). Exp/log tables built by the standard doubling loop.
  • Generator: 16 consecutive roots α⁰…α¹⁵, b₀ = 0 (_rs_genpoly dvbs.py): g(x) = ∏(x − α^i), i = 0…15. Shortened from RS(255,239) — 51 virtual leading zeros (dvbs.py).
  • Corrects t = 8 byte errors. Decoder: syndromes → Berlekamp–Massey → Chien → Forney (b₀=0). Verified to correct exactly 8 errors and to fail cleanly at 11 (test_datv.py).

6.3 Convolutional code + puncture patterns — the DVB-S inner code

  • K = 7, 64 states, generators G1 = 171₈, G2 = 133₈. Stored bit-reversed as G1 = 0o117, G2 = 0o155 because this LFSR keeps the newest bit in the LSB (dvbs.py).
  • Output tables _OUT1[r] = parity(r & G1), _OUT2[r] = parity(r & G2) over r = 0…127 (dvbs.py).
  • Puncture masks: the five (X, Y) pairs of PUNCTURE (dvbs.py, reproduced verbatim in §4.1). Puncture period in bits = sum(X) + sum(Y) (dvbs.py).

6.4 DVB-S2 LDPC / BCH / PL tables (dvbs2_tables.py)

  • LDPC parity-address tables LDPC_SHORT — the EN 302 307-1 Annex C short-FECFRAME (16 200-bit) tables for all 10 code rates. This is the largest table in the modem. It is made self-contained here by an exact generation recipe, the encode algorithm that consumes it, and per-rate anchor rows — enough to rebuild the full table and checksum it without any source file.

    Table format. For a given rate the table is a list of K/360 rows, one per group of 360 consecutive information bits. Each row is a tuple of parity addresses — the check-node indices that the first bit of that 360-bit group toggles. Per-rate N, K, kbch and q = (N−K)/360 are in the §3.2 table.

    How the table is used (encode, dvbs2.py Ldpc.encode). Parity is an accumulator array of length N − K, initially zero. For information-bit group g (g = 0 … K/360−1) and within-group offset j (j = 0 … 359), information bit g·360 + j toggles parity accumulator (a + j·q) mod (N−K) for every address a in row g. Once every information bit is accumulated, a running XOR along the parity array turns it into the DVB-S2 staircase:

    parity = [0] * (N - K)
    for g, row in enumerate(rows):                 # rows = LDPC_SHORT[rate]['rows']
        for j in range(360):
            if info[g * 360 + j]:
                for a in row:
                    parity[(a + j * q) % (N - K)] ^= 1
    for i in range(1, N - K):                       # staircase accumulate
        parity[i] ^= parity[i - 1]
    codeword = info + parity                        # length N = 16200

    How the table is generated (transcription recipe, scripts/gen_dvbs2_constants.py). The published Annex-C table for each rate is held in gr-dvbs2rx's lib/dvb_s2_tables.hh as a struct DVB_S2_TABLE_C1 … C10 (C1↔︎1/4, C2↔︎1/3, C3↔︎2/5, C4↔︎1/2, C5↔︎3/5, C6↔︎2/3, C7↔︎3/4, C8↔︎4/5, C9↔︎5/6, C10↔︎8/9), each with three scalars M, N, K and three flat integer arrays DEG[], LEN[], POS[]: DEG[b] is a row degree (address count), LEN[b] the number of consecutive rows having that degree, and POS[] the concatenated addresses. Rows are expanded exactly thus:

    rows, pos = [], 0
    for deg, cnt in zip(DEG, LEN):     # each (degree, how-many-rows-of-it) band
        if deg == 0:
            continue
        for _ in range(cnt):
            rows.append(tuple(POS[pos:pos + deg]))
            pos += deg
    assert N == 16200
    assert pos == len(POS)             # every address consumed
    assert len(rows) * 360 == K        # structural self-check on the transcription

    (kbch per rate is taken from EN 302 307 — the (rate, C-name, kbch) list in the generator — and equals K − 168 for all rates.) Regeneration from the reference:

    git clone --depth 1 https://github.com/igorauad/gr-dvbs2rx refs/dvbs2rx_src
    python scripts/gen_dvbs2_constants.py     # emits app/radio/datv/dvbs2_tables.py

    Anchor rows. After expansion each rate has exactly K/360 rows, arranged in DEG/LEN bands of equal degree: a leading high-degree band (row 0 has 8 addresses for 1/2, 13 for 2/3 and 5/6, 12 for the other high-rate cases) then a band of degree-3 rows — except 4/5, which is uniformly degree 3, and 8/9, which is uniformly degree 4. Check a transcription against the first and last row of each rate:

    Rate rows (=K/360) row 0 (first) last row
    1/4 9 (6295, 9626, 304, 7695, 4839, 4936, 1660, 144, 11203, 5567, 6347, 12557) (9840, 12726, 4977)
    1/3 15 (416, 8909, 4156, 3216, 3112, 2560, 2912, 6405, 8593, 4969, 6723, 6912) (10127, 3334, 8267)
    2/5 18 (5650, 4143, 8750, 583, 6720, 8071, 635, 1767, 1344, 6922, 738, 6658) (1387, 8910, 2660)
    1/2 20 (20, 712, 2386, 6354, 4061, 1062, 5045, 5158) (14, 7411, 3450)
    3/5 27 (2765, 5713, 6426, 3596, 1374, 4811, 2182, 544, 3394, 2840, 4310, 771) (17, 4908, 4177)
    2/3 30 (0, 2084, 1613, 1548, 1286, 1460, 3196, 4297, 2481, 3369, 3451, 4620, 2622) (14, 1129, 3894)
    3/4 33 (3, 3198, 478, 4207, 1481, 1009, 2616, 1924, 3437, 554, 683, 1801) (11, 1415, 2808)
    4/5 35 (5, 896, 1565) (9, 3545, 1168)
    5/6 37 (3, 2409, 499, 1481, 908, 559, 716, 1270, 333, 2508, 2264, 1702, 2805) (7, 2644, 1704)
    8/9 40 (0, 1558, 712, 805) (4, 1104, 1172)

    For a given rate, the whole N × (N−K) parity-check matrix is defined by that rate's K/360 address rows (264 rows across all ten rate tables) plus the two-diagonal staircase parity part — no dense matrix is ever stored.

  • BCH: primitive polynomial BCH_PRIM_POLY_SHORT = 0b100000000101011 (GF(2¹⁴), dvbs2_tables.py); the degree-168 generator is computed, not stored, from the minimal polynomials of α¹…α²⁴ (dvbs2.py).

  • Physical-layer constants (dvbs2_tables.py): SOF = 0x18D2E82, PLSC_SCRAMBLER = 0x719d83c953422dfa, RM_G (6 Reed–Muller basis words), SLOT_LEN = 90, PILOT_BLK_LEN = 36, SLOTS_PER_PILOT_BLK = 16. The PLSC coder _rm_encode (dvbs2.py) XORs the RM_G rows selected by the 6 MSB of PLS to form a 32-bit word y, then emits 64 bits by interleaving y with y ⊕ b₇ (b₇ = PLS LSB) and finally XORs the 64-bit PLSC_SCRAMBLER — this is exactly the Reed–Muller (first-order, length-64 Walsh–Hadamard) MODCOD code of EN 302 307-1 §5.5.2.4.

  • QPSK constellation (both layers): [1+1j, 1−1j, −1+1j, −1−1j]/√2, Gray, index (bit0<<1)|bit1 (dvbs.py, dvbs2.py).


7. Interoperability & validation

The regression suite test_datv.py exercises both chains:

DVB-S unit + loopback (always run):

  • test_rs204_roundtrip_and_correction — RS encode/decode, corrects 8 injected errors, fails cleanly at 11 (test_datv.py).
  • test_interleaver_roundtrip — de-interleave(interleave(x)) recovers x after the 2244-byte delay (test_datv.py).
  • test_randomizer_involutive_and_first_byte — randomiser is its own inverse and its first PRBS byte is 0x03 (test_datv.py).
  • test_viterbi_all_rates — the punctured convolutional code decodes at all five rates (test_datv.py).
  • test_iq_loopback_with_noise_and_phase — full modulate → add phase offset 0.7 rad
    • AWGN → demodulate recovers ≥ 6 valid TS packets (test_datv.py).

DVB-S interop oracle (leansdr/leandvb): test_interop_leandvbtx_to_native (test_datv.py) runs the reference leandvbtx --cr <rate> -f 4 --f32 transmitter over the same TS, then feeds its float32 IQ to Rafe's demodulate_iq, asserting ≥ 24 valid packets at rates 1/2 and 3/4. This proves the Rafe receiver is bit-compatible with a real, independent DVB-S transmitter — the randomiser seed convention, RS field, interleaver, puncture patterns and QPSK Gray mapping all match the reference. The oracle binaries are optional; if refs/leansdr_src/src/apps/leandvbtx is absent the test no-ops (test_datv.py) while the pure-Python loopbacks still run. The randomiser's leansdr-compatible reversed seed (dvbs.py) is the one place this interoperability is explicitly designed in.

DVB-S2 unit + loopback:

  • test_s2_bch_correction — BCH corrects exactly 12 injected errors (test_datv.py).
  • test_s2_ldpc_all_rates — LDPC encode passes the parity check and min-sum decode recovers the info bits under AWGN at rates 1/4, 1/2, 3/4, 8/9 (test_datv.py).
  • test_s2_frame_loopback — full BBFRAME→BCH→LDPC→QPSK→PLFRAME → phase offset + AWGN → demod_symbols recovers all TS packets, with and without pilots (test_datv.py).

App integration (datv_mode.py): DatvReceiver opens an SDR source (RTL-SDR / LimeSDR, like native AIS) at SR·SPS (datv_mode.py), reads 2-second chunks, routes to dvbs.demodulate_iq or dvbs2.demod_iq by the DATV_STANDARD env var (datv_mode.py), appends recovered packets to a timestamped .ts file, and every 10 s shells out to ffmpeg for a single JPEG snapshot — a separate process per the NOTICE policy, used only for the picture, never for the protocol (datv_mode.py). Defaults: 437 MHz, 333 kS/s, DVB-S2, rate 1/2, SPS = 4 (datv_mode.py).


8. Limitations and future work

This is an honest, working subset of two very large standards. Known gaps, roughly in order of impact:

  • DVB-S2 modulation is QPSK-only. The 8PSK / 16APSK / 32APSK constellations (and their MODCODs) that DVB-S2 defines are not implemented (dvbs2.py). Only QPSK is mapped. This covers the common QO-100 amateur configuration but not high-SNR modes.
  • DVB-S2 short FECFRAME (16200) only. The normal 64 800-bit FECFRAME — the more common broadcast length, with its own Annex-B LDPC tables — is not built; only LDPC_SHORT is present.
  • CCM only, single input stream. The BBHEADER is hard-coded to TS / single / CCM / roll-off 0.35 (MATYPE 0xF0 0x00, dvbs2.py). No ACM/VCM, no multiple input streams, no generic-stream (GSE) mode.
  • Batch, not streaming. Both receivers decode a whole IQ chunk at once. There is no continuous carrier/timing tracking loop across chunks; DVB-S2's within-frame drift relies on the per-frame SOF-coherent phase and would need the pilot-aided fine tracker for large drift (dvbs2.py calls this out as roadmap).
  • DVB-S sync is brute force. The receiver tries conj × 4 rotations × puncture offset × 8 byte offsets and Viterbi-decodes each (dvbs.py). Correct, but O(candidates) — fine for the small amateur chunk sizes, not for wideband broadcast rates.
  • LDPC decoder is normalised min-sum, 30 iterations, 0.75 scale (dvbs2.py) — a good, cheap approximation to sum-product, but not the layered / offset-min-sum schedules a production receiver would use for the last fraction of a dB.
  • No physical transmit path wired to the radio. modulate_ts / Dvbs2Modulator.iq produce standards-shaped IQ, exercised in loopback and (DVB-S) against the leansdr oracle, but the app's DatvReceiver is receive-only.

None of these are architectural; each is a bounded extension of a working base.


9. Implementation & reproduction

file role
app/radio/datv/dvbs.py DVB-S full chain: randomiser, RS(204,188), interleaver, conv+puncture, QPSK, RRC, RX front end + Viterbi + sync
app/radio/datv/dvbs2.py DVB-S2: BBFRAME, BCH, LDPC (encode + min-sum decode), PLFRAME/PLHEADER, PL scrambler, modem
app/radio/datv/dvbs2_tables.py Annex-C LDPC parity-address tables + PL constants (auto-generated)
app/radio/datv_mode.py DatvReceiver — SDR → TS file + ffmpeg snapshot app mode
scripts/gen_dvbs2_constants.py offline generator of dvbs2_tables.py from gr-dvbs2rx
test_datv.py regression + leansdr interop tests

Runtime API sketch:

from app.radio.datv import dvbs, dvbs2

# DVB-S
iq  = dvbs.modulate_ts(ts_bytes, rate="1/2", sps=4)   # TS -> IQ
ts  = dvbs.demodulate_iq(iq, sps=4, rate="1/2")       # IQ -> TS

# DVB-S2 (short FECFRAME, QPSK)
mod   = dvbs2.Dvbs2Modulator(rate="1/2", pilots=False)
iq    = mod.iq(ts_bytes, sps=2)                        # TS -> IQ
pkts  = dvbs2.demod_iq(iq, sps=2)                      # IQ -> list of 188-byte TS

Everything at runtime is NumPy. The only transcribed artefact is dvbs2_tables.py, regenerated from the published Annex-C tables exactly as the LoRa / Meshtastic constant tables were transcribed once and committed (§6.4).


10. References

  1. ETSI EN 300 421Digital Video Broadcasting (DVB); Framing structure, channel coding and modulation for 11/12 GHz satellite services (DVB-S). The randomiser (x¹⁵+x¹⁴+1, 8-packet group), RS(204,188), Forney interleaver (I=12, M=17), K=7 convolutional code (171/133 octal) with puncturing, and QPSK.
  2. ETSI EN 302 307-1DVB; Second generation framing structure, channel coding and modulation for … satellite … (DVB-S2). BBFRAME / MATYPE, BCH + LDPC, the Annex-B/C code tables, PLFRAME / PLHEADER (SOF + Reed–Muller PLSC), pilots, and the PL Gold-code scrambler.
  3. F. Bernard / P. Crussière — leansdr / leandvb (leandvbtx, leandvb): the open-source DVB-S transmit/receive reference used as Rafe's DVB-S interop oracle (§7); source of the reversed-seed randomiser convention.
  4. igorauad — gr-dvbs2rx (lib/dvb_s2_tables.hh): the machine-readable copy of the EN 302 307-1 Annex-C LDPC tables that gen_dvbs2_constants.py parses.
  5. R. Gallager, Low-Density Parity-Check Codes, 1962 — the LDPC codes DVB-S2 adopted; decoded here by normalised min-sum belief propagation.
  6. G. D. Forney, Convolutional interleavers and the Viterbi algorithm — the DVB-S inner-code decoder and byte interleaver.