42 Commits

Author SHA1 Message Date
gac-x02da
7a2df919fb First config file for S-TOMCAT 2025-06-04 09:22:46 +02:00
4c342de5c4 chore: upgrade to copier template v1 2025-05-19 11:21:58 +02:00
gac-x05la
f688cfca0f Flaking 2025-04-30 13:31:10 +02:00
gac-x05la
5d5eef11f6 Fixed threading mutex 2025-04-30 13:10:53 +02:00
gac-x05la
b3e0a64bf1 Found out stdDAQ filename problem 2025-04-28 17:37:17 +02:00
gac-x05la
672cb60e72 Works without stdDAQ 2025-04-17 14:21:27 +02:00
gac-x05la
2d23c5e071 Verifying scan compatibility 2025-04-17 13:01:22 +02:00
gac-x05la
9a878db49a Device code to 9.6 2025-04-16 15:47:28 +02:00
gac-x05la
3689ec0677 Merging development 2025-04-16 15:28:54 +02:00
gac-x05la
f3961322e3 WIP 2025-04-16 15:23:25 +02:00
gac-x05la
4437bb13b8 WIP 2025-04-16 15:01:06 +02:00
bea5f95503 Merge branch 'main' into 'feature/revisiting-aerotech'
# Conflicts:
#   tomcat_bec/device_configs/microxas_test_bed.yaml
#   tomcat_bec/devices/aerotech/AerotechDriveDataCollection.py
#   tomcat_bec/devices/gigafrost/gigafrostcamera.py
#   tomcat_bec/devices/gigafrost/pcoedgecamera.py
#   tomcat_bec/scans/__init__.py
#   tomcat_bec/scans/tutorial_fly_scan.py
2025-04-16 14:10:42 +02:00
gac-x05la
56f4a6a61e Cutting back on unittests 2025-04-16 14:03:22 +02:00
gac-x05la
6dd03d24a0 WIP 2025-04-16 12:42:24 +02:00
gac-x05la
38fe391654 WIP 2025-04-16 12:37:55 +02:00
gac-x05la
faaccafec6 WIP 2025-04-16 12:32:27 +02:00
gac-x05la
5f5bf291a1 Upgraed DDC 2025-04-16 12:15:27 +02:00
gac-x05la
84bc0d692f BEC free consumer 2025-03-31 12:24:10 +02:00
gac-x05la
8a2d9c3569 WIP 2025-03-24 13:41:56 +01:00
gac-x05la
5726b4349a I'm up against higher powers 2025-03-21 17:37:49 +01:00
gac-x05la
f64d603cff Zombie thread fix 2025-03-21 17:00:38 +01:00
gac-x05la
eff10e1386 WIP 2025-03-21 16:22:03 +01:00
gac-x05la
bb5c2316e1 WIP 2025-03-21 16:11:10 +01:00
gac-x05la
045f348322 GF seems done, working on PCO 2025-03-21 15:42:40 +01:00
gac-x05la
32b976f9d6 Starting to look good 2025-03-20 17:46:51 +01:00
gac-x05la
49e8c64433 Run control works 2025-03-19 14:37:30 +01:00
gac-x05la
20dcd1849a WIP 2025-03-19 09:52:34 +01:00
gac-x05la
c9a2ce0dc5 Livestream with access mutex 2025-03-18 11:18:06 +01:00
gac-x05la
9051e1a9ee WIP 2025-03-17 18:27:47 +01:00
gac-x05la
6c0405ec7a Fix triggering order 2025-03-17 17:21:39 +01:00
gac-x05la
1c5e4a691a GF camera part seems working 2025-03-17 17:21:21 +01:00
gac-x05la
ab72fa3ffa WIP 2025-03-17 17:21:03 +01:00
gac-x05la
e02bb5892e WIP 2025-03-03 12:20:56 +01:00
gac-x05la
4266798e30 Bump 2025-02-25 14:31:46 +01:00
gac-x05la
f15fd00712 After session with Klaus 2025-02-20 12:42:33 +01:00
gac-x05la
76cf6ac447 New additions from klaus 2025-02-19 16:58:25 +01:00
gac-x05la
0bc3778d3f Before redeployment 2025-02-19 10:18:57 +01:00
gac-x05la
9d104173bd Work on fede scans 2025-02-18 18:50:04 +01:00
gac-x05la
f6fecfdc3f WIP with Fede's scans 2025-02-17 17:46:52 +01:00
gac-x05la
e8b3aedb10 Trigger mode notes 2025-02-14 17:46:27 +01:00
gac-x05la
c3cf12b8e2 SnapNStep works, Sequence fails on Aerotech axis 2025-02-14 17:39:36 +01:00
gac-x05la
d1e072b8d9 Delayed start on Aerotech tasks 2025-02-14 13:48:47 +01:00
41 changed files with 2922 additions and 1791 deletions

9
.copier-answers.yml Normal file
View File

@@ -0,0 +1,9 @@
# Do not edit this file!
# It is needed to track the repo template version, and editing may break things.
# This file will be overwritten by copier on template updates.
_commit: v1.0.0
_src_path: https://gitea.psi.ch/bec/bec_plugin_copier_template.git
make_commit: false
project_name: tomcat_bec
widget_plugins_input: []

View File

@@ -1,6 +1,7 @@
BSD 3-Clause License
Copyright (c) 2024, Paul Scherrer Institute
Copyright (c) 2025, Paul Scherrer Institute
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
@@ -25,4 +26,4 @@ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

1
bin/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
# Add anything you don't want to check in to git, e.g. very large files

View File

