From 934ca33d58f76e21c28c6cbce73ca0d1af78c583 Mon Sep 17 00:00:00 2001 From: awe Date: Fri, 10 Apr 2026 16:20:48 +0300 Subject: [PATCH] giga fix --- rfg_adc_plotter/gui/pyqtgraph_backend.py | 500 +++++++++++++++++------ rfg_adc_plotter/state/ring_buffer.py | 63 ++- tests/test_processing.py | 72 ++++ tests/test_ring_buffer.py | 41 ++ 4 files changed, 531 insertions(+), 145 deletions(-) diff --git a/rfg_adc_plotter/gui/pyqtgraph_backend.py b/rfg_adc_plotter/gui/pyqtgraph_backend.py index 5154d9a..f4fdf31 100644 --- a/rfg_adc_plotter/gui/pyqtgraph_backend.py +++ b/rfg_adc_plotter/gui/pyqtgraph_backend.py @@ -42,6 +42,151 @@ RAW_PLOT_MAX_POINTS = 4096 RAW_WATERFALL_MAX_POINTS = 2048 UI_MAX_PACKETS_PER_TICK = 8 DEBUG_FRAME_LOG_EVERY = 10 +UI_BACKLOG_TAIL_THRESHOLD_MULTIPLIER = 2 +UI_BACKLOG_LATEST_ONLY_THRESHOLD_MULTIPLIER = 4 +UI_HEAVY_REFRESH_BACKLOG_MULTIPLIER = 2 +UI_HEAVY_REFRESH_MAX_STRIDE = 4 +DEFAULT_MAIN_WINDOW_WIDTH = 1200 +DEFAULT_MAIN_WINDOW_HEIGHT = 680 +MIN_MAIN_WINDOW_WIDTH = 640 +MIN_MAIN_WINDOW_HEIGHT = 420 + + +def sanitize_curve_data_for_display( + xs: Optional[np.ndarray], + ys: Optional[np.ndarray], +) -> Tuple[np.ndarray, np.ndarray]: + """Drop non-finite points before passing a line series to pyqtgraph.""" + if xs is None or ys is None: + return ( + np.zeros((0,), dtype=np.float64), + np.zeros((0,), dtype=np.float32), + ) + + x_arr = np.asarray(xs, dtype=np.float64).reshape(-1) + y_arr = np.asarray(ys, dtype=np.float32).reshape(-1) + width = min(x_arr.size, y_arr.size) + if width <= 0: + return ( + np.zeros((0,), dtype=np.float64), + np.zeros((0,), dtype=np.float32), + ) + + x_arr = x_arr[:width] + y_arr = y_arr[:width] + finite = np.isfinite(x_arr) & np.isfinite(y_arr) + if not np.any(finite): + return ( + np.zeros((0,), dtype=np.float64), + np.zeros((0,), dtype=np.float32), + ) + return x_arr[finite], y_arr[finite] + + +def sanitize_image_for_display(data: Optional[np.ndarray]) -> Optional[np.ndarray]: + """Reject empty or fully non-finite images before calling ImageItem.setImage.""" + if data is None: + return None + arr = np.asarray(data, dtype=np.float32) + if arr.ndim != 2 or arr.size <= 0 or not np.isfinite(arr).any(): + return None + return arr + + +def resolve_axis_bounds( + values: Optional[np.ndarray], + *, + default_span: float = 1.0, +) -> Optional[Tuple[float, float]]: + """Return a finite plotting span for a 1D axis.""" + if values is None: + return None + arr = np.asarray(values, dtype=np.float64).reshape(-1) + finite = arr[np.isfinite(arr)] + if finite.size <= 0: + return None + lower = float(np.min(finite)) + upper = float(np.max(finite)) + if upper <= lower: + upper = lower + max(float(default_span), 1e-9) + return (lower, upper) + + +def resolve_heavy_refresh_stride( + backlog_packets: int, + *, + max_packets: int = UI_MAX_PACKETS_PER_TICK, +) -> int: + """Lower image refresh frequency when the UI is already behind.""" + base = max(1, int(max_packets)) + backlog = max(0, int(backlog_packets)) + if backlog >= (base * UI_BACKLOG_LATEST_ONLY_THRESHOLD_MULTIPLIER): + return UI_HEAVY_REFRESH_MAX_STRIDE + if backlog >= (base * UI_HEAVY_REFRESH_BACKLOG_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)) + height_in = int(max(0, available_height)) + if width_in <= 0 or height_in <= 0: + return DEFAULT_MAIN_WINDOW_WIDTH, DEFAULT_MAIN_WINDOW_HEIGHT + + width_floor = min(MIN_MAIN_WINDOW_WIDTH, width_in) + height_floor = min(MIN_MAIN_WINDOW_HEIGHT, height_in) + width = int(width_in * 0.94) + height = int(height_in * 0.92) + width = min(max(width, width_floor), width_in) + height = min(max(height, height_floor), height_in) + return width, height + + +def build_main_window_layout(QtCore, QtWidgets, main_window): + """Build a splitter-based main layout so settings stay usable on small screens.""" + main_layout = QtWidgets.QVBoxLayout(main_window) + main_layout.setContentsMargins(6, 6, 6, 6) + main_layout.setSpacing(6) + + splitter = QtWidgets.QSplitter(QtCore.Qt.Horizontal, main_window) + try: + splitter.setChildrenCollapsible(False) + splitter.setHandleWidth(6) + except Exception: + pass + main_layout.addWidget(splitter) + + plot_host = QtWidgets.QWidget() + plot_layout = QtWidgets.QVBoxLayout(plot_host) + plot_layout.setContentsMargins(0, 0, 0, 0) + plot_layout.setSpacing(0) + splitter.addWidget(plot_host) + + settings_widget = QtWidgets.QWidget() + settings_layout = QtWidgets.QVBoxLayout(settings_widget) + settings_layout.setContentsMargins(6, 6, 6, 6) + settings_layout.setSpacing(8) + + settings_scroll = QtWidgets.QScrollArea() + settings_scroll.setWidgetResizable(True) + settings_scroll.setFrameShape(QtWidgets.QFrame.NoFrame) + settings_scroll.setWidget(settings_widget) + try: + settings_scroll.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + settings_scroll.setMinimumWidth(250) + settings_scroll.setMaximumWidth(360) + except Exception: + pass + splitter.addWidget(settings_scroll) + + try: + splitter.setStretchFactor(0, 1) + splitter.setStretchFactor(1, 0) + splitter.setSizes([900, 300]) + except Exception: + pass + return main_layout, splitter, plot_layout, settings_widget, settings_layout, settings_scroll def _visible_levels_pyqtgraph(data: np.ndarray, plot_item) -> Optional[Tuple[float, float]]: @@ -247,10 +392,18 @@ def coalesce_packets_for_ui( packets: Sequence[SweepPacket], *, max_packets: int = UI_MAX_PACKETS_PER_TICK, + backlog_packets: Optional[int] = None, ) -> Tuple[List[SweepPacket], int]: """Keep only the newest packets so a burst cannot starve the Qt event loop.""" packet_list = list(packets) - limit = max(1, int(max_packets)) + base_limit = max(1, int(max_packets)) + backlog = max(len(packet_list), int(backlog_packets or 0)) + if backlog >= (base_limit * UI_BACKLOG_LATEST_ONLY_THRESHOLD_MULTIPLIER): + limit = 1 + elif backlog >= (base_limit * UI_BACKLOG_TAIL_THRESHOLD_MULTIPLIER): + limit = min(2, base_limit) + else: + limit = base_limit if len(packet_list) <= limit: return packet_list, 0 skipped = len(packet_list) - limit @@ -381,23 +534,16 @@ def run_pyqtgraph(args) -> None: except Exception: pass - main_layout = QtWidgets.QHBoxLayout(main_window) - main_layout.setContentsMargins(6, 6, 6, 6) - main_layout.setSpacing(6) - win = pg.GraphicsLayoutWidget(show=False, title=args.title) - main_layout.addWidget(win) - - settings_widget = QtWidgets.QWidget() - settings_layout = QtWidgets.QVBoxLayout(settings_widget) - settings_layout.setContentsMargins(6, 6, 6, 6) - settings_layout.setSpacing(8) - try: - settings_widget.setMinimumWidth(250) - settings_widget.setMaximumWidth(340) - except Exception: - pass - main_layout.addWidget(settings_widget) + ( + _main_layout, + splitter, + plot_layout, + settings_widget, + settings_layout, + settings_scroll, + ) = build_main_window_layout(QtCore, QtWidgets, main_window) + plot_layout.addWidget(win) p_line = win.addPlot(row=0, col=0, title="Сырые данные") p_line.showGrid(x=True, y=True, alpha=0.3) @@ -597,8 +743,13 @@ def run_pyqtgraph(args) -> None: fft_imag_enabled = True fft_mode = "symmetric" status_note = "" + status_note_expires_at: Optional[float] = None status_dirty = True range_change_in_progress = False + debug_event_counts: Dict[str, int] = {} + last_queue_backlog = 0 + last_backlog_skipped = 0 + last_heavy_refresh_stride = 1 fixed_ylim: Optional[Tuple[float, float]] = None if args.ylim: try: @@ -615,50 +766,37 @@ def run_pyqtgraph(args) -> None: return f_min = float(runtime.range_min_ghz) f_max = float(runtime.range_max_ghz) - if runtime.current_freqs is not None and runtime.current_freqs.size > 0: - finite_f = runtime.current_freqs[np.isfinite(runtime.current_freqs)] - if finite_f.size > 0: - f_min = float(np.min(finite_f)) - f_max = float(np.max(finite_f)) - img.setImage(runtime.ring.get_display_raw_decimated(RAW_WATERFALL_MAX_POINTS), autoLevels=False) + freq_bounds = resolve_axis_bounds(runtime.current_freqs) + if freq_bounds is not None: + f_min, f_max = freq_bounds + disp_raw = sanitize_image_for_display(runtime.ring.get_display_raw_decimated(RAW_WATERFALL_MAX_POINTS)) + if disp_raw is not None: + img.setImage(disp_raw, autoLevels=False) img.setRect(0, f_min, max_sweeps, max(1e-9, f_max - f_min)) - p_img.setRange( - xRange=(0, max_sweeps - 1), - yRange=(f_min, f_max), - padding=0, - ) + p_img.setRange(xRange=(0, max_sweeps - 1), yRange=(f_min, f_max), padding=0) p_line.setXRange(f_min, f_max, padding=0) - img_fft.setImage(runtime.ring.get_display_fft_linear(), autoLevels=False) + disp_fft = sanitize_image_for_display(runtime.ring.get_display_fft_linear()) + if disp_fft is not None: + img_fft.setImage(disp_fft, autoLevels=False) img_fft.setRect(0, 0.0, max_sweeps, 1.0) p_spec.setRange(xRange=(0, max_sweeps - 1), yRange=(0.0, 1.0), padding=0) p_fft.setXRange(0.0, 1.0, padding=0) def update_physical_axes() -> None: - if runtime.current_freqs is not None and runtime.current_freqs.size > 0: - finite_f = runtime.current_freqs[np.isfinite(runtime.current_freqs)] - if finite_f.size > 0: - f_min = float(np.min(finite_f)) - f_max = float(np.max(finite_f)) - if f_max <= f_min: - f_max = f_min + 1.0 - img.setRect(0, f_min, max_sweeps, f_max - f_min) - p_img.setRange(xRange=(0, max_sweeps - 1), yRange=(f_min, f_max), padding=0) - p_line.setXRange(f_min, f_max, padding=0) - else: - f_min = float(runtime.range_min_ghz) - f_max = float(runtime.range_max_ghz) - if f_max <= f_min: - f_max = f_min + 1.0 + freq_bounds = resolve_axis_bounds(runtime.current_freqs) + if freq_bounds is None: + freq_bounds = resolve_axis_bounds( + np.asarray([runtime.range_min_ghz, runtime.range_max_ghz], dtype=np.float64) + ) + if freq_bounds is not None: + f_min, f_max = freq_bounds img.setRect(0, f_min, max_sweeps, f_max - f_min) p_img.setRange(xRange=(0, max_sweeps - 1), yRange=(f_min, f_max), padding=0) p_line.setXRange(f_min, f_max, padding=0) - distance_axis = runtime.ring.distance_axis - if distance_axis is not None and distance_axis.size > 0: - d_min = float(distance_axis[0]) - d_max = float(distance_axis[-1]) if distance_axis.size > 1 else float(distance_axis[0] + 1.0) - if d_max <= d_min: - d_max = d_min + 1.0 + distance_bounds = resolve_axis_bounds(runtime.ring.distance_axis) + if distance_bounds is not None: + d_min, d_max = distance_bounds img_fft.setRect(0, d_min, max_sweeps, d_max - d_min) p_spec.setRange(xRange=(0, max_sweeps - 1), yRange=(d_min, d_max), padding=0) @@ -685,11 +823,28 @@ def run_pyqtgraph(args) -> None: return runtime.ring.x_shared[:size] return np.arange(size, dtype=np.float32) - def set_status_note(note: str) -> None: - nonlocal status_note, status_dirty + def set_status_note(note: str, ttl_s: Optional[float] = None) -> None: + nonlocal status_note, status_note_expires_at, status_dirty status_note = str(note).strip() + status_note_expires_at = None if ttl_s is None else (time.time() + max(0.1, float(ttl_s))) status_dirty = True + def clear_expired_status_note() -> None: + nonlocal status_note, status_note_expires_at, status_dirty + if status_note_expires_at is None: + return + if time.time() < status_note_expires_at: + return + status_note = "" + status_note_expires_at = None + status_dirty = True + + def log_debug_event(name: str, message: str, *, every: int = DEBUG_FRAME_LOG_EVERY) -> None: + count = int(debug_event_counts.get(name, 0)) + 1 + debug_event_counts[name] = count + if count == 1 or count % max(1, int(every)) == 0: + sys.stderr.write(f"[debug] {message} (x{count})\n") + def get_calib_file_path() -> str: try: path = calib_path_edit.text().strip() @@ -836,7 +991,13 @@ def run_pyqtgraph(args) -> None: runtime.current_fft_complex = None runtime.current_fft_mag = runtime.ring.get_last_fft_linear() runtime.current_fft_db = runtime.ring.last_fft_db - if runtime.current_fft_mag is not None: + if not runtime.ring.last_push_fft_valid: + set_status_note("fft: пропущен невалидный кадр", ttl_s=2.0) + log_debug_event("invalid_fft", "ui invalid FFT row suppressed") + if not runtime.ring.last_push_axis_valid: + set_status_note("fft: ось оставлена по последнему валидному кадру", ttl_s=2.0) + log_debug_event("invalid_fft_axis", "ui invalid FFT axis suppressed") + if runtime.ring.last_push_fft_valid and runtime.current_fft_mag is not None: runtime.background_buffer.push(runtime.current_fft_mag) def set_calib_enabled() -> None: @@ -1261,19 +1422,26 @@ def run_pyqtgraph(args) -> None: processed_frames = 0 ui_frames_skipped = 0 ui_started_at = time.perf_counter() + update_ticks = 0 def refresh_current_fft_cache(sweep_for_fft: np.ndarray, bins: int) -> None: - runtime.current_fft_complex = compute_fft_complex_row( + fft_complex = compute_fft_complex_row( sweep_for_fft, runtime.current_freqs, bins, mode=fft_mode, ) - runtime.current_fft_mag = np.abs(runtime.current_fft_complex).astype(np.float32, copy=False) + fft_mag = np.abs(fft_complex).astype(np.float32, copy=False) + if not np.isfinite(fft_mag).any(): + set_status_note("fft: локальный пересчет пропущен", ttl_s=2.0) + log_debug_event("invalid_fft_cache", "ui invalid FFT cache suppressed") + return + runtime.current_fft_complex = fft_complex + runtime.current_fft_mag = fft_mag runtime.current_fft_db = fft_mag_to_db(runtime.current_fft_mag) def drain_queue() -> int: - nonlocal processed_frames, ui_frames_skipped + nonlocal processed_frames, ui_frames_skipped, last_queue_backlog, last_backlog_skipped, last_heavy_refresh_stride pending_packets: List[SweepPacket] = [] while True: try: @@ -1281,12 +1449,31 @@ def run_pyqtgraph(args) -> None: except Empty: break drained = len(pending_packets) + last_queue_backlog = drained if drained <= 0: + last_backlog_skipped = 0 + last_heavy_refresh_stride = 1 return 0 - pending_packets, skipped_packets = coalesce_packets_for_ui(pending_packets) + pending_packets, skipped_packets = coalesce_packets_for_ui( + pending_packets, + backlog_packets=drained, + ) + last_backlog_skipped = skipped_packets + last_heavy_refresh_stride = resolve_heavy_refresh_stride(drained) ui_frames_skipped += skipped_packets + if skipped_packets > 0: + log_debug_event( + "ui_backlog", + f"ui backlog:{drained} keep:{len(pending_packets)} skipped:{skipped_packets}", + ) for sweep, info, aux_curves in pending_packets: + if runtime.ring.width > 0 and sweep.size < max(8, runtime.ring.width // 2): + set_status_note(f"короткий свип: {int(sweep.size)} точек", ttl_s=2.0) + log_debug_event( + "short_sweep", + f"ui short sweep width:{int(sweep.size)} expected:{int(runtime.ring.width)}", + ) base_freqs = np.linspace(SWEEP_FREQ_MIN_GHZ, SWEEP_FREQ_MAX_GHZ, sweep.size, dtype=np.float64) runtime.full_current_aux_curves = None runtime.full_current_fft_source = None @@ -1364,15 +1551,22 @@ def run_pyqtgraph(args) -> None: pass def update() -> None: - nonlocal peak_ref_window, status_dirty + nonlocal peak_ref_window, status_dirty, update_ticks norm_display_scale = 500.0 if peak_calibrate_mode and any(edit.hasFocus() for edit in c_edits): return if peak_search_enabled and peak_window_edit is not None and peak_window_edit.hasFocus(): return + update_ticks += 1 + clear_expired_status_note() changed = drain_queue() > 0 redraw_needed = changed or runtime.plot_dirty + refresh_heavy_views = ( + runtime.plot_dirty + or last_heavy_refresh_stride <= 1 + or (update_ticks % last_heavy_refresh_stride) == 0 + ) if redraw_needed: xs = resolve_curve_xs( @@ -1385,6 +1579,7 @@ def run_pyqtgraph(args) -> None: if runtime.current_sweep_raw is not None: raw_x, raw_y = decimate_curve_for_display(xs, runtime.current_sweep_raw) + raw_x, raw_y = sanitize_curve_data_for_display(raw_x, raw_y) curve.setData(raw_x, raw_y, autoDownsample=False) else: curve.setData([], []) @@ -1394,6 +1589,8 @@ def run_pyqtgraph(args) -> None: aux_width = min(xs.size, aux_1.size, aux_2.size) aux_x_1, aux_y_1 = decimate_curve_for_display(xs[:aux_width], aux_1[:aux_width]) aux_x_2, aux_y_2 = decimate_curve_for_display(xs[:aux_width], aux_2[:aux_width]) + aux_x_1, aux_y_1 = sanitize_curve_data_for_display(aux_x_1, aux_y_1) + aux_x_2, aux_y_2 = sanitize_curve_data_for_display(aux_x_2, aux_y_2) curve_aux_1.setData(aux_x_1, aux_y_1, autoDownsample=False) curve_aux_2.setData(aux_x_2, aux_y_2, autoDownsample=False) else: @@ -1408,6 +1605,7 @@ def run_pyqtgraph(args) -> None: displayed_calib = runtime.calib_envelope xs_calib = resolve_curve_xs(displayed_calib.size) calib_x, calib_y = decimate_curve_for_display(xs_calib, displayed_calib) + calib_x, calib_y = sanitize_curve_data_for_display(calib_x, calib_y) curve_calib.setData(calib_x, calib_y, autoDownsample=False) else: curve_calib.setData([], []) @@ -1415,6 +1613,7 @@ def run_pyqtgraph(args) -> None: if runtime.current_sweep_norm is not None: norm_display = runtime.current_sweep_norm * norm_display_scale norm_x, norm_y = decimate_curve_for_display(xs[: norm_display.size], norm_display) + norm_x, norm_y = sanitize_curve_data_for_display(norm_x, norm_y) curve_norm.setData(norm_x, norm_y, autoDownsample=False) else: curve_norm.setData([], []) @@ -1431,10 +1630,9 @@ def run_pyqtgraph(args) -> None: if y_limits is not None: p_line.setYRange(y_limits[0], y_limits[1], padding=0) - if isinstance(xs, np.ndarray) and xs.size > 0: - finite_x = xs[np.isfinite(xs)] - if finite_x.size > 0: - p_line.setXRange(float(np.min(finite_x)), float(np.max(finite_x)), padding=0) + line_x_bounds = resolve_axis_bounds(xs) + if line_x_bounds is not None: + p_line.setXRange(line_x_bounds[0], line_x_bounds[1], padding=0) sweep_for_fft = runtime.current_fft_input if sweep_for_fft is None: @@ -1470,9 +1668,9 @@ def run_pyqtgraph(args) -> None: set_status_note(f"фон: не удалось применить ({exc})") active_background = None display_fft_mag = fft_mag - finite_x = xs_fft[np.isfinite(xs_fft)] - if finite_x.size > 0: - p_fft.setXRange(float(np.min(finite_x)), float(np.max(finite_x)), padding=0) + fft_x_bounds = resolve_axis_bounds(xs_fft) + if fft_x_bounds is not None: + p_fft.setXRange(fft_x_bounds[0], fft_x_bounds[1], padding=0) fft_vals_db = fft_mag_to_db(display_fft_mag) ref_curve_for_range = None @@ -1487,15 +1685,18 @@ def run_pyqtgraph(args) -> None: show_imag=fft_imag_enabled, ) if visible_abs is not None: - curve_fft.setData(xs_fft[: visible_abs.size], visible_abs) + abs_x, abs_y = sanitize_curve_data_for_display(xs_fft[: visible_abs.size], visible_abs) + curve_fft.setData(abs_x, abs_y) else: curve_fft.setData([], []) if visible_real is not None: - curve_fft_real.setData(xs_fft[: visible_real.size], visible_real) + real_x, real_y = sanitize_curve_data_for_display(xs_fft[: visible_real.size], visible_real) + curve_fft_real.setData(real_x, real_y) else: curve_fft_real.setData([], []) if visible_imag is not None: - curve_fft_imag.setData(xs_fft[: visible_imag.size], visible_imag) + imag_x, imag_y = sanitize_curve_data_for_display(xs_fft[: visible_imag.size], visible_imag) + curve_fft_imag.setData(imag_x, imag_y) else: curve_fft_imag.setData([], []) @@ -1504,7 +1705,8 @@ def run_pyqtgraph(args) -> None: finite_ref = np.isfinite(xs_fft) & np.isfinite(fft_ref_db) if np.any(finite_ref): fft_ref_lin = _db_to_linear_amplitude(fft_ref_db[finite_ref]) - curve_fft_ref.setData(xs_fft[finite_ref], fft_ref_lin) + ref_x, ref_y = sanitize_curve_data_for_display(xs_fft[finite_ref], fft_ref_lin) + curve_fft_ref.setData(ref_x, ref_y) curve_fft_ref.setVisible(True) ref_curve_for_range = fft_ref_lin else: @@ -1573,7 +1775,8 @@ def run_pyqtgraph(args) -> None: curve_fft_real.setData([], []) curve_fft_imag.setData([], []) if fft_abs_enabled: - curve_fft.setData(xs_fft, fft_vals_db) + fft_x, fft_y = sanitize_curve_data_for_display(xs_fft, fft_vals_db) + curve_fft.setData(fft_x, fft_y) else: curve_fft.setData([], []) @@ -1583,7 +1786,8 @@ def run_pyqtgraph(args) -> None: fft_ref = rolling_median_ref(xs_fft, fft_vals_db, peak_ref_window) finite_ref = np.isfinite(xs_fft) & np.isfinite(fft_ref) if np.any(finite_ref): - curve_fft_ref.setData(xs_fft[finite_ref], fft_ref[finite_ref]) + ref_x, ref_y = sanitize_curve_data_for_display(xs_fft[finite_ref], fft_ref[finite_ref]) + curve_fft_ref.setData(ref_x, ref_y) curve_fft_ref.setVisible(True) y_for_range = np.concatenate((y_for_range, fft_ref[finite_ref])) else: @@ -1668,12 +1872,22 @@ def run_pyqtgraph(args) -> None: runtime.plot_dirty = False if changed and runtime.ring.ring is not None: - disp = runtime.ring.get_display_raw_decimated(RAW_WATERFALL_MAX_POINTS) - levels = _visible_levels_pyqtgraph(disp, p_img) - if levels is not None: - img.setImage(disp, autoLevels=False, levels=levels) + if refresh_heavy_views: + disp = sanitize_image_for_display(runtime.ring.get_display_raw_decimated(RAW_WATERFALL_MAX_POINTS)) + if disp is not None: + levels = _visible_levels_pyqtgraph(disp, p_img) + if levels is not None: + img.setImage(disp, autoLevels=False, levels=levels) + else: + img.setImage(disp, autoLevels=False) + else: + set_status_note("raw waterfall: кадр пропущен", ttl_s=2.0) + log_debug_event("invalid_raw_image", "ui invalid raw waterfall suppressed") else: - img.setImage(disp, autoLevels=False) + log_debug_event( + "suppressed_image_refresh", + f"ui waterfall refresh suppressed stride:{last_heavy_refresh_stride}", + ) if redraw_needed or status_dirty: try: @@ -1683,8 +1897,16 @@ def run_pyqtgraph(args) -> None: if peak_calibrate_mode and runtime.current_peak_amplitude is not None: status_payload["peak_a"] = runtime.current_peak_amplitude base_status = format_status_kv(status_payload) if status_payload else "" + status_parts = [] if status_note: - status.setText(f"{base_status} | {status_note}" if base_status else status_note) + status_parts.append(status_note) + if last_backlog_skipped > 0: + status_parts.append(f"ui backlog:{last_queue_backlog} skip:{last_backlog_skipped}") + if last_heavy_refresh_stride > 1: + status_parts.append(f"ui wf/{last_heavy_refresh_stride}") + status_suffix = " | ".join(status_parts) + if status_suffix: + status.setText(f"{base_status} | {status_suffix}" if base_status else status_suffix) else: status.setText(base_status) except Exception: @@ -1713,66 +1935,77 @@ def run_pyqtgraph(args) -> None: pass if redraw_needed and runtime.ring.ring_fft is not None: - disp_fft_lin = runtime.ring.get_display_fft_linear() - if spec_mean_sec > 0.0: - disp_times = runtime.ring.get_display_times() - if disp_times is not None: - now_t = time.time() - mask = np.isfinite(disp_times) & (disp_times >= (now_t - spec_mean_sec)) - if np.any(mask): - try: - mean_spec = np.nanmean(disp_fft_lin[:, mask], axis=1) - mean_spec = np.nan_to_num(mean_spec, nan=0.0) - disp_fft_lin = disp_fft_lin - mean_spec[:, None] - except Exception: - pass + if not refresh_heavy_views: + log_debug_event( + "suppressed_fft_image_refresh", + f"ui FFT waterfall refresh suppressed stride:{last_heavy_refresh_stride}", + ) + else: + disp_fft_lin = runtime.ring.get_display_fft_linear() + if spec_mean_sec > 0.0: + disp_times = runtime.ring.get_display_times() + if disp_times is not None: + now_t = time.time() + mask = np.isfinite(disp_times) & (disp_times >= (now_t - spec_mean_sec)) + if np.any(mask): + try: + mean_spec = np.nanmean(disp_fft_lin[:, mask], axis=1) + mean_spec = np.nan_to_num(mean_spec, nan=0.0) + disp_fft_lin = disp_fft_lin - mean_spec[:, None] + except Exception: + pass - active_background = None - try: - active_background = resolve_active_background(disp_fft_lin.shape[0]) - except Exception: active_background = None - if active_background is not None: try: - disp_fft_lin = subtract_fft_background(disp_fft_lin, active_background) - except Exception as exc: - set_status_note(f"фон: не удалось применить ({exc})") - active_background = None - disp_fft = fft_mag_to_db(disp_fft_lin) - - levels = None - if active_background is not None: - levels = compute_background_subtracted_bscan_levels(disp_fft_lin, disp_fft) - if levels is None: - try: - mean_spec = np.nanmean(disp_fft, axis=1) - vmin_v = float(np.nanmin(mean_spec)) - vmax_v = float(np.nanmax(mean_spec)) - if np.isfinite(vmin_v) and np.isfinite(vmax_v) and vmin_v != vmax_v: - levels = (vmin_v, vmax_v) + active_background = resolve_active_background(disp_fft_lin.shape[0]) except Exception: - levels = None - if levels is None and spec_clip is not None: + active_background = None + if active_background is not None: try: - vmin_v = float(np.nanpercentile(disp_fft, spec_clip[0])) - vmax_v = float(np.nanpercentile(disp_fft, spec_clip[1])) + disp_fft_lin = subtract_fft_background(disp_fft_lin, active_background) + except Exception as exc: + set_status_note(f"фон: не удалось применить ({exc})") + active_background = None + disp_fft = fft_mag_to_db(disp_fft_lin) + disp_fft = sanitize_image_for_display(disp_fft) + if disp_fft is None: + set_status_note("b-scan: кадр пропущен", ttl_s=2.0) + log_debug_event("invalid_fft_image", "ui invalid FFT waterfall suppressed") + return + + levels = None + if active_background is not None: + levels = compute_background_subtracted_bscan_levels(disp_fft_lin, disp_fft) + if levels is None: + try: + mean_spec = np.nanmean(disp_fft, axis=1) + vmin_v = float(np.nanmin(mean_spec)) + vmax_v = float(np.nanmax(mean_spec)) if np.isfinite(vmin_v) and np.isfinite(vmax_v) and vmin_v != vmax_v: levels = (vmin_v, vmax_v) except Exception: levels = None - if ( - levels is None - and runtime.ring.y_min_fft is not None - and runtime.ring.y_max_fft is not None - and np.isfinite(runtime.ring.y_min_fft) - and np.isfinite(runtime.ring.y_max_fft) - and runtime.ring.y_min_fft != runtime.ring.y_max_fft - ): - levels = (runtime.ring.y_min_fft, runtime.ring.y_max_fft) - if levels is not None: - img_fft.setImage(disp_fft, autoLevels=False, levels=levels) - else: - img_fft.setImage(disp_fft, autoLevels=False) + if levels is None and spec_clip is not None: + try: + vmin_v = float(np.nanpercentile(disp_fft, spec_clip[0])) + vmax_v = float(np.nanpercentile(disp_fft, spec_clip[1])) + if np.isfinite(vmin_v) and np.isfinite(vmax_v) and vmin_v != vmax_v: + levels = (vmin_v, vmax_v) + except Exception: + levels = None + if ( + levels is None + and runtime.ring.y_min_fft is not None + and runtime.ring.y_max_fft is not None + and np.isfinite(runtime.ring.y_min_fft) + and np.isfinite(runtime.ring.y_max_fft) + and runtime.ring.y_min_fft != runtime.ring.y_max_fft + ): + levels = (runtime.ring.y_min_fft, runtime.ring.y_max_fft) + if levels is not None: + img_fft.setImage(disp_fft, autoLevels=False, levels=levels) + else: + img_fft.setImage(disp_fft, autoLevels=False) timer = pg.QtCore.QTimer() timer.timeout.connect(update) @@ -1842,7 +2075,16 @@ def run_pyqtgraph(args) -> None: app.aboutToQuit.connect(on_quit) try: - main_window.resize(1200, 680) + available = None + screen = app.primaryScreen() + if screen is not None: + available = screen.availableGeometry() + if available is not None: + width, height = resolve_initial_window_size(available.width(), available.height()) + else: + width, height = DEFAULT_MAIN_WINDOW_WIDTH, DEFAULT_MAIN_WINDOW_HEIGHT + main_window.resize(width, height) + splitter.setSizes([max(int(width * 0.68), width - 320), min(320, max(240, int(width * 0.32)))]) except Exception: pass main_window.show() diff --git a/rfg_adc_plotter/state/ring_buffer.py b/rfg_adc_plotter/state/ring_buffer.py index 84b13a6..426cc36 100644 --- a/rfg_adc_plotter/state/ring_buffer.py +++ b/rfg_adc_plotter/state/ring_buffer.py @@ -31,6 +31,9 @@ class RingBuffer: self.last_freqs: Optional[np.ndarray] = None self.y_min_fft: Optional[float] = None self.y_max_fft: Optional[float] = None + self.last_push_valid_points = 0 + self.last_push_fft_valid = False + self.last_push_axis_valid = False @property def is_ready(self) -> bool: @@ -55,6 +58,38 @@ class RingBuffer: self.last_freqs = None self.y_min_fft = None self.y_max_fft = None + self.last_push_valid_points = 0 + self.last_push_fft_valid = False + self.last_push_axis_valid = False + + def _promote_fft_cache(self, fft_mag: np.ndarray) -> bool: + fft_mag_arr = np.asarray(fft_mag, dtype=np.float32).reshape(-1) + if fft_mag_arr.size <= 0: + self.last_push_fft_valid = False + return False + fft_db = fft_mag_to_db(fft_mag_arr) + finite_db = fft_db[np.isfinite(fft_db)] + if finite_db.size <= 0: + self.last_push_fft_valid = False + return False + + self.last_fft_mag = fft_mag_arr.copy() + self.last_fft_db = fft_db + fr_min = float(np.min(finite_db)) + fr_max = float(np.max(finite_db)) + self.y_min_fft = fr_min if self.y_min_fft is None else min(self.y_min_fft, fr_min) + self.y_max_fft = fr_max if self.y_max_fft is None else max(self.y_max_fft, fr_max) + self.last_push_fft_valid = True + return True + + def _promote_distance_axis(self, axis: np.ndarray) -> bool: + axis_arr = np.asarray(axis, dtype=np.float64).reshape(-1) + if axis_arr.size <= 0 or not np.all(np.isfinite(axis_arr)): + self.last_push_axis_valid = False + return False + self.distance_axis = axis_arr.copy() + self.last_push_axis_valid = True + return True def ensure_init(self, sweep_width: int) -> bool: """Allocate or resize buffers. Returns True when geometry changed.""" @@ -107,6 +142,8 @@ class RingBuffer: self.fft_mode = normalized_mode self.y_min_fft = None self.y_max_fft = None + self.last_push_fft_valid = False + self.last_push_axis_valid = False if self.ring is None or self.ring_fft is None: return True @@ -131,17 +168,18 @@ class RingBuffer: self.ring_fft[row_idx, :] = fft_mag if self.last_freqs is not None: - self.distance_axis = compute_distance_axis( - self.last_freqs, - self.fft_bins, - mode=self.fft_mode, + self._promote_distance_axis( + compute_distance_axis( + self.last_freqs, + self.fft_bins, + mode=self.fft_mode, + ) ) last_idx = (self.head - 1) % self.max_sweeps if self.ring_fft.shape[0] > 0: last_fft = self.ring_fft[last_idx] - self.last_fft_mag = np.asarray(last_fft, dtype=np.float32).copy() - self.last_fft_db = fft_mag_to_db(last_fft) + self._promote_fft_cache(last_fft) finite = self.ring_fft[np.isfinite(self.ring_fft)] if finite.size > 0: finite_db = fft_mag_to_db(finite.astype(np.float32, copy=False)) @@ -170,6 +208,7 @@ class RingBuffer: row = np.full((self.width,), np.nan, dtype=np.float32) take = min(self.width, int(sweep.size)) row[:take] = np.asarray(sweep[:take], dtype=np.float32) + self.last_push_valid_points = int(np.count_nonzero(np.isfinite(row[:take]))) self.ring[self.head, :] = row self.ring_time[self.head] = time.time() if freqs is not None: @@ -183,16 +222,8 @@ class RingBuffer: fft_mag = compute_fft_mag_row(fft_source, freqs, self.fft_bins, mode=self.fft_mode) self.ring_fft[self.head, :] = fft_mag - self.last_fft_mag = np.asarray(fft_mag, dtype=np.float32).copy() - self.last_fft_db = fft_mag_to_db(fft_mag) - - if self.last_fft_db.size > 0: - fr_min = float(np.nanmin(self.last_fft_db)) - fr_max = float(np.nanmax(self.last_fft_db)) - self.y_min_fft = fr_min if self.y_min_fft is None else min(self.y_min_fft, fr_min) - self.y_max_fft = fr_max if self.y_max_fft is None else max(self.y_max_fft, fr_max) - - self.distance_axis = compute_distance_axis(freqs, self.fft_bins, mode=self.fft_mode) + self._promote_fft_cache(fft_mag) + self._promote_distance_axis(compute_distance_axis(freqs, self.fft_bins, mode=self.fft_mode)) self.head = (self.head + 1) % self.max_sweeps def get_display_raw(self) -> np.ndarray: diff --git a/tests/test_processing.py b/tests/test_processing.py index 31a2741..538eadd 100644 --- a/tests/test_processing.py +++ b/tests/test_processing.py @@ -9,9 +9,15 @@ from rfg_adc_plotter.constants import FFT_LEN, SWEEP_FREQ_MAX_GHZ, SWEEP_FREQ_MI from rfg_adc_plotter.gui.pyqtgraph_backend import ( apply_working_range, apply_working_range_to_aux_curves, + build_main_window_layout, coalesce_packets_for_ui, compute_background_subtracted_bscan_levels, decimate_curve_for_display, + resolve_axis_bounds, + resolve_heavy_refresh_stride, + resolve_initial_window_size, + sanitize_curve_data_for_display, + sanitize_image_for_display, resolve_visible_fft_curves, resolve_visible_aux_curves, ) @@ -253,6 +259,72 @@ class ProcessingTests(unittest.TestCase): self.assertEqual(len(kept), 1) self.assertEqual(int(kept[0][1]["sweep"]), 1) + def test_coalesce_packets_for_ui_switches_to_latest_only_on_large_backlog(self): + packets = [ + (np.asarray([float(idx)], dtype=np.float32), {"sweep": idx}, None) + for idx in range(40) + ] + + kept, skipped = coalesce_packets_for_ui(packets, max_packets=8, backlog_packets=40) + + self.assertEqual(skipped, 39) + self.assertEqual(len(kept), 1) + self.assertEqual(int(kept[0][1]["sweep"]), 39) + + def test_resolve_heavy_refresh_stride_increases_with_backlog(self): + self.assertEqual(resolve_heavy_refresh_stride(0, max_packets=8), 1) + self.assertEqual(resolve_heavy_refresh_stride(20, max_packets=8), 2) + self.assertEqual(resolve_heavy_refresh_stride(40, max_packets=8), 4) + + def test_sanitize_curve_data_for_display_rejects_fully_nonfinite_series(self): + xs, ys = sanitize_curve_data_for_display( + np.asarray([np.nan, np.nan], dtype=np.float64), + np.asarray([np.nan, np.nan], dtype=np.float32), + ) + + self.assertEqual(xs.shape, (0,)) + self.assertEqual(ys.shape, (0,)) + + def test_sanitize_image_for_display_rejects_fully_nonfinite_frame(self): + data = sanitize_image_for_display(np.full((4, 4), np.nan, dtype=np.float32)) + + self.assertIsNone(data) + + def test_resolve_axis_bounds_rejects_nonfinite_ranges(self): + bounds = resolve_axis_bounds(np.asarray([np.nan, np.inf], dtype=np.float64)) + + self.assertIsNone(bounds) + + def test_resolve_initial_window_size_stays_within_small_screen(self): + width, height = resolve_initial_window_size(800, 480) + + self.assertLessEqual(width, 800) + self.assertLessEqual(height, 480) + self.assertGreaterEqual(width, 640) + self.assertGreaterEqual(height, 420) + + def test_build_main_window_layout_uses_splitter_and_scroll_area(self): + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + try: + from PyQt5 import QtCore, QtWidgets + except Exception as exc: # pragma: no cover - environment-dependent + self.skipTest(f"Qt unavailable: {exc}") + + app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) + main_window = QtWidgets.QWidget() + try: + _layout, splitter, _plot_layout, settings_widget, settings_layout, settings_scroll = build_main_window_layout( + QtCore, + QtWidgets, + main_window, + ) + self.assertIsInstance(splitter, QtWidgets.QSplitter) + self.assertIsInstance(settings_scroll, QtWidgets.QScrollArea) + self.assertIs(settings_scroll.widget(), settings_widget) + self.assertIsInstance(settings_layout, QtWidgets.QVBoxLayout) + finally: + main_window.close() + def test_background_subtracted_bscan_levels_ignore_zero_floor(self): disp_fft_lin = np.zeros((4, 8), dtype=np.float32) disp_fft_lin[1, 2:6] = np.asarray([0.05, 0.1, 0.5, 2.0], dtype=np.float32) diff --git a/tests/test_ring_buffer.py b/tests/test_ring_buffer.py index 9b1e41b..b30a7ca 100644 --- a/tests/test_ring_buffer.py +++ b/tests/test_ring_buffer.py @@ -2,6 +2,8 @@ from __future__ import annotations import numpy as np import unittest +import warnings +from unittest.mock import patch from rfg_adc_plotter.processing.fft import compute_fft_mag_row from rfg_adc_plotter.state.ring_buffer import RingBuffer @@ -116,6 +118,45 @@ class RingBufferTests(unittest.TestCase): self.assertEqual(ring.width, 0) self.assertEqual(ring.head, 0) + def test_ring_buffer_push_ignores_all_nan_fft_without_runtime_warning(self): + ring = RingBuffer(max_sweeps=2) + freqs = np.linspace(3.3, 14.3, 64, dtype=np.float64) + ring.push(np.linspace(0.0, 1.0, 64, dtype=np.float32), freqs) + fft_before = ring.last_fft_db.copy() + y_min_before = ring.y_min_fft + y_max_before = ring.y_max_fft + + with warnings.catch_warnings(): + warnings.simplefilter("error", RuntimeWarning) + with patch( + "rfg_adc_plotter.state.ring_buffer.compute_fft_mag_row", + return_value=np.full((ring.fft_bins,), np.nan, dtype=np.float32), + ): + ring.push(np.linspace(1.0, 2.0, 64, dtype=np.float32), freqs) + + self.assertFalse(ring.last_push_fft_valid) + self.assertTrue(np.allclose(ring.last_fft_db, fft_before)) + self.assertEqual(ring.y_min_fft, y_min_before) + self.assertEqual(ring.y_max_fft, y_max_before) + + def test_ring_buffer_set_fft_mode_ignores_all_nan_rebuild_without_runtime_warning(self): + ring = RingBuffer(max_sweeps=2) + freqs = np.linspace(3.3, 14.3, 64, dtype=np.float64) + ring.push(np.linspace(0.0, 1.0, 64, dtype=np.float32), freqs) + fft_before = ring.last_fft_db.copy() + + with warnings.catch_warnings(): + warnings.simplefilter("error", RuntimeWarning) + with patch( + "rfg_adc_plotter.state.ring_buffer.compute_fft_mag_row", + return_value=np.full((ring.fft_bins,), np.nan, dtype=np.float32), + ): + ring.set_fft_mode("direct") + + self.assertFalse(ring.last_push_fft_valid) + self.assertTrue(np.allclose(ring.last_fft_db, fft_before)) + self.assertEqual(ring.fft_mode, "direct") + if __name__ == "__main__": unittest.main()