From a7b863aff1043e29418120d5bb1af46571520728 Mon Sep 17 00:00:00 2001 From: Dominic Griesel Date: Mon, 7 Sep 2026 21:22:51 +0200 Subject: [PATCH 1/7] refactor: share RX transitions and spectrum statistics Extract backend-neutral control and math helpers for the tinySA prerequisites. Preserve finite IQ averaging, peak decay, noise-floor calculation, and RX session behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/signal/fft/analysis.rs | 19 ++-- src/signal/fft/frame.rs | 56 +++++++++--- src/signal/mod.rs | 1 + src/signal/stats.rs | 94 ++++++++++++++++++++ src/tasks/rx/control.rs | 175 +++++++++++++++++++++++++++++++------ 5 files changed, 298 insertions(+), 47 deletions(-) create mode 100644 src/signal/stats.rs diff --git a/src/signal/fft/analysis.rs b/src/signal/fft/analysis.rs index 83732d2..47201ee 100644 --- a/src/signal/fft/analysis.rs +++ b/src/signal/fft/analysis.rs @@ -52,12 +52,7 @@ pub(super) fn carrier(linear: &[f32], sample_rate: f64, noise_floor_db: f32) -> /// /// `scratch` is the worker's reused buffer; nothing is allocated here. pub(super) fn noise_floor(smoothed: &[f32], scratch: &mut [f32]) -> f32 { - scratch.copy_from_slice(smoothed); - let count = (smoothed.len() / NOISE_FLOOR_FRACTION).max(1); - scratch.select_nth_unstable_by(count - 1, |a, b| { - a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal) - }); - scratch[..count].iter().sum::() / count as f32 + crate::signal::stats::quietest_mean(smoothed, scratch, NOISE_FLOOR_FRACTION) } /// The SNR the worker publishes: the strongest real bin *near centre*, minus the @@ -192,6 +187,18 @@ mod tests { assert_eq!(noise_floor(&[-42.0], &mut scratch), -42.0); } + #[test] + fn finite_iq_noise_floor_keeps_the_quietest_tenth_mean() { + let mut bins = [-20.0; 29]; + bins[7] = -100.0; + bins[19] = -90.0; + bins[25] = -80.0; + let mut scratch = [0.0; 29]; + assert_eq!(noise_floor(&bins, &mut scratch), -95.0); + bins.reverse(); + assert_eq!(noise_floor(&bins, &mut scratch), -95.0); + } + /// `level_db` is the carrier's **total** power, spread evenly across the bins it /// covers - not a per-bin level. That distinction is the whole point: a fixed /// per-bin level would make the carrier's power scale with how many bins the diff --git a/src/signal/fft/frame.rs b/src/signal/fft/frame.rs index 50e3871..b36bc08 100644 --- a/src/signal/fft/frame.rs +++ b/src/signal/fft/frame.rs @@ -125,19 +125,8 @@ pub(super) fn average( decay_db: f32, initialized: &mut bool, ) { - if !*initialized { - smoothed.copy_from_slice(shifted); - peak.copy_from_slice(shifted); - *initialized = true; - return; - } - let one_minus = 1.0 - alpha; - for (s, &new) in smoothed.iter_mut().zip(shifted.iter()) { - *s = alpha * new + one_minus * *s; - } - for (p, &s) in peak.iter_mut().zip(smoothed.iter()) { - *p = (*p - decay_db).max(s); - } + crate::signal::stats::average_and_peak(shifted, smoothed, peak, alpha, decay_db, *initialized); + *initialized = true; } #[cfg(test)] @@ -299,4 +288,45 @@ mod tests { } assert!(s > 0.99, "EMA should converge to target, got {}", s); } + + #[test] + fn finite_iq_sequence_keeps_the_existing_average_and_peak_law() { + let mut smoothed = [0.0]; + let mut peak = [0.0]; + let mut initialized = false; + for sample in [-90.0, -80.0, -100.0] { + average( + &[sample], + &mut smoothed, + &mut peak, + 0.2, + 0.5, + &mut initialized, + ); + } + + assert!((smoothed[0] - -90.4).abs() < 1e-4); + assert!((peak[0] - -88.5).abs() < 1e-4); + } + + #[test] + fn finite_iq_first_frame_and_reset_seed_both_traces() { + let mut smoothed = [-120.0; 2]; + let mut peak = [0.0; 2]; + let mut initialized = false; + for samples in [[-90.0, -80.0], [-100.0, -110.0]] { + average( + &samples, + &mut smoothed, + &mut peak, + 0.2, + 0.5, + &mut initialized, + ); + assert!(initialized); + assert_eq!(smoothed, samples); + assert_eq!(peak, samples); + initialized = false; + } + } } diff --git a/src/signal/mod.rs b/src/signal/mod.rs index aa319e3..2d46034 100644 --- a/src/signal/mod.rs +++ b/src/signal/mod.rs @@ -12,6 +12,7 @@ pub mod net; pub mod noise_slope; pub mod rds; pub mod rds_demod; +mod stats; pub mod stream; pub use demod::DemodWorker; diff --git a/src/signal/stats.rs b/src/signal/stats.rs new file mode 100644 index 0000000..897f67d --- /dev/null +++ b/src/signal/stats.rs @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 MusiThang + +pub(crate) fn finite_ema(previous: f32, sample: f32, alpha: f32) -> f32 { + match (previous.is_finite(), sample.is_finite()) { + (true, true) => alpha * sample + (1.0 - alpha) * previous, + (false, true) => sample, + (true, false) => previous, + (false, false) => f32::NEG_INFINITY, + } +} + +pub(crate) fn average_and_peak( + samples: &[f32], + smoothed: &mut [f32], + peak: &mut [f32], + alpha: f32, + decay_db: f32, + has_history: bool, +) { + for ((smoothed, peak), sample) in smoothed + .iter_mut() + .zip(peak.iter_mut()) + .zip(samples.iter().copied()) + { + if !has_history { + *smoothed = f32::NEG_INFINITY; + *peak = f32::NEG_INFINITY; + } + *smoothed = finite_ema(*smoothed, sample, alpha); + let decayed_peak = if peak.is_finite() { + *peak - decay_db + } else { + f32::NEG_INFINITY + }; + *peak = if smoothed.is_finite() { + decayed_peak.max(*smoothed) + } else { + decayed_peak + }; + } +} + +pub(crate) fn quietest_mean(values: &[f32], scratch: &mut [f32], fraction: usize) -> f32 { + let mut finite_count = 0; + for value in values.iter().copied().filter(|value| value.is_finite()) { + scratch[finite_count] = value; + finite_count += 1; + } + if finite_count == 0 { + return f32::NEG_INFINITY; + } + + let count = (finite_count / fraction.max(1)).max(1); + scratch[..finite_count].select_nth_unstable_by(count - 1, f32::total_cmp); + scratch[..count].iter().sum::() / count as f32 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn finite_ema_keeps_the_available_value() { + assert_eq!(finite_ema(-90.0, -80.0, 0.2), -88.0); + assert_eq!(finite_ema(f32::NAN, -80.0, 0.2), -80.0); + assert_eq!(finite_ema(-90.0, f32::NAN, 0.2), -90.0); + assert_eq!(finite_ema(f32::NAN, f32::INFINITY, 0.2), f32::NEG_INFINITY); + } + + #[test] + fn average_and_peak_handles_missing_samples() { + let mut smoothed = [0.0, 0.0]; + let mut peak = [0.0, 0.0]; + average_and_peak( + &[f32::NAN, -90.0], + &mut smoothed, + &mut peak, + 0.2, + 0.5, + false, + ); + average_and_peak(&[-70.0, f32::NAN], &mut smoothed, &mut peak, 0.2, 0.5, true); + assert_eq!(smoothed, [-70.0, -90.0]); + assert_eq!(peak, [-70.0, -90.0]); + } + + #[test] + fn quietest_mean_ignores_non_finite_values() { + let values = [-100.0, f32::NAN, -90.0, f32::INFINITY, -80.0]; + let mut scratch = vec![0.0; values.len()]; + assert_eq!(quietest_mean(&values, &mut scratch, 10), -100.0); + } +} diff --git a/src/tasks/rx/control.rs b/src/tasks/rx/control.rs index c8e8e6c..dd4223b 100644 --- a/src/tasks/rx/control.rs +++ b/src/tasks/rx/control.rs @@ -23,6 +23,37 @@ use crate::state::{SdrMetrics, ADC_COMFORT_DBFS as AUTOGAIN_COMFORT_DBFS}; use super::metrics::RateTracker; use super::publish::Throughput; +pub(super) enum RxRequestTransition { + Unchanged(bool), + Started, + StartFailed(anyhow::Error), + Stopped(anyhow::Result<()>), +} + +pub(super) fn request_transition( + rx_enabled: bool, + hw_rx_active: bool, + start: impl FnOnce() -> anyhow::Result<()>, + stop: impl FnOnce() -> anyhow::Result<()>, +) -> RxRequestTransition { + match (rx_enabled, hw_rx_active) { + (true, false) => match start() { + Ok(()) => RxRequestTransition::Started, + Err(error) => RxRequestTransition::StartFailed(error), + }, + (false, true) => RxRequestTransition::Stopped(stop()), + (_, active) => RxRequestTransition::Unchanged(active), + } +} + +pub(super) fn unexpected_stop( + hw_rx_active: bool, + hw_streaming: bool, + cleanup: impl FnOnce() -> anyhow::Result<()>, +) -> Option> { + (hw_rx_active && !hw_streaming).then(cleanup) +} + /// Notice that the radio stopped streaming without being asked, and say so. /// /// Returns the new `hw_rx_active`. The device is told to stop as well: it has @@ -34,10 +65,9 @@ pub(super) fn note_unexpected_stop( hw_rx_active: bool, hw_streaming: bool, ) -> bool { - if !hw_rx_active || hw_streaming { + let Some(_) = unexpected_stop(hw_rx_active, hw_streaming, || device.stop_rx()) else { return hw_rx_active; - } - let _ = device.stop_rx(); + }; let mut m = state.lock().unwrap_or_else(|e| e.into_inner()); m.radio.rx_enabled = false; m.radio.hw_streaming = false; @@ -57,30 +87,29 @@ pub(super) fn apply_rx_request( rx_enabled: bool, hw_rx_active: bool, ) -> bool { - match (rx_enabled, hw_rx_active) { - (true, false) => match device.start_rx(Arc::clone(rx_ctx)) { - Ok(()) => { - // Fresh per-session throughput statistics, and a rate baseline - // that does not span the stop. Averaging across one would mix a - // silent stretch into the sample-rate offset. - tp.reset(); - rate.reset(); - let mut m = state.lock().unwrap_or_else(|e| e.into_inner()); - m.radio.rx_start_time = Some(Instant::now()); - m.timing.jitter_session_max_us = 0; - m.push_log("RX streaming started"); - true - } - Err(e) => { - let msg = format!("Error starting RX: {}", e); - let mut m = state.lock().unwrap_or_else(|e| e.into_inner()); - m.radio.rx_enabled = false; - m.push_log(msg); - false - } - }, - (false, true) => { - let result = device.stop_rx(); + match request_transition( + rx_enabled, + hw_rx_active, + || device.start_rx(Arc::clone(rx_ctx)), + || device.stop_rx(), + ) { + RxRequestTransition::Started => { + // Keep the sample-rate baseline within one RX session + tp.reset(); + rate.reset(); + let mut m = state.lock().unwrap_or_else(|e| e.into_inner()); + m.radio.rx_start_time = Some(Instant::now()); + m.timing.jitter_session_max_us = 0; + m.push_log("RX streaming started"); + true + } + RxRequestTransition::StartFailed(error) => { + let mut m = state.lock().unwrap_or_else(|e| e.into_inner()); + m.radio.rx_enabled = false; + m.push_log(format!("Error starting RX: {error}")); + false + } + RxRequestTransition::Stopped(result) => { rate.reset(); let mut m = state.lock().unwrap_or_else(|e| e.into_inner()); m.radio.rx_start_time = None; @@ -90,7 +119,7 @@ pub(super) fn apply_rx_request( } false } - (_, active) => active, + RxRequestTransition::Unchanged(active) => active, } } @@ -252,6 +281,7 @@ pub(super) fn advance_noise_sweep(state: &Arc>, device: &Arc Date: Mon, 7 Sep 2026 21:25:14 +0200 Subject: [PATCH 2/7] fix: align spectrum and waterfall FFT bin geometry Use N intervals across an N-bin IQ FFT span. Share bin windows and frequency conversion across spectrum rendering, waterfall columns, and peak-jump tuning. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/app/input/core.rs | 49 ++++- src/signal/fft/publish.rs | 3 + src/state/fixture.rs | 1 + src/state/mod.rs | 2 +- src/state/waterfall.rs | 176 +++++++++++++++++ src/ui/panels/core/spectrum/mod.rs | 1 + src/ui/panels/core/spectrum/scale.rs | 44 ++--- src/ui/panels/core/spectrum/trace.rs | 6 +- src/ui/panels/core/spectrum/view.rs | 181 ++++++++++++++---- src/ui/panels/core/waterfall/cells.rs | 61 ++++-- src/ui/panels/core/waterfall/mod.rs | 28 ++- .../lab/signal_characterization/metrics.rs | 1 + 12 files changed, 447 insertions(+), 106 deletions(-) diff --git a/src/app/input/core.rs b/src/app/input/core.rs index cd8d50f..2d9ad91 100644 --- a/src/app/input/core.rs +++ b/src/app/input/core.rs @@ -20,6 +20,18 @@ use crate::ui::widgets::micro_common::fmt_bw; use super::{global, metrics, InputCtx, KeyAction}; +fn strongest_bin_frequency(frame: &crate::state::FftFrame) -> Option { + let peak_bin = frame + .bins_dbfs + .iter() + .enumerate() + .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal)) + .map(|(index, _)| index)?; + frame + .frequency_of_bin(peak_bin) + .map(|frequency| frequency.round() as u64) +} + // ── Spectrum focus keys ─────────────────────────────────────────────────────── pub(super) fn spectrum(key: KeyEvent, ctx: &mut InputCtx<'_>) -> KeyAction { @@ -131,16 +143,7 @@ pub(super) fn spectrum(key: KeyEvent, ctx: &mut InputCtx<'_>) -> KeyAction { let freq = if let Some(f) = m.spectrum.cursor_freq { f } else if let Some(frame) = &m.waterfall.last_fft { - let peak_bin = frame - .bins_dbfs - .iter() - .enumerate() - .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal)) - .map(|(i, _)| i) - .unwrap_or(frame.bins_dbfs.len() / 2); - let left_hz = m.radio.frequency as f64 - frame.sample_rate / 2.0; - (left_hz + peak_bin as f64 / frame.bins_dbfs.len() as f64 * frame.sample_rate) - .round() as u64 + strongest_bin_frequency(frame).unwrap_or(m.radio.frequency) } else { m.radio.frequency }; @@ -297,3 +300,29 @@ pub(super) fn waterfall(key: KeyEvent, ctx: &mut InputCtx<'_>) -> KeyAction { } KeyAction::Continue } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn peak_jump_uses_the_captured_fft_frequency_after_retuning() { + let mut state = crate::state::SdrMetrics::fixture(); + state.radio.frequency = 100_000_000; + state.radio.config_sample_rate = 32_000_000.0; + let mut state = state.with_carrier(8_000_000.0, 70.0); + state.radio.frequency = 200_000_000; + let frame = state.waterfall.last_fft.as_ref().unwrap(); + assert_eq!(strongest_bin_frequency(frame), Some(108_000_000)); + assert_eq!(frame.bin_axis, crate::state::BinAxis::FftBins); + assert_eq!(frame.window(4).unwrap().span_hz, 8_000_000.0); + } + + #[test] + fn peak_jump_rejects_an_empty_frame() { + let state = crate::state::SdrMetrics::fixture().with_carrier(0.0, 70.0); + let mut frame = state.waterfall.last_fft.unwrap(); + frame.bins_dbfs = std::sync::Arc::new(Vec::new()); + assert_eq!(strongest_bin_frequency(&frame), None); + } +} diff --git a/src/signal/fft/publish.rs b/src/signal/fft/publish.rs index 7120753..a0b17b5 100644 --- a/src/signal/fft/publish.rs +++ b/src/signal/fft/publish.rs @@ -248,6 +248,7 @@ fn refresh_spectrum(m: &mut SdrMetrics, snap: &Snapshot<'_>) { channel_power_dbfs: r.channel_power_dbfs, occupied_bw_hz: r.occupied_bw_hz, enbw_hz: snap.enbw_hz, + bin_axis: crate::state::BinAxis::FftBins, }); } @@ -404,6 +405,8 @@ mod tests { let published = m.waterfall.last_fft.expect("a frame was published"); assert_eq!(published.bins_dbfs[0], -70.0, "the new trace"); + assert_eq!(published.bin_axis, crate::state::BinAxis::FftBins); + assert_eq!(published.frequency_of_bin(48), Some(100_250_000.0)); let held = being_drawn.waterfall.last_fft.expect("the UI's copy"); assert_eq!(held.bins_dbfs[0], -40.0, "still the frame it was drawing"); } diff --git a/src/state/fixture.rs b/src/state/fixture.rs index ccf6429..677d5b5 100644 --- a/src/state/fixture.rs +++ b/src/state/fixture.rs @@ -148,6 +148,7 @@ impl SdrMetrics { channel_power_dbfs: noise_floor + snr_db, occupied_bw_hz: 150_000, enbw_hz: sample_rate / N as f64, + bin_axis: crate::state::BinAxis::FftBins, }); self.signal.peak_to_nf_db = snr_db; self.signal.channel_power_dbfs = noise_floor + snr_db; diff --git a/src/state/mod.rs b/src/state/mod.rs index 9fd9a79..618a250 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -44,7 +44,7 @@ pub use ui::{ active_recall_slot, recall_from_hz, recall_to_hz, InputMode, LogEntry, LogLevel, MenuPane, MenuState, RailMode, UiState, RECALL_SLOTS, }; -pub use waterfall::{FftFrame, WaterfallState, WATERFALL_MIN_ROWS}; +pub use waterfall::{BinAxis, FftFrame, WaterfallState, WATERFALL_MIN_ROWS}; pub const THROUGHPUT_HISTORY_LEN: usize = 64; /// Depth of the per-callback gap ring feeding the `lab_timing` strip chart. ~256 diff --git a/src/state/waterfall.rs b/src/state/waterfall.rs index 13b41ee..9f2f159 100644 --- a/src/state/waterfall.rs +++ b/src/state/waterfall.rs @@ -5,6 +5,92 @@ use std::collections::VecDeque; use std::sync::Arc; use std::time::Instant; +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum BinAxis { + #[default] + FftBins, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BinWindow { + pub first_bin: usize, + pub bin_count: usize, + pub left_hz: f64, + pub span_hz: f64, +} + +impl BinAxis { + pub fn interval_count(self, bin_count: usize) -> Option { + match self { + Self::FftBins => (bin_count > 0).then_some(bin_count), + } + } + + pub fn window( + self, + center_hz: u64, + span_hz: f64, + bin_count: usize, + zoom: usize, + ) -> Option { + if bin_count == 0 || !span_hz.is_finite() || span_hz <= 0.0 { + return None; + } + + let visible = (bin_count / zoom.max(1)).max(1).min(bin_count); + let first = (bin_count / 2) + .saturating_sub(visible / 2) + .min(bin_count - visible); + let intervals = self.interval_count(bin_count)?; + let bin_hz = span_hz / intervals as f64; + let visible_intervals = self.interval_count(visible)?; + + Some(BinWindow { + first_bin: first, + bin_count: visible, + left_hz: center_hz as f64 - span_hz / 2.0 + first as f64 * bin_hz, + span_hz: visible_intervals as f64 * bin_hz, + }) + } + + pub fn frequency_of_bin( + self, + left_hz: f64, + span_hz: f64, + bin_count: usize, + index: usize, + ) -> Option { + if bin_count == 0 || index >= bin_count { + return None; + } + let intervals = self.interval_count(bin_count)?; + if !left_hz.is_finite() || !span_hz.is_finite() || span_hz <= 0.0 { + return None; + } + Some(left_hz + index as f64 * span_hz / intervals as f64) + } + + pub fn nearest_bin( + self, + left_hz: f64, + span_hz: f64, + bin_count: usize, + frequency_hz: f64, + ) -> Option { + if !left_hz.is_finite() + || !span_hz.is_finite() + || span_hz <= 0.0 + || !frequency_hz.is_finite() + || !(left_hz..=left_hz + span_hz).contains(&frequency_hz) + { + return None; + } + let intervals = self.interval_count(bin_count)?; + let index = ((frequency_hz - left_hz) * intervals as f64 / span_hz).round() as usize; + Some(index.min(bin_count.saturating_sub(1))) + } +} + #[derive(Clone)] #[allow(dead_code)] pub struct FftFrame { @@ -18,6 +104,24 @@ pub struct FftFrame { pub channel_power_dbfs: f32, pub occupied_bw_hz: u64, pub enbw_hz: f64, + pub bin_axis: BinAxis, +} + +impl FftFrame { + pub fn window(&self, zoom: usize) -> Option { + self.bin_axis.window( + self.center_freq_hz, + self.sample_rate, + self.bins_dbfs.len(), + zoom, + ) + } + + pub fn frequency_of_bin(&self, index: usize) -> Option { + let window = self.window(1)?; + self.bin_axis + .frequency_of_bin(window.left_hz, window.span_hz, window.bin_count, index) + } } pub struct WaterfallBuffer { @@ -175,6 +279,78 @@ mod min_rows_tests { mod tests { use super::*; + #[test] + fn fft_bins_keep_n_intervals() { + let axis = BinAxis::FftBins; + let window = axis.window(100_000_000, 64_000_000.0, 64, 1).unwrap(); + assert_eq!(axis.interval_count(64), Some(64)); + assert_eq!(window.first_bin, 0); + assert_eq!(window.bin_count, 64); + assert_eq!(window.left_hz, 68_000_000.0); + assert_eq!(window.span_hz, 64_000_000.0); + assert_eq!( + axis.frequency_of_bin(window.left_hz, window.span_hz, window.bin_count, 63), + Some(131_000_000.0) + ); + for (frequency, index) in [ + (68_000_000.0, 0), + (100_000_000.0, 32), + (131_000_000.0, 63), + (132_000_000.0, 63), + ] { + assert_eq!( + axis.nearest_bin(window.left_hz, window.span_hz, window.bin_count, frequency), + Some(index) + ); + } + } + + #[test] + fn fft_zoom_preserves_bin_spacing_and_clamps_to_one_bin() { + let axis = BinAxis::FftBins; + assert_eq!( + axis.window(100, 10.0, 10, 3), + Some(BinWindow { + first_bin: 4, + bin_count: 3, + left_hz: 99.0, + span_hz: 3.0, + }) + ); + assert_eq!(axis.window(100, 10.0, 10, 0), axis.window(100, 10.0, 10, 1)); + for count in [1, 10] { + let window = axis.window(100, 10.0, count, usize::MAX).unwrap(); + assert_eq!(window.bin_count, 1); + assert_eq!(window.span_hz, 10.0 / count as f64); + assert_eq!( + axis.nearest_bin(window.left_hz, window.span_hz, 1, window.left_hz), + Some(0) + ); + } + } + + #[test] + fn bin_axis_rejects_invalid_bounds() { + let axis = BinAxis::FftBins; + assert_eq!(axis.interval_count(0), None); + assert!(axis.window(100, 10.0, 0, 1).is_none()); + assert!(axis.frequency_of_bin(0.0, 10.0, 0, 0).is_none()); + assert!(axis.frequency_of_bin(0.0, 10.0, 4, 4).is_none()); + assert!(axis.nearest_bin(0.0, 10.0, 0, 0.0).is_none()); + for span in [0.0, -1.0, f64::NAN, f64::INFINITY] { + assert!(axis.window(100, span, 32, 1).is_none()); + assert!(axis.frequency_of_bin(0.0, span, 4, 0).is_none()); + assert!(axis.nearest_bin(0.0, span, 4, 0.0).is_none()); + } + for left in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + assert!(axis.frequency_of_bin(left, 10.0, 4, 0).is_none()); + assert!(axis.nearest_bin(left, 10.0, 4, 0.0).is_none()); + } + for frequency in [-1.0, 11.0, f64::NAN, f64::INFINITY] { + assert!(axis.nearest_bin(0.0, 10.0, 4, frequency).is_none()); + } + } + #[test] fn push_adds_newest_row_first() { let mut buf = WaterfallBuffer::new(4); diff --git a/src/ui/panels/core/spectrum/mod.rs b/src/ui/panels/core/spectrum/mod.rs index 7ca6b00..b491b8c 100644 --- a/src/ui/panels/core/spectrum/mod.rs +++ b/src/ui/panels/core/spectrum/mod.rs @@ -166,6 +166,7 @@ fn contents( fft.center_freq_hz, fft.sample_rate, zoom, + fft.bin_axis, ) else { return; }; diff --git a/src/ui/panels/core/spectrum/scale.rs b/src/ui/panels/core/spectrum/scale.rs index da58afa..95f716e 100644 --- a/src/ui/panels/core/spectrum/scale.rs +++ b/src/ui/panels/core/spectrum/scale.rs @@ -4,7 +4,7 @@ //! Coordinate mapping and the frequency ruler. //! //! The spectrum works in three coordinate systems at once and they are easy to -//! confuse: hertz, canvas x (`0..n-1`, as wide as the FFT has bins) and terminal +//! confuse: hertz, canvas x (one unit per bin interval) and terminal //! columns (bounded by the panel). Everything that converts between them lives //! here, alone, with tests. @@ -25,32 +25,25 @@ pub(super) fn dim(c: Color, f: f32) -> Color { } } -/// Map a frequency to a canvas x-coordinate in `[0, n-1]`, or `None` if out of view. -pub(super) fn freq_to_canvas_x(freq_hz: f64, left_hz: f64, bw: f64, n: f64) -> Option { - if bw <= 0.0 { +/// Map a frequency to a canvas x-coordinate, or `None` if out of view +pub(super) fn freq_to_canvas_x(freq_hz: f64, left_hz: f64, bw: f64, max_x: f64) -> Option { + if bw <= 0.0 || max_x <= 0.0 { return None; } let frac = (freq_hz - left_hz) / bw; if (0.0..=1.0).contains(&frac) { - Some(frac * (n - 1.0)) + Some(frac * max_x) } else { None } } -/// A canvas x - the units [`freq_to_canvas_x`] returns, spanning `0..n-1` - as a -/// terminal column inside `width`. -/// -/// The two coordinate systems cost nothing to mix up silently: the canvas is as -/// wide as the spectrum has bins, typically 2048, while the column is bounded by -/// the panel, typically under 200. Using one as the other clamps every position -/// to the right-hand edge, which is exactly what the OBW label did at every -/// terminal size it was tried at. -pub(super) fn canvas_x_to_col(x: f64, n: f64, width: u16) -> u16 { - if n <= 1.0 || width == 0 { +/// Map a canvas x-coordinate to a terminal column inside `width` +pub(super) fn canvas_x_to_col(x: f64, max_x: f64, width: u16) -> u16 { + if max_x <= 0.0 || width == 0 { return 0; } - let frac = (x / (n - 1.0)).clamp(0.0, 1.0); + let frac = (x / max_x).clamp(0.0, 1.0); ((frac * width as f64).round() as u16).min(width - 1) } @@ -159,24 +152,21 @@ mod tests { #[test] fn canvas_x_and_column_are_not_interchangeable() { - // The bug this guards: a 2048-bin canvas x used directly as a column. - // Three quarters across a 2048-bin canvas is column 120 of 160, not 160. - let n = 2048.0; - assert_eq!(canvas_x_to_col(0.0, n, 160), 0); + let max_x = 2048.0; + assert_eq!(canvas_x_to_col(0.0, max_x, 160), 0); assert_eq!( - canvas_x_to_col(n - 1.0, n, 160), + canvas_x_to_col(max_x, max_x, 160), 159, - "the last bin is the last column" + "the right edge is the last column" ); - assert_eq!(canvas_x_to_col((n - 1.0) * 0.75, n, 160), 120); - // Degenerate geometry answers 0 rather than dividing by zero. - assert_eq!(canvas_x_to_col(500.0, 1.0, 160), 0); - assert_eq!(canvas_x_to_col(500.0, n, 0), 0); + assert_eq!(canvas_x_to_col(max_x * 0.75, max_x, 160), 120); + assert_eq!(canvas_x_to_col(500.0, 0.0, 160), 0); + assert_eq!(canvas_x_to_col(500.0, max_x, 0), 0); } #[test] fn freq_to_canvas_x_rejects_what_is_off_screen() { - let (left, bw, n) = (92_000_000.0, 2_000_000.0, 1001.0); + let (left, bw, n) = (92_000_000.0, 2_000_000.0, 1000.0); assert_eq!(freq_to_canvas_x(92_000_000.0, left, bw, n), Some(0.0)); assert_eq!(freq_to_canvas_x(94_000_000.0, left, bw, n), Some(1000.0)); assert_eq!(freq_to_canvas_x(93_000_000.0, left, bw, n), Some(500.0)); diff --git a/src/ui/panels/core/spectrum/trace.rs b/src/ui/panels/core/spectrum/trace.rs index b0772c0..680d856 100644 --- a/src/ui/panels/core/spectrum/trace.rs +++ b/src/ui/panels/core/spectrum/trace.rs @@ -183,7 +183,7 @@ pub(super) fn draw( f.render_widget( Canvas::default() - .x_bounds([0.0, (n - 1.0).max(0.0)]) + .x_bounds([0.0, n.max(0.0)]) .y_bounds([y_min, y_max]) .paint(move |ctx| { let bright_at = |level: f32| band_bright[band_of(level, v_min, span, steps)]; @@ -201,7 +201,7 @@ pub(super) fn draw( ctx.draw(&CanvasLine { x1: 0.0, y1: y, - x2: n - 1.0, + x2: n, y2: y, color, }); @@ -223,7 +223,7 @@ pub(super) fn draw( // so only the parts above the signal show through. for i in 0..=4 { level_line(ctx, y_min + (y_max - y_min) * (i as f64 / 4.0), pal.grid); - rule(ctx, (n - 1.0).max(0.0) * (i as f64 / 4.0), pal.grid); + rule(ctx, n.max(0.0) * (i as f64 / 4.0), pal.grid); } // 1. Hold ghost - the entire frozen spectrum as a soft outline. if let Some(ref h) = held { diff --git a/src/ui/panels/core/spectrum/view.rs b/src/ui/panels/core/spectrum/view.rs index 1012b90..132ea39 100644 --- a/src/ui/panels/core/spectrum/view.rs +++ b/src/ui/panels/core/spectrum/view.rs @@ -15,6 +15,8 @@ use std::sync::Arc; +use crate::state::BinAxis; + /// The window the panel is actually drawing: the bins in view and the frequency /// span they cover. pub(super) struct SpectrumView { @@ -30,16 +32,14 @@ pub(super) struct SpectrumView { pub left_hz: f64, /// Width of the window in hertz. pub bw: f64, + bin_axis: BinAxis, } impl SpectrumView { - /// Window `full_*` down to the centre `1/zoom` of its bins. A `zoom` of 1 - /// (or a frame with nothing in it) returns the whole span, sharing the - /// frame's `Arc`s rather than copying. + /// Select the centre slice of the frame at `zoom` /// - /// `held` may have been captured at a different bin count than the live - /// frame, so it is windowed against its own length. Slicing it blind is a - /// panic waiting for the user to change sample rate while holding. + /// The full view shares the frame's buffers. Empty frames have no view. + /// A held trace may have a different bin count. pub fn new( bins: &Arc>, peaks: &Arc>, @@ -47,41 +47,35 @@ impl SpectrumView { center_hz: u64, sample_rate: f64, zoom: usize, + bin_axis: BinAxis, ) -> Option { let full_n = bins.len(); - if full_n == 0 || sample_rate <= 0.0 { - return None; - } - let full_left = center_hz as f64 - sample_rate / 2.0; - let zoom = zoom.max(1); + let window = bin_axis.window(center_hz, sample_rate, full_n, zoom)?; + let lo = window.first_bin; + let hi = lo + window.bin_count; - if zoom == 1 { - // Arc::clone is O(1) - no data copied. + if lo == 0 && hi == full_n { return Some(Self { bins: Arc::clone(bins), peaks: Arc::clone(peaks), held, n_bins: full_n, - left_hz: full_left, - bw: sample_rate, + left_hz: window.left_hz, + bw: window.span_hz, + bin_axis, }); } - let visible_n = (full_n / zoom).max(1); - let lo = (full_n / 2) - .saturating_sub(visible_n / 2) - .min(full_n - visible_n); - let hi = lo + visible_n; - let bin_hz = sample_rate / full_n as f64; let win = |v: &[f32]| Arc::new(v[lo.min(v.len())..hi.min(v.len())].to_vec()); Some(Self { bins: win(bins), peaks: win(peaks), held: held.map(|h| win(&h)), - n_bins: visible_n, - left_hz: full_left + lo as f64 * bin_hz, - bw: visible_n as f64 * bin_hz, + n_bins: window.bin_count, + left_hz: window.left_hz, + bw: window.span_hz, + bin_axis, }) } @@ -90,24 +84,24 @@ impl SpectrumView { self.left_hz + self.bw } - /// Canvas width in the units the paint closure works in: `0..n-1`. + /// Return the right edge of the canvas in bin-interval units pub fn n(&self) -> f64 { - self.n_bins as f64 + self.bin_axis.interval_count(self.n_bins).unwrap_or(1) as f64 } /// The level at `freq_hz`, or `None` when it falls outside the window. pub fn level_at(&self, freq_hz: u64) -> Option { - let frac = (freq_hz as f64 - self.left_hz) / self.bw; - if !(0.0..=1.0).contains(&frac) { - return None; - } - let idx = (frac * (self.n_bins - 1) as f64).round() as usize; - self.bins.get(idx.min(self.n_bins - 1)).copied() + let idx = self + .bin_axis + .nearest_bin(self.left_hz, self.bw, self.n_bins, freq_hz as f64)?; + self.bins.get(idx).copied() } /// The centre frequency of bin `idx`. pub fn freq_of_bin(&self, idx: usize) -> f64 { - self.left_hz + self.bw * (idx as f64 / (self.n_bins - 1).max(1) as f64) + self.bin_axis + .frequency_of_bin(self.left_hz, self.bw, self.n_bins, idx) + .unwrap_or(self.left_hz) } } @@ -123,7 +117,16 @@ mod tests { fn zoom_one_shows_the_whole_span_without_copying() { let bins = ramp(1024); let peaks = ramp(1024); - let v = SpectrumView::new(&bins, &peaks, None, 92_800_000, 2_000_000.0, 1).unwrap(); + let v = SpectrumView::new( + &bins, + &peaks, + None, + 92_800_000, + 2_000_000.0, + 1, + BinAxis::FftBins, + ) + .unwrap(); assert_eq!(v.n_bins, 1024); assert_eq!(v.left_hz, 91_800_000.0); assert_eq!(v.bw, 2_000_000.0); @@ -136,7 +139,16 @@ mod tests { #[test] fn zoom_takes_the_centre_slice_around_the_tuned_frequency() { let bins = ramp(1000); - let v = SpectrumView::new(&bins, &ramp(1000), None, 92_800_000, 2_000_000.0, 4).unwrap(); + let v = SpectrumView::new( + &bins, + &ramp(1000), + None, + 92_800_000, + 2_000_000.0, + 4, + BinAxis::FftBins, + ) + .unwrap(); assert_eq!(v.n_bins, 250); assert_eq!(v.bins[0], 375.0, "starts a quarter of the way in, not at 0"); assert_eq!(v.bw, 500_000.0, "a quarter of the span"); @@ -150,15 +162,42 @@ mod tests { // than the live frame, so the window has to clamp to its own length. let bins = ramp(1024); let held = Some(ramp(200)); - let v = SpectrumView::new(&bins, &ramp(1024), held, 92_800_000, 2_000_000.0, 4).unwrap(); + let v = SpectrumView::new( + &bins, + &ramp(1024), + held, + 92_800_000, + 2_000_000.0, + 4, + BinAxis::FftBins, + ) + .unwrap(); assert!(v.held.unwrap().len() <= 200); } #[test] fn an_empty_frame_yields_no_view() { - assert!(SpectrumView::new(&ramp(0), &ramp(0), None, 92_800_000, 2_000_000.0, 1).is_none()); + assert!(SpectrumView::new( + &ramp(0), + &ramp(0), + None, + 92_800_000, + 2_000_000.0, + 1, + BinAxis::FftBins + ) + .is_none()); assert!( - SpectrumView::new(&ramp(64), &ramp(64), None, 92_800_000, 0.0, 1).is_none(), + SpectrumView::new( + &ramp(64), + &ramp(64), + None, + 92_800_000, + 0.0, + 1, + BinAxis::FftBins + ) + .is_none(), "a zero sample rate has no span to draw" ); } @@ -166,9 +205,73 @@ mod tests { #[test] fn level_at_reads_the_window_not_the_frame() { let bins = ramp(1000); - let v = SpectrumView::new(&bins, &ramp(1000), None, 92_800_000, 2_000_000.0, 4).unwrap(); + let v = SpectrumView::new( + &bins, + &ramp(1000), + None, + 92_800_000, + 2_000_000.0, + 4, + BinAxis::FftBins, + ) + .unwrap(); // Mid-window is bin 125 of the slice, which held the value 500. assert_eq!(v.level_at((v.left_hz + v.bw / 2.0) as u64), Some(500.0)); assert!(v.level_at(90_000_000).is_none(), "outside the window"); } + + #[test] + fn fft_bin_24_maps_to_canvas_24() { + let bins = ramp(32); + let view = SpectrumView::new( + &bins, + &bins, + None, + 100_000_000, + 32_000_000.0, + 1, + BinAxis::FftBins, + ) + .unwrap(); + assert_eq!(view.freq_of_bin(24), 108_000_000.0); + assert_eq!(view.level_at(108_000_000), Some(24.0)); + assert_eq!( + super::super::scale::freq_to_canvas_x(108_000_000.0, view.left_hz, view.bw, view.n(),), + Some(24.0) + ); + } + + #[test] + fn bin_frequencies_map_to_their_canvas_positions_across_zoom() { + let bins = ramp(32); + for zoom in [0, 1, 2, 3, 4, 32, 64] { + let view = SpectrumView::new( + &bins, + &bins, + None, + 100_000_000, + 32_000_000.0, + zoom, + BinAxis::FftBins, + ) + .unwrap(); + for index in 0..view.n_bins { + let frequency = 84_000_000.0 + view.bins[index] as f64 * 1_000_000.0; + assert_eq!(view.freq_of_bin(index), frequency); + assert_eq!(view.level_at(frequency as u64), Some(view.bins[index])); + let x = super::super::scale::freq_to_canvas_x( + frequency, + view.left_hz, + view.bw, + view.n(), + ) + .unwrap(); + assert!((x - index as f64).abs() < 1e-9, "zoom {zoom} bin {index}"); + } + assert_eq!( + view.level_at(view.right_hz() as u64), + view.bins.last().copied() + ); + } + } } diff --git a/src/ui/panels/core/waterfall/cells.rs b/src/ui/panels/core/waterfall/cells.rs index cd85f88..1f943c3 100644 --- a/src/ui/panels/core/waterfall/cells.rs +++ b/src/ui/panels/core/waterfall/cells.rs @@ -22,6 +22,7 @@ use ratatui::{ }; use crate::palette::{magnitude_to_color_palette, ColorDepth, WaterfallPalette}; +use crate::state::BinAxis; /// Top of the colour scale. The waterfall is always referenced to full scale; /// only the floor (`db_min`) moves, under `↑`/`↓`. @@ -51,29 +52,39 @@ pub(super) struct Columns { visible_n: usize, row_bins: usize, cols: usize, + bin_axis: BinAxis, } impl Columns { - pub fn new(row_bins: usize, zoom: u32, cols: usize) -> Self { - // A zero-bin row would underflow the `row_bins - 1` clamp below. It should - // not happen, but a malformed row must not take the TUI down with it. + pub fn new( + row_bins: usize, + first_bin: usize, + visible_n: usize, + cols: usize, + bin_axis: BinAxis, + ) -> Self { let row_bins = row_bins.max(1); - let visible_n = (row_bins / (zoom as usize).max(1)).max(1); + let visible_n = visible_n.max(1).min(row_bins); Self { - lo_bin: (row_bins / 2).saturating_sub(visible_n / 2), + lo_bin: first_bin.min(row_bins - visible_n), visible_n, row_bins, cols: cols.max(1), + bin_axis, } } /// The `[start, end)` bin span column `col` reads. Always non-empty, so a /// wide panel over few bins still gets one bin per column rather than none. pub fn range(&self, col: usize) -> (usize, usize) { - let start = (self.lo_bin + col * self.visible_n / self.cols).min(self.row_bins - 1); - let end = (self.lo_bin + ((col + 1) * self.visible_n) / self.cols) - .max(start + 1) - .min(self.row_bins); + let (start, end) = match self.bin_axis { + BinAxis::FftBins => ( + col * self.visible_n / self.cols, + (col + 1) * self.visible_n / self.cols, + ), + }; + let start = (self.lo_bin + start).min(self.row_bins - 1); + let end = (self.lo_bin + end).max(start + 1).min(self.row_bins); (start, end) } } @@ -148,7 +159,7 @@ mod tests { #[test] fn unzoomed_columns_cover_every_bin_exactly_once() { - let c = Columns::new(1024, 1, 128); + let c = Columns::new(1024, 0, 1024, 128, BinAxis::FftBins); let (first, _) = c.range(0); let (_, last) = c.range(127); assert_eq!(first, 0, "the first column starts at the first bin"); @@ -165,18 +176,34 @@ mod tests { #[test] fn zoom_keeps_the_centre_slice() { - let c = Columns::new(1024, 4, 128); + let c = Columns::new(1024, 384, 256, 128, BinAxis::FftBins); let (first, _) = c.range(0); let (_, last) = c.range(127); assert_eq!(first, 384, "a quarter of the way in"); assert_eq!(last, 640, "and out again: 256 bins around the centre"); } + #[test] + fn columns_follow_the_bin_window_at_uneven_zoom() { + let axis = BinAxis::FftBins; + let window = axis.window(100, 10.0, 10, 3).unwrap(); + let columns = Columns::new(10, window.first_bin, window.bin_count, 3, axis); + assert_eq!(columns.range(0), (4, 5)); + assert_eq!(columns.range(1), (5, 6)); + assert_eq!(columns.range(2), (6, 7)); + for col in 0..3 { + assert_eq!( + axis.frequency_of_bin(window.left_hz, window.span_hz, window.bin_count, col), + Some(99.0 + col as f64) + ); + } + } + #[test] fn a_column_is_never_empty_however_odd_the_geometry() { // More columns than bins: every column still reads at least one bin, // rather than an empty span that would paint the whole plot at the floor. - let c = Columns::new(8, 1, 200); + let c = Columns::new(8, 0, 8, 200, BinAxis::FftBins); for col in 0..200 { let (lo, hi) = c.range(col); assert!(hi > lo, "column {col} is empty"); @@ -194,12 +221,12 @@ mod tests { let (row_bins, cols) = (1024usize, 128usize); let naive = |col: usize| col * row_bins / cols; - let unzoomed = Columns::new(row_bins, 1, cols); + let unzoomed = Columns::new(row_bins, 0, row_bins, cols, BinAxis::FftBins); for col in 0..cols { assert_eq!(unzoomed.range(col).0, naive(col), "zoom 1 hides the bug"); } - let zoomed = Columns::new(row_bins, 4, cols); + let zoomed = Columns::new(row_bins, 384, 256, cols, BinAxis::FftBins); assert_eq!(zoomed.range(0).0, 384); assert_eq!( naive(0), @@ -212,12 +239,10 @@ mod tests { #[test] fn degenerate_input_does_not_underflow() { - // A zero-bin row and a zero zoom are both nonsense, and both used to be - // one subtraction away from panicking. - let c = Columns::new(0, 0, 40); + let c = Columns::new(0, 0, 0, 40, BinAxis::FftBins); let (lo, hi) = c.range(0); assert!(hi > lo); - let zero_cols = Columns::new(1024, 4, 0); + let zero_cols = Columns::new(1024, 384, 256, 0, BinAxis::FftBins); let _ = zero_cols.range(0); } } diff --git a/src/ui/panels/core/waterfall/mod.rs b/src/ui/panels/core/waterfall/mod.rs index af7c59a..7eb5093 100644 --- a/src/ui/panels/core/waterfall/mod.rs +++ b/src/ui/panels/core/waterfall/mod.rs @@ -253,15 +253,14 @@ fn contents( let cols = plot.width as usize; // The frequency window, narrowed by the shared zoom around the tuned centre. - let window = wf + let bin_window = wf .last_fft .as_ref() - .map(|fr| { - let visible = fr.sample_rate / wf.hz_zoom as f64; - Window { - left_hz: fr.center_freq_hz as f64 - visible / 2.0, - bw: visible, - } + .and_then(|frame| frame.window(wf.hz_zoom as usize)); + let window = bin_window + .map(|window| Window { + left_hz: window.left_hz, + bw: window.span_hz, }) .unwrap_or(Window { left_hz: 0.0, @@ -286,7 +285,20 @@ fn contents( let skip_data = wf.scroll_offset.min(max_scroll) * 2; let row_bins = buf.rows.front().map(|(_, r)| r.len()).unwrap_or(1); - let columns = Columns::new(row_bins, wf.hz_zoom, cols); + let columns = bin_window + .zip(wf.last_fft.as_ref()) + .map(|(window, frame)| { + Columns::new( + row_bins, + window.first_bin, + window.bin_count, + cols, + frame.bin_axis, + ) + }) + .unwrap_or_else(|| { + Columns::new(row_bins, 0, row_bins, cols, crate::state::BinAxis::FftBins) + }); cells::draw( f, plot, &buf.rows, &columns, cursor_col, skip_data, wf.db_min, wf.palette, theme, diff --git a/src/ui/panels/lab/signal_characterization/metrics.rs b/src/ui/panels/lab/signal_characterization/metrics.rs index 0bac308..1daf5f1 100644 --- a/src/ui/panels/lab/signal_characterization/metrics.rs +++ b/src/ui/panels/lab/signal_characterization/metrics.rs @@ -167,6 +167,7 @@ mod tests { channel_power_dbfs: -22.0, occupied_bw_hz: 180_000, enbw_hz: 1_000.0, + bin_axis: crate::state::BinAxis::FftBins, } } From 7eddac65a6fe219ffd0d705adb55cdcee9b1ce1a Mon Sep 17 00:00:00 2001 From: Dominic Griesel Date: Tue, 8 Sep 2026 11:04:42 +0200 Subject: [PATCH 3/7] fix(signal): enforce spectrum statistics contracts Address musithang/sdrtop#14 review with documented availability and parameter contracts, buffer preconditions, and boundary regressions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/signal/stats.rs | 199 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) diff --git a/src/signal/stats.rs b/src/signal/stats.rs index 897f67d..b4662f0 100644 --- a/src/signal/stats.rs +++ b/src/signal/stats.rs @@ -1,6 +1,18 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2026 MusiThang +//! These statistics operate on finite spectrum measurements in a common dB unit. +//! Non-finite inputs represent unavailable measurements. +//! `NEG_INFINITY` represents an unavailable output. +//! A finite floor such as the FFT's `DB_FLOOR` remains a measurement. + +/// Compute an EMA from the available values. +/// +/// A finite sample seeds unavailable history regardless of `alpha`. +/// An unavailable sample preserves finite history. +/// Two unavailable values produce `NEG_INFINITY`. +/// The caller must supply a finite `alpha` in `0..=1`. +/// With two finite values, zero retains history and one selects the sample. pub(crate) fn finite_ema(previous: f32, sample: f32, alpha: f32) -> f32 { match (previous.is_finite(), sample.is_finite()) { (true, true) => alpha * sample + (1.0 - alpha) * previous, @@ -10,6 +22,20 @@ pub(crate) fn finite_ema(previous: f32, sample: f32, alpha: f32) -> f32 { } } +/// Smooth each bin and decay its peak toward the smoothed value. +/// +/// `has_history = false` discards both traces before seeding from the samples. +/// With history, each bin follows [`finite_ema`]'s availability rules. +/// Non-finite previous peaks are unavailable. +/// The caller must supply a finite `alpha` in `0..=1`. +/// `decay_db` must be finite and nonnegative. +/// It sets the peak reduction in dB per call, including calls with unavailable samples. +/// Zero holds the peak. +/// +/// # Panics +/// +/// All three slices must have equal lengths. +/// A mismatch panics before either output is mutated. pub(crate) fn average_and_peak( samples: &[f32], smoothed: &mut [f32], @@ -18,6 +44,12 @@ pub(crate) fn average_and_peak( decay_db: f32, has_history: bool, ) { + assert_eq!( + samples.len(), + smoothed.len(), + "samples/smoothed length mismatch" + ); + assert_eq!(samples.len(), peak.len(), "samples/peak length mismatch"); for ((smoothed, peak), sample) in smoothed .iter_mut() .zip(peak.iter_mut()) @@ -41,7 +73,23 @@ pub(crate) fn average_and_peak( } } +/// Average the quietest fraction of the finite dB measurements. +/// +/// The selected count is the finite count divided by `fraction`, rounded down. +/// At least one measurement is selected from nonempty finite input. +/// A zero fraction selects all finite measurements. +/// Empty or all-non-finite input produces `NEG_INFINITY`. +/// `scratch` is reusable workspace with unspecified contents after the call. +/// +/// # Panics +/// +/// `scratch` must hold at least `values.len()` entries, including unavailable values. +/// Insufficient capacity panics before `scratch` is mutated. pub(crate) fn quietest_mean(values: &[f32], scratch: &mut [f32], fraction: usize) -> f32 { + assert!( + scratch.len() >= values.len(), + "scratch is shorter than values" + ); let mut finite_count = 0; for value in values.iter().copied().filter(|value| value.is_finite()) { scratch[finite_count] = value; @@ -59,6 +107,7 @@ pub(crate) fn quietest_mean(values: &[f32], scratch: &mut [f32], fraction: usize #[cfg(test)] mod tests { use super::*; + use std::panic::{catch_unwind, AssertUnwindSafe}; #[test] fn finite_ema_keeps_the_available_value() { @@ -91,4 +140,154 @@ mod tests { let mut scratch = vec![0.0; values.len()]; assert_eq!(quietest_mean(&values, &mut scratch, 10), -100.0); } + + #[test] + fn finite_ema_handles_all_unavailable_values_and_recovers() { + for unavailable in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + assert_eq!(finite_ema(unavailable, -80.0, 0.2), -80.0); + assert_eq!(finite_ema(-90.0, unavailable, 0.2), -90.0); + for other in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + let absent = finite_ema(unavailable, other, 0.2); + assert_eq!(absent, f32::NEG_INFINITY); + assert_eq!(finite_ema(absent, -80.0, 0.2), -80.0); + } + } + } + + #[test] + fn finite_ema_alpha_boundaries_preserve_seeding() { + assert_eq!(finite_ema(-90.0, -80.0, 0.0), -90.0); + assert_eq!(finite_ema(-90.0, -80.0, 1.0), -80.0); + for alpha in [0.0, 1.0] { + assert_eq!(finite_ema(f32::NEG_INFINITY, -80.0, alpha), -80.0); + assert_eq!(finite_ema(-90.0, f32::NAN, alpha), -90.0); + } + } + + #[test] + fn average_and_peak_accepts_empty_traces() { + for has_history in [false, true] { + average_and_peak(&[], &mut [], &mut [], 0.2, 0.5, has_history); + } + } + + #[test] + fn average_and_peak_recovers_from_all_unavailable_samples() { + let unavailable = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY]; + let mut smoothed = [-90.0; 3]; + let mut peak = [-80.0; 3]; + average_and_peak(&unavailable, &mut smoothed, &mut peak, 0.2, 0.5, false); + assert_eq!(smoothed, [f32::NEG_INFINITY; 3]); + assert_eq!(peak, [f32::NEG_INFINITY; 3]); + average_and_peak(&unavailable, &mut smoothed, &mut peak, 0.2, 0.5, true); + assert_eq!(smoothed, [f32::NEG_INFINITY; 3]); + assert_eq!(peak, [f32::NEG_INFINITY; 3]); + average_and_peak(&[-70.0; 3], &mut smoothed, &mut peak, 0.2, 0.5, true); + assert_eq!(smoothed, [-70.0; 3]); + assert_eq!(peak, [-70.0; 3]); + } + + #[test] + fn average_and_peak_discards_non_finite_previous_peaks() { + for previous_peak in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + for previous in [-90.0, f32::NEG_INFINITY] { + let mut smoothed = [previous]; + let mut peak = [previous_peak]; + average_and_peak(&[f32::NAN], &mut smoothed, &mut peak, 0.2, 0.5, true); + assert_eq!(smoothed, [previous]); + assert_eq!(peak, [previous]); + average_and_peak(&[-70.0], &mut smoothed, &mut peak, 1.0, 0.5, true); + assert_eq!(smoothed, [-70.0]); + assert_eq!(peak, [-70.0]); + } + } + } + + #[test] + fn average_and_peak_alpha_and_decay_boundaries() { + for (alpha, expected) in [(0.0, -90.0), (1.0, -80.0)] { + for decay in [0.0, 1.0] { + let mut smoothed = [-90.0]; + let mut peak = [-70.0]; + average_and_peak(&[-80.0], &mut smoothed, &mut peak, alpha, decay, true); + assert_eq!(smoothed, [expected]); + assert_eq!(peak, [-70.0 - decay]); + average_and_peak(&[f32::NAN], &mut smoothed, &mut peak, alpha, decay, true); + assert_eq!(smoothed, [expected]); + assert_eq!(peak, [-70.0 - 2.0 * decay]); + } + } + let mut smoothed = [-90.0]; + let mut peak = [-89.5]; + average_and_peak(&[-90.0], &mut smoothed, &mut peak, 1.0, 1.0, true); + assert_eq!(peak, smoothed); + } + + #[test] + fn average_and_peak_rejects_mismatches_before_mutating() { + for (samples_len, smoothed_len, peak_len) in + [(1, 2, 2), (2, 1, 2), (2, 2, 1), (2, 2, 3), (0, 1, 1)] + { + for sample in [-80.0, f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + for has_history in [false, true] { + let samples = vec![sample; samples_len]; + let mut smoothed = vec![-90.0; smoothed_len]; + let mut peak = vec![-70.0; peak_len]; + let result = catch_unwind(AssertUnwindSafe(|| { + average_and_peak(&samples, &mut smoothed, &mut peak, 0.2, 0.5, has_history); + })); + assert!(result.is_err()); + assert_eq!(smoothed, vec![-90.0; smoothed_len]); + assert_eq!(peak, vec![-70.0; peak_len]); + } + } + } + } + + #[test] + fn quietest_mean_empty_and_unavailable_inputs_have_no_measurement() { + assert_eq!(quietest_mean(&[], &mut [], 10), f32::NEG_INFINITY); + let mut scratch = [42.0; 3]; + assert_eq!( + quietest_mean( + &[f32::NAN, f32::INFINITY, f32::NEG_INFINITY], + &mut scratch, + 10, + ), + f32::NEG_INFINITY + ); + assert_eq!( + quietest_mean(&[-90.0, -80.0, -70.0], &mut scratch, 10), + -90.0 + ); + } + + #[test] + fn quietest_mean_zero_and_one_select_all_finite_values() { + let values = [-90.0, f32::NAN, -70.0, f32::INFINITY, f32::NEG_INFINITY]; + let mut scratch = [0.0; 8]; + for fraction in [0, 1] { + assert_eq!(quietest_mean(&values, &mut scratch, fraction), -80.0); + } + assert_eq!(quietest_mean(&values, &mut scratch, usize::MAX), -90.0); + } + + #[test] + fn quietest_mean_rejects_short_scratch_before_mutating() { + for values in [ + [-90.0, -80.0], + [-90.0, f32::NAN], + [f32::NAN, f32::INFINITY], + [f32::NEG_INFINITY, f32::NEG_INFINITY], + ] { + for fraction in [0, 1, 10] { + let mut scratch = [42.0]; + let result = catch_unwind(AssertUnwindSafe(|| { + quietest_mean(&values, &mut scratch, fraction); + })); + assert!(result.is_err()); + assert_eq!(scratch, [42.0]); + } + } + } } From fa47b06800adf22d345e20364cc957023013b0a2 Mon Sep 17 00:00:00 2001 From: Dominic Griesel Date: Tue, 8 Sep 2026 11:10:07 +0200 Subject: [PATCH 4/7] fix(ui): complete FFT interval mapping after review Use containing FFT intervals for cursor and marker lookup. Extend the final rendered interval and derive waterfall columns from each drawn row. Use captured-frame coordinates for lab peaks and recall. Address the approved geometry review of musithang/sdrtop#12. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/app/input/core.rs | 15 ++ src/state/mod.rs | 2 +- src/state/waterfall.rs | 17 ++- src/ui/panels/core/command_rail/modes.rs | 56 +++----- src/ui/panels/core/command_rail/recall.rs | 35 ++++- src/ui/panels/core/spectrum/scale.rs | 45 +++++- src/ui/panels/core/spectrum/trace.rs | 131 ++++++++++++++++-- src/ui/panels/core/spectrum/view.rs | 27 ++++ src/ui/panels/core/waterfall/axes.rs | 6 +- src/ui/panels/core/waterfall/cells.rs | 93 ++++++++----- src/ui/panels/core/waterfall/mod.rs | 130 ++++++++++++----- src/ui/panels/lab/bars.rs | 32 +++-- .../lab/signal_characterization/metrics.rs | 14 +- 13 files changed, 455 insertions(+), 148 deletions(-) diff --git a/src/app/input/core.rs b/src/app/input/core.rs index 2d9ad91..ea3b1b8 100644 --- a/src/app/input/core.rs +++ b/src/app/input/core.rs @@ -29,6 +29,7 @@ fn strongest_bin_frequency(frame: &crate::state::FftFrame) -> Option { .map(|(index, _)| index)?; frame .frequency_of_bin(peak_bin) + .filter(|frequency| *frequency >= 0.0) .map(|frequency| frequency.round() as u64) } @@ -325,4 +326,18 @@ mod tests { frame.bins_dbfs = std::sync::Arc::new(Vec::new()); assert_eq!(strongest_bin_frequency(&frame), None); } + + #[test] + fn peak_jump_falls_back_for_a_negative_frequency() { + let mut state = crate::state::SdrMetrics::fixture(); + state.radio.frequency = 1_000_000; + state.radio.config_sample_rate = 32_000_000.0; + let state = state.with_carrier(-8_000_000.0, 70.0); + let frame = state.waterfall.last_fft.as_ref().unwrap(); + assert_eq!(strongest_bin_frequency(frame), None); + assert_eq!( + strongest_bin_frequency(frame).unwrap_or(state.radio.frequency), + 1_000_000 + ); + } } diff --git a/src/state/mod.rs b/src/state/mod.rs index 618a250..b080e60 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -44,7 +44,7 @@ pub use ui::{ active_recall_slot, recall_from_hz, recall_to_hz, InputMode, LogEntry, LogLevel, MenuPane, MenuState, RailMode, UiState, RECALL_SLOTS, }; -pub use waterfall::{BinAxis, FftFrame, WaterfallState, WATERFALL_MIN_ROWS}; +pub use waterfall::{BinAxis, BinWindow, FftFrame, WaterfallState, WATERFALL_MIN_ROWS}; pub const THROUGHPUT_HISTORY_LEN: usize = 64; /// Depth of the per-callback gap ring feeding the `lab_timing` strip chart. ~256 diff --git a/src/state/waterfall.rs b/src/state/waterfall.rs index 9f2f159..3fbb135 100644 --- a/src/state/waterfall.rs +++ b/src/state/waterfall.rs @@ -5,12 +5,15 @@ use std::collections::VecDeque; use std::sync::Arc; use std::time::Instant; +/// Define how bins cover a frequency span #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum BinAxis { + /// Each FFT bin owns one interval starting at its frequency #[default] FftBins, } +/// A nonempty centre slice retains the full frame's bin spacing #[derive(Clone, Copy, Debug, PartialEq)] pub struct BinWindow { pub first_bin: usize, @@ -20,12 +23,15 @@ pub struct BinWindow { } impl BinAxis { + /// Return the number of frequency intervals, or `None` for an empty axis pub fn interval_count(self, bin_count: usize) -> Option { match self { Self::FftBins => (bin_count > 0).then_some(bin_count), } } + /// Select a centre slice with at least one bin. Zero zoom means full span. + /// Empty axes and non-positive or non-finite spans have no window. pub fn window( self, center_hz: u64, @@ -53,6 +59,9 @@ impl BinAxis { }) } + /// Return a bin's frequency + /// + /// The last FFT bin starts below the right edge. pub fn frequency_of_bin( self, left_hz: f64, @@ -70,6 +79,9 @@ impl BinAxis { Some(left_hz + index as f64 * span_hz / intervals as f64) } + /// Look up the FFT interval containing `frequency_hz` + /// + /// The right edge reads the last bin. Frequencies outside the window return `None`. pub fn nearest_bin( self, left_hz: f64, @@ -86,7 +98,10 @@ impl BinAxis { return None; } let intervals = self.interval_count(bin_count)?; - let index = ((frequency_hz - left_hz) * intervals as f64 / span_hz).round() as usize; + let position = (frequency_hz - left_hz) * intervals as f64 / span_hz; + let index = match self { + Self::FftBins => position.floor() as usize, + }; Some(index.min(bin_count.saturating_sub(1))) } } diff --git a/src/ui/panels/core/command_rail/modes.rs b/src/ui/panels/core/command_rail/modes.rs index 4917f3e..5406bd8 100644 --- a/src/ui/panels/core/command_rail/modes.rs +++ b/src/ui/panels/core/command_rail/modes.rs @@ -57,24 +57,15 @@ pub(super) fn mode_tabs_line(active: RailMode, iw: usize, theme: &crate::Theme) /// `(freq_hz, dbfs)`, strongest-first. A thin wrapper over the spectrum panel's /// [`detect_peaks`] (shared prominence ≥ NF+10 dB + min-separation logic) plus /// the bin→Hz map, so HUNT and the MONITOR activity count agree with the markers. -pub(super) fn rail_peaks( - bins: &[f32], - noise_floor: f32, - center_hz: u64, - sample_rate: f64, - n: usize, -) -> Vec<(u64, f32)> { - if sample_rate <= 0.0 || bins.is_empty() { - return Vec::new(); - } +pub(super) fn rail_peaks(frame: &crate::state::FftFrame, n: usize) -> Vec<(u64, f32)> { + let bins = &frame.bins_dbfs; let len = bins.len(); let sep = (len / 48).max(2); - let left_hz = center_hz as f64 - sample_rate / 2.0; - detect_peaks(bins, noise_floor, n, sep) + detect_peaks(bins, frame.noise_floor, n, sep) .into_iter() - .map(|i| { - let hz = (left_hz + i as f64 / len as f64 * sample_rate).max(0.0) as u64; - (hz, bins[i]) + .filter_map(|i| { + let hz = frame.frequency_of_bin(i)?.max(0.0) as u64; + Some((hz, bins[i])) }) .collect() } @@ -140,13 +131,7 @@ pub(super) fn mode_card_lines( let Some(fr) = fft else { return vec![dim("scanning…".into())]; }; - let peaks = rail_peaks( - &fr.bins_dbfs, - fr.noise_floor, - state.radio.frequency, - fr.sample_rate, - 3, - ); + let peaks = rail_peaks(fr, 3); if peaks.is_empty() { return vec![dim("no peaks".into())]; } @@ -192,16 +177,7 @@ pub(super) fn mode_card_lines( .last_fft .as_ref() .filter(|_| !stale) - .map_or(0, |fr| { - rail_peaks( - &fr.bins_dbfs, - fr.noise_floor, - state.radio.frequency, - fr.sample_rate, - 8, - ) - .len() - }); + .map_or(0, |fr| rail_peaks(fr, 8).len()); vec![ Line::from(vec![ Span::raw(" "), @@ -278,6 +254,16 @@ pub(super) fn mode_card_lines( mod tests { use super::*; + fn frame(bins: &[f32], noise_floor: f32, sample_rate: f64) -> crate::state::FftFrame { + let state = SdrMetrics::fixture().with_carrier(0.0, 20.0); + let mut frame = state.waterfall.last_fft.unwrap(); + frame.bins_dbfs = std::sync::Arc::new(bins.to_vec()); + frame.noise_floor = noise_floor; + frame.center_freq_hz = 100_000_000; + frame.sample_rate = sample_rate; + frame + } + #[test] fn rail_peaks_maps_bins_to_frequency_strongest_first() { // Two lobes above the −80 dB noise floor: a tall one left of centre @@ -285,7 +271,7 @@ mod tests { let bins = [ -90.0, -40.0, -10.0, -40.0, -80.0, -50.0, -25.0, -50.0, -90.0, ]; - let peaks = rail_peaks(&bins, -80.0, 100_000_000, 10_000_000.0, 3); + let peaks = rail_peaks(&frame(&bins, -80.0, 10_000_000.0), 3); assert_eq!(peaks.len(), 2, "two distinct lobes above NF+10"); assert!(peaks[0].1 > peaks[1].1, "strongest first"); assert!((peaks[0].1 - (-10.0)).abs() < 1e-3); @@ -296,9 +282,9 @@ mod tests { #[test] fn rail_peaks_empty_without_signal_or_rate() { // All near the floor → nothing clears NF+10 dB. - assert!(rail_peaks(&[-90.0, -88.0, -90.0], -90.0, 100_000_000, 6_000_000.0, 3).is_empty()); + assert!(rail_peaks(&frame(&[-90.0, -88.0, -90.0], -90.0, 6_000_000.0), 3).is_empty()); // No sample rate → no usable frequency map. - assert!(rail_peaks(&[-90.0, -10.0, -90.0], -90.0, 100_000_000, 0.0, 3).is_empty()); + assert!(rail_peaks(&frame(&[-90.0, -10.0, -90.0], -90.0, 0.0), 3).is_empty()); } #[test] diff --git a/src/ui/panels/core/command_rail/recall.rs b/src/ui/panels/core/command_rail/recall.rs index bf857d7..a9fc26e 100644 --- a/src/ui/panels/core/command_rail/recall.rs +++ b/src/ui/panels/core/command_rail/recall.rs @@ -22,12 +22,14 @@ fn recall_pip(slot_hz: u64, state: &SdrMetrics, stale: bool) -> Option<(&'static return None; } let fr = state.waterfall.last_fft.as_ref()?; - let half_sr = (fr.sample_rate / 2.0) as u64; - let center = state.radio.frequency; - if slot_hz < center.saturating_sub(half_sr) || slot_hz > center + half_sr { - return None; - } - let peaks = rail_peaks(&fr.bins_dbfs, fr.noise_floor, center, fr.sample_rate, 8); + let window = fr.window(1)?; + fr.bin_axis.nearest_bin( + window.left_hz, + window.span_hz, + window.bin_count, + slot_hz as f64, + )?; + let peaks = rail_peaks(fr, 8); let close = peaks.iter().any(|&(f, _)| f.abs_diff(slot_hz) < 250_000); let strong = peaks .iter() @@ -102,3 +104,24 @@ pub(super) fn lines(state: &SdrMetrics, stale: bool, theme: &crate::Theme) -> Ve out.push(Line::raw("")); out } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn recall_uses_the_captured_frame_after_retuning() { + let mut state = SdrMetrics::fixture(); + state.radio.frequency = 100_000_000; + state.radio.config_sample_rate = 32_000_000.0; + let mut state = state.with_carrier(8_000_000.0, 70.0); + state.radio.frequency = 200_000_000; + assert_eq!(recall_pip(108_000_000, &state, false), Some(("⣿⡇", true))); + assert_eq!(recall_pip(208_000_000, &state, false), None); + assert_eq!(recall_pip(108_000_000, &state, true), None); + assert_eq!( + rail_peaks(state.waterfall.last_fft.as_ref().unwrap(), 3), + vec![(108_000_000, -30.0)] + ); + } +} diff --git a/src/ui/panels/core/spectrum/scale.rs b/src/ui/panels/core/spectrum/scale.rs index 95f716e..c03adc5 100644 --- a/src/ui/panels/core/spectrum/scale.rs +++ b/src/ui/panels/core/spectrum/scale.rs @@ -38,13 +38,14 @@ pub(super) fn freq_to_canvas_x(freq_hz: f64, left_hz: f64, bw: f64, max_x: f64) } } -/// Map a canvas x-coordinate to a terminal column inside `width` +/// Map a canvas x-coordinate to the column painted by the Braille canvas pub(super) fn canvas_x_to_col(x: f64, max_x: f64, width: u16) -> u16 { if max_x <= 0.0 || width == 0 { return 0; } - let frac = (x / max_x).clamp(0.0, 1.0); - ((frac * width as f64).round() as u16).min(width - 1) + let x = x.clamp(0.0, max_x); + let dot = (x * (2.0 * width as f64 - 1.0) / max_x) as usize; + (dot / 2) as u16 } /// The occupied-bandwidth window as a symmetric span around `center_hz` - the @@ -150,6 +151,42 @@ pub fn freq_scale_spans( mod tests { use super::*; + #[test] + fn columns_match_the_rendered_braille_dot() { + use ratatui::{ + buffer::Buffer, + layout::Rect, + widgets::{ + canvas::{Canvas, Points}, + Widget, + }, + }; + for width in [1, 2, 40, 160] { + for x in [0.0, 0.1, 7.9, 8.0, 16.0, 24.0, 31.9, 32.0] { + let area = Rect::new(0, 0, width, 1); + let mut buffer = Buffer::empty(area); + Canvas::default() + .x_bounds([0.0, 32.0]) + .y_bounds([0.0, 1.0]) + .paint(|ctx| { + ctx.draw(&Points { + coords: &[(x, 0.0)], + color: Color::Red, + }) + }) + .render(area, &mut buffer); + let painted = (0..width) + .find(|col| buffer.get(*col, 0).fg == Color::Red) + .unwrap(); + assert_eq!( + canvas_x_to_col(x, 32.0, width), + painted, + "width {width} x {x}" + ); + } + } + } + #[test] fn canvas_x_and_column_are_not_interchangeable() { let max_x = 2048.0; @@ -159,7 +196,7 @@ mod tests { 159, "the right edge is the last column" ); - assert_eq!(canvas_x_to_col(max_x * 0.75, max_x, 160), 120); + assert_eq!(canvas_x_to_col(max_x * 0.75, max_x, 160), 119); assert_eq!(canvas_x_to_col(500.0, 0.0, 160), 0); assert_eq!(canvas_x_to_col(500.0, max_x, 0), 0); } diff --git a/src/ui/panels/core/spectrum/trace.rs b/src/ui/panels/core/spectrum/trace.rs index 680d856..bbe0dcb 100644 --- a/src/ui/panels/core/spectrum/trace.rs +++ b/src/ui/panels/core/spectrum/trace.rs @@ -151,6 +151,26 @@ pub(super) struct Layers { pub noise_floor: f32, } +fn series_segments( + values: &[f32], + tail_width: f64, +) -> impl Iterator + '_ { + let joined = values + .windows(2) + .enumerate() + .map(|(i, pair)| (i as f64, pair[0], (i + 1) as f64, pair[1])); + // Extend the last FFT sample across its final interval + let tail = values.last().filter(|_| tail_width > 0.0).map(|&last| { + ( + (values.len() - 1) as f64, + last, + (values.len() - 1) as f64 + tail_width, + last, + ) + }); + joined.chain(tail) +} + /// Paint the trace and everything drawn on the canvas itself. pub(super) fn draw( f: &mut Frame, @@ -168,6 +188,7 @@ pub(super) fn draw( } = layers; let pal = Palette::new(theme); let n = view.n(); + let tail_width = view.bin_end(0); let (y_min, y_max) = (vert.min as f64, vert.max as f64); // The closure takes ownership, so everything it needs is moved in. @@ -208,12 +229,12 @@ pub(super) fn draw( }; let series = |ctx: &mut ratatui::widgets::canvas::Context, v: &[f32], color: Color| { - for i in 1..v.len() { + for (x1, y1, x2, y2) in series_segments(v, tail_width) { ctx.draw(&CanvasLine { - x1: (i - 1) as f64, - y1: v[i - 1].clamp(v_min, v_max) as f64, - x2: i as f64, - y2: v[i].clamp(v_min, v_max) as f64, + x1, + y1: y1.clamp(v_min, v_max) as f64, + x2, + y2: y2.clamp(v_min, v_max) as f64, color, }); } @@ -251,7 +272,7 @@ pub(super) fn draw( ctx.draw(&CanvasLine { x1: start as f64, y1: yb as f64, - x2: (i - 1) as f64, + x2: view.bin_end(i - 1), y2: yb as f64, color, }); @@ -265,13 +286,12 @@ pub(super) fn draw( // height. Only Braille draws it: Fill's bright body is its own // edge, Scatter has no line. if style == SpectrumStyle::Braille { - for i in 1..bins.len() { - let (y0, y1) = - (bins[i - 1].clamp(v_min, v_max), bins[i].clamp(v_min, v_max)); + for (x0, y0, x1, y1) in series_segments(&bins, tail_width) { + let (y0, y1) = (y0.clamp(v_min, v_max), y1.clamp(v_min, v_max)); ctx.draw(&CanvasLine { - x1: (i - 1) as f64, + x1: x0, y1: y0 as f64, - x2: i as f64, + x2: x1, y2: y1 as f64, color: bright_at((y0 + y1) * 0.5), }); @@ -336,6 +356,95 @@ pub(super) fn draw( mod tests { use super::*; + #[test] + fn series_keep_connected_samples_and_extend_only_the_final_interval() { + assert_eq!( + series_segments(&[-80.0, -20.0], 1.0).collect::>(), + vec![(0.0, -80.0, 1.0, -20.0), (1.0, -20.0, 2.0, -20.0)] + ); + assert_eq!( + series_segments(&[-80.0, -20.0], 0.0).collect::>(), + vec![(0.0, -80.0, 1.0, -20.0)] + ); + assert_eq!(series_segments(&[], 1.0).count(), 0); + assert_eq!( + series_segments(&[-20.0], 1.0).collect::>(), + vec![(0.0, -20.0, 1.0, -20.0)] + ); + } + + fn render_style( + style: SpectrumStyle, + bins: Vec, + held: Option>>, + ) -> ratatui::buffer::Buffer { + let mut terminal = + ratatui::Terminal::new(ratatui::backend::TestBackend::new(40, 10)).unwrap(); + let theme = crate::theme::Theme::sdr(); + let bins = Arc::new(bins); + let view = SpectrumView::new( + &bins, + &Arc::new(Vec::new()), + held, + 100_000_000, + 32_000_000.0, + 1, + crate::state::BinAxis::FftBins, + ) + .unwrap(); + terminal + .draw(|f| { + draw( + f, + f.size(), + &view, + &Vertical::new(-100.0, 0.0, 10, &theme), + Layers { + rules: Rules { + markers: vec![], + obw: (None, None), + cursor: None, + }, + ghosts: LabGhosts { + trace: None, + ref_dbfs: None, + }, + style, + noise_floor: -100.0, + }, + &theme, + ); + }) + .unwrap(); + terminal.backend().buffer().clone() + } + + #[test] + fn final_fft_interval_is_filled_without_turning_scatter_into_a_line() { + for bins in [vec![-100.0, -20.0], vec![-20.0]] { + let braille = render_style(SpectrumStyle::Braille, bins.clone(), None); + let fill = render_style(SpectrumStyle::Fill, bins.clone(), None); + let scatter = render_style(SpectrumStyle::Scatter, bins.clone(), None); + let background = render_style(SpectrumStyle::Scatter, vec![-100.0; bins.len()], None); + // Column 35 lies inside the final FFT interval at both bin counts + assert_ne!(braille.get(35, 4), background.get(35, 4)); + assert_ne!(fill.get(35, 4), background.get(35, 4)); + assert_eq!(scatter.get(35, 4), background.get(35, 4)); + assert_ne!(braille.get(35, 1), background.get(35, 1)); + } + } + + #[test] + fn held_trace_reaches_the_end_of_its_last_interval() { + let background = render_style(SpectrumStyle::Scatter, vec![-100.0; 2], None); + let held = render_style( + SpectrumStyle::Scatter, + vec![-100.0; 2], + Some(Arc::new(vec![-20.0; 2])), + ); + assert_ne!(held.get(35, 1), background.get(35, 1)); + } + #[test] fn band_of_is_monotone_and_never_indexes_past_the_palette() { let t = crate::theme::Theme::sdr(); diff --git a/src/ui/panels/core/spectrum/view.rs b/src/ui/panels/core/spectrum/view.rs index 132ea39..9a2a004 100644 --- a/src/ui/panels/core/spectrum/view.rs +++ b/src/ui/panels/core/spectrum/view.rs @@ -89,6 +89,12 @@ impl SpectrumView { self.bin_axis.interval_count(self.n_bins).unwrap_or(1) as f64 } + pub fn bin_end(&self, index: usize) -> f64 { + match self.bin_axis { + BinAxis::FftBins => (index + 1) as f64, + } + } + /// The level at `freq_hz`, or `None` when it falls outside the window. pub fn level_at(&self, freq_hz: u64) -> Option { let idx = self @@ -241,6 +247,27 @@ mod tests { ); } + #[test] + fn sub_bin_lookup_stays_in_its_fft_interval_at_high_zoom() { + let bins = ramp(256); + let view = SpectrumView::new( + &bins, + &bins, + None, + 100_000_000, + 32_000_000.0, + 32, + BinAxis::FftBins, + ) + .unwrap(); + for i in 0..view.n_bins { + let hz = view.freq_of_bin(i) as u64; + for offset in [0, 62_500, 100_000, 124_999] { + assert_eq!(view.level_at(hz + offset), Some(view.bins[i])); + } + } + } + #[test] fn bin_frequencies_map_to_their_canvas_positions_across_zoom() { let bins = ramp(32); diff --git a/src/ui/panels/core/waterfall/axes.rs b/src/ui/panels/core/waterfall/axes.rs index 9f339bb..8ec9bac 100644 --- a/src/ui/panels/core/waterfall/axes.rs +++ b/src/ui/panels/core/waterfall/axes.rs @@ -82,14 +82,14 @@ pub(super) fn indicator( area: Rect, state: &SdrMetrics, rows: &VecDeque<(Instant, Arc>)>, - columns: &Columns, + columns: Option<&Columns>, skip_data: usize, cursor_col: Option, stride: usize, theme: &crate::Theme, ) { - let text = match (state.waterfall.cursor_freq, cursor_col) { - (Some(cf), Some(col)) => cursor_readout(cf, col, rows, columns, skip_data), + let text = match (state.waterfall.cursor_freq, cursor_col.zip(columns)) { + (Some(cf), Some((col, columns))) => cursor_readout(cf, col, rows, columns, skip_data), // A cursor set outside the current zoom window: name it, but there is // nothing on screen to read a level from. (Some(cf), None) => format!(" cur: {:.3} MHz \u{2190} \u{2192} M", cf as f64 / 1e6), diff --git a/src/ui/panels/core/waterfall/cells.rs b/src/ui/panels/core/waterfall/cells.rs index 1f943c3..02685a8 100644 --- a/src/ui/panels/core/waterfall/cells.rs +++ b/src/ui/panels/core/waterfall/cells.rs @@ -22,7 +22,7 @@ use ratatui::{ }; use crate::palette::{magnitude_to_color_palette, ColorDepth, WaterfallPalette}; -use crate::state::BinAxis; +use crate::state::{BinAxis, BinWindow}; /// Top of the colour scale. The waterfall is always referenced to full scale; /// only the floor (`db_min`) moves, under `↑`/`↓`. @@ -48,27 +48,16 @@ pub(super) fn band_max(row: &[f32], start: usize, end: usize) -> f32 { /// Zoom keeps the centre `1/zoom` of the row's bins - the same slice the bonded /// spectrum above narrows to, so `+`/`-` zoom both plots as one instrument. pub(super) struct Columns { - lo_bin: usize, - visible_n: usize, - row_bins: usize, + pub window: BinWindow, cols: usize, bin_axis: BinAxis, } impl Columns { - pub fn new( - row_bins: usize, - first_bin: usize, - visible_n: usize, - cols: usize, - bin_axis: BinAxis, - ) -> Self { - let row_bins = row_bins.max(1); - let visible_n = visible_n.max(1).min(row_bins); + /// Use a window computed from the row being drawn + pub fn new(window: BinWindow, cols: usize, bin_axis: BinAxis) -> Self { Self { - lo_bin: first_bin.min(row_bins - visible_n), - visible_n, - row_bins, + window, cols: cols.max(1), bin_axis, } @@ -77,15 +66,16 @@ impl Columns { /// The `[start, end)` bin span column `col` reads. Always non-empty, so a /// wide panel over few bins still gets one bin per column rather than none. pub fn range(&self, col: usize) -> (usize, usize) { + let visible_n = self.window.bin_count; let (start, end) = match self.bin_axis { BinAxis::FftBins => ( - col * self.visible_n / self.cols, - (col + 1) * self.visible_n / self.cols, + col * visible_n / self.cols, + (col + 1) * visible_n / self.cols, ), }; - let start = (self.lo_bin + start).min(self.row_bins - 1); - let end = (self.lo_bin + end).max(start + 1).min(self.row_bins); - (start, end) + let start = start.min(visible_n - 1); + let end = end.max(start + 1).min(visible_n); + (self.window.first_bin + start, self.window.first_bin + end) } } @@ -95,7 +85,7 @@ pub(super) fn draw( f: &mut Frame, area: Rect, rows: &VecDeque<(Instant, Arc>)>, - columns: &Columns, + columns_for_row: impl Fn(usize) -> Option, cursor_col: Option, skip_data: usize, db_min: f32, @@ -111,16 +101,25 @@ pub(super) fn draw( let mut data = rows.iter().skip(skip_data).take(area.height as usize * 2); while let Some((_ts, top_row)) = data.next() { let bot_row = data.next().map(|(_ts, r)| r.as_ref()); + let top_columns = columns_for_row(top_row.len()); + let bot_columns = bot_row.and_then(|row| columns_for_row(row.len())); + let row_color = |row: &[f32], columns: Option<&Columns>, col| { + columns.map_or(floor, |columns| { + let (lo, hi) = columns.range(col); + color(band_max(row, lo, hi)) + }) + }; let spans: Vec = (0..cols) .map(|col| { - let (lo, hi) = columns.range(col); - let bot_color = bot_row.map(|r| color(band_max(r, lo, hi))).unwrap_or(floor); + let bot_color = bot_row + .map(|r| row_color(r, bot_columns.as_ref(), col)) + .unwrap_or(floor); // The cursor column keeps the background so the history still reads // through it, but takes a bright foreground as its marker. let top_color = if Some(col) == cursor_col { theme.value_hi } else { - color(band_max(top_row, lo, hi)) + row_color(top_row, top_columns.as_ref(), col) }; Span::styled("\u{2580}", Style::default().fg(top_color).bg(bot_color)) }) @@ -135,6 +134,14 @@ pub(super) fn draw( mod tests { use super::*; + fn columns(row_bins: usize, zoom: usize, cols: usize) -> Columns { + let axis = BinAxis::FftBins; + let window = axis + .window(100_000_000, 32_000_000.0, row_bins, zoom) + .unwrap(); + Columns::new(window, cols, axis) + } + #[test] fn band_max_reads_in_range() { let row = [-90.0, -50.0, -70.0, -60.0]; @@ -159,7 +166,7 @@ mod tests { #[test] fn unzoomed_columns_cover_every_bin_exactly_once() { - let c = Columns::new(1024, 0, 1024, 128, BinAxis::FftBins); + let c = columns(1024, 1, 128); let (first, _) = c.range(0); let (_, last) = c.range(127); assert_eq!(first, 0, "the first column starts at the first bin"); @@ -176,7 +183,7 @@ mod tests { #[test] fn zoom_keeps_the_centre_slice() { - let c = Columns::new(1024, 384, 256, 128, BinAxis::FftBins); + let c = columns(1024, 4, 128); let (first, _) = c.range(0); let (_, last) = c.range(127); assert_eq!(first, 384, "a quarter of the way in"); @@ -187,7 +194,7 @@ mod tests { fn columns_follow_the_bin_window_at_uneven_zoom() { let axis = BinAxis::FftBins; let window = axis.window(100, 10.0, 10, 3).unwrap(); - let columns = Columns::new(10, window.first_bin, window.bin_count, 3, axis); + let columns = Columns::new(window, 3, axis); assert_eq!(columns.range(0), (4, 5)); assert_eq!(columns.range(1), (5, 6)); assert_eq!(columns.range(2), (6, 7)); @@ -203,7 +210,7 @@ mod tests { fn a_column_is_never_empty_however_odd_the_geometry() { // More columns than bins: every column still reads at least one bin, // rather than an empty span that would paint the whole plot at the floor. - let c = Columns::new(8, 0, 8, 200, BinAxis::FftBins); + let c = columns(8, 1, 200); for col in 0..200 { let (lo, hi) = c.range(col); assert!(hi > lo, "column {col} is empty"); @@ -221,12 +228,12 @@ mod tests { let (row_bins, cols) = (1024usize, 128usize); let naive = |col: usize| col * row_bins / cols; - let unzoomed = Columns::new(row_bins, 0, row_bins, cols, BinAxis::FftBins); + let unzoomed = columns(row_bins, 1, cols); for col in 0..cols { assert_eq!(unzoomed.range(col).0, naive(col), "zoom 1 hides the bug"); } - let zoomed = Columns::new(row_bins, 384, 256, cols, BinAxis::FftBins); + let zoomed = columns(row_bins, 4, cols); assert_eq!(zoomed.range(0).0, 384); assert_eq!( naive(0), @@ -238,11 +245,29 @@ mod tests { } #[test] - fn degenerate_input_does_not_underflow() { - let c = Columns::new(0, 0, 0, 40, BinAxis::FftBins); + fn singleton_and_zero_columns_do_not_underflow() { + let c = columns(1, 32, 40); let (lo, hi) = c.range(0); assert!(hi > lo); - let zero_cols = Columns::new(1024, 384, 256, 0, BinAxis::FftBins); + let zero_cols = columns(1024, 4, 0); let _ = zero_cols.range(0); } + + #[test] + fn high_zoom_sub_bin_frequencies_use_the_same_interval() { + let axis = BinAxis::FftBins; + let columns = columns(256, 32, 160); + let window = columns.window; + for col in 0..160 { + let frequency = window.left_hz + col as f64 * window.span_hz / 160.0; + let index = axis + .nearest_bin(window.left_hz, window.span_hz, window.bin_count, frequency) + .unwrap(); + assert_eq!( + columns.range(col).0, + window.first_bin + index, + "column {col}" + ); + } + } } diff --git a/src/ui/panels/core/waterfall/mod.rs b/src/ui/panels/core/waterfall/mod.rs index 7eb5093..7b60092 100644 --- a/src/ui/panels/core/waterfall/mod.rs +++ b/src/ui/panels/core/waterfall/mod.rs @@ -252,15 +252,21 @@ fn contents( } let cols = plot.width as usize; - // The frequency window, narrowed by the shared zoom around the tuned centre. - let bin_window = wf - .last_fft + // Two data rows occupy each character row + let data_rows = plot.height as usize * 2; + let max_scroll = buf.rows.len().saturating_sub(data_rows) / 2; + let skip_data = wf.scroll_offset.min(max_scroll) * 2; + + let columns_for_row = |row_bins| columns_for_row(wf, row_bins, cols); + let columns = buf + .rows + .get(skip_data) + .and_then(|(_, row)| columns_for_row(row.len())); + let window = columns .as_ref() - .and_then(|frame| frame.window(wf.hz_zoom as usize)); - let window = bin_window - .map(|window| Window { - left_hz: window.left_hz, - bw: window.span_hz, + .map(|columns| Window { + left_hz: columns.window.left_hz, + bw: columns.window.span_hz, }) .unwrap_or(Window { left_hz: 0.0, @@ -278,30 +284,16 @@ fn contents( .then(|| ((frac * cols as f64) as usize).min(cols - 1)) }); - // Two rows of history per character cell, so every scroll figure exists in - // both units: `skip_chars` on screen, `skip_data` into the buffer. - let data_rows = plot.height as usize * 2; - let max_scroll = buf.rows.len().saturating_sub(data_rows) / 2; - let skip_data = wf.scroll_offset.min(max_scroll) * 2; - - let row_bins = buf.rows.front().map(|(_, r)| r.len()).unwrap_or(1); - let columns = bin_window - .zip(wf.last_fft.as_ref()) - .map(|(window, frame)| { - Columns::new( - row_bins, - window.first_bin, - window.bin_count, - cols, - frame.bin_axis, - ) - }) - .unwrap_or_else(|| { - Columns::new(row_bins, 0, row_bins, cols, crate::state::BinAxis::FftBins) - }); - cells::draw( - f, plot, &buf.rows, &columns, cursor_col, skip_data, wf.db_min, wf.palette, theme, + f, + plot, + &buf.rows, + columns_for_row, + cursor_col, + skip_data, + wf.db_min, + wf.palette, + theme, ); // Bonded, the spectrum above already carries the band plan; twice is noise. @@ -326,7 +318,7 @@ fn contents( area, state, &buf.rows, - &columns, + columns.as_ref(), skip_data, cursor_col, status.stride, @@ -335,9 +327,83 @@ fn contents( } } +fn columns_for_row( + wf: &crate::state::WaterfallState, + row_bins: usize, + cols: usize, +) -> Option { + let (axis, center_hz, span_hz) = wf + .last_fft + .as_ref() + .map_or((crate::state::BinAxis::FftBins, 0, 1.0), |frame| { + (frame.bin_axis, frame.center_freq_hz, frame.sample_rate) + }); + let window = axis.window(center_hz, span_hz, row_bins, wf.hz_zoom as usize)?; + Some(Columns::new(window, cols, axis)) +} + #[cfg(test)] mod tests { use super::*; + use std::sync::Arc; + + #[test] + fn paused_history_keeps_its_own_bin_window_after_fft_size_changes() { + let mut state = crate::state::SdrMetrics::fixture().with_carrier(0.0, 70.0); + state.waterfall.hz_zoom = 4; + state.waterfall.buffer.paused = true; + let old_row = Arc::clone(&state.waterfall.buffer.rows.front().unwrap().1); + let frame = state.waterfall.last_fft.as_mut().unwrap(); + frame.bins_dbfs = Arc::new(vec![-90.0; 1024]); + assert!(!state.waterfall.buffer.push(&frame.bins_dbfs)); + + let columns = columns_for_row(&state.waterfall, old_row.len(), 64).unwrap(); + assert_eq!(columns.window.first_bin, 96); + assert_eq!(columns.window.bin_count, 64); + assert_eq!(columns.range(0), (96, 97)); + assert_eq!(columns.range(63), (159, 160)); + assert!(columns_for_row(&state.waterfall, 0, 64).is_none()); + } + + #[test] + fn mixed_size_history_rows_draw_their_own_centre_slices() { + use ratatui::{backend::TestBackend, Terminal}; + let state = crate::state::SdrMetrics::fixture().with_carrier(0.0, 70.0); + let mut wf = state.waterfall; + wf.hz_zoom = 4; + let mut top = vec![-120.0; 256]; + let mut bottom = vec![-120.0; 1024]; + top[96] = -20.0; + bottom[384] = -20.0; + wf.buffer.rows.clear(); + wf.buffer + .rows + .push_back((std::time::Instant::now(), Arc::new(top))); + wf.buffer + .rows + .push_back((std::time::Instant::now(), Arc::new(bottom))); + let mut terminal = Terminal::new(TestBackend::new(64, 1)).unwrap(); + let theme = crate::theme::Theme::sdr(); + terminal + .draw(|f| { + cells::draw( + f, + Rect::new(0, 0, 64, 1), + &wf.buffer.rows, + |n| columns_for_row(&wf, n, 64), + None, + 0, + wf.db_min, + wf.palette, + &theme, + ) + }) + .unwrap(); + let buffer = terminal.backend().buffer(); + assert_eq!(buffer.get(0, 0).fg, buffer.get(0, 0).bg); + assert_ne!(buffer.get(0, 0).fg, buffer.get(1, 0).fg); + assert_ne!(buffer.get(0, 0).bg, buffer.get(1, 0).bg); + } #[test] fn the_ladders_walk_and_stop_at_their_ends() { diff --git a/src/ui/panels/lab/bars.rs b/src/ui/panels/lab/bars.rs index 62b382d..86c4c46 100644 --- a/src/ui/panels/lab/bars.rs +++ b/src/ui/panels/lab/bars.rs @@ -82,17 +82,14 @@ fn fmt_delta(df_hz: u64, dl_db: Option) -> String { /// is no frame yet or the frequency is outside the captured span. fn level_at_freq(state: &SdrMetrics, freq_hz: u64) -> Option { let fr = state.waterfall.last_fft.as_ref()?; - let n = fr.bins_dbfs.len(); - if n == 0 { - return None; - } - let left = fr.center_freq_hz as f64 - fr.sample_rate / 2.0; - let frac = (freq_hz as f64 - left) / fr.sample_rate; - if !(0.0..=1.0).contains(&frac) { - return None; - } - let idx = (frac * (n - 1) as f64).round() as usize; - fr.bins_dbfs.get(idx.min(n - 1)).copied() + let window = fr.window(1)?; + let idx = fr.bin_axis.nearest_bin( + window.left_hz, + window.span_hz, + window.bin_count, + freq_hz as f64, + )?; + fr.bins_dbfs.get(idx).copied() } /// Display width (columns) of a span run - every glyph we use here is single-width. @@ -1050,6 +1047,19 @@ mod tests { use super::*; use crate::hardware::native::{hackrf, rtlsdr}; + #[test] + fn marker_level_uses_the_captured_fft_interval() { + let mut state = SdrMetrics::fixture(); + state.radio.frequency = 100_000_000; + state.radio.config_sample_rate = 32_000_000.0; + let mut state = state.with_carrier(8_000_000.0, 70.0); + state.radio.frequency = 200_000_000; + assert_eq!(level_at_freq(&state, 108_000_000), Some(-30.0)); + assert_eq!(level_at_freq(&state, 108_100_000), Some(-30.0)); + assert_eq!(level_at_freq(&state, 108_125_000), Some(-100.0)); + assert_eq!(level_at_freq(&state, 116_000_000), Some(-100.0)); + assert_eq!(level_at_freq(&state, 116_000_001), None); + } /// A signal state with real numbers in it, as if the FFT had just run. fn measured() -> crate::state::SignalState { crate::state::SignalState { diff --git a/src/ui/panels/lab/signal_characterization/metrics.rs b/src/ui/panels/lab/signal_characterization/metrics.rs index 1daf5f1..1d8c2d2 100644 --- a/src/ui/panels/lab/signal_characterization/metrics.rs +++ b/src/ui/panels/lab/signal_characterization/metrics.rs @@ -139,13 +139,7 @@ fn peak_bin(fr: &FftFrame) -> Option<(f32, u64)> { let n = bins.len(); let radius = crate::signal::fft::centre_radius_bins(n, fr.sample_rate); let (idx, best) = crate::signal::fft::strongest_real_bin(bins, Some(radius))?; - let left = fr.center_freq_hz as f64 - fr.sample_rate / 2.0; - let span_frac = if n > 1 { - idx as f64 / (n - 1) as f64 - } else { - 0.0 - }; - let freq = (left + span_frac * fr.sample_rate).max(0.0).round() as u64; + let freq = fr.frequency_of_bin(idx)?.max(0.0).round() as u64; Some((best, freq)) } @@ -206,7 +200,7 @@ mod tests { fn peak_bin_maps_index_to_frequency() { let (lvl, hz) = peak_bin(&peaked_at(75)).unwrap(); assert!((lvl + 10.0).abs() < 1e-6, "peak level is the max bin"); - assert_eq!(hz, 100_100_000, "three quarters across the span"); + assert_eq!(hz, 100_097_030); } #[test] @@ -222,7 +216,7 @@ mod tests { (lvl + 30.0).abs() < 1e-6, "reported a station out of channel: {lvl}" ); - assert_eq!(hz, 100_100_000); + assert_eq!(hz, 100_097_030); } #[test] @@ -235,7 +229,7 @@ mod tests { bins[60] = -30.0; // a real, weaker carrier let (lvl, hz) = peak_bin(&frame(bins, 100_000_000, 400_000.0)).unwrap(); assert!((lvl + 30.0).abs() < 1e-6, "reported the artefact: {lvl}"); - assert_eq!(hz, 100_040_000); + assert_eq!(hz, 100_037_624); } #[test] From b051974e4218e868556db0c0778b99ec18d8db82 Mon Sep 17 00:00:00 2001 From: Dominic Griesel Date: Mon, 7 Sep 2026 21:25:12 +0200 Subject: [PATCH 5/7] refactor: derive display levels and trace age from device capabilities Keep existing IQ profiles at -120 to 0 dBFS with a 500 ms trace-age limit. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/app/builder/boot.rs | 70 +++++++++++++++-- src/app/input/core.rs | 103 ++++++++++++++++++++++++-- src/hardware/mod.rs | 5 +- src/hardware/native/hackrf/mod.rs | 4 + src/hardware/native/rtlsdr/mod.rs | 4 + src/hardware/soapy/caps.rs | 9 +++ src/hardware/traits.rs | 19 +++++ src/state/fixture.rs | 5 +- src/state/waterfall.rs | 2 + src/ui/panel.rs | 59 +++++++++++---- src/ui/panels/core/spectrum/axes.rs | 5 +- src/ui/panels/core/spectrum/labels.rs | 6 +- src/ui/panels/core/spectrum/mod.rs | 21 ++++++ src/ui/panels/core/waterfall/axes.rs | 45 +++++++++-- src/ui/panels/core/waterfall/cells.rs | 44 +++++++++-- src/ui/panels/core/waterfall/mod.rs | 32 ++++++-- 16 files changed, 379 insertions(+), 54 deletions(-) diff --git a/src/app/builder/boot.rs b/src/app/builder/boot.rs index ee86e23..98e3751 100644 --- a/src/app/builder/boot.rs +++ b/src/app/builder/boot.rs @@ -361,8 +361,8 @@ pub(super) fn initial_metrics(cfg: &AppConfig, boot: Boot) -> SdrMetrics { observer, spectrum: SpectrumState { step_hz: 100_000, - y_min: -120.0, - y_max: 0.0, + y_min: caps.level_min_db, + y_max: caps.level_max_db, hold: None, cursor_freq: None, markers, @@ -372,10 +372,15 @@ pub(super) fn initial_metrics(cfg: &AppConfig, boot: Boot) -> SdrMetrics { // Clamped, not trusted: `save_config` writes the live buffer depth back, // so a config written before the floor existed carries a value too small // to fill a full-height waterfall. See [`WATERFALL_MIN_ROWS`]. - waterfall: WaterfallState::new( - cfg.display.waterfall_max_rows.max(WATERFALL_MIN_ROWS), - cfg.display.waterfall_palette, - ), + waterfall: { + let mut waterfall = WaterfallState::new( + cfg.display.waterfall_max_rows.max(WATERFALL_MIN_ROWS), + cfg.display.waterfall_palette, + ); + waterfall.db_min = caps.level_min_db; + waterfall.db_max = caps.level_max_db; + waterfall + }, system: SystemState { board_name: Arc::from(identity.board_name.as_str()), serial: Arc::from(identity.serial.as_str()), @@ -576,6 +581,59 @@ mod tests { } } + #[test] + fn iq_devices_keep_the_existing_startup_defaults() { + let cfg = AppConfig::default(); + for caps in [ + hardware::native::hackrf::caps(), + hardware::native::rtlsdr::observer_caps(), + ] { + let tuning = resolve_tuning(&cfg.radio, &caps); + let metrics = initial_metrics( + &cfg, + Boot::normal( + &cfg, + Arc::new(caps), + tuning, + &hardware::DeviceInfo::default(), + ), + ); + assert!(!metrics.radio.rx_enabled); + assert!(!metrics.radio.hw_streaming); + assert_eq!(metrics.spectrum.y_min, -120.0); + assert_eq!(metrics.spectrum.y_max, 0.0); + assert_eq!(metrics.waterfall.db_min, -120.0); + assert_eq!(metrics.waterfall.db_max, 0.0); + assert_eq!(metrics.caps.level_unit, hardware::LevelUnit::Dbfs); + assert_eq!(metrics.caps.level_unit.label(), "dBFS"); + assert_eq!(metrics.caps.trace_stale_ms, 500); + } + } + + #[test] + fn startup_uses_the_device_level_axis() { + let cfg = AppConfig::default(); + let mut caps = hardware::native::hackrf::caps(); + caps.level_min_db = -110.0; + caps.level_max_db = -10.0; + let tuning = resolve_tuning(&cfg.radio, &caps); + let metrics = initial_metrics( + &cfg, + Boot::normal( + &cfg, + Arc::new(caps), + tuning, + &hardware::DeviceInfo::default(), + ), + ); + assert!(!metrics.radio.rx_enabled); + assert!(!metrics.radio.hw_streaming); + assert_eq!(metrics.spectrum.y_min, -110.0); + assert_eq!(metrics.spectrum.y_max, -10.0); + assert_eq!(metrics.waterfall.db_min, -110.0); + assert_eq!(metrics.waterfall.db_max, -10.0); + } + /// A config that asks for less waterfall history than a full-height panel /// needs is raised, not honoured. /// diff --git a/src/app/input/core.rs b/src/app/input/core.rs index ea3b1b8..5bd9f8b 100644 --- a/src/app/input/core.rs +++ b/src/app/input/core.rs @@ -113,14 +113,16 @@ pub(super) fn spectrum(key: KeyEvent, ctx: &mut InputCtx<'_>) -> KeyAction { let new_min = (m.spectrum.y_min + 10.0).min(m.spectrum.y_max - 20.0); m.spectrum.y_min = new_min; let ymax = m.spectrum.y_max; - m.push_log(format!("Zoom: {:.0}…{:.0} dBFS", new_min, ymax)); + let unit = m.caps.level_unit.label(); + m.push_log(format!("Zoom: {new_min:.0}\u{2026}{ymax:.0} {unit}")); } KeyCode::Down => { let mut m = metrics(state); - let new_min = (m.spectrum.y_min - 10.0).max(-120.0); + let new_min = (m.spectrum.y_min - 10.0).max(m.caps.level_min_db); m.spectrum.y_min = new_min; let ymax = m.spectrum.y_max; - m.push_log(format!("Zoom: {:.0}…{:.0} dBFS", new_min, ymax)); + let unit = m.caps.level_unit.label(); + m.push_log(format!("Zoom: {new_min:.0}\u{2026}{ymax:.0} {unit}")); } KeyCode::Char('j') => { let mut m = metrics(state); @@ -212,6 +214,7 @@ pub(super) fn spectrum(key: KeyEvent, ctx: &mut InputCtx<'_>) -> KeyAction { } KeyAction::Continue } + // ── Waterfall focus keys ────────────────────────────────────────────────────── pub(super) fn waterfall(key: KeyEvent, ctx: &mut InputCtx<'_>) -> KeyAction { @@ -219,15 +222,23 @@ pub(super) fn waterfall(key: KeyEvent, ctx: &mut InputCtx<'_>) -> KeyAction { match key.code { KeyCode::Up => { let mut m = metrics(state); - let new_min = (m.waterfall.db_min + 10.0).min(-20.0); + let new_min = (m.waterfall.db_min + 10.0).min(m.waterfall.db_max - 20.0); m.waterfall.db_min = new_min; - m.push_log(format!("Waterfall zoom: {:.0}…0 dBFS", new_min)); + let max = m.waterfall.db_max; + let unit = m.caps.level_unit.label(); + m.push_log(format!( + "Waterfall zoom: {new_min:.0}\u{2026}{max:.0} {unit}" + )); } KeyCode::Down => { let mut m = metrics(state); - let new_min = (m.waterfall.db_min - 10.0).max(-120.0); + let new_min = (m.waterfall.db_min - 10.0).max(m.caps.level_min_db); m.waterfall.db_min = new_min; - m.push_log(format!("Waterfall zoom: {:.0}…0 dBFS", new_min)); + let max = m.waterfall.db_max; + let unit = m.caps.level_unit.label(); + m.push_log(format!( + "Waterfall zoom: {new_min:.0}\u{2026}{max:.0} {unit}" + )); } KeyCode::Char('[') => { let mut m = metrics(state); @@ -305,6 +316,11 @@ pub(super) fn waterfall(key: KeyEvent, ctx: &mut InputCtx<'_>) -> KeyAction { #[cfg(test)] mod tests { use super::*; + use std::collections::HashMap; + use std::sync::{Arc, Mutex}; + + use crate::state::SdrMetrics; + use crate::ui::{LayoutEngine, PanelRegistry}; #[test] fn peak_jump_uses_the_captured_fft_frequency_after_retuning() { @@ -340,4 +356,77 @@ mod tests { 1_000_000 ); } + + #[test] + fn level_zoom_uses_device_bounds_and_preserves_iq_log_text() { + for (min, max) in [(-120.0, 0.0), (-110.0, -10.0)] { + for is_waterfall in [false, true] { + let mut m = SdrMetrics::fixture(); + Arc::make_mut(&mut m.caps).level_min_db = min; + Arc::make_mut(&mut m.caps).level_max_db = max; + m.spectrum.y_min = min; + m.spectrum.y_max = max; + m.waterfall.db_min = min; + m.waterfall.db_max = max; + let state = Arc::new(Mutex::new(m)); + let mut engine = LayoutEngine::new( + crate::config::LayoutConfig::default_config(), + PanelRegistry::new(), + ); + let mut show_footer = true; + let focus_keys = HashMap::new(); + let mut ctx = InputCtx { + state: &state, + device: None, + engine: &mut engine, + show_footer: &mut show_footer, + focus_keys: &focus_keys, + }; + let prefix = if is_waterfall { + "Waterfall zoom" + } else { + "Zoom" + }; + let mut press = |code| { + let key = KeyEvent::new(code, crossterm::event::KeyModifiers::NONE); + if is_waterfall { + waterfall(key, &mut ctx); + } else { + spectrum(key, &mut ctx); + } + }; + press(KeyCode::Up); + assert_eq!( + metrics(&state).ui.log.back().unwrap().text.as_ref(), + format!("{prefix}: {:.0}\u{2026}{max:.0} dBFS", min + 10.0) + ); + for _ in 0..20 { + press(KeyCode::Up); + } + { + let m = metrics(&state); + let floor = if is_waterfall { + m.waterfall.db_min + } else { + m.spectrum.y_min + }; + assert_eq!(floor, max - 20.0); + } + for _ in 0..20 { + press(KeyCode::Down); + } + let m = metrics(&state); + let floor = if is_waterfall { + m.waterfall.db_min + } else { + m.spectrum.y_min + }; + assert_eq!(floor, min); + assert_eq!( + m.ui.log.back().unwrap().text.as_ref(), + format!("{prefix}: {min:.0}\u{2026}{max:.0} dBFS") + ); + } + } + } } diff --git a/src/hardware/mod.rs b/src/hardware/mod.rs index 504e252..5969df8 100644 --- a/src/hardware/mod.rs +++ b/src/hardware/mod.rs @@ -30,6 +30,7 @@ mod traits; pub use discovery::{list_all_devices, open_device, DeviceKind, DeviceListing}; pub use traits::{ - Boost, DeliveryModel, DeviceCapabilities, DeviceInfo, FeedHealth, GainModel, RxContext, - SampleFormat, SampleGeometry, SdrDevice, SoftwareStack, StageSpec, StreamBlock, + Boost, DeliveryModel, DeviceCapabilities, DeviceInfo, FeedHealth, GainModel, LevelUnit, + RxContext, SampleFormat, SampleGeometry, SdrDevice, SoftwareStack, StageSpec, StreamBlock, + IQ_TRACE_STALE_MS, }; diff --git a/src/hardware/native/hackrf/mod.rs b/src/hardware/native/hackrf/mod.rs index 8e59847..5c4ee9f 100644 --- a/src/hardware/native/hackrf/mod.rs +++ b/src/hardware/native/hackrf/mod.rs @@ -332,6 +332,10 @@ pub fn gain_model() -> GainModel { /// HackRF One capability descriptor - also used as the observer-mode default. pub fn caps() -> DeviceCapabilities { DeviceCapabilities { + level_unit: crate::hardware::LevelUnit::Dbfs, + level_min_db: -120.0, + level_max_db: 0.0, + trace_stale_ms: crate::hardware::IQ_TRACE_STALE_MS, freq_min_hz: 1_000_000, freq_max_hz: 6_000_000_000, sample_rate_min_hz: 2_000_000.0, diff --git a/src/hardware/native/rtlsdr/mod.rs b/src/hardware/native/rtlsdr/mod.rs index 347662d..b53a758 100644 --- a/src/hardware/native/rtlsdr/mod.rs +++ b/src/hardware/native/rtlsdr/mod.rs @@ -364,6 +364,10 @@ fn rtl_caps(tuner: c_int, gains_tenths: &[i32]) -> DeviceCapabilities { } DeviceCapabilities { + level_unit: crate::hardware::LevelUnit::Dbfs, + level_min_db: -120.0, + level_max_db: 0.0, + trace_stale_ms: crate::hardware::IQ_TRACE_STALE_MS, freq_min_hz, freq_max_hz, // RTL-SDR's usable upper band is 900_001..=3_200_000 Hz (the lower diff --git a/src/hardware/soapy/caps.rs b/src/hardware/soapy/caps.rs index d61e434..d6d8440 100644 --- a/src/hardware/soapy/caps.rs +++ b/src/hardware/soapy/caps.rs @@ -163,6 +163,10 @@ pub fn capabilities(a: &DriverAnswers) -> Result { let boost = element_boost.or(a.has_gain_mode.then_some(Boost::GainMode)); let caps = DeviceCapabilities { + level_unit: crate::hardware::LevelUnit::Dbfs, + level_min_db: -120.0, + level_max_db: 0.0, + trace_stale_ms: crate::hardware::IQ_TRACE_STALE_MS, freq_min_hz: freq_min.max(0.0) as u64, freq_max_hz: freq_max.max(0.0) as u64, sample_rate_min_hz: rate_min, @@ -330,6 +334,11 @@ mod tests { #[test] fn a_soapy_hackrf_comes_out_the_way_the_probe_describes_it() { let c = capabilities(&soapy_hackrf()).unwrap().caps; + assert_eq!(c.level_unit, crate::hardware::LevelUnit::Dbfs); + assert_eq!(c.level_unit.label(), "dBFS"); + assert_eq!(c.level_min_db, -120.0); + assert_eq!(c.level_max_db, 0.0); + assert_eq!(c.trace_stale_ms, 500); assert_eq!(c.freq_min_hz, 0); assert_eq!(c.freq_max_hz, 7_250_000_000); assert_eq!(c.sample_rate_min_hz, 1e6); diff --git a/src/hardware/traits.rs b/src/hardware/traits.rs index bd0f04e..5cb9a59 100644 --- a/src/hardware/traits.rs +++ b/src/hardware/traits.rs @@ -12,6 +12,21 @@ use std::sync::{Arc, Mutex}; use crate::state::SdrMetrics; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LevelUnit { + Dbfs, +} + +pub const IQ_TRACE_STALE_MS: u128 = 500; + +impl LevelUnit { + pub fn label(self) -> &'static str { + match self { + Self::Dbfs => "dBFS", + } + } +} + /// How raw USB bytes encode each I/Q component. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum SampleFormat { @@ -616,6 +631,10 @@ pub enum DeliveryModel { /// truth for every clamp, default, and UI capability check. Built once at open. #[derive(Clone, Debug)] pub struct DeviceCapabilities { + pub level_unit: LevelUnit, + pub level_min_db: f32, + pub level_max_db: f32, + pub trace_stale_ms: u128, pub freq_min_hz: u64, pub freq_max_hz: u64, pub sample_rate_min_hz: f64, diff --git a/src/state/fixture.rs b/src/state/fixture.rs index 677d5b5..c6e62c4 100644 --- a/src/state/fixture.rs +++ b/src/state/fixture.rs @@ -160,12 +160,11 @@ impl SdrMetrics { self } - /// Age the newest FFT frame past [`crate::ui::panel::FFT_STALE_MS`], so the - /// staleness paths can be rendered without a test sleeping. + /// Age the newest FFT frame past the IQ trace limit pub(crate) fn with_stale_fft(mut self) -> Self { if let Some(fr) = self.waterfall.last_fft.as_mut() { fr.timestamp = Instant::now() - - std::time::Duration::from_millis(crate::ui::panel::FFT_STALE_MS as u64 + 50); + - std::time::Duration::from_millis(crate::hardware::IQ_TRACE_STALE_MS as u64 + 50); } self } diff --git a/src/state/waterfall.rs b/src/state/waterfall.rs index 3fbb135..eb32805 100644 --- a/src/state/waterfall.rs +++ b/src/state/waterfall.rs @@ -240,6 +240,7 @@ impl WaterfallBuffer { #[derive(Clone)] pub struct WaterfallState { pub db_min: f32, + pub db_max: f32, pub scroll_offset: usize, pub cursor_freq: Option, pub hz_zoom: u32, @@ -253,6 +254,7 @@ impl WaterfallState { pub fn new(max_rows: usize, palette: crate::palette::WaterfallPalette) -> Self { Self { db_min: -120.0, + db_max: 0.0, scroll_offset: 0, cursor_freq: None, hz_zoom: 1, diff --git a/src/ui/panel.rs b/src/ui/panel.rs index 2efce55..2e697e9 100644 --- a/src/ui/panel.rs +++ b/src/ui/panel.rs @@ -26,17 +26,13 @@ pub enum Staleness { /// Stale whenever the radio is not streaming. For anything read from /// hardware counters: timing, drops, gain staging, IQ balance. NotStreaming, - /// Stale when the newest FFT frame has aged past [`FFT_STALE_MS`], or there - /// is no frame yet. For anything derived from the spectrum. + /// Mark spectrum readings stale when the frame exceeds the device's trace-age limit + /// Missing frames are stale FftAge, /// Never stale. For panels that show configuration rather than measurement. Never, } -/// How old the newest FFT frame may get before an [`Staleness::FftAge`] panel -/// calls its readings stale. Shared with `widgets::micro_common::fft_stale`. -pub const FFT_STALE_MS: u128 = 500; - impl Staleness { /// Resolve the rule against a metrics snapshot. pub fn resolve(self, state: &SdrMetrics) -> bool { @@ -47,16 +43,19 @@ impl Staleness { .last_fft .as_ref() .map(|fr| fr.timestamp.elapsed().as_millis()), + state.caps.trace_stale_ms, ) } /// The rule itself, on plain inputs: `fft_age_ms` is `None` when no frame has /// arrived yet. Split out from [`resolve`](Self::resolve) so the decision can /// be tested without building a whole metrics snapshot. - fn decide(self, streaming: bool, fft_age_ms: Option) -> bool { + fn decide(self, streaming: bool, fft_age_ms: Option, trace_stale_ms: u128) -> bool { match self { Staleness::NotStreaming => !streaming, - Staleness::FftAge => fft_age_ms.map(|ms| ms > FFT_STALE_MS).unwrap_or(true), + Staleness::FftAge => fft_age_ms + .map(|milliseconds| milliseconds > trace_stale_ms) + .unwrap_or(true), Staleness::Never => false, } } @@ -373,21 +372,51 @@ mod tests { #[test] fn staleness_rules_are_independent_of_each_other() { + let stale_ms = crate::hardware::IQ_TRACE_STALE_MS; // A dead radio staleness NotStreaming, and nothing else. - assert!(Staleness::NotStreaming.decide(false, Some(0))); + assert!(Staleness::NotStreaming.decide(false, Some(0), stale_ms)); assert!( - !Staleness::FftAge.decide(false, Some(0)), + !Staleness::FftAge.decide(false, Some(0), stale_ms), "fresh frame, dead radio → live" ); - assert!(!Staleness::Never.decide(false, None), "Never means never"); + assert!( + !Staleness::Never.decide(false, None, stale_ms), + "Never means never" + ); // A streaming radio whose FFT has dried up staleness only FftAge. - assert!(!Staleness::NotStreaming.decide(true, None)); - assert!(Staleness::FftAge.decide(true, None), "no frame yet → stale"); - assert!(Staleness::FftAge.decide(true, Some(FFT_STALE_MS + 1))); + assert!(!Staleness::NotStreaming.decide(true, None, stale_ms)); assert!( - !Staleness::FftAge.decide(true, Some(FFT_STALE_MS)), + Staleness::FftAge.decide(true, None, stale_ms), + "no frame yet → stale" + ); + assert!(Staleness::FftAge.decide(true, Some(stale_ms + 1), stale_ms)); + assert!( + !Staleness::FftAge.decide(true, Some(stale_ms), stale_ms), "the threshold itself is live" ); } + + #[test] + fn trace_staleness_uses_the_device_limit() { + let mut state = SdrMetrics::fixture().streaming().with_carrier(0.0, 20.0); + std::sync::Arc::make_mut(&mut state.caps).trace_stale_ms = 60_000; + state.waterfall.last_fft.as_mut().unwrap().timestamp = + std::time::Instant::now() - std::time::Duration::from_secs(1); + assert!(!Staleness::FftAge.resolve(&state)); + + std::sync::Arc::make_mut(&mut state.caps).trace_stale_ms = 100; + assert!(Staleness::FftAge.resolve(&state)); + + state.waterfall.last_fft = None; + assert!(Staleness::FftAge.resolve(&state)); + } + + #[test] + fn trace_age_threshold_is_inclusive_for_each_device() { + for limit in [0, 100, 500, 60_000] { + assert!(!Staleness::FftAge.decide(false, Some(limit), limit)); + assert!(Staleness::FftAge.decide(true, Some(limit + 1), limit)); + } + } } diff --git a/src/ui/panels/core/spectrum/axes.rs b/src/ui/panels/core/spectrum/axes.rs index 5ba614d..fdbe460 100644 --- a/src/ui/panels/core/spectrum/axes.rs +++ b/src/ui/panels/core/spectrum/axes.rs @@ -49,12 +49,15 @@ pub(super) fn tuning( freq_hz: u64, step_hz: u64, cursor: Option<(f64, f32)>, + unit: &str, theme: &crate::Theme, ) { let step_str = fmt_spectrum_step(step_hz); let freq_str = format!(" {:.3} MHz ", freq_hz as f64 / 1_000_000.0); let readout = match cursor { - Some((mhz, pwr)) => format!(" cur: {mhz:.3} MHz {pwr:.1} dBFS step {step_str} J/K"), + Some((mhz, pwr)) => { + format!(" cur: {mhz:.3} MHz {pwr:.1} {unit} step {step_str} J/K") + } None => format!(" step {step_str} [/]"), }; diff --git a/src/ui/panels/core/spectrum/labels.rs b/src/ui/panels/core/spectrum/labels.rs index 07795d4..a856bdc 100644 --- a/src/ui/panels/core/spectrum/labels.rs +++ b/src/ui/panels/core/spectrum/labels.rs @@ -285,7 +285,11 @@ pub(super) fn signal_annotations( // Noise-floor label - near the left edge, on the row the line actually sits. let nf_row = ((vert.frac_down_to(noise_floor) * (ch - 1) as f32) as u16).min(ch.saturating_sub(2)); - let nf_label = format!("noise floor {:.0} dBFS", noise_floor); + let nf_label = format!( + "noise floor {:.0} {}", + noise_floor, + state.caps.level_unit.label() + ); let nf_lw = nf_label.chars().count() as u16; if nf_lw < area.width { f.render_widget( diff --git a/src/ui/panels/core/spectrum/mod.rs b/src/ui/panels/core/spectrum/mod.rs index b491b8c..b1ce8e8 100644 --- a/src/ui/panels/core/spectrum/mod.rs +++ b/src/ui/panels/core/spectrum/mod.rs @@ -368,6 +368,7 @@ fn draw_instrument( state.radio.frequency, state.spectrum.step_hz, cursor, + state.caps.level_unit.label(), theme, ); } @@ -431,6 +432,26 @@ mod zoom_tests { SdrMetrics::fixture().streaming().with_carrier(0.0, 40.0) } + #[test] + fn level_axis_and_noise_floor_keep_the_iq_readout() { + let mut state = tuned(); + state.ui.active_preset = "lab_signal".into(); + for (min, max) in [(-120.0, 0.0), (-110.0, -10.0)] { + state.spectrum.y_min = min; + state.spectrum.y_max = max; + let rows = draw(SpectrumPanel, 100, 20, &state); + let gutter: String = rows + .iter() + .skip(1) + .take(18) + .flat_map(|row| row.chars().skip(1).take(6)) + .collect(); + assert!(gutter.contains(&format!("{min:.0}")), "{gutter}"); + assert!(gutter.contains(&format!("{max:.0}")), "{gutter}"); + assert!(rows.join("\n").contains("noise floor -100 dBFS")); + } + } + /// The frequency axis has to narrow with the zoom even when the spectrum is /// standalone. /// diff --git a/src/ui/panels/core/waterfall/axes.rs b/src/ui/panels/core/waterfall/axes.rs index 8ec9bac..991a377 100644 --- a/src/ui/panels/core/waterfall/axes.rs +++ b/src/ui/panels/core/waterfall/axes.rs @@ -23,7 +23,7 @@ use crate::palette::{magnitude_to_color_palette, ColorDepth, WaterfallPalette}; use crate::state::SdrMetrics; use crate::ui::panels::core::spectrum::fmt_spectrum_step; -use super::cells::{band_max, Columns, DB_MAX}; +use super::cells::{band_max, Columns}; /// Width of the dB gutter. Matches the spectrum's label column so the two plots /// start at the same x. @@ -35,6 +35,7 @@ pub(super) fn db_legend( f: &mut Frame, area: Rect, db_min: f32, + db_max: f32, palette: WaterfallPalette, theme: &crate::Theme, ) { @@ -46,9 +47,9 @@ pub(super) fn db_legend( let steps = (h * 2).max(2); let at = |t: f32| { magnitude_to_color_palette( - DB_MAX + (db_min - DB_MAX) * t, + db_max + (db_min - db_max) * t, db_min, - DB_MAX, + db_max, depth, theme, palette, @@ -60,9 +61,9 @@ pub(super) fn db_legend( let top = at((row * 2) as f32 / (steps - 1) as f32); let bot = at((row * 2 + 1) as f32 / (steps - 1) as f32); let label = match row { - 0 => format!("{:>+4} ", DB_MAX as i32), + 0 => format!("{:>+4} ", db_max as i32), r if r == h.saturating_sub(1) => format!("{:>4} ", db_min as i32), - r if r == h / 2 => format!("{:>4} ", ((DB_MAX + db_min) / 2.0) as i32), + r if r == h / 2 => format!("{:>4} ", ((db_max + db_min) / 2.0) as i32), _ => " ".to_string(), }; Line::from(vec![ @@ -89,7 +90,14 @@ pub(super) fn indicator( theme: &crate::Theme, ) { let text = match (state.waterfall.cursor_freq, cursor_col.zip(columns)) { - (Some(cf), Some((col, columns))) => cursor_readout(cf, col, rows, columns, skip_data), + (Some(cf), Some((col, columns))) => cursor_readout( + cf, + col, + rows, + columns, + skip_data, + state.caps.level_unit.label(), + ), // A cursor set outside the current zoom window: name it, but there is // nothing on screen to read a level from. (Some(cf), None) => format!(" cur: {:.3} MHz \u{2190} \u{2192} M", cf as f64 / 1e6), @@ -120,6 +128,7 @@ fn cursor_readout( rows: &VecDeque<(Instant, Arc>)>, columns: &Columns, skip_data: usize, + unit: &str, ) -> String { let mhz = freq_hz as f64 / 1e6; let Some((ts, row)) = rows.get(skip_data) else { @@ -129,10 +138,32 @@ fn cursor_readout( let db = band_max(row, lo, hi); if db.is_finite() { format!( - " cur: {mhz:.3} MHz {db:.1} dBFS {}s ago \u{2190} \u{2192} M", + " cur: {mhz:.3} MHz {db:.1} {unit} {}s ago \u{2190} \u{2192} M", ts.elapsed().as_secs() ) } else { format!(" cur: {mhz:.3} MHz \u{2190} \u{2192} M") } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cursor_readout_keeps_the_iq_unit_and_level() { + let rows = VecDeque::from([(Instant::now(), Arc::new(vec![-60.0]))]); + let window = crate::state::BinAxis::FftBins + .window(100_000_000, 1.0, 1, 1) + .unwrap(); + let text = cursor_readout( + 100_000_000, + 0, + &rows, + &Columns::new(window, 1, crate::state::BinAxis::FftBins), + 0, + crate::hardware::LevelUnit::Dbfs.label(), + ); + assert!(text.starts_with(" cur: 100.000 MHz -60.0 dBFS ")); + } +} diff --git a/src/ui/panels/core/waterfall/cells.rs b/src/ui/panels/core/waterfall/cells.rs index 02685a8..2118732 100644 --- a/src/ui/panels/core/waterfall/cells.rs +++ b/src/ui/panels/core/waterfall/cells.rs @@ -24,10 +24,6 @@ use ratatui::{ use crate::palette::{magnitude_to_color_palette, ColorDepth, WaterfallPalette}; use crate::state::{BinAxis, BinWindow}; -/// Top of the colour scale. The waterfall is always referenced to full scale; -/// only the floor (`db_min`) moves, under `↑`/`↓`. -pub(super) const DB_MAX: f32 = 0.0; - /// Max dB over the bin range `[start, end)` of one waterfall row, clamped to the /// row's own length. Rows are normally all the (fixed) FFT bin count, but reading /// each row against its own length means a row that ever differs - e.g. if the FFT @@ -89,12 +85,13 @@ pub(super) fn draw( cursor_col: Option, skip_data: usize, db_min: f32, + db_max: f32, palette: WaterfallPalette, theme: &crate::Theme, ) { let cols = area.width as usize; let depth = ColorDepth::detect(); - let color = |db: f32| magnitude_to_color_palette(db, db_min, DB_MAX, depth, theme, palette); + let color = |db: f32| magnitude_to_color_palette(db, db_min, db_max, depth, theme, palette); let floor = color(f32::NEG_INFINITY); let mut lines: Vec = Vec::with_capacity(area.height as usize); @@ -141,6 +138,43 @@ mod tests { .unwrap(); Columns::new(window, cols, axis) } + #[test] + fn cell_colors_use_the_configured_ceiling() { + let theme = crate::Theme::sdr(); + let palette = WaterfallPalette::default(); + let rows = VecDeque::from([(Instant::now(), Arc::new(vec![-10.0]))]); + for max in [0.0, -10.0] { + let mut terminal = + ratatui::Terminal::new(ratatui::backend::TestBackend::new(1, 1)).unwrap(); + terminal + .draw(|f| { + draw( + f, + f.size(), + &rows, + |row_bins| Some(columns(row_bins, 1, 1)), + None, + 0, + -110.0, + max, + palette, + &theme, + ) + }) + .unwrap(); + assert_eq!( + terminal.backend().buffer().get(0, 0).fg, + magnitude_to_color_palette( + -10.0, + -110.0, + max, + ColorDepth::detect(), + &theme, + palette + ) + ); + } + } #[test] fn band_max_reads_in_range() { diff --git a/src/ui/panels/core/waterfall/mod.rs b/src/ui/panels/core/waterfall/mod.rs index 7b60092..f4fa1ff 100644 --- a/src/ui/panels/core/waterfall/mod.rs +++ b/src/ui/panels/core/waterfall/mod.rs @@ -208,13 +208,6 @@ fn contents( return; } - // The bonded status cap and the nameplate answer the same question, so they - // ask it once. This used to be a second copy of the rule against a local - // `STALE_MS = 500` sitting beside `panel::FFT_STALE_MS = 500`: the two agreed - // only by coincidence, and disagreed already on "no frame yet" (the copy said - // fresh, the plate said stale). Unreachable in practice - `contents` has - // returned by then if there are no rows - but two rules for one word is how - // the deck starts contradicting itself. let stale = Staleness::FftAge.resolve(state); // Clamp the reported scroll to what the buffer can actually give, so the // bonded status cap never promises history that is not there. The content @@ -292,6 +285,7 @@ fn contents( cursor_col, skip_data, wf.db_min, + wf.db_max, wf.palette, theme, ); @@ -309,6 +303,7 @@ fn contents( ..content }, wf.db_min, + wf.db_max, wf.palette, theme, ); @@ -394,6 +389,7 @@ mod tests { None, 0, wf.db_min, + wf.db_max, wf.palette, &theme, ) @@ -405,6 +401,28 @@ mod tests { assert_ne!(buffer.get(0, 0).bg, buffer.get(1, 0).bg); } + #[test] + fn the_legend_uses_both_level_bounds() { + let mut state = SdrMetrics::fixture().streaming().with_carrier(0.0, 40.0); + for (min, max, labels) in [ + (-120.0, 0.0, ["+0", "-60", "-120"]), + (-110.0, -10.0, ["-10", "-60", "-110"]), + ] { + state.waterfall.db_min = min; + state.waterfall.db_max = max; + let rows = crate::state::fixture::draw(WaterfallPanel, 100, 20, &state); + let gutter: String = rows + .iter() + .skip(1) + .take(18) + .flat_map(|row| row.chars().skip(1).take(DB_COL as usize)) + .collect(); + for label in labels { + assert!(gutter.contains(label), "{label} missing from {gutter}"); + } + } + } + #[test] fn the_ladders_walk_and_stop_at_their_ends() { assert_eq!(next_wf_stride(1), 2); From d17872b37b639c6f4379b19b09af6740bb8f45b3 Mon Sep 17 00:00:00 2001 From: Dominic Griesel Date: Tue, 8 Sep 2026 11:07:43 +0200 Subject: [PATCH 6/7] fix(display): enforce capability bounds and trace pacing Address musithang/sdrtop#13 review with shared range-safe zoom, explicit display validation, capability-based initialization, and budget-aware FFT pacing. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/app/builder/boot.rs | 114 ++++++++++++++++++++++++++++++------ src/app/builder/mod.rs | 4 +- src/app/builder/registry.rs | 2 + src/app/input/core.rs | 74 ++++++++++++++++++++--- src/hardware/traits.rs | 25 ++++++++ src/signal/fft/publish.rs | 60 ++++++++++++++----- src/state/fixture.rs | 14 +++-- src/state/waterfall.rs | 12 +++- 8 files changed, 256 insertions(+), 49 deletions(-) diff --git a/src/app/builder/boot.rs b/src/app/builder/boot.rs index 98e3751..fde6db6 100644 --- a/src/app/builder/boot.rs +++ b/src/app/builder/boot.rs @@ -301,7 +301,7 @@ pub fn resolve_gains(radio: &RadioConfig, gm: &GainModel) -> (Vec, Vec SdrMetrics { +pub(super) fn initial_metrics(cfg: &AppConfig, boot: Boot) -> anyhow::Result { let Boot { caps, tuning, @@ -311,7 +311,9 @@ pub(super) fn initial_metrics(cfg: &AppConfig, boot: Boot) -> SdrMetrics { recall, } = boot; - SdrMetrics { + caps.validate_display()?; + + Ok(SdrMetrics { radio: RadioState { frequency: tuning.frequency_hz, config_sample_rate: tuning.sample_rate, @@ -372,15 +374,12 @@ pub(super) fn initial_metrics(cfg: &AppConfig, boot: Boot) -> SdrMetrics { // Clamped, not trusted: `save_config` writes the live buffer depth back, // so a config written before the floor existed carries a value too small // to fill a full-height waterfall. See [`WATERFALL_MIN_ROWS`]. - waterfall: { - let mut waterfall = WaterfallState::new( - cfg.display.waterfall_max_rows.max(WATERFALL_MIN_ROWS), - cfg.display.waterfall_palette, - ); - waterfall.db_min = caps.level_min_db; - waterfall.db_max = caps.level_max_db; - waterfall - }, + waterfall: WaterfallState::new( + cfg.display.waterfall_max_rows.max(WATERFALL_MIN_ROWS), + cfg.display.waterfall_palette, + caps.level_min_db, + caps.level_max_db, + ), system: SystemState { board_name: Arc::from(identity.board_name.as_str()), serial: Arc::from(identity.serial.as_str()), @@ -411,7 +410,7 @@ pub(super) fn initial_metrics(cfg: &AppConfig, boot: Boot) -> SdrMetrics { net: crate::state::NetState::default(), caps, acc: Accumulators::default(), - } + }) } #[cfg(test)] @@ -569,6 +568,7 @@ mod tests { ), ), ] { + let m = m.unwrap(); assert!(!m.radio.rx_enabled); assert!(!m.radio.hw_streaming); assert_eq!(m.radio.actual_sample_rate, 0); @@ -597,7 +597,8 @@ mod tests { tuning, &hardware::DeviceInfo::default(), ), - ); + ) + .unwrap(); assert!(!metrics.radio.rx_enabled); assert!(!metrics.radio.hw_streaming); assert_eq!(metrics.spectrum.y_min, -120.0); @@ -625,7 +626,8 @@ mod tests { tuning, &hardware::DeviceInfo::default(), ), - ); + ) + .unwrap(); assert!(!metrics.radio.rx_enabled); assert!(!metrics.radio.hw_streaming); assert_eq!(metrics.spectrum.y_min, -110.0); @@ -634,6 +636,78 @@ mod tests { assert_eq!(metrics.waterfall.db_max, -10.0); } + #[test] + fn startup_rejects_invalid_display_capabilities() { + let cfg = AppConfig::default(); + for (min, max, budget, error) in [ + (0.0, 0.0, 500, "Invalid display level bounds"), + (0.0, -10.0, 500, "Invalid display level bounds"), + (f32::NAN, 0.0, 500, "Invalid display level bounds"), + (-120.0, f32::NAN, 500, "Invalid display level bounds"), + (f32::NEG_INFINITY, 0.0, 500, "Invalid display level bounds"), + (-120.0, f32::INFINITY, 500, "Invalid display level bounds"), + (-f32::MAX, f32::MAX, 500, "Invalid display level bounds"), + (-120.0, 0.0, 0, "Invalid trace_stale_ms"), + ] { + let mut caps = hardware::native::hackrf::caps(); + caps.level_min_db = min; + caps.level_max_db = max; + caps.trace_stale_ms = budget; + let tuning = resolve_tuning(&cfg.radio, &caps); + let result = initial_metrics( + &cfg, + Boot::normal( + &cfg, + Arc::new(caps), + tuning, + &hardware::DeviceInfo::default(), + ), + ); + assert!(result + .err() + .expect("invalid caps must fail") + .to_string() + .contains(error)); + } + } + + #[test] + fn startup_accepts_valid_display_capabilities() { + let cfg = AppConfig::default(); + for (min, max, budget) in [(-10.0, 0.0, 1), (-120.0, 20.0, 5_000)] { + let mut caps = hardware::native::hackrf::caps(); + caps.level_min_db = min; + caps.level_max_db = max; + caps.trace_stale_ms = budget; + let tuning = resolve_tuning(&cfg.radio, &caps); + let m = initial_metrics( + &cfg, + Boot::normal( + &cfg, + Arc::new(caps), + tuning, + &hardware::DeviceInfo::default(), + ), + ) + .unwrap(); + assert_eq!((m.spectrum.y_min, m.spectrum.y_max), (min, max)); + assert_eq!((m.waterfall.db_min, m.waterfall.db_max), (min, max)); + assert_eq!(m.caps.trace_stale_ms, budget); + } + } + + #[test] + fn observer_startup_also_rejects_invalid_display_capabilities() { + let cfg = AppConfig::default(); + let mut boot = Boot::observer(&cfg, &sysinfo(), profile(hardware::DeviceKind::HackRf)); + Arc::make_mut(&mut boot.caps).trace_stale_ms = 0; + assert!(initial_metrics(&cfg, boot) + .err() + .expect("invalid observer caps must fail") + .to_string() + .contains("Invalid trace_stale_ms")); + } + /// A config that asks for less waterfall history than a full-height panel /// needs is raised, not honoured. /// @@ -647,7 +721,8 @@ mod tests { let m = initial_metrics( &cfg, Boot::observer(&cfg, &sysinfo(), profile(hardware::DeviceKind::HackRf)), - ); + ) + .unwrap(); assert_eq!( m.waterfall.buffer.max_rows, crate::state::WATERFALL_MIN_ROWS @@ -662,7 +737,8 @@ mod tests { let m = initial_metrics( &cfg, Boot::observer(&cfg, &sysinfo(), profile(hardware::DeviceKind::HackRf)), - ); + ) + .unwrap(); assert_eq!(m.waterfall.buffer.max_rows, 4_096); } @@ -685,14 +761,16 @@ mod tests { resolve_tuning(&cfg.radio, &hardware::native::hackrf::caps()), &hardware::DeviceInfo::default(), ), - ); + ) + .unwrap(); assert_eq!(live.spectrum.markers.len(), 1); assert_eq!(live.ui.recall[0], Some(100_000_000)); let obs = initial_metrics( &cfg, Boot::observer(&cfg, &sysinfo(), profile(hardware::DeviceKind::HackRf)), - ); + ) + .unwrap(); assert!(obs.spectrum.markers.is_empty()); assert!(obs.ui.recall.iter().all(|s| s.is_none())); assert!(obs.observer.active); diff --git a/src/app/builder/mod.rs b/src/app/builder/mod.rs index 69051de..516f8b4 100644 --- a/src/app/builder/mod.rs +++ b/src/app/builder/mod.rs @@ -89,7 +89,7 @@ impl App { let state = Arc::new(Mutex::new(initial_metrics( &cfg, Boot::normal(&cfg, Arc::clone(&caps), tuning, &info), - ))); + )?)); { let mut m = state.lock().unwrap_or_else(|e| e.into_inner()); @@ -204,7 +204,7 @@ impl App { let state = Arc::new(Mutex::new(initial_metrics( &cfg, Boot::observer(&cfg, &sysinfo, profile), - ))); + )?)); { let mut m = state.lock().unwrap_or_else(|e| e.into_inner()); diff --git a/src/app/builder/registry.rs b/src/app/builder/registry.rs index bfd8382..e5a3970 100644 --- a/src/app/builder/registry.rs +++ b/src/app/builder/registry.rs @@ -386,6 +386,8 @@ mod tests { m.waterfall = WaterfallState::new( WATERFALL_MIN_ROWS, crate::palette::WaterfallPalette::default(), + m.caps.level_min_db, + m.caps.level_max_db, ); for i in 0..WATERFALL_MIN_ROWS { let bins: Vec = (0..256) diff --git a/src/app/input/core.rs b/src/app/input/core.rs index 5bd9f8b..ae596a6 100644 --- a/src/app/input/core.rs +++ b/src/app/input/core.rs @@ -32,6 +32,18 @@ fn strongest_bin_frequency(frame: &crate::state::FftFrame) -> Option { .filter(|frequency| *frequency >= 0.0) .map(|frequency| frequency.round() as u64) } +const LEVEL_ZOOM_STEP_DB: f32 = 10.0; +const LEVEL_MIN_WINDOW_DB: f32 = 20.0; +fn adjust_level_floor( + floor: f32, + ceiling: f32, + delta: f32, + caps: &crate::hardware::DeviceCapabilities, +) -> f32 { + let window = LEVEL_MIN_WINDOW_DB.min(caps.level_max_db - caps.level_min_db); + let highest_floor = (ceiling.min(caps.level_max_db) - window).max(caps.level_min_db); + (floor + delta).clamp(caps.level_min_db, highest_floor) +} // ── Spectrum focus keys ─────────────────────────────────────────────────────── @@ -110,7 +122,12 @@ pub(super) fn spectrum(key: KeyEvent, ctx: &mut InputCtx<'_>) -> KeyAction { } KeyCode::Up => { let mut m = metrics(state); - let new_min = (m.spectrum.y_min + 10.0).min(m.spectrum.y_max - 20.0); + let new_min = adjust_level_floor( + m.spectrum.y_min, + m.spectrum.y_max, + LEVEL_ZOOM_STEP_DB, + &m.caps, + ); m.spectrum.y_min = new_min; let ymax = m.spectrum.y_max; let unit = m.caps.level_unit.label(); @@ -118,7 +135,12 @@ pub(super) fn spectrum(key: KeyEvent, ctx: &mut InputCtx<'_>) -> KeyAction { } KeyCode::Down => { let mut m = metrics(state); - let new_min = (m.spectrum.y_min - 10.0).max(m.caps.level_min_db); + let new_min = adjust_level_floor( + m.spectrum.y_min, + m.spectrum.y_max, + -LEVEL_ZOOM_STEP_DB, + &m.caps, + ); m.spectrum.y_min = new_min; let ymax = m.spectrum.y_max; let unit = m.caps.level_unit.label(); @@ -222,7 +244,12 @@ pub(super) fn waterfall(key: KeyEvent, ctx: &mut InputCtx<'_>) -> KeyAction { match key.code { KeyCode::Up => { let mut m = metrics(state); - let new_min = (m.waterfall.db_min + 10.0).min(m.waterfall.db_max - 20.0); + let new_min = adjust_level_floor( + m.waterfall.db_min, + m.waterfall.db_max, + LEVEL_ZOOM_STEP_DB, + &m.caps, + ); m.waterfall.db_min = new_min; let max = m.waterfall.db_max; let unit = m.caps.level_unit.label(); @@ -232,7 +259,12 @@ pub(super) fn waterfall(key: KeyEvent, ctx: &mut InputCtx<'_>) -> KeyAction { } KeyCode::Down => { let mut m = metrics(state); - let new_min = (m.waterfall.db_min - 10.0).max(m.caps.level_min_db); + let new_min = adjust_level_floor( + m.waterfall.db_min, + m.waterfall.db_max, + -LEVEL_ZOOM_STEP_DB, + &m.caps, + ); m.waterfall.db_min = new_min; let max = m.waterfall.db_max; let unit = m.caps.level_unit.label(); @@ -357,9 +389,34 @@ mod tests { ); } + #[test] + fn either_zoom_direction_keeps_the_floor_within_device_limits() { + let mut caps = crate::hardware::native::hackrf::caps(); + for (min, max) in [(-120.0, 0.0), (-10.0, 0.0), (-10.5, -10.0)] { + caps.level_min_db = min; + caps.level_max_db = max; + for delta in [-LEVEL_ZOOM_STEP_DB, LEVEL_ZOOM_STEP_DB] { + for floor in [min - 100.0, min, max, max + 100.0] { + for ceiling in [max - 5.0, max, max + 100.0] { + let adjusted = adjust_level_floor(floor, ceiling, delta, &caps); + assert!((min..max).contains(&adjusted)); + assert!(adjusted <= max - (max - min).min(20.0)); + } + } + } + } + } + #[test] fn level_zoom_uses_device_bounds_and_preserves_iq_log_text() { - for (min, max) in [(-120.0, 0.0), (-110.0, -10.0)] { + for (min, max) in [ + (-120.0_f32, 0.0), + (-120.0, 20.0), + (-110.0, -10.0), + (-10.0, 0.0), + (-10.5, -10.0), + ] { + let highest_floor = max - (max - min).min(20.0_f32); for is_waterfall in [false, true] { let mut m = SdrMetrics::fixture(); Arc::make_mut(&mut m.caps).level_min_db = min; @@ -398,7 +455,10 @@ mod tests { press(KeyCode::Up); assert_eq!( metrics(&state).ui.log.back().unwrap().text.as_ref(), - format!("{prefix}: {:.0}\u{2026}{max:.0} dBFS", min + 10.0) + format!( + "{prefix}: {:.0}\u{2026}{max:.0} dBFS", + (min + 10.0).min(highest_floor) + ) ); for _ in 0..20 { press(KeyCode::Up); @@ -410,7 +470,7 @@ mod tests { } else { m.spectrum.y_min }; - assert_eq!(floor, max - 20.0); + assert_eq!(floor, highest_floor); } for _ in 0..20 { press(KeyCode::Down); diff --git a/src/hardware/traits.rs b/src/hardware/traits.rs index 5cb9a59..3cf65b5 100644 --- a/src/hardware/traits.rs +++ b/src/hardware/traits.rs @@ -631,9 +631,13 @@ pub enum DeliveryModel { /// truth for every clamp, default, and UI capability check. Built once at open. #[derive(Clone, Debug)] pub struct DeviceCapabilities { + /// Spectral levels and display bounds use this unit pub level_unit: LevelUnit, + /// The finite display floor must be below `level_max_db` pub level_min_db: f32, + /// The finite display ceiling must form a finite span with `level_min_db` pub level_max_db: f32, + /// A trace becomes stale after this positive millisecond budget pub trace_stale_ms: u128, pub freq_min_hz: u64, pub freq_max_hz: u64, @@ -669,6 +673,27 @@ pub struct DeviceCapabilities { pub delivery: DeliveryModel, } +impl DeviceCapabilities { + /// Reject invalid display bounds or a zero trace-age budget before initializing views + pub fn validate_display(&self) -> anyhow::Result<()> { + anyhow::ensure!( + self.level_min_db.is_finite() + && self.level_max_db.is_finite() + && self.level_min_db < self.level_max_db + && (self.level_max_db - self.level_min_db).is_finite(), + "Invalid display level bounds: {}..{} {}; expected finite ordered bounds with a finite span", + self.level_min_db, + self.level_max_db, + self.level_unit.label(), + ); + anyhow::ensure!( + self.trace_stale_ms > 0, + "Invalid trace_stale_ms: expected a positive millisecond budget", + ); + Ok(()) + } +} + /// The software layer between sdrtop and a radio that has no firmware of its /// own, for the header's firmware field. /// diff --git a/src/signal/fft/publish.rs b/src/signal/fft/publish.rs index a0b17b5..11ee63b 100644 --- a/src/signal/fft/publish.rs +++ b/src/signal/fft/publish.rs @@ -13,7 +13,7 @@ //! comment, the way `tasks/rx/` makes its two lock blocks visible. use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant}; +use std::time::Instant; use crate::state::{FftFrame, SdrMetrics}; @@ -25,10 +25,6 @@ use super::analysis::Reading; /// lockstep. Signal metrics stay at full rate. const ROWS_PER_WATERFALL_LINE: u32 = 2; -/// Never let the drawn frame age past the panels' 500 ms STALE threshold, even at -/// large frames or row strides. -const SPECTRUM_STALE_GUARD: Duration = Duration::from_millis(400); - /// How much of a marker's channel the measured-bandwidth cut-offs bracket. const MARKER_BW_LOW: f32 = 0.005; const MARKER_BW_HIGH: f32 = 0.995; @@ -36,30 +32,32 @@ const MARKER_BW_HIGH: f32 = 0.995; /// The display-paced spectrum refresh's state, carried between frames. pub(super) struct Pacing { rows_since_spectrum: u32, - last_update: Instant, + last_update: Option, } impl Pacing { pub(super) fn new() -> Self { Self { rows_since_spectrum: 0, - last_update: Instant::now() - .checked_sub(SPECTRUM_STALE_GUARD) - .unwrap_or_else(Instant::now), + last_update: None, } } /// Whether to redraw the spectrum: there is none yet, a visible waterfall line /// has gone by, or it would otherwise age toward `[STALE]`. - fn due(&self, has_frame: bool) -> bool { + fn due(&self, has_frame: bool, trace_stale_ms: u128, now: Instant) -> bool { + // Reserve one fifth of the trace-age budget for display scheduling + let guard_ms = trace_stale_ms - trace_stale_ms.div_ceil(5); !has_frame || self.rows_since_spectrum >= ROWS_PER_WATERFALL_LINE - || self.last_update.elapsed() >= SPECTRUM_STALE_GUARD + || self + .last_update + .is_none_or(|last| now.duration_since(last).as_millis() >= guard_ms) } - fn mark(&mut self) { + fn mark(&mut self, now: Instant) { self.rows_since_spectrum = 0; - self.last_update = Instant::now(); + self.last_update = Some(now); } } @@ -118,8 +116,9 @@ pub(super) fn publish( pacing.rows_since_spectrum += 1; } - if pacing.due(m.waterfall.last_fft.is_some()) { - pacing.mark(); + let now = Instant::now(); + if pacing.due(m.waterfall.last_fft.is_some(), m.caps.trace_stale_ms, now) { + pacing.mark(now); refresh_spectrum(&mut m, &snap); } Some(alpha) @@ -256,6 +255,37 @@ fn refresh_spectrum(m: &mut SdrMetrics, snap: &Snapshot<'_>) { mod tests { use super::*; use crate::state::SpectrumMarker; + use std::time::Duration; + + #[test] + fn pacing_reserves_one_fifth_of_each_trace_age_budget() { + let now = Instant::now(); + let mut pacing = Pacing::new(); + for (budget, guard_ms) in [(500, 400), (100, 80), (5_000, 4_000), (2, 1)] { + pacing.mark(now); + assert!(!pacing.due(true, budget, now + Duration::from_millis(guard_ms - 1))); + assert!(pacing.due(true, budget, now + Duration::from_millis(guard_ms))); + } + assert!(pacing.due(true, 1, now)); + assert!(!pacing.due(true, u128::MAX, now + Duration::from_secs(1))); + } + + #[test] + fn pacing_still_refreshes_initial_frames_and_visible_waterfall_lines() { + let now = Instant::now(); + let mut pacing = Pacing::new(); + assert!(pacing.due(true, 500, now)); + pacing.mark(now); + assert!(pacing.due(false, 500, now)); + assert!(!pacing.due(true, 500, now)); + pacing.rows_since_spectrum = 1; + assert!(!pacing.due(true, 500, now)); + pacing.rows_since_spectrum = 2; + assert!(pacing.due(true, 500, now)); + pacing.mark(now); + assert_eq!(pacing.rows_since_spectrum, 0); + assert!(!pacing.due(true, 500, now)); + } fn marker(freq_hz: u64, channel_bw_hz: u64, measured_bw_hz: Option) -> SpectrumMarker { SpectrumMarker { diff --git a/src/state/fixture.rs b/src/state/fixture.rs index c6e62c4..636c4a3 100644 --- a/src/state/fixture.rs +++ b/src/state/fixture.rs @@ -35,6 +35,7 @@ impl SdrMetrics { /// also the state most likely to be got wrong - every "waiting for RX" and /// `[STALE]` path runs through it. pub(crate) fn fixture() -> Self { + let caps = Arc::new(crate::hardware::native::hackrf::caps()); SdrMetrics { radio: RadioState { frequency: 100_000_000, @@ -74,15 +75,20 @@ impl SdrMetrics { observer: ObserverState::default(), spectrum: SpectrumState { step_hz: 100_000, - y_min: -120.0, - y_max: 0.0, + y_min: caps.level_min_db, + y_max: caps.level_max_db, hold: None, cursor_freq: None, markers: vec![], pending_marker: None, style: SpectrumStyle::default(), }, - waterfall: WaterfallState::new(512, crate::palette::WaterfallPalette::default()), + waterfall: WaterfallState::new( + 512, + crate::palette::WaterfallPalette::default(), + caps.level_min_db, + caps.level_max_db, + ), system: SystemState { // The HackRF fixture reports its own firmware, so no stack row. stack: None, @@ -101,7 +107,7 @@ impl SdrMetrics { lab: LabState::default(), demod: DemodState::default(), net: crate::state::NetState::default(), - caps: Arc::new(crate::hardware::native::hackrf::caps()), + caps, acc: Accumulators::default(), } } diff --git a/src/state/waterfall.rs b/src/state/waterfall.rs index eb32805..49c55a5 100644 --- a/src/state/waterfall.rs +++ b/src/state/waterfall.rs @@ -251,10 +251,16 @@ pub struct WaterfallState { } impl WaterfallState { - pub fn new(max_rows: usize, palette: crate::palette::WaterfallPalette) -> Self { + /// Initialize the view with finite ordered bounds from validated device capabilities + pub fn new( + max_rows: usize, + palette: crate::palette::WaterfallPalette, + db_min: f32, + db_max: f32, + ) -> Self { Self { - db_min: -120.0, - db_max: 0.0, + db_min, + db_max, scroll_offset: 0, cursor_freq: None, hz_zoom: 1, From 41893000c01e4f6d4c47006cb48be7689813b3aa Mon Sep 17 00:00:00 2001 From: Dominic Griesel Date: Mon, 7 Sep 2026 22:42:03 +0200 Subject: [PATCH 7/7] feat: add direct power trace foundation Add backend-neutral calibrated trace acquisition, measured-point publication, power RX control, and capability-aware layout fallback. Preserve IQ behavior through the shared prerequisite contracts. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/app/builder/boot.rs | 27 ++ src/app/builder/mod.rs | 96 ++++-- src/app/builder/registry.rs | 301 +++++++++++++++++- src/app/input/core.rs | 38 ++- src/app/mod.rs | 2 +- src/hardware/mod.rs | 8 +- src/hardware/native/hackrf/mod.rs | 1 + src/hardware/native/rtlsdr/mod.rs | 1 + src/hardware/process.rs | 2 + src/hardware/soapy/caps.rs | 1 + src/hardware/traits.rs | 25 ++ src/signal/mod.rs | 2 + src/signal/power.rs | 422 +++++++++++++++++++++++++ src/state/fixture.rs | 2 +- src/state/waterfall.rs | 77 +++-- src/tasks/mod.rs | 2 +- src/tasks/rx/mod.rs | 250 +++++++++++++++ src/tasks/rx/poll.rs | 2 + src/ui/engine.rs | 27 ++ src/ui/panel.rs | 52 ++- src/ui/panels/core/footer.rs | 3 + src/ui/panels/core/header.rs | 6 + src/ui/panels/core/log.rs | 3 + src/ui/panels/core/spectrum/mod.rs | 25 ++ src/ui/panels/core/spectrum/view.rs | 59 +++- src/ui/panels/core/system_resources.rs | 3 + src/ui/panels/core/waterfall/cells.rs | 21 ++ src/ui/panels/core/waterfall/mod.rs | 3 + 28 files changed, 1373 insertions(+), 88 deletions(-) create mode 100644 src/signal/power.rs diff --git a/src/app/builder/boot.rs b/src/app/builder/boot.rs index fde6db6..31fd715 100644 --- a/src/app/builder/boot.rs +++ b/src/app/builder/boot.rs @@ -742,6 +742,33 @@ mod tests { assert_eq!(m.waterfall.buffer.max_rows, 4_096); } + #[test] + fn a_power_trace_backend_uses_its_level_axis_and_starts_paused() { + let cfg = AppConfig::default(); + let mut caps = hardware::native::hackrf::caps(); + caps.acquisition = hardware::AcquisitionKind::PowerTrace; + caps.level_unit = hardware::LevelUnit::Dbm; + caps.level_min_db = -110.0; + caps.level_max_db = -10.0; + let tuning = resolve_tuning(&cfg.radio, &caps); + let metrics = initial_metrics( + &cfg, + Boot::normal( + &cfg, + Arc::new(caps), + tuning, + &hardware::DeviceInfo::default(), + ), + ) + .unwrap(); + assert!(!metrics.radio.rx_enabled); + assert!(!metrics.radio.hw_streaming); + assert_eq!(metrics.spectrum.y_min, -110.0); + assert_eq!(metrics.spectrum.y_max, -10.0); + assert_eq!(metrics.waterfall.db_min, -110.0); + assert_eq!(metrics.waterfall.db_max, -10.0); + } + #[test] fn observer_mode_starts_without_markers_or_recall() { let mut cfg = AppConfig::default(); diff --git a/src/app/builder/mod.rs b/src/app/builder/mod.rs index 516f8b4..c190145 100644 --- a/src/app/builder/mod.rs +++ b/src/app/builder/mod.rs @@ -25,7 +25,7 @@ use std::time::Duration; use crate::config::AppConfig; use crate::event::EventStream; use crate::hardware; -use crate::signal::{DemodWorker, FftWorker, NetWorker}; +use crate::signal::{DemodWorker, FftWorker, NetWorker, PowerWorker}; use crate::state::SdrMetrics; use crate::tasks; @@ -157,6 +157,7 @@ impl App { // thread, and anything deeper starts hiding the losses rather than // absorbing them. let (net_tx, net_rx) = crossbeam_channel::bounded::(4); + let (power_tx, power_rx) = crossbeam_channel::bounded::(4); let rx_ctx = Arc::new(hardware::RxContext { metrics: Arc::clone(&state), sample_tx, @@ -164,35 +165,51 @@ impl App { demod_tx, net_tx, net_feed: hardware::FeedHealth::default(), + power_tx, geometry, }); - let fft_state = Arc::clone(&state); - std::thread::spawn(move || FftWorker::new(sample_rx, fft_state, geometry).run()); + let app = Self::assemble( + cfg, + config_path, + Arc::clone(&state), + Some(Arc::clone(&device)), + Some(Arc::clone(&rx_ctx)), + None, + )?; - let demod_state = Arc::clone(&state); - std::thread::spawn(move || DemodWorker::new(demod_rx, demod_state, geometry).run()); + match caps.acquisition { + hardware::AcquisitionKind::IqSamples => { + let fft_state = Arc::clone(&state); + std::thread::spawn(move || FftWorker::new(sample_rx, fft_state, geometry).run()); - // Spawned whether or not the gate admitted the section: with no section - // on screen nothing is forwarded, so the thread costs one blocked - // `recv`. Deciding here would mean two places that know what admits the - // feature, and the one that already knows is `net::gate`. - let net_state = Arc::clone(&state); - std::thread::spawn(move || NetWorker::new(net_rx, net_state, geometry).run()); + let demod_state = Arc::clone(&state); + std::thread::spawn(move || DemodWorker::new(demod_rx, demod_state, geometry).run()); - tasks::spawn_rx_task(Arc::clone(&state), Arc::clone(&device), Arc::clone(&rx_ctx)); - tasks::spawn_sweep_task(Arc::clone(&state), Arc::clone(&device)); - tasks::spawn_net_survey_task(Arc::clone(&state), Arc::clone(&device)); + // Spawned whether or not the gate admitted the section: with no section + // on screen nothing is forwarded, so the thread costs one blocked + // `recv`. Deciding here would mean two places that know what admits the + // feature, and the one that already knows is `net::gate`. + let net_state = Arc::clone(&state); + std::thread::spawn(move || NetWorker::new(net_rx, net_state, geometry).run()); + + tasks::spawn_rx_task(Arc::clone(&state), Arc::clone(&device), Arc::clone(&rx_ctx)); + tasks::spawn_sweep_task(Arc::clone(&state), Arc::clone(&device)); + tasks::spawn_net_survey_task(Arc::clone(&state), Arc::clone(&device)); + } + hardware::AcquisitionKind::PowerTrace => { + let power_state = Arc::clone(&state); + std::thread::spawn(move || PowerWorker::new(power_rx, power_state).run()); + tasks::spawn_power_rx_task( + Arc::clone(&state), + Arc::clone(&device), + Arc::clone(&rx_ctx), + ); + } + } tasks::spawn_sys_resource_task(Arc::clone(&state)); - Ok(Self::assemble( - cfg, - config_path, - state, - Some(device), - Some(rx_ctx), - None, - )) + Ok(app) } pub(super) fn new_observer( @@ -216,17 +233,17 @@ impl App { m.push_log("Device is in use by another process — hardware controls disabled"); } - tasks::spawn_observer_task(Arc::clone(&state), sysinfo.bus, sysinfo.dev, profile); - tasks::spawn_sys_resource_task(Arc::clone(&state)); - - Ok(Self::assemble( + let app = Self::assemble( cfg, config_path, - state, + Arc::clone(&state), None, None, Some("observer"), - )) + )?; + tasks::spawn_observer_task(Arc::clone(&state), sysinfo.bus, sysinfo.dev, profile); + tasks::spawn_sys_resource_task(Arc::clone(&state)); + Ok(app) } /// The tail both startups end in: resolve the config paths, build the theme @@ -243,7 +260,7 @@ impl App { device: Option>, rx_ctx: Option>, preset_override: Option<&str>, - ) -> Self { + ) -> anyhow::Result { let themes_dir = config_path .as_deref() .and_then(crate::config::AppConfig::themes_dir); @@ -261,8 +278,18 @@ impl App { }; let active = preset_override.unwrap_or(&cfg.display.active_preset); - let (engine, focus_keys) = - Self::build_ui(active, &cfg.presets, presets_dir.as_deref(), net.is_ok()); + let acquisition = state + .lock() + .unwrap_or_else(|error| error.into_inner()) + .caps + .acquisition; + let (engine, focus_keys) = Self::build_ui_for( + active, + &cfg.presets, + presets_dir.as_deref(), + net.is_ok(), + acquisition, + )?; // A user preset that wanted a number key already taken says so, once, // here. `menu::model::build` collects these instead of logging them so it @@ -273,6 +300,9 @@ impl App { for warning in engine.menu_warnings() { m.push_log(warning.clone()); } + for warning in engine.startup_warnings() { + m.push_log(warning.clone()); + } if let Err(why) = &net { m.push_log(why.clone()); } @@ -294,7 +324,7 @@ impl App { }); } - Self { + Ok(Self { state, device, rx_ctx, @@ -307,6 +337,6 @@ impl App { focus_keys, theme_config: cfg.theme.clone(), user_presets: cfg.presets, - } + }) } } diff --git a/src/app/builder/registry.rs b/src/app/builder/registry.rs index e5a3970..2043369 100644 --- a/src/app/builder/registry.rs +++ b/src/app/builder/registry.rs @@ -17,17 +17,35 @@ use crate::ui; use crate::app::App; impl App { + #[cfg(test)] + pub(super) fn build_ui( + active_preset: &str, + user_presets: &HashMap, + presets_dir: Option<&std::path::Path>, + net_admitted: bool, + ) -> (ui::LayoutEngine, HashMap) { + Self::build_ui_for( + active_preset, + user_presets, + presets_dir, + net_admitted, + crate::hardware::AcquisitionKind::IqSamples, + ) + .expect("built-in IQ layouts must include a usable preset") + } + /// `net_admitted` comes from `signal::net::gate`: on a radio that cannot /// reach the 2.4 GHz band, or cannot run even the cheapest mode there, the /// presets in that section are dropped here and the section is **absent** /// from the menu rather than present and empty. Rule 2, and the same /// decision the RF bench makes about its noise-figure card. - pub(super) fn build_ui( + pub(super) fn build_ui_for( active_preset: &str, user_presets: &HashMap, presets_dir: Option<&std::path::Path>, net_admitted: bool, - ) -> (ui::LayoutEngine, HashMap) { + acquisition: crate::hardware::AcquisitionKind, + ) -> anyhow::Result<(ui::LayoutEngine, HashMap)> { let mut registry = ui::PanelRegistry::new(); registry.register(ui::HeaderPanel); registry.register(ui::SlimHeaderPanel); @@ -81,10 +99,84 @@ impl App { .presets .retain(|_, p| p.section.as_deref() != Some(ui::menu::model::NET)); } + let warnings = Self::filter_incompatible_layouts(&mut layout, ®istry, acquisition)?; + let selected = if layout.presets.contains_key(active_preset) { + active_preset.to_string() + } else { + ["spectrum_waterfall", "spectrum", "waterfall"] + .into_iter() + .find(|name| { + layout.presets.get(*name).is_some_and(|preset| { + preset + .panels + .iter() + .any(|spec| registry.get(&spec.name).is_some()) + }) + }) + .map(str::to_string) + .or_else(|| { + let mut names: Vec = layout + .presets + .iter() + .filter(|(_, preset)| { + preset + .panels + .iter() + .any(|spec| registry.get(&spec.name).is_some()) + }) + .map(|(name, _)| name.clone()) + .collect(); + names.sort(); + names.into_iter().next() + }) + .ok_or_else(|| anyhow::anyhow!("No usable presets remain for this device"))? + }; + layout.active_preset = selected; - let mut engine = ui::LayoutEngine::new(layout, registry); - engine.set_preset(active_preset); - (engine, focus_keys) + let mut engine = + ui::LayoutEngine::new_with_saved_preset(layout, registry, active_preset.to_string()); + engine.set_startup_warnings(warnings); + Ok((engine, focus_keys)) + } + + fn filter_incompatible_layouts( + config: &mut LayoutConfig, + registry: &ui::PanelRegistry, + acquisition: crate::hardware::AcquisitionKind, + ) -> anyhow::Result> { + let mut warnings = Vec::new(); + config.presets.retain(|name, preset| { + if preset.panels.is_empty() { + warnings.push(format!("Preset '{name}' is unavailable because it has no panels")); + return false; + } + for spec in &preset.panels { + let Some(panel) = registry.get(&spec.name) else { + warnings.push(format!( + "Preset '{name}' references unknown panel '{}'", + spec.name + )); + continue; + }; + if !panel.supports_acquisition(acquisition) { + warnings.push(format!( + "Preset '{name}' is unavailable because panel '{}' does not support this device", + spec.name + )); + return false; + } + } + true + }); + if !config.presets.values().any(|preset| { + preset + .panels + .iter() + .any(|spec| registry.get(&spec.name).is_some()) + }) { + anyhow::bail!("No usable presets remain for this device"); + } + Ok(warnings) } } @@ -365,6 +457,205 @@ mod tests { assert!(engine.is_panel_visible("spectrum")); } + #[test] + fn a_power_trace_device_keeps_only_compatible_trace_layouts() { + let mut user = HashMap::new(); + user.insert( + "my_trace".to_string(), + crate::config::PresetConfig { + panels: vec![ + crate::config::PanelSpec { + name: "header_slim".into(), + position: crate::config::Position::Top, + height: None, + width_pct: None, + }, + crate::config::PanelSpec { + name: "spectrum".into(), + position: crate::config::Position::Body, + height: None, + width_pct: None, + }, + crate::config::PanelSpec { + name: "footer".into(), + position: crate::config::Position::Bottom, + height: None, + width_pct: None, + }, + ], + ..Default::default() + }, + ); + user.insert( + "my_iq".to_string(), + crate::config::PresetConfig { + panels: vec![ + crate::config::PanelSpec { + name: "spectrum".into(), + position: crate::config::Position::Body, + height: None, + width_pct: None, + }, + crate::config::PanelSpec { + name: "iq_constellation".into(), + position: crate::config::Position::Right, + height: None, + width_pct: None, + }, + ], + ..Default::default() + }, + ); + user.insert( + "my_status".to_string(), + crate::config::PresetConfig { + panels: vec![ + crate::config::PanelSpec { + name: "system_resources".into(), + position: crate::config::Position::Body, + height: None, + width_pct: None, + }, + crate::config::PanelSpec { + name: "log".into(), + position: crate::config::Position::Bottom, + height: None, + width_pct: None, + }, + ], + ..Default::default() + }, + ); + + let (engine, _) = App::build_ui_for( + "my_trace", + &user, + None, + false, + crate::hardware::AcquisitionKind::PowerTrace, + ) + .unwrap(); + for available in [ + "spectrum", + "waterfall", + "spectrum_waterfall", + "my_trace", + "my_status", + ] { + assert!(engine.has_preset(available), "{available} was hidden"); + } + for unavailable in [ + "command_rail", + "lab_iq", + "lab_rf", + "lab_timing", + "lab_signal", + "lab_sweep", + "micro_sweep", + "my_iq", + ] { + assert!(!engine.has_preset(unavailable), "{unavailable} survived"); + } + assert_eq!(engine.active_preset(), "my_trace"); + } + + #[test] + fn automatic_fallback_does_not_replace_the_saved_preference() { + let (engine, _) = App::build_ui_for( + "command_rail", + &HashMap::new(), + None, + false, + crate::hardware::AcquisitionKind::PowerTrace, + ) + .unwrap(); + assert_eq!(engine.active_preset(), "spectrum_waterfall"); + assert_eq!(engine.saved_active_preset(), "command_rail"); + } + + #[test] + fn explicitly_selecting_the_fallback_updates_the_saved_preference() { + let (mut engine, _) = App::build_ui_for( + "command_rail", + &HashMap::new(), + None, + false, + crate::hardware::AcquisitionKind::PowerTrace, + ) + .unwrap(); + engine.set_preset("spectrum_waterfall"); + assert_eq!(engine.saved_active_preset(), "spectrum_waterfall"); + } + + #[test] + fn an_unknown_panel_warns_without_removing_the_preset() { + let mut user = HashMap::new(); + user.insert( + "future_panel".to_string(), + crate::config::PresetConfig { + panels: vec![crate::config::PanelSpec { + name: "not_registered_yet".into(), + position: crate::config::Position::Body, + height: None, + width_pct: None, + }], + ..Default::default() + }, + ); + + let (engine, _) = App::build_ui("future_panel", &user, None, true); + assert!(engine.has_preset("future_panel")); + assert_eq!(engine.active_preset(), "future_panel"); + assert!(engine + .startup_warnings() + .iter() + .any(|warning| warning.contains("unknown panel 'not_registered_yet'"))); + } + + #[test] + fn incompatible_overrides_cannot_leave_a_power_device_without_a_layout() { + let mut user = HashMap::new(); + for name in ["spectrum", "waterfall", "spectrum_waterfall"] { + user.insert( + name.to_string(), + crate::config::PresetConfig { + panels: vec![crate::config::PanelSpec { + name: "iq_constellation".into(), + position: crate::config::Position::Body, + height: None, + width_pct: None, + }], + ..Default::default() + }, + ); + } + user.insert( + "future_panel".to_string(), + crate::config::PresetConfig { + panels: vec![crate::config::PanelSpec { + name: "not_registered_yet".into(), + position: crate::config::Position::Body, + height: None, + width_pct: None, + }], + ..Default::default() + }, + ); + + let error = App::build_ui_for( + "spectrum_waterfall", + &user, + None, + false, + crate::hardware::AcquisitionKind::PowerTrace, + ) + .err() + .expect("all compatible layouts were overridden"); + assert!(error + .to_string() + .contains("No usable presets remain for this device")); + } + /// A full-height waterfall must reach its own bottom border. /// /// The `waterfall` preset gives the panel the whole body, and each character diff --git a/src/app/input/core.rs b/src/app/input/core.rs index ae596a6..5a716c8 100644 --- a/src/app/input/core.rs +++ b/src/app/input/core.rs @@ -347,34 +347,58 @@ pub(super) fn waterfall(key: KeyEvent, ctx: &mut InputCtx<'_>) -> KeyAction { #[cfg(test)] mod tests { - use super::*; use std::collections::HashMap; - use std::sync::{Arc, Mutex}; + use std::sync::Arc; + use std::sync::Mutex; + use std::time::Instant; - use crate::state::SdrMetrics; + use super::*; + use crate::state::{BinAxis, FftFrame, SdrMetrics}; use crate::ui::{LayoutEngine, PanelRegistry}; #[test] fn peak_jump_uses_the_captured_fft_frequency_after_retuning() { - let mut state = crate::state::SdrMetrics::fixture(); + let mut state = SdrMetrics::fixture(); state.radio.frequency = 100_000_000; state.radio.config_sample_rate = 32_000_000.0; let mut state = state.with_carrier(8_000_000.0, 70.0); state.radio.frequency = 200_000_000; let frame = state.waterfall.last_fft.as_ref().unwrap(); assert_eq!(strongest_bin_frequency(frame), Some(108_000_000)); - assert_eq!(frame.bin_axis, crate::state::BinAxis::FftBins); + assert_eq!(frame.bin_axis, BinAxis::FftBins); assert_eq!(frame.window(4).unwrap().span_hz, 8_000_000.0); } #[test] fn peak_jump_rejects_an_empty_frame() { - let state = crate::state::SdrMetrics::fixture().with_carrier(0.0, 70.0); + let state = SdrMetrics::fixture().with_carrier(0.0, 70.0); let mut frame = state.waterfall.last_fft.unwrap(); - frame.bins_dbfs = std::sync::Arc::new(Vec::new()); + frame.bins_dbfs = Arc::new(Vec::new()); assert_eq!(strongest_bin_frequency(&frame), None); } + #[test] + fn peak_jump_reaches_the_last_measured_point() { + let mut bins = vec![-90.0; 64]; + bins[63] = -20.0; + let bins = Arc::new(bins); + let frame = FftFrame { + bins_dbfs: Arc::clone(&bins), + peak_hold: bins, + noise_floor: -90.0, + center_freq_hz: 131_500_000, + sample_rate: 63_000_000.0, + timestamp: Instant::now(), + peak_to_nf_db: 70.0, + channel_power_dbfs: -20.0, + occupied_bw_hz: 0, + enbw_hz: 0.0, + bin_axis: BinAxis::MeasuredPoints, + }; + + assert_eq!(strongest_bin_frequency(&frame), Some(163_000_000)); + } + #[test] fn peak_jump_falls_back_for_a_negative_frequency() { let mut state = crate::state::SdrMetrics::fixture(); diff --git a/src/app/mod.rs b/src/app/mod.rs index 24619b9..b5eff2d 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -311,7 +311,7 @@ impl App { recall_hz: recall, }, display: DisplayConfig { - active_preset: self.engine.active_preset().to_string(), + active_preset: self.engine.saved_active_preset().to_string(), waterfall_max_rows: wf_rows, waterfall_palette: wf_palette, spectrum_style: spec_style, diff --git a/src/hardware/mod.rs b/src/hardware/mod.rs index 5969df8..7e80b1e 100644 --- a/src/hardware/mod.rs +++ b/src/hardware/mod.rs @@ -29,8 +29,10 @@ pub mod sysfs; mod traits; pub use discovery::{list_all_devices, open_device, DeviceKind, DeviceListing}; +#[cfg(test)] +pub(crate) use traits::RateSet; pub use traits::{ - Boost, DeliveryModel, DeviceCapabilities, DeviceInfo, FeedHealth, GainModel, LevelUnit, - RxContext, SampleFormat, SampleGeometry, SdrDevice, SoftwareStack, StageSpec, StreamBlock, - IQ_TRACE_STALE_MS, + AcquisitionKind, Boost, DeliveryModel, DeviceCapabilities, DeviceInfo, FeedHealth, GainModel, + LevelUnit, PowerTrace, RxContext, SampleFormat, SampleGeometry, SdrDevice, SoftwareStack, + StageSpec, StreamBlock, IQ_TRACE_STALE_MS, }; diff --git a/src/hardware/native/hackrf/mod.rs b/src/hardware/native/hackrf/mod.rs index 5c4ee9f..2004e2d 100644 --- a/src/hardware/native/hackrf/mod.rs +++ b/src/hardware/native/hackrf/mod.rs @@ -336,6 +336,7 @@ pub fn caps() -> DeviceCapabilities { level_min_db: -120.0, level_max_db: 0.0, trace_stale_ms: crate::hardware::IQ_TRACE_STALE_MS, + acquisition: crate::hardware::AcquisitionKind::IqSamples, freq_min_hz: 1_000_000, freq_max_hz: 6_000_000_000, sample_rate_min_hz: 2_000_000.0, diff --git a/src/hardware/native/rtlsdr/mod.rs b/src/hardware/native/rtlsdr/mod.rs index b53a758..5f6ba35 100644 --- a/src/hardware/native/rtlsdr/mod.rs +++ b/src/hardware/native/rtlsdr/mod.rs @@ -368,6 +368,7 @@ fn rtl_caps(tuner: c_int, gains_tenths: &[i32]) -> DeviceCapabilities { level_min_db: -120.0, level_max_db: 0.0, trace_stale_ms: crate::hardware::IQ_TRACE_STALE_MS, + acquisition: crate::hardware::AcquisitionKind::IqSamples, freq_min_hz, freq_max_hz, // RTL-SDR's usable upper band is 900_001..=3_200_000 Hz (the lower diff --git a/src/hardware/process.rs b/src/hardware/process.rs index 9c98d58..2d01512 100644 --- a/src/hardware/process.rs +++ b/src/hardware/process.rs @@ -492,6 +492,7 @@ mod tests { let (sample_tx, sample_rx) = crossbeam_channel::bounded(fft_cap); let (demod_tx, demod_rx) = crossbeam_channel::bounded(8); let (net_tx, net_rx) = crossbeam_channel::bounded(net_cap); + let (power_tx, _) = crossbeam_channel::bounded(1); let mut m = SdrMetrics::fixture(); m.demod.enabled = true; m.ui.section = crate::signal::net::SECTION.to_string(); @@ -502,6 +503,7 @@ mod tests { demod_tx, net_tx, net_feed: crate::hardware::FeedHealth::default(), + power_tx, geometry: eight_bit(), }; (Arc::new(ctx), sample_rx, demod_rx, net_rx) diff --git a/src/hardware/soapy/caps.rs b/src/hardware/soapy/caps.rs index d6d8440..b90e116 100644 --- a/src/hardware/soapy/caps.rs +++ b/src/hardware/soapy/caps.rs @@ -167,6 +167,7 @@ pub fn capabilities(a: &DriverAnswers) -> Result { level_min_db: -120.0, level_max_db: 0.0, trace_stale_ms: crate::hardware::IQ_TRACE_STALE_MS, + acquisition: crate::hardware::AcquisitionKind::IqSamples, freq_min_hz: freq_min.max(0.0) as u64, freq_max_hz: freq_max.max(0.0) as u64, sample_rate_min_hz: rate_min, diff --git a/src/hardware/traits.rs b/src/hardware/traits.rs index 3cf65b5..5612c1d 100644 --- a/src/hardware/traits.rs +++ b/src/hardware/traits.rs @@ -12,9 +12,21 @@ use std::sync::{Arc, Mutex}; use crate::state::SdrMetrics; +/// How a backend acquires spectrum data. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AcquisitionKind { + /// Complex time-domain samples feed the FFT and diagnostic workers. + IqSamples, + /// The backend publishes calibrated power-spectrum traces. + PowerTrace, +} + +/// Unit carried by spectral levels from a backend. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum LevelUnit { Dbfs, + #[allow(dead_code)] + Dbm, } pub const IQ_TRACE_STALE_MS: u128 = 500; @@ -23,10 +35,19 @@ impl LevelUnit { pub fn label(self) -> &'static str { match self { Self::Dbfs => "dBFS", + Self::Dbm => "dBm", } } } +/// One calibrated power-spectrum trace. +#[derive(Debug)] +pub struct PowerTrace { + pub frequencies_hz: Vec, + pub levels_dbm: Vec, + pub rbw_hz: Option, +} + /// How raw USB bytes encode each I/Q component. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum SampleFormat { @@ -631,6 +652,7 @@ pub enum DeliveryModel { /// truth for every clamp, default, and UI capability check. Built once at open. #[derive(Clone, Debug)] pub struct DeviceCapabilities { + pub acquisition: AcquisitionKind, /// Spectral levels and display bounds use this unit pub level_unit: LevelUnit, /// The finite display floor must be below `level_max_db` @@ -759,6 +781,9 @@ pub struct RxContext { pub net_tx: crossbeam_channel::Sender, /// What the NET feed did with the blocks handed to it, for the poll task. pub net_feed: FeedHealth, + /// Direct power-spectrum traces from backends that do not publish IQ. + #[allow(dead_code)] + pub power_tx: crossbeam_channel::Sender, pub geometry: SampleGeometry, } diff --git a/src/signal/mod.rs b/src/signal/mod.rs index 2d46034..99e859c 100644 --- a/src/signal/mod.rs +++ b/src/signal/mod.rs @@ -10,6 +10,7 @@ pub mod fft; pub mod iq; pub mod net; pub mod noise_slope; +pub mod power; pub mod rds; pub mod rds_demod; mod stats; @@ -19,3 +20,4 @@ pub use demod::DemodWorker; pub use fft::FftWorker; pub use iq::{corrected_moments, image_rejection_db, iq_correction_coeffs}; pub use net::worker::NetWorker; +pub use power::PowerWorker; diff --git a/src/signal/power.rs b/src/signal/power.rs new file mode 100644 index 0000000..a3be84f --- /dev/null +++ b/src/signal/power.rs @@ -0,0 +1,422 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 MusiThang + +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use crossbeam_channel::Receiver; + +use crate::hardware::PowerTrace; +use crate::state::{FftFrame, SdrMetrics}; + +const EMA_ALPHA: f32 = 0.2; +const PEAK_DECAY_DB: f32 = 0.5; +const REJECTION_REPORT_INTERVAL: Duration = Duration::from_secs(5); + +pub struct PowerWorker { + trace_rx: Receiver, + state: Arc>, +} + +impl PowerWorker { + pub fn new(trace_rx: Receiver, state: Arc>) -> Self { + Self { trace_rx, state } + } + + pub fn run(self) { + let mut accumulator = SpectrumAccumulator::default(); + let mut rejection_reporter = RejectionReporter::default(); + while let Ok(trace) = self.trace_rx.recv() { + if let Err(reason) = accumulator.publish(&self.state, trace) { + rejection_reporter.report(&self.state, reason, Instant::now()); + } + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum TraceRejection { + Empty, + LengthMismatch, + NonUniformGrid, +} + +impl TraceRejection { + fn message(self) -> &'static str { + match self { + Self::Empty => "empty trace", + Self::LengthMismatch => "frequency and level counts differ", + Self::NonUniformGrid => "frequency grid is not uniform and ascending", + } + } +} + +#[derive(Default)] +struct RejectionReporter { + last_report: Option, + suppressed: usize, +} + +impl RejectionReporter { + fn report(&mut self, state: &Arc>, reason: TraceRejection, now: Instant) { + let should_report = self + .last_report + .is_none_or(|last| now.duration_since(last) >= REJECTION_REPORT_INTERVAL); + if !should_report { + self.suppressed += 1; + return; + } + + let suffix = match self.suppressed { + 0 => String::new(), + 1 => "; 1 additional trace rejected".to_string(), + count => format!("; {count} additional traces rejected"), + }; + let mut metrics = state.lock().unwrap_or_else(|error| error.into_inner()); + metrics.push_log(format!( + "Power trace rejected: {}{suffix}", + reason.message() + )); + self.last_report = Some(now); + self.suppressed = 0; + } +} + +#[derive(Default)] +struct SpectrumAccumulator { + start_hz: u64, + stop_hz: u64, + rbw_hz: Option, + smoothed: Vec, + peak: Vec, + noise_scratch: Vec, +} + +impl SpectrumAccumulator { + fn publish( + &mut self, + state: &Arc>, + trace: PowerTrace, + ) -> Result<(), TraceRejection> { + if trace.frequencies_hz.is_empty() { + return Err(TraceRejection::Empty); + } + if trace.frequencies_hz.len() != trace.levels_dbm.len() { + return Err(TraceRejection::LengthMismatch); + } + let Some((center_freq_hz, sample_rate)) = trace_window(&trace.frequencies_hz) else { + return Err(TraceRejection::NonUniformGrid); + }; + let start_hz = trace.frequencies_hz[0]; + let stop_hz = *trace.frequencies_hz.last().unwrap_or(&start_hz); + let reset = self.start_hz != start_hz + || self.stop_hz != stop_hz + || self.rbw_hz != trace.rbw_hz + || self.smoothed.len() != trace.levels_dbm.len(); + if reset { + self.start_hz = start_hz; + self.stop_hz = stop_hz; + self.rbw_hz = trace.rbw_hz; + self.smoothed + .resize(trace.levels_dbm.len(), f32::NEG_INFINITY); + self.peak.resize(trace.levels_dbm.len(), f32::NEG_INFINITY); + } + crate::signal::stats::average_and_peak( + &trace.levels_dbm, + &mut self.smoothed, + &mut self.peak, + EMA_ALPHA, + PEAK_DECAY_DB, + !reset, + ); + + self.noise_scratch.resize(self.smoothed.len(), 0.0); + let noise_floor = + crate::signal::stats::quietest_mean(&self.smoothed, &mut self.noise_scratch, 10); + let strongest = self + .smoothed + .iter() + .copied() + .filter(|value| value.is_finite()) + .fold(f32::NEG_INFINITY, f32::max); + let prominence = if strongest.is_finite() && noise_floor.is_finite() { + strongest - noise_floor + } else { + 0.0 + }; + + let bins = Arc::new(self.smoothed.clone()); + let peak = Arc::new(self.peak.clone()); + let mut metrics = state.lock().unwrap_or_else(|error| error.into_inner()); + metrics.waterfall.buffer.push(&bins); + metrics.signal.peak_to_nf_db = prominence; + metrics.waterfall.last_fft = Some(FftFrame { + bins_dbfs: bins, + peak_hold: peak, + noise_floor, + center_freq_hz, + sample_rate, + timestamp: Instant::now(), + peak_to_nf_db: prominence, + channel_power_dbfs: f32::NEG_INFINITY, + occupied_bw_hz: 0, + enbw_hz: trace.rbw_hz.unwrap_or(0) as f64, + bin_axis: crate::state::BinAxis::MeasuredPoints, + }); + Ok(()) + } +} + +pub(crate) fn trace_window(frequencies_hz: &[u64]) -> Option<(u64, f64)> { + let start = *frequencies_hz.first()?; + let stop = *frequencies_hz.last()?; + let intervals = frequencies_hz.len().checked_sub(1)?; + let span = stop.checked_sub(start)?; + if span == 0 { + return None; + } + let low_step = span / intervals as u64; + let high_step = span.div_ceil(intervals as u64); + if !frequencies_hz.windows(2).all(|pair| { + pair[1] + .checked_sub(pair[0]) + .is_some_and(|step| step > 0 && (low_step..=high_step).contains(&step)) + }) { + return None; + } + Some((start.saturating_add(span / 2), span as f64)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn power_worker_publishes_spectrum_and_waterfall_frames() { + let state = Arc::new(Mutex::new(SdrMetrics::fixture())); + let (tx, rx) = crossbeam_channel::bounded(1); + let worker_state = Arc::clone(&state); + let worker = std::thread::spawn(move || PowerWorker::new(rx, worker_state).run()); + + tx.send(PowerTrace { + frequencies_hz: vec![100_000_000, 101_000_000, 102_000_000], + levels_dbm: vec![-90.0, -45.0, -80.0], + rbw_hz: Some(10_000), + }) + .unwrap(); + drop(tx); + worker.join().unwrap(); + + let metrics = state.lock().unwrap_or_else(|error| error.into_inner()); + let frame = metrics.waterfall.last_fft.as_ref().unwrap(); + assert_eq!(frame.bins_dbfs.as_slice(), &[-90.0, -45.0, -80.0]); + assert_eq!(frame.peak_hold.as_slice(), &[-90.0, -45.0, -80.0]); + assert_eq!(frame.center_freq_hz, 101_000_000); + assert_eq!(frame.sample_rate, 2_000_000.0); + assert_eq!(frame.enbw_hz, 10_000.0); + assert_eq!(metrics.waterfall.buffer.rows.len(), 1); + assert_eq!(metrics.signal.peak_to_nf_db, 45.0); + assert_eq!(metrics.radio.frequency, 100_000_000); + assert_eq!(metrics.signal.channel_power_dbfs, f32::NEG_INFINITY); + assert_eq!(frame.channel_power_dbfs, f32::NEG_INFINITY); + assert!(metrics + .ui + .log + .iter() + .all(|entry| !entry.text.contains("Power trace rejected"))); + } + + #[test] + fn noise_floor_uses_the_quiet_part_of_the_trace() { + let mut levels = vec![-100.0; 80]; + levels.extend([-30.0; 20]); + let mut scratch = vec![0.0; levels.len()]; + assert_eq!( + crate::signal::stats::quietest_mean(&levels, &mut scratch, 10), + -100.0 + ); + } + + #[test] + fn smoothing_and_peak_hold_stay_ordered() { + let state = Arc::new(Mutex::new(SdrMetrics::fixture())); + let mut accumulator = SpectrumAccumulator::default(); + for levels in [[-90.0, -80.0], [-91.0, -70.0], [f32::NAN, -72.0]] { + accumulator + .publish( + &state, + PowerTrace { + frequencies_hz: vec![100_000_000, 101_000_000], + levels_dbm: levels.to_vec(), + rbw_hz: None, + }, + ) + .unwrap(); + } + + let metrics = state.lock().unwrap_or_else(|error| error.into_inner()); + let frame = metrics.waterfall.last_fft.as_ref().unwrap(); + assert!((frame.bins_dbfs[0] - -90.2).abs() < 1e-4); + assert!((frame.bins_dbfs[1] - -76.8).abs() < 1e-4); + assert!((frame.peak_hold[0] - -90.2).abs() < 1e-4); + assert!((frame.peak_hold[1] - -76.8).abs() < 1e-4); + for (peak, smoothed) in frame.peak_hold.iter().zip(frame.bins_dbfs.iter()) { + assert!(peak >= smoothed); + } + } + + #[test] + fn a_finite_level_recovers_a_non_finite_bin() { + let state = Arc::new(Mutex::new(SdrMetrics::fixture())); + let mut accumulator = SpectrumAccumulator::default(); + accumulator + .publish( + &state, + PowerTrace { + frequencies_hz: vec![100_000_000, 101_000_000], + levels_dbm: vec![f32::NAN, -90.0], + rbw_hz: None, + }, + ) + .unwrap(); + accumulator + .publish( + &state, + PowerTrace { + frequencies_hz: vec![100_000_000, 101_000_000], + levels_dbm: vec![-70.0, -80.0], + rbw_hz: None, + }, + ) + .unwrap(); + + let metrics = state.lock().unwrap_or_else(|error| error.into_inner()); + let frame = metrics.waterfall.last_fft.as_ref().unwrap(); + assert_eq!(frame.bins_dbfs[0], -70.0); + assert!(frame.bins_dbfs[1].is_finite()); + assert!(frame.peak_hold[0] >= frame.bins_dbfs[0]); + } + + #[test] + fn non_finite_initial_levels_publish_as_unavailable() { + let state = Arc::new(Mutex::new(SdrMetrics::fixture())); + let mut accumulator = SpectrumAccumulator::default(); + accumulator + .publish( + &state, + PowerTrace { + frequencies_hz: vec![100_000_000, 101_000_000], + levels_dbm: vec![f32::NAN, f32::INFINITY], + rbw_hz: None, + }, + ) + .unwrap(); + + let metrics = state.lock().unwrap_or_else(|error| error.into_inner()); + let frame = metrics.waterfall.last_fft.as_ref().unwrap(); + assert_eq!( + frame.bins_dbfs.as_slice(), + &[f32::NEG_INFINITY, f32::NEG_INFINITY] + ); + assert_eq!(frame.peak_hold.as_slice(), frame.bins_dbfs.as_slice()); + assert_eq!(frame.noise_floor, f32::NEG_INFINITY); + } + + #[test] + fn an_rbw_change_resets_smoothing() { + let state = Arc::new(Mutex::new(SdrMetrics::fixture())); + let mut accumulator = SpectrumAccumulator::default(); + for (rbw_hz, levels) in [(Some(10_000), -90.0), (Some(20_000), -40.0)] { + accumulator + .publish( + &state, + PowerTrace { + frequencies_hz: vec![100_000_000, 101_000_000], + levels_dbm: vec![levels; 2], + rbw_hz, + }, + ) + .unwrap(); + } + let metrics = state.lock().unwrap_or_else(|error| error.into_inner()); + let frame = metrics.waterfall.last_fft.as_ref().unwrap(); + assert_eq!(frame.bins_dbfs.as_slice(), &[-40.0, -40.0]); + assert_eq!(frame.peak_hold.as_slice(), &[-40.0, -40.0]); + assert_eq!(frame.enbw_hz, 20_000.0); + } + + #[test] + fn rejected_traces_are_reported_at_a_bounded_rate() { + let state = Arc::new(Mutex::new(SdrMetrics::fixture())); + let mut reporter = RejectionReporter::default(); + let now = Instant::now(); + reporter.report(&state, TraceRejection::Empty, now); + reporter.report( + &state, + TraceRejection::LengthMismatch, + now + Duration::from_secs(1), + ); + reporter.report( + &state, + TraceRejection::NonUniformGrid, + now + REJECTION_REPORT_INTERVAL, + ); + + let metrics = state.lock().unwrap_or_else(|error| error.into_inner()); + assert_eq!(metrics.ui.log.len(), 2); + assert!(metrics.ui.log[0].text.contains("empty trace")); + assert!(metrics.ui.log[1] + .text + .contains("1 additional trace rejected")); + } + + #[test] + fn invalid_traces_return_specific_rejections() { + let state = Arc::new(Mutex::new(SdrMetrics::fixture())); + let mut accumulator = SpectrumAccumulator::default(); + let cases = [ + ( + PowerTrace { + frequencies_hz: vec![], + levels_dbm: vec![], + rbw_hz: None, + }, + TraceRejection::Empty, + ), + ( + PowerTrace { + frequencies_hz: vec![100, 200], + levels_dbm: vec![-90.0], + rbw_hz: None, + }, + TraceRejection::LengthMismatch, + ), + ( + PowerTrace { + frequencies_hz: vec![100, 200, 350], + levels_dbm: vec![-90.0; 3], + rbw_hz: None, + }, + TraceRejection::NonUniformGrid, + ), + ]; + for (trace, expected) in cases { + assert_eq!(accumulator.publish(&state, trace), Err(expected)); + } + } + + #[test] + fn trace_window_uses_the_measured_edges() { + assert_eq!( + trace_window(&[100_000, 200_000, 300_000]), + Some((200_000, 200_000.0)) + ); + assert_eq!(trace_window(&[100_000]), None); + assert_eq!(trace_window(&[100_000, 200_000, 350_000]), None); + assert_eq!(trace_window(&[300_000, 200_000, 100_000]), None); + assert_eq!( + trace_window(&[100_000, 133_333, 166_667, 200_000]), + Some((150_000, 100_000.0)) + ); + } +} diff --git a/src/state/fixture.rs b/src/state/fixture.rs index 636c4a3..759a500 100644 --- a/src/state/fixture.rs +++ b/src/state/fixture.rs @@ -166,7 +166,7 @@ impl SdrMetrics { self } - /// Age the newest FFT frame past the IQ trace limit + /// Age the newest FFT frame past the IQ trace limit. pub(crate) fn with_stale_fft(mut self) -> Self { if let Some(fr) = self.waterfall.last_fft.as_mut() { fr.timestamp = Instant::now() diff --git a/src/state/waterfall.rs b/src/state/waterfall.rs index 49c55a5..c2e4928 100644 --- a/src/state/waterfall.rs +++ b/src/state/waterfall.rs @@ -11,6 +11,7 @@ pub enum BinAxis { /// Each FFT bin owns one interval starting at its frequency #[default] FftBins, + MeasuredPoints, } /// A nonempty centre slice retains the full frame's bin spacing @@ -27,6 +28,7 @@ impl BinAxis { pub fn interval_count(self, bin_count: usize) -> Option { match self { Self::FftBins => (bin_count > 0).then_some(bin_count), + Self::MeasuredPoints => bin_count.checked_sub(1).filter(|count| *count > 0), } } @@ -43,13 +45,21 @@ impl BinAxis { return None; } - let visible = (bin_count / zoom.max(1)).max(1).min(bin_count); + let minimum = if self == Self::MeasuredPoints && bin_count > 1 { + 2 + } else { + 1 + }; + let visible = (bin_count / zoom.max(1)).max(minimum).min(bin_count); let first = (bin_count / 2) .saturating_sub(visible / 2) .min(bin_count - visible); let intervals = self.interval_count(bin_count)?; let bin_hz = span_hz / intervals as f64; - let visible_intervals = self.interval_count(visible)?; + let visible_intervals = match self { + Self::FftBins => visible, + Self::MeasuredPoints => visible.saturating_sub(1), + }; Some(BinWindow { first_bin: first, @@ -101,6 +111,7 @@ impl BinAxis { let position = (frequency_hz - left_hz) * intervals as f64 / span_hz; let index = match self { Self::FftBins => position.floor() as usize, + Self::MeasuredPoints => position.round() as usize, }; Some(index.min(bin_count.saturating_sub(1))) } @@ -302,6 +313,32 @@ mod min_rows_tests { mod tests { use super::*; + #[test] + fn measured_points_keep_endpoints_across_zoom() { + let full = BinAxis::MeasuredPoints + .window(131_500_000, 63_000_000.0, 64, 1) + .unwrap(); + assert_eq!(full.left_hz, 100_000_000.0); + assert_eq!(full.span_hz, 63_000_000.0); + assert_eq!( + BinAxis::MeasuredPoints.frequency_of_bin( + full.left_hz, + full.span_hz, + full.bin_count, + 63, + ), + Some(163_000_000.0) + ); + + let zoomed = BinAxis::MeasuredPoints + .window(131_500_000, 63_000_000.0, 64, 4) + .unwrap(); + assert_eq!(zoomed.first_bin, 24); + assert_eq!(zoomed.bin_count, 16); + assert_eq!(zoomed.left_hz, 124_000_000.0); + assert_eq!(zoomed.span_hz, 15_000_000.0); + } + #[test] fn fft_bins_keep_n_intervals() { let axis = BinAxis::FftBins; @@ -354,24 +391,26 @@ mod tests { #[test] fn bin_axis_rejects_invalid_bounds() { - let axis = BinAxis::FftBins; - assert_eq!(axis.interval_count(0), None); - assert!(axis.window(100, 10.0, 0, 1).is_none()); - assert!(axis.frequency_of_bin(0.0, 10.0, 0, 0).is_none()); - assert!(axis.frequency_of_bin(0.0, 10.0, 4, 4).is_none()); - assert!(axis.nearest_bin(0.0, 10.0, 0, 0.0).is_none()); - for span in [0.0, -1.0, f64::NAN, f64::INFINITY] { - assert!(axis.window(100, span, 32, 1).is_none()); - assert!(axis.frequency_of_bin(0.0, span, 4, 0).is_none()); - assert!(axis.nearest_bin(0.0, span, 4, 0.0).is_none()); - } - for left in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { - assert!(axis.frequency_of_bin(left, 10.0, 4, 0).is_none()); - assert!(axis.nearest_bin(left, 10.0, 4, 0.0).is_none()); - } - for frequency in [-1.0, 11.0, f64::NAN, f64::INFINITY] { - assert!(axis.nearest_bin(0.0, 10.0, 4, frequency).is_none()); + for axis in [BinAxis::FftBins, BinAxis::MeasuredPoints] { + assert_eq!(axis.interval_count(0), None); + assert!(axis.window(100, 10.0, 0, 1).is_none()); + assert!(axis.frequency_of_bin(0.0, 10.0, 0, 0).is_none()); + assert!(axis.frequency_of_bin(0.0, 10.0, 4, 4).is_none()); + assert!(axis.nearest_bin(0.0, 10.0, 0, 0.0).is_none()); + for span in [0.0, -1.0, f64::NAN, f64::INFINITY] { + assert!(axis.window(100, span, 32, 1).is_none()); + assert!(axis.frequency_of_bin(0.0, span, 4, 0).is_none()); + assert!(axis.nearest_bin(0.0, span, 4, 0.0).is_none()); + } + for left in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + assert!(axis.frequency_of_bin(left, 10.0, 4, 0).is_none()); + assert!(axis.nearest_bin(left, 10.0, 4, 0.0).is_none()); + } + for frequency in [-1.0, 11.0, f64::NAN, f64::INFINITY] { + assert!(axis.nearest_bin(0.0, 10.0, 4, frequency).is_none()); + } } + assert!(BinAxis::MeasuredPoints.window(100, 10.0, 1, 1).is_none()); } #[test] diff --git a/src/tasks/mod.rs b/src/tasks/mod.rs index a8d20b6..f873040 100644 --- a/src/tasks/mod.rs +++ b/src/tasks/mod.rs @@ -9,7 +9,7 @@ mod system; pub use net::spawn_net_survey_task; pub use observer::spawn_observer_task; -pub use rx::spawn_rx_task; +pub use rx::{spawn_power_rx_task, spawn_rx_task}; pub use sweep::spawn_sweep_task; pub use system::spawn_sys_resource_task; diff --git a/src/tasks/rx/mod.rs b/src/tasks/rx/mod.rs index ab927bb..5b927a0 100644 --- a/src/tasks/rx/mod.rs +++ b/src/tasks/rx/mod.rs @@ -143,6 +143,93 @@ pub fn spawn_rx_task( }); } +/// Control a backend that publishes complete power-spectrum traces. +pub fn spawn_power_rx_task( + state: Arc>, + device: Arc, + rx_ctx: Arc, +) { + tokio::spawn(async move { + let mut active = false; + loop { + active = power_control_step(&state, &device, &rx_ctx, active); + tokio::time::sleep(POLL_INTERVAL).await; + } + }); +} + +fn power_control_step( + state: &Arc>, + device: &Arc, + rx_ctx: &Arc, + active: bool, +) -> bool { + let requested = state + .lock() + .unwrap_or_else(|error| error.into_inner()) + .radio + .rx_enabled; + let transition = control::request_transition( + requested, + active, + || device.start_rx(Arc::clone(rx_ctx)), + || device.stop_rx(), + ); + let unchanged_active = matches!(transition, control::RxRequestTransition::Unchanged(true)); + let active = match transition { + control::RxRequestTransition::Started => { + let mut metrics = state.lock().unwrap_or_else(|error| error.into_inner()); + metrics.radio.rx_start_time = Some(Instant::now()); + metrics.radio.hw_streaming = true; + metrics.push_log("Power trace acquisition started"); + true + } + control::RxRequestTransition::StartFailed(error) => { + let mut metrics = state.lock().unwrap_or_else(|e| e.into_inner()); + metrics.radio.rx_enabled = false; + metrics.radio.hw_streaming = false; + metrics.push_log(format!("Error starting power trace acquisition: {error}")); + false + } + control::RxRequestTransition::Stopped(result) => { + let mut metrics = state.lock().unwrap_or_else(|error| error.into_inner()); + metrics.radio.rx_start_time = None; + metrics.radio.hw_streaming = false; + match result { + Ok(()) => metrics.push_log("Power trace acquisition stopped"), + Err(error) => { + metrics.push_log(format!("Error stopping power trace acquisition: {error}")) + } + } + false + } + control::RxRequestTransition::Unchanged(active) => active, + }; + + if unchanged_active { + let streaming = device.is_streaming(); + if let Some(cleanup) = control::unexpected_stop(active, streaming, || device.stop_rx()) { + let mut metrics = state.lock().unwrap_or_else(|error| error.into_inner()); + metrics.radio.rx_enabled = false; + metrics.radio.hw_streaming = false; + metrics.radio.rx_start_time = None; + metrics.push_log("WARNING: Power trace acquisition stopped unexpectedly"); + if let Err(error) = cleanup { + metrics.push_log(format!( + "Error cleaning up power trace acquisition: {error}" + )); + } + return false; + } + state + .lock() + .unwrap_or_else(|error| error.into_inner()) + .radio + .hw_streaming = streaming; + } + active +} + /// Turn the read loop's cumulative clock into this window's occupancy. /// /// The counters are cumulative and never reset, so the first poll of a session @@ -158,3 +245,166 @@ fn window_occupancy(current: Option<(u64, u64)>, last: &mut Option<(u64, u64)>) work.saturating_sub(prev_work), ) } + +#[cfg(test)] +mod power_control_tests { + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + + use super::*; + use crate::hardware::{DeviceCapabilities, DeviceInfo, FeedHealth, RateSet, SampleGeometry}; + + struct TestDevice { + caps: DeviceCapabilities, + streaming: AtomicBool, + fail_start: AtomicBool, + fail_stop: AtomicBool, + starts: AtomicUsize, + stops: AtomicUsize, + } + + impl TestDevice { + fn new() -> Self { + let mut caps = crate::hardware::native::hackrf::caps(); + caps.acquisition = crate::hardware::AcquisitionKind::PowerTrace; + Self { + caps, + streaming: AtomicBool::new(false), + fail_start: AtomicBool::new(false), + fail_stop: AtomicBool::new(false), + starts: AtomicUsize::new(0), + stops: AtomicUsize::new(0), + } + } + } + + impl SdrDevice for TestDevice { + fn capabilities(&self) -> &DeviceCapabilities { + &self.caps + } + + fn info(&self) -> DeviceInfo { + DeviceInfo::default() + } + + fn start_rx(&self, _ctx: Arc) -> anyhow::Result<()> { + self.starts.fetch_add(1, Ordering::Relaxed); + if self.fail_start.load(Ordering::Relaxed) { + anyhow::bail!("start failed") + } + self.streaming.store(true, Ordering::Relaxed); + Ok(()) + } + + fn stop_rx(&self) -> anyhow::Result<()> { + self.stops.fetch_add(1, Ordering::Relaxed); + self.streaming.store(false, Ordering::Relaxed); + if self.fail_stop.load(Ordering::Relaxed) { + anyhow::bail!("stop failed") + } + Ok(()) + } + + fn is_streaming(&self) -> bool { + self.streaming.load(Ordering::Relaxed) + } + + fn set_frequency(&self, _hz: u64) -> anyhow::Result<()> { + Ok(()) + } + + fn set_sample_rate(&self, hz: f64) -> anyhow::Result { + Ok(RateSet::new(hz, Some(hz), 0)) + } + + fn set_lna_gain(&self, _db: u32) -> anyhow::Result<()> { + Ok(()) + } + } + + fn context(state: &Arc>) -> Arc { + let (sample_tx, _) = crossbeam_channel::bounded(1); + let (demod_tx, _) = crossbeam_channel::bounded(1); + let (net_tx, _) = crossbeam_channel::bounded(1); + let (power_tx, _) = crossbeam_channel::bounded(1); + Arc::new(RxContext { + metrics: Arc::clone(state), + sample_tx, + fft_feed: FeedHealth::default(), + demod_tx, + net_tx, + net_feed: FeedHealth::default(), + power_tx, + geometry: SampleGeometry::default(), + }) + } + + #[test] + fn a_power_start_failure_clears_the_request() { + let state = Arc::new(Mutex::new(SdrMetrics::fixture())); + state.lock().unwrap().radio.rx_enabled = true; + let device = Arc::new(TestDevice::new()); + device.fail_start.store(true, Ordering::Relaxed); + let dyn_device: Arc = device; + + assert!(!power_control_step( + &state, + &dyn_device, + &context(&state), + false + )); + let metrics = state.lock().unwrap(); + assert!(!metrics.radio.rx_enabled); + assert!(!metrics.radio.hw_streaming); + assert!(metrics.ui.log.back().unwrap().text.contains("start failed")); + } + + #[test] + fn a_requested_power_stop_closes_the_active_session() { + let state = Arc::new(Mutex::new(SdrMetrics::fixture())); + state.lock().unwrap().radio.rx_enabled = true; + let device = Arc::new(TestDevice::new()); + let dyn_device: Arc = device.clone(); + let ctx = context(&state); + let active = power_control_step(&state, &dyn_device, &ctx, false); + assert!(active); + + state.lock().unwrap().radio.rx_enabled = false; + assert!(!power_control_step(&state, &dyn_device, &ctx, active)); + assert_eq!(device.stops.load(Ordering::Relaxed), 1); + assert!(!state.lock().unwrap().radio.hw_streaming); + } + + #[test] + fn an_unexpected_power_stop_reports_cleanup_failure() { + let state = Arc::new(Mutex::new(SdrMetrics::fixture())); + { + let mut metrics = state.lock().unwrap(); + metrics.radio.rx_enabled = true; + metrics.radio.hw_streaming = true; + } + let device = Arc::new(TestDevice::new()); + device.fail_stop.store(true, Ordering::Relaxed); + let dyn_device: Arc = device.clone(); + + assert!(!power_control_step( + &state, + &dyn_device, + &context(&state), + true + )); + let metrics = state.lock().unwrap(); + assert!(!metrics.radio.rx_enabled); + assert!(!metrics.radio.hw_streaming); + assert_eq!(device.stops.load(Ordering::Relaxed), 1); + assert!(metrics + .ui + .log + .iter() + .any(|entry| entry.text.contains("stopped unexpectedly"))); + assert!(metrics + .ui + .log + .iter() + .any(|entry| entry.text.contains("cleaning up") && entry.text.contains("stop failed"))); + } +} diff --git a/src/tasks/rx/poll.rs b/src/tasks/rx/poll.rs index ed2922c..d4e6137 100644 --- a/src/tasks/rx/poll.rs +++ b/src/tasks/rx/poll.rs @@ -204,6 +204,7 @@ mod tests { let (sample_tx, sample_rx) = crossbeam_channel::bounded(4); let (demod_tx, demod_rx) = crossbeam_channel::bounded(2); let (net_tx, net_rx) = crossbeam_channel::bounded(4); + let (power_tx, _) = crossbeam_channel::bounded(1); let ctx = RxContext { metrics: Arc::clone(&state), sample_tx, @@ -211,6 +212,7 @@ mod tests { demod_tx, net_tx, net_feed: FeedHealth::default(), + power_tx, geometry: SampleGeometry { format: SampleFormat::Int8, full_scale: 128.0, diff --git a/src/ui/engine.rs b/src/ui/engine.rs index 0a13577..9d55ea9 100644 --- a/src/ui/engine.rs +++ b/src/ui/engine.rs @@ -18,6 +18,7 @@ use crate::ui::registry::PanelRegistry; pub struct LayoutEngine { pub config: LayoutConfig, + saved_active_preset: String, registry: PanelRegistry, focused_panel: Option, hidden_panels: HashSet, @@ -33,20 +34,37 @@ pub struct LayoutEngine { /// nothing does that outside the tests, and a preset added that way would be /// absent from the menu rather than break it. menu: menu::model::Menu, + startup_warnings: Vec, } impl LayoutEngine { + #[cfg_attr(not(test), allow(dead_code))] pub fn new(config: LayoutConfig, registry: PanelRegistry) -> Self { + let saved_active_preset = config.active_preset.clone(); + Self::new_with_saved_preset(config, registry, saved_active_preset) + } + + pub fn new_with_saved_preset( + config: LayoutConfig, + registry: PanelRegistry, + saved_active_preset: String, + ) -> Self { let menu = menu::model::build(&config.presets); Self { config, + saved_active_preset, registry, focused_panel: None, hidden_panels: HashSet::new(), menu, + startup_warnings: Vec::new(), } } + pub fn set_startup_warnings(&mut self, warnings: Vec) { + self.startup_warnings = warnings; + } + /// Anything odd found while building the menu, for the caller to log once at /// startup. Collected rather than logged in `model` so that module needs no /// mutex and stays testable as a pure function. @@ -54,6 +72,10 @@ impl LayoutEngine { &self.menu.warnings } + pub fn startup_warnings(&self) -> &[String] { + &self.startup_warnings + } + /// The section table the menu draws. pub fn menu(&self) -> &menu::model::Menu { &self.menu @@ -114,6 +136,10 @@ impl LayoutEngine { &self.config.active_preset } + pub fn saved_active_preset(&self) -> &str { + &self.saved_active_preset + } + /// Names of every panel the registry knows. /// /// Nothing draws this. It exists so `builder.rs` can check the built-in @@ -141,6 +167,7 @@ impl LayoutEngine { pub fn set_preset(&mut self, name: &str) { if self.config.presets.contains_key(name) { self.config.active_preset = name.to_string(); + self.saved_active_preset = name.to_string(); } } diff --git a/src/ui/panel.rs b/src/ui/panel.rs index 2e697e9..4c91b95 100644 --- a/src/ui/panel.rs +++ b/src/ui/panel.rs @@ -26,8 +26,8 @@ pub enum Staleness { /// Stale whenever the radio is not streaming. For anything read from /// hardware counters: timing, drops, gain staging, IQ balance. NotStreaming, - /// Mark spectrum readings stale when the frame exceeds the device's trace-age limit - /// Missing frames are stale + /// Stale when the newest FFT frame exceeds the device's trace-age limit, or + /// there is no frame yet. For anything derived from the spectrum. FftAge, /// Never stale. For panels that show configuration rather than measurement. Never, @@ -36,15 +36,16 @@ pub enum Staleness { impl Staleness { /// Resolve the rule against a metrics snapshot. pub fn resolve(self, state: &SdrMetrics) -> bool { - self.decide( - state.radio.hw_streaming, - state - .waterfall - .last_fft - .as_ref() - .map(|fr| fr.timestamp.elapsed().as_millis()), - state.caps.trace_stale_ms, - ) + let age = state + .waterfall + .last_fft + .as_ref() + .map(|frame| frame.timestamp.elapsed().as_millis()); + let stale = self.decide(state.radio.hw_streaming, age, state.caps.trace_stale_ms); + stale + || (self == Staleness::FftAge + && state.caps.acquisition == crate::hardware::AcquisitionKind::PowerTrace + && !state.radio.hw_streaming) } /// The rule itself, on plain inputs: `fft_age_ms` is `None` when no frame has @@ -276,6 +277,10 @@ pub trait Panel: Send + Sync { #[allow(dead_code)] fn min_size(&self) -> (u16, u16); + fn supports_acquisition(&self, acquisition: crate::hardware::AcquisitionKind) -> bool { + acquisition == crate::hardware::AcquisitionKind::IqSamples + } + /// Draw the panel's contents into `area`. /// /// `area` is the **inner** rect the engine has already carved out, guaranteed @@ -372,8 +377,8 @@ mod tests { #[test] fn staleness_rules_are_independent_of_each_other() { - let stale_ms = crate::hardware::IQ_TRACE_STALE_MS; // A dead radio staleness NotStreaming, and nothing else. + let stale_ms = crate::hardware::IQ_TRACE_STALE_MS; assert!(Staleness::NotStreaming.decide(false, Some(0), stale_ms)); assert!( !Staleness::FftAge.decide(false, Some(0), stale_ms), @@ -397,6 +402,29 @@ mod tests { ); } + #[test] + fn power_trace_staleness_uses_the_device_limit_and_rx_state() { + let mut state = SdrMetrics::fixture().streaming().with_carrier(0.0, 20.0); + let mut caps = (*state.caps).clone(); + caps.acquisition = crate::hardware::AcquisitionKind::PowerTrace; + caps.trace_stale_ms = 100; + state.caps = std::sync::Arc::new(caps); + state.waterfall.last_fft.as_mut().unwrap().timestamp = + std::time::Instant::now() - std::time::Duration::from_millis(50); + + assert!(!Staleness::FftAge.resolve(&state)); + state.caps = { + let mut caps = (*state.caps).clone(); + caps.trace_stale_ms = 10; + std::sync::Arc::new(caps) + }; + assert!(Staleness::FftAge.resolve(&state)); + + state.waterfall.last_fft.as_mut().unwrap().timestamp = std::time::Instant::now(); + state.radio.hw_streaming = false; + assert!(Staleness::FftAge.resolve(&state)); + } + #[test] fn trace_staleness_uses_the_device_limit() { let mut state = SdrMetrics::fixture().streaming().with_carrier(0.0, 20.0); diff --git a/src/ui/panels/core/footer.rs b/src/ui/panels/core/footer.rs index a0a57d8..42b9921 100644 --- a/src/ui/panels/core/footer.rs +++ b/src/ui/panels/core/footer.rs @@ -332,6 +332,9 @@ impl Panel for FooterPanel { fn name(&self) -> &'static str { "footer" } + fn supports_acquisition(&self, _acquisition: crate::hardware::AcquisitionKind) -> bool { + true + } fn min_size(&self) -> (u16, u16) { (40, 3) } diff --git a/src/ui/panels/core/header.rs b/src/ui/panels/core/header.rs index bcac22c..e6a3fb0 100644 --- a/src/ui/panels/core/header.rs +++ b/src/ui/panels/core/header.rs @@ -676,6 +676,9 @@ impl Panel for HeaderPanel { fn name(&self) -> &'static str { "header" } + fn supports_acquisition(&self, _acquisition: crate::hardware::AcquisitionKind) -> bool { + true + } fn min_size(&self) -> (u16, u16) { (60, 5) } @@ -748,6 +751,9 @@ impl Panel for SlimHeaderPanel { fn name(&self) -> &'static str { "header_slim" } + fn supports_acquisition(&self, _acquisition: crate::hardware::AcquisitionKind) -> bool { + true + } fn min_size(&self) -> (u16, u16) { (60, 4) } diff --git a/src/ui/panels/core/log.rs b/src/ui/panels/core/log.rs index 10c1788..e39d87b 100644 --- a/src/ui/panels/core/log.rs +++ b/src/ui/panels/core/log.rs @@ -85,6 +85,9 @@ impl Panel for LogPanel { fn name(&self) -> &'static str { "log" } + fn supports_acquisition(&self, _acquisition: crate::hardware::AcquisitionKind) -> bool { + true + } fn min_size(&self) -> (u16, u16) { (20, 7) } diff --git a/src/ui/panels/core/spectrum/mod.rs b/src/ui/panels/core/spectrum/mod.rs index b1ce8e8..e0beef9 100644 --- a/src/ui/panels/core/spectrum/mod.rs +++ b/src/ui/panels/core/spectrum/mod.rs @@ -51,6 +51,9 @@ impl Panel for SpectrumPanel { fn name(&self) -> &'static str { "spectrum" } + fn supports_acquisition(&self, _acquisition: crate::hardware::AcquisitionKind) -> bool { + true + } fn min_size(&self) -> (u16, u16) { (40, 10) } @@ -378,6 +381,7 @@ fn draw_instrument( #[cfg(test)] mod tests { use super::*; + use std::sync::Arc; #[test] fn bond_below_drops_bottom_border() { @@ -420,6 +424,27 @@ mod tests { assert_eq!(full.gutter.height, full.canvas.height); assert_eq!(full.gutter.width, 6); } + + #[test] + fn direct_trace_coordinates_map_through_the_spectrum_view() { + let frequencies = [100_u64, 200, 300]; + let (center_hz, span_hz) = crate::signal::power::trace_window(&frequencies).unwrap(); + let bins = Arc::new(vec![-90.0, -60.0, -80.0]); + let view = SpectrumView::new( + &bins, + &Arc::clone(&bins), + None, + center_hz, + span_hz, + 1, + crate::state::BinAxis::MeasuredPoints, + ) + .unwrap(); + + for (index, frequency) in frequencies.into_iter().enumerate() { + assert_eq!(view.freq_of_bin(index), frequency as f64); + } + } } #[cfg(test)] diff --git a/src/ui/panels/core/spectrum/view.rs b/src/ui/panels/core/spectrum/view.rs index 9a2a004..cddd54f 100644 --- a/src/ui/panels/core/spectrum/view.rs +++ b/src/ui/panels/core/spectrum/view.rs @@ -36,10 +36,13 @@ pub(super) struct SpectrumView { } impl SpectrumView { - /// Select the centre slice of the frame at `zoom` + /// Window `full_*` down to the centre `1/zoom` of its bins. A `zoom` of 1 + /// (or a frame with nothing in it) returns the whole span, sharing the + /// frame's `Arc`s rather than copying. /// - /// The full view shares the frame's buffers. Empty frames have no view. - /// A held trace may have a different bin count. + /// `held` may have been captured at a different bin count than the live + /// frame, so it is windowed against its own length. Slicing it blind is a + /// panic waiting for the user to change sample rate while holding. pub fn new( bins: &Arc>, peaks: &Arc>, @@ -55,6 +58,7 @@ impl SpectrumView { let hi = lo + window.bin_count; if lo == 0 && hi == full_n { + // Arc::clone is O(1) - no data copied. return Some(Self { bins: Arc::clone(bins), peaks: Arc::clone(peaks), @@ -84,7 +88,7 @@ impl SpectrumView { self.left_hz + self.bw } - /// Return the right edge of the canvas in bin-interval units + /// Right edge of the canvas in bin-interval units. pub fn n(&self) -> f64 { self.bin_axis.interval_count(self.n_bins).unwrap_or(1) as f64 } @@ -92,6 +96,7 @@ impl SpectrumView { pub fn bin_end(&self, index: usize) -> f64 { match self.bin_axis { BinAxis::FftBins => (index + 1) as f64, + BinAxis::MeasuredPoints => index as f64, } } @@ -162,6 +167,48 @@ mod tests { assert!(v.left_hz < 92_800_000.0 && v.right_hz() > 92_800_000.0); } + #[test] + fn measured_point_zoom_keeps_the_slice_endpoints() { + let bins = ramp(64); + let v = SpectrumView::new( + &bins, + &ramp(64), + None, + 131_500_000, + 63_000_000.0, + 4, + BinAxis::MeasuredPoints, + ) + .unwrap(); + + assert_eq!(v.bins.first(), Some(&24.0)); + assert_eq!(v.bins.last(), Some(&39.0)); + assert_eq!(v.freq_of_bin(0), 124_000_000.0); + assert_eq!(v.freq_of_bin(15), 139_000_000.0); + assert_eq!(v.left_hz, 124_000_000.0); + assert_eq!(v.right_hz(), 139_000_000.0); + } + + #[test] + fn bin_frequencies_map_to_their_canvas_positions() { + for axis in [BinAxis::FftBins, BinAxis::MeasuredPoints] { + let bins = ramp(32); + let view = + SpectrumView::new(&bins, &ramp(32), None, 100_000_000, 32_000_000.0, 1, axis) + .unwrap(); + for index in [0, 8, 16, 24, 31] { + let x = super::super::scale::freq_to_canvas_x( + view.freq_of_bin(index), + view.left_hz, + view.bw, + view.n(), + ) + .unwrap(); + assert!((x - index as f64).abs() < 1e-9, "{axis:?} bin {index}"); + } + } + } + #[test] fn a_hold_captured_at_another_bin_count_does_not_panic() { // The user changed sample rate while holding: the snapshot is shorter @@ -190,7 +237,7 @@ mod tests { 92_800_000, 2_000_000.0, 1, - BinAxis::FftBins + BinAxis::FftBins, ) .is_none()); assert!( @@ -201,7 +248,7 @@ mod tests { 92_800_000, 0.0, 1, - BinAxis::FftBins + BinAxis::FftBins, ) .is_none(), "a zero sample rate has no span to draw" diff --git a/src/ui/panels/core/system_resources.rs b/src/ui/panels/core/system_resources.rs index bbc62bb..ecda773 100644 --- a/src/ui/panels/core/system_resources.rs +++ b/src/ui/panels/core/system_resources.rs @@ -18,6 +18,9 @@ impl Panel for SystemResourcesPanel { fn name(&self) -> &'static str { "system_resources" } + fn supports_acquisition(&self, _acquisition: crate::hardware::AcquisitionKind) -> bool { + true + } fn min_size(&self) -> (u16, u16) { (30, 10) } diff --git a/src/ui/panels/core/waterfall/cells.rs b/src/ui/panels/core/waterfall/cells.rs index 2118732..02fc20b 100644 --- a/src/ui/panels/core/waterfall/cells.rs +++ b/src/ui/panels/core/waterfall/cells.rs @@ -68,6 +68,16 @@ impl Columns { col * visible_n / self.cols, (col + 1) * visible_n / self.cols, ), + BinAxis::MeasuredPoints => { + let intervals = visible_n.saturating_sub(1); + let start = (col * intervals).div_ceil(self.cols); + let end = if col + 1 >= self.cols { + visible_n + } else { + ((col + 1) * intervals).div_ceil(self.cols) + }; + (start, end) + } }; let start = start.min(visible_n - 1); let end = end.max(start + 1).min(visible_n); @@ -280,6 +290,8 @@ mod tests { #[test] fn singleton_and_zero_columns_do_not_underflow() { + // A zero-bin row and a zero zoom are both nonsense, and both used to be + // one subtraction away from panicking. let c = columns(1, 32, 40); let (lo, hi) = c.range(0); assert!(hi > lo); @@ -304,4 +316,13 @@ mod tests { ); } } + #[test] + fn measured_point_columns_follow_endpoint_coordinates() { + let window = BinAxis::MeasuredPoints + .window(131_500_000, 63_000_000.0, 64, 1) + .unwrap(); + let columns = Columns::new(window, 40, BinAxis::MeasuredPoints); + assert_eq!(columns.range(0), (0, 2)); + assert_eq!(columns.range(39), (62, 64)); + } } diff --git a/src/ui/panels/core/waterfall/mod.rs b/src/ui/panels/core/waterfall/mod.rs index f4fa1ff..daf05d0 100644 --- a/src/ui/panels/core/waterfall/mod.rs +++ b/src/ui/panels/core/waterfall/mod.rs @@ -101,6 +101,9 @@ impl Panel for WaterfallPanel { fn name(&self) -> &'static str { "waterfall" } + fn supports_acquisition(&self, _acquisition: crate::hardware::AcquisitionKind) -> bool { + true + } fn min_size(&self) -> (u16, u16) { (40, 5) }