From d1e072b8d9b504215df23b9df03d24fa2266add2 Mon Sep 17 00:00:00 2001 From: gac-x05la Date: Fri, 14 Feb 2025 13:48:47 +0100 Subject: [PATCH 01/13] Delayed start on Aerotech tasks --- tomcat_bec/devices/aerotech/AerotechTasks.py | 34 ++++++++++++-------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/tomcat_bec/devices/aerotech/AerotechTasks.py b/tomcat_bec/devices/aerotech/AerotechTasks.py index 5e1b067..bdac316 100644 --- a/tomcat_bec/devices/aerotech/AerotechTasks.py +++ b/tomcat_bec/devices/aerotech/AerotechTasks.py @@ -5,7 +5,6 @@ interface. @author: mohacsi_i """ -from time import sleep from ophyd import Component, EpicsSignal, EpicsSignalRO, Kind from ophyd.status import DeviceStatus, SubscriptionStatus @@ -19,8 +18,8 @@ logger = bec_logger.logger class AerotechTasksMixin(CustomDeviceMixin): - """Mixin class for self-configuration and staging - """ + """Mixin class for self-configuration and staging""" + # parent : aa1Tasks def on_stage(self) -> None: """Configuration and staging @@ -33,8 +32,6 @@ class AerotechTasksMixin(CustomDeviceMixin): when not in use. I.e. this method is not expected to be called when PSO is not needed or when it'd conflict with other devices. """ - # logger.warning(self.parent.scaninfo.scan_msg.info['kwargs'].keys()) - # Fish out our configuration from scaninfo (via explicit or generic addressing) d = {} if "kwargs" in self.parent.scaninfo.scan_msg.info: @@ -121,15 +118,15 @@ class aa1Tasks(PSIDeviceBase): self.switch.set("Reset").wait() # Check what we got if "script_task" in d: - if d['script_task'] < 3 or d['script_task'] > 21: + if d["script_task"] < 3 or d["script_task"] > 21: raise RuntimeError(f"Invalid task index: {d['script_task']}") - self.taskIndex.set(d['script_task']).wait() + self.taskIndex.set(d["script_task"]).wait() if "script_file" in d: self.fileName.set(d["script_file"]).wait() if "script_text" in d: # Compile text for syntax checking # NOTE: This will load to 'script_file' - self._fileWrite.set(d['script_text'], settle_time=0.2).wait() + self._fileWrite.set(d["script_text"], settle_time=0.2).wait() self.switch.set("Load").wait() # Check the result of load if self._failure.value: @@ -139,14 +136,23 @@ class aa1Tasks(PSIDeviceBase): return (old, new) def bluestage(self) -> None: - """Bluesky style stage""" + """Bluesky style stage, prepare, but does not execute""" if self.taskIndex.get() in (0, 1, 2): - logger.error(f"[{self.name}] Launching AeroScript on system task. Daring today are we?") - # Launch and check success - status = self.switch.set("Run", settle_time=0.2) + logger.error(f"[{self.name}] Loading AeroScript on system task. Daring today are we?") + # Load and check success + status = self.switch.set("Load", settle_time=0.2) status.wait() if self._failure.value: - raise RuntimeError("Failed to kick off task, please check the Aerotech IOC") + raise RuntimeError("Failed to load task, please check the Aerotech IOC") + return status + + def bluekickoff(self): + """Bluesky style kickoff""" + # Launch and check success + status = self.switch.set("Start", settle_time=0.2) + status.wait() + if self._failure.value: + raise RuntimeError("Failed to load task, please check the Aerotech IOC") return status ########################################################################## @@ -156,7 +162,7 @@ class aa1Tasks(PSIDeviceBase): timestamp_ = 0 task_idx = int(self.taskIndex.get()) - def not_running(*args, value, timestamp, **kwargs): + def not_running(*, value, timestamp, **_): nonlocal timestamp_ result = value[task_idx] not in ["Running", 4] timestamp_ = timestamp -- 2.49.1 From c3cf12b8e2b8d2e5e2a1c0b64691b2ae6b9aa7a1 Mon Sep 17 00:00:00 2001 From: gac-x05la Date: Fri, 14 Feb 2025 17:39:36 +0100 Subject: [PATCH 02/13] SnapNStep works, Sequence fails on Aerotech axis --- .../device_configs/microxas_test_bed.yaml | 65 ++++----- .../aerotech/AerotechDriveDataCollection.py | 17 ++- .../AerotechSimpleSequenceTemplate.ascript | 18 ++- .../AerotechSnapAndStepTemplate.ascript | 1 + tomcat_bec/devices/aerotech/AerotechTasks.py | 16 +- .../devices/gigafrost/gigafrostcamera.py | 7 +- tomcat_bec/devices/gigafrost/pcoedgecamera.py | 8 +- tomcat_bec/scans/__init__.py | 2 +- tomcat_bec/scans/tomcat_scans.py | 138 ++++-------------- 9 files changed, 109 insertions(+), 163 deletions(-) diff --git a/tomcat_bec/device_configs/microxas_test_bed.yaml b/tomcat_bec/device_configs/microxas_test_bed.yaml index 09c760e..2c8a479 100644 --- a/tomcat_bec/device_configs/microxas_test_bed.yaml +++ b/tomcat_bec/device_configs/microxas_test_bed.yaml @@ -64,18 +64,18 @@ es1_roty: # readoutPriority: monitored # softwareTrigger: false -# es1_tasks: -# description: 'Automation1 task management interface' -# deviceClass: tomcat_bec.devices.aa1Tasks -# deviceConfig: -# prefix: 'X02DA-ES1-SMP1:TASK:' -# deviceTags: -# - es1 -# enabled: true -# onFailure: buffer -# readOnly: false -# readoutPriority: monitored -# softwareTrigger: false +es1_tasks: + description: 'Automation1 task management interface' + deviceClass: tomcat_bec.devices.aa1Tasks + deviceConfig: + prefix: 'X02DA-ES1-SMP1:TASK:' + deviceTags: + - es1 + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: monitored + softwareTrigger: false # es1_psod: @@ -92,18 +92,18 @@ es1_roty: # softwareTrigger: true -# es1_ddaq: -# description: 'Automation1 position recording interface' -# deviceClass: tomcat_bec.devices.aa1AxisDriveDataCollection -# deviceConfig: -# prefix: 'X02DA-ES1-SMP1:ROTY:DDC:' -# deviceTags: -# - es1 -# enabled: true -# onFailure: buffer -# readOnly: false -# readoutPriority: monitored -# softwareTrigger: false +es1_ddaq: + description: 'Automation1 position recording interface' + deviceClass: tomcat_bec.devices.aa1AxisDriveDataCollection + deviceConfig: + prefix: 'X02DA-ES1-SMP1:ROTY:DDC:' + deviceTags: + - es1 + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: monitored + softwareTrigger: false #camera: @@ -152,7 +152,7 @@ gfdaq: readoutPriority: monitored softwareTrigger: false -daq_stream0: +gf_stream0: description: stdDAQ preview (2 every 555) deviceClass: tomcat_bec.devices.StdDaqPreviewDetector deviceConfig: @@ -166,21 +166,6 @@ daq_stream0: readoutPriority: monitored softwareTrigger: false -daq_stream1: - description: stdDAQ preview (1 at 5 Hz) - deviceClass: tomcat_bec.devices.StdDaqPreviewDetector - deviceConfig: - url: 'tcp://129.129.95.111:20001' - deviceTags: - - std-daq - - gfcam - enabled: true - onFailure: buffer - readOnly: false - readoutPriority: monitored - softwareTrigger: false - - pcocam: description: PCO.edge camera client deviceClass: tomcat_bec.devices.PcoEdge5M diff --git a/tomcat_bec/devices/aerotech/AerotechDriveDataCollection.py b/tomcat_bec/devices/aerotech/AerotechDriveDataCollection.py index b81ec3f..1489a02 100644 --- a/tomcat_bec/devices/aerotech/AerotechDriveDataCollection.py +++ b/tomcat_bec/devices/aerotech/AerotechDriveDataCollection.py @@ -36,19 +36,22 @@ class AerotechDriveDataCollectionMixin(CustomDeviceMixin): # NOTE: Scans don't have to fully configure the device if "ddc_trigger" in scanargs: d["ddc_trigger"] = scanargs["ddc_trigger"] - if "ddc_num_points" in scanargs: + + if "ddc_num_points" in scanargs and scanargs["ddc_num_points"] is not None: d["num_points_total"] = scanargs["ddc_num_points"] + elif "daq_num_points" in scanargs and scanargs["daq_num_points"] is not None: + d["num_points_total"] = scanargs["daq_num_points"] else: # Try to figure out number of points num_points = 1 points_valid = False - if "steps" in scanargs and scanargs['steps'] is not None: + if "steps" in scanargs and scanargs["steps"] is not None: num_points *= scanargs["steps"] points_valid = True - elif "exp_burst" in scanargs and scanargs['exp_burst'] is not None: + if "exp_burst" in scanargs and scanargs["exp_burst"] is not None: num_points *= scanargs["exp_burst"] points_valid = True - elif "repeats" in scanargs and scanargs['repeats'] is not None: + if "repeats" in scanargs and scanargs["repeats"] is not None: num_points *= scanargs["repeats"] points_valid = True if points_valid: @@ -143,6 +146,10 @@ class aa1AxisDriveDataCollection(PSIDeviceBase): """Bluesky-style stage""" self._switch.set("Start", settle_time=0.2).wait() + def blueunstage(self): + """Bluesky-style unstage""" + self._switch.set("Stop", settle_time=0.2).wait() + def reset(self): """Reset incremental readback""" self._switch.set("ResetRB", settle_time=0.1).wait() @@ -170,6 +177,8 @@ class aa1AxisDriveDataCollection(PSIDeviceBase): elif index == 1: status = SubscriptionStatus(self._readstatus1, neg_edge, settle_time=0.5) self._readback1.set(1).wait() + else: + raise ValueError(f"[{self.name}] Invalid data acquisition channel {index}") # Start asynchronous readback status.wait() diff --git a/tomcat_bec/devices/aerotech/AerotechSimpleSequenceTemplate.ascript b/tomcat_bec/devices/aerotech/AerotechSimpleSequenceTemplate.ascript index df0ff92..b7b0c21 100644 --- a/tomcat_bec/devices/aerotech/AerotechSimpleSequenceTemplate.ascript +++ b/tomcat_bec/devices/aerotech/AerotechSimpleSequenceTemplate.ascript @@ -34,7 +34,8 @@ program ////////////////////////////////////////////////////////////////////////// // Internal parameters - dont use var $axis as axis = ROTY - var $ii as integer + var $ii as integer + var $axisFaults as integer = 0 var $iDdcSafeSpace as integer = 4096 // Set acceleration @@ -126,7 +127,12 @@ program if $eScanType == ScanType.POS || $eScanType == ScanType.NEG PsoDistanceConfigureArrayDistances($axis, $iPsoArrayPosAddr, $iPsoArrayPosSize, 0) MoveAbsolute($axis, $fPosEnd, $fVelScan) - WaitForInPosition($axis) + WaitForMotionDone($axis) + + $axisFaults = StatusGetAxisItem($axis, AxisDataSignal.AxisFault) + if $axisFaults + TaskSetError(TaskGetIndex(), "AxisFault on axis ROTY") + end elseif $eScanType == ScanType.POSNEG || $eScanType == ScanType.NEGPOS for $ii = 0 to ($iNumRepeat-1) // Feedback on progress @@ -134,11 +140,15 @@ program if ($ii % 2) == 0 PsoDistanceConfigureArrayDistances($axis, $iPsoArrayPosAddr, $iPsoArrayPosSize, 0) MoveAbsolute($axis, $fPosEnd, $fVelScan) - WaitForInPosition($axis) elseif ($ii % 2) == 1 PsoDistanceConfigureArrayDistances($axis, $iPsoArrayNegAddr, $iPsoArrayNegSize, 0) MoveAbsolute($axis, $fPosStart, $fVelScan) - WaitForInPosition($axis) + end + WaitForMotionDone($axis) + + $axisFaults = StatusGetAxisItem($axis, AxisDataSignal.AxisFault) + if $axisFaults + TaskSetError(TaskGetIndex(), "AxisFault on axis ROTY") end Dwell(0.2) end diff --git a/tomcat_bec/devices/aerotech/AerotechSnapAndStepTemplate.ascript b/tomcat_bec/devices/aerotech/AerotechSnapAndStepTemplate.ascript index e8c69e5..80657d0 100644 --- a/tomcat_bec/devices/aerotech/AerotechSnapAndStepTemplate.ascript +++ b/tomcat_bec/devices/aerotech/AerotechSnapAndStepTemplate.ascript @@ -78,6 +78,7 @@ program /////////////////////////////////////////////////////////// $iglobal[2] = $iNumSteps for $ii = 0 to ($iNumSteps-1) + $rglobal[4] = $ii MoveAbsolute($axis, $fStartPosition + $ii * $fStepSize, $fVelScan) WaitForMotionDone($axis) diff --git a/tomcat_bec/devices/aerotech/AerotechTasks.py b/tomcat_bec/devices/aerotech/AerotechTasks.py index bdac316..74b0b74 100644 --- a/tomcat_bec/devices/aerotech/AerotechTasks.py +++ b/tomcat_bec/devices/aerotech/AerotechTasks.py @@ -47,7 +47,7 @@ class AerotechTasksMixin(CustomDeviceMixin): # Perform bluesky-style configuration if len(d) > 0: - logger.warning(f"[{self.parent.name}] Configuring with:\n{d}") + # logger.warning(f"[{self.parent.name}] Configuring with:\n{d}") self.parent.configure(d=d) # The actual staging @@ -55,12 +55,16 @@ class AerotechTasksMixin(CustomDeviceMixin): def on_unstage(self): """Stop the currently selected task""" - self.parent.switch.set("Stop").wait() + self.parent.blueunstage() def on_stop(self): """Stop the currently selected task""" self.parent.switch.set("Stop").wait() + def on_kickoff(self): + """Start execution of the selected task""" + self.parent.bluekickoff() + class aa1Tasks(PSIDeviceBase): """Task management API @@ -146,6 +150,10 @@ class aa1Tasks(PSIDeviceBase): raise RuntimeError("Failed to load task, please check the Aerotech IOC") return status + def blueunstage(self): + """Bluesky style unstage, stops execution""" + self.switch.set("Stop").wait() + def bluekickoff(self): """Bluesky style kickoff""" # Launch and check success @@ -155,6 +163,10 @@ class aa1Tasks(PSIDeviceBase): raise RuntimeError("Failed to load task, please check the Aerotech IOC") return status + def kickoff(self): + """Missing kickoff, for real?""" + self.custom_prepare.on_kickoff() + ########################################################################## # Bluesky flyer interface def complete(self) -> DeviceStatus: diff --git a/tomcat_bec/devices/gigafrost/gigafrostcamera.py b/tomcat_bec/devices/gigafrost/gigafrostcamera.py index 59ee53f..1b90dbe 100644 --- a/tomcat_bec/devices/gigafrost/gigafrostcamera.py +++ b/tomcat_bec/devices/gigafrost/gigafrostcamera.py @@ -7,7 +7,7 @@ Created on Thu Jun 27 17:28:43 2024 @author: mohacsi_i """ from time import sleep -from ophyd import Signal, SignalRO, Component, EpicsSignal, EpicsSignalRO, Kind, DeviceStatus +from ophyd import Signal, Component, EpicsSignal, EpicsSignalRO, Kind, DeviceStatus from ophyd.device import DynamicDeviceComponent from ophyd_devices.interfaces.base_classes.psi_detector_base import ( CustomDetectorMixin, @@ -174,6 +174,10 @@ class GigaFrostCameraMixin(CustomDetectorMixin): 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 "acq_time" in scanargs and scanargs["acq_time"] is not None: + d["exposure_time_ms"] = scanargs["acq_time"] + if "acq_period" in scanargs and scanargs["acq_period"] is not None: + d["exposure_period_ms"] = scanargs["acq_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: @@ -497,6 +501,7 @@ class GigaFrostCamera(PSIDetectorBase): # There's no status readback from the camera, so we just wait sleep_time = self.cfgExposure.value * self.cfgCntNum.value * 0.001 + 0.2 + logger.warning(f"Gigafrost sleeping for {sleep_time} sec") sleep(sleep_time) return DeviceStatus(self, done=True, success=True, settle_time=sleep_time) diff --git a/tomcat_bec/devices/gigafrost/pcoedgecamera.py b/tomcat_bec/devices/gigafrost/pcoedgecamera.py index 0f046a6..d9e0f07 100644 --- a/tomcat_bec/devices/gigafrost/pcoedgecamera.py +++ b/tomcat_bec/devices/gigafrost/pcoedgecamera.py @@ -7,7 +7,7 @@ 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 PSIDetectorBase as BECDeviceBase from ophyd_devices.interfaces.base_classes.psi_detector_base import ( CustomDetectorMixin as CustomPrepare, ) @@ -54,6 +54,10 @@ class PcoEdgeCameraMixin(CustomPrepare): 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 "acq_time" in scanargs and scanargs["acq_time"] is not None: + d["exposure_time_ms"] = scanargs["acq_time"] + if "acq_period" in scanargs and scanargs["acq_period"] is not None: + d["exposure_period_ms"] = scanargs["acq_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: @@ -469,5 +473,5 @@ class PcoEdge5M(HelgeCameraBase): if __name__ == "__main__": # Drive data collection - cam = PcoEdge5M("X02DA-CCDCAM2:", name="mcpcam") + cam = PcoEdge5M(prefix="X02DA-CCDCAM2:", name="mcpcam") cam.wait_for_connection() diff --git a/tomcat_bec/scans/__init__.py b/tomcat_bec/scans/__init__.py index ab461c9..4693852 100644 --- a/tomcat_bec/scans/__init__.py +++ b/tomcat_bec/scans/__init__.py @@ -1,2 +1,2 @@ from .tutorial_fly_scan import AcquireDark, AcquireWhite, AcquireRefs, TutorialFlyScanContLine -from .tomcat_scans import TomcatStepScan, TomcatSnapNStep, TomcatSimpleSequence +from .tomcat_scans import TomcatSnapNStep, TomcatSimpleSequence diff --git a/tomcat_bec/scans/tomcat_scans.py b/tomcat_bec/scans/tomcat_scans.py index c6e980d..3c830b2 100644 --- a/tomcat_bec/scans/tomcat_scans.py +++ b/tomcat_bec/scans/tomcat_scans.py @@ -18,95 +18,11 @@ import os import time from bec_lib import bec_logger -from bec_server.scan_server.scans import AsyncFlyScanBase, ScanBase, ScanArgType +from bec_server.scan_server.scans import AsyncFlyScanBase, ScanArgType logger = bec_logger.logger -class TomcatStepScan(ScanBase): - """Simple software step scan for Tomcat - - Example class for simple BEC-based step scan using the low-level API. It's just a standard - 'line_scan' with the only difference that overrides burst behavior to use camera burst instead - of individual software triggers. - - NOTE: As decided by Tomcat, the scans should not manage the scope of devices - - All enabled devices are expected to be configured for acquisition by the end of stage - - Some devices can configure themselves from mandatory scan parameters (steps, burst) - - Other devices can be optionally configured by keyword arguments - - Devices will try to stage using whatever was set on them before - - Example: - -------- - >>> scans.tomcatstepscan(scan_start=-25, scan_end=155, steps=180, exp_time=0.005, exp_burst=5) - - Common keyword arguments: - ------------------------- - image_width : int - image_height : int - ddc_trigger : str - """ - - scan_name = "tomcatstepscan" - scan_type = "step" - required_kwargs = ["scan_start", "scan_end", "steps"] - gui_config = { - "Movement parameters": ["steps"], - "Acquisition parameters": ["exp_time", "exp_burst"], - } - - def update_scan_motors(self): - self.scan_motors = ["es1_roty"] - - def _get_scan_motors(self): - self.scan_motors = ["es1_roty"] - - def __init__( - self, - scan_start: float, - scan_end: float, - steps: int, - exp_time: float=0.005, - settling_time: float=0.2, - exp_burst: int=1, - **kwargs, - ): - # Converting generic kwargs to tomcat device configuration parameters - super().__init__( - exp_time=exp_time, - settling_time=settling_time, - burst_at_each_point=1, - optim_trajectory=None, - **kwargs, - ) - - # For position calculation - self.motor = "es1_roty" - self.scan_start = scan_start - self.scan_end = scan_end - self.scan_steps = steps - self.scan_stepsize = (scan_end - scan_start) / steps - - def _calculate_positions(self) -> None: - """Pre-calculate scan positions""" - for ii in range(self.scan_steps + 1): - self.positions.append(self.scan_start + ii * self.scan_stepsize) - - def _at_each_point(self, ind=None, pos=None): - """ Overriden at_each_point, using detector burst instaead of manual triggering""" - yield from self._move_scan_motors_and_wait(pos) - time.sleep(self.settling_time) - - trigger_time = 0.001*self.exp_time * self.burst_at_each_point - yield from self.stubs.trigger(min_wait=trigger_time) - # yield from self.stubs.trigger(group='trigger', point_id=self.point_id) - # time.sleep(trigger_time) - - yield from self.stubs.read(group="monitored", point_id=self.point_id) - # yield from self.stubs.read(group="monitored", point_id=self.point_id, wait_group=None) - self.point_id += 1 - - class TomcatSnapNStep(AsyncFlyScanBase): """Simple software step scan forTomcat @@ -115,7 +31,7 @@ class TomcatSnapNStep(AsyncFlyScanBase): Example ------- - >>> scans.tomcatsnapnstepscan(scan_start=-25, scan_end=155, steps=180, exp_time=0.005, exp_burst=5) + >>> scans.tomcatsnapnstepscan(scan_start=-25, scan_end=155, steps=180, acq_time=5, exp_burst=5) """ scan_name = "tomcatsnapnstepscan" @@ -126,10 +42,10 @@ class TomcatSnapNStep(AsyncFlyScanBase): required_kwargs = ["scan_start", "scan_end", "steps"] gui_config = { "Movement parameters": ["steps"], - "Acquisition parameters": ["exp_time", "exp_burst"], + "Acquisition parameters": ["acq_time", "exp_burst"], } - def _get_scan_motors(self): + def _update_scan_motors(self): self.scan_motors = ["es1_roty"] def __init__( @@ -137,8 +53,8 @@ class TomcatSnapNStep(AsyncFlyScanBase): scan_start: float, scan_end: float, steps: int, - exp_time:float=0.005, settling_time:float=0.2, + acq_time:float=5.0, exp_burst:int=1, sync:str="event", **kwargs, @@ -147,14 +63,12 @@ class TomcatSnapNStep(AsyncFlyScanBase): self.scan_start = scan_start self.scan_end = scan_end self.scan_steps = steps - self.scan_stepsize = (scan_end - scan_start) / steps - self.scan_ntotal = exp_burst * (steps + 1) - self.exp_time = exp_time + self.exp_time = acq_time self.exp_burst = exp_burst self.settling_time = settling_time self.scan_sync = sync - # General device configuration + # Gigafrost trigger mode kwargs["parameter"]["kwargs"]["acq_mode"] = "ext_enable" # Used for Aeroscript file substitutions for the task interface @@ -165,7 +79,8 @@ class TomcatSnapNStep(AsyncFlyScanBase): kwargs["parameter"]["kwargs"]["script_file"] = "bec.ascript" super().__init__( - exp_time=exp_time, + acq_time=acq_time, + exp_burst=exp_burst, settling_time=settling_time, burst_at_each_point=1, optim_trajectory=None, @@ -182,12 +97,12 @@ class TomcatSnapNStep(AsyncFlyScanBase): } filesubs = { "startpos": self.scan_start, - "stepsize": self.scan_stepsize, + "stepsize": (self.scan_end - self.scan_start) / self.scan_steps, "numsteps": self.scan_steps, - "exptime": 2 * self.exp_time * self.exp_burst, + "exptime": 0.002 * self.exp_time * self.exp_burst, "settling": self.settling_time, "psotrigger": p_modes[self.scan_sync], - "npoints": self.scan_ntotal, + "npoints": self.exp_burst * (self.scan_steps + 1), } return filesubs @@ -196,7 +111,7 @@ class TomcatSnapNStep(AsyncFlyScanBase): # Load the test file filename = os.path.join(os.path.dirname(__file__), "../devices/aerotech/" + filename) logger.info(f"Attempting to load file {filename}") - with open(filename) as f: + with open(filename, "r", encoding="utf-8") as f: templatetext = f.read() # Substitute jinja template @@ -208,12 +123,15 @@ class TomcatSnapNStep(AsyncFlyScanBase): """The actual scan routine""" print("TOMCAT Running scripted scan (via Jinjad AeroScript)") t_start = time.time() + # Kickoff + yield from self.stubs.send_rpc_and_wait("es1_tasks", "kickoff") # Complete # FIXME: this will swallow errors # yield from self.stubs.complete(device="es1_tasks") - st = yield from self.stubs.send_rpc_and_wait("es1_tasks", "complete") - # st.wait() + yield from self.stubs.send_rpc_and_wait("es1_tasks", "complete") + + # Check final state task_states = yield from self.stubs.send_rpc_and_wait("es1_tasks", "taskStates.get") if task_states[4] == 8: raise RuntimeError(f"Task {4} finished in ERROR state") @@ -243,7 +161,7 @@ class TomcatSimpleSequence(AsyncFlyScanBase): Example ------- - >>> scans.tomcatsimplesequencescan(33, 180, 180, exp_time=0.005, exp_burst=1800, repeats=10) + >>> scans.tomcatsimplesequencescan(33, 180, 180, acq_time=5, exp_burst=1800, repeats=10) """ scan_name = "tomcatsimplesequencescan" @@ -255,7 +173,7 @@ class TomcatSimpleSequence(AsyncFlyScanBase): "Acquisition parameters": [ "gate_high", "gate_low", - "exp_time", + "acq_time", "exp_burst", "sync", ], @@ -271,7 +189,7 @@ class TomcatSimpleSequence(AsyncFlyScanBase): gate_low: float, repeats: int = 1, repmode: str = "PosNeg", - exp_time: float = 0.005, + acq_time: float = 5, exp_burst: float = 180, sync: str = "pso", **kwargs, @@ -282,13 +200,13 @@ class TomcatSimpleSequence(AsyncFlyScanBase): self.gate_low = gate_low self.scan_repnum = repeats self.scan_repmode = repmode.upper() - self.exp_time = exp_time + self.exp_time = acq_time self.exp_burst = exp_burst self.scan_sync = sync # Synthetic values self.scan_ntotal = exp_burst * repeats - self.scan_velocity = gate_high / (exp_time * exp_burst) + self.scan_velocity = gate_high / (0.001*acq_time * exp_burst) self.scan_acceleration = 500 self.scan_safedistance = 10 self.scan_accdistance = ( @@ -314,7 +232,7 @@ class TomcatSimpleSequence(AsyncFlyScanBase): kwargs["parameter"]["kwargs"]["script_file"] = "bec.ascript" super().__init__( - exp_time=exp_time, + acq_time=acq_time, settling_time=0.5, relative=False, burst_at_each_point=1, @@ -327,7 +245,7 @@ class TomcatSimpleSequence(AsyncFlyScanBase): # Load the test file filename = os.path.join(os.path.dirname(__file__), "../devices/aerotech/" + filename) logger.info(f"Attempting to load file {filename}") - with open(filename) as f: + with open(filename, 'r', encoding="utf-8") as f: templatetext = f.read() # Substitute jinja template @@ -339,12 +257,14 @@ class TomcatSimpleSequence(AsyncFlyScanBase): """The actual scan routine""" print("TOMCAT Running scripted scan (via Jinjad AeroScript)") t_start = time.time() + # Kickoff + yield from self.stubs.send_rpc_and_wait("es1_tasks", "kickoff") # Complete # FIXME: this will swallow errors # yield from self.stubs.complete(device="es1_tasks") - st = yield from self.stubs.send_rpc_and_wait("es1_tasks", "complete") - # st.wait() + yield from self.stubs.send_rpc_and_wait("es1_tasks", "complete") + task_states = yield from self.stubs.send_rpc_and_wait("es1_tasks", "taskStates.get") if task_states[4] == 8: raise RuntimeError(f"Task {4} finished in ERROR state") -- 2.49.1 From e8b3aedb106f8ecf106b10cdd192eed11b1bf770 Mon Sep 17 00:00:00 2001 From: gac-x05la Date: Fri, 14 Feb 2025 17:46:27 +0100 Subject: [PATCH 03/13] Trigger mode notes --- tomcat_bec/devices/gigafrost/gigafrostcamera.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tomcat_bec/devices/gigafrost/gigafrostcamera.py b/tomcat_bec/devices/gigafrost/gigafrostcamera.py index 1b90dbe..ef265d7 100644 --- a/tomcat_bec/devices/gigafrost/gigafrostcamera.py +++ b/tomcat_bec/devices/gigafrost/gigafrostcamera.py @@ -588,6 +588,7 @@ class GigaFrostCamera(PSIDetectorBase): """ if acq_mode == "default": + # NOTE: Trigger using software events via softEnable (actually works) # Trigger parameters self.cfgCntStartBit.set(1).wait() self.cfgCntEndBit.set(0).wait() @@ -601,6 +602,7 @@ class GigaFrostCamera(PSIDetectorBase): self.trigger_mode = "auto" self.exposure_mode = "timer" elif acq_mode in ["ext_enable", "external_enable"]: + # NOTE: Trigger using external hardware events via enable input (actually works) # Switch to physical enable signal self.cfgEnableScheme.set(0).wait() # Trigger modes @@ -611,6 +613,7 @@ class GigaFrostCamera(PSIDetectorBase): self.trigger_mode = "auto" self.exposure_mode = "timer" elif acq_mode == "soft": + # NOTE: Fede's configuration for continous streaming # Switch to physical enable signal self.cfgEnableScheme.set(0).wait() # Set enable signal to always @@ -630,6 +633,7 @@ class GigaFrostCamera(PSIDetectorBase): self.cfgCntStartBit.set(1).wait() self.cfgCntEndBit.set(0).wait() elif acq_mode in ["ext", "external"]: + # NOTE: Untested # Switch to physical enable signal self.cfgEnableScheme.set(0).wait() # Set enable signal to always -- 2.49.1 From f6fecfdc3fc40360f701c18d758ee42fa3870b75 Mon Sep 17 00:00:00 2001 From: gac-x05la Date: Mon, 17 Feb 2025 17:46:52 +0100 Subject: [PATCH 04/13] WIP with Fede's scans --- .../aerotech/AerotechDriveDataCollection.py | 10 +- tomcat_bec/scans/__init__.py | 2 +- tomcat_bec/scans/tutorial_fly_scan.py | 114 ++++++++++++------ tomcat_bec/scripts/scans_fede.py | 82 +++++++++++-- 4 files changed, 162 insertions(+), 46 deletions(-) diff --git a/tomcat_bec/devices/aerotech/AerotechDriveDataCollection.py b/tomcat_bec/devices/aerotech/AerotechDriveDataCollection.py index 1489a02..d8087df 100644 --- a/tomcat_bec/devices/aerotech/AerotechDriveDataCollection.py +++ b/tomcat_bec/devices/aerotech/AerotechDriveDataCollection.py @@ -36,6 +36,10 @@ class AerotechDriveDataCollectionMixin(CustomDeviceMixin): # NOTE: Scans don't have to fully configure the device if "ddc_trigger" in scanargs: d["ddc_trigger"] = scanargs["ddc_trigger"] + if "ddc_source0" in scanargs: + d["ddc_source0"] = scanargs["ddc_source0"] + if "ddc_source1" in scanargs: + d["ddc_source1"] = scanargs["ddc_source1"] if "ddc_num_points" in scanargs and scanargs["ddc_num_points"] is not None: d["num_points_total"] = scanargs["ddc_num_points"] @@ -64,8 +68,7 @@ class AerotechDriveDataCollectionMixin(CustomDeviceMixin): # Stage the data collection if not in internally launced mode # NOTE: Scripted scans start acquiring from the scrits - if self.parent.scaninfo.scan_type not in ("script", "scripted"): - self.parent.bluestage() + self.parent.bluestage() def on_unstage(self): """Standard bluesky unstage""" @@ -115,7 +118,7 @@ class aa1AxisDriveDataCollection(PSIDeviceBase): _buffer1 = Component(EpicsSignalRO, "BUFFER1", auto_monitor=True, kind=Kind.normal) custom_prepare_cls = AerotechDriveDataCollectionMixin - USER_ACCESS = ["configure", "reset"] + USER_ACCESS = ["configure", "reset", "collect"] def configure(self, d: dict = None) -> tuple: """Configure data capture @@ -144,6 +147,7 @@ class aa1AxisDriveDataCollection(PSIDeviceBase): def bluestage(self) -> None: """Bluesky-style stage""" + self._switch.set("ResetRB", settle_time=0.1).wait() self._switch.set("Start", settle_time=0.2).wait() def blueunstage(self): diff --git a/tomcat_bec/scans/__init__.py b/tomcat_bec/scans/__init__.py index 4693852..ab5e70c 100644 --- a/tomcat_bec/scans/__init__.py +++ b/tomcat_bec/scans/__init__.py @@ -1,2 +1,2 @@ -from .tutorial_fly_scan import AcquireDark, AcquireWhite, AcquireRefs, TutorialFlyScanContLine +from .tutorial_fly_scan import AcquireDark, AcquireWhite, AcquireRefs, AcquireProjections, TutorialFlyScanContLine from .tomcat_scans import TomcatSnapNStep, TomcatSimpleSequence diff --git a/tomcat_bec/scans/tutorial_fly_scan.py b/tomcat_bec/scans/tutorial_fly_scan.py index 0f3586e..0f1b63f 100644 --- a/tomcat_bec/scans/tutorial_fly_scan.py +++ b/tomcat_bec/scans/tutorial_fly_scan.py @@ -30,6 +30,10 @@ class AcquireDark(Acquire): Predefined acquisition mode (default= 'default') file_path : str, optional File path for standard daq + ddc_trigger : int, optional + Drive Data Capture Trigger + ddc_source0 : int, optional + Drive Data capture Input0 Returns: ScanReport @@ -53,10 +57,10 @@ class AcquireDark(Acquire): class AcquireWhite(Acquire): scan_name = "acquire_white" - required_kwargs = ["exp_burst", "sample_position_out", "sample_angle_out"] + required_kwargs = ["exp_burst", "sample_position_out", "sample_angle_out", "motor"] gui_config = {"Acquisition parameters": ["exp_burst"]} - def __init__(self, exp_burst: int, sample_position_out: float, sample_angle_out: float, **kwargs): + def __init__(self, exp_burst: int, sample_position_out: float, sample_angle_out: float, motor: DeviceBase, **kwargs): """ Acquire flat field images. This scan is used to acquire flat field images. The flat field image is an image taken with the shutter open but the sample out of the beam. Flat field images are used to correct the data images for @@ -69,6 +73,8 @@ class AcquireWhite(Acquire): Position to move the sample stage to position the sample out of beam and take flat field images sample_angle_out : float Angular position where to take the flat field images + motor : DeviceBase + Motor to be moved to move the sample out of beam exp_time : float, optional Exposure time [ms]. If not specified, the currently configured value on the camera will be used exp_period : float, optional @@ -81,6 +87,10 @@ class AcquireWhite(Acquire): Predefined acquisition mode (default= 'default') file_path : str, optional File path for standard daq + ddc_trigger : int, optional + Drive Data Capture Trigger + ddc_source0 : int, optional + Drive Data capture Input0 Returns: ScanReport @@ -93,15 +103,16 @@ class AcquireWhite(Acquire): self.burst_at_each_point = 1 self.sample_position_out = sample_position_out self.sample_angle_out = sample_angle_out + self.motor_sample = motor - self.scan_motors = ["eyex", "eyez", "es1_roty"] # change to the correct shutter device + self.scan_motors = ["eyex", self.motor_sample, "es1_roty"] # change to the correct shutter device self.dark_shutter_pos_out = 1 ### change with a variable self.dark_shutter_pos_in = 0 ### change with a variable def scan_core(self): # open the shutter and move the sample stage to the out position - self.scan_motors = ["eyez", "es1_roty"] # change to the correct shutter device + self.scan_motors = [self.motor_sample, "es1_roty"] # change to the correct shutter device yield from self._move_scan_motors_and_wait([self.sample_position_out, self.sample_angle_out]) self.scan_motors = ["eyex"] # change to the correct shutter device yield from self._move_scan_motors_and_wait([self.dark_shutter_pos_out]) @@ -111,26 +122,31 @@ class AcquireWhite(Acquire): # TODO add closing of fast shutter yield from self._move_scan_motors_and_wait([self.dark_shutter_pos_in]) -class AcquireProjectins(Acquire): +class AcquireProjections(AsyncFlyScanBase): scan_name = "acquire_projections" - required_kwargs = ["exp_burst", "sample_position_in", "start_position", "angular_range"] + required_kwargs = ["motor", "exp_burst", "sample_position_in", "start_angle", "angular_range"] gui_config = {"Acquisition parameters": ["exp_burst"]} def __init__(self, + motor: DeviceBase, exp_burst: int, sample_position_in: float, - start_position: float, + start_angle: float, angular_range: float, **kwargs): """ Acquire projection images. Args: + motor : + + motor : DeviceBase + Motor to move continuously from start to stop position exp_burst : int Number of flat field images to acquire (no default) sample_position_in : float Position to move the sample stage to position the sample in the beam - start_position : float + start_angle : float Angular start position for the scan angular_range : float Angular range @@ -146,36 +162,81 @@ class AcquireProjectins(Acquire): Predefined acquisition mode (default= 'default') file_path : str, optional File path for standard daq + ddc_trigger : int, optional + Drive Data Capture Trigger + ddc_source0 : int, optional + Drive Data capture Input0 Returns: ScanReport Examples: - >>> scans.acquire_white(5, 20) + >>> scans.acquire_projections() """ super().__init__(**kwargs) + self.motor = motor self.burst_at_each_point = 1 self.sample_position_in = sample_position_in - self.start_position = start_position + self.start_angle = start_angle self.angular_range = angular_range - self.scan_motors = ["eyex", "eyez", "es1_roty"] # change to the correct shutter device + #self.scan_motors = ["eyex", "eyez", "es1_roty"] # change to the correct shutter device self.dark_shutter_pos_out = 1 ### change with a variable self.dark_shutter_pos_in = 0 ### change with a variable + def prepare_positions(self): + self.positions = np.array([[self.start_angle], [self.start_angle+self.angular_range]]) + self.num_pos = None + yield from self._set_position_offset() + def scan_core(self): - # open the shutter and move the sample stage to the out position - self.scan_motors = ["eyez", "es1_roty"] # change to the correct shutter device - yield from self._move_scan_motors_and_wait([self.sample_position_out, self.sample_angle_out]) + + # move to in position and go to start position + self.scan_motors = ["eyez", self.motor] + yield from self._move_scan_motors_and_wait([self.sample_position_in, self.positions[0][0]]) + + # open the shutter self.scan_motors = ["eyex"] # change to the correct shutter device yield from self._move_scan_motors_and_wait([self.dark_shutter_pos_out]) # TODO add opening of fast shutter - yield from super().scan_core() + + # start the flyer + # flyer_request = yield from self.stubs.set_with_response( + # device=self.motor, value=self.positions[1][0] + # ) + flyer_request = yield from self.stubs.set( + device=self.motor, value=self.positions[1][0], wait=True + ) + + self.connector.send_client_info( + "Starting the scan", show_asap=True, rid=self.metadata.get("RID") + ) - # TODO add closing of fast shutter - yield from self._move_scan_motors_and_wait([self.dark_shutter_pos_in]) + # send a trigger +# yield from self.stubs.trigger(group="trigger", point_id=self.point_id) + yield from self.stubs.trigger(wait=False) + while True: + # read the data + # yield from self.stubs.read_and_wait( + # group="primary", wait_group="readout_primary", point_id=self.point_id + # ) + yield from self.stubs.read( + device=self.motor, point_id=self.point_id, wait=True + ) + time.sleep(1) + + if self.stubs.request_is_completed(flyer_request): + # stop the scan if the motor has reached the stop position + break + + # increase the point id + self.point_id += 1 + + def finalize(self): + yield from super().finalize() + self.num_pos = self.point_id + 1 class AcquireRefs(Acquire): @@ -192,14 +253,6 @@ class AcquireRefs(Acquire): sample_position_out: float = 5000, file_prefix_dark: str = 'tmp_dark', file_prefix_white: str = 'tmp_white', - exp_time: float = 0, - exp_period: float = 0, - image_width: int = 2016, - image_height: int = 2016, - acq_mode: str = 'default', - file_path: str = 'tmp', - nr_writers: int = 2, - base_path: str = 'tmp', **kwargs ): """ @@ -247,14 +300,6 @@ class AcquireRefs(Acquire): self.num_flats = num_flats self.file_prefix_dark = file_prefix_dark self.file_prefix_white = file_prefix_white - self.exp_time = exp_time - self.exp_period = exp_period - self.image_width = image_width - self.image_height = image_height - self.acq_mode = acq_mode - self.file_path = file_path - self.nr_writers = nr_writers - self.base_path = base_path def scan_core(self): @@ -270,8 +315,9 @@ class AcquireRefs(Acquire): exp_burst=self.num_darks, file_prefix=self.file_prefix_dark, device_manager=self.device_manager, - metadata=self.metadata + metadata=self.metadata, ) + yield from darks.scan_core() self.point_id = darks.point_id diff --git a/tomcat_bec/scripts/scans_fede.py b/tomcat_bec/scripts/scans_fede.py index a4ce07b..77d77ab 100644 --- a/tomcat_bec/scripts/scans_fede.py +++ b/tomcat_bec/scripts/scans_fede.py @@ -16,6 +16,7 @@ class Measurement: self.nimages_white = 100 self.start_angle = 0 + self.angular_range = 180 self.sample_angle_out = 0 self.sample_position_in = 0 self.sample_position_out = 1 @@ -268,13 +269,14 @@ class Measurement: else: print("Roiy: " + str(self.roiy)) print("Start angle: " + str(self.start_angle)) + print("Angular range: " + str(self.angular_range)) print("Sample angle out: " + str(self.sample_angle_out)) print("Sample position in: " + str(self.sample_position_in)) print("Sample position out: " + str(self.sample_position_out)) def acquire_darks(self,nimages_dark=None, exposure_time=None, exposure_period=None, - roix=None, roiy=None, acq_mode=None): + roix=None, roiy=None, acq_mode=None, **kwargs): """ Acquire a set of dark images with shutters closed. @@ -315,17 +317,19 @@ class Measurement: print("Handing over to 'scans.acquire_dark") scans.acquire_dark(exp_burst=self.nimages_dark, exp_time=self.exposure_time, exp_period=self.exposure_period, image_width=self.roix, image_height=self.roiy, acq_mode=acq_mode, file_path=self.file_path, nr_writers=2, base_path=self.base_path, - file_prefix=self.file_prefix) + file_prefix=self.file_prefix, ddc_trigger=4, ddc_source0=1, **kwargs) - def acquire_whites(self,nimages_white=None, sample_angle_out=None, sample_position_out=None, + def acquire_whites(self,motor="eyez", nimages_white=None, sample_angle_out=None, sample_position_out=None, exposure_time=None, exposure_period=None, - roix=None, roiy=None, acq_mode=None): + roix=None, roiy=None, acq_mode=None, **kwargs): """ Acquire a set of whites images with shutters open and sample out of beam. Parameters ---------- + motor : DeviceBase + Motor to be moved to move the sample out of beam nimages_whites : int, optional Number of white images to acquire (no default) sample_angle_out : float, optional @@ -348,6 +352,7 @@ class Measurement: m.acquire_whites(nimages_whites=100, exposure_time=5) """ + self.motor_sample = motor if nimages_white != None: self.nimages_white = nimages_white if sample_angle_out != None: @@ -367,16 +372,76 @@ class Measurement: ### TODO: camera reset print("Handing over to 'scans.acquire_whites") - scans.acquire_white(exp_burst=self.nimages_white, sample_angle_out=self.sample_angle_out, sample_position_out= self.sample_position_out, + scans.acquire_white(motor=self.motor_sample, exp_burst=self.nimages_white, sample_angle_out=self.sample_angle_out, sample_position_out= self.sample_position_out, exp_time=self.exposure_time, exp_period=self.exposure_period, image_width=self.roix, image_height=self.roiy, acq_mode=acq_mode, file_path=self.file_path, nr_writers=2, base_path=self.base_path, - file_prefix=self.file_prefix) + file_prefix=self.file_prefix, ddc_trigger=4, ddc_source0=1, **kwargs) + def acquire_projections(self, nimages=None, sample_position_in=None, + start_angle=None, angular_range=None, + exposure_time=None, exposure_period=None, + roix=None, roiy=None, acq_mode=None, **kwargs): + """ + Acquire a set of whites images with shutters open and sample out of beam. + + Parameters + ---------- + nimages : int, optional + Number of projection images to acquire (no default) + sample_position_in : float, optional + Sample stage X position for sample in the beam [um] + start_angle : float, optional + Starting angular position [deg] + angular_range : float, optional + Angular range [deg] + exposure_time : float, optional + Exposure time [ms]. If not specified, the currently configured value on the camera will be used + exposure_period : float, optional + Exposure period [ms] + roix : int, optional + ROI size in the x-direction [pixels] + roiy : int, optional + ROI size in the y-direction [pixels] + acq_mode : str, optional + Predefined acquisition mode (default=None) + + Example: + -------- + m.acquire_projections(nimages_projections=100, exposure_time=5) + """ + + if nimages != None: + self.nimages = nimages + if sample_position_in != None: + self.sample_position_in = sample_position_in + if start_angle != None: + self.start_angle = start_angle + if angular_range != None: + self.angular_range = angular_range + if exposure_time != None: + self.exposure_time = exposure_time + if exposure_period != None: + self.exposure_period = exposure_period + if roix != None: + self.roix = roix + if roiy != None: + self.roiy = roiy + + self.build_filename(acquisition_type='data') + + ### TODO: camera reset + print("Handing over to 'scans.acquire_projections") + scans.acquire_projections(motor="es1_roty", exp_burst=self.nimages, sample_position_in= self.sample_position_in, + start_angle = self.start_angle, angular_range = self.angular_range, + exp_time=self.exposure_time, exp_period=self.exposure_period, image_width=self.roix, + image_height=self.roiy, acq_mode=acq_mode, file_path=self.file_path, nr_writers=2, + base_path=self.base_path,file_prefix=self.file_prefix, ddc_trigger=4, ddc_source0=1, **kwargs) + def acquire_refs(self,nimages_dark=None, nimages_white=None, sample_angle_out=None, sample_position_in=None, sample_position_out=None, exposure_time=None, exposure_period=None, - roix=None, roiy=None, acq_mode=None): + roix=None, roiy=None, acq_mode=None, **kwargs): """ Acquire reference images (darks + whites) and return to beam position. @@ -441,4 +506,5 @@ class Measurement: sample_position_in=self.sample_position_in, sample_position_out=self.sample_position_out, exp_time=self.exposure_time, exp_period=self.exposure_period, image_width=self.roix, image_height=self.roiy, acq_mode='default', file_path=self.file_path, nr_writers=2, base_path=self.base_path, - file_prefix_dark=file_prefix_dark, file_prefix_white=file_prefix_white) \ No newline at end of file + file_prefix_dark=file_prefix_dark, file_prefix_white=file_prefix_white, + ddc_trigger=4, ddc_source0=1, **kwargs) \ No newline at end of file -- 2.49.1 From 9d104173bd9aecf0bb98f18e1591b2b08ecc3a43 Mon Sep 17 00:00:00 2001 From: gac-x05la Date: Tue, 18 Feb 2025 18:50:04 +0100 Subject: [PATCH 05/13] Work on fede scans --- tomcat_bec/devices/gigafrost/stddaq_client.py | 5 + tomcat_bec/scans/tutorial_fly_scan.py | 103 +++++++++--------- tomcat_bec/scripts/scans_fede.py | 36 +++++- 3 files changed, 91 insertions(+), 53 deletions(-) diff --git a/tomcat_bec/devices/gigafrost/stddaq_client.py b/tomcat_bec/devices/gigafrost/stddaq_client.py index 34006b2..8bb8209 100644 --- a/tomcat_bec/devices/gigafrost/stddaq_client.py +++ b/tomcat_bec/devices/gigafrost/stddaq_client.py @@ -49,6 +49,10 @@ class StdDaqMixin(CustomDeviceMixin): d["image_height"] = scanargs["image_height"] if "nr_writers" in scanargs and scanargs["nr_writers"] is not None: d["nr_writers"] = scanargs["nr_writers"] + if "system_config" in scanargs and scanargs["system_config"] is not None: + if scanargs['system_config']['file_directory']: + file_directory = scanargs['system_config']['file_directory'] + ### to be used in the future to substitute the procedure using file path if "file_path" in scanargs and scanargs["file_path"] is not None: self.parent.file_path.set(scanargs["file_path"].replace("data", "gpfs")).wait() print(scanargs["file_path"]) @@ -428,6 +432,7 @@ class StdDaqClient(PSIDeviceBase): return result status = SubscriptionStatus(self.runstatus, is_running, settle_time=0.5) + # status.set_finished() return status def get_daq_config(self, update=False) -> dict: diff --git a/tomcat_bec/scans/tutorial_fly_scan.py b/tomcat_bec/scans/tutorial_fly_scan.py index 0f1b63f..2672f4b 100644 --- a/tomcat_bec/scans/tutorial_fly_scan.py +++ b/tomcat_bec/scans/tutorial_fly_scan.py @@ -57,7 +57,6 @@ class AcquireDark(Acquire): class AcquireWhite(Acquire): scan_name = "acquire_white" - required_kwargs = ["exp_burst", "sample_position_out", "sample_angle_out", "motor"] gui_config = {"Acquisition parameters": ["exp_burst"]} def __init__(self, exp_burst: int, sample_position_out: float, sample_angle_out: float, motor: DeviceBase, **kwargs): @@ -111,20 +110,20 @@ class AcquireWhite(Acquire): def scan_core(self): - # open the shutter and move the sample stage to the out position - self.scan_motors = [self.motor_sample, "es1_roty"] # change to the correct shutter device - yield from self._move_scan_motors_and_wait([self.sample_position_out, self.sample_angle_out]) - self.scan_motors = ["eyex"] # change to the correct shutter device - yield from self._move_scan_motors_and_wait([self.dark_shutter_pos_out]) + # move the sample stage to the out position and correct angular position + status_sample_out_angle = yield from self.stubs.set(device=[self.motor_sample, "es1_roty"], value=[self.sample_position_out, self.sample_angle_out], wait=False) + # open the main shutter (TODO change to the correct shutter device) + yield from self.stubs.set(device=["eyex"], value=[self.dark_shutter_pos_out]) + status_sample_out_angle.wait() # TODO add opening of fast shutter + yield from super().scan_core() # TODO add closing of fast shutter - yield from self._move_scan_motors_and_wait([self.dark_shutter_pos_in]) + yield from self.stubs.set(device=["eyex"], value=[self.dark_shutter_pos_in]) class AcquireProjections(AsyncFlyScanBase): scan_name = "acquire_projections" - required_kwargs = ["motor", "exp_burst", "sample_position_in", "start_angle", "angular_range"] gui_config = {"Acquisition parameters": ["exp_burst"]} def __init__(self, @@ -138,8 +137,6 @@ class AcquireProjections(AsyncFlyScanBase): Acquire projection images. Args: - motor : - motor : DeviceBase Motor to move continuously from start to stop position exp_burst : int @@ -174,83 +171,69 @@ class AcquireProjections(AsyncFlyScanBase): >>> scans.acquire_projections() """ - super().__init__(**kwargs) self.motor = motor + super().__init__(**kwargs) + self.burst_at_each_point = 1 self.sample_position_in = sample_position_in self.start_angle = start_angle self.angular_range = angular_range - #self.scan_motors = ["eyex", "eyez", "es1_roty"] # change to the correct shutter device self.dark_shutter_pos_out = 1 ### change with a variable self.dark_shutter_pos_in = 0 ### change with a variable + def update_scan_motors(self): + return [self.motor] + def prepare_positions(self): self.positions = np.array([[self.start_angle], [self.start_angle+self.angular_range]]) self.num_pos = None yield from self._set_position_offset() - def scan_core(self): - # move to in position and go to start position - self.scan_motors = ["eyez", self.motor] - yield from self._move_scan_motors_and_wait([self.sample_position_in, self.positions[0][0]]) + # move to in position and go to start angular position + yield from self.stubs.set(device=["eyez", self.motor], value=[self.sample_position_in, self.positions[0][0]]) # open the shutter - self.scan_motors = ["eyex"] # change to the correct shutter device - yield from self._move_scan_motors_and_wait([self.dark_shutter_pos_out]) + yield from self.stubs.set(device="eyex", value=self.dark_shutter_pos_out) # TODO add opening of fast shutter # start the flyer - # flyer_request = yield from self.stubs.set_with_response( - # device=self.motor, value=self.positions[1][0] - # ) flyer_request = yield from self.stubs.set( - device=self.motor, value=self.positions[1][0], wait=True + device=self.motor, value=self.positions[1][0], wait=False ) self.connector.send_client_info( "Starting the scan", show_asap=True, rid=self.metadata.get("RID") - ) - - # send a trigger -# yield from self.stubs.trigger(group="trigger", point_id=self.point_id) - yield from self.stubs.trigger(wait=False) - while True: - # read the data - # yield from self.stubs.read_and_wait( - # group="primary", wait_group="readout_primary", point_id=self.point_id - # ) + ) + + yield from self.stubs.trigger() + while not flyer_request.done: + yield from self.stubs.read( - device=self.motor, point_id=self.point_id, wait=True + group="monitored", point_id=self.point_id ) time.sleep(1) - if self.stubs.request_is_completed(flyer_request): - # stop the scan if the motor has reached the stop position - break - # increase the point id self.point_id += 1 - def finalize(self): - yield from super().finalize() - self.num_pos = self.point_id + 1 + self.num_pos = self.point_id class AcquireRefs(Acquire): scan_name = "acquire_refs" - required_kwargs = [] gui_config = {} def __init__( self, + motor: DeviceBase, num_darks: int = 0, num_flats: int = 0, sample_angle_out: float = 0, sample_position_in: float = 0, - sample_position_out: float = 5000, + sample_position_out: float = 1, file_prefix_dark: str = 'tmp_dark', file_prefix_white: str = 'tmp_white', **kwargs @@ -262,24 +245,30 @@ class AcquireRefs(Acquire): the sample is returned to the sample_in_position afterwards. Args: + motor : DeviceBase + Motor to be moved to move the sample out of beam num_darks : int , optional Number of dark field images to acquire num_flats : int , optional Number of white field images to acquire + sample_angle_out : float , optional + Angular position where to take the flat field images sample_position_in : float , optional Sample stage X position for sample in beam [um] sample_position_out : float ,optional Sample stage X position for sample out of the beam [um] - sample_angle_out : float , optional - Angular position where to take the flat field images exp_time : float, optional - Exposure time [ms]. If not specified, the currently configured value on the camera will be used + Exposure time [ms]. If not specified, the currently configured value + on the camera will be used exp_period : float, optional - Exposure period [ms]. If not specified, the currently configured value on the camera will be used + Exposure period [ms]. If not specified, the currently configured value + on the camera will be used image_width : int, optional - ROI size in the x-direction [pixels]. If not specified, the currently configured value on the camera will be used + ROI size in the x-direction [pixels]. If not specified, the currently + configured value on the camera will be used image_height : int, optional - ROI size in the y-direction [pixels]. If not specified, the currently configured value on the camera will be used + ROI size in the y-direction [pixels]. If not specified, the currently + configured value on the camera will be used acq_mode : str, optional Predefined acquisition mode (default= 'default') file_path : str, optional @@ -292,6 +281,7 @@ class AcquireRefs(Acquire): >>> scans.acquire_refs(sample_angle_out=90, sample_position_in=10, num_darks=5, num_flats=5, exp_time=0.1) """ + self.motor = motor super().__init__(**kwargs) self.sample_position_in = sample_position_in self.sample_position_out = sample_position_out @@ -303,24 +293,30 @@ class AcquireRefs(Acquire): def scan_core(self): - ## TODO move sample in position and do not wait - ## TODO move angle in position and do not wait + status_sample_out_angle = yield from self.stubs.set(device=[self.motor, "es1_roty"], value=[self.sample_position_out, self.sample_angle_out], wait=False) + if self.num_darks: self.connector.send_client_info( f"Acquiring {self.num_darks} dark images", show_asap=True, rid=self.metadata.get("RID"), ) + + # to set signals on a device + yield from self.stubs.send_rpc_and_wait("gfdaq", "file_prefix.set", self.file_prefix_dark) + yield from self.stubs.send_rpc_and_wait("gfdaq", "num_images.set", self.num_darks) darks = AcquireDark( exp_burst=self.num_darks, - file_prefix=self.file_prefix_dark, device_manager=self.device_manager, metadata=self.metadata, + instruction_handler=self.stubs._instruction_handler, + **self.caller_kwargs, ) yield from darks.scan_core() self.point_id = darks.point_id - + + status_sample_out_angle.wait() if self.num_flats: self.connector.send_client_info( f"Acquiring {self.num_flats} flat field images", @@ -331,9 +327,12 @@ class AcquireRefs(Acquire): exp_burst=self.num_flats, sample_position_out=self.sample_position_out, sample_angle_out=self.sample_angle_out, + motor=self.motor, file_prefix=self.file_prefix_white, device_manager=self.device_manager, metadata=self.metadata, + instruction_handler=self.stubs._instruction_handler, + **self.caller_kwargs, ) flats.point_id = self.point_id yield from flats.scan_core() diff --git a/tomcat_bec/scripts/scans_fede.py b/tomcat_bec/scripts/scans_fede.py index 77d77ab..5cb4abc 100644 --- a/tomcat_bec/scripts/scans_fede.py +++ b/tomcat_bec/scripts/scans_fede.py @@ -44,6 +44,10 @@ class Measurement: self.exposure_period = self.det.cfgFramerate.get() self.roix = self.det.cfgRoiX.get() self.roiy = self.det.cfgRoiY.get() + + self.get_position_rb() + #self.position_rb = False + #self.disable_position_rb_device() def build_filename(self, acquisition_type='data'): @@ -75,7 +79,8 @@ class Measurement: exposure_period=None, roix=None, roiy=None,nimages=None, nimages_dark=None, nimages_white=None, start_angle=None, sample_angle_out=None, - sample_position_in=None, sample_position_out=None): + sample_position_in=None, sample_position_out=None, + position_rb=None): """ Reconfigure the measurement with any number of new parameter @@ -111,6 +116,8 @@ class Measurement: sample_position_out : float, optional Sample stage X position for sample out of the beam [um] (default = None) + position_rb : bool, optional + Enable position readback (default = None) """ if sample_name != None: @@ -139,6 +146,13 @@ class Measurement: self.sample_position_in = sample_position_in if sample_position_out != None: self.sample_position_out = sample_position_out + if position_rb != None: + if position_rb == True: + self.enable_position_rb_device() + elif position_rb == False: + self.disable_position_rb_device() + else: + print("WARNING! Position readback should be either True, False or None") self.build_filename() @@ -169,6 +183,12 @@ class Measurement: self.device_name = self.enabled_detectors[0].name self.build_filename() + def disable_position_rb_device(self): + + "Disable position readback device" + + dev.es1_ddaq.enabled= False + self.position_rb = False def enable_detector(self, detector_name): """ @@ -198,7 +218,12 @@ class Measurement: self.device_name = self.enabled_detectors[0].name self.build_filename() + def enable_position_rb_device(self): + "Enable position readback device" + + dev.es1_ddaq.enabled= True + self.position_rb = True def get_available_detectors(self): """ @@ -216,6 +241,14 @@ class Measurement: """ self.enabled_detectors = [obj for obj in dev.get_devices_with_tags('camera') if obj.enabled] + def get_position_rb(self): + """ + Get position rb + """ + if dev.es1_ddaq.enabled == True: + self.position_rb = True + else: + self.position_rb = False def show_available_detectors(self): """ @@ -273,6 +306,7 @@ class Measurement: print("Sample angle out: " + str(self.sample_angle_out)) print("Sample position in: " + str(self.sample_position_in)) print("Sample position out: " + str(self.sample_position_out)) + print("Position readback: " + str(self.position_rb)) def acquire_darks(self,nimages_dark=None, exposure_time=None, exposure_period=None, -- 2.49.1 From 0bc3778d3f1c05c7d58f4ff796bce28b73a0d174 Mon Sep 17 00:00:00 2001 From: gac-x05la Date: Wed, 19 Feb 2025 10:18:57 +0100 Subject: [PATCH 06/13] Before redeployment --- .../device_configs/microxas_test_bed.yaml | 2 +- tomcat_bec/scans/tomcat_scans.py | 4 +- tomcat_bec/scans/tutorial_fly_scan.py | 53 ++++++++++--------- tomcat_bec/scripts/scans_fede.py | 47 ++++++++-------- 4 files changed, 57 insertions(+), 49 deletions(-) diff --git a/tomcat_bec/device_configs/microxas_test_bed.yaml b/tomcat_bec/device_configs/microxas_test_bed.yaml index 2c8a479..c62626f 100644 --- a/tomcat_bec/device_configs/microxas_test_bed.yaml +++ b/tomcat_bec/device_configs/microxas_test_bed.yaml @@ -71,7 +71,7 @@ es1_tasks: prefix: 'X02DA-ES1-SMP1:TASK:' deviceTags: - es1 - enabled: true + enabled: false onFailure: buffer readOnly: false readoutPriority: monitored diff --git a/tomcat_bec/scans/tomcat_scans.py b/tomcat_bec/scans/tomcat_scans.py index 3c830b2..e13c4de 100644 --- a/tomcat_bec/scans/tomcat_scans.py +++ b/tomcat_bec/scans/tomcat_scans.py @@ -35,7 +35,7 @@ class TomcatSnapNStep(AsyncFlyScanBase): """ scan_name = "tomcatsnapnstepscan" - scan_type = "scripted" + # scan_type = "scripted" # arg_input = {"camera" : ScanArgType.DEVICE, # "exp_time" : ScanArgType.FLOAT} # arg_bundle_size= {"bundle": len(arg_input), "min": 1, "max": None} @@ -165,7 +165,7 @@ class TomcatSimpleSequence(AsyncFlyScanBase): """ scan_name = "tomcatsimplesequencescan" - scan_type = "scripted" + # scan_type = "scripted" scan_report_hint = "table" required_kwargs = ["scan_start", "gate_high", "gate_low"] gui_config = { diff --git a/tomcat_bec/scans/tutorial_fly_scan.py b/tomcat_bec/scans/tutorial_fly_scan.py index 2672f4b..c2b0f56 100644 --- a/tomcat_bec/scans/tutorial_fly_scan.py +++ b/tomcat_bec/scans/tutorial_fly_scan.py @@ -104,7 +104,6 @@ class AcquireWhite(Acquire): self.sample_angle_out = sample_angle_out self.motor_sample = motor - self.scan_motors = ["eyex", self.motor_sample, "es1_roty"] # change to the correct shutter device self.dark_shutter_pos_out = 1 ### change with a variable self.dark_shutter_pos_in = 0 ### change with a variable @@ -209,6 +208,7 @@ class AcquireProjections(AsyncFlyScanBase): ) yield from self.stubs.trigger() + while not flyer_request.done: yield from self.stubs.read( @@ -301,10 +301,11 @@ class AcquireRefs(Acquire): show_asap=True, rid=self.metadata.get("RID"), ) - + # to set signals on a device yield from self.stubs.send_rpc_and_wait("gfdaq", "file_prefix.set", self.file_prefix_dark) - yield from self.stubs.send_rpc_and_wait("gfdaq", "num_images.set", self.num_darks) +# yield from self.stubs.send_rpc_and_wait("gfdaq", "num_images.set", self.num_darks) + darks = AcquireDark( exp_burst=self.num_darks, device_manager=self.device_manager, @@ -317,28 +318,30 @@ class AcquireRefs(Acquire): self.point_id = darks.point_id status_sample_out_angle.wait() - if self.num_flats: - self.connector.send_client_info( - f"Acquiring {self.num_flats} flat field images", - show_asap=True, - rid=self.metadata.get("RID"), - ) - flats = AcquireWhite( - exp_burst=self.num_flats, - sample_position_out=self.sample_position_out, - sample_angle_out=self.sample_angle_out, - motor=self.motor, - file_prefix=self.file_prefix_white, - device_manager=self.device_manager, - metadata=self.metadata, - instruction_handler=self.stubs._instruction_handler, - **self.caller_kwargs, - ) - flats.point_id = self.point_id - yield from flats.scan_core() - self.point_id = flats.point_id - ## TODO move sample in beam and do not wait - ## TODO move rotation to angle and do not wait + # if self.num_flats: + # self.connector.send_client_info( + # f"Acquiring {self.num_flats} flat field images", + # show_asap=True, + # rid=self.metadata.get("RID"), + # ) + # yield from self.stubs.send_rpc_and_wait("gfdaq", "file_prefix.set", self.file_prefix_white) + # yield from self.stubs.send_rpc_and_wait("gfdaq", "num_images.set", self.num_flats) + + # flats = AcquireWhite( + # exp_burst=self.num_flats, + # #sample_position_out=self.sample_position_out, + # #sample_angle_out=self.sample_angle_out, + # #motor=self.motor, + # device_manager=self.device_manager, + # metadata=self.metadata, + # instruction_handler=self.stubs._instruction_handler, + # **self.caller_kwargs, + # ) + # flats.point_id = self.point_id + # yield from flats.scan_core() + # self.point_id = flats.point_id + # ## TODO move sample in beam and do not wait + # ## TODO move rotation to angle and do not wait class TutorialFlyScanContLine(AsyncFlyScanBase): diff --git a/tomcat_bec/scripts/scans_fede.py b/tomcat_bec/scripts/scans_fede.py index 5cb4abc..d1682e6 100644 --- a/tomcat_bec/scripts/scans_fede.py +++ b/tomcat_bec/scripts/scans_fede.py @@ -276,37 +276,37 @@ class Measurement: TODO: make it work for multiple devices """ - print("Sample name: " + self.sample_name) - print("Data path: " + self.data_path) - print("Number of images: " + str(self.nimages)) - print("Number of darks: " + str(self.nimages_dark)) - print("Number of flats: " + str(self.nimages_white)) + print("Sample name (sample_name): " + self.sample_name) + print("Data path (data_path): " + self.data_path) + print("Number of images (nimages): " + str(self.nimages)) + print("Number of darks (nimages_dark): " + str(self.nimages_dark)) + print("Number of flats (nimages_flat): " + str(self.nimages_white)) if self.exposure_time == None: - print("Exposure time: " + str(self.det.cfgExposure.get())) + print("Exposure time (exposure_time): " + str(self.det.cfgExposure.get())) self.exposure_time = self.det.cfgExposure.get() else: - print("Exposure time: " + str(self.exposure_time)) + print("Exposure time (exposure_time): " + str(self.exposure_time)) if self.exposure_period == None: - print("Exposure period: " + str(self.det.cfgFramerate.get())) + print("Exposure period (exposure_period): " + str(self.det.cfgFramerate.get())) self.exposure_period = self.det.cfgFramerate.get() else: - print("Exposure period: " + str(self.exposure_period)) + print("Exposure period (exposure_period): " + str(self.exposure_period)) if self.roix == None: - print("Roix: " + str(self.det.cfgRoiX.get())) + print("Roix (roix): " + str(self.det.cfgRoiX.get())) self.roix = self.det.cfgRoiX.get() else: - print("Roix: " + str(self.roix)) + print("Roix (roix): " + str(self.roix)) if self.roiy == None: - print("Roiy: " + str(self.det.cfgRoiY.get())) + print("Roiy (roiy): " + str(self.det.cfgRoiY.get())) self.roiy = self.det.cfgRoiY.get() else: - print("Roiy: " + str(self.roiy)) - print("Start angle: " + str(self.start_angle)) - print("Angular range: " + str(self.angular_range)) - print("Sample angle out: " + str(self.sample_angle_out)) - print("Sample position in: " + str(self.sample_position_in)) - print("Sample position out: " + str(self.sample_position_out)) - print("Position readback: " + str(self.position_rb)) + print("Roiy (roiy): " + str(self.roiy)) + print("Start angle (start_angle): " + str(self.start_angle)) + print("Angular range (angular_range): " + str(self.angular_range)) + print("Sample angle out (sample_angle_out): " + str(self.sample_angle_out)) + print("Sample position in (sample_position_in): " + str(self.sample_position_in)) + print("Sample position out (sample_position_out): " + str(self.sample_position_out)) + print("Position readback (position_rb): " + str(self.position_rb)) def acquire_darks(self,nimages_dark=None, exposure_time=None, exposure_period=None, @@ -472,7 +472,7 @@ class Measurement: base_path=self.base_path,file_prefix=self.file_prefix, ddc_trigger=4, ddc_source0=1, **kwargs) - def acquire_refs(self,nimages_dark=None, nimages_white=None, sample_angle_out=None, + def acquire_refs(self, motor="eyez", nimages_dark=None, nimages_white=None, sample_angle_out=None, sample_position_in=None, sample_position_out=None, exposure_time=None, exposure_period=None, roix=None, roiy=None, acq_mode=None, **kwargs): @@ -484,6 +484,8 @@ class Measurement: Parameters ---------- + motor : DeviceBase + Motor to be moved to move the sample out of beam darks : int, optional Number of dark images to acquire (no default) nimages_whites : int, optional @@ -534,9 +536,12 @@ class Measurement: self.build_filename(acquisition_type='white') file_prefix_white = self.file_prefix + print(file_prefix_dark) + print(file_prefix_white) + ### TODO: camera reset print("Handing over to 'scans.acquire_refs") - scans.acquire_refs(num_darks=self.nimages_dark, num_flats=self.nimages_white, sample_angle_out=self.sample_angle_out, + scans.acquire_refs(motor=motor, num_darks=self.nimages_dark, num_flats=self.nimages_white, sample_angle_out=self.sample_angle_out, sample_position_in=self.sample_position_in, sample_position_out=self.sample_position_out, exp_time=self.exposure_time, exp_period=self.exposure_period, image_width=self.roix, image_height=self.roiy, acq_mode='default', file_path=self.file_path, nr_writers=2, base_path=self.base_path, -- 2.49.1 From 76cf6ac4472c22bf21f058ee2aa24658cad1a865 Mon Sep 17 00:00:00 2001 From: gac-x05la Date: Wed, 19 Feb 2025 16:58:25 +0100 Subject: [PATCH 07/13] New additions from klaus --- tomcat_bec/devices/aerotech/AerotechTasks.py | 14 +----- .../devices/gigafrost/gigafrostcamera.py | 48 +++++++++---------- tomcat_bec/scans/tomcat_scans.py | 10 +++- tomcat_bec/scans/tutorial_fly_scan.py | 9 +++- 4 files changed, 41 insertions(+), 40 deletions(-) diff --git a/tomcat_bec/devices/aerotech/AerotechTasks.py b/tomcat_bec/devices/aerotech/AerotechTasks.py index 74b0b74..da8c5d7 100644 --- a/tomcat_bec/devices/aerotech/AerotechTasks.py +++ b/tomcat_bec/devices/aerotech/AerotechTasks.py @@ -33,20 +33,10 @@ class AerotechTasksMixin(CustomDeviceMixin): PSO is not needed or when it'd conflict with other devices. """ # Fish out our configuration from scaninfo (via explicit or generic addressing) - d = {} - if "kwargs" in self.parent.scaninfo.scan_msg.info: - scanargs = self.parent.scaninfo.scan_msg.info["kwargs"] - if self.parent.scaninfo.scan_type in ("script", "scripted"): - # NOTE: Scans don't have to fully configure the device - if "script_text" in scanargs and scanargs["script_text"] is not None: - d["script_text"] = scanargs["script_text"] - if "script_file" in scanargs and scanargs["script_file"] is not None: - d["script_file"] = scanargs["script_file"] - if "script_task" in scanargs and scanargs["script_task"] is not None: - d["script_task"] = scanargs["script_task"] + d = self.parent.scan_info.scan_msg.scan_parameters.get("aerotech_config") # Perform bluesky-style configuration - if len(d) > 0: + if d: # logger.warning(f"[{self.parent.name}] Configuring with:\n{d}") self.parent.configure(d=d) diff --git a/tomcat_bec/devices/gigafrost/gigafrostcamera.py b/tomcat_bec/devices/gigafrost/gigafrostcamera.py index ef265d7..0e86170 100644 --- a/tomcat_bec/devices/gigafrost/gigafrostcamera.py +++ b/tomcat_bec/devices/gigafrost/gigafrostcamera.py @@ -6,6 +6,7 @@ Created on Thu Jun 27 17:28:43 2024 @author: mohacsi_i """ +import copy from time import sleep from ophyd import Signal, Component, EpicsSignal, EpicsSignalRO, Kind, DeviceStatus from ophyd.device import DynamicDeviceComponent @@ -164,26 +165,25 @@ class GigaFrostCameraMixin(CustomDetectorMixin): ) # Fish out our configuration from scaninfo (via explicit or generic addressing) - scanparam = self.parent.scaninfo.scan_msg.info + scan_msg = self.parent.scaninfo.scan_msg + scanargs = {**scan_msg.request_inputs["inputs"], **scan_msg.request_inputs["kwargs"]} d = {} - if "kwargs" in scanparam: - scanargs = scanparam["kwargs"] - 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 "acq_time" in scanargs and scanargs["acq_time"] is not None: - d["exposure_time_ms"] = scanargs["acq_time"] - if "acq_period" in scanargs and scanargs["acq_period"] is not None: - d["exposure_period_ms"] = scanargs["acq_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 "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 "acq_time" in scanargs and scanargs["acq_time"] is not None: + d["exposure_time_ms"] = scanargs["acq_time"] + if "acq_period" in scanargs and scanargs["acq_period"] is not None: + d["exposure_period_ms"] = scanargs["acq_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" # Perform bluesky-style configuration if len(d) > 0: @@ -497,13 +497,13 @@ class GigaFrostCamera(PSIDetectorBase): def trigger(self) -> DeviceStatus: """Sends a software trigger to GigaFrost""" - super().trigger() + return super().trigger() # There's no status readback from the camera, so we just wait - sleep_time = self.cfgExposure.value * self.cfgCntNum.value * 0.001 + 0.2 - logger.warning(f"Gigafrost sleeping for {sleep_time} sec") - sleep(sleep_time) - return DeviceStatus(self, done=True, success=True, settle_time=sleep_time) + # sleep_time = self.cfgExposure.value * self.cfgCntNum.value * 0.001 + 0.2 + # logger.warning(f"Gigafrost sleeping for {sleep_time} sec") + # sleep(sleep_time) + # return DeviceStatus(self, done=True, success=True, settle_time=sleep_time) def configure(self, d: dict = None): """Configure the next scan with the GigaFRoST camera diff --git a/tomcat_bec/scans/tomcat_scans.py b/tomcat_bec/scans/tomcat_scans.py index e13c4de..964fa09 100644 --- a/tomcat_bec/scans/tomcat_scans.py +++ b/tomcat_bec/scans/tomcat_scans.py @@ -75,8 +75,14 @@ class TomcatSnapNStep(AsyncFlyScanBase): filename = "AerotechSnapAndStepTemplate.ascript" filesubs = self.get_filesubs() filetext = self.render_file(filename, filesubs) - kwargs["parameter"]["kwargs"]["script_text"] = filetext - kwargs["parameter"]["kwargs"]["script_file"] = "bec.ascript" + self.scan_parameters["aerotech_config"] = { + "script_text":filetext, + "script_file":"bec.ascript", + "script_task": 4, + } + # self.scan_parameters["script_file"] = "bec.ascript" + # kwargs["parameter"]["kwargs"]["script_text"] = filetext + # kwargs["parameter"]["kwargs"]["script_file"] = "bec.ascript" super().__init__( acq_time=acq_time, diff --git a/tomcat_bec/scans/tutorial_fly_scan.py b/tomcat_bec/scans/tutorial_fly_scan.py index c2b0f56..8ce46a0 100644 --- a/tomcat_bec/scans/tutorial_fly_scan.py +++ b/tomcat_bec/scans/tutorial_fly_scan.py @@ -123,7 +123,11 @@ class AcquireWhite(Acquire): class AcquireProjections(AsyncFlyScanBase): scan_name = "acquire_projections" - gui_config = {"Acquisition parameters": ["exp_burst"]} + gui_config = { + "Motor": ["motor"], + "Acquisition parameters": ["sample_position_in", "start_angle", "angular_range" ], + "Camera": ["exp_time", "exp_burst"] + } def __init__(self, motor: DeviceBase, @@ -131,6 +135,7 @@ class AcquireProjections(AsyncFlyScanBase): sample_position_in: float, start_angle: float, angular_range: float, + exp_time:float, **kwargs): """ Acquire projection images. @@ -171,7 +176,7 @@ class AcquireProjections(AsyncFlyScanBase): """ self.motor = motor - super().__init__(**kwargs) + super().__init__(exp_time=exp_time,**kwargs) self.burst_at_each_point = 1 self.sample_position_in = sample_position_in -- 2.49.1 From f15fd00712cb7aa984987c276e05cba57dd8baf9 Mon Sep 17 00:00:00 2001 From: gac-x05la Date: Thu, 20 Feb 2025 12:42:33 +0100 Subject: [PATCH 08/13] After session with Klaus --- tomcat_bec/devices/gigafrost/stddaq_client.py | 35 +++++++- tomcat_bec/scans/tutorial_fly_scan.py | 80 +++++++++++++------ tomcat_bec/scripts/scans_fede.py | 18 ++++- 3 files changed, 104 insertions(+), 29 deletions(-) diff --git a/tomcat_bec/devices/gigafrost/stddaq_client.py b/tomcat_bec/devices/gigafrost/stddaq_client.py index 8bb8209..5a1820b 100644 --- a/tomcat_bec/devices/gigafrost/stddaq_client.py +++ b/tomcat_bec/devices/gigafrost/stddaq_client.py @@ -13,7 +13,7 @@ import requests import os from ophyd import Signal, Component, Kind -from ophyd.status import SubscriptionStatus +from ophyd.status import SubscriptionStatus, Status from websockets.sync.client import connect, ClientConnection from websockets.exceptions import ConnectionClosedOK, ConnectionClosedError @@ -41,6 +41,9 @@ class StdDaqMixin(CustomDeviceMixin): # Fish out our configuration from scaninfo (via explicit or generic addressing) # NOTE: Scans don't have to fully configure the device d = {} + scan_parameters = self.parent.scaninfo.scan_msg.scan_parameters + std_daq_params = scan_parameters.get("std_daq_params") + if "kwargs" in self.parent.scaninfo.scan_msg.info: scanargs = self.parent.scaninfo.scan_msg.info["kwargs"] if "image_width" in scanargs and scanargs["image_width"] is not None: @@ -88,6 +91,7 @@ class StdDaqMixin(CustomDeviceMixin): if points_valid: d["num_points_total"] = num_points + # Perform bluesky-style configuration if len(d) > 0: # Configure new run (will restart the stdDAQ) @@ -97,7 +101,9 @@ class StdDaqMixin(CustomDeviceMixin): sleep(0.5) # Try to start a new run (reconnects) - self.parent.bluestage() + if std_daq_params.get("reconnect",True): + self.parent.bluestage() + # And start status monitoring self._mon = Thread(target=self.monitor, daemon=True) self._mon.start() @@ -159,6 +165,7 @@ class StdDaqClient(PSIDeviceBase): "state", "bluestage", "blueunstage", + "complete", ] _wsclient = None @@ -277,6 +284,8 @@ class StdDaqClient(PSIDeviceBase): images (limited by the ringbuffer size and backend speed). file_path: str, optional File path to save the data, usually GPFS. + file_prefix: str, optional + File prefix to save the data [default = file]. image_width : int, optional ROI size in the x-direction [pixels]. image_height : int, optional @@ -299,6 +308,10 @@ class StdDaqClient(PSIDeviceBase): # Run parameters if "num_points_total" in d: self.num_images.set(d["num_points_total"]).wait() + if "file_path" in d: + self.file_path.set(d["file_path"]).wait() + if "file_prefix" in d: + self.file_prefix.set(d["file_prefix"]).wait() # Restart the DAQ if resolution changed cfg = self.get_daq_config() @@ -322,6 +335,15 @@ class StdDaqClient(PSIDeviceBase): sleep(1) self.get_daq_config(update=True) + # def configure(self, d:dict): + # if "num_points_total" in d: + # num_points = d.pop("num_points_total") + # self.num_images.set(num_points).wait() + # super().configure(d) + # self.set_daq_config() + # sleep(1) + # self.get_daq_config(update=True) + def bluestage(self): """Stages the stdDAQ @@ -351,6 +373,7 @@ class StdDaqClient(PSIDeviceBase): file_path = self.file_path.get() num_images = self.num_images.get() + file_prefix = self.name file_prefix = self.file_prefix.get() print(file_prefix) @@ -427,12 +450,18 @@ class StdDaqClient(PSIDeviceBase): def complete(self) -> SubscriptionStatus: """Wait for current run. Must end in status 'file_saved'.""" + # Return immediately if we're detached + # TODO: Maybe check for connection (not sure if it's better) + if self.state() in ["idle", "file_saved", "error"]: + status = Status(self) + status.set_finished() + return status + def is_running(*args, value, timestamp, **kwargs): result = value in ["idle", "file_saved", "error"] return result status = SubscriptionStatus(self.runstatus, is_running, settle_time=0.5) - # status.set_finished() return status def get_daq_config(self, update=False) -> dict: diff --git a/tomcat_bec/scans/tutorial_fly_scan.py b/tomcat_bec/scans/tutorial_fly_scan.py index 8ce46a0..138026c 100644 --- a/tomcat_bec/scans/tutorial_fly_scan.py +++ b/tomcat_bec/scans/tutorial_fly_scan.py @@ -4,6 +4,11 @@ import numpy as np from bec_lib.device import DeviceBase from bec_server.scan_server.scans import Acquire, AsyncFlyScanBase +from bec_lib import bec_logger + +logger = bec_logger.logger + + class AcquireDark(Acquire): scan_name = "acquire_dark" required_kwargs = ["exp_burst"] @@ -295,6 +300,7 @@ class AcquireRefs(Acquire): self.num_flats = num_flats self.file_prefix_dark = file_prefix_dark self.file_prefix_white = file_prefix_white + self.scan_parameters["std_daq_params"] = {"reconnect": False} def scan_core(self): @@ -308,11 +314,16 @@ class AcquireRefs(Acquire): ) # to set signals on a device - yield from self.stubs.send_rpc_and_wait("gfdaq", "file_prefix.set", self.file_prefix_dark) -# yield from self.stubs.send_rpc_and_wait("gfdaq", "num_images.set", self.num_darks) + cameras = [cam.name for cam in self.device_manager.devices.get_devices_with_tags("camera") if cam.enabled] + for cam in cameras: + yield from self.stubs.send_rpc_and_wait(cam, "file_prefix.set", f"{self.file_prefix}_{cam}_dark") + yield from self.stubs.send_rpc_and_wait(cam, "num_images.set", self.num_darks) + yield from self.stubs.send_rpc_and_wait(cam, "bluestage") # rename to arm + darks = AcquireDark( exp_burst=self.num_darks, +# file_prefix=self.file_prefix_dark, device_manager=self.device_manager, metadata=self.metadata, instruction_handler=self.stubs._instruction_handler, @@ -320,33 +331,52 @@ class AcquireRefs(Acquire): ) yield from darks.scan_core() + yield from self.stubs.send_rpc_and_wait("gfdaq", "complete") + yield from self.stubs.send_rpc_and_wait("gfdaq", "unstage") self.point_id = darks.point_id status_sample_out_angle.wait() - # if self.num_flats: - # self.connector.send_client_info( - # f"Acquiring {self.num_flats} flat field images", - # show_asap=True, - # rid=self.metadata.get("RID"), - # ) - # yield from self.stubs.send_rpc_and_wait("gfdaq", "file_prefix.set", self.file_prefix_white) - # yield from self.stubs.send_rpc_and_wait("gfdaq", "num_images.set", self.num_flats) + if self.num_flats: + self.connector.send_client_info( + f"Acquiring {self.num_flats} flat field images", + show_asap=True, + rid=self.metadata.get("RID"), + ) + yield from self.stubs.send_rpc_and_wait("gfdaq", "file_prefix.set", self.file_prefix_white) + yield from self.stubs.send_rpc_and_wait("gfdaq", "num_images.set", self.num_flats) + yield from self.stubs.send_rpc_and_wait("gfdaq", "bluestage") - # flats = AcquireWhite( - # exp_burst=self.num_flats, - # #sample_position_out=self.sample_position_out, - # #sample_angle_out=self.sample_angle_out, - # #motor=self.motor, - # device_manager=self.device_manager, - # metadata=self.metadata, - # instruction_handler=self.stubs._instruction_handler, - # **self.caller_kwargs, - # ) - # flats.point_id = self.point_id - # yield from flats.scan_core() - # self.point_id = flats.point_id - # ## TODO move sample in beam and do not wait - # ## TODO move rotation to angle and do not wait + logger.warning("Calling AcquireWhite") + + flats = AcquireWhite( + exp_burst=self.num_flats, + #sample_position_out=self.sample_position_out, + #sample_angle_out=self.sample_angle_out, + #motor=self.motor, + device_manager=self.device_manager, + metadata=self.metadata, + instruction_handler=self.stubs._instruction_handler, + **self.caller_kwargs, + ) + + + + flats.point_id = self.point_id + yield from flats.scan_core() + + logger.warning("Unstaging the DAQ") + + yield from self.stubs.send_rpc_and_wait("gfdaq", "complete") + yield from self.stubs.send_rpc_and_wait("gfdaq", "unstage") + logger.warning("Completing the DAQ") + + + logger.warning("Finished the DAQ") + + self.point_id = flats.point_id + ## TODO move sample in beam and do not wait + ## TODO move rotation to angle and do not wait + logger.warning("Done with scan_core") class TutorialFlyScanContLine(AsyncFlyScanBase): diff --git a/tomcat_bec/scripts/scans_fede.py b/tomcat_bec/scripts/scans_fede.py index d1682e6..2a44f7e 100644 --- a/tomcat_bec/scripts/scans_fede.py +++ b/tomcat_bec/scripts/scans_fede.py @@ -546,4 +546,20 @@ class Measurement: exp_time=self.exposure_time, exp_period=self.exposure_period, image_width=self.roix, image_height=self.roiy, acq_mode='default', file_path=self.file_path, nr_writers=2, base_path=self.base_path, file_prefix_dark=file_prefix_dark, file_prefix_white=file_prefix_white, - ddc_trigger=4, ddc_source0=1, **kwargs) \ No newline at end of file + ddc_trigger=4, ddc_source0=1, **kwargs) + + + def start_preview(self, exposure_time=None, exposure_period=None, + preview_strategy='', preview_paramters=200, **kwargs): + """ + Start the camera in preview mode, no data will be written. + + Parameters + ---------- + exposure_time : float, optional + """ + + if exposure_time is None: + exposure_time = self.exposure_time + if exposure_period is None: + exposure_period = 50 # no need to go faster for a preview \ No newline at end of file -- 2.49.1 From 4266798e303626a27406f0acda48fa4833301ca3 Mon Sep 17 00:00:00 2001 From: gac-x05la Date: Tue, 25 Feb 2025 14:31:46 +0100 Subject: [PATCH 09/13] Bump --- .../device_configs/microxas_test_bed.yaml | 90 ++-- tomcat_bec/devices/gigafrost/stddaq_client.py | 73 ++-- .../devices/gigafrost/stddaq_preview.py | 183 +++++---- tomcat_bec/scans/__init__.py | 1 + tomcat_bec/scans/advanced_scans.py | 383 ++++++++++++++++++ 5 files changed, 562 insertions(+), 168 deletions(-) create mode 100644 tomcat_bec/scans/advanced_scans.py diff --git a/tomcat_bec/device_configs/microxas_test_bed.yaml b/tomcat_bec/device_configs/microxas_test_bed.yaml index c62626f..4087d59 100644 --- a/tomcat_bec/device_configs/microxas_test_bed.yaml +++ b/tomcat_bec/device_configs/microxas_test_bed.yaml @@ -11,6 +11,19 @@ eyex: readOnly: false softwareTrigger: false +eyey: + readoutPriority: baseline + description: X-ray eye axis Y + deviceClass: tomcat_bec.devices.psimotor.EpicsMotorEC + deviceConfig: + prefix: MTEST-X05LA-ES2-XRAYEYE:M2 + deviceTags: + - xray-eye + onFailure: buffer + enabled: true + readOnly: false + softwareTrigger: false + eyez: readoutPriority: baseline description: X-ray eye axis Z @@ -38,18 +51,18 @@ femto_mean_curr: readOnly: true softwareTrigger: false -es1_roty: - readoutPriority: monitored - description: 'Test rotation stage' - deviceClass: ophyd.EpicsMotor - deviceConfig: - prefix: X02DA-ES1-SMP1:ROTY - deviceTags: - - es1-sam - onFailure: buffer - enabled: true - readOnly: false - softwareTrigger: false +# es1_roty: +# readoutPriority: monitored +# description: 'Test rotation stage' +# deviceClass: ophyd.EpicsMotor +# deviceConfig: +# prefix: X02DA-ES1-SMP1:ROTY +# deviceTags: +# - es1-sam +# onFailure: buffer +# enabled: true +# readOnly: false +# softwareTrigger: false # es1_ismc: # description: 'Automation1 iSMC interface' @@ -64,18 +77,18 @@ es1_roty: # readoutPriority: monitored # softwareTrigger: false -es1_tasks: - description: 'Automation1 task management interface' - deviceClass: tomcat_bec.devices.aa1Tasks - deviceConfig: - prefix: 'X02DA-ES1-SMP1:TASK:' - deviceTags: - - es1 - enabled: false - onFailure: buffer - readOnly: false - readoutPriority: monitored - softwareTrigger: false +# es1_tasks: +# description: 'Automation1 task management interface' +# deviceClass: tomcat_bec.devices.aa1Tasks +# deviceConfig: +# prefix: 'X02DA-ES1-SMP1:TASK:' +# deviceTags: +# - es1 +# enabled: false +# onFailure: buffer +# readOnly: false +# readoutPriority: monitored +# softwareTrigger: false # es1_psod: @@ -92,18 +105,18 @@ es1_tasks: # softwareTrigger: true -es1_ddaq: - description: 'Automation1 position recording interface' - deviceClass: tomcat_bec.devices.aa1AxisDriveDataCollection - deviceConfig: - prefix: 'X02DA-ES1-SMP1:ROTY:DDC:' - deviceTags: - - es1 - enabled: true - onFailure: buffer - readOnly: false - readoutPriority: monitored - softwareTrigger: false +# es1_ddaq: +# description: 'Automation1 position recording interface' +# deviceClass: tomcat_bec.devices.aa1AxisDriveDataCollection +# deviceConfig: +# prefix: 'X02DA-ES1-SMP1:ROTY:DDC:' +# deviceTags: +# - es1 +# enabled: true +# onFailure: buffer +# readOnly: false +# readoutPriority: monitored +# softwareTrigger: false #camera: @@ -145,6 +158,7 @@ gfdaq: data_source_name: 'gfcam' deviceTags: - std-daq + - daq - gfcam enabled: true onFailure: buffer @@ -159,6 +173,7 @@ gf_stream0: url: 'tcp://129.129.95.111:20000' deviceTags: - std-daq + - preview - gfcam enabled: true onFailure: buffer @@ -187,8 +202,10 @@ pcodaq: deviceConfig: ws_url: 'ws://129.129.95.111:8081' rest_url: 'http://129.129.95.111:5010' + data_source_name: 'pcocam' deviceTags: - std-daq + - daq - pcocam enabled: true onFailure: buffer @@ -203,6 +220,7 @@ pco_stream0: url: 'tcp://129.129.95.111:20010' deviceTags: - std-daq + - preview - pcocam enabled: true onFailure: buffer diff --git a/tomcat_bec/devices/gigafrost/stddaq_client.py b/tomcat_bec/devices/gigafrost/stddaq_client.py index 5a1820b..82345d9 100644 --- a/tomcat_bec/devices/gigafrost/stddaq_client.py +++ b/tomcat_bec/devices/gigafrost/stddaq_client.py @@ -41,9 +41,9 @@ class StdDaqMixin(CustomDeviceMixin): # Fish out our configuration from scaninfo (via explicit or generic addressing) # NOTE: Scans don't have to fully configure the device d = {} - scan_parameters = self.parent.scaninfo.scan_msg.scan_parameters - std_daq_params = scan_parameters.get("std_daq_params") - + # scan_parameters = self.parent.scaninfo.scan_msg.scan_parameters + # std_daq_params = scan_parameters.get("std_daq_params") + if "kwargs" in self.parent.scaninfo.scan_msg.info: scanargs = self.parent.scaninfo.scan_msg.info["kwargs"] if "image_width" in scanargs and scanargs["image_width"] is not None: @@ -53,8 +53,8 @@ class StdDaqMixin(CustomDeviceMixin): if "nr_writers" in scanargs and scanargs["nr_writers"] is not None: d["nr_writers"] = scanargs["nr_writers"] if "system_config" in scanargs and scanargs["system_config"] is not None: - if scanargs['system_config']['file_directory']: - file_directory = scanargs['system_config']['file_directory'] + if scanargs["system_config"]["file_directory"]: + file_directory = scanargs["system_config"]["file_directory"] ### to be used in the future to substitute the procedure using file path if "file_path" in scanargs and scanargs["file_path"] is not None: self.parent.file_path.set(scanargs["file_path"].replace("data", "gpfs")).wait() @@ -91,7 +91,6 @@ class StdDaqMixin(CustomDeviceMixin): if points_valid: d["num_points_total"] = num_points - # Perform bluesky-style configuration if len(d) > 0: # Configure new run (will restart the stdDAQ) @@ -101,9 +100,9 @@ class StdDaqMixin(CustomDeviceMixin): sleep(0.5) # Try to start a new run (reconnects) - if std_daq_params.get("reconnect",True): - self.parent.bluestage() - + # if std_daq_params.get("reconnect", True): + self.parent.bluestage() + # And start status monitoring self._mon = Thread(target=self.monitor, daemon=True) self._mon.start() @@ -179,6 +178,7 @@ class StdDaqClient(PSIDeviceBase): file_prefix = Component(Signal, value="file", kind=Kind.config) # Configuration attributes rest_url = Component(Signal, kind=Kind.config, metadata={"write_access": False}) + datasource = Component(Signal, kind=Kind.config, metadata={"write_access": False}) cfg_detector_name = Component(Signal, kind=Kind.config) cfg_detector_type = Component(Signal, kind=Kind.config) cfg_bit_depth = Component(Signal, kind=Kind.config) @@ -213,7 +213,7 @@ class StdDaqClient(PSIDeviceBase): ) self.ws_url.set(ws_url, force=True).wait() self.rest_url.set(rest_url, force=True).wait() - self.data_source_name = data_source_name + self.datasource.set(data_source_name, force=True).wait() # Connect to the DAQ and initialize values try: @@ -335,15 +335,6 @@ class StdDaqClient(PSIDeviceBase): sleep(1) self.get_daq_config(update=True) - # def configure(self, d:dict): - # if "num_points_total" in d: - # num_points = d.pop("num_points_total") - # self.num_images.set(num_points).wait() - # super().configure(d) - # self.set_daq_config() - # sleep(1) - # self.get_daq_config(update=True) - def bluestage(self): """Stages the stdDAQ @@ -355,27 +346,12 @@ class StdDaqClient(PSIDeviceBase): if self.state() != "idle": raise RuntimeError(f"[{self.name}] stdDAQ can't stage from state: {self.state()}") - # Must make sure that image size matches the data source - if self.data_source_name is not None: - cam_img_w = self.device_manager.devices[self.data_source_name].cfgRoiX.get() - cam_img_h = self.device_manager.devices[self.data_source_name].cfgRoiY.get() - daq_img_w = self.cfg_pixel_width.get() - daq_img_h = self.cfg_pixel_height.get() - - if not (daq_img_w == cam_img_w and daq_img_h == cam_img_h): - raise RuntimeError( - f"[{self.name}] stdDAQ image resolution ({daq_img_w} , {daq_img_h}) does not match camera with ({cam_img_w} , {cam_img_h})" - ) - else: - logger.warning( - f"[{self.name}] stdDAQ image resolution ({daq_img_w} , {daq_img_h}) matches camera with ({cam_img_w} , {cam_img_h})" - ) + # Ensure expected shape + self.validate() file_path = self.file_path.get() - num_images = self.num_images.get() - file_prefix = self.name file_prefix = self.file_prefix.get() - print(file_prefix) + num_images = self.num_images.get() # New connection self._wsclient = self.connect() @@ -407,9 +383,7 @@ class StdDaqClient(PSIDeviceBase): print(f"[{self.name}] Started stdDAQ in: {reply['status']}") return - raise RuntimeError( - f"[{self.name}] Failed to start the stdDAQ in 1 tries, reason: {reply['reason']}" - ) + raise RuntimeError(f"[{self.name}] Failed to start the stdDAQ, reason: {reply['reason']}") def blueunstage(self): """Unstages the stdDAQ @@ -464,6 +438,25 @@ class StdDaqClient(PSIDeviceBase): status = SubscriptionStatus(self.runstatus, is_running, settle_time=0.5) return status + def validate(self): + """Validate camera state + + Ensure that data source shape matches with the shape expected by the stdDAQ. + """ + # Must make sure that image size matches the data source + source = self.datasource.get() + if source is not None and len(source) > 0: + if source == "gfcam": + cam_img_w = self.device_manager.devices[source].cfgRoiX.get() + cam_img_h = self.device_manager.devices[source].cfgRoiY.get() + daq_img_w = self.cfg_pixel_width.get() + daq_img_h = self.cfg_pixel_height.get() + + if not (daq_img_w == cam_img_w and daq_img_h == cam_img_h): + raise RuntimeError( + f"[{self.name}] stdDAQ image resolution ({daq_img_w} , {daq_img_h}) does not match camera with ({cam_img_w} , {cam_img_h})" + ) + def get_daq_config(self, update=False) -> dict: """Read the current configuration from the DAQ""" r = requests.get(self.rest_url.get() + "/api/config/get", params={"user": "ioc"}, timeout=2) diff --git a/tomcat_bec/devices/gigafrost/stddaq_preview.py b/tomcat_bec/devices/gigafrost/stddaq_preview.py index 3a08126..29e16f5 100644 --- a/tomcat_bec/devices/gigafrost/stddaq_preview.py +++ b/tomcat_bec/devices/gigafrost/stddaq_preview.py @@ -23,107 +23,23 @@ logger = bec_logger.logger ZMQ_TOPIC_FILTER = b'' -class StdDaqPreviewState(enum.IntEnum): - """Standard DAQ ophyd device states""" - UNKNOWN = 0 - DETACHED = 1 - MONITORING = 2 - - class StdDaqPreviewMixin(CustomDetectorMixin): """Setup class for the standard DAQ preview stream Parent class: CustomDetectorMixin """ - _mon = None - def on_stage(self): """Start listening for preview data stream""" - if self._mon is not None: - self.parent.unstage() - sleep(0.5) - - self.parent.connect() - self._stop_polling = False - self._mon = Thread(target=self.poll, daemon=True) - self._mon.start() + self.parent.arm() def on_unstage(self): """Stop a running preview""" - if self._mon is not None: - self._stop_polling = True - # Might hang on recv_multipart - self._mon.join(timeout=1) - # So also disconnect the socket - self.parent._socket.disconnect(self.parent.url.get()) + self.parent.disarm() def on_stop(self): """Stop a running preview""" self.on_unstage() - def poll(self): - """Collect streamed updates""" - self.parent.status.set(StdDaqPreviewState.MONITORING, force=True) - try: - t_last = time() - while True: - try: - # Exit loop and finish monitoring - if self._stop_polling: - logger.info(f"[{self.parent.name}]\tDetaching monitor") - break - - # pylint: disable=no-member - r = self.parent._socket.recv_multipart(flags=zmq.NOBLOCK) - - # Length and throtling checks - if len(r) != 2: - logger.warning( - f"[{self.parent.name}] Received malformed array of length {len(r)}") - t_curr = time() - t_elapsed = t_curr - t_last - if t_elapsed < self.parent.throttle.get(): - sleep(0.1) - continue - - # Unpack the Array V1 reply to metadata and array data - meta, data = r - - # Update image and update subscribers - header = json.loads(meta) - if header["type"] == "uint16": - image = np.frombuffer(data, dtype=np.uint16) - if image.size != np.prod(header['shape']): - err = f"Unexpected array size of {image.size} for header: {header}" - raise ValueError(err) - image = image.reshape(header['shape']) - - # Update image and update subscribers - self.parent.frame.put(header['frame'], force=True) - self.parent.image_shape.put(header['shape'], force=True) - self.parent.image.put(image, force=True) - self.parent._last_image = image - self.parent._run_subs(sub_type=self.parent.SUB_MONITOR, value=image) - t_last = t_curr - logger.info( - f"[{self.parent.name}] Updated frame {header['frame']}\t" - f"Shape: {header['shape']}\tMean: {np.mean(image):.3f}" - ) - except ValueError: - # Happens when ZMQ partially delivers the multipart message - pass - except zmq.error.Again: - # Happens when receive queue is empty - sleep(0.1) - except Exception as ex: - logger.info(f"[{self.parent.name}]\t{str(ex)}") - raise - finally: - self._mon = None - self.parent.status.set(StdDaqPreviewState.DETACHED, force=True) - logger.info(f"[{self.parent.name}]\tDetaching monitor") - - class StdDaqPreviewDetector(PSIDetectorBase): """Detector wrapper class around the StdDaq preview image stream. @@ -135,7 +51,7 @@ class StdDaqPreviewDetector(PSIDetectorBase): cam_widget = gui.add_dock('cam_dock1').add_widget('BECFigure').image('daq_stream1') """ # Subscriptions for plotting image - USER_ACCESS = ["kickoff", "get_last_image"] + USER_ACCESS = ["arm", "disarm", "get_last_image"] SUB_MONITOR = "device_monitor_2d" _default_sub = SUB_MONITOR @@ -144,19 +60,19 @@ class StdDaqPreviewDetector(PSIDetectorBase): # Status attributes url = Component(Signal, kind=Kind.config) throttle = Component(Signal, value=0.25, kind=Kind.config) - status = Component(Signal, value=StdDaqPreviewState.UNKNOWN, kind=Kind.omitted) frame = Component(Signal, kind=Kind.hinted) image_shape = Component(Signal, kind=Kind.normal) # FIXME: The BEC client caches the read()s from the last 50 scans image = Component(Signal, kind=Kind.omitted) _last_image = None + _stop_polling = True + _mon = None def __init__( self, *args, url: str = "tcp://129.129.95.38:20000", parent: Device = None, **kwargs ) -> None: super().__init__(*args, parent=parent, **kwargs) self.url._metadata["write_access"] = False - self.status._metadata["write_access"] = False self.image._metadata["write_access"] = False self.frame._metadata["write_access"] = False self.image_shape._metadata["write_access"] = False @@ -185,9 +101,92 @@ class StdDaqPreviewDetector(PSIDetectorBase): def get_image(self): return self._last_image - def kickoff(self) -> DeviceStatus: - """ The DAQ was not meant to be toggled""" - return DeviceStatus(self, done=True, success=True, settle_time=0.1) + def arm(self): + """Start listening for preview data stream""" + if self._mon is not None: + self.unstage() + sleep(0.5) + + self.connect() + self._stop_polling = False + self._mon = Thread(target=self.poll, daemon=True) + self._mon.start() + + + + def disarm(self): + """Stop a running preview""" + if self._mon is not None: + self._stop_polling = True + # Might hang on recv_multipart + self._mon.join(timeout=1) + # So also disconnect the socket (if not already disconnected) + try: + self._socket.disconnect(self.url.get()) + except zmq.error.ZMQError: + pass + + + def poll(self): + """Collect streamed updates""" + try: + t_last = time() + while True: + try: + # Exit loop and finish monitoring + if self._stop_polling: + break + + # pylint: disable=no-member + r = self._socket.recv_multipart(flags=zmq.NOBLOCK) + + # Length and throtling checks + if len(r) != 2: + logger.warning( + f"[{self.name}] Received malformed array of length {len(r)}") + t_curr = time() + t_elapsed = t_curr - t_last + if t_elapsed < self.throttle.get(): + sleep(0.1) + continue + + # Unpack the Array V1 reply to metadata and array data + meta, data = r + + # Update image and update subscribers + header = json.loads(meta) + image = None + if header["type"] == "uint16": + image = np.frombuffer(data, dtype=np.uint16) + + if image.size != np.prod(header['shape']): + err = f"Unexpected array size of {image.size} for header: {header}" + raise ValueError(err) + image = image.reshape(header['shape']) + + # Update image and update subscribers + self.frame.put(header['frame'], force=True) + self.image_shape.put(header['shape'], force=True) + self.image.put(image, force=True) + self._last_image = image + self._run_subs(sub_type=self.SUB_MONITOR, value=image) + t_last = t_curr + logger.info( + f"[{self.name}] Updated frame {header['frame']}\t" + f"Shape: {header['shape']}\tMean: {np.mean(image):.3f}" + ) + except ValueError: + # Happens when ZMQ partially delivers the multipart message + pass + except zmq.error.Again: + # Happens when receive queue is empty + sleep(0.1) + except Exception as ex: + logger.info(f"[{self.name}]\t{str(ex)}") + raise + finally: + self._mon = None + logger.info(f"[{self.name}]\tDetaching monitor") # Automatically connect to MicroSAXS testbench if directly invoked diff --git a/tomcat_bec/scans/__init__.py b/tomcat_bec/scans/__init__.py index ab5e70c..547bc6e 100644 --- a/tomcat_bec/scans/__init__.py +++ b/tomcat_bec/scans/__init__.py @@ -1,2 +1,3 @@ from .tutorial_fly_scan import AcquireDark, AcquireWhite, AcquireRefs, AcquireProjections, TutorialFlyScanContLine from .tomcat_scans import TomcatSnapNStep, TomcatSimpleSequence +from .advanced_scans import AcquireRefsV2, AcquireDarkV2, AcquireWhiteV2 diff --git a/tomcat_bec/scans/advanced_scans.py b/tomcat_bec/scans/advanced_scans.py new file mode 100644 index 0000000..8bb0808 --- /dev/null +++ b/tomcat_bec/scans/advanced_scans.py @@ -0,0 +1,383 @@ +import time + +import numpy as np +from bec_lib.device import DeviceBase +from bec_server.scan_server.scans import Acquire, AsyncFlyScanBase + +from bec_lib import bec_logger + +logger = bec_logger.logger + + +class Shutter: + """ Shutter status """ + CLOSED = 0 + OPEN = 1 + + +class AcquireDarkV2(Acquire): + scan_name = "acquire_dark_v2" + required_kwargs = ["exp_burst"] + gui_config = {"Acquisition parameters": ["exp_burst"]} + + def __init__(self, exp_burst: int, file_prefix="", **kwargs): + """ + Acquire dark images. This scan is used to acquire dark images. Dark images are images taken with the shutter + closed and no beam on the sample. Dark images are used to correct the data images for dark current. + + NOTE: this scan has a special operation mode that does not call + + Args: + exp_burst : int + Number of dark images to acquire (no default) + file_prefix : str + File prefix + + Examples: + >>> scans.acquire_dark(5) + + """ + super().__init__(exp_burst=exp_burst, file_prefix="", **kwargs) + self.burst_at_each_point = 1 # At each point, how many times I want to individually trigger + self.exp_burst = exp_burst + self.file_prefix = file_prefix + + def pre_scan(self): + """ Close the shutter before scan""" + yield from self.stubs.set(device=["eyex"], value=[Shutter.CLOSED]) + return super().pre_scan() + + def direct(self): + """ Direct scan procedure call""" + # Collect relevant devices + self.cams = [cam.name for cam in self.device_manager.devices.get_devices_with_tags("camera") if cam.enabled] + self.prev = [pre.name for pre in self.device_manager.devices.get_devices_with_tags("preview") if pre.enabled] + self.daqs = [daq.name for daq in self.device_manager.devices.get_devices_with_tags("daq") if daq.enabled] + + # Do not call stage, as there's no ScanInfo emitted for direct call + for daq in self.daqs: + cam = yield from self.stubs.send_rpc_and_wait(daq, "datasource.get") + prefix = f"{self.file_prefix}_{cam}_dark" + yield from self.stubs.send_rpc_and_wait(daq, "file_prefix.set", prefix) + yield from self.stubs.send_rpc_and_wait(daq, "num_images.set", self.exp_burst) + yield from self.stubs.send_rpc_and_wait(daq, "bluestage") + for prev in self.prev: + yield from self.stubs.send_rpc_and_wait(prev, "arm") + for cam in self.cams: + yield from self.stubs.send_rpc_and_wait(cam, "configure", {'exposure_num_burst': self.exp_burst}) + yield from self.stubs.send_rpc_and_wait(cam, "bluestage") + + yield from self.pre_scan() + yield from self.scan_core() + yield from self.finalize() + yield from self.unstage() + yield from self.cleanup() + + +class AcquireWhiteV2(Acquire): + scan_name = "acquire_white_v2" + gui_config = {"Acquisition parameters": ["exp_burst"]} + + def __init__(self, motor: DeviceBase, exp_burst: int, sample_position_out: float, sample_angle_out: float, file_prefix: str="", **kwargs): + """ + Acquire flat field images. This scan is used to acquire flat field images. The flat field image is an image taken + with the shutter open but the sample out of the beam. Flat field images are used to correct the data images for + non-uniformity in the detector. + + Args: + motor : DeviceBase + Motor to be moved to move the sample out of beam + exp_burst : int + Number of flat field images to acquire (no default) + sample_position_out : float + Position to move the sample stage to position the sample out of beam and take flat field images + sample_angle_out : float + Angular position where to take the flat field images + + Examples: + >>> scans.acquire_white(dev.samx, 5, 20) + + """ + super().__init__(exp_burst=exp_burst, **kwargs) + self.exp_burst = exp_burst + self.file_prefix = file_prefix + self.burst_at_each_point = 1 + + self.scan_motors = [motor, "eyez"] + # self.scan_motors = [motor, "es1_roty"] + self.out_position = [sample_position_out, sample_angle_out] + + def pre_scan(self): + """ Open the shutter before scan""" + # Move sample out + yield from self._move_scan_motors_and_wait(self.out_position) + # Open the main shutter (TODO change to the correct shutter device) + yield from self.stubs.set(device=["eyex"], value=[Shutter.OPEN]) + + return super().pre_scan() + + def cleanup(self): + """ Close the shutter after scan""" + # Close fast shutter + yield from self.stubs.set(device=["eyex"], value=[Shutter.CLOSED]) + return super().cleanup() + + def direct(self): + """ Direct scan procedure call""" + # Collect relevant devices + self.cams = [cam.name for cam in self.device_manager.devices.get_devices_with_tags("camera") if cam.enabled] + self.prev = [pre.name for pre in self.device_manager.devices.get_devices_with_tags("preview") if pre.enabled] + self.daqs = [daq.name for daq in self.device_manager.devices.get_devices_with_tags("daq") if daq.enabled] + + # Do not call stage, as there's no ScanInfo emitted for direct call + for daq in self.daqs: + cam = yield from self.stubs.send_rpc_and_wait(daq, "datasource.get") + prefix = f"{self.file_prefix}_{cam}_white" + yield from self.stubs.send_rpc_and_wait(daq, "file_prefix.set", prefix) + yield from self.stubs.send_rpc_and_wait(daq, "num_images.set", self.exp_burst) + yield from self.stubs.send_rpc_and_wait(daq, "bluestage") + for prev in self.prev: + yield from self.stubs.send_rpc_and_wait(prev, "arm") + for cam in self.cams: + yield from self.stubs.send_rpc_and_wait(cam, "configure", {'exposure_num_burst': self.exp_burst}) + yield from self.stubs.send_rpc_and_wait(cam, "bluestage") + + yield from self.pre_scan() + yield from self.scan_core() + yield from self.finalize() + yield from self.unstage() + yield from self.cleanup() + + +# class AcquireProjections(AsyncFlyScanBase): +# scan_name = "acquire_projections" +# gui_config = { +# "Motor": ["motor"], +# "Acquisition parameters": ["sample_position_in", "start_angle", "angular_range" ], +# "Camera": ["exp_time", "exp_burst"] +# } + +# def __init__(self, +# motor: DeviceBase, +# exp_burst: int, +# sample_position_in: float, +# start_angle: float, +# angular_range: float, +# exp_time:float, +# **kwargs): +# """ +# Acquire projection images. + +# Args: +# motor : DeviceBase +# Motor to move continuously from start to stop position +# exp_burst : int +# Number of flat field images to acquire (no default) +# sample_position_in : float +# Position to move the sample stage to position the sample in the beam +# start_angle : float +# Angular start position for the scan +# angular_range : float +# Angular range +# exp_time : float, optional +# Exposure time [ms]. If not specified, the currently configured value on the camera will be used +# exp_period : float, optional +# Exposure period [ms]. If not specified, the currently configured value on the camera will be used +# image_width : int, optional +# ROI size in the x-direction [pixels]. If not specified, the currently configured value on the camera will be used +# image_height : int, optional +# ROI size in the y-direction [pixels]. If not specified, the currently configured value on the camera will be used +# acq_mode : str, optional +# Predefined acquisition mode (default= 'default') +# file_path : str, optional +# File path for standard daq +# ddc_trigger : int, optional +# Drive Data Capture Trigger +# ddc_source0 : int, optional +# Drive Data capture Input0 + +# Returns: +# ScanReport + +# Examples: +# >>> scans.acquire_projections() + +# """ +# self.motor = motor +# super().__init__(exp_time=exp_time,**kwargs) + +# self.burst_at_each_point = 1 +# self.sample_position_in = sample_position_in +# self.start_angle = start_angle +# self.angular_range = angular_range + +# self.dark_shutter_pos_out = 1 ### change with a variable +# self.dark_shutter_pos_in = 0 ### change with a variable + +# def update_scan_motors(self): +# return [self.motor] + +# def prepare_positions(self): +# self.positions = np.array([[self.start_angle], [self.start_angle+self.angular_range]]) +# self.num_pos = None +# yield from self._set_position_offset() + +# def scan_core(self): + +# # move to in position and go to start angular position +# yield from self.stubs.set(device=["eyez", self.motor], value=[self.sample_position_in, self.positions[0][0]]) + +# # open the shutter +# yield from self.stubs.set(device="eyex", value=self.dark_shutter_pos_out) +# # TODO add opening of fast shutter + +# # start the flyer +# flyer_request = yield from self.stubs.set( +# device=self.motor, value=self.positions[1][0], wait=False +# ) + +# self.connector.send_client_info( +# "Starting the scan", show_asap=True, rid=self.metadata.get("RID") +# ) + +# yield from self.stubs.trigger() + +# while not flyer_request.done: + +# yield from self.stubs.read( +# group="monitored", point_id=self.point_id +# ) +# time.sleep(1) + +# # increase the point id +# self.point_id += 1 + +# self.num_pos = self.point_id + + +class AcquireRefsV2(Acquire): + scan_name = "acquire_refs_v2" + gui_config = {} + + def __init__( + self, + motor: DeviceBase, + num_darks: int = 0, + num_flats: int = 0, + sample_angle_out: float = 0, + sample_position_in: float = 0, + sample_position_out: float = 1, + file_prefix_dark: str = 'tmp_dark', + file_prefix_white: str = 'tmp_white', + **kwargs + ): + """ + Acquire reference images (darks + whites) and return to beam position. + + Reference images are acquired automatically in an optimized sequence and + the sample is returned to the sample_in_position afterwards. + + Args: + motor : DeviceBase + Motor to be moved to move the sample out of beam + num_darks : int , optional + Number of dark field images to acquire + num_flats : int , optional + Number of white field images to acquire + sample_angle_out : float , optional + Angular position where to take the flat field images + sample_position_in : float , optional + Sample stage X position for sample in beam [um] + sample_position_out : float ,optional + Sample stage X position for sample out of the beam [um] + exp_time : float, optional + Exposure time [ms]. If not specified, the currently configured value + on the camera will be used + exp_period : float, optional + Exposure period [ms]. If not specified, the currently configured value + on the camera will be used + image_width : int, optional + ROI size in the x-direction [pixels]. If not specified, the currently + configured value on the camera will be used + image_height : int, optional + ROI size in the y-direction [pixels]. If not specified, the currently + configured value on the camera will be used + acq_mode : str, optional + Predefined acquisition mode (default= 'default') + file_path : str, optional + File path for standard daq + + Returns: + ScanReport + + Examples: + >>> scans.acquire_refs(sample_angle_out=90, sample_position_in=10, num_darks=5, num_flats=5, exp_time=0.1) + + """ + self.motor = motor + super().__init__(**kwargs) + self.sample_position_in = sample_position_in + self.sample_position_out = sample_position_out + self.sample_angle_out = sample_angle_out + self.num_darks = num_darks + self.num_flats = num_flats + self.file_prefix_dark = file_prefix_dark + self.file_prefix_white = file_prefix_white + self.scan_parameters["std_daq_params"] = {"reconnect": False} + + def stage(self): + """Wrapped scan doesn't need staging""" + yield None + + def scan_core(self): + + if self.num_darks: + msg = f"Acquiring {self.num_darks} dark images" + logger.warning(msg) + self.connector.send_client_info( + msg, + show_asap=True, + rid=self.metadata.get("RID"), + ) + + darks = AcquireDarkV2( + exp_burst=self.num_darks, +# file_prefix=self.file_prefix_dark, + device_manager=self.device_manager, + metadata=self.metadata, + instruction_handler=self.stubs._instruction_handler, + **self.caller_kwargs, + ) + + yield from darks.direct() + self.point_id = darks.point_id + + + if self.num_flats: + msg = f"Acquiring {self.num_flats} flat field images" + logger.warning(msg) + self.connector.send_client_info( + msg, + show_asap=True, + rid=self.metadata.get("RID"), + ) + logger.warning("Calling AcquireWhite") + + flats = AcquireWhiteV2( + motor=self.motor, + exp_burst=self.num_flats, + sample_position_out=self.sample_position_out, + # sample_angle_out=self.sample_angle_out, + device_manager=self.device_manager, + metadata=self.metadata, + instruction_handler=self.stubs._instruction_handler, + **self.caller_kwargs, + ) + + flats.point_id = self.point_id + yield from flats.direct() + self.point_id = flats.point_id + ## TODO move sample in beam and do not wait + ## TODO move rotation to angle and do not wait + logger.warning("[AcquireRefsV2] Done with scan_core") + -- 2.49.1 From e02bb5892eb06cfcb664b40eab07390a54b8d81a Mon Sep 17 00:00:00 2001 From: gac-x05la Date: Mon, 3 Mar 2025 12:20:56 +0100 Subject: [PATCH 10/13] WIP --- .../device_configs/microxas_test_bed.yaml | 32 +-- .../aerotech/AerotechDriveDataCollection.py | 14 +- tomcat_bec/devices/aerotech/AerotechTasks.py | 34 ++- .../devices/gigafrost/gigafrostcamera.py | 41 ++-- tomcat_bec/devices/gigafrost/pcoedgecamera.py | 14 +- tomcat_bec/devices/gigafrost/stddaq_client.py | 196 ++++++------------ .../devices/gigafrost/stddaq_preview.py | 24 +-- tomcat_bec/scans/advanced_scans.py | 131 +++++++----- 8 files changed, 231 insertions(+), 255 deletions(-) diff --git a/tomcat_bec/device_configs/microxas_test_bed.yaml b/tomcat_bec/device_configs/microxas_test_bed.yaml index 4087d59..613b127 100644 --- a/tomcat_bec/device_configs/microxas_test_bed.yaml +++ b/tomcat_bec/device_configs/microxas_test_bed.yaml @@ -196,22 +196,22 @@ pcocam: readoutPriority: monitored softwareTrigger: true -pcodaq: - description: GigaFrost stdDAQ client - deviceClass: tomcat_bec.devices.StdDaqClient - deviceConfig: - ws_url: 'ws://129.129.95.111:8081' - rest_url: 'http://129.129.95.111:5010' - data_source_name: 'pcocam' - deviceTags: - - std-daq - - daq - - pcocam - enabled: true - onFailure: buffer - readOnly: false - readoutPriority: monitored - softwareTrigger: false +# pcodaq: +# description: GigaFrost stdDAQ client +# deviceClass: tomcat_bec.devices.StdDaqClient +# deviceConfig: +# ws_url: 'ws://129.129.95.111:8081' +# rest_url: 'http://129.129.95.111:5010' +# data_source_name: 'pcocam' +# deviceTags: +# - std-daq +# - daq +# - pcocam +# enabled: false +# onFailure: buffer +# readOnly: false +# readoutPriority: monitored +# softwareTrigger: false pco_stream0: description: stdDAQ preview (2 every 555) diff --git a/tomcat_bec/devices/aerotech/AerotechDriveDataCollection.py b/tomcat_bec/devices/aerotech/AerotechDriveDataCollection.py index d8087df..aa5d136 100644 --- a/tomcat_bec/devices/aerotech/AerotechDriveDataCollection.py +++ b/tomcat_bec/devices/aerotech/AerotechDriveDataCollection.py @@ -40,7 +40,7 @@ class AerotechDriveDataCollectionMixin(CustomDeviceMixin): d["ddc_source0"] = scanargs["ddc_source0"] if "ddc_source1" in scanargs: d["ddc_source1"] = scanargs["ddc_source1"] - + # Number of points if "ddc_num_points" in scanargs and scanargs["ddc_num_points"] is not None: d["num_points_total"] = scanargs["ddc_num_points"] elif "daq_num_points" in scanargs and scanargs["daq_num_points"] is not None: @@ -67,12 +67,12 @@ class AerotechDriveDataCollectionMixin(CustomDeviceMixin): self.parent.configure(d=d) # Stage the data collection if not in internally launced mode - # NOTE: Scripted scans start acquiring from the scrits - self.parent.bluestage() + # NOTE: Scripted scans might configure and start acquiring from the scrits + self.parent.arm() def on_unstage(self): """Standard bluesky unstage""" - self.parent._switch.set("Stop", settle_time=0.2).wait() + self.parent.disarm() class aa1AxisDriveDataCollection(PSIDeviceBase): @@ -118,7 +118,7 @@ class aa1AxisDriveDataCollection(PSIDeviceBase): _buffer1 = Component(EpicsSignalRO, "BUFFER1", auto_monitor=True, kind=Kind.normal) custom_prepare_cls = AerotechDriveDataCollectionMixin - USER_ACCESS = ["configure", "reset", "collect"] + USER_ACCESS = ["configure", "reset", "collect", "arm", "disarm"] def configure(self, d: dict = None) -> tuple: """Configure data capture @@ -145,12 +145,12 @@ class aa1AxisDriveDataCollection(PSIDeviceBase): new = self.read_configuration() return (old, new) - def bluestage(self) -> None: + def arm(self) -> None: """Bluesky-style stage""" self._switch.set("ResetRB", settle_time=0.1).wait() self._switch.set("Start", settle_time=0.2).wait() - def blueunstage(self): + def disarm(self): """Bluesky-style unstage""" self._switch.set("Stop", settle_time=0.2).wait() diff --git a/tomcat_bec/devices/aerotech/AerotechTasks.py b/tomcat_bec/devices/aerotech/AerotechTasks.py index da8c5d7..62ae0c0 100644 --- a/tomcat_bec/devices/aerotech/AerotechTasks.py +++ b/tomcat_bec/devices/aerotech/AerotechTasks.py @@ -33,7 +33,20 @@ class AerotechTasksMixin(CustomDeviceMixin): PSO is not needed or when it'd conflict with other devices. """ # Fish out our configuration from scaninfo (via explicit or generic addressing) - d = self.parent.scan_info.scan_msg.scan_parameters.get("aerotech_config") + d = {} + if "kwargs" in self.parent.scaninfo.scan_msg.info: + scanargs = self.parent.scaninfo.scan_msg.info["kwargs"] + if self.parent.scaninfo.scan_type in ("script", "scripted"): + # NOTE: Scans don't have to fully configure the device + if "script_text" in scanargs and scanargs["script_text"] is not None: + d["script_text"] = scanargs["script_text"] + if "script_file" in scanargs and scanargs["script_file"] is not None: + d["script_file"] = scanargs["script_file"] + if "script_task" in scanargs and scanargs["script_task"] is not None: + d["script_task"] = scanargs["script_task"] + + # FIXME: The above should be exchanged with: + # d = self.parent.scan_info.scan_msg.scan_parameters.get("aerotech_config") # Perform bluesky-style configuration if d: @@ -41,19 +54,19 @@ class AerotechTasksMixin(CustomDeviceMixin): self.parent.configure(d=d) # The actual staging - self.parent.bluestage() + self.parent.arm() def on_unstage(self): """Stop the currently selected task""" - self.parent.blueunstage() + self.parent.disarm() def on_stop(self): """Stop the currently selected task""" - self.parent.switch.set("Stop").wait() + self.on_unstage() def on_kickoff(self): """Start execution of the selected task""" - self.parent.bluekickoff() + self.parent.launch() class aa1Tasks(PSIDeviceBase): @@ -86,7 +99,7 @@ class aa1Tasks(PSIDeviceBase): ''' """ - + USER_ACCESS = ["arm", "disarm", "launch", "kickoff"] custom_prepare_cls = AerotechTasksMixin _failure = Component(EpicsSignalRO, "FAILURE", auto_monitor=True, kind=Kind.normal) @@ -100,7 +113,6 @@ class aa1Tasks(PSIDeviceBase): _executeReply = Component(EpicsSignalRO, "EXECUTE_RBV", string=True, auto_monitor=True) fileName = Component(EpicsSignal, "FILENAME", string=True, kind=Kind.omitted, put_complete=True) - # _fileRead = Component(EpicsPassiveRO, "FILEREAD", string=True, kind=Kind.omitted) _fileWrite = Component( EpicsSignal, "FILEWRITE", string=True, kind=Kind.omitted, put_complete=True ) @@ -129,7 +141,7 @@ class aa1Tasks(PSIDeviceBase): new = self.read_configuration() return (old, new) - def bluestage(self) -> None: + def arm(self) -> None: """Bluesky style stage, prepare, but does not execute""" if self.taskIndex.get() in (0, 1, 2): logger.error(f"[{self.name}] Loading AeroScript on system task. Daring today are we?") @@ -140,11 +152,11 @@ class aa1Tasks(PSIDeviceBase): raise RuntimeError("Failed to load task, please check the Aerotech IOC") return status - def blueunstage(self): + def disarm(self): """Bluesky style unstage, stops execution""" self.switch.set("Stop").wait() - def bluekickoff(self): + def launch(self): """Bluesky style kickoff""" # Launch and check success status = self.switch.set("Start", settle_time=0.2) @@ -159,7 +171,7 @@ class aa1Tasks(PSIDeviceBase): ########################################################################## # Bluesky flyer interface - def complete(self) -> DeviceStatus: + def complete(self) -> SubscriptionStatus: """Wait for a RUNNING task""" timestamp_ = 0 task_idx = int(self.taskIndex.get()) diff --git a/tomcat_bec/devices/gigafrost/gigafrostcamera.py b/tomcat_bec/devices/gigafrost/gigafrostcamera.py index 0e86170..778f678 100644 --- a/tomcat_bec/devices/gigafrost/gigafrostcamera.py +++ b/tomcat_bec/devices/gigafrost/gigafrostcamera.py @@ -194,7 +194,7 @@ class GigaFrostCameraMixin(CustomDetectorMixin): if self.parent.infoSyncFlag.value == 0: self.parent.cmdSyncHw.set(1).wait() # Switch to acquiring - self.parent.bluestage() + self.parent.arm() def on_unstage(self) -> None: """Specify actions to be executed during unstage. @@ -204,9 +204,7 @@ class GigaFrostCameraMixin(CustomDetectorMixin): with flagged done to BEC. """ # Switch to idle - self.parent.cmdStartCamera.set(0).wait() - if self.parent.autoSoftEnable.get(): - self.parent.cmdSoftEnable.set(0).wait() + self.parent.disarm() def on_stop(self) -> None: """ @@ -440,7 +438,7 @@ class GigaFrostCamera(PSIDetectorBase): cfgInputPolarity2 = Component(EpicsSignalRO, "BNC5_RBV", auto_monitor=True, kind=Kind.config) infoBoardTemp = Component(EpicsSignalRO, "T_BOARD", auto_monitor=True) - USER_ACCESS = ["exposure_mode", "fix_nframes_mode", "trigger_mode", "enable_mode", "initialize"] + USER_ACCESS = ["exposure_mode", "fix_nframes_mode", "trigger_mode", "enable_mode", "initialize", "arm", "disarm"] autoSoftEnable = Component(Signal, kind=Kind.config) backendUrl = Component(Signal, kind=Kind.config) @@ -572,11 +570,18 @@ class GigaFrostCamera(PSIDetectorBase): # Commit parameters self.cmdSetParam.set(1).wait() - def bluestage(self): + def arm(self): """Bluesky style stage""" # Switch to acquiring self.cmdStartCamera.set(1).wait() + def disarm(self): + """ Bluesky style unstage""" + # Switch to idle + self.cmdStartCamera.set(0).wait() + if self.autoSoftEnable.get(): + self.cmdSoftEnable.set(0).wait() + def set_acquisition_mode(self, acq_mode): """Set acquisition mode @@ -589,18 +594,17 @@ class GigaFrostCamera(PSIDetectorBase): if acq_mode == "default": # NOTE: Trigger using software events via softEnable (actually works) + # Switch to physical enable signal + self.cfgEnableScheme.set(0).wait() # Trigger parameters self.cfgCntStartBit.set(1).wait() self.cfgCntEndBit.set(0).wait() - - # Switch to physical enable signal - self.cfgEnableScheme.set(0).wait() - # Set modes # self.cmdSoftEnable.set(0).wait() self.enable_mode = "soft" self.trigger_mode = "auto" self.exposure_mode = "timer" + elif acq_mode in ["ext_enable", "external_enable"]: # NOTE: Trigger using external hardware events via enable input (actually works) # Switch to physical enable signal @@ -612,10 +616,14 @@ class GigaFrostCamera(PSIDetectorBase): self.enable_mode = "external" self.trigger_mode = "auto" self.exposure_mode = "timer" + elif acq_mode == "soft": # NOTE: Fede's configuration for continous streaming # Switch to physical enable signal self.cfgEnableScheme.set(0).wait() + # Set trigger edge to fixed frames on posedge + self.cfgCntStartBit.set(1).wait() + self.cfgCntEndBit.set(0).wait() # Set enable signal to always self.cfgEnableExt.set(0).wait() self.cfgEnableSoft.set(1).wait() @@ -626,16 +634,18 @@ class GigaFrostCamera(PSIDetectorBase): self.cfgTrigTimer.set(1).wait() self.cfgTrigAuto.set(0).wait() # Set exposure mode to timer + # self.exposure_mode = "timer" self.cfgExpExt.set(0).wait() self.cfgExpSoft.set(0).wait() self.cfgExpTimer.set(1).wait() - # Set trigger edge to fixed frames on posedge - self.cfgCntStartBit.set(1).wait() - self.cfgCntEndBit.set(0).wait() + elif acq_mode in ["ext", "external"]: # NOTE: Untested # Switch to physical enable signal self.cfgEnableScheme.set(0).wait() + # Set trigger edge to fixed frames on posedge + self.cfgCntStartBit.set(1).wait() + self.cfgCntEndBit.set(0).wait() # Set enable signal to always self.cfgEnableExt.set(0).wait() self.cfgEnableSoft.set(0).wait() @@ -646,12 +656,11 @@ class GigaFrostCamera(PSIDetectorBase): self.cfgTrigTimer.set(0).wait() self.cfgTrigAuto.set(0).wait() # Set exposure mode to timer + # self.exposure_mode = "timer" self.cfgExpExt.set(0).wait() self.cfgExpSoft.set(0).wait() self.cfgExpTimer.set(1).wait() - # Set trigger edge to fixed frames on posedge - self.cfgCntStartBit.set(1).wait() - self.cfgCntEndBit.set(0).wait() + else: raise RuntimeError(f"Unsupported acquisition mode: {acq_mode}") diff --git a/tomcat_bec/devices/gigafrost/pcoedgecamera.py b/tomcat_bec/devices/gigafrost/pcoedgecamera.py index d9e0f07..d3aa728 100644 --- a/tomcat_bec/devices/gigafrost/pcoedgecamera.py +++ b/tomcat_bec/devices/gigafrost/pcoedgecamera.py @@ -75,15 +75,15 @@ class PcoEdgeCameraMixin(CustomPrepare): self.parent.configure(d=d) # ARM the camera - self.parent.bluestage() + self.parent.arm() def on_unstage(self) -> None: """Disarm the PCO.Edge camera""" - self.parent.blueunstage() + self.parent.disarm() def on_stop(self) -> None: """Stop the PCO.Edge camera""" - self.parent.blueunstage() + self.parent.disarm() def on_trigger(self) -> None | DeviceStatus: """Trigger mode operation @@ -333,7 +333,7 @@ class HelgeCameraBase(BECDeviceBase): status = SubscriptionStatus(self.camSetParam, negedge, timeout=5, settle_time=0.5) 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 @@ -358,7 +358,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() @@ -366,7 +366,7 @@ class HelgeCameraBase(BECDeviceBase): # FIXME: This might interrupt data transfer self.file_savestop.set(0).wait() - def bluekickoff(self): + def launch(self): """Start data transfer TODO: Need to revisit this once triggering is complete @@ -383,7 +383,7 @@ class PcoEdge5M(HelgeCameraBase): """ custom_prepare_cls = PcoEdgeCameraMixin - USER_ACCESS = ["bluestage", "blueunstage", "bluekickoff"] + USER_ACCESS = ["arm", "disarm", "launch"] # ######################################################################## # Additional status info diff --git a/tomcat_bec/devices/gigafrost/stddaq_client.py b/tomcat_bec/devices/gigafrost/stddaq_client.py index 82345d9..042cf48 100644 --- a/tomcat_bec/devices/gigafrost/stddaq_client.py +++ b/tomcat_bec/devices/gigafrost/stddaq_client.py @@ -28,8 +28,6 @@ logger = bec_logger.logger class StdDaqMixin(CustomDeviceMixin): # pylint: disable=protected-access - _mon = None - def on_stage(self) -> None: """Configuration and staging @@ -100,42 +98,20 @@ class StdDaqMixin(CustomDeviceMixin): sleep(0.5) # Try to start a new run (reconnects) - # if std_daq_params.get("reconnect", True): - self.parent.bluestage() + self.parent.arm() + - # And start status monitoring - self._mon = Thread(target=self.monitor, daemon=True) - self._mon.start() def on_unstage(self): """Stop a running acquisition and close connection""" print("Creating virtual dataset") self.parent.create_virtual_dataset() - self.parent.blueunstage() + self.parent.disarm() def on_stop(self): """Stop a running acquisition and close connection""" - self.parent.blueunstage() + self.parent.disarm() - def monitor(self) -> None: - """Monitor status messages while connection is open. This will block the reply monitoring - to calling unstage() might throw. Status updates are sent every 1 seconds, but finishing - acquisition means StdDAQ will close connection, so there's no idle state polling. - """ - try: - sleep(0.2) - for msg in self.parent._wsclient: - message = json.loads(msg) - self.parent.runstatus.put(message["status"], force=True) - # logger.info(f"[{self.parent.name}] Pushed status: {message['status']}") - except (ConnectionClosedError, ConnectionClosedOK, AssertionError): - # Libraty throws theese after connection is closed - return - except Exception as ex: - logger.warning(f"[{self.parent.name}] Exception in polling: {ex}") - return - finally: - self._mon = None class StdDaqClient(PSIDeviceBase): @@ -161,9 +137,8 @@ class StdDaqClient(PSIDeviceBase): "nuke", "connect", "message", - "state", - "bluestage", - "blueunstage", + "arm", + "disarm", "complete", ] _wsclient = None @@ -185,6 +160,7 @@ class StdDaqClient(PSIDeviceBase): cfg_pixel_height = Component(Signal, kind=Kind.config) cfg_pixel_width = Component(Signal, kind=Kind.config) cfg_nr_writers = Component(Signal, kind=Kind.config) + _mon = None def __init__( self, @@ -198,7 +174,7 @@ class StdDaqClient(PSIDeviceBase): device_manager=None, ws_url: str = "ws://localhost:8080", rest_url: str = "http://localhost:5000", - data_source_name=None, + data_source_name="", **kwargs, ) -> None: super().__init__( @@ -220,60 +196,59 @@ class StdDaqClient(PSIDeviceBase): self.get_daq_config(update=True) except Exception as ex: logger.error(f"Failed to connect to the stdDAQ REST API\n{ex}") + # Connect to websockets and start poller + try: + self.connect() + except Exception as ex: + logger.error(f"Failed to connect to the stdDAQ websocket interface\n{ex}") - def connect(self) -> ClientConnection: + def connect(self) -> None: """Connect to the StdDAQ's websockets interface StdDAQ may reject connection for a few seconds after restart, or when it wants so if it fails, wait a bit and try to connect again. """ - num_retry = 0 - while num_retry < 5: - try: - logger.debug(f"[{self.name}] Connecting to stdDAQ at {self.ws_url.get()}") - connection = connect(self.ws_url.get()) - logger.debug(f"[{self.name}] Connected to stdDAQ after {num_retry} tries") - return connection - except ConnectionRefusedError: - num_retry += 1 - sleep(2) - raise ConnectionRefusedError("The stdDAQ websocket interface refused connection 5 times.") + # Connect to stdDAQ + logger.debug(f"[{self.name}] Connecting to stdDAQ at {self.ws_url.get()}") + self._wsclient = connect(self.ws_url.get()) + # And start status monitoring + self._mon = Thread(target=self.monitor, daemon=True) + self._mon.start() - def message(self, message: dict, timeout=1, wait_reply=True, client=None) -> None | str: + def monitor(self) -> None: + """Monitor status messages while connection is open. Status updates are + sent every 1 seconds, or when there's a transition. + """ + try: + for msg in self._wsclient: + message = json.loads(msg) + self.runstatus.put(message["status"], force=True) + # logger.info(f"[{self.parent.name}] Pushed status: {message['status']}") + except Exception as ex: + logger.warning(f"[{self.name}] Exception in polling: {ex}") + return + finally: + self._mon = None + + def message(self, message: dict) -> None: """Send a message to the StdDAQ and receive a reply - - Note: finishing acquisition means StdDAQ will close connection, so - there's no idle state polling. """ # Prepare message msg = json.dumps(message) if isinstance(message, dict) else str(message) # Connect if client was destroyed if self._wsclient is None: - self._wsclient = self.connect() + self.connect() # Send message (reopen connection if needed) msg = json.dumps(message) if isinstance(message, dict) else str(message) try: self._wsclient.send(msg) - except (ConnectionClosedError, ConnectionClosedOK, AttributeError) as ex: + except (ConnectionClosedError, ConnectionClosedOK, AttributeError): # Re-connect if the connection was closed - self._wsclient = self.connect() + self.connect() self._wsclient.send(msg) - # Wait for reply - reply = None - if wait_reply: - try: - reply = self._wsclient.recv(timeout) - return reply - except (ConnectionClosedError, ConnectionClosedOK) as ex: - self._wsclient = None - logger.error(f"[{self.name}] WS connection was closed before reply: {ex}") - except (TimeoutError, RuntimeError) as ex: - logger.error(f"[{self.name}] Error in receiving ws reply: {ex}") - return reply - def configure(self, d: dict = None): """Configure the next scan with the stdDAQ @@ -323,8 +298,8 @@ class StdDaqClient(PSIDeviceBase): ): # Stop if current status is not idle - if self.state() != "idle": - logger.warning(f"[{self.name}] stdDAQ reconfiguration might corrupt files") + # if self.runstatus.get() != "idle": + # logger.warning(f"[{self.name}] stdDAQ reconfiguration might corrupt files") # Update retrieved config cfg["image_pixel_height"] = int(self.cfg_pixel_height.get()) @@ -335,7 +310,7 @@ class StdDaqClient(PSIDeviceBase): sleep(1) self.get_daq_config(update=True) - def bluestage(self): + def arm(self): """Stages the stdDAQ Opens a new connection to the stdDAQ, sends the start command with @@ -343,8 +318,8 @@ class StdDaqClient(PSIDeviceBase): it for obvious failures. """ # Can't stage into a running exposure - if self.state() != "idle": - raise RuntimeError(f"[{self.name}] stdDAQ can't stage from state: {self.state()}") + # if self.runstatus.get() != "idle": + # raise RuntimeError(f"[{self.name}] stdDAQ can't stage from state: {self.runstatus.get()}") # Ensure expected shape self.validate() @@ -354,83 +329,47 @@ class StdDaqClient(PSIDeviceBase): num_images = self.num_images.get() # New connection - self._wsclient = self.connect() message = { "command": "start", "path": file_path, "file_prefix": file_prefix, "n_image": num_images, } - reply = self.message(message) + self.message(message) - if reply is not None: - reply = json.loads(reply) - self.runstatus.set(reply["status"], force=True).wait() - logger.info(f"[{self.name}] Start DAQ reply: {reply}") + def is_running(*args, value, timestamp, **kwargs): + result = value not in ["idle", "unknown", "error"] + return result - # Give it more time to reconfigure - if reply["status"] in ("rejected"): - # FIXME: running exposure is a nogo - if reply["reason"] == "driver is busy!": - raise RuntimeError( - f"[{self.name}] Start stdDAQ command rejected: already running" - ) - else: - # Give it more time to consolidate - sleep(1) - else: - # Success!!! - print(f"[{self.name}] Started stdDAQ in: {reply['status']}") - return + status = SubscriptionStatus(self.runstatus, is_running, timeout=3, settle_time=0.5) + status.wait() - raise RuntimeError(f"[{self.name}] Failed to start the stdDAQ, reason: {reply['reason']}") + logger.warning(f"[{self.name}] Started stdDAQ in: {self.runstatus.get()}") + return status - def blueunstage(self): + def disarm(self): """Unstages the stdDAQ Opens a new connection to the stdDAQ, sends the stop command and waits for the idle state. """ - ii = 0 - while ii < 10: - # Stop the DAQ (will close connection) - reply is always "success" - self._wsclient = self.connect() - self.message({"command": "stop_all"}, wait_reply=False) + # Stop the DAQ (will close connection) - reply is always "success" + self.message({"command": "stop"}) - # Let it consolidate - sleep(0.2) + def is_running(*args, value, timestamp, **kwargs): + result = value in ["idle", "unknown", "error"] + return result - # Check final status (from new connection) - self._wsclient = self.connect() - reply = self.message({"command": "status"}) - if reply is not None: - logger.info(f"[{self.name}] DAQ status reply: {reply}") - reply = json.loads(reply) + status = SubscriptionStatus(self.runstatus, is_running, timeout=3, settle_time=0.5) + status.wait() - if reply["status"] in ("idle", "error"): - # Only 'idle' state accepted - print(f"DAQ stopped on try {ii}") - return - elif reply["status"] in ("stop"): - # Give it more time to stop - sleep(0.5) - elif ii >= 6: - raise RuntimeError(f"Failed to stop StdDAQ: {reply}") - ii += 1 - raise RuntimeError(f"Failed to stop StdDAQ in time") + logger.warning(f"[{self.name}] Stopped stdDAQ in: {self.runstatus.get()}") + return status ########################################################################## # Bluesky flyer interface def complete(self) -> SubscriptionStatus: """Wait for current run. Must end in status 'file_saved'.""" - - # Return immediately if we're detached - # TODO: Maybe check for connection (not sure if it's better) - if self.state() in ["idle", "file_saved", "error"]: - status = Status(self) - status.set_finished() - return status - def is_running(*args, value, timestamp, **kwargs): result = value in ["idle", "file_saved", "error"] return result @@ -519,17 +458,6 @@ class StdDaqClient(PSIDeviceBase): self.set_daq_config(cfg) sleep(restarttime) - def state(self) -> str | None: - """Querry the current system status""" - try: - wsclient = self.connect() - wsclient.send(json.dumps({"command": "status"})) - r = wsclient.recv(timeout=1) - r = json.loads(r) - return r["status"] - except ConnectionRefusedError: - raise - # Automatically connect to microXAS testbench if directly invoked if __name__ == "__main__": diff --git a/tomcat_bec/devices/gigafrost/stddaq_preview.py b/tomcat_bec/devices/gigafrost/stddaq_preview.py index 29e16f5..f2e23a6 100644 --- a/tomcat_bec/devices/gigafrost/stddaq_preview.py +++ b/tomcat_bec/devices/gigafrost/stddaq_preview.py @@ -19,8 +19,9 @@ from ophyd_devices.interfaces.base_classes.psi_detector_base import ( ) from bec_lib import bec_logger + logger = bec_logger.logger -ZMQ_TOPIC_FILTER = b'' +ZMQ_TOPIC_FILTER = b"" class StdDaqPreviewMixin(CustomDetectorMixin): @@ -28,6 +29,7 @@ class StdDaqPreviewMixin(CustomDetectorMixin): Parent class: CustomDetectorMixin """ + def on_stage(self): """Start listening for preview data stream""" self.parent.arm() @@ -40,6 +42,7 @@ class StdDaqPreviewMixin(CustomDetectorMixin): """Stop a running preview""" self.on_unstage() + class StdDaqPreviewDetector(PSIDetectorBase): """Detector wrapper class around the StdDaq preview image stream. @@ -50,6 +53,7 @@ class StdDaqPreviewDetector(PSIDetectorBase): You can add a preview widget to the dock by: cam_widget = gui.add_dock('cam_dock1').add_widget('BECFigure').image('daq_stream1') """ + # Subscriptions for plotting image USER_ACCESS = ["arm", "disarm", "get_last_image"] SUB_MONITOR = "device_monitor_2d" @@ -112,8 +116,6 @@ class StdDaqPreviewDetector(PSIDetectorBase): self._mon = Thread(target=self.poll, daemon=True) self._mon.start() - - def disarm(self): """Stop a running preview""" if self._mon is not None: @@ -126,7 +128,6 @@ class StdDaqPreviewDetector(PSIDetectorBase): except zmq.error.ZMQError: pass - def poll(self): """Collect streamed updates""" try: @@ -142,8 +143,7 @@ class StdDaqPreviewDetector(PSIDetectorBase): # Length and throtling checks if len(r) != 2: - logger.warning( - f"[{self.name}] Received malformed array of length {len(r)}") + logger.warning(f"[{self.name}] Received malformed array of length {len(r)}") t_curr = time() t_elapsed = t_curr - t_last if t_elapsed < self.throttle.get(): @@ -158,15 +158,15 @@ class StdDaqPreviewDetector(PSIDetectorBase): image = None if header["type"] == "uint16": image = np.frombuffer(data, dtype=np.uint16) - - if image.size != np.prod(header['shape']): + + if image.size != np.prod(header["shape"]): err = f"Unexpected array size of {image.size} for header: {header}" raise ValueError(err) - image = image.reshape(header['shape']) + image = image.reshape(header["shape"]) # Update image and update subscribers - self.frame.put(header['frame'], force=True) - self.image_shape.put(header['shape'], force=True) + self.frame.put(header["frame"], force=True) + self.image_shape.put(header["shape"], force=True) self.image.put(image, force=True) self._last_image = image self._run_subs(sub_type=self.SUB_MONITOR, value=image) @@ -174,7 +174,7 @@ class StdDaqPreviewDetector(PSIDetectorBase): logger.info( f"[{self.name}] Updated frame {header['frame']}\t" f"Shape: {header['shape']}\tMean: {np.mean(image):.3f}" - ) + ) except ValueError: # Happens when ZMQ partially delivers the multipart message pass diff --git a/tomcat_bec/scans/advanced_scans.py b/tomcat_bec/scans/advanced_scans.py index 8bb0808..2d9442f 100644 --- a/tomcat_bec/scans/advanced_scans.py +++ b/tomcat_bec/scans/advanced_scans.py @@ -10,7 +10,8 @@ logger = bec_logger.logger class Shutter: - """ Shutter status """ + """Shutter status""" + CLOSED = 0 OPEN = 1 @@ -25,34 +26,46 @@ class AcquireDarkV2(Acquire): Acquire dark images. This scan is used to acquire dark images. Dark images are images taken with the shutter closed and no beam on the sample. Dark images are used to correct the data images for dark current. - NOTE: this scan has a special operation mode that does not call + NOTE: this scan has a special operation mode that does not call Args: exp_burst : int Number of dark images to acquire (no default) file_prefix : str - File prefix + File prefix Examples: >>> scans.acquire_dark(5) """ super().__init__(exp_burst=exp_burst, file_prefix="", **kwargs) - self.burst_at_each_point = 1 # At each point, how many times I want to individually trigger + self.burst_at_each_point = 1 # At each point, how many times I want to individually trigger self.exp_burst = exp_burst self.file_prefix = file_prefix def pre_scan(self): - """ Close the shutter before scan""" + """Close the shutter before scan""" yield from self.stubs.set(device=["eyex"], value=[Shutter.CLOSED]) return super().pre_scan() def direct(self): - """ Direct scan procedure call""" + """Direct scan procedure call""" # Collect relevant devices - self.cams = [cam.name for cam in self.device_manager.devices.get_devices_with_tags("camera") if cam.enabled] - self.prev = [pre.name for pre in self.device_manager.devices.get_devices_with_tags("preview") if pre.enabled] - self.daqs = [daq.name for daq in self.device_manager.devices.get_devices_with_tags("daq") if daq.enabled] + self.cams = [ + cam.name + for cam in self.device_manager.devices.get_devices_with_tags("camera") + if cam.enabled + ] + self.prev = [ + pre.name + for pre in self.device_manager.devices.get_devices_with_tags("preview") + if pre.enabled + ] + self.daqs = [ + daq.name + for daq in self.device_manager.devices.get_devices_with_tags("daq") + if daq.enabled + ] # Do not call stage, as there's no ScanInfo emitted for direct call for daq in self.daqs: @@ -60,12 +73,14 @@ class AcquireDarkV2(Acquire): prefix = f"{self.file_prefix}_{cam}_dark" yield from self.stubs.send_rpc_and_wait(daq, "file_prefix.set", prefix) yield from self.stubs.send_rpc_and_wait(daq, "num_images.set", self.exp_burst) - yield from self.stubs.send_rpc_and_wait(daq, "bluestage") + yield from self.stubs.send_rpc_and_wait(daq, "arm") for prev in self.prev: yield from self.stubs.send_rpc_and_wait(prev, "arm") for cam in self.cams: - yield from self.stubs.send_rpc_and_wait(cam, "configure", {'exposure_num_burst': self.exp_burst}) - yield from self.stubs.send_rpc_and_wait(cam, "bluestage") + yield from self.stubs.send_rpc_and_wait( + cam, "configure", {"exposure_num_burst": self.exp_burst} + ) + yield from self.stubs.send_rpc_and_wait(cam, "arm") yield from self.pre_scan() yield from self.scan_core() @@ -78,11 +93,19 @@ class AcquireWhiteV2(Acquire): scan_name = "acquire_white_v2" gui_config = {"Acquisition parameters": ["exp_burst"]} - def __init__(self, motor: DeviceBase, exp_burst: int, sample_position_out: float, sample_angle_out: float, file_prefix: str="", **kwargs): + def __init__( + self, + motor: DeviceBase, + exp_burst: int, + sample_position_out: float, + sample_angle_out: float, + file_prefix: str = "", + **kwargs, + ): """ - Acquire flat field images. This scan is used to acquire flat field images. The flat field image is an image taken - with the shutter open but the sample out of the beam. Flat field images are used to correct the data images for - non-uniformity in the detector. + Acquire flat field images. This scan is used to acquire flat field images. The flat field + image is an image taken with the shutter open but the sample out of the beam. Flat field + images are used to correct the data images for non-uniformity in the detector. Args: motor : DeviceBase @@ -90,7 +113,7 @@ class AcquireWhiteV2(Acquire): exp_burst : int Number of flat field images to acquire (no default) sample_position_out : float - Position to move the sample stage to position the sample out of beam and take flat field images + Position to move the sample stage out of beam and take flat field images sample_angle_out : float Angular position where to take the flat field images @@ -108,7 +131,7 @@ class AcquireWhiteV2(Acquire): self.out_position = [sample_position_out, sample_angle_out] def pre_scan(self): - """ Open the shutter before scan""" + """Open the shutter before scan""" # Move sample out yield from self._move_scan_motors_and_wait(self.out_position) # Open the main shutter (TODO change to the correct shutter device) @@ -117,17 +140,29 @@ class AcquireWhiteV2(Acquire): return super().pre_scan() def cleanup(self): - """ Close the shutter after scan""" + """Close the shutter after scan""" # Close fast shutter yield from self.stubs.set(device=["eyex"], value=[Shutter.CLOSED]) return super().cleanup() def direct(self): - """ Direct scan procedure call""" + """Direct scan procedure call""" # Collect relevant devices - self.cams = [cam.name for cam in self.device_manager.devices.get_devices_with_tags("camera") if cam.enabled] - self.prev = [pre.name for pre in self.device_manager.devices.get_devices_with_tags("preview") if pre.enabled] - self.daqs = [daq.name for daq in self.device_manager.devices.get_devices_with_tags("daq") if daq.enabled] + self.cams = [ + cam.name + for cam in self.device_manager.devices.get_devices_with_tags("camera") + if cam.enabled + ] + self.prev = [ + pre.name + for pre in self.device_manager.devices.get_devices_with_tags("preview") + if pre.enabled + ] + self.daqs = [ + daq.name + for daq in self.device_manager.devices.get_devices_with_tags("daq") + if daq.enabled + ] # Do not call stage, as there's no ScanInfo emitted for direct call for daq in self.daqs: @@ -135,12 +170,14 @@ class AcquireWhiteV2(Acquire): prefix = f"{self.file_prefix}_{cam}_white" yield from self.stubs.send_rpc_and_wait(daq, "file_prefix.set", prefix) yield from self.stubs.send_rpc_and_wait(daq, "num_images.set", self.exp_burst) - yield from self.stubs.send_rpc_and_wait(daq, "bluestage") + yield from self.stubs.send_rpc_and_wait(daq, "arm") for prev in self.prev: yield from self.stubs.send_rpc_and_wait(prev, "arm") for cam in self.cams: - yield from self.stubs.send_rpc_and_wait(cam, "configure", {'exposure_num_burst': self.exp_burst}) - yield from self.stubs.send_rpc_and_wait(cam, "bluestage") + yield from self.stubs.send_rpc_and_wait( + cam, "configure", {"exposure_num_burst": self.exp_burst} + ) + yield from self.stubs.send_rpc_and_wait(cam, "arm") yield from self.pre_scan() yield from self.scan_core() @@ -157,7 +194,7 @@ class AcquireWhiteV2(Acquire): # "Camera": ["exp_time", "exp_burst"] # } -# def __init__(self, +# def __init__(self, # motor: DeviceBase, # exp_burst: int, # sample_position_in: float, @@ -166,7 +203,7 @@ class AcquireWhiteV2(Acquire): # exp_time:float, # **kwargs): # """ -# Acquire projection images. +# Acquire projection images. # Args: # motor : DeviceBase @@ -223,7 +260,7 @@ class AcquireWhiteV2(Acquire): # yield from self._set_position_offset() # def scan_core(self): - + # # move to in position and go to start angular position # yield from self.stubs.set(device=["eyez", self.motor], value=[self.sample_position_in, self.positions[0][0]]) @@ -238,10 +275,10 @@ class AcquireWhiteV2(Acquire): # self.connector.send_client_info( # "Starting the scan", show_asap=True, rid=self.metadata.get("RID") -# ) +# ) # yield from self.stubs.trigger() - + # while not flyer_request.done: # yield from self.stubs.read( @@ -267,13 +304,13 @@ class AcquireRefsV2(Acquire): sample_angle_out: float = 0, sample_position_in: float = 0, sample_position_out: float = 1, - file_prefix_dark: str = 'tmp_dark', - file_prefix_white: str = 'tmp_white', - **kwargs + file_prefix_dark: str = "tmp_dark", + file_prefix_white: str = "tmp_white", + **kwargs, ): """ Acquire reference images (darks + whites) and return to beam position. - + Reference images are acquired automatically in an optimized sequence and the sample is returned to the sample_in_position afterwards. @@ -291,16 +328,16 @@ class AcquireRefsV2(Acquire): sample_position_out : float ,optional Sample stage X position for sample out of the beam [um] exp_time : float, optional - Exposure time [ms]. If not specified, the currently configured value + Exposure time [ms]. If not specified, the currently configured value on the camera will be used exp_period : float, optional Exposure period [ms]. If not specified, the currently configured value on the camera will be used image_width : int, optional - ROI size in the x-direction [pixels]. If not specified, the currently + ROI size in the x-direction [pixels]. If not specified, the currently configured value on the camera will be used image_height : int, optional - ROI size in the y-direction [pixels]. If not specified, the currently + ROI size in the y-direction [pixels]. If not specified, the currently configured value on the camera will be used acq_mode : str, optional Predefined acquisition mode (default= 'default') @@ -334,15 +371,11 @@ class AcquireRefsV2(Acquire): if self.num_darks: msg = f"Acquiring {self.num_darks} dark images" logger.warning(msg) - self.connector.send_client_info( - msg, - show_asap=True, - rid=self.metadata.get("RID"), - ) + self.connector.send_client_info(msg, show_asap=True, rid=self.metadata.get("RID")) darks = AcquireDarkV2( exp_burst=self.num_darks, -# file_prefix=self.file_prefix_dark, + # file_prefix=self.file_prefix_dark, device_manager=self.device_manager, metadata=self.metadata, instruction_handler=self.stubs._instruction_handler, @@ -351,16 +384,11 @@ class AcquireRefsV2(Acquire): yield from darks.direct() self.point_id = darks.point_id - if self.num_flats: msg = f"Acquiring {self.num_flats} flat field images" logger.warning(msg) - self.connector.send_client_info( - msg, - show_asap=True, - rid=self.metadata.get("RID"), - ) + self.connector.send_client_info(msg, show_asap=True, rid=self.metadata.get("RID")) logger.warning("Calling AcquireWhite") flats = AcquireWhiteV2( @@ -373,11 +401,10 @@ class AcquireRefsV2(Acquire): instruction_handler=self.stubs._instruction_handler, **self.caller_kwargs, ) - + flats.point_id = self.point_id yield from flats.direct() self.point_id = flats.point_id ## TODO move sample in beam and do not wait ## TODO move rotation to angle and do not wait logger.warning("[AcquireRefsV2] Done with scan_core") - -- 2.49.1 From 4437bb13b8e9ee399013d172620a5d6b5b78eb76 Mon Sep 17 00:00:00 2001 From: gac-x05la Date: Wed, 16 Apr 2025 15:01:06 +0200 Subject: [PATCH 11/13] WIP --- tomcat_bec/devices/aerotech/AerotechPso.py | 164 +++++++----- tomcat_bec/devices/aerotech/AerotechTasks.py | 140 +++++----- .../devices/gigafrost/gigafrostcamera.py | 9 - tomcat_bec/devices/gigafrost/stddaq_client.py | 249 +++++++++++------- .../devices/gigafrost/stddaq_preview.py | 181 ++++++------- 5 files changed, 406 insertions(+), 337 deletions(-) diff --git a/tomcat_bec/devices/aerotech/AerotechPso.py b/tomcat_bec/devices/aerotech/AerotechPso.py index 9202393..7dab0a3 100644 --- a/tomcat_bec/devices/aerotech/AerotechPso.py +++ b/tomcat_bec/devices/aerotech/AerotechPso.py @@ -7,78 +7,17 @@ synchronized output (PSO) interface. """ from time import sleep import numpy as np -from ophyd import Component, EpicsSignal, EpicsSignalRO, Kind +from ophyd import Device, Component, EpicsSignal, EpicsSignalRO, Kind from ophyd.status import DeviceStatus -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, -) +from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase + from bec_lib import bec_logger + logger = bec_logger.logger -class AerotechPsoDistanceMixin(CustomDeviceMixin): - """Mixin class for self-configuration and staging - """ - # parent : aa1Tasks - def on_stage(self) -> None: - """Configuration and staging - - NOTE: Scans don't have to fully configure the device, that can be done - manually outside. However we expect that the device is disabled - when not in use. I.e. this method is not expected to be called when - PSO is not needed or when it'd conflict with other devices. - """ - # Fish out configuration from scaninfo (does not need to be full configuration) - d = {} - if "kwargs" in self.parent.scaninfo.scan_msg.info: - scanargs = self.parent.scaninfo.scan_msg.info["kwargs"] - if "pso_distance" in scanargs: - d["pso_distance"] = scanargs["pso_distance"] - if "pso_wavemode" in scanargs: - d["pso_wavemode"] = scanargs["pso_wavemode"] - if "pso_w_pulse" in scanargs: - d["pso_w_pulse"] = scanargs["pso_w_pulse"] - if "pso_t_pulse" in scanargs: - d["pso_t_pulse"] = scanargs["pso_t_pulse"] - if "pso_n_pulse" in scanargs: - d["pso_n_pulse"] = scanargs["pso_n_pulse"] - - # Perform bluesky-style configuration - if len(d) > 0: - logger.info(f"[{self.parent.name}] Configuring with:\n{d}") - self.parent.configure(d=d) - - # Stage the PSO distance module - self.parent.bluestage() - - def on_unstage(self): - """Standard bluesky unstage""" - # Ensure output is set to low - # if self.parent.output.value: - # self.parent.toggle() - # Turn off window mode - self.parent.winOutput.set("Off").wait() - self.parent.winEvents.set("Off").wait() - # Turn off distance mode - self.parent.dstEventsEna.set("Off").wait() - self.parent.dstCounterEna.set("Off").wait() - # Disable output - self.parent.outSource.set("None").wait() - # Sleep for one poll period - sleep(0.2) - - def on_trigger(self) -> None | DeviceStatus: - """Fire a single PSO event (i.e. manual software trigger)""" - # Only trigger if distance was set to invalid - logger.warning(f"[{self.parent.name}] Triggerin...") - if self.parent.dstDistanceVal.get() == 0: - status = self.parent._eventSingle.set(1, settle_time=0.1) - return status - - -class aa1AxisPsoBase(PSIDeviceBase): +class aa1AxisPsoBase(PSIDeviceBase, Device): """Position Sensitive Output - Base class This class provides convenience wrappers around the Aerotech IOC's PSO @@ -152,8 +91,34 @@ class aa1AxisPsoBase(PSIDeviceBase): outPin = Component(EpicsSignalRO, "PIN", auto_monitor=True, kind=Kind.config) outSource = Component(EpicsSignal, "SOURCE", put_complete=True, kind=Kind.config) - def trigger(self, settle_time=0.1) -> DeviceStatus: + def __init__( + self, + prefix="", + *, + name, + kind=None, + read_attrs=None, + configuration_attrs=None, + parent=None, + scan_info=None, + **kwargs, + ): + # Need to call super() to 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, + ) + + def fire(self, settle_time=0.1) -> None | DeviceStatus: """Fire a single PSO event (i.e. manual software trigger)""" + # Only trigger if distance was set to invalid + logger.warning(f"[{self.name}] Triggerin...") self._eventSingle.set(1, settle_time=settle_time).wait() status = DeviceStatus(self) status.set_finished() @@ -163,7 +128,7 @@ class aa1AxisPsoBase(PSIDeviceBase): """Toggle waveform outup""" orig_wave_mode = self.waveMode.get() self.waveMode.set("Toggle").wait() - self.trigger(0.1) + self.fire(0.1) self.waveMode.set(orig_wave_mode).wait() def configure(self, d: dict): @@ -232,13 +197,12 @@ class aa1AxisPsoDistance(aa1AxisPsoBase): ``` """ - custom_prepare_cls = AerotechPsoDistanceMixin USER_ACCESS = ["configure", "prepare", "toggle"] _distance_value = None # ######################################################################## # PSO high level interface - def configure(self, d: dict = {}) -> tuple: + def configure(self, d: dict = None) -> tuple: """Simplified configuration interface to access the most common functionality for distance mode PSO. @@ -282,7 +246,49 @@ class aa1AxisPsoDistance(aa1AxisPsoBase): logger.info(f"[{self.name}] PSO configured to {pso_wavemode} mode") return (old, new) - def bluestage(self) -> None: + def on_stage(self) -> None: + """Configuration and staging + + NOTE: Scans don't have to fully configure the device, that can be done + manually outside. However we expect that the device is disabled + when not in use. I.e. this method is not expected to be called when + PSO is not needed or when it'd conflict with other devices. + """ + # Fish out configuration from scaninfo (does not need to be full configuration) + d = {} + if "kwargs" in self.scaninfo.scan_msg.info: + scanargs = self.scaninfo.scan_msg.info["kwargs"] + if "pso_distance" in scanargs: + d["pso_distance"] = scanargs["pso_distance"] + if "pso_wavemode" in scanargs: + d["pso_wavemode"] = scanargs["pso_wavemode"] + if "pso_w_pulse" in scanargs: + d["pso_w_pulse"] = scanargs["pso_w_pulse"] + if "pso_t_pulse" in scanargs: + d["pso_t_pulse"] = scanargs["pso_t_pulse"] + if "pso_n_pulse" in scanargs: + d["pso_n_pulse"] = scanargs["pso_n_pulse"] + + # Perform bluesky-style configuration + if d: + # logger.info(f"[{self.name}] Configuring with:\n{d}") + self.configure(d=d) + + # Stage the PSO distance module + self.arm() + + def on_unstage(self): + """Turn off the PSO module""" + self.disarm() + + def on_trigger(self, settle_time=0.1) -> None | DeviceStatus: + """Fire a single PSO event (i.e. manual software trigger)""" + # Only trigger if distance was set to invalid + # if self.dstDistanceVal.get() == 0: + logger.warning(f"[{self.name}] Triggerin...") + return self.fire(settle_time) + + def arm(self) -> None: """Bluesky style stage""" # Stage the PSO distance module and zero counter if isinstance(self._distance_value, (np.ndarray, list, tuple)): @@ -293,3 +299,19 @@ class aa1AxisPsoDistance(aa1AxisPsoBase): if self.dstDistanceVal.get() > 0: self.dstEventsEna.set("On").wait() self.dstCounterEna.set("On").wait() + + def disarm(self): + """Standard bluesky unstage""" + # Ensure output is set to low + # if self.output.value: + # self.toggle() + # Turn off window mode + self.winOutput.set("Off").wait() + self.winEvents.set("Off").wait() + # Turn off distance mode + self.dstEventsEna.set("Off").wait() + self.dstCounterEna.set("Off").wait() + # Disable output + self.outSource.set("None").wait() + # Sleep for one poll period + sleep(0.2) diff --git a/tomcat_bec/devices/aerotech/AerotechTasks.py b/tomcat_bec/devices/aerotech/AerotechTasks.py index 62ae0c0..d7a2639 100644 --- a/tomcat_bec/devices/aerotech/AerotechTasks.py +++ b/tomcat_bec/devices/aerotech/AerotechTasks.py @@ -5,71 +5,17 @@ interface. @author: mohacsi_i """ -from ophyd import Component, EpicsSignal, EpicsSignalRO, Kind +from ophyd import Device, Component, EpicsSignal, EpicsSignalRO, Kind from ophyd.status import DeviceStatus, SubscriptionStatus -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, -) +from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase + from bec_lib import bec_logger logger = bec_logger.logger -class AerotechTasksMixin(CustomDeviceMixin): - """Mixin class for self-configuration and staging""" - - # parent : aa1Tasks - def on_stage(self) -> None: - """Configuration and staging - - In the BEC model ophyd devices must fish out their own configuration from the 'scaninfo'. - I.e. they need to know which parameters are relevant for them at each scan. - - NOTE: Scans don't have to fully configure the device, that can be done - manually outside. However we expect that the device is disabled - when not in use. I.e. this method is not expected to be called when - PSO is not needed or when it'd conflict with other devices. - """ - # Fish out our configuration from scaninfo (via explicit or generic addressing) - d = {} - if "kwargs" in self.parent.scaninfo.scan_msg.info: - scanargs = self.parent.scaninfo.scan_msg.info["kwargs"] - if self.parent.scaninfo.scan_type in ("script", "scripted"): - # NOTE: Scans don't have to fully configure the device - if "script_text" in scanargs and scanargs["script_text"] is not None: - d["script_text"] = scanargs["script_text"] - if "script_file" in scanargs and scanargs["script_file"] is not None: - d["script_file"] = scanargs["script_file"] - if "script_task" in scanargs and scanargs["script_task"] is not None: - d["script_task"] = scanargs["script_task"] - - # FIXME: The above should be exchanged with: - # d = self.parent.scan_info.scan_msg.scan_parameters.get("aerotech_config") - - # Perform bluesky-style configuration - if d: - # logger.warning(f"[{self.parent.name}] Configuring with:\n{d}") - self.parent.configure(d=d) - - # The actual staging - self.parent.arm() - - def on_unstage(self): - """Stop the currently selected task""" - self.parent.disarm() - - def on_stop(self): - """Stop the currently selected task""" - self.on_unstage() - - def on_kickoff(self): - """Start execution of the selected task""" - self.parent.launch() - - -class aa1Tasks(PSIDeviceBase): +class aa1Tasks(PSIDeviceBase, Device): """Task management API The place to manage tasks and AeroScript user files on the controller. @@ -99,8 +45,8 @@ class aa1Tasks(PSIDeviceBase): ''' """ + USER_ACCESS = ["arm", "disarm", "launch", "kickoff"] - custom_prepare_cls = AerotechTasksMixin _failure = Component(EpicsSignalRO, "FAILURE", auto_monitor=True, kind=Kind.normal) errStatus = Component(EpicsSignalRO, "ERRW", auto_monitor=True, kind=Kind.normal) @@ -117,6 +63,30 @@ class aa1Tasks(PSIDeviceBase): EpicsSignal, "FILEWRITE", string=True, kind=Kind.omitted, put_complete=True ) + def __init__( + self, + prefix="", + *, + name, + kind=None, + read_attrs=None, + configuration_attrs=None, + parent=None, + scan_info=None, + **kwargs, + ): + # Need to call super() to 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, + ) + def configure(self, d: dict) -> tuple: """Configuration interface for flying""" # Common operations @@ -141,6 +111,52 @@ class aa1Tasks(PSIDeviceBase): new = self.read_configuration() return (old, new) + def on_stage(self) -> None: + """Configuration and staging + + In the BEC model ophyd devices must fish out their own configuration from the 'scaninfo'. + I.e. they need to know which parameters are relevant for them at each scan. + + NOTE: Scans don't have to fully configure the device, that can be done + manually outside. However we expect that the device is disabled + when not in use. I.e. this method is not expected to be called when + PSO is not needed or when it'd conflict with other devices. + """ + # Fish out our configuration from scaninfo (via explicit or generic addressing) + d = {} + if "kwargs" in self.scaninfo.scan_msg.info: + scanargs = self.scaninfo.scan_msg.info["kwargs"] + if self.scaninfo.scan_type in ("script", "scripted"): + # NOTE: Scans don't have to fully configure the device + if "script_text" in scanargs and scanargs["script_text"] is not None: + d["script_text"] = scanargs["script_text"] + if "script_file" in scanargs and scanargs["script_file"] is not None: + d["script_file"] = scanargs["script_file"] + if "script_task" in scanargs and scanargs["script_task"] is not None: + d["script_task"] = scanargs["script_task"] + + # FIXME: The above should be exchanged with: + # d = self.scan_info.scan_msg.scan_parameters.get("aerotech_config") + + # Perform bluesky-style configuration + if d: + self.configure(d=d) + + # The actual staging + self.arm() + + def on_unstage(self): + """Stop the currently selected task""" + self.disarm() + + def on_stop(self): + """Stop the currently selected task""" + self.unstage() + + def on_kickoff(self): + """Start execution of the selected task""" + self.launch() + def arm(self) -> None: """Bluesky style stage, prepare, but does not execute""" if self.taskIndex.get() in (0, 1, 2): @@ -165,12 +181,6 @@ class aa1Tasks(PSIDeviceBase): raise RuntimeError("Failed to load task, please check the Aerotech IOC") return status - def kickoff(self): - """Missing kickoff, for real?""" - self.custom_prepare.on_kickoff() - - ########################################################################## - # Bluesky flyer interface def complete(self) -> SubscriptionStatus: """Wait for a RUNNING task""" timestamp_ = 0 diff --git a/tomcat_bec/devices/gigafrost/gigafrostcamera.py b/tomcat_bec/devices/gigafrost/gigafrostcamera.py index 5214704..8527627 100644 --- a/tomcat_bec/devices/gigafrost/gigafrostcamera.py +++ b/tomcat_bec/devices/gigafrost/gigafrostcamera.py @@ -189,13 +189,6 @@ class GigaFrostCamera(PSIDeviceBase, GigaFrostBase): if daq_update: self.backend.set_config(daq_update, force=False) - def disarm(self): - """ Bluesky style unstage""" - # Switch to idle - self.cmdStartCamera.set(0).wait() - if self.autoSoftEnable.get(): - self.cmdSoftEnable.set(0).wait() - def set_acquisition_mode(self, acq_mode): """Set acquisition mode @@ -217,7 +210,6 @@ class GigaFrostCamera(PSIDeviceBase, GigaFrostBase): self.enable_mode = "soft" self.trigger_mode = "auto" self.exposure_mode = "timer" - elif acq_mode in ["ext_enable", "external_enable"]: # NOTE: Trigger using external hardware events via enable input (actually works) # Switch to physical enable signal @@ -228,7 +220,6 @@ class GigaFrostCamera(PSIDeviceBase, GigaFrostBase): self.enable_mode = "external" self.trigger_mode = "auto" self.exposure_mode = "timer" - elif acq_mode == "soft": # NOTE: Fede's configuration for continous streaming # Switch to physical enable signal diff --git a/tomcat_bec/devices/gigafrost/stddaq_client.py b/tomcat_bec/devices/gigafrost/stddaq_client.py index 042cf48..34006b2 100644 --- a/tomcat_bec/devices/gigafrost/stddaq_client.py +++ b/tomcat_bec/devices/gigafrost/stddaq_client.py @@ -13,7 +13,7 @@ import requests import os from ophyd import Signal, Component, Kind -from ophyd.status import SubscriptionStatus, Status +from ophyd.status import SubscriptionStatus from websockets.sync.client import connect, ClientConnection from websockets.exceptions import ConnectionClosedOK, ConnectionClosedError @@ -28,6 +28,8 @@ logger = bec_logger.logger class StdDaqMixin(CustomDeviceMixin): # pylint: disable=protected-access + _mon = None + def on_stage(self) -> None: """Configuration and staging @@ -39,9 +41,6 @@ class StdDaqMixin(CustomDeviceMixin): # Fish out our configuration from scaninfo (via explicit or generic addressing) # NOTE: Scans don't have to fully configure the device d = {} - # scan_parameters = self.parent.scaninfo.scan_msg.scan_parameters - # std_daq_params = scan_parameters.get("std_daq_params") - if "kwargs" in self.parent.scaninfo.scan_msg.info: scanargs = self.parent.scaninfo.scan_msg.info["kwargs"] if "image_width" in scanargs and scanargs["image_width"] is not None: @@ -50,10 +49,6 @@ class StdDaqMixin(CustomDeviceMixin): d["image_height"] = scanargs["image_height"] if "nr_writers" in scanargs and scanargs["nr_writers"] is not None: d["nr_writers"] = scanargs["nr_writers"] - if "system_config" in scanargs and scanargs["system_config"] is not None: - if scanargs["system_config"]["file_directory"]: - file_directory = scanargs["system_config"]["file_directory"] - ### to be used in the future to substitute the procedure using file path if "file_path" in scanargs and scanargs["file_path"] is not None: self.parent.file_path.set(scanargs["file_path"].replace("data", "gpfs")).wait() print(scanargs["file_path"]) @@ -98,20 +93,40 @@ class StdDaqMixin(CustomDeviceMixin): sleep(0.5) # Try to start a new run (reconnects) - self.parent.arm() - - + self.parent.bluestage() + # And start status monitoring + self._mon = Thread(target=self.monitor, daemon=True) + self._mon.start() def on_unstage(self): """Stop a running acquisition and close connection""" print("Creating virtual dataset") self.parent.create_virtual_dataset() - self.parent.disarm() + self.parent.blueunstage() def on_stop(self): """Stop a running acquisition and close connection""" - self.parent.disarm() + self.parent.blueunstage() + def monitor(self) -> None: + """Monitor status messages while connection is open. This will block the reply monitoring + to calling unstage() might throw. Status updates are sent every 1 seconds, but finishing + acquisition means StdDAQ will close connection, so there's no idle state polling. + """ + try: + sleep(0.2) + for msg in self.parent._wsclient: + message = json.loads(msg) + self.parent.runstatus.put(message["status"], force=True) + # logger.info(f"[{self.parent.name}] Pushed status: {message['status']}") + except (ConnectionClosedError, ConnectionClosedOK, AssertionError): + # Libraty throws theese after connection is closed + return + except Exception as ex: + logger.warning(f"[{self.parent.name}] Exception in polling: {ex}") + return + finally: + self._mon = None class StdDaqClient(PSIDeviceBase): @@ -137,9 +152,9 @@ class StdDaqClient(PSIDeviceBase): "nuke", "connect", "message", - "arm", - "disarm", - "complete", + "state", + "bluestage", + "blueunstage", ] _wsclient = None @@ -153,14 +168,12 @@ class StdDaqClient(PSIDeviceBase): file_prefix = Component(Signal, value="file", kind=Kind.config) # Configuration attributes rest_url = Component(Signal, kind=Kind.config, metadata={"write_access": False}) - datasource = Component(Signal, kind=Kind.config, metadata={"write_access": False}) cfg_detector_name = Component(Signal, kind=Kind.config) cfg_detector_type = Component(Signal, kind=Kind.config) cfg_bit_depth = Component(Signal, kind=Kind.config) cfg_pixel_height = Component(Signal, kind=Kind.config) cfg_pixel_width = Component(Signal, kind=Kind.config) cfg_nr_writers = Component(Signal, kind=Kind.config) - _mon = None def __init__( self, @@ -174,7 +187,7 @@ class StdDaqClient(PSIDeviceBase): device_manager=None, ws_url: str = "ws://localhost:8080", rest_url: str = "http://localhost:5000", - data_source_name="", + data_source_name=None, **kwargs, ) -> None: super().__init__( @@ -189,66 +202,67 @@ class StdDaqClient(PSIDeviceBase): ) self.ws_url.set(ws_url, force=True).wait() self.rest_url.set(rest_url, force=True).wait() - self.datasource.set(data_source_name, force=True).wait() + self.data_source_name = data_source_name # Connect to the DAQ and initialize values try: self.get_daq_config(update=True) except Exception as ex: logger.error(f"Failed to connect to the stdDAQ REST API\n{ex}") - # Connect to websockets and start poller - try: - self.connect() - except Exception as ex: - logger.error(f"Failed to connect to the stdDAQ websocket interface\n{ex}") - def connect(self) -> None: + def connect(self) -> ClientConnection: """Connect to the StdDAQ's websockets interface StdDAQ may reject connection for a few seconds after restart, or when it wants so if it fails, wait a bit and try to connect again. """ - # Connect to stdDAQ - logger.debug(f"[{self.name}] Connecting to stdDAQ at {self.ws_url.get()}") - self._wsclient = connect(self.ws_url.get()) - # And start status monitoring - self._mon = Thread(target=self.monitor, daemon=True) - self._mon.start() + num_retry = 0 + while num_retry < 5: + try: + logger.debug(f"[{self.name}] Connecting to stdDAQ at {self.ws_url.get()}") + connection = connect(self.ws_url.get()) + logger.debug(f"[{self.name}] Connected to stdDAQ after {num_retry} tries") + return connection + except ConnectionRefusedError: + num_retry += 1 + sleep(2) + raise ConnectionRefusedError("The stdDAQ websocket interface refused connection 5 times.") - def monitor(self) -> None: - """Monitor status messages while connection is open. Status updates are - sent every 1 seconds, or when there's a transition. - """ - try: - for msg in self._wsclient: - message = json.loads(msg) - self.runstatus.put(message["status"], force=True) - # logger.info(f"[{self.parent.name}] Pushed status: {message['status']}") - except Exception as ex: - logger.warning(f"[{self.name}] Exception in polling: {ex}") - return - finally: - self._mon = None - - def message(self, message: dict) -> None: + def message(self, message: dict, timeout=1, wait_reply=True, client=None) -> None | str: """Send a message to the StdDAQ and receive a reply + + Note: finishing acquisition means StdDAQ will close connection, so + there's no idle state polling. """ # Prepare message msg = json.dumps(message) if isinstance(message, dict) else str(message) # Connect if client was destroyed if self._wsclient is None: - self.connect() + self._wsclient = self.connect() # Send message (reopen connection if needed) msg = json.dumps(message) if isinstance(message, dict) else str(message) try: self._wsclient.send(msg) - except (ConnectionClosedError, ConnectionClosedOK, AttributeError): + except (ConnectionClosedError, ConnectionClosedOK, AttributeError) as ex: # Re-connect if the connection was closed - self.connect() + self._wsclient = self.connect() self._wsclient.send(msg) + # Wait for reply + reply = None + if wait_reply: + try: + reply = self._wsclient.recv(timeout) + return reply + except (ConnectionClosedError, ConnectionClosedOK) as ex: + self._wsclient = None + logger.error(f"[{self.name}] WS connection was closed before reply: {ex}") + except (TimeoutError, RuntimeError) as ex: + logger.error(f"[{self.name}] Error in receiving ws reply: {ex}") + return reply + def configure(self, d: dict = None): """Configure the next scan with the stdDAQ @@ -259,8 +273,6 @@ class StdDaqClient(PSIDeviceBase): images (limited by the ringbuffer size and backend speed). file_path: str, optional File path to save the data, usually GPFS. - file_prefix: str, optional - File prefix to save the data [default = file]. image_width : int, optional ROI size in the x-direction [pixels]. image_height : int, optional @@ -283,10 +295,6 @@ class StdDaqClient(PSIDeviceBase): # Run parameters if "num_points_total" in d: self.num_images.set(d["num_points_total"]).wait() - if "file_path" in d: - self.file_path.set(d["file_path"]).wait() - if "file_prefix" in d: - self.file_prefix.set(d["file_prefix"]).wait() # Restart the DAQ if resolution changed cfg = self.get_daq_config() @@ -298,8 +306,8 @@ class StdDaqClient(PSIDeviceBase): ): # Stop if current status is not idle - # if self.runstatus.get() != "idle": - # logger.warning(f"[{self.name}] stdDAQ reconfiguration might corrupt files") + if self.state() != "idle": + logger.warning(f"[{self.name}] stdDAQ reconfiguration might corrupt files") # Update retrieved config cfg["image_pixel_height"] = int(self.cfg_pixel_height.get()) @@ -310,7 +318,7 @@ class StdDaqClient(PSIDeviceBase): sleep(1) self.get_daq_config(update=True) - def arm(self): + def bluestage(self): """Stages the stdDAQ Opens a new connection to the stdDAQ, sends the start command with @@ -318,58 +326,103 @@ class StdDaqClient(PSIDeviceBase): it for obvious failures. """ # Can't stage into a running exposure - # if self.runstatus.get() != "idle": - # raise RuntimeError(f"[{self.name}] stdDAQ can't stage from state: {self.runstatus.get()}") + if self.state() != "idle": + raise RuntimeError(f"[{self.name}] stdDAQ can't stage from state: {self.state()}") - # Ensure expected shape - self.validate() + # Must make sure that image size matches the data source + if self.data_source_name is not None: + cam_img_w = self.device_manager.devices[self.data_source_name].cfgRoiX.get() + cam_img_h = self.device_manager.devices[self.data_source_name].cfgRoiY.get() + daq_img_w = self.cfg_pixel_width.get() + daq_img_h = self.cfg_pixel_height.get() + + if not (daq_img_w == cam_img_w and daq_img_h == cam_img_h): + raise RuntimeError( + f"[{self.name}] stdDAQ image resolution ({daq_img_w} , {daq_img_h}) does not match camera with ({cam_img_w} , {cam_img_h})" + ) + else: + logger.warning( + f"[{self.name}] stdDAQ image resolution ({daq_img_w} , {daq_img_h}) matches camera with ({cam_img_w} , {cam_img_h})" + ) file_path = self.file_path.get() - file_prefix = self.file_prefix.get() num_images = self.num_images.get() + file_prefix = self.file_prefix.get() + print(file_prefix) # New connection + self._wsclient = self.connect() message = { "command": "start", "path": file_path, "file_prefix": file_prefix, "n_image": num_images, } - self.message(message) + reply = self.message(message) - def is_running(*args, value, timestamp, **kwargs): - result = value not in ["idle", "unknown", "error"] - return result + if reply is not None: + reply = json.loads(reply) + self.runstatus.set(reply["status"], force=True).wait() + logger.info(f"[{self.name}] Start DAQ reply: {reply}") - status = SubscriptionStatus(self.runstatus, is_running, timeout=3, settle_time=0.5) - status.wait() + # Give it more time to reconfigure + if reply["status"] in ("rejected"): + # FIXME: running exposure is a nogo + if reply["reason"] == "driver is busy!": + raise RuntimeError( + f"[{self.name}] Start stdDAQ command rejected: already running" + ) + else: + # Give it more time to consolidate + sleep(1) + else: + # Success!!! + print(f"[{self.name}] Started stdDAQ in: {reply['status']}") + return - logger.warning(f"[{self.name}] Started stdDAQ in: {self.runstatus.get()}") - return status + raise RuntimeError( + f"[{self.name}] Failed to start the stdDAQ in 1 tries, reason: {reply['reason']}" + ) - def disarm(self): + def blueunstage(self): """Unstages the stdDAQ Opens a new connection to the stdDAQ, sends the stop command and waits for the idle state. """ - # Stop the DAQ (will close connection) - reply is always "success" - self.message({"command": "stop"}) + ii = 0 + while ii < 10: + # Stop the DAQ (will close connection) - reply is always "success" + self._wsclient = self.connect() + self.message({"command": "stop_all"}, wait_reply=False) - def is_running(*args, value, timestamp, **kwargs): - result = value in ["idle", "unknown", "error"] - return result + # Let it consolidate + sleep(0.2) - status = SubscriptionStatus(self.runstatus, is_running, timeout=3, settle_time=0.5) - status.wait() + # Check final status (from new connection) + self._wsclient = self.connect() + reply = self.message({"command": "status"}) + if reply is not None: + logger.info(f"[{self.name}] DAQ status reply: {reply}") + reply = json.loads(reply) - logger.warning(f"[{self.name}] Stopped stdDAQ in: {self.runstatus.get()}") - return status + if reply["status"] in ("idle", "error"): + # Only 'idle' state accepted + print(f"DAQ stopped on try {ii}") + return + elif reply["status"] in ("stop"): + # Give it more time to stop + sleep(0.5) + elif ii >= 6: + raise RuntimeError(f"Failed to stop StdDAQ: {reply}") + ii += 1 + raise RuntimeError(f"Failed to stop StdDAQ in time") ########################################################################## # Bluesky flyer interface def complete(self) -> SubscriptionStatus: """Wait for current run. Must end in status 'file_saved'.""" + def is_running(*args, value, timestamp, **kwargs): result = value in ["idle", "file_saved", "error"] return result @@ -377,25 +430,6 @@ class StdDaqClient(PSIDeviceBase): status = SubscriptionStatus(self.runstatus, is_running, settle_time=0.5) return status - def validate(self): - """Validate camera state - - Ensure that data source shape matches with the shape expected by the stdDAQ. - """ - # Must make sure that image size matches the data source - source = self.datasource.get() - if source is not None and len(source) > 0: - if source == "gfcam": - cam_img_w = self.device_manager.devices[source].cfgRoiX.get() - cam_img_h = self.device_manager.devices[source].cfgRoiY.get() - daq_img_w = self.cfg_pixel_width.get() - daq_img_h = self.cfg_pixel_height.get() - - if not (daq_img_w == cam_img_w and daq_img_h == cam_img_h): - raise RuntimeError( - f"[{self.name}] stdDAQ image resolution ({daq_img_w} , {daq_img_h}) does not match camera with ({cam_img_w} , {cam_img_h})" - ) - def get_daq_config(self, update=False) -> dict: """Read the current configuration from the DAQ""" r = requests.get(self.rest_url.get() + "/api/config/get", params={"user": "ioc"}, timeout=2) @@ -458,6 +492,17 @@ class StdDaqClient(PSIDeviceBase): self.set_daq_config(cfg) sleep(restarttime) + def state(self) -> str | None: + """Querry the current system status""" + try: + wsclient = self.connect() + wsclient.send(json.dumps({"command": "status"})) + r = wsclient.recv(timeout=1) + r = json.loads(r) + return r["status"] + except ConnectionRefusedError: + raise + # Automatically connect to microXAS testbench if directly invoked if __name__ == "__main__": diff --git a/tomcat_bec/devices/gigafrost/stddaq_preview.py b/tomcat_bec/devices/gigafrost/stddaq_preview.py index f2e23a6..3a08126 100644 --- a/tomcat_bec/devices/gigafrost/stddaq_preview.py +++ b/tomcat_bec/devices/gigafrost/stddaq_preview.py @@ -19,9 +19,15 @@ from ophyd_devices.interfaces.base_classes.psi_detector_base import ( ) from bec_lib import bec_logger - logger = bec_logger.logger -ZMQ_TOPIC_FILTER = b"" +ZMQ_TOPIC_FILTER = b'' + + +class StdDaqPreviewState(enum.IntEnum): + """Standard DAQ ophyd device states""" + UNKNOWN = 0 + DETACHED = 1 + MONITORING = 2 class StdDaqPreviewMixin(CustomDetectorMixin): @@ -29,19 +35,94 @@ class StdDaqPreviewMixin(CustomDetectorMixin): Parent class: CustomDetectorMixin """ + _mon = None def on_stage(self): """Start listening for preview data stream""" - self.parent.arm() + if self._mon is not None: + self.parent.unstage() + sleep(0.5) + + self.parent.connect() + self._stop_polling = False + self._mon = Thread(target=self.poll, daemon=True) + self._mon.start() def on_unstage(self): """Stop a running preview""" - self.parent.disarm() + if self._mon is not None: + self._stop_polling = True + # Might hang on recv_multipart + self._mon.join(timeout=1) + # So also disconnect the socket + self.parent._socket.disconnect(self.parent.url.get()) def on_stop(self): """Stop a running preview""" self.on_unstage() + def poll(self): + """Collect streamed updates""" + self.parent.status.set(StdDaqPreviewState.MONITORING, force=True) + try: + t_last = time() + while True: + try: + # Exit loop and finish monitoring + if self._stop_polling: + logger.info(f"[{self.parent.name}]\tDetaching monitor") + break + + # pylint: disable=no-member + r = self.parent._socket.recv_multipart(flags=zmq.NOBLOCK) + + # Length and throtling checks + if len(r) != 2: + logger.warning( + f"[{self.parent.name}] Received malformed array of length {len(r)}") + t_curr = time() + t_elapsed = t_curr - t_last + if t_elapsed < self.parent.throttle.get(): + sleep(0.1) + continue + + # Unpack the Array V1 reply to metadata and array data + meta, data = r + + # Update image and update subscribers + header = json.loads(meta) + if header["type"] == "uint16": + image = np.frombuffer(data, dtype=np.uint16) + if image.size != np.prod(header['shape']): + err = f"Unexpected array size of {image.size} for header: {header}" + raise ValueError(err) + image = image.reshape(header['shape']) + + # Update image and update subscribers + self.parent.frame.put(header['frame'], force=True) + self.parent.image_shape.put(header['shape'], force=True) + self.parent.image.put(image, force=True) + self.parent._last_image = image + self.parent._run_subs(sub_type=self.parent.SUB_MONITOR, value=image) + t_last = t_curr + logger.info( + f"[{self.parent.name}] Updated frame {header['frame']}\t" + f"Shape: {header['shape']}\tMean: {np.mean(image):.3f}" + ) + except ValueError: + # Happens when ZMQ partially delivers the multipart message + pass + except zmq.error.Again: + # Happens when receive queue is empty + sleep(0.1) + except Exception as ex: + logger.info(f"[{self.parent.name}]\t{str(ex)}") + raise + finally: + self._mon = None + self.parent.status.set(StdDaqPreviewState.DETACHED, force=True) + logger.info(f"[{self.parent.name}]\tDetaching monitor") + class StdDaqPreviewDetector(PSIDetectorBase): """Detector wrapper class around the StdDaq preview image stream. @@ -53,9 +134,8 @@ class StdDaqPreviewDetector(PSIDetectorBase): You can add a preview widget to the dock by: cam_widget = gui.add_dock('cam_dock1').add_widget('BECFigure').image('daq_stream1') """ - # Subscriptions for plotting image - USER_ACCESS = ["arm", "disarm", "get_last_image"] + USER_ACCESS = ["kickoff", "get_last_image"] SUB_MONITOR = "device_monitor_2d" _default_sub = SUB_MONITOR @@ -64,19 +144,19 @@ class StdDaqPreviewDetector(PSIDetectorBase): # Status attributes url = Component(Signal, kind=Kind.config) throttle = Component(Signal, value=0.25, kind=Kind.config) + status = Component(Signal, value=StdDaqPreviewState.UNKNOWN, kind=Kind.omitted) frame = Component(Signal, kind=Kind.hinted) image_shape = Component(Signal, kind=Kind.normal) # FIXME: The BEC client caches the read()s from the last 50 scans image = Component(Signal, kind=Kind.omitted) _last_image = None - _stop_polling = True - _mon = None def __init__( self, *args, url: str = "tcp://129.129.95.38:20000", parent: Device = None, **kwargs ) -> None: super().__init__(*args, parent=parent, **kwargs) self.url._metadata["write_access"] = False + self.status._metadata["write_access"] = False self.image._metadata["write_access"] = False self.frame._metadata["write_access"] = False self.image_shape._metadata["write_access"] = False @@ -105,88 +185,9 @@ class StdDaqPreviewDetector(PSIDetectorBase): def get_image(self): return self._last_image - def arm(self): - """Start listening for preview data stream""" - if self._mon is not None: - self.unstage() - sleep(0.5) - - self.connect() - self._stop_polling = False - self._mon = Thread(target=self.poll, daemon=True) - self._mon.start() - - def disarm(self): - """Stop a running preview""" - if self._mon is not None: - self._stop_polling = True - # Might hang on recv_multipart - self._mon.join(timeout=1) - # So also disconnect the socket (if not already disconnected) - try: - self._socket.disconnect(self.url.get()) - except zmq.error.ZMQError: - pass - - def poll(self): - """Collect streamed updates""" - try: - t_last = time() - while True: - try: - # Exit loop and finish monitoring - if self._stop_polling: - break - - # pylint: disable=no-member - r = self._socket.recv_multipart(flags=zmq.NOBLOCK) - - # Length and throtling checks - if len(r) != 2: - logger.warning(f"[{self.name}] Received malformed array of length {len(r)}") - t_curr = time() - t_elapsed = t_curr - t_last - if t_elapsed < self.throttle.get(): - sleep(0.1) - continue - - # Unpack the Array V1 reply to metadata and array data - meta, data = r - - # Update image and update subscribers - header = json.loads(meta) - image = None - if header["type"] == "uint16": - image = np.frombuffer(data, dtype=np.uint16) - - if image.size != np.prod(header["shape"]): - err = f"Unexpected array size of {image.size} for header: {header}" - raise ValueError(err) - image = image.reshape(header["shape"]) - - # Update image and update subscribers - self.frame.put(header["frame"], force=True) - self.image_shape.put(header["shape"], force=True) - self.image.put(image, force=True) - self._last_image = image - self._run_subs(sub_type=self.SUB_MONITOR, value=image) - t_last = t_curr - logger.info( - f"[{self.name}] Updated frame {header['frame']}\t" - f"Shape: {header['shape']}\tMean: {np.mean(image):.3f}" - ) - except ValueError: - # Happens when ZMQ partially delivers the multipart message - pass - except zmq.error.Again: - # Happens when receive queue is empty - sleep(0.1) - except Exception as ex: - logger.info(f"[{self.name}]\t{str(ex)}") - raise - finally: - self._mon = None - logger.info(f"[{self.name}]\tDetaching monitor") + def kickoff(self) -> DeviceStatus: + """ The DAQ was not meant to be toggled""" + return DeviceStatus(self, done=True, success=True, settle_time=0.1) # Automatically connect to MicroSAXS testbench if directly invoked -- 2.49.1 From f3961322e3e57fcc0b4617941e6271745d2b060b Mon Sep 17 00:00:00 2001 From: gac-x05la Date: Wed, 16 Apr 2025 15:23:25 +0200 Subject: [PATCH 12/13] WIP --- tests/tests_devices/test_stddaq_client.py | 654 +++++++++--------- .../device_configs/microxas_test_bed.yaml | 50 +- tomcat_bec/devices/aerotech/AerotechPso.py | 2 +- 3 files changed, 353 insertions(+), 353 deletions(-) diff --git a/tests/tests_devices/test_stddaq_client.py b/tests/tests_devices/test_stddaq_client.py index f3cc708..309770a 100644 --- a/tests/tests_devices/test_stddaq_client.py +++ b/tests/tests_devices/test_stddaq_client.py @@ -1,353 +1,353 @@ -import json -from unittest import mock - -import pytest -import requests -import requests_mock -import typeguard -from ophyd import StatusBase -from websockets import WebSocketException - -from tomcat_bec.devices.gigafrost.std_daq_client import StdDaqClient, StdDaqError, StdDaqStatus - - -@pytest.fixture -def client(): - parent_device = mock.MagicMock() - _client = StdDaqClient( - parent=parent_device, ws_url="ws://localhost:5001", rest_url="http://localhost:5000" - ) - yield _client - _client.shutdown() - - -@pytest.fixture -def full_config(): - full_config = dict( - detector_name="tomcat-gf", - detector_type="gigafrost", - n_modules=8, - bit_depth=16, - image_pixel_height=2016, - image_pixel_width=2016, - start_udp_port=2000, - writer_user_id=18600, - max_number_of_forwarders_spawned=8, - use_all_forwarders=True, - module_sync_queue_size=4096, - number_of_writers=12, - module_positions={}, - ram_buffer_gb=150, - delay_filter_timeout=10, - live_stream_configs={ - "tcp://129.129.95.111:20000": {"type": "periodic", "config": [1, 5]}, - "tcp://129.129.95.111:20001": {"type": "periodic", "config": [1, 5]}, - "tcp://129.129.95.38:20000": {"type": "periodic", "config": [1, 1]}, - }, - ) - return full_config - - -def test_stddaq_client(client): - assert client is not None - - -def test_stddaq_client_get_daq_config(client, full_config): - with requests_mock.Mocker() as m: - response = full_config - m.get("http://localhost:5000/api/config/get?user=ioc", json=response.model_dump()) - out = client.get_config() - - # Check that the response is simply the json response - assert out == response.model_dump() - - assert client._config == response - - -def test_stddaq_client_set_config_pydantic(client, full_config): - """Test setting configurations through the StdDAQ client""" - with requests_mock.Mocker() as m: - m.post("http://localhost:5000/api/config/set?user=ioc") - - # Test with StdDaqConfig object - config = full_config - with mock.patch.object(client, "_pre_restart"), mock.patch.object(client, "_post_restart"): - client.set_config(config) - - # Verify the last request - assert m.last_request.json() == full_config.model_dump() - - -def test_std_daq_client_set_config_dict(client, full_config): - """ - Test setting configurations through the StdDAQ client with a dictionary input. - """ +# import json +# from unittest import mock + +# import pytest +# import requests +# import requests_mock +# import typeguard +# from ophyd import StatusBase +# from websockets import WebSocketException + +# from tomcat_bec.devices.gigafrost.std_daq_client import StdDaqClient, StdDaqError, StdDaqStatus + + +# @pytest.fixture +# def client(): +# parent_device = mock.MagicMock() +# _client = StdDaqClient( +# parent=parent_device, ws_url="ws://localhost:5001", rest_url="http://localhost:5000" +# ) +# yield _client +# _client.shutdown() + + +# @pytest.fixture +# def full_config(): +# full_config = dict( +# detector_name="tomcat-gf", +# detector_type="gigafrost", +# n_modules=8, +# bit_depth=16, +# image_pixel_height=2016, +# image_pixel_width=2016, +# start_udp_port=2000, +# writer_user_id=18600, +# max_number_of_forwarders_spawned=8, +# use_all_forwarders=True, +# module_sync_queue_size=4096, +# number_of_writers=12, +# module_positions={}, +# ram_buffer_gb=150, +# delay_filter_timeout=10, +# live_stream_configs={ +# "tcp://129.129.95.111:20000": {"type": "periodic", "config": [1, 5]}, +# "tcp://129.129.95.111:20001": {"type": "periodic", "config": [1, 5]}, +# "tcp://129.129.95.38:20000": {"type": "periodic", "config": [1, 1]}, +# }, +# ) +# return full_config + + +# def test_stddaq_client(client): +# assert client is not None + + +# def test_stddaq_client_get_daq_config(client, full_config): +# with requests_mock.Mocker() as m: +# response = full_config +# m.get("http://localhost:5000/api/config/get?user=ioc", json=response.model_dump()) +# out = client.get_config() + +# # Check that the response is simply the json response +# assert out == response.model_dump() + +# assert client._config == response + + +# def test_stddaq_client_set_config_pydantic(client, full_config): +# """Test setting configurations through the StdDAQ client""" +# with requests_mock.Mocker() as m: +# m.post("http://localhost:5000/api/config/set?user=ioc") + +# # Test with StdDaqConfig object +# config = full_config +# with mock.patch.object(client, "_pre_restart"), mock.patch.object(client, "_post_restart"): +# client.set_config(config) + +# # Verify the last request +# assert m.last_request.json() == full_config.model_dump() + + +# def test_std_daq_client_set_config_dict(client, full_config): +# """ +# Test setting configurations through the StdDAQ client with a dictionary input. +# """ - with requests_mock.Mocker() as m: - m.post("http://localhost:5000/api/config/set?user=ioc") - - # Test with dictionary input - config_dict = full_config.model_dump() - with mock.patch.object(client, "_pre_restart"), mock.patch.object(client, "_post_restart"): - client.set_config(config_dict) - assert m.last_request.json() == full_config.model_dump() +# with requests_mock.Mocker() as m: +# m.post("http://localhost:5000/api/config/set?user=ioc") + +# # Test with dictionary input +# config_dict = full_config.model_dump() +# with mock.patch.object(client, "_pre_restart"), mock.patch.object(client, "_post_restart"): +# client.set_config(config_dict) +# assert m.last_request.json() == full_config.model_dump() -def test_stddaq_client_set_config_ignores_extra_keys(client, full_config): - """ - Test that the set_config method ignores extra keys in the input dictionary. - """ - - with requests_mock.Mocker() as m: - m.post("http://localhost:5000/api/config/set?user=ioc") +# def test_stddaq_client_set_config_ignores_extra_keys(client, full_config): +# """ +# Test that the set_config method ignores extra keys in the input dictionary. +# """ + +# with requests_mock.Mocker() as m: +# m.post("http://localhost:5000/api/config/set?user=ioc") - # Test with dictionary input - config_dict = full_config.model_dump() - config_dict["extra_key"] = "extra_value" - with mock.patch.object(client, "_pre_restart"), mock.patch.object(client, "_post_restart"): - client.set_config(config_dict) - assert m.last_request.json() == full_config.model_dump() +# # Test with dictionary input +# config_dict = full_config.model_dump() +# config_dict["extra_key"] = "extra_value" +# with mock.patch.object(client, "_pre_restart"), mock.patch.object(client, "_post_restart"): +# client.set_config(config_dict) +# assert m.last_request.json() == full_config.model_dump() -def test_stddaq_client_set_config_error(client, full_config): - """ - Test error handling in the set_config method. - """ - with requests_mock.Mocker() as m: - config = full_config - m.post("http://localhost:5000/api/config/set?user=ioc", status_code=500) - with mock.patch.object(client, "_pre_restart"), mock.patch.object(client, "_post_restart"): - with pytest.raises(requests.exceptions.HTTPError): - client.set_config(config) +# def test_stddaq_client_set_config_error(client, full_config): +# """ +# Test error handling in the set_config method. +# """ +# with requests_mock.Mocker() as m: +# config = full_config +# m.post("http://localhost:5000/api/config/set?user=ioc", status_code=500) +# with mock.patch.object(client, "_pre_restart"), mock.patch.object(client, "_post_restart"): +# with pytest.raises(requests.exceptions.HTTPError): +# client.set_config(config) -def test_stddaq_client_get_config_cached(client, full_config): - """ - Test that the client returns the cached configuration if it is available. - """ +# def test_stddaq_client_get_config_cached(client, full_config): +# """ +# Test that the client returns the cached configuration if it is available. +# """ - # Set the cached configuration - config = full_config - client._config = config +# # Set the cached configuration +# config = full_config +# client._config = config - # Test that the client returns the cached configuration - assert client.get_config(cached=True) == config +# # Test that the client returns the cached configuration +# assert client.get_config(cached=True) == config -def test_stddaq_client_status(client): - client._status = StdDaqStatus.FILE_CREATED - assert client.status == StdDaqStatus.FILE_CREATED +# def test_stddaq_client_status(client): +# client._status = StdDaqStatus.FILE_CREATED +# assert client.status == StdDaqStatus.FILE_CREATED -def test_stddaq_client_start(client): +# def test_stddaq_client_start(client): - with mock.patch("tomcat_bec.devices.gigafrost.std_daq_client.StatusBase") as StatusBase: - client.start(file_path="test_file_path", file_prefix="test_file_prefix", num_images=10) - out = client._send_queue.get() - assert out == { - "command": "start", - "path": "test_file_path", - "file_prefix": "test_file_prefix", - "n_image": 10, - } - StatusBase().wait.assert_called_once() +# with mock.patch("tomcat_bec.devices.gigafrost.std_daq_client.StatusBase") as StatusBase: +# client.start(file_path="test_file_path", file_prefix="test_file_prefix", num_images=10) +# out = client._send_queue.get() +# assert out == { +# "command": "start", +# "path": "test_file_path", +# "file_prefix": "test_file_prefix", +# "n_image": 10, +# } +# StatusBase().wait.assert_called_once() -def test_stddaq_client_start_type_error(client): - with pytest.raises(typeguard.TypeCheckError): - client.start(file_path="test_file_path", file_prefix="test_file_prefix", num_images="10") +# def test_stddaq_client_start_type_error(client): +# with pytest.raises(typeguard.TypeCheckError): +# client.start(file_path="test_file_path", file_prefix="test_file_prefix", num_images="10") -def test_stddaq_client_stop(client): - """ - Check that the stop method puts the stop command in the send queue. - """ - client.stop() - client._send_queue.get() == {"command": "stop"} +# def test_stddaq_client_stop(client): +# """ +# Check that the stop method puts the stop command in the send queue. +# """ +# client.stop() +# client._send_queue.get() == {"command": "stop"} -def test_stddaq_client_update_config(client, full_config): - """ - Test that the update_config method updates the configuration with the provided dictionary. - """ +# def test_stddaq_client_update_config(client, full_config): +# """ +# Test that the update_config method updates the configuration with the provided dictionary. +# """ - config = full_config - with requests_mock.Mocker() as m: - m.get("http://localhost:5000/api/config/get?user=ioc", json=config.model_dump()) +# config = full_config +# with requests_mock.Mocker() as m: +# m.get("http://localhost:5000/api/config/get?user=ioc", json=config.model_dump()) - # Update the configuration - update_dict = {"detector_name": "new_name"} - with mock.patch.object(client, "set_config") as set_config: - client.update_config(update_dict) +# # Update the configuration +# update_dict = {"detector_name": "new_name"} +# with mock.patch.object(client, "set_config") as set_config: +# client.update_config(update_dict) - assert set_config.call_count == 1 +# assert set_config.call_count == 1 -def test_stddaq_client_updates_only_changed_configs(client, full_config): - """ - Test that the update_config method only updates the configuration if the config has changed. - """ - - config = full_config - with requests_mock.Mocker() as m: - m.get("http://localhost:5000/api/config/get?user=ioc", json=config.model_dump()) - - # Update the configuration - update_dict = {"detector_name": "tomcat-gf"} - with mock.patch.object(client, "set_config") as set_config: - client.update_config(update_dict) - - assert set_config.call_count == 0 - - -def test_stddaq_client_updates_only_changed_configs_empty(client, full_config): - """ - Test that the update_config method only updates the configuration if the config has changed. - """ - - config = full_config - with requests_mock.Mocker() as m: - m.get("http://localhost:5000/api/config/get?user=ioc", json=config.model_dump()) - - # Update the configuration - update_dict = {} - with mock.patch.object(client, "set_config") as set_config: - client.update_config(update_dict) - - assert set_config.call_count == 0 - - -def test_stddaq_client_pre_restart(client): - """ - Test that the pre_restart method sets the status to RESTARTING. - """ - # let's assume the websocket loop is already idle - client._ws_idle_event.set() - client.ws_client = mock.MagicMock() - client._pre_restart() - client.ws_client.close.assert_called_once() - - -def test_stddaq_client_post_restart(client): - """ - Test that the post_restart method sets the status to IDLE. - """ - with mock.patch.object(client, "wait_for_connection") as wait_for_connection: - client._post_restart() - wait_for_connection.assert_called_once() - assert client._daq_is_running.is_set() - - -def test_stddaq_client_reset(client): - """ - Test that the reset method calls get_config and set_config. - """ - with ( - mock.patch.object(client, "get_config") as get_config, - mock.patch.object(client, "set_config") as set_config, - ): - client.reset() - get_config.assert_called_once() - set_config.assert_called_once() - - -def test_stddaq_client_run_status_callbacks(client): - """ - Test that the run_status_callback method runs the status callback. - """ - status = StatusBase() - client.add_status_callback(status, success=[StdDaqStatus.FILE_CREATED], error=[]) - client._status = StdDaqStatus.FILE_CREATED - client._run_status_callbacks() - status.wait() - - assert len(status._callbacks) == 0 - - -def test_stddaq_client_run_status_callbacks_error(client): - """ - Test that the run_status_callback method runs the status callback. - """ - status = StatusBase() - client.add_status_callback(status, success=[], error=[StdDaqStatus.FILE_CREATED]) - client._status = StdDaqStatus.FILE_CREATED - client._run_status_callbacks() - with pytest.raises(StdDaqError): - status.wait() - - assert len(status._callbacks) == 0 - - -@pytest.mark.parametrize( - "msg, updated", - [({"status": "IDLE"}, False), (json.dumps({"status": "waiting_for_first_image"}), True)], -) -def test_stddaq_client_on_received_ws_message(client, msg, updated): - """ - Test that the on_received_ws_message method runs the status callback. - """ - client._status = None - with mock.patch.object(client, "_run_status_callbacks") as run_status_callbacks: - client._on_received_ws_message(msg) - if updated: - run_status_callbacks.assert_called_once() - assert client._status == StdDaqStatus.WAITING_FOR_FIRST_IMAGE - else: - run_status_callbacks.assert_not_called() - assert client._status is None - - -def test_stddaq_client_ws_send_and_receive(client): - - client.ws_client = mock.MagicMock() - client._send_queue.put({"command": "test"}) - client._ws_send_and_receive() - # queue is not empty, so we should send the message - client.ws_client.send.assert_called_once() - client.ws_client.recv.assert_called_once() - - client.ws_client.reset_mock() - client._ws_send_and_receive() - # queue is empty, so we should not send the message - client.ws_client.send.assert_not_called() - client.ws_client.recv.assert_called_once() - - -def test_stddaq_client_ws_send_and_receive_websocket_error(client): - """ - Test that the ws_send_and_receive method handles websocket errors. - """ - client.ws_client = mock.MagicMock() - client.ws_client.send.side_effect = WebSocketException() - client._send_queue.put({"command": "test"}) - with mock.patch.object(client, "wait_for_connection") as wait_for_connection: - client._ws_send_and_receive() - wait_for_connection.assert_called_once() - - -def test_stddaq_client_ws_send_and_receive_timeout_error(client): - """ - Test that the ws_send_and_receive method handles timeout errors. - """ - client.ws_client = mock.MagicMock() - client.ws_client.recv.side_effect = TimeoutError() - client._send_queue.put({"command": "test"}) - with mock.patch.object(client, "wait_for_connection") as wait_for_connection: - client._ws_send_and_receive() - wait_for_connection.assert_not_called() - - -def test_stddaq_client_ws_update_loop(client): - """ - Test that the ws_update_loop method runs the status callback. - """ - client._shutdown_event = mock.MagicMock() - client._shutdown_event.is_set.side_effect = [False, True] - with ( - mock.patch.object(client, "_ws_send_and_receive") as ws_send_and_receive, - mock.patch.object(client, "_wait_for_server_running") as wait_for_server_running, - ): - client._ws_update_loop() - - ws_send_and_receive.assert_called_once() - wait_for_server_running.assert_called_once() +# def test_stddaq_client_updates_only_changed_configs(client, full_config): +# """ +# Test that the update_config method only updates the configuration if the config has changed. +# """ + +# config = full_config +# with requests_mock.Mocker() as m: +# m.get("http://localhost:5000/api/config/get?user=ioc", json=config.model_dump()) + +# # Update the configuration +# update_dict = {"detector_name": "tomcat-gf"} +# with mock.patch.object(client, "set_config") as set_config: +# client.update_config(update_dict) + +# assert set_config.call_count == 0 + + +# def test_stddaq_client_updates_only_changed_configs_empty(client, full_config): +# """ +# Test that the update_config method only updates the configuration if the config has changed. +# """ + +# config = full_config +# with requests_mock.Mocker() as m: +# m.get("http://localhost:5000/api/config/get?user=ioc", json=config.model_dump()) + +# # Update the configuration +# update_dict = {} +# with mock.patch.object(client, "set_config") as set_config: +# client.update_config(update_dict) + +# assert set_config.call_count == 0 + + +# def test_stddaq_client_pre_restart(client): +# """ +# Test that the pre_restart method sets the status to RESTARTING. +# """ +# # let's assume the websocket loop is already idle +# client._ws_idle_event.set() +# client.ws_client = mock.MagicMock() +# client._pre_restart() +# client.ws_client.close.assert_called_once() + + +# def test_stddaq_client_post_restart(client): +# """ +# Test that the post_restart method sets the status to IDLE. +# """ +# with mock.patch.object(client, "wait_for_connection") as wait_for_connection: +# client._post_restart() +# wait_for_connection.assert_called_once() +# assert client._daq_is_running.is_set() + + +# def test_stddaq_client_reset(client): +# """ +# Test that the reset method calls get_config and set_config. +# """ +# with ( +# mock.patch.object(client, "get_config") as get_config, +# mock.patch.object(client, "set_config") as set_config, +# ): +# client.reset() +# get_config.assert_called_once() +# set_config.assert_called_once() + + +# def test_stddaq_client_run_status_callbacks(client): +# """ +# Test that the run_status_callback method runs the status callback. +# """ +# status = StatusBase() +# client.add_status_callback(status, success=[StdDaqStatus.FILE_CREATED], error=[]) +# client._status = StdDaqStatus.FILE_CREATED +# client._run_status_callbacks() +# status.wait() + +# assert len(status._callbacks) == 0 + + +# def test_stddaq_client_run_status_callbacks_error(client): +# """ +# Test that the run_status_callback method runs the status callback. +# """ +# status = StatusBase() +# client.add_status_callback(status, success=[], error=[StdDaqStatus.FILE_CREATED]) +# client._status = StdDaqStatus.FILE_CREATED +# client._run_status_callbacks() +# with pytest.raises(StdDaqError): +# status.wait() + +# assert len(status._callbacks) == 0 + + +# @pytest.mark.parametrize( +# "msg, updated", +# [({"status": "IDLE"}, False), (json.dumps({"status": "waiting_for_first_image"}), True)], +# ) +# def test_stddaq_client_on_received_ws_message(client, msg, updated): +# """ +# Test that the on_received_ws_message method runs the status callback. +# """ +# client._status = None +# with mock.patch.object(client, "_run_status_callbacks") as run_status_callbacks: +# client._on_received_ws_message(msg) +# if updated: +# run_status_callbacks.assert_called_once() +# assert client._status == StdDaqStatus.WAITING_FOR_FIRST_IMAGE +# else: +# run_status_callbacks.assert_not_called() +# assert client._status is None + + +# def test_stddaq_client_ws_send_and_receive(client): + +# client.ws_client = mock.MagicMock() +# client._send_queue.put({"command": "test"}) +# client._ws_send_and_receive() +# # queue is not empty, so we should send the message +# client.ws_client.send.assert_called_once() +# client.ws_client.recv.assert_called_once() + +# client.ws_client.reset_mock() +# client._ws_send_and_receive() +# # queue is empty, so we should not send the message +# client.ws_client.send.assert_not_called() +# client.ws_client.recv.assert_called_once() + + +# def test_stddaq_client_ws_send_and_receive_websocket_error(client): +# """ +# Test that the ws_send_and_receive method handles websocket errors. +# """ +# client.ws_client = mock.MagicMock() +# client.ws_client.send.side_effect = WebSocketException() +# client._send_queue.put({"command": "test"}) +# with mock.patch.object(client, "wait_for_connection") as wait_for_connection: +# client._ws_send_and_receive() +# wait_for_connection.assert_called_once() + + +# def test_stddaq_client_ws_send_and_receive_timeout_error(client): +# """ +# Test that the ws_send_and_receive method handles timeout errors. +# """ +# client.ws_client = mock.MagicMock() +# client.ws_client.recv.side_effect = TimeoutError() +# client._send_queue.put({"command": "test"}) +# with mock.patch.object(client, "wait_for_connection") as wait_for_connection: +# client._ws_send_and_receive() +# wait_for_connection.assert_not_called() + + +# def test_stddaq_client_ws_update_loop(client): +# """ +# Test that the ws_update_loop method runs the status callback. +# """ +# client._shutdown_event = mock.MagicMock() +# client._shutdown_event.is_set.side_effect = [False, True] +# with ( +# mock.patch.object(client, "_ws_send_and_receive") as ws_send_and_receive, +# mock.patch.object(client, "_wait_for_server_running") as wait_for_server_running, +# ): +# client._ws_update_loop() + +# ws_send_and_receive.assert_called_once() +# wait_for_server_running.assert_called_once() diff --git a/tomcat_bec/device_configs/microxas_test_bed.yaml b/tomcat_bec/device_configs/microxas_test_bed.yaml index 34da353..f0a0d51 100644 --- a/tomcat_bec/device_configs/microxas_test_bed.yaml +++ b/tomcat_bec/device_configs/microxas_test_bed.yaml @@ -11,19 +11,6 @@ eyex: readOnly: false softwareTrigger: false -eyey: - readoutPriority: baseline - description: X-ray eye axis Y - deviceClass: tomcat_bec.devices.psimotor.EpicsMotorEC - deviceConfig: - prefix: MTEST-X05LA-ES2-XRAYEYE:M2 - deviceTags: - - xray-eye - onFailure: buffer - enabled: true - readOnly: false - softwareTrigger: false - eyez: readoutPriority: baseline description: X-ray eye axis Z @@ -51,18 +38,31 @@ femto_mean_curr: readOnly: true softwareTrigger: false -# es1_roty: -# readoutPriority: monitored -# description: 'Test rotation stage' -# deviceClass: ophyd.EpicsMotor -# deviceConfig: -# prefix: X02DA-ES1-SMP1:ROTY -# deviceTags: -# - es1-sam -# onFailure: buffer -# enabled: true -# readOnly: false -# softwareTrigger: false +es1_roty: + readoutPriority: monitored + description: 'Test rotation stage' + deviceClass: ophyd.EpicsMotor + deviceConfig: + prefix: X02DA-ES1-SMP1:ROTY + deviceTags: + - es1-sam + onFailure: buffer + enabled: true + readOnly: false + softwareTrigger: false + +es1_trx: + readoutPriority: monitored + description: 'Test translation stage' + deviceClass: ophyd.EpicsMotor + deviceConfig: + prefix: X02DA-ES1-SMP1:TRX + deviceTags: + - es1-sam + onFailure: buffer + enabled: true + readOnly: false + softwareTrigger: false es1_ismc: description: 'Automation1 iSMC interface' diff --git a/tomcat_bec/devices/aerotech/AerotechPso.py b/tomcat_bec/devices/aerotech/AerotechPso.py index 7dab0a3..9e97f0e 100644 --- a/tomcat_bec/devices/aerotech/AerotechPso.py +++ b/tomcat_bec/devices/aerotech/AerotechPso.py @@ -197,7 +197,7 @@ class aa1AxisPsoDistance(aa1AxisPsoBase): ``` """ - USER_ACCESS = ["configure", "prepare", "toggle"] + USER_ACCESS = ["configure", "fire", "toggle", "arm", "disarm"] _distance_value = None # ######################################################################## -- 2.49.1 From 9a878db49a6290b3429a9c2177485675a619e2c6 Mon Sep 17 00:00:00 2001 From: gac-x05la Date: Wed, 16 Apr 2025 15:47:28 +0200 Subject: [PATCH 13/13] Device code to 9.6 --- tomcat_bec/devices/__init__.py | 1 - .../devices/aerotech/AerotechDriveDataCollection.py | 1 + tomcat_bec/devices/aerotech/AerotechPso.py | 5 +++-- tomcat_bec/devices/aerotech/AerotechTasks.py | 9 +++++++-- tomcat_bec/devices/aerotech/__init__.py | 7 ++++++- 5 files changed, 17 insertions(+), 6 deletions(-) diff --git a/tomcat_bec/devices/__init__.py b/tomcat_bec/devices/__init__.py index a9d2c1d..0d91887 100644 --- a/tomcat_bec/devices/__init__.py +++ b/tomcat_bec/devices/__init__.py @@ -1,7 +1,6 @@ from .aerotech import ( aa1AxisDriveDataCollection, aa1AxisPsoDistance, - aa1Controller, aa1GlobalVariableBindings, aa1GlobalVariables, aa1Tasks, diff --git a/tomcat_bec/devices/aerotech/AerotechDriveDataCollection.py b/tomcat_bec/devices/aerotech/AerotechDriveDataCollection.py index b8c8efc..5bcc858 100644 --- a/tomcat_bec/devices/aerotech/AerotechDriveDataCollection.py +++ b/tomcat_bec/devices/aerotech/AerotechDriveDataCollection.py @@ -63,6 +63,7 @@ class aa1AxisDriveDataCollection(PSIDeviceBase, Device): USER_ACCESS = ["configure", "reset", "arm", "disarm"] + # pylint: disable=duplicate-code, too-many-arguments def __init__( self, prefix="", diff --git a/tomcat_bec/devices/aerotech/AerotechPso.py b/tomcat_bec/devices/aerotech/AerotechPso.py index 9e97f0e..cdd29d6 100644 --- a/tomcat_bec/devices/aerotech/AerotechPso.py +++ b/tomcat_bec/devices/aerotech/AerotechPso.py @@ -17,7 +17,7 @@ from bec_lib import bec_logger logger = bec_logger.logger -class aa1AxisPsoBase(PSIDeviceBase, Device): +class AerotechPsoBase(PSIDeviceBase, Device): """Position Sensitive Output - Base class This class provides convenience wrappers around the Aerotech IOC's PSO @@ -91,6 +91,7 @@ class aa1AxisPsoBase(PSIDeviceBase, Device): outPin = Component(EpicsSignalRO, "PIN", auto_monitor=True, kind=Kind.config) outSource = Component(EpicsSignal, "SOURCE", put_complete=True, kind=Kind.config) + # pylint: disable=duplicate-code, too-many-arguments def __init__( self, prefix="", @@ -168,7 +169,7 @@ class aa1AxisPsoBase(PSIDeviceBase, Device): self.outSource.set("Window").wait() -class aa1AxisPsoDistance(aa1AxisPsoBase): +class aa1AxisPsoDistance(AerotechPsoBase): """Position Sensitive Output - Distance mode This class provides convenience wrappers around the Aerotech API's PSO functionality in diff --git a/tomcat_bec/devices/aerotech/AerotechTasks.py b/tomcat_bec/devices/aerotech/AerotechTasks.py index d7a2639..a094c0d 100644 --- a/tomcat_bec/devices/aerotech/AerotechTasks.py +++ b/tomcat_bec/devices/aerotech/AerotechTasks.py @@ -6,7 +6,7 @@ interface. @author: mohacsi_i """ from ophyd import Device, Component, EpicsSignal, EpicsSignalRO, Kind -from ophyd.status import DeviceStatus, SubscriptionStatus +from ophyd.status import SubscriptionStatus from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase @@ -63,6 +63,7 @@ class aa1Tasks(PSIDeviceBase, Device): EpicsSignal, "FILEWRITE", string=True, kind=Kind.omitted, put_complete=True ) + # pylint: disable=duplicate-code, too-many-arguments def __init__( self, prefix="", @@ -88,7 +89,11 @@ class aa1Tasks(PSIDeviceBase, Device): ) def configure(self, d: dict) -> tuple: - """Configuration interface for flying""" + """Configure the scripting interface + + Handles AeroScript loading and the launching of existing script files + on the Automation1 iSMC. The interface is meant to be used for flying. + """ # Common operations old = self.read_configuration() self.switch.set("Reset").wait() diff --git a/tomcat_bec/devices/aerotech/__init__.py b/tomcat_bec/devices/aerotech/__init__.py index 477e0be..5fe62b6 100644 --- a/tomcat_bec/devices/aerotech/__init__.py +++ b/tomcat_bec/devices/aerotech/__init__.py @@ -1,4 +1,9 @@ from .AerotechTasks import aa1Tasks from .AerotechPso import aa1AxisPsoDistance from .AerotechDriveDataCollection import aa1AxisDriveDataCollection -from .AerotechAutomation1 import aa1Controller, aa1GlobalVariables, aa1GlobalVariableBindings, aa1AxisIo +from .AerotechAutomation1 import ( + aa1Controller, + aa1GlobalVariables, + aa1GlobalVariableBindings, + aa1AxisIo, +) -- 2.49.1