From 0a1e11ed4f5c5d34797dab3232e2709cec758140 Mon Sep 17 00:00:00 2001 From: gac-x05la Date: Thu, 30 Jan 2025 17:19:43 +0100 Subject: [PATCH] Work on Helge cameras --- .../device_configs/microxas_test_bed.yaml | 13 + tomcat_bec/devices/gigafrost/helgecamera.py | 358 +++++++----------- 2 files changed, 157 insertions(+), 214 deletions(-) diff --git a/tomcat_bec/device_configs/microxas_test_bed.yaml b/tomcat_bec/device_configs/microxas_test_bed.yaml index a759856..d9fe00f 100644 --- a/tomcat_bec/device_configs/microxas_test_bed.yaml +++ b/tomcat_bec/device_configs/microxas_test_bed.yaml @@ -185,3 +185,16 @@ daq_stream1: readOnly: false readoutPriority: monitored softwareTrigger: false + +pco_stream0: + description: Raw camera stream from PCO.edge + deviceClass: tomcat_bec.devices.StdDaqPreviewDetector + deviceConfig: + url: 'tcp://129.129.106.124:8080' + deviceTags: + - std-daq + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: async + softwareTrigger: false diff --git a/tomcat_bec/devices/gigafrost/helgecamera.py b/tomcat_bec/devices/gigafrost/helgecamera.py index 28fd8e3..3f9751a 100644 --- a/tomcat_bec/devices/gigafrost/helgecamera.py +++ b/tomcat_bec/devices/gigafrost/helgecamera.py @@ -14,105 +14,71 @@ import time from ophyd_devices.interfaces.base_classes.psi_detector_base import PSIDetectorBase as PSIDeviceBase from ophyd_devices.interfaces.base_classes.psi_detector_base import CustomDetectorMixin as CustomDeviceMixin +try: + from bec_lib import bec_logger + logger = bec_logger.logger +except ModuleNotFoundError: + import logging + logger = logging.getLogger("PcoEdgeCam") -class HelgeCameraMixin(CustomDeviceMixin): + +class PcoEdgeCameraMixin(CustomDeviceMixin): """Mixin class to setup the Helge camera bae class. This class will be called by the custom_prepare_cls attribute of the detector class. """ - - def __init__(self, *_args, parent: Device = None, **_kwargs) -> None: - super().__init__(*_args, parent=parent, **_kwargs) - self.monitor_thread = None - self.stop_monitor = False - self.update_frequency = 1 - - def set_exposure_time(self, exposure_time: float) -> None: - """Set the detector framerate. - - Args: - exposure_time (float): Desired exposure time in [sec] - """ - if exposure_time is not None: - self.parent.acqExpTime.set(exposure_time).wait() - - def prepare_detector_backend(self) -> None: - pass - - def prepare_detector(self) -> None: - """Prepare detector for acquisition. - - State machine: - BUSY and SET both low -> BUSY high, SET low -> BUSY low, SET high -> BUSY low, SET low + def on_stage(self) -> None: + """Configure and arm PCO.Edge camera for acquisition """ - self.parent.camSetParam.set(1).wait() - def risingEdge(*args, old_value, value, timestamp, **kwargs): - return bool(not old_value and value) - def fallingEdge(*args, old_value, value, timestamp, **kwargs): - return bool(old_value and not value) - # Subscribe and wait for update - status = SubscriptionStatus(self.parent.camSetParam, fallingEdge, settle_time=0.5) - status.wait() + # Gigafrost can finish a run without explicit unstaging + if self.parent.infoBusyFlag.value: + logger.warning("Camera is already running, unstaging it first!") + self.parent.unstage() + sleep(0.5) + # Fish out our configuration from scaninfo (via explicit or generic addressing) + scanparam = self.parent.scaninfo.scan_msg.info + alias = self.parent.parent.name if self.parent.parent is not None else self.parent.name + # logger.warning(f"[{alias}] Scan parameters:\n{scanparam}") + d = {} + if 'kwargs' in scanparam: + scanargs = scanparam['kwargs'] + if 'image_width' in scanargs and scanargs['image_width']!=None: + d['image_width'] = scanargs['image_width'] + if 'image_height' in scanargs and scanargs['image_height']!=None: + d['image_height'] = scanargs['image_height'] + if 'exp_time' in scanargs and scanargs['exp_time']!=None: + d['exposure_time_ms'] = scanargs['exp_time'] + if 'exp_period' in scanargs and scanargs['exp_period']!=None: + d['exposure_period_ms'] = scanargs['exp_period'] + # if 'exp_burst' in scanargs and scanargs['exp_burst']!=None: + # d['exposure_num_burst'] = scanargs['exp_burst'] + # if 'acq_mode' in scanargs and scanargs['acq_mode']!=None: + # d['acq_mode'] = scanargs['acq_mode'] + # elif self.parent.scaninfo.scan_type == "step": + # d['acq_mode'] = "default" - def arm_acquisition(self) -> None: - """Arm camera for acquisition""" + # Perform bluesky-style configuration + if len(d) > 0: + logger.warning(f"[{self.parent.name}] Configuring with:\n{d}") + self.parent.configure(d=d) - # Acquisition is only allowed when the IOC is not busy - if self.parent.state in ("OFFLINE", "BUSY", "REMOVED", "RUNNING"): - raise RuntimeError(f"Camera in in state: {self.parent.state}") + # ARM the camera + self.parent.bluestage() - # Start the acquisition (this sets parameers and starts acquisition) - self.parent.camStatusCmd.set("Running").wait() - - # Subscribe and wait for update - def isRunning(*args, old_value, value, timestamp, **kwargs): - return bool(self.parent.state=="RUNNING") - status = SubscriptionStatus(self.parent.camStatusCode, isRunning, settle_time=0.2) - status.wait() - - def stop_detector(self) -> None: - self.camStatusCmd.set("Idle").wait() - - - # Subscribe and wait for update - def isIdle(*args, old_value, value, timestamp, **kwargs): - return bool(value==2) - status = SubscriptionStatus(self.parent.camStatusCode, isIdle, settle_time=0.5) - status.wait() - - def send_data(self) -> None: - """Send data to monitor endpoint in redis.""" - try: - img = self.parent.image.get() - # pylint: disable=protected-access - self.parent._run_subs(sub_type=self.parent.SUB_VALUE, value=img) - except Exception as e: - logger.debug(f"{e} for image with shape {self.parent.image.get().shape}") - - def monitor_loop(self) -> None: + def on_unstage(self) -> None: + """Disarm the PCO.Edge camera """ - Monitor the detector status and send data. + self.parent.blueunstage() + + def on_stop(self) -> None: + """Stop the PCO.Edge camera """ - while True: - self.send_data() - time.sleep(1 / self.update_frequency) - if self.parent.state != "RUNNING": - break - if self.stop_monitor: - break - - def run_monitor(self) -> None: - """ - Run the monitor loop in a separate thread. - """ - self.monitor_thread = threading.Thread(target=self.monitor_loop, daemon=True) - self.monitor_thread.start() + self.parent.blueunstage() - -class HelgeCameraCore(PSIDeviceBase): +class HelgeCameraBase(PSIDeviceBase): """Ophyd baseclass for Helge camera IOCs This class provides wrappers for Helge's camera IOCs around SwissFEL and @@ -122,33 +88,42 @@ class HelgeCameraCore(PSIDeviceBase): 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 afull re-configuration is required every time we change the + 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). + 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 - """ # Specify Mixin class custom_prepare_cls = HelgeCameraMixin - - USER_ACCESS = ["kickoff"] # ######################################################################## - # General hardware info - camType = Component(EpicsSignalRO, "QUERY", kind=Kind.omitted) - camBoard = Component(EpicsSignalRO, "BOARD", kind=Kind.config) - camError = Component(EpicsSignalRO, "ERRCODE", auto_monitor=True, kind=Kind.config) - camWarning = Component(EpicsSignalRO, "WARNCODE", auto_monitor=True, kind=Kind.config) - + # 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") + # ######################################################################## # Acquisition commands camStatusCmd = Component(EpicsSignal, "CAMERASTATUS", put_complete=True, kind=Kind.config) # ######################################################################## - # Acquisition configuration - acqExpTime = Component(EpicsSignalRO, "EXPOSURE", auto_monitor=True, kind=Kind.config) + # 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) # ######################################################################## # Configuration state maschine with separate transition states @@ -162,7 +137,7 @@ class HelgeCameraCore(PSIDeviceBase): # ######################################################################## # Throtled image preview - image = Component(EpicsSignalRO, "FPICTURE", kind=Kind.omitted) + image = Component(EpicsSignalRO, "FPICTURE", kind=Kind.omitted, doc="Throttled image preview") # ######################################################################## # Misc PVs @@ -190,57 +165,78 @@ class HelgeCameraCore(PSIDeviceBase): raise ReadOnlyError("State is a ReadOnly property") def configure(self, d: dict = {}) -> tuple: + """ Configure the base Helge camera device""" if self.state in ["OFFLINE", "REMOVED", "RUNNING"]: raise RuntimeError(f"Can't change configuration from state {self.state}") - def stage(self) -> list[object]: - """ Start acquisition""" - self.custom_prepare.arm_acquisition() - return super().stage() + # Stop acquisition + self.unstage() + if not self._initialized: + pass + # If Bluesky style configure + if d is not None: + # Commonly changed settings + if 'exposure_time_ms' in d: + self.acquire_time.set(d['exposure_time_ms']).wait() + if 'exposure_period_ms' in d: + # acquire_time = d['exposure_time_ms'] if 'exposure_time_ms' in d else self.acquire_time.get() + self.acquire_delay.set(d['exposure_period_ms']).wait() + + # State machine + # Initial: BUSY and SET both low + # 0. Write 1 to SET_PARAM + # 1. BUSY goes high, SET stays low + # 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 fallingEdge(*args, old_value, value, timestamp, **kwargs): + return bool(old_value and not value) + # Subscribe and wait for update + status = SubscriptionStatus(self.camSetParam, fallingEdge, timeout=5, settle_time=0.5) + status.wait() - def kickoff(self) -> DeviceStatus: - """ Start acquisition""" - return self.stage() + def bluestage(self): + """Bluesky style stage: arm the detector + """ + # Acquisition is only allowed when the IOC is not busy + if self.state in ("OFFLINE", "BUSY", "REMOVED", "RUNNING"): + raise RuntimeError(f"Camera in in state: {self.state}") - def stop(self): - """ Stop the running acquisition """ + # Start the acquisition (this sets parameers and starts acquisition) + self.camStatusCmd.set("Running").wait() + + # Subscribe and wait for update + def isRunning(*args, old_value, value, timestamp, **kwargs): + return bool(self.state=="RUNNING") + status = SubscriptionStatus(self.camStatusCode, isRunning, timeout=5, settle_time=0.2) + status.wait() + + def blueunstage(self): + """Bluesky style unstage: stop the detector + """ self.camStatusCmd.set("Idle").wait() self.custom_prepare.stop_monitor = True - return super().unstage() - def unstage(self): - """ Stop the running acquisition and unstage the device""" - self.camStatusCmd.set("Idle").wait() - self.custom_prepare.stop_monitor = True - return super().unstage() + # Subscribe and wait for update + def isIdle(*args, old_value, value, timestamp, **kwargs): + return bool(value==2) + status = SubscriptionStatus(self.parent.camStatusCode, isIdle, timeout=5, settle_time=0.2) + status.wait() - - - -class HelgeCameraBase(HelgeCameraCore): - """Ophyd baseclass for Helge camera IOCs +class PcoEdgeBase(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. - - The IOC's operation is a bit arcane, and is documented on the "read the code" - level. However the most important part is the state machine of 7+1 PV signals: - INIT - BUSY_INIT - SET_PARAM - BUSY_SET_PARAM - CAMERA - BUSY_CAMERA - CAMERASTATUSCODE - CAMERASTATUS """ - + custom_prepare_cls = PcoEdgeCameraMixin + USER_ACCESS = ["bluestage", "blueunstage"] - USER_ACCESS = ["describe", "shape", "bin", "roi"] # ######################################################################## # Additional status info busy = Component(EpicsSignalRO, "BUSY", auto_monitor=True, kind=Kind.config) @@ -259,15 +255,10 @@ class HelgeCameraBase(HelgeCameraCore): # ######################################################################## # Image size settings # Priority is: binning -> roi -> final size - pxBinX = Component(EpicsSignal, "BINX", put_complete=True, auto_monitor=True, kind=Kind.config) - pxBinY = Component(EpicsSignal, "BINY", put_complete=True, auto_monitor=True, kind=Kind.config) 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) - pxNumX = Component(EpicsSignalRO, "WIDTH", auto_monitor=True, kind=Kind.config) - pxNumY = Component(EpicsSignalRO, "HEIGHT", auto_monitor=True, kind=Kind.config) - # ######################################################################## # Buffer configuration @@ -275,16 +266,6 @@ class HelgeCameraBase(HelgeCameraCore): bufferStoreMode = Component(EpicsSignalRO, "STOREMODE", auto_monitor=True, kind=Kind.config) fileRecMode = Component(EpicsSignalRO, "RECMODE", auto_monitor=True, kind=Kind.config) - # ######################################################################## - # File interface - camFileFormat = Component(EpicsSignal, "FILEFORMAT", put_complete=True, kind=Kind.config) - camFilePath = Component(EpicsSignal, "FILEPATH", put_complete=True, kind=Kind.config) - camFileName = Component(EpicsSignal, "FILENAME", put_complete=True, kind=Kind.config) - camFileNr = Component(EpicsSignal, "FILENR", put_complete=True, kind=Kind.config) - camFilePath = Component(EpicsSignal, "FILEPATH", put_complete=True, kind=Kind.config) - camFileTransferStart = Component(EpicsSignal, "FTRANSFER", put_complete=True, kind=Kind.config) - camFileTransferStop = Component(EpicsSignal, "SAVESTOP", put_complete=True, kind=Kind.config) - def configure(self, d: dict = {}) -> tuple: @@ -297,77 +278,26 @@ class HelgeCameraBase(HelgeCameraCore): call SET_PARAM, but it might take long. """ old = self.read_configuration() - super().configure(d) - if "exptime" in d: - exposure_time = d['exptime'] - if exposure_time is not None: - self.acqExpTime.set(exposure_time).wait() - - if "roi" in d: - roi = d["roi"] - if not isinstance(roi, (list, tuple)): - raise ValueError(f"Unknown ROI data type {type(roi)}") - if not len(roi[0])==2 and len(roi[1])==2: - raise ValueError(f"Unknown ROI shape: {roi}") + 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 - self.pxRoiX_lo.set(roi[0][0]).wait() - self.pxRoiX_hi.set(roi[0][1]).wait() - self.pxRoiY_lo.set(roi[1][0]).wait() - self.pxRoiY_hi.set(roi[1][1]).wait() - - if "bin" in d: - binning = d["bin"] - if not isinstance(binning, (list, tuple)): - raise ValueError(f"Unknown BINNING data type {type(binning)}") - if not len(binning)==2: - raise ValueError(f"Unknown ROI shape: {binning}") - self.pxBinX.set(binning[0]).wait() - self.pxBinY.set(binning[1]).wait() - - # State machine - # Initial: BUSY and SET both low - # 1. BUSY set to high - # 2. BUSY goes low, SET goes high - # 3. SET goes low - self.camSetParam.set(1).wait() - def risingEdge(*args, old_value, value, timestamp, **kwargs): - return bool(not old_value and value) - def fallingEdge(*args, old_value, value, timestamp, **kwargs): - return bool(old_value and not value) - # Subscribe and wait for update - status = SubscriptionStatus(self.camSetParam, fallingEdge, settle_time=0.5) - status.wait() - new = self.read_configuration() - return (old, new) - - @property - def shape(self): - return (int(self.pxNumX.value), int(self.pxNumY.value)) - - @shape.setter - def shape(self): - raise ReadOnlyError("Shape is a ReadOnly property") - - @property - def bin(self): - return (int(self.pxBinX.value), int(self.pxBinY.value)) - - @bin.setter - def bin(self): - raise ReadOnlyError("Bin is a ReadOnly property") - - @property - def roi(self): - return ((int(self.pxRoiX_lo.value), int(self.pxRoiX_hi.value)), (int(self.pxRoiY_lo.value), int(self.pxRoiY_hi.value))) - - @roi.setter - def roi(self): - raise ReadOnlyError("Roi is a ReadOnly property") - - - + 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.pxBinX.set(d['image_binx']).wait() + if 'image_biny' in d and d['image_biny'] is not None: + self.pxBinY.set(d['image_biny']).wait() + # Call super() to commit the changes + super().configure(d)