From ef0c31c8dc403af45166ffd83b69268e39ec2152 Mon Sep 17 00:00:00 2001 From: appel_c Date: Mon, 8 Dec 2025 17:37:39 +0100 Subject: [PATCH 1/9] refactor(mcs-card): adjust mcs card to only have mca channels. --- .../devices/epics/mcs_card/mcs_card_csaxs.py | 470 ++++++++++++------ 1 file changed, 320 insertions(+), 150 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 91149eb..066c311 100644 --- a/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py +++ b/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py @@ -2,16 +2,17 @@ from __future__ import annotations -import enum +import time from threading import RLock -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal import numpy as np 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 import EpicsSignalRO, Kind, SignalRO +from ophyd_devices import AsyncSignal, CompareStatus, ProgressSignal, TransitionStatus from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase +from ophyd_devices.utils.bec_signals import AsyncMultiSignal from csaxs_bec.devices.epics.mcs_card.mcs_card import ( ACQUIREMODE, @@ -24,7 +25,6 @@ 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 @@ -32,81 +32,289 @@ if TYPE_CHECKING: # pragma: no cover 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, 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, - 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 MCSRaw(Device): - """Class for BPM device of the MCSCard with normalized currents.""" - - 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): """ Implementation of the MCSCard SIS3820 for CSAXS, prefix 'X12SA-MCS:'. The basic functionality is inherited from the MCSCard class. """ - ready_to_read = Cpt( - Signal, - 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") - # Make this an async signal.. - mcs = Cpt( - MCSRaw, - name="mcs", + # All counter from the MCS card. + # mca = Cpt( + # AsyncMultiSignal, + # name="counters", + # signals=[ + # f"mca{i}" for i in range(1, 33) + # ], # This needs to be in sync with counters DynamicDeviceComponent + # ndim=0, + # async_update={"type": "add", "max_shape": [None]}, + # max_size=1000, + # kind=Kind.normal, + # doc="AsyncMultiSignal for MCA card channels 1-32", + # ) + mca1 = Cpt( + AsyncSignal, + name="mca1", kind=Kind.normal, - doc="MCS device with raw current and count time readings", + async_update={"type": "add", "max_shape": [None]}, + max_size=1000, + doc="AsyncSignal for MCA channel 1", ) - bpm = Cpt( - BPMDevice, - name="bpm", + mca2 = Cpt( + AsyncSignal, + name="mca2", kind=Kind.normal, - doc="BPM device for MCSCard with count times and normalized currents", + async_update={"type": "add", "max_shape": [None]}, + max_size=1000, + doc="AsyncSignal for MCA channel 2", ) + mca3 = Cpt( + AsyncSignal, + name="mca3", + kind=Kind.normal, + async_update={"type": "add", "max_shape": [None]}, + max_size=1000, + doc="AsyncSignal for MCA channel 3", + ) + mca4 = Cpt( + AsyncSignal, + name="mca4", + kind=Kind.normal, + async_update={"type": "add", "max_shape": [None]}, + max_size=1000, + doc="AsyncSignal for MCA channel 4", + ) + mca5 = Cpt( + AsyncSignal, + name="mca5", + kind=Kind.normal, + async_update={"type": "add", "max_shape": [None]}, + max_size=1000, + doc="AsyncSignal for MCA channel 5", + ) + mca6 = Cpt( + AsyncSignal, + name="mca6", + kind=Kind.normal, + async_update={"type": "add", "max_shape": [None]}, + max_size=1000, + doc="AsyncSignal for MCA channel 6", + ) + mca7 = Cpt( + AsyncSignal, + name="mca7", + kind=Kind.normal, + async_update={"type": "add", "max_shape": [None]}, + max_size=1000, + doc="AsyncSignal for MCA channel 7", + ) + mca8 = Cpt( + AsyncSignal, + name="mca8", + kind=Kind.normal, + async_update={"type": "add", "max_shape": [None]}, + max_size=1000, + doc="AsyncSignal for MCA channel 8", + ) + mca9 = Cpt( + AsyncSignal, + name="mca9", + kind=Kind.normal, + async_update={"type": "add", "max_shape": [None]}, + max_size=1000, + doc="AsyncSignal for MCA channel 9", + ) + mca10 = Cpt( + AsyncSignal, + name="mca10", + kind=Kind.normal, + async_update={"type": "add", "max_shape": [None]}, + max_size=1000, + doc="AsyncSignal for MCA channel 10", + ) + mca11 = Cpt( + AsyncSignal, + name="mca11", + kind=Kind.normal, + async_update={"type": "add", "max_shape": [None]}, + max_size=1000, + doc="AsyncSignal for MCA channel 11", + ) + mca12 = Cpt( + AsyncSignal, + name="mca12", + kind=Kind.normal, + async_update={"type": "add", "max_shape": [None]}, + max_size=1000, + doc="AsyncSignal for MCA channel 12", + ) + mca13 = Cpt( + AsyncSignal, + name="mca13", + kind=Kind.normal, + async_update={"type": "add", "max_shape": [None]}, + max_size=1000, + doc="AsyncSignal for MCA channel 13", + ) + mca14 = Cpt( + AsyncSignal, + name="mca14", + kind=Kind.normal, + async_update={"type": "add", "max_shape": [None]}, + max_size=1000, + doc="AsyncSignal for MCA channel 14", + ) + mca15 = Cpt( + AsyncSignal, + name="mca15", + kind=Kind.normal, + async_update={"type": "add", "max_shape": [None]}, + max_size=1000, + doc="AsyncSignal for MCA channel 15", + ) + mca16 = Cpt( + AsyncSignal, + name="mca16", + kind=Kind.normal, + async_update={"type": "add", "max_shape": [None]}, + max_size=1000, + doc="AsyncSignal for MCA channel 16", + ) + mca17 = Cpt( + AsyncSignal, + name="mca17", + kind=Kind.normal, + async_update={"type": "add", "max_shape": [None]}, + max_size=1000, + doc="AsyncSignal for MCA channel 17", + ) + mca18 = Cpt( + AsyncSignal, + name="mca18", + kind=Kind.normal, + async_update={"type": "add", "max_shape": [None]}, + max_size=1000, + doc="AsyncSignal for MCA channel 18", + ) + mca19 = Cpt( + AsyncSignal, + name="mca19", + kind=Kind.normal, + async_update={"type": "add", "max_shape": [None]}, + max_size=1000, + doc="AsyncSignal for MCA channel 19", + ) + mca20 = Cpt( + AsyncSignal, + name="mca20", + kind=Kind.normal, + async_update={"type": "add", "max_shape": [None]}, + max_size=1000, + doc="AsyncSignal for MCA channel 20", + ) + mca21 = Cpt( + AsyncSignal, + name="mca21", + kind=Kind.normal, + async_update={"type": "add", "max_shape": [None]}, + max_size=1000, + doc="AsyncSignal for MCA channel 21", + ) + mca22 = Cpt( + AsyncSignal, + name="mca22", + kind=Kind.normal, + async_update={"type": "add", "max_shape": [None]}, + max_size=1000, + doc="AsyncSignal for MCA channel 22", + ) + mca23 = Cpt( + AsyncSignal, + name="mca23", + kind=Kind.normal, + async_update={"type": "add", "max_shape": [None]}, + max_size=1000, + doc="AsyncSignal for MCA channel 23", + ) + mca24 = Cpt( + AsyncSignal, + name="mca24", + kind=Kind.normal, + async_update={"type": "add", "max_shape": [None]}, + max_size=1000, + doc="AsyncSignal for MCA channel 24", + ) + mca25 = Cpt( + AsyncSignal, + name="mca25", + kind=Kind.normal, + async_update={"type": "add", "max_shape": [None]}, + max_size=1000, + doc="AsyncSignal for MCA channel 25", + ) + mca26 = Cpt( + AsyncSignal, + name="mca26", + kind=Kind.normal, + async_update={"type": "add", "max_shape": [None]}, + max_size=1000, + doc="AsyncSignal for MCA channel 26", + ) + mca27 = Cpt( + AsyncSignal, + name="mca27", + kind=Kind.normal, + async_update={"type": "add", "max_shape": [None]}, + max_size=1000, + doc="AsyncSignal for MCA channel 27", + ) + mca28 = Cpt( + AsyncSignal, + name="mca28", + kind=Kind.normal, + async_update={"type": "add", "max_shape": [None]}, + max_size=1000, + doc="AsyncSignal for MCA channel 28", + ) + mca29 = Cpt( + AsyncSignal, + name="mca29", + kind=Kind.normal, + async_update={"type": "add", "max_shape": [None]}, + max_size=1000, + doc="AsyncSignal for MCA channel 29", + ) + mca30 = Cpt( + AsyncSignal, + name="mca30", + kind=Kind.normal, + async_update={"type": "add", "max_shape": [None]}, + max_size=1000, + doc="AsyncSignal for MCA channel 30", + ) + mca31 = Cpt( + AsyncSignal, + name="mca31", + kind=Kind.normal, + async_update={"type": "add", "max_shape": [None]}, + max_size=1000, + doc="AsyncSignal for MCA channel 31", + ) + mca32 = Cpt( + AsyncSignal, + name="mca32", + kind=Kind.normal, + async_update={"type": "add", "max_shape": [None]}, + max_size=1000, + doc="AsyncSignal for MCA channel 32", + ) + # Progress Signal + progress = Cpt(ProgressSignal, doc="ProgressSignal indicating the progress of the device") def __init__( self, name: str, prefix: str = "", + num_connected_channels: int = 5, scan_info: ScanInfo | None = None, device_manager: DeviceManagerBase | None = None, **kwargs, @@ -118,16 +326,14 @@ 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 = 3 # TODO remove timeout once #129 in ophyd_devices is solved + self._pv_timeout = 2.0 # seconds 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", - f"{self.counters.name}_mca4": "current4", - f"{self.counters.name}_mca5": "count_time", - } - self.counter_updated = [] + self.num_connected_channels = num_connected_channels + self._received_updates: dict[ + str, dict[Literal["value", "timestamp"], list[int] | float] + ] = {} + self._acquisition_group: str = "monitored" + self._num_total_triggers: int = 0 def on_connected(self): """ @@ -136,14 +342,15 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): # Make sure card is not running self.stop_all.put(1) - # TODO Check channel1_source !! + # Setup the MCS card settings self.channel_advance.set(CHANNELADVANCE.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) - # Only channel 1-5 are connected so far, adjust if more are needed - self.mux_output.set(5).wait(timeout=self._pv_timeout) + + # Set mux_output to number of connected channels. Connect channels in increasing order + self.mux_output.set(self.num_connected_channels).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) @@ -151,90 +358,60 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): 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 + # Set ReadMode to PASSIVE, card will wait for external trigger to be read self.read_mode.set(READMODE.PASSIVE).wait(timeout=self._pv_timeout) # 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) + 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.subscribe(self._on_counter_update, run=False) + for sig in self.counters.component_names: + sig_obj: EpicsSignalRO = getattr(self.counters, sig) + sig_obj.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. + Data from the mca channels will be pushed to a list, and then forwarded to + the async multi signal 'raw' for readout after the trigger is complete. - 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: # This should never happen, but just in case - logger.info(f"Called without 'obj' in kwargs: {kwargs}") + if signal is None: + logger.error(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: + attr_name = signal.name + mca_channel = getattr(self, attr_name, None) + if mca_channel is None: + logger.error(f"Could not find matching MCA channel for signal {signal.name}") 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 + mca_channel: AsyncSignal - # 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) - # 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 + if isinstance(value, np.ndarray): + value = value.tolist() # Convert numpy array to list + else: + value = [value] # Received single value, convert to list + data = { + attr_name: {"value": value, "timestamp": kwargs.get("timestamp") or time.time()} + } + mca_channel.put(data) + # self._received_updates.update(data) + # if len(self._received_updates) == self.num_connected_channels: + # # Send out data on multi async signal + # self.mca.put(self._received_updates, acquisition_group=self._acquisition_group) + # self._received_updates.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) + value=value, + max_value=self._num_total_triggers, + done=bool(value == self._num_total_triggers), ) def on_stage(self) -> None: @@ -243,25 +420,21 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): """ self.erase_all.set(1).wait(timeout=self._pv_timeout) triggers = self.scan_info.msg.scan_parameters.get("frames_per_trigger", 1) + num_points = self.scan_info.msg.num_points + self._num_total_triggers = triggers * num_points + self._acquisition_group = "monitored" if triggers == 1 else "burst_group" self.preset_real.set(0).wait(timeout=self._pv_timeout) self.num_use_all.set(triggers).wait(timeout=self._pv_timeout) + # Reset data + self._received_updates.clear() def on_unstage(self) -> None: """ Called when the device is unstaged. """ 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: - 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. @@ -279,6 +452,3 @@ 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 14c56939bf64c335eb0cf18ee2a136059177a010 Mon Sep 17 00:00:00 2001 From: appel_c Date: Tue, 9 Dec 2025 09:58:46 +0100 Subject: [PATCH 2/9] fix(ddg): adapt DDG, remove mcs.readytoread --- csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py | 7 +------ 1 file changed, 1 insertion(+), 6 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 bbe621d..5c6704e 100644 --- a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py @@ -52,7 +52,7 @@ from csaxs_bec.devices.epics.delay_generator_csaxs.delay_generator_csaxs import LiteralChannels, StatusBitsCompareStatus, ) -from csaxs_bec.devices.epics.mcs_card.mcs_card_csaxs import ACQUIRING, READYTOREAD +from csaxs_bec.devices.epics.mcs_card.mcs_card_csaxs import ACQUIRING if TYPE_CHECKING: # pragma: no cover from bec_lib.devicemanager import DeviceManagerBase, ScanInfo @@ -169,14 +169,9 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): 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 -- 2.49.1 From 188e23df4845efafa81efd3b567be2be4ac52076 Mon Sep 17 00:00:00 2001 From: appel_c Date: Tue, 9 Dec 2025 10:00:16 +0100 Subject: [PATCH 3/9] fix: Fix MCS card and DDG implementation after testing with hardware at cSAXS --- .../epics/delay_generator_csaxs/ddg_1.py | 112 +++- .../delay_generator_csaxs.py | 13 +- csaxs_bec/devices/epics/mcs_card/mcs_card.py | 7 +- .../devices/epics/mcs_card/mcs_card_csaxs.py | 488 +++++++----------- tests/tests_devices/test_mcs_card.py | 7 +- 5 files changed, 290 insertions(+), 337 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 5c6704e..6d92e96 100644 --- a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py @@ -127,14 +127,27 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): Sets DEFAULT_IO_CONFIG into each channel, sets the trigger source to DEFAULT_TRIGGER_SOURCE, and turns off burst mode. """ - self.burst_disable() # it is possible to miss setting settings if burst is enabled + # NOTE First we make sure that there is nothing running on the DDG. This seems to + # help to tackle that the DDG occasionally freezes during the first scan + # after reconnecting to it. Do not remove. + self.stop_ddg() + + # NOTE Setting DEFAULT configurations for IO config, trigger config and references. + # The three dictionaries above 'DEFAULT_IO_CONFIG', 'DEFAULT_TRIGGER_SOURCE' and + # 'DEFAULT_REFERNCES' should be used to adapt configurations if needed. for channel, config in DEFAULT_IO_CONFIG.items(): 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) + + # NOTE Set state proc_status to be event based. This triggers readouts of the EventStatusLI bit + # based on events. This was empirically found to be a stable solution in combination with the poll + # loop of the state. self.state.proc_status_mode.put(PROC_EVENT_MODE.EVENT) + # NOTE Burst delay should be set to 0, don't remove as this will not be checked + self.burst_delay.put(0) + def on_stage(self) -> None: """ Stage logic for the DDG1 device, being th main trigger delay generator for CSAXS. @@ -143,45 +156,87 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): This DDG is always not in burst mode. """ + # NOTE Only set relevant channels on burst_mode channel + # After mutliple tests with the HW, this procedure has been determined empirically + # to improve stability and avoid HW getting stuck in triggering cycles + # Please also note that this should happen first, before setting delay times on the chabnnels. + if self.burst_mode.get() == 0: + self.burst_mode.put(1) + 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"] + if self.burst_period.get() != exp_time: + self.burst_period.put(exp_time) + + if self.burst_delay.get() != 0: + self.burst_delay.put(0) + + ######################################### + ### Setup delay pairs for acquisition ### + ######################################### + frames_per_trigger = self.scan_info.msg.scan_parameters["frames_per_trigger"] + # Trigger DDG2 # a = t0 + 2ms, b = a + 1us # a has reference to t0, b has reference to a self.set_delay_pairs(channel="ab", delay=2e-3, width=1e-6) + # Trigger shutter - shutter_width = 2e-3 + exp_time * frames_per_trigger + 1e-3 # d = c/t0 + 2ms + exp_time * burst_count + 1ms # c has reference to t0, d has reference to c + shutter_width = 2e-3 + exp_time * frames_per_trigger + 1e-3 self.set_delay_pairs(channel="cd", delay=0, width=shutter_width) + # Trigger extra pulse for MCS OR gate # f = e + 1us # e has refernce to d, f has reference to e self.set_delay_pairs(channel="ef", delay=0, width=1e-6) - time.sleep( - 0.2 - ) # After staging, make sure that the DDG HW has some time to process changes properly. + + # NOTE Add additional sleep to make sure that the IOC and DDG HW process the values properly + # This value has been choosen empirically after testing with the HW. Please acknowledge that + # this is called in parallel, so it should not add significant overhead to acquisition. It's + # also just called once per scan. + time.sleep(0.2) 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. """ - mcs.stop_all.put(1) + + # NOTE: It is crucial to first wait for the MCS card to finish it's acquisition before + # the DDG moves on to the next trigger cycle. + status = CompareStatus(mcs.acquiring, ACQUIRING.DONE) + self.cancel_on_stop(status) + status.wait(timeout=5) + + # NOTE: Important logic on the MCS card, this makes sure that callbacks from the MCA channels + # are not surpressed. Please check MCS card and 'erase_all' comment. + mcs._omit_mca_callbacks.clear() status_acquiring = TransitionStatus(mcs.acquiring, [ACQUIRING.DONE, ACQUIRING.ACQUIRING]) self.cancel_on_stop(status_acquiring) - mcs.erase_start.put(1) - status_acquiring.wait(timeout=10) # Allow 10 seconds in case communication is slow + mcs.erase_start.put(1) + + # NOTE: Now we wait for the card to go to Acuiring after we've called erase_start + # Please increase the timeout if this turns out to be problematic + status_acquiring.wait(timeout=3) 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. """ + # NOTE hook to kill the loop, only needed if device is destroyed while not self._poll_thread_kill_event.is_set(): + + # The thread will wait in this event if IDLE. Polling can be started + # by setting 'poll_thread_run_event.set()'. Please check usage for software + # triggered scans from BEC within on_trigger. self._poll_thread_run_event.wait() + + # NOTE Event to indicate that polling is taking place currently. This is needed as there + # are sleeps of 20ms in the poll loop which were empirically determined after long testing + # to improve stability in communication with the HW. self._poll_thread_poll_loop_done.clear() while ( self._poll_thread_run_event.is_set() and not self._poll_thread_kill_event.is_set() @@ -193,29 +248,36 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): logger.error( f"Exception in polling loop thread, polling continues...\n Error content:\n{content}" ) - + # NOTE Important to set the event again. The next trigger loop waits for the poll thread to become + # IDLE again. Do not remove. 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. + IMPORTANT: Do not remove sleeps or try to optimize this logic. This seems to be a fragile balance between polling frequency and device processing time. Also in between start/stop of polling. Please also consider that there is a sleep in on_trigger and that this might also be necessary to avoid that HW becomes unavailable/unstable. """ self.state.proc_status.put(1, use_complete=True) - time.sleep(0.02) # 20ms delay for processing, important for not missing events + #NOTE: Important sleep that has been empirically determined after testing for a long time + # Only remove if absolutely certain that the DDG logic of polling the EventStatusLI works without it. + time.sleep(0.02) if self._poll_thread_kill_event.is_set() or not self._poll_thread_run_event.is_set(): return self.state.event_status.get(use_monitor=False) if self._poll_thread_kill_event.is_set() or not self._poll_thread_run_event.is_set(): return - time.sleep(0.02) # 20ms delay for processing, important for not missing events + #NOTE: Again important sleep that has been empirically determined after testing for a long time + # Only remove if certain that logic can be replaced to not risk HW failures. + time.sleep(0.02) def _start_polling(self) -> None: """Start the polling loop in the background thread.""" @@ -246,7 +308,8 @@ 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._stop_polling() + if status.done: + self._stop_polling() # Run false is important to ensure that the status is only checked on the next event status update status = StatusBitsCompareStatus( @@ -257,20 +320,27 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): return status 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. + """ + This method is called from BEC as a software trigger. + It first stops any active polling if still running. The sleep of 20ms is important + for proper functionality of the card. Then it checks if the 'mcs' card is in the config + and enabled, and prepares the card for triggering. For now this is still relevant, but may + be moved to a high level logic in BEC in the future (neeeds). + + Then a status_object is prepared that receives the EventStatusLI epics channel (self.state.event_status), + and attaches a callback that resolves once the burst is done. The polling thread is enabled to manually + trigger a reading of the event status before a software trigger is sent via trigger_shot. """ # Stop polling, poll once manually to ensure that the register is clean self._stop_polling() self._poll_thread_poll_loop_done.wait(timeout=1) - # IMPORTANT: Keep this sleep setting, as it is necessary to avoid that the HW - # becomes unresponsive. This was found empirically and seems to be necessary + # NOTE: THis sleep is important for the HW to process the event and avoid that + # becomes unresponsive. This was found empirically after long testing. time.sleep(0.02) # Prepare the MCS card for the next software trigger mcs = self.device_manager.devices.get("mcs", None) - if mcs is None: + if mcs is None or mcs.enabled is False: logger.info("Did not find mcs card with name 'mcs' in current session") else: self._prepare_mcs_on_trigger(mcs) 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 33dcec2..a917022 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 @@ -3,6 +3,11 @@ Delay generator implementation for CSAXS. Detailed information can be found in the manual: https://www.thinksrs.com/downloads/pdfs/manuals/DG645m.pdf + +On the beamline consoles, the caqtdm panel can be started via: +caqtdm -noMsg -attach -macro P=X12SA-CPCL-DDG,R=1: srsDG645.ui + +R=1,2,3 for 3 different DDG units installed at CSAXS. """ import enum @@ -151,8 +156,9 @@ class StatusBitsCompareStatus(SubscriptionStatus): run=run, ) - def _compare_callback(self, value, **kwargs) -> bool: + def _compare_callback(self, *args, value, **kwargs) -> bool: """Callback for subscription status""" + logger.info(f"StatusBitsCompareStatus: Received value {value}") obj = kwargs.get("obj", None) if obj is None: name = "no object received" @@ -167,7 +173,9 @@ class StatusBitsCompareStatus(SubscriptionStatus): return False if self._add_delay != 0: time.sleep(self._add_delay) - + logger.info( + f"Returning comparison for {name}: {(STATUSBITS(value) & self._value) == self._value}" + ) return (STATUSBITS(value) & self._value) == self._value @@ -533,6 +541,7 @@ class DelayGeneratorCSAXS(Device): write_pv="BurstDelayAO", name="burst_delay", kind=Kind.omitted, + auto_monitor=True, doc="Delay before bursts start in seconds. Must be >=0.", ) burst_period = Cpt( diff --git a/csaxs_bec/devices/epics/mcs_card/mcs_card.py b/csaxs_bec/devices/epics/mcs_card/mcs_card.py index eb7d091..ef0360e 100644 --- a/csaxs_bec/devices/epics/mcs_card/mcs_card.py +++ b/csaxs_bec/devices/epics/mcs_card/mcs_card.py @@ -170,11 +170,12 @@ class MCSCard(Device): kind=Kind.omitted, doc="Indicates whether the SNL program has connected to all PVs.", ) + # NOTE: Please note that the erase_all command sends the mca or waveform records to process after erasing, potentially also values of 0. This logic needs to be considered when running callbacks on the mca channels. 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.", + doc="Erases all mca or waveform records, setting elapsed times and counts in all channels to 0. Please note that this operation sends the mca or waveform records to process after erasing, potentially also 0s.", ) erase_start = Cpt( EpicsSignal, @@ -192,6 +193,7 @@ class MCSCard(Device): EpicsSignalRO, "Acquiring", kind=Kind.omitted, + auto_monitor=True, 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.") @@ -279,11 +281,12 @@ class MCSCard(Device): 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.", ) + # NOTE: Setting mux_output programmatically results in occasional errors on the IOC; it is recommended to avoid using it. 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.", + doc="Value of 0-32 used to select which input signal is routed to output signal 7 on the SIS3820 in output mode 3. NOTE: This settings seems to occasionally result in errors on the IOC; it is recommended to avoid using it.", ) user_led = Cpt( EpicsSignal, 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 066c311..e86fa76 100644 --- a/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py +++ b/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py @@ -2,15 +2,19 @@ from __future__ import annotations +import threading import time +import traceback +from contextlib import contextmanager +from functools import partial from threading import RLock -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING, Callable, Literal import numpy as np from bec_lib.logger import bec_logger from ophyd import Component as Cpt -from ophyd import EpicsSignalRO, Kind, SignalRO -from ophyd_devices import AsyncSignal, CompareStatus, ProgressSignal, TransitionStatus +from ophyd import EpicsSignalRO, Kind +from ophyd_devices import AsyncSignal, CompareStatus, ProgressSignal, StatusBase from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase from ophyd_devices.utils.bec_signals import AsyncMultiSignal @@ -26,6 +30,23 @@ from csaxs_bec.devices.epics.mcs_card.mcs_card import ( MCSCard, ) + +@contextmanager +def suppress_mca_callbacks(mcs_card: MCSCard): + """ + Context manager to temporarily disable MCA channel callbacks. + Required to avoid additional callbacks when erasing all channels. + + Args: + mcs_card (MCSCard): The MCSCard instance to suppress callbacks for. + """ + mcs_card._omit_mca_callbacks.set() + try: + yield + finally: + pass + + if TYPE_CHECKING: # pragma: no cover from bec_lib.devicemanager import DeviceManagerBase, ScanInfo @@ -36,276 +57,27 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): """ Implementation of the MCSCard SIS3820 for CSAXS, prefix 'X12SA-MCS:'. The basic functionality is inherited from the MCSCard class. + + Please note that the number of channels is fixed to 32, so there will be data for all + 32 channels even if not all channels are used in the experiment. This setting can not + be realibly changed on the SIS3820 card's IOC through mux_output, so it is fixed here. + Mux_output should therefore also be set to 32 in the IOC configuration. """ + USER_ACCESS = ["mcs_recovery"] + NUM_MCA_CHANNELS: int = 32 # All counter from the MCS card. - # mca = Cpt( - # AsyncMultiSignal, - # name="counters", - # signals=[ - # f"mca{i}" for i in range(1, 33) - # ], # This needs to be in sync with counters DynamicDeviceComponent - # ndim=0, - # async_update={"type": "add", "max_shape": [None]}, - # max_size=1000, - # kind=Kind.normal, - # doc="AsyncMultiSignal for MCA card channels 1-32", - # ) - mca1 = Cpt( - AsyncSignal, - name="mca1", - kind=Kind.normal, + mca = Cpt( + AsyncMultiSignal, + name="counters", + signals=[ + f"mca{i}" for i in range(1, 33) + ], # This needs to be in sync with counters DynamicDeviceComponent + ndim=1, async_update={"type": "add", "max_shape": [None]}, max_size=1000, - doc="AsyncSignal for MCA channel 1", - ) - mca2 = Cpt( - AsyncSignal, - name="mca2", kind=Kind.normal, - async_update={"type": "add", "max_shape": [None]}, - max_size=1000, - doc="AsyncSignal for MCA channel 2", - ) - mca3 = Cpt( - AsyncSignal, - name="mca3", - kind=Kind.normal, - async_update={"type": "add", "max_shape": [None]}, - max_size=1000, - doc="AsyncSignal for MCA channel 3", - ) - mca4 = Cpt( - AsyncSignal, - name="mca4", - kind=Kind.normal, - async_update={"type": "add", "max_shape": [None]}, - max_size=1000, - doc="AsyncSignal for MCA channel 4", - ) - mca5 = Cpt( - AsyncSignal, - name="mca5", - kind=Kind.normal, - async_update={"type": "add", "max_shape": [None]}, - max_size=1000, - doc="AsyncSignal for MCA channel 5", - ) - mca6 = Cpt( - AsyncSignal, - name="mca6", - kind=Kind.normal, - async_update={"type": "add", "max_shape": [None]}, - max_size=1000, - doc="AsyncSignal for MCA channel 6", - ) - mca7 = Cpt( - AsyncSignal, - name="mca7", - kind=Kind.normal, - async_update={"type": "add", "max_shape": [None]}, - max_size=1000, - doc="AsyncSignal for MCA channel 7", - ) - mca8 = Cpt( - AsyncSignal, - name="mca8", - kind=Kind.normal, - async_update={"type": "add", "max_shape": [None]}, - max_size=1000, - doc="AsyncSignal for MCA channel 8", - ) - mca9 = Cpt( - AsyncSignal, - name="mca9", - kind=Kind.normal, - async_update={"type": "add", "max_shape": [None]}, - max_size=1000, - doc="AsyncSignal for MCA channel 9", - ) - mca10 = Cpt( - AsyncSignal, - name="mca10", - kind=Kind.normal, - async_update={"type": "add", "max_shape": [None]}, - max_size=1000, - doc="AsyncSignal for MCA channel 10", - ) - mca11 = Cpt( - AsyncSignal, - name="mca11", - kind=Kind.normal, - async_update={"type": "add", "max_shape": [None]}, - max_size=1000, - doc="AsyncSignal for MCA channel 11", - ) - mca12 = Cpt( - AsyncSignal, - name="mca12", - kind=Kind.normal, - async_update={"type": "add", "max_shape": [None]}, - max_size=1000, - doc="AsyncSignal for MCA channel 12", - ) - mca13 = Cpt( - AsyncSignal, - name="mca13", - kind=Kind.normal, - async_update={"type": "add", "max_shape": [None]}, - max_size=1000, - doc="AsyncSignal for MCA channel 13", - ) - mca14 = Cpt( - AsyncSignal, - name="mca14", - kind=Kind.normal, - async_update={"type": "add", "max_shape": [None]}, - max_size=1000, - doc="AsyncSignal for MCA channel 14", - ) - mca15 = Cpt( - AsyncSignal, - name="mca15", - kind=Kind.normal, - async_update={"type": "add", "max_shape": [None]}, - max_size=1000, - doc="AsyncSignal for MCA channel 15", - ) - mca16 = Cpt( - AsyncSignal, - name="mca16", - kind=Kind.normal, - async_update={"type": "add", "max_shape": [None]}, - max_size=1000, - doc="AsyncSignal for MCA channel 16", - ) - mca17 = Cpt( - AsyncSignal, - name="mca17", - kind=Kind.normal, - async_update={"type": "add", "max_shape": [None]}, - max_size=1000, - doc="AsyncSignal for MCA channel 17", - ) - mca18 = Cpt( - AsyncSignal, - name="mca18", - kind=Kind.normal, - async_update={"type": "add", "max_shape": [None]}, - max_size=1000, - doc="AsyncSignal for MCA channel 18", - ) - mca19 = Cpt( - AsyncSignal, - name="mca19", - kind=Kind.normal, - async_update={"type": "add", "max_shape": [None]}, - max_size=1000, - doc="AsyncSignal for MCA channel 19", - ) - mca20 = Cpt( - AsyncSignal, - name="mca20", - kind=Kind.normal, - async_update={"type": "add", "max_shape": [None]}, - max_size=1000, - doc="AsyncSignal for MCA channel 20", - ) - mca21 = Cpt( - AsyncSignal, - name="mca21", - kind=Kind.normal, - async_update={"type": "add", "max_shape": [None]}, - max_size=1000, - doc="AsyncSignal for MCA channel 21", - ) - mca22 = Cpt( - AsyncSignal, - name="mca22", - kind=Kind.normal, - async_update={"type": "add", "max_shape": [None]}, - max_size=1000, - doc="AsyncSignal for MCA channel 22", - ) - mca23 = Cpt( - AsyncSignal, - name="mca23", - kind=Kind.normal, - async_update={"type": "add", "max_shape": [None]}, - max_size=1000, - doc="AsyncSignal for MCA channel 23", - ) - mca24 = Cpt( - AsyncSignal, - name="mca24", - kind=Kind.normal, - async_update={"type": "add", "max_shape": [None]}, - max_size=1000, - doc="AsyncSignal for MCA channel 24", - ) - mca25 = Cpt( - AsyncSignal, - name="mca25", - kind=Kind.normal, - async_update={"type": "add", "max_shape": [None]}, - max_size=1000, - doc="AsyncSignal for MCA channel 25", - ) - mca26 = Cpt( - AsyncSignal, - name="mca26", - kind=Kind.normal, - async_update={"type": "add", "max_shape": [None]}, - max_size=1000, - doc="AsyncSignal for MCA channel 26", - ) - mca27 = Cpt( - AsyncSignal, - name="mca27", - kind=Kind.normal, - async_update={"type": "add", "max_shape": [None]}, - max_size=1000, - doc="AsyncSignal for MCA channel 27", - ) - mca28 = Cpt( - AsyncSignal, - name="mca28", - kind=Kind.normal, - async_update={"type": "add", "max_shape": [None]}, - max_size=1000, - doc="AsyncSignal for MCA channel 28", - ) - mca29 = Cpt( - AsyncSignal, - name="mca29", - kind=Kind.normal, - async_update={"type": "add", "max_shape": [None]}, - max_size=1000, - doc="AsyncSignal for MCA channel 29", - ) - mca30 = Cpt( - AsyncSignal, - name="mca30", - kind=Kind.normal, - async_update={"type": "add", "max_shape": [None]}, - max_size=1000, - doc="AsyncSignal for MCA channel 30", - ) - mca31 = Cpt( - AsyncSignal, - name="mca31", - kind=Kind.normal, - async_update={"type": "add", "max_shape": [None]}, - max_size=1000, - doc="AsyncSignal for MCA channel 31", - ) - mca32 = Cpt( - AsyncSignal, - name="mca32", - kind=Kind.normal, - async_update={"type": "add", "max_shape": [None]}, - max_size=1000, - doc="AsyncSignal for MCA channel 32", + doc="AsyncMultiSignal for MCA card channels 1-32", ) # Progress Signal progress = Cpt(ProgressSignal, doc="ProgressSignal indicating the progress of the device") @@ -314,7 +86,6 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): self, name: str, prefix: str = "", - num_connected_channels: int = 5, scan_info: ScanInfo | None = None, device_manager: DeviceManagerBase | None = None, **kwargs, @@ -328,29 +99,36 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): self._mcs_clock = 1e7 # 10MHz clock -> 1e7 Hz self._pv_timeout = 2.0 # seconds self._rlock = RLock() # Needed to ensure thread safety for counter updates - self.num_connected_channels = num_connected_channels - self._received_updates: dict[ - str, dict[Literal["value", "timestamp"], list[int] | float] - ] = {} self._acquisition_group: str = "monitored" self._num_total_triggers: int = 0 + # Event logic to schedule async data emission & monitoring + # Please note that complete needs to wait until all data was sent + # This requires additional logic and a thread to monitor the data emission + self._scan_done_thread_kill_event: threading.Event = threading.Event() + self._start_monitor_async_data_emission: threading.Event = threading.Event() + self._scan_done_callbacks: list[Callable[[], None]] = [] + self._scan_done_thread: threading.Thread = threading.Thread( + target=self._monitor_async_data_emission, daemon=True + ) + self._current_data_index: int = 0 + self._mca_counter_index: int = 0 + self._current_data: dict[str, dict[Literal["value", "timestamp"], list[int] | float]] = {} + self._omit_mca_callbacks: threading.Event = threading.Event() + def on_connected(self): """ Called when the device is connected. """ - # Make sure card is not running self.stop_all.put(1) + # Setup the MCS card settings self.channel_advance.set(CHANNELADVANCE.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) self.user_led.set(0).wait(timeout=self._pv_timeout) - # Set mux_output to number of connected channels. Connect channels in increasing order - self.mux_output.set(self.num_connected_channels).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) @@ -367,11 +145,17 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): # Subscribe the progress signal self.current_channel.subscribe(self._progress_update, run=False) + self.mcs_recovery() + # Subscribe to the mca updates for sig in self.counters.component_names: sig_obj: EpicsSignalRO = getattr(self.counters, sig) sig_obj.subscribe(self._on_counter_update, run=False) + # Start monitoring thread + self._scan_done_thread.start() + + def _on_counter_update(self, value, **kwargs) -> None: """ Callback for counter updates of the mca channels (1-32). @@ -381,74 +165,166 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): """ with self._rlock: + self._mca_counter_index += 1 + if self._omit_mca_callbacks.is_set(): + return # Suppress callbacks when erasing all channels signal = kwargs.get("obj", None) if signal is None: logger.error(f"Called without 'obj' in kwargs: {kwargs}") return - attr_name = signal.name - mca_channel = getattr(self, attr_name, None) - if mca_channel is None: - logger.error(f"Could not find matching MCA channel for signal {signal.name}") + signal: EpicsSignalRO + attr_name = signal.attr_name + + # Ignore updates for channels that are not setup through num_connected_channels + index = int(attr_name[3:]) # Extract index from 'mcaX' + if index > self.NUM_MCA_CHANNELS: return - mca_channel: AsyncSignal if isinstance(value, np.ndarray): value = value.tolist() # Convert numpy array to list else: value = [value] # Received single value, convert to list - data = { - attr_name: {"value": value, "timestamp": kwargs.get("timestamp") or time.time()} - } - mca_channel.put(data) - # self._received_updates.update(data) - # if len(self._received_updates) == self.num_connected_channels: - # # Send out data on multi async signal - # self.mca.put(self._received_updates, acquisition_group=self._acquisition_group) - # self._received_updates.clear() + self._current_data.update( + {attr_name: {"value": value, "timestamp": kwargs.get("timestamp") or time.time()}} + ) + if len(self._current_data) == self.NUM_MCA_CHANNELS: + # Send out data on multi async signal + self.mca.put(self._current_data, acquisition_group=self._acquisition_group) + self._current_data.clear() + self._mca_counter_index = 0 + self._current_data_index += 1 - def _progress_update(self, value, **kwargs) -> None: + def _progress_update(self, *args, old_value: any, value: any, **kwargs) -> None: """Callback for progress updates from ophyd subscription on current_channel.""" - self.progress.put( - value=value, - max_value=self._num_total_triggers, - done=bool(value == self._num_total_triggers), - ) + scan_done = bool(value == self._num_total_triggers) + self.progress.put(value=value, max_value=self._num_total_triggers, done=scan_done) + if scan_done: + self._scan_done_event.set() def on_stage(self) -> None: """ Called when the device is staged. """ - self.erase_all.set(1).wait(timeout=self._pv_timeout) + + # NOTE: If for some reason, the card is still acquiring, we need to stop it first + # This should never happen as the card is properly stopped during unstage + # Can only happen if user manually interferes with the IOC through other means + if self.acquiring.get() == ACQUIRING.ACQUIRING: + logger.warning( + f"MCS Card {self.name} was still acquiring on staging. Stopping acquisition." + ) + self.stop_all.put(1) + status = CompareStatus(self.acquiring, ACQUIRING.DONE) + status.wait(timeout=10) + + # NOTE: Erase all will result in data emission through mca callback subscriptions + # The buffer needs to be cleared as this will otherwise lead to missing + # triggers during the scan. Again, this should not happen if unstage is properly called. + # But user interference or a restart of the device_server may lead to this situation. + # self.erase_all.put(1) + # time.sleep(3) + if self.current_channel.get() != 0: + with suppress_mca_callbacks(self): + logger.warning( + f"MCS Card {self.name} had still data in buffer Erased all data on staging and sleeping for 1 second." + ) + # Erase all data on the MCS card + self.erase_all.put(1) + time.sleep(1) # Allow time to process erase + triggers = self.scan_info.msg.scan_parameters.get("frames_per_trigger", 1) num_points = self.scan_info.msg.num_points self._num_total_triggers = triggers * num_points self._acquisition_group = "monitored" if triggers == 1 else "burst_group" self.preset_real.set(0).wait(timeout=self._pv_timeout) self.num_use_all.set(triggers).wait(timeout=self._pv_timeout) - # Reset data - self._received_updates.clear() + + # Make sure to clear any remaining data in the local buffer + with self._rlock: + self._current_data.clear() + # Reset monitoring of async data emission + self._start_monitor_async_data_emission.clear() + self._scan_done_callbacks.clear() + self._current_data_index = 0 def on_unstage(self) -> None: """ Called when the device is unstaged. """ self.stop_all.put(1) - self.erase_all.set(0).wait(timeout=self._pv_timeout) + # Make sure that upon unstaging, all data on the MCS card is erased. + with suppress_mca_callbacks(self): + with self._rlock: + self._current_data.clear() + self.erase_all.put(1) - def on_pre_scan(self) -> None: - """ - Called before the scan starts. - """ + def _monitor_async_data_emission(self, timeout: int = 10) -> None: + """Monitor data emission after scan is done.""" + while not self._scan_done_thread_kill_event.is_set(): + while self._start_monitor_async_data_emission.wait(): + try: + if self._current_data_index == self.scan_info.msg.num_points: + for callback in self._scan_done_callbacks: + callback() + time.sleep(0.02) # 20ms delay to avoid busy loop + except Exception: # pylint: disable=broad-except + content = traceback.format_exc() + logger.error( + f"Exception in monitoring thread of complete for {self.name}:\n{content}" + "Running callbacks to avoid deadlock." + ) + for callback in self._scan_done_callbacks: + callback() + + def _status_callback(self, status: StatusBase) -> None: + """Callback for status completion.""" + if not status.done: + status.set_finished() + self._start_monitor_async_data_emission.clear() # Stop monitoring + + def _status_failed_callback(self, status: StatusBase) -> None: + """Callback for status failure.""" + if status.done and not status.success: + self._start_monitor_async_data_emission.clear() # Stop monitoring def on_complete(self) -> CompareStatus: """On scan completion.""" - # Check if we should get a signal based on updates from the MCA channels + # Prepare callback for data emission done + status_async_data = StatusBase(obj=self) + self._scan_done_callbacks.append(partial(self._status_callback, status_async_data)) + + # Start done callback loop + self._start_monitor_async_data_emission.set() + # Add CompareStatus for Acquiring DONE status = CompareStatus(self.acquiring, ACQUIRING.DONE) - self.cancel_on_stop(status) + + # Combine both statuses + ret_status = status & status_async_data + ret_status.add_callback(self._status_failed_callback) + self.cancel_on_stop(ret_status) return status + def on_destroy(self): + self._scan_done_thread_kill_event.set() + self._start_monitor_async_data_emission.set() + if self._scan_done_thread.is_alive(): + self._scan_done_thread.join(timeout=2.0) + if self._scan_done_thread.is_alive(): + logger.warning(f"Thread for device {self.name} did not terminate properly.") + def on_stop(self) -> None: """ Called when the scan is stopped. """ self.stop_all.put(1) + self.erase_all.put(1) + + def mcs_recovery(self, timeout:int=1) -> None: + """Recovery procedure for the mcs card""" + sleep_time = timeout / 2 # 2 sleeps + logger.info(f"Running recovery procedure for MCS card {self.name} with {sleep_time}s sleep, calling stop_all and erase_all, and another {sleep_time}s sleep") + self.erase_start.put(1) + time.sleep(sleep_time) + self.stop_all.put(1) + self.erase_all.put(1) + time.sleep(sleep_time) \ No newline at end of file diff --git a/tests/tests_devices/test_mcs_card.py b/tests/tests_devices/test_mcs_card.py index 034a80a..c829988 100644 --- a/tests/tests_devices/test_mcs_card.py +++ b/tests/tests_devices/test_mcs_card.py @@ -21,7 +21,7 @@ from csaxs_bec.devices.epics.mcs_card.mcs_card import ( READMODE, MCSCard, ) -from csaxs_bec.devices.epics.mcs_card.mcs_card_csaxs import READYTOREAD, MCSCardCSAXS +from csaxs_bec.devices.epics.mcs_card.mcs_card_csaxs import MCSCardCSAXS @pytest.fixture(scope="function") @@ -85,8 +85,6 @@ def test_mcs_card_csaxs_on_connected(mock_mcs_csaxs): 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 @@ -122,7 +120,6 @@ def test_mcs_card_csaxs_unstage(mock_mcs_csaxs): 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 @@ -133,14 +130,12 @@ def test_mcs_card_csaxs_complete_and_stop(mock_mcs_csaxs): 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 def test_mcs_card_csaxs_on_counter_updated(mock_mcs_csaxs): -- 2.49.1 From 48ad1b334c00a21044b77476d80910dde69b132e Mon Sep 17 00:00:00 2001 From: appel_c Date: Fri, 19 Dec 2025 14:17:34 +0100 Subject: [PATCH 4/9] docs: Add documentation to MCS and DDG modules --- .../epics/delay_generator_csaxs/README.md | 58 +++ .../epics/delay_generator_csaxs/ddg_1.py | 288 ++++++++++----- .../epics/delay_generator_csaxs/ddg_2.py | 134 ++++--- csaxs_bec/devices/epics/mcs_card/README.md | 13 + .../devices/epics/mcs_card/mcs_card_csaxs.py | 331 ++++++++++++++---- 5 files changed, 630 insertions(+), 194 deletions(-) create mode 100644 csaxs_bec/devices/epics/delay_generator_csaxs/README.md create mode 100644 csaxs_bec/devices/epics/mcs_card/README.md diff --git a/csaxs_bec/devices/epics/delay_generator_csaxs/README.md b/csaxs_bec/devices/epics/delay_generator_csaxs/README.md new file mode 100644 index 0000000..439d920 --- /dev/null +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/README.md @@ -0,0 +1,58 @@ +# Delay Generator implementation at the CSAXS beamline + +This module provides an ophyd device implementation for the Stanford Research Systems Delay Generator DDG645, used at the cSAXS beamline as a master timing source for detector triggering and other beamline devices. Detailed information about the DDG manual can be found here: +https://www.thinksrs.com/downloads/pdfs/manuals/DG645m.pdf. +The implementation is based on a community EPICS driver (https://github.com/epics-modules/delaygen?tab=readme-ov-file). + +**EPICS Interface** + +At the cSAXS beamline, the DDG panel is avaiable via caqtdm on the beamline consoles. + +``` bash +caqtdm -noMsg -attach -macro P=X12SA-CPCL-DDG,R=1: srsDG645.ui +``` +with R=1,2,3,4,5 for 5 different DDG units installed at CSAXS. + +# Ophyd Device integration at cSAXS +For cSAXS, a custom ophyd device class implementation of the DDG is provided [here](./delay_generator_csaxs.py). This class provides a basic interface to the DDG PVs. The interface provides channels 'A', B', 'C', ... with setpoint, readback and references, as well as high level parameters such as *width* and *delay*. Please check the source code of the class for more details of the implementation. + +In addition, the class provides a set of utility methods to configure sets of channel pairs 'AB', 'CD', ... as commonly needed in operation at the beamline. At the cSAXS beamline, a single DDG device is used as a master timing source for other devices. The general scheme is described in a [PDF document here](./trigger_scheme_ddg1_ddg2.pdf). Below is a description of the configuration of the two DDG units used at cSAXS for detector triggering and beamline shutter control. + +## Master card: DDG1 (X12SA-CPCL-DDG1) +The master [delay generator DDG1](./ddg_1.py) is configured to provide the following signals: + +**Connection Scheme**: +- EXT/EN: May be connected to external devices, e.g. SGalil motion controller for fly scans. +- Operation Mode: Burst mode, but with single burst (burst count = 1). This is for practical reasons as it allows +to interrupt and ongoing sequence if needed. +- Software Trigger: Controlled through BEC. +- State Control: BEC checks the *state* of this DDG to wait for the completion of a timing sequence. + +**Delay Pairs**: +- DelayPair 'AB': Provides the external enable (EXT/EN) signal to the second DDG (R=2). +- DelayPair 'CD': Controls the beamline shutter. +- DelayPair 'EF': Generates pulses for the MCS card, combined with the detector pulse train via an OR gate. This ensures the MCS card receives an additional pulse required for proper operation. + +**Delay Channels**: +- a = t0 + 2ms (2ms delay to allow the shutter to open) +- b = a + 1us (short pulse) +- c = t0 +- d = a + exp_time * burst_count + 1ms (to allow the shutter to close) +- e = d +- f = e + 1us (short pulse to OR gate for MCS triggering) + +## Detector card: DDG2 (X12SA-CPCL-DDG2) +The second [delay generator DDG2](./ddg_2.py) is configured to provide the following signals: + +**Connection Scheme**: +- EXT/EN: Connected to the DelayPair AB of the master DDG (R=1). +- Operation Mode: Burst mode: The *burst count* is set to the number of frames per trigger. The *burst delay* is set to 0, and the *burst period* is set to the exposure time. +- Software Trigger: Irrelevant, as the device is externally triggered by DDG1. + +**Delay Pairs**: +- DelayPair 'AB': Provides the trigger signal to the detector. + +**Delay Channels**: +- a = t0 +- b = a + (exp_time - READOUT_TIMES) + 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 6d92e96..628617f 100644 --- a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py @@ -61,6 +61,13 @@ if TYPE_CHECKING: # pragma: no cover logger = bec_logger.logger +######################## +## DEFAULT SETTINGS #### +######################## + +# NOTE Default channel configuration for all channels of the DDG1 delay generator +# This can be adapted as needed, or fine-tuned per channel. On every reload of the +# device configuration in BEC, these values will be set into the DDG1 device. _DEFAULT_CHANNEL_CONFIG: ChannelConfig = { "amplitude": 5.0, "offset": 0.0, @@ -68,6 +75,8 @@ _DEFAULT_CHANNEL_CONFIG: ChannelConfig = { "mode": "ttl", } +# NOTE Here you can adapt the default IO configuration for all channels of the DDG1 +# Currently, all channels are set to the same default configuration `_DEFAULT_CHANNEL_CONFIG`. DEFAULT_IO_CONFIG: dict[AllChannelNames, ChannelConfig] = { "t0": _DEFAULT_CHANNEL_CONFIG, "ab": _DEFAULT_CHANNEL_CONFIG, @@ -75,9 +84,19 @@ DEFAULT_IO_CONFIG: dict[AllChannelNames, ChannelConfig] = { "ef": _DEFAULT_CHANNEL_CONFIG, "gh": _DEFAULT_CHANNEL_CONFIG, } + DEFAULT_TRIGGER_SOURCE: TRIGGERSOURCE = TRIGGERSOURCE.SINGLE_SHOT + +# NOTE Default readout times for each channel, can be adapted as needed. +# These values are relevant to calculate proper widths of the timing signals. +# They also define a minimum exposure time that can be used as they are subtracted +# as dead times from the exposure time. DEFAULT_READOUT_TIMES = {"ab": 2e-4, "cd": 2e-4, "ef": 2e-4, "gh": 2e-4} # 0.2 ms 5kHz +# NOTE Default channel references for each channel of the DDG1 delay generator. +# This needs to be carefully adjusted to match the envisioned trigger scheme. +# If the trigger scheme changes, adapt the values here together with the README and +# PDF `trigger_scheme_ddg1_ddg2.pdf`. DEFAULT_REFERENCES: list[tuple[LiteralChannels, CHANNELREFERENCE]] = [ ("A", CHANNELREFERENCE.T0), # T0 + 2ms delay ("B", CHANNELREFERENCE.A), @@ -89,14 +108,27 @@ DEFAULT_REFERENCES: list[tuple[LiteralChannels, CHANNELREFERENCE]] = [ ("H", CHANNELREFERENCE.G), ] +############################### +## DDG1 IMPLEMENTATION ######## +############################### + class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): """ - 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). + + Implementation of the DelayGenerator DDG1 for the cSAXS beamline. It is the main trigger + source for the cSAXS beamline, and will be triggered by BEC through a software trigger or + by a hardware trigger from a beamline device (e.g. Galil stages). Specific implementation + of the cabling logic expected for this device are described in the module README, the attached + PDF 'trigger_scheme_ddg1_ddg2.pdf' and the module docstring. + + The IOC prefix is 'X12SA-CPCL-DDG1:'. + + Args: + name (str): Name of the device. + prefix (str, optional): EPICS prefix for the device. Defaults to ''. + scan_info (ScanInfo | None, optional): Scan info object. Defaults to None. + device_manager (DeviceManagerBase | None, optional): Device manager. Defaults to None. """ def __init__( @@ -107,9 +139,6 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): 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 ) @@ -123,17 +152,30 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): # pylint: disable=attribute-defined-outside-init def on_connected(self) -> None: """ - Set the default values on the device - intended to overwrite everything to a usable default state. - Sets DEFAULT_IO_CONFIG into each channel, sets the trigger source to DEFAULT_TRIGGER_SOURCE, - and turns off burst mode. + + This method is called after the device is initialized and all signals are connected. This happens + when a device configuration is loaded in BEC. + + It sets the default values for this device - intended to overwrite everything to a usable default state. + For this purpose, we use the DEFAULT SETTINGS defined at the top of this module. + + To ensure that this process is robust, we follow these steps: + - First, we stop any ongoing burst mode operation. + - Then, we set the DEFAULT_IO_CONFIG for each channel, the trigger source to DEFAULT_TRIGGER_SOURCE, + and the channel references to DEFAULT_REFERENCES. + - We set the state proc_status to be event based. This triggers readouts of the EventStatusLI bit + based on events. This was empirically found to be a stable solution in combination with the poll + loop of the state. + - Finally, we set the burst delay to 0, to set it to be of no delay. """ - # NOTE First we make sure that there is nothing running on the DDG. This seems to + + # NOTE First we make sure that there is nothing running on the DDG. This seems to # help to tackle that the DDG occasionally freezes during the first scan # after reconnecting to it. Do not remove. self.stop_ddg() # NOTE Setting DEFAULT configurations for IO config, trigger config and references. - # The three dictionaries above 'DEFAULT_IO_CONFIG', 'DEFAULT_TRIGGER_SOURCE' and + # The three dictionaries above 'DEFAULT_IO_CONFIG', 'DEFAULT_TRIGGER_SOURCE' and # 'DEFAULT_REFERNCES' should be used to adapt configurations if needed. for channel, config in DEFAULT_IO_CONFIG.items(): self.set_io_values(channel, **config) @@ -146,45 +188,71 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): self.state.proc_status_mode.put(PROC_EVENT_MODE.EVENT) # NOTE Burst delay should be set to 0, don't remove as this will not be checked + # Also set the burst count to 1 to only have a single pulse for DDG1. self.burst_delay.put(0) + self.burst_count.put(1) 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. - It also has a hardware trigger feeded into the EXT/EN for fly-scanning, i.e. Galil stages. - This DDG is always not in burst mode. + This method is called in preparation for a scan. All information about the upcoming + scan is available in self.scan_info.msg at this point. We use this information to + configure the DDG1 for the upcoming scan. + + The DDG is operated in burst mode for the scan, but with only a single burst pulse. + THe length of the pulse is set to the expected exposure time for a single trigger, + which includes any burst acquisitions if frames_per_trigger > 1. + + The logic is as follows: + - We check if any default burst parameters need to be set, and set them if needed. + - We calculate the burst pulse width based on the exposure time and frames_per_trigger. + - We set the burst_period and the shutter signal (delay pairs cd) to be + exposure_time * frames_per_trigger + 3ms (2ms for shutter to open, 1ms to close). + - We set the delay pairs ab to be 2ms delayed (to allow the shutter to open) with a width of 1us to trigger DDG2. + - We set the delay pairs ef to be triggered after the shutter closes with a width of 1us to trigger the MCS card. + - Finally, we add a short sleep to ensure that the IOC and DDG HW process the values properly. """ - # NOTE Only set relevant channels on burst_mode channel - # After mutliple tests with the HW, this procedure has been determined empirically - # to improve stability and avoid HW getting stuck in triggering cycles - # Please also note that this should happen first, before setting delay times on the chabnnels. + + ######################################## + ### Burst mode settings ################ + ######################################## + + # NOTE We check here if the delay generator is not in burst mode. We check these values + # and set them to the requried values if they differ from the expected ones. + # This has been found empirically to improve stability and avoid HW getting stuck in triggering cycles. if self.burst_mode.get() == 0: self.burst_mode.put(1) - exp_time = self.scan_info.msg.scan_parameters["exp_time"] - if self.burst_period.get() != exp_time: - self.burst_period.put(exp_time) - if self.burst_delay.get() != 0: self.burst_delay.put(0) + if self.burst_count.get() != 1: + self.burst_count.put(1) + ######################################### - ### Setup delay pairs for acquisition ### + ### Setup timing for burst and delays ### ######################################### frames_per_trigger = self.scan_info.msg.scan_parameters["frames_per_trigger"] + exp_time = self.scan_info.msg.scan_parameters["exp_time"] + + # Burst Period DDG1 + # Set burst_period to shutter width + # c/t0 + 2ms + exp_time * burst_count + 1ms + shutter_width = 2e-3 + exp_time * frames_per_trigger + 1e-3 + if self.burst_period.get() != shutter_width: + self.burst_period.put(shutter_width) # Trigger DDG2 # a = t0 + 2ms, b = a + 1us # a has reference to t0, b has reference to a + # Add delay of 2ms to allow shutter to open self.set_delay_pairs(channel="ab", delay=2e-3, width=1e-6) - + # Trigger shutter # d = c/t0 + 2ms + exp_time * burst_count + 1ms # c has reference to t0, d has reference to c - shutter_width = 2e-3 + exp_time * frames_per_trigger + 1e-3 + # Shutter opens without delay at t0, closes after exp_time * burst_count + 3ms (2ms open, 1ms close) self.set_delay_pairs(channel="cd", delay=0, width=shutter_width) # Trigger extra pulse for MCS OR gate @@ -193,50 +261,64 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): self.set_delay_pairs(channel="ef", delay=0, width=1e-6) # NOTE Add additional sleep to make sure that the IOC and DDG HW process the values properly - # This value has been choosen empirically after testing with the HW. Please acknowledge that - # this is called in parallel, so it should not add significant overhead to acquisition. It's - # also just called once per scan. + # This value has been choosen empirically after testing with the HW. It's + # also just called once per scan and has been found to improve stability of the HW. time.sleep(0.2) 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. """ - # NOTE: It is crucial to first wait for the MCS card to finish it's acquisition before - # the DDG moves on to the next trigger cycle. + This method is used by the DDG1 on_trigger method to prepare the MCS card for the next trigger. + It checks that the MCS card is properly prepared before BEC sends a software trigger to the DDG1, + which is needed for step scans. + + It relies on the MCS card implementation and needs to be adapted if the MCS card logic changes. + """ + + # NOTE First we wait that the MCS card is not acquiring. We add here a timeout of 5s to avoid + # a deadlock in case the MCS card is stuck for some reason. This should not happen normally. status = CompareStatus(mcs.acquiring, ACQUIRING.DONE) self.cancel_on_stop(status) status.wait(timeout=5) - # NOTE: Important logic on the MCS card, this makes sure that callbacks from the MCA channels - # are not surpressed. Please check MCS card and 'erase_all' comment. - mcs._omit_mca_callbacks.clear() + # NOTE Clear the '_omit_mca_callbacks' flag. This makes sure that data received from the mca1...mca3 + # counters are forwarded to BEC. Once the flag is set, we create a TransitionStatus DONE->ACQUIRING + # and start the acquisition through erase_start.put(1). Finally, we wait for the card to go to ACQUIRING state. + mcs._omit_mca_callbacks.clear() # pylint: disable=protected-access status_acquiring = TransitionStatus(mcs.acquiring, [ACQUIRING.DONE, ACQUIRING.ACQUIRING]) self.cancel_on_stop(status_acquiring) - mcs.erase_start.put(1) + mcs.erase_start.put(1) - # NOTE: Now we wait for the card to go to Acuiring after we've called erase_start - # Please increase the timeout if this turns out to be problematic - status_acquiring.wait(timeout=3) + # NOTE Timeout of 3s should be plenty, any longer wait should checked. If this happens to crash + # an acquisition regularly with a WaitTimeoutError, the timeout can be increased but it should + # be investigated why the EPICS interface is slow to respond. + status_acquiring.wait(timeout=3) 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. + + Polling loop to retrieve the event status register of the delay generator DDG1. + This method runs in a background thread and the polling is controlled through the + '_poll_thread_run_event' and '_poll_thread_kill_event'. Polling should only become + active when a software trigger was sent in BEC and we are waiting for the burst to complete. """ - # NOTE hook to kill the loop, only needed if device is destroyed + # Main loop of the polling thread. As long as the kill event is not set, the loop continues. while not self._poll_thread_kill_event.is_set(): - # The thread will wait in this event if IDLE. Polling can be started - # by setting 'poll_thread_run_event.set()'. Please check usage for software - # triggered scans from BEC within on_trigger. + # NOTE Main wait event for the polling thread. If the _poll_thread_run_event is not set, + # The thread will wait here. This event is used to start/stop polling from outside the thread, + # as used in on_trigger and on_stop. Please make sure to set this event also when the thread + # should be killed as its otherwise stuck inside the wait. self._poll_thread_run_event.wait() - # NOTE Event to indicate that polling is taking place currently. This is needed as there - # are sleeps of 20ms in the poll loop which were empirically determined after long testing - # to improve stability in communication with the HW. + # NOTE Set the event to indicate that we are currently still in the poll_loop. This is needed + # as we have to use sleeps of 20ms within the poll loop. These sleeps were empirically detetermined + # to ensure that no state changes are missed. However, these sleeps have the side effect that + # setting the '_poll_thread_run_event' may not immediately stop the polling. Therefore, we need the + # '_poll_thread_poll_loop_done' event to indicate that polling has finished. If this logic is changed, + # it requires careful testing as failure rates can be in the 1 out of 500 events rate, which are still + # not acceptable for operation. The current implementation has been tested with failure rates smaller then + # ~ 1:100000 if failures happened at all. self._poll_thread_poll_loop_done.clear() while ( self._poll_thread_run_event.is_set() and not self._poll_thread_kill_event.is_set() @@ -248,36 +330,49 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): logger.error( f"Exception in polling loop thread, polling continues...\n Error content:\n{content}" ) - # NOTE Important to set the event again. The next trigger loop waits for the poll thread to become - # IDLE again. Do not remove. + # NOTE Set the _poll_thread_poll_loop_done event to indicate that we are done polling. Do not remove! 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. + This method is the actual poll loop to update the event status from the satus register + of the delay generator DDG1. + + It follows a procedure that was established empirically after extended testing with the HW. + Any adaptations to this logic need to be carefully tested to avoid that the HW becomes unstable. + NOTE: Sleeps are important in this logic, and should not be removed or optimized without extensive testing. + 20ms has been found to be the minimum sleep time that proofed to be stable in operation. + + The logic is as follows: + - Set the 'proc_status' to 1 with use_complete=True to trigger an event based readout of the EventStatusLI. + - Sleep 20ms to give the device time to process the command. + - Check if the kill event or run event are cleared, and exit the loop if so. + - Read the EventStatusLI channel to update the event status. + - Check again if the kill event or run event are cleared, and exit the loop if so. + + Please note that any important changes of the status register reading will trigger callbacks + if attached to the event status signal. These callbacks hold the logic to resolve status objects + when waiting for specific events (e.g. end of burst). + - IMPORTANT: Do not remove sleeps or try to optimize this logic. This seems to be a - fragile balance between polling frequency and device processing time. Also in between - start/stop of polling. Please also consider that there is a sleep in on_trigger and - that this might also be necessary to avoid that HW becomes unavailable/unstable. """ self.state.proc_status.put(1, use_complete=True) - #NOTE: Important sleep that has been empirically determined after testing for a long time + + # NOTE: Important sleep that has been empirically determined after testing for a long time # Only remove if absolutely certain that the DDG logic of polling the EventStatusLI works without it. - time.sleep(0.02) + time.sleep(0.02) + if self._poll_thread_kill_event.is_set() or not self._poll_thread_run_event.is_set(): return + self.state.event_status.get(use_monitor=False) if self._poll_thread_kill_event.is_set() or not self._poll_thread_run_event.is_set(): return - #NOTE: Again important sleep that has been empirically determined after testing for a long time + + # NOTE: Again important sleep that has been empirically determined after testing for a long time # Only remove if certain that logic can be replaced to not risk HW failures. - time.sleep(0.02) + time.sleep(0.02) def _start_polling(self) -> None: """Start the polling loop in the background thread.""" @@ -297,8 +392,23 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): else: 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""" + def _prepare_trigger_status_event( + self, timeout: float | None = None + ) -> StatusBitsCompareStatus: + """ + Method to prepare a status object that indicates the end of a burst cycle. + It also sets up a callback to cancel the polling of the event status register + if the status is cancelled externally (e.g. by stopping the device). In addition, + a timeout can either be specified, or is automatically calculated based on the + exposure time, frames_per_trigger and a default extra time of 5 seconds. + + Args: + timeout (float | None, optional): Timeout for the status object. If None, a + default timeout based on exposure time and frames_per_trigger is used. + + Returns: + StatusBitsCompareStatus: + """ if timeout is None: # Default timeout of 5 seconds + exposure time * frames_per_trigger timeout = 5 + self.scan_info.msg.scan_parameters.get( @@ -322,43 +432,57 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): def on_trigger(self) -> DeviceStatus: """ This method is called from BEC as a software trigger. - It first stops any active polling if still running. The sleep of 20ms is important - for proper functionality of the card. Then it checks if the 'mcs' card is in the config - and enabled, and prepares the card for triggering. For now this is still relevant, but may - be moved to a high level logic in BEC in the future (neeeds). - Then a status_object is prepared that receives the EventStatusLI epics channel (self.state.event_status), - and attaches a callback that resolves once the burst is done. The polling thread is enabled to manually - trigger a reading of the event status before a software trigger is sent via trigger_shot. + It follows a specific procedure to ensure that the DDG1 and MCS card are properly handled + on a trigger event. The established logic is as follows: + + - Stop polling the event status register to avoid that the polling loop is still active + before sending the software trigger. This needs to be done to avoid conflicts + in reading the event status register. + - Wait for the _poll_thread_poll_loop_done event to ensure that the polling loop is no + longer active. A timeout of 1s is plenty as sleeps of 20ms are used in the poll loop. + - Add an extra sleep of 20ms to make sure that the HW is again ready to process new commands. + This has been found empirically after long testing to improve stability. + - If the MCS card is present in the current session of BEC, prepare the card for the next trigger. + - Prepare a status StatusBitsCompareStatus that will be resolved once the burst is done. + - Start the polling loop again to monitor the event status register. + - Send the software trigger to the DDG1 + - Return the status object to BEC which will automatically resolve once the status register has + the END_OF_BURST bit set. The callback of the status object will also stop the polling loop. """ - # Stop polling, poll once manually to ensure that the register is clean self._stop_polling() self._poll_thread_poll_loop_done.wait(timeout=1) - # NOTE: THis sleep is important for the HW to process the event and avoid that - # becomes unresponsive. This was found empirically after long testing. + # NOTE: This sleep is important to ensure that the HW is ready to process new commands. + # It has been empirically determined after long testing that this improves stability. time.sleep(0.02) + # NOTE If the MCS card is present in the current session of BEC, + # we prepare the card for the next trigger. The procedure is implemented + # in the '_prepare_mcs_on_trigger' method. # Prepare the MCS card for the next software trigger mcs = self.device_manager.devices.get("mcs", None) if mcs is None or mcs.enabled is False: logger.info("Did not find mcs card with name 'mcs' in current session") else: self._prepare_mcs_on_trigger(mcs) - # Prepare status with callback to cancel the polling once finished + + # Prepare StatusBitsCompareStatus to resolve once the END_OF_BURST bit was set. status = self._prepare_trigger_status_event() - # Start polling + + # Start polling thread again to monitor event status self._start_polling() # Trigger the DDG1 self.trigger_shot.put(1, use_complete=True) return status def on_stop(self) -> None: - """Stop the delay generator by setting the burst mode to 0""" + """Stop the delay generator HW and polling thread when the device is stopped.""" self.stop_ddg() self._stop_polling() def on_destroy(self) -> None: """Clean up resources when the device is destroyed.""" + self.stop_ddg() self._kill_poll_thread() 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 4d8d0c4..16172b5 100644 --- a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_2.py +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_2.py @@ -41,6 +41,11 @@ from csaxs_bec.devices.epics.delay_generator_csaxs.delay_generator_csaxs import logger = bec_logger.logger +######################## +## DEFAULT SETTINGS #### +######################## + +# NOTE Default channel configuration for the DDG2 delay generator channels _DEFAULT_CHANNEL_CONFIG: ChannelConfig = { "amplitude": 5.0, "offset": 0.0, @@ -48,6 +53,9 @@ _DEFAULT_CHANNEL_CONFIG: ChannelConfig = { "mode": "ttl", } +# NOTE Default IO configuration for all channels in DDG2 +# Each channel uses the same default configuration as defined above +# If needed, individual channel configurations should be modified here. DEFAULT_IO_CONFIG: dict[AllChannelNames, ChannelConfig] = { "t0": _DEFAULT_CHANNEL_CONFIG, "ab": _DEFAULT_CHANNEL_CONFIG, @@ -55,9 +63,16 @@ DEFAULT_IO_CONFIG: dict[AllChannelNames, ChannelConfig] = { "ef": _DEFAULT_CHANNEL_CONFIG, "gh": _DEFAULT_CHANNEL_CONFIG, } + DEFAULT_TRIGGER_SOURCE: TRIGGERSOURCE = TRIGGERSOURCE.EXT_RISING_EDGE + +# NOTE Default readout times for the detectors connected to DDG2 +# These values are used to calculate the difference between the burst_period and the pulse width of +# individual channel pairs. They also mark a lower limit for the exposure time. Needs to be +# adjusted if the exposure time should possibly go below 0.2 ms. DEFAULT_READOUT_TIMES = {"ab": 2e-4, "cd": 2e-4, "ef": 2e-4, "gh": 2e-4} # 0.2 ms 5kHz +# NOTE Default refernce settings for each channel in DDG2 DEFAULT_REFERENCES: list[tuple[LiteralChannels, CHANNELREFERENCE]] = [ ("A", CHANNELREFERENCE.T0), ("B", CHANNELREFERENCE.A), @@ -69,9 +84,27 @@ DEFAULT_REFERENCES: list[tuple[LiteralChannels, CHANNELREFERENCE]] = [ ("H", CHANNELREFERENCE.G), ] +############################### +## DDG2 IMPLEMENTATION ######## +############################### + class DDG2(PSIDeviceBase, DelayGeneratorCSAXS): """ + + Implementation of the DelayGenerator DDG2 for the cSAXS beamline. This delay generator is + reponsible to create triggers for the detectors. It is configured in burst mode. Please + check the module docstring, the module README and the attached PDF 'trigger_scheme_ddg1_ddg2.pdf' + for more information about the expected cabling and trigger logic. + + The IOC prefix is 'X12SA-CPCL-DDG2:'. + + Args: + name (str): Name of the device. + prefix (str, optional): EPICS prefix for the device. Defaults to ''. + scan_info (ScanInfo | None, optional): Scan info object. Defaults to None. + device_manager (DeviceManagerBase | None, optional): Device manager. Defaults to None. + Implementation of DelayGeneratorCSAXS for the CSAXS master trigger delay generator at X12SA-CPCL-DDG2. This device is responsible for creating triggers in burst mode and is connected to a multiplexer that distributes the trigger to the detectors. The DDG2 is triggered by the DDG1 through the EXT/EN channel. @@ -80,10 +113,22 @@ class DDG2(PSIDeviceBase, DelayGeneratorCSAXS): # pylint: disable=attribute-defined-outside-init def on_connected(self) -> None: """ - Set the default values on the device - intended to overwrite everything to a usable default state. - Sets DEFAULT_IO_CONFIG into each channel, sets the trigger source to DEFAULT_TRIGGER_SOURCE. + + This method is called after the device is initialized and all signals are connected. This happens + when a device configuration is loaded in BEC. + + It sets the default values for this device - intended to overwrite everything to a usable default state. + For this purpose, we use the DEFAULT SETTINGS defined at the top of this module. + + The following procedure is followed: + - Stop the DDG to ensure it is not running. + - Then, we set the DEFAULT_IO_CONFIG for each channel, the trigger source to DEFAULT_TRIGGER_SOURCE, + and the channel references to DEFAULT_REFERENCES. """ - self.burst_disable() # it is possible to miss setting settings if burst is enabled + self.stop_ddg() + + # NOTE Please adjust the default settings under 'DEFAULT SETTINGS' at the top of this module if needed. + # This makes sure that we have a well defined default state for the DDG2 device. for channel, config in DEFAULT_IO_CONFIG.items(): self.set_io_values(channel, **config) self.set_trigger(DEFAULT_TRIGGER_SOURCE) @@ -91,66 +136,73 @@ class DDG2(PSIDeviceBase, DelayGeneratorCSAXS): def on_stage(self) -> DeviceStatus | StatusBase | 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. - It also has a hardware trigger feeded into the EXT/EN for fly-scanning, i.e. Galil stages. - This DDG is always not in burst mode. + This method is called when the device is staged before a scan. All information about the scan + is available through self.scan_info.msg at this point. The DDG2 needs to be configured to + create a sequence of TTL pulses in burst mode that are sent to the detectors. It therefore needs + to know the exposure time and frames per trigger from the self.scan_info.msg.scan_parameters. + + This logic is robust for step scans as well as fly scans, as the DDG2 is triggered by the DDG1 + through the EXT/EN channel. """ + ######################################## + ### Burst mode settings ################ + ######################################## + + # NOTE Only adjust settings if needed. DDG2 should always be in burst mode when used at CSAXS. + if self.burst_mode.get() == 0: + self.burst_mode.put(1) + + # Ensure that there is no delay for the burst + if self.burst_delay.get() != 0: + self.burst_delay.put(0) + exp_time = self.scan_info.msg.scan_parameters["exp_time"] frames_per_trigger = self.scan_info.msg.scan_parameters["frames_per_trigger"] - # a = t0 - # a has reference to t0, b has reference to a + + # NOTE Check if the exposure time is longer than all readout times. + # Raise a ValueError if requested exposure time is too short. 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}" ) + + ######################################### + ### Setup timing for burst and delays ### + ######################################### + + # Burst Period DDG2 settings. Only adjust them if needed. + if self.burst_count.get() != frames_per_trigger: + self.burst_count.put(frames_per_trigger) + if self.burst_period.get() != exp_time: + self.burst_period.put(exp_time) + + # Calculate the pulse width for the channel pair 'ab' burst_pulse_width = exp_time - DEFAULT_READOUT_TIMES["ab"] + + # Trigger detectors with delay 0, and pulse width = exp_time - readout_time 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 + + Method that is called just before a scan starts. It was observed that a short delay of 50ms + improves the overall stability in operation. This may be removed as other parts were adjusted, + but for now we will keep it as the delay is short. """ - # Delay Generator occasionaly needs a bit extra time to process all commands, sleep 50ms + # NOTE Short delay to allow for the HW to process the commands before the scan starts. + # This may no longer be needed after other adjustments, and may be removed in the future. time.sleep(0.05) def on_trigger(self) -> DeviceStatus | StatusBase | None: """ - DDG2 will not receive a trigger from BEC, but will be triggered by the DDG1 through the EXT/EN channel. - """ - def wait_for_status( - self, status: DeviceStatus, bit_event: STATUSBITS, timeout: float = 5 - ) -> None: - """Wait for a event status bit to be set. - - Args: - status (StatusBase): 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. + DDG2 does not implement any trigger specific logic as it is triggered by DDG1 through the EXT/EN channel. """ - 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 + pass def on_stop(self) -> None: - """Stop the delay generator by setting the burst mode to 0""" + """Stop the delay generator""" self.stop_ddg() diff --git a/csaxs_bec/devices/epics/mcs_card/README.md b/csaxs_bec/devices/epics/mcs_card/README.md new file mode 100644 index 0000000..d76f8a3 --- /dev/null +++ b/csaxs_bec/devices/epics/mcs_card/README.md @@ -0,0 +1,13 @@ +# MCS Card implementation at the CSAXS beamline + +This module provides an ophyd device implementation for the SIS3820 Multi-Channel Scaler (MCS) card, used at the cSAXS beamline for time-resolved data acquisition. It interfaces with the EPICS IOC for the SIS3820 MCS card. +Information about the EPICS driver can be found here (https://millenia.cars.aps.anl.gov/software/epics/mcaStruck.html). + +# Important Notes +Operation of the MCS card requires proper configuration as some of the parameters are interdependent. In addition, empirical adjustments have been found to be necessary for optimal performance at the beamline. In its current implementation, comments about these dependencies are highlighted in the source code of the ophyd device classes [MCSCard](./mcs_card.py) and [MCSCardCSAXS](./mcs_card_csaxs.py). It is highly recommended to review these comments before refactoring, modifying, or extending the code. + +## Ophyd Device Implementation + +The ophyd device implementation is provided [MCSCard](./mcs_card.py). This class provides a basic interface to the MCS PVs, including configuration of parameters such as number of channels, dwell time, and control of acquisition start/stop. Please check the source code of the class for more details of the implementation. + +The [MCSCardCSAXS](./mcs_card_csaxs.py) class extends the basic MCSCard implementation with cSAXS-specific logic and configurations. Please be aware that this is also linked to the implementation of other devices, most notably the [delay generator integration](../delay_generator/README.md), which is used as the trigger source for the MCS card during operation. \ No newline at end of file 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 e86fa76..3f21f71 100644 --- a/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py +++ b/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py @@ -1,4 +1,12 @@ -"""Module for the MCSCard CSAXS implementation.""" +""" +Module for the MCSCard CSAXS implementation at cSAXS. + +Please respect the comments regarding timing and procedures of the MCS card. These +are highlighted with NOTE comments directly in the code, indicating requirements +for stable device operation. Most of these constraints were identified +empirically through extensive testing with the SIS3820 MCS card IOC and are intended +to prevent unexpected hardware or IOC behavior. +""" from __future__ import annotations @@ -14,9 +22,8 @@ import numpy as np from bec_lib.logger import bec_logger from ophyd import Component as Cpt from ophyd import EpicsSignalRO, Kind -from ophyd_devices import AsyncSignal, CompareStatus, ProgressSignal, StatusBase +from ophyd_devices import AsyncMultiSignal, CompareStatus, ProgressSignal, StatusBase from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase -from ophyd_devices.utils.bec_signals import AsyncMultiSignal from csaxs_bec.devices.epics.mcs_card.mcs_card import ( ACQUIREMODE, @@ -32,19 +39,33 @@ from csaxs_bec.devices.epics.mcs_card.mcs_card import ( @contextmanager -def suppress_mca_callbacks(mcs_card: MCSCard): +def suppress_mca_callbacks(mcs_card: MCSCard, restore_after_timeout: None | float = None): """ - Context manager to temporarily disable MCA channel callbacks. - Required to avoid additional callbacks when erasing all channels. + Utility context manager to suppress MCA channel callbacks temporarily. + It is required because erasing all channels via 'erase_all' PV triggers + callbacks for each channel. Depending on timing, this can interfere with + ongoing data acquisition so this context manager can be used to suppress + those callbacks temporarily. If used with restore_after_timeout, the suppression + will be automatically cleared after the specified timeout in seconds. + + NOTE: Please be aware that it does not restore previous state, which means + that _omit_mca_callbacks will remain set after exiting the context. It has + to be cleared manually if needed. This can be improved in the future, but + should be carefully coordinated with the logic implemented within '_on_counter_update'. Args: mcs_card (MCSCard): The MCSCard instance to suppress callbacks for. + restore_after_timeout (float | None): Optional timeout in seconds to automatically + clear the suppression after the specified time. If None, the original state + is not restored. """ - mcs_card._omit_mca_callbacks.set() + mcs_card._omit_mca_callbacks.set() # pylint: disable=protected-access try: yield finally: - pass + if restore_after_timeout is not None: + time.sleep(restore_after_timeout) + mcs_card._omit_mca_callbacks.clear() # pylint: disable=protected-access if TYPE_CHECKING: # pragma: no cover @@ -59,27 +80,43 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): The basic functionality is inherited from the MCSCard class. Please note that the number of channels is fixed to 32, so there will be data for all - 32 channels even if not all channels are used in the experiment. This setting can not - be realibly changed on the SIS3820 card's IOC through mux_output, so it is fixed here. - Mux_output should therefore also be set to 32 in the IOC configuration. + 32 channels. In addition, the logic of the card is linked to the timing system (DDG) + and therefore changes have to be coordinated with the logic on the DDG side. + + Args: + name (str): Name of the device. + prefix (str, optional): Prefix for the EPICS PVs. Defaults to "". """ + USER_ACCESS = ["mcs_recovery"] + + # NOTE The number of MCA channels is fixed to 32 for the CSAXS MCS card. + # On the IOC, we receive a 'warning' or 'error' once we set this channel for the + # envisioned input/output mode settings of the card. However, we need to know the + # channels set as callback timing relies on the channels to be set. + # For the future, we may consider adding an initialization parameter to set + # the number of channels, which in return limits the number of subscriptions + # on the channels. However, mux_output should still be set to 32 on the IOC side. + # If this limits performance, this should be investigated with Controls engineers and + # the IOC. NUM_MCA_CHANNELS: int = 32 - # All counter from the MCS card. + # MCA counters for the card. Channels 1-32 will be sent to BEC. mca = Cpt( AsyncMultiSignal, name="counters", signals=[ f"mca{i}" for i in range(1, 33) - ], # This needs to be in sync with counters DynamicDeviceComponent + ], # NOTE Channels 1-32, they need to be in sync with the 'counters' component (DynamicDeviceComponent) of the MCSCard ndim=1, async_update={"type": "add", "max_shape": [None]}, max_size=1000, kind=Kind.normal, - doc="AsyncMultiSignal for MCA card channels 1-32", + doc=( + "AsyncMultiSignal for MCA card channels 1-32." + "Cabling of the MCS card determines which channel corresponds to which input." + ), ) - # Progress Signal progress = Cpt(ProgressSignal, doc="ProgressSignal indicating the progress of the device") def __init__( @@ -90,21 +127,30 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): 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 ) + # NOTE MCS Clock frequency. This is linked to the settings of the SIS3820 IOC and + # cabeling of the card. Currently, the 'output_mode' is set to MODE_2 and one of the outputs + # 6 or 7 (both 10MHz clocks) is used on channel 5 input for the timing signal of the IOC. + # Please adjust this comment if the cabling or IOC settings change. self._mcs_clock = 1e7 # 10MHz clock -> 1e7 Hz self._pv_timeout = 2.0 # seconds - self._rlock = RLock() # Needed to ensure thread safety for counter updates - self._acquisition_group: str = "monitored" + self._rlock = RLock() + + # NOTE This parameter will be sent with async data of the mcs counters. + # Based on scan-paramters, e.g. frames_per_trigger, this will be either + # 'monitored' or 'burst_group'. This means whether data from this channel + # is in sync with monitored devices or another group. In this scenario, + # the other group is called burst_group. Other detectors connected and + # triggered through the same timing system should implement the same logic + # to allow data to be properly grouped afterwards. + self._acquisition_group: str = "monitored" # default value, will be updated in on_stage self._num_total_triggers: int = 0 - # Event logic to schedule async data emission & monitoring - # Please note that complete needs to wait until all data was sent - # This requires additional logic and a thread to monitor the data emission + # Thread and event logic for monitoring async data emission after scan is done + # These are mostly internal variables for which values should not be changed externally. + # Adjusting the logic of them should also be handled with care and proper testing. self._scan_done_thread_kill_event: threading.Event = threading.Event() self._start_monitor_async_data_emission: threading.Event = threading.Event() self._scan_done_callbacks: list[Callable[[], None]] = [] @@ -118,17 +164,40 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): def on_connected(self): """ - Called when the device is connected. + This method is called once the device and all its PVs are connected. Any initial + setup of PVs should be managed here. Please be aware that settings of the MCS card + correlate with its operation mode, input/output modes, and timing. Changing single + parameters without understanding the overall logic may lead to unexpected behavior + of the device.Therefore, any modification of these parameters should be handled + with care and tested. + + A brief summary of the procesdure that is implemented here: + - Stop any ongoing acquisiton. + - Setup the Initial initial settings of the MCS card with respective operation modes + - Run 'mcs_recovery' procedure to ensure that no pending acquisition data is scheduled + to be pushed through mcs channels + - Subscribe a callback '_on_counter_update' to mcs counter PVs to forward + data through AsyncMultiSignal to BEC + - Start the monitoring thread for async data emission after scan is done """ + # NOTE Stop any ongoing acquisition first. This shut be done before setting any PVs. self.stop_all.put(1) - - # Setup the MCS card settings + ######################### + ### Setup MCS Card ### + ######################### + # Setup the MCS card settings. Please note that any runtime modification + # these parameter may lead to unexpected behavior of the device. + # Therefore this has to be set up correctly. self.channel_advance.set(CHANNELADVANCE.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) self.user_led.set(0).wait(timeout=self._pv_timeout) + # NOTE The number of output channels has to be set to NUM_MCA_CHANNELS. + # The logic to send data to BEC relies on knowing how many channels are active. + self.mux_output.put(self.NUM_MCA_CHANNELS) + # 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) @@ -136,7 +205,10 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): self.output_polarity.set(POLARITY.NORMAL).wait(timeout=self._pv_timeout) self.count_on_start.set(0).wait(timeout=self._pv_timeout) - # Set ReadMode to PASSIVE, card will wait for external trigger to be read + # NOTE Data is read out when the MCS card finishes an acquisition. The logic for this + # is also linked to triggering on the DDG. + # Set ReadMode to PASSIVE, the card will wait either wait for readout command or + # automatically readout once acquisition is done. self.read_mode.set(READMODE.PASSIVE).wait(timeout=self._pv_timeout) # Set the acquire mode @@ -145,57 +217,93 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): # Subscribe the progress signal self.current_channel.subscribe(self._progress_update, run=False) - self.mcs_recovery() + # NOTE: Run a recovery procedure to ensure that the card has no pending data + # that needs to be pushed through the mca channels. The procedure involves + # stopping any ongoing acquisition and erasing all data on the card. Including + # a short sleep to allow the IOC to process the commands. + self.mcs_recovery(timeout=1) - # Subscribe to the mca updates + #################################### + ### Setup MCS Subscriptions ### + #################################### for sig in self.counters.component_names: sig_obj: EpicsSignalRO = getattr(self.counters, sig) sig_obj.subscribe(self._on_counter_update, run=False) # Start monitoring thread self._scan_done_thread.start() - def _on_counter_update(self, value, **kwargs) -> None: """ - Callback for counter updates of the mca channels (1-32). + Callback for counter updates of the mca channels (1-32). This callback is attached + to each mca channel PV on the MCS card. It collects data from all channels + and once all channels have been updated for a given acquisition, it pushes + the data to BEC through the AsyncMultiSignal 'mca'. - Data from the mca channels will be pushed to a list, and then forwarded to - the async multi signal 'raw' for readout after the trigger is complete. + It is important that mux_output is set to the correct number of channels in on_connected, + because the callback here waits for updates on all channels before pushing data to BEC. + The _rlock is used to ensure thread safety as multiple callbacks may be executed + simultaneously from different threads. + + If _omit_mca_callbacks is set, the callback will return immediately without processing the + data. This is used when erasing all channels to avoid interference with ongoing acquisition. + It has to manually cleared after the context manager 'suppress_mca_callbacks' is used. + + Args: + value: The new value from the counter PV. + **kwargs: Additional keyword arguments from the subscription, including 'obj' (the EpicsSignalRO instance). """ with self._rlock: self._mca_counter_index += 1 if self._omit_mca_callbacks.is_set(): return # Suppress callbacks when erasing all channels - signal = kwargs.get("obj", None) + signal: EpicsSignalRO | None = kwargs.get("obj", None) if signal is None: logger.error(f"Called without 'obj' in kwargs: {kwargs}") return - signal: EpicsSignalRO - attr_name = signal.attr_name - # Ignore updates for channels that are not setup through num_connected_channels + # NOTE: This relies on the naming convention of the mca channels being 'mca1', 'mca2', ..., 'mca32'. + # for the MCSCard class with the 'counters' DynamicDeviceComponent. + # Ignore any updates from channels beyond NUM_MCA_CHANNELS + attr_name = signal.attr_name index = int(attr_name[3:]) # Extract index from 'mcaX' if index > self.NUM_MCA_CHANNELS: return + # NOTE Depending on the scan parameters, we may either receive single values or numpy arrays. + # Therefore, we need to handle both cases here to ensure that data is always stored. We do + # this by converting single values to a list with one element, and numpy arrays to lists. if isinstance(value, np.ndarray): value = value.tolist() # Convert numpy array to list else: value = [value] # Received single value, convert to list + + # Store the value with timestamp. If available in kwargs, use provided timestamp from CA, + # otherwise use current time when received. self._current_data.update( {attr_name: {"value": value, "timestamp": kwargs.get("timestamp") or time.time()}} ) + + # Once we have received all channels, push data to BEC and reset for next accumulation if len(self._current_data) == self.NUM_MCA_CHANNELS: - # Send out data on multi async signal self.mca.put(self._current_data, acquisition_group=self._acquisition_group) self._current_data.clear() self._mca_counter_index = 0 self._current_data_index += 1 + # NOTE The logic for the device progress is not yet fully refined for all scan types. + # This has to be adjusted once fly scan and step scan logic is fully implemented. + # pylint: disable=unused-argument def _progress_update(self, *args, old_value: any, value: any, **kwargs) -> None: - """Callback for progress updates from ophyd subscription on current_channel.""" + """ + Callback to update the progress signals base on values of current_channel in respect to expected total triggers. + Logic for these updates need to be extended once fly and step scan logic is fully implemented. + + Args: + old_value: Previous value of the signal. + value: New value of the signal. + """ scan_done = bool(value == self._num_total_triggers) self.progress.put(value=value, max_value=self._num_total_triggers, done=scan_done) if scan_done: @@ -203,7 +311,16 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): def on_stage(self) -> None: """ - Called when the device is staged. + This method is called when the device is staged before a scan. Any bootstrapping required + for the scan should be handled here. We also need to handle MCS card specific logic to ensure + that the card is properly prepared for the scan. + + The following procedure is implemented here: + - Ensure that any ongoing acquisition is stopped (should never happen if not interfered with manually) + - Erase all data on the MCS card to ensure a clean start (should never + - Set acquisition parameters based on scan parameters (frames_per_trigger, num_points, acquisition_group) + - Clear any events and buffers related to async data emission. This includes '_omit_mca_callbacks', + '_start_monitor_async_data_emission', '_scan_done_callbacks', and '_current_data'. """ # NOTE: If for some reason, the card is still acquiring, we need to stop it first @@ -217,21 +334,23 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): status = CompareStatus(self.acquiring, ACQUIRING.DONE) status.wait(timeout=10) - # NOTE: Erase all will result in data emission through mca callback subscriptions + # NOTE: If current_channel != 0, erase all data on the card. This + # needs to be done with the 'suppress_mca_callbacks' context manager as erase_all will result + # in data emission through mca callback subscriptions. # The buffer needs to be cleared as this will otherwise lead to missing # triggers during the scan. Again, this should not happen if unstage is properly called. # But user interference or a restart of the device_server may lead to this situation. - # self.erase_all.put(1) - # time.sleep(3) if self.current_channel.get() != 0: - with suppress_mca_callbacks(self): + with suppress_mca_callbacks(self, restore_after_timeout=1.0): logger.warning( f"MCS Card {self.name} had still data in buffer Erased all data on staging and sleeping for 1 second." ) # Erase all data on the MCS card self.erase_all.put(1) - time.sleep(1) # Allow time to process erase + ##################################### + ### Setup Acquisition Parameters ### + ##################################### triggers = self.scan_info.msg.scan_parameters.get("frames_per_trigger", 1) num_points = self.scan_info.msg.num_points self._num_total_triggers = triggers * num_points @@ -239,72 +358,130 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): self.preset_real.set(0).wait(timeout=self._pv_timeout) self.num_use_all.set(triggers).wait(timeout=self._pv_timeout) - # Make sure to clear any remaining data in the local buffer + # Clear any previous data, just to be sure with self._rlock: self._current_data.clear() - # Reset monitoring of async data emission + + # NOTE Reset events for monitoring async_data_emission thread which is + # running during complete to wait for all data from the card + # to be emitted to BEC. self._start_monitor_async_data_emission.clear() + + # Clear any previous scan done callbacks self._scan_done_callbacks.clear() + + # Reset counter for data index of emitted data, NOTE for fly scans, this logic may have to be adjusted. self._current_data_index = 0 + # NOTE Make sure that the signal that omits mca callbacks is cleared + self._omit_mca_callbacks.clear() + def on_unstage(self) -> None: """ - Called when the device is unstaged. + Called when the device is unstaged. This method should be omnipotent and resolve fast. + It stops any ongoing acquisition, erases all data on the MCS and clears the local buffer '_current_data'. + + NOTE: It is important that the logic for on_complete is solid and properly waiting for mca data to be emitted + to BEC. Otherwise, unstage may interfere with ongoing data emission. Unstage is called after complete during scans. + It is crucial that the device itself calls '_omit_mca_callbacks' in its on_stage method to make sure + that data is emitted once the card is properly staged. """ self.stop_all.put(1) - # Make sure that upon unstaging, all data on the MCS card is erased. with suppress_mca_callbacks(self): with self._rlock: self._current_data.clear() self.erase_all.put(1) - def _monitor_async_data_emission(self, timeout: int = 10) -> None: - """Monitor data emission after scan is done.""" + def _monitor_async_data_emission(self) -> None: + """ + Monitoring loop that runs in a separate thread to check if all async data has been emitted to BEC. + It is IDLE most of the time, but activate in the 'on_complete' method called by 'complete'. + + The check is done by comparing the number of data updates '_current_data_index' received through + mca channel callbacks with the expected number of points in the scan. Once they match, all + callbacks in _scan_done_callbacks are called to indicate that data emission is done. + Callbacks need to also accept and handle exceptions to properly report failure. + NOTE! This logic currently works for any step scan, but has to be extended for fly scans. + """ while not self._scan_done_thread_kill_event.is_set(): while self._start_monitor_async_data_emission.wait(): try: if self._current_data_index == self.scan_info.msg.num_points: for callback in self._scan_done_callbacks: - callback() + callback(exception=None) time.sleep(0.02) # 20ms delay to avoid busy loop - except Exception: # pylint: disable=broad-except + except Exception as exc: # pylint: disable=broad-except content = traceback.format_exc() logger.error( f"Exception in monitoring thread of complete for {self.name}:\n{content}" "Running callbacks to avoid deadlock." ) for callback in self._scan_done_callbacks: - callback() + callback(exception=exc) - def _status_callback(self, status: StatusBase) -> None: + def _status_callback(self, status: StatusBase, exception=None) -> None: """Callback for status completion.""" - if not status.done: - status.set_finished() self._start_monitor_async_data_emission.clear() # Stop monitoring + # NOTE Important check as set_finished or set_exception should not be called + # if the status is already done (e.g. cancelled externally) + if status.done: + return # Already done and cancelled externally. + if exception: + status.set_exception(exception) + else: + status.set_finished() def _status_failed_callback(self, status: StatusBase) -> None: - """Callback for status failure.""" - if status.done and not status.success: + """Callback for status failure, the monitoring thread should be stopped.""" + # NOTE Check for status.done and status.success is important to avoid + if status.done: self._start_monitor_async_data_emission.clear() # Stop monitoring def on_complete(self) -> CompareStatus: - """On scan completion.""" - # Prepare callback for data emission done + """ + + Method that is called at the end of scan core, but before unstage. This method is + used to report whether the device successfully completed its data acquisition for the scan. + The check has to be implemented asynchronously and resolve through a status (future) object + returned by this method. + NOTE: For the MCS card, we need to ensure that all data has been acquired + and emitted to BEC as updates after 'on_complete' resolved will be rejected by BEC. + Therefore, we need to ensure that all data has been emitted to BEC before + reporting completion of the device. + + This method implements the following procedure: + - Starts the IDLE async data monitoring thread that checks if all expected data + has been emitted to BEC through the mca channel callbacks. + - Use a CompareStatus to monitor when the MCS card becomes DONE. Please note that this + only indicates that the card has finished acquisition, but not that all data has been + emitted to BEC. + - Return combined status object. A callback is registered to handle failure of the status + if it is stopped externally, e.g. through scan abort. This should ensure that the + monitoring thread is stopped properly. + + """ + # Prepare and register status callback for the async monitoring loop status_async_data = StatusBase(obj=self) self._scan_done_callbacks.append(partial(self._status_callback, status_async_data)) - # Start done callback loop + # Set the event to start monitoring async data emission self._start_monitor_async_data_emission.set() + # Add CompareStatus for Acquiring DONE status = CompareStatus(self.acquiring, ACQUIRING.DONE) # Combine both statuses ret_status = status & status_async_data + # Handle external stop/cancel, and stop monitoring ret_status.add_callback(self._status_failed_callback) self.cancel_on_stop(ret_status) return status def on_destroy(self): + """ + The on destroy hook is called when the device is destroyed, but also reloaded. + Here, we need to clean up all resources used up by the device, including running threads. + """ self._scan_done_thread_kill_event.set() self._start_monitor_async_data_emission.set() if self._scan_done_thread.is_alive(): @@ -313,18 +490,30 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): logger.warning(f"Thread for device {self.name} did not terminate properly.") def on_stop(self) -> None: - """ - Called when the scan is stopped. - """ + """Hook called when the device is stopped. In addition, any status that is registered through cancel_on_stop will be cancelled here.""" self.stop_all.put(1) self.erase_all.put(1) - def mcs_recovery(self, timeout:int=1) -> None: - """Recovery procedure for the mcs card""" - sleep_time = timeout / 2 # 2 sleeps - logger.info(f"Running recovery procedure for MCS card {self.name} with {sleep_time}s sleep, calling stop_all and erase_all, and another {sleep_time}s sleep") + def mcs_recovery(self, timeout: int = 1) -> None: + """ + Recovery procedure for the mcs card. This procedure has been empirically found and can + be used to ensure that the MCS card is stopped and has no pending data to be emitted. + It involves stopping any ongoing acquisition and erasing all data on the card, with + a sleep in between to allow the IOC to process the commands. + + Args: + timeout (int): Total timeout for the recovery procedure. Defaults to 1 second. + """ + sleep_time = timeout / 2 # 2 sleeps + logger.info( + f"Running recovery procedure for MCS card {self.name} with {sleep_time}s sleep, calling stop_all and erase_all, and another {sleep_time}s sleep" + ) + # First erase and start ongoing acquisition. self.erase_start.put(1) time.sleep(sleep_time) + # After a brief processing time, we stop any ongoing acquisition. self.stop_all.put(1) - self.erase_all.put(1) - time.sleep(sleep_time) \ No newline at end of file + # Finally, we erase all data while suppressing mca callbacks to avoid interference. + # We restore the callback suppression after timeout to ensure proper operation afterwards. + with suppress_mca_callbacks(self, restore_after_timeout=sleep_time): + self.erase_all.put(1) -- 2.49.1 From 146b10eb8595831e585dadd5ae2ec872d47a5266 Mon Sep 17 00:00:00 2001 From: appel_c Date: Tue, 6 Jan 2026 09:29:21 +0100 Subject: [PATCH 5/9] tests: fix tests for ddg and mcs integrations --- .../epics/delay_generator_csaxs/ddg_1.py | 11 +- .../epics/delay_generator_csaxs/ddg_2.py | 2 +- .../devices/epics/mcs_card/mcs_card_csaxs.py | 2 +- .../test_delay_generator_csaxs.py | 523 ++++++++++++---- tests/tests_devices/test_mcs_card.py | 564 ++++++------------ 5 files changed, 590 insertions(+), 512 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 628617f..c3a1160 100644 --- a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py @@ -289,10 +289,7 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): self.cancel_on_stop(status_acquiring) mcs.erase_start.put(1) - # NOTE Timeout of 3s should be plenty, any longer wait should checked. If this happens to crash - # an acquisition regularly with a WaitTimeoutError, the timeout can be increased but it should - # be investigated why the EPICS interface is slow to respond. - status_acquiring.wait(timeout=3) + return status_acquiring def _poll_event_status(self) -> None: """ @@ -464,7 +461,11 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): if mcs is None or mcs.enabled is False: logger.info("Did not find mcs card with name 'mcs' in current session") else: - self._prepare_mcs_on_trigger(mcs) + status_mcs = self._prepare_mcs_on_trigger(mcs) + # NOTE Timeout of 3s should be plenty, any longer wait should checked. If this happens to crash + # an acquisition regularly with a WaitTimeoutError, the timeout can be increased but it should + # be investigated why the EPICS interface is slow to respond. + status_mcs.wait(timeout=3) # Prepare StatusBitsCompareStatus to resolve once the END_OF_BURST bit was set. status = self._prepare_trigger_status_event() 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 16172b5..55dad7c 100644 --- a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_2.py +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_2.py @@ -25,7 +25,7 @@ Burst mode is enabled: import time from bec_lib.logger import bec_logger -from ophyd import DeviceStatus, StatusBase +from ophyd_devices import DeviceStatus, StatusBase from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase from csaxs_bec.devices.epics.delay_generator_csaxs.delay_generator_csaxs import ( 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 3f21f71..b42e3b6 100644 --- a/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py +++ b/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py @@ -475,7 +475,7 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): # Handle external stop/cancel, and stop monitoring ret_status.add_callback(self._status_failed_callback) self.cancel_on_stop(ret_status) - return status + return ret_status def on_destroy(self): """ diff --git a/tests/tests_devices/test_delay_generator_csaxs.py b/tests/tests_devices/test_delay_generator_csaxs.py index 252babc..18079c6 100644 --- a/tests/tests_devices/test_delay_generator_csaxs.py +++ b/tests/tests_devices/test_delay_generator_csaxs.py @@ -6,9 +6,35 @@ from unittest import mock import numpy as np import ophyd import pytest -from ophyd_devices.tests.utils import MockPV, patch_dual_pvs +from bec_server.device_server.tests.utils import DMMock +from ophyd_devices.tests.utils import patched_device from csaxs_bec.devices.epics.delay_generator_csaxs import DDG1, DDG2 +from csaxs_bec.devices.epics.delay_generator_csaxs.ddg_1 import ( + DEFAULT_IO_CONFIG as DDG1_DEFAULT_IO_CONFIG, +) +from csaxs_bec.devices.epics.delay_generator_csaxs.ddg_1 import ( + DEFAULT_READOUT_TIMES as DDG1_DEFAULT_READOUT_TIMES, +) +from csaxs_bec.devices.epics.delay_generator_csaxs.ddg_1 import ( + DEFAULT_REFERENCES as DDG1_DEFAULT_REFERENCES, +) +from csaxs_bec.devices.epics.delay_generator_csaxs.ddg_1 import ( + DEFAULT_TRIGGER_SOURCE as DDG1_DEFAULT_TRIGGER_SOURCE, +) +from csaxs_bec.devices.epics.delay_generator_csaxs.ddg_1 import PROC_EVENT_MODE +from csaxs_bec.devices.epics.delay_generator_csaxs.ddg_2 import ( + DEFAULT_IO_CONFIG as DDG2_DEFAULT_IO_CONFIG, +) +from csaxs_bec.devices.epics.delay_generator_csaxs.ddg_2 import ( + DEFAULT_READOUT_TIMES as DDG2_DEFAULT_READOUT_TIMES, +) +from csaxs_bec.devices.epics.delay_generator_csaxs.ddg_2 import ( + DEFAULT_REFERENCES as DDG2_DEFAULT_REFERENCES, +) +from csaxs_bec.devices.epics.delay_generator_csaxs.ddg_2 import ( + DEFAULT_TRIGGER_SOURCE as DDG2_DEFAULT_TRIGGER_SOURCE, +) from csaxs_bec.devices.epics.delay_generator_csaxs.delay_generator_csaxs import ( BURSTCONFIG, CHANNELREFERENCE, @@ -16,68 +42,46 @@ from csaxs_bec.devices.epics.delay_generator_csaxs.delay_generator_csaxs import TRIGGERSOURCE, DelayGeneratorCSAXS, ) +from csaxs_bec.devices.epics.mcs_card.mcs_card_csaxs import MCSCardCSAXS - -@pytest.fixture(scope="function") -def mock_ddg1() -> Generator[DDG1, DDG1, DDG1]: - """Fixture to mock the DDG1 device.""" - name = "ddg1" - prefix = "test_ddg1:" - with mock.patch.object(ophyd, "cl") as mock_cl: - mock_cl.get_pv = MockPV - mock_cl.thread_class = threading.Thread - dev = DDG1(name=name, prefix=prefix) - patch_dual_pvs(dev) - yield dev - - -@pytest.fixture(scope="function") -def mock_ddg2() -> Generator[DDG2, DDG2, DDG2]: - """Fixture to mock the DDG1 device.""" - name = "ddg2" - prefix = "test_ddg2:" - with mock.patch.object(ophyd, "cl") as mock_cl: - mock_cl.get_pv = MockPV - mock_cl.thread_class = threading.Thread - dev = DDG2(name=name, prefix=prefix) - patch_dual_pvs(dev) - yield dev +############################ +### Test Delay Generator ### +############################ @pytest.fixture(scope="function") def mock_ddg() -> Generator[DelayGeneratorCSAXS, DelayGeneratorCSAXS, DelayGeneratorCSAXS]: """Fixture to mock the camera device.""" - name = "ddg" - prefix = "test:" - with mock.patch.object(ophyd, "cl") as mock_cl: - mock_cl.get_pv = MockPV - mock_cl.thread_class = threading.Thread - dev = DelayGeneratorCSAXS(name=name, prefix=prefix) - patch_dual_pvs(dev) - yield dev + with patched_device( + DelayGeneratorCSAXS, name="ddg", prefix="test:", _mock_pv_initial_value=0 + ) as dev: + try: + yield dev + finally: + dev.destroy() -def test_ddg_init(mock_ddg): +def test_ddg_init(mock_ddg: DelayGeneratorCSAXS): """Test the proc event status method.""" assert mock_ddg.name == "ddg" assert mock_ddg.prefix == "test:" -def test_ddg_proc_event_status(mock_ddg): +def test_ddg_proc_event_status(mock_ddg: DelayGeneratorCSAXS): """Test the proc event status method.""" mock_ddg.state.proc_status.put(0) mock_ddg.proc_event_status() assert mock_ddg.state.proc_status.get() == 1 -def test_ddg_set_trigger(mock_ddg): +def test_ddg_set_trigger(mock_ddg: DelayGeneratorCSAXS): """Test setting the trigger.""" for trigger in TRIGGERSOURCE: mock_ddg.set_trigger(trigger) assert mock_ddg.trigger_source.get() == trigger.value -def test_ddg_burst_enable(mock_ddg): +def test_ddg_burst_enable(mock_ddg: DelayGeneratorCSAXS): """Test enabling burst mode.""" mock_ddg.burst_enable(count=100, delay=0.1, period=0.02, config=BURSTCONFIG.ALL_CYCLES) mock_ddg.burst_mode.get() == 1 @@ -101,7 +105,7 @@ def test_ddg_burst_enable(mock_ddg): mock_ddg.burst_mode.get() == BURSTCONFIG.FIRST_CYCLE.value -def test_ddg_wait_for_event_status(mock_ddg): +def test_ddg_wait_for_event_status(mock_ddg: DelayGeneratorCSAXS): """Test setting wait for event status.""" mock_ddg: DelayGeneratorCSAXS mock_ddg.state.event_status._read_pv.mock_data = 0 @@ -117,7 +121,7 @@ def test_ddg_wait_for_event_status(mock_ddg): # assert status.done is True -def test_ddg_set_io_values(mock_ddg): +def test_ddg_set_io_values(mock_ddg: DelayGeneratorCSAXS): """Test setting IO values.""" mock_ddg.set_io_values(channel="ab", amplitude=3, offset=2, polarity=1, mode="ttl") assert mock_ddg.ab.io.amplitude.get() == 3 @@ -138,7 +142,7 @@ def test_ddg_set_io_values(mock_ddg): assert attr.nim_mode.get() == 1 -def test_ddg_set_delay_pairs(mock_ddg): +def test_ddg_set_delay_pairs(mock_ddg: DelayGeneratorCSAXS): """Test setting delay pairs.""" mock_ddg.set_delay_pairs(channel="ab", delay=0.1, width=0.2) assert np.isclose(mock_ddg.ab.delay.get(), 0.1) @@ -156,52 +160,143 @@ def test_ddg_set_delay_pairs(mock_ddg): assert np.isclose(getattr(mock_ddg, channel).ch2.setpoint.get(), delay + 0.2) -def test_ddg1_on_connected(mock_ddg1): +######################### +### Test DDG1 Device #### +######################### + + +@pytest.fixture(scope="function") +def mock_mcs_csaxs() -> Generator[MCSCardCSAXS, None, None]: + """Fixture to mock the MCSCardCSAXS device.""" + dm = DMMock() + with patched_device( + MCSCardCSAXS, + name="mcs", + prefix="X12SA-MCS-CSAXS:", + device_manager=dm, + _mock_pv_initial_value=0, + ) as dev: + dev.enabled = True + dev.device_manager.devices["mcs"] = dev + try: + yield dev + finally: + dev.destroy() + + +@pytest.fixture(scope="function") +def mock_ddg1(mock_mcs_csaxs: MCSCardCSAXS) -> Generator[DDG1, None, None]: + """Fixture to mock the DDG1 device.""" + # Add enabled to mock_mcs_csaxs + dm_mock = mock_mcs_csaxs.device_manager + with patched_device( + DDG1, name="ddg1", prefix="test_ddg1:", device_manager=dm_mock, _mock_pv_initial_value=0 + ) as dev: + dev.enabled = True + dev.device_manager.devices["ddg1"] = dev + try: + yield dev + finally: + dev.destroy() + + +def test_ddg1_on_connected(mock_ddg1: DDG1): """Test the on_connected method of DDG1.""" - mock_ddg1.on_connected() - # IO defaults - assert mock_ddg1.burst_mode.get() == 0 - assert mock_ddg1.ab.io.amplitude.get() == 5.0 - assert mock_ddg1.cd.io.offset.get() == 0.0 - assert mock_ddg1.ef.io.polarity.get() == 1 - assert mock_ddg1.gh.io.ttl_mode.get() == 1 + mock_ddg1.burst_mode.put(1) # Set burst mode to 1, if connected should reset it to 0 + mock_ddg1.burst_delay.put(5) # Set to non-zero, should reset to 0 on connected + mock_ddg1.burst_count.put(10) # Set to non-default, should reset to 1 on connected + with mock.patch.object(mock_ddg1, "set_io_values") as mock_set_io_values: + mock_ddg1.on_connected() - # reference defaults - assert mock_ddg1.ab.ch1.reference.get() == 0 # CHANNELREFERENCE.T0.value - assert mock_ddg1.ab.ch2.reference.get() == 1 # CHANNELREFERENCE.A.value - assert mock_ddg1.cd.ch1.reference.get() == 0 # CHANNELREFERENCE.T0.value - assert mock_ddg1.cd.ch2.reference.get() == 3 # CHANNELREFERENCE.C.value - assert mock_ddg1.ef.ch1.reference.get() == 4 # CHANNELREFERENCE.D.value - assert mock_ddg1.ef.ch2.reference.get() == 5 # CHANNELREFERENCE.E.value - assert mock_ddg1.gh.ch1.reference.get() == 0 # CHANNELREFERENCE.T0.value - assert mock_ddg1.gh.ch2.reference.get() == 7 # CHANNELREFERENCE.G.value + # Burst mode Defaults + assert mock_ddg1.burst_mode.get() == 0 + assert mock_ddg1.burst_delay.get() == 0 + assert mock_ddg1.burst_count.get() == 1 - # Default trigger source - assert mock_ddg1.trigger_source.get() == 5 # TRIGGERSOURCE.SINGLE_SHOT.value + assert mock_set_io_values.call_count == len(DDG1_DEFAULT_IO_CONFIG) + for ch, config in DDG1_DEFAULT_IO_CONFIG.items(): + assert mock.call(ch, **config) in mock_set_io_values.call_args_list + + # Check reference values from DEFAULT_REFERENCES + for ch, refs in DDG1_DEFAULT_REFERENCES: + if ch == "A": + sub_ch = mock_ddg1.ab.ch1 + elif ch == "B": + sub_ch = mock_ddg1.ab.ch2 + elif ch == "C": + sub_ch = mock_ddg1.cd.ch1 + elif ch == "D": + sub_ch = mock_ddg1.cd.ch2 + elif ch == "E": + sub_ch = mock_ddg1.ef.ch1 + elif ch == "F": + sub_ch = mock_ddg1.ef.ch2 + elif ch == "G": + sub_ch = mock_ddg1.gh.ch1 + elif ch == "H": + sub_ch = mock_ddg1.gh.ch2 + assert sub_ch.reference.get() == refs.value + + # Check Default trigger source + assert mock_ddg1.trigger_source.get() == DDG1_DEFAULT_TRIGGER_SOURCE.value + + # Check proc state mode + assert mock_ddg1.state.proc_status_mode.get() == PROC_EVENT_MODE.EVENT.value + + # Check the poll thread is started + assert mock_ddg1._poll_thread.is_alive() + assert not mock_ddg1._poll_thread_kill_event.is_set() + assert not mock_ddg1._poll_thread_poll_loop_done.is_set() + assert not mock_ddg1._poll_thread_run_event.is_set() -def test_ddg1_stage(mock_ddg1): +def test_ddg1_prepare_mcs(mock_ddg1: DDG1, mock_mcs_csaxs: MCSCardCSAXS): + """Test the prepare_mcs method of DDG1.""" + mcs = mock_mcs_csaxs + ddg = mock_ddg1 + # Simulate default state + mcs.acquiring._read_pv.mock_data = 0 # not acquiring + mcs.erase_start.put(0) # reset erase start + + # Prepare MCS on trigger + st = ddg._prepare_mcs_on_trigger(mcs) + assert st.done is False + assert st.success is False + assert mcs.erase_start.get() == 1 # erase started + + # Simulate acquiring started + mcs.acquiring._read_pv.mock_data = 1 # acquiring + st.wait(2) + assert st.done is True + assert st.success is True + + +def test_ddg1_stage(mock_ddg1: DDG1): """Test the on_stage method of DDG1.""" exp_time = 0.1 frames_per_trigger = 10 - mock_ddg1.burst_mode.put(1) + mock_ddg1.burst_mode.put(0) # Non-default, should be reset on stage + mock_ddg1.burst_delay.put(5) # Non-default, should be reset on stage + mock_ddg1.burst_count.put(10) # Non-default, should be reset on stage + mock_ddg1.scan_info.msg.scan_parameters["exp_time"] = exp_time mock_ddg1.scan_info.msg.scan_parameters["frames_per_trigger"] = frames_per_trigger mock_ddg1.stage() + shutter_width = 2e-3 + exp_time * frames_per_trigger + 1e-3 + 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) + assert np.isclose(mock_ddg1.burst_period.get(), shutter_width) # Trigger DDG2 through EXT/EN - assert np.isclose(mock_ddg1.ab.delay.get(), 2e-3) assert np.isclose(mock_ddg1.ab.width.get(), 1e-6) # Shutter channel cd assert np.isclose(mock_ddg1.cd.delay.get(), 0) - assert np.isclose(mock_ddg1.cd.width.get(), 2e-3 + exp_time * frames_per_trigger + 1e-3) + assert np.isclose(mock_ddg1.cd.width.get(), shutter_width) # MCS channel ef or gate assert np.isclose(mock_ddg1.ef.delay.get(), 0) assert np.isclose(mock_ddg1.ef.width.get(), 1e-6) @@ -209,96 +304,266 @@ def test_ddg1_stage(mock_ddg1): assert mock_ddg1.staged == ophyd.Staged.yes -def test_ddg1_trigger(mock_ddg1): - """Test the on_trigger method of DDG1.""" - mock_ddg1.state.event_status._read_pv.mock_data = STATUSBITS.NONE.value +def test_ddg1_on_trigger(mock_ddg1: DDG1): + """ + Test the on_trigger method of the DDG1. + + We will test two scenarios: + I. Trigger is prepared, and resolves successfully after END_OF_BURST is reached in event status register. + II. Trigger is called while _poll_thread_loop_done is not yet finished from a previous trigger. + This may be the case if polling is yet to finsish. The next on_trigger should terminate the previous + polling, and work as expected. In addition, we will simulate that the mcs card is disabled, thus not prepared. + """ + ddg = mock_ddg1 + # Make sure DDG is setup in default state through on_connected + ddg.on_connected() + + # Check that poll thread is running and run event is not set + assert ddg._poll_thread.is_alive() + assert not ddg._poll_thread_run_event.is_set() + assert not ddg._poll_thread_poll_loop_done.is_set() + + # Set the status register bit + ddg.state.event_status._read_pv.mock_data = STATUSBITS.ABORT_DELAY.value + + ################################# + # Scenario I - normal operation # + ################################# + with mock.patch.object(ddg, "_prepare_mcs_on_trigger") as mock_prepare_mcs: + mock_prepare_mcs.return_value = ophyd.StatusBase(done=True, success=True) + status = ddg.trigger() + + # Check that the poll thread run event is set + assert ddg._poll_thread_run_event.is_set() + assert not ddg._poll_thread_poll_loop_done.is_set() - 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_BURST.value + assert ddg.trigger_shot.get() == 1 + + # Simulate that the event status bit reaches END_OF_BURST + ddg.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 + # Should finish the poll loop + ddg._poll_thread_poll_loop_done.wait(timeout=1) + assert not ddg._poll_thread_run_event.is_set() -def test_ddg1_stop(mock_ddg1): - """Test the on_stop method of DDG1.""" - mock_ddg1.burst_mode.put(1) # Enable burst mode - mock_ddg1.stop() - assert mock_ddg1.burst_mode.get() == 0 # Burst mode is disabled + ############################################ + # Scenario II - previous poll not finished # + # MCS card disabled # + ############################################ + + # Set mcs card to enabled = False + ddg.device_manager.devices["mcs"].enabled = False + ddg.state.event_status._read_pv.mock_data = STATUSBITS.ABORT_DELAY.value + ddg._start_polling() + assert ddg._poll_thread_run_event.is_set() + with mock.patch.object(ddg, "_prepare_mcs_on_trigger") as mock_prepare_mcs: + status = ddg.trigger() + mock_prepare_mcs.assert_not_called() # MCS is disabled, should not be called + assert status.done is False + assert status.success is False + + # Resolve the status by simulating END_OF_BURST + ddg.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 + + # Wait for poll loop to finish + ddg._poll_thread_poll_loop_done.wait(timeout=1) + assert not ddg._poll_thread_run_event.is_set() -def test_ddg2_on_connected(mock_ddg2): - """Test on connected method of DDG2.""" - mock_ddg2.on_connected() - # IO defaults - assert mock_ddg2.burst_mode.get() == 0 - assert mock_ddg2.ab.io.amplitude.get() == 5.0 - assert mock_ddg2.cd.io.offset.get() == 0.0 - assert mock_ddg2.ef.io.polarity.get() == 1 - assert mock_ddg2.gh.io.ttl_mode.get() == 1 +# def test_ddg1_trigger(mock_ddg1): +# """Test the on_trigger method of DDG1.""" +# mock_ddg1.state.event_status._read_pv.mock_data = STATUSBITS.NONE.value - # reference defaults - assert mock_ddg2.ab.ch1.reference.get() == 0 # CHANNELREFERENCE.T0.value - assert mock_ddg2.ab.ch2.reference.get() == 1 # CHANNELREFERENCE.A.value - assert mock_ddg2.cd.ch1.reference.get() == 0 # CHANNELREFERENCE.T0.value - assert mock_ddg2.cd.ch2.reference.get() == 3 # CHANNELREFERENCE.C.value - assert mock_ddg2.ef.ch1.reference.get() == 0 # CHANNELREFERENCE.T0.value - assert mock_ddg2.ef.ch2.reference.get() == 5 # CHANNELREFERENCE.E.value - assert mock_ddg2.gh.ch1.reference.get() == 0 # CHANNELREFERENCE.T0.value - assert mock_ddg2.gh.ch2.reference.get() == 7 # CHANNELREFERENCE.G.value - - # Default trigger source - assert mock_ddg2.trigger_source.get() == 1 # TRIGGERSOURCE.EXT_RISING_EDGE.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_BURST.value +# status.wait(timeout=1) # Wait for the status to be done +# assert status.done is True +# assert status.success is True -def test_ddg2_stage(mock_ddg2): - """Test the on_stage method of DDG2.""" +# def test_ddg1_stop(mock_ddg1): +# """Test the on_stop method of DDG1.""" +# mock_ddg1.burst_mode.put(1) # Enable burst mode +# mock_ddg1.stop() +# assert mock_ddg1.burst_mode.get() == 0 # Burst mode is disabled + + +######################### +### Test DDG2 Device #### +######################### + + +@pytest.fixture(scope="function") +def mock_ddg2(mock_mcs_csaxs: MCSCardCSAXS) -> Generator[DDG2, None, None]: + """Fixture to mock the DDG1 device.""" + # Add enabled to mock_mcs_csaxs + dm_mock = mock_mcs_csaxs.device_manager + with patched_device( + DDG2, name="ddg2", prefix="test_ddg2:", device_manager=dm_mock, _mock_pv_initial_value=0 + ) as dev: + dev.enabled = True + dev.device_manager.devices["ddg2"] = dev + try: + yield dev + finally: + dev.destroy() + + +def test_ddg2_on_connected(mock_ddg2: DDG2): + """Test the on_connected method of DDG1.""" + mock_ddg2.burst_mode.put(1) # Set burst mode to 1, if connected should reset it to 0 + mock_ddg2.burst_delay.put(5) # Set to non-zero, should reset to 0 on connected + mock_ddg2.burst_count.put(10) # Set to non-default, should reset to 1 on connected + with mock.patch.object(mock_ddg2, "set_io_values") as mock_set_io_values: + mock_ddg2.on_connected() + # Burst mode Defaults + assert mock_ddg2.burst_mode.get() == 0 + + assert mock_set_io_values.call_count == len(DDG2_DEFAULT_IO_CONFIG) + for ch, config in DDG2_DEFAULT_IO_CONFIG.items(): + assert mock.call(ch, **config) in mock_set_io_values.call_args_list + + # Check reference values from DEFAULT_REFERENCES + for ch, refs in DDG2_DEFAULT_REFERENCES: + if ch == "A": + sub_ch = mock_ddg2.ab.ch1 + elif ch == "B": + sub_ch = mock_ddg2.ab.ch2 + elif ch == "C": + sub_ch = mock_ddg2.cd.ch1 + elif ch == "D": + sub_ch = mock_ddg2.cd.ch2 + elif ch == "E": + sub_ch = mock_ddg2.ef.ch1 + elif ch == "F": + sub_ch = mock_ddg2.ef.ch2 + elif ch == "G": + sub_ch = mock_ddg2.gh.ch1 + elif ch == "H": + sub_ch = mock_ddg2.gh.ch2 + assert sub_ch.reference.get() == refs.value + + # Check Default trigger source + assert mock_ddg2.trigger_source.get() == DDG2_DEFAULT_TRIGGER_SOURCE.value + + +def test_ddg2_on_stage(mock_ddg2: DDG2): + """ + Test the on_stage method of DDG2. + + We will test two scenarios: + I. Stage device with valid parameters. + II. Stage device with invalid parameters (too short exp_time). Should raise ValueError. + """ + ddg = mock_ddg2 exp_time = 0.1 frames_per_trigger = 10 - mock_ddg2.on_connected() + ddg.on_connected() + ddg.scan_info.msg.scan_parameters["exp_time"] = exp_time + ddg.scan_info.msg.scan_parameters["frames_per_trigger"] = frames_per_trigger - mock_ddg2.burst_mode.put(0) - mock_ddg2.scan_info.msg.scan_parameters["exp_time"] = exp_time - mock_ddg2.scan_info.msg.scan_parameters["frames_per_trigger"] = frames_per_trigger + # Set non-default burst mode settings + ddg.burst_mode.put(0) + ddg.burst_delay.put(5) - mock_ddg2.stage() + # Stage device with valid parameters + ddg.stage() + assert ddg.staged == ophyd.Staged.yes + assert ddg.burst_mode.get() == 1 # Burst mode is enabled + assert ddg.burst_delay.get() == 0 # Burst delay is set to 0 + assert ddg.burst_count.get() == frames_per_trigger + assert ddg.burst_period.get() == exp_time - assert np.isclose(mock_ddg2.burst_mode.get(), 1) # Burst mode is enabled - assert np.isclose(mock_ddg2.ab.delay.get(), 0) - assert np.isclose(mock_ddg2.ab.width.get(), exp_time - 2e-4) # DEFAULT_READOUT_TIMES["ab"]) - assert mock_ddg2.burst_count.get() == frames_per_trigger - assert np.isclose(mock_ddg2.burst_delay.get(), 0) - assert np.isclose(mock_ddg2.burst_period.get(), exp_time) - - 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 + # Pulse width is exp_time - readout_time + burst_pulse_width = exp_time - DDG2_DEFAULT_READOUT_TIMES["ab"] + assert np.isclose(ddg.ab.delay.get(), 0) + assert np.isclose(ddg.ab.width.get(), burst_pulse_width) + # Unstage to reset + ddg.unstage() # Reset staged state for next test + exp_time_short = 2e-4 # too short exposure time with pytest.raises(ValueError): - mock_ddg2.scan_info.msg.scan_parameters["exp_time"] = 2e-4 # too short exposure time - mock_ddg2.stage() + ddg.scan_info.msg.scan_parameters["exp_time"] = exp_time_short + ddg.stage() -def test_ddg2_trigger(mock_ddg2): +def test_ddg2_on_trigger(mock_ddg2: DDG2): """Test the on_trigger method of DDG2.""" - mock_ddg2.trigger_shot.put(0) - status = mock_ddg2.trigger() - assert mock_ddg2.trigger_shot.get() == 0 # Should not trigger DDG2 via soft trigger + ddg = mock_ddg2 + ddg.on_connected() + ddg.trigger_shot.put(0) + status = ddg.trigger() + assert ddg.trigger_shot.get() == 0 # Should not trigger DDG2 via soft trigger status.wait() assert status.done is True assert status.success is True -def test_ddg2_stop(mock_ddg2): +def test_ddg2_on_stop(mock_ddg2: DDG2): """Test the on_stop method of DDG2.""" - mock_ddg2.burst_mode.put(1) # Enable burst mode - mock_ddg2.stop() - assert mock_ddg2.burst_mode.get() == 0 # Burst mode is disabled + ddg = mock_ddg2 + ddg.on_connected() + ddg.burst_mode.put(1) # Enable burst mode + ddg.stop() + assert ddg.burst_mode.get() == 0 # Burst mode is disabled + + +# def test_ddg2_stage(mock_ddg2): +# """Test the on_stage method of DDG2.""" +# exp_time = 0.1 +# frames_per_trigger = 10 +# mock_ddg2.on_connected() + +# mock_ddg2.burst_mode.put(0) +# mock_ddg2.scan_info.msg.scan_parameters["exp_time"] = exp_time +# mock_ddg2.scan_info.msg.scan_parameters["frames_per_trigger"] = frames_per_trigger + +# mock_ddg2.stage() + +# assert np.isclose(mock_ddg2.burst_mode.get(), 1) # Burst mode is enabled +# assert np.isclose(mock_ddg2.ab.delay.get(), 0) +# assert np.isclose(mock_ddg2.ab.width.get(), exp_time - 2e-4) # DEFAULT_READOUT_TIMES["ab"]) +# assert mock_ddg2.burst_count.get() == frames_per_trigger +# assert np.isclose(mock_ddg2.burst_delay.get(), 0) +# assert np.isclose(mock_ddg2.burst_period.get(), exp_time) + +# 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): +# """Test the on_trigger method of DDG2.""" +# mock_ddg2.trigger_shot.put(0) +# status = mock_ddg2.trigger() +# assert mock_ddg2.trigger_shot.get() == 0 # Should not trigger DDG2 via soft trigger +# status.wait() +# assert status.done is True +# assert status.success is True + + +# def test_ddg2_stop(mock_ddg2): +# """Test the on_stop method of DDG2.""" +# mock_ddg2.burst_mode.put(1) # Enable burst mode +# mock_ddg2.stop() +# assert mock_ddg2.burst_mode.get() == 0 # Burst mode is disabled diff --git a/tests/tests_devices/test_mcs_card.py b/tests/tests_devices/test_mcs_card.py index c829988..4bfe5ee 100644 --- a/tests/tests_devices/test_mcs_card.py +++ b/tests/tests_devices/test_mcs_card.py @@ -1,5 +1,7 @@ # pylint: skip-file import threading +from copy import deepcopy +from typing import Generator from unittest import mock import numpy as np @@ -8,6 +10,7 @@ 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.interfaces.base_classes.psi_device_base import DeviceStoppedError from ophyd_devices.tests.utils import MockPV, patch_dual_pvs from csaxs_bec.devices.epics.mcs_card.mcs_card import ( @@ -46,429 +49,238 @@ def test_mcs_card(mock_mcs_card): @pytest.fixture(scope="function") -def mock_mcs_csaxs(): +def mock_mcs_csaxs() -> Generator[MCSCardCSAXS, None, None]: """Fixture to mock the MCSCardCSAXS device.""" name = "mcs_csaxs" prefix = "X12SA-MCS-CSAXS:" dm = DMMock() - 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 + try: + 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 + finally: + mcs_card_csaxs.on_destroy() -def test_mcs_card_csaxs(mock_mcs_csaxs): +def test_mcs_card_csaxs(mock_mcs_csaxs: MCSCardCSAXS): """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 + assert mock_mcs_csaxs._acquisition_group == "monitored" + assert mock_mcs_csaxs._num_total_triggers == 0 + assert mock_mcs_csaxs._mcs_clock == 1e7 + assert mock_mcs_csaxs._pv_timeout == 2.0 + assert mock_mcs_csaxs._mca_counter_index == 0 + assert mock_mcs_csaxs._current_data_index == 0 + assert mock_mcs_csaxs._current_data == {} + assert mock_mcs_csaxs.NUM_MCA_CHANNELS == 32 -def test_mcs_card_csaxs_on_connected(mock_mcs_csaxs): +def test_mcs_card_csaxs_on_connected(mock_mcs_csaxs: MCSCardCSAXS): """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 - # 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 - - with mock.patch.object(mcs.counters.mca1, "subscribe") as mock_mca_subscribe: + with ( + mock.patch.object(mcs.counters.mca1, "subscribe") as mock_mca_subscribe, + mock.patch.object(mcs, "mcs_recovery") as mock_mcs_recovery, + mock.patch.object(mcs._scan_done_thread, "start") as mock_scan_done_thread_start, + ): 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 + + # Mux output + assert mcs.mux_output.get() == mcs.NUM_MCA_CHANNELS + + # 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 + + # Check if subscriptions are setup correctly assert mock_mca_subscribe.call_args == mock.call(mcs._on_counter_update, run=False) + # Check if recovery is called + mock_mcs_recovery.assert_called_once_with(timeout=1) + # Check if scan done thread is started + mock_scan_done_thread_start.assert_called_once() -def test_mcs_card_csaxs_stage(mock_mcs_csaxs): +def test_mcs_card_csaxs_stage(mock_mcs_csaxs: MCSCardCSAXS): """Test on stage method of MCSCardCSAXS""" mcs = mock_mcs_csaxs triggers = 5 + num_points = 10 mcs.scan_info.msg.scan_parameters["frames_per_trigger"] = triggers - mcs.erase_all.put(0) + mcs.scan_info.msg.num_points = num_points + + # Simulate that the MCS card is still acquiring, and that current channel is !=0 + mcs.current_channel._read_pv.mock_data = 2 # Simulate that current channel is not zero + mcs.erase_all.put(0) # Set erase_all to 0 + mcs._current_data = {"mca1": [1, 2, 3]} # Simulate existing data + mcs._scan_done_callbacks = [lambda: None] # Simulate existing callbacks + mcs._start_monitor_async_data_emission.set() # Simulate that monitoring is started + mcs._omit_mca_callbacks.set() # Simulate that mca callbacks are omitted + mcs.stage() + # Check that card is staged assert mcs._staged == ophyd.Staged.yes - assert mcs.erase_all.get() == 1 + + # Check that erase_all, stop_all, preset_real, num_use_all are set correctly + assert mcs.erase_all.get() == 1 # Should be set to 1 as current_channel !=0 assert mcs.preset_real.get() == 0 assert mcs.num_use_all.get() == triggers + # Check that internal variables are reset + assert mcs._num_total_triggers == triggers * num_points + assert mcs._current_data == {} + assert mcs._scan_done_callbacks == [] + assert mcs._current_data_index == 0 + + # Check that thread events are cleared properly + assert not mcs._start_monitor_async_data_emission.is_set() + assert not mcs._omit_mca_callbacks.is_set() + 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.erase_all.put(0) mcs.unstage() assert mcs.stop_all.get() == 1 - assert mcs.erase_all.get() == 0 + assert mcs.erase_all.get() == 1 -def test_mcs_card_csaxs_complete_and_stop(mock_mcs_csaxs): - """Test complete method of MCSCarcCSAXS""" +def test_mcs_card_csaxs_complete_and_stop(mock_mcs_csaxs: MCSCardCSAXS): + """ + Test complete method of MCSCarcCSAXS. + + Two use cases: + I. Acquisition is stopped externally + II. Acquisition completes normally + """ mcs = mock_mcs_csaxs mcs.acquiring._read_pv.mock_data = ACQUIRING.ACQUIRING + # Make sure that device on_connected has been called which starts the monitoring thread + mcs.on_connected() + + ####################### + # I. Use case where acquisition is stopped + ####################### + st = mcs.complete() assert st.done is False - mcs.stop_all.put(0) + assert mcs._start_monitor_async_data_emission.is_set() + + # Status should be cancelled by stop mcs.stop() - with pytest.raises(Exception): + with pytest.raises(DeviceStoppedError): st.wait(timeout=3) + + # Callback on status failure should stop monitoring + mcs._start_monitor_async_data_emission.wait(2) + assert not mcs._start_monitor_async_data_emission.is_set() + + ####################### + # II. Use case where acquisition completes normally + ####################### + + mcs._current_data_index = 0 + mcs.scan_info.msg.num_points = 10 + mcs.acquiring._read_pv.mock_data = ACQUIRING.ACQUIRING + + st = mcs.complete() + assert st.done is False + assert mcs._start_monitor_async_data_emission.is_set() + + mcs.acquiring._read_pv.mock_data = ACQUIRING.DONE + + # This should now automatically complete the status + mcs._current_data_index = 10 + st.wait(timeout=3) assert st.done is True - assert st.success is False - assert mcs.stop_all.get() == 1 + assert st.success is True + + # Clean up procedure should stop the async_data monitoring + mcs._start_monitor_async_data_emission.wait(2) + assert not mcs._start_monitor_async_data_emission.is_set() -def test_mcs_card_csaxs_on_counter_updated(mock_mcs_csaxs): +def test_mcs_recovery(mock_mcs_csaxs: MCSCardCSAXS): mcs = mock_mcs_csaxs - # Called for mca1 + # Simulate ongoing acquisition + mcs.erase_all._read_pv.mock_data = 0 + mcs.stop_all._read_pv.mock_data = 0 + mcs.erase_start.put(0) + mcs.mcs_recovery(timeout=0.1) + assert mcs.erase_all.get() == 1 + assert mcs.stop_all.get() == 1 + assert mcs.erase_start.get() == 1 + assert not mcs._omit_mca_callbacks.is_set() + + +def test_mcs_card_csaxs_on_counter_updated(mock_mcs_csaxs: MCSCardCSAXS): + """ + Test the on_counter_update method of MCSCardCSAXS. + We will test 2 use cases: + I. Suppressed callbacks + II. Callback from 32 mca counters, should result in data being sent to BEC + """ + mcs = mock_mcs_csaxs + + # I. Suppressed callbacks + mcs._omit_mca_callbacks.set() 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] + assert mcs._mca_counter_index == 1 # Counter index should still increment + assert mcs._current_data == {} + # II. Callback from 32 mca counters + mcs._omit_mca_callbacks.clear() + mcs._mca_counter_index = 0 + mcs._current_data_index = 0 + val = mcs.mca.get() -# @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 + for ii in range(mcs.NUM_MCA_CHANNELS): + counter = getattr(mcs.counters, f"mca{ii+1}") + kwargs = {"obj": counter, "timestamp": 1.0} + if ii % 2 == 1: + value = np.array([ii, (ii + 1) * 2]) + else: + value = ii + mcs._on_counter_update(value, **kwargs) + if ii < (mcs.NUM_MCA_CHANNELS - 1): + assert mcs._current_data_index == 0 + assert mcs._mca_counter_index == ii + 1 + assert counter.attr_name in mcs._current_data + assert ( + mcs._current_data[counter.attr_name]["value"] == value.tolist() + if isinstance(value, np.ndarray) + else [value] + ) + buffer = deepcopy(mcs._current_data) + assert mcs.mca.get() == val # Async mca signal should not change + else: + # On last counter, data should be sent to BEC, and internal variables reset + buffer[counter.attr_name] = { + "value": value.tolist() if isinstance(value, np.ndarray) else [value], + "timestamp": 1.0, + } + assert mcs._mca_counter_index == 0 + assert mcs._current_data_index == 1 + assert mcs._current_data == {} - -# 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() - - -# @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_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() - - -# @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( -# "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 - - -# @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 + # Check that the async mca signal is properly set + assert isinstance(mcs.mca.get(), messages.DeviceMessage) + assert len(mcs.mca.get().signals) == mcs.NUM_MCA_CHANNELS -- 2.49.1 From bfcecd73c251e63535ad85cd69db008c77dab2b5 Mon Sep 17 00:00:00 2001 From: appel_c Date: Thu, 15 Jan 2026 08:36:54 +0100 Subject: [PATCH 6/9] refactor(mcs-ddg): cleanup and fix mcs and ddg from beamline tests --- .../epics/delay_generator_csaxs/ddg_1.py | 3 ++ .../epics/delay_generator_csaxs/ddg_2.py | 3 ++ csaxs_bec/devices/epics/mcs_card/README.md | 2 +- .../devices/epics/mcs_card/mcs_card_csaxs.py | 41 ++++++++++++++----- 4 files changed, 37 insertions(+), 12 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 c3a1160..29a0623 100644 --- a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py @@ -212,6 +212,7 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): - We set the delay pairs ef to be triggered after the shutter closes with a width of 1us to trigger the MCS card. - Finally, we add a short sleep to ensure that the IOC and DDG HW process the values properly. """ + start_time = time.time() ######################################## ### Burst mode settings ################ @@ -264,6 +265,7 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): # This value has been choosen empirically after testing with the HW. It's # also just called once per scan and has been found to improve stability of the HW. time.sleep(0.2) + logger.info(f"DDG {self.name} on_stage completed in {time.time() - start_time:.3f}s.") def _prepare_mcs_on_trigger(self, mcs: MCSCardCSAXS) -> None: """ @@ -415,6 +417,7 @@ 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.""" + logger.debug("DDG1 end of burst detected, stopping polling loop.") if status.done: self._stop_polling() 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 55dad7c..bf781f3 100644 --- a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_2.py +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_2.py @@ -145,6 +145,7 @@ class DDG2(PSIDeviceBase, DelayGeneratorCSAXS): This logic is robust for step scans as well as fly scans, as the DDG2 is triggered by the DDG1 through the EXT/EN channel. """ + start_time = time.time() ######################################## ### Burst mode settings ################ ######################################## @@ -183,6 +184,8 @@ class DDG2(PSIDeviceBase, DelayGeneratorCSAXS): # Trigger detectors with delay 0, and pulse width = exp_time - readout_time self.set_delay_pairs(channel="ab", delay=0, width=burst_pulse_width) + logger.info(f"DDG {self.name} on_stage completed in {time.time() - start_time:.3f}s.") + def on_pre_scan(self): """ diff --git a/csaxs_bec/devices/epics/mcs_card/README.md b/csaxs_bec/devices/epics/mcs_card/README.md index d76f8a3..7465c79 100644 --- a/csaxs_bec/devices/epics/mcs_card/README.md +++ b/csaxs_bec/devices/epics/mcs_card/README.md @@ -10,4 +10,4 @@ Operation of the MCS card requires proper configuration as some of the parameter The ophyd device implementation is provided [MCSCard](./mcs_card.py). This class provides a basic interface to the MCS PVs, including configuration of parameters such as number of channels, dwell time, and control of acquisition start/stop. Please check the source code of the class for more details of the implementation. -The [MCSCardCSAXS](./mcs_card_csaxs.py) class extends the basic MCSCard implementation with cSAXS-specific logic and configurations. Please be aware that this is also linked to the implementation of other devices, most notably the [delay generator integration](../delay_generator/README.md), which is used as the trigger source for the MCS card during operation. \ No newline at end of file +The [MCSCardCSAXS](./mcs_card_csaxs.py) class extends the basic MCSCard implementation with cSAXS-specific logic and configurations. Please be aware that this is also linked to the implementation of other devices, most notably the [delay generator integration](../delay_generator_csaxs/README.md), which is used as the trigger source for the MCS card during operation. \ No newline at end of file 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 b42e3b6..1ff3d08 100644 --- a/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py +++ b/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py @@ -255,9 +255,9 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): **kwargs: Additional keyword arguments from the subscription, including 'obj' (the EpicsSignalRO instance). """ with self._rlock: - self._mca_counter_index += 1 if self._omit_mca_callbacks.is_set(): return # Suppress callbacks when erasing all channels + self._mca_counter_index += 1 signal: EpicsSignalRO | None = kwargs.get("obj", None) if signal is None: logger.error(f"Called without 'obj' in kwargs: {kwargs}") @@ -286,7 +286,13 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): ) # Once we have received all channels, push data to BEC and reset for next accumulation + logger.debug( + f"Received update for {attr_name}, index {self._mca_counter_index}/{self.NUM_MCA_CHANNELS}" + ) if len(self._current_data) == self.NUM_MCA_CHANNELS: + logger.debug( + f"Current data index {self._current_data_index} complete, pushing to BEC." + ) self.mca.put(self._current_data, acquisition_group=self._acquisition_group) self._current_data.clear() self._mca_counter_index = 0 @@ -322,6 +328,7 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): - Clear any events and buffers related to async data emission. This includes '_omit_mca_callbacks', '_start_monitor_async_data_emission', '_scan_done_callbacks', and '_current_data'. """ + start_time = time.time() # NOTE: If for some reason, the card is still acquiring, we need to stop it first # This should never happen as the card is properly stopped during unstage @@ -361,6 +368,7 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): # Clear any previous data, just to be sure with self._rlock: self._current_data.clear() + self._mca_counter_index = 0 # NOTE Reset events for monitoring async_data_emission thread which is # running during complete to wait for all data from the card @@ -376,6 +384,8 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): # NOTE Make sure that the signal that omits mca callbacks is cleared self._omit_mca_callbacks.clear() + logger.info(f"MCS Card {self.name} on_stage completed in {time.time() - start_time:.3f}s.") + def on_unstage(self) -> None: """ Called when the device is unstaged. This method should be omnipotent and resolve fast. @@ -390,6 +400,7 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): with suppress_mca_callbacks(self): with self._rlock: self._current_data.clear() + self._current_data_index = 0 self.erase_all.put(1) def _monitor_async_data_emission(self) -> None: @@ -406,9 +417,14 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): while not self._scan_done_thread_kill_event.is_set(): while self._start_monitor_async_data_emission.wait(): try: - if self._current_data_index == self.scan_info.msg.num_points: - for callback in self._scan_done_callbacks: - callback(exception=None) + logger.debug(f"Monitoring async data emission for {self.name}...") + if ( + hasattr(self.scan_info.msg, "num_points") + and self.scan_info.msg.num_points is not None + ): + if self._current_data_index == self.scan_info.msg.num_points: + for callback in self._scan_done_callbacks: + callback(exception=None) time.sleep(0.02) # 20ms delay to avoid busy loop except Exception as exc: # pylint: disable=broad-except content = traceback.format_exc() @@ -424,17 +440,19 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): self._start_monitor_async_data_emission.clear() # Stop monitoring # NOTE Important check as set_finished or set_exception should not be called # if the status is already done (e.g. cancelled externally) - if status.done: - return # Already done and cancelled externally. - if exception: - status.set_exception(exception) - else: - status.set_finished() + with self._rlock: + if status.done: + return # Already done and cancelled externally. + if exception is not None: + status.set_exception(exception) + else: + status.set_finished() def _status_failed_callback(self, status: StatusBase) -> None: """Callback for status failure, the monitoring thread should be stopped.""" # NOTE Check for status.done and status.success is important to avoid if status.done: + self._start_monitor_async_data_emission.clear() # Stop monitoring def on_complete(self) -> CompareStatus: @@ -465,6 +483,7 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): self._scan_done_callbacks.append(partial(self._status_callback, status_async_data)) # Set the event to start monitoring async data emission + logger.debug(f"Starting to monitor async data emission for {self.name}...") self._start_monitor_async_data_emission.set() # Add CompareStatus for Acquiring DONE @@ -505,7 +524,7 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): timeout (int): Total timeout for the recovery procedure. Defaults to 1 second. """ sleep_time = timeout / 2 # 2 sleeps - logger.info( + logger.debug( f"Running recovery procedure for MCS card {self.name} with {sleep_time}s sleep, calling stop_all and erase_all, and another {sleep_time}s sleep" ) # First erase and start ongoing acquisition. -- 2.49.1 From 11c887b078f5bf2aa4f5d3debd62c8ccc342aa7e Mon Sep 17 00:00:00 2001 From: appel_c Date: Thu, 22 Jan 2026 22:32:58 +0100 Subject: [PATCH 7/9] feat(debug-tools): add debug tools and adjust logic from beamline tests --- .../plugins/tool_box/__init__.py | 0 .../plugins/tool_box/debug_tools.py | 257 ++++++++++++++++++ .../plugins/tool_box/jfj_config.json | 162 +++++++++++ .../startup/post_startup.py | 5 + .../delay_generator_csaxs.py | 4 +- tests/tests_devices/test_mcs_card.py | 1 - 6 files changed, 426 insertions(+), 3 deletions(-) create mode 100644 csaxs_bec/bec_ipython_client/plugins/tool_box/__init__.py create mode 100644 csaxs_bec/bec_ipython_client/plugins/tool_box/debug_tools.py create mode 100644 csaxs_bec/bec_ipython_client/plugins/tool_box/jfj_config.json diff --git a/csaxs_bec/bec_ipython_client/plugins/tool_box/__init__.py b/csaxs_bec/bec_ipython_client/plugins/tool_box/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/csaxs_bec/bec_ipython_client/plugins/tool_box/debug_tools.py b/csaxs_bec/bec_ipython_client/plugins/tool_box/debug_tools.py new file mode 100644 index 0000000..1a72d19 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/tool_box/debug_tools.py @@ -0,0 +1,257 @@ +"""Module providing debugging tools for the BEC IPython client at cSAXS.""" + +from __future__ import annotations + +import inspect +import json +import os +import re +import socket +from concurrent.futures import ThreadPoolExecutor +from functools import partial +from typing import TYPE_CHECKING, Literal + +import numpy as np +from pydantic import BaseModel +from rich.console import Console +from rich.panel import Panel +from rich.table import Table +from rich.text import Text +from slugify import slugify + +if TYPE_CHECKING: + from bec_ipython_client.main import BECClient + from bec_lib.scans import Scans + from bec_widgets.cli.client_utils import BECGuiClient + + scans: Scans # type: ignore[no-redef] + + bec: BECClient # type: ignore[no-redef] + + gui: BECGuiClient # type: ignore[no-redef] + + dev: bec.device_manager + + +class Detector(BaseModel): + """Model representing a detector configuration.""" + + name: str + hostnames: list[str] + cfg: dict + + +def to_identifier(text: str) -> str: + """ + Convert an unsafe string into a valid Python identifier. + """ + name = slugify(text.strip(), separator="_") + name = re.sub(r"[^a-zA-Z0-9_]", "", name) + + if not name: + return "_" + + if name[0].isdigit(): + name = f"_{name}" + + return name + + +class DebugTools: + """A collection of debugging tools for the BEC IPython client at cSAXS.""" + + _PURPOSE = " ".join( + [ + "Debugging helpers for the cSAXS BEC IPython client. These tools are intended for ", + "advanced users and developers to diagnose and troubleshoot issues within the BEC environment.", + "Below are the available methods together with a brief description of their functionality.", + ] + ) + + ###################### + ## Internal Methods ## + ###################### + + def _describe(self) -> None: + """Pretty-print a description of this debugging tool.""" + console = Console() + + # Offset for IPython prompt misplacement + console.print("\n\n", end="") + + header = Text("DebugTools", style="bold cyan") + purpose = Text(self._PURPOSE, style="dim") + + console.print(Panel(purpose, title=header, expand=False)) + + table = Table(show_header=True, header_style="bold magenta") + table.add_column("Method", style="bold", no_wrap=True) + table.add_column("Description") + + for name, member in inspect.getmembers(self, predicate=inspect.ismethod): + if name.startswith("_"): + continue + + doc = inspect.getdoc(member) + short_doc = doc.splitlines()[0] if doc else "" + table.add_row(name, short_doc) + + console.print(table) + + def _repr_pretty_(self, p, cycle: bool) -> None: + if cycle: + p.text("DebugTools(...)") + else: + self._describe() + + ##################### + ### MCS Card Check ### + ##################### + + def _check_if_mcs_card_is_loaded(self): + """Check if the MCS card device is loaded in the current BEC session.""" + if "mcs" not in dev: + raise RuntimeError("MCS device is not loaded in the current active BEC session.") + + def _check_if_ddg_is_loaded(self): + """Check if the DDG1 device is loaded in the current BEC session.""" + if "ddg1" not in dev: + raise RuntimeError("DDG1 device is not loaded in the current active BEC session.") + if "ddg2" not in dev: + raise RuntimeError("DDG2 device is not loaded in the current active BEC session.") + + def mcs_test_acquire( + self, mode: Literal["high_frame", "medium_frame", "low_frame"] = "high_frame" + ): + """ + Method to perform a test acquisition with randomized exposure time, burst frames, and cycles + on the MCS card using the DDG trigger setup. + + Args: + mode (Literal["high_frame", "medium_frame", "low_frame"]): The mode of the test. + - 'high_frame': Tests high frame rates with short exposure times. + - 'medium_frame': Tests medium frame rates with moderate exposure times. + - 'low_frame': Tests low frame rates with longer exposure times. + """ + self._check_if_mcs_card_is_loaded() + self._check_if_ddg_is_loaded() + + if mode == "high_frame": + burst_frames = np.random.randint(10000, 100000) # between 10000 and 100000 + cycles = np.random.randint(5, 20) # between 5 and 20 + exp_time = ( + np.random.rand() * (0.001 - 0.000201) + 0.000201 + ) # between 0.000201 ms and 0.001 s + elif mode == "medium_frame": + burst_frames = np.random.randint(50, 500) # between 50 and 500 + cycles = np.random.randint(1, 10) # between 1 and 10 + exp_time = np.random.rand() * (0.01 - 0.001) + 0.001 # between 0.001 ms and 0.01 s + elif mode == "low_frame": + burst_frames = np.random.randint(5, 20) # between 5 and 20 + cycles = np.random.randint(1, 5) # between 1 and 5 + exp_time = np.random.rand() * (2 - 0.1) + 0.1 # between 0.1 ms and 2 s + else: + raise ValueError(f"Invalid mode '{mode}' specified for acquire scan test.") + print( + f"Starting acquire measurement with exp_time={exp_time}, burst_frames={burst_frames}, cycles={cycles}" + ) + s = scans.acquire( + exp_time=exp_time, frames_per_trigger=burst_frames, burst_at_each_point=cycles + ) + s.wait() + print("Acquire measurement finished.") + print("Checking MCS data...") + scan_data = bec.history.get_by_scan_id(s.scan.scan_id) + mcs_data = scan_data.devices.mcs + print(mcs_data) + + shape = mcs_data._info["mcs_mca_mca1"]["value"]["shape"] + expected_shape = (cycles * burst_frames,) + # Assert will raise an error if the shapes do not match + assert ( + shape == expected_shape + ), f"MCS data shape {shape} does not match expected shape {expected_shape}." + + ######################## + ### JFJ/Eiger Checks ### + ######################## + + def _get_jfj_eiger_config(self) -> dict[str, Detector]: + """Retrieve the current JFJ/Eiger detector configuration from the BEC client.""" + # FIXME: Implement REST API call once ready for use from Leo Sala's team. + ret = {} + base_path = os.path.dirname(__file__) + config_path = os.path.join(base_path, "jfj_config.json") + with open(config_path, "r", encoding="utf-8") as fh: + cfg = json.load(fh) + + for entry in cfg["detector"]: + det = Detector( + name=to_identifier(entry["description"]), hostnames=entry["hostname"], cfg=cfg + ) + ret[det.name] = det + return ret + + def list_detectors(self) -> list[str]: + """ + List the names of all JFJ/Eiger detectors configured in the BEC client. + + Returns: + list[str]: A list of detector names. + """ + detectors = self._get_jfj_eiger_config() + return list(detectors.keys()) + + def ping_detector(self, detector_name: str) -> bool: + """ + Ping a JFJ/Eiger detector to check if it is reachable. + + Args: + detector_name (str): The name of the detector to ping. + + Returns: + bool: True if the detector is reachable, False otherwise. + """ + detectors = self._get_jfj_eiger_config() + if detector_name not in detectors: + raise ValueError(f"Detector '{detector_name}' not found in configuration.") + + det = detectors[detector_name] + results = self._ping_many(det.hostnames) + + table = Table(title=f"Ping results for detector '{detector_name}'") + table.add_column("Hostname", style="cyan", no_wrap=True) + table.add_column("Status", style="magenta") + + for host, alive in results.items(): + status = "[green]OK[/green]" if alive else "[red]DOWN[/red]" + table.add_row(host, status) + + console = Console() + console.print(table) + + def _ping_many(self, hosts: list[str], port=22, timeout=2, max_workers=None): + max_workers = max_workers or len(hosts) + with ThreadPoolExecutor(max_workers=max_workers) as executor: + primed_ping = partial(self._ping, port=port, timeout=timeout) + pings = executor.map(primed_ping, hosts) + return dict(zip(hosts, pings)) + + def _ping(self, host: str, port=23, timeout=2): # telnet is port 23 + address = (host, port) + try: + with socket.create_connection(address, timeout): + return True + except OSError: + return False + + def open_it_service_page(self): + """Open the overview of IT services hosted by Science IT (Leo Sala) for cSAXS.""" + gui = bec.gui + dock_area = gui.new() + print("Opening IT service page in new dock...") + url = "https://metrics.psi.ch/d/saf8mxv/x12sa?orgId=1&from=now-24h&to=now&timezone=browser&var-receiver_hosts=sls-jfjoch-001.psi.ch&var-writer_hosts=xbl-daq-34.psi.ch&var-beamline=X12SA&var-slurm_partitions=csaxs&var-receiver_services=broker&var-writer_services=writer&refresh=15m" + # FIXME BEC WIDGETS v3 + dock = dock_area.new() + wb = dock.new(widget=gui.available_widgets.WebsiteWidget) + wb.set_url(url) diff --git a/csaxs_bec/bec_ipython_client/plugins/tool_box/jfj_config.json b/csaxs_bec/bec_ipython_client/plugins/tool_box/jfj_config.json new file mode 100644 index 0000000..38be24c --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/tool_box/jfj_config.json @@ -0,0 +1,162 @@ +{ + "zeromq" : { + "image_socket": ["tcp://0.0.0.0:5500"] + }, + "zeromq_preview": { + "socket_address": "tcp://0.0.0.0:5400", + "enabled": true, + "period_ms": 1000 + }, + "zeromq_metadata" : { + "socket_address": "tcp://0.0.0.0:5600", + "enabled": true, + "period_ms": 100 + }, + "instrument" : { + "source_name": "Swiss Light Source", + "instrument_name": "cSAXS", + "source_type": "Synchrotron X-ray Source" + }, + "detector": [ + { + "description": "EIGER 9M", + "serial_number": "E1", + "type": "EIGER", + "mirror_y": true, + "base_data_ipv4_address": "10.10.10.10", + "calibration_file":["/opt/jfjoch/calibration/"], + "standard_geometry" : { + "nmodules": 18, + "modules_in_row": 3, + "gap_x": 8, + "gap_y": 36 + }, + "hostname": [ + "beb101", + "beb103", + "beb014", + "beb078", + "beb060", + "beb030", + "beb092", + "beb178", + "beb009", + "beb038", + "beb056", + "beb058", + "beb033", + "beb113", + "beb005", + "beb017", + "beb119", + "beb095", + "beb186", + "beb042", + "beb106", + "beb059", + "beb111", + "beb203", + "beb100", + "beb093", + "beb123", + "beb061", + "beb121", + "beb055", + "beb004", + "beb190", + "beb054", + "beb189", + "beb107", + "beb115" + ] + }, + { + "description": "EIGER 8.5M (tmp)", + "serial_number": "E1-tmp", + "type": "EIGER", + "mirror_y": true, + "base_data_ipv4_address": "10.10.10.10", + "calibration_file":["/opt/jfjoch/calibration/"], + "standard_geometry" : { + "nmodules": 17, + "modules_in_row": 3, + "gap_x": 8, + "gap_y": 36 + }, + "hostname": [ + "beb101", + "beb103", + "beb014", + "beb078", + "beb060", + "beb030", + "beb092", + "beb178", + "beb009", + "beb038", + "beb056", + "beb058", + "beb033", + "beb113", + "beb005", + "beb017", + "beb119", + "beb095", + "beb186", + "beb042", + "beb106", + "beb059", + "beb100", + "beb093", + "beb123", + "beb061", + "beb121", + "beb055", + "beb004", + "beb190", + "beb054", + "beb189", + "beb107", + "beb115" + ] + }, + { + "description": "EIGER 1.5M", + "serial_number": "E2", + "type": "EIGER", + "mirror_y": true, + "base_data_ipv4_address": "10.10.11.10", + "calibration_file":["/opt/jfjoch/calibration_e1p5m/"], + "standard_geometry" : { + "nmodules": 3, + "modules_in_row": 1, + "gap_x": 8, + "gap_y": 36 + }, + "hostname": ["beb062", "beb026", "beb099", "beb084", "beb120", "beb108"] + } + ], + "frontend_directory": "/usr/share/jfjoch/frontend/", + "image_pusher": "ZeroMQ", + "numa_policy": "n2g2", + "receiver_threads": 64, + "image_buffer_MiB": 96000, + "pcie": [ + { + "blk": "/dev/jfjoch0", + "ipv4": "10.10.10.1" + }, + { + "blk": "/dev/jfjoch1", + "ipv4": "10.10.10.2" + }, + { + "blk": "/dev/jfjoch2", + "ipv4": "10.10.10.3" + }, + { + "blk": "/dev/jfjoch3", + "ipv4": "10.10.10.4" + } + ] +} \ No newline at end of file diff --git a/csaxs_bec/bec_ipython_client/startup/post_startup.py b/csaxs_bec/bec_ipython_client/startup/post_startup.py index a23d73b..5d696e6 100644 --- a/csaxs_bec/bec_ipython_client/startup/post_startup.py +++ b/csaxs_bec/bec_ipython_client/startup/post_startup.py @@ -48,6 +48,11 @@ elif _args.session.lower() == "csaxs": logger.success("cSAXS session loaded.") +from csaxs_bec.bec_ipython_client.plugins.tool_box.debug_tools import DebugTools + +debug = DebugTools() +logger.success("Debug tools loaded. Use 'debug' to access them.") + # SETUP BEAMLINE INFO from bec_ipython_client.plugins.SLS.sls_info import OperatorInfo, SLSInfo 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 a917022..d0f1c1d 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 @@ -158,7 +158,7 @@ class StatusBitsCompareStatus(SubscriptionStatus): def _compare_callback(self, *args, value, **kwargs) -> bool: """Callback for subscription status""" - logger.info(f"StatusBitsCompareStatus: Received value {value}") + logger.debug(f"StatusBitsCompareStatus: Received value {value}") obj = kwargs.get("obj", None) if obj is None: name = "no object received" @@ -173,7 +173,7 @@ class StatusBitsCompareStatus(SubscriptionStatus): return False if self._add_delay != 0: time.sleep(self._add_delay) - logger.info( + logger.debug( f"Returning comparison for {name}: {(STATUSBITS(value) & self._value) == self._value}" ) return (STATUSBITS(value) & self._value) == self._value diff --git a/tests/tests_devices/test_mcs_card.py b/tests/tests_devices/test_mcs_card.py index 4bfe5ee..7a9ea61 100644 --- a/tests/tests_devices/test_mcs_card.py +++ b/tests/tests_devices/test_mcs_card.py @@ -243,7 +243,6 @@ def test_mcs_card_csaxs_on_counter_updated(mock_mcs_csaxs: MCSCardCSAXS): mcs._omit_mca_callbacks.set() kwargs = {"obj": mcs.counters.mca1} mcs._on_counter_update(1, **kwargs) - assert mcs._mca_counter_index == 1 # Counter index should still increment assert mcs._current_data == {} # II. Callback from 32 mca counters -- 2.49.1 From b48b27114ddbbdc47c8d8f8eef88b79f6a81eefc Mon Sep 17 00:00:00 2001 From: appel_c Date: Fri, 23 Jan 2026 15:16:18 +0100 Subject: [PATCH 8/9] cleanup --- .../plugins/tool_box/debug_tools.py | 53 ++++++++----------- .../devices/epics/mcs_card/mcs_card_csaxs.py | 2 +- 2 files changed, 24 insertions(+), 31 deletions(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/tool_box/debug_tools.py b/csaxs_bec/bec_ipython_client/plugins/tool_box/debug_tools.py index 1a72d19..270078d 100644 --- a/csaxs_bec/bec_ipython_client/plugins/tool_box/debug_tools.py +++ b/csaxs_bec/bec_ipython_client/plugins/tool_box/debug_tools.py @@ -20,17 +20,16 @@ from rich.text import Text from slugify import slugify if TYPE_CHECKING: - from bec_ipython_client.main import BECClient + from bec_ipython_client.main import BECIPythonClient + from bec_lib.devicemanager import DeviceManagerBase from bec_lib.scans import Scans from bec_widgets.cli.client_utils import BECGuiClient scans: Scans # type: ignore[no-redef] - bec: BECClient # type: ignore[no-redef] + bec: BECIPythonClient # type: ignore[no-redef] - gui: BECGuiClient # type: ignore[no-redef] - - dev: bec.device_manager + dev: DeviceManagerBase # type: ignore[no-redef] class Detector(BaseModel): @@ -49,7 +48,7 @@ def to_identifier(text: str) -> str: name = re.sub(r"[^a-zA-Z0-9_]", "", name) if not name: - return "_" + raise ValueError(f"Cannot convert '{text}' to a valid identifier.") if name[0].isdigit(): name = f"_{name}" @@ -60,12 +59,10 @@ def to_identifier(text: str) -> str: class DebugTools: """A collection of debugging tools for the BEC IPython client at cSAXS.""" - _PURPOSE = " ".join( - [ - "Debugging helpers for the cSAXS BEC IPython client. These tools are intended for ", - "advanced users and developers to diagnose and troubleshoot issues within the BEC environment.", - "Below are the available methods together with a brief description of their functionality.", - ] + _PURPOSE = ( + "Debugging helpers for the cSAXS BEC IPython client. These tools are intended for advanced users " + "and developers to diagnose and troubleshoot issues within the BEC environment. " + "Below are the available methods together with a brief description of their functionality." ) ###################### @@ -108,17 +105,12 @@ class DebugTools: ### MCS Card Check ### ##################### - def _check_if_mcs_card_is_loaded(self): - """Check if the MCS card device is loaded in the current BEC session.""" - if "mcs" not in dev: - raise RuntimeError("MCS device is not loaded in the current active BEC session.") - - def _check_if_ddg_is_loaded(self): - """Check if the DDG1 device is loaded in the current BEC session.""" - if "ddg1" not in dev: - raise RuntimeError("DDG1 device is not loaded in the current active BEC session.") - if "ddg2" not in dev: - raise RuntimeError("DDG2 device is not loaded in the current active BEC session.") + def _check_if_device_is_loaded(self, device_name: str): + """Check if a device is loaded in the current BEC session.""" + if device_name not in dev: + raise RuntimeError( + f"Device {device_name} was not loaded in the current active BEC session." + ) def mcs_test_acquire( self, mode: Literal["high_frame", "medium_frame", "low_frame"] = "high_frame" @@ -133,14 +125,15 @@ class DebugTools: - 'medium_frame': Tests medium frame rates with moderate exposure times. - 'low_frame': Tests low frame rates with longer exposure times. """ - self._check_if_mcs_card_is_loaded() - self._check_if_ddg_is_loaded() + self._check_if_device_is_loaded("mcs") + self._check_if_device_is_loaded("ddg1") + self._check_if_device_is_loaded("ddg2") if mode == "high_frame": - burst_frames = np.random.randint(10000, 100000) # between 10000 and 100000 + burst_frames = np.random.randint(10_000, 100_000) # between 10000 and 100000 cycles = np.random.randint(5, 20) # between 5 and 20 exp_time = ( - np.random.rand() * (0.001 - 0.000201) + 0.000201 + np.random.rand() * (0.001 - 0.201e-3) + 0.201e-3 ) # between 0.000201 ms and 0.001 s elif mode == "medium_frame": burst_frames = np.random.randint(50, 500) # between 50 and 500 @@ -153,7 +146,7 @@ class DebugTools: else: raise ValueError(f"Invalid mode '{mode}' specified for acquire scan test.") print( - f"Starting acquire measurement with exp_time={exp_time}, burst_frames={burst_frames}, cycles={cycles}" + f"Starting acquire measurement with exp_time={exp_time:.6f}, burst_frames={burst_frames}, cycles={cycles}" ) s = scans.acquire( exp_time=exp_time, frames_per_trigger=burst_frames, burst_at_each_point=cycles @@ -246,8 +239,8 @@ class DebugTools: return False def open_it_service_page(self): - """Open the overview of IT services hosted by Science IT (Leo Sala) for cSAXS.""" - gui = bec.gui + """Open the overview of IT services hosted by Science IT Infrastructure and Services for cSAXS.""" + gui: BECGuiClient = bec.gui dock_area = gui.new() print("Opening IT service page in new dock...") url = "https://metrics.psi.ch/d/saf8mxv/x12sa?orgId=1&from=now-24h&to=now&timezone=browser&var-receiver_hosts=sls-jfjoch-001.psi.ch&var-writer_hosts=xbl-daq-34.psi.ch&var-beamline=X12SA&var-slurm_partitions=csaxs&var-receiver_services=broker&var-writer_services=writer&refresh=15m" 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 1ff3d08..fdda685 100644 --- a/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py +++ b/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py @@ -233,7 +233,7 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): # Start monitoring thread self._scan_done_thread.start() - def _on_counter_update(self, value, **kwargs) -> None: + def _on_counter_update(self, value: float | np.ndarray, **kwargs) -> None: """ Callback for counter updates of the mca channels (1-32). This callback is attached to each mca channel PV on the MCS card. It collects data from all channels -- 2.49.1 From 6647140d4304eb0256bb253fc17be2993095dee5 Mon Sep 17 00:00:00 2001 From: appel_c Date: Fri, 23 Jan 2026 15:25:52 +0100 Subject: [PATCH 9/9] w --- csaxs_bec/bec_ipython_client/plugins/tool_box/debug_tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/tool_box/debug_tools.py b/csaxs_bec/bec_ipython_client/plugins/tool_box/debug_tools.py index 270078d..6e3589d 100644 --- a/csaxs_bec/bec_ipython_client/plugins/tool_box/debug_tools.py +++ b/csaxs_bec/bec_ipython_client/plugins/tool_box/debug_tools.py @@ -151,7 +151,7 @@ class DebugTools: s = scans.acquire( exp_time=exp_time, frames_per_trigger=burst_frames, burst_at_each_point=cycles ) - s.wait() + s.wait(file_written=True) print("Acquire measurement finished.") print("Checking MCS data...") scan_data = bec.history.get_by_scan_id(s.scan.scan_id) -- 2.49.1