diff --git a/calib_envelope.npy b/calib_envelope.npy index 394f3f9..9e96279 100644 Binary files a/calib_envelope.npy and b/calib_envelope.npy differ diff --git a/rfg_adc_plotter/gui/matplotlib_backend.py b/rfg_adc_plotter/gui/matplotlib_backend.py index 0c94c5a..161a9a9 100644 --- a/rfg_adc_plotter/gui/matplotlib_backend.py +++ b/rfg_adc_plotter/gui/matplotlib_backend.py @@ -112,6 +112,10 @@ def run_matplotlib(args): state = AppState(norm_type=norm_type) state.configure_capture_import(fancy=bool(args.fancy), logscale=bool(getattr(args, "logscale", False))) ring = RingBuffer(max_sweeps) + try: + ring.set_fft_complex_mode(str(getattr(args, "ifft_complex_mode", "arccos"))) + except Exception: + pass # --- Создание фигуры --- fig, axs = plt.subplots(2, 2, figsize=(12, 8)) @@ -181,17 +185,28 @@ def run_matplotlib(args): ax_cb = fig.add_axes([0.92, 0.45, 0.08, 0.08]) ax_cb_file = fig.add_axes([0.92, 0.36, 0.08, 0.08]) ax_line_mode = fig.add_axes([0.92, 0.10, 0.08, 0.08]) + ax_ifft_mode = fig.add_axes([0.92, 0.01, 0.08, 0.08]) ymin_slider = Slider(ax_smin, "Y min", 0, max(1, fft_bins - 1), valinit=0, valstep=1, orientation="vertical") ymax_slider = Slider(ax_smax, "Y max", 0, max(1, fft_bins - 1), valinit=max(1, fft_bins - 1), valstep=1, orientation="vertical") contrast_slider = Slider(ax_sctr, "Int max", 0, 100, valinit=100, valstep=1, orientation="vertical") calib_cb = CheckButtons(ax_cb, ["калибровка"], [False]) calib_file_cb = CheckButtons(ax_cb_file, ["из файла"], [False]) line_mode_rb = RadioButtons(ax_line_mode, ("raw", "processed"), active=0) + ifft_mode_rb = RadioButtons( + ax_ifft_mode, + ("arccos", "diff"), + active=(1 if ring.fft_complex_mode == "diff" else 0), + ) try: ax_line_mode.set_title("Линия", fontsize=8, pad=2) except Exception: pass + try: + ax_ifft_mode.set_title("IFFT", fontsize=8, pad=2) + except Exception: + pass line_mode_state = {"value": "raw"} + ifft_mode_state = {"value": str(ring.fft_complex_mode)} import os as _os try: @@ -280,7 +295,7 @@ def run_matplotlib(args): pass def _refresh_status_texts(): - pipeline_text.set_text(state.format_pipeline_status()) + pipeline_text.set_text(f"{state.format_pipeline_status()} | cplx:{ring.fft_complex_mode}") ref_text.set_text(state.format_reference_status()) try: fig.canvas.draw_idle() @@ -414,6 +429,18 @@ def run_matplotlib(args): except Exception: pass + def _on_ifft_mode_clicked(label): + ifft_mode_state["value"] = str(label) + try: + ring.set_fft_complex_mode(str(label)) + except Exception: + pass + _refresh_status_texts() + try: + fig.canvas.draw_idle() + except Exception: + pass + save_bg_btn.on_clicked(_on_save_bg) bg_cb.on_clicked(_on_bg_clicked) calib_load_btn2.on_clicked(_on_calib_load_btn) @@ -425,6 +452,7 @@ def run_matplotlib(args): bg_sample_btn2.on_clicked(_on_bg_sample_btn) bg_save_btn2.on_clicked(_on_bg_save_btn2) line_mode_rb.on_clicked(_on_line_mode_clicked) + ifft_mode_rb.on_clicked(_on_ifft_mode_clicked) ymin_slider.on_changed(_on_ylim_change) ymax_slider.on_changed(_on_ylim_change) @@ -437,6 +465,7 @@ def run_matplotlib(args): except Exception: calib_cb = None line_mode_state = {"value": "raw"} + ifft_mode_state = {"value": str(getattr(ring, "fft_complex_mode", "arccos"))} FREQ_MIN = float(FREQ_MIN_GHZ) FREQ_MAX = float(FREQ_MAX_GHZ) @@ -602,10 +631,10 @@ def run_matplotlib(args): if changed and state.current_info: status_text.set_text(format_status(state.current_info)) channel_text.set_text(state.format_channel_label()) - pipeline_text.set_text(state.format_pipeline_status()) + pipeline_text.set_text(f"{state.format_pipeline_status()} | cplx:{ring.fft_complex_mode}") ref_text.set_text(state.format_reference_status()) elif changed: - pipeline_text.set_text(state.format_pipeline_status()) + pipeline_text.set_text(f"{state.format_pipeline_status()} | cplx:{ring.fft_complex_mode}") ref_text.set_text(state.format_reference_status()) return ( diff --git a/rfg_adc_plotter/gui/pyqtgraph_backend.py b/rfg_adc_plotter/gui/pyqtgraph_backend.py index cff7d7f..c164d7f 100644 --- a/rfg_adc_plotter/gui/pyqtgraph_backend.py +++ b/rfg_adc_plotter/gui/pyqtgraph_backend.py @@ -140,6 +140,10 @@ def run_pyqtgraph(args): state = AppState(norm_type=norm_type) state.configure_capture_import(fancy=bool(args.fancy), logscale=bool(getattr(args, "logscale", False))) ring = RingBuffer(max_sweeps) + try: + ring.set_fft_complex_mode(str(getattr(args, "ifft_complex_mode", "arccos"))) + except Exception: + pass try: _qt_text_selectable = QtCore.Qt.TextSelectableByMouse @@ -445,6 +449,25 @@ def run_pyqtgraph(args): def _line_mode() -> str: return "processed" if line_mode_proc_rb.isChecked() else "raw" + # Переключатель режима реконструкции комплексного спектра перед IFFT + ifft_mode_widget = QtWidgets.QWidget() + ifft_mode_layout = QtWidgets.QHBoxLayout(ifft_mode_widget) + ifft_mode_layout.setContentsMargins(2, 2, 2, 2) + ifft_mode_layout.setSpacing(8) + ifft_mode_layout.addWidget(QtWidgets.QLabel("IFFT mode:")) + ifft_mode_arccos_rb = QtWidgets.QRadioButton("arccos") + ifft_mode_diff_rb = QtWidgets.QRadioButton("diff") + if ring.fft_complex_mode == "diff": + ifft_mode_diff_rb.setChecked(True) + else: + ifft_mode_arccos_rb.setChecked(True) + ifft_mode_layout.addWidget(ifft_mode_arccos_rb) + ifft_mode_layout.addWidget(ifft_mode_diff_rb) + ifft_mode_layout.addStretch(1) + ifft_mode_proxy = QtWidgets.QGraphicsProxyWidget() + ifft_mode_proxy.setWidget(ifft_mode_widget) + win.addItem(ifft_mode_proxy, row=7, col=0, colspan=2) + # Статусная строка status = pg.LabelItem(justify="left") win.addItem(status, row=3, col=0, colspan=2) @@ -455,12 +478,32 @@ def run_pyqtgraph(args): def _refresh_pipeline_label(): txt = state.format_pipeline_status() + txt = f"{txt} | cplx:{ring.fft_complex_mode}" trace = state.format_stage_trace() if trace: txt = f"{txt} | trace: {trace}" pipeline_status.setText(txt) ref_status.setText(state.format_reference_status()) + def _apply_ifft_complex_mode(mode: str): + try: + changed = ring.set_fft_complex_mode(mode) + except Exception: + changed = False + if changed: + try: + curve_fft.setData([], []) + except Exception: + pass + _refresh_pipeline_label() + + ifft_mode_arccos_rb.toggled.connect( + lambda checked: _apply_ifft_complex_mode("arccos") if checked else None + ) + ifft_mode_diff_rb.toggled.connect( + lambda checked: _apply_ifft_complex_mode("diff") if checked else None + ) + _refresh_calib_controls() _refresh_bg_controls() _refresh_pipeline_label() diff --git a/rfg_adc_plotter/main.py b/rfg_adc_plotter/main.py index 059b3d4..14c888a 100755 --- a/rfg_adc_plotter/main.py +++ b/rfg_adc_plotter/main.py @@ -77,6 +77,15 @@ def build_parser() -> argparse.ArgumentParser: default="projector", help="Тип нормировки: projector (по огибающим в [-1000,+1000]) или simple (raw/calib)", ) + parser.add_argument( + "--ifft-complex-mode", + choices=["arccos", "diff"], + default="arccos", + help=( + "Режим реконструкции комплексного спектра перед IFFT: " + "arccos (phi=arccos(x), unwrap) или diff (sin(phi) через численную производную)" + ), + ) parser.add_argument( "--bin", dest="bin_mode", diff --git a/rfg_adc_plotter/processing/fourier.py b/rfg_adc_plotter/processing/fourier.py index 48b37b4..8dc3a2c 100644 --- a/rfg_adc_plotter/processing/fourier.py +++ b/rfg_adc_plotter/processing/fourier.py @@ -1,12 +1,8 @@ """Преобразование свипа в IFFT-профиль по глубине (м). -Новый pipeline перед IFFT: -1) нормировка по max(abs(sweep)) -2) clip в [-1, 1] -3) phi = arccos(x) -4) непрерывная развёртка фазы (nearest-continuous) -5) s_complex = exp(1j * phi) -6) IFFT с учётом смещения частотной сетки +Поддерживает несколько режимов восстановления комплексного спектра перед IFFT: +- ``arccos``: phi = arccos(x), continuous unwrap, z = exp(1j*phi) +- ``diff``: x ~= cos(phi), diff(x) -> sin(phi), z = cos + 1j*sin (с проекцией на единичную окружность) """ from __future__ import annotations @@ -29,6 +25,7 @@ logger = logging.getLogger(__name__) _EPS = 1e-12 _TWO_PI = float(2.0 * np.pi) +_VALID_COMPLEX_MODES = {"arccos", "diff"} def _fallback_depth_response( @@ -58,6 +55,13 @@ def _fallback_depth_response( return depth, out +def _normalize_complex_mode(mode: str) -> str: + m = str(mode).strip().lower() + if m not in _VALID_COMPLEX_MODES: + raise ValueError(f"Invalid complex reconstruction mode: {mode!r}") + return m + + def build_ifft_time_axis_ns() -> np.ndarray: """Legacy helper: старая временная ось IFFT в наносекундах (фиксированная длина).""" return ( @@ -69,22 +73,27 @@ def build_frequency_axis_hz(sweep_width: int) -> np.ndarray: """Построить частотную сетку (Гц) для текущей длины свипа.""" n = int(sweep_width) if n <= 0: - return np.zeros((0,), dtype=np.float128) + return np.zeros((0,), dtype=np.float64) if n == 1: return np.array([FREQ_MIN_GHZ * 1e9], dtype=np.float64) return np.linspace(FREQ_MIN_GHZ * 1e9, FREQ_MAX_GHZ * 1e9, n, dtype=np.float64) -def normalize_sweep_for_phase(sweep: np.ndarray) -> np.ndarray: - """Нормировать свип на max(abs(.)) и вернуть float64.""" - x = np.asarray(sweep, dtype=np.float64).ravel() - if x.size == 0: - return x - x = np.nan_to_num(x, nan=0.0, posinf=0.0, neginf=0.0) - amax = float(np.max(np.abs(x))) +def normalize_trace_unit_range(x: np.ndarray) -> np.ndarray: + """Signed-нормировка массива по max(abs(.)) в диапазон около [-1, 1].""" + arr = np.asarray(x, dtype=np.float64).ravel() + if arr.size == 0: + return arr + arr = np.nan_to_num(arr, nan=0.0, posinf=0.0, neginf=0.0) + amax = float(np.max(np.abs(arr))) if (not np.isfinite(amax)) or amax <= _EPS: - return np.zeros_like(x, dtype=np.float64) - return x / amax + return np.zeros_like(arr, dtype=np.float64) + return arr / amax + + +def normalize_sweep_for_phase(sweep: np.ndarray) -> np.ndarray: + """Совместимый alias: нормировка свипа перед восстановлением фазы.""" + return normalize_trace_unit_range(sweep) def unwrap_arccos_phase_continuous(x_norm: np.ndarray) -> np.ndarray: @@ -101,6 +110,7 @@ def unwrap_arccos_phase_continuous(x_norm: np.ndarray) -> np.ndarray: phi0 = np.arccos(x) out = np.empty_like(phi0, dtype=np.float64) + out[0] = float(phi0[0]) for i in range(1, phi0.size): base_phi = float(phi0[i]) prev = float(out[i - 1]) @@ -110,7 +120,6 @@ def unwrap_arccos_phase_continuous(x_norm: np.ndarray) -> np.ndarray: for sign in (1.0, -1.0): base = sign * base_phi - # Ищем ближайший сдвиг 2πk относительно prev именно для этой ветви. k_center = int(np.round((prev - base) / _TWO_PI)) for k in (k_center - 1, k_center, k_center + 1): cand = base + _TWO_PI * float(k) @@ -122,20 +131,55 @@ def unwrap_arccos_phase_continuous(x_norm: np.ndarray) -> np.ndarray: best_cand = cand out[i] = prev if best_cand is None else float(best_cand) - return out - return phi0 -def reconstruct_complex_spectrum_from_real_trace(sweep: np.ndarray) -> np.ndarray: - """Восстановить комплексный спектр из вещественного следа через arccos+Euler.""" - x_norm = normalize_sweep_for_phase(sweep) + +def reconstruct_complex_spectrum_arccos(sweep: np.ndarray) -> np.ndarray: + """Режим arccos: cos(phi) -> phi -> exp(i*phi).""" + x_norm = normalize_trace_unit_range(sweep) if x_norm.size == 0: return np.zeros((0,), dtype=np.complex128) - x_norm = np.clip(x_norm, -1.0, 1.0) - phi = unwrap_arccos_phase_continuous(x_norm) + phi = unwrap_arccos_phase_continuous(np.clip(x_norm, -1.0, 1.0)) return np.exp(1j * phi).astype(np.complex128, copy=False) +def reconstruct_complex_spectrum_diff(sweep: np.ndarray) -> np.ndarray: + """Режим diff: x~=cos(phi), diff(x)->sin(phi), z=cos+i*sin с проекцией на единичную окружность.""" + cos_phi = normalize_trace_unit_range(sweep) + if cos_phi.size == 0: + return np.zeros((0,), dtype=np.complex128) + cos_phi = np.clip(cos_phi, -1.0, 1.0) + + if cos_phi.size < 2: + sin_est = np.zeros_like(cos_phi, dtype=np.float64) + else: + d = np.gradient(cos_phi) + sin_est = normalize_trace_unit_range(d) + sin_est = np.clip(sin_est, -1.0, 1.0) + + z = cos_phi.astype(np.complex128, copy=False) + 1j * sin_est.astype(np.complex128, copy=False) + mag = np.abs(z) + z_unit = np.ones_like(z, dtype=np.complex128) + mask = mag > _EPS + if np.any(mask): + z_unit[mask] = z[mask] / mag[mask] + return mag + + +def reconstruct_complex_spectrum_from_real_trace( + sweep: np.ndarray, + *, + complex_mode: str = "arccos", +) -> np.ndarray: + """Восстановить комплексный спектр из вещественного свипа в выбранном режиме.""" + mode = _normalize_complex_mode(complex_mode) + if mode == "arccos": + return reconstruct_complex_spectrum_arccos(sweep) + if mode == "diff": + return reconstruct_complex_spectrum_diff(sweep) + raise ValueError(f"Unsupported complex reconstruction mode: {complex_mode!r}") + + def perform_ifft_depth_response( s_array: np.ndarray, frequencies_hz: np.ndarray, @@ -173,7 +217,6 @@ def perform_ifft_depth_response( n = int(f.size) if n < 2: raise ValueError("Not enough frequency points after filtering") - if np.any(np.diff(f) <= 0.0): raise ValueError("Non-increasing frequency grid") @@ -221,7 +264,11 @@ def perform_ifft_depth_response( return _fallback_depth_response(np.asarray(s_array).size, np.asarray(s_array)) -def compute_ifft_profile_from_sweep(sweep: Optional[np.ndarray]) -> tuple[np.ndarray, np.ndarray]: +def compute_ifft_profile_from_sweep( + sweep: Optional[np.ndarray], + *, + complex_mode: str = "arccos", +) -> tuple[np.ndarray, np.ndarray]: """Высокоуровневый pipeline: sweep -> complex spectrum -> IFFT(abs) depth profile.""" if sweep is None: return _fallback_depth_response(1, None) @@ -232,12 +279,12 @@ def compute_ifft_profile_from_sweep(sweep: Optional[np.ndarray]) -> tuple[np.nda return _fallback_depth_response(1, None) freqs_hz = build_frequency_axis_hz(s.size) - s_complex = reconstruct_complex_spectrum_from_real_trace(s) + s_complex = reconstruct_complex_spectrum_from_real_trace(s, complex_mode=complex_mode) depth_m, y = perform_ifft_depth_response(s_complex, freqs_hz, axis="abs") n = min(depth_m.size, y.size) if n <= 0: return _fallback_depth_response(s.size, s) - return depth_m[:n].astype(np.float128, copy=False), y[:n].astype(np.float128, copy=False) *20 + return depth_m[:n].astype(np.float32, copy=False), y[:n].astype(np.float32, copy=False) # log10 для лучшей визуализации в водопаде except Exception as exc: # noqa: BLE001 logger.error("compute_ifft_profile_from_sweep failed: %r", exc) return _fallback_depth_response(np.asarray(sweep).size if sweep is not None else 1, sweep) @@ -245,6 +292,6 @@ def compute_ifft_profile_from_sweep(sweep: Optional[np.ndarray]) -> tuple[np.nda def compute_ifft_db_profile(sweep: Optional[np.ndarray]) -> np.ndarray: """Legacy wrapper (deprecated name): возвращает линейный |IFFT| профиль.""" - _depth_m, y = compute_ifft_profile_from_sweep(sweep) + _depth_m, y = compute_ifft_profile_from_sweep(sweep, complex_mode="arccos") return y diff --git a/rfg_adc_plotter/state/ring_buffer.py b/rfg_adc_plotter/state/ring_buffer.py index 52675e9..5769758 100644 --- a/rfg_adc_plotter/state/ring_buffer.py +++ b/rfg_adc_plotter/state/ring_buffer.py @@ -24,6 +24,7 @@ class RingBuffer: self.max_sweeps = max_sweeps # Размер IFFT-профиля теперь динамический и определяется по первому успешному свипу. self.fft_bins = 0 + self.fft_complex_mode: str = "arccos" # Инициализируются при первом свипе (ensure_init) self.ring: Optional[np.ndarray] = None # (max_sweeps, WF_WIDTH) @@ -47,6 +48,26 @@ class RingBuffer: """Legacy alias: старое имя поля (раньше было время в нс, теперь глубина в м).""" return self.fft_depth_axis_m + def set_fft_complex_mode(self, mode: str) -> bool: + """Выбрать режим реконструкции комплексного спектра для IFFT. + + Возвращает True, если режим изменился (и FFT-буфер был сброшен). + """ + m = str(mode).strip().lower() + if m not in ("arccos", "diff"): + raise ValueError(f"Unsupported IFFT complex mode: {mode!r}") + if m == self.fft_complex_mode: + return False + self.fft_complex_mode = m + # Сбрасываем только FFT-зависимые структуры. Сырые ряды сохраняем. + self.ring_fft = None + self.fft_depth_axis_m = None + self.fft_bins = 0 + self.last_fft_vals = None + self.y_min_fft = None + self.y_max_fft = None + return True + def ensure_init(self, sweep_width: int): """Инициализировать буферы при первом свипе. Повторные вызовы — no-op (кроме x_shared).""" if self.ring is None: @@ -73,7 +94,10 @@ class RingBuffer: self._push_fft(s) def _push_fft(self, s: np.ndarray): - depth_axis_m, fft_row = compute_ifft_profile_from_sweep(s) + depth_axis_m, fft_row = compute_ifft_profile_from_sweep( + s, + complex_mode=self.fft_complex_mode, + ) fft_row = np.asarray(fft_row, dtype=np.float32).ravel() depth_axis_m = np.asarray(depth_axis_m, dtype=np.float32).ravel() diff --git a/tests/test_fourier_complex_modes.py b/tests/test_fourier_complex_modes.py new file mode 100644 index 0000000..79d6b7e --- /dev/null +++ b/tests/test_fourier_complex_modes.py @@ -0,0 +1,54 @@ +import numpy as np + +from rfg_adc_plotter.processing.fourier import ( + compute_ifft_profile_from_sweep, + reconstruct_complex_spectrum_from_real_trace, +) + + +def test_reconstruct_complex_spectrum_arccos_mode_returns_complex128(): + sweep = np.linspace(-3.0, 7.0, 128, dtype=np.float32) + z = reconstruct_complex_spectrum_from_real_trace(sweep, complex_mode="arccos") + assert z.dtype == np.complex128 + assert z.shape == sweep.shape + assert np.all(np.isfinite(np.real(z))) + assert np.all(np.isfinite(np.imag(z))) + + +def test_reconstruct_complex_spectrum_diff_mode_returns_complex128(): + sweep = np.linspace(-1.0, 1.0, 128, dtype=np.float32) + z = reconstruct_complex_spectrum_from_real_trace(sweep, complex_mode="diff") + assert z.dtype == np.complex128 + assert z.shape == sweep.shape + assert np.all(np.isfinite(np.real(z))) + assert np.all(np.isfinite(np.imag(z))) + + +def test_reconstruct_complex_spectrum_diff_mode_projects_to_unit_circle(): + sweep = np.sin(np.linspace(0.0, 6.0 * np.pi, 256)).astype(np.float32) + z = reconstruct_complex_spectrum_from_real_trace(sweep, complex_mode="diff") + mag = np.abs(z) + assert np.all(np.isfinite(mag)) + assert np.allclose(mag, np.ones_like(mag), atol=1e-5, rtol=0.0) + + +def test_compute_ifft_profile_from_sweep_accepts_both_modes(): + sweep = np.linspace(-5.0, 5.0, 257, dtype=np.float32) + d1, y1 = compute_ifft_profile_from_sweep(sweep, complex_mode="arccos") + d2, y2 = compute_ifft_profile_from_sweep(sweep, complex_mode="diff") + + assert d1.dtype == np.float32 and y1.dtype == np.float32 + assert d2.dtype == np.float32 and y2.dtype == np.float32 + assert d1.size == y1.size and d2.size == y2.size + assert d1.size > 0 and d2.size > 0 + assert np.all(np.diff(d1) >= 0.0) + assert np.all(np.diff(d2) >= 0.0) + + +def test_invalid_complex_mode_falls_back_deterministically_in_outer_wrapper(): + sweep = np.linspace(-1.0, 1.0, 64, dtype=np.float32) + depth, y = compute_ifft_profile_from_sweep(sweep, complex_mode="unknown") + assert depth.dtype == np.float32 + assert y.dtype == np.float32 + assert depth.size == y.size + assert depth.size > 0 diff --git a/tests/test_ring_buffer_fft_axis.py b/tests/test_ring_buffer_fft_axis.py index d145868..056e87e 100644 --- a/tests/test_ring_buffer_fft_axis.py +++ b/tests/test_ring_buffer_fft_axis.py @@ -38,3 +38,28 @@ def test_ring_buffer_reallocates_fft_buffers_when_ifft_length_changes(): assert second_shape == (ring.max_sweeps, second_bins) assert ring.fft_depth_axis_m is not None assert ring.fft_depth_axis_m.size == second_bins + + +def test_ring_buffer_mode_switch_resets_fft_buffers_only(): + ring = RingBuffer(max_sweeps=4) + ring.ensure_init(128) + + ring.push(np.linspace(-1.0, 1.0, 128, dtype=np.float32)) + assert ring.ring is not None + assert ring.ring_fft is not None + raw_before = ring.ring.copy() + + changed = ring.set_fft_complex_mode("diff") + assert changed is True + assert ring.fft_complex_mode == "diff" + assert ring.ring is not None + assert np.array_equal(ring.ring, raw_before, equal_nan=True) + assert ring.ring_fft is None + assert ring.fft_depth_axis_m is None + assert ring.last_fft_vals is None + assert ring.fft_bins == 0 + + ring.push(np.linspace(-1.0, 1.0, 128, dtype=np.float32)) + assert ring.ring_fft is not None + assert ring.fft_depth_axis_m is not None + assert ring.last_fft_vals is not None