From d28f515c0d17bb1ff89ce33813691a53817eca60 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Thu, 6 Mar 2025 15:08:40 +0100 Subject: [PATCH] refactor(gf): cleanup of the std daq integration --- pyproject.toml | 21 +- tests/tests_devices/test_stddaq_client.py | 358 +++++++ .../microxas_test_bed_stddaq_test.yaml | 191 ++++ .../devices/gigafrost/default_gf_config.json | 22 + .../devices/gigafrost/gigafrost_base.py | 292 ++++++ .../devices/gigafrost/gigafrostcamera.py | 967 +++++++----------- .../devices/gigafrost/std_daq_client.py | 433 ++++++++ .../devices/gigafrost/std_daq_preview.py | 108 ++ tomcat_bec/scans/__init__.py | 4 +- tomcat_bec/scans/tutorial_fly_scan.py | 259 +++-- 10 files changed, 1983 insertions(+), 672 deletions(-) create mode 100644 tests/tests_devices/test_stddaq_client.py create mode 100644 tomcat_bec/device_configs/microxas_test_bed_stddaq_test.yaml create mode 100644 tomcat_bec/devices/gigafrost/default_gf_config.json create mode 100644 tomcat_bec/devices/gigafrost/gigafrost_base.py create mode 100644 tomcat_bec/devices/gigafrost/std_daq_client.py create mode 100644 tomcat_bec/devices/gigafrost/std_daq_preview.py diff --git a/pyproject.toml b/pyproject.toml index 731a0d5..acc4032 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,10 +12,27 @@ classifiers = [ "Programming Language :: Python :: 3", "Topic :: Scientific/Engineering", ] -dependencies = ["ophyd_devices", "bec_lib", "requests", "websockets", "pyzmq", "jinja2"] +dependencies = [ + "ophyd_devices", + "bec_lib", + "requests", + "websockets", + "pyzmq", + "jinja2", +] [project.optional-dependencies] -dev = ["black", "isort", "coverage", "pylint", "pytest", "pytest-random-order", "ophyd_devices", "bec_server"] +dev = [ + "black", + "isort", + "coverage", + "pylint", + "pytest", + "pytest-random-order", + "ophyd_devices", + "bec_server", + "requests-mock", +] [project.entry-points."bec"] plugin_bec = "tomcat_bec" diff --git a/tests/tests_devices/test_stddaq_client.py b/tests/tests_devices/test_stddaq_client.py new file mode 100644 index 0000000..e055774 --- /dev/null +++ b/tests/tests_devices/test_stddaq_client.py @@ -0,0 +1,358 @@ +import json +from unittest import mock + +import pytest +import requests +import requests_mock +import typeguard +from ophyd import StatusBase +from websockets import WebSocketException + +from tomcat_bec.devices.gigafrost.std_daq_client import ( + StdDaqClient, + StdDaqConfig, + StdDaqError, + StdDaqStatus, +) + + +@pytest.fixture +def client(): + parent_device = mock.MagicMock() + _client = StdDaqClient( + parent=parent_device, ws_url="http://localhost:5000", rest_url="http://localhost:5000" + ) + yield _client + _client.shutdown() + + +@pytest.fixture +def full_config(): + full_config = StdDaqConfig( + detector_name="tomcat-gf", + detector_type="gigafrost", + n_modules=8, + bit_depth=16, + image_pixel_height=2016, + image_pixel_width=2016, + start_udp_port=2000, + writer_user_id=18600, + max_number_of_forwarders_spawned=8, + use_all_forwarders=True, + module_sync_queue_size=4096, + number_of_writers=12, + module_positions={}, + ram_buffer_gb=150, + delay_filter_timeout=10, + live_stream_configs={ + "tcp://129.129.95.111:20000": {"type": "periodic", "config": [1, 5]}, + "tcp://129.129.95.111:20001": {"type": "periodic", "config": [1, 5]}, + "tcp://129.129.95.38:20000": {"type": "periodic", "config": [1, 1]}, + }, + ) + return full_config + + +def test_stddaq_client(client): + assert client is not None + + +def test_stddaq_client_get_daq_config(client, full_config): + with requests_mock.Mocker() as m: + response = full_config + m.get("http://localhost:5000/api/config/get?user=ioc", json=response.model_dump()) + out = client.get_config() + + # Check that the response is simply the json response + assert out == response.model_dump() + + assert client._config == response + + +def test_stddaq_client_set_config_pydantic(client, full_config): + """Test setting configurations through the StdDAQ client""" + with requests_mock.Mocker() as m: + m.post("http://localhost:5000/api/config/set?user=ioc") + + # Test with StdDaqConfig object + config = full_config + with mock.patch.object(client, "_pre_restart"), mock.patch.object(client, "_post_restart"): + client.set_config(config) + + # Verify the last request + assert m.last_request.json() == full_config.model_dump() + + +def test_std_daq_client_set_config_dict(client, full_config): + """ + Test setting configurations through the StdDAQ client with a dictionary input. + """ + + with requests_mock.Mocker() as m: + m.post("http://localhost:5000/api/config/set?user=ioc") + + # Test with dictionary input + config_dict = full_config.model_dump() + with mock.patch.object(client, "_pre_restart"), mock.patch.object(client, "_post_restart"): + client.set_config(config_dict) + assert m.last_request.json() == full_config.model_dump() + + +def test_stddaq_client_set_config_ignores_extra_keys(client, full_config): + """ + Test that the set_config method ignores extra keys in the input dictionary. + """ + + with requests_mock.Mocker() as m: + m.post("http://localhost:5000/api/config/set?user=ioc") + + # Test with dictionary input + config_dict = full_config.model_dump() + config_dict["extra_key"] = "extra_value" + with mock.patch.object(client, "_pre_restart"), mock.patch.object(client, "_post_restart"): + client.set_config(config_dict) + assert m.last_request.json() == full_config.model_dump() + + +def test_stddaq_client_set_config_error(client, full_config): + """ + Test error handling in the set_config method. + """ + with requests_mock.Mocker() as m: + config = full_config + m.post("http://localhost:5000/api/config/set?user=ioc", status_code=500) + with mock.patch.object(client, "_pre_restart"), mock.patch.object(client, "_post_restart"): + with pytest.raises(requests.exceptions.HTTPError): + client.set_config(config) + + +def test_stddaq_client_get_config_cached(client, full_config): + """ + Test that the client returns the cached configuration if it is available. + """ + + # Set the cached configuration + config = full_config + client._config = config + + # Test that the client returns the cached configuration + assert client.get_config(cached=True) == config + + +def test_stddaq_client_status(client): + client._status = StdDaqStatus.FILE_CREATED + assert client.status == StdDaqStatus.FILE_CREATED + + +def test_stddaq_client_start(client): + + with mock.patch("tomcat_bec.devices.gigafrost.std_daq_client.StatusBase") as StatusBase: + client.start(file_path="test_file_path", file_prefix="test_file_prefix", num_images=10) + out = client._send_queue.get() + assert out == { + "command": "start", + "path": "test_file_path", + "file_prefix": "test_file_prefix", + "n_image": 10, + } + StatusBase().wait.assert_called_once() + + +def test_stddaq_client_start_type_error(client): + with pytest.raises(typeguard.TypeCheckError): + client.start(file_path="test_file_path", file_prefix="test_file_prefix", num_images="10") + + +def test_stddaq_client_stop(client): + """ + Check that the stop method puts the stop command in the send queue. + """ + client.stop() + client._send_queue.get() == {"command": "stop"} + + +def test_stddaq_client_update_config(client, full_config): + """ + Test that the update_config method updates the configuration with the provided dictionary. + """ + + config = full_config + with requests_mock.Mocker() as m: + m.get("http://localhost:5000/api/config/get?user=ioc", json=config.model_dump()) + + # Update the configuration + update_dict = {"detector_name": "new_name"} + with mock.patch.object(client, "set_config") as set_config: + client.update_config(update_dict) + + assert set_config.call_count == 1 + + +def test_stddaq_client_updates_only_changed_configs(client, full_config): + """ + Test that the update_config method only updates the configuration if the config has changed. + """ + + config = full_config + with requests_mock.Mocker() as m: + m.get("http://localhost:5000/api/config/get?user=ioc", json=config.model_dump()) + + # Update the configuration + update_dict = {"detector_name": "tomcat-gf"} + with mock.patch.object(client, "set_config") as set_config: + client.update_config(update_dict) + + assert set_config.call_count == 0 + + +def test_stddaq_client_updates_only_changed_configs_empty(client, full_config): + """ + Test that the update_config method only updates the configuration if the config has changed. + """ + + config = full_config + with requests_mock.Mocker() as m: + m.get("http://localhost:5000/api/config/get?user=ioc", json=config.model_dump()) + + # Update the configuration + update_dict = {} + with mock.patch.object(client, "set_config") as set_config: + client.update_config(update_dict) + + assert set_config.call_count == 0 + + +def test_stddaq_client_pre_restart(client): + """ + Test that the pre_restart method sets the status to RESTARTING. + """ + # let's assume the websocket loop is already idle + client._ws_idle_event.set() + client.ws_client = mock.MagicMock() + client._pre_restart() + client.ws_client.close.assert_called_once() + + +def test_stddaq_client_post_restart(client): + """ + Test that the post_restart method sets the status to IDLE. + """ + with mock.patch.object(client, "wait_for_connection") as wait_for_connection: + client._post_restart() + wait_for_connection.assert_called_once() + assert client._daq_is_running.is_set() + + +def test_stddaq_client_reset(client): + """ + Test that the reset method calls get_config and set_config. + """ + with ( + mock.patch.object(client, "get_config") as get_config, + mock.patch.object(client, "set_config") as set_config, + ): + client.reset() + get_config.assert_called_once() + set_config.assert_called_once() + + +def test_stddaq_client_run_status_callbacks(client): + """ + Test that the run_status_callback method runs the status callback. + """ + status = StatusBase() + client.add_status_callback(status, success=[StdDaqStatus.FILE_CREATED], error=[]) + client._status = StdDaqStatus.FILE_CREATED + client._run_status_callbacks() + status.wait() + + assert len(status._callbacks) == 0 + + +def test_stddaq_client_run_status_callbacks_error(client): + """ + Test that the run_status_callback method runs the status callback. + """ + status = StatusBase() + client.add_status_callback(status, success=[], error=[StdDaqStatus.FILE_CREATED]) + client._status = StdDaqStatus.FILE_CREATED + client._run_status_callbacks() + with pytest.raises(StdDaqError): + status.wait() + + assert len(status._callbacks) == 0 + + +@pytest.mark.parametrize( + "msg, updated", + [({"status": "IDLE"}, False), (json.dumps({"status": "waiting_for_first_image"}), True)], +) +def test_stddaq_client_on_received_ws_message(client, msg, updated): + """ + Test that the on_received_ws_message method runs the status callback. + """ + client._status = None + with mock.patch.object(client, "_run_status_callbacks") as run_status_callbacks: + client._on_received_ws_message(msg) + if updated: + run_status_callbacks.assert_called_once() + assert client._status == StdDaqStatus.WAITING_FOR_FIRST_IMAGE + else: + run_status_callbacks.assert_not_called() + assert client._status is None + + +def test_stddaq_client_ws_send_and_receive(client): + + client.ws_client = mock.MagicMock() + client._send_queue.put({"command": "test"}) + client._ws_send_and_receive() + # queue is not empty, so we should send the message + client.ws_client.send.assert_called_once() + client.ws_client.recv.assert_called_once() + + client.ws_client.reset_mock() + client._ws_send_and_receive() + # queue is empty, so we should not send the message + client.ws_client.send.assert_not_called() + client.ws_client.recv.assert_called_once() + + +def test_stddaq_client_ws_send_and_receive_websocket_error(client): + """ + Test that the ws_send_and_receive method handles websocket errors. + """ + client.ws_client = mock.MagicMock() + client.ws_client.send.side_effect = WebSocketException() + client._send_queue.put({"command": "test"}) + with mock.patch.object(client, "wait_for_connection") as wait_for_connection: + client._ws_send_and_receive() + wait_for_connection.assert_called_once() + + +def test_stddaq_client_ws_send_and_receive_timeout_error(client): + """ + Test that the ws_send_and_receive method handles timeout errors. + """ + client.ws_client = mock.MagicMock() + client.ws_client.recv.side_effect = TimeoutError() + client._send_queue.put({"command": "test"}) + with mock.patch.object(client, "wait_for_connection") as wait_for_connection: + client._ws_send_and_receive() + wait_for_connection.assert_not_called() + + +def test_stddaq_client_ws_update_loop(client): + """ + Test that the ws_update_loop method runs the status callback. + """ + client._shutdown_event = mock.MagicMock() + client._shutdown_event.is_set.side_effect = [False, True] + with ( + mock.patch.object(client, "_ws_send_and_receive") as ws_send_and_receive, + mock.patch.object(client, "_wait_for_server_running") as wait_for_server_running, + ): + client._ws_update_loop() + + ws_send_and_receive.assert_called_once() + wait_for_server_running.assert_called_once() diff --git a/tomcat_bec/device_configs/microxas_test_bed_stddaq_test.yaml b/tomcat_bec/device_configs/microxas_test_bed_stddaq_test.yaml new file mode 100644 index 0000000..8d5b673 --- /dev/null +++ b/tomcat_bec/device_configs/microxas_test_bed_stddaq_test.yaml @@ -0,0 +1,191 @@ +eyex: + readoutPriority: baseline + description: X-ray eye axis X + deviceClass: tomcat_bec.devices.psimotor.EpicsMotorEC + deviceConfig: + prefix: MTEST-X05LA-ES2-XRAYEYE:M1 + deviceTags: + - xray-eye + onFailure: buffer + enabled: true + readOnly: false + softwareTrigger: false +# eyey: +# readoutPriority: baseline +# description: X-ray eye axis Y +# deviceClass: tomcat_bec.devices.psimotor.EpicsMotorEC +# deviceConfig: +# prefix: MTEST-X05LA-ES2-XRAYEYE:M2 +# deviceTags: +# - xray-eye +# onFailure: buffer +# enabled: true +# readOnly: false +# softwareTrigger: false +# eyez: +# readoutPriority: baseline +# description: X-ray eye axis Z +# deviceClass: tomcat_bec.devices.psimotor.EpicsMotorEC +# deviceConfig: +# prefix: MTEST-X05LA-ES2-XRAYEYE:M3 +# deviceTags: +# - xray-eye +# onFailure: buffer +# enabled: true +# readOnly: false +# softwareTrigger: false +femto_mean_curr: + readoutPriority: monitored + description: Femto mean current + deviceClass: ophyd.EpicsSignal + deviceConfig: + auto_monitor: true + read_pv: MTEST-X05LA-ES2-XRAYEYE:FEMTO-MEAN-CURR + deviceTags: + - xray-eye + onFailure: buffer + enabled: true + readOnly: true + softwareTrigger: false + +# es1_roty: +# readoutPriority: baseline +# description: 'Test rotation stage' +# deviceClass: tomcat_bec.devices.psimotor.EpicsMotorMR +# deviceConfig: +# prefix: X02DA-ES1-SMP1:ROTY +# deviceTags: +# - es1-sam +# onFailure: buffer +# enabled: true +# readOnly: false +# softwareTrigger: false + +# es1_ismc: +# description: 'Automation1 iSMC interface' +# deviceClass: tomcat_bec.devices.aa1Controller +# deviceConfig: +# prefix: 'X02DA-ES1-SMP1:CTRL:' +# deviceTags: +# - es1 +# enabled: true +# onFailure: buffer +# readOnly: false +# readoutPriority: monitored +# softwareTrigger: false + +# es1_tasks: +# description: 'Automation1 task management interface' +# deviceClass: tomcat_bec.devices.aa1Tasks +# deviceConfig: +# prefix: 'X02DA-ES1-SMP1:TASK:' +# deviceTags: +# - es1 +# enabled: true +# onFailure: buffer +# readOnly: false +# readoutPriority: monitored +# softwareTrigger: false + + +# es1_psod: +# description: 'AA1 PSO output interface (trigger)' +# deviceClass: tomcat_bec.devices.aa1AxisPsoDistance +# deviceConfig: +# prefix: 'X02DA-ES1-SMP1:ROTY:PSO:' +# deviceTags: +# - es1 +# enabled: true +# onFailure: buffer +# readOnly: false +# readoutPriority: monitored +# softwareTrigger: true + + +# es1_ddaq: +# description: 'Automation1 position recording interface' +# deviceClass: tomcat_bec.devices.aa1AxisDriveDataCollection +# deviceConfig: +# prefix: 'X02DA-ES1-SMP1:ROTY:DDC:' +# deviceTags: +# - es1 +# enabled: true +# onFailure: buffer +# readOnly: false +# readoutPriority: monitored +# softwareTrigger: false + + +#camera: +# description: Grashopper Camera +# deviceClass: tomcat_bec.devices.GrashopperTOMCAT +# deviceConfig: +# prefix: 'X02DA-PG-USB:' +# deviceTags: +# - camera +# enabled: true +# onFailure: buffer +# readOnly: false +# readoutPriority: monitored +# softwareTrigger: true + +gfcam: + description: GigaFrost camera client + deviceClass: tomcat_bec.devices.GigaFrostCamera + deviceConfig: + prefix: 'X02DA-CAM-GF2:' + backend_url: 'http://sls-daq-001:8080' + std_daq_ws: 'ws://129.129.95.111:8080' + std_daq_rest: 'http://129.129.95.111:5000' + std_daq_live: 'tcp://129.129.95.111:20001' + auto_soft_enable: true + deviceTags: + - camera + - trigger + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: monitored + softwareTrigger: true + +# gfdaq: +# description: GigaFrost stdDAQ client +# deviceClass: tomcat_bec.devices.StdDaqClient +# deviceConfig: +# ws_url: 'ws://129.129.95.111:8080' +# rest_url: 'http://129.129.95.111:5000' +# deviceTags: +# - std-daq +# enabled: true +# onFailure: buffer +# readOnly: false +# readoutPriority: monitored +# softwareTrigger: false + +# daq_stream0: +# description: stdDAQ preview (2 every 555) +# deviceClass: tomcat_bec.devices.StdDaqPreviewDetector +# deviceConfig: +# url: 'tcp://129.129.95.111:20000' +# deviceTags: +# - std-daq +# enabled: true +# onFailure: buffer +# readOnly: false +# readoutPriority: monitored +# softwareTrigger: false + +# daq_stream1: +# description: stdDAQ preview (1 at 5 Hz) +# deviceClass: tomcat_bec.devices.StdDaqPreviewDetector +# deviceConfig: +# url: 'tcp://129.129.95.111:20001' +# deviceTags: +# - std-daq +# enabled: true +# onFailure: buffer +# readOnly: false +# readoutPriority: monitored +# softwareTrigger: false + + diff --git a/tomcat_bec/devices/gigafrost/default_gf_config.json b/tomcat_bec/devices/gigafrost/default_gf_config.json new file mode 100644 index 0000000..7447aab --- /dev/null +++ b/tomcat_bec/devices/gigafrost/default_gf_config.json @@ -0,0 +1,22 @@ +{ + "detector_name": "tomcat-gf", + "detector_type": "gigafrost", + "n_modules": 8, + "bit_depth": 16, + "image_pixel_height": 2016, + "image_pixel_width": 2016, + "start_udp_port": 2000, + "writer_user_id": 18600, + "max_number_of_forwarders_spawned": 8, + "use_all_forwarders": true, + "module_sync_queue_size": 4096, + "number_of_writers": 12, + "module_positions": {}, + "ram_buffer_gb": 150, + "delay_filter_timeout": 10, + "live_stream_configs": { + "tcp://129.129.95.111:20000": { "type": "periodic", "config": [1, 5] }, + "tcp://129.129.95.111:20001": { "type": "periodic", "config": [1, 5] }, + "tcp://129.129.95.38:20000": { "type": "periodic", "config": [1, 1] } + } +} diff --git a/tomcat_bec/devices/gigafrost/gigafrost_base.py b/tomcat_bec/devices/gigafrost/gigafrost_base.py new file mode 100644 index 0000000..f44a92c --- /dev/null +++ b/tomcat_bec/devices/gigafrost/gigafrost_base.py @@ -0,0 +1,292 @@ +""" +This module contains the PV definitions for the Gigafrost camera at Tomcat. It +does not contain any logic to control the camera. +""" + +from ophyd import Component as Cpt +from ophyd import Device, DynamicDeviceComponent, EpicsSignal, EpicsSignalRO, Kind, Signal + +import tomcat_bec.devices.gigafrost.gfconstants as const + + +class GigaFrostSignalWithValidation(EpicsSignal): + """ + Custom EpicsSignal class that validates the value with the specified validator + before setting the value. + """ + + def __init__( + self, + read_pv, + write_pv=None, + *, + put_complete=False, + string=False, + limits=False, + name=None, + validator=None, + **kwargs, + ): + self._validator = validator + super().__init__( + read_pv, + write_pv, + put_complete=put_complete, + string=string, + limits=limits, + name=name, + **kwargs, + ) + + def check_value(self, value): + if self._validator is not None: + self._validator(value) + return super().check_value(value) + + +def check_image_width(value): + """ + The Gigafrost camera requires the image width to be a multiple of 48. + """ + if value % 48 != 0: + raise ValueError("Image width must be a multiple of 48") + + +def check_image_height(value): + """ + The Gigafrost camera requires the image height to be a multiple of 16. + """ + if value % 16 != 0: + raise ValueError("Image height must be a multiple of 16") + + +class GigaFrostBase(Device): + """Ophyd device class to control Gigafrost cameras at Tomcat + + The actual hardware is implemented by an IOC based on an old fork of Helge's + cameras. This means that the camera behaves differently than the SF cameras + in particular it provides even less feedback about it's internal progress. + Helge will update the GigaFrost IOC after working beamline. + The ophyd class is based on the 'gfclient' package and has a lot of Tomcat + specific additions. It does behave differently though, as ophyd swallows the + errors from failed PV writes. + + Parameters + ---------- + use_soft_enable : bool + Flag to use the camera's soft enable (default: False) + backend_url : str + Backend url address necessary to set up the camera's udp header. + (default: http://xbl-daq-23:8080) + + Bugs: + ---------- + FRAMERATE : Ignored in soft trigger mode, period becomes 2xExposure time + """ + + # pylint: disable=too-many-instance-attributes + + busy_stat = Cpt(EpicsSignalRO, "BUSY_STAT", auto_monitor=True) + sync_flag = Cpt(EpicsSignalRO, "SYNC_FLAG", auto_monitor=True) + sync_swhw = Cpt(EpicsSignal, "SYNC_SWHW.PROC", put_complete=True, kind=Kind.omitted) + start_cam = Cpt(EpicsSignal, "START_CAM", put_complete=True, kind=Kind.omitted) + set_param = Cpt(EpicsSignal, "SET_PARAM.PROC", put_complete=True, kind=Kind.omitted) + acqmode = Cpt(EpicsSignal, "ACQMODE", put_complete=True, kind=Kind.config) + + array_size = DynamicDeviceComponent( + { + "array_size_x": (EpicsSignalRO, "ROIX", {"auto_monitor": True}), + "array_size_y": (EpicsSignalRO, "ROIY", {"auto_monitor": True}), + }, + doc="Size of the array in the XY dimensions", + ) + + # UDP header + ports = Cpt(EpicsSignal, "PORTS", put_complete=True, kind=Kind.config) + framenum = Cpt(EpicsSignal, "FRAMENUM", put_complete=True, kind=Kind.config) + ht_offset = Cpt(EpicsSignal, "HT_OFFSET", put_complete=True, kind=Kind.config) + write_srv = Cpt(EpicsSignal, "WRITE_SRV.PROC", put_complete=True, kind=Kind.omitted) + + # Standard camera configs + exposure = Cpt(EpicsSignal, "EXPOSURE", put_complete=True, auto_monitor=True, kind=Kind.config) + framerate = Cpt( + EpicsSignal, "FRAMERATE", put_complete=True, auto_monitor=True, kind=Kind.config + ) + roix = Cpt( + GigaFrostSignalWithValidation, + "ROIX", + put_complete=True, + auto_monitor=True, + kind=Kind.config, + validator=check_image_width, + ) + roiy = Cpt( + GigaFrostSignalWithValidation, + "ROIY", + put_complete=True, + auto_monitor=True, + kind=Kind.config, + validator=check_image_height, + ) + scan_id = Cpt(EpicsSignal, "SCAN_ID", put_complete=True, auto_monitor=True, kind=Kind.config) + cnt_num = Cpt(EpicsSignal, "CNT_NUM", put_complete=True, auto_monitor=True, kind=Kind.config) + corr_mode = Cpt( + EpicsSignal, "CORR_MODE", put_complete=True, auto_monitor=True, kind=Kind.config + ) + + # Software signals + soft_enable = Cpt(EpicsSignal, "SOFT_ENABLE", put_complete=True) + soft_trig = Cpt(EpicsSignal, "SOFT_TRIG.PROC", put_complete=True, kind=Kind.omitted) + soft_exp = Cpt(EpicsSignal, "SOFT_EXP", put_complete=True) + + ############################################################################################### + # Enable schemes + # NOTE: 0 physical, 1 virtual (i.e. always running, but logs enable signal) + mode_enbl_exp = Cpt( + EpicsSignal, + "MODE_ENBL_EXP_RBV", + write_pv="MODE_ENBL_EXP", + put_complete=True, + kind=Kind.config, + ) + # Enable signals (combined by OR gate) + mode_enbl_ext = Cpt( + EpicsSignal, + "MODE_ENBL_EXT_RBV", + write_pv="MODE_ENBL_EXT", + put_complete=True, + kind=Kind.config, + ) + mode_endbl_soft = Cpt( + EpicsSignal, + "MODE_ENBL_SOFT_RBV", + write_pv="MODE_ENBL_SOFT", + put_complete=True, + kind=Kind.config, + ) + mode_enbl_auto = Cpt( + EpicsSignal, + "MODE_ENBL_AUTO_RBV", + write_pv="MODE_ENBL_AUTO", + put_complete=True, + kind=Kind.config, + ) + + ############################################################################################### + # Trigger modes + mode_trig_ext = Cpt( + EpicsSignal, + "MODE_TRIG_EXT_RBV", + write_pv="MODE_TRIG_EXT", + put_complete=True, + kind=Kind.config, + ) + mode_trig_soft = Cpt( + EpicsSignal, + "MODE_TRIG_SOFT_RBV", + write_pv="MODE_TRIG_SOFT", + put_complete=True, + kind=Kind.config, + ) + mode_trig_timer = Cpt( + EpicsSignal, + "MODE_TRIG_TIMER_RBV", + write_pv="MODE_TRIG_TIMER", + put_complete=True, + kind=Kind.config, + ) + mode_trig_auto = Cpt( + EpicsSignal, + "MODE_TRIG_AUTO_RBV", + write_pv="MODE_TRIG_AUTO", + put_complete=True, + kind=Kind.config, + ) + + ############################################################################################### + # Exposure modes + # NOTE: I.e.exposure time control, usually TIMER + mode_exp_ext = Cpt( + EpicsSignal, + "MODE_EXP_EXT_RBV", + write_pv="MODE_EXP_EXT", + put_complete=True, + kind=Kind.config, + ) + mode_exp_soft = Cpt( + EpicsSignal, + "MODE_EXP_SOFT_RBV", + write_pv="MODE_EXP_SOFT", + put_complete=True, + kind=Kind.config, + ) + mode_exp_timer = Cpt( + EpicsSignal, + "MODE_EXP_TIMER_RBV", + write_pv="MODE_EXP_TIMER", + put_complete=True, + kind=Kind.config, + ) + + ############################################################################################### + # Trigger configuration PVs + # NOTE: Theese PVs set the behavior on posedge and negedge of the trigger signal + cnt_startbit = Cpt( + EpicsSignal, + "CNT_STARTBIT_RBV", + write_pv="CNT_STARTBIT", + put_complete=True, + kind=Kind.config, + ) + cnt_endbit = Cpt( + EpicsSignal, "CNT_ENDBIT_RBV", write_pv="CNT_ENDBIT", put_complete=True, kind=Kind.config + ) + + # Line swap selection + ls_sw = Cpt(EpicsSignal, "LS_SW", put_complete=True, kind=Kind.config) + ls_nw = Cpt(EpicsSignal, "LS_NW", put_complete=True, kind=Kind.config) + ls_se = Cpt(EpicsSignal, "LS_SE", put_complete=True, kind=Kind.config) + ls_ne = Cpt(EpicsSignal, "LS_NE", put_complete=True, kind=Kind.config) + conn_parm = Cpt(EpicsSignal, "CONN_PARM", string=True, put_complete=True, kind=Kind.config) + + # HW settings as read only + pixrate = Cpt(EpicsSignalRO, "PIXRATE", auto_monitor=True, kind=Kind.config) + trig_delay = Cpt(EpicsSignalRO, "TRIG_DELAY", auto_monitor=True, kind=Kind.config) + syncout_dly = Cpt(EpicsSignalRO, "SYNCOUT_DLY", auto_monitor=True, kind=Kind.config) + bnc0_rbv = Cpt(EpicsSignalRO, "BNC0_RBV", auto_monitor=True, kind=Kind.config) + bnc1_rbv = Cpt(EpicsSignalRO, "BNC1_RBV", auto_monitor=True, kind=Kind.config) + bnc2_rbv = Cpt(EpicsSignalRO, "BNC2_RBV", auto_monitor=True, kind=Kind.config) + bnc3_rbv = Cpt(EpicsSignalRO, "BNC3_RBV", auto_monitor=True, kind=Kind.config) + bnc4_rbv = Cpt(EpicsSignalRO, "BNC4_RBV", auto_monitor=True, kind=Kind.config) + bnc5_rbv = Cpt(EpicsSignalRO, "BNC5_RBV", auto_monitor=True, kind=Kind.config) + t_board = Cpt(EpicsSignalRO, "T_BOARD", auto_monitor=True) + + auto_soft_enable = Cpt(Signal, kind=Kind.config) + backend_url = Cpt(Signal, kind=Kind.config) + mac_north = Cpt(Signal, kind=Kind.config) + mac_south = Cpt(Signal, kind=Kind.config) + ip_north = Cpt(Signal, kind=Kind.config) + ip_south = Cpt(Signal, kind=Kind.config) + + file_path = Cpt(Signal, kind=Kind.config, value="") + file_prefix = Cpt(Signal, kind=Kind.config, value="") + num_images = Cpt(Signal, kind=Kind.config, value=1) + + # pylint: disable=protected-access + def _define_backend_ip(self): + """Select backend IP address for UDP stream""" + if self.backend_url.get() == const.BE3_DAFL_CLIENT: # xbl-daq-33 + return const.BE3_NORTH_IP, const.BE3_SOUTH_IP + if self.backend_url.get() == const.BE999_DAFL_CLIENT: + return const.BE999_NORTH_IP, const.BE999_SOUTH_IP + + raise RuntimeError(f"Backend {self.backend_url.get()} not recognized.") + + def _define_backend_mac(self): + """Select backend MAC address for UDP stream""" + if self.backend_url.get() == const.BE3_DAFL_CLIENT: # xbl-daq-33 + return const.BE3_NORTH_MAC, const.BE3_SOUTH_MAC + if self.backend_url.get() == const.BE999_DAFL_CLIENT: + return const.BE999_NORTH_MAC, const.BE999_SOUTH_MAC + + raise RuntimeError(f"Backend {self.backend_url.get()} not recognized.") diff --git a/tomcat_bec/devices/gigafrost/gigafrostcamera.py b/tomcat_bec/devices/gigafrost/gigafrostcamera.py index 59ee53f..61dd312 100644 --- a/tomcat_bec/devices/gigafrost/gigafrostcamera.py +++ b/tomcat_bec/devices/gigafrost/gigafrostcamera.py @@ -7,236 +7,37 @@ Created on Thu Jun 27 17:28:43 2024 @author: mohacsi_i """ from time import sleep -from ophyd import Signal, SignalRO, Component, EpicsSignal, EpicsSignalRO, Kind, DeviceStatus -from ophyd.device import DynamicDeviceComponent -from ophyd_devices.interfaces.base_classes.psi_detector_base import ( - CustomDetectorMixin, - PSIDetectorBase, +from typing import Literal +import numpy as np +from bec_lib.logger import bec_logger +from ophyd import DeviceStatus +from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase + +import tomcat_bec.devices.gigafrost.gfconstants as const +from tomcat_bec.devices.gigafrost.gfutils import extend_header_table +from tomcat_bec.devices.gigafrost.gigafrost_base import GigaFrostBase +from tomcat_bec.devices.gigafrost.std_daq_client import ( + StdDaqClient, + StdDaqConfigPartial, + StdDaqStatus, ) -try: - import gfconstants as const -except ModuleNotFoundError: - import tomcat_bec.devices.gigafrost.gfconstants as const +from tomcat_bec.devices.gigafrost.std_daq_preview import StdDaqPreview -try: - from gfutils import extend_header_table -except ModuleNotFoundError: - from tomcat_bec.devices.gigafrost.gfutils import extend_header_table - -try: - from bec_lib import bec_logger - - logger = bec_logger.logger -except ModuleNotFoundError: - import logging - - logger = logging.getLogger("GfCam") +logger = bec_logger.logger -class GigaFrostCameraMixin(CustomDetectorMixin): - """Mixin class to setup TOMCAT specific implementations of the detector. - - This class will be called by the custom_prepare_cls attribute of the - detector class. - """ - # pylint: disable=protected-access - def _define_backend_ip(self): - """Select backend IP address for UDP stream""" - if self.parent.backendUrl.get() == const.BE3_DAFL_CLIENT: # xbl-daq-33 - return const.BE3_NORTH_IP, const.BE3_SOUTH_IP - if self.parent.backendUrl.get() == const.BE999_DAFL_CLIENT: - return const.BE999_NORTH_IP, const.BE999_SOUTH_IP - - raise RuntimeError(f"Backend {self.parent.backendUrl.get()} not recognized.") - - def _define_backend_mac(self): - """Select backend MAC address for UDP stream""" - if self.parent.backendUrl.get() == const.BE3_DAFL_CLIENT: # xbl-daq-33 - return const.BE3_NORTH_MAC, const.BE3_SOUTH_MAC - if self.parent.backendUrl.get() == const.BE999_DAFL_CLIENT: - return const.BE999_NORTH_MAC, const.BE999_SOUTH_MAC - - raise RuntimeError(f"Backend {self.parent.backendUrl.get()} not recognized.") - - def _set_udp_header_table(self): - """Set the communication parameters for the camera module""" - self.parent.cfgConnectionParam.set(self._build_udp_header_table()).wait() - - def _build_udp_header_table(self): - """Build the header table for the UDP communication""" - udp_header_table = [] - - for i in range(0, 64, 1): - for j in range(0, 8, 1): - dest_port = 2000 + 8 * i + j - source_port = 3000 + j - if j < 4: - extend_header_table( - udp_header_table, - self.parent.macSouth.get(), - self.parent.ipSouth.get(), - dest_port, - source_port, - ) - else: - extend_header_table( - udp_header_table, - self.parent.macNorth.get(), - self.parent.ipNorth.get(), - dest_port, - source_port, - ) - - return udp_header_table - - def on_init(self) -> None: - """Initialize the camera, set channel values""" - # ToDo: Not sure if it's a good idea to change camera settings upon - # ophyd device startup, i.e. each deviceserver restart. - self._init_gigafrost() - self.parent._initialized = True - - def _init_gigafrost(self) -> None: - """Initialize the camera, set channel values""" - # Stop acquisition - self.parent.cmdStartCamera.set(0).wait() - - # set entry to UDP table - # number of UDP ports to use - self.parent.cfgUdpNumPorts.set(2).wait() - # number of images to send to each UDP port before switching to next - self.parent.cfgUdpNumFrames.set(5).wait() - # offset in UDP table - where to find the first entry - self.parent.cfgUdpHtOffset.set(0).wait() - # activate changes - self.parent.cmdWriteService.set(1).wait() - - # Configure software triggering if needed - if self.parent.autoSoftEnable.get(): - # trigger modes - self.parent.cfgCntStartBit.set(1).wait() - self.parent.cfgCntEndBit.set(0).wait() - - # set modes - self.parent.enable_mode = "soft" - self.parent.trigger_mode = "auto" - self.parent.exposure_mode = "timer" - - # line swap - on for west, off for east - self.parent.cfgLineSwapSW.set(1).wait() - self.parent.cfgLineSwapNW.set(1).wait() - self.parent.cfgLineSwapSE.set(0).wait() - self.parent.cfgLineSwapNE.set(0).wait() - - # Commit parameters - self.parent.cmdSetParam.set(1).wait() - - # Initialize data backend - n, s = self._define_backend_ip() - self.parent.ipNorth.put(n, force=True) - self.parent.ipSouth.put(s, force=True) - n, s = self._define_backend_mac() - self.parent.macNorth.put(n, force=True) - self.parent.macSouth.put(s, force=True) - # Set udp header table - self._set_udp_header_table() - - return super().on_init() - - def on_stage(self) -> None: - """Configuration and staging - - In the BEC model ophyd devices must fish out their own configuration from the 'scaninfo'. - I.e. they need to know which parameters are relevant for them at each scan. - - NOTE: Tomcat might use multiple cameras. - """ - # Gigafrost can finish a run without explicit unstaging - if self.parent.infoBusyFlag.value: - logger.warning("Camera is already running, unstaging it first!") - self.parent.unstage() - sleep(0.5) - if not self.parent._initialized: - logger.warning( - f"[{self.parent.name}] Ophyd device havent ran the initialization sequence," - "IOC might be in unknown configuration." - ) - - # Fish out our configuration from scaninfo (via explicit or generic addressing) - scanparam = self.parent.scaninfo.scan_msg.info - d = {} - if "kwargs" in scanparam: - scanargs = scanparam["kwargs"] - if "image_width" in scanargs and scanargs["image_width"] is not None: - d["image_width"] = scanargs["image_width"] - if "image_height" in scanargs and scanargs["image_height"] is not None: - d["image_height"] = scanargs["image_height"] - if "exp_time" in scanargs and scanargs["exp_time"] is not None: - d["exposure_time_ms"] = scanargs["exp_time"] - if "exp_burst" in scanargs and scanargs["exp_burst"] is not None: - d["exposure_num_burst"] = scanargs["exp_burst"] - if "acq_mode" in scanargs and scanargs["acq_mode"] is not None: - d["acq_mode"] = scanargs["acq_mode"] - # elif self.parent.scaninfo.scan_type == "step": - # d['acq_mode'] = "default" - - # Perform bluesky-style configuration - if len(d) > 0: - logger.warning(f"[{self.parent.name}] Configuring with:\n{d}") - self.parent.configure(d=d) - - # Sync if out of sync - if self.parent.infoSyncFlag.value == 0: - self.parent.cmdSyncHw.set(1).wait() - # Switch to acquiring - self.parent.bluestage() - - def on_unstage(self) -> None: - """Specify actions to be executed during unstage. - - This step should include checking if the acquisition was successful, - and publishing the file location and file event message, - with flagged done to BEC. - """ - # Switch to idle - self.parent.cmdStartCamera.set(0).wait() - if self.parent.autoSoftEnable.get(): - self.parent.cmdSoftEnable.set(0).wait() - - def on_stop(self) -> None: - """ - Specify actions to be executed during stop. - This must also set self.parent.stopped to True. - - This step should include stopping the detector and backend service. - """ - return self.on_unstage() - - def on_trigger(self) -> None | DeviceStatus: - """ - Specify actions to be executed upon receiving trigger signal. - Return a DeviceStatus object or None - """ - if self.parent.infoBusyFlag.get() in (0, "IDLE"): - raise RuntimeError("GigaFrost must be running before triggering") - - logger.warning(f"[{self.parent.name}] SW triggering gigafrost") - - # Soft triggering based on operation mode - if ( - self.parent.autoSoftEnable.get() - and self.parent.trigger_mode == "auto" - and self.parent.enable_mode == "soft" - ): - # BEC teststand operation mode: posedge of SoftEnable if Started - self.parent.cmdSoftEnable.set(0).wait() - self.parent.cmdSoftEnable.set(1).wait() - else: - self.parent.cmdSoftTrigger.set(1).wait() +""" +TBD: +- Why is mode_enbl_exp not set during the enable_mode setter, only in the set_acquisition_mode? +- Why is set_acquisition_mode a method and not a property? +- When is a call to set_param necessary? +- Which access pattern is more common, setting the signal directly or using the method / property? + If the latter, we may want to change the inheritance structure to 'hide' the signals in a sub-component. +""" -class GigaFrostCamera(PSIDetectorBase): +class GigaFrostCamera(PSIDeviceBase, GigaFrostBase): """Ophyd device class to control Gigafrost cameras at Tomcat The actual hardware is implemented by an IOC based on an old fork of Helge's @@ -261,190 +62,17 @@ class GigaFrostCamera(PSIDetectorBase): """ # pylint: disable=too-many-instance-attributes - - custom_prepare_cls = GigaFrostCameraMixin - USER_ACCESS = ["initialize"] + USER_ACCESS = [ + "exposure_mode", + "fix_nframes_mode", + "trigger_mode", + "enable_mode", + "backend", + "acq_done", + "live_preview" + ] _initialized = False - infoBusyFlag = Component(EpicsSignalRO, "BUSY_STAT", auto_monitor=True) - infoSyncFlag = Component(EpicsSignalRO, "SYNC_FLAG", auto_monitor=True) - cmdSyncHw = Component(EpicsSignal, "SYNC_SWHW.PROC", put_complete=True, kind=Kind.omitted) - cmdStartCamera = Component(EpicsSignal, "START_CAM", put_complete=True, kind=Kind.omitted) - cmdSetParam = Component(EpicsSignal, "SET_PARAM.PROC", put_complete=True, kind=Kind.omitted) - cfgAcqMode = Component(EpicsSignal, "ACQMODE", put_complete=True, kind=Kind.config) - - array_size = DynamicDeviceComponent( - { - "array_size_x": (EpicsSignalRO, "ROIX", {"auto_monitor": True}), - "array_size_y": (EpicsSignalRO, "ROIY", {"auto_monitor": True}), - }, - doc="Size of the array in the XY dimensions", - ) - - # UDP header - cfgUdpNumPorts = Component(EpicsSignal, "PORTS", put_complete=True, kind=Kind.config) - cfgUdpNumFrames = Component(EpicsSignal, "FRAMENUM", put_complete=True, kind=Kind.config) - cfgUdpHtOffset = Component(EpicsSignal, "HT_OFFSET", put_complete=True, kind=Kind.config) - cmdWriteService = Component(EpicsSignal, "WRITE_SRV.PROC", put_complete=True, kind=Kind.omitted) - - # Standard camera configs - cfgExposure = Component( - EpicsSignal, "EXPOSURE", put_complete=True, auto_monitor=True, kind=Kind.config - ) - cfgFramerate = Component( - EpicsSignal, "FRAMERATE", put_complete=True, auto_monitor=True, kind=Kind.config - ) - cfgRoiX = Component(EpicsSignal, "ROIX", put_complete=True, auto_monitor=True, kind=Kind.config) - cfgRoiY = Component(EpicsSignal, "ROIY", put_complete=True, auto_monitor=True, kind=Kind.config) - cfgScanId = Component( - EpicsSignal, "SCAN_ID", put_complete=True, auto_monitor=True, kind=Kind.config - ) - cfgCntNum = Component( - EpicsSignal, "CNT_NUM", put_complete=True, auto_monitor=True, kind=Kind.config - ) - cfgCorrMode = Component( - EpicsSignal, "CORR_MODE", put_complete=True, auto_monitor=True, kind=Kind.config - ) - - # Software signals - cmdSoftEnable = Component(EpicsSignal, "SOFT_ENABLE", put_complete=True) - cmdSoftTrigger = Component(EpicsSignal, "SOFT_TRIG.PROC", put_complete=True, kind=Kind.omitted) - cmdSoftExposure = Component(EpicsSignal, "SOFT_EXP", put_complete=True) - cfgAcqMode = Component(EpicsSignal, "ACQMODE", put_complete=True, kind=Kind.config) - - ############################################################################################### - # Enable schemes - # NOTE: 0 physical, 1 virtual (i.e. always running, but logs enable signal) - cfgEnableScheme = Component( - EpicsSignal, - "MODE_ENBL_EXP_RBV", - write_pv="MODE_ENBL_EXP", - put_complete=True, - kind=Kind.config, - ) - # Enable signals (combined by OR gate) - cfgEnableExt = Component( - EpicsSignal, - "MODE_ENBL_EXT_RBV", - write_pv="MODE_ENBL_EXT", - put_complete=True, - kind=Kind.config, - ) - cfgEnableSoft = Component( - EpicsSignal, - "MODE_ENBL_SOFT_RBV", - write_pv="MODE_ENBL_SOFT", - put_complete=True, - kind=Kind.config, - ) - cfgEnableAlways = Component( - EpicsSignal, - "MODE_ENBL_AUTO_RBV", - write_pv="MODE_ENBL_AUTO", - put_complete=True, - kind=Kind.config, - ) - - ############################################################################################### - # Trigger modes - cfgTrigExt = Component( - EpicsSignal, - "MODE_TRIG_EXT_RBV", - write_pv="MODE_TRIG_EXT", - put_complete=True, - kind=Kind.config, - ) - cfgTrigSoft = Component( - EpicsSignal, - "MODE_TRIG_SOFT_RBV", - write_pv="MODE_TRIG_SOFT", - put_complete=True, - kind=Kind.config, - ) - cfgTrigTimer = Component( - EpicsSignal, - "MODE_TRIG_TIMER_RBV", - write_pv="MODE_TRIG_TIMER", - put_complete=True, - kind=Kind.config, - ) - cfgTrigAuto = Component( - EpicsSignal, - "MODE_TRIG_AUTO_RBV", - write_pv="MODE_TRIG_AUTO", - put_complete=True, - kind=Kind.config, - ) - - ############################################################################################### - # Exposure modes - # NOTE: I.e.exposure time control, usually TIMER - cfgExpExt = Component( - EpicsSignal, - "MODE_EXP_EXT_RBV", - write_pv="MODE_EXP_EXT", - put_complete=True, - kind=Kind.config, - ) - cfgExpSoft = Component( - EpicsSignal, - "MODE_EXP_SOFT_RBV", - write_pv="MODE_EXP_SOFT", - put_complete=True, - kind=Kind.config, - ) - cfgExpTimer = Component( - EpicsSignal, - "MODE_EXP_TIMER_RBV", - write_pv="MODE_EXP_TIMER", - put_complete=True, - kind=Kind.config, - ) - - ############################################################################################### - # Trigger configuration PVs - # NOTE: Theese PVs set the behavior on posedge and negedge of the trigger signal - cfgCntStartBit = Component( - EpicsSignal, - "CNT_STARTBIT_RBV", - write_pv="CNT_STARTBIT", - put_complete=True, - kind=Kind.config, - ) - cfgCntEndBit = Component( - EpicsSignal, "CNT_ENDBIT_RBV", write_pv="CNT_ENDBIT", put_complete=True, kind=Kind.config - ) - - # Line swap selection - cfgLineSwapSW = Component(EpicsSignal, "LS_SW", put_complete=True, kind=Kind.config) - cfgLineSwapNW = Component(EpicsSignal, "LS_NW", put_complete=True, kind=Kind.config) - cfgLineSwapSE = Component(EpicsSignal, "LS_SE", put_complete=True, kind=Kind.config) - cfgLineSwapNE = Component(EpicsSignal, "LS_NE", put_complete=True, kind=Kind.config) - cfgConnectionParam = Component( - EpicsSignal, "CONN_PARM", string=True, put_complete=True, kind=Kind.config - ) - - # HW settings as read only - cfgSyncFlag = Component(EpicsSignalRO, "PIXRATE", auto_monitor=True, kind=Kind.config) - cfgTrigDelay = Component(EpicsSignalRO, "TRIG_DELAY", auto_monitor=True, kind=Kind.config) - cfgSyncoutDelay = Component(EpicsSignalRO, "SYNCOUT_DLY", auto_monitor=True, kind=Kind.config) - cfgOutputPolarity0 = Component(EpicsSignalRO, "BNC0_RBV", auto_monitor=True, kind=Kind.config) - cfgOutputPolarity1 = Component(EpicsSignalRO, "BNC1_RBV", auto_monitor=True, kind=Kind.config) - cfgOutputPolarity2 = Component(EpicsSignalRO, "BNC2_RBV", auto_monitor=True, kind=Kind.config) - cfgOutputPolarity3 = Component(EpicsSignalRO, "BNC3_RBV", auto_monitor=True, kind=Kind.config) - cfgInputPolarity1 = Component(EpicsSignalRO, "BNC4_RBV", auto_monitor=True, kind=Kind.config) - cfgInputPolarity2 = Component(EpicsSignalRO, "BNC5_RBV", auto_monitor=True, kind=Kind.config) - infoBoardTemp = Component(EpicsSignalRO, "T_BOARD", auto_monitor=True) - - USER_ACCESS = ["exposure_mode", "fix_nframes_mode", "trigger_mode", "enable_mode", "initialize"] - - autoSoftEnable = Component(Signal, kind=Kind.config) - backendUrl = Component(Signal, kind=Kind.config) - macNorth = Component(Signal, kind=Kind.config) - macSouth = Component(Signal, kind=Kind.config) - ipNorth = Component(Signal, kind=Kind.config) - ipSouth = Component(Signal, kind=Kind.config) - def __init__( self, prefix="", @@ -454,10 +82,12 @@ class GigaFrostCamera(PSIDetectorBase): read_attrs=None, configuration_attrs=None, parent=None, - device_manager=None, - sim_mode=False, + scan_info=None, auto_soft_enable=False, backend_url=const.BE999_DAFL_CLIENT, + std_daq_rest: str | None = None, + std_daq_ws: str | None = None, + std_daq_live: str | None = None, **kwargs, ): # Ugly hack to pass values before on_init() @@ -473,32 +103,16 @@ class GigaFrostCamera(PSIDetectorBase): read_attrs=read_attrs, configuration_attrs=configuration_attrs, parent=parent, - device_manager=device_manager, + scan_info=scan_info, **kwargs, ) + if std_daq_rest is None or std_daq_ws is None: + raise ValueError("Both std_daq_rest and std_daq_ws must be provided") + self.backend = StdDaqClient(parent=self, ws_url=std_daq_ws, rest_url=std_daq_rest) + self.live_preview = None + if std_daq_live is not None: + self.live_preview = StdDaqPreview(url=std_daq_live, cb=self._on_preview_update) - def _init(self): - """Ugly hack: values must be set before on_init() is called""" - # Additional parameters - self.autoSoftEnable._metadata["write_access"] = False - self.backendUrl._metadata["write_access"] = False - self.autoSoftEnable.put(self._signals_to_be_set["auto_soft_enable"], force=True) - self.backendUrl.put(self._signals_to_be_set["backend_url"], force=True) - return super()._init() - - def initialize(self): - """Initialization in separate command""" - self.custom_prepare._init_gigafrost() - self._initialized = True - - def trigger(self) -> DeviceStatus: - """Sends a software trigger to GigaFrost""" - super().trigger() - - # There's no status readback from the camera, so we just wait - sleep_time = self.cfgExposure.value * self.cfgCntNum.value * 0.001 + 0.2 - sleep(sleep_time) - return DeviceStatus(self, done=True, success=True, settle_time=sleep_time) def configure(self, d: dict = None): """Configure the next scan with the GigaFRoST camera @@ -534,43 +148,33 @@ class GigaFrostCamera(PSIDetectorBase): Select one of the pre-configured trigger behavior """ # Stop acquisition - self.unstage() - if not self._initialized: - pass + self.set_idle() - # If Bluesky style configure - if d is not None: - # Commonly changed settings - if "exposure_num_burst" in d: - self.cfgCntNum.set(d["exposure_num_burst"]).wait() - if "exposure_time_ms" in d: - self.cfgExposure.set(d["exposure_time_ms"]).wait() - if "exposure_period_ms" in d: - self.cfgFramerate.set(d["exposure_period_ms"]).wait() - if "image_width" in d: - if d["image_width"] % 48 != 0: - raise RuntimeError(f"[{self.name}] image_width must be divisible by 48") - self.cfgRoiX.set(d["image_width"]).wait() - if "image_height" in d: - if d["image_height"] % 16 != 0: - raise RuntimeError(f"[{self.name}] image_height must be divisible by 16") - self.cfgRoiY.set(d["image_height"]).wait() - # Dont change these - scanid = d.get("scanid", 0) - correction_mode = d.get("correction_mode", 5) - self.cfgScanId.set(scanid).wait() - self.cfgCorrMode.set(correction_mode).wait() + backend_config = StdDaqConfigPartial(**d) + self.backend.update_config(backend_config) - if "acq_mode" in d: - self.set_acquisition_mode(d["acq_mode"]) + # Update all specified ophyd signals + config = {} + for key in self.component_names: + val = d.get(key) + if val is not None: + config[key] = val + + if d.get("exp_time", 0) > 0: + config["exposure"] = d["exp_time"] * 1000 # exposure time in ms + + if "corr_mode" not in config: + config["corr_mode"] = 5 + if "scan_id" not in config: + config["scan_id"] = 0 + super().configure(config) + + # If the acquisition mode is specified, set it + if "acq_mode" in d: + self.set_acquisition_mode(config["acq_mode"]) # Commit parameters - self.cmdSetParam.set(1).wait() - - def bluestage(self): - """Bluesky style stage""" - # Switch to acquiring - self.cmdStartCamera.set(1).wait() + self.set_param.set(1).wait() def set_acquisition_mode(self, acq_mode): """Set acquisition mode @@ -583,66 +187,51 @@ class GigaFrostCamera(PSIDetectorBase): """ if acq_mode == "default": + # NOTE: Trigger using software events via softEnable (actually works) # Trigger parameters - self.cfgCntStartBit.set(1).wait() - self.cfgCntEndBit.set(0).wait() + self.fix_nframes_mode = "start" # Switch to physical enable signal - self.cfgEnableScheme.set(0).wait() + self.mode_enbl_exp.set(0).wait() - # Set modes - # self.cmdSoftEnable.set(0).wait() + # self.soft_enable.set(0).wait() self.enable_mode = "soft" self.trigger_mode = "auto" self.exposure_mode = "timer" elif acq_mode in ["ext_enable", "external_enable"]: + # NOTE: Trigger using external hardware events via enable input (actually works) # Switch to physical enable signal - self.cfgEnableScheme.set(0).wait() + self.mode_enbl_exp.set(0).wait() # Trigger modes - self.cfgCntStartBit.set(1).wait() - self.cfgCntEndBit.set(0).wait() + self.fix_nframes_mode = "start" # Set modes self.enable_mode = "external" self.trigger_mode = "auto" self.exposure_mode = "timer" elif acq_mode == "soft": + # NOTE: Fede's configuration for continous streaming # Switch to physical enable signal - self.cfgEnableScheme.set(0).wait() + self.mode_enbl_exp.set(0).wait() # Set enable signal to always - self.cfgEnableExt.set(0).wait() - self.cfgEnableSoft.set(1).wait() - self.cfgEnableAlways.set(1).wait() + self.enable_mode = "always" # Set trigger mode to software - self.cfgTrigExt.set(0).wait() - self.cfgTrigSoft.set(1).wait() - self.cfgTrigTimer.set(1).wait() - self.cfgTrigAuto.set(0).wait() + self.trigger_mode = "soft" # Set exposure mode to timer - self.cfgExpExt.set(0).wait() - self.cfgExpSoft.set(0).wait() - self.cfgExpTimer.set(1).wait() + self.exposure_mode = "timer" # Set trigger edge to fixed frames on posedge - self.cfgCntStartBit.set(1).wait() - self.cfgCntEndBit.set(0).wait() + self.fix_nframes_mode = "start" elif acq_mode in ["ext", "external"]: + # NOTE: Untested # Switch to physical enable signal - self.cfgEnableScheme.set(0).wait() + self.mode_enbl_exp.set(0).wait() # Set enable signal to always - self.cfgEnableExt.set(0).wait() - self.cfgEnableSoft.set(0).wait() - self.cfgEnableAlways.set(1).wait() + self.enable_mode = "always" # Set trigger mode to external - self.cfgTrigExt.set(1).wait() - self.cfgTrigSoft.set(0).wait() - self.cfgTrigTimer.set(0).wait() - self.cfgTrigAuto.set(0).wait() + self.trigger_mode = "external" # Set exposure mode to timer - self.cfgExpExt.set(0).wait() - self.cfgExpSoft.set(0).wait() - self.cfgExpTimer.set(1).wait() + self.exposure_mode = "timer" # Set trigger edge to fixed frames on posedge - self.cfgCntStartBit.set(1).wait() - self.cfgCntEndBit.set(0).wait() + self.fix_nframes_mode = "start" else: raise RuntimeError(f"Unsupported acquisition mode: {acq_mode}") @@ -656,9 +245,9 @@ class GigaFrostCamera(PSIDetectorBase): The camera's active exposure mode. If more than one mode is active at the same time, it returns None. """ - mode_soft = self.cfgExpSoft.get() - mode_timer = self.cfgExpTimer.get() - mode_external = self.cfgExpExt.get() + mode_soft = self.mode_exp_soft.get() + mode_timer = self.mode_exp_timer.get() + mode_external = self.mode_exp_ext.get() if mode_soft and not mode_timer and not mode_external: return "soft" if not mode_soft and mode_timer and not mode_external: @@ -677,28 +266,27 @@ class GigaFrostCamera(PSIDetectorBase): exp_mode : {'external', 'timer', 'soft'} The exposure mode to be set. """ - if exp_mode == "external": - self.cfgExpExt.set(1).wait() - self.cfgExpSoft.set(0).wait() - self.cfgExpTimer.set(0).wait() - elif exp_mode == "timer": - self.cfgExpExt.set(0).wait() - self.cfgExpSoft.set(0).wait() - self.cfgExpTimer.set(1).wait() - elif exp_mode == "soft": - self.cfgExpExt.set(0).wait() - self.cfgExpSoft.set(1).wait() - self.cfgExpTimer.set(0).wait() - else: + + modes = { + "external": self.mode_exp_ext, + "timer": self.mode_exp_timer, + "soft": self.mode_exp_soft, + } + + if exp_mode not in const.gf_valid_exposure_modes: raise ValueError( f"Invalid exposure mode! Valid modes are:\n{const.gf_valid_exposure_modes}" ) + for key, attr in modes.items(): + # set the desired mode to 1, all others to 0 + attr.set(int(key == exp_mode)).wait() + # Commit parameters - self.cmdSetParam.set(1).wait() + self.set_param.set(1).wait() @property - def fix_nframes_mode(self): + def fix_nframes_mode(self) -> Literal["off", "start", "end", "start+end"] | None: """Return the current fixed number of frames mode of the GigaFRoST camera. Returns @@ -706,8 +294,8 @@ class GigaFrostCamera(PSIDetectorBase): fix_nframes_mode : {'off', 'start', 'end', 'start+end'} The camera's active fixed number of frames mode. """ - start_bit = self.cfgCntStartBit.get() - end_bit = self.cfgCntStartBit.get() + start_bit = self.cnt_startbit.get() + end_bit = self.cnt_startbit.get() if not start_bit and not end_bit: return "off" @@ -721,7 +309,7 @@ class GigaFrostCamera(PSIDetectorBase): return None @fix_nframes_mode.setter - def fix_nframes_mode(self, mode): + def fix_nframes_mode(self, mode: Literal["off", "start", "end", "start+end"]): """Apply the fixed number of frames settings to the GigaFRoST camera. Parameters @@ -731,27 +319,27 @@ class GigaFrostCamera(PSIDetectorBase): """ self._fix_nframes_mode = mode if self._fix_nframes_mode == "off": - self.cfgCntStartBit.set(0).wait() - self.cfgCntEndBit.set(0).wait() + self.cnt_startbit.set(0).wait() + self.cnt_endbit.set(0).wait() elif self._fix_nframes_mode == "start": - self.cfgCntStartBit.set(1).wait() - self.cfgCntEndBit.set(0).wait() + self.cnt_startbit.set(1).wait() + self.cnt_endbit.set(0).wait() elif self._fix_nframes_mode == "end": - self.cfgCntStartBit.set(0).wait() - self.cfgCntEndBit.set(1).wait() + self.cnt_startbit.set(0).wait() + self.cnt_endbit.set(1).wait() elif self._fix_nframes_mode == "start+end": - self.cfgCntStartBit.set(1).wait() - self.cfgCntEndBit.set(1).wait() + self.cnt_startbit.set(1).wait() + self.cnt_endbit.set(1).wait() else: raise ValueError( f"Invalid fixed frame number mode! Valid modes are: {const.gf_valid_fix_nframe_modes}" ) # Commit parameters - self.cmdSetParam.set(1).wait() + self.set_param.set(1).wait() @property - def trigger_mode(self): + def trigger_mode(self) -> Literal["auto", "external", "timer", "soft"] | None: """Method to detect the current trigger mode set in the GigaFRost camera. Returns @@ -760,10 +348,10 @@ class GigaFrostCamera(PSIDetectorBase): The camera's active trigger mode. If more than one mode is active at the moment, None is returned. """ - mode_auto = self.cfgTrigAuto.get() - mode_external = self.cfgTrigExt.get() - mode_timer = self.cfgTrigTimer.get() - mode_soft = self.cfgTrigSoft.get() + mode_auto = self.mode_trig_auto.get() + mode_external = self.mode_trig_ext.get() + mode_timer = self.mode_trig_timer.get() + mode_soft = self.mode_trig_soft.get() if mode_auto: return "auto" if mode_soft: @@ -776,44 +364,34 @@ class GigaFrostCamera(PSIDetectorBase): return None @trigger_mode.setter - def trigger_mode(self, mode): - """Set the trigger mode for the GigaFRoST camera. - - Parameters - ---------- - mode : {'auto', 'external', 'timer', 'soft'} - The GigaFRoST trigger mode. + def trigger_mode(self, mode: Literal["auto", "external", "timer", "soft"]): """ - if mode == "auto": - self.cfgTrigAuto.set(1).wait() - self.cfgTrigSoft.set(0).wait() - self.cfgTrigTimer.set(0).wait() - self.cfgTrigExt.set(0).wait() - elif mode == "external": - self.cfgTrigAuto.set(0).wait() - self.cfgTrigSoft.set(0).wait() - self.cfgTrigTimer.set(0).wait() - self.cfgTrigExt.set(1).wait() - elif mode == "timer": - self.cfgTrigAuto.set(0).wait() - self.cfgTrigSoft.set(0).wait() - self.cfgTrigTimer.set(1).wait() - self.cfgTrigExt.set(0).wait() - elif mode == "soft": - self.cfgTrigAuto.set(0).wait() - self.cfgTrigSoft.set(1).wait() - self.cfgTrigTimer.set(0).wait() - self.cfgTrigExt.set(0).wait() - else: + Set the trigger mode for the GigaFRoST camera. + + Args: + mode(str): The trigger mode to be set. Valid arguments are: ['auto', 'external', 'timer', 'soft'] + """ + modes = { + "auto": self.mode_trig_auto, + "soft": self.mode_trig_soft, + "timer": self.mode_trig_timer, + "external": self.mode_trig_ext, + } + + if mode not in modes: raise ValueError( - "Invalid trigger mode! Valid modes are:\n{const.gf_valid_trigger_modes}" + "Invalid trigger mode! Valid modes are: ['auto', 'external', 'timer', 'soft']" ) + for key, attr in modes.items(): + # set the desired mode to 1, all others to 0 + attr.set(int(key == mode)).wait() + # Commit parameters - self.cmdSetParam.set(1).wait() + self.set_param.set(1).wait() @property - def enable_mode(self): + def enable_mode(self) -> Literal["soft", "external", "soft+ext", "always"] | None: """Return the enable mode set in the GigaFRoST camera. Returns @@ -821,22 +399,21 @@ class GigaFrostCamera(PSIDetectorBase): enable_mode: {'soft', 'external', 'soft+ext', 'always'} The camera's active enable mode. """ - mode_soft = self.cfgEnableSoft.get() - mode_external = self.cfgEnableExt.get() - mode_always = self.cfgEnableAlways.get() + mode_soft = self.mode_endbl_soft.get() + mode_external = self.mode_enbl_ext.get() + mode_always = self.mode_enbl_auto.get() if mode_always: return "always" - elif mode_soft and mode_external: + if mode_soft and mode_external: return "soft+ext" - elif mode_soft and not mode_external: + if mode_soft and not mode_external: return "soft" - elif mode_external and not mode_soft: + if mode_external and not mode_soft: return "external" - else: - return None + return None @enable_mode.setter - def enable_mode(self, mode): + def enable_mode(self, mode: Literal["soft", "external", "soft+ext", "always"]): """ Set the enable mode for the GigaFRoST camera. @@ -865,26 +442,244 @@ class GigaFrostCamera(PSIDetectorBase): """ if mode not in const.gf_valid_enable_modes: - raise ValueError("Invalid enable mode! Valid modes are:\n{const.gf_valid_enable_modes}") + raise ValueError( + f"Invalid enable mode {mode}! Valid modes are:\n{const.gf_valid_enable_modes}" + ) if mode == "soft": - self.cfgEnableExt.set(0).wait() - self.cfgEnableSoft.set(1).wait() - self.cfgEnableAlways.set(0).wait() + self.mode_enbl_ext.set(0).wait() + self.mode_endbl_soft.set(1).wait() + self.mode_enbl_auto.set(0).wait() elif mode == "external": - self.cfgEnableExt.set(1).wait() - self.cfgEnableSoft.set(0).wait() - self.cfgEnableAlways.set(0).wait() + self.mode_enbl_ext.set(1).wait() + self.mode_endbl_soft.set(0).wait() + self.mode_enbl_auto.set(0).wait() elif mode == "soft+ext": - self.cfgEnableExt.set(1).wait() - self.cfgEnableSoft.set(1).wait() - self.cfgEnableAlways.set(0).wait() + self.mode_enbl_ext.set(1).wait() + self.mode_endbl_soft.set(1).wait() + self.mode_enbl_auto.set(0).wait() elif mode == "always": - self.cfgEnableExt.set(0).wait() - self.cfgEnableSoft.set(0).wait() - self.cfgEnableAlways.set(1).wait() + self.mode_enbl_ext.set(0).wait() + self.mode_endbl_soft.set(0).wait() + self.mode_enbl_auto.set(1).wait() # Commit parameters - self.cmdSetParam.set(1).wait() + self.set_param.set(1).wait() + + def set_idle(self): + """Set the camera to idle state""" + self.start_cam.set(0).wait() + if self.auto_soft_enable.get(): + self.soft_enable.set(0).wait() + + def initialize_gigafrost(self) -> None: + """Initialize the camera, set channel values""" + # Stop acquisition + self.start_cam.set(0).wait() + + # set entry to UDP table + # number of UDP ports to use + self.ports.set(2).wait() + # number of images to send to each UDP port before switching to next + self.framenum.set(5).wait() + # offset in UDP table - where to find the first entry + self.ht_offset.set(0).wait() + # activate changes + self.write_srv.set(1).wait() + + # Configure software triggering if needed + if self.auto_soft_enable.get(): + # trigger modes + self.cnt_startbit.set(1).wait() + self.cnt_endbit.set(0).wait() + + # set modes + self.enable_mode = "soft" + self.trigger_mode = "auto" + self.exposure_mode = "timer" + + # line swap - on for west, off for east + self.ls_sw.set(1).wait() + self.ls_nw.set(1).wait() + self.ls_se.set(0).wait() + self.ls_ne.set(0).wait() + + # Commit parameters + self.set_param.set(1).wait() + + # Initialize data backend + n, s = self._define_backend_ip() + self.ip_north.put(n, force=True) + self.ip_south.put(s, force=True) + n, s = self._define_backend_mac() + self.mac_north.put(n, force=True) + self.mac_south.put(s, force=True) + # Set udp header table + self.set_udp_header_table() + + def set_udp_header_table(self): + """Set the communication parameters for the camera module""" + self.conn_parm.set(self._build_udp_header_table()).wait() + + def destroy(self): + self.backend.shutdown() + super().destroy() + + def _build_udp_header_table(self): + """Build the header table for the UDP communication""" + udp_header_table = [] + + for i in range(0, 64, 1): + for j in range(0, 8, 1): + dest_port = 2000 + 8 * i + j + source_port = 3000 + j + if j < 4: + extend_header_table( + udp_header_table, + self.mac_south.get(), + self.ip_south.get(), + dest_port, + source_port, + ) + else: + extend_header_table( + udp_header_table, + self.mac_north.get(), + self.ip_north.get(), + dest_port, + source_port, + ) + + return udp_header_table + + def _on_preview_update(self, img:np.ndarray): + self._run_subs(sub_type=self.SUB_DEVICE_MONITOR_2D, obj=self, value=img) + + def acq_done(self) -> DeviceStatus: + """ + Check if the acquisition is done. For the GigaFrost camera, this is + done by checking the status of the backend as the camera does not + provide any feedback about its internal state. + + Returns: + DeviceStatus: The status of the acquisition + """ + status = DeviceStatus(self) + + self.backend.add_status_callback( + status, + success=[StdDaqStatus.IDLE, StdDaqStatus.FILE_SAVED], + error=[StdDaqStatus.REJECTED, StdDaqStatus.ERROR], + ) + return status + + ######################################## + # Beamline Specific Implementations # + ######################################## + + def on_init(self) -> None: + """ + Called when the device is initialized. + + No signals 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. + """ + + # TODO: check if this can be moved to the config file + # pylint: disable=protected-access + self.auto_soft_enable._metadata["write_access"] = False + self.backend_url._metadata["write_access"] = False + self.auto_soft_enable.put(self._signals_to_be_set["auto_soft_enable"], force=True) + self.backend_url.put(self._signals_to_be_set["backend_url"], force=True) + + self.initialize_gigafrost() + + self.backend.connect() + + def on_stage(self) -> DeviceStatus | None: + """ + Called while staging the device. + + Information about the upcoming scan can be accessed from the scan_info object. + """ + # Gigafrost can finish a run without explicit unstaging + if self.busy_stat.value: + logger.warning("Camera is already running, unstaging it first!") + self.unstage() + sleep(0.5) + + scan_msg = self.scan_info.msg + scan_args = { + **scan_msg.request_inputs["inputs"], + **scan_msg.request_inputs["kwargs"], + **scan_msg.scan_parameters, + } + + self.configure(scan_args) + + # Sync if out of sync + if self.sync_flag.value == 0: + self.sync_swhw.set(1).wait() + + num_points = ( + 1 + * scan_args.get("steps", 1) + * scan_args.get("exp_burst", 1) + * scan_args.get("repeats", 1) + ) + self.num_images.set(num_points).wait() + + def on_unstage(self) -> DeviceStatus | None: + """Called while unstaging the device.""" + # Switch to idle + self.set_idle() + logger.info(f"StdDaq status on unstage: {self.backend.status}") + self.backend.stop() + + def on_pre_scan(self) -> DeviceStatus | None: + """Called right before the scan starts on all devices automatically.""" + # Switch to acquiring + self.backend.start( + file_path=self.file_path.get(), + file_prefix=self.file_prefix.get(), + num_images=self.num_images.get(), + ) + self.start_cam.set(1).wait() + + def on_trigger(self) -> DeviceStatus | None: + """Called when the device is triggered.""" + if self.busy_stat.get() in (0, "IDLE"): + raise RuntimeError("GigaFrost must be running before triggering") + + logger.warning(f"[{self.name}] SW triggering gigafrost") + + # Soft triggering based on operation mode + if ( + self.auto_soft_enable.get() + and self.trigger_mode == "auto" + and self.enable_mode == "soft" + ): + # BEC teststand operation mode: posedge of SoftEnable if Started + self.soft_enable.set(0).wait() + self.soft_enable.set(1).wait() + else: + self.soft_trig.set(1).wait() + + def on_complete(self) -> DeviceStatus | None: + """Called to inquire if a device has completed a scans.""" + return self.acq_done() + + 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.""" + return self.on_unstage() # Automatically connect to MicroSAXS testbench if directly invoked diff --git a/tomcat_bec/devices/gigafrost/std_daq_client.py b/tomcat_bec/devices/gigafrost/std_daq_client.py new file mode 100644 index 0000000..e28e655 --- /dev/null +++ b/tomcat_bec/devices/gigafrost/std_daq_client.py @@ -0,0 +1,433 @@ +from __future__ import annotations + +import copy +import enum +import json +import queue +import threading +import time +import traceback +from typing import TYPE_CHECKING, Callable, Literal + +import requests +from bec_lib.logger import bec_logger +from ophyd import StatusBase +from pydantic import BaseModel, ConfigDict, Field, model_validator +from typeguard import typechecked +from websockets import State +from websockets.exceptions import WebSocketException +from websockets.sync.client import ClientConnection, connect + +if TYPE_CHECKING: # pragma: no cover + from ophyd import Device, DeviceStatus + + +logger = bec_logger.logger + + +class StdDaqError(Exception): ... + + +class StdDaqStatus(str, enum.Enum): + """ + Status of the StdDAQ. + Extracted from https://git.psi.ch/controls-ci/std_detector_buffer/-/blob/master/source/std-det-driver/src/driver_state.hpp + """ + + CREATING_FILE = "creating_file" + ERROR = "error" + FILE_CREATED = "file_created" + FILE_SAVED = "file_saved" + IDLE = "idle" + RECORDING = "recording" + REJECTED = "rejected" + SAVING_FILE = "saving_file" + STARTED = "started" + STOP = "stop" + UNDEFINED = "undefined" + WAITING_FOR_FIRST_IMAGE = "waiting_for_first_image" + + +class StdDaqConfig(BaseModel): + """ + Configuration for the StdDAQ + """ + + detector_name: str + detector_type: str + n_modules: int + bit_depth: int + image_pixel_height: int + image_pixel_width: int + start_udp_port: int + writer_user_id: int + max_number_of_forwarders_spawned: int + use_all_forwarders: bool + module_sync_queue_size: int + number_of_writers: int + module_positions: dict + ram_buffer_gb: float + delay_filter_timeout: float + live_stream_configs: dict[str, dict[Literal["type", "config"], str | list]] + + model_config = ConfigDict(extra="ignore") + + @model_validator(mode="before") + @classmethod + def resolve_aliases(cls, values): + if "roix" in values: + values["image_pixel_height"] = values.pop("roiy") + if "roiy" in values: + values["image_pixel_width"] = values.pop("roix") + return values + + +class StdDaqConfigPartial(BaseModel): + """ + Partial configuration for the StdDAQ. + """ + + detector_name: str | None = None + detector_type: str | None = None + n_modules: int | None = None + bit_depth: int | None = None + image_pixel_height: int | None = Field(default=None, alias="roiy") + image_pixel_width: int | None = Field(default=None, alias="roix") + start_udp_port: int | None = None + writer_user_id: int | None = None + max_number_of_forwarders_spawned: int | None = None + use_all_forwarders: bool | None = None + module_sync_queue_size: int | None = None + number_of_writers: int | None = None + module_positions: dict | None = None + ram_buffer_gb: float | None = None + delay_filter_timeout: float | None = None + live_stream_configs: dict[str, dict[Literal["type", "config"], str | list]] | None = None + + model_config = ConfigDict(extra="ignore") + + +class StdDaqWsResponse(BaseModel): + """ + Response from the StdDAQ websocket + """ + + status: StdDaqStatus + reason: str | None = None + + model_config = ConfigDict(extra="allow") + + +class StdDaqClient: + + USER_ACCESS = ["status", "start", "stop", "get_config", "set_config", "reset"] + + def __init__(self, parent: Device, ws_url: str, rest_url: str): + self.parent = parent + self.ws_url = ws_url + self.rest_url = rest_url + self.ws_client: ClientConnection | None = None + self._status: StdDaqStatus = StdDaqStatus.UNDEFINED + self._ws_update_thread: threading.Thread | None = None + self._shutdown_event = threading.Event() + self._ws_idle_event = threading.Event() + self._daq_is_running = threading.Event() + self._config: StdDaqConfig | None = None + self._status_callbacks: dict[ + str, tuple[DeviceStatus, list[StdDaqStatus], list[StdDaqStatus]] + ] = {} + self._send_queue = queue.Queue() + self._daq_is_running.set() + + @property + def status(self) -> StdDaqStatus: + """ + Get the status of the StdDAQ. + """ + return self._status + + def add_status_callback( + self, status: DeviceStatus, success: list[StdDaqStatus], error: list[StdDaqStatus] + ): + """ + Add a DeviceStatus callback for the StdDAQ. The status will be updated when the StdDAQ status changes and + set to finished when the status matches one of the specified success statuses and to exception when the status + matches one of the specified error statuses. + + Args: + status (DeviceStatus): DeviceStatus object + success (list[StdDaqStatus]): list of statuses that indicate success + error (list[StdDaqStatus]): list of statuses that indicate error + """ + self._status_callbacks[id(status)] = (status, success, error) + + @typechecked + def start( + self, file_path: str, file_prefix: str, num_images: int, timeout: float = 20, wait=True + ) -> StatusBase: + """ + Start acquisition on the StdDAQ. + + Args: + file_path (str): path to save the files + file_prefix (str): prefix of the files + num_images (int): number of images to acquire + timeout (float): timeout for the request + """ + logger.info(f"Starting StdDaq backend. Current status: {self.status}") + status = StatusBase() + self.add_status_callback(status, success=["waiting_for_first_image"], error=[]) + message = { + "command": "start", + "path": file_path, + "file_prefix": file_prefix, + "n_image": num_images, + } + self._send_queue.put(message) + if wait: + return status.wait(timeout=timeout) + + return status + + def stop(self): + """ + Stop acquisition on the StdDAQ. + + Args: + timeout (float): timeout for the request + """ + message = {"command": "stop"} + return self._send_queue.put(message) + + def get_config(self, cached=False, timeout: float = 2) -> dict: + """ + Get the current configuration of the StdDAQ. + + Args: + cached (bool): whether to use the cached configuration + timeout (float): timeout for the request + + Returns: + StdDaqConfig: configuration of the StdDAQ + """ + if cached and self._config is not None: + return self._config + response = requests.get( + self.rest_url + "/api/config/get", params={"user": "ioc"}, timeout=timeout + ) + response.raise_for_status() + self._config = StdDaqConfig(**response.json()) + return self._config.model_dump() + + def set_config(self, config: StdDaqConfig | dict, timeout: float = 2) -> None: + """ + Set the configuration of the StdDAQ. This will overwrite the current configuration. + + Args: + config (StdDaqConfig | dict): configuration to set + timeout (float): timeout for the request + """ + if not isinstance(config, StdDaqConfig): + config = StdDaqConfig(**config) + + out = config.model_dump(exclude_none=True) + if not out: + logger.info( + "The provided config does not contain relevant values for the StdDaq. Skipping set_config." + ) + return + + self._pre_restart() + + response = requests.post( + self.rest_url + "/api/config/set", params={"user": "ioc"}, json=out, timeout=timeout + ) + response.raise_for_status() + + # Setting a new config will reboot the backend; we therefore have to restart the websocket + self._post_restart() + + def _pre_restart(self): + self._daq_is_running.clear() + self._ws_idle_event.wait() + if self.ws_client is not None: + self.ws_client.close() + + def _post_restart(self): + self.wait_for_connection() + self._daq_is_running.set() + + def update_config(self, config: StdDaqConfigPartial | dict, timeout: float = 2) -> None: + """ + Update the configuration of the StdDAQ. This will update the current configuration. + + Args: + config (StdDaqConfigPartial | dict): configuration to update + timeout (float): timeout for the request + """ + if not isinstance(config, StdDaqConfigPartial): + config = StdDaqConfigPartial(**config) + + patch_config_dict = config.model_dump(exclude_none=True) + if not patch_config_dict: + return + + current_config = copy.deepcopy(self.get_config()) + new_config = copy.deepcopy(current_config) + new_config.update(patch_config_dict) + if current_config == new_config: + return + + self.set_config(StdDaqConfig(**new_config), timeout=timeout) + + def reset(self, min_wait: float = 5) -> None: + """ + Reset the StdDAQ. + + Args: + min_wait (float): minimum wait time after reset + """ + self.set_config(self.get_config()) + time.sleep(min_wait) + + def wait_for_connection(self, timeout: float = 20) -> None: + """ + Wait for the connection to the StdDAQ to be established. + + Args: + timeout (float): timeout for the request + """ + start_time = time.time() + while True: + if self.ws_client is not None and self.ws_client.state == State.OPEN: + return + try: + self.ws_client = connect(self.ws_url) + break + except ConnectionRefusedError as exc: + if time.time() - start_time > timeout: + raise TimeoutError("Timeout while waiting for connection to StdDAQ") from exc + time.sleep(2) + + def create_virtual_datasets(self, file_path: str, file_prefix: str, timeout: float = 5) -> None: + """ + Combine the stddaq written files in a given folder in an interleaved + h5 virtual dataset. + + Args: + file_path (str): path to the folder containing the files + file_prefix (str): prefix of the files to combine + timeout (float): timeout for the request + """ + + # TODO: Add wait for 'idle' state + + response = requests.post( + self.rest_url + "/api/h5/create_interleaved_vds", + params={"user": "ioc"}, + json={ + "base_path": file_path, + "file_prefix": file_prefix, + "output_file": file_prefix.rstrip("_") + ".h5", + }, + timeout=timeout, + headers={"Content-type": "application/json"}, + ) + response.raise_for_status() + + def connect(self): + """ + Connect to the StdDAQ. This method should be called after the client is created. It will + launch a background thread to exchange data with the StdDAQ. + """ + self._ws_update_thread = threading.Thread( + target=self._ws_update_loop, name=f"{self.parent.name}_stddaq_ws_loop", daemon=True + ) + self._ws_update_thread.start() + + def shutdown(self): + """ + Shutdown the StdDAQ client. + """ + if self._ws_update_thread is not None: + self._ws_update_thread.join() + if self.ws_client is not None: + self.ws_client.close() + self.ws_client = None + + def _wait_for_server_running(self): + """ + Wait for the StdDAQ to be running. If the StdDaq is not running, the + websocket loop will be set to idle. + """ + while not self._shutdown_event.is_set(): + if self._daq_is_running.wait(0.1): + self._ws_idle_event.clear() + break + self._ws_idle_event.set() + + def _ws_send_and_receive(self): + if not self.ws_client: + self.wait_for_connection() + try: + try: + msg = self._send_queue.get(block=False) + logger.trace(f"Sending to stddaq ws: {msg}") + self.ws_client.send(json.dumps(msg)) + logger.trace(f"Sent to stddaq ws: {msg}") + except queue.Empty: + pass + try: + recv_msgs = self.ws_client.recv(timeout=0.1) + except TimeoutError: + return + logger.trace(f"Received from stddaq ws: {recv_msgs}") + if recv_msgs is not None: + self._on_received_ws_message(recv_msgs) + except WebSocketException: + content = traceback.format_exc() + logger.warning(f"Websocket connection closed unexpectedly: {content}") + self.wait_for_connection() + + def _ws_update_loop(self): + """ + Loop to update the status property of the StdDAQ. + """ + while not self._shutdown_event.is_set(): + self._wait_for_server_running() + self._ws_send_and_receive() + + def _on_received_ws_message(self, msg: str): + """ + Handle a message received from the StdDAQ. + """ + try: + data = StdDaqWsResponse(**json.loads(msg)) + except Exception: + content = traceback.format_exc() + logger.warning(f"Failed to decode websocket message: {content}") + return + self._status = data.status + self._run_status_callbacks() + + def _run_status_callbacks(self): + """ + Update the DeviceStatus objects based on the current status of the StdDAQ. + If the status matches one of the success or error statuses, the DeviceStatus object will be set to finished + or exception, respectively and removed from the list of callbacks. + """ + + status = self._status + completed_callbacks = [] + for dev_status, success, error in self._status_callbacks.values(): + if status in success: + dev_status.set_finished() + logger.info(f"StdDaq status is {status}") + completed_callbacks.append(dev_status) + elif status in error: + logger.warning(f"StdDaq status is {status}") + dev_status.set_exception(StdDaqError(f"StdDaq status is {status}")) + completed_callbacks.append(dev_status) + + for cb in completed_callbacks: + self._status_callbacks.pop(id(cb)) diff --git a/tomcat_bec/devices/gigafrost/std_daq_preview.py b/tomcat_bec/devices/gigafrost/std_daq_preview.py new file mode 100644 index 0000000..fdd3d34 --- /dev/null +++ b/tomcat_bec/devices/gigafrost/std_daq_preview.py @@ -0,0 +1,108 @@ +import json +import threading +import time +from typing import Callable + +import numpy as np +import zmq +from bec_lib.logger import bec_logger + +logger = bec_logger.logger + +ZMQ_TOPIC_FILTER = b"" + + +class StdDaqPreview: + USER_ACCESS = ["start", "stop"] + + def __init__(self, url: str, cb: Callable): + self.url = url + self._socket = None + self._shutdown_event = threading.Event() + self._zmq_thread = None + self._on_update_callback = cb + + def connect(self): + """Connect to te StDAQs PUB-SUB streaming interface + + StdDAQ may reject connection for a few seconds when it restarts, + so if it fails, wait a bit and try to connect again. + """ + # pylint: disable=no-member + + context = zmq.Context() + self._socket = context.socket(zmq.SUB) + self._socket.setsockopt(zmq.SUBSCRIBE, ZMQ_TOPIC_FILTER) + try: + self._socket.connect(self.url) + except ConnectionRefusedError: + time.sleep(1) + self._socket.connect(self.url) + + def start(self): + self._zmq_thread = threading.Thread( + target=self._zmq_update_loop, daemon=True, name="StdDaq_live_preview" + ) + self._zmq_thread.start() + + def stop(self): + self._shutdown_event.set() + if self._zmq_thread: + self._zmq_thread.join() + + def _zmq_update_loop(self): + while not self._shutdown_event.is_set(): + if self._socket is None: + self.connect() + try: + self._poll() + except ValueError: + # Happens when ZMQ partially delivers the multipart message + pass + except zmq.error.Again: + # Happens when receive queue is empty + time.sleep(0.1) + + def _poll(self): + """ + Poll the ZMQ socket for new data. It will throttle the data update and + only subscribe to the topic for a single update. This is not very nice + but it seems like there is currently no option to set the update rate on + the backend. + """ + + if self._shutdown_event.wait(0.2): + return + + try: + # subscribe to the topic + self._socket.setsockopt(zmq.SUBSCRIBE, ZMQ_TOPIC_FILTER) + + # pylint: disable=no-member + r = self._socket.recv_multipart(flags=zmq.NOBLOCK) + self._parse_data(r) + + finally: + # Unsubscribe from the topic + self._socket.setsockopt(zmq.UNSUBSCRIBE, ZMQ_TOPIC_FILTER) + + def _parse_data(self, data): + # Length and throtling checks + if len(data) != 2: + logger.warning(f"Received malformed array of length {len(data)}") + + # Unpack the Array V1 reply to metadata and array data + meta, img_data = data + + # Update image and update subscribers + header = json.loads(meta) + if header["type"] == "uint16": + image = np.frombuffer(img_data, dtype=np.uint16) + else: + raise ValueError(f"Unexpected type {header['type']}") + if image.size != np.prod(header["shape"]): + err = f"Unexpected array size of {image.size} for header: {header}" + raise ValueError(err) + image = image.reshape(header["shape"]) + logger.info(f"Live update: frame {header['frame']}") + self._on_update_callback(image) diff --git a/tomcat_bec/scans/__init__.py b/tomcat_bec/scans/__init__.py index ab461c9..ab5e70c 100644 --- a/tomcat_bec/scans/__init__.py +++ b/tomcat_bec/scans/__init__.py @@ -1,2 +1,2 @@ -from .tutorial_fly_scan import AcquireDark, AcquireWhite, AcquireRefs, TutorialFlyScanContLine -from .tomcat_scans import TomcatStepScan, TomcatSnapNStep, TomcatSimpleSequence +from .tutorial_fly_scan import AcquireDark, AcquireWhite, AcquireRefs, AcquireProjections, TutorialFlyScanContLine +from .tomcat_scans import TomcatSnapNStep, TomcatSimpleSequence diff --git a/tomcat_bec/scans/tutorial_fly_scan.py b/tomcat_bec/scans/tutorial_fly_scan.py index 0f3586e..8246dc8 100644 --- a/tomcat_bec/scans/tutorial_fly_scan.py +++ b/tomcat_bec/scans/tutorial_fly_scan.py @@ -1,15 +1,19 @@ import time import numpy as np +from bec_lib import bec_logger from bec_lib.device import DeviceBase from bec_server.scan_server.scans import Acquire, AsyncFlyScanBase +logger = bec_logger.logger + + class AcquireDark(Acquire): scan_name = "acquire_dark" required_kwargs = ["exp_burst"] gui_config = {"Acquisition parameters": ["exp_burst"]} - def __init__(self, exp_burst: int, **kwargs): + def __init__(self, exp_burst: int, exp_time: float = 0, **kwargs): """ Acquire dark images. This scan is used to acquire dark images. Dark images are images taken with the shutter closed and no beam on the sample. Dark images are used to correct the data images for dark current. @@ -30,6 +34,10 @@ class AcquireDark(Acquire): Predefined acquisition mode (default= 'default') file_path : str, optional File path for standard daq + ddc_trigger : int, optional + Drive Data Capture Trigger + ddc_source0 : int, optional + Drive Data capture Input0 Returns: ScanReport @@ -38,25 +46,33 @@ class AcquireDark(Acquire): >>> scans.acquire_dark(5) """ - super().__init__(**kwargs) - self.burst_at_each_point = 1 # At each point, how many times I want to individually trigger + self.exp_time = exp_time / 1000 # In BEC, the exp time is always in s, not ms. + super().__init__(self.exp_time, **kwargs) + self.burst_at_each_point = 1 # At each point, how many times I want to individually trigger self.scan_motors = ["eyex"] # change to the correct shutter device - #self.shutter = "eyex" # change to the correct shutter device - self.dark_shutter_pos = 0 ### change with a variable + # self.shutter = "eyex" # change to the correct shutter device + self.dark_shutter_pos = 0 ### change with a variable def scan_core(self): # close the shutter yield from self._move_scan_motors_and_wait(self.dark_shutter_pos) - #yield from self.stubs.set_and_wait(device=[self.shutter], positions=[0]) + # yield from self.stubs.set_and_wait(device=[self.shutter], positions=[0]) yield from super().scan_core() class AcquireWhite(Acquire): scan_name = "acquire_white" - required_kwargs = ["exp_burst", "sample_position_out", "sample_angle_out"] gui_config = {"Acquisition parameters": ["exp_burst"]} - def __init__(self, exp_burst: int, sample_position_out: float, sample_angle_out: float, **kwargs): + def __init__( + self, + exp_burst: int, + sample_position_out: float, + sample_angle_out: float, + motor: DeviceBase, + exp_time: float = 0, + **kwargs, + ): """ Acquire flat field images. This scan is used to acquire flat field images. The flat field image is an image taken with the shutter open but the sample out of the beam. Flat field images are used to correct the data images for @@ -69,6 +85,8 @@ class AcquireWhite(Acquire): Position to move the sample stage to position the sample out of beam and take flat field images sample_angle_out : float Angular position where to take the flat field images + motor : DeviceBase + Motor to be moved to move the sample out of beam exp_time : float, optional Exposure time [ms]. If not specified, the currently configured value on the camera will be used exp_period : float, optional @@ -81,6 +99,10 @@ class AcquireWhite(Acquire): Predefined acquisition mode (default= 'default') file_path : str, optional File path for standard daq + ddc_trigger : int, optional + Drive Data Capture Trigger + ddc_source0 : int, optional + Drive Data capture Input0 Returns: ScanReport @@ -89,48 +111,63 @@ class AcquireWhite(Acquire): >>> scans.acquire_white(5, 20) """ - super().__init__(**kwargs) + self.exp_time = exp_time / 1000 # In BEC, the exp time is always in s, not ms. + super().__init__(exp_time=exp_time, **kwargs) self.burst_at_each_point = 1 self.sample_position_out = sample_position_out self.sample_angle_out = sample_angle_out + self.motor_sample = motor - self.scan_motors = ["eyex", "eyez", "es1_roty"] # change to the correct shutter device - self.dark_shutter_pos_out = 1 ### change with a variable - self.dark_shutter_pos_in = 0 ### change with a variable - + self.dark_shutter_pos_out = 1 ### change with a variable + self.dark_shutter_pos_in = 0 ### change with a variable def scan_core(self): - # open the shutter and move the sample stage to the out position - self.scan_motors = ["eyez", "es1_roty"] # change to the correct shutter device - yield from self._move_scan_motors_and_wait([self.sample_position_out, self.sample_angle_out]) - self.scan_motors = ["eyex"] # change to the correct shutter device - yield from self._move_scan_motors_and_wait([self.dark_shutter_pos_out]) + # move the sample stage to the out position and correct angular position + status_sample_out_angle = yield from self.stubs.set( + device=[self.motor_sample, "es1_roty"], + value=[self.sample_position_out, self.sample_angle_out], + wait=False, + ) + # open the main shutter (TODO change to the correct shutter device) + yield from self.stubs.set(device=["eyex"], value=[self.dark_shutter_pos_out]) + status_sample_out_angle.wait() # TODO add opening of fast shutter + yield from super().scan_core() - + # TODO add closing of fast shutter - yield from self._move_scan_motors_and_wait([self.dark_shutter_pos_in]) + yield from self.stubs.set(device=["eyex"], value=[self.dark_shutter_pos_in]) -class AcquireProjectins(Acquire): + +class AcquireProjections(AsyncFlyScanBase): scan_name = "acquire_projections" - required_kwargs = ["exp_burst", "sample_position_in", "start_position", "angular_range"] - gui_config = {"Acquisition parameters": ["exp_burst"]} + gui_config = { + "Motor": ["motor"], + "Acquisition parameters": ["sample_position_in", "start_angle", "angular_range"], + "Camera": ["exp_time", "exp_burst"], + } - def __init__(self, - exp_burst: int, - sample_position_in: float, - start_position: float, - angular_range: float, - **kwargs): + def __init__( + self, + motor: DeviceBase, + exp_burst: int, + sample_position_in: float, + start_angle: float, + angular_range: float, + exp_time: float, + **kwargs, + ): """ - Acquire projection images. + Acquire projection images. Args: + motor : DeviceBase + Motor to move continuously from start to stop position exp_burst : int Number of flat field images to acquire (no default) sample_position_in : float Position to move the sample stage to position the sample in the beam - start_position : float + start_angle : float Angular start position for the scan angular_range : float Angular range @@ -146,87 +183,117 @@ class AcquireProjectins(Acquire): Predefined acquisition mode (default= 'default') file_path : str, optional File path for standard daq + ddc_trigger : int, optional + Drive Data Capture Trigger + ddc_source0 : int, optional + Drive Data capture Input0 Returns: ScanReport Examples: - >>> scans.acquire_white(5, 20) + >>> scans.acquire_projections() """ - super().__init__(**kwargs) + self.motor = motor + super().__init__(exp_time=exp_time, **kwargs) + self.burst_at_each_point = 1 self.sample_position_in = sample_position_in - self.start_position = start_position + self.start_angle = start_angle self.angular_range = angular_range - self.scan_motors = ["eyex", "eyez", "es1_roty"] # change to the correct shutter device - self.dark_shutter_pos_out = 1 ### change with a variable - self.dark_shutter_pos_in = 0 ### change with a variable + self.dark_shutter_pos_out = 1 ### change with a variable + self.dark_shutter_pos_in = 0 ### change with a variable + def update_scan_motors(self): + return [self.motor] + + def prepare_positions(self): + self.positions = np.array([[self.start_angle], [self.start_angle + self.angular_range]]) + self.num_pos = None + yield from self._set_position_offset() def scan_core(self): - # open the shutter and move the sample stage to the out position - self.scan_motors = ["eyez", "es1_roty"] # change to the correct shutter device - yield from self._move_scan_motors_and_wait([self.sample_position_out, self.sample_angle_out]) - self.scan_motors = ["eyex"] # change to the correct shutter device - yield from self._move_scan_motors_and_wait([self.dark_shutter_pos_out]) + + # move to in position and go to start angular position + yield from self.stubs.set( + device=["eyez", self.motor], value=[self.sample_position_in, self.positions[0][0]] + ) + + # open the shutter + yield from self.stubs.set(device="eyex", value=self.dark_shutter_pos_out) # TODO add opening of fast shutter - yield from super().scan_core() - - # TODO add closing of fast shutter - yield from self._move_scan_motors_and_wait([self.dark_shutter_pos_in]) + + # start the flyer + flyer_request = yield from self.stubs.set( + device=self.motor, value=self.positions[1][0], wait=False + ) + + self.connector.send_client_info( + "Starting the scan", show_asap=True, rid=self.metadata.get("RID") + ) + + yield from self.stubs.trigger() + + while not flyer_request.done: + + yield from self.stubs.read(group="monitored", point_id=self.point_id) + time.sleep(1) + + # increase the point id + self.point_id += 1 + + self.num_pos = self.point_id class AcquireRefs(Acquire): scan_name = "acquire_refs" - required_kwargs = [] gui_config = {} def __init__( self, + motor: DeviceBase, num_darks: int = 0, num_flats: int = 0, sample_angle_out: float = 0, sample_position_in: float = 0, - sample_position_out: float = 5000, - file_prefix_dark: str = 'tmp_dark', - file_prefix_white: str = 'tmp_white', - exp_time: float = 0, - exp_period: float = 0, - image_width: int = 2016, - image_height: int = 2016, - acq_mode: str = 'default', - file_path: str = 'tmp', - nr_writers: int = 2, - base_path: str = 'tmp', - **kwargs + sample_position_out: float = 1, + file_prefix_dark: str = "tmp_dark", + file_prefix_white: str = "tmp_white", + **kwargs, ): """ Acquire reference images (darks + whites) and return to beam position. - + Reference images are acquired automatically in an optimized sequence and the sample is returned to the sample_in_position afterwards. Args: + motor : DeviceBase + Motor to be moved to move the sample out of beam num_darks : int , optional Number of dark field images to acquire num_flats : int , optional Number of white field images to acquire + sample_angle_out : float , optional + Angular position where to take the flat field images sample_position_in : float , optional Sample stage X position for sample in beam [um] sample_position_out : float ,optional Sample stage X position for sample out of the beam [um] - sample_angle_out : float , optional - Angular position where to take the flat field images exp_time : float, optional - Exposure time [ms]. If not specified, the currently configured value on the camera will be used + Exposure time [ms]. If not specified, the currently configured value + on the camera will be used exp_period : float, optional - Exposure period [ms]. If not specified, the currently configured value on the camera will be used + Exposure period [ms]. If not specified, the currently configured value + on the camera will be used image_width : int, optional - ROI size in the x-direction [pixels]. If not specified, the currently configured value on the camera will be used + ROI size in the x-direction [pixels]. If not specified, the currently + configured value on the camera will be used image_height : int, optional - ROI size in the y-direction [pixels]. If not specified, the currently configured value on the camera will be used + ROI size in the y-direction [pixels]. If not specified, the currently + configured value on the camera will be used acq_mode : str, optional Predefined acquisition mode (default= 'default') file_path : str, optional @@ -239,6 +306,7 @@ class AcquireRefs(Acquire): >>> scans.acquire_refs(sample_angle_out=90, sample_position_in=10, num_darks=5, num_flats=5, exp_time=0.1) """ + self.motor = motor super().__init__(**kwargs) self.sample_position_in = sample_position_in self.sample_position_out = sample_position_out @@ -247,50 +315,77 @@ class AcquireRefs(Acquire): self.num_flats = num_flats self.file_prefix_dark = file_prefix_dark self.file_prefix_white = file_prefix_white - self.exp_time = exp_time - self.exp_period = exp_period - self.image_width = image_width - self.image_height = image_height - self.acq_mode = acq_mode - self.file_path = file_path - self.nr_writers = nr_writers - self.base_path = base_path def scan_core(self): - ## TODO move sample in position and do not wait - ## TODO move angle in position and do not wait + status_sample_out_angle = yield from self.stubs.set( + device=[self.motor, "es1_roty"], + value=[self.sample_position_out, self.sample_angle_out], + wait=False, + ) + + cameras = [ + cam.name + for cam in self.device_manager.devices.get_devices_with_tags("camera") + if cam.enabled + ] + if self.num_darks: self.connector.send_client_info( f"Acquiring {self.num_darks} dark images", show_asap=True, rid=self.metadata.get("RID"), ) + + # to set signals on a device darks = AcquireDark( exp_burst=self.num_darks, file_prefix=self.file_prefix_dark, device_manager=self.device_manager, - metadata=self.metadata + metadata=self.metadata, + instruction_handler=self.stubs._instruction_handler, + **self.caller_kwargs, ) - yield from darks.scan_core() + + # reconfigure the cameras to write to a different file + for cam in cameras: + yield from self.stubs.send_rpc_and_wait(cam, "configure", **darks.scan_parameters) + + yield from darks.pre_scan() # prepare for the upcoming scan + yield from darks.scan_core() # do the scan + yield from darks.finalize() # wait for everything to finish + self.point_id = darks.point_id + status_sample_out_angle.wait() if self.num_flats: self.connector.send_client_info( f"Acquiring {self.num_flats} flat field images", show_asap=True, rid=self.metadata.get("RID"), ) + flats = AcquireWhite( exp_burst=self.num_flats, - sample_position_out=self.sample_position_out, - sample_angle_out=self.sample_angle_out, - file_prefix=self.file_prefix_white, + # sample_position_out=self.sample_position_out, + # sample_angle_out=self.sample_angle_out, + # motor=self.motor, device_manager=self.device_manager, metadata=self.metadata, + instruction_handler=self.stubs._instruction_handler, + **self.caller_kwargs, ) + flats.point_id = self.point_id - yield from flats.scan_core() + + # reconfigure the cameras to write to a different file + for cam in cameras: + yield from self.stubs.send_rpc_and_wait(cam, "configure", **flats.scan_parameters) + + yield from flats.pre_scan() # prepare for the upcoming scan + yield from flats.scan_core() # do the scan + yield from flats.finalize() # wait for everything to finish + self.point_id = flats.point_id ## TODO move sample in beam and do not wait ## TODO move rotation to angle and do not wait