feat: add psi_device_base class; remove old/redundant base classes

This commit is contained in:
2025-01-17 19:15:41 +01:00
parent 7b87c3374b
commit cb57bfd62a
15 changed files with 981 additions and 1345 deletions

View File

@ -1,523 +0,0 @@
"""
This module contains the base class for custom device integrations at PSI.
Please check the device section in BEC's developer documentation
(https://bec.readthedocs.io/en/latest/) for more information about device integration.
"""
import os
import threading
import time
import traceback
from typing import Generic, TypeVar
from bec_lib.file_utils import FileWriter
from bec_lib.logger import bec_logger
from ophyd import Device, DeviceStatus, Kind
from ophyd.device import Staged
from ophyd_devices.utils import bec_utils
from ophyd_devices.utils.bec_scaninfo_mixin import BecScaninfoMixin
from ophyd_devices.utils.errors import DeviceStopError, DeviceTimeoutError
logger = bec_logger.logger
T = TypeVar("T", bound="BECDeviceBase")
class BECDeviceBaseError(Exception):
"""Error class for BECDeviceBase."""
class CustomPrepare(Generic[T]):
"""Custom prepare class for beamline specific logic.
This class provides a set of hooks for beamline specific logic to be implemented.
BECDeviceBase will be injected as the parent device.
To implement custom logic, inherit from this class and implement the desired methods.
If the __init__ method is overwritten, please ensure the proper initialisation.
It is important to pass the parent device to the custom prepare class as this
will allow the custom prepare class to access the parent device with all its signals.
"""
def __init__(self, *_args, parent: T = None, **_kwargs) -> None:
"""
Initialize the custom prepare class.
Args:
parent (BECDeviceBase): The parent device which gives access to all methods and signals.
"""
self.parent = parent
def on_init(self) -> None:
"""
Hook for beamline specific logic during class initialization.
This method is called during the initialization of the device class.
It should not be used to set any of the class's signals as they may not
be connected yet.
"""
pass
def on_wait_for_connection(self) -> None:
"""
Hook for beamline specific logic during the wait_for_connection method.
This method is called after Ophyd's wait_for_connection method was called,
meaning that signals will be connected at this point. It can be used to check
signal values, or set default values for those.
"""
def on_stage(self) -> None:
"""
Hook for beamline specific logic during the stage method.
This method is called during the stage method of the device class.
It is used to implement logic in preparation for a scan."""
def on_unstage(self) -> None:
"""
Hook for beamline specific logic during the unstage method.
This method is called during the unstage method of the device class.
It is used to implement logic to clean up the device after a scan.
"""
def on_stop(self) -> None:
"""Hook for beamline specific logic during the stop method."""
def on_trigger(self) -> None | DeviceStatus:
"""
Hook for beamline specific logic during the trigger method.
This method has to be non-blocking, and if time-consuming actions are necessary,
they should be implemented asynchronously. Please check the wait_with_status
method to implement asynchronous checks.
The softwareTrigger config value needs to be set to True to indicate to BEC
that the device should be triggered from the software during the scan.
Returns:
DeviceStatus: DeviceStatus object that BEC will use to check if the trigger was successful
"""
def on_pre_scan(self) -> None:
"""
Hook for beamline specific logic during the pre_scan method.
This method is called from BEC just before the scan core starts, and be used
to execute time-critical actions, e.g. arming a detector in case there is a risk of timing out.
Note, this method should not be used to implement blocking logic.
"""
def on_complete(self) -> None | DeviceStatus:
"""
Hook for beamline specific logic during the complete method.
This method is used to check whether the device has successfully completed its acquisition.
For example, a detector may want to check if it has received the correct number of frames, or
if it's data backend has finished writing the data to disk. The method should be implemented
asynchronously. Please check the wait_with_status method on how to implement asynchronous checks.
Returns:
DeviceStatus: DeviceStatus object that BEC will use to check if the device has successfully completed
"""
def on_kickoff(self) -> None | DeviceStatus:
"""
Hook for beamline specific logic during the kickoff method.
This method is called to kickoff the flyer acquisition. BEC will not call this method in general
for its scans, but only if the scan explicitly implements this. The method should be non-blocking,
and if time-consuming actions are necessary, they should be implemented asynchronously.
Please check the wait_with_status method on how to implement asynchronous checks.
Returns:
DeviceStatus: DeviceStatus object that BEC will use to check if the kickoff was successful
"""
def wait_for_signals(
self,
signal_conditions: list[tuple],
timeout: float,
check_stopped: bool = False,
interval: float = 0.05,
all_signals: bool = False,
) -> bool:
"""
Utility method to implement waiting for signals to reach a certain condition. It accepts
a list of conditions passed as tuples of executable calls for conditions (get_current_state, condition) to check.
It can further be specified if all signals should be True or if any signal should be True.
If the timeout is reached, it will return False.
Args:
signal_conditions (list[tuple]): tuple of executable calls for conditions (get_current_state, condition) to check
timeout (float): timeout in seconds
check_stopped (bool): True if stopped flag should be checked
interval (float): interval in seconds
all_signals (bool): True if all signals should be True, False if any signal should be True
Returns:
bool: True if all signals are in the desired state, False if timeout is reached
Example:
>>> self.wait_for_signals(signal_conditions=[(self.acquiring.get, False)], timeout=5, interval=0.05, check_stopped=True, all_signals=True)
"""
timer = 0
while True:
checks = [
get_current_state() == condition
for get_current_state, condition in signal_conditions
]
if check_stopped is True and self.parent.stopped is True:
return False
if (all_signals and all(checks)) or (not all_signals and any(checks)):
return True
if timer > timeout:
return False
time.sleep(interval)
timer += interval
def wait_with_status(
self,
signal_conditions: list[tuple],
timeout: float,
check_stopped: bool = False,
interval: float = 0.05,
all_signals: bool = False,
exception_on_timeout: Exception = None,
) -> DeviceStatus:
"""
Utility method to implement asynchronous waiting for signals to reach a certain condition.
It accepts a list of conditions passed as tuples of executable calls.
Please check the wait_for_signals method as it is used to implement the waiting logic.
It returns a DeviceStatus object that can be used to check if the asynchronous action is done
through 'status.done', and if it was successful through 'status.success'. An exception can be
passed to the method which will be raised if the timeout is reached. If the device was stopped
during the waiting, a DeviceStopError will be raised.
Args:
signal_conditions (list[tuple]): tuple of executable calls for conditions (get_current_state, condition) to check
timeout (float): timeout in seconds
check_stopped (bool): True if stopped flag should be checked
interval (float): interval in seconds
all_signals (bool): True if all signals should be True, False if any signal should be True
exception_on_timeout (Exception): Exception to raise on timeout
Returns:
DeviceStatus: DeviceStatus object to check the state of the asynchronous action (status.done, status.success)
Example:
>>> status = self.wait_with_status(signal_conditions=[(self.acquiring.get, False)], timeout=5, interval=0.05, check_stopped=True, all_signals=True)
"""
if exception_on_timeout is None:
exception_on_timeout = DeviceTimeoutError(
f"Timeout error for {self.parent.name} while waiting for signals {signal_conditions}"
)
status = DeviceStatus(self.parent)
# utility function to wrap the wait_for_signals function
def wait_for_signals_wrapper(
status: DeviceStatus,
signal_conditions: list[tuple],
timeout: float,
check_stopped: bool,
interval: float,
all_signals: bool,
exception_on_timeout: Exception,
):
try:
result = self.wait_for_signals(
signal_conditions, timeout, check_stopped, interval, all_signals
)
if result:
status.set_finished()
else:
if self.parent.stopped:
# INFO This will execute a callback to the parent device.stop() method
status.set_exception(exc=DeviceStopError(f"{self.parent.name} was stopped"))
else:
# INFO This will execute a callback to the parent device.stop() method
status.set_exception(exc=exception_on_timeout)
# pylint: disable=broad-except
except Exception as exc:
content = traceback.format_exc()
logger.warning(
f"Error in wait_for_signals in {self.parent.name}; Traceback: {content}"
)
# INFO This will execute a callback to the parent device.stop() method
status.set_exception(exc=exc)
thread = threading.Thread(
target=wait_for_signals_wrapper,
args=(
status,
signal_conditions,
timeout,
check_stopped,
interval,
all_signals,
exception_on_timeout,
),
daemon=True,
)
thread.start()
return status
class BECDeviceBase(Device):
"""
Base class for custom device integrations at PSI. This class wraps around the ophyd's standard
set of methods, providing hooks for custom logic to be implemented in the custom_prepare_cls.
Please check the device section in BEC's developer documentation
(https://bec.readthedocs.io/en/latest/) for more information about device integration.
"""
custom_prepare_cls = CustomPrepare
# All possible subscription types that the Device Manager subscribes to
# Run the command _run_subs(sub_type=self.SUB_VALUE, value=value) to trigger
# the subscription of type value. Please be aware that the signature of
# the subscription callbacks needs to be matched.
SUB_READBACK = "readback"
SUB_VALUE = "value"
SUB_DONE_MOVING = "done_moving"
SUB_MOTOR_IS_MOVING = "motor_is_moving"
SUB_PROGRESS = "progress"
SUB_FILE_EVENT = "file_event"
SUB_DEVICE_MONITOR_1D = "device_monitor_1d"
SUB_DEVICE_MONITOR_2D = "device_monitor_2d"
_default_sub = SUB_VALUE
def __init__(
self,
name: str,
prefix: str = "",
kind: Kind | None = None,
parent=None,
device_manager=None,
**kwargs,
):
"""
Initialize the device.
Args:
name (str): name of the device
prefix (str): prefix of the device
kind (Kind): kind of the device
parent (Device): parent device
device_manager (DeviceManager): device manager. BEC will inject the device manager as a dependency.
"""
super().__init__(prefix=prefix, name=name, kind=kind, parent=parent, **kwargs)
self._stopped = False
self.service_cfg = None
self.scaninfo = None
self.filewriter = None
if not issubclass(self.custom_prepare_cls, CustomPrepare):
raise BECDeviceBaseError(
f"Custom prepare class must be subclass of CustomDetectorMixin, provided: {self.custom_prepare_cls}"
)
self.custom_prepare = self.custom_prepare_cls(parent=self, **kwargs)
if device_manager:
self._update_service_config()
self.device_manager = device_manager
else:
# If device manager is not provided through dependency injection
# A mock device manager is created. This is necessary to be able
# to use the device class in a standalone context without BEC.
self.device_manager = bec_utils.DMMock()
base_path = kwargs["basepath"] if "basepath" in kwargs else "."
self.service_cfg = {"base_path": os.path.abspath(base_path)}
self.connector = self.device_manager.connector
self._update_scaninfo()
self._update_filewriter()
self._init()
@property
def stopped(self) -> bool:
"""Property to indicate if the device was stopped."""
return self._stopped
@stopped.setter
def stopped(self, value: bool) -> None:
self._stopped = value
def _update_filewriter(self) -> None:
"""Initialise the file writer utility class."""
self.filewriter = FileWriter(service_config=self.service_cfg, connector=self.connector)
def _update_scaninfo(self) -> None:
"""Initialise the utility class to get scan metadata from BEC."""
self.scaninfo = BecScaninfoMixin(self.device_manager)
self.scaninfo.load_scan_metadata()
def _update_service_config(self) -> None:
"""Update the Service Config. This has the info on where REDIS and other services are running."""
# pylint: disable=import-outside-toplevel
from bec_lib.bec_service import SERVICE_CONFIG
if SERVICE_CONFIG:
self.service_cfg = SERVICE_CONFIG.config["service_config"]["file_writer"]
return
self.service_cfg = {"base_path": os.path.abspath(".")}
def check_scan_id(self) -> None:
"""
Check if the scan ID has changed, if yes, set the stopped property to True.
The idea is that if the scan ID on the device and the scan ID from BEC are different,
the device is out of sync with the scan and should be stopped.
"""
old_scan_id = self.scaninfo.scan_id
self.scaninfo.load_scan_metadata()
if self.scaninfo.scan_id != old_scan_id:
self.stopped = True
def _init(self) -> None:
"""
Hook for beamline specific logic during class initialization.
Please to not set any of the class's signals during intialisation, but
instead use the wait_for_connection.
"""
self.custom_prepare.on_init()
def wait_for_connection(self, all_signals=False, timeout=5) -> None:
"""
Thin wrapper around ophyd's wait_for_connection.
Calling Ophyd's wait_for_connection will ensure that signals are connected. BEC
will call this method if a device is not connected yet. This method should
be used to set up default values for signals, or to check if the device signals
are in the expected state.
Args:
all_signals (bool): True if all signals should be considered. Default is False.
timeout (float): timeout in seconds. Default is 5 seconds.
"""
super().wait_for_connection(all_signals, timeout)
self.custom_prepare.on_wait_for_connection()
def stage(self) -> list[object]:
"""
Thin wrapper around ophyd's stage, the method called in preparation for a scan.
Stage is idempotent, if staged twice it should raise (we let ophyd.Device handle the raise here).
Other that that, we reset the stopped property in case the device was stopped before, and
pull the latest scan metadata from BEC. Ater that, we allow for beamline specific logic to
be implemented through the custom_prepare.on_stage method.
Returns:
list(object): list of objects that were staged
"""
if self._staged != Staged.no:
return super().stage()
self.stopped = False
self.scaninfo.load_scan_metadata()
self.custom_prepare.on_stage()
return super().stage()
def unstage(self) -> list[object]:
"""
This wrapper around ophyd's unstage method, which is called to clean up the device.
It must be possible to call unstage multiple times without raising an exception. It should
be implemented to clean up the device if it is in a staged state.
Beamline specific logic can be implemented through the custom_prepare.on_unstage method.
Returns:
list(object): list of objects that were unstaged
"""
self.check_scan_id()
self.custom_prepare.on_unstage()
self.stopped = False
return super().unstage()
def pre_scan(self) -> None:
"""
Pre-scan is a method introduced by BEC that is not native to the ophyd interface.
This method is called from BEC just before the scan core starts, and therefore should only
implement time-critical actions. I.e. Arming a detector in case there is a risk of timing out.
"""
self.custom_prepare.on_pre_scan()
def trigger(self) -> DeviceStatus:
"""
Thin wrapper around the trigger method of the device, for which the config value
softwareTrigger needs to be set to True, which will indicate to BEC that the device
should be triggered from the software during the scan.
Custom logic should be implemented non-blocking, i.e. be fast, or implemented asynchroniously.
Returns:
DeviceStatus: DeviceStatus object that BEC will use to check if the trigger was successful.
"""
# pylint: disable=assignment-from-no-return
status = self.custom_prepare.on_trigger()
if isinstance(status, DeviceStatus):
return status
return super().trigger()
def complete(self) -> DeviceStatus:
"""
Thin wrapper around ophyd's complete method. Complete is called once the scan core
has finished, but before the scan is closed. It will be called before unstage.
It can also be used for fly scans to track the status of the flyer, and indicate if the
flyer has completed.
The method is used to check whether the device has successfully completed the acquisition.
Actions are implemented in custom_prepare.on_complete since they are beamline specific.
This method has to be non-blocking. If checks are necessary, they should be implemented asynchronously.
Returns:
DeviceStatus: DeviceStatus object that BEC will use to check if the device has successfully completed.
"""
# pylint: disable=assignment-from-no-return
status = self.custom_prepare.on_complete()
if isinstance(status, DeviceStatus):
return status
status = DeviceStatus(self)
status.set_finished()
return status
def stop(self, *, success=False) -> None:
"""Stop the device.
Args:
success (bool): Argument from ophyd's stop method. Default is False.
"""
self.custom_prepare.on_stop()
super().stop(success=success)
self.stopped = True
def kickoff(self) -> DeviceStatus:
"""
This wrapper around Ophyd's kickoff method.
Kickoff is a method native to the flyer interface of Ophyd. It is called to
start the flyer acquisition. This method is not called by BEC in general, but
only if the scan explicitly implements this.
The method should be non-blocking, and if time-consuming actions are necessary,
they should be implemented asynchronously.
Returns:
DeviceStatus: DeviceStatus object that BEC will use to check if the kickoff was successful.
"""
# pylint: disable=assignment-from-no-return
status = self.custom_prepare.on_kickoff()
if isinstance(status, DeviceStatus):
return status
status = DeviceStatus(self)
status.set_finished()
return status

View File

@ -1,105 +0,0 @@
from abc import ABC, abstractmethod
from bec_lib import bec_logger
from ophyd import Component as Cpt
from ophyd import EpicsMotor
from typeguard import typechecked
from ophyd_devices.interfaces.protocols.bec_protocols import BECRotationProtocol
from ophyd_devices.utils.bec_utils import ConfigSignal
logger = bec_logger.logger
class OphtyRotationBaseError(Exception):
"""Exception specific for implmenetation of rotation stages."""
class OphydRotationBase(BECRotationProtocol, ABC):
allow_mod360 = Cpt(ConfigSignal, name="allow_mod360", value=False, kind="config")
def __init__(self, *args, **kwargs):
"""
Base class to implement functionality specific for rotation devices.
Childrens should override the instance attributes:
- has_mod360
- has_freerun
- valid_rotation_modes
"""
# pylint: disable=protected-access
self._has_mod360 = False
self._has_freerun = False
self._valid_rotation_modes = []
if "allow_mod360" in kwargs:
if not isinstance(kwargs["allow_mod360"], bool):
raise ValueError("allow_mod360 must be a boolean")
self.allow_mod360.put(kwargs["allow_mod360"])
super().__init__(*args, **kwargs)
@abstractmethod
def apply_mod360(self) -> None:
"""Method to apply the modulus 360 operation on the specific device.
Childrens should override this method
"""
@property
def has_mod360(self) -> bool:
"""Property to check if the device has mod360 operation.
ReadOnly property, childrens should override this method.
"""
return self._has_mod360
@property
def has_freerun(self) -> bool:
"""Property to check if the device has freerun operation.
ReadOnly property, childrens should override this method.
"""
return self._has_freerun
@property
def valid_rotation_modes(self) -> list:
"""Method to get the valid rotation modes for the specific device."""
return self._valid_rotation_modes
@typechecked
@valid_rotation_modes.setter
def valid_rotation_modes(self, value: list[str]):
"""Method to set the valid rotation modes for the specific device."""
self._valid_rotation_modes = value
return self._valid_rotation_modes
# pylint: disable=too-many-ancestors
class EpicsRotationBase(OphydRotationBase, EpicsMotor):
"""Class for Epics rotation devices."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._has_freerun = True
self._has_freerun = True
self._valid_rotation_modes = ["target", "radiography"]
def apply_mod360(self) -> None:
"""Apply modulos 360 operation for EpicsMotorRecord.
EpicsMotor has the function "set_current_position" which can be used for this purpose.
In addition, there is a check if mod360 is allowed and available.
"""
if self.has_mod360 and self.allow_mod360.get():
cur_val = self.user_readback.get()
new_val = cur_val % 360
try:
self.set_current_position(new_val)
except Exception as exc:
error_msg = f"Failed to set new position {new_val} from {cur_val} on device {self.name} with error {exc}"
raise OphtyRotationBaseError(error_msg) from exc
return
logger.info(
f"Did not apply mod360 for device {self.name} with has_mod={self.has_mod360} and allow_mod={self.allow_mod360.get()}"
)

View File

@ -1,65 +0,0 @@
from bec_lib import messages
from bec_lib.endpoints import MessageEndpoints
from ophyd import Component as Cpt
from ophyd import Kind
from ophyd_devices.interfaces.base_classes.bec_device_base import BECDeviceBase, CustomPrepare
from ophyd_devices.sim.sim_signals import SetableSignal
class CustomDetectorMixin(CustomPrepare):
"""Deprecated, use CustomPrepare instead. Here for backwards compatibility."""
def publish_file_location(
self,
done: bool,
successful: bool,
filepath: str = None,
hinted_locations: dict = None,
metadata: dict = None,
) -> None:
"""
Publish the filepath to REDIS.
We publish two events here:
- file_event: event for the filewriter
- public_file: event for any secondary service (e.g. radial integ code)
Args:
done (bool): True if scan is finished
successful (bool): True if scan was successful
filepath (str): Optional, filepath to publish. If None, it will be taken from self.parent.filepath.get()
hinted_locations (dict): Optional, dictionary with hinted locations; {dev_name : h5_entry}
metadata (dict): additional metadata to publish
"""
if metadata is None:
metadata = {}
if filepath is None:
file_path = self.parent.filepath.get()
msg = messages.FileMessage(
file_path=self.parent.filepath.get(),
hinted_locations=hinted_locations,
done=done,
successful=successful,
metadata=metadata,
)
pipe = self.parent.connector.pipeline()
self.parent.connector.set_and_publish(
MessageEndpoints.public_file(self.parent.scaninfo.scan_id, self.parent.name),
msg,
pipe=pipe,
)
self.parent.connector.set_and_publish(
MessageEndpoints.file_event(self.parent.name), msg, pipe=pipe
)
pipe.execute()
class PSIDetectorBase(BECDeviceBase):
"""Deprecated, use BECDeviceBase instead. Here for backwards compatibility."""
custom_prepare_cls = CustomDetectorMixin
filepath = Cpt(SetableSignal, value="", kind=Kind.config)

View File

@ -0,0 +1,176 @@
"""
Consider using the bec_device_base name for the base class.
I will use this name instead here to simplify comparisons between the two approaches
"""
from __future__ import annotations
from ophyd import Device, DeviceStatus, Staged, StatusBase
from ophyd_devices.tests.utils import get_mock_scan_info
from ophyd_devices.utils.psi_device_base_utils import FileHandler, TaskHandler
class PSIDeviceBase(Device):
"""
Base class for all PSI ophyd devices to ensure consistent configuration
and communication with BEC services.
"""
# These are all possible subscription types that the device_manager supports
# and automatically subscribes to
SUB_READBACK = "readback"
SUB_VALUE = "value"
SUB_DONE_MOVING = "done_moving"
SUB_MOTOR_IS_MOVING = "motor_is_moving"
SUB_PROGRESS = "progress"
SUB_FILE_EVENT = "file_event"
SUB_DEVICE_MONITOR_1D = "device_monitor_1d"
SUB_DEVICE_MONITOR_2D = "device_monitor_2d"
_default_sub = SUB_VALUE
def __init__(self, name: str, scan_info=None, **kwargs):
"""
Initialize the PSI Device Base class.
Args:
name (str) : Name of the device
scan_info (ScanInfo): The scan info to use.
"""
super().__init__(name=name, **kwargs)
self._stopped = False
self.task_handler = TaskHandler(parent=self)
if scan_info is None:
scan_info = get_mock_scan_info()
self.scan_info = scan_info
self.file_utils = FileHandler()
self.on_init()
########################################
# Additional Properties and Attributes #
########################################
@property
def destroyed(self) -> bool:
"""Check if the device has been destroyed."""
return self._destroyed
@property
def staged(self) -> Staged:
"""Check if the device has been staged."""
return self._staged
@property
def stopped(self) -> bool:
"""Check if the device has been stopped."""
return self._stopped
@stopped.setter
def stopped(self, value: bool):
self._stopped = value
########################################
# Wrapper around Device class methods #
########################################
def stage(self) -> list[object] | StatusBase:
"""Stage the device."""
if self.staged != Staged.no:
return super().stage()
self.stopped = False
super_staged = super().stage()
status = self.on_stage() # pylint: disable=assignment-from-no-return
if isinstance(status, StatusBase):
return status
return super_staged
def unstage(self) -> list[object] | StatusBase:
"""Unstage the device."""
super_unstage = super().unstage()
status = self.on_unstage() # pylint: disable=assignment-from-no-return
if isinstance(status, StatusBase):
return status
return super_unstage
def pre_scan(self) -> StatusBase | None:
"""Pre-scan function."""
status = self.on_pre_scan() # pylint: disable=assignment-from-no-return
return status
def trigger(self) -> DeviceStatus:
"""Trigger the device."""
super_trigger = super().trigger()
status = self.on_trigger() # pylint: disable=assignment-from-no-return
return status if status else super_trigger
def complete(self) -> DeviceStatus:
"""Complete the device."""
status = self.on_complete() # pylint: disable=assignment-from-no-return
if isinstance(status, StatusBase):
return status
status = DeviceStatus(self)
status.set_finished()
return status
def kickoff(self) -> DeviceStatus:
"""Kickoff the device."""
status = self.on_kickoff() # pylint: disable=assignment-from-no-return
if isinstance(status, StatusBase):
return status
status = DeviceStatus(self)
status.set_finished()
return status
# pylint: disable=arguments-differ
def stop(self, success: bool = False) -> None:
"""Stop the device.
Args:
success (bool): True if the device was stopped successfully.
"""
self.on_stop()
super().stop(success=success)
self.stopped = True
########################################
# Beamline Specific Implementations #
########################################
def on_init(self) -> None:
"""
Called when the device is initialized.
No siganls are connected at this point,
thus should not be set here but in on_connected instead.
"""
def on_connected(self) -> None:
"""
Called after the device is connected and its signals are connected.
Default values for signals should be set here.
"""
def on_stage(self) -> DeviceStatus | None:
"""
Called while staging the device.
Information about the upcoming scan can be accessed from the scan_info object.
"""
def on_unstage(self) -> DeviceStatus | None:
"""Called while unstaging the device."""
def on_pre_scan(self) -> DeviceStatus | None:
"""Called right before the scan starts on all devices automatically."""
def on_trigger(self) -> DeviceStatus | None:
"""Called when the device is triggered."""
def on_complete(self) -> DeviceStatus | None:
"""Called to inquire if a device has completed a scans."""
def on_kickoff(self) -> DeviceStatus | None:
"""Called to kickoff a device for a fly scan. Has to be called explicitly."""
def on_stop(self) -> None:
"""Called when the device is stopped."""

View File

@ -1,7 +1,9 @@
""" This module provides a range of protocols that describe the expected interface for different types of devices.
""" This module provides a range of protocols that describe the expected
interface for different types of devices.
The protocols below can be used as teamplates for functionality to be implemeted by different type of devices.
They further facilitate runtime checks on devices and provide a minimum set of properties required for a device to be loadable by BEC.
The protocols below can be used as teamplates for functionality to be implemeted
by different type of devices. They further facilitate runtime checks on devices
and provide a minimum set of properties required for a device to be loadable by BEC.
The protocols are:
- BECBaseProtocol: Protocol for devices in BEC. All devices must at least implement this protocol.
@ -11,17 +13,15 @@ The protocols are:
- BECPositionerProtocol: Protocol for positioners.
- BECFlyerProtocol: Protocol with for flyers.
Keep in mind, that a device of type flyer should generally also implement the BECDeviceProtocol that provides the required functionality for scans.
Flyers in addition, also implement the BECFlyerProtocol. Similarly, positioners should also implement the BECDeviceProtocol and BECPositionerProtocol.
Keep in mind, that a device of type flyer should generally also implement the BECDeviceProtocol
with the functionality needed for scans. In addition, flyers also implement the BECFlyerProtocol.
Similarly, positioners should also implement the BECDeviceProtocol and BECPositionerProtocol.
"""
from typing import Protocol, runtime_checkable
from bec_lib.file_utils import FileWriter
from ophyd import Component, DeviceStatus, Kind, Staged
from ophyd_devices.utils import bec_scaninfo_mixin
from ophyd import DeviceStatus, Kind, Staged
@runtime_checkable
@ -349,8 +349,8 @@ class BECPositionerProtocol(BECDeviceProtocol, Protocol):
def move(self, position: float) -> DeviceStatus:
"""Move method for positioners.
The returned DeviceStatus is marked as done once the positioner has reached the target position.
DeviceStatus.wait() can be used to block until the move is completed.
The returned DeviceStatus is marked as done once the positioner has reached the target
position. DeviceStatus.wait() can be used to block until the move is completed.
Args:
position: position to move to

View File

@ -1,161 +1,23 @@
import traceback
from threading import Thread
""" Simulated 2D camera device"""
import numpy as np
from bec_lib.logger import bec_logger
from ophyd import Component as Cpt
from ophyd import DeviceStatus, Kind
from ophyd import Device, Kind, StatusBase
from ophyd_devices.interfaces.base_classes.psi_detector_base import (
CustomDetectorMixin,
PSIDetectorBase,
)
from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase
from ophyd_devices.sim.sim_data import SimulatedDataCamera
from ophyd_devices.sim.sim_signals import ReadOnlySignal, SetableSignal
from ophyd_devices.sim.sim_utils import H5Writer
from ophyd_devices.utils.errors import DeviceStopError
logger = bec_logger.logger
class SimCameraSetup(CustomDetectorMixin):
"""Mixin class for the SimCamera device."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._thread_trigger = None
self._thread_complete = None
self.file_path = None
def on_trigger(self) -> None:
"""Trigger the camera to acquire images.
This method can be called from BEC during a scan. It will acquire images and send them to BEC.
Whether the trigger is send from BEC is determined by the softwareTrigger argument in the device config.
Here, we also run a callback on SUB_MONITOR to send the image data the device_monitor endpoint in BEC.
"""
status = DeviceStatus(self.parent)
def on_trigger_call(status: DeviceStatus) -> None:
try:
for _ in range(self.parent.burst.get()):
data = self.parent.image.get()
# pylint: disable=protected-access
self.parent._run_subs(sub_type=self.parent.SUB_MONITOR, value=data)
if self.parent.stopped:
raise DeviceStopError(f"{self.parent.name} was stopped")
if self.parent.write_to_disk.get():
self.parent.h5_writer.receive_data(data)
status.set_finished()
# pylint: disable=broad-except
except Exception as exc:
content = traceback.format_exc()
logger.warning(
f"Error in on_trigger_call in device {self.parent.name}. Error traceback: {content}"
)
status.set_exception(exc)
self._thread_trigger = Thread(target=on_trigger_call, args=(status,))
self._thread_trigger.start()
return status
def on_stage(self) -> None:
"""Stage the camera for upcoming scan
This method is called from BEC in preparation of a scan.
It receives metadata about the scan from BEC,
compiles it and prepares the camera for the scan.
FYI: No data is written to disk in the simulation, but upon each trigger it
is published to the device_monitor endpoint in REDIS.
"""
self.file_path = self.parent.filewriter.compile_full_filename(f"{self.parent.name}")
self.parent.frames.set(
self.parent.scaninfo.num_points * self.parent.scaninfo.frames_per_trigger
)
self.parent.exp_time.set(self.parent.scaninfo.exp_time)
self.parent.burst.set(self.parent.scaninfo.frames_per_trigger)
if self.parent.write_to_disk.get():
self.parent.h5_writer.on_stage(file_path=self.file_path, h5_entry="/entry/data/data")
self.parent._run_subs(
sub_type=self.parent.SUB_FILE_EVENT,
file_path=self.file_path,
done=False,
successful=False,
hinted_location={"data": "/entry/data/data"},
)
self.parent.stopped = False
def on_complete(self) -> None:
"""Complete the motion of the simulated device."""
status = DeviceStatus(self.parent)
def on_complete_call(status: DeviceStatus) -> None:
try:
if self.parent.write_to_disk.get():
self.parent.h5_writer.on_complete()
self.parent._run_subs(
sub_type=self.parent.SUB_FILE_EVENT,
file_path=self.file_path,
done=True,
successful=True,
hinted_location={"data": "/entry/data/data"},
)
if self.parent.stopped:
raise DeviceStopError(f"{self.parent.name} was stopped")
status.set_finished()
# pylint: disable=broad-except
except Exception as exc:
content = traceback.format_exc()
logger.warning(
f"Error in on_complete call in device {self.parent.name}. Error traceback: {content}"
)
status.set_exception(exc)
self._thread_complete = Thread(target=on_complete_call, args=(status,), daemon=True)
self._thread_complete.start()
return status
def on_unstage(self):
"""Unstage the camera device."""
if self.parent.write_to_disk.get():
self.parent.h5_writer.on_unstage()
def on_stop(self) -> None:
"""Stop the camera acquisition."""
if self._thread_trigger:
self._thread_trigger.join()
if self._thread_complete:
self._thread_complete.join()
self.on_unstage()
self._thread_trigger = None
self._thread_complete = None
class SimCamera(PSIDetectorBase):
"""A simulated device mimic any 2D camera.
It's image is a computed signal, which is configurable by the user and from the command line.
The corresponding simulation class is sim_cls=SimulatedDataCamera, more details on defaults within the simulation class.
>>> camera = SimCamera(name="camera")
Parameters
----------
name (string) : Name of the device. This is the only required argmuent, passed on to all signals of the device.
precision (integer) : Precision of the readback in digits, written to .describe(). Default is 3 digits.
sim_init (dict) : Dictionary to initiate parameters of the simulation, check simulation type defaults for more details.
parent : Parent device, optional, is used internally if this signal/device is part of a larger device.
kind : A member the Kind IntEnum (or equivalent integer), optional. Default is Kind.normal. See Kind for options.
device_manager : DeviceManager from BEC, optional . Within startup of simulation, device_manager is passed on automatically.
"""
class SimCameraControl(Device):
"""SimCamera Control layer"""
USER_ACCESS = ["sim", "registered_proxies"]
custom_prepare_cls = SimCameraSetup
sim_cls = SimulatedDataCamera
SHAPE = (100, 100)
BIT_DEPTH = np.uint16
@ -178,16 +40,13 @@ class SimCamera(PSIDetectorBase):
)
write_to_disk = Cpt(SetableSignal, name="write_to_disk", value=False, kind=Kind.config)
def __init__(
self, name, *, kind=None, parent=None, sim_init: dict = None, device_manager=None, **kwargs
):
def __init__(self, name, *, parent=None, sim_init: dict = None, device_manager=None, **kwargs):
self.sim_init = sim_init
self.device_manager = device_manager
self._registered_proxies = {}
self.sim = self.sim_cls(parent=self, **kwargs)
self.h5_writer = H5Writer()
super().__init__(
name=name, parent=parent, kind=kind, device_manager=device_manager, **kwargs
)
super().__init__(name=name, parent=parent, **kwargs)
if self.sim_init:
self.sim.set_init(self.sim_init)
@ -195,3 +54,104 @@ class SimCamera(PSIDetectorBase):
def registered_proxies(self) -> None:
"""Dictionary of registered signal_names and proxies."""
return self._registered_proxies
class SimCamera(PSIDeviceBase, SimCameraControl):
"""A simulated device mimic any 2D camera.
It's image is a computed signal, which is configurable by the user and from the command line.
The corresponding simulation class is sim_cls=SimulatedDataCamera, more details on defaults within the simulation class.
>>> camera = SimCamera(name="camera")
Parameters
----------
name (string) : Name of the device. This is the only required argmuent, passed on to all signals of the device.
precision (integer) : Precision of the readback in digits, written to .describe(). Default is 3 digits.
sim_init (dict) : Dictionary to initiate parameters of the simulation, check simulation type defaults for more details.
parent : Parent device, optional, is used internally if this signal/device is part of a larger device.
kind : A member the Kind IntEnum (or equivalent integer), optional. Default is Kind.normal. See Kind for options.
"""
def __init__(self, name: str, scan_info=None, device_manager=None, **kwargs):
super().__init__(name=name, scan_info=scan_info, device_manager=device_manager, **kwargs)
self.file_path = None
def on_trigger(self) -> StatusBase:
"""Trigger the camera to acquire images.
This method can be called from BEC during a scan. It will acquire images and send them to BEC.
Whether the trigger is send from BEC is determined by the softwareTrigger argument in the device config.
Here, we also run a callback on SUB_MONITOR to send the image data the device_monitor endpoint in BEC.
"""
def trigger_cam() -> None:
"""Trigger the camera to acquire images."""
for _ in range(self.burst.get()):
data = self.image.get()
# pylint: disable=protected-access
self._run_subs(sub_type=self.SUB_MONITOR, value=data)
if self.write_to_disk.get():
self.h5_writer.receive_data(data)
status = self.task_handler.submit_task(trigger_cam)
return status
def on_stage(self) -> None:
"""Stage the camera for upcoming scan
This method is called from BEC in preparation of a scan.
It receives metadata about the scan from BEC,
compiles it and prepares the camera for the scan.
FYI: No data is written to disk in the simulation, but upon each trigger it
is published to the device_monitor endpoint in REDIS.
"""
self.file_path = self.file_utils.get_file_path(
scan_status_msg=self.scan_info.msg, name=self.name
)
self.frames.set(
self.scan_info.msg.num_points * self.scan_info.msg.scan_parameters["frames_per_trigger"]
)
self.exp_time.set(self.scan_info.msg.scan_parameters["exp_time"])
self.burst.set(self.scan_info.msg.scan_parameters["frames_per_trigger"])
if self.write_to_disk.get():
self.h5_writer.on_stage(file_path=self.file_path, h5_entry="/entry/data/data")
# pylint: disable=protected-access
self._run_subs(
sub_type=self.SUB_FILE_EVENT,
file_path=self.file_path,
done=False,
successful=False,
hinted_location={"data": "/entry/data/data"},
)
def on_complete(self) -> StatusBase:
"""Complete the motion of the simulated device."""
def complete_cam():
"""Complete the camera acquisition."""
if self.write_to_disk.get():
self.h5_writer.on_complete()
self._run_subs(
sub_type=self.SUB_FILE_EVENT,
file_path=self.file_path,
done=True,
successful=True,
hinted_location={"data": "/entry/data/data"},
)
status = self.task_handler.submit_task(complete_cam)
return status
def on_unstage(self) -> None:
"""Unstage the camera device."""
if self.write_to_disk.get():
self.h5_writer.on_unstage()
def on_stop(self) -> None:
"""Stop the camera acquisition."""
self.task_handler.shutdown()
self.on_unstage()

