diff --git a/cfg/terascan.cfg b/cfg/terascan.cfg index 275a27b..3406a67 100644 --- a/cfg/terascan.cfg +++ b/cfg/terascan.cfg @@ -1,6 +1,6 @@ global: # list of modules to load when starting - startup_modules: [terascan_gui] + startup_modules: [photon_counts_time_average_gui, terascan_gui] # Module server configuration for accessing qudi GUI/logic/hardware modules from remote clients remote_modules_server: @@ -38,6 +38,13 @@ gui: connect: terascan_logic: terascan_logic + photon_counts_time_average_gui: + module.Class: 'swabian.photon_counts_time_average_gui.PhotonCountsTimeAverageGui' + connect: + swabian_timetagger: 'swabian_timetagger' + options: + ring_buffer_length_s: 5 + logic: terascan_logic: module.Class: 'terascan_logic.TerascanLogic' @@ -48,6 +55,7 @@ logic: daq: nidaq # Note that this logic assumes there is exactly one (digital) input to the DAQ. options: record_length_ms: 1 # Length of data (in ms) to take at each wavelength. Only integers. + laser_timeout_s: 10 # time in seconds before we automatically restart the laser scan # daq_reader_logic: diff --git a/src/qudi/gui/swabian/photon_counts_time_average_gui.py b/src/qudi/gui/swabian/photon_counts_time_average_gui.py new file mode 100644 index 0000000..544a765 --- /dev/null +++ b/src/qudi/gui/swabian/photon_counts_time_average_gui.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- + +__all__ = ['PhotonCountsTimeAverageGui'] + +import numpy as np +import os +from PySide2 import QtCore, QtGui +from typing import List +from time import sleep +from collections import deque + +from qudi.util.datastorage import TextDataStorage +from qudi.core.module import GuiBase +from qudi.core.connector import Connector +from qudi.core.statusvariable import StatusVar +from qudi.core.configoption import ConfigOption +from qudi.gui.swabian.photon_counts_time_average_main_window import PhotonCountsTimeAverageMainWindow +from qudi.util.paths import get_artwork_dir + +from qudi.logic.terascan_logic import TerascanData + +# TODO: put the maxlen in the config file +class PhotonCountsTimeAverageGui(GuiBase): + """ Photon Counting Time Average GUI + + example config for copy-paste: + photon_counts_time_average_gui: + module.Class: 'swabian.photon_counts_time_average_gui.PhotonCountsTimeAverageGui' + connect: + swabian_timetagger: 'swabian_timetagger' + options: + ring_buffer_length_s: 10 + """ + # One logic module + _ring_buffer_length_s = ConfigOption(name='ring_buffer_length_s', default=10, missing='warn') + _photon_counts_logic = Connector(name='swabian_timetagger', interface='FastCounterInterface') + + def on_activate(self) -> None: + self._data = deque(maxlen=1000 * self._ring_buffer_length_s) + + # initialize the main window + self._mw = PhotonCountsTimeAverageMainWindow() + + # Signals from GUI: + self._mw.start_button.clicked.connect(self._photon_counts_logic().start_measure) + + # Connect signals from logic modules + # The first function is a QtCore.Slot + self._photon_counts_logic().sigScanFinished.connect( + self._counts_changed, QtCore.Qt.QueuedConnection + ) + + # Turn on update timer: + self.__timer = QtCore.QTimer() + self.__timer.setSingleShot(False) # False means that the timer will repeat + self.__timer.timeout.connect(self.__update_plot) + self.__timer.start(250) # 250 ms + + self.show() + + + def on_deactivate(self) -> None: + # When you call a connector, you should do it as a function, as shown here. Noone knows why. + # For some reason, when connecting to an external connector, you also need to specify which function you are disconnecting + self._photon_counts_logic().sigPhotonCounts.disconnect(self._counts_changed) + + self._mw.start_button.clicked.disconnect() + + # disable update timer: + self.__timer.stop() + self.__timer.timeout.disconnect() + self.__timer = None + + self._mw.close() + + def show(self) -> None: + """ Mandatory method to show the main window """ + self._mw.show() + self._mw.raise_() + + @QtCore.Slot(np.ndarray) + def _counts_changed(self, counts: np.ndarray) -> None: + self._data.append(np.sum(counts)) + + def __update_plot(self) -> None: + if (len(self._data) == 0): + return + + x = range(len(self._data)) + y = list(self._data) + + + self._mw.data_item.setData(x = x, y = y) \ No newline at end of file diff --git a/src/qudi/gui/swabian/photon_counts_time_average_main_window.py b/src/qudi/gui/swabian/photon_counts_time_average_main_window.py new file mode 100644 index 0000000..e5a5b89 --- /dev/null +++ b/src/qudi/gui/swabian/photon_counts_time_average_main_window.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- + +__all__ = ['PhotonCountsTimeAverageMainWindow'] + +import os # imported for potential future use (e.g., loading icons or other assets) +from PySide2 import QtGui, QtCore, QtWidgets +import pyqtgraph as pg + +# Although these imports are not used in the current code, they might be needed for extended functionality. +from qudi.util.widgets.plotting.image_widget import ImageWidget +from qudi.util.paths import get_artwork_dir +from qudi.util.colordefs import QudiPalettePale as palette +from qudi.hardware.laser.solstis_constants import * # wildcard import used per Qudi convention + +class PhotonCountsTimeAverageMainWindow(QtWidgets.QMainWindow): + """ + Main window for displaying time-averaged photon counts. + + This window contains a live-updating plot using pyqtgraph that displays photon counts on the y-axis + versus time (in seconds) on the x-axis. The current implementation sets up the plot layout and visual style. + Additional widgets, such as an LCD display for the live number, are provided as commented code for future use. + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Set the window title to reflect its function + self.setWindowTitle('Time Averaged Photon Counts') + + # Create the plot widget using pyqtgraph + self.plot_widget = pg.PlotWidget() + # Set minimal margins for the plot area + self.plot_widget.getPlotItem().setContentsMargins(1, 1, 1, 1) + # Ensure the widget expands with the window + self.plot_widget.setSizePolicy(QtWidgets.QSizePolicy.Expanding, + QtWidgets.QSizePolicy.Expanding) + # Prevent the plot widget from receiving focus + self.plot_widget.setFocusPolicy(QtCore.Qt.FocusPolicy.NoFocus) + # Set axis labels + self.plot_widget.setLabel('bottom', text='Time', units='s') + self.plot_widget.setLabel('left', text='Counts') + + # Create a PlotDataItem for displaying data points on the plot + self.data_item = pg.PlotDataItem( + pen=pg.mkPen(palette.c1, style=QtCore.Qt.DotLine), + symbol='o', + symbolPen=palette.c1, + symbolBrush=palette.c1, + symbolSize=7 + ) + self.plot_widget.addItem(self.data_item) + + self.start_button = QtWidgets.QPushButton('Start') + + # Optional: An LCD widget to display the current photon count as a number. + # Uncomment and adjust if a numerical display is required. + # self.lcd = QtWidgets.QLCDNumber() + # self.lcd.setDigitCount(5) + + # Arrange widgets in a grid layout + layout = QtWidgets.QGridLayout() + # Place the plot widget spanning 4 rows and 4 columns + layout.addWidget(self.plot_widget, 0, 0, 4, 4) + layout.addWidget(self.start_button, 4, 5, 1, 1) + # Set column stretch to ensure proper scaling + layout.setColumnStretch(1, 1) + + # Create a central widget, set the layout, and then assign it as the main window's central widget + central_widget = QtWidgets.QWidget() + central_widget.setLayout(layout) + self.setCentralWidget(central_widget) diff --git a/src/qudi/gui/terascan/terascan_gui.py b/src/qudi/gui/terascan/terascan_gui.py index c99ba1e..c938253 100644 --- a/src/qudi/gui/terascan/terascan_gui.py +++ b/src/qudi/gui/terascan/terascan_gui.py @@ -22,7 +22,7 @@ class TerascanGui(GuiBase): """ Terascan Measurement GUI - exaple config for copy-paste: + example config for copy-paste: terascan_gui: module.Class: 'terascan.terascan_gui.TerascanGui' connect: @@ -112,6 +112,9 @@ def on_activate(self) -> None: self.__timer = QtCore.QTimer() self.__timer.setSingleShot(False) self.__timer.timeout.connect(self._update_plot) + + # Restore saved wavelengths: + self.sigSetWavelengths.emit(self._start_wavelength, self._stop_wavelength) # Show the main window and raise it above all others self.show() @@ -243,10 +246,15 @@ def _update_ui(self, running: bool) -> None: self._mw.start_stop_button.setText('Start Measurement') self._mw._statusbar.showMessage('Ready') + # TODO: Make this more efficient. There is no reason to redraw the entire plot each time. def _update_plot(self) -> None: if (len(self._data) == 0): return + + # x, y = self._mw.data_item.getData() + # new_count = len(self._data) - len(x) + img = np.zeros((len(self._data),2)) for i in range(len(self._data) - 1): img[i][0] = self._data[i].wavelength*1e-3 diff --git a/src/qudi/hardware/laser/solstis_laser.py b/src/qudi/hardware/laser/solstis_laser.py index 5bbc200..795fefa 100644 --- a/src/qudi/hardware/laser/solstis_laser.py +++ b/src/qudi/hardware/laser/solstis_laser.py @@ -18,6 +18,7 @@ from typing import List from PySide2 import QtCore +from time import time from qudi.core.configoption import ConfigOption from qudi.core.statusvariable import StatusVar @@ -79,6 +80,8 @@ def on_activate(self): if (self._scan_type == -1): self._scan_type = self.get_default_scan_type() self._scan_rate = self.get_default_scan_rate() + + self._scan_started = time() def on_deactivate(self): """ Deactivate module. @@ -237,6 +240,7 @@ def start_scan(self) -> bool: pause=True) solstis.scan_stitch_op(self.socket, self._scan_type, "start") self.sigScanStarted.emit() + self._scan_started = time() self.module_state.lock() return True @@ -257,6 +261,19 @@ def stop_scan(self) -> bool: except solstis.SolstisError as e: self.log.exception(f'Scan stop failure: {e.message}') return False + + @QtCore.Slot(float) + def restart_scan(self, wavelength: float) -> bool: + """Restart a scan from a specified wavelength""" + + if self.module_state() == 'locked': + self.module_state.unlock() + self._start_wavelength = wavelength + self._scan_started = time() + return self.start_scan() + else: + self.log.warning('Restart scan called when not running') + return False def pause_scan(self): """Pause a running scan (unimplemented)""" @@ -358,7 +375,8 @@ def set_scan_rate(self, scan_rate: int): def __status_update(self): - if self.module_state() == 'locked': + if self.module_state() == 'locked' \ + and self._scan_started + 3 < time(): # if we check for status too soon, we might get a false positive status = self.get_laser_state() if status['in_progress'] == False: self.sigScanFinished.emit() diff --git a/src/qudi/logic/terascan_logic.py b/src/qudi/logic/terascan_logic.py index 1fefc9b..2cb3efe 100644 --- a/src/qudi/logic/terascan_logic.py +++ b/src/qudi/logic/terascan_logic.py @@ -40,7 +40,7 @@ class TerascanLogic(LogicBase): daq: nidaq # Note that this logic assumes there is exactly one (digital) input to the DAQ. options: record_length_ms: 1 # Length of data (in ms) to take at each wavelength. Only integers. - + laser_timeout_s: 10 # Time in seconds to wait for the laser to lock before automatically restarting the scan """ # declare connectors @@ -53,6 +53,8 @@ class TerascanLogic(LogicBase): _record_length_ms = ConfigOption(name='record_length_ms', default=1, missing='info') + + _laser_timeout_s = ConfigOption(name='laser_timeout_s', default=10) # status variables: _start_wavelength = StatusVar('start_wavelength', default=0.75) @@ -65,6 +67,7 @@ class TerascanLogic(LogicBase): _laser_locked = StatusVar('laser_locked', default=False) _current_data = StatusVar('current_data', default=[]) # list of TerascanData + _last_locked: float = 0 # Update signals, e.g. for GUI module sigWavelengthUpdated = QtCore.Signal(float) @@ -76,6 +79,7 @@ class TerascanLogic(LogicBase): sigSetLaserWavelengths = QtCore.Signal(float, float) sigSetLaserScanRate = QtCore.Signal(int) sigSetLaserScanType = QtCore.Signal(int) + sigRestartLaser = QtCore.Signal(float) # used when we need to restart the laser, as everything else is already running sigStartScan = QtCore.Signal() sigStopScan = QtCore.Signal() @@ -87,7 +91,7 @@ class TerascanLogic(LogicBase): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - + self.__timer = None self._thread_lock = RecursiveMutex() def on_activate(self): @@ -101,6 +105,7 @@ def on_activate(self): self.sigSetLaserWavelengths.connect(laser.set_wavelengths, QtCore.Qt.QueuedConnection) self.sigSetLaserScanRate.connect(laser.set_scan_rate, QtCore.Qt.QueuedConnection) self.sigSetLaserScanType.connect(laser.set_scan_type, QtCore.Qt.DirectConnection) # Is direct on purpose + self.sigRestartLaser.connect(laser.restart_scan, QtCore.Qt.QueuedConnection) self.sigStartScan.connect(counter.start_measure, QtCore.Qt.QueuedConnection) self.sigStartScan.connect(laser.start_scan, QtCore.Qt.QueuedConnection) @@ -124,9 +129,17 @@ def on_activate(self): self._record_length_s = self._record_length_ms * 1e-3 self._bin_width_s = self._record_length_s self.sigConfigureCounter.emit(self._bin_width_s, self._record_length_s) + + # Add watchdog timer + self.__timer = QtCore.QTimer() + self.__timer.setSingleShot(False) + self.__timer.timeout.connect(self.__watchdog) + self.__timer.start(500) def on_deactivate(self): - pass + self.__timer.stop() + self.__timer.timeout.disconnect() + self.__timer = None @property def locked(self) -> bool: @@ -146,6 +159,9 @@ def start_scan(self): if self.module_state() == 'idle': self.module_state.lock() self._current_data = [] + self._last_locked = time.time() + self.sigSetLaserWavelengths.emit(self._start_wavelength, self._end_wavelength) + self.sigStopCounting.emit() # In case we are counting from somewhere else self.sigStartScan.emit() @QtCore.Slot() @@ -243,3 +259,17 @@ def _new_daq_data(self, data: List[ReaderVal]): # self.sigStopCounting.emit() self.sigLaserLocked.emit(self._laser_locked) + + + #### Watchdog Timer #### + def __watchdog(self): + with self._thread_lock: + if self.module_state() == 'locked': + if self._laser_locked: + self._last_locked = time.time() + elif time.time() - self._last_locked > self._laser_timeout_s: + self.log.info('Laser did not lock in time. Restarting scan.') + new_start = self._current_wavelength*1e-3 + self._last_locked = time.time() + self.sigRestartLaser.emit(new_start) +