From 256adb14d4e1455a33f41631a45c2660ba9e14d0 Mon Sep 17 00:00:00 2001 From: appel_c Date: Wed, 16 Jul 2025 14:32:52 +0200 Subject: [PATCH 01/23] refactor(mcs-card): refactor mcs card integration --- csaxs_bec/device_configs/ddg_test.yaml | 16 +- csaxs_bec/devices/epics/mcs_card/__init__.py | 1 + csaxs_bec/devices/epics/mcs_card/mcs_card.py | 335 ++++++++++++++++++ .../devices/epics/mcs_card/mcs_card_csaxs.py | 102 ++++++ .../devices/epics/mcs_card/mcs_csaxs_old.py | 319 +++++++++++++++++ 5 files changed, 768 insertions(+), 5 deletions(-) create mode 100644 csaxs_bec/devices/epics/mcs_card/__init__.py create mode 100644 csaxs_bec/devices/epics/mcs_card/mcs_card.py create mode 100644 csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py create mode 100644 csaxs_bec/devices/epics/mcs_card/mcs_csaxs_old.py diff --git a/csaxs_bec/device_configs/ddg_test.yaml b/csaxs_bec/device_configs/ddg_test.yaml index d4748b1..a08fdbd 100644 --- a/csaxs_bec/device_configs/ddg_test.yaml +++ b/csaxs_bec/device_configs/ddg_test.yaml @@ -30,8 +30,6 @@ samx: - 50 tolerance: 0.01 update_frequency: 400 - deviceTags: - - user motors enabled: true readOnly: false @@ -39,7 +37,15 @@ bpm4i: readoutPriority: monitored deviceClass: ophyd_devices.SimMonitor deviceConfig: - deviceTags: - - beamline enabled: true - readOnly: false \ No newline at end of file + readOnly: false + +mcs: + description: Mcs scalar card for transmission readout + deviceClass: csaxs_bec.devices.epics.mcs_card.mcs_card_csaxs.MCSCardCSAXS + deviceConfig: + prefix: 'X12SA-MCS:' + onFailure: raise + enabled: true + readoutPriority: monitored + softwareTrigger: false \ No newline at end of file diff --git a/csaxs_bec/devices/epics/mcs_card/__init__.py b/csaxs_bec/devices/epics/mcs_card/__init__.py new file mode 100644 index 0000000..a40350f --- /dev/null +++ b/csaxs_bec/devices/epics/mcs_card/__init__.py @@ -0,0 +1 @@ +from .mcs_card import MCSCard diff --git a/csaxs_bec/devices/epics/mcs_card/mcs_card.py b/csaxs_bec/devices/epics/mcs_card/mcs_card.py new file mode 100644 index 0000000..44060cd --- /dev/null +++ b/csaxs_bec/devices/epics/mcs_card/mcs_card.py @@ -0,0 +1,335 @@ +""" +EPICS SIS38XX Multichannel Scaler (MCS) Interface + +This module provides an interface to the SIS3801/SIS3820 multichannel scaler (MCS) cards via EPICS. +It focuses on the implementation for the SIS3820 model, as input/output modes differ between SIS3801 +and SIS3820. It supports both MCS and scaler record operations, enabling configuration and control of +acquisition parameters such as dwell time, channel advance mode, and input/output settings. +The module facilitates data acquisition by managing FIFO buffers and simulating conventional +MCS behavior through memory buffers. + +At cSAXS, the SIS3820 model is used, which supports 32 channels. + +References: +- EPICS SIS3801 and SIS3820 Drivers: https://millenia.cars.aps.anl.gov/software/epics/mcaStruck.html +""" + +from __future__ import annotations + +import enum + +from ophyd import Component as Cpt +from ophyd import DynamicDeviceComponent, EpicsSignal, EpicsSignalRO, Kind + + +class CHANNELADVANCE(int, enum.Enum): + """Channel advance pixel mode for MCS card.""" + + INTERNAL = 0 + EXTERNAL = 1 + + +class ACQUIRING(int, enum.Enum): + """Acquisition status for MCS card.""" + + DONE = 0 + ACQUIRING = 1 + + +class READMODE(int, enum.Enum): + """Read mode for MCS channels.""" + + PASSIVE = 0 + EVENT = 1 + IO_INTR = 2 + FREQ_0_1HZ = 3 + FREQ_0_2HZ = 4 + FREQ_0_5HZ = 5 + FREQ_1HZ = 6 + FREQ_2HZ = 7 + FREQ_5HZ = 8 + FREQ_10HZ = 9 + FREQ_100HZ = 10 + + +class CHANNEL1SOURCE(int, enum.Enum): + """Source for first counter pulses.""" + + INTERNAL_CLOCK = 0 + EXTERNAL = 1 + + +class POLARITY(int, enum.Enum): + """Polarity of input_polarity/output_polarity for MCS card.""" + + NORMAL = 0 + INVERTED = 1 + + +class ACQUIREMODE(int, enum.Enum): + """Acquire mode for the card. Allowed modes are Scaler and MCS.""" + + MCS = 0 + SCALER = 1 + + +class MODELS(int, enum.Enum): + + SIS3801 = 0 + SIS3820 = 1 + + +class INPUTMODE(int, enum.Enum): + """SIS3820 input mode definitions, in total there are 8 modes (0-7). + + Each mode defines the function of external inputs 1-4. + Note: SIS3820 has extended input modes compared to SIS3801. + Please check the EPICS documentation for details on the specific input modes supported by SIS3801. + """ + + MODE_0 = 0 + MODE_1 = 1 + MODE_2 = 2 + MODE_3 = 3 + MODE_4 = 4 + MODE_5 = 5 + MODE_6 = 6 + MODE_7 = 7 + + def describe(self) -> str: + """Return a description of the input mode.""" + descriptions = { + self.MODE_0: "Inputs 1-4: No function (default idle mode)", + self.MODE_1: "Inputs 1-4: Next pulse, User bit 1, User bit 2, Inhibit next pulse", + self.MODE_2: "Inputs 1-4: Next pulse, User bit 1, Inhibit counting, Inhibit next pulse", + self.MODE_3: "Inputs 1-4: Next pulse, User bit 1, User bit 2, Inhibit counting", + self.MODE_4: "Inputs 1-4: Inhibit counting channels 1-8, 9-16, 17-24, 25-32", + self.MODE_5: "Inputs 1-4: Next pulse, HISCAL_START, No function, No function", + self.MODE_6: "Inputs 1-4: Next pulse, Inhibit counting, Clear counters, User bit 1", + self.MODE_7: "Inputs 1-4: Encoder A, Encoder B, Encoder I, Inhibit counting", + } + return descriptions.get(self, "Unknown input mode") + + +class OUTPUTMODE(int, enum.Enum): + """SIS3820 output mode definitions, in total there are 4 modes (0-3). + + Each mode configures output signals 5-8. + Note: SIS3820 supports 4 output modes (0-3), SIS3801 supports only Mode 0 with differen functionality. + Please check the EPICS documentation for details on the specific output modes supported by SIS3801. + """ + + MODE_0 = 0 + MODE_1 = 1 + MODE_2 = 2 + MODE_3 = 3 + + def describe(self) -> str: + """Return a description of the output mode.""" + descriptions = { + self.MODE_0: "Outputs 5-8: LNE/CIP, SDRAM empty, SDRAM threshold, User LED", + self.MODE_1: "Outputs 5-8: LNE/CIP, Enabled, 50 MHz, User LED", + self.MODE_2: "Outputs 5-8: LNE/CIP, 10 MHz (20ns), 10 MHz (20ns), User LED", + self.MODE_3: "Outputs 5-8: LNE/CIP, 10 MHz (20ns), MUX OUT channel, User LED (requires firmware ≥ 0x10A)", + } + return descriptions.get(self, "Unknown output mode") + + +def _create_mca_channels(num_channels: int) -> dict[str, tuple]: + """ + Create a dictionary of MCA channel definitions for the DynamicDeviceComponent. + Starts from channel 1 to num_channels. + + Args: + num_channels (int): The number of MCA channels to create. + """ + mcs_channels = {} + for i in range(1, num_channels + 1): + mcs_channels[f"mca{i}"] = ( + EpicsSignalRO, + f"MCA{i}", + {"kind": Kind.omitted, "auto_monitor": True, "doc": f"MCA channel {i}."}, + ) + return mcs_channels + + +class MCSCard: + """ + Ophyd implementation for the interface to the SIS3801/SIS3820 multichannel scaler (MCS) cards via EPICS. + + This class provides signals to expose EPICS PVs of the MCS card. More details can be found in the + documentation of the EPICS drivers for SIS3801 and SIS3820. + + References: + - EPICS SIS3801 and SIS3820 Drivers: https://millenia.cars.aps.anl.gov/software/epics/mcaStruck.html + """ + + snl_connected = Cpt( + EpicsSignalRO, + "SNL_Connected", + kind=Kind.omitted, + doc="Indicates whether the SNL program has connected to all PVs.", + ) + erase_all = Cpt( + EpicsSignal, + "EraseAll", + kind=Kind.omitted, + doc="Erases all mca or waveform records, setting elapsed times and counts in all channels to 0.", + ) + erase_start = Cpt( + EpicsSignal, + "EraseStart", + kind=Kind.omitted, + doc="Erases all mca or waveform records and starts acquisition.", + ) + start_all = Cpt( + EpicsSignal, + "StartAll", + kind=Kind.omitted, + doc="Starts or resumes acquisition without erasing first.", + ) + acquiring = Cpt( + EpicsSignalRO, + "Acquiring", + kind=Kind.omitted, + doc="Acquiring (=1) when acquisition is in progress and Done (=0) when acquisition is complete.", + ) + stop_all = Cpt(EpicsSignal, "StopAll", kind=Kind.omitted, doc="Stops acquisition.") + preset_real = Cpt( + EpicsSignal, + "PresetReal", + kind=Kind.omitted, + doc="Preset real time. If non-zero then acquisition will stop when this time is reached.", + ) + elapsed_real = Cpt( + EpicsSignalRO, + "ElapsedReal", + kind=Kind.omitted, + doc="Elapsed time since acquisition started.", + ) + read_all = Cpt( + EpicsSignal, + "ReadAll", + kind=Kind.omitted, + doc="Forces a read of all mca or waveform records from the hardware. This record can be set to periodically process to update the records during acquisition. Note that even if this record has SCAN=Passive the mca or waveform records will always process once when acquisition completes.", + ) + num_use_all = Cpt( + EpicsSignal, + "NUseAll", + kind=Kind.omitted, + doc="The number of channels to use for the mca or waveform records. Acquisition will automatically stop when the number of channel advances reaches this value.", + ) + dwell = Cpt( + EpicsSignal, + "Dwell", + kind=Kind.omitted, + doc="The dwell time per channel when using internal channel advance mode.", + ) + channel_advance = Cpt( + EpicsSignal, + "ChannelAdvance", + kind=Kind.omitted, + doc="The channel advance mode. Choices are 'Internal' (count for a preset time per channel) or 'External' (advance on external hardware channel advance signal).", + ) + count_on_start = Cpt( + EpicsSignal, + "CountOnStart", + kind=Kind.omitted, + doc="Flag controlling whether the module begins counting immediately when acquisition starts. This record only applies in External channel advance mode. If No (=0) then counting does not start in channel 0 until receipt of the first external channel advance pulse. If Yes (=1) then counting in channel 0 starts immediately when acquisition starts, without waiting for the first external channel advance pulse.", + ) + software_channel_advance = Cpt( + EpicsSignal, + "SoftwareChannelAdvance", + kind=Kind.omitted, + doc="Processing this record causes a channel advance to occur immediately, without waiting for the current dwell time to be reached or the next external channel advance pulse to arrive.", + ) + channel1_source = Cpt( + EpicsSignal, + "Channel1Source", + kind=Kind.omitted, + doc="Controls the source of pulses into the first counter. The choices are 'Int. clock' which selects the internal clock, and 'External' which selects the external pulse input to counter 1.", + ) + prescale = Cpt( + EpicsSignal, + "Prescale", + kind=Kind.omitted, + doc="The prescale factor for external channel advance pulses. If the prescale factor is N then N external channel advance pulses must be received before a channel advance will occur.", + ) + enable_client_wait = Cpt( + EpicsSignal, + "EnableClientWait", + kind=Kind.omitted, + doc="Flag to force acquisition to wait until a client clears the ClientWait busy record before proceeding to the next acquisition. This can be useful with the scan record.", + ) + client_wait = Cpt( + EpicsSignal, + "ClientWait", + kind=Kind.omitted, + doc="Flag that will be set to 1 when acquisition completes, and which a client must set back to 0 to allow acquisition to proceed. This only has an effect if EnableClientWait is 1.", + ) + acquire_mode = Cpt( + EpicsSignal, + "AcquireMode", + kind=Kind.omitted, + doc="The current acquisition mode (MCS=0 or Scaler=1). This record is used to turn off the scaler record Autocount in MCS mode.", + ) + mux_output = Cpt( + EpicsSignal, + "MUXOutput", + kind=Kind.omitted, + doc="Value of 0-32 used to select which input signal is routed to output signal 7 on the SIS3820 in output mode 3.", + ) + user_led = Cpt( + EpicsSignal, + "UserLED", + kind=Kind.omitted, + doc="Toggles the user LED and also output signal 8 on the SIS3820.", + ) + input_mode = Cpt( + EpicsSignal, + "InputMode", + kind=Kind.omitted, + doc="The input mode. Supported input modes vary for SIS3801 and SIS3820.", + ) + input_polarity = Cpt( + EpicsSignal, + "InputPolarity", + kind=Kind.omitted, + doc="The polarity of the input control signals on the SIS3820. Choices are Normal and Inverted.", + ) + output_mode = Cpt( + EpicsSignal, + "OutputMode", + kind=Kind.omitted, + doc="The output mode. Supported output modes vary for SIS3801 and SIS3820.", + ) + output_polarity = Cpt( + EpicsSignal, + "OutputPolarity", + kind=Kind.omitted, + doc="The polarity of the output control signals on the SIS3820. Choices are Normal and Inverted.", + ) + model = Cpt( + EpicsSignalRO, + "Model", + kind=Kind.omitted, + doc="The scaler model. Values are 'SIS3801' and 'SIS3820'.", + ) + firmware = Cpt(EpicsSignalRO, "Firmware", kind=Kind.omitted, doc="The firmware version.") + max_channels = Cpt( + EpicsSignalRO, "MaxChannels", kind=Kind.omitted, doc="The maximum number of channels." + ) + + # Relevant counters + current_channel = Cpt( + EpicsSignalRO, + "CurrentChannel", + kind=Kind.omitted, + auto_monitor=True, + doc="The current channel number, i.e. the number of channel advances that have occurred minus 1.", + ) + counters = DynamicDeviceComponent( + _create_mca_channels(32), + kind=Kind.omitted, + doc="Sub-device with the mca counters 1-32 for SIS3820.", + ) diff --git a/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py b/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py new file mode 100644 index 0000000..7af0fe9 --- /dev/null +++ b/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py @@ -0,0 +1,102 @@ +"""Module for the MCSCard CSAXS implementation.""" + +from ophyd import Component as Cpt +from ophyd_devices import CompareStatus, ProgressSignal +from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase + +from csaxs_bec.devices.epics.mcs_card.mcs_card import ( + ACQUIREMODE, + ACQUIRING, + CHANNEL1SOURCE, + CHANNELADVANCE, + INPUTMODE, + OUTPUTMODE, + POLARITY, + READMODE, + MCSCard, +) + + +class MCSCardCSAXS(PSIDeviceBase, MCSCard): + """ + Implementation of the MCSCard SIS3820 for CSAXS, prefix 'X12SA-MCS:'. + The basic functionality is inherited from the MCSCard class. + """ + + progress: ProgressSignal = Cpt(ProgressSignal, "progress") + + def __init__(self, *args, **kwargs): + """ + Initialize the MCSCardCSAXS with the given arguments and keyword arguments. + """ + super().__init__(*args, **kwargs) + self._pv_timeout = 2 + + def on_connected(self): + """ + Called when the device is connected. + """ + self.channel_advance.set(CHANNELADVANCE.EXTERNAL).wait(timeout=self._pv_timeout) + self.channel_advance.set(CHANNEL1SOURCE.EXTERNAL).wait( + timeout=self._pv_timeout + ) # Check if this is correct, or internal clock + self.user_led.set(0).wait(timeout=self._pv_timeout) + # Only channel 1-5 are connected so far, adjust if more are needed + self.mux_output.set(5).wait(timeout=self._pv_timeout) + self.input_mode.set(INPUTMODE.MODE_3).wait(timeout=self._pv_timeout) + self.input_polarity.set(POLARITY.NORMAL).wait(timeout=self._pv_timeout) + self.output_mode.set(OUTPUTMODE.MODE_2).wait( + timeout=self._pv_timeout + ) # To be checked due to cabling of time counter + self.output_polarity.set(POLARITY.INVERTED).wait( + timeout=self._pv_timeout + ) # To be checked and tested! + self.count_on_start.set(0).wait(timeout=self._pv_timeout) + + # Subscribe the progress signal + self.current_channel.subscribe(self._progress_update, run=False) + + # Set appropriate read mode + self.read_mode.set(READMODE.PASSIVE).wait(timeout=self._pv_timeout) + + def _progress_update(self, value, **kwargs) -> None: + """Callback for progress updates from ophyd subscription on current_channel.""" + frames_per_trigger = self.scan_info.msg.scan_parameters.get("frames_per_trigger", 1) + channel_advance = frames_per_trigger + 1 + self.progress.put(value, max_value=channel_advance, done=bool(value == channel_advance)) + + def on_stage(self) -> None: + """ + Called when the device is staged. + """ + self.erase_all.set(1).wait(timeout=self._pv_timeout) + triggers = self.scan_info.msg.scan_parameters.get("frames_per_trigger", 1) + self.preset_real.set(0).wait(timeout=self._pv_timeout) + self.num_use_all.set(triggers).wait(timeout=self._pv_timeout) + + def on_unstage(self) -> None: + """ + Called when the device is unstaged. + """ + self.erase_all.set(0).wait(timeout=self._pv_timeout) + + def on_pre_scan(self) -> None: + """ + Called before the scan starts. + """ + self.erase_start.put(1) + + def on_complete(self) -> CompareStatus: + """On scan completion.""" + # Check if we should get a signal based on updates from the MCA channels + status = CompareStatus(self.acquiring, ACQUIRING.DONE) + self.cancel_on_stop(status) + return status + + def on_stop(self) -> None: + """ + Called when the scan is stopped. + """ + self.stop_all.put(1) + # Reset the progress signal + # self.progress.put(0, done=True) diff --git a/csaxs_bec/devices/epics/mcs_card/mcs_csaxs_old.py b/csaxs_bec/devices/epics/mcs_card/mcs_csaxs_old.py new file mode 100644 index 0000000..17822de --- /dev/null +++ b/csaxs_bec/devices/epics/mcs_card/mcs_csaxs_old.py @@ -0,0 +1,319 @@ +import enum +import threading +from collections import defaultdict + +import numpy as np +from bec_lib import bec_logger, messages +from bec_lib.endpoints import MessageEndpoints +from ophyd import Component as Cpt +from ophyd import Device, EpicsSignal, EpicsSignalRO +from ophyd_devices.interfaces.base_classes.psi_detector_base import ( + CustomDetectorMixin, + PSIDetectorBase, +) +from ophyd_devices.utils import bec_utils + +logger = bec_logger.logger + + +class MCSError(Exception): + """Base class for exceptions in this module.""" + + +class MCSTimeoutError(MCSError): + """Raise when MCS card runs into a timeout""" + + +class TriggerSource(int, enum.Enum): + """Trigger source for mcs card - see manual for more information""" + + MODE0 = 0 + MODE1 = 1 + MODE2 = 2 + MODE3 = 3 + MODE4 = 4 + MODE5 = 5 + MODE6 = 6 + + +class ChannelAdvance(int, enum.Enum): + """Channel advance pixel mode for mcs card - see manual for more information""" + + INTERNAL = 0 + EXTERNAL = 1 + + +class ReadoutMode(int, enum.Enum): + """Readout mode for mcs card - see manual for more information""" + + PASSIVE = 0 + EVENT = 1 + IO_INTR = 2 + FREQ_0_1HZ = 3 + FREQ_0_2HZ = 4 + FREQ_0_5HZ = 5 + FREQ_1HZ = 6 + FREQ_2HZ = 7 + FREQ_5HZ = 8 + FREQ_10HZ = 9 + FREQ_100HZ = 10 + + +class MCSSetup(CustomDetectorMixin): + """Setup mixin class for the MCS card""" + + def __init__(self, *args, parent: Device = None, **kwargs) -> None: + super().__init__(*args, parent=parent, **kwargs) + self._lock = threading.RLock() + self._stream_ttl = 1800 + self.acquisition_done = False + self.counter = 0 + self.n_points = 0 + self.mca_names = [ + signal for signal in self.parent.component_names if signal.startswith("mca") + ] + self.mca_data = defaultdict(lambda: []) + + def on_init(self) -> None: + """Init sequence for the detector""" + self.initialize_detector() + self.initialize_detector_backend() + + def initialize_detector(self) -> None: + """Initialize d etector""" + # External trigger for pixel advance + self.parent.channel_advance.set(ChannelAdvance.EXTERNAL) + # Use internal clock for channel 1 + self.parent.channel1_source.set(ChannelAdvance.INTERNAL) + self.parent.user_led.set(0) + # Set number of channels to 5 + self.parent.mux_output.set(5) + # Trigger Mode used for cSAXS + self.parent.input_mode.set(TriggerSource.MODE3) + # specify polarity of trigger signals + self.parent.input_polarity.set(0) + self.parent.output_polarity.set(1) + # do not start counting on start + self.parent.count_on_start.set(0) + self.stop_detector() + + def initialize_detector_backend(self) -> None: + """Initialize detector backend""" + for mca in self.mca_names: + signal = getattr(self.parent, mca) + signal.subscribe(self._on_mca_data, run=False) + self.parent.current_channel.subscribe(self._progress_update, run=False) + + def _progress_update(self, value, **kwargs) -> None: + """Progress update on the scan""" + num_lines = self.parent.num_lines.get() + max_value = self.parent.scaninfo.num_points + # self.counter seems to be a deprecated variable from a former implementation of the mcs card + # pylint: disable=protected-access + self.parent._run_subs( + sub_type=self.parent.SUB_PROGRESS, + value=self.counter * int(self.parent.scaninfo.num_points / num_lines) + value, + max_value=max_value, + # TODO check if that is correct with + done=bool(max_value == value), # == self.counter), + ) + + def _on_mca_data(self, *args, obj=None, value=None, **kwargs) -> None: + """Callback function for scan progress""" + with self._lock: + if not isinstance(value, (list, np.ndarray)): + return + self.mca_data[obj.attr_name] = value + if len(self.mca_names) != len(self.mca_data): + return + self.acquisition_done = True + self._send_data_to_bec() + self.mca_data = defaultdict(lambda: []) + + def _send_data_to_bec(self) -> None: + """Sends bundled data to BEC""" + if self.parent.scaninfo.scan_msg is None: + return + metadata = self.parent.scaninfo.scan_msg.metadata + metadata.update({"async_update": "append", "num_lines": self.parent.num_lines.get()}) + msg = messages.DeviceMessage( + signals=dict(self.mca_data), metadata=self.parent.scaninfo.scan_msg.metadata + ) + self.parent.connector.xadd( + topic=MessageEndpoints.device_async_readback( + scan_id=self.parent.scaninfo.scan_id, device=self.parent.name + ), + msg={"data": msg}, + expire=self._stream_ttl, + ) + + def on_stage(self) -> None: + """Stage detector""" + self.prepare_detector() + self.prepare_detector_backend() + + def prepare_detector(self) -> None: + """Prepare detector for scan""" + self.set_acquisition_params() + self.parent.input_mode.set(TriggerSource.MODE3) + + def set_acquisition_params(self) -> None: + """Set acquisition parameters for scan""" + if self.parent.scaninfo.scan_type == "step": + self.n_points = int(self.parent.scaninfo.frames_per_trigger) * int( + self.parent.scaninfo.num_points + ) + elif self.parent.scaninfo.scan_type == "fly": + self.n_points = int(self.parent.scaninfo.num_points) # / int(self.num_lines.get())) + else: + raise MCSError(f"Scantype {self.parent.scaninfo} not implemented for MCS card") + if self.n_points > 10000: + raise MCSError( + f"Requested number of points N={self.n_points} exceeds hardware limit of mcs card" + " 10000 (N-1)" + ) + self.parent.num_use_all.set(self.n_points) + self.parent.preset_real.set(0) + + def prepare_detector_backend(self) -> None: + """Prepare detector backend for scan""" + self.parent.erase_all.set(1) + self.parent.read_mode.set(ReadoutMode.EVENT) + + def arm_acquisition(self) -> None: + """Arm detector for acquisition""" + self.counter = 0 + self.parent.erase_start.set(1) + + def on_unstage(self) -> None: + """Unstage detector""" + pass + + def on_complete(self) -> None: + """Complete detector""" + self.finished(timeout=self.parent.TIMEOUT_FOR_SIGNALS) + + def finished(self, timeout: int = 5) -> None: + """Check if acquisition is finished, if not successful, rais MCSTimeoutError""" + signal_conditions = [ + (lambda: self.acquisition_done, True), + (self.parent.acquiring.get, 0), # Considering making a enum.Int class for this state + ] + if not self.wait_for_signals( + signal_conditions=signal_conditions, + timeout=timeout, + check_stopped=True, + all_signals=True, + ): + total_frames = self.counter * int( + self.parent.scaninfo.num_points / self.parent.num_lines.get() + ) + max(self.parent.current_channel.get(), 0) + raise MCSTimeoutError( + f"Reached timeout with mcs in state {self.parent.acquiring.get()} and" + f" {total_frames} frames arriving at the mcs card" + ) + + def on_stop(self) -> None: + """Stop detector""" + self.stop_detector() + self.stop_detector_backend() + + def stop_detector(self) -> None: + """Stop detector""" + self.parent.stop_all.set(1) + + def stop_detector_backend(self) -> None: + """Stop acquisition of data""" + self.acquisition_done = True + + +class SIS38XX(Device): + """SIS38XX card for access to EPICs PVs at cSAXS beamline""" + + +class MCScSAXS(PSIDetectorBase): + """MCS card for cSAXS for implementation at cSAXS beamline""" + + USER_ACCESS = [] + SUB_PROGRESS = "progress" + SUB_VALUE = "value" + _default_sub = SUB_VALUE + + # specify Setup class + custom_prepare_cls = MCSSetup + # specify minimum readout time for detector + MIN_READOUT = 0 + TIMEOUT_FOR_SIGNALS = 5 + + # PV access to SISS38XX card + # Acquisition + erase_all = Cpt(EpicsSignal, "EraseAll") + erase_start = Cpt(EpicsSignal, "EraseStart") # ,trigger_value=1 + start_all = Cpt(EpicsSignal, "StartAll") + stop_all = Cpt(EpicsSignal, "StopAll") + acquiring = Cpt(EpicsSignal, "Acquiring") + preset_real = Cpt(EpicsSignal, "PresetReal") + elapsed_real = Cpt(EpicsSignal, "ElapsedReal") + read_mode = Cpt(EpicsSignal, "ReadAll.SCAN") + read_all = Cpt(EpicsSignal, "DoReadAll.VAL") # ,trigger_value=1 + num_use_all = Cpt(EpicsSignal, "NuseAll") + current_channel = Cpt(EpicsSignal, "CurrentChannel") + dwell = Cpt(EpicsSignal, "Dwell") + channel_advance = Cpt(EpicsSignal, "ChannelAdvance") + count_on_start = Cpt(EpicsSignal, "CountOnStart") + software_channel_advance = Cpt(EpicsSignal, "SoftwareChannelAdvance") + channel1_source = Cpt(EpicsSignal, "Channel1Source") + prescale = Cpt(EpicsSignal, "Prescale") + enable_client_wait = Cpt(EpicsSignal, "EnableClientWait") + client_wait = Cpt(EpicsSignal, "ClientWait") + acquire_mode = Cpt(EpicsSignal, "AcquireMode") + mux_output = Cpt(EpicsSignal, "MUXOutput") + user_led = Cpt(EpicsSignal, "UserLED") + input_mode = Cpt(EpicsSignal, "InputMode") + input_polarity = Cpt(EpicsSignal, "InputPolarity") + output_mode = Cpt(EpicsSignal, "OutputMode") + output_polarity = Cpt(EpicsSignal, "OutputPolarity") + model = Cpt(EpicsSignalRO, "Model", string=True) + firmware = Cpt(EpicsSignalRO, "Firmware") + max_channels = Cpt(EpicsSignalRO, "MaxChannels") + + # PV access to MCA signals + mca1 = Cpt(EpicsSignalRO, "mca1.VAL", auto_monitor=True) + mca3 = Cpt(EpicsSignalRO, "mca3.VAL", auto_monitor=True) + mca4 = Cpt(EpicsSignalRO, "mca4.VAL", auto_monitor=True) + current_channel = Cpt(EpicsSignalRO, "CurrentChannel", auto_monitor=True) + + # Custom signal readout from device config + num_lines = Cpt( + bec_utils.ConfigSignal, name="num_lines", kind="config", config_storage_name="mcs_config" + ) + + def __init__( + self, + prefix="", + *, + name, + kind=None, + parent=None, + device_manager=None, + mcs_config=None, + **kwargs, + ): + self.mcs_config = {f"{name}_num_lines": 1} + if mcs_config is not None: + # pylint: disable=expression-not-assigned + [self.mcs_config.update({f"{name}_{key}": value}) for key, value in mcs_config.items()] + + super().__init__( + prefix=prefix, + name=name, + kind=kind, + parent=parent, + device_manager=device_manager, + **kwargs, + ) + + +# Automatically connect to test environmenr if directly invoked +if __name__ == "__main__": + mcs = MCScSAXS(name="mcs", prefix="X12SA-MCS:", sim_mode=True) -- 2.49.1 From 62ecdd37ffce2c0cad884cd51fe2bfd0d919fe1a Mon Sep 17 00:00:00 2001 From: gac-x12sa Date: Wed, 16 Jul 2025 17:23:57 +0200 Subject: [PATCH 02/23] refactor(mcs-card): fix mcs card integration at the beamline --- .../epics/delay_generator_csaxs/ddg_1.py | 36 ++++++++ csaxs_bec/devices/epics/mcs_card/mcs_card.py | 11 +-- .../devices/epics/mcs_card/mcs_card_csaxs.py | 90 ++++++++++++++++--- 3 files changed, 121 insertions(+), 16 deletions(-) diff --git a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py index b0bcadd..0a32c6d 100644 --- a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py @@ -28,12 +28,16 @@ DELAY CHANNELS: - e = d - f = e + 1us (short pulse to OR gate for MCS triggering) """ +from __future__ import annotations + +from typing import TYPE_CHECKING import time from bec_lib.logger import bec_logger from ophyd import DeviceStatus, StatusBase from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase +from ophyd_devices import CompareStatus, TransitionStatus from csaxs_bec.devices.epics.delay_generator_csaxs.delay_generator_csaxs import ( CHANNELREFERENCE, @@ -44,6 +48,12 @@ from csaxs_bec.devices.epics.delay_generator_csaxs.delay_generator_csaxs import ChannelConfig, DelayGeneratorCSAXS, ) +from csaxs_bec.devices.epics.mcs_card.mcs_card_csaxs import READYTOREAD, ACQUIRING + + +if TYPE_CHECKING: # pragma: no cover + from bec_lib.devicemanager import DeviceManagerBase, ScanInfo + from csaxs_bec.devices.epics.mcs_card.mcs_card_csaxs import MCSCardCSAXS logger = bec_logger.logger @@ -83,6 +93,16 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): It is operated in standard mode, not burst mode and will trigger the EXT/EN of DDG2 (channel ab). It is responsible for opening the shutter (channel cd) and sending an extra trigger to an or gate for the MCS card (channel ef). """ + def __init__(self, name: str, + prefix: str = "", + scan_info: ScanInfo | None = None, + device_manager: DeviceManagerBase | None = None, + **kwargs): + """ + Initialize the MCSCardCSAXS with the given arguments and keyword arguments. + """ + super().__init__(name=name, prefix=prefix, scan_info=scan_info,device_manager=device_manager, **kwargs) + self.device_manager=device_manager # pylint: disable=attribute-defined-outside-init def on_connected(self) -> None: @@ -127,6 +147,22 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): If we don't then subsequent triggers may reach the DDG too early, and will be ignored. To avoid this, we've added the option to specify a delay via add_delay, default here is 50ms. """ + mcs = self.device_manager.devices.get('mcs', None) + if mcs is None: + logger.info(f"Did not find mcs card in current session") + else: + mcs :MCSCardCSAXS + status_ready_read = CompareStatus(mcs.ready_to_read, READYTOREAD.DONE) + mcs.stop_all.put(1) + status_acquiring = TransitionStatus(mcs.acquiring, [ACQUIRING.DONE, ACQUIRING.ACQUIRING]) + self.cancel_on_stop(status_ready_read) + self.cancel_on_stop(status_acquiring) + status_ready_read.wait(2) + + mcs.ready_to_read.put(READYTOREAD.PROCESSING) + mcs.erase_start.put(1) + status_acquiring.wait(timeout=2)# 2 s wait for mcs card to start should be more than enough.. + st = StatusBase() self.cancel_on_stop(st) self.trigger_shot.put(1, use_complete=True) diff --git a/csaxs_bec/devices/epics/mcs_card/mcs_card.py b/csaxs_bec/devices/epics/mcs_card/mcs_card.py index 44060cd..afb605a 100644 --- a/csaxs_bec/devices/epics/mcs_card/mcs_card.py +++ b/csaxs_bec/devices/epics/mcs_card/mcs_card.py @@ -19,7 +19,7 @@ from __future__ import annotations import enum from ophyd import Component as Cpt -from ophyd import DynamicDeviceComponent, EpicsSignal, EpicsSignalRO, Kind +from ophyd import DynamicDeviceComponent, EpicsSignal, EpicsSignalRO, Kind, Device class CHANNELADVANCE(int, enum.Enum): @@ -147,13 +147,13 @@ def _create_mca_channels(num_channels: int) -> dict[str, tuple]: for i in range(1, num_channels + 1): mcs_channels[f"mca{i}"] = ( EpicsSignalRO, - f"MCA{i}", + f'mca{i}.VAL', {"kind": Kind.omitted, "auto_monitor": True, "doc": f"MCA channel {i}."}, ) return mcs_channels -class MCSCard: +class MCSCard(Device): """ Ophyd implementation for the interface to the SIS3801/SIS3820 multichannel scaler (MCS) cards via EPICS. @@ -209,13 +209,14 @@ class MCSCard: ) read_all = Cpt( EpicsSignal, - "ReadAll", + "DoReadAll.VAL", kind=Kind.omitted, doc="Forces a read of all mca or waveform records from the hardware. This record can be set to periodically process to update the records during acquisition. Note that even if this record has SCAN=Passive the mca or waveform records will always process once when acquisition completes.", ) + read_mode = Cpt(EpicsSignal, "ReadAll.SCAN", kind=Kind.omitted, doc="Readout mode for transferring data from FIFO buffer to mca EPICS scalars.") num_use_all = Cpt( EpicsSignal, - "NUseAll", + "NuseAll", kind=Kind.omitted, doc="The number of channels to use for the mca or waveform records. Acquisition will automatically stop when the number of channel advances reaches this value.", ) diff --git a/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py b/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py index 7af0fe9..3d785c9 100644 --- a/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py +++ b/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py @@ -1,9 +1,14 @@ """Module for the MCSCard CSAXS implementation.""" - -from ophyd import Component as Cpt -from ophyd_devices import CompareStatus, ProgressSignal +from __future__ import annotations +from typing import TYPE_CHECKING +from ophyd import Component as Cpt, Signal, Kind, EpicsSignalRO +from ophyd_devices import CompareStatus, ProgressSignal, TransitionStatus from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase +from threading import RLock +import enum +from bec_lib.logger import bec_logger + from csaxs_bec.devices.epics.mcs_card.mcs_card import ( ACQUIREMODE, ACQUIRING, @@ -16,6 +21,15 @@ from csaxs_bec.devices.epics.mcs_card.mcs_card import ( MCSCard, ) +if TYPE_CHECKING: # pragma: no cover + from bec_lib.devicemanager import DeviceManagerBase, ScanInfo + +logger =bec_logger.logger + +class READYTOREAD(int, enum.Enum): + + PROCESSING = 0 + DONE = 1 class MCSCardCSAXS(PSIDeviceBase, MCSCard): """ @@ -23,19 +37,40 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): The basic functionality is inherited from the MCSCard class. """ - progress: ProgressSignal = Cpt(ProgressSignal, "progress") + ready_to_read = Cpt(Signal, name='ready_to_read', kind=Kind.omitted, doc='Signal that indicates if mcs card is ready to be read from after triggers. 0 not ready, 1 ready') + progress: ProgressSignal = Cpt(ProgressSignal, name="progress") + count_time = Cpt(Signal, name="count_time", kind=Kind.normal) + bpm_1 = Cpt(Signal, name='bpm_1', kind=Kind.normal) + bpm_2 = Cpt(Signal, name='bpm_2', kind=Kind.normal) + bpm_3 = Cpt(Signal, name='bpm_3', kind=Kind.normal) + bpm_4 = Cpt(Signal, name='bpm_4', kind=Kind.normal) - def __init__(self, *args, **kwargs): + def __init__(self, name: str, + prefix: str = "", + scan_info: ScanInfo | None = None, + device_manager: DeviceManagerBase | None = None, + **kwargs): """ Initialize the MCSCardCSAXS with the given arguments and keyword arguments. """ - super().__init__(*args, **kwargs) + super().__init__(name=name, prefix=prefix, scan_info=scan_info,device_manager=device_manager, **kwargs) self._pv_timeout = 2 + self._rlock = RLock() + self.counter_mapping = {f"{self.counters.name}_mca1" : 'bpm_1', + f"{self.counters.name}_mca2" : 'bpm_2', + f"{self.counters.name}_mca3" : 'bpm_3', + f"{self.counters.name}_mca4" : 'bpm_4', + f"{self.counters.name}_mca5" : 'count_time' + } + self.counter_updated = [] def on_connected(self): """ Called when the device is connected. """ + # Make sure card is not running + self.stop_all.put(1) + self.channel_advance.set(CHANNELADVANCE.EXTERNAL).wait(timeout=self._pv_timeout) self.channel_advance.set(CHANNEL1SOURCE.EXTERNAL).wait( timeout=self._pv_timeout @@ -53,17 +88,40 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): ) # To be checked and tested! self.count_on_start.set(0).wait(timeout=self._pv_timeout) + # Set appropriate read mode + self.read_mode.set(READMODE.PASSIVE).wait(timeout=self._pv_timeout) + # Subscribe the progress signal self.current_channel.subscribe(self._progress_update, run=False) - # Set appropriate read mode - self.read_mode.set(READMODE.PASSIVE).wait(timeout=self._pv_timeout) + # Subscribe to the mca updates + for name in self.counters.component_names: + sig:EpicsSignalRO = getattr(self.counters, name) + sig.subscribe(self._on_counter_update, run=False) + + def _on_counter_update(self, value, **kwargs) -> None: + with self._rlock: + signal = kwargs.get("obj", None) + if signal is None: + logger.info(f"Called without 'obj' in kwargs: {kwargs}") + return + mapped_signal_name = self.counter_mapping.get(signal.name, None) + if mapped_signal_name is None: + logger.info(f"Received update from unmapped signal {signal.name}") + return + sig = getattr(self, mapped_signal_name) + sig.put(value) + self.counter_updated.append(signal.name) + received_all_updates = (set(self.counter_updated) == set(self.counter_mapping.keys())) + logger.info(f"Value for received_all_updates {received_all_updates}") + if received_all_updates: + self.ready_to_read.put(1) # Reset happens from DDG class! + self.counter_updated.clear() def _progress_update(self, value, **kwargs) -> None: """Callback for progress updates from ophyd subscription on current_channel.""" frames_per_trigger = self.scan_info.msg.scan_parameters.get("frames_per_trigger", 1) - channel_advance = frames_per_trigger + 1 - self.progress.put(value, max_value=channel_advance, done=bool(value == channel_advance)) + self.progress.put(value=value, max_value=frames_per_trigger, done=bool(value == frames_per_trigger)) def on_stage(self) -> None: """ @@ -78,13 +136,22 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): """ Called when the device is unstaged. """ + self.stop_all.put(1) + self.ready_to_read.put(READYTOREAD.DONE) self.erase_all.set(0).wait(timeout=self._pv_timeout) + + + + def on_trigger(self) -> None: + status = TransitionStatus(self.ready_to_read, stric=True, transitions=[READYTOREAD.PROCESSING, READYTOREAD.DONE]) + self.cancel_on_stop(status) + return status + def on_pre_scan(self) -> None: """ Called before the scan starts. """ - self.erase_start.put(1) def on_complete(self) -> CompareStatus: """On scan completion.""" @@ -98,5 +165,6 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): Called when the scan is stopped. """ self.stop_all.put(1) + self.ready_to_read.put(READYTOREAD.DONE) # Reset the progress signal # self.progress.put(0, done=True) -- 2.49.1 From 7a3ce97e3018bf5ff084699b3ca34103640a5767 Mon Sep 17 00:00:00 2001 From: appel_c Date: Thu, 17 Jul 2025 09:41:25 +0200 Subject: [PATCH 03/23] refactor: cleanup and formatting --- .../epics/delay_generator_csaxs/ddg_1.py | 40 +++-- .../epics/delay_generator_csaxs/ddg_2.py | 4 +- csaxs_bec/devices/epics/mcs_card/mcs_card.py | 11 +- .../devices/epics/mcs_card/mcs_card_csaxs.py | 167 ++++++++++++++---- 4 files changed, 163 insertions(+), 59 deletions(-) diff --git a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py index 0a32c6d..a5c2702 100644 --- a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py @@ -28,16 +28,16 @@ DELAY CHANNELS: - e = d - f = e + 1us (short pulse to OR gate for MCS triggering) """ + from __future__ import annotations -from typing import TYPE_CHECKING - import time +from typing import TYPE_CHECKING from bec_lib.logger import bec_logger from ophyd import DeviceStatus, StatusBase -from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase from ophyd_devices import CompareStatus, TransitionStatus +from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase from csaxs_bec.devices.epics.delay_generator_csaxs.delay_generator_csaxs import ( CHANNELREFERENCE, @@ -48,11 +48,11 @@ from csaxs_bec.devices.epics.delay_generator_csaxs.delay_generator_csaxs import ChannelConfig, DelayGeneratorCSAXS, ) -from csaxs_bec.devices.epics.mcs_card.mcs_card_csaxs import READYTOREAD, ACQUIRING - +from csaxs_bec.devices.epics.mcs_card.mcs_card_csaxs import ACQUIRING, READYTOREAD if TYPE_CHECKING: # pragma: no cover from bec_lib.devicemanager import DeviceManagerBase, ScanInfo + from csaxs_bec.devices.epics.mcs_card.mcs_card_csaxs import MCSCardCSAXS logger = bec_logger.logger @@ -93,16 +93,22 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): It is operated in standard mode, not burst mode and will trigger the EXT/EN of DDG2 (channel ab). It is responsible for opening the shutter (channel cd) and sending an extra trigger to an or gate for the MCS card (channel ef). """ - def __init__(self, name: str, + + def __init__( + self, + name: str, prefix: str = "", scan_info: ScanInfo | None = None, device_manager: DeviceManagerBase | None = None, - **kwargs): + **kwargs, + ): """ Initialize the MCSCardCSAXS with the given arguments and keyword arguments. """ - super().__init__(name=name, prefix=prefix, scan_info=scan_info,device_manager=device_manager, **kwargs) - self.device_manager=device_manager + super().__init__( + name=name, prefix=prefix, scan_info=scan_info, device_manager=device_manager, **kwargs + ) + self.device_manager = device_manager # pylint: disable=attribute-defined-outside-init def on_connected(self) -> None: @@ -147,22 +153,26 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): If we don't then subsequent triggers may reach the DDG too early, and will be ignored. To avoid this, we've added the option to specify a delay via add_delay, default here is 50ms. """ - mcs = self.device_manager.devices.get('mcs', None) + mcs = self.device_manager.devices.get("mcs", None) if mcs is None: logger.info(f"Did not find mcs card in current session") else: - mcs :MCSCardCSAXS + mcs: MCSCardCSAXS status_ready_read = CompareStatus(mcs.ready_to_read, READYTOREAD.DONE) mcs.stop_all.put(1) - status_acquiring = TransitionStatus(mcs.acquiring, [ACQUIRING.DONE, ACQUIRING.ACQUIRING]) + status_acquiring = TransitionStatus( + mcs.acquiring, [ACQUIRING.DONE, ACQUIRING.ACQUIRING] + ) self.cancel_on_stop(status_ready_read) self.cancel_on_stop(status_acquiring) status_ready_read.wait(2) - + mcs.ready_to_read.put(READYTOREAD.PROCESSING) mcs.erase_start.put(1) - status_acquiring.wait(timeout=2)# 2 s wait for mcs card to start should be more than enough.. - + status_acquiring.wait( + timeout=2 + ) # 2 s wait for mcs card to start should be more than enough.. + st = StatusBase() self.cancel_on_stop(st) self.trigger_shot.put(1, use_complete=True) diff --git a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_2.py b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_2.py index 9b9c76b..260e649 100644 --- a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_2.py +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_2.py @@ -103,13 +103,13 @@ class DDG2(PSIDeviceBase, DelayGeneratorCSAXS): burst_pulse_width = exp_time - DEFAULT_READOUT_TIMES["ab"] self.set_delay_pairs(channel="ab", delay=0, width=burst_pulse_width) self.burst_enable(count=frames_per_trigger, delay=0, period=exp_time) - + def on_pre_scan(self): """ The delay generator occasionally needs a bit extra time to process all commands from stage. Therefore, we introduce here a short sleep """ - # Delay Generator occasionaly needs a bit extra time to process all commands, sleep 50ms + # Delay Generator occasionaly needs a bit extra time to process all commands, sleep 50ms time.sleep(0.05) def on_trigger(self) -> DeviceStatus | StatusBase | None: diff --git a/csaxs_bec/devices/epics/mcs_card/mcs_card.py b/csaxs_bec/devices/epics/mcs_card/mcs_card.py index afb605a..eb7d091 100644 --- a/csaxs_bec/devices/epics/mcs_card/mcs_card.py +++ b/csaxs_bec/devices/epics/mcs_card/mcs_card.py @@ -19,7 +19,7 @@ from __future__ import annotations import enum from ophyd import Component as Cpt -from ophyd import DynamicDeviceComponent, EpicsSignal, EpicsSignalRO, Kind, Device +from ophyd import Device, DynamicDeviceComponent, EpicsSignal, EpicsSignalRO, Kind class CHANNELADVANCE(int, enum.Enum): @@ -147,7 +147,7 @@ def _create_mca_channels(num_channels: int) -> dict[str, tuple]: for i in range(1, num_channels + 1): mcs_channels[f"mca{i}"] = ( EpicsSignalRO, - f'mca{i}.VAL', + f"mca{i}.VAL", {"kind": Kind.omitted, "auto_monitor": True, "doc": f"MCA channel {i}."}, ) return mcs_channels @@ -213,7 +213,12 @@ class MCSCard(Device): kind=Kind.omitted, doc="Forces a read of all mca or waveform records from the hardware. This record can be set to periodically process to update the records during acquisition. Note that even if this record has SCAN=Passive the mca or waveform records will always process once when acquisition completes.", ) - read_mode = Cpt(EpicsSignal, "ReadAll.SCAN", kind=Kind.omitted, doc="Readout mode for transferring data from FIFO buffer to mca EPICS scalars.") + read_mode = Cpt( + EpicsSignal, + "ReadAll.SCAN", + kind=Kind.omitted, + doc="Readout mode for transferring data from FIFO buffer to mca EPICS scalars.", + ) num_use_all = Cpt( EpicsSignal, "NuseAll", diff --git a/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py b/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py index 3d785c9..288da5c 100644 --- a/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py +++ b/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py @@ -1,14 +1,17 @@ """Module for the MCSCard CSAXS implementation.""" + from __future__ import annotations + +import enum +from threading import RLock from typing import TYPE_CHECKING -from ophyd import Component as Cpt, Signal, Kind, EpicsSignalRO + +from bec_lib.logger import bec_logger +from ophyd import Component as Cpt +from ophyd import Device, EpicsSignalRO, Kind, Signal from ophyd_devices import CompareStatus, ProgressSignal, TransitionStatus from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase -from threading import RLock -import enum -from bec_lib.logger import bec_logger - from csaxs_bec.devices.epics.mcs_card.mcs_card import ( ACQUIREMODE, ACQUIRING, @@ -20,48 +23,111 @@ from csaxs_bec.devices.epics.mcs_card.mcs_card import ( READMODE, MCSCard, ) +from csaxs_bec.devices.epics.xbpms import DiffXYSignal, SumSignal if TYPE_CHECKING: # pragma: no cover from bec_lib.devicemanager import DeviceManagerBase, ScanInfo -logger =bec_logger.logger +logger = bec_logger.logger + class READYTOREAD(int, enum.Enum): PROCESSING = 0 DONE = 1 + +class BPMDevice(Device): + """Class for BPM device of the MCSCard.""" + + current1 = Cpt(Signal, name="current1", kind=Kind.normal, doc="Current 1") + current2 = Cpt(Signal, name="current2", kind=Kind.normal, doc="Current 2") + current3 = Cpt(Signal, name="current3", kind=Kind.normal, doc="Current 3") + current4 = Cpt(Signal, name="current4", kind=Kind.normal, doc="Current 4") + sum = Cpt(SumSignal, kind="hinted", doc="Sum of all currents") + x = Cpt( + DiffXYSignal, + sum1=["current1", "current2"], + sum2=["current3", "current4"], + doc="X difference signal", + ) + y = Cpt( + DiffXYSignal, + sum1=["current1", "current3"], + sum2=["current2", "current4"], + doc="Y difference signal", + ) + diag = Cpt( + DiffXYSignal, + sum1=["current1", "current4"], + sum2=["current2", "current3"], + doc="Diagonal difference signal", + ) + + +class BPMCountNormalized(BPMDevice): + """Class for BPM device of the MCSCard with normalized currents.""" + + current1 = Cpt(Signal, name="current1", kind=Kind.normal, doc="Count time normalized current 1") + current2 = Cpt(Signal, name="current2", kind=Kind.normal, doc="Count time normalized current 2") + current3 = Cpt(Signal, name="current3", kind=Kind.normal, doc="Count time normalized current 3") + current4 = Cpt(Signal, name="current4", kind=Kind.normal, doc="Count time normalized current 4") + + class MCSCardCSAXS(PSIDeviceBase, MCSCard): """ Implementation of the MCSCard SIS3820 for CSAXS, prefix 'X12SA-MCS:'. The basic functionality is inherited from the MCSCard class. """ - ready_to_read = Cpt(Signal, name='ready_to_read', kind=Kind.omitted, doc='Signal that indicates if mcs card is ready to be read from after triggers. 0 not ready, 1 ready') + ready_to_read = Cpt( + Signal, + name="ready_to_read", + kind=Kind.omitted, + doc="Signal that indicates if mcs card is ready to be read from after triggers. 0 not ready, 1 ready", + ) progress: ProgressSignal = Cpt(ProgressSignal, name="progress") count_time = Cpt(Signal, name="count_time", kind=Kind.normal) - bpm_1 = Cpt(Signal, name='bpm_1', kind=Kind.normal) - bpm_2 = Cpt(Signal, name='bpm_2', kind=Kind.normal) - bpm_3 = Cpt(Signal, name='bpm_3', kind=Kind.normal) - bpm_4 = Cpt(Signal, name='bpm_4', kind=Kind.normal) + bpm = Cpt( + BPMDevice, name="bpm", kind=Kind.normal, doc="BPM device for MCSCard, normalized currents" + ) + bpm_norm = Cpt( + BPMCountNormalized, + name="bpmnorm", + kind=Kind.normal, + doc="BPM device for MCSCard, normalized currents", + ) + bpm_plot = Cpt( + BPMDevice, + name="bpm_plot", + kind=Kind.normal, + doc="subdevice with synchronized readings for plotting with monitored signals", + ) - def __init__(self, name: str, + def __init__( + self, + name: str, prefix: str = "", scan_info: ScanInfo | None = None, device_manager: DeviceManagerBase | None = None, - **kwargs): + **kwargs, + ): """ Initialize the MCSCardCSAXS with the given arguments and keyword arguments. """ - super().__init__(name=name, prefix=prefix, scan_info=scan_info,device_manager=device_manager, **kwargs) + super().__init__( + name=name, prefix=prefix, scan_info=scan_info, device_manager=device_manager, **kwargs + ) + self._mcs_clock = 10e-6 # 10MHz clock self._pv_timeout = 2 self._rlock = RLock() - self.counter_mapping = {f"{self.counters.name}_mca1" : 'bpm_1', - f"{self.counters.name}_mca2" : 'bpm_2', - f"{self.counters.name}_mca3" : 'bpm_3', - f"{self.counters.name}_mca4" : 'bpm_4', - f"{self.counters.name}_mca5" : 'count_time' - } + self.counter_mapping = { + f"{self.counters.name}_mca1": "current1", + f"{self.counters.name}_mca2": "current2", + f"{self.counters.name}_mca3": "current3", + f"{self.counters.name}_mca4": "current4", + f"{self.counters.name}_mca5": "count_time", + } self.counter_updated = [] def on_connected(self): @@ -72,20 +138,17 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): self.stop_all.put(1) self.channel_advance.set(CHANNELADVANCE.EXTERNAL).wait(timeout=self._pv_timeout) - self.channel_advance.set(CHANNEL1SOURCE.EXTERNAL).wait( - timeout=self._pv_timeout - ) # Check if this is correct, or internal clock + self.channel_advance.set(CHANNEL1SOURCE.EXTERNAL).wait(timeout=self._pv_timeout) + self.prescale.set(1).wait(timeout=self._pv_timeout) + # Set the user LED to off self.user_led.set(0).wait(timeout=self._pv_timeout) # Only channel 1-5 are connected so far, adjust if more are needed self.mux_output.set(5).wait(timeout=self._pv_timeout) + # Set the input and output modes & polarities self.input_mode.set(INPUTMODE.MODE_3).wait(timeout=self._pv_timeout) self.input_polarity.set(POLARITY.NORMAL).wait(timeout=self._pv_timeout) - self.output_mode.set(OUTPUTMODE.MODE_2).wait( - timeout=self._pv_timeout - ) # To be checked due to cabling of time counter - self.output_polarity.set(POLARITY.INVERTED).wait( - timeout=self._pv_timeout - ) # To be checked and tested! + self.output_mode.set(OUTPUTMODE.MODE_2).wait(timeout=self._pv_timeout) + self.output_polarity.set(POLARITY.NORMAL).wait(timeout=self._pv_timeout) self.count_on_start.set(0).wait(timeout=self._pv_timeout) # Set appropriate read mode @@ -94,9 +157,12 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): # Subscribe the progress signal self.current_channel.subscribe(self._progress_update, run=False) + # Set the acquire mode + self.acquire_mode.set(ACQUIREMODE.MCS).wait(timeout=self._pv_timeout) + # Subscribe to the mca updates for name in self.counters.component_names: - sig:EpicsSignalRO = getattr(self.counters, name) + sig: EpicsSignalRO = getattr(self.counters, name) sig.subscribe(self._on_counter_update, run=False) def _on_counter_update(self, value, **kwargs) -> None: @@ -109,19 +175,43 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): if mapped_signal_name is None: logger.info(f"Received update from unmapped signal {signal.name}") return - sig = getattr(self, mapped_signal_name) + if mapped_signal_name == "count_time": + # Count time is not mapped into bpm signals + self.count_time.put(value) + return + # Update self.bpm + sig = getattr(self.bpm, mapped_signal_name) sig.put(value) self.counter_updated.append(signal.name) - received_all_updates = (set(self.counter_updated) == set(self.counter_mapping.keys())) - logger.info(f"Value for received_all_updates {received_all_updates}") + received_all_updates = set(self.counter_updated) == set(self.counter_mapping.keys()) if received_all_updates: - self.ready_to_read.put(1) # Reset happens from DDG class! + # Set the normalized currents + count_time = self.count_time.get() + for name in self.counter_mapping.values(): + if name == "count_time": + continue + sig = getattr(self.bpm, name) + sig_norm = getattr(self.bpm_norm, name) + if count_time <= 0: + logger.warning( + f"Count time is zero or negative ({count_time}), setting normalized current to 0." + ) + sig_norm.put(0.0) + continue + sig_norm.put(v / t for v, t in zip(sig.get(), count_time)) + sig_plot = getattr(self.bpm_plot, name) + sig_plot.put(sum(sig_norm.get()) / len(sig_norm.get())) + self.ready_to_read.put(1) # Reset happens from DDG class! self.counter_updated.clear() def _progress_update(self, value, **kwargs) -> None: """Callback for progress updates from ophyd subscription on current_channel.""" + # This logic needs to be further refined as this is currently reporting the progress + # of a single trigger from BEC within a burst scan. frames_per_trigger = self.scan_info.msg.scan_parameters.get("frames_per_trigger", 1) - self.progress.put(value=value, max_value=frames_per_trigger, done=bool(value == frames_per_trigger)) + self.progress.put( + value=value, max_value=frames_per_trigger, done=bool(value == frames_per_trigger) + ) def on_stage(self) -> None: """ @@ -139,15 +229,14 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): self.stop_all.put(1) self.ready_to_read.put(READYTOREAD.DONE) self.erase_all.set(0).wait(timeout=self._pv_timeout) - - def on_trigger(self) -> None: - status = TransitionStatus(self.ready_to_read, stric=True, transitions=[READYTOREAD.PROCESSING, READYTOREAD.DONE]) + status = TransitionStatus( + self.ready_to_read, strict=True, transitions=[READYTOREAD.PROCESSING, READYTOREAD.DONE] + ) self.cancel_on_stop(status) return status - def on_pre_scan(self) -> None: """ Called before the scan starts. -- 2.49.1 From cdac29bed1577df6e412f55b7f43769aa1e9fb3f Mon Sep 17 00:00:00 2001 From: appel_c Date: Mon, 21 Jul 2025 16:16:35 +0200 Subject: [PATCH 04/23] fix(xbpms): generalize describe method of sum,diff signals --- csaxs_bec/devices/epics/xbpms.py | 38 +++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/csaxs_bec/devices/epics/xbpms.py b/csaxs_bec/devices/epics/xbpms.py index db297bc..3706658 100644 --- a/csaxs_bec/devices/epics/xbpms.py +++ b/csaxs_bec/devices/epics/xbpms.py @@ -1,4 +1,5 @@ import time + from ophyd import Component as Cpt from ophyd import Device, EpicsSignalRO, Signal @@ -22,10 +23,10 @@ class SumSignal(Signal): def describe(self): source = [ - self.parent.current1.pvname, - self.parent.current2.pvname, - self.parent.current3.pvname, - self.parent.current4.pvname, + self.parent.current1.describe()[self.parent.current1.name]["source"], + self.parent.current2.describe()[self.parent.current2.name]["source"], + self.parent.current3.describe()[self.parent.current3.name]["source"], + self.parent.current4.describe()[self.parent.current4.name]["source"], ] source = " / ".join(source) desc = { @@ -33,7 +34,9 @@ class SumSignal(Signal): "dtype": "number", "source": f"PV: {source}", "units": "", - "precision": self.parent.current1.precision, + "precision": ( + self.parent.current1.precision if hasattr(self.parent.current1, "precision") else 0 + ), } return desc @@ -64,23 +67,36 @@ class DiffXYSignal(Signal): return (summed_1 - summed_2) / _sum def describe(self): - source = [getattr(self.parent, signal).pvname for signal in self.sum1 + self.sum2] + source = [ + getattr(self.parent, signal).describe()[getattr(self.parent, signal).name]["source"] + for signal in self.sum1 + self.sum2 + ] source = " / ".join(source) desc = { "shape": [], "dtype": "number", "source": f"PV: {source}", "units": "", - "precision": self.parent.current1.precision, + "precision": ( + self.parent.current1.precision if hasattr(self.parent.current1, "precision") else 0 + ), } return desc class BPMDevice(Device): - current1 = Cpt(EpicsSignalRO, ":Current1:MeanValue_RBV", kind="normal", doc="Current 1", auto_monitor=True) - current2 = Cpt(EpicsSignalRO, ":Current2:MeanValue_RBV", kind="normal", doc="Current 2", auto_monitor=True) - current3 = Cpt(EpicsSignalRO, ":Current3:MeanValue_RBV", kind="normal", doc="Current 3", auto_monitor=True) - current4 = Cpt(EpicsSignalRO, ":Current4:MeanValue_RBV", kind="normal", doc="Current 4", auto_monitor=True) + current1 = Cpt( + EpicsSignalRO, ":Current1:MeanValue_RBV", kind="normal", doc="Current 1", auto_monitor=True + ) + current2 = Cpt( + EpicsSignalRO, ":Current2:MeanValue_RBV", kind="normal", doc="Current 2", auto_monitor=True + ) + current3 = Cpt( + EpicsSignalRO, ":Current3:MeanValue_RBV", kind="normal", doc="Current 3", auto_monitor=True + ) + current4 = Cpt( + EpicsSignalRO, ":Current4:MeanValue_RBV", kind="normal", doc="Current 4", auto_monitor=True + ) sum = Cpt(SumSignal, kind="hinted", doc="Sum of all currents") x = Cpt( DiffXYSignal, -- 2.49.1 From 7679fa13838e4104b28e7cbcf15acd3f8abf3f10 Mon Sep 17 00:00:00 2001 From: appel_c Date: Mon, 21 Jul 2025 16:28:24 +0200 Subject: [PATCH 05/23] fix: cleanup, fix mcs_clock --- .../devices/epics/mcs_card/mcs_card_csaxs.py | 87 +++++++++---------- 1 file changed, 41 insertions(+), 46 deletions(-) diff --git a/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py b/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py index 288da5c..e2423f2 100644 --- a/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py +++ b/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py @@ -5,6 +5,7 @@ from __future__ import annotations import enum from threading import RLock from typing import TYPE_CHECKING +import numpy as np from bec_lib.logger import bec_logger from ophyd import Component as Cpt @@ -40,10 +41,13 @@ class READYTOREAD(int, enum.Enum): class BPMDevice(Device): """Class for BPM device of the MCSCard.""" - current1 = Cpt(Signal, name="current1", kind=Kind.normal, doc="Current 1") - current2 = Cpt(Signal, name="current2", kind=Kind.normal, doc="Current 2") - current3 = Cpt(Signal, name="current3", kind=Kind.normal, doc="Current 3") - current4 = Cpt(Signal, name="current4", kind=Kind.normal, doc="Current 4") + current1 = Cpt(Signal, name="current1", kind=Kind.normal, doc="Normalized current 1") + current2 = Cpt(Signal, name="current2", kind=Kind.normal, doc="Normalized current 2") + current3 = Cpt(Signal, name="current3", kind=Kind.normal, doc="Normalized current 3") + current4 = Cpt(Signal, name="current4", kind=Kind.normal, doc="Normalized current 4") + count_time = Cpt( + Signal, name="count_time", kind=Kind.normal, doc="Count time for bpm signal counts" + ) sum = Cpt(SumSignal, kind="hinted", doc="Sum of all currents") x = Cpt( DiffXYSignal, @@ -65,13 +69,14 @@ class BPMDevice(Device): ) -class BPMCountNormalized(BPMDevice): +class MCSRaw(Device): """Class for BPM device of the MCSCard with normalized currents.""" - current1 = Cpt(Signal, name="current1", kind=Kind.normal, doc="Count time normalized current 1") - current2 = Cpt(Signal, name="current2", kind=Kind.normal, doc="Count time normalized current 2") - current3 = Cpt(Signal, name="current3", kind=Kind.normal, doc="Count time normalized current 3") - current4 = Cpt(Signal, name="current4", kind=Kind.normal, doc="Count time normalized current 4") + mca1 = Cpt(Signal, name="current1", kind=Kind.normal, doc="Raw counts on mca1 channel") + mca2 = Cpt(Signal, name="current2", kind=Kind.normal, doc="Raw counts on mca2 channel") + mca3 = Cpt(Signal, name="current3", kind=Kind.normal, doc="Raw counts on mca3 channel") + mca4 = Cpt(Signal, name="current4", kind=Kind.normal, doc="Raw counts on mca4 channel") + mca5 = Cpt(Signal, name="current5", kind=Kind.normal, doc="Raw counts on mca5 channel") class MCSCardCSAXS(PSIDeviceBase, MCSCard): @@ -87,21 +92,18 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): doc="Signal that indicates if mcs card is ready to be read from after triggers. 0 not ready, 1 ready", ) progress: ProgressSignal = Cpt(ProgressSignal, name="progress") - count_time = Cpt(Signal, name="count_time", kind=Kind.normal) + # Make this an async signal.. + mcs = Cpt( + MCSRaw, + name="mcs", + kind=Kind.normal, + doc="MCS device with raw current and count time readings", + ) bpm = Cpt( - BPMDevice, name="bpm", kind=Kind.normal, doc="BPM device for MCSCard, normalized currents" - ) - bpm_norm = Cpt( - BPMCountNormalized, - name="bpmnorm", - kind=Kind.normal, - doc="BPM device for MCSCard, normalized currents", - ) - bpm_plot = Cpt( BPMDevice, - name="bpm_plot", + name="bpm", kind=Kind.normal, - doc="subdevice with synchronized readings for plotting with monitored signals", + doc="BPM device for MCSCard with count times and normalized currents", ) def __init__( @@ -118,7 +120,7 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): super().__init__( name=name, prefix=prefix, scan_info=scan_info, device_manager=device_manager, **kwargs ) - self._mcs_clock = 10e-6 # 10MHz clock + self._mcs_clock = 1e-7 # 10MHz clock self._pv_timeout = 2 self._rlock = RLock() self.counter_mapping = { @@ -161,8 +163,8 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): self.acquire_mode.set(ACQUIREMODE.MCS).wait(timeout=self._pv_timeout) # Subscribe to the mca updates - for name in self.counters.component_names: - sig: EpicsSignalRO = getattr(self.counters, name) + for name in self.counter_mapping.keys(): + sig: EpicsSignalRO = getattr(self.counters, name.split('_')[-1]) sig.subscribe(self._on_counter_update, run=False) def _on_counter_update(self, value, **kwargs) -> None: @@ -173,34 +175,27 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): return mapped_signal_name = self.counter_mapping.get(signal.name, None) if mapped_signal_name is None: - logger.info(f"Received update from unmapped signal {signal.name}") return - if mapped_signal_name == "count_time": - # Count time is not mapped into bpm signals - self.count_time.put(value) - return - # Update self.bpm + mca_raw = getattr(self.mcs, signal.name.split("_")[-1], None) + if mca_raw is None: + return + logger.info(f"Received update of type {type(value)} for {signal.name}") + if isinstance(value, np.ndarray): + mca_raw.put(value.tolist()) + if mapped_signal_name == "count_time": + value = value*self._mcs_clock + value = float(value.mean()) + else: + mca_raw.put(value) + if mapped_signal_name == "count_time": + value = value*self._mcs_clock + + # Mean signal for burst acquisition sig = getattr(self.bpm, mapped_signal_name) sig.put(value) self.counter_updated.append(signal.name) received_all_updates = set(self.counter_updated) == set(self.counter_mapping.keys()) if received_all_updates: - # Set the normalized currents - count_time = self.count_time.get() - for name in self.counter_mapping.values(): - if name == "count_time": - continue - sig = getattr(self.bpm, name) - sig_norm = getattr(self.bpm_norm, name) - if count_time <= 0: - logger.warning( - f"Count time is zero or negative ({count_time}), setting normalized current to 0." - ) - sig_norm.put(0.0) - continue - sig_norm.put(v / t for v, t in zip(sig.get(), count_time)) - sig_plot = getattr(self.bpm_plot, name) - sig_plot.put(sum(sig_norm.get()) / len(sig_norm.get())) self.ready_to_read.put(1) # Reset happens from DDG class! self.counter_updated.clear() -- 2.49.1 From c2cba873d4fb22a1cbe036c0d493c1eba536f1c2 Mon Sep 17 00:00:00 2001 From: appel_c Date: Mon, 21 Jul 2025 21:38:06 +0200 Subject: [PATCH 06/23] refactor: remove old mcs card --- .../devices/epics/mcs_card/mcs_csaxs_old.py | 319 ------------------ csaxs_bec/devices/epics/mcs_csaxs.py | 319 ------------------ .../test_delay_generator_csaxs.py | 13 +- 3 files changed, 9 insertions(+), 642 deletions(-) delete mode 100644 csaxs_bec/devices/epics/mcs_card/mcs_csaxs_old.py delete mode 100644 csaxs_bec/devices/epics/mcs_csaxs.py diff --git a/csaxs_bec/devices/epics/mcs_card/mcs_csaxs_old.py b/csaxs_bec/devices/epics/mcs_card/mcs_csaxs_old.py deleted file mode 100644 index 17822de..0000000 --- a/csaxs_bec/devices/epics/mcs_card/mcs_csaxs_old.py +++ /dev/null @@ -1,319 +0,0 @@ -import enum -import threading -from collections import defaultdict - -import numpy as np -from bec_lib import bec_logger, messages -from bec_lib.endpoints import MessageEndpoints -from ophyd import Component as Cpt -from ophyd import Device, EpicsSignal, EpicsSignalRO -from ophyd_devices.interfaces.base_classes.psi_detector_base import ( - CustomDetectorMixin, - PSIDetectorBase, -) -from ophyd_devices.utils import bec_utils - -logger = bec_logger.logger - - -class MCSError(Exception): - """Base class for exceptions in this module.""" - - -class MCSTimeoutError(MCSError): - """Raise when MCS card runs into a timeout""" - - -class TriggerSource(int, enum.Enum): - """Trigger source for mcs card - see manual for more information""" - - MODE0 = 0 - MODE1 = 1 - MODE2 = 2 - MODE3 = 3 - MODE4 = 4 - MODE5 = 5 - MODE6 = 6 - - -class ChannelAdvance(int, enum.Enum): - """Channel advance pixel mode for mcs card - see manual for more information""" - - INTERNAL = 0 - EXTERNAL = 1 - - -class ReadoutMode(int, enum.Enum): - """Readout mode for mcs card - see manual for more information""" - - PASSIVE = 0 - EVENT = 1 - IO_INTR = 2 - FREQ_0_1HZ = 3 - FREQ_0_2HZ = 4 - FREQ_0_5HZ = 5 - FREQ_1HZ = 6 - FREQ_2HZ = 7 - FREQ_5HZ = 8 - FREQ_10HZ = 9 - FREQ_100HZ = 10 - - -class MCSSetup(CustomDetectorMixin): - """Setup mixin class for the MCS card""" - - def __init__(self, *args, parent: Device = None, **kwargs) -> None: - super().__init__(*args, parent=parent, **kwargs) - self._lock = threading.RLock() - self._stream_ttl = 1800 - self.acquisition_done = False - self.counter = 0 - self.n_points = 0 - self.mca_names = [ - signal for signal in self.parent.component_names if signal.startswith("mca") - ] - self.mca_data = defaultdict(lambda: []) - - def on_init(self) -> None: - """Init sequence for the detector""" - self.initialize_detector() - self.initialize_detector_backend() - - def initialize_detector(self) -> None: - """Initialize d etector""" - # External trigger for pixel advance - self.parent.channel_advance.set(ChannelAdvance.EXTERNAL) - # Use internal clock for channel 1 - self.parent.channel1_source.set(ChannelAdvance.INTERNAL) - self.parent.user_led.set(0) - # Set number of channels to 5 - self.parent.mux_output.set(5) - # Trigger Mode used for cSAXS - self.parent.input_mode.set(TriggerSource.MODE3) - # specify polarity of trigger signals - self.parent.input_polarity.set(0) - self.parent.output_polarity.set(1) - # do not start counting on start - self.parent.count_on_start.set(0) - self.stop_detector() - - def initialize_detector_backend(self) -> None: - """Initialize detector backend""" - for mca in self.mca_names: - signal = getattr(self.parent, mca) - signal.subscribe(self._on_mca_data, run=False) - self.parent.current_channel.subscribe(self._progress_update, run=False) - - def _progress_update(self, value, **kwargs) -> None: - """Progress update on the scan""" - num_lines = self.parent.num_lines.get() - max_value = self.parent.scaninfo.num_points - # self.counter seems to be a deprecated variable from a former implementation of the mcs card - # pylint: disable=protected-access - self.parent._run_subs( - sub_type=self.parent.SUB_PROGRESS, - value=self.counter * int(self.parent.scaninfo.num_points / num_lines) + value, - max_value=max_value, - # TODO check if that is correct with - done=bool(max_value == value), # == self.counter), - ) - - def _on_mca_data(self, *args, obj=None, value=None, **kwargs) -> None: - """Callback function for scan progress""" - with self._lock: - if not isinstance(value, (list, np.ndarray)): - return - self.mca_data[obj.attr_name] = value - if len(self.mca_names) != len(self.mca_data): - return - self.acquisition_done = True - self._send_data_to_bec() - self.mca_data = defaultdict(lambda: []) - - def _send_data_to_bec(self) -> None: - """Sends bundled data to BEC""" - if self.parent.scaninfo.scan_msg is None: - return - metadata = self.parent.scaninfo.scan_msg.metadata - metadata.update({"async_update": "append", "num_lines": self.parent.num_lines.get()}) - msg = messages.DeviceMessage( - signals=dict(self.mca_data), metadata=self.parent.scaninfo.scan_msg.metadata - ) - self.parent.connector.xadd( - topic=MessageEndpoints.device_async_readback( - scan_id=self.parent.scaninfo.scan_id, device=self.parent.name - ), - msg={"data": msg}, - expire=self._stream_ttl, - ) - - def on_stage(self) -> None: - """Stage detector""" - self.prepare_detector() - self.prepare_detector_backend() - - def prepare_detector(self) -> None: - """Prepare detector for scan""" - self.set_acquisition_params() - self.parent.input_mode.set(TriggerSource.MODE3) - - def set_acquisition_params(self) -> None: - """Set acquisition parameters for scan""" - if self.parent.scaninfo.scan_type == "step": - self.n_points = int(self.parent.scaninfo.frames_per_trigger) * int( - self.parent.scaninfo.num_points - ) - elif self.parent.scaninfo.scan_type == "fly": - self.n_points = int(self.parent.scaninfo.num_points) # / int(self.num_lines.get())) - else: - raise MCSError(f"Scantype {self.parent.scaninfo} not implemented for MCS card") - if self.n_points > 10000: - raise MCSError( - f"Requested number of points N={self.n_points} exceeds hardware limit of mcs card" - " 10000 (N-1)" - ) - self.parent.num_use_all.set(self.n_points) - self.parent.preset_real.set(0) - - def prepare_detector_backend(self) -> None: - """Prepare detector backend for scan""" - self.parent.erase_all.set(1) - self.parent.read_mode.set(ReadoutMode.EVENT) - - def arm_acquisition(self) -> None: - """Arm detector for acquisition""" - self.counter = 0 - self.parent.erase_start.set(1) - - def on_unstage(self) -> None: - """Unstage detector""" - pass - - def on_complete(self) -> None: - """Complete detector""" - self.finished(timeout=self.parent.TIMEOUT_FOR_SIGNALS) - - def finished(self, timeout: int = 5) -> None: - """Check if acquisition is finished, if not successful, rais MCSTimeoutError""" - signal_conditions = [ - (lambda: self.acquisition_done, True), - (self.parent.acquiring.get, 0), # Considering making a enum.Int class for this state - ] - if not self.wait_for_signals( - signal_conditions=signal_conditions, - timeout=timeout, - check_stopped=True, - all_signals=True, - ): - total_frames = self.counter * int( - self.parent.scaninfo.num_points / self.parent.num_lines.get() - ) + max(self.parent.current_channel.get(), 0) - raise MCSTimeoutError( - f"Reached timeout with mcs in state {self.parent.acquiring.get()} and" - f" {total_frames} frames arriving at the mcs card" - ) - - def on_stop(self) -> None: - """Stop detector""" - self.stop_detector() - self.stop_detector_backend() - - def stop_detector(self) -> None: - """Stop detector""" - self.parent.stop_all.set(1) - - def stop_detector_backend(self) -> None: - """Stop acquisition of data""" - self.acquisition_done = True - - -class SIS38XX(Device): - """SIS38XX card for access to EPICs PVs at cSAXS beamline""" - - -class MCScSAXS(PSIDetectorBase): - """MCS card for cSAXS for implementation at cSAXS beamline""" - - USER_ACCESS = [] - SUB_PROGRESS = "progress" - SUB_VALUE = "value" - _default_sub = SUB_VALUE - - # specify Setup class - custom_prepare_cls = MCSSetup - # specify minimum readout time for detector - MIN_READOUT = 0 - TIMEOUT_FOR_SIGNALS = 5 - - # PV access to SISS38XX card - # Acquisition - erase_all = Cpt(EpicsSignal, "EraseAll") - erase_start = Cpt(EpicsSignal, "EraseStart") # ,trigger_value=1 - start_all = Cpt(EpicsSignal, "StartAll") - stop_all = Cpt(EpicsSignal, "StopAll") - acquiring = Cpt(EpicsSignal, "Acquiring") - preset_real = Cpt(EpicsSignal, "PresetReal") - elapsed_real = Cpt(EpicsSignal, "ElapsedReal") - read_mode = Cpt(EpicsSignal, "ReadAll.SCAN") - read_all = Cpt(EpicsSignal, "DoReadAll.VAL") # ,trigger_value=1 - num_use_all = Cpt(EpicsSignal, "NuseAll") - current_channel = Cpt(EpicsSignal, "CurrentChannel") - dwell = Cpt(EpicsSignal, "Dwell") - channel_advance = Cpt(EpicsSignal, "ChannelAdvance") - count_on_start = Cpt(EpicsSignal, "CountOnStart") - software_channel_advance = Cpt(EpicsSignal, "SoftwareChannelAdvance") - channel1_source = Cpt(EpicsSignal, "Channel1Source") - prescale = Cpt(EpicsSignal, "Prescale") - enable_client_wait = Cpt(EpicsSignal, "EnableClientWait") - client_wait = Cpt(EpicsSignal, "ClientWait") - acquire_mode = Cpt(EpicsSignal, "AcquireMode") - mux_output = Cpt(EpicsSignal, "MUXOutput") - user_led = Cpt(EpicsSignal, "UserLED") - input_mode = Cpt(EpicsSignal, "InputMode") - input_polarity = Cpt(EpicsSignal, "InputPolarity") - output_mode = Cpt(EpicsSignal, "OutputMode") - output_polarity = Cpt(EpicsSignal, "OutputPolarity") - model = Cpt(EpicsSignalRO, "Model", string=True) - firmware = Cpt(EpicsSignalRO, "Firmware") - max_channels = Cpt(EpicsSignalRO, "MaxChannels") - - # PV access to MCA signals - mca1 = Cpt(EpicsSignalRO, "mca1.VAL", auto_monitor=True) - mca3 = Cpt(EpicsSignalRO, "mca3.VAL", auto_monitor=True) - mca4 = Cpt(EpicsSignalRO, "mca4.VAL", auto_monitor=True) - current_channel = Cpt(EpicsSignalRO, "CurrentChannel", auto_monitor=True) - - # Custom signal readout from device config - num_lines = Cpt( - bec_utils.ConfigSignal, name="num_lines", kind="config", config_storage_name="mcs_config" - ) - - def __init__( - self, - prefix="", - *, - name, - kind=None, - parent=None, - device_manager=None, - mcs_config=None, - **kwargs, - ): - self.mcs_config = {f"{name}_num_lines": 1} - if mcs_config is not None: - # pylint: disable=expression-not-assigned - [self.mcs_config.update({f"{name}_{key}": value}) for key, value in mcs_config.items()] - - super().__init__( - prefix=prefix, - name=name, - kind=kind, - parent=parent, - device_manager=device_manager, - **kwargs, - ) - - -# Automatically connect to test environmenr if directly invoked -if __name__ == "__main__": - mcs = MCScSAXS(name="mcs", prefix="X12SA-MCS:", sim_mode=True) diff --git a/csaxs_bec/devices/epics/mcs_csaxs.py b/csaxs_bec/devices/epics/mcs_csaxs.py deleted file mode 100644 index 2969a85..0000000 --- a/csaxs_bec/devices/epics/mcs_csaxs.py +++ /dev/null @@ -1,319 +0,0 @@ -import enum -import threading -from collections import defaultdict - -import numpy as np -from bec_lib import bec_logger, messages -from bec_lib.endpoints import MessageEndpoints -from ophyd import Component as Cpt -from ophyd import Device, EpicsSignal, EpicsSignalRO -from ophyd_devices.interfaces.base_classes.psi_detector_base import ( - CustomDetectorMixin, - PSIDetectorBase, -) -from ophyd_devices.utils import bec_utils - -logger = bec_logger.logger - - -class MCSError(Exception): - """Base class for exceptions in this module.""" - - -class MCSTimeoutError(MCSError): - """Raise when MCS card runs into a timeout""" - - -class TriggerSource(int, enum.Enum): - """Trigger source for mcs card - see manual for more information""" - - MODE0 = 0 - MODE1 = 1 - MODE2 = 2 - MODE3 = 3 - MODE4 = 4 - MODE5 = 5 - MODE6 = 6 - - -class ChannelAdvance(int, enum.Enum): - """Channel advance pixel mode for mcs card - see manual for more information""" - - INTERNAL = 0 - EXTERNAL = 1 - - -class ReadoutMode(int, enum.Enum): - """Readout mode for mcs card - see manual for more information""" - - PASSIVE = 0 - EVENT = 1 - IO_INTR = 2 - FREQ_0_1HZ = 3 - FREQ_0_2HZ = 4 - FREQ_0_5HZ = 5 - FREQ_1HZ = 6 - FREQ_2HZ = 7 - FREQ_5HZ = 8 - FREQ_10HZ = 9 - FREQ_100HZ = 10 - - -class MCSSetup(CustomDetectorMixin): - """Setup mixin class for the MCS card""" - - def __init__(self, *args, parent: Device = None, **kwargs) -> None: - super().__init__(*args, parent=parent, **kwargs) - self._lock = threading.RLock() - self._stream_ttl = 1800 - self.acquisition_done = False - self.counter = 0 - self.n_points = 0 - self.mca_names = [ - signal for signal in self.parent.component_names if signal.startswith("mca") - ] - self.mca_data = defaultdict(lambda: []) - - def on_init(self) -> None: - """Init sequence for the detector""" - self.initialize_detector() - self.initialize_detector_backend() - - def initialize_detector(self) -> None: - """Initialize detector""" - # External trigger for pixel advance - self.parent.channel_advance.set(ChannelAdvance.EXTERNAL) - # Use internal clock for channel 1 - self.parent.channel1_source.set(ChannelAdvance.INTERNAL) - self.parent.user_led.set(0) - # Set number of channels to 5 - self.parent.mux_output.set(5) - # Trigger Mode used for cSAXS - self.parent.input_mode.set(TriggerSource.MODE3) - # specify polarity of trigger signals - self.parent.input_polarity.set(0) - self.parent.output_polarity.set(1) - # do not start counting on start - self.parent.count_on_start.set(0) - self.stop_detector() - - def initialize_detector_backend(self) -> None: - """Initialize detector backend""" - for mca in self.mca_names: - signal = getattr(self.parent, mca) - signal.subscribe(self._on_mca_data, run=False) - self.parent.current_channel.subscribe(self._progress_update, run=False) - - def _progress_update(self, value, **kwargs) -> None: - """Progress update on the scan""" - num_lines = self.parent.num_lines.get() - max_value = self.parent.scaninfo.num_points - # self.counter seems to be a deprecated variable from a former implementation of the mcs card - # pylint: disable=protected-access - self.parent._run_subs( - sub_type=self.parent.SUB_PROGRESS, - value=self.counter * int(self.parent.scaninfo.num_points / num_lines) + value, - max_value=max_value, - # TODO check if that is correct with - done=bool(max_value == value), # == self.counter), - ) - - def _on_mca_data(self, *args, obj=None, value=None, **kwargs) -> None: - """Callback function for scan progress""" - with self._lock: - if not isinstance(value, (list, np.ndarray)): - return - self.mca_data[obj.attr_name] = value - if len(self.mca_names) != len(self.mca_data): - return - self.acquisition_done = True - self._send_data_to_bec() - self.mca_data = defaultdict(lambda: []) - - def _send_data_to_bec(self) -> None: - """Sends bundled data to BEC""" - if self.parent.scaninfo.scan_msg is None: - return - metadata = self.parent.scaninfo.scan_msg.metadata - metadata.update({"async_update": "append", "num_lines": self.parent.num_lines.get()}) - msg = messages.DeviceMessage( - signals=dict(self.mca_data), metadata=self.parent.scaninfo.scan_msg.metadata - ) - self.parent.connector.xadd( - topic=MessageEndpoints.device_async_readback( - scan_id=self.parent.scaninfo.scan_id, device=self.parent.name - ), - msg={"data": msg}, - expire=self._stream_ttl, - ) - - def on_stage(self) -> None: - """Stage detector""" - self.prepare_detector() - self.prepare_detector_backend() - - def prepare_detector(self) -> None: - """Prepare detector for scan""" - self.set_acquisition_params() - self.parent.input_mode.set(TriggerSource.MODE3) - - def set_acquisition_params(self) -> None: - """Set acquisition parameters for scan""" - if self.parent.scaninfo.scan_type == "step": - self.n_points = int(self.parent.scaninfo.frames_per_trigger) * int( - self.parent.scaninfo.num_points - ) - elif self.parent.scaninfo.scan_type == "fly": - self.n_points = int(self.parent.scaninfo.num_points) # / int(self.num_lines.get())) - else: - raise MCSError(f"Scantype {self.parent.scaninfo} not implemented for MCS card") - if self.n_points > 10000: - raise MCSError( - f"Requested number of points N={self.n_points} exceeds hardware limit of mcs card" - " 10000 (N-1)" - ) - self.parent.num_use_all.set(self.n_points) - self.parent.preset_real.set(0) - - def prepare_detector_backend(self) -> None: - """Prepare detector backend for scan""" - self.parent.erase_all.set(1) - self.parent.read_mode.set(ReadoutMode.EVENT) - - def arm_acquisition(self) -> None: - """Arm detector for acquisition""" - self.counter = 0 - self.parent.erase_start.set(1) - - def on_unstage(self) -> None: - """Unstage detector""" - pass - - def on_complete(self) -> None: - """Complete detector""" - self.finished(timeout=self.parent.TIMEOUT_FOR_SIGNALS) - - def finished(self, timeout: int = 5) -> None: - """Check if acquisition is finished, if not successful, rais MCSTimeoutError""" - signal_conditions = [ - (lambda: self.acquisition_done, True), - (self.parent.acquiring.get, 0), # Considering making a enum.Int class for this state - ] - if not self.wait_for_signals( - signal_conditions=signal_conditions, - timeout=timeout, - check_stopped=True, - all_signals=True, - ): - total_frames = self.counter * int( - self.parent.scaninfo.num_points / self.parent.num_lines.get() - ) + max(self.parent.current_channel.get(), 0) - raise MCSTimeoutError( - f"Reached timeout with mcs in state {self.parent.acquiring.get()} and" - f" {total_frames} frames arriving at the mcs card" - ) - - def on_stop(self) -> None: - """Stop detector""" - self.stop_detector() - self.stop_detector_backend() - - def stop_detector(self) -> None: - """Stop detector""" - self.parent.stop_all.set(1) - - def stop_detector_backend(self) -> None: - """Stop acquisition of data""" - self.acquisition_done = True - - -class SIS38XX(Device): - """SIS38XX card for access to EPICs PVs at cSAXS beamline""" - - -class MCScSAXS(PSIDetectorBase): - """MCS card for cSAXS for implementation at cSAXS beamline""" - - USER_ACCESS = [] - SUB_PROGRESS = "progress" - SUB_VALUE = "value" - _default_sub = SUB_VALUE - - # specify Setup class - custom_prepare_cls = MCSSetup - # specify minimum readout time for detector - MIN_READOUT = 0 - TIMEOUT_FOR_SIGNALS = 5 - - # PV access to SISS38XX card - # Acquisition - erase_all = Cpt(EpicsSignal, "EraseAll") - erase_start = Cpt(EpicsSignal, "EraseStart") # ,trigger_value=1 - start_all = Cpt(EpicsSignal, "StartAll") - stop_all = Cpt(EpicsSignal, "StopAll") - acquiring = Cpt(EpicsSignal, "Acquiring") - preset_real = Cpt(EpicsSignal, "PresetReal") - elapsed_real = Cpt(EpicsSignal, "ElapsedReal") - read_mode = Cpt(EpicsSignal, "ReadAll.SCAN") - read_all = Cpt(EpicsSignal, "DoReadAll.VAL") # ,trigger_value=1 - num_use_all = Cpt(EpicsSignal, "NuseAll") - current_channel = Cpt(EpicsSignal, "CurrentChannel") - dwell = Cpt(EpicsSignal, "Dwell") - channel_advance = Cpt(EpicsSignal, "ChannelAdvance") - count_on_start = Cpt(EpicsSignal, "CountOnStart") - software_channel_advance = Cpt(EpicsSignal, "SoftwareChannelAdvance") - channel1_source = Cpt(EpicsSignal, "Channel1Source") - prescale = Cpt(EpicsSignal, "Prescale") - enable_client_wait = Cpt(EpicsSignal, "EnableClientWait") - client_wait = Cpt(EpicsSignal, "ClientWait") - acquire_mode = Cpt(EpicsSignal, "AcquireMode") - mux_output = Cpt(EpicsSignal, "MUXOutput") - user_led = Cpt(EpicsSignal, "UserLED") - input_mode = Cpt(EpicsSignal, "InputMode") - input_polarity = Cpt(EpicsSignal, "InputPolarity") - output_mode = Cpt(EpicsSignal, "OutputMode") - output_polarity = Cpt(EpicsSignal, "OutputPolarity") - model = Cpt(EpicsSignalRO, "Model", string=True) - firmware = Cpt(EpicsSignalRO, "Firmware") - max_channels = Cpt(EpicsSignalRO, "MaxChannels") - - # PV access to MCA signals - mca1 = Cpt(EpicsSignalRO, "mca1.VAL", auto_monitor=True) - mca3 = Cpt(EpicsSignalRO, "mca3.VAL", auto_monitor=True) - mca4 = Cpt(EpicsSignalRO, "mca4.VAL", auto_monitor=True) - current_channel = Cpt(EpicsSignalRO, "CurrentChannel", auto_monitor=True) - - # Custom signal readout from device config - num_lines = Cpt( - bec_utils.ConfigSignal, name="num_lines", kind="config", config_storage_name="mcs_config" - ) - - def __init__( - self, - prefix="", - *, - name, - kind=None, - parent=None, - device_manager=None, - mcs_config=None, - **kwargs, - ): - self.mcs_config = {f"{name}_num_lines": 1} - if mcs_config is not None: - # pylint: disable=expression-not-assigned - [self.mcs_config.update({f"{name}_{key}": value}) for key, value in mcs_config.items()] - - super().__init__( - prefix=prefix, - name=name, - kind=kind, - parent=parent, - device_manager=device_manager, - **kwargs, - ) - - -# Automatically connect to test environmenr if directly invoked -if __name__ == "__main__": - mcs = MCScSAXS(name="mcs", prefix="X12SA-MCS:", sim_mode=True) diff --git a/tests/tests_devices/test_delay_generator_csaxs.py b/tests/tests_devices/test_delay_generator_csaxs.py index 0d6e5b4..0817e9f 100644 --- a/tests/tests_devices/test_delay_generator_csaxs.py +++ b/tests/tests_devices/test_delay_generator_csaxs.py @@ -211,10 +211,15 @@ def test_ddg1_trigger(mock_ddg1): mock_ddg1.state.event_status._read_pv.mock_data = ( 5 # STATUSBITS.END_OF_DELAY.value + STATUSBITS.TRIG.value ) - status = mock_ddg1.trigger() - assert status.done is True - assert status.success is True - assert mock_ddg1.trigger_shot.get() == 1 + + with mock.patch.object(mock_ddg1, "device_manager") as mock_device_manager: + # TODO add device manager DMMock, and properly test logic for mcs triggering. + mock_get = mock_device_manager.devices.get = mock.Mock(return_value=None) + status = mock_ddg1.trigger() + assert mock_get.call_args == mock.call("mcs", None) + assert status.done is True + assert status.success is True + assert mock_ddg1.trigger_shot.get() == 1 def test_ddg1_stop(mock_ddg1): -- 2.49.1 From 3fd3d540032b4f9e078508bd759a9aeeba011740 Mon Sep 17 00:00:00 2001 From: appel_c Date: Mon, 21 Jul 2025 22:24:02 +0200 Subject: [PATCH 07/23] refactor(mcs-card): cleanup class, add tests --- .../devices/epics/mcs_card/mcs_card_csaxs.py | 24 +- tests/tests_devices/test_mcs_card.py | 709 +++++++++++------- 2 files changed, 452 insertions(+), 281 deletions(-) diff --git a/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py b/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py index e2423f2..0e32fd1 100644 --- a/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py +++ b/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py @@ -5,8 +5,8 @@ from __future__ import annotations import enum from threading import RLock from typing import TYPE_CHECKING -import numpy as np +import numpy as np from bec_lib.logger import bec_logger from ophyd import Component as Cpt from ophyd import Device, EpicsSignalRO, Kind, Signal @@ -120,7 +120,7 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): super().__init__( name=name, prefix=prefix, scan_info=scan_info, device_manager=device_manager, **kwargs ) - self._mcs_clock = 1e-7 # 10MHz clock + self._mcs_clock = 1e7 # 10MHz clock -> 1e7 Hz self._pv_timeout = 2 self._rlock = RLock() self.counter_mapping = { @@ -139,8 +139,9 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): # Make sure card is not running self.stop_all.put(1) + # TODO Check channel1_source !! self.channel_advance.set(CHANNELADVANCE.EXTERNAL).wait(timeout=self._pv_timeout) - self.channel_advance.set(CHANNEL1SOURCE.EXTERNAL).wait(timeout=self._pv_timeout) + self.channel1_source.set(CHANNEL1SOURCE.EXTERNAL).wait(timeout=self._pv_timeout) self.prescale.set(1).wait(timeout=self._pv_timeout) # Set the user LED to off self.user_led.set(0).wait(timeout=self._pv_timeout) @@ -156,15 +157,15 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): # Set appropriate read mode self.read_mode.set(READMODE.PASSIVE).wait(timeout=self._pv_timeout) - # Subscribe the progress signal - self.current_channel.subscribe(self._progress_update, run=False) - # Set the acquire mode self.acquire_mode.set(ACQUIREMODE.MCS).wait(timeout=self._pv_timeout) + # Subscribe the progress signal + self.current_channel.subscribe(self._progress_update, run=False) + # Subscribe to the mca updates for name in self.counter_mapping.keys(): - sig: EpicsSignalRO = getattr(self.counters, name.split('_')[-1]) + sig: EpicsSignalRO = getattr(self.counters, name.split("_")[-1]) sig.subscribe(self._on_counter_update, run=False) def _on_counter_update(self, value, **kwargs) -> None: @@ -178,18 +179,18 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): return mca_raw = getattr(self.mcs, signal.name.split("_")[-1], None) if mca_raw is None: - return + return logger.info(f"Received update of type {type(value)} for {signal.name}") if isinstance(value, np.ndarray): mca_raw.put(value.tolist()) if mapped_signal_name == "count_time": - value = value*self._mcs_clock + value = value / self._mcs_clock value = float(value.mean()) else: mca_raw.put(value) if mapped_signal_name == "count_time": - value = value*self._mcs_clock - + value = value / self._mcs_clock + # Mean signal for burst acquisition sig = getattr(self.bpm, mapped_signal_name) sig.put(value) @@ -223,6 +224,7 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): """ self.stop_all.put(1) self.ready_to_read.put(READYTOREAD.DONE) + # TODO why 0? self.erase_all.set(0).wait(timeout=self._pv_timeout) def on_trigger(self) -> None: diff --git a/tests/tests_devices/test_mcs_card.py b/tests/tests_devices/test_mcs_card.py index f9163d4..19447c3 100644 --- a/tests/tests_devices/test_mcs_card.py +++ b/tests/tests_devices/test_mcs_card.py @@ -2,311 +2,480 @@ import threading from unittest import mock +import numpy as np import ophyd import pytest from bec_lib import messages from bec_lib.endpoints import MessageEndpoints from bec_server.device_server.tests.utils import DMMock -from ophyd_devices.tests.utils import MockPV +from ophyd_devices.tests.utils import MockPV, patch_dual_pvs -from csaxs_bec.devices.epics.mcs_csaxs import ( - MCScSAXS, - MCSError, - MCSTimeoutError, - ReadoutMode, - TriggerSource, +from csaxs_bec.devices.epics.mcs_card.mcs_card import ( + ACQUIREMODE, + ACQUIRING, + CHANNEL1SOURCE, + CHANNELADVANCE, + INPUTMODE, + OUTPUTMODE, + POLARITY, + READMODE, + MCSCard, ) -from csaxs_bec.devices.tests_utils.utils import patch_dual_pvs +from csaxs_bec.devices.epics.mcs_card.mcs_card_csaxs import READYTOREAD, MCSCardCSAXS @pytest.fixture(scope="function") -def mock_det(): - name = "mcs" +def mock_mcs_card(): + """Fixture to mock the MCSCard device.""" + name = "mcs_card" prefix = "X12SA-MCS:" + with mock.patch.object(ophyd, "cl") as mock_cl: + mock_cl.get_pv = MockPV + mock_cl.thread_class = threading + mcs_card = MCSCard(name=name, prefix=prefix) + patch_dual_pvs(mcs_card) + yield mcs_card + + +def test_mcs_card(mock_mcs_card): + """Test the MCSCard initialization.""" + assert mock_mcs_card.name == "mcs_card" + assert mock_mcs_card.prefix == "X12SA-MCS:" + assert len(mock_mcs_card.counters.component_names) == 32 + assert mock_mcs_card.counters.mca1.name == "mcs_card_counters_mca1" + + +@pytest.fixture(scope="function") +def mock_mcs_csaxs(): + """Fixture to mock the MCSCardCSAXS device.""" + name = "mcs_csaxs" + prefix = "X12SA-MCS-CSAXS:" dm = DMMock() - with mock.patch.object(dm, "connector"): - with ( - mock.patch( - "ophyd_devices.interfaces.base_classes.bec_device_base.FileWriter" - ) as filemixin, - mock.patch( - "ophyd_devices.interfaces.base_classes.psi_detector_base.PSIDetectorBase._update_service_config" - ) as mock_service_config, - ): - with mock.patch.object(ophyd, "cl") as mock_cl: - mock_cl.get_pv = MockPV - mock_cl.thread_class = threading.Thread - with mock.patch.object(MCScSAXS, "_init"): - det = MCScSAXS(name=name, prefix=prefix, device_manager=dm) - patch_dual_pvs(det) - det.TIMEOUT_FOR_SIGNALS = 0.1 - yield det + with mock.patch.object(ophyd, "cl") as mock_cl: + mock_cl.get_pv = MockPV + mock_cl.thread_class = threading.Thread + mcs_card_csaxs = MCSCardCSAXS(name=name, prefix=prefix, device_manager=dm) + patch_dual_pvs(mcs_card_csaxs) + yield mcs_card_csaxs -def test_init(): - """Test the _init function:""" - name = "eiger" - prefix = "X12SA-ES-EIGER9M:" - dm = DMMock() - with mock.patch.object(dm, "connector"): - with ( - mock.patch("ophyd_devices.interfaces.base_classes.bec_device_base.FileWriter"), - mock.patch( - "ophyd_devices.interfaces.base_classes.psi_detector_base.PSIDetectorBase._update_service_config" - ), - ): - with mock.patch.object(ophyd, "cl") as mock_cl: - mock_cl.get_pv = MockPV - with ( - mock.patch( - "csaxs_bec.devices.epics.mcs_csaxs.MCSSetup.initialize_detector" - ) as mock_init_det, - mock.patch( - "csaxs_bec.devices.epics.mcs_csaxs.MCSSetup.initialize_detector_backend" - ) as mock_init_backend, - ): - MCScSAXS(name=name, prefix=prefix, device_manager=dm) - mock_init_det.assert_called_once() - mock_init_backend.assert_called_once() +def test_mcs_card_csaxs(mock_mcs_csaxs): + """Test the MCSCardCSAXS initialization.""" + assert mock_mcs_csaxs.name == "mcs_csaxs" + assert mock_mcs_csaxs.prefix == "X12SA-MCS-CSAXS:" + assert mock_mcs_csaxs.counter_mapping == { + "mcs_csaxs_counters_mca1": "current1", + "mcs_csaxs_counters_mca2": "current2", + "mcs_csaxs_counters_mca3": "current3", + "mcs_csaxs_counters_mca4": "current4", + "mcs_csaxs_counters_mca5": "count_time", + } + assert mock_mcs_csaxs._mcs_clock == 1e7 # 10 MHz -@pytest.mark.parametrize( - "trigger_source, channel_advance, channel_source1, pv_channels", - [ - ( - 3, - 1, - 0, - { - "user_led": 0, - "mux_output": 5, - "input_pol": 0, - "output_pol": 1, - "count_on_start": 0, - "stop_all": 1, - }, - ) - ], -) -def test_initialize_detector( - mock_det, trigger_source, channel_advance, channel_source1, pv_channels -): - """Test the _init function: +def test_mcs_card_csaxs_on_connected(mock_mcs_csaxs): + """Test the on_connected method of MCSCardCSAXS.""" + mcs = mock_mcs_csaxs + mcs.on_connected() + # Stop called + assert mcs.stop_all.get() == 1 + # Channel advance settings + assert mcs.channel_advance.get() == CHANNELADVANCE.EXTERNAL + assert mcs.channel1_source.get() == CHANNEL1SOURCE.EXTERNAL + assert mcs.prescale.get() == 1 + # + assert mcs.user_led.get() == 0 + # Only 5 channels are connected + assert mcs.mux_output.get() == 5 + # input output settings + assert mcs.input_mode.get() == INPUTMODE.MODE_3 + assert mcs.input_polarity.get() == POLARITY.NORMAL + assert mcs.output_mode.get() == OUTPUTMODE.MODE_2 + assert mcs.output_polarity.get() == POLARITY.NORMAL + assert mcs.count_on_start.get() == 0 + assert mcs.read_mode.get() == READMODE.PASSIVE + assert mcs.acquire_mode.get() == ACQUIREMODE.MCS - This includes testing the functions: - - initialize_detector - - stop_det - - parent.set_trigger - --> Testing the filewriter is done in test_init_filewriter - - Validation upon setting the correct PVs - - """ - mock_det.custom_prepare.initialize_detector() # call the method you want to test - assert mock_det.channel_advance.get() == channel_advance - assert mock_det.channel1_source.get() == channel_source1 - assert mock_det.user_led.get() == pv_channels["user_led"] - assert mock_det.mux_output.get() == pv_channels["mux_output"] - assert mock_det.input_polarity.get() == pv_channels["input_pol"] - assert mock_det.output_polarity.get() == pv_channels["output_pol"] - assert mock_det.count_on_start.get() == pv_channels["count_on_start"] - assert mock_det.input_mode.get() == trigger_source + with mock.patch.object(mcs.current_channel, "subscribe") as mock_cur_ch_subscribe: + with mock.patch.object(mcs.counters.mca1, "subscribe") as mock_mca_subscribe: + mcs.on_connected() + assert mock_cur_ch_subscribe.call_args == mock.call(mcs._progress_update, run=False) + assert mock_mca_subscribe.call_args == mock.call(mcs._on_counter_update, run=False) -def test_trigger(mock_det): - """Test the trigger function: - Validate that trigger calls the custom_prepare.on_trigger() function - """ - with mock.patch.object(mock_det.custom_prepare, "on_trigger") as mock_on_trigger: - mock_det.trigger() - mock_on_trigger.assert_called_once() +def test_mcs_card_csaxs_stage(mock_mcs_csaxs): + """Test on stage method of MCSCardCSAXS""" + mcs = mock_mcs_csaxs + triggers = 5 + mcs.scan_info.msg.scan_parameters["frames_per_trigger"] = triggers + mcs.erase_all.put(0) + mcs.stage() + assert mcs._staged == ophyd.Staged.yes + assert mcs.erase_all.get() == 1 + assert mcs.preset_real.get() == 0 + assert mcs.num_use_all.get() == triggers -@pytest.mark.parametrize( - "value, num_lines, num_points, done", [(100, 5, 500, False), (500, 5, 500, True)] -) -def test_progress_update(mock_det, value, num_lines, num_points, done): - mock_det.num_lines.set(num_lines) - mock_det.scaninfo.num_points = num_points - calls = mock.call(sub_type="progress", value=value, max_value=num_points, done=done) - with mock.patch.object(mock_det, "_run_subs") as mock_run_subs: - mock_det.custom_prepare._progress_update(value=value) - mock_run_subs.assert_called_once() - assert mock_run_subs.call_args == calls +def test_mcs_card_csaxs_unstage(mock_mcs_csaxs): + """Test unstage method of MCSCardCSAXS""" + mcs = mock_mcs_csaxs + mcs.stop_all.put(0) + mcs.ready_to_read.put(0) + mcs.erase_all.put(1) + mcs.unstage() + assert mcs.stop_all.get() == 1 + assert mcs.ready_to_read.get() == READYTOREAD.DONE + assert mcs.erase_all.get() == 0 -@pytest.mark.parametrize( - "values, expected_nothing", - [([[100, 120, 140], [200, 220, 240], [300, 320, 340]], False), ([100, 200, 300], True)], -) -def test_on_mca_data(mock_det, values, expected_nothing): - """Test the on_mca_data function: - Validate that on_mca_data calls the custom_prepare.on_mca_data() function - """ - with mock.patch.object(mock_det.custom_prepare, "_send_data_to_bec") as mock_send_data: - mock_object = mock.MagicMock() - for ii, name in enumerate(mock_det.custom_prepare.mca_names): - mock_object.attr_name = name - mock_det.custom_prepare._on_mca_data(obj=mock_object, value=values[ii]) - if not expected_nothing and ii < (len(values) - 1): - assert mock_det.custom_prepare.mca_data[name] == values[ii] - - if not expected_nothing: - mock_send_data.assert_called_once() - assert mock_det.custom_prepare.acquisition_done is True +def test_mcs_card_csaxs_complete_and_stop(mock_mcs_csaxs): + """Test complete method of MCSCarcCSAXS""" + mcs = mock_mcs_csaxs + mcs.acquiring._read_pv.mock_data = ACQUIRING.ACQUIRING + st = mcs.complete() + assert st.done is False + mcs.stop_all.put(0) + mcs.ready_to_read.put(READYTOREAD.PROCESSING) + mcs.stop() + with pytest.raises(Exception): + st.wait(timeout=3) + assert st.done is True + assert st.success is False + assert mcs.stop_all.get() == 1 + assert mcs.ready_to_read.get() == READYTOREAD.DONE -@pytest.mark.parametrize( - "metadata, mca_data", - [ - ( - {"scan_id": 123}, - { - "mca1": {"value": [100, 120, 140]}, - "mca3": {"value": [200, 220, 240]}, - "mca4": {"value": [300, 320, 340]}, - }, - ) - ], -) -def test_send_data_to_bec(mock_det, metadata, mca_data): - mock_det.scaninfo.scan_msg = mock.MagicMock() - mock_det.scaninfo.scan_msg.metadata = metadata - mock_det.scaninfo.scan_id = metadata["scan_id"] - mock_det.custom_prepare.mca_data = mca_data - mock_det.custom_prepare._send_data_to_bec() - device_metadata = mock_det.scaninfo.scan_msg.metadata - metadata.update({"async_update": "append", "num_lines": mock_det.num_lines.get()}) - data = messages.DeviceMessage(signals=dict(mca_data), metadata=device_metadata) - calls = mock.call( - topic=MessageEndpoints.device_async_readback( - scan_id=metadata["scan_id"], device=mock_det.name - ), - msg={"data": data}, - expire=1800, - ) - - assert mock_det.connector.xadd.call_args == calls +def test_mcs_card_csaxs_on_counter_updated(mock_mcs_csaxs): + mcs = mock_mcs_csaxs + # Called for mca1 + kwargs = {"obj": mcs.counters.mca1} + mcs._on_counter_update(1, **kwargs) + assert mcs.mcs.mca1.get() == 1 + assert mcs.bpm.current1.get() == 1 + assert mcs.counter_updated == [mcs.counters.mca1.name] + # Called for mca2 + kwargs = {"obj": mcs.counters.mca2} + mcs._on_counter_update(np.array([2, 4]), **kwargs) + assert mcs.mcs.mca2.get() == [2, 4] + assert np.isclose(mcs.bpm.current2.get(), 3) + assert mcs.counter_updated == [mcs.counters.mca1.name, mcs.counters.mca2.name] + # Called for mca3 + kwargs = {"obj": mcs.counters.mca3} + mcs._on_counter_update(1000, **kwargs) + assert mcs.mcs.mca3.get() == 1000 + assert mcs.bpm.current3.get() == 1000 + assert mcs.counter_updated == [ + mcs.counters.mca1.name, + mcs.counters.mca2.name, + mcs.counters.mca3.name, + ] + # Called for mca4 + kwargs = {"obj": mcs.counters.mca4} + mcs._on_counter_update(np.array([20, 40]), **kwargs) + assert mcs.mcs.mca4.get() == [20, 40] + assert np.isclose(mcs.bpm.current4.get(), 30) + assert mcs.counter_updated == [ + mcs.counters.mca1.name, + mcs.counters.mca2.name, + mcs.counters.mca3.name, + mcs.counters.mca4.name, + ] + # Called for mca5 + assert mcs.ready_to_read.get() == 0 + kwargs = {"obj": mcs.counters.mca5} + mcs._on_counter_update(np.array([10000, 10000]), **kwargs) + assert np.isclose(mcs.bpm.count_time.get(), 10000 / 1e7) + assert mcs.mcs.mca5.get() == [10000, 10000] -@pytest.mark.parametrize( - "scaninfo, triggersource, stopped, expected_exception", - [ - ( - {"num_points": 500, "frames_per_trigger": 1, "scan_type": "step"}, - TriggerSource.MODE3, - False, - False, - ), - ( - {"num_points": 500, "frames_per_trigger": 1, "scan_type": "fly"}, - TriggerSource.MODE3, - False, - False, - ), - ( - {"num_points": 5001, "frames_per_trigger": 2, "scan_type": "step"}, - TriggerSource.MODE3, - False, - True, - ), - ( - {"num_points": 500, "frames_per_trigger": 2, "scan_type": "random"}, - TriggerSource.MODE3, - False, - True, - ), - ], -) -def test_stage(mock_det, scaninfo, triggersource, stopped, expected_exception): - mock_det.scaninfo.num_points = scaninfo["num_points"] - mock_det.scaninfo.frames_per_trigger = scaninfo["frames_per_trigger"] - mock_det.scaninfo.scan_type = scaninfo["scan_type"] - mock_det.stopped = stopped - with mock.patch.object(mock_det.custom_prepare, "prepare_detector_backend") as mock_prep_fw: - if expected_exception: - with pytest.raises(MCSError): - mock_det.stage() - mock_prep_fw.assert_called_once() - else: - mock_det.stage() - mock_prep_fw.assert_called_once() - # Check set_trigger - mock_det.input_mode.get() == triggersource - if scaninfo["scan_type"] == "step": - assert mock_det.num_use_all.get() == int(scaninfo["frames_per_trigger"]) * int( - scaninfo["num_points"] - ) - elif scaninfo["scan_type"] == "fly": - assert mock_det.num_use_all.get() == int(scaninfo["num_points"]) - mock_det.preset_real.get() == 0 - - # # CHeck custom_prepare.arm_acquisition - # assert mock_det.custom_prepare.counter == 0 - # assert mock_det.erase_start.get() == 1 - # mock_prep_fw.assert_called_once() - # # Check _prep_det - # assert mock_det.cam.num_images.get() == int( - # scaninfo["num_points"] * scaninfo["frames_per_trigger"] - # ) - # assert mock_det.cam.num_frames.get() == 1 - - # mock_publish_file_location.assert_called_with(done=False) - # assert mock_det.cam.acquire.get() == 1 +# @pytest.fixture(scope="function") +# def mock_det(): +# name = "mcs" +# prefix = "X12SA-MCS:" +# dm = DMMock() +# with mock.patch.object(dm, "connector"): +# with ( +# mock.patch( +# "ophyd_devices.interfaces.base_classes.bec_device_base.FileWriter" +# ) as filemixin, +# mock.patch( +# "ophyd_devices.interfaces.base_classes.psi_detector_base.PSIDetectorBase._update_service_config" +# ) as mock_service_config, +# ): +# with mock.patch.object(ophyd, "cl") as mock_cl: +# mock_cl.get_pv = MockPV +# mock_cl.thread_class = threading.Thread +# with mock.patch.object(MCScSAXS, "_init"): +# det = MCScSAXS(name=name, prefix=prefix, device_manager=dm) +# patch_dual_pvs(det) +# det.TIMEOUT_FOR_SIGNALS = 0.1 +# yield det -def test_prepare_detector_backend(mock_det): - mock_det.custom_prepare.prepare_detector_backend() - assert mock_det.erase_all.get() == 1 - assert mock_det.read_mode.get() == ReadoutMode.EVENT +# def test_init(): +# """Test the _init function:""" +# name = "eiger" +# prefix = "X12SA-ES-EIGER9M:" +# dm = DMMock() +# with mock.patch.object(dm, "connector"): +# with ( +# mock.patch("ophyd_devices.interfaces.base_classes.bec_device_base.FileWriter"), +# mock.patch( +# "ophyd_devices.interfaces.base_classes.psi_detector_base.PSIDetectorBase._update_service_config" +# ), +# ): +# with mock.patch.object(ophyd, "cl") as mock_cl: +# mock_cl.get_pv = MockPV +# with ( +# mock.patch( +# "csaxs_bec.devices.epics.mcs_csaxs.MCSSetup.initialize_detector" +# ) as mock_init_det, +# mock.patch( +# "csaxs_bec.devices.epics.mcs_csaxs.MCSSetup.initialize_detector_backend" +# ) as mock_init_backend, +# ): +# MCScSAXS(name=name, prefix=prefix, device_manager=dm) +# mock_init_det.assert_called_once() +# mock_init_backend.assert_called_once() -def test_complete(mock_det): - with (mock.patch.object(mock_det.custom_prepare, "finished") as mock_finished,): - mock_det.complete() - assert mock_finished.call_count == 1 +# @pytest.mark.parametrize( +# "trigger_source, channel_advance, channel_source1, pv_channels", +# [ +# ( +# 3, +# 1, +# 0, +# { +# "user_led": 0, +# "mux_output": 5, +# "input_pol": 0, +# "output_pol": 1, +# "count_on_start": 0, +# "stop_all": 1, +# }, +# ) +# ], +# ) +# def test_initialize_detector( +# mock_det, trigger_source, channel_advance, channel_source1, pv_channels +# ): +# """Test the _init function: + +# This includes testing the functions: +# - initialize_detector +# - stop_det +# - parent.set_trigger +# --> Testing the filewriter is done in test_init_filewriter + +# Validation upon setting the correct PVs + +# """ +# mock_det.custom_prepare.initialize_detector() # call the method you want to test +# assert mock_det.channel_advance.get() == channel_advance +# assert mock_det.channel1_source.get() == channel_source1 +# assert mock_det.user_led.get() == pv_channels["user_led"] +# assert mock_det.mux_output.get() == pv_channels["mux_output"] +# assert mock_det.input_polarity.get() == pv_channels["input_pol"] +# assert mock_det.output_polarity.get() == pv_channels["output_pol"] +# assert mock_det.count_on_start.get() == pv_channels["count_on_start"] +# assert mock_det.input_mode.get() == trigger_source -def test_stop_detector_backend(mock_det): - mock_det.custom_prepare.stop_detector_backend() - assert mock_det.custom_prepare.acquisition_done is True +# def test_trigger(mock_det): +# """Test the trigger function: +# Validate that trigger calls the custom_prepare.on_trigger() function +# """ +# with mock.patch.object(mock_det.custom_prepare, "on_trigger") as mock_on_trigger: +# mock_det.trigger() +# mock_on_trigger.assert_called_once() -def test_stop(mock_det): - with ( - mock.patch.object(mock_det.custom_prepare, "stop_detector") as mock_stop_det, - mock.patch.object( - mock_det.custom_prepare, "stop_detector_backend" - ) as mock_stop_detector_backend, - ): - mock_det.stop() - mock_stop_det.assert_called_once() - mock_stop_detector_backend.assert_called_once() - assert mock_det.stopped is True +# @pytest.mark.parametrize( +# "value, num_lines, num_points, done", [(100, 5, 500, False), (500, 5, 500, True)] +# ) +# def test_progress_update(mock_det, value, num_lines, num_points, done): +# mock_det.num_lines.set(num_lines) +# mock_det.scaninfo.num_points = num_points +# calls = mock.call(sub_type="progress", value=value, max_value=num_points, done=done) +# with mock.patch.object(mock_det, "_run_subs") as mock_run_subs: +# mock_det.custom_prepare._progress_update(value=value) +# mock_run_subs.assert_called_once() +# assert mock_run_subs.call_args == calls -@pytest.mark.parametrize( - "stopped, acquisition_done, acquiring_state, expected_exception", - [ - (False, True, 0, False), - (False, False, 0, True), - (False, True, 1, True), - (True, True, 0, True), - ], -) -def test_finished(mock_det, stopped, acquisition_done, acquiring_state, expected_exception): - mock_det.custom_prepare.acquisition_done = acquisition_done - mock_det.acquiring._read_pv.mock_data = acquiring_state - mock_det.scaninfo.num_points = 500 - mock_det.num_lines.put(500) - mock_det.current_channel._read_pv.mock_data = 1 - mock_det.stopped = stopped +# @pytest.mark.parametrize( +# "values, expected_nothing", +# [([[100, 120, 140], [200, 220, 240], [300, 320, 340]], False), ([100, 200, 300], True)], +# ) +# def test_on_mca_data(mock_det, values, expected_nothing): +# """Test the on_mca_data function: +# Validate that on_mca_data calls the custom_prepare.on_mca_data() function +# """ +# with mock.patch.object(mock_det.custom_prepare, "_send_data_to_bec") as mock_send_data: +# mock_object = mock.MagicMock() +# for ii, name in enumerate(mock_det.custom_prepare.mca_names): +# mock_object.attr_name = name +# mock_det.custom_prepare._on_mca_data(obj=mock_object, value=values[ii]) +# if not expected_nothing and ii < (len(values) - 1): +# assert mock_det.custom_prepare.mca_data[name] == values[ii] - if expected_exception: - with pytest.raises(MCSTimeoutError): - mock_det.timeout = 0.1 - mock_det.custom_prepare.finished() - else: - mock_det.custom_prepare.finished() - if stopped: - assert mock_det.stopped is stopped +# if not expected_nothing: +# mock_send_data.assert_called_once() +# assert mock_det.custom_prepare.acquisition_done is True + + +# @pytest.mark.parametrize( +# "metadata, mca_data", +# [ +# ( +# {"scan_id": 123}, +# { +# "mca1": {"value": [100, 120, 140]}, +# "mca3": {"value": [200, 220, 240]}, +# "mca4": {"value": [300, 320, 340]}, +# }, +# ) +# ], +# ) +# def test_send_data_to_bec(mock_det, metadata, mca_data): +# mock_det.scaninfo.scan_msg = mock.MagicMock() +# mock_det.scaninfo.scan_msg.metadata = metadata +# mock_det.scaninfo.scan_id = metadata["scan_id"] +# mock_det.custom_prepare.mca_data = mca_data +# mock_det.custom_prepare._send_data_to_bec() +# device_metadata = mock_det.scaninfo.scan_msg.metadata +# metadata.update({"async_update": "append", "num_lines": mock_det.num_lines.get()}) +# data = messages.DeviceMessage(signals=dict(mca_data), metadata=device_metadata) +# calls = mock.call( +# topic=MessageEndpoints.device_async_readback( +# scan_id=metadata["scan_id"], device=mock_det.name +# ), +# msg={"data": data}, +# expire=1800, +# ) + +# assert mock_det.connector.xadd.call_args == calls + + +# @pytest.mark.parametrize( +# "scaninfo, triggersource, stopped, expected_exception", +# [ +# ( +# {"num_points": 500, "frames_per_trigger": 1, "scan_type": "step"}, +# TriggerSource.MODE3, +# False, +# False, +# ), +# ( +# {"num_points": 500, "frames_per_trigger": 1, "scan_type": "fly"}, +# TriggerSource.MODE3, +# False, +# False, +# ), +# ( +# {"num_points": 5001, "frames_per_trigger": 2, "scan_type": "step"}, +# TriggerSource.MODE3, +# False, +# True, +# ), +# ( +# {"num_points": 500, "frames_per_trigger": 2, "scan_type": "random"}, +# TriggerSource.MODE3, +# False, +# True, +# ), +# ], +# ) +# def test_stage(mock_det, scaninfo, triggersource, stopped, expected_exception): +# mock_det.scaninfo.num_points = scaninfo["num_points"] +# mock_det.scaninfo.frames_per_trigger = scaninfo["frames_per_trigger"] +# mock_det.scaninfo.scan_type = scaninfo["scan_type"] +# mock_det.stopped = stopped +# with mock.patch.object(mock_det.custom_prepare, "prepare_detector_backend") as mock_prep_fw: +# if expected_exception: +# with pytest.raises(MCSError): +# mock_det.stage() +# mock_prep_fw.assert_called_once() +# else: +# mock_det.stage() +# mock_prep_fw.assert_called_once() +# # Check set_trigger +# mock_det.input_mode.get() == triggersource +# if scaninfo["scan_type"] == "step": +# assert mock_det.num_use_all.get() == int(scaninfo["frames_per_trigger"]) * int( +# scaninfo["num_points"] +# ) +# elif scaninfo["scan_type"] == "fly": +# assert mock_det.num_use_all.get() == int(scaninfo["num_points"]) +# mock_det.preset_real.get() == 0 + +# # # CHeck custom_prepare.arm_acquisition +# # assert mock_det.custom_prepare.counter == 0 +# # assert mock_det.erase_start.get() == 1 +# # mock_prep_fw.assert_called_once() +# # # Check _prep_det +# # assert mock_det.cam.num_images.get() == int( +# # scaninfo["num_points"] * scaninfo["frames_per_trigger"] +# # ) +# # assert mock_det.cam.num_frames.get() == 1 + +# # mock_publish_file_location.assert_called_with(done=False) +# # assert mock_det.cam.acquire.get() == 1 + + +# def test_prepare_detector_backend(mock_det): +# mock_det.custom_prepare.prepare_detector_backend() +# assert mock_det.erase_all.get() == 1 +# assert mock_det.read_mode.get() == ReadoutMode.EVENT + + +# def test_complete(mock_det): +# with (mock.patch.object(mock_det.custom_prepare, "finished") as mock_finished,): +# mock_det.complete() +# assert mock_finished.call_count == 1 + + +# def test_stop_detector_backend(mock_det): +# mock_det.custom_prepare.stop_detector_backend() +# assert mock_det.custom_prepare.acquisition_done is True + + +# def test_stop(mock_det): +# with ( +# mock.patch.object(mock_det.custom_prepare, "stop_detector") as mock_stop_det, +# mock.patch.object( +# mock_det.custom_prepare, "stop_detector_backend" +# ) as mock_stop_detector_backend, +# ): +# mock_det.stop() +# mock_stop_det.assert_called_once() +# mock_stop_detector_backend.assert_called_once() +# assert mock_det.stopped is True + + +# @pytest.mark.parametrize( +# "stopped, acquisition_done, acquiring_state, expected_exception", +# [ +# (False, True, 0, False), +# (False, False, 0, True), +# (False, True, 1, True), +# (True, True, 0, True), +# ], +# ) +# def test_finished(mock_det, stopped, acquisition_done, acquiring_state, expected_exception): +# mock_det.custom_prepare.acquisition_done = acquisition_done +# mock_det.acquiring._read_pv.mock_data = acquiring_state +# mock_det.scaninfo.num_points = 500 +# mock_det.num_lines.put(500) +# mock_det.current_channel._read_pv.mock_data = 1 +# mock_det.stopped = stopped + +# if expected_exception: +# with pytest.raises(MCSTimeoutError): +# mock_det.timeout = 0.1 +# mock_det.custom_prepare.finished() +# else: +# mock_det.custom_prepare.finished() +# if stopped: +# assert mock_det.stopped is stopped -- 2.49.1 From 14713c0d2f742eca69c2fa4bef7db24afe4028a5 Mon Sep 17 00:00:00 2001 From: appel_c Date: Mon, 21 Jul 2025 22:28:55 +0200 Subject: [PATCH 08/23] fix: remove old mcs card from config --- .../bec_device_config_sastt.yaml | 28 +++++++++---------- .../devices/epics/mcs_card/mcs_card_csaxs.py | 24 +++++++--------- 2 files changed, 24 insertions(+), 28 deletions(-) diff --git a/csaxs_bec/device_configs/bec_device_config_sastt.yaml b/csaxs_bec/device_configs/bec_device_config_sastt.yaml index 11784f1..cb75637 100644 --- a/csaxs_bec/device_configs/bec_device_config_sastt.yaml +++ b/csaxs_bec/device_configs/bec_device_config_sastt.yaml @@ -27,20 +27,20 @@ mokev: onFailure: buffer readoutPriority: baseline softwareTrigger: false -mcs: - description: Mcs scalar card for transmission readout - deviceClass: csaxs_bec.devices.epics.mcs_csaxs.MCScSAXS - deviceConfig: - prefix: 'X12SA-MCS:' - mcs_config: - num_lines: 1 - deviceTags: - - cSAXS - - mcs - onFailure: buffer - enabled: true - readoutPriority: monitored - softwareTrigger: false +# mcs: +# description: Mcs scalar card for transmission readout +# deviceClass: csaxs_bec.devices.epics.mcs_csaxs.MCScSAXS +# deviceConfig: +# prefix: 'X12SA-MCS:' +# mcs_config: +# num_lines: 1 +# deviceTags: +# - cSAXS +# - mcs +# onFailure: buffer +# enabled: true +# readoutPriority: monitored +# softwareTrigger: false eiger9m: description: Eiger9m HPC area detector 9M deviceClass: csaxs_bec.devices.epics.eiger9m_csaxs.Eiger9McSAXS diff --git a/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py b/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py index 0e32fd1..166a48e 100644 --- a/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py +++ b/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py @@ -41,13 +41,11 @@ class READYTOREAD(int, enum.Enum): class BPMDevice(Device): """Class for BPM device of the MCSCard.""" - current1 = Cpt(Signal, name="current1", kind=Kind.normal, doc="Normalized current 1") - current2 = Cpt(Signal, name="current2", kind=Kind.normal, doc="Normalized current 2") - current3 = Cpt(Signal, name="current3", kind=Kind.normal, doc="Normalized current 3") - current4 = Cpt(Signal, name="current4", kind=Kind.normal, doc="Normalized current 4") - count_time = Cpt( - Signal, name="count_time", kind=Kind.normal, doc="Count time for bpm signal counts" - ) + current1 = Cpt(Signal, kind=Kind.normal, doc="Normalized current 1") + current2 = Cpt(Signal, kind=Kind.normal, doc="Normalized current 2") + current3 = Cpt(Signal, kind=Kind.normal, doc="Normalized current 3") + current4 = Cpt(Signal, kind=Kind.normal, doc="Normalized current 4") + count_time = Cpt(Signal, kind=Kind.normal, doc="Count time for bpm signal counts") sum = Cpt(SumSignal, kind="hinted", doc="Sum of all currents") x = Cpt( DiffXYSignal, @@ -72,11 +70,11 @@ class BPMDevice(Device): class MCSRaw(Device): """Class for BPM device of the MCSCard with normalized currents.""" - mca1 = Cpt(Signal, name="current1", kind=Kind.normal, doc="Raw counts on mca1 channel") - mca2 = Cpt(Signal, name="current2", kind=Kind.normal, doc="Raw counts on mca2 channel") - mca3 = Cpt(Signal, name="current3", kind=Kind.normal, doc="Raw counts on mca3 channel") - mca4 = Cpt(Signal, name="current4", kind=Kind.normal, doc="Raw counts on mca4 channel") - mca5 = Cpt(Signal, name="current5", kind=Kind.normal, doc="Raw counts on mca5 channel") + mca1 = Cpt(Signal, kind=Kind.normal, doc="Raw counts on mca1 channel") + mca2 = Cpt(Signal, kind=Kind.normal, doc="Raw counts on mca2 channel") + mca3 = Cpt(Signal, kind=Kind.normal, doc="Raw counts on mca3 channel") + mca4 = Cpt(Signal, kind=Kind.normal, doc="Raw counts on mca4 channel") + mca5 = Cpt(Signal, kind=Kind.normal, doc="Raw counts on mca5 channel") class MCSCardCSAXS(PSIDeviceBase, MCSCard): @@ -87,7 +85,6 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): ready_to_read = Cpt( Signal, - name="ready_to_read", kind=Kind.omitted, doc="Signal that indicates if mcs card is ready to be read from after triggers. 0 not ready, 1 ready", ) @@ -180,7 +177,6 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): mca_raw = getattr(self.mcs, signal.name.split("_")[-1], None) if mca_raw is None: return - logger.info(f"Received update of type {type(value)} for {signal.name}") if isinstance(value, np.ndarray): mca_raw.put(value.tolist()) if mapped_signal_name == "count_time": -- 2.49.1 From e6d7e0d82ff77f6b89bae31290bc4f3f7ee2db65 Mon Sep 17 00:00:00 2001 From: gac-x12sa Date: Tue, 22 Jul 2025 10:58:42 +0200 Subject: [PATCH 09/23] refactor: rename ddg test config to endstation, add to first light --- .../{ddg_test.yaml => endstation.yaml} | 20 ------------------- csaxs_bec/device_configs/first_light.yaml | 5 ++++- 2 files changed, 4 insertions(+), 21 deletions(-) rename csaxs_bec/device_configs/{ddg_test.yaml => endstation.yaml} (70%) diff --git a/csaxs_bec/device_configs/ddg_test.yaml b/csaxs_bec/device_configs/endstation.yaml similarity index 70% rename from csaxs_bec/device_configs/ddg_test.yaml rename to csaxs_bec/device_configs/endstation.yaml index a08fdbd..56a3d10 100644 --- a/csaxs_bec/device_configs/ddg_test.yaml +++ b/csaxs_bec/device_configs/endstation.yaml @@ -20,26 +20,6 @@ ddg2: readoutPriority: baseline softwareTrigger: false -samx: - readoutPriority: baseline - deviceClass: ophyd_devices.SimPositioner - deviceConfig: - delay: 1 - limits: - - -50 - - 50 - tolerance: 0.01 - update_frequency: 400 - enabled: true - readOnly: false - -bpm4i: - readoutPriority: monitored - deviceClass: ophyd_devices.SimMonitor - deviceConfig: - enabled: true - readOnly: false - mcs: description: Mcs scalar card for transmission readout deviceClass: csaxs_bec.devices.epics.mcs_card.mcs_card_csaxs.MCSCardCSAXS diff --git a/csaxs_bec/device_configs/first_light.yaml b/csaxs_bec/device_configs/first_light.yaml index 9c5a283..e882957 100644 --- a/csaxs_bec/device_configs/first_light.yaml +++ b/csaxs_bec/device_configs/first_light.yaml @@ -2,4 +2,7 @@ optics: - !include ./optics_hutch.yaml frontend: - - !include ./frontend.yaml \ No newline at end of file + - !include ./frontend.yaml + +endstation: + - !include ./endstation.yaml \ No newline at end of file -- 2.49.1 From 3db74d9877ce13366ee75013da39ba681b5eab8e Mon Sep 17 00:00:00 2001 From: gac-x12sa Date: Tue, 22 Jul 2025 11:01:23 +0200 Subject: [PATCH 10/23] fix: put idgap to readonly false --- csaxs_bec/device_configs/frontend.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/csaxs_bec/device_configs/frontend.yaml b/csaxs_bec/device_configs/frontend.yaml index 5b86b5a..1dfbb07 100644 --- a/csaxs_bec/device_configs/frontend.yaml +++ b/csaxs_bec/device_configs/frontend.yaml @@ -6,7 +6,7 @@ idgap: onFailure: raise # Consider changing to buffer enabled: true readoutPriority: baseline - readOnly: true # put to false if you like to move it + readOnly: false # put to false if you like to move it softwareTrigger: false xbpm1x: -- 2.49.1 From b6af93806a89bdf44390a4ed1f9dff1e495fe9c3 Mon Sep 17 00:00:00 2001 From: gac-x12sa Date: Tue, 22 Jul 2025 12:14:39 +0200 Subject: [PATCH 11/23] fix: remove get_config from pre_startup --- .../bec_ipython_client/startup/pre_startup.py | 20 +++++++++---------- csaxs_bec/device_configs/optics_hutch.yaml | 8 ++++++++ test.txt | 1 + 3 files changed, 19 insertions(+), 10 deletions(-) create mode 100644 test.txt diff --git a/csaxs_bec/bec_ipython_client/startup/pre_startup.py b/csaxs_bec/bec_ipython_client/startup/pre_startup.py index 4ba6343..c5b433a 100644 --- a/csaxs_bec/bec_ipython_client/startup/pre_startup.py +++ b/csaxs_bec/bec_ipython_client/startup/pre_startup.py @@ -20,13 +20,13 @@ def extend_command_line_args(parser): return parser -def get_config() -> ServiceConfig: - """ - Create and return the ServiceConfig for the plugin repository - """ - deployment_path = os.path.dirname(os.path.dirname(os.path.dirname(csaxs_bec.__file__))) - files = os.listdir(deployment_path) - if "bec_config.yaml" in files: - return ServiceConfig(config_path=os.path.join(deployment_path, "bec_config.yaml")) - else: - return ServiceConfig(redis={"host": "localhost", "port": 6379}) +# def get_config() -> ServiceConfig: +# """ +# Create and return the ServiceConfig for the plugin repository +# """ +# deployment_path = os.path.dirname(os.path.dirname(os.path.dirname(csaxs_bec.__file__))) +# files = os.listdir(deployment_path) +# if "bec_config.yaml" in files: +# return ServiceConfig(config_path=os.path.join(deployment_path, "bec_config.yaml")) +# else: +# return ServiceConfig(redis={"host": "localhost", "port": 6379}) diff --git a/csaxs_bec/device_configs/optics_hutch.yaml b/csaxs_bec/device_configs/optics_hutch.yaml index 4a0feae..2683a8d 100644 --- a/csaxs_bec/device_configs/optics_hutch.yaml +++ b/csaxs_bec/device_configs/optics_hutch.yaml @@ -79,6 +79,8 @@ xbpm2x: - 200 port: 5000 sign: 1 + # precision: 3 + # tolerance: 0.005 enabled: true onFailure: buffer readOnly: false @@ -95,6 +97,8 @@ xbpm2y: - 200 port: 5000 sign: 1 + # precision: 3 + # tolerance: 0.005 enabled: true onFailure: buffer readOnly: false @@ -111,6 +115,8 @@ cu_foilx: - 200 port: 5000 sign: 1 + # precision: 3 + # tolerance: 0.005 enabled: true onFailure: buffer readOnly: false @@ -127,6 +133,8 @@ scinx: - 200 port: 5000 sign: 1 + # precision: 3 + # tolerance: 0.005 enabled: true onFailure: buffer readOnly: false diff --git a/test.txt b/test.txt new file mode 100644 index 0000000..df9fe2d --- /dev/null +++ b/test.txt @@ -0,0 +1 @@ +jjakjda -- 2.49.1 From 8f7ada2f92bc701b68d78ed6fa82c69b405bec5d Mon Sep 17 00:00:00 2001 From: appel_c Date: Thu, 24 Jul 2025 10:19:04 +0200 Subject: [PATCH 12/23] refactor(ids): refactor ids camera --- .copier-answers.yml | 4 +- csaxs_bec/device_configs/endstation.yaml | 15 +- .../epics/delay_generator_csaxs/ddg_1.py | 14 +- .../epics/delay_generator_csaxs/ddg_2.py | 8 +- .../delay_generator_csaxs.py | 2 +- .../devices/epics/mcs_card/mcs_card_csaxs.py | 2 +- .../ids_cameras/base_integration/__init__.py | 0 .../ids_cameras/base_integration/camera.py | 251 ++++++ .../ids_cameras/base_integration/utils.py | 281 +++++++ csaxs_bec/devices/ids_cameras/ids_camera.py | 772 ++++++++---------- .../devices/ids_cameras/ids_camera_new.py | 285 +++++++ .../devices/ids_cameras/ids_ueye_signals.py | 83 -- 12 files changed, 1196 insertions(+), 521 deletions(-) create mode 100644 csaxs_bec/devices/ids_cameras/base_integration/__init__.py create mode 100644 csaxs_bec/devices/ids_cameras/base_integration/camera.py create mode 100644 csaxs_bec/devices/ids_cameras/base_integration/utils.py create mode 100644 csaxs_bec/devices/ids_cameras/ids_camera_new.py delete mode 100644 csaxs_bec/devices/ids_cameras/ids_ueye_signals.py diff --git a/.copier-answers.yml b/.copier-answers.yml index a4b4022..e233aca 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -6,4 +6,6 @@ _commit: v1.1.2 _src_path: https://github.com/bec-project/plugin_copier_template.git make_commit: false project_name: csaxs_bec -widget_plugins_input: [] +widget_plugins_input: +- name: csaxs_test + use_ui: true diff --git a/csaxs_bec/device_configs/endstation.yaml b/csaxs_bec/device_configs/endstation.yaml index 56a3d10..1bd8ef6 100644 --- a/csaxs_bec/device_configs/endstation.yaml +++ b/csaxs_bec/device_configs/endstation.yaml @@ -28,4 +28,17 @@ mcs: onFailure: raise enabled: true readoutPriority: monitored - softwareTrigger: false \ No newline at end of file + softwareTrigger: false + +ids_cam: + description: IDS camera for live image acquisition + deviceClass: csaxs_bec.devices.ids_cameras.ids_camera_new.IDSCamera + deviceConfig: + camera_id: 201 + bits_per_pixel: 24 + m_n_colormode: 1 + live_mode: True + onFailure: raise + enabled: true + readoutPriority: async + softwareTrigger: True diff --git a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py index a5c2702..f5dde68 100644 --- a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py @@ -165,20 +165,20 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): ) self.cancel_on_stop(status_ready_read) self.cancel_on_stop(status_acquiring) - status_ready_read.wait(2) + status_ready_read.wait(10) mcs.ready_to_read.put(READYTOREAD.PROCESSING) mcs.erase_start.put(1) status_acquiring.wait( - timeout=2 + timeout=10 ) # 2 s wait for mcs card to start should be more than enough.. - st = StatusBase() + st = DeviceStatus(self) self.cancel_on_stop(st) self.trigger_shot.put(1, use_complete=True) time.sleep(self.scan_info.msg.scan_parameters["exp_time"]) self.cancel_on_stop(st) - status = self.wait_for_status(status=st, bit_event=STATUSBITS.END_OF_DELAY, timeout=2) + status = self.wait_for_status(status=st, bit_event=STATUSBITS.END_OF_DELAY, timeout=10) return status def wait_for_status( @@ -198,7 +198,11 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): if (STATUSBITS(event_status) & bit_event) == bit_event: status.set_finished() if time.time() - current_time > timeout: - status.set_exception(TimeoutError(f"Timeout waiting for status {status}")) + status.set_exception( + TimeoutError( + f"Timeout waiting for status of device {self.name} for event_status {bit_event}" + ) + ) break time.sleep(0.1) time.sleep(0.05) # Give time for the IOC to be ready again diff --git a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_2.py b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_2.py index 260e649..af84506 100644 --- a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_2.py +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_2.py @@ -118,7 +118,7 @@ class DDG2(PSIDeviceBase, DelayGeneratorCSAXS): """ def wait_for_status( - self, status: StatusBase, bit_event: STATUSBITS, timeout: float = 2 + self, status: DeviceStatus, bit_event: STATUSBITS, timeout: float = 5 ) -> None: """Wait for a event status bit to be set. @@ -134,7 +134,11 @@ class DDG2(PSIDeviceBase, DelayGeneratorCSAXS): if (STATUSBITS(event_status) & bit_event) == bit_event: status.set_finished() if time.time() - current_time > timeout: - status.set_exception(TimeoutError(f"Timeout waiting for status {status}")) + status.set_exception( + TimeoutError( + f"Timeout waiting for status of device {self.name} for event_status {bit_event}" + ) + ) break time.sleep(0.1) time.sleep(0.05) # Give time for the IOC to be ready again diff --git a/csaxs_bec/devices/epics/delay_generator_csaxs/delay_generator_csaxs.py b/csaxs_bec/devices/epics/delay_generator_csaxs/delay_generator_csaxs.py index 981f09c..676ef48 100644 --- a/csaxs_bec/devices/epics/delay_generator_csaxs/delay_generator_csaxs.py +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/delay_generator_csaxs.py @@ -403,7 +403,7 @@ class DelayGeneratorCSAXS(Device): In addition, the io layer allows setting amplitude, offset and polarity for each pair. """ - _pv_timeout: float = 1.5 # Default timeout for PV operations in seconds + _pv_timeout: float = 5 # Default timeout for PV operations in seconds # Front Panel t0 = Cpt(StaticPair, "T0", name="t0", doc="T0 static pair") diff --git a/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py b/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py index 166a48e..e3d352c 100644 --- a/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py +++ b/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py @@ -118,7 +118,7 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): name=name, prefix=prefix, scan_info=scan_info, device_manager=device_manager, **kwargs ) self._mcs_clock = 1e7 # 10MHz clock -> 1e7 Hz - self._pv_timeout = 2 + self._pv_timeout = 3 # TODO remove timeout once #129 in ophyd_devices is solved self._rlock = RLock() self.counter_mapping = { f"{self.counters.name}_mca1": "current1", diff --git a/csaxs_bec/devices/ids_cameras/base_integration/__init__.py b/csaxs_bec/devices/ids_cameras/base_integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/csaxs_bec/devices/ids_cameras/base_integration/camera.py b/csaxs_bec/devices/ids_cameras/base_integration/camera.py new file mode 100644 index 0000000..a5a7d86 --- /dev/null +++ b/csaxs_bec/devices/ids_cameras/base_integration/camera.py @@ -0,0 +1,251 @@ +"""This module provides a Camera class for handling IDS cameras using the pyueye SDK library.""" + +from __future__ import annotations + +import atexit +from typing import Literal + +import numpy as np +from bec_lib.logger import bec_logger + +from csaxs_bec.devices.ids_cameras.base_integration.utils import check_error + +logger = bec_logger.logger + +try: + from pyueye import ueye +except ImportError as exc: + logger.warning(f"The pyueye library is not properly installed : {exc}") + ueye = None # type: ignore[assignment] + + +class IDSCameraObject: + """Base class for IDS Camera object. + + Args: + device_id (int): The ID of the camera device. # e.g. 201; check idscamera tool + m_n_colormode (int): Color mode for the camera. # 1 for cSAXS color cameras + bits_per_pixel (int): Number of bits per pixel for the camera. # 24 for color cameras, 8 for monochrome cameras + """ + + def __init__(self, device_id: int, m_n_colormode, bits_per_pixel): + self.ueye = ueye + self._device_id = device_id + self.h_cam = ueye.HIDS(device_id) + self.s_info = ueye.SENSORINFO() + self.c_info = ueye.CAMINFO() + self.rect_roi = ueye.IS_RECT() + self.pc_image_mem = ueye.c_mem_p() + self.mem_id = ueye.int() + self.pitch = ueye.INT() + self.m_n_colormode = ueye.INT(m_n_colormode) + self.n_bits_per_pixel = ueye.INT(bits_per_pixel) + self.bytes_per_pixel = int(self.n_bits_per_pixel / 8) + + # Sequence to initialize the camera + check_error(ueye.is_InitCamera(self.h_cam, None), "IDSCameraObject") + check_error(ueye.is_GetSensorInfo(self.h_cam, self.s_info), "IDSCameraObject") + check_error(ueye.is_GetCameraInfo(self.h_cam, self.c_info), "IDSCameraObject") + check_error(ueye.is_ResetToDefault(self.h_cam), "IDSCameraObject") + check_error(ueye.is_SetDisplayMode(self.h_cam, ueye.IS_SET_DM_DIB), "IDSCameraObject") + + if ( + int.from_bytes(self.s_info.nColorMode.value, byteorder="big") + == self.ueye.IS_COLORMODE_BAYER + ): + print("Bayer color mode detected.") + # setup the color depth to the current windows setting + self.ueye.is_GetColorDepth( + self.h_cam, self.n_bits_per_pixel, self.m_n_colormode + ) # TODO This raises an error - maybe check the m_n_colormode value + self.bytes_per_pixel = int(self.n_bits_per_pixel / 8) + elif ( + int.from_bytes(self.s_info.nColorMode.value, byteorder="big") + == self.ueye.IS_COLORMODE_CBYCRY + ): + # for color camera models use RGB32 mode + self.m_n_colormode = self.ueye.IS_CM_BGRA8_PACKED + self.n_bits_per_pixel = self.ueye.INT(32) + self.bytes_per_pixel = int(self.n_bits_per_pixel / 8) + elif ( + int.from_bytes(self.s_info.nColorMode.value, byteorder="big") + == self.ueye.IS_COLORMODE_MONOCHROME + ): + # for color camera models use RGB32 mode + self.m_n_colormode = self.ueye.IS_CM_MONO8 + self.n_bits_per_pixel = self.ueye.INT(8) + self.bytes_per_pixel = int(self.n_bits_per_pixel / 8) + else: + # for monochrome camera models use Y8 mode + self.m_n_colormode = self.ueye.IS_CM_MONO8 + self.n_bits_per_pixel = self.ueye.INT(8) + self.bytes_per_pixel = int(self.n_bits_per_pixel / 8) + print("else") + + # Can be used to set the size and position of an "area of interest"(AOI) within an image + check_error( + self.ueye.is_AOI( + self.h_cam, + self.ueye.IS_AOI_IMAGE_GET_AOI, + self.rect_roi, + self.ueye.sizeof(self.rect_roi), + ), + "IDSCameraObject", + ) + self.width = self.rect_roi.s32Width + self.height = self.rect_roi.s32Height + + check_error( + self.ueye.is_AllocImageMem( + self.h_cam, + self.width, + self.height, + self.n_bits_per_pixel, + self.pc_image_mem, + self.mem_id, + ), + "IDSCameraObject", + ) + + check_error( + self.ueye.is_SetImageMem(self.h_cam, self.pc_image_mem, self.mem_id), "IDSCameraObject" + ) + check_error(self.ueye.is_SetColorMode(self.h_cam, self.m_n_colormode), "IDSCameraObject") + + check_error( + self.ueye.is_CaptureVideo(self.h_cam, self.ueye.IS_DONT_WAIT), "IDSCameraObject" + ) + check_error( + self.ueye.is_InquireImageMem( + self.h_cam, + self.pc_image_mem, + self.mem_id, + self.width, + self.height, + self.n_bits_per_pixel, + self.pitch, + ), + "IDSCameraObject", + ) + + def __repr__(self): + return f"IDSCameraObject\n\ndevice_id={self._device_id},\ns_info={self.s_info},\nc_info={self.c_info},\nrect_roi={self.rect_roi},\npc_image_mem={self.pc_image_mem},\nmem_id={self.mem_id},\npitch={self.pitch},\nm_n_colormode={self.m_n_colormode},\nn_bits_per_pixel={self.n_bits_per_pixel},\nbytes_per_pixel={self.bytes_per_pixel}" + + +class Camera: + """Camera base class for IDS cameras.""" + + def __init__( + self, + camera_id: int, + m_n_colormode: Literal[0, 1, 2, 3] = 1, + bits_per_pixel: int = 24, + connect: bool = True, + ): + self.ueye = ueye + self.camera_id = camera_id + self._inputs = {"m_n_colormode": m_n_colormode, "bits_per_pixel": bits_per_pixel} + self._connected = False + self.cam = None + atexit.register(self.on_disconnect) + + if connect: + self.on_connect() + + def set_roi(self, x: int, y: int, width: int, height: int): + """Set the region of interest (ROI) for the camera.""" + rect_roi = ueye.IS_RECT() + rect_roi.s32X = x + rect_roi.s32Y = y + rect_roi.s32Width = width + rect_roi.s32Height = height + + ret = self.ueye.is_AOI( + self.cam.h_cam, self.ueye.IS_AOI_IMAGE_SET_AOI, rect_roi, self.ueye.sizeof(rect_roi) + ) + check_error(ret, "IDSCameraObject") + logger.info(f"ROI set to: {rect_roi}") + + def on_connect(self): + """Connect to the camera and initialize it.""" + if self._connected: + logger.warning("Camera is already connected.") + return + self.cam = IDSCameraObject(self.camera_id, **self._inputs) + self._connected = True + + def on_disconnect(self): + """Disconnect from the camera.""" + try: + if self.cam and self.cam.h_cam: + check_error(self.ueye.is_ExitCamera(self.cam.h_cam), "IDSCameraObject") + self._connected = False + self.cam = None + logger.info("Camera disconnected.") + except Exception as e: + logger.info(f"Error during camera disconnection: {e}") + + @property + def exposure_time(self) -> float: + """Get the exposure time of the camera.""" + exposure = ueye.c_double() + ret = self.ueye.is_Exposure(self.cam.h_cam, ueye.IS_EXPOSURE_CMD_GET_EXPOSURE, exposure, 8) + check_error(ret, "IDSCameraObject") + return exposure.value + + @exposure_time.setter + def exposure_time(self, value: float): + """Set the exposure time of the camera.""" + exposure = ueye.c_double(value) + check_error( + self.ueye.is_Exposure(self.cam.h_cam, ueye.IS_EXPOSURE_CMD_SET_EXPOSURE, exposure, 8), + "IDSCameraObject", + ) + + def set_auto_gain(self, enable: bool): + """Enable or disable auto gain.""" + enable = ueye.c_int(1) if enable else ueye.c_int(0) + value_to_return = ueye.c_double() + check_error( + self.ueye.is_SetAutoParameter( + self.cam.h_cam, ueye.IS_SET_ENABLE_AUTO_GAIN, enable, value_to_return + ), + "IDSCameraObject", + ) + + def set_auto_shutter(self, enable: bool): + """Enable or disable auto exposure.""" + enable = ueye.c_int(1) if enable else ueye.c_int(0) + value_to_return = ueye.c_double() + check_error( + self.ueye.is_SetAutoParameter( + self.cam.h_cam, ueye.IS_SET_ENABLE_AUTO_SHUTTER, enable, value_to_return + ), + "IDSCameraObject", + ) + + def get_image_data(self) -> np.ndarray | None: + """Get the image data from the camera.""" + if not self._connected: + logger.warning("Camera is not connected.") + return None + array = self.ueye.get_data( + self.cam.pc_image_mem, + self.cam.width, + self.cam.height, + self.cam.n_bits_per_pixel, + self.cam.pitch, + copy=False, + ) + if array is None: + logger.error("Failed to get image data from the camera.") + return None + return np.reshape( + array, (self.cam.height.value, self.cam.width.value, self.cam.bytes_per_pixel) + ) + + +if __name__ == "__main__": + # Example usage + camera = Camera(camera_id=201) + camera.on_connect() diff --git a/csaxs_bec/devices/ids_cameras/base_integration/utils.py b/csaxs_bec/devices/ids_cameras/base_integration/utils.py new file mode 100644 index 0000000..c059cab --- /dev/null +++ b/csaxs_bec/devices/ids_cameras/base_integration/utils.py @@ -0,0 +1,281 @@ +"""Utility functions and classes for IDS cameras using the pyueye library.""" + +from unittest import mock + +from bec_lib.logger import bec_logger + +logger = bec_logger.logger + +try: + from pyueye import ueye +except ImportError as exc: + logger.warning(f"The pyueye library is not properly installed : {exc}") + ueye = None + +if ueye is not None: + error_codes = { + ueye.IS_NO_SUCCESS: "No success", + ueye.IS_SUCCESS: "Success", + ueye.IS_INVALID_CAMERA_HANDLE: "Invalid camera handle", + ueye.IS_INVALID_HANDLE: "Invalid handle", + ueye.IS_IO_REQUEST_FAILED: "IO request failed", + ueye.IS_CANT_OPEN_DEVICE: "Cannot open device", + ueye.IS_CANT_CLOSE_DEVICE: "Cannot close device", + ueye.IS_CANT_SETUP_MEMORY: "Cannot setup memory", + ueye.IS_NO_HWND_FOR_ERROR_REPORT: "No HWND for error report", + ueye.IS_ERROR_MESSAGE_NOT_CREATED: "Error message not created", + ueye.IS_ERROR_STRING_NOT_FOUND: "Error string not found", + ueye.IS_HOOK_NOT_CREATED: "Hook not created", + ueye.IS_TIMER_NOT_CREATED: "Timer not created", + ueye.IS_CANT_OPEN_REGISTRY: "Cannot open registry", + ueye.IS_CANT_READ_REGISTRY: "Cannot read registry", + ueye.IS_CANT_VALIDATE_BOARD: "Cannot validate board", + ueye.IS_CANT_GIVE_BOARD_ACCESS: "Cannot give board access", + ueye.IS_NO_IMAGE_MEM_ALLOCATED: "No image memory allocated", + ueye.IS_CANT_CLEANUP_MEMORY: "Cannot clean up memory", + ueye.IS_CANT_COMMUNICATE_WITH_DRIVER: "Cannot communicate with driver", + ueye.IS_FUNCTION_NOT_SUPPORTED_YET: "Function not supported yet", + ueye.IS_OPERATING_SYSTEM_NOT_SUPPORTED: "Operating system not supported", + ueye.IS_INVALID_VIDEO_IN: "Invalid video input", + ueye.IS_INVALID_IMG_SIZE: "Invalid image size", + ueye.IS_INVALID_ADDRESS: "Invalid address", + ueye.IS_INVALID_VIDEO_MODE: "Invalid video mode", + ueye.IS_INVALID_AGC_MODE: "Invalid AGC mode", + ueye.IS_INVALID_GAMMA_MODE: "Invalid gamma mode", + ueye.IS_INVALID_SYNC_LEVEL: "Invalid sync level", + ueye.IS_INVALID_CBARS_MODE: "Invalid color bars mode", + ueye.IS_INVALID_COLOR_MODE: "Invalid color mode", + ueye.IS_INVALID_SCALE_FACTOR: "Invalid scale factor", + ueye.IS_INVALID_IMAGE_SIZE: "Invalid image size", + ueye.IS_INVALID_IMAGE_POS: "Invalid image position", + ueye.IS_INVALID_CAPTURE_MODE: "Invalid capture mode", + ueye.IS_INVALID_RISC_PROGRAM: "Invalid RISC program", + ueye.IS_INVALID_BRIGHTNESS: "Invalid brightness", + ueye.IS_INVALID_CONTRAST: "Invalid contrast", + ueye.IS_INVALID_SATURATION_U: "Invalid saturation U", + ueye.IS_INVALID_SATURATION_V: "Invalid saturation V", + ueye.IS_INVALID_HUE: "Invalid hue", + ueye.IS_INVALID_HOR_FILTER_STEP: "Invalid horizontal filter step", + ueye.IS_INVALID_VERT_FILTER_STEP: "Invalid vertical filter step", + ueye.IS_INVALID_EEPROM_READ_ADDRESS: "Invalid EEPROM read address", + ueye.IS_INVALID_EEPROM_WRITE_ADDRESS: "Invalid EEPROM write address", + ueye.IS_INVALID_EEPROM_READ_LENGTH: "Invalid EEPROM read length", + ueye.IS_INVALID_EEPROM_WRITE_LENGTH: "Invalid EEPROM write length", + ueye.IS_INVALID_BOARD_INFO_POINTER: "Invalid board info pointer", + ueye.IS_INVALID_DISPLAY_MODE: "Invalid display mode", + ueye.IS_INVALID_ERR_REP_MODE: "Invalid error report mode", + ueye.IS_INVALID_BITS_PIXEL: "Invalid bits per pixel", + ueye.IS_INVALID_MEMORY_POINTER: "Invalid memory pointer", + ueye.IS_FILE_WRITE_OPEN_ERROR: "File write open error", + ueye.IS_FILE_READ_OPEN_ERROR: "File read open error", + ueye.IS_FILE_READ_INVALID_BMP_ID: "File read invalid BMP ID", + ueye.IS_FILE_READ_INVALID_BMP_SIZE: "File read invalid BMP size", + ueye.IS_FILE_READ_INVALID_BIT_COUNT: "File read invalid bit count", + ueye.IS_WRONG_KERNEL_VERSION: "Wrong kernel version", + ueye.IS_RISC_INVALID_XLENGTH: "RISC invalid X length", + ueye.IS_RISC_INVALID_YLENGTH: "RISC invalid Y length", + ueye.IS_RISC_EXCEED_IMG_SIZE: "RISC exceed image size", + ueye.IS_DD_MAIN_FAILED: "DirectDraw main surface failed", + ueye.IS_DD_PRIMSURFACE_FAILED: "DirectDraw primary surface failed", + ueye.IS_DD_SCRN_SIZE_NOT_SUPPORTED: "Screen size not supported", + ueye.IS_DD_CLIPPER_FAILED: "Clipper failed", + ueye.IS_DD_CLIPPER_HWND_FAILED: "Clipper HWND failed", + ueye.IS_DD_CLIPPER_CONNECT_FAILED: "Clipper connect failed", + ueye.IS_DD_BACKSURFACE_FAILED: "Backsurface failed", + ueye.IS_DD_BACKSURFACE_IN_SYSMEM: "Backsurface in system memory", + ueye.IS_DD_MDL_MALLOC_ERR: "Memory malloc error", + ueye.IS_DD_MDL_SIZE_ERR: "Memory size error", + ueye.IS_DD_CLIP_NO_CHANGE: "Clip no change", + ueye.IS_DD_PRIMMEM_NULL: "Primary memory null", + ueye.IS_DD_BACKMEM_NULL: "Back memory null", + ueye.IS_DD_BACKOVLMEM_NULL: "Back overlay memory null", + ueye.IS_DD_OVERLAYSURFACE_FAILED: "Overlay surface failed", + ueye.IS_DD_OVERLAYSURFACE_IN_SYSMEM: "Overlay surface in system memory", + ueye.IS_DD_OVERLAY_NOT_ALLOWED: "Overlay not allowed", + ueye.IS_DD_OVERLAY_COLKEY_ERR: "Overlay color key error", + ueye.IS_DD_OVERLAY_NOT_ENABLED: "Overlay not enabled", + ueye.IS_DD_GET_DC_ERROR: "Get DC error", + ueye.IS_DD_DDRAW_DLL_NOT_LOADED: "DirectDraw DLL not loaded", + ueye.IS_DD_THREAD_NOT_CREATED: "DirectDraw thread not created", + ueye.IS_DD_CANT_GET_CAPS: "Cannot get capabilities", + ueye.IS_DD_NO_OVERLAYSURFACE: "No overlay surface", + ueye.IS_DD_NO_OVERLAYSTRETCH: "No overlay stretch", + ueye.IS_DD_CANT_CREATE_OVERLAYSURFACE: "Cannot create overlay surface", + ueye.IS_DD_CANT_UPDATE_OVERLAYSURFACE: "Cannot update overlay surface", + ueye.IS_DD_INVALID_STRETCH: "Invalid stretch", + ueye.IS_EV_INVALID_EVENT_NUMBER: "Invalid event number", + ueye.IS_INVALID_MODE: "Invalid mode", + ueye.IS_CANT_FIND_HOOK: "Cannot find hook", + ueye.IS_CANT_GET_HOOK_PROC_ADDR: "Cannot get hook procedure address", + ueye.IS_CANT_CHAIN_HOOK_PROC: "Cannot chain hook procedure", + ueye.IS_CANT_SETUP_WND_PROC: "Cannot setup window procedure", + ueye.IS_HWND_NULL: "HWND is null", + ueye.IS_INVALID_UPDATE_MODE: "Invalid update mode", + ueye.IS_NO_ACTIVE_IMG_MEM: "No active image memory", + ueye.IS_CANT_INIT_EVENT: "Cannot initialize event", + ueye.IS_FUNC_NOT_AVAIL_IN_OS: "Function not available in OS", + ueye.IS_CAMERA_NOT_CONNECTED: "Camera not connected", + ueye.IS_SEQUENCE_LIST_EMPTY: "Sequence list empty", + ueye.IS_CANT_ADD_TO_SEQUENCE: "Cannot add to sequence", + ueye.IS_LOW_OF_SEQUENCE_RISC_MEM: "Low sequence RISC memory", + ueye.IS_IMGMEM2FREE_USED_IN_SEQ: "Image memory to free used in sequence", + ueye.IS_IMGMEM_NOT_IN_SEQUENCE_LIST: "Image memory not in sequence list", + ueye.IS_SEQUENCE_BUF_ALREADY_LOCKED: "Sequence buffer already locked", + ueye.IS_INVALID_DEVICE_ID: "Invalid device ID", + ueye.IS_INVALID_BOARD_ID: "Invalid board ID", + ueye.IS_ALL_DEVICES_BUSY: "All devices busy", + ueye.IS_HOOK_BUSY: "Hook busy", + ueye.IS_TIMED_OUT: "Timed out", + ueye.IS_NULL_POINTER: "Null pointer", + ueye.IS_WRONG_HOOK_VERSION: "Wrong hook version", + ueye.IS_INVALID_PARAMETER: "Invalid parameter", + ueye.IS_NOT_ALLOWED: "Not allowed", + ueye.IS_OUT_OF_MEMORY: "Out of memory", + ueye.IS_INVALID_WHILE_LIVE: "Invalid while live", + ueye.IS_ACCESS_VIOLATION: "Access violation", + ueye.IS_UNKNOWN_ROP_EFFECT: "Unknown ROP effect", + ueye.IS_INVALID_RENDER_MODE: "Invalid render mode", + ueye.IS_INVALID_THREAD_CONTEXT: "Invalid thread context", + ueye.IS_NO_HARDWARE_INSTALLED: "No hardware installed", + ueye.IS_INVALID_WATCHDOG_TIME: "Invalid watchdog time", + ueye.IS_INVALID_WATCHDOG_MODE: "Invalid watchdog mode", + ueye.IS_INVALID_PASSTHROUGH_IN: "Invalid passthrough input", + ueye.IS_ERROR_SETTING_PASSTHROUGH_IN: "Error setting passthrough input", + ueye.IS_FAILURE_ON_SETTING_WATCHDOG: "Failure setting watchdog", + ueye.IS_NO_USB20: "No USB 2.0", + ueye.IS_CAPTURE_RUNNING: "Capture running", + ueye.IS_MEMORY_BOARD_ACTIVATED: "Memory board activated", + ueye.IS_MEMORY_BOARD_DEACTIVATED: "Memory board deactivated", + ueye.IS_NO_MEMORY_BOARD_CONNECTED: "No memory board connected", + ueye.IS_TOO_LESS_MEMORY: "Too little memory", + ueye.IS_IMAGE_NOT_PRESENT: "Image not present", + ueye.IS_MEMORY_MODE_RUNNING: "Memory mode running", + ueye.IS_MEMORYBOARD_DISABLED: "Memoryboard disabled", + ueye.IS_TRIGGER_ACTIVATED: "Trigger activated", + ueye.IS_WRONG_KEY: "Wrong key", + ueye.IS_CRC_ERROR: "CRC error", + ueye.IS_NOT_YET_RELEASED: "Not yet released", + ueye.IS_NOT_CALIBRATED: "Not calibrated", # already present + ueye.IS_WAITING_FOR_KERNEL: "Waiting for kernel", + ueye.IS_NOT_SUPPORTED: "Not supported", # already present + ueye.IS_TRIGGER_NOT_ACTIVATED: "Trigger not activated", + ueye.IS_OPERATION_ABORTED: "Operation aborted", + ueye.IS_BAD_STRUCTURE_SIZE: "Bad structure size", + ueye.IS_INVALID_BUFFER_SIZE: "Invalid buffer size", + ueye.IS_INVALID_PIXEL_CLOCK: "Invalid pixel clock", + ueye.IS_INVALID_EXPOSURE_TIME: "Invalid exposure time", + ueye.IS_AUTO_EXPOSURE_RUNNING: "Auto exposure running", + ueye.IS_CANNOT_CREATE_BB_SURF: "Cannot create BB surface", + ueye.IS_CANNOT_CREATE_BB_MIX: "Cannot create BB mix", + ueye.IS_BB_OVLMEM_NULL: "BB overlay memory null", + ueye.IS_CANNOT_CREATE_BB_OVL: "Cannot create BB overlay", + ueye.IS_NOT_SUPP_IN_OVL_SURF_MODE: "Not supported in overlay surface mode", + ueye.IS_INVALID_SURFACE: "Invalid surface", + ueye.IS_SURFACE_LOST: "Surface lost", + ueye.IS_RELEASE_BB_OVL_DC: "Release BB overlay DC", + ueye.IS_BB_TIMER_NOT_CREATED: "BB timer not created", + ueye.IS_BB_OVL_NOT_EN: "BB overlay not enabled", + ueye.IS_ONLY_IN_BB_MODE: "Only in BB mode", + ueye.IS_INVALID_COLOR_FORMAT: "Invalid color format", + ueye.IS_INVALID_WB_BINNING_MODE: "Invalid WB binning mode", + ueye.IS_INVALID_I2C_DEVICE_ADDRESS: "Invalid I²C device address", + ueye.IS_COULD_NOT_CONVERT: "Could not convert", + ueye.IS_TRANSFER_ERROR: "Transfer error", # already present + ueye.IS_PARAMETER_SET_NOT_PRESENT: "Parameter set not present", + ueye.IS_INVALID_CAMERA_TYPE: "Invalid camera type", + ueye.IS_INVALID_HOST_IP_HIBYTE: "Invalid host IP high byte", + ueye.IS_CM_NOT_SUPP_IN_CURR_DISPLAYMODE: "Color matrix not supported in current display mode", + ueye.IS_NO_IR_FILTER: "No IR filter", + ueye.IS_STARTER_FW_UPLOAD_NEEDED: "Starter firmware upload needed", + ueye.IS_DR_LIBRARY_NOT_FOUND: "Driver library not found", + ueye.IS_DR_DEVICE_OUT_OF_MEMORY: "Driver device out of memory", + ueye.IS_DR_CANNOT_CREATE_SURFACE: "Driver cannot create surface", + ueye.IS_DR_CANNOT_CREATE_VERTEX_BUFFER: "Driver cannot create vertex buffer", + ueye.IS_DR_CANNOT_CREATE_TEXTURE: "Driver cannot create texture", + ueye.IS_DR_CANNOT_LOCK_OVERLAY_SURFACE: "Driver cannot lock overlay surface", + ueye.IS_DR_CANNOT_UNLOCK_OVERLAY_SURFACE: "Driver cannot unlock overlay surface", + ueye.IS_DR_CANNOT_GET_OVERLAY_DC: "Driver cannot get overlay DC", + ueye.IS_DR_CANNOT_RELEASE_OVERLAY_DC: "Driver cannot release overlay DC", + ueye.IS_DR_DEVICE_CAPS_INSUFFICIENT: "Driver device capabilities insufficient", + ueye.IS_INCOMPATIBLE_SETTING: "Incompatible setting", + ueye.IS_DR_NOT_ALLOWED_WHILE_DC_IS_ACTIVE: "Driver not allowed while DC is active", + ueye.IS_DEVICE_ALREADY_PAIRED: "Device already paired", + ueye.IS_SUBNETMASK_MISMATCH: "Subnet mask mismatch", + ueye.IS_SUBNET_MISMATCH: "Subnet mismatch", + ueye.IS_INVALID_IP_CONFIGURATION: "Invalid IP configuration", + ueye.IS_DEVICE_NOT_COMPATIBLE: "Device not compatible", + ueye.IS_NETWORK_FRAME_SIZE_INCOMPATIBLE: "Network frame size incompatible", + ueye.IS_NETWORK_CONFIGURATION_INVALID: "Network configuration invalid", + ueye.IS_ERROR_CPU_IDLE_STATES_CONFIGURATION: "CPU idle states configuration error", + ueye.IS_DEVICE_BUSY: "Device busy", + ueye.IS_SENSOR_INITIALIZATION_FAILED: "Sensor initialization failed", + ueye.IS_IMAGE_BUFFER_NOT_DWORD_ALIGNED: "Image buffer not DWORD aligned", + ueye.IS_SEQ_BUFFER_IS_LOCKED: "Sequence buffer is locked", + ueye.IS_FILE_PATH_DOES_NOT_EXIST: "File path does not exist", + ueye.IS_INVALID_WINDOW_HANDLE: "Invalid window handle", + ueye.IS_INVALID_IMAGE_PARAMETER: "Invalid image parameter", + ueye.IS_NO_SUCH_DEVICE: "No such device", + ueye.IS_DEVICE_IN_USE: "Device in use", + } + + bits_per_pixel = { + ueye.IS_CM_SENSOR_RAW8: 8, + ueye.IS_CM_SENSOR_RAW10: 16, + ueye.IS_CM_SENSOR_RAW12: 16, + ueye.IS_CM_SENSOR_RAW16: 16, + ueye.IS_CM_MONO8: 8, + ueye.IS_CM_RGB8_PACKED: 24, + ueye.IS_CM_BGR8_PACKED: 24, + ueye.IS_CM_RGBA8_PACKED: 32, + ueye.IS_CM_BGRA8_PACKED: 32, + ueye.IS_CM_BGR10_PACKED: 32, + ueye.IS_CM_RGB10_PACKED: 32, + ueye.IS_CM_BGRA12_UNPACKED: 64, + ueye.IS_CM_BGR12_UNPACKED: 48, + ueye.IS_CM_BGRY8_PACKED: 32, + ueye.IS_CM_BGR565_PACKED: 16, + ueye.IS_CM_BGR5_PACKED: 16, + ueye.IS_CM_UYVY_PACKED: 16, + ueye.IS_CM_UYVY_MONO_PACKED: 16, + ueye.IS_CM_UYVY_BAYER_PACKED: 16, + ueye.IS_CM_CBYCRY_PACKED: 16, + } + + +def get_bits_per_pixel(color_mode): + """ + Returns the number of bits per pixel for the given color mode. + """ + if color_mode not in bits_per_pixel: + raise UEyeException(f"Unknown color mode: {color_mode}") + return bits_per_pixel[color_mode] + + +class UEyeException(Exception): + """Custom exception for uEye errors.""" + + def __init__(self, error_code, called_from: str | None = None): + self.error_code = error_code + self.called_from = called_from if called_from is not None else "" + + def __str__(self): + if self.error_code in error_codes: + return f"Exception: {error_codes[self.error_code]} raised in {self.called_from}." + else: + for att, val in ueye.__dict__.items(): + if ( + att[0:2] == "IS" + and val == self.error_code + and ("FAILED" in att or "INVALID" in att or "ERROR" in att or "NOT" in att) + ): + return f"Exception: {str(self.error_code)} ({att} ? {val}) raised in {self.called_from}." + return f"Exception: {str(self.error_code)} raised in {self.called_from}." + + +def check_error(error_code, called_from: str | None = None): + """ + Check an error code, and raise an error if adequate. + """ + if error_code != ueye.IS_SUCCESS: + called_from = called_from if called_from is not None else "" + raise UEyeException(error_code, called_from) diff --git a/csaxs_bec/devices/ids_cameras/ids_camera.py b/csaxs_bec/devices/ids_cameras/ids_camera.py index 37edadb..1b7fe7f 100644 --- a/csaxs_bec/devices/ids_cameras/ids_camera.py +++ b/csaxs_bec/devices/ids_cameras/ids_camera.py @@ -3,226 +3,95 @@ import time import numpy as np from ophyd import Component as Cpt -from ophyd import Device, Kind -from ophyd_devices.interfaces.base_classes.psi_detector_base import ( - CustomDetectorMixin, - PSIDetectorBase, -) -from ophyd_devices.sim.sim_signals import SetableSignal - -try: - from pyueye import ueye -except ImportError: - # The pyueye library is not installed or doesn't provide the necessary c libs - ueye = None +from ophyd import DeviceStatus, Kind, Signal, StatusBase +from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase +from ophyd_devices.utils.bec_signals import PreviewSignal -class IDSCustomPrepare(CustomDetectorMixin): +class ROISignal(Signal): + """ + Signal to handle the Region of Interest (ROI) for the IDS camera. + It is a tuple of (x, y, width, height). + """ - USER_ACCESS = ["pyueye"] - pyueye = ueye - - def __init__(self, *_args, parent: Device = None, **_kwargs) -> None: - super().__init__(*_args, parent=parent, **_kwargs) - self.ueye = ueye - self.h_cam = None - self.s_info = None - self.data_thread = None - self.thread_event = None - - def on_connection_established(self): - self.hCam = self.ueye.HIDS( - self.parent.camera_ID - ) # 0: first available camera; 1-254: The camera with the specified camera ID - self.sInfo = self.ueye.SENSORINFO() - self.cInfo = self.ueye.CAMINFO() - self.pcImageMemory = self.ueye.c_mem_p() - self.MemID = self.ueye.int() - self.rectAOI = self.ueye.IS_RECT() - self.pitch = self.ueye.INT() - self.nBitsPerPixel = self.ueye.INT( - self.parent.bits_per_pixel - ) # 24: bits per pixel for color mode; take 8 bits per pixel for monochrome - self.channels = ( - self.parent.channels - ) # 3: channels for color mode(RGB); take 1 channel for monochrome - self.m_nColorMode = self.ueye.INT( - self.parent.m_n_colormode - ) # Y8/RGB16/RGB24/REG32 (1 for our color cameras) - self.bytes_per_pixel = int(self.nBitsPerPixel / 8) - - # Starts the driver and establishes the connection to the camera - nRet = self.ueye.is_InitCamera(self.hCam, None) - if nRet != self.ueye.IS_SUCCESS: - print("is_InitCamera ERROR") - - # Reads out the data hard-coded in the non-volatile camera memory and writes it to the data structure that cInfo points to - nRet = self.ueye.is_GetCameraInfo(self.hCam, self.cInfo) - if nRet != self.ueye.IS_SUCCESS: - print("is_GetCameraInfo ERROR") - - # You can query additional information about the sensor type used in the camera - nRet = self.ueye.is_GetSensorInfo(self.hCam, self.sInfo) - if nRet != self.ueye.IS_SUCCESS: - print("is_GetSensorInfo ERROR") - - nRet = self.ueye.is_ResetToDefault(self.hCam) - if nRet != self.ueye.IS_SUCCESS: - print("is_ResetToDefault ERROR") - - # Set display mode to DIB - nRet = self.ueye.is_SetDisplayMode(self.hCam, self.ueye.IS_SET_DM_DIB) - - # Set the right color mode - if ( - int.from_bytes(self.sInfo.nColorMode.value, byteorder="big") - == self.ueye.IS_COLORMODE_BAYER - ): - # setup the color depth to the current windows setting - self.ueye.is_GetColorDepth(self.hCam, self.nBitsPerPixel, self.m_nColorMode) - bytes_per_pixel = int(self.nBitsPerPixel / 8) - print("IS_COLORMODE_BAYER: ") - print("\tm_nColorMode: \t\t", self.m_nColorMode) - print("\tnBitsPerPixel: \t\t", self.nBitsPerPixel) - print("\tbytes_per_pixel: \t\t", bytes_per_pixel) - print() - - elif ( - int.from_bytes(self.sInfo.nColorMode.value, byteorder="big") - == self.ueye.IS_COLORMODE_CBYCRY - ): - # for color camera models use RGB32 mode - m_nColorMode = ueye.IS_CM_BGRA8_PACKED - nBitsPerPixel = ueye.INT(32) - bytes_per_pixel = int(self.nBitsPerPixel / 8) - print("IS_COLORMODE_CBYCRY: ") - print("\tm_nColorMode: \t\t", m_nColorMode) - print("\tnBitsPerPixel: \t\t", nBitsPerPixel) - print("\tbytes_per_pixel: \t\t", bytes_per_pixel) - print() - - elif ( - int.from_bytes(self.sInfo.nColorMode.value, byteorder="big") - == self.ueye.IS_COLORMODE_MONOCHROME - ): - # for color camera models use RGB32 mode - m_nColorMode = self.ueye.IS_CM_MONO8 - nBitsPerPixel = self.ueye.INT(8) - bytes_per_pixel = int(nBitsPerPixel / 8) - print("IS_COLORMODE_MONOCHROME: ") - print("\tm_nColorMode: \t\t", m_nColorMode) - print("\tnBitsPerPixel: \t\t", nBitsPerPixel) - print("\tbytes_per_pixel: \t\t", bytes_per_pixel) - print() - - else: - # for monochrome camera models use Y8 mode - m_nColorMode = self.ueye.IS_CM_MONO8 - nBitsPerPixel = self.ueye.INT(8) - bytes_per_pixel = int(nBitsPerPixel / 8) - print("else") - - # Can be used to set the size and position of an "area of interest"(AOI) within an image - nRet = self.ueye.is_AOI( - self.hCam, ueye.IS_AOI_IMAGE_GET_AOI, self.rectAOI, self.ueye.sizeof(self.rectAOI) + def __init__( + self, + *, + name, + roi: tuple | None = None, + value=0, + dtype=None, + shape=None, + timestamp=None, + parent=None, + labels=None, + kind=Kind.hinted, + tolerance=None, + rtolerance=None, + metadata=None, + cl=None, + attr_name="", + ): + super().__init__( + name=name, + value=value, + dtype=dtype, + shape=shape, + timestamp=timestamp, + parent=parent, + labels=labels, + kind=kind, + tolerance=tolerance, + rtolerance=rtolerance, + metadata=metadata, + cl=cl, + attr_name=attr_name, ) - if nRet != self.ueye.IS_SUCCESS: - print("is_AOI ERROR") + self.roi = roi - self.width = self.rectAOI.s32Width - self.height = self.rectAOI.s32Height + def get(self, **kwargs): + image = self.parent.image_data.get().data + if not isinstance(image, np.ndarray): + return -1 # -1 if no valid image is available - # Prints out some information about the camera and the sensor - print("Camera model:\t\t", self.sInfo.strSensorName.decode("utf-8")) - print("Camera serial no.:\t", self.cInfo.SerNo.decode("utf-8")) - print("Maximum image width:\t", self.width) - print("Maximum image height:\t", self.height) - print() - - # --------------------------------------------------------------------------------------------------------------------------------------- - - # Allocates an image memory for an image having its dimensions defined by width and height and its color depth defined by nBitsPerPixel - nRet = self.ueye.is_AllocImageMem( - self.hCam, self.width, self.height, self.nBitsPerPixel, self.pcImageMemory, self.MemID - ) - if nRet != self.ueye.IS_SUCCESS: - print("is_AllocImageMem ERROR") + if self.roi is None: + roi = (0, 0, image.shape[1], image.shape[0]) else: - # Makes the specified image memory the active memory - nRet = self.ueye.is_SetImageMem(self.hCam, self.pcImageMemory, self.MemID) - if nRet != self.ueye.IS_SUCCESS: - print("is_SetImageMem ERROR") - else: - # Set the desired color mode - nRet = self.ueye.is_SetColorMode(self.hCam, self.m_nColorMode) - - # Activates the camera's live video mode (free run mode) - nRet = self.ueye.is_CaptureVideo(self.hCam, self.ueye.IS_DONT_WAIT) - if nRet != self.ueye.IS_SUCCESS: - print("is_CaptureVideo ERROR") - - # Enables the queue mode for existing image memory sequences - nRet = self.ueye.is_InquireImageMem( - self.hCam, - self.pcImageMemory, - self.MemID, - self.width, - self.height, - self.nBitsPerPixel, - self.pitch, - ) - if nRet != self.ueye.IS_SUCCESS: - print("is_InquireImageMem ERROR") - else: - print("Press q to leave the programm") - startmeasureframerate = True - Gain = False - - # Start live mode of camera immediately - self.parent.start_live_mode() - - def _start_data_thread(self): - self.thread_event = threading.Event() - self.data_thread = threading.Thread(target=self._receive_data_from_camera, daemon=True) - self.data_thread.start() - - def _receive_data_from_camera(self): - while not self.thread_event.is_set(): - - # In order to display the image in an OpenCV window we need to... - # ...extract the data of our image memory - array = ueye.get_data( - self.pcImageMemory, - self.width, - self.height, - self.nBitsPerPixel, - self.pitch, - copy=False, - ) - - # bytes_per_pixel = int(nBitsPerPixel / 8) - - # ...reshape it in an numpy array... - frame = np.reshape(array, (self.height.value, self.width.value, self.bytes_per_pixel)) - self.parent.image_data.put(frame) - self.parent._run_subs(sub_type=self.parent.SUB_MONITOR, value=frame) - - time.sleep(0.1) - - def on_trigger(self): - pass - # self.parent._run_subs(sub_type=self.parent.SUB_MONITOR, value=self.parent.image_data.get()) + roi = self.roi + if len(image.shape) > 2: + image = np.sum(image, axis=2) # Convert to grayscale if it's a color image + return np.sum(image[roi[1] : roi[1] + roi[3], roi[0] : roi[0] + roi[2]], (0, 1)) -class IDSCamera(PSIDetectorBase): - USER_ACCESS = ["start_live_mode", "stop_live_mode"] +class IDSCamera(PSIDeviceBase): + """ " + #--------------------------------------------------------------------------------------------------------------------------------------- - custom_prepare_cls = IDSCustomPrepare + #Variables + hCam = ueye.HIDS(202) #0: first available camera; 1-254: The camera with the specified camera ID + sInfo = ueye.SENSORINFO() + cInfo = ueye.CAMINFO() + pcImageMemory = ueye.c_mem_p() + MemID = ueye.int() + rectAOI = ueye.IS_RECT() + pitch = ueye.INT() + nBitsPerPixel = ueye.INT(24) #24: bits per pixel for color mode; take 8 bits per pixel for monochrome + channels = 3 #3: channels for color mode(RGB); take 1 channel for monochrome + m_nColorMode = ueye.INT(1) # Y8/RGB16/RGB24/REG32 (1 for our color cameras) + bytes_per_pixel = int(nBitsPerPixel / 8) - image_data = Cpt(SetableSignal, value=np.empty((100, 100)), kind=Kind.omitted) + ids_cam + ... + """ - SUB_MONITOR = "device_monitor_2d" - _default_sub = SUB_MONITOR + USER_ACCESS = ["start_live_mode", "stop_live_mode", "set_roi", "width", "height"] + + image_data = Cpt(PreviewSignal, ndim=2, kind=Kind.omitted) + # roi_bot_left = Cpt(ROISignal, roi=(400, 525, 118, 105), kind=Kind.normal) + # roi_bot_right = Cpt(ROISignal, roi=(518, 525, 118, 105), kind=Kind.normal) + # roi_top_left = Cpt(ROISignal, roi=(400, 630, 118, 105), kind=Kind.normal) + # roi_top_right = Cpt(ROISignal, roi=(518, 630, 118, 105), kind=Kind.normal) + # roi_signal = Cpt(ROISignal, kind=Kind.normal, doc="Region of Interest signal") def __init__( self, @@ -234,19 +103,221 @@ class IDSCamera(PSIDetectorBase): channels: int, m_n_colormode: int, kind=None, - parent=None, device_manager=None, **kwargs, ): + super().__init__( - prefix=prefix, name=name, kind=kind, parent=parent, device_manager=device_manager, **kwargs + prefix=prefix, name=name, kind=kind, device_manager=device_manager, **kwargs ) self.camera_ID = camera_ID self.bits_per_pixel = bits_per_pixel + self.bytes_per_pixel = None self.channels = channels - self.m_n_colormode = m_n_colormode - #TODO fix connected and wait_for_connection - self.custom_prepare.on_connection_established() + self._m_n_colormode_input = m_n_colormode + self.m_n_colormode = None + self.ueye = ueye + self.h_cam = None + self.s_info = None + self.data_thread = None + self.c_info = None + self.pc_image_memory = None + self.mem_id = None + self.rect_aoi = None + self.pitch = None + self.n_bits_per_pixel = None + self.width = None + self.height = None + self.thread_event = threading.Event() + self.data_thread = None + self._roi: tuple | None = None # x, y, width, height + + def set_roi(self, x: int, y: int, width: int, height: int): + self._roi = (x, y, width, height) + + def start_backend(self): + if self.ueye is None: + raise ImportError("The pyueye library is not installed.") + self.h_cam = self.ueye.HIDS( + self.camera_ID + ) # 0: first available camera; 1-254: The camera with the specified camera ID + self.s_info = self.ueye.SENSORINFO() + self.c_info = self.ueye.CAMINFO() + self.pc_image_memory = self.ueye.c_mem_p() + self.mem_id = self.ueye.int() + self.rect_aoi = self.ueye.IS_RECT() + self.pitch = self.ueye.INT() + self.n_bits_per_pixel = self.ueye.INT( + self.bits_per_pixel + ) # 24: bits per pixel for color mode; take 8 bits per pixel for monochrome + self.m_n_colormode = self.ueye.INT( + self._m_n_colormode_input + ) # Y8/RGB16/RGB24/REG32 (1 for our color cameras) + self.bytes_per_pixel = int(self.n_bits_per_pixel / 8) + + # Starts the driver and establishes the connection to the camera + ret = self.ueye.is_InitCamera(self.h_cam, None) + if ret != self.ueye.IS_SUCCESS: + print("is_InitCamera ERROR") + + # Reads out the data hard-coded in the non-volatile camera memory and writes it to the data structure that c_info points to + ret = self.ueye.is_GetCameraInfo(self.h_cam, self.c_info) + if ret != self.ueye.IS_SUCCESS: + print("is_GetCameraInfo ERROR") + + # You can query additional information about the sensor type used in the camera + ret = self.ueye.is_GetSensorInfo(self.h_cam, self.s_info) + if ret != self.ueye.IS_SUCCESS: + print("is_GetSensorInfo ERROR") + + ret = self.ueye.is_ResetToDefault(self.h_cam) + if ret != self.ueye.IS_SUCCESS: + print("is_ResetToDefault ERROR") + + # Set display mode to DIB + ret = self.ueye.is_SetDisplayMode(self.h_cam, self.ueye.IS_SET_DM_DIB) + + # Set the right color mode + if ( + int.from_bytes(self.s_info.nColorMode.value, byteorder="big") + == self.ueye.IS_COLORMODE_BAYER + ): + # setup the color depth to the current windows setting + self.ueye.is_GetColorDepth(self.h_cam, self.n_bits_per_pixel, self.m_n_colormode) + bytes_per_pixel = int(self.n_bits_per_pixel / 8) + print("IS_COLORMODE_BAYER: ") + print("\tm_n_colormode: \t\t", self.m_n_colormode) + print("\tn_bits_per_pixel: \t\t", self.n_bits_per_pixel) + print("\tbytes_per_pixel: \t\t", bytes_per_pixel) + print() + + elif ( + int.from_bytes(self.s_info.nColorMode.value, byteorder="big") + == self.ueye.IS_COLORMODE_CBYCRY + ): + # for color camera models use RGB32 mode + m_n_colormode = self.ueye.IS_CM_BGRA8_PACKED + n_bits_per_pixel = self.ueye.INT(32) + bytes_per_pixel = int(self.n_bits_per_pixel / 8) + print("IS_COLORMODE_CBYCRY: ") + print("\tm_n_colormode: \t\t", m_n_colormode) + print("\tn_bits_per_pixel: \t\t", n_bits_per_pixel) + print("\tbytes_per_pixel: \t\t", bytes_per_pixel) + print() + + elif ( + int.from_bytes(self.s_info.nColorMode.value, byteorder="big") + == self.ueye.IS_COLORMODE_MONOCHROME + ): + # for color camera models use RGB32 mode + m_n_colormode = self.ueye.IS_CM_MONO8 + n_bits_per_pixel = self.ueye.INT(8) + bytes_per_pixel = int(n_bits_per_pixel / 8) + print("IS_COLORMODE_MONOCHROME: ") + print("\tm_n_colormode: \t\t", m_n_colormode) + print("\tn_bits_per_pixel: \t\t", n_bits_per_pixel) + print("\tbytes_per_pixel: \t\t", bytes_per_pixel) + print() + + else: + # for monochrome camera models use Y8 mode + m_n_colormode = self.ueye.IS_CM_MONO8 + n_bits_per_pixel = self.ueye.INT(8) + bytes_per_pixel = int(n_bits_per_pixel / 8) + print("else") + + # Can be used to set the size and position of an "area of interest"(AOI) within an image + ret = self.ueye.is_AOI( + self.h_cam, + self.ueye.IS_AOI_IMAGE_GET_AOI, + self.rect_aoi, + self.ueye.sizeof(self.rect_aoi), + ) + if ret != self.ueye.IS_SUCCESS: + print("is_AOI ERROR") + + self.width = self.rect_aoi.s32Width + self.height = self.rect_aoi.s32Height + + # Prints out some information about the camera and the sensor + print("Camera model:\t\t", self.s_info.strSensorName.decode("utf-8")) + print("Camera serial no.:\t", self.c_info.SerNo.decode("utf-8")) + print("Maximum image width:\t", self.width) + print("Maximum image height:\t", self.height) + print() + + # --------------------------------------------------------------------------------------------------------------------------------------- + + # Allocates an image memory for an image having its dimensions defined by width and height and its color depth defined by n_bits_per_pixel + ret = self.ueye.is_AllocImageMem( + self.h_cam, + self.width, + self.height, + self.n_bits_per_pixel, + self.pc_image_memory, + self.mem_id, + ) + if ret != self.ueye.IS_SUCCESS: + print("is_AllocImageMem ERROR") + else: + # Makes the specified image memory the active memory + ret = self.ueye.is_SetImageMem(self.h_cam, self.pc_image_memory, self.mem_id) + if ret != self.ueye.IS_SUCCESS: + print("is_SetImageMem ERROR") + else: + # Set the desired color mode + ret = self.ueye.is_SetColorMode(self.h_cam, self.m_n_colormode) + + # Activates the camera's live video mode (free run mode) + ret = self.ueye.is_CaptureVideo(self.h_cam, self.ueye.IS_DONT_WAIT) + if ret != self.ueye.IS_SUCCESS: + print("is_CaptureVideo ERROR") + + # Enables the queue mode for existing image memory sequences + ret = self.ueye.is_InquireImageMem( + self.h_cam, + self.pc_image_memory, + self.mem_id, + self.width, + self.height, + self.n_bits_per_pixel, + self.pitch, + ) + if ret != self.ueye.IS_SUCCESS: + print("is_InquireImageMem ERROR") + else: + print("Press q to leave the programm") + # startmeasureframerate = True + # Gain = False + + # Start live mode of camera immediately + self.start_live_mode() + + def _start_data_thread(self): + self.data_thread = threading.Thread(target=self._receive_data_from_camera, daemon=True) + self.data_thread.start() + + def _receive_data_from_camera(self): + while not self.thread_event.is_set(): + if self.ueye is None: + print("pyueye library not available.") + return + # In order to display the image in an OpenCV window we need to... + # ...extract the data of our image memory + array = self.ueye.get_data( + self.pc_image_memory, + self.width, + self.height, + self.n_bits_per_pixel, + self.pitch, + copy=False, + ) + + # ...reshape it in an numpy array... + frame = np.reshape(array, (self.height.value, self.width.value, self.bytes_per_pixel)) + self.image_data.put(frame) + + time.sleep(0.1) def wait_for_connection(self, all_signals=False, timeout=10): if ueye is None: @@ -254,226 +325,73 @@ class IDSCamera(PSIDetectorBase): "The pyueye library is not installed or doesn't provide the necessary c libs" ) super().wait_for_connection(all_signals, timeout) - #self.custom_prepare.on_connection_established() - - def destroy(self): - """Extend Ophyds destroy function to kill the data thread""" - self.stop_live_mode() - super().destroy() def start_live_mode(self): - if self.custom_prepare.data_thread is not None: + if self.data_thread is not None: self.stop_live_mode() - self.custom_prepare._start_data_thread() + self._start_data_thread() def stop_live_mode(self): """Stopping the camera live mode.""" - if self.custom_prepare.thread_event is not None: - self.custom_prepare.thread_event.set() - if self.custom_prepare.data_thread is not None: - self.custom_prepare.data_thread.join() - self.custom_prepare.thread_event = None - self.custom_prepare.data_thread = None + self.thread_event.set() + if self.data_thread is not None: + self.data_thread.join() + self.thread_event.clear() + self.data_thread = None + + ######################################## + # Beamline Specific Implementations # + ######################################## + + def on_init(self) -> None: + """ + Called when the device is initialized. + + No signals are connected at this point. If you like to + set default values on signals, please use on_connected instead. + """ + + def on_connected(self) -> None: + """ + Called after the device is connected and its signals are connected. + Default values for signals should be set here. + """ + self.start_backend() + self.start_live_mode() + + def on_stage(self) -> DeviceStatus | StatusBase | None: + """ + Called while staging the device. + + Information about the upcoming scan can be accessed from the scan_info (self.scan_info.msg) object. + """ + + def on_unstage(self) -> DeviceStatus | StatusBase | None: + """Called while unstaging the device.""" + + def on_pre_scan(self) -> DeviceStatus | StatusBase | None: + """Called right before the scan starts on all devices automatically.""" + + def on_trigger(self) -> DeviceStatus | StatusBase | None: + """Called when the device is triggered.""" + + def on_complete(self) -> DeviceStatus | StatusBase | None: + """Called to inquire if a device has completed a scans.""" + + def on_kickoff(self) -> DeviceStatus | StatusBase | None: + """Called to kickoff a device for a fly scan. Has to be called explicitly.""" + + def on_stop(self) -> None: + """Called when the device is stopped.""" + + def on_destroy(self) -> None: + """Called when the device is destroyed. Cleanup resources here.""" + self.stop_live_mode() -"""from pyueye import ueye -import numpy as np -import cv2 -import sys -import time +if __name__ == "__main__": + # Example usage + camera = IDSCamera(name="camera", camera_ID=201, bits_per_pixel=24, channels=3, m_n_colormode=1) + camera.wait_for_connection() -#--------------------------------------------------------------------------------------------------------------------------------------- - -#Variables -hCam = ueye.HIDS(202) #0: first available camera; 1-254: The camera with the specified camera ID -sInfo = ueye.SENSORINFO() -cInfo = ueye.CAMINFO() -pcImageMemory = ueye.c_mem_p() -MemID = ueye.int() -rectAOI = ueye.IS_RECT() -pitch = ueye.INT() -nBitsPerPixel = ueye.INT(24) #24: bits per pixel for color mode; take 8 bits per pixel for monochrome -channels = 3 #3: channels for color mode(RGB); take 1 channel for monochrome -m_nColorMode = ueye.INT(1) # Y8/RGB16/RGB24/REG32 (1 for our color cameras) -bytes_per_pixel = int(nBitsPerPixel / 8) - -ids_cam - ... - deviceConfig: - camera_ID: 202 - bits_per_pixel: 24 - channels: 3 - m_n_colormode: 1 - -#--------------------------------------------------------------------------------------------------------------------------------------- -print("START") -print() - -# Starts the driver and establishes the connection to the camera -nRet = ueye.is_InitCamera(hCam, None) -if nRet != ueye.IS_SUCCESS: - print("is_InitCamera ERROR") - -# Reads out the data hard-coded in the non-volatile camera memory and writes it to the data structure that cInfo points to -nRet = ueye.is_GetCameraInfo(hCam, cInfo) -if nRet != ueye.IS_SUCCESS: - print("is_GetCameraInfo ERROR") - -# You can query additional information about the sensor type used in the camera -nRet = ueye.is_GetSensorInfo(hCam, sInfo) -if nRet != ueye.IS_SUCCESS: - print("is_GetSensorInfo ERROR") - -nRet = ueye.is_ResetToDefault( hCam) -if nRet != ueye.IS_SUCCESS: - print("is_ResetToDefault ERROR") - -# Set display mode to DIB -nRet = ueye.is_SetDisplayMode(hCam, ueye.IS_SET_DM_DIB) - - - -# Set the right color mode -if int.from_bytes(sInfo.nColorMode.value, byteorder='big') == ueye.IS_COLORMODE_BAYER: - # setup the color depth to the current windows setting - ueye.is_GetColorDepth(hCam, nBitsPerPixel, m_nColorMode) - bytes_per_pixel = int(nBitsPerPixel / 8) - print("IS_COLORMODE_BAYER: ", ) - print("\tm_nColorMode: \t\t", m_nColorMode) - print("\tnBitsPerPixel: \t\t", nBitsPerPixel) - print("\tbytes_per_pixel: \t\t", bytes_per_pixel) - print() - -elif int.from_bytes(sInfo.nColorMode.value, byteorder='big') == ueye.IS_COLORMODE_CBYCRY: - # for color camera models use RGB32 mode - m_nColorMode = ueye.IS_CM_BGRA8_PACKED - nBitsPerPixel = ueye.INT(32) - bytes_per_pixel = int(nBitsPerPixel / 8) - print("IS_COLORMODE_CBYCRY: ", ) - print("\tm_nColorMode: \t\t", m_nColorMode) - print("\tnBitsPerPixel: \t\t", nBitsPerPixel) - print("\tbytes_per_pixel: \t\t", bytes_per_pixel) - print() - -elif int.from_bytes(sInfo.nColorMode.value, byteorder='big') == ueye.IS_COLORMODE_MONOCHROME: - # for color camera models use RGB32 mode - m_nColorMode = ueye.IS_CM_MONO8 - nBitsPerPixel = ueye.INT(8) - bytes_per_pixel = int(nBitsPerPixel / 8) - print("IS_COLORMODE_MONOCHROME: ", ) - print("\tm_nColorMode: \t\t", m_nColorMode) - print("\tnBitsPerPixel: \t\t", nBitsPerPixel) - print("\tbytes_per_pixel: \t\t", bytes_per_pixel) - print() - -else: - # for monochrome camera models use Y8 mode - m_nColorMode = ueye.IS_CM_MONO8 - nBitsPerPixel = ueye.INT(8) - bytes_per_pixel = int(nBitsPerPixel / 8) - print("else") - -# Can be used to set the size and position of an "area of interest"(AOI) within an image -nRet = ueye.is_AOI(hCam, ueye.IS_AOI_IMAGE_GET_AOI, rectAOI, ueye.sizeof(rectAOI)) -if nRet != ueye.IS_SUCCESS: - print("is_AOI ERROR") - -width = rectAOI.s32Width -height = rectAOI.s32Height - -# Prints out some information about the camera and the sensor -print("Camera model:\t\t", sInfo.strSensorName.decode('utf-8')) -print("Camera serial no.:\t", cInfo.SerNo.decode('utf-8')) -print("Maximum image width:\t", width) -print("Maximum image height:\t", height) -print() - -#--------------------------------------------------------------------------------------------------------------------------------------- - -# Allocates an image memory for an image having its dimensions defined by width and height and its color depth defined by nBitsPerPixel -nRet = ueye.is_AllocImageMem(hCam, width, height, nBitsPerPixel, pcImageMemory, MemID) -if nRet != ueye.IS_SUCCESS: - print("is_AllocImageMem ERROR") -else: - # Makes the specified image memory the active memory - nRet = ueye.is_SetImageMem(hCam, pcImageMemory, MemID) - if nRet != ueye.IS_SUCCESS: - print("is_SetImageMem ERROR") - else: - # Set the desired color mode - nRet = ueye.is_SetColorMode(hCam, m_nColorMode) - - - -# Activates the camera's live video mode (free run mode) -nRet = ueye.is_CaptureVideo(hCam, ueye.IS_DONT_WAIT) -if nRet != ueye.IS_SUCCESS: - print("is_CaptureVideo ERROR") - -# Enables the queue mode for existing image memory sequences -nRet = ueye.is_InquireImageMem(hCam, pcImageMemory, MemID, width, height, nBitsPerPixel, pitch) -if nRet != ueye.IS_SUCCESS: - print("is_InquireImageMem ERROR") -else: - print("Press q to leave the programm") -startmeasureframerate=True -Gain = False -#--------------------------------------------------------------------------------------------------------------------------------------- - -# Continuous image display -while(nRet == ueye.IS_SUCCESS): - - # In order to display the image in an OpenCV window we need to... - # ...extract the data of our image memory - array = ueye.get_data(pcImageMemory, width, height, nBitsPerPixel, pitch, copy=False) - - # bytes_per_pixel = int(nBitsPerPixel / 8) - - # ...reshape it in an numpy array... - frame = np.reshape(array,(height.value, width.value, bytes_per_pixel)) - - # ...resize the image by a half - frame = cv2.resize(frame,(0,0),fx=0.5, fy=0.5) - -#--------------------------------------------------------------------------------------------------------------------------------------- - #Include image data processing here - -#--------------------------------------------------------------------------------------------------------------------------------------- - - #...and finally display it - cv2.imshow("SimpleLive_Python_uEye_OpenCV", frame) - if startmeasureframerate: - starttime = time.time() - startmeasureframerate=False - framenumber=0 - if time.time() > starttime+5: - print(f"Caught {framenumber/5} frames per second") - startmeasureframerate=True - Gain = ~Gain - if Gain: - nRet = ueye.is_SetGainBoost(hCam, 1) - else: - nRet = ueye.is_SetGainBoost(hCam, 0) - print(f"Gain setting status {nRet}") - #...and finally display it - cv2.imshow("SimpleLive_Python_uEye_OpenCV", frame) - framenumber+=1 - time.sleep(0.1) - - # Press q if you want to end the loop - if (cv2.waitKey(1) & 0xFF) == ord('q'): - break -#--------------------------------------------------------------------------------------------------------------------------------------- - -# Releases an image memory that was allocated using is_AllocImageMem() and removes it from the driver management -ueye.is_FreeImageMem(hCam, pcImageMemory, MemID) - -# Disables the hCam camera handle and releases the data structures and memory areas taken up by the uEye camera -ueye.is_ExitCamera(hCam) - -# Destroys the OpenCv windows -cv2.destroyAllWindows() - -print() -print("END") -""" + camera.on_destroy() diff --git a/csaxs_bec/devices/ids_cameras/ids_camera_new.py b/csaxs_bec/devices/ids_cameras/ids_camera_new.py new file mode 100644 index 0000000..0fe2e5e --- /dev/null +++ b/csaxs_bec/devices/ids_cameras/ids_camera_new.py @@ -0,0 +1,285 @@ +"""IDS Camera class for cSAXS IDS cameras.""" + +from __future__ import annotations + +import threading +import time +from typing import TYPE_CHECKING, Literal, Tuple, TypedDict + +import numpy as np +from bec_lib import messages +from bec_lib.logger import bec_logger +from ophyd import Component as Cpt +from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase +from ophyd_devices.utils.bec_signals import AsyncSignal, PreviewSignal +from pydantic import BaseModel, field_validator + +from csaxs_bec.devices.ids_cameras.base_integration.camera import Camera + +if TYPE_CHECKING: + from bec_lib.devicemanager import ScanInfo + from pydantic import ValidationInfo + + +logger = bec_logger.logger + + +AnyDimShape = Tuple[int, ...] + + +class ROISpec(TypedDict): + """ + Typed dictionary representing the specification for a Region of Interest (ROI). + """ + + x: int + y: int + width: int + height: int + img_shape: AnyDimShape + mask: np.ndarray + + +def validate_roi(roi: ROISpec) -> ROISpec: + """ + Validate the ROI specification to ensure it matches the image shape. + + Args: + roi (ROISpec): The ROI specification to validate. + + Returns: + ROISpec: The validated ROI specification. + """ + if roi["mask"].shape != roi["img_shape"]: + raise ValueError( + f"Mask shape {roi['mask'].shape} does not match image shape {roi['img_shape']}." + ) + return roi + + +class IDSCamera(PSIDeviceBase): + """IDS Camera class for cSAXS. + + This class inherits from PSIDeviceBase and implements the necessary methods + to interact with the IDS camera using the pyueye library. + """ + + image = Cpt(PreviewSignal, name="image", ndim=2, doc="Preview signal for the camera.") + roi_signal = Cpt( + AsyncSignal, + name="roi_signal", + ndim=0, + max_size=1000, + doc="Signal for the region of interest (ROI).", + async_update={"type": "add", "max_shape": [None]}, + ) + + USER_ACCESS = ["live_mode", "roi", "get_last_image"] + + def __init__( + self, + *, + name: str, + camera_id: int, + prefix: str = "", + scan_info: ScanInfo | None = None, + m_n_colormode: Literal[0, 1, 2, 3] = 1, + bits_per_pixel: Literal[8, 24] = 24, + live_mode: bool = False, + roi: tuple[int, int, int, int] | None = None, + **kwargs, + ): + """Initialize the IDS Camera. + + Args: + name (str): Name of the device. + camera_id (int): The ID of the camera device. + prefix (str): Prefix for the device. + scan_info (ScanInfo | None): Scan information for the device. + m_n_colormode (Literal[0, 1, 2, 3]): Color mode for the camera. + bits_per_pixel (Literal[8, 24]): Number of bits per pixel for the camera. + live_mode (bool): Whether to enable live mode for the camera. + """ + super().__init__(name=name, prefix=prefix, scan_info=scan_info, **kwargs) + self._live_mode_thread: threading.Thread | None = None + self._stop_live_mode_event: threading.Event = threading.Event() + self.cam = Camera( + camera_id=camera_id, + m_n_colormode=m_n_colormode, + bits_per_pixel=bits_per_pixel, + connect=False, + ) + self._live_mode = False + self._inputs = {"roi": roi if roi else (0, 0, 1, 1), "live_mode": live_mode} + self._roi: ROISpec = validate_roi( + ROISpec( + { + "x": 0, + "y": 0, + "width": 1, + "height": 1, + "img_shape": (1, 1), + "mask": np.zeros((1, 1), dtype=np.uint8), + } + ) + ) + + ############## Live Mode Methods ############## + + @property + def roi(self) -> ROISpec: + """Return the current region of interest (ROI) for the camera.""" + return self._roi + + @roi.setter + def roi(self, value: ROISpec | tuple[int, int, int, int] | list[int, int, int, int]): + """ + Set the region of interest (ROI) for the camera. + + Args: + value (ROI | tuple[int, int, int, int] | list[int, int, int, int]): Either an ROI object, or a tuple or list with x, y, width, and height. + """ + if isinstance(value, (tuple, list)) and len(value) == 4: + x = value[0] + y = value[1] + width = value[2] + height = value[3] + if x + width > self.cam.cam.width.value or y + height > self.cam.cam.height.value: + raise ValueError("ROI exceeds camera dimensions.") + img_shape = (self.cam.cam.height.value, self.cam.cam.width.value) + mask = np.zeros(img_shape, dtype=np.uint8) + mask[y : y + height, x : x + width] = 1 + value = validate_roi( + ROISpec( + { + "x": x, + "y": y, + "width": width, + "height": height, + "img_shape": img_shape, + "mask": mask, + } + ) + ) + if not isinstance(value, dict) or not all( + key in value for key in ["x", "y", "width", "height", "img_shape", "mask"] + ): + raise TypeError(f"ROI must be an instance of ROISpec {value}.") + self._roi = value + + @property + def live_mode(self) -> bool: + """Return whether the camera is in live mode.""" + return self._live_mode + + @live_mode.setter + def live_mode(self, value: bool): + """Set the live mode for the camera.""" + if value != self._live_mode: + if self.cam._connected is False: # $ pylint: disable=protected-access + self.cam.on_connect() + self._live_mode = value + if value: + self._start_live() + else: + self._stop_live() + + def _start_live(self): + """Start the live mode for the camera.""" + if self._live_mode_thread is not None: + logger.info("Live mode thread is already running.") + return + self._stop_live_mode_event.clear() + self._live_mode_thread = threading.Thread( + target=self._live_mode_loop, args=(self._stop_live_mode_event,) + ) + self._live_mode_thread.start() + + def _stop_live(self): + """Stop the live mode for the camera.""" + if self._live_mode_thread is None: + logger.info("Live mode thread is not running.") + return + self._stop_live_mode_event.set() + self._live_mode_thread.join(timeout=5) + if self._live_mode_thread.is_alive(): + logger.warning("Live mode thread did not stop gracefully.") + else: + self._live_mode_thread = None + logger.info("Live mode stopped.") + + def _live_mode_loop(self, stop_event: threading.Event): + """Loop to capture images in live mode.""" + while not stop_event.is_set(): + try: + self.process_data(self.cam.get_image_data()) + except Exception as e: + logger.error(f"Error in live mode loop: {e}") + break + stop_event.wait(0.2) # 5 Hz + + def process_data(self, image: np.ndarray | None): + """Process the image data before sending it to the preview signal.""" + if image is None: + return + self.image.put(image) + + def get_last_image(self) -> np.ndarray: + image = self.image.get() + if image: + return image.data + + ############## User Interface Methods ############## + + def on_connected(self): + """Connect to the camera.""" + self.cam.on_connect() + self.live_mode = self._inputs.get("live_mode", None) + roi = self._inputs.get("roi", None) + if roi is None or not isinstance(roi, (tuple, list)) or not len(roi) == 4: + # If ROI is not set, use the full camera resolution + roi = (0, 0, self.cam.cam.width.value, self.cam.cam.height.value) + self.roi = roi + + def on_destroy(self): + """Clean up resources when the device is destroyed.""" + self.cam.on_disconnect() + super().on_destroy() + + def on_trigger(self): + """Handle the trigger event.""" + if not self.live_mode: + return + image = self.image.get() + if image is not None: + image: messages.DevicePreviewMessage + if self.roi["img_shape"][0:2] != image.data.shape[0:2]: + logger.info( + f"ROI shape does not match image shape, skipping ROI application for device {self.name}." + ) + return + + if len(image.data.shape) == 3: + # If the image has multiple channels, apply the mask to each channel + data = ( + image.data * self.roi["mask"][:, :, np.newaxis] + ) # Apply mask to the image data + n_channels = 3 + else: + data = image.data * self.roi["mask"] + n_channels = 1 + self.roi_signal.put( + { + self.roi_signal.name: { + "value": np.sum(data) + / (np.sum(self.roi["mask"]) * n_channels), # TODO can be optimized + "timestamp": time.time(), + } + } + ) + + +if __name__ == "__main__": + # Example usage of the IDSCamera class + camera = IDSCamera(name="TestCamera", camera_id=201, live_mode=False) + print(f"Camera {camera.name} initialized with ID {camera.cam.camera_id}.") diff --git a/csaxs_bec/devices/ids_cameras/ids_ueye_signals.py b/csaxs_bec/devices/ids_cameras/ids_ueye_signals.py deleted file mode 100644 index 9c1c215..0000000 --- a/csaxs_bec/devices/ids_cameras/ids_ueye_signals.py +++ /dev/null @@ -1,83 +0,0 @@ -import time - -import numpy as np -from bec_lib import bec_logger -from ophyd import Kind, Signal -from ophyd.utils import ReadOnlyError - -from ophyd_devices.utils.bec_device_base import BECDeviceBase - -logger = bec_logger.logger - -# Readout precision for Setable/ReadOnlySignal signals -PRECISION = 3 - - -class ReadOnlySignal(Signal): - """Setable signal for simulated devices. - - The signal will store the value in sim_state of the SimulatedData class of the parent device. - It will also return the value from sim_state when get is called. Compared to the ReadOnlySignal, - this signal can be written to. - The setable signal inherits from the Signal class of ophyd, thus the class attribute needs to be - initiated as a Component (class from ophyd). - - >>> signal = SetableSignal(name="signal", parent=parent, value=0) - - Parameters - ---------- - - name (string) : Name of the signal - parent (object) : Parent object of the signal, default none. - value (any) : Initial value of the signal, default 0. - kind (int) : Kind of the signal, default Kind.normal. - precision (float) : Precision of the signal, default PRECISION. - """ - - def __init__( - self, - name: str, - *args, - fcn: callable, - kind: int = Kind.normal, - precision: float = PRECISION, - **kwargs, - ): - super().__init__(*args, name=name, value=value, kind=kind, **kwargs) - self._metadata.update(connected=True, write_access=False) - self._value = None - self.precision = precision - self.fcn = fcn - - # pylint: disable=arguments-differ - def get(self): - """Get the current position of the simulated device. - - Core function for signal. - """ - self._value = self.fcn() - return self._value - - # pylint: disable=arguments-differ - def put(self, value): - """Put the value to the simulated device. - - Core function for signal. - """ - self._update_sim_state(value) - self._value = value - - def describe(self): - """Describe the readback signal. - - Core function for signal. - """ - res = super().describe() - if self.precision is not None: - res[self.name]["precision"] = self.precision - return res - - @property - def timestamp(self): - """Timestamp of the readback value""" - return self._get_timestamp() -- 2.49.1 From 9e45e927a0799bd1ba25989087cf56ebee81d165 Mon Sep 17 00:00:00 2001 From: appel_c Date: Tue, 5 Aug 2025 10:33:29 +0200 Subject: [PATCH 13/23] test(ids-camera): add tests for the IDSCamera integration --- .../devices/ids_cameras/ids_camera_new.py | 123 +++++------------- tests/tests_devices/test_ids_camera.py | 88 +++++++++++++ 2 files changed, 120 insertions(+), 91 deletions(-) create mode 100644 tests/tests_devices/test_ids_camera.py diff --git a/csaxs_bec/devices/ids_cameras/ids_camera_new.py b/csaxs_bec/devices/ids_cameras/ids_camera_new.py index 0fe2e5e..17abdab 100644 --- a/csaxs_bec/devices/ids_cameras/ids_camera_new.py +++ b/csaxs_bec/devices/ids_cameras/ids_camera_new.py @@ -12,7 +12,6 @@ from bec_lib.logger import bec_logger from ophyd import Component as Cpt from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase from ophyd_devices.utils.bec_signals import AsyncSignal, PreviewSignal -from pydantic import BaseModel, field_validator from csaxs_bec.devices.ids_cameras.base_integration.camera import Camera @@ -24,39 +23,6 @@ if TYPE_CHECKING: logger = bec_logger.logger -AnyDimShape = Tuple[int, ...] - - -class ROISpec(TypedDict): - """ - Typed dictionary representing the specification for a Region of Interest (ROI). - """ - - x: int - y: int - width: int - height: int - img_shape: AnyDimShape - mask: np.ndarray - - -def validate_roi(roi: ROISpec) -> ROISpec: - """ - Validate the ROI specification to ensure it matches the image shape. - - Args: - roi (ROISpec): The ROI specification to validate. - - Returns: - ROISpec: The validated ROI specification. - """ - if roi["mask"].shape != roi["img_shape"]: - raise ValueError( - f"Mask shape {roi['mask'].shape} does not match image shape {roi['img_shape']}." - ) - return roi - - class IDSCamera(PSIDeviceBase): """IDS Camera class for cSAXS. @@ -74,7 +40,7 @@ class IDSCamera(PSIDeviceBase): async_update={"type": "add", "max_shape": [None]}, ) - USER_ACCESS = ["live_mode", "roi", "get_last_image"] + USER_ACCESS = ["live_mode", "roi", "set_rect_roi", "get_last_image"] def __init__( self, @@ -86,7 +52,6 @@ class IDSCamera(PSIDeviceBase): m_n_colormode: Literal[0, 1, 2, 3] = 1, bits_per_pixel: Literal[8, 24] = 24, live_mode: bool = False, - roi: tuple[int, int, int, int] | None = None, **kwargs, ): """Initialize the IDS Camera. @@ -110,62 +75,32 @@ class IDSCamera(PSIDeviceBase): connect=False, ) self._live_mode = False - self._inputs = {"roi": roi if roi else (0, 0, 1, 1), "live_mode": live_mode} - self._roi: ROISpec = validate_roi( - ROISpec( - { - "x": 0, - "y": 0, - "width": 1, - "height": 1, - "img_shape": (1, 1), - "mask": np.zeros((1, 1), dtype=np.uint8), - } - ) - ) + self._inputs = {"live_mode": live_mode} + self._mask = np.zeros((1, 1), dtype=np.uint8) ############## Live Mode Methods ############## @property - def roi(self) -> ROISpec: + def mask(self) -> np.ndarray: """Return the current region of interest (ROI) for the camera.""" - return self._roi + return self._mask - @roi.setter - def roi(self, value: ROISpec | tuple[int, int, int, int] | list[int, int, int, int]): + @mask.setter + def mask(self, value: np.ndarray): """ Set the region of interest (ROI) for the camera. Args: - value (ROI | tuple[int, int, int, int] | list[int, int, int, int]): Either an ROI object, or a tuple or list with x, y, width, and height. + value (np.ndarray): The mask to set as the ROI. """ - if isinstance(value, (tuple, list)) and len(value) == 4: - x = value[0] - y = value[1] - width = value[2] - height = value[3] - if x + width > self.cam.cam.width.value or y + height > self.cam.cam.height.value: - raise ValueError("ROI exceeds camera dimensions.") - img_shape = (self.cam.cam.height.value, self.cam.cam.width.value) - mask = np.zeros(img_shape, dtype=np.uint8) - mask[y : y + height, x : x + width] = 1 - value = validate_roi( - ROISpec( - { - "x": x, - "y": y, - "width": width, - "height": height, - "img_shape": img_shape, - "mask": mask, - } - ) + if value.ndim != 2: + raise ValueError("ROI mask must be a 2D array.") + img_shape = (self.cam.cam.height.value, self.cam.cam.width.value) + if value.shape[0] != img_shape[0] or value.shape[1] != img_shape[1]: + raise ValueError( + f"ROI mask shape {value.shape} does not match image shape {img_shape}." ) - if not isinstance(value, dict) or not all( - key in value for key in ["x", "y", "width", "height", "img_shape", "mask"] - ): - raise TypeError(f"ROI must be an instance of ROISpec {value}.") - self._roi = value + self._mask = value @property def live_mode(self) -> bool: @@ -184,6 +119,17 @@ class IDSCamera(PSIDeviceBase): else: self._stop_live() + def set_rect_roi(self, x: int, y: int, width: int, height: int): + """Set the rectangular region of interest (ROI) for the camera.""" + if x < 0 or y < 0 or width <= 0 or height <= 0: + raise ValueError("ROI coordinates and dimensions must be positive integers.") + img_shape = (self.cam.cam.height.value, self.cam.cam.width.value) + if x + width > img_shape[0] or y + height > img_shape[1]: + raise ValueError("ROI exceeds camera dimensions.") + mask = np.zeros(img_shape, dtype=np.uint8) + mask[y : y + height, x : x + width] = 1 + self.mask = mask + def _start_live(self): """Start the live mode for the camera.""" if self._live_mode_thread is not None: @@ -225,6 +171,7 @@ class IDSCamera(PSIDeviceBase): self.image.put(image) def get_last_image(self) -> np.ndarray: + """Get the last captured image from the camera.""" image = self.image.get() if image: return image.data @@ -235,11 +182,7 @@ class IDSCamera(PSIDeviceBase): """Connect to the camera.""" self.cam.on_connect() self.live_mode = self._inputs.get("live_mode", None) - roi = self._inputs.get("roi", None) - if roi is None or not isinstance(roi, (tuple, list)) or not len(roi) == 4: - # If ROI is not set, use the full camera resolution - roi = (0, 0, self.cam.cam.width.value, self.cam.cam.height.value) - self.roi = roi + self.set_rect_roi(0, 0, self.cam.cam.width.value, self.cam.cam.height.value) def on_destroy(self): """Clean up resources when the device is destroyed.""" @@ -253,7 +196,7 @@ class IDSCamera(PSIDeviceBase): image = self.image.get() if image is not None: image: messages.DevicePreviewMessage - if self.roi["img_shape"][0:2] != image.data.shape[0:2]: + if self.mask.shape[0:2] != image.data.shape[0:2]: logger.info( f"ROI shape does not match image shape, skipping ROI application for device {self.name}." ) @@ -261,18 +204,16 @@ class IDSCamera(PSIDeviceBase): if len(image.data.shape) == 3: # If the image has multiple channels, apply the mask to each channel - data = ( - image.data * self.roi["mask"][:, :, np.newaxis] - ) # Apply mask to the image data + data = image.data * self.mask[:, :, np.newaxis] # Apply mask to the image data n_channels = 3 else: - data = image.data * self.roi["mask"] + data = image.data * self.mask n_channels = 1 self.roi_signal.put( { self.roi_signal.name: { "value": np.sum(data) - / (np.sum(self.roi["mask"]) * n_channels), # TODO can be optimized + / (np.sum(self.mask) * n_channels), # TODO could be optimized "timestamp": time.time(), } } diff --git a/tests/tests_devices/test_ids_camera.py b/tests/tests_devices/test_ids_camera.py new file mode 100644 index 0000000..a1b51dc --- /dev/null +++ b/tests/tests_devices/test_ids_camera.py @@ -0,0 +1,88 @@ +"""Unit tests for the IDS Camera device.""" + +from unittest import mock + +import numpy as np +import pytest + +from csaxs_bec.devices.ids_cameras.ids_camera_new import IDSCamera + + +@pytest.fixture(scope="function") +def ids_camera(): + """Fixture for creating an instance of the IDSCamera.""" + camera = IDSCamera( + name="test_camera", + camera_id=1, + prefix="test:", + scan_info=None, + m_n_colormode=1, + bits_per_pixel=24, + live_mode=False, + ) + # Mock camera connection and attributes + camera.cam = mock.Mock() + camera.cam._connected = True + camera.cam.cam = mock.Mock() + camera.cam.cam.width.value = 2 + camera.cam.cam.height.value = 2 + yield camera + + +def test_mask_setter_getter(ids_camera): + """Test the mask setter and getter methods.""" + mask = np.zeros((2, 2), dtype=np.uint8) + mask[0, 0] = 1 + ids_camera.mask = mask + assert np.array_equal(ids_camera.mask, mask) + + +def test_mask_setter_invalid_shape(ids_camera): + """Test the mask setter with an invalid shape.""" + with pytest.raises(ValueError): + ids_camera.mask = np.zeros((3, 3), dtype=np.uint8) # Exceeds mocked camera dimensions + + +def test_on_connected_sets_mask_and_live_mode(ids_camera): + """Test the on_connected method to ensure it sets the mask and live mode.""" + ids_camera.cam.on_connect = mock.Mock() + ids_camera.on_connected() + ids_camera.cam.on_connect.assert_called_once() + expected_mask = np.ones((2, 2), dtype=np.uint8) + assert np.array_equal(ids_camera.mask, expected_mask) + + +def test_on_trigger_roi_signal(ids_camera): + """Test the on_trigger method to ensure it processes the ROI signal correctly.""" + ids_camera.live_mode = True + test_image = np.array([[2, 4], [6, 8]]) + test_mask = np.array([[1, 0], [0, 1]], dtype=np.uint8) + ids_camera.mask = test_mask + mock_image = mock.Mock() + mock_image.data = test_image + ids_camera.image.get = mock.Mock(return_value=mock_image) + ids_camera.roi_signal.put = mock.Mock(side_effect=ids_camera.roi_signal.put) + ids_camera.on_trigger() + expected_value = (2 * 1 + 4 * 0 + 6 * 0 + 8 * 1) / (np.sum(test_mask) * 1) + result = ids_camera.roi_signal.get() + assert np.isclose( + result.content["signals"][ids_camera.roi_signal.name]["value"], expected_value, atol=1e-6 + ) + + +def test_get_last_image(ids_camera): + """Test the get_last_image method to ensure it returns the last captured image.""" + test_image = np.array([[1, 2], [3, 4]], dtype=np.uint8) + mock_image = mock.Mock() + mock_image.data = test_image + ids_camera.image.get = mock.Mock(return_value=mock_image) + + result = ids_camera.get_last_image() + assert np.array_equal(result, test_image) + + +def test_on_destroy(ids_camera): + """Test the on_destroy method to ensure it cleans up resources.""" + ids_camera.cam.on_disconnect = mock.Mock() + ids_camera.on_destroy() + ids_camera.cam.on_disconnect.assert_called_once() -- 2.49.1 From 40d6acf431913bf41c2494f9f270e3df5d93ccbc Mon Sep 17 00:00:00 2001 From: gac-x12sa <> Date: Mon, 4 Aug 2025 15:19:27 +0200 Subject: [PATCH 14/23] refactor: change sl1 names following convention of controls --- csaxs_bec/device_configs/frontend.yaml | 8 ++++---- csaxs_bec/devices/ids_cameras/ids_camera_new.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/csaxs_bec/device_configs/frontend.yaml b/csaxs_bec/device_configs/frontend.yaml index 1dfbb07..966248b 100644 --- a/csaxs_bec/device_configs/frontend.yaml +++ b/csaxs_bec/device_configs/frontend.yaml @@ -41,7 +41,7 @@ sl1xr: description: 'slit 1 (frontend) x ring' deviceClass: ophyd.EpicsMotor deviceConfig: - prefix: 'X12SA-FE-SLDI1:TRXR' + prefix: 'X12SA-FE-SL1:TRXR' onFailure: raise enabled: true readoutPriority: baseline @@ -55,7 +55,7 @@ sl1xw: description: 'slit 1 (frontend) x wall' deviceClass: ophyd.EpicsMotor deviceConfig: - prefix: 'X12SA-FE-SLDI1:TRXW' + prefix: 'X12SA-FE-SL1:TRXW' onFailure: raise enabled: true readoutPriority: baseline @@ -69,7 +69,7 @@ sl1yb: description: 'slit 1 (frontend) y bottom' deviceClass: ophyd.EpicsMotor deviceConfig: - prefix: 'X12SA-FE-SLDI1:TRYB' + prefix: 'X12SA-FE-SL1:TRYB' onFailure: raise enabled: true readoutPriority: baseline @@ -83,7 +83,7 @@ sl1yt: description: 'slit 1 (frontend) y top' deviceClass: ophyd.EpicsMotor deviceConfig: - prefix: 'X12SA-FE-SLDI1:TRYT' + prefix: 'X12SA-FE-SL1:TRYT' onFailure: raise enabled: true readoutPriority: baseline diff --git a/csaxs_bec/devices/ids_cameras/ids_camera_new.py b/csaxs_bec/devices/ids_cameras/ids_camera_new.py index 17abdab..ecb3f83 100644 --- a/csaxs_bec/devices/ids_cameras/ids_camera_new.py +++ b/csaxs_bec/devices/ids_cameras/ids_camera_new.py @@ -40,7 +40,7 @@ class IDSCamera(PSIDeviceBase): async_update={"type": "add", "max_shape": [None]}, ) - USER_ACCESS = ["live_mode", "roi", "set_rect_roi", "get_last_image"] + USER_ACCESS = ["live_mode", "mask", "set_rect_roi", "get_last_image"] def __init__( self, @@ -124,7 +124,7 @@ class IDSCamera(PSIDeviceBase): if x < 0 or y < 0 or width <= 0 or height <= 0: raise ValueError("ROI coordinates and dimensions must be positive integers.") img_shape = (self.cam.cam.height.value, self.cam.cam.width.value) - if x + width > img_shape[0] or y + height > img_shape[1]: + if x + width > img_shape[1] or y + height > img_shape[0]: raise ValueError("ROI exceeds camera dimensions.") mask = np.zeros(img_shape, dtype=np.uint8) mask[y : y + height, x : x + width] = 1 -- 2.49.1 From a01593fa4b42d4cfe78b3598a2ed76796e8994b8 Mon Sep 17 00:00:00 2001 From: appel_c Date: Tue, 5 Aug 2025 10:43:20 +0200 Subject: [PATCH 15/23] refactor(ids_camera): add deprecation warning for ids_camera --- csaxs_bec/devices/ids_cameras/__init__.py | 1 + csaxs_bec/devices/ids_cameras/base_integration/utils.py | 3 +++ csaxs_bec/devices/ids_cameras/ids_camera.py | 6 ++++++ 3 files changed, 10 insertions(+) diff --git a/csaxs_bec/devices/ids_cameras/__init__.py b/csaxs_bec/devices/ids_cameras/__init__.py index e69de29..a16d89e 100644 --- a/csaxs_bec/devices/ids_cameras/__init__.py +++ b/csaxs_bec/devices/ids_cameras/__init__.py @@ -0,0 +1 @@ +from .ids_camera_new import IDSCamera diff --git a/csaxs_bec/devices/ids_cameras/base_integration/utils.py b/csaxs_bec/devices/ids_cameras/base_integration/utils.py index c059cab..0128d52 100644 --- a/csaxs_bec/devices/ids_cameras/base_integration/utils.py +++ b/csaxs_bec/devices/ids_cameras/base_integration/utils.py @@ -240,6 +240,9 @@ if ueye is not None: ueye.IS_CM_UYVY_BAYER_PACKED: 16, ueye.IS_CM_CBYCRY_PACKED: 16, } +else: + error_codes = {} + bits_per_pixel = {} def get_bits_per_pixel(color_mode): diff --git a/csaxs_bec/devices/ids_cameras/ids_camera.py b/csaxs_bec/devices/ids_cameras/ids_camera.py index 1b7fe7f..1250927 100644 --- a/csaxs_bec/devices/ids_cameras/ids_camera.py +++ b/csaxs_bec/devices/ids_cameras/ids_camera.py @@ -2,11 +2,14 @@ import threading import time import numpy as np +from bec_lib.logger import bec_logger from ophyd import Component as Cpt from ophyd import DeviceStatus, Kind, Signal, StatusBase from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase from ophyd_devices.utils.bec_signals import PreviewSignal +logger = bec_logger.logger + class ROISignal(Signal): """ @@ -131,6 +134,9 @@ class IDSCamera(PSIDeviceBase): self.thread_event = threading.Event() self.data_thread = None self._roi: tuple | None = None # x, y, width, height + logger.info( + f"Deprecation warning: The IDSCamera class is deprecated. Use the new IDSCameraNew class instead." + ) def set_roi(self, x: int, y: int, width: int, height: int): self._roi = (x, y, width, height) -- 2.49.1 From 3c9192d6a52774918aed560b3fc5912aac519abf Mon Sep 17 00:00:00 2001 From: appel_c Date: Tue, 5 Aug 2025 11:35:43 +0200 Subject: [PATCH 16/23] refactor(ddg2): add check for negative pulse widths. --- .../epics/delay_generator_csaxs/ddg_1.py | 22 +++++--- .../epics/delay_generator_csaxs/ddg_2.py | 4 ++ .../delay_generator_csaxs.py | 56 +++++++++++++++++-- .../devices/epics/mcs_card/mcs_card_csaxs.py | 2 +- 4 files changed, 69 insertions(+), 15 deletions(-) diff --git a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py index f5dde68..ea0e348 100644 --- a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py @@ -42,6 +42,7 @@ from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase from csaxs_bec.devices.epics.delay_generator_csaxs.delay_generator_csaxs import ( CHANNELREFERENCE, OUTPUTPOLARITY, + PROC_EVENT_MODE, STATUSBITS, TRIGGERSOURCE, AllChannelNames, @@ -122,6 +123,8 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): self.set_io_values(channel, **config) self.set_trigger(DEFAULT_TRIGGER_SOURCE) self.set_references_for_channels(DEFAULT_REFERENCES) + # Set proc status to passively update with 5Hz (0.2s) + self.state.proc_status_mode.put(PROC_EVENT_MODE.FREQ_5HZ) def on_stage(self) -> DeviceStatus | StatusBase | None: """ @@ -153,6 +156,8 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): If we don't then subsequent triggers may reach the DDG too early, and will be ignored. To avoid this, we've added the option to specify a delay via add_delay, default here is 50ms. """ + status = CompareStatus(self.state.event_status, STATUSBITS.NONE) + self.cancel_on_stop(status) mcs = self.device_manager.devices.get("mcs", None) if mcs is None: logger.info(f"Did not find mcs card in current session") @@ -172,22 +177,23 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): status_acquiring.wait( timeout=10 ) # 2 s wait for mcs card to start should be more than enough.. - - st = DeviceStatus(self) - self.cancel_on_stop(st) + status.wait(timeout=10) + # Default timeout of 5 seconds + exposure time * frames_per_trigger + timeout = 5 + self.scan_info.msg.scan_parameters.get( + "exp_time", 0.1 + ) * self.scan_info.msg.scan_parameters.get("frames_per_trigger", 1) + status = CompareStatus(self.state.event_status, STATUSBITS.END_OF_DELAY, timeout=timeout) + self.cancel_on_stop(status) self.trigger_shot.put(1, use_complete=True) - time.sleep(self.scan_info.msg.scan_parameters["exp_time"]) - self.cancel_on_stop(st) - status = self.wait_for_status(status=st, bit_event=STATUSBITS.END_OF_DELAY, timeout=10) return status def wait_for_status( - self, status: StatusBase, bit_event: STATUSBITS, timeout: float = 2 + self, status: DeviceStatus, bit_event: STATUSBITS, timeout: float = 2 ) -> None: """Wait for a event status bit to be set. Args: - status (StatusBase): The status object to update. + status (DeviceStatus): The status object to update. bit_event (STATUSBITS): The event status bit to wait for. timeout (float): Maximum time to wait for the event status bit to be set. """ diff --git a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_2.py b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_2.py index af84506..0e78b7f 100644 --- a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_2.py +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_2.py @@ -100,6 +100,10 @@ class DDG2(PSIDeviceBase, DelayGeneratorCSAXS): frames_per_trigger = self.scan_info.msg.scan_parameters["frames_per_trigger"] # a = t0 # a has reference to t0, b has reference to a + if any(exp_time < rt for rt in DEFAULT_READOUT_TIMES.values()): + raise ValueError( + f"Exposure time {exp_time} is too short for the readout times {DEFAULT_READOUT_TIMES}" + ) burst_pulse_width = exp_time - DEFAULT_READOUT_TIMES["ab"] self.set_delay_pairs(channel="ab", delay=0, width=burst_pulse_width) self.burst_enable(count=frames_per_trigger, delay=0, period=exp_time) diff --git a/csaxs_bec/devices/epics/delay_generator_csaxs/delay_generator_csaxs.py b/csaxs_bec/devices/epics/delay_generator_csaxs/delay_generator_csaxs.py index 676ef48..526ce78 100644 --- a/csaxs_bec/devices/epics/delay_generator_csaxs/delay_generator_csaxs.py +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/delay_generator_csaxs.py @@ -6,8 +6,8 @@ https://www.thinksrs.com/downloads/pdfs/manuals/DG645m.pdf """ import enum -from typing import Literal, TypedDict import time +from typing import Literal, TypedDict from bec_lib.logger import bec_logger from ophyd import Component as Cpt @@ -76,9 +76,26 @@ class OUTPUTPOLARITY(enum.Enum): POSITIVE = 1 +class PROC_EVENT_MODE(int, enum.Enum): + """Read mode for MCS channels.""" + + PASSIVE = 0 + EVENT = 1 + IO_INTR = 2 + FREQ_0_1HZ = 3 + FREQ_0_2HZ = 4 + FREQ_0_5HZ = 5 + FREQ_1HZ = 6 + FREQ_2HZ = 7 + FREQ_5HZ = 8 + FREQ_10HZ = 9 + FREQ_100HZ = 10 + + class STATUSBITS(enum.IntFlag): """Bit flags for the status signal of the delay generator.""" + NONE = 0 << 0 # No status bits set. TRIG = 1 << 0 # Got a trigger. RATE = 1 << 1 # Got a trigger while a delay or burst was in progress. END_OF_DELAY = 1 << 2 # A delay cycle has completed. @@ -91,6 +108,7 @@ class STATUSBITS(enum.IntFlag): def describe(self) -> dict: """Return a description of the status bits.""" descriptions = { + STATUSBITS.NONE: "No status bits set.", STATUSBITS.TRIG: "Got a trigger.", STATUSBITS.RATE: "Got a trigger while a delay or burst was in progress.", STATUSBITS.END_OF_DELAY: "A delay cycle has completed.", @@ -114,7 +132,7 @@ class StatusBitsCompareStatus(SubscriptionStatus): *args, event_type=None, timeout: float | None = None, - add_delay:float|None = None, + add_delay: float | None = None, settle_time: float = 0, run: bool = True, **kwargs, @@ -137,9 +155,9 @@ class StatusBitsCompareStatus(SubscriptionStatus): """Callback for subscription status""" obj = kwargs.get("obj", None) if obj is None: - name = 'no object received' + name = "no object received" else: - name=obj.name + name = obj.name if any((STATUSBITS(value) & state) == state for state in self._raise_states): self.set_exception( ValueError( @@ -147,7 +165,7 @@ class StatusBitsCompareStatus(SubscriptionStatus): ) ) return False - if self._add_delay !=0: + if self._add_delay != 0: time.sleep(self._add_delay) return (STATUSBITS(value) & self._value) == self._value @@ -378,17 +396,24 @@ class DelayGeneratorEventStatus(Device): "EventStatusLI", name="event_status", kind=Kind.omitted, - auto_monitor=True, doc="Event status register for the delay generator", ) proc_status = Cpt( EpicsSignal, "EventStatusLI.PROC", name="proc_status", + auto_monitor=True, kind=Kind.omitted, doc="Poll and flush the latest event status register entry from the HW to the event_status signal", ) + proc_status_mode = Cpt( + EpicsSignal, + "EventStatusLI.SCAN", + kind=Kind.omitted, + doc="Readout mode for transferring data from status buffer to the event_status signal.", + ) + class DelayGeneratorCSAXS(Device): """ @@ -403,6 +428,13 @@ class DelayGeneratorCSAXS(Device): In addition, the io layer allows setting amplitude, offset and polarity for each pair. """ + # USER_ACCESS = [ + # "set_channel_reference", + # "set_references_for_channels", + # "set_io_values", + # "set_trigger", + # ] + _pv_timeout: float = 5 # Default timeout for PV operations in seconds # Front Panel @@ -686,11 +718,23 @@ class DelayGeneratorCSAXS(Device): }[channel] def set_channel_reference(self, channel: LiteralChannels, reference_channel: CHANNELREFERENCE): + """Set the reference channel for a specific channel. + + Args: + channel (LiteralChannels): The channel to set the reference for. + reference_channel (CHANNELREFERENCE): The reference channel to set. + """ self._get_literal_channel(channel).reference.put(reference_channel.value) def set_references_for_channels( self, channels_and_refs: list[tuple[LiteralChannels, CHANNELREFERENCE]] ): + """Set the reference channels for multiple channels. + + Args: + channels_and_refs (list[tuple[LiteralChannels, CHANNELREFERENCE]]): A list of + tuples where each tuple contains a channel and its corresponding reference channel. + """ for ch, ref in channels_and_refs: self.set_channel_reference(ch, ref) diff --git a/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py b/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py index e3d352c..35ff0a3 100644 --- a/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py +++ b/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py @@ -193,7 +193,7 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): self.counter_updated.append(signal.name) received_all_updates = set(self.counter_updated) == set(self.counter_mapping.keys()) if received_all_updates: - self.ready_to_read.put(1) # Reset happens from DDG class! + self.ready_to_read.put(READYTOREAD.DONE) # Reset happens from DDG class! self.counter_updated.clear() def _progress_update(self, value, **kwargs) -> None: -- 2.49.1 From a03e99d615ae219d0b79452d9d5cbf2242ce8107 Mon Sep 17 00:00:00 2001 From: appel_c Date: Tue, 5 Aug 2025 12:27:51 +0200 Subject: [PATCH 17/23] refactor(ddg): passiv readout of event_status register --- csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py | 2 +- tests/tests_devices/test_delay_generator_csaxs.py | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py index ea0e348..7b097f6 100644 --- a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py @@ -160,7 +160,7 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): self.cancel_on_stop(status) mcs = self.device_manager.devices.get("mcs", None) if mcs is None: - logger.info(f"Did not find mcs card in current session") + logger.info("Did not find mcs card with name 'mcs' in current session") else: mcs: MCSCardCSAXS status_ready_read = CompareStatus(mcs.ready_to_read, READYTOREAD.DONE) diff --git a/tests/tests_devices/test_delay_generator_csaxs.py b/tests/tests_devices/test_delay_generator_csaxs.py index 0817e9f..25d99ef 100644 --- a/tests/tests_devices/test_delay_generator_csaxs.py +++ b/tests/tests_devices/test_delay_generator_csaxs.py @@ -208,18 +208,20 @@ def test_ddg1_stage(mock_ddg1): def test_ddg1_trigger(mock_ddg1): """Test the on_trigger method of DDG1.""" - mock_ddg1.state.event_status._read_pv.mock_data = ( - 5 # STATUSBITS.END_OF_DELAY.value + STATUSBITS.TRIG.value - ) + mock_ddg1.state.event_status._read_pv.mock_data = STATUSBITS.NONE.value with mock.patch.object(mock_ddg1, "device_manager") as mock_device_manager: # TODO add device manager DMMock, and properly test logic for mcs triggering. mock_get = mock_device_manager.devices.get = mock.Mock(return_value=None) status = mock_ddg1.trigger() assert mock_get.call_args == mock.call("mcs", None) + assert status.done is False + assert status.success is False + assert mock_ddg1.trigger_shot.get() == 1 + mock_ddg1.state.event_status._read_pv.mock_data = STATUSBITS.END_OF_DELAY.value + status.wait(timeout=1) # Wait for the status to be done assert status.done is True assert status.success is True - assert mock_ddg1.trigger_shot.get() == 1 def test_ddg1_stop(mock_ddg1): -- 2.49.1 From c7e11eeabc502eccba9a9eca3afef6a1b55d74d6 Mon Sep 17 00:00:00 2001 From: appel_c Date: Tue, 5 Aug 2025 13:16:10 +0200 Subject: [PATCH 18/23] refactor(ddg): use threadpool with active polling for state --- csaxs_bec/device_configs/endstation.yaml | 2 +- .../epics/delay_generator_csaxs/ddg_1.py | 139 +++++++++++------- .../delay_generator_csaxs.py | 1 - .../test_delay_generator_csaxs.py | 7 +- 4 files changed, 91 insertions(+), 58 deletions(-) diff --git a/csaxs_bec/device_configs/endstation.yaml b/csaxs_bec/device_configs/endstation.yaml index 1bd8ef6..c9a2c20 100644 --- a/csaxs_bec/device_configs/endstation.yaml +++ b/csaxs_bec/device_configs/endstation.yaml @@ -32,7 +32,7 @@ mcs: ids_cam: description: IDS camera for live image acquisition - deviceClass: csaxs_bec.devices.ids_cameras.ids_camera_new.IDSCamera + deviceClass: csaxs_bec.devices.ids_cameras.IDSCamera deviceConfig: camera_id: 201 bits_per_pixel: 24 diff --git a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py index 7b097f6..f12a0b7 100644 --- a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py @@ -31,11 +31,14 @@ DELAY CHANNELS: from __future__ import annotations +import threading import time +from concurrent.futures import Future, ThreadPoolExecutor from typing import TYPE_CHECKING from bec_lib.logger import bec_logger -from ophyd import DeviceStatus, StatusBase +from ophyd import Component as Cpt +from ophyd import DeviceStatus, Signal, StatusBase from ophyd_devices import CompareStatus, TransitionStatus from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase @@ -48,6 +51,7 @@ from csaxs_bec.devices.epics.delay_generator_csaxs.delay_generator_csaxs import AllChannelNames, ChannelConfig, DelayGeneratorCSAXS, + StatusBitsCompareStatus, ) from csaxs_bec.devices.epics.mcs_card.mcs_card_csaxs import ACQUIRING, READYTOREAD @@ -110,6 +114,9 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): name=name, prefix=prefix, scan_info=scan_info, device_manager=device_manager, **kwargs ) self.device_manager = device_manager + self._threadpool = ThreadPoolExecutor(max_workers=1) + self._status_thread_event = threading.Event() + self._status_future = None # Future for the status job # pylint: disable=attribute-defined-outside-init def on_connected(self) -> None: @@ -124,7 +131,7 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): self.set_trigger(DEFAULT_TRIGGER_SOURCE) self.set_references_for_channels(DEFAULT_REFERENCES) # Set proc status to passively update with 5Hz (0.2s) - self.state.proc_status_mode.put(PROC_EVENT_MODE.FREQ_5HZ) + self.state.proc_status_mode.put(PROC_EVENT_MODE.EVENT) def on_stage(self) -> DeviceStatus | StatusBase | None: """ @@ -134,7 +141,8 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): This DDG is always not in burst mode. """ - self.burst_disable() + exp_time = self.scan_info.msg.scan_parameters["exp_time"] + self.burst_enable(1, 0, exp_time) exp_time = self.scan_info.msg.scan_parameters["exp_time"] frames_per_trigger = self.scan_info.msg.scan_parameters["frames_per_trigger"] # Trigger DDG2 @@ -151,73 +159,96 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): # e has refernce to d, f has reference to e self.set_delay_pairs(channel="ef", delay=0, width=1e-6) + def _prepare_mcs_on_trigger(self, mcs: MCSCardCSAXS) -> None: + """Prepare the MCS card for the next trigger. + This method holds the logic to ensure that the MCS card is ready to read. + It's logic is coupled to the MCS card implementation and the DDG1 trigger logic. + """ + status_ready_read = CompareStatus(mcs.ready_to_read, READYTOREAD.DONE) + mcs.stop_all.put(1) + status_acquiring = TransitionStatus(mcs.acquiring, [ACQUIRING.DONE, ACQUIRING.ACQUIRING]) + self.cancel_on_stop(status_ready_read) + self.cancel_on_stop(status_acquiring) + status_ready_read.wait(10) + + mcs.ready_to_read.put(READYTOREAD.PROCESSING) + mcs.erase_start.put(1) + status_acquiring.wait(timeout=10) # Allow 10 seconds in case communication is slow + + def _reset_status_event_future(self) -> None: + """Reset the status future and thread event.""" + if self._status_future and not self._status_future.done(): + self._status_thread_event.set() + self._status_future.result(timeout=10) # Allow 10 seconds for communication to finish + + # Reset to known state + self._status_thread_event.clear() + self.state.proc_status.put(1, use_complete=True) + + def _prepare_trigger_status_event(self, timeout: float | None = None) -> DeviceStatus: + """Prepare the trigger status event for the DDG1, and trigger the de""" + if timeout is None: + # Default timeout of 5 seconds + exposure time * frames_per_trigger + timeout = 5 + self.scan_info.msg.scan_parameters.get( + "exp_time", 0.1 + ) * self.scan_info.msg.scan_parameters.get("frames_per_trigger", 1) + + # Callback to cancel the status if the device is stopped + def cancel_cb(status: CompareStatus) -> None: + """Callback to cancel the status if the device is stopped.""" + self._status_thread_event.set() + + status = StatusBitsCompareStatus( + self.state.event_status, STATUSBITS.END_OF_BURST, timeout=timeout, run=False + ) + status.add_callback(cancel_cb) + self.cancel_on_stop(status) + + # Callback to poll events, this gets executed in a separate thread by the threadpool + def _status_job(): + """Callback to poll event status an update the status_event signal.""" + while not self._status_thread_event.is_set(): + self.state.proc_status.put(1, use_complete=True) + time.sleep(0.02) # 20ms delay for processing + self.state.event_status.get(use_monitor=False) + time.sleep(0.02) # 20ms delay for processing + + # Submit the status job to the threadpool + self._status_future = self._threadpool.submit(_status_job) + return status + def on_trigger(self) -> DeviceStatus | StatusBase | None: """Note, we need to add a delay to the StatusBits callback on the event_status. If we don't then subsequent triggers may reach the DDG too early, and will be ignored. To avoid this, we've added the option to specify a delay via add_delay, default here is 50ms. """ - status = CompareStatus(self.state.event_status, STATUSBITS.NONE) - self.cancel_on_stop(status) + # Make sure to reset the status future and thread event + self._reset_status_event_future() + + # Prepare the MCS card for the next software trigger mcs = self.device_manager.devices.get("mcs", None) if mcs is None: logger.info("Did not find mcs card with name 'mcs' in current session") else: - mcs: MCSCardCSAXS - status_ready_read = CompareStatus(mcs.ready_to_read, READYTOREAD.DONE) - mcs.stop_all.put(1) - status_acquiring = TransitionStatus( - mcs.acquiring, [ACQUIRING.DONE, ACQUIRING.ACQUIRING] - ) - self.cancel_on_stop(status_ready_read) - self.cancel_on_stop(status_acquiring) - status_ready_read.wait(10) - - mcs.ready_to_read.put(READYTOREAD.PROCESSING) - mcs.erase_start.put(1) - status_acquiring.wait( - timeout=10 - ) # 2 s wait for mcs card to start should be more than enough.. - status.wait(timeout=10) - # Default timeout of 5 seconds + exposure time * frames_per_trigger - timeout = 5 + self.scan_info.msg.scan_parameters.get( - "exp_time", 0.1 - ) * self.scan_info.msg.scan_parameters.get("frames_per_trigger", 1) - status = CompareStatus(self.state.event_status, STATUSBITS.END_OF_DELAY, timeout=timeout) - self.cancel_on_stop(status) + self._prepare_mcs_on_trigger(mcs) + # Prepare status to wait for the end of burst + status = self._prepare_trigger_status_event() + # Trigger the DDG1 self.trigger_shot.put(1, use_complete=True) return status - def wait_for_status( - self, status: DeviceStatus, bit_event: STATUSBITS, timeout: float = 2 - ) -> None: - """Wait for a event status bit to be set. - - Args: - status (DeviceStatus): The status object to update. - bit_event (STATUSBITS): The event status bit to wait for. - timeout (float): Maximum time to wait for the event status bit to be set. - """ - current_time = time.time() - while not status.done: - self.state.proc_status.put(1, use_complete=True) - event_status = self.state.event_status.get() - if (STATUSBITS(event_status) & bit_event) == bit_event: - status.set_finished() - if time.time() - current_time > timeout: - status.set_exception( - TimeoutError( - f"Timeout waiting for status of device {self.name} for event_status {bit_event}" - ) - ) - break - time.sleep(0.1) - time.sleep(0.05) # Give time for the IOC to be ready again - return status - def on_stop(self) -> None: """Stop the delay generator by setting the burst mode to 0""" self.stop_ddg() + def on_destroy(self): + """Clean up resources when the device is destroyed.""" + self._status_thread_event.set() + if hasattr(self, "_threadpool") and self._threadpool is not None: + self._threadpool.shutdown(wait=False) + self._threadpool = None + super().on_destroy() + if __name__ == "__main__": ddg = DDG1(name="ddg1", prefix="X12SA-CPCL-DDG1:") diff --git a/csaxs_bec/devices/epics/delay_generator_csaxs/delay_generator_csaxs.py b/csaxs_bec/devices/epics/delay_generator_csaxs/delay_generator_csaxs.py index 526ce78..f55d682 100644 --- a/csaxs_bec/devices/epics/delay_generator_csaxs/delay_generator_csaxs.py +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/delay_generator_csaxs.py @@ -402,7 +402,6 @@ class DelayGeneratorEventStatus(Device): EpicsSignal, "EventStatusLI.PROC", name="proc_status", - auto_monitor=True, kind=Kind.omitted, doc="Poll and flush the latest event status register entry from the HW to the event_status signal", ) diff --git a/tests/tests_devices/test_delay_generator_csaxs.py b/tests/tests_devices/test_delay_generator_csaxs.py index 25d99ef..68a1f3c 100644 --- a/tests/tests_devices/test_delay_generator_csaxs.py +++ b/tests/tests_devices/test_delay_generator_csaxs.py @@ -191,7 +191,10 @@ def test_ddg1_stage(mock_ddg1): mock_ddg1.stage() - assert np.isclose(mock_ddg1.burst_mode.get(), 0) # Burst mode is disabled + assert np.isclose(mock_ddg1.burst_mode.get(), 1) # burst mode is enabled + assert np.isclose(mock_ddg1.burst_delay.get(), 0) + assert np.isclose(mock_ddg1.burst_period.get(), exp_time) + # Trigger DDG2 through EXT/EN assert np.isclose(mock_ddg1.ab.delay.get(), 2e-3) @@ -218,7 +221,7 @@ def test_ddg1_trigger(mock_ddg1): assert status.done is False assert status.success is False assert mock_ddg1.trigger_shot.get() == 1 - mock_ddg1.state.event_status._read_pv.mock_data = STATUSBITS.END_OF_DELAY.value + mock_ddg1.state.event_status._read_pv.mock_data = STATUSBITS.END_OF_BURST.value status.wait(timeout=1) # Wait for the status to be done assert status.done is True assert status.success is True -- 2.49.1 From a58c23845f2bd50be285e7a640e404b312658b22 Mon Sep 17 00:00:00 2001 From: appel_c Date: Wed, 6 Aug 2025 10:50:39 +0200 Subject: [PATCH 19/23] refactor(ddg1): remove threadpool, use dedicated thread for polling --- .../epics/delay_generator_csaxs/ddg_1.py | 102 +++++++++++------- 1 file changed, 64 insertions(+), 38 deletions(-) diff --git a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py index f12a0b7..586c9cc 100644 --- a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py @@ -33,12 +33,10 @@ from __future__ import annotations import threading import time -from concurrent.futures import Future, ThreadPoolExecutor from typing import TYPE_CHECKING from bec_lib.logger import bec_logger -from ophyd import Component as Cpt -from ophyd import DeviceStatus, Signal, StatusBase +from ophyd import DeviceStatus, StatusBase from ophyd_devices import CompareStatus, TransitionStatus from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase @@ -51,6 +49,7 @@ from csaxs_bec.devices.epics.delay_generator_csaxs.delay_generator_csaxs import AllChannelNames, ChannelConfig, DelayGeneratorCSAXS, + LiteralChannels, StatusBitsCompareStatus, ) from csaxs_bec.devices.epics.mcs_card.mcs_card_csaxs import ACQUIRING, READYTOREAD @@ -79,7 +78,7 @@ DEFAULT_IO_CONFIG: dict[AllChannelNames, ChannelConfig] = { DEFAULT_TRIGGER_SOURCE: TRIGGERSOURCE = TRIGGERSOURCE.SINGLE_SHOT DEFAULT_READOUT_TIMES = {"ab": 2e-4, "cd": 2e-4, "ef": 2e-4, "gh": 2e-4} # 0.2 ms 5kHz -DEFAULT_REFERENCES: list[tuple[AllChannelNames, CHANNELREFERENCE]] = [ +DEFAULT_REFERENCES: list[tuple[LiteralChannels, CHANNELREFERENCE]] = [ ("A", CHANNELREFERENCE.T0), # T0 + 2ms delay ("B", CHANNELREFERENCE.A), ("C", CHANNELREFERENCE.T0), # T0 @@ -93,10 +92,11 @@ DEFAULT_REFERENCES: list[tuple[AllChannelNames, CHANNELREFERENCE]] = [ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): """ - Implementation of DelayGeneratorCSAXS for the CSAXS master trigger delay generator at X12SA-CPCL-DDG1. - It will be triggered by a soft trigger from BEC or a hardware trigger from a beamline device (e.g. the Galil stages). - It is operated in standard mode, not burst mode and will trigger the EXT/EN of DDG2 (channel ab). - It is responsible for opening the shutter (channel cd) and sending an extra trigger to an or gate for the MCS card (channel ef). + Implementation of DelayGeneratorCSAXS for master trigger delay generator at X12SA-CPCL-DDG1. + It will be triggered by a soft trigger from BEC or a hardware trigger from a beamline device + (e.g. the Galil stages). It is operated in standard mode, not burst mode and will trigger the + EXT/EN of DDG2 (channel ab). It is responsible for opening the shutter (channel cd) and sending + an extra trigger to an or gate for the MCS card (channel ef). """ def __init__( @@ -114,9 +114,10 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): name=name, prefix=prefix, scan_info=scan_info, device_manager=device_manager, **kwargs ) self.device_manager = device_manager - self._threadpool = ThreadPoolExecutor(max_workers=1) - self._status_thread_event = threading.Event() - self._status_future = None # Future for the status job + self._poll_thread = threading.Thread(target=self._poll_event_status, daemon=True) + self._poll_thread_run_event = threading.Event() + self._poll_thread_kill_event = threading.Event() + self._poll_thread.start() # pylint: disable=attribute-defined-outside-init def on_connected(self) -> None: @@ -175,15 +176,52 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): mcs.erase_start.put(1) status_acquiring.wait(timeout=10) # Allow 10 seconds in case communication is slow - def _reset_status_event_future(self) -> None: - """Reset the status future and thread event.""" - if self._status_future and not self._status_future.done(): - self._status_thread_event.set() - self._status_future.result(timeout=10) # Allow 10 seconds for communication to finish + def _poll_event_status(self) -> None: + """ + Poll the event status register in a background thread. Control + the polling with the _poll_thread_run_event and _poll_thread_kill_event. + """ + while not self._poll_thread_kill_event.is_set(): + self._poll_thread_run_event.wait() + while ( + not self._poll_thread_run_event.is_set() and self._poll_thread_kill_event.is_set() + ): + self._poll_loop() - # Reset to known state - self._status_thread_event.clear() + def _poll_loop(self) -> None: + """ + Poll loop to update event status. + The checks ensure that the loop exist after each operation and be stuck in sleep. + """ self.state.proc_status.put(1, use_complete=True) + if not self._poll_thread_run_event.is_set(): + return + time.sleep(0.02) # 20ms delay for processing + if not self._poll_thread_run_event.is_set(): + return + self.state.event_status.get(use_monitor=False) + if not self._poll_thread_run_event.is_set(): + return + time.sleep(0.02) # 20ms delay for processing + + def _start_polling(self) -> None: + """Start the polling loop in the background thread.""" + self._poll_thread_run_event.set() + + def _stop_polling(self) -> None: + """Stop the polling loop in the background thread.""" + self._poll_thread_run_event.clear() + + def _kill_poll_thread(self) -> None: + """Kill the polling thread.""" + self._poll_thread_kill_event.set() + self._stop_polling() + self._poll_thread.join(timeout=1) + if self._poll_thread.is_alive(): + logger.warning("Polling thread did not stop gracefully.") + else: + self._poll_thread = None + logger.info("Polling thread stopped.") def _prepare_trigger_status_event(self, timeout: float | None = None) -> DeviceStatus: """Prepare the trigger status event for the DDG1, and trigger the de""" @@ -196,25 +234,13 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): # Callback to cancel the status if the device is stopped def cancel_cb(status: CompareStatus) -> None: """Callback to cancel the status if the device is stopped.""" - self._status_thread_event.set() + self._stop_polling() status = StatusBitsCompareStatus( self.state.event_status, STATUSBITS.END_OF_BURST, timeout=timeout, run=False ) status.add_callback(cancel_cb) self.cancel_on_stop(status) - - # Callback to poll events, this gets executed in a separate thread by the threadpool - def _status_job(): - """Callback to poll event status an update the status_event signal.""" - while not self._status_thread_event.is_set(): - self.state.proc_status.put(1, use_complete=True) - time.sleep(0.02) # 20ms delay for processing - self.state.event_status.get(use_monitor=False) - time.sleep(0.02) # 20ms delay for processing - - # Submit the status job to the threadpool - self._status_future = self._threadpool.submit(_status_job) return status def on_trigger(self) -> DeviceStatus | StatusBase | None: @@ -222,8 +248,9 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): If we don't then subsequent triggers may reach the DDG too early, and will be ignored. To avoid this, we've added the option to specify a delay via add_delay, default here is 50ms. """ - # Make sure to reset the status future and thread event - self._reset_status_event_future() + # Stop polling, poll once manually to ensure that the register is clean + self._stop_polling() + self._poll_loop() # Prepare the MCS card for the next software trigger mcs = self.device_manager.devices.get("mcs", None) @@ -231,8 +258,10 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): logger.info("Did not find mcs card with name 'mcs' in current session") else: self._prepare_mcs_on_trigger(mcs) - # Prepare status to wait for the end of burst + # Prepare status with callback to cancel the polling once finished status = self._prepare_trigger_status_event() + # Start polling + self._start_polling() # Trigger the DDG1 self.trigger_shot.put(1, use_complete=True) return status @@ -243,10 +272,7 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): def on_destroy(self): """Clean up resources when the device is destroyed.""" - self._status_thread_event.set() - if hasattr(self, "_threadpool") and self._threadpool is not None: - self._threadpool.shutdown(wait=False) - self._threadpool = None + self._kill_poll_thread() super().on_destroy() -- 2.49.1 From 9f5254abe272d149d3d67fe5a313e07603ce5ab1 Mon Sep 17 00:00:00 2001 From: appel_c Date: Wed, 6 Aug 2025 11:00:46 +0200 Subject: [PATCH 20/23] refactor(ddg): cleanup and improve comments --- .../epics/delay_generator_csaxs/ddg_1.py | 33 +++++++++++-------- .../epics/delay_generator_csaxs/ddg_2.py | 5 +-- .../test_delay_generator_csaxs.py | 5 +++ 3 files changed, 27 insertions(+), 16 deletions(-) diff --git a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py index 586c9cc..314730d 100644 --- a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py @@ -36,7 +36,7 @@ import time from typing import TYPE_CHECKING from bec_lib.logger import bec_logger -from ophyd import DeviceStatus, StatusBase +from ophyd import DeviceStatus from ophyd_devices import CompareStatus, TransitionStatus from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase @@ -116,6 +116,7 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): self.device_manager = device_manager self._poll_thread = threading.Thread(target=self._poll_event_status, daemon=True) self._poll_thread_run_event = threading.Event() + self._poll_thread_poll_loop_done = threading.Event() self._poll_thread_kill_event = threading.Event() self._poll_thread.start() @@ -134,7 +135,7 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): # Set proc status to passively update with 5Hz (0.2s) self.state.proc_status_mode.put(PROC_EVENT_MODE.EVENT) - def on_stage(self) -> DeviceStatus | StatusBase | None: + def on_stage(self) -> None: """ Stage logic for the DDG1 device, being th main trigger delay generator for CSAXS. For standard scans, it will be triggered by a soft trigger from BEC. @@ -183,26 +184,30 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): """ while not self._poll_thread_kill_event.is_set(): self._poll_thread_run_event.wait() + self._poll_thread_poll_loop_done.clear() while ( - not self._poll_thread_run_event.is_set() and self._poll_thread_kill_event.is_set() + self._poll_thread_run_event.is_set() and not self._poll_thread_kill_event.is_set() ): self._poll_loop() + self._poll_thread_poll_loop_done.set() + def _poll_loop(self) -> None: """ Poll loop to update event status. The checks ensure that the loop exist after each operation and be stuck in sleep. + The 20ms sleep was added to ensure that the event status is not polled too frequently, + and to give the device time to process the previous command. This was found empirically + to be necessary to avoid missing events. """ self.state.proc_status.put(1, use_complete=True) - if not self._poll_thread_run_event.is_set(): - return - time.sleep(0.02) # 20ms delay for processing - if not self._poll_thread_run_event.is_set(): + time.sleep(0.02) # 20ms delay for processing, important for not missing events + if self._poll_thread_run_event.is_set() and not self._poll_thread_kill_event.is_set(): return self.state.event_status.get(use_monitor=False) - if not self._poll_thread_run_event.is_set(): + if self._poll_thread_run_event.is_set() and not self._poll_thread_kill_event.is_set(): return - time.sleep(0.02) # 20ms delay for processing + time.sleep(0.02) # 20ms delay for processing, important for not missing events def _start_polling(self) -> None: """Start the polling loop in the background thread.""" @@ -220,7 +225,6 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): if self._poll_thread.is_alive(): logger.warning("Polling thread did not stop gracefully.") else: - self._poll_thread = None logger.info("Polling thread stopped.") def _prepare_trigger_status_event(self, timeout: float | None = None) -> DeviceStatus: @@ -236,6 +240,7 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): """Callback to cancel the status if the device is stopped.""" self._stop_polling() + # Run false is important to ensure that the status is only checked on the next event status update status = StatusBitsCompareStatus( self.state.event_status, STATUSBITS.END_OF_BURST, timeout=timeout, run=False ) @@ -243,14 +248,14 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): self.cancel_on_stop(status) return status - def on_trigger(self) -> DeviceStatus | StatusBase | None: + def on_trigger(self) -> DeviceStatus: """Note, we need to add a delay to the StatusBits callback on the event_status. If we don't then subsequent triggers may reach the DDG too early, and will be ignored. To avoid this, we've added the option to specify a delay via add_delay, default here is 50ms. """ # Stop polling, poll once manually to ensure that the register is clean self._stop_polling() - self._poll_loop() + self._poll_thread_poll_loop_done.wait(timeout=1) # Prepare the MCS card for the next software trigger mcs = self.device_manager.devices.get("mcs", None) @@ -269,11 +274,11 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): def on_stop(self) -> None: """Stop the delay generator by setting the burst mode to 0""" self.stop_ddg() + self._stop_polling() - def on_destroy(self): + def on_destroy(self) -> None: """Clean up resources when the device is destroyed.""" self._kill_poll_thread() - super().on_destroy() if __name__ == "__main__": diff --git a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_2.py b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_2.py index 0e78b7f..4d8d0c4 100644 --- a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_2.py +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_2.py @@ -36,6 +36,7 @@ from csaxs_bec.devices.epics.delay_generator_csaxs.delay_generator_csaxs import AllChannelNames, ChannelConfig, DelayGeneratorCSAXS, + LiteralChannels, ) logger = bec_logger.logger @@ -57,7 +58,7 @@ DEFAULT_IO_CONFIG: dict[AllChannelNames, ChannelConfig] = { DEFAULT_TRIGGER_SOURCE: TRIGGERSOURCE = TRIGGERSOURCE.EXT_RISING_EDGE DEFAULT_READOUT_TIMES = {"ab": 2e-4, "cd": 2e-4, "ef": 2e-4, "gh": 2e-4} # 0.2 ms 5kHz -DEFAULT_REFERENCES: list[tuple[AllChannelNames, CHANNELREFERENCE]] = [ +DEFAULT_REFERENCES: list[tuple[LiteralChannels, CHANNELREFERENCE]] = [ ("A", CHANNELREFERENCE.T0), ("B", CHANNELREFERENCE.A), ("C", CHANNELREFERENCE.T0), @@ -100,7 +101,7 @@ class DDG2(PSIDeviceBase, DelayGeneratorCSAXS): frames_per_trigger = self.scan_info.msg.scan_parameters["frames_per_trigger"] # a = t0 # a has reference to t0, b has reference to a - if any(exp_time < rt for rt in DEFAULT_READOUT_TIMES.values()): + if any(exp_time <= rt for rt in DEFAULT_READOUT_TIMES.values()): raise ValueError( f"Exposure time {exp_time} is too short for the readout times {DEFAULT_READOUT_TIMES}" ) diff --git a/tests/tests_devices/test_delay_generator_csaxs.py b/tests/tests_devices/test_delay_generator_csaxs.py index 68a1f3c..252babc 100644 --- a/tests/tests_devices/test_delay_generator_csaxs.py +++ b/tests/tests_devices/test_delay_generator_csaxs.py @@ -280,6 +280,11 @@ def test_ddg2_stage(mock_ddg2): assert mock_ddg2.trigger_source.get() == TRIGGERSOURCE.EXT_RISING_EDGE.value assert mock_ddg2.staged == ophyd.Staged.yes + mock_ddg2.unstage() # Reset staged state for next test + + with pytest.raises(ValueError): + mock_ddg2.scan_info.msg.scan_parameters["exp_time"] = 2e-4 # too short exposure time + mock_ddg2.stage() def test_ddg2_trigger(mock_ddg2): -- 2.49.1 From 5c263bdb63fbfb53839c41e6f38e1481aaae2b42 Mon Sep 17 00:00:00 2001 From: appel_c Date: Wed, 6 Aug 2025 11:27:36 +0200 Subject: [PATCH 21/23] refactor(ids-camera): improve docs and camera class --- csaxs_bec/devices/ids_cameras/base_integration/camera.py | 8 ++++++-- csaxs_bec/devices/ids_cameras/base_integration/utils.py | 2 -- csaxs_bec/devices/ids_cameras/ids_camera_new.py | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/csaxs_bec/devices/ids_cameras/base_integration/camera.py b/csaxs_bec/devices/ids_cameras/base_integration/camera.py index a5a7d86..ed12a6d 100644 --- a/csaxs_bec/devices/ids_cameras/base_integration/camera.py +++ b/csaxs_bec/devices/ids_cameras/base_integration/camera.py @@ -29,6 +29,10 @@ class IDSCameraObject: """ def __init__(self, device_id: int, m_n_colormode, bits_per_pixel): + if ueye is None: + raise ImportError( + "The pyueye library is not installed or library files are missing. Please check your Python environment or library paths." + ) self.ueye = ueye self._device_id = device_id self.h_cam = ueye.HIDS(device_id) @@ -53,7 +57,7 @@ class IDSCameraObject: int.from_bytes(self.s_info.nColorMode.value, byteorder="big") == self.ueye.IS_COLORMODE_BAYER ): - print("Bayer color mode detected.") + logger.info("Bayer color mode detected.") # setup the color depth to the current windows setting self.ueye.is_GetColorDepth( self.h_cam, self.n_bits_per_pixel, self.m_n_colormode @@ -80,7 +84,7 @@ class IDSCameraObject: self.m_n_colormode = self.ueye.IS_CM_MONO8 self.n_bits_per_pixel = self.ueye.INT(8) self.bytes_per_pixel = int(self.n_bits_per_pixel / 8) - print("else") + logger.info("Monochrome camera mode detected.") # Can be used to set the size and position of an "area of interest"(AOI) within an image check_error( diff --git a/csaxs_bec/devices/ids_cameras/base_integration/utils.py b/csaxs_bec/devices/ids_cameras/base_integration/utils.py index 0128d52..0799178 100644 --- a/csaxs_bec/devices/ids_cameras/base_integration/utils.py +++ b/csaxs_bec/devices/ids_cameras/base_integration/utils.py @@ -1,7 +1,5 @@ """Utility functions and classes for IDS cameras using the pyueye library.""" -from unittest import mock - from bec_lib.logger import bec_logger logger = bec_logger.logger diff --git a/csaxs_bec/devices/ids_cameras/ids_camera_new.py b/csaxs_bec/devices/ids_cameras/ids_camera_new.py index ecb3f83..9d4b274 100644 --- a/csaxs_bec/devices/ids_cameras/ids_camera_new.py +++ b/csaxs_bec/devices/ids_cameras/ids_camera_new.py @@ -181,7 +181,7 @@ class IDSCamera(PSIDeviceBase): def on_connected(self): """Connect to the camera.""" self.cam.on_connect() - self.live_mode = self._inputs.get("live_mode", None) + self.live_mode = self._inputs.get("live_mode", False) self.set_rect_roi(0, 0, self.cam.cam.width.value, self.cam.cam.height.value) def on_destroy(self): -- 2.49.1 From f69e6430929953d50b2f76615d52501d3b9d9e46 Mon Sep 17 00:00:00 2001 From: appel_c Date: Wed, 6 Aug 2025 11:28:04 +0200 Subject: [PATCH 22/23] refactor(mcs): imrove documentation for counter updates --- .copier-answers.yml | 4 +- .../devices/epics/mcs_card/mcs_card_csaxs.py | 44 ++++++++++++++++--- test.txt | 1 - 3 files changed, 39 insertions(+), 10 deletions(-) delete mode 100644 test.txt diff --git a/.copier-answers.yml b/.copier-answers.yml index e233aca..a4b4022 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -6,6 +6,4 @@ _commit: v1.1.2 _src_path: https://github.com/bec-project/plugin_copier_template.git make_commit: false project_name: csaxs_bec -widget_plugins_input: -- name: csaxs_test - use_ui: true +widget_plugins_input: [] diff --git a/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py b/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py index 35ff0a3..25958c9 100644 --- a/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py +++ b/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py @@ -119,8 +119,8 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): ) self._mcs_clock = 1e7 # 10MHz clock -> 1e7 Hz self._pv_timeout = 3 # TODO remove timeout once #129 in ophyd_devices is solved - self._rlock = RLock() - self.counter_mapping = { + self._rlock = RLock() # Needed to ensure thread safety for counter updates + self.counter_mapping = { # Any mca counter that should be updated has to be added here f"{self.counters.name}_mca1": "current1", f"{self.counters.name}_mca2": "current2", f"{self.counters.name}_mca3": "current3", @@ -166,35 +166,67 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): sig.subscribe(self._on_counter_update, run=False) def _on_counter_update(self, value, **kwargs) -> None: + """ + Callback for counter updates of the mca channels (1-32). + + The raw data is pushed to the mcs sub-device (MCSRaw). We need to ensure that + the MCSRaw device has all signals defined for which we want to push the values. + + As we may receive multiple readings per point, e.g. if frames_per_trigger > 1, + we also create a mean value for the counter signals. These are then pushed to the bpm device + for plotting and further processing. The signal names are defined and mapped in the + self.counter_mapping dictionary & the bpm sub-device. + + There are multiple mca channels, each giving individual updates. We want to ensure that + each is updated before we signal that we are ready to read. In future, these signals may + become asynchronous, but we first need to ensure that we can properly combine monitored + signals with async signals for plotting. Until then, we will keep this logic. + """ with self._rlock: + # Retrieve the signal object which executes this callback signal = kwargs.get("obj", None) - if signal is None: + if signal is None: # This should never happen, but just in case logger.info(f"Called without 'obj' in kwargs: {kwargs}") return + # Get the maped signal name from the mapping dictionary mapped_signal_name = self.counter_mapping.get(signal.name, None) + # If we did not map the signal name in counter_mapping, but receive an update + # we will skip it. if mapped_signal_name is None: return + # Push the raw values of the mca channels. The signal name has to be defined + # in the self.mcs sub-device (MCSRaw) to be able to push the values. Otherwise + # we will skip the update. mca_raw = getattr(self.mcs, signal.name.split("_")[-1], None) if mca_raw is None: return + # In case there was more than one value received, i.e. frames_per_trigger > 1, + # we will receive a np.array of values. if isinstance(value, np.ndarray): + # We push the raw values as a list to the mca_raw signal + # And otherwise compute the mean value for plotting of counter signals mca_raw.put(value.tolist()) + # compute the count_time in seconds if mapped_signal_name == "count_time": value = value / self._mcs_clock value = float(value.mean()) else: + # We received a single value, so we can directly push it mca_raw.put(value) + # compute the count_time in seconds if mapped_signal_name == "count_time": value = value / self._mcs_clock - # Mean signal for burst acquisition + # Get the mapped signal from the bpm device and update it sig = getattr(self.bpm, mapped_signal_name) sig.put(value) self.counter_updated.append(signal.name) + # Once all mca channels have been updated, we can signal that we are ready to read received_all_updates = set(self.counter_updated) == set(self.counter_mapping.keys()) if received_all_updates: - self.ready_to_read.put(READYTOREAD.DONE) # Reset happens from DDG class! - self.counter_updated.clear() + self.ready_to_read.put(READYTOREAD.DONE) + # The reset of the signal is done in the on_trigger method of ddg1 for the next trigger + self.counter_updated.clear() # Clear the list for the next update cycle def _progress_update(self, value, **kwargs) -> None: """Callback for progress updates from ophyd subscription on current_channel.""" diff --git a/test.txt b/test.txt deleted file mode 100644 index df9fe2d..0000000 --- a/test.txt +++ /dev/null @@ -1 +0,0 @@ -jjakjda -- 2.49.1 From 189141a047e790231d9fd860951f1b333e48f742 Mon Sep 17 00:00:00 2001 From: appel_c Date: Wed, 6 Aug 2025 15:30:10 +0200 Subject: [PATCH 23/23] refactor(ids-camera): add additional information to the docstrings --- .../ids_cameras/base_integration/camera.py | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/csaxs_bec/devices/ids_cameras/base_integration/camera.py b/csaxs_bec/devices/ids_cameras/base_integration/camera.py index ed12a6d..bdd27c2 100644 --- a/csaxs_bec/devices/ids_cameras/base_integration/camera.py +++ b/csaxs_bec/devices/ids_cameras/base_integration/camera.py @@ -1,4 +1,16 @@ -"""This module provides a Camera class for handling IDS cameras using the pyueye SDK library.""" +""" +This module provides a Camera class for handling IDS cameras using the pyueye library, +that links to the vendors C++ SDK. Details about the camera's C++ SDK API can be found +in the IDS Software Suite 4.96.1 documentation: +(https://www.1stvision.com/cameras/IDS/IDS-manuals/uEye_Manual/sdk_einleitung_schnellstart.html) + +Here, we follow a procedure to set up the camera, configure its basic parameters and +allow automated capturing of images. The IDSCameraObject class is the low-level interface, +and requires the pyueye library and appropriate DLL files on the system. The Camera class +provides a high level interface which only creates the IDSCameraObject instance when the +on_connect method is called. This allows for lazy initialization of the camera, and +CI/CD pipelines can run without the pyueye library or the related DLLs installed on the system. +""" from __future__ import annotations @@ -20,7 +32,7 @@ except ImportError as exc: class IDSCameraObject: - """Base class for IDS Camera object. + """Low-level base class for IDS Camera object. Args: device_id (int): The ID of the camera device. # e.g. 201; check idscamera tool @@ -137,7 +149,14 @@ class IDSCameraObject: class Camera: - """Camera base class for IDS cameras.""" + """High level camera base class for IDS cameras. + + Args: + camera_id (int): The ID of the camera device. + m_n_colormode (Literal[0, 1, 2, 3]): Color mode for the camera. + bits_per_pixel (Literal[8, 24]): Number of bits per pixel for the camera. + live_mode (bool): Whether to enable live mode for the camera. + """ def __init__( self, -- 2.49.1