First round of bamline feedback #18

Merged
mohacsi_i merged 12 commits from feature/first-beamline-feedback into main 2025-01-23 12:47:44 +01:00
15 changed files with 1157 additions and 397 deletions

View File

@@ -49,9 +49,9 @@ femto_mean_curr:
softwareTrigger: false
es1_roty:
readoutPriority: baseline
readoutPriority: monitored
description: 'Test rotation stage'
deviceClass: tomcat_bec.devices.psimotor.EpicsMotorMR
deviceClass: ophyd.EpicsMotor
deviceConfig:
prefix: X02DA-ES1-SMP1:ROTY
deviceTags:
@@ -61,59 +61,59 @@ es1_roty:
readOnly: false
softwareTrigger: false
es1_ismc:
description: 'Automation1 iSMC interface'
deviceClass: tomcat_bec.devices.aa1Controller
deviceConfig:
prefix: 'X02DA-ES1-SMP1:CTRL:'
deviceTags:
- es1
enabled: true
onFailure: buffer
readOnly: false
readoutPriority: monitored
softwareTrigger: false
# es1_ismc:
# description: 'Automation1 iSMC interface'
# deviceClass: tomcat_bec.devices.aa1Controller
# deviceConfig:
# prefix: 'X02DA-ES1-SMP1:CTRL:'
# 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_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:
description: 'AA1 PSO output interface (trigger)'
deviceClass: tomcat_bec.devices.aa1AxisPsoDistance
deviceConfig:
prefix: 'X02DA-ES1-SMP1:ROTY:PSO:'
deviceTags:
- es1
enabled: true
onFailure: buffer
readOnly: false
readoutPriority: monitored
softwareTrigger: true
# es1_psod:
# description: 'AA1 PSO output interface (trigger)'
# deviceClass: tomcat_bec.devices.aa1AxisPsoDistance
# deviceConfig:
# prefix: 'X02DA-ES1-SMP1:ROTY:PSO:'
# deviceTags:
# - es1
# enabled: true
# onFailure: buffer
# readOnly: false
# readoutPriority: monitored
# 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:
@@ -151,6 +151,7 @@ gfdaq:
deviceConfig:
ws_url: 'ws://129.129.95.111:8080'
rest_url: 'http://129.129.95.111:5000'
data_source_name: 'gfcam'
deviceTags:
- std-daq
enabled: true

View File

@@ -0,0 +1,186 @@
eyex:
readoutPriority: baseline
description: X-ray eye axis X
deviceClass: tomcat_bec.devices.psimotor.EpicsMotorEC
deviceConfig:
prefix: MTEST-X05LA-ES2-XRAYEYE:M1
deviceTags:
- xray-eye
onFailure: buffer
enabled: true
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
# deviceClass: tomcat_bec.devices.psimotor.EpicsMotorEC
# deviceConfig:
# prefix: MTEST-X05LA-ES2-XRAYEYE:M3
# deviceTags:
# - xray-eye
# onFailure: buffer
# enabled: true
# readOnly: false
# softwareTrigger: false
femto_mean_curr:
readoutPriority: monitored
description: Femto mean current
deviceClass: ophyd.EpicsSignal
deviceConfig:
auto_monitor: true
read_pv: MTEST-X05LA-ES2-XRAYEYE:FEMTO-MEAN-CURR
deviceTags:
- xray-eye
onFailure: buffer
enabled: true
readOnly: true
softwareTrigger: false
es1_roty:
readoutPriority: baseline
description: 'Test rotation stage'
deviceClass: tomcat_bec.devices.psimotor.EpicsMotorMR
deviceConfig:
prefix: X02DA-ES1-SMP1:ROTY
deviceTags:
- es1-sam
onFailure: buffer
enabled: true
readOnly: false
softwareTrigger: false
es1_ismc:
description: 'Automation1 iSMC interface'
deviceClass: tomcat_bec.devices.aa1Controller
deviceConfig:
prefix: 'X02DA-ES1-SMP1:CTRL:'
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:
description: 'AA1 PSO output interface (trigger)'
deviceClass: tomcat_bec.devices.aa1AxisPsoDistance
deviceConfig:
prefix: 'X02DA-ES1-SMP1:ROTY:PSO:'
deviceTags:
- es1
enabled: true
onFailure: buffer
readOnly: false
readoutPriority: monitored
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
#camera:
# description: Grashopper Camera
# deviceClass: tomcat_bec.devices.GrashopperTOMCAT
# deviceConfig:
# prefix: 'X02DA-PG-USB:'
# deviceTags:
# - camera
# enabled: true
# onFailure: buffer
# readOnly: false
# readoutPriority: monitored
# softwareTrigger: true
gfcam:
description: GigaFrost camera client
deviceClass: tomcat_bec.devices.GigaFrostCamera
deviceConfig:
prefix: 'X02DA-CAM-GF2:'
backend_url: 'http://sls-daq-001:8080'
auto_soft_enable: true
deviceTags:
- camera
- trigger
enabled: true
onFailure: buffer
readOnly: false
readoutPriority: monitored
softwareTrigger: true
gfdaq:
description: GigaFrost stdDAQ client
deviceClass: tomcat_bec.devices.StdDaqClient
deviceConfig:
ws_url: 'ws://129.129.95.111:8080'
rest_url: 'http://129.129.95.111:5000'
deviceTags:
- std-daq
enabled: true
onFailure: buffer
readOnly: false
readoutPriority: monitored
softwareTrigger: false
daq_stream0:
description: stdDAQ preview (2 every 555)
deviceClass: tomcat_bec.devices.StdDaqPreviewDetector
deviceConfig:
url: 'tcp://129.129.95.111:20000'
deviceTags:
- std-daq
enabled: true
onFailure: buffer
readOnly: false
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
enabled: true
onFailure: buffer
readOnly: false
readoutPriority: monitored
softwareTrigger: false

View File

@@ -1,7 +1,11 @@
from time import sleep
# -*- coding: utf-8 -*-
"""
Ophyd device for the Aerotech Automation1 IOC's generic interfaces
@author: mohacsi_i
"""
import numpy as np
from ophyd import Component, Device, EpicsMotor, EpicsSignal, EpicsSignalRO, Kind
from ophyd.status import DeviceStatus, SubscriptionStatus
from bec_lib import bec_logger
logger = bec_logger.logger
@@ -38,8 +42,8 @@ class aa1Controller(Device):
taskcount = Component(EpicsSignalRO, "TASKCOUNT", kind=Kind.config)
fastpoll = Component(EpicsSignalRO, "POLLTIME", auto_monitor=True, kind=Kind.normal)
slowpoll = Component(EpicsSignalRO, "DRVPOLLTIME", auto_monitor=True, kind=Kind.normal)
errno = Component(EpicsSignalRO, "ERRNO", auto_monitor=True, kind=Kind.hinted)
errnmsg = Component(EpicsSignalRO, "ERRMSG", auto_monitor=True, kind=Kind.hinted)
errno = Component(EpicsSignalRO, "ERRNO", auto_monitor=True, kind=Kind.normal)
errnmsg = Component(EpicsSignalRO, "ERRMSG", auto_monitor=True, kind=Kind.normal)
_set_ismc = Component(EpicsSignal, "SET", put_complete=True, kind=Kind.omitted)
USER_ACCESS = ["reset"]
@@ -111,10 +115,10 @@ class aa1GlobalVariables(Device):
if size is None or size == 0:
self.integer_addr.set(address).wait()
return self.integer_rb.get()
else:
self.integer_addr.set(address).wait()
self.integer_size.set(size).wait()
return self.integerarr_rb.get()
self.integer_addr.set(address).wait()
self.integer_size.set(size).wait()
return self.integerarr_rb.get()
def write_int(self, address: int, value, settle_time=0.1) -> None:
"""Write a 64-bit integer global variable
@@ -150,10 +154,10 @@ class aa1GlobalVariables(Device):
if size is None:
self.real_addr.set(address).wait()
return self.real_rb.get()
else:
self.real_addr.set(address).wait()
self.real_size.set(size).wait()
return self.realarr_rb.get()
self.real_addr.set(address).wait()
self.real_size.set(size).wait()
return self.realarr_rb.get()
def write_float(self, address: int, value, settle_time=0.1) -> None:
"""Write a 64-bit float global variable"""

View File

@@ -1,5 +1,14 @@
# -*- coding: utf-8 -*-
"""
Enumerations for the Aerotech Automation1 controller as adopted from the Aerotech library.
@author: mohacsi_i
"""
from enum import Enum
# pylint: disable=missing-class-docstring
# pylint: disable=too-few-public-methods
class TomcatSequencerState:
IDLE = 0

View File

@@ -1,15 +1,16 @@
# -*- coding: utf-8 -*-
"""
Ophyd device for the Aerotech Automation1 IOC's axis-specific synchronized
drive data collection (DDC) interface.
@author: mohacsi_i
"""
import time
from collections import OrderedDict
from ophyd import Component, EpicsSignal, EpicsSignalRO, Kind
from ophyd.status import SubscriptionStatus
try:
from AerotechAutomation1Enums import DriveDataCaptureInput, DriveDataCaptureTrigger
except ModuleNotFoundError:
from tomcat_bec.devices.aerotech.AerotechAutomation1Enums import DriveDataCaptureInput
from tomcat_bec.devices.aerotech.AerotechAutomation1Enums import DriveDataCaptureTrigger
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,
@@ -20,7 +21,7 @@ logger = bec_logger.logger
class AerotechDriveDataCollectionMixin(CustomDeviceMixin):
"""Configuration and staging
"""Mixin class for self-configuration and staging
NOTE: scripted scans start drive data collection internally
"""
@@ -29,23 +30,29 @@ class AerotechDriveDataCollectionMixin(CustomDeviceMixin):
"""Configuration and staging"""
# Fish out configuration from scaninfo (does not need to be full configuration)
scanparam = self.parent.scaninfo.scan_msg.info
alias = self.parent.parent.name if self.parent.parent is not None else self.parent.name
logger.warning(f"[{alias}] Scan parameters:\n{scanparam}")
d = {}
if "kwargs" in scanparam:
scanargs = scanparam["kwargs"]
if "kwargs" in self.parent.scaninfo.scan_msg.info:
scanargs = self.parent.scaninfo.scan_msg.info["kwargs"]
# NOTE: Scans don't have to fully configure the device
if "ddc_trigger" in scanargs:
d["ddc_trigger"] = scanargs["ddc_trigger"]
if "steps" in scanargs and "exp_burst" in scanargs:
scan_steps = scanargs["steps"]
scan_burst = scanargs["exp_burst"]
d["num_points_total"] = (scan_steps+1) * scan_burst
elif "exp_burst" in scanargs:
d["num_points_total"] = scanargs["exp_burst"]
elif "steps" in scanargs:
d["num_points_total"] = scanargs["steps"]
if "ddc_num_points" in scanargs:
d["num_points_total"] = scanargs["ddc_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:
num_points *= scanargs["steps"]
points_valid = True
elif "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:
num_points *= scanargs["repeats"]
points_valid = True
if points_valid:
d["num_points_total"] = num_points
# Perform bluesky-style configuration
if len(d) > 0:
@@ -107,7 +114,7 @@ class aa1AxisDriveDataCollection(PSIDeviceBase):
custom_prepare_cls = AerotechDriveDataCollectionMixin
USER_ACCESS = ["configure", "reset"]
def configure(self, d: dict = {}) -> tuple:
def configure(self, d: dict = None) -> tuple:
"""Configure data capture
Configures the hardware synchronized drive data capture (DDC) on an
@@ -117,14 +124,15 @@ class aa1AxisDriveDataCollection(PSIDeviceBase):
"""
old = self.read_configuration()
if "num_points_total" in d:
self.npoints.set(d["num_points_total"]).wait()
if "ddc_trigger" in d:
self._trigger.set(d['ddc_trigger']).wait()
if "ddc_source0" in d:
self._input0.set(d['ddc_source0']).wait()
if "ddc_source1" in d:
self._input1.set(d['ddc_source1']).wait()
if d is not None:
if "num_points_total" in d:
self.npoints.set(d["num_points_total"]).wait()
if "ddc_trigger" in d:
self._trigger.set(d['ddc_trigger']).wait()
if "ddc_source0" in d:
self._input0.set(d['ddc_source0']).wait()
if "ddc_source1" in d:
self._input1.set(d['ddc_source1']).wait()
# Reset incremental readback
self._switch.set("ResetRB", settle_time=0.1).wait()
@@ -150,17 +158,17 @@ class aa1AxisDriveDataCollection(PSIDeviceBase):
# Define wait until the busy flag goes down (excluding initial update)
timestamp_ = 0
def negEdge(*args, old_value, value, timestamp, **kwargs):
def neg_edge(*args, old_value, value, timestamp, **kwargs):
nonlocal timestamp_
result = False if (timestamp_ == 0) else (old_value == 1 and value == 0)
timestamp_ = timestamp
return result
if index == 0:
status = SubscriptionStatus(self._readstatus0, negEdge, settle_time=0.5)
status = SubscriptionStatus(self._readstatus0, neg_edge, settle_time=0.5)
self._readback0.set(1).wait()
elif index == 1:
status = SubscriptionStatus(self._readstatus1, negEdge, settle_time=0.5)
status = SubscriptionStatus(self._readstatus1, neg_edge, settle_time=0.5)
self._readback1.set(1).wait()
# Start asynchronous readback
@@ -168,6 +176,8 @@ class aa1AxisDriveDataCollection(PSIDeviceBase):
return status
def describe_collect(self) -> OrderedDict:
"""Describes collected array format according to JSONschema
"""
ret = OrderedDict()
ret["buffer0"] = {
"source": "internal",

View File

@@ -1,3 +1,10 @@
# -*- coding: utf-8 -*-
"""
Ophyd device for the Aerotech Automation1 IOC's axis-specific position
synchronized output (PSO) interface.
@author: mohacsi_i
"""
from time import sleep
import numpy as np
from ophyd import Component, EpicsSignal, EpicsSignalRO, Kind
@@ -12,6 +19,8 @@ logger = bec_logger.logger
class AerotechPsoDistanceMixin(CustomDeviceMixin):
"""Mixin class for self-configuration and staging
"""
# parent : aa1Tasks
def on_stage(self) -> None:
"""Configuration and staging
@@ -21,14 +30,10 @@ class AerotechPsoDistanceMixin(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.
"""
# Fish out configuration from scaninfo (does not need to be full configuration)
scanparam = self.parent.scaninfo.scan_msg.info
alias = self.parent.parent.name if self.parent.parent is not None else self.parent.name
logger.warning(f"[{alias}] Scan parameters:\n{scanparam}")
d = {}
if "kwargs" in scanparam:
scanargs = scanparam["kwargs"]
if "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:
@@ -237,7 +242,7 @@ class aa1AxisPsoDistance(aa1AxisPsoBase):
"""Simplified configuration interface to access the most common
functionality for distance mode PSO.
:param pso_distance: The trigger distance or the array of distances between subsequent points.
:param pso_distance: Distance or array of distances between subsequent trigger points.
:param pso_wavemode: Waveform mode configuration, usually pulsed/toggled (default: pulsed).
:param pso_t_pulse : trigger high duration in pulsed mode (default: 100 us)
:param pso_w_pulse : trigger hold-off time in pulsed mode (default: 200 us)
@@ -276,7 +281,7 @@ class aa1AxisPsoDistance(aa1AxisPsoBase):
new = self.read_configuration()
logger.info(f"[{self.name}] PSO configured to {pso_wavemode} mode")
return (old, new)
def bluestage(self) -> None:
"""Bluesky style stage"""
# Stage the PSO distance module and zero counter
@@ -288,25 +293,3 @@ class aa1AxisPsoDistance(aa1AxisPsoBase):
if self.dstDistanceVal.get() > 0:
self.dstEventsEna.set("On").wait()
self.dstCounterEna.set("On").wait()
# # ########################################################################
# # Bluesky flyer interface
# def prepare(self, distance=None) -> DeviceStatus:
# """ Arm trigger for a synchronous or asynchronous acquisition"""
# if distance is not None:
# # Write a new array
# if isinstance(distance, (float, int)):
# self.dstDistance.set(distance).wait()
# elif isinstance(distance, (np.ndarray, list, tuple)):
# self.dstDistanceArr.set(distance).wait()
# else:
# # Rearm the already configured array
# if isinstance(self._distance_value, (np.ndarray, list, tuple)):
# self.dstArrayRearm.set(1).wait()
# # Start monitoring the counters
# self.dstEventsEna.set("On").wait()
# self.dstCounterEna.set("On").wait()
# status = DeviceStatus(self)
# status.set_finished()
# return status

View File

@@ -1,3 +1,10 @@
# -*- coding: utf-8 -*-
"""
Ophyd device for the Aerotech Automation1 IOC's controller's task management
interface.
@author: mohacsi_i
"""
from time import sleep
from ophyd import Component, EpicsSignal, EpicsSignalRO, Kind
from ophyd.status import DeviceStatus, SubscriptionStatus
@@ -12,6 +19,8 @@ logger = bec_logger.logger
class AerotechTasksMixin(CustomDeviceMixin):
"""Mixin class for self-configuration and staging
"""
# parent : aa1Tasks
def on_stage(self) -> None:
"""Configuration and staging
@@ -27,20 +36,16 @@ class AerotechTasksMixin(CustomDeviceMixin):
# logger.warning(self.parent.scaninfo.scan_msg.info['kwargs'].keys())
# Fish out our configuration from scaninfo (via explicit or generic addressing)
scanparam = self.parent.scaninfo.scan_msg.info
alias = self.parent.parent.name if self.parent.parent is not None else self.parent.name
# logger.warning(f"[{alias}] Scan parameters:\n{scanparam}")
d = {}
if "kwargs" in scanparam:
scanargs = scanparam["kwargs"]
if "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:
if "script_text" in scanargs and scanargs["script_text"] is not None:
d["script_text"] = scanargs["script_text"]
if "script_file" in scanargs:
if "script_file" in scanargs and scanargs["script_file"] is not None:
d["script_file"] = scanargs["script_file"]
if "script_task" in scanargs:
if "script_task" in scanargs and scanargs["script_task"] is not None:
d["script_task"] = scanargs["script_task"]
# Perform bluesky-style configuration
@@ -114,7 +119,7 @@ class aa1Tasks(PSIDeviceBase):
# Common operations
old = self.read_configuration()
self.switch.set("Reset").wait()
# Check if
# Check what we got
if "script_task" in d:
if d['script_task'] < 3 or d['script_task'] > 21:
raise RuntimeError(f"Invalid task index: {d['script_task']}")
@@ -136,7 +141,7 @@ class aa1Tasks(PSIDeviceBase):
def bluestage(self) -> None:
"""Bluesky style stage"""
if self.taskIndex.get() in (0, 1, 2):
logger.error(f"[{self.name}] Woah, launching AeroScript on a system task. Daring today are we?")
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)
status.wait()
@@ -151,13 +156,10 @@ class aa1Tasks(PSIDeviceBase):
timestamp_ = 0
task_idx = int(self.taskIndex.get())
def not_running(*args, old_value, value, timestamp, **kwargs):
def not_running(*args, value, timestamp, **kwargs):
nonlocal timestamp_
result = False if value[task_idx] in ["Running", 4] else True
# FIXME: BEC will swallow this exception
# error = bool(value[task_idx] in ["Error", 8])
result = value[task_idx] not in ["Running", 4]
timestamp_ = timestamp
# print(result)
return result
# Subscribe and wait for update

View File

@@ -2,5 +2,3 @@ from .AerotechTasks import aa1Tasks
from .AerotechPso import aa1AxisPsoDistance
from .AerotechDriveDataCollection import aa1AxisDriveDataCollection
from .AerotechAutomation1 import aa1Controller, aa1GlobalVariables, aa1GlobalVariableBindings, aa1AxisIo

View File

@@ -256,7 +256,7 @@ class GigaFrostCamera(PSIDetectorBase):
# pylint: disable=too-many-instance-attributes
custom_prepare_cls = GigaFrostCameraMixin
USER_ACCESS = [""]
USER_ACCESS = ["initialize"]
_initialized = False
infoBusyFlag = Component(EpicsSignalRO, "BUSY_STAT", auto_monitor=True)
@@ -466,7 +466,7 @@ class GigaFrostCamera(PSIDetectorBase):
def initialize(self):
""" Initialization in separate command"""
self.custom_prepare_cls._init_gigafrost()
self.custom_prepare._init_gigafrost()
self._initialized = True
def trigger(self) -> DeviceStatus:

View File

@@ -0,0 +1,388 @@
# -*- coding: utf-8 -*-
"""
Created on Wed Dec 6 11:33:54 2023
@author: mohacsi_i
"""
from ophyd import Device, Component, EpicsMotor, EpicsSignal, EpicsSignalRO, Kind
from ophyd.status import Status, SubscriptionStatus, StatusBase, DeviceStatus
from time import sleep
import warnings
import numpy as np
import time
from ophyd_devices.epics.devices.psi_detector_base import CustomDetectorMixin, PSIDetectorBase
class HelgeCameraMixin(CustomDetectorMixin):
"""Mixin class to setup the Helge camera bae class.
This class will be called by the custom_prepare_cls attribute of the detector class.
"""
def __init__(self, *_args, parent: Device = None, **_kwargs) -> None:
super().__init__(*_args, parent=parent, **_kwargs)
self.monitor_thread = None
self.stop_monitor = False
self.update_frequency = 1
def set_exposure_time(self, exposure_time: float) -> None:
"""Set the detector framerate.
Args:
exposure_time (float): Desired exposure time in [sec]
"""
if exposure_time is not None:
self.parent.acqExpTime.set(exposure_time).wait()
def prepare_detector_backend(self) -> None:
pass
def prepare_detector(self) -> None:
"""Prepare detector for acquisition.
State machine:
BUSY and SET both low -> BUSY high, SET low -> BUSY low, SET high -> BUSY low, SET low
"""
self.parent.camSetParam.set(1).wait()
def risingEdge(*args, old_value, value, timestamp, **kwargs):
return bool(not old_value and value)
def fallingEdge(*args, old_value, value, timestamp, **kwargs):
return bool(old_value and not value)
# Subscribe and wait for update
status = SubscriptionStatus(self.parent.camSetParam, fallingEdge, settle_time=0.5)
status.wait()
def arm_acquisition(self) -> None:
"""Arm camera for acquisition"""
# Acquisition is only allowed when the IOC is not busy
if self.parent.state in ("OFFLINE", "BUSY", "REMOVED", "RUNNING"):
raise RuntimeError(f"Camera in in state: {self.parent.state}")
# Start the acquisition (this sets parameers and starts acquisition)
self.parent.camStatusCmd.set("Running").wait()
# Subscribe and wait for update
def isRunning(*args, old_value, value, timestamp, **kwargs):
return bool(self.parent.state=="RUNNING")
status = SubscriptionStatus(self.parent.camStatusCode, isRunning, settle_time=0.2)
status.wait()
def stop_detector(self) -> None:
self.camStatusCmd.set("Idle").wait()
# Subscribe and wait for update
def isIdle(*args, old_value, value, timestamp, **kwargs):
return bool(value==2)
status = SubscriptionStatus(self.parent.camStatusCode, isIdle, settle_time=0.5)
status.wait()
def send_data(self) -> None:
"""Send data to monitor endpoint in redis."""
try:
img = self.parent.image.get()
# pylint: disable=protected-access
self.parent._run_subs(sub_type=self.parent.SUB_VALUE, value=img)
except Exception as e:
logger.debug(f"{e} for image with shape {self.parent.image.get().shape}")
def monitor_loop(self) -> None:
"""
Monitor the detector status and send data.
"""
while True:
self.send_data()
time.sleep(1 / self.update_frequency)
if self.parent.state != "RUNNING":
break
if self.stop_monitor:
break
def run_monitor(self) -> None:
"""
Run the monitor loop in a separate thread.
"""
self.monitor_thread = threading.Thread(target=self.monitor_loop, daemon=True)
self.monitor_thread.start()
class HelgeCameraCore(PSIDetectorBase):
"""Ophyd baseclass for Helge camera IOCs
This class provides wrappers for Helge's camera IOCs around SwissFEL and
for high performance SLS 2.0 cameras. The IOC's operation is a bit arcane
and there are different versions and cameras all around. So this device
only covers the absolute basics.
Probably the most important part is the configuration state machine. As
the SET_PARAMS takes care of buffer allocations it might take some time,
as well as afull re-configuration is required every time we change the
binning, roi, etc... This is automatically performed upon starting an
exposure (if it heven't been done before).
The status flag state machine during re-configuration is:
BUSY low, SET low -> BUSY high, SET low -> BUSY low, SET high -> BUSY low, SET low
"""
# Specify Mixin class
custom_prepare_cls = HelgeCameraMixin
USER_ACCESS = ["kickoff"]
# ########################################################################
# General hardware info
camType = Component(EpicsSignalRO, "QUERY", kind=Kind.omitted)
camBoard = Component(EpicsSignalRO, "BOARD", kind=Kind.config)
camError = Component(EpicsSignalRO, "ERRCODE", auto_monitor=True, kind=Kind.config)
camWarning = Component(EpicsSignalRO, "WARNCODE", auto_monitor=True, kind=Kind.config)
# ########################################################################
# Acquisition commands
camStatusCmd = Component(EpicsSignal, "CAMERASTATUS", put_complete=True, kind=Kind.config)
# ########################################################################
# Acquisition configuration
acqExpTime = Component(EpicsSignalRO, "EXPOSURE", auto_monitor=True, kind=Kind.config)
# ########################################################################
# Configuration state maschine with separate transition states
camStatusCode = Component(EpicsSignalRO, "STATUSCODE", auto_monitor=True, kind=Kind.config)
camSetParam = Component(EpicsSignal, "SET_PARAM", auto_monitor=True, kind=Kind.config)
camSetParamBusy = Component(EpicsSignalRO, "BUSY_SET_PARAM", auto_monitor=True, kind=Kind.config)
camCamera = Component(EpicsSignalRO, "CAMERA", auto_monitor=True, kind=Kind.config)
camCameraBusy = Component(EpicsSignalRO, "BUSY_CAMERA", auto_monitor=True, kind=Kind.config)
camInit= Component(EpicsSignalRO, "INIT", auto_monitor=True, kind=Kind.config)
camInitBusy = Component(EpicsSignalRO, "BUSY_INIT", auto_monitor=True, kind=Kind.config)
# ########################################################################
# Throtled image preview
image = Component(EpicsSignalRO, "FPICTURE", kind=Kind.omitted)
# ########################################################################
# Misc PVs
#camRemoval = Component(EpicsSignalRO, "REMOVAL", auto_monitor=True, kind=Kind.config)
camStateString = Component(EpicsSignalRO, "SS_CAMERA", string=True, auto_monitor=True, kind=Kind.config)
@property
def state(self) -> str:
""" Single word camera state"""
if self.camSetParamBusy.value:
return "BUSY"
if self.camStatusCode.value==2 and self.camInit.value==1:
return "IDLE"
if self.camStatusCode.value==6 and self.camInit.value==1:
return "RUNNING"
#if self.camRemoval.value==0 and self.camInit.value==0:
if self.camInit.value==0:
return "OFFLINE"
#if self.camRemoval.value:
# return "REMOVED"
return "UNKNOWN"
@state.setter
def state(self):
raise ReadOnlyError("State is a ReadOnly property")
def configure(self, d: dict = {}) -> tuple:
if self.state in ["OFFLINE", "REMOVED", "RUNNING"]:
raise RuntimeError(f"Can't change configuration from state {self.state}")
def stage(self) -> list[object]:
""" Start acquisition"""
self.custom_prepare.arm_acquisition()
return super().stage()
def kickoff(self) -> DeviceStatus:
""" Start acquisition"""
return self.stage()
def stop(self):
""" Stop the running acquisition """
self.camStatusCmd.set("Idle").wait()
self.custom_prepare.stop_monitor = True
return super().unstage()
def unstage(self):
""" Stop the running acquisition and unstage the device"""
self.camStatusCmd.set("Idle").wait()
self.custom_prepare.stop_monitor = True
return super().unstage()
class HelgeCameraBase(HelgeCameraCore):
"""Ophyd baseclass for Helge camera IOCs
This class provides wrappers for Helge's camera IOCs around SwissFEL and
for high performance SLS 2.0 cameras. Theese are mostly PCO cameras running
on a special Windows IOC host with lots of RAM and CPU power.
The IOC's operation is a bit arcane, and is documented on the "read the code"
level. However the most important part is the state machine of 7+1 PV signals:
INIT
BUSY_INIT
SET_PARAM
BUSY_SET_PARAM
CAMERA
BUSY_CAMERA
CAMERASTATUSCODE
CAMERASTATUS
"""
USER_ACCESS = ["describe", "shape", "bin", "roi"]
# ########################################################################
# Additional status info
busy = Component(EpicsSignalRO, "BUSY", auto_monitor=True, kind=Kind.config)
camState = Component(EpicsSignalRO, "SS_CAMERA", auto_monitor=True, kind=Kind.config)
camProgress = Component(EpicsSignalRO, "CAMPROGRESS", auto_monitor=True, kind=Kind.config)
camRate = Component(EpicsSignalRO, "CAMRATE", auto_monitor=True, kind=Kind.config)
# ########################################################################
# Acquisition configuration
acqMode = Component(EpicsSignalRO, "ACQMODE", auto_monitor=True, kind=Kind.config)
acqDelay = Component(EpicsSignalRO, "DELAY", auto_monitor=True, kind=Kind.config)
acqTriggerEna = Component(EpicsSignalRO, "TRIGGER", auto_monitor=True, kind=Kind.config)
#acqTriggerSource = Component(EpicsSignalRO, "TRIGGERSOURCE", auto_monitor=True, kind=Kind.config)
#acqTriggerEdge = Component(EpicsSignalRO, "TRIGGEREDGE", auto_monitor=True, kind=Kind.config)
# ########################################################################
# Image size settings
# Priority is: binning -> roi -> final size
pxBinX = Component(EpicsSignal, "BINX", put_complete=True, auto_monitor=True, kind=Kind.config)
pxBinY = Component(EpicsSignal, "BINY", put_complete=True, auto_monitor=True, kind=Kind.config)
pxRoiX_lo = Component(EpicsSignal, "REGIONX_START", put_complete=True, auto_monitor=True, kind=Kind.config)
pxRoiX_hi = Component(EpicsSignal, "REGIONX_END", put_complete=True, auto_monitor=True, kind=Kind.config)
pxRoiY_lo = Component(EpicsSignal, "REGIONY_START", put_complete=True, auto_monitor=True, kind=Kind.config)
pxRoiY_hi = Component(EpicsSignal, "REGIONY_END", put_complete=True, auto_monitor=True, kind=Kind.config)
pxNumX = Component(EpicsSignalRO, "WIDTH", auto_monitor=True, kind=Kind.config)
pxNumY = Component(EpicsSignalRO, "HEIGHT", auto_monitor=True, kind=Kind.config)
# ########################################################################
# Buffer configuration
bufferRecMode = Component(EpicsSignalRO, "RECMODE", auto_monitor=True, kind=Kind.config)
bufferStoreMode = Component(EpicsSignalRO, "STOREMODE", auto_monitor=True, kind=Kind.config)
fileRecMode = Component(EpicsSignalRO, "RECMODE", auto_monitor=True, kind=Kind.config)
# ########################################################################
# File interface
camFileFormat = Component(EpicsSignal, "FILEFORMAT", put_complete=True, kind=Kind.config)
camFilePath = Component(EpicsSignal, "FILEPATH", put_complete=True, kind=Kind.config)
camFileName = Component(EpicsSignal, "FILENAME", put_complete=True, kind=Kind.config)
camFileNr = Component(EpicsSignal, "FILENR", put_complete=True, kind=Kind.config)
camFilePath = Component(EpicsSignal, "FILEPATH", put_complete=True, kind=Kind.config)
camFileTransferStart = Component(EpicsSignal, "FTRANSFER", put_complete=True, kind=Kind.config)
camFileTransferStop = Component(EpicsSignal, "SAVESTOP", put_complete=True, kind=Kind.config)
def configure(self, d: dict = {}) -> tuple:
"""
Camera configuration instructions:
After setting the corresponding PVs, one needs to process SET_PARAM and wait until
BUSY_SET_PARAM goes high and low, followed by SET_PARAM goes high and low. This will
both send the settings to the camera and allocate the necessary buffers in the correct
size and shape (that takes time). Starting the exposure with CAMERASTATUS will also
call SET_PARAM, but it might take long.
"""
old = self.read_configuration()
super().configure(d)
if "exptime" in d:
exposure_time = d['exptime']
if exposure_time is not None:
self.acqExpTime.set(exposure_time).wait()
if "roi" in d:
roi = d["roi"]
if not isinstance(roi, (list, tuple)):
raise ValueError(f"Unknown ROI data type {type(roi)}")
if not len(roi[0])==2 and len(roi[1])==2:
raise ValueError(f"Unknown ROI shape: {roi}")
# Values are rounded to multiples of 16
self.pxRoiX_lo.set(roi[0][0]).wait()
self.pxRoiX_hi.set(roi[0][1]).wait()
self.pxRoiY_lo.set(roi[1][0]).wait()
self.pxRoiY_hi.set(roi[1][1]).wait()
if "bin" in d:
binning = d["bin"]
if not isinstance(binning, (list, tuple)):
raise ValueError(f"Unknown BINNING data type {type(binning)}")
if not len(binning)==2:
raise ValueError(f"Unknown ROI shape: {binning}")
self.pxBinX.set(binning[0]).wait()
self.pxBinY.set(binning[1]).wait()
# State machine
# Initial: BUSY and SET both low
# 1. BUSY set to high
# 2. BUSY goes low, SET goes high
# 3. SET goes low
self.camSetParam.set(1).wait()
def risingEdge(*args, old_value, value, timestamp, **kwargs):
return bool(not old_value and value)
def fallingEdge(*args, old_value, value, timestamp, **kwargs):
return bool(old_value and not value)
# Subscribe and wait for update
status = SubscriptionStatus(self.camSetParam, fallingEdge, settle_time=0.5)
status.wait()
new = self.read_configuration()
return (old, new)
@property
def shape(self):
return (int(self.pxNumX.value), int(self.pxNumY.value))
@shape.setter
def shape(self):
raise ReadOnlyError("Shape is a ReadOnly property")
@property
def bin(self):
return (int(self.pxBinX.value), int(self.pxBinY.value))
@bin.setter
def bin(self):
raise ReadOnlyError("Bin is a ReadOnly property")
@property
def roi(self):
return ((int(self.pxRoiX_lo.value), int(self.pxRoiX_hi.value)), (int(self.pxRoiY_lo.value), int(self.pxRoiY_hi.value)))
@roi.setter
def roi(self):
raise ReadOnlyError("Roi is a ReadOnly property")
# Automatically connect to test camera if directly invoked
if __name__ == "__main__":
# Drive data collection
cam = HelgeCameraBase("SINBC02-DSRM310:", name="mcpcam")
cam.wait_for_connection()

View File

@@ -12,7 +12,9 @@ from threading import Thread
import requests
from ophyd import Device, Signal, Component, Kind, Staged
from websockets.sync.client import connect
from ophyd.status import SubscriptionStatus
from ophyd.flyers import FlyerInterface
from websockets.sync.client import connect, ClientConnection
from websockets.exceptions import ConnectionClosedOK, ConnectionClosedError
from ophyd_devices.interfaces.base_classes.psi_detector_base import PSIDetectorBase as PSIDeviceBase
@@ -34,81 +36,64 @@ class StdDaqMixin(CustomDeviceMixin):
NOTE: Tomcat might use multiple cameras with their own separate DAQ instances.
"""
# Fish out our configuration from scaninfo (via explicit or generic addressing)
scanparam = self.parent.scaninfo.scan_msg.info
alias = self.parent.parent.name if self.parent.parent is not None else self.parent.name
# logger.warning(f"[{alias}] Scan parameters:\n{scanparam}")
# NOTE: Scans don't have to fully configure the device
d = {}
if 'kwargs' in scanparam:
scanargs = scanparam['kwargs']
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'] != None:
d['image_width'] = scanargs['image_width']
if 'image_height' in scanargs and scanargs['image_height'] != None:
d['image_height'] = scanargs['image_height']
# NOTE: Scans don't have to fully configure the device
points_total = 1
if 'steps' in scanargs and scanargs['steps'] != None:
points_total *= scanargs['steps']
if 'exp_burst' in scanargs and scanargs['exp_burst'] != None:
points_total *= scanargs['exp_burst']
if 'repeats' in scanargs and scanargs['repeats']!= None:
points_total *= scanargs['repeats']
if points_total != 1:
d['num_points_total'] = points_total
if 'nr_writers' in scanargs and scanargs['nr_writers'] != None:
d['nr_writers'] = scanargs['nr_writers']
if 'file_path' in scanargs and scanargs['file_path']!=None:
self.parent.file_path.set(scanargs['file_path']).wait()
if "daq_num_points" in scanargs:
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:
num_points *= scanargs["steps"]
points_valid = True
if "exp_burst" in scanargs and scanargs['exp_burst'] is not None:
num_points *= scanargs["exp_burst"]
points_valid = True
if "repeats" in scanargs and scanargs['repeats'] is not None:
num_points *= scanargs["repeats"]
points_valid = True
if points_valid:
d["num_points_total"] = num_points
# Perform bluesky-style configuration
if len(d) > 0:
# Stop if current status is not idle
if self.parent.state() != "idle":
self.parent.surestop()
# Configure new run (will restart the stdDAQ)
logger.warning(f"[{self.parent.name}] Configuring with:\n{d}")
logger.warning(f"[{self.parent.name}] stdDAQ needs reconfiguring with:\n{d}")
self.parent.configure(d=d)
# Wait for REST API to kill the DAQ
sleep(0.5)
# Try to start a new run
file_path = self.parent.file_path.get()
num_images = self.parent.num_images.get()
message = {"command": "start", "path": file_path, "n_image": num_images}
ii = 0
while True:
self.parent.connect()
reply = self.parent.message(message)
if reply is not None:
reply = json.loads(reply)
self.parent.status.set(reply["status"], force=True).wait()
logger.info(f"[{self.parent.name}] Start DAQ reply: {reply}")
# Give it more time to reconfigure
if reply["status"] in ("rejected"):
# FIXME: running exposure is a nogo
if reply['reason'] == "gerhtrhjfjf":
raise RuntimeError(f"[{self.parent.name}] Start StdDAQ command rejected: already running")
else:
# Give it more time to restart
sleep(2)
else:
break
ii += 1
if ii == 5:
break
if reply is not None and reply["status"] in ("rejected"):
raise RuntimeError(
f"Start StdDAQ command rejected (might be already running): {reply['reason']}"
)
# Try to start a new run (reconnects)
self.parent.bluestage()
# And start status monitoring
self._mon = Thread(target=self.poll, daemon=True)
self._mon = Thread(target=self.monitor, daemon=True)
self._mon.start()
def on_unstage(self):
""" Stop a running acquisition and close connection
"""
self.parent.surestop()
print("Creating virtual dataset")
self.parent.create_virtual_dataset()
self.parent.blueunstage()
def on_stop(self):
""" Stop a running acquisition and close connection
"""
self.parent.surestop()
self.parent.blueunstage()
def poll(self) -> None:
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.
@@ -117,8 +102,8 @@ class StdDaqMixin(CustomDeviceMixin):
sleep(0.2)
for msg in self.parent._wsclient:
message = json.loads(msg)
self.parent.status.put(message["status"], force=True)
# logger.info(f"[{self.parent.name}] Pushed status: {message['status']}")
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
@@ -132,11 +117,10 @@ class StdDaqMixin(CustomDeviceMixin):
class StdDaqClient(PSIDeviceBase):
"""StdDaq API
This class combines the new websocket and REST interfaces of the stdDAQ replaced the documented
python client. The websocket interface starts and stops the acquisition and provides status,
while the REST interface can read and write the JSON configuration file.
The DAQ needs to restart all services to reconfigure with a new config, which might corrupt
This class combines the new websocket and REST interfaces of the stdDAQ. The websocket
interface starts and stops the acquisition and provides status, while the REST interface
can read and write the JSON configuration file. The stdDAQ needs to restart all services
to reconfigure with a new config, which might corrupt
the currently written files (fix is underway).
Example:
@@ -146,22 +130,22 @@ class StdDaqClient(PSIDeviceBase):
"""
# pylint: disable=too-many-instance-attributes
custom_prepare_cls = StdDaqMixin
USER_ACCESS = ["set_daq_config", "get_daq_config", "surestop", "nuke", "connect", "message", "state"]
USER_ACCESS = ["set_daq_config", "get_daq_config", "nuke", "connect", "message", "state", "bluestage", "blueunstage"]
_wsclient = None
# Status attributes
ws_url = Component(Signal, kind=Kind.config)
status = Component(Signal, value="unknown", kind=Kind.normal)
ws_url = Component(Signal, kind=Kind.config, metadata={'write_access': False})
runstatus = Component(Signal, value="unknown", kind=Kind.normal, metadata={'write_access': False})
num_images = Component(Signal, value=10000, kind=Kind.config)
file_path = Component(Signal, value="/gpfs/test/test-beamline", kind=Kind.config)
# Configuration attributes
rest_url = Component(Signal, kind=Kind.config)
rest_url = 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)
def __init__(
self,
@@ -173,32 +157,23 @@ class StdDaqClient(PSIDeviceBase):
configuration_attrs=None,
parent=None,
device_manager=None,
sim_mode=False,
ws_url: str = "ws://localhost:8080",
rest_url: str = "http://localhost:5000",
data_source_name = None,
**kwargs,
) -> None:
super().__init__(prefix=prefix, name=name, kind=kind, read_attrs=read_attrs, configuration_attrs=configuration_attrs, parent=parent, device_manager=device_manager, **kwargs)
self.status._metadata["write_access"] = False
self.ws_url._metadata["write_access"] = False
self.ws_url.set(ws_url, force=True).wait()
self.rest_url._metadata["write_access"] = False
self.rest_url.set(rest_url, force=True).wait()
self.data_source_name = data_source_name
# Connect ro the DAQ and initialize values
# Connect to the DAQ and initialize values
try:
self.read_daq_config()
self.get_daq_config(update=True)
except Exception as ex:
logger.error(f"Failed to connect to the stdDAQ REST API\n{ex}")
def __del__(self) -> None:
try:
self._wsclient.close()
except TypeError:
pass
return super().__del__()
def connect(self):
def connect(self) -> ClientConnection:
"""Connect to the StdDAQ's websockets interface
StdDAQ may reject connection for a few seconds after restart, or when
@@ -207,16 +182,14 @@ class StdDaqClient(PSIDeviceBase):
num_retry = 0
while num_retry < 5:
try:
logger.debug(f"[{self.name}] Connecting to {self.ws_url.get()}")
self._wsclient = connect(self.ws_url.get())
break
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(3)
if num_retry == 5:
raise ConnectionRefusedError(
"The stdDAQ websocket interface refused connection 5 times.")
logger.debug(f"[{self.name}] Connected to DAQ after {num_retry} tries")
sleep(2)
raise ConnectionRefusedError("The stdDAQ websocket interface refused connection 5 times.")
def message(self, message: dict, timeout=1, wait_reply=True, client=None) -> None | str:
"""Send a message to the StdDAQ and receive a reply
@@ -224,9 +197,12 @@ class StdDaqClient(PSIDeviceBase):
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)
@@ -234,7 +210,7 @@ class StdDaqClient(PSIDeviceBase):
self._wsclient.send(msg)
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
@@ -243,70 +219,167 @@ class StdDaqClient(PSIDeviceBase):
try:
reply = self._wsclient.recv(timeout)
return reply
except (ConnectionClosedError, ConnectionClosedOK, TimeoutError, RuntimeError) as ex:
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
Parameters as 'd' dictionary
Parameters as 'd' dictionary, the default is unchanged.
----------------------------
num_points_total : int, optional
Number of images to be taken during each scan. Set to -1 for an
unlimited number of images (limited by the ringbuffer size and
backend speed). (default = 10)
exposure : float, optional
Exposure time [ms]. (default = 0.2)
period : float, optional
Exposure period [ms], ignored in soft trigger mode. (default = 1.0)
pixel_width : int, optional
ROI size in the x-direction [pixels] (default = 2016)
pixel_height : int, optional
ROI size in the y-direction [pixels] (default = 2016)
scanid : int, optional
Scan identification number to be associated with the scan data
(default = 0)
trigger_mode : str, optional
Trigger mode of the gifafrost
(default = unchanged)
correction_mode : int, optional
The correction to be applied to the imaging data. The following
modes are available (default = 5):
* 0: Bypass. No corrections are applied to the data.
* 1: Send correction factor A instead of pixel values
* 2: Send correction factor B instead of pixel values
* 3: Send correction factor C instead of pixel values
* 4: Invert pixel values, but do not apply any linearity correction
* 5: Apply the full linearity correction
Number of images to be taken during each scan. Set to -1 for an unlimited number of
images (limited by the ringbuffer size and backend speed).
file_path: str, optional
File path to save the data, usually GPFS.
image_width : int, optional
ROI size in the x-direction [pixels].
image_height : int, optional
ROI size in the y-direction [pixels].
bit_depth: int, optional
Image bit depth for cameras that can change it [int].
nr_writers: int, optional
Number of writers [int].
"""
if 'image_width' in d:
# Configuration parameters
if 'image_width' in d and d['image_width']!=None:
self.cfg_pixel_width.set(d['image_width']).wait()
if 'image_height' in d:
if 'image_height' in d and d['image_height']!=None:
self.cfg_pixel_height.set(d['image_height']).wait()
if 'bit_depth' in d:
self.cfg_bit_depth.set(d['bit_depth']).wait()
if 'nr_writers' in d and d['nr_writers']!=None:
self.cfg_nr_writers.set(d['nr_writers']).wait()
# Run parameters
if 'num_points_total' in d:
self.num_images.set(d['num_points_total']).wait()
if 'file_path' in d:
if 'file_path' in d and d['file_path']!=None:
self.file_path.set(d['file_path']).wait()
# Restart the DAQ if resolution changed
cfg = self.get_daq_config()
if cfg['image_pixel_height'] != self.cfg_pixel_height.get() or cfg['image_pixel_width'] != self.cfg_pixel_width.get():
if cfg['image_pixel_height'] != self.cfg_pixel_height.get() or \
cfg['image_pixel_width'] != self.cfg_pixel_width.get() or \
cfg['bit_depth'] != self.cfg_bit_depth.get() or \
cfg['number_of_writers'] != self.cfg_nr_writers.get():
# Stop if current status is not idle
if self.state() != "idle":
self.surestop()
raise RuntimeWarning(f"[{self.name}] stdDAQ reconfiguration might corrupt files")
# Stop running acquisition
self.unstage()
# Update retrieved config
cfg['image_pixel_height'] = int(self.cfg_pixel_height.get())
cfg['image_pixel_width'] = int(self.cfg_pixel_width.get())
cfg['bit_depth'] = int(self.cfg_bit_depth.get())
cfg['number_of_writers'] = int(self.cfg_nr_writers.get())
self.set_daq_config(cfg)
self.read_daq_config()
sleep(1)
self.get_daq_config(update=True)
def get_daq_config(self) -> dict:
def bluestage(self):
""" Stages the stdDAQ
Opens a new connection to the stdDAQ, sends the start command with
the current configuration. It waits for the first reply and checks
it for obvious failures.
"""
# Can't stage into a running exposure
print('Before')
if self.state() != 'idle':
raise RuntimeError(f"[{self.name}] stdDAQ can't stage from state: {self.state()}")
print('After')
# 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()
num_images = self.num_images.get()
# New connection
self._wsclient = self.connect()
message = {"command": "start", "path": file_path, "n_image": num_images, }
reply = 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}")
# 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
raise RuntimeError(f"[{self.name}] Failed to start the stdDAQ in 1 tries, reason: {reply['reason']}")
def blueunstage(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)
# Let it consolidate
sleep(0.2)
# 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)
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
status = SubscriptionStatus(self.runstatus, is_running, settle_time=0.5)
return status
def get_daq_config(self, update=False) -> dict:
"""Read the current configuration from the DAQ
"""
r = requests.get(
@@ -315,20 +388,18 @@ class StdDaqClient(PSIDeviceBase):
timeout=2)
if r.status_code != 200:
raise ConnectionError(f"[{self.name}] Error {r.status_code}:\t{r.text}")
return r.json()
cfg = r.json()
def read_daq_config(self) -> dict:
"""Read the current configuration from the DAQ and update the ophyd device
"""
cfg = self.get_daq_config()
self.cfg_detector_name.set(cfg['detector_name']).wait()
self.cfg_detector_type.set(cfg['detector_type']).wait()
self.cfg_bit_depth.set(cfg['bit_depth']).wait()
self.cfg_pixel_height.set(cfg['image_pixel_height']).wait()
self.cfg_pixel_width.set(cfg['image_pixel_width']).wait()
if update:
self.cfg_detector_name.set(cfg['detector_name']).wait()
self.cfg_detector_type.set(cfg['detector_type']).wait()
self.cfg_bit_depth.set(cfg['bit_depth']).wait()
self.cfg_pixel_height.set(cfg['image_pixel_height']).wait()
self.cfg_pixel_width.set(cfg['image_pixel_width']).wait()
self.cfg_nr_writers.set(cfg['number_of_writers']).wait()
return cfg
def set_daq_config(self, config):
def set_daq_config(self, config, settle_time=1):
"""Write a full configuration to the DAQ
"""
url = self.rest_url.get() + '/api/config/set'
@@ -341,63 +412,50 @@ class StdDaqClient(PSIDeviceBase):
)
if r.status_code != 200:
raise ConnectionError(f"[{self.name}] Error {r.status_code}:\t{r.text}")
# Wait for service to restart (and connect to make sure)
sleep(settle_time)
self.connect()
return r.json()
def nuke(self):
""" Reconfigures the stdDAQ to restart the services
def create_virtual_dataset(self):
"""Combine the stddaq written files in a given folder in an interleaved
h5 virtual dataset
"""
url = self.rest_url.get() + '/api/h5/create_interleaved_vds'
file_path = self.file_path.get()
r = requests.post(
url,
params = {'user': 'ioc'},
data = {'base_path': file_path, 'output_file': 'fede_virtual_test'},
timeout = 2,
headers = {'Content-type': 'application/json'}
)
print(r)
print(file_path)
def nuke(self, restarttime=5):
""" Reconfigures the stdDAQ to restart the services. This causes
systemd to kill the current DAQ service and restart it with the same
configuration. Which might corrupt the currently written file...
"""
cfg = self.get_daq_config()
self.set_daq_config(cfg)
sleep(restarttime)
def state(self) -> str | None:
""" Querry the current system state"""
r = self.message({'command': 'status'}, wait_reply=True)
if r is None:
return None
else:
""" 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']
def surestop(self, timeout=5):
""" Stops a running acquisition
REST reconfiguration restarts with systemd and can corrupt currently written files.
"""
# Retries to steal connection from poller
for rr in range(5):
client = connect(self.ws_url.get())
msg = json.dumps({"command": "stop_all"})
client.send(msg)
reply = client.recv(timeout=1)
reply = json.loads(reply)
# logger.warning(reply)
# if r['status'] == 'success':
# break
sleep(0.5)
client = connect(self.ws_url.get())
msg = json.dumps({"command": "status"})
client.send(msg)
reply = client.recv(timeout=1)
reply = json.loads(reply)
if reply['status'] in ['idle', 'stoped']:
logger.warning(f"[{self.name}] Stop-all command finished in {reply['status']}")
return
# If stop_all didn't stop, nuke the whole thing
logger.error(f"[{self.name}] Don't stop, make it rock!!!")
self.nuke()
sleep(timeout)
except ConnectionRefusedError:
raise
# Automatically connect to microXAS testbench if directly invoked
if __name__ == "__main__":
daq = StdDaqClient(name="daq", ws_url="ws://xbl-daq-29:8080", rest_url="http://xbl-daq-29:5000")
daq = StdDaqClient(name="daq", ws_url="ws://sls-daq-001:8080", rest_url="http://sls-daq-001:5000")
daq.wait_for_connection()

View File

@@ -102,6 +102,7 @@ class StdDaqPreviewMixin(CustomDetectorMixin):
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(
@@ -134,7 +135,7 @@ class StdDaqPreviewDetector(PSIDetectorBase):
cam_widget = gui.add_dock('cam_dock1').add_widget('BECFigure').image('daq_stream1')
"""
# Subscriptions for plotting image
USER_ACCESS = ["kickoff"]
USER_ACCESS = ["kickoff", "get_last_image"]
SUB_MONITOR = "device_monitor_2d"
_default_sub = SUB_MONITOR
@@ -147,7 +148,8 @@ class StdDaqPreviewDetector(PSIDetectorBase):
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.config)
image = Component(Signal, kind=Kind.omitted)
_last_image = None
def __init__(
self, *args, url: str = "tcp://129.129.95.38:20000", parent: Device = None, **kwargs
@@ -180,6 +182,9 @@ class StdDaqPreviewDetector(PSIDetectorBase):
sleep(1)
self._socket.connect(self.url.get())
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)

View File

@@ -7,16 +7,30 @@ from bec_server.scan_server.scans import Acquire, AsyncFlyScanBase
class AcquireDark(Acquire):
scan_name = "acquire_dark"
required_kwargs = ["num"]
gui_config = {"Acquisition parameters": ["num"]}
required_kwargs = ["exp_burst"]
gui_config = {"Acquisition parameters": ["exp_burst"]}
def __init__(self, num: int, **kwargs):
def __init__(self, exp_burst: int, **kwargs):
"""
Acquire a dark image. This scan is used to acquire a dark image. The dark image is an image taken with the shutter
closed and no beam on the sample. The dark image is used to correct the data images for dark current.
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.
Args:
num (int): number of dark images to acquire
exp_burst : int
Number of dark images to acquire (no default)
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]
image_width : int, optional
ROI size in the x-direction [pixels]
image_height : int, optional
ROI size in the y-direction [pixels]
acq_mode : str, optional
Predefined acquisition mode (default=)
file_path : str, optional
File path for standard daq
Returns:
ScanReport
@@ -26,7 +40,7 @@ class AcquireDark(Acquire):
"""
super().__init__(**kwargs)
self.burst_at_each_point = num
self.burst_at_each_point = 1 # At each point, how many times I want to individually trigger
self.scan_motors = ["eyex"] # change to the correct shutter device
#self.shutter = "eyex" # change to the correct shutter device
self.dark_shutter_pos = 0 ### change with a variable

View File

@@ -14,8 +14,8 @@
def dev_disable_all():
"""Disable all devices """
for d in dev:
d.enabled = False
for k in dev:
dev[k].enabled = False
@@ -41,12 +41,12 @@ def anotherstepscan(
--------
demostepscan(scan_start=-32, scan_end=148, steps=180, exp_time=0.005, exp_burst=5)
"""
if not bl_check_beam():
raise RuntimeError("Beamline is not in ready state")
dev.es1_tasks.enabled = False
dev.es1_psod.enabled = False
dev.es1_ddaq.enabled = True
# if not bl_check_beam():
# raise RuntimeError("Beamline is not in ready state")
dev_disable_all()
dev.es1_roty.enabled = True
#dev.es1_ddaq.enabled = True
dev.gfcam.enabled = True
dev.gfdaq.enabled = True
dev.daq_stream0.enabled = True

