diff --git a/pyproject.toml b/pyproject.toml index 2d033a7..7f0744c 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 dada0e8..cb98fc2 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 @@ -54,6 +55,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 72c6315..e8df1ac 100644 --- a/src/fastcs_eiger/controllers/eiger_controller.py +++ b/src/fastcs_eiger/controllers/eiger_controller.py @@ -6,7 +6,7 @@ from fastcs.controllers import Controller from fastcs.datatypes import Bool 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 @@ -24,6 +24,8 @@ class EigerController(Controller): port: Port of Eiger detector """ + detector: EigerDetectorController + # Internal Attribute stale_parameters = AttrR(Bool()) @@ -77,7 +79,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: @@ -114,3 +116,16 @@ 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() + 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=1) + 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 71377a1..cf96cc4 100644 --- a/src/fastcs_eiger/controllers/eiger_detector_controller.py +++ b/src/fastcs_eiger/controllers/eiger_detector_controller.py @@ -20,7 +20,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 7b4bca8..8021363 100644 --- a/src/fastcs_eiger/controllers/odin/eiger_odin_controller.py +++ b/src/fastcs_eiger/controllers/odin/eiger_odin_controller.py @@ -1,6 +1,7 @@ import asyncio from fastcs.connections import IPConnectionSettings +from fastcs.methods import command from fastcs_eiger.controllers.eiger_controller import EigerController from fastcs_eiger.controllers.odin.odin_controller import OdinController @@ -22,3 +23,38 @@ async def initialise(self) -> None: """Initialise eiger controller and odin controller""" await asyncio.gather(super().initialise(), self.OD.initialise()) + + @command() + 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=1) + except TimeoutError as e: + raise TimeoutError("Eiger fan not ready") from e + + @command() + 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=1) + 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 2177131..9e4d70c 100644 --- a/tests/system/test_eiger_introspection.py +++ b/tests/system/test_eiger_introspection.py @@ -99,7 +99,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": @@ -129,7 +129,7 @@ async def test_threshold_mode_api_inconsistency_handled( controller = EigerController(IPConnectionSettings("127.0.0.1", 8081)) 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 @@ -160,7 +160,7 @@ async def test_fetch_before_returning_parameters(sim_eiger, mocker: MockerFixtur controller = EigerController(IPConnectionSettings("127.0.0.1", 8081)) 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] = ( @@ -212,7 +212,7 @@ async def test_stale_propagates_to_top_controller( controller = EigerController(IPConnectionSettings("127.0.0.1", 8081)) 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 @@ -271,7 +271,7 @@ async def test_eiger_controller_trigger_correctly_introspected( controller = EigerController(IPConnectionSettings("127.0.0.1", 8081)) 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() @@ -341,7 +341,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 20a3e02..56cb5c4 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") 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_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" )