-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #5 from hoodlabpurdue/make-more-streamlined
Speed up rendering
- Loading branch information
Showing
4 changed files
with
337 additions
and
7 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
139 changes: 139 additions & 0 deletions
139
src/qudi/logic/data_analysis_logic/data_analysis_logic.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |