diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 48076ba0f..a27a1ab0e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,6 +14,7 @@ Added: - HTTP REST Adapter & example device - Example Counter device +- Basic Eiger device without major logic - Unit tests covering: - EPICS Adapter @@ -30,6 +31,8 @@ Changed: - User may now reference the :code:`ComponentConfig`, which encapsulate a device and adapters - Device & Adapter config classes are no longer autmatically generated, configuration should be performed via the :code:`ComponentConfig` +- Made :code:`Device` a typed :code:`Generic` of :code:`InMap` and :code:`OutMap` + Deprecated: Removed: @@ -38,6 +41,7 @@ Fixed: - Cryostream flow rate threshold (from 900K > 90K) - Added dependency on Click to :code:`setup.cfg` +- Added missing :code:`__init__.py` to :code:`tickit.utils.compat` Security: diff --git a/Pipfile.lock b/Pipfile.lock index 3ca12e331..c4584a47f 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -90,6 +90,13 @@ ], "version": "==0.7.1" }, + "aiozmq": { + "hashes": [ + "sha256:85b677fe118f9470e85eb66e8209d1a71ab289538a57b5595d365d2e0b236a34", + "sha256:9d7315bb77e2655fc3a5051ea22e79527d62c42e51b2420c4f6f3dcebae65230" + ], + "version": "==0.9.0" + }, "apischema": { "hashes": [ "sha256:8e540344bd1073d858f27ae65d5158729ff594488528fe8f84049b4e7a82af87", @@ -306,6 +313,43 @@ ], "version": "==5.3.1" }, + "pyzmq": { + "hashes": [ + "sha256:00dca814469436455399660247d74045172955459c0bd49b54a540ce4d652185", + "sha256:046b92e860914e39612e84fa760fc3f16054d268c11e0e25dcb011fb1bc6a075", + "sha256:09d24a80ccb8cbda1af6ed8eb26b005b6743e58e9290566d2a6841f4e31fa8e0", + "sha256:0a422fc290d03958899743db091f8154958410fc76ce7ee0ceb66150f72c2c97", + "sha256:18189fc59ff5bf46b7ccf5a65c1963326dbfc85a2bc73e9f4a90a40322b992c8", + "sha256:276ad604bffd70992a386a84bea34883e696a6b22e7378053e5d3227321d9702", + "sha256:296540a065c8c21b26d63e3cea2d1d57902373b16e4256afe46422691903a438", + "sha256:29d51279060d0a70f551663bc592418bcad7f4be4eea7b324f6dd81de05cb4c1", + "sha256:36ab114021c0cab1a423fe6689355e8f813979f2c750968833b318c1fa10a0fd", + "sha256:3fa6debf4bf9412e59353defad1f8035a1e68b66095a94ead8f7a61ae90b2675", + "sha256:5120c64646e75f6db20cc16b9a94203926ead5d633de9feba4f137004241221d", + "sha256:59f1e54627483dcf61c663941d94c4af9bf4163aec334171686cdaee67974fe5", + "sha256:5d9fc809aa8d636e757e4ced2302569d6e60e9b9c26114a83f0d9d6519c40493", + "sha256:654d3e06a4edc566b416c10293064732516cf8871a4522e0a2ba00cc2a2e600c", + "sha256:720d2b6083498a9281eaee3f2927486e9fe02cd16d13a844f2e95217f243efea", + "sha256:73483a2caaa0264ac717af33d6fb3f143d8379e60a422730ee8d010526ce1913", + "sha256:8a6ada5a3f719bf46a04ba38595073df8d6b067316c011180102ba2a1925f5b5", + "sha256:8b66b94fe6243d2d1d89bca336b2424399aac57932858b9a30309803ffc28112", + "sha256:949a219493a861c263b75a16588eadeeeab08f372e25ff4a15a00f73dfe341f4", + "sha256:99cc0e339a731c6a34109e5c4072aaa06d8e32c0b93dc2c2d90345dd45fa196c", + "sha256:a7e7f930039ee0c4c26e4dfee015f20bd6919cd8b97c9cd7afbde2923a5167b6", + "sha256:ab0d01148d13854de716786ca73701012e07dff4dfbbd68c4e06d8888743526e", + "sha256:b1dd4cf4c5e09cbeef0aee83f3b8af1e9986c086a8927b261c042655607571e8", + "sha256:c1a31cd42905b405530e92bdb70a8a56f048c8a371728b8acf9d746ecd4482c0", + "sha256:c20dd60b9428f532bc59f2ef6d3b1029a28fc790d408af82f871a7db03e722ff", + "sha256:c36ffe1e5aa35a1af6a96640d723d0d211c5f48841735c2aa8d034204e87eb87", + "sha256:c40fbb2b9933369e994b837ee72193d6a4c35dfb9a7c573257ef7ff28961272c", + "sha256:c6d653bab76b3925c65d4ac2ddbdffe09710f3f41cc7f177299e8c4498adb04a", + "sha256:d46fb17f5693244de83e434648b3dbb4f4b0fec88415d6cbab1c1452b6f2ae17", + "sha256:e36f12f503511d72d9bdfae11cadbadca22ff632ff67c1b5459f69756a029c19", + "sha256:f1a25a61495b6f7bb986accc5b597a3541d9bd3ef0016f50be16dbb32025b302", + "sha256:fa411b1d8f371d3a49d31b0789eb6da2537dadbb2aef74a43aa99a78195c3f76" + ], + "version": "==19.0.2" + }, "setuptools-dso": { "hashes": [ "sha256:10f0e4eab7bf6789f76a5544f15a1be0f285413652b7d3d7ac11203ce72569d5", @@ -478,6 +522,13 @@ ], "version": "==0.7.1" }, + "aiozmq": { + "hashes": [ + "sha256:85b677fe118f9470e85eb66e8209d1a71ab289538a57b5595d365d2e0b236a34", + "sha256:9d7315bb77e2655fc3a5051ea22e79527d62c42e51b2420c4f6f3dcebae65230" + ], + "version": "==0.9.0" + }, "alabaster": { "hashes": [ "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359", @@ -1077,6 +1128,43 @@ ], "version": "==5.3.1" }, + "pyzmq": { + "hashes": [ + "sha256:00dca814469436455399660247d74045172955459c0bd49b54a540ce4d652185", + "sha256:046b92e860914e39612e84fa760fc3f16054d268c11e0e25dcb011fb1bc6a075", + "sha256:09d24a80ccb8cbda1af6ed8eb26b005b6743e58e9290566d2a6841f4e31fa8e0", + "sha256:0a422fc290d03958899743db091f8154958410fc76ce7ee0ceb66150f72c2c97", + "sha256:18189fc59ff5bf46b7ccf5a65c1963326dbfc85a2bc73e9f4a90a40322b992c8", + "sha256:276ad604bffd70992a386a84bea34883e696a6b22e7378053e5d3227321d9702", + "sha256:296540a065c8c21b26d63e3cea2d1d57902373b16e4256afe46422691903a438", + "sha256:29d51279060d0a70f551663bc592418bcad7f4be4eea7b324f6dd81de05cb4c1", + "sha256:36ab114021c0cab1a423fe6689355e8f813979f2c750968833b318c1fa10a0fd", + "sha256:3fa6debf4bf9412e59353defad1f8035a1e68b66095a94ead8f7a61ae90b2675", + "sha256:5120c64646e75f6db20cc16b9a94203926ead5d633de9feba4f137004241221d", + "sha256:59f1e54627483dcf61c663941d94c4af9bf4163aec334171686cdaee67974fe5", + "sha256:5d9fc809aa8d636e757e4ced2302569d6e60e9b9c26114a83f0d9d6519c40493", + "sha256:654d3e06a4edc566b416c10293064732516cf8871a4522e0a2ba00cc2a2e600c", + "sha256:720d2b6083498a9281eaee3f2927486e9fe02cd16d13a844f2e95217f243efea", + "sha256:73483a2caaa0264ac717af33d6fb3f143d8379e60a422730ee8d010526ce1913", + "sha256:8a6ada5a3f719bf46a04ba38595073df8d6b067316c011180102ba2a1925f5b5", + "sha256:8b66b94fe6243d2d1d89bca336b2424399aac57932858b9a30309803ffc28112", + "sha256:949a219493a861c263b75a16588eadeeeab08f372e25ff4a15a00f73dfe341f4", + "sha256:99cc0e339a731c6a34109e5c4072aaa06d8e32c0b93dc2c2d90345dd45fa196c", + "sha256:a7e7f930039ee0c4c26e4dfee015f20bd6919cd8b97c9cd7afbde2923a5167b6", + "sha256:ab0d01148d13854de716786ca73701012e07dff4dfbbd68c4e06d8888743526e", + "sha256:b1dd4cf4c5e09cbeef0aee83f3b8af1e9986c086a8927b261c042655607571e8", + "sha256:c1a31cd42905b405530e92bdb70a8a56f048c8a371728b8acf9d746ecd4482c0", + "sha256:c20dd60b9428f532bc59f2ef6d3b1029a28fc790d408af82f871a7db03e722ff", + "sha256:c36ffe1e5aa35a1af6a96640d723d0d211c5f48841735c2aa8d034204e87eb87", + "sha256:c40fbb2b9933369e994b837ee72193d6a4c35dfb9a7c573257ef7ff28961272c", + "sha256:c6d653bab76b3925c65d4ac2ddbdffe09710f3f41cc7f177299e8c4498adb04a", + "sha256:d46fb17f5693244de83e434648b3dbb4f4b0fec88415d6cbab1c1452b6f2ae17", + "sha256:e36f12f503511d72d9bdfae11cadbadca22ff632ff67c1b5459f69756a029c19", + "sha256:f1a25a61495b6f7bb986accc5b597a3541d9bd3ef0016f50be16dbb32025b302", + "sha256:fa411b1d8f371d3a49d31b0789eb6da2537dadbb2aef74a43aa99a78195c3f76" + ], + "version": "==19.0.2" + }, "regex": { "hashes": [ "sha256:04f6b9749e335bb0d2f68c707f23bb1773c3fb6ecd10edf0f04df12a8920d468", diff --git a/examples/configs/eiger-shutter.yaml b/examples/configs/eiger-shutter.yaml new file mode 100644 index 000000000..e8e7b620e --- /dev/null +++ b/examples/configs/eiger-shutter.yaml @@ -0,0 +1,14 @@ +- tickit.devices.source.Source: + name: source + inputs: {} + value: 42.0 +- examples.devices.shutter.Shutter: + name: shutter + inputs: + flux: source:value + default_position: 0.2 + initial_position: 0.24 +- tickit.devices.eiger.Eiger: + inputs: + flux: shutter:flux + name: eiger \ No newline at end of file diff --git a/examples/configs/eiger.yaml b/examples/configs/eiger.yaml new file mode 100644 index 000000000..bc6ab163c --- /dev/null +++ b/examples/configs/eiger.yaml @@ -0,0 +1,8 @@ +- tickit.devices.source.Source: + name: source + inputs: {} + value: 42.0 +- tickit.devices.eiger.Eiger: + inputs: + flux: source:value + name: eiger \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index 809301c5d..32965a73e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -19,9 +19,11 @@ packages = find: install_requires = aiohttp aiokafka + aiozmq==0.9.0 apischema==0.16.1 immutables pyyaml + pyzmq==19.0.2 softioc click diff --git a/tests/adapters/test_zmqadapter.py b/tests/adapters/test_zmqadapter.py new file mode 100644 index 000000000..0809ed0b8 --- /dev/null +++ b/tests/adapters/test_zmqadapter.py @@ -0,0 +1,143 @@ +import asyncio +import logging + +import aiozmq +import pytest +from mock import Mock +from mock.mock import AsyncMock, create_autospec + +from tickit.adapters.zmqadapter import ZeroMQAdapter +from tickit.core.device import Device + + +@pytest.fixture +def mock_device() -> Device: + return create_autospec(Device) + + +@pytest.fixture +def mock_raise_interrupt(): + async def raise_interrupt(): + return False + + return Mock(raise_interrupt) + + +@pytest.fixture +@pytest.mark.asyncio +async def mock_process_message_queue() -> AsyncMock: + async def _process_message_queue(): + return True + + return AsyncMock(_process_message_queue) + + +@pytest.fixture +def zeromq_adapter() -> ZeroMQAdapter: + zmq_adapter = ZeroMQAdapter() + zmq_adapter._dealer = AsyncMock() + zmq_adapter._router = AsyncMock() + zmq_adapter._message_queue = Mock(asyncio.Queue) + return zmq_adapter + + +def test_zeromq_adapter_constructor(): + ZeroMQAdapter() + + +@pytest.mark.asyncio +async def test_zeromq_adapter_start_stream(zeromq_adapter): + await zeromq_adapter.start_stream() + + assert isinstance(zeromq_adapter._router, aiozmq.stream.ZmqStream) + assert isinstance(zeromq_adapter._dealer, aiozmq.stream.ZmqStream) + + await zeromq_adapter.close_stream() + + +@pytest.mark.asyncio +async def test_zeromq_adapter_close_stream(zeromq_adapter): + await zeromq_adapter.start_stream() + + await zeromq_adapter.close_stream() + await asyncio.sleep(0.1) + + assert None is zeromq_adapter._router._transport + assert None is zeromq_adapter._dealer._transport + + +@pytest.mark.asyncio +async def test_zeromq_adapter_after_update(zeromq_adapter): + + zeromq_adapter.after_update() + + +@pytest.mark.asyncio +async def test_zeromq_adapter_send_message(zeromq_adapter): + + mock_message = AsyncMock() + + zeromq_adapter.send_message(mock_message) + task = asyncio.current_task() + asyncio.gather(task) + zeromq_adapter._message_queue.put.assert_called_once() + + +@pytest.mark.asyncio +async def test_zeromq_adapter_run_forever_method( + zeromq_adapter, + mock_device: Device, + mock_process_message_queue: AsyncMock, + mock_raise_interrupt: Mock, +): + + zeromq_adapter._process_message_queue = mock_process_message_queue + + await zeromq_adapter.run_forever(mock_device, mock_raise_interrupt) + + zeromq_adapter._process_message_queue.assert_called_once() + + +@pytest.mark.asyncio +async def test_zeromq_adapter_check_if_running(zeromq_adapter): + + assert zeromq_adapter.check_if_running() is False + + +@pytest.mark.asyncio +async def test_zeromq_adapter_process_message_queue(zeromq_adapter): + + zeromq_adapter._process_message = AsyncMock() + zeromq_adapter.check_if_running = Mock(return_value=False) + + await zeromq_adapter._process_message_queue() + + zeromq_adapter._process_message.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_zeromq_adapter_process_message(zeromq_adapter): + + mock_message = "test" + + zeromq_adapter._dealer.read.return_value = ("Data", "test") + zeromq_adapter._router.read.return_value = ("Data", "test") + + await zeromq_adapter._process_message(mock_message) + + zeromq_adapter._dealer.read.assert_awaited_once() + zeromq_adapter._router.read.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_zeromq_adapter_process_message_no_message(zeromq_adapter, caplog): + + mock_message = None + + zeromq_adapter._dealer.read.return_value = ("Data", None) + zeromq_adapter._router.read.return_value = ("Data", None) + + with caplog.at_level(logging.DEBUG): + await zeromq_adapter._process_message(mock_message) + + assert len(caplog.records) == 1 diff --git a/tests/devices/eiger/test_eiger.py b/tests/devices/eiger/test_eiger.py new file mode 100644 index 000000000..994a263d5 --- /dev/null +++ b/tests/devices/eiger/test_eiger.py @@ -0,0 +1,99 @@ +import pytest + +from tickit.devices.eiger.eiger import EigerDevice +from tickit.devices.eiger.eiger_status import State + + +@pytest.fixture +def eiger() -> EigerDevice: + return EigerDevice() + + +def test_eiger_constructor(): + EigerDevice() + + +@pytest.mark.asyncio +async def test_eiger_initialize(eiger: EigerDevice): + await eiger.initialize() + + assert State.IDLE.value == eiger.get_state()["value"] + + +@pytest.mark.asyncio +async def test_eiger_arm(eiger: EigerDevice): + await eiger.arm() + + assert State.READY.value == eiger.get_state()["value"] + + +@pytest.mark.asyncio +async def test_eiger_disarm(eiger: EigerDevice): + await eiger.disarm() + + assert State.IDLE.value == eiger.get_state()["value"] + + +@pytest.mark.asyncio +async def test_eiger_trigger_ints_and_ready(eiger: EigerDevice): + + eiger._set_state(State.READY) + eiger.settings.trigger_mode = "ints" + + message = await eiger.trigger() + + assert State.ACQUIRE.value == eiger.get_state()["value"] + assert "Aquiring Data from Eiger..." == message + + +@pytest.mark.asyncio +async def test_eiger_trigger_not_ints_and_ready(eiger: EigerDevice): + + eiger._set_state(State.READY) + + message = await eiger.trigger() + + assert State.READY.value == eiger.get_state()["value"] + assert ( + f"Ignoring trigger, state={eiger.status.state}," + f"trigger_mode={eiger.settings.trigger_mode}" == message + ) + + +@pytest.mark.asyncio +async def test_eiger_trigger_not_ints_and_not_ready(eiger: EigerDevice): + + eiger._set_state(State.IDLE) + + message = await eiger.trigger() + + assert State.READY.value != eiger.get_state()["value"] + assert ( + f"Ignoring trigger, state={eiger.status.state}," + f"trigger_mode={eiger.settings.trigger_mode}" == message + ) + + +@pytest.mark.asyncio +async def test_eiger_cancel(eiger: EigerDevice): + await eiger.cancel() + + assert State.READY.value == eiger.get_state()["value"] + + +@pytest.mark.asyncio +async def test_eiger_abort(eiger: EigerDevice): + await eiger.abort() + + assert State.IDLE.value == eiger.get_state()["value"] + + +def test_eiger_get_state(eiger: EigerDevice): + assert State.NA.value == eiger.get_state()["value"] + + +def test_eiger_set_state(eiger: EigerDevice): + + eiger._set_state(State.IDLE) + + assert State.IDLE.value == eiger.get_state()["value"] diff --git a/tests/devices/eiger/test_eiger_adapters.py b/tests/devices/eiger/test_eiger_adapters.py new file mode 100644 index 000000000..d5c4a1a70 --- /dev/null +++ b/tests/devices/eiger/test_eiger_adapters.py @@ -0,0 +1,156 @@ +import json + +import aiohttp +import pytest +from aiohttp import web +from mock import MagicMock, Mock +from mock.mock import create_autospec + +from tickit.devices.eiger.eiger import EigerDevice +from tickit.devices.eiger.eiger_adapters import EigerRESTAdapter +from tickit.devices.eiger.eiger_settings import EigerSettings +from tickit.devices.eiger.eiger_status import EigerStatus, State + + +@pytest.fixture +def mock_status() -> MagicMock: + status = create_autospec(EigerStatus, instance=True) + status.state = State.NA + return status + + +@pytest.fixture +def mock_settings() -> MagicMock: + settings = create_autospec(EigerSettings, instance=True) + settings.count_time = { + "value": 0.1, + "metadata": {"value_type": Mock(value="int"), "access_mode": Mock(value="rw")}, + } + return settings + + +@pytest.fixture +def mock_eiger(mock_status: MagicMock, mock_settings: MagicMock) -> MagicMock: + mock_eiger = create_autospec(EigerDevice, instance=True) + mock_eiger.status = mock_status + mock_eiger.settings = mock_settings + return mock_eiger + + +@pytest.fixture +def raise_interrupt(): + async def raise_interrupt(): + return False + + return Mock(raise_interrupt) + + +@pytest.fixture +def eiger_adapter(mock_eiger: MagicMock) -> EigerRESTAdapter: + return EigerRESTAdapter(mock_eiger, raise_interrupt) + + +def test_eiger_adapter_contructor(): + EigerRESTAdapter(mock_eiger, raise_interrupt) + + +@pytest.fixture() +def mock_request(): + mock_request = MagicMock(web.Request) + return mock_request + + +@pytest.mark.asyncio +@pytest.mark.parametrize("tickit_task", ["examples/configs/eiger.yaml"], indirect=True) +async def test_eiger_system(tickit_task): + + commands = { + "initialize": {"sequence_id": 1}, + "disarm": {"sequence_id": 3}, + "cancel": {"sequence_id": 5}, + "abort": {"sequence_id": 6}, + } + + url = "http://0.0.0.0:8081/detector/api/1.8.0/" + headers = {"content-type": "application/json"} + + filewriter_url = "http://0.0.0.0:8081/filewriter/api/1.8.0/" + monitor_url = "http://0.0.0.0:8081/monitor/api/1.8.0/" + stream_url = "http://0.0.0.0:8081/stream/api/1.8.0/" + + async def get_status(status, expected): + async with session.get(url + f"status/{status}") as resp: + assert expected == json.loads(str(await resp.text()))["value"] + + async with aiohttp.ClientSession() as session: + await get_status(status="state", expected="na") + + # Test setting config var before Eiger set up + data = '{"value": "test"}' + async with session.put( + url + "config/element", headers=headers, data=data + ) as resp: + assert json.loads(str(await resp.text())) == {"sequence_id": 7} + + # Test each command + for key, value in commands.items(): + async with session.put(url + f"command/{key}") as resp: + assert value == json.loads(str(await resp.text())) + + await get_status(status="doesnt_exist", expected="None") + + await get_status(status="board_000/th0_temp", expected=24.5) + + await get_status(status="board_000/doesnt_exist", expected="None") + + await get_status(status="builder/dcu_buffer_free", expected=0.5) + + await get_status(status="builder/doesnt_exist", expected="None") + + # Test Eiger in IDLE state + await get_status(status="state", expected="idle") + + async with session.get(url + "config/doesnt_exist") as resp: + assert json.loads(str(await resp.text()))["value"] == "None" + + data = '{"value": "test"}' + async with session.put( + url + "config/doesnt_exist", headers=headers, data=data + ) as resp: + assert json.loads(str(await resp.text())) == {"sequence_id": 9} + + async with session.get(url + "config/element") as resp: + assert json.loads(str(await resp.text()))["value"] == "Co" + + data = '{"value": "Li"}' + async with session.put( + url + "config/element", headers=headers, data=data + ) as resp: + assert json.loads(str(await resp.text())) == {"sequence_id": 8} + + async with session.get(url + "config/photon_energy") as resp: + assert json.loads(str(await resp.text()))["value"] == 54.3 + + async with session.get(filewriter_url + "config/mode") as resp: + assert "enabled" == json.loads(str(await resp.text()))["value"] + + async with session.get(filewriter_url + "status/state") as resp: + assert "ready" == json.loads(str(await resp.text()))["value"] + + async with session.get(monitor_url + "config/mode") as resp: + assert "enabled" == json.loads(str(await resp.text()))["value"] + + async with session.get(monitor_url + "status/error") as resp: + assert [] == json.loads(str(await resp.text()))["value"] + + async with session.get(stream_url + "config/mode") as resp: + assert "enabled" == json.loads(str(await resp.text()))["value"] + + async with session.get(stream_url + "status/state") as resp: + assert "ready" == json.loads(str(await resp.text()))["value"] + + async with session.put(url + "command/arm") as resp: + assert {"sequence_id": 2} == json.loads(str(await resp.text())) + + async with session.put(url + "command/trigger") as resp: + assert {"sequence_id": 4} == json.loads(str(await resp.text())) diff --git a/tests/devices/eiger/test_eiger_filewriter.py b/tests/devices/eiger/test_eiger_filewriter.py new file mode 100644 index 000000000..75fb5f182 --- /dev/null +++ b/tests/devices/eiger/test_eiger_filewriter.py @@ -0,0 +1,12 @@ +import pytest + +from tickit.devices.eiger.filewriter.eiger_filewriter import EigerFileWriter + + +@pytest.fixture +def filewriter() -> EigerFileWriter: + return EigerFileWriter() + + +def test_eiger_filewriter_constructor(): + EigerFileWriter() diff --git a/tests/devices/eiger/test_eiger_filewriter_config.py b/tests/devices/eiger/test_eiger_filewriter_config.py new file mode 100644 index 000000000..911fc22cc --- /dev/null +++ b/tests/devices/eiger/test_eiger_filewriter_config.py @@ -0,0 +1,18 @@ +import pytest + +from tickit.devices.eiger.filewriter.filewriter_config import FileWriterConfig + +# # # # # Eiger FileWriterConfig Tests # # # # # + + +@pytest.fixture +def filewriter_config() -> FileWriterConfig: + return FileWriterConfig() + + +def test_eiger_filewriter_config_constructor(): + FileWriterConfig() + + +def test_eiger_filewriter_config_getitem(filewriter_config): + assert "enabled" == filewriter_config["mode"]["value"] diff --git a/tests/devices/eiger/test_eiger_filewriter_status.py b/tests/devices/eiger/test_eiger_filewriter_status.py new file mode 100644 index 000000000..0429b5705 --- /dev/null +++ b/tests/devices/eiger/test_eiger_filewriter_status.py @@ -0,0 +1,18 @@ +import pytest + +from tickit.devices.eiger.filewriter.filewriter_status import FileWriterStatus + +# # # # # Eiger FileWriterStatus Tests # # # # # + + +@pytest.fixture +def filewriter_status() -> FileWriterStatus: + return FileWriterStatus() + + +def test_eiger_filewriter_status_constructor(): + FileWriterStatus() + + +def test_eiger_status_getitem(filewriter_status): + assert "ready" == filewriter_status["state"]["value"] diff --git a/tests/devices/eiger/test_eiger_monitor.py b/tests/devices/eiger/test_eiger_monitor.py new file mode 100644 index 000000000..6e1a49509 --- /dev/null +++ b/tests/devices/eiger/test_eiger_monitor.py @@ -0,0 +1,12 @@ +import pytest + +from tickit.devices.eiger.monitor.eiger_monitor import EigerMonitor + + +@pytest.fixture +def filewriter() -> EigerMonitor: + return EigerMonitor() + + +def test_eiger_monitor_constructor(): + EigerMonitor() diff --git a/tests/devices/eiger/test_eiger_monitor_config.py b/tests/devices/eiger/test_eiger_monitor_config.py new file mode 100644 index 000000000..013d4a3b5 --- /dev/null +++ b/tests/devices/eiger/test_eiger_monitor_config.py @@ -0,0 +1,18 @@ +import pytest + +from tickit.devices.eiger.monitor.monitor_config import MonitorConfig + +# # # # # Eiger MonitorConfig Tests # # # # # + + +@pytest.fixture +def monitor_config() -> MonitorConfig: + return MonitorConfig() + + +def test_eiger_monitor_config_constructor(): + MonitorConfig() + + +def test_eiger_monitor_config_getitem(monitor_config): + assert "enabled" == monitor_config["mode"]["value"] diff --git a/tests/devices/eiger/test_eiger_monitor_status.py b/tests/devices/eiger/test_eiger_monitor_status.py new file mode 100644 index 000000000..9642fc3e1 --- /dev/null +++ b/tests/devices/eiger/test_eiger_monitor_status.py @@ -0,0 +1,18 @@ +import pytest + +from tickit.devices.eiger.monitor.monitor_status import MonitorStatus + +# # # # # Eiger MonitorStatus Tests # # # # # + + +@pytest.fixture +def monitor_status() -> MonitorStatus: + return MonitorStatus() + + +def test_eiger_monitor_status_constructor(): + MonitorStatus() + + +def test_eiger_monitor_status_getitem(monitor_status): + assert [] == monitor_status["error"]["value"] diff --git a/tests/devices/eiger/test_eiger_settings.py b/tests/devices/eiger/test_eiger_settings.py new file mode 100644 index 000000000..aabe72756 --- /dev/null +++ b/tests/devices/eiger/test_eiger_settings.py @@ -0,0 +1,31 @@ +import pytest + +from tickit.devices.eiger.eiger_settings import EigerSettings, KA_Energy + +# # # # # EigerStatus Tests # # # # # + + +@pytest.fixture +def eiger_settings() -> EigerSettings: + return EigerSettings() + + +def test_eiger_settings_constructor(): + EigerSettings() + + +def test_eiger_settings_getitem(eiger_settings): + value = eiger_settings["count_time"]["value"] + + assert 0.1 == value + + +def test_eiger_settings_get_element(eiger_settings): + assert "Co" == eiger_settings.element + + +def test_eiger_settings_set_element(eiger_settings): + eiger_settings["element"] = "Li" + + assert "Li" == eiger_settings.element + assert KA_Energy["Li"].value == eiger_settings.photon_energy diff --git a/tests/devices/eiger/test_eiger_status.py b/tests/devices/eiger/test_eiger_status.py new file mode 100644 index 000000000..790d8ed36 --- /dev/null +++ b/tests/devices/eiger/test_eiger_status.py @@ -0,0 +1,18 @@ +import pytest + +from tickit.devices.eiger.eiger_status import EigerStatus + +# # # # # EigerStatus Tests # # # # # + + +@pytest.fixture +def eiger_status() -> EigerStatus: + return EigerStatus() + + +def test_eiger_status_constructor(): + EigerStatus() + + +def test_eiger_status_getitem(eiger_status): + assert 24.5 == eiger_status["th0_temp"] diff --git a/tests/devices/eiger/test_eiger_stream.py b/tests/devices/eiger/test_eiger_stream.py new file mode 100644 index 000000000..26eafc54c --- /dev/null +++ b/tests/devices/eiger/test_eiger_stream.py @@ -0,0 +1,12 @@ +import pytest + +from tickit.devices.eiger.stream.eiger_stream import EigerStream + + +@pytest.fixture +def filewriter() -> EigerStream: + return EigerStream() + + +def test_eiger_stream_constructor(): + EigerStream() diff --git a/tests/devices/eiger/test_eiger_stream_config.py b/tests/devices/eiger/test_eiger_stream_config.py new file mode 100644 index 000000000..4b6b79d69 --- /dev/null +++ b/tests/devices/eiger/test_eiger_stream_config.py @@ -0,0 +1,18 @@ +import pytest + +from tickit.devices.eiger.stream.stream_config import StreamConfig + +# # # # # Eiger StreamConfig Tests # # # # # + + +@pytest.fixture +def stream_config() -> StreamConfig: + return StreamConfig() + + +def test_eiger_stream_config_constructor(): + StreamConfig() + + +def test_eiger_stream_config_getitem(stream_config): + assert "enabled" == stream_config["mode"]["value"] diff --git a/tests/devices/eiger/test_eiger_stream_status.py b/tests/devices/eiger/test_eiger_stream_status.py new file mode 100644 index 000000000..e7789ef69 --- /dev/null +++ b/tests/devices/eiger/test_eiger_stream_status.py @@ -0,0 +1,18 @@ +import pytest + +from tickit.devices.eiger.stream.stream_status import StreamStatus + +# # # # # Eiger StreamStatus Tests # # # # # + + +@pytest.fixture +def stream_status() -> StreamStatus: + return StreamStatus() + + +def test_eiger_stream_status_constructor(): + StreamStatus() + + +def test_eiger_status_getitem(stream_status): + assert "ready" == stream_status["state"]["value"] diff --git a/tickit/adapters/httpadapter.py b/tickit/adapters/httpadapter.py index b8862d54a..03f254c57 100644 --- a/tickit/adapters/httpadapter.py +++ b/tickit/adapters/httpadapter.py @@ -1,4 +1,5 @@ import asyncio +import logging from dataclasses import dataclass from inspect import getmembers from typing import Iterable @@ -10,6 +11,8 @@ from tickit.core.adapter import Adapter, RaiseInterrupt from tickit.core.device import Device +LOGGER = logging.getLogger(__name__) + @dataclass class HTTPAdapter(Adapter): @@ -27,6 +30,7 @@ async def run_forever( ) -> None: """Runs the server continously.""" await super().run_forever(device, raise_interrupt) + LOGGER.debug(f"Starting HTTP server... {self}") app = web.Application() app.add_routes(list(self.endpoints())) runner = web.AppRunner(app) diff --git a/tickit/adapters/zmqadapter.py b/tickit/adapters/zmqadapter.py new file mode 100644 index 000000000..b1155d5b6 --- /dev/null +++ b/tickit/adapters/zmqadapter.py @@ -0,0 +1,83 @@ +import asyncio +import logging +from dataclasses import dataclass +from typing import Any + +import aiozmq +import zmq + +from tickit.core.adapter import Adapter, RaiseInterrupt +from tickit.core.device import Device + +LOGGER = logging.getLogger(__name__) + + +@dataclass +class ZeroMQAdapter(Adapter): + """An adapter for a ZeroMQ data stream.""" + + zmq_host: str = "127.0.0.1" + zmq_port: int = 5555 + running: bool = False + + async def start_stream(self) -> None: + """Start the ZeroMQ stream.""" + LOGGER.debug("Starting stream...") + self._router = await aiozmq.create_zmq_stream( + zmq.ROUTER, bind=f"tcp://{self.zmq_host}:{self.zmq_port}" + ) + + addr = list(self._router.transport.bindings())[0] + self._dealer = await aiozmq.create_zmq_stream(zmq.DEALER, connect=addr) + + async def close_stream(self) -> None: + """Close the ZeroMQ stream.""" + self._dealer.close() + self._router.close() + + self.running = False + + def send_message(self, message: Any) -> None: + """Send a message down the ZeroMQ stream. + + Sets up an asyncio task to put the message on the message queue, before + being processed. + + Args: + message (str): The message to send down the ZeroMQ stream. + """ + asyncio.create_task(self._message_queue.put(message)) + + async def run_forever( + self, device: Device, raise_interrupt: RaiseInterrupt + ) -> None: + """Runs the ZeroMQ adapter continuously.""" + await super().run_forever(device, raise_interrupt) + self._message_queue: asyncio.Queue = asyncio.Queue() + await self.start_stream() + self.running = True + await self._process_message_queue() + + def check_if_running(self): + """Returns the running state of the adapter.""" + return self.running + + async def _process_message_queue(self) -> None: + running = True + while running: + message = await self._message_queue.get() + await self._process_message(message) + running = self.check_if_running() + + async def _process_message(self, message: str) -> None: + if message is not None: + LOGGER.debug("Data from ZMQ stream: {!r}".format(message)) + + msg = (b"Data", str(message).encode("utf-8")) + self._dealer.write(msg) + data = await self._router.read() + self._router.write(data) + answer = await self._dealer.read() + LOGGER.debug("Received {!r}".format(answer)) + else: + LOGGER.debug("No message") diff --git a/tickit/core/device.py b/tickit/core/device.py index fc8778850..775eddf95 100644 --- a/tickit/core/device.py +++ b/tickit/core/device.py @@ -24,7 +24,7 @@ class DeviceUpdate(Generic[OutMap]): @as_tagged_union -class Device: +class Device(Generic[InMap, OutMap]): """An interface for types which implement simulated devices.""" def update(self, time: SimTime, inputs: InMap) -> DeviceUpdate[OutMap]: diff --git a/tickit/devices/eiger/__init__.py b/tickit/devices/eiger/__init__.py new file mode 100644 index 000000000..c0a63776e --- /dev/null +++ b/tickit/devices/eiger/__init__.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass + +from tickit.core.components.component import Component, ComponentConfig +from tickit.core.components.device_simulation import DeviceSimulation +from tickit.devices.eiger.eiger import EigerDevice +from tickit.devices.eiger.eiger_adapters import EigerRESTAdapter, EigerZMQAdapter + + +@dataclass +class Eiger(ComponentConfig): + """Eiger simulation with HTTP adapter.""" + + host: str = "0.0.0.0" + port: int = 8081 + zmq_host: str = "127.0.0.1" + zmq_port: int = 9999 + + def __call__(self) -> Component: # noqa: D102 + return DeviceSimulation( + name=self.name, + device=EigerDevice(), + adapters=[ + EigerRESTAdapter(host=self.host, port=self.port), + EigerZMQAdapter(zmq_host=self.zmq_host, zmq_port=self.zmq_port), + ], + ) diff --git a/tickit/devices/eiger/data/dummy_image.py b/tickit/devices/eiger/data/dummy_image.py new file mode 100644 index 000000000..52b5b43f2 --- /dev/null +++ b/tickit/devices/eiger/data/dummy_image.py @@ -0,0 +1,47 @@ +from dataclasses import dataclass +from typing import List + + +@dataclass +class Image: + """Dataclass to create a basic Image object.""" + + index: int + hash: str + dtype: str + data: bytes + encoding: str + + @classmethod + def create_dummy_image(cls, index: int) -> "Image": + """Returns an Image object wrapping the dummy blob using the metadata provided. + + Args: + index (int): The index of the Image in the current acquisition. + + Returns: + Image: An Image object wrapping the dummy blob. + """ + data = dummy_image_blob() + hsh = str(hash(data)) + dtype = "uint16" + encoding = "bs16-lz4<" + return Image(index, hsh, dtype, data, encoding) + + +_DUMMY_IMAGE_BLOBS: List[bytes] = [] + + +def dummy_image_blob() -> bytes: + """Returns the current dummy data blob. + + Return the raw bytes of a compressed image + taken from the stream of a real Eiger detector. + + Returns: + A compressed image as a bytes object. + """ + if not _DUMMY_IMAGE_BLOBS: + with open("tickit/devices/eiger/resources/frame_sample", "rb") as frame_file: + _DUMMY_IMAGE_BLOBS.append(frame_file.read()) + return _DUMMY_IMAGE_BLOBS[0] diff --git a/tickit/devices/eiger/eiger.py b/tickit/devices/eiger/eiger.py new file mode 100644 index 000000000..99df1e2bf --- /dev/null +++ b/tickit/devices/eiger/eiger.py @@ -0,0 +1,197 @@ +import logging +from dataclasses import fields + +from apischema import serialize +from typing_extensions import TypedDict + +from tickit.core.device import Device, DeviceUpdate +from tickit.core.typedefs import SimTime +from tickit.devices.eiger.data.dummy_image import Image +from tickit.devices.eiger.eiger_schema import AccessMode, Value +from tickit.devices.eiger.eiger_settings import EigerSettings +from tickit.devices.eiger.filewriter.filewriter_config import FileWriterConfig +from tickit.devices.eiger.filewriter.filewriter_status import FileWriterStatus +from tickit.devices.eiger.monitor.monitor_config import MonitorConfig +from tickit.devices.eiger.monitor.monitor_status import MonitorStatus +from tickit.devices.eiger.stream.stream_config import StreamConfig +from tickit.devices.eiger.stream.stream_status import StreamStatus + +from .eiger_status import EigerStatus, State + +LOGGER = logging.getLogger(__name__) + + +class EigerDevice(Device): + """A device class for the Eiger detector.""" + + settings: EigerSettings + status: EigerStatus + + #: An empty typed mapping of input values + Inputs: TypedDict = TypedDict("Inputs", {"flux": float}) + #: A typed mapping containing the 'value' output value + Outputs: TypedDict = TypedDict("Outputs", {}) + + def __init__( + self, + ) -> None: + """An Eiger device constructor. + + An Eiger device constructor which configures the default settings and various + states of the device. + """ + self.settings = EigerSettings() + self.status = EigerStatus() + + self.stream_status = StreamStatus() + self.stream_config = StreamConfig() + self.stream_callback_period = SimTime(int(1e9)) + + self.filewriter_status: FileWriterStatus = FileWriterStatus() + self.filewriter_config: FileWriterConfig = FileWriterConfig() + self.filewriter_callback_period = SimTime(int(1e9)) + + self.monitor_status: MonitorStatus = MonitorStatus() + self.monitor_config: MonitorConfig = MonitorConfig() + self.monitor_callback_period = SimTime(int(1e9)) + + async def initialize(self) -> None: + """Function to initialise the Eiger.""" + self._set_state(State.IDLE) + + async def arm(self) -> None: + """Function to arm the Eiger.""" + self._set_state(State.READY) + + header_detail = self.stream_config["header_detail"]["value"] + + json = { + "htype": "dheader-1.0", + "series": "", + "header_detail": header_detail, + } + if header_detail != "none": + config_json = {} + disallowed_configs = ["flatfield", "pixelmask" "countrate_correction_table"] + for field_ in fields(self.settings): + if field_.name not in disallowed_configs: + config_json[field_.name] = vars(self.settings)[field_.name] + + LOGGER.debug(json) + LOGGER.debug(config_json) + + async def disarm(self) -> None: + """Function to disarm the Eiger.""" + self._set_state(State.IDLE) + + json = {"htype": "dseries_end-1.0", "series": ""} + + LOGGER.debug(json) + + async def trigger(self) -> str: + """Function to trigger the Eiger. + + If the detector is in an external trigger mode, this is disabled as + this software command interface only works for internal triggers. + """ + trigger_mode = self.settings.trigger_mode + state = self.status.state + + if state == State.READY and trigger_mode == "ints": + self._set_state(State.ACQUIRE) + + for idx in range(0, self.settings.nimages): + + aquired = Image.create_dummy_image(idx) + + header_json = { + "htype": "dimage-1.0", + "series": "", + "frame": aquired.index, + "hash": aquired.hash, + } + + json2 = { + "htype": "dimage_d-1.0", + "shape": "[x,y,(z)]", + "type": aquired.dtype, + "encoding": aquired.encoding, + "size": len(aquired.data), + } + + json3 = { + "htype": "dconfig-1.0", + "start_time": "", + "stop_time": "", + "real_time": "", + } + + LOGGER.debug(header_json) + LOGGER.debug(json2) + LOGGER.debug(json3) + + return "Aquiring Data from Eiger..." + else: + return ( + f"Ignoring trigger, state={self.status.state}," + f"trigger_mode={trigger_mode}" + ) + + async def cancel(self) -> None: + """Function to stop the data acquisition. + + Function to stop the data acquisition, but only after the next + image is finished. + """ + self._set_state(State.READY) + + header_json = {"htype": "dseries_end-1.0", "series": ""} + + LOGGER.debug(header_json) + + async def abort(self) -> None: + """Function to abort the current task on the Eiger.""" + self._set_state(State.IDLE) + + header_json = {"htype": "dseries_end-1.0", "series": ""} + + LOGGER.debug(header_json) + + def update(self, time: SimTime, inputs: Inputs) -> DeviceUpdate[Outputs]: + """Generic update function to update the values of the ExampleHTTPDevice. + + Args: + time (SimTime): The simulation time in nanoseconds. + inputs (Inputs): A TypedDict of the inputs to the ExampleHTTPDevice. + + Returns: + DeviceUpdate[Outputs]: + The produced update event which contains the value of the device + variables. + """ + current_flux = inputs["flux"] + + intensity_scale = (current_flux / 100) * 100 + LOGGER.debug(f"Relative beam intensity: {intensity_scale}") + + return DeviceUpdate(self.Outputs(), None) + + def get_state(self) -> Value: + """Returns the current state of the Eiger. + + Returns: + State: The state of the Eiger. + """ + val = self.status.state + allowed = [s.value for s in State] + return serialize( + Value( + val, + AccessMode.STRING, + access_mode=AccessMode.READ_ONLY, + allowed_values=allowed, + ) + ) + + def _set_state(self, state: State): + self.status.state = state diff --git a/tickit/devices/eiger/eiger_adapters.py b/tickit/devices/eiger/eiger_adapters.py new file mode 100644 index 000000000..b26e55927 --- /dev/null +++ b/tickit/devices/eiger/eiger_adapters.py @@ -0,0 +1,264 @@ +import logging + +from aiohttp import web +from apischema import serialize + +from tickit.adapters.httpadapter import HTTPAdapter +from tickit.adapters.interpreters.endpoints.http_endpoint import HTTPEndpoint +from tickit.adapters.zmqadapter import ZeroMQAdapter +from tickit.devices.eiger.eiger import EigerDevice +from tickit.devices.eiger.eiger_schema import AccessMode, SequenceComplete, Value +from tickit.devices.eiger.eiger_status import State +from tickit.devices.eiger.filewriter.eiger_filewriter import EigerFileWriterAdapter +from tickit.devices.eiger.monitor.eiger_monitor import EigerMonitorAdapter +from tickit.devices.eiger.stream.eiger_stream import EigerStreamAdapter + +DETECTOR_API = "detector/api/1.8.0" + +LOGGER = logging.getLogger(__name__) + + +class EigerRESTAdapter( + HTTPAdapter, EigerStreamAdapter, EigerMonitorAdapter, EigerFileWriterAdapter +): + """An Eiger adapter which parses the commands sent to the HTTP server.""" + + device: EigerDevice + + @HTTPEndpoint.get(f"/{DETECTOR_API}" + "/config/{parameter_name}") + async def get_config(self, request: web.Request) -> web.Response: + """A HTTP Endpoint for requesting configuration variables from the Eiger. + + Args: + request (web.Request): The request object that takes the given parameter. + + Returns: + web.Response: The response object returned given the result of the HTTP + request. + """ + param = request.match_info["parameter_name"] + + if hasattr(self.device.settings, param): + attr = self.device.settings[param] + + data = serialize( + Value( + attr["value"], + attr["metadata"]["value_type"].value, + access_mode=( + attr["metadata"]["access_mode"].value + if hasattr(attr["metadata"], "access_mode") + else AccessMode.READ_ONLY.value + ), + ) + ) + else: + data = serialize(Value("None", "string", access_mode="None")) + + return web.json_response(data) + + @HTTPEndpoint.put( + f"/{DETECTOR_API}" + "/config/{parameter_name}", include_json=True + ) + async def put_config(self, request: web.Request) -> web.Response: + """A HTTP Endpoint for setting configuration variables for the Eiger. + + Args: + request (web.Request): The request object that takes the given parameter + and value. + + Returns: + web.Response: The response object returned given the result of the HTTP + request. + """ + param = request.match_info["parameter_name"] + + response = await request.json() + + if self.device.get_state()["value"] != State.IDLE.value: + LOGGER.warning("Eiger not initialized or is currently running.") + return web.json_response(serialize(SequenceComplete(7))) + elif ( + hasattr(self.device.settings, param) + and self.device.get_state()["value"] == State.IDLE.value + ): + attr = response["value"] + + LOGGER.debug(f"Changing to {attr} for {param}") + + self.device.settings[param] = attr + + LOGGER.debug("Set " + str(param) + " to " + str(attr)) + return web.json_response(serialize(SequenceComplete(8))) + else: + LOGGER.debug("Eiger has no config variable: " + str(param)) + return web.json_response(serialize(SequenceComplete(9))) + + @HTTPEndpoint.get(f"/{DETECTOR_API}" + "/status/{status_param}") + async def get_status(self, request: web.Request) -> web.Response: + """A HTTP Endpoint for requesting the status of the Eiger. + + Args: + request (web.Request): The request object that takes the request method. + + Returns: + web.Response: The response object returned given the result of the HTTP + request. + """ + param = request.match_info["status_param"] + + if hasattr(self.device.status, param): + attr = self.device.status[param] + else: + attr = "None" + + data = serialize({"value": attr}) + + return web.json_response(data) + + @HTTPEndpoint.get(f"/{DETECTOR_API}" + "/status/board_000/{status_param}") + async def get_board_000_status(self, request: web.Request) -> web.Response: + """A HTTP Endpoint for requesting the status of the Eiger. + + Args: + request (web.Request): The request object that takes the request method. + + Returns: + web.Response: The response object returned given the result of the HTTP + request. + """ + param = request.match_info["status_param"] + + if hasattr(self.device.status, param): + attr = self.device.status[param] + else: + attr = "None" + + data = serialize({"value": attr}) + + return web.json_response(data) + + @HTTPEndpoint.get(f"/{DETECTOR_API}" + "/status/builder/{status_param}") + async def get_builder_status(self, request: web.Request) -> web.Response: + """A HTTP Endpoint for requesting the status of the Eiger. + + Args: + request (web.Request): The request object that takes the request method. + + Returns: + web.Response: The response object returned given the result of the HTTP + request. + """ + param = request.match_info["status_param"] + + if hasattr(self.device.status, param): + attr = self.device.status[param] + else: + attr = "None" + + data = serialize({"value": attr}) + + return web.json_response(data) + + @HTTPEndpoint.put(f"/{DETECTOR_API}" + "/command/initialize") + async def initialize_eiger(self, request: web.Request) -> web.Response: + """A HTTP Endpoint for the 'initialize' command of the Eiger. + + Args: + request (web.Request): The request object that takes the request method. + + Returns: + web.Response: The response object returned given the result of the HTTP + request. + """ + await self.device.initialize() + + LOGGER.debug("Initializing Eiger...") + return web.json_response(serialize(SequenceComplete(1))) + + @HTTPEndpoint.put(f"/{DETECTOR_API}" + "/command/arm") + async def arm_eiger(self, request: web.Request) -> web.Response: + """A HTTP Endpoint for the 'arm' command of the Eiger. + + Args: + request (web.Request): The request object that takes the request method. + + Returns: + web.Response: The response object returned given the result of the HTTP + request. + """ + await self.device.arm() + + LOGGER.debug("Arming Eiger...") + return web.json_response(serialize(SequenceComplete(2))) + + @HTTPEndpoint.put(f"/{DETECTOR_API}" + "/command/disarm") + async def disarm_eiger(self, request: web.Request) -> web.Response: + """A HTTP Endpoint for the 'disarm' command of the Eiger. + + Args: + request (web.Request): The request object that takes the request method. + + Returns: + web.Response: The response object returned given the result of the HTTP + request. + """ + await self.device.disarm() + + LOGGER.debug("Disarming Eiger...") + return web.json_response(serialize(SequenceComplete(3))) + + @HTTPEndpoint.put(f"/{DETECTOR_API}" + "/command/trigger") + async def trigger_eiger(self, request: web.Request) -> web.Response: + """A HTTP Endpoint for the 'trigger' command of the Eiger. + + Args: + request (web.Request): The request object that takes the request method. + + Returns: + web.Response: The response object returned given the result of the HTTP + request. + """ + trigger_message = await self.device.trigger() + self.device._set_state(State.IDLE) + + LOGGER.debug(trigger_message) + return web.json_response(serialize(SequenceComplete(4))) + + @HTTPEndpoint.put(f"/{DETECTOR_API}" + "/command/cancel") + async def cancel_eiger(self, request: web.Request) -> web.Response: + """A HTTP Endpoint for the 'cancel' command of the Eiger. + + Args: + request (web.Request): The request object that takes the request method. + + Returns: + web.Response: The response object returned given the result of the HTTP + request. + """ + await self.device.cancel() + + LOGGER.debug("Cancelling Eiger...") + return web.json_response(serialize(SequenceComplete(5))) + + @HTTPEndpoint.put(f"/{DETECTOR_API}" + "/command/abort") + async def abort_eiger(self, request: web.Request) -> web.Response: + """A HTTP Endpoint for the 'abort' command of the Eiger. + + Args: + request (web.Request): The request object that takes the request method. + + Returns: + web.Response: The response object returned given the result of the HTTP + request. + """ + await self.device.abort() + + LOGGER.debug("Aborting Eiger...") + return web.json_response(serialize(SequenceComplete(6))) + + +class EigerZMQAdapter(ZeroMQAdapter): + """An Eiger adapter which parses the data to send along a ZeroMQStream.""" + + device: EigerDevice diff --git a/tickit/devices/eiger/eiger_schema.py b/tickit/devices/eiger/eiger_schema.py new file mode 100644 index 000000000..35c7b6f80 --- /dev/null +++ b/tickit/devices/eiger/eiger_schema.py @@ -0,0 +1,101 @@ +from dataclasses import dataclass, field +from enum import Enum +from functools import partial +from typing import Any, Generic, List, Mapping, Optional, TypeVar + +T = TypeVar("T") + + +def field_config(**kwargs) -> Mapping[str, Any]: + """Helper function to create a typesafe dictionary. + + Helper function to create a typesafe dictionary to be inserted as + dataclass metadata. + + Args: + kwargs: Key/value pairs to go into the metadata + + Returns: + Mapping[str, Any]: A dictionary of {key: value} where all keys are strings + """ + return dict(**kwargs) + + +class AccessMode(Enum): + """Possible access modes for field metadata.""" + + READ_ONLY: str = "r" + WRITE_ONLY: str = "w" + READ_WRITE: str = "rw" + FLOAT: str = "float" + INT: str = "int" + UINT: str = "uint" + STRING: str = "string" + LIST_STR: str = "string[]" + BOOL: str = "bool" + FLOAT_GRID: str = "float[][]" + UINT_GRID: str = "uint[][]" + DATE: str = "date" + NONE: str = "none" + + +# +# Shortcuts to creating dataclass field metadata +# +rw_float: partial = partial( + field_config, value_type=AccessMode.FLOAT, access_mode=AccessMode.READ_WRITE +) +ro_float: partial = partial( + field_config, value_type=AccessMode.FLOAT, access_mode=AccessMode.READ_ONLY +) +rw_int: partial = partial( + field_config, value_type=AccessMode.INT, access_mode=AccessMode.READ_WRITE +) +ro_int: partial = partial( + field_config, value_type=AccessMode.INT, access_mode=AccessMode.READ_ONLY +) +rw_uint: partial = partial( + field_config, value_type=AccessMode.UINT, access_mode=AccessMode.READ_WRITE +) +rw_str: partial = partial( + field_config, value_type=AccessMode.STRING, access_mode=AccessMode.READ_WRITE +) +ro_str: partial = partial( + field_config, value_type=AccessMode.STRING, access_mode=AccessMode.READ_ONLY +) +rw_bool: partial = partial( + field_config, value_type=AccessMode.BOOL, access_mode=AccessMode.READ_WRITE +) +rw_float_grid: partial = partial( + field_config, + value_type=AccessMode.FLOAT_GRID, + access_mode=AccessMode.READ_WRITE, +) +rw_uint_grid: partial = partial( + field_config, + value_type=AccessMode.UINT_GRID, + access_mode=AccessMode.READ_WRITE, +) +ro_date: partial = partial( + field_config, value_type=AccessMode.DATE, access_mode=AccessMode.READ_ONLY +) + + +@dataclass +class Value(Generic[T]): + """Schema for a value to be returned by the API. Most fields are optional.""" + + value: T + value_type: str + access_mode: Optional[str] = None + unit: Optional[str] = None + min: Optional[T] = None + max: Optional[T] = None + allowed_values: Optional[List[str]] = None + + +@dataclass +class SequenceComplete: + """Schema for confirmation returned by operations that do not return values.""" + + sequence_id: int = field(default=1, metadata=ro_int()) diff --git a/tickit/devices/eiger/eiger_settings.py b/tickit/devices/eiger/eiger_settings.py new file mode 100644 index 000000000..7e199917d --- /dev/null +++ b/tickit/devices/eiger/eiger_settings.py @@ -0,0 +1,135 @@ +from dataclasses import dataclass, field, fields +from enum import Enum +from typing import Any, List + +from .eiger_schema import ( + AccessMode, + field_config, + ro_float, + ro_str, + rw_bool, + rw_float, + rw_int, + rw_str, +) + +FRAME_WIDTH: int = 4148 +FRAME_HEIGHT: int = 4362 + + +class KA_Energy(Enum): + """Possible element K-alpha energies for samples.""" + + Li: float = 54.3 + Be: float = 108.5 + B: float = 183.3 + C: float = 277.0 + N: float = 392.4 + O: float = 524.9 + F: float = 676.8 + Ne: float = 848.6 + Na: float = 1040.98 + Mg: float = 1253.6 + Al: float = 1486.7 + Si: float = 1739.98 + P: float = 2013.7 + S: float = 2307.84 + Cl: float = 2622.39 + Ar: float = 2957.7 + K: float = 3313.8 + Ca: float = 3691.68 + Sc: float = 4090.6 + Ti: float = 4510.84 + V: float = 4952.2 + Cr: float = 5414.72 + Mn: float = 5898.75 + Fe: float = 6403.84 + Co: float = 6930.32 + Ni: float = 7478.15 + Cu: float = 8047.78 + Zn: float = 8638.86 + + +@dataclass +class EigerSettings: + """A data container for Eiger device configuration.""" + + auto_summation: bool = field(default=True, metadata=rw_bool()) + beam_center_x: float = field(default=0.0, metadata=rw_float()) + beam_center_y: float = field(default=0.0, metadata=rw_float()) + bit_depth_image: int = field(default=16, metadata=rw_int()) + bit_depth_readout: int = field(default=16, metadata=rw_int()) + chi_increment: float = field(default=0.0, metadata=rw_float()) + chi_start: float = field(default=0.0, metadata=rw_float()) + compression: str = field( + default="bslz4", metadata=rw_str(allowed_values=["bslz4", "lz4"]) + ) + count_time: float = field(default=0.1, metadata=rw_float()) + countrate_correction_applied: bool = field(default=True, metadata=rw_bool()) + countrate_correction_count_cutoff: int = field(default=1000, metadata=rw_int()) + data_collection_date: str = field( + default="2021-30-09T16:30:00.000-01:00", metadata=ro_str() + ) + description: str = field( + default="Simulated Eiger X 16M Detector", metadata=ro_str() + ) + detector_distance: float = field(default=2.0, metadata=rw_float()) + detector_number: str = field(default="EIGERSIM001", metadata=ro_str()) + detector_readout_time: float = field(default=0.01, metadata=rw_float()) + element: str = field( + default="Co", metadata=rw_str(allowed_values=[e.name for e in KA_Energy]) + ) + flatfield: List[List[float]] = field( + default_factory=lambda: [[]], + metadata=field_config(value_type=AccessMode.FLOAT_GRID), + ) + flatfield_correction_applied: bool = field(default=True, metadata=rw_bool()) + frame_time: float = field(default=0.12, metadata=rw_float()) + kappa_increment: float = field(default=0.0, metadata=rw_float()) + kappa_start: float = field(default=0.0, metadata=rw_float()) + nimages: int = field(default=1, metadata=rw_int()) + ntrigger: int = field(default=1, metadata=rw_int()) + number_of_excuded_pixels: int = field(default=0, metadata=rw_int()) + omega_increment: float = field(default=0.0, metadata=rw_float()) + omega_start: float = field(default=0.0, metadata=rw_float()) + phi_increment: float = field(default=0.0, metadata=rw_float()) + phi_start: float = field(default=0.0, metadata=rw_float()) + photon_energy: float = field(default=6930.32, metadata=rw_float()) + pixel_mask: List[List[int]] = field( + default_factory=lambda: [[]], + metadata=field_config(value_type=AccessMode.UINT_GRID), + ) + pixel_mask_applied: bool = field(default=False, metadata=rw_bool()) + roi_mode: str = field( + default="disabled", metadata=rw_str(allowed_values=["disabled", "4M"]) + ) + sensor_material: str = field(default="Silicon", metadata=ro_str()) + sensor_thickness: float = field(default=0.01, metadata=ro_float()) + software_version: str = field(default="0.1.0", metadata=ro_str()) + threshold_energy: float = field(default=4020.5, metadata=rw_float()) + trigger_mode: str = field( + default="exts", metadata=rw_str(allowed_values=["exts", "ints", "exte", "inte"]) + ) + two_theta_increment: float = field(default=0.0, metadata=rw_float()) + two_theta_start: float = field(default=0.0, metadata=rw_float()) + wavelength: float = field(default=1e-9, metadata=rw_float()) + x_pixel_size: float = field(default=0.01, metadata=ro_float()) + x_pixels_in_detector: int = field(default=FRAME_WIDTH, metadata=rw_int()) + y_pixel_size: float = field(default=0.01, metadata=ro_float()) + y_pixels_in_detector: int = field(default=FRAME_HEIGHT, metadata=rw_int()) + + def __getitem__(self, key: str) -> Any: # noqa: D105 + f = {} + for field_ in fields(self): + f[field_.name] = { + "value": vars(self)[field_.name], + "metadata": field_.metadata, + } + return f[key] + + def __setitem__(self, key: str, value: Any) -> None: # noqa: D105 + self.__dict__[key] = value + + if key == "element": + self.photon_energy = getattr(KA_Energy, value).value + self.threshold_energy = 0.5 * self.photon_energy diff --git a/tickit/devices/eiger/eiger_status.py b/tickit/devices/eiger/eiger_status.py new file mode 100644 index 000000000..8209ccbfb --- /dev/null +++ b/tickit/devices/eiger/eiger_status.py @@ -0,0 +1,35 @@ +from dataclasses import dataclass, field, fields +from datetime import datetime +from enum import Enum +from typing import Any, List + + +class State(Enum): + """Possible states of the Eiger detector.""" + + NA = "na" + READY = "ready" + INITIALIZE = "initialize" + CONFIGURE = "configure" + ACQUIRE = "acquire" + IDLE = "idle" + TEST = "test" + ERROR = "error" + + +@dataclass +class EigerStatus: + """Stores the status parameters of the Eiger detector.""" + + state: State = field(default=State.NA) + errors: List[str] = field(default_factory=list) + th0_temp: float = field(default=24.5) + th0_humidity: float = field(default=0.2) + time: datetime = field(default=datetime.now()) + dcu_buffer_free: float = field(default=0.5) + + def __getitem__(self, key: str) -> Any: # noqa: D105 + f = {} + for field_ in fields(self): + f[field_.name] = vars(self)[field_.name] + return f[key] diff --git a/tickit/devices/eiger/filewriter/eiger_filewriter.py b/tickit/devices/eiger/filewriter/eiger_filewriter.py new file mode 100644 index 000000000..9d757f118 --- /dev/null +++ b/tickit/devices/eiger/filewriter/eiger_filewriter.py @@ -0,0 +1,78 @@ +import logging + +from aiohttp import web +from apischema import serialize +from typing_extensions import TypedDict + +from tickit.adapters.interpreters.endpoints.http_endpoint import HTTPEndpoint +from tickit.core.typedefs import SimTime +from tickit.devices.eiger.eiger_schema import Value +from tickit.devices.eiger.filewriter.filewriter_config import FileWriterConfig +from tickit.devices.eiger.filewriter.filewriter_status import FileWriterStatus + +LOGGER = logging.getLogger(__name__) + +FILEWRITER_API = "filewriter/api/1.8.0" + + +class EigerFileWriter: + """Simulation of an Eiger FileWriter.""" + + #: An empty typed mapping of input values + Inputs: TypedDict = TypedDict("Inputs", {}) + #: A typed mapping containing the 'value' output value + Outputs: TypedDict = TypedDict("Outputs", {}) + + def __init__(self) -> None: + """An Eiger FileWriter constructor.""" + self.filewriter_status: FileWriterStatus = FileWriterStatus() + self.filewriter_config: FileWriterConfig = FileWriterConfig() + self.filewriter_callback_period = SimTime(int(1e9)) + + +class EigerFileWriterAdapter: + """An adapter for the FileWriter.""" + + device: EigerFileWriter + + @HTTPEndpoint.get(f"/{FILEWRITER_API}" + "/config/{param}") + async def get_filewriter_config(self, request: web.Request) -> web.Response: + """A HTTP Endpoint for requesting config values from the Filewriter. + + Args: + request (web.Request): The request object that takes the given parameter. + + Returns: + web.Response: The response object returned given the result of the HTTP + request. + """ + param = request.match_info["param"] + val = self.device.filewriter_config[param]["value"] + meta = self.device.filewriter_config[param]["metadata"] + + data = serialize( + Value(val, meta["value_type"].value, access_mode=meta["access_mode"].value) + ) + + return web.json_response(data) + + @HTTPEndpoint.get(f"/{FILEWRITER_API}" + "/status/{param}") + async def get_filewriter_status(self, request: web.Request) -> web.Response: + """A HTTP Endpoint for requesting status values from the Filewriter. + + Args: + request (web.Request): The request object that takes the given parameter. + + Returns: + web.Response: The response object returned given the result of the HTTP + request. + """ + param = request.match_info["param"] + val = self.device.filewriter_status[param]["value"] + meta = self.device.filewriter_status[param]["metadata"] + + data = serialize( + Value(val, meta["value_type"].value, access_mode=meta["access_mode"].value) + ) + + return web.json_response(data) diff --git a/tickit/devices/eiger/filewriter/filewriter_config.py b/tickit/devices/eiger/filewriter/filewriter_config.py new file mode 100644 index 000000000..38c3e5445 --- /dev/null +++ b/tickit/devices/eiger/filewriter/filewriter_config.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass, field, fields +from typing import Any + +from tickit.devices.eiger.eiger_schema import rw_bool, rw_int, rw_str + + +@dataclass +class FileWriterConfig: + """Eiger filewriter configuration taken from the API spec.""" + + mode: str = field( + default="enabled", metadata=rw_str(allowed_values=["enabled", "disabled"]) + ) + nimages_per_file: int = field(default=1, metadata=rw_int()) + image_nr_start: int = field(default=0, metadata=rw_int()) + name_pattern: str = field(default="test.h5", metadata=rw_str()) + compression_enabled: bool = field(default=False, metadata=rw_bool()) + + def __getitem__(self, key: str) -> Any: # noqa: D105 + f = {} + for field_ in fields(self): + f[field_.name] = { + "value": vars(self)[field_.name], + "metadata": field_.metadata, + } + return f[key] diff --git a/tickit/devices/eiger/filewriter/filewriter_status.py b/tickit/devices/eiger/filewriter/filewriter_status.py new file mode 100644 index 000000000..b29638fdb --- /dev/null +++ b/tickit/devices/eiger/filewriter/filewriter_status.py @@ -0,0 +1,32 @@ +from dataclasses import dataclass, field, fields +from typing import Any, List + +from tickit.devices.eiger.eiger_schema import AccessMode, ro_str + + +@dataclass +class FileWriterStatus: + """Eiger filewriter status taken from the API spec.""" + + state: str = field(default="ready", metadata=ro_str()) + error: List[str] = field( + default_factory=lambda: [], + metadata=dict( + value=[], value_type=AccessMode.LIST_STR, access_mode=AccessMode.READ_ONLY + ), + ) + files: List[str] = field( + default_factory=lambda: [], + metadata=dict( + value=[], value_type=AccessMode.LIST_STR, access_mode=AccessMode.READ_ONLY + ), + ) + + def __getitem__(self, key: str) -> Any: # noqa: D105 + f = {} + for field_ in fields(self): + f[field_.name] = { + "value": vars(self)[field_.name], + "metadata": field_.metadata, + } + return f[key] diff --git a/tickit/devices/eiger/monitor/eiger_monitor.py b/tickit/devices/eiger/monitor/eiger_monitor.py new file mode 100644 index 000000000..f40919225 --- /dev/null +++ b/tickit/devices/eiger/monitor/eiger_monitor.py @@ -0,0 +1,78 @@ +import logging + +from aiohttp import web +from apischema import serialize +from typing_extensions import TypedDict + +from tickit.adapters.interpreters.endpoints.http_endpoint import HTTPEndpoint +from tickit.core.typedefs import SimTime +from tickit.devices.eiger.eiger_schema import Value +from tickit.devices.eiger.monitor.monitor_config import MonitorConfig +from tickit.devices.eiger.monitor.monitor_status import MonitorStatus + +LOGGER = logging.getLogger(__name__) + +MONITOR_API = "monitor/api/1.8.0" + + +class EigerMonitor: + """Simulation of an Eiger Monitor.""" + + #: An empty typed mapping of input values + Inputs: TypedDict = TypedDict("Inputs", {}) + #: A typed mapping containing the 'value' output value + Outputs: TypedDict = TypedDict("Outputs", {}) + + def __init__(self) -> None: + """An Eiger Monitor constructor.""" + self.monitor_status: MonitorStatus = MonitorStatus() + self.monitor_config: MonitorConfig = MonitorConfig() + self.monitor_callback_period = SimTime(int(1e9)) + + +class EigerMonitorAdapter: + """An adapter for the Monitor.""" + + device: EigerMonitor + + @HTTPEndpoint.get(f"/{MONITOR_API}" + "/config/{param}") + async def get_monitor_config(self, request: web.Request) -> web.Response: + """A HTTP Endpoint for requesting config values from the Monitor. + + Args: + request (web.Request): The request object that takes the given parameter. + + Returns: + web.Response: The response object returned given the result of the HTTP + request. + """ + param = request.match_info["param"] + val = self.device.monitor_config[param]["value"] + meta = self.device.monitor_config[param]["metadata"] + + data = serialize( + Value(val, meta["value_type"].value, access_mode=meta["access_mode"].value) + ) + + return web.json_response(data) + + @HTTPEndpoint.get(f"/{MONITOR_API}" + "/status/{param}") + async def get_monitor_status(self, request: web.Request) -> web.Response: + """A HTTP Endpoint for requesting status values from the Monitor. + + Args: + request (web.Request): The request object that takes the given parameter. + + Returns: + web.Response: The response object returned given the result of the HTTP + request. + """ + param = request.match_info["param"] + val = self.device.monitor_status[param]["value"] + meta = self.device.monitor_status[param]["metadata"] + + data = serialize( + Value(val, meta["value_type"].value, access_mode=meta["access_mode"].value) + ) + + return web.json_response(data) diff --git a/tickit/devices/eiger/monitor/monitor_config.py b/tickit/devices/eiger/monitor/monitor_config.py new file mode 100644 index 000000000..99376fea6 --- /dev/null +++ b/tickit/devices/eiger/monitor/monitor_config.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass, field, fields +from typing import Any + +from tickit.devices.eiger.eiger_schema import rw_int, rw_str + + +@dataclass +class MonitorConfig: + """Eiger monitor configuration taken from the API spec.""" + + mode: str = field( + default="enabled", metadata=rw_str(allowed_values=["disabled", "enabled"]) + ) + buffer_size: int = field(default=512, metadata=rw_int()) + + def __getitem__(self, key: str) -> Any: # noqa: D105 + f = {} + for field_ in fields(self): + f[field_.name] = { + "value": vars(self)[field_.name], + "metadata": field_.metadata, + } + return f[key] diff --git a/tickit/devices/eiger/monitor/monitor_status.py b/tickit/devices/eiger/monitor/monitor_status.py new file mode 100644 index 000000000..f34a504af --- /dev/null +++ b/tickit/devices/eiger/monitor/monitor_status.py @@ -0,0 +1,25 @@ +from dataclasses import dataclass, field, fields +from typing import Any, List + +from tickit.devices.eiger.eiger_schema import AccessMode + + +@dataclass +class MonitorStatus: + """Eiger monitor status taken from the API spec.""" + + error: List[str] = field( + default_factory=lambda: [], + metadata=dict( + value=[], value_type=AccessMode.LIST_STR, access_mode=AccessMode.READ_ONLY + ), + ) + + def __getitem__(self, key: str) -> Any: # noqa: D105 + f = {} + for field_ in fields(self): + f[field_.name] = { + "value": vars(self)[field_.name], + "metadata": field_.metadata, + } + return f[key] diff --git a/tickit/devices/eiger/resources/frame_sample b/tickit/devices/eiger/resources/frame_sample new file mode 100644 index 000000000..89ffbcc41 Binary files /dev/null and b/tickit/devices/eiger/resources/frame_sample differ diff --git a/tickit/devices/eiger/stream/eiger_stream.py b/tickit/devices/eiger/stream/eiger_stream.py new file mode 100644 index 000000000..0589e41d9 --- /dev/null +++ b/tickit/devices/eiger/stream/eiger_stream.py @@ -0,0 +1,81 @@ +import logging + +from aiohttp import web +from apischema import serialize +from typing_extensions import TypedDict + +from tickit.adapters.interpreters.endpoints.http_endpoint import HTTPEndpoint +from tickit.core.typedefs import SimTime +from tickit.devices.eiger.eiger_schema import Value +from tickit.devices.eiger.stream.stream_config import StreamConfig +from tickit.devices.eiger.stream.stream_status import StreamStatus + +LOGGER = logging.getLogger(__name__) +STREAM_API = "stream/api/1.8.0" + + +class EigerStream: + """Simulation of an Eiger stream.""" + + stream_status: StreamStatus + stream_config: StreamConfig + stream_callback_period: SimTime + + #: An empty typed mapping of input values + Inputs: TypedDict = TypedDict("Inputs", {}) + #: A typed mapping containing the 'value' output value + Outputs: TypedDict = TypedDict("Outputs", {}) + + def __init__(self, callback_period: int = int(1e9)) -> None: + """An Eiger Stream constructor.""" + self.stream_status = StreamStatus() + self.stream_config = StreamConfig() + self.stream_callback_period = SimTime(callback_period) + + +class EigerStreamAdapter: + """An adapter for the Stream.""" + + device: EigerStream + + @HTTPEndpoint.get(f"/{STREAM_API}" + "/status/{param}") + async def get_stream_status(self, request: web.Request) -> web.Response: + """A HTTP Endpoint for requesting status values from the Stream. + + Args: + request (web.Request): The request object that takes the given parameter. + + Returns: + web.Response: The response object returned given the result of the HTTP + request. + """ + param = request.match_info["param"] + val = self.device.stream_status[param]["value"] + meta = self.device.stream_status[param]["metadata"] + + data = serialize( + Value(val, meta["value_type"].value, access_mode=meta["access_mode"].value) + ) + + return web.json_response(data) + + @HTTPEndpoint.get(f"/{STREAM_API}" + "/config/{param}") + async def get_stream_config(self, request: web.Request) -> web.Response: + """A HTTP Endpoint for requesting config values from the Stream. + + Args: + request (web.Request): The request object that takes the given parameter. + + Returns: + web.Response: The response object returned given the result of the HTTP + request. + """ + param = request.match_info["param"] + val = self.device.stream_config[param]["value"] + meta = self.device.stream_config[param]["metadata"] + + data = serialize( + Value(val, meta["value_type"].value, access_mode=meta["access_mode"].value) + ) + + return web.json_response(data) diff --git a/tickit/devices/eiger/stream/stream_config.py b/tickit/devices/eiger/stream/stream_config.py new file mode 100644 index 000000000..09932f436 --- /dev/null +++ b/tickit/devices/eiger/stream/stream_config.py @@ -0,0 +1,27 @@ +from dataclasses import dataclass, field, fields +from typing import Any + +from tickit.devices.eiger.eiger_schema import rw_str + + +@dataclass +class StreamConfig: + """Eiger stream configuration taken from the API spec.""" + + mode: str = field( + default="enabled", metadata=rw_str(allowed_values=["disabled", "enabled"]) + ) + header_detail: str = field( + default="basic", metadata=rw_str(allowed_values=["all", "basic", "none"]) + ) + header_appendix: str = field(default="", metadata=rw_str()) + image_appendix: str = field(default="", metadata=rw_str()) + + def __getitem__(self, key: str) -> Any: # noqa: D105 + f = {} + for field_ in fields(self): + f[field_.name] = { + "value": vars(self)[field_.name], + "metadata": field_.metadata, + } + return f[key] diff --git a/tickit/devices/eiger/stream/stream_status.py b/tickit/devices/eiger/stream/stream_status.py new file mode 100644 index 000000000..7fbc09692 --- /dev/null +++ b/tickit/devices/eiger/stream/stream_status.py @@ -0,0 +1,27 @@ +from dataclasses import dataclass, field, fields +from typing import Any, List + +from tickit.devices.eiger.eiger_schema import AccessMode, ro_int, ro_str + + +@dataclass +class StreamStatus: + """Eiger stream status taken from the API spec.""" + + state: str = field(default="ready", metadata=ro_str()) + error: List[str] = field( + default_factory=lambda: [], + metadata=dict( + value=[], value_type=AccessMode.LIST_STR, access_mode=AccessMode.READ_ONLY + ), + ) + dropped: int = field(default=0, metadata=ro_int()) + + def __getitem__(self, key: str) -> Any: # noqa: D105 + f = {} + for field_ in fields(self): + f[field_.name] = { + "value": vars(self)[field_.name], + "metadata": field_.metadata, + } + return f[key] diff --git a/tickit/utils/compat/__init__.py b/tickit/utils/compat/__init__.py new file mode 100644 index 000000000..e69de29bb