Skip to main content

orion_sdr/demodulate/
ofdm_probe.rs

1// Copyright (c) 2026 G & R Associates LLC
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4// src/demodulate/ofdm_probe.rs
5//
6// The COFDM receive probe: the two per-frame quantities an analyzer's
7// constellation / decoder display needs, exposed opt-in and at zero cost when
8// unused.
9//
10//   1. The equalizer's output — the complex data-carrier symbols exactly as the
11//      demapper saw them (`s_k = r_k / H_k`, after `OfdmEqualizer`, after
12//      common-phase-error removal, before `OfdmSoftDemod`). This is where a
13//      vector signal analyzer takes its constellation, and it is where
14//      `decode_frame_body` already has it: the vector is filled on every frame
15//      to measure EVM and then dropped.
16//
17//   2. A per-coded-bit correction map — for each coded bit, whether the channel
18//      corrupted it and whether the inner decoder fixed it.
19//
20// Both are observations of a decode that happens anyway. Neither changes what
21// decodes; see `probing_does_not_change_what_decodes`.
22
23use crate::modulate::ofdm::ConstellationOrder;
24use num_complex::Complex32 as C32;
25use std::ops::Range;
26
27/// What the channel and the inner decoder each did to one coded bit.
28///
29/// Derived by comparing three bit-streams in the **coded-bit index space** —
30/// what the transmitter sent, what arrived at the demapper, and what the inner
31/// decoder's own output re-encodes to:
32///
33/// | State | arrived correct | decoder agrees | Meaning |
34/// | --- | --- | --- | --- |
35/// | [`Clean`](Self::Clean) | yes | yes | the channel did not touch it |
36/// | [`Corrected`](Self::Corrected) | no | yes | the inner code fixed it |
37/// | [`Uncorrected`](Self::Uncorrected) | no | no | arrived wrong, still wrong — the outer code's problem now |
38/// | [`Introduced`](Self::Introduced) | yes | no | arrived right, the decoder broke it |
39///
40/// `Introduced` is not padding for the fourth cell. A belief-propagation
41/// decoder that fails to converge flips correct bits, and one that does so *at
42/// high SNR* is broken. Having the state means the map can show that; folding
43/// it into `Uncorrected` would hide the symptom in with its opposite.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
45#[repr(u8)]
46pub enum BitOutcome {
47    /// Arrived correct and the decoder left it correct.
48    #[default]
49    Clean = 0,
50    /// Arrived wrong; the inner decoder fixed it.
51    Corrected = 1,
52    /// Arrived wrong and the inner decoder did not fix it.
53    Uncorrected = 2,
54    /// Arrived correct and the inner decoder broke it.
55    Introduced = 3,
56}
57
58impl BitOutcome {
59    /// Classifies one coded bit from the two comparisons that define it.
60    #[inline(always)]
61    pub(crate) fn classify(arrived_correct: bool, decoder_agrees: bool) -> Self {
62        match (arrived_correct, decoder_agrees) {
63            (true, true) => BitOutcome::Clean,
64            (false, true) => BitOutcome::Corrected,
65            (false, false) => BitOutcome::Uncorrected,
66            (true, false) => BitOutcome::Introduced,
67        }
68    }
69
70    /// Whether the channel corrupted this bit — i.e. anything but
71    /// [`Clean`](Self::Clean) or [`Introduced`](Self::Introduced).
72    ///
73    /// Counting these over a frame and dividing by its coded-bit count
74    /// reproduces [`channel_ber`](crate::demodulate::OfdmRxFrame::channel_ber)
75    /// exactly: the map is that rate's per-bit expansion, not a second
76    /// measurement of it.
77    ///
78    /// **This says nothing about the decoder.** Both states it matches mean
79    /// "arrived wrong"; whether the decoder then fixed it is the other half of
80    /// the map, and [`decoder_disagreed`](Self::decoder_disagreed) is how to
81    /// ask. A consumer checking only this one is blind to the estimate stream
82    /// entirely — see that method's note.
83    #[inline(always)]
84    pub fn arrived_wrong(self) -> bool {
85        matches!(self, BitOutcome::Corrected | BitOutcome::Uncorrected)
86    }
87
88    /// Whether the inner decoder's output still disagrees with the truth at
89    /// this bit — i.e. [`Uncorrected`](Self::Uncorrected) or
90    /// [`Introduced`](Self::Introduced).
91    ///
92    /// The dual of [`arrived_wrong`](Self::arrived_wrong): that one is the
93    /// *channel's* half of the map, this is the *decoder's*. Both halves need a
94    /// name, because this predicate is the map's whole meaning and a consumer
95    /// that spells it out by hand can get it subtly wrong with nothing to say
96    /// so.
97    ///
98    /// The two are independent, not complementary. `Corrected` is
99    /// `arrived_wrong && !decoder_disagreed`; `Introduced` is the reverse; a
100    /// bit can be both (`Uncorrected`) or neither (`Clean`).
101    #[inline(always)]
102    pub fn decoder_disagreed(self) -> bool {
103        matches!(self, BitOutcome::Uncorrected | BitOutcome::Introduced)
104    }
105}
106
107/// One frame's probe record: where its symbols and correction map live inside
108/// the owning [`OfdmRxProbe`]'s flat buffers, plus the metadata needed to render
109/// them.
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct OfdmProbeFrame {
112    /// The frame's sequence number, or `None` when the link carries no
113    /// header-derived one. Always `Some` on the header-bearing COFDM path this
114    /// receiver decodes — a frame whose *header* fails never reaches the payload
115    /// demapper and so produces no probe record at all.
116    pub sequence_num: Option<u32>,
117    /// The payload constellation the symbols were demapped against, so a
118    /// display can draw the right reference points without re-reading the MCS
119    /// table.
120    pub constellation: ConstellationOrder,
121    /// This frame's span in [`OfdmRxProbe::symbols`].
122    ///
123    /// **Private, deliberately.** A span is only meaningful against the buffer
124    /// it was minted from, and the probe's buffers are cleared and refilled on
125    /// every probed call — so a record that outlived its call would index the
126    /// wrong frame's data, or past the end. Public spans plus a `Clone` on this
127    /// struct made that mistake compile. [`OfdmRxProbe::iter`] hands out
128    /// already-resolved slices instead, whose lifetime is tied to the probe, so
129    /// the mistake is now unrepresentable and cloning a record is harmless: it
130    /// carries metadata and has no way to index anything.
131    pub(crate) symbols: Range<usize>,
132    /// This frame's span in [`OfdmRxProbe::correction`]. **Empty when
133    /// [`decoded`](Self::decoded) is false** — a frame that did not decode has
134    /// no ground truth to compare against. Private on the same reasoning as
135    /// [`symbols`](Self::symbols).
136    pub(crate) correction: Range<usize>,
137    /// The inner code's codeword length `n`, so a display can draw codeword
138    /// boundaries across the map. `0` when the inner code has no block
139    /// structure to draw — [`InnerFec::None`](crate::fec::InnerFec::None) and
140    /// the convolutional arm, which terminates once per frame rather than per
141    /// codeword.
142    pub codeword_bits: usize,
143    /// The inner code's information length `k`, on the same terms as
144    /// [`codeword_bits`](Self::codeword_bits). For a systematic code the first
145    /// `k` bits of each codeword are the message.
146    pub codeword_info_bits: usize,
147    /// Whether the payload decoded and passed its integrity check. `false` ⇒
148    /// there is no ground truth, so [`ProbedFrame::correction`] is empty and
149    /// only the symbols are meaningful.
150    ///
151    /// The map therefore empties exactly when the link is worst. That is
152    /// honest — nothing can be measured against a payload that did not
153    /// verify — but it has to be rendered as "no ground truth", not as "no
154    /// errors".
155    pub decoded: bool,
156}
157
158/// Reusable per-call diagnostic buffers for
159/// [`OfdmFrameStreamDemod::feed_probed`](crate::demodulate::OfdmFrameStreamDemod::feed_probed).
160///
161/// Cleared and refilled by each probed call; **capacity is retained**, so
162/// steady-state probing does not reallocate. That is the reason the caller owns
163/// this rather than each frame carrying its own `Option<Vec<_>>`: a probed
164/// frame is ~2600 complex symbols and ~5100 outcome bytes, at 8 to 51 frames
165/// per second, and `feed` returns *several* frames per call — so a per-frame
166/// allocation is paid on every frame and a borrowed buffer cannot live on
167/// [`RxFrame`](crate::demodulate::RxFrame) at all.
168///
169/// # Layout
170///
171/// [`symbols`](Self::symbols) and [`correction`](Self::correction) are flat
172/// across every frame the call produced — read them directly for a bulk view
173/// that does not care about frame boundaries (a density accumulator, say).
174/// [`iter`](Self::iter) is the per-frame view, and hands out resolved slices
175/// rather than spans so a record cannot outlive the call that filled it.
176///
177/// ```ignore
178/// let mut probe = OfdmRxProbe::new();
179/// for chunk in stream {
180///     // Read the probe after the call that filled it: every probed entry
181///     // point clears first, so records do not accumulate across calls.
182///     for frame in rx.feed_probed(chunk, &mut probe) { /* ... */ }
183///     for f in probe.iter() {
184///         plot_constellation(f.symbols, f.meta.constellation);
185///         if f.meta.decoded {
186///             plot_corrections(f.correction, f.meta.codeword_bits);
187///         }
188///     }
189/// }
190/// ```
191#[derive(Debug, Clone, Default)]
192pub struct OfdmRxProbe {
193    /// Equalized payload symbols, in demap order, for every frame this call
194    /// produced.
195    pub(crate) symbols: Vec<C32>,
196    /// Per-coded-bit outcomes, for every frame this call decoded.
197    pub(crate) correction: Vec<BitOutcome>,
198    /// Per-frame spans into the two buffers above, plus metadata.
199    pub(crate) frames: Vec<OfdmProbeFrame>,
200    /// Private scratch, never handed out: the re-encode of the inner decoder's
201    /// own output, in the coded-bit domain. Held here so its buffer is reused
202    /// across frames — the encode helpers that fill it still allocate their own
203    /// intermediates, exactly as
204    /// [`with_error_rates`](crate::demodulate::OfdmFrameStreamDemod::with_error_rates)
205    /// does today.
206    pub(crate) estimate: Vec<u8>,
207}
208
209impl OfdmRxProbe {
210    /// An empty probe. Reuse one across calls — that is the point of the type.
211    pub fn new() -> Self {
212        Self::default()
213    }
214
215    /// The per-frame records this call produced, in the order the frames were
216    /// drained from the buffer.
217    pub fn frames(&self) -> &[OfdmProbeFrame] {
218        &self.frames
219    }
220
221    /// Every frame's equalized payload symbols, flat — a bulk view that does
222    /// not care about frame boundaries. Use [`iter`](Self::iter) for the
223    /// per-frame view.
224    pub fn symbols(&self) -> &[C32] {
225        &self.symbols
226    }
227
228    /// Every decoded frame's per-coded-bit outcomes, flat, on the same terms
229    /// as [`symbols`](Self::symbols).
230    pub fn correction(&self) -> &[BitOutcome] {
231        &self.correction
232    }
233
234    /// Each frame this call produced, with its metadata and both of its slices
235    /// already resolved — the way to read a probe.
236    ///
237    /// The slices borrow the probe, so a [`ProbedFrame`] cannot outlive the
238    /// call that filled it: the next `feed_probed` needs `&mut` and the borrow
239    /// checker refuses. That is the whole reason this exists rather than a
240    /// `symbols_for(&frame)` lookup, which a stale record would silently index
241    /// into the wrong frame's data.
242    ///
243    /// ```ignore
244    /// for f in probe.iter() {
245    ///     plot_constellation(f.symbols, f.meta.constellation);
246    ///     if f.meta.decoded {
247    ///         plot_corrections(f.correction, f.meta.codeword_bits);
248    ///     }
249    /// }
250    /// ```
251    pub fn iter(&self) -> impl Iterator<Item = ProbedFrame<'_>> {
252        self.frames.iter().map(move |meta| ProbedFrame {
253            meta,
254            symbols: &self.symbols[meta.symbols.clone()],
255            correction: &self.correction[meta.correction.clone()],
256        })
257    }
258
259    /// Whether this call produced no probe frames.
260    pub fn is_empty(&self) -> bool {
261        self.frames.is_empty()
262    }
263
264    /// Drops the contents, keeping the allocations. Called at the start of each
265    /// probed `feed`/`flush`, so a caller never has to.
266    pub fn clear(&mut self) {
267        self.symbols.clear();
268        self.correction.clear();
269        self.frames.clear();
270    }
271
272    /// Records the current buffer lengths so a frame that fails part-way can be
273    /// rolled back to them. See [`rollback`](Self::rollback).
274    pub(crate) fn mark(&self) -> ProbeMark {
275        ProbeMark {
276            symbols: self.symbols.len(),
277            correction: self.correction.len(),
278            frames: self.frames.len(),
279        }
280    }
281
282    /// Truncates back to `mark`, discarding whatever a partial frame appended.
283    ///
284    /// **A probe frame is committed as one unit.** `BodyError::Incomplete`
285    /// consumes no buffer and the next `feed` re-runs the frame from its header,
286    /// so anything a partial attempt appended must not survive — or the same
287    /// frame would be reported twice. `soft_demap` returns `None` on a short
288    /// buffer before touching the sink, which makes the common case safe by
289    /// construction; the equalized path's second loop is the one that can sink
290    /// symbols before its own early return.
291    pub(crate) fn rollback(&mut self, mark: ProbeMark) {
292        debug_assert!(
293            self.symbols.len() >= mark.symbols
294                && self.correction.len() >= mark.correction
295                && self.frames.len() >= mark.frames,
296            "probe buffers must only grow between mark and rollback"
297        );
298        self.symbols.truncate(mark.symbols);
299        self.correction.truncate(mark.correction);
300        self.frames.truncate(mark.frames);
301    }
302
303    /// Records a frame that reached the demapper but produced no ground truth:
304    /// symbols only, an empty correction span, `decoded: false`.
305    ///
306    /// A failed payload CRC still has a constellation, and the constellation is
307    /// precisely where an operator looks when frames stop decoding.
308    pub(crate) fn push_undecoded(
309        &mut self,
310        sym_start: usize,
311        constellation: ConstellationOrder,
312        sequence_num: Option<u32>,
313    ) {
314        let end = self.correction.len();
315        self.frames.push(OfdmProbeFrame {
316            sequence_num,
317            constellation,
318            symbols: sym_start..self.symbols.len(),
319            correction: end..end,
320            codeword_bits: 0,
321            codeword_info_bits: 0,
322            decoded: false,
323        });
324    }
325
326    /// Records a decoded frame, building its correction map from the three
327    /// coded-bit-domain streams that define it: `truth` (the re-encode of the
328    /// CRC-verified payload — what the transmitter sent), `received` (the
329    /// demapper's hard decisions), and the estimate scratch (the re-encode of
330    /// the inner decoder's own output).
331    ///
332    /// All three are already in the coded-bit index space, ordered
333    /// post-inner-interleave and post-`AfterInnerFec` scramble — i.e. exactly
334    /// the order the bits were mapped to symbols in, so the map indexes the
335    /// same way the symbols do.
336    pub(crate) fn push_decoded(
337        &mut self,
338        sym_start: usize,
339        meta: ProbeMeta,
340        truth: &[u8],
341        received: &[u8],
342    ) {
343        // Field-level split borrow: the map reads `estimate` while writing
344        // `correction`, and both are fields of `self`.
345        let Self {
346            correction,
347            estimate,
348            frames,
349            symbols,
350        } = self;
351        let start = correction.len();
352        let n = truth.len().min(received.len()).min(estimate.len());
353        correction.extend(
354            (0..n).map(|i| BitOutcome::classify(received[i] == truth[i], estimate[i] == truth[i])),
355        );
356        frames.push(OfdmProbeFrame {
357            sequence_num: meta.sequence_num,
358            constellation: meta.constellation,
359            symbols: sym_start..symbols.len(),
360            correction: start..correction.len(),
361            codeword_bits: meta.codeword_bits,
362            codeword_info_bits: meta.codeword_info_bits,
363            decoded: true,
364        });
365    }
366}
367
368/// One frame's probe record with both of its slices resolved, as yielded by
369/// [`OfdmRxProbe::iter`].
370///
371/// # Symbols and bits do not index 1:1
372///
373/// `correction[i]` is coded bit `i`, in the order the bits were mapped to
374/// subcarriers; `symbols[j]` is the `j`th data carrier in demap order. So the
375/// bits of `symbols[j]` are
376///
377/// ```text
378/// let bps = meta.constellation.bits_per_symbol();
379/// &correction[j * bps .. (j + 1) * bps]        // NOT always in range
380/// ```
381///
382/// — but **the symbols carry more bit-slots than the map covers.** A payload is
383/// mapped to a whole number of OFDM symbols, so the final one is padded past
384/// the end of the coded block. Measured on one test link: 806 QPSK symbols =
385/// 1612 slots against a 1536-bit map, 76 slots of padding with no outcome
386/// behind them.
387///
388/// A consumer walking symbols and colouring each by its bits must therefore
389/// stop at `correction.len()` rather than run to the end of `symbols`. Walking
390/// the map and finding each bit's symbol is always in range and is the safer
391/// direction.
392#[derive(Debug, Clone, Copy)]
393pub struct ProbedFrame<'a> {
394    /// This frame's metadata: sequence number, constellation, codeword
395    /// geometry, and whether it decoded.
396    pub meta: &'a OfdmProbeFrame,
397    /// The equalized payload symbols, in demap order.
398    pub symbols: &'a [C32],
399    /// The per-coded-bit outcomes. **Empty when `meta.decoded` is false** —
400    /// that is "no ground truth", not "no errors".
401    pub correction: &'a [BitOutcome],
402}
403
404/// The per-frame metadata a probe record carries alongside its two spans —
405/// everything in [`OfdmProbeFrame`] that is not derived from the buffers.
406#[derive(Debug, Clone, Copy)]
407pub(crate) struct ProbeMeta {
408    pub(crate) sequence_num: Option<u32>,
409    pub(crate) constellation: ConstellationOrder,
410    pub(crate) codeword_bits: usize,
411    pub(crate) codeword_info_bits: usize,
412}
413
414/// The buffer lengths at the start of one frame's probe, for
415/// [`OfdmRxProbe::rollback`].
416#[derive(Debug, Clone, Copy)]
417pub(crate) struct ProbeMark {
418    symbols: usize,
419    correction: usize,
420    frames: usize,
421}