plot.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537
  1. import sys
  2. import numpy as np
  3. import string
  4. from PySide6.QtWidgets import (
  5. QApplication, QMainWindow, QWidget,
  6. QVBoxLayout, QHBoxLayout, QGridLayout,
  7. QPushButton, QLabel, QLineEdit, QFileDialog,
  8. QListWidget, QListWidgetItem, QGroupBox, QMessageBox,
  9. QCheckBox
  10. )
  11. from PySide6.QtCore import Qt
  12. # Для PySide6 используем:
  13. from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
  14. # Если бы использовали PyQt5: from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
  15. # Если PySide6 + matplotlib>=3.5, часто тоже 'qt5agg' работает, либо 'qt6agg'.
  16. from matplotlib.figure import Figure
  17. class MplCanvas(FigureCanvas):
  18. """
  19. Класс-обёртка для холста matplotlib, чтобы легко встраивать в PySide/PyQt.
  20. """
  21. def __init__(self, parent=None, width=5, height=4, dpi=100):
  22. self.fig = Figure(figsize=(width, height), dpi=dpi)
  23. self.ax = self.fig.add_subplot(111)
  24. super(MplCanvas, self).__init__(self.fig)
  25. self.setParent(parent)
  26. self.fig.tight_layout()
  27. class MainWindow(QMainWindow):
  28. def __init__(self):
  29. super().__init__()
  30. self.setWindowTitle("Пример GUI: сигналы, спектр, демодуляция + фильтрация")
  31. # Основной виджет и лейаут
  32. central_widget = QWidget()
  33. self.setCentralWidget(central_widget)
  34. main_layout = QVBoxLayout()
  35. central_widget.setLayout(main_layout)
  36. # ========== Блок кнопок и полей ввода ==========
  37. controls_layout = QGridLayout()
  38. # Кнопка "Загрузить CSV"
  39. self.load_btn = QPushButton("Загрузить CSV")
  40. self.load_btn.clicked.connect(self.load_csv)
  41. controls_layout.addWidget(self.load_btn, 0, 0, 1, 3)
  42. # Список доступных каналов (столбцов)
  43. self.channels_list = QListWidget()
  44. self.channels_list.setSelectionMode(QListWidget.MultiSelection)
  45. self.channels_list.setFixedHeight(100)
  46. controls_layout.addWidget(QLabel("Выберите каналы:"), 1, 0)
  47. controls_layout.addWidget(self.channels_list, 2, 0, 1, 3)
  48. # Поле ввода центральной частоты (МГц)
  49. self.fc_label = QLabel("Центральная частота (МГц):")
  50. self.fc_input = QLineEdit("0")
  51. controls_layout.addWidget(self.fc_label, 3, 0)
  52. controls_layout.addWidget(self.fc_input, 3, 1)
  53. # Два поля: начальное и конечное время (нс)
  54. self.start_time_label = QLabel("Начальное время (нс):")
  55. self.start_time_input = QLineEdit("0")
  56. controls_layout.addWidget(self.start_time_label, 4, 0)
  57. controls_layout.addWidget(self.start_time_input, 4, 1)
  58. self.end_time_label = QLabel("Конечное время (нс):")
  59. self.end_time_input = QLineEdit("1000")
  60. controls_layout.addWidget(self.end_time_label, 5, 0)
  61. controls_layout.addWidget(self.end_time_input, 5, 1)
  62. # Кнопки для построения (исходный сигнал)
  63. self.plot_time_btn = QPushButton("Построить временной график")
  64. self.plot_time_btn.clicked.connect(self.plot_time_domain)
  65. controls_layout.addWidget(self.plot_time_btn, 6, 0)
  66. self.plot_spectrum_btn = QPushButton("Построить спектр")
  67. self.plot_spectrum_btn.clicked.connect(self.plot_spectrum)
  68. controls_layout.addWidget(self.plot_spectrum_btn, 6, 1)
  69. # Новые элементы: Коэффициент децимации + кнопка "Демодуляция и децимация"
  70. self.dec_factor_label = QLabel("Коэффициент децимации:")
  71. self.dec_factor_input = QLineEdit("4") # По умолчанию 4
  72. controls_layout.addWidget(self.dec_factor_label, 7, 0)
  73. controls_layout.addWidget(self.dec_factor_input, 7, 1)
  74. self.demod_dec_btn = QPushButton("Демодуляция и децимация")
  75. self.demod_dec_btn.clicked.connect(self.demodulate_and_decimate)
  76. controls_layout.addWidget(self.demod_dec_btn, 7, 2)
  77. # ====== Чекбокс для скользящего среднего ======
  78. self.use_moving_average_checkbox = QCheckBox("Применять фильтрацию (скользящее среднее)")
  79. controls_layout.addWidget(self.use_moving_average_checkbox, 8, 0, 1, 3)
  80. # ====== Чекбокс для полосового фильтра + поля ввода ======
  81. self.use_bandpass_checkbox = QCheckBox("Применять полосовой фильтр")
  82. controls_layout.addWidget(self.use_bandpass_checkbox, 9, 0, 1, 3)
  83. self.bandpass_low_label = QLabel("Нижняя частота (МГц):")
  84. self.bandpass_low_input = QLineEdit("10")
  85. controls_layout.addWidget(self.bandpass_low_label, 10, 0)
  86. controls_layout.addWidget(self.bandpass_low_input, 10, 1)
  87. self.bandpass_high_label = QLabel("Верхняя частота (МГц):")
  88. self.bandpass_high_input = QLineEdit("50")
  89. controls_layout.addWidget(self.bandpass_high_label, 11, 0)
  90. controls_layout.addWidget(self.bandpass_high_input, 11, 1)
  91. main_layout.addLayout(controls_layout)
  92. # ========== Блок для отображения графиков ==========
  93. plots_layout = QHBoxLayout()
  94. # Левый блок (временной график - с учётом фильтра, если включен)
  95. self.time_group = QGroupBox("Временная зависимость")
  96. time_layout = QVBoxLayout()
  97. self.time_canvas = MplCanvas(self, width=5, height=4)
  98. time_layout.addWidget(self.time_canvas)
  99. self.time_group.setLayout(time_layout)
  100. plots_layout.addWidget(self.time_group, stretch=1)
  101. # Правый блок (спектр - с учётом фильтра, если включен)
  102. self.freq_group = QGroupBox("Спектр")
  103. freq_layout = QVBoxLayout()
  104. self.freq_canvas = MplCanvas(self, width=5, height=4)
  105. freq_layout.addWidget(self.freq_canvas)
  106. self.freq_group.setLayout(freq_layout)
  107. plots_layout.addWidget(self.freq_group, stretch=1)
  108. main_layout.addLayout(plots_layout)
  109. # ======== Второй ряд для демодулированных данных ========
  110. demod_layout = QHBoxLayout()
  111. # График во временной области после демодуляции/децимации
  112. self.demod_time_group = QGroupBox("Временная зависимость (после демодуляции и децимации)")
  113. demod_time_layout = QVBoxLayout()
  114. self.demod_time_canvas = MplCanvas(self, width=5, height=4)
  115. demod_time_layout.addWidget(self.demod_time_canvas)
  116. self.demod_time_group.setLayout(demod_time_layout)
  117. demod_layout.addWidget(self.demod_time_group, stretch=1)
  118. main_layout.addLayout(demod_layout)
  119. # Инициализируем переменные для хранения данных
  120. self.csv_data = None # Исходные данные из CSV (numpy-массив)
  121. self.demod_data = None # Данные после демодуляции/децимации
  122. self.sampling_rate_ns = 13 # Шаг во времени 13 нс
  123. self.loaded_file_path = None
  124. # Показываем окно
  125. self.resize(1400, 800)
  126. self.show()
  127. def load_csv(self):
  128. """
  129. Загрузка CSV-файла и заполнение списка каналов.
  130. Учтён случай, когда в конце строк есть лишняя запятая.
  131. """
  132. file_dialog = QFileDialog(self, "Выберите CSV-файл", ".", "CSV Files (*.csv)")
  133. if file_dialog.exec():
  134. file_path = file_dialog.selectedFiles()[0]
  135. else:
  136. return
  137. if not file_path:
  138. return
  139. try:
  140. # Открываем файл и обрезаем лишнюю запятую в конце строки
  141. with open(file_path, 'r', encoding='utf-8') as f:
  142. cleaned_lines = []
  143. for line in f:
  144. line = line.strip() # Убираем пробелы и \n
  145. if line.endswith(','): # Если строка заканчивается запятой
  146. line = line[:-1] # Удаляем её
  147. cleaned_lines.append(line)
  148. # Передаём полученные «чистые» строки в loadtxt
  149. loaded_array = np.loadtxt(cleaned_lines, delimiter=',')
  150. # Если массив одномерный (ndim=1), превращаем в (N, 1)
  151. if loaded_array.ndim == 1:
  152. loaded_array = loaded_array.reshape(-1, 1)
  153. # Сохраняем в self.csv_data
  154. self.csv_data = loaded_array
  155. # Определяем число столбцов
  156. num_cols = self.csv_data.shape[1]
  157. # Заполняем список каналов
  158. self.channels_list.clear()
  159. for i in range(num_cols):
  160. channel = string.ascii_uppercase[i]
  161. item = QListWidgetItem(f"Канал {channel}")
  162. # Пусть у первого канала будет Checked по умолчанию
  163. item.setCheckState(Qt.Checked if i == 0 else Qt.Unchecked)
  164. self.channels_list.addItem(item)
  165. self.loaded_file_path = file_path
  166. QMessageBox.information(self, "Загрузка CSV", f"Файл {file_path} успешно загружен.")
  167. except Exception as e:
  168. QMessageBox.warning(self, "Ошибка", f"Не удалось загрузить файл: {e}")
  169. self.csv_data = None
  170. def get_time_slice_indices(self):
  171. """
  172. Вспомогательный метод: получить start_idx, end_idx из полей ввода,
  173. с учётом sampling_rate_ns и размеров массива csv_data.
  174. """
  175. if self.csv_data is None:
  176. return None, None
  177. try:
  178. start_ns = float(self.start_time_input.text())
  179. end_ns = float(self.end_time_input.text())
  180. if end_ns <= start_ns:
  181. raise ValueError("end_ns must be > start_ns")
  182. except ValueError:
  183. return None, None
  184. dt = self.sampling_rate_ns
  185. start_idx = int(round(start_ns / dt))
  186. end_idx = int(round(end_ns / dt))
  187. if start_idx < 0:
  188. start_idx = 0
  189. if end_idx > self.csv_data.shape[0]:
  190. end_idx = self.csv_data.shape[0]
  191. if start_idx >= end_idx:
  192. return None, None
  193. return start_idx, end_idx
  194. def get_selected_channels(self):
  195. """
  196. Вспомогательный метод: получить индексы выбранных каналов.
  197. """
  198. selected_channels = []
  199. for i in range(self.channels_list.count()):
  200. item = self.channels_list.item(i)
  201. if item.checkState() == Qt.Checked:
  202. selected_channels.append(i)
  203. return selected_channels
  204. # ----------------------------------------------------------------------------
  205. # Вспомогательные методы фильтрации
  206. # ----------------------------------------------------------------------------
  207. def moving_average_filter(self, data, window_size=25):
  208. """
  209. Фильтр скользящего среднего.
  210. data.shape = (N, num_channels)
  211. Возвращаем копию data (float) с отфильтрованными значениями.
  212. """
  213. filtered = np.zeros_like(data, dtype=float)
  214. kernel = np.ones(window_size) / window_size
  215. for ch in range(data.shape[1]):
  216. y = data[:, ch]
  217. y_filt = np.convolve(y, kernel, mode='same')
  218. filtered[:, ch] = y_filt
  219. return filtered
  220. def bandpass_filter_fft(self, data, dt_s, f_low_hz, f_high_hz):
  221. """
  222. Пример полосового фильтра в частотной области:
  223. обнуляем все компоненты спектра, которые лежат вне диапазона [f_low_hz, f_high_hz].
  224. data.shape = (N, num_channels)
  225. dt_s — шаг по времени, с
  226. f_low_hz, f_high_hz — границы полосы (Гц)
  227. Возвращаем отфильтрованный сигнал (float).
  228. """
  229. N = data.shape[0]
  230. num_ch = data.shape[1]
  231. filtered = np.zeros_like(data, dtype=float)
  232. # Частоты для FFT
  233. freqs = np.fft.fftfreq(N, d=dt_s)
  234. for ch in range(num_ch):
  235. y = data[:, ch]
  236. Y = np.fft.fft(y)
  237. # Создаём маску, где частоты внутри [f_low_hz, f_high_hz] (по модулю)
  238. mask = (np.abs(freqs) >= f_low_hz) & (np.abs(freqs) <= f_high_hz)
  239. # Обнулим всё, что вне маски
  240. Y[~mask] = 0.0
  241. # Обратное преобразование
  242. y_filt = np.fft.ifft(Y)
  243. filtered[:, ch] = np.real(y_filt) # сигнал считаем вещественным
  244. return filtered
  245. def apply_filters_if_needed(self, data, dt_s):
  246. """
  247. Последовательно применяем фильтры, если соответствующие чекбоксы включены:
  248. 1) Полосовой фильтр (bandpass)
  249. 2) Фильтр скользящего среднего (moving average)
  250. """
  251. # data.shape = (N, num_channels)
  252. out = data.copy()
  253. # 1) Проверяем, включён ли чекбокс "Применять полосовой фильтр"
  254. if self.use_bandpass_checkbox.isChecked():
  255. try:
  256. f_low_mhz = float(self.bandpass_low_input.text())
  257. f_high_mhz = float(self.bandpass_high_input.text())
  258. if f_high_mhz <= f_low_mhz:
  259. raise ValueError
  260. except ValueError:
  261. QMessageBox.warning(self, "Ошибка", "Некорректные границы полосового фильтра.")
  262. # Если ошибка, не делаем полосовой фильтр
  263. else:
  264. f_low_hz = f_low_mhz * 1e6
  265. f_high_hz = f_high_mhz * 1e6
  266. out = self.bandpass_filter_fft(out, dt_s, f_low_hz, f_high_hz)
  267. # 2) Проверяем, включён ли чекбокс "Применять фильтрацию (скользящее среднее)"
  268. if self.use_moving_average_checkbox.isChecked():
  269. out = self.moving_average_filter(out, window_size=25)
  270. return out
  271. # ----------------------------------------------------------------------------
  272. # Основные методы: построение во времени, в спектре, демодуляция
  273. # ----------------------------------------------------------------------------
  274. def plot_time_domain(self):
  275. """
  276. Построение временной зависимости выбранных каналов (с учётом фильтрации, если включена).
  277. """
  278. if self.csv_data is None:
  279. QMessageBox.warning(self, "Ошибка", "Сперва загрузите CSV-файл.")
  280. return
  281. start_idx, end_idx = self.get_time_slice_indices()
  282. if start_idx is None or end_idx is None:
  283. QMessageBox.warning(self, "Ошибка", "Некорректный интервал времени.")
  284. return
  285. selected_channels = self.get_selected_channels()
  286. if not selected_channels:
  287. QMessageBox.warning(self, "Ошибка", "Выберите хотя бы один канал для построения.")
  288. return
  289. # Срезаем нужные данные
  290. raw_cropped = self.csv_data[start_idx:end_idx, :]
  291. # Нормируем к вольтам
  292. raw_cropped_v = raw_cropped / 32767.0 * 5.0
  293. # Применяем фильтры (если чекбоксы включены)
  294. dt_s = self.sampling_rate_ns * 1e-9
  295. data_for_plot = self.apply_filters_if_needed(raw_cropped_v, dt_s)
  296. # Очищаем ось
  297. self.time_canvas.ax.clear()
  298. dt = self.sampling_rate_ns
  299. time_axis_ns = np.arange(start_idx, end_idx) * dt # в нс
  300. for ch in selected_channels:
  301. y = data_for_plot[:, ch]
  302. self.time_canvas.ax.plot(time_axis_ns, y, label=f"Канал {ch+1}")
  303. self.time_canvas.ax.set_xlabel("Время (нс)")
  304. self.time_canvas.ax.set_ylabel("Амплитуда (В)")
  305. self.time_canvas.ax.legend()
  306. self.time_canvas.ax.grid(True)
  307. self.time_canvas.draw()
  308. def plot_spectrum(self):
  309. """
  310. Построение спектра для выбранных каналов (с учётом фильтрации).
  311. """
  312. if self.csv_data is None:
  313. QMessageBox.warning(self, "Ошибка", "Сперва загрузите CSV-файл.")
  314. return
  315. # Считываем центральную частоту (МГц) — чтобы отметить её на графике
  316. try:
  317. fc_mhz = float(self.fc_input.text())
  318. except ValueError:
  319. fc_mhz = 0.0
  320. start_idx, end_idx = self.get_time_slice_indices()
  321. if start_idx is None or end_idx is None:
  322. QMessageBox.warning(self, "Ошибка", "Некорректный интервал времени.")
  323. return
  324. selected_channels = self.get_selected_channels()
  325. if not selected_channels:
  326. QMessageBox.warning(self, "Ошибка", "Выберите хотя бы один канал для построения.")
  327. return
  328. # Срезаем нужные данные
  329. raw_cropped = self.csv_data[start_idx:end_idx, :]
  330. # Нормируем к вольтам
  331. raw_cropped_v = raw_cropped / 32767.0 * 5.0
  332. # Применяем фильтры (если нужно)
  333. dt_s = self.sampling_rate_ns * 1e-9
  334. data_for_fft = self.apply_filters_if_needed(raw_cropped_v, dt_s)
  335. # Очищаем ось
  336. self.freq_canvas.ax.clear()
  337. # Параметры для FFT
  338. n_win = data_for_fft.shape[0] # число точек
  339. freqs = np.fft.fftfreq(n_win, d=dt_s) # массив частот (в Гц)
  340. freqs_mhz = freqs / 1e6 # переведём в МГц
  341. for ch in selected_channels:
  342. y = data_for_fft[:, ch]
  343. # FFT
  344. Y = np.fft.fft(y)
  345. spectrum = np.abs(Y)
  346. self.freq_canvas.ax.plot(freqs_mhz, spectrum, label=f"Канал {ch+1}")
  347. # Добавим вертикальную линию на центральной частоте (если нужно)
  348. if fc_mhz != 0.0:
  349. self.freq_canvas.ax.axvline(x=fc_mhz, color='red', linestyle='--', label="Центр")
  350. self.freq_canvas.ax.set_xlabel("Частота (МГц)")
  351. self.freq_canvas.ax.set_ylabel("Амплитуда спектра (отн. ед.)")
  352. self.freq_canvas.ax.legend()
  353. self.freq_canvas.ax.grid(True)
  354. self.freq_canvas.draw()
  355. def demodulate_and_decimate(self):
  356. """
  357. Демодуляция (IQ-смещение) на центральную частоту fc_mhz и децимация
  358. для выбранных каналов, исходя из заданного интервала [start_ns, end_ns].
  359. При этом берём либо исходный, либо уже отфильтрованный сигнал.
  360. Результат во временной области (I-компонента) выводим на self.demod_time_canvas.
  361. """
  362. if self.csv_data is None:
  363. QMessageBox.warning(self, "Ошибка", "Сперва загрузите CSV-файл.")
  364. return
  365. # Считываем центральную частоту (МГц)
  366. try:
  367. fc_mhz = float(self.fc_input.text())
  368. except ValueError:
  369. fc_mhz = 0.0
  370. # Считываем коэффициент децимации
  371. try:
  372. dec_factor = int(self.dec_factor_input.text())
  373. if dec_factor < 1:
  374. raise ValueError
  375. except ValueError:
  376. QMessageBox.warning(self, "Ошибка", "Некорректный коэффициент децимации.")
  377. return
  378. start_idx, end_idx = self.get_time_slice_indices()
  379. if start_idx is None or end_idx is None:
  380. QMessageBox.warning(self, "Ошибка", "Некорректный интервал времени.")
  381. return
  382. selected_channels = self.get_selected_channels()
  383. if not selected_channels:
  384. QMessageBox.warning(self, "Ошибка", "Выберите хотя бы один канал для обработки.")
  385. return
  386. # Берём нужный срез по времени, нормируем к вольтам
  387. cropped_data = self.csv_data[start_idx:end_idx, :]
  388. cropped_data_v = cropped_data / 32767.0 * 5.0
  389. # Применяем фильтры (если включены чекбоксы)
  390. dt_s = self.sampling_rate_ns * 1e-9
  391. data_for_demod = self.apply_filters_if_needed(cropped_data_v, dt_s)
  392. # Рассчитаем временные метки (в секундах) для исходного сигнала
  393. n_win = data_for_demod.shape[0]
  394. t_vec = np.arange(n_win) * dt_s
  395. # Переводим частоту в Гц
  396. fc_hz = fc_mhz * 1e6
  397. # Создадим массив для результирующего сигнала (комплексный)
  398. self.demod_data = np.zeros((n_win, len(selected_channels)), dtype=np.complex64)
  399. # Для каждой выбранной колонки — демодулируем
  400. for idx, ch in enumerate(selected_channels):
  401. y = data_for_demod[:, ch]
  402. # Умножение на e^-j(2π fc t) для смещения
  403. mixer = np.exp(-1j * 2 * np.pi * fc_hz * t_vec)
  404. y_demod = y * mixer
  405. self.demod_data[:, idx] = y_demod
  406. # Далее простейшая децимация (прореживание) — без доп. фильтра
  407. self.demod_data = self.demod_data[::dec_factor, :]
  408. # Очищаем ось для отображения демодулированного сигнала
  409. self.demod_time_canvas.ax.clear()
  410. # Создадим новую временную ось для децимированного сигнала
  411. decimated_dt_s = dt_s * dec_factor
  412. dec_time_vec_ns = (np.arange(self.demod_data.shape[0]) * decimated_dt_s) * 1e9 # в нс
  413. # Построим только I-компоненту для каждого канала
  414. for idx, ch in enumerate(selected_channels):
  415. y_i = np.real(self.demod_data[:, idx])
  416. self.demod_time_canvas.ax.plot(dec_time_vec_ns, y_i, label=f"Канал {ch+1} (I)")
  417. self.demod_time_canvas.ax.set_xlabel("Время после децимации (нс)")
  418. self.demod_time_canvas.ax.set_ylabel("I-компонента (В)")
  419. self.demod_time_canvas.ax.legend()
  420. self.demod_time_canvas.ax.grid(True)
  421. self.demod_time_canvas.draw()
  422. QMessageBox.information(
  423. self, "Демодуляция и децимация",
  424. f"Демодуляция на {fc_mhz} МГц и децимация (factor={dec_factor}) выполнены."
  425. )
  426. def main():
  427. app = QApplication(sys.argv)
  428. window = MainWindow()
  429. sys.exit(app.exec())
  430. if __name__ == "__main__":
  431. main()