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:
- The register is initialised
reg = 0o251(= 169 =000000010101001, the standard seed100101010000000loaded bit-reversed — the leansdr convention,dvbs.py). The first eight PRBS output bits are0,0,0,0,0,0,1,1= the canonical 0x03 (asserted attest_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 indexgpos·188 + i − 1). The stage is involutive:derandomizejust re-appliesrandomizeand 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):
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)¶
- RRC matched filter — convolve the IQ with the same
rrc_taps(dvbs.py). - Coarse frequency,
_coarse_freqdvbs.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). - Symbol timing — pick the polyphase sub-stream
z[p::sps]of maximum mean power (dvbs.py); normalise to unit average magnitude. - Phase tracking,
_track_phasedvbs.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 offset ∈
0 … period−1(align to the puncture pattern), and - byte offset ∈
0 … 7,
and for each runs the Viterbi decoder, then looks for a valid TS. Per candidate:
- Viterbi depuncture + decode,
viterbi_depunctureddvbs.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 tablessgn1/sgn2(dvbs.py); survivors are normalised each step to avoid overflow (dvbs.py). - Sync search + FEC,
_sync_and_decodedvbs.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 firstDEINT_DELAY-rounded 204-byte block of garbage (dvbs.py), then RS-decode each 204-byte codeword. - RS decode,
rs204_decodedvbs.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. - De-randomise,
derandomizedvbs.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:
- SOF correlation (
dvbs2.py) — slide the known 26-symbol SOF reference over the stream; a magnitude ≥0.75·26is a hit, and the correlation's phaseangle(c)is the coherent reference used to de-rotate the header (this is what disambiguates the two complementary PLSC codewords). - 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'splsgives MODCOD, the short-frame flag, and the pilot flag. - 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. - LDPC decode,
Ldpc.decodedvbs2.py— normalised min-sum belief propagation. QPSK LLRs are2·Reand2·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 (_checkdvbs2.py). - BCH decode,
bch_decodedvbs2.py— syndromes over α¹…α²⁴, Berlekamp–Massey over GF(2¹⁴) (dvbs2.py), vectorised Chien search (dvbs2.py), corrects ≤ 12 bit errors → thekbch-bit BBFRAME. - BB de-scramble + parse,
bb_scramble/parse_bbframe(dvbs2.py) — undo the BB PRBS, verify the header CRC-8, read DFL and SYNCD. - TS reassembly,
TsExtractor.feeddvbs2.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_genpolydvbs.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 = 0o155because 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 ofPUNCTURE(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 theRM_Grows selected by the 6 MSB of PLS to form a 32-bit wordy, then emits 64 bits by interleavingywithy ⊕ b₇(b₇ = PLS LSB) and finally XORs the 64-bitPLSC_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_SHORTis 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.pycalls 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.iqproduce standards-shaped IQ, exercised in loopback and (DVB-S) against the leansdr oracle, but the app'sDatvReceiveris 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¶
- ETSI EN 300 421 — Digital 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.
- ETSI EN 302 307-1 — DVB; 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.
- 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. - igorauad — gr-dvbs2rx (
lib/dvb_s2_tables.hh): the machine-readable copy of the EN 302 307-1 Annex-C LDPC tables thatgen_dvbs2_constants.pyparses. - R. Gallager, Low-Density Parity-Check Codes, 1962 — the LDPC codes DVB-S2 adopted; decoded here by normalised min-sum belief propagation.
- G. D. Forney, Convolutional interleavers and the Viterbi algorithm — the DVB-S inner-code decoder and byte interleaver. ```