orion_sdr/fec/conv.rs
1// Copyright (c) 2026 G & R Associates LLC
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4// src/fec/conv.rs
5//
6// Punctured convolutional coding for the inner FEC stage. Two mother codes are
7// supported, selected by [`ConvCode`]:
8//
9// • [`ConvCode::K5`] — the crate's original rate-1/2, constraint-length-5 code
10// (generators G0 = 0o25, G1 = 0o23), built on `codec::conv_encode`. This is
11// the default and is byte-identical to the pre-K7 behavior.
12// • [`ConvCode::DvbK7`] — DVB-T's rate-1/2, constraint-length-7 inner code
13// (generators G0 = 0o171, G1 = 0o133, ETSI EN 300 744 §4.3.3). Needed for a
14// conformant DVB-T payload.
15//
16// On top of the mother code this module adds:
17//
18// • zero-tail termination — K-1 zero bits appended before encoding so the
19// trellis ends in the all-zero state, giving a clean per-frame block Viterbi
20// traceback (the streaming PSK31 decoder is fixed-lag; a frame code wants
21// block termination). K-1 = 4 for K5, 6 for K7.
22// • puncturing — deleting coded bits per a fixed per-rate matrix to raise the
23// rate from 1/2 to 2/3, 3/4, 5/6, or 7/8. The decoder reinserts an erasure
24// (LLR = 0, i.e. "no information") at each punctured position before the
25// Viterbi ACS. The puncture patterns are shared by both mother codes (the
26// standard DVB/802.11 patterns derived from a rate-1/2 code).
27//
28// The decoder is a soft-input (LLR-domain) Viterbi: the existing
29// `codec::psk31::viterbi_decode` metric is hardwired to the DQPSK constellation
30// and cannot consume `OfdmSoftDemod`'s per-bit LLRs. Here the branch metric is
31// the LLR-correlation `Σ (1 - 2·c) · llr` over the branch's coded bits (LLR
32// convention: positive ⇒ bit 0), maximized along the surviving path. It is
33// generic over the mother code via a small [`ConvCode`] descriptor; the K5 path
34// stays bit-identical to the original hand-rolled implementation.
35
36use crate::codec::conv_encode;
37
38/// Selects the convolutional mother code. Both are rate-1/2, zero-tail
39/// terminated, and share the puncture matrices in [`PunctureRate`].
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
41pub enum ConvCode {
42 /// The crate's original constraint-length-5 code, G0 = 0o25, G1 = 0o23.
43 /// Also the code PSK31 uses (`codec::conv_encode`). Default for backward
44 /// compatibility.
45 #[default]
46 K5,
47 /// DVB-T's constraint-length-7 code, G0 = 0o171, G1 = 0o133
48 /// (ETSI EN 300 744 §4.3.3). The conformant DVB-T inner code.
49 DvbK7,
50}
51
52impl ConvCode {
53 /// Constraint length K (register width is K − 1).
54 #[inline]
55 pub const fn constraint_length(self) -> usize {
56 match self {
57 ConvCode::K5 => 5,
58 ConvCode::DvbK7 => 7,
59 }
60 }
61
62 /// Register width in bits, K − 1 (also the number of zero tail bits).
63 #[inline]
64 pub const fn reg_bits(self) -> usize {
65 self.constraint_length() - 1
66 }
67
68 /// Number of trellis states, `2^(K−1)`.
69 #[inline]
70 pub const fn num_states(self) -> usize {
71 1usize << self.reg_bits()
72 }
73
74 /// Number of zero tail bits appended to terminate the trellis, `K − 1`.
75 #[inline]
76 pub const fn tail_bits(self) -> usize {
77 self.reg_bits()
78 }
79
80 /// Generator taps (G0, G1) as bit masks over a `K`-bit window whose low
81 /// `K−1` bits are the register and whose top bit is the current input.
82 ///
83 /// K5: G0 = 0o25 = 0b10101, G1 = 0o23 = 0b10011 (matches
84 /// `codec::conv_encode`). K7: G0 = 0o171 = 0b1111001, G1 = 0o133 =
85 /// 0b1011011 (DVB-T). The MSB of each generator is the input tap and the
86 /// LSB the oldest register bit, so the window packs `(input << (K-1)) |
87 /// register`.
88 #[inline]
89 const fn generators(self) -> (u16, u16) {
90 match self {
91 ConvCode::K5 => (0b10101, 0b10011),
92 ConvCode::DvbK7 => (0b1111001, 0b1011011),
93 }
94 }
95}
96
97/// Convolutional puncturing rate (numerator/denominator of the code rate).
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum PunctureRate {
100 /// The unpunctured mother code.
101 R1_2,
102 R2_3,
103 R3_4,
104 R5_6,
105 R7_8,
106}
107
108impl PunctureRate {
109 /// The puncturing matrix, as two rows (one per generator output G0/G1) of a
110 /// fixed period. A `1` keeps the coded bit; a `0` deletes it. Standard
111 /// DVB/802.11-style patterns derived from a rate-1/2 mother code.
112 fn matrix(self) -> (&'static [u8], &'static [u8]) {
113 match self {
114 PunctureRate::R1_2 => (&[1], &[1]),
115 PunctureRate::R2_3 => (&[1, 1], &[1, 0]),
116 PunctureRate::R3_4 => (&[1, 1, 0], &[1, 0, 1]),
117 PunctureRate::R5_6 => (&[1, 1, 0, 1, 0], &[1, 0, 1, 0, 1]),
118 PunctureRate::R7_8 => (&[1, 1, 1, 1, 0, 1, 0], &[1, 0, 0, 0, 1, 0, 1]),
119 }
120 }
121
122 /// Puncture period (columns in the matrix).
123 fn period(self) -> usize {
124 self.matrix().0.len()
125 }
126
127 /// Coded bits kept per period (matrix ones).
128 fn kept_per_period(self) -> usize {
129 let (g0, g1) = self.matrix();
130 g0.iter().chain(g1.iter()).filter(|&&x| x == 1).count()
131 }
132}
133
134/// Number of tail bits appended to terminate the K = 5 trellis. Retained for
135/// the K5 public API; K7 uses [`ConvCode::tail_bits`].
136pub const TAIL_BITS: usize = 4;
137
138/// Coded bits `(c0, c1)` emitted when register state `s` takes input bit `b`
139/// under mother code `code`.
140#[inline]
141fn branch_bits(code: ConvCode, s: u16, b: u8) -> (u8, u8) {
142 let (g0, g1) = code.generators();
143 let window = (((b & 1) as u16) << code.reg_bits()) | (s & reg_mask(code));
144 (parity(window & g0), parity(window & g1))
145}
146
147/// Next register state after `s` receives input `b`: shift right, insert `b` at
148/// the top register bit.
149#[inline]
150fn next_state(code: ConvCode, s: u16, b: u8) -> u16 {
151 (s >> 1) | (((b & 1) as u16) << (code.reg_bits() - 1))
152}
153
154/// Low `reg_bits` mask for the register.
155#[inline]
156fn reg_mask(code: ConvCode) -> u16 {
157 (1u16 << code.reg_bits()) - 1
158}
159
160#[inline]
161fn parity(x: u16) -> u8 {
162 (x.count_ones() & 1) as u8
163}
164
165/// One trellis branch, resolved once per decode instead of once per visit.
166#[derive(Clone, Copy)]
167struct Branch {
168 /// The branch's coded pair packed as `(c0 << 1) | c1`, which indexes the
169 /// four correlations any branch can contribute at a given step.
170 sym: u8,
171 /// The state this branch leads to.
172 next: u16,
173}
174
175/// The whole trellis for `code`: `num_states · 2` branches, indexed
176/// `state * 2 + bit`.
177///
178/// **Why this is precomputed.** [`branch_bits`] and [`next_state`] take `code`
179/// as a *runtime* value, so each call re-`match`es the enum for `generators()`,
180/// `reg_bits()` and `reg_mask()` before taking two `count_ones` parities. Called
181/// from inside the ACS loop that is `n_steps · num_states · 2` visits deep, that
182/// costs about half the decoder's throughput — measured, when the coder was
183/// generalized over `ConvCode` to add the DVB-T K=7 code, as a drop from ~26 to
184/// ~13.6 Msps at rate 1/2 with bit-identical output. Resolving every branch once
185/// up front is 32 entries for K=5 and 128 for K=7, against the tens of thousands
186/// of repeat evaluations it replaces.
187fn branch_table(code: ConvCode) -> Vec<Branch> {
188 let num_states = code.num_states();
189 let mut out = Vec::with_capacity(num_states * 2);
190 for s in 0..num_states as u16 {
191 for b in 0..2u8 {
192 let (c0, c1) = branch_bits(code, s, b);
193 out.push(Branch {
194 sym: (c0 << 1) | c1,
195 next: next_state(code, s, b),
196 });
197 }
198 }
199 out
200}
201
202/// Systematic-free rate-1/2 encode of `bits` (already tail-padded) under `code`,
203/// returning the interleaved `[g0_0, g1_0, g0_1, g1_1, …]` mother-code output.
204/// For [`ConvCode::K5`] this defers to `codec::conv_encode` so the output stays
205/// bit-identical to the original path.
206fn conv_encode_code(code: ConvCode, bits: &[u8]) -> Vec<u8> {
207 if code == ConvCode::K5 {
208 return conv_encode(bits);
209 }
210 let mut out = Vec::with_capacity(bits.len() * 2);
211 let mut state: u16 = 0;
212 for &b in bits {
213 let (c0, c1) = branch_bits(code, state, b);
214 out.push(c0);
215 out.push(c1);
216 state = next_state(code, state, b);
217 }
218 out
219}
220
221/// Encodes `info_bits` with the K5 mother code (zero-tail, punctured). Kept for
222/// backward compatibility; equivalent to
223/// [`conv_encode_punctured_with`]`(ConvCode::K5, …)`.
224pub fn conv_encode_punctured(info_bits: &[u8], rate: PunctureRate) -> Vec<u8> {
225 conv_encode_punctured_with(ConvCode::K5, info_bits, rate)
226}
227
228/// Encodes `info_bits` with mother code `code`, zero-tail termination, and the
229/// given puncture rate, returning the coded (punctured) bit stream.
230///
231/// Layout before puncturing: `encode([info | (K−1) zero tail bits])`, an
232/// interleaved `[g0_0, g1_0, g0_1, g1_1, …]` of length `2·(info + K − 1)`.
233/// Puncturing then deletes bits per the rate matrix.
234pub fn conv_encode_punctured_with(code: ConvCode, info_bits: &[u8], rate: PunctureRate) -> Vec<u8> {
235 let mut padded = info_bits.to_vec();
236 padded.extend(std::iter::repeat_n(0u8, code.tail_bits()));
237 let coded = conv_encode_code(code, &padded);
238 puncture(&coded, rate)
239}
240
241/// Deletes coded bits per the rate's puncture matrix. `coded` is the
242/// interleaved `[g0, g1, g0, g1, …]` mother-code output.
243fn puncture(coded: &[u8], rate: PunctureRate) -> Vec<u8> {
244 if rate == PunctureRate::R1_2 {
245 return coded.to_vec();
246 }
247 let (g0, g1) = rate.matrix();
248 let period = rate.period();
249 let mut out = Vec::with_capacity(coded.len());
250 // Each trellis step contributes a (g0, g1) pair; step t uses matrix column
251 // t % period.
252 let n_steps = coded.len() / 2;
253 for t in 0..n_steps {
254 let col = t % period;
255 if g0[col] == 1 {
256 out.push(coded[t * 2]);
257 }
258 if g1[col] == 1 {
259 out.push(coded[t * 2 + 1]);
260 }
261 }
262 out
263}
264
265/// Number of coded bits [`conv_encode_punctured`] produces for `info_bits`
266/// information bits at `rate` under the K5 code.
267pub fn punctured_coded_len(info_bits: usize, rate: PunctureRate) -> usize {
268 punctured_coded_len_with(ConvCode::K5, info_bits, rate)
269}
270
271/// Number of coded bits [`conv_encode_punctured_with`] produces for `info_bits`
272/// information bits at `rate` under mother code `code` (deterministic; used by
273/// the frame layer's size bookkeeping).
274pub fn punctured_coded_len_with(code: ConvCode, info_bits: usize, rate: PunctureRate) -> usize {
275 let n_steps = info_bits + code.tail_bits(); // mother code emits 2 bits/step
276 if rate == PunctureRate::R1_2 {
277 return n_steps * 2;
278 }
279 let period = rate.period();
280 let full_periods = n_steps / period;
281 let rem = n_steps % period;
282 let (g0, g1) = rate.matrix();
283 let mut len = full_periods * rate.kept_per_period();
284 for col in 0..rem {
285 len += (g0[col] + g1[col]) as usize;
286 }
287 len
288}
289
290/// Soft-decision Viterbi decode of a K5, punctured, zero-tail-terminated
291/// stream. Kept for backward compatibility; equivalent to
292/// [`viterbi_decode_soft_with`]`(ConvCode::K5, …)`.
293pub fn viterbi_decode_soft(coded_llrs: &[f32], info_bits: usize, rate: PunctureRate) -> Vec<u8> {
294 viterbi_decode_soft_with(ConvCode::K5, coded_llrs, info_bits, rate)
295}
296
297/// Soft-decision Viterbi decode of a punctured, zero-tail-terminated stream
298/// under mother code `code`.
299///
300/// `coded_llrs` are the received per-coded-bit LLRs (positive ⇒ bit 0), in the
301/// punctured order. `info_bits` is the number of information bits to recover
302/// (the tail bits are decoded but dropped). Punctured positions are treated as
303/// erasures (LLR 0). Returns the `info_bits` recovered information bits.
304pub fn viterbi_decode_soft_with(
305 code: ConvCode,
306 coded_llrs: &[f32],
307 info_bits: usize,
308 rate: PunctureRate,
309) -> Vec<u8> {
310 let n_steps = info_bits + code.tail_bits();
311 let num_states = code.num_states();
312
313 // Depuncture: rebuild the full 2-per-step LLR stream, inserting 0.0 at
314 // deleted positions.
315 let mut full = vec![0.0f32; n_steps * 2];
316 if rate == PunctureRate::R1_2 {
317 let n = coded_llrs.len().min(full.len());
318 full[..n].copy_from_slice(&coded_llrs[..n]);
319 } else {
320 let (g0, g1) = rate.matrix();
321 let period = rate.period();
322 let mut src = 0usize;
323 for t in 0..n_steps {
324 let col = t % period;
325 if g0[col] == 1 {
326 if src < coded_llrs.len() {
327 full[t * 2] = coded_llrs[src];
328 }
329 src += 1;
330 }
331 if g1[col] == 1 {
332 if src < coded_llrs.len() {
333 full[t * 2 + 1] = coded_llrs[src];
334 }
335 src += 1;
336 }
337 }
338 }
339
340 // Forward ACS. Metrics are correlations to be MAXIMIZED: for a branch with
341 // coded bits (c0, c1), the contribution is `(1-2c0)·llr0 + (1-2c1)·llr1`
342 // (a positive llr favors bit 0, so `(1-2·0)=+1` rewards agreement).
343 let neg_inf = f32::MIN / 2.0;
344 let mut pm = vec![neg_inf; num_states];
345 pm[0] = 0.0; // known start state
346 // The trellis, resolved once (see `branch_table`) rather than re-derived on
347 // every visit.
348 let table = branch_table(code);
349 // Flat survivor table, `prev_state[t * num_states + s]`. This was a
350 // `Vec<Vec<u16>>`, i.e. one heap allocation per trellis step — 516 of them
351 // for a 512-bit block, to hold 16 `u16` each.
352 let mut prev_state = vec![0u16; n_steps * num_states];
353 let top_bit = code.reg_bits() - 1;
354
355 let mut new_pm = vec![neg_inf; num_states];
356 for t in 0..n_steps {
357 let l0 = full[t * 2];
358 let l1 = full[t * 2 + 1];
359 // The only four correlations any branch can contribute at this step,
360 // indexed by the branch's `(c0 << 1) | c1`. Arithmetically identical to
361 // `(1 − 2c0)·l0 + (1 − 2c1)·l1`, which scales by exactly ±1 — so this is
362 // bit-identical, not merely equivalent.
363 let corr = [l0 + l1, l0 - l1, -l0 + l1, -l0 - l1];
364 new_pm.iter_mut().for_each(|m| *m = neg_inf);
365 let row = &mut prev_state[t * num_states..(t + 1) * num_states];
366 for (prev, &pm_prev) in pm.iter().enumerate() {
367 if pm_prev <= neg_inf {
368 continue;
369 }
370 // Bit 0 then bit 1, states ascending — the same visit order as
371 // before, which matters: survivors are kept on a strict `>`, so ties
372 // go to whichever branch is seen first.
373 for br in &table[prev * 2..prev * 2 + 2] {
374 let ns = br.next as usize;
375 let cand = pm_prev + corr[br.sym as usize];
376 if cand > new_pm[ns] {
377 new_pm[ns] = cand;
378 row[ns] = prev as u16;
379 }
380 }
381 }
382 std::mem::swap(&mut pm, &mut new_pm);
383 }
384
385 // With zero-tail termination the ending state is 0.
386 let mut state = 0usize;
387 let mut bits = vec![0u8; n_steps];
388 for t in (0..n_steps).rev() {
389 let prev = prev_state[t * num_states + state] as usize;
390 // The input bit driving prev → state is the top register bit of `state`
391 // (next_state inserts b at bit `reg_bits-1`).
392 bits[t] = ((state >> top_bit) & 1) as u8;
393 state = prev;
394 }
395
396 bits.truncate(info_bits);
397 bits
398}