[fix](trx-rds): add staleness timeout to prevent decoder freeze

If the incumbent candidate has not produced a state update in 2 seconds,
clear its score advantage so any candidate can take over.  This prevents
the decoder from "freezing" on stale data when the incumbent's timing or
carrier tracking degrades — particularly important for dynamic PS where
the station rotates program service text.

Signed-off-by: Claude <noreply@anthropic.com>

https://claude.ai/code/session_0136sPdLUpYgvskrzbi2Epkv
Signed-off-by: Claude <noreply@anthropic.com>
This commit is contained in:
Claude
2026-03-27 07:41:13 +00:00
committed by Stan Grams
parent 104629e373
commit 6b33550116
+29 -13
View File
@@ -65,6 +65,11 @@ const RRC_ALPHA: f32 = 0.30;
/// and the extra taps keep stopband leakage below 60 dB, critical when α /// and the extra taps keep stopband leakage below 60 dB, critical when α
/// is small. Added latency is ~4.2 ms at 2375 chips/s, negligible for RDS. /// is small. Added latency is ~4.2 ms at 2375 chips/s, negligible for RDS.
const RRC_SPAN_CHIPS: usize = 10; const RRC_SPAN_CHIPS: usize = 10;
/// Staleness timeout in seconds. If the incumbent candidate has not produced
/// a state update in this many seconds, its score advantage is cleared so any
/// candidate can take over. Prevents the decoder from "freezing" when the
/// incumbent's timing or carrier tracking degrades.
const STALE_TIMEOUT_SECS: f32 = 2.0;
const OFFSET_A: u16 = 0x0FC; const OFFSET_A: u16 = 0x0FC;
const OFFSET_B: u16 = 0x198; const OFFSET_B: u16 = 0x198;
@@ -305,8 +310,6 @@ struct Candidate {
rt_ab_flag: bool, rt_ab_flag: bool,
ptyn_bytes: [u8; 8], ptyn_bytes: [u8; 8],
ptyn_seen: [bool; 2], ptyn_seen: [bool; 2],
/// Consecutive block decode failures in locked mode.
consecutive_block_failures: u8,
/// Tech 6: accumulated LLR for the PI field (16 bits, MSB first). /// Tech 6: accumulated LLR for the PI field (16 bits, MSB first).
pi_llr_acc: [f32; 16], pi_llr_acc: [f32; 16],
/// Tech 6: number of Block A observations accumulated. /// Tech 6: number of Block A observations accumulated.
@@ -360,7 +363,7 @@ impl Candidate {
rt_ab_flag: false, rt_ab_flag: false,
ptyn_bytes: [b' '; 8], ptyn_bytes: [b' '; 8],
ptyn_seen: [false; 2], ptyn_seen: [false; 2],
consecutive_block_failures: 0,
pi_llr_acc: [0.0; 16], pi_llr_acc: [0.0; 16],
pi_acc_count: 0, pi_acc_count: 0,
nominal_clock_inc, nominal_clock_inc,
@@ -511,7 +514,6 @@ impl Candidate {
self.block_reg = 0; self.block_reg = 0;
self.block_bits = 0; self.block_bits = 0;
self.block_a = data; self.block_a = data;
self.consecutive_block_failures = 0;
self.state.pi = Some(data); self.state.pi = Some(data);
None None
} }
@@ -534,7 +536,6 @@ impl Candidate {
return None; return None;
}; };
self.consecutive_block_failures = 0;
match (expected, kind) { match (expected, kind) {
(ExpectBlock::B, BlockKind::B) => { (ExpectBlock::B, BlockKind::B) => {
self.block_b = data; self.block_b = data;
@@ -858,6 +859,12 @@ pub struct RdsDecoder {
/// cycling through `best_state` with partially-accumulated ps_seen / rt_seen. /// cycling through `best_state` with partially-accumulated ps_seen / rt_seen.
best_candidate_idx: Option<usize>, best_candidate_idx: Option<usize>,
best_state: Option<RdsData>, best_state: Option<RdsData>,
/// Running sample counter for staleness detection.
sample_counter: u64,
/// Sample counter at which best_state was last updated.
last_update_sample: u64,
/// Number of samples before the incumbent is considered stale.
stale_threshold: u64,
} }
impl RdsDecoder { impl RdsDecoder {
@@ -882,6 +889,9 @@ impl RdsDecoder {
best_score: 0, best_score: 0,
best_candidate_idx: None, best_candidate_idx: None,
best_state: None, best_state: None,
sample_counter: 0,
last_update_sample: 0,
stale_threshold: (STALE_TIMEOUT_SECS * sample_rate_f) as u64,
} }
} }
@@ -940,14 +950,22 @@ impl RdsDecoder {
self.carrier_phase = self.carrier_phase.rem_euclid(TAU); self.carrier_phase = self.carrier_phase.rem_euclid(TAU);
} }
self.sample_counter += 1;
// Staleness check: if the incumbent hasn't produced an update in
// STALE_TIMEOUT_SECS, clear its score advantage so any candidate
// can take over. This prevents the decoder from "freezing" on stale
// data when the incumbent's timing or carrier tracking degrades.
if self.best_candidate_idx.is_some()
&& self.sample_counter - self.last_update_sample > self.stale_threshold
{
self.best_score = 0;
self.best_candidate_idx = None;
}
for (idx, candidate) in self.candidates.iter_mut().enumerate() { for (idx, candidate) in self.candidates.iter_mut().enumerate() {
let is_incumbent = self.best_candidate_idx == Some(idx); let is_incumbent = self.best_candidate_idx == Some(idx);
if let Some(update) = candidate.process_sample(mixed_i, mixed_q) { if let Some(update) = candidate.process_sample(mixed_i, mixed_q) {
// The incumbent candidate can always update at equal score so its
// accumulated text state (ps_seen, rt_seen, etc.) stays coherent.
// A challenger must exceed the current best score to take over,
// which prevents N candidates decoding the same groups from cycling
// through best_state with different partial ps_seen / rt_seen arrays.
let qualifies = candidate.score > self.best_score let qualifies = candidate.score > self.best_score
|| (is_incumbent && candidate.score >= self.best_score) || (is_incumbent && candidate.score >= self.best_score)
|| self.best_state.is_none(); || self.best_state.is_none();
@@ -960,12 +978,10 @@ impl RdsDecoder {
self.best_score = candidate.score; self.best_score = candidate.score;
self.best_candidate_idx = Some(idx); self.best_candidate_idx = Some(idx);
self.best_state = Some(update); self.best_state = Some(update);
self.last_update_sample = self.sample_counter;
} }
} }
} else if is_incumbent { } else if is_incumbent {
// Even when no state change occurred, keep best_score current so
// challengers must match the incumbent's actual group count to
// take over, not just its last state-emitting group count.
self.best_score = candidate.score; self.best_score = candidate.score;
} }
} }