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}