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 5adf4b1..a6eb98b 100644 --- a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py @@ -37,7 +37,9 @@ import traceback from typing import TYPE_CHECKING from bec_lib.logger import bec_logger -from ophyd_devices import CompareStatus, DeviceStatus, TransitionStatus +from ophyd import Component as Cpt +from ophyd import EpicsSignalRO, Kind +from ophyd_devices import CompareStatus, DeviceStatus, StatusBase, TransitionStatus from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase from csaxs_bec.devices.epics.delay_generator_csaxs.delay_generator_csaxs import ( @@ -133,6 +135,24 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): USER_ACCESS = ["keep_shutter_open_during_scan", "set_trigger"] + fast_shutter_readback = Cpt( + EpicsSignalRO, + read_pv="X12SA-ES1-TTL:IN_01", + add_prefix=("",), # Add this to prevent the prefix to be added to the signal + kind=Kind.omitted, + auto_monitor=True, + ) + # The shutter control PV can indicate if the shutter is requested to be kept open. If that + # is the case, we can not use the signal shutter_readback signal to check if the delay cycle + # finishes but have to use the polling of the event status register to check if the burst finished. + fast_shutter_control = Cpt( + EpicsSignalRO, + read_pv="X12SA-ES1-TTL:OUT_01", + add_prefix=("",), # Add this to prevent the prefix to be added to the signal + kind=Kind.omitted, + auto_monitor=True, + ) + def __init__( self, name: str, @@ -195,7 +215,16 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): self.burst_delay.put(0) self.burst_count.put(1) - def keep_shutter_open_during_scan(self, open:True) -> None: + def keep_shutter_open_during_scan(self, open: True) -> None: + """ + Method to configure the delay generator for keeping the shutter open during a scans. + This means that the additional delay to open the shutter needs to be removed (2e-3) + from the timing of the signals. + + Args: + open (bool): If True, the shutter will be kept open during the scan. + If False, the shutter will be opened and closed for each trigger cycle. + """ if open is True: self._shutter_to_open_delay = 0 else: @@ -239,6 +268,18 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): if self.burst_count.get() != 1: self.burst_count.put(1) + ##################################### + ## Setup trigger source if needed ### + ##################################### + + # NOTE Some scans may change the trigger source to an external trigger, + # so we will make sure that the default trigger source is set for the DDG1 + # before each scan. If a scan requires a different trigger source, i.e. + # external triggers then the scan should implement this change after the + # on_stage method was called. + if self.trigger_source.get() != DEFAULT_TRIGGER_SOURCE: + self.set_trigger(DEFAULT_TRIGGER_SOURCE) + ######################################### ### Setup timing for burst and delays ### ######################################### @@ -249,7 +290,9 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): # Burst Period DDG1 # Set burst_period to shutter width # c/t0 + self._shutter_to_open_delay + exp_time * burst_count - shutter_width = self._shutter_to_open_delay + exp_time * frames_per_trigger # Shutter starts closing at end of exposure + shutter_width = ( + self._shutter_to_open_delay + exp_time * frames_per_trigger + ) # Shutter starts closing at end of exposure if self.burst_period.get() != shutter_width: self.burst_period.put(shutter_width) @@ -443,7 +486,13 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): def on_trigger(self) -> DeviceStatus: """ - This method is called from BEC as a software trigger. + This method is called from BEC as a software trigger. Here the logic is as follows: + We check the signal of the "fast_shutter_control". If it is low, we know that the shutter + open/closes for each trigger cycle. In this case, we can use the signal of the + "fast_shutter_readback" to check if the shutter transitioned from open to close, which + indicates the end of the burst. If the "fast_shutter_control" signal is high, we know + that the shutter is kept open during the scan. In this case, we can only rely on polling + the event status register to check if the burst finished. 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: @@ -464,9 +513,13 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): """ self._stop_polling() self._poll_thread_poll_loop_done.wait(timeout=1) + # TODO This may move to scan modifiers + if self.trigger_source.get() != TRIGGERSOURCE.SINGLE_SHOT.value: + status = StatusBase(obj=self) + status.set_finished() + return status # 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 @@ -482,12 +535,23 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): # 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() - - # Start polling thread again to monitor event status - self._start_polling() + if self.fast_shutter_control.get() == 0: + # Shutter is not kept open, we can rely on the shutter readback signal + status = TransitionStatus( + self.fast_shutter_readback, [1, 0] + ) # Wait for shutter to transition from open (1) to close (0) + else: + # NOTE This sleep is needed for 20ms to make sure that the HW of the DDG is + # again ready to process new commands. It was transferred from just after the + # _stop_polling() call, as it should only be relevant in case of polling the + # event status register, which may only be if the shutter is kept open. + time.sleep(0.02) + # Shutter is kept open, we can only rely on the event status register + status = self._prepare_trigger_status_event() + # Start polling thread again to monitor event status + self._start_polling() # Trigger the DDG1 + self.cancel_on_stop(status) self.trigger_shot.put(1, use_complete=True) return status 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 d0f1c1d..4789f5c 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 @@ -488,6 +488,7 @@ class DelayGeneratorCSAXS(Device): name="trigger_source", kind=Kind.omitted, doc="Trigger Source for the DDG, options in TRIGGERSOURCE", + auto_monitor=True, ) trigger_level = 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 f4a7b7c..9ceaf98 100644 --- a/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py +++ b/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py @@ -22,7 +22,13 @@ 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 AsyncMultiSignal, CompareStatus, ProgressSignal, StatusBase, TransitionStatus +from ophyd_devices import ( + AsyncMultiSignal, + CompareStatus, + ProgressSignal, + StatusBase, + TransitionStatus, +) from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase from csaxs_bec.devices.epics.mcs_card.mcs_card import ( @@ -369,7 +375,6 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): elif self.scan_info.msg.scan_type == "fly": self.num_use_all.set(self._num_total_triggers).wait(timeout=self._pv_timeout) - # Clear any previous data, just to be sure with self._rlock: self._current_data.clear() @@ -392,10 +397,19 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): logger.info(f"MCS Card {self.name} on_stage completed in {time.time() - start_time:.3f}s.") # For a fly scan we need to start the mcs card ourselves if self.scan_info.msg.scan_type == "fly": - status_acquiring = TransitionStatus(self.acquiring, [ACQUIRING.DONE, ACQUIRING.ACQUIRING]) - self.cancel_on_stop(status_acquiring) self.erase_start.put(1) + def on_prescan(self) -> None | StatusBase: + """ + This method is called after on_stage and before the scan starts. For the MCS card, we need to make sure + that the card is properly started for fly scans. For step scans, this will be handled by the DDG, + so no action is required here. + """ + if self.scan_info.msg.scan_type == "fly": + status_acquiring = CompareStatus(self.acquiring, ACQUIRING.ACQUIRING) + self.cancel_on_stop(status_acquiring) + return status_acquiring + return None def on_unstage(self) -> None: """ @@ -439,7 +453,7 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): callback(exception=None) else: logger.info(f"Current data index is {self._current_data_index}") - if self._current_data_index >=1: + if self._current_data_index >= 1: for callback in self._scan_done_callbacks: callback(exception=None) @@ -470,7 +484,6 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): """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: @@ -496,7 +509,13 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): monitoring thread is stopped properly. """ + # NOTE For fly scans with EXT/EN enabled triggering, the MCS card needs to receive an + # additional trigger at the end of the scan to advance the channel. This will ensure + # that the acquisition finishes on the card and that data is emitted to BEC. If the acquisition + # was already finished (i.e. normal step scan sends 1 extra pulse per burst cycle), this will + # not have any effect as the card will already be in DONE state and signal. self.software_channel_advance.put(1) + # 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)) @@ -510,7 +529,7 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): # Combine both statuses ret_status = status & status_async_data - # Handle external stop/cancel, and stop monitoring + # NOTE: Handle external stop/cancel, and stop monitoring ret_status.add_callback(self._status_failed_callback) self.cancel_on_stop(ret_status) return ret_status