diff --git a/ophyd_devices/epics/devices/__init__.py b/ophyd_devices/epics/devices/__init__.py index 285483a..c2b5cf1 100644 --- a/ophyd_devices/epics/devices/__init__.py +++ b/ophyd_devices/epics/devices/__init__.py @@ -25,7 +25,7 @@ from ophyd.quadem import QuadEM # cSAXS from .epics_motor_ex import EpicsMotorEx from .mcs_csaxs import McsCsaxs -from .eiger9m_csaxs import Eiger9mCsaxs -from .pilatus_csaxs import PilatusCsaxs +from .eiger9m_csaxs import Eiger9McSAXS +from .pilatus_csaxs import PilatuscSAXS from .falcon_csaxs import FalconCsaxs from .DelayGeneratorDG645 import DelayGeneratorDG645 diff --git a/ophyd_devices/epics/devices/bec_scaninfo_mixin.py b/ophyd_devices/epics/devices/bec_scaninfo_mixin.py index 5adf6ef..2f39ad7 100644 --- a/ophyd_devices/epics/devices/bec_scaninfo_mixin.py +++ b/ophyd_devices/epics/devices/bec_scaninfo_mixin.py @@ -6,31 +6,88 @@ from bec_lib.core import bec_logger 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 = "mockqueueID111", + scan_number: int = 1, + exp_time: float = 12e-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: + info_msg = { + "RID": self.mockrid, + "queueID": 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: - def __init__(self, device_manager: DeviceManagerBase = None, sim_mode=False) -> None: + """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, sim_mode: bool = False, bec_info_msg=None + ) -> None: self.device_manager = device_manager self.sim_mode = sim_mode self.scan_msg = None self.scanID = None - self.bec_info_msg = { - "RID": "mockrid", - "queueID": "mockqueuid", - "scan_number": 1, - "exp_time": 12e-3, - "num_points": 500, - "readout_time": 3e-3, - "scan_type": "fly", - "num_lines": 1, - "frames_per_trigger": 1, - } + 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 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) -> BECMessage.ScanStatusMessage: + """Get current scan message + + Returns: + BECMessage.ScanStatusMessage: BECMessage.ScanStatusMessage object + """ if not self.sim_mode: # TODO what if no scan info is there yet! msg = self.device_manager.producer.get(MessageEndpoints.scan_status()) @@ -43,11 +100,16 @@ class BecScaninfoMixin: ) def get_username(self) -> str: + """Get username""" if not self.sim_mode: return self.device_manager.producer.get(MessageEndpoints.account()).decode() return os.getlogin() 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() logger.info(f"{self.scan_msg}") try: diff --git a/ophyd_devices/epics/devices/eiger9m_csaxs.py b/ophyd_devices/epics/devices/eiger9m_csaxs.py index 3037e32..812486d 100644 --- a/ophyd_devices/epics/devices/eiger9m_csaxs.py +++ b/ophyd_devices/epics/devices/eiger9m_csaxs.py @@ -1,6 +1,6 @@ import enum -import threading import time +import threading from bec_lib.core.devicemanager import DeviceStatus import numpy as np import os @@ -16,12 +16,15 @@ from std_daq_client import StdDaqClient from bec_lib.core import BECMessage, MessageEndpoints, threadlocked from bec_lib.core.file_utils import FileWriterMixin from bec_lib.core import bec_logger +from bec_lib.core.bec_service import SERVICE_CONFIG from ophyd_devices.epics.devices.bec_scaninfo_mixin import BecScaninfoMixin from ophyd_devices.utils import bec_utils logger = bec_logger.logger +EIGER9M_MIN_READOUT = 3e-3 + class EigerError(Exception): """Base class for exceptions in this module.""" @@ -29,13 +32,20 @@ class EigerError(Exception): pass -class EigerTimeoutError(Exception): +class EigerTimeoutError(EigerError): """Raised when the Eiger does not respond in time during unstage.""" pass -class SlsDetectorCam(Device): +class DeviceClassInitError(EigerError): + """Raised when initiation of the device class fails, + due to missing device manager or not started in sim_mode.""" + + pass + + +class SLSDetectorCam(Device): """SLS Detector Camera - Eiger 9M Base class to map EPICS PVs to ophyd signals. @@ -52,7 +62,7 @@ class SlsDetectorCam(Device): detector_state = ADCpt(EpicsSignalRO, "DetectorState_RBV") -class TriggerSource(int, enum.Enum): +class TriggerSource(enum.IntEnum): """Trigger signals for Eiger9M detector""" AUTO = 0 @@ -61,7 +71,7 @@ class TriggerSource(int, enum.Enum): BURST_TRIGGER = 3 -class DetectorState(int, enum.Enum): +class DetectorState(enum.IntEnum): """Detector states for Eiger9M detector""" IDLE = 0 @@ -77,7 +87,7 @@ class DetectorState(int, enum.Enum): ABORTED = 10 -class Eiger9mCsaxs(DetectorBase): +class Eiger9McSAXS(DetectorBase): """Eiger 9M detector for CSAXS Parent class: DetectorBase @@ -94,7 +104,7 @@ class Eiger9mCsaxs(DetectorBase): "describe", ] - cam = ADCpt(SlsDetectorCam, "cam1:") + cam = ADCpt(SLSDetectorCam, "cam1:") def __init__( self, @@ -131,34 +141,50 @@ class Eiger9mCsaxs(DetectorBase): **kwargs, ) if device_manager is None and not sim_mode: - raise EigerError("Add DeviceManager to initialization or init with sim_mode=True") - - # Not sure if this is needed, comment it for now! - # self._lock = threading.RLock() + raise DeviceClassInitError( + f"No device manager for device: {name}, and not started sim_mode: {sim_mode}. Add DeviceManager to initialization or init with sim_mode=True" + ) + self.sim_mode = sim_mode + # TODO check if threadlock is needed for unstage + self._lock = threading.RLock() self._stopped = False self.name = name - self.wait_for_connection() - # Spin up connections for simulation or BEC mode - # TODO check if sim_mode still works. Is it needed? I believe filewriting might be handled properly + self.service_cfg = None + self.std_client = None + self.scaninfo = None + self.filewriter = None + self.readout_time_min = EIGER9M_MIN_READOUT + self.std_rest_server_url = ( + kwargs["file_writer_url"] if "file_writer_url" in kwargs else "http://xbl-daq-29:5000" + ) + self.wait_for_connection(all_signals=True) if not sim_mode: - from bec_lib.core.bec_service import SERVICE_CONFIG - + self._update_service_config() self.device_manager = device_manager - self._producer = self.device_manager.producer - self.service_cfg = SERVICE_CONFIG.config["service_config"]["file_writer"] else: - base_path = f"/sls/X12SA/data/{self.scaninfo.username}/Data10/" - self._producer = bec_utils.MockProducer() - self.device_manager = bec_utils.MockDeviceManager() - self.scaninfo = BecScaninfoMixin(device_manager, sim_mode) - self.scaninfo.load_scan_metadata() - self.service_cfg = {"base_path": base_path} - - self.scaninfo = BecScaninfoMixin(device_manager, sim_mode) - self.scaninfo.load_scan_metadata() - self.filewriter = FileWriterMixin(self.service_cfg) + self.device_manager = bec_utils.DMMock() + base_path = kwargs["basepath"] if "basepath" in kwargs else "~/Data10/" + self.service_cfg = {"base_path": os.path.expanduser(base_path)} + self._producer = self.device_manager.producer + self._update_scaninfo() + self._update_filewriter() self._init() + def _update_filewriter(self) -> None: + """Update filewriter with service config""" + self.filewriter = FileWriterMixin(self.service_cfg) + + def _update_scaninfo(self) -> None: + """Update scaninfo from BecScaninfoMixing + This depends on device manager and operation/sim_mode + """ + self.scaninfo = BecScaninfoMixin(self.device_manager, self.sim_mode) + self.scaninfo.load_scan_metadata() + + def _update_service_config(self) -> None: + """Update service config from BEC service config""" + self.service_cfg = SERVICE_CONFIG.config["service_config"]["file_writer"] + # TODO function for abstract class? def _init(self) -> None: """Initialize detector, filewriter and set default parameters""" @@ -166,12 +192,19 @@ class Eiger9mCsaxs(DetectorBase): self._init_detector() self._init_filewriter() - # TODO function for abstract class? def _default_parameter(self) -> None: - """Set default parameters for Eiger 9M - readout (float) : readout time in seconds + """Set default parameters for Pilatus300k detector + readout (float): readout time in seconds """ - self.reduce_readout = 1e-3 + self._update_readout_time() + + def _update_readout_time(self) -> None: + readout_time = ( + self.scaninfo.readout_time + if hasattr(self.scaninfo, "readout_time") + else self.readout_time_min + ) + self.readout_time = max(readout_time, self.readout_time_min) # TODO function for abstract class? def _init_detector(self) -> None: @@ -188,20 +221,19 @@ class Eiger9mCsaxs(DetectorBase): For the Eiger9M, the data backend is std_daq client. Setting up these parameters depends on the backend, and would need to change upon changes in the backend. """ - self.std_rest_server_url = "http://xbl-daq-29:5000" self.std_client = StdDaqClient(url_base=self.std_rest_server_url) self.std_client.stop_writer() timeout = 0 - # TODO changing e-account was not possible during beamtimes. - # self._update_std_cfg("writer_user_id", int(self.scaninfo.username.strip(" e"))) - # time.sleep(5) - # TODO is this the only state to wait for or should we wait for more from the std_daq client? + # TODO put back change of e-account! and check with Leo which status to wait for + eacc = self.scaninfo.username + self._update_std_cfg("writer_user_id", int(eacc.strip(" e"))) + time.sleep(5) while not self.std_client.get_status()["state"] == "READY": time.sleep(0.1) timeout = timeout + 0.1 logger.info("Waiting for std_daq init.") if timeout > 5: - if not self.std_client.get_status()["state"]: + if not self.std_client.get_status()["state"] == "READY": raise EigerError( f"Std client not in READY state, returns: {self.std_client.get_status()}" ) @@ -252,26 +284,32 @@ class Eiger9mCsaxs(DetectorBase): self._prep_file_writer() self._prep_det() state = False - self._publish_file_location(done=state, successful=state) + self._publish_file_location(done=state) self._arm_acquisition() # TODO Fix should take place in EPICS or directly on the hardware! # We observed that the detector missed triggers in the beginning in case BEC was to fast. Adding 50ms delay solved this time.sleep(0.05) return super().stage() + def _filepath_exists(self, filepath: str) -> None: + timer = 0 + while not os.path.exists(os.path.dirname(self.filepath)): + timer = time + 0.1 + time.sleep(0.1) + if timer > 3: + raise EigerError(f"Timeout of 3s reached for filepath {self.filepath}") + # TODO function for abstract class? def _prep_file_writer(self) -> None: """Prepare file writer for scan self.filewriter is a FileWriterMixin object that hosts logic for compiling the filepath """ + timer = 0 self.filepath = self.filewriter.compile_full_filename( self.scaninfo.scan_number, f"{self.name}.h5", 1000, 5, True ) - # TODO needed, should be checked from the filerwriter mixin right? - while not os.path.exists(os.path.dirname(self.filepath)): - time.sleep(0.1) - + self._filepath_exists(self.filepath) self._stop_file_writer() logger.info(f" std_daq output filepath {self.filepath}") # TODO Discuss with Leo if this is needed, or how to start the async writing best @@ -288,16 +326,22 @@ class Eiger9mCsaxs(DetectorBase): raise EigerError(f"Timeout of start_writer_async with {exc}") while True: + timer = timer + 0.01 det_ctrl = self.std_client.get_status()["acquisition"]["state"] if det_ctrl == "WAITING_IMAGES": break - time.sleep(0.005) + time.sleep(0.01) + if timer > 5: + self._close_file_writer() + raise EigerError( + f"Timeout of 5s reached for std_daq start_writer_async with std_daq client status {det_ctrl}" + ) # TODO function for abstract class? def _stop_file_writer(self) -> None: """Close file writer""" self.std_client.stop_writer() - # TODO can I wait for a status message here maybe? To ensure writer returned + # TODO can I wait for a status message here maybe? To ensure writer stopped and returned # TODO function for abstract class? def _prep_det(self) -> None: @@ -312,7 +356,8 @@ class Eiger9mCsaxs(DetectorBase): """Set correct detector threshold to 1/2 of current X-ray energy, allow 5% tolerance""" # threshold energy might be in eV or keV factor = 1 - if self.cam.threshold_energy._metadata["units"] == "eV": + unit = getattr(self.cam.threshold_energy, "units", None) + if unit != None and unit == "eV": factor = 1000 setpoint = int(self.mokev * factor) energy = self.cam.beam_energy.read()[self.cam.beam_energy.name]["value"] @@ -326,40 +371,60 @@ class Eiger9mCsaxs(DetectorBase): """Set acquisition parameters for the detector""" self.cam.num_images.put(int(self.scaninfo.num_points * self.scaninfo.frames_per_trigger)) self.cam.num_frames.put(1) + self._update_readout_time() # TODO function for abstract class? + call it for each scan?? def _set_trigger(self, trigger_source: TriggerSource) -> None: """Set trigger source for the detector. Check the TriggerSource enum for possible values + + Args: + trigger_source (TriggerSource): Trigger source for the detector + """ - value = int(trigger_source) + value = trigger_source self.cam.trigger_mode.put(value) - def _publish_file_location(self, done=False, successful=False) -> None: - """Publish the filepath to REDIS - First msg for file writer and the second one for other listeners (e.g. radial integ) + def _publish_file_location(self, done: bool = False, successful: bool = 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 + """ pipe = self._producer.pipeline() - msg = BECMessage.FileMessage(file_path=self.filepath, done=done, successful=successful) + if successful is None: + msg = BECMessage.FileMessage(file_path=self.filepath, done=done) + else: + msg = BECMessage.FileMessage(file_path=self.filepath, done=done, successful=successful) self._producer.set_and_publish( MessageEndpoints.public_file(self.scaninfo.scanID, self.name), msg.dumps(), pipe=pipe ) self._producer.set_and_publish( - MessageEndpoints.file_event(self.name), msg.dumps(), pip=pipe + MessageEndpoints.file_event(self.name), msg.dumps(), pipe=pipe ) pipe.execute() # TODO function for abstract class? def _arm_acquisition(self) -> None: """Arm Eiger detector for acquisition""" + timer = 0 self.cam.acquire.put(1) while True: det_ctrl = self.cam.detector_state.read()[self.cam.detector_state.name]["value"] - if det_ctrl == int(DetectorState.RUNNING): + if det_ctrl == DetectorState.RUNNING: break if self._stopped == True: break - time.sleep(0.005) + time.sleep(0.01) + timer += 0.01 + if timer > 5: + self.stop() + raise EigerTimeoutError("Failed to arm the acquisition. IOC did not update.") # TODO function for abstract class? def trigger(self) -> DeviceStatus: @@ -419,15 +484,13 @@ class Eiger9mCsaxs(DetectorBase): # Check status with timeout, break out if _stopped=True while True: det_ctrl = self.cam.acquire.read()[self.cam.acquire.name]["value"] - std_ctrl = self.std_client.get_status()["acquisition"]["state"] status = self.std_client.get_status() + std_ctrl = status["acquisition"]["state"] received_frames = status["acquisition"]["stats"]["n_write_completed"] total_frames = int(self.scaninfo.num_points * self.scaninfo.frames_per_trigger) if det_ctrl == 0 and std_ctrl == "FINISHED" and total_frames == received_frames: break if self._stopped == True: - self._stop_det() - self._stop_file_writer() break time.sleep(sleep_time) timer += sleep_time @@ -452,7 +515,7 @@ class Eiger9mCsaxs(DetectorBase): # Check status while True: det_ctrl = self.cam.detector_state.read()[self.cam.detector_state.name]["value"] - if det_ctrl == int(DetectorState.IDLE): + if det_ctrl == DetectorState.IDLE: break if self._stopped == True: break @@ -474,4 +537,4 @@ class Eiger9mCsaxs(DetectorBase): if __name__ == "__main__": - eiger = Eiger9mCsaxs(name="eiger", prefix="X12SA-ES-EIGER9M:", sim_mode=True) + eiger = Eiger9McSAXS(name="eiger", prefix="X12SA-ES-EIGER9M:", sim_mode=True) diff --git a/ophyd_devices/epics/devices/pilatus_csaxs.py b/ophyd_devices/epics/devices/pilatus_csaxs.py index 1982d86..228e1d9 100644 --- a/ophyd_devices/epics/devices/pilatus_csaxs.py +++ b/ophyd_devices/epics/devices/pilatus_csaxs.py @@ -14,6 +14,7 @@ from ophyd import ADComponent as ADCpt from bec_lib.core import BECMessage, MessageEndpoints from bec_lib.core.file_utils import FileWriterMixin +from bec_lib.core.bec_service import SERVICE_CONFIG from bec_lib.core import bec_logger from ophyd_devices.utils import bec_utils as bec_utils @@ -21,6 +22,8 @@ from ophyd_devices.epics.devices.bec_scaninfo_mixin import BecScaninfoMixin logger = bec_logger.logger +PILATUS_MIN_READOUT = 3e-3 + class PilatusError(Exception): """Base class for exceptions in this module.""" @@ -28,13 +31,20 @@ class PilatusError(Exception): pass -class PilatusTimeoutError(Exception): +class PilatusTimeoutError(PilatusError): """Raised when the Pilatus does not respond in time during unstage.""" pass -class TriggerSource(int, enum.Enum): +class DeviceClassInitError(PilatusError): + """Raised when initiation of the device class fails, + due to missing device manager or not started in sim_mode.""" + + pass + + +class TriggerSource(enum.IntEnum): INTERNAL = 0 EXT_ENABLE = 1 EXT_TRIGGER = 2 @@ -42,14 +52,14 @@ class TriggerSource(int, enum.Enum): ALGINMENT = 4 -class SlsDetectorCam(Device): +class SLSDetectorCam(Device): """SLS Detector Camera - Pilatus Base class to map EPICS PVs to ophyd signals. """ num_images = ADCpt(EpicsSignalWithRBV, "NumImages") - num_exposures = ADCpt(EpicsSignalWithRBV, "NumExposures") + num_frames = ADCpt(EpicsSignalWithRBV, "NumExposures") delay_time = ADCpt(EpicsSignalWithRBV, "NumExposures") trigger_mode = ADCpt(EpicsSignalWithRBV, "TriggerMode") acquire = ADCpt(EpicsSignal, "Acquire") @@ -70,7 +80,7 @@ class SlsDetectorCam(Device): gap_fill = ADCpt(EpicsSignalWithRBV, "GapFill") -class PilatusCsaxs(DetectorBase): +class PilatuscSAXS(DetectorBase): """Pilatus_2 300k detector for CSAXS Parent class: DetectorBase @@ -87,7 +97,7 @@ class PilatusCsaxs(DetectorBase): "describe", ] - cam = ADCpt(SlsDetectorCam, "cam1:") + cam = ADCpt(SLSDetectorCam, "cam1:") def __init__( self, @@ -124,30 +134,46 @@ class PilatusCsaxs(DetectorBase): **kwargs, ) if device_manager is None and not sim_mode: - raise PilatusError("Add DeviceManager to initialization or init with sim_mode=True") - + raise DeviceClassInitError( + f"No device manager for device: {name}, and not started sim_mode: {sim_mode}. Add DeviceManager to initialization or init with sim_mode=True" + ) + self.sim_mode = sim_mode + self._stopped = False self.name = name - self.wait_for_connection() - # Spin up connections for simulation or BEC mode + self.service_cfg = None + self.std_client = None + self.scaninfo = None + self.filewriter = None + self.readout_time_min = PILATUS_MIN_READOUT + # TODO move url from data backend up here? + self.wait_for_connection(all_signals=True) if not sim_mode: - from bec_lib.core.bec_service import SERVICE_CONFIG - + self._update_service_config() self.device_manager = device_manager - self._producer = self.device_manager.producer - self.service_cfg = SERVICE_CONFIG.config["service_config"]["file_writer"] else: - base_path = f"/sls/X12SA/data/{self.scaninfo.username}/Data10/" - self._producer = bec_utils.MockProducer() - self.device_manager = bec_utils.MockDeviceManager() - self.scaninfo = BecScaninfoMixin(device_manager, sim_mode) - self.scaninfo.load_scan_metadata() - self.service_cfg = {"base_path": base_path} - - self.scaninfo = BecScaninfoMixin(device_manager, sim_mode) - self.scaninfo.load_scan_metadata() - self.filewriter = FileWriterMixin(self.service_cfg) + self.device_manager = bec_utils.DMMock() + base_path = kwargs["basepath"] if "basepath" in kwargs else "~/Data10/" + self.service_cfg = {"base_path": os.path.expanduser(base_path)} + self._producer = self.device_manager.producer + self._update_scaninfo() + self._update_filewriter() self._init() + def _update_filewriter(self) -> None: + """Update filewriter with service config""" + self.filewriter = FileWriterMixin(self.service_cfg) + + def _update_scaninfo(self) -> None: + """Update scaninfo from BecScaninfoMixing + This depends on device manager and operation/sim_mode + """ + self.scaninfo = BecScaninfoMixin(self.device_manager, self.sim_mode) + self.scaninfo.load_scan_metadata() + + def _update_service_config(self) -> None: + """Update service config from BEC service config""" + self.service_cfg = SERVICE_CONFIG.config["service_config"]["file_writer"] + def _init(self) -> None: """Initialize detector, filewriter and set default parameters""" self._default_parameter() @@ -158,12 +184,21 @@ class PilatusCsaxs(DetectorBase): """Set default parameters for Pilatus300k detector readout (float): readout time in seconds """ - self.reduce_readout = 1e-3 + self._update_readout_time() + + def _update_readout_time(self) -> None: + readout_time = ( + self.scaninfo.readout_time + if hasattr(self.scaninfo, "readout_time") + else self.readout_time_min + ) + self.readout_time = max(readout_time, self.readout_time_min) def _init_detector(self) -> None: """Initialize the detector""" # TODO add check if detector is running - pass + self._stop_det() + self._set_trigger(TriggerSource.EXT_ENABLE) def _init_filewriter(self) -> None: """Initialize the file writer""" @@ -174,26 +209,28 @@ class PilatusCsaxs(DetectorBase): # TODO slow reaction, seemed to have timeout. self._set_det_threshold() self._set_acquisition_params() + self._set_trigger(TriggerSource.EXT_ENABLE) def _set_det_threshold(self) -> None: # threshold_energy PV exists on Eiger 9M? factor = 1 - if self.cam.threshold_energy._metadata["units"] == "eV": + unit = getattr(self.cam.threshold_energy, "units", None) + if unit != None and unit == "eV": factor = 1000 setpoint = int(self.mokev * factor) threshold = self.cam.threshold_energy.read()[self.cam.threshold_energy.name]["value"] if not np.isclose(setpoint / 2, threshold, rtol=0.05): - self.cam.threshold_energy.set(setpoint / 2) + self.cam.threshold_energy.put(setpoint / 2) def _set_acquisition_params(self) -> None: """set acquisition parameters on the detector""" # self.cam.acquire_time.set(self.exp_time) # self.cam.acquire_period.set(self.exp_time + self.readout) - self.cam.num_images.set(int(self.scaninfo.num_points * self.scaninfo.frames_per_trigger)) - self.cam.num_exposures.set(1) - self._set_trigger(TriggerSource.EXT_ENABLE) # EXT_TRIGGER) + self.cam.num_images.put(int(self.scaninfo.num_points * self.scaninfo.frames_per_trigger)) + self.cam.num_frames.put(1) + self._update_readout_time() - def _set_trigger(self, trigger_source: TriggerSource) -> None: + def _set_trigger(self, trigger_source: int) -> None: """Set trigger source for the detector, either directly to value or TriggerSource.* with INTERNAL = 0 EXT_ENABLE = 1 @@ -201,19 +238,22 @@ class PilatusCsaxs(DetectorBase): MULTI_TRIGGER = 3 ALGINMENT = 4 """ - value = int(trigger_source) - self.cam.trigger_mode.set(value) + value = trigger_source + self.cam.trigger_mode.put(value) + + def _create_directory(filepath: str) -> None: + """Create directory if it does not exist""" + os.makedirs(filepath, exist_ok=True) def _prep_file_writer(self) -> None: - """Prepare the file writer for pilatus_2 - a zmq service is running on xbl-daq-34 that is waiting - for a zmq message to start the writer for the pilatus_2 x12sa-pd-2 """ - # TODO worked reliable with time.sleep(2) - # self._close_file_writer() - # time.sleep(2) - # self._stop_file_writer() - # time.sleep(2) + Prepare the file writer for pilatus_2 + + A zmq service is running on xbl-daq-34 that is waiting + for a zmq message to start the writer for the pilatus_2 x12sa-pd-2 + + """ + # TODO explore required sleep time here self._close_file_writer() time.sleep(0.1) self._stop_file_writer() @@ -229,6 +269,7 @@ class PilatusCsaxs(DetectorBase): self.cam.file_format.put(0) # 0: TIFF self.cam.file_template.put("%s%s_%5.5d.cbf") + # TODO remove hardcoded filepath here # compile filename basepath = f"/sls/X12SA/data/{self.scaninfo.username}/Data10/pilatus_2/" self.filepath = os.path.join( @@ -236,8 +277,11 @@ class PilatusCsaxs(DetectorBase): self.filewriter.get_scan_directory(self.scaninfo.scan_number, 1000, 5), ) # Make directory if needed - os.makedirs(self.filepath, exist_ok=True) + self._create_directory(self.filepath) + headers = {"Content-Type": "application/json", "Accept": "application/json"} + # start the stream on x12sa-pd-2 + url = "http://x12sa-pd-2:8080/stream/pilatus_2" data_msg = { "source": [ { @@ -247,21 +291,14 @@ class PilatusCsaxs(DetectorBase): } ] } - - logger.info(data_msg) - headers = {"Content-Type": "application/json", "Accept": "application/json"} - - res = requests.put( - url="http://x12sa-pd-2:8080/stream/pilatus_2", - data=json.dumps(data_msg), - headers=headers, - ) + res = self._send_requests_put(url=url, data=data_msg, headers=headers) logger.info(f"{res.status_code} - {res.text} - {res.content}") if not res.ok: res.raise_for_status() - # prepare writer + # start the data receiver on xbl-daq-34 + url = "http://xbl-daq-34:8091/pilatus_2/run" data_msg = [ "zmqWriter", self.scaninfo.username, @@ -274,14 +311,7 @@ class PilatusCsaxs(DetectorBase): "user": self.scaninfo.username, }, ] - - res = requests.put( - url="http://xbl-daq-34:8091/pilatus_2/run", - data=json.dumps(data_msg), - headers=headers, - ) - # subprocess.run("curl -i -s -X PUT http://xbl-daq-34:8091/pilatus_2/run -d '[\"zmqWriter\",\"e20636\",{\"addr\":\"tcp://x12sa-pd-2:8888\",\"dst\":[\"file\"],\"numFrm\":10,\"timeout\":2000,\"ifType\":\"PULL\",\"user\":\"e20636\"}]'", shell=True) - + res = self._send_requests_put(url=url, data=data_msg, headers=headers) logger.info(f"{res.status_code} - {res.text} - {res.content}") if not res.ok: @@ -289,8 +319,10 @@ class PilatusCsaxs(DetectorBase): # Wait for server to become available again time.sleep(0.1) + logger.info(f"{res.status_code} -{res.text} - {res.content}") - headers = {"Content-Type": "application/json", "Accept": "application/json"} + # Sent requests.put to xbl-daq-34 to wait for data + url = "http://xbl-daq-34:8091/pilatus_2/wait" data_msg = [ "zmqWriter", self.scaninfo.username, @@ -299,15 +331,8 @@ class PilatusCsaxs(DetectorBase): "timeout": 2000, }, ] - logger.info(f"{res.status_code} -{res.text} - {res.content}") - try: - res = requests.put( - url="http://xbl-daq-34:8091/pilatus_2/wait", - data=json.dumps(data_msg), - # headers=headers, - ) - + res = self._send_requests_put(url=url, data=data_msg, headers=headers) logger.info(f"{res}") if not res.ok: @@ -315,25 +340,56 @@ class PilatusCsaxs(DetectorBase): except Exception as exc: logger.info(f"Pilatus2 wait threw Exception: {exc}") - def _close_file_writer(self) -> None: - """Close the file writer for pilatus_2 - a zmq service is running on xbl-daq-34 that is waiting - for a zmq message to stop the writer for the pilatus_2 x12sa-pd-2 + def _send_requests_put(self, url: str, data_msg: list = None, headers: dict = None) -> object: """ + Send a put request to the given url + + Args: + url (str): url to send the request to + data (dict): data to be sent with the request (optional) + headers (dict): headers to be sent with the request (optional) + + Returns: + status code of the request + """ + return requests.put(url=url, data=json.dumps(data_msg), headers=headers) + + def _send_requests_delete(self, url: str, headers: dict = None) -> object: + """ + Send a delete request to the given url + + Args: + url (str): url to send the request to + headers (dict): headers to be sent with the request (optional) + + Returns: + status code of the request + """ + return requests.delete(url=url, headers=headers) + + def _close_file_writer(self) -> None: + """ + Close the file writer for pilatus_2 + + Delete the data from x12sa-pd-2 + + """ + url = "http://x12sa-pd-2:8080/stream/pilatus_2" try: - res = requests.delete(url="http://x12sa-pd-2:8080/stream/pilatus_2") + res = self._send_requests_delete(url=url) if not res.ok: res.raise_for_status() except Exception as exc: - logger.info(f"Pilatus2 delete threw Exception: {exc}") + logger.info(f"Pilatus2 close threw Exception: {exc}") def _stop_file_writer(self) -> None: - res = requests.put( - url="http://xbl-daq-34:8091/pilatus_2/stop", - # data=json.dumps(data_msg), - # headers=headers, - ) + """ + Stop the file writer for pilatus_2 + Runs on xbl-daq-34 + """ + url = "http://xbl-daq-34:8091/pilatus_2/stop" + res = self._send_requests_put(url=url) if not res.ok: res.raise_for_status() @@ -362,28 +418,44 @@ class PilatusCsaxs(DetectorBase): self._prep_file_writer() self._prep_det() state = False - self._publish_file_location(done=state, successful=state) + self._publish_file_location(done=state) return super().stage() # TODO might be useful for base class def pre_scan(self) -> None: - """ " Pre_scan gets executed right before""" + """Pre_scan is an (optional) function that is executed by BEC just before the scan core + + For the pilatus detector, it is used to arm the detector for the acquisition, + because the detector times out after ˜7-8seconds without seeing a trigger. + """ self._arm_acquisition() def _arm_acquisition(self) -> None: - self.acquire() + self.cam.acquire.put(1) + # TODO check if sleep of 1s is needed, could be that less is enough + time.sleep(1) + + def _publish_file_location(self, done: bool = False, successful: bool = 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 - def _publish_file_location(self, done=False, successful=False) -> None: - """Publish the filepath to REDIS - First msg for file writer and the second one for other listeners (e.g. radial integ) """ pipe = self._producer.pipeline() - msg = BECMessage.FileMessage(file_path=self.filepath, done=done, successful=successful) + if successful is None: + msg = BECMessage.FileMessage(file_path=self.filepath, done=done) + else: + msg = BECMessage.FileMessage(file_path=self.filepath, done=done, successful=successful) self._producer.set_and_publish( MessageEndpoints.public_file(self.scaninfo.scanID, self.name), msg.dumps(), pipe=pipe ) self._producer.set_and_publish( - MessageEndpoints.file_event(self.name), msg.dumps(), pip=pipe + MessageEndpoints.file_event(self.name), msg.dumps(), pipe=pipe ) pipe.execute() @@ -441,44 +513,31 @@ class PilatusCsaxs(DetectorBase): - _stop_det - _stop_file_writer """ + timer = 0 + sleep_time = 0.1 + # TODO this is a workaround at the moment which relies on the status of the mcs device while True: if self.device_manager.devices.mcs.obj._staged != Staged.yes: break - time.sleep(0.1) - # TODO implement a waiting function or not - # time.sleep(2) - # timer = 0 - # while True: - # # rtr = self.cam.status_message_camserver.get() - # #if self.cam.acquire.get() == 0 and rtr == "Camserver returned OK": - # # if rtr == "Camserver returned OK": - # # break - # if self._stopped == True: - # break - # time.sleep(0.1) - # timer += 0.1 - # if timer > 5: - # self._close_file_writer() - # self._stop_file_writer() - # self._stopped == True - # # raise PilatusTimeoutError( - # # f"Pilatus timeout with detector state {self.cam.acquire.get()} and camserver return status: {rtr} " - # # ) + if self._stopped == True: + break + time.sleep(sleep_time) + timer = timer + sleep_time + if timer > 5: + self._stopped == True + self._stop_det() + self._stop_file_writer() + # TODO explore if sleep is needed + time.sleep(0.5) + self._close_file_writer() + raise PilatusTimeoutError(f"Timeout waiting for mcs device to unstage") self._stop_det() self._stop_file_writer() - # TODO explore if sleep is needed + # TODO explore if sleep time is needed time.sleep(0.5) self._close_file_writer() - def acquire(self) -> None: - """Start acquisition in software trigger mode, - or arm the detector in hardware of the detector - """ - self.cam.acquire.put(1) - # TODO check if sleep of 1s is needed, could be that less is enough - time.sleep(1) - def _stop_det(self) -> None: """Stop the detector""" self.cam.acquire.put(0) @@ -494,4 +553,4 @@ class PilatusCsaxs(DetectorBase): # Automatically connect to test environmenr if directly invoked if __name__ == "__main__": - pilatus_2 = PilatusCsaxs(name="pilatus_2", prefix="X12SA-ES-PILATUS300K:", sim_mode=True) + pilatus_2 = PilatuscSAXS(name="pilatus_2", prefix="X12SA-ES-PILATUS300K:", sim_mode=True) diff --git a/ophyd_devices/utils/bec_utils.py b/ophyd_devices/utils/bec_utils.py index 304a2ed..1ac07a8 100644 --- a/ophyd_devices/utils/bec_utils.py +++ b/ophyd_devices/utils/bec_utils.py @@ -1,6 +1,8 @@ import time from bec_lib.core import bec_logger +from bec_lib.core.devicemanager import DeviceContainer +from bec_lib.core.tests.utils import ProducerMock from ophyd import Signal, Kind @@ -11,33 +13,88 @@ logger = bec_logger.logger DEFAULT_EPICSSIGNAL_VALUE = object() -class MockProducer: - def set_and_publish(self, endpoint: str, msgdump: str): - logger.info(f"BECMessage to {endpoint} with msg dump {msgdump}") - - -class MockDeviceManager: - def __init__(self) -> None: - self.devices = devices() - - -class OphydObject: - def __init__(self) -> None: - self.name = "mock_mokev" - self.obj = mokev() - - -class devices: - def __init__(self): - self.mokev = OphydObject() - - -class mokev: - def __init__(self): - self.name = "mock_mokev" +# TODO maybe specify here that this DeviceMock is for usage in the DeviceServer +class DeviceMock: + def __init__(self, name: str, value: float = 0.0): + self.name = name + self.read_buffer = value + self._config = {"deviceConfig": {"limits": [-50, 50]}, "userParameter": None} + self._enabled_set = True + self._enabled = True def read(self): - return {self.name: {"value": 16.0, "timestamp": time.time()}} + return {self.name: {"value": self.read_buffer}} + + def readback(self): + return self.read_buffer + + @property + def enabled_set(self) -> bool: + return self._enabled_set + + @enabled_set.setter + def enabled_set(self, val: bool): + self._enabled_set = val + + @property + def enabled(self) -> bool: + return self._enabled + + @enabled.setter + def enabled(self, val: bool): + self._enabled = val + + @property + def user_parameter(self): + return self._config["userParameter"] + + @property + def obj(self): + return self + + +class DMMock: + """Mock for DeviceManager + + The mocked DeviceManager creates a device containert and a producer. + + """ + + def __init__(self): + self.devices = DeviceContainer() + self.producer = ProducerMock() + + def add_device(self, name: str, value: float = 0.0): + self.devices[name] = DeviceMock(name, value) + + +# class MockProducer: +# def set_and_publish(self, endpoint: str, msgdump: str): +# logger.info(f"BECMessage to {endpoint} with msg dump {msgdump}") + + +# class MockDeviceManager: +# def __init__(self) -> None: +# self.devices = devices() + + +# class OphydObject: +# def __init__(self) -> None: +# self.name = "mock_mokev" +# self.obj = mokev() + + +# class devices: +# def __init__(self): +# self.mokev = OphydObject() + + +# class mokev: +# def __init__(self): +# self.name = "mock_mokev" + +# def read(self): +# return {self.name: {"value": 16.0, "timestamp": time.time()}} class ConfigSignal(Signal): diff --git a/ophyd_devices/utils/re_test.py b/ophyd_devices/utils/re_test.py index 4194a35..1791e4b 100644 --- a/ophyd_devices/utils/re_test.py +++ b/ophyd_devices/utils/re_test.py @@ -1,17 +1,17 @@ -from bluesky import RunEngine -from bluesky.plans import grid_scan -from bluesky.callbacks.best_effort import BestEffortCallback -from bluesky.callbacks.mpl_plotting import LivePlot +# from bluesky import RunEngine +# from bluesky.plans import grid_scan +# from bluesky.callbacks.best_effort import BestEffortCallback +# from bluesky.callbacks.mpl_plotting import LivePlot -RE = RunEngine({}) +# RE = RunEngine({}) -from bluesky.callbacks.best_effort import BestEffortCallback +# from bluesky.callbacks.best_effort import BestEffortCallback -bec = BestEffortCallback() +# bec = BestEffortCallback() -# Send all metadata/data captured to the BestEffortCallback. -RE.subscribe(bec) -# RE.subscribe(dummy) +# # Send all metadata/data captured to the BestEffortCallback. +# RE.subscribe(bec) +# # RE.subscribe(dummy) -# RE(grid_scan(dets, motor1, -10, 10, 10, motor2, -10, 10, 10)) +# # RE(grid_scan(dets, motor1, -10, 10, 10, motor2, -10, 10, 10)) diff --git a/tests/test_eiger9m_csaxs.py b/tests/test_eiger9m_csaxs.py new file mode 100644 index 0000000..c98e202 --- /dev/null +++ b/tests/test_eiger9m_csaxs.py @@ -0,0 +1,505 @@ +import pytest +from unittest import mock + +import ophyd + +from bec_lib.core import BECMessage, MessageEndpoints +from ophyd_devices.epics.devices.eiger9m_csaxs import Eiger9McSAXS + +from tests.utils import DMMock, MockPV + + +def patch_dual_pvs(device): + for walk in device.walk_signals(): + if not hasattr(walk.item, "_read_pv"): + continue + if not hasattr(walk.item, "_write_pv"): + continue + if walk.item._read_pv.pvname.endswith("_RBV"): + walk.item._read_pv = walk.item._write_pv + + +@pytest.fixture(scope="function") +def mock_det(): + name = "eiger" + prefix = "X12SA-ES-EIGER9M:" + sim_mode = False + dm = DMMock() + with mock.patch.object(dm, "producer"): + with mock.patch( + "ophyd_devices.epics.devices.eiger9m_csaxs.FileWriterMixin" + ) as filemixin, mock.patch( + "ophyd_devices.epics.devices.eiger9m_csaxs.Eiger9McSAXS._update_service_config" + ) as mock_service_config: + with mock.patch.object(ophyd, "cl") as mock_cl: + mock_cl.get_pv = MockPV + with mock.patch.object(Eiger9McSAXS, "_init"): + det = Eiger9McSAXS( + name=name, prefix=prefix, device_manager=dm, sim_mode=sim_mode + ) + patch_dual_pvs(det) + yield det + + +def test_init(): + """Test the _init function:""" + name = "eiger" + prefix = "X12SA-ES-EIGER9M:" + sim_mode = False + dm = DMMock() + with mock.patch.object(dm, "producer"): + with mock.patch( + "ophyd_devices.epics.devices.eiger9m_csaxs.FileWriterMixin" + ) as filemixin, mock.patch( + "ophyd_devices.epics.devices.eiger9m_csaxs.Eiger9McSAXS._update_service_config" + ) as mock_service_config: + with mock.patch.object(ophyd, "cl") as mock_cl: + mock_cl.get_pv = MockPV + with mock.patch.object( + Eiger9McSAXS, "_default_parameter" + ) as mock_default, mock.patch.object( + Eiger9McSAXS, "_init_detector" + ) as mock_init_det, mock.patch.object( + Eiger9McSAXS, "_init_filewriter" + ) as mock_init_fw: + Eiger9McSAXS(name=name, prefix=prefix, device_manager=dm, sim_mode=sim_mode) + mock_default.assert_called_once() + mock_init_det.assert_called_once() + mock_init_fw.assert_called_once() + + +@pytest.mark.parametrize( + "trigger_source, detector_state, expected_exception", + [ + ( + 2, + 1, + True, + ), + ( + 2, + 0, + False, + ), + ], +) +def test_init_detector( + mock_det, + trigger_source, + detector_state, + expected_exception, +): + """Test the _init function: + + This includes testing the functions: + - _init_detector + - _stop_det + - _set_trigger + --> Testing the filewriter is done in test_init_filewriter + + Validation upon setting the correct PVs + + """ + mock_det.cam.detector_state._read_pv.mock_data = detector_state + if expected_exception: + with pytest.raises(Exception): + mock_det._init_detector() + else: + mock_det._init_detector() # call the method you want to test + assert mock_det.cam.acquire.get() == 0 + assert mock_det.cam.detector_state.get() == detector_state + assert mock_det.cam.trigger_mode.get() == trigger_source + + +@pytest.mark.parametrize( + "readout_time, expected_value", + [ + (1e-3, 3e-3), + (3e-3, 3e-3), + (5e-3, 5e-3), + (None, 3e-3), + ], +) +def test_update_readout_time(mock_det, readout_time, expected_value): + if readout_time is None: + mock_det._update_readout_time() + assert mock_det.readout_time == expected_value + else: + mock_det.scaninfo.readout_time = readout_time + mock_det._update_readout_time() + assert mock_det.readout_time == expected_value + + +@pytest.mark.parametrize( + "eacc, exp_url, daq_status, daq_cfg, expected_exception", + [ + ( + "e12345", + "http://xbl-daq-29:5000", + {"state": "READY"}, + {"writer_user_id": 12543}, + False, + ), + ( + "e12345", + "http://xbl-daq-29:5000", + {"state": "READY"}, + {"writer_user_id": 15421}, + False, + ), + ( + "e12345", + "http://xbl-daq-29:5000", + {"state": "BUSY"}, + {"writer_user_id": 15421}, + True, + ), + ( + "e12345", + "http://xbl-daq-29:5000", + {"state": "READY"}, + {"writer_ud": 12345}, + True, + ), + ], +) +def test_init_filewriter(mock_det, eacc, exp_url, daq_status, daq_cfg, expected_exception): + """Test _init_filewriter (std daq in this case) + + This includes testing the functions: + + - _update_service_config + + Validation upon checking set values in mocked std_daq instance + """ + with mock.patch("ophyd_devices.epics.devices.eiger9m_csaxs.StdDaqClient") as mock_std_daq: + instance = mock_std_daq.return_value + instance.stop_writer.return_value = None + instance.get_status.return_value = daq_status + instance.get_config.return_value = daq_cfg + mock_det.scaninfo.username = eacc + # scaninfo.username.return_value = eacc + if expected_exception: + with pytest.raises(Exception): + mock_det._init_filewriter() + else: + mock_det._init_filewriter() + + assert mock_det.std_rest_server_url == exp_url + instance.stop_writer.assert_called_once() + instance.get_status.assert_called() + instance.set_config.assert_called_once_with(daq_cfg) + + +@pytest.mark.parametrize( + "scaninfo, daq_status, daq_cfg, detector_state, stopped, expected_exception", + [ + ( + { + "eacc": "e12345", + "num_points": 500, + "frames_per_trigger": 1, + "filepath": "test.h5", + "scanID": "123", + "mokev": 12.4, + }, + {"state": "READY"}, + {"writer_user_id": 12543}, + 5, + False, + False, + ), + ( + { + "eacc": "e12345", + "num_points": 500, + "frames_per_trigger": 1, + "filepath": "test.h5", + "scanID": "123", + "mokev": 12.4, + }, + {"state": "BUSY"}, + {"writer_user_id": 15421}, + 5, + False, + False, + ), + ( + { + "eacc": "e12345", + "num_points": 500, + "frames_per_trigger": 1, + "filepath": "test.h5", + "scanID": "123", + "mokev": 18.4, + }, + {"state": "READY"}, + {"writer_user_id": 12345}, + 4, + False, + True, + ), + ], +) +def test_stage( + mock_det, + scaninfo, + daq_status, + daq_cfg, + detector_state, + stopped, + expected_exception, +): + with mock.patch.object(mock_det, "std_client") as mock_std_daq, mock.patch.object( + Eiger9McSAXS, "_publish_file_location" + ) as mock_publish_file_location: + mock_std_daq.stop_writer.return_value = None + mock_std_daq.get_status.return_value = daq_status + mock_std_daq.get_config.return_value = daq_cfg + mock_det.scaninfo.num_points = scaninfo["num_points"] + mock_det.scaninfo.frames_per_trigger = scaninfo["frames_per_trigger"] + mock_det.filewriter.compile_full_filename.return_value = scaninfo["filepath"] + # TODO consider putting energy as variable in scaninfo + mock_det.device_manager.add_device("mokev", value=12.4) + mock_det.cam.beam_energy.put(scaninfo["mokev"]) + mock_det._stopped = stopped + mock_det.cam.detector_state._read_pv.mock_data = detector_state + with mock.patch.object(mock_det, "_prep_file_writer") as mock_prep_fw: + mock_det.filepath = scaninfo["filepath"] + if expected_exception: + with pytest.raises(Exception): + mock_det.stage() + else: + mock_det.stage() + mock_prep_fw.assert_called_once() + # Check _prep_det + assert mock_det.cam.num_images.get() == int( + scaninfo["num_points"] * scaninfo["frames_per_trigger"] + ) + assert mock_det.cam.num_frames.get() == 1 + + mock_publish_file_location.assert_called_with(done=False) + assert mock_det.cam.acquire.get() == 1 + + +@pytest.mark.parametrize( + "scaninfo, daq_status, expected_exception", + [ + ( + { + "eacc": "e12345", + "num_points": 500, + "frames_per_trigger": 1, + "filepath": "test.h5", + "scanID": "123", + }, + {"state": "BUSY", "acquisition": {"state": "WAITING_IMAGES"}}, + False, + ), + ( + { + "eacc": "e12345", + "num_points": 500, + "frames_per_trigger": 1, + "filepath": "test.h5", + "scanID": "123", + }, + {"state": "BUSY", "acquisition": {"state": "WAITING_IMAGES"}}, + False, + ), + ( + { + "eacc": "e12345", + "num_points": 500, + "frames_per_trigger": 1, + "filepath": "test.h5", + "scanID": "123", + }, + {"state": "BUSY", "acquisition": {"state": "ERROR"}}, + True, + ), + ], +) +def test_prep_file_writer(mock_det, scaninfo, daq_status, expected_exception): + with mock.patch.object(mock_det, "std_client") as mock_std_daq, mock.patch.object( + mock_det, "_filepath_exists" + ) as mock_file_path_exists, mock.patch.object( + mock_det, "_stop_file_writer" + ) as mock_stop_file_writer, mock.patch.object( + mock_det, "scaninfo" + ) as mock_scaninfo: + # mock_det = eiger_factory(name, prefix, sim_mode) + mock_det.std_client = mock_std_daq + mock_std_daq.start_writer_async.return_value = None + mock_std_daq.get_status.return_value = daq_status + mock_det.filewriter.compile_full_filename.return_value = scaninfo["filepath"] + mock_det.scaninfo.num_points = scaninfo["num_points"] + mock_det.scaninfo.frames_per_trigger = scaninfo["frames_per_trigger"] + + if expected_exception: + with pytest.raises(Exception): + mock_det._prep_file_writer() + mock_file_path_exists.assert_called_once() + assert mock_stop_file_writer.call_count == 2 + + else: + mock_det._prep_file_writer() + mock_file_path_exists.assert_called_once() + mock_stop_file_writer.assert_called_once() + + daq_writer_call = { + "output_file": scaninfo["filepath"], + "n_images": int(scaninfo["num_points"] * scaninfo["frames_per_trigger"]), + } + mock_std_daq.start_writer_async.assert_called_with(daq_writer_call) + + +@pytest.mark.parametrize( + "stopped, expected_exception", + [ + ( + False, + False, + ), + ( + True, + True, + ), + ], +) +def test_unstage( + mock_det, + stopped, + expected_exception, +): + with mock.patch.object(mock_det, "_finished") as mock_finished, mock.patch.object( + mock_det, "_publish_file_location" + ) as mock_publish_file_location: + mock_det._stopped = stopped + if expected_exception: + mock_det.unstage() + assert mock_det._stopped == True + else: + mock_det.unstage() + mock_finished.assert_called_once() + mock_publish_file_location.assert_called_with(done=True, successful=True) + assert mock_det._stopped == False + + +def test_stop_fw(mock_det): + with mock.patch.object(mock_det, "std_client") as mock_std_daq: + mock_std_daq.stop_writer.return_value = None + mock_det.std_client = mock_std_daq + mock_det._stop_file_writer() + mock_std_daq.stop_writer.assert_called_once() + + +@pytest.mark.parametrize( + "scaninfo", + [ + ({"filepath": "test.h5", "successful": True, "done": False, "scanID": "123"}), + ({"filepath": "test.h5", "successful": False, "done": True, "scanID": "123"}), + ({"filepath": "test.h5", "successful": None, "done": True, "scanID": "123"}), + ], +) +def test_publish_file_location(mock_det, scaninfo): + mock_det.scaninfo.scanID = scaninfo["scanID"] + mock_det.filepath = scaninfo["filepath"] + mock_det._publish_file_location(done=scaninfo["done"], successful=scaninfo["successful"]) + if scaninfo["successful"] is None: + msg = BECMessage.FileMessage(file_path=scaninfo["filepath"], done=scaninfo["done"]).dumps() + else: + msg = BECMessage.FileMessage( + file_path=scaninfo["filepath"], done=scaninfo["done"], successful=scaninfo["successful"] + ).dumps() + expected_calls = [ + mock.call( + MessageEndpoints.public_file(scaninfo["scanID"], mock_det.name), + msg, + pipe=mock_det._producer.pipeline.return_value, + ), + mock.call( + MessageEndpoints.file_event(mock_det.name), + msg, + pipe=mock_det._producer.pipeline.return_value, + ), + ] + assert mock_det._producer.set_and_publish.call_args_list == expected_calls + + +def test_stop(mock_det): + with mock.patch.object(mock_det, "_stop_det") as mock_stop_det, mock.patch.object( + mock_det, "_stop_file_writer" + ) as mock_stop_file_writer: + mock_det.stop() + mock_stop_det.assert_called_once() + mock_stop_file_writer.assert_called_once() + assert mock_det._stopped == True + + +@pytest.mark.parametrize( + "stopped, scaninfo, cam_state, daq_status, expected_exception", + [ + ( + False, + { + "num_points": 500, + "frames_per_trigger": 4, + }, + 0, + {"acquisition": {"state": "FINISHED", "stats": {"n_write_completed": 2000}}}, + False, + ), + ( + False, + { + "num_points": 500, + "frames_per_trigger": 4, + }, + 0, + {"acquisition": {"state": "FINISHED", "stats": {"n_write_completed": 1999}}}, + True, + ), + ( + False, + { + "num_points": 500, + "frames_per_trigger": 1, + }, + 1, + {"acquisition": {"state": "READY", "stats": {"n_write_completed": 500}}}, + True, + ), + ( + False, + { + "num_points": 500, + "frames_per_trigger": 1, + }, + 0, + {"acquisition": {"state": "FINISHED", "stats": {"n_write_completed": 500}}}, + False, + ), + ], +) +def test_finished(mock_det, stopped, cam_state, daq_status, scaninfo, expected_exception): + with mock.patch.object(mock_det, "std_client") as mock_std_daq, mock.patch.object( + mock_det, "_stop_file_writer" + ) as mock_stop_file_friter, mock.patch.object(mock_det, "_stop_det") as mock_stop_det: + mock_std_daq.get_status.return_value = daq_status + mock_det.cam.acquire._read_pv.mock_state = cam_state + mock_det.scaninfo.num_points = scaninfo["num_points"] + mock_det.scaninfo.frames_per_trigger = scaninfo["frames_per_trigger"] + if expected_exception: + with pytest.raises(Exception): + mock_det._finished() + assert mock_det._stopped == stopped + mock_stop_file_friter.assert_called() + mock_stop_det.assert_called_once() + else: + mock_det._finished() + if stopped: + assert mock_det._stopped == stopped + + mock_stop_file_friter.assert_called() + mock_stop_det.assert_called_once() diff --git a/tests/test_pilatus_csaxs.py b/tests/test_pilatus_csaxs.py new file mode 100644 index 0000000..dfedb85 --- /dev/null +++ b/tests/test_pilatus_csaxs.py @@ -0,0 +1,503 @@ +import os +import pytest +from unittest import mock + +import ophyd + +from bec_lib.core import BECMessage, MessageEndpoints +from ophyd_devices.epics.devices.pilatus_csaxs import PilatuscSAXS + +from tests.utils import DMMock, MockPV + + +def patch_dual_pvs(device): + for walk in device.walk_signals(): + if not hasattr(walk.item, "_read_pv"): + continue + if not hasattr(walk.item, "_write_pv"): + continue + if walk.item._read_pv.pvname.endswith("_RBV"): + walk.item._read_pv = walk.item._write_pv + + +@pytest.fixture(scope="function") +def mock_det(): + name = "pilatus" + prefix = "X12SA-ES-PILATUS300K:" + sim_mode = False + dm = DMMock() + with mock.patch.object(dm, "producer"): + with mock.patch( + "ophyd_devices.epics.devices.pilatus_csaxs.FileWriterMixin" + ) as filemixin, mock.patch( + "ophyd_devices.epics.devices.pilatus_csaxs.PilatuscSAXS._update_service_config" + ) as mock_service_config: + with mock.patch.object(ophyd, "cl") as mock_cl: + mock_cl.get_pv = MockPV + with mock.patch.object(PilatuscSAXS, "_init"): + det = PilatuscSAXS( + name=name, prefix=prefix, device_manager=dm, sim_mode=sim_mode + ) + patch_dual_pvs(det) + yield det + + +@pytest.mark.parametrize( + "trigger_source, detector_state", + [ + (1, 0), + ], +) +# TODO rewrite this one, write test for init_detector, init_filewriter is tested +def test_init_detector( + mock_det, + trigger_source, + detector_state, +): + """Test the _init function: + + This includes testing the functions: + - _init_detector + - _stop_det + - _set_trigger + --> Testing the filewriter is done in test_init_filewriter + + Validation upon setting the correct PVs + + """ + mock_det._init_detector() # call the method you want to test + assert mock_det.cam.acquire.get() == detector_state + assert mock_det.cam.trigger_mode.get() == trigger_source + + +@pytest.mark.parametrize( + "scaninfo, stopped, expected_exception", + [ + ( + { + "eacc": "e12345", + "num_points": 500, + "frames_per_trigger": 1, + "filepath": "test.h5", + "scanID": "123", + "mokev": 12.4, + }, + False, + False, + ), + ( + { + "eacc": "e12345", + "num_points": 500, + "frames_per_trigger": 1, + "filepath": "test.h5", + "scanID": "123", + "mokev": 12.4, + }, + True, + False, + ), + ], +) +def test_stage( + mock_det, + scaninfo, + stopped, + expected_exception, +): + with mock.patch.object(mock_det, "_publish_file_location") as mock_publish_file_location: + mock_det.scaninfo.num_points = scaninfo["num_points"] + mock_det.scaninfo.frames_per_trigger = scaninfo["frames_per_trigger"] + mock_det.filewriter.compile_full_filename.return_value = scaninfo["filepath"] + # TODO consider putting energy as variable in scaninfo + mock_det.device_manager.add_device("mokev", value=12.4) + mock_det._stopped = stopped + with mock.patch.object(mock_det, "_prep_file_writer") as mock_prep_fw, mock.patch.object( + mock_det, "_update_readout_time" + ) as mock_update_readout_time: + mock_det.filepath = scaninfo["filepath"] + if expected_exception: + with pytest.raises(Exception): + mock_det.stage() + else: + mock_det.stage() + mock_prep_fw.assert_called_once() + mock_update_readout_time.assert_called() + # Check _prep_det + assert mock_det.cam.num_images.get() == int( + scaninfo["num_points"] * scaninfo["frames_per_trigger"] + ) + assert mock_det.cam.num_frames.get() == 1 + + mock_publish_file_location.assert_called_with(done=False) + + +def test_pre_scan(mock_det): + mock_det.pre_scan() + assert mock_det.cam.acquire.get() == 1 + + +@pytest.mark.parametrize( + "readout_time, expected_value", + [ + (1e-3, 3e-3), + (3e-3, 3e-3), + (5e-3, 5e-3), + (None, 3e-3), + ], +) +def test_update_readout_time(mock_det, readout_time, expected_value): + if readout_time is None: + mock_det._update_readout_time() + assert mock_det.readout_time == expected_value + else: + mock_det.scaninfo.readout_time = readout_time + mock_det._update_readout_time() + assert mock_det.readout_time == expected_value + + +@pytest.mark.parametrize( + "scaninfo", + [ + ({"filepath": "test.h5", "successful": True, "done": False, "scanID": "123"}), + ({"filepath": "test.h5", "successful": False, "done": True, "scanID": "123"}), + ({"filepath": "test.h5", "successful": None, "done": True, "scanID": "123"}), + ], +) +def test_publish_file_location(mock_det, scaninfo): + mock_det.scaninfo.scanID = scaninfo["scanID"] + mock_det.filepath = scaninfo["filepath"] + mock_det._publish_file_location(done=scaninfo["done"], successful=scaninfo["successful"]) + if scaninfo["successful"] is None: + msg = BECMessage.FileMessage(file_path=scaninfo["filepath"], done=scaninfo["done"]).dumps() + else: + msg = BECMessage.FileMessage( + file_path=scaninfo["filepath"], done=scaninfo["done"], successful=scaninfo["successful"] + ).dumps() + expected_calls = [ + mock.call( + MessageEndpoints.public_file(scaninfo["scanID"], mock_det.name), + msg, + pipe=mock_det._producer.pipeline.return_value, + ), + mock.call( + MessageEndpoints.file_event(mock_det.name), + msg, + pipe=mock_det._producer.pipeline.return_value, + ), + ] + assert mock_det._producer.set_and_publish.call_args_list == expected_calls + + +@pytest.mark.parametrize( + "requests_state, expected_exception, url", + [ + ( + True, + False, + "http://x12sa-pd-2:8080/stream/pilatus_2", + ), + ( + False, + False, + "http://x12sa-pd-2:8080/stream/pilatus_2", + ), + ], +) +def test_close_file_writer(mock_det, requests_state, expected_exception, url): + with mock.patch.object(mock_det, "_send_requests_delete") as mock_send_requests_delete: + instance = mock_send_requests_delete.return_value + instance.ok = requests_state + if expected_exception: + mock_det._close_file_writer() + mock_send_requests_delete.assert_called_once_with(url=url) + instance.raise_for_status.called_once() + else: + mock_det._close_file_writer() + mock_send_requests_delete.assert_called_once_with(url=url) + + +@pytest.mark.parametrize( + "requests_state, expected_exception, url", + [ + ( + True, + False, + "http://xbl-daq-34:8091/pilatus_2/stop", + ), + ( + False, + True, + "http://xbl-daq-34:8091/pilatus_2/stop", + ), + ], +) +def test_stop_file_writer(mock_det, requests_state, expected_exception, url): + with mock.patch.object(mock_det, "_send_requests_put") as mock_send_requests_put: + instance = mock_send_requests_put.return_value + instance.ok = requests_state + instance.raise_for_status.side_effect = Exception + if expected_exception: + with pytest.raises(Exception): + mock_det._stop_file_writer() + mock_send_requests_put.assert_called_once_with(url=url) + instance.raise_for_status.called_once() + else: + mock_det._stop_file_writer() + mock_send_requests_put.assert_called_once_with(url=url) + + +@pytest.mark.parametrize( + "scaninfo, data_msgs, urls, requests_state, expected_exception", + [ + ( + { + "filepath_raw": "pilatus_2.h5", + "eacc": "e12345", + "scan_number": 1000, + "scan_directory": "S00000_00999", + "num_points": 500, + "frames_per_trigger": 1, + "headers": {"Content-Type": "application/json", "Accept": "application/json"}, + }, + [ + { + "source": [ + { + "searchPath": "/", + "searchPattern": "glob:*.cbf", + "destinationPath": "/sls/X12SA/data/e12345/Data10/pilatus_2/S00000_00999", + } + ] + }, + [ + "zmqWriter", + "e12345", + { + "addr": "tcp://x12sa-pd-2:8888", + "dst": ["file"], + "numFrm": 500, + "timeout": 2000, + "ifType": "PULL", + "user": "e12345", + }, + ], + [ + "zmqWriter", + "e12345", + { + "frmCnt": 500, + "timeout": 2000, + }, + ], + ], + [ + "http://x12sa-pd-2:8080/stream/pilatus_2", + "http://xbl-daq-34:8091/pilatus_2/run", + "http://xbl-daq-34:8091/pilatus_2/wait", + ], + True, + False, + ), + ( + { + "filepath_raw": "pilatus_2.h5", + "eacc": "e12345", + "scan_number": 1000, + "scan_directory": "S00000_00999", + "num_points": 500, + "frames_per_trigger": 1, + "headers": {"Content-Type": "application/json", "Accept": "application/json"}, + }, + [ + { + "source": [ + { + "searchPath": "/", + "searchPattern": "glob:*.cbf", + "destinationPath": "/sls/X12SA/data/e12345/Data10/pilatus_2/S00000_00999", + } + ] + }, + [ + "zmqWriter", + "e12345", + { + "addr": "tcp://x12sa-pd-2:8888", + "dst": ["file"], + "numFrm": 500, + "timeout": 2000, + "ifType": "PULL", + "user": "e12345", + }, + ], + [ + "zmqWriter", + "e12345", + { + "frmCnt": 500, + "timeout": 2000, + }, + ], + ], + [ + "http://x12sa-pd-2:8080/stream/pilatus_2", + "http://xbl-daq-34:8091/pilatus_2/run", + "http://xbl-daq-34:8091/pilatus_2/wait", + ], + False, # return of res.ok is False! + True, + ), + ], +) +def test_prep_file_writer(mock_det, scaninfo, data_msgs, urls, requests_state, expected_exception): + with mock.patch.object( + mock_det, "_close_file_writer" + ) as mock_close_file_writer, mock.patch.object( + mock_det, "_stop_file_writer" + ) as mock_stop_file_writer, mock.patch.object( + mock_det, "filewriter" + ) as mock_filewriter, mock.patch.object( + mock_det, "_create_directory" + ) as mock_create_directory, mock.patch.object( + mock_det, "_send_requests_put" + ) as mock_send_requests_put: + mock_det.scaninfo.scan_number = scaninfo["scan_number"] + mock_det.scaninfo.num_points = scaninfo["num_points"] + mock_det.scaninfo.frames_per_trigger = scaninfo["frames_per_trigger"] + mock_det.scaninfo.username = scaninfo["eacc"] + mock_filewriter.compile_full_filename.return_value = scaninfo["filepath_raw"] + mock_filewriter.get_scan_directory.return_value = scaninfo["scan_directory"] + instance = mock_send_requests_put.return_value + instance.ok = requests_state + instance.raise_for_status.side_effect = Exception + + if expected_exception: + with pytest.raises(Exception): + mock_det._prep_file_writer() + mock_close_file_writer.assert_called_once() + mock_stop_file_writer.assert_called_once() + instance.raise_for_status.assert_called_once() + else: + mock_det._prep_file_writer() + + mock_close_file_writer.assert_called_once() + mock_stop_file_writer.assert_called_once() + + # Assert values set on detector + assert mock_det.cam.file_path.get() == "/dev/shm/zmq/" + assert ( + mock_det.cam.file_name.get() + == f"{scaninfo['eacc']}_2_{scaninfo['scan_number']:05d}" + ) + assert mock_det.cam.auto_increment.get() == 1 + assert mock_det.cam.file_number.get() == 0 + assert mock_det.cam.file_format.get() == 0 + assert mock_det.cam.file_template.get() == "%s%s_%5.5d.cbf" + # Remove last / from destinationPath + mock_create_directory.assert_called_once_with( + os.path.join(data_msgs[0]["source"][0]["destinationPath"]) + ) + assert mock_send_requests_put.call_count == 3 + + calls = [ + mock.call(url=url, data=data_msg, headers=scaninfo["headers"]) + for url, data_msg in zip(urls, data_msgs) + ] + for call, mock_call in zip(calls, mock_send_requests_put.call_args_list): + assert call == mock_call + + +@pytest.mark.parametrize( + "stopped, expected_exception", + [ + ( + False, + False, + ), + ( + True, + True, + ), + ], +) +def test_unstage( + mock_det, + stopped, + expected_exception, +): + with mock.patch.object(mock_det, "_finished") as mock_finished, mock.patch.object( + mock_det, "_publish_file_location" + ) as mock_publish_file_location, mock.patch.object( + mock_det, "_start_h5converter" + ) as mock_start_h5converter: + mock_det._stopped = stopped + if expected_exception: + mock_det.unstage() + assert mock_det._stopped == True + else: + mock_det.unstage() + mock_finished.assert_called_once() + mock_publish_file_location.assert_called_with(done=True, successful=True) + mock_start_h5converter.assert_called_once() + assert mock_det._stopped == False + + +def test_stop(mock_det): + with mock.patch.object(mock_det, "_stop_det") as mock_stop_det, mock.patch.object( + mock_det, "_stop_file_writer" + ) as mock_stop_file_writer, mock.patch.object( + mock_det, "_close_file_writer" + ) as mock_close_file_writer: + mock_det.stop() + mock_stop_det.assert_called_once() + mock_stop_file_writer.assert_called_once() + mock_close_file_writer.assert_called_once() + assert mock_det._stopped == True + + +@pytest.mark.parametrize( + "stopped, mcs_stage_state, expected_exception", + [ + ( + False, + ophyd.Staged.no, + False, + ), + ( + True, + ophyd.Staged.no, + False, + ), + ( + False, + ophyd.Staged.yes, + True, + ), + ], +) +def test_finished(mock_det, stopped, mcs_stage_state, expected_exception): + with mock.patch.object(mock_det, "device_manager") as mock_dm, mock.patch.object( + mock_det, "_stop_file_writer" + ) as mock_stop_file_friter, mock.patch.object( + mock_det, "_stop_det" + ) as mock_stop_det, mock.patch.object( + mock_det, "_close_file_writer" + ) as mock_close_file_writer: + mock_dm.devices.mcs.obj._staged = mcs_stage_state + mock_det._stopped = stopped + if expected_exception: + with pytest.raises(Exception): + mock_det._finished() + assert mock_det._stopped == stopped + mock_stop_file_friter.assert_called() + mock_stop_det.assert_called_once() + mock_close_file_writer.assert_called_once() + else: + mock_det._finished() + if stopped: + assert mock_det._stopped == stopped + + mock_stop_file_friter.assert_called() + mock_stop_det.assert_called_once() + mock_close_file_writer.assert_called_once() diff --git a/tests/utils.py b/tests/utils.py index 90af2e9..047ab68 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,3 +1,9 @@ +from bec_lib.core.devicemanager import DeviceContainer +from bec_lib.core.tests.utils import ProducerMock + +from unittest import mock + + class SocketMock: def __init__(self, host, port): self.host = host @@ -44,3 +50,246 @@ class SocketMock: def flush_buffer(self): self.buffer_put = [] self.buffer_recv = "" + + +class MockPV: + """ + MockPV class + + This class is used for mocking pyepics signals for testing purposes + + """ + + _fmtsca = "" + _fmtarr = "" + _fields = ( + "pvname", + "value", + "char_value", + "status", + "ftype", + "chid", + "host", + "count", + "access", + "write_access", + "read_access", + "severity", + "timestamp", + "posixseconds", + "nanoseconds", + "precision", + "units", + "enum_strs", + "upper_disp_limit", + "lower_disp_limit", + "upper_alarm_limit", + "lower_alarm_limit", + "lower_warning_limit", + "upper_warning_limit", + "upper_ctrl_limit", + "lower_ctrl_limit", + ) + + def __init__( + self, + pvname, + callback=None, + form="time", + verbose=False, + auto_monitor=None, + count=None, + connection_callback=None, + connection_timeout=None, + access_callback=None, + ): + self.pvname = pvname.strip() + self.form = form.lower() + self.verbose = verbose + self._auto_monitor = auto_monitor + self.ftype = None + self.connected = True + self.connection_timeout = connection_timeout + self._user_max_count = count + + if self.connection_timeout is None: + self.connection_timeout = 3 + self._args = {}.fromkeys(self._fields) + self._args["pvname"] = self.pvname + self._args["count"] = count + self._args["nelm"] = -1 + self._args["type"] = "unknown" + self._args["typefull"] = "unknown" + self._args["access"] = "unknown" + self._args["status"] = 0 + self.connection_callbacks = [] + self.mock_data = 0 + + if connection_callback is not None: + self.connection_callbacks = [connection_callback] + + self.access_callbacks = [] + if access_callback is not None: + self.access_callbacks = [access_callback] + + self.callbacks = {} + self._put_complete = None + self._monref = None # holder of data returned from create_subscription + self._monref_mask = None + self._conn_started = False + if isinstance(callback, (tuple, list)): + for i, thiscb in enumerate(callback): + if callable(thiscb): + self.callbacks[i] = (thiscb, {}) + elif callable(callback): + self.callbacks[0] = (callback, {}) + + self.chid = None + self.context = mock.MagicMock() + self._cache_key = (pvname, form, self.context) + self._reference_count = 0 + for conn_cb in self.connection_callbacks: + conn_cb(pvname=pvname, conn=True, pv=self) + for acc_cb in self.access_callbacks: + acc_cb(True, True, pv=self) + + def wait_for_connection(self, timeout=None): + return self.connected + + def get_all_metadata_blocking(self, timeout): + md = self._args.copy() + md.pop("value", None) + return md + + def get_all_metadata_callback(self, callback, *, timeout): + def get_metadata_thread(pvname): + md = self.get_all_metadata_blocking(timeout=timeout) + callback(pvname, md) + + get_metadata_thread(pvname=self.pvname) + + def put( + self, value, wait=False, timeout=None, use_complete=False, callback=None, callback_data=None + ): + self.mock_data = value + if callback is not None: + callback(None, None, None) + + def get_with_metadata( + self, + count=None, + as_string=False, + as_numpy=True, + timeout=None, + with_ctrlvars=False, + form=None, + use_monitor=True, + as_namespace=False, + ): + return {"value": self.mock_data} + + def get( + self, + count=None, + as_string=False, + as_numpy=True, + timeout=None, + with_ctrlvars=False, + use_monitor=True, + ): + data = self.get_with_metadata( + count=count, + as_string=as_string, + as_numpy=as_numpy, + timeout=timeout, + with_ctrlvars=with_ctrlvars, + use_monitor=use_monitor, + ) + return data["value"] if data is not None else None + + +class DeviceMock: + """Device Mock. Used for testing in combination with the DeviceManagerMock + + Args: + name (str): name of the device + value (float, optional): initial value of the device. Defaults to 0.0. + Returns: + DeviceMock: DeviceMock object + + """ + + def __init__(self, name: str, value: float = 0.0): + self.name = name + self.read_buffer = value + self._config = {"deviceConfig": {"limits": [-50, 50]}, "userParameter": None} + self._enabled_set = True + self._enabled = True + + def read(self): + return {self.name: {"value": self.read_buffer}} + + def readback(self): + return self.read_buffer + + @property + def enabled_set(self) -> bool: + return self._enabled_set + + @enabled_set.setter + def enabled_set(self, val: bool): + self._enabled_set = val + + @property + def enabled(self) -> bool: + return self._enabled + + @enabled.setter + def enabled(self, val: bool): + self._enabled = val + + @property + def user_parameter(self): + return self._config["userParameter"] + + @property + def obj(self): + return self + + +class DMMock: + """Mock for DeviceManager + + The mocked DeviceManager creates a device containert and a producer. + + """ + + def __init__(self): + self.devices = DeviceContainer() + self.producer = ProducerMock() + + def add_device(self, name: str, value: float = 0.0): + self.devices[name] = DeviceMock(name, value) + + +# #TODO check what is the difference to SynSignal! +# class MockSignal(Signal): +# """Can mock an OphydSignal""" +# def __init__(self, read_pv, *, string=False, name=None, parent=None, **kwargs): +# self.read_pv = read_pv +# self._string = bool(string) +# super().__init__(name=name, parent=parent, **kwargs) +# self._waited_for_connection = False +# self._subscriptions = [] + +# def wait_for_connection(self): +# self._waited_for_connection = True + +# def subscribe(self, method, event_type, **kw): +# self._subscriptions.append((method, event_type, kw)) + +# def describe_configuration(self): +# return {self.name + "_conf": {"source": "SIM:test"}} + +# def read_configuration(self): +# return {self.name + "_conf": {"value": 0}}