View File

@ -106,7 +106,7 @@ class SimulatedDataBase(ABC):
def execute_simulation_method(self, *args, method=None, signal_name: str = "", **kwargs) -> any:
"""
Execute either the provided method or reroutes the method execution
to a device proxy in case it is registered in self.parentregistered_proxies.
to a device proxy in case it is registered in self.parent.registered_proxies.
"""
if self.registered_proxies and self.parent.device_manager:
for proxy_name, signal in self.registered_proxies.items():

View File

@ -1,22 +1,16 @@
"""Module for simulated monitor devices."""
import traceback
from threading import Thread
import numpy as np
from bec_lib import messages
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from ophyd import Component as Cpt
from ophyd import Device, DeviceStatus, Kind
from ophyd import Device, Kind, StatusBase
from ophyd_devices.interfaces.base_classes.psi_detector_base import (
CustomDetectorMixin,
PSIDetectorBase,
)
from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase
from ophyd_devices.sim.sim_data import SimulatedDataMonitor
from ophyd_devices.sim.sim_signals import ReadOnlySignal, SetableSignal
from ophyd_devices.utils.errors import DeviceStopError
from ophyd_devices.utils import bec_utils
logger = bec_logger.logger
@ -26,18 +20,25 @@ class SimMonitor(ReadOnlySignal):
A simulated device mimic any 1D Axis (position, temperature, beam).
It's readback is a computed signal, which is configurable by the user and from the command line.
The corresponding simulation class is sim_cls=SimulatedDataMonitor, more details on defaults within the simulation class.
The corresponding simulation class is sim_cls=SimulatedDataMonitor, more details on defaults
within the simulation class.
>>> monitor = SimMonitor(name="monitor")
Parameters
----------
name (string) : Name of the device. This is the only required argmuent, passed on to all signals of the device.
precision (integer) : Precision of the readback in digits, written to .describe(). Default is 3 digits.
sim_init (dict) : Dictionary to initiate parameters of the simulation, check simulation type defaults for more details.
parent : Parent device, optional, is used internally if this signal/device is part of a larger device.
kind : A member the Kind IntEnum (or equivalent integer), optional. Default is Kind.normal. See Kind for options.
device_manager : DeviceManager from BEC, optional . Within startup of simulation, device_manager is passed on automatically.
name (string) : Name of the device. This is the only required argmuent,
passed on to all signals of the device.
precision (integer) : Precision of the readback in digits, written to .describe().
Default is 3 digits.
sim_init (dict) : Dictionary to initiate parameters of the simulation,
check simulation type defaults for more details.
parent : Parent device, optional, is used internally if this
signal/device is part of a larger device.
kind : A member the Kind IntEnum (or equivalent integer), optional.
Default is Kind.normal. See Kind for options.
device_manager : DeviceManager from BEC, optional . Within startup of simulation,
device_manager is passed on automatically.
"""
@ -81,135 +82,11 @@ class SimMonitor(ReadOnlySignal):
return self._registered_proxies
class SimMonitorAsyncPrepare(CustomDetectorMixin):
"""Custom prepare for the SimMonitorAsync class."""
def __init__(self, *args, parent: Device = None, **kwargs) -> None:
super().__init__(*args, parent=parent, **kwargs)
self._stream_ttl = 1800
self._random_send_interval = None
self._counter = 0
self._thread_trigger = None
self._thread_complete = None
self.prep_random_interval()
self.parent.current_trigger.subscribe(self._progress_update, run=False)
def clear_buffer(self):
"""Clear the data buffer."""
self.parent.data_buffer["value"].clear()
self.parent.data_buffer["timestamp"].clear()
def prep_random_interval(self):
"""Prepare counter and random interval to send data to BEC."""
self._random_send_interval = np.random.randint(1, 10)
self.parent.current_trigger.set(0).wait()
self._counter = self.parent.current_trigger.get()
def on_stage(self):
"""Prepare the device for staging."""
self.clear_buffer()
self.prep_random_interval()
def on_complete(self):
"""Prepare the device for completion."""
status = DeviceStatus(self.parent)
def on_complete_call(status: DeviceStatus) -> None:
try:
if self.parent.data_buffer["value"]:
self._send_data_to_bec()
if self.parent.stopped:
raise DeviceStopError(f"{self.parent.name} was stopped")
status.set_finished()
# pylint: disable=broad-except
except Exception as exc:
content = traceback.format_exc()
status.set_exception(exc=exc)
logger.warning(f"Error in {self.parent.name} on_complete; Traceback: {content}")
self._thread_complete = Thread(target=on_complete_call, args=(status,))
self._thread_complete.start()
return status
def _send_data_to_bec(self) -> None:
"""Sends bundled data to BEC"""
if self.parent.scaninfo.scan_msg is None:
return
metadata = self.parent.scaninfo.scan_msg.metadata
metadata.update({"async_update": self.parent.async_update.get()})
msg = messages.DeviceMessage(
signals={self.parent.readback.name: self.parent.data_buffer},
metadata=self.parent.scaninfo.scan_msg.metadata,
)
self.parent.connector.xadd(
MessageEndpoints.device_async_readback(
scan_id=self.parent.scaninfo.scan_id, device=self.parent.name
),
{"data": msg},
expire=self._stream_ttl,
)
self.clear_buffer()
def on_trigger(self):
"""Prepare the device for triggering."""
status = DeviceStatus(self.parent)
def on_trigger_call(status: DeviceStatus) -> None:
try:
self.parent.data_buffer["value"].append(self.parent.readback.get())
self.parent.data_buffer["timestamp"].append(self.parent.readback.timestamp)
self._counter += 1
self.parent.current_trigger.set(self._counter).wait()
if self._counter % self._random_send_interval == 0:
self._send_data_to_bec()
if self.parent.stopped:
raise DeviceStopError(f"{self.parent.name} was stopped")
status.set_finished()
# pylint: disable=broad-except
except Exception as exc:
content = traceback.format_exc()
logger.warning(
f"Error in on_trigger_call in device {self.parent.name}; Traceback: {content}"
)
status.set_exception(exc=exc)
self._thread_trigger = Thread(target=on_trigger_call, args=(status,))
self._thread_trigger.start()
return status
def _progress_update(self, value: int, **kwargs):
"""Update the progress of the device."""
max_value = self.parent.scaninfo.num_points
# pylint: disable=protected-access
self.parent._run_subs(
sub_type=self.parent.SUB_PROGRESS,
value=value,
max_value=max_value,
done=bool(max_value == value),
)
def on_stop(self):
"""Stop the device."""
if self._thread_trigger:
self._thread_trigger.join()
if self._thread_complete:
self._thread_complete.join()
self._thread_trigger = None
self._thread_complete = None
class SimMonitorAsync(PSIDetectorBase):
"""
A simulated device to mimic the behaviour of an asynchronous monitor.
During a scan, this device will send data not in sync with the point ID to BEC,
but buffer data and send it in random intervals.s
"""
class SimMonitorAsyncControl(Device):
"""SimMonitor Sync Control Device"""
USER_ACCESS = ["sim", "registered_proxies", "async_update"]
custom_prepare_cls = SimMonitorAsyncPrepare
sim_cls = SimulatedDataMonitor
BIT_DEPTH = np.uint32
@ -221,17 +98,17 @@ class SimMonitorAsync(PSIDetectorBase):
SUB_PROGRESS = "progress"
_default_sub = SUB_READBACK
def __init__(
self, name, *, sim_init: dict = None, parent=None, kind=None, device_manager=None, **kwargs
):
def __init__(self, name, *, sim_init: dict = None, parent=None, device_manager=None, **kwargs):
if device_manager:
self.device_manager = device_manager
else:
self.device_manager = bec_utils.DMMock()
self.connector = self.device_manager.connector
self.sim_init = sim_init
self.device_manager = device_manager
self.sim = self.sim_cls(parent=self, **kwargs)
self._registered_proxies = {}
super().__init__(
name=name, parent=parent, kind=kind, device_manager=device_manager, **kwargs
)
super().__init__(name=name, parent=parent, **kwargs)
self.sim.sim_state[self.name] = self.sim.sim_state.pop(self.readback.name, None)
self.readback.name = self.name
self._data_buffer = {"value": [], "timestamp": []}
@ -247,3 +124,101 @@ class SimMonitorAsync(PSIDetectorBase):
def registered_proxies(self) -> None:
"""Dictionary of registered signal_names and proxies."""
return self._registered_proxies
class SimMonitorAsync(PSIDeviceBase, SimMonitorAsyncControl):
"""
A simulated device to mimic the behaviour of an asynchronous monitor.
During a scan, this device will send data not in sync with the point ID to BEC,
but buffer data and send it in random intervals.s
"""
def __init__(
self, name: str, scan_info=None, parent: Device = None, device_manager=None, **kwargs
) -> None:
super().__init__(
name=name, scan_info=scan_info, parent=parent, device_manager=device_manager, **kwargs
)
self._stream_ttl = 1800
self._random_send_interval = None
self._counter = 0
self.prep_random_interval()
def on_connected(self):
self.current_trigger.subscribe(self._progress_update, run=False)
def clear_buffer(self):
"""Clear the data buffer."""
self.data_buffer["value"].clear()
self.data_buffer["timestamp"].clear()
def prep_random_interval(self):
"""Prepare counter and random interval to send data to BEC."""
self._random_send_interval = np.random.randint(1, 10)
self.current_trigger.set(0).wait()
self._counter = self.current_trigger.get()
def on_stage(self):
"""Prepare the device for staging."""
self.clear_buffer()
self.prep_random_interval()
def on_complete(self) -> StatusBase:
"""Prepare the device for completion."""
def complete_action():
if self.data_buffer["value"]:
self._send_data_to_bec()
status = self.task_handler.submit_task(complete_action)
return status
def _send_data_to_bec(self) -> None:
"""Sends bundled data to BEC"""
if self.scan_info.msg is None:
return
metadata = self.scan_info.msg.metadata
metadata.update({"async_update": self.async_update.get()})
msg = messages.DeviceMessage(
signals={self.readback.name: self.data_buffer}, metadata=self.scan_info.msg.metadata
)
self.connector.xadd(
MessageEndpoints.device_async_readback(
scan_id=self.scan_info.msg.scan_id, device=self.name
),
{"data": msg},
expire=self._stream_ttl,
)
self.clear_buffer()
def on_trigger(self):
"""Prepare the device for triggering."""
def trigger_action():
"""Trigger actions"""
self.data_buffer["value"].append(self.readback.get())
self.data_buffer["timestamp"].append(self.readback.timestamp)
self._counter += 1
self.current_trigger.set(self._counter).wait()
if self._counter % self._random_send_interval == 0:
self._send_data_to_bec()
status = self.task_handler.submit_task(trigger_action)
return status
def _progress_update(self, value: int, **kwargs):
"""Update the progress of the device."""
max_value = self.scan_info.msg.num_points
# pylint: disable=protected-access
self._run_subs(
sub_type=self.SUB_PROGRESS,
value=value,
max_value=max_value,
done=bool(max_value == value),
)
def on_stop(self):
"""Stop the device."""
self.task_handler.shutdown()

View File

@ -15,7 +15,6 @@ from ophyd import Device, DeviceStatus, Kind, Staged
from ophyd_devices.sim.sim_data import SimulatedDataWaveform
from ophyd_devices.sim.sim_signals import ReadOnlySignal, SetableSignal
from ophyd_devices.utils import bec_utils
from ophyd_devices.utils.bec_scaninfo_mixin import BecScaninfoMixin
from ophyd_devices.utils.errors import DeviceStopError
logger = bec_logger.logger
@ -67,7 +66,15 @@ class SimWaveform(Device):
async_update = Cpt(SetableSignal, value="append", kind=Kind.config)
def __init__(
self, name, *, kind=None, parent=None, sim_init: dict = None, device_manager=None, **kwargs
self,
name,
*,
kind=None,
parent=None,
sim_init: dict = None,
device_manager=None,
scan_info=None,
**kwargs,
):
self.sim_init = sim_init
self._registered_proxies = {}
@ -83,9 +90,8 @@ class SimWaveform(Device):
self._stream_ttl = 1800 # 30 min max
self.stopped = False
self._staged = Staged.no
self.scaninfo = None
self._trigger_thread = None
self._update_scaninfo()
self.scan_info = scan_info
if self.sim_init:
self.sim.set_init(self.sim_init)
@ -124,7 +130,7 @@ class SimWaveform(Device):
def _send_async_update(self):
"""Send the async update to BEC."""
metadata = self.scaninfo.scan_msg.metadata
metadata = self.scan_info.msg.metadata
async_update_type = self.async_update.get()
if async_update_type not in ["extend", "append"]:
raise ValueError(f"Invalid async_update type: {async_update_type}")
@ -134,19 +140,15 @@ class SimWaveform(Device):
signals={self.waveform.name: {"value": self.waveform.get(), "timestamp": time.time()}},
metadata=metadata,
)
# logger.warning(f"Adding async update to {self.name} and {self.scaninfo.scan_id}")
# logger.warning(f"Adding async update to {self.name} and {self.scan_info.msg.scan_id}")
self.connector.xadd(
MessageEndpoints.device_async_readback(scan_id=self.scaninfo.scan_id, device=self.name),
MessageEndpoints.device_async_readback(
scan_id=self.scan_info.msg.scan_id, device=self.name
),
{"data": msg},
expire=self._stream_ttl,
)
def _update_scaninfo(self) -> None:
"""Update scaninfo from BecScaninfoMixing
This depends on device manager and operation/sim_mode
"""
self.scaninfo = BecScaninfoMixin(self.device_manager)
def stage(self) -> list[object]:
"""Stage the camera for upcoming scan
@ -160,17 +162,18 @@ class SimWaveform(Device):
if self._staged is Staged.yes:
return super().stage()
self.scaninfo.load_scan_metadata()
self.file_path.set(
os.path.join(
self.file_path.get(), self.file_pattern.get().format(self.scaninfo.scan_number)
self.file_path.get(), self.file_pattern.get().format(self.scan_info.msg.scan_number)
)
)
self.frames.set(self.scaninfo.num_points * self.scaninfo.frames_per_trigger)
self.exp_time.set(self.scaninfo.exp_time)
self.burst.set(self.scaninfo.frames_per_trigger)
self.frames.set(
self.scan_info.msg.num_points * self.scan_info.msg.scan_parameters["frames_per_trigger"]
)
self.exp_time.set(self.scan_info.msg.scan_parameters["exp_time"])
self.burst.set(self.scan_info.msg.scan_parameters["frames_per_trigger"])
self.stopped = False
logger.warning(f"Staged {self.name}, scan_id : {self.scaninfo.scan_id}")
logger.warning(f"Staged {self.name}, scan_id : {self.scan_info.msg.scan_id}")
return super().stage()
def unstage(self) -> list[object]:

