diff --git a/src/qudi/gui/data_analysis/data_analysis_gui.py b/src/qudi/gui/data_analysis/data_analysis_gui.py new file mode 100644 index 0000000..5b7d709 --- /dev/null +++ b/src/qudi/gui/data_analysis/data_analysis_gui.py @@ -0,0 +1,187 @@ +# -*- coding: utf-8 -*- +__all__ = ['DataAnalysisGui'] + +import os +import numpy as np +from PySide2 import QtCore, QtWidgets, QtGui +import pyqtgraph as pg +from qudi.core.module import GuiBase +from qudi.core.connector import Connector +from qudi.core.configoption import ConfigOption +from qudi.util.paths import get_artwork_dir + +class DataAnalysisMainWindow(QtWidgets.QMainWindow): + """ Main window for the Data Analysis GUI """ + def __init__(self, data_color, fit_color, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setWindowTitle('Data Analysis') + self.resize(1250, 500) + + # Menu bar + menu_bar = QtWidgets.QMenuBar() + file_menu = menu_bar.addMenu('File') + self.action_load_data = QtWidgets.QAction('Load Data', self) + path = os.path.join(get_artwork_dir(), 'icons', 'document-open') + self.action_load_data.setIcon(QtGui.QIcon(path)) + file_menu.addAction(self.action_load_data) + self.action_copy_plot = QtWidgets.QAction('Copy Plot to Clipboard', self) + path = os.path.join(get_artwork_dir(), 'icons', 'edit-copy') + self.action_copy_plot.setIcon(QtGui.QIcon(path)) + file_menu.addAction(self.action_copy_plot) + self.setMenuBar(menu_bar) + + # Plot widget + self.plot_widget = pg.PlotWidget() + self.plot_widget.setLabel('bottom', 'Wavelength / Frequency') + self.plot_widget.setLabel('left', 'Counts') + self.data_item = pg.PlotDataItem(pen=pg.mkPen(data_color)) + self.plot_widget.addItem(self.data_item) + + # Control panel + self.load_button = QtWidgets.QPushButton('Load Most Recent Data') + self.home_button = QtWidgets.QPushButton('Home Plot') + self.window_size_spin = QtWidgets.QSpinBox() + self.window_size_spin.setRange(1, 100) + self.window_size_spin.setValue(5) + self.height_spin = QtWidgets.QDoubleSpinBox() + self.height_spin.setRange(0, 10000) + self.height_spin.setValue(4) + self.distance_spin = QtWidgets.QDoubleSpinBox() + self.distance_spin.setRange(0, 100) + self.distance_spin.setValue(1) + self.prominence_spin = QtWidgets.QDoubleSpinBox() + self.prominence_spin.setRange(0, 10000) + self.prominence_spin.setValue(0.3) + self.unit_combo = QtWidgets.QComboBox() + self.unit_combo.addItems(['um', 'nm', 'THz', 'GHz', 'MHz']) + self.fit_button = QtWidgets.QPushButton('Fit Peaks') + + # Layout + control_layout = QtWidgets.QFormLayout() + control_layout.addRow('Running Avg Window:', self.window_size_spin) + control_layout.addRow('Min Peak Height:', self.height_spin) + control_layout.addRow('Min Distance (GHz):', self.distance_spin) + control_layout.addRow('Min Prominence:', self.prominence_spin) + control_layout.addRow('X-Axis Unit:', self.unit_combo) + control_layout.addWidget(self.fit_button) + control_layout.addWidget(self.load_button) + control_layout.addWidget(self.home_button) # New Home Plot button + + main_layout = QtWidgets.QHBoxLayout() + main_layout.addWidget(self.plot_widget) + main_layout.addLayout(control_layout) + + central_widget = QtWidgets.QWidget() + central_widget.setLayout(main_layout) + self.setCentralWidget(central_widget) + + # Store fit color for use in update_plot + self.fit_color = fit_color + +class DataAnalysisGui(GuiBase): + """ Qudi GUI module for data analysis """ + _data_analysis_logic = Connector(name='data_analysis_logic', interface='DataAnalysisLogic') + data_color = ConfigOption('data_color', default='#00CED1') + fit_color = ConfigOption('fit_color', default='#FF6F61') + data_dir = ConfigOption('data_dir', default='C:\\Users\\hoodl\\qudi\\Data') # Default data directory + + def on_activate(self): + self._mw = DataAnalysisMainWindow(data_color=self.data_color, fit_color=self.fit_color) + self._mw.action_load_data.triggered.connect(self.load_data) + self._mw.action_copy_plot.triggered.connect(self.copy_plot_to_clipboard) + self._mw.load_button.clicked.connect(self.load_most_recent_data) + self._mw.fit_button.clicked.connect(self.fit_peaks) + self._mw.home_button.clicked.connect(self.home_plot) # Connect Home Plot button + self._mw.unit_combo.currentTextChanged.connect(self.update_plot_units) + self._data_analysis_logic().sigPlotUpdated.connect(self.update_plot) + # Initialize current file location as empty + self.current_file_path = "" + self.show() + + def on_deactivate(self): + self._mw.close() + + def show(self): + self._mw.show() + + @QtCore.Slot() + def load_data(self): + file_path, _ = QtWidgets.QFileDialog.getOpenFileName(self._mw, 'Load Data', self.data_dir, 'Data files (*.dat)') + if file_path: + self.current_file_path = file_path + self._data_analysis_logic().load_data(file_path) + # Immediately update the plot title to show the data location. + self._mw.plot_widget.setTitle(file_path) + + @QtCore.Slot() + def load_most_recent_data(self): + # For most recent data, if the logic module does not return the location, + # we use a default string. + self.current_file_path = self._data_analysis_logic()._get_most_recent_data_file() + self._data_analysis_logic().load_most_recent_data() + + @QtCore.Slot() + def fit_peaks(self): + params = { + 'window_size': self._mw.window_size_spin.value(), + 'height': self._mw.height_spin.value(), + 'distance': self._mw.distance_spin.value(), + 'prominence': self._mw.prominence_spin.value(), + 'unit': self._mw.unit_combo.currentText() + } + self._data_analysis_logic().analyze_data(params) + + @QtCore.Slot(str) + def update_plot_units(self, unit): + if self._data_analysis_logic()._data is not None: + params = { + 'window_size': self._mw.window_size_spin.value(), + 'height': self._mw.height_spin.value(), + 'distance': self._mw.distance_spin.value(), + 'prominence': self._mw.prominence_spin.value(), + 'unit': unit + } + self._data_analysis_logic().analyze_data(params) + + @QtCore.Slot(object) + def update_plot(self, plot_data): + x_data, y_data, fits = plot_data + self._mw.plot_widget.setLabel('bottom', f'Wavelength / Frequency ({self._mw.unit_combo.currentText()})') + self._mw.data_item.setData(x=x_data, y=y_data) + # Clear previous fits + for item in self._mw.plot_widget.listDataItems(): + if item != self._mw.data_item: + self._mw.plot_widget.removeItem(item) + for text in self._mw.plot_widget.items(): + if isinstance(text, pg.TextItem): + self._mw.plot_widget.removeItem(text) + # Add new fits + for fit in fits: + fit_item = pg.PlotDataItem(x=fit['x_fit'], y=fit['y_fit'], pen=pg.mkPen(self._mw.fit_color)) + self._mw.plot_widget.addItem(fit_item) + # Add FWHM annotation + text = pg.TextItem(text=f"{np.abs(fit['fwhm_mhz']):.1f} MHz", anchor=(0.5, 1)) + self._mw.plot_widget.addItem(text) + text.setPos(fit['center'], fit['amplitude'] + fit['offset']) + # Auto-home the plot after new data is loaded + self.home_plot() + # Update the title with the current file location. + self._mw.plot_widget.setTitle(self.current_file_path) + + @QtCore.Slot() + def home_plot(self): + # Get the current data from the plot data item. + x_data, y_data = self._mw.data_item.getData() + if x_data is None or y_data is None or len(x_data) == 0: + return + xmin, xmax = np.min(x_data), np.max(x_data) + ymin, ymax = np.min(y_data), np.max(y_data) + # Set x range to exactly span the data. + self._mw.plot_widget.setXRange(xmin, xmax) + # Set y range from y min to y max scaled by 1.1. + self._mw.plot_widget.setYRange(ymin, ymax * 1.1) + + @QtCore.Slot() + def copy_plot_to_clipboard(self): + pixmap = self._mw.plot_widget.grab() + QtWidgets.QApplication.clipboard().setPixmap(pixmap) diff --git a/src/qudi/gui/terascan/terascan_gui.py b/src/qudi/gui/terascan/terascan_gui.py index 43898e5..a6882f8 100644 --- a/src/qudi/gui/terascan/terascan_gui.py +++ b/src/qudi/gui/terascan/terascan_gui.py @@ -10,6 +10,7 @@ from qudi.util.datastorage import TextDataStorage from qudi.core.module import GuiBase from qudi.core.connector import Connector +from qudi.core.configoption import ConfigOption from qudi.core.statusvariable import StatusVar from qudi.gui.terascan.terascan_main_window import TerascanMainWindow from qudi.util.paths import get_artwork_dir @@ -35,6 +36,7 @@ class TerascanGui(GuiBase): # Connector to the logic module _terascan_logic = Connector(name='terascan_logic', interface='TerascanLogic') + save_dir = ConfigOption('save_dir', default='C:\\Users\\hoodl\\qudi\\Data') # Status variables saved in the AppStatus: _start_wavelength = StatusVar(name='start_wavelength', default=0.775) @@ -186,7 +188,7 @@ def _wavelength_changed(self, wave: float) -> None: @QtCore.Slot() def _save_data(self) -> None: ds = TextDataStorage( - root_dir=self.module_default_data_dir, + root_dir=self.save_dir, # Use the configurable save directory column_formats='.15e' ) array = np.array([(d.wavelength, d.counts) for d in self._data]) diff --git a/src/qudi/gui/terascan/terascan_main_window.py b/src/qudi/gui/terascan/terascan_main_window.py index 9e3ac74..dab0b3d 100644 --- a/src/qudi/gui/terascan/terascan_main_window.py +++ b/src/qudi/gui/terascan/terascan_main_window.py @@ -4,6 +4,7 @@ import os from PySide2 import QtGui, QtCore, QtWidgets import pyqtgraph as pg +pg.setConfigOption('useOpenGL', True) # Add this at the top of your file from qudi.util.widgets.plotting.image_widget import ImageWidget from qudi.util.paths import get_artwork_dir @@ -16,6 +17,7 @@ class TerascanMainWindow(QtWidgets.QMainWindow): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.setWindowTitle('Terascan Measurement') + self.resize(1250, 500) # Create menu bar menu_bar = QtWidgets.QMenuBar() @@ -68,6 +70,7 @@ def __init__(self, *args, **kwargs): self.scan_type = QtWidgets.QComboBox() self.plot_widget = pg.PlotWidget() + self.plot_widget.setAntialiasing(False) self.plot_widget.getPlotItem().setContentsMargins(1, 1, 1, 1) self.plot_widget.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) @@ -76,15 +79,14 @@ def __init__(self, *args, **kwargs): self.plot_widget.setLabel('left', text='Counts') self.data_item = pg.PlotDataItem( - pen=pg.mkPen(palette.c1, style=QtCore.Qt.DotLine), - symbol='o', - symbolPen=palette.c1, - symbolBrush=palette.c1, - symbolSize=7 + pen=pg.mkPen(palette.c1, style=QtCore.Qt.SolidLine), + # downsampling if you want + # downsample=10, # Render 1 out of every 10 points + # downsampleMethod='mean' # Average points for smoother results ) self.plot_widget.addItem(self.data_item) - # New Running Average controls + # Running Average controls self.checkbox_running_avg = QtWidgets.QCheckBox("Enable Running Average") self.checkbox_running_avg.setChecked(False) self.label_avg_points = QtWidgets.QLabel("Points in Rolling Average:") diff --git a/src/qudi/logic/data_analysis_logic/data_analysis_logic.py b/src/qudi/logic/data_analysis_logic/data_analysis_logic.py new file mode 100644 index 0000000..ab18da6 --- /dev/null +++ b/src/qudi/logic/data_analysis_logic/data_analysis_logic.py @@ -0,0 +1,139 @@ +# -*- coding: utf-8 -*- +__all__ = ['DataAnalysisLogic'] + +import os +import glob +import numpy as np +from scipy.signal import find_peaks +from scipy.optimize import curve_fit +from qudi.core.module import LogicBase +from qudi.core.configoption import ConfigOption +from qudi.util.mutex import RecursiveMutex +from PySide2 import QtCore + +class DataAnalysisLogic(LogicBase): + """ Logic module for data analysis """ + sigPlotUpdated = QtCore.Signal(object) + + _base_path = ConfigOption('base_path', default=os.getcwd()) + _max_peaks_to_fit = ConfigOption('max_peaks_to_fit', default=10) + _maxfev = ConfigOption('maxfev', default=10000) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._thread_lock = RecursiveMutex() + self._data = None + + def on_activate(self): + pass + + def on_deactivate(self): + pass + + def load_data(self, file_path): + with self._thread_lock: + self._data = self._load_data(file_path) + self.sigPlotUpdated.emit((self._data[:, 0], self._data[:, 1], [])) + + def load_most_recent_data(self): + with self._thread_lock: + file_path = self._get_most_recent_data_file() + if file_path: + self._data = self._load_data(file_path) + self.sigPlotUpdated.emit((self._data[:, 0], self._data[:, 1], [])) + + return file_path + + def analyze_data(self, params): + with self._thread_lock: + if self._data is None: + return + x_nm = self._data[:, 0] + y_counts = self._data[:, 1] + y_smooth = self._running_average(y_counts, params['window_size']) + peaks, _ = find_peaks(y_smooth, height=params['height'], distance=self._distance_to_points(x_nm, params['distance']), prominence=params['prominence']) + fits = self._fit_peaks(x_nm, y_smooth, peaks) + x_plot = self._convert_units(x_nm, params['unit']) + fit_x_converted = [dict(fit, x_fit=self._convert_units(fit['x_fit'], params['unit']), center=self._convert_units(np.array([fit['center']]), params['unit'])[0]) for fit in fits] + self.sigPlotUpdated.emit((x_plot, y_smooth, fit_x_converted)) + + def _load_data(self, file_path): + with open(file_path, 'r') as f: + lines = [line for line in f if not line.startswith('#') and line.strip()] + data = np.array([list(map(float, line.split())) for line in lines]) + return data + + def _get_most_recent_data_file(self): + search_path = os.path.join(self._base_path, '**', '*.dat') + files = glob.glob(search_path, recursive=True) + if not files: + return None + return max(files, key=os.path.getmtime) + + def _running_average(self, y, window_size): + return np.convolve(y, np.ones(window_size)/window_size, mode='same') + + def _distance_to_points(self, x_nm, distance_ghz): + dx_nm = np.mean(np.diff(x_nm)) + x_ref = np.median(x_nm) + separation_nm = distance_ghz / (2.99792458e17 / (x_ref**2 * 1e9)) + return max(1, int(round(separation_nm / dx_nm))) + + def _lorentzian(self, x, amplitude, center, gamma, offset): + return offset + amplitude * (gamma**2) / ((x - center)**2 + gamma**2) + + def _fit_peaks(self, x_nm, y_smooth, peaks): + fits = [] + fit_width_nm = 0.01 + for pk in peaks[:self._max_peaks_to_fit]: + pk_x = x_nm[pk] + left = np.searchsorted(x_nm, pk_x - fit_width_nm) + right = np.searchsorted(x_nm, pk_x + fit_width_nm) + + # Skip if the slice is empty or too small + if right - left < 3: + continue + + x_slice = x_nm[left:right] + y_slice = y_smooth[left:right] + + # Now it's safe to call y_slice.max(), etc. + p0 = [ + y_slice.max() - y_slice.min(), + pk_x, + 0.001, + y_slice.min() + ] + + try: + popt, _ = curve_fit(self._lorentzian, x_slice, y_slice, p0=p0, maxfev=self._maxfev) + fits.append({ + 'x_fit': x_slice, + 'y_fit': self._lorentzian(x_slice, *popt), + 'center': popt[1], + 'amplitude': popt[0], + 'offset': popt[3], + 'fwhm_mhz': self._fwhm_nm_to_mhz(popt[1], 2 * popt[2]) + }) + except Exception as exc: + # If the fit fails, just skip it + self.log.warning(f'Peak fit failed: {exc}') + pass + + return fits + + def _fwhm_nm_to_mhz(self, center_nm, fwhm_nm): + return (2.99792458e17 / (center_nm**2)) * fwhm_nm / 1e6 + + def _convert_units(self, x_nm, unit): + if unit == 'nm': + return x_nm + elif unit == 'um': + return x_nm / 1000 + elif unit == 'GHz': + return 2.99792458e17 / (x_nm * 1e9) + elif unit == 'THz': + return 2.99792458e17 / (x_nm * 1e12) + elif unit == 'MHz': + return 2.99792458e17 / (x_nm * 1e6) + return x_nm \ No newline at end of file