From 23e7013c779a658bc6ef81e9b3c4d98d8861136e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 20:26:55 +0000 Subject: [PATCH 1/3] Cookbook lot 3: camera with hardware ROI, binning and exposure; spectrometer recipe BSCamera now applies a ROI and a binning like a camera does, scales the signal by the exposure time and sends axes in um that follow them (the photodiode still reads the whole sensor). RoiStuff keeps the software ROI example. New CookbookSpectrometer (1D detector and central wavelength actuator): a calibrated wavelength axis that changes, and an actuator that is a slave of the detector. The simulated spectrometer is adapted from pymodaq_plugins_teaching. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Tdiso1u1ZhikSutj2Qs5Cg --- CONTEXT.md | 13 +- README.rst | 16 +- .../daq_move_CookbookSpectrometer.py | 78 ++++++++++ .../daq_1Dviewer_CookbookSpectrometer.py | 71 +++++++++ .../plugins_2D/daq_2Dviewer_BSCamera.py | 55 ++++++- .../plugins_2D/daq_2Dviewer_RoiStuff.py | 17 +-- .../hardware/beam_steering.py | 66 +++++++- .../hardware/spectrometer.py | 120 +++++++++++++++ tests/test_bs_camera.py | 76 ++++++++++ tests/test_cookbook_spectrometer.py | 141 ++++++++++++++++++ 10 files changed, 625 insertions(+), 28 deletions(-) create mode 100644 src/pymodaq_plugins_mockexamples/daq_move_plugins/daq_move_CookbookSpectrometer.py create mode 100644 src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_1D/daq_1Dviewer_CookbookSpectrometer.py create mode 100644 src/pymodaq_plugins_mockexamples/hardware/spectrometer.py create mode 100644 tests/test_cookbook_spectrometer.py 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..052d4b6 100644 --- a/README.rst +++ b/README.rst @@ -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,15 @@ 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`` ====================================== ============================================== ================================================================ Patterns already shown by the mock instruments of this package @@ -222,8 +232,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 + ``RoiStuff`` viewer (``roi_select`` with ``RoiInfo``). The hardware ROI and + binning are in the cookbook above 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 Simulated device, no hardware every plugin of this package A pure python controller behind a small wrapper class in 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..48fd895 --- /dev/null +++ b/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_1D/daq_1Dviewer_CookbookSpectrometer.py @@ -0,0 +1,71 @@ +"""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. + +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 pymodaq.control_modules.viewer_utility_classes import DAQ_Viewer_base, comon_parameters, main +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'}, + ] + + 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']) + 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()) + 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()) + + def grab_data(self, Naverage=1, **kwargs): + axis = Axis(data=self.controller.get_wavelength_axis(), label='wavelength', units='nm', index=0) + spectrum = np.mean([self.controller.grab_spectrum() for _ in range(Naverage)], axis=0) + self.dte_signal.emit(DataToExport( + name='CookbookSpectrometer', + data=[DataFromPlugins(name='Spectrum', data=[spectrum], dim='Data1D', labels=['intensity'], + axes=[axis])])) + + def stop(self): + self.controller.stop() + + +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..c70d659 --- /dev/null +++ b/src/pymodaq_plugins_mockexamples/hardware/spectrometer.py @@ -0,0 +1,120 @@ +"""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 + + 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 + + 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._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..70f49cf --- /dev/null +++ b/tests/test_cookbook_spectrometer.py @@ -0,0 +1,141 @@ +"""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() From fe1f74a3f02b3b4b4796cc2c7457877b778188ea Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 21:04:26 +0000 Subject: [PATCH 2/3] Replace Mock_spectro by CookbookSpectrometer, with the interface of the spectrometer extension CookbookSpectrometer gets the laser line, the exposure time and get/set_spectro_wl, get/set_laser_wl, get/set_exposure_ms. Mock_spectro is removed (no alias, noted in the README); MockSpectroErrors now builds on CookbookSpectrometer. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Tdiso1u1ZhikSutj2Qs5Cg --- README.rst | 11 +- .../daq_1Dviewer_CookbookSpectrometer.py | 63 ++++- .../daq_1Dviewer_MockSpectroErrors.py | 36 +-- .../plugins_1D/daq_1Dviewer_Mock_spectro.py | 260 ------------------ .../hardware/spectrometer.py | 7 +- tests/test_cookbook_spectrometer.py | 45 +++ 6 files changed, 130 insertions(+), 292 deletions(-) delete mode 100644 src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_1D/daq_1Dviewer_Mock_spectro.py diff --git a/README.rst b/README.rst index 052d4b6..1f3038f 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 the spectrometer averaged over several acquisitions; shows data with errors from averaging. 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: @@ -218,7 +218,8 @@ done by the camera ``hardware/beam_steering.py`` 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`` + 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 @@ -234,8 +235,6 @@ controller detector) 2D camera ``BSCamera`` (2D detector), Pixel axes, live acquisition, ROI selection done on the ``RoiStuff`` viewer (``roi_select`` with ``RoiInfo``). The hardware ROI and binning are in the cookbook above -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 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_viewer_plugins/plugins_1D/daq_1Dviewer_CookbookSpectrometer.py b/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_1D/daq_1Dviewer_CookbookSpectrometer.py index 48fd895..945bed9 100644 --- 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 @@ -10,12 +10,20 @@ - 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 @@ -29,6 +37,10 @@ class DAQ_1DViewer_CookbookSpectrometer(DAQ_Viewer_base): '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): @@ -39,10 +51,14 @@ def ini_detector(self, controller=None): 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): @@ -54,15 +70,60 @@ def commit_settings(self, param): 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 = np.mean([self.controller.grab_spectrum() for _ in range(Naverage)], axis=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() 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..ddc2236 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,33 @@ -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 data is the average of the acquisitions and its error their standard + deviation - 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] - + """Average Naverage spectra (at least 2, to have a standard deviation) and send their std as errors""" + axis = Axis(data=self.controller.get_wavelength_axis(), label='wavelength', units='nm', index=0) + spectra = self.average_spectra(max(Naverage, 2)) self.dte_signal.emit( DataToExport( 'Mock1D', data=[ DataFromPlugins(name='Mock1D', - data=average, - errors= std, + data=[spectra.mean(axis=0)], + errors=[spectra.std(axis=0)], dim='Data1D', - axes=[self.x_axis])])) + axes=[axis])])) 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/hardware/spectrometer.py b/src/pymodaq_plugins_mockexamples/hardware/spectrometer.py index c70d659..8663596 100644 --- a/src/pymodaq_plugins_mockexamples/hardware/spectrometer.py +++ b/src/pymodaq_plugins_mockexamples/hardware/spectrometer.py @@ -21,6 +21,7 @@ class Spectrometer: 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. @@ -35,6 +36,7 @@ def __init__(self): 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() @@ -116,5 +118,6 @@ def get_wavelength_axis(self) -> np.ndarray: def grab_spectrum(self) -> np.ndarray: """Intensity on each pixel: one line and some noise""" wavelengths = self.get_wavelength_axis() - return (self._amp * gauss1D(wavelengths, self._peak_wavelength, self._width) - + self._noise * np.random.rand(self.Nx)) + 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_cookbook_spectrometer.py b/tests/test_cookbook_spectrometer.py index 70f49cf..c8435c4 100644 --- a/tests/test_cookbook_spectrometer.py +++ b/tests/test_cookbook_spectrometer.py @@ -139,3 +139,48 @@ def test_move_home(self, qapp): 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_deviation(qapp): + from pymodaq_plugins_mockexamples.daq_viewer_plugins.plugins_1D.daq_1Dviewer_MockSpectroErrors import \ + DAQ_1DViewer_MockSpectroErrors + plugin = make_detector(DAQ_1DViewer_MockSpectroErrors) + try: + dwa = grab_and_wait(plugin, naverage=5)[0] + assert dwa.errors is not None and dwa.errors[0].shape == dwa[0].shape + assert dwa.errors[0].max() > 0 + finally: + plugin.close() + From 238c290ae4753d95641467d2609daad552139f78 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 21:07:46 +0000 Subject: [PATCH 3/3] MockSpectroErrors: send the standard error of the mean, like the DAQ_Viewer averaging Same calculation as PyMoDAQ PR 1401 (sigma / sqrt(N), ddof=1) for the plugin that averages itself, with n_averaged set. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Tdiso1u1ZhikSutj2Qs5Cg --- README.rst | 2 +- .../daq_1Dviewer_MockSpectroErrors.py | 25 ++++++++++--------- tests/test_cookbook_spectrometer.py | 13 ++++++---- 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/README.rst b/README.rst index 1f3038f..3772f6f 100644 --- a/README.rst +++ b/README.rst @@ -72,7 +72,7 @@ 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. * **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 the spectrometer averaged over several acquisitions; shows data with errors from averaging. +* **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 ++++++++ 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 ddc2236..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 @@ -9,25 +9,26 @@ class DAQ_1DViewer_MockSpectroErrors(DAQ_1DViewer_CookbookSpectrometer): """ - Same as CookbookSpectrometer but the data is the average of the acquisitions and its error their standard - deviation - 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): - """Average Naverage spectra (at least 2, to have a standard deviation) and send their std as errors""" + """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)) - self.dte_signal.emit( - DataToExport( - 'Mock1D', - data=[ - DataFromPlugins(name='Mock1D', - data=[spectra.mean(axis=0)], - errors=[spectra.std(axis=0)], - dim='Data1D', - axes=[axis])])) + 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/tests/test_cookbook_spectrometer.py b/tests/test_cookbook_spectrometer.py index c8435c4..b4775b4 100644 --- a/tests/test_cookbook_spectrometer.py +++ b/tests/test_cookbook_spectrometer.py @@ -173,14 +173,17 @@ def test_exposure_scales_the_signal(self, detector, statuses): assert grab_and_wait(detector)[0][0].max() == pytest.approx(ref / 2, rel=0.2) -def test_errors_variant_sends_the_standard_deviation(qapp): +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: - dwa = grab_and_wait(plugin, naverage=5)[0] - assert dwa.errors is not None and dwa.errors[0].shape == dwa[0].shape - assert dwa.errors[0].max() > 0 + 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() -