View File

@ -1,5 +1,21 @@
""" Utilities to mock and test devices."""
from dataclasses import dataclass
from typing import TYPE_CHECKING
from unittest import mock
from bec_lib.devicemanager import ScanInfo
from bec_lib.logger import bec_logger
from bec_lib.utils.import_utils import lazy_import_from
if TYPE_CHECKING:
from bec_lib.messages import ScanStatusMessage
else:
# TODO: put back normal import when Pydantic gets faster
ScanStatusMessage = lazy_import_from("bec_lib.messages", ("ScanStatusMessage",))
logger = bec_logger.logger
def patch_dual_pvs(device):
"""Patch dual PVs"""
@ -255,3 +271,128 @@ class MockPV:
use_monitor=use_monitor,
)
return data["value"] if data is not None else None
def get_mock_scan_info():
"""
Get a mock scan info object.
"""
return ScanInfo(msg=fake_scan_status_msg())
def fake_scan_status_msg():
"""
Create a fake scan status message.
"""
logger.warning(
("Device is not connected to a Redis server. Fetching mocked ScanStatusMessage.")
)
return ScanStatusMessage(
metadata={},
scan_id="mock_scan_id",
status="closed",
scan_number=0,
session_id=None,
num_points=11,
scan_name="mock_line_scan",
scan_type="step",
dataset_number=0,
scan_report_devices=["samx"],
user_metadata={},
readout_priority={
"monitored": ["bpm4a", "samx"],
"baseline": ["eyex"],
"async": ["waveform"],
"continuous": [],
"on_request": ["flyer_sim"],
},
scan_parameters={
"exp_time": 0,
"frames_per_trigger": 1,
"settling_time": 0,
"readout_time": 0,
"optim_trajectory": None,
"return_to_start": True,
"relative": True,
"system_config": {"file_suffix": None, "file_directory": None},
},
request_inputs={
"arg_bundle": ["samx", -10, 10],
"inputs": {},
"kwargs": {
"steps": 11,
"relative": True,
"system_config": {"file_suffix": None, "file_directory": None},
},
},
info={
"readout_priority": {
"monitored": ["bpm4a", "samx"],
"baseline": ["eyex"],
"async": ["waveform"],
"continuous": [],
"on_request": ["flyer_sim"],
},
"file_suffix": None,
"file_directory": None,
"user_metadata": {},
"RID": "a1d86f61-191c-4460-bcd6-f33c61b395ea",
"scan_id": "3edb8219-75a7-4791-8f86-d5ca112b771a",
"queue_id": "0f3639ee-899f-4ad1-9e71-f40514c937ef",
"scan_motors": ["samx"],
"num_points": 11,
"positions": [
[-10.0],
[-8.0],
[-6.0],
[-4.0],
[-2.0],
[0.0],
[2.0],
[4.0],
[6.0],
[8.0],
[10.0],
],
"file_path": "./data/test_file",
"scan_name": "mock_line_scan",
"scan_type": "step",
"scan_number": 0,
"dataset_number": 0,
"exp_time": 0,
"frames_per_trigger": 1,
"settling_time": 0,
"readout_time": 0,
"scan_report_devices": ["samx"],
"monitor_sync": "bec",
"scan_parameters": {
"exp_time": 0,
"frames_per_trigger": 1,
"settling_time": 0,
"readout_time": 0,
"optim_trajectory": None,
"return_to_start": True,
"relative": True,
"system_config": {"file_suffix": None, "file_directory": None},
},
"request_inputs": {
"arg_bundle": ["samx", -10, 10],
"inputs": {},
"kwargs": {
"steps": 11,
"relative": True,
"system_config": {"file_suffix": None, "file_directory": None},
},
},
"scan_msgs": [
"metadata={'file_suffix': None, 'file_directory': None, 'user_metadata': {}, 'RID': 'a1d86f61-191c-4460-bcd6-f33c61b395ea'} scan_type='mock_line_scan' parameter={'args': {'samx': [-10, 10]}, 'kwargs': {'steps': 11, 'relative': True, 'system_config': {'file_suffix': None, 'file_directory': None}}} queue='primary'"
],
"args": {"samx": [-10, 10]},
"kwargs": {
"steps": 11,
"relative": True,
"system_config": {"file_suffix": None, "file_directory": None},
},
},
timestamp=1737100681.694211,
)

