diff --git a/rfg_adc_plotter/gui/pyqtgraph_backend.py b/rfg_adc_plotter/gui/pyqtgraph_backend.py index 382e311..9277a7a 100644 --- a/rfg_adc_plotter/gui/pyqtgraph_backend.py +++ b/rfg_adc_plotter/gui/pyqtgraph_backend.py @@ -48,6 +48,7 @@ from rfg_adc_plotter.types import SweepAuxCurves, SweepInfo, SweepPacket RAW_PLOT_MAX_POINTS = 4096 RAW_WATERFALL_MAX_POINTS = 2048 +BSCAN_MAX_POINTS = 512 UI_MAX_PACKETS_PER_TICK = 8 DEBUG_FRAME_LOG_EVERY = 10 UI_BACKLOG_TAIL_THRESHOLD_MULTIPLIER = 1 @@ -197,6 +198,19 @@ def resolve_heavy_refresh_stride( return 1 +def resolve_bscan_refresh_stride( + backlog_packets: int, + *, + max_packets: int = UI_MAX_PACKETS_PER_TICK, +) -> int: + """Keep B-scan responsive by limiting suppression to every other frame.""" + base = max(1, int(max_packets)) + backlog = max(0, int(backlog_packets)) + if backlog >= (base * UI_BACKLOG_LATEST_ONLY_THRESHOLD_MULTIPLIER): + return 2 + return 1 + + def resolve_initial_window_size(available_width: int, available_height: int) -> Tuple[int, int]: """Fit the initial window to the current screen without assuming desktop-sized geometry.""" width_in = int(max(0, available_width)) @@ -572,6 +586,38 @@ def decimate_curve_for_display( return x_arr[display_idx], y_arr[display_idx] +def decimate_bscan_rows_for_display( + axis: Optional[np.ndarray], + data: np.ndarray, + *, + max_points: int = BSCAN_MAX_POINTS, +) -> Tuple[Optional[np.ndarray], np.ndarray]: + """Reduce B-scan rows to keep waterfall rendering responsive.""" + data_arr = np.asarray(data, dtype=np.float32) + if data_arr.ndim != 2: + return axis, data_arr + row_count = int(data_arr.shape[0]) + limit = max(1, int(max_points)) + if row_count <= limit: + return axis, data_arr + + row_idx = np.linspace(0, row_count - 1, limit, dtype=np.int64) + row_idx = np.unique(row_idx) + decimated = data_arr[row_idx, :] + if axis is None: + return None, decimated + + axis_arr = np.asarray(axis, dtype=np.float64).reshape(-1) + if axis_arr.size <= 0: + return None, decimated + take = min(axis_arr.size, row_count) + axis_arr = axis_arr[:take] + valid_idx = row_idx[row_idx < axis_arr.size] + if valid_idx.size != row_idx.size: + decimated = data_arr[valid_idx, :] + return axis_arr[valid_idx], decimated + + def coalesce_packets_for_ui( packets: Sequence[SweepPacket], *, @@ -1103,6 +1149,7 @@ def run_pyqtgraph(args) -> None: last_queue_backlog = 0 last_backlog_skipped = 0 last_heavy_refresh_stride = 1 + last_bscan_refresh_stride = 1 expected_sweep_width = 0 base_freqs_cache: Dict[int, np.ndarray] = {} last_packet_processed_at: Optional[float] = None @@ -2110,7 +2157,8 @@ def run_pyqtgraph(args) -> None: runtime.current_fft_db = fft_mag_to_db(runtime.current_fft_mag) def drain_queue() -> int: - nonlocal processed_frames, ui_frames_skipped, last_queue_backlog, last_backlog_skipped, last_heavy_refresh_stride + nonlocal processed_frames, ui_frames_skipped, last_queue_backlog, last_backlog_skipped + nonlocal last_heavy_refresh_stride, last_bscan_refresh_stride nonlocal expected_sweep_width, base_freqs_cache, last_packet_processed_at pending_packets: List[SweepPacket] = [] while True: @@ -2123,6 +2171,7 @@ def run_pyqtgraph(args) -> None: if drained <= 0: last_backlog_skipped = 0 last_heavy_refresh_stride = 1 + last_bscan_refresh_stride = 1 return 0 pending_packets, skipped_packets = coalesce_packets_for_ui( @@ -2131,6 +2180,7 @@ def run_pyqtgraph(args) -> None: ) last_backlog_skipped = skipped_packets last_heavy_refresh_stride = resolve_heavy_refresh_stride(drained) + last_bscan_refresh_stride = resolve_bscan_refresh_stride(drained) ui_frames_skipped += skipped_packets if skipped_packets > 0: log_debug_event( @@ -2272,6 +2322,11 @@ def run_pyqtgraph(args) -> None: or last_heavy_refresh_stride <= 1 or (update_ticks % last_heavy_refresh_stride) == 0 ) + refresh_bscan_views = ( + runtime.plot_dirty + or last_bscan_refresh_stride <= 1 + or (update_ticks % last_bscan_refresh_stride) == 0 + ) if redraw_needed: refresh_signal_mode_labels() @@ -2717,10 +2772,10 @@ def run_pyqtgraph(args) -> None: pass if redraw_needed and runtime.ring.ring_fft is not None: - if not refresh_heavy_views: + if not refresh_bscan_views: log_debug_event( "suppressed_fft_image_refresh", - f"ui FFT waterfall refresh suppressed stride:{last_heavy_refresh_stride}", + f"ui FFT waterfall refresh suppressed stride:{last_bscan_refresh_stride}", ) else: disp_fft_lin = runtime.ring.get_display_fft_linear() @@ -2735,6 +2790,11 @@ def run_pyqtgraph(args) -> None: if keep_mask.size > 0: disp_fft_lin = disp_fft_lin[keep_mask, :] disp_fft_axis = axis_arr + disp_fft_axis, disp_fft_lin = decimate_bscan_rows_for_display( + disp_fft_axis, + disp_fft_lin, + max_points=BSCAN_MAX_POINTS, + ) if spec_mean_sec > 0.0: disp_times = runtime.ring.get_display_times() if disp_times is not None: diff --git a/tests/test_processing.py b/tests/test_processing.py index b581004..798d9e1 100644 --- a/tests/test_processing.py +++ b/tests/test_processing.py @@ -16,9 +16,11 @@ from rfg_adc_plotter.gui.pyqtgraph_backend import ( compute_background_subtracted_bscan_levels, compute_aux_phase_curve, convert_tty_i16_to_voltage, + decimate_bscan_rows_for_display, decimate_curve_for_display, is_short_sweep, resolve_axis_bounds, + resolve_bscan_refresh_stride, resolve_heavy_refresh_stride, resolve_initial_window_size, resolve_distance_cut_start, @@ -377,6 +379,31 @@ class ProcessingTests(unittest.TestCase): self.assertEqual(resolve_heavy_refresh_stride(8, max_packets=8), 2) self.assertEqual(resolve_heavy_refresh_stride(16, max_packets=8), 4) + def test_resolve_bscan_refresh_stride_limits_suppression(self): + self.assertEqual(resolve_bscan_refresh_stride(0, max_packets=8), 1) + self.assertEqual(resolve_bscan_refresh_stride(8, max_packets=8), 1) + self.assertEqual(resolve_bscan_refresh_stride(16, max_packets=8), 2) + + def test_decimate_bscan_rows_for_display_keeps_shape_consistent(self): + axis = np.linspace(0.0, 1.0, 10, dtype=np.float64) + data = np.arange(50, dtype=np.float32).reshape(10, 5) + + dec_axis, dec_data = decimate_bscan_rows_for_display(axis, data, max_points=4) + + self.assertEqual(dec_data.shape, (4, 5)) + self.assertIsNotNone(dec_axis) + self.assertEqual(dec_axis.shape, (4,)) + self.assertAlmostEqual(float(dec_axis[0]), 0.0, places=12) + self.assertAlmostEqual(float(dec_axis[-1]), 1.0, places=12) + + def test_decimate_bscan_rows_for_display_handles_missing_axis(self): + data = np.arange(32, dtype=np.float32).reshape(8, 4) + + dec_axis, dec_data = decimate_bscan_rows_for_display(None, data, max_points=3) + + self.assertIsNone(dec_axis) + self.assertEqual(dec_data.shape, (3, 4)) + def test_update_expected_sweep_width_initializes_from_first_valid_sweep(self): self.assertEqual(update_expected_sweep_width(0, 411), 411)