From 3cb3d1c31a5b7bfa8593cf0d23011767daf42356 Mon Sep 17 00:00:00 2001 From: awe Date: Tue, 14 Apr 2026 20:39:44 +0300 Subject: [PATCH] voltage range --- rfg_adc_plotter/cli.py | 12 +- rfg_adc_plotter/gui/pyqtgraph_backend.py | 139 +++++++++++++++++++---- rfg_adc_plotter/io/sweep_parser_core.py | 2 +- rfg_adc_plotter/state/runtime_state.py | 1 + tests/test_cli.py | 4 +- tests/test_processing.py | 12 ++ 6 files changed, 148 insertions(+), 22 deletions(-) diff --git a/rfg_adc_plotter/cli.py b/rfg_adc_plotter/cli.py index e3de860..0964735 100644 --- a/rfg_adc_plotter/cli.py +++ b/rfg_adc_plotter/cli.py @@ -74,7 +74,17 @@ def build_parser() -> argparse.ArgumentParser: "8-байтный бинарный протокол: либо legacy старт " "0xFFFF,0xFFFF,0xFFFF,(CH<<8)|0x0A и точки step,uint32(hi16,lo16),0x000A, " "либо tty CH1/CH2 поток из kamil_adc в формате 0x000A,step,ch1_i16,ch2_i16. " - "Для tty CH1/CH2: сырая кривая = ch1^2+ch2^2, FFT вход = ch1+i*ch2" + "Для tty CH1/CH2: после парсинга int16 переводятся в В, " + "сырая кривая = ch1^2+ch2^2 (В^2), FFT вход = ch1+i*ch2 (В)" + ), + ) + parser.add_argument( + "--tty-range-v", + type=float, + default=5.0, + help=( + "Полный диапазон для пересчета tty int16 в напряжение ±V " + "(только для --bin CH1/CH2, по умолчанию 5.0)" ), ) parser.add_argument( diff --git a/rfg_adc_plotter/gui/pyqtgraph_backend.py b/rfg_adc_plotter/gui/pyqtgraph_backend.py index 5a3a3a8..2e3480d 100644 --- a/rfg_adc_plotter/gui/pyqtgraph_backend.py +++ b/rfg_adc_plotter/gui/pyqtgraph_backend.py @@ -61,6 +61,10 @@ DEFAULT_MAIN_WINDOW_WIDTH = 1200 DEFAULT_MAIN_WINDOW_HEIGHT = 680 MIN_MAIN_WINDOW_WIDTH = 640 MIN_MAIN_WINDOW_HEIGHT = 420 +TTY_CODE_SCALE_DENOM = 32767.0 +TTY_RANGE_DEFAULT_V = 5.0 +TTY_RANGE_MIN_V = 1e-6 +TTY_RANGE_MAX_V = 1_000_000.0 def sanitize_curve_data_for_display( @@ -489,6 +493,28 @@ def compute_aux_phase_curve(aux_curves: SweepAuxCurves) -> Optional[np.ndarray]: return phase +def sanitize_tty_voltage_range(range_v: float, default: float = TTY_RANGE_DEFAULT_V) -> float: + """Return a finite positive full-scale voltage range for tty int16 conversion.""" + try: + value = float(range_v) + except Exception: + value = float(default) + if not np.isfinite(value): + value = float(default) + return float(np.clip(abs(value), TTY_RANGE_MIN_V, TTY_RANGE_MAX_V)) + + +def convert_tty_i16_to_voltage(codes: np.ndarray, range_v: float) -> np.ndarray: + """Convert signed tty int16 code array to clipped voltage values in ``[-range_v, +range_v]``.""" + code_arr = np.asarray(codes, dtype=np.float32).reshape(-1) + if code_arr.size <= 0: + return np.zeros((0,), dtype=np.float32) + range_abs_v = sanitize_tty_voltage_range(range_v) + scale_v = range_abs_v / float(TTY_CODE_SCALE_DENOM) + volt = code_arr * np.float32(scale_v) + return np.clip(volt, -range_abs_v, range_abs_v).astype(np.float32, copy=False) + + def decimate_curve_for_display( xs: Optional[np.ndarray], ys: Optional[np.ndarray], @@ -606,6 +632,7 @@ def run_pyqtgraph(args) -> None: peak_calibrate_mode = bool(getattr(args, "calibrate", False)) peak_search_enabled = bool(getattr(args, "peak_search", False)) bin_mode = bool(getattr(args, "bin_mode", False)) + tty_range_v = sanitize_tty_voltage_range(getattr(args, "tty_range_v", TTY_RANGE_DEFAULT_V)) complex_ascii_mode = bool(getattr(args, "parser_complex_ascii", False)) complex_sweep_mode = bool( bin_mode @@ -695,7 +722,7 @@ def run_pyqtgraph(args) -> None: p_line_aux_vb = pg.ViewBox() try: p_line.showAxis("right") - p_line.getAxis("right").setLabel("CH1/CH2") + p_line.getAxis("right").setLabel("CH1/CH2, В") p_line.scene().addItem(p_line_aux_vb) p_line.getAxis("right").linkToView(p_line_aux_vb) p_line_aux_vb.setXLink(p_line) @@ -718,7 +745,7 @@ def run_pyqtgraph(args) -> None: p_line.setLabel("left", "Y") if bin_iq_power_mode: try: - p_line.setLabel("left", "CH1^2 + CH2^2") + p_line.setLabel("left", "CH1^2 + CH2^2, В^2") except Exception: pass ch_text = pg.TextItem("", anchor=(1, 1)) @@ -790,7 +817,7 @@ def run_pyqtgraph(args) -> None: try: if bin_iq_power_mode: p_fft.setTitle("FFT: CH1 + i*CH2") - p_line.setTitle("Сырые CH1/CH2 и CH1^2 + CH2^2") + p_line.setTitle("Сырые CH1/CH2 (В) и CH1^2 + CH2^2 (В^2)") else: p_fft.setTitle("FFT: Re / Im / Abs") p_line.setTitle("Сырые данные до FFT") @@ -820,7 +847,7 @@ def run_pyqtgraph(args) -> None: curve_complex_calib_real = p_complex_calib.plot(pen=pg.mkPen((80, 120, 255), width=1)) curve_complex_calib_imag = p_complex_calib.plot(pen=pg.mkPen((120, 200, 120), width=1)) p_complex_calib.setLabel("bottom", "ГГц") - p_complex_calib.setLabel("left", "Амплитуда") + p_complex_calib.setLabel("left", "В" if bin_iq_power_mode else "Амплитуда") try: p_complex_calib.setXLink(p_line) p_complex_calib.setVisible(bool(complex_sweep_mode)) @@ -945,10 +972,28 @@ def run_pyqtgraph(args) -> None: background_buttons_row.addWidget(background_save_btn) background_buttons_row.addWidget(background_load_btn) background_group_layout.addLayout(background_buttons_row) + tty_range_group = QtWidgets.QGroupBox("TTY диапазон") + tty_range_layout = QtWidgets.QFormLayout(tty_range_group) + tty_range_layout.setContentsMargins(6, 6, 6, 6) + tty_range_layout.setSpacing(6) + tty_range_spin = QtWidgets.QDoubleSpinBox() + tty_range_spin.setDecimals(6) + tty_range_spin.setRange(TTY_RANGE_MIN_V, TTY_RANGE_MAX_V) + tty_range_spin.setSingleStep(0.1) + try: + tty_range_spin.setSuffix(" V") + except Exception: + pass + tty_range_spin.setValue(tty_range_v) + tty_range_layout.addRow("±V", tty_range_spin) + try: + tty_range_group.setEnabled(bool(bin_iq_power_mode)) + except Exception: + pass parsed_data_cb = QtWidgets.QCheckBox("данные после парсинга") if complex_sweep_mode: try: - parsed_data_cb.setText("Сырые CH1/CH2" if bin_iq_power_mode else "Сырые Re/Im") + parsed_data_cb.setText("Сырые CH1/CH2 (В)" if bin_iq_power_mode else "Сырые Re/Im") parsed_data_cb.setChecked(True) except Exception: pass @@ -978,6 +1023,7 @@ def run_pyqtgraph(args) -> None: settings_layout.addWidget(range_group) settings_layout.addWidget(calib_group) settings_layout.addWidget(complex_calib_group) + settings_layout.addWidget(tty_range_group) settings_layout.addWidget(parsed_data_cb) settings_layout.addWidget(background_group) settings_layout.addWidget(fft_mode_label) @@ -1005,6 +1051,7 @@ def run_pyqtgraph(args) -> None: status_dirty = True calibration_toggle_in_progress = False range_change_in_progress = False + tty_range_change_in_progress = False debug_event_counts: Dict[str, int] = {} last_queue_backlog = 0 last_backlog_skipped = 0 @@ -1164,6 +1211,27 @@ def run_pyqtgraph(args) -> None: path = "" return path or "fft_background.npy" + def rebuild_tty_voltage_curves_from_codes() -> bool: + if (not bin_iq_power_mode) or runtime.full_current_aux_curves_codes is None: + return False + try: + code_1, code_2 = runtime.full_current_aux_curves_codes + except Exception: + return False + code_1_arr = np.asarray(code_1, dtype=np.float32).reshape(-1) + code_2_arr = np.asarray(code_2, dtype=np.float32).reshape(-1) + width = min(code_1_arr.size, code_2_arr.size) + if width <= 0: + return False + ch_1_v = convert_tty_i16_to_voltage(code_1_arr[:width], tty_range_v) + ch_2_v = convert_tty_i16_to_voltage(code_2_arr[:width], tty_range_v) + runtime.full_current_aux_curves = (ch_1_v, ch_2_v) + runtime.full_current_fft_source = ch_1_v.astype(np.complex64) + (1j * ch_2_v.astype(np.complex64)) + ch_1_v_f64 = ch_1_v.astype(np.float64, copy=False) + ch_2_v_f64 = ch_2_v.astype(np.float64, copy=False) + runtime.full_current_sweep_raw = np.asarray((ch_1_v_f64 * ch_1_v_f64) + (ch_2_v_f64 * ch_2_v_f64), dtype=np.float32) + return True + def reset_background_state(*, clear_profile: bool = True) -> None: runtime.background_buffer.reset() if clear_profile: @@ -1435,6 +1503,33 @@ def run_pyqtgraph(args) -> None: set_status_note(f"диапазон: {new_min:.6g}..{new_max:.6g} GHz") runtime.mark_dirty() + def set_tty_range() -> None: + nonlocal tty_range_v, tty_range_change_in_progress + if tty_range_change_in_progress: + return + if not bin_iq_power_mode: + return + try: + requested_v = float(tty_range_spin.value()) + except Exception: + requested_v = tty_range_v + new_range_v = sanitize_tty_voltage_range(requested_v, default=tty_range_v) + if np.isclose(new_range_v, tty_range_v, rtol=0.0, atol=1e-12): + return + tty_range_v = new_range_v + tty_range_change_in_progress = True + try: + tty_range_spin.setValue(tty_range_v) + finally: + tty_range_change_in_progress = False + if rebuild_tty_voltage_curves_from_codes(): + reset_background_state(clear_profile=True) + refresh_current_window(push_to_ring=True, reset_ring=True) + set_status_note(f"tty диапазон: ±{tty_range_v:.6g} В") + else: + set_status_note(f"tty диапазон: ±{tty_range_v:.6g} В (ожидание данных)") + runtime.mark_dirty() + def pick_calib_file() -> None: start_path = get_calib_file_path() try: @@ -1687,6 +1782,7 @@ def run_pyqtgraph(args) -> None: except Exception: pass restore_range_controls() + set_tty_range() set_calib_enabled() set_complex_calib_enabled() set_parsed_data_enabled() @@ -1698,6 +1794,7 @@ def run_pyqtgraph(args) -> None: try: range_min_spin.valueChanged.connect(lambda _v: set_working_range()) range_max_spin.valueChanged.connect(lambda _v: set_working_range()) + tty_range_spin.valueChanged.connect(lambda _v: set_tty_range()) calib_cb.stateChanged.connect(lambda _v: set_calib_enabled()) complex_calib_cb.stateChanged.connect(lambda _v: set_complex_calib_enabled()) parsed_data_cb.stateChanged.connect(lambda _v: set_parsed_data_enabled()) @@ -1935,6 +2032,7 @@ def run_pyqtgraph(args) -> None: ) 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_aux_curves_codes = None runtime.full_current_fft_source = None if complex_sweep_mode and aux_curves is not None: try: @@ -1944,21 +2042,20 @@ def run_pyqtgraph(args) -> None: runtime.full_current_freqs = np.asarray(calibrated_aux_1_payload["F"], dtype=np.float64) calibrated_aux_1 = np.asarray(calibrated_aux_1_payload["I"], dtype=np.float32) calibrated_aux_2 = np.asarray(calibrated_aux_2, dtype=np.float32) - runtime.full_current_aux_curves = (calibrated_aux_1, calibrated_aux_2) - runtime.full_current_fft_source = ( - calibrated_aux_1.astype(np.complex64) + (1j * calibrated_aux_2.astype(np.complex64)) - ) if bin_iq_power_mode: - aux_1_f64 = calibrated_aux_1.astype(np.float64, copy=False) - aux_2_f64 = calibrated_aux_2.astype(np.float64, copy=False) - runtime.full_current_sweep_raw = np.asarray( - (aux_1_f64 * aux_1_f64) + (aux_2_f64 * aux_2_f64), - dtype=np.float32, - ) + runtime.full_current_aux_curves_codes = (calibrated_aux_1, calibrated_aux_2) + if not rebuild_tty_voltage_curves_from_codes(): + runtime.full_current_aux_curves = None + runtime.full_current_fft_source = None else: + runtime.full_current_aux_curves = (calibrated_aux_1, calibrated_aux_2) + runtime.full_current_fft_source = ( + calibrated_aux_1.astype(np.complex64) + (1j * calibrated_aux_2.astype(np.complex64)) + ) runtime.full_current_sweep_raw = np.abs(runtime.full_current_fft_source).astype(np.float32) except Exception: runtime.full_current_aux_curves = None + runtime.full_current_aux_curves_codes = None runtime.full_current_fft_source = None if runtime.full_current_fft_source is None: @@ -1976,12 +2073,16 @@ def run_pyqtgraph(args) -> None: aux_1, aux_2 = aux_curves calibrated_aux_1 = calibrate_freqs({"F": base_freqs, "I": aux_1})["I"] calibrated_aux_2 = calibrate_freqs({"F": base_freqs, "I": aux_2})["I"] - runtime.full_current_aux_curves = ( - np.asarray(calibrated_aux_1, dtype=np.float32), - np.asarray(calibrated_aux_2, dtype=np.float32), - ) + calibrated_aux_1_arr = np.asarray(calibrated_aux_1, dtype=np.float32) + calibrated_aux_2_arr = np.asarray(calibrated_aux_2, dtype=np.float32) + if bin_iq_power_mode: + runtime.full_current_aux_curves_codes = (calibrated_aux_1_arr, calibrated_aux_2_arr) + rebuild_tty_voltage_curves_from_codes() + else: + runtime.full_current_aux_curves = (calibrated_aux_1_arr, calibrated_aux_2_arr) except Exception: runtime.full_current_aux_curves = None + runtime.full_current_aux_curves_codes = None runtime.current_info = info refresh_current_window(push_to_ring=True) processed_frames += 1 diff --git a/rfg_adc_plotter/io/sweep_parser_core.py b/rfg_adc_plotter/io/sweep_parser_core.py index fbb52c1..89bb71f 100644 --- a/rfg_adc_plotter/io/sweep_parser_core.py +++ b/rfg_adc_plotter/io/sweep_parser_core.py @@ -36,7 +36,7 @@ def tty_ch_pair_to_sweep(ch_1: int, ch_2: int) -> float: """Reduce a raw CH1/CH2 TTY point to power-like scalar ``ch1^2 + ch2^2``.""" ch_1_i = int(ch_1) ch_2_i = int(ch_2) - return float(((ch_1_i * ch_1_i) + (ch_2_i * ch_2_i))**0.5) + return float((ch_1_i * ch_1_i) + (ch_2_i * ch_2_i)) class AsciiSweepParser: diff --git a/rfg_adc_plotter/state/runtime_state.py b/rfg_adc_plotter/state/runtime_state.py index 3960161..84de51d 100644 --- a/rfg_adc_plotter/state/runtime_state.py +++ b/rfg_adc_plotter/state/runtime_state.py @@ -22,6 +22,7 @@ class RuntimeState: full_current_sweep_raw: Optional[np.ndarray] = None full_current_fft_source: Optional[np.ndarray] = None full_current_aux_curves: SweepAuxCurves = None + full_current_aux_curves_codes: SweepAuxCurves = None current_freqs: Optional[np.ndarray] = None current_distances: Optional[np.ndarray] = None current_sweep_raw: Optional[np.ndarray] = None diff --git a/tests/test_cli.py b/tests/test_cli.py index 78b666d..dcd4984 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -26,10 +26,12 @@ class CliTests(unittest.TestCase): args = build_parser().parse_args(["/dev/null"]) self.assertFalse(args.logscale) self.assertFalse(args.opengl) + self.assertAlmostEqual(float(args.tty_range_v), 5.0, places=6) - args_log = build_parser().parse_args(["/dev/null", "--logscale", "--opengl"]) + args_log = build_parser().parse_args(["/dev/null", "--logscale", "--opengl", "--tty-range-v", "2.5"]) self.assertTrue(args_log.logscale) self.assertTrue(args_log.opengl) + self.assertAlmostEqual(float(args_log.tty_range_v), 2.5, places=6) def test_wrapper_help_works(self): proc = _run("RFG_ADC_dataplotter.py", "--help") diff --git a/tests/test_processing.py b/tests/test_processing.py index b19955b..a3038dc 100644 --- a/tests/test_processing.py +++ b/tests/test_processing.py @@ -14,6 +14,7 @@ from rfg_adc_plotter.gui.pyqtgraph_backend import ( coalesce_packets_for_ui, compute_background_subtracted_bscan_levels, compute_aux_phase_curve, + convert_tty_i16_to_voltage, decimate_curve_for_display, resolve_axis_bounds, resolve_heavy_refresh_stride, @@ -62,6 +63,17 @@ from rfg_adc_plotter.processing.peaks import find_peak_width_markers, find_top_p class ProcessingTests(unittest.TestCase): + def test_convert_tty_i16_to_voltage_maps_and_clips_full_range(self): + codes = np.asarray([-32768.0, -16384.0, 0.0, 16384.0, 32767.0], dtype=np.float32) + volts = convert_tty_i16_to_voltage(codes, 5.0) + + self.assertEqual(volts.shape, codes.shape) + self.assertAlmostEqual(float(volts[0]), -5.0, places=6) + self.assertAlmostEqual(float(volts[2]), 0.0, places=6) + self.assertAlmostEqual(float(volts[-1]), 5.0, places=6) + self.assertTrue(np.all(volts >= -5.0)) + self.assertTrue(np.all(volts <= 5.0)) + def test_recalculate_calibration_preserves_requested_edges(self): coeffs = recalculate_calibration_c(np.asarray([0.0, 1.0, 0.025], dtype=np.float64), 3.3, 14.3) y0 = coeffs[0] + coeffs[1] * 3.3 + coeffs[2] * (3.3 ** 2)