View File

@ -1,142 +0,0 @@
import getpass
from bec_lib import bec_logger, messages
from bec_lib.devicemanager import DeviceManagerBase
from bec_lib.endpoints import MessageEndpoints
from ophyd_devices.utils.bec_utils import DMMock
logger = bec_logger.logger
class BECInfoMsgMock:
"""Mock BECInfoMsg class
This class is used for mocking BECInfoMsg for testing purposes
"""
def __init__(
self,
mockrid: str = "mockrid1111",
mockqueueid: str = "mockqueue_id111",
scan_number: int = 1,
exp_time: float = 15e-3,
num_points: int = 500,
readout_time: float = 3e-3,
scan_type: str = "fly",
num_lines: int = 1,
frames_per_trigger: int = 1,
) -> None:
self.mockrid = mockrid
self.mockqueueid = mockqueueid
self.scan_number = scan_number
self.exp_time = exp_time
self.num_points = num_points
self.readout_time = readout_time
self.scan_type = scan_type
self.num_lines = num_lines
self.frames_per_trigger = frames_per_trigger
def get_bec_info_msg(self) -> dict:
"""Get BECInfoMsg object"""
info_msg = {
"RID": self.mockrid,
"queue_id": self.mockqueueid,
"scan_number": self.scan_number,
"exp_time": self.exp_time,
"num_points": self.num_points,
"readout_time": self.readout_time,
"scan_type": self.scan_type,
"num_lines": self.exp_time,
"frames_per_trigger": self.frames_per_trigger,
}
return info_msg
class BecScaninfoMixin:
"""BecScaninfoMixin class
Args:
device_manager (DeviceManagerBase): DeviceManagerBase object
sim_mode (bool): Simulation mode flag
bec_info_msg (dict): BECInfoMsg object
Returns:
BecScaninfoMixin: BecScaninfoMixin object
"""
def __init__(self, device_manager: DeviceManagerBase = None, bec_info_msg=None) -> None:
self.sim_mode = bool(isinstance(device_manager, DMMock))
self.device_manager = device_manager
self.scan_msg = None
self.scan_id = None
if bec_info_msg is None:
infomsgmock = BECInfoMsgMock()
self.bec_info_msg = infomsgmock.get_bec_info_msg()
else:
self.bec_info_msg = bec_info_msg
self.metadata = None
self.scan_id = None
self.scan_number = None
self.exp_time = None
self.frames_per_trigger = None
self.num_points = None
self.scan_type = None
def get_bec_info_msg(self) -> None:
"""Get BECInfoMsg object"""
return self.bec_info_msg
def change_config(self, bec_info_msg: dict) -> None:
"""Change BECInfoMsg object"""
self.bec_info_msg = bec_info_msg
def _get_current_scan_msg(self) -> messages.ScanStatusMessage:
"""Get current scan message
Returns:
messages.ScanStatusMessage: messages.ScanStatusMessage object
"""
if not self.sim_mode:
msg = self.device_manager.connector.get(MessageEndpoints.scan_status())
if not isinstance(msg, messages.ScanStatusMessage):
return None
return msg
return messages.ScanStatusMessage(scan_id="1", status="open", info=self.bec_info_msg)
def get_username(self) -> str:
"""Get username"""
if self.sim_mode:
return getpass.getuser()
msg = self.device_manager.connector.get(MessageEndpoints.account())
if msg:
return msg
return getpass.getuser()
def load_scan_metadata(self) -> None:
"""Load scan metadata
This function loads scan metadata from the current scan message
"""
self.scan_msg = scan_msg = self._get_current_scan_msg()
try:
logger.info(f"Received scan msg for {self.scan_msg.content['scan_id']}")
self.metadata = {
"scan_id": scan_msg.content["scan_id"],
"RID": scan_msg.content["info"]["RID"],
"queue_id": scan_msg.content["info"]["queue_id"],
}
self.scan_id = scan_msg.content["scan_id"]
self.scan_number = scan_msg.content["info"]["scan_number"]
self.exp_time = scan_msg.content["info"]["exp_time"]
self.frames_per_trigger = scan_msg.content["info"]["frames_per_trigger"]
self.num_points = scan_msg.content["info"]["num_points"]
self.scan_type = scan_msg.content["info"].get("scan_type", "step")
self.readout_time = scan_msg.content["info"]["readout_time"]
except Exception as exc:
logger.error(f"Failed to load scan metadata: {exc}.")
self.username = self.get_username()

