From cf4c262456a0461a1219f49d2253ff23c3c3e4d5 Mon Sep 17 00:00:00 2001 From: Stanislaw Grams Date: Tue, 17 Mar 2026 22:57:13 +0100 Subject: [PATCH] [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 Signed-off-by: Stanislaw Grams --- src/decoders/trx-wspr/src/decoder.rs | 10 +++++++++- src/decoders/trx-wspr/src/protocol.rs | 21 +++++++++++++++++++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/decoders/trx-wspr/src/decoder.rs b/src/decoders/trx-wspr/src/decoder.rs index 6cfc997..2ad8308 100644 --- a/src/decoders/trx-wspr/src/decoder.rs +++ b/src/decoders/trx-wspr/src/decoder.rs @@ -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]; diff --git a/src/decoders/trx-wspr/src/protocol.rs b/src/decoders/trx-wspr/src/protocol.rs index c5e3a0f..e800d49 100644 --- a/src/decoders/trx-wspr/src/protocol.rs +++ b/src/decoders/trx-wspr/src/protocol.rs @@ -141,9 +141,12 @@ fn unpack_message(bits: &[u8; NBITS]) -> Option { power_code = (power_code << 1) | b as u32; } - // power_code is the raw dBm value; valid WSPR levels are 0–60 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 { .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