diff --git a/rfg_adc_plotter/visualization/pyqtgraph_backend.py b/rfg_adc_plotter/visualization/pyqtgraph_backend.py index 3437be1..a3fe930 100644 --- a/rfg_adc_plotter/visualization/pyqtgraph_backend.py +++ b/rfg_adc_plotter/visualization/pyqtgraph_backend.py @@ -2,9 +2,11 @@ Визуализация данных с использованием pyqtgraph (быстрый бэкенд). """ +import csv import sys import threading import time +from datetime import datetime from queue import Empty, Queue from typing import Optional, Tuple @@ -13,11 +15,13 @@ import numpy as np try: import pyqtgraph as pg from PyQt5 import QtCore, QtWidgets # noqa: F401 + from PyQt5.QtWidgets import QPushButton, QWidget, QHBoxLayout, QCheckBox, QFileDialog except Exception: # Возможно установлена PySide6 try: import pyqtgraph as pg from PySide6 import QtCore, QtWidgets # noqa: F401 + from PySide6.QtWidgets import QPushButton, QWidget, QHBoxLayout, QCheckBox, QFileDialog except Exception as e: raise RuntimeError( "pyqtgraph/PyQt5(Pyside6) не найдены. Установите: pip install pyqtgraph PyQt5" @@ -138,6 +142,131 @@ def run_pyqtgraph(args): status = pg.LabelItem(justify="left") win.addItem(status, row=3, col=0, colspan=2) + # Функция сохранения медианы последних 1000 свипов + def save_median_data(): + """Сохранить медиану последних 1000 свипов в CSV файл""" + if ring is None: + status.setText("Нет данных для сохранения") + return + + # Определяем сколько свипов доступно + n_sweeps = 1000 + available = min(n_sweeps, max_sweeps) + + # Проверяем сколько свипов реально заполнено + filled_count = np.count_nonzero(~np.isnan(ring[:, 0])) + if filled_count == 0: + status.setText("Нет данных для сохранения") + return + + available = min(available, filled_count) + + # Получаем хронологически упорядоченные данные + ordered = ring if head == 0 else np.roll(ring, -head, axis=0) + + # Берем последние n свипов + recent_sweeps = ordered[-available:, :] + + # Вычисляем медиану по свипам (ось 0) + median_sweep = np.nanmedian(recent_sweeps, axis=0) + + # Сохраняем в CSV + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"median_sweep_{timestamp}.csv" + + try: + with open(filename, 'w', newline='') as f: + writer = csv.writer(f) + writer.writerow(['Index', 'Median_Value']) + for i, value in enumerate(median_sweep): + if np.isfinite(value): + writer.writerow([i, float(value)]) + + status.setText(f"Сохранено {available} свипов (медиана) в {filename}") + except Exception as e: + status.setText(f"Ошибка сохранения: {e}") + + # Функция загрузки медианного файла + def load_median_file(): + """Загрузить медианный файл из CSV""" + nonlocal median_data + + filename, _ = QFileDialog.getOpenFileName( + None, + "Выберите файл с медианой", + "", + "CSV Files (*.csv);;All Files (*)" + ) + + if not filename: + return + + try: + # Загружаем CSV файл + data = [] + with open(filename, 'r') as f: + reader = csv.reader(f) + next(reader) # Пропускаем заголовок + for row in reader: + if len(row) >= 2: + try: + data.append(float(row[1])) + except ValueError: + continue + + if not data: + status.setText("Ошибка: файл пустой или неверный формат") + return + + median_data = np.array(data, dtype=np.float32) + status.setText(f"Загружена медиана из {filename} ({len(median_data)} точек)") + + # Автоматически включаем чекбокс + subtract_checkbox.setChecked(True) + + except Exception as e: + status.setText(f"Ошибка загрузки: {e}") + median_data = None + + # Функция переключения вычитания медианы + def toggle_median_subtraction(state): + nonlocal median_subtract_enabled + median_subtract_enabled = bool(state) + if median_subtract_enabled and median_data is None: + status.setText("Сначала загрузите файл с медианой") + subtract_checkbox.setChecked(False) + elif median_subtract_enabled: + status.setText("Вычитание медианы включено") + else: + status.setText("Вычитание медианы выключено") + + # Создаем контейнер для кнопок управления + button_container = QWidget() + button_layout = QHBoxLayout() + + # Кнопка сохранения медианы + save_btn = QPushButton("Сохранить медиану (1000 свипов)") + save_btn.clicked.connect(save_median_data) + button_layout.addWidget(save_btn) + + # Кнопка загрузки медианы + load_btn = QPushButton("Загрузить медиану") + load_btn.clicked.connect(load_median_file) + button_layout.addWidget(load_btn) + + # Чекбокс для включения вычитания + subtract_checkbox = QCheckBox("Вычитать медиану") + subtract_checkbox.stateChanged.connect(toggle_median_subtraction) + button_layout.addWidget(subtract_checkbox) + + button_layout.setContentsMargins(5, 5, 5, 5) + button_container.setLayout(button_layout) + + # Добавляем кнопки в окно + proxy_widget = QtWidgets.QGraphicsProxyWidget() + proxy_widget.setWidget(button_container) + win.addItem(proxy_widget, row=4, col=0, colspan=2) + # Состояние ring: Optional[np.ndarray] = None head = 0 @@ -145,6 +274,9 @@ def run_pyqtgraph(args): x_shared: Optional[np.ndarray] = None current_sweep: Optional[np.ndarray] = None current_info: Optional[SweepInfo] = None + # Медианные данные для вычитания + median_data: Optional[np.ndarray] = None + median_subtract_enabled = False # Авто-уровни цветовой шкалы водопада сырых данных пересчитываются по видимой области. # Для спектров (полное FFT для отрицательных частот) fft_bins = FFT_LEN @@ -232,6 +364,15 @@ def run_pyqtgraph(args): nonlocal ring_phase, prev_phase_per_bin, phase_offset_per_bin, y_min_phase, y_max_phase if s is None or s.size == 0 or ring is None: return + + # Применяем вычитание медианы если включено + if median_subtract_enabled and median_data is not None: + # Вычитаем медиану из сигнала + take_median = min(s.size, median_data.size) + s_corrected = s.copy() + s_corrected[:take_median] = s[:take_median] - median_data[:take_median] + s = s_corrected + w = ring.shape[1] row = np.full((w,), np.nan, dtype=np.float32) take = min(w, s.size) @@ -336,20 +477,27 @@ def run_pyqtgraph(args): def update(): changed = drain_queue() > 0 if current_sweep is not None and x_shared is not None: - if current_sweep.size <= x_shared.size: - xs = x_shared[: current_sweep.size] + # Применяем вычитание медианы для отображения + display_sweep = current_sweep + if median_subtract_enabled and median_data is not None: + take_median = min(current_sweep.size, median_data.size) + display_sweep = current_sweep.copy() + display_sweep[:take_median] = current_sweep[:take_median] - median_data[:take_median] + + if display_sweep.size <= x_shared.size: + xs = x_shared[: display_sweep.size] else: - xs = np.arange(current_sweep.size) - curve.setData(xs, current_sweep, autoDownsample=True) + xs = np.arange(display_sweep.size) + curve.setData(xs, display_sweep, autoDownsample=True) if fixed_ylim is None: - y0 = float(np.nanmin(current_sweep)) - y1 = float(np.nanmax(current_sweep)) + y0 = float(np.nanmin(display_sweep)) + y1 = float(np.nanmax(display_sweep)) if np.isfinite(y0) and np.isfinite(y1): margin = 0.05 * max(1.0, (y1 - y0)) p_line.setYRange(y0 - margin, y1 + margin, padding=0) # Обновим спектр и фазу - take_fft = min(int(current_sweep.size), FFT_LEN) + take_fft = min(int(display_sweep.size), FFT_LEN) if take_fft > 0 and freq_shared is not None: # Создаем буфер для полного FFT (с отрицательными частотами) fft_in = np.zeros((FFT_LEN,), dtype=np.float32) @@ -365,7 +513,7 @@ def run_pyqtgraph(args): data_points = min(data_points, take_fft, FFT_LEN - start_idx) # Подготовка данных с окном Хэннинга - seg = np.nan_to_num(current_sweep[:data_points], nan=0.0).astype(np.float32, copy=False) + seg = np.nan_to_num(display_sweep[:data_points], nan=0.0).astype(np.float32, copy=False) win = np.hanning(data_points).astype(np.float32) # Размещаем данные в правильной позиции