diff --git a/tomcat_bec/devices/gigafrost/gigafrostcamera.py b/tomcat_bec/devices/gigafrost/gigafrostcamera.py index 3fa4082..8527627 100644 --- a/tomcat_bec/devices/gigafrost/gigafrostcamera.py +++ b/tomcat_bec/devices/gigafrost/gigafrostcamera.py @@ -6,22 +6,17 @@ Created on Thu Jun 27 17:28:43 2024 @author: mohacsi_i """ -from time import sleep +from time import sleep, time import numpy as np from bec_lib.logger import bec_logger from ophyd import DeviceStatus from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase - -from tomcat_bec.devices.gigafrost.gigafrost_base import GigaFrostBase -from tomcat_bec.devices.gigafrost.std_daq_client import ( - StdDaqClient, - StdDaqStatus, -) - import tomcat_bec.devices.gigafrost.gfconstants as const - +from tomcat_bec.devices.gigafrost.gigafrost_base import GigaFrostBase from tomcat_bec.devices.gigafrost.std_daq_preview import StdDaqPreview +from tomcat_bec.devices.gigafrost.std_daq_client import StdDaqClient, StdDaqStatus + logger = bec_logger.logger @@ -62,6 +57,7 @@ class GigaFrostCamera(PSIDeviceBase, GigaFrostBase): # pylint: disable=too-many-instance-attributes USER_ACCESS = [ + "complete", "exposure_mode", "fix_nframes_mode", "trigger_mode", @@ -182,13 +178,13 @@ class GigaFrostCamera(PSIDeviceBase, GigaFrostBase): if d and self.backend is not None: daq_update = {} if "image_height" in d: - daq_update['image_pixel_height'] = d["image_height"] + daq_update["image_pixel_height"] = d["image_height"] if "image_width" in d: - daq_update['image_pixel_width'] = d["image_width"] + daq_update["image_pixel_width"] = d["image_width"] if "bit_depth" in d: - daq_update['bit_depth'] = d["bit_depth"] + daq_update["bit_depth"] = d["bit_depth"] if "number_of_writers" in d: - daq_update['number_of_writers'] = d["number_of_writers"] + daq_update["number_of_writers"] = d["number_of_writers"] if daq_update: self.backend.set_config(daq_update, force=False) @@ -493,28 +489,28 @@ class GigaFrostCamera(PSIDeviceBase, GigaFrostBase): self.backend.shutdown() super().destroy() - def _on_preview_update(self, img:np.ndarray, header: dict): + def _on_preview_update(self, img: np.ndarray, header: dict): """Send preview stream and update frame index counter""" - self.num_images_counter.put(header['frame'], force=True) + self.num_images_counter.put(header["frame"], force=True) self._run_subs(sub_type=self.SUB_DEVICE_MONITOR_2D, obj=self, value=img) - # def acq_done(self) -> DeviceStatus: - # """ - # Check if the acquisition is done. For the GigaFrost camera, this is - # done by checking the status of the backend as the camera does not - # provide any feedback about its internal state. + def acq_done(self) -> DeviceStatus: + """ + Check if the acquisition is done. For the GigaFrost camera, this is + done by checking the status of the backend as the camera does not + provide any feedback about its internal state. - # Returns: - # DeviceStatus: The status of the acquisition - # """ - # status = DeviceStatus(self) - # if self.backend is not None: - # self.backend.add_status_callback( - # status, - # success=[StdDaqStatus.IDLE, StdDaqStatus.FILE_SAVED], - # error=[StdDaqStatus.REJECTED, StdDaqStatus.ERROR], - # ) - # return status + Returns: + DeviceStatus: The status of the acquisition + """ + status = DeviceStatus(self) + if self.backend is not None: + self.backend.add_status_callback( + status, + success=[StdDaqStatus.IDLE, StdDaqStatus.FILE_SAVED], + error=[StdDaqStatus.REJECTED, StdDaqStatus.ERROR], + ) + return status ######################################## # Beamline Specific Implementations # @@ -590,11 +586,11 @@ class GigaFrostCamera(PSIDeviceBase, GigaFrostBase): ) self.num_images.set(num_points).wait() if "daq_file_path" in scan_args and scan_args["daq_file_path"] is not None: - self.file_path.set(scan_args['daq_file_path']).wait() + self.file_path.set(scan_args["daq_file_path"]).wait() if "daq_file_prefix" in scan_args and scan_args["daq_file_prefix"] is not None: - self.file_prefix.set(scan_args['daq_file_prefix']).wait() + self.file_prefix.set(scan_args["daq_file_prefix"]).wait() if "daq_num_images" in scan_args and scan_args["daq_num_images"] is not None: - self.num_images.set(scan_args['daq_num_images']).wait() + self.num_images.set(scan_args["daq_num_images"]).wait() # Start stdDAQ preview if self.live_preview is not None: self.live_preview.start() @@ -632,10 +628,13 @@ class GigaFrostCamera(PSIDeviceBase, GigaFrostBase): and self.trigger_mode == "auto" and self.enable_mode == "soft" ): + t_start = time() # BEC teststand operation mode: posedge of SoftEnable if Started self.soft_enable.set(0).wait() self.soft_enable.set(1).wait() + logger.info(f"Elapsed: {time()-t_start}") + if self.acquire_block.get(): wait_time = 0.2 + 0.001 * self.num_exposures.value * max( self.acquire_time.value, self.acquire_period.value @@ -647,8 +646,7 @@ class GigaFrostCamera(PSIDeviceBase, GigaFrostBase): def on_complete(self) -> DeviceStatus | None: """Called to inquire if a device has completed a scans.""" - # return self.acq_done() - return None + return self.acq_done() def on_kickoff(self) -> DeviceStatus | None: """Called to kickoff a device for a fly scan. Has to be called explicitly.""" @@ -667,6 +665,6 @@ if __name__ == "__main__": auto_soft_enable=True, std_daq_ws="ws://129.129.95.111:8080", std_daq_rest="http://129.129.95.111:5000", - std_daq_live='tcp://129.129.95.111:20000', + std_daq_live="tcp://129.129.95.111:20000", ) gf.wait_for_connection() diff --git a/tomcat_bec/devices/gigafrost/pcoedge_base.py b/tomcat_bec/devices/gigafrost/pcoedge_base.py new file mode 100644 index 0000000..f4e6316 --- /dev/null +++ b/tomcat_bec/devices/gigafrost/pcoedge_base.py @@ -0,0 +1,176 @@ +# -*- coding: utf-8 -*- +""" +Created on Wed Dec 6 11:33:54 2023 + +@author: mohacsi_i +""" +from ophyd import Component as Cpt +from ophyd import Device, DynamicDeviceComponent, EpicsSignal, EpicsSignalRO, Kind, Signal + + +class PcoEdgeBase(Device): + """Ophyd baseclass for Helge camera IOCs + + This class provides wrappers for Helge's camera IOCs around SwissFEL and + for high performance SLS 2.0 cameras. The IOC's operation is a bit arcane + and there are different versions and cameras all around. So this device + only covers the absolute basics. + + Probably the most important part is the configuration state machine. As + the SET_PARAMS takes care of buffer allocations it might take some time, + as well as a full re-configuration is required every time we change the + binning, roi, etc... This is automatically performed upon starting an + exposure (if it heven't been done before). + + The status flag state machine during re-configuration is: + BUSY low, SET low -> BUSY high, SET low -> BUSY low, SET high -> BUSY low, SET low + + + UPDATE: Data sending operation modes + - Switch to ZMQ streaming by setting FILEFORMAT to ZEROMQ + - Set SAVESTART and SAVESTOP to select a ROI of image indices + - Start file transfer with FTRANSFER. + The ZMQ connection operates in PUSH-PULL mode, i.e. it needs incoming connection. + + STOREMODE sets the acquisition mode: + if STOREMODE == Recorder + Fills up the buffer with images. Here SAVESTART and SAVESTOP selects a ROI + of image indices to be streamed out (i.e. maximum buffer_size number of images) + + if STOREMODE == FIFO buffer + Continously streams out data using the buffer as a FIFO queue. + Here SAVESTART and SAVESTOP selects a ROI of image indices to be streamed continously + (i.e. a large SAVESTOP streams indefinitely). Note that in FIFO mode buffer reads are + destructive. to prevent this, we don't have EPICS preview + """ + + # ######################################################################## + # General hardware info (in AD nomenclature) + manufacturer = Cpt(EpicsSignalRO, "QUERY", kind=Kind.config, doc="Camera manufacturer info") + model = Cpt(EpicsSignalRO, "BOARD", kind=Kind.omitted, doc="Camera board info") + + # ######################################################################## + # Acquisition configuration (in AD nomenclature) + acquire = Cpt(EpicsSignal, "CAMERASTATUS", put_complete=True, kind=Kind.omitted) + acquire_time = Cpt( + EpicsSignal, "EXPOSURE", put_complete=True, auto_monitor=True, kind=Kind.config + ) + acquire_delay = Cpt( + EpicsSignal, "DELAY", put_complete=True, auto_monitor=True, kind=Kind.config + ) + trigger_mode = Cpt( + EpicsSignal, "TRIGGER", put_complete=True, auto_monitor=True, kind=Kind.config + ) + # num_exposures = Cpt( + # EpicsSignal, "CNT_NUM", put_complete=True, auto_monitor=True, kind=Kind.config + # ) + + array_size = DynamicDeviceComponent( + { + "array_size_x": (EpicsSignal, "WIDTH", {"auto_monitor": True, "put_complete": True}), + "array_size_y": (EpicsSignal, "HEIGHT", {"auto_monitor": True, "put_complete": True}), + }, + doc="Size of the array in the XY dimensions", + ) + + # DAQ parameters + file_path = Cpt(Signal, kind=Kind.config, value="/gpfs/test/test-beamline") + file_prefix = Cpt(Signal, kind=Kind.config, value="scan_") + num_images = Cpt(Signal, kind=Kind.config, value=1000) + num_images_counter = Cpt(Signal, kind=Kind.hinted, value=0) + + # GF specific interface + acquire_block = Cpt(Signal, kind=Kind.config, value=0) + + # ######################################################################## + # Image size configuration (in AD nomenclature) + bin_x = Cpt(EpicsSignal, "BINX", put_complete=True, auto_monitor=True, kind=Kind.config) + bin_y = Cpt(EpicsSignal, "BINY", put_complete=True, auto_monitor=True, kind=Kind.config) + + # ######################################################################## + # Additional status info + busy = Cpt(EpicsSignalRO, "BUSY", auto_monitor=True, kind=Kind.config) + camState = Cpt(EpicsSignalRO, "SS_CAMERA", auto_monitor=True, kind=Kind.config) + camProgress = Cpt(EpicsSignalRO, "CAMPROGRESS", auto_monitor=True, kind=Kind.config) + + # ######################################################################## + # Configuration state maschine with separate transition states + set_param = Cpt( + EpicsSignal, + "BUSY_SET_PARAM", + write_pv="SET_PARAM", + put_complete=True, + auto_monitor=True, + kind=Kind.config, + ) + + camera_statuscode = Cpt(EpicsSignalRO, "STATUSCODE", auto_monitor=True, kind=Kind.config) + camera_init = Cpt(EpicsSignalRO, "INIT", auto_monitor=True, kind=Kind.config) + camera_init_busy = Cpt(EpicsSignalRO, "BUSY_INIT", auto_monitor=True, kind=Kind.config) + # camCamera = Cpt(EpicsSignalRO, "CAMERA", auto_monitor=True, kind=Kind.config) + # camCameraBusy = Component(EpicsSignalRO, "BUSY_CAMERA", auto_monitor=True, kind=Kind.config) + + # ######################################################################## + # Acquisition configuration + acquire_mode = Cpt(EpicsSignalRO, "ACQMODE", auto_monitor=True, kind=Kind.config) + acquire_trigger = Cpt(EpicsSignalRO, "TRIGGER", auto_monitor=True, kind=Kind.config) + # acqTriggerSource = Component( + # EpicsSignalRO, "TRIGGERSOURCE", auto_monitor=True, kind=Kind.config) + # acqTriggerEdge = Component(EpicsSignalRO, "TRIGGEREDGE", auto_monitor=True, kind=Kind.config) + + # ######################################################################## + # Buffer configuration + bufferRecMode = Cpt(EpicsSignalRO, "RECMODE", auto_monitor=True, kind=Kind.config) + bufferStoreMode = Cpt(EpicsSignal, "STOREMODE", auto_monitor=True, kind=Kind.config) + fileRecMode = Cpt(EpicsSignalRO, "RECMODE", auto_monitor=True, kind=Kind.config) + + buffer_used = Cpt(EpicsSignalRO, "PIC_BUFFER", auto_monitor=True, kind=Kind.normal) + buffer_size = Cpt(EpicsSignalRO, "PIC_MAX", auto_monitor=True, kind=Kind.normal) + buffer_clear = Cpt(EpicsSignal, "CLEARMEM", put_complete=True, kind=Kind.omitted) + + # ######################################################################## + # File saving/streaming interface + cam_data_rate = Cpt(EpicsSignalRO, "CAMRATE", auto_monitor=True, kind=Kind.normal) + file_data_rate = Cpt(EpicsSignalRO, "FILERATE", auto_monitor=True, kind=Kind.normal) + file_savestart = Cpt(EpicsSignal, "SAVESTART", put_complete=True, kind=Kind.config) + file_savestop = Cpt(EpicsSignal, "SAVESTOP", put_complete=True, kind=Kind.config) + file_format = Cpt(EpicsSignal, "FILEFORMAT", put_complete=True, kind=Kind.config) + file_transfer = Cpt(EpicsSignal, "FTRANSFER", put_complete=True, kind=Kind.config) + file_savebusy = Cpt(EpicsSignalRO, "FILESAVEBUSY", auto_monitor=True, kind=Kind.normal) + + # ######################################################################## + # Throtled image preview + image = Cpt(EpicsSignalRO, "FPICTURE", kind=Kind.omitted, doc="Throttled image preview") + + # ######################################################################## + # General hardware info + camError = Cpt(EpicsSignalRO, "ERRCODE", auto_monitor=True, kind=Kind.config) + camWarning = Cpt(EpicsSignalRO, "WARNCODE", auto_monitor=True, kind=Kind.config) + + @property + def state(self) -> str: + """Single word camera state""" + if self.set_param.value: + return "BUSY" + if self.camera_statuscode.value == 2 and self.camera_init.value == 1: + return "IDLE" + if self.camera_statuscode.value == 6 and self.camera_init.value == 1: + return "RUNNING" + # if self.camRemoval.value==0 and self.camInit.value==0: + if self.camera_init.value == 0: + return "OFFLINE" + # if self.camRemoval.value: + # return "REMOVED" + return "UNKNOWN" + + @state.setter + def state(self): + raise RuntimeError("State is a ReadOnly property") + + +# Automatically connect to test camera if directly invoked +if __name__ == "__main__": + + # Drive data collection + cam = PcoEdgeBase("X02DA-CCDCAM2:", name="mcpcam") + cam.wait_for_connection() diff --git a/tomcat_bec/devices/gigafrost/pcoedgecamera.py b/tomcat_bec/devices/gigafrost/pcoedgecamera.py index 0f046a6..6fc54f2 100644 --- a/tomcat_bec/devices/gigafrost/pcoedgecamera.py +++ b/tomcat_bec/devices/gigafrost/pcoedgecamera.py @@ -7,19 +7,16 @@ Created on Wed Dec 6 11:33:54 2023 import time from ophyd import Component, EpicsSignal, EpicsSignalRO, Kind from ophyd.status import SubscriptionStatus, DeviceStatus -from ophyd_devices import BECDeviceBase -from ophyd_devices.interfaces.base_classes.psi_detector_base import ( - CustomDetectorMixin as CustomPrepare, -) +from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase -try: - from bec_lib import bec_logger +from tomcat_bec.devices.gigafrost.pcoedge_base import PcoEdgeBase +from tomcat_bec.devices.gigafrost.std_daq_preview import StdDaqPreview +from tomcat_bec.devices.gigafrost.std_daq_client import StdDaqClient, StdDaqStatus - logger = bec_logger.logger -except ModuleNotFoundError: - import logging - logger = logging.getLogger("PcoEdgeCam") +from bec_lib.logger import bec_logger + +logger = bec_logger.logger class PcoEdgeCameraMixin(CustomPrepare): @@ -27,119 +24,9 @@ class PcoEdgeCameraMixin(CustomPrepare): This class will be called by the custom_prepare_cls attribute of the detector class. """ - # pylint: disable=protected-access - def on_stage(self) -> None: - """Configure and arm PCO.Edge camera for acquisition""" - - # PCO can finish a run without explicit unstaging - if self.parent.state not in ("IDLE"): - logger.warning( - f"Trying to stage the camera from state {self.parent.state}, unstaging it first!" - ) - self.parent.unstage() - time.sleep(0.5) - - # Fish out our configuration from scaninfo (via explicit or generic addressing) - scanparam = self.parent.scaninfo.scan_msg.info - d = {} - if "kwargs" in scanparam: - scanargs = scanparam["kwargs"] - if "exp_burst" in scanargs and scanargs["exp_burst"] is not None: - d["exposure_num_burst"] = scanargs["exp_burst"] - if "image_width" in scanargs and scanargs["image_width"] is not None: - d["image_width"] = scanargs["image_width"] - if "image_height" in scanargs and scanargs["image_height"] is not None: - d["image_height"] = scanargs["image_height"] - if "exp_time" in scanargs and scanargs["exp_time"] is not None: - d["exposure_time_ms"] = scanargs["exp_time"] - if "exp_period" in scanargs and scanargs["exp_period"] is not None: - d["exposure_period_ms"] = scanargs["exp_period"] - # if 'exp_burst' in scanargs and scanargs['exp_burst'] is not None: - # d['exposure_num_burst'] = scanargs['exp_burst'] - # if 'acq_mode' in scanargs and scanargs['acq_mode'] is not None: - # d['acq_mode'] = scanargs['acq_mode'] - # elif self.parent.scaninfo.scan_type == "step": - # d['acq_mode'] = "default" - if "pco_store_mode" in scanargs and scanargs["pco_store_mode"] is not None: - d["store_mode"] = scanargs["pco_store_mode"] - if "pco_data_format" in scanargs and scanargs["pco_data_format"] is not None: - d["data_format"] = scanargs["pco_data_format"] - - # Perform bluesky-style configuration - if len(d) > 0: - logger.warning(f"[{self.parent.name}] Configuring with:\n{d}") - self.parent.configure(d=d) - - # ARM the camera - self.parent.bluestage() - - def on_unstage(self) -> None: - """Disarm the PCO.Edge camera""" - self.parent.blueunstage() - - def on_stop(self) -> None: - """Stop the PCO.Edge camera""" - self.parent.blueunstage() - - def on_trigger(self) -> None | DeviceStatus: - """Trigger mode operation - - Use it to repeatedly record a fixed number of frames and send it to stdDAQ. The method waits - for the acquisition and data transfer to complete. - - NOTE: Maciej confirmed that sparse data is no problem to the stdDAQ. - TODO: Optimize data transfer to launch at end and check completion at the beginning. - """ - # Ensure that previous data transfer finished - # def sentIt(*args, value, timestamp, **kwargs): - # return value==0 - # status = SubscriptionStatus(self.parent.file_savebusy, sentIt, timeout=120) - # status.wait() - - # Not sure if it always sends the first batch of images or the newest - def wait_bufferreset(*, old_value, value, timestamp, **_): - return (value < old_value) or (value == 0) - - self.parent.buffer_clear.set(1).wait() - status = SubscriptionStatus(self.parent.buffer_used, wait_bufferreset, timeout=5) - status.wait() - - t_expected = ( - self.parent.acquire_time.get() + self.parent.acquire_delay.get() - ) * self.parent.file_savestop.get() - - # Wait until the buffer fills up with enough images - def wait_acquisition(*, value, timestamp, **_): - num_target = self.parent.file_savestop.get() - # logger.warning(f"{value} of {num_target}") - return bool(value >= num_target) - max_wait = max(5, 5 * t_expected) - status = SubscriptionStatus( - self.parent.buffer_used, wait_acquisition, timeout=max_wait, settle_time=0.2 - ) - status.wait() - - # Then start file transfer (need to get the save busy flag update) - # self.parent.file_transfer.set(1, settle_time=0.2).wait() - self.parent.file_transfer.set(1).wait() - - # And wait until the images have been sent - # NOTE: this does not wait for new value, the first check will be - # against values from the previous cycle, i.e. pass automatically. - t_start = time.time() - - def wait_sending(*args, old_value, value, timestamp, **kwargs): - t_elapsed = timestamp - t_start - # logger.warning(f"{old_value}\t{value}\t{t_elapsed}") - return old_value == 1 and value == 0 and t_elapsed > 0 - - status = SubscriptionStatus( - self.parent.file_savebusy, wait_sending, timeout=120, settle_time=0.2 - ) - status.wait() -class HelgeCameraBase(BECDeviceBase): +class PcoEdge5M(PSIDeviceBase, PcoEdgeBase): """Ophyd baseclass for Helge camera IOCs This class provides wrappers for Helge's camera IOCs around SwissFEL and @@ -175,105 +62,57 @@ class HelgeCameraBase(BECDeviceBase): destructive. to prevent this, we don't have EPICS preview """ - # ######################################################################## - # General hardware info (in AD nomenclature) - manufacturer = Component(EpicsSignalRO, "QUERY", kind=Kind.config, doc="Camera model info") - model = Component(EpicsSignalRO, "BOARD", kind=Kind.omitted, doc="Camera board info") + # pylint: disable=too-many-instance-attributes + USER_ACCESS = [ + "complete", + "backend", + # "acq_done", + "live_preview", + "arm", + "disarm", + ] - # ######################################################################## - # Acquisition commands - camStatusCmd = Component(EpicsSignal, "CAMERASTATUS", put_complete=True, kind=Kind.config) + # Placeholders for stdDAQ and livestream clients + backend = None + live_preview = None - # ######################################################################## - # Acquisition configuration (in AD nomenclature) - acquire_time = Component( - EpicsSignal, "EXPOSURE", put_complete=True, auto_monitor=True, kind=Kind.config - ) - acquire_delay = Component( - EpicsSignal, "DELAY", put_complete=True, auto_monitor=True, kind=Kind.config - ) - trigger_mode = Component( - EpicsSignal, "TRIGGER", put_complete=True, auto_monitor=True, kind=Kind.config - ) - - # ######################################################################## - # Image size configuration (in AD nomenclature) - bin_x = Component(EpicsSignal, "BINX", put_complete=True, auto_monitor=True, kind=Kind.config) - bin_y = Component(EpicsSignal, "BINY", put_complete=True, auto_monitor=True, kind=Kind.config) - array_size_x = Component( - EpicsSignalRO, "WIDTH", auto_monitor=True, kind=Kind.config, doc="Final image width" - ) - array_size_y = Component( - EpicsSignalRO, "HEIGHT", auto_monitor=True, kind=Kind.config, doc="Final image height" - ) - - # ######################################################################## - # General hardware info - camError = Component(EpicsSignalRO, "ERRCODE", auto_monitor=True, kind=Kind.config) - camWarning = Component(EpicsSignalRO, "WARNCODE", auto_monitor=True, kind=Kind.config) - - # ######################################################################## - # Buffer configuration - bufferRecMode = Component(EpicsSignalRO, "RECMODE", auto_monitor=True, kind=Kind.config) - bufferStoreMode = Component(EpicsSignal, "STOREMODE", auto_monitor=True, kind=Kind.config) - fileRecMode = Component(EpicsSignalRO, "RECMODE", auto_monitor=True, kind=Kind.config) - - buffer_used = Component(EpicsSignalRO, "PIC_BUFFER", auto_monitor=True, kind=Kind.normal) - buffer_size = Component(EpicsSignalRO, "PIC_MAX", auto_monitor=True, kind=Kind.normal) - buffer_clear = Component(EpicsSignal, "CLEARMEM", put_complete=True, kind=Kind.omitted) - - # ######################################################################## - # File saving interface - cam_data_rate = Component(EpicsSignalRO, "CAMRATE", auto_monitor=True, kind=Kind.normal) - file_data_rate = Component(EpicsSignalRO, "FILERATE", auto_monitor=True, kind=Kind.normal) - file_savestart = Component(EpicsSignal, "SAVESTART", put_complete=True, kind=Kind.config) - file_savestop = Component(EpicsSignal, "SAVESTOP", put_complete=True, kind=Kind.config) - file_format = Component(EpicsSignal, "FILEFORMAT", put_complete=True, kind=Kind.config) - file_transfer = Component(EpicsSignal, "FTRANSFER", put_complete=True, kind=Kind.config) - file_savebusy = Component(EpicsSignalRO, "FILESAVEBUSY", auto_monitor=True, kind=Kind.normal) - - # ######################################################################## - # Configuration state maschine with separate transition states - camStatusCode = Component(EpicsSignalRO, "STATUSCODE", auto_monitor=True, kind=Kind.config) - camSetParam = Component(EpicsSignal, "SET_PARAM", auto_monitor=True, kind=Kind.config) - camSetParamBusy = Component( - EpicsSignalRO, "BUSY_SET_PARAM", auto_monitor=True, kind=Kind.config - ) - camCamera = Component(EpicsSignalRO, "CAMERA", auto_monitor=True, kind=Kind.config) - camCameraBusy = Component(EpicsSignalRO, "BUSY_CAMERA", auto_monitor=True, kind=Kind.config) - camInit = Component(EpicsSignalRO, "INIT", auto_monitor=True, kind=Kind.config) - camInitBusy = Component(EpicsSignalRO, "BUSY_INIT", auto_monitor=True, kind=Kind.config) - - # ######################################################################## - # Throtled image preview - image = Component(EpicsSignalRO, "FPICTURE", kind=Kind.omitted, doc="Throttled image preview") - - # ######################################################################## - # Misc PVs - # camRemoval = Component(EpicsSignalRO, "REMOVAL", auto_monitor=True, kind=Kind.config) - camStateString = Component( - EpicsSignalRO, "SS_CAMERA", string=True, auto_monitor=True, kind=Kind.config - ) - - @property - def state(self) -> str: - """Single word camera state""" - if self.camSetParamBusy.value: - return "BUSY" - if self.camStatusCode.value == 2 and self.camInit.value == 1: - return "IDLE" - if self.camStatusCode.value == 6 and self.camInit.value == 1: - return "RUNNING" - # if self.camRemoval.value==0 and self.camInit.value==0: - if self.camInit.value == 0: - return "OFFLINE" - # if self.camRemoval.value: - # return "REMOVED" - return "UNKNOWN" - - @state.setter - def state(self): - raise RuntimeError("State is a ReadOnly property") + def __init__( + self, + prefix="", + *, + name, + kind=None, + read_attrs=None, + configuration_attrs=None, + parent=None, + scan_info=None, + std_daq_rest: str | None = None, + std_daq_ws: str | None = None, + std_daq_live: str | None = None, + **kwargs, + ): + # super() will call the mixin class + super().__init__( + prefix=prefix, + name=name, + kind=kind, + read_attrs=read_attrs, + configuration_attrs=configuration_attrs, + parent=parent, + scan_info=scan_info, + **kwargs, + ) + # Configure the stdDAQ client + if std_daq_rest is None or std_daq_ws is None: + # raise ValueError("Both std_daq_rest and std_daq_ws must be provided") + logger.error("No stdDAQ address provided, launching without data backend!") + else: + self.backend = StdDaqClient(parent=self, ws_url=std_daq_ws, rest_url=std_daq_rest) + # Configure image preview + if std_daq_live is not None: + self.live_preview = StdDaqPreview(url=std_daq_live, cb=self._on_preview_update) + else: + logger.error("No stdDAQ stream address provided, launching without preview!") def configure(self, d: dict = {}) -> tuple: """Configure the base Helge camera device @@ -308,6 +147,10 @@ class HelgeCameraBase(BECDeviceBase): self.acquire_delay.set(d["exposure_period_ms"]).wait() if "exposure_period_ms" in d: self.acquire_delay.set(d["exposure_period_ms"]).wait() + if "image_width" in d: + self.array_size.array_size_x.set(d["image_width"]).wait() + if "image_height" in d: + self.array_size.array_size_y.set(d["image_height"]).wait() if "store_mode" in d: self.bufferStoreMode.set(d["store_mode"]).wait() if "data_format" in d: @@ -320,16 +163,16 @@ class HelgeCameraBase(BECDeviceBase): # 2. BUSY goes low, SET goes high # 3. BUSY stays low, SET goes low # So we need a 'negedge' on SET_PARAM - self.camSetParam.set(1).wait() - def negedge(*, old_value, value, timestamp, **_): return bool(old_value and not value) # Subscribe and wait for update - status = SubscriptionStatus(self.camSetParam, negedge, timeout=5, settle_time=0.5) + status = SubscriptionStatus(self.set_param, negedge, timeout=5, settle_time=0.5) + + self.set_param.set(1).wait() status.wait() - def bluestage(self): + def arm(self): """Bluesky style stage: arm the detector""" logger.warning("Staging PCO") # Acquisition is only allowed when the IOC is not busy @@ -354,7 +197,7 @@ class HelgeCameraBase(BECDeviceBase): status = SubscriptionStatus(self.camStatusCode, is_running, timeout=5, settle_time=0.2) status.wait() - def blueunstage(self): + def disarm(self): """Bluesky style unstage: stop the detector""" self.camStatusCmd.set("Idle").wait() @@ -362,107 +205,190 @@ class HelgeCameraBase(BECDeviceBase): # FIXME: This might interrupt data transfer self.file_savestop.set(0).wait() - def bluekickoff(self): + def destroy(self): + if self.backend is not None: + self.backend.shutdown() + super().destroy() + + def _on_preview_update(self, img: np.ndarray, header: dict): + """Send preview stream and update frame index counter""" + self.num_images_counter.put(header["frame"], force=True) + self._run_subs(sub_type=self.SUB_DEVICE_MONITOR_2D, obj=self, value=img) + + def acq_done(self) -> DeviceStatus: + """ + Check if the acquisition is done. For the GigaFrost camera, this is + done by checking the status of the backend as the camera does not + provide any feedback about its internal state. + + Returns: + DeviceStatus: The status of the acquisition + """ + status = DeviceStatus(self) + if self.backend is not None: + self.backend.add_status_callback( + status, + success=[StdDaqStatus.IDLE, StdDaqStatus.FILE_SAVED], + error=[StdDaqStatus.REJECTED, StdDaqStatus.ERROR], + ) + return status + + ######################################## + # Beamline Specific Implementations # + ######################################## + + # pylint: disable=protected-access + def on_stage(self) -> None: + """Configure and arm PCO.Edge camera for acquisition""" + + # PCO can finish a run without explicit unstaging + if self.state not in ("IDLE"): + logger.warning( + f"Trying to stage the camera from state {self.state}, unstaging it first!" + ) + self.unstage() + time.sleep(0.5) + + # Fish out our configuration from scaninfo (via explicit or generic addressing) + scan_args = { + **self.scan_info.msg.request_inputs["inputs"], + **self.scan_info.msg.request_inputs["kwargs"], + **self.scan_info.msg.scan_parameters, + } + + d = {} + if "exp_burst" in scan_args and scan_args["exp_burst"] is not None: + d["exposure_num_burst"] = scan_args["exp_burst"] + if "image_width" in scan_args and scan_args["image_width"] is not None: + d["image_width"] = scan_args["image_width"] + if "image_height" in scan_args and scan_args["image_height"] is not None: + d["image_height"] = scan_args["image_height"] + if "exp_time" in scan_args and scan_args["exp_time"] is not None: + d["exposure_time_ms"] = scan_args["exp_time"] + if "exp_period" in scan_args and scan_args["exp_period"] is not None: + d["exposure_period_ms"] = scan_args["exp_period"] + # if 'exp_burst' in scan_args and scan_args['exp_burst'] is not None: + # d['exposure_num_burst'] = scan_args['exp_burst'] + # if 'acq_mode' in scan_args and scan_args['acq_mode'] is not None: + # d['acq_mode'] = scan_args['acq_mode'] + # elif self.scaninfo.scan_type == "step": + # d['acq_mode'] = "default" + if "pco_store_mode" in scan_args and scan_args["pco_store_mode"] is not None: + d["store_mode"] = scan_args["pco_store_mode"] + if "pco_data_format" in scan_args and scan_args["pco_data_format"] is not None: + d["data_format"] = scan_args["pco_data_format"] + + # Perform bluesky-style configuration + if d: + logger.warning(f"[{self.name}] Configuring with:\n{d}") + self.configure(d=d) + + # stdDAQ backend parameters + num_points = ( + 1 + * scan_args.get("steps", 1) + * scan_args.get("exp_burst", 1) + * scan_args.get("repeats", 1) + * scan_args.get("burst_at_each_point", 1) + ) + self.num_images.set(num_points).wait() + if "daq_file_path" in scan_args and scan_args["daq_file_path"] is not None: + self.file_path.set(scan_args["daq_file_path"]).wait() + if "daq_file_prefix" in scan_args and scan_args["daq_file_prefix"] is not None: + self.file_prefix.set(scan_args["daq_file_prefix"]).wait() + if "daq_num_images" in scan_args and scan_args["daq_num_images"] is not None: + self.num_images.set(scan_args["daq_num_images"]).wait() + # Start stdDAQ preview + if self.live_preview is not None: + self.live_preview.start() + + def on_unstage(self) -> None: + """Disarm the PCO.Edge camera""" + self.disarm() + if self.backend is not None: + logger.info(f"StdDaq status before unstage: {self.backend.status}") + self.backend.stop() + + def on_pre_scan(self) -> DeviceStatus | None: + """Called right before the scan starts on all devices automatically.""" + # First start the stdDAQ + if self.backend is not None: + self.backend.start( + file_path=self.file_path.get(), + file_prefix=self.file_prefix.get(), + num_images=self.num_images.get(), + ) + # Then start the camera + self.arm() + + def on_trigger(self) -> None | DeviceStatus: + """Trigger mode operation + + Use it to repeatedly record a fixed number of frames and send it to stdDAQ. The method waits + for the acquisition and data transfer to complete. + + NOTE: Maciej confirmed that sparse data is no problem to the stdDAQ. + TODO: Optimize data transfer to launch at end and check completion at the beginning. + """ + # Ensure that previous data transfer finished + # def sentIt(*args, value, timestamp, **kwargs): + # return value==0 + # status = SubscriptionStatus(self.file_savebusy, sentIt, timeout=120) + # status.wait() + + # Not sure if it always sends the first batch of images or the newest + def wait_bufferreset(*, old_value, value, timestamp, **_): + return (value < old_value) or (value == 0) + + self.buffer_clear.set(1).wait() + status = SubscriptionStatus(self.buffer_used, wait_bufferreset, timeout=5) + status.wait() + + t_expected = (self.acquire_time.get() + self.acquire_delay.get()) * self.file_savestop.get() + + # Wait until the buffer fills up with enough images + def wait_acquisition(*, value, timestamp, **_): + num_target = self.file_savestop.get() + # logger.warning(f"{value} of {num_target}") + return bool(value >= num_target) + + max_wait = max(5, 5 * t_expected) + status = SubscriptionStatus( + self.buffer_used, wait_acquisition, timeout=max_wait, settle_time=0.2 + ) + status.wait() + + # Then start file transfer (need to get the save busy flag update) + # self.file_transfer.set(1, settle_time=0.2).wait() + self.file_transfer.set(1).wait() + + # And wait until the images have been sent + # NOTE: this does not wait for new value, the first check will be + # against values from the previous cycle, i.e. pass automatically. + t_start = time.time() + + def wait_sending(*, old_value, value, timestamp, **kwargs): + t_elapsed = timestamp - t_start + # logger.warning(f"{old_value}\t{value}\t{t_elapsed}") + return old_value == 1 and value == 0 and t_elapsed > 0 + + status = SubscriptionStatus(self.file_savebusy, wait_sending, timeout=120, settle_time=0.2) + status.wait() + + def on_complete(self) -> DeviceStatus | None: + """Called to inquire if a device has completed a scans.""" + return self.acq_done() + + def on_kickoff(self) -> DeviceStatus | None: """Start data transfer TODO: Need to revisit this once triggering is complete """ self.file_transfer.set(1).wait() - -class PcoEdge5M(HelgeCameraBase): - """Ophyd baseclass for PCO.Edge cameras - - This class provides wrappers for Helge's camera IOCs around SwissFEL and - for high performance SLS 2.0 cameras. Theese are mostly PCO cameras running - on a special Windows IOC host with lots of RAM and CPU power. - """ - - custom_prepare_cls = PcoEdgeCameraMixin - USER_ACCESS = ["bluestage", "blueunstage", "bluekickoff"] - - # ######################################################################## - # Additional status info - busy = Component(EpicsSignalRO, "BUSY", auto_monitor=True, kind=Kind.config) - camState = Component(EpicsSignalRO, "SS_CAMERA", auto_monitor=True, kind=Kind.config) - camProgress = Component(EpicsSignalRO, "CAMPROGRESS", auto_monitor=True, kind=Kind.config) - camRate = Component(EpicsSignalRO, "CAMRATE", auto_monitor=True, kind=Kind.config) - - # ######################################################################## - # Acquisition configuration - acqMode = Component(EpicsSignalRO, "ACQMODE", auto_monitor=True, kind=Kind.config) - acqDelay = Component(EpicsSignalRO, "DELAY", auto_monitor=True, kind=Kind.config) - acqTriggerEna = Component(EpicsSignalRO, "TRIGGER", auto_monitor=True, kind=Kind.config) - # acqTriggerSource = Component( - # EpicsSignalRO, "TRIGGERSOURCE", auto_monitor=True, kind=Kind.config) - # acqTriggerEdge = Component(EpicsSignalRO, "TRIGGEREDGE", auto_monitor=True, kind=Kind.config) - - # ######################################################################## - # Image size settings - # Priority is: binning -> roi -> final size - pxRoiX_lo = Component( - EpicsSignal, "REGIONX_START", put_complete=True, auto_monitor=True, kind=Kind.config - ) - pxRoiX_hi = Component( - EpicsSignal, "REGIONX_END", put_complete=True, auto_monitor=True, kind=Kind.config - ) - pxRoiY_lo = Component( - EpicsSignal, "REGIONY_START", put_complete=True, auto_monitor=True, kind=Kind.config - ) - pxRoiY_hi = Component( - EpicsSignal, "REGIONY_END", put_complete=True, auto_monitor=True, kind=Kind.config - ) - - def configure(self, d: dict = {}) -> tuple: - """ - Camera configuration instructions: - After setting the corresponding PVs, one needs to process SET_PARAM and wait until - BUSY_SET_PARAM goes high and low, followed by SET_PARAM goes high and low. This will - both send the settings to the camera and allocate the necessary buffers in the correct - size and shape (that takes time). Starting the exposure with CAMERASTATUS will also - call SET_PARAM, but it might take long. - - NOTE: - The camera IOC will automatically round up RoiX coordinates to the - next multiple of 160. This means that configure can only change image - width in steps of 320 pixels (or manually of 160). Roi - - Parameters as 'd' dictionary - ---------------------------- - exposure_time_ms : float, optional - Exposure time [ms]. - exposure_period_ms : float, optional - Exposure period [ms], ignored in soft trigger mode. - image_width : int, optional - ROI size in the x-direction, multiple of 320 [pixels] - image_height : int, optional - ROI size in the y-direction, multiple of 2 [pixels] - image_binx : int optional - Binning along image width [pixels] - image_biny: int, optional - Binning along image height [pixels] - acq_mode : str, not yet implemented - Select one of the pre-configured trigger behavior - """ - if d is not None: - # Need to be smart how we set the ROI.... - # Image sensor is 2560x2160 (X and Y) - # Values are rounded to multiples of 16 - if "image_width" in d and d["image_width"] is not None: - width = d["image_width"] - self.pxRoiX_lo.set(2560 / 2 - width / 2).wait() - self.pxRoiX_hi.set(2560 / 2 + width / 2).wait() - if "image_height" in d and d["image_height"] is not None: - height = d["image_height"] - self.pxRoiY_lo.set(2160 / 2 - height / 2).wait() - self.pxRoiY_hi.set(2160 / 2 + height / 2).wait() - if "image_binx" in d and d["image_binx"] is not None: - self.bin_x.set(d["image_binx"]).wait() - if "image_biny" in d and d["image_biny"] is not None: - self.bin_y.set(d["image_biny"]).wait() - - # Call super() to commit the changes - super().configure(d) + def on_stop(self) -> None: + """Called when the device is stopped.""" + return self.on_unstage() # Automatically connect to test camera if directly invoked diff --git a/tomcat_bec/devices/gigafrost/std_daq_client.py b/tomcat_bec/devices/gigafrost/std_daq_client.py index d72a502..9d7ca5e 100644 --- a/tomcat_bec/devices/gigafrost/std_daq_client.py +++ b/tomcat_bec/devices/gigafrost/std_daq_client.py @@ -306,10 +306,10 @@ class StdDaqClient: msg_timestamp = time.time() except TimeoutError: continue - except WebSocketException: - content = traceback.format_exc() - # TODO: this is expected to happen on every reconfiguration - logger.warning(f"Websocket connection closed unexpectedly: {content}") + except WebSocketException as ex: + # content = traceback.format_exc() + # TODO: ConnectionCloserError is expected to happen on every reconfiguration + logger.warning(f"Websocket connection closed unexpectedly: {ex}") self.wait_for_connection() continue msg = json.loads(msg)