123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614 |
- import sys
- import numpy as np
- import string
- from PySide6.QtWidgets import (
- QApplication, QMainWindow, QWidget, QDockWidget,
- QVBoxLayout, QHBoxLayout,
- QPushButton, QLabel, QLineEdit, QFileDialog,
- QListWidget, QListWidgetItem, QGroupBox, QMessageBox,
- QCheckBox
- )
- from PySide6.QtCore import Qt
- # Для встроенного тулбара matplotlib
- from matplotlib.backends.backend_qtagg import NavigationToolbar2QT as NavigationToolbar
- from scipy.io import savemat # <-- Импорт для .mat сохранения
- from mpl_canvas import MplCanvas
- from plotting import (
- plot_time_domain,
- plot_spectrum,
- plot_demodulated_time_domain
- )
- class MainWindow(QMainWindow):
- def __init__(self):
- super().__init__()
- self.csv_data = None
- self.demod_data = None
- self.demod_time_ns = None
- self.sampling_rate_ns = 13
- self.loaded_file_path = None
- self.init_ui()
- def init_ui(self):
- self.setWindowTitle("Demo: Signal / Spectrum / Demodulation plotting by Vladimir Pugovkin")
- central_widget = QWidget()
- self.setCentralWidget(central_widget)
- main_layout = QVBoxLayout(central_widget)
- # ------------ Первая строка с кнопками -----------
- top_bar_1 = QHBoxLayout()
- top_bar_1.setSpacing(8)
- self.load_btn = QPushButton("Load CSV")
- self.load_btn.setFixedWidth(130)
- self.load_btn.clicked.connect(self.load_csv)
- top_bar_1.addWidget(self.load_btn)
- # Чекбокс для bandpass
- self.use_bandpass_checkbox = QCheckBox("Bandpass Filter")
- top_bar_1.addWidget(self.use_bandpass_checkbox)
- bandpass_label_low = QLabel("Lower freq (MHz):")
- self.bandpass_low_input = QLineEdit("10")
- self.bandpass_low_input.setFixedWidth(60)
- bandpass_label_high = QLabel("Upper freq (MHz):")
- self.bandpass_high_input = QLineEdit("50")
- self.bandpass_high_input.setFixedWidth(60)
- top_bar_1.addWidget(bandpass_label_low)
- top_bar_1.addWidget(self.bandpass_low_input)
- top_bar_1.addWidget(bandpass_label_high)
- top_bar_1.addWidget(self.bandpass_high_input)
- top_bar_1.setSpacing(15)
- self.plot_time_btn = QPushButton("Plot Time Domain")
- self.plot_time_btn.setFixedWidth(180)
- self.plot_time_btn.clicked.connect(self.plot_time_domain)
- top_bar_1.addWidget(self.plot_time_btn)
- # --- Дополнительные поля для спектра ---
- freq_range_label = QLabel("Spectrum range (MHz):")
- self.freq_min_input = QLineEdit("-100")
- self.freq_min_input.setFixedWidth(60)
- self.freq_max_input = QLineEdit("100")
- self.freq_max_input.setFixedWidth(60)
- top_bar_1.addWidget(freq_range_label)
- top_bar_1.addWidget(self.freq_min_input)
- top_bar_1.addWidget(self.freq_max_input)
- # Убрали чекбокс "Shift spectrum to 0?" так как он не нужен
- # Изменили текст чекбокса "Plot demod data?" -> "Plot demod data"
- self.plot_demod_checkbox = QCheckBox("Plot demod data")
- top_bar_1.addWidget(self.plot_demod_checkbox)
- self.plot_spectrum_btn = QPushButton("Plot Spectrum")
- self.plot_spectrum_btn.setFixedWidth(180)
- self.plot_spectrum_btn.clicked.connect(self.plot_spectrum)
- top_bar_1.addWidget(self.plot_spectrum_btn)
- # ---------------- Кнопка экспорта в .mat -------------
- self.export_btn = QPushButton("Export to .mat")
- self.export_btn.setFixedWidth(180)
- self.export_btn.clicked.connect(self.export_data_to_mat)
- top_bar_1.addWidget(self.export_btn)
- top_bar_1.addStretch()
- main_layout.addLayout(top_bar_1)
- # ------------ Вторая строка с элементами -----------
- top_bar_2 = QHBoxLayout()
- top_bar_2.setSpacing(15)
- label_fc = QLabel("Central freq (MHz):")
- self.fc_input = QLineEdit("0")
- self.fc_input.setFixedWidth(60)
- label_st = QLabel("Start time (ns):")
- self.start_time_input = QLineEdit("0")
- self.start_time_input.setFixedWidth(70)
- label_end = QLabel("End time (ns):")
- self.end_time_input = QLineEdit("1000")
- self.end_time_input.setFixedWidth(70)
- label_dec = QLabel("Decimation factor:")
- self.decimation_factor_input = QLineEdit("4")
- self.decimation_factor_input.setFixedWidth(60)
- top_bar_2.addWidget(label_fc)
- top_bar_2.addWidget(self.fc_input)
- top_bar_2.addSpacing(5)
- top_bar_2.addWidget(label_st)
- top_bar_2.addWidget(self.start_time_input)
- top_bar_2.addWidget(label_end)
- top_bar_2.addWidget(self.end_time_input)
- top_bar_2.addSpacing(5)
- top_bar_2.addWidget(label_dec)
- top_bar_2.addWidget(self.decimation_factor_input)
- self.demod_dec_btn = QPushButton("Demodulation + Decimation")
- self.demod_dec_btn.setFixedWidth(300)
- self.demod_dec_btn.clicked.connect(self.demodulate_and_decimate)
- top_bar_2.addWidget(self.demod_dec_btn)
- top_bar_2.addStretch()
- main_layout.addLayout(top_bar_2)
- # ============ Блоки с графиками (Time, Spectrum, Demod) =============
- plot_row_1 = QHBoxLayout()
- # --- Группа "Time Domain" ---
- self.time_group = QGroupBox("Time Domain")
- time_layout = QVBoxLayout(self.time_group)
- self.time_canvas = MplCanvas(self, width=5, height=4)
- time_layout.addWidget(self.time_canvas)
- # Добавляем тулбар:
- self.time_toolbar = NavigationToolbar(self.time_canvas, self.time_group)
- time_layout.addWidget(self.time_toolbar)
- # --- Группа "Spectrum" ---
- self.freq_group = QGroupBox("Spectrum")
- freq_layout = QVBoxLayout(self.freq_group)
- self.freq_canvas = MplCanvas(self, width=5, height=4)
- freq_layout.addWidget(self.freq_canvas)
- self.freq_toolbar = NavigationToolbar(self.freq_canvas, self.freq_group)
- freq_layout.addWidget(self.freq_toolbar)
- plot_row_1.addWidget(self.time_group, stretch=1)
- plot_row_1.addWidget(self.freq_group, stretch=1)
- main_layout.addLayout(plot_row_1)
- # --- Вторая строка с графиком демодулированного сигнала ---
- plot_row_2 = QHBoxLayout()
- self.demod_group = QGroupBox("Demodulated Signal")
- demod_layout = QVBoxLayout(self.demod_group)
- self.demod_time_canvas = MplCanvas(self, width=5, height=3)
- demod_layout.addWidget(self.demod_time_canvas)
- self.demod_toolbar = NavigationToolbar(self.demod_time_canvas, self.demod_group)
- demod_layout.addWidget(self.demod_toolbar)
- plot_row_2.addWidget(self.demod_group, stretch=1)
- main_layout.addLayout(plot_row_2)
- self.demod_group.raise_()
- # ============ DockWidget для выбора каналов ============
- dock = QDockWidget("Channels", self)
- dock.setFeatures(
- QDockWidget.DockWidgetFloatable
- | QDockWidget.DockWidgetMovable
- | QDockWidget.DockWidgetVerticalTitleBar
- )
- self.channels_list = QListWidget()
- self.channels_list.setSelectionMode(QListWidget.MultiSelection)
- self.channels_list.setMaximumWidth(250)
- dock_widget = QWidget()
- vbox = QVBoxLayout(dock_widget)
- label_ch = QLabel("Select channels:")
- vbox.addWidget(label_ch)
- vbox.addWidget(self.channels_list)
- dock_widget.setLayout(vbox)
- dock.setWidget(dock_widget)
- self.addDockWidget(Qt.RightDockWidgetArea, dock)
- # ================== Стили ===================
- self.setStyleSheet("""
- QMainWindow {
- background-color: #FAFAFA;
- }
- QDockWidget {
- background-color: #F4F7F9;
- }
- QDockWidget::title {
- background-color: #34495E;
- color: white;
- padding: 8px;
- font-size: 16px;
- }
- QGroupBox {
- border: 2px solid #3498db;
- border-radius: 9px;
- margin-top: 30px;
- font-weight: bold;
- font-size: 20px;
- }
- QGroupBox::title {
- subcontrol-origin: margin;
- subcontrol-position: top center;
- padding: -5px 25px;
- font-size: 20px;
- }
- QPushButton {
- background-color: #3DAEE9;
- color: white;
- border-radius: 9px;
- padding: 6px 16px;
- font-size: 16px;
- }
- QPushButton:hover {
- background-color: #3592CC;
- }
- QPushButton:pressed {
- background-color: #2A7098;
- }
- QCheckBox {
- font-size: 18px;
- }
- QLineEdit {
- padding: 3px;
- font-size: 18px;
- }
- QLabel {
- font-size: 18px;
- }
- """)
- self.showMaximized()
- # ========== Загрузка CSV ==========
- def load_csv(self):
- file_dialog = QFileDialog(self, "Select .csv file", ".", "CSV Files (*.csv)")
- if file_dialog.exec():
- file_path = file_dialog.selectedFiles()[0]
- else:
- return
- if not file_path:
- return
- try:
- with open(file_path, 'r', encoding='utf-8') as f:
- cleaned_lines = []
- for line in f:
- line = line.strip()
- if line.endswith(','):
- line = line[:-1]
- cleaned_lines.append(line)
- loaded_array = np.loadtxt(cleaned_lines, delimiter=',')
- if loaded_array.ndim == 1:
- loaded_array = loaded_array.reshape(-1, 1)
- self.csv_data = loaded_array
- num_cols = self.csv_data.shape[1]
- self.channels_list.clear()
- for i in range(num_cols):
- channel_name = string.ascii_uppercase[i] if i < 26 else f"CH{i}"
- item = QListWidgetItem(f"Channel {channel_name}")
- if i == 0:
- item.setCheckState(Qt.Checked)
- else:
- item.setCheckState(Qt.Unchecked)
- self.channels_list.addItem(item)
- self.loaded_file_path = file_path
- QMessageBox.information(
- self, "File loaded", f"CSV file loaded:\n{file_path}"
- )
- except Exception as e:
- QMessageBox.warning(self, "Error loading", f"Could not load file:\n{e}")
- self.csv_data = None
- # ========== Определение интервала времени ==========
- def get_time_slice_indices(self):
- if self.csv_data is None:
- return None, None
- try:
- start_ns = float(self.start_time_input.text())
- end_ns = float(self.end_time_input.text())
- if end_ns <= start_ns:
- raise ValueError("end <= start")
- except ValueError:
- return None, None
- dt_ns = self.sampling_rate_ns
- start_idx = int(round(start_ns / dt_ns))
- end_idx = int(round(end_ns / dt_ns))
- if start_idx < 0:
- start_idx = 0
- if end_idx > self.csv_data.shape[0]:
- end_idx = self.csv_data.shape[0]
- if start_idx >= end_idx:
- return None, None
- return start_idx, end_idx
- # ========== Выбор каналов ==========
- def get_selected_channels(self):
- indices = []
- for i in range(self.channels_list.count()):
- item = self.channels_list.item(i)
- if item.checkState() == Qt.Checked:
- indices.append(i)
- return indices
- # ========== Полосовой фильтр через FFT ==========
- def bandpass_filter_fft(self, data, dt_s, f_low_hz, f_high_hz):
- N = data.shape[0]
- num_ch = data.shape[1]
- filtered = np.zeros_like(data, dtype=float)
- freqs = np.fft.fftfreq(N, d=dt_s)
- for ch in range(num_ch):
- y = data[:, ch]
- Y = np.fft.fft(y)
- # Создаём маску в нужном диапазоне
- mask = (np.abs(freqs) >= f_low_hz) & (np.abs(freqs) <= f_high_hz)
- Y[~mask] = 0.0
- y_filt = np.fft.ifft(Y)
- filtered[:, ch] = np.real(y_filt)
- return filtered
- # ========== Применить фильтр при необходимости ==========
- def apply_bandpass_if_needed(self, data, dt_s):
- out = data.copy()
- if self.use_bandpass_checkbox.isChecked():
- try:
- f_low_mhz = float(self.bandpass_low_input.text())
- f_high_mhz = float(self.bandpass_high_input.text())
- if f_high_mhz <= f_low_mhz:
- raise ValueError
- except ValueError:
- QMessageBox.warning(self, "Bandpass Error", "Incorrect bandpass frequencies.")
- else:
- f_low_hz = f_low_mhz * 1e6
- f_high_hz = f_high_mhz * 1e6
- out = self.bandpass_filter_fft(out, dt_s, f_low_hz, f_high_hz)
- return out
- # ========== Построение временной области ==========
- def plot_time_domain(self):
- if self.csv_data is None:
- QMessageBox.warning(self, "Error", "No CSV loaded.")
- return
- start_idx, end_idx = self.get_time_slice_indices()
- if start_idx is None or end_idx is None:
- QMessageBox.warning(self, "Error", "Invalid time interval.")
- return
- selected_channels = self.get_selected_channels()
- if not selected_channels:
- QMessageBox.warning(self, "Error", "No channels selected.")
- return
- raw_cropped = self.csv_data[start_idx:end_idx, :]
- # Перевод в вольты
- raw_cropped_v = raw_cropped / 32767.0 * 5.0
- dt_s = self.sampling_rate_ns * 1e-9
- data_for_plot = self.apply_bandpass_if_needed(raw_cropped_v, dt_s)
- self.time_canvas.ax.clear()
- time_axis_ns = np.arange(start_idx, end_idx) * self.sampling_rate_ns
- channel_labels = [
- f"Channel {string.ascii_uppercase[ch] if ch<26 else ch}"
- for ch in selected_channels
- ]
- plot_time_domain(
- axis=self.time_canvas.ax,
- time_axis_ns=time_axis_ns,
- data=data_for_plot[:, selected_channels],
- channels=channel_labels
- )
- self.time_canvas.draw()
- # ========== Построение спектра ==========
- def plot_spectrum(self):
- if self.csv_data is None:
- QMessageBox.warning(self, "Error", "No CSV loaded.")
- return
- try:
- frequency_mhz = float(self.fc_input.text())
- except ValueError:
- frequency_mhz = 0.0
- try:
- freq_min = float(self.freq_min_input.text())
- freq_max = float(self.freq_max_input.text())
- except ValueError:
- freq_min, freq_max = None, None
- start_idx, end_idx = self.get_time_slice_indices()
- if start_idx is None or end_idx is None:
- QMessageBox.warning(self, "Error", "Invalid time interval.")
- return
- selected_channels = self.get_selected_channels()
- if not selected_channels:
- QMessageBox.warning(self, "Error", "No channels selected.")
- return
- # Если включён режим построения спектра демодулированных данных
- if self.plot_demod_checkbox.isChecked():
- if self.demod_data is None or self.demod_time_ns is None:
- QMessageBox.warning(self, "Error", "No demodulated data. Please demodulate first.")
- return
- decimation_factor = self.get_decimation_factor()
- if not decimation_factor:
- return
- dt_s = (self.sampling_rate_ns * 1e-9) * decimation_factor
- data_for_fft = self.demod_data[:, selected_channels]
- n_win = data_for_fft.shape[0]
- freq_axis_mhz = np.fft.fftfreq(n_win, d=dt_s) / 1e6
- self.freq_canvas.ax.clear()
- channel_labels = [
- f"Channel {string.ascii_uppercase[ch] if ch<26 else ch}"
- for ch in selected_channels
- ]
- plot_spectrum(
- axis=self.freq_canvas.ax,
- frequency=freq_axis_mhz,
- data=data_for_fft,
- channels=channel_labels,
- middle_frequency=0.0 # демодулированные данные находятся в базовой полосе
- )
- if freq_min is not None and freq_max is not None and freq_max > freq_min:
- self.freq_canvas.ax.set_xlim([freq_min, freq_max])
- self.freq_canvas.draw()
- else:
- raw_cropped = self.csv_data[start_idx:end_idx, :]
- raw_cropped_v = raw_cropped / 32767.0 * 5.0
- dt_s = self.sampling_rate_ns * 1e-9
- data_for_fft = self.apply_bandpass_if_needed(raw_cropped_v, dt_s)
- n_win = data_for_fft.shape[0]
- freq_axis_mhz = np.fft.fftfreq(n_win, d=dt_s) / 1e6
- self.freq_canvas.ax.clear()
- channel_labels = [
- f"Channel {string.ascii_uppercase[ch] if ch<26 else ch}"
- for ch in selected_channels
- ]
- plot_spectrum(
- axis=self.freq_canvas.ax,
- frequency=freq_axis_mhz,
- data=data_for_fft[:, selected_channels],
- channels=channel_labels,
- middle_frequency=frequency_mhz # центральная частота берётся из поля
- )
- if freq_min is not None and freq_max is not None and freq_max > freq_min:
- self.freq_canvas.ax.set_xlim([freq_min, freq_max])
- self.freq_canvas.draw()
- def get_decimation_factor(self):
- try:
- decimation_factor = int(self.decimation_factor_input.text())
- if decimation_factor < 1:
- raise ValueError
- return decimation_factor
- except ValueError:
- QMessageBox.warning(self, "Error", "Invalid decimation factor.")
- return None
- # ========== Демодуляция и децимация ==========
- def demodulate_and_decimate(self):
- if self.csv_data is None:
- QMessageBox.warning(self, "Error", "No CSV loaded.")
- return
- try:
- frequency_mhz = float(self.fc_input.text())
- except ValueError:
- frequency_mhz = 0.0
- decimation_factor = self.get_decimation_factor()
- if not decimation_factor:
- return
- start_idx, end_idx = self.get_time_slice_indices()
- if start_idx is None or end_idx is None:
- QMessageBox.warning(self, "Error", "Invalid time interval.")
- return
- selected_channels = self.get_selected_channels()
- if not selected_channels:
- QMessageBox.warning(self, "Error", "Select at least one channel.")
- return
- cropped_data = self.csv_data[start_idx:end_idx, :]
- cropped_data_v = cropped_data / 32767.0 * 5.0
- dt_s = self.sampling_rate_ns * 1e-9
- data_for_demod = self.apply_bandpass_if_needed(cropped_data_v, dt_s)
- n_win = data_for_demod.shape[0]
- t_vec = np.arange(n_win) * dt_s
- fc_hz = frequency_mhz * 1e6
- # Демодуляция: перенос на нулевую частоту
- self.demod_data = np.zeros((n_win, len(selected_channels)), dtype=np.complex64)
- for idx, ch in enumerate(selected_channels):
- y = data_for_demod[:, ch]
- mixer = np.exp(-1j * 2 * np.pi * fc_hz * t_vec)
- self.demod_data[:, idx] = y * mixer
- # Децимация
- self.demod_data = self.demod_data[::decimation_factor, :]
- decimated_dt_s = dt_s * decimation_factor
- self.demod_time_ns = np.arange(self.demod_data.shape[0]) * decimated_dt_s * 1e9
- # Рисуем демодулированный сигнал (I-компонент)
- self.demod_time_canvas.ax.clear()
- channel_labels = [
- f"Channel {string.ascii_uppercase[ch] if ch<26 else ch}"
- for ch in selected_channels
- ]
- plot_demodulated_time_domain(
- ax=self.demod_time_canvas.ax,
- time_ns=self.demod_time_ns,
- demod_data=self.demod_data,
- selected_channels=channel_labels
- )
- self.demod_time_canvas.draw()
- QMessageBox.information(
- self, "Demodulation",
- f"Demod on {frequency_mhz} MHz + decim x{decimation_factor} done!"
- )
- # ========== Экспорт в .mat ==========
- def export_data_to_mat(self):
- if self.demod_data is None or self.demod_time_ns is None:
- QMessageBox.warning(self, "Error", "No demodulated data.")
- return
- file_dialog = QFileDialog(self, "Save as .mat", ".", "MAT files (*.mat)")
- file_dialog.setAcceptMode(QFileDialog.AcceptSave)
- file_dialog.setDefaultSuffix("mat")
- if file_dialog.exec():
- save_path = file_dialog.selectedFiles()[0]
- else:
- return
- if not save_path:
- return
- try:
- savemat(save_path, {
- 'time_ns': self.demod_time_ns,
- 'demod_data': self.demod_data
- })
- QMessageBox.information(self, "Export", f"Saved:\n{save_path}")
- except Exception as e:
- QMessageBox.warning(self, "Error saving", f"Could not save:\n{e}")
- def main():
- app = QApplication(sys.argv)
- window = MainWindow()
- window.show()
- sys.exit(app.exec())
- main()
|