View File

@ -1,3 +1,5 @@
""" Utility class linked to BEC"""
import time
from bec_lib import bec_logger
@ -11,8 +13,9 @@ logger = bec_logger.logger
DEFAULT_EPICSSIGNAL_VALUE = object()
# TODO maybe specify here that this DeviceMock is for usage in the DeviceServer
class DeviceMock:
"""Mock for Device"""
def __init__(self, name: str, value: float = 0.0):
self.name = name
self.read_buffer = value
@ -21,13 +24,16 @@ class DeviceMock:
self._enabled = True
def read(self):
"""Return the current value of the device"""
return {self.name: {"value": self.read_buffer}}
def readback(self):
"""Return the current value of the device"""
return self.read_buffer
@property
def read_only(self) -> bool:
"""Get the read only status of the device"""
return self._read_only
@read_only.setter
@ -36,6 +42,7 @@ class DeviceMock:
@property
def enabled(self) -> bool:
"""Get the enabled status of the device"""
return self._enabled
@enabled.setter
@ -44,10 +51,12 @@ class DeviceMock:
@property
def user_parameter(self):
"""Get the user parameter of the device"""
return self._config["userParameter"]
@property
def obj(self):
"""Get the device object"""
return self

View File

@ -0,0 +1,193 @@
""" Utility handler to run tasks (function, conditions) in an asynchronous fashion."""
import ctypes
import threading
import traceback
import uuid
from enum import Enum
from typing import TYPE_CHECKING
from bec_lib.file_utils import get_full_file_path
from bec_lib.logger import bec_logger
from bec_lib.utils.import_utils import lazy_import_from
from ophyd import Device, DeviceStatus, StatusBase
if TYPE_CHECKING:
from bec_lib.messages import ScanStatusMessage
else:
# TODO: put back normal import when Pydantic gets faster
ScanStatusMessage = lazy_import_from("bec_lib.messages", ("ScanStatusMessage",))
logger = bec_logger.logger
set_async_exc = ctypes.pythonapi.PyThreadState_SetAsyncExc
class TaskState(str, Enum):
"""Possible task states"""
NOT_STARTED = "not_started"
RUNNING = "running"
TIMEOUT = "timeout"
ERROR = "error"
COMPLETED = "completed"
KILLED = "killed"
class TaskKilledError(Exception):
"""Exception raised when a task thread is killed"""
class TaskStatus(DeviceStatus):
"""Thin wrapper around StatusBase to add information about tasks"""
def __init__(self, device: Device, *, timeout=None, settle_time=0, done=None, success=None):
super().__init__(
device=device, timeout=timeout, settle_time=settle_time, done=done, success=success
)
self._state = TaskState.NOT_STARTED
self._task_id = str(uuid.uuid4())
@property
def exc(self) -> Exception:
"""Get the exception of the task"""
return self.exception()
@property
def state(self) -> str:
"""Get the state of the task"""
return self._state.value
@state.setter
def state(self, value: TaskState):
self._state = value
@property
def task_id(self) -> bool:
"""Get the task ID"""
return self._task_id
class TaskHandler:
"""Handler to manage asynchronous tasks"""
def __init__(self, parent: Device):
"""Initialize the handler"""
self._tasks = {}
self._parent = parent
def submit_task(self, task: callable, run: bool = True) -> TaskStatus:
"""Submit a task to the task handler.
Args:
task: The task to run.
run: Whether to run the task immediately.
"""
task_status = TaskStatus(device=self._parent)
thread = threading.Thread(
target=self._wrap_task,
args=(task, task_status),
name=f"task {task_status.task_id}",
daemon=True,
)
self._tasks.update({task_status.task_id: (task_status, thread)})
if run is True:
self.start_task(task_status)
return task_status
def start_task(self, task_status: TaskStatus) -> None:
"""Start a task,
Args:
task_status: The task status object.
"""
thread = self._tasks[task_status.task_id][1]
if thread.is_alive():
logger.warning(f"Task with ID {task_status.task_id} is already running.")
return
thread.start()
task_status.state = TaskState.RUNNING
def _wrap_task(self, task: callable, task_status: TaskStatus):
"""Wrap the task in a function"""
try:
task()
except TimeoutError as exc:
content = traceback.format_exc()
logger.warning(
(
f"Timeout Exception in task handler for task {task_status.task_id},"
f" Traceback: {content}"
)
)
task_status.set_exception(exc)
task_status.state = TaskState.TIMEOUT
except TaskKilledError as exc:
exc = exc.__class__(
f"Task {task_status.task_id} was killed. ThreadID:"
f" {self._tasks[task_status.task_id][1].ident}"
)
content = traceback.format_exc()
logger.warning(
(
f"TaskKilled Exception in task handler for task {task_status.task_id},"
f" Traceback: {content}"
)
)
task_status.set_exception(exc)
task_status.state = TaskState.KILLED
except Exception as exc: # pylint: disable=broad-except
content = traceback.format_exc()
logger.warning(
f"Exception in task handler for task {task_status.task_id}, Traceback: {content}"
)
task_status.set_exception(exc)
task_status.state = TaskState.ERROR
else:
task_status.set_finished()
task_status.state = TaskState.COMPLETED
finally:
self._tasks.pop(task_status.task_id)
def kill_task(self, task_status: TaskStatus) -> None:
"""Kill the thread
task_status: The task status object.
"""
thread = self._tasks[task_status.task_id][1]
exception_cls = TaskKilledError
ident = ctypes.c_long(thread.ident)
exc = ctypes.py_object(exception_cls)
try:
res = set_async_exc(ident, exc)
if res == 0:
raise ValueError("Invalid thread ID")
elif res > 1:
set_async_exc(ident, None)
logger.warning(f"Exception raise while kille Thread {ident}; return value: {res}")
except Exception as e: # pylint: disable=broad-except
logger.warning(f"Exception raised while killing thread {ident}: {e}")
def shutdown(self):
"""Shutdown all tasks of task handler"""
for info in self._tasks.values():
self.kill_task(info[0])
self._tasks.clear()
class FileHandler:
"""Utility class for file operations."""
def get_file_path(
self, scan_status_msg: ScanStatusMessage, name: str, create_dir: bool = True
) -> str:
"""Get the file path.
Args:
scan_info_msg: The scan info message.
name: The name of the file.
create_dir: Whether to create the directory.
"""
return get_full_file_path(scan_status_msg=scan_status_msg, name=name, create_dir=create_dir)

