ab8425c85c
Move ft2_encode from ft4/ to ft2/ where it belongs. Remove all module-level #[allow] suppressions and fix the underlying issues: - Remove dead code: wf_mag_at, xor_rows, unused Monitor IFFT fields, OsdBox.size - Gate encode174_to_bits with #[cfg(test)] (only used in tests) - Convert 40+ C-style index loops to idiomatic iterators - Add targeted #[allow(clippy::too_many_arguments)] on two OSD functions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Stan Grams <sjg@haxx.space>
299 lines
9.7 KiB
Rust
299 lines
9.7 KiB
Rust
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
//
|
|
// SPDX-License-Identifier: BSD-2-Clause
|
|
|
|
//! Per-symbol FFT and multi-scale bit metrics extraction.
|
|
//!
|
|
//! Takes the downsampled complex signal, computes per-symbol FFTs to extract
|
|
//! complex tone amplitudes, and generates bit metrics at three scales:
|
|
//! 1-symbol, 2-symbol, and 4-symbol coherent integration.
|
|
|
|
use num_complex::Complex32;
|
|
use rustfft::FftPlanner;
|
|
|
|
use crate::common::constants::{FT4_COSTAS_PATTERN, FT4_GRAY_MAP};
|
|
|
|
use super::{FT2_FRAME_SYMBOLS, FT2_NSS};
|
|
|
|
const N_METRICS: usize = 2 * FT2_FRAME_SYMBOLS;
|
|
|
|
/// Reusable FFT plans and scratch buffers for bit-metric extraction.
|
|
pub struct BitMetricsWorkspace {
|
|
fft: std::sync::Arc<dyn rustfft::Fft<f32>>,
|
|
scratch: Vec<Complex32>,
|
|
symbols: [[Complex32; 4]; FT2_FRAME_SYMBOLS],
|
|
s4: [[f32; 4]; FT2_FRAME_SYMBOLS],
|
|
metric1: [f32; N_METRICS],
|
|
metric2: [f32; N_METRICS],
|
|
metric4: [f32; N_METRICS],
|
|
bitmetrics: [[f32; 3]; N_METRICS],
|
|
csymb: [Complex32; FT2_NSS],
|
|
}
|
|
|
|
impl BitMetricsWorkspace {
|
|
pub fn new() -> Self {
|
|
let mut planner = FftPlanner::<f32>::new();
|
|
let fft = planner.plan_fft_forward(FT2_NSS);
|
|
let scratch = vec![Complex32::new(0.0, 0.0); fft.get_inplace_scratch_len()];
|
|
|
|
Self {
|
|
fft,
|
|
scratch,
|
|
symbols: [[Complex32::new(0.0, 0.0); 4]; FT2_FRAME_SYMBOLS],
|
|
s4: [[0.0; 4]; FT2_FRAME_SYMBOLS],
|
|
metric1: [0.0; N_METRICS],
|
|
metric2: [0.0; N_METRICS],
|
|
metric4: [0.0; N_METRICS],
|
|
bitmetrics: [[0.0; 3]; N_METRICS],
|
|
csymb: [Complex32::new(0.0, 0.0); FT2_NSS],
|
|
}
|
|
}
|
|
|
|
/// Extract bit metrics into a reusable internal buffer.
|
|
pub fn extract<'a>(&'a mut self, signal: &[Complex32]) -> Option<&'a [[f32; 3]]> {
|
|
self.metric1.fill(0.0);
|
|
self.metric2.fill(0.0);
|
|
self.metric4.fill(0.0);
|
|
|
|
for sym in 0..FT2_FRAME_SYMBOLS {
|
|
let offset = sym * FT2_NSS;
|
|
if offset + FT2_NSS <= signal.len() {
|
|
self.csymb
|
|
.copy_from_slice(&signal[offset..(offset + FT2_NSS)]);
|
|
} else {
|
|
self.csymb.fill(Complex32::new(0.0, 0.0));
|
|
let remaining = signal.len().saturating_sub(offset);
|
|
self.csymb[..remaining].copy_from_slice(&signal[offset..(offset + remaining)]);
|
|
}
|
|
|
|
self.fft
|
|
.process_with_scratch(&mut self.csymb, &mut self.scratch);
|
|
|
|
for tone in 0..4 {
|
|
let symbol = self.csymb[tone];
|
|
self.symbols[sym][tone] = symbol;
|
|
self.s4[sym][tone] = symbol.norm();
|
|
}
|
|
}
|
|
|
|
// Sync quality check: verify Costas patterns are detectable
|
|
let mut sync_ok = 0;
|
|
for (group, costas_group) in FT4_COSTAS_PATTERN.iter().enumerate() {
|
|
let base = group * 33;
|
|
for (i, &costas_tone) in costas_group.iter().enumerate() {
|
|
if base + i >= FT2_FRAME_SYMBOLS {
|
|
continue;
|
|
}
|
|
let mut best = 0;
|
|
for tone in 1..4 {
|
|
if self.s4[base + i][tone] > self.s4[base + i][best] {
|
|
best = tone;
|
|
}
|
|
}
|
|
if best == costas_tone as usize {
|
|
sync_ok += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
if sync_ok < 4 {
|
|
return None;
|
|
}
|
|
|
|
for nseq in 0..3 {
|
|
let (nsym, metric): (usize, &mut [f32; N_METRICS]) = match nseq {
|
|
0 => (1, &mut self.metric1),
|
|
1 => (2, &mut self.metric2),
|
|
_ => (4, &mut self.metric4),
|
|
};
|
|
let nt = 1usize << (2 * nsym);
|
|
let ibmax = match nsym {
|
|
1 => 1,
|
|
2 => 3,
|
|
4 => 7,
|
|
_ => 0,
|
|
};
|
|
|
|
let mut ks = 0;
|
|
while ks + nsym <= FT2_FRAME_SYMBOLS {
|
|
let mut max_one = [f32::NEG_INFINITY; 8];
|
|
let mut max_zero = [f32::NEG_INFINITY; 8];
|
|
|
|
for i in 0..nt {
|
|
let sum = match nsym {
|
|
1 => self.symbols[ks][FT4_GRAY_MAP[i & 0x03] as usize],
|
|
2 => {
|
|
self.symbols[ks][FT4_GRAY_MAP[(i >> 2) & 0x03] as usize]
|
|
+ self.symbols[ks + 1][FT4_GRAY_MAP[i & 0x03] as usize]
|
|
}
|
|
4 => {
|
|
self.symbols[ks][FT4_GRAY_MAP[(i >> 6) & 0x03] as usize]
|
|
+ self.symbols[ks + 1][FT4_GRAY_MAP[(i >> 4) & 0x03] as usize]
|
|
+ self.symbols[ks + 2][FT4_GRAY_MAP[(i >> 2) & 0x03] as usize]
|
|
+ self.symbols[ks + 3][FT4_GRAY_MAP[i & 0x03] as usize]
|
|
}
|
|
_ => Complex32::new(0.0, 0.0),
|
|
};
|
|
let coherent = sum.norm();
|
|
|
|
for ib in 0..=ibmax {
|
|
if ((i >> (ibmax - ib)) & 1) != 0 {
|
|
max_one[ib] = max_one[ib].max(coherent);
|
|
} else {
|
|
max_zero[ib] = max_zero[ib].max(coherent);
|
|
}
|
|
}
|
|
}
|
|
|
|
let ipt = 2 * ks;
|
|
for ib in 0..=ibmax {
|
|
let metric_idx = ipt + ib;
|
|
if metric_idx < N_METRICS {
|
|
metric[metric_idx] = max_one[ib] - max_zero[ib];
|
|
}
|
|
}
|
|
|
|
ks += nsym;
|
|
}
|
|
}
|
|
|
|
// Patch boundary metrics where multi-symbol integration overruns
|
|
self.metric2[204] = self.metric1[204];
|
|
self.metric2[205] = self.metric1[205];
|
|
self.metric4[200] = self.metric2[200];
|
|
self.metric4[201] = self.metric2[201];
|
|
self.metric4[202] = self.metric2[202];
|
|
self.metric4[203] = self.metric2[203];
|
|
self.metric4[204] = self.metric1[204];
|
|
self.metric4[205] = self.metric1[205];
|
|
|
|
normalize_metric(&mut self.metric1);
|
|
normalize_metric(&mut self.metric2);
|
|
normalize_metric(&mut self.metric4);
|
|
|
|
for i in 0..N_METRICS {
|
|
self.bitmetrics[i][0] = self.metric1[i];
|
|
self.bitmetrics[i][1] = self.metric2[i];
|
|
self.bitmetrics[i][2] = self.metric4[i];
|
|
}
|
|
|
|
Some(&self.bitmetrics)
|
|
}
|
|
}
|
|
|
|
impl Default for BitMetricsWorkspace {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
/// Extract bit metrics from the downsampled signal region.
|
|
///
|
|
/// Returns a 2D array of shape `[2 * FT2_FRAME_SYMBOLS][3]` where:
|
|
/// - Index 0: 1-symbol scale metric
|
|
/// - Index 1: 2-symbol scale metric
|
|
/// - Index 2: 4-symbol scale metric
|
|
///
|
|
/// Returns `None` if the sync quality is too poor (fewer than 4 of 16
|
|
/// Costas sync tones decoded correctly).
|
|
pub fn extract_bitmetrics_raw(signal: &[Complex32]) -> Option<Vec<[f32; 3]>> {
|
|
let mut workspace = BitMetricsWorkspace::new();
|
|
workspace
|
|
.extract(signal)
|
|
.map(|bitmetrics| bitmetrics.to_vec())
|
|
}
|
|
|
|
/// Normalize a metric array by dividing by its standard deviation.
|
|
fn normalize_metric(metric: &mut [f32]) {
|
|
let count = metric.len();
|
|
if count == 0 {
|
|
return;
|
|
}
|
|
|
|
let mut sum = 0.0f32;
|
|
let mut sum2 = 0.0f32;
|
|
for &v in metric.iter() {
|
|
sum += v;
|
|
sum2 += v * v;
|
|
}
|
|
|
|
let mean = sum / count as f32;
|
|
let variance = (sum2 / count as f32) - (mean * mean);
|
|
let sigma = if variance > 0.0 {
|
|
variance.sqrt()
|
|
} else {
|
|
(sum2 / count as f32).max(0.0).sqrt()
|
|
};
|
|
|
|
if sigma <= 1e-6 {
|
|
return;
|
|
}
|
|
|
|
for v in metric.iter_mut() {
|
|
*v /= sigma;
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn normalize_metric_zeros() {
|
|
let mut m = vec![0.0f32; 100];
|
|
normalize_metric(&mut m);
|
|
for &v in &m {
|
|
assert_eq!(v, 0.0);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_metric_uniform() {
|
|
let mut m = vec![1.0f32; 100];
|
|
normalize_metric(&mut m);
|
|
// All values are the same so variance is zero, sigma will be computed
|
|
// from sum2/n which is 1.0, so sigma=1.0 and values remain 1.0
|
|
for &v in &m {
|
|
assert!((v - 1.0).abs() < 1e-4);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_metric_nonzero() {
|
|
let mut m: Vec<f32> = (0..100).map(|i| (i as f32 - 50.0) * 0.1).collect();
|
|
normalize_metric(&mut m);
|
|
// After normalization, standard deviation should be ~1.0
|
|
let mean: f32 = m.iter().sum::<f32>() / m.len() as f32;
|
|
let variance: f32 =
|
|
m.iter().map(|&v| (v - mean) * (v - mean)).sum::<f32>() / m.len() as f32;
|
|
assert!(
|
|
(variance - 1.0).abs() < 0.1,
|
|
"Normalized variance should be ~1.0, got {}",
|
|
variance
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn extract_bitmetrics_silent_signal() {
|
|
let signal = vec![Complex32::new(0.0, 0.0); FT2_FRAME_SYMBOLS * FT2_NSS];
|
|
// Silent signal: all tones have zero magnitude, so the "best tone"
|
|
// defaults to tone 0 for every symbol. When tone 0 happens to match
|
|
// the Costas pattern (which it does for some groups), sync_ok may
|
|
// reach >= 4. So a silent signal can still pass the sync quality
|
|
// gate — the important thing is it does not panic.
|
|
let _result = extract_bitmetrics_raw(&signal);
|
|
}
|
|
|
|
#[test]
|
|
fn frame_symbols_constant() {
|
|
// FT2_NN=105, FT2_NR=2 => FT2_FRAME_SYMBOLS=103
|
|
assert_eq!(FT2_FRAME_SYMBOLS, 103);
|
|
}
|
|
|
|
#[test]
|
|
fn nss_constant() {
|
|
// FT2_NSTEP=288, FT2_NDOWN=9 => FT2_NSS=32
|
|
assert_eq!(FT2_NSS, 32);
|
|
}
|
|
}
|