@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
[project]
name = "tomcat_bec"
version = "0.0.0"
description = "Custom device implementations based on the ophyd hardware abstraction layer"
description = "The TOMCAT plugin repository for BEC"
requires-python = ">=3.10"
classifiers = [
"Development Status :: 3 - Alpha",
@@ -24,6 +24,7 @@ dependencies = [
[project.optional-dependencies]
dev = [
"black",
"copier",
"isort",
"coverage",
"pylint",
@@ -46,12 +47,15 @@ plugin_file_writer = "tomcat_bec.file_writer"
[project.entry-points."bec.scans"]
plugin_scans = "tomcat_bec.scans"
[project.entry-points."bec.scans.metadata_schema"]
plugin_metadata_schema = "tomcat_bec.scans.metadata_schema"
[project.entry-points."bec.ipython_client_startup"]
plugin_ipython_client_pre = "tomcat_bec.bec_ipython_client.startup.pre_startup"
plugin_ipython_client_post = "tomcat_bec.bec_ipython_client.startup"
[project.entry-points."bec.widgets.auto_updates"]
plugin_widgets_update = "tomcat_bec.bec_widgets.auto_updates:PlotUpdate"
plugin_widgets_update = "tomcat_bec.bec_widgets.auto_updates"
[project.entry-points."bec.widgets.user_widgets"]
plugin_widgets = "tomcat_bec.bec_widgets.widgets"

View File

@@ -1,31 +1,34 @@
# Getting Started with Testing using pytest
BEC is using the [pytest](https://docs.pytest.org/en/8.0.x/) framework.
It can be install via
``` bash
BEC is using the [pytest](https://docs.pytest.org/en/latest/) framework.
It can be installed via
```bash
pip install pytest
```
in your *python environment*.
in your _python environment_.
We note that pytest is part of the optional-dependencies `[dev]` of the plugin package.
## Introduction
Tests in this package should be stored in the `tests` directory.
We suggest to sort tests of different submodules, i.e. `scans` or `devices` in the respective folder structure, and to folow a naming convention of `<test_module_name.py>`.
It is mandatory for test files to begin with `test_` for pytest to discover them.
To run all tests, navigate to the directory of the plugin from the command line, and run the command
To run all tests, navigate to the directory of the plugin from the command line, and run the command
``` bash
```bash
pytest -v --random-order ./tests
```
Note, the python environment needs to be active.
The additional arg `-v` allows pytest to run in verbose mode which provides more detailed information about the tests being run.
The argument `--random-order` instructs pytest to run the tests in random order, which is the default in the CI pipelines.
The argument `--random-order` instructs pytest to run the tests in random order, which is the default in the CI pipelines.
## Test examples
Writing tests can be quite specific for the given function.
Writing tests can be quite specific for the given function.
We recommend writing tests as isolated as possible, i.e. try to test single functions instead of full classes.
A very useful class to enable isolated testing is [MagicMock](https://docs.python.org/3/library/unittest.mock.html).
In addition, we also recommend to take a look at the [How-to guides from pytest](https://docs.pytest.org/en/8.0.x/how-to/index.html).

View File

@@ -1,31 +1,34 @@
# Getting Started with Testing using pytest
BEC is using the [pytest](https://docs.pytest.org/en/8.0.x/) framework.
It can be install via
``` bash
BEC is using the [pytest](https://docs.pytest.org/en/latest/) framework.
It can be installed via
```bash
pip install pytest
```
in your *python environment*.
in your _python environment_.
We note that pytest is part of the optional-dependencies `[dev]` of the plugin package.
## Introduction
Tests in this package should be stored in the `tests` directory.
We suggest to sort tests of different submodules, i.e. `scans` or `devices` in the respective folder structure, and to folow a naming convention of `<test_module_name.py>`.
It is mandatory for test files to begin with `test_` for pytest to discover them.
To run all tests, navigate to the directory of the plugin from the command line, and run the command
To run all tests, navigate to the directory of the plugin from the command line, and run the command
``` bash
```bash
pytest -v --random-order ./tests
```
Note, the python environment needs to be active.
The additional arg `-v` allows pytest to run in verbose mode which provides more detailed information about the tests being run.
The argument `--random-order` instructs pytest to run the tests in random order, which is the default in the CI pipelines.
The argument `--random-order` instructs pytest to run the tests in random order, which is the default in the CI pipelines.
## Test examples
Writing tests can be quite specific for the given function.
Writing tests can be quite specific for the given function.
We recommend writing tests as isolated as possible, i.e. try to test single functions instead of full classes.
A very useful class to enable isolated testing is [MagicMock](https://docs.python.org/3/library/unittest.mock.html).
In addition, we also recommend to take a look at the [How-to guides from pytest](https://docs.pytest.org/en/8.0.x/how-to/index.html).

View File

@@ -0,0 +1,34 @@
# Getting Started with Testing using pytest
BEC is using the [pytest](https://docs.pytest.org/en/latest/) framework.
It can be installed via
```bash
pip install pytest
```
in your _python environment_.
We note that pytest is part of the optional-dependencies `[dev]` of the plugin package.
## Introduction
Tests in this package should be stored in the `tests` directory.
We suggest to sort tests of different submodules, i.e. `scans` or `devices` in the respective folder structure, and to folow a naming convention of `<test_module_name.py>`.
It is mandatory for test files to begin with `test_` for pytest to discover them.
To run all tests, navigate to the directory of the plugin from the command line, and run the command
```bash
pytest -v --random-order ./tests
```
Note, the python environment needs to be active.
The additional arg `-v` allows pytest to run in verbose mode which provides more detailed information about the tests being run.
The argument `--random-order` instructs pytest to run the tests in random order, which is the default in the CI pipelines.
## Test examples
Writing tests can be quite specific for the given function.
We recommend writing tests as isolated as possible, i.e. try to test single functions instead of full classes.
A very useful class to enable isolated testing is [MagicMock](https://docs.python.org/3/library/unittest.mock.html).
In addition, we also recommend to take a look at the [How-to guides from pytest](https://docs.pytest.org/en/8.0.x/how-to/index.html).

View File

@@ -1,31 +1,34 @@
# Getting Started with Testing using pytest
BEC is using the [pytest](https://docs.pytest.org/en/8.0.x/) framework.
It can be install via
``` bash
BEC is using the [pytest](https://docs.pytest.org/en/latest/) framework.
It can be installed via
```bash
pip install pytest
```
in your *python environment*.
in your _python environment_.
We note that pytest is part of the optional-dependencies `[dev]` of the plugin package.
## Introduction
Tests in this package should be stored in the `tests` directory.
We suggest to sort tests of different submodules, i.e. `scans` or `devices` in the respective folder structure, and to folow a naming convention of `<test_module_name.py>`.
It is mandatory for test files to begin with `test_` for pytest to discover them.
To run all tests, navigate to the directory of the plugin from the command line, and run the command
To run all tests, navigate to the directory of the plugin from the command line, and run the command
``` bash
```bash
pytest -v --random-order ./tests
```
Note, the python environment needs to be active.
The additional arg `-v` allows pytest to run in verbose mode which provides more detailed information about the tests being run.
The argument `--random-order` instructs pytest to run the tests in random order, which is the default in the CI pipelines.
The argument `--random-order` instructs pytest to run the tests in random order, which is the default in the CI pipelines.
## Test examples
Writing tests can be quite specific for the given function.
Writing tests can be quite specific for the given function.
We recommend writing tests as isolated as possible, i.e. try to test single functions instead of full classes.
A very useful class to enable isolated testing is [MagicMock](https://docs.python.org/3/library/unittest.mock.html).
In addition, we also recommend to take a look at the [How-to guides from pytest](https://docs.pytest.org/en/8.0.x/how-to/index.html).

View File

@@ -1,358 +1,353 @@
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")
# 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, StdDaqError, StdDaqStatus
# @pytest.fixture
# def client():
# parent_device = mock.MagicMock()
# _client = StdDaqClient(
# parent=parent_device, ws_url="ws://localhost:5001", rest_url="http://localhost:5000"
# )
# yield _client
# _client.shutdown()
# @pytest.fixture
# def full_config():
# full_config = dict(
# 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.
# """
# 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()
# 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_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_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_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
# 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)
# Test that the client returns the cached configuration
assert client.get_config(cached=True) == config
# def test_stddaq_client_get_config_cached(client, full_config):
# """
# Test that the client returns the cached configuration if it is available.
# """
def test_stddaq_client_status(client):
client._status = StdDaqStatus.FILE_CREATED
assert client.status == StdDaqStatus.FILE_CREATED
# # 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_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_status(client):
# client._status = StdDaqStatus.FILE_CREATED
# assert client.status == StdDaqStatus.FILE_CREATED
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_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_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_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_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())
# 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"}
# 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_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())
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()
# # 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()

View File

@@ -1,31 +1,34 @@
# Getting Started with Testing using pytest
BEC is using the [pytest](https://docs.pytest.org/en/8.0.x/) framework.
It can be install via
``` bash
BEC is using the [pytest](https://docs.pytest.org/en/latest/) framework.
It can be installed via
```bash
pip install pytest
```
in your *python environment*.
in your _python environment_.
We note that pytest is part of the optional-dependencies `[dev]` of the plugin package.
## Introduction
Tests in this package should be stored in the `tests` directory.
We suggest to sort tests of different submodules, i.e. `scans` or `devices` in the respective folder structure, and to folow a naming convention of `<test_module_name.py>`.
It is mandatory for test files to begin with `test_` for pytest to discover them.
To run all tests, navigate to the directory of the plugin from the command line, and run the command
To run all tests, navigate to the directory of the plugin from the command line, and run the command
``` bash
```bash
pytest -v --random-order ./tests
```
Note, the python environment needs to be active.
The additional arg `-v` allows pytest to run in verbose mode which provides more detailed information about the tests being run.
The argument `--random-order` instructs pytest to run the tests in random order, which is the default in the CI pipelines.
The argument `--random-order` instructs pytest to run the tests in random order, which is the default in the CI pipelines.
## Test examples
Writing tests can be quite specific for the given function.
Writing tests can be quite specific for the given function.
We recommend writing tests as isolated as possible, i.e. try to test single functions instead of full classes.
A very useful class to enable isolated testing is [MagicMock](https://docs.python.org/3/library/unittest.mock.html).
In addition, we also recommend to take a look at the [How-to guides from pytest](https://docs.pytest.org/en/8.0.x/how-to/index.html).

View File

@@ -1,31 +1,34 @@
# Getting Started with Testing using pytest
BEC is using the [pytest](https://docs.pytest.org/en/8.0.x/) framework.
It can be install via
``` bash
BEC is using the [pytest](https://docs.pytest.org/en/latest/) framework.
It can be installed via
```bash
pip install pytest
```
in your *python environment*.
in your _python environment_.
We note that pytest is part of the optional-dependencies `[dev]` of the plugin package.
## Introduction
Tests in this package should be stored in the `tests` directory.
We suggest to sort tests of different submodules, i.e. `scans` or `devices` in the respective folder structure, and to folow a naming convention of `<test_module_name.py>`.
It is mandatory for test files to begin with `test_` for pytest to discover them.
To run all tests, navigate to the directory of the plugin from the command line, and run the command
To run all tests, navigate to the directory of the plugin from the command line, and run the command
``` bash
```bash
pytest -v --random-order ./tests
```
Note, the python environment needs to be active.
The additional arg `-v` allows pytest to run in verbose mode which provides more detailed information about the tests being run.
The argument `--random-order` instructs pytest to run the tests in random order, which is the default in the CI pipelines.
The argument `--random-order` instructs pytest to run the tests in random order, which is the default in the CI pipelines.
## Test examples
Writing tests can be quite specific for the given function.
Writing tests can be quite specific for the given function.
We recommend writing tests as isolated as possible, i.e. try to test single functions instead of full classes.
A very useful class to enable isolated testing is [MagicMock](https://docs.python.org/3/library/unittest.mock.html).
In addition, we also recommend to take a look at the [How-to guides from pytest](https://docs.pytest.org/en/8.0.x/how-to/index.html).

View File

@@ -10,7 +10,7 @@ While command-line arguments have to be set in the pre-startup script, the
post-startup script can be used to load beamline specific information and
to setup the prompts.
from bec_lib import bec_logger
from bec_lib.logger import bec_logger
logger = bec_logger.logger

View File

@@ -1,6 +1,6 @@
"""
Pre-startup script for BEC client. This script is executed before the BEC client
is started. It can be used to add additional command line arguments.
is started. It can be used to add additional command line arguments.
"""
from bec_lib.service_config import ServiceConfig

View File

@@ -0,0 +1,144 @@
fe_sldi_centerx_mm:
description: FE slit horizontal center position in mm
deviceClass: ophyd.EpicsMotor
deviceConfig:
prefix: X02DA-FE-SLDI:CENTERX
deviceTags:
- ??
enabled: true
onFailure: buffer
readOnly: false
readoutPriority: baseline
softwareTrigger: false
fe_sldi_centerx_mrad:
description: FE slit horizontal center position in mrad
deviceClass: ophyd.EpicsMotor
deviceConfig:
prefix: X02DA-FE-SLDI:CENTERX_MRAD
deviceTags:
- ??
enabled: true
onFailure: buffer
readOnly: false
readoutPriority: baseline
softwareTrigger: false
fe_sldi_centery_mm:
description: FE slit vertical center position in mm
deviceClass: ophyd.EpicsMotor
deviceConfig:
prefix: X02DA-FE-SLDI:CENTERY
deviceTags:
- ??
enabled: true
onFailure: buffer
readOnly: false
readoutPriority: baseline
softwareTrigger: false
fe_sldi_centery_mrad:
description: FE slit vertical center position in mrad
deviceClass: ophyd.EpicsMotor
deviceConfig:
prefix: X02DA-FE-SLDI:CENTERY_MRAD
deviceTags:
- ??
enabled: true
onFailure: buffer
readOnly: false
readoutPriority: baseline
softwareTrigger: false
fe_sldi_sizex_mm:
description: FE slit horizontal size in mm
deviceClass: ophyd.EpicsMotor
deviceConfig:
prefix: X02DA-FE-SLDI:SIZEX
deviceTags:
- ??
enabled: true
onFailure: buffer
readOnly: false
readoutPriority: baseline
softwareTrigger: false
fe_sldi_sizex_mrad:
description: FE slit horizontal size in mrad
deviceClass: ophyd.EpicsMotor
deviceConfig:
prefix: X02DA-FE-SLDI:SIZEX_MRAD
deviceTags:
- ??
enabled: true
onFailure: buffer
readOnly: false
readoutPriority: baseline
softwareTrigger: false
fe_sldi_sizey_mm:
description: FE slit vertical size in mm
deviceClass: ophyd.EpicsMotor
deviceConfig:
prefix: X02DA-FE-SLDI:SIZEY
deviceTags:
- ??
enabled: true
onFailure: buffer
readOnly: false
readoutPriority: baseline
softwareTrigger: false
fe_sldi_sizey_mrad:
description: FE slit vertical size in mrad
deviceClass: ophyd.EpicsMotor
deviceConfig:
prefix: X02DA-FE-SLDI:SIZEY_MRAD
deviceTags:
- ??
enabled: true
onFailure: buffer
readOnly: false
readoutPriority: baseline
softwareTrigger: false
fe_sldi_trxr:
description: Ring FE slit X motion
deviceClass: ophyd.EpicsMotor
deviceConfig:
prefix: X02DA-FE-SLDI:TRXR
deviceTags:
- ??
enabled: true
onFailure: buffer
readOnly: false
readoutPriority: baseline
softwareTrigger: false
fe_sldi_trxw:
description: Wall FE slit X motion
deviceClass: ophyd.EpicsMotor
deviceConfig:
prefix: X02DA-FE-SLDI:TRXW
deviceTags:
- ??
enabled: true
onFailure: buffer
readOnly: false
readoutPriority: baseline
softwareTrigger: false
fe_sldi_tryb:
description: Bottom FE slit Y motion
deviceClass: ophyd.EpicsMotor
deviceConfig:
prefix: X02DA-FE-SLDI:TRYB
deviceTags:
- ??
enabled: true
onFailure: buffer
readOnly: false
readoutPriority: baseline
softwareTrigger: false
fe_sldi_tryt:
description: Top FE slit Y motion
deviceClass: ophyd.EpicsMotor
deviceConfig:
prefix: X02DA-FE-SLDI:TRYT
deviceTags:
- ??
enabled: true
onFailure: buffer
readOnly: false
readoutPriority: baseline
softwareTrigger: false

View File

@@ -51,59 +51,72 @@ es1_roty:
readOnly: false
softwareTrigger: false
# es1_ismc:
# description: 'Automation1 iSMC interface'
# deviceClass: tomcat_bec.devices.aa1Controller
# deviceConfig:
# prefix: 'X02DA-ES1-SMP1:CTRL:'
# deviceTags:
# - es1
# enabled: true
# onFailure: buffer
# readOnly: false
# readoutPriority: monitored
# softwareTrigger: false
es1_trx:
readoutPriority: monitored
description: 'Test translation stage'
deviceClass: ophyd.EpicsMotor
deviceConfig:
prefix: X02DA-ES1-SMP1:TRX
deviceTags:
- es1-sam
onFailure: buffer
enabled: true
readOnly: false
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_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_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: 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
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,6 +132,25 @@ es1_roty:
# readoutPriority: monitored
# softwareTrigger: true
# gfcam:
# description: GigaFrost camera client
# deviceClass: tomcat_bec.devices.GigaFrostCamera
# deviceConfig:
# prefix: 'X02DA-CAM-GF2:'
# backend_url: 'http://sls-daq-001:8080'
# auto_soft_enable: true
# deviceTags:
# - camera
# - trigger
# - gfcam
# enabled: true
# onFailure: buffer
# readOnly: false
# readoutPriority: monitored
# softwareTrigger: true
gfcam:
description: GigaFrost camera client
deviceClass: tomcat_bec.devices.GigaFrostCamera
@@ -126,6 +158,9 @@ 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'
std_daq_ws: 'ws://129.129.95.111:8080'
std_daq_rest: 'http://129.129.95.111:5000'
deviceTags:
- camera
- trigger
@@ -136,49 +171,51 @@ 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: true
onFailure: buffer
readOnly: false
readoutPriority: monitored
softwareTrigger: false
# 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
daq_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: true
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
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:
@@ -186,6 +223,9 @@ pcocam:
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
@@ -196,31 +236,31 @@ pcocam:
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)
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

View File

@@ -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

View File

@@ -5,13 +5,10 @@ from .aerotech import (
aa1GlobalVariableBindings,
aa1GlobalVariables,
aa1Tasks,
aa1Controller
)
from .grashopper_tomcat import GrashopperTOMCAT
from .psimotor import EpicsMotorMR, EpicsMotorEC
from .gigafrost.gigafrostcamera import GigaFrostCamera
from .gigafrost.pcoedgecamera import PcoEdge5M
from .gigafrost.stddaq_client import StdDaqClient
from .gigafrost.stddaq_preview import StdDaqPreviewDetector
from .grashopper_tomcat import GrashopperTOMCAT
from .psimotor import EpicsMotorEC, EpicsMotorMR

View File

@@ -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,32 @@ 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"]
# pylint: disable=duplicate-code, too-many-arguments
def __init__(
self,
prefix="",
*,
name,
kind=None,
read_attrs=None,
configuration_attrs=None,
parent=None,
scan_info=None,
**kwargs,
):
# Need to call super() to 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 +102,75 @@ 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 = {}
scan_args = {
**self.scan_info.msg.request_inputs["inputs"],
**self.scan_info.msg.request_inputs["kwargs"],
**self.scan_info.msg.scan_parameters,
}
# NOTE: Scans don't have to fully configure the device
if "ddc_trigger" in scan_args:
d["ddc_trigger"] = scan_args["ddc_trigger"]
if "ddc_num_points" in scan_args:
d["num_points_total"] = scan_args["ddc_num_points"]
else:
# Try to figure out number of points
num_points = 1
points_valid = False
if "steps" in scan_args and scan_args["steps"] is not None:
num_points *= scan_args["steps"]
points_valid = True
if "exp_burst" in scan_args and scan_args["exp_burst"] is not None:
num_points *= scan_args["exp_burst"]
points_valid = True
if "repeats" in scan_args and scan_args["repeats"] is not None:
num_points *= scan_args["repeats"]
points_valid = True
if "burst_at_each_point" in scan_args and scan_args["burst_at_each_point"] is not None:
num_points *= scan_args["burst_at_each_point"]
points_valid = True
if points_valid:
d["num_points_total"] = num_points
# Perform bluesky-style configuration
if d:
self.configure(d=d)
# Stage the data collection if not in internally launced mode
# NOTE: Scripted scans start acquiring from the scrits
if "scan_type" in scan_args and scan_args["scan_type"] in ("scripted", "script"):
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("ResetRB", settle_time=0.1).wait()
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 +192,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",

View File

@@ -7,78 +7,17 @@ synchronized output (PSO) interface.
"""
from time import sleep
import numpy as np
from ophyd import Component, EpicsSignal, EpicsSignalRO, Kind
from ophyd import Device, Component, EpicsSignal, EpicsSignalRO, Kind
from ophyd.status import DeviceStatus
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 AerotechPsoDistanceMixin(CustomDeviceMixin):
"""Mixin class for self-configuration and staging
"""
# parent : aa1Tasks
def on_stage(self) -> None:
"""Configuration and staging
NOTE: Scans don't have to fully configure the device, that can be done
manually outside. However we expect that the device is disabled
when not in use. I.e. this method is not expected to be called when
PSO is not needed or when it'd conflict with other devices.
"""
# Fish out configuration from scaninfo (does not need to be full configuration)
d = {}
if "kwargs" in self.parent.scaninfo.scan_msg.info:
scanargs = self.parent.scaninfo.scan_msg.info["kwargs"]
if "pso_distance" in scanargs:
d["pso_distance"] = scanargs["pso_distance"]
if "pso_wavemode" in scanargs:
d["pso_wavemode"] = scanargs["pso_wavemode"]
if "pso_w_pulse" in scanargs:
d["pso_w_pulse"] = scanargs["pso_w_pulse"]
if "pso_t_pulse" in scanargs:
d["pso_t_pulse"] = scanargs["pso_t_pulse"]
if "pso_n_pulse" in scanargs:
d["pso_n_pulse"] = scanargs["pso_n_pulse"]
# Perform bluesky-style configuration
if len(d) > 0:
logger.info(f"[{self.parent.name}] Configuring with:\n{d}")
self.parent.configure(d=d)
# Stage the PSO distance module
self.parent.bluestage()
def on_unstage(self):
"""Standard bluesky unstage"""
# Ensure output is set to low
# if self.parent.output.value:
# self.parent.toggle()
# Turn off window mode
self.parent.winOutput.set("Off").wait()
self.parent.winEvents.set("Off").wait()
# Turn off distance mode
self.parent.dstEventsEna.set("Off").wait()
self.parent.dstCounterEna.set("Off").wait()
# Disable output
self.parent.outSource.set("None").wait()
# Sleep for one poll period
sleep(0.2)
def on_trigger(self) -> None | DeviceStatus:
"""Fire a single PSO event (i.e. manual software trigger)"""
# Only trigger if distance was set to invalid
logger.warning(f"[{self.parent.name}] Triggerin...")
if self.parent.dstDistanceVal.get() == 0:
status = self.parent._eventSingle.set(1, settle_time=0.1)
return status
class aa1AxisPsoBase(PSIDeviceBase):
class AerotechPsoBase(PSIDeviceBase, Device):
"""Position Sensitive Output - Base class
This class provides convenience wrappers around the Aerotech IOC's PSO
@@ -152,8 +91,35 @@ class aa1AxisPsoBase(PSIDeviceBase):
outPin = Component(EpicsSignalRO, "PIN", auto_monitor=True, kind=Kind.config)
outSource = Component(EpicsSignal, "SOURCE", put_complete=True, kind=Kind.config)
def trigger(self, settle_time=0.1) -> DeviceStatus:
# pylint: disable=duplicate-code, too-many-arguments
def __init__(
self,
prefix="",
*,
name,
kind=None,
read_attrs=None,
configuration_attrs=None,
parent=None,
scan_info=None,
**kwargs,
):
# Need to call super() to 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 fire(self, settle_time=0.1) -> None | DeviceStatus:
"""Fire a single PSO event (i.e. manual software trigger)"""
# Only trigger if distance was set to invalid
logger.warning(f"[{self.name}] Triggerin...")
self._eventSingle.set(1, settle_time=settle_time).wait()
status = DeviceStatus(self)
status.set_finished()
@@ -163,7 +129,7 @@ class aa1AxisPsoBase(PSIDeviceBase):
"""Toggle waveform outup"""
orig_wave_mode = self.waveMode.get()
self.waveMode.set("Toggle").wait()
self.trigger(0.1)
self.fire(0.1)
self.waveMode.set(orig_wave_mode).wait()
def configure(self, d: dict):
@@ -203,7 +169,7 @@ class aa1AxisPsoBase(PSIDeviceBase):
self.outSource.set("Window").wait()
class aa1AxisPsoDistance(aa1AxisPsoBase):
class aa1AxisPsoDistance(AerotechPsoBase):
"""Position Sensitive Output - Distance mode
This class provides convenience wrappers around the Aerotech API's PSO functionality in
@@ -232,13 +198,12 @@ class aa1AxisPsoDistance(aa1AxisPsoBase):
```
"""
custom_prepare_cls = AerotechPsoDistanceMixin
USER_ACCESS = ["configure", "prepare", "toggle"]
USER_ACCESS = ["configure", "fire", "toggle", "arm", "disarm"]
_distance_value = None
# ########################################################################
# PSO high level interface
def configure(self, d: dict = {}) -> tuple:
def configure(self, d: dict = None) -> tuple:
"""Simplified configuration interface to access the most common
functionality for distance mode PSO.
@@ -282,7 +247,51 @@ class aa1AxisPsoDistance(aa1AxisPsoBase):
logger.info(f"[{self.name}] PSO configured to {pso_wavemode} mode")
return (old, new)
def bluestage(self) -> None:
def on_stage(self) -> None:
"""Configuration and staging
NOTE: Scans don't have to fully configure the device, that can be done
manually outside. However we expect that the device is disabled
when not in use. I.e. this method is not expected to be called when
PSO is not needed or when it'd conflict with other devices.
"""
# Fish out configuration from scaninfo (does not need to be full configuration)
d = {}
scan_args = {
**self.scan_info.msg.request_inputs["inputs"],
**self.scan_info.msg.request_inputs["kwargs"],
**self.scan_info.msg.scan_parameters,
}
if "pso_distance" in scan_args:
d["pso_distance"] = scan_args["pso_distance"]
if "pso_wavemode" in scan_args:
d["pso_wavemode"] = scan_args["pso_wavemode"]
if "pso_w_pulse" in scan_args:
d["pso_w_pulse"] = scan_args["pso_w_pulse"]
if "pso_t_pulse" in scan_args:
d["pso_t_pulse"] = scan_args["pso_t_pulse"]
if "pso_n_pulse" in scan_args:
d["pso_n_pulse"] = scan_args["pso_n_pulse"]
# Perform bluesky-style configuration
if d:
self.configure(d=d)
# Stage the PSO distance module
self.arm()
def on_unstage(self):
"""Turn off the PSO module"""
self.disarm()
def on_trigger(self, settle_time=0.1) -> None | DeviceStatus:
"""Fire a single PSO event (i.e. manual software trigger)"""
# Only trigger if distance was set to invalid
# if self.dstDistanceVal.get() == 0:
logger.warning(f"[{self.name}] Triggerin...")
return self.fire(settle_time)
def arm(self) -> None:
"""Bluesky style stage"""
# Stage the PSO distance module and zero counter
if isinstance(self._distance_value, (np.ndarray, list, tuple)):
@@ -293,3 +302,19 @@ class aa1AxisPsoDistance(aa1AxisPsoBase):
if self.dstDistanceVal.get() > 0:
self.dstEventsEna.set("On").wait()
self.dstCounterEna.set("On").wait()
def disarm(self):
"""Standard bluesky unstage"""
# Ensure output is set to low
# if self.output.value:
# self.toggle()
# Turn off window mode
self.winOutput.set("Off").wait()
self.winEvents.set("Off").wait()
# Turn off distance mode
self.dstEventsEna.set("Off").wait()
self.dstCounterEna.set("Off").wait()
# Disable output
self.outSource.set("None").wait()
# Sleep for one poll period
sleep(0.2)

View File

@@ -34,7 +34,8 @@ program
//////////////////////////////////////////////////////////////////////////
// Internal parameters - dont use
var $axis as axis = ROTY
var $ii as integer
var $ii as integer
var $axisFaults as integer = 0
var $iDdcSafeSpace as integer = 4096
// Set acceleration
@@ -126,7 +127,12 @@ program
if $eScanType == ScanType.POS || $eScanType == ScanType.NEG
PsoDistanceConfigureArrayDistances($axis, $iPsoArrayPosAddr, $iPsoArrayPosSize, 0)
MoveAbsolute($axis, $fPosEnd, $fVelScan)
WaitForInPosition($axis)
WaitForMotionDone($axis)
$axisFaults = StatusGetAxisItem($axis, AxisDataSignal.AxisFault)
if $axisFaults
TaskSetError(TaskGetIndex(), "AxisFault on axis ROTY")
end
elseif $eScanType == ScanType.POSNEG || $eScanType == ScanType.NEGPOS
for $ii = 0 to ($iNumRepeat-1)
// Feedback on progress
@@ -134,11 +140,15 @@ program
if ($ii % 2) == 0
PsoDistanceConfigureArrayDistances($axis, $iPsoArrayPosAddr, $iPsoArrayPosSize, 0)
MoveAbsolute($axis, $fPosEnd, $fVelScan)
WaitForInPosition($axis)
elseif ($ii % 2) == 1
PsoDistanceConfigureArrayDistances($axis, $iPsoArrayNegAddr, $iPsoArrayNegSize, 0)
MoveAbsolute($axis, $fPosStart, $fVelScan)
WaitForInPosition($axis)
end
WaitForMotionDone($axis)
$axisFaults = StatusGetAxisItem($axis, AxisDataSignal.AxisFault)
if $axisFaults
TaskSetError(TaskGetIndex(), "AxisFault on axis ROTY")
end
Dwell(0.2)
end

View File

@@ -78,6 +78,7 @@ program
///////////////////////////////////////////////////////////
$iglobal[2] = $iNumSteps
for $ii = 0 to ($iNumSteps-1)
$rglobal[4] = $ii
MoveAbsolute($axis, $fStartPosition + $ii * $fStepSize, $fVelScan)
WaitForMotionDone($axis)

View File

@@ -5,67 +5,17 @@ interface.
@author: mohacsi_i
"""
from time import sleep
from ophyd import Component, EpicsSignal, EpicsSignalRO, Kind
from ophyd.status import DeviceStatus, SubscriptionStatus
from ophyd import Device, Component, EpicsSignal, EpicsSignalRO, Kind
from ophyd.status import SubscriptionStatus
from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase
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 bec_lib import bec_logger
logger = bec_logger.logger
class AerotechTasksMixin(CustomDeviceMixin):
"""Mixin class for self-configuration and staging
"""
# parent : aa1Tasks
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: Scans don't have to fully configure the device, that can be done
manually outside. However we expect that the device is disabled
when not in use. I.e. this method is not expected to be called when
PSO is not needed or when it'd conflict with other devices.
"""
# logger.warning(self.parent.scaninfo.scan_msg.info['kwargs'].keys())
# Fish out our configuration from scaninfo (via explicit or generic addressing)
d = {}
if "kwargs" in self.parent.scaninfo.scan_msg.info:
scanargs = self.parent.scaninfo.scan_msg.info["kwargs"]
if self.parent.scaninfo.scan_type in ("script", "scripted"):
# NOTE: Scans don't have to fully configure the device
if "script_text" in scanargs and scanargs["script_text"] is not None:
d["script_text"] = scanargs["script_text"]
if "script_file" in scanargs and scanargs["script_file"] is not None:
d["script_file"] = scanargs["script_file"]
if "script_task" in scanargs and scanargs["script_task"] is not None:
d["script_task"] = scanargs["script_task"]
# Perform bluesky-style configuration
if len(d) > 0:
logger.warning(f"[{self.parent.name}] Configuring with:\n{d}")
self.parent.configure(d=d)
# The actual staging
self.parent.bluestage()
def on_unstage(self):
"""Stop the currently selected task"""
self.parent.switch.set("Stop").wait()
def on_stop(self):
"""Stop the currently selected task"""
self.parent.switch.set("Stop").wait()
class aa1Tasks(PSIDeviceBase):
class aa1Tasks(PSIDeviceBase, Device):
"""Task management API
The place to manage tasks and AeroScript user files on the controller.
@@ -96,7 +46,7 @@ class aa1Tasks(PSIDeviceBase):
"""
custom_prepare_cls = AerotechTasksMixin
USER_ACCESS = ["arm", "disarm", "launch", "kickoff"]
_failure = Component(EpicsSignalRO, "FAILURE", auto_monitor=True, kind=Kind.normal)
errStatus = Component(EpicsSignalRO, "ERRW", auto_monitor=True, kind=Kind.normal)
@@ -109,27 +59,55 @@ class aa1Tasks(PSIDeviceBase):
_executeReply = Component(EpicsSignalRO, "EXECUTE_RBV", string=True, auto_monitor=True)
fileName = Component(EpicsSignal, "FILENAME", string=True, kind=Kind.omitted, put_complete=True)
# _fileRead = Component(EpicsPassiveRO, "FILEREAD", string=True, kind=Kind.omitted)
_fileWrite = Component(
EpicsSignal, "FILEWRITE", string=True, kind=Kind.omitted, put_complete=True
)
# pylint: disable=duplicate-code, too-many-arguments
def __init__(
self,
prefix="",
*,
name,
kind=None,
read_attrs=None,
configuration_attrs=None,
parent=None,
scan_info=None,
**kwargs,
):
# Need to call super() to 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) -> tuple:
"""Configuration interface for flying"""
"""Configure the scripting interface
Handles AeroScript loading and the launching of existing script files
on the Automation1 iSMC. The interface is meant to be used for flying.
"""
# Common operations
old = self.read_configuration()
self.switch.set("Reset").wait()
# Check what we got
if "script_task" in d:
if d['script_task'] < 3 or d['script_task'] > 21:
if d["script_task"] < 3 or d["script_task"] > 21:
raise RuntimeError(f"Invalid task index: {d['script_task']}")
self.taskIndex.set(d['script_task']).wait()
self.taskIndex.set(d["script_task"]).wait()
if "script_file" in d:
self.fileName.set(d["script_file"]).wait()
if "script_text" in d:
# Compile text for syntax checking
# NOTE: This will load to 'script_file'
self._fileWrite.set(d['script_text'], settle_time=0.2).wait()
self._fileWrite.set(d["script_text"], settle_time=0.2).wait()
self.switch.set("Load").wait()
# Check the result of load
if self._failure.value:
@@ -138,25 +116,85 @@ class aa1Tasks(PSIDeviceBase):
new = self.read_configuration()
return (old, new)
def bluestage(self) -> None:
"""Bluesky style stage"""
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: Scans don't have to fully configure the device, that can be done
manually outside. However we expect that the device is disabled
when not in use. I.e. this method is not expected to be called when
PSO is not needed or when it'd conflict with other devices.
"""
# Fish out our configuration from scaninfo (via explicit or generic addressing)
d = {}
scan_args = {
**self.scan_info.msg.request_inputs["inputs"],
**self.scan_info.msg.request_inputs["kwargs"],
**self.scan_info.msg.scan_parameters,
}
# if self.scan_info.scan_type in ("script", "scripted"):
# NOTE: Scans don't have to fully configure the device
if "script_text" in scan_args and scan_args["script_text"] is not None:
d["script_text"] = scan_args["script_text"]
if "script_file" in scan_args and scan_args["script_file"] is not None:
d["script_file"] = scan_args["script_file"]
if "script_task" in scan_args and scan_args["script_task"] is not None:
d["script_task"] = scan_args["script_task"]
# FIXME: The above should be exchanged with:
# d = self.scan_info.scan_msg.scan_parameters.get("aerotech_config")
# Perform bluesky-style configuration
if d:
self.configure(d=d)
# The actual staging
self.arm()
def on_unstage(self):
"""Stop the currently selected task"""
self.disarm()
def on_stop(self):
"""Stop the currently selected task"""
self.unstage()
def on_kickoff(self):
"""Start execution of the selected task"""
self.launch()
def arm(self) -> None:
"""Bluesky style stage, prepare, but does not execute"""
if self.taskIndex.get() in (0, 1, 2):
logger.error(f"[{self.name}] Launching AeroScript on system task. Daring today are we?")
# Launch and check success
status = self.switch.set("Run", settle_time=0.2)
logger.error(f"[{self.name}] Loading AeroScript on system task. Daring today are we?")
# Load and check success
status = self.switch.set("Load", settle_time=0.2)
status.wait()
if self._failure.value:
raise RuntimeError("Failed to kick off task, please check the Aerotech IOC")
raise RuntimeError("Failed to load task, please check the Aerotech IOC")
return status
##########################################################################
# Bluesky flyer interface
def complete(self) -> DeviceStatus:
def disarm(self):
"""Bluesky style unstage, stops execution"""
self.switch.set("Stop").wait()
def launch(self):
"""Bluesky style kickoff"""
# Launch and check success
status = self.switch.set("Start", settle_time=0.2)
status.wait()
if self._failure.value:
raise RuntimeError("Failed to load task, please check the Aerotech IOC")
return status
def complete(self) -> SubscriptionStatus:
"""Wait for a RUNNING task"""
timestamp_ = 0
task_idx = int(self.taskIndex.get())
def not_running(*args, value, timestamp, **kwargs):
def not_running(*, value, timestamp, **_):
nonlocal timestamp_
result = value[task_idx] not in ["Running", 4]
timestamp_ = timestamp

View File

@@ -1,4 +1,9 @@
from .AerotechTasks import aa1Tasks
from .AerotechPso import aa1AxisPsoDistance
from .AerotechDriveDataCollection import aa1AxisDriveDataCollection
from .AerotechAutomation1 import aa1Controller, aa1GlobalVariables, aa1GlobalVariableBindings, aa1AxisIo
from .AerotechAutomation1 import (
aa1Controller,
aa1GlobalVariables,
aa1GlobalVariableBindings,
aa1AxisIo,
)

View File

@@ -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,39 @@ 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="/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)
# 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 +78,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 +185,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 +197,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()

View File

@@ -6,23 +6,17 @@ Created on Thu Jun 27 17:28:43 2024
@author: mohacsi_i
"""
from time import sleep
from typing import Literal
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
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,
)
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
@@ -63,15 +57,21 @@ class GigaFrostCamera(PSIDeviceBase, GigaFrostBase):
# pylint: disable=too-many-instance-attributes
USER_ACCESS = [
"complete",
"exposure_mode",
"fix_nframes_mode",
"trigger_mode",
"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,
@@ -90,11 +90,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,76 +101,94 @@ 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 Bluesky style configure
if d:
# 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()
# Update all specified ophyd signals
config = {}
for key in self.component_names:
val = d.get(key)
if val is not None:
config[key] = val
self.corr_mode.set(d.get("corr_mode", 5)).wait()
self.scan_id.set(d.get("scan_id", 0)).wait()
if d.get("exp_time", 0) > 0:
config["exposure"] = d["exp_time"] * 1000 # exposure time in ms
# If a pre-configured acquisition mode is specified, set it
if "acq_mode" in d:
self.set_acquisition_mode(d["acq_mode"])
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
# 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
@@ -186,11 +199,10 @@ class GigaFrostCamera(PSIDeviceBase, GigaFrostBase):
supplied signal. Use external enable instead, that works!
"""
if acq_mode == "default":
# NOTE: Trigger using software events via softEnable (actually works)
if acq_mode in ["default", "step"]:
# 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 +248,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 +270,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 +320,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 +328,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 +372,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 +430,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 +442,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,96 +469,29 @@ 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 = []
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):
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:
@@ -564,12 +504,14 @@ class GigaFrostCamera(PSIDeviceBase, GigaFrostBase):
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],
)
if self.backend is not None:
self.backend.add_status_callback(
status,
success=[StdDaqStatus.IDLE, StdDaqStatus.FILE_SAVED],
error=[StdDaqStatus.REJECTED, StdDaqStatus.ERROR],
)
else:
status.set_finished()
return status
########################################
@@ -589,18 +531,9 @@ 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()
def on_stage(self) -> DeviceStatus | None:
"""
Called while staging the device.
@@ -613,43 +546,76 @@ 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 "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:
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:
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.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 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."""
# 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()
# 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."""
@@ -667,6 +633,13 @@ 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() or self.backend is None:
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 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()
@@ -685,6 +658,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()

View File

@@ -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()

View File

@@ -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']):
@@ -94,6 +96,7 @@ class PcoTestConsumerMixin(CustomDetectorMixin):
# f"[{self.parent.name}] Updated frame {header['frame']}\t"
# f"Shape: {header['shape']}\tMean: {np.mean(image):.3f}"
# )
print(f"[{self.parent.name}] Updated frame {header['frame']}\t")
except ValueError:
# Happens when ZMQ partially delivers the multipart message
pass
@@ -105,7 +108,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
@@ -128,6 +131,8 @@ class PcoTestConsumer(PSIDetectorBase):
SUB_MONITOR = "device_monitor_2d"
_default_sub = SUB_MONITOR
header = None
custom_prepare_cls = PcoTestConsumerMixin
# Status attributes

View File

@@ -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()

View File

@@ -5,141 +5,22 @@ Created on Wed Dec 6 11:33:54 2023
@author: mohacsi_i
"""
import time
from ophyd import Component, EpicsSignal, EpicsSignalRO, Kind
import numpy as np
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 bec_lib.logger import bec_logger
logger = bec_logger.logger
except ModuleNotFoundError:
import logging
logger = logging.getLogger("PcoEdgeCam")
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
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.
"""
# 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()
logger = bec_logger.logger
class HelgeCameraBase(BECDeviceBase):
# pylint: disable=too-many-instance-attributes
class PcoEdge5M(PSIDeviceBase, PcoEdgeBase):
"""Ophyd baseclass for Helge camera IOCs
This class provides wrappers for Helge's camera IOCs around SwissFEL and
@@ -175,107 +56,52 @@ 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")
USER_ACCESS = ["complete", "backend", "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
)
# pylint: disable=too-many-arguments
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!")
# ########################################################################
# 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 configure(self, d: dict = {}) -> tuple:
def configure(self, d: dict = None) -> tuple:
"""Configure the base Helge camera device
Parameters as 'd' dictionary
@@ -293,6 +119,8 @@ class HelgeCameraBase(BECDeviceBase):
*'FIFO buffer' for continous streaming
data_format : str
Usually set to 'ZEROMQ'
acq_mode : str
Store mode and data format according to preconfigured settings
"""
if self.state not in ("IDLE"):
raise RuntimeError(f"Can't change configuration from state {self.state}")
@@ -308,11 +136,19 @@ 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:
self.file_format.set(d["data_format"]).wait()
# If a pre-configured acquisition mode is specified, set it
if "acq_mode" in d:
self.set_acquisition_mode(d["acq_mode"])
# State machine
# Initial: BUSY and SET both low
# 0. Write 1 to SET_PARAM
@@ -320,16 +156,31 @@ 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 set_acquisition_mode(self, acq_mode):
"""Set acquisition mode
Utility function to quickly select between pre-configured and tested
acquisition modes.
"""
if acq_mode in ["default", "step"]:
# NOTE: Trigger duration requires a consumer
self.bufferStoreMode.set("FIFO Buffer").wait()
if acq_mode in ["stream"]:
# NOTE: Trigger duration requires a consumer
self.bufferStoreMode.set("FIFO Buffer").wait()
else:
raise RuntimeError(f"Unsupported acquisition mode: {acq_mode}")
def arm(self):
"""Bluesky style stage: arm the detector"""
logger.warning("Staging PCO")
# Acquisition is only allowed when the IOC is not busy
@@ -345,129 +196,220 @@ class HelgeCameraBase(BECDeviceBase):
)
# 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 blueunstage(self):
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
# FIXME: This will interrupt data transfer
self.file_savestop.set(0).wait()
def bluekickoff(self):
def destroy(self):
logger.warning("Destroy called")
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"""
# FIXME: There's also a recorded images counter provided by the stdDAQ writer
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 "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_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 "acq_burst" in scan_args and scan_args["acq_burst"] is not None:
d["exposure_num_burst"] = scan_args["acq_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."""
logger.warning("Called op_prescan on PCO camera")
# 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
self.buffer_clear.set(1, settle_time=0.1).wait()
# Wait until the buffer fills up with enough images
t_expected = (self.acquire_time.get() + self.acquire_delay.get()) * self.file_savestop.get()
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, **_):
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
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()

View File

@@ -3,20 +3,18 @@ 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
from websockets.sync.client import ClientConnection, connect
import websockets.sync.client as ws
if TYPE_CHECKING: # pragma: no cover
from ophyd import Device, DeviceStatus
@@ -48,96 +46,38 @@ 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")
# pylint: disable=too-many-instance-attributes
class StdDaqClient:
"""Standalone stdDAQ client class"""
USER_ACCESS = ["status", "start", "stop", "get_config", "set_config", "reset"]
USER_ACCESS = ["status", "count", "start", "stop", "get_config", "set_config", "reset"]
_ws_client: ws.ClientConnection | None = None
_count: int = 0
_status: str = "undefined"
_status_timestamp: float | None = None
_ws_monitor_thread: threading.Thread | 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):
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.name = self.parent.name if self.parent is not None else "None"
# Must be here otherwise they're static (shared between class instances)
self._ws_recv_mutex = threading.Lock()
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()
# Connect to WS interface and start status monitoring
self.wait_for_connection()
self._daq_is_running.set()
self._ws_monitor_thread = threading.Thread(
target=self._ws_monitor_loop, name=f"{self.name}_ws_monitor", daemon=True
)
self._ws_monitor_thread.start()
@property
def status(self) -> StdDaqStatus:
@@ -146,13 +86,19 @@ class StdDaqClient:
"""
return self._status
@property
def count(self) -> int:
"""Get the recorded frame count"""
return self._count
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.
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
@@ -163,63 +109,90 @@ class StdDaqClient:
@typechecked
def start(
self, file_path: str, file_prefix: str, num_images: int, timeout: float = 20, wait=True
) -> StatusBase:
"""
Start acquisition on the StdDAQ.
self,
file_path: str,
file_prefix: str,
num_images: int,
timeout: float = 20,
wait: bool = True,
) -> StatusBase | None:
"""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
"""
logger.info(f"Starting StdDaq backend. Current status: {self.status}")
# Ensure connection
self.wait_for_connection()
status = StatusBase()
self.add_status_callback(status, success=["waiting_for_first_image"], error=[])
# NOTE: CREATING_FILE --> IDLE is a known error, the exact cause is unknown,
# Might be botched overwrite protection (solved by changing file_prefix)
# In previous versions there was also a mutex ownership problem
self.add_status_callback(
status, success=["waiting_for_first_image"], error=["rejected", "idle"]
)
message = {
"command": "start",
"path": file_path,
"file_prefix": file_prefix,
"n_image": num_images,
}
self._send_queue.put(message)
logger.info(f"Starting StdDaq backend. Current status: {self.status}. Message: {message}")
self._ws_client.send(json.dumps(message))
if wait:
return status.wait(timeout=timeout)
status.wait(timeout=timeout)
return None
return status
def stop(self):
"""
Stop acquisition on the StdDAQ.
@typechecked
def stop(self, timeout: float = 5, wait=True, stop_cmd="stop") -> StatusBase | None:
"""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
status (StatusBase): Ophyd status object with attached monitor
"""
if cached and self._config is not None:
# 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_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, cached=False) -> dict:
"""Get the current configuration of the StdDAQ.
Args:
timeout (float): timeout for the request
Returns:
config (dict): configuration of the StdDAQ
"""
if cached:
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, force: bool = True
) -> None:
"""
Set the configuration of the StdDAQ. This will overwrite the current configuration.
@@ -227,20 +200,29 @@ class StdDaqClient:
config (StdDaqConfig | dict): configuration to set
timeout (float): timeout for the request
"""
if not isinstance(config, StdDaqConfig):
config = StdDaqConfig(**config)
old_config = self.get_config()
if update:
cfg = copy.deepcopy(self._config)
cfg.update(config)
new_config = cfg
else:
new_config = 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."
)
# 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=out, timeout=timeout
self.rest_url + "/api/config/set",
params={"user": "ioc"},
json=new_config,
timeout=timeout,
)
response.raise_for_status()
@@ -248,38 +230,18 @@ 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"""
time.sleep(2)
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 +261,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 = ws.connect(self.ws_url)
break
except ConnectionRefusedError as exc:
if time.time() - start_time > timeout:
@@ -335,25 +297,19 @@ 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.
"""
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
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("Thread 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):
"""
@@ -366,55 +322,48 @@ 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):
This is a persistent monitor that updates the status and calls attached
callbacks. It also handles stdDAQ restarts and reconnection by itself.
"""
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}")
if self._ws_recv_mutex.locked():
logger.warning(f"[{self.name}] stdDAQ WS monitor loop already locked")
return
self._status = data.status
self._run_status_callbacks()
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.warning(
f"[{self.name}] stdDAQ state transition: {self._status} --> {msg['status']}"
)
if msg["status"] == "recording":
self._count = msg.get("count", 0)
# Update status and run callbacks
self._status = msg["status"]
self._status_timestamp = msg_timestamp
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
@@ -431,3 +380,16 @@ class StdDaqClient:
for cb in completed_callbacks:
self._status_callbacks.pop(id(cb))
# 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(
parent=foo(), ws_url="ws://129.129.95.111:8080", rest_url="http://129.129.95.111:5000"
)

View File

@@ -2,6 +2,7 @@ import json
import threading
import time
from typing import Callable
import traceback
import numpy as np
import zmq
@@ -13,14 +14,21 @@ ZMQ_TOPIC_FILTER = b""
class StdDaqPreview:
USER_ACCESS = ["start", "stop"]
"""Standalone stdDAQ preview class"""
USER_ACCESS = ["start", "stop", "image", "frameno"]
_socket = None
_zmq_thread = None
_throttle = 0.2
image = None
frameno = 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
# Must be here otherwise they're static (shared between class instances)
self._monitor_mutex = threading.Lock()
self._shutdown_event = threading.Event()
def connect(self):
"""Connect to te StDAQs PUB-SUB streaming interface
@@ -40,56 +48,65 @@ class StdDaqPreview:
self._socket.connect(self.url)
def start(self):
"""Start the preview thread"""
# 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"
target=self._zmq_monitor, daemon=True, name="StdDaq_live_preview"
)
self._zmq_thread.start()
def stop(self):
"""Stop the preview and disconnect from ZMQ stream"""
self._shutdown_event.set()
if self._zmq_thread:
self._zmq_thread.join()
self._zmq_thread = None
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 _zmq_monitor(self):
"""ZMQ stream monitor"""
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):
# Exit if another monitor is running
if self._monitor_mutex.locked():
return
try:
# subscribe to the topic
self._socket.setsockopt(zmq.SUBSCRIBE, ZMQ_TOPIC_FILTER)
with self._monitor_mutex:
# Open a new connection
self.connect()
# pylint: disable=no-member
r = self._socket.recv_multipart(flags=zmq.NOBLOCK)
self._parse_data(r)
try:
# 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)
finally:
# Unsubscribe from the topic
self._socket.setsockopt(zmq.UNSUBSCRIBE, ZMQ_TOPIC_FILTER)
# 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
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)
finally:
# Stop receiving incoming data
self._socket.close()
logger.warning("Detached live_preview monitoring")
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 +115,19 @@ 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.image = image
self.frameno = header["frame"]
self._on_update_callback(image, header)

View File

@@ -1,2 +1,8 @@
from .tutorial_fly_scan import AcquireDark, AcquireWhite, AcquireRefs, AcquireProjections, TutorialFlyScanContLine
from .tomcat_scans import TomcatSnapNStep, TomcatSimpleSequence
from .tomcat_scans import TomcatSimpleSequence, TomcatSnapNStep
from .tutorial_fly_scan import (
AcquireDark,
AcquireProjections,
AcquireRefs,
AcquireWhite,
TutorialFlyScanContLine,
)

View File

@@ -0,0 +1,410 @@
import time
import numpy as np
from bec_lib.device import DeviceBase
from bec_server.scan_server.scans import Acquire, AsyncFlyScanBase
from bec_lib import bec_logger
logger = bec_logger.logger
class Shutter:
"""Shutter status"""
CLOSED = 0
OPEN = 1
class AcquireDarkV2(Acquire):
scan_name = "acquire_dark_v2"
required_kwargs = ["exp_burst"]
gui_config = {"Acquisition parameters": ["exp_burst"]}
def __init__(self, exp_burst: int, file_prefix="", **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.
NOTE: this scan has a special operation mode that does not call
Args:
exp_burst : int
Number of dark images to acquire (no default)
file_prefix : str
File prefix
Examples:
>>> scans.acquire_dark(5)
"""
super().__init__(exp_burst=exp_burst, file_prefix="", **kwargs)
self.burst_at_each_point = 1 # At each point, how many times I want to individually trigger
self.exp_burst = exp_burst
self.file_prefix = file_prefix
def pre_scan(self):
"""Close the shutter before scan"""
yield from self.stubs.set(device=["eyex"], value=[Shutter.CLOSED])
return super().pre_scan()
def direct(self):
"""Direct scan procedure call"""
# Collect relevant devices
self.cams = [
cam.name
for cam in self.device_manager.devices.get_devices_with_tags("camera")
if cam.enabled
]
self.prev = [
pre.name
for pre in self.device_manager.devices.get_devices_with_tags("preview")
if pre.enabled
]
self.daqs = [
daq.name
for daq in self.device_manager.devices.get_devices_with_tags("daq")
if daq.enabled
]
# Do not call stage, as there's no ScanInfo emitted for direct call
for daq in self.daqs:
cam = yield from self.stubs.send_rpc_and_wait(daq, "datasource.get")
prefix = f"{self.file_prefix}_{cam}_dark"
yield from self.stubs.send_rpc_and_wait(daq, "file_prefix.set", prefix)
yield from self.stubs.send_rpc_and_wait(daq, "num_images.set", self.exp_burst)
yield from self.stubs.send_rpc_and_wait(daq, "arm")
for prev in self.prev:
yield from self.stubs.send_rpc_and_wait(prev, "arm")
for cam in self.cams:
yield from self.stubs.send_rpc_and_wait(
cam, "configure", {"exposure_num_burst": self.exp_burst}
)
yield from self.stubs.send_rpc_and_wait(cam, "arm")
yield from self.pre_scan()
yield from self.scan_core()
yield from self.finalize()
yield from self.unstage()
yield from self.cleanup()
class AcquireWhiteV2(Acquire):
scan_name = "acquire_white_v2"
gui_config = {"Acquisition parameters": ["exp_burst"]}
def __init__(
self,
motor: DeviceBase,
exp_burst: int,
sample_position_out: float,
sample_angle_out: float,
file_prefix: str = "",
**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 non-uniformity in the detector.
Args:
motor : DeviceBase
Motor to be moved to move the sample out of beam
exp_burst : int
Number of flat field images to acquire (no default)
sample_position_out : float
Position to move the sample stage out of beam and take flat field images
sample_angle_out : float
Angular position where to take the flat field images
Examples:
>>> scans.acquire_white(dev.samx, 5, 20)
"""
super().__init__(exp_burst=exp_burst, **kwargs)
self.exp_burst = exp_burst
self.file_prefix = file_prefix
self.burst_at_each_point = 1
self.scan_motors = [motor, "eyez"]
# self.scan_motors = [motor, "es1_roty"]
self.out_position = [sample_position_out, sample_angle_out]
def pre_scan(self):
"""Open the shutter before scan"""
# Move sample out
yield from self._move_scan_motors_and_wait(self.out_position)
# Open the main shutter (TODO change to the correct shutter device)
yield from self.stubs.set(device=["eyex"], value=[Shutter.OPEN])
return super().pre_scan()
def cleanup(self):
"""Close the shutter after scan"""
# Close fast shutter
yield from self.stubs.set(device=["eyex"], value=[Shutter.CLOSED])
return super().cleanup()
def direct(self):
"""Direct scan procedure call"""
# Collect relevant devices
self.cams = [
cam.name
for cam in self.device_manager.devices.get_devices_with_tags("camera")
if cam.enabled
]
self.prev = [
pre.name
for pre in self.device_manager.devices.get_devices_with_tags("preview")
if pre.enabled
]
self.daqs = [
daq.name
for daq in self.device_manager.devices.get_devices_with_tags("daq")
if daq.enabled
]
# Do not call stage, as there's no ScanInfo emitted for direct call
for daq in self.daqs:
cam = yield from self.stubs.send_rpc_and_wait(daq, "datasource.get")
prefix = f"{self.file_prefix}_{cam}_white"
yield from self.stubs.send_rpc_and_wait(daq, "file_prefix.set", prefix)
yield from self.stubs.send_rpc_and_wait(daq, "num_images.set", self.exp_burst)
yield from self.stubs.send_rpc_and_wait(daq, "arm")
for prev in self.prev:
yield from self.stubs.send_rpc_and_wait(prev, "arm")
for cam in self.cams:
yield from self.stubs.send_rpc_and_wait(
cam, "configure", {"exposure_num_burst": self.exp_burst}
)
yield from self.stubs.send_rpc_and_wait(cam, "arm")
yield from self.pre_scan()
yield from self.scan_core()
yield from self.finalize()
yield from self.unstage()
yield from self.cleanup()
# class AcquireProjections(AsyncFlyScanBase):
# scan_name = "acquire_projections"
# gui_config = {
# "Motor": ["motor"],
# "Acquisition parameters": ["sample_position_in", "start_angle", "angular_range" ],
# "Camera": ["exp_time", "exp_burst"]
# }
# 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.
# 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_angle : float
# Angular start position for the scan
# angular_range : float
# Angular range
# exp_time : float, optional
# Exposure time [ms]. If not specified, the currently configured value on the camera will be used
# exp_period : float, optional
# Exposure period [ms]. 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
# image_height : int, optional
# 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
# 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_projections()
# """
# 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_angle = start_angle
# self.angular_range = angular_range
# 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):
# # 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
# # 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 AcquireRefsV2(Acquire):
scan_name = "acquire_refs_v2"
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 = 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]
exp_time : float, optional
Exposure time [ms]. If not specified, the currently configured value
on the camera will be used
exp_period : float, optional
Exposure period [ms]. 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
image_height : int, optional
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
File path for standard daq
Returns:
ScanReport
Examples:
>>> 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
self.sample_angle_out = sample_angle_out
self.num_darks = num_darks
self.num_flats = num_flats
self.file_prefix_dark = file_prefix_dark
self.file_prefix_white = file_prefix_white
self.scan_parameters["std_daq_params"] = {"reconnect": False}
def stage(self):
"""Wrapped scan doesn't need staging"""
yield None
def scan_core(self):
if self.num_darks:
msg = f"Acquiring {self.num_darks} dark images"
logger.warning(msg)
self.connector.send_client_info(msg, show_asap=True, rid=self.metadata.get("RID"))
darks = AcquireDarkV2(
exp_burst=self.num_darks,
# file_prefix=self.file_prefix_dark,
device_manager=self.device_manager,
metadata=self.metadata,
instruction_handler=self.stubs._instruction_handler,
**self.caller_kwargs,
)
yield from darks.direct()
self.point_id = darks.point_id
if self.num_flats:
msg = f"Acquiring {self.num_flats} flat field images"
logger.warning(msg)
self.connector.send_client_info(msg, show_asap=True, rid=self.metadata.get("RID"))
logger.warning("Calling AcquireWhite")
flats = AcquireWhiteV2(
motor=self.motor,
exp_burst=self.num_flats,
sample_position_out=self.sample_position_out,
# sample_angle_out=self.sample_angle_out,
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.direct()
self.point_id = flats.point_id
## TODO move sample in beam and do not wait
## TODO move rotation to angle and do not wait
logger.warning("[AcquireRefsV2] Done with scan_core")

View File

@@ -0,0 +1,12 @@
# from .metadata_schema_template import ExampleSchema
METADATA_SCHEMA_REGISTRY = {
# Add models which should be used to validate scan metadata here.
# Make a model according to the template, and import it as above
# Then associate it with a scan like so:
# "example_scan": ExampleSchema
}
# Define a default schema type which should be used as the fallback for everything:
DEFAULT_SCHEMA = None

View File

@@ -0,0 +1,34 @@
# # By inheriting from BasicScanMetadata you can define a schema by which metadata
# # supplied to a scan must be validated.
# # This schema is a Pydantic model: https://docs.pydantic.dev/latest/concepts/models/
# # but by default it will still allow you to add any arbitrary information to it.
# # That is to say, when you run a scan with which such a model has been associated in the
# # metadata_schema_registry, you can supply any python dictionary with strings as keys
# # and built-in python types (strings, integers, floats) as values, and these will be
# # added to the experiment metadata, but it *must* contain the keys and values of the
# # types defined in the schema class.
# #
# #
# # For example, say that you would like to enforce recording information about sample
# # pretreatment, you could define the following:
# #
#
# from bec_lib.metadata_schema import BasicScanMetadata
#
#
# class ExampleSchema(BasicScanMetadata):
# treatment_description: str
# treatment_temperature_k: int
#
#
# # If this was used according to the example in metadata_schema_registry.py,
# # then when calling the scan, the user would need to write something like:
# >>> scans.example_scan(
# >>> motor,
# >>> 1,
# >>> 2,
# >>> 3,
# >>> metadata={"treatment_description": "oven overnight", "treatment_temperature_k": 575},
# >>> )
#
# # And the additional metadata would be saved in the HDF5 file created for the scan.

View File

View File

@@ -18,95 +18,11 @@ import os
import time
from bec_lib import bec_logger
from bec_server.scan_server.scans import AsyncFlyScanBase, ScanBase, ScanArgType
from bec_server.scan_server.scans import AsyncFlyScanBase, ScanArgType
logger = bec_logger.logger
class TomcatStepScan(ScanBase):
"""Simple software step scan for Tomcat
Example class for simple BEC-based step scan using the low-level API. It's just a standard
'line_scan' with the only difference that overrides burst behavior to use camera burst instead
of individual software triggers.
NOTE: As decided by Tomcat, the scans should not manage the scope of devices
- All enabled devices are expected to be configured for acquisition by the end of stage
- Some devices can configure themselves from mandatory scan parameters (steps, burst)
- Other devices can be optionally configured by keyword arguments
- Devices will try to stage using whatever was set on them before
Example:
--------
>>> scans.tomcatstepscan(scan_start=-25, scan_end=155, steps=180, exp_time=0.005, exp_burst=5)
Common keyword arguments:
-------------------------
image_width : int
image_height : int
ddc_trigger : str
"""
scan_name = "tomcatstepscan"
scan_type = "step"
required_kwargs = ["scan_start", "scan_end", "steps"]
gui_config = {
"Movement parameters": ["steps"],
"Acquisition parameters": ["exp_time", "exp_burst"],
}
def update_scan_motors(self):
self.scan_motors = ["es1_roty"]
def _get_scan_motors(self):
self.scan_motors = ["es1_roty"]
def __init__(
self,
scan_start: float,
scan_end: float,
steps: int,
exp_time: float=0.005,
settling_time: float=0.2,
exp_burst: int=1,
**kwargs,
):
# Converting generic kwargs to tomcat device configuration parameters
super().__init__(
exp_time=exp_time,
settling_time=settling_time,
burst_at_each_point=1,
optim_trajectory=None,
**kwargs,
)
# For position calculation
self.motor = "es1_roty"
self.scan_start = scan_start
self.scan_end = scan_end
self.scan_steps = steps
self.scan_stepsize = (scan_end - scan_start) / steps
def _calculate_positions(self) -> None:
"""Pre-calculate scan positions"""
for ii in range(self.scan_steps + 1):
self.positions.append(self.scan_start + ii * self.scan_stepsize)
def _at_each_point(self, ind=None, pos=None):
""" Overriden at_each_point, using detector burst instaead of manual triggering"""
yield from self._move_scan_motors_and_wait(pos)
time.sleep(self.settling_time)
trigger_time = 0.001*self.exp_time * self.burst_at_each_point
yield from self.stubs.trigger(min_wait=trigger_time)
# yield from self.stubs.trigger(group='trigger', point_id=self.point_id)
# time.sleep(trigger_time)
yield from self.stubs.read(group="monitored", point_id=self.point_id)
# yield from self.stubs.read(group="monitored", point_id=self.point_id, wait_group=None)
self.point_id += 1
class TomcatSnapNStep(AsyncFlyScanBase):
"""Simple software step scan forTomcat
@@ -115,21 +31,21 @@ class TomcatSnapNStep(AsyncFlyScanBase):
Example
-------
>>> scans.tomcatsnapnstepscan(scan_start=-25, scan_end=155, steps=180, exp_time=0.005, exp_burst=5)
>>> scans.tomcatsnapnstepscan(scan_start=-25, scan_end=155, steps=180, acq_time=5, exp_burst=5)
"""
scan_name = "tomcatsnapnstepscan"
scan_type = "scripted"
# scan_type = "scripted"
# arg_input = {"camera" : ScanArgType.DEVICE,
# "exp_time" : ScanArgType.FLOAT}
# arg_bundle_size= {"bundle": len(arg_input), "min": 1, "max": None}
required_kwargs = ["scan_start", "scan_end", "steps"]
gui_config = {
"Movement parameters": ["steps"],
"Acquisition parameters": ["exp_time", "exp_burst"],
"Acquisition parameters": ["acq_time", "exp_burst"],
}
def _get_scan_motors(self):
def _update_scan_motors(self):
self.scan_motors = ["es1_roty"]
def __init__(
@@ -137,8 +53,8 @@ class TomcatSnapNStep(AsyncFlyScanBase):
scan_start: float,
scan_end: float,
steps: int,
exp_time:float=0.005,
settling_time:float=0.2,
acq_time:float=5.0,
exp_burst:int=1,
sync:str="event",
**kwargs,
@@ -147,25 +63,30 @@ class TomcatSnapNStep(AsyncFlyScanBase):
self.scan_start = scan_start
self.scan_end = scan_end
self.scan_steps = steps
self.scan_stepsize = (scan_end - scan_start) / steps
self.scan_ntotal = exp_burst * (steps + 1)
self.exp_time = exp_time
self.exp_time = acq_time
self.exp_burst = exp_burst
self.settling_time = settling_time
self.scan_sync = sync
# General device configuration
# Gigafrost trigger mode
kwargs["parameter"]["kwargs"]["acq_mode"] = "ext_enable"
# Used for Aeroscript file substitutions for the task interface
filename = "AerotechSnapAndStepTemplate.ascript"
filesubs = self.get_filesubs()
filetext = self.render_file(filename, filesubs)
kwargs["parameter"]["kwargs"]["script_text"] = filetext
kwargs["parameter"]["kwargs"]["script_file"] = "bec.ascript"
self.scan_parameters["aerotech_config"] = {
"script_text":filetext,
"script_file":"bec.ascript",
"script_task": 4,
}
# self.scan_parameters["script_file"] = "bec.ascript"
# kwargs["parameter"]["kwargs"]["script_text"] = filetext
# kwargs["parameter"]["kwargs"]["script_file"] = "bec.ascript"
super().__init__(
exp_time=exp_time,
acq_time=acq_time,
exp_burst=exp_burst,
settling_time=settling_time,
burst_at_each_point=1,
optim_trajectory=None,
@@ -182,12 +103,12 @@ class TomcatSnapNStep(AsyncFlyScanBase):
}
filesubs = {
"startpos": self.scan_start,
"stepsize": self.scan_stepsize,
"stepsize": (self.scan_end - self.scan_start) / self.scan_steps,
"numsteps": self.scan_steps,
"exptime": 2 * self.exp_time * self.exp_burst,
"exptime": 0.002 * self.exp_time * self.exp_burst,
"settling": self.settling_time,
"psotrigger": p_modes[self.scan_sync],
"npoints": self.scan_ntotal,
"npoints": self.exp_burst * (self.scan_steps + 1),
}
return filesubs
@@ -196,7 +117,7 @@ class TomcatSnapNStep(AsyncFlyScanBase):
# Load the test file
filename = os.path.join(os.path.dirname(__file__), "../devices/aerotech/" + filename)
logger.info(f"Attempting to load file {filename}")
with open(filename) as f:
with open(filename, "r", encoding="utf-8") as f:
templatetext = f.read()
# Substitute jinja template
@@ -208,12 +129,15 @@ class TomcatSnapNStep(AsyncFlyScanBase):
"""The actual scan routine"""
print("TOMCAT Running scripted scan (via Jinjad AeroScript)")
t_start = time.time()
# Kickoff
yield from self.stubs.send_rpc_and_wait("es1_tasks", "kickoff")
# Complete
# FIXME: this will swallow errors
# yield from self.stubs.complete(device="es1_tasks")
st = yield from self.stubs.send_rpc_and_wait("es1_tasks", "complete")
# st.wait()
yield from self.stubs.send_rpc_and_wait("es1_tasks", "complete")
# Check final state
task_states = yield from self.stubs.send_rpc_and_wait("es1_tasks", "taskStates.get")
if task_states[4] == 8:
raise RuntimeError(f"Task {4} finished in ERROR state")
@@ -243,11 +167,11 @@ class TomcatSimpleSequence(AsyncFlyScanBase):
Example
-------
>>> scans.tomcatsimplesequencescan(33, 180, 180, exp_time=0.005, exp_burst=1800, repeats=10)
>>> scans.tomcatsimplesequencescan(33, 180, 180, acq_time=5, exp_burst=1800, repeats=10)
"""
scan_name = "tomcatsimplesequencescan"
scan_type = "scripted"
# scan_type = "scripted"
scan_report_hint = "table"
required_kwargs = ["scan_start", "gate_high", "gate_low"]
gui_config = {
@@ -255,7 +179,7 @@ class TomcatSimpleSequence(AsyncFlyScanBase):
"Acquisition parameters": [
"gate_high",
"gate_low",
"exp_time",
"acq_time",
"exp_burst",
"sync",
],
@@ -271,7 +195,7 @@ class TomcatSimpleSequence(AsyncFlyScanBase):
gate_low: float,
repeats: int = 1,
repmode: str = "PosNeg",
exp_time: float = 0.005,
acq_time: float = 5,
exp_burst: float = 180,
sync: str = "pso",
**kwargs,
@@ -282,13 +206,13 @@ class TomcatSimpleSequence(AsyncFlyScanBase):
self.gate_low = gate_low
self.scan_repnum = repeats
self.scan_repmode = repmode.upper()
self.exp_time = exp_time
self.exp_time = acq_time
self.exp_burst = exp_burst
self.scan_sync = sync
# Synthetic values
self.scan_ntotal = exp_burst * repeats
self.scan_velocity = gate_high / (exp_time * exp_burst)
self.scan_velocity = gate_high / (0.001*acq_time * exp_burst)
self.scan_acceleration = 500
self.scan_safedistance = 10
self.scan_accdistance = (
@@ -314,7 +238,7 @@ class TomcatSimpleSequence(AsyncFlyScanBase):
kwargs["parameter"]["kwargs"]["script_file"] = "bec.ascript"
super().__init__(
exp_time=exp_time,
acq_time=acq_time,
settling_time=0.5,
relative=False,
burst_at_each_point=1,
@@ -327,7 +251,7 @@ class TomcatSimpleSequence(AsyncFlyScanBase):
# Load the test file
filename = os.path.join(os.path.dirname(__file__), "../devices/aerotech/" + filename)
logger.info(f"Attempting to load file {filename}")
with open(filename) as f:
with open(filename, 'r', encoding="utf-8") as f:
templatetext = f.read()
# Substitute jinja template
@@ -339,12 +263,14 @@ class TomcatSimpleSequence(AsyncFlyScanBase):
"""The actual scan routine"""
print("TOMCAT Running scripted scan (via Jinjad AeroScript)")
t_start = time.time()
# Kickoff
yield from self.stubs.send_rpc_and_wait("es1_tasks", "kickoff")
# Complete
# FIXME: this will swallow errors
# yield from self.stubs.complete(device="es1_tasks")
st = yield from self.stubs.send_rpc_and_wait("es1_tasks", "complete")
# st.wait()
yield from self.stubs.send_rpc_and_wait("es1_tasks", "complete")
task_states = yield from self.stubs.send_rpc_and_wait("es1_tasks", "taskStates.get")
if task_states[4] == 8:
raise RuntimeError(f"Task {4} finished in ERROR state")

View File

@@ -340,7 +340,7 @@ class AcquireRefs(Acquire):
# to set signals on a device
darks = AcquireDark(
exp_burst=self.num_darks,
file_prefix=self.file_prefix_dark,
# file_prefix=self.file_prefix_dark,
device_manager=self.device_manager,
metadata=self.metadata,
instruction_handler=self.stubs._instruction_handler,
@@ -389,6 +389,7 @@ class AcquireRefs(Acquire):
self.point_id = flats.point_id
## TODO move sample in beam and do not wait
## TODO move rotation to angle and do not wait
logger.warning("Done with scan_core")
class TutorialFlyScanContLine(AsyncFlyScanBase):

View File

@@ -16,6 +16,7 @@ class Measurement:
self.nimages_white = 100
self.start_angle = 0
self.angular_range = 180
self.sample_angle_out = 0
self.sample_position_in = 0
self.sample_position_out = 1
@@ -43,6 +44,10 @@ class Measurement:
self.exposure_period = self.det.cfgFramerate.get()
self.roix = self.det.cfgRoiX.get()
self.roiy = self.det.cfgRoiY.get()
self.get_position_rb()
#self.position_rb = False
#self.disable_position_rb_device()
def build_filename(self, acquisition_type='data'):
@@ -74,7 +79,8 @@ class Measurement:
exposure_period=None, roix=None, roiy=None,nimages=None,
nimages_dark=None, nimages_white=None,
start_angle=None, sample_angle_out=None,
sample_position_in=None, sample_position_out=None):
sample_position_in=None, sample_position_out=None,
position_rb=None):
"""
Reconfigure the measurement with any number of new parameter
@@ -110,6 +116,8 @@ class Measurement:
sample_position_out : float, optional
Sample stage X position for sample out of the beam [um]
(default = None)
position_rb : bool, optional
Enable position readback (default = None)
"""
if sample_name != None:
@@ -138,6 +146,13 @@ class Measurement:
self.sample_position_in = sample_position_in
if sample_position_out != None:
self.sample_position_out = sample_position_out
if position_rb != None:
if position_rb == True:
self.enable_position_rb_device()
elif position_rb == False:
self.disable_position_rb_device()
else:
print("WARNING! Position readback should be either True, False or None")
self.build_filename()
@@ -168,6 +183,12 @@ class Measurement:
self.device_name = self.enabled_detectors[0].name
self.build_filename()
def disable_position_rb_device(self):
"Disable position readback device"
dev.es1_ddaq.enabled= False
self.position_rb = False
def enable_detector(self, detector_name):
"""
@@ -197,7 +218,12 @@ class Measurement:
self.device_name = self.enabled_detectors[0].name
self.build_filename()
def enable_position_rb_device(self):
"Enable position readback device"
dev.es1_ddaq.enabled= True
self.position_rb = True
def get_available_detectors(self):
"""
@@ -215,6 +241,14 @@ class Measurement:
"""
self.enabled_detectors = [obj for obj in dev.get_devices_with_tags('camera') if obj.enabled]
def get_position_rb(self):
"""
Get position rb
"""
if dev.es1_ddaq.enabled == True:
self.position_rb = True
else:
self.position_rb = False
def show_available_detectors(self):
"""
@@ -242,39 +276,41 @@ class Measurement:
TODO: make it work for multiple devices
"""
print("Sample name: " + self.sample_name)
print("Data path: " + self.data_path)
print("Number of images: " + str(self.nimages))
print("Number of darks: " + str(self.nimages_dark))
print("Number of flats: " + str(self.nimages_white))
print("Sample name (sample_name): " + self.sample_name)
print("Data path (data_path): " + self.data_path)
print("Number of images (nimages): " + str(self.nimages))
print("Number of darks (nimages_dark): " + str(self.nimages_dark))
print("Number of flats (nimages_flat): " + str(self.nimages_white))
if self.exposure_time == None:
print("Exposure time: " + str(self.det.cfgExposure.get()))
print("Exposure time (exposure_time): " + str(self.det.cfgExposure.get()))
self.exposure_time = self.det.cfgExposure.get()
else:
print("Exposure time: " + str(self.exposure_time))
print("Exposure time (exposure_time): " + str(self.exposure_time))
if self.exposure_period == None:
print("Exposure period: " + str(self.det.cfgFramerate.get()))
print("Exposure period (exposure_period): " + str(self.det.cfgFramerate.get()))
self.exposure_period = self.det.cfgFramerate.get()
else:
print("Exposure period: " + str(self.exposure_period))
print("Exposure period (exposure_period): " + str(self.exposure_period))
if self.roix == None:
print("Roix: " + str(self.det.cfgRoiX.get()))
print("Roix (roix): " + str(self.det.cfgRoiX.get()))
self.roix = self.det.cfgRoiX.get()
else:
print("Roix: " + str(self.roix))
print("Roix (roix): " + str(self.roix))
if self.roiy == None:
print("Roiy: " + str(self.det.cfgRoiY.get()))
print("Roiy (roiy): " + str(self.det.cfgRoiY.get()))
self.roiy = self.det.cfgRoiY.get()
else:
print("Roiy: " + str(self.roiy))
print("Start angle: " + str(self.start_angle))
print("Sample angle out: " + str(self.sample_angle_out))
print("Sample position in: " + str(self.sample_position_in))
print("Sample position out: " + str(self.sample_position_out))
print("Roiy (roiy): " + str(self.roiy))
print("Start angle (start_angle): " + str(self.start_angle))
print("Angular range (angular_range): " + str(self.angular_range))
print("Sample angle out (sample_angle_out): " + str(self.sample_angle_out))
print("Sample position in (sample_position_in): " + str(self.sample_position_in))
print("Sample position out (sample_position_out): " + str(self.sample_position_out))
print("Position readback (position_rb): " + str(self.position_rb))
def acquire_darks(self,nimages_dark=None, exposure_time=None, exposure_period=None,
roix=None, roiy=None, acq_mode=None):
roix=None, roiy=None, acq_mode=None, **kwargs):
"""
Acquire a set of dark images with shutters closed.
@@ -315,17 +351,19 @@ class Measurement:
print("Handing over to 'scans.acquire_dark")
scans.acquire_dark(exp_burst=self.nimages_dark, exp_time=self.exposure_time, exp_period=self.exposure_period, image_width=self.roix,
image_height=self.roiy, acq_mode=acq_mode, file_path=self.file_path, nr_writers=2, base_path=self.base_path,
file_prefix=self.file_prefix)
file_prefix=self.file_prefix, ddc_trigger=4, ddc_source0=1, **kwargs)
def acquire_whites(self,nimages_white=None, sample_angle_out=None, sample_position_out=None,
def acquire_whites(self,motor="eyez", nimages_white=None, sample_angle_out=None, sample_position_out=None,
exposure_time=None, exposure_period=None,
roix=None, roiy=None, acq_mode=None):
roix=None, roiy=None, acq_mode=None, **kwargs):
"""
Acquire a set of whites images with shutters open and sample out of beam.
Parameters
----------
motor : DeviceBase
Motor to be moved to move the sample out of beam
nimages_whites : int, optional
Number of white images to acquire (no default)
sample_angle_out : float, optional
@@ -348,6 +386,7 @@ class Measurement:
m.acquire_whites(nimages_whites=100, exposure_time=5)
"""
self.motor_sample = motor
if nimages_white != None:
self.nimages_white = nimages_white
if sample_angle_out != None:
@@ -367,16 +406,76 @@ class Measurement:
### TODO: camera reset
print("Handing over to 'scans.acquire_whites")
scans.acquire_white(exp_burst=self.nimages_white, sample_angle_out=self.sample_angle_out, sample_position_out= self.sample_position_out,
scans.acquire_white(motor=self.motor_sample, exp_burst=self.nimages_white, sample_angle_out=self.sample_angle_out, sample_position_out= self.sample_position_out,
exp_time=self.exposure_time, exp_period=self.exposure_period, image_width=self.roix,
image_height=self.roiy, acq_mode=acq_mode, file_path=self.file_path, nr_writers=2, base_path=self.base_path,
file_prefix=self.file_prefix)
file_prefix=self.file_prefix, ddc_trigger=4, ddc_source0=1, **kwargs)
def acquire_projections(self, nimages=None, sample_position_in=None,
start_angle=None, angular_range=None,
exposure_time=None, exposure_period=None,
roix=None, roiy=None, acq_mode=None, **kwargs):
"""
Acquire a set of whites images with shutters open and sample out of beam.
def acquire_refs(self,nimages_dark=None, nimages_white=None, sample_angle_out=None,
Parameters
----------
nimages : int, optional
Number of projection images to acquire (no default)
sample_position_in : float, optional
Sample stage X position for sample in the beam [um]
start_angle : float, optional
Starting angular position [deg]
angular_range : float, optional
Angular range [deg]
exposure_time : float, optional
Exposure time [ms]. If not specified, the currently configured value on the camera will be used
exposure_period : float, optional
Exposure period [ms]
roix : int, optional
ROI size in the x-direction [pixels]
roiy : int, optional
ROI size in the y-direction [pixels]
acq_mode : str, optional
Predefined acquisition mode (default=None)
Example:
--------
m.acquire_projections(nimages_projections=100, exposure_time=5)
"""
if nimages != None:
self.nimages = nimages
if sample_position_in != None:
self.sample_position_in = sample_position_in
if start_angle != None:
self.start_angle = start_angle
if angular_range != None:
self.angular_range = angular_range
if exposure_time != None:
self.exposure_time = exposure_time
if exposure_period != None:
self.exposure_period = exposure_period
if roix != None:
self.roix = roix
if roiy != None:
self.roiy = roiy
self.build_filename(acquisition_type='data')
### TODO: camera reset
print("Handing over to 'scans.acquire_projections")
scans.acquire_projections(motor="es1_roty", exp_burst=self.nimages, sample_position_in= self.sample_position_in,
start_angle = self.start_angle, angular_range = self.angular_range,
exp_time=self.exposure_time, exp_period=self.exposure_period, image_width=self.roix,
image_height=self.roiy, acq_mode=acq_mode, file_path=self.file_path, nr_writers=2,
base_path=self.base_path,file_prefix=self.file_prefix, ddc_trigger=4, ddc_source0=1, **kwargs)
def acquire_refs(self, motor="eyez", nimages_dark=None, nimages_white=None, sample_angle_out=None,
sample_position_in=None, sample_position_out=None,
exposure_time=None, exposure_period=None,
roix=None, roiy=None, acq_mode=None):
roix=None, roiy=None, acq_mode=None, **kwargs):
"""
Acquire reference images (darks + whites) and return to beam position.
@@ -385,6 +484,8 @@ class Measurement:
Parameters
----------
motor : DeviceBase
Motor to be moved to move the sample out of beam
darks : int, optional
Number of dark images to acquire (no default)
nimages_whites : int, optional
@@ -435,10 +536,30 @@ class Measurement:
self.build_filename(acquisition_type='white')
file_prefix_white = self.file_prefix
print(file_prefix_dark)
print(file_prefix_white)
### TODO: camera reset
print("Handing over to 'scans.acquire_refs")
scans.acquire_refs(num_darks=self.nimages_dark, num_flats=self.nimages_white, sample_angle_out=self.sample_angle_out,
scans.acquire_refs(motor=motor, num_darks=self.nimages_dark, num_flats=self.nimages_white, sample_angle_out=self.sample_angle_out,
sample_position_in=self.sample_position_in, sample_position_out=self.sample_position_out,
exp_time=self.exposure_time, exp_period=self.exposure_period, image_width=self.roix,
image_height=self.roiy, acq_mode='default', file_path=self.file_path, nr_writers=2, base_path=self.base_path,
file_prefix_dark=file_prefix_dark, file_prefix_white=file_prefix_white)
file_prefix_dark=file_prefix_dark, file_prefix_white=file_prefix_white,
ddc_trigger=4, ddc_source0=1, **kwargs)
def start_preview(self, exposure_time=None, exposure_period=None,
preview_strategy='', preview_paramters=200, **kwargs):
"""
Start the camera in preview mode, no data will be written.
Parameters
----------
exposure_time : float, optional
"""
if exposure_time is None:
exposure_time = self.exposure_time
if exposure_period is None:
exposure_period = 50 # no need to go faster for a preview