View File

@ -6,200 +6,215 @@ import pytest
from ophyd import DeviceStatus, Staged
from ophyd.utils.errors import RedundantStaging
from ophyd_devices.interfaces.base_classes.bec_device_base import BECDeviceBase, CustomPrepare
from ophyd_devices.utils.bec_scaninfo_mixin import BecScaninfoMixin
from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase
from ophyd_devices.utils.errors import DeviceStopError, DeviceTimeoutError
@pytest.fixture
def detector_base():
yield BECDeviceBase(name="test_detector")
yield PSIDeviceBase(name="test_detector")
def test_detector_base_init(detector_base):
assert detector_base.stopped is False
assert detector_base.name == "test_detector"
assert "base_path" in detector_base.filewriter.service_config
assert isinstance(detector_base.scaninfo, BecScaninfoMixin)
assert issubclass(detector_base.custom_prepare_cls, CustomPrepare)
assert detector_base.staged == Staged.no
assert detector_base.destroyed == False
def test_stage(detector_base):
detector_base._staged = Staged.yes
with pytest.raises(RedundantStaging):
detector_base.stage()
assert detector_base._staged == Staged.no
assert detector_base.stopped is False
detector_base._staged = Staged.no
with (
mock.patch.object(detector_base.custom_prepare, "on_stage") as mock_on_stage,
mock.patch.object(detector_base.scaninfo, "load_scan_metadata") as mock_load_metadata,
):
with mock.patch.object(detector_base, "on_stage") as mock_on_stage:
rtr = detector_base.stage()
assert isinstance(rtr, list)
mock_on_stage.assert_called_once()
mock_load_metadata.assert_called_once()
assert mock_on_stage.called is True
with pytest.raises(RedundantStaging):
detector_base.stage()
detector_base._staged = Staged.no
detector_base.stopped = True
detector_base.stage()
assert detector_base.stopped is False
assert mock_on_stage.call_count == 2
def test_pre_scan(detector_base):
with mock.patch.object(detector_base.custom_prepare, "on_pre_scan") as mock_on_pre_scan:
detector_base.pre_scan()
mock_on_pre_scan.assert_called_once()
# def test_stage(detector_base):
# detector_base._staged = Staged.yes
# with pytest.raises(RedundantStaging):
# detector_base.stage()
# assert detector_base.stopped is False
# detector_base._staged = Staged.no
# with (
# mock.patch.object(detector_base.custom_prepare, "on_stage") as mock_on_stage,
# mock.patch.object(detector_base.scaninfo, "load_scan_metadata") as mock_load_metadata,
# ):
# rtr = detector_base.stage()
# assert isinstance(rtr, list)
# mock_on_stage.assert_called_once()
# mock_load_metadata.assert_called_once()
# assert detector_base.stopped is False
def test_trigger(detector_base):
status = DeviceStatus(detector_base)
with mock.patch.object(
detector_base.custom_prepare, "on_trigger", side_effect=[None, status]
) as mock_on_trigger:
st = detector_base.trigger()
assert isinstance(st, DeviceStatus)
time.sleep(0.1)
assert st.done is True
st = detector_base.trigger()
assert st.done is False
assert id(st) == id(status)
# def test_pre_scan(detector_base):
# with mock.patch.object(detector_base.custom_prepare, "on_pre_scan") as mock_on_pre_scan:
# detector_base.pre_scan()
# mock_on_pre_scan.assert_called_once()
def test_unstage(detector_base):
detector_base.stopped = True
with (
mock.patch.object(detector_base.custom_prepare, "on_unstage") as mock_on_unstage,
mock.patch.object(detector_base, "check_scan_id") as mock_check_scan_id,
):
rtr = detector_base.unstage()
assert isinstance(rtr, list)
assert mock_check_scan_id.call_count == 1
assert mock_on_unstage.call_count == 1
detector_base.stopped = False
rtr = detector_base.unstage()
assert isinstance(rtr, list)
assert mock_check_scan_id.call_count == 2
assert mock_on_unstage.call_count == 2
# def test_trigger(detector_base):
# status = DeviceStatus(detector_base)
# with mock.patch.object(
# detector_base.custom_prepare, "on_trigger", side_effect=[None, status]
# ) as mock_on_trigger:
# st = detector_base.trigger()
# assert isinstance(st, DeviceStatus)
# time.sleep(0.1)
# assert st.done is True
# st = detector_base.trigger()
# assert st.done is False
# assert id(st) == id(status)
def test_complete(detector_base):
status = DeviceStatus(detector_base)
with mock.patch.object(
detector_base.custom_prepare, "on_complete", side_effect=[None, status]
) as mock_on_complete:
st = detector_base.complete()
assert isinstance(st, DeviceStatus)
time.sleep(0.1)
assert st.done is True
st = detector_base.complete()
assert st.done is False
assert id(st) == id(status)
# def test_unstage(detector_base):
# detector_base.stopped = True
# with (
# mock.patch.object(detector_base.custom_prepare, "on_unstage") as mock_on_unstage,
# mock.patch.object(detector_base, "check_scan_id") as mock_check_scan_id,
# ):
# rtr = detector_base.unstage()
# assert isinstance(rtr, list)
# assert mock_check_scan_id.call_count == 1
# assert mock_on_unstage.call_count == 1
# detector_base.stopped = False
# rtr = detector_base.unstage()
# assert isinstance(rtr, list)
# assert mock_check_scan_id.call_count == 2
# assert mock_on_unstage.call_count == 2
def test_stop(detector_base):
with mock.patch.object(detector_base.custom_prepare, "on_stop") as mock_on_stop:
detector_base.stop()
mock_on_stop.assert_called_once()
assert detector_base.stopped is True
# def test_complete(detector_base):
# status = DeviceStatus(detector_base)
# with mock.patch.object(
# detector_base.custom_prepare, "on_complete", side_effect=[None, status]
# ) as mock_on_complete:
# st = detector_base.complete()
# assert isinstance(st, DeviceStatus)
# time.sleep(0.1)
# assert st.done is True
# st = detector_base.complete()
# assert st.done is False
# assert id(st) == id(status)
def test_check_scan_id(detector_base):
detector_base.scaninfo.scan_id = "abcde"
detector_base.stopped = False
detector_base.check_scan_id()
assert detector_base.stopped is True
detector_base.stopped = False
detector_base.check_scan_id()
assert detector_base.stopped is False
# def test_stop(detector_base):
# with mock.patch.object(detector_base.custom_prepare, "on_stop") as mock_on_stop:
# detector_base.stop()
# mock_on_stop.assert_called_once()
# assert detector_base.stopped is True
def test_wait_for_signal(detector_base):
my_value = False
def my_callback():
return my_value
detector_base
status = detector_base.custom_prepare.wait_with_status(
[(my_callback, True)],
check_stopped=True,
timeout=5,
interval=0.01,
exception_on_timeout=None,
)
time.sleep(0.1)
assert status.done is False
# Check first that it is stopped when detector_base.stop() is called
detector_base.stop()
# some delay to allow the stop to take effect
time.sleep(0.15)
assert status.done is True
assert status.exception().args == DeviceStopError(f"{detector_base.name} was stopped").args
detector_base.stopped = False
status = detector_base.custom_prepare.wait_with_status(
[(my_callback, True)],
check_stopped=True,
timeout=5,
interval=0.01,
exception_on_timeout=None,
)
# Check that thread resolves when expected value is set
my_value = True
# some delay to allow the stop to take effect
time.sleep(0.15)
assert status.done is True
assert status.success is True
assert status.exception() is None
detector_base.stopped = False
# Check that wait for status runs into timeout with expectd exception
my_value = "random_value"
exception = TimeoutError("Timeout")
status = detector_base.custom_prepare.wait_with_status(
[(my_callback, True)],
check_stopped=True,
timeout=0.01,
interval=0.01,
exception_on_timeout=exception,
)
time.sleep(0.2)
assert status.done is True
assert id(status.exception()) == id(exception)
assert status.success is False
# def test_check_scan_id(detector_base):
# detector_base.scaninfo.scan_id = "abcde"
# detector_base.stopped = False
# detector_base.check_scan_id()
# assert detector_base.stopped is True
# detector_base.stopped = False
# detector_base.check_scan_id()
# assert detector_base.stopped is False
def test_wait_for_signal_returns_exception(detector_base):
my_value = False
# def test_wait_for_signal(detector_base):
# my_value = False
def my_callback():
return my_value
# def my_callback():
# return my_value
# Check that wait for status runs into timeout with expectd exception
# detector_base
# status = detector_base.custom_prepare.wait_with_status(
# [(my_callback, True)],
# check_stopped=True,
# timeout=5,
# interval=0.01,
# exception_on_timeout=None,
# )
# time.sleep(0.1)
# assert status.done is False
# # Check first that it is stopped when detector_base.stop() is called
# detector_base.stop()
# # some delay to allow the stop to take effect
# time.sleep(0.15)
# assert status.done is True
# assert status.exception().args == DeviceStopError(f"{detector_base.name} was stopped").args
# detector_base.stopped = False
# status = detector_base.custom_prepare.wait_with_status(
# [(my_callback, True)],
# check_stopped=True,
# timeout=5,
# interval=0.01,
# exception_on_timeout=None,
# )
# # Check that thread resolves when expected value is set
# my_value = True
# # some delay to allow the stop to take effect
# time.sleep(0.15)
# assert status.done is True
# assert status.success is True
# assert status.exception() is None
exception = TimeoutError("Timeout")
status = detector_base.custom_prepare.wait_with_status(
[(my_callback, True)],
check_stopped=True,
timeout=0.01,
interval=0.01,
exception_on_timeout=exception,
)
time.sleep(0.2)
assert status.done is True
assert id(status.exception()) == id(exception)
assert status.success is False
# detector_base.stopped = False
# # Check that wait for status runs into timeout with expectd exception
# my_value = "random_value"
# exception = TimeoutError("Timeout")
# status = detector_base.custom_prepare.wait_with_status(
# [(my_callback, True)],
# check_stopped=True,
# timeout=0.01,
# interval=0.01,
# exception_on_timeout=exception,
# )
# time.sleep(0.2)
# assert status.done is True
# assert id(status.exception()) == id(exception)
# assert status.success is False
detector_base.stopped = False
# Check that standard exception is thrown
status = detector_base.custom_prepare.wait_with_status(
[(my_callback, True)],
check_stopped=True,
timeout=0.01,
interval=0.01,
exception_on_timeout=None,
)
time.sleep(0.2)
assert status.done is True
assert (
status.exception().args
== DeviceTimeoutError(
f"Timeout error for {detector_base.name} while waiting for signals {[(my_callback, True)]}"
).args
)
assert status.success is False
# def test_wait_for_signal_returns_exception(detector_base):
# my_value = False
# def my_callback():
# return my_value
# # Check that wait for status runs into timeout with expectd exception
# exception = TimeoutError("Timeout")
# status = detector_base.custom_prepare.wait_with_status(
# [(my_callback, True)],
# check_stopped=True,
# timeout=0.01,
# interval=0.01,
# exception_on_timeout=exception,
# )
# time.sleep(0.2)
# assert status.done is True
# assert id(status.exception()) == id(exception)
# assert status.success is False
# detector_base.stopped = False
# # Check that standard exception is thrown
# status = detector_base.custom_prepare.wait_with_status(
# [(my_callback, True)],
# check_stopped=True,
# timeout=0.01,
# interval=0.01,
# exception_on_timeout=None,
# )
# time.sleep(0.2)
# assert status.done is True
# assert (
# status.exception().args
# == DeviceTimeoutError(
# f"Timeout error for {detector_base.name} while waiting for signals {[(my_callback, True)]}"
# ).args
# )
# assert status.success is False

