diff --git a/CONTEXT.md b/CONTEXT.md index 467b9a4..89003d1 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -13,5 +13,14 @@ A set of mock instruments that simulate one experiment together (beam steering, setup has an experiment file in `resources/`. **Recipe**: -A cookbook example of a device pattern: an actuator and a 0D detector, with their driver in `hardware/`, that run -on a mock device and are meant to be copied and adapted. +A cookbook example of a device pattern: plugins (an actuator and a detector, 0D or 1D) with their driver in +`hardware/`, that run on a mock device and are meant to be copied and adapted. The camera recipe is the exception: it +is an existing mock plugin (`BSCamera`) completed to show the pattern. + +**Hardware ROI**: +The region of the sensor read and binned by the camera itself, in sensor pixels; the image is smaller. Opposed to the +*software ROI* drawn in the viewer and applied by the plugin after the grab (`RoiStuff`). + +**Calibrated axis**: +The axis sent with the data of a detector, in physical units (um, nm), as the instrument gives it. It can change +between two grabs (ROI, binning, central wavelength), so it is asked at every grab. diff --git a/README.rst b/README.rst index 51691e3..3772f6f 100644 --- a/README.rst +++ b/README.rst @@ -71,8 +71,8 @@ Viewer1D * **MockRandom** (`gaussian scan`_): simulates a shuffled Gaussian; shows the sort button of the 1D viewer. * **Harmonics** (`harmonics`_): simulates the harmonics spectrum of the source; shows pint units and roi_select. -* **Mock_spectro** (`spectrometer`_): simulates a spectrometer with laser line and exposure; shows a calibrated wavelength axis (nm). -* **MockSpectroErrors** (`error tests`_): simulates the spectrometer averaged over 10 acquisitions; shows data with errors from averaging. +* **CookbookSpectrometer** (`spectrometer`_): simulates a spectrometer with grating, laser line and exposure; shows a calibrated wavelength axis (nm) and the interface of the spectrometer extension. It replaces *Mock_spectro*, removed in this version: a saved preset that uses it must pick *CookbookSpectrometer* instead. +* **MockSpectroErrors** (`error tests`_): simulates a spectrometer that averages itself (hardware averaging); shows how a plugin fills the errors of the data with the standard error of the mean, as PyMoDAQ does when it averages (PyMoDAQ PR 1401). Viewer2D ++++++++ @@ -148,7 +148,7 @@ plugin to stop it), they load in the Dashboard of PyMoDAQ 5.3. **Spectrometer with a calibrated axis** - - 1D *Mock_spectro* + - 1D *CookbookSpectrometer*, *MockSpectroErrors*; actuator *CookbookSpectrometer* - none - A wavelength axis that follows a setting, data with errors * - .. _actuator interfaces: @@ -181,7 +181,8 @@ Short, runnable and tested plugins to copy from when writing a plugin for a real to a simulated device: a stage with a voltmeter (``hardware/mock_serial_visa_instrument.py``) or a 3-axis controller behind a library (``hardware/mock_dll_controller.py``). Each file starts with a header telling what to copy, what to adapt and what to keep. The tests (``tests/test_cookbook_drivers.py``, ``tests/test_cookbook_plugins.py``, -``tests/test_cookbook_dll.py``) show how to test a plugin against a simulated device with the harness of ``pymodaq.utils.plugin_testing``. +``tests/test_cookbook_dll.py``, ``tests/test_cookbook_spectrometer.py``, ``tests/test_bs_camera.py``) show how to test a +plugin against a simulated device with the harness of ``pymodaq.utils.plugin_testing``. Giving the same *controller ID* to an actuator and a detector of a recipe makes them share one driver: the first initialized is the master and creates it, the other is the slave and receives it. @@ -209,6 +210,16 @@ Loading a native library ``hardware/native_library.py``, ``hardware/system_library_demo.py`` environment variable, an error that lists what was tried. Real calls to the C library of the system (``strlen``, ``frexp``, ``qsort`` with a callback) +Camera (2D detector), ROI and binning ``daq_2Dviewer_BSCamera``, What a camera driver gives its plugin: a ROI and a binning +done by the camera ``hardware/beam_steering.py`` applied by the camera (smaller image), an exposure time that + scales the signal, axes in um (pixel size) that follow the ROI + and the binning. ``RoiStuff`` is the other way: the ROI is drawn + in the viewer and the plugin crops after the grab +Spectrometer, calibrated axis that ``daq_1Dviewer_CookbookSpectrometer``, A wavelength axis in nm asked to the instrument at every grab +changes, actuator slave of a detector ``daq_move_CookbookSpectrometer``, because the grating moves it. The actuator (central wavelength) + ``hardware/spectrometer.py`` and the detector share one instrument; a homing that waits for + its end and calls ``move_done``. It also has the interface of + the spectrometer extension (laser line, exposure time) ====================================== ============================================== ================================================================ Patterns already shown by the mock instruments of this package @@ -222,10 +233,8 @@ Several axes on one controller ``MockNamedAxes`` (actuator) Actuator and detector on the same ``MockTempController`` (actuator and 0D A temperature controller with a PID loop: the actuator is controller detector) the master, the detector is its slave 2D camera ``BSCamera`` (2D detector), Pixel axes, live acquisition, ROI selection done on the - ``RoiStuff`` viewer (``roi_select`` with ``RoiInfo``). Not shown: binning - and exposure time of a camera -1D spectrometer with a calibrated axis ``Mock_spectro`` (1D detector) A wavelength axis in nm that follows the laser line setting, - and an exposure time that scales the signal + ``RoiStuff`` viewer (``roi_select`` with ``RoiInfo``). The hardware ROI and + binning are in the cookbook above Simulated device, no hardware every plugin of this package A pure python controller behind a small wrapper class in ``hardware/``: the plugins run on it, no hardware needed ====================================== ============================================== ================================================================ diff --git a/src/pymodaq_plugins_mockexamples/daq_move_plugins/daq_move_CookbookSpectrometer.py b/src/pymodaq_plugins_mockexamples/daq_move_plugins/daq_move_CookbookSpectrometer.py new file mode 100644 index 0000000..d94f755 --- /dev/null +++ b/src/pymodaq_plugins_mockexamples/daq_move_plugins/daq_move_CookbookSpectrometer.py @@ -0,0 +1,78 @@ +"""Cookbook recipe 3b: actuator that is a slave of a detector: the grating of a spectrometer (central wavelength). + +COPY this file for an actuator that is one function of an instrument mostly used as a detector: the grating of a +spectrometer, the focus of a camera, the filter wheel of a detector... +ADAPT: ``params``, ``ini_stage`` and the calls of the driver in ``move_abs``, ``move_rel``, ``move_home``, +``stop_motion`` and ``get_actuator_value``. +KEEP: +- the master/slave handling in ``ini_stage`` and ``close``: alone the actuator owns the instrument, with the same + controller ID as the detector ``CookbookSpectrometer`` it shares the instrument of the master; +- ``user_condition_to_reach_target``: the grating takes time, so the end of the move is read from the instrument; +- ``move_home``: the homing is not polled by PyMoDAQ, so the plugin waits for its end and calls ``move_done``. + +Moving the grating changes the wavelength axis of the detector, see ``daq_1Dviewer_CookbookSpectrometer.py``. +""" +from qtpy.QtCore import QThread + +from pymodaq.control_modules.move_utility_classes import (DAQ_Move_base, comon_parameters_fun, DataActuatorType, + DataActuator, main) + +from pymodaq_plugins_mockexamples.hardware.spectrometer import Spectrometer + + +class DAQ_Move_CookbookSpectrometer(DAQ_Move_base): + """Central wavelength of the simulated spectrometer""" + _controller_units = 'nm' + is_multiaxes = False + _epsilons = 0.1 + data_actuator_type = DataActuatorType.DataActuator + params = comon_parameters_fun(is_multiaxes, epsilon=_epsilons) + + def ini_attributes(self): + self.controller: Spectrometer = None + + def ini_stage(self, controller=None): + if self.is_master: + self.controller = Spectrometer() + else: # a master detector shares its instrument + self.controller = controller + return self.controller.infos, True + + def close(self): + if self.is_master and self.controller is not None: + self.controller.close() + + def user_condition_to_reach_target(self) -> bool: + return not self.controller.moving + + def get_actuator_value(self) -> DataActuator: + position = DataActuator(data=self.controller.get_wavelength(), units=self.axis_unit) + return self.get_position_with_scaling(position) + + def move_abs(self, value: DataActuator): + value = self.check_bound(value) + self.target_value = value + value = self.set_position_with_scaling(value) + self.controller.set_wavelength(value.value(self.axis_unit)) + + def move_rel(self, value: DataActuator): + value = self.check_bound(self.current_value + value) - self.current_value + self.target_value = value + self.current_value + value = self.set_position_with_scaling(self.target_value) + self.controller.set_wavelength(value.value(self.axis_unit)) + + def move_home(self): + self.controller.find_reference() + while self.controller.moving: # PyMoDAQ does not poll the position after a home: wait for the end here + QThread.msleep(20) + self.move_done() + + def stop_motion(self): + self.controller.stop() + + def commit_settings(self, param): + pass + + +if __name__ == '__main__': + main(__file__) diff --git a/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_1D/daq_1Dviewer_CookbookSpectrometer.py b/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_1D/daq_1Dviewer_CookbookSpectrometer.py new file mode 100644 index 0000000..945bed9 --- /dev/null +++ b/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_1D/daq_1Dviewer_CookbookSpectrometer.py @@ -0,0 +1,132 @@ +"""Cookbook recipe 3a: 1D detector with an axis that is calibrated and changes: a spectrometer. + +COPY this file for an instrument whose data axis is not fixed: a spectrometer (the wavelength of each pixel depends +on the central wavelength and on the grating), an oscilloscope (time base), an analyser... +ADAPT: ``params``, ``ini_detector`` and ``grab_data`` (what the driver gives for the data and for the axis). +KEEP: +- the axis is asked to the instrument at every grab and sent with the data. Another module (here the actuator + ``CookbookSpectrometer``, a slave that turns the grating) can change it at any time; +- the units of the axis and of the data; +- the master/slave handling in ``ini_detector`` and ``close``: alone the detector owns the instrument, with the + same controller ID as the actuator it shares the instrument of the master. + +The methods ``get_spectro_wl``, ``set_spectro_wl``, ``get_laser_wl``, ``set_laser_wl``, ``get_exposure_ms`` and +``set_exposure_ms`` are the interface that the spectrometer extension calls on a spectrometer plugin: copy them to be +driven by it (the laser ones only if the laser line can be set). Each one answers by a status command carrying the +value. + +Try it with the actuator ``CookbookSpectrometer`` in the Dashboard (same controller ID), or alone: change the +central wavelength or the grating and the axis follows. See ``hardware/spectrometer.py`` for the simulated instrument. +""" +import numpy as np +from qtpy import QtWidgets +from qtpy.QtCore import QThread + +from pymodaq.control_modules.viewer_utility_classes import DAQ_Viewer_base, comon_parameters, main +from pymodaq_utils.utils import ThreadCommand +from pymodaq.utils.data import DataFromPlugins, Axis +from pymodaq_data import DataToExport + +from pymodaq_plugins_mockexamples.hardware.spectrometer import Spectrometer + + +class DAQ_1DViewer_CookbookSpectrometer(DAQ_Viewer_base): + """Spectrum of the simulated spectrometer, on a wavelength axis in nm""" + params = comon_parameters + [ + {'title': 'Grating:', 'name': 'grating', 'type': 'list', 'value': next(iter(Spectrometer.gratings)), + 'limits': list(Spectrometer.gratings), 'tip': 'The dispersion of the grating sets the range of the axis'}, + {'title': 'Central wavelength (nm):', 'name': 'central_wavelength', 'type': 'float', 'value': 532., + 'min': 0, 'tip': 'Turns the grating. An actuator that shares the instrument can turn it too'}, + {'title': 'Laser wavelength (nm):', 'name': 'laser_wl', 'type': 'list', 'value': 532., + 'limits': [405., 515., 532., 632.8], 'tip': 'The laser line is the line that the spectrometer measures'}, + {'title': 'Exposure (ms):', 'name': 'exposure_ms', 'type': 'int', 'value': 100, 'min': 1, + 'tip': 'The signal is proportional to the exposure time (100 ms gives the reference signal)'}, + ] + + def ini_attributes(self): + self.controller: Spectrometer = None + + def ini_detector(self, controller=None): + if self.is_master: + self.controller = Spectrometer() + self.controller.grating = self.settings['grating'] # apply the settings loaded from a preset + self.controller.set_wavelength(self.settings['central_wavelength']) + self.controller.peak_wavelength = self.settings['laser_wl'] + self.controller.exposure_ms = self.settings['exposure_ms'] + else: # a master actuator shares its instrument: do not move its grating + self.controller = controller + self.settings.child('grating').setValue(self.controller.grating) + self.settings.child('central_wavelength').setValue(self.controller.get_wavelength()) + self.settings.child('laser_wl').setValue(self.controller.peak_wavelength) + self.settings.child('exposure_ms').setValue(self.controller.exposure_ms) + return self.controller.infos, True + + def close(self): + if self.is_master and self.controller is not None: + self.controller.close() + + def commit_settings(self, param): + if param.name() == 'grating': + self.controller.grating = param.value() + elif param.name() == 'central_wavelength': + self.controller.set_wavelength(param.value()) + elif param.name() == 'laser_wl': + self.controller.peak_wavelength = param.value() + elif param.name() == 'exposure_ms': + self.controller.exposure_ms = param.value() + + def get_spectro_wl(self): + """Interface of the spectrometer extension: emit the central wavelength (the "grating" position)""" + self.emit_status(ThreadCommand('spectro_wl', [self.settings['central_wavelength']])) + + def set_spectro_wl(self, spectro_wl: float): + """Interface of the spectrometer extension: set the central wavelength and emit the new one""" + self.controller.set_wavelength(spectro_wl) + self.settings.child('central_wavelength').setValue(spectro_wl) + QtWidgets.QApplication.processEvents() + self.get_spectro_wl() + + def get_laser_wl(self): + """Interface of the spectrometer extension: emit the wavelength of the laser""" + self.emit_status(ThreadCommand('laser_wl', [self.settings['laser_wl']])) + + def set_laser_wl(self, laser_wl: float): + """Interface of the spectrometer extension: set the wavelength of the laser and emit the new one""" + self.controller.peak_wavelength = laser_wl + self.settings.child('laser_wl').setValue(laser_wl) + QtWidgets.QApplication.processEvents() + self.get_laser_wl() + + def get_exposure_ms(self): + """Interface of the spectrometer extension: emit the exposure time""" + self.emit_status(ThreadCommand('exposure_ms', [self.settings['exposure_ms']])) + + def set_exposure_ms(self, exposure: int): + """Interface of the spectrometer extension: set the exposure time and emit the new one""" + self.controller.exposure_ms = exposure + self.settings.child('exposure_ms').setValue(exposure) + QtWidgets.QApplication.processEvents() + self.get_exposure_ms() + + def grab_data(self, Naverage=1, **kwargs): + axis = Axis(data=self.controller.get_wavelength_axis(), label='wavelength', units='nm', index=0) + spectrum = self.average_spectra(Naverage).mean(axis=0) + self.dte_signal.emit(DataToExport( + name='CookbookSpectrometer', + data=[DataFromPlugins(name='Spectrum', data=[spectrum], dim='Data1D', labels=['intensity'], + axes=[axis])])) + + def average_spectra(self, Naverage: int) -> np.ndarray: + """Naverage spectra, each one taking the exposure time""" + spectra = [] + for _ in range(max(Naverage, 1)): + QThread.msleep(self.settings['exposure_ms']) + spectra.append(self.controller.grab_spectrum()) + return np.array(spectra) + + def stop(self): + self.controller.stop() + + +if __name__ == '__main__': + main(__file__) diff --git a/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_1D/daq_1Dviewer_MockSpectroErrors.py b/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_1D/daq_1Dviewer_MockSpectroErrors.py index 7fb5c6e..a65a0d5 100644 --- a/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_1D/daq_1Dviewer_MockSpectroErrors.py +++ b/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_1D/daq_1Dviewer_MockSpectroErrors.py @@ -1,43 +1,34 @@ -from qtpy.QtCore import QThread - -from pymodaq.control_modules.viewer_utility_classes import DAQ_Viewer_base, main import numpy as np +from pymodaq.control_modules.viewer_utility_classes import main from pymodaq.utils.data import DataFromPlugins, Axis, DataToExport -from pymodaq_plugins_mockexamples.daq_viewer_plugins.plugins_1D.daq_1Dviewer_Mock_spectro import DAQ_1DViewer_Mock_spectro + +from pymodaq_plugins_mockexamples.daq_viewer_plugins.plugins_1D.daq_1Dviewer_CookbookSpectrometer import \ + DAQ_1DViewer_CookbookSpectrometer -class DAQ_1DViewer_MockSpectroErrors(DAQ_1DViewer_Mock_spectro): +class DAQ_1DViewer_MockSpectroErrors(DAQ_1DViewer_CookbookSpectrometer): """ - Same as Mock Spectro but each data is the average and std of 10 acquisition - - testing errors saving/loading purpose + Same as CookbookSpectrometer but the instrument averages the acquisitions itself (``hardware_averaging``) + and the plugin fills the ``errors`` of the data, as PyMoDAQ does when it averages: the standard error of the + mean, sigma / sqrt(N), with sigma the standard deviation of the N acquisitions (ddof=1). + Testing errors saving/loading purpose """ hardware_averaging = True def grab_data(self, Naverage=1, **kwargs): - """ - - """ - data_tot = [np.zeros(list(dat.shape)+[Naverage]) for dat in self.set_Mock_data()] - - for ind in range(Naverage): - for ind_list, data_array in enumerate(self.set_Mock_data()): - data_tot[ind_list][..., ind] = data_array - QThread.msleep(self.settings.child('exposure_ms').value()) - - average = [np.average(data_array, 1) for data_array in data_tot] - std = [np.std(data_array, 1) for data_array in data_tot] - - self.dte_signal.emit( - DataToExport( - 'Mock1D', - data=[ - DataFromPlugins(name='Mock1D', - data=average, - errors= std, - dim='Data1D', - axes=[self.x_axis])])) + """Average Naverage spectra (at least 2, to have a standard deviation) and send the standard error""" + axis = Axis(data=self.controller.get_wavelength_axis(), label='wavelength', units='nm', index=0) + spectra = self.average_spectra(max(Naverage, 2)) + n_spectra = len(spectra) + dwa = DataFromPlugins(name='Mock1D', + data=[spectra.mean(axis=0)], + errors=[spectra.std(axis=0, ddof=1) / np.sqrt(n_spectra)], + dim='Data1D', + axes=[axis]) + dwa.n_averaged = n_spectra + self.dte_signal.emit(DataToExport('Mock1D', data=[dwa])) if __name__ == '__main__': diff --git a/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_1D/daq_1Dviewer_Mock_spectro.py b/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_1D/daq_1Dviewer_Mock_spectro.py deleted file mode 100644 index 29e0111..0000000 --- a/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_1D/daq_1Dviewer_Mock_spectro.py +++ /dev/null @@ -1,260 +0,0 @@ -from qtpy.QtCore import QThread -from qtpy import QtWidgets -from pymodaq.control_modules.viewer_utility_classes import DAQ_Viewer_base, main -import numpy as np -from easydict import EasyDict as edict -from pymodaq_utils.utils import ThreadCommand, getLineInfo -from pymodaq.utils.data import DataFromPlugins, Axis, DataToExport -from pymodaq_utils.math_utils import gauss1D, linspace_step -from pymodaq.control_modules.viewer_utility_classes import comon_parameters -from pymodaq_gui.parameter.utils import iter_children - - -class DAQ_1DViewer_Mock_spectro(DAQ_Viewer_base): - """ - 1D viewer plugin simulating a photon spectrometer controlling the laser source, the exposure and the calibration axis - Produces one data signal with two hypergaussians whose parameters are fully controllable or - produces 2 data signals, each beeing a fully controllable hypergaussian - Features specific methods that should be present for spectrometer features using Pymodaq_spectrometer package: - - get_laser_wl: emit the value of the currently selected laser (if available) - set_laser_wl: try to set the selected laser wavelength (if applicable), emit the value of the new one - - get_spectro_wl: emit the value of the central frequency - set_spectro_wl: set the newly central frequency - - get_exposure_ms: emit the value of the exposure time in ms - set_exposure_ms: set the new exposure time in ms - - - """ - params = comon_parameters + [ - {'title': 'Rolling?:', 'name': 'rolling', 'type': 'int', 'value': 0, 'min': 0}, - {'title': 'Multi Channels?:', 'name': 'multi', 'type': 'bool', 'value': False, - 'tip': 'if true, plugin produces multiple curves (2) otherwise produces one curve with 2 peaks'}, - {'title': 'Mock1:', 'name': 'Mock1', 'type': 'group', 'children': [ - {'title': 'Amp:', 'name': 'Amp', 'type': 'int', 'value': 20, 'default': 20}, - {'title': 'x0:', 'name': 'x0', 'type': 'float', 'value': 500, 'default': 500}, - {'title': 'dx:', 'name': 'dx', 'type': 'float', 'value': 0.3, 'default': 20}, - {'title': 'n:', 'name': 'n', 'type': 'int', 'value': 1, 'default': 1, 'min': 1}, - {'title': 'noise:', 'name': 'amp_noise', 'type': 'float', 'value': 0.1, 'default': 0.1, 'min': 0} - ]}, - {'title': 'Mock2:', 'name': 'Mock2', 'type': 'group', 'children': [ - {'title': 'Amp?:', 'name': 'Amp', 'type': 'int', 'value': 10}, - {'title': 'x0:', 'name': 'x0', 'type': 'float', 'value': 520}, - {'title': 'dx:', 'name': 'dx', 'type': 'float', 'value': 0.7}, - {'title': 'n:', 'name': 'n', 'type': 'int', 'value': 2, 'default': 2, 'min': 1}, - {'title': 'noise:', 'name': 'amp_noise', 'type': 'float', 'value': 0.1, 'default': 0.1, 'min': 0}, ]}, - - {'title': 'xaxis:', 'name': 'x_axis', 'type': 'group', 'children': [ - {'title': 'Npts:', 'name': 'Npts', 'type': 'int', 'value': 513, }, - {'title': 'x0:', 'name': 'x0', 'type': 'float', 'value': 515, }, - {'title': 'dx:', 'name': 'dx', 'type': 'float', 'value': 0.1, }, - ]}, - {'title': 'Laser Wavelength', 'name': 'laser_wl', 'type': 'list', 'value': 515, 'limits': [405, 515, 632.8]}, - {'title': 'Exposure (ms)', 'name': 'exposure_ms', 'type': 'int', 'value': 100, 'default': 100} - ] - hardware_averaging = False - - def __init__(self, parent=None, - params_state=None): # init_params is a list of tuple where each tuple contains info on a 1D channel (Ntps,amplitude, width, position and noise) - super().__init__(parent, params_state) - - self.x_axis: Axis = None - self.ind_data = 0 - self._update_x_axis = True - - def commit_settings(self, param): - """ - Setting the mock data - - ============== ========= ================= - **Parameters** **Type** **Description** - *param* none not used - ============== ========= ================= - - See Also - -------- - set_Mock_data - """ - if param.name() in iter_children(self.settings.child('x_axis'), []): - if param.name() == 'x0': - self.get_spectro_wl() - self.set_x_axis() - else: - self.set_Mock_data() - - def set_Mock_data(self): - """ - For each parameter of the settings tree : - * compute linspace numpy distribution with local parameters values - * shift right the current data of ind_data position - * add computed results to the data_mock list - - Returns - ------- - list - The computed data_mock list. - """ - ind = -1 - self.data_mock = [] - data = np.zeros((self.x_axis.size, )) - exposure_factor = self.settings['exposure_ms'] / \ - self.settings.child('exposure_ms').opts['default'] - - for param in self.settings.children(): # - if 'Mock' in param.name(): - ind += 1 - - data_tmp = exposure_factor * \ - param['Amp'] * gauss1D(self.x_axis.get_data(), param.child('x0').value(), - param.child('dx').value(), - param.child('n').value()) - if ind == 0: - data_tmp = data_tmp * np.sin(self.x_axis.get_data() / 4) ** 2 - data_tmp += param['amp_noise'] * np.random.rand((self.x_axis.size)) - data_tmp = self.settings['exposure_ms'] /\ - 1000 * np.roll(data_tmp, self.ind_data * self.settings['rolling']) - if self.settings['multi']: - self.data_mock.append(data_tmp) - else: - data += data_tmp - if not self.settings['multi']: - self.data_mock.append(data) - self.ind_data += 1 - return self.data_mock - - def set_x_axis(self): - Npts = self.settings['x_axis', 'Npts'] - x0 = self.settings['x_axis', 'x0'] - dx = self.settings['x_axis', 'dx'] - self.x_axis = Axis(label='photon wavelength', units='nm', - data=linspace_step(x0 - (Npts - 1) * dx / 2, x0 + (Npts - 1) * dx / 2, dx), - index=0) - self._update_x_axis = True - - def set_spectro_wl(self, spectro_wl): - """ - Method related to spectrometer module, mandatory - Parameters - ---------- - spectro_wl: set the "grating" position - - """ - self.settings.child('x_axis', 'x0').setValue(spectro_wl) - QtWidgets.QApplication.processEvents() - self.emit_status(ThreadCommand('spectro_wl', [self.settings['x_axis', 'x0']])) - self.set_x_axis() - - def get_spectro_wl(self): - """ - Method related to spectrometer module, mandatory - Get the "grating" position - """ - self.emit_status(ThreadCommand('spectro_wl', [self.settings['x_axis', 'x0']])) - - def get_laser_wl(self): - """ - Method related to spectrometer module, mandatory if laser can be set - Get the "laser" position - """ - self.emit_status(ThreadCommand('laser_wl', [self.settings["laser_wl"]])) - - def set_laser_wl(self, laser_wl): - """ - Method related to spectrometer module, mandatory if laser can be set - Set the "laser" position - """ - self.settings.child("laser_wl").setValue(laser_wl) - QtWidgets.QApplication.processEvents() - self.emit_status(ThreadCommand('laser_wl', [self.settings['laser_wl']])) - - def get_exposure_ms(self): - self.emit_status(ThreadCommand('exposure_ms', [self.settings['exposure_ms']])) - - def set_exposure_ms(self, exposure): - self.settings.child("exposure_ms").setValue(exposure) - QtWidgets.QApplication.processEvents() - self.emit_status(ThreadCommand('exposure_ms', [self.settings['exposure_ms']])) - - def ini_detector(self, controller=None): - """ - Initialisation procedure of the detector updating the status dictionnary. - - See Also - -------- - set_Mock_data, daq_utils.ThreadCommand - """ - if self.is_master: - self.controller = "Mock controller" - self.set_x_axis() - self.set_Mock_data() - else: - self.controller = controller - - # initialize viewers with the future type of data - self.dte_signal_temp.emit(DataToExport('Mock1D', - data=[DataFromPlugins(name='Mock1', data=self.data_mock, - dim='Data1D', - axes=[self.x_axis], - labels=['Mock1', 'label2']),])) - - initialized = True - return 'Initialized', initialized - - def close(self): - """ - Not implemented. - """ - pass - - def grab_data(self, Naverage=1, **kwargs): - """ - | Start new acquisition - - For each integer step of naverage range: - * set mock data - * wait 100 ms - * update the data_tot array - - | Send the data_grabed_signal once done. - - =============== ======== =============================================== - **Parameters** **Type** **Description** - *Naverage* int Number of spectrum to average. - Specify the threshold of the mean calculation - =============== ======== =============================================== - - See Also - -------- - set_Mock_data - """ - Naverage = 1 - data_tot = self.set_Mock_data() - for ind in range(Naverage - 1): - data_tmp = self.set_Mock_data() - QThread.msleep(self.settings.child('exposure_ms').value()) - - for ind, data in enumerate(data_tmp): - data_tot[ind] += data - - data_tot = [data / Naverage for data in data_tot] - QThread.msleep(self.settings.child('exposure_ms').value()) - if not self._update_x_axis: - self.dte_signal.emit(DataToExport('Mock1D', - data=[DataFromPlugins(name='Mock1D', data=data_tot, dim='Data1D',)])) - else: - self.dte_signal.emit(DataToExport('Mock1D', - data=[DataFromPlugins(name='Mock1D', data=data_tot, dim='Data1D', - axes=[self.x_axis])])) - self._update_x_axis = False - - def stop(self): - """ - not implemented. - """ - - return "" - -if __name__ == '__main__': - main(__file__) diff --git a/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_2D/daq_2Dviewer_BSCamera.py b/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_2D/daq_2Dviewer_BSCamera.py index bf259ae..7f546e1 100644 --- a/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_2D/daq_2Dviewer_BSCamera.py +++ b/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_2D/daq_2Dviewer_BSCamera.py @@ -12,7 +12,13 @@ class DAQ_2DViewer_BSCamera(DAQ_Viewer_base): """ Generate a 2D image with a spot on it whose position can be set - using DAQ_Move_BSPiezoMirror""" + using DAQ_Move_BSPiezoMirror + + It is also the camera recipe of the cookbook. It shows what a camera driver gives to its plugin: a region of + interest (ROI) and a binning applied by the camera itself, an exposure time that scales the signal, and axes + calibrated in um (the pixel size of the sensor) that follow the ROI and the binning. Compare with + ``DAQ_2DViewer_RoiStuff``, where the ROI is drawn in the viewer and applied by the plugin after the grab. + """ live_mode_available = True hardware_averaging = True @@ -33,6 +39,21 @@ class DAQ_2DViewer_BSCamera(DAQ_Viewer_base): 'tip': 'Standard deviation of the random walk of the beam position'}, {'title': 'std_y', 'name': 'std_y', 'type': 'float', 'value': 0.}, ]}, + {'title': 'Sensor', 'name': 'sensor', 'type': 'group', 'children': [ + {'title': 'Pixel size (um)', 'name': 'pixel_size', 'type': 'float', 'value': 5.5, 'min': 0.1, + 'tip': 'Size of a pixel of the sensor: sets the calibration of the axes'}, + {'title': 'Exposure (ms)', 'name': 'exposure_ms', 'type': 'float', 'value': 100., 'min': 0.1, + 'tip': 'The signal is proportional to the exposure time (100 ms gives the reference signal)'}, + {'title': 'Binning', 'name': 'binning', 'type': 'list', 'value': 1, 'limits': [1, 2, 4, 8], + 'tip': 'Sensor pixels summed along each direction. Pixels that do not fill a bin are dropped'}, + {'title': 'ROI', 'name': 'roi', 'type': 'group', 'children': [ + {'title': 'X (pixel)', 'name': 'roi_x', 'type': 'int', 'value': 0, 'min': 0, 'max': 255}, + {'title': 'Y (pixel)', 'name': 'roi_y', 'type': 'int', 'value': 0, 'min': 0, 'max': 255}, + {'title': 'Width (pixel)', 'name': 'roi_width', 'type': 'int', 'value': 256, 'min': 1, 'max': 256}, + {'title': 'Height (pixel)', 'name': 'roi_height', 'type': 'int', 'value': 256, 'min': 1, + 'max': 256}, + ]}, + ]}, {'title': 'Nimages colors:', 'name': 'Nimagescolor', 'type': 'int', 'value': 1, 'min': 1, 'max': 3}, {'title': 'Nimages pannels:', 'name': 'Nimagespannel', 'type': 'int', 'value': 1, 'min': 1}, {'title': 'Threshold', 'name': 'threshold', 'type': 'float', 'value': 0., 'min': 0}, @@ -59,6 +80,13 @@ def commit_settings(self, param: Parameter): setattr(self.controller.camera, param.name(), param.value()) if param.name() == 'is_drifting': self.controller.camera.refresh_drift() + elif param.name() in ('pixel_size', 'exposure_ms', 'binning'): + setattr(self.controller.camera, param.name(), param.value()) + self.update_axes() + elif param.name().startswith('roi_'): + self.controller.camera.set_roi(*(self.settings['sensor', 'roi', name] for name in + ('roi_x', 'roi_y', 'roi_width', 'roi_height'))) + self.update_axes() def ini_detector(self, controller=None): if self.is_master: @@ -66,13 +94,13 @@ def ini_detector(self, controller=None): else: self.controller = controller for name in ('size_x', 'size_y', 'angle', 'index_n', 'power_noise', 'fringes', 'is_drifting', 'drift_x', - 'drift_y', 'std_x', 'std_y'): # apply the settings loaded from a preset + 'drift_y', 'std_x', 'std_y', 'pixel_size', 'exposure_ms', 'binning', + 'roi_x'): # apply the settings loaded from a preset (the four ROI values at once) self.commit_settings(self.settings.child(*self._path(name))) self.emit_status(ThreadCommand('update_main_settings', [['wait_time'], self.settings['wait_time'], 'value'])) - self.x_axis = Axis(data=self.controller.camera.x_axis, label='pixel', index=1) - self.y_axis = Axis(data=self.controller.camera.y_axis, label='pixel', index=0) + self.update_axes() initialized = True info = 'Controller ok' @@ -80,7 +108,24 @@ def ini_detector(self, controller=None): @staticmethod def _path(name: str): - return ('is_drifting', name) if name.endswith(('_x', '_y')) and name.startswith(('drift', 'std')) else (name,) + if name.startswith(('drift', 'std')) and name.endswith(('_x', '_y')): + return 'is_drifting', name + if name.startswith('roi_'): + return 'sensor', 'roi', name + if name in ('pixel_size', 'exposure_ms', 'binning'): + return 'sensor', name + return (name,) + + def _build_axes(self): + """The axes (x, y) of the images, in um: they follow the ROI and the binning of the camera""" + camera = self.controller.camera + x, y = camera.get_axes() + return (Axis(data=x, label='x', units='um', index=1), + Axis(data=y, label='y', units='um', index=0)) + + def update_axes(self): + """Axes are sent with the data, so changing them here is enough""" + self.x_axis, self.y_axis = self._build_axes() def close(self): pass diff --git a/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_2D/daq_2Dviewer_RoiStuff.py b/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_2D/daq_2Dviewer_RoiStuff.py index d04b08c..0b87ab5 100644 --- a/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_2D/daq_2Dviewer_RoiStuff.py +++ b/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_2D/daq_2Dviewer_RoiStuff.py @@ -23,18 +23,11 @@ def ini_attributes(self): self.roi_select_info: RoiInfo = None self.roi_select_viewer_index: int = None - def ini_detector(self, controller=None): - info, initialized = super().ini_detector(controller) - - - - self.x_axis = Axis(size=self.controller.camera.Nx, offset=23, - scaling=0.5, label='scaled X', index=1) - self.y_axis = Axis(size=self.controller.camera.Ny, - offset=-16, - scaling=2., label='scaled Y', index=0) - - return info, initialized + def _build_axes(self): + """Arbitrary scaled axes, to show that the ROI of the viewer is given in the units of the axes""" + rows, columns = self.controller.camera.shape + return (Axis(size=columns, offset=23, scaling=0.5, label='scaled X', index=1), + Axis(size=rows, offset=-16, scaling=2., label='scaled Y', index=0)) def ROISelect(self, info: QRectF): raise DeprecationWarning('Do not use it anymore, use the roi_select method') diff --git a/src/pymodaq_plugins_mockexamples/hardware/beam_steering.py b/src/pymodaq_plugins_mockexamples/hardware/beam_steering.py index 111cbcd..bf14e72 100644 --- a/src/pymodaq_plugins_mockexamples/hardware/beam_steering.py +++ b/src/pymodaq_plugins_mockexamples/hardware/beam_steering.py @@ -47,13 +47,58 @@ class Camera: drift_y = 0. std_x = 0. std_y = 0. + reference_exposure_ms = 100. # the exposure at which the signal is ``amp`` def __init__(self): super().__init__() self._image: np.ndarray = None self.drift = np.zeros(2) + self.pixel_size = 5.5 # um, of one pixel of the sensor + self.exposure_ms = self.reference_exposure_ms + self._binning = 1 + self._roi = (0, 0, self.Nx, self.Ny) # x, y, width, height on the sensor, in pixels self.base_Mock_data() + @property + def binning(self) -> int: + """Number of sensor pixels summed in each direction to make one pixel of the image""" + return self._binning + + @binning.setter + def binning(self, binning: int): + self._binning = max(1, int(binning)) + + @property + def roi(self) -> tuple: + """Region of the sensor that is read: x, y, width, height in sensor pixels""" + return self._roi + + def set_roi(self, x: int, y: int, width: int, height: int): + """Set the region read from the sensor, kept inside the sensor and at least one binned pixel wide""" + x = min(max(int(x), 0), self.Nx - 1) + y = min(max(int(y), 0), self.Ny - 1) + width = min(max(int(width), 1), self.Nx - x) + height = min(max(int(height), 1), self.Ny - y) + self._roi = (x, y, width, height) + + @property + def shape(self) -> tuple: + """Shape (rows, columns) of the images: the ROI divided by the binning (incomplete pixels are dropped)""" + _, _, width, height = self._roi + return max(height // self._binning, 1), max(width // self._binning, 1) + + def get_axes(self) -> tuple: + """Positions of the centre of the image pixels along x and y, in um (the sensor pixel i is at i * pixel_size) + + They follow the ROI and the binning: the same sensor feature keeps the same position whatever they are. + """ + x, y, _, _ = self._roi + rows, columns = self.shape + pitch = self._binning * self.pixel_size + half = (self._binning - 1) / 2 + return ((x + half) * self.pixel_size + pitch * np.arange(columns), + (y + half) * self.pixel_size + pitch * np.arange(rows)) + def refresh_drift(self): """Put the beam back where the actuators say it is""" self.drift = np.zeros(2) @@ -111,6 +156,11 @@ def base_Mock_data(self): return self._image def get_data(self, xpos, ypos) -> np.ndarray: + """The image given by the camera: the ROI of the sensor, binned and scaled by the exposure""" + return self._read_out(self.get_sensor_image(xpos, ypos)) + + def get_sensor_image(self, xpos, ypos) -> np.ndarray: + """The beam on the whole sensor, whatever the ROI, the binning and the exposure""" if self.is_drifting: self.drift[0] += self.drift_x + self.std_x * np.random.randn() self.drift[1] += self.drift_y + self.std_y * np.random.randn() @@ -121,12 +171,16 @@ def get_data(self, xpos, ypos) -> np.ndarray: self.angle)) if self.fringes: image = image * np.sin((self.x_axis - self.drift[0]) / 4) ** 2 - # return np.roll(np.roll(self._image + self.amp_noise * np.random.rand(len(self.y_axis), - # len(self.x_axis)), - # int(xpos), axis=1), - # int(ypos), axis=0) return image + def _read_out(self, image: np.ndarray) -> np.ndarray: + """What the camera gives: the ROI of the sensor, binned (summed) and scaled by the exposure""" + x, y, _, _ = self._roi + rows, columns = self.shape + b = self._binning + image = image[y:y + rows * b, x:x + columns * b] + image = image.reshape(rows, b, columns, b).sum(axis=(1, 3)) + return image * self.exposure_ms / self.reference_exposure_ms class BeamSteering: @@ -204,8 +258,8 @@ def get_photodiode_data(self) -> float: self._called_first = True lock.release_lock() return (np.mean( - self.camera.get_data(self.actuators.get_value('X'), - self.actuators.get_value('Y'))) + self.camera.get_sensor_image(self.actuators.get_value('X'), + self.actuators.get_value('Y'))) * self._noise) def power_law(self, power_in: float) -> float: diff --git a/src/pymodaq_plugins_mockexamples/hardware/spectrometer.py b/src/pymodaq_plugins_mockexamples/hardware/spectrometer.py new file mode 100644 index 0000000..8663596 --- /dev/null +++ b/src/pymodaq_plugins_mockexamples/hardware/spectrometer.py @@ -0,0 +1,123 @@ +"""A simulated grating spectrometer: a grating that turns to set the central wavelength, and a spectrum on a sensor. + +Adapted from the spectrometer of ``pymodaq_plugins_teaching`` (module A of the teaching experiments). +""" +import math +from time import perf_counter + +import numpy as np + +from pymodaq_utils.math_utils import gauss1D + + +class Spectrometer: + """Simulated spectrometer + + The grating can be changed, turned to set the central wavelength (it takes time, as a real one) and the + spectrum is read from a sensor of ``Nx`` pixels. The wavelength of each pixel depends on the central wavelength + and on the dispersion of the grating: this calibration axis is what the plugin sends with the data. + """ + gratings = {'G300': 0.7, 'G1200': 0.25} # name: dispersion in nm/pixel + Nx = 256 # pixels of the sensor + infos = 'Spectrometer simulator' + _epsilon = 0.01 # nm, the grating is considered at its target below that distance + reference_exposure_ms = 100. # the exposure at which the amplitude of the line is ``amp`` + + def __init__(self): + self._amp = 10. + self._noise = 0.5 + self._width = 2. # nm + self._grating = next(iter(self.gratings)) + self._tau = 0.5 # s, characteristic time of the grating to reach its target + self._lambda = 532. # nm, central wavelength + self._target_lambda = self._lambda + self._init_value = self._lambda + self._alpha = 0. + self._start_time = 0. + self._moving = False + self._peak_wavelength = 528. # nm, the line that is measured + self.exposure_ms = self.reference_exposure_ms + + def close(self): + self.stop() + + @property + def tau(self) -> float: + """Characteristic time (s) taken by the grating to reach a wavelength""" + return self._tau + + @tau.setter + def tau(self, value: float): + if value <= 0: + raise ValueError(f'A characteristic time of {value} is not possible. It should be strictly positive') + self._tau = value + + @property + def grating(self) -> str: + return self._grating + + @grating.setter + def grating(self, name: str): + if name not in self.gratings: + raise ValueError(f'Unknown grating {name}, use one of {list(self.gratings)}') + self._grating = name + + @property + def dispersion(self) -> float: + """Wavelength range (nm) of one pixel of the sensor with the current grating""" + return self.gratings[self._grating] + + @property + def peak_wavelength(self) -> float: + """Wavelength (nm) of the line that is measured""" + return self._peak_wavelength + + @peak_wavelength.setter + def peak_wavelength(self, value: float): + self._peak_wavelength = value + + @property + def moving(self) -> bool: + self.get_wavelength() + return self._moving + + def stop(self): + self.get_wavelength() + self._moving = False + + def find_reference(self): + """Turn the grating to a known position, for absolute positioning""" + self.set_wavelength(600.) + + def set_wavelength(self, value: float, relative: bool = False): + """Start turning the grating to set the central wavelength (nm)""" + target = self._lambda + value if relative else value + if target < 0: + raise ValueError('Wavelength cannot be negative') + self._init_value = self.get_wavelength() + self._target_lambda = target + distance = math.fabs(self._init_value - target) + self._alpha = math.fabs(math.log(self._epsilon / max(distance, 10 * self._epsilon))) + self._start_time = perf_counter() + self._moving = True + + def get_wavelength(self) -> float: + """Current central wavelength (nm): it approaches the target exponentially while the grating turns""" + if self._moving: + decay = math.exp(-self._alpha * (perf_counter() - self._start_time) / self._tau) + self._lambda = decay * (self._init_value - self._target_lambda) + self._target_lambda + if math.fabs(self._lambda - self._target_lambda) < self._epsilon: + self._lambda = self._target_lambda + self._moving = False + return self._lambda + + def get_wavelength_axis(self) -> np.ndarray: + """Wavelength (nm) of each pixel: centred on the central wavelength, spaced by the dispersion""" + return (np.arange(self.Nx) - self.Nx / 2) * self.dispersion + self.get_wavelength() + + def grab_spectrum(self) -> np.ndarray: + """Intensity on each pixel: one line and some noise""" + wavelengths = self.get_wavelength_axis() + return (self.exposure_ms / self.reference_exposure_ms + * (self._amp * gauss1D(wavelengths, self._peak_wavelength, self._width) + + self._noise * np.random.rand(self.Nx))) diff --git a/tests/test_bs_camera.py b/tests/test_bs_camera.py index b5791e7..95a885f 100644 --- a/tests/test_bs_camera.py +++ b/tests/test_bs_camera.py @@ -57,3 +57,79 @@ def test_roi_stuff_is_a_beam_steering_camera(qapp): assert grab_and_wait(plugin)[0].shape == (256, 256) finally: plugin.close() + + +def _axis(dwa, label): + return next(axis for axis in dwa.axes if axis.label == label) + + +def _set(camera, name, value, *path): + param = camera.settings.child('sensor', *path, name) + param.setValue(value) + camera.commit_settings(param) + + +def test_axes_are_calibrated_in_um(camera): + axis = _axis(grab_and_wait(camera)[0], 'x') + assert axis.units == 'um' + assert axis.size == 256 + assert axis.scaling == pytest.approx(5.5) + assert axis.offset == pytest.approx(0.) + + +def test_roi_crops_the_image_and_moves_the_axes(camera): + for name, value in (('roi_x', 100), ('roi_y', 50), ('roi_width', 64), ('roi_height', 32)): + _set(camera, name, value, 'roi') + dwa = grab_and_wait(camera)[0] + assert dwa.shape == (32, 64) + x_axis, y_axis = _axis(dwa, 'x'), _axis(dwa, 'y') + assert x_axis.offset == pytest.approx(100 * 5.5) + assert y_axis.offset == pytest.approx(50 * 5.5) + assert x_axis.units == y_axis.units == 'um' + + +def test_roi_stays_on_the_beam_position(camera): + """The beam sits at pixel 128: it is at the same place in um with or without the ROI""" + _set(camera, 'roi_x', 64, 'roi') + _set(camera, 'roi_width', 128, 'roi') + dwa = grab_and_wait(camera)[0][0] + x_axis = camera.x_axis.get_data() + assert x_axis[np.argmax(dwa.sum(axis=0))] == pytest.approx(128 * 5.5, abs=2 * 5.5) + + +def test_binning_sums_pixels_and_scales_the_axes(camera): + plain = grab_and_wait(camera)[0] + _set(camera, 'binning', 4) + binned = grab_and_wait(camera)[0] + assert binned.shape == (64, 64) + assert _axis(binned, 'x').scaling == pytest.approx(4 * 5.5) + assert _axis(binned, 'x').offset == pytest.approx(1.5 * 5.5) # centre of the first 4 pixels + # the signal is summed: same total as without binning, up to the power noise of the beam + assert binned[0].sum() == pytest.approx(plain[0].sum(), rel=0.3) + + +def test_binning_drops_incomplete_pixels(camera): + _set(camera, 'roi_width', 10, 'roi') + _set(camera, 'binning', 4) + assert grab_and_wait(camera)[0].shape == (64, 2) + + +def test_exposure_scales_the_signal(camera): + camera.settings.child('power_noise').setValue(1e6) # no noise on the beam power + camera.commit_settings(camera.settings.child('power_noise')) + ref = grab_and_wait(camera)[0][0].max() + _set(camera, 'exposure_ms', 50.) + assert grab_and_wait(camera)[0][0].max() == pytest.approx(ref / 2, rel=0.1) + + +def test_roi_stuff_follows_the_hardware_roi(qapp): + plugin = make_detector(DAQ_2DViewer_RoiStuff) + try: + param = plugin.settings.child('sensor', 'roi', 'roi_width') + param.setValue(100) + plugin.commit_settings(param) + dwa = grab_and_wait(plugin)[0] + assert dwa.shape == (256, 100) + assert _axis(dwa, 'scaled X').size == 100 + finally: + plugin.close() diff --git a/tests/test_cookbook_spectrometer.py b/tests/test_cookbook_spectrometer.py new file mode 100644 index 0000000..b4775b4 --- /dev/null +++ b/tests/test_cookbook_spectrometer.py @@ -0,0 +1,189 @@ +"""The spectrometer recipe: the simulated instrument, the 1D detector (calibrated axis) and the actuator of the grating + +To test your own plugins copy the last part and replace the plugin classes. +""" +import time + +import numpy as np +import pytest + +from pymodaq.utils.plugin_testing import make_actuator, make_detector, move_abs_and_wait, move_home_and_wait, \ + grab_and_wait + +from pymodaq_plugins_mockexamples.daq_move_plugins.daq_move_CookbookSpectrometer import \ + DAQ_Move_CookbookSpectrometer +from pymodaq_plugins_mockexamples.daq_viewer_plugins.plugins_1D.daq_1Dviewer_CookbookSpectrometer import \ + DAQ_1DViewer_CookbookSpectrometer +from pymodaq_plugins_mockexamples.hardware.spectrometer import Spectrometer + + +@pytest.fixture +def spectrometer(): + spectro = Spectrometer() + spectro.tau = 0.05 + return spectro + + +class TestInstrument: + def test_axis_is_centred_and_follows_the_grating(self, spectrometer): + spectrometer.set_wavelength(600.) + while spectrometer.moving: + time.sleep(0.01) + axis = spectrometer.get_wavelength_axis() + assert len(axis) == Spectrometer.Nx + assert axis[Spectrometer.Nx // 2] == pytest.approx(600.) + assert np.diff(axis) == pytest.approx(spectrometer.dispersion) + + def test_dispersion_depends_on_the_grating(self, spectrometer): + wide = np.ptp(spectrometer.get_wavelength_axis()) + spectrometer.grating = 'G1200' + assert np.ptp(spectrometer.get_wavelength_axis()) < wide + + def test_unknown_grating(self, spectrometer): + with pytest.raises(ValueError): + spectrometer.grating = 'G1' + + def test_negative_wavelength(self, spectrometer): + with pytest.raises(ValueError): + spectrometer.set_wavelength(-1.) + + def test_the_grating_takes_time(self, spectrometer): + spectrometer.tau = 10 + spectrometer.set_wavelength(600.) + assert spectrometer.moving + assert 532 <= spectrometer.get_wavelength() < 600 + + def test_peak_is_at_the_right_wavelength(self, spectrometer): + spectrometer.peak_wavelength = 540. + axis = spectrometer.get_wavelength_axis() + spectrum = spectrometer.grab_spectrum() + assert axis[np.argmax(spectrum)] == pytest.approx(540., abs=spectrometer.dispersion) + + +@pytest.fixture +def detector(qapp): + plugin = make_detector(DAQ_1DViewer_CookbookSpectrometer) + plugin.controller.tau = 0.05 + yield plugin + plugin.close() + + +def _wait_for_the_grating(spectrometer): + while spectrometer.moving: + time.sleep(0.01) + + +class TestDetector: + def test_grab_has_a_wavelength_axis_in_nm(self, detector): + dwa = grab_and_wait(detector)[0] + assert dwa.shape == (Spectrometer.Nx,) + assert dwa.axes[0].units == 'nm' + assert dwa.axes[0].label == 'wavelength' + + def test_axis_follows_the_central_wavelength(self, detector): + param = detector.settings.child('central_wavelength') + param.setValue(600.) + detector.commit_settings(param) + _wait_for_the_grating(detector.controller) + axis = grab_and_wait(detector)[0].axes[0].get_data() + assert axis[len(axis) // 2] == pytest.approx(600.) + + def test_line_is_in_the_right_place(self, detector): + detector.controller.peak_wavelength = 535. + dwa = grab_and_wait(detector)[0] + axis = dwa.axes[0].get_data() + assert axis[np.argmax(dwa[0])] == pytest.approx(535., abs=1.) + + def test_grating_changes_the_range(self, detector): + wide = np.ptp(grab_and_wait(detector)[0].axes[0].get_data()) + param = detector.settings.child('grating') + param.setValue('G1200') + detector.commit_settings(param) + assert np.ptp(grab_and_wait(detector)[0].axes[0].get_data()) < wide + + +class TestMasterSlave: + def test_actuator_turns_the_grating_of_the_detector(self, detector): + actuator = make_actuator(DAQ_Move_CookbookSpectrometer, controller=detector.controller) + try: + assert actuator.controller is detector.controller + position = move_abs_and_wait(actuator, 580.) + assert position.value('nm') == pytest.approx(580., abs=0.1) + axis = grab_and_wait(detector)[0].axes[0].get_data() + assert axis[len(axis) // 2] == pytest.approx(580., abs=0.2) + finally: + actuator.close() # a slave never closes the shared instrument + assert grab_and_wait(detector)[0].shape == (Spectrometer.Nx,) + + def test_slave_detector_does_not_move_the_grating(self, qapp): + actuator = make_actuator(DAQ_Move_CookbookSpectrometer) + try: + actuator.controller.tau = 0.05 + move_abs_and_wait(actuator, 560.) + detector = make_detector(DAQ_1DViewer_CookbookSpectrometer, controller=actuator.controller) + try: + assert actuator.get_actuator_value().value('nm') == pytest.approx(560., abs=0.1) + assert detector.settings['central_wavelength'] == pytest.approx(560., abs=0.1) + finally: + detector.close() + finally: + actuator.close() + + +class TestActuator: + def test_move_home(self, qapp): + actuator = make_actuator(DAQ_Move_CookbookSpectrometer) + try: + actuator.controller.tau = 0.05 + assert actuator.get_actuator_value().units == 'nm' + assert move_home_and_wait(actuator).value('nm') == pytest.approx(600., abs=0.1) + finally: + actuator.close() + + +class TestExtensionInterface: + """The methods called by the spectrometer extension answer by a status command carrying the value""" + + @pytest.fixture + def statuses(self, detector, monkeypatch): + received = [] + monkeypatch.setattr(detector, 'emit_status', received.append) # standalone, the status is only printed + return received + + @staticmethod + def _answer(statuses, command): + return next(status.attribute[0] for status in statuses if status.command == command) + + def test_spectro_wl(self, detector, statuses): + detector.set_spectro_wl(570.) + assert self._answer(statuses, 'spectro_wl') == 570. + detector.get_spectro_wl() + assert detector.controller.get_wavelength() != 532. # the grating is turning + + def test_laser_wl(self, detector, statuses): + detector.set_laser_wl(632.8) + assert self._answer(statuses, 'laser_wl') == 632.8 + assert detector.controller.peak_wavelength == 632.8 + + def test_exposure_scales_the_signal(self, detector, statuses): + detector.controller.peak_wavelength = detector.controller.get_wavelength() + ref = grab_and_wait(detector)[0][0].max() + detector.set_exposure_ms(50) + assert self._answer(statuses, 'exposure_ms') == 50 + assert grab_and_wait(detector)[0][0].max() == pytest.approx(ref / 2, rel=0.2) + + +def test_errors_variant_sends_the_standard_error_of_the_mean(qapp): + """Same calculation as the DAQ_Viewer when it averages (PyMoDAQ PR 1401): sigma / sqrt(N), sigma with ddof=1""" + from pymodaq_plugins_mockexamples.daq_viewer_plugins.plugins_1D.daq_1Dviewer_MockSpectroErrors import \ + DAQ_1DViewer_MockSpectroErrors + plugin = make_detector(DAQ_1DViewer_MockSpectroErrors) + try: + spectra = np.random.default_rng(1).normal(2., 0.5, size=(7, Spectrometer.Nx)) + plugin.average_spectra = lambda n_average: spectra + dwa = grab_and_wait(plugin, naverage=7)[0] + assert dwa.n_averaged == 7 + assert dwa[0] == pytest.approx(spectra.mean(axis=0)) + assert dwa.errors[0] == pytest.approx(spectra.std(axis=0, ddof=1) / np.sqrt(7)) + finally: + plugin.close()