diff --git a/docs/how-to/external-io-devices.md b/docs/how-to/external-io-devices.md index eb10d4131f..4570f152c6 100644 --- a/docs/how-to/external-io-devices.md +++ b/docs/how-to/external-io-devices.md @@ -14,7 +14,7 @@ It's not recommended to read from the filesystem going forward and instead devel ## Extant examples -- [aperturescatterguard](../../src/dodal/devices/aperturescatterguard.py) - reads a set of valid positions from a file. +- [aperturescatterguard](../../src/dodal/devices/mx_phase1/aperturescatterguard.py) - reads a set of valid positions from a file. - [oav_to_redis_forwarder](../../src/dodal/devices/oav/oav_to_redis_forwarder.py) - pushes data into redis - [OAV_detector](../../src/dodal/devices/oav/oav_detector.py) - detector configuration is based on a file on disk diff --git a/docs/images/aperture_scatterguard.svg b/docs/images/aperture_scatterguard.svg new file mode 100644 index 0000000000..a7fab2dc88 --- /dev/null +++ b/docs/images/aperture_scatterguard.svg @@ -0,0 +1,4 @@ + + + +
Cryostream
Cryostream
Aperture (with 3 positions)
Aperture (wit...
Scatterguard
(has additional independent axis)
Scatterguard...
Moves together on miniapt axis
Moves together on...
+ y
+ y
Text is not SVG - cannot display
diff --git a/docs/images/scintillator-motion.svg b/docs/images/scintillator-motion.svg new file mode 100644 index 0000000000..29edf9349a --- /dev/null +++ b/docs/images/scintillator-motion.svg @@ -0,0 +1,481 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + aperture-scatterguardmust move in -ve x directionin order for scintillator to come forward and up + + scintillator has y, z axis motion only + z + y + + beam + + + diff --git a/docs/reference/aperture-scatterguard-collisions.md b/docs/reference/aperture-scatterguard-collisions.md new file mode 100644 index 0000000000..c94dc21027 --- /dev/null +++ b/docs/reference/aperture-scatterguard-collisions.md @@ -0,0 +1,19 @@ +# Aperture Scatterguard and Scintillator Collisions + +If the aperture/scatterguard are not moved sensibly in y it is possible for the scatterguard to collide with the cryostream. See the below diagram... + +```{raw} html +:file: ../images/aperture_scatterguard.svg +``` +If for example the top aperture is to be chosen then the whole assembly must move down so that the top aperture matches the beam then the scatterguard up to match the top aperture. If instead the bottom aperture is chosen then the scatterguard must move down then the whole assembly moved up. + +The aperture/scatterguard may also collide with the scintillator as described by the diagram below. + +Normally the scintillator is stowed behind the aperture/scatterguard in the z direction, to move it into position it must be moved in front; to do this the aperture/scatterguard must be moved sideways in the -ve x direction out of the way. + +The aperture/scatterguard must be moved in the -ve x direction in order to avoid colliding with the scintillator when it is +moved in / out (scintillator only has motion in the y, z axes). + +```{raw} html +:file: ../images/scintillator-motion.svg +``` diff --git a/src/dodal/beamlines/i03.py b/src/dodal/beamlines/i03.py index d260dbd1fe..553c651b16 100644 --- a/src/dodal/beamlines/i03.py +++ b/src/dodal/beamlines/i03.py @@ -9,11 +9,6 @@ from dodal.common.beamlines.beamline_utils import set_beamline as set_utils_beamline from dodal.common.udc_directory_provider import PandASubpathProvider from dodal.device_manager import DeviceManager -from dodal.devices.aperturescatterguard import ( - AperturePosition, - ApertureScatterguard, - load_positions_from_beamline_parameters, -) from dodal.devices.attenuator.attenuator import BinaryFilterAttenuator from dodal.devices.backlight import Backlight from dodal.devices.baton import Baton @@ -37,13 +32,18 @@ from dodal.devices.hutch_shutter import HutchShutter from dodal.devices.ipin import IPin from dodal.devices.motors import XYZStage +from dodal.devices.mx_phase1.aperturescatterguard import ( + AperturePosition, + ApertureScatterguard, + load_configuration, +) +from dodal.devices.mx_phase1.scintillator import Scintillator from dodal.devices.oav.oav_detector import OAVBeamCentreFile from dodal.devices.oav.oav_parameters import OAVConfigBeamCentre from dodal.devices.oav.pin_image_recognition import PinTipDetection from dodal.devices.qbpm import QBPM from dodal.devices.robot import BartRobot from dodal.devices.s4_slit_gaps import S4SlitGaps -from dodal.devices.scintillator import Scintillator from dodal.devices.smargon import Smargon from dodal.devices.synchrotron import Synchrotron from dodal.devices.thawer import Thawer @@ -99,7 +99,7 @@ def aperture_scatterguard() -> ApertureScatterguard: return ApertureScatterguard( aperture_prefix=f"{PREFIX.beamline_prefix}-MO-MAPT-01:", scatterguard_prefix=f"{PREFIX.beamline_prefix}-MO-SCAT-01:", - loaded_positions=load_positions_from_beamline_parameters(params), + config=load_configuration(params), tolerances=AperturePosition.tolerances_from_gda_params(params), ) @@ -342,11 +342,14 @@ def fluorescence_det_motion() -> FluorescenceDetector: @devices.factory() -def scintillator(aperture_scatterguard: ApertureScatterguard) -> Scintillator: +def scintillator( + aperture_scatterguard: ApertureScatterguard, beamstop: Beamstop +) -> Scintillator: return Scintillator( f"{PREFIX.beamline_prefix}-MO-SCIN-01:", - Reference(aperture_scatterguard), get_beamline_parameters(), + Reference(aperture_scatterguard), + Reference(beamstop), ) diff --git a/src/dodal/beamlines/i04.py b/src/dodal/beamlines/i04.py index a7d7223ff1..12ef6e2a7d 100644 --- a/src/dodal/beamlines/i04.py +++ b/src/dodal/beamlines/i04.py @@ -3,11 +3,6 @@ from dodal.common.beamlines.beamline_parameters import get_beamline_parameters from dodal.common.beamlines.beamline_utils import set_beamline as set_utils_beamline from dodal.device_manager import DeviceManager -from dodal.devices.aperturescatterguard import ( - AperturePosition, - ApertureScatterguard, - load_positions_from_beamline_parameters, -) from dodal.devices.attenuator.attenuator import BinaryFilterAttenuator from dodal.devices.backlight import Backlight from dodal.devices.baton import Baton @@ -25,7 +20,13 @@ from dodal.devices.flux import Flux from dodal.devices.ipin import IPin from dodal.devices.motors import XYZStage +from dodal.devices.mx_phase1.aperturescatterguard import ( + AperturePosition, + ApertureScatterguard, + load_configuration, +) from dodal.devices.mx_phase1.beamstop import Beamstop +from dodal.devices.mx_phase1.scintillator import Scintillator from dodal.devices.oav.oav_detector import ( OAVBeamCentrePV, ZoomControllerWithBeamCentres, @@ -35,7 +36,6 @@ from dodal.devices.oav.pin_image_recognition import PinTipDetection from dodal.devices.robot import BartRobot from dodal.devices.s4_slit_gaps import S4SlitGaps -from dodal.devices.scintillator import Scintillator from dodal.devices.smargon import Smargon from dodal.devices.synchrotron import Synchrotron from dodal.devices.thawer import Thawer @@ -152,7 +152,7 @@ def aperture_scatterguard() -> ApertureScatterguard: return ApertureScatterguard( aperture_prefix=f"{PREFIX.beamline_prefix}-MO-MAPT-01:", scatterguard_prefix=f"{PREFIX.beamline_prefix}-MO-SCAT-01:", - loaded_positions=load_positions_from_beamline_parameters(params), + config=load_configuration(params), tolerances=AperturePosition.tolerances_from_gda_params(params), ) @@ -277,11 +277,14 @@ def pin_tip_detection() -> PinTipDetection: @devices.factory() -def scintillator(aperture_scatterguard: ApertureScatterguard) -> Scintillator: +def scintillator( + aperture_scatterguard: ApertureScatterguard, beamstop: Beamstop +) -> Scintillator: return Scintillator( f"{PREFIX.beamline_prefix}-MO-SCIN-01:", - Reference(aperture_scatterguard), get_beamline_parameters(), + Reference(aperture_scatterguard), + Reference(beamstop), ) diff --git a/src/dodal/devices/beamlines/i03/beamsize.py b/src/dodal/devices/beamlines/i03/beamsize.py index 40224587da..8e29f65c25 100644 --- a/src/dodal/devices/beamlines/i03/beamsize.py +++ b/src/dodal/devices/beamlines/i03/beamsize.py @@ -1,8 +1,8 @@ from ophyd_async.core import Reference, derived_signal_r -from dodal.devices.aperturescatterguard import ApertureScatterguard from dodal.devices.beamlines.i03.constants import BeamsizeConstants from dodal.devices.beamsize.beamsize import BeamsizeBase +from dodal.devices.mx_phase1.aperturescatterguard import ApertureScatterguard class Beamsize(BeamsizeBase): diff --git a/src/dodal/devices/beamlines/i04/beamsize.py b/src/dodal/devices/beamlines/i04/beamsize.py index c1de9cc7ef..9935c2592c 100644 --- a/src/dodal/devices/beamlines/i04/beamsize.py +++ b/src/dodal/devices/beamlines/i04/beamsize.py @@ -1,8 +1,8 @@ from ophyd_async.core import Reference, derived_signal_r -from dodal.devices.aperturescatterguard import ApertureScatterguard from dodal.devices.beamlines.i04.transfocator import Transfocator from dodal.devices.beamsize.beamsize import BeamsizeBase +from dodal.devices.mx_phase1.aperturescatterguard import ApertureScatterguard class Beamsize(BeamsizeBase): diff --git a/src/dodal/devices/fast_grid_scan.py.orig b/src/dodal/devices/fast_grid_scan.py.orig new file mode 100644 index 0000000000..0bda3bef47 --- /dev/null +++ b/src/dodal/devices/fast_grid_scan.py.orig @@ -0,0 +1,484 @@ +import asyncio +from abc import ABC, abstractmethod +from functools import partial +from math import isclose +from typing import Generic, TypeVar + +import numpy as np +from bluesky.plan_stubs import prepare +from bluesky.protocols import Flyable, Preparable +from numpy import ndarray +from ophyd_async.core import ( + AsyncStatus, + Device, + Signal, + SignalR, + SignalRW, + StandardReadable, + derived_signal_r, + set_and_wait_for_value, + soft_signal_r_and_setter, + wait_for_value, +) +from ophyd_async.epics.core import ( + epics_signal_r, + epics_signal_rw, + epics_signal_rw_rbv, + epics_signal_x, +) +from pydantic import BaseModel, field_validator +from pydantic.dataclasses import dataclass + +from dodal.log import LOGGER +from dodal.parameters.experiment_parameter_base import AbstractExperimentWithBeamParams + + +class GridScanInvalidError(RuntimeError): + """Raised when the gridscan parameters are not valid.""" + + +@dataclass +class GridAxis: + start: float + step_size_mm: float + full_steps: int + + def steps_to_motor_position(self, steps): + """Gives the motor position based on steps, where steps are 0 indexed.""" + return self.start + self.step_size_mm * steps + + @property + def end(self): + """Gives the point where the final frame is taken.""" + # Note that full_steps is one indexed e.g. if there is one step then the end is + # refering to the first position + return self.steps_to_motor_position(self.full_steps - 1) + + def is_within(self, steps: float): + """Determine whether a single axis coordinate is within the grid. + The coordinate is from a continuous coordinate space based on the + XRC grid where the origin corresponds to the centre of the first grid box. + + Args: + steps (float): The coordinate to check. + + Returns: + bool: True if the coordinate falls within the grid. + """ + return -0.5 <= steps <= self.full_steps - 0.5 + + +class GridScanParamsCommon(AbstractExperimentWithBeamParams): + """Common holder class for the parameters of a grid scan in a similar + layout to EPICS. The parameters and functions of this class are common + to both the zebra and panda triggered fast grid scans in 2d or 3d. + + The grid specified is where data is taken e.g. it can be assumed the first frame is + at x_start, y1_start, z1_start and subsequent frames are N*step_size away. + """ + + x_steps: int = 1 + y_steps: int = 1 + x_step_size_mm: float = 0.1 + y_step_size_mm: float = 0.1 + x_start_mm: float = 0.1 + y1_start_mm: float = 0.1 + z1_start_mm: float = 0.1 + + # Whether to set the stub offsets after centering + set_stub_offsets: bool = False + + @property + def x_axis(self) -> GridAxis: + return GridAxis(self.x_start_mm, self.x_step_size_mm, self.x_steps) + + @property + def y_axis(self) -> GridAxis: + return GridAxis(self.y1_start_mm, self.y_step_size_mm, self.y_steps) + + # In 2D grid scans, z axis is just the start position + @property + def z_axis(self) -> GridAxis: + return GridAxis(self.z1_start_mm, 0, 1) + + def grid_position_to_motor_position(self, grid_position: ndarray) -> ndarray: + """Converts a grid position, given as steps in the x, y, z grid, + to a real motor position. + + Args: + grid_position (ndarray): The x, y, z position in grid steps. The origin is + at the centre of the first grid box + + Returns: + ndarray: The motor position this corresponds to. + + Raises: + IndexError if the desired position is outside the grid. + """ + for position, axis in zip( + grid_position, [self.x_axis, self.y_axis, self.z_axis], strict=False + ): + if not axis.is_within(position): + raise IndexError(f"{grid_position} is outside the bounds of the grid") + + return np.array( + [ + self.x_axis.steps_to_motor_position(grid_position[0]), + self.y_axis.steps_to_motor_position(grid_position[1]), + self.z_axis.steps_to_motor_position(grid_position[2]), + ] + ) + + +class GridScanParamsThreeD(GridScanParamsCommon): + """Additional parameters required to do a 3 dimensional gridscan. + + A 3D gridscan works by doing two 2D gridscans. The first of these grids is x_steps + by y_steps. The sample is then rotated by 90 degrees, and then the second grid is + x_steps by z_steps. + """ + + # Start position for z and y during the second gridscan + z2_start_mm: float = 0.1 + y2_start_mm: float = 0.1 + + z_step_size_mm: float = 0.1 + + # Number of vertical steps during the second grid scan, after the rotation in omega + z_steps: int = 1 + + @property + def z_axis(self) -> GridAxis: + return GridAxis(self.z2_start_mm, self.z_step_size_mm, self.z_steps) + + +ParamType = TypeVar("ParamType", bound=GridScanParamsCommon) + + +class WithDwellTime(BaseModel): + dwell_time_ms: float = 213 + + @field_validator("dwell_time_ms") + @classmethod + def non_integer_dwell_time(cls, dwell_time_ms: float) -> float: + dwell_time_floor_rounded = np.floor(dwell_time_ms) + dwell_time_is_close = np.isclose( + dwell_time_ms, dwell_time_floor_rounded, rtol=1e-3 + ) + if not dwell_time_is_close: + raise ValueError( + f"Dwell time of {dwell_time_ms}ms is not an integer value. Fast Grid Scan only accepts integer values" + ) + return dwell_time_ms + + +class ZebraGridScanParamsThreeD(GridScanParamsThreeD, WithDwellTime): + """Params for standard Zebra FGS. Adds on the dwell time, which is really the time + between trigger positions. + """ + + +class PandAGridScanParams(GridScanParamsThreeD): + """Params for panda constant-motion scan. Adds on the goniometer run-up distance.""" + + run_up_distance_mm: float = 0.17 + + +class MotionProgram(Device): + def __init__(self, prefix: str, name: str = "", has_prog_num=True) -> None: + super().__init__(name) + self.running = epics_signal_r(int, prefix + "PROGBITS") + if has_prog_num: + self.program_number = epics_signal_r(float, prefix + "CS1:PROG_NUM") + else: + # Prog number PV doesn't currently exist for i02-1 + self.program_number = soft_signal_r_and_setter(float, -1)[0] + + +class FastGridScanCommon( + StandardReadable, Flyable, ABC, Preparable, Generic[ParamType] +): + """Device containing the minimal signals for a general fast grid scan. + + When the motion program is started, the goniometer will move in a snake-like grid + trajectory, with X as the fast axis and Y as the slow axis. + + See ZebraFastGridScanThreeD as an example of how to implement. + """ + + def __init__( + self, prefix: str, motion_controller_prefix: str, name: str = "" + ) -> None: + super().__init__(name) + self.x_steps = epics_signal_rw_rbv(int, f"{prefix}X_NUM_STEPS") + self.y_steps = epics_signal_rw_rbv( + int, f"{prefix}Y_NUM_STEPS" + ) # Number of vertical steps during the first grid scan + self.x_step_size = epics_signal_rw_rbv(float, f"{prefix}X_STEP_SIZE") + self.y_step_size = epics_signal_rw_rbv(float, f"{prefix}Y_STEP_SIZE") + self.x_start = epics_signal_rw_rbv(float, f"{prefix}X_START") + self.y1_start = epics_signal_rw_rbv(float, f"{prefix}Y_START") + self.z1_start = epics_signal_rw_rbv(float, f"{prefix}Z_START") + + # This can be created like a regular signal instead of an abstract method + # once https://github.com/DiamondLightSource/mx-bluesky/issues/1203 is done + self.scan_invalid = self._create_scan_invalid_signal(prefix) + + self.run_cmd = epics_signal_x(f"{prefix}RUN.PROC") + self.stop_cmd = epics_signal_x(f"{prefix}STOP.PROC") + self.status = epics_signal_r(int, f"{prefix}SCAN_STATUS") + + self.expected_images = self._create_expected_images_signal() + + self.motion_program = self._create_motion_program(motion_controller_prefix) + + self.position_counter = self._create_position_counter(prefix) + + # Kickoff timeout in seconds + self.KICKOFF_TIMEOUT: float = 5.0 + + self.COMPLETE_STATUS: float = 60.0 + self.VALIDITY_CHECK_TIMEOUT = 0.5 + + self._movable_params: dict[str, Signal] = { + "x_steps": self.x_steps, + "y_steps": self.y_steps, + "x_step_size_mm": self.x_step_size, + "y_step_size_mm": self.y_step_size, + "x_start_mm": self.x_start, + "y1_start_mm": self.y1_start, + "z1_start_mm": self.z1_start, + } + + @AsyncStatus.wrap + async def kickoff(self): + curr_prog = await self.motion_program.program_number.get_value() + running = await self.motion_program.running.get_value() + if running: + LOGGER.info(f"Motion program {curr_prog} still running, waiting...") + await wait_for_value(self.motion_program.running, 0, self.KICKOFF_TIMEOUT) + + LOGGER.debug("Running scan") + await self.run_cmd.trigger() + LOGGER.info("Waiting for FGS to start") + await wait_for_value(self.status, 1, self.KICKOFF_TIMEOUT) + LOGGER.debug("FGS kicked off") + + @AsyncStatus.wrap + async def complete(self): + try: + await wait_for_value(self.status, 0, self.COMPLETE_STATUS) + except TimeoutError: + LOGGER.error( + "Hyperion timed out waiting for FGS motion to complete. This may have been caused by a goniometer stage getting stuck.\n\ + Forcibly stopping the FGS motion program..." + ) + await self.stop_cmd.trigger() + raise + + @abstractmethod + def _create_expected_images_signal(self) -> SignalR[int]: ... + + @abstractmethod + def _create_position_counter(self, prefix: str) -> SignalRW[int]: ... + + # This can be created within init rather than as a separate method after https://github.com/DiamondLightSource/mx-bluesky/issues/1203 + @abstractmethod + def _create_scan_invalid_signal(self, prefix: str) -> SignalR[float]: ... + + # This can be created within init rather than as a separate method after https://github.com/DiamondLightSource/mx-bluesky/issues/1203 + @abstractmethod + def _create_motion_program( + self, motion_controller_prefix: str + ) -> MotionProgram: ... + + @AsyncStatus.wrap + async def prepare(self, value: ParamType): + """Submit the gridscan parameters to the device for validation prior to + gridscan kickoff. + + Args: + value (ParamType): The gridscan parameters. + + Raises: + GridScanInvalidError: If the gridscan parameters were not valid. + """ + set_statuses = [] + + LOGGER.info("Applying gridscan parameters...") + + # Create arguments for bps.mv + for key, signal in self._movable_params.items(): + param_value = value.__dict__[key] + + matcher = partial(isclose, param_value, abs_tol=0.001) + + set_statuses.append( + set_and_wait_for_value( + signal, # type: ignore + param_value, + match_value=matcher, + ) + ) + + # Counter should always start at 0 + set_statuses.append(set_and_wait_for_value(self.position_counter, 0)) + + LOGGER.info("Gridscan parameters applied, waiting for sets to complete...") + + # wait for parameter sets to complete + await asyncio.gather(*set_statuses) + + LOGGER.info("Sets confirmed, waiting for validity checks to pass...") + try: + await wait_for_value( + self.scan_invalid, 0.0, timeout=self.VALIDITY_CHECK_TIMEOUT + ) + except TimeoutError as e: + raise GridScanInvalidError( + f"Gridscan parameters not validated after {self.VALIDITY_CHECK_TIMEOUT}s" + ) from e + + LOGGER.info("Gridscan validity confirmed, gridscan is now prepared.") + + +class FastGridScanThreeD(FastGridScanCommon[ParamType]): + """Device for standard 3D FGS. + + After completeing the first grid, if Z steps isn't 0, the goniometer will + rotate in the omega direction such that it moves from the X-Y, to the X-Z plane then + do a second grid scan. The detector is triggered after every x step. + See https://github.com/DiamondLightSource/hyperion/wiki/Coordinate-Systems for more. + + Subclasses must implement _create_position_counter. + """ + + def __init__(self, prefix: str, infix: str, name: str = "") -> None: + full_prefix = prefix + infix + + # Number of vertical steps during the second grid scan, after the rotation in omega + self.z_steps = epics_signal_rw_rbv(int, f"{full_prefix}Z_NUM_STEPS") + self.z_step_size = epics_signal_rw_rbv(float, f"{full_prefix}Z_STEP_SIZE") + self.z2_start = epics_signal_rw_rbv(float, f"{full_prefix}Z2_START") + self.y2_start = epics_signal_rw_rbv(float, f"{full_prefix}Y2_START") + # panda does not have x counter + self.y_counter = epics_signal_r(int, f"{full_prefix}Y_COUNTER") + + super().__init__(full_prefix, prefix, name) + + self._movable_params["z_step_size_mm"] = self.z_step_size + self._movable_params["z2_start_mm"] = self.z2_start + self._movable_params["y2_start_mm"] = self.y2_start + self._movable_params["z_steps"] = self.z_steps + + def _create_expected_images_signal(self): + return derived_signal_r( + self._calculate_expected_images, + x=self.x_steps, + y=self.y_steps, + z=self.z_steps, + ) + + def _calculate_expected_images(self, x: int, y: int, z: int) -> int: + LOGGER.info(f"Reading num of images found {x, y, z} images in each axis") + first_grid = x * y + second_grid = x * z + return first_grid + second_grid + + def _create_scan_invalid_signal(self, prefix: str) -> SignalR[float]: + self.x_scan_valid = epics_signal_r(float, f"{prefix}X_SCAN_VALID") + self.y_scan_valid = epics_signal_r(float, f"{prefix}Y_SCAN_VALID") + self.z_scan_valid = epics_signal_r(float, f"{prefix}Z_SCAN_VALID") + self.device_scan_invalid = epics_signal_r(float, f"{prefix}SCAN_INVALID") + + def compute_derived_value( + x_scan_valid: float, + y_scan_valid: float, + z_scan_valid: float, + device_scan_invalid: float, + ) -> float: + return ( + 1.0 + if not ( + x_scan_valid + and y_scan_valid + and z_scan_valid + and not device_scan_invalid + ) + else 0.0 + ) + + return derived_signal_r( + compute_derived_value, + x_scan_valid=self.x_scan_valid, + y_scan_valid=self.y_scan_valid, + z_scan_valid=self.z_scan_valid, + device_scan_invalid=self.device_scan_invalid, + ) + + def _create_motion_program(self, motion_controller_prefix: str): + return MotionProgram(motion_controller_prefix) + + +class ZebraFastGridScanThreeD(FastGridScanThreeD[ZebraGridScanParamsThreeD]): + """Device for standard Zebra 3D FGS. + + In this scan, the goniometer's velocity profile follows a parabolic shape between X + steps, with the slowest points occuring at each X step. + """ + + def __init__(self, prefix: str, name: str = "") -> None: + infix = "FGS:" + full_prefix = prefix + infix + # Time taken to travel between X steps + self.dwell_time_ms = epics_signal_rw_rbv(float, f"{full_prefix}DWELL_TIME") + self.x_counter = epics_signal_r(int, f"{full_prefix}X_COUNTER") + super().__init__(prefix, infix, name) + self._movable_params["dwell_time_ms"] = self.dwell_time_ms + + def _create_position_counter(self, prefix: str): + return epics_signal_rw( + int, f"{prefix}POS_COUNTER", write_pv=f"{prefix}POS_COUNTER_WRITE" + ) + + +class PandAFastGridScan(FastGridScanThreeD[PandAGridScanParams]): + """Device for panda constant-motion scan. + + In this scan, the goniometer's velocity is constant through each row. It doesn't + slow down when going through trigger points. + """ + + def __init__(self, prefix: str, name: str = "") -> None: + infix = "PGS:" + full_prefix = prefix + infix + self.time_between_x_steps_ms = ( + epics_signal_rw_rbv( # Used by motion controller to set goniometer velocity + float, f"{full_prefix}TIME_BETWEEN_X_STEPS" + ) + ) + + # Distance before and after the grid given to allow goniometer to reach desired speed while it is within the + # grid + self.run_up_distance_mm = epics_signal_rw_rbv( + float, f"{full_prefix}RUNUP_DISTANCE" + ) + super().__init__(prefix, infix, name) + + self._movable_params["run_up_distance_mm"] = self.run_up_distance_mm + + def _create_position_counter(self, prefix: str): + return epics_signal_rw(int, f"{prefix}Y_COUNTER") + + +def set_fast_grid_scan_params(scan: FastGridScanCommon[ParamType], params: ParamType): + """Apply the fast grid scan parameters to the grid scan device and validate them. + + Args: + scan (FastGridScancommon[ParamType]): The fast grid scan device. + params (ParamType): The parameters to set. + + Raises: + GridScanInvalidError: if the grid scan parameters are not valid. + """ + yield from prepare(scan, params, wait=True) diff --git a/src/dodal/devices/aperturescatterguard.py b/src/dodal/devices/mx_phase1/aperturescatterguard.py similarity index 70% rename from src/dodal/devices/aperturescatterguard.py rename to src/dodal/devices/mx_phase1/aperturescatterguard.py index 5ea944b16c..f09260e3e9 100644 --- a/src/dodal/devices/aperturescatterguard.py +++ b/src/dodal/devices/mx_phase1/aperturescatterguard.py @@ -1,9 +1,14 @@ from __future__ import annotations import asyncio -from math import inf +import dataclasses +from collections.abc import Callable +from math import inf, isclose +import bluesky.plan_stubs as bps +from bluesky.preprocessors import contingency_wrapper from bluesky.protocols import Preparable +from bluesky.utils import MsgGenerator from ophyd_async.core import ( AsyncStatus, StandardReadable, @@ -12,6 +17,7 @@ derived_signal_r, derived_signal_rw, ) +from ophyd_async.epics.motor import Motor from pydantic import BaseModel, Field from dodal.common.beamlines.beamline_parameters import GDABeamlineParameters @@ -31,6 +37,7 @@ class _GDAParamApertureValue(StrictEnum): MEDIUM = "MEDIUM_APERTURE" LARGE = "LARGE_APERTURE" MANUAL_LOAD = "MANUAL_LOAD" + SCIN_MOVE = "SCIN_MOVE" class AperturePosition(BaseModel): @@ -103,15 +110,31 @@ class ApertureValue(StrictEnum): LARGE = "LARGE_APERTURE" OUT_OF_BEAM = "Out of beam" PARKED = "Parked" # Parked under the collimation table for manual load + SCIN_MOVE = "Scintillator Move" # Transient position that is not defined by a single location def __str__(self): return self.name.capitalize() -def load_positions_from_beamline_parameters( +@dataclasses.dataclass +class ApertureScatterguardConfiguration: + aperture_positions: dict[ApertureValue, AperturePosition] + # Note on scintillator move configuration: + # The aperture scatterguard must move out of the way of the scintillator when + # the scintillator moves. + # (see "Aperture Scatterguard and Scintillator Collisions" in the documentation) + # The SCIN MOVE position is defined only for the x-axis; the y and z coordinates + # are preserved at the current aperture-scatterguard position. + # Both the aperture (miniap) and scatterguard(sg) are moved, and then subsequently restored. + # As this is a transient move, it is not represented in the ApertureValue enumeration. + scintillator_move_aperture_x: float + scintillator_move_scatterguard_x: float + + +def load_configuration( params: GDABeamlineParameters, -) -> dict[ApertureValue, AperturePosition]: - return { +) -> ApertureScatterguardConfiguration: + positions = { ApertureValue.OUT_OF_BEAM: AperturePosition.from_gda_params( _GDAParamApertureValue.ROBOT_LOAD, inf, params ), @@ -128,6 +151,11 @@ def load_positions_from_beamline_parameters( _GDAParamApertureValue.MANUAL_LOAD, inf, params ), } + return ApertureScatterguardConfiguration( + aperture_positions=positions, + scintillator_move_aperture_x=params["miniap_x_SCIN_MOVE"], + scintillator_move_scatterguard_x=params["sg_x_SCIN_MOVE"], + ) class ApertureScatterguard(StandardReadable, Preparable): @@ -166,13 +194,13 @@ def __init__( self, aperture_prefix: str, scatterguard_prefix: str, - loaded_positions: dict[ApertureValue, AperturePosition], + config: ApertureScatterguardConfiguration, tolerances: AperturePosition, name: str = "", ) -> None: self.aperture = Aperture(aperture_prefix) self.scatterguard = XYStage(scatterguard_prefix) - self._loaded_positions = loaded_positions + self._config = config self._tolerances = tolerances with self.add_children_as_readables(StandardReadableFormat.HINTED_SIGNAL): self.selected_aperture = derived_signal_rw( @@ -181,6 +209,8 @@ def __init__( large=self.aperture.large, medium=self.aperture.medium, small=self.aperture.small, + current_sg_x=self.scatterguard.x.user_readback, + current_ap_x=self.aperture.x.user_readback, current_ap_y=self.aperture.y.user_readback, current_ap_z=self.aperture.z.user_readback, ) @@ -208,7 +238,7 @@ async def _unpark(self, position_to_move_to: ApertureValue): """When the aperture is parked it is under the collimation table. It needs to be moved out from under the table before it is moved up to beam height. """ - position = self._loaded_positions[position_to_move_to] + position = self._config.aperture_positions[position_to_move_to] await self.aperture.z.set(position.aperture_z) async def _set_current_aperture_position(self, value: ApertureValue) -> None: @@ -216,12 +246,32 @@ async def _set_current_aperture_position(self, value: ApertureValue) -> None: raise NotImplementedError( "Currently not able to park aperture/scatterguard, see https://github.com/DiamondLightSource/mx-bluesky/issues/1197" ) + if value == ApertureValue.SCIN_MOVE: + raise NotImplementedError( + "The SCIN_MOVE position cannot be set directly - use move_scintillator_safely plan instead." + ) - position = self._loaded_positions[value] + position = self._config.aperture_positions[value] + current_sg_x = await self.scatterguard.x.user_readback.get_value() + current_ap_x = await self.aperture.x.user_readback.get_value() current_ap_y = await self.aperture.y.user_readback.get_value() current_ap_z = await self.aperture.z.user_readback.get_value() - if self._is_in_position(ApertureValue.PARKED, current_ap_y, current_ap_z): + if self._is_in_position( + ApertureValue.SCIN_MOVE, + current_sg_x, + current_ap_x, + current_ap_y, + current_ap_z, + ): + raise NotImplementedError( + "Cannot move aperture-scatterguard while in SCIN_MOVE position, please ensure scintillator " + "move is complete and restore the aperture-scatterguard position." + ) + + if self._is_in_position( + ApertureValue.PARKED, current_sg_x, current_ap_x, current_ap_y, current_ap_z + ): await self._unpark(value) await self._check_safe_to_move(position.aperture_z) @@ -263,13 +313,29 @@ async def _check_safe_to_move(self, expected_z_position: float): ) def _get_current_diameter(self, current_aperture: ApertureValue) -> float: - return self._loaded_positions[current_aperture].diameter + return self._config.aperture_positions[current_aperture].diameter def _is_in_position( - self, position: ApertureValue, current_ap_y: float, current_ap_z: float + self, + position: ApertureValue, + current_sg_x: float, + current_ap_x: float, + current_ap_y: float, + current_ap_z: float, ) -> bool: - position_y = self._loaded_positions[position].aperture_y - position_z = self._loaded_positions[position].aperture_z + if position == ApertureValue.SCIN_MOVE: + return isclose( + self._config.scintillator_move_aperture_x, + current_ap_x, + abs_tol=self._tolerances.aperture_x, + ) and isclose( + self._config.scintillator_move_scatterguard_x, + current_sg_x, + abs_tol=self._tolerances.scatterguard_x, + ) + + position_y = self._config.aperture_positions[position].aperture_y + position_z = self._config.aperture_positions[position].aperture_z y_matches = abs(current_ap_y - position_y) <= self._tolerances.aperture_y z_matches = abs(current_ap_z - position_z) <= self._tolerances.aperture_z return y_matches and z_matches @@ -279,19 +345,35 @@ def _get_current_aperture_position( large: float, medium: float, small: float, + current_sg_x: float, + current_ap_x: float, current_ap_y: float, current_ap_z: float, ) -> ApertureValue: + if self._is_in_position( + ApertureValue.SCIN_MOVE, + current_sg_x, + current_ap_x, + current_ap_y, + current_ap_z, + ): + return ApertureValue.SCIN_MOVE if large == 1: return ApertureValue.LARGE elif medium == 1: return ApertureValue.MEDIUM elif small == 1: return ApertureValue.SMALL - elif self._is_in_position(ApertureValue.PARKED, current_ap_y, current_ap_z): + elif self._is_in_position( + ApertureValue.PARKED, current_sg_x, current_ap_x, current_ap_y, current_ap_z + ): return ApertureValue.PARKED elif self._is_in_position( - ApertureValue.OUT_OF_BEAM, current_ap_y, current_ap_z + ApertureValue.OUT_OF_BEAM, + current_sg_x, + current_ap_x, + current_ap_y, + current_ap_z, ): return ApertureValue.OUT_OF_BEAM @@ -357,11 +439,15 @@ async def prepare(self, value: ApertureValue): Moving the assembly whilst out of the beam has no collision risk so we can just move all the motors together. """ + current_x = await self.aperture.x.user_readback.get_value() + current_sg_x = await self.scatterguard.x.user_readback.get_value() current_y = await self.aperture.y.user_readback.get_value() current_z = await self.aperture.z.user_readback.get_value() - if self._is_in_position(ApertureValue.OUT_OF_BEAM, current_y, current_z): + if self._is_in_position( + ApertureValue.OUT_OF_BEAM, current_sg_x, current_x, current_y, current_z + ): aperture_x, _, aperture_z, scatterguard_x, scatterguard_y = ( - self._loaded_positions[value].values + self._config.aperture_positions[value].values ) await asyncio.gather( @@ -372,3 +458,28 @@ async def prepare(self, value: ApertureValue): ) else: await self.selected_aperture.set(value) + + def get_scin_move_position(self) -> dict[Motor, float]: + return { + self.aperture.x: self._config.scintillator_move_aperture_x, + self.scatterguard.x: self._config.scintillator_move_scatterguard_x, + } + + +def do_with_aperture_scatterguard_in_scin_move_position( + aperture_scatterguard: ApertureScatterguard, inner_plan: Callable[[], MsgGenerator] +) -> MsgGenerator: + motors_and_safe_moves = aperture_scatterguard.get_scin_move_position() + saved_positions = {} + for motor in motors_and_safe_moves: + readback_ = yield from bps.rd(motor.user_readback) + saved_positions[motor] = readback_ + + for motor, position in motors_and_safe_moves.items(): + yield from bps.mv(motor, position) + + def restore_previous_position(): + for motor, position in saved_positions.items(): + yield from bps.mv(motor, position) + + yield from contingency_wrapper(inner_plan(), else_plan=restore_previous_position) diff --git a/src/dodal/devices/mx_phase1/scintillator.py b/src/dodal/devices/mx_phase1/scintillator.py new file mode 100644 index 0000000000..6c46d9678d --- /dev/null +++ b/src/dodal/devices/mx_phase1/scintillator.py @@ -0,0 +1,160 @@ +from math import isclose + +import bluesky.plan_stubs as bps +from bluesky.utils import MsgGenerator +from ophyd_async.core import Reference, StandardReadable, StrictEnum, derived_signal_rw +from ophyd_async.epics.motor import Motor + +from dodal.common.beamlines.beamline_parameters import GDABeamlineParameters +from dodal.devices.mx_phase1.aperturescatterguard import ( + ApertureScatterguard, + ApertureValue, + do_with_aperture_scatterguard_in_scin_move_position, +) +from dodal.devices.mx_phase1.beamstop import Beamstop, BeamstopPositions + + +class InOut(StrictEnum): + """Moves scintillator in and out of the beam.""" + + OUT = "Out" # Out of beam + IN = "In" # In to beam + UNKNOWN = "Unknown" + + +class Scintillator(StandardReadable): + """Moves a scintillator into and out of the beam. + + The scintillator will light up when hit with xrays, this allows scientists to use it + in conjunction with the optical OAV camera to commission the beamline. + + When moved out of the beam it is parked under the table. This parking has a potential + to collide with the aperture/scatterguard if that is not correctly parked already. + + The scintillator does not supports reading the current position but not writing to it; + due to potential collisions with the aperture-scatterguard. Instead a bluesky plan is provided + which moves the aperture-scatterguard to a safe position for the duration of the move. + """ + + def __init__( + self, + prefix: str, + beamline_parameters: GDABeamlineParameters, + aperture_scatterguard: Reference[ApertureScatterguard], + beamstop: Reference[Beamstop], + name: str = "", + ): + with self.add_children_as_readables(): + self.y_mm = Motor(f"{prefix}Y") + self.z_mm = Motor(f"{prefix}Z") + self.selected_pos = derived_signal_rw( + self._get_selected_position, + self._set_selected_position, + y=self.y_mm, + z=self.z_mm, + ) + self._beamstop = beamstop + self._aperture_scatterguard = aperture_scatterguard + + self._scintillator_out_yz_mm = [ + float(beamline_parameters[f"scin_{axis}_SCIN_OUT"]) for axis in ("y", "z") + ] + self._scintillator_in_yz_mm = [ + float(beamline_parameters[f"scin_{axis}_SCIN_IN"]) for axis in ("y", "z") + ] + self._yz_tolerance_mm = [ + float(beamline_parameters[f"scin_{axis}_tolerance"]) for axis in ("y", "z") + ] + + super().__init__(name) + + def _check_position(self, current_pos: list[float], pos_to_check: list[float]): + return all( + isclose(axis_pos, axis_in_beam, abs_tol=axis_tolerance) + for axis_pos, axis_in_beam, axis_tolerance in zip( + current_pos, + pos_to_check, + self._yz_tolerance_mm, + strict=False, + ) + ) + + def _get_selected_position(self, y: float, z: float) -> InOut: + current_pos = [y, z] + if self._check_position(current_pos, self._scintillator_out_yz_mm): + return InOut.OUT + + elif self._check_position(current_pos, self._scintillator_in_yz_mm): + return InOut.IN + + else: + return InOut.UNKNOWN + + async def _set_selected_position(self, position: InOut) -> None: + current_y = await self.y_mm.user_readback.get_value() + current_z = await self.z_mm.user_readback.get_value() + if self._get_selected_position(current_y, current_z) == position: + return + + await self._check_beamstop_position() + aperture_scatterguard_pos = ( + await self._aperture_scatterguard().selected_aperture.get_value() + ) + if aperture_scatterguard_pos != ApertureValue.SCIN_MOVE: + raise ValueError( + "Scintillator cannot be moved while aperture-scatterguard not in SCIN_MOVE position" + ) + + match position: + case InOut.OUT | InOut.IN: + await self._move_to_new_position(position) + case _: + raise ValueError(f"Cannot set scintillator to position {position}") + + async def _move_to_new_position(self, position): + if position == InOut.OUT: + await self.y_mm.set(self._scintillator_out_yz_mm[0]) + await self.z_mm.set(self._scintillator_out_yz_mm[1]) + elif position == InOut.IN: + await self.z_mm.set(self._scintillator_in_yz_mm[1]) + await self.y_mm.set(self._scintillator_in_yz_mm[0]) + + async def _check_beamstop_position(self): + position = await self._beamstop().selected_pos.get_value() + match position: + case BeamstopPositions.OUT_OF_BEAM | BeamstopPositions.DATA_COLLECTION: + return + case _: + raise ValueError( + f"Scintillator cannot be moved due to beamstop position {position}, must be in either in DATA_COLLECTION or OUT_OF_BEAM position." + ) + + +def move_scintillator_safely( + scintillator: Scintillator, + position: InOut, +) -> MsgGenerator: + """Bluesky plan to move the scintillator which moves the aperture-scatterguard out of the way + for the duration of the move and then restores it. + + Args: + scintillator (Scintillator): + The scintillator to move + position (InOut): + The scintillator position to move to + """ + current_pos = yield from bps.rd(scintillator.selected_pos) + if current_pos == position: + return + + def _move_to_new_position(): + yield from bps.abs_set(scintillator.selected_pos, position, wait=True) + + match position: + case InOut.OUT | InOut.IN: + yield from do_with_aperture_scatterguard_in_scin_move_position( + scintillator._aperture_scatterguard(), # noqa: SLF001 + _move_to_new_position, + ) + case _: + raise ValueError(f"Cannot set scintillator to position {position}") diff --git a/src/dodal/devices/scintillator.py b/src/dodal/devices/scintillator.py deleted file mode 100644 index bfa4506b1e..0000000000 --- a/src/dodal/devices/scintillator.py +++ /dev/null @@ -1,108 +0,0 @@ -from math import isclose - -from ophyd_async.core import Reference, StandardReadable, StrictEnum, derived_signal_rw -from ophyd_async.epics.motor import Motor - -from dodal.common.beamlines.beamline_parameters import GDABeamlineParameters -from dodal.devices.aperturescatterguard import ApertureScatterguard, ApertureValue - - -class InOut(StrictEnum): - """Moves scintillator in and out of the beam.""" - - OUT = "Out" # Out of beam - IN = "In" # In to beam - UNKNOWN = "Unknown" - - -class Scintillator(StandardReadable): - """Moves a scintillator into and out of the beam. - - The scintillator will light up when hit with xrays, this allows scientists to use it - in conjunction with the optical OAV camera to commission the beamline. - - When moved out of the beam it is parked under the table. This parking has a potential - to collide with the aperture/scatterguard if that is not correctly parked already. - """ - - def __init__( - self, - prefix: str, - aperture_scatterguard: Reference[ApertureScatterguard], - beamline_parameters: GDABeamlineParameters, - name: str = "", - ): - with self.add_children_as_readables(): - self.y_mm = Motor(f"{prefix}Y") - self.z_mm = Motor(f"{prefix}Z") - self.selected_pos = derived_signal_rw( - self._get_selected_position, - self._set_selected_position, - y=self.y_mm, - z=self.z_mm, - ) - - self._aperture_scatterguard = aperture_scatterguard - self._scintillator_out_yz_mm = [ - float(beamline_parameters[f"scin_{axis}_SCIN_OUT"]) for axis in ("y", "z") - ] - self._scintillator_in_yz_mm = [ - float(beamline_parameters[f"scin_{axis}_SCIN_IN"]) for axis in ("y", "z") - ] - self._yz_tolerance_mm = [ - float(beamline_parameters[f"scin_{axis}_tolerance"]) for axis in ("y", "z") - ] - - super().__init__(name) - - def _check_position(self, current_pos: list[float], pos_to_check: list[float]): - return all( - isclose(axis_pos, axis_in_beam, abs_tol=axis_tolerance) - for axis_pos, axis_in_beam, axis_tolerance in zip( - current_pos, - pos_to_check, - self._yz_tolerance_mm, - strict=False, - ) - ) - - def _get_selected_position(self, y: float, z: float) -> InOut: - current_pos = [y, z] - if self._check_position(current_pos, self._scintillator_out_yz_mm): - return InOut.OUT - - elif self._check_position(current_pos, self._scintillator_in_yz_mm): - return InOut.IN - - else: - return InOut.UNKNOWN - - async def _check_aperture_parked(self): - if ( - await self._aperture_scatterguard().selected_aperture.get_value() - != ApertureValue.PARKED - ): - raise ValueError( - f"Cannot move scintillator if aperture/scatterguard is not parked. Position is currently {await self._aperture_scatterguard().selected_aperture.get_value()}" - ) - - async def _set_selected_position(self, position: InOut) -> None: - match position: - case InOut.OUT: - current_y = await self.y_mm.user_readback.get_value() - current_z = await self.z_mm.user_readback.get_value() - if self._get_selected_position(current_y, current_z) == InOut.OUT: - return - await self._check_aperture_parked() - await self.y_mm.set(self._scintillator_out_yz_mm[0]) - await self.z_mm.set(self._scintillator_out_yz_mm[1]) - case InOut.IN: - current_y = await self.y_mm.user_readback.get_value() - current_z = await self.z_mm.user_readback.get_value() - if self._get_selected_position(current_y, current_z) == InOut.IN: - return - await self._check_aperture_parked() - await self.z_mm.set(self._scintillator_in_yz_mm[1]) - await self.y_mm.set(self._scintillator_in_yz_mm[0]) - case _: - raise ValueError(f"Cannot set scintillator to position {position}") diff --git a/src/dodal/devices/smargon.py.orig b/src/dodal/devices/smargon.py.orig new file mode 100644 index 0000000000..6ca3e9dde9 --- /dev/null +++ b/src/dodal/devices/smargon.py.orig @@ -0,0 +1,134 @@ +import asyncio +from enum import Enum +from math import isclose +from typing import TypedDict, cast + +from bluesky.protocols import Movable +from ophyd_async.core import ( + AsyncStatus, + Device, + StrictEnum, + set_and_wait_for_value, + wait_for_value, +) +from ophyd_async.epics.core import epics_signal_r, epics_signal_rw +from ophyd_async.epics.motor import Motor + +from dodal.devices.motors import XYZOmegaStage +from dodal.devices.util.epics_util import SetWhenEnabled + + +class StubPosition(Enum): + CURRENT_AS_CENTER = 0 + RESET_TO_ROBOT_LOAD = 1 + + +def approx_equal_to(target, deadband: float = 1e-9): + def approx_equal_to_target(value): + return isclose(target, value, rel_tol=0, abs_tol=deadband) + + return approx_equal_to_target + + +class StubOffsets(Device): + """Stub offsets are used to change the internal co-ordinate system of the smargon by + adding an offset to x, y, z. + This is useful as the smargon's centre of rotation is around (0, 0, 0). As such + changing stub offsets will change the centre of rotation. + In practice we don't need to change them manually, instead there are helper PVs to + set them so that the current position is zero or to pre-defined positions. + """ + + def __init__(self, prefix: str, name: str = ""): + self.center_at_current_position = SetWhenEnabled(prefix=prefix + "CENTER_CS") + self.to_robot_load = SetWhenEnabled(prefix=prefix + "SET_STUBS_TO_RL") + super().__init__(name) + + @AsyncStatus.wrap + async def set(self, value: StubPosition): + if value == StubPosition.CURRENT_AS_CENTER: + await self.center_at_current_position.set(1) + smargon = cast(Smargon, self.parent) + await wait_for_value( + smargon.x.user_readback, approx_equal_to(0.0, 0.1), None + ) + await wait_for_value( + smargon.y.user_readback, approx_equal_to(0.0, 0.1), None + ) + await wait_for_value( + smargon.z.user_readback, approx_equal_to(0.0, 0.1), None + ) + else: + await self.to_robot_load.set(1) + + +class DeferMoves(StrictEnum): + ON = "Defer On" + OFF = "Defer Off" + + +class CombinedMove(TypedDict, total=False): + """A move on multiple axes at once using a deferred move.""" + + x: float | None + y: float | None + z: float | None + omega: float | None + phi: float | None + chi: float | None + + +class Smargon(XYZOmegaStage, Movable): + """Real motors added to allow stops following pin load (e.g. real_x1.stop() ) + X1 and X2 real motors provide compound chi motion as well as the compound X travel, + increasing the gap between x1 and x2 changes chi, moving together changes virtual x. + Robot loading can nudge these and lead to errors. + """ + + DEFERRED_MOVE_SET_TIMEOUT = 5 + + def __init__(self, prefix: str, name: str = ""): + with self.add_children_as_readables(): + self.chi = Motor(prefix + "CHI") + self.phi = Motor(prefix + "PHI") + self.real_x1 = Motor(prefix + "MOTOR_3") + self.real_x2 = Motor(prefix + "MOTOR_4") + self.real_y = Motor(prefix + "MOTOR_1") + self.real_z = Motor(prefix + "MOTOR_2") + self.real_phi = Motor(prefix + "MOTOR_5") + self.real_chi = Motor(prefix + "MOTOR_6") + self.stub_offsets = StubOffsets(prefix=prefix) + self.disabled = epics_signal_r(int, prefix + "DISABLED") + + self.defer_move = epics_signal_rw(DeferMoves, prefix + "CS1:DeferMoves") + + super().__init__(prefix, name) + + @AsyncStatus.wrap + async def set(self, value: CombinedMove): + """This will move all motion together in a deferred move. + + Once defer_move is on, sets to any axis do not immediately move the axis. Instead + the setpoint will go to that value. Then, when defer_move is switched off all + axes will move at the same time. The put callbacks on the axes themselves will + only come back after the motion on that axis finished. + """ + await self.defer_move.set(DeferMoves.ON) + try: + finished_moving = [] + for motor_name, new_setpoint in value.items(): + if new_setpoint is not None and isinstance(new_setpoint, int | float): + axis: Motor = getattr(self, motor_name) + await axis.check_motor_limit( + await axis.user_setpoint.get_value(), new_setpoint + ) + put_completion = await set_and_wait_for_value( + axis.user_setpoint, + new_setpoint, + timeout=self.DEFERRED_MOVE_SET_TIMEOUT, + wait_for_set_completion=False, + ) + finished_moving.append(put_completion) + finally: + await self.defer_move.set(DeferMoves.OFF) + await asyncio.gather(*finished_moving) diff --git a/tests/devices/beamlines/i03/test_beamsize.py b/tests/devices/beamlines/i03/test_beamsize.py index d76db1c5c7..2bb8aca4d8 100644 --- a/tests/devices/beamlines/i03/test_beamsize.py +++ b/tests/devices/beamlines/i03/test_beamsize.py @@ -4,10 +4,10 @@ import pytest from ophyd_async.core import set_mock_value -from dodal.devices.aperturescatterguard import ( +from dodal.devices.beamlines.i03.beamsize import Beamsize +from dodal.devices.mx_phase1.aperturescatterguard import ( ApertureScatterguard, ) -from dodal.devices.beamlines.i03.beamsize import Beamsize @pytest.mark.parametrize( diff --git a/tests/devices/beamlines/i04/test_beamsize.py b/tests/devices/beamlines/i04/test_beamsize.py index 8f2916003d..12aeefb5e1 100644 --- a/tests/devices/beamlines/i04/test_beamsize.py +++ b/tests/devices/beamlines/i04/test_beamsize.py @@ -4,11 +4,11 @@ import pytest from ophyd_async.core import set_mock_value -from dodal.devices.aperturescatterguard import ( - ApertureScatterguard, -) from dodal.devices.beamlines.i04.beamsize import Beamsize from dodal.devices.beamlines.i04.transfocator import Transfocator +from dodal.devices.mx_phase1.aperturescatterguard import ( + ApertureScatterguard, +) @pytest.mark.parametrize( diff --git a/tests/devices/conftest.py b/tests/devices/conftest.py index 33c2a24121..1a8eb0c4ef 100644 --- a/tests/devices/conftest.py +++ b/tests/devices/conftest.py @@ -1,20 +1,20 @@ import asyncio import pytest -from ophyd_async.core import init_devices +from ophyd_async.core import get_mock_put, init_devices from dodal.common.beamlines.beamline_parameters import GDABeamlineParameters -from dodal.devices.aperturescatterguard import ( +from dodal.devices.mx_phase1.aperturescatterguard import ( AperturePosition, ApertureScatterguard, - ApertureValue, - load_positions_from_beamline_parameters, + ApertureScatterguardConfiguration, + load_configuration, ) @pytest.fixture -def aperture_positions() -> dict[ApertureValue, AperturePosition]: - return load_positions_from_beamline_parameters( +def ap_sg_configuration() -> ApertureScatterguardConfiguration: + return load_configuration( GDABeamlineParameters( params={ "miniap_x_LARGE_APERTURE": 2.389, @@ -42,6 +42,8 @@ def aperture_positions() -> dict[ApertureValue, AperturePosition]: "miniap_z_MANUAL_LOAD": -10.0, "sg_x_MANUAL_LOAD": -4.7, "sg_y_MANUAL_LOAD": 1.8, + "miniap_x_SCIN_MOVE": -4.91, + "sg_x_SCIN_MOVE": -4.75, } ) ) @@ -64,7 +66,7 @@ def aperture_tolerances(): @pytest.fixture async def ap_sg( - aperture_positions: dict[ApertureValue, AperturePosition], + ap_sg_configuration: ApertureScatterguardConfiguration, aperture_tolerances: AperturePosition, ) -> ApertureScatterguard: async with init_devices(mock=True): @@ -72,7 +74,7 @@ async def ap_sg( aperture_prefix="-MO-MAPT-01:", scatterguard_prefix="-MO-SCAT-01:", name="test_ap_sg", - loaded_positions=aperture_positions, + config=ap_sg_configuration, tolerances=aperture_tolerances, ) return ap_sg @@ -83,10 +85,15 @@ async def set_to_position( ): aperture_x, aperture_y, aperture_z, scatterguard_x, scatterguard_y = position.values + motors = [ + aperture_scatterguard.aperture.x, + aperture_scatterguard.aperture.y, + aperture_scatterguard.aperture.z, + aperture_scatterguard.scatterguard.x, + aperture_scatterguard.scatterguard.y, + ] await asyncio.gather( - aperture_scatterguard.aperture.x.set(aperture_x), - aperture_scatterguard.aperture.y.set(aperture_y), - aperture_scatterguard.aperture.z.set(aperture_z), - aperture_scatterguard.scatterguard.x.set(scatterguard_x), - aperture_scatterguard.scatterguard.y.set(scatterguard_y), + *[m.set(value) for m, value in zip(motors, position.values, strict=True)] ) + for signal in [m.user_setpoint for m in motors]: + get_mock_put(signal).reset_mock() diff --git a/tests/devices/test_aperture_scatterguard.py b/tests/devices/test_aperture_scatterguard.py index 985ac20827..6e7b8b5ae3 100644 --- a/tests/devices/test_aperture_scatterguard.py +++ b/tests/devices/test_aperture_scatterguard.py @@ -6,6 +6,8 @@ import bluesky.plan_stubs as bps import pytest from bluesky.run_engine import RunEngine +from bluesky.simulators import RunEngineSimulator, assert_message_and_return_remaining +from bluesky.utils import MsgGenerator from ophyd_async.core import ( callback_on_mock_put, get_mock, @@ -13,11 +15,13 @@ set_mock_value, ) -from dodal.devices.aperturescatterguard import ( +from dodal.devices.mx_phase1.aperturescatterguard import ( AperturePosition, ApertureScatterguard, + ApertureScatterguardConfiguration, ApertureValue, InvalidApertureMoveError, + do_with_aperture_scatterguard_in_scin_move_position, ) from tests.devices.conftest import set_to_position @@ -25,9 +29,11 @@ @pytest.fixture async def aperture_in_medium_pos( ap_sg: ApertureScatterguard, - aperture_positions: dict[ApertureValue, AperturePosition], + ap_sg_configuration: ApertureScatterguardConfiguration, ) -> AsyncGenerator[ApertureScatterguard, None]: - await set_to_position(ap_sg, aperture_positions[ApertureValue.MEDIUM]) + await set_to_position( + ap_sg, ap_sg_configuration.aperture_positions[ApertureValue.MEDIUM] + ) set_mock_value(ap_sg.aperture.medium, 1) @@ -175,13 +181,17 @@ async def test_aperture_scatterguard_returns_status_if_within_tolerance( await ap_sg._safe_move_whilst_in_beam(pos) -def set_underlying_motors(ap_sg: ApertureScatterguard, position: AperturePosition): +async def set_underlying_motors( + ap_sg: ApertureScatterguard, + config: ApertureScatterguardConfiguration, + aperture: ApertureValue, +): for motor, pos in zip( get_all_motors(ap_sg), - position.values, + dict(config.aperture_positions[aperture]).values(), strict=False, ): - motor.set(pos) + await motor.set(pos) @pytest.mark.parametrize( @@ -194,7 +204,7 @@ def set_underlying_motors(ap_sg: ApertureScatterguard, position: AperturePositio ) async def test_aperture_positions( ap_sg: ApertureScatterguard, - aperture_positions: dict[ApertureValue, AperturePosition], + ap_sg_configuration: ApertureScatterguardConfiguration, read_pv: str, aperture: ApertureValue, ): @@ -203,19 +213,19 @@ async def test_aperture_positions( assert isinstance(reading, dict) assert ( reading[f"{ap_sg.name}-diameter"]["value"] - == aperture_positions[aperture].diameter + == ap_sg_configuration.aperture_positions[aperture].diameter ) assert reading[f"{ap_sg.name}-selected_aperture"]["value"] == aperture async def test_aperture_positions_robot_load( ap_sg: ApertureScatterguard, - aperture_positions: dict[ApertureValue, AperturePosition], + ap_sg_configuration: ApertureScatterguardConfiguration, ): set_mock_value(ap_sg.aperture.large, 0) set_mock_value(ap_sg.aperture.medium, 0) set_mock_value(ap_sg.aperture.small, 0) - robot_load = aperture_positions[ApertureValue.OUT_OF_BEAM] + robot_load = ap_sg_configuration.aperture_positions[ApertureValue.OUT_OF_BEAM] await ap_sg.aperture.y.set(robot_load.aperture_y) await ap_sg.aperture.z.set(robot_load.aperture_z) reading = await ap_sg.read() @@ -228,9 +238,9 @@ async def test_aperture_positions_robot_load( async def test_aperture_positions_robot_load_within_tolerance( ap_sg: ApertureScatterguard, - aperture_positions: dict[ApertureValue, AperturePosition], + ap_sg_configuration: ApertureScatterguardConfiguration, ): - robot_load = aperture_positions[ApertureValue.OUT_OF_BEAM] + robot_load = ap_sg_configuration.aperture_positions[ApertureValue.OUT_OF_BEAM] robot_load_ap_y = robot_load.aperture_y tolerance = ap_sg._tolerances.aperture_y - 0.001 set_mock_value(ap_sg.aperture.large, 0) @@ -248,9 +258,9 @@ async def test_aperture_positions_robot_load_within_tolerance( async def test_aperture_positions_robot_load_outside_tolerance( ap_sg: ApertureScatterguard, - aperture_positions: dict[ApertureValue, AperturePosition], + ap_sg_configuration: ApertureScatterguardConfiguration, ): - robot_load = aperture_positions[ApertureValue.OUT_OF_BEAM] + robot_load = ap_sg_configuration.aperture_positions[ApertureValue.OUT_OF_BEAM] robot_load_ap_y = robot_load.aperture_y tolerance = ap_sg._tolerances.aperture_y + 0.01 set_mock_value(ap_sg.aperture.large, 0) @@ -264,12 +274,12 @@ async def test_aperture_positions_robot_load_outside_tolerance( async def test_aperture_positions_parked( ap_sg: ApertureScatterguard, - aperture_positions: dict[ApertureValue, AperturePosition], + ap_sg_configuration: ApertureScatterguardConfiguration, ): set_mock_value(ap_sg.aperture.large, 0) set_mock_value(ap_sg.aperture.medium, 0) set_mock_value(ap_sg.aperture.small, 0) - parked = aperture_positions[ApertureValue.PARKED] + parked = ap_sg_configuration.aperture_positions[ApertureValue.PARKED] await ap_sg.aperture.y.set(parked.aperture_y) await ap_sg.aperture.z.set(parked.aperture_z) reading = await ap_sg.read() @@ -280,9 +290,9 @@ async def test_aperture_positions_parked( async def test_aperture_positions_parked_within_tolerance( ap_sg: ApertureScatterguard, - aperture_positions: dict[ApertureValue, AperturePosition], + ap_sg_configuration: ApertureScatterguardConfiguration, ): - parked = aperture_positions[ApertureValue.PARKED] + parked = ap_sg_configuration.aperture_positions[ApertureValue.PARKED] parked_z = parked.aperture_z tolerance = ap_sg._tolerances.aperture_z - 0.01 set_mock_value(ap_sg.aperture.large, 0) @@ -298,9 +308,9 @@ async def test_aperture_positions_parked_within_tolerance( async def test_aperture_positions_parked_outside_tolerance( ap_sg: ApertureScatterguard, - aperture_positions: dict[ApertureValue, AperturePosition], + ap_sg_configuration: ApertureScatterguardConfiguration, ): - parked = aperture_positions[ApertureValue.PARKED] + parked = ap_sg_configuration.aperture_positions[ApertureValue.PARKED] parked_z = parked.aperture_z tolerance = ap_sg._tolerances.aperture_z + 0.01 set_mock_value(ap_sg.aperture.large, 0) @@ -325,14 +335,14 @@ async def test_aperture_positions_unsafe( async def test_given_aperture_not_set_through_device_but_motors_in_position_when_device_read_then_position_returned( aperture_in_medium_pos: ApertureScatterguard, - aperture_positions: dict[ApertureValue, AperturePosition], + ap_sg_configuration: ApertureScatterguardConfiguration, ): reading = await aperture_in_medium_pos.read() assert isinstance(reading, dict) _assert_position_in_reading( reading, ApertureValue.MEDIUM, - aperture_positions[ApertureValue.MEDIUM], + ap_sg_configuration.aperture_positions[ApertureValue.MEDIUM], aperture_in_medium_pos.name, ) @@ -348,14 +358,14 @@ async def test_given_aperture_not_set_through_device_but_motors_in_position_when async def test_when_aperture_set_and_device_read_then_position_returned( aperture: ApertureValue, aperture_in_medium_pos: ApertureScatterguard, - aperture_positions: dict[ApertureValue, AperturePosition], + ap_sg_configuration: ApertureScatterguardConfiguration, ): await aperture_in_medium_pos.selected_aperture.set(aperture) reading = await aperture_in_medium_pos.read() _assert_position_in_reading( reading, aperture, - aperture_positions[aperture], + ap_sg_configuration.aperture_positions[aperture], aperture_in_medium_pos.name, ) @@ -363,11 +373,11 @@ async def test_when_aperture_set_and_device_read_then_position_returned( async def test_ap_sg_in_runengine( aperture_in_medium_pos: ApertureScatterguard, run_engine: RunEngine, - aperture_positions: dict[ApertureValue, AperturePosition], + ap_sg_configuration: ApertureScatterguardConfiguration, ): ap = aperture_in_medium_pos.aperture sg = aperture_in_medium_pos.scatterguard - test_loc = aperture_positions[ApertureValue.SMALL] + test_loc = ap_sg_configuration.aperture_positions[ApertureValue.SMALL] def set_small_readback_pv(value, *args, **kwargs): set_mock_value(ap.small, 1) @@ -413,11 +423,15 @@ async def assert_all_positions_other_than_y( async def test_given_aperture_out_when_new_aperture_selected_then_aperture_not_moved_in( ap_sg: ApertureScatterguard, - aperture_positions: dict[ApertureValue, AperturePosition], + ap_sg_configuration: ApertureScatterguardConfiguration, ): ap = ap_sg.aperture - y_set_point = aperture_positions[ApertureValue.OUT_OF_BEAM].aperture_y - z_set_point = aperture_positions[ApertureValue.OUT_OF_BEAM].aperture_z + y_set_point = ap_sg_configuration.aperture_positions[ + ApertureValue.OUT_OF_BEAM + ].aperture_y + z_set_point = ap_sg_configuration.aperture_positions[ + ApertureValue.OUT_OF_BEAM + ].aperture_z ap.y.set(y_set_point) ap.z.set(z_set_point) set_mock_value(ap.y.user_readback, y_set_point) @@ -427,18 +441,18 @@ async def test_given_aperture_out_when_new_aperture_selected_then_aperture_not_m assert await ap.y.user_setpoint.get_value() == y_set_point await assert_all_positions_other_than_y( - ap_sg, aperture_positions[ApertureValue.SMALL] + ap_sg, ap_sg_configuration.aperture_positions[ApertureValue.SMALL] ) async def test_given_aperture_in_when_new_aperture_set_then_aperture_moved_safely( aperture_in_medium_pos: ApertureScatterguard, - aperture_positions: dict[ApertureValue, AperturePosition], + ap_sg_configuration: ApertureScatterguardConfiguration, ): aperture_in_medium_pos._safe_move_whilst_in_beam = AsyncMock() await aperture_in_medium_pos.prepare(ApertureValue.SMALL) aperture_in_medium_pos._safe_move_whilst_in_beam.assert_called_once_with( - aperture_positions[ApertureValue.SMALL] + ap_sg_configuration.aperture_positions[ApertureValue.SMALL] ) @@ -449,10 +463,10 @@ async def test_given_aperture_in_when_new_aperture_set_then_aperture_moved_safel async def test_given_in_and_aperture_selected_when_move_out_then_only_aperture_y_moves( selected_aperture: ApertureValue, ap_sg: ApertureScatterguard, - aperture_positions: dict[ApertureValue, AperturePosition], + ap_sg_configuration: ApertureScatterguardConfiguration, ): y_setpoint = ap_sg.aperture.y.user_setpoint - aperture_position = aperture_positions[selected_aperture] + aperture_position = ap_sg_configuration.aperture_positions[selected_aperture] await set_to_position(ap_sg, aperture_position) assert await y_setpoint.get_value() == aperture_position.aperture_y @@ -462,7 +476,7 @@ async def test_given_in_and_aperture_selected_when_move_out_then_only_aperture_y assert ( await y_setpoint.get_value() - == aperture_positions[ApertureValue.OUT_OF_BEAM].aperture_y + == ap_sg_configuration.aperture_positions[ApertureValue.OUT_OF_BEAM].aperture_y ) @@ -473,10 +487,10 @@ async def test_given_in_and_aperture_selected_when_move_out_then_only_aperture_y async def test_given_out_and_aperture_selected_when_move_in_then_correct_y_selected( selected_aperture: ApertureValue, ap_sg: ApertureScatterguard, - aperture_positions: dict[ApertureValue, AperturePosition], + ap_sg_configuration: ApertureScatterguardConfiguration, ): y_setpoint = ap_sg.aperture.y.user_setpoint - aperture_position = aperture_positions[selected_aperture] + aperture_position = ap_sg_configuration.aperture_positions[selected_aperture] set_mock_value(ap_sg.aperture.z.user_readback, aperture_position.aperture_z) await ap_sg.selected_aperture.set(ApertureValue.OUT_OF_BEAM) @@ -499,9 +513,9 @@ async def test_given_out_and_aperture_selected_when_move_in_then_correct_y_selec async def test_given_parked_and_aperture_selected_when_move_in_then_z_moved_out_first( selected_aperture: ApertureValue, ap_sg: ApertureScatterguard, - aperture_positions: dict[ApertureValue, AperturePosition], + ap_sg_configuration: ApertureScatterguardConfiguration, ): - parked_position = aperture_positions[ApertureValue.PARKED] + parked_position = ap_sg_configuration.aperture_positions[ApertureValue.PARKED] set_mock_value(ap_sg.aperture.y.user_readback, parked_position.aperture_y) set_mock_value(ap_sg.aperture.z.user_readback, parked_position.aperture_z) @@ -514,7 +528,7 @@ async def test_given_parked_and_aperture_selected_when_move_in_then_z_moved_out_ selected_aperture, wait=True ) assert parent_mock.method_calls[1] == call.aperture.z.user_setpoint.put( - aperture_positions[selected_aperture].aperture_z, wait=True + ap_sg_configuration.aperture_positions[selected_aperture].aperture_z, wait=True ) @@ -530,9 +544,9 @@ async def test_given_parked_and_aperture_selected_when_move_in_then_z_moved_out_ async def test_given_parked_and_ap_sg_prepared_when_move_in_then_z_moved_out_first( selected_aperture: ApertureValue, ap_sg: ApertureScatterguard, - aperture_positions: dict[ApertureValue, AperturePosition], + ap_sg_configuration: ApertureScatterguardConfiguration, ): - parked_position = aperture_positions[ApertureValue.PARKED] + parked_position = ap_sg_configuration.aperture_positions[ApertureValue.PARKED] set_mock_value(ap_sg.aperture.y.user_readback, parked_position.aperture_y) set_mock_value(ap_sg.aperture.z.user_readback, parked_position.aperture_z) @@ -545,7 +559,7 @@ async def test_given_parked_and_ap_sg_prepared_when_move_in_then_z_moved_out_fir selected_aperture, wait=True ) assert parent_mock.method_calls[1] == call.aperture.z.user_setpoint.put( - aperture_positions[selected_aperture].aperture_z, wait=True + ap_sg_configuration.aperture_positions[selected_aperture].aperture_z, wait=True ) @@ -569,3 +583,145 @@ async def set_motor_moving(value, *args, **kwargs): with pytest.raises(InvalidApertureMoveError): await aperture_in_medium_pos.selected_aperture.set(ApertureValue.SMALL) + + +async def test_get_scin_move_position_returns_expected( + aperture_in_medium_pos: ApertureScatterguard, + ap_sg_configuration: ApertureScatterguardConfiguration, + run_engine: RunEngine, +): + ap_sg = aperture_in_medium_pos + + positions = ap_sg.get_scin_move_position() + assert ( + positions[ap_sg.aperture.x] == ap_sg_configuration.scintillator_move_aperture_x + ) + assert ( + positions[ap_sg.scatterguard.x] + == ap_sg_configuration.scintillator_move_scatterguard_x + ) + + +def test_do_with_aperture_scatterguard_in_scin_move_position_moves_and_restores_ap_sg( + sim_run_engine: RunEngineSimulator, + aperture_in_medium_pos: ApertureScatterguard, +): + ap_sg = aperture_in_medium_pos + + def my_plan() -> MsgGenerator: + yield from bps.sleep(1.0) + + sim_run_engine.add_read_handler_for(ap_sg.aperture.x.user_readback, 2.384) + sim_run_engine.add_read_handler_for(ap_sg.scatterguard.x.user_readback, 5.285) + msgs = sim_run_engine.simulate_plan( + do_with_aperture_scatterguard_in_scin_move_position(ap_sg, my_plan) + ) + + msgs = assert_message_and_return_remaining( + msgs, + lambda msg: msg.command == "set" + and msg.obj is ap_sg.aperture.x + and msg.args[0] == -4.91, + ) + msgs = assert_message_and_return_remaining( + msgs, + lambda msg: msg.command == "set" + and msg.obj is ap_sg.scatterguard.x + and msg.args[0] == -4.75, + ) + msgs = assert_message_and_return_remaining(msgs, lambda msg: msg.command == "sleep") + msgs = assert_message_and_return_remaining( + msgs, + lambda msg: msg.command == "set" + and msg.obj is ap_sg.aperture.x + and msg.args[0] == 2.384, + ) + assert_message_and_return_remaining( + msgs, + lambda msg: msg.command == "set" + and msg.obj is ap_sg.scatterguard.x + and msg.args[0] == 5.285, + ) + + +def test_do_with_aperture_scatterguard_in_scin_move_position_does_not_restore_on_inner_failure( + run_engine: RunEngine, + aperture_in_medium_pos: ApertureScatterguard, +): + ap_sg = aperture_in_medium_pos + + def my_plan() -> MsgGenerator: + raise AssertionError("simulated exception") + + with pytest.raises(AssertionError): + run_engine(do_with_aperture_scatterguard_in_scin_move_position(ap_sg, my_plan)) + + get_mock_put(ap_sg.aperture.x.user_setpoint).assert_called_once_with( + -4.91, wait=True + ) + get_mock_put(ap_sg.scatterguard.x.user_setpoint).assert_called_once_with( + -4.75, wait=True + ) + + +async def test_set_scin_move_position_raises_not_implemented( + aperture_in_medium_pos: ApertureScatterguard, +): + aperture_scatterguard = aperture_in_medium_pos + with pytest.raises( + NotImplementedError, match="The SCIN_MOVE position cannot be set directly" + ): + await aperture_scatterguard.selected_aperture.set(ApertureValue.SCIN_MOVE) + + +@pytest.mark.parametrize( + "ap_x_offset, sg_x_offset, expected_value", + [ + [-0.0041, 0, ApertureValue.MEDIUM], + [-0.0039, 0, ApertureValue.SCIN_MOVE], + [0.0041, 0, ApertureValue.MEDIUM], + [0, -0.09, ApertureValue.SCIN_MOVE], + [0, 0.09, ApertureValue.SCIN_MOVE], + [0, 0.11, ApertureValue.MEDIUM], + [0, -0.11, ApertureValue.MEDIUM], + ], +) +async def test_get_selected_position_returns_scin_move( + ap_x_offset: float, + sg_x_offset: float, + expected_value: ApertureValue | None, + aperture_in_medium_pos: ApertureScatterguard, + ap_sg_configuration: ApertureScatterguardConfiguration, +): + aperture_scatterguard = aperture_in_medium_pos + set_mock_value( + aperture_scatterguard.aperture.x.user_readback, + ap_sg_configuration.scintillator_move_aperture_x + ap_x_offset, + ) + + set_mock_value( + aperture_scatterguard.scatterguard.x.user_readback, + ap_sg_configuration.scintillator_move_scatterguard_x + sg_x_offset, + ) + + assert await aperture_scatterguard.selected_aperture.get_value() == expected_value + + +async def test_set_selected_position_raises_not_implemented_if_currently_in_scin_move_position( + aperture_in_medium_pos: ApertureScatterguard, + ap_sg_configuration: ApertureScatterguardConfiguration, +): + aperture_scatterguard = aperture_in_medium_pos + set_mock_value( + aperture_scatterguard.aperture.x.user_readback, + ap_sg_configuration.scintillator_move_aperture_x, + ) + set_mock_value( + aperture_scatterguard.scatterguard.x.user_readback, + ap_sg_configuration.scintillator_move_scatterguard_x, + ) + with pytest.raises( + NotImplementedError, + match="Cannot move aperture-scatterguard while in SCIN_MOVE position", + ): + await aperture_scatterguard.selected_aperture.set(ApertureValue.SMALL) diff --git a/tests/devices/test_scintillator.py b/tests/devices/test_scintillator.py index ce1579bc1b..00a693656c 100644 --- a/tests/devices/test_scintillator.py +++ b/tests/devices/test_scintillator.py @@ -1,12 +1,22 @@ -from unittest.mock import AsyncMock, MagicMock +from contextlib import nullcontext +from unittest.mock import MagicMock, call import pytest -from ophyd_async.core import get_mock_put, init_devices +from bluesky import FailedStatus, RunEngine +from ophyd_async.core import Reference, get_mock_put, init_devices, set_mock_value from ophyd_async.testing import assert_value from dodal.common.beamlines.beamline_parameters import GDABeamlineParameters -from dodal.devices.aperturescatterguard import ApertureScatterguard, ApertureValue -from dodal.devices.scintillator import InOut, Scintillator +from dodal.devices.mx_phase1.aperturescatterguard import ( + ApertureScatterguard, + ApertureValue, +) +from dodal.devices.mx_phase1.beamstop import Beamstop +from dodal.devices.mx_phase1.scintillator import ( + InOut, + Scintillator, + move_scintillator_safely, +) @pytest.fixture @@ -19,27 +29,89 @@ def mock_beamline_parameters() -> GDABeamlineParameters: "scin_z_SCIN_OUT": 0.1, "scin_y_tolerance": 0.1, "scin_z_tolerance": 0.12, + "in_beam_x_STANDARD": 1.21, + "in_beam_y_STANDARD": 45.4, + "in_beam_z_STANDARD": 30.0, + "bs_x_tolerance": 0.02, + "bs_y_tolerance": 0.005, + "bs_z_tolerance": 0.3, } ) +async def move_aperture_scatterguard_and_reset_mocks( + aperture_scatterguard: ApertureScatterguard, pos: ApertureValue +): + motor_positions = aperture_scatterguard._config.aperture_positions[pos] + for motor, value in zip( + [ + aperture_scatterguard.aperture.x, + aperture_scatterguard.aperture.y, + aperture_scatterguard.aperture.z, + aperture_scatterguard.scatterguard.x, + aperture_scatterguard.scatterguard.y, + ], + [ + motor_positions.aperture_x, + motor_positions.aperture_y, + motor_positions.aperture_z, + motor_positions.scatterguard_x, + motor_positions.scatterguard_y, + ], + strict=True, + ): + set_mock_value(motor.user_readback, value) + get_mock_put(motor.user_readback).reset_mock() + + set_mock_value( + aperture_scatterguard.aperture.large, 1 if pos == ApertureValue.LARGE else 0 + ) + set_mock_value( + aperture_scatterguard.aperture.medium, 1 if pos == ApertureValue.MEDIUM else 0 + ) + set_mock_value( + aperture_scatterguard.aperture.small, 1 if pos == ApertureValue.SMALL else 0 + ) + + +@pytest.fixture +async def beamstop(mock_beamline_parameters) -> Beamstop: + beamstop = Beamstop("-MO-BS-01:", mock_beamline_parameters, name="beamstop") + async with init_devices(mock=True): + beamstop = Beamstop("", mock_beamline_parameters) + + set_mock_value( + beamstop.x_mm.user_readback, mock_beamline_parameters["in_beam_x_STANDARD"] + ) + set_mock_value( + beamstop.y_mm.user_readback, mock_beamline_parameters["in_beam_y_STANDARD"] + ) + set_mock_value( + beamstop.z_mm.user_readback, mock_beamline_parameters["in_beam_z_STANDARD"] + ) + return beamstop + + @pytest.fixture async def scintillator_and_ap_sg( mock_beamline_parameters: GDABeamlineParameters, -) -> tuple[Scintillator, MagicMock]: + ap_sg: ApertureScatterguard, + beamstop: Beamstop, +) -> tuple[Scintillator, ApertureScatterguard]: + # ap_sg.get_scin_move_position = MagicMock() async with init_devices(mock=True): - mock_ap_sg = MagicMock() - mock_ap_sg.return_value.selected_aperture.set = AsyncMock() - mock_ap_sg.return_value.selected_aperture.get_value = AsyncMock() scintillator = Scintillator( prefix="", name="test_scin", - aperture_scatterguard=mock_ap_sg, beamline_parameters=mock_beamline_parameters, + aperture_scatterguard=Reference(ap_sg), + beamstop=Reference(beamstop), ) + set_mock_value(ap_sg.aperture.x.user_readback, 1.0) + set_mock_value(ap_sg.scatterguard.x.user_readback, 2.0) await scintillator.y_mm.set(5) await scintillator.z_mm.set(5) - return scintillator, mock_ap_sg + return scintillator, ap_sg @pytest.mark.parametrize( @@ -65,21 +137,27 @@ async def test_given_at_positions_when_position_read_then_returns_expected( assert in_out == expected_position -async def test_when_set_to_unknown_position_then_error_raised( +def test_when_set_to_unknown_position_then_error_raised( scintillator_and_ap_sg: tuple[Scintillator, ApertureScatterguard], + beamstop: Beamstop, + run_engine: RunEngine, ): - scintillator, _ = scintillator_and_ap_sg + scintillator, ap_sg = scintillator_and_ap_sg + set_mock_value(scintillator.y_mm.user_readback, 100.855) + set_mock_value(scintillator.z_mm.user_readback, 101.5115) with pytest.raises(ValueError): - await scintillator.selected_pos.set(InOut.UNKNOWN) + run_engine(move_scintillator_safely(scintillator, InOut.UNKNOWN)) async def test_given_aperture_scatterguard_parked_when_set_to_out_position_then_returns_expected( + run_engine: RunEngine, scintillator_and_ap_sg: tuple[Scintillator, ApertureScatterguard], + beamstop: Beamstop, ): scintillator, ap_sg = scintillator_and_ap_sg - ap_sg.return_value.selected_aperture.get_value.return_value = ApertureValue.PARKED # type: ignore + await move_aperture_scatterguard_and_reset_mocks(ap_sg, ApertureValue.PARKED) - await scintillator.selected_pos.set(InOut.OUT) + run_engine(move_scintillator_safely(scintillator, InOut.OUT)) await assert_value(scintillator.y_mm.user_setpoint, -0.02) await assert_value(scintillator.z_mm.user_setpoint, 0.1) @@ -87,28 +165,18 @@ async def test_given_aperture_scatterguard_parked_when_set_to_out_position_then_ async def test_given_aperture_scatterguard_parked_when_set_to_in_position_then_returns_expected( scintillator_and_ap_sg: tuple[Scintillator, ApertureScatterguard], + run_engine: RunEngine, + beamstop: Beamstop, ): scintillator, ap_sg = scintillator_and_ap_sg - ap_sg.return_value.selected_aperture.get_value.return_value = ApertureValue.PARKED # type: ignore + await move_aperture_scatterguard_and_reset_mocks(ap_sg, ApertureValue.PARKED) - await scintillator.selected_pos.set(InOut.IN) + run_engine(move_scintillator_safely(scintillator, InOut.IN)) await assert_value(scintillator.y_mm.user_setpoint, 100.855) await assert_value(scintillator.z_mm.user_setpoint, 101.5115) -@pytest.mark.parametrize("scint_pos", [InOut.OUT, InOut.IN]) -async def test_given_aperture_scatterguard_not_parked_when_set_to_in_or_out_position_then_exception_raised( - scintillator_and_ap_sg: tuple[Scintillator, ApertureScatterguard], scint_pos -): - for position in ApertureValue: - if position != ApertureValue.PARKED: - scintillator, ap_sg = scintillator_and_ap_sg - ap_sg.return_value.selected_aperture.get_value.return_value = position # type: ignore - with pytest.raises(ValueError): - await scintillator.selected_pos.set(scint_pos) - - @pytest.mark.parametrize( "y, z, expected_position", [ @@ -118,19 +186,144 @@ async def test_given_aperture_scatterguard_not_parked_when_set_to_in_or_out_posi ) async def test_given_scintillator_already_out_when_moved_in_or_out_then_does_nothing( scintillator_and_ap_sg: tuple[Scintillator, ApertureScatterguard], + beamstop: Beamstop, expected_position, y, z, + run_engine: RunEngine, ): scintillator, ap_sg = scintillator_and_ap_sg + set_mock_value(beamstop.x_mm.user_readback, 25) # unknown position + await move_aperture_scatterguard_and_reset_mocks(ap_sg, ApertureValue.LARGE) await scintillator.y_mm.set(y) await scintillator.z_mm.set(z) get_mock_put(scintillator.y_mm.user_setpoint).reset_mock() get_mock_put(scintillator.z_mm.user_setpoint).reset_mock() - ap_sg.return_value.selected_aperture.get_value.return_value = ApertureValue.LARGE # type: ignore - await scintillator.selected_pos.set(expected_position) + run_engine(move_scintillator_safely(scintillator, expected_position)) get_mock_put(scintillator.y_mm.user_setpoint).assert_not_called() get_mock_put(scintillator.z_mm.user_setpoint).assert_not_called() + get_mock_put(ap_sg.aperture.x.user_setpoint).assert_not_called() + get_mock_put(ap_sg.scatterguard.x.user_setpoint).assert_not_called() + + +@pytest.mark.parametrize( + "beamstop_y_position, expected_good", + [ + [45.4, True], # in beam + [43.4, True], # Out of beam + [25, False], # Unknown position + ], +) +async def test_beamstop_check_in_known_good_position( + scintillator_and_ap_sg: tuple[Scintillator, ApertureScatterguard], + beamstop: Beamstop, + beamstop_y_position: float, + expected_good: bool, + run_engine: RunEngine, +): + set_mock_value( + beamstop.y_mm.user_readback, beamstop_y_position + ) # Beamstop out of beam + scintillator, ap_sg = scintillator_and_ap_sg + await move_aperture_scatterguard_and_reset_mocks(ap_sg, ApertureValue.MEDIUM) + with ( + pytest.raises( + FailedStatus, match="Scintillator cannot be moved due to beamstop position" + ) + if not expected_good + else nullcontext() + ): + run_engine(move_scintillator_safely(scintillator, InOut.OUT)) + + +@pytest.mark.parametrize( + "initial_y, initial_z, final_y, final_z, swap_order, final_position", + [ + [100.855, 101.5115, -0.02, 0.1, False, InOut.OUT], + [-0.02, 0.1, 100.855, 101.5115, True, InOut.IN], + ], +) +async def test_move_scintillator_moves_ap_sg_to_scin_move_and_back( + scintillator_and_ap_sg: tuple[Scintillator, ApertureScatterguard], + initial_y: float, + initial_z: float, + final_y: float, + final_z: float, + swap_order: bool, + final_position: InOut, + beamstop: Beamstop, + run_engine: RunEngine, +): + scintillator, ap_sg = scintillator_and_ap_sg + set_mock_value(scintillator.y_mm.user_readback, initial_y) + set_mock_value(scintillator.z_mm.user_readback, initial_z) + + parent = MagicMock() + parent.aperture.attach_mock(get_mock_put(ap_sg.aperture.x.user_setpoint), "x") + parent.aperture.attach_mock(get_mock_put(ap_sg.aperture.y.user_setpoint), "y") + parent.aperture.attach_mock(get_mock_put(ap_sg.aperture.z.user_setpoint), "z") + parent.scatterguard.attach_mock( + get_mock_put(ap_sg.scatterguard.x.user_setpoint), "x" + ) + parent.scatterguard.attach_mock( + get_mock_put(ap_sg.scatterguard.y.user_setpoint), "y" + ) + parent.scintillator.attach_mock( + get_mock_put(scintillator.y_mm.user_setpoint), "y_mm" + ) + parent.scintillator.attach_mock( + get_mock_put(scintillator.z_mm.user_setpoint), "z_mm" + ) + run_engine(move_scintillator_safely(scintillator, final_position)) + + expected_scintillator_move = [ + call.scintillator.y_mm(final_y, wait=True), + call.scintillator.z_mm(final_z, wait=True), + ] + if swap_order: + expected_scintillator_move = expected_scintillator_move[::-1] + expected_calls = ( + [ + call.aperture.x(ap_sg._config.scintillator_move_aperture_x, wait=True), + call.scatterguard.x( + ap_sg._config.scintillator_move_scatterguard_x, wait=True + ), + ] + + expected_scintillator_move + + [ + call.aperture.x(1.0, wait=True), + call.scatterguard.x(2.0, wait=True), + ] + ) + parent.assert_has_calls(expected_calls) + + +async def test_scintillator_set_raises_if_aperture_scatterguard_not_in_scin_move_position( + scintillator_and_ap_sg: tuple[Scintillator, ApertureScatterguard], +): + scintillator, ap_sg = scintillator_and_ap_sg + await move_aperture_scatterguard_and_reset_mocks(ap_sg, ApertureValue.MEDIUM) + with pytest.raises( + ValueError, + match="Scintillator cannot be moved while aperture-scatterguard not in SCIN_MOVE position", + ): + await scintillator.selected_pos.set(InOut.OUT) + + +async def test_scintillator_set_raises_if_beamstop_not_in_good_position( + scintillator_and_ap_sg: tuple[Scintillator, ApertureScatterguard], + beamstop: Beamstop, +): + scintillator, ap_sg = scintillator_and_ap_sg + set_mock_value( + beamstop.y_mm.user_readback, + 25, # Unknown position + ) + scintillator, ap_sg = scintillator_and_ap_sg + with pytest.raises( + ValueError, match="Scintillator cannot be moved due to beamstop position" + ): + await scintillator.selected_pos.set(InOut.OUT)