View File

@ -16,7 +16,6 @@ from ophyd import Device, Signal
from ophyd.status import wait as status_wait
from ophyd_devices.interfaces.protocols.bec_protocols import (
BECBaseProtocol,
BECDeviceProtocol,
BECFlyerProtocol,
BECPositionerProtocol,
@ -30,6 +29,7 @@ from ophyd_devices.sim.sim_positioner import SimLinearTrajectoryPositioner, SimP
from ophyd_devices.sim.sim_signals import ReadOnlySignal
from ophyd_devices.sim.sim_utils import H5Writer, LinearTrajectory
from ophyd_devices.sim.sim_waveform import SimWaveform
from ophyd_devices.tests.utils import get_mock_scan_info
from ophyd_devices.utils.bec_device_base import BECDevice, BECDeviceBase
@ -377,7 +377,6 @@ def test_h5proxy(h5proxy_fixture, camera):
)
camera._registered_proxies.update({h5proxy.name: camera.image.name})
camera.sim.params = {"noise": "none", "noise_multiplier": 0}
camera.scaninfo.sim_mode = True
# pylint: disable=no-member
camera.image_shape.set(data.shape[1:])
camera.stage()
@ -451,15 +450,15 @@ def test_cam_stage_h5writer(camera):
mock.patch.object(camera, "h5_writer") as mock_h5_writer,
mock.patch.object(camera, "_run_subs") as mock_run_subs,
):
camera.scaninfo.num_points = 10
camera.scaninfo.frames_per_trigger = 1
camera.scaninfo.exp_time = 1
camera.scan_info.msg.num_points = 10
camera.scan_info.msg.scan_parameters["frames_per_trigger"] = 1
camera.scan_info.msg.scan_parameters["exp_time"] = 1
camera.stage()
assert mock_h5_writer.on_stage.call_count == 0
camera.unstage()
camera.write_to_disk.put(True)
camera.stage()
calls = [mock.call(file_path="", h5_entry="/entry/data/data")]
calls = [mock.call(file_path="./data/test_file_camera.h5", h5_entry="/entry/data/data")]
assert mock_h5_writer.on_stage.mock_calls == calls
# mock_h5_writer.prepare
@ -529,17 +528,17 @@ def test_async_monitor_stage(async_monitor):
def test_async_monitor_prep_random_interval(async_monitor):
"""Test the stage method of SimMonitorAsync."""
async_monitor.custom_prepare.prep_random_interval()
assert async_monitor.custom_prepare._counter == 0
async_monitor.prep_random_interval()
assert async_monitor._counter == 0
assert async_monitor.current_trigger.get() == 0
assert 0 < async_monitor.custom_prepare._random_send_interval < 10
assert 0 < async_monitor._random_send_interval < 10
def test_async_monitor_complete(async_monitor):
"""Test the on_complete method of SimMonitorAsync."""
with (
mock.patch.object(async_monitor.custom_prepare, "_send_data_to_bec") as mock_send,
mock.patch.object(async_monitor.custom_prepare, "prep_random_interval") as mock_prep,
mock.patch.object(async_monitor, "_send_data_to_bec") as mock_send,
mock.patch.object(async_monitor, "prep_random_interval") as mock_prep,
):
status = async_monitor.complete()
status_wait(status)
@ -556,11 +555,11 @@ def test_async_monitor_complete(async_monitor):
def test_async_mon_on_trigger(async_monitor):
"""Test the on_trigger method of SimMonitorAsync."""
with (mock.patch.object(async_monitor.custom_prepare, "_send_data_to_bec") as mock_send,):
async_monitor.custom_prepare.on_stage()
upper_limit = async_monitor.custom_prepare._random_send_interval
with (mock.patch.object(async_monitor, "_send_data_to_bec") as mock_send,):
async_monitor.on_stage()
upper_limit = async_monitor._random_send_interval
for ii in range(1, upper_limit + 1):
status = async_monitor.custom_prepare.on_trigger()
status = async_monitor.on_trigger()
status_wait(status)
assert async_monitor.current_trigger.get() == ii
assert mock_send.call_count == 1
@ -568,10 +567,10 @@ def test_async_mon_on_trigger(async_monitor):
def test_async_mon_send_data_to_bec(async_monitor):
"""Test the _send_data_to_bec method of SimMonitorAsync."""
async_monitor.scaninfo.scan_msg = SimpleNamespace(metadata={})
async_monitor.scan_info = get_mock_scan_info()
async_monitor.data_buffer.update({"value": [0, 5], "timestamp": [0, 0]})
with mock.patch.object(async_monitor.connector, "xadd") as mock_xadd:
async_monitor.custom_prepare._send_data_to_bec()
async_monitor._send_data_to_bec()
dev_msg = messages.DeviceMessage(
signals={async_monitor.readback.name: async_monitor.data_buffer},
metadata={"async_update": async_monitor.async_update.get()},
@ -580,10 +579,10 @@ def test_async_mon_send_data_to_bec(async_monitor):
call = [
mock.call(
MessageEndpoints.device_async_readback(
scan_id=async_monitor.scaninfo.scan_id, device=async_monitor.name
scan_id=async_monitor.scan_info.msg.scan_id, device=async_monitor.name
),
{"data": dev_msg},
expire=async_monitor.custom_prepare._stream_ttl,
expire=async_monitor._stream_ttl,
)
]
assert mock_xadd.mock_calls == call
@ -618,8 +617,8 @@ def test_waveform(waveform):
# Now also test the async readback
mock_connector = waveform.connector = mock.MagicMock()
mock_run_subs = waveform._run_subs = mock.MagicMock()
waveform.scaninfo.scan_msg = SimpleNamespace(metadata={})
waveform.scaninfo.scan_id = "test"
waveform.scan_info = get_mock_scan_info()
waveform.scan_info.msg.scan_id = "test"
status = waveform.trigger()
timer = 0
while not status.done: