diff --git a/rfg_adc_plotter/gui/pyqtgraph_backend.py b/rfg_adc_plotter/gui/pyqtgraph_backend.py index 64b07a1..40cb62b 100644 --- a/rfg_adc_plotter/gui/pyqtgraph_backend.py +++ b/rfg_adc_plotter/gui/pyqtgraph_backend.py @@ -48,6 +48,8 @@ UI_BACKLOG_LATEST_ONLY_THRESHOLD_MULTIPLIER = 4 UI_HEAVY_REFRESH_BACKLOG_MULTIPLIER = 2 UI_HEAVY_REFRESH_MAX_STRIDE = 4 UI_DATA_WAIT_NOTE_AFTER_S = 3.0 +FFT_LOW_CUT_SLIDER_SCALE = 10 +FFT_LOW_CUT_MAX_PERCENT = 99.0 DEFAULT_MAIN_WINDOW_WIDTH = 1200 DEFAULT_MAIN_WINDOW_HEIGHT = 680 MIN_MAIN_WINDOW_WIDTH = 640 @@ -397,6 +399,57 @@ def apply_working_range_to_signal( return np.asarray(signal_arr[valid], dtype=np.float32) +def resolve_distance_cut_start( + distance_axis: Optional[np.ndarray], + cut_percent: float, +) -> Optional[float]: + """Return distance threshold for hiding the beginning of FFT/B-scan axis.""" + if distance_axis is None: + return None + axis_arr = np.asarray(distance_axis, dtype=np.float64).reshape(-1) + finite = axis_arr[np.isfinite(axis_arr)] + if finite.size <= 0: + return None + d_min = float(np.min(finite)) + d_max = float(np.max(finite)) + if not (np.isfinite(d_min) and np.isfinite(d_max)): + return None + if d_max <= d_min: + return d_min + + pct = float(np.clip(float(cut_percent), 0.0, FFT_LOW_CUT_MAX_PERCENT)) + start = d_min + (d_max - d_min) * (pct / 100.0) + if start >= d_max: + start = float(np.nextafter(d_max, d_min)) + return float(start) + + +def apply_distance_cut_to_axis( + distance_axis: Optional[np.ndarray], + min_distance_m: Optional[float], +) -> Tuple[np.ndarray, np.ndarray]: + """Apply distance threshold and return ``(cropped_axis, keep_mask)``.""" + if distance_axis is None: + return np.zeros((0,), dtype=np.float64), np.zeros((0,), dtype=bool) + axis_arr = np.asarray(distance_axis, dtype=np.float64).reshape(-1) + if axis_arr.size <= 0: + return np.zeros((0,), dtype=np.float64), np.zeros((0,), dtype=bool) + + finite = np.isfinite(axis_arr) + if min_distance_m is None or not np.isfinite(min_distance_m): + keep = finite + else: + keep = finite & (axis_arr >= float(min_distance_m)) + + if not np.any(keep) and np.any(finite): + # Keep the farthest finite point so the view never becomes completely empty. + keep = np.zeros((axis_arr.size,), dtype=bool) + last_idx = int(np.flatnonzero(finite)[-1]) + keep[last_idx] = True + + return axis_arr[keep], keep + + def resolve_visible_aux_curves(aux_curves: SweepAuxCurves, enabled: bool) -> SweepAuxCurves: """Return auxiliary curves only when their display is enabled.""" if (not enabled) or aux_curves is None: @@ -753,6 +806,16 @@ def run_pyqtgraph(args) -> None: fft_mode_combo.addItem("Симметричный", "symmetric") fft_mode_combo.addItem("Нули [-max,+min]", "positive_only") fft_mode_combo.addItem("Нули [-max,+min] точный", "positive_only_exact") + fft_low_cut_label = QtWidgets.QLabel("Срез начала FFT/B-scan") + fft_low_cut_slider = QtWidgets.QSlider(QtCore.Qt.Horizontal) + fft_low_cut_slider.setRange(0, int(FFT_LOW_CUT_MAX_PERCENT * FFT_LOW_CUT_SLIDER_SCALE)) + fft_low_cut_slider.setValue(0) + fft_low_cut_value_label = QtWidgets.QLabel("0.0%") + fft_low_cut_row = QtWidgets.QHBoxLayout() + fft_low_cut_row.setContentsMargins(0, 0, 0, 0) + fft_low_cut_row.setSpacing(6) + fft_low_cut_row.addWidget(fft_low_cut_slider) + fft_low_cut_row.addWidget(fft_low_cut_value_label) peak_search_cb = QtWidgets.QCheckBox("поиск пиков") calib_group = QtWidgets.QGroupBox("Калибровка") calib_group_layout = QtWidgets.QVBoxLayout(calib_group) @@ -842,6 +905,8 @@ def run_pyqtgraph(args) -> None: settings_layout.addWidget(background_group) settings_layout.addWidget(fft_mode_label) settings_layout.addWidget(fft_mode_combo) + settings_layout.addWidget(fft_low_cut_label) + settings_layout.addLayout(fft_low_cut_row) settings_layout.addWidget(fft_curve_group) settings_layout.addWidget(peak_search_cb) @@ -855,6 +920,7 @@ def run_pyqtgraph(args) -> None: fft_real_enabled = True fft_imag_enabled = True fft_mode = "symmetric" + fft_low_cut_percent = 0.0 status_note = "" waiting_data_note = "" status_note_expires_at: Optional[float] = None @@ -897,6 +963,24 @@ def run_pyqtgraph(args) -> None: 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 _active_distance_axis() -> Optional[np.ndarray]: + if runtime.current_distances is not None and runtime.current_distances.size > 0: + return runtime.current_distances + return runtime.ring.distance_axis + + def _active_distance_cut_start() -> Optional[float]: + return resolve_distance_cut_start(_active_distance_axis(), fft_low_cut_percent) + + def refresh_fft_low_cut_label() -> None: + text = f"{fft_low_cut_percent:.1f}%" + cut_start = _active_distance_cut_start() + if cut_start is not None and np.isfinite(cut_start): + text = f"{text} (~{cut_start:.4g} м)" + try: + fft_low_cut_value_label.setText(text) + except Exception: + pass + def update_physical_axes() -> None: freq_bounds = resolve_axis_bounds(runtime.current_freqs) if freq_bounds is None: @@ -912,8 +996,14 @@ def run_pyqtgraph(args) -> None: distance_bounds = resolve_axis_bounds(runtime.ring.distance_axis) if distance_bounds is not None: d_min, d_max = distance_bounds - set_image_rect_if_ready(img_fft, 0.0, d_min, float(max_sweeps), d_max - d_min) + d_cut = _active_distance_cut_start() + if d_cut is not None and np.isfinite(d_cut): + d_min = max(float(d_min), float(d_cut)) + span = max(1e-9, float(d_max - d_min)) + set_image_rect_if_ready(img_fft, 0.0, d_min, float(max_sweeps), span) p_spec.setRange(xRange=(0, max_sweeps - 1), yRange=(d_min, d_max), padding=0) + p_fft.setXRange(d_min, d_max, padding=0) + refresh_fft_low_cut_label() def resolve_curve_xs(size: int) -> np.ndarray: if size <= 0: @@ -1341,6 +1431,17 @@ def run_pyqtgraph(args) -> None: set_status_note("фон: профиль не загружен") runtime.mark_dirty() + def set_fft_low_cut_percent() -> None: + nonlocal fft_low_cut_percent + try: + fft_low_cut_percent = float(fft_low_cut_slider.value()) / float(FFT_LOW_CUT_SLIDER_SCALE) + except Exception: + fft_low_cut_percent = 0.0 + fft_low_cut_percent = float(np.clip(fft_low_cut_percent, 0.0, FFT_LOW_CUT_MAX_PERCENT)) + refresh_fft_low_cut_label() + update_physical_axes() + runtime.mark_dirty() + def set_fft_mode() -> None: nonlocal fft_mode try: @@ -1371,6 +1472,7 @@ def run_pyqtgraph(args) -> None: set_background_enabled() set_fft_curve_visibility() set_fft_mode() + set_fft_low_cut_percent() try: range_min_spin.valueChanged.connect(lambda _v: set_working_range()) @@ -1385,6 +1487,7 @@ def run_pyqtgraph(args) -> None: background_save_btn.clicked.connect(lambda _checked=False: save_current_background()) background_load_btn.clicked.connect(lambda _checked=False: load_background_file()) fft_mode_combo.currentIndexChanged.connect(lambda _v: set_fft_mode()) + fft_low_cut_slider.valueChanged.connect(lambda _v: set_fft_low_cut_percent()) fft_abs_cb.stateChanged.connect(lambda _v: set_fft_curve_visibility()) fft_real_cb.stateChanged.connect(lambda _v: set_fft_curve_visibility()) fft_imag_cb.stateChanged.connect(lambda _v: set_fft_curve_visibility()) @@ -1809,7 +1912,7 @@ def run_pyqtgraph(args) -> None: refresh_current_fft_cache(sweep_for_fft, distance_axis.size) fft_mag = runtime.current_fft_mag fft_complex = runtime.current_fft_complex - xs_fft = distance_axis[: fft_mag.size] + xs_fft = np.asarray(distance_axis[: fft_mag.size], dtype=np.float64) active_background = None try: active_background = resolve_active_background(fft_mag.size) @@ -1823,17 +1926,29 @@ def run_pyqtgraph(args) -> None: set_status_note(f"фон: не удалось применить ({exc})") active_background = None display_fft_mag = fft_mag + fft_mag_plot = np.asarray(display_fft_mag[: xs_fft.size], dtype=np.float32).reshape(-1) + fft_complex_plot = None + if fft_complex is not None: + fft_complex_plot = np.asarray(fft_complex[: xs_fft.size], dtype=np.complex64).reshape(-1) + fft_cut_start = _active_distance_cut_start() + xs_fft, fft_keep_mask = apply_distance_cut_to_axis(xs_fft, fft_cut_start) + if fft_keep_mask.size > 0: + fft_mag_plot = fft_mag_plot[fft_keep_mask] + if fft_complex_plot is not None and fft_complex_plot.size == fft_keep_mask.size: + fft_complex_plot = fft_complex_plot[fft_keep_mask] + elif fft_complex_plot is not None: + fft_complex_plot = None 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) + fft_vals_db = fft_mag_to_db(fft_mag_plot) ref_curve_for_range = None if complex_sweep_mode: visible_abs, visible_real, visible_imag = resolve_visible_fft_curves( - fft_complex, - display_fft_mag, + fft_complex_plot, + fft_mag_plot, complex_mode=True, show_abs=fft_abs_enabled, show_real=fft_real_enabled, @@ -2099,6 +2214,17 @@ def run_pyqtgraph(args) -> None: ) else: disp_fft_lin = runtime.ring.get_display_fft_linear() + disp_fft_axis = runtime.ring.distance_axis + if disp_fft_axis is not None: + axis_arr = np.asarray(disp_fft_axis, dtype=np.float64).reshape(-1) + row_take = min(axis_arr.size, disp_fft_lin.shape[0]) + axis_arr = axis_arr[:row_take] + disp_fft_lin = disp_fft_lin[:row_take, :] + fft_cut_start = _active_distance_cut_start() + axis_arr, keep_mask = apply_distance_cut_to_axis(axis_arr, fft_cut_start) + if keep_mask.size > 0: + disp_fft_lin = disp_fft_lin[keep_mask, :] + disp_fft_axis = axis_arr if spec_mean_sec > 0.0: disp_times = runtime.ring.get_display_times() if disp_times is not None: @@ -2159,6 +2285,11 @@ def run_pyqtgraph(args) -> None: and runtime.ring.y_min_fft != runtime.ring.y_max_fft ): levels = (runtime.ring.y_min_fft, runtime.ring.y_max_fft) + distance_bounds = resolve_axis_bounds(disp_fft_axis) + if distance_bounds is not None: + d_min, d_max = distance_bounds + set_image_rect_if_ready(img_fft, 0.0, d_min, float(max_sweeps), max(1e-9, d_max - d_min)) + p_spec.setRange(xRange=(0, max_sweeps - 1), yRange=(d_min, d_max), padding=0) if levels is not None: img_fft.setImage(disp_fft, autoLevels=False, levels=levels) else: diff --git a/tests/test_processing.py b/tests/test_processing.py index cd667bf..88ee0d7 100644 --- a/tests/test_processing.py +++ b/tests/test_processing.py @@ -7,6 +7,7 @@ import unittest from rfg_adc_plotter.constants import C_M_S, FFT_LEN, SWEEP_FREQ_MAX_GHZ, SWEEP_FREQ_MIN_GHZ from rfg_adc_plotter.gui.pyqtgraph_backend import ( + apply_distance_cut_to_axis, apply_working_range, apply_working_range_to_aux_curves, build_main_window_layout, @@ -16,6 +17,7 @@ from rfg_adc_plotter.gui.pyqtgraph_backend import ( resolve_axis_bounds, resolve_heavy_refresh_stride, resolve_initial_window_size, + resolve_distance_cut_start, sanitize_curve_data_for_display, sanitize_image_for_display, set_image_rect_if_ready, @@ -317,6 +319,22 @@ class ProcessingTests(unittest.TestCase): self.assertIsNone(bounds) + def test_resolve_distance_cut_start_interpolates_with_percent(self): + axis = np.asarray([0.0, 1.0, 2.0, 3.0], dtype=np.float64) + cut_start = resolve_distance_cut_start(axis, 50.0) + + self.assertIsNotNone(cut_start) + self.assertAlmostEqual(float(cut_start), 1.5, places=6) + + def test_apply_distance_cut_to_axis_keeps_farthest_point_for_extreme_cut(self): + axis = np.asarray([0.0, 1.0, 2.0, 3.0], dtype=np.float64) + cut_axis, keep_mask = apply_distance_cut_to_axis(axis, 10.0) + + self.assertEqual(cut_axis.shape, (1,)) + self.assertEqual(keep_mask.shape, axis.shape) + self.assertTrue(bool(keep_mask[-1])) + self.assertAlmostEqual(float(cut_axis[0]), 3.0, places=6) + def test_resolve_initial_window_size_stays_within_small_screen(self): width, height = resolve_initial_window_size(800, 480)