From a0ae47aba6dcd8116fa5accf1b2c8e95c2b83528 Mon Sep 17 00:00:00 2001 From: gac-x05la Date: Mon, 26 Aug 2024 16:56:58 +0200 Subject: [PATCH] Adding motor class and PEPing up --- .../devices/gigafrost/gigafrostcamera.py | 61 ++++--- .../devices/gigafrost/gigafrostclient.py | 42 ++--- .../devices/gigafrost/stddaq_preview.py | 17 +- tomcat_bec/devices/gigafrost/stddaq_rest.py | 10 +- tomcat_bec/devices/gigafrost/stddaq_ws.py | 5 +- tomcat_bec/devices/psimotor.py | 149 ++++++++++++++++++ 6 files changed, 216 insertions(+), 68 deletions(-) create mode 100644 tomcat_bec/devices/psimotor.py diff --git a/tomcat_bec/devices/gigafrost/gigafrostcamera.py b/tomcat_bec/devices/gigafrost/gigafrostcamera.py index 637804a..aec94a0 100644 --- a/tomcat_bec/devices/gigafrost/gigafrostcamera.py +++ b/tomcat_bec/devices/gigafrost/gigafrostcamera.py @@ -69,11 +69,19 @@ class GigaFrostCameraMixin(CustomDetectorMixin): source_port = 3000 + j if j < 4: extend_header_table( - udp_header_table, self.parent.macSouth.get(), self.parent.ipSouth.get(), dest_port, source_port + udp_header_table, + self.parent.macSouth.get(), + self.parent.ipSouth.get(), + dest_port, + source_port ) else: extend_header_table( - udp_header_table, self.parent.macNorth.get(), self.parent.ipNorth.get(), dest_port, source_port + udp_header_table, + self.parent.macNorth.get(), + self.parent.ipNorth.get(), + dest_port, + source_port ) return udp_header_table @@ -230,17 +238,25 @@ class GigaFrostCamera(PSIDetectorBase): cmdWriteService = Component(EpicsSignal, "WRITE_SRV.PROC", put_complete=True, kind=Kind.omitted) # Standard camera configs - cfgExposure = Component(EpicsSignal, "EXPOSURE", put_complete=True, auto_monitor=True, kind=Kind.config) - cfgFramerate = Component(EpicsSignal, "FRAMERATE", put_complete=True, auto_monitor=True, kind=Kind.config) - cfgRoiX = Component(EpicsSignal, "ROIX", put_complete=True, auto_monitor=True, kind=Kind.config) - cfgRoiY = Component(EpicsSignal, "ROIY", put_complete=True, auto_monitor=True, kind=Kind.config) - cfgScanId = Component(EpicsSignal, "SCAN_ID", put_complete=True, auto_monitor=True, kind=Kind.config) - cfgCntNum = Component(EpicsSignal, "CNT_NUM", put_complete=True, auto_monitor=True, kind=Kind.config) - cfgCorrMode = Component(EpicsSignal, "CORR_MODE", put_complete=True, auto_monitor=True, kind=Kind.config) + cfgExposure = Component( + EpicsSignal, "EXPOSURE", put_complete=True, auto_monitor=True, kind=Kind.config) + cfgFramerate = Component( + EpicsSignal, "FRAMERATE", put_complete=True, auto_monitor=True, kind=Kind.config) + cfgRoiX = Component( + EpicsSignal, "ROIX", put_complete=True, auto_monitor=True, kind=Kind.config) + cfgRoiY = Component( + EpicsSignal, "ROIY", put_complete=True, auto_monitor=True, kind=Kind.config) + cfgScanId = Component( + EpicsSignal, "SCAN_ID", put_complete=True, auto_monitor=True, kind=Kind.config) + cfgCntNum = Component( + EpicsSignal, "CNT_NUM", put_complete=True, auto_monitor=True, kind=Kind.config) + cfgCorrMode = Component( + EpicsSignal, "CORR_MODE", put_complete=True, auto_monitor=True, kind=Kind.config) # Software signals cmdSoftEnable = Component(EpicsSignal, "SOFT_ENABLE", put_complete=True) - cmdSoftTrigger = Component(EpicsSignal, "SOFT_TRIG.PROC", put_complete=True, kind=Kind.omitted) + cmdSoftTrigger = Component( + EpicsSignal, "SOFT_TRIG.PROC", put_complete=True, kind=Kind.omitted) cmdSoftExposure = Component(EpicsSignal, "SOFT_EXP", put_complete=True) # Trigger configuration PVs @@ -252,7 +268,11 @@ class GigaFrostCamera(PSIDetectorBase): kind=Kind.config, ) cfgCntEndBit = Component( - EpicsSignal, "CNT_ENDBIT_RBV", write_pv="CNT_ENDBIT", put_complete=True, kind=Kind.config + EpicsSignal, + "CNT_ENDBIT_RBV", + write_pv="CNT_ENDBIT", + put_complete=True, + kind=Kind.config ) # Enable modes cfgTrigEnableExt = Component( @@ -373,10 +393,6 @@ class GigaFrostCamera(PSIDetectorBase): name, auto_soft_enable=False, backend_url=const.BE999_DAFL_CLIENT, - kind=None, - read_attrs=None, - configuration_attrs=None, - parent=None, **kwargs, ): # Ugly hack to pass values before on_init() @@ -388,10 +404,6 @@ class GigaFrostCamera(PSIDetectorBase): super().__init__( prefix=prefix, name=name, - kind=kind, - read_attrs=read_attrs, - configuration_attrs=configuration_attrs, - parent=parent, **kwargs, ) @@ -414,7 +426,7 @@ class GigaFrostCamera(PSIDetectorBase): status = DeviceStatus(self) sleep_time = self.cfgExposure.value*self.cfgCntNum.value*0.001+0.050 sleep(sleep_time) - logger.info(f"[GF2] Slept for: {sleep_time} seconds") + logger.info("[%s] Slept for %f seconds", self.name, sleep_time) status.set_finished() return status @@ -658,13 +670,10 @@ class GigaFrostCamera(PSIDetectorBase): mode_external = self.cfgTrigEnableExt.get() mode_auto = self.cfgTrigEnableAuto.get() if mode_soft and not mode_auto: - if mode_external: - return "soft+ext" - else: - return "soft" - elif mode_auto and not mode_soft and not mode_external: + return "soft+ext" if mode_external else "soft" + if mode_auto and not mode_soft and not mode_external: return "always" - elif mode_external and not mode_soft and not mode_auto: + if mode_external and not mode_soft and not mode_auto: return "external" return None diff --git a/tomcat_bec/devices/gigafrost/gigafrostclient.py b/tomcat_bec/devices/gigafrost/gigafrostclient.py index 5898f97..e8133e7 100644 --- a/tomcat_bec/devices/gigafrost/gigafrostclient.py +++ b/tomcat_bec/devices/gigafrost/gigafrostclient.py @@ -29,13 +29,6 @@ class GigaFrostClientMixin(CustomDetectorMixin): This class will be called by the custom_prepare_cls attribute of the detector class. """ - def on_init(self) -> None: - """Initialize the camera, set channel values - - on_init is automatically called during __init__ of the sub devices. - """ - return super().on_init() - def on_stage(self) -> None: """ Specify actions to be executed during stage in preparation for a scan. @@ -120,6 +113,7 @@ class GigaFrostClient(PSIDetectorBase): cam = Component(GigaFrostCamera, prefix="X02DA-CAM-GF2:", name="cam") daq = Component(StdDaqClient, name="daq") + # pylint: disable=too-many-arguments def __init__( self, prefix="", @@ -130,31 +124,25 @@ class GigaFrostClient(PSIDetectorBase): daq_ws_url = "ws://localhost:8080", daq_rest_url = "http://localhost:5000", kind=None, - read_attrs=None, - configuration_attrs=None, - parent=None, **kwargs, ): self.__class__.__dict__["cam"].kwargs['backend_url'] = backend_url self.__class__.__dict__["cam"].kwargs['auto_soft_enable'] = auto_soft_enable self.__class__.__dict__["daq"].kwargs['ws_url'] = daq_ws_url self.__class__.__dict__["daq"].kwargs['rest_url'] = daq_rest_url - #self.__class__.__dict__["daq"].__class__.__dict__["config"].kwargs['rest_url'] = daq_rest_url super().__init__( prefix=prefix, name=name, kind=kind, - read_attrs=read_attrs, - configuration_attrs=configuration_attrs, - parent=parent, **kwargs, ) - def configure(self, d: dict=None): - """Configure the next scan with the GigaFRoST camera and standard DAQ backend + """Configure the next scan with the GigaFRoST camera and standard DAQ backend. + It also makes some simple checks for consistent configuration, but otherwise + status feedback is missing on both sides. Parameters ---------- @@ -163,30 +151,22 @@ class GigaFrostClient(PSIDetectorBase): for an unlimited number of images (limited by the ringbuffer size and backend speed). (default = 10000) nimages : int, optional - Number of images to be taken during each scan. Set to -1 for an - unlimited number of images (limited by the ringbuffer size and - backend speed). (default = 10) + Number of images to be taken during each trigger (i.e. burst). + Maximum is 16777215 images. (default = 10) exposure : float, optional - Exposure time [ms]. (default = 0.2) + Exposure time, max 40 ms. [ms]. (default = 0.2) period : float, optional Exposure period [ms], ignored in soft trigger mode. (default = 1.0) pixel_width : int, optional - Image size in the x-direction [pixels] (default = 2016) + Image size in the x-direction, must be multiple of 48 [pixels] (default = 2016) pixel_height : int, optional - Image size in the y-direction [pixels] (default = 2016) + Image size in the y-direction, must be multiple of 16 [pixels] (default = 2016) scanid : int, optional - Scan identification number to be associated with the scan data - (default = 0) + Scan identification number to be associated with the scan data. + ToDo: This should be retrieved from the BEC. (default = 0) correction_mode : int, optional The correction to be applied to the imaging data. The following modes are available (default = 5): - - * 0: Bypass. No corrections are applied to the data. - * 1: Send correction factor A instead of pixel values - * 2: Send correction factor B instead of pixel values - * 3: Send correction factor C instead of pixel values - * 4: Invert pixel values, but do not apply any linearity correction - * 5: Apply the full linearity correction """ # Unstage camera (reconfiguration will anyway stop camera) super().unstage() diff --git a/tomcat_bec/devices/gigafrost/stddaq_preview.py b/tomcat_bec/devices/gigafrost/stddaq_preview.py index 4b11978..a87d381 100644 --- a/tomcat_bec/devices/gigafrost/stddaq_preview.py +++ b/tomcat_bec/devices/gigafrost/stddaq_preview.py @@ -102,7 +102,7 @@ class StdDaqPreview(Device): self._stop_polling = True return super().unstage() - def stop(self, success=False): + def stop(self, *, success=False): """Stop a running preview""" self.unstage() @@ -119,7 +119,13 @@ class StdDaqPreview(Device): try: # pylint: disable=no-member - meta, data = self._socket.recv_multipart(flags=zmq.NOBLOCK) + r = self._socket.recv_multipart(flags=zmq.NOBLOCK) + if len(r)==2: + meta, data = r + else: + sleep(0.1) + continue + t_curr = time() t_elapsed = t_curr - t_last if t_elapsed > self.throttle.get(): @@ -127,7 +133,8 @@ class StdDaqPreview(Device): if header["type"]=="uint16": image = np.frombuffer(data, dtype=np.uint16) if image.size != np.prod(header['shape']): - raise ValueError(f"Unexpected array size of {image.size} for header: {header}") + err = f"Unexpected array size of {image.size} for header: {header}" + raise ValueError(err) image = image.reshape(header['shape']) # Update image and update subscribers @@ -202,7 +209,9 @@ class StdDaqPreviewMixin(CustomDetectorMixin): self.parent.image.put(image, force=True) self.parent._run_subs(sub_type=self.parent.SUB_MONITOR, value=image) t_last=t_curr - logger.info(f"[{self.parent.name}]\tUpdated frame {header['frame']}\tMean: {np.mean(image)}") + name = self.parent.name + nfo = f"[{name}]\tFrameNo: {header['frame']}\tMean: {np.mean(image)}" + logger.info(nfo) except ValueError: # Happens when ZMQ partially delivers the multipart message pass diff --git a/tomcat_bec/devices/gigafrost/stddaq_rest.py b/tomcat_bec/devices/gigafrost/stddaq_rest.py index 7f21e94..b3b5ddd 100644 --- a/tomcat_bec/devices/gigafrost/stddaq_rest.py +++ b/tomcat_bec/devices/gigafrost/stddaq_rest.py @@ -52,7 +52,7 @@ class StdDaqRestClient(Device): cfg_start_udp_port = Component(Signal, kind=Kind.config) cfg_writer_user_id = Component(Signal, kind=Kind.config) cfg_submodule_info = Component(Signal, kind=Kind.config) - cfg_max_number_of_forwarders_spawned = Component(Signal, kind=Kind.config) + cfg_max_number_of_forwarders = Component(Signal, kind=Kind.config) cfg_use_all_forwarders = Component(Signal, kind=Kind.config) cfg_module_sync_queue_size = Component(Signal, kind=Kind.config) cfg_module_positions = Component(Signal, kind=Kind.config) @@ -89,7 +89,7 @@ class StdDaqRestClient(Device): self.cfg_start_udp_port.set(cfg['start_udp_port']).wait() self.cfg_writer_user_id.set(cfg['writer_user_id']).wait() #self.cfg_submodule_info.set(cfg['submodule_info']).wait() - self.cfg_max_number_of_forwarders_spawned.set(cfg['max_number_of_forwarders_spawned']).wait() + self.cfg_max_number_of_forwarders.set(cfg['max_number_of_forwarders']).wait() self.cfg_use_all_forwarders.set(cfg['use_all_forwarders']).wait() self.cfg_module_sync_queue_size.set(cfg['module_sync_queue_size']).wait() #self.cfg_module_positions.set(cfg['module_positions']).wait() @@ -109,7 +109,7 @@ class StdDaqRestClient(Device): 'writer_user_id': int(self.cfg_writer_user_id.get()), 'log_level': "debug", 'submodule_info': {}, - 'max_number_of_forwarders_spawned': int(self.cfg_max_number_of_forwarders_spawned.get()), + 'max_number_of_forwarders': int(self.cfg_max_number_of_forwarders.get()), 'use_all_forwarders': bool(self.cfg_use_all_forwarders.get()), 'module_sync_queue_size': int(self.cfg_module_sync_queue_size.get()), 'module_positions': {} @@ -165,7 +165,7 @@ class StdDaqRestClient(Device): self.cfg_pixel_width.set(pixel_width).wait() self.write_daq_config() - logger.info(f"[{self.name}] Reconfigured the StdDAQ") + logger.info("[%s] Reconfigured the StdDAQ", self.name) # No feedback on restart, we just sleep sleep(3) @@ -192,7 +192,7 @@ class StdDaqRestClient(Device): self.read_daq_config() return super().unstage() - def stop(self, success=False): + def stop(self, *, success=False): """Stop op: Read the current configuration from the DAQ """ self.unstage() diff --git a/tomcat_bec/devices/gigafrost/stddaq_ws.py b/tomcat_bec/devices/gigafrost/stddaq_ws.py index 0d8fcb4..4423d82 100644 --- a/tomcat_bec/devices/gigafrost/stddaq_ws.py +++ b/tomcat_bec/devices/gigafrost/stddaq_ws.py @@ -85,7 +85,8 @@ class StdDaqClient(Device): num_retry += 1 sleep(3) if num_retry==5: - raise ConnectionRefusedError("The standard DAQ websocket interface refused connection 5 times.") + raise ConnectionRefusedError( + "The stdDAQ websocket interface refused connection 5 times.") def __del__(self): """Try to close the socket""" @@ -174,7 +175,7 @@ class StdDaqClient(Device): pass return super().unstage() - def stop(self, success=False): + def stop(self, *, success=False): """ Stop a running acquisition WARN: This will also close the connection!!! diff --git a/tomcat_bec/devices/psimotor.py b/tomcat_bec/devices/psimotor.py new file mode 100644 index 0000000..f06a7be --- /dev/null +++ b/tomcat_bec/devices/psimotor.py @@ -0,0 +1,149 @@ +""" Extension class for EpicsMotor + + +This module extends the basic EpicsMotor with additional functionality. It +exposes additional parameters of the EPICS MotorRecord and provides a more +detailed interface for motors using the new ECMC-based motion systems at PSI. +""" + +import warnings + +from ophyd import Component, EpicsMotor, EpicsSignal, EpicsSignalRO, Kind +from ophyd.status import MoveStatus + + +class SpmgStates: + """ Enum for the EPICS MotorRecord's SPMG state""" + # pylint: disable=too-few-public-methods + STOP = 0 + PAUSE = 1 + MOVE= 2 + GO = 3 + + +class EpicsMotorMR(EpicsMotor): + """ Extended EPICS Motor class + + Special motor class that exposes additional motor record functionality. + It extends EpicsMotor base class to provide some simple status checks + before movement. + """ + + SUB_PROGRESS = "progress" + motor_deadband = Component( + EpicsSignalRO, ".RDBD", auto_monitor=True, kind=Kind.config) + motor_mode = Component( + EpicsSignal, ".SPMG", auto_monitor=True, put_complete=True, kind=Kind.omitted) + + _start_position = None + _target_position = None + + # pylint: disable=too-many-arguments + def __init__( + self, + prefix="", + *, + name, + kind=None, + read_attrs=None, + configuration_attrs=None, + parent=None, + **kwargs, + ): + super().__init__( + prefix=prefix, + name=name, + kind=kind, + read_attrs=read_attrs, + configuration_attrs=configuration_attrs, + parent=parent, + **kwargs, + ) + + self.subscribe(self._progress_update, run=False) + + def move(self, position, wait=True, **kwargs) -> MoveStatus: + """ Extended move function with a few sanity checks + + Note that the default EpicsMotor only supports the 'GO' movement mode. + """ + # Reset SPMG before move + spmg = self.motor_mode.get() + if spmg != SpmgStates.GO: + self.motor_mode.put(SpmgStates.GO).wait() + #Warni if trying to move beyond an active limit + if self.high_limit_switch and position > self.position: + warnings.warn("Attempting to move above active HLS", RuntimeWarning) + if self.low_limit_switch and position < self.position: + warnings.warn("Attempting to move below active LLS", RuntimeWarning) + + self._start_position = self.position + self._target_position = position + + return super().move(position, wait, **kwargs) + + def _progress_update(self, value, **kwargs) -> None: + """Progress update on the current movement""" + if (self._start_position is None) or (self._target_position is None) or (not self.moving): + self._run_subs(sub_type=self.SUB_PROGRESS, value=1, max_value=1, done=1) + return + + progress = abs( + (value - self._start_position) / (self._target_position - self._start_position) + ) + self._run_subs( + sub_type=self.SUB_PROGRESS, value=progress, max_value=1, done=self.moving) + + +class EpicsMotorEC(EpicsMotorMR): + """ Detailed ECMC EPICS motor class + + Special motor class to provide additional functionality for ECMC based motors. + It exposes additional diagnostic fields and includes basic error management. + """ + + USER_ACCESS = ['reset'] + enable_readback = Component(EpicsSignalRO, "-EnaAct", auto_monitor=True, kind=Kind.normal) + enable = Component( + EpicsSignal, "-EnaCmd-RB", write_pv="-EnaCmd", auto_monitor=True, kind=Kind.normal) + homed = Component(EpicsSignalRO, "-Homed", auto_monitor=True, kind=Kind.normal) + velocity_readback = Component(EpicsSignalRO, "-VelAct", auto_monitor=True, kind=Kind.normal) + position_readback = Component(EpicsSignalRO, "-PosAct", auto_monitor=True, kind=Kind.normal) + position_error = Component(EpicsSignalRO, "-PosErr", auto_monitor=True, kind=Kind.normal) + #high_interlock = Component(EpicsSignalRO, "-SumIlockFwd", auto_monitor=True, kind=Kind.normal) + #low_interlock = Component(EpicsSignalRO, "-SumIlockBwd", auto_monitor=True, kind=Kind.normal) + + ecmc_status = Component(EpicsSignalRO, "-Status", auto_monitor=True, kind=Kind.normal) + error = Component(EpicsSignalRO, "-ErrId", auto_monitor=True, kind=Kind.normal) + error_msg = Component(EpicsSignalRO, "-MsgTxt", auto_monitor=True, kind=Kind.normal) + error_reset = Component(EpicsSignal, "-ErrRst", put_complete=True, kind=Kind.omitted) + + def move(self, position, wait=True, **kwargs) -> MoveStatus: + """ Extended move function with a few sanity checks + + Note that the default EpicsMotor only supports the 'GO' movement mode. + """ + # Reset SPMG before move + error = self.error.get() + if error: + raise RuntimeError(f"Motor is in error state with message: '{self.error_msg.get()}'") + + return super().move(position, wait, **kwargs) + + def reset(self): + """ Resets an ECMC axis + + Recovers an ECMC axis from a previous error. Note that this does not + solve the cause of the error, that you'll have to do yourself. + + Common error causes: + ------------------------- + - MAX_POSITION_LAG_EXCEEDED : The PID tuning is wrong. + - MAX_VELOCITY_EXCEEDED : Either the PID is wrong or the motor is sticking-slipping + - BOTH_LIMITS_ACTIVE : The motors are probably not connected + """ + # Reset the error + self.error_reset.set(1, settle_time=0.1).wait() + # Check if it disappeared + if self.error.get(): + raise RuntimeError(f"Failed to reset axis error: '{self.error_msg.get()}'")