[fix](trx-wspr): reduce false positives with stricter validation

Restrict accepted power levels to the 19 valid WSPR values instead of
any 0-60. Require a digit at position 1 or 2 of the trimmed callsign
per the WSPR encoding rules. Skip candidates whose sync correlation
score falls below a minimum threshold before attempting Fano decode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
This commit is contained in:
2026-03-17 22:57:13 +01:00
parent 7527770c0c
commit cf4c262456
2 changed files with 28 additions and 3 deletions
+9 -1
View File
@@ -25,6 +25,11 @@ const DT_SEARCH_STEP_SAMPLES: isize = (WSPR_SAMPLE_RATE as isize) / 2;
// Number of top frequency candidates to try full decode on
const MAX_FREQ_CANDIDATES: usize = 8;
// Minimum sync correlation score to attempt a full decode. Candidates below
// this threshold are almost certainly noise and skipping them avoids expensive
// Fano decode attempts that would produce false positives.
const MIN_SYNC_SCORE: f32 = 10.0;
/// WSPR sync vector (162 bits). symbol = sync[i] + 2*data[i].
/// The LSB of each received symbol should match this pattern.
#[rustfmt::skip]
@@ -123,7 +128,10 @@ impl WsprDecoder {
let mut results = Vec::new();
let mut seen_messages = std::collections::HashSet::new();
for &(freq, dt_samples, _) in candidates.iter().take(MAX_FREQ_CANDIDATES) {
for &(freq, dt_samples, score) in candidates.iter().take(MAX_FREQ_CANDIDATES) {
if score < MIN_SYNC_SCORE {
break; // candidates are sorted by score, no point continuing
}
let start = (EXPECTED_SIGNAL_START_SAMPLES as isize + dt_samples) as usize;
let signal = &samples[start..start + WSPR_SIGNAL_SAMPLES];
+19 -2
View File
@@ -141,9 +141,12 @@ fn unpack_message(bits: &[u8; NBITS]) -> Option<String> {
power_code = (power_code << 1) | b as u32;
}
// power_code is the raw dBm value; valid WSPR levels are 060 dBm.
// WSPR only permits specific power levels (dBm).
const VALID_POWER: [i32; 19] = [
0, 3, 7, 10, 13, 17, 20, 23, 27, 30, 33, 37, 40, 43, 47, 50, 53, 57, 60,
];
let power_dbm = power_code as i32;
if !(0..=60).contains(&power_dbm) {
if !VALID_POWER.contains(&power_dbm) {
return None;
}
@@ -182,9 +185,23 @@ fn unpack_message(bits: &[u8; NBITS]) -> Option<String> {
.trim()
.to_string();
// WSPR callsigns: after trimming, the digit (from position 2 of the
// 6-char padded form) must appear at index 1 or 2. The callsign must
// also contain at least one letter and be at least 3 characters long.
if callsign.len() < 3 || !callsign.chars().any(|c| c.is_alphabetic()) {
return None;
}
let has_digit_at_1_or_2 = callsign
.chars()
.nth(1)
.is_some_and(|c| c.is_ascii_digit())
|| callsign
.chars()
.nth(2)
.is_some_and(|c| c.is_ascii_digit());
if !has_digit_at_1_or_2 {
return None;
}
// Decode Maidenhead grid from M1.
// M1 = (179 - 10*loc1 - loc3)*180 + 10*loc2 + loc4