diff --git a/pyproject.toml b/pyproject.toml index 9e98fe4..750dfeb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,8 +12,8 @@ classifiers = [ description = "Eiger control system integration with FastCS" dependencies = [ "aiohttp", - "fastcs[epicsca]~=0.11.3", - "fastcs-odin @ git+https://github.com/DiamondLightSource/fastcs-odin.git@0.7.0", + "fastcs[epicsca]", + "fastcs-odin @ git+https://github.com/DiamondLightSource/fastcs-odin.git@0.8.0a1", "numpy", "pillow", "typer", diff --git a/run_acquisition.py b/run_acquisition.py index e99bf0b..6c86f9e 100644 --- a/run_acquisition.py +++ b/run_acquisition.py @@ -31,44 +31,29 @@ async def run_acquisition( print("Configuring") await asyncio.gather( - caput(f"{odin_prefix}:EF:BlockSize", 1), - caput_str(f"{odin_prefix}:EF:Acqid", file_name), - caput_str(f"{odin_prefix}:FP:FilePath", file_path), - caput_str(f"{odin_prefix}:FP:FilePrefix", file_name), - caput_str(f"{odin_prefix}:FP:AcquisitionId", file_name), - caput_str(f"{odin_prefix}:MW:Directory", file_path), - caput_str(f"{odin_prefix}:MW:FilePrefix", file_name), - caput_str(f"{odin_prefix}:MW:AcquisitionId", file_name), + caput(f"{odin_prefix}:BlockSize", 1), + caput_str(f"{odin_prefix}:FilePath", file_path), + caput_str(f"{odin_prefix}:AcquisitionId", file_name), caput(f"{odin_prefix}:FP:Frames", frames), - caput_str(f"{odin_prefix}:FP:DataCompression", "BSLZ4"), caput(f"{eiger_prefix}:Detector:Nimages", frames), caput(f"{eiger_prefix}:Detector:Ntrigger", 1), caput(f"{eiger_prefix}:Detector:FrameTime", exposure_time), # caput(f"{eiger_prefix}:Detector:TriggerMode", "ints"), # for real detector caput_str(f"{eiger_prefix}:Detector:TriggerMode", "ints"), # for tickit sim ) - await pv_equals(f"{eiger_prefix}:StaleParameters", 0) print("Arming") - await caput(f"{eiger_prefix}:Detector:Arm", True) - - datatype = f"uint{await aioca.caget(f'{eiger_prefix}:Detector:BitDepthImage')}" - await caput_str(f"{odin_prefix}:FP:DataDatatype", datatype) + await caput(f"{eiger_prefix}:ArmWhenReady", True) print("Starting writing") - await caput(f"{odin_prefix}:FP:StartWriting", True) - await asyncio.sleep(1) - await asyncio.gather( - pv_equals(f"{odin_prefix}:FP:Writing", 1, timeout=5), - pv_equals(f"{odin_prefix}:EF:Ready", 1, timeout=5), - ) + await caput(f"{eiger_prefix}:StartWriting", True) print("Triggering") await caput(f"{eiger_prefix}:Detector:Trigger", True, wait=False) print("Waiting") await pv_equals( - f"{odin_prefix}:FP:Writing", + f"{odin_prefix}:Writing", 0, timeout=exposure_time * frames * 5, # tickit sim is much slower than requested ) diff --git a/src/fastcs_eiger/__main__.py b/src/fastcs_eiger/__main__.py index f691054..e2dc962 100644 --- a/src/fastcs_eiger/__main__.py +++ b/src/fastcs_eiger/__main__.py @@ -1,10 +1,11 @@ from pathlib import Path from typing import Optional +import softioc.pvlog # noqa: F401 import typer from fastcs.connections import IPConnectionSettings from fastcs.launch import FastCS -from fastcs.logging import LogLevel, configure_logging +from fastcs.logging import LogLevel, configure_logging, intercept_std_logger from fastcs.transports.epics import EpicsGUIOptions, EpicsIOCOptions from fastcs.transports.epics.ca.transport import EpicsCATransport @@ -56,6 +57,7 @@ def ioc( ui_path = OPI_PATH if OPI_PATH.is_dir() else Path.cwd() / "opi" configure_logging(log_level) + intercept_std_logger("root") if odin_ip is None: controller = EigerController( diff --git a/src/fastcs_eiger/controllers/eiger_controller.py b/src/fastcs_eiger/controllers/eiger_controller.py index ca22d9b..0f4310c 100644 --- a/src/fastcs_eiger/controllers/eiger_controller.py +++ b/src/fastcs_eiger/controllers/eiger_controller.py @@ -1,12 +1,12 @@ import asyncio from collections.abc import Coroutine -from fastcs.attributes import AttrR +from fastcs.attributes import AttrR, AttrRW from fastcs.connections import IPConnectionSettings from fastcs.controllers import Controller -from fastcs.datatypes import Bool +from fastcs.datatypes import Bool, Int from fastcs.logging import bind_logger -from fastcs.methods import scan +from fastcs.methods import command, scan from fastcs_eiger.controllers.eiger_detector_controller import EigerDetectorController from fastcs_eiger.controllers.eiger_monitor_controller import EigerMonitorController @@ -15,6 +15,8 @@ from fastcs_eiger.eiger_parameter import EIGER_PARAMETER_SUBSYSTEMS, EigerAPIVersion from fastcs_eiger.http_connection import HTTPConnection, HTTPRequestError +COMMAND_GROUP = "Command" + class EigerController(Controller): """Root controller for Eiger detectors @@ -24,8 +26,16 @@ class EigerController(Controller): port: Port of Eiger detector """ - # Internal Attribute + detector: EigerDetectorController + + # Internal Attributes stale_parameters = AttrR(Bool()) + arm_timeout = AttrRW( + Int(min=1), + initial_value=3, + description="Timeout for arm command", + group=COMMAND_GROUP, + ) def __init__( self, connection_settings: IPConnectionSettings, api_version: EigerAPIVersion @@ -82,8 +92,7 @@ async def initialise(self) -> None: raise NotImplementedError( f"No subcontroller implemented for subsystem {subsystem}" ) - - self.add_sub_controller(subsystem.capitalize(), controller) + self.add_sub_controller(subsystem, controller) await controller.initialise() except HTTPRequestError: @@ -120,3 +129,19 @@ async def queue_subsystem_update(self, coros: list[Coroutine]): async with self._parameter_update_lock: for coro in coros: await self.queue.put(coro) + + @command(group=COMMAND_GROUP) + async def arm_when_ready(self): + """Arm detector and return when ready to send triggers + + Wait for parmeters to be synchronised before arming detector + + Raises: + TimeoutError: If parameters are not synchronised or arm PUT request fails + + """ + await self.stale_parameters.wait_for_value( + False, timeout=self.arm_timeout.get() + ) + + await self.detector.arm() diff --git a/src/fastcs_eiger/controllers/eiger_detector_controller.py b/src/fastcs_eiger/controllers/eiger_detector_controller.py index f7b6a43..bf0cbdc 100644 --- a/src/fastcs_eiger/controllers/eiger_detector_controller.py +++ b/src/fastcs_eiger/controllers/eiger_detector_controller.py @@ -21,7 +21,10 @@ class EigerDetectorController(EigerSubsystemController): # Internal attribute to control triggers in `inte` mode trigger_exposure = AttrRW(Float()) - # Introspected attribute needed for trigger logic + + # Introspected attributes needed for internal logic + bit_depth_image: AttrR[int] + compression: AttrRW[str] trigger_mode: AttrR[str] @detector_command diff --git a/src/fastcs_eiger/controllers/odin/eiger_fan.py b/src/fastcs_eiger/controllers/odin/eiger_fan.py index 321c76a..9992e9b 100644 --- a/src/fastcs_eiger/controllers/odin/eiger_fan.py +++ b/src/fastcs_eiger/controllers/odin/eiger_fan.py @@ -1,4 +1,4 @@ -from fastcs.attributes import AttrR +from fastcs.attributes import AttrR, AttrRW from fastcs.datatypes import Bool from fastcs_odin.controllers import OdinSubController from fastcs_odin.io import StatusSummaryAttributeIORef @@ -9,6 +9,9 @@ class EigerFanAdapterController(OdinSubController): """Controller for an EigerFan adapter in an odin control server""" state: AttrR[str] + acqid: AttrRW[str] + block_size: AttrRW[int] + ready: AttrR[bool] async def initialise(self): for parameter in self.parameters: @@ -22,7 +25,7 @@ async def initialise(self): ) # Manually validate `state` to get a nicer error message if not introspected - self._validate_hinted_attributes() + self._validate_hinted_attribute("state") self.ready = AttrR( Bool(), diff --git a/src/fastcs_eiger/controllers/odin/eiger_fp_adapter_controller.py b/src/fastcs_eiger/controllers/odin/eiger_fp_adapter_controller.py new file mode 100644 index 0000000..0cb6549 --- /dev/null +++ b/src/fastcs_eiger/controllers/odin/eiger_fp_adapter_controller.py @@ -0,0 +1,9 @@ +from fastcs.attributes import AttrRW +from fastcs_odin.controllers.odin_data.frame_processor import ( + FrameProcessorAdapterController, +) + + +class EigerFrameProcessorAdapterController(FrameProcessorAdapterController): + data_compression: AttrRW[str] + data_datatype: AttrRW[str] diff --git a/src/fastcs_eiger/controllers/odin/eiger_odin_controller.py b/src/fastcs_eiger/controllers/odin/eiger_odin_controller.py index a24e95c..39c4ba4 100644 --- a/src/fastcs_eiger/controllers/odin/eiger_odin_controller.py +++ b/src/fastcs_eiger/controllers/odin/eiger_odin_controller.py @@ -1,8 +1,11 @@ import asyncio +from fastcs.attributes import AttrRW from fastcs.connections import IPConnectionSettings +from fastcs.datatypes import Int +from fastcs.methods import command -from fastcs_eiger.controllers.eiger_controller import EigerController +from fastcs_eiger.controllers.eiger_controller import COMMAND_GROUP, EigerController from fastcs_eiger.controllers.odin.odin_controller import OdinController from fastcs_eiger.eiger_parameter import EigerAPIVersion @@ -10,6 +13,13 @@ class EigerOdinController(EigerController): """Eiger controller with Odin sub controller""" + start_writing_timeout = AttrRW( + Int(min=1), + initial_value=5, + description="Timeout for start writing command", + group=COMMAND_GROUP, + ) + def __init__( self, detector_connection_settings: IPConnectionSettings, @@ -24,3 +34,40 @@ async def initialise(self) -> None: """Initialise eiger controller and odin controller""" await asyncio.gather(super().initialise(), self.OD.initialise()) + + @command(group=COMMAND_GROUP) + async def arm_when_ready(self): + """Check eiger fan is ready before reporting arm as successful + + Raises: + TimeoutError: If eiger fan is not ready + + """ + await super().arm_when_ready() + + try: + await self.OD.EF.ready.wait_for_value(True, timeout=self.arm_timeout.get()) + except TimeoutError as e: + raise TimeoutError("Eiger fan not ready") from e + + @command(group=COMMAND_GROUP) + async def start_writing(self): + """Sync eiger parameters to file writers, start writing and return when ready + + Raises: + TimeoutError: If file writers fail to start + + """ + await asyncio.gather( + self.OD.FP.data_compression.put(self.detector.compression.get().upper()), + self.OD.FP.data_datatype.put(f"uint{self.detector.bit_depth_image.get()}"), + ) + + await self.OD.FP.start_writing() + + try: + await self.OD.writing.wait_for_value( + True, timeout=self.start_writing_timeout.get() + ) + except TimeoutError as e: + raise TimeoutError("File writers failed to start") from e diff --git a/src/fastcs_eiger/controllers/odin/odin_controller.py b/src/fastcs_eiger/controllers/odin/odin_controller.py index 3c5288f..ae320d9 100644 --- a/src/fastcs_eiger/controllers/odin/odin_controller.py +++ b/src/fastcs_eiger/controllers/odin/odin_controller.py @@ -1,21 +1,59 @@ -from fastcs.attributes import AttrR +from fastcs.attributes import AttrR, AttrRW from fastcs.controllers import BaseController -from fastcs.datatypes import Bool +from fastcs.datatypes import Bool, Int, String from fastcs_odin.controllers import OdinController as _OdinController +from fastcs_odin.controllers.odin_data.meta_writer import MetaWriterAdapterController from fastcs_odin.http_connection import HTTPConnection from fastcs_odin.io import StatusSummaryAttributeIORef +from fastcs_odin.io.config_fan_sender_attribute_io import ConfigFanAttributeIORef from fastcs_odin.util import OdinParameter from fastcs_eiger.controllers.odin.eiger_fan import EigerFanAdapterController +from fastcs_eiger.controllers.odin.eiger_fp_adapter_controller import ( + EigerFrameProcessorAdapterController, +) class OdinController(_OdinController): """Eiger-specific Odin controller""" - writing: AttrR = AttrR( + FP: EigerFrameProcessorAdapterController + EF: EigerFanAdapterController + MW: MetaWriterAdapterController + + writing = AttrR( Bool(), io_ref=StatusSummaryAttributeIORef([("MW", "FP")], "writing", any) ) + async def initialise(self): + await super().initialise() + + self.file_path = AttrRW( + String(), + io_ref=ConfigFanAttributeIORef([self.FP.file_path, self.MW.directory]), + ) + self.file_prefix = AttrRW( + String(), + io_ref=ConfigFanAttributeIORef([self.FP.file_prefix, self.MW.file_prefix]), + ) + self.acquisition_id = AttrRW( + String(), + io_ref=ConfigFanAttributeIORef( + [ + self.file_prefix, + self.FP.acquisition_id, + self.MW.acquisition_id, + self.EF.acqid, + ] + ), + ) + self.block_size = AttrRW( + Int(), + io_ref=ConfigFanAttributeIORef( + [self.FP.process_frames_per_block, self.EF.block_size] + ), + ) + def _create_adapter_controller( self, connection: HTTPConnection, @@ -26,6 +64,10 @@ def _create_adapter_controller( """Create Eiger-specific adapter controllers.""" match module: + case "FrameProcessorAdapter": + return EigerFrameProcessorAdapterController( + connection, parameters, f"{self.API_PREFIX}/{adapter}", self._ios + ) case "EigerFanAdapter": return EigerFanAdapterController( connection, parameters, f"{self.API_PREFIX}/{adapter}", self._ios diff --git a/tests/system/test_eiger_introspection.py b/tests/system/test_eiger_introspection.py index 321883d..04c68c5 100644 --- a/tests/system/test_eiger_introspection.py +++ b/tests/system/test_eiger_introspection.py @@ -103,7 +103,7 @@ async def test_controller_groups_and_parameters(sim_eiger): await controller.initialise() for subsystem in MISSING_KEYS: - subcontroller = controller.sub_controllers[subsystem.title()] + subcontroller = controller.sub_controllers[subsystem] assert isinstance(subcontroller, EigerSubsystemController) parameters = await subcontroller._introspect_detector_subsystem() if subsystem == "detector": @@ -135,7 +135,7 @@ async def test_threshold_mode_api_inconsistency_handled( ) await controller.initialise() - detector_controller = controller.sub_controllers["Detector"] + detector_controller = controller.sub_controllers["detector"] assert isinstance(detector_controller, EigerDetectorController) attr: AttrRW = detector_controller.attributes["threshold_1_energy"] # type: ignore @@ -168,7 +168,7 @@ async def test_fetch_before_returning_parameters(sim_eiger, mocker: MockerFixtur ) await controller.initialise() - detector_controller = controller.sub_controllers["Detector"] + detector_controller = controller.sub_controllers["detector"] assert isinstance(detector_controller, EigerDetectorController) count_time_attr: AttrRW[float, EigerParameterRef] = ( @@ -222,7 +222,7 @@ async def test_stale_propagates_to_top_controller( ) await controller.initialise() - detector_controller = controller.sub_controllers["Detector"] + detector_controller = controller.sub_controllers["detector"] assert isinstance(detector_controller, EigerDetectorController) await detector_controller.queue_update(["threshold_energy"]) assert controller.stale_parameters.get() is True @@ -283,7 +283,7 @@ async def test_eiger_controller_trigger_correctly_introspected( ) await controller.initialise() - detector_controller = controller.sub_controllers["Detector"] + detector_controller = controller.sub_controllers["detector"] assert isinstance(detector_controller, EigerDetectorController) detector_controller.connection = mocker.AsyncMock() @@ -353,7 +353,7 @@ async def test_if_min_value_provided_then_prec_set_correctly( ): await eiger_controller.initialise() - test_float_attr = eiger_controller.sub_controllers["Detector"].attributes.get( + test_float_attr = eiger_controller.sub_controllers["detector"].attributes.get( "test_float_attr" ) diff --git a/tests/test_eiger_controller.py b/tests/test_eiger_controller.py index 159be39..65d13c0 100644 --- a/tests/test_eiger_controller.py +++ b/tests/test_eiger_controller.py @@ -20,9 +20,9 @@ async def test_eiger_controller_creates_subcontrollers(mock_connection): await eiger_controller.initialise() assert list(eiger_controller.sub_controllers.keys()) == [ - "Detector", - "Stream", - "Monitor", + "detector", + "stream", + "monitor", ] connection.get.assert_any_call("detector/api/1.8.0/status/state") connection.get.assert_any_call("detector/api/1.8.0/status/keys") @@ -101,6 +101,26 @@ async def test_eiger_io_send( io.queue_update.assert_awaited_with(["no_updated_params"]) +@pytest.mark.asyncio +async def test_arm_when_ready(mock_connection, mocker: MockerFixture): + eiger_controller, _ = mock_connection + wait_mock = mocker.patch.object(eiger_controller.stale_parameters, "wait_for_value") + detector_mock = mocker.AsyncMock() + eiger_controller.detector = detector_mock + + wait_mock.side_effect = TimeoutError("Stale") + + with pytest.raises(TimeoutError, match="Stale"): + await eiger_controller.arm_when_ready() + + wait_mock.assert_called_once() + detector_mock.arm.assert_not_called() + + wait_mock.side_effect = None + await eiger_controller.arm_when_ready() + detector_mock.arm.assert_called_once() + + @pytest.mark.asyncio async def test_eiger_accepts_different_api_versions(): diff --git a/tests/test_eiger_fan_controller.py b/tests/test_eiger_fan_controller.py index 0c6c870..2d25d61 100644 --- a/tests/test_eiger_fan_controller.py +++ b/tests/test_eiger_fan_controller.py @@ -44,6 +44,7 @@ async def test_ef_ready(mocker: MockerFixture): "prefix", [StatusSummaryAttributeIO(), ParameterTreeAttributeIO(mock_connection)], ) + mocker.patch.object(eiger_fan, "_validate_type_hints") await eiger_fan.initialise() eiger_fan.post_initialise() diff --git a/tests/test_eiger_odin_controller.py b/tests/test_eiger_odin_controller.py index d18c581..ad0ef43 100644 --- a/tests/test_eiger_odin_controller.py +++ b/tests/test_eiger_odin_controller.py @@ -2,18 +2,23 @@ from fastcs.connections import IPConnectionSettings from pytest_mock import MockerFixture +from fastcs_eiger.controllers.eiger_controller import EigerController from fastcs_eiger.controllers.odin.eiger_odin_controller import EigerOdinController from fastcs_eiger.controllers.odin.odin_controller import OdinController -@pytest.mark.asyncio -async def test_eiger_odin_controller(mocker: MockerFixture): +@pytest.fixture +def eiger_odin_controller(): detector_connection_settings = IPConnectionSettings("127.0.0.1", 8000) odin_connection_settings = IPConnectionSettings("127.0.0.1", 8001) - - controller = EigerOdinController( + return EigerOdinController( detector_connection_settings, odin_connection_settings, api_version="1.8.0" ) + + +@pytest.mark.asyncio +async def test_eiger_odin_controller(eiger_odin_controller, mocker: MockerFixture): + controller = eiger_odin_controller assert isinstance(controller.OD, OdinController) eiger_initialise_mock = mocker.patch( @@ -25,3 +30,51 @@ async def test_eiger_odin_controller(mocker: MockerFixture): eiger_initialise_mock.assert_called_once_with() odin_initialise_mock.assert_called_once_with() + + +@pytest.mark.asyncio +async def test_odin_arm_when_ready(eiger_odin_controller, mocker: MockerFixture): + controller = eiger_odin_controller + + _super_arm_mock = mocker.patch.object(EigerController, "arm_when_ready") + ef_mock = mocker.patch.object(controller.OD, "EF", create=True) + ef_mock.ready.wait_for_value = mocker.AsyncMock() + + ef_mock.ready.wait_for_value.side_effect = TimeoutError + with pytest.raises(TimeoutError, match="Eiger fan not ready"): + await controller.arm_when_ready() + + _super_arm_mock.assert_called_once_with() + + ef_mock.ready.wait_for_value.side_effect = None + await controller.arm_when_ready() + + +@pytest.mark.asyncio +async def test_start_writing(eiger_odin_controller, mocker: MockerFixture): + controller = eiger_odin_controller + + detector_mock = mocker.patch.object(controller, "detector", create=True) + detector_mock.compression.get.return_value = "lz4" + detector_mock.bit_depth_image.get.return_value = 16 + + fp_mock = mocker.patch.object(controller.OD, "FP", create=True) + fp_mock.data_compression.put = mocker.AsyncMock() + fp_mock.data_datatype.put = mocker.AsyncMock() + fp_mock.start_writing = mocker.AsyncMock() + + writing_wait_mock = mocker.patch.object(controller.OD.writing, "wait_for_value") + + writing_wait_mock.side_effect = TimeoutError + with pytest.raises(TimeoutError, match="File writers failed to start"): + await controller.start_writing() + + writing_wait_mock.side_effect = None + await controller.start_writing() + + fp_mock.data_compression.put.assert_awaited_with("LZ4") + fp_mock.data_datatype.put.assert_awaited_with("uint16") + fp_mock.start_writing.assert_awaited_with() + writing_wait_mock.assert_awaited_with( + True, timeout=controller.start_writing_timeout.get() + ) diff --git a/tests/test_odin_controller.py b/tests/test_odin_controller.py index 572de6b..a8b7c6c 100644 --- a/tests/test_odin_controller.py +++ b/tests/test_odin_controller.py @@ -5,6 +5,9 @@ from pytest_mock import MockerFixture from fastcs_eiger.controllers.odin.eiger_fan import EigerFanAdapterController +from fastcs_eiger.controllers.odin.eiger_fp_adapter_controller import ( + EigerFrameProcessorAdapterController, +) from fastcs_eiger.controllers.odin.odin_controller import OdinController @@ -18,6 +21,11 @@ async def test_create_adapter_controller(mocker: MockerFixture): ) ] + ctrl = controller._create_adapter_controller( + controller.connection, parameters, "fp", "FrameProcessorAdapter" + ) + assert isinstance(ctrl, EigerFrameProcessorAdapterController) + ctrl = controller._create_adapter_controller( controller.connection, parameters, "ef", "EigerFanAdapter" )