From d28f515c0d17bb1ff89ce33813691a53817eca60 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Thu, 6 Mar 2025 15:08:40 +0100 Subject: [PATCH 01/20] 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 From ab72fa3ffa1bdbf31e9650da8f25bd1b7d415b31 Mon Sep 17 00:00:00 2001 From: gac-x05la Date: Thu, 6 Mar 2025 10:10:57 +0100 Subject: [PATCH 02/20] WIP --- tomcat_bec/device_configs/microxas_test_bed_tmp.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tomcat_bec/device_configs/microxas_test_bed_tmp.yaml b/tomcat_bec/device_configs/microxas_test_bed_tmp.yaml index 7edb0a3..3d1cea2 100644 --- a/tomcat_bec/device_configs/microxas_test_bed_tmp.yaml +++ b/tomcat_bec/device_configs/microxas_test_bed_tmp.yaml @@ -57,7 +57,7 @@ es1_roty: deviceTags: - es1-sam onFailure: buffer - enabled: true + enabled: false readOnly: false softwareTrigger: false @@ -81,7 +81,7 @@ es1_tasks: prefix: 'X02DA-ES1-SMP1:TASK:' deviceTags: - es1 - enabled: true + enabled: false onFailure: buffer readOnly: false readoutPriority: monitored @@ -109,7 +109,7 @@ es1_ddaq: prefix: 'X02DA-ES1-SMP1:ROTY:DDC:' deviceTags: - es1 - enabled: true + enabled: false onFailure: buffer readOnly: false readoutPriority: monitored From 1c5e4a691a414f0a989a4afda2e22db18f13c417 Mon Sep 17 00:00:00 2001 From: gac-x05la Date: Mon, 17 Mar 2025 16:54:47 +0100 Subject: [PATCH 03/20] GF camera part seems working --- .../device_configs/microxas_test_bed.yaml | 32 +- .../devices/gigafrost/gigafrost_base.py | 234 +++++----- .../devices/gigafrost/gigafrostcamera.py | 436 ++++++++---------- 3 files changed, 343 insertions(+), 359 deletions(-) diff --git a/tomcat_bec/device_configs/microxas_test_bed.yaml b/tomcat_bec/device_configs/microxas_test_bed.yaml index 09c760e..4606e0f 100644 --- a/tomcat_bec/device_configs/microxas_test_bed.yaml +++ b/tomcat_bec/device_configs/microxas_test_bed.yaml @@ -38,18 +38,18 @@ femto_mean_curr: readOnly: true softwareTrigger: false -es1_roty: - readoutPriority: monitored - description: 'Test rotation stage' - deviceClass: ophyd.EpicsMotor - deviceConfig: - prefix: X02DA-ES1-SMP1:ROTY - deviceTags: - - es1-sam - onFailure: buffer - enabled: true - readOnly: false - softwareTrigger: false +# es1_roty: +# readoutPriority: monitored +# description: 'Test rotation stage' +# deviceClass: ophyd.EpicsMotor +# deviceConfig: +# prefix: X02DA-ES1-SMP1:ROTY +# deviceTags: +# - es1-sam +# onFailure: buffer +# enabled: true +# readOnly: false +# softwareTrigger: false # es1_ismc: # description: 'Automation1 iSMC interface' @@ -66,12 +66,12 @@ es1_roty: # es1_tasks: # description: 'Automation1 task management interface' -# deviceClass: tomcat_bec.devices.aa1Tasks -# deviceConfig: +# deviceClass: tomcat_bec.devices.aa1Tasks +# deviceConfig: # prefix: 'X02DA-ES1-SMP1:TASK:' # deviceTags: # - es1 -# enabled: true +# enabled: false # onFailure: buffer # readOnly: false # readoutPriority: monitored @@ -94,7 +94,7 @@ es1_roty: # es1_ddaq: # description: 'Automation1 position recording interface' -# deviceClass: tomcat_bec.devices.aa1AxisDriveDataCollection +# deviceClass: tomcat_bec.devices.aa1AxisDriveDataCollection # deviceConfig: # prefix: 'X02DA-ES1-SMP1:ROTY:DDC:' # deviceTags: diff --git a/tomcat_bec/devices/gigafrost/gigafrost_base.py b/tomcat_bec/devices/gigafrost/gigafrost_base.py index f44a92c..232af1c 100644 --- a/tomcat_bec/devices/gigafrost/gigafrost_base.py +++ b/tomcat_bec/devices/gigafrost/gigafrost_base.py @@ -7,57 +7,7 @@ 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") +from tomcat_bec.devices.gigafrost.gfutils import extend_header_table class GigaFrostBase(Device): @@ -86,50 +36,38 @@ class GigaFrostBase(Device): # 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) - + # Standard camera configs + acquire = Cpt(EpicsSignal, "START_CAM", put_complete=True, kind=Kind.omitted) + acquire_time = Cpt( + EpicsSignal, "EXPOSURE", put_complete=True, auto_monitor=True, kind=Kind.config + ) + acquire_period = Cpt( + EpicsSignal, "FRAMERATE", put_complete=True, auto_monitor=True, kind=Kind.config + ) + num_exposures = Cpt( + EpicsSignal, "CNT_NUM", put_complete=True, auto_monitor=True, kind=Kind.config + ) array_size = DynamicDeviceComponent( { - "array_size_x": (EpicsSignalRO, "ROIX", {"auto_monitor": True}), - "array_size_y": (EpicsSignalRO, "ROIY", {"auto_monitor": True}), + "array_size_x": (EpicsSignal, "ROIX", {"auto_monitor": True, "put_complete": True}), + "array_size_y": (EpicsSignal, "ROIY", {"auto_monitor": True, "put_complete": 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) + # DAQ parameters + 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) - # 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, - ) + # GF specific interface + acquire_block = Cpt(Signal, kind=Kind.config, value=0) + 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) + set_param = Cpt(EpicsSignal, "SET_PARAM.PROC", put_complete=True, kind=Kind.omitted) + acqmode = Cpt(EpicsSignal, "ACQMODE", put_complete=True, kind=Kind.config) 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 ) @@ -139,6 +77,10 @@ class GigaFrostBase(Device): soft_trig = Cpt(EpicsSignal, "SOFT_TRIG.PROC", put_complete=True, kind=Kind.omitted) soft_exp = Cpt(EpicsSignal, "SOFT_EXP", put_complete=True) + ############################################################################################### + # Automatically set modes on camera init + auto_soft_enable = Cpt(Signal, kind=Kind.config, metadata={"write_access": False}) + ############################################################################################### # Enable schemes # NOTE: 0 physical, 1 virtual (i.e. always running, but logs enable signal) @@ -242,13 +184,6 @@ class GigaFrostBase(Device): 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) @@ -261,32 +196,121 @@ class GigaFrostBase(Device): 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) + ### HW configuration parameters + # TODO: Only used at INIT, signals not needed + # UDP header configuration parameters 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) + udp_backend_url = Cpt(Signal, kind=Kind.config, metadata={"write_access": False}) + udp_ports = Cpt(EpicsSignal, "PORTS", put_complete=True, kind=Kind.config) + udp_framenum = Cpt(EpicsSignal, "FRAMENUM", put_complete=True, kind=Kind.config) + udp_ht_offset = Cpt(EpicsSignal, "HT_OFFSET", put_complete=True, kind=Kind.config) + udp_write_srv = Cpt(EpicsSignal, "WRITE_SRV.PROC", put_complete=True, kind=Kind.omitted) + conn_parm = Cpt(EpicsSignal, "CONN_PARM", string=True, put_complete=True, 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) + # 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) # 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 + if self.udp_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: + if self.udp_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.") + raise RuntimeError(f"Backend {self.udp_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 + if self.udp_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: + if self.udp_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.") + raise RuntimeError(f"Backend {self.udp_backend_url.get()} not recognized.") + + 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 initialize_gigafrost(self) -> None: + """Initialize the camera, set channel values""" + # Stop acquisition + self.acquire.set(0).wait() + + # set entry to UDP table + # number of UDP ports to use + self.udp_ports.set(2).wait() + # number of images to send to each UDP port before switching to next + self.udp_framenum.set(5).wait() + # offset in UDP table - where to find the first entry + self.udp_ht_offset.set(0).wait() + # activate changes + self.udp_write_srv.set(1).wait() + + # Configure triggering if needed + if self.auto_soft_enable.get(): + # Set modes + # self.fix_nframes_mode = "start" + self.cnt_startbit.set(1).wait() + self.cnt_endbit.set(0).wait() + # self.enable_mode = "soft" + self.mode_enbl_ext.set(0).wait() + self.mode_endbl_soft.set(1).wait() + self.mode_enbl_auto.set(0).wait() + # self.trigger_mode = "auto" + self.mode_trig_auto.set(1).wait() + self.mode_trig_soft.set(0).wait() + self.mode_trig_timer.set(0).wait() + self.mode_trig_ext.set(0).wait() + # self.exposure_mode = "timer" + self.mode_exp_ext.set(0).wait() + self.mode_exp_soft.set(0).wait() + self.mode_exp_timer.set(1).wait() + + # 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 (data communication parameters) + self.conn_parm.set(self._build_udp_header_table()).wait() diff --git a/tomcat_bec/devices/gigafrost/gigafrostcamera.py b/tomcat_bec/devices/gigafrost/gigafrostcamera.py index 61dd312..ddff828 100644 --- a/tomcat_bec/devices/gigafrost/gigafrostcamera.py +++ b/tomcat_bec/devices/gigafrost/gigafrostcamera.py @@ -7,14 +7,12 @@ Created on Thu Jun 27 17:28:43 2024 @author: mohacsi_i """ from time import sleep -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, @@ -22,6 +20,8 @@ from tomcat_bec.devices.gigafrost.std_daq_client import ( StdDaqStatus, ) +import tomcat_bec.devices.gigafrost.gfconstants as const + from tomcat_bec.devices.gigafrost.std_daq_preview import StdDaqPreview logger = bec_logger.logger @@ -69,10 +69,16 @@ class GigaFrostCamera(PSIDeviceBase, GigaFrostBase): "enable_mode", "backend", "acq_done", - "live_preview" + "live_preview", + "arm", + "disarm", ] _initialized = False + # Placeholders for stdDAQ and livestream clients + backend = None + live_preview = None + def __init__( self, prefix="", @@ -90,11 +96,6 @@ class GigaFrostCamera(PSIDeviceBase, GigaFrostBase): std_daq_live: str | None = None, **kwargs, ): - # Ugly hack to pass values before on_init() - self._signals_to_be_set = {} - self._signals_to_be_set["auto_soft_enable"] = auto_soft_enable - self._signals_to_be_set["backend_url"] = backend_url - # super() will call the mixin class super().__init__( prefix=prefix, @@ -106,72 +107,79 @@ class GigaFrostCamera(PSIDeviceBase, GigaFrostBase): scan_info=scan_info, **kwargs, ) + # Configure the stdDAQ client 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 + # raise ValueError("Both std_daq_rest and std_daq_ws must be provided") + logger.error("No stdDAQ address provided, launching without data backend!") + else: + self.backend = StdDaqClient(parent=self, ws_url=std_daq_ws, rest_url=std_daq_rest) + # Configure image preview if std_daq_live is not None: self.live_preview = StdDaqPreview(url=std_daq_live, cb=self._on_preview_update) - + else: + logger.error("No stdDAQ stream address provided, launching without preview!") + # Configure camera backend + self.auto_soft_enable.put(auto_soft_enable, force=True) + self.udp_backend_url.put(backend_url, force=True) def configure(self, d: dict = None): """Configure the next scan with the GigaFRoST camera Parameters as 'd' dictionary ---------------------------- - num_images : int, optional - Number of images to be taken during each scan. Set to -1 for an - unlimited number of images (limited by the ringbuffer size and - backend speed). (default = 10) + num_exposures : int, optional + Number of images to be taken during each scan. Set to -1 for unlimited + number of images (limited by the ringbuffer size and backend speed). exposure_time_ms : float, optional - Exposure time [ms]. (default = 0.2) + Exposure time [ms]. exposure_period_ms : float, optional - Exposure period [ms], ignored in soft trigger mode. (default = 1.0) + Exposure period [ms], ignored in soft trigger mode. image_width : int, optional - ROI size in the x-direction [pixels] (default = 2016) + ROI size in the x-direction [pixels] (max. 2016) image_height : int, optional - ROI size in the y-direction [pixels] (default = 2016) + ROI size in the y-direction [pixels] (max. 2016) scanid : int, optional Scan identification number to be associated with the scan data (default = 0) correction_mode : int, optional The correction to be applied to the imaging data. The following modes are available (default = 5): - - * 0: Bypass. No corrections are applied to the data. - * 1: Send correction factor A instead of pixel values - * 2: Send correction factor B instead of pixel values - * 3: Send correction factor C instead of pixel values - * 4: Invert pixel values, but do not apply any linearity correction - * 5: Apply the full linearity correction acq_mode : str, optional Select one of the pre-configured trigger behavior """ # Stop acquisition - self.set_idle() + self.disarm() - backend_config = StdDaqConfigPartial(**d) - self.backend.update_config(backend_config) + # if self.backend is not None: + # backend_config = StdDaqConfigPartial(**d) + # self.backend.configure(backend_config) - # 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 Bluesky style configure + if d is not None: + # Commonly changed settings + if "exposure_num_burst" in d: + self.num_exposures.set(d["exposure_num_burst"]).wait() + if "num_exposures" in d: + self.num_exposures.set(d["num_exposures"]).wait() + if "exposure_time_ms" in d: + self.acquire_time.set(d["exposure_time_ms"]).wait() + if "exposure_period_ms" in d: + self.acquire_period.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.array_size.array_size_x.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.array_size.array_size_y.set(d["image_height"]).wait() - if d.get("exp_time", 0) > 0: - config["exposure"] = d["exp_time"] * 1000 # exposure time in ms + self.corr_mode.set(d.get("corr_mode", 5)).wait() + self.scan_id.set(d.get("scan_id", 0)).wait() - 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"]) + # If a pre-configured acquisition mode is specified, set it + if "acq_mode" in d: + self.set_acquisition_mode(d["acq_mode"]) # Commit parameters self.set_param.set(1).wait() @@ -187,10 +195,9 @@ class GigaFrostCamera(PSIDeviceBase, GigaFrostBase): """ if acq_mode == "default": - # NOTE: Trigger using software events via softEnable (actually works) + # NOTE: Software trigger via softEnable (actually works) # Trigger parameters self.fix_nframes_mode = "start" - # Switch to physical enable signal self.mode_enbl_exp.set(0).wait() @@ -236,7 +243,7 @@ class GigaFrostCamera(PSIDeviceBase, GigaFrostBase): raise RuntimeError(f"Unsupported acquisition mode: {acq_mode}") @property - def exposure_mode(self): + def exposure_mode(self) -> str | None: """Returns the current exposure mode of the GigaFRost camera. Returns @@ -258,35 +265,34 @@ class GigaFrostCamera(PSIDeviceBase, GigaFrostBase): return None @exposure_mode.setter - def exposure_mode(self, exp_mode): + def exposure_mode(self, mode): """Apply the exposure mode for the GigaFRoST camera. Parameters ---------- - exp_mode : {'external', 'timer', 'soft'} + mode : {'external', 'timer', 'soft'} The exposure mode to be set. """ - - 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() + if mode == "external": + self.mode_exp_ext.set(1).wait() + self.mode_exp_soft.set(0).wait() + self.mode_exp_timer.set(0).wait() + elif mode == "timer": + self.mode_exp_ext.set(0).wait() + self.mode_exp_soft.set(0).wait() + self.mode_exp_timer.set(1).wait() + elif mode == "soft": + self.mode_exp_ext.set(0).wait() + self.mode_exp_soft.set(1).wait() + self.mode_exp_timer.set(0).wait() + else: + raise ValueError(f"Invalid exposure mode: {mode}!") # Commit parameters self.set_param.set(1).wait() @property - def fix_nframes_mode(self) -> Literal["off", "start", "end", "start+end"] | None: + def fix_nframes_mode(self) -> str | None: """Return the current fixed number of frames mode of the GigaFRoST camera. Returns @@ -309,7 +315,7 @@ class GigaFrostCamera(PSIDeviceBase, GigaFrostBase): return None @fix_nframes_mode.setter - def fix_nframes_mode(self, mode: Literal["off", "start", "end", "start+end"]): + def fix_nframes_mode(self, mode: str): """Apply the fixed number of frames settings to the GigaFRoST camera. Parameters @@ -317,29 +323,26 @@ class GigaFrostCamera(PSIDeviceBase, GigaFrostBase): mode : {'off', 'start', 'end', 'start+end'} The fixed number of frames mode to be applied. """ - self._fix_nframes_mode = mode - if self._fix_nframes_mode == "off": + if mode == "off": self.cnt_startbit.set(0).wait() self.cnt_endbit.set(0).wait() - elif self._fix_nframes_mode == "start": + elif mode == "start": self.cnt_startbit.set(1).wait() self.cnt_endbit.set(0).wait() - elif self._fix_nframes_mode == "end": + elif mode == "end": self.cnt_startbit.set(0).wait() self.cnt_endbit.set(1).wait() - elif self._fix_nframes_mode == "start+end": + elif mode == "start+end": 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}" - ) + raise ValueError(f"Invalid fixed frame number mode: {mode}!") # Commit parameters self.set_param.set(1).wait() @property - def trigger_mode(self) -> Literal["auto", "external", "timer", "soft"] | None: + def trigger_mode(self) -> str | None: """Method to detect the current trigger mode set in the GigaFRost camera. Returns @@ -364,34 +367,43 @@ class GigaFrostCamera(PSIDeviceBase, GigaFrostBase): return None @trigger_mode.setter - def trigger_mode(self, mode: Literal["auto", "external", "timer", "soft"]): + def trigger_mode(self, mode: str): """ Set the trigger mode for the GigaFRoST camera. - Args: - mode(str): The trigger mode to be set. Valid arguments are: ['auto', 'external', 'timer', 'soft'] + Parameters + ---------- + mode : {'auto', 'external', 'timer', 'soft'} + The trigger mode to be set. """ - 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: ['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() + if mode == "auto": + self.mode_trig_auto.set(1).wait() + self.mode_trig_soft.set(0).wait() + self.mode_trig_timer.set(0).wait() + self.mode_trig_ext.set(0).wait() + elif mode == "soft": + self.mode_trig_auto.set(0).wait() + self.mode_trig_soft.set(1).wait() + self.mode_trig_timer.set(0).wait() + self.mode_trig_ext.set(0).wait() + elif mode == "timer": + self.mode_trig_auto.set(0).wait() + self.mode_trig_soft.set(0).wait() + self.mode_trig_timer.set(1).wait() + self.mode_trig_ext.set(0).wait() + elif mode == "external": + self.mode_trig_auto.set(0).wait() + self.mode_trig_soft.set(0).wait() + self.mode_trig_timer.set(0).wait() + self.mode_trig_ext.set(1).wait() + else: + raise ValueError(f"Invalid trigger mode: {mode}!") # Commit parameters self.set_param.set(1).wait() @property - def enable_mode(self) -> Literal["soft", "external", "soft+ext", "always"] | None: + def enable_mode(self) -> str | None: """Return the enable mode set in the GigaFRoST camera. Returns @@ -413,7 +425,7 @@ class GigaFrostCamera(PSIDeviceBase, GigaFrostBase): return None @enable_mode.setter - def enable_mode(self, mode: Literal["soft", "external", "soft+ext", "always"]): + def enable_mode(self, mode: str): """ Set the enable mode for the GigaFRoST camera. @@ -425,27 +437,17 @@ class GigaFrostCamera(PSIDeviceBase, GigaFrostBase): The GigaFRoST enable mode. Valid arguments are: * 'soft': - The GigaFRoST enable signal is supplied through a software - signal + The GigaFRoST enable signal is supplied through a software signal * 'external': - The GigaFRoST enable signal is supplied through an external TTL - gating signal from the rotaiton stage or some other control - unit + The GigaFRoST enable signal is supplied through an external TTL gating + signal from the rotaiton stage or some other control unit * 'soft+ext': - The GigaFRoST enable signal can be supplied either via the - software signal or externally. The two signals are combined - with a logical OR gate. + The GigaFRoST enable signal can be supplied either via the software signal + or externally. The two signals are combined with a logical OR gate. * 'always': The GigaFRoST is always enabled. - CAUTION: This mode is not compatible with the fixed number of - frames modes! + CAUTION: This mode is not compatible with the fixed number of frames modes! """ - - if mode not in 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.mode_enbl_ext.set(0).wait() self.mode_endbl_soft.set(1).wait() @@ -462,115 +464,46 @@ class GigaFrostCamera(PSIDeviceBase, GigaFrostBase): self.mode_enbl_ext.set(0).wait() self.mode_endbl_soft.set(0).wait() self.mode_enbl_auto.set(1).wait() + else: + raise ValueError(f"Invalid enable mode {mode}!") # Commit parameters self.set_param.set(1).wait() - def set_idle(self): + def arm(self) -> None: + """Prepare the camera to accept triggers""" + self.acquire.set(1).wait() + + def disarm(self): """Set the camera to idle state""" - self.start_cam.set(0).wait() + self.acquire.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() + if self.backend is not None: + self.backend.shutdown() super().destroy() - def _build_udp_header_table(self): - """Build the header table for the UDP communication""" - 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) - 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, - ) + # 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. - 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 + # Returns: + # DeviceStatus: The status of the acquisition + # """ + # status = DeviceStatus(self) + # if self.backend is not None: + # self.backend.add_status_callback( + # status, + # success=[StdDaqStatus.IDLE, StdDaqStatus.FILE_SAVED], + # error=[StdDaqStatus.REJECTED, StdDaqStatus.ERROR], + # ) + # return status ######################################## # Beamline Specific Implementations # @@ -589,17 +522,11 @@ class GigaFrostCamera(PSIDeviceBase, GigaFrostBase): 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) - + # Perform a full initialization of the GigaFrost self.initialize_gigafrost() - - self.backend.connect() + # Connect to the stdDAQ backend + if self.backend is not None: + self.backend.connect() def on_stage(self) -> DeviceStatus | None: """ @@ -613,14 +540,31 @@ class GigaFrostCamera(PSIDeviceBase, GigaFrostBase): self.unstage() sleep(0.5) - scan_msg = self.scan_info.msg + # FIXME: I don't care about how we fish out config parameters from scan info scan_args = { - **scan_msg.request_inputs["inputs"], - **scan_msg.request_inputs["kwargs"], - **scan_msg.scan_parameters, + **self.scan_info.msg.request_inputs["inputs"], + **self.scan_info.msg.request_inputs["kwargs"], + **self.scan_info.msg.scan_parameters, } - self.configure(scan_args) + d = {} + if "image_width" in scan_args and scan_args["image_width"] is not None: + d["image_width"] = scan_args["image_width"] + if "image_height" in scan_args and scan_args["image_height"] is not None: + d["image_height"] = scan_args["image_height"] + if "exp_time" in scan_args and scan_args["exp_time"] is not None: + d["exposure_time_ms"] = scan_args["exp_time"] + if "acq_time" in scan_args and scan_args["acq_time"] is not None: + d["exposure_time_ms"] = scan_args["acq_time"] + if "acq_period" in scan_args and scan_args["acq_period"] is not None: + d["exposure_period_ms"] = scan_args["acq_period"] + if "exp_burst" in scan_args and scan_args["exp_burst"] is not None: + d["exposure_num_burst"] = scan_args["exp_burst"] + if "acq_mode" in scan_args and scan_args["acq_mode"] is not None: + d["acq_mode"] = scan_args["acq_mode"] + + if d: + self.configure(d) # Sync if out of sync if self.sync_flag.value == 0: @@ -637,19 +581,21 @@ class GigaFrostCamera(PSIDeviceBase, GigaFrostBase): 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() + self.disarm() + if self.backend is not None: + 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() + self.arm() + if self.backend is not None: + self.backend.start( + file_path=self.file_path.get(), + file_prefix=self.file_prefix.get(), + num_images=self.num_images.get(), + ) def on_trigger(self) -> DeviceStatus | None: """Called when the device is triggered.""" @@ -667,12 +613,20 @@ class GigaFrostCamera(PSIDeviceBase, GigaFrostBase): # BEC teststand operation mode: posedge of SoftEnable if Started self.soft_enable.set(0).wait() self.soft_enable.set(1).wait() + + if self.acquire_block.get(): + wait_time = 0.2 + 0.001 * self.num_exposures.value * max( + self.acquire_time.value, self.acquire_period.value + ) + logger.info(f"[{self.name}] Triggering blocks for {wait_time} seconds") + return DeviceStatus(self, done=True, success=True, settle_time=wait_time) 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() + # return self.acq_done() + return None def on_kickoff(self) -> DeviceStatus | None: """Called to kickoff a device for a fly scan. Has to be called explicitly.""" @@ -685,6 +639,12 @@ class GigaFrostCamera(PSIDeviceBase, GigaFrostBase): # Automatically connect to MicroSAXS testbench if directly invoked if __name__ == "__main__": gf = GigaFrostCamera( - "X02DA-CAM-GF2:", name="gf2", backend_url="http://xbl-daq-28:8080", auto_soft_enable=True + "X02DA-CAM-GF2:", + name="gf2", + backend_url="http://xbl-daq-28:8080", + auto_soft_enable=True, + # 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:20000', ) gf.wait_for_connection() From 6c0405ec7ad9830c563be70199d31a3be8cb6b77 Mon Sep 17 00:00:00 2001 From: gac-x05la Date: Mon, 17 Mar 2025 17:10:00 +0100 Subject: [PATCH 04/20] Fix triggering order --- tomcat_bec/devices/gigafrost/gigafrostcamera.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tomcat_bec/devices/gigafrost/gigafrostcamera.py b/tomcat_bec/devices/gigafrost/gigafrostcamera.py index ddff828..9f76cc5 100644 --- a/tomcat_bec/devices/gigafrost/gigafrostcamera.py +++ b/tomcat_bec/devices/gigafrost/gigafrostcamera.py @@ -554,6 +554,8 @@ class GigaFrostCamera(PSIDeviceBase, GigaFrostBase): d["image_height"] = scan_args["image_height"] if "exp_time" in scan_args and scan_args["exp_time"] is not None: d["exposure_time_ms"] = scan_args["exp_time"] + if "exp_period" in scan_args and scan_args["exp_period"] is not None: + d["exposure_period_ms"] = scan_args["exp_period"] if "acq_time" in scan_args and scan_args["acq_time"] is not None: d["exposure_time_ms"] = scan_args["acq_time"] if "acq_period" in scan_args and scan_args["acq_period"] is not None: @@ -588,14 +590,15 @@ class GigaFrostCamera(PSIDeviceBase, GigaFrostBase): def on_pre_scan(self) -> DeviceStatus | None: """Called right before the scan starts on all devices automatically.""" - # Switch to acquiring - self.arm() + # First start the stdDAQ if self.backend is not None: self.backend.start( file_path=self.file_path.get(), file_prefix=self.file_prefix.get(), num_images=self.num_images.get(), ) + # Then start the camera + self.arm() def on_trigger(self) -> DeviceStatus | None: """Called when the device is triggered.""" @@ -618,7 +621,7 @@ class GigaFrostCamera(PSIDeviceBase, GigaFrostBase): wait_time = 0.2 + 0.001 * self.num_exposures.value * max( self.acquire_time.value, self.acquire_period.value ) - logger.info(f"[{self.name}] Triggering blocks for {wait_time} seconds") + logger.info(f"[{self.name}] Triggering set to block for {wait_time} seconds") return DeviceStatus(self, done=True, success=True, settle_time=wait_time) else: self.soft_trig.set(1).wait() From 9051e1a9ee1cc77a6ee9a5d6cc6b3dc51721914f Mon Sep 17 00:00:00 2001 From: gac-x05la Date: Mon, 17 Mar 2025 18:27:47 +0100 Subject: [PATCH 05/20] WIP --- .../device_configs/microxas_test_bed.yaml | 78 ++++++++----------- .../devices/gigafrost/gigafrost_base.py | 1 + .../devices/gigafrost/gigafrostcamera.py | 5 +- .../devices/gigafrost/std_daq_preview.py | 66 ++++++++-------- 4 files changed, 69 insertions(+), 81 deletions(-) diff --git a/tomcat_bec/device_configs/microxas_test_bed.yaml b/tomcat_bec/device_configs/microxas_test_bed.yaml index 4606e0f..67d74e3 100644 --- a/tomcat_bec/device_configs/microxas_test_bed.yaml +++ b/tomcat_bec/device_configs/microxas_test_bed.yaml @@ -126,6 +126,7 @@ gfcam: prefix: 'X02DA-CAM-GF2:' backend_url: 'http://sls-daq-001:8080' auto_soft_enable: true + std_daq_live: 'tcp://129.129.95.111:20000' deviceTags: - camera - trigger @@ -146,13 +147,13 @@ gfdaq: deviceTags: - std-daq - gfcam - enabled: true + enabled: false onFailure: buffer readOnly: false readoutPriority: monitored softwareTrigger: false -daq_stream0: +gf_stream0: description: stdDAQ preview (2 every 555) deviceClass: tomcat_bec.devices.StdDaqPreviewDetector deviceConfig: @@ -160,56 +161,41 @@ daq_stream0: deviceTags: - std-daq - gfcam - enabled: true + enabled: false 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 - - gfcam - enabled: true - onFailure: buffer - readOnly: false - readoutPriority: monitored - softwareTrigger: false +# pcocam: +# description: PCO.edge camera client +# deviceClass: tomcat_bec.devices.PcoEdge5M +# deviceConfig: +# prefix: 'X02DA-CCDCAM2:' +# deviceTags: +# - camera +# - trigger +# - pcocam +# enabled: true +# onFailure: buffer +# readOnly: false +# readoutPriority: monitored +# softwareTrigger: true - -pcocam: - description: PCO.edge camera client - deviceClass: tomcat_bec.devices.PcoEdge5M - deviceConfig: - prefix: 'X02DA-CCDCAM2:' - deviceTags: - - camera - - trigger - - pcocam - enabled: true - onFailure: buffer - readOnly: false - readoutPriority: monitored - softwareTrigger: true - -pcodaq: - description: GigaFrost stdDAQ client - deviceClass: tomcat_bec.devices.StdDaqClient - deviceConfig: - ws_url: 'ws://129.129.95.111:8081' - rest_url: 'http://129.129.95.111:5010' - deviceTags: - - std-daq - - pcocam - enabled: true - onFailure: buffer - readOnly: false - readoutPriority: monitored - softwareTrigger: false +# pcodaq: +# description: GigaFrost stdDAQ client +# deviceClass: tomcat_bec.devices.StdDaqClient +# deviceConfig: +# ws_url: 'ws://129.129.95.111:8081' +# rest_url: 'http://129.129.95.111:5010' +# deviceTags: +# - std-daq +# - pcocam +# enabled: true +# onFailure: buffer +# readOnly: false +# readoutPriority: monitored +# softwareTrigger: false pco_stream0: description: stdDAQ preview (2 every 555) diff --git a/tomcat_bec/devices/gigafrost/gigafrost_base.py b/tomcat_bec/devices/gigafrost/gigafrost_base.py index 232af1c..3bc105f 100644 --- a/tomcat_bec/devices/gigafrost/gigafrost_base.py +++ b/tomcat_bec/devices/gigafrost/gigafrost_base.py @@ -59,6 +59,7 @@ class GigaFrostBase(Device): 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) + num_images_counter = Cpt(Signal, kind=Kind.hinted, value=0) # GF specific interface acquire_block = Cpt(Signal, kind=Kind.config, value=0) diff --git a/tomcat_bec/devices/gigafrost/gigafrostcamera.py b/tomcat_bec/devices/gigafrost/gigafrostcamera.py index 9f76cc5..29b7363 100644 --- a/tomcat_bec/devices/gigafrost/gigafrostcamera.py +++ b/tomcat_bec/devices/gigafrost/gigafrostcamera.py @@ -484,8 +484,9 @@ class GigaFrostCamera(PSIDeviceBase, GigaFrostBase): self.backend.shutdown() super().destroy() - # def _on_preview_update(self, img:np.ndarray): - # self._run_subs(sub_type=self.SUB_DEVICE_MONITOR_2D, obj=self, value=img) + def _on_preview_update(self, img:np.ndarray, header: dict): + self.num_images_counter.put(header['frame'], force=True) + self._run_subs(sub_type=self.SUB_DEVICE_MONITOR_2D, obj=self, value=img) # def acq_done(self) -> DeviceStatus: # """ diff --git a/tomcat_bec/devices/gigafrost/std_daq_preview.py b/tomcat_bec/devices/gigafrost/std_daq_preview.py index fdd3d34..c519785 100644 --- a/tomcat_bec/devices/gigafrost/std_daq_preview.py +++ b/tomcat_bec/devices/gigafrost/std_daq_preview.py @@ -13,13 +13,15 @@ ZMQ_TOPIC_FILTER = b"" class StdDaqPreview: - USER_ACCESS = ["start", "stop"] + USER_ACCESS = ["start", "stop", "image"] + _socket = None + _zmq_thread = None + _shutdown_event = threading.Event() + _throttle = 0.2 + image = None 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): @@ -40,6 +42,11 @@ class StdDaqPreview: self._socket.connect(self.url) def start(self): + # Only one consumer thread + if self._zmq_thread: + self.stop() + + self._shutdown_event.clear() self._zmq_thread = threading.Thread( target=self._zmq_update_loop, daemon=True, name="StdDaq_live_preview" ) @@ -49,13 +56,23 @@ class StdDaqPreview: self._shutdown_event.set() if self._zmq_thread: self._zmq_thread.join() + self._zmq_thread = None def _zmq_update_loop(self): + if self._socket is None: + self.connect() + + t_last = time.time() while not self._shutdown_event.is_set(): - if self._socket is None: - self.connect() try: - self._poll() + # pylint: disable=no-member + r = self._socket.recv_multipart(flags=zmq.NOBLOCK) + + # Throttle parsing and callbacks + t_curr = time.time() + if t_curr - t_last > self._throttle: + self._parse_data(r) + t_last = t_curr except ValueError: # Happens when ZMQ partially delivers the multipart message pass @@ -63,33 +80,10 @@ class StdDaqPreview: # 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)}") + logger.warning(f"Received incomplete ZMQ message of length {len(data)}") # Unpack the Array V1 reply to metadata and array data meta, img_data = data @@ -98,11 +92,17 @@ class StdDaqPreview: header = json.loads(meta) if header["type"] == "uint16": image = np.frombuffer(img_data, dtype=np.uint16) + elif header["type"] == "uint8": + image = np.frombuffer(img_data, dtype=np.uint8) 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) + # Print diadnostics and run callback + logger.info( + f"Live update: frame {header['frame']}\tShape: {header['shape']}\t" + f"Mean: {np.mean(image):.3f}" + ) + self._on_update_callback(image, header) From c9a2ce0dc5845b75da281dda60410fe9d1c3add6 Mon Sep 17 00:00:00 2001 From: gac-x05la Date: Tue, 18 Mar 2025 11:18:06 +0100 Subject: [PATCH 06/20] Livestream with access mutex --- .../devices/gigafrost/std_daq_preview.py | 52 ++++++++++++------- 1 file changed, 33 insertions(+), 19 deletions(-) diff --git a/tomcat_bec/devices/gigafrost/std_daq_preview.py b/tomcat_bec/devices/gigafrost/std_daq_preview.py index c519785..6ea8a80 100644 --- a/tomcat_bec/devices/gigafrost/std_daq_preview.py +++ b/tomcat_bec/devices/gigafrost/std_daq_preview.py @@ -16,6 +16,7 @@ class StdDaqPreview: USER_ACCESS = ["start", "stop", "image"] _socket = None _zmq_thread = None + _monitor_mutex = threading.Lock() _shutdown_event = threading.Event() _throttle = 0.2 image = None @@ -48,7 +49,7 @@ class StdDaqPreview: self._shutdown_event.clear() self._zmq_thread = threading.Thread( - target=self._zmq_update_loop, daemon=True, name="StdDaq_live_preview" + target=self._zmq_monitor, daemon=True, name="StdDaq_live_preview" ) self._zmq_thread.start() @@ -58,27 +59,40 @@ class StdDaqPreview: self._zmq_thread.join() self._zmq_thread = None - def _zmq_update_loop(self): - if self._socket is None: - self.connect() + def _zmq_monitor(self): + """ZMQ stream monitor""" + + # Exit if another monitor is running + if self._monitor_mutex.locked(): + return + + with self._monitor_mutex: + # Open a new connection + if self._socket is None: + self.connect() - t_last = time.time() - while not self._shutdown_event.is_set(): try: - # pylint: disable=no-member - r = self._socket.recv_multipart(flags=zmq.NOBLOCK) + # Run the monitor loop + t_last = time.time() + while not self._shutdown_event.is_set(): + try: + # pylint: disable=no-member + r = self._socket.recv_multipart(flags=zmq.NOBLOCK) - # Throttle parsing and callbacks - t_curr = time.time() - if t_curr - t_last > self._throttle: - self._parse_data(r) - t_last = t_curr - 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) + # Throttle parsing and callbacks + t_curr = time.time() + if t_curr - t_last > self._throttle: + self._parse_data(r) + t_last = t_curr + 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) + finally: + # Stop receiving incoming data + self._socket.close() def _parse_data(self, data): # Length and throtling checks From 20dcd1849a9e104f60404ee6e7b4e44cfe54260c Mon Sep 17 00:00:00 2001 From: gac-x05la Date: Wed, 19 Mar 2025 09:52:34 +0100 Subject: [PATCH 07/20] WIP --- .../devices/gigafrost/std_daq_client.py | 87 +++++++------------ 1 file changed, 31 insertions(+), 56 deletions(-) diff --git a/tomcat_bec/devices/gigafrost/std_daq_client.py b/tomcat_bec/devices/gigafrost/std_daq_client.py index e28e655..06e76f3 100644 --- a/tomcat_bec/devices/gigafrost/std_daq_client.py +++ b/tomcat_bec/devices/gigafrost/std_daq_client.py @@ -122,17 +122,19 @@ class StdDaqClient: USER_ACCESS = ["status", "start", "stop", "get_config", "set_config", "reset"] + _ws_client: ClientConnection | None = None + _status: StdDaqStatus = StdDaqStatus.UNDEFINED + _ws_update_thread: threading.Thread | None = None + _shutdown_event = threading.Event() + _ws_idle_event = threading.Event() + _daq_is_running = threading.Event() + _config: StdDaqConfig | None = None + 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]] ] = {} @@ -199,7 +201,7 @@ class StdDaqClient: message = {"command": "stop"} return self._send_queue.put(message) - def get_config(self, cached=False, timeout: float = 2) -> dict: + def get_config(self, timeout: float = 2) -> dict: """ Get the current configuration of the StdDAQ. @@ -210,16 +212,14 @@ class StdDaqClient: 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() + self._config = response.json() + return self._config - def set_config(self, config: StdDaqConfig | dict, timeout: float = 2) -> None: + def set_config(self, config: dict, timeout: float = 2, update: bool = True) -> None: """ Set the configuration of the StdDAQ. This will overwrite the current configuration. @@ -227,20 +227,16 @@ class StdDaqClient: 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 + if self._config is None: + self.get_config() + if update: + self._config.update(config) + config = copy.deepcopy(self._config) self._pre_restart() response = requests.post( - self.rest_url + "/api/config/set", params={"user": "ioc"}, json=out, timeout=timeout + self.rest_url + "/api/config/set", params={"user": "ioc"}, json=config, timeout=timeout ) response.raise_for_status() @@ -248,38 +244,17 @@ class StdDaqClient: self._post_restart() def _pre_restart(self): + """Stop monitor before restart""" self._daq_is_running.clear() self._ws_idle_event.wait() - if self.ws_client is not None: - self.ws_client.close() + if self._ws_client is not None: + self._ws_client.close() def _post_restart(self): + """Start monitor after a restart""" 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. @@ -299,10 +274,10 @@ class StdDaqClient: """ start_time = time.time() while True: - if self.ws_client is not None and self.ws_client.state == State.OPEN: + if self._ws_client is not None and self._ws_client.state == State.OPEN: return try: - self.ws_client = connect(self.ws_url) + self._ws_client = connect(self.ws_url) break except ConnectionRefusedError as exc: if time.time() - start_time > timeout: @@ -351,9 +326,9 @@ class StdDaqClient: """ 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 + if self._ws_client is not None: + self._ws_client.close() + self._ws_client = None def _wait_for_server_running(self): """ @@ -367,18 +342,18 @@ class StdDaqClient: self._ws_idle_event.set() def _ws_send_and_receive(self): - if not self.ws_client: + 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)) + 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) + recv_msgs = self._ws_client.recv(timeout=0.1) except TimeoutError: return logger.trace(f"Received from stddaq ws: {recv_msgs}") From 49e8c64433e0b92d8d3a196bb485291bf488ff71 Mon Sep 17 00:00:00 2001 From: gac-x05la Date: Wed, 19 Mar 2025 14:37:30 +0100 Subject: [PATCH 08/20] Run control works --- .../devices/gigafrost/gigafrostcamera.py | 3 - .../devices/gigafrost/std_daq_client.py | 161 +++++++++--------- 2 files changed, 80 insertions(+), 84 deletions(-) diff --git a/tomcat_bec/devices/gigafrost/gigafrostcamera.py b/tomcat_bec/devices/gigafrost/gigafrostcamera.py index 29b7363..86ef637 100644 --- a/tomcat_bec/devices/gigafrost/gigafrostcamera.py +++ b/tomcat_bec/devices/gigafrost/gigafrostcamera.py @@ -525,9 +525,6 @@ class GigaFrostCamera(PSIDeviceBase, GigaFrostBase): """ # Perform a full initialization of the GigaFrost self.initialize_gigafrost() - # Connect to the stdDAQ backend - if self.backend is not None: - self.backend.connect() def on_stage(self) -> DeviceStatus | None: """ diff --git a/tomcat_bec/devices/gigafrost/std_daq_client.py b/tomcat_bec/devices/gigafrost/std_daq_client.py index 06e76f3..3ba5610 100644 --- a/tomcat_bec/devices/gigafrost/std_daq_client.py +++ b/tomcat_bec/devices/gigafrost/std_daq_client.py @@ -16,7 +16,7 @@ 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 +import websockets.sync.client as ws if TYPE_CHECKING: # pragma: no cover from ophyd import Device, DeviceStatus @@ -122,25 +122,29 @@ class StdDaqClient: USER_ACCESS = ["status", "start", "stop", "get_config", "set_config", "reset"] - _ws_client: ClientConnection | None = None + _ws_client: ws.ClientConnection | None = None _status: StdDaqStatus = StdDaqStatus.UNDEFINED + _ws_recv_mutex = threading.Lock() _ws_update_thread: threading.Thread | None = None _shutdown_event = threading.Event() _ws_idle_event = threading.Event() _daq_is_running = threading.Event() _config: StdDaqConfig | None = None + _status_callbacks: dict[str, tuple[DeviceStatus, list[StdDaqStatus], list[StdDaqStatus]]] = {} 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._status_callbacks: dict[ - str, tuple[DeviceStatus, list[StdDaqStatus], list[StdDaqStatus]] - ] = {} - self._send_queue = queue.Queue() self._daq_is_running.set() + # Connect to WS interface and start status monitoring + self.wait_for_connection() + self._ws_monitor_thread = threading.Thread( + target=self._ws_monitor_loop, name=f"{self.parent.name}_stddaq_ws_monitor", daemon=True + ) + self._ws_monitor_thread.start() + @property def status(self) -> StdDaqStatus: """ @@ -167,50 +171,62 @@ class StdDaqClient: def start( self, file_path: str, file_prefix: str, num_images: int, timeout: float = 20, wait=True ) -> StatusBase: - """ - Start acquisition on the StdDAQ. + """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 + Returns: + status (StatusBase): Ophyd status object with attached monitor """ + # Ensure connection + self.wait_for_connection() + logger.info(f"Starting StdDaq backend. Current status: {self.status}") status = StatusBase() - self.add_status_callback(status, success=["waiting_for_first_image"], error=[]) + self.add_status_callback(status, success=["waiting_for_first_image"], error=["rejected"]) message = { "command": "start", "path": file_path, "file_prefix": file_prefix, "n_image": num_images, } - self._send_queue.put(message) + self._ws_client.send(json.dumps(message)) if wait: - return status.wait(timeout=timeout) - + status.wait(timeout=timeout) return status - def stop(self): - """ - Stop acquisition on the StdDAQ. + @typechecked + def stop(self, timeout: float = 5, wait=True) -> StatusBase: + """Stop acquisition on the StdDAQ. Args: timeout (float): timeout for the request + Returns: + status (StatusBase): Ophyd status object with attached monitor """ + # Ensure connection + self.wait_for_connection() + + logger.info(f"Stopping StdDaq backend. Current status: {self.status}") + status = StatusBase() + self.add_status_callback(status, success=["idle"], error=["error"]) message = {"command": "stop"} - return self._send_queue.put(message) + + self._ws_client.send(json.dumps(message)) + if wait: + status.wait(timeout=timeout) + return status def get_config(self, timeout: float = 2) -> dict: - """ - Get the current configuration of the StdDAQ. + """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 + config (dict): configuration of the StdDAQ """ response = requests.get( self.rest_url + "/api/config/get", params={"user": "ioc"}, timeout=timeout @@ -219,7 +235,7 @@ class StdDaqClient: self._config = response.json() return self._config - def set_config(self, config: dict, timeout: float = 2, update: bool = True) -> None: + def set_config(self, config: dict, timeout: float = 2, update: bool = True, force: bool=True) -> None: """ Set the configuration of the StdDAQ. This will overwrite the current configuration. @@ -227,16 +243,17 @@ class StdDaqClient: config (StdDaqConfig | dict): configuration to set timeout (float): timeout for the request """ - if self._config is None: - self.get_config() - if update: - self._config.update(config) - config = copy.deepcopy(self._config) + old_config = self.get_config() + new_config = copy.deepcopy(self._config.update(config)) if update else config + + # Escape unnecesary restarts + if not force and new_config == old_config: + return self._pre_restart() response = requests.post( - self.rest_url + "/api/config/set", params={"user": "ioc"}, json=config, timeout=timeout + self.rest_url + "/api/config/set", params={"user": "ioc"}, json=new_config, timeout=timeout ) response.raise_for_status() @@ -277,7 +294,7 @@ class StdDaqClient: if self._ws_client is not None and self._ws_client.state == State.OPEN: return try: - self._ws_client = connect(self.ws_url) + self._ws_client = ws.connect(self.ws_url) break except ConnectionRefusedError as exc: if time.time() - start_time > timeout: @@ -310,16 +327,6 @@ class StdDaqClient: ) 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. @@ -341,55 +348,36 @@ class StdDaqClient: 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_monitor_loop(self): + """Loop to update the status property of the StdDAQ. - def _ws_update_loop(self): - """ - Loop to update the status property of the StdDAQ. + This is a persistent monitor that updates the status and calls attached + callbacks. It also handles stdDAQ restarts and reconnection by itself. """ 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() + try: + msg = self._ws_client.recv(timeout=0.1) + except TimeoutError: + continue + except WebSocketException: + content = traceback.format_exc() + # TODO: this is expected to happen on every reconfiguration + logger.warning(f"Websocket connection closed unexpectedly: {content}") + self.wait_for_connection() + continue + msg = json.loads(msg) + if self._status != msg["status"]: + logger.info(f"stdDAQ state transition by: {msg}") + self._status = msg["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. + 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 @@ -406,3 +394,14 @@ class StdDaqClient: for cb in completed_callbacks: self._status_callbacks.pop(id(cb)) + + + +# Automatically connect to microXAS testbench if directly invoked +if __name__ == "__main__": + class foo: + name="bar" + + daq = StdDaqClient( + parent=foo(), ws_url='ws://129.129.95.111:8080', rest_url='http://129.129.95.111:5000' + ) From 32b976f9d639ab6fe8976eb8671d127d6bdafbbd Mon Sep 17 00:00:00 2001 From: gac-x05la Date: Thu, 20 Mar 2025 17:46:51 +0100 Subject: [PATCH 09/20] Starting to look good --- .../device_configs/microxas_test_bed.yaml | 2 + .../devices/gigafrost/gigafrost_base.py | 6 +- .../devices/gigafrost/gigafrostcamera.py | 45 ++++-- .../devices/gigafrost/std_daq_client.py | 141 ++++++------------ .../devices/gigafrost/std_daq_preview.py | 9 +- 5 files changed, 89 insertions(+), 114 deletions(-) diff --git a/tomcat_bec/device_configs/microxas_test_bed.yaml b/tomcat_bec/device_configs/microxas_test_bed.yaml index 67d74e3..85b159b 100644 --- a/tomcat_bec/device_configs/microxas_test_bed.yaml +++ b/tomcat_bec/device_configs/microxas_test_bed.yaml @@ -127,6 +127,8 @@ gfcam: backend_url: 'http://sls-daq-001:8080' auto_soft_enable: true std_daq_live: 'tcp://129.129.95.111:20000' + std_daq_ws: 'ws://129.129.95.111:8080' + std_daq_rest: 'http://129.129.95.111:5000' deviceTags: - camera - trigger diff --git a/tomcat_bec/devices/gigafrost/gigafrost_base.py b/tomcat_bec/devices/gigafrost/gigafrost_base.py index 3bc105f..bfe6a94 100644 --- a/tomcat_bec/devices/gigafrost/gigafrost_base.py +++ b/tomcat_bec/devices/gigafrost/gigafrost_base.py @@ -56,9 +56,9 @@ class GigaFrostBase(Device): ) # DAQ parameters - 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) + file_path = Cpt(Signal, kind=Kind.config, value="/gpfs/test/test-beamline") + file_prefix = Cpt(Signal, kind=Kind.config, value="scan_") + num_images = Cpt(Signal, kind=Kind.config, value=1000) num_images_counter = Cpt(Signal, kind=Kind.hinted, value=0) # GF specific interface diff --git a/tomcat_bec/devices/gigafrost/gigafrostcamera.py b/tomcat_bec/devices/gigafrost/gigafrostcamera.py index 86ef637..3fa4082 100644 --- a/tomcat_bec/devices/gigafrost/gigafrostcamera.py +++ b/tomcat_bec/devices/gigafrost/gigafrostcamera.py @@ -16,7 +16,6 @@ from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase from tomcat_bec.devices.gigafrost.gigafrost_base import GigaFrostBase from tomcat_bec.devices.gigafrost.std_daq_client import ( StdDaqClient, - StdDaqConfigPartial, StdDaqStatus, ) @@ -73,7 +72,6 @@ class GigaFrostCamera(PSIDeviceBase, GigaFrostBase): "arm", "disarm", ] - _initialized = False # Placeholders for stdDAQ and livestream clients backend = None @@ -150,12 +148,8 @@ class GigaFrostCamera(PSIDeviceBase, GigaFrostBase): # Stop acquisition self.disarm() - # if self.backend is not None: - # backend_config = StdDaqConfigPartial(**d) - # self.backend.configure(backend_config) - # If Bluesky style configure - if d is not None: + if d: # Commonly changed settings if "exposure_num_burst" in d: self.num_exposures.set(d["exposure_num_burst"]).wait() @@ -181,9 +175,24 @@ class GigaFrostCamera(PSIDeviceBase, GigaFrostBase): if "acq_mode" in d: self.set_acquisition_mode(d["acq_mode"]) - # Commit parameters + # Commit parameters to GigaFrost self.set_param.set(1).wait() + # Backend stdDAQ configuration + if d and self.backend is not None: + daq_update = {} + if "image_height" in d: + daq_update['image_pixel_height'] = d["image_height"] + if "image_width" in d: + daq_update['image_pixel_width'] = d["image_width"] + if "bit_depth" in d: + daq_update['bit_depth'] = d["bit_depth"] + if "number_of_writers" in d: + daq_update['number_of_writers'] = d["number_of_writers"] + + if daq_update: + self.backend.set_config(daq_update, force=False) + def set_acquisition_mode(self, acq_mode): """Set acquisition mode @@ -485,6 +494,7 @@ class GigaFrostCamera(PSIDeviceBase, GigaFrostBase): super().destroy() def _on_preview_update(self, img:np.ndarray, header: dict): + """Send preview stream and update frame index counter""" self.num_images_counter.put(header['frame'], force=True) self._run_subs(sub_type=self.SUB_DEVICE_MONITOR_2D, obj=self, value=img) @@ -570,20 +580,31 @@ class GigaFrostCamera(PSIDeviceBase, GigaFrostBase): if self.sync_flag.value == 0: self.sync_swhw.set(1).wait() + # stdDAQ backend parameters num_points = ( 1 * scan_args.get("steps", 1) * scan_args.get("exp_burst", 1) * scan_args.get("repeats", 1) + * scan_args.get("burst_at_each_point", 1) ) self.num_images.set(num_points).wait() + if "daq_file_path" in scan_args and scan_args["daq_file_path"] is not None: + self.file_path.set(scan_args['daq_file_path']).wait() + if "daq_file_prefix" in scan_args and scan_args["daq_file_prefix"] is not None: + self.file_prefix.set(scan_args['daq_file_prefix']).wait() + if "daq_num_images" in scan_args and scan_args["daq_num_images"] is not None: + self.num_images.set(scan_args['daq_num_images']).wait() + # Start stdDAQ preview + if self.live_preview is not None: + self.live_preview.start() def on_unstage(self) -> DeviceStatus | None: """Called while unstaging the device.""" # Switch to idle self.disarm() if self.backend is not None: - logger.info(f"StdDaq status on unstage: {self.backend.status}") + logger.info(f"StdDaq status before unstage: {self.backend.status}") self.backend.stop() def on_pre_scan(self) -> DeviceStatus | None: @@ -644,8 +665,8 @@ if __name__ == "__main__": name="gf2", backend_url="http://xbl-daq-28:8080", auto_soft_enable=True, - # 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:20000', + 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:20000', ) gf.wait_for_connection() diff --git a/tomcat_bec/devices/gigafrost/std_daq_client.py b/tomcat_bec/devices/gigafrost/std_daq_client.py index 3ba5610..d72a502 100644 --- a/tomcat_bec/devices/gigafrost/std_daq_client.py +++ b/tomcat_bec/devices/gigafrost/std_daq_client.py @@ -3,16 +3,14 @@ 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 +from typing import TYPE_CHECKING 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 @@ -48,88 +46,19 @@ class StdDaqStatus(str, enum.Enum): 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"] + USER_ACCESS = ["status", "start", "stop", "get_config", "set_config", "reset", "_status"] _ws_client: ws.ClientConnection | None = None _status: StdDaqStatus = StdDaqStatus.UNDEFINED + _status_timestamp: float | None = None _ws_recv_mutex = threading.Lock() _ws_update_thread: threading.Thread | None = None _shutdown_event = threading.Event() _ws_idle_event = threading.Event() _daq_is_running = threading.Event() - _config: StdDaqConfig | None = None + _config: dict | None = None _status_callbacks: dict[str, tuple[DeviceStatus, list[StdDaqStatus], list[StdDaqStatus]]] = {} def __init__(self, parent: Device, ws_url: str, rest_url: str): @@ -170,7 +99,7 @@ class StdDaqClient: @typechecked def start( self, file_path: str, file_prefix: str, num_images: int, timeout: float = 20, wait=True - ) -> StatusBase: + ) -> StatusBase | None: """Start acquisition on the StdDAQ. Args: @@ -196,10 +125,11 @@ class StdDaqClient: self._ws_client.send(json.dumps(message)) if wait: status.wait(timeout=timeout) + return None return status @typechecked - def stop(self, timeout: float = 5, wait=True) -> StatusBase: + def stop(self, timeout: float = 5, wait=True, stop_cmd="stop") -> StatusBase | None: """Stop acquisition on the StdDAQ. Args: @@ -213,11 +143,12 @@ class StdDaqClient: logger.info(f"Stopping StdDaq backend. Current status: {self.status}") status = StatusBase() self.add_status_callback(status, success=["idle"], error=["error"]) - message = {"command": "stop"} + message = {"command": stop_cmd} self._ws_client.send(json.dumps(message)) if wait: status.wait(timeout=timeout) + return None return status def get_config(self, timeout: float = 2) -> dict: @@ -244,14 +175,23 @@ class StdDaqClient: timeout (float): timeout for the request """ old_config = self.get_config() - new_config = copy.deepcopy(self._config.update(config)) if update else config + if update: + cfg = copy.deepcopy(self._config) + cfg.update(config) + new_config = cfg + else: + new_config = config # Escape unnecesary restarts if not force and new_config == old_config: return + if not new_config: + return self._pre_restart() + # new_jason = json.dumps(new_config) + logger.warning(new_config) response = requests.post( self.rest_url + "/api/config/set", params={"user": "ioc"}, json=new_config, timeout=timeout ) @@ -269,6 +209,7 @@ class StdDaqClient: def _post_restart(self): """Start monitor after a restart""" + time.sleep(2) self.wait_for_connection() self._daq_is_running.set() @@ -354,23 +295,29 @@ class StdDaqClient: This is a persistent monitor that updates the status and calls attached callbacks. It also handles stdDAQ restarts and reconnection by itself. """ - while not self._shutdown_event.is_set(): - self._wait_for_server_running() - try: - msg = self._ws_client.recv(timeout=0.1) - except TimeoutError: - continue - except WebSocketException: - content = traceback.format_exc() - # TODO: this is expected to happen on every reconfiguration - logger.warning(f"Websocket connection closed unexpectedly: {content}") - self.wait_for_connection() - continue - msg = json.loads(msg) - if self._status != msg["status"]: - logger.info(f"stdDAQ state transition by: {msg}") - self._status = msg["status"] - self._run_status_callbacks() + if self._ws_recv_mutex.locked(): + return + + with self._ws_recv_mutex: + while not self._shutdown_event.is_set(): + self._wait_for_server_running() + try: + msg = self._ws_client.recv(timeout=0.1) + msg_timestamp = time.time() + except TimeoutError: + continue + except WebSocketException: + content = traceback.format_exc() + # TODO: this is expected to happen on every reconfiguration + logger.warning(f"Websocket connection closed unexpectedly: {content}") + self.wait_for_connection() + continue + msg = json.loads(msg) + if self._status != msg["status"]: + logger.info(f"stdDAQ state transition by: {msg}") + self._status = msg["status"] + self._status_timestamp = msg_timestamp + self._run_status_callbacks() def _run_status_callbacks(self): """ @@ -399,7 +346,9 @@ class StdDaqClient: # Automatically connect to microXAS testbench if directly invoked if __name__ == "__main__": + # pylint: disable=disallowed-name,too-few-public-methods class foo: + """Dummy""" name="bar" daq = StdDaqClient( diff --git a/tomcat_bec/devices/gigafrost/std_daq_preview.py b/tomcat_bec/devices/gigafrost/std_daq_preview.py index 6ea8a80..fcb1696 100644 --- a/tomcat_bec/devices/gigafrost/std_daq_preview.py +++ b/tomcat_bec/devices/gigafrost/std_daq_preview.py @@ -13,13 +13,14 @@ ZMQ_TOPIC_FILTER = b"" class StdDaqPreview: - USER_ACCESS = ["start", "stop", "image"] + USER_ACCESS = ["start", "stop", "image", "frameno"] _socket = None _zmq_thread = None _monitor_mutex = threading.Lock() _shutdown_event = threading.Event() _throttle = 0.2 image = None + frameno = None def __init__(self, url: str, cb: Callable): self.url = url @@ -68,8 +69,7 @@ class StdDaqPreview: with self._monitor_mutex: # Open a new connection - if self._socket is None: - self.connect() + self.connect() try: # Run the monitor loop @@ -93,6 +93,7 @@ class StdDaqPreview: finally: # Stop receiving incoming data self._socket.close() + logger.warning("Detached live_preview monitoring") def _parse_data(self, data): # Length and throtling checks @@ -119,4 +120,6 @@ class StdDaqPreview: f"Live update: frame {header['frame']}\tShape: {header['shape']}\t" f"Mean: {np.mean(image):.3f}" ) + self.image = image + self.frameno = header['frame'] self._on_update_callback(image, header) From 045f348322aadd86a239a566e0bff786d9e70b6f Mon Sep 17 00:00:00 2001 From: gac-x05la Date: Fri, 21 Mar 2025 15:42:40 +0100 Subject: [PATCH 10/20] GF seems done, working on PCO --- .../devices/gigafrost/gigafrostcamera.py | 72 ++- tomcat_bec/devices/gigafrost/pcoedge_base.py | 176 ++++++ tomcat_bec/devices/gigafrost/pcoedgecamera.py | 562 ++++++++---------- .../devices/gigafrost/std_daq_client.py | 8 +- 4 files changed, 459 insertions(+), 359 deletions(-) create mode 100644 tomcat_bec/devices/gigafrost/pcoedge_base.py diff --git a/tomcat_bec/devices/gigafrost/gigafrostcamera.py b/tomcat_bec/devices/gigafrost/gigafrostcamera.py index 3fa4082..8527627 100644 --- a/tomcat_bec/devices/gigafrost/gigafrostcamera.py +++ b/tomcat_bec/devices/gigafrost/gigafrostcamera.py @@ -6,22 +6,17 @@ Created on Thu Jun 27 17:28:43 2024 @author: mohacsi_i """ -from time import sleep +from time import sleep, time 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 - -from tomcat_bec.devices.gigafrost.gigafrost_base import GigaFrostBase -from tomcat_bec.devices.gigafrost.std_daq_client import ( - StdDaqClient, - StdDaqStatus, -) - import tomcat_bec.devices.gigafrost.gfconstants as const - +from tomcat_bec.devices.gigafrost.gigafrost_base import GigaFrostBase from tomcat_bec.devices.gigafrost.std_daq_preview import StdDaqPreview +from tomcat_bec.devices.gigafrost.std_daq_client import StdDaqClient, StdDaqStatus + logger = bec_logger.logger @@ -62,6 +57,7 @@ class GigaFrostCamera(PSIDeviceBase, GigaFrostBase): # pylint: disable=too-many-instance-attributes USER_ACCESS = [ + "complete", "exposure_mode", "fix_nframes_mode", "trigger_mode", @@ -182,13 +178,13 @@ class GigaFrostCamera(PSIDeviceBase, GigaFrostBase): if d and self.backend is not None: daq_update = {} if "image_height" in d: - daq_update['image_pixel_height'] = d["image_height"] + daq_update["image_pixel_height"] = d["image_height"] if "image_width" in d: - daq_update['image_pixel_width'] = d["image_width"] + daq_update["image_pixel_width"] = d["image_width"] if "bit_depth" in d: - daq_update['bit_depth'] = d["bit_depth"] + daq_update["bit_depth"] = d["bit_depth"] if "number_of_writers" in d: - daq_update['number_of_writers'] = d["number_of_writers"] + daq_update["number_of_writers"] = d["number_of_writers"] if daq_update: self.backend.set_config(daq_update, force=False) @@ -493,28 +489,28 @@ class GigaFrostCamera(PSIDeviceBase, GigaFrostBase): self.backend.shutdown() super().destroy() - def _on_preview_update(self, img:np.ndarray, header: dict): + def _on_preview_update(self, img: np.ndarray, header: dict): """Send preview stream and update frame index counter""" - self.num_images_counter.put(header['frame'], force=True) + self.num_images_counter.put(header["frame"], force=True) 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. + 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) - # if self.backend is not None: - # self.backend.add_status_callback( - # status, - # success=[StdDaqStatus.IDLE, StdDaqStatus.FILE_SAVED], - # error=[StdDaqStatus.REJECTED, StdDaqStatus.ERROR], - # ) - # return status + Returns: + DeviceStatus: The status of the acquisition + """ + status = DeviceStatus(self) + if self.backend is not None: + self.backend.add_status_callback( + status, + success=[StdDaqStatus.IDLE, StdDaqStatus.FILE_SAVED], + error=[StdDaqStatus.REJECTED, StdDaqStatus.ERROR], + ) + return status ######################################## # Beamline Specific Implementations # @@ -590,11 +586,11 @@ class GigaFrostCamera(PSIDeviceBase, GigaFrostBase): ) self.num_images.set(num_points).wait() if "daq_file_path" in scan_args and scan_args["daq_file_path"] is not None: - self.file_path.set(scan_args['daq_file_path']).wait() + self.file_path.set(scan_args["daq_file_path"]).wait() if "daq_file_prefix" in scan_args and scan_args["daq_file_prefix"] is not None: - self.file_prefix.set(scan_args['daq_file_prefix']).wait() + self.file_prefix.set(scan_args["daq_file_prefix"]).wait() if "daq_num_images" in scan_args and scan_args["daq_num_images"] is not None: - self.num_images.set(scan_args['daq_num_images']).wait() + self.num_images.set(scan_args["daq_num_images"]).wait() # Start stdDAQ preview if self.live_preview is not None: self.live_preview.start() @@ -632,10 +628,13 @@ class GigaFrostCamera(PSIDeviceBase, GigaFrostBase): and self.trigger_mode == "auto" and self.enable_mode == "soft" ): + t_start = time() # BEC teststand operation mode: posedge of SoftEnable if Started self.soft_enable.set(0).wait() self.soft_enable.set(1).wait() + logger.info(f"Elapsed: {time()-t_start}") + if self.acquire_block.get(): wait_time = 0.2 + 0.001 * self.num_exposures.value * max( self.acquire_time.value, self.acquire_period.value @@ -647,8 +646,7 @@ class GigaFrostCamera(PSIDeviceBase, GigaFrostBase): def on_complete(self) -> DeviceStatus | None: """Called to inquire if a device has completed a scans.""" - # return self.acq_done() - return None + return self.acq_done() def on_kickoff(self) -> DeviceStatus | None: """Called to kickoff a device for a fly scan. Has to be called explicitly.""" @@ -667,6 +665,6 @@ if __name__ == "__main__": auto_soft_enable=True, 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:20000', + std_daq_live="tcp://129.129.95.111:20000", ) gf.wait_for_connection() diff --git a/tomcat_bec/devices/gigafrost/pcoedge_base.py b/tomcat_bec/devices/gigafrost/pcoedge_base.py new file mode 100644 index 0000000..f4e6316 --- /dev/null +++ b/tomcat_bec/devices/gigafrost/pcoedge_base.py @@ -0,0 +1,176 @@ +# -*- coding: utf-8 -*- +""" +Created on Wed Dec 6 11:33:54 2023 + +@author: mohacsi_i +""" +from ophyd import Component as Cpt +from ophyd import Device, DynamicDeviceComponent, EpicsSignal, EpicsSignalRO, Kind, Signal + + +class PcoEdgeBase(Device): + """Ophyd baseclass for Helge camera IOCs + + This class provides wrappers for Helge's camera IOCs around SwissFEL and + for high performance SLS 2.0 cameras. The IOC's operation is a bit arcane + and there are different versions and cameras all around. So this device + only covers the absolute basics. + + Probably the most important part is the configuration state machine. As + the SET_PARAMS takes care of buffer allocations it might take some time, + as well as a full re-configuration is required every time we change the + binning, roi, etc... This is automatically performed upon starting an + exposure (if it heven't been done before). + + The status flag state machine during re-configuration is: + BUSY low, SET low -> BUSY high, SET low -> BUSY low, SET high -> BUSY low, SET low + + + UPDATE: Data sending operation modes + - Switch to ZMQ streaming by setting FILEFORMAT to ZEROMQ + - Set SAVESTART and SAVESTOP to select a ROI of image indices + - Start file transfer with FTRANSFER. + The ZMQ connection operates in PUSH-PULL mode, i.e. it needs incoming connection. + + STOREMODE sets the acquisition mode: + if STOREMODE == Recorder + Fills up the buffer with images. Here SAVESTART and SAVESTOP selects a ROI + of image indices to be streamed out (i.e. maximum buffer_size number of images) + + if STOREMODE == FIFO buffer + Continously streams out data using the buffer as a FIFO queue. + Here SAVESTART and SAVESTOP selects a ROI of image indices to be streamed continously + (i.e. a large SAVESTOP streams indefinitely). Note that in FIFO mode buffer reads are + destructive. to prevent this, we don't have EPICS preview + """ + + # ######################################################################## + # General hardware info (in AD nomenclature) + manufacturer = Cpt(EpicsSignalRO, "QUERY", kind=Kind.config, doc="Camera manufacturer info") + model = Cpt(EpicsSignalRO, "BOARD", kind=Kind.omitted, doc="Camera board info") + + # ######################################################################## + # Acquisition configuration (in AD nomenclature) + acquire = Cpt(EpicsSignal, "CAMERASTATUS", put_complete=True, kind=Kind.omitted) + acquire_time = Cpt( + EpicsSignal, "EXPOSURE", put_complete=True, auto_monitor=True, kind=Kind.config + ) + acquire_delay = Cpt( + EpicsSignal, "DELAY", put_complete=True, auto_monitor=True, kind=Kind.config + ) + trigger_mode = Cpt( + EpicsSignal, "TRIGGER", put_complete=True, auto_monitor=True, kind=Kind.config + ) + # num_exposures = Cpt( + # EpicsSignal, "CNT_NUM", put_complete=True, auto_monitor=True, kind=Kind.config + # ) + + array_size = DynamicDeviceComponent( + { + "array_size_x": (EpicsSignal, "WIDTH", {"auto_monitor": True, "put_complete": True}), + "array_size_y": (EpicsSignal, "HEIGHT", {"auto_monitor": True, "put_complete": True}), + }, + doc="Size of the array in the XY dimensions", + ) + + # DAQ parameters + file_path = Cpt(Signal, kind=Kind.config, value="/gpfs/test/test-beamline") + file_prefix = Cpt(Signal, kind=Kind.config, value="scan_") + num_images = Cpt(Signal, kind=Kind.config, value=1000) + num_images_counter = Cpt(Signal, kind=Kind.hinted, value=0) + + # GF specific interface + acquire_block = Cpt(Signal, kind=Kind.config, value=0) + + # ######################################################################## + # Image size configuration (in AD nomenclature) + bin_x = Cpt(EpicsSignal, "BINX", put_complete=True, auto_monitor=True, kind=Kind.config) + bin_y = Cpt(EpicsSignal, "BINY", put_complete=True, auto_monitor=True, kind=Kind.config) + + # ######################################################################## + # Additional status info + busy = Cpt(EpicsSignalRO, "BUSY", auto_monitor=True, kind=Kind.config) + camState = Cpt(EpicsSignalRO, "SS_CAMERA", auto_monitor=True, kind=Kind.config) + camProgress = Cpt(EpicsSignalRO, "CAMPROGRESS", auto_monitor=True, kind=Kind.config) + + # ######################################################################## + # Configuration state maschine with separate transition states + set_param = Cpt( + EpicsSignal, + "BUSY_SET_PARAM", + write_pv="SET_PARAM", + put_complete=True, + auto_monitor=True, + kind=Kind.config, + ) + + camera_statuscode = Cpt(EpicsSignalRO, "STATUSCODE", auto_monitor=True, kind=Kind.config) + camera_init = Cpt(EpicsSignalRO, "INIT", auto_monitor=True, kind=Kind.config) + camera_init_busy = Cpt(EpicsSignalRO, "BUSY_INIT", auto_monitor=True, kind=Kind.config) + # camCamera = Cpt(EpicsSignalRO, "CAMERA", auto_monitor=True, kind=Kind.config) + # camCameraBusy = Component(EpicsSignalRO, "BUSY_CAMERA", auto_monitor=True, kind=Kind.config) + + # ######################################################################## + # Acquisition configuration + acquire_mode = Cpt(EpicsSignalRO, "ACQMODE", auto_monitor=True, kind=Kind.config) + acquire_trigger = Cpt(EpicsSignalRO, "TRIGGER", auto_monitor=True, kind=Kind.config) + # acqTriggerSource = Component( + # EpicsSignalRO, "TRIGGERSOURCE", auto_monitor=True, kind=Kind.config) + # acqTriggerEdge = Component(EpicsSignalRO, "TRIGGEREDGE", auto_monitor=True, kind=Kind.config) + + # ######################################################################## + # Buffer configuration + bufferRecMode = Cpt(EpicsSignalRO, "RECMODE", auto_monitor=True, kind=Kind.config) + bufferStoreMode = Cpt(EpicsSignal, "STOREMODE", auto_monitor=True, kind=Kind.config) + fileRecMode = Cpt(EpicsSignalRO, "RECMODE", auto_monitor=True, kind=Kind.config) + + buffer_used = Cpt(EpicsSignalRO, "PIC_BUFFER", auto_monitor=True, kind=Kind.normal) + buffer_size = Cpt(EpicsSignalRO, "PIC_MAX", auto_monitor=True, kind=Kind.normal) + buffer_clear = Cpt(EpicsSignal, "CLEARMEM", put_complete=True, kind=Kind.omitted) + + # ######################################################################## + # File saving/streaming interface + cam_data_rate = Cpt(EpicsSignalRO, "CAMRATE", auto_monitor=True, kind=Kind.normal) + file_data_rate = Cpt(EpicsSignalRO, "FILERATE", auto_monitor=True, kind=Kind.normal) + file_savestart = Cpt(EpicsSignal, "SAVESTART", put_complete=True, kind=Kind.config) + file_savestop = Cpt(EpicsSignal, "SAVESTOP", put_complete=True, kind=Kind.config) + file_format = Cpt(EpicsSignal, "FILEFORMAT", put_complete=True, kind=Kind.config) + file_transfer = Cpt(EpicsSignal, "FTRANSFER", put_complete=True, kind=Kind.config) + file_savebusy = Cpt(EpicsSignalRO, "FILESAVEBUSY", auto_monitor=True, kind=Kind.normal) + + # ######################################################################## + # Throtled image preview + image = Cpt(EpicsSignalRO, "FPICTURE", kind=Kind.omitted, doc="Throttled image preview") + + # ######################################################################## + # General hardware info + camError = Cpt(EpicsSignalRO, "ERRCODE", auto_monitor=True, kind=Kind.config) + camWarning = Cpt(EpicsSignalRO, "WARNCODE", auto_monitor=True, kind=Kind.config) + + @property + def state(self) -> str: + """Single word camera state""" + if self.set_param.value: + return "BUSY" + if self.camera_statuscode.value == 2 and self.camera_init.value == 1: + return "IDLE" + if self.camera_statuscode.value == 6 and self.camera_init.value == 1: + return "RUNNING" + # if self.camRemoval.value==0 and self.camInit.value==0: + if self.camera_init.value == 0: + return "OFFLINE" + # if self.camRemoval.value: + # return "REMOVED" + return "UNKNOWN" + + @state.setter + def state(self): + raise RuntimeError("State is a ReadOnly property") + + +# Automatically connect to test camera if directly invoked +if __name__ == "__main__": + + # Drive data collection + cam = PcoEdgeBase("X02DA-CCDCAM2:", name="mcpcam") + cam.wait_for_connection() diff --git a/tomcat_bec/devices/gigafrost/pcoedgecamera.py b/tomcat_bec/devices/gigafrost/pcoedgecamera.py index 0f046a6..6fc54f2 100644 --- a/tomcat_bec/devices/gigafrost/pcoedgecamera.py +++ b/tomcat_bec/devices/gigafrost/pcoedgecamera.py @@ -7,19 +7,16 @@ Created on Wed Dec 6 11:33:54 2023 import time from ophyd import Component, EpicsSignal, EpicsSignalRO, Kind from ophyd.status import SubscriptionStatus, DeviceStatus -from ophyd_devices import BECDeviceBase -from ophyd_devices.interfaces.base_classes.psi_detector_base import ( - CustomDetectorMixin as CustomPrepare, -) +from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase -try: - from bec_lib import bec_logger +from tomcat_bec.devices.gigafrost.pcoedge_base import PcoEdgeBase +from tomcat_bec.devices.gigafrost.std_daq_preview import StdDaqPreview +from tomcat_bec.devices.gigafrost.std_daq_client import StdDaqClient, StdDaqStatus - logger = bec_logger.logger -except ModuleNotFoundError: - import logging - logger = logging.getLogger("PcoEdgeCam") +from bec_lib.logger import bec_logger + +logger = bec_logger.logger class PcoEdgeCameraMixin(CustomPrepare): @@ -27,119 +24,9 @@ class PcoEdgeCameraMixin(CustomPrepare): This class will be called by the custom_prepare_cls attribute of the detector class. """ - # pylint: disable=protected-access - def on_stage(self) -> None: - """Configure and arm PCO.Edge camera for acquisition""" - - # PCO can finish a run without explicit unstaging - if self.parent.state not in ("IDLE"): - logger.warning( - f"Trying to stage the camera from state {self.parent.state}, unstaging it first!" - ) - self.parent.unstage() - time.sleep(0.5) - - # 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 "exp_burst" in scanargs and scanargs["exp_burst"] is not None: - d["exposure_num_burst"] = scanargs["exp_burst"] - 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_period" in scanargs and scanargs["exp_period"] is not None: - d["exposure_period_ms"] = scanargs["exp_period"] - # if 'exp_burst' in scanargs and scanargs['exp_burst'] is not None: - # d['exposure_num_burst'] = scanargs['exp_burst'] - # if 'acq_mode' in scanargs and scanargs['acq_mode'] is not None: - # d['acq_mode'] = scanargs['acq_mode'] - # elif self.parent.scaninfo.scan_type == "step": - # d['acq_mode'] = "default" - if "pco_store_mode" in scanargs and scanargs["pco_store_mode"] is not None: - d["store_mode"] = scanargs["pco_store_mode"] - if "pco_data_format" in scanargs and scanargs["pco_data_format"] is not None: - d["data_format"] = scanargs["pco_data_format"] - - # Perform bluesky-style configuration - if len(d) > 0: - logger.warning(f"[{self.parent.name}] Configuring with:\n{d}") - self.parent.configure(d=d) - - # ARM the camera - self.parent.bluestage() - - def on_unstage(self) -> None: - """Disarm the PCO.Edge camera""" - self.parent.blueunstage() - - def on_stop(self) -> None: - """Stop the PCO.Edge camera""" - self.parent.blueunstage() - - def on_trigger(self) -> None | DeviceStatus: - """Trigger mode operation - - Use it to repeatedly record a fixed number of frames and send it to stdDAQ. The method waits - for the acquisition and data transfer to complete. - - NOTE: Maciej confirmed that sparse data is no problem to the stdDAQ. - TODO: Optimize data transfer to launch at end and check completion at the beginning. - """ - # Ensure that previous data transfer finished - # def sentIt(*args, value, timestamp, **kwargs): - # return value==0 - # status = SubscriptionStatus(self.parent.file_savebusy, sentIt, timeout=120) - # status.wait() - - # Not sure if it always sends the first batch of images or the newest - def wait_bufferreset(*, old_value, value, timestamp, **_): - return (value < old_value) or (value == 0) - - self.parent.buffer_clear.set(1).wait() - status = SubscriptionStatus(self.parent.buffer_used, wait_bufferreset, timeout=5) - status.wait() - - t_expected = ( - self.parent.acquire_time.get() + self.parent.acquire_delay.get() - ) * self.parent.file_savestop.get() - - # Wait until the buffer fills up with enough images - def wait_acquisition(*, value, timestamp, **_): - num_target = self.parent.file_savestop.get() - # logger.warning(f"{value} of {num_target}") - return bool(value >= num_target) - max_wait = max(5, 5 * t_expected) - status = SubscriptionStatus( - self.parent.buffer_used, wait_acquisition, timeout=max_wait, settle_time=0.2 - ) - status.wait() - - # Then start file transfer (need to get the save busy flag update) - # self.parent.file_transfer.set(1, settle_time=0.2).wait() - self.parent.file_transfer.set(1).wait() - - # And wait until the images have been sent - # NOTE: this does not wait for new value, the first check will be - # against values from the previous cycle, i.e. pass automatically. - t_start = time.time() - - def wait_sending(*args, old_value, value, timestamp, **kwargs): - t_elapsed = timestamp - t_start - # logger.warning(f"{old_value}\t{value}\t{t_elapsed}") - return old_value == 1 and value == 0 and t_elapsed > 0 - - status = SubscriptionStatus( - self.parent.file_savebusy, wait_sending, timeout=120, settle_time=0.2 - ) - status.wait() -class HelgeCameraBase(BECDeviceBase): +class PcoEdge5M(PSIDeviceBase, PcoEdgeBase): """Ophyd baseclass for Helge camera IOCs This class provides wrappers for Helge's camera IOCs around SwissFEL and @@ -175,105 +62,57 @@ class HelgeCameraBase(BECDeviceBase): destructive. to prevent this, we don't have EPICS preview """ - # ######################################################################## - # General hardware info (in AD nomenclature) - manufacturer = Component(EpicsSignalRO, "QUERY", kind=Kind.config, doc="Camera model info") - model = Component(EpicsSignalRO, "BOARD", kind=Kind.omitted, doc="Camera board info") + # pylint: disable=too-many-instance-attributes + USER_ACCESS = [ + "complete", + "backend", + # "acq_done", + "live_preview", + "arm", + "disarm", + ] - # ######################################################################## - # Acquisition commands - camStatusCmd = Component(EpicsSignal, "CAMERASTATUS", put_complete=True, kind=Kind.config) + # Placeholders for stdDAQ and livestream clients + backend = None + live_preview = None - # ######################################################################## - # Acquisition configuration (in AD nomenclature) - acquire_time = Component( - EpicsSignal, "EXPOSURE", put_complete=True, auto_monitor=True, kind=Kind.config - ) - acquire_delay = Component( - EpicsSignal, "DELAY", put_complete=True, auto_monitor=True, kind=Kind.config - ) - trigger_mode = Component( - EpicsSignal, "TRIGGER", put_complete=True, auto_monitor=True, kind=Kind.config - ) - - # ######################################################################## - # Image size configuration (in AD nomenclature) - bin_x = Component(EpicsSignal, "BINX", put_complete=True, auto_monitor=True, kind=Kind.config) - bin_y = Component(EpicsSignal, "BINY", put_complete=True, auto_monitor=True, kind=Kind.config) - array_size_x = Component( - EpicsSignalRO, "WIDTH", auto_monitor=True, kind=Kind.config, doc="Final image width" - ) - array_size_y = Component( - EpicsSignalRO, "HEIGHT", auto_monitor=True, kind=Kind.config, doc="Final image height" - ) - - # ######################################################################## - # General hardware info - camError = Component(EpicsSignalRO, "ERRCODE", auto_monitor=True, kind=Kind.config) - camWarning = Component(EpicsSignalRO, "WARNCODE", auto_monitor=True, kind=Kind.config) - - # ######################################################################## - # Buffer configuration - bufferRecMode = Component(EpicsSignalRO, "RECMODE", auto_monitor=True, kind=Kind.config) - bufferStoreMode = Component(EpicsSignal, "STOREMODE", auto_monitor=True, kind=Kind.config) - fileRecMode = Component(EpicsSignalRO, "RECMODE", auto_monitor=True, kind=Kind.config) - - buffer_used = Component(EpicsSignalRO, "PIC_BUFFER", auto_monitor=True, kind=Kind.normal) - buffer_size = Component(EpicsSignalRO, "PIC_MAX", auto_monitor=True, kind=Kind.normal) - buffer_clear = Component(EpicsSignal, "CLEARMEM", put_complete=True, kind=Kind.omitted) - - # ######################################################################## - # File saving interface - cam_data_rate = Component(EpicsSignalRO, "CAMRATE", auto_monitor=True, kind=Kind.normal) - file_data_rate = Component(EpicsSignalRO, "FILERATE", auto_monitor=True, kind=Kind.normal) - file_savestart = Component(EpicsSignal, "SAVESTART", put_complete=True, kind=Kind.config) - file_savestop = Component(EpicsSignal, "SAVESTOP", put_complete=True, kind=Kind.config) - file_format = Component(EpicsSignal, "FILEFORMAT", put_complete=True, kind=Kind.config) - file_transfer = Component(EpicsSignal, "FTRANSFER", put_complete=True, kind=Kind.config) - file_savebusy = Component(EpicsSignalRO, "FILESAVEBUSY", auto_monitor=True, kind=Kind.normal) - - # ######################################################################## - # Configuration state maschine with separate transition states - camStatusCode = Component(EpicsSignalRO, "STATUSCODE", auto_monitor=True, kind=Kind.config) - camSetParam = Component(EpicsSignal, "SET_PARAM", auto_monitor=True, kind=Kind.config) - camSetParamBusy = Component( - EpicsSignalRO, "BUSY_SET_PARAM", auto_monitor=True, kind=Kind.config - ) - camCamera = Component(EpicsSignalRO, "CAMERA", auto_monitor=True, kind=Kind.config) - camCameraBusy = Component(EpicsSignalRO, "BUSY_CAMERA", auto_monitor=True, kind=Kind.config) - camInit = Component(EpicsSignalRO, "INIT", auto_monitor=True, kind=Kind.config) - camInitBusy = Component(EpicsSignalRO, "BUSY_INIT", auto_monitor=True, kind=Kind.config) - - # ######################################################################## - # Throtled image preview - image = Component(EpicsSignalRO, "FPICTURE", kind=Kind.omitted, doc="Throttled image preview") - - # ######################################################################## - # Misc PVs - # camRemoval = Component(EpicsSignalRO, "REMOVAL", auto_monitor=True, kind=Kind.config) - camStateString = Component( - EpicsSignalRO, "SS_CAMERA", string=True, auto_monitor=True, kind=Kind.config - ) - - @property - def state(self) -> str: - """Single word camera state""" - if self.camSetParamBusy.value: - return "BUSY" - if self.camStatusCode.value == 2 and self.camInit.value == 1: - return "IDLE" - if self.camStatusCode.value == 6 and self.camInit.value == 1: - return "RUNNING" - # if self.camRemoval.value==0 and self.camInit.value==0: - if self.camInit.value == 0: - return "OFFLINE" - # if self.camRemoval.value: - # return "REMOVED" - return "UNKNOWN" - - @state.setter - def state(self): - raise RuntimeError("State is a ReadOnly property") + def __init__( + self, + prefix="", + *, + name, + kind=None, + read_attrs=None, + configuration_attrs=None, + parent=None, + scan_info=None, + std_daq_rest: str | None = None, + std_daq_ws: str | None = None, + std_daq_live: str | None = None, + **kwargs, + ): + # super() will call the mixin class + super().__init__( + prefix=prefix, + name=name, + kind=kind, + read_attrs=read_attrs, + configuration_attrs=configuration_attrs, + parent=parent, + scan_info=scan_info, + **kwargs, + ) + # Configure the stdDAQ client + 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") + logger.error("No stdDAQ address provided, launching without data backend!") + else: + self.backend = StdDaqClient(parent=self, ws_url=std_daq_ws, rest_url=std_daq_rest) + # Configure image preview + if std_daq_live is not None: + self.live_preview = StdDaqPreview(url=std_daq_live, cb=self._on_preview_update) + else: + logger.error("No stdDAQ stream address provided, launching without preview!") def configure(self, d: dict = {}) -> tuple: """Configure the base Helge camera device @@ -308,6 +147,10 @@ class HelgeCameraBase(BECDeviceBase): self.acquire_delay.set(d["exposure_period_ms"]).wait() if "exposure_period_ms" in d: self.acquire_delay.set(d["exposure_period_ms"]).wait() + if "image_width" in d: + self.array_size.array_size_x.set(d["image_width"]).wait() + if "image_height" in d: + self.array_size.array_size_y.set(d["image_height"]).wait() if "store_mode" in d: self.bufferStoreMode.set(d["store_mode"]).wait() if "data_format" in d: @@ -320,16 +163,16 @@ class HelgeCameraBase(BECDeviceBase): # 2. BUSY goes low, SET goes high # 3. BUSY stays low, SET goes low # So we need a 'negedge' on SET_PARAM - self.camSetParam.set(1).wait() - def negedge(*, old_value, value, timestamp, **_): return bool(old_value and not value) # Subscribe and wait for update - status = SubscriptionStatus(self.camSetParam, negedge, timeout=5, settle_time=0.5) + status = SubscriptionStatus(self.set_param, negedge, timeout=5, settle_time=0.5) + + self.set_param.set(1).wait() status.wait() - def bluestage(self): + def arm(self): """Bluesky style stage: arm the detector""" logger.warning("Staging PCO") # Acquisition is only allowed when the IOC is not busy @@ -354,7 +197,7 @@ class HelgeCameraBase(BECDeviceBase): status = SubscriptionStatus(self.camStatusCode, is_running, timeout=5, settle_time=0.2) status.wait() - def blueunstage(self): + def disarm(self): """Bluesky style unstage: stop the detector""" self.camStatusCmd.set("Idle").wait() @@ -362,107 +205,190 @@ class HelgeCameraBase(BECDeviceBase): # FIXME: This might interrupt data transfer self.file_savestop.set(0).wait() - def bluekickoff(self): + def destroy(self): + if self.backend is not None: + self.backend.shutdown() + super().destroy() + + def _on_preview_update(self, img: np.ndarray, header: dict): + """Send preview stream and update frame index counter""" + self.num_images_counter.put(header["frame"], force=True) + 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) + if self.backend is not None: + self.backend.add_status_callback( + status, + success=[StdDaqStatus.IDLE, StdDaqStatus.FILE_SAVED], + error=[StdDaqStatus.REJECTED, StdDaqStatus.ERROR], + ) + return status + + ######################################## + # Beamline Specific Implementations # + ######################################## + + # pylint: disable=protected-access + def on_stage(self) -> None: + """Configure and arm PCO.Edge camera for acquisition""" + + # PCO can finish a run without explicit unstaging + if self.state not in ("IDLE"): + logger.warning( + f"Trying to stage the camera from state {self.state}, unstaging it first!" + ) + self.unstage() + time.sleep(0.5) + + # Fish out our configuration from scaninfo (via explicit or generic addressing) + scan_args = { + **self.scan_info.msg.request_inputs["inputs"], + **self.scan_info.msg.request_inputs["kwargs"], + **self.scan_info.msg.scan_parameters, + } + + d = {} + if "exp_burst" in scan_args and scan_args["exp_burst"] is not None: + d["exposure_num_burst"] = scan_args["exp_burst"] + if "image_width" in scan_args and scan_args["image_width"] is not None: + d["image_width"] = scan_args["image_width"] + if "image_height" in scan_args and scan_args["image_height"] is not None: + d["image_height"] = scan_args["image_height"] + if "exp_time" in scan_args and scan_args["exp_time"] is not None: + d["exposure_time_ms"] = scan_args["exp_time"] + if "exp_period" in scan_args and scan_args["exp_period"] is not None: + d["exposure_period_ms"] = scan_args["exp_period"] + # if 'exp_burst' in scan_args and scan_args['exp_burst'] is not None: + # d['exposure_num_burst'] = scan_args['exp_burst'] + # if 'acq_mode' in scan_args and scan_args['acq_mode'] is not None: + # d['acq_mode'] = scan_args['acq_mode'] + # elif self.scaninfo.scan_type == "step": + # d['acq_mode'] = "default" + if "pco_store_mode" in scan_args and scan_args["pco_store_mode"] is not None: + d["store_mode"] = scan_args["pco_store_mode"] + if "pco_data_format" in scan_args and scan_args["pco_data_format"] is not None: + d["data_format"] = scan_args["pco_data_format"] + + # Perform bluesky-style configuration + if d: + logger.warning(f"[{self.name}] Configuring with:\n{d}") + self.configure(d=d) + + # stdDAQ backend parameters + num_points = ( + 1 + * scan_args.get("steps", 1) + * scan_args.get("exp_burst", 1) + * scan_args.get("repeats", 1) + * scan_args.get("burst_at_each_point", 1) + ) + self.num_images.set(num_points).wait() + if "daq_file_path" in scan_args and scan_args["daq_file_path"] is not None: + self.file_path.set(scan_args["daq_file_path"]).wait() + if "daq_file_prefix" in scan_args and scan_args["daq_file_prefix"] is not None: + self.file_prefix.set(scan_args["daq_file_prefix"]).wait() + if "daq_num_images" in scan_args and scan_args["daq_num_images"] is not None: + self.num_images.set(scan_args["daq_num_images"]).wait() + # Start stdDAQ preview + if self.live_preview is not None: + self.live_preview.start() + + def on_unstage(self) -> None: + """Disarm the PCO.Edge camera""" + self.disarm() + if self.backend is not None: + logger.info(f"StdDaq status before unstage: {self.backend.status}") + self.backend.stop() + + def on_pre_scan(self) -> DeviceStatus | None: + """Called right before the scan starts on all devices automatically.""" + # First start the stdDAQ + if self.backend is not None: + self.backend.start( + file_path=self.file_path.get(), + file_prefix=self.file_prefix.get(), + num_images=self.num_images.get(), + ) + # Then start the camera + self.arm() + + def on_trigger(self) -> None | DeviceStatus: + """Trigger mode operation + + Use it to repeatedly record a fixed number of frames and send it to stdDAQ. The method waits + for the acquisition and data transfer to complete. + + NOTE: Maciej confirmed that sparse data is no problem to the stdDAQ. + TODO: Optimize data transfer to launch at end and check completion at the beginning. + """ + # Ensure that previous data transfer finished + # def sentIt(*args, value, timestamp, **kwargs): + # return value==0 + # status = SubscriptionStatus(self.file_savebusy, sentIt, timeout=120) + # status.wait() + + # Not sure if it always sends the first batch of images or the newest + def wait_bufferreset(*, old_value, value, timestamp, **_): + return (value < old_value) or (value == 0) + + self.buffer_clear.set(1).wait() + status = SubscriptionStatus(self.buffer_used, wait_bufferreset, timeout=5) + status.wait() + + t_expected = (self.acquire_time.get() + self.acquire_delay.get()) * self.file_savestop.get() + + # Wait until the buffer fills up with enough images + def wait_acquisition(*, value, timestamp, **_): + num_target = self.file_savestop.get() + # logger.warning(f"{value} of {num_target}") + return bool(value >= num_target) + + max_wait = max(5, 5 * t_expected) + status = SubscriptionStatus( + self.buffer_used, wait_acquisition, timeout=max_wait, settle_time=0.2 + ) + status.wait() + + # Then start file transfer (need to get the save busy flag update) + # self.file_transfer.set(1, settle_time=0.2).wait() + self.file_transfer.set(1).wait() + + # And wait until the images have been sent + # NOTE: this does not wait for new value, the first check will be + # against values from the previous cycle, i.e. pass automatically. + t_start = time.time() + + def wait_sending(*, old_value, value, timestamp, **kwargs): + t_elapsed = timestamp - t_start + # logger.warning(f"{old_value}\t{value}\t{t_elapsed}") + return old_value == 1 and value == 0 and t_elapsed > 0 + + status = SubscriptionStatus(self.file_savebusy, wait_sending, timeout=120, settle_time=0.2) + status.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: """Start data transfer TODO: Need to revisit this once triggering is complete """ self.file_transfer.set(1).wait() - -class PcoEdge5M(HelgeCameraBase): - """Ophyd baseclass for PCO.Edge cameras - - This class provides wrappers for Helge's camera IOCs around SwissFEL and - for high performance SLS 2.0 cameras. Theese are mostly PCO cameras running - on a special Windows IOC host with lots of RAM and CPU power. - """ - - custom_prepare_cls = PcoEdgeCameraMixin - USER_ACCESS = ["bluestage", "blueunstage", "bluekickoff"] - - # ######################################################################## - # Additional status info - busy = Component(EpicsSignalRO, "BUSY", auto_monitor=True, kind=Kind.config) - camState = Component(EpicsSignalRO, "SS_CAMERA", auto_monitor=True, kind=Kind.config) - camProgress = Component(EpicsSignalRO, "CAMPROGRESS", auto_monitor=True, kind=Kind.config) - camRate = Component(EpicsSignalRO, "CAMRATE", auto_monitor=True, kind=Kind.config) - - # ######################################################################## - # Acquisition configuration - acqMode = Component(EpicsSignalRO, "ACQMODE", auto_monitor=True, kind=Kind.config) - acqDelay = Component(EpicsSignalRO, "DELAY", auto_monitor=True, kind=Kind.config) - acqTriggerEna = Component(EpicsSignalRO, "TRIGGER", auto_monitor=True, kind=Kind.config) - # acqTriggerSource = Component( - # EpicsSignalRO, "TRIGGERSOURCE", auto_monitor=True, kind=Kind.config) - # acqTriggerEdge = Component(EpicsSignalRO, "TRIGGEREDGE", auto_monitor=True, kind=Kind.config) - - # ######################################################################## - # Image size settings - # Priority is: binning -> roi -> final size - pxRoiX_lo = Component( - EpicsSignal, "REGIONX_START", put_complete=True, auto_monitor=True, kind=Kind.config - ) - pxRoiX_hi = Component( - EpicsSignal, "REGIONX_END", put_complete=True, auto_monitor=True, kind=Kind.config - ) - pxRoiY_lo = Component( - EpicsSignal, "REGIONY_START", put_complete=True, auto_monitor=True, kind=Kind.config - ) - pxRoiY_hi = Component( - EpicsSignal, "REGIONY_END", put_complete=True, auto_monitor=True, kind=Kind.config - ) - - def configure(self, d: dict = {}) -> tuple: - """ - Camera configuration instructions: - After setting the corresponding PVs, one needs to process SET_PARAM and wait until - BUSY_SET_PARAM goes high and low, followed by SET_PARAM goes high and low. This will - both send the settings to the camera and allocate the necessary buffers in the correct - size and shape (that takes time). Starting the exposure with CAMERASTATUS will also - call SET_PARAM, but it might take long. - - NOTE: - The camera IOC will automatically round up RoiX coordinates to the - next multiple of 160. This means that configure can only change image - width in steps of 320 pixels (or manually of 160). Roi - - Parameters as 'd' dictionary - ---------------------------- - exposure_time_ms : float, optional - Exposure time [ms]. - exposure_period_ms : float, optional - Exposure period [ms], ignored in soft trigger mode. - image_width : int, optional - ROI size in the x-direction, multiple of 320 [pixels] - image_height : int, optional - ROI size in the y-direction, multiple of 2 [pixels] - image_binx : int optional - Binning along image width [pixels] - image_biny: int, optional - Binning along image height [pixels] - acq_mode : str, not yet implemented - Select one of the pre-configured trigger behavior - """ - if d is not None: - # Need to be smart how we set the ROI.... - # Image sensor is 2560x2160 (X and Y) - # Values are rounded to multiples of 16 - if "image_width" in d and d["image_width"] is not None: - width = d["image_width"] - self.pxRoiX_lo.set(2560 / 2 - width / 2).wait() - self.pxRoiX_hi.set(2560 / 2 + width / 2).wait() - if "image_height" in d and d["image_height"] is not None: - height = d["image_height"] - self.pxRoiY_lo.set(2160 / 2 - height / 2).wait() - self.pxRoiY_hi.set(2160 / 2 + height / 2).wait() - if "image_binx" in d and d["image_binx"] is not None: - self.bin_x.set(d["image_binx"]).wait() - if "image_biny" in d and d["image_biny"] is not None: - self.bin_y.set(d["image_biny"]).wait() - - # Call super() to commit the changes - super().configure(d) + def on_stop(self) -> None: + """Called when the device is stopped.""" + return self.on_unstage() # Automatically connect to test camera if directly invoked diff --git a/tomcat_bec/devices/gigafrost/std_daq_client.py b/tomcat_bec/devices/gigafrost/std_daq_client.py index d72a502..9d7ca5e 100644 --- a/tomcat_bec/devices/gigafrost/std_daq_client.py +++ b/tomcat_bec/devices/gigafrost/std_daq_client.py @@ -306,10 +306,10 @@ class StdDaqClient: msg_timestamp = time.time() except TimeoutError: continue - except WebSocketException: - content = traceback.format_exc() - # TODO: this is expected to happen on every reconfiguration - logger.warning(f"Websocket connection closed unexpectedly: {content}") + except WebSocketException as ex: + # content = traceback.format_exc() + # TODO: ConnectionCloserError is expected to happen on every reconfiguration + logger.warning(f"Websocket connection closed unexpectedly: {ex}") self.wait_for_connection() continue msg = json.loads(msg) From bb5c2316e15d00d236e98668a03c6c9cb6decda3 Mon Sep 17 00:00:00 2001 From: gac-x05la Date: Fri, 21 Mar 2025 16:11:10 +0100 Subject: [PATCH 11/20] WIP --- tomcat_bec/devices/gigafrost/pcoedgecamera.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/tomcat_bec/devices/gigafrost/pcoedgecamera.py b/tomcat_bec/devices/gigafrost/pcoedgecamera.py index 6fc54f2..220c9c6 100644 --- a/tomcat_bec/devices/gigafrost/pcoedgecamera.py +++ b/tomcat_bec/devices/gigafrost/pcoedgecamera.py @@ -5,6 +5,7 @@ Created on Wed Dec 6 11:33:54 2023 @author: mohacsi_i """ import time +import numpy as np from ophyd import Component, EpicsSignal, EpicsSignalRO, Kind from ophyd.status import SubscriptionStatus, DeviceStatus from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase @@ -19,13 +20,6 @@ from bec_lib.logger import bec_logger logger = bec_logger.logger -class PcoEdgeCameraMixin(CustomPrepare): - """Mixin class to setup the Helge camera bae class. - - This class will be called by the custom_prepare_cls attribute of the detector class. - """ - - class PcoEdge5M(PSIDeviceBase, PcoEdgeBase): """Ophyd baseclass for Helge camera IOCs @@ -377,7 +371,8 @@ class PcoEdge5M(PSIDeviceBase, PcoEdgeBase): def on_complete(self) -> DeviceStatus | None: """Called to inquire if a device has completed a scans.""" - return self.acq_done() + # return self.acq_done() + return None def on_kickoff(self) -> DeviceStatus | None: """Start data transfer @@ -395,5 +390,11 @@ class PcoEdge5M(PSIDeviceBase, PcoEdgeBase): if __name__ == "__main__": # Drive data collection - cam = PcoEdge5M("X02DA-CCDCAM2:", name="mcpcam") + cam = PcoEdge5M( + "X02DA-CCDCAM2:", + name="mcpcam", + std_daq_ws="ws://129.129.95.111:8081", + std_daq_rest="http://129.129.95.111:5010", + std_daq_live="tcp://129.129.95.111:20010", + ) cam.wait_for_connection() From eff10e13860f18b72df9113b02b642ee3f556fc0 Mon Sep 17 00:00:00 2001 From: gac-x05la Date: Fri, 21 Mar 2025 16:22:03 +0100 Subject: [PATCH 12/20] WIP --- .../device_configs/microxas_test_bed.yaml | 107 +++++++++--------- .../devices/gigafrost/std_daq_client.py | 8 +- 2 files changed, 59 insertions(+), 56 deletions(-) diff --git a/tomcat_bec/device_configs/microxas_test_bed.yaml b/tomcat_bec/device_configs/microxas_test_bed.yaml index 85b159b..b884e06 100644 --- a/tomcat_bec/device_configs/microxas_test_bed.yaml +++ b/tomcat_bec/device_configs/microxas_test_bed.yaml @@ -139,50 +139,53 @@ gfcam: 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' - data_source_name: 'gfcam' - deviceTags: - - std-daq - - gfcam - enabled: false - onFailure: buffer - readOnly: false - readoutPriority: monitored - softwareTrigger: false - -gf_stream0: - description: stdDAQ preview (2 every 555) - deviceClass: tomcat_bec.devices.StdDaqPreviewDetector - deviceConfig: - url: 'tcp://129.129.95.111:20000' - deviceTags: - - std-daq - - gfcam - enabled: false - onFailure: buffer - readOnly: false - readoutPriority: monitored - softwareTrigger: false - -# pcocam: -# description: PCO.edge camera client -# deviceClass: tomcat_bec.devices.PcoEdge5M +# gfdaq: +# description: GigaFrost stdDAQ client +# deviceClass: tomcat_bec.devices.StdDaqClient # deviceConfig: -# prefix: 'X02DA-CCDCAM2:' +# ws_url: 'ws://129.129.95.111:8080' +# rest_url: 'http://129.129.95.111:5000' +# data_source_name: 'gfcam' # deviceTags: -# - camera -# - trigger -# - pcocam -# enabled: true +# - std-daq +# - gfcam +# enabled: false # onFailure: buffer # readOnly: false # readoutPriority: monitored -# softwareTrigger: true +# softwareTrigger: false + +# gf_stream0: +# description: stdDAQ preview (2 every 555) +# deviceClass: tomcat_bec.devices.StdDaqPreviewDetector +# deviceConfig: +# url: 'tcp://129.129.95.111:20000' +# deviceTags: +# - std-daq +# - gfcam +# enabled: false +# onFailure: buffer +# readOnly: false +# readoutPriority: monitored +# softwareTrigger: false + +pcocam: + description: PCO.edge camera client + deviceClass: tomcat_bec.devices.PcoEdge5M + deviceConfig: + prefix: 'X02DA-CCDCAM2:' + std_daq_live: 'tcp://129.129.95.111:20010' + std_daq_ws: 'ws://129.129.95.111:8081' + std_daq_rest: 'http://129.129.95.111:5010' + deviceTags: + - camera + - trigger + - pcocam + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: monitored + softwareTrigger: true # pcodaq: # description: GigaFrost stdDAQ client @@ -199,16 +202,16 @@ gf_stream0: # readoutPriority: monitored # softwareTrigger: false -pco_stream0: - description: stdDAQ preview (2 every 555) - deviceClass: tomcat_bec.devices.StdDaqPreviewDetector - deviceConfig: - url: 'tcp://129.129.95.111:20010' - deviceTags: - - std-daq - - pcocam - enabled: true - onFailure: buffer - readOnly: false - readoutPriority: monitored - softwareTrigger: false +# pco_stream0: +# description: stdDAQ preview (2 every 555) +# deviceClass: tomcat_bec.devices.StdDaqPreviewDetector +# deviceConfig: +# url: 'tcp://129.129.95.111:20010' +# deviceTags: +# - std-daq +# - pcocam +# enabled: true +# onFailure: buffer +# readOnly: false +# readoutPriority: monitored +# softwareTrigger: false diff --git a/tomcat_bec/devices/gigafrost/std_daq_client.py b/tomcat_bec/devices/gigafrost/std_daq_client.py index 9d7ca5e..d72a502 100644 --- a/tomcat_bec/devices/gigafrost/std_daq_client.py +++ b/tomcat_bec/devices/gigafrost/std_daq_client.py @@ -306,10 +306,10 @@ class StdDaqClient: msg_timestamp = time.time() except TimeoutError: continue - except WebSocketException as ex: - # content = traceback.format_exc() - # TODO: ConnectionCloserError is expected to happen on every reconfiguration - logger.warning(f"Websocket connection closed unexpectedly: {ex}") + except WebSocketException: + content = traceback.format_exc() + # TODO: this is expected to happen on every reconfiguration + logger.warning(f"Websocket connection closed unexpectedly: {content}") self.wait_for_connection() continue msg = json.loads(msg) From f64d603cff4b3f93268c0c08bbaef851e7b62902 Mon Sep 17 00:00:00 2001 From: gac-x05la Date: Fri, 21 Mar 2025 17:00:38 +0100 Subject: [PATCH 13/20] Zombie thread fix --- .../device_configs/microxas_test_bed.yaml | 38 +++++++++---------- tomcat_bec/devices/gigafrost/pcoedgecamera.py | 1 + .../devices/gigafrost/std_daq_client.py | 13 +++++-- 3 files changed, 30 insertions(+), 22 deletions(-) diff --git a/tomcat_bec/device_configs/microxas_test_bed.yaml b/tomcat_bec/device_configs/microxas_test_bed.yaml index b884e06..1dd228d 100644 --- a/tomcat_bec/device_configs/microxas_test_bed.yaml +++ b/tomcat_bec/device_configs/microxas_test_bed.yaml @@ -119,25 +119,25 @@ femto_mean_curr: # readoutPriority: monitored # softwareTrigger: true -gfcam: - description: GigaFrost camera client - deviceClass: tomcat_bec.devices.GigaFrostCamera - deviceConfig: - prefix: 'X02DA-CAM-GF2:' - backend_url: 'http://sls-daq-001:8080' - auto_soft_enable: true - std_daq_live: 'tcp://129.129.95.111:20000' - std_daq_ws: 'ws://129.129.95.111:8080' - std_daq_rest: 'http://129.129.95.111:5000' - deviceTags: - - camera - - trigger - - gfcam - enabled: true - onFailure: buffer - readOnly: false - readoutPriority: monitored - softwareTrigger: true +# gfcam: +# description: GigaFrost camera client +# deviceClass: tomcat_bec.devices.GigaFrostCamera +# deviceConfig: +# prefix: 'X02DA-CAM-GF2:' +# backend_url: 'http://sls-daq-001:8080' +# auto_soft_enable: true +# std_daq_live: 'tcp://129.129.95.111:20000' +# std_daq_ws: 'ws://129.129.95.111:8080' +# std_daq_rest: 'http://129.129.95.111:5000' +# deviceTags: +# - camera +# - trigger +# - gfcam +# enabled: true +# onFailure: buffer +# readOnly: false +# readoutPriority: monitored +# softwareTrigger: true # gfdaq: # description: GigaFrost stdDAQ client diff --git a/tomcat_bec/devices/gigafrost/pcoedgecamera.py b/tomcat_bec/devices/gigafrost/pcoedgecamera.py index 220c9c6..1e8105d 100644 --- a/tomcat_bec/devices/gigafrost/pcoedgecamera.py +++ b/tomcat_bec/devices/gigafrost/pcoedgecamera.py @@ -200,6 +200,7 @@ class PcoEdge5M(PSIDeviceBase, PcoEdgeBase): self.file_savestop.set(0).wait() def destroy(self): + logger.warning("Destroy called") if self.backend is not None: self.backend.shutdown() super().destroy() diff --git a/tomcat_bec/devices/gigafrost/std_daq_client.py b/tomcat_bec/devices/gigafrost/std_daq_client.py index d72a502..fc4364c 100644 --- a/tomcat_bec/devices/gigafrost/std_daq_client.py +++ b/tomcat_bec/devices/gigafrost/std_daq_client.py @@ -54,7 +54,7 @@ class StdDaqClient: _status: StdDaqStatus = StdDaqStatus.UNDEFINED _status_timestamp: float | None = None _ws_recv_mutex = threading.Lock() - _ws_update_thread: threading.Thread | None = None + _ws_monitor_thread: threading.Thread | None = None _shutdown_event = threading.Event() _ws_idle_event = threading.Event() _daq_is_running = threading.Event() @@ -272,11 +272,16 @@ class StdDaqClient: """ Shutdown the StdDAQ client. """ - if self._ws_update_thread is not None: - self._ws_update_thread.join() + logger.warning("Shutting down sdtDAQ monitor") + self._shutdown_event.set() + if self._ws_monitor_thread is not None: + self._ws_monitor_thread.join() + logger.warning("Shutdown joined") if self._ws_client is not None: self._ws_client.close() self._ws_client = None + logger.warning("Shutdown complete") + def _wait_for_server_running(self): """ @@ -296,10 +301,12 @@ class StdDaqClient: callbacks. It also handles stdDAQ restarts and reconnection by itself. """ if self._ws_recv_mutex.locked(): + logger.warning("stdDAQ WS monitor loop already locked") return with self._ws_recv_mutex: while not self._shutdown_event.is_set(): + logger.warning("NI") self._wait_for_server_running() try: msg = self._ws_client.recv(timeout=0.1) From 5726b4349a358bbcd1c21a6ef0f2eacd52620e9a Mon Sep 17 00:00:00 2001 From: gac-x05la Date: Fri, 21 Mar 2025 17:37:49 +0100 Subject: [PATCH 14/20] I'm up against higher powers --- .../device_configs/microxas_test_bed.yaml | 72 +++++++++---------- tomcat_bec/devices/gigafrost/pco_datasink.py | 2 +- tomcat_bec/devices/gigafrost/pcoedgecamera.py | 7 +- .../devices/gigafrost/std_daq_client.py | 9 ++- .../devices/gigafrost/std_daq_preview.py | 5 +- 5 files changed, 48 insertions(+), 47 deletions(-) diff --git a/tomcat_bec/device_configs/microxas_test_bed.yaml b/tomcat_bec/device_configs/microxas_test_bed.yaml index 1dd228d..d16dfd2 100644 --- a/tomcat_bec/device_configs/microxas_test_bed.yaml +++ b/tomcat_bec/device_configs/microxas_test_bed.yaml @@ -119,25 +119,25 @@ femto_mean_curr: # readoutPriority: monitored # softwareTrigger: true -# gfcam: -# description: GigaFrost camera client -# deviceClass: tomcat_bec.devices.GigaFrostCamera -# deviceConfig: -# prefix: 'X02DA-CAM-GF2:' -# backend_url: 'http://sls-daq-001:8080' -# auto_soft_enable: true -# std_daq_live: 'tcp://129.129.95.111:20000' -# std_daq_ws: 'ws://129.129.95.111:8080' -# std_daq_rest: 'http://129.129.95.111:5000' -# deviceTags: -# - camera -# - trigger -# - gfcam -# enabled: true -# onFailure: buffer -# readOnly: false -# readoutPriority: monitored -# softwareTrigger: true +gfcam: + description: GigaFrost camera client + deviceClass: tomcat_bec.devices.GigaFrostCamera + deviceConfig: + prefix: 'X02DA-CAM-GF2:' + backend_url: 'http://sls-daq-001:8080' + auto_soft_enable: true + std_daq_live: 'tcp://129.129.95.111:20000' + std_daq_ws: 'ws://129.129.95.111:8080' + std_daq_rest: 'http://129.129.95.111:5000' + deviceTags: + - camera + - trigger + - gfcam + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: monitored + softwareTrigger: true # gfdaq: # description: GigaFrost stdDAQ client @@ -169,23 +169,23 @@ femto_mean_curr: # readoutPriority: monitored # softwareTrigger: false -pcocam: - description: PCO.edge camera client - deviceClass: tomcat_bec.devices.PcoEdge5M - deviceConfig: - prefix: 'X02DA-CCDCAM2:' - std_daq_live: 'tcp://129.129.95.111:20010' - std_daq_ws: 'ws://129.129.95.111:8081' - std_daq_rest: 'http://129.129.95.111:5010' - deviceTags: - - camera - - trigger - - pcocam - enabled: true - onFailure: buffer - readOnly: false - readoutPriority: monitored - softwareTrigger: true +# pcocam: +# description: PCO.edge camera client +# deviceClass: tomcat_bec.devices.PcoEdge5M +# deviceConfig: +# prefix: 'X02DA-CCDCAM2:' +# std_daq_live: 'tcp://129.129.95.111:20010' +# std_daq_ws: 'ws://129.129.95.111:8081' +# std_daq_rest: 'http://129.129.95.111:5010' +# deviceTags: +# - camera +# - trigger +# - pcocam +# enabled: true +# onFailure: buffer +# readOnly: false +# readoutPriority: monitored +# softwareTrigger: true # pcodaq: # description: GigaFrost stdDAQ client diff --git a/tomcat_bec/devices/gigafrost/pco_datasink.py b/tomcat_bec/devices/gigafrost/pco_datasink.py index e64f3ac..4e7c8c9 100644 --- a/tomcat_bec/devices/gigafrost/pco_datasink.py +++ b/tomcat_bec/devices/gigafrost/pco_datasink.py @@ -105,7 +105,7 @@ class PcoTestConsumerMixin(CustomDetectorMixin): raise finally: try: - self.parent._socket.disconnect() + self.parent._socket.disconnect(self.parent.url.get()) except RuntimeError: pass self.parent._mon = None diff --git a/tomcat_bec/devices/gigafrost/pcoedgecamera.py b/tomcat_bec/devices/gigafrost/pcoedgecamera.py index 1e8105d..7653bdd 100644 --- a/tomcat_bec/devices/gigafrost/pcoedgecamera.py +++ b/tomcat_bec/devices/gigafrost/pcoedgecamera.py @@ -6,7 +6,6 @@ Created on Wed Dec 6 11:33:54 2023 """ import time import numpy as np -from ophyd import Component, EpicsSignal, EpicsSignalRO, Kind from ophyd.status import SubscriptionStatus, DeviceStatus from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase @@ -182,18 +181,18 @@ class PcoEdge5M(PSIDeviceBase, PcoEdgeBase): ) # Start the acquisition (this sets parameers and starts acquisition) - self.camStatusCmd.set("Running").wait() + self.acquire.set("Running").wait() # Subscribe and wait for update def is_running(*, value, timestamp, **_): return bool(value == 6) - status = SubscriptionStatus(self.camStatusCode, is_running, timeout=5, settle_time=0.2) + status = SubscriptionStatus(self.camera_statuscode, is_running, timeout=5, settle_time=0.2) status.wait() def disarm(self): """Bluesky style unstage: stop the detector""" - self.camStatusCmd.set("Idle").wait() + self.acquire.set("Idle").wait() # Data streaming is stopped by setting the max index to 0 # FIXME: This might interrupt data transfer diff --git a/tomcat_bec/devices/gigafrost/std_daq_client.py b/tomcat_bec/devices/gigafrost/std_daq_client.py index fc4364c..241e790 100644 --- a/tomcat_bec/devices/gigafrost/std_daq_client.py +++ b/tomcat_bec/devices/gigafrost/std_daq_client.py @@ -176,9 +176,9 @@ class StdDaqClient: """ old_config = self.get_config() if update: - cfg = copy.deepcopy(self._config) - cfg.update(config) - new_config = cfg + cfg = copy.deepcopy(self._config) + cfg.update(config) + new_config = cfg else: new_config = config @@ -276,7 +276,7 @@ class StdDaqClient: self._shutdown_event.set() if self._ws_monitor_thread is not None: self._ws_monitor_thread.join() - logger.warning("Shutdown joined") + logger.warning("Thread joined") if self._ws_client is not None: self._ws_client.close() self._ws_client = None @@ -306,7 +306,6 @@ class StdDaqClient: with self._ws_recv_mutex: while not self._shutdown_event.is_set(): - logger.warning("NI") self._wait_for_server_running() try: msg = self._ws_client.recv(timeout=0.1) diff --git a/tomcat_bec/devices/gigafrost/std_daq_preview.py b/tomcat_bec/devices/gigafrost/std_daq_preview.py index fcb1696..03884d0 100644 --- a/tomcat_bec/devices/gigafrost/std_daq_preview.py +++ b/tomcat_bec/devices/gigafrost/std_daq_preview.py @@ -2,6 +2,7 @@ import json import threading import time from typing import Callable +import traceback import numpy as np import zmq @@ -86,7 +87,9 @@ class StdDaqPreview: t_last = t_curr except ValueError: # Happens when ZMQ partially delivers the multipart message - pass + content = traceback.format_exc() + logger.warning(f"Websocket connection closed unexpectedly: {content}") + continue except zmq.error.Again: # Happens when receive queue is empty time.sleep(0.1) From 8a2d9c3569f9e17baf25623c96f8aa466c96a45b Mon Sep 17 00:00:00 2001 From: gac-x05la Date: Mon, 24 Mar 2025 13:41:56 +0100 Subject: [PATCH 15/20] WIP --- tomcat_bec/devices/gigafrost/pco_datasink.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tomcat_bec/devices/gigafrost/pco_datasink.py b/tomcat_bec/devices/gigafrost/pco_datasink.py index 4e7c8c9..214dc8c 100644 --- a/tomcat_bec/devices/gigafrost/pco_datasink.py +++ b/tomcat_bec/devices/gigafrost/pco_datasink.py @@ -9,6 +9,7 @@ Created on Thu Jun 27 17:28:43 2024 from time import sleep, time from threading import Thread import zmq +import json from ophyd import Device, Signal, Component, Kind from ophyd_devices.interfaces.base_classes.psi_detector_base import ( CustomDetectorMixin, @@ -55,6 +56,7 @@ class PcoTestConsumerMixin(CustomDetectorMixin): """Collect streamed updates""" try: t_last = time() + print("Starting monitor") while True: try: # Exit loop and finish monitoring @@ -63,7 +65,7 @@ class PcoTestConsumerMixin(CustomDetectorMixin): break # pylint: disable=no-member - r = self.parent._socket.recv() + r = self.parent._socket.recv_multipart(flags=zmq.NOBLOCK) # Length and throtling checks t_curr = time() @@ -71,11 +73,11 @@ class PcoTestConsumerMixin(CustomDetectorMixin): if t_elapsed < self.parent.throttle.get(): continue # # Unpack the Array V1 reply to metadata and array data - # meta, data = r - # print(meta) + meta, data = r - # # Update image and update subscribers - # header = json.loads(meta) + # Update image and update subscribers + header = json.loads(meta) + self.parent.header = header # if header["type"] == "uint16": # image = np.frombuffer(data, dtype=np.uint16) # if image.size != np.prod(header['shape']): @@ -128,6 +130,8 @@ class PcoTestConsumer(PSIDetectorBase): SUB_MONITOR = "device_monitor_2d" _default_sub = SUB_MONITOR + header = None + custom_prepare_cls = PcoTestConsumerMixin # Status attributes From 84bc0d692f7080d75843fabbafda990adcbd264a Mon Sep 17 00:00:00 2001 From: gac-x05la Date: Mon, 31 Mar 2025 12:24:10 +0200 Subject: [PATCH 16/20] BEC free consumer --- tomcat_bec/devices/gigafrost/pco_consumer.py | 158 +++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 tomcat_bec/devices/gigafrost/pco_consumer.py diff --git a/tomcat_bec/devices/gigafrost/pco_consumer.py b/tomcat_bec/devices/gigafrost/pco_consumer.py new file mode 100644 index 0000000..00f9c1f --- /dev/null +++ b/tomcat_bec/devices/gigafrost/pco_consumer.py @@ -0,0 +1,158 @@ +# -*- coding: utf-8 -*- +""" +Standard DAQ preview image stream module + +Created on Thu Jun 27 17:28:43 2024 + +@author: mohacsi_i +""" +from time import sleep, time +import threading +import zmq +import json + +ZMQ_TOPIC_FILTER = b"" + + + +class PcoTestConsumer: + """Detector wrapper class around the StdDaq preview image stream. + + This was meant to provide live image stream directly from the StdDAQ. + Note that the preview stream must be already throtled in order to cope + with the incoming data and the python class might throttle it further. + + You can add a preview widget to the dock by: + cam_widget = gui.add_dock('cam_dock1').add_widget('BECFigure').image('daq_stream1') + """ + + # Subscriptions for plotting image + _shutdown_event = threading.Event() + _monitor_mutex = threading.Lock() + _monitor_thread = None + + # Status attributes + _url = None + _image = None + _frame = None + _socket = None + + def __init__(self, url: str = "tcp://129.129.95.38:20000") -> None: + super().__init__() + self._url = url + + def connect(self): + """Connect to te StDAQs PUB-SUB streaming interface""" + # Socket to talk to server + context = zmq.Context() + self._socket = context.socket(zmq.PULL) + try: + self._socket.connect(self.url) + except ConnectionRefusedError: + sleep(1) + self._socket.connect(self.url) + + def disconnect(self): + """Disconnect""" + try: + if self._socket is not None: + self._socket.disconnect(self.url) + except zmq.ZMQError: + pass + finally: + self._socket = None + + @property + def url(self): + return self._url + + @property + def image(self): + return self._image + + @property + def frame(self): + return self._frame + + # pylint: disable=protected-access + def start(self): + """Start listening for preview data stream""" + if self._monitor_mutex.locked(): + raise RuntimeError("Only one consumer permitted") + + self.connect() + self._mon = threading.Thread(target=self.poll, daemon=True) + self._mon.start() + + def stop(self): + """Stop a running preview""" + self._shutdown_event.set() + if self._mon is not None: + self._stop_polling = True + # Might hang on recv_multipart + self._mon.join(timeout=1) + # So also disconnect the socket + self.disconnect() + self._shutdown_event.clear() + + def poll(self): + """Collect streamed updates""" + try: + t_last = time() + print("Starting monitor") + with self._monitor_mutex: + while not self._shutdown_event.is_set(): + try: + # pylint: disable=no-member + r = self._socket.recv_multipart(flags=zmq.NOBLOCK) + + # Length and throtling checks + t_curr = time() + t_elapsed = t_curr - t_last + if t_elapsed < self.parent.throttle.get(): + continue + # # Unpack the Array V1 reply to metadata and array data + meta, data = r + + # Update image and update subscribers + header = json.loads(meta) + self.header = header + # if header["type"] == "uint16": + # image = np.frombuffer(data, dtype=np.uint16) + # if image.size != np.prod(header['shape']): + # err = f"Unexpected array size of {image.size} for header: {header}" + # raise ValueError(err) + # image = image.reshape(header['shape']) + + # # Update image and update subscribers + # self._frame = header['frame'] + # self._image = image + t_last = t_curr + # print( + # f"[{self.name}] Updated frame {header['frame']}\t" + # f"Shape: {header['shape']}\tMean: {np.mean(image):.3f}" + # ) + except ValueError: + # Happens when ZMQ partially delivers the multipart message + pass + except zmq.error.Again: + # Happens when receive queue is empty + sleep(0.1) + except Exception as ex: + print(f"{str(ex)}") + raise + finally: + try: + self._socket.disconnect(self.url) + except RuntimeError: + pass + self._monitor_thread = None + print(f"Detaching monitor") + + +# Automatically connect to MicroSAXS testbench if directly invoked +if __name__ == "__main__": + daq = PcoTestConsumer(url="tcp://10.4.0.82:8080") + daq.start() + sleep(500) + daq.stop() From 5f5bf291a154cab484e50995c4bfc4f39a277163 Mon Sep 17 00:00:00 2001 From: gac-x05la Date: Wed, 16 Apr 2025 12:15:27 +0200 Subject: [PATCH 17/20] Upgraed DDC --- .../device_configs/microxas_test_bed.yaml | 158 +++++++++--------- .../aerotech/AerotechDriveDataCollection.py | 150 ++++++++++------- 2 files changed, 165 insertions(+), 143 deletions(-) diff --git a/tomcat_bec/device_configs/microxas_test_bed.yaml b/tomcat_bec/device_configs/microxas_test_bed.yaml index d16dfd2..5b37ad7 100644 --- a/tomcat_bec/device_configs/microxas_test_bed.yaml +++ b/tomcat_bec/device_configs/microxas_test_bed.yaml @@ -38,72 +38,72 @@ femto_mean_curr: readOnly: true softwareTrigger: false -# es1_roty: -# readoutPriority: monitored -# description: 'Test rotation stage' -# deviceClass: ophyd.EpicsMotor -# deviceConfig: -# prefix: X02DA-ES1-SMP1:ROTY -# deviceTags: -# - es1-sam -# onFailure: buffer -# enabled: true -# readOnly: false -# softwareTrigger: false +es1_roty: + readoutPriority: monitored + description: 'Test rotation stage' + deviceClass: ophyd.EpicsMotor + deviceConfig: + prefix: X02DA-ES1-SMP1:ROTY + deviceTags: + - es1-sam + onFailure: buffer + enabled: true + readOnly: false + softwareTrigger: false -# es1_ismc: -# description: 'Automation1 iSMC interface' -# deviceClass: tomcat_bec.devices.aa1Controller -# deviceConfig: -# prefix: 'X02DA-ES1-SMP1:CTRL:' -# deviceTags: -# - es1 -# enabled: true -# onFailure: buffer -# readOnly: false -# readoutPriority: monitored -# softwareTrigger: false +es1_ismc: + description: 'Automation1 iSMC interface' + deviceClass: tomcat_bec.devices.aa1Controller + deviceConfig: + prefix: 'X02DA-ES1-SMP1:CTRL:' + deviceTags: + - es1 + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: monitored + softwareTrigger: false -# es1_tasks: -# description: 'Automation1 task management interface' -# deviceClass: tomcat_bec.devices.aa1Tasks -# deviceConfig: -# prefix: 'X02DA-ES1-SMP1:TASK:' -# deviceTags: -# - es1 -# enabled: false -# onFailure: buffer -# readOnly: false -# readoutPriority: monitored -# softwareTrigger: false +es1_tasks: + description: 'Automation1 task management interface' + deviceClass: tomcat_bec.devices.aa1Tasks + deviceConfig: + prefix: 'X02DA-ES1-SMP1:TASK:' + deviceTags: + - es1 + enabled: false + onFailure: buffer + readOnly: false + readoutPriority: monitored + softwareTrigger: false -# es1_psod: -# description: 'AA1 PSO output interface (trigger)' -# deviceClass: tomcat_bec.devices.aa1AxisPsoDistance -# deviceConfig: -# prefix: 'X02DA-ES1-SMP1:ROTY:PSO:' -# deviceTags: -# - es1 -# enabled: true -# onFailure: buffer -# readOnly: false -# readoutPriority: monitored -# softwareTrigger: true +es1_psod: + description: 'AA1 PSO output interface (trigger)' + deviceClass: tomcat_bec.devices.aa1AxisPsoDistance + deviceConfig: + prefix: 'X02DA-ES1-SMP1:ROTY:PSO:' + deviceTags: + - es1 + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: monitored + softwareTrigger: true -# es1_ddaq: -# description: 'Automation1 position recording interface' -# deviceClass: tomcat_bec.devices.aa1AxisDriveDataCollection -# deviceConfig: -# prefix: 'X02DA-ES1-SMP1:ROTY:DDC:' -# deviceTags: -# - es1 -# enabled: true -# onFailure: buffer -# readOnly: false -# readoutPriority: monitored -# softwareTrigger: false +es1_ddaq: + description: 'Automation1 position recording interface' + deviceClass: tomcat_bec.devices.aa1AxisDriveDataCollection + deviceConfig: + prefix: 'X02DA-ES1-SMP1:ROTY:DDC:' + deviceTags: + - es1 + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: monitored + softwareTrigger: false #camera: @@ -119,25 +119,25 @@ femto_mean_curr: # readoutPriority: monitored # softwareTrigger: true -gfcam: - description: GigaFrost camera client - deviceClass: tomcat_bec.devices.GigaFrostCamera - deviceConfig: - prefix: 'X02DA-CAM-GF2:' - backend_url: 'http://sls-daq-001:8080' - auto_soft_enable: true - std_daq_live: 'tcp://129.129.95.111:20000' - std_daq_ws: 'ws://129.129.95.111:8080' - std_daq_rest: 'http://129.129.95.111:5000' - deviceTags: - - camera - - trigger - - gfcam - enabled: true - onFailure: buffer - readOnly: false - readoutPriority: monitored - softwareTrigger: true +# gfcam: +# description: GigaFrost camera client +# deviceClass: tomcat_bec.devices.GigaFrostCamera +# deviceConfig: +# prefix: 'X02DA-CAM-GF2:' +# backend_url: 'http://sls-daq-001:8080' +# auto_soft_enable: true +# std_daq_live: 'tcp://129.129.95.111:20000' +# std_daq_ws: 'ws://129.129.95.111:8080' +# std_daq_rest: 'http://129.129.95.111:5000' +# deviceTags: +# - camera +# - trigger +# - gfcam +# enabled: true +# onFailure: buffer +# readOnly: false +# readoutPriority: monitored +# softwareTrigger: true # gfdaq: # description: GigaFrost stdDAQ client diff --git a/tomcat_bec/devices/aerotech/AerotechDriveDataCollection.py b/tomcat_bec/devices/aerotech/AerotechDriveDataCollection.py index b81ec3f..55cf2a9 100644 --- a/tomcat_bec/devices/aerotech/AerotechDriveDataCollection.py +++ b/tomcat_bec/devices/aerotech/AerotechDriveDataCollection.py @@ -8,68 +8,17 @@ drive data collection (DDC) interface. import time from collections import OrderedDict -from ophyd import Component, EpicsSignal, EpicsSignalRO, Kind +from ophyd import Device, Component, EpicsSignal, EpicsSignalRO, Kind from ophyd.status import SubscriptionStatus -from ophyd_devices.interfaces.base_classes.psi_detector_base import PSIDetectorBase as PSIDeviceBase -from ophyd_devices.interfaces.base_classes.psi_detector_base import ( - CustomDetectorMixin as CustomDeviceMixin, -) +from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase + from bec_lib import bec_logger logger = bec_logger.logger -class AerotechDriveDataCollectionMixin(CustomDeviceMixin): - """Mixin class for self-configuration and staging - - NOTE: scripted scans start drive data collection internally - """ - # parent : aa1Tasks - def on_stage(self) -> None: - """Configuration and staging""" - - # Fish out configuration from scaninfo (does not need to be full configuration) - d = {} - if "kwargs" in self.parent.scaninfo.scan_msg.info: - scanargs = self.parent.scaninfo.scan_msg.info["kwargs"] - # NOTE: Scans don't have to fully configure the device - if "ddc_trigger" in scanargs: - d["ddc_trigger"] = scanargs["ddc_trigger"] - if "ddc_num_points" in scanargs: - d["num_points_total"] = scanargs["ddc_num_points"] - else: - # Try to figure out number of points - num_points = 1 - points_valid = False - if "steps" in scanargs and scanargs['steps'] is not None: - num_points *= scanargs["steps"] - points_valid = True - elif "exp_burst" in scanargs and scanargs['exp_burst'] is not None: - num_points *= scanargs["exp_burst"] - points_valid = True - elif "repeats" in scanargs and scanargs['repeats'] is not None: - num_points *= scanargs["repeats"] - points_valid = True - if points_valid: - d["num_points_total"] = num_points - - # Perform bluesky-style configuration - if len(d) > 0: - logger.warning(f"[{self.parent.name}] Configuring with:\n{d}") - self.parent.configure(d=d) - - # Stage the data collection if not in internally launced mode - # NOTE: Scripted scans start acquiring from the scrits - if self.parent.scaninfo.scan_type not in ("script", "scripted"): - self.parent.bluestage() - - def on_unstage(self): - """Standard bluesky unstage""" - self.parent._switch.set("Stop", settle_time=0.2).wait() - - -class aa1AxisDriveDataCollection(PSIDeviceBase): +class aa1AxisDriveDataCollection(PSIDeviceBase, Device): """Axis data collection This class provides convenience wrappers around the Aerotech API's axis @@ -88,9 +37,10 @@ class aa1AxisDriveDataCollection(PSIDeviceBase): ... ret = yield from ddc.collect() + NOTE: scripted scans start drive data collection internally NOTE: Expected behavior is that the device is disabled when not in use, - i.e. there's avtive enable/disable management. + i.e. there's active enable/disable management. """ # ######################################################################## @@ -111,8 +61,31 @@ class aa1AxisDriveDataCollection(PSIDeviceBase): _buffer0 = Component(EpicsSignalRO, "BUFFER0", auto_monitor=True, kind=Kind.normal) _buffer1 = Component(EpicsSignalRO, "BUFFER1", auto_monitor=True, kind=Kind.normal) - custom_prepare_cls = AerotechDriveDataCollectionMixin - USER_ACCESS = ["configure", "reset"] + USER_ACCESS = ["configure", "reset", "arm", "disarm"] + + def __init__( + self, + prefix="", + *, + name, + kind=None, + read_attrs=None, + configuration_attrs=None, + parent=None, + scan_info=None, + **kwargs, + ): + # super() will call the mixin class + super().__init__( + prefix=prefix, + name=name, + kind=kind, + read_attrs=read_attrs, + configuration_attrs=configuration_attrs, + parent=parent, + scan_info=scan_info, + **kwargs, + ) def configure(self, d: dict = None) -> tuple: """Configure data capture @@ -128,21 +101,68 @@ class aa1AxisDriveDataCollection(PSIDeviceBase): if "num_points_total" in d: self.npoints.set(d["num_points_total"]).wait() if "ddc_trigger" in d: - self._trigger.set(d['ddc_trigger']).wait() + self._trigger.set(d["ddc_trigger"]).wait() if "ddc_source0" in d: - self._input0.set(d['ddc_source0']).wait() + self._input0.set(d["ddc_source0"]).wait() if "ddc_source1" in d: - self._input1.set(d['ddc_source1']).wait() + self._input1.set(d["ddc_source1"]).wait() # Reset incremental readback self._switch.set("ResetRB", settle_time=0.1).wait() new = self.read_configuration() return (old, new) - def bluestage(self) -> None: + def on_stage(self) -> None: + """Configuration and staging""" + # Fish out configuration from scaninfo (does not need to be full configuration) + d = {} + if "kwargs" in self.scaninfo.scan_msg.info: + scanargs = self.scaninfo.scan_msg.info["kwargs"] + # NOTE: Scans don't have to fully configure the device + if "ddc_trigger" in scanargs: + d["ddc_trigger"] = scanargs["ddc_trigger"] + if "ddc_num_points" in scanargs: + d["num_points_total"] = scanargs["ddc_num_points"] + else: + # Try to figure out number of points + num_points = 1 + points_valid = False + if "steps" in scanargs and scanargs["steps"] is not None: + num_points *= scanargs["steps"] + points_valid = True + elif "exp_burst" in scanargs and scanargs["exp_burst"] is not None: + num_points *= scanargs["exp_burst"] + points_valid = True + elif "repeats" in scanargs and scanargs["repeats"] is not None: + num_points *= scanargs["repeats"] + points_valid = True + if points_valid: + d["num_points_total"] = num_points + + # Perform bluesky-style configuration + if len(d) > 0: + logger.warning(f"[{self.name}] Configuring with:\n{d}") + self.configure(d=d) + + # Stage the data collection if not in internally launced mode + # NOTE: Scripted scans start acquiring from the scrits + if self.scaninfo.scan_type not in ("script", "scripted"): + self.arm() + # Reset readback + self.reset() + + def on_unstage(self): + """Standard bluesky unstage""" + self.disarm() + + def arm(self) -> None: """Bluesky-style stage""" self._switch.set("Start", settle_time=0.2).wait() + def disarm(self): + """Standard bluesky unstage""" + self._switch.set("Stop", settle_time=0.2).wait() + def reset(self): """Reset incremental readback""" self._switch.set("ResetRB", settle_time=0.1).wait() @@ -164,20 +184,22 @@ class aa1AxisDriveDataCollection(PSIDeviceBase): timestamp_ = timestamp return result + status = None if index == 0: status = SubscriptionStatus(self._readstatus0, neg_edge, settle_time=0.5) self._readback0.set(1).wait() elif index == 1: status = SubscriptionStatus(self._readstatus1, neg_edge, settle_time=0.5) self._readback1.set(1).wait() + else: + raise RuntimeError(f"Unsupported drive data collection channel: {index}") # Start asynchronous readback status.wait() return status def describe_collect(self) -> OrderedDict: - """Describes collected array format according to JSONschema - """ + """Describes collected array format according to JSONschema""" ret = OrderedDict() ret["buffer0"] = { "source": "internal", From faaccafec63ea0daa5aab2fe3d311f6a860f238c Mon Sep 17 00:00:00 2001 From: gac-x05la Date: Wed, 16 Apr 2025 12:32:27 +0200 Subject: [PATCH 18/20] WIP --- tests/tests_devices/test_stddaq_client.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/tests_devices/test_stddaq_client.py b/tests/tests_devices/test_stddaq_client.py index e055774..75e777f 100644 --- a/tests/tests_devices/test_stddaq_client.py +++ b/tests/tests_devices/test_stddaq_client.py @@ -10,7 +10,6 @@ from websockets import WebSocketException from tomcat_bec.devices.gigafrost.std_daq_client import ( StdDaqClient, - StdDaqConfig, StdDaqError, StdDaqStatus, ) @@ -28,7 +27,7 @@ def client(): @pytest.fixture def full_config(): - full_config = StdDaqConfig( + full_config = dict( detector_name="tomcat-gf", detector_type="gigafrost", n_modules=8, From 38fe391654a6c0e77e7c0cb454dd78a08f5598b9 Mon Sep 17 00:00:00 2001 From: gac-x05la Date: Wed, 16 Apr 2025 12:37:55 +0200 Subject: [PATCH 19/20] WIP --- tests/tests_devices/test_stddaq_client.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/tests_devices/test_stddaq_client.py b/tests/tests_devices/test_stddaq_client.py index 75e777f..c4fc124 100644 --- a/tests/tests_devices/test_stddaq_client.py +++ b/tests/tests_devices/test_stddaq_client.py @@ -8,18 +8,14 @@ import typeguard from ophyd import StatusBase from websockets import WebSocketException -from tomcat_bec.devices.gigafrost.std_daq_client import ( - StdDaqClient, - StdDaqError, - StdDaqStatus, -) +from tomcat_bec.devices.gigafrost.std_daq_client import StdDaqClient, StdDaqError, StdDaqStatus @pytest.fixture def client(): parent_device = mock.MagicMock() _client = StdDaqClient( - parent=parent_device, ws_url="http://localhost:5000", rest_url="http://localhost:5000" + parent=parent_device, ws_url="ws://localhost:5000", rest_url="http://localhost:5000" ) yield _client _client.shutdown() From 6dd03d24a002f1584efa63226b51a067eade8602 Mon Sep 17 00:00:00 2001 From: gac-x05la Date: Wed, 16 Apr 2025 12:42:24 +0200 Subject: [PATCH 20/20] WIP --- tests/tests_devices/test_stddaq_client.py | 2 +- tomcat_bec/devices/aerotech/AerotechDriveDataCollection.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/tests_devices/test_stddaq_client.py b/tests/tests_devices/test_stddaq_client.py index c4fc124..f3cc708 100644 --- a/tests/tests_devices/test_stddaq_client.py +++ b/tests/tests_devices/test_stddaq_client.py @@ -15,7 +15,7 @@ from tomcat_bec.devices.gigafrost.std_daq_client import StdDaqClient, StdDaqErro def client(): parent_device = mock.MagicMock() _client = StdDaqClient( - parent=parent_device, ws_url="ws://localhost:5000", rest_url="http://localhost:5000" + parent=parent_device, ws_url="ws://localhost:5001", rest_url="http://localhost:5000" ) yield _client _client.shutdown() diff --git a/tomcat_bec/devices/aerotech/AerotechDriveDataCollection.py b/tomcat_bec/devices/aerotech/AerotechDriveDataCollection.py index 55cf2a9..2c4141c 100644 --- a/tomcat_bec/devices/aerotech/AerotechDriveDataCollection.py +++ b/tomcat_bec/devices/aerotech/AerotechDriveDataCollection.py @@ -75,7 +75,7 @@ class aa1AxisDriveDataCollection(PSIDeviceBase, Device): scan_info=None, **kwargs, ): - # super() will call the mixin class + # Need to call super() to call the mixin class super().__init__( prefix=prefix, name=name,