#![cfg(feature = "wspr")]
use yodel::geo::GeoError;
use yodel::wspr::{
DATA_BITS, POLY_A, POLY_B, SYMBOL_COUNT, SYNC_VECTOR, WsprConfig, WsprError, WsprMessage,
WsprModulator, convolutional_encode, interleave,
};
use yodel::{MaidenheadGrid, SampleRate};
fn grid(text: &str) -> MaidenheadGrid {
MaidenheadGrid::new(text).expect("valid locator")
}
fn expected_callsign_value(aligned: &[u8; 6]) -> u32 {
fn v(c: u8) -> u32 {
match c {
b'0'..=b'9' => u32::from(c - b'0'),
b'A'..=b'Z' => u32::from(c - b'A') + 10,
b' ' => 36,
_ => panic!("bad char"),
}
}
let mut n = v(aligned[0]);
n = n * 36 + v(aligned[1]);
n = n * 10 + v(aligned[2]);
for &c in &aligned[3..] {
n = n * 27 + if c == b' ' { 26 } else { u32::from(c - b'A') };
}
n
}
fn expected_m(grid: &[u8; 4], power: u8) -> u32 {
let lon_f = u32::from(grid[0] - b'A');
let lat_f = u32::from(grid[1] - b'A');
let lon_s = u32::from(grid[2] - b'0');
let lat_s = u32::from(grid[3] - b'0');
let g = (179 - 10 * lon_f - lon_s) * 180 + 10 * lat_f + lat_s;
g * 128 + u32::from(power) + 64
}
fn packed_bit(packed: &[u8; 11], i: usize) -> u32 {
u32::from((packed[i / 8] >> (7 - i % 8)) & 1)
}
fn unpack(packed: &[u8; 11]) -> (u32, u32) {
let mut n = 0u32;
for i in 0..28 {
n = (n << 1) | packed_bit(packed, i);
}
let mut m = 0u32;
for i in 28..50 {
m = (m << 1) | packed_bit(packed, i);
}
(n, m)
}
#[test]
fn callsign_alignment_and_packing() {
let msg = WsprMessage::new("K1ABC", grid("FN42"), 37).unwrap();
assert_eq!(msg.callsign(), b" K1ABC");
let (n, m) = unpack(&msg.pack());
assert_eq!(n, expected_callsign_value(b" K1ABC"));
assert_eq!(m, expected_m(b"FN42", 37));
let msg = WsprMessage::new("KA1ABC", grid("FN42"), 37).unwrap();
assert_eq!(msg.callsign(), b"KA1ABC");
let msg = WsprMessage::new("g4xyz", grid("io90"), 30).unwrap();
assert_eq!(msg.callsign(), b" G4XYZ");
assert_eq!(msg.grid().as_str(), "IO90");
}
#[test]
fn short_callsigns_pack() {
let msg = WsprMessage::new("K1AB", grid("AA00"), 0).unwrap();
assert_eq!(msg.callsign(), b" K1AB ");
let msg = WsprMessage::new("W1A", grid("RR99"), 60).unwrap();
assert_eq!(msg.callsign(), b" W1A ");
}
#[test]
fn callsign_rejections() {
assert_eq!(
WsprMessage::new("K1ABC/P", grid("FN42"), 37),
Err(WsprError::CallsignCompound)
);
assert_eq!(
WsprMessage::new("ABCDEF", grid("FN42"), 37),
Err(WsprError::CallsignShape)
);
assert_eq!(
WsprMessage::new("A1BCDE", grid("FN42"), 37),
Err(WsprError::CallsignShape)
);
assert!(matches!(
WsprMessage::new("", grid("FN42"), 37),
Err(WsprError::CallsignLength { len: 0 })
));
assert!(matches!(
WsprMessage::new("KA1ABCD", grid("FN42"), 37),
Err(WsprError::CallsignLength { .. })
));
assert!(matches!(
WsprMessage::new("K1A-C", grid("FN42"), 37),
Err(WsprError::CallsignChar { .. })
));
assert!(matches!(
WsprMessage::new("KA1A2C", grid("FN42"), 37),
Err(WsprError::CallsignChar { .. })
));
}
#[test]
fn callsign_and_grid_cannot_be_transposed() {
assert_eq!(
WsprMessage::new("FN42", grid("FN42"), 37),
Err(WsprError::CallsignChar { ch: '2', index: 3 })
);
assert_eq!(
MaidenheadGrid::new("K1ABC"),
Err(GeoError::BadGridLength { got: 5 })
);
assert_eq!(
MaidenheadGrid::new("K1AB"),
Err(GeoError::BadGridChar {
got: b'1',
position: 1
})
);
}
#[test]
fn grid_corners_pack_in_range() {
for wire in [b"AA00", b"RR99"] {
let g = core::str::from_utf8(wire).unwrap();
let msg = WsprMessage::new("K1ABC", grid(g), 37).unwrap();
let (_, m) = unpack(&msg.pack());
assert_eq!(m, expected_m(wire, 37));
assert!(m >> 7 < (1 << 15), "grid value must be 15 bits");
}
}
#[test]
fn locator_packing_matches_the_published_values() {
for (locator, published_m1) in [("AA00", 32_220u32), ("RR99", 179)] {
let msg = WsprMessage::new("K1ABC", grid(locator), 37).expect("valid");
let (_, m) = unpack(&msg.pack());
assert_eq!(
m >> 7,
published_m1,
"{locator} must pack to the published M1 value"
);
assert_eq!(m & 0x7f, 37 + 64);
}
assert_eq!(37 * 36 * 10 * 27 * 27 * 27, 262_177_560u32);
let widest = WsprMessage::new("999ZZZ", grid("AA00"), 60).expect("valid");
let (n, _) = unpack(&widest.pack());
assert!(
n < 262_177_560,
"callsign value {n} exceeds the published bound"
);
}
#[test]
fn grid_text_rejections() {
assert_eq!(
MaidenheadGrid::new("FN4"),
Err(GeoError::BadGridLength { got: 3 })
);
assert_eq!(
MaidenheadGrid::new("FN421"),
Err(GeoError::BadGridLength { got: 5 })
);
assert_eq!(
MaidenheadGrid::new("SN42"),
Err(GeoError::BadGridChar {
got: b'S',
position: 0
})
);
assert_eq!(
MaidenheadGrid::new("FNA2"),
Err(GeoError::BadGridChar {
got: b'A',
position: 2
})
);
}
#[test]
fn finer_grids_are_rejected_not_truncated() {
assert_eq!(
WsprMessage::new("K1ABC", grid("FN42ab"), 37),
Err(WsprError::GridLength { len: 6 })
);
assert_eq!(
WsprMessage::new("K1ABC", grid("FN42ab12"), 37),
Err(WsprError::GridLength { len: 8 })
);
}
#[test]
fn power_bounds_and_standard_values() {
assert!(WsprMessage::new("K1ABC", grid("FN42"), 0).is_ok());
assert!(WsprMessage::new("K1ABC", grid("FN42"), 60).is_ok());
assert!(WsprMessage::new("K1ABC", grid("FN42"), 33).is_ok());
assert_eq!(
WsprMessage::new("K1ABC", grid("FN42"), 61),
Err(WsprError::PowerOutOfRange { got: 61 })
);
assert_eq!(
WsprMessage::new("K1ABC", grid("FN42"), 5),
Err(WsprError::PowerNotStandard { got: 5 })
);
}
#[test]
fn packed_tail_bits_are_zero() {
let packed = WsprMessage::new("K1ABC", grid("FN42"), 37).unwrap().pack();
for i in DATA_BITS..88 {
assert_eq!(packed_bit(&packed, i), 0, "bit {i} must be zero");
}
}
#[test]
fn conv_encoder_zero_input_gives_zero_output() {
let out = convolutional_encode(&[0u8; 11]);
assert_eq!(out, [0u8; SYMBOL_COUNT]);
}
#[test]
fn conv_encoder_kat_single_leading_one() {
let mut packed = [0u8; 11];
packed[0] = 0x80;
let out = convolutional_encode(&packed);
for k in 0..81 {
let (ea, eb) = if k < 32 {
(((POLY_A >> k) & 1) as u8, ((POLY_B >> k) & 1) as u8)
} else {
(0, 0)
};
assert_eq!(out[2 * k], ea, "poly A parity at input bit {k}");
assert_eq!(out[2 * k + 1], eb, "poly B parity at input bit {k}");
}
}
#[test]
fn conv_encoder_is_linear() {
let a = WsprMessage::new("K1ABC", grid("FN42"), 37).unwrap().pack();
let b = WsprMessage::new("G4XYZ", grid("IO90"), 30).unwrap().pack();
let mut x = [0u8; 11];
for i in 0..11 {
x[i] = a[i] ^ b[i];
}
let ea = convolutional_encode(&a);
let eb = convolutional_encode(&b);
let ex = convolutional_encode(&x);
for i in 0..SYMBOL_COUNT {
assert_eq!(ea[i] ^ eb[i], ex[i], "linearity at coded bit {i}");
}
}
#[test]
fn interleaver_is_a_permutation() {
let mut seen = [false; SYMBOL_COUNT];
let mut input = [0u8; SYMBOL_COUNT];
for round in 0..SYMBOL_COUNT {
input.fill(0);
input[round] = 1;
let out = interleave(&input);
let pos = out.iter().position(|&b| b == 1).expect("bit lost");
assert_eq!(out.iter().filter(|&&b| b == 1).count(), 1, "bit duplicated");
assert!(!seen[pos], "two inputs land at {pos}");
seen[pos] = true;
}
assert!(seen.iter().all(|&s| s), "not surjective");
}
#[test]
fn interleaver_matches_bit_reversal_definition() {
let mut input = [0u8; SYMBOL_COUNT];
input[0] = 1;
input[1] = 1;
input[2] = 1;
input[3] = 1;
let out = interleave(&input);
for (i, &b) in out.iter().enumerate() {
let expect = matches!(i, 0 | 128 | 64 | 32);
assert_eq!(b == 1, expect, "position {i}");
}
}
#[test]
fn sync_vector_spot_checks() {
assert_eq!(SYNC_VECTOR.len(), SYMBOL_COUNT);
assert_eq!(&SYNC_VECTOR[..8], &[1, 1, 0, 0, 0, 0, 0, 0]);
assert_eq!(&SYNC_VECTOR[8..16], &[1, 0, 0, 0, 1, 1, 1, 0]);
assert_eq!(&SYNC_VECTOR[80..90], &[0, 0, 1, 0, 1, 1, 0, 0, 0, 1]);
assert_eq!(&SYNC_VECTOR[154..], &[0, 0, 0, 1, 1, 0, 0, 0]);
assert!(SYNC_VECTOR.iter().all(|&b| b <= 1));
}
#[test]
fn channel_symbols_shape() {
let symbols = WsprMessage::new("K1ABC", grid("FN42"), 37)
.unwrap()
.channel_symbols();
assert_eq!(symbols.len(), SYMBOL_COUNT);
assert!(symbols.iter().all(|&s| s <= 3));
for i in 0..SYMBOL_COUNT {
assert_eq!(symbols[i] & 1, SYNC_VECTOR[i], "sync parity at {i}");
}
}
#[test]
fn channel_symbols_end_to_end_rederivation() {
let msg = WsprMessage::new("G4XYZ", grid("IO90"), 30).unwrap();
let n = expected_callsign_value(b" G4XYZ");
let m = expected_m(b"IO90", 30);
let bits50: u64 = (u64::from(n) << 22) | u64::from(m);
let mut coded = [0u8; SYMBOL_COUNT];
let mut reg: u32 = 0;
for k in 0..81 {
let bit = if k < 50 {
((bits50 >> (49 - k)) & 1) as u32
} else {
0
};
reg = (reg << 1) | bit;
coded[2 * k] = ((reg & POLY_A).count_ones() & 1) as u8;
coded[2 * k + 1] = ((reg & POLY_B).count_ones() & 1) as u8;
}
let mut data = [0u8; SYMBOL_COUNT];
let mut k = 0usize;
for i in 0..=255u8 {
let j = usize::from(i.reverse_bits());
if j < SYMBOL_COUNT {
data[j] = coded[k];
k += 1;
}
}
let mut expected = [0u8; SYMBOL_COUNT];
for i in 0..SYMBOL_COUNT {
expected[i] = SYNC_VECTOR[i] + 2 * data[i];
}
assert_eq!(msg.channel_symbols(), expected);
}
#[rustfmt::skip]
const K1ABC_FN42_37_SNAPSHOT: [u8; SYMBOL_COUNT] = [
3, 3, 0, 0, 2, 0, 0, 0, 1, 0, 2, 0, 1, 3, 1, 2, 2, 2, 1, 0,
0, 3, 2, 3, 1, 3, 3, 2, 2, 0, 2, 0, 0, 0, 3, 2, 0, 1, 2, 3,
2, 2, 0, 0, 2, 2, 3, 2, 1, 1, 0, 2, 3, 3, 2, 1, 0, 2, 2, 1,
3, 2, 1, 2, 2, 2, 0, 3, 3, 0, 3, 0, 3, 0, 1, 2, 1, 0, 2, 1,
2, 0, 3, 2, 1, 3, 2, 0, 0, 3, 3, 2, 3, 0, 3, 2, 2, 0, 3, 0,
2, 0, 2, 0, 1, 0, 2, 3, 0, 2, 1, 1, 1, 2, 3, 3, 0, 2, 3, 1,
2, 1, 2, 2, 2, 1, 3, 3, 2, 0, 0, 0, 0, 1, 0, 3, 2, 0, 1, 3,
2, 2, 2, 2, 2, 0, 2, 3, 3, 2, 3, 2, 3, 3, 2, 0, 0, 3, 1, 2,
2, 2,
];
#[test]
fn k1abc_regression_snapshot() {
let symbols = WsprMessage::new("K1ABC", grid("FN42"), 37)
.unwrap()
.channel_symbols();
assert_eq!(symbols, K1ABC_FN42_37_SNAPSHOT);
}
#[test]
fn config_validation() {
let sr12k = SampleRate::new(12_000).unwrap();
assert!(WsprConfig::new(1_500, sr12k).is_ok());
assert_eq!(
WsprConfig::new(1_500, SampleRate::new(44_100).unwrap()),
Err(WsprError::SampleRateInexact { got: 44_100 })
);
let cfg48 = WsprConfig::new(1_500, SampleRate::new(48_000).unwrap()).unwrap();
assert_eq!(cfg48.samples_per_symbol(), 32_768);
assert!(WsprConfig::new(0, sr12k).is_err());
assert!(WsprConfig::new(6_000, sr12k).is_err());
assert!(WsprConfig::new(5_990, sr12k).is_ok());
}
#[test]
fn total_sample_count() {
let cfg = WsprConfig::new(1_500, SampleRate::new(12_000).unwrap()).unwrap();
assert_eq!(cfg.samples_per_symbol(), 8_192);
let msg = WsprMessage::new("K1ABC", grid("FN42"), 37).unwrap();
let mut tx = WsprModulator::for_message(cfg, &msg);
assert_eq!(tx.total_samples(), 162 * 8_192);
let mut count = 0u64;
let mut buf = [0i16; 4_096];
loop {
let n = tx.fill_i16(&mut buf);
count += n as u64;
if n < buf.len() {
break;
}
}
assert_eq!(count, 162 * 8_192); assert_eq!(tx.next_i16(), None);
}
fn goertzel(samples: &[i16], freq_hz: f64, sample_rate: f64) -> f64 {
let w = 2.0 * core::f64::consts::PI * freq_hz / sample_rate;
let coeff = 2.0 * w.cos();
let (mut s1, mut s2) = (0.0f64, 0.0f64);
for &x in samples {
let s0 = f64::from(x) + coeff * s1 - s2;
s2 = s1;
s1 = s0;
}
(s1 * s1 + s2 * s2 - coeff * s1 * s2).sqrt()
}
#[test]
fn per_symbol_dominant_tone() {
let cfg = WsprConfig::new(1_500, SampleRate::new(12_000).unwrap()).unwrap();
let msg = WsprMessage::new("K1ABC", grid("FN42"), 37).unwrap();
let symbols = msg.channel_symbols();
let mut tx = WsprModulator::new(cfg, symbols);
let spacing = 12_000.0 / 8_192.0;
let mut buf = [0i16; 8_192];
for (idx, &sym) in symbols.iter().enumerate() {
let n = tx.fill_i16(&mut buf);
assert_eq!(n, buf.len());
if idx % 20 != 0 {
continue;
}
let mut best = (0usize, 0.0f64);
for tone in 0..4 {
let f = 1_500.0 + tone as f64 * spacing;
let mag = goertzel(&buf, f, 12_000.0);
if mag > best.1 {
best = (tone, mag);
}
}
assert_eq!(
best.0,
usize::from(sym),
"dominant tone at symbol {idx} (expected {sym})"
);
}
}
#[test]
fn phase_continuity() {
let cfg = WsprConfig::new(1_500, SampleRate::new(12_000).unwrap()).unwrap();
let msg = WsprMessage::new("G4XYZ", grid("IO90"), 30).unwrap();
let mut tx = WsprModulator::for_message(cfg, &msg);
let max_delta = (2.0 * core::f64::consts::PI * 1_504.5 / 12_000.0 * 32_767.0) as i32 + 64;
let mut prev = i32::from(tx.next_i16().unwrap());
for _ in 0..(6 * 8_192 - 1) {
let s = i32::from(tx.next_i16().unwrap());
assert!(
(s - prev).abs() <= max_delta,
"phase jump: {prev} -> {s} exceeds {max_delta}"
);
prev = s;
}
}
#[test]
fn f32_path_matches_i16_shape() {
let cfg = WsprConfig::new(1_500, SampleRate::new(12_000).unwrap()).unwrap();
let msg = WsprMessage::new("K1ABC", grid("FN42"), 37).unwrap();
let mut a = WsprModulator::for_message(cfg, &msg);
let mut b = WsprModulator::for_message(cfg, &msg);
for _ in 0..10_000 {
let x = f64::from(a.next_i16().unwrap()) / 32_767.0;
let y = f64::from(b.next_f32().unwrap());
assert!((x - y).abs() < 2.0e-3, "i16/f32 paths diverge: {x} vs {y}");
}
}
#[test]
fn iterator_adapter_agrees_with_pull() {
let cfg = WsprConfig::new(1_500, SampleRate::new(12_000).unwrap()).unwrap();
let msg = WsprMessage::new("K1ABC", grid("FN42"), 37).unwrap();
let mut pull = WsprModulator::for_message(cfg, &msg);
let it = WsprModulator::for_message(cfg, &msg);
for (i, s) in it.take(20_000).enumerate() {
assert_eq!(Some(s), pull.next_i16(), "sample {i}");
}
}