View File

@@ -1,61 +1,163 @@
def fede_darks(nimages_dark, exposure_time=None, exposure_period=None, roix=None, roiy=None, acq_mode=None):
import os.path
class Measurement:
"""
Acquire a set of dark images with shutters closed.
Parameters
----------
nimages_dark : int
Number of dark images to acquire (no default)
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=)
Example:
--------
tutorialdarks(num=100, exp_time=5)
This class provides a standard set of tomographic measurement functions
that can be used to acquire data at the TOMCAT beamline
"""
dev.es1_tasks.enabled = False
dev.es1_psod.enabled = False
dev.es1_ddaq.enabled = False
dev.es1_ismc.enabled = False
dev.es1_roty.enabled = False
dev.gfcam.enabled = True
dev.gfdaq.enabled = True
dev.daq_stream0.enabled = True
dev.daq_stream1.enabled = False
def __init__(self):
self.sample_name = 'tmp'
self.data_path = 'disk_test'
self.nimages = 1000
self.nimages_dark = 50
self.nimages_white = 100
# To be able to keep what is already set on the camera
self.exposure_time = None
self.exposure_period = None
self.roix = None
self.roiy = None
bec.system_config.file_suffix = self.sample_name
bec.system_config.file_directory = os.path.join(self.data_path,self.sample_name)
self.build_filename()
def build_filename(self):
"""
Build and set filename for bec and stddaq
"""
bec.system_config.file_suffix = self.sample_name
bec.system_config.file_directory = os.path.join(self.data_path,self.sample_name)
self.file_path = '/data/test/test-beamline/test_fede'
if os.path.isdir(self.file_path):
pass
else:
os.mkdir(self.file_path)
def configure(self,sample_name=None, data_path=None, exposure_time=None,
exposure_period=None, roix=None, roiy=None,nimages=None,
nimages_dark=None, nimages_white=None):
"""
Reconfigure the measurement with any number of new parameter
Parameters
----------
sample_name : string, optional
Name of the sample or measurement. This name will be used to construct
the name of the measurement directory (default=None)
data_path : string, optional
Information used to build the data directory for the measurement
(default=None)
exposure_time : float, optional
Exposure time [ms] (default=None)
exposure_period : float, optional
Exposure period [ms] (default=None)
roix : int, optional
ROI size in the x-direction [pixels] (default=None)
roiy : int, optional
ROI size in the y-direction [pixels] (default=None)
nimages : int, optional
Number of images to acquire (default=None)
nimages_dark : int, optional
Number of dark images to acquire (default=None)
nimages_white : int, optional
Number of white images to acquire (default=None)
"""
if sample_name != None:
self.sample_name = sample_name
if data_path != None:
self.data_path = data_path
if nimages != None:
self.nimages = nimages
if nimages_dark != None:
self.nimages_dark = nimages_dark
if nimages_white != None:
self.nimages_white = nimages_white
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()
def acquire_darks(self,nimages_dark, exposure_time=None, exposure_period=None,
roix=None, roiy=None, acq_mode=None, file_path=None):
"""
Acquire a set of dark images with shutters closed.
Parameters
----------
nimages_dark : int
Number of dark images to acquire (no default)
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)
file_path : str, optional
File path for standard daq (default=None)
Example:
--------
fede_darks(100, exposure_time=5)
"""
# dev.es1_tasks.enabled = False
# dev.es1_psod.enabled = False
# dev.es1_ddaq.enabled = False
# dev.es1_ismc.enabled = False
# dev.es1_roty.enabled = False
dev.gfcam.enabled = True
dev.gfdaq.enabled = True
dev.daq_stream0.enabled = True
dev.daq_stream1.enabled = False
dev.gfcam.cfgAcqMode.set(1).wait()
dev.gfcam.cmdSetParam.set(1).wait()
dev.gfcam.cfgEnableExt.set(0).wait()
dev.gfcam.cfgEnableSoft.set(0).wait()
dev.gfcam.cfgEnableAlways.set(1).wait()
dev.gfcam.cfgAcqMode.set(1).wait()
dev.gfcam.cmdSetParam.set(1).wait()
dev.gfcam.cfgEnableExt.set(0).wait()
dev.gfcam.cfgEnableSoft.set(0).wait()
dev.gfcam.cfgEnableAlways.set(1).wait()
dev.gfcam.cfgTrigExt.set(0).wait()
dev.gfcam.cfgTrigSoft.set(0).wait()
dev.gfcam.cfgTrigTimer.set(0).wait()
dev.gfcam.cfgTrigAuto.set(1).wait()
dev.gfcam.cfgTrigExt.set(0).wait()
dev.gfcam.cfgTrigSoft.set(0).wait()
dev.gfcam.cfgTrigTimer.set(0).wait()
dev.gfcam.cfgTrigAuto.set(1).wait()
dev.gfcam.cfgExpExt.set(0).wait()
dev.gfcam.cfgExpSoft.set(0).wait()
dev.gfcam.cfgExpTimer.set(1).wait()
dev.gfcam.cfgExpExt.set(0).wait()
dev.gfcam.cfgExpSoft.set(0).wait()
dev.gfcam.cfgExpTimer.set(1).wait()
dev.gfcam.cfgCntStartBit.set(0).wait()
dev.gfcam.cfgCntStartBit.set(0).wait()
# Commit changes to GF
dev.gfcam.cmdSetParam.set(1).wait()
# Commit changes to GF
dev.gfcam.cmdSetParam.set(1).wait()
### TODO: camera reset
print("Handing over to 'scans.acquire_dark")
scans.acquire_dark(num=1, exp_burst=nimages_dark, exp_time=exposure_time, exp_period=exposure_period, image_width=roix, image_height=roiy)
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
if file_path!=None:
if os.path.isdir(file_path):
pass
else:
os.mkdir(file_path)
### TODO: camera reset
print("Handing over to 'scans.acquire_dark")
scans.acquire_dark(exp_burst=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=file_path, nr_writers=2)