Skip to content

Commit

Permalink
Speed up rendering
Browse files Browse the repository at this point in the history
Some minor changes to speed up the rendering time of the plot.
  • Loading branch information
lange50 committed Apr 14, 2025
1 parent 62404c6 commit a3305a4
Show file tree
Hide file tree
Showing 4 changed files with 337 additions and 7 deletions.
187 changes: 187 additions & 0 deletions src/qudi/gui/data_analysis/data_analysis_gui.py
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 3 additions & 1 deletion src/qudi/gui/terascan/terascan_gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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])
Expand Down
14 changes: 8 additions & 6 deletions src/qudi/gui/terascan/terascan_main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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)
Expand All @@ -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:")
Expand Down
139 changes: 139 additions & 0 deletions src/qudi/logic/data_analysis_logic/data_analysis_logic.py
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit a3305a4

Please sign in to comment.