diff --git a/src/ophyd_async/epics/eiger/_eiger.py b/src/ophyd_async/epics/eiger/_eiger.py index d485fb47b0..1c8bbc7572 100644 --- a/src/ophyd_async/epics/eiger/_eiger.py +++ b/src/ophyd_async/epics/eiger/_eiger.py @@ -1,3 +1,5 @@ +from dataclasses import dataclass + from pydantic import Field from ophyd_async.core import AsyncStatus, PathProvider, StandardDetector, TriggerInfo @@ -5,10 +7,63 @@ from ._eiger_controller import EigerController from ._eiger_io import EigerDriverIO from ._odin_io import Odin, OdinWriter +from .det_dim_constants import ( + EIGER2_X_16M_SIZE, + DetectorSize, + DetectorSizeConstants, +) +from .det_dist_to_beam_converter import ( + DetectorDistanceToBeamXYConverter, +) + + +@dataclass +class EigerTimeouts: + stale_params_timeout: int = 60 + general_status_timeout: int = 10 + meta_file_ready_timeout: int = 30 + all_frames_timeout: int = 120 + arming_timeout: int = 60 class EigerTriggerInfo(TriggerInfo): energy_ev: float = Field(gt=0) + exposure_time: float = Field() + detector_size_constants: DetectorSizeConstants = EIGER2_X_16M_SIZE + use_roi_mode: bool + det_dist_to_beam_converter_path: str + detector_distance: float + omega_start: float + omega_increment: float + + @property + def beam_xy_converter(self) -> DetectorDistanceToBeamXYConverter: + return DetectorDistanceToBeamXYConverter(self.det_dist_to_beam_converter_path) + + def get_detector_size_pizels(self) -> DetectorSize: + full_size = self.detector_size_constants.det_size_pixels + roi_size = self.detector_size_constants.roi_size_pixels + return roi_size if self.use_roi_mode else full_size + + def get_beam_position_pixels(self, detector_distance: float) -> tuple[float, float]: + full_size_pixels = self.detector_size_constants.det_size_pixels + roi_size_pixels = self.get_detector_size_pizels() + + x_beam_pixels = self.beam_xy_converter.get_beam_x_pixels( + detector_distance, + full_size_pixels.width, + self.detector_size_constants.det_dimension.width, + ) + y_beam_pixels = self.beam_xy_converter.get_beam_y_pixels( + detector_distance, + full_size_pixels.height, + self.detector_size_constants.det_dimension.height, + ) + + offset_x = (full_size_pixels.width - roi_size_pixels.width) / 2.0 + offset_y = (full_size_pixels.height - roi_size_pixels.height) / 2.0 + + return x_beam_pixels - offset_x, y_beam_pixels - offset_y class EigerDetector(StandardDetector): @@ -17,7 +72,7 @@ class EigerDetector(StandardDetector): """ _controller: EigerController - _writer: Odin + _writer: OdinWriter def __init__( self, @@ -29,7 +84,8 @@ def __init__( ): self.drv = EigerDriverIO(prefix + drv_suffix) self.odin = Odin(prefix + hdf_suffix + "FP:") - + self.detector_params: EigerTriggerInfo | None = None + self.timeouts = EigerTimeouts() super().__init__( EigerController(self.drv), OdinWriter(path_provider, lambda: self.name, self.odin), @@ -38,5 +94,53 @@ def __init__( @AsyncStatus.wrap async def prepare(self, value: EigerTriggerInfo) -> None: # type: ignore + self.set_detector_parameters(value) await self._controller.set_energy(value.energy_ev) await super().prepare(value) + + def set_detector_parameters(self, detector_params: EigerTriggerInfo): + self.detector_params = detector_params + if self.detector_params is None: + raise ValueError("Parameters for scan must be specified") + + to_check = [ + ( + self.detector_params.detector_size_constants is None, + "Detector Size must be set", + ), + ( + self.detector_params.beam_xy_converter is None, + "Beam converter must be set", + ), + ] + + errors = [message for check_result, message in to_check if check_result] + + if errors: + raise Exception("\n".join(errors)) + + @AsyncStatus.wrap + async def set_mx_settings_pvs(self) -> None: + if not self.detector_params: + raise TypeError("Detector parameters are not instantiated") + beam_x_pixels, beam_y_pixels = self.detector_params.get_beam_position_pixels( + self.detector_params.detector_distance + ) + self.drv.beam_centre_x.set( + beam_x_pixels, timeout=self.timeouts.general_status_timeout + ) + self.drv.beam_centre_y.set( + beam_y_pixels, timeout=self.timeouts.general_status_timeout + ) + self.drv.det_distance.set( + self.detector_params.detector_distance, + timeout=self.timeouts.general_status_timeout, + ) + self.drv.omega_start.set( + self.detector_params.omega_start, + timeout=self.timeouts.general_status_timeout, + ) + self.drv.omega_increment.set( + self.detector_params.omega_increment, + timeout=self.timeouts.general_status_timeout, + ) diff --git a/src/ophyd_async/epics/eiger/_eiger_io.py b/src/ophyd_async/epics/eiger/_eiger_io.py index 484843ed30..00d0061b83 100644 --- a/src/ophyd_async/epics/eiger/_eiger_io.py +++ b/src/ophyd_async/epics/eiger/_eiger_io.py @@ -20,6 +20,7 @@ def __init__(self, prefix: str, name: str = "") -> None: self.num_images = epics_signal_rw_rbv(int, f"{prefix}Nimages") self.num_triggers = epics_signal_rw_rbv(int, f"{prefix}Ntrigger") + self.num_exposures = epics_signal_rw_rbv(int, f"{prefix}Nexpi") # Check PV name # TODO: Should be EigerTriggerMode enum, see https://github.com/DiamondLightSource/eiger-fastcs/issues/43 self.trigger_mode = epics_signal_rw_rbv(str, f"{prefix}TriggerMode") diff --git a/src/ophyd_async/epics/eiger/det_dim_constants.py b/src/ophyd_async/epics/eiger/det_dim_constants.py new file mode 100644 index 0000000000..afb71d1039 --- /dev/null +++ b/src/ophyd_async/epics/eiger/det_dim_constants.py @@ -0,0 +1,81 @@ +from typing import Generic, TypeVar + +from pydantic.dataclasses import dataclass + +T = TypeVar("T", bound=float | int) + + +@dataclass +class DetectorSize(Generic[T]): + width: T + height: T + + +ALL_DETECTORS: dict[str, "DetectorSizeConstants"] = {} + + +@dataclass +class DetectorSizeConstants: + det_type_string: str + det_dimension: DetectorSize[float] + det_size_pixels: DetectorSize[int] + roi_dimension: DetectorSize[float] + roi_size_pixels: DetectorSize[int] + + def __post_init__(self): + ALL_DETECTORS[self.det_type_string] = self + + +EIGER_TYPE_EIGER2_X_4M = "EIGER2_X_4M" +EIGER2_X_4M_DIMENSION_X = 155.1 +EIGER2_X_4M_DIMENSION_Y = 162.15 +EIGER2_X_4M_DIMENSION = DetectorSize(EIGER2_X_4M_DIMENSION_X, EIGER2_X_4M_DIMENSION_Y) +PIXELS_X_EIGER2_X_4M = 2068 +PIXELS_Y_EIGER2_X_4M = 2162 +PIXELS_EIGER2_X_4M = DetectorSize(PIXELS_X_EIGER2_X_4M, PIXELS_Y_EIGER2_X_4M) +EIGER2_X_4M_SIZE = DetectorSizeConstants( + EIGER_TYPE_EIGER2_X_4M, + EIGER2_X_4M_DIMENSION, + PIXELS_EIGER2_X_4M, + EIGER2_X_4M_DIMENSION, + PIXELS_EIGER2_X_4M, +) + +EIGER_TYPE_EIGER2_X_9M = "EIGER2_X_9M" +EIGER2_X_9M_DIMENSION_X = 233.1 +EIGER2_X_9M_DIMENSION_Y = 244.65 +EIGER2_X_9M_DIMENSION = DetectorSize(EIGER2_X_9M_DIMENSION_X, EIGER2_X_9M_DIMENSION_Y) +PIXELS_X_EIGER2_X_9M = 3108 +PIXELS_Y_EIGER2_X_9M = 3262 +PIXELS_EIGER2_X_9M = DetectorSize(PIXELS_X_EIGER2_X_9M, PIXELS_Y_EIGER2_X_9M) +EIGER2_X_9M_SIZE = DetectorSizeConstants( + EIGER_TYPE_EIGER2_X_9M, + EIGER2_X_9M_DIMENSION, + PIXELS_EIGER2_X_9M, + EIGER2_X_9M_DIMENSION, + PIXELS_EIGER2_X_9M, +) + +EIGER_TYPE_EIGER2_X_16M = "EIGER2_X_16M" +EIGER2_X_16M_DIMENSION_X = 311.1 +EIGER2_X_16M_DIMENSION_Y = 327.15 +EIGER2_X_16M_DIMENSION = DetectorSize( + EIGER2_X_16M_DIMENSION_X, EIGER2_X_16M_DIMENSION_Y +) +PIXELS_X_EIGER2_X_16M = 4148 +PIXELS_Y_EIGER2_X_16M = 4362 +PIXELS_EIGER2_X_16M = DetectorSize(PIXELS_X_EIGER2_X_16M, PIXELS_Y_EIGER2_X_16M) +EIGER2_X_16M_SIZE = DetectorSizeConstants( + EIGER_TYPE_EIGER2_X_16M, + EIGER2_X_16M_DIMENSION, + PIXELS_EIGER2_X_16M, + EIGER2_X_4M_DIMENSION, + PIXELS_EIGER2_X_4M, +) + + +def constants_from_type(det_type_string: str) -> DetectorSizeConstants: + try: + return ALL_DETECTORS[det_type_string] + except KeyError as e: + raise KeyError(f"Detector {det_type_string} not found") from e diff --git a/src/ophyd_async/epics/eiger/det_dist_to_beam_converter.py b/src/ophyd_async/epics/eiger/det_dist_to_beam_converter.py new file mode 100644 index 0000000000..25f4977aee --- /dev/null +++ b/src/ophyd_async/epics/eiger/det_dist_to_beam_converter.py @@ -0,0 +1,61 @@ +from enum import Enum + +from numpy import interp, loadtxt + + +class Axis(Enum): + X_AXIS = 1 + Y_AXIS = 2 + + +class DetectorDistanceToBeamXYConverter: + def __init__(self, lookup_file: str): + self.lookup_file: str = lookup_file + self.lookup_table_values: list = self.parse_table() + + def get_beam_xy_from_det_dist(self, det_dist_mm: float, beam_axis: Axis) -> float: + beam_axis_values = self.lookup_table_values[beam_axis.value] + det_dist_array = self.lookup_table_values[0] + return float(interp(det_dist_mm, det_dist_array, beam_axis_values)) + + def get_beam_axis_pixels( + self, + det_distance: float, + image_size_pixels: int, + det_dim: float, + beam_axis: Axis, + ) -> float: + beam_mm = self.get_beam_xy_from_det_dist(det_distance, beam_axis) + return beam_mm * image_size_pixels / det_dim + + def get_beam_y_pixels( + self, det_distance: float, image_size_pixels: int, det_dim: float + ) -> float: + return self.get_beam_axis_pixels( + det_distance, image_size_pixels, det_dim, Axis.Y_AXIS + ) + + def get_beam_x_pixels( + self, det_distance: float, image_size_pixels: int, det_dim: float + ) -> float: + return self.get_beam_axis_pixels( + det_distance, image_size_pixels, det_dim, Axis.X_AXIS + ) + + def reload_lookup_table(self): + self.lookup_table_values = self.parse_table() + + def parse_table(self) -> list: + rows = loadtxt(self.lookup_file, delimiter=" ", comments=["#", "Units"]) + columns = list(zip(*rows, strict=False)) + + return columns + + def __eq__(self, other): + if not isinstance(other, DetectorDistanceToBeamXYConverter): + return NotImplemented + if self.lookup_file != other.lookup_file: + return False + if self.lookup_table_values != other.lookup_table_values: + return False + return True diff --git a/tests/epics/eiger/test_beam_converter.py b/tests/epics/eiger/test_beam_converter.py new file mode 100644 index 0000000000..1cc9c6c485 --- /dev/null +++ b/tests/epics/eiger/test_beam_converter.py @@ -0,0 +1,106 @@ +from unittest.mock import Mock, patch + +import pytest + +from ophyd_async.epics.eiger.det_dist_to_beam_converter import ( + Axis, + DetectorDistanceToBeamXYConverter, +) + +LOOKUP_TABLE_TEST_VALUES = [(100.0, 200.0), (150.0, 151.0), (160.0, 165.0)] + + +@pytest.fixture +def fake_converter(): + with patch.object( + DetectorDistanceToBeamXYConverter, + "parse_table", + return_value=LOOKUP_TABLE_TEST_VALUES, + ): + yield DetectorDistanceToBeamXYConverter("test.txt") + + +def test_converter_eq(): + test_file = "tests/epics/eiger/test_lookup_table.txt" + test_converter = DetectorDistanceToBeamXYConverter(test_file) + test_converter_dupe = DetectorDistanceToBeamXYConverter(test_file) + test_file_2 = "tests/epics/eiger/test_lookup_table_2.txt" + test_converter_2 = DetectorDistanceToBeamXYConverter(test_file_2) + assert test_converter != 1 + assert test_converter == test_converter_dupe + assert test_converter != test_converter_2 + previous_value = test_converter_dupe.lookup_table_values[0] + test_converter_dupe.lookup_table_values[0] = (7.5, 23.5) + assert test_converter != test_converter_dupe + test_converter_dupe.lookup_table_values[0] = previous_value + + +@pytest.mark.parametrize( + "detector_distance, axis, expected_value", + [ + (100.0, Axis.Y_AXIS, 160.0), + (200.0, Axis.X_AXIS, 151.0), + (150.0, Axis.X_AXIS, 150.5), + (190.0, Axis.Y_AXIS, 164.5), + ], +) +def test_interpolate_beam_xy_from_det_distance( + fake_converter: DetectorDistanceToBeamXYConverter, + detector_distance: float, + axis: Axis, + expected_value: float, +): + assert isinstance( + fake_converter.get_beam_xy_from_det_dist(detector_distance, axis), float + ) + + assert ( + fake_converter.get_beam_xy_from_det_dist(detector_distance, axis) + == expected_value + ) + + +def test_get_beam_in_pixels(fake_converter: DetectorDistanceToBeamXYConverter): + detector_distance = 100.0 + image_size_pixels = 100 + detector_dimensions = 200.0 + interpolated_x_value = 150.0 + interpolated_y_value = 160.0 + + def mock_callback(dist: float, axis: Axis): + match axis: + case Axis.X_AXIS: + return interpolated_x_value + case Axis.Y_AXIS: + return interpolated_y_value + + fake_converter.get_beam_xy_from_det_dist = Mock() + fake_converter.get_beam_xy_from_det_dist.side_effect = mock_callback + expected_y_value = interpolated_y_value * image_size_pixels / detector_dimensions + expected_x_value = interpolated_x_value * image_size_pixels / detector_dimensions + + calculated_y_value = fake_converter.get_beam_y_pixels( + detector_distance, image_size_pixels, detector_dimensions + ) + + assert calculated_y_value == expected_y_value + assert ( + fake_converter.get_beam_x_pixels( + detector_distance, image_size_pixels, detector_dimensions + ) + == expected_x_value + ) + + +def test_parse_table(): + test_file = "tests/epics/eiger/test_lookup_table.txt" + test_converter = DetectorDistanceToBeamXYConverter(test_file) + + assert test_converter.lookup_file == test_file + assert test_converter.lookup_table_values == LOOKUP_TABLE_TEST_VALUES + assert test_converter.parse_table() == LOOKUP_TABLE_TEST_VALUES + + test_converter.reload_lookup_table() + + assert test_converter.lookup_file == test_file + assert test_converter.lookup_table_values == LOOKUP_TABLE_TEST_VALUES diff --git a/tests/epics/eiger/test_eiger_detector.py b/tests/epics/eiger/test_eiger_detector.py index a9d2b4b473..f8d40664af 100644 --- a/tests/epics/eiger/test_eiger_detector.py +++ b/tests/epics/eiger/test_eiger_detector.py @@ -4,6 +4,7 @@ from ophyd_async.core import DetectorTrigger, init_devices from ophyd_async.epics.eiger import EigerDetector, EigerTriggerInfo +from ophyd_async.epics.eiger.det_dim_constants import EIGER2_X_16M_SIZE from ophyd_async.testing import get_mock_put @@ -14,6 +15,24 @@ def detector(RE): return detector +def create_eiger_trigger_info(): + return EigerTriggerInfo( + frame_timeout=None, + number_of_triggers=1, + trigger=DetectorTrigger.INTERNAL, + deadtime=None, + livetime=None, + energy_ev=10000, + exposure_time=1.0, + detector_distance=1.0, + omega_start=0.0, + omega_increment=0.0, + use_roi_mode=False, + det_dist_to_beam_converter_path="tests/epics/eiger/test_lookup_table.txt", + detector_size_constants=EIGER2_X_16M_SIZE, + ) + + def test_when_detector_initialised_then_driver_and_odin_have_expected_prefixes( detector, ): @@ -23,15 +42,19 @@ def test_when_detector_initialised_then_driver_and_odin_have_expected_prefixes( async def test_when_prepared_with_energy_then_energy_set_on_detector(detector): detector._controller.arm = AsyncMock() - await detector.prepare( - EigerTriggerInfo( - frame_timeout=None, - number_of_triggers=1, - trigger=DetectorTrigger.INTERNAL, - deadtime=None, - livetime=None, - energy_ev=10000, - ) - ) + await detector.prepare(create_eiger_trigger_info()) get_mock_put(detector.drv.photon_energy).assert_called_once_with(10000, wait=ANY) + + +async def test_set_mx_settings_sets_pvs_correctly(detector): + detector.detector_params = create_eiger_trigger_info() + beam_center_x_calculated = detector.detector_params.get_beam_position_pixels( + detector.detector_params.detector_distance + )[0] + + assert await detector.drv.beam_centre_x.get_value() != beam_center_x_calculated + + await detector.set_mx_settings_pvs() + + assert await detector.drv.beam_centre_x.get_value() == beam_center_x_calculated diff --git a/tests/epics/eiger/test_lookup_table.txt b/tests/epics/eiger/test_lookup_table.txt new file mode 100644 index 0000000000..16fa297a05 --- /dev/null +++ b/tests/epics/eiger/test_lookup_table.txt @@ -0,0 +1,5 @@ +# Beam converter lookup table for testing + +Units det_dist beam_x beam_y +100.0 150.0 160.0 +200.0 151.0 165.0 diff --git a/tests/epics/eiger/test_lookup_table_2.txt b/tests/epics/eiger/test_lookup_table_2.txt new file mode 100644 index 0000000000..16fa297a05 --- /dev/null +++ b/tests/epics/eiger/test_lookup_table_2.txt @@ -0,0 +1,5 @@ +# Beam converter lookup table for testing + +Units det_dist beam_x beam_y +100.0 150.0 160.0 +200.0 151.0 165.0