[test](trx-backend-soapysdr): add demodulator unit tests
Add 9 unit tests covering all demodulators in demod.rs: USB/LSB/Passthrough real-part extraction, AM DC removal and varying-envelope, FM tone frequency and silence, CW peak normalisation, mode mapping, and empty-input safety. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -160,3 +160,170 @@ fn demod_cw(samples: &[Complex<f32>]) -> Vec<f32> {
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use num_complex::Complex;
|
||||
|
||||
fn complex_tone(freq_norm: f32, len: usize) -> Vec<Complex<f32>> {
|
||||
use std::f32::consts::TAU;
|
||||
(0..len)
|
||||
.map(|n| Complex::from_polar(1.0, TAU * freq_norm * n as f32))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn assert_approx_eq(a: f32, b: f32, tol: f32, label: &str) {
|
||||
assert!(
|
||||
(a - b).abs() <= tol,
|
||||
"{}: expected {} ≈ {} (tol {})",
|
||||
label,
|
||||
a,
|
||||
b,
|
||||
tol
|
||||
);
|
||||
}
|
||||
|
||||
// Test 1: USB and Passthrough return the real part of each sample.
|
||||
#[test]
|
||||
fn test_usb_passthrough_takes_real_part() {
|
||||
let input = vec![
|
||||
Complex::new(1.0_f32, 2.0),
|
||||
Complex::new(3.0, 4.0),
|
||||
Complex::new(-1.0, 0.0),
|
||||
Complex::new(0.0, -1.0),
|
||||
];
|
||||
let expected = vec![1.0_f32, 3.0, -1.0, 0.0];
|
||||
|
||||
let usb_out = Demodulator::Usb.demodulate(&input);
|
||||
assert_eq!(usb_out, expected, "USB should return real parts");
|
||||
|
||||
let pass_out = Demodulator::Passthrough.demodulate(&input);
|
||||
assert_eq!(pass_out, expected, "Passthrough should return real parts");
|
||||
}
|
||||
|
||||
// Test 2: LSB returns the real part (mixing handled upstream).
|
||||
#[test]
|
||||
fn test_lsb_takes_real_part() {
|
||||
let input = vec![
|
||||
Complex::new(1.0_f32, 2.0),
|
||||
Complex::new(3.0, 4.0),
|
||||
Complex::new(-1.0, 0.0),
|
||||
Complex::new(0.0, -1.0),
|
||||
];
|
||||
let expected = vec![1.0_f32, 3.0, -1.0, 0.0];
|
||||
|
||||
let lsb_out = Demodulator::Lsb.demodulate(&input);
|
||||
assert_eq!(lsb_out, expected, "LSB should return real parts");
|
||||
}
|
||||
|
||||
// Test 3: AM on a constant-magnitude signal produces all zeros (DC removed).
|
||||
#[test]
|
||||
fn test_am_dc_removed() {
|
||||
let input: Vec<Complex<f32>> = (0..8).map(|_| Complex::new(1.0, 0.0)).collect();
|
||||
let out = Demodulator::Am.demodulate(&input);
|
||||
assert_eq!(out.len(), 8);
|
||||
for (i, &v) in out.iter().enumerate() {
|
||||
assert_approx_eq(v, 0.0, 1e-6, &format!("AM DC removed sample {}", i));
|
||||
}
|
||||
}
|
||||
|
||||
// Test 4: AM on alternating-magnitude signal produces DC-centered output.
|
||||
#[test]
|
||||
fn test_am_varying_envelope() {
|
||||
let input = vec![
|
||||
Complex::new(0.0_f32, 0.0),
|
||||
Complex::new(1.0, 0.0),
|
||||
Complex::new(0.0, 0.0),
|
||||
Complex::new(1.0, 0.0),
|
||||
];
|
||||
let expected = vec![-0.5_f32, 0.5, -0.5, 0.5];
|
||||
let out = Demodulator::Am.demodulate(&input);
|
||||
assert_eq!(out.len(), 4);
|
||||
for (i, (&got, &exp)) in out.iter().zip(expected.iter()).enumerate() {
|
||||
assert_approx_eq(got, exp, 1e-6, &format!("AM varying envelope sample {}", i));
|
||||
}
|
||||
}
|
||||
|
||||
// Test 5: FM discriminator on a pure tone at 0.25 cycles/sample.
|
||||
// arg(s[n]*conj(s[n-1])) = 2π*0.25 = π/2; scaled by 1/π → 0.5.
|
||||
#[test]
|
||||
fn test_fm_tone_frequency() {
|
||||
let input = complex_tone(0.25, 16);
|
||||
let out = Demodulator::Fm.demodulate(&input);
|
||||
assert_eq!(out.len(), 16);
|
||||
// First sample is 0.0 by convention.
|
||||
assert_approx_eq(out[0], 0.0, 1e-6, "FM tone sample 0 (zero by convention)");
|
||||
// Remaining samples should be approximately 0.5.
|
||||
for i in 1..out.len() {
|
||||
assert_approx_eq(out[i], 0.5, 0.01, &format!("FM tone sample {}", i));
|
||||
}
|
||||
}
|
||||
|
||||
// Test 6: FM discriminator on a DC (constant-phase) signal outputs all zeros.
|
||||
#[test]
|
||||
fn test_fm_silence_is_zero() {
|
||||
let input: Vec<Complex<f32>> = (0..8).map(|_| Complex::new(1.0, 0.0)).collect();
|
||||
let out = Demodulator::Fm.demodulate(&input);
|
||||
assert_eq!(out.len(), 8);
|
||||
for (i, &v) in out.iter().enumerate() {
|
||||
assert_approx_eq(v, 0.0, 1e-6, &format!("FM silence sample {}", i));
|
||||
}
|
||||
}
|
||||
|
||||
// Test 7: CW envelope detector normalises peak to 1.0.
|
||||
#[test]
|
||||
fn test_cw_magnitude_envelope() {
|
||||
let input = vec![
|
||||
Complex::new(3.0_f32, 4.0), // magnitude 5.0
|
||||
Complex::new(0.0, 0.0), // magnitude 0.0
|
||||
Complex::new(1.0, 0.0), // magnitude 1.0
|
||||
];
|
||||
let out = Demodulator::Cw.demodulate(&input);
|
||||
assert_eq!(out.len(), 3);
|
||||
assert_approx_eq(out[0], 1.0, 1e-6, "CW sample 0");
|
||||
assert_approx_eq(out[1], 0.0, 1e-6, "CW sample 1");
|
||||
assert_approx_eq(out[2], 0.2, 1e-6, "CW sample 2");
|
||||
}
|
||||
|
||||
// Test 8: Demodulator::for_mode maps each RigMode to the correct variant.
|
||||
#[test]
|
||||
fn test_demodulator_for_mode_mapping() {
|
||||
assert_eq!(Demodulator::for_mode(&RigMode::USB), Demodulator::Usb);
|
||||
assert_eq!(Demodulator::for_mode(&RigMode::LSB), Demodulator::Lsb);
|
||||
assert_eq!(Demodulator::for_mode(&RigMode::AM), Demodulator::Am);
|
||||
assert_eq!(Demodulator::for_mode(&RigMode::FM), Demodulator::Fm);
|
||||
assert_eq!(Demodulator::for_mode(&RigMode::WFM), Demodulator::Wfm);
|
||||
assert_eq!(Demodulator::for_mode(&RigMode::CW), Demodulator::Cw);
|
||||
assert_eq!(Demodulator::for_mode(&RigMode::CWR), Demodulator::Cw);
|
||||
assert_eq!(Demodulator::for_mode(&RigMode::DIG), Demodulator::Passthrough);
|
||||
assert_eq!(Demodulator::for_mode(&RigMode::PKT), Demodulator::Passthrough);
|
||||
}
|
||||
|
||||
// Test 9: All demodulators return an empty Vec for empty input without panicking.
|
||||
#[test]
|
||||
fn test_empty_input() {
|
||||
let empty: Vec<Complex<f32>> = Vec::new();
|
||||
let demodulators = [
|
||||
Demodulator::Usb,
|
||||
Demodulator::Lsb,
|
||||
Demodulator::Am,
|
||||
Demodulator::Fm,
|
||||
Demodulator::Wfm,
|
||||
Demodulator::Cw,
|
||||
Demodulator::Passthrough,
|
||||
];
|
||||
for demod in &demodulators {
|
||||
let out = demod.demodulate(&empty);
|
||||
assert!(
|
||||
out.is_empty(),
|
||||
"{:?} should return empty Vec for empty input",
|
||||
demod
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user