Files
trx-rs/src/decoders/trx-ftx/src/ft8/mod.rs
T
sjg ba48de2d30
Sync docs to Wiki / wiki (push) Has been cancelled
Initial commit
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-05-17 23:25:14 +02:00

241 lines
7.4 KiB
Rust

// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! FT8-specific sync scoring, likelihood extraction, and tone encoding.
use crate::common::constants::*;
use crate::common::crc::ftx_add_crc;
use crate::common::decode::{get_cand_offset, wf_mag_safe, Candidate};
use crate::common::encode::encode174;
use crate::common::monitor::Waterfall;
use crate::common::protocol::*;
/// Compute FT8 sync score for a candidate.
pub(crate) fn ft8_sync_score(wf: &Waterfall, cand: &Candidate) -> i32 {
let base = get_cand_offset(wf, cand);
let mut score: i32 = 0;
let mut num_average: i32 = 0;
for m in 0..FT8_NUM_SYNC {
for (k, &sm_val) in FT8_COSTAS_PATTERN.iter().enumerate().take(FT8_LENGTH_SYNC) {
let block = FT8_SYNC_OFFSET * m + k;
let block_abs = cand.time_offset as i32 + block as i32;
if block_abs < 0 {
continue;
}
if block_abs >= wf.num_blocks as i32 {
break;
}
let p_offset = base + block * wf.block_stride;
let sm = sm_val as usize;
if sm > 0 {
let a = wf_mag_safe(wf, p_offset + sm).mag_int();
let b = wf_mag_safe(wf, p_offset + sm - 1).mag_int();
score += a - b;
num_average += 1;
}
if sm < 7 {
let a = wf_mag_safe(wf, p_offset + sm).mag_int();
let b = wf_mag_safe(wf, p_offset + sm + 1).mag_int();
score += a - b;
num_average += 1;
}
if k > 0 && block_abs > 0 {
let a = wf_mag_safe(wf, p_offset + sm).mag_int();
let b_idx = (p_offset + sm).wrapping_sub(wf.block_stride);
let b = if b_idx < wf.mag.len() {
wf.mag[b_idx].mag_int()
} else {
0
};
score += a - b;
num_average += 1;
}
if k + 1 < FT8_LENGTH_SYNC && block_abs + 1 < wf.num_blocks as i32 {
let a = wf_mag_safe(wf, p_offset + sm).mag_int();
let b = wf_mag_safe(wf, p_offset + sm + wf.block_stride).mag_int();
score += a - b;
num_average += 1;
}
}
}
if num_average > 0 {
score / num_average
} else {
0
}
}
/// Extract log-likelihood ratios for FT8 symbols.
pub(crate) fn ft8_extract_likelihood(
wf: &Waterfall,
cand: &Candidate,
log174: &mut [f32; FTX_LDPC_N],
) {
let base = get_cand_offset(wf, cand);
for k in 0..FT8_ND {
let sym_idx = k + if k < 29 { 7 } else { 14 };
let bit_idx = 3 * k;
let block = cand.time_offset as i32 + sym_idx as i32;
if block < 0 || block >= wf.num_blocks as i32 {
log174[bit_idx] = 0.0;
log174[bit_idx + 1] = 0.0;
log174[bit_idx + 2] = 0.0;
} else {
let p_offset = base + sym_idx * wf.block_stride;
ft8_extract_symbol(wf, p_offset, &mut log174[bit_idx..bit_idx + 3]);
}
}
}
fn ft8_extract_symbol(wf: &Waterfall, offset: usize, logl: &mut [f32]) {
let mut s2 = [0.0f32; 8];
for j in 0..8 {
s2[j] = wf_mag_safe(wf, offset + FT8_GRAY_MAP[j] as usize).mag;
}
logl[0] = max4(s2[4], s2[5], s2[6], s2[7]) - max4(s2[0], s2[1], s2[2], s2[3]);
logl[1] = max4(s2[2], s2[3], s2[6], s2[7]) - max4(s2[0], s2[1], s2[4], s2[5]);
logl[2] = max4(s2[1], s2[3], s2[5], s2[7]) - max4(s2[0], s2[2], s2[4], s2[6]);
}
fn max4(a: f32, b: f32, c: f32, d: f32) -> f32 {
a.max(b).max(c.max(d))
}
/// Generate FT8 tone sequence from payload data.
///
/// `payload` is a 10-byte array containing 77 bits of payload data.
/// `tones` is an array of `FT8_NN` (79) bytes to store the generated tones (encoded as 0..7).
///
/// Message structure: S7 D29 S7 D29 S7
pub fn ft8_encode(payload: &[u8], tones: &mut [u8]) {
let mut a91 = [0u8; FTX_LDPC_K_BYTES];
// Compute and add CRC at the end of the message
ftx_add_crc(payload, &mut a91);
let mut codeword = [0u8; FTX_LDPC_N_BYTES];
encode174(&a91, &mut codeword);
let mut mask: u8 = 0x80;
let mut i_byte: usize = 0;
for i_tone in 0..FT8_NN {
if i_tone < 7 {
tones[i_tone] = FT8_COSTAS_PATTERN[i_tone];
} else if (36..43).contains(&i_tone) {
tones[i_tone] = FT8_COSTAS_PATTERN[i_tone - 36];
} else if (72..79).contains(&i_tone) {
tones[i_tone] = FT8_COSTAS_PATTERN[i_tone - 72];
} else {
// Extract 3 bits from codeword
let mut bits3: u8 = 0;
if codeword[i_byte] & mask != 0 {
bits3 |= 4;
}
mask >>= 1;
if mask == 0 {
mask = 0x80;
i_byte += 1;
}
if codeword[i_byte] & mask != 0 {
bits3 |= 2;
}
mask >>= 1;
if mask == 0 {
mask = 0x80;
i_byte += 1;
}
if codeword[i_byte] & mask != 0 {
bits3 |= 1;
}
mask >>= 1;
if mask == 0 {
mask = 0x80;
i_byte += 1;
}
tones[i_tone] = FT8_GRAY_MAP[bits3 as usize];
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ft8_encode_length() {
let payload = [0u8; 10];
let mut tones = [0u8; FT8_NN];
ft8_encode(&payload, &mut tones);
assert_eq!(tones.len(), 79);
}
#[test]
fn ft8_encode_costas_sync() {
let payload = [0u8; 10];
let mut tones = [0u8; FT8_NN];
ft8_encode(&payload, &mut tones);
// Verify the three Costas sync patterns at positions 0..7, 36..43, 72..79
for i in 0..7 {
assert_eq!(tones[i], FT8_COSTAS_PATTERN[i], "Costas S1 mismatch at {i}");
assert_eq!(
tones[36 + i],
FT8_COSTAS_PATTERN[i],
"Costas S2 mismatch at {}",
36 + i
);
assert_eq!(
tones[72 + i],
FT8_COSTAS_PATTERN[i],
"Costas S3 mismatch at {}",
72 + i
);
}
}
#[test]
fn ft8_encode_tones_in_range() {
let payload = [0xAB, 0xCD, 0xEF, 0x01, 0x23, 0x45, 0x67, 0x89, 0x0A, 0xB0];
let mut tones = [0u8; FT8_NN];
ft8_encode(&payload, &mut tones);
for (i, &t) in tones.iter().enumerate() {
assert!(t < 8, "FT8 tone at position {i} out of range: {t}");
}
}
#[test]
fn ft8_encode_deterministic() {
let payload = [0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE, 0x00, 0x10];
let mut tones1 = [0u8; FT8_NN];
let mut tones2 = [0u8; FT8_NN];
ft8_encode(&payload, &mut tones1);
ft8_encode(&payload, &mut tones2);
assert_eq!(tones1, tones2);
}
#[test]
fn ft8_encode_different_payloads_differ() {
let payload1 = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
let payload2 = [0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0];
let mut tones1 = [0u8; FT8_NN];
let mut tones2 = [0u8; FT8_NN];
ft8_encode(&payload1, &mut tones1);
ft8_encode(&payload2, &mut tones2);
// Data tones should differ (sync tones are the same)
assert_ne!(tones1, tones2);
}
}