From a490228de89dd13ecb5f0beb312c7c6ce423e944 Mon Sep 17 00:00:00 2001 From: Stanislaw Malinowski Date: Fri, 19 Jan 2024 10:29:03 +0000 Subject: [PATCH 001/134] added read and write name to the scatterguard --- src/dodal/devices/aperturescatterguard.py | 57 +++++++++++++++++++++-- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/src/dodal/devices/aperturescatterguard.py b/src/dodal/devices/aperturescatterguard.py index 911818735d..1be4fbec53 100644 --- a/src/dodal/devices/aperturescatterguard.py +++ b/src/dodal/devices/aperturescatterguard.py @@ -1,6 +1,8 @@ from dataclasses import dataclass +from enum import Enum from typing import Optional, Tuple +import numpy as np from ophyd import Component as Cpt from ophyd.status import AndStatus, Status @@ -14,17 +16,53 @@ class InvalidApertureMove(Exception): pass +class PositionName(str, Enum): + LARGE = "large" + MEDIUM = "medium" + SMALL = "small" + INVALID = "invalid" + ROBOT_LOAD = "robot_load" + + @dataclass class AperturePositions: """Holds tuples (miniap_x, miniap_y, miniap_z, scatterguard_x, scatterguard_y) representing the motor positions needed to select a particular aperture size. """ + # one micrometre tolerance + TOLERANCE_MM: float = 0.001 + LARGE: Tuple[float, float, float, float, float] MEDIUM: Tuple[float, float, float, float, float] SMALL: Tuple[float, float, float, float, float] ROBOT_LOAD: Tuple[float, float, float, float, float] + def _distance_check( + self, + target: Tuple[float, float, float, float, float], + present: Tuple[float, float, float, float, float], + ) -> bool: + return np.allclose(present, target, self.tolerance) + + @classmethod + def match_to_name( + self, present_position: Tuple[float, float, float, float, float] + ) -> PositionName: + assert self.aperture_positions.position_valid(present_position) + positions = [ + (PositionName.LARGE, self.LARGE), + (PositionName.MEDIUM, self.MEDIUM), + (PositionName.SMALL, self.SMALL), + (PositionName.ROBOT_LOAD, self.ROBOT_LOAD), + ] + + for position_name, position_constant in positions: + if self._distance_check(position_constant, present_position): + return position_name + + return PositionName.INVALID + @classmethod def from_gda_beamline_params(cls, params): return cls( @@ -58,31 +96,42 @@ def from_gda_beamline_params(cls, params): ), ) - def position_valid(self, pos: Tuple[float, float, float, float, float]): + def position_valid(self, pos: Tuple[float, float, float, float, float]) -> bool: """ Check if argument 'pos' is a valid position in this AperturePositions object. """ - if pos not in [self.LARGE, self.MEDIUM, self.SMALL, self.ROBOT_LOAD]: - return False - return True + options = [self.LARGE, self.MEDIUM, self.SMALL, self.ROBOT_LOAD] + return pos in options + + +AperturePosition5d = Tuple[float, float, float, float, float] class ApertureScatterguard(InfoLoggingDevice): aperture = Cpt(Aperture, "-MO-MAPT-01:") scatterguard = Cpt(Scatterguard, "-MO-SCAT-01:") aperture_positions: Optional[AperturePositions] = None + aperture_name: PositionName = PositionName.INVALID APERTURE_Z_TOLERANCE = 3 # Number of MRES steps def load_aperture_positions(self, positions: AperturePositions): LOGGER.info(f"{self.name} loaded in {positions}") self.aperture_positions = positions + def read_name(self) -> PositionName: + return self.aperture_name + + def _update_name(self, pos: Tuple[float, float, float, float, float]) -> None: + name = AperturePositions.match_to_name(pos) + self.aperture_name = name + def set(self, pos: Tuple[float, float, float, float, float]) -> AndStatus: try: assert isinstance(self.aperture_positions, AperturePositions) assert self.aperture_positions.position_valid(pos) except AssertionError as e: raise InvalidApertureMove(repr(e)) + self._update_name(pos) return self._safe_move_within_datacollection_range(*pos) def _safe_move_within_datacollection_range( From ef35c352aca1e2bd4c24783d9322ce7795efce7d Mon Sep 17 00:00:00 2001 From: Stanislaw Malinowski Date: Fri, 19 Jan 2024 13:48:27 +0000 Subject: [PATCH 002/134] updated the aperture readouts --- src/dodal/devices/aperturescatterguard.py | 33 ++++++++++++----------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/src/dodal/devices/aperturescatterguard.py b/src/dodal/devices/aperturescatterguard.py index 1be4fbec53..3f168375b6 100644 --- a/src/dodal/devices/aperturescatterguard.py +++ b/src/dodal/devices/aperturescatterguard.py @@ -30,24 +30,24 @@ class AperturePositions: representing the motor positions needed to select a particular aperture size. """ + LARGE: Aperture5d + MEDIUM: Aperture5d + SMALL: Aperture5d + ROBOT_LOAD: Aperture5d + # one micrometre tolerance TOLERANCE_MM: float = 0.001 - LARGE: Tuple[float, float, float, float, float] - MEDIUM: Tuple[float, float, float, float, float] - SMALL: Tuple[float, float, float, float, float] - ROBOT_LOAD: Tuple[float, float, float, float, float] - def _distance_check( self, - target: Tuple[float, float, float, float, float], - present: Tuple[float, float, float, float, float], + target: Aperture5d, + present: Aperture5d, ) -> bool: return np.allclose(present, target, self.tolerance) @classmethod def match_to_name( - self, present_position: Tuple[float, float, float, float, float] + self, present_position: Aperture5d ) -> PositionName: assert self.aperture_positions.position_valid(present_position) positions = [ @@ -96,7 +96,7 @@ def from_gda_beamline_params(cls, params): ), ) - def position_valid(self, pos: Tuple[float, float, float, float, float]) -> bool: + def position_valid(self, pos: Aperture5d) -> bool: """ Check if argument 'pos' is a valid position in this AperturePositions object. """ @@ -104,8 +104,8 @@ def position_valid(self, pos: Tuple[float, float, float, float, float]) -> bool: return pos in options -AperturePosition5d = Tuple[float, float, float, float, float] +Aperture5d = Tuple[float, float, float, float, float] class ApertureScatterguard(InfoLoggingDevice): aperture = Cpt(Aperture, "-MO-MAPT-01:") @@ -118,14 +118,11 @@ def load_aperture_positions(self, positions: AperturePositions): LOGGER.info(f"{self.name} loaded in {positions}") self.aperture_positions = positions - def read_name(self) -> PositionName: - return self.aperture_name - - def _update_name(self, pos: Tuple[float, float, float, float, float]) -> None: + def _update_name(self, pos: Aperture5d) -> None: name = AperturePositions.match_to_name(pos) self.aperture_name = name - def set(self, pos: Tuple[float, float, float, float, float]) -> AndStatus: + def set(self, pos: Aperture5d) -> AndStatus: try: assert isinstance(self.aperture_positions, AperturePositions) assert self.aperture_positions.position_valid(pos) @@ -151,6 +148,7 @@ def _safe_move_within_datacollection_range( # in a datacollection position is to see if we are "ready" (DMOV) and the target # position is correct ap_z_in_position = self.aperture.z.motor_done_move.get() + # CASE still moving if not ap_z_in_position: status: Status = Status(obj=self) status.set_exception( @@ -162,6 +160,7 @@ def _safe_move_within_datacollection_range( return status current_ap_z = self.aperture.z.user_setpoint.get() tolerance = self.APERTURE_Z_TOLERANCE * self.aperture.z.motor_resolution.get() + # CASE invalid target position if abs(current_ap_z - aperture_z) > tolerance: raise InvalidApertureMove( "ApertureScatterguard safe move is not yet defined for positions " @@ -169,6 +168,7 @@ def _safe_move_within_datacollection_range( f"Current aperture z ({current_ap_z}), outside of tolerance ({tolerance}) from target ({aperture_z})." ) + # CASE moves along Z current_ap_y = self.aperture.y.user_readback.get() if aperture_y > current_ap_y: sg_status: AndStatus = self.scatterguard.x.set( @@ -182,7 +182,8 @@ def _safe_move_within_datacollection_range( & self.aperture.z.set(aperture_z) ) return final_status - + + # CASE does not move along Z else: ap_status: AndStatus = ( self.aperture.x.set(aperture_x) From af0a2106b431a7e1afd3ff90cffb788875d22635 Mon Sep 17 00:00:00 2001 From: Stanislaw Malinowski Date: Fri, 19 Jan 2024 14:44:48 +0000 Subject: [PATCH 003/134] attempting lint fix --- src/dodal/devices/aperturescatterguard.py | 2 +- src/dodal/devices/oav/oav_detector.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/dodal/devices/aperturescatterguard.py b/src/dodal/devices/aperturescatterguard.py index 3f168375b6..34544cd243 100644 --- a/src/dodal/devices/aperturescatterguard.py +++ b/src/dodal/devices/aperturescatterguard.py @@ -11,6 +11,7 @@ from dodal.devices.scatterguard import Scatterguard from dodal.log import LOGGER +Aperture5d = Tuple[float, float, float, float, float] class InvalidApertureMove(Exception): pass @@ -105,7 +106,6 @@ def position_valid(self, pos: Aperture5d) -> bool: -Aperture5d = Tuple[float, float, float, float, float] class ApertureScatterguard(InfoLoggingDevice): aperture = Cpt(Aperture, "-MO-MAPT-01:") diff --git a/src/dodal/devices/oav/oav_detector.py b/src/dodal/devices/oav/oav_detector.py index 575194bf74..3a15b1fb78 100644 --- a/src/dodal/devices/oav/oav_detector.py +++ b/src/dodal/devices/oav/oav_detector.py @@ -108,7 +108,7 @@ def update_on_zoom(self, value, xsize, ysize, *args, **kwargs): zoom, xsize, ysize ) - def load_microns_per_pixel(self, zoom: float, xsize: int, ysize: int): + def load_microns_per_pixel(self, zoom: float, xsize: int, ysize: int) -> None: """ Loads the microns per x pixel and y pixel for a given zoom level. These are currently generated by GDA, though hyperion could generate them in future. From 6830b3e9258e187657a1a83e4be40e7da078d248 Mon Sep 17 00:00:00 2001 From: Stanislaw Malinowski Date: Mon, 22 Jan 2024 08:53:11 +0000 Subject: [PATCH 004/134] trying to fix lint --- src/dodal/devices/aperturescatterguard.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/dodal/devices/aperturescatterguard.py b/src/dodal/devices/aperturescatterguard.py index 34544cd243..543c31a51b 100644 --- a/src/dodal/devices/aperturescatterguard.py +++ b/src/dodal/devices/aperturescatterguard.py @@ -1,6 +1,6 @@ from dataclasses import dataclass from enum import Enum -from typing import Optional, Tuple +from typing import List, Literal, Optional, Tuple import numpy as np from ophyd import Component as Cpt @@ -13,6 +13,7 @@ Aperture5d = Tuple[float, float, float, float, float] + class InvalidApertureMove(Exception): pass @@ -47,11 +48,9 @@ def _distance_check( return np.allclose(present, target, self.tolerance) @classmethod - def match_to_name( - self, present_position: Aperture5d - ) -> PositionName: + def match_to_name(self, present_position: Aperture5d) -> PositionName: assert self.aperture_positions.position_valid(present_position) - positions = [ + positions: List[(Literal, Aperture5d)] = [ (PositionName.LARGE, self.LARGE), (PositionName.MEDIUM, self.MEDIUM), (PositionName.SMALL, self.SMALL), @@ -105,8 +104,6 @@ def position_valid(self, pos: Aperture5d) -> bool: return pos in options - - class ApertureScatterguard(InfoLoggingDevice): aperture = Cpt(Aperture, "-MO-MAPT-01:") scatterguard = Cpt(Scatterguard, "-MO-SCAT-01:") @@ -182,7 +179,7 @@ def _safe_move_within_datacollection_range( & self.aperture.z.set(aperture_z) ) return final_status - + # CASE does not move along Z else: ap_status: AndStatus = ( From 8607048cb2d7c0f94980e46a675cfbb76bb96483 Mon Sep 17 00:00:00 2001 From: Stanislaw Malinowski Date: Mon, 22 Jan 2024 09:47:07 +0000 Subject: [PATCH 005/134] try to set up dataclasses and classmethods right --- src/dodal/devices/aperturescatterguard.py | 4 ++-- tests/devices/unit_tests/test_aperture_scatterguard.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/dodal/devices/aperturescatterguard.py b/src/dodal/devices/aperturescatterguard.py index 543c31a51b..d5f70ba083 100644 --- a/src/dodal/devices/aperturescatterguard.py +++ b/src/dodal/devices/aperturescatterguard.py @@ -49,7 +49,7 @@ def _distance_check( @classmethod def match_to_name(self, present_position: Aperture5d) -> PositionName: - assert self.aperture_positions.position_valid(present_position) + assert AperturePositions.position_valid(present_position) positions: List[(Literal, Aperture5d)] = [ (PositionName.LARGE, self.LARGE), (PositionName.MEDIUM, self.MEDIUM), @@ -58,7 +58,7 @@ def match_to_name(self, present_position: Aperture5d) -> PositionName: ] for position_name, position_constant in positions: - if self._distance_check(position_constant, present_position): + if AperturePositions._distance_check(position_constant, present_position): return position_name return PositionName.INVALID diff --git a/tests/devices/unit_tests/test_aperture_scatterguard.py b/tests/devices/unit_tests/test_aperture_scatterguard.py index 84a71b0c31..8706308bfe 100644 --- a/tests/devices/unit_tests/test_aperture_scatterguard.py +++ b/tests/devices/unit_tests/test_aperture_scatterguard.py @@ -104,12 +104,12 @@ def test_aperture_scatterguard_rejects_unknown_position( def test_aperture_scatterguard_select_bottom_moves_sg_down_then_assembly_up( - aperture_positions, aperture_in_medium_pos + aperture_in_medium_pos, ): aperture_scatterguard = aperture_in_medium_pos call_logger = install_logger_for_aperture_and_scatterguard(aperture_scatterguard) - aperture_scatterguard.set(aperture_positions.SMALL) + aperture_scatterguard.set(AperturePositions.SMALL) actual_calls = call_logger.mock_calls expected_calls = [ @@ -125,12 +125,12 @@ def test_aperture_scatterguard_select_bottom_moves_sg_down_then_assembly_up( def test_aperture_scatterguard_select_top_moves_assembly_down_then_sg_up( - aperture_positions, aperture_in_medium_pos + aperture_in_medium_pos: ApertureScatterguard ): aperture_scatterguard = aperture_in_medium_pos call_logger = install_logger_for_aperture_and_scatterguard(aperture_scatterguard) - aperture_scatterguard.set(aperture_positions.LARGE) + aperture_scatterguard.set(AperturePositions.LARGE) actual_calls = call_logger.mock_calls expected_calls = [ From 23aaaff580ab71d050c3f65ca41e50953a71505d Mon Sep 17 00:00:00 2001 From: Stanislaw Malinowski Date: Mon, 22 Jan 2024 14:53:52 +0000 Subject: [PATCH 006/134] partial fix of the tests --- src/dodal/devices/aperturescatterguard.py | 6 ++++-- tests/devices/unit_tests/test_aperture_scatterguard.py | 9 +++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/dodal/devices/aperturescatterguard.py b/src/dodal/devices/aperturescatterguard.py index d5f70ba083..cdb3e9246d 100644 --- a/src/dodal/devices/aperturescatterguard.py +++ b/src/dodal/devices/aperturescatterguard.py @@ -40,6 +40,7 @@ class AperturePositions: # one micrometre tolerance TOLERANCE_MM: float = 0.001 + @classmethod def _distance_check( self, target: Aperture5d, @@ -49,7 +50,7 @@ def _distance_check( @classmethod def match_to_name(self, present_position: Aperture5d) -> PositionName: - assert AperturePositions.position_valid(present_position) + assert self.position_valid(present_position) positions: List[(Literal, Aperture5d)] = [ (PositionName.LARGE, self.LARGE), (PositionName.MEDIUM, self.MEDIUM), @@ -58,7 +59,7 @@ def match_to_name(self, present_position: Aperture5d) -> PositionName: ] for position_name, position_constant in positions: - if AperturePositions._distance_check(position_constant, present_position): + if self._distance_check(position_constant, present_position): return position_name return PositionName.INVALID @@ -96,6 +97,7 @@ def from_gda_beamline_params(cls, params): ), ) + @classmethod def position_valid(self, pos: Aperture5d) -> bool: """ Check if argument 'pos' is a valid position in this AperturePositions object. diff --git a/tests/devices/unit_tests/test_aperture_scatterguard.py b/tests/devices/unit_tests/test_aperture_scatterguard.py index 8706308bfe..83f71484b4 100644 --- a/tests/devices/unit_tests/test_aperture_scatterguard.py +++ b/tests/devices/unit_tests/test_aperture_scatterguard.py @@ -104,12 +104,13 @@ def test_aperture_scatterguard_rejects_unknown_position( def test_aperture_scatterguard_select_bottom_moves_sg_down_then_assembly_up( - aperture_in_medium_pos, + aperture_positions: AperturePositions, + aperture_in_medium_pos: ApertureScatterguard, ): aperture_scatterguard = aperture_in_medium_pos call_logger = install_logger_for_aperture_and_scatterguard(aperture_scatterguard) - aperture_scatterguard.set(AperturePositions.SMALL) + aperture_scatterguard.set(aperture_positions.SMALL) actual_calls = call_logger.mock_calls expected_calls = [ @@ -125,12 +126,12 @@ def test_aperture_scatterguard_select_bottom_moves_sg_down_then_assembly_up( def test_aperture_scatterguard_select_top_moves_assembly_down_then_sg_up( - aperture_in_medium_pos: ApertureScatterguard + aperture_positions: AperturePositions, aperture_in_medium_pos: ApertureScatterguard ): aperture_scatterguard = aperture_in_medium_pos call_logger = install_logger_for_aperture_and_scatterguard(aperture_scatterguard) - aperture_scatterguard.set(AperturePositions.LARGE) + aperture_scatterguard.set(aperture_positions.LARGE) actual_calls = call_logger.mock_calls expected_calls = [ From c41f8fec1c9fb08e2fe9efacde7ceb77886c5bdc Mon Sep 17 00:00:00 2001 From: Stanislaw Malinowski Date: Tue, 23 Jan 2024 11:30:55 +0000 Subject: [PATCH 007/134] fix aperture scatterguard test failing --- src/dodal/devices/aperturescatterguard.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/dodal/devices/aperturescatterguard.py b/src/dodal/devices/aperturescatterguard.py index cdb3e9246d..de07d4eebd 100644 --- a/src/dodal/devices/aperturescatterguard.py +++ b/src/dodal/devices/aperturescatterguard.py @@ -40,15 +40,13 @@ class AperturePositions: # one micrometre tolerance TOLERANCE_MM: float = 0.001 - @classmethod def _distance_check( self, target: Aperture5d, present: Aperture5d, ) -> bool: - return np.allclose(present, target, self.tolerance) + return np.allclose(present, target, AperturePositions.TOLERANCE_MM) - @classmethod def match_to_name(self, present_position: Aperture5d) -> PositionName: assert self.position_valid(present_position) positions: List[(Literal, Aperture5d)] = [ @@ -97,7 +95,6 @@ def from_gda_beamline_params(cls, params): ), ) - @classmethod def position_valid(self, pos: Aperture5d) -> bool: """ Check if argument 'pos' is a valid position in this AperturePositions object. @@ -118,7 +115,7 @@ def load_aperture_positions(self, positions: AperturePositions): self.aperture_positions = positions def _update_name(self, pos: Aperture5d) -> None: - name = AperturePositions.match_to_name(pos) + name = self.aperture_positions.match_to_name(pos) self.aperture_name = name def set(self, pos: Aperture5d) -> AndStatus: From ebf71a2d8b0c43695b95aa2e6c4774608e381f0f Mon Sep 17 00:00:00 2001 From: Stanislaw Malinowski Date: Wed, 24 Jan 2024 10:58:24 +0000 Subject: [PATCH 008/134] added the --- src/dodal/devices/aperturescatterguard.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/dodal/devices/aperturescatterguard.py b/src/dodal/devices/aperturescatterguard.py index de07d4eebd..dd26c5bf38 100644 --- a/src/dodal/devices/aperturescatterguard.py +++ b/src/dodal/devices/aperturescatterguard.py @@ -4,6 +4,7 @@ import numpy as np from ophyd import Component as Cpt +from ophyd import Signal from ophyd.status import AndStatus, Status from dodal.devices.aperture import Aperture @@ -107,7 +108,13 @@ class ApertureScatterguard(InfoLoggingDevice): aperture = Cpt(Aperture, "-MO-MAPT-01:") scatterguard = Cpt(Scatterguard, "-MO-SCAT-01:") aperture_positions: Optional[AperturePositions] = None - aperture_name: PositionName = PositionName.INVALID + aperture_name = PositionName.INVALID + + class NamingSignal(Signal): + def get(self): + return self.parent.aperture_name.value + + ap_name = Cpt(NamingSignal) APERTURE_Z_TOLERANCE = 3 # Number of MRES steps def load_aperture_positions(self, positions: AperturePositions): From 19aaa9bfbd281b47c31a29b3979d5065dcbfe1cb Mon Sep 17 00:00:00 2001 From: Stanislaw Malinowski Date: Wed, 31 Jan 2024 13:44:54 +0000 Subject: [PATCH 009/134] scatterguard name signal change to float --- src/dodal/devices/aperturescatterguard.py | 26 +++++++++++------------ 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/dodal/devices/aperturescatterguard.py b/src/dodal/devices/aperturescatterguard.py index dd26c5bf38..bbf9d96369 100644 --- a/src/dodal/devices/aperturescatterguard.py +++ b/src/dodal/devices/aperturescatterguard.py @@ -19,12 +19,12 @@ class InvalidApertureMove(Exception): pass -class PositionName(str, Enum): - LARGE = "large" - MEDIUM = "medium" - SMALL = "small" - INVALID = "invalid" - ROBOT_LOAD = "robot_load" +class ConfigurationRadiusMicrons(Optional[float], Enum): + LARGE = 100 + MEDIUM = 50 + SMALL = 20 + INVALID = None + ROBOT_LOAD = None @dataclass @@ -48,20 +48,20 @@ def _distance_check( ) -> bool: return np.allclose(present, target, AperturePositions.TOLERANCE_MM) - def match_to_name(self, present_position: Aperture5d) -> PositionName: + def match_to_name(self, present_position: Aperture5d) -> ConfigurationRadiusMicrons: assert self.position_valid(present_position) positions: List[(Literal, Aperture5d)] = [ - (PositionName.LARGE, self.LARGE), - (PositionName.MEDIUM, self.MEDIUM), - (PositionName.SMALL, self.SMALL), - (PositionName.ROBOT_LOAD, self.ROBOT_LOAD), + (ConfigurationRadiusMicrons.LARGE, self.LARGE), + (ConfigurationRadiusMicrons.MEDIUM, self.MEDIUM), + (ConfigurationRadiusMicrons.SMALL, self.SMALL), + (ConfigurationRadiusMicrons.ROBOT_LOAD, self.ROBOT_LOAD), ] for position_name, position_constant in positions: if self._distance_check(position_constant, present_position): return position_name - return PositionName.INVALID + return ConfigurationRadiusMicrons.INVALID @classmethod def from_gda_beamline_params(cls, params): @@ -108,7 +108,7 @@ class ApertureScatterguard(InfoLoggingDevice): aperture = Cpt(Aperture, "-MO-MAPT-01:") scatterguard = Cpt(Scatterguard, "-MO-SCAT-01:") aperture_positions: Optional[AperturePositions] = None - aperture_name = PositionName.INVALID + aperture_name = ConfigurationRadiusMicrons.INVALID class NamingSignal(Signal): def get(self): From f597ae418cc9c036cfdd59a2055225fa8f01260a Mon Sep 17 00:00:00 2001 From: Noemi Frisina <54588199+noemifrisina@users.noreply.github.com> Date: Tue, 23 Jan 2024 17:30:27 +0000 Subject: [PATCH 010/134] Add motor records to pmac device (#259) * Add motor record to pmac * Add method to home stages to pmac * Rething home method * Use epicsmotors * Use put instead of set --- src/dodal/devices/i24/pmac.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/dodal/devices/i24/pmac.py b/src/dodal/devices/i24/pmac.py index 78cec0d7b8..2155dec6b4 100644 --- a/src/dodal/devices/i24/pmac.py +++ b/src/dodal/devices/i24/pmac.py @@ -1,6 +1,7 @@ from ophyd import Component as Cpt from ophyd import ( Device, + EpicsMotor, EpicsSignal, ) @@ -9,3 +10,10 @@ class PMAC(Device): """Device to control the chip stage on I24.""" pmac_string = Cpt(EpicsSignal, "PMAC_STRING") + + x = Cpt(EpicsMotor, "X") + y = Cpt(EpicsMotor, "Y") + z = Cpt(EpicsMotor, "Z") + + def home_stages(self): + self.pmac_string.put(r"\#1hmz\#2hmz\#3hmz", wait=True) From 6800790b25645c1b8ce08306604fa2557ff46301 Mon Sep 17 00:00:00 2001 From: Oliver Silvester Date: Tue, 3 Oct 2023 17:01:44 +0100 Subject: [PATCH 011/134] add initial get panda --- src/dodal/beamlines/i03.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/dodal/beamlines/i03.py b/src/dodal/beamlines/i03.py index d4462d0389..4604f93439 100644 --- a/src/dodal/beamlines/i03.py +++ b/src/dodal/beamlines/i03.py @@ -1,5 +1,7 @@ from typing import Optional +from ophyd_async.devices.panda import PandA + from dodal.beamlines.beamline_utils import device_instantiation from dodal.beamlines.beamline_utils import set_beamline as set_utils_beamline from dodal.devices.aperturescatterguard import AperturePositions, ApertureScatterguard @@ -343,6 +345,21 @@ def attenuator( ) +def panda( + wait_for_connection: bool = True, fake_with_ophyd_sim: bool = False +) -> PandA: + """Get the i03 panda device, instantiate it if it hasn't already been. + If this is called when already instantiated in i03, it will return the existing object. + """ + return device_instantiation( + PandA, + "panda", + "i03-panda", + wait_for_connection, + fake_with_ophyd_sim, + ) + + @skip_device(lambda: BL == "s03") def sample_shutter( wait_for_connection: bool = True, fake_with_ophyd_sim: bool = False From f5956aa3e216bb6c6a3bf997e86bcbf0936c29f6 Mon Sep 17 00:00:00 2001 From: Oliver Silvester Date: Wed, 8 Nov 2023 16:24:32 +0000 Subject: [PATCH 012/134] add panda fast grid scan with the runnup distance PV --- src/dodal/devices/panda_fast_grid_scan.py | 322 ++++++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 src/dodal/devices/panda_fast_grid_scan.py diff --git a/src/dodal/devices/panda_fast_grid_scan.py b/src/dodal/devices/panda_fast_grid_scan.py new file mode 100644 index 0000000000..631544bb87 --- /dev/null +++ b/src/dodal/devices/panda_fast_grid_scan.py @@ -0,0 +1,322 @@ +import threading +import time +from typing import Any + +import numpy as np +from bluesky.plan_stubs import mv +from numpy import ndarray +from ophyd import ( + Component, + Device, + EpicsSignal, + EpicsSignalRO, + EpicsSignalWithRBV, + Signal, +) +from ophyd.status import DeviceStatus, StatusBase +from pydantic import BaseModel, validator +from pydantic.dataclasses import dataclass + +from dodal.devices.motors import XYZLimitBundle +from dodal.devices.status import await_value +from dodal.parameters.experiment_parameter_base import AbstractExperimentParameterBase + + +@dataclass +class GridAxis: + start: float + step_size: 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 * 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): + return 0 <= steps <= self.full_steps + + +class PandaGridScanParams(BaseModel, AbstractExperimentParameterBase): + """ + Holder class for the parameters of a grid scan in a similar + layout to EPICS. + + Motion program will do a grid in x-y then rotate omega +90 and perform + a grid in x-z. + + 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 + z_steps: int = 0 + x_step_size: float = 0.1 + y_step_size: float = 0.1 + z_step_size: float = 0.1 + dwell_time_ms: float = 0.1 + x_start: float = 0.1 + y1_start: float = 0.1 + y2_start: float = 0.1 + z1_start: float = 0.1 + z2_start: float = 0.1 + x_axis: GridAxis = GridAxis(0, 0, 0) + y_axis: GridAxis = GridAxis(0, 0, 0) + z_axis: GridAxis = GridAxis(0, 0, 0) + runnup_distance_mm: float = 0.1 # TODO: make pv for this and put validator in + + class Config: + arbitrary_types_allowed = True + fields = { + "x_axis": {"exclude": True}, + "y_axis": {"exclude": True}, + "z_axis": {"exclude": True}, + } + + @validator("x_axis", always=True) + def _get_x_axis(cls, x_axis: GridAxis, values: dict[str, Any]) -> GridAxis: + return GridAxis(values["x_start"], values["x_step_size"], values["x_steps"]) + + @validator("y_axis", always=True) + def _get_y_axis(cls, y_axis: GridAxis, values: dict[str, Any]) -> GridAxis: + return GridAxis(values["y1_start"], values["y_step_size"], values["y_steps"]) + + @validator("z_axis", always=True) + def _get_z_axis(cls, z_axis: GridAxis, values: dict[str, Any]) -> GridAxis: + return GridAxis(values["z2_start"], values["z_step_size"], values["z_steps"]) + + def is_valid(self, limits: XYZLimitBundle) -> bool: + """ + Validates scan parameters + + :param limits: The motor limits against which to validate + the parameters + :return: True if the scan is valid + """ + x_in_limits = limits.x.is_within(self.x_axis.start) and limits.x.is_within( + self.x_axis.end + ) + y_in_limits = limits.y.is_within(self.y_axis.start) and limits.y.is_within( + self.y_axis.end + ) + + first_grid_in_limits = ( + x_in_limits and y_in_limits and limits.z.is_within(self.z1_start) + ) + + z_in_limits = limits.z.is_within(self.z_axis.start) and limits.z.is_within( + self.z_axis.end + ) + + second_grid_in_limits = ( + x_in_limits and z_in_limits and limits.y.is_within(self.y2_start) + ) + + return first_grid_in_limits and second_grid_in_limits + + def get_num_images(self): + return self.x_steps * self.y_steps + self.x_steps * self.z_steps + + @property + def is_3d_grid_scan(self): + return self.z_steps > 0 + + 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. + + :param grid_position: The x, y, z position in grid steps + :return: 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] + ): + 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 GridScanCompleteStatus(DeviceStatus): + """ + A Status for the grid scan completion + A special status object that notifies watchers (progress bars) + based on comparing device.expected_images to device.position_counter. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.start_ts = time.time() + + self.device.position_counter.subscribe(self._notify_watchers) + self.device.status.subscribe(self._running_changed) + + self._name = self.device.name + self._target_count = self.device.expected_images.get() + + def _notify_watchers(self, value, *args, **kwargs): + if not self._watchers: + return + time_elapsed = time.time() - self.start_ts + try: + fraction = 1 - value / self._target_count + except ZeroDivisionError: + fraction = 0 + time_remaining = 0 + except Exception as e: + fraction = None + time_remaining = None + self.set_exception(e) + self.clean_up() + else: + time_remaining = time_elapsed / fraction + for watcher in self._watchers: + watcher( + name=self._name, + current=value, + initial=0, + target=self._target_count, + unit="images", + precision=0, + fraction=fraction, + time_elapsed=time_elapsed, + time_remaining=time_remaining, + ) + + def _running_changed(self, value=None, old_value=None, **kwargs): + if (old_value == 1) and (value == 0): + self.set_finished() + self.clean_up() + + def clean_up(self): + self.device.position_counter.clear_sub(self._notify_watchers) + self.device.status.clear_sub(self._running_changed) + + +class PandAFastGridScan(Device): + # This is almost identical to the regular FastGridScan device. It has one extra PV for runup distance, and doesnt use dwell time + + x_steps: EpicsSignalWithRBV = Component(EpicsSignalWithRBV, "X_NUM_STEPS") + y_steps: EpicsSignalWithRBV = Component(EpicsSignalWithRBV, "Y_NUM_STEPS") + z_steps: EpicsSignalWithRBV = Component(EpicsSignalWithRBV, "Z_NUM_STEPS") + + x_step_size: EpicsSignalWithRBV = Component(EpicsSignalWithRBV, "X_STEP_SIZE") + y_step_size: EpicsSignalWithRBV = Component(EpicsSignalWithRBV, "Y_STEP_SIZE") + z_step_size: EpicsSignalWithRBV = Component(EpicsSignalWithRBV, "Z_STEP_SIZE") + + # dwell_time: EpicsSignalWithRBV = Component(EpicsSignalWithRBV, "DWELL_TIME") + # Dwell time shouldn't be used for panda scan + + runup_distance: EpicsSignalWithRBV = Component(EpicsSignalWithRBV, "RUNUP_DISTANCE") + + x_start: EpicsSignalWithRBV = Component(EpicsSignalWithRBV, "X_START") + y1_start: EpicsSignalWithRBV = Component(EpicsSignalWithRBV, "Y_START") + y2_start: EpicsSignalWithRBV = Component(EpicsSignalWithRBV, "Y2_START") + z1_start: EpicsSignalWithRBV = Component(EpicsSignalWithRBV, "Z_START") + z2_start: EpicsSignalWithRBV = Component(EpicsSignalWithRBV, "Z2_START") + + position_counter: EpicsSignal = Component( + EpicsSignal, "POS_COUNTER", write_pv="POS_COUNTER_WRITE" + ) + x_counter: EpicsSignalRO = Component(EpicsSignalRO, "X_COUNTER") + y_counter: EpicsSignalRO = Component(EpicsSignalRO, "Y_COUNTER") + scan_invalid: EpicsSignalRO = Component(EpicsSignalRO, "SCAN_INVALID") + + run_cmd: EpicsSignal = Component(EpicsSignal, "RUN.PROC") + stop_cmd: EpicsSignal = Component(EpicsSignal, "STOP.PROC") + status: EpicsSignalRO = Component(EpicsSignalRO, "SCAN_STATUS") + + expected_images: Signal = Component(Signal) + + # Kickoff timeout in seconds + KICKOFF_TIMEOUT: float = 5.0 + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def set_expected_images(*_, **__): + x, y, z = self.x_steps.get(), self.y_steps.get(), self.z_steps.get() + first_grid = x * y + second_grid = x * z + self.expected_images.put(first_grid + second_grid) + + self.x_steps.subscribe(set_expected_images) + self.y_steps.subscribe(set_expected_images) + self.z_steps.subscribe(set_expected_images) + + def is_invalid(self) -> bool: + if "GONP" in self.scan_invalid.pvname: + return False + return self.scan_invalid.get() + + def kickoff(self) -> StatusBase: + # Check running already here? + st = DeviceStatus(device=self, timeout=self.KICKOFF_TIMEOUT) + + def scan(): + try: + self.log.debug("Running scan") + self.run_cmd.put(1) + self.log.debug("Waiting for scan to start") + await_value(self.status, 1).wait() + st.set_finished() + except Exception as e: + st.set_exception(e) + + threading.Thread(target=scan, daemon=True).start() + return st + + def complete(self) -> DeviceStatus: + return GridScanCompleteStatus(self) + + def collect(self): + return {} + + def describe_collect(self): + return {} + + +def set_fast_grid_scan_params(scan: PandAFastGridScan, params: PandaGridScanParams): + yield from mv( + scan.x_steps, + params.x_steps, + scan.y_steps, + params.y_steps, + scan.z_steps, + params.z_steps, + scan.x_step_size, + params.x_step_size, + scan.y_step_size, + params.y_step_size, + scan.z_step_size, + params.z_step_size, + scan.dwell_time, + params.dwell_time, + scan.x_start, + params.x_start, + scan.y1_start, + params.y1_start, + scan.y2_start, + params.y2_start, + scan.z1_start, + params.z1_start, + scan.z2_start, + params.z2_start, + scan.position_counter, + 0, + scan.runup_distance, + params.dwell_time_ms, + ) From b4100ff090d81369e18efd31d7e0f0a0c26b5ddb Mon Sep 17 00:00:00 2001 From: Oliver Silvester Date: Fri, 10 Nov 2023 12:05:27 +0000 Subject: [PATCH 013/134] Add new output --- src/dodal/devices/zebra.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/dodal/devices/zebra.py b/src/dodal/devices/zebra.py index f9893f6725..1f5c4fae1a 100644 --- a/src/dodal/devices/zebra.py +++ b/src/dodal/devices/zebra.py @@ -39,6 +39,7 @@ TTL_DETECTOR = 1 TTL_SHUTTER = 2 TTL_XSPRESS3 = 3 +TTL_PANDA = 4 class I03Axes(Enum): From 4faf92ef901e63a50f97f6f58581e014dfa7c0df Mon Sep 17 00:00:00 2001 From: Oliver Silvester Date: Wed, 15 Nov 2023 14:01:33 +0000 Subject: [PATCH 014/134] add PandAFastGridScan to i03 devices --- src/dodal/beamlines/i03.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/dodal/beamlines/i03.py b/src/dodal/beamlines/i03.py index 4604f93439..050485507d 100644 --- a/src/dodal/beamlines/i03.py +++ b/src/dodal/beamlines/i03.py @@ -1,6 +1,6 @@ from typing import Optional -from ophyd_async.devices.panda import PandA +from ophyd_async.panda import PandA from dodal.beamlines.beamline_utils import device_instantiation from dodal.beamlines.beamline_utils import set_beamline as set_utils_beamline @@ -16,6 +16,7 @@ from dodal.devices.focusing_mirror import FocusingMirror, VFMMirrorVoltages from dodal.devices.oav.oav_detector import OAV, OAVConfigParams from dodal.devices.oav.pin_image_recognition import PinTipDetection +from dodal.devices.panda_fast_grid_scan import PandAFastGridScan from dodal.devices.qbpm1 import QBPM1 from dodal.devices.s4_slit_gaps import S4SlitGaps from dodal.devices.sample_shutter import SampleShutter @@ -191,6 +192,21 @@ def fast_grid_scan( ) +def panda_fast_grid_scan( + wait_for_connection: bool = True, fake_with_ophyd_sim: bool = False +) -> PandAFastGridScan: + """Get the i03 fast_grid_scan device, instantiate it if it hasn't already been. + If this is called when already instantiated in i03, it will return the existing object. + """ + return device_instantiation( + device_factory=PandAFastGridScan, + name="fast_grid_scan", + prefix="-MO-SGON-01:PGS:", + wait=wait_for_connection, + fake=fake_with_ophyd_sim, + ) + + @skip_device(lambda: BL == "s03") def oav(wait_for_connection: bool = True, fake_with_ophyd_sim: bool = False) -> OAV: """Get the i03 OAV device, instantiate it if it hasn't already been. @@ -345,9 +361,7 @@ def attenuator( ) -def panda( - wait_for_connection: bool = True, fake_with_ophyd_sim: bool = False -) -> PandA: +def panda(wait_for_connection: bool = True, fake_with_ophyd_sim: bool = False) -> PandA: """Get the i03 panda device, instantiate it if it hasn't already been. If this is called when already instantiated in i03, it will return the existing object. """ From 4b145c19cb021a2c7bb0f38b4d8d6b44899e48dc Mon Sep 17 00:00:00 2001 From: Oliver Silvester Date: Thu, 16 Nov 2023 14:21:08 +0000 Subject: [PATCH 015/134] fixes from i03 testing --- src/dodal/beamlines/i03.py | 4 ++-- src/dodal/devices/panda_fast_grid_scan.py | 6 ++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/dodal/beamlines/i03.py b/src/dodal/beamlines/i03.py index 050485507d..438fc82d59 100644 --- a/src/dodal/beamlines/i03.py +++ b/src/dodal/beamlines/i03.py @@ -200,7 +200,7 @@ def panda_fast_grid_scan( """ return device_instantiation( device_factory=PandAFastGridScan, - name="fast_grid_scan", + name="panda_fast_grid_scan", prefix="-MO-SGON-01:PGS:", wait=wait_for_connection, fake=fake_with_ophyd_sim, @@ -368,7 +368,7 @@ def panda(wait_for_connection: bool = True, fake_with_ophyd_sim: bool = False) - return device_instantiation( PandA, "panda", - "i03-panda", + "-EA-PANDA-01", wait_for_connection, fake_with_ophyd_sim, ) diff --git a/src/dodal/devices/panda_fast_grid_scan.py b/src/dodal/devices/panda_fast_grid_scan.py index 631544bb87..aa5c810916 100644 --- a/src/dodal/devices/panda_fast_grid_scan.py +++ b/src/dodal/devices/panda_fast_grid_scan.py @@ -220,7 +220,7 @@ class PandAFastGridScan(Device): # dwell_time: EpicsSignalWithRBV = Component(EpicsSignalWithRBV, "DWELL_TIME") # Dwell time shouldn't be used for panda scan - runup_distance: EpicsSignalWithRBV = Component(EpicsSignalWithRBV, "RUNUP_DISTANCE") + runup_distance: EpicsSignalWithRBV = Component(EpicsSignal, "RUNUP_DISTANCE") x_start: EpicsSignalWithRBV = Component(EpicsSignalWithRBV, "X_START") y1_start: EpicsSignalWithRBV = Component(EpicsSignalWithRBV, "Y_START") @@ -303,8 +303,6 @@ def set_fast_grid_scan_params(scan: PandAFastGridScan, params: PandaGridScanPara params.y_step_size, scan.z_step_size, params.z_step_size, - scan.dwell_time, - params.dwell_time, scan.x_start, params.x_start, scan.y1_start, @@ -318,5 +316,5 @@ def set_fast_grid_scan_params(scan: PandAFastGridScan, params: PandaGridScanPara scan.position_counter, 0, scan.runup_distance, - params.dwell_time_ms, + params.runnup_distance_mm, ) From 97441c93fabb4336de3fffe8d5a8f1a05a66fff6 Mon Sep 17 00:00:00 2001 From: Oliver Silvester Date: Tue, 21 Nov 2023 16:07:02 +0000 Subject: [PATCH 016/134] x_steps put back, but not as a pv --- src/dodal/devices/fast_grid_scan.py | 6 +----- src/dodal/devices/panda_fast_grid_scan.py | 17 ++--------------- 2 files changed, 3 insertions(+), 20 deletions(-) diff --git a/src/dodal/devices/fast_grid_scan.py b/src/dodal/devices/fast_grid_scan.py index 2d9c56594a..e782adad3b 100644 --- a/src/dodal/devices/fast_grid_scan.py +++ b/src/dodal/devices/fast_grid_scan.py @@ -264,7 +264,7 @@ def set_expected_images(*_, **__): second_grid = x * z self.expected_images.put(first_grid + second_grid) - self.x_steps.subscribe(set_expected_images) + # self.x_steps.subscribe(set_expected_images) self.y_steps.subscribe(set_expected_images) self.z_steps.subscribe(set_expected_images) @@ -302,8 +302,6 @@ def describe_collect(self): def set_fast_grid_scan_params(scan: FastGridScan, params: GridScanParams): yield from mv( - scan.x_steps, - params.x_steps, scan.y_steps, params.y_steps, scan.z_steps, @@ -326,6 +324,4 @@ def set_fast_grid_scan_params(scan: FastGridScan, params: GridScanParams): params.z1_start, scan.z2_start, params.z2_start, - scan.position_counter, - 0, ) diff --git a/src/dodal/devices/panda_fast_grid_scan.py b/src/dodal/devices/panda_fast_grid_scan.py index aa5c810916..39fd68d2eb 100644 --- a/src/dodal/devices/panda_fast_grid_scan.py +++ b/src/dodal/devices/panda_fast_grid_scan.py @@ -209,7 +209,6 @@ def clean_up(self): class PandAFastGridScan(Device): # This is almost identical to the regular FastGridScan device. It has one extra PV for runup distance, and doesnt use dwell time - x_steps: EpicsSignalWithRBV = Component(EpicsSignalWithRBV, "X_NUM_STEPS") y_steps: EpicsSignalWithRBV = Component(EpicsSignalWithRBV, "Y_NUM_STEPS") z_steps: EpicsSignalWithRBV = Component(EpicsSignalWithRBV, "Z_NUM_STEPS") @@ -228,11 +227,7 @@ class PandAFastGridScan(Device): z1_start: EpicsSignalWithRBV = Component(EpicsSignalWithRBV, "Z_START") z2_start: EpicsSignalWithRBV = Component(EpicsSignalWithRBV, "Z2_START") - position_counter: EpicsSignal = Component( - EpicsSignal, "POS_COUNTER", write_pv="POS_COUNTER_WRITE" - ) - x_counter: EpicsSignalRO = Component(EpicsSignalRO, "X_COUNTER") - y_counter: EpicsSignalRO = Component(EpicsSignalRO, "Y_COUNTER") + position_counter: EpicsSignalRO = Component(EpicsSignalRO, "Y_COUNTER") scan_invalid: EpicsSignalRO = Component(EpicsSignalRO, "SCAN_INVALID") run_cmd: EpicsSignal = Component(EpicsSignal, "RUN.PROC") @@ -248,12 +243,8 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) def set_expected_images(*_, **__): - x, y, z = self.x_steps.get(), self.y_steps.get(), self.z_steps.get() - first_grid = x * y - second_grid = x * z - self.expected_images.put(first_grid + second_grid) + self.expected_images.put(120) - self.x_steps.subscribe(set_expected_images) self.y_steps.subscribe(set_expected_images) self.z_steps.subscribe(set_expected_images) @@ -291,8 +282,6 @@ def describe_collect(self): def set_fast_grid_scan_params(scan: PandAFastGridScan, params: PandaGridScanParams): yield from mv( - scan.x_steps, - params.x_steps, scan.y_steps, params.y_steps, scan.z_steps, @@ -313,8 +302,6 @@ def set_fast_grid_scan_params(scan: PandAFastGridScan, params: PandaGridScanPara params.z1_start, scan.z2_start, params.z2_start, - scan.position_counter, - 0, scan.runup_distance, params.runnup_distance_mm, ) From fcae5586d2955d4eb2257fdba3f71d884044fcf7 Mon Sep 17 00:00:00 2001 From: Oliver Silvester Date: Wed, 6 Dec 2023 14:25:07 +0000 Subject: [PATCH 017/134] Reintroduce x steps, remove progress bar subscriptions --- src/dodal/devices/fast_grid_scan.py | 4 +++- src/dodal/devices/panda_fast_grid_scan.py | 17 +++++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/dodal/devices/fast_grid_scan.py b/src/dodal/devices/fast_grid_scan.py index e782adad3b..3a8b28ef96 100644 --- a/src/dodal/devices/fast_grid_scan.py +++ b/src/dodal/devices/fast_grid_scan.py @@ -264,7 +264,7 @@ def set_expected_images(*_, **__): second_grid = x * z self.expected_images.put(first_grid + second_grid) - # self.x_steps.subscribe(set_expected_images) + self.x_steps.subscribe(set_expected_images) self.y_steps.subscribe(set_expected_images) self.z_steps.subscribe(set_expected_images) @@ -302,6 +302,8 @@ def describe_collect(self): def set_fast_grid_scan_params(scan: FastGridScan, params: GridScanParams): yield from mv( + scan.x_steps, + params.x_steps, scan.y_steps, params.y_steps, scan.z_steps, diff --git a/src/dodal/devices/panda_fast_grid_scan.py b/src/dodal/devices/panda_fast_grid_scan.py index 39fd68d2eb..9eb474d5cc 100644 --- a/src/dodal/devices/panda_fast_grid_scan.py +++ b/src/dodal/devices/panda_fast_grid_scan.py @@ -161,12 +161,15 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.start_ts = time.time() - self.device.position_counter.subscribe(self._notify_watchers) + # Progress bar not used for now + # self.device.position_counter.subscribe(self._notify_watchers) self.device.status.subscribe(self._running_changed) self._name = self.device.name - self._target_count = self.device.expected_images.get() + self._target_count = self.device.y_steps.get() + + # Function currently not used def _notify_watchers(self, value, *args, **kwargs): if not self._watchers: return @@ -202,13 +205,13 @@ def _running_changed(self, value=None, old_value=None, **kwargs): self.clean_up() def clean_up(self): - self.device.position_counter.clear_sub(self._notify_watchers) self.device.status.clear_sub(self._running_changed) class PandAFastGridScan(Device): # This is almost identical to the regular FastGridScan device. It has one extra PV for runup distance, and doesnt use dwell time + x_steps: EpicsSignalWithRBV = Component(EpicsSignalWithRBV, "X_NUM_STEPS") y_steps: EpicsSignalWithRBV = Component(EpicsSignalWithRBV, "Y_NUM_STEPS") z_steps: EpicsSignalWithRBV = Component(EpicsSignalWithRBV, "Z_NUM_STEPS") @@ -243,8 +246,12 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) def set_expected_images(*_, **__): - self.expected_images.put(120) + x, y, z = self.x_steps.get(), self.y_steps.get(), self.z_steps.get() + first_grid = x * y + second_grid = x * z + self.expected_images.put(first_grid + second_grid) + self.x_steps.subscribe(set_expected_images) self.y_steps.subscribe(set_expected_images) self.z_steps.subscribe(set_expected_images) @@ -282,6 +289,8 @@ def describe_collect(self): def set_fast_grid_scan_params(scan: PandAFastGridScan, params: PandaGridScanParams): yield from mv( + scan.x_steps, + params.x_steps, scan.y_steps, params.y_steps, scan.z_steps, From 58d1c14538f45759c75d06914ddc014e9fc7e186 Mon Sep 17 00:00:00 2001 From: Oliver Silvester Date: Wed, 3 Jan 2024 16:17:14 +0000 Subject: [PATCH 018/134] Add time_between_x_steps as a parameter, add smargon speed limit check --- src/dodal/devices/panda_fast_grid_scan.py | 26 ++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/src/dodal/devices/panda_fast_grid_scan.py b/src/dodal/devices/panda_fast_grid_scan.py index 9eb474d5cc..e056b76f06 100644 --- a/src/dodal/devices/panda_fast_grid_scan.py +++ b/src/dodal/devices/panda_fast_grid_scan.py @@ -1,6 +1,6 @@ import threading import time -from typing import Any +from typing import Any, Optional import numpy as np from bluesky.plan_stubs import mv @@ -70,7 +70,8 @@ class PandaGridScanParams(BaseModel, AbstractExperimentParameterBase): x_axis: GridAxis = GridAxis(0, 0, 0) y_axis: GridAxis = GridAxis(0, 0, 0) z_axis: GridAxis = GridAxis(0, 0, 0) - runnup_distance_mm: float = 0.1 # TODO: make pv for this and put validator in + runnup_distance_mm: float = 0.1 + time_between_x_steps_ms: float = 0 class Config: arbitrary_types_allowed = True @@ -92,6 +93,17 @@ def _get_y_axis(cls, y_axis: GridAxis, values: dict[str, Any]) -> GridAxis: def _get_z_axis(cls, z_axis: GridAxis, values: dict[str, Any]) -> GridAxis: return GridAxis(values["z2_start"], values["z_step_size"], values["z_steps"]) + # If not specified, set so that the goniometer speed is 9mm/s + @validator("time_between_x_steps_ms", always=True) + def _set_time_between_x_steps( + cls, time_between_x_steps_ms: float, values: dict[str, Any] + ) -> float: + if time_between_x_steps_ms == 0: + time_between_x_steps_ms = values["x_step_size"] * 1e3 + return time_between_x_steps_ms / 9e-3 + else: + return time_between_x_steps_ms + def is_valid(self, limits: XYZLimitBundle) -> bool: """ Validates scan parameters @@ -119,7 +131,15 @@ def is_valid(self, limits: XYZLimitBundle) -> bool: x_in_limits and z_in_limits and limits.y.is_within(self.y2_start) ) - return first_grid_in_limits and second_grid_in_limits + within_smargon_speed_limit = ( + self.runnup_distance_mm / self.time_between_x_steps_ms < 10e-3 + ) + + return ( + first_grid_in_limits + and second_grid_in_limits + and within_smargon_speed_limit + ) def get_num_images(self): return self.x_steps * self.y_steps + self.x_steps * self.z_steps From 3e85e2070c374afba4c4723eb254016d8582868b Mon Sep 17 00:00:00 2001 From: Oliver Silvester Date: Wed, 3 Jan 2024 17:04:08 +0000 Subject: [PATCH 019/134] add time between x steps as a PV which we set within hyperion --- src/dodal/devices/panda_fast_grid_scan.py | 26 ++--------------------- 1 file changed, 2 insertions(+), 24 deletions(-) diff --git a/src/dodal/devices/panda_fast_grid_scan.py b/src/dodal/devices/panda_fast_grid_scan.py index e056b76f06..5a82a22704 100644 --- a/src/dodal/devices/panda_fast_grid_scan.py +++ b/src/dodal/devices/panda_fast_grid_scan.py @@ -71,7 +71,6 @@ class PandaGridScanParams(BaseModel, AbstractExperimentParameterBase): y_axis: GridAxis = GridAxis(0, 0, 0) z_axis: GridAxis = GridAxis(0, 0, 0) runnup_distance_mm: float = 0.1 - time_between_x_steps_ms: float = 0 class Config: arbitrary_types_allowed = True @@ -93,17 +92,6 @@ def _get_y_axis(cls, y_axis: GridAxis, values: dict[str, Any]) -> GridAxis: def _get_z_axis(cls, z_axis: GridAxis, values: dict[str, Any]) -> GridAxis: return GridAxis(values["z2_start"], values["z_step_size"], values["z_steps"]) - # If not specified, set so that the goniometer speed is 9mm/s - @validator("time_between_x_steps_ms", always=True) - def _set_time_between_x_steps( - cls, time_between_x_steps_ms: float, values: dict[str, Any] - ) -> float: - if time_between_x_steps_ms == 0: - time_between_x_steps_ms = values["x_step_size"] * 1e3 - return time_between_x_steps_ms / 9e-3 - else: - return time_between_x_steps_ms - def is_valid(self, limits: XYZLimitBundle) -> bool: """ Validates scan parameters @@ -131,15 +119,7 @@ def is_valid(self, limits: XYZLimitBundle) -> bool: x_in_limits and z_in_limits and limits.y.is_within(self.y2_start) ) - within_smargon_speed_limit = ( - self.runnup_distance_mm / self.time_between_x_steps_ms < 10e-3 - ) - - return ( - first_grid_in_limits - and second_grid_in_limits - and within_smargon_speed_limit - ) + return first_grid_in_limits and second_grid_in_limits def get_num_images(self): return self.x_steps * self.y_steps + self.x_steps * self.z_steps @@ -238,9 +218,7 @@ class PandAFastGridScan(Device): x_step_size: EpicsSignalWithRBV = Component(EpicsSignalWithRBV, "X_STEP_SIZE") y_step_size: EpicsSignalWithRBV = Component(EpicsSignalWithRBV, "Y_STEP_SIZE") z_step_size: EpicsSignalWithRBV = Component(EpicsSignalWithRBV, "Z_STEP_SIZE") - - # dwell_time: EpicsSignalWithRBV = Component(EpicsSignalWithRBV, "DWELL_TIME") - # Dwell time shouldn't be used for panda scan + time_between_x_steps_ms = Component(EpicsSignalWithRBV, "TIME_BETWEEN_X_STEPS") runup_distance: EpicsSignalWithRBV = Component(EpicsSignal, "RUNUP_DISTANCE") From c7dd3f07758d47c82c1eb5d54fde300de844317f Mon Sep 17 00:00:00 2001 From: Oliver Silvester Date: Wed, 3 Jan 2024 17:58:39 +0000 Subject: [PATCH 020/134] keep consistent with other fgs --- src/dodal/devices/panda_fast_grid_scan.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/dodal/devices/panda_fast_grid_scan.py b/src/dodal/devices/panda_fast_grid_scan.py index 5a82a22704..4bf3bcec23 100644 --- a/src/dodal/devices/panda_fast_grid_scan.py +++ b/src/dodal/devices/panda_fast_grid_scan.py @@ -72,6 +72,9 @@ class PandaGridScanParams(BaseModel, AbstractExperimentParameterBase): z_axis: GridAxis = GridAxis(0, 0, 0) runnup_distance_mm: float = 0.1 + # Whether to set the stub offsets after centering + set_stub_offsets: bool = False + class Config: arbitrary_types_allowed = True fields = { From 9f0ec5e765b895961cc0cdd2c52c4f60fc9c479e Mon Sep 17 00:00:00 2001 From: Oliver Silvester Date: Mon, 8 Jan 2024 16:31:25 +0000 Subject: [PATCH 021/134] Add tests to panda grid scan device --- pyproject.toml | 3 +- src/dodal/beamlines/i03.py | 3 +- src/dodal/devices/fast_grid_scan.py | 50 ++- src/dodal/devices/panda_fast_grid_scan.py | 206 +----------- .../devices/unit_tests/test_panda_gridscan.py | 303 ++++++++++++++++++ 5 files changed, 358 insertions(+), 207 deletions(-) create mode 100644 tests/devices/unit_tests/test_panda_gridscan.py diff --git a/pyproject.toml b/pyproject.toml index 1025942c87..0cc48e5931 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ classifiers = [ description = "Ophyd devices and other utils that could be used across DLS beamlines" dependencies = [ "ophyd", - "ophyd-async", + "ophyd_async@git+https://github.com/bluesky/ophyd-async@give_panda_name", #Use a specific commit from ophyd async until https://github.com/bluesky/ophyd-async/pull/101 is merged "bluesky", "pyepics", "dataclasses-json", @@ -27,6 +27,7 @@ dependencies = [ "aioca", # Required for CA support with ophyd-async. "p4p", # Required for PVA support with ophyd-async. ] + dynamic = ["version"] license.file = "LICENSE" readme = "README.rst" diff --git a/src/dodal/beamlines/i03.py b/src/dodal/beamlines/i03.py index 438fc82d59..129a2d5522 100644 --- a/src/dodal/beamlines/i03.py +++ b/src/dodal/beamlines/i03.py @@ -195,8 +195,9 @@ def fast_grid_scan( def panda_fast_grid_scan( wait_for_connection: bool = True, fake_with_ophyd_sim: bool = False ) -> PandAFastGridScan: - """Get the i03 fast_grid_scan device, instantiate it if it hasn't already been. + """Get the i03 panda_fast_grid_scan device, instantiate it if it hasn't already been. If this is called when already instantiated in i03, it will return the existing object. + This is used instead of the fast_grid_scan device when using the PandA. """ return device_instantiation( device_factory=PandAFastGridScan, diff --git a/src/dodal/devices/fast_grid_scan.py b/src/dodal/devices/fast_grid_scan.py index 3a8b28ef96..a0d9a63644 100644 --- a/src/dodal/devices/fast_grid_scan.py +++ b/src/dodal/devices/fast_grid_scan.py @@ -43,10 +43,11 @@ def is_within(self, steps): return 0 <= steps <= self.full_steps -class GridScanParams(BaseModel, AbstractExperimentParameterBase): +class GridScanParamsCommon(BaseModel, AbstractExperimentParameterBase): """ - Holder class for the parameters of a grid scan in a similar - layout to EPICS. + 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. Motion program will do a grid in x-y then rotate omega +90 and perform a grid in x-z. @@ -61,7 +62,6 @@ class GridScanParams(BaseModel, AbstractExperimentParameterBase): x_step_size: float = 0.1 y_step_size: float = 0.1 z_step_size: float = 0.1 - dwell_time_ms: float = 10 x_start: float = 0.1 y1_start: float = 0.1 y2_start: float = 0.1 @@ -94,18 +94,6 @@ def _get_y_axis(cls, y_axis: GridAxis, values: dict[str, Any]) -> GridAxis: def _get_z_axis(cls, z_axis: GridAxis, values: dict[str, Any]) -> GridAxis: return GridAxis(values["z2_start"], values["z_step_size"], values["z_steps"]) - @validator("dwell_time_ms", always=True, check_fields=True) - 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 - def is_valid(self, limits: XYZLimitBundle) -> bool: """ Validates scan parameters @@ -164,6 +152,34 @@ def grid_position_to_motor_position(self, grid_position: ndarray) -> ndarray: ) +class GridScanParams(GridScanParamsCommon): + """ + Holder class for the parameters of a grid scan in a similar + layout to EPICS. These params are used for the zebra-triggered + fast grid scan + + Motion program will do a grid in x-y then rotate omega +90 and perform + a grid in x-z. + + 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. + """ + + dwell_time_ms: float = 10 + + @validator("dwell_time_ms", always=True, check_fields=True) + 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 GridScanCompleteStatus(DeviceStatus): """ A Status for the grid scan completion @@ -326,4 +342,6 @@ def set_fast_grid_scan_params(scan: FastGridScan, params: GridScanParams): params.z1_start, scan.z2_start, params.z2_start, + scan.position_counter, + 0, ) diff --git a/src/dodal/devices/panda_fast_grid_scan.py b/src/dodal/devices/panda_fast_grid_scan.py index 4bf3bcec23..f0cc7af727 100644 --- a/src/dodal/devices/panda_fast_grid_scan.py +++ b/src/dodal/devices/panda_fast_grid_scan.py @@ -1,10 +1,6 @@ import threading -import time -from typing import Any, Optional -import numpy as np from bluesky.plan_stubs import mv -from numpy import ndarray from ophyd import ( Component, Device, @@ -14,39 +10,19 @@ Signal, ) from ophyd.status import DeviceStatus, StatusBase -from pydantic import BaseModel, validator -from pydantic.dataclasses import dataclass -from dodal.devices.motors import XYZLimitBundle +from dodal.devices.fast_grid_scan import ( + GridScanCompleteStatus, + GridScanParamsCommon, +) from dodal.devices.status import await_value -from dodal.parameters.experiment_parameter_base import AbstractExperimentParameterBase - - -@dataclass -class GridAxis: - start: float - step_size: 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 * 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): - return 0 <= steps <= self.full_steps - -class PandaGridScanParams(BaseModel, AbstractExperimentParameterBase): +class PandaGridScanParams(GridScanParamsCommon): """ Holder class for the parameters of a grid scan in a similar - layout to EPICS. + layout to EPICS. These params are used for the panda-triggered + constant motion grid scan Motion program will do a grid in x-y then rotate omega +90 and perform a grid in x-z. @@ -55,164 +31,13 @@ class PandaGridScanParams(BaseModel, AbstractExperimentParameterBase): at x_start, y1_start, z1_start and subsequent frames are N*step_size away. """ - x_steps: int = 1 - y_steps: int = 1 - z_steps: int = 0 - x_step_size: float = 0.1 - y_step_size: float = 0.1 - z_step_size: float = 0.1 - dwell_time_ms: float = 0.1 - x_start: float = 0.1 - y1_start: float = 0.1 - y2_start: float = 0.1 - z1_start: float = 0.1 - z2_start: float = 0.1 - x_axis: GridAxis = GridAxis(0, 0, 0) - y_axis: GridAxis = GridAxis(0, 0, 0) - z_axis: GridAxis = GridAxis(0, 0, 0) - runnup_distance_mm: float = 0.1 - - # Whether to set the stub offsets after centering - set_stub_offsets: bool = False - - class Config: - arbitrary_types_allowed = True - fields = { - "x_axis": {"exclude": True}, - "y_axis": {"exclude": True}, - "z_axis": {"exclude": True}, - } - - @validator("x_axis", always=True) - def _get_x_axis(cls, x_axis: GridAxis, values: dict[str, Any]) -> GridAxis: - return GridAxis(values["x_start"], values["x_step_size"], values["x_steps"]) - - @validator("y_axis", always=True) - def _get_y_axis(cls, y_axis: GridAxis, values: dict[str, Any]) -> GridAxis: - return GridAxis(values["y1_start"], values["y_step_size"], values["y_steps"]) - - @validator("z_axis", always=True) - def _get_z_axis(cls, z_axis: GridAxis, values: dict[str, Any]) -> GridAxis: - return GridAxis(values["z2_start"], values["z_step_size"], values["z_steps"]) - - def is_valid(self, limits: XYZLimitBundle) -> bool: - """ - Validates scan parameters - - :param limits: The motor limits against which to validate - the parameters - :return: True if the scan is valid - """ - x_in_limits = limits.x.is_within(self.x_axis.start) and limits.x.is_within( - self.x_axis.end - ) - y_in_limits = limits.y.is_within(self.y_axis.start) and limits.y.is_within( - self.y_axis.end - ) - - first_grid_in_limits = ( - x_in_limits and y_in_limits and limits.z.is_within(self.z1_start) - ) - - z_in_limits = limits.z.is_within(self.z_axis.start) and limits.z.is_within( - self.z_axis.end - ) - - second_grid_in_limits = ( - x_in_limits and z_in_limits and limits.y.is_within(self.y2_start) - ) - - return first_grid_in_limits and second_grid_in_limits - - def get_num_images(self): - return self.x_steps * self.y_steps + self.x_steps * self.z_steps - - @property - def is_3d_grid_scan(self): - return self.z_steps > 0 - - 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. - - :param grid_position: The x, y, z position in grid steps - :return: 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] - ): - 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 GridScanCompleteStatus(DeviceStatus): - """ - A Status for the grid scan completion - A special status object that notifies watchers (progress bars) - based on comparing device.expected_images to device.position_counter. - """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.start_ts = time.time() - - # Progress bar not used for now - # self.device.position_counter.subscribe(self._notify_watchers) - self.device.status.subscribe(self._running_changed) - - self._name = self.device.name - - self._target_count = self.device.y_steps.get() - - # Function currently not used - def _notify_watchers(self, value, *args, **kwargs): - if not self._watchers: - return - time_elapsed = time.time() - self.start_ts - try: - fraction = 1 - value / self._target_count - except ZeroDivisionError: - fraction = 0 - time_remaining = 0 - except Exception as e: - fraction = None - time_remaining = None - self.set_exception(e) - self.clean_up() - else: - time_remaining = time_elapsed / fraction - for watcher in self._watchers: - watcher( - name=self._name, - current=value, - initial=0, - target=self._target_count, - unit="images", - precision=0, - fraction=fraction, - time_elapsed=time_elapsed, - time_remaining=time_remaining, - ) - - def _running_changed(self, value=None, old_value=None, **kwargs): - if (old_value == 1) and (value == 0): - self.set_finished() - self.clean_up() - - def clean_up(self): - self.device.status.clear_sub(self._running_changed) + run_up_distance_mm: float = 0.1 class PandAFastGridScan(Device): - # This is almost identical to the regular FastGridScan device. It has one extra PV for runup distance, and doesnt use dwell time + """This is similar to the regular FastGridScan device. It has two extra PVs: runup distance and time between x steps. + Dwell time is not moved in this scan. + """ x_steps: EpicsSignalWithRBV = Component(EpicsSignalWithRBV, "X_NUM_STEPS") y_steps: EpicsSignalWithRBV = Component(EpicsSignalWithRBV, "Y_NUM_STEPS") @@ -221,9 +46,12 @@ class PandAFastGridScan(Device): x_step_size: EpicsSignalWithRBV = Component(EpicsSignalWithRBV, "X_STEP_SIZE") y_step_size: EpicsSignalWithRBV = Component(EpicsSignalWithRBV, "Y_STEP_SIZE") z_step_size: EpicsSignalWithRBV = Component(EpicsSignalWithRBV, "Z_STEP_SIZE") + + # This value is fixed by the time between X steps detector deadtime. The only reason it is a PV + # Is so the value can be read by the motion program in the PPMAC time_between_x_steps_ms = Component(EpicsSignalWithRBV, "TIME_BETWEEN_X_STEPS") - runup_distance: EpicsSignalWithRBV = Component(EpicsSignal, "RUNUP_DISTANCE") + run_up_distance: EpicsSignalWithRBV = Component(EpicsSignal, "RUNUP_DISTANCE") x_start: EpicsSignalWithRBV = Component(EpicsSignalWithRBV, "X_START") y1_start: EpicsSignalWithRBV = Component(EpicsSignalWithRBV, "Y_START") @@ -312,6 +140,6 @@ def set_fast_grid_scan_params(scan: PandAFastGridScan, params: PandaGridScanPara params.z1_start, scan.z2_start, params.z2_start, - scan.runup_distance, - params.runnup_distance_mm, + scan.run_up_distance, + params.run_up_distance_mm, ) diff --git a/tests/devices/unit_tests/test_panda_gridscan.py b/tests/devices/unit_tests/test_panda_gridscan.py new file mode 100644 index 0000000000..580448ff5b --- /dev/null +++ b/tests/devices/unit_tests/test_panda_gridscan.py @@ -0,0 +1,303 @@ +import numpy as np +import pytest +from bluesky import plan_stubs as bps +from bluesky import preprocessors as bpp +from bluesky.run_engine import RunEngine +from mockito import mock, verify, when +from mockito.matchers import ANY +from ophyd.sim import instantiate_fake_device, make_fake_device + +from dodal.devices.panda_fast_grid_scan import ( + PandAFastGridScan, + PandaGridScanParams, + set_fast_grid_scan_params, +) +from dodal.devices.smargon import Smargon + + +@pytest.fixture +def fast_grid_scan(): + fast_grid_scan: PandAFastGridScan = instantiate_fake_device( + PandAFastGridScan, name="test fake FGS" + ) + fast_grid_scan.scan_invalid.pvname = "" + + yield fast_grid_scan + + +def test_given_settings_valid_when_kickoff_then_run_started( + fast_grid_scan: PandAFastGridScan, +): + when(fast_grid_scan.scan_invalid).get().thenReturn(False) + when(fast_grid_scan.position_counter).get().thenReturn(0) + + mock_run_set_status = mock() + when(fast_grid_scan.run_cmd).put(ANY).thenReturn(mock_run_set_status) + fast_grid_scan.status.subscribe = lambda func, **_: func(1) + + status = fast_grid_scan.kickoff() + + status.wait() + assert status.exception() is None + + verify(fast_grid_scan.run_cmd).put(1) + + +@pytest.mark.parametrize( + "steps, expected_images", + [ + ((10, 10, 0), 100), + ((30, 5, 10), 450), + ((7, 0, 5), 35), + ], +) +def test_given_different_step_numbers_then_expected_images_correct( + fast_grid_scan: PandAFastGridScan, steps, expected_images +): + fast_grid_scan.x_steps.sim_put(steps[0]) # type: ignore + fast_grid_scan.y_steps.sim_put(steps[1]) # type: ignore + fast_grid_scan.z_steps.sim_put(steps[2]) # type: ignore + + assert fast_grid_scan.expected_images.get() == expected_images + + +def test_running_finished_with_all_images_done_then_complete_status_finishes_not_in_error( + fast_grid_scan: PandAFastGridScan, +): + num_pos_1d = 2 + RE = RunEngine() + RE( + set_fast_grid_scan_params( + fast_grid_scan, PandaGridScanParams(x_steps=num_pos_1d, y_steps=num_pos_1d) + ) + ) + + fast_grid_scan.status.sim_put(1) # type: ignore + + complete_status = fast_grid_scan.complete() + assert not complete_status.done + fast_grid_scan.position_counter.sim_put(num_pos_1d**2) # type: ignore + fast_grid_scan.status.sim_put(0) # type: ignore + + complete_status.wait() + + assert complete_status.done + assert complete_status.exception() is None + + +def create_motor_bundle_with_limits(low_limit, high_limit) -> Smargon: + FakeSmargon = make_fake_device(Smargon) + grid_scan_motor_bundle: Smargon = FakeSmargon(name="test fake Smargon") + grid_scan_motor_bundle.wait_for_connection() + for axis in [ + grid_scan_motor_bundle.x, + grid_scan_motor_bundle.y, + grid_scan_motor_bundle.z, + ]: + axis.low_limit_travel.sim_put(low_limit) # type: ignore + axis.high_limit_travel.sim_put(high_limit) # type: ignore + return grid_scan_motor_bundle + + +@pytest.mark.parametrize( + "position, expected_in_limit", + [ + (-1, False), + (20, False), + (5, True), + ], +) +def test_within_limits_check(position, expected_in_limit): + limits = create_motor_bundle_with_limits(0.0, 10).get_xyz_limits() + assert limits.x.is_within(position) == expected_in_limit + + +PASSING_LINE_1 = (1, 5, 1) +PASSING_LINE_2 = (0, 10, 0.5) +FAILING_LINE_1 = (-1, 20, 0.5) +PASSING_CONST = 6 +FAILING_CONST = 15 + + +@pytest.mark.parametrize( + "start, steps, size, expected_in_limits", + [ + (*PASSING_LINE_1, True), + (*PASSING_LINE_2, True), + (*FAILING_LINE_1, False), + (-1, 5, 1, False), + (-1, 10, 2, False), + (0, 10, 0.1, True), + (5, 10, 0.5, True), + (5, 20, 0.6, False), + ], +) +def test_scan_within_limits_1d(start, steps, size, expected_in_limits): + motor_bundle = create_motor_bundle_with_limits(0.0, 10.0) + grid_params = PandaGridScanParams(x_start=start, x_steps=steps, x_step_size=size) + assert grid_params.is_valid(motor_bundle.get_xyz_limits()) == expected_in_limits + + +@pytest.mark.parametrize( + "x_start, x_steps, x_size, y1_start, y_steps, y_size, z1_start, expected_in_limits", + [ + (*PASSING_LINE_1, *PASSING_LINE_2, PASSING_CONST, True), + (*PASSING_LINE_1, *FAILING_LINE_1, PASSING_CONST, False), + (*PASSING_LINE_1, *PASSING_LINE_2, FAILING_CONST, False), + ], +) +def test_scan_within_limits_2d( + x_start, x_steps, x_size, y1_start, y_steps, y_size, z1_start, expected_in_limits +): + motor_bundle = create_motor_bundle_with_limits(0.0, 10.0) + grid_params = PandaGridScanParams( + x_start=x_start, + x_steps=x_steps, + x_step_size=x_size, + y1_start=y1_start, + y_steps=y_steps, + y_step_size=y_size, + z1_start=z1_start, + ) + assert grid_params.is_valid(motor_bundle.get_xyz_limits()) == expected_in_limits + + +@pytest.mark.parametrize( + "x_start, x_steps, x_size, y1_start, y_steps, y_size, z1_start, z2_start, z_steps, z_size, y2_start, expected_in_limits", + [ + ( + *PASSING_LINE_1, + *PASSING_LINE_2, + PASSING_CONST, + *PASSING_LINE_1, + PASSING_CONST, + True, + ), + ( + *PASSING_LINE_1, + *PASSING_LINE_2, + PASSING_CONST, + *PASSING_LINE_1, + FAILING_CONST, + False, + ), + ( + *PASSING_LINE_1, + *PASSING_LINE_2, + PASSING_CONST, + *FAILING_LINE_1, + PASSING_CONST, + False, + ), + ], +) +def test_scan_within_limits_3d( + x_start, + x_steps, + x_size, + y1_start, + y_steps, + y_size, + z1_start, + z2_start, + z_steps, + z_size, + y2_start, + expected_in_limits, +): + motor_bundle = create_motor_bundle_with_limits(0.0, 10.0) + grid_params = PandaGridScanParams( + x_start=x_start, + x_steps=x_steps, + x_step_size=x_size, + y1_start=y1_start, + y_steps=y_steps, + y_step_size=y_size, + z1_start=z1_start, + z2_start=z2_start, + z_steps=z_steps, + z_step_size=z_size, + y2_start=y2_start, + ) + assert grid_params.is_valid(motor_bundle.get_xyz_limits()) == expected_in_limits + + +@pytest.fixture +def grid_scan_params(): + yield PandaGridScanParams( + x_steps=10, + y_steps=15, + z_steps=20, + x_step_size=0.3, + y_step_size=0.2, + z_step_size=0.1, + x_start=0, + y1_start=1, + y2_start=2, + z1_start=3, + z2_start=4, + run_up_distance_mm=0.05, + ) + + +@pytest.mark.parametrize( + "grid_position", + [ + (np.array([-1, 2, 4])), + (np.array([11, 2, 4])), + (np.array([1, 17, 4])), + (np.array([1, 5, 22])), + ], +) +def test_given_x_y_z_out_of_range_then_converting_to_motor_coords_raises( + grid_scan_params: PandaGridScanParams, grid_position +): + with pytest.raises(IndexError): + grid_scan_params.grid_position_to_motor_position(grid_position) + + +def test_given_x_y_z_of_origin_when_get_motor_positions_then_initial_positions_returned( + grid_scan_params: PandaGridScanParams, +): + motor_positions = grid_scan_params.grid_position_to_motor_position( + np.array([0, 0, 0]) + ) + assert np.allclose(motor_positions, np.array([0, 1, 4])) + + +@pytest.mark.parametrize( + "grid_position, expected_x, expected_y, expected_z", + [ + (np.array([1, 1, 1]), 0.3, 1.2, 4.1), + (np.array([2, 11, 16]), 0.6, 3.2, 5.6), + (np.array([6, 5, 5]), 1.8, 2.0, 4.5), + ], +) +def test_given_various_x_y_z_when_get_motor_positions_then_expected_positions_returned( + grid_scan_params: PandaGridScanParams, + grid_position, + expected_x, + expected_y, + expected_z, +): + motor_positions = grid_scan_params.grid_position_to_motor_position(grid_position) + np.testing.assert_allclose( + motor_positions, np.array([expected_x, expected_y, expected_z]) + ) + + +def test_can_run_fast_grid_scan_in_run_engine(fast_grid_scan): + @bpp.run_decorator() + def kickoff_and_complete(device): + yield from bps.kickoff(device) + yield from bps.complete(device) + + RE = RunEngine() + RE(kickoff_and_complete(fast_grid_scan)) + assert RE.state == "idle" + + +def test_given_x_y_z_steps_when_full_number_calculated_then_answer_is_as_expected( + grid_scan_params: PandaGridScanParams, +): + assert grid_scan_params.get_num_images() == 350 From 8895667ae88c7bc1c094c263035aa285668a52f0 Mon Sep 17 00:00:00 2001 From: Oliver Silvester Date: Fri, 19 Jan 2024 10:38:52 +0000 Subject: [PATCH 022/134] use working ophyd-async --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0cc48e5931..dbb235e573 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ classifiers = [ description = "Ophyd devices and other utils that could be used across DLS beamlines" dependencies = [ "ophyd", - "ophyd_async@git+https://github.com/bluesky/ophyd-async@give_panda_name", #Use a specific commit from ophyd async until https://github.com/bluesky/ophyd-async/pull/101 is merged + "ophyd_async@git+https://github.com/bluesky/ophyd-async@ec5729640041ee5b77b4614158793af3a34cf9d8", #Use a specific branch from ophyd async until https://github.com/bluesky/ophyd-async/pull/101 is merged "bluesky", "pyepics", "dataclasses-json", From b24a5a926d2e5fd31ea136c41acf3c113b82735f Mon Sep 17 00:00:00 2001 From: Oliver Silvester Date: Fri, 19 Jan 2024 11:21:49 +0000 Subject: [PATCH 023/134] keep names consistant --- src/dodal/devices/panda_fast_grid_scan.py | 4 ++-- .../devices/unit_tests/test_panda_gridscan.py | 20 +++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/dodal/devices/panda_fast_grid_scan.py b/src/dodal/devices/panda_fast_grid_scan.py index f0cc7af727..d7d0f633db 100644 --- a/src/dodal/devices/panda_fast_grid_scan.py +++ b/src/dodal/devices/panda_fast_grid_scan.py @@ -18,7 +18,7 @@ from dodal.devices.status import await_value -class PandaGridScanParams(GridScanParamsCommon): +class PandAGridScanParams(GridScanParamsCommon): """ Holder class for the parameters of a grid scan in a similar layout to EPICS. These params are used for the panda-triggered @@ -116,7 +116,7 @@ def describe_collect(self): return {} -def set_fast_grid_scan_params(scan: PandAFastGridScan, params: PandaGridScanParams): +def set_fast_grid_scan_params(scan: PandAFastGridScan, params: PandAGridScanParams): yield from mv( scan.x_steps, params.x_steps, diff --git a/tests/devices/unit_tests/test_panda_gridscan.py b/tests/devices/unit_tests/test_panda_gridscan.py index 580448ff5b..e58c1251f7 100644 --- a/tests/devices/unit_tests/test_panda_gridscan.py +++ b/tests/devices/unit_tests/test_panda_gridscan.py @@ -9,7 +9,7 @@ from dodal.devices.panda_fast_grid_scan import ( PandAFastGridScan, - PandaGridScanParams, + PandAGridScanParams, set_fast_grid_scan_params, ) from dodal.devices.smargon import Smargon @@ -68,7 +68,7 @@ def test_running_finished_with_all_images_done_then_complete_status_finishes_not RE = RunEngine() RE( set_fast_grid_scan_params( - fast_grid_scan, PandaGridScanParams(x_steps=num_pos_1d, y_steps=num_pos_1d) + fast_grid_scan, PandAGridScanParams(x_steps=num_pos_1d, y_steps=num_pos_1d) ) ) @@ -134,7 +134,7 @@ def test_within_limits_check(position, expected_in_limit): ) def test_scan_within_limits_1d(start, steps, size, expected_in_limits): motor_bundle = create_motor_bundle_with_limits(0.0, 10.0) - grid_params = PandaGridScanParams(x_start=start, x_steps=steps, x_step_size=size) + grid_params = PandAGridScanParams(x_start=start, x_steps=steps, x_step_size=size) assert grid_params.is_valid(motor_bundle.get_xyz_limits()) == expected_in_limits @@ -150,7 +150,7 @@ def test_scan_within_limits_2d( x_start, x_steps, x_size, y1_start, y_steps, y_size, z1_start, expected_in_limits ): motor_bundle = create_motor_bundle_with_limits(0.0, 10.0) - grid_params = PandaGridScanParams( + grid_params = PandAGridScanParams( x_start=x_start, x_steps=x_steps, x_step_size=x_size, @@ -206,7 +206,7 @@ def test_scan_within_limits_3d( expected_in_limits, ): motor_bundle = create_motor_bundle_with_limits(0.0, 10.0) - grid_params = PandaGridScanParams( + grid_params = PandAGridScanParams( x_start=x_start, x_steps=x_steps, x_step_size=x_size, @@ -224,7 +224,7 @@ def test_scan_within_limits_3d( @pytest.fixture def grid_scan_params(): - yield PandaGridScanParams( + yield PandAGridScanParams( x_steps=10, y_steps=15, z_steps=20, @@ -250,14 +250,14 @@ def grid_scan_params(): ], ) def test_given_x_y_z_out_of_range_then_converting_to_motor_coords_raises( - grid_scan_params: PandaGridScanParams, grid_position + grid_scan_params: PandAGridScanParams, grid_position ): with pytest.raises(IndexError): grid_scan_params.grid_position_to_motor_position(grid_position) def test_given_x_y_z_of_origin_when_get_motor_positions_then_initial_positions_returned( - grid_scan_params: PandaGridScanParams, + grid_scan_params: PandAGridScanParams, ): motor_positions = grid_scan_params.grid_position_to_motor_position( np.array([0, 0, 0]) @@ -274,7 +274,7 @@ def test_given_x_y_z_of_origin_when_get_motor_positions_then_initial_positions_r ], ) def test_given_various_x_y_z_when_get_motor_positions_then_expected_positions_returned( - grid_scan_params: PandaGridScanParams, + grid_scan_params: PandAGridScanParams, grid_position, expected_x, expected_y, @@ -298,6 +298,6 @@ def kickoff_and_complete(device): def test_given_x_y_z_steps_when_full_number_calculated_then_answer_is_as_expected( - grid_scan_params: PandaGridScanParams, + grid_scan_params: PandAGridScanParams, ): assert grid_scan_params.get_num_images() == 350 From edd155237938615b51bcd9a5b256f60c6c9955ce Mon Sep 17 00:00:00 2001 From: Oliver Silvester Date: Wed, 24 Jan 2024 11:35:01 +0000 Subject: [PATCH 024/134] Add x velocity speed limit --- src/dodal/devices/smargon.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/dodal/devices/smargon.py b/src/dodal/devices/smargon.py index 494301c4a8..9a27bde268 100644 --- a/src/dodal/devices/smargon.py +++ b/src/dodal/devices/smargon.py @@ -1,7 +1,7 @@ from enum import Enum from ophyd import Component as Cpt -from ophyd import Device, EpicsMotor, EpicsSignal +from ophyd import Device, EpicsMotor, EpicsSignal, EpicsSignalRO from ophyd.epics_motor import MotorBundle from ophyd.status import StatusBase @@ -49,6 +49,7 @@ class Smargon(MotorBundle): """ x = Cpt(EpicsMotor, "X") + x_speed_limit_mm_per_s = Cpt(EpicsSignalRO, "X.VMAX") y = Cpt(EpicsMotor, "Y") z = Cpt(EpicsMotor, "Z") chi = Cpt(EpicsMotor, "CHI") From 84e78e45a6c3eebb9771c3ecd9bd5c02438842e6 Mon Sep 17 00:00:00 2001 From: Oliver Silvester Date: Thu, 25 Jan 2024 16:49:50 +0000 Subject: [PATCH 025/134] Remove redundant tests, put back panda grid scan complete status --- src/dodal/devices/panda_fast_grid_scan.py | 25 ++- .../devices/unit_tests/test_panda_gridscan.py | 194 ------------------ 2 files changed, 21 insertions(+), 198 deletions(-) diff --git a/src/dodal/devices/panda_fast_grid_scan.py b/src/dodal/devices/panda_fast_grid_scan.py index d7d0f633db..b4019f71fb 100644 --- a/src/dodal/devices/panda_fast_grid_scan.py +++ b/src/dodal/devices/panda_fast_grid_scan.py @@ -11,13 +11,30 @@ ) from ophyd.status import DeviceStatus, StatusBase -from dodal.devices.fast_grid_scan import ( - GridScanCompleteStatus, - GridScanParamsCommon, -) +from dodal.devices.fast_grid_scan import GridScanParamsCommon from dodal.devices.status import await_value +class GridScanCompleteStatus(DeviceStatus): + """ + A Status for the grid scan completion + Progress bar functionality has been removed for now in the panda fast grid scan + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.device.status.subscribe(self._running_changed) + + def _running_changed(self, value=None, old_value=None, **kwargs): + if (old_value == 1) and (value == 0): + self.set_finished() + self.clean_up() + + def clean_up(self): + self.device.status.clear_sub(self._running_changed) + + class PandAGridScanParams(GridScanParamsCommon): """ Holder class for the parameters of a grid scan in a similar diff --git a/tests/devices/unit_tests/test_panda_gridscan.py b/tests/devices/unit_tests/test_panda_gridscan.py index e58c1251f7..a200f814e3 100644 --- a/tests/devices/unit_tests/test_panda_gridscan.py +++ b/tests/devices/unit_tests/test_panda_gridscan.py @@ -1,4 +1,3 @@ -import numpy as np import pytest from bluesky import plan_stubs as bps from bluesky import preprocessors as bpp @@ -99,193 +98,6 @@ def create_motor_bundle_with_limits(low_limit, high_limit) -> Smargon: return grid_scan_motor_bundle -@pytest.mark.parametrize( - "position, expected_in_limit", - [ - (-1, False), - (20, False), - (5, True), - ], -) -def test_within_limits_check(position, expected_in_limit): - limits = create_motor_bundle_with_limits(0.0, 10).get_xyz_limits() - assert limits.x.is_within(position) == expected_in_limit - - -PASSING_LINE_1 = (1, 5, 1) -PASSING_LINE_2 = (0, 10, 0.5) -FAILING_LINE_1 = (-1, 20, 0.5) -PASSING_CONST = 6 -FAILING_CONST = 15 - - -@pytest.mark.parametrize( - "start, steps, size, expected_in_limits", - [ - (*PASSING_LINE_1, True), - (*PASSING_LINE_2, True), - (*FAILING_LINE_1, False), - (-1, 5, 1, False), - (-1, 10, 2, False), - (0, 10, 0.1, True), - (5, 10, 0.5, True), - (5, 20, 0.6, False), - ], -) -def test_scan_within_limits_1d(start, steps, size, expected_in_limits): - motor_bundle = create_motor_bundle_with_limits(0.0, 10.0) - grid_params = PandAGridScanParams(x_start=start, x_steps=steps, x_step_size=size) - assert grid_params.is_valid(motor_bundle.get_xyz_limits()) == expected_in_limits - - -@pytest.mark.parametrize( - "x_start, x_steps, x_size, y1_start, y_steps, y_size, z1_start, expected_in_limits", - [ - (*PASSING_LINE_1, *PASSING_LINE_2, PASSING_CONST, True), - (*PASSING_LINE_1, *FAILING_LINE_1, PASSING_CONST, False), - (*PASSING_LINE_1, *PASSING_LINE_2, FAILING_CONST, False), - ], -) -def test_scan_within_limits_2d( - x_start, x_steps, x_size, y1_start, y_steps, y_size, z1_start, expected_in_limits -): - motor_bundle = create_motor_bundle_with_limits(0.0, 10.0) - grid_params = PandAGridScanParams( - x_start=x_start, - x_steps=x_steps, - x_step_size=x_size, - y1_start=y1_start, - y_steps=y_steps, - y_step_size=y_size, - z1_start=z1_start, - ) - assert grid_params.is_valid(motor_bundle.get_xyz_limits()) == expected_in_limits - - -@pytest.mark.parametrize( - "x_start, x_steps, x_size, y1_start, y_steps, y_size, z1_start, z2_start, z_steps, z_size, y2_start, expected_in_limits", - [ - ( - *PASSING_LINE_1, - *PASSING_LINE_2, - PASSING_CONST, - *PASSING_LINE_1, - PASSING_CONST, - True, - ), - ( - *PASSING_LINE_1, - *PASSING_LINE_2, - PASSING_CONST, - *PASSING_LINE_1, - FAILING_CONST, - False, - ), - ( - *PASSING_LINE_1, - *PASSING_LINE_2, - PASSING_CONST, - *FAILING_LINE_1, - PASSING_CONST, - False, - ), - ], -) -def test_scan_within_limits_3d( - x_start, - x_steps, - x_size, - y1_start, - y_steps, - y_size, - z1_start, - z2_start, - z_steps, - z_size, - y2_start, - expected_in_limits, -): - motor_bundle = create_motor_bundle_with_limits(0.0, 10.0) - grid_params = PandAGridScanParams( - x_start=x_start, - x_steps=x_steps, - x_step_size=x_size, - y1_start=y1_start, - y_steps=y_steps, - y_step_size=y_size, - z1_start=z1_start, - z2_start=z2_start, - z_steps=z_steps, - z_step_size=z_size, - y2_start=y2_start, - ) - assert grid_params.is_valid(motor_bundle.get_xyz_limits()) == expected_in_limits - - -@pytest.fixture -def grid_scan_params(): - yield PandAGridScanParams( - x_steps=10, - y_steps=15, - z_steps=20, - x_step_size=0.3, - y_step_size=0.2, - z_step_size=0.1, - x_start=0, - y1_start=1, - y2_start=2, - z1_start=3, - z2_start=4, - run_up_distance_mm=0.05, - ) - - -@pytest.mark.parametrize( - "grid_position", - [ - (np.array([-1, 2, 4])), - (np.array([11, 2, 4])), - (np.array([1, 17, 4])), - (np.array([1, 5, 22])), - ], -) -def test_given_x_y_z_out_of_range_then_converting_to_motor_coords_raises( - grid_scan_params: PandAGridScanParams, grid_position -): - with pytest.raises(IndexError): - grid_scan_params.grid_position_to_motor_position(grid_position) - - -def test_given_x_y_z_of_origin_when_get_motor_positions_then_initial_positions_returned( - grid_scan_params: PandAGridScanParams, -): - motor_positions = grid_scan_params.grid_position_to_motor_position( - np.array([0, 0, 0]) - ) - assert np.allclose(motor_positions, np.array([0, 1, 4])) - - -@pytest.mark.parametrize( - "grid_position, expected_x, expected_y, expected_z", - [ - (np.array([1, 1, 1]), 0.3, 1.2, 4.1), - (np.array([2, 11, 16]), 0.6, 3.2, 5.6), - (np.array([6, 5, 5]), 1.8, 2.0, 4.5), - ], -) -def test_given_various_x_y_z_when_get_motor_positions_then_expected_positions_returned( - grid_scan_params: PandAGridScanParams, - grid_position, - expected_x, - expected_y, - expected_z, -): - motor_positions = grid_scan_params.grid_position_to_motor_position(grid_position) - np.testing.assert_allclose( - motor_positions, np.array([expected_x, expected_y, expected_z]) - ) - - def test_can_run_fast_grid_scan_in_run_engine(fast_grid_scan): @bpp.run_decorator() def kickoff_and_complete(device): @@ -295,9 +107,3 @@ def kickoff_and_complete(device): RE = RunEngine() RE(kickoff_and_complete(fast_grid_scan)) assert RE.state == "idle" - - -def test_given_x_y_z_steps_when_full_number_calculated_then_answer_is_as_expected( - grid_scan_params: PandAGridScanParams, -): - assert grid_scan_params.get_num_images() == 350 From 41d88c67fc870ae3ac69318674b29cbe1fc6baa8 Mon Sep 17 00:00:00 2001 From: Robert Tuck Date: Tue, 16 Jan 2024 11:10:06 +0000 Subject: [PATCH 026/134] (DiamondLightSource/hyperion#1071) Replace detector current_energy_ev with expected_energy_ev and make optional to allow deferred population --- src/dodal/devices/detector.py | 2 +- src/dodal/devices/eiger.py | 2 +- tests/devices/unit_tests/test_eiger.py | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/dodal/devices/detector.py b/src/dodal/devices/detector.py index 743fd6cecd..73c8cc8d2f 100644 --- a/src/dodal/devices/detector.py +++ b/src/dodal/devices/detector.py @@ -28,7 +28,7 @@ class DetectorParams(BaseModel): """Holds parameters for the detector. Provides access to a list of Dectris detector sizes and a converter for distance to beam centre.""" - current_energy_ev: float + expected_energy_ev: Optional[float] exposure_time: float directory: str prefix: str diff --git a/src/dodal/devices/eiger.py b/src/dodal/devices/eiger.py index 74ece6982e..84c4be92e3 100644 --- a/src/dodal/devices/eiger.py +++ b/src/dodal/devices/eiger.py @@ -320,7 +320,7 @@ def do_arming_chain(self) -> Status: functions_to_do_arm.extend( [ lambda: self.set_detector_threshold( - energy=detector_params.current_energy_ev + energy=detector_params.expected_energy_ev ), self.set_cam_pvs, self.set_odin_number_of_frame_chunks, diff --git a/tests/devices/unit_tests/test_eiger.py b/tests/devices/unit_tests/test_eiger.py index 88084ada2e..f7ac7d08a3 100644 --- a/tests/devices/unit_tests/test_eiger.py +++ b/tests/devices/unit_tests/test_eiger.py @@ -289,7 +289,7 @@ def test_unsuccessful_true_roi_mode_change_results_in_callback_error( unwrapped_funcs = [ lambda: fake_eiger.change_roi_mode(enable=True), lambda: fake_eiger.set_detector_threshold( - energy=fake_eiger.detector_params.current_energy_ev + energy=fake_eiger.detector_params.expected_energy_ev ), ] with pytest.raises(StatusException): @@ -309,7 +309,7 @@ def test_unsuccessful_false_roi_mode_change_results_in_callback_error( unwrapped_funcs = [ lambda: fake_eiger.change_roi_mode(enable=False), lambda: fake_eiger.set_detector_threshold( - energy=fake_eiger.detector_params.current_energy_ev + energy=fake_eiger.detector_params.expected_energy_ev ), ] with pytest.raises(StatusException): @@ -480,7 +480,7 @@ def get_good_status(): unwrapped_funcs = [ ( lambda: fake_eiger.set_detector_threshold( - energy=fake_eiger.detector_params.current_energy_ev + energy=fake_eiger.detector_params.expected_energy_ev ) ), (fake_eiger.set_cam_pvs), From d6d4ee65a28eb2d2e2e7afd8b53920d1e52b6c31 Mon Sep 17 00:00:00 2001 From: Robert Tuck Date: Tue, 16 Jan 2024 11:37:08 +0000 Subject: [PATCH 027/134] (DiamondLightSource/hyperion#1071) Replace current_energy_ev with expected_energy_ev for some remaining tests --- tests/devices/system_tests/test_eiger_system.py | 2 +- tests/devices/unit_tests/test_detector.py | 6 +++--- tests/devices/unit_tests/test_eiger.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/devices/system_tests/test_eiger_system.py b/tests/devices/system_tests/test_eiger_system.py index 02986d03e9..cf71c10c53 100644 --- a/tests/devices/system_tests/test_eiger_system.py +++ b/tests/devices/system_tests/test_eiger_system.py @@ -6,7 +6,7 @@ @pytest.fixture() def eiger(): detector_params: DetectorParams = DetectorParams( - current_energy_ev=100, + expected_energy_ev=100, exposure_time=0.1, directory="/tmp", prefix="file_name", diff --git a/tests/devices/unit_tests/test_detector.py b/tests/devices/unit_tests/test_detector.py index 89b4656b29..218692297b 100644 --- a/tests/devices/unit_tests/test_detector.py +++ b/tests/devices/unit_tests/test_detector.py @@ -36,7 +36,7 @@ def test_if_trailing_slash_provided_then_not_appended(): ) def test_correct_det_dist_to_beam_converter_path_passed_in(mocked_parse_table): params = DetectorParams( - current_energy_ev=100, + expected_energy_ev=100, exposure_time=1.0, directory="directory", prefix="test", @@ -59,7 +59,7 @@ def test_correct_det_dist_to_beam_converter_path_passed_in(mocked_parse_table): ) def test_run_number_correct_when_not_specified(mocked_parse_table, tmpdir): params = DetectorParams( - current_energy_ev=100, + expected_energy_ev=100, exposure_time=1.0, directory=str(tmpdir), prefix="test", @@ -80,7 +80,7 @@ def test_run_number_correct_when_not_specified(mocked_parse_table, tmpdir): ) def test_run_number_correct_when_specified(mocked_parse_table, tmpdir): params = DetectorParams( - current_energy_ev=100, + expected_energy_ev=100, exposure_time=1.0, directory=str(tmpdir), run_number=6, diff --git a/tests/devices/unit_tests/test_eiger.py b/tests/devices/unit_tests/test_eiger.py index f7ac7d08a3..ae964811f1 100644 --- a/tests/devices/unit_tests/test_eiger.py +++ b/tests/devices/unit_tests/test_eiger.py @@ -35,7 +35,7 @@ class StatusException(Exception): def create_new_params() -> DetectorParams: return DetectorParams( - current_energy_ev=TEST_CURRENT_ENERGY, + expected_energy_ev=TEST_CURRENT_ENERGY, exposure_time=TEST_EXPOSURE_TIME, directory=TEST_DIR, prefix=TEST_PREFIX, From f49d8b399f8c18b18f56c2e7e3aa99495430b6df Mon Sep 17 00:00:00 2001 From: Robert Tuck Date: Fri, 19 Jan 2024 09:11:15 +0000 Subject: [PATCH 028/134] (#1071) Changes following PR comments --- tests/devices/unit_tests/test_eiger.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/devices/unit_tests/test_eiger.py b/tests/devices/unit_tests/test_eiger.py index ae964811f1..0c2021cf52 100644 --- a/tests/devices/unit_tests/test_eiger.py +++ b/tests/devices/unit_tests/test_eiger.py @@ -15,7 +15,7 @@ TEST_DETECTOR_SIZE_CONSTANTS = EIGER2_X_16M_SIZE -TEST_CURRENT_ENERGY = 100.0 +TEST_EXPECTED_ENERGY = 100.0 TEST_EXPOSURE_TIME = 1.0 TEST_DIR = "/test/dir" TEST_PREFIX = "test" @@ -35,7 +35,7 @@ class StatusException(Exception): def create_new_params() -> DetectorParams: return DetectorParams( - expected_energy_ev=TEST_CURRENT_ENERGY, + expected_energy_ev=TEST_EXPECTED_ENERGY, exposure_time=TEST_EXPOSURE_TIME, directory=TEST_DIR, prefix=TEST_PREFIX, From 3547cd77e5bf2da1a90c7b93264cc6ff8248778b Mon Sep 17 00:00:00 2001 From: David Perl Date: Fri, 26 Jan 2024 13:29:00 +0000 Subject: [PATCH 029/134] add logging --- src/dodal/devices/fast_grid_scan.py | 6 ++++-- src/dodal/log.py | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/dodal/devices/fast_grid_scan.py b/src/dodal/devices/fast_grid_scan.py index a0d9a63644..43f515f14f 100644 --- a/src/dodal/devices/fast_grid_scan.py +++ b/src/dodal/devices/fast_grid_scan.py @@ -19,6 +19,7 @@ from dodal.devices.motors import XYZLimitBundle from dodal.devices.status import await_value +from dodal.log import LOGGER from dodal.parameters.experiment_parameter_base import AbstractExperimentParameterBase @@ -295,15 +296,16 @@ def kickoff(self) -> StatusBase: def scan(): try: - self.log.debug("Running scan") + LOGGER.info("Running scan") self.run_cmd.put(1) - self.log.debug("Waiting for scan to start") + LOGGER.info("Waiting for scan to start") await_value(self.status, 1).wait() st.set_finished() except Exception as e: st.set_exception(e) threading.Thread(target=scan, daemon=True).start() + LOGGER.info("returning FGS kickoff status") return st def complete(self) -> DeviceStatus: diff --git a/src/dodal/log.py b/src/dodal/log.py index 408c04c9e8..d85d7901f2 100644 --- a/src/dodal/log.py +++ b/src/dodal/log.py @@ -139,6 +139,7 @@ def set_up_logging_handlers( """ logging_level = logging_level or "INFO" stream_handler = logging.StreamHandler() + print(f"adding handler {stream_handler} to logger {logger}, at level: {logging_level}") _add_handler(logger, stream_handler, logging_level) graylog_handler = set_up_graylog_handler(logging_level, dev_mode, logger) file_handler_logging_level = ( From 165850e625204231169bb383bf892206d9aa2251 Mon Sep 17 00:00:00 2001 From: David Perl Date: Fri, 26 Jan 2024 14:56:06 +0000 Subject: [PATCH 030/134] sleep before setting off FGS --- src/dodal/devices/fast_grid_scan.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/dodal/devices/fast_grid_scan.py b/src/dodal/devices/fast_grid_scan.py index 43f515f14f..db9ac54787 100644 --- a/src/dodal/devices/fast_grid_scan.py +++ b/src/dodal/devices/fast_grid_scan.py @@ -268,7 +268,7 @@ class FastGridScan(Device): expected_images = Component(Signal) # Kickoff timeout in seconds - KICKOFF_TIMEOUT: float = 5.0 + KICKOFF_TIMEOUT: float = 60.0 def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -297,10 +297,14 @@ def kickoff(self) -> StatusBase: def scan(): try: LOGGER.info("Running scan") + from time import sleep + sleep(0.1) # TODO get rid of this self.run_cmd.put(1) LOGGER.info("Waiting for scan to start") await_value(self.status, 1).wait() + LOGGER.info("Scan started according to EPICS, setting status to done") st.set_finished() + LOGGER.info(f"{st} finished, exiting FGS kickoff thread") except Exception as e: st.set_exception(e) From 8390669b3245173ffdb9666332533306a1383e1a Mon Sep 17 00:00:00 2001 From: David Perl Date: Fri, 26 Jan 2024 16:05:19 +0000 Subject: [PATCH 031/134] tidy up logging --- src/dodal/devices/fast_grid_scan.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/dodal/devices/fast_grid_scan.py b/src/dodal/devices/fast_grid_scan.py index db9ac54787..badbe947ad 100644 --- a/src/dodal/devices/fast_grid_scan.py +++ b/src/dodal/devices/fast_grid_scan.py @@ -296,20 +296,19 @@ def kickoff(self) -> StatusBase: def scan(): try: - LOGGER.info("Running scan") + LOGGER.debug("Running scan") from time import sleep - sleep(0.1) # TODO get rid of this + sleep(0.1) # TODO see https://github.com/DiamondLightSource/hyperion/issues/1101 self.run_cmd.put(1) - LOGGER.info("Waiting for scan to start") + LOGGER.info("Waiting for FGS to start") await_value(self.status, 1).wait() - LOGGER.info("Scan started according to EPICS, setting status to done") st.set_finished() - LOGGER.info(f"{st} finished, exiting FGS kickoff thread") + LOGGER.debug(f"{st} finished, exiting FGS kickoff thread") except Exception as e: st.set_exception(e) threading.Thread(target=scan, daemon=True).start() - LOGGER.info("returning FGS kickoff status") + LOGGER.info("Returning FGS kickoff status") return st def complete(self) -> DeviceStatus: From 8e4b580b03db7f117ae5cb4bf7f21c17bdac6367 Mon Sep 17 00:00:00 2001 From: David Perl Date: Fri, 26 Jan 2024 16:11:43 +0000 Subject: [PATCH 032/134] fix linting --- src/dodal/devices/fast_grid_scan.py | 5 ++++- src/dodal/log.py | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/dodal/devices/fast_grid_scan.py b/src/dodal/devices/fast_grid_scan.py index badbe947ad..a42601db72 100644 --- a/src/dodal/devices/fast_grid_scan.py +++ b/src/dodal/devices/fast_grid_scan.py @@ -298,7 +298,10 @@ def scan(): try: LOGGER.debug("Running scan") from time import sleep - sleep(0.1) # TODO see https://github.com/DiamondLightSource/hyperion/issues/1101 + + sleep( + 0.1 + ) # TODO see https://github.com/DiamondLightSource/hyperion/issues/1101 self.run_cmd.put(1) LOGGER.info("Waiting for FGS to start") await_value(self.status, 1).wait() diff --git a/src/dodal/log.py b/src/dodal/log.py index d85d7901f2..b0633eb89c 100644 --- a/src/dodal/log.py +++ b/src/dodal/log.py @@ -139,7 +139,9 @@ def set_up_logging_handlers( """ logging_level = logging_level or "INFO" stream_handler = logging.StreamHandler() - print(f"adding handler {stream_handler} to logger {logger}, at level: {logging_level}") + print( + f"adding handler {stream_handler} to logger {logger}, at level: {logging_level}" + ) _add_handler(logger, stream_handler, logging_level) graylog_handler = set_up_graylog_handler(logging_level, dev_mode, logger) file_handler_logging_level = ( From 9554afcf8c8261578c42bf762a447741c39749d9 Mon Sep 17 00:00:00 2001 From: David Perl Date: Mon, 29 Jan 2024 08:40:08 +0000 Subject: [PATCH 033/134] reset timeout --- src/dodal/devices/fast_grid_scan.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dodal/devices/fast_grid_scan.py b/src/dodal/devices/fast_grid_scan.py index a42601db72..e0ac78f749 100644 --- a/src/dodal/devices/fast_grid_scan.py +++ b/src/dodal/devices/fast_grid_scan.py @@ -268,7 +268,7 @@ class FastGridScan(Device): expected_images = Component(Signal) # Kickoff timeout in seconds - KICKOFF_TIMEOUT: float = 60.0 + KICKOFF_TIMEOUT: float = 5.0 def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) From 5a273a0e9b147a1f1a35c74056cdb43c67ed5367 Mon Sep 17 00:00:00 2001 From: Dominic Oram Date: Tue, 30 Jan 2024 12:27:14 +0000 Subject: [PATCH 034/134] (#311) Remove mypy precommit check --- .pre-commit-config.yaml | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bb84047f18..7d3c4e26fa 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -23,11 +23,12 @@ repos: language: system entry: ruff types: [python] - - - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.7.1 - hooks: - - id: mypy - files: 'src/.*\.py$' - additional_dependencies: [types-requests, pydantic] - args: ["--ignore-missing-imports", "--no-strict-optional"] + + # Re-enable after https://github.com/DiamondLightSource/dodal/issues/311 + # - repo: https://github.com/pre-commit/mirrors-mypy + # rev: v1.7.1 + # hooks: + # - id: mypy + # files: 'src/.*\.py$' + # additional_dependencies: [types-requests, pydantic] + # args: ["--ignore-missing-imports", "--no-strict-optional"] From d2a3fdb717d534c3ab348e8b73e7c2af323b9842 Mon Sep 17 00:00:00 2001 From: Dominic Oram Date: Tue, 30 Jan 2024 12:53:01 +0000 Subject: [PATCH 035/134] (#311) Actually show errors in mypy --- .pre-commit-config.yaml | 2 +- pyproject.toml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7d3c4e26fa..c3b62b737e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -31,4 +31,4 @@ repos: # - id: mypy # files: 'src/.*\.py$' # additional_dependencies: [types-requests, pydantic] - # args: ["--ignore-missing-imports", "--no-strict-optional"] + # args: ["--ignore-missing-imports", "--show-traceback", "--no-strict-optional"] diff --git a/pyproject.toml b/pyproject.toml index dbb235e573..08056ec8ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,9 +23,9 @@ dependencies = [ "requests", "graypy", "pydantic<2.0", - "opencv-python-headless", # For pin-tip detection. - "aioca", # Required for CA support with ophyd-async. - "p4p", # Required for PVA support with ophyd-async. + "opencv-python-headless", # For pin-tip detection. + "aioca", # Required for CA support with ophyd-async. + "p4p", # Required for PVA support with ophyd-async. ] dynamic = ["version"] @@ -113,7 +113,7 @@ allowlist_externals = sphinx-autobuild commands = pytest: pytest -m 'not s03' {posargs} - mypy: mypy src tests -v --ignore-missing-imports --no-strict-optional --check-untyped-defs {posargs} + mypy: mypy src tests -v --ignore-missing-imports --show-traceback --no-strict-optional --check-untyped-defs {posargs} pre-commit: pre-commit run --all-files {posargs} docs: sphinx-{posargs:build -EW --keep-going} -T docs build/html """ From 35b0885d3ea5b85bb577fd75f132d4413c272eae Mon Sep 17 00:00:00 2001 From: Dominic Oram Date: Tue, 30 Jan 2024 17:07:16 +0000 Subject: [PATCH 036/134] (DiamondLightSource/hyperion#1105) Increase the FGS wait for kickoff --- src/dodal/devices/fast_grid_scan.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dodal/devices/fast_grid_scan.py b/src/dodal/devices/fast_grid_scan.py index e0ac78f749..89dfbf326b 100644 --- a/src/dodal/devices/fast_grid_scan.py +++ b/src/dodal/devices/fast_grid_scan.py @@ -300,7 +300,7 @@ def scan(): from time import sleep sleep( - 0.1 + 0.6 ) # TODO see https://github.com/DiamondLightSource/hyperion/issues/1101 self.run_cmd.put(1) LOGGER.info("Waiting for FGS to start") From a6e5fffa7d27b49f499c4c735cbf8525dfaa7aa7 Mon Sep 17 00:00:00 2001 From: Stanislaw Malinowski Date: Fri, 2 Feb 2024 11:40:54 +0000 Subject: [PATCH 037/134] scatterguard device refactor --- src/dodal/devices/aperturescatterguard.py | 217 +++++++++++----------- 1 file changed, 113 insertions(+), 104 deletions(-) diff --git a/src/dodal/devices/aperturescatterguard.py b/src/dodal/devices/aperturescatterguard.py index bbf9d96369..717d3fc839 100644 --- a/src/dodal/devices/aperturescatterguard.py +++ b/src/dodal/devices/aperturescatterguard.py @@ -1,6 +1,6 @@ +from collections import namedtuple from dataclasses import dataclass -from enum import Enum -from typing import List, Literal, Optional, Tuple +from typing import List, Optional import numpy as np from ophyd import Component as Cpt @@ -12,136 +12,140 @@ from dodal.devices.scatterguard import Scatterguard from dodal.log import LOGGER -Aperture5d = Tuple[float, float, float, float, float] - class InvalidApertureMove(Exception): pass -class ConfigurationRadiusMicrons(Optional[float], Enum): - LARGE = 100 - MEDIUM = 50 - SMALL = 20 - INVALID = None - ROBOT_LOAD = None +ApertureFiveDimensionalLocation = namedtuple( + "ApertureFiveDimensionalLocation", + [ + "miniap_x", + "miniap_y", + "miniap_z", + "scatterguard_x", + "scatterguard_y", + ], +) + + +@dataclass +class SingleAperturePosition: + name: str + radius_microns: int | None + location: ApertureFiveDimensionalLocation @dataclass class AperturePositions: - """Holds tuples (miniap_x, miniap_y, miniap_z, scatterguard_x, scatterguard_y) - representing the motor positions needed to select a particular aperture size. - """ + """Holds the motor positions needed to select a particular aperture size.""" - LARGE: Aperture5d - MEDIUM: Aperture5d - SMALL: Aperture5d - ROBOT_LOAD: Aperture5d + LARGE: SingleAperturePosition + MEDIUM: SingleAperturePosition + SMALL: SingleAperturePosition + ROBOT_LOAD: SingleAperturePosition # one micrometre tolerance TOLERANCE_MM: float = 0.001 - def _distance_check( - self, - target: Aperture5d, - present: Aperture5d, - ) -> bool: - return np.allclose(present, target, AperturePositions.TOLERANCE_MM) - - def match_to_name(self, present_position: Aperture5d) -> ConfigurationRadiusMicrons: - assert self.position_valid(present_position) - positions: List[(Literal, Aperture5d)] = [ - (ConfigurationRadiusMicrons.LARGE, self.LARGE), - (ConfigurationRadiusMicrons.MEDIUM, self.MEDIUM), - (ConfigurationRadiusMicrons.SMALL, self.SMALL), - (ConfigurationRadiusMicrons.ROBOT_LOAD, self.ROBOT_LOAD), - ] - - for position_name, position_constant in positions: - if self._distance_check(position_constant, present_position): - return position_name - - return ConfigurationRadiusMicrons.INVALID - @classmethod def from_gda_beamline_params(cls, params): return cls( - LARGE=( - params["miniap_x_LARGE_APERTURE"], - params["miniap_y_LARGE_APERTURE"], - params["miniap_z_LARGE_APERTURE"], - params["sg_x_LARGE_APERTURE"], - params["sg_y_LARGE_APERTURE"], + LARGE=SingleAperturePosition( + "Large", + 100, + ApertureFiveDimensionalLocation( + params["miniap_x_LARGE_APERTURE"], + params["miniap_y_LARGE_APERTURE"], + params["miniap_z_LARGE_APERTURE"], + params["sg_x_LARGE_APERTURE"], + params["sg_y_LARGE_APERTURE"], + ), ), - MEDIUM=( - params["miniap_x_MEDIUM_APERTURE"], - params["miniap_y_MEDIUM_APERTURE"], - params["miniap_z_MEDIUM_APERTURE"], - params["sg_x_MEDIUM_APERTURE"], - params["sg_y_MEDIUM_APERTURE"], + MEDIUM=SingleAperturePosition( + "Medium", + 50, + ApertureFiveDimensionalLocation( + params["miniap_x_MEDIUM_APERTURE"], + params["miniap_y_MEDIUM_APERTURE"], + params["miniap_z_MEDIUM_APERTURE"], + params["sg_x_MEDIUM_APERTURE"], + params["sg_y_MEDIUM_APERTURE"], + ), ), - SMALL=( - params["miniap_x_SMALL_APERTURE"], - params["miniap_y_SMALL_APERTURE"], - params["miniap_z_SMALL_APERTURE"], - params["sg_x_SMALL_APERTURE"], - params["sg_y_SMALL_APERTURE"], + SMALL=SingleAperturePosition( + "Small", + 20, + ApertureFiveDimensionalLocation( + params["miniap_x_SMALL_APERTURE"], + params["miniap_y_SMALL_APERTURE"], + params["miniap_z_SMALL_APERTURE"], + params["sg_x_SMALL_APERTURE"], + params["sg_y_SMALL_APERTURE"], + ), ), - ROBOT_LOAD=( - params["miniap_x_ROBOT_LOAD"], - params["miniap_y_ROBOT_LOAD"], - params["miniap_z_ROBOT_LOAD"], - params["sg_x_ROBOT_LOAD"], - params["sg_y_ROBOT_LOAD"], + ROBOT_LOAD=SingleAperturePosition( + "Robot load", + None, + ApertureFiveDimensionalLocation( + params["miniap_x_ROBOT_LOAD"], + params["miniap_y_ROBOT_LOAD"], + params["miniap_z_ROBOT_LOAD"], + params["sg_x_ROBOT_LOAD"], + params["sg_y_ROBOT_LOAD"], + ), ), ) - def position_valid(self, pos: Aperture5d) -> bool: + def get_new_position( + self, pos: ApertureFiveDimensionalLocation + ) -> SingleAperturePosition: """ Check if argument 'pos' is a valid position in this AperturePositions object. """ - options = [self.LARGE, self.MEDIUM, self.SMALL, self.ROBOT_LOAD] - return pos in options + options: List[SingleAperturePosition] = [ + self.LARGE, + self.MEDIUM, + self.SMALL, + self.ROBOT_LOAD, + ] + for obj in options: + if np.allclose(obj.location, pos, atol=self.TOLERANCE_MM): + return obj + return None class ApertureScatterguard(InfoLoggingDevice): aperture = Cpt(Aperture, "-MO-MAPT-01:") scatterguard = Cpt(Scatterguard, "-MO-SCAT-01:") aperture_positions: Optional[AperturePositions] = None - aperture_name = ConfigurationRadiusMicrons.INVALID - - class NamingSignal(Signal): - def get(self): - return self.parent.aperture_name.value - - ap_name = Cpt(NamingSignal) + selected_aperture = Cpt(Signal) APERTURE_Z_TOLERANCE = 3 # Number of MRES steps def load_aperture_positions(self, positions: AperturePositions): + print(self.aperture_positions.LARGE.location.scatterguard_x) LOGGER.info(f"{self.name} loaded in {positions}") self.aperture_positions = positions - def _update_name(self, pos: Aperture5d) -> None: - name = self.aperture_positions.match_to_name(pos) - self.aperture_name = name - - def set(self, pos: Aperture5d) -> AndStatus: + def set(self, pos: ApertureFiveDimensionalLocation) -> AndStatus: + new_selected_aperture: SingleAperturePosition | None = None try: + # make sure that the reference values are loaded assert isinstance(self.aperture_positions, AperturePositions) - assert self.aperture_positions.position_valid(pos) + # get the new aperture obrect + new_selected_aperture = self.aperture_positions.get_new_position(pos) + assert new_selected_aperture is not None except AssertionError as e: raise InvalidApertureMove(repr(e)) - self._update_name(pos) - return self._safe_move_within_datacollection_range(*pos) + + self.selected_aperture.set(new_selected_aperture) + return self._safe_move_within_datacollection_range( + new_selected_aperture.location + ) def _safe_move_within_datacollection_range( - self, - aperture_x: float, - aperture_y: float, - aperture_z: float, - scatterguard_x: float, - scatterguard_y: float, - ) -> Status: + self, pos: ApertureFiveDimensionalLocation + ) -> AndStatus | Status: """ Move the aperture and scatterguard combo safely to a new position. See https://github.com/DiamondLightSource/hyperion/wiki/Aperture-Scatterguard-Collisions @@ -150,6 +154,10 @@ def _safe_move_within_datacollection_range( # EpicsMotor does not have deadband/MRES field, so the way to check if we are # in a datacollection position is to see if we are "ready" (DMOV) and the target # position is correct + + # unpacking the position + aperture_x, aperture_y, aperture_z, scatterguard_x, scatterguard_y = pos + ap_z_in_position = self.aperture.z.motor_done_move.get() # CASE still moving if not ap_z_in_position: @@ -161,10 +169,12 @@ def _safe_move_within_datacollection_range( ) ) return status + + # CASE invalid target position current_ap_z = self.aperture.z.user_setpoint.get() tolerance = self.APERTURE_Z_TOLERANCE * self.aperture.z.motor_resolution.get() - # CASE invalid target position - if abs(current_ap_z - aperture_z) > tolerance: + diff_on_z = abs(current_ap_z - aperture_z) + if diff_on_z > tolerance: raise InvalidApertureMove( "ApertureScatterguard safe move is not yet defined for positions " "outside of LARGE, MEDIUM, SMALL, ROBOT_LOAD. " @@ -178,7 +188,7 @@ def _safe_move_within_datacollection_range( scatterguard_x ) & self.scatterguard.y.set(scatterguard_y) sg_status.wait() - final_status = ( + final_status: AndStatus = ( sg_status & self.aperture.x.set(aperture_x) & self.aperture.y.set(aperture_y) @@ -187,16 +197,15 @@ def _safe_move_within_datacollection_range( return final_status # CASE does not move along Z - else: - ap_status: AndStatus = ( - self.aperture.x.set(aperture_x) - & self.aperture.y.set(aperture_y) - & self.aperture.z.set(aperture_z) - ) - ap_status.wait() - final_status = ( - ap_status - & self.scatterguard.x.set(scatterguard_x) - & self.scatterguard.y.set(scatterguard_y) - ) - return final_status + ap_status: AndStatus = ( + self.aperture.x.set(aperture_x) + & self.aperture.y.set(aperture_y) + & self.aperture.z.set(aperture_z) + ) + ap_status.wait() + final_status: AndStatus = ( + ap_status + & self.scatterguard.x.set(scatterguard_x) + & self.scatterguard.y.set(scatterguard_y) + ) + return final_status From 22c119290c892996de948dfedb060525b797f6f4 Mon Sep 17 00:00:00 2001 From: Stanislaw Malinowski Date: Fri, 2 Feb 2024 15:43:10 +0000 Subject: [PATCH 038/134] got the unit test to pass --- src/dodal/devices/aperturescatterguard.py | 9 +-- .../unit_tests/test_aperture_scatterguard.py | 77 +++++++++---------- 2 files changed, 39 insertions(+), 47 deletions(-) diff --git a/src/dodal/devices/aperturescatterguard.py b/src/dodal/devices/aperturescatterguard.py index 717d3fc839..a80519ec64 100644 --- a/src/dodal/devices/aperturescatterguard.py +++ b/src/dodal/devices/aperturescatterguard.py @@ -20,9 +20,9 @@ class InvalidApertureMove(Exception): ApertureFiveDimensionalLocation = namedtuple( "ApertureFiveDimensionalLocation", [ - "miniap_x", - "miniap_y", - "miniap_z", + "aperture_x", + "aperture_y", + "aperture_z", "scatterguard_x", "scatterguard_y", ], @@ -123,7 +123,6 @@ class ApertureScatterguard(InfoLoggingDevice): APERTURE_Z_TOLERANCE = 3 # Number of MRES steps def load_aperture_positions(self, positions: AperturePositions): - print(self.aperture_positions.LARGE.location.scatterguard_x) LOGGER.info(f"{self.name} loaded in {positions}") self.aperture_positions = positions @@ -158,8 +157,8 @@ def _safe_move_within_datacollection_range( # unpacking the position aperture_x, aperture_y, aperture_z, scatterguard_x, scatterguard_y = pos - ap_z_in_position = self.aperture.z.motor_done_move.get() # CASE still moving + ap_z_in_position = self.aperture.z.motor_done_move.get() if not ap_z_in_position: status: Status = Status(obj=self) status.set_exception( diff --git a/tests/devices/unit_tests/test_aperture_scatterguard.py b/tests/devices/unit_tests/test_aperture_scatterguard.py index 83f71484b4..9187a63352 100644 --- a/tests/devices/unit_tests/test_aperture_scatterguard.py +++ b/tests/devices/unit_tests/test_aperture_scatterguard.py @@ -5,6 +5,7 @@ from ophyd.status import Status, StatusBase from dodal.devices.aperturescatterguard import ( + ApertureFiveDimensionalLocation, AperturePositions, ApertureScatterguard, InvalidApertureMove, @@ -25,34 +26,34 @@ def aperture_in_medium_pos( ): fake_aperture_scatterguard.load_aperture_positions(aperture_positions) fake_aperture_scatterguard.aperture.x.user_setpoint.sim_put( # type: ignore - aperture_positions.MEDIUM[0] + aperture_positions.MEDIUM.location.aperture_x ) fake_aperture_scatterguard.aperture.y.user_setpoint.sim_put( # type: ignore - aperture_positions.MEDIUM[1] + aperture_positions.MEDIUM.location.aperture_y ) fake_aperture_scatterguard.aperture.z.user_setpoint.sim_put( # type: ignore - aperture_positions.MEDIUM[2] + aperture_positions.MEDIUM.location[2] ) fake_aperture_scatterguard.aperture.x.user_readback.sim_put( # type: ignore - aperture_positions.MEDIUM[1] + aperture_positions.MEDIUM.location[1] ) fake_aperture_scatterguard.aperture.y.user_readback.sim_put( # type: ignore - aperture_positions.MEDIUM[1] + aperture_positions.MEDIUM.location[1] ) fake_aperture_scatterguard.aperture.z.user_readback.sim_put( # type: ignore - aperture_positions.MEDIUM[1] + aperture_positions.MEDIUM.location[1] ) fake_aperture_scatterguard.scatterguard.x.user_setpoint.sim_put( # type: ignore - aperture_positions.MEDIUM[3] + aperture_positions.MEDIUM.location[3] ) fake_aperture_scatterguard.scatterguard.y.user_setpoint.sim_put( # type: ignore - aperture_positions.MEDIUM[4] + aperture_positions.MEDIUM.location[4] ) fake_aperture_scatterguard.scatterguard.x.user_readback.sim_put( # type: ignore - aperture_positions.MEDIUM[3] + aperture_positions.MEDIUM.location[3] ) fake_aperture_scatterguard.scatterguard.y.user_readback.sim_put( # type: ignore - aperture_positions.MEDIUM[4] + aperture_positions.MEDIUM.location[4] ) fake_aperture_scatterguard.aperture.x.motor_done_move.sim_put(1) # type: ignore fake_aperture_scatterguard.aperture.y.motor_done_move.sim_put(1) # type: ignore @@ -94,10 +95,12 @@ def aperture_positions(): def test_aperture_scatterguard_rejects_unknown_position( aperture_positions, aperture_in_medium_pos ): - for i in range(0, len(aperture_positions.MEDIUM)): - temp_pos = list(aperture_positions.MEDIUM) - temp_pos[i] += 0.01 - position_to_reject = tuple(temp_pos) + for i in range(len(aperture_positions.MEDIUM.location)): + # get a list copy + pos = list(aperture_positions.MEDIUM.location) + # change 1 dimension more than tolerance + pos[i] += 0.01 + position_to_reject:ApertureFiveDimensionalLocation = tuple(pos) with pytest.raises(InvalidApertureMove): aperture_in_medium_pos.set(position_to_reject) @@ -110,7 +113,7 @@ def test_aperture_scatterguard_select_bottom_moves_sg_down_then_assembly_up( aperture_scatterguard = aperture_in_medium_pos call_logger = install_logger_for_aperture_and_scatterguard(aperture_scatterguard) - aperture_scatterguard.set(aperture_positions.SMALL) + aperture_scatterguard.set(aperture_positions.SMALL.location) actual_calls = call_logger.mock_calls expected_calls = [ @@ -131,7 +134,7 @@ def test_aperture_scatterguard_select_top_moves_assembly_down_then_sg_up( aperture_scatterguard = aperture_in_medium_pos call_logger = install_logger_for_aperture_and_scatterguard(aperture_scatterguard) - aperture_scatterguard.set(aperture_positions.LARGE) + aperture_scatterguard.set(aperture_positions.LARGE.location) actual_calls = call_logger.mock_calls expected_calls = [ @@ -154,9 +157,8 @@ def test_aperture_scatterguard_throws_error_if_outside_tolerance( fake_aperture_scatterguard.aperture.z.motor_done_move.sim_put(1) # type: ignore with pytest.raises(InvalidApertureMove): - fake_aperture_scatterguard._safe_move_within_datacollection_range( - 0, 0, 1.1, 0, 0 - ) + pos: ApertureFiveDimensionalLocation = (0, 0, 1.1, 0, 0) + fake_aperture_scatterguard._safe_move_within_datacollection_range(pos) def test_aperture_scatterguard_returns_status_if_within_tolerance( @@ -175,9 +177,8 @@ def test_aperture_scatterguard_returns_status_if_within_tolerance( fake_aperture_scatterguard.scatterguard.x.set = mock_set fake_aperture_scatterguard.scatterguard.y.set = mock_set - status = fake_aperture_scatterguard._safe_move_within_datacollection_range( - 0, 0, 1, 0, 0 - ) + pos = (0, 0, 1, 0, 0) + status = fake_aperture_scatterguard._safe_move_within_datacollection_range(pos) assert isinstance(status, StatusBase) @@ -204,26 +205,18 @@ def install_logger_for_aperture_and_scatterguard(aperture_scatterguard): def compare_actual_and_expected_calls(actual_calls, expected_calls): # ideally, we could use MagicMock.assert_has_calls but a) it doesn't work properly and b) doesn't do what I need i_actual = 0 - for i_expected in range(0, len(expected_calls)): - try: - expected = expected_calls[i_expected] - if isinstance(expected, tuple): - # simple comparison - i_actual = actual_calls.index(expected_calls[i_expected], i_actual) + for i, expected in enumerate(expected_calls): + if isinstance(expected, tuple): + # simple comparison + i_actual = actual_calls.index(expected, i_actual) + else: + # expected is a predicate to be satisfied + i_matches = [ + i for i, call in enumerate(actual_calls[i_actual:]) if expected(call) + ] + if i_matches: + i_actual = i_matches[0] else: - # expected is a predicate to be satisfied - i_matches = [ - i - for i in range(i_actual, len(actual_calls)) - if expected(actual_calls[i]) - ] - if i_matches: - i_actual = i_matches[0] - else: - raise ValueError("Couldn't find call matching predicate") - except ValueError: - assert ( - False - ), f"Couldn't find call #{i_expected}: {expected_calls[i_expected]}" + raise ValueError("Couldn't find call matching predicate") i_actual += 1 From 4b41926c644e8c55cb39861a9a0fd56b54327b50 Mon Sep 17 00:00:00 2001 From: Stanislaw Malinowski Date: Tue, 6 Feb 2024 09:15:33 +0000 Subject: [PATCH 039/134] fixed 1 test --- src/dodal/devices/aperturescatterguard.py | 4 +++- src/dodal/utils.py | 12 ++++-------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/dodal/devices/aperturescatterguard.py b/src/dodal/devices/aperturescatterguard.py index a80519ec64..d72fd8d975 100644 --- a/src/dodal/devices/aperturescatterguard.py +++ b/src/dodal/devices/aperturescatterguard.py @@ -109,8 +109,10 @@ def get_new_position( self.SMALL, self.ROBOT_LOAD, ] + pos_list = list(pos) for obj in options: - if np.allclose(obj.location, pos, atol=self.TOLERANCE_MM): + local_position = list(obj.location) + if np.allclose(local_position, pos_list, atol=self.TOLERANCE_MM): return obj return None diff --git a/src/dodal/utils.py b/src/dodal/utils.py index 893ccc61d8..d2d80ea82e 100644 --- a/src/dodal/utils.py +++ b/src/dodal/utils.py @@ -178,9 +178,7 @@ def collect_factories(module: ModuleType) -> dict[str, AnyDeviceFactory]: def _is_device_skipped(func: AnyDeviceFactory) -> bool: - if not hasattr(func, "__skip__"): - return False - return func.__skip__ # type: ignore + return getattr(func, "__skip__", False) def is_v1_device_factory(func: Callable) -> bool: @@ -233,7 +231,7 @@ def get_beamline_based_on_environment_variable() -> ModuleType: if ( len(beamline) == 0 or beamline[0] not in string.ascii_letters - or not all(c in valid_characters for c in beamline) + or any(c not in valid_characters for c in beamline) ): raise ValueError( "Invalid BEAMLINE variable - module name is not a permissible python module name, got '{}'".format( @@ -263,10 +261,8 @@ def _find_next_run_number_from_files(file_names: List[str]) -> int: dodal.log.LOGGER.warning( f"Identified nexus file {file_name} with unexpected format" ) - if len(valid_numbers) != 0: - return max(valid_numbers) + 1 - else: - return 1 + return max(valid_numbers) + 1 if valid_numbers else 1 + def get_run_number(directory: str) -> int: From 7b6b7700ce3a75a559711f51f1659df09ae68c4f Mon Sep 17 00:00:00 2001 From: Stanislaw Malinowski Date: Wed, 7 Feb 2024 13:49:07 +0000 Subject: [PATCH 040/134] add ruff --- src/dodal/utils.py | 1 - tests/devices/unit_tests/test_aperture_scatterguard.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/dodal/utils.py b/src/dodal/utils.py index d2d80ea82e..e3a9b49e73 100644 --- a/src/dodal/utils.py +++ b/src/dodal/utils.py @@ -262,7 +262,6 @@ def _find_next_run_number_from_files(file_names: List[str]) -> int: f"Identified nexus file {file_name} with unexpected format" ) return max(valid_numbers) + 1 if valid_numbers else 1 - def get_run_number(directory: str) -> int: diff --git a/tests/devices/unit_tests/test_aperture_scatterguard.py b/tests/devices/unit_tests/test_aperture_scatterguard.py index 9187a63352..256491c85b 100644 --- a/tests/devices/unit_tests/test_aperture_scatterguard.py +++ b/tests/devices/unit_tests/test_aperture_scatterguard.py @@ -100,7 +100,7 @@ def test_aperture_scatterguard_rejects_unknown_position( pos = list(aperture_positions.MEDIUM.location) # change 1 dimension more than tolerance pos[i] += 0.01 - position_to_reject:ApertureFiveDimensionalLocation = tuple(pos) + position_to_reject: ApertureFiveDimensionalLocation = tuple(pos) with pytest.raises(InvalidApertureMove): aperture_in_medium_pos.set(position_to_reject) From e245a8d3f803095ba1acfd5e93f1f2265a777ad1 Mon Sep 17 00:00:00 2001 From: Stanislaw Malinowski Date: Wed, 7 Feb 2024 13:49:20 +0000 Subject: [PATCH 041/134] 3.9 correction --- src/dodal/devices/aperturescatterguard.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dodal/devices/aperturescatterguard.py b/src/dodal/devices/aperturescatterguard.py index d72fd8d975..664483099f 100644 --- a/src/dodal/devices/aperturescatterguard.py +++ b/src/dodal/devices/aperturescatterguard.py @@ -32,7 +32,7 @@ class InvalidApertureMove(Exception): @dataclass class SingleAperturePosition: name: str - radius_microns: int | None + radius_microns: Optional[int] location: ApertureFiveDimensionalLocation From 06b58f44ff543a2171b21810e1f4e2ea4fd54bdf Mon Sep 17 00:00:00 2001 From: Stanislaw Malinowski Date: Wed, 7 Feb 2024 14:35:03 +0000 Subject: [PATCH 042/134] mypy success --- src/dodal/devices/aperturescatterguard.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/dodal/devices/aperturescatterguard.py b/src/dodal/devices/aperturescatterguard.py index 664483099f..62ea9282d8 100644 --- a/src/dodal/devices/aperturescatterguard.py +++ b/src/dodal/devices/aperturescatterguard.py @@ -189,13 +189,12 @@ def _safe_move_within_datacollection_range( scatterguard_x ) & self.scatterguard.y.set(scatterguard_y) sg_status.wait() - final_status: AndStatus = ( + return ( sg_status & self.aperture.x.set(aperture_x) & self.aperture.y.set(aperture_y) & self.aperture.z.set(aperture_z) ) - return final_status # CASE does not move along Z ap_status: AndStatus = ( From 5ac9fef91191f921d7631b2c8c6171c8fc9976e7 Mon Sep 17 00:00:00 2001 From: Stanislaw Malinowski Date: Wed, 7 Feb 2024 14:51:02 +0000 Subject: [PATCH 043/134] add union type --- src/dodal/devices/aperturescatterguard.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/dodal/devices/aperturescatterguard.py b/src/dodal/devices/aperturescatterguard.py index 62ea9282d8..b7e5b31698 100644 --- a/src/dodal/devices/aperturescatterguard.py +++ b/src/dodal/devices/aperturescatterguard.py @@ -1,6 +1,6 @@ from collections import namedtuple from dataclasses import dataclass -from typing import List, Optional +from typing import List, Optional, Union import numpy as np from ophyd import Component as Cpt @@ -146,7 +146,7 @@ def set(self, pos: ApertureFiveDimensionalLocation) -> AndStatus: def _safe_move_within_datacollection_range( self, pos: ApertureFiveDimensionalLocation - ) -> AndStatus | Status: + ) -> Union[AndStatus, Status]: """ Move the aperture and scatterguard combo safely to a new position. See https://github.com/DiamondLightSource/hyperion/wiki/Aperture-Scatterguard-Collisions From 75d171548f899a24bb996f5ad57330855960abb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanis=C5=82aw=20Malinowski?= Date: Thu, 8 Feb 2024 09:57:33 +0000 Subject: [PATCH 044/134] delete redundant comments --- src/dodal/devices/aperturescatterguard.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/dodal/devices/aperturescatterguard.py b/src/dodal/devices/aperturescatterguard.py index b7e5b31698..571a658865 100644 --- a/src/dodal/devices/aperturescatterguard.py +++ b/src/dodal/devices/aperturescatterguard.py @@ -131,9 +131,7 @@ def load_aperture_positions(self, positions: AperturePositions): def set(self, pos: ApertureFiveDimensionalLocation) -> AndStatus: new_selected_aperture: SingleAperturePosition | None = None try: - # make sure that the reference values are loaded assert isinstance(self.aperture_positions, AperturePositions) - # get the new aperture obrect new_selected_aperture = self.aperture_positions.get_new_position(pos) assert new_selected_aperture is not None except AssertionError as e: From 3c9f7ddddd1bb047030faa48e68dbda75e7c38fd Mon Sep 17 00:00:00 2001 From: Stanislaw Malinowski Date: Wed, 14 Feb 2024 08:56:12 +0000 Subject: [PATCH 045/134] add basic aperture positions test --- .../unit_tests/test_aperture_scatterguard.py | 100 ++++++++++++------ 1 file changed, 66 insertions(+), 34 deletions(-) diff --git a/tests/devices/unit_tests/test_aperture_scatterguard.py b/tests/devices/unit_tests/test_aperture_scatterguard.py index 256491c85b..2f07186af1 100644 --- a/tests/devices/unit_tests/test_aperture_scatterguard.py +++ b/tests/devices/unit_tests/test_aperture_scatterguard.py @@ -13,7 +13,7 @@ @pytest.fixture -def fake_aperture_scatterguard(): +def ap_sg(): FakeApertureScatterguard = make_fake_device(ApertureScatterguard) ap_sg: ApertureScatterguard = FakeApertureScatterguard(name="test_ap_sg") yield ap_sg @@ -21,46 +21,46 @@ def fake_aperture_scatterguard(): @pytest.fixture def aperture_in_medium_pos( - fake_aperture_scatterguard: ApertureScatterguard, + ap_sg: ApertureScatterguard, aperture_positions: AperturePositions, ): - fake_aperture_scatterguard.load_aperture_positions(aperture_positions) - fake_aperture_scatterguard.aperture.x.user_setpoint.sim_put( # type: ignore + ap_sg.load_aperture_positions(aperture_positions) + ap_sg.aperture.x.user_setpoint.sim_put( # type: ignore aperture_positions.MEDIUM.location.aperture_x ) - fake_aperture_scatterguard.aperture.y.user_setpoint.sim_put( # type: ignore + ap_sg.aperture.y.user_setpoint.sim_put( # type: ignore aperture_positions.MEDIUM.location.aperture_y ) - fake_aperture_scatterguard.aperture.z.user_setpoint.sim_put( # type: ignore + ap_sg.aperture.z.user_setpoint.sim_put( # type: ignore aperture_positions.MEDIUM.location[2] ) - fake_aperture_scatterguard.aperture.x.user_readback.sim_put( # type: ignore + ap_sg.aperture.x.user_readback.sim_put( # type: ignore aperture_positions.MEDIUM.location[1] ) - fake_aperture_scatterguard.aperture.y.user_readback.sim_put( # type: ignore + ap_sg.aperture.y.user_readback.sim_put( # type: ignore aperture_positions.MEDIUM.location[1] ) - fake_aperture_scatterguard.aperture.z.user_readback.sim_put( # type: ignore + ap_sg.aperture.z.user_readback.sim_put( # type: ignore aperture_positions.MEDIUM.location[1] ) - fake_aperture_scatterguard.scatterguard.x.user_setpoint.sim_put( # type: ignore + ap_sg.scatterguard.x.user_setpoint.sim_put( # type: ignore aperture_positions.MEDIUM.location[3] ) - fake_aperture_scatterguard.scatterguard.y.user_setpoint.sim_put( # type: ignore + ap_sg.scatterguard.y.user_setpoint.sim_put( # type: ignore aperture_positions.MEDIUM.location[4] ) - fake_aperture_scatterguard.scatterguard.x.user_readback.sim_put( # type: ignore + ap_sg.scatterguard.x.user_readback.sim_put( # type: ignore aperture_positions.MEDIUM.location[3] ) - fake_aperture_scatterguard.scatterguard.y.user_readback.sim_put( # type: ignore + ap_sg.scatterguard.y.user_readback.sim_put( # type: ignore aperture_positions.MEDIUM.location[4] ) - fake_aperture_scatterguard.aperture.x.motor_done_move.sim_put(1) # type: ignore - fake_aperture_scatterguard.aperture.y.motor_done_move.sim_put(1) # type: ignore - fake_aperture_scatterguard.aperture.z.motor_done_move.sim_put(1) # type: ignore - fake_aperture_scatterguard.scatterguard.x.motor_done_move.sim_put(1) # type: ignore - fake_aperture_scatterguard.scatterguard.y.motor_done_move.sim_put(1) # type: ignore - return fake_aperture_scatterguard + ap_sg.aperture.x.motor_done_move.sim_put(1) # type: ignore + ap_sg.aperture.y.motor_done_move.sim_put(1) # type: ignore + ap_sg.aperture.z.motor_done_move.sim_put(1) # type: ignore + ap_sg.scatterguard.x.motor_done_move.sim_put(1) # type: ignore + ap_sg.scatterguard.y.motor_done_move.sim_put(1) # type: ignore + return ap_sg @pytest.fixture @@ -150,38 +150,70 @@ def test_aperture_scatterguard_select_top_moves_assembly_down_then_sg_up( def test_aperture_scatterguard_throws_error_if_outside_tolerance( - fake_aperture_scatterguard: ApertureScatterguard, + ap_sg: ApertureScatterguard, ): - fake_aperture_scatterguard.aperture.z.motor_resolution.sim_put(0.001) # type: ignore - fake_aperture_scatterguard.aperture.z.user_setpoint.sim_put(1) # type: ignore - fake_aperture_scatterguard.aperture.z.motor_done_move.sim_put(1) # type: ignore + ap_sg.aperture.z.motor_resolution.sim_put(0.001) # type: ignore + ap_sg.aperture.z.user_setpoint.sim_put(1) # type: ignore + ap_sg.aperture.z.motor_done_move.sim_put(1) # type: ignore with pytest.raises(InvalidApertureMove): pos: ApertureFiveDimensionalLocation = (0, 0, 1.1, 0, 0) - fake_aperture_scatterguard._safe_move_within_datacollection_range(pos) + ap_sg._safe_move_within_datacollection_range(pos) def test_aperture_scatterguard_returns_status_if_within_tolerance( - fake_aperture_scatterguard: ApertureScatterguard, + ap_sg: ApertureScatterguard, ): - fake_aperture_scatterguard.aperture.z.motor_resolution.sim_put(0.001) # type: ignore - fake_aperture_scatterguard.aperture.z.user_setpoint.sim_put(1) # type: ignore - fake_aperture_scatterguard.aperture.z.motor_done_move.sim_put(1) # type: ignore + ap_sg.aperture.z.motor_resolution.sim_put(0.001) # type: ignore + ap_sg.aperture.z.user_setpoint.sim_put(1) # type: ignore + ap_sg.aperture.z.motor_done_move.sim_put(1) # type: ignore mock_set = MagicMock(return_value=Status(done=True, success=True)) - fake_aperture_scatterguard.aperture.x.set = mock_set - fake_aperture_scatterguard.aperture.y.set = mock_set - fake_aperture_scatterguard.aperture.z.set = mock_set + ap_sg.aperture.x.set = mock_set + ap_sg.aperture.y.set = mock_set + ap_sg.aperture.z.set = mock_set - fake_aperture_scatterguard.scatterguard.x.set = mock_set - fake_aperture_scatterguard.scatterguard.y.set = mock_set + ap_sg.scatterguard.x.set = mock_set + ap_sg.scatterguard.y.set = mock_set pos = (0, 0, 1, 0, 0) - status = fake_aperture_scatterguard._safe_move_within_datacollection_range(pos) + status = ap_sg._safe_move_within_datacollection_range(pos) assert isinstance(status, StatusBase) +def test_aperture_positions_get_new_position_truthy_exact(aperture_positions): + should_be_large = ApertureFiveDimensionalLocation( + 2.389, 40.986, 15.8, 5.25, 4.43 + ) + new_position = aperture_positions.get_new_position(should_be_large) + assert new_position == aperture_positions.LARGE + + +def test_aperture_positions_get_new_position_truthy_inside_tolerance(aperture_positions): + should_be_large = ApertureFiveDimensionalLocation( + 2.389, 40.9865, 15.8, 5.25, 4.43 + ) + new_position = aperture_positions.get_new_position(should_be_large) + assert new_position == aperture_positions.LARGE + + +def test_aperture_positions_get_new_position_falsy(aperture_positions): + large_missed_by_2_at_y = ApertureFiveDimensionalLocation( + 2.389, 42, 15.8, 5.25, 4.43 + ) + new_position = aperture_positions.get_new_position(large_missed_by_2_at_y) + assert new_position is None + + +def test_aperture_positions_get_new_position_robot_load_exact(aperture_positions): + robot_exact = ApertureFiveDimensionalLocation( + 2.386, 31.40, 15.8, 5.25, 4.43, + ) + new_position = aperture_positions.get_new_position(robot_exact) + assert new_position is aperture_positions.ROBOT_LOAD + + def install_logger_for_aperture_and_scatterguard(aperture_scatterguard): parent_mock = MagicMock() mock_ap_x = MagicMock(aperture_scatterguard.aperture.x.set) From d7765a862d257665c3d3fd6aa0a194315d57e453 Mon Sep 17 00:00:00 2001 From: Stanislaw Malinowski Date: Wed, 14 Feb 2024 09:47:42 +0000 Subject: [PATCH 046/134] linter correct --- pyproject.toml | 4 ++-- src/dodal/beamlines/beamline_utils.py | 8 ++++--- src/dodal/devices/oav/grid_overlay.py | 16 ++++++++------ src/dodal/devices/oav/oav_errors.py | 1 + .../oav/pin_image_recognition/manual_test.py | 1 + src/dodal/devices/util/adjuster_plans.py | 1 + src/dodal/devices/util/lookup_tables.py | 1 + .../image_recognition/test_pin_tip_detect.py | 7 +++--- .../unit_tests/test_aperture_scatterguard.py | 18 ++++++++------- tests/devices/unit_tests/test_oav.py | 22 +++++++++++-------- 10 files changed, 46 insertions(+), 33 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 08056ec8ae..ca50a4218e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -122,11 +122,11 @@ commands = [tool.ruff] src = ["src", "tests"] line-length = 88 -extend-ignore = [ +lint.extend-ignore = [ "E501", # Line too long "F811", # support typing.overload decorator ] -select = [ +lint.select = [ "C4", # flake8-comprehensions - https://beta.ruff.rs/docs/rules/#flake8-comprehensions-c4 "E", # pycodestyle errors - https://beta.ruff.rs/docs/rules/#error-e "F", # pyflakes rules - https://beta.ruff.rs/docs/rules/#pyflakes-f diff --git a/src/dodal/beamlines/beamline_utils.py b/src/dodal/beamlines/beamline_utils.py index 43f5bdc335..0fe052f6bb 100644 --- a/src/dodal/beamlines/beamline_utils.py +++ b/src/dodal/beamlines/beamline_utils.py @@ -95,9 +95,11 @@ def device_instantiation( if already_existing_device is None: device_instance = device_factory( name=name, - prefix=f"{(BeamlinePrefix(BL).beamline_prefix)}{prefix}" - if bl_prefix - else prefix, + prefix=( + f"{(BeamlinePrefix(BL).beamline_prefix)}{prefix}" + if bl_prefix + else prefix + ), **kwargs, ) ACTIVE_DEVICES[name] = device_instance diff --git a/src/dodal/devices/oav/grid_overlay.py b/src/dodal/devices/oav/grid_overlay.py index a0908e054a..062ba60ae6 100644 --- a/src/dodal/devices/oav/grid_overlay.py +++ b/src/dodal/devices/oav/grid_overlay.py @@ -44,13 +44,15 @@ def _add_parallel_lines_to_image( parallel lines to draw.""" lines = [ ( - (start_x, start_y + i * spacing), - (start_x + line_length, start_y + i * spacing), - ) - if orientation == Orientation.horizontal - else ( - (start_x + i * spacing, start_y), - (start_x + i * spacing, start_y + line_length), + ( + (start_x, start_y + i * spacing), + (start_x + line_length, start_y + i * spacing), + ) + if orientation == Orientation.horizontal + else ( + (start_x + i * spacing, start_y), + (start_x + i * spacing, start_y + line_length), + ) ) for i in range(num_lines) ] diff --git a/src/dodal/devices/oav/oav_errors.py b/src/dodal/devices/oav/oav_errors.py index 90febccb75..82834196df 100644 --- a/src/dodal/devices/oav/oav_errors.py +++ b/src/dodal/devices/oav/oav_errors.py @@ -1,6 +1,7 @@ """ Module for containing errors in operation of the OAV. """ + from dodal.log import LOGGER diff --git a/src/dodal/devices/oav/pin_image_recognition/manual_test.py b/src/dodal/devices/oav/pin_image_recognition/manual_test.py index b70b0a10da..d6f5e495fc 100644 --- a/src/dodal/devices/oav/pin_image_recognition/manual_test.py +++ b/src/dodal/devices/oav/pin_image_recognition/manual_test.py @@ -5,6 +5,7 @@ It is otherwise unused. """ + import asyncio from dodal.devices.oav.pin_image_recognition import PinTipDetection diff --git a/src/dodal/devices/util/adjuster_plans.py b/src/dodal/devices/util/adjuster_plans.py index 98ddd9c91a..9c7cc69826 100644 --- a/src/dodal/devices/util/adjuster_plans.py +++ b/src/dodal/devices/util/adjuster_plans.py @@ -2,6 +2,7 @@ All the methods in this module return a bluesky plan generator that adjusts a value according to some criteria either via feedback, preset positions, lookup tables etc. """ + from typing import Callable, Generator from bluesky import plan_stubs as bps diff --git a/src/dodal/devices/util/lookup_tables.py b/src/dodal/devices/util/lookup_tables.py index 24fff56109..0e1b74475d 100644 --- a/src/dodal/devices/util/lookup_tables.py +++ b/src/dodal/devices/util/lookup_tables.py @@ -2,6 +2,7 @@ All the public methods in this module return a lookup table of some kind that converts the source value s to a target value t for different values of s. """ + from collections.abc import Sequence from typing import Callable diff --git a/tests/devices/unit_tests/oav/image_recognition/test_pin_tip_detect.py b/tests/devices/unit_tests/oav/image_recognition/test_pin_tip_detect.py index e5940263ba..7101dfd3a3 100644 --- a/tests/devices/unit_tests/oav/image_recognition/test_pin_tip_detect.py +++ b/tests/devices/unit_tests/oav/image_recognition/test_pin_tip_detect.py @@ -73,10 +73,9 @@ async def test_invalid_processing_func_uses_identity_function(): set_sim_value(device.preprocess_operation, 50) # Invalid index - with patch.object( - MxSampleDetect, "__init__", return_value=None - ) as mock_init, patch.object( - MxSampleDetect, "processArray", return_value=((None, None), None) + with ( + patch.object(MxSampleDetect, "__init__", return_value=None) as mock_init, + patch.object(MxSampleDetect, "processArray", return_value=((None, None), None)), ): await device.read() diff --git a/tests/devices/unit_tests/test_aperture_scatterguard.py b/tests/devices/unit_tests/test_aperture_scatterguard.py index 2f07186af1..9bf5940633 100644 --- a/tests/devices/unit_tests/test_aperture_scatterguard.py +++ b/tests/devices/unit_tests/test_aperture_scatterguard.py @@ -183,17 +183,15 @@ def test_aperture_scatterguard_returns_status_if_within_tolerance( def test_aperture_positions_get_new_position_truthy_exact(aperture_positions): - should_be_large = ApertureFiveDimensionalLocation( - 2.389, 40.986, 15.8, 5.25, 4.43 - ) + should_be_large = ApertureFiveDimensionalLocation(2.389, 40.986, 15.8, 5.25, 4.43) new_position = aperture_positions.get_new_position(should_be_large) assert new_position == aperture_positions.LARGE -def test_aperture_positions_get_new_position_truthy_inside_tolerance(aperture_positions): - should_be_large = ApertureFiveDimensionalLocation( - 2.389, 40.9865, 15.8, 5.25, 4.43 - ) +def test_aperture_positions_get_new_position_truthy_inside_tolerance( + aperture_positions, +): + should_be_large = ApertureFiveDimensionalLocation(2.389, 40.9865, 15.8, 5.25, 4.43) new_position = aperture_positions.get_new_position(should_be_large) assert new_position == aperture_positions.LARGE @@ -208,7 +206,11 @@ def test_aperture_positions_get_new_position_falsy(aperture_positions): def test_aperture_positions_get_new_position_robot_load_exact(aperture_positions): robot_exact = ApertureFiveDimensionalLocation( - 2.386, 31.40, 15.8, 5.25, 4.43, + 2.386, + 31.40, + 15.8, + 5.25, + 4.43, ) new_position = aperture_positions.get_new_position(robot_exact) assert new_position is aperture_positions.ROBOT_LOAD diff --git a/tests/devices/unit_tests/test_oav.py b/tests/devices/unit_tests/test_oav.py index b28e81fa02..4b99289123 100644 --- a/tests/devices/unit_tests/test_oav.py +++ b/tests/devices/unit_tests/test_oav.py @@ -151,15 +151,19 @@ def test_get_beam_position_from_zoom_only_called_once_on_multiple_connects( fake_oav.wait_for_connection() fake_oav.wait_for_connection() - with patch( - "dodal.devices.oav.oav_detector.OAVConfigParams.update_on_zoom", - MagicMock(), - ), patch( - "dodal.devices.oav.oav_detector.OAVConfigParams.get_beam_position_from_zoom", - MagicMock(), - ) as mock_get_beam_position_from_zoom, patch( - "dodal.devices.oav.oav_detector.OAVConfigParams.load_microns_per_pixel", - MagicMock(), + with ( + patch( + "dodal.devices.oav.oav_detector.OAVConfigParams.update_on_zoom", + MagicMock(), + ), + patch( + "dodal.devices.oav.oav_detector.OAVConfigParams.get_beam_position_from_zoom", + MagicMock(), + ) as mock_get_beam_position_from_zoom, + patch( + "dodal.devices.oav.oav_detector.OAVConfigParams.load_microns_per_pixel", + MagicMock(), + ), ): fake_oav.zoom_controller.level.sim_put("2.0x") # type: ignore assert mock_get_beam_position_from_zoom.call_count == 1 From 576c6b8645391d5f74fbc644e96adce430df0bac Mon Sep 17 00:00:00 2001 From: "Ware, Joseph (DLSLtd,RAL,LSCI)" Date: Wed, 21 Feb 2024 13:20:42 +0000 Subject: [PATCH 047/134] Move utils from dls-bluesky-core to dodal --- pyproject.toml | 6 +++ src/dodal/common/__init__.py | 12 ++++++ src/dodal/common/coordination.py | 38 ++++++++++++++++++ src/dodal/common/maths.py | 52 +++++++++++++++++++++++++ src/dodal/common/types.py | 17 ++++++++ tests/common/test_coordination.py | 12 ++++++ tests/common/test_maths.py | 65 +++++++++++++++++++++++++++++++ 7 files changed, 202 insertions(+) create mode 100644 src/dodal/common/__init__.py create mode 100644 src/dodal/common/coordination.py create mode 100644 src/dodal/common/maths.py create mode 100644 src/dodal/common/types.py create mode 100644 tests/common/test_coordination.py create mode 100644 tests/common/test_maths.py diff --git a/pyproject.toml b/pyproject.toml index 1027e09cd7..9b69eebdc6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ dependencies = [ "opencv-python-headless", # For pin-tip detection. "aioca", # Required for CA support with ophyd-async. "p4p", # Required for PVA support with ophyd-async. + "numpy", ] dynamic = ["version"] @@ -88,6 +89,11 @@ addopts = """ # Doctest python code in docs, python code in src docstrings, test functions in tests testpaths = "docs src tests" +[tool.coverage.report] +exclude_also = [ + '^"""', # Ignore the start/end of a file-level triple quoted docstring +] + [tool.coverage.run] data_file = "/tmp/dodal.coverage" diff --git a/src/dodal/common/__init__.py b/src/dodal/common/__init__.py new file mode 100644 index 0000000000..4a53b30530 --- /dev/null +++ b/src/dodal/common/__init__.py @@ -0,0 +1,12 @@ +from .coordination import group_uuid, inject +from .maths import in_micros, step_to_num +from .types import MsgGenerator, PlanGenerator + +__all__ = [ + "group_uuid", + "inject", + "in_micros", + "MsgGenerator", + "PlanGenerator", + "step_to_num", +] diff --git a/src/dodal/common/coordination.py b/src/dodal/common/coordination.py new file mode 100644 index 0000000000..3c061490bf --- /dev/null +++ b/src/dodal/common/coordination.py @@ -0,0 +1,38 @@ +import uuid + +from dodal.common.types import Group + + +def group_uuid(name: str) -> Group: + """ + Returns a unique but human-readable string, to assist debugging orchestrated groups. + + Args: + name (str): A human readable name + + Returns: + readable_uid (Group): name appended with a unique string + """ + return f"{name}-{str(uuid.uuid4())[:6]}" + + +def inject(name: str): # type: ignore + """ + Function to mark a default argument of a plan method as a reference to a device + that is stored in the Blueapi context, as devices are constructed on startup of the + service, and are not available to be used when writing plans. + Bypasses mypy linting, returning x as Any and therefore valid as a default + argument. + e.g. For a 1-dimensional scan, that is usually performed on a consistent Movable + axis with name "stage_x" + def scan(x: Movable = inject("stage_x"), start: float = 0.0 ...) + + Args: + name (str): Name of a device to be fetched from the Blueapi context + + Returns: + Any: name but without typing checking, valid as any default type + + """ + + return name diff --git a/src/dodal/common/maths.py b/src/dodal/common/maths.py new file mode 100644 index 0000000000..a691279f24 --- /dev/null +++ b/src/dodal/common/maths.py @@ -0,0 +1,52 @@ +from typing import Tuple + +import numpy as np + + +def step_to_num(start: float, stop: float, step: float) -> Tuple[float, float, int]: + """ + Standard handling for converting from start, stop, step to start, stop, num + Forces step to be same direction as length + Includes a final point if it is within 1% of the final step, prevents floating + point arithmatic errors from giving inconsistent shaped scans between steps of an + outer axis. + + Args: + start (float): + Start of length, will be returned unchanged + stop (float): + End of length, if length/step does not divide cleanly will be returned + extended up to 1% of step, or else truncated. + step (float): + Length of a step along the line formed from start to stop. + If stop < start, will be coerced to be backwards. + + Returns: + start, adjusted_stop, num = Tuple[float, float, int] + start will be returned unchanged + adjusted_stop = start + (num - 1) * step + num is the maximal number of steps that could fit into the length. + + """ + # Make step be the right direction + step = abs(step) if stop >= start else -abs(step) + # If stop is within 1% of a step then include it + steps = int((stop - start) / step + 0.01) + return start, start + steps * step, steps + 1 # include 1st point + + +def in_micros(t: float) -> int: + """ + Converts between a positive number of seconds and an equivalent + number of microseconds. + + Args: + t (float): A time in seconds + Raises: + ValueError: if t < 0 + Returns: + t (int): A time in microseconds, rounded up to the nearest whole microsecond, + """ + if t < 0: + raise ValueError(f"Expected a positive time in seconds, got {t!r}") + return int(np.ceil(t * 1e6)) diff --git a/src/dodal/common/types.py b/src/dodal/common/types.py new file mode 100644 index 0000000000..d721a890ab --- /dev/null +++ b/src/dodal/common/types.py @@ -0,0 +1,17 @@ +from typing import ( + Annotated, + Any, + Callable, + Generator, +) + +from bluesky.utils import Msg + +Group = Annotated[str, "String identifier used by 'wait' or stubs that await"] +MsgGenerator = Annotated[ + Generator[Msg, Any, None], + "A true 'plan', usually the output of a generator function", +] +PlanGenerator = Annotated[ + Callable[..., MsgGenerator], "A function that generates a plan" +] diff --git a/tests/common/test_coordination.py b/tests/common/test_coordination.py new file mode 100644 index 0000000000..b4db6e1204 --- /dev/null +++ b/tests/common/test_coordination.py @@ -0,0 +1,12 @@ +import uuid + +import pytest + +from dodal.common.coordination import group_uuid + + +@pytest.mark.parametrize("group", ["foo", "bar", "baz", str(uuid.uuid4())]) +def test_group_uid(group: str): + gid = group_uuid(group) + assert gid.startswith(f"{group}-") + assert not gid.endswith(f"{group}-") diff --git a/tests/common/test_maths.py b/tests/common/test_maths.py new file mode 100644 index 0000000000..7cf682d1c1 --- /dev/null +++ b/tests/common/test_maths.py @@ -0,0 +1,65 @@ +from typing import Optional + +import pytest + +from dodal.common import in_micros, step_to_num + + +@pytest.mark.parametrize( + "s,us", + [ + (4.000_001, 4_000_001), + (4.999_999, 4_999_999), + (4, 4_000_000), + (4.000_000_1, 4_000_001), + (4.999_999_9, 5_000_000), + (0.1, 100_000), + (0.000_000_1, 1), + (0, 0), + ], +) +def test_in_micros(s: float, us: int): + assert in_micros(s) == us + + +@pytest.mark.parametrize( + "s", [-4.000_001, -4.999_999, -4, -4.000_000_5, -4.999_999_9, -4.05] +) +def test_in_micros_negative(s: float): + with pytest.raises(ValueError): + in_micros(s) + + +@pytest.mark.parametrize( + "start,stop,step,expected_num,truncated_stop", + [ + (0, 0, 1, 1, None), # start=stop, 1 point at start + (0, 0.5, 1, 1, 0), # step>length, 1 point at start + (0, 1, 1, 2, None), # stop=start+step, point at start & stop + (0, 0.99, 1, 2, 1), # stop >= start + 0.99*step, included + (0, 0.98, 1, 1, 0), # stop < start + 0.99*step, not included + (0, 1.01, 1, 2, 1), # stop >= start + 0.99*step, included + (0, 1.75, 0.25, 8, 1.75), + (0, 0, -1, 1, None), # start=stop, 1 point at start + (0, 0.5, -1, 1, 0), # abs(step)>length, 1 point at start + (0, -1, 1, 2, None), # stop=start+-abs(step), point at start & stop + (0, -0.99, 1, 2, -1), # stop >= start + 0.99*-abs(step), included + (0, -0.98, 1, 1, 0), # stop < start + 0.99*-abs(step), not included + (0, -1.01, 1, 2, -1), # stop >= start + 0.99*-abs(step), included + (0, -1.75, 0.25, 8, -1.75), + (1, 10, -0.901, 10, 9.109), # length overrules step for direction + (10, 1, -0.901, 10, 1.891), + ], +) +def test_step_to_num( + start: float, + stop: float, + step: float, + expected_num: int, + truncated_stop: Optional[float], +): + truncated_stop = stop if truncated_stop is None else truncated_stop + actual_start, actual_stop, num = step_to_num(start, stop, step) + assert actual_start == start + assert actual_stop == truncated_stop + assert num == expected_num From cc3e89cf2cde279a64ee3a0df02a205cf50ddc8f Mon Sep 17 00:00:00 2001 From: Dominic Oram Date: Thu, 29 Feb 2024 20:59:20 +0000 Subject: [PATCH 048/134] (DiamondLightSource/hyperion#1091) Allow sending start index and frame number to zocalo --- .../devices/zocalo/zocalo_interaction.py | 22 ++++++++-- .../system_tests/test_zocalo_results.py | 4 +- .../unit_tests/test_zocalo_interaction.py | 41 +++++++++++++------ 3 files changed, 49 insertions(+), 18 deletions(-) diff --git a/src/dodal/devices/zocalo/zocalo_interaction.py b/src/dodal/devices/zocalo/zocalo_interaction.py index 217da22d41..0f9211621f 100644 --- a/src/dodal/devices/zocalo/zocalo_interaction.py +++ b/src/dodal/devices/zocalo/zocalo_interaction.py @@ -42,16 +42,30 @@ def _send_to_zocalo(self, parameters: dict): finally: transport.disconnect() - def run_start(self, data_collection_id: int): + def run_start( + self, + data_collection_id: int, + start_index: int, + number_of_frames: int, + ): """Tells the data analysis pipeline we have started a run. Assumes that appropriate data has already been put into ISPyB Args: - data_collection_id (int): The ID of the data collection representing the - gridscan in ISPyB + data_collection_id (int): The ID of the data collection in ISPyB + start_index (int): The index of the first image of this collection within + the file written by the detector. + number_of_frames (int): The number of frames in this collection. """ LOGGER.info(f"Starting Zocalo job with ispyb id {data_collection_id}") - self._send_to_zocalo({"event": "start", "ispyb_dcid": data_collection_id}) + self._send_to_zocalo( + { + "event": "start", + "ispyb_dcid": data_collection_id, + "start_index": start_index, + "number_of_frames": number_of_frames, + } + ) def run_end(self, data_collection_id: int): """Tells the data analysis pipeline we have finished a run. diff --git a/tests/devices/system_tests/test_zocalo_results.py b/tests/devices/system_tests/test_zocalo_results.py index 18849f0c46..3b40d20980 100644 --- a/tests/devices/system_tests/test_zocalo_results.py +++ b/tests/devices/system_tests/test_zocalo_results.py @@ -39,7 +39,7 @@ async def zocalo_device(): async def test_read_results_from_fake_zocalo(zocalo_device: ZocaloResults): zocalo_device._subscribe_to_results() zc = ZocaloTrigger("dev_artemis") - zc.run_start(0) + zc.run_start(0, 0, 100) zc.run_end(0) zocalo_device.timeout_s = 5 @@ -66,7 +66,7 @@ async def test_stage_unstage_controls_read_results_from_fake_zocalo( def plan(): yield from bps.open_run() - zc.run_start(0) + zc.run_start(0, 0, 100) zc.run_end(0) yield from bps.sleep(0.15) yield from bps.trigger_and_read([zocalo_device]) diff --git a/tests/devices/unit_tests/test_zocalo_interaction.py b/tests/devices/unit_tests/test_zocalo_interaction.py index ea43abfafe..978bc197b7 100644 --- a/tests/devices/unit_tests/test_zocalo_interaction.py +++ b/tests/devices/unit_tests/test_zocalo_interaction.py @@ -13,7 +13,12 @@ SIM_ZOCALO_ENV = "dev_artemis" EXPECTED_DCID = 100 -EXPECTED_RUN_START_MESSAGE = {"event": "start", "ispyb_dcid": EXPECTED_DCID} +EXPECTED_RUN_START_MESSAGE = { + "event": "start", + "ispyb_dcid": EXPECTED_DCID, + "start_index": 0, + "number_of_frames": 100, +} EXPECTED_RUN_END_MESSAGE = { "event": "end", "ispyb_dcid": EXPECTED_DCID, @@ -65,27 +70,39 @@ def with_exception(function_to_run, mock_transport): @mark.parametrize( - "function_to_test,function_wrapper,expected_message", + "function_wrapper,expected_message", [ - (zc.run_start, normally, EXPECTED_RUN_START_MESSAGE), + (normally, EXPECTED_RUN_START_MESSAGE), ( - zc.run_start, with_exception, EXPECTED_RUN_START_MESSAGE, ), - (zc.run_end, normally, EXPECTED_RUN_END_MESSAGE), - (zc.run_end, with_exception, EXPECTED_RUN_END_MESSAGE), ], ) -def test__run_start_and_end( - function_to_test: Callable, function_wrapper: Callable, expected_message: Dict -): +def test_run_start(function_wrapper: Callable, expected_message: Dict): + """ + Args: + function_wrapper (Callable): A wrapper used to test for expected exceptions + expected_message (Dict): The expected dictionary sent to zocalo + """ + function_to_run = partial(zc.run_start, EXPECTED_DCID, 0, 100) + function_to_run = partial(function_wrapper, function_to_run) + _test_zocalo(function_to_run, expected_message) + + +@mark.parametrize( + "function_wrapper,expected_message", + [ + (normally, EXPECTED_RUN_END_MESSAGE), + (with_exception, EXPECTED_RUN_END_MESSAGE), + ], +) +def test__run_start_and_end(function_wrapper: Callable, expected_message: Dict): """ Args: - function_to_test (Callable): The function to test e.g. start/stop zocalo - function_wrapper (Callable): A wrapper around the function, used to test for expected exceptions + function_wrapper (Callable): A wrapper used to test for expected exceptions expected_message (Dict): The expected dictionary sent to zocalo """ - function_to_run = partial(function_to_test, EXPECTED_DCID) + function_to_run = partial(zc.run_end, EXPECTED_DCID) function_to_run = partial(function_wrapper, function_to_run) _test_zocalo(function_to_run, expected_message) From dd5e99f9d933c36d23b38409349024fe919e8bd8 Mon Sep 17 00:00:00 2001 From: David Perl Date: Mon, 4 Mar 2024 11:25:45 +0000 Subject: [PATCH 049/134] update typing --- src/dodal/devices/motors.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/dodal/devices/motors.py b/src/dodal/devices/motors.py index da98a88dd6..a7dc062979 100644 --- a/src/dodal/devices/motors.py +++ b/src/dodal/devices/motors.py @@ -39,7 +39,9 @@ class XYZLimitBundle: y: MotorLimitHelper z: MotorLimitHelper - def position_valid(self, position: np.ndarray): + def position_valid( + self, position: np.ndarray | list[float] | tuple[float, float, float] + ): if len(position) != 3: raise ValueError( f"Position valid expects a 3-vector, got {position} instead" From 010a7620473597c8d26e7430fdb5151dfb9f783a Mon Sep 17 00:00:00 2001 From: David Perl Date: Mon, 4 Mar 2024 12:17:42 +0000 Subject: [PATCH 050/134] hyperion #1068 add edges to PinTipDetection --- .../oav/pin_image_recognition/__init__.py | 55 ++++++++++++++++--- .../image_recognition/test_pin_tip_detect.py | 21 +++++-- 2 files changed, 62 insertions(+), 14 deletions(-) diff --git a/src/dodal/devices/oav/pin_image_recognition/__init__.py b/src/dodal/devices/oav/pin_image_recognition/__init__.py index 1e785400be..d03c616445 100644 --- a/src/dodal/devices/oav/pin_image_recognition/__init__.py +++ b/src/dodal/devices/oav/pin_image_recognition/__init__.py @@ -1,6 +1,6 @@ import asyncio import time -from typing import Optional +from typing import Optional, Tuple import numpy as np from numpy.typing import NDArray @@ -10,6 +10,7 @@ from dodal.devices.oav.pin_image_recognition.utils import ( ARRAY_PROCESSING_FUNCTIONS_MAP, MxSampleDetect, + SampleLocation, ScanDirections, identity, ) @@ -46,6 +47,12 @@ def __init__(self, prefix: str, name: str = ""): self._name = name self.triggered_tip = create_soft_signal_r(Tip, "triggered_tip", self.name) + self.triggered_top_edge = create_soft_signal_r( + NDArray[np.uint32], "triggered_top_edge", self.name + ) + self.triggered_bottom_edge = create_soft_signal_r( + NDArray[np.uint32], "triggered_bottom_edge", self.name + ) self.array_data = epics_signal_r(NDArray[np.uint8], f"pva://{prefix}PVA:ARRAY") # Soft parameters for pin-tip detection. @@ -73,23 +80,30 @@ def __init__(self, prefix: str, name: str = ""): ) self.set_readable_signals( - read=[self.triggered_tip], + read=[ + self.triggered_tip, + self.triggered_top_edge, + self.triggered_bottom_edge, + ], ) super().__init__(name=name) - async def _set_triggered_tip(self, value): + async def _set_triggered_tip(self, value: Tip): if value == self.INVALID_POSITION: raise InvalidPinException else: await self.triggered_tip._backend.put(value) - async def _get_tip_position(self, array_data: NDArray[np.uint8]) -> Tip: - """ - Gets the location of the pin tip. + async def _set_edges(self, values: tuple[NDArray, NDArray]): + await self.triggered_top_edge._backend.put(values[0]) + await self.triggered_bottom_edge._backend.put(values[0]) - Returns tuple of: - (tip_x, tip_y) + async def _get_tip_and_edge_data( + self, array_data: NDArray[np.uint8] + ) -> SampleLocation: + """ + Gets the location of the pin tip and the top and bottom edges. """ preprocess_key = await self.preprocess_operation.get_value() preprocess_iter = await self.preprocess_iterations.get_value() @@ -127,9 +141,30 @@ async def _get_tip_position(self, array_data: NDArray[np.uint8]) -> Tip: (end_time - start_time) * 1000.0 ) ) + return location + async def _get_tip_position(self, array_data: NDArray[np.uint8]) -> Tip: + """ + Gets the location of the pin tip. + + Returns tuple of: + (tip_x, tip_y) + """ + location = await self._get_tip_and_edge_data(array_data) return (location.tip_x, location.tip_y) + async def _get_sample_edges( + self, array_data: NDArray[np.uint8] + ) -> Tuple[NDArray, NDArray]: + """ + Gets the location of the pin tip. + + Returns tuple of: + (edge_top: NDArray, edge_bottom: NDArray) + """ + location = await self._get_tip_and_edge_data(array_data) + return (location.edge_top, location.edge_bottom) + async def connect(self, sim: bool = False): await super().connect(sim) @@ -156,7 +191,9 @@ async def _set_triggered_tip(): """ async for value in observe_value(self.array_data): try: - await self._set_triggered_tip(await self._get_tip_position(value)) + location = await self._get_tip_and_edge_data(value) + await self._set_edges((location.edge_top, location.edge_bottom)) + await self._set_triggered_tip((location.tip_x, location.tip_y)) except Exception as e: LOGGER.warn( f"Failed to detect pin-tip location, will retry with next image: {e}" diff --git a/tests/devices/unit_tests/oav/image_recognition/test_pin_tip_detect.py b/tests/devices/unit_tests/oav/image_recognition/test_pin_tip_detect.py index b3ee5eb4b4..0ca27d2590 100644 --- a/tests/devices/unit_tests/oav/image_recognition/test_pin_tip_detect.py +++ b/tests/devices/unit_tests/oav/image_recognition/test_pin_tip_detect.py @@ -172,14 +172,25 @@ async def get_array_data(_): device = await _get_pin_tip_detection_device() class FakeLocation: - def __init__(self, tip_x, tip_y): + def __init__(self, tip_x, tip_y, edge_top, edge_bottom): self.tip_x = tip_x self.tip_y = tip_y + self.edge_top = edge_top + self.edge_bottom = edge_bottom - with patch.object(MxSampleDetect, "__init__", return_value=None), patch.object( - MxSampleDetect, - "processArray", - side_effect=[FakeLocation(None, None), FakeLocation(1, 1)], + fake_top_edge = np.array([1, 2, 3]) + fake_bottom_edge = np.array([4, 5, 6]) + + with ( + patch.object(MxSampleDetect, "__init__", return_value=None), + patch.object( + MxSampleDetect, + "processArray", + side_effect=[ + FakeLocation(None, None, fake_top_edge, fake_bottom_edge), + FakeLocation(1, 1, fake_top_edge, fake_bottom_edge), + ], + ), ): await device.trigger() mock_logger.assert_called_once() From f73e6ab1ebf3d344244d9b00ea37ef06541d1d6f Mon Sep 17 00:00:00 2001 From: David Perl Date: Mon, 4 Mar 2024 12:32:44 +0000 Subject: [PATCH 051/134] make field order x,y --- src/dodal/devices/oav/pin_image_recognition/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/dodal/devices/oav/pin_image_recognition/utils.py b/src/dodal/devices/oav/pin_image_recognition/utils.py index 7f42f136c6..9e8e232d84 100644 --- a/src/dodal/devices/oav/pin_image_recognition/utils.py +++ b/src/dodal/devices/oav/pin_image_recognition/utils.py @@ -97,8 +97,8 @@ class SampleLocation: Holder type for results from sample detection. """ - tip_y: Optional[int] tip_x: Optional[int] + tip_y: Optional[int] edge_top: np.ndarray edge_bottom: np.ndarray @@ -248,5 +248,5 @@ def _locate_sample(self, edge_arr: np.ndarray) -> SampleLocation: ) ) return SampleLocation( - tip_y=tip_y, tip_x=tip_x, edge_bottom=bottom, edge_top=top + tip_x=tip_x, tip_y=tip_y, edge_bottom=bottom, edge_top=top ) From 44aa1a1e405919731d0dc6a020560f0ab823f409 Mon Sep 17 00:00:00 2001 From: David Perl Date: Mon, 4 Mar 2024 14:19:24 +0000 Subject: [PATCH 052/134] also set edges to empty on failed read --- src/dodal/devices/oav/pin_image_recognition/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/dodal/devices/oav/pin_image_recognition/__init__.py b/src/dodal/devices/oav/pin_image_recognition/__init__.py index d03c616445..fba2d3c213 100644 --- a/src/dodal/devices/oav/pin_image_recognition/__init__.py +++ b/src/dodal/devices/oav/pin_image_recognition/__init__.py @@ -210,3 +210,4 @@ async def _set_triggered_tip(): f"No tip found in {await self.validity_timeout.get_value()} seconds." ) await self.triggered_tip._backend.put(self.INVALID_POSITION) + await self._set_edges((np.array([]), np.array([]))) From 2ecf5da624cff6134d57b4fd9312696fd8e15063 Mon Sep 17 00:00:00 2001 From: David Perl Date: Mon, 4 Mar 2024 14:32:08 +0000 Subject: [PATCH 053/134] store both top and bottom arrays --- src/dodal/devices/oav/pin_image_recognition/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dodal/devices/oav/pin_image_recognition/__init__.py b/src/dodal/devices/oav/pin_image_recognition/__init__.py index fba2d3c213..35d98bd12e 100644 --- a/src/dodal/devices/oav/pin_image_recognition/__init__.py +++ b/src/dodal/devices/oav/pin_image_recognition/__init__.py @@ -97,7 +97,7 @@ async def _set_triggered_tip(self, value: Tip): async def _set_edges(self, values: tuple[NDArray, NDArray]): await self.triggered_top_edge._backend.put(values[0]) - await self.triggered_bottom_edge._backend.put(values[0]) + await self.triggered_bottom_edge._backend.put(values[1]) async def _get_tip_and_edge_data( self, array_data: NDArray[np.uint8] From f51ec5aa4a258a960e70a894cf93e3061ab3c893 Mon Sep 17 00:00:00 2001 From: David Perl Date: Mon, 4 Mar 2024 14:50:03 +0000 Subject: [PATCH 054/134] fix test --- .../unit_tests/oav/image_recognition/test_pin_tip_detect.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/devices/unit_tests/oav/image_recognition/test_pin_tip_detect.py b/tests/devices/unit_tests/oav/image_recognition/test_pin_tip_detect.py index 0ca27d2590..82dd95a2b7 100644 --- a/tests/devices/unit_tests/oav/image_recognition/test_pin_tip_detect.py +++ b/tests/devices/unit_tests/oav/image_recognition/test_pin_tip_detect.py @@ -111,7 +111,7 @@ async def test_given_valid_data_reading_then_used_to_find_location(): process_call = mock_process_array.call_args[0][0] assert np.array_equal(process_call, image_array) - assert location[TRIGGERED_TIP_READING]["value"] == (200, 100) + assert location[TRIGGERED_TIP_READING]["value"] == (100, 200) assert location[TRIGGERED_TIP_READING]["timestamp"] > 0 From c193fc1d2b0f3df8af8825241a88f6d28a6d0414 Mon Sep 17 00:00:00 2001 From: David Perl Date: Tue, 5 Mar 2024 11:26:41 +0000 Subject: [PATCH 055/134] fix typing issues in motors for py3.9 --- src/dodal/devices/motors.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/dodal/devices/motors.py b/src/dodal/devices/motors.py index a7dc062979..3c522243ae 100644 --- a/src/dodal/devices/motors.py +++ b/src/dodal/devices/motors.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from typing import List, Tuple, Union import numpy as np from ophyd import Component, Device, EpicsMotor @@ -24,8 +25,8 @@ def is_within(self, position: float) -> bool: :param position: The position to check :return: True if position is within the limits """ - low = self.motor.low_limit_travel.get() - high = self.motor.high_limit_travel.get() + low = float(self.motor.low_limit_travel.get()) + high = float(self.motor.high_limit_travel.get()) return low <= position <= high @@ -40,7 +41,7 @@ class XYZLimitBundle: z: MotorLimitHelper def position_valid( - self, position: np.ndarray | list[float] | tuple[float, float, float] + self, position: Union[np.ndarray, List[float], Tuple[float, float, float]] ): if len(position) != 3: raise ValueError( From 65609c8b8b2230ca37e1737995e19a84c9716acb Mon Sep 17 00:00:00 2001 From: Dominic Oram Date: Tue, 5 Mar 2024 13:42:55 +0000 Subject: [PATCH 056/134] (DiamondLightSource/hyperion#1091) Add filename to zocalo callback --- src/dodal/devices/zocalo/zocalo_interaction.py | 3 +++ tests/devices/unit_tests/test_zocalo_interaction.py | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/dodal/devices/zocalo/zocalo_interaction.py b/src/dodal/devices/zocalo/zocalo_interaction.py index 0f9211621f..9229fbb995 100644 --- a/src/dodal/devices/zocalo/zocalo_interaction.py +++ b/src/dodal/devices/zocalo/zocalo_interaction.py @@ -45,6 +45,7 @@ def _send_to_zocalo(self, parameters: dict): def run_start( self, data_collection_id: int, + filename: str, start_index: int, number_of_frames: int, ): @@ -53,6 +54,7 @@ def run_start( Args: data_collection_id (int): The ID of the data collection in ISPyB + filename (str): The name of the file that the detector will store into dev/shm start_index (int): The index of the first image of this collection within the file written by the detector. number_of_frames (int): The number of frames in this collection. @@ -62,6 +64,7 @@ def run_start( { "event": "start", "ispyb_dcid": data_collection_id, + "filename": filename, "start_index": start_index, "number_of_frames": number_of_frames, } diff --git a/tests/devices/unit_tests/test_zocalo_interaction.py b/tests/devices/unit_tests/test_zocalo_interaction.py index 978bc197b7..d1b91172bb 100644 --- a/tests/devices/unit_tests/test_zocalo_interaction.py +++ b/tests/devices/unit_tests/test_zocalo_interaction.py @@ -13,11 +13,13 @@ SIM_ZOCALO_ENV = "dev_artemis" EXPECTED_DCID = 100 +EXPECTED_FILENAME = "test/file" EXPECTED_RUN_START_MESSAGE = { "event": "start", "ispyb_dcid": EXPECTED_DCID, "start_index": 0, "number_of_frames": 100, + "filename": EXPECTED_FILENAME, } EXPECTED_RUN_END_MESSAGE = { "event": "end", @@ -85,7 +87,7 @@ def test_run_start(function_wrapper: Callable, expected_message: Dict): function_wrapper (Callable): A wrapper used to test for expected exceptions expected_message (Dict): The expected dictionary sent to zocalo """ - function_to_run = partial(zc.run_start, EXPECTED_DCID, 0, 100) + function_to_run = partial(zc.run_start, EXPECTED_DCID, EXPECTED_FILENAME, 0, 100) function_to_run = partial(function_wrapper, function_to_run) _test_zocalo(function_to_run, expected_message) From ede913a3960a14e3f4efb0428de32133b057c862 Mon Sep 17 00:00:00 2001 From: David Perl Date: Tue, 5 Mar 2024 14:07:53 +0000 Subject: [PATCH 057/134] #1068 remove unused functions --- .../oav/pin_image_recognition/__init__.py | 24 +------------------ 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/src/dodal/devices/oav/pin_image_recognition/__init__.py b/src/dodal/devices/oav/pin_image_recognition/__init__.py index 35d98bd12e..e1905a37de 100644 --- a/src/dodal/devices/oav/pin_image_recognition/__init__.py +++ b/src/dodal/devices/oav/pin_image_recognition/__init__.py @@ -1,6 +1,6 @@ import asyncio import time -from typing import Optional, Tuple +from typing import Optional import numpy as np from numpy.typing import NDArray @@ -143,28 +143,6 @@ async def _get_tip_and_edge_data( ) return location - async def _get_tip_position(self, array_data: NDArray[np.uint8]) -> Tip: - """ - Gets the location of the pin tip. - - Returns tuple of: - (tip_x, tip_y) - """ - location = await self._get_tip_and_edge_data(array_data) - return (location.tip_x, location.tip_y) - - async def _get_sample_edges( - self, array_data: NDArray[np.uint8] - ) -> Tuple[NDArray, NDArray]: - """ - Gets the location of the pin tip. - - Returns tuple of: - (edge_top: NDArray, edge_bottom: NDArray) - """ - location = await self._get_tip_and_edge_data(array_data) - return (location.edge_top, location.edge_bottom) - async def connect(self, sim: bool = False): await super().connect(sim) From cecc998a6dcd0f0395b81dcf9ec323fb88235cb2 Mon Sep 17 00:00:00 2001 From: David Perl Date: Tue, 5 Mar 2024 15:13:14 +0000 Subject: [PATCH 058/134] #1068 fix test --- .../unit_tests/oav/image_recognition/test_pin_tip_detect.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/devices/unit_tests/oav/image_recognition/test_pin_tip_detect.py b/tests/devices/unit_tests/oav/image_recognition/test_pin_tip_detect.py index 82dd95a2b7..3aa6043268 100644 --- a/tests/devices/unit_tests/oav/image_recognition/test_pin_tip_detect.py +++ b/tests/devices/unit_tests/oav/image_recognition/test_pin_tip_detect.py @@ -82,7 +82,7 @@ async def test_invalid_processing_func_uses_identity_function(): patch.object(MxSampleDetect, "__init__", return_value=None) as mock_init, patch.object(MxSampleDetect, "processArray", return_value=test_sample_location), ): - await device._get_tip_position(np.array([])) + await device._get_tip_and_edge_data(np.array([])) mock_init.assert_called_once() From e3ace0eead4ef7e43a02b25f072395d0d42c060c Mon Sep 17 00:00:00 2001 From: Dominic Oram Date: Tue, 5 Mar 2024 16:28:44 +0000 Subject: [PATCH 059/134] (DiamondLightSource/hyperion#1091) Created dataclass to hold zocalo info --- src/dodal/devices/zocalo/__init__.py | 5 +-- .../devices/zocalo/zocalo_interaction.py | 44 +++++++++++-------- .../unit_tests/test_zocalo_interaction.py | 10 +++-- 3 files changed, 33 insertions(+), 26 deletions(-) diff --git a/src/dodal/devices/zocalo/__init__.py b/src/dodal/devices/zocalo/__init__.py index edd2579d2e..3644917422 100644 --- a/src/dodal/devices/zocalo/__init__.py +++ b/src/dodal/devices/zocalo/__init__.py @@ -1,6 +1,4 @@ -from dodal.devices.zocalo.zocalo_interaction import ( - ZocaloTrigger, -) +from dodal.devices.zocalo.zocalo_interaction import ZocaloStartInfo, ZocaloTrigger from dodal.devices.zocalo.zocalo_results import ( NoResultsFromZocalo, NoZocaloSubscription, @@ -17,6 +15,7 @@ "ZOCALO_READING_PLAN_NAME", "NoResultsFromZocalo", "NoZocaloSubscription", + "ZocaloStartInfo", ] ZOCALO_READING_PLAN_NAME = "zocalo reading" diff --git a/src/dodal/devices/zocalo/zocalo_interaction.py b/src/dodal/devices/zocalo/zocalo_interaction.py index 9229fbb995..bc4e8dd47e 100644 --- a/src/dodal/devices/zocalo/zocalo_interaction.py +++ b/src/dodal/devices/zocalo/zocalo_interaction.py @@ -1,5 +1,8 @@ +import dataclasses import getpass import socket +from dataclasses import dataclass +from typing import Optional import zocalo.configuration from workflows.transport import lookup @@ -16,6 +19,22 @@ def _get_zocalo_connection(environment): return transport +@dataclass +class ZocaloStartInfo: + """ + data_collection_id (int): The ID of the data collection in ISPyB + filename (str): The name of the file that the detector will store into dev/shm + number_of_frames (int): The number of frames in this collection. + start_index (int): The index of the first image of this collection within the file + written by the detector + """ + + ispyb_dcid: int + filename: Optional[str] + start_index: int + number_of_frames: int + + class ZocaloTrigger: """This class just sends 'run_start' and 'run_end' messages to zocalo, it is intended to be used in bluesky callback classes. To get results from zocalo back @@ -44,31 +63,18 @@ def _send_to_zocalo(self, parameters: dict): def run_start( self, - data_collection_id: int, - filename: str, - start_index: int, - number_of_frames: int, + start_data: ZocaloStartInfo, ): """Tells the data analysis pipeline we have started a run. Assumes that appropriate data has already been put into ISPyB Args: - data_collection_id (int): The ID of the data collection in ISPyB - filename (str): The name of the file that the detector will store into dev/shm - start_index (int): The index of the first image of this collection within - the file written by the detector. - number_of_frames (int): The number of frames in this collection. + start_data (ZocaloStartInfo): Data about the collection to send to zocalo """ - LOGGER.info(f"Starting Zocalo job with ispyb id {data_collection_id}") - self._send_to_zocalo( - { - "event": "start", - "ispyb_dcid": data_collection_id, - "filename": filename, - "start_index": start_index, - "number_of_frames": number_of_frames, - } - ) + LOGGER.info(f"Starting Zocalo job {start_data}") + data = dataclasses.asdict(start_data) + data["event"] = "start" + self._send_to_zocalo(data) def run_end(self, data_collection_id: int): """Tells the data analysis pipeline we have finished a run. diff --git a/tests/devices/unit_tests/test_zocalo_interaction.py b/tests/devices/unit_tests/test_zocalo_interaction.py index d1b91172bb..5767fcd1bf 100644 --- a/tests/devices/unit_tests/test_zocalo_interaction.py +++ b/tests/devices/unit_tests/test_zocalo_interaction.py @@ -9,17 +9,18 @@ from dodal.devices.zocalo import ( ZocaloTrigger, ) +from dodal.devices.zocalo.zocalo_interaction import ZocaloStartInfo SIM_ZOCALO_ENV = "dev_artemis" EXPECTED_DCID = 100 EXPECTED_FILENAME = "test/file" EXPECTED_RUN_START_MESSAGE = { - "event": "start", "ispyb_dcid": EXPECTED_DCID, - "start_index": 0, - "number_of_frames": 100, "filename": EXPECTED_FILENAME, + "number_of_frames": 100, + "start_index": 0, + "event": "start", } EXPECTED_RUN_END_MESSAGE = { "event": "end", @@ -87,7 +88,8 @@ def test_run_start(function_wrapper: Callable, expected_message: Dict): function_wrapper (Callable): A wrapper used to test for expected exceptions expected_message (Dict): The expected dictionary sent to zocalo """ - function_to_run = partial(zc.run_start, EXPECTED_DCID, EXPECTED_FILENAME, 0, 100) + data = ZocaloStartInfo(EXPECTED_DCID, EXPECTED_FILENAME, 0, 100) + function_to_run = partial(zc.run_start, data) function_to_run = partial(function_wrapper, function_to_run) _test_zocalo(function_to_run, expected_message) From 61e841ecbc6b28e1f240c958294c9e1b658d4530 Mon Sep 17 00:00:00 2001 From: Dominic Oram Date: Tue, 5 Mar 2024 16:43:00 +0000 Subject: [PATCH 060/134] Fix typo --- src/dodal/devices/zocalo/zocalo_interaction.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dodal/devices/zocalo/zocalo_interaction.py b/src/dodal/devices/zocalo/zocalo_interaction.py index bc4e8dd47e..48303e5f7d 100644 --- a/src/dodal/devices/zocalo/zocalo_interaction.py +++ b/src/dodal/devices/zocalo/zocalo_interaction.py @@ -22,7 +22,7 @@ def _get_zocalo_connection(environment): @dataclass class ZocaloStartInfo: """ - data_collection_id (int): The ID of the data collection in ISPyB + ispyb_dcid (int): The ID of the data collection in ISPyB filename (str): The name of the file that the detector will store into dev/shm number_of_frames (int): The number of frames in this collection. start_index (int): The index of the first image of this collection within the file From 21aa5a9770fd968824b4d63f9305b8a465efcb01 Mon Sep 17 00:00:00 2001 From: Dominic Oram Date: Tue, 5 Mar 2024 16:53:12 +0000 Subject: [PATCH 061/134] Fix SampleLocation init ordering --- src/dodal/devices/oav/pin_image_recognition/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dodal/devices/oav/pin_image_recognition/utils.py b/src/dodal/devices/oav/pin_image_recognition/utils.py index 9e8e232d84..ff249a1f6f 100644 --- a/src/dodal/devices/oav/pin_image_recognition/utils.py +++ b/src/dodal/devices/oav/pin_image_recognition/utils.py @@ -209,7 +209,7 @@ def _locate_sample(self, edge_arr: np.ndarray) -> SampleLocation: "pin-tip detection: No non-narrow edges found - cannot locate pin tip" ) return SampleLocation( - tip_y=None, tip_x=None, edge_bottom=bottom, edge_top=top + tip_x=None, tip_y=None, edge_bottom=bottom, edge_top=top ) # Choose our starting point - i.e. first column with non-narrow width for positive scan, last one for negative scan. From 1a30a01dcc0bab87a91ae50dd1c4eb2200a4e887 Mon Sep 17 00:00:00 2001 From: David Perl Date: Tue, 5 Mar 2024 17:21:34 +0000 Subject: [PATCH 062/134] #1068 use one function for storing results --- .../oav/pin_image_recognition/__init__.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/dodal/devices/oav/pin_image_recognition/__init__.py b/src/dodal/devices/oav/pin_image_recognition/__init__.py index e1905a37de..6a78772585 100644 --- a/src/dodal/devices/oav/pin_image_recognition/__init__.py +++ b/src/dodal/devices/oav/pin_image_recognition/__init__.py @@ -89,15 +89,14 @@ def __init__(self, prefix: str, name: str = ""): super().__init__(name=name) - async def _set_triggered_tip(self, value: Tip): - if value == self.INVALID_POSITION: + async def _set_triggered_values(self, results: SampleLocation): + tip = (results.tip_x, results.tip_y) + if tip == self.INVALID_POSITION: raise InvalidPinException else: - await self.triggered_tip._backend.put(value) - - async def _set_edges(self, values: tuple[NDArray, NDArray]): - await self.triggered_top_edge._backend.put(values[0]) - await self.triggered_bottom_edge._backend.put(values[1]) + await self.triggered_tip._backend.put(tip) + await self.triggered_top_edge._backend.put(results.edge_top) + await self.triggered_bottom_edge._backend.put(results.edge_bottom) async def _get_tip_and_edge_data( self, array_data: NDArray[np.uint8] @@ -170,8 +169,7 @@ async def _set_triggered_tip(): async for value in observe_value(self.array_data): try: location = await self._get_tip_and_edge_data(value) - await self._set_edges((location.edge_top, location.edge_bottom)) - await self._set_triggered_tip((location.tip_x, location.tip_y)) + await self._set_triggered_values(location) except Exception as e: LOGGER.warn( f"Failed to detect pin-tip location, will retry with next image: {e}" @@ -188,4 +186,5 @@ async def _set_triggered_tip(): f"No tip found in {await self.validity_timeout.get_value()} seconds." ) await self.triggered_tip._backend.put(self.INVALID_POSITION) - await self._set_edges((np.array([]), np.array([]))) + await self.triggered_bottom_edge._backend.put(np.array([])) + await self.triggered_top_edge._backend.put(np.array([])) From 8c15d40079c0d63d7bd24720e042bb1d76cac0de Mon Sep 17 00:00:00 2001 From: David Perl Date: Tue, 5 Mar 2024 17:27:57 +0000 Subject: [PATCH 063/134] #1068 add edges to tests --- .../oav/image_recognition/test_pin_tip_detect.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/devices/unit_tests/oav/image_recognition/test_pin_tip_detect.py b/tests/devices/unit_tests/oav/image_recognition/test_pin_tip_detect.py index 3aa6043268..cc46169b1d 100644 --- a/tests/devices/unit_tests/oav/image_recognition/test_pin_tip_detect.py +++ b/tests/devices/unit_tests/oav/image_recognition/test_pin_tip_detect.py @@ -14,6 +14,8 @@ pytest_plugins = ("pytest_asyncio",) DEVICE_NAME = "pin_tip_detection" TRIGGERED_TIP_READING = DEVICE_NAME + "-triggered_tip" +TRIGGERED_TOP_EDGE_READING = DEVICE_NAME + "-triggered_top_edge" +TRIGGERED_BOTTOM_EDGE_READING = DEVICE_NAME + "-triggered_bottom_edge" async def _get_pin_tip_detection_device() -> PinTipDetection: @@ -97,7 +99,9 @@ async def test_invalid_processing_func_uses_identity_function(): async def test_given_valid_data_reading_then_used_to_find_location(): device = await _get_pin_tip_detection_device() image_array = np.array([1, 2, 3]) - test_sample_location = SampleLocation(100, 200, np.array([]), np.array([])) + test_sample_location = SampleLocation( + 100, 200, np.array([1, 2, 3]), np.array([4, 5, 6]) + ) set_sim_value(device.array_data, image_array) with ( @@ -112,6 +116,12 @@ async def test_given_valid_data_reading_then_used_to_find_location(): process_call = mock_process_array.call_args[0][0] assert np.array_equal(process_call, image_array) assert location[TRIGGERED_TIP_READING]["value"] == (100, 200) + assert np.all( + location[TRIGGERED_TOP_EDGE_READING]["value"] == np.array([1, 2, 3]) + ) + assert np.all( + location[TRIGGERED_BOTTOM_EDGE_READING]["value"] == np.array([4, 5, 6]) + ) assert location[TRIGGERED_TIP_READING]["timestamp"] > 0 @@ -128,6 +138,8 @@ async def test_given_find_tip_fails_when_triggered_then_tip_invalid(): await device.trigger() reading = await device.read() assert reading[TRIGGERED_TIP_READING]["value"] == device.INVALID_POSITION + assert len(reading[TRIGGERED_TOP_EDGE_READING]["value"]) == 0 + assert len(reading[TRIGGERED_BOTTOM_EDGE_READING]["value"]) == 0 @pytest.mark.asyncio From f8ae6a0f30c3d63e5d2d725f3f3caa595c6ffa79 Mon Sep 17 00:00:00 2001 From: Dominic Oram Date: Wed, 6 Mar 2024 15:41:58 +0000 Subject: [PATCH 064/134] (DiamondLightSource/hyperion#1032) Minor changes from review --- src/dodal/devices/aperturescatterguard.py | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/src/dodal/devices/aperturescatterguard.py b/src/dodal/devices/aperturescatterguard.py index 571a658865..7d484b92ff 100644 --- a/src/dodal/devices/aperturescatterguard.py +++ b/src/dodal/devices/aperturescatterguard.py @@ -1,11 +1,11 @@ from collections import namedtuple from dataclasses import dataclass -from typing import List, Optional, Union +from typing import List, Optional import numpy as np from ophyd import Component as Cpt from ophyd import Signal -from ophyd.status import AndStatus, Status +from ophyd.status import AndStatus, Status, StatusBase from dodal.devices.aperture import Aperture from dodal.devices.logging_ophyd_device import InfoLoggingDevice @@ -114,7 +114,7 @@ def get_new_position( local_position = list(obj.location) if np.allclose(local_position, pos_list, atol=self.TOLERANCE_MM): return obj - return None + raise InvalidApertureMove(f"Unknown aperture position: {pos}") class ApertureScatterguard(InfoLoggingDevice): @@ -128,14 +128,11 @@ def load_aperture_positions(self, positions: AperturePositions): LOGGER.info(f"{self.name} loaded in {positions}") self.aperture_positions = positions - def set(self, pos: ApertureFiveDimensionalLocation) -> AndStatus: + def set(self, pos: ApertureFiveDimensionalLocation) -> StatusBase: new_selected_aperture: SingleAperturePosition | None = None - try: - assert isinstance(self.aperture_positions, AperturePositions) - new_selected_aperture = self.aperture_positions.get_new_position(pos) - assert new_selected_aperture is not None - except AssertionError as e: - raise InvalidApertureMove(repr(e)) + + assert isinstance(self.aperture_positions, AperturePositions) + new_selected_aperture = self.aperture_positions.get_new_position(pos) self.selected_aperture.set(new_selected_aperture) return self._safe_move_within_datacollection_range( @@ -144,7 +141,7 @@ def set(self, pos: ApertureFiveDimensionalLocation) -> AndStatus: def _safe_move_within_datacollection_range( self, pos: ApertureFiveDimensionalLocation - ) -> Union[AndStatus, Status]: + ) -> StatusBase: """ Move the aperture and scatterguard combo safely to a new position. See https://github.com/DiamondLightSource/hyperion/wiki/Aperture-Scatterguard-Collisions @@ -157,7 +154,6 @@ def _safe_move_within_datacollection_range( # unpacking the position aperture_x, aperture_y, aperture_z, scatterguard_x, scatterguard_y = pos - # CASE still moving ap_z_in_position = self.aperture.z.motor_done_move.get() if not ap_z_in_position: status: Status = Status(obj=self) @@ -169,7 +165,6 @@ def _safe_move_within_datacollection_range( ) return status - # CASE invalid target position current_ap_z = self.aperture.z.user_setpoint.get() tolerance = self.APERTURE_Z_TOLERANCE * self.aperture.z.motor_resolution.get() diff_on_z = abs(current_ap_z - aperture_z) @@ -180,7 +175,6 @@ def _safe_move_within_datacollection_range( f"Current aperture z ({current_ap_z}), outside of tolerance ({tolerance}) from target ({aperture_z})." ) - # CASE moves along Z current_ap_y = self.aperture.y.user_readback.get() if aperture_y > current_ap_y: sg_status: AndStatus = self.scatterguard.x.set( @@ -194,7 +188,6 @@ def _safe_move_within_datacollection_range( & self.aperture.z.set(aperture_z) ) - # CASE does not move along Z ap_status: AndStatus = ( self.aperture.x.set(aperture_x) & self.aperture.y.set(aperture_y) From c30fbbddad34ecd8c8b1e9ca747c8c76c1435404 Mon Sep 17 00:00:00 2001 From: Dominic Oram Date: Wed, 6 Mar 2024 15:53:12 +0000 Subject: [PATCH 065/134] (DiamondLightSource/hyperion#1032) Fix test --- tests/devices/unit_tests/test_aperture_scatterguard.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/devices/unit_tests/test_aperture_scatterguard.py b/tests/devices/unit_tests/test_aperture_scatterguard.py index 9bf5940633..0623e7e7f2 100644 --- a/tests/devices/unit_tests/test_aperture_scatterguard.py +++ b/tests/devices/unit_tests/test_aperture_scatterguard.py @@ -200,8 +200,8 @@ def test_aperture_positions_get_new_position_falsy(aperture_positions): large_missed_by_2_at_y = ApertureFiveDimensionalLocation( 2.389, 42, 15.8, 5.25, 4.43 ) - new_position = aperture_positions.get_new_position(large_missed_by_2_at_y) - assert new_position is None + with pytest.raises(InvalidApertureMove): + aperture_positions.get_new_position(large_missed_by_2_at_y) def test_aperture_positions_get_new_position_robot_load_exact(aperture_positions): From c7f48c923b8b50f14f0f3ab7ff2f7b67f930384e Mon Sep 17 00:00:00 2001 From: Dominic Oram Date: Wed, 6 Mar 2024 18:05:05 +0000 Subject: [PATCH 066/134] (DiamondLightSource/hyperion#1228) Reading aperture scatterguard reads underlying motors to work out position --- src/dodal/devices/aperturescatterguard.py | 22 ++++- .../unit_tests/test_aperture_scatterguard.py | 92 +++++++++---------- 2 files changed, 61 insertions(+), 53 deletions(-) diff --git a/src/dodal/devices/aperturescatterguard.py b/src/dodal/devices/aperturescatterguard.py index 7d484b92ff..0c87c1d57f 100644 --- a/src/dodal/devices/aperturescatterguard.py +++ b/src/dodal/devices/aperturescatterguard.py @@ -121,7 +121,6 @@ class ApertureScatterguard(InfoLoggingDevice): aperture = Cpt(Aperture, "-MO-MAPT-01:") scatterguard = Cpt(Scatterguard, "-MO-SCAT-01:") aperture_positions: Optional[AperturePositions] = None - selected_aperture = Cpt(Signal) APERTURE_Z_TOLERANCE = 3 # Number of MRES steps def load_aperture_positions(self, positions: AperturePositions): @@ -129,16 +128,31 @@ def load_aperture_positions(self, positions: AperturePositions): self.aperture_positions = positions def set(self, pos: ApertureFiveDimensionalLocation) -> StatusBase: - new_selected_aperture: SingleAperturePosition | None = None - assert isinstance(self.aperture_positions, AperturePositions) new_selected_aperture = self.aperture_positions.get_new_position(pos) - self.selected_aperture.set(new_selected_aperture) return self._safe_move_within_datacollection_range( new_selected_aperture.location ) + def read(self): + selected_aperture = Signal(name=f"{self.name}_selected_aperture") + current_motor_positions = ApertureFiveDimensionalLocation( + self.aperture.x.user_readback.get(), + self.aperture.y.user_readback.get(), + self.aperture.z.user_readback.get(), + self.scatterguard.x.user_readback.get(), + self.scatterguard.y.user_readback.get(), + ) + assert isinstance(self.aperture_positions, AperturePositions) + current_aperture = self.aperture_positions.get_new_position( + current_motor_positions + ) + selected_aperture.put(current_aperture) + res = super().read() + res.update(selected_aperture.read()) + return res + def _safe_move_within_datacollection_range( self, pos: ApertureFiveDimensionalLocation ) -> StatusBase: diff --git a/tests/devices/unit_tests/test_aperture_scatterguard.py b/tests/devices/unit_tests/test_aperture_scatterguard.py index 0623e7e7f2..11634b3f08 100644 --- a/tests/devices/unit_tests/test_aperture_scatterguard.py +++ b/tests/devices/unit_tests/test_aperture_scatterguard.py @@ -2,7 +2,7 @@ import pytest from ophyd.sim import make_fake_device -from ophyd.status import Status, StatusBase +from ophyd.status import StatusBase from dodal.devices.aperturescatterguard import ( ApertureFiveDimensionalLocation, @@ -11,12 +11,21 @@ InvalidApertureMove, ) +from .conftest import patch_motor + @pytest.fixture def ap_sg(): FakeApertureScatterguard = make_fake_device(ApertureScatterguard) ap_sg: ApertureScatterguard = FakeApertureScatterguard(name="test_ap_sg") - yield ap_sg + with ( + patch_motor(ap_sg.aperture.x), + patch_motor(ap_sg.aperture.y), + patch_motor(ap_sg.aperture.z), + patch_motor(ap_sg.scatterguard.x), + patch_motor(ap_sg.scatterguard.y), + ): + yield ap_sg @pytest.fixture @@ -25,42 +34,14 @@ def aperture_in_medium_pos( aperture_positions: AperturePositions, ): ap_sg.load_aperture_positions(aperture_positions) - ap_sg.aperture.x.user_setpoint.sim_put( # type: ignore - aperture_positions.MEDIUM.location.aperture_x - ) - ap_sg.aperture.y.user_setpoint.sim_put( # type: ignore - aperture_positions.MEDIUM.location.aperture_y - ) - ap_sg.aperture.z.user_setpoint.sim_put( # type: ignore - aperture_positions.MEDIUM.location[2] - ) - ap_sg.aperture.x.user_readback.sim_put( # type: ignore - aperture_positions.MEDIUM.location[1] - ) - ap_sg.aperture.y.user_readback.sim_put( # type: ignore - aperture_positions.MEDIUM.location[1] - ) - ap_sg.aperture.z.user_readback.sim_put( # type: ignore - aperture_positions.MEDIUM.location[1] - ) - ap_sg.scatterguard.x.user_setpoint.sim_put( # type: ignore - aperture_positions.MEDIUM.location[3] - ) - ap_sg.scatterguard.y.user_setpoint.sim_put( # type: ignore - aperture_positions.MEDIUM.location[4] - ) - ap_sg.scatterguard.x.user_readback.sim_put( # type: ignore - aperture_positions.MEDIUM.location[3] - ) - ap_sg.scatterguard.y.user_readback.sim_put( # type: ignore - aperture_positions.MEDIUM.location[4] - ) - ap_sg.aperture.x.motor_done_move.sim_put(1) # type: ignore - ap_sg.aperture.y.motor_done_move.sim_put(1) # type: ignore - ap_sg.aperture.z.motor_done_move.sim_put(1) # type: ignore - ap_sg.scatterguard.x.motor_done_move.sim_put(1) # type: ignore - ap_sg.scatterguard.y.motor_done_move.sim_put(1) # type: ignore - return ap_sg + + medium = aperture_positions.MEDIUM.location + ap_sg.aperture.x.set(medium.aperture_x) + ap_sg.aperture.y.set(medium.aperture_y) + ap_sg.aperture.z.set(medium.aperture_z) + ap_sg.scatterguard.x.set(medium.scatterguard_x) + ap_sg.scatterguard.y.set(medium.scatterguard_y) + yield ap_sg @pytest.fixture @@ -157,7 +138,7 @@ def test_aperture_scatterguard_throws_error_if_outside_tolerance( ap_sg.aperture.z.motor_done_move.sim_put(1) # type: ignore with pytest.raises(InvalidApertureMove): - pos: ApertureFiveDimensionalLocation = (0, 0, 1.1, 0, 0) + pos = ApertureFiveDimensionalLocation(0, 0, 1.1, 0, 0) ap_sg._safe_move_within_datacollection_range(pos) @@ -168,16 +149,7 @@ def test_aperture_scatterguard_returns_status_if_within_tolerance( ap_sg.aperture.z.user_setpoint.sim_put(1) # type: ignore ap_sg.aperture.z.motor_done_move.sim_put(1) # type: ignore - mock_set = MagicMock(return_value=Status(done=True, success=True)) - - ap_sg.aperture.x.set = mock_set - ap_sg.aperture.y.set = mock_set - ap_sg.aperture.z.set = mock_set - - ap_sg.scatterguard.x.set = mock_set - ap_sg.scatterguard.y.set = mock_set - - pos = (0, 0, 1, 0, 0) + pos = ApertureFiveDimensionalLocation(0, 0, 1, 0, 0) status = ap_sg._safe_move_within_datacollection_range(pos) assert isinstance(status, StatusBase) @@ -216,6 +188,28 @@ def test_aperture_positions_get_new_position_robot_load_exact(aperture_positions assert new_position is aperture_positions.ROBOT_LOAD +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: AperturePositions +): + selected_aperture = aperture_in_medium_pos.read() + assert ( + selected_aperture["test_ap_sg_selected_aperture"]["value"] + == aperture_positions.MEDIUM + ) + + +def test_when_aperture_set_and_device_read_then_position_returned( + aperture_in_medium_pos: ApertureScatterguard, aperture_positions: AperturePositions +): + set_status = aperture_in_medium_pos.set(aperture_positions.SMALL.location) + set_status.wait() + selected_aperture = aperture_in_medium_pos.read() + assert ( + selected_aperture["test_ap_sg_selected_aperture"]["value"] + == aperture_positions.SMALL + ) + + def install_logger_for_aperture_and_scatterguard(aperture_scatterguard): parent_mock = MagicMock() mock_ap_x = MagicMock(aperture_scatterguard.aperture.x.set) From 1a36b8b6cdb9af17055d6426ad3d045bbc9f0339 Mon Sep 17 00:00:00 2001 From: Dominic Oram Date: Wed, 6 Mar 2024 18:41:36 +0000 Subject: [PATCH 067/134] (DiamondLightSource/hyperion#1228) Set position as SingleAperturePosition --- src/dodal/devices/aperturescatterguard.py | 4 ++-- .../unit_tests/test_aperture_scatterguard.py | 24 ++++++++----------- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/src/dodal/devices/aperturescatterguard.py b/src/dodal/devices/aperturescatterguard.py index 0c87c1d57f..b6dca9b6c5 100644 --- a/src/dodal/devices/aperturescatterguard.py +++ b/src/dodal/devices/aperturescatterguard.py @@ -127,9 +127,9 @@ def load_aperture_positions(self, positions: AperturePositions): LOGGER.info(f"{self.name} loaded in {positions}") self.aperture_positions = positions - def set(self, pos: ApertureFiveDimensionalLocation) -> StatusBase: + def set(self, pos: SingleAperturePosition) -> StatusBase: assert isinstance(self.aperture_positions, AperturePositions) - new_selected_aperture = self.aperture_positions.get_new_position(pos) + new_selected_aperture = self.aperture_positions.get_new_position(pos.location) return self._safe_move_within_datacollection_range( new_selected_aperture.location diff --git a/tests/devices/unit_tests/test_aperture_scatterguard.py b/tests/devices/unit_tests/test_aperture_scatterguard.py index 11634b3f08..02178c0818 100644 --- a/tests/devices/unit_tests/test_aperture_scatterguard.py +++ b/tests/devices/unit_tests/test_aperture_scatterguard.py @@ -9,6 +9,7 @@ AperturePositions, ApertureScatterguard, InvalidApertureMove, + SingleAperturePosition, ) from .conftest import patch_motor @@ -73,18 +74,13 @@ def aperture_positions(): return aperture_positions -def test_aperture_scatterguard_rejects_unknown_position( - aperture_positions, aperture_in_medium_pos -): - for i in range(len(aperture_positions.MEDIUM.location)): - # get a list copy - pos = list(aperture_positions.MEDIUM.location) - # change 1 dimension more than tolerance - pos[i] += 0.01 - position_to_reject: ApertureFiveDimensionalLocation = tuple(pos) +def test_aperture_scatterguard_rejects_unknown_position(aperture_in_medium_pos): + position_to_reject = ApertureFiveDimensionalLocation(0, 0, 0, 0, 0) - with pytest.raises(InvalidApertureMove): - aperture_in_medium_pos.set(position_to_reject) + with pytest.raises(InvalidApertureMove): + aperture_in_medium_pos.set( + SingleAperturePosition("test", 10, position_to_reject) + ) def test_aperture_scatterguard_select_bottom_moves_sg_down_then_assembly_up( @@ -94,7 +90,7 @@ def test_aperture_scatterguard_select_bottom_moves_sg_down_then_assembly_up( aperture_scatterguard = aperture_in_medium_pos call_logger = install_logger_for_aperture_and_scatterguard(aperture_scatterguard) - aperture_scatterguard.set(aperture_positions.SMALL.location) + aperture_scatterguard.set(aperture_positions.SMALL) actual_calls = call_logger.mock_calls expected_calls = [ @@ -115,7 +111,7 @@ def test_aperture_scatterguard_select_top_moves_assembly_down_then_sg_up( aperture_scatterguard = aperture_in_medium_pos call_logger = install_logger_for_aperture_and_scatterguard(aperture_scatterguard) - aperture_scatterguard.set(aperture_positions.LARGE.location) + aperture_scatterguard.set(aperture_positions.LARGE) actual_calls = call_logger.mock_calls expected_calls = [ @@ -201,7 +197,7 @@ def test_given_aperture_not_set_through_device_but_motors_in_position_when_devic def test_when_aperture_set_and_device_read_then_position_returned( aperture_in_medium_pos: ApertureScatterguard, aperture_positions: AperturePositions ): - set_status = aperture_in_medium_pos.set(aperture_positions.SMALL.location) + set_status = aperture_in_medium_pos.set(aperture_positions.SMALL) set_status.wait() selected_aperture = aperture_in_medium_pos.read() assert ( From cb2f36277b69cddc14d88e85fd956c39657bb39c Mon Sep 17 00:00:00 2001 From: Dominic Oram Date: Wed, 6 Mar 2024 18:47:35 +0000 Subject: [PATCH 068/134] (DiamondLightSource/hyperion#1228) Tidy up --- src/dodal/devices/aperturescatterguard.py | 29 ++++++++++--------- .../unit_tests/test_aperture_scatterguard.py | 16 +++++----- 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/src/dodal/devices/aperturescatterguard.py b/src/dodal/devices/aperturescatterguard.py index b6dca9b6c5..77110b16db 100644 --- a/src/dodal/devices/aperturescatterguard.py +++ b/src/dodal/devices/aperturescatterguard.py @@ -97,24 +97,26 @@ def from_gda_beamline_params(cls, params): ), ) - def get_new_position( - self, pos: ApertureFiveDimensionalLocation - ) -> SingleAperturePosition: - """ - Check if argument 'pos' is a valid position in this AperturePositions object. - """ - options: List[SingleAperturePosition] = [ + def as_list(self) -> List[SingleAperturePosition]: + return [ self.LARGE, self.MEDIUM, self.SMALL, self.ROBOT_LOAD, ] + + def get_close_position( + self, pos: ApertureFiveDimensionalLocation + ) -> SingleAperturePosition: + """ + Returns the closest valid position to {pos} within {TOLERANCE_MM} + """ pos_list = list(pos) - for obj in options: + for obj in self.as_list(): local_position = list(obj.location) if np.allclose(local_position, pos_list, atol=self.TOLERANCE_MM): return obj - raise InvalidApertureMove(f"Unknown aperture position: {pos}") + raise InvalidApertureMove(f"Unknown aperture: {pos}") class ApertureScatterguard(InfoLoggingDevice): @@ -129,11 +131,10 @@ def load_aperture_positions(self, positions: AperturePositions): def set(self, pos: SingleAperturePosition) -> StatusBase: assert isinstance(self.aperture_positions, AperturePositions) - new_selected_aperture = self.aperture_positions.get_new_position(pos.location) + if pos not in self.aperture_positions.as_list(): + raise InvalidApertureMove(f"Unknown aperture: {pos}") - return self._safe_move_within_datacollection_range( - new_selected_aperture.location - ) + return self._safe_move_within_datacollection_range(pos.location) def read(self): selected_aperture = Signal(name=f"{self.name}_selected_aperture") @@ -145,7 +146,7 @@ def read(self): self.scatterguard.y.user_readback.get(), ) assert isinstance(self.aperture_positions, AperturePositions) - current_aperture = self.aperture_positions.get_new_position( + current_aperture = self.aperture_positions.get_close_position( current_motor_positions ) selected_aperture.put(current_aperture) diff --git a/tests/devices/unit_tests/test_aperture_scatterguard.py b/tests/devices/unit_tests/test_aperture_scatterguard.py index 02178c0818..5fd83ec0a2 100644 --- a/tests/devices/unit_tests/test_aperture_scatterguard.py +++ b/tests/devices/unit_tests/test_aperture_scatterguard.py @@ -150,29 +150,29 @@ def test_aperture_scatterguard_returns_status_if_within_tolerance( assert isinstance(status, StatusBase) -def test_aperture_positions_get_new_position_truthy_exact(aperture_positions): +def test_aperture_positions_get_close_position_truthy_exact(aperture_positions): should_be_large = ApertureFiveDimensionalLocation(2.389, 40.986, 15.8, 5.25, 4.43) - new_position = aperture_positions.get_new_position(should_be_large) + new_position = aperture_positions.get_close_position(should_be_large) assert new_position == aperture_positions.LARGE -def test_aperture_positions_get_new_position_truthy_inside_tolerance( +def test_aperture_positions_get_close_position_truthy_inside_tolerance( aperture_positions, ): should_be_large = ApertureFiveDimensionalLocation(2.389, 40.9865, 15.8, 5.25, 4.43) - new_position = aperture_positions.get_new_position(should_be_large) + new_position = aperture_positions.get_close_position(should_be_large) assert new_position == aperture_positions.LARGE -def test_aperture_positions_get_new_position_falsy(aperture_positions): +def test_aperture_positions_get_close_position_falsy(aperture_positions): large_missed_by_2_at_y = ApertureFiveDimensionalLocation( 2.389, 42, 15.8, 5.25, 4.43 ) with pytest.raises(InvalidApertureMove): - aperture_positions.get_new_position(large_missed_by_2_at_y) + aperture_positions.get_close_position(large_missed_by_2_at_y) -def test_aperture_positions_get_new_position_robot_load_exact(aperture_positions): +def test_aperture_positions_get_close_position_robot_load_exact(aperture_positions): robot_exact = ApertureFiveDimensionalLocation( 2.386, 31.40, @@ -180,7 +180,7 @@ def test_aperture_positions_get_new_position_robot_load_exact(aperture_positions 5.25, 4.43, ) - new_position = aperture_positions.get_new_position(robot_exact) + new_position = aperture_positions.get_close_position(robot_exact) assert new_position is aperture_positions.ROBOT_LOAD From 85fe5711105e2102b5e43f778b5548a781da8619 Mon Sep 17 00:00:00 2001 From: Dominic Oram Date: Wed, 6 Mar 2024 18:53:00 +0000 Subject: [PATCH 069/134] (DiamondLightSource/hyperion#1228) Move reading aperture positions to a function --- src/dodal/devices/aperturescatterguard.py | 52 +++++++---------------- 1 file changed, 16 insertions(+), 36 deletions(-) diff --git a/src/dodal/devices/aperturescatterguard.py b/src/dodal/devices/aperturescatterguard.py index 77110b16db..bc30976b69 100644 --- a/src/dodal/devices/aperturescatterguard.py +++ b/src/dodal/devices/aperturescatterguard.py @@ -29,6 +29,18 @@ class InvalidApertureMove(Exception): ) +def create_location_from_params( + GDA_location_name: str, params: dict +) -> ApertureFiveDimensionalLocation: + return ApertureFiveDimensionalLocation( + params[f"miniap_x_{GDA_location_name}"], + params[f"miniap_y_{GDA_location_name}"], + params[f"miniap_z_{GDA_location_name}"], + params[f"sg_x_{GDA_location_name}"], + params[f"sg_y_{GDA_location_name}"], + ) + + @dataclass class SingleAperturePosition: name: str @@ -52,48 +64,16 @@ class AperturePositions: def from_gda_beamline_params(cls, params): return cls( LARGE=SingleAperturePosition( - "Large", - 100, - ApertureFiveDimensionalLocation( - params["miniap_x_LARGE_APERTURE"], - params["miniap_y_LARGE_APERTURE"], - params["miniap_z_LARGE_APERTURE"], - params["sg_x_LARGE_APERTURE"], - params["sg_y_LARGE_APERTURE"], - ), + "Large", 100, create_location_from_params("LARGE_APERTURE", params) ), MEDIUM=SingleAperturePosition( - "Medium", - 50, - ApertureFiveDimensionalLocation( - params["miniap_x_MEDIUM_APERTURE"], - params["miniap_y_MEDIUM_APERTURE"], - params["miniap_z_MEDIUM_APERTURE"], - params["sg_x_MEDIUM_APERTURE"], - params["sg_y_MEDIUM_APERTURE"], - ), + "Medium", 50, create_location_from_params("MEDIUM_APERTURE", params) ), SMALL=SingleAperturePosition( - "Small", - 20, - ApertureFiveDimensionalLocation( - params["miniap_x_SMALL_APERTURE"], - params["miniap_y_SMALL_APERTURE"], - params["miniap_z_SMALL_APERTURE"], - params["sg_x_SMALL_APERTURE"], - params["sg_y_SMALL_APERTURE"], - ), + "Small", 20, create_location_from_params("SMALL_APERTURE", params) ), ROBOT_LOAD=SingleAperturePosition( - "Robot load", - None, - ApertureFiveDimensionalLocation( - params["miniap_x_ROBOT_LOAD"], - params["miniap_y_ROBOT_LOAD"], - params["miniap_z_ROBOT_LOAD"], - params["sg_x_ROBOT_LOAD"], - params["sg_y_ROBOT_LOAD"], - ), + "Robot load", None, create_location_from_params("ROBOT_LOAD", params) ), ) From 58ec5182103b4f8cc99fd62e8b96106bd9e8b667 Mon Sep 17 00:00:00 2001 From: Dominic Oram Date: Wed, 6 Mar 2024 19:32:38 +0000 Subject: [PATCH 070/134] (DiamondLightSource/hyperion#1228) User motor res as tolerance when reading position --- src/dodal/devices/aperture.py | 10 ++-- src/dodal/devices/aperturescatterguard.py | 56 +++++++++---------- src/dodal/devices/scatterguard.py | 8 ++- .../unit_tests/test_aperture_scatterguard.py | 49 ++++++++-------- 4 files changed, 63 insertions(+), 60 deletions(-) diff --git a/src/dodal/devices/aperture.py b/src/dodal/devices/aperture.py index d78c983e51..62ba0c772a 100644 --- a/src/dodal/devices/aperture.py +++ b/src/dodal/devices/aperture.py @@ -1,11 +1,9 @@ -from ophyd import Component, Device, EpicsMotor, EpicsSignalRO +from ophyd import Component, Device - -class EpicsMotorWithMRES(EpicsMotor): - motor_resolution: Component[EpicsSignalRO] = Component(EpicsSignalRO, ".MRES") +from dodal.devices.util.motor_utils import EpicsMotorWithMRES class Aperture(Device): - x = Component(EpicsMotor, "X") - y = Component(EpicsMotor, "Y") + x = Component(EpicsMotorWithMRES, "X") + y = Component(EpicsMotorWithMRES, "Y") z = Component(EpicsMotorWithMRES, "Z") diff --git a/src/dodal/devices/aperturescatterguard.py b/src/dodal/devices/aperturescatterguard.py index bc30976b69..408d6193a4 100644 --- a/src/dodal/devices/aperturescatterguard.py +++ b/src/dodal/devices/aperturescatterguard.py @@ -57,9 +57,6 @@ class AperturePositions: SMALL: SingleAperturePosition ROBOT_LOAD: SingleAperturePosition - # one micrometre tolerance - TOLERANCE_MM: float = 0.001 - @classmethod def from_gda_beamline_params(cls, params): return cls( @@ -85,25 +82,12 @@ def as_list(self) -> List[SingleAperturePosition]: self.ROBOT_LOAD, ] - def get_close_position( - self, pos: ApertureFiveDimensionalLocation - ) -> SingleAperturePosition: - """ - Returns the closest valid position to {pos} within {TOLERANCE_MM} - """ - pos_list = list(pos) - for obj in self.as_list(): - local_position = list(obj.location) - if np.allclose(local_position, pos_list, atol=self.TOLERANCE_MM): - return obj - raise InvalidApertureMove(f"Unknown aperture: {pos}") - class ApertureScatterguard(InfoLoggingDevice): aperture = Cpt(Aperture, "-MO-MAPT-01:") scatterguard = Cpt(Scatterguard, "-MO-SCAT-01:") aperture_positions: Optional[AperturePositions] = None - APERTURE_Z_TOLERANCE = 3 # Number of MRES steps + TOLERANCE_STEPS = 3 # Number of MRES steps def load_aperture_positions(self, positions: AperturePositions): LOGGER.info(f"{self.name} loaded in {positions}") @@ -116,19 +100,35 @@ def set(self, pos: SingleAperturePosition) -> StatusBase: return self._safe_move_within_datacollection_range(pos.location) + def _get_closest_position_to_current(self) -> SingleAperturePosition: + """ + Returns the closest valid position to current position within {TOLERANCE_STEPS}. + If no position is found then raises InvalidApertureMove. + """ + assert isinstance(self.aperture_positions, AperturePositions) + for aperture in self.aperture_positions.as_list(): + aperture_in_tolerence = [] + motors = [ + self.aperture.x, + self.aperture.y, + self.aperture.z, + self.scatterguard.x, + self.scatterguard.y, + ] + for motor, test_position in zip(motors, list(aperture.location)): + current_position = motor.user_readback.get() + tolerance = self.TOLERANCE_STEPS * motor.motor_resolution.get() + diff = abs(current_position - test_position) + aperture_in_tolerence.append(diff <= tolerance) + if np.all(aperture_in_tolerence): + return aperture + + raise InvalidApertureMove("Current aperture/scatterguard state unrecognised") + def read(self): selected_aperture = Signal(name=f"{self.name}_selected_aperture") - current_motor_positions = ApertureFiveDimensionalLocation( - self.aperture.x.user_readback.get(), - self.aperture.y.user_readback.get(), - self.aperture.z.user_readback.get(), - self.scatterguard.x.user_readback.get(), - self.scatterguard.y.user_readback.get(), - ) assert isinstance(self.aperture_positions, AperturePositions) - current_aperture = self.aperture_positions.get_close_position( - current_motor_positions - ) + current_aperture = self._get_closest_position_to_current() selected_aperture.put(current_aperture) res = super().read() res.update(selected_aperture.read()) @@ -161,7 +161,7 @@ def _safe_move_within_datacollection_range( return status current_ap_z = self.aperture.z.user_setpoint.get() - tolerance = self.APERTURE_Z_TOLERANCE * self.aperture.z.motor_resolution.get() + tolerance = self.TOLERANCE_STEPS * self.aperture.z.motor_resolution.get() diff_on_z = abs(current_ap_z - aperture_z) if diff_on_z > tolerance: raise InvalidApertureMove( diff --git a/src/dodal/devices/scatterguard.py b/src/dodal/devices/scatterguard.py index 6c9374169f..eb05052166 100644 --- a/src/dodal/devices/scatterguard.py +++ b/src/dodal/devices/scatterguard.py @@ -1,7 +1,9 @@ from ophyd import Component as Cpt -from ophyd import Device, EpicsMotor +from ophyd import Device + +from dodal.devices.util.motor_utils import EpicsMotorWithMRES class Scatterguard(Device): - x = Cpt(EpicsMotor, "X") - y = Cpt(EpicsMotor, "Y") + x = Cpt(EpicsMotorWithMRES, "X") + y = Cpt(EpicsMotorWithMRES, "Y") diff --git a/tests/devices/unit_tests/test_aperture_scatterguard.py b/tests/devices/unit_tests/test_aperture_scatterguard.py index 5fd83ec0a2..d75f93141e 100644 --- a/tests/devices/unit_tests/test_aperture_scatterguard.py +++ b/tests/devices/unit_tests/test_aperture_scatterguard.py @@ -16,9 +16,10 @@ @pytest.fixture -def ap_sg(): +def ap_sg(aperture_positions: AperturePositions): FakeApertureScatterguard = make_fake_device(ApertureScatterguard) ap_sg: ApertureScatterguard = FakeApertureScatterguard(name="test_ap_sg") + ap_sg.load_aperture_positions(aperture_positions) with ( patch_motor(ap_sg.aperture.x), patch_motor(ap_sg.aperture.y), @@ -34,8 +35,6 @@ def aperture_in_medium_pos( ap_sg: ApertureScatterguard, aperture_positions: AperturePositions, ): - ap_sg.load_aperture_positions(aperture_positions) - medium = aperture_positions.MEDIUM.location ap_sg.aperture.x.set(medium.aperture_x) ap_sg.aperture.y.set(medium.aperture_y) @@ -150,38 +149,42 @@ def test_aperture_scatterguard_returns_status_if_within_tolerance( assert isinstance(status, StatusBase) -def test_aperture_positions_get_close_position_truthy_exact(aperture_positions): +def set_underlying_motors( + ap_sg: ApertureScatterguard, position: ApertureFiveDimensionalLocation +): + ap_sg.aperture.x.set(position.aperture_x) + ap_sg.aperture.y.set(position.aperture_y) + ap_sg.aperture.z.set(position.aperture_z) + ap_sg.scatterguard.x.set(position.scatterguard_x) + ap_sg.scatterguard.y.set(position.scatterguard_y) + + +def test_aperture_positions_get_close_position_truthy_exact( + ap_sg: ApertureScatterguard, aperture_positions: AperturePositions +): should_be_large = ApertureFiveDimensionalLocation(2.389, 40.986, 15.8, 5.25, 4.43) - new_position = aperture_positions.get_close_position(should_be_large) - assert new_position == aperture_positions.LARGE + set_underlying_motors(ap_sg, should_be_large) + + assert ap_sg._get_closest_position_to_current() == aperture_positions.LARGE def test_aperture_positions_get_close_position_truthy_inside_tolerance( - aperture_positions, + ap_sg: ApertureScatterguard, aperture_positions: AperturePositions ): should_be_large = ApertureFiveDimensionalLocation(2.389, 40.9865, 15.8, 5.25, 4.43) - new_position = aperture_positions.get_close_position(should_be_large) - assert new_position == aperture_positions.LARGE + set_underlying_motors(ap_sg, should_be_large) + assert ap_sg._get_closest_position_to_current() == aperture_positions.LARGE -def test_aperture_positions_get_close_position_falsy(aperture_positions): +def test_aperture_positions_get_close_position_falsy( + ap_sg: ApertureScatterguard, aperture_positions: AperturePositions +): large_missed_by_2_at_y = ApertureFiveDimensionalLocation( 2.389, 42, 15.8, 5.25, 4.43 ) + set_underlying_motors(ap_sg, large_missed_by_2_at_y) with pytest.raises(InvalidApertureMove): - aperture_positions.get_close_position(large_missed_by_2_at_y) - - -def test_aperture_positions_get_close_position_robot_load_exact(aperture_positions): - robot_exact = ApertureFiveDimensionalLocation( - 2.386, - 31.40, - 15.8, - 5.25, - 4.43, - ) - new_position = aperture_positions.get_close_position(robot_exact) - assert new_position is aperture_positions.ROBOT_LOAD + ap_sg._get_closest_position_to_current() def test_given_aperture_not_set_through_device_but_motors_in_position_when_device_read_then_position_returned( From 64c00c90b4069e6a230224b4aa1aa66e36401280 Mon Sep 17 00:00:00 2001 From: Dominic Oram Date: Wed, 6 Mar 2024 21:18:01 +0000 Subject: [PATCH 071/134] (DiamondLightSource/hyperion#1228) Move selected aperture to new signal type --- src/dodal/devices/aperturescatterguard.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/dodal/devices/aperturescatterguard.py b/src/dodal/devices/aperturescatterguard.py index 408d6193a4..ab19e27a71 100644 --- a/src/dodal/devices/aperturescatterguard.py +++ b/src/dodal/devices/aperturescatterguard.py @@ -4,7 +4,7 @@ import numpy as np from ophyd import Component as Cpt -from ophyd import Signal +from ophyd import SignalRO from ophyd.status import AndStatus, Status, StatusBase from dodal.devices.aperture import Aperture @@ -89,6 +89,13 @@ class ApertureScatterguard(InfoLoggingDevice): aperture_positions: Optional[AperturePositions] = None TOLERANCE_STEPS = 3 # Number of MRES steps + class SelectedAperture(SignalRO): + def get(self): + assert isinstance(self.parent, ApertureScatterguard) + return self.parent._get_closest_position_to_current() + + selected_aperture = Cpt(SelectedAperture) + def load_aperture_positions(self, positions: AperturePositions): LOGGER.info(f"{self.name} loaded in {positions}") self.aperture_positions = positions @@ -125,15 +132,6 @@ def _get_closest_position_to_current(self) -> SingleAperturePosition: raise InvalidApertureMove("Current aperture/scatterguard state unrecognised") - def read(self): - selected_aperture = Signal(name=f"{self.name}_selected_aperture") - assert isinstance(self.aperture_positions, AperturePositions) - current_aperture = self._get_closest_position_to_current() - selected_aperture.put(current_aperture) - res = super().read() - res.update(selected_aperture.read()) - return res - def _safe_move_within_datacollection_range( self, pos: ApertureFiveDimensionalLocation ) -> StatusBase: From 85fee2437d51ee9471f1190f83b5fb5936fa1f56 Mon Sep 17 00:00:00 2001 From: Dominic Oram Date: Wed, 6 Mar 2024 21:36:29 +0000 Subject: [PATCH 072/134] (DiamondLightSource/hyperion#1228) Add GDA name to selected aperture --- src/dodal/devices/aperturescatterguard.py | 46 +++++++++++------------ 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/src/dodal/devices/aperturescatterguard.py b/src/dodal/devices/aperturescatterguard.py index ab19e27a71..966556aa2a 100644 --- a/src/dodal/devices/aperturescatterguard.py +++ b/src/dodal/devices/aperturescatterguard.py @@ -29,25 +29,31 @@ class InvalidApertureMove(Exception): ) -def create_location_from_params( - GDA_location_name: str, params: dict -) -> ApertureFiveDimensionalLocation: - return ApertureFiveDimensionalLocation( - params[f"miniap_x_{GDA_location_name}"], - params[f"miniap_y_{GDA_location_name}"], - params[f"miniap_z_{GDA_location_name}"], - params[f"sg_x_{GDA_location_name}"], - params[f"sg_y_{GDA_location_name}"], - ) - - @dataclass class SingleAperturePosition: name: str + GDA_name: str radius_microns: Optional[int] location: ApertureFiveDimensionalLocation +def position_from_params( + name: str, GDA_name: str, radius_microns: Optional[float], params: dict +) -> SingleAperturePosition: + return SingleAperturePosition( + name, + GDA_name, + radius_microns, + ApertureFiveDimensionalLocation( + params[f"miniap_x_{GDA_name}"], + params[f"miniap_y_{GDA_name}"], + params[f"miniap_z_{GDA_name}"], + params[f"sg_x_{GDA_name}"], + params[f"sg_y_{GDA_name}"], + ), + ) + + @dataclass class AperturePositions: """Holds the motor positions needed to select a particular aperture size.""" @@ -60,18 +66,10 @@ class AperturePositions: @classmethod def from_gda_beamline_params(cls, params): return cls( - LARGE=SingleAperturePosition( - "Large", 100, create_location_from_params("LARGE_APERTURE", params) - ), - MEDIUM=SingleAperturePosition( - "Medium", 50, create_location_from_params("MEDIUM_APERTURE", params) - ), - SMALL=SingleAperturePosition( - "Small", 20, create_location_from_params("SMALL_APERTURE", params) - ), - ROBOT_LOAD=SingleAperturePosition( - "Robot load", None, create_location_from_params("ROBOT_LOAD", params) - ), + LARGE=position_from_params("Large", "LARGE_APERTURE", 100, params), + MEDIUM=position_from_params("Medium", "MEDIUM_APERTURE", 50, params), + SMALL=position_from_params("Small", "SMALL_APERTURE", 20, params), + ROBOT_LOAD=position_from_params("Robot load", "ROBOT_LOAD", None, params), ) def as_list(self) -> List[SingleAperturePosition]: From 0ff3ec7194e3cb03833c151e8a38a205a3f50b96 Mon Sep 17 00:00:00 2001 From: Dominic Oram Date: Wed, 6 Mar 2024 21:46:41 +0000 Subject: [PATCH 073/134] (DiamondLightSource/hyperion#1228) Fix linting and add missing files --- src/dodal/devices/aperturescatterguard.py | 2 +- src/dodal/devices/util/motor_utils.py | 5 +++++ tests/devices/unit_tests/conftest.py | 23 +++++++++++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 src/dodal/devices/util/motor_utils.py create mode 100644 tests/devices/unit_tests/conftest.py diff --git a/src/dodal/devices/aperturescatterguard.py b/src/dodal/devices/aperturescatterguard.py index 966556aa2a..f920dd8008 100644 --- a/src/dodal/devices/aperturescatterguard.py +++ b/src/dodal/devices/aperturescatterguard.py @@ -33,7 +33,7 @@ class InvalidApertureMove(Exception): class SingleAperturePosition: name: str GDA_name: str - radius_microns: Optional[int] + radius_microns: Optional[float] location: ApertureFiveDimensionalLocation diff --git a/src/dodal/devices/util/motor_utils.py b/src/dodal/devices/util/motor_utils.py new file mode 100644 index 0000000000..591cd66b63 --- /dev/null +++ b/src/dodal/devices/util/motor_utils.py @@ -0,0 +1,5 @@ +from ophyd import Component, EpicsMotor, EpicsSignalRO + + +class EpicsMotorWithMRES(EpicsMotor): + motor_resolution: Component[EpicsSignalRO] = Component(EpicsSignalRO, ".MRES") diff --git a/tests/devices/unit_tests/conftest.py b/tests/devices/unit_tests/conftest.py new file mode 100644 index 0000000000..5b872623c8 --- /dev/null +++ b/tests/devices/unit_tests/conftest.py @@ -0,0 +1,23 @@ +from functools import partial +from unittest.mock import MagicMock, patch + +from ophyd.epics_motor import EpicsMotor +from ophyd.status import Status + +from dodal.devices.util.motor_utils import EpicsMotorWithMRES + + +def mock_set(motor: EpicsMotor, val): + motor.user_setpoint.sim_put(val) # type: ignore + motor.user_readback.sim_put(val) # type: ignore + return Status(done=True, success=True) + + +def patch_motor(motor: EpicsMotor | EpicsMotorWithMRES, initial_position=0): + motor.user_setpoint.sim_put(initial_position) # type: ignore + motor.user_readback.sim_put(initial_position) # type: ignore + motor.motor_done_move.sim_put(1) # type: ignore + motor.user_setpoint._use_limits = False + if isinstance(motor, EpicsMotorWithMRES): + motor.motor_resolution.sim_put(0.001) # type: ignore + return patch.object(motor, "set", MagicMock(side_effect=partial(mock_set, motor))) From 6cb0d564f1ce77ce286760e557b28e4eac1731d1 Mon Sep 17 00:00:00 2001 From: Dominic Oram Date: Wed, 6 Mar 2024 21:50:18 +0000 Subject: [PATCH 074/134] (DiamondLightSource/hyperion#1228) Fix tests --- .../unit_tests/test_aperture_scatterguard.py | 79 ++++++------------- 1 file changed, 25 insertions(+), 54 deletions(-) diff --git a/tests/devices/unit_tests/test_aperture_scatterguard.py b/tests/devices/unit_tests/test_aperture_scatterguard.py index d75f93141e..46b86a3fe0 100644 --- a/tests/devices/unit_tests/test_aperture_scatterguard.py +++ b/tests/devices/unit_tests/test_aperture_scatterguard.py @@ -1,4 +1,4 @@ -from unittest.mock import MagicMock +from unittest.mock import MagicMock, call import pytest from ophyd.sim import make_fake_device @@ -78,7 +78,7 @@ def test_aperture_scatterguard_rejects_unknown_position(aperture_in_medium_pos): with pytest.raises(InvalidApertureMove): aperture_in_medium_pos.set( - SingleAperturePosition("test", 10, position_to_reject) + SingleAperturePosition("test", "GDA_NAME", 10, position_to_reject) ) @@ -91,17 +91,15 @@ def test_aperture_scatterguard_select_bottom_moves_sg_down_then_assembly_up( aperture_scatterguard.set(aperture_positions.SMALL) - actual_calls = call_logger.mock_calls - expected_calls = [ - ("_mock_sg_x", (5.3375,)), - ("_mock_sg_y", (-3.55,)), - lambda call: call[0].endswith("__and__().wait"), - ("_mock_ap_x", (2.43,)), - ("_mock_ap_y", (48.974,)), - ("_mock_ap_z", (15.8,)), - ] - - compare_actual_and_expected_calls(actual_calls, expected_calls) + call_logger.assert_has_calls( + [ + call._mock_sg_x(5.3375), + call._mock_sg_y(-3.55), + call._mock_ap_x(2.43), + call._mock_ap_y(48.974), + call._mock_ap_z(15.8), + ] + ) def test_aperture_scatterguard_select_top_moves_assembly_down_then_sg_up( @@ -112,17 +110,15 @@ def test_aperture_scatterguard_select_top_moves_assembly_down_then_sg_up( aperture_scatterguard.set(aperture_positions.LARGE) - actual_calls = call_logger.mock_calls - expected_calls = [ - ("_mock_ap_x", (2.389,)), - ("_mock_ap_y", (40.986,)), - ("_mock_ap_z", (15.8,)), - lambda call: call[0].endswith("__and__().wait"), - ("_mock_sg_x", (5.25,)), - ("_mock_sg_y", (4.43,)), - ] - - compare_actual_and_expected_calls(actual_calls, expected_calls) + call_logger.assert_has_calls( + [ + call._mock_ap_x(2.389), + call._mock_ap_y(40.986), + call._mock_ap_z(15.8), + call._mock_sg_x(5.25), + call._mock_sg_y(4.43), + ] + ) def test_aperture_scatterguard_throws_error_if_outside_tolerance( @@ -211,39 +207,14 @@ def test_when_aperture_set_and_device_read_then_position_returned( def install_logger_for_aperture_and_scatterguard(aperture_scatterguard): parent_mock = MagicMock() - mock_ap_x = MagicMock(aperture_scatterguard.aperture.x.set) - mock_ap_y = MagicMock(aperture_scatterguard.aperture.y.set) - mock_ap_z = MagicMock(aperture_scatterguard.aperture.z.set) - mock_sg_x = MagicMock(aperture_scatterguard.scatterguard.x.set) - mock_sg_y = MagicMock(aperture_scatterguard.scatterguard.y.set) - aperture_scatterguard.aperture.x.set = mock_ap_x - aperture_scatterguard.aperture.y.set = mock_ap_y - aperture_scatterguard.aperture.z.set = mock_ap_z - aperture_scatterguard.scatterguard.x.set = mock_sg_x - aperture_scatterguard.scatterguard.y.set = mock_sg_y + mock_ap_x = aperture_scatterguard.aperture.x.set + mock_ap_y = aperture_scatterguard.aperture.y.set + mock_ap_z = aperture_scatterguard.aperture.z.set + mock_sg_x = aperture_scatterguard.scatterguard.x.set + mock_sg_y = aperture_scatterguard.scatterguard.y.set parent_mock.attach_mock(mock_ap_x, "_mock_ap_x") parent_mock.attach_mock(mock_ap_y, "_mock_ap_y") parent_mock.attach_mock(mock_ap_z, "_mock_ap_z") parent_mock.attach_mock(mock_sg_x, "_mock_sg_x") parent_mock.attach_mock(mock_sg_y, "_mock_sg_y") return parent_mock - - -def compare_actual_and_expected_calls(actual_calls, expected_calls): - # ideally, we could use MagicMock.assert_has_calls but a) it doesn't work properly and b) doesn't do what I need - i_actual = 0 - for i, expected in enumerate(expected_calls): - if isinstance(expected, tuple): - # simple comparison - i_actual = actual_calls.index(expected, i_actual) - else: - # expected is a predicate to be satisfied - i_matches = [ - i for i, call in enumerate(actual_calls[i_actual:]) if expected(call) - ] - if i_matches: - i_actual = i_matches[0] - else: - raise ValueError("Couldn't find call matching predicate") - - i_actual += 1 From 15a2a2b6b7c61a47904fdf175ac9dcd5ba74cdc7 Mon Sep 17 00:00:00 2001 From: David Perl Date: Thu, 7 Mar 2024 16:48:41 +0000 Subject: [PATCH 075/134] #170 update DetectorParams for pydantic 1&2 --- src/dodal/devices/detector/detector.py | 39 +++++++++----------------- 1 file changed, 13 insertions(+), 26 deletions(-) diff --git a/src/dodal/devices/detector/detector.py b/src/dodal/devices/detector/detector.py index 5d970a6823..be407b909e 100644 --- a/src/dodal/devices/detector/detector.py +++ b/src/dodal/devices/detector/detector.py @@ -1,7 +1,7 @@ from enum import Enum, auto from typing import Any, Optional, Tuple -from pydantic import BaseModel, validator +from pydantic import BaseModel, root_validator, validator from dodal.devices.detector.det_dim_constants import ( EIGER2_X_16M_SIZE, @@ -28,7 +28,7 @@ class DetectorParams(BaseModel): """Holds parameters for the detector. Provides access to a list of Dectris detector sizes and a converter for distance to beam centre.""" - expected_energy_ev: Optional[float] + expected_energy_ev: Optional[float] = None exposure_time: float directory: str prefix: str @@ -41,8 +41,8 @@ class DetectorParams(BaseModel): det_dist_to_beam_converter_path: str trigger_mode: TriggerMode = TriggerMode.SET_FRAMES detector_size_constants: DetectorSizeConstants = EIGER2_X_16M_SIZE - beam_xy_converter: DetectorDistanceToBeamXYConverter = None - run_number: Optional[int] = None + beam_xy_converter: DetectorDistanceToBeamXYConverter + run_number: int class Config: arbitrary_types_allowed = True @@ -51,6 +51,15 @@ class Config: DetectorSizeConstants: lambda d: d.det_type_string, } + @root_validator(pre=True, skip_on_failure=True) # type: ignore # should be replaced with model_validator once move to pydantic 2 is complete + def create_beamxy_and_runnumber(cls, values: dict[str, Any]) -> dict[str, Any]: + values["beam_xy_converter"] = DetectorDistanceToBeamXYConverter( + values["det_dist_to_beam_converter_path"] + ) + if values.get("run_number") is None: + values["run_number"] = get_run_number(values["directory"]) + return values + @validator("detector_size_constants", pre=True) def _parse_detector_size_constants( cls, det_type: str, values: dict[str, Any] @@ -63,28 +72,6 @@ def _parse_directory(cls, directory: str, values: dict[str, Any]) -> str: directory += "/" return directory - @validator("beam_xy_converter", always=True) - def _parse_beam_xy_converter( - cls, - beam_xy_converter: DetectorDistanceToBeamXYConverter, - values: dict[str, Any], - ) -> DetectorDistanceToBeamXYConverter: - return DetectorDistanceToBeamXYConverter( - values["det_dist_to_beam_converter_path"] - ) - - @validator("run_number", always=True) - def _set_run_number(cls, run_number: int, values: dict[str, Any]): - if run_number is None: - return get_run_number(values["directory"]) - else: - return run_number - - def __post_init__(self): - self.beam_xy_converter = DetectorDistanceToBeamXYConverter( - self.det_dist_to_beam_converter_path - ) - def get_beam_position_mm(self, detector_distance: float) -> Tuple[float, float]: x_beam_mm = self.beam_xy_converter.get_beam_xy_from_det_dist( detector_distance, Axis.X_AXIS From ad83cdb230e3180b52dfca29b92a908561cfb3a7 Mon Sep 17 00:00:00 2001 From: David Perl Date: Thu, 7 Mar 2024 16:48:46 +0000 Subject: [PATCH 076/134] #170 unpin pydantic --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9b69eebdc6..31d87bd4cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ dependencies = [ "zocalo", "requests", "graypy", - "pydantic<2.0", + "pydantic", "opencv-python-headless", # For pin-tip detection. "aioca", # Required for CA support with ophyd-async. "p4p", # Required for PVA support with ophyd-async. From c9f6a89eea84cb2a01d0b1d0f28805acc3c3f9a4 Mon Sep 17 00:00:00 2001 From: Dominic Oram Date: Thu, 7 Mar 2024 18:15:42 +0000 Subject: [PATCH 077/134] (DiamondLightSource/hyperion#1235) Create an extended epics motor containing more fields --- src/dodal/devices/aperture.py | 8 ++++---- src/dodal/devices/scatterguard.py | 6 +++--- src/dodal/devices/smargon.py | 6 +++--- src/dodal/devices/util/motor_utils.py | 3 ++- tests/devices/unit_tests/conftest.py | 6 +++--- 5 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/dodal/devices/aperture.py b/src/dodal/devices/aperture.py index 62ba0c772a..d66173f5cd 100644 --- a/src/dodal/devices/aperture.py +++ b/src/dodal/devices/aperture.py @@ -1,9 +1,9 @@ from ophyd import Component, Device -from dodal.devices.util.motor_utils import EpicsMotorWithMRES +from dodal.devices.util.motor_utils import ExtendedEpicsMotor class Aperture(Device): - x = Component(EpicsMotorWithMRES, "X") - y = Component(EpicsMotorWithMRES, "Y") - z = Component(EpicsMotorWithMRES, "Z") + x = Component(ExtendedEpicsMotor, "X") + y = Component(ExtendedEpicsMotor, "Y") + z = Component(ExtendedEpicsMotor, "Z") diff --git a/src/dodal/devices/scatterguard.py b/src/dodal/devices/scatterguard.py index eb05052166..b29b148bd7 100644 --- a/src/dodal/devices/scatterguard.py +++ b/src/dodal/devices/scatterguard.py @@ -1,9 +1,9 @@ from ophyd import Component as Cpt from ophyd import Device -from dodal.devices.util.motor_utils import EpicsMotorWithMRES +from dodal.devices.util.motor_utils import ExtendedEpicsMotor class Scatterguard(Device): - x = Cpt(EpicsMotorWithMRES, "X") - y = Cpt(EpicsMotorWithMRES, "Y") + x = Cpt(ExtendedEpicsMotor, "X") + y = Cpt(ExtendedEpicsMotor, "Y") diff --git a/src/dodal/devices/smargon.py b/src/dodal/devices/smargon.py index 9a27bde268..1f61e49e7f 100644 --- a/src/dodal/devices/smargon.py +++ b/src/dodal/devices/smargon.py @@ -1,13 +1,14 @@ from enum import Enum from ophyd import Component as Cpt -from ophyd import Device, EpicsMotor, EpicsSignal, EpicsSignalRO +from ophyd import Device, EpicsMotor, EpicsSignal from ophyd.epics_motor import MotorBundle from ophyd.status import StatusBase from dodal.devices.motors import MotorLimitHelper, XYZLimitBundle from dodal.devices.status import await_approx_value from dodal.devices.util.epics_util import SetWhenEnabled +from dodal.devices.util.motor_utils import ExtendedEpicsMotor class StubPosition(Enum): @@ -48,8 +49,7 @@ class Smargon(MotorBundle): Robot loading can nudge these and lead to errors. """ - x = Cpt(EpicsMotor, "X") - x_speed_limit_mm_per_s = Cpt(EpicsSignalRO, "X.VMAX") + x = Cpt(ExtendedEpicsMotor, "X") y = Cpt(EpicsMotor, "Y") z = Cpt(EpicsMotor, "Z") chi = Cpt(EpicsMotor, "CHI") diff --git a/src/dodal/devices/util/motor_utils.py b/src/dodal/devices/util/motor_utils.py index 591cd66b63..07638ba3f6 100644 --- a/src/dodal/devices/util/motor_utils.py +++ b/src/dodal/devices/util/motor_utils.py @@ -1,5 +1,6 @@ from ophyd import Component, EpicsMotor, EpicsSignalRO -class EpicsMotorWithMRES(EpicsMotor): +class ExtendedEpicsMotor(EpicsMotor): motor_resolution: Component[EpicsSignalRO] = Component(EpicsSignalRO, ".MRES") + max_velocity: Component[EpicsSignalRO] = Component(EpicsSignalRO, ".VMAX") diff --git a/tests/devices/unit_tests/conftest.py b/tests/devices/unit_tests/conftest.py index 5b872623c8..6dcbe07a0b 100644 --- a/tests/devices/unit_tests/conftest.py +++ b/tests/devices/unit_tests/conftest.py @@ -4,7 +4,7 @@ from ophyd.epics_motor import EpicsMotor from ophyd.status import Status -from dodal.devices.util.motor_utils import EpicsMotorWithMRES +from dodal.devices.util.motor_utils import ExtendedEpicsMotor def mock_set(motor: EpicsMotor, val): @@ -13,11 +13,11 @@ def mock_set(motor: EpicsMotor, val): return Status(done=True, success=True) -def patch_motor(motor: EpicsMotor | EpicsMotorWithMRES, initial_position=0): +def patch_motor(motor: EpicsMotor | ExtendedEpicsMotor, initial_position=0): motor.user_setpoint.sim_put(initial_position) # type: ignore motor.user_readback.sim_put(initial_position) # type: ignore motor.motor_done_move.sim_put(1) # type: ignore motor.user_setpoint._use_limits = False - if isinstance(motor, EpicsMotorWithMRES): + if isinstance(motor, ExtendedEpicsMotor): motor.motor_resolution.sim_put(0.001) # type: ignore return patch.object(motor, "set", MagicMock(side_effect=partial(mock_set, motor))) From ca9d6df8f17f88ce0128af9cbdfd40d0cfc44a26 Mon Sep 17 00:00:00 2001 From: Dominic Oram Date: Thu, 7 Mar 2024 18:21:38 +0000 Subject: [PATCH 078/134] (DiamondLightSource/hyperion#1235) Fix typing for 3.9 --- tests/devices/unit_tests/conftest.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/devices/unit_tests/conftest.py b/tests/devices/unit_tests/conftest.py index 6dcbe07a0b..f27893fbed 100644 --- a/tests/devices/unit_tests/conftest.py +++ b/tests/devices/unit_tests/conftest.py @@ -1,4 +1,5 @@ from functools import partial +from typing import Union from unittest.mock import MagicMock, patch from ophyd.epics_motor import EpicsMotor @@ -13,7 +14,7 @@ def mock_set(motor: EpicsMotor, val): return Status(done=True, success=True) -def patch_motor(motor: EpicsMotor | ExtendedEpicsMotor, initial_position=0): +def patch_motor(motor: Union[EpicsMotor, ExtendedEpicsMotor], initial_position=0): motor.user_setpoint.sim_put(initial_position) # type: ignore motor.user_readback.sim_put(initial_position) # type: ignore motor.motor_done_move.sim_put(1) # type: ignore From aa8711865dbbdef3b164ff34debb1e3c0135fecc Mon Sep 17 00:00:00 2001 From: Dominic Oram Date: Thu, 7 Mar 2024 18:54:23 +0000 Subject: [PATCH 079/134] (DiamondLightSource/hyperion#1234) Add load to bart robot --- src/dodal/devices/robot.py | 44 +++++++++++++++++++-- src/dodal/devices/util/epics_util.py | 7 ++++ tests/devices/unit_tests/test_bart_robot.py | 27 ++++++++++++- 3 files changed, 73 insertions(+), 5 deletions(-) diff --git a/src/dodal/devices/robot.py b/src/dodal/devices/robot.py index a30c448895..92cd5c26b4 100644 --- a/src/dodal/devices/robot.py +++ b/src/dodal/devices/robot.py @@ -1,9 +1,14 @@ +import asyncio from collections import OrderedDict +from dataclasses import dataclass from typing import Dict, Sequence -from bluesky.protocols import Descriptor, Reading -from ophyd_async.core import StandardReadable -from ophyd_async.epics.signal import epics_signal_r +from bluesky.protocols import Descriptor, Movable, Reading +from ophyd_async.core import AsyncStatus, StandardReadable, observe_value +from ophyd_async.epics.signal import epics_signal_r, epics_signal_x + +from dodal.devices.util.epics_util import epics_signal_rw_rbv +from dodal.log import LOGGER class SingleIndexWaveformReadable(StandardReadable): @@ -44,9 +49,17 @@ async def describe(self) -> dict[str, Descriptor]: return desc -class BartRobot(StandardReadable): +@dataclass +class SampleLocation: + puck: int + pin: int + + +class BartRobot(StandardReadable, Movable): """The sample changing robot.""" + LOAD_TIMEOUT = 60 + def __init__( self, name: str, @@ -54,4 +67,27 @@ def __init__( ) -> None: self.barcode = SingleIndexWaveformReadable(prefix + "BARCODE") self.gonio_pin_sensor = epics_signal_r(bool, prefix + "PIN_MOUNTED") + self.next_pin = epics_signal_rw_rbv(int, prefix + "NEXT_PIN") + self.next_puck = epics_signal_rw_rbv(int, prefix + "NEXT_PUCK") + self.load = epics_signal_x(prefix + "LOAD.PROC") + self.program_running = epics_signal_r(bool, prefix + "PROGRAM_RUNNING") super().__init__(name=name) + + async def _load_pin_and_puck(self, sample_location: SampleLocation): + LOGGER.info(f"Loading pin {sample_location}") + async for value in observe_value(self.program_running): + if not value: + break + LOGGER.info("Waiting on robot program to finish") + await asyncio.gather( + self.next_puck.set(sample_location.puck), + self.next_pin.set(sample_location.pin), + ) + await self.load.trigger() + + def set(self, sample_location: SampleLocation) -> AsyncStatus: + return AsyncStatus( + asyncio.wait_for( + self._load_pin_and_puck(sample_location), timeout=self.LOAD_TIMEOUT + ) + ) diff --git a/src/dodal/devices/util/epics_util.py b/src/dodal/devices/util/epics_util.py index 80525a8d2e..0f71e62025 100644 --- a/src/dodal/devices/util/epics_util.py +++ b/src/dodal/devices/util/epics_util.py @@ -3,6 +3,7 @@ from ophyd import Component, Device, EpicsSignal from ophyd.status import Status, StatusBase +from ophyd_async.epics.signal import epics_signal_rw from dodal.devices.status import await_value from dodal.log import LOGGER @@ -125,3 +126,9 @@ def set(self, proc: int) -> Status: lambda: self.proc.set(proc), ] ) + + +def epics_signal_rw_rbv( + T, write_pv: str +): # Remove when https://github.com/bluesky/ophyd-async/issues/139 is done + return epics_signal_rw(T, write_pv + "_RBV", write_pv) diff --git a/tests/devices/unit_tests/test_bart_robot.py b/tests/devices/unit_tests/test_bart_robot.py index 30ee449f9c..710102ca11 100644 --- a/tests/devices/unit_tests/test_bart_robot.py +++ b/tests/devices/unit_tests/test_bart_robot.py @@ -1,11 +1,15 @@ +from asyncio import TimeoutError +from unittest.mock import AsyncMock + import pytest from ophyd_async.core import set_sim_value -from dodal.devices.robot import BartRobot +from dodal.devices.robot import BartRobot, SampleLocation async def _get_bart_robot() -> BartRobot: device = BartRobot("robot", "-MO-ROBOT-01:") + device.LOAD_TIMEOUT = 0.01 # type: ignore await device.connect(sim=True) return device @@ -22,3 +26,24 @@ async def test_when_barcode_updates_then_new_barcode_read(): expected_barcode = "expected" set_sim_value(device.barcode.bare_signal, [expected_barcode, "other_barcode"]) assert (await device.barcode.read())["robot-barcode"]["value"] == expected_barcode + + +@pytest.mark.asyncio +async def test_given_program_running_when_load_pin_then_times_out(): + device = await _get_bart_robot() + set_sim_value(device.program_running, True) + with pytest.raises(TimeoutError): + await device.set(SampleLocation(0, 0)) + + +@pytest.mark.asyncio +async def test_given_program_not_running_when_load_pin_then_pin_loaded(): + device = await _get_bart_robot() + set_sim_value(device.program_running, False) + device.load = AsyncMock(side_effect=device.load) + status = device.set(SampleLocation(15, 10)) + await status + assert status.success + assert (await device.next_puck.get_value()) == 15 + assert (await device.next_pin.get_value()) == 10 + device.load.trigger.assert_called_once() # type:ignore From 42ac7e00f125609de5e1309045497aa2885d19ef Mon Sep 17 00:00:00 2001 From: Dominic Oram Date: Thu, 7 Mar 2024 18:59:26 +0000 Subject: [PATCH 080/134] (DiamondLightSource/hyperion#1234) Fix types --- src/dodal/devices/robot.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/dodal/devices/robot.py b/src/dodal/devices/robot.py index 92cd5c26b4..4edb5935a0 100644 --- a/src/dodal/devices/robot.py +++ b/src/dodal/devices/robot.py @@ -67,8 +67,8 @@ def __init__( ) -> None: self.barcode = SingleIndexWaveformReadable(prefix + "BARCODE") self.gonio_pin_sensor = epics_signal_r(bool, prefix + "PIN_MOUNTED") - self.next_pin = epics_signal_rw_rbv(int, prefix + "NEXT_PIN") - self.next_puck = epics_signal_rw_rbv(int, prefix + "NEXT_PUCK") + self.next_pin = epics_signal_rw_rbv(float, prefix + "NEXT_PIN") + self.next_puck = epics_signal_rw_rbv(float, prefix + "NEXT_PUCK") self.load = epics_signal_x(prefix + "LOAD.PROC") self.program_running = epics_signal_r(bool, prefix + "PROGRAM_RUNNING") super().__init__(name=name) From 99305780a983a58774fa850d2b9405705df4d8f9 Mon Sep 17 00:00:00 2001 From: David Perl Date: Fri, 8 Mar 2024 08:55:59 +0000 Subject: [PATCH 081/134] hyperion #685 use ExtendedEpicsMotor for smargon omega --- src/dodal/devices/smargon.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dodal/devices/smargon.py b/src/dodal/devices/smargon.py index 1f61e49e7f..c4ea5c421e 100644 --- a/src/dodal/devices/smargon.py +++ b/src/dodal/devices/smargon.py @@ -54,7 +54,7 @@ class Smargon(MotorBundle): z = Cpt(EpicsMotor, "Z") chi = Cpt(EpicsMotor, "CHI") phi = Cpt(EpicsMotor, "PHI") - omega = Cpt(EpicsMotor, "OMEGA") + omega = Cpt(ExtendedEpicsMotor, "OMEGA") real_x1 = Cpt(EpicsMotor, "MOTOR_3") real_x2 = Cpt(EpicsMotor, "MOTOR_4") From 3f9e61834b9b2e15a1b33d3bd386aea79c2ee439 Mon Sep 17 00:00:00 2001 From: Joseph Ware Date: Fri, 8 Mar 2024 13:40:28 +0000 Subject: [PATCH 082/134] Remove use of Annotated - get_type_hints(function) strips annotated metadata, and prevents checking for equality --- src/dodal/common/types.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/dodal/common/types.py b/src/dodal/common/types.py index d721a890ab..1eeedcfa59 100644 --- a/src/dodal/common/types.py +++ b/src/dodal/common/types.py @@ -1,5 +1,4 @@ from typing import ( - Annotated, Any, Callable, Generator, @@ -7,11 +6,9 @@ from bluesky.utils import Msg -Group = Annotated[str, "String identifier used by 'wait' or stubs that await"] -MsgGenerator = Annotated[ - Generator[Msg, Any, None], - "A true 'plan', usually the output of a generator function", -] -PlanGenerator = Annotated[ - Callable[..., MsgGenerator], "A function that generates a plan" -] +# String identifier used by 'wait' or stubs that await +Group = str +# A true 'plan', usually the output of a generator function +MsgGenerator = Generator[Msg, Any, None] +# A function that generates a plan +PlanGenerator = Callable[..., MsgGenerator] From ed383d9528bd677ea001453a6a2da9a4c8db7dcc Mon Sep 17 00:00:00 2001 From: Dominic Oram Date: Fri, 8 Mar 2024 13:30:32 +0000 Subject: [PATCH 083/134] (#368) Reduce debug log amount --- src/dodal/log.py | 11 ++++++++--- tests/unit_tests/test_log.py | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/dodal/log.py b/src/dodal/log.py index c59c735738..7484875095 100644 --- a/src/dodal/log.py +++ b/src/dodal/log.py @@ -18,8 +18,9 @@ DEFAULT_FORMATTER = logging.Formatter( "[%(asctime)s] %(name)s %(module)s %(levelname)s: %(message)s" ) -ERROR_LOG_BUFFER_LINES = 200000 +ERROR_LOG_BUFFER_LINES = 20000 INFO_LOG_DAYS = 30 +DEBUG_LOG_DAYS = 7 class CircularMemoryHandler(logging.Handler): @@ -131,10 +132,14 @@ def set_up_DEBUG_memory_handler( print(f"Logging to {path/filename}") debug_path = path / "debug" debug_path.mkdir(parents=True, exist_ok=True) - file_handler = TimedRotatingFileHandler(filename=debug_path / filename, when="H") + file_handler = TimedRotatingFileHandler( + filename=debug_path / filename, when="H", backupCount=DEBUG_LOG_DAYS + ) file_handler.setLevel(logging.DEBUG) memory_handler = CircularMemoryHandler( - capacity=capacity, flushLevel=logging.ERROR, target=file_handler + capacity=capacity, + flushLevel=logging.ERROR, + target=file_handler, ) memory_handler.setLevel(logging.DEBUG) memory_handler.addFilter(beamline_filter) diff --git a/tests/unit_tests/test_log.py b/tests/unit_tests/test_log.py index 410618f605..d0ed3205f6 100644 --- a/tests/unit_tests/test_log.py +++ b/tests/unit_tests/test_log.py @@ -91,7 +91,7 @@ def test_no_env_variable_sets_correct_file_handler( expected_calls = [ call(filename=PosixPath("tmp/dev/dodal.log"), when="MIDNIGHT", backupCount=30), - call(PosixPath("tmp/dev/debug/dodal.log"), when="H"), + call(PosixPath("tmp/dev/debug/dodal.log"), when="H", backupCount=7), ] mock_file_handler.assert_has_calls(expected_calls, any_order=True) From 5bd571f10039241838989556ab1cc32a391fbe80 Mon Sep 17 00:00:00 2001 From: Dominic Oram Date: Fri, 8 Mar 2024 13:51:01 +0000 Subject: [PATCH 084/134] (#368) Be clearer with constant name --- src/dodal/log.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/dodal/log.py b/src/dodal/log.py index 7484875095..ad7083181c 100644 --- a/src/dodal/log.py +++ b/src/dodal/log.py @@ -20,7 +20,7 @@ ) ERROR_LOG_BUFFER_LINES = 20000 INFO_LOG_DAYS = 30 -DEBUG_LOG_DAYS = 7 +DEBUG_LOG_FILES_TO_KEEP = 7 class CircularMemoryHandler(logging.Handler): @@ -133,7 +133,7 @@ def set_up_DEBUG_memory_handler( debug_path = path / "debug" debug_path.mkdir(parents=True, exist_ok=True) file_handler = TimedRotatingFileHandler( - filename=debug_path / filename, when="H", backupCount=DEBUG_LOG_DAYS + filename=debug_path / filename, when="H", backupCount=DEBUG_LOG_FILES_TO_KEEP ) file_handler.setLevel(logging.DEBUG) memory_handler = CircularMemoryHandler( From 97e3cdc11b1b5092c7f12ab6bc5ea1d702401b68 Mon Sep 17 00:00:00 2001 From: David Perl Date: Fri, 8 Mar 2024 16:30:10 +0000 Subject: [PATCH 085/134] add raw set to aperturescatterguard --- src/dodal/devices/aperturescatterguard.py | 28 ++++++++++++++++------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/dodal/devices/aperturescatterguard.py b/src/dodal/devices/aperturescatterguard.py index f920dd8008..bd41dc4393 100644 --- a/src/dodal/devices/aperturescatterguard.py +++ b/src/dodal/devices/aperturescatterguard.py @@ -1,10 +1,13 @@ +import operator from collections import namedtuple from dataclasses import dataclass -from typing import List, Optional +from functools import reduce +from typing import List, Optional, Sequence import numpy as np from ophyd import Component as Cpt from ophyd import SignalRO +from ophyd.epics_motor import EpicsMotor from ophyd.status import AndStatus, Status, StatusBase from dodal.devices.aperture import Aperture @@ -105,6 +108,21 @@ def set(self, pos: SingleAperturePosition) -> StatusBase: return self._safe_move_within_datacollection_range(pos.location) + def _get_motor_list(self): + return [ + self.aperture.x, + self.aperture.y, + self.aperture.z, + self.scatterguard.x, + self.scatterguard.y, + ] + + def _set_raw_unsafe(self, positions: ApertureFiveDimensionalLocation) -> AndStatus: + motors: Sequence[EpicsMotor] = self._get_motor_list() + return reduce( + operator.and_, [motor.set(pos) for motor, pos in zip(motors, positions)] + ) + def _get_closest_position_to_current(self) -> SingleAperturePosition: """ Returns the closest valid position to current position within {TOLERANCE_STEPS}. @@ -113,13 +131,7 @@ def _get_closest_position_to_current(self) -> SingleAperturePosition: assert isinstance(self.aperture_positions, AperturePositions) for aperture in self.aperture_positions.as_list(): aperture_in_tolerence = [] - motors = [ - self.aperture.x, - self.aperture.y, - self.aperture.z, - self.scatterguard.x, - self.scatterguard.y, - ] + motors = self._get_motor_list() for motor, test_position in zip(motors, list(aperture.location)): current_position = motor.user_readback.get() tolerance = self.TOLERANCE_STEPS * motor.motor_resolution.get() From 3c66a51823c6d4dacb8b021d92d08ab4de40bdb0 Mon Sep 17 00:00:00 2001 From: David Perl Date: Fri, 8 Mar 2024 16:41:07 +0000 Subject: [PATCH 086/134] add test --- .../unit_tests/test_aperture_scatterguard.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/devices/unit_tests/test_aperture_scatterguard.py b/tests/devices/unit_tests/test_aperture_scatterguard.py index 46b86a3fe0..76f1010b30 100644 --- a/tests/devices/unit_tests/test_aperture_scatterguard.py +++ b/tests/devices/unit_tests/test_aperture_scatterguard.py @@ -102,6 +102,26 @@ def test_aperture_scatterguard_select_bottom_moves_sg_down_then_assembly_up( ) +def test_aperture_unsafe_move( + aperture_positions: AperturePositions, + aperture_in_medium_pos: ApertureScatterguard, +): + (a, b, c, d, e) = (0.2, 3.4, 5.6, 7.8, 9.0) + aperture_scatterguard = aperture_in_medium_pos + call_logger = install_logger_for_aperture_and_scatterguard(aperture_scatterguard) + aperture_scatterguard._set_raw_unsafe((a, b, c, d, e)) # type: ignore + + call_logger.assert_has_calls( + [ + call._mock_ap_x(a), + call._mock_ap_y(b), + call._mock_ap_z(c), + call._mock_sg_x(d), + call._mock_sg_y(e), + ] + ) + + def test_aperture_scatterguard_select_top_moves_assembly_down_then_sg_up( aperture_positions: AperturePositions, aperture_in_medium_pos: ApertureScatterguard ): From 2194a9d8add0b63077878119d8e4baeacfd4e5a5 Mon Sep 17 00:00:00 2001 From: Dominic Oram Date: Mon, 11 Mar 2024 17:29:36 +0000 Subject: [PATCH 087/134] (DiamondLightSource/hyperion#1234) Wait for pin to be mounted --- src/dodal/devices/robot.py | 4 ++++ tests/devices/unit_tests/test_bart_robot.py | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/dodal/devices/robot.py b/src/dodal/devices/robot.py index 4edb5935a0..fedc85761d 100644 --- a/src/dodal/devices/robot.py +++ b/src/dodal/devices/robot.py @@ -84,6 +84,10 @@ async def _load_pin_and_puck(self, sample_location: SampleLocation): self.next_pin.set(sample_location.pin), ) await self.load.trigger() + async for value in observe_value(self.gonio_pin_sensor): + if value: + break + LOGGER.info("Waiting on pin mounted") def set(self, sample_location: SampleLocation) -> AsyncStatus: return AsyncStatus( diff --git a/tests/devices/unit_tests/test_bart_robot.py b/tests/devices/unit_tests/test_bart_robot.py index 710102ca11..350c1eee3a 100644 --- a/tests/devices/unit_tests/test_bart_robot.py +++ b/tests/devices/unit_tests/test_bart_robot.py @@ -40,6 +40,7 @@ async def test_given_program_running_when_load_pin_then_times_out(): async def test_given_program_not_running_when_load_pin_then_pin_loaded(): device = await _get_bart_robot() set_sim_value(device.program_running, False) + set_sim_value(device.gonio_pin_sensor, True) device.load = AsyncMock(side_effect=device.load) status = device.set(SampleLocation(15, 10)) await status @@ -47,3 +48,14 @@ async def test_given_program_not_running_when_load_pin_then_pin_loaded(): assert (await device.next_puck.get_value()) == 15 assert (await device.next_pin.get_value()) == 10 device.load.trigger.assert_called_once() # type:ignore + + +@pytest.mark.asyncio +async def test_given_program_not_running_but_pin_not_mounting_when_load_pin_then_timeout(): + device = await _get_bart_robot() + set_sim_value(device.program_running, False) + set_sim_value(device.gonio_pin_sensor, False) + device.load = AsyncMock(side_effect=device.load) + with pytest.raises(TimeoutError): + await device.set(SampleLocation(15, 10)) + device.load.trigger.assert_called_once() # type:ignore From 71a7bf6e5d9ccb046a2ad3b4011e59c7cb65cde5 Mon Sep 17 00:00:00 2001 From: Dominic Oram Date: Mon, 11 Mar 2024 17:39:39 +0000 Subject: [PATCH 088/134] (DiamondLightSource/hyperion#1234) Add helper function for waiting for signals --- src/dodal/devices/robot.py | 18 ++++++++---------- src/dodal/devices/util/epics_util.py | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/src/dodal/devices/robot.py b/src/dodal/devices/robot.py index fedc85761d..c62445c258 100644 --- a/src/dodal/devices/robot.py +++ b/src/dodal/devices/robot.py @@ -4,10 +4,10 @@ from typing import Dict, Sequence from bluesky.protocols import Descriptor, Movable, Reading -from ophyd_async.core import AsyncStatus, StandardReadable, observe_value +from ophyd_async.core import AsyncStatus, StandardReadable from ophyd_async.epics.signal import epics_signal_r, epics_signal_x -from dodal.devices.util.epics_util import epics_signal_rw_rbv +from dodal.devices.util.epics_util import epics_signal_rw_rbv, signal_meets_predicate from dodal.log import LOGGER @@ -75,19 +75,17 @@ def __init__( async def _load_pin_and_puck(self, sample_location: SampleLocation): LOGGER.info(f"Loading pin {sample_location}") - async for value in observe_value(self.program_running): - if not value: - break - LOGGER.info("Waiting on robot program to finish") + await signal_meets_predicate( + self.program_running, lambda v: not v, "Waiting on robot program to finish" + ) await asyncio.gather( self.next_puck.set(sample_location.puck), self.next_pin.set(sample_location.pin), ) await self.load.trigger() - async for value in observe_value(self.gonio_pin_sensor): - if value: - break - LOGGER.info("Waiting on pin mounted") + await signal_meets_predicate( + self.gonio_pin_sensor, lambda v: v, "Waiting on pin mounted" + ) def set(self, sample_location: SampleLocation) -> AsyncStatus: return AsyncStatus( diff --git a/src/dodal/devices/util/epics_util.py b/src/dodal/devices/util/epics_util.py index 0f71e62025..9e9c56ff63 100644 --- a/src/dodal/devices/util/epics_util.py +++ b/src/dodal/devices/util/epics_util.py @@ -3,7 +3,9 @@ from ophyd import Component, Device, EpicsSignal from ophyd.status import Status, StatusBase +from ophyd_async.core import observe_value from ophyd_async.epics.signal import epics_signal_rw +from ophyd_async.epics.signal.signal import SignalR from dodal.devices.status import await_value from dodal.log import LOGGER @@ -132,3 +134,16 @@ def epics_signal_rw_rbv( T, write_pv: str ): # Remove when https://github.com/bluesky/ophyd-async/issues/139 is done return epics_signal_rw(T, write_pv + "_RBV", write_pv) + + +async def signal_meets_predicate( + signal: SignalR, predicate: Callable, message: Optional[str] = None +): + """Takes a signal and passes any updates it gets to {predicate}, will wait until the + return value of {predicate} is True. Optionally prints {message} when waiting. + """ + async for value in observe_value(signal): + if predicate(value): + break + if message: + LOGGER.info(message) From 4c676df754f513f3d88372a7dfdbf0cc93176687 Mon Sep 17 00:00:00 2001 From: "Ware, Joseph (DLSLtd,RAL,LSCI)" Date: Tue, 12 Mar 2024 13:39:38 +0000 Subject: [PATCH 089/134] #375 Use asyncio_mode=auto in tests - Makes writing asyncio tests simpler, removing decorator and explicit async fixtures --- pyproject.toml | 1 + tests/devices/system_tests/test_zocalo_results.py | 6 +----- .../oav/image_recognition/test_pin_tip_detect.py | 10 ---------- tests/devices/unit_tests/test_bart_robot.py | 3 --- tests/devices/unit_tests/test_zocalo_results.py | 9 +-------- 5 files changed, 3 insertions(+), 26 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9b69eebdc6..ef7b6eae2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,6 +79,7 @@ ignore_missing_imports = true # Ignore missing stubs in imported modules [tool.pytest.ini_options] # Run pytest with all our checkers, and don't spam us with massive tracebacks on error +asyncio_mode = "auto" markers = [ "s03: marks tests as requiring the s03 simulator running (deselect with '-m \"not s03\"')", ] diff --git a/tests/devices/system_tests/test_zocalo_results.py b/tests/devices/system_tests/test_zocalo_results.py index 3b40d20980..4bbd0f994f 100644 --- a/tests/devices/system_tests/test_zocalo_results.py +++ b/tests/devices/system_tests/test_zocalo_results.py @@ -4,7 +4,6 @@ import bluesky.plan_stubs as bps import psutil import pytest -import pytest_asyncio from bluesky.preprocessors import stage_decorator from bluesky.run_engine import RunEngine from bluesky.utils import FailedStatus @@ -27,7 +26,7 @@ } -@pytest_asyncio.fixture +@pytest.fixture async def zocalo_device(): zd = ZocaloResults() await zd.connect() @@ -35,7 +34,6 @@ async def zocalo_device(): @pytest.mark.s03 -@pytest.mark.asyncio async def test_read_results_from_fake_zocalo(zocalo_device: ZocaloResults): zocalo_device._subscribe_to_results() zc = ZocaloTrigger("dev_artemis") @@ -56,7 +54,6 @@ def plan(): @pytest.mark.s03 -@pytest.mark.asyncio async def test_stage_unstage_controls_read_results_from_fake_zocalo( zocalo_device: ZocaloResults, ): @@ -104,7 +101,6 @@ def plan_with_stage(): @pytest.mark.s03 -@pytest.mark.asyncio async def test_stale_connections_closed_after_unstage( zocalo_device: ZocaloResults, ): diff --git a/tests/devices/unit_tests/oav/image_recognition/test_pin_tip_detect.py b/tests/devices/unit_tests/oav/image_recognition/test_pin_tip_detect.py index cc46169b1d..432d3b7ae2 100644 --- a/tests/devices/unit_tests/oav/image_recognition/test_pin_tip_detect.py +++ b/tests/devices/unit_tests/oav/image_recognition/test_pin_tip_detect.py @@ -2,7 +2,6 @@ from unittest.mock import MagicMock, patch import numpy as np -import pytest from ophyd_async.core import set_sim_value from dodal.devices.oav.pin_image_recognition import MxSampleDetect, PinTipDetection @@ -11,7 +10,6 @@ EVENT_LOOP = asyncio.new_event_loop() -pytest_plugins = ("pytest_asyncio",) DEVICE_NAME = "pin_tip_detection" TRIGGERED_TIP_READING = DEVICE_NAME + "-triggered_tip" TRIGGERED_TOP_EDGE_READING = DEVICE_NAME + "-triggered_top_edge" @@ -24,13 +22,11 @@ async def _get_pin_tip_detection_device() -> PinTipDetection: return device -@pytest.mark.asyncio async def test_pin_tip_detect_can_be_connected_in_sim_mode(): device = await _get_pin_tip_detection_device() await device.connect(sim=True) -@pytest.mark.asyncio async def test_soft_parameter_defaults_are_correct(): device = await _get_pin_tip_detection_device() @@ -46,7 +42,6 @@ async def test_soft_parameter_defaults_are_correct(): assert await device.preprocess_ksize.get_value() == 5 -@pytest.mark.asyncio async def test_numeric_soft_parameters_can_be_changed(): device = await _get_pin_tip_detection_device() @@ -73,7 +68,6 @@ async def test_numeric_soft_parameters_can_be_changed(): assert await device.preprocess_iterations.get_value() == 4 -@pytest.mark.asyncio async def test_invalid_processing_func_uses_identity_function(): device = await _get_pin_tip_detection_device() test_sample_location = SampleLocation(100, 200, np.array([]), np.array([])) @@ -95,7 +89,6 @@ async def test_invalid_processing_func_uses_identity_function(): assert arg == captured_func(arg) -@pytest.mark.asyncio async def test_given_valid_data_reading_then_used_to_find_location(): device = await _get_pin_tip_detection_device() image_array = np.array([1, 2, 3]) @@ -125,7 +118,6 @@ async def test_given_valid_data_reading_then_used_to_find_location(): assert location[TRIGGERED_TIP_READING]["timestamp"] > 0 -@pytest.mark.asyncio async def test_given_find_tip_fails_when_triggered_then_tip_invalid(): device = await _get_pin_tip_detection_device() await device.validity_timeout.set(0.1) @@ -142,7 +134,6 @@ async def test_given_find_tip_fails_when_triggered_then_tip_invalid(): assert len(reading[TRIGGERED_BOTTOM_EDGE_READING]["value"]) == 0 -@pytest.mark.asyncio @patch("dodal.devices.oav.pin_image_recognition.observe_value") async def test_given_find_tip_fails_twice_when_triggered_then_tip_invalid_and_tried_twice( mock_image_read, @@ -168,7 +159,6 @@ async def get_array_data(_): assert mock_process_array.call_count > 1 -@pytest.mark.asyncio @patch("dodal.devices.oav.pin_image_recognition.LOGGER.warn") @patch("dodal.devices.oav.pin_image_recognition.observe_value") async def test_given_tip_invalid_then_loop_keeps_retrying_until_valid( diff --git a/tests/devices/unit_tests/test_bart_robot.py b/tests/devices/unit_tests/test_bart_robot.py index 30ee449f9c..2c3426b654 100644 --- a/tests/devices/unit_tests/test_bart_robot.py +++ b/tests/devices/unit_tests/test_bart_robot.py @@ -1,4 +1,3 @@ -import pytest from ophyd_async.core import set_sim_value from dodal.devices.robot import BartRobot @@ -10,13 +9,11 @@ async def _get_bart_robot() -> BartRobot: return device -@pytest.mark.asyncio async def test_bart_robot_can_be_connected_in_sim_mode(): device = await _get_bart_robot() await device.connect(sim=True) -@pytest.mark.asyncio async def test_when_barcode_updates_then_new_barcode_read(): device = await _get_bart_robot() expected_barcode = "expected" diff --git a/tests/devices/unit_tests/test_zocalo_results.py b/tests/devices/unit_tests/test_zocalo_results.py index 281ab449f2..346c941b86 100644 --- a/tests/devices/unit_tests/test_zocalo_results.py +++ b/tests/devices/unit_tests/test_zocalo_results.py @@ -4,7 +4,6 @@ import bluesky.plan_stubs as bps import numpy as np import pytest -import pytest_asyncio from bluesky.run_engine import RunEngine from bluesky.utils import FailedStatus from ophyd_async.core.async_status import AsyncStatus @@ -81,7 +80,7 @@ @patch("dodal.devices.zocalo_results._get_zocalo_connection") -@pytest_asyncio.fixture +@pytest.fixture async def mocked_zocalo_device(RE): async def device(results, run_setup=False): zd = ZocaloResults(zocalo_environment="test_env") @@ -106,7 +105,6 @@ def plan(): return device -@pytest.mark.asyncio async def test_put_result_read_results( mocked_zocalo_device, RE, @@ -122,7 +120,6 @@ async def test_put_result_read_results( assert np.all(bboxes[0] == [2, 2, 1]) -@pytest.mark.asyncio async def test_rd_top_results( mocked_zocalo_device, RE, @@ -141,7 +138,6 @@ def test_plan(): RE(test_plan()) -@pytest.mark.asyncio async def test_trigger_and_wait_puts_results( mocked_zocalo_device, RE, @@ -159,7 +155,6 @@ def plan(): zocalo_device._put_results.assert_called() -@pytest.mark.asyncio async def test_extraction_plan(mocked_zocalo_device, RE) -> None: zocalo_device: ZocaloResults = await mocked_zocalo_device( TEST_RESULTS, run_setup=False @@ -176,7 +171,6 @@ def plan(): RE(plan()) -@pytest.mark.asyncio @patch( "dodal.devices.zocalo.zocalo_results.workflows.recipe.wrap_subscribe", autospec=True ) @@ -202,7 +196,6 @@ async def test_subscribe_only_on_called_stage( mock_wrap_subscribe.assert_called_once() -@pytest.mark.asyncio @patch("dodal.devices.zocalo.zocalo_results._get_zocalo_connection", autospec=True) async def test_when_exception_caused_by_zocalo_message_then_exception_propagated( mock_connection, From df3bee1798ba493780cb452536d8a2141705b2f5 Mon Sep 17 00:00:00 2001 From: Noemi Frisina Date: Wed, 13 Mar 2024 15:21:17 +0000 Subject: [PATCH 090/134] Use ophyd-async device for the zebra, probably breaks all tests --- src/dodal/devices/zebra.py | 177 ++++++++++++++++++++++--------------- 1 file changed, 106 insertions(+), 71 deletions(-) diff --git a/src/dodal/devices/zebra.py b/src/dodal/devices/zebra.py index 3a1066041d..cd0ed8fec5 100644 --- a/src/dodal/devices/zebra.py +++ b/src/dodal/devices/zebra.py @@ -4,10 +4,11 @@ from functools import partialmethod from typing import List -from ophyd import Component, Device, EpicsSignal, StatusBase +from ophyd import StatusBase +from ophyd_async.core import SignalRW, StandardReadable +from ophyd_async.epics.signal import epics_signal_rw from dodal.devices.status import await_value -from dodal.devices.util.epics_util import epics_signal_put_wait PC_ARM_SOURCE_SOFT = "Soft" PC_ARM_SOURCE_EXT = "External" @@ -74,70 +75,86 @@ class FastShutterAction(IntEnum): CLOSE = 0 -class ArmingDevice(Device): +# TODO check all types for the PVs! + + +class ArmingDevice(StandardReadable): """A useful device that can abstract some of the logic of arming. Allows a user to just call arm.set(ArmDemand.ARM)""" TIMEOUT = 3 - arm_set = Component(EpicsSignal, "PC_ARM") - disarm_set = Component(EpicsSignal, "PC_DISARM") - armed = Component(EpicsSignal, "PC_ARM_OUT") + def __init__(self, prefix: str, name: str = "") -> None: + self.arm_set = epics_signal_rw(int, prefix + "PC_ARM") + self.disarm_set = epics_signal_rw(int, prefix + "PC_DISARM") + self.armed = epics_signal_rw(int, prefix + "PC_ARM_OUT") + super().__init__(name) def set(self, demand: ArmDemand) -> StatusBase: + # TODO Ask about StatusBase vs AsyncStatus status = await_value(self.armed, demand.value, timeout=self.TIMEOUT) signal_to_set = self.arm_set if demand == ArmDemand.ARM else self.disarm_set status &= signal_to_set.set(1) return status -class PositionCompare(Device): - num_gates = epics_signal_put_wait("PC_GATE_NGATE") - gate_trigger = epics_signal_put_wait("PC_ENC") - gate_source = epics_signal_put_wait("PC_GATE_SEL") - gate_input = epics_signal_put_wait("PC_GATE_INP") - gate_width = epics_signal_put_wait("PC_GATE_WID") - gate_start = epics_signal_put_wait("PC_GATE_START") - gate_step = epics_signal_put_wait("PC_GATE_STEP") +class PositionCompare(StandardReadable): + def __init__(self, prefix: str, name: str = "") -> None: + self.num_gates = epics_signal_rw(int, prefix + "PC_GATE_NGATE") + self.gate_trigger = epics_signal_rw(str, prefix + "PC_ENC") + self.gate_source = epics_signal_rw(str, prefix + "PC_GATE_SEL") + self.gate_input = epics_signal_rw(int, prefix + "PC_GATE_INP") + self.gate_width = epics_signal_rw(float, prefix + "PC_GATE_WID") + self.gate_start = epics_signal_rw(float, prefix + "PC_GATE_START") + self.gate_step = epics_signal_rw(float, prefix + "PC_GATE_STEP") + + self.pulse_source = epics_signal_rw(str, prefix + "PC_PULSE_SEL") + self.pulse_input = epics_signal_rw(int, prefix + "PC_PULSE_INP") + self.pulse_start = epics_signal_rw(float, prefix + "PC_PULSE_START") + self.pulse_width = epics_signal_rw(float, prefix + "PC_PULSE_WID") + self.pulse_step = epics_signal_rw(float, prefix + "PC_PULSE_STEP") + self.pulse_max = epics_signal_rw(int, prefix + "PC_PULSE_MAX") - pulse_source = epics_signal_put_wait("PC_PULSE_SEL") - pulse_input = epics_signal_put_wait("PC_PULSE_INP") - pulse_start = epics_signal_put_wait("PC_PULSE_START") - pulse_width = epics_signal_put_wait("PC_PULSE_WID") - pulse_step = epics_signal_put_wait("PC_PULSE_STEP") - pulse_max = epics_signal_put_wait("PC_PULSE_MAX") + self.dir = epics_signal_rw(int, prefix + "PC_DIR") + self.arm_source = epics_signal_rw(str, prefix + "PC_ARM_SEL") + self.reset = epics_signal_rw(int, prefix + "SYS_RESET.PROC") - dir = Component(EpicsSignal, "PC_DIR") - arm_source = epics_signal_put_wait("PC_ARM_SEL") - reset = Component(EpicsSignal, "SYS_RESET.PROC") + self.arm = ArmingDevice(prefix) + super().__init__(name) - arm = Component(ArmingDevice, "") + async def is_armed(self) -> bool: + # TODO Check this makes sense + arm_state = await self.arm.armed.get_value() + return arm_state == 1 - def is_armed(self) -> bool: - return self.arm.armed.get() == 1 +class PulseOutput(StandardReadable): + """Zebra pulse output panel.""" -class PulseOutput(Device): - input = epics_signal_put_wait("_INP") - delay = epics_signal_put_wait("_DLY") - width = epics_signal_put_wait("_WID") + def __init__(self, prefix: str, name: str = "") -> None: + self.input = epics_signal_rw(int, prefix + "_INP") + self.delay = epics_signal_rw(float, prefix + "_DLY") + self.delay = epics_signal_rw(float, prefix + "_WID") + super().__init__(name) -class ZebraOutputPanel(Device): - pulse_1 = Component(PulseOutput, "PULSE1") - pulse_2 = Component(PulseOutput, "PULSE2") +class ZebraOutputPanel(StandardReadable): + def __init__(self, prefix: str, name: str = "") -> None: + self.pulse1 = PulseOutput(prefix + "PULSE1") + self.pulse2 = PulseOutput(prefix + "PULSE2") - out_1 = epics_signal_put_wait("OUT1_TTL") - out_2 = epics_signal_put_wait("OUT2_TTL") - out_3 = epics_signal_put_wait("OUT3_TTL") - out_4 = epics_signal_put_wait("OUT4_TTL") + self.out_1 = epics_signal_rw(int, prefix + "OUT1_TTL") + self.out_2 = epics_signal_rw(int, prefix + "OUT2_TTL") + self.out_3 = epics_signal_rw(int, prefix + "OUT3_TTL") + self.out_4 = epics_signal_rw(int, prefix + "OUT4_TTL") + super().__init__(name) @property - def out_pvs(self) -> List[EpicsSignal]: + def out_pvs(self) -> List[SignalRW]: """A list of all the output TTL PVs. Note that as the PVs are 1 indexed `out_pvs[0]` is `None`. """ - return [None, self.out_1, self.out_2, self.out_3, self.out_4] + return [None, self.out_1, self.out_2, self.out_3, self.out_4] # type:ignore def boolean_array_to_integer(values: List[bool]) -> int: @@ -153,13 +170,18 @@ def boolean_array_to_integer(values: List[bool]) -> int: return sum(v << i for i, v in enumerate(values)) -class GateControl(Device): - enable = epics_signal_put_wait("_ENA", 30.0) - source_1 = epics_signal_put_wait("_INP1", 30.0) - source_2 = epics_signal_put_wait("_INP2", 30.0) - source_3 = epics_signal_put_wait("_INP3", 30.0) - source_4 = epics_signal_put_wait("_INP4", 30.0) - invert = epics_signal_put_wait("_INV", 30.0) +class GateControl(StandardReadable): + # TODO: Ophyd v1 had a timeout of 30 - see epics_signal_put_wait + # SignalRW has + # set(value: T, wait=True, timeout='USE_DEFAULT_TIMEOUT') → AsyncStatus + def __init__(self, prefix: str, name: str = "") -> None: + self.enable = epics_signal_rw(int, prefix + "_ENA") + self.source_1 = epics_signal_rw(int, prefix + "_INP1") + self.source_2 = epics_signal_rw(int, prefix + "_INP2") + self.source_3 = epics_signal_rw(int, prefix + "_INP3") + self.source_4 = epics_signal_rw(int, prefix + "_INP4") + self.invert = epics_signal_rw(int, prefix + "_INV") + super().__init__(name) @property def sources(self): @@ -171,21 +193,20 @@ class GateType(Enum): OR = "OR" -class LogicGateConfigurer(Device): +class LogicGateConfigurer(StandardReadable): DEFAULT_SOURCE_IF_GATE_NOT_USED = 0 - and_gate_1 = Component(GateControl, "AND1") - and_gate_2 = Component(GateControl, "AND2") - and_gate_3 = Component(GateControl, "AND3") - and_gate_4 = Component(GateControl, "AND4") + def __init__(self, prefix: str, name: str = "") -> None: + self.and_gate_1 = GateControl(prefix + "AND1") + self.and_gate_2 = GateControl(prefix + "AND2") + self.and_gate_3 = GateControl(prefix + "AND3") + self.and_gate_4 = GateControl(prefix + "AND4") - or_gate_1 = Component(GateControl, "OR1") - or_gate_2 = Component(GateControl, "OR2") - or_gate_3 = Component(GateControl, "OR3") - or_gate_4 = Component(GateControl, "OR4") + self.or_gate_1 = GateControl(prefix + "OR1") + self.or_gate_2 = GateControl(prefix + "OR1") + self.or_gate_3 = GateControl(prefix + "OR1") + self.or_gate_4 = GateControl(prefix + "OR1") - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) self.all_gates = { GateType.AND: [ self.and_gate_1, @@ -200,6 +221,7 @@ def __init__(self, *args, **kwargs): self.or_gate_4, ], } + super().__init__(name) def apply_logic_gate_config( self, type: GateType, gate_number: int, config: LogicGateConfiguration @@ -213,17 +235,19 @@ def apply_logic_gate_config( """ gate: GateControl = self.all_gates[type][gate_number - 1] - gate.enable.put(boolean_array_to_integer([True] * len(config.sources))) + gate.enable.set(boolean_array_to_integer([True] * len(config.sources))) # Input Source for source_number, source_pv in enumerate(gate.sources): try: - source_pv.put(config.sources[source_number]) + # TODO Maybe the wait can go here now? + # What was the reason for such a long wait on the gates? + source_pv.set(config.sources[source_number]) except IndexError: - source_pv.put(self.DEFAULT_SOURCE_IF_GATE_NOT_USED) + source_pv.set(self.DEFAULT_SOURCE_IF_GATE_NOT_USED) # Invert - gate.invert.put(boolean_array_to_integer(config.invert)) + gate.invert.set(boolean_array_to_integer(config.invert)) apply_and_gate_config = partialmethod(apply_logic_gate_config, GateType.AND) apply_or_gate_config = partialmethod(apply_logic_gate_config, GateType.OR) @@ -265,15 +289,26 @@ def __str__(self) -> str: return ", ".join(input_strings) -class SoftInputs(Device): - soft_in_1 = Component(EpicsSignal, "SOFT_IN:B0") - soft_in_2 = Component(EpicsSignal, "SOFT_IN:B1") - soft_in_3 = Component(EpicsSignal, "SOFT_IN:B2") - soft_in_4 = Component(EpicsSignal, "SOFT_IN:B3") +class SoftInputs(StandardReadable): + def __init__(self, prefix: str, name: str = "") -> None: + self.soft_in_1 = epics_signal_rw(Enum, prefix + "SOFT_IN:B0") + self.soft_in_2 = epics_signal_rw(Enum, prefix + "SOFT_IN:B1") + self.soft_in_3 = epics_signal_rw(Enum, prefix + "SOFT_IN:B2") + self.soft_in_4 = epics_signal_rw(Enum, prefix + "SOFT_IN:B3") + super().__init__(name) + + +class Zebra(StandardReadable): + """The Zebra device.""" + def __init__(self, name: str, prefix: str) -> None: + self.pc = PositionCompare(prefix, name) + self.output = ZebraOutputPanel(prefix, name) + self.inputs = SoftInputs(prefix, name) + self.logic_gates = LogicGateConfigurer(prefix, name) + super().__init__(name=name) -class Zebra(Device): - pc = Component(PositionCompare, "") - output = Component(ZebraOutputPanel, "") - inputs = Component(SoftInputs, "") - logic_gates = Component(LogicGateConfigurer, "") + # pc = Component(PositionCompare, "") + # output = Component(ZebraOutputPanel, "") + # inputs = Component(SoftInputs, "") + # logic_gates = Component(LogicGateConfigurer, "") From d9c104685cc8bbeab2f40ef9b86331958aa4bb53 Mon Sep 17 00:00:00 2001 From: David Perl Date: Thu, 14 Mar 2024 14:36:39 +0000 Subject: [PATCH 091/134] hyperion 1239: make fgs device wait for motion program --- src/dodal/beamlines/i03.py | 2 +- src/dodal/devices/fast_grid_scan.py | 51 ++++++++++++++--------- tests/devices/unit_tests/test_gridscan.py | 26 +++++++++++- 3 files changed, 57 insertions(+), 22 deletions(-) diff --git a/src/dodal/beamlines/i03.py b/src/dodal/beamlines/i03.py index aaa335f794..373a2c7c03 100644 --- a/src/dodal/beamlines/i03.py +++ b/src/dodal/beamlines/i03.py @@ -187,7 +187,7 @@ def fast_grid_scan( return device_instantiation( device_factory=FastGridScan, name="fast_grid_scan", - prefix="-MO-SGON-01:FGS:", + prefix="-MO-SGON-01:", wait=wait_for_connection, fake=fake_with_ophyd_sim, ) diff --git a/src/dodal/devices/fast_grid_scan.py b/src/dodal/devices/fast_grid_scan.py index bde9120f12..23768f56b3 100644 --- a/src/dodal/devices/fast_grid_scan.py +++ b/src/dodal/devices/fast_grid_scan.py @@ -237,36 +237,43 @@ def clean_up(self): self.device.status.clear_sub(self._running_changed) +class MotionProgram(Device): + running = Component(EpicsSignalRO, "PROGBITS") + program_number = Component(EpicsSignalRO, "CS1:PROG_NUM") + + class FastGridScan(Device): - x_steps = Component(EpicsSignalWithRBV, "X_NUM_STEPS") - y_steps = Component(EpicsSignalWithRBV, "Y_NUM_STEPS") - z_steps = Component(EpicsSignalWithRBV, "Z_NUM_STEPS") + x_steps = Component(EpicsSignalWithRBV, "FGS:X_NUM_STEPS") + y_steps = Component(EpicsSignalWithRBV, "FGS:Y_NUM_STEPS") + z_steps = Component(EpicsSignalWithRBV, "FGS:Z_NUM_STEPS") - x_step_size = Component(EpicsSignalWithRBV, "X_STEP_SIZE") - y_step_size = Component(EpicsSignalWithRBV, "Y_STEP_SIZE") - z_step_size = Component(EpicsSignalWithRBV, "Z_STEP_SIZE") + x_step_size = Component(EpicsSignalWithRBV, "FGS:X_STEP_SIZE") + y_step_size = Component(EpicsSignalWithRBV, "FGS:Y_STEP_SIZE") + z_step_size = Component(EpicsSignalWithRBV, "FGS:Z_STEP_SIZE") - dwell_time_ms = Component(EpicsSignalWithRBV, "DWELL_TIME") + dwell_time_ms = Component(EpicsSignalWithRBV, "FGS:DWELL_TIME") - x_start = Component(EpicsSignalWithRBV, "X_START") - y1_start = Component(EpicsSignalWithRBV, "Y_START") - y2_start = Component(EpicsSignalWithRBV, "Y2_START") - z1_start = Component(EpicsSignalWithRBV, "Z_START") - z2_start = Component(EpicsSignalWithRBV, "Z2_START") + x_start = Component(EpicsSignalWithRBV, "FGS:X_START") + y1_start = Component(EpicsSignalWithRBV, "FGS:Y_START") + y2_start = Component(EpicsSignalWithRBV, "FGS:Y2_START") + z1_start = Component(EpicsSignalWithRBV, "FGS:Z_START") + z2_start = Component(EpicsSignalWithRBV, "FGS:Z2_START") position_counter = Component( - EpicsSignal, "POS_COUNTER", write_pv="POS_COUNTER_WRITE" + EpicsSignal, "FGS:POS_COUNTER", write_pv="FGS:POS_COUNTER_WRITE" ) - x_counter = Component(EpicsSignalRO, "X_COUNTER") - y_counter = Component(EpicsSignalRO, "Y_COUNTER") - scan_invalid = Component(EpicsSignalRO, "SCAN_INVALID") + x_counter = Component(EpicsSignalRO, "FGS:X_COUNTER") + y_counter = Component(EpicsSignalRO, "FGS:Y_COUNTER") + scan_invalid = Component(EpicsSignalRO, "FGS:SCAN_INVALID") - run_cmd = Component(EpicsSignal, "RUN.PROC") - stop_cmd = Component(EpicsSignal, "STOP.PROC") - status = Component(EpicsSignalRO, "SCAN_STATUS") + run_cmd = Component(EpicsSignal, "FGS:RUN.PROC") + stop_cmd = Component(EpicsSignal, "FGS:STOP.PROC") + status = Component(EpicsSignalRO, "FGS:SCAN_STATUS") expected_images = Component(Signal) + motion_program = Component(MotionProgram, "") + # Kickoff timeout in seconds KICKOFF_TIMEOUT: float = 5.0 @@ -291,11 +298,15 @@ def is_invalid(self) -> bool: return bool(self.scan_invalid.get()) def kickoff(self) -> StatusBase: - # Check running already here? st = DeviceStatus(device=self, timeout=self.KICKOFF_TIMEOUT) def scan(): try: + curr_prog = self.motion_program.program_number.get() + running = self.motion_program.running.get() + if running: + LOGGER.info(f"Motion program {curr_prog} still running, waiting...") + await_value(self.motion_program.running, 0).wait() LOGGER.debug("Running scan") self.run_cmd.put(1) LOGGER.info("Waiting for FGS to start") diff --git a/tests/devices/unit_tests/test_gridscan.py b/tests/devices/unit_tests/test_gridscan.py index 2a5ee6de74..9394856409 100644 --- a/tests/devices/unit_tests/test_gridscan.py +++ b/tests/devices/unit_tests/test_gridscan.py @@ -9,6 +9,7 @@ from mockito.matchers import ANY, ARGS, KWARGS from ophyd.sim import make_fake_device from ophyd.status import DeviceStatus, Status +from ophyd.utils.errors import StatusTimeoutError from dodal.devices.fast_grid_scan import ( FastGridScan, @@ -43,7 +44,7 @@ def test_given_settings_valid_when_kickoff_then_run_started( mock_run_set_status = mock() when(fast_grid_scan.run_cmd).put(ANY).thenReturn(mock_run_set_status) - fast_grid_scan.status.subscribe = lambda func, **_: func(1) + fast_grid_scan.status.subscribe = lambda func, **_: func(1) # type: ignore status = fast_grid_scan.kickoff() @@ -53,6 +54,29 @@ def test_given_settings_valid_when_kickoff_then_run_started( verify(fast_grid_scan.run_cmd).put(1) +def test_waits_for_running_motion( + fast_grid_scan: FastGridScan, +): + when(fast_grid_scan.motion_program.running).get().thenReturn(1) + + fast_grid_scan.KICKOFF_TIMEOUT = 0.01 + + with pytest.raises(StatusTimeoutError): + status = fast_grid_scan.kickoff() + status.wait() + + fast_grid_scan.KICKOFF_TIMEOUT = 1 + + mock_run_set_status = mock() + when(fast_grid_scan.run_cmd).put(ANY).thenReturn(mock_run_set_status) + fast_grid_scan.status.subscribe = lambda func, **_: func(1) # type: ignore + + when(fast_grid_scan.motion_program.running).get().thenReturn(0) + status = fast_grid_scan.kickoff() + status.wait() + verify(fast_grid_scan.run_cmd).put(1) + + def run_test_on_complete_watcher( fast_grid_scan: FastGridScan, num_pos_1d, put_value, expected_frac ): From b2dfb3f55ce2ff9dc3db527463e75f365fe6a8c9 Mon Sep 17 00:00:00 2001 From: Noemi Frisina Date: Thu, 14 Mar 2024 16:37:18 +0000 Subject: [PATCH 092/134] Tidy up after clarifications --- src/dodal/devices/zebra.py | 175 ++++++++++++++++++------------------- 1 file changed, 83 insertions(+), 92 deletions(-) diff --git a/src/dodal/devices/zebra.py b/src/dodal/devices/zebra.py index cd0ed8fec5..249c269a68 100644 --- a/src/dodal/devices/zebra.py +++ b/src/dodal/devices/zebra.py @@ -1,26 +1,13 @@ from __future__ import annotations +import asyncio from enum import Enum, IntEnum from functools import partialmethod from typing import List -from ophyd import StatusBase -from ophyd_async.core import SignalRW, StandardReadable +from ophyd_async.core import AsyncStatus, StandardReadable from ophyd_async.epics.signal import epics_signal_rw -from dodal.devices.status import await_value - -PC_ARM_SOURCE_SOFT = "Soft" -PC_ARM_SOURCE_EXT = "External" - -PC_GATE_SOURCE_POSITION = 0 -PC_GATE_SOURCE_TIME = 1 -PC_GATE_SOURCE_EXTERNAL = 2 - -PC_PULSE_SOURCE_POSITION = 0 -PC_PULSE_SOURCE_TIME = 1 -PC_PULSE_SOURCE_EXTERNAL = 2 - # Sources DISCONNECT = 0 IN1_TTL = 1 @@ -46,18 +33,36 @@ TTL_PANDA = 4 +class ArmSource(Enum, str): + SOFT = "Soft" + EXTERNAL = "External" + + +class TrigSource(Enum, str): + POSITION = "Position" + TIME = "Time" + EXTERNAL = "External" + + +class EncEnum(Enum): + Enc1 = "Enc1" + Enc2 = "Enc2" + Enc3 = "Enc3" + Enc4 = "Enc4" + + class I03Axes(Enum): - SMARGON_X1 = "Enc1" - SMARGON_Y = "Enc2" - SMARGON_Z = "Enc3" - OMEGA = "Enc4" + SMARGON_X1 = EncEnum.Enc1 + SMARGON_Y = EncEnum.Enc2 + SMARGON_Z = EncEnum.Enc3 + OMEGA = EncEnum.Enc4 class I24Axes(Enum): - VGON_Z = "Enc1" - OMEGA = "Enc2" - VGON_X = "Enc3" - VGON_YH = "Enc4" + VGON_Z = EncEnum.Enc1 + OMEGA = EncEnum.Enc2 + VGON_X = EncEnum.Enc3 + VGON_YH = EncEnum.Enc4 class RotationDirection(IntEnum): @@ -75,7 +80,9 @@ class FastShutterAction(IntEnum): CLOSE = 0 -# TODO check all types for the PVs! +class SoftInState(IntEnum): + YES = 1 + NO = 0 class ArmingDevice(StandardReadable): @@ -87,28 +94,31 @@ class ArmingDevice(StandardReadable): def __init__(self, prefix: str, name: str = "") -> None: self.arm_set = epics_signal_rw(int, prefix + "PC_ARM") self.disarm_set = epics_signal_rw(int, prefix + "PC_DISARM") - self.armed = epics_signal_rw(int, prefix + "PC_ARM_OUT") + self.armed = epics_signal_rw(ArmDemand, prefix + "PC_ARM_OUT") super().__init__(name) - def set(self, demand: ArmDemand) -> StatusBase: - # TODO Ask about StatusBase vs AsyncStatus - status = await_value(self.armed, demand.value, timeout=self.TIMEOUT) + async def _set_armed(self, demand: ArmDemand): + await self.armed.set(demand) signal_to_set = self.arm_set if demand == ArmDemand.ARM else self.disarm_set - status &= signal_to_set.set(1) - return status + await signal_to_set.set(1) + + async def set(self, demand: ArmDemand) -> AsyncStatus: + return AsyncStatus( + asyncio.wait_for(self._set_armed(demand), timeout=self.TIMEOUT) + ) class PositionCompare(StandardReadable): def __init__(self, prefix: str, name: str = "") -> None: self.num_gates = epics_signal_rw(int, prefix + "PC_GATE_NGATE") - self.gate_trigger = epics_signal_rw(str, prefix + "PC_ENC") - self.gate_source = epics_signal_rw(str, prefix + "PC_GATE_SEL") + self.gate_trigger = epics_signal_rw(EncEnum, prefix + "PC_ENC") + self.gate_source = epics_signal_rw(TrigSource, prefix + "PC_GATE_SEL") self.gate_input = epics_signal_rw(int, prefix + "PC_GATE_INP") self.gate_width = epics_signal_rw(float, prefix + "PC_GATE_WID") self.gate_start = epics_signal_rw(float, prefix + "PC_GATE_START") self.gate_step = epics_signal_rw(float, prefix + "PC_GATE_STEP") - self.pulse_source = epics_signal_rw(str, prefix + "PC_PULSE_SEL") + self.pulse_source = epics_signal_rw(TrigSource, prefix + "PC_PULSE_SEL") self.pulse_input = epics_signal_rw(int, prefix + "PC_PULSE_INP") self.pulse_start = epics_signal_rw(float, prefix + "PC_PULSE_START") self.pulse_width = epics_signal_rw(float, prefix + "PC_PULSE_WID") @@ -116,14 +126,13 @@ def __init__(self, prefix: str, name: str = "") -> None: self.pulse_max = epics_signal_rw(int, prefix + "PC_PULSE_MAX") self.dir = epics_signal_rw(int, prefix + "PC_DIR") - self.arm_source = epics_signal_rw(str, prefix + "PC_ARM_SEL") + self.arm_source = epics_signal_rw(ArmSource, prefix + "PC_ARM_SEL") self.reset = epics_signal_rw(int, prefix + "SYS_RESET.PROC") self.arm = ArmingDevice(prefix) super().__init__(name) async def is_armed(self) -> bool: - # TODO Check this makes sense arm_state = await self.arm.armed.get_value() return arm_state == 1 @@ -143,19 +152,14 @@ def __init__(self, prefix: str, name: str = "") -> None: self.pulse1 = PulseOutput(prefix + "PULSE1") self.pulse2 = PulseOutput(prefix + "PULSE2") - self.out_1 = epics_signal_rw(int, prefix + "OUT1_TTL") - self.out_2 = epics_signal_rw(int, prefix + "OUT2_TTL") - self.out_3 = epics_signal_rw(int, prefix + "OUT3_TTL") - self.out_4 = epics_signal_rw(int, prefix + "OUT4_TTL") + self.out_pvs = [ + epics_signal_rw(int, prefix + "OUT1_TTL"), + epics_signal_rw(int, prefix + "OUT2_TTL"), + epics_signal_rw(int, prefix + "OUT3_TTL"), + epics_signal_rw(int, prefix + "OUT4_TTL"), + ] super().__init__(name) - @property - def out_pvs(self) -> List[SignalRW]: - """A list of all the output TTL PVs. Note that as the PVs are 1 indexed - `out_pvs[0]` is `None`. - """ - return [None, self.out_1, self.out_2, self.out_3, self.out_4] # type:ignore - def boolean_array_to_integer(values: List[bool]) -> int: """Converts a boolean array to integer by interpretting it in binary with LSB 0 bit @@ -171,22 +175,17 @@ def boolean_array_to_integer(values: List[bool]) -> int: class GateControl(StandardReadable): - # TODO: Ophyd v1 had a timeout of 30 - see epics_signal_put_wait - # SignalRW has - # set(value: T, wait=True, timeout='USE_DEFAULT_TIMEOUT') → AsyncStatus def __init__(self, prefix: str, name: str = "") -> None: self.enable = epics_signal_rw(int, prefix + "_ENA") - self.source_1 = epics_signal_rw(int, prefix + "_INP1") - self.source_2 = epics_signal_rw(int, prefix + "_INP2") - self.source_3 = epics_signal_rw(int, prefix + "_INP3") - self.source_4 = epics_signal_rw(int, prefix + "_INP4") + self.sources = [ + epics_signal_rw(int, prefix + "_INP1"), + epics_signal_rw(int, prefix + "_INP2"), + epics_signal_rw(int, prefix + "_INP3"), + epics_signal_rw(int, prefix + "_INP4"), + ] self.invert = epics_signal_rw(int, prefix + "_INV") super().__init__(name) - @property - def sources(self): - return [self.source_1, self.source_2, self.source_3, self.source_4] - class GateType(Enum): AND = "AND" @@ -197,30 +196,32 @@ class LogicGateConfigurer(StandardReadable): DEFAULT_SOURCE_IF_GATE_NOT_USED = 0 def __init__(self, prefix: str, name: str = "") -> None: - self.and_gate_1 = GateControl(prefix + "AND1") - self.and_gate_2 = GateControl(prefix + "AND2") - self.and_gate_3 = GateControl(prefix + "AND3") - self.and_gate_4 = GateControl(prefix + "AND4") - - self.or_gate_1 = GateControl(prefix + "OR1") - self.or_gate_2 = GateControl(prefix + "OR1") - self.or_gate_3 = GateControl(prefix + "OR1") - self.or_gate_4 = GateControl(prefix + "OR1") + self.and_gates = [ + GateControl(prefix + "AND1"), + GateControl(prefix + "AND2"), + GateControl(prefix + "AND3"), + GateControl(prefix + "AND4"), + ] + + self.or_gates = [ + GateControl(prefix + "OR1"), + GateControl(prefix + "OR2"), + GateControl(prefix + "OR3"), + GateControl(prefix + "OR4"), + ] self.all_gates = { - GateType.AND: [ - self.and_gate_1, - self.and_gate_2, - self.and_gate_3, - self.and_gate_4, - ], - GateType.OR: [ - self.or_gate_1, - self.or_gate_2, - self.or_gate_3, - self.or_gate_4, - ], + GateType.AND: self.and_gates, + GateType.OR: self.or_gates, } + + self.apply_and_gate_config = partialmethod( + self.apply_logic_gate_config, GateType.AND + ) + self.apply_or_gate_config = partialmethod( + self.apply_logic_gate_config, GateType.OR + ) + super().__init__(name) def apply_logic_gate_config( @@ -240,8 +241,6 @@ def apply_logic_gate_config( # Input Source for source_number, source_pv in enumerate(gate.sources): try: - # TODO Maybe the wait can go here now? - # What was the reason for such a long wait on the gates? source_pv.set(config.sources[source_number]) except IndexError: source_pv.set(self.DEFAULT_SOURCE_IF_GATE_NOT_USED) @@ -249,9 +248,6 @@ def apply_logic_gate_config( # Invert gate.invert.set(boolean_array_to_integer(config.invert)) - apply_and_gate_config = partialmethod(apply_logic_gate_config, GateType.AND) - apply_or_gate_config = partialmethod(apply_logic_gate_config, GateType.OR) - class LogicGateConfiguration: NUMBER_OF_INPUTS = 4 @@ -291,10 +287,10 @@ def __str__(self) -> str: class SoftInputs(StandardReadable): def __init__(self, prefix: str, name: str = "") -> None: - self.soft_in_1 = epics_signal_rw(Enum, prefix + "SOFT_IN:B0") - self.soft_in_2 = epics_signal_rw(Enum, prefix + "SOFT_IN:B1") - self.soft_in_3 = epics_signal_rw(Enum, prefix + "SOFT_IN:B2") - self.soft_in_4 = epics_signal_rw(Enum, prefix + "SOFT_IN:B3") + self.soft_in_1 = epics_signal_rw(SoftInState, prefix + "SOFT_IN:B0") + self.soft_in_2 = epics_signal_rw(SoftInState, prefix + "SOFT_IN:B1") + self.soft_in_3 = epics_signal_rw(SoftInState, prefix + "SOFT_IN:B2") + self.soft_in_4 = epics_signal_rw(SoftInState, prefix + "SOFT_IN:B3") super().__init__(name) @@ -307,8 +303,3 @@ def __init__(self, name: str, prefix: str) -> None: self.inputs = SoftInputs(prefix, name) self.logic_gates = LogicGateConfigurer(prefix, name) super().__init__(name=name) - - # pc = Component(PositionCompare, "") - # output = Component(ZebraOutputPanel, "") - # inputs = Component(SoftInputs, "") - # logic_gates = Component(LogicGateConfigurer, "") From f878e73effea81f51c52c8178df250de40a2eb08 Mon Sep 17 00:00:00 2001 From: Noemi Frisina Date: Thu, 14 Mar 2024 16:43:02 +0000 Subject: [PATCH 093/134] Fix enum --- src/dodal/devices/zebra.py | 4 ++-- tests/devices/unit_tests/test_zebra.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/dodal/devices/zebra.py b/src/dodal/devices/zebra.py index 249c269a68..f86f8408d5 100644 --- a/src/dodal/devices/zebra.py +++ b/src/dodal/devices/zebra.py @@ -33,12 +33,12 @@ TTL_PANDA = 4 -class ArmSource(Enum, str): +class ArmSource(Enum): SOFT = "Soft" EXTERNAL = "External" -class TrigSource(Enum, str): +class TrigSource(Enum): POSITION = "Position" TIME = "Time" EXTERNAL = "External" diff --git a/tests/devices/unit_tests/test_zebra.py b/tests/devices/unit_tests/test_zebra.py index 3285e18899..a8cfb1e422 100644 --- a/tests/devices/unit_tests/test_zebra.py +++ b/tests/devices/unit_tests/test_zebra.py @@ -47,7 +47,7 @@ def test_logic_gate_configuration_62_and_34_inv_and_15_inv(): def run_configurer_test(gate_type: GateType, gate_num, config, expected_pv_values): FakeLogicConfigurer = make_fake_device(LogicGateConfigurer) - configurer = FakeLogicConfigurer(name="test fake logicconfigurer") + configurer = FakeLogicConfigurer(prefix="", name="test fake logicconfigurer") mock_gate_control = mock() mock_pvs = [mock() for i in range(6)] From 9cbab95eff206d2a292c4853fb74eba94450f99e Mon Sep 17 00:00:00 2001 From: Noemi Frisina Date: Thu, 14 Mar 2024 16:47:50 +0000 Subject: [PATCH 094/134] Fix zebra unit tests --- src/dodal/devices/zebra.py | 10 +++------- tests/devices/unit_tests/test_zebra.py | 2 +- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/dodal/devices/zebra.py b/src/dodal/devices/zebra.py index f86f8408d5..598c1ac028 100644 --- a/src/dodal/devices/zebra.py +++ b/src/dodal/devices/zebra.py @@ -215,13 +215,6 @@ def __init__(self, prefix: str, name: str = "") -> None: GateType.OR: self.or_gates, } - self.apply_and_gate_config = partialmethod( - self.apply_logic_gate_config, GateType.AND - ) - self.apply_or_gate_config = partialmethod( - self.apply_logic_gate_config, GateType.OR - ) - super().__init__(name) def apply_logic_gate_config( @@ -248,6 +241,9 @@ def apply_logic_gate_config( # Invert gate.invert.set(boolean_array_to_integer(config.invert)) + apply_and_gate_config = partialmethod(apply_logic_gate_config, GateType.AND) + apply_or_gate_config = partialmethod(apply_logic_gate_config, GateType.OR) + class LogicGateConfiguration: NUMBER_OF_INPUTS = 4 diff --git a/tests/devices/unit_tests/test_zebra.py b/tests/devices/unit_tests/test_zebra.py index a8cfb1e422..9b64f222e6 100644 --- a/tests/devices/unit_tests/test_zebra.py +++ b/tests/devices/unit_tests/test_zebra.py @@ -62,7 +62,7 @@ def run_configurer_test(gate_type: GateType, gate_num, config, expected_pv_value configurer.apply_or_gate_config(gate_num, config) for pv, value in zip(mock_pvs, expected_pv_values): - verify(pv).put(value) + verify(pv).set(value) def test_apply_and_logic_gate_configuration_32_and_51_inv_and_1(): From 68f47e0fc49284a4934a688f00bfd5a8ac7fcdf2 Mon Sep 17 00:00:00 2001 From: Noemi Frisina Date: Thu, 14 Mar 2024 17:16:28 +0000 Subject: [PATCH 095/134] Fix test --- .../beamlines/unit_tests/test_beamline_utils.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/tests/beamlines/unit_tests/test_beamline_utils.py b/tests/beamlines/unit_tests/test_beamline_utils.py index 20f182723d..89d4ade65e 100644 --- a/tests/beamlines/unit_tests/test_beamline_utils.py +++ b/tests/beamlines/unit_tests/test_beamline_utils.py @@ -6,6 +6,8 @@ from ophyd.device import Device as OphydV1Device from ophyd.sim import FakeEpicsSignal from ophyd_async.core import Device as OphydV2Device +from ophyd_async.core import StandardReadable +from ophyd_async.core.sim_signal_backend import SimSignalBackend from dodal.beamlines import beamline_utils, i03 from dodal.devices.aperturescatterguard import ApertureScatterguard @@ -49,12 +51,21 @@ def test_instantiating_different_device_with_same_name(): assert dev2 in beamline_utils.ACTIVE_DEVICES.values() -def test_instantiate_function_fake_makes_fake(): +def test_instantiate_v1_function_fake_makes_fake(): + smargon: Smargon = beamline_utils.device_instantiation( + i03.Smargon, "smargon", "", True, True, None + ) + assert isinstance(smargon, Device) + assert isinstance(smargon.disabled, FakeEpicsSignal) + + +def test_instantiate_v2_function_fake_makes_fake(): + RE() fake_zeb: Zebra = beamline_utils.device_instantiation( i03.Zebra, "zebra", "", True, True, None ) - assert isinstance(fake_zeb, Device) - assert isinstance(fake_zeb.pc.arm_source, FakeEpicsSignal) + assert isinstance(fake_zeb, StandardReadable) + assert isinstance(fake_zeb.pc.arm.armed._backend, SimSignalBackend) def test_clear_devices(RE): From 288697d744a1d745dc865889960063e575c59b21 Mon Sep 17 00:00:00 2001 From: Dominic Oram Date: Thu, 14 Mar 2024 17:38:50 +0000 Subject: [PATCH 096/134] (DiamondLightSource/hyperion#1260) Provide indexes to zocalo --- src/dodal/devices/zocalo/zocalo_interaction.py | 11 +++++++---- tests/devices/unit_tests/test_zocalo_interaction.py | 5 +++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/dodal/devices/zocalo/zocalo_interaction.py b/src/dodal/devices/zocalo/zocalo_interaction.py index 48303e5f7d..ead267a58b 100644 --- a/src/dodal/devices/zocalo/zocalo_interaction.py +++ b/src/dodal/devices/zocalo/zocalo_interaction.py @@ -24,15 +24,18 @@ class ZocaloStartInfo: """ ispyb_dcid (int): The ID of the data collection in ISPyB filename (str): The name of the file that the detector will store into dev/shm - number_of_frames (int): The number of frames in this collection. - start_index (int): The index of the first image of this collection within the file - written by the detector + start_frame_index (int): The index of the first image of this collection within the file + written by the detector + number_of_frames (int): The number of frames in this collection + message_index (int): Which trigger this is in the detector collection e.g. 0 for the + first collection after a single arm, 1 for the next... """ ispyb_dcid: int filename: Optional[str] - start_index: int + start_frame_index: int number_of_frames: int + message_index: int class ZocaloTrigger: diff --git a/tests/devices/unit_tests/test_zocalo_interaction.py b/tests/devices/unit_tests/test_zocalo_interaction.py index 5767fcd1bf..688a3d751d 100644 --- a/tests/devices/unit_tests/test_zocalo_interaction.py +++ b/tests/devices/unit_tests/test_zocalo_interaction.py @@ -18,8 +18,9 @@ EXPECTED_RUN_START_MESSAGE = { "ispyb_dcid": EXPECTED_DCID, "filename": EXPECTED_FILENAME, + "start_frame_index": 0, "number_of_frames": 100, - "start_index": 0, + "message_index": 0, "event": "start", } EXPECTED_RUN_END_MESSAGE = { @@ -88,7 +89,7 @@ def test_run_start(function_wrapper: Callable, expected_message: Dict): function_wrapper (Callable): A wrapper used to test for expected exceptions expected_message (Dict): The expected dictionary sent to zocalo """ - data = ZocaloStartInfo(EXPECTED_DCID, EXPECTED_FILENAME, 0, 100) + data = ZocaloStartInfo(EXPECTED_DCID, EXPECTED_FILENAME, 0, 100, 0) function_to_run = partial(zc.run_start, data) function_to_run = partial(function_wrapper, function_to_run) _test_zocalo(function_to_run, expected_message) From e2ba019f625b107d00ce90d519da9f6110063e24 Mon Sep 17 00:00:00 2001 From: David Perl Date: Thu, 14 Mar 2024 17:59:16 +0000 Subject: [PATCH 097/134] hyperion 1219 remove MXSC --- src/dodal/devices/oav/oav_detector.py | 10 +++------- tests/devices/unit_tests/oav/test_oav.py | 11 ++--------- tests/devices/unit_tests/test_oav.py | 4 ---- 3 files changed, 5 insertions(+), 20 deletions(-) diff --git a/src/dodal/devices/oav/oav_detector.py b/src/dodal/devices/oav/oav_detector.py index fcabc9f728..d95840a43b 100644 --- a/src/dodal/devices/oav/oav_detector.py +++ b/src/dodal/devices/oav/oav_detector.py @@ -17,7 +17,6 @@ StatusBase, ) -from dodal.devices.areadetector.plugins.MXSC import MXSC from dodal.devices.oav.grid_overlay import SnapshotWithGrid from dodal.devices.oav.oav_errors import ( OAVError_BeamPositionNotFound, @@ -60,11 +59,9 @@ class ZoomController(Device): def set_flatfield_on_zoom_level_one(self, value): flat_applied = self.parent.proc.port_name.get() no_flat_applied = self.parent.cam.port_name.get() - - input_plugin = flat_applied if value == "1.0x" else no_flat_applied - - flat_field_status = self.parent.mxsc.input_plugin.set(input_plugin) - return flat_field_status & self.parent.snapshot.input_plugin.set(input_plugin) + return self.parent.snapshot.input_plugin.set( + flat_applied if value == "1.0x" else no_flat_applied + ) @property def allowed_zoom_levels(self): @@ -198,7 +195,6 @@ class OAV(AreaDetector): tiff = ADC(OverlayPlugin, "-DI-OAV-01:TIFF:") hdf5 = ADC(HDF5Plugin, "-DI-OAV-01:HDF5:") snapshot = Component(SnapshotWithGrid, "-DI-OAV-01:MJPG:") - mxsc = ADC(MXSC, "-DI-OAV-01:MXSC:") zoom_controller = Component(ZoomController, "-EA-OAV-01:FZOOM:") def __init__(self, *args, params: OAVConfigParams, **kwargs): diff --git a/tests/devices/unit_tests/oav/test_oav.py b/tests/devices/unit_tests/oav/test_oav.py index 0fe1148996..58daa13adf 100644 --- a/tests/devices/unit_tests/oav/test_oav.py +++ b/tests/devices/unit_tests/oav/test_oav.py @@ -2,7 +2,7 @@ import pytest from ophyd.sim import instantiate_fake_device -from ophyd.status import Status +from ophyd.status import AndStatus, Status from dodal.devices.oav.oav_detector import OAV, OAVConfigParams from dodal.devices.oav.oav_errors import ( @@ -46,25 +46,18 @@ def oav() -> OAV: def test_when_zoom_level_changed_then_oav_rewired(zoom, expected_plugin, oav: OAV): oav.zoom_controller.set(zoom).wait() - assert oav.mxsc.input_plugin.get() == expected_plugin assert oav.snapshot.input_plugin.get() == expected_plugin def test_when_zoom_level_changed_then_status_waits_for_all_plugins_to_be_updated( oav: OAV, ): - mxsc_status = Status(obj="msxc - test_when_zoom_level...") - oav.mxsc.input_plugin.set = MagicMock(return_value=mxsc_status) - mjpg_status = Status("mjpg - test_when_zoom_level...") oav.snapshot.input_plugin.set = MagicMock(return_value=mjpg_status) - full_status = oav.zoom_controller.set("1.0x") - - assert mxsc_status in full_status + assert isinstance(full_status := oav.zoom_controller.set("1.0x"), AndStatus) assert mjpg_status in full_status - mxsc_status.set_finished() mjpg_status.set_finished() full_status.wait() diff --git a/tests/devices/unit_tests/test_oav.py b/tests/devices/unit_tests/test_oav.py index f32c1d2a45..683d9be0b1 100644 --- a/tests/devices/unit_tests/test_oav.py +++ b/tests/devices/unit_tests/test_oav.py @@ -120,14 +120,12 @@ def test_bottom_right_from_top_left(): def test_when_zoom_1_then_flat_field_applied(fake_oav: OAV): RE = RunEngine() RE(bps.abs_set(fake_oav.zoom_controller, "1.0x")) - assert fake_oav.mxsc.input_plugin.get() == "PROC" assert fake_oav.snapshot.input_plugin.get() == "PROC" def test_when_zoom_not_1_then_flat_field_removed(fake_oav: OAV): RE = RunEngine() RE(bps.abs_set(fake_oav.zoom_controller, "10.0x")) - assert fake_oav.mxsc.input_plugin.get() == "CAM" assert fake_oav.snapshot.input_plugin.get() == "CAM" @@ -136,11 +134,9 @@ def test_when_zoom_is_externally_changed_to_1_then_flat_field_not_changed( ): """This test is required to ensure that Hyperion doesn't cause unexpected behaviour e.g. change the flatfield when the zoom level is changed through the synoptic""" - fake_oav.mxsc.input_plugin.sim_put("CAM") # type: ignore fake_oav.snapshot.input_plugin.sim_put("CAM") # type: ignore fake_oav.zoom_controller.level.sim_put("1.0x") # type: ignore - assert fake_oav.mxsc.input_plugin.get() == "CAM" assert fake_oav.snapshot.input_plugin.get() == "CAM" From 6aba8b811c678063cefef6a85e1c502c1f3acf40 Mon Sep 17 00:00:00 2001 From: David Perl Date: Thu, 14 Mar 2024 18:01:01 +0000 Subject: [PATCH 098/134] hyperion 1219 remove MXSC --- .../devices/areadetector/plugins/MXSC.py | 130 ----------------- .../devices/unit_tests/test_pin_tip_detect.py | 133 ------------------ 2 files changed, 263 deletions(-) delete mode 100644 src/dodal/devices/areadetector/plugins/MXSC.py delete mode 100644 tests/devices/unit_tests/test_pin_tip_detect.py diff --git a/src/dodal/devices/areadetector/plugins/MXSC.py b/src/dodal/devices/areadetector/plugins/MXSC.py deleted file mode 100644 index 82177896d0..0000000000 --- a/src/dodal/devices/areadetector/plugins/MXSC.py +++ /dev/null @@ -1,130 +0,0 @@ -from typing import List, Tuple - -import numpy as np -from ophyd import Component, Device, EpicsSignal, EpicsSignalRO, Kind, Signal -from ophyd.status import StableSubscriptionStatus, Status, StatusBase - -from dodal.log import LOGGER - -Pixel = Tuple[int, int] - - -def statistics_of_positions( - positions: List[Pixel], -) -> Tuple[Pixel, Tuple[float, float]]: - """Get the median and standard deviation from a list of readings. - - Note that x/y are treated separately so the median position is not guaranteed to be - a position that was actually read. - - Args: - positions (List[Pixel]): A list of tip positions. - - Returns: - Tuple[Pixel, Tuple[float, float]]: The median tip position and the standard - deviation in x/y - """ - x_coords, y_coords = np.array(positions).T - - median = (int(np.median(x_coords)), int(np.median(y_coords))) - std = (np.std(x_coords, dtype=float), np.std(y_coords, dtype=float)) - - return median, std - - -class PinTipDetect(Device): - """This will read the pin tip location from the MXSC plugin. - - If the plugin finds no tip it will return {INVALID_POSITION}. However, it will also - occassionally give incorrect data. Therefore, it is recommended that you trigger - this device, which will set {triggered_tip} to a median of the valid points taken - for {settle_time_s} seconds. - - If no valid points are found within {validity_timeout} seconds a {triggered_tip} - will be set to {INVALID_POSITION}. - """ - - INVALID_POSITION = (-1, -1) - tip_x = Component(EpicsSignalRO, "TipX") - tip_y = Component(EpicsSignalRO, "TipY") - - triggered_tip = Component(Signal, kind=Kind.hinted, value=INVALID_POSITION) - validity_timeout = Component(Signal, value=5) - settle_time_s = Component(Signal, value=0.5) - - def log_tips_and_statistics(self, _): - median, standard_deviation = statistics_of_positions(self.tip_positions) - LOGGER.info( - f"Found tips {self.tip_positions} with median {median} and standard deviation {standard_deviation}" - ) - - def update_tip_if_valid(self, value: int, **_): - current_value = (value, int(self.tip_y.get())) - if current_value != self.INVALID_POSITION: - self.tip_positions.append(current_value) - - ( - median_tip_location, - __, - ) = statistics_of_positions(self.tip_positions) - - self.triggered_tip.put(median_tip_location) - return True - return False - - def trigger(self) -> StatusBase: - self.tip_positions: List[Pixel] = [] - - subscription_status = StableSubscriptionStatus( - self.tip_x, - self.update_tip_if_valid, - stability_time=self.settle_time_s.get(), - run=True, - ) - - def set_to_default_and_finish(timeout_status: Status): - try: - if not timeout_status.success: - self.triggered_tip.set(self.INVALID_POSITION) - subscription_status.set_finished() - except Exception as e: - subscription_status.set_exception(e) - - # We use a separate status for measuring the timeout as we don't want an error - # on the returned status - self._timeout_status = Status(self, timeout=self.validity_timeout.get()) - self._timeout_status.add_callback(set_to_default_and_finish) - subscription_status.add_callback(lambda _: self._timeout_status.set_finished()) - subscription_status.add_callback(self.log_tips_and_statistics) - - return subscription_status - - -class MXSC(Device): - """ - Device for edge detection plugin. - """ - - input_plugin = Component(EpicsSignal, "NDArrayPort") - enable_callbacks = Component(EpicsSignal, "EnableCallbacks") - min_callback_time = Component(EpicsSignal, "MinCallbackTime") - blocking_callbacks = Component(EpicsSignal, "BlockingCallbacks") - read_file = Component(EpicsSignal, "ReadFile") - filename = Component(EpicsSignal, "Filename", string=True) - preprocess_operation = Component(EpicsSignal, "Preprocess") - preprocess_ksize = Component(EpicsSignal, "PpParam1") - canny_upper_threshold = Component(EpicsSignal, "CannyUpper") - canny_lower_threshold = Component(EpicsSignal, "CannyLower") - close_ksize = Component(EpicsSignal, "CloseKsize") - scan_direction = Component(EpicsSignal, "ScanDirection") - min_tip_height = Component(EpicsSignal, "MinTipHeight") - - top = Component(EpicsSignal, "Top") - bottom = Component(EpicsSignal, "Bottom") - output_array = Component(EpicsSignal, "OutputArray") - draw_tip = Component(EpicsSignal, "DrawTip") - draw_edges = Component(EpicsSignal, "DrawEdges") - waveform_size_x = Component(EpicsSignal, "ArraySize1_RBV") - waveform_size_y = Component(EpicsSignal, "ArraySize2_RBV") - - pin_tip = Component(PinTipDetect, "") diff --git a/tests/devices/unit_tests/test_pin_tip_detect.py b/tests/devices/unit_tests/test_pin_tip_detect.py deleted file mode 100644 index 951c305f19..0000000000 --- a/tests/devices/unit_tests/test_pin_tip_detect.py +++ /dev/null @@ -1,133 +0,0 @@ -from typing import Generator, List, Tuple - -import bluesky.plan_stubs as bps -import pytest -from bluesky.run_engine import RunEngine -from ophyd.sim import make_fake_device - -from dodal.devices.areadetector.plugins.MXSC import ( - PinTipDetect, - statistics_of_positions, -) - - -@pytest.fixture -def fake_pin_tip_detect() -> Generator[PinTipDetect, None, None]: - FakePinTipDetect = make_fake_device(PinTipDetect) - pin_tip_detect: PinTipDetect = FakePinTipDetect(name="pin_tip") - pin_tip_detect.settle_time_s.set(0).wait() - yield pin_tip_detect - - -def trigger_and_read( - fake_pin_tip_detect, values_to_set_during_trigger: List[Tuple] = None -): - yield from bps.trigger(fake_pin_tip_detect) - if values_to_set_during_trigger: - for position in values_to_set_during_trigger: - fake_pin_tip_detect.tip_y.sim_put(position[1]) # type: ignore - fake_pin_tip_detect.tip_x.sim_put(position[0]) # type: ignore - yield from bps.wait() - return (yield from bps.rd(fake_pin_tip_detect)) - - -def test_given_pin_tip_stays_invalid_when_triggered_then_return_( - fake_pin_tip_detect: PinTipDetect, -): - def set_small_timeout_then_trigger_and_read(): - yield from bps.abs_set(fake_pin_tip_detect.validity_timeout, 0.01) - return (yield from trigger_and_read(fake_pin_tip_detect)) - - RE = RunEngine(call_returns_result=True) - result = RE(set_small_timeout_then_trigger_and_read()) - - assert result.plan_result == fake_pin_tip_detect.INVALID_POSITION - - -def test_given_pin_tip_invalid_when_triggered_and_set_then_rd_returns_value( - fake_pin_tip_detect: PinTipDetect, -): - RE = RunEngine(call_returns_result=True) - result = RE(trigger_and_read(fake_pin_tip_detect, [(200, 100)])) - - assert result.plan_result == (200, 100) - - -def test_given_pin_tip_found_before_timeout_then_timeout_status_cleaned_up_and_tip_value_remains( - fake_pin_tip_detect: PinTipDetect, -): - RE = RunEngine(call_returns_result=True) - RE(trigger_and_read(fake_pin_tip_detect, [(100, 200)])) - # A success should clear up the timeout status but it may clear it up slightly later - # so we need the small timeout to avoid the race condition - fake_pin_tip_detect._timeout_status.wait(0.01) - assert fake_pin_tip_detect.triggered_tip.get() == (100, 200) - - -def test_median_of_positions_calculated_correctly(): - test = [(1, 2), (1, 5), (3, 3)] - - actual_med, _ = statistics_of_positions(test) - - assert actual_med == (1, 3) - - -def test_standard_dev_of_positions_calculated_correctly(): - test = [(1, 2), (1, 3)] - - _, actual_std = statistics_of_positions(test) - - assert actual_std == (0, 0.5) - - -def test_given_multiple_tips_found_then_running_median_calculated( - fake_pin_tip_detect: PinTipDetect, -): - fake_pin_tip_detect.settle_time_s.set(0.1).wait() - - RE = RunEngine(call_returns_result=True) - RE(trigger_and_read(fake_pin_tip_detect, [(100, 200), (50, 60), (400, 800)])) - - assert fake_pin_tip_detect.triggered_tip.get() == (100, 200) - - -def trigger_and_read_twice( - fake_pin_tip_detect: PinTipDetect, first_values: List[Tuple], second_value: Tuple -): - yield from trigger_and_read(fake_pin_tip_detect, first_values) - fake_pin_tip_detect.tip_y.sim_put(second_value[1]) # type: ignore - fake_pin_tip_detect.tip_x.sim_put(second_value[0]) # type: ignore - return (yield from trigger_and_read(fake_pin_tip_detect, [])) - - -def test_given_median_previously_calculated_when_triggered_again_then_only_calculated_on_new_values( - fake_pin_tip_detect: PinTipDetect, -): - fake_pin_tip_detect.settle_time_s.set(0.1).wait() - - RE = RunEngine(call_returns_result=True) - - def my_plan(): - tip_pos = yield from trigger_and_read_twice( - fake_pin_tip_detect, [(10, 20), (1, 3), (4, 8)], (100, 200) - ) - assert tip_pos == (100, 200) - - RE(my_plan()) - - -def test_given_previous_tip_found_when_this_tip_not_found_then_returns_invalid( - fake_pin_tip_detect: PinTipDetect, -): - fake_pin_tip_detect.settle_time_s.set(0.1).wait() - fake_pin_tip_detect.validity_timeout.set(0.5).wait() - - RE = RunEngine(call_returns_result=True) - - def my_plan(): - tip_pos = yield from trigger_and_read_twice( - fake_pin_tip_detect, [(10, 20), (1, 3), (4, 8)], (-1, -1) - ) - assert tip_pos == (-1, -1) - - RE(my_plan()) From 9b3a8f9aaf040acaa92dd49589ee71766cb9ecdd Mon Sep 17 00:00:00 2001 From: David Perl Date: Thu, 14 Mar 2024 18:03:18 +0000 Subject: [PATCH 099/134] hyperion 1219 remove MXSC --- tests/devices/unit_tests/test_oav_centring.py | 269 ------------------ 1 file changed, 269 deletions(-) delete mode 100644 tests/devices/unit_tests/test_oav_centring.py diff --git a/tests/devices/unit_tests/test_oav_centring.py b/tests/devices/unit_tests/test_oav_centring.py deleted file mode 100644 index 6b2d477d3f..0000000000 --- a/tests/devices/unit_tests/test_oav_centring.py +++ /dev/null @@ -1,269 +0,0 @@ -import bluesky.plan_stubs as bps -import bluesky.preprocessors as bpp -import numpy as np -import pytest -from bluesky.run_engine import RunEngine -from ophyd.sim import make_fake_device - -from dodal.devices.backlight import Backlight -from dodal.devices.oav.oav_calculations import ( - camera_coordinates_to_xyz, - check_i_within_bounds, - extract_pixel_centre_values_from_rotation_data, - filter_rotation_data, - find_midpoint, - find_widest_point_and_orthogonal_point, - get_orthogonal_index, - get_rotation_increment, - keep_inside_bounds, -) -from dodal.devices.oav.oav_detector import OAV, OAVConfigParams -from dodal.devices.oav.oav_errors import ( - OAVError_MissingRotations, - OAVError_NoRotationsPassValidityTest, -) -from dodal.devices.oav.oav_parameters import OAVParameters -from dodal.devices.smargon import Smargon - -OAV_CENTRING_JSON = "tests/devices/unit_tests/test_OAVCentring.json" -DISPLAY_CONFIGURATION = "tests/devices/unit_tests/test_display.configuration" -ZOOM_LEVELS_XML = "tests/devices/unit_tests/test_jCameraManZoomLevels.xml" - - -def do_nothing(*args, **kwargs): - pass - - -@pytest.fixture -def mock_oav(): - oav_params = OAVConfigParams(ZOOM_LEVELS_XML, DISPLAY_CONFIGURATION) - oav: OAV = make_fake_device(OAV)( - name="oav", prefix="a fake beamline", params=oav_params - ) - oav.snapshot.x_size.sim_put("1024") # type: ignore - oav.snapshot.y_size.sim_put("768") # type: ignore - oav.wait_for_connection() - return oav - - -@pytest.fixture -def mock_parameters(): - return OAVParameters( - "loopCentring", - OAV_CENTRING_JSON, - ) - - -@pytest.fixture -def mock_smargon(): - smargon: Smargon = make_fake_device(Smargon)(name="smargon") - smargon.wait_for_connection = do_nothing - return smargon - - -@pytest.fixture -def mock_backlight(): - backlight: Backlight = make_fake_device(Backlight)(name="backlight") - backlight.wait_for_connection = do_nothing - return backlight - - -def test_can_make_fake_testing_devices_and_use_run_engine( - mock_oav: OAV, - mock_parameters: OAVParameters, - mock_smargon: Smargon, - mock_backlight: Backlight, -): - @bpp.run_decorator() - def fake_run( - mock_oav: OAV, - mock_parameters: OAVParameters, - mock_smargon: Smargon, - mock_backlight: Backlight, - ): - yield from bps.abs_set(mock_oav.cam.acquire_period, 5) - mock_parameters.acquire_period = 10 - # can't change the smargon motors because of limit issues with FakeEpicsDevice - # yield from bps.mv(mock_smargon.omega, 1) - yield from bps.mv(mock_backlight.pos, 1) - - RE = RunEngine() - RE(fake_run(mock_oav, mock_parameters, mock_smargon, mock_backlight)) - - -def test_find_midpoint_symmetric_pin(): - x = np.arange(-15, 10, 25 / 1024) - x2 = x**2 - top = -1 * x2 + 100 - bottom = x2 - 100 - top += 500 - bottom += 500 - - # set the waveforms to 0 before the edge is found - top[np.where(top < bottom)[0]] = 0 - bottom[np.where(bottom > top)[0]] = 0 - - (x_pos, y_pos, width) = find_midpoint(top, bottom) - assert x_pos == 614 - assert y_pos == 500 - - -def test_find_midpoint_non_symmetric_pin(): - x = np.arange(-4, 2.35, 6.35 / 1024) - x2 = x**2 - x4 = x2**2 - top = -1 * x2 + 6 - bottom = x4 - 5 * x2 - 3 - - top += 400 - bottom += 400 - - # set the waveforms to 0 before the edge is found - top[np.where(top < bottom)[0]] = 0 - bottom[np.where(bottom > top)[0]] = 0 - - (x_pos, y_pos, width) = find_midpoint(top, bottom) - assert x_pos == 419 - assert np.floor(y_pos) == 397 - # x = 205/1024*4.7 - 2.35 ≈ -1.41 which is the first stationary point of the width on - # our midpoint line - - -def test_get_rotation_increment_threshold_within_180(): - increment = get_rotation_increment(6, 0, 180) - assert increment == 180 / 6 - - -def test_get_rotation_increment_threshold_exceeded(): - increment = get_rotation_increment(6, 30, 180) - assert increment == -180 / 6 - - -@pytest.mark.parametrize( - "value,lower_bound,upper_bound,expected_value", - [(0.5, -10, 10, 0.5), (-100, -10, 10, -10), (10000, -213, 50, 50)], -) -def test_keep_inside_bounds(value, lower_bound, upper_bound, expected_value): - assert keep_inside_bounds(value, lower_bound, upper_bound) == expected_value - - -def test_filter_rotation_data(): - x_positions = np.array([400, 450, 7, 500]) - y_positions = np.array([400, 450, 7, 500]) - widths = np.array([400, 450, 7, 500]) - omegas = np.array([400, 450, 7, 500]) - - ( - filtered_x, - filtered_y, - filtered_widths, - filtered_omegas, - ) = filter_rotation_data(x_positions, y_positions, widths, omegas) - - assert filtered_x[2] == 500 - assert filtered_omegas[2] == 500 - - -def test_filter_rotation_data_throws_error_when_all_fail(): - x_positions = np.array([1020, 20]) - y_positions = np.array([10, 450]) - widths = np.array([400, 450]) - omegas = np.array([400, 450]) - with pytest.raises(OAVError_NoRotationsPassValidityTest): - ( - filtered_x, - filtered_y, - filtered_widths, - filtered_omegas, - ) = filter_rotation_data(x_positions, y_positions, widths, omegas) - - -@pytest.mark.parametrize( - "max_tip_distance, tip_x, x, expected_return", - [ - (180, 400, 600, 580), - (180, 400, 450, 450), - ], -) -def test_keep_x_within_bounds(max_tip_distance, tip_x, x, expected_return): - assert check_i_within_bounds(max_tip_distance, tip_x, x) == expected_return - - -@pytest.mark.parametrize( - "h,v,omega,expected_values", - [ - (0.0, 0.0, 0.0, np.array([0.0, 0.0, 0.0])), - (10, -5, 90, np.array([-10, 3.062e-16, -5])), - (100, -50, 40, np.array([-100, 38.302, -32.139])), - (10, 100, -4, np.array([-10, -99.756, -6.976])), - ], -) -def test_distance_from_beam_centre_to_motor_coords_returns_the_same_values_as_GDA( - h, v, omega, expected_values, mock_oav: OAV, mock_parameters: OAVParameters -): - mock_parameters.zoom = "5.0" - mock_oav.zoom_controller.level.sim_put(mock_parameters.zoom) # type: ignore - results = camera_coordinates_to_xyz( - h, - v, - omega, - mock_oav.parameters.micronsPerXPixel, - mock_oav.parameters.micronsPerYPixel, - ) - expected_values = expected_values * 1e-3 - expected_values[0] *= mock_oav.parameters.micronsPerXPixel - expected_values[1] *= mock_oav.parameters.micronsPerYPixel - expected_values[2] *= mock_oav.parameters.micronsPerYPixel - expected_values = np.around(expected_values, decimals=3) - - assert np.array_equal(np.around(results, decimals=3), expected_values) - - -def test_find_widest_point_and_orthogonal_point(): - widths = np.array([400, 450, 7, 500, 600, 400]) - omegas = np.array([0, 30, 60, 90, 120, 180]) - assert find_widest_point_and_orthogonal_point(widths, omegas) == (4, 1) - - -def test_find_widest_point_and_orthogonal_point_no_orthogonal_angles(): - widths = np.array([400, 7, 500, 600, 400]) - omegas = np.array([0, 60, 90, 120, 180]) - with pytest.raises(OAVError_MissingRotations): - find_widest_point_and_orthogonal_point(widths, omegas) - - -def test_extract_pixel_centre_values_from_rotation_data(): - x_positions = np.array([400, 450, 7, 500, 475, 412]) - y_positions = np.array([500, 512, 518, 498, 486, 530]) - widths = np.array([400, 450, 7, 500, 600, 400]) - omegas = np.array([0, 30, 60, 90, 120, 180]) - assert extract_pixel_centre_values_from_rotation_data( - x_positions, y_positions, widths, omegas - ) == (475, 486, 512, 120, 30) - - -@pytest.mark.parametrize( - "angle_array,angle,expected_index", - [ - (np.array([0, 30, 60, 75, 110, 140, 160, 179]), 50, 5), - (np.array([0, 15, 10, 65, 89, 135, 174]), 0, 4), - (np.array([-40, -80, -52, 10, -3, -5, 60]), 85, 5), - (np.array([-150, -120, -90, -60, -30, 0]), 30, 3), - ( - np.array( - [6.0013e01, 3.0010e01, 7.0000e-03, -3.0002e01, -6.0009e01, -9.0016e01] - ), - -90.016, - 2, - ), - ], -) -def test_get_closest_orthogonal_index(angle_array, angle, expected_index): - assert get_orthogonal_index(angle_array, angle) == expected_index - - -def test_get_closest_orthogonal_index_not_orthogonal_enough(): - with pytest.raises(OAVError_MissingRotations): - get_orthogonal_index( - np.array([0, 30, 60, 90, 160, 180, 210, 240, 250, 255]), 50 - ) From 478d64688f0903566dde3e0ae0d1013816ad6d51 Mon Sep 17 00:00:00 2001 From: Noemi Frisina Date: Fri, 15 Mar 2024 13:50:29 +0000 Subject: [PATCH 100/134] Do not connect to real PVs in testing and fix tests --- src/dodal/devices/zebra.py | 64 ++++++++++++++------------ tests/devices/unit_tests/test_zebra.py | 35 ++++++++++---- 2 files changed, 60 insertions(+), 39 deletions(-) diff --git a/src/dodal/devices/zebra.py b/src/dodal/devices/zebra.py index 598c1ac028..04771bc7db 100644 --- a/src/dodal/devices/zebra.py +++ b/src/dodal/devices/zebra.py @@ -5,7 +5,7 @@ from functools import partialmethod from typing import List -from ophyd_async.core import AsyncStatus, StandardReadable +from ophyd_async.core import AsyncStatus, DeviceVector, SignalRW, StandardReadable from ophyd_async.epics.signal import epics_signal_rw # Sources @@ -100,6 +100,7 @@ def __init__(self, prefix: str, name: str = "") -> None: async def _set_armed(self, demand: ArmDemand): await self.armed.set(demand) signal_to_set = self.arm_set if demand == ArmDemand.ARM else self.disarm_set + # await asyncio.gather(self.armed.set(demand), signal_to_set.set(1)) await signal_to_set.set(1) async def set(self, demand: ArmDemand) -> AsyncStatus: @@ -152,12 +153,14 @@ def __init__(self, prefix: str, name: str = "") -> None: self.pulse1 = PulseOutput(prefix + "PULSE1") self.pulse2 = PulseOutput(prefix + "PULSE2") - self.out_pvs = [ - epics_signal_rw(int, prefix + "OUT1_TTL"), - epics_signal_rw(int, prefix + "OUT2_TTL"), - epics_signal_rw(int, prefix + "OUT3_TTL"), - epics_signal_rw(int, prefix + "OUT4_TTL"), - ] + self.out_pvs: DeviceVector[SignalRW] = DeviceVector( + { + 1: epics_signal_rw(int, prefix + "OUT1_TTL"), + 2: epics_signal_rw(int, prefix + "OUT2_TTL"), + 3: epics_signal_rw(int, prefix + "OUT3_TTL"), + 4: epics_signal_rw(int, prefix + "OUT4_TTL"), + } + ) super().__init__(name) @@ -177,12 +180,9 @@ def boolean_array_to_integer(values: List[bool]) -> int: class GateControl(StandardReadable): def __init__(self, prefix: str, name: str = "") -> None: self.enable = epics_signal_rw(int, prefix + "_ENA") - self.sources = [ - epics_signal_rw(int, prefix + "_INP1"), - epics_signal_rw(int, prefix + "_INP2"), - epics_signal_rw(int, prefix + "_INP3"), - epics_signal_rw(int, prefix + "_INP4"), - ] + self.sources = DeviceVector( + {i: epics_signal_rw(int, prefix + f"_INP{i}") for i in range(1, 5)} + ) self.invert = epics_signal_rw(int, prefix + "_INV") super().__init__(name) @@ -196,23 +196,27 @@ class LogicGateConfigurer(StandardReadable): DEFAULT_SOURCE_IF_GATE_NOT_USED = 0 def __init__(self, prefix: str, name: str = "") -> None: - self.and_gates = [ - GateControl(prefix + "AND1"), - GateControl(prefix + "AND2"), - GateControl(prefix + "AND3"), - GateControl(prefix + "AND4"), - ] - - self.or_gates = [ - GateControl(prefix + "OR1"), - GateControl(prefix + "OR2"), - GateControl(prefix + "OR3"), - GateControl(prefix + "OR4"), - ] + self.and_gates: DeviceVector[GateControl] = DeviceVector( + { + 1: GateControl(prefix + "AND1"), + 2: GateControl(prefix + "AND2"), + 3: GateControl(prefix + "AND3"), + 4: GateControl(prefix + "AND4"), + } + ) + + self.or_gates: DeviceVector[GateControl] = DeviceVector( + { + 1: GateControl(prefix + "OR1"), + 2: GateControl(prefix + "OR2"), + 3: GateControl(prefix + "OR3"), + 4: GateControl(prefix + "OR4"), + } + ) self.all_gates = { - GateType.AND: self.and_gates, - GateType.OR: self.or_gates, + GateType.AND: list(self.and_gates.values()), + GateType.OR: list(self.or_gates.values()), } super().__init__(name) @@ -232,9 +236,9 @@ def apply_logic_gate_config( gate.enable.set(boolean_array_to_integer([True] * len(config.sources))) # Input Source - for source_number, source_pv in enumerate(gate.sources): + for source_number, source_pv in gate.sources.items(): try: - source_pv.set(config.sources[source_number]) + source_pv.set(config.sources[source_number - 1]) except IndexError: source_pv.set(self.DEFAULT_SOURCE_IF_GATE_NOT_USED) diff --git a/tests/devices/unit_tests/test_zebra.py b/tests/devices/unit_tests/test_zebra.py index 9b64f222e6..daf972a3b5 100644 --- a/tests/devices/unit_tests/test_zebra.py +++ b/tests/devices/unit_tests/test_zebra.py @@ -1,14 +1,25 @@ import pytest +from bluesky.run_engine import RunEngine from mockito import mock, verify -from ophyd.sim import make_fake_device from dodal.devices.zebra import ( + # ArmDemand, + # ArmingDevice, GateType, LogicGateConfiguration, LogicGateConfigurer, boolean_array_to_integer, ) +# async def test_arming_device(): +# RunEngine() +# arming_device = ArmingDevice("", name="fake arming device") +# await arming_device.connect(sim=True) +# await arming_device.set(ArmDemand.ARM) +# # TODO This fails on this assert (arm set not to 1?) +# # Never hits _set_armed +# assert await arming_device.arm_set.get_value() == 1 + @pytest.mark.parametrize( "boolean_array,expected_integer", @@ -45,14 +56,20 @@ def test_logic_gate_configuration_62_and_34_inv_and_15_inv(): assert str(config) == "INP1=62, INP2=!34, INP3=!15" -def run_configurer_test(gate_type: GateType, gate_num, config, expected_pv_values): - FakeLogicConfigurer = make_fake_device(LogicGateConfigurer) - configurer = FakeLogicConfigurer(prefix="", name="test fake logicconfigurer") +async def run_configurer_test( + gate_type: GateType, + gate_num, + config, + expected_pv_values, +): + RunEngine() + configurer = LogicGateConfigurer(prefix="", name="test fake logicconfigurer") + await configurer.connect(sim=True) mock_gate_control = mock() mock_pvs = [mock() for i in range(6)] mock_gate_control.enable = mock_pvs[0] - mock_gate_control.sources = mock_pvs[1:5] + mock_gate_control.sources = {i: mock_pvs[i] for i in range(1, 5)} mock_gate_control.invert = mock_pvs[5] configurer.all_gates[gate_type][gate_num - 1] = mock_gate_control @@ -65,18 +82,18 @@ def run_configurer_test(gate_type: GateType, gate_num, config, expected_pv_value verify(pv).set(value) -def test_apply_and_logic_gate_configuration_32_and_51_inv_and_1(): +async def test_apply_and_logic_gate_configuration_32_and_51_inv_and_1(): config = LogicGateConfiguration(32).add_input(51, True).add_input(1) expected_pv_values = [7, 32, 51, 1, 0, 2] - run_configurer_test(GateType.AND, 1, config, expected_pv_values) + await run_configurer_test(GateType.AND, 1, config, expected_pv_values) -def test_apply_or_logic_gate_configuration_19_and_36_inv_and_60_inv(): +async def test_apply_or_logic_gate_configuration_19_and_36_inv_and_60_inv(): config = LogicGateConfiguration(19).add_input(36, True).add_input(60, True) expected_pv_values = [7, 19, 36, 60, 0, 6] - run_configurer_test(GateType.OR, 2, config, expected_pv_values) + await run_configurer_test(GateType.OR, 2, config, expected_pv_values) @pytest.mark.parametrize( From 4664c0b89455757fe8d9f457a71a8b2a3362039f Mon Sep 17 00:00:00 2001 From: Noemi Frisina Date: Fri, 15 Mar 2024 14:03:20 +0000 Subject: [PATCH 101/134] Add a test on arming device --- src/dodal/devices/zebra.py | 2 +- tests/devices/unit_tests/test_zebra.py | 22 ++++++++++++---------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/dodal/devices/zebra.py b/src/dodal/devices/zebra.py index 04771bc7db..76466aaac7 100644 --- a/src/dodal/devices/zebra.py +++ b/src/dodal/devices/zebra.py @@ -103,7 +103,7 @@ async def _set_armed(self, demand: ArmDemand): # await asyncio.gather(self.armed.set(demand), signal_to_set.set(1)) await signal_to_set.set(1) - async def set(self, demand: ArmDemand) -> AsyncStatus: + def set(self, demand: ArmDemand) -> AsyncStatus: return AsyncStatus( asyncio.wait_for(self._set_armed(demand), timeout=self.TIMEOUT) ) diff --git a/tests/devices/unit_tests/test_zebra.py b/tests/devices/unit_tests/test_zebra.py index daf972a3b5..670f517d68 100644 --- a/tests/devices/unit_tests/test_zebra.py +++ b/tests/devices/unit_tests/test_zebra.py @@ -3,22 +3,24 @@ from mockito import mock, verify from dodal.devices.zebra import ( - # ArmDemand, - # ArmingDevice, + ArmDemand, + ArmingDevice, GateType, LogicGateConfiguration, LogicGateConfigurer, boolean_array_to_integer, ) -# async def test_arming_device(): -# RunEngine() -# arming_device = ArmingDevice("", name="fake arming device") -# await arming_device.connect(sim=True) -# await arming_device.set(ArmDemand.ARM) -# # TODO This fails on this assert (arm set not to 1?) -# # Never hits _set_armed -# assert await arming_device.arm_set.get_value() == 1 + +async def test_arming_device(): + RunEngine() + arming_device = ArmingDevice("", name="fake arming device") + await arming_device.connect(sim=True) + status = arming_device.set(ArmDemand.ARM) + await status + # TODO This fails on this assert (arm set not to 1?) + # Never hits _set_armed + assert await arming_device.arm_set.get_value() == 1 @pytest.mark.parametrize( From 350ec7c3c16c26323623b999532038cbbf265050 Mon Sep 17 00:00:00 2001 From: Noemi Frisina Date: Fri, 15 Mar 2024 14:04:07 +0000 Subject: [PATCH 102/134] Add a test on arming device -maybe save it all this time --- src/dodal/devices/zebra.py | 1 - tests/devices/unit_tests/test_zebra.py | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/dodal/devices/zebra.py b/src/dodal/devices/zebra.py index 76466aaac7..2c68d2ed55 100644 --- a/src/dodal/devices/zebra.py +++ b/src/dodal/devices/zebra.py @@ -100,7 +100,6 @@ def __init__(self, prefix: str, name: str = "") -> None: async def _set_armed(self, demand: ArmDemand): await self.armed.set(demand) signal_to_set = self.arm_set if demand == ArmDemand.ARM else self.disarm_set - # await asyncio.gather(self.armed.set(demand), signal_to_set.set(1)) await signal_to_set.set(1) def set(self, demand: ArmDemand) -> AsyncStatus: diff --git a/tests/devices/unit_tests/test_zebra.py b/tests/devices/unit_tests/test_zebra.py index 670f517d68..1a0cd8361b 100644 --- a/tests/devices/unit_tests/test_zebra.py +++ b/tests/devices/unit_tests/test_zebra.py @@ -18,8 +18,7 @@ async def test_arming_device(): await arming_device.connect(sim=True) status = arming_device.set(ArmDemand.ARM) await status - # TODO This fails on this assert (arm set not to 1?) - # Never hits _set_armed + assert status.success assert await arming_device.arm_set.get_value() == 1 From f3d3eb86b786a56711659c34a037ae321a3d6ded Mon Sep 17 00:00:00 2001 From: Noemi Frisina Date: Fri, 15 Mar 2024 15:40:59 +0000 Subject: [PATCH 103/134] Fix typo --- src/dodal/devices/zebra.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dodal/devices/zebra.py b/src/dodal/devices/zebra.py index 2c68d2ed55..de667f2893 100644 --- a/src/dodal/devices/zebra.py +++ b/src/dodal/devices/zebra.py @@ -143,7 +143,7 @@ class PulseOutput(StandardReadable): def __init__(self, prefix: str, name: str = "") -> None: self.input = epics_signal_rw(int, prefix + "_INP") self.delay = epics_signal_rw(float, prefix + "_DLY") - self.delay = epics_signal_rw(float, prefix + "_WID") + self.witdh = epics_signal_rw(float, prefix + "_WID") super().__init__(name) From c52f2df843eff32529ac7c18160c004b4e4fdb5d Mon Sep 17 00:00:00 2001 From: Noemi Frisina Date: Fri, 15 Mar 2024 17:16:21 +0000 Subject: [PATCH 104/134] Add pc test --- src/dodal/devices/zebra.py | 2 +- tests/devices/unit_tests/test_zebra.py | 23 +++++++++++++++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/dodal/devices/zebra.py b/src/dodal/devices/zebra.py index de667f2893..afaa6eb5c9 100644 --- a/src/dodal/devices/zebra.py +++ b/src/dodal/devices/zebra.py @@ -111,7 +111,7 @@ def set(self, demand: ArmDemand) -> AsyncStatus: class PositionCompare(StandardReadable): def __init__(self, prefix: str, name: str = "") -> None: self.num_gates = epics_signal_rw(int, prefix + "PC_GATE_NGATE") - self.gate_trigger = epics_signal_rw(EncEnum, prefix + "PC_ENC") + self.gate_trigger = epics_signal_rw(str, prefix + "PC_ENC") self.gate_source = epics_signal_rw(TrigSource, prefix + "PC_GATE_SEL") self.gate_input = epics_signal_rw(int, prefix + "PC_GATE_INP") self.gate_width = epics_signal_rw(float, prefix + "PC_GATE_WID") diff --git a/tests/devices/unit_tests/test_zebra.py b/tests/devices/unit_tests/test_zebra.py index 1a0cd8361b..dae901a93d 100644 --- a/tests/devices/unit_tests/test_zebra.py +++ b/tests/devices/unit_tests/test_zebra.py @@ -8,6 +8,8 @@ GateType, LogicGateConfiguration, LogicGateConfigurer, + PositionCompare, + TrigSource, boolean_array_to_integer, ) @@ -16,10 +18,27 @@ async def test_arming_device(): RunEngine() arming_device = ArmingDevice("", name="fake arming device") await arming_device.connect(sim=True) - status = arming_device.set(ArmDemand.ARM) + status = arming_device.set(ArmDemand.DISARM) await status assert status.success - assert await arming_device.arm_set.get_value() == 1 + assert await arming_device.disarm_set.get_value() == 1 + + +async def test_position_compare_sets(): + RunEngine() + fake_pc = PositionCompare("", name="fake position compare") + await fake_pc.connect(sim=True) + + fake_pc.gate_source.set(TrigSource.EXTERNAL) + # fake_pc.gate_trigger.set(I03Axes.OMEGA.value()) + # TODO fix EncEnum bit in code + assert await fake_pc.gate_source.get_value() == TrigSource.EXTERNAL + + status = fake_pc.arm.set(ArmDemand.ARM) + await status + assert await fake_pc.arm.arm_set.get_value() == 1 + assert await fake_pc.arm.disarm_set.get_value() == 0 + assert await fake_pc.is_armed() @pytest.mark.parametrize( From e6ce194a5af97b331b48aa35f82b5c811bf2e680 Mon Sep 17 00:00:00 2001 From: David Perl Date: Mon, 18 Mar 2024 09:17:54 +0000 Subject: [PATCH 105/134] hyperion #1033 divide expt. param base class --- src/dodal/devices/fast_grid_scan.py | 4 ++-- src/dodal/parameters/experiment_parameter_base.py | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/dodal/devices/fast_grid_scan.py b/src/dodal/devices/fast_grid_scan.py index 23768f56b3..75cbd3e60a 100644 --- a/src/dodal/devices/fast_grid_scan.py +++ b/src/dodal/devices/fast_grid_scan.py @@ -20,7 +20,7 @@ from dodal.devices.motors import XYZLimitBundle from dodal.devices.status import await_value from dodal.log import LOGGER -from dodal.parameters.experiment_parameter_base import AbstractExperimentParameterBase +from dodal.parameters.experiment_parameter_base import AbstractExperimentWithBeamParams @dataclass @@ -44,7 +44,7 @@ def is_within(self, steps): return 0 <= steps <= self.full_steps -class GridScanParamsCommon(BaseModel, AbstractExperimentParameterBase): +class GridScanParamsCommon(BaseModel, 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 diff --git a/src/dodal/parameters/experiment_parameter_base.py b/src/dodal/parameters/experiment_parameter_base.py index ccfd758e4a..6e08b9e5e3 100644 --- a/src/dodal/parameters/experiment_parameter_base.py +++ b/src/dodal/parameters/experiment_parameter_base.py @@ -2,6 +2,12 @@ class AbstractExperimentParameterBase(ABC): + pass + + +class AbstractExperimentWithBeamParams(AbstractExperimentParameterBase): + transmission_fraction: float + @abstractmethod def get_num_images(self) -> int: pass From 30815f1f1696fee78359a744f4c2232b43554ccf Mon Sep 17 00:00:00 2001 From: Callum Forrester Date: Mon, 18 Mar 2024 13:42:34 +0000 Subject: [PATCH 106/134] Remove Container Build from CI (#388) * Remove container build from CI * Remove container docs * Remove runtime build from dockerfile as it is no longer used --- .github/workflows/code.yml | 90 ------------------------------ Dockerfile | 12 ---- docs/user/how-to/run-container.rst | 15 ----- docs/user/index.rst | 1 - 4 files changed, 118 deletions(-) delete mode 100644 docs/user/how-to/run-container.rst diff --git a/.github/workflows/code.yml b/.github/workflows/code.yml index 9a8954befe..88b4e565cb 100644 --- a/.github/workflows/code.yml +++ b/.github/workflows/code.yml @@ -112,96 +112,6 @@ jobs: # If more than one module in src/ replace with module name to test run: python -m dodal --version - container: - needs: [lint, dist, test] - runs-on: ubuntu-latest - - permissions: - contents: read - packages: write - - env: - TEST_TAG: "testing" - - steps: - - name: Checkout - uses: actions/checkout@v4 - - # image names must be all lower case - - name: Generate image repo name - run: echo IMAGE_REPOSITORY=ghcr.io/$(tr '[:upper:]' '[:lower:]' <<< "${{ github.repository }}") >> $GITHUB_ENV - - - name: Set lockfile location in environment - run: | - echo "DIST_LOCKFILE_PATH=lockfiles-${{ env.CONTAINER_PYTHON }}-dist-${{ github.sha }}" >> $GITHUB_ENV - - - name: Download wheel and lockfiles - uses: actions/download-artifact@v4.1.2 - with: - path: artifacts/ - pattern: "*dist*" - - - name: Log in to GitHub Docker Registry - if: github.event_name != 'pull_request' - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Set up Docker Buildx - id: buildx - uses: docker/setup-buildx-action@v3 - - - name: Build and export to Docker local cache - uses: docker/build-push-action@v5 - with: - # Note build-args, context, file, and target must all match between this - # step and the later build-push-action, otherwise the second build-push-action - # will attempt to build the image again - build-args: | - PIP_OPTIONS=-r ${{ env.DIST_LOCKFILE_PATH }}/requirements.txt ${{ env.DIST_WHEEL_PATH }}/*.whl - context: artifacts/ - file: ./Dockerfile - target: runtime - load: true - tags: ${{ env.TEST_TAG }} - # If you have a long docker build (2+ minutes), uncomment the - # following to turn on caching. For short build times this - # makes it a little slower - #cache-from: type=gha - #cache-to: type=gha,mode=max - - - name: Create tags for publishing image - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.IMAGE_REPOSITORY }} - tags: | - type=ref,event=tag - type=raw,value=latest, enable=${{ github.ref_type == 'tag' }} - # type=edge,branch=main - # Add line above to generate image for every commit to given branch, - # and uncomment the end of if clause in next step - - - name: Push cached image to container registry - if: github.ref_type == 'tag' # || github.ref_name == 'main' - uses: docker/build-push-action@v5 - # This does not build the image again, it will find the image in the - # Docker cache and publish it - with: - # Note build-args, context, file, and target must all match between this - # step and the previous build-push-action, otherwise this step will - # attempt to build the image again - build-args: | - PIP_OPTIONS=-r ${{ env.DIST_LOCKFILE_PATH }}/requirements.txt ${{ env.DIST_WHEEL_PATH }}/*.whl - context: artifacts/ - file: ./Dockerfile - target: runtime - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - release: # upload to PyPI and make a release on every tag needs: [lint, dist, test] diff --git a/Dockerfile b/Dockerfile index a932e69851..a7cf36f3bb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,15 +23,3 @@ WORKDIR /context # install python package into /venv RUN pip install ${PIP_OPTIONS} - -FROM python:3.11-slim as runtime - -# Add apt-get system dependecies for runtime here if needed - -# copy the virtual environment from the build stage and put it in PATH -COPY --from=build /venv/ /venv/ -ENV PATH=/venv/bin:$PATH - -# change this entrypoint if it is not the same as the repo -ENTRYPOINT ["dodal"] -CMD ["--version"] diff --git a/docs/user/how-to/run-container.rst b/docs/user/how-to/run-container.rst deleted file mode 100644 index 64baca9b3e..0000000000 --- a/docs/user/how-to/run-container.rst +++ /dev/null @@ -1,15 +0,0 @@ -Run in a container -================== - -Pre-built containers with dodal and its dependencies already -installed are available on `Github Container Registry -`_. - -Starting the container ----------------------- - -To pull the container from github container registry and run:: - - $ docker run ghcr.io/DiamondLightSource/dodal:main --version - -To get a released version, use a numbered release instead of ``main``. diff --git a/docs/user/index.rst b/docs/user/index.rst index c3ba4b7c0c..538d0fce92 100644 --- a/docs/user/index.rst +++ b/docs/user/index.rst @@ -26,7 +26,6 @@ side-bar. :caption: How-to Guides :maxdepth: 1 - how-to/run-container how-to/create-beamline.rst +++ From 91d0abf3dc8b0d8d77d3ae6be4c7b296df797d11 Mon Sep 17 00:00:00 2001 From: Noemi Frisina Date: Mon, 18 Mar 2024 14:12:50 +0000 Subject: [PATCH 107/134] Fix enums and test --- src/dodal/devices/zebra.py | 10 +++++----- tests/devices/unit_tests/test_zebra.py | 16 ++++++++++++---- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/dodal/devices/zebra.py b/src/dodal/devices/zebra.py index afaa6eb5c9..3ed51e25da 100644 --- a/src/dodal/devices/zebra.py +++ b/src/dodal/devices/zebra.py @@ -33,32 +33,32 @@ TTL_PANDA = 4 -class ArmSource(Enum): +class ArmSource(str, Enum): SOFT = "Soft" EXTERNAL = "External" -class TrigSource(Enum): +class TrigSource(str, Enum): POSITION = "Position" TIME = "Time" EXTERNAL = "External" -class EncEnum(Enum): +class EncEnum(str, Enum): Enc1 = "Enc1" Enc2 = "Enc2" Enc3 = "Enc3" Enc4 = "Enc4" -class I03Axes(Enum): +class I03Axes: SMARGON_X1 = EncEnum.Enc1 SMARGON_Y = EncEnum.Enc2 SMARGON_Z = EncEnum.Enc3 OMEGA = EncEnum.Enc4 -class I24Axes(Enum): +class I24Axes: VGON_Z = EncEnum.Enc1 OMEGA = EncEnum.Enc2 VGON_X = EncEnum.Enc3 diff --git a/tests/devices/unit_tests/test_zebra.py b/tests/devices/unit_tests/test_zebra.py index dae901a93d..e5ed9663fc 100644 --- a/tests/devices/unit_tests/test_zebra.py +++ b/tests/devices/unit_tests/test_zebra.py @@ -5,7 +5,9 @@ from dodal.devices.zebra import ( ArmDemand, ArmingDevice, + ArmSource, GateType, + I03Axes, LogicGateConfiguration, LogicGateConfigurer, PositionCompare, @@ -24,18 +26,24 @@ async def test_arming_device(): assert await arming_device.disarm_set.get_value() == 1 -async def test_position_compare_sets(): +async def test_position_compare_sets_signals(): RunEngine() fake_pc = PositionCompare("", name="fake position compare") await fake_pc.connect(sim=True) fake_pc.gate_source.set(TrigSource.EXTERNAL) - # fake_pc.gate_trigger.set(I03Axes.OMEGA.value()) - # TODO fix EncEnum bit in code - assert await fake_pc.gate_source.get_value() == TrigSource.EXTERNAL + fake_pc.gate_trigger.set(I03Axes.OMEGA.value) + fake_pc.num_gates.set(10) + assert await fake_pc.gate_source.get_value() == "External" + assert await fake_pc.gate_trigger.get_value() == "Enc4" + assert await fake_pc.num_gates.get_value() == 10 + + fake_pc.arm_source.set(ArmSource.SOFT) status = fake_pc.arm.set(ArmDemand.ARM) await status + + assert await fake_pc.arm_source.get_value() == "Soft" assert await fake_pc.arm.arm_set.get_value() == 1 assert await fake_pc.arm.disarm_set.get_value() == 0 assert await fake_pc.is_armed() From 73ce54435fc856edc6b5ff71a6a532716c9671d9 Mon Sep 17 00:00:00 2001 From: Dominic Oram Date: Mon, 18 Mar 2024 14:37:07 +0000 Subject: [PATCH 108/134] (DiamondLightSource/hyperion#1234) Wait for pin to be unloaded then new one loaded --- src/dodal/devices/robot.py | 29 ++++++---- src/dodal/devices/util/epics_util.py | 15 ------ tests/devices/unit_tests/test_bart_robot.py | 60 ++++++++++++++++----- 3 files changed, 67 insertions(+), 37 deletions(-) diff --git a/src/dodal/devices/robot.py b/src/dodal/devices/robot.py index c62445c258..e775e8702f 100644 --- a/src/dodal/devices/robot.py +++ b/src/dodal/devices/robot.py @@ -1,13 +1,14 @@ import asyncio from collections import OrderedDict from dataclasses import dataclass +from enum import Enum from typing import Dict, Sequence from bluesky.protocols import Descriptor, Movable, Reading -from ophyd_async.core import AsyncStatus, StandardReadable +from ophyd_async.core import AsyncStatus, StandardReadable, wait_for_value from ophyd_async.epics.signal import epics_signal_r, epics_signal_x -from dodal.devices.util.epics_util import epics_signal_rw_rbv, signal_meets_predicate +from dodal.devices.util.epics_util import epics_signal_rw_rbv from dodal.log import LOGGER @@ -55,6 +56,11 @@ class SampleLocation: pin: int +class PinMounted(str, Enum): + NO_PIN_MOUNTED = "No Pin Mounted" + PIN_MOUNTED = "Pin Mounted" + + class BartRobot(StandardReadable, Movable): """The sample changing robot.""" @@ -66,26 +72,31 @@ def __init__( prefix: str, ) -> None: self.barcode = SingleIndexWaveformReadable(prefix + "BARCODE") - self.gonio_pin_sensor = epics_signal_r(bool, prefix + "PIN_MOUNTED") + self.gonio_pin_sensor = epics_signal_r(PinMounted, prefix + "PIN_MOUNTED") self.next_pin = epics_signal_rw_rbv(float, prefix + "NEXT_PIN") self.next_puck = epics_signal_rw_rbv(float, prefix + "NEXT_PUCK") self.load = epics_signal_x(prefix + "LOAD.PROC") self.program_running = epics_signal_r(bool, prefix + "PROGRAM_RUNNING") + self.program_name = epics_signal_r(str, prefix + "PROGRAM_NAME") super().__init__(name=name) async def _load_pin_and_puck(self, sample_location: SampleLocation): LOGGER.info(f"Loading pin {sample_location}") - await signal_meets_predicate( - self.program_running, lambda v: not v, "Waiting on robot program to finish" - ) + if await self.program_running.get_value(): + LOGGER.info( + f"Waiting on robot to finish {await self.program_name.get_value()}" + ) + await wait_for_value(self.program_running, False, None) await asyncio.gather( self.next_puck.set(sample_location.puck), self.next_pin.set(sample_location.pin), ) await self.load.trigger() - await signal_meets_predicate( - self.gonio_pin_sensor, lambda v: v, "Waiting on pin mounted" - ) + if await self.gonio_pin_sensor.get_value() == PinMounted.PIN_MOUNTED: + LOGGER.info("Waiting on old pin unloaded") + await wait_for_value(self.gonio_pin_sensor, PinMounted.NO_PIN_MOUNTED, None) + LOGGER.info("Waiting on new pin loaded") + await wait_for_value(self.gonio_pin_sensor, PinMounted.PIN_MOUNTED, None) def set(self, sample_location: SampleLocation) -> AsyncStatus: return AsyncStatus( diff --git a/src/dodal/devices/util/epics_util.py b/src/dodal/devices/util/epics_util.py index 9e9c56ff63..0f71e62025 100644 --- a/src/dodal/devices/util/epics_util.py +++ b/src/dodal/devices/util/epics_util.py @@ -3,9 +3,7 @@ from ophyd import Component, Device, EpicsSignal from ophyd.status import Status, StatusBase -from ophyd_async.core import observe_value from ophyd_async.epics.signal import epics_signal_rw -from ophyd_async.epics.signal.signal import SignalR from dodal.devices.status import await_value from dodal.log import LOGGER @@ -134,16 +132,3 @@ def epics_signal_rw_rbv( T, write_pv: str ): # Remove when https://github.com/bluesky/ophyd-async/issues/139 is done return epics_signal_rw(T, write_pv + "_RBV", write_pv) - - -async def signal_meets_predicate( - signal: SignalR, predicate: Callable, message: Optional[str] = None -): - """Takes a signal and passes any updates it gets to {predicate}, will wait until the - return value of {predicate} is True. Optionally prints {message} when waiting. - """ - async for value in observe_value(signal): - if predicate(value): - break - if message: - LOGGER.info(message) diff --git a/tests/devices/unit_tests/test_bart_robot.py b/tests/devices/unit_tests/test_bart_robot.py index 350c1eee3a..06fea83216 100644 --- a/tests/devices/unit_tests/test_bart_robot.py +++ b/tests/devices/unit_tests/test_bart_robot.py @@ -1,10 +1,10 @@ -from asyncio import TimeoutError -from unittest.mock import AsyncMock +from asyncio import TimeoutError, sleep +from unittest.mock import AsyncMock, MagicMock, patch import pytest from ophyd_async.core import set_sim_value -from dodal.devices.robot import BartRobot, SampleLocation +from dodal.devices.robot import BartRobot, PinMounted, SampleLocation async def _get_bart_robot() -> BartRobot: @@ -29,33 +29,67 @@ async def test_when_barcode_updates_then_new_barcode_read(): @pytest.mark.asyncio -async def test_given_program_running_when_load_pin_then_times_out(): +@patch("dodal.devices.robot.LOGGER") +async def test_given_program_running_when_load_pin_then_logs_the_program_name_and_times_out( + patch_logger: MagicMock, +): device = await _get_bart_robot() + program_name = "BAD_PROGRAM" set_sim_value(device.program_running, True) + set_sim_value(device.program_name, program_name) with pytest.raises(TimeoutError): await device.set(SampleLocation(0, 0)) + last_log = patch_logger.mock_calls[1].args[0] + assert program_name in last_log @pytest.mark.asyncio -async def test_given_program_not_running_when_load_pin_then_pin_loaded(): +@patch("dodal.devices.robot.LOGGER") +async def test_given_program_not_running_but_pin_not_unmounting_when_load_pin_then_timeout( + patch_logger: MagicMock, +): device = await _get_bart_robot() set_sim_value(device.program_running, False) - set_sim_value(device.gonio_pin_sensor, True) + set_sim_value(device.gonio_pin_sensor, PinMounted.PIN_MOUNTED) device.load = AsyncMock(side_effect=device.load) - status = device.set(SampleLocation(15, 10)) - await status - assert status.success - assert (await device.next_puck.get_value()) == 15 - assert (await device.next_pin.get_value()) == 10 + with pytest.raises(TimeoutError): + await device.set(SampleLocation(15, 10)) device.load.trigger.assert_called_once() # type:ignore + last_log = patch_logger.mock_calls[1].args[0] + assert "Waiting on old pin unloaded" in last_log @pytest.mark.asyncio -async def test_given_program_not_running_but_pin_not_mounting_when_load_pin_then_timeout(): +@patch("dodal.devices.robot.LOGGER") +async def test_given_program_not_running_and_pin_unmounting_but_new_pin_not_mounting_when_load_pin_then_timeout( + patch_logger: MagicMock, +): device = await _get_bart_robot() set_sim_value(device.program_running, False) - set_sim_value(device.gonio_pin_sensor, False) + set_sim_value(device.gonio_pin_sensor, PinMounted.NO_PIN_MOUNTED) device.load = AsyncMock(side_effect=device.load) with pytest.raises(TimeoutError): await device.set(SampleLocation(15, 10)) device.load.trigger.assert_called_once() # type:ignore + last_log = patch_logger.mock_calls[1].args[0] + assert "Waiting on new pin loaded" in last_log + + +@pytest.mark.asyncio +async def test_given_program_not_running_and_pin_unmounts_then_mounts_when_load_pin_then_pin_loaded(): + device = await _get_bart_robot() + device.LOAD_TIMEOUT = 0.03 # type: ignore + set_sim_value(device.program_running, False) + set_sim_value(device.gonio_pin_sensor, PinMounted.PIN_MOUNTED) + + device.load = AsyncMock(side_effect=device.load) + status = device.set(SampleLocation(15, 10)) + await sleep(0.01) + set_sim_value(device.gonio_pin_sensor, PinMounted.NO_PIN_MOUNTED) + await sleep(0.005) + set_sim_value(device.gonio_pin_sensor, PinMounted.PIN_MOUNTED) + await status + assert status.success + assert (await device.next_puck.get_value()) == 15 + assert (await device.next_pin.get_value()) == 10 + device.load.trigger.assert_called_once() # type:ignore From b090b8cd7758fac1890d73325e799840ef9b2241 Mon Sep 17 00:00:00 2001 From: Noemi Frisina Date: Mon, 18 Mar 2024 14:47:14 +0000 Subject: [PATCH 109/134] Tidy up DeviceVectors --- src/dodal/devices/zebra.py | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/src/dodal/devices/zebra.py b/src/dodal/devices/zebra.py index 3ed51e25da..2545da3655 100644 --- a/src/dodal/devices/zebra.py +++ b/src/dodal/devices/zebra.py @@ -153,12 +153,7 @@ def __init__(self, prefix: str, name: str = "") -> None: self.pulse2 = PulseOutput(prefix + "PULSE2") self.out_pvs: DeviceVector[SignalRW] = DeviceVector( - { - 1: epics_signal_rw(int, prefix + "OUT1_TTL"), - 2: epics_signal_rw(int, prefix + "OUT2_TTL"), - 3: epics_signal_rw(int, prefix + "OUT3_TTL"), - 4: epics_signal_rw(int, prefix + "OUT4_TTL"), - } + {i: epics_signal_rw(int, prefix + f"OUT{i}_TTL") for i in range(1, 5)} ) super().__init__(name) @@ -196,21 +191,11 @@ class LogicGateConfigurer(StandardReadable): def __init__(self, prefix: str, name: str = "") -> None: self.and_gates: DeviceVector[GateControl] = DeviceVector( - { - 1: GateControl(prefix + "AND1"), - 2: GateControl(prefix + "AND2"), - 3: GateControl(prefix + "AND3"), - 4: GateControl(prefix + "AND4"), - } + {i: GateControl(prefix + f"AND{i}") for i in range(1, 5)} ) self.or_gates: DeviceVector[GateControl] = DeviceVector( - { - 1: GateControl(prefix + "OR1"), - 2: GateControl(prefix + "OR2"), - 3: GateControl(prefix + "OR3"), - 4: GateControl(prefix + "OR4"), - } + {i: GateControl(prefix + f"OR{i}") for i in range(1, 5)} ) self.all_gates = { From 114614b0120a8332cc8fa7c4bc300b8536c29a3b Mon Sep 17 00:00:00 2001 From: Noemi Frisina Date: Mon, 18 Mar 2024 14:56:04 +0000 Subject: [PATCH 110/134] Fix typo --- src/dodal/devices/zebra.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dodal/devices/zebra.py b/src/dodal/devices/zebra.py index 2545da3655..c430bdf47a 100644 --- a/src/dodal/devices/zebra.py +++ b/src/dodal/devices/zebra.py @@ -143,7 +143,7 @@ class PulseOutput(StandardReadable): def __init__(self, prefix: str, name: str = "") -> None: self.input = epics_signal_rw(int, prefix + "_INP") self.delay = epics_signal_rw(float, prefix + "_DLY") - self.witdh = epics_signal_rw(float, prefix + "_WID") + self.width = epics_signal_rw(float, prefix + "_WID") super().__init__(name) From 7ab82c46eaefd5fd84650c5247405fe67ad8c504 Mon Sep 17 00:00:00 2001 From: David Perl Date: Mon, 18 Mar 2024 15:56:37 +0000 Subject: [PATCH 111/134] #1033 fix defaults for tests --- src/dodal/devices/fast_grid_scan.py | 4 +-- .../parameters/experiment_parameter_base.py | 4 ++- .../system_tests/test_gridscan_system.py | 10 +++++-- tests/devices/unit_tests/test_gridscan.py | 29 +++++++++++++++---- .../devices/unit_tests/test_panda_gridscan.py | 7 ++++- 5 files changed, 43 insertions(+), 11 deletions(-) diff --git a/src/dodal/devices/fast_grid_scan.py b/src/dodal/devices/fast_grid_scan.py index 75cbd3e60a..7d58f1768f 100644 --- a/src/dodal/devices/fast_grid_scan.py +++ b/src/dodal/devices/fast_grid_scan.py @@ -14,7 +14,7 @@ Signal, ) from ophyd.status import DeviceStatus, StatusBase -from pydantic import BaseModel, validator +from pydantic import validator from pydantic.dataclasses import dataclass from dodal.devices.motors import XYZLimitBundle @@ -44,7 +44,7 @@ def is_within(self, steps): return 0 <= steps <= self.full_steps -class GridScanParamsCommon(BaseModel, AbstractExperimentWithBeamParams): +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 diff --git a/src/dodal/parameters/experiment_parameter_base.py b/src/dodal/parameters/experiment_parameter_base.py index 6e08b9e5e3..604453fd63 100644 --- a/src/dodal/parameters/experiment_parameter_base.py +++ b/src/dodal/parameters/experiment_parameter_base.py @@ -1,7 +1,9 @@ from abc import ABC, abstractmethod +from pydantic import BaseModel -class AbstractExperimentParameterBase(ABC): + +class AbstractExperimentParameterBase(BaseModel, ABC): pass diff --git a/tests/devices/system_tests/test_gridscan_system.py b/tests/devices/system_tests/test_gridscan_system.py index bf7f00a91b..4a15093ffd 100644 --- a/tests/devices/system_tests/test_gridscan_system.py +++ b/tests/devices/system_tests/test_gridscan_system.py @@ -32,7 +32,12 @@ def test_when_program_data_set_and_staged_then_expected_images_correct( fast_grid_scan: FastGridScan, ): RE = RunEngine() - RE(set_fast_grid_scan_params(fast_grid_scan, GridScanParams(x_steps=2, y_steps=2))) + RE( + set_fast_grid_scan_params( + fast_grid_scan, + GridScanParams(transmission_fraction=0.01, x_steps=2, y_steps=2), + ) + ) assert fast_grid_scan.expected_images.get() == 2 * 2 fast_grid_scan.stage() assert fast_grid_scan.position_counter.get() == 0 @@ -44,7 +49,8 @@ def test_given_valid_params_when_kickoff_then_completion_status_increases_and_fi ): def set_and_wait_plan(fast_grid_scan: FastGridScan): yield from set_fast_grid_scan_params( - fast_grid_scan, GridScanParams(x_steps=3, y_steps=3) + fast_grid_scan, + GridScanParams(transmission_fraction=0.01, x_steps=3, y_steps=3), ) yield from wait_for_fgs_valid(fast_grid_scan) diff --git a/tests/devices/unit_tests/test_gridscan.py b/tests/devices/unit_tests/test_gridscan.py index 9394856409..f3ad6a5cb2 100644 --- a/tests/devices/unit_tests/test_gridscan.py +++ b/tests/devices/unit_tests/test_gridscan.py @@ -83,7 +83,12 @@ def run_test_on_complete_watcher( RE = RunEngine() RE( set_fast_grid_scan_params( - fast_grid_scan, GridScanParams(x_steps=num_pos_1d, y_steps=num_pos_1d) + fast_grid_scan, + GridScanParams( + x_steps=num_pos_1d, + y_steps=num_pos_1d, + transmission_fraction=0.01, + ), ) ) @@ -146,7 +151,10 @@ def test_running_finished_with_all_images_done_then_complete_status_finishes_not RE = RunEngine() RE( set_fast_grid_scan_params( - fast_grid_scan, GridScanParams(x_steps=num_pos_1d, y_steps=num_pos_1d) + fast_grid_scan, + GridScanParams( + transmission_fraction=0.01, x_steps=num_pos_1d, y_steps=num_pos_1d + ), ) ) @@ -212,7 +220,9 @@ def test_within_limits_check(position, expected_in_limit): ) def test_scan_within_limits_1d(start, steps, size, expected_in_limits): motor_bundle = create_motor_bundle_with_limits(0.0, 10.0) - grid_params = GridScanParams(x_start=start, x_steps=steps, x_step_size=size) + grid_params = GridScanParams( + transmission_fraction=0.01, x_start=start, x_steps=steps, x_step_size=size + ) assert grid_params.is_valid(motor_bundle.get_xyz_limits()) == expected_in_limits @@ -229,6 +239,7 @@ def test_scan_within_limits_2d( ): motor_bundle = create_motor_bundle_with_limits(0.0, 10.0) grid_params = GridScanParams( + transmission_fraction=0.01, x_start=x_start, x_steps=x_steps, x_step_size=x_size, @@ -285,6 +296,7 @@ def test_scan_within_limits_3d( ): motor_bundle = create_motor_bundle_with_limits(0.0, 10.0) grid_params = GridScanParams( + transmission_fraction=0.01, x_start=x_start, x_steps=x_steps, x_step_size=x_size, @@ -303,6 +315,7 @@ def test_scan_within_limits_3d( @pytest.fixture def grid_scan_params(): yield GridScanParams( + transmission_fraction=0.01, x_steps=10, y_steps=15, z_steps=20, @@ -404,8 +417,14 @@ def test_given_x_y_z_steps_when_full_number_calculated_then_answer_is_as_expecte ) def test_non_test_integer_dwell_time(test_dwell_times, expected_dwell_time_is_integer): if expected_dwell_time_is_integer: - params = GridScanParams(dwell_time_ms=test_dwell_times) + params = GridScanParams( + dwell_time_ms=test_dwell_times, + transmission_fraction=0.01, + ) assert params.dwell_time_ms == test_dwell_times else: with pytest.raises(ValueError): - GridScanParams(dwell_time_ms=test_dwell_times) + GridScanParams( + dwell_time_ms=test_dwell_times, + transmission_fraction=0.01, + ) diff --git a/tests/devices/unit_tests/test_panda_gridscan.py b/tests/devices/unit_tests/test_panda_gridscan.py index c3db08245a..6338fd420a 100644 --- a/tests/devices/unit_tests/test_panda_gridscan.py +++ b/tests/devices/unit_tests/test_panda_gridscan.py @@ -67,7 +67,12 @@ def test_running_finished_with_all_images_done_then_complete_status_finishes_not RE = RunEngine() RE( set_fast_grid_scan_params( - fast_grid_scan, PandAGridScanParams(x_steps=num_pos_1d, y_steps=num_pos_1d) + fast_grid_scan, + PandAGridScanParams( + x_steps=num_pos_1d, + y_steps=num_pos_1d, + transmission_fraction=0.01, + ), ) ) From 4cd34cfbdc9e9fc3e963a0a88db36b6a79cc463a Mon Sep 17 00:00:00 2001 From: Keith Ralphs Date: Mon, 18 Mar 2024 16:18:07 +0000 Subject: [PATCH 112/134] Intial implementation of Synchrotron device (#361) * Intial implementation * Use Gather * Satisfy mypy * simplified * Add tests, convert constants to Enum * mypy * Refactoring from review cmments * Remove pin comment * Try container build fix --- pyproject.toml | 8 +- src/dodal/devices/synchrotron.py | 76 ++++++- tests/devices/unit_tests/test_synchrotron.py | 228 +++++++++++++++++++ 3 files changed, 298 insertions(+), 14 deletions(-) create mode 100644 tests/devices/unit_tests/test_synchrotron.py diff --git a/pyproject.toml b/pyproject.toml index 7443298810..1deadf9edb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ classifiers = [ description = "Ophyd devices and other utils that could be used across DLS beamlines" dependencies = [ "ophyd", - "ophyd_async@git+https://github.com/bluesky/ophyd-async@ec5729640041ee5b77b4614158793af3a34cf9d8", #Use a specific branch from ophyd async until https://github.com/bluesky/ophyd-async/pull/101 is merged + "ophyd-async@git+https://github.com/bluesky/ophyd-async", "bluesky", "pyepics", "dataclasses-json", @@ -23,9 +23,9 @@ dependencies = [ "requests", "graypy", "pydantic", - "opencv-python-headless", # For pin-tip detection. - "aioca", # Required for CA support with ophyd-async. - "p4p", # Required for PVA support with ophyd-async. + "opencv-python-headless", # For pin-tip detection. + "aioca", # Required for CA support with ophyd-async. + "p4p", # Required for PVA support with ophyd-async. "numpy", ] diff --git a/src/dodal/devices/synchrotron.py b/src/dodal/devices/synchrotron.py index cc75bcef65..19ead2c5ab 100644 --- a/src/dodal/devices/synchrotron.py +++ b/src/dodal/devices/synchrotron.py @@ -1,9 +1,26 @@ from enum import Enum from ophyd import Component, Device, EpicsSignal +from ophyd_async.core import StandardReadable +from ophyd_async.epics.signal import epics_signal_r -class SynchrotronMode(Enum): +class Prefix(str, Enum): + STATUS = "CS-CS-MSTAT-01:" + TOP_UP = "SR-CS-FILL-01:" + SIGNAL = "SR-DI-DCCT-01:" + + +class Suffix(str, Enum): + SIGNAL = "SIGNAL" + MODE = "MODE" + USER_COUNTDOWN = "USERCOUNTDN" + BEAM_ENERGY = "BEAMENERGY" + COUNTDOWN = "COUNTDOWN" + END_COUNTDOWN = "ENDCOUNTDN" + + +class SynchrotronMode(str, Enum): SHUTDOWN = "Shutdown" INJECTION = "Injection" NOBEAM = "No Beam" @@ -14,19 +31,58 @@ class SynchrotronMode(Enum): UNKNOWN = "Unknown" -class SynchrotoronMachineStatus(Device): - synchrotron_mode = Component(EpicsSignal, "MODE", string=True) - user_countdown = Component(EpicsSignal, "USERCOUNTDN") - beam_energy = Component(EpicsSignal, "BEAMENERGY") +class SynchrotronMachineStatus(Device): + synchrotron_mode = Component(EpicsSignal, Suffix.MODE, string=True) + user_countdown = Component(EpicsSignal, Suffix.USER_COUNTDOWN) + beam_energy = Component(EpicsSignal, Suffix.BEAM_ENERGY) class SynchrotronTopUp(Device): - start_countdown = Component(EpicsSignal, "COUNTDOWN") - end_countdown = Component(EpicsSignal, "ENDCOUNTDN") + start_countdown = Component(EpicsSignal, Suffix.COUNTDOWN) + end_countdown = Component(EpicsSignal, Suffix.END_COUNTDOWN) class Synchrotron(Device): - machine_status = Component(SynchrotoronMachineStatus, "CS-CS-MSTAT-01:") - top_up = Component(SynchrotronTopUp, "SR-CS-FILL-01:") + machine_status = Component(SynchrotronMachineStatus, Prefix.STATUS) + top_up = Component(SynchrotronTopUp, Prefix.TOP_UP) + ring_current = Component(EpicsSignal, Prefix.SIGNAL + Suffix.SIGNAL) + + +class OASynchrotron(StandardReadable): + def __init__( + self, + prefix: str = "", + name: str = "synchrotron", + *, + signal_prefix=Prefix.SIGNAL, + status_prefix=Prefix.STATUS, + topup_prefix=Prefix.TOP_UP, + ): + self.ring_current = epics_signal_r(float, signal_prefix + Suffix.SIGNAL) + self.synchrotron_mode = epics_signal_r( + SynchrotronMode, status_prefix + Suffix.MODE + ) + self.machine_user_countdown = epics_signal_r( + float, status_prefix + Suffix.USER_COUNTDOWN + ) + self.beam_energy = epics_signal_r(float, status_prefix + Suffix.BEAM_ENERGY) + self.topup_start_countdown = epics_signal_r( + float, topup_prefix + Suffix.COUNTDOWN + ) + self.top_up_end_countdown = epics_signal_r( + float, topup_prefix + Suffix.END_COUNTDOWN + ) - ring_current = Component(EpicsSignal, "SR-DI-DCCT-01:SIGNAL") + self.set_readable_signals( + read=[ + self.ring_current, + self.machine_user_countdown, + self.topup_start_countdown, + self.top_up_end_countdown, + ], + config=[ + self.beam_energy, + self.synchrotron_mode, + ], + ) + super().__init__(name=name) diff --git a/tests/devices/unit_tests/test_synchrotron.py b/tests/devices/unit_tests/test_synchrotron.py new file mode 100644 index 0000000000..ab65392aba --- /dev/null +++ b/tests/devices/unit_tests/test_synchrotron.py @@ -0,0 +1,228 @@ +import json +from typing import Any, Awaitable, Callable, Dict, List + +import bluesky.plan_stubs as bps +import pytest +from bluesky.run_engine import RunEngine +from ophyd_async.core import DeviceCollector, StandardReadable, set_sim_value + +from dodal.devices.synchrotron import ( + OASynchrotron, + Prefix, + Suffix, + SynchrotronMode, +) + +RING_CURRENT = 0.556677 +USER_COUNTDOWN = 55.0 +START_COUNTDOWN = 66.0 +END_COUNTDOWN = 77.0 +BEAM_ENERGY = 3.0158 +MODE = SynchrotronMode.INJECTION +NUMBER = "number" +STRING = "string" +EMPTY_LIST: List = [] + +READINGS = [RING_CURRENT, USER_COUNTDOWN, START_COUNTDOWN, END_COUNTDOWN] +CONFIGS = [BEAM_ENERGY, MODE.value] + +READING_FIELDS = ["value", "alarm_severity"] +DESCRIPTION_FIELDS = ["source", "dtype", "shape"] +READING_ADDRESSES = [ + f"sim://{Prefix.SIGNAL + Suffix.SIGNAL}", + f"sim://{Prefix.STATUS + Suffix.USER_COUNTDOWN}", + f"sim://{Prefix.TOP_UP + Suffix.COUNTDOWN}", + f"sim://{Prefix.TOP_UP + Suffix.END_COUNTDOWN}", +] + +CONFIG_ADDRESSES = [ + f"sim://{Prefix.STATUS + Suffix.BEAM_ENERGY}", + f"sim://{Prefix.STATUS + Suffix.MODE}", +] + +READ_SIGNALS = [ + "synchrotron-ring_current", + "synchrotron-machine_user_countdown", + "synchrotron-topup_start_countdown", + "synchrotron-top_up_end_countdown", +] + +CONFIG_SIGNALS = [ + "synchrotron-beam_energy", + "synchrotron-synchrotron_mode", +] + +EXPECTED_READ_RESULT = f"""{{ + "{READ_SIGNALS[0]}": {{ + "{READING_FIELDS[0]}": {READINGS[0]}, + "{READING_FIELDS[1]}": 0 + }}, + "{READ_SIGNALS[1]}": {{ + "{READING_FIELDS[0]}": {READINGS[1]}, + "{READING_FIELDS[1]}": 0 + }}, + "{READ_SIGNALS[2]}": {{ + "{READING_FIELDS[0]}": {READINGS[2]}, + "{READING_FIELDS[1]}": 0 + }}, + "{READ_SIGNALS[3]}": {{ + "{READING_FIELDS[0]}": {READINGS[3]}, + "{READING_FIELDS[1]}": 0 + }} +}}""" + +EXPECTED_READ_CONFIG_RESULT = f"""{{ + "{CONFIG_SIGNALS[0]}":{{ + "{READING_FIELDS[0]}": {CONFIGS[0]}, + "{READING_FIELDS[1]}": 0 + }}, + "{CONFIG_SIGNALS[1]}":{{ + "{READING_FIELDS[0]}": "{CONFIGS[1]}", + "{READING_FIELDS[1]}": 0 + }} +}}""" + +EXPECTED_DESCRIBE_RESULT = f"""{{ + "{READ_SIGNALS[0]}":{{ + "{DESCRIPTION_FIELDS[0]}": "{READING_ADDRESSES[0]}", + "{DESCRIPTION_FIELDS[1]}": "{NUMBER}", + "{DESCRIPTION_FIELDS[2]}": {EMPTY_LIST} + }}, + "{READ_SIGNALS[1]}":{{ + "{DESCRIPTION_FIELDS[0]}": "{READING_ADDRESSES[1]}", + "{DESCRIPTION_FIELDS[1]}": "{NUMBER}", + "{DESCRIPTION_FIELDS[2]}": {EMPTY_LIST} + }}, + "{READ_SIGNALS[2]}":{{ + "{DESCRIPTION_FIELDS[0]}": "{READING_ADDRESSES[2]}", + "{DESCRIPTION_FIELDS[1]}": "{NUMBER}", + "{DESCRIPTION_FIELDS[2]}": {EMPTY_LIST} + }}, + "{READ_SIGNALS[3]}":{{ + "{DESCRIPTION_FIELDS[0]}": "{READING_ADDRESSES[3]}", + "{DESCRIPTION_FIELDS[1]}": "{NUMBER}", + "{DESCRIPTION_FIELDS[2]}": {EMPTY_LIST} + }} +}}""" + +EXPECTED_DESCRIBE_CONFIG_RESULT = f"""{{ + "{CONFIG_SIGNALS[0]}":{{ + "{DESCRIPTION_FIELDS[0]}": "{CONFIG_ADDRESSES[0]}", + "{DESCRIPTION_FIELDS[1]}": "{NUMBER}", + "{DESCRIPTION_FIELDS[2]}": {EMPTY_LIST} + }}, + "{CONFIG_SIGNALS[1]}":{{ + "{DESCRIPTION_FIELDS[0]}": "{CONFIG_ADDRESSES[1]}", + "{DESCRIPTION_FIELDS[1]}": "{STRING}", + "{DESCRIPTION_FIELDS[2]}": {EMPTY_LIST} + }} +}}""" + + +@pytest.fixture +async def sim_synchrotron() -> OASynchrotron: + async with DeviceCollector(sim=True): + sim_synchrotron = OASynchrotron() + set_sim_value(sim_synchrotron.ring_current, RING_CURRENT) + set_sim_value(sim_synchrotron.machine_user_countdown, USER_COUNTDOWN) + set_sim_value(sim_synchrotron.topup_start_countdown, START_COUNTDOWN) + set_sim_value(sim_synchrotron.top_up_end_countdown, END_COUNTDOWN) + set_sim_value(sim_synchrotron.beam_energy, BEAM_ENERGY) + set_sim_value(sim_synchrotron.synchrotron_mode, MODE) + return sim_synchrotron + + +async def test_oasynchrotron_read(sim_synchrotron: OASynchrotron): + await verify( + sim_synchrotron.read, + READ_SIGNALS, + READING_FIELDS, + EXPECTED_READ_RESULT, + ) + + +async def test_oasynchrotron_read_configuration(sim_synchrotron: OASynchrotron): + await verify( + sim_synchrotron.read_configuration, + CONFIG_SIGNALS, + READING_FIELDS, + EXPECTED_READ_CONFIG_RESULT, + ) + + +async def test_oasynchrotron_describe(sim_synchrotron: OASynchrotron): + await verify( + sim_synchrotron.describe, + READ_SIGNALS, + DESCRIPTION_FIELDS, + EXPECTED_DESCRIBE_RESULT, + ) + + +async def test_oasynchrotron_describe_configuration(sim_synchrotron: OASynchrotron): + await verify( + sim_synchrotron.describe_configuration, + CONFIG_SIGNALS, + DESCRIPTION_FIELDS, + EXPECTED_DESCRIBE_CONFIG_RESULT, + ) + + +async def test_oasynchrotron_count(RE: RunEngine, sim_synchrotron: OASynchrotron): + docs = [] + RE(count_sim(sim_synchrotron), lambda x, y: docs.append(y)) + + assert len(docs) == 4 + assert sim_synchrotron.name in docs[1]["configuration"] + cfg_data_keys = docs[1]["configuration"][sim_synchrotron.name]["data_keys"] + for sig, addr in zip(CONFIG_SIGNALS, CONFIG_ADDRESSES): + assert sig in cfg_data_keys + dtype = NUMBER if sig == CONFIG_SIGNALS[0] else STRING + assert cfg_data_keys[sig][DESCRIPTION_FIELDS[0]] == addr + assert cfg_data_keys[sig][DESCRIPTION_FIELDS[1]] == dtype + assert cfg_data_keys[sig][DESCRIPTION_FIELDS[2]] == EMPTY_LIST + cfg_data = docs[1]["configuration"][sim_synchrotron.name]["data"] + for sig, value in zip(CONFIG_SIGNALS, CONFIGS): + assert cfg_data[sig] == value + data_keys = docs[1]["data_keys"] + for sig, addr in zip(READ_SIGNALS, READING_ADDRESSES): + assert sig in data_keys + assert data_keys[sig][DESCRIPTION_FIELDS[0]] == addr + assert data_keys[sig][DESCRIPTION_FIELDS[1]] == NUMBER + assert data_keys[sig][DESCRIPTION_FIELDS[2]] == EMPTY_LIST + + data = docs[2]["data"] + assert len(data) == len(READ_SIGNALS) + for sig, value in zip(READ_SIGNALS, READINGS): + assert sig in data + assert data[sig] == value + + +async def verify( + func: Callable[[], Awaitable[Dict[str, Any]]], + signals: List[str], + fields: List[str], + expectation: str, +): + expected = json.loads(expectation) + result = await func() + + for signal in signals: + for field in fields: + assert result[signal][field] == expected[signal][field] + + +def count_sim(det: StandardReadable, times: int = 1): + """Test plan to do equivalent of bp.count for a sim detector (no file writing).""" + + yield from bps.stage_all(det) + yield from bps.open_run() + yield from bps.declare_stream(det, name="primary", collect=False) + for _ in range(times): + yield from bps.wait(group="wait_for_trigger") + yield from bps.create() + yield from bps.read(det) + yield from bps.save() + + yield from bps.close_run() + yield from bps.unstage_all(det) From ef5b61efc371694ee8b7e2a9f80212d39cd9c60f Mon Sep 17 00:00:00 2001 From: Noemi Frisina Date: Mon, 18 Mar 2024 16:50:18 +0000 Subject: [PATCH 113/134] Fix types and enums --- src/dodal/devices/zebra.py | 41 +++++++++++++------------- tests/devices/unit_tests/test_zebra.py | 2 +- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/src/dodal/devices/zebra.py b/src/dodal/devices/zebra.py index c430bdf47a..e5859f4399 100644 --- a/src/dodal/devices/zebra.py +++ b/src/dodal/devices/zebra.py @@ -49,6 +49,7 @@ class EncEnum(str, Enum): Enc2 = "Enc2" Enc3 = "Enc3" Enc4 = "Enc4" + Enc1_4Av = "Enc1-4Av" class I03Axes: @@ -65,12 +66,12 @@ class I24Axes: VGON_YH = EncEnum.Enc4 -class RotationDirection(IntEnum): - POSITIVE = 1 - NEGATIVE = -1 +class RotationDirection(str, Enum): + POSITIVE = "Positive" + NEGATIVE = "Negative" -class ArmDemand(IntEnum): +class ArmDemand(Enum): ARM = 1 DISARM = 0 @@ -80,9 +81,9 @@ class FastShutterAction(IntEnum): CLOSE = 0 -class SoftInState(IntEnum): - YES = 1 - NO = 0 +class SoftInState(str, Enum): + YES = "Yes" + NO = "No" class ArmingDevice(StandardReadable): @@ -92,13 +93,13 @@ class ArmingDevice(StandardReadable): TIMEOUT = 3 def __init__(self, prefix: str, name: str = "") -> None: - self.arm_set = epics_signal_rw(int, prefix + "PC_ARM") - self.disarm_set = epics_signal_rw(int, prefix + "PC_DISARM") - self.armed = epics_signal_rw(ArmDemand, prefix + "PC_ARM_OUT") + self.arm_set = epics_signal_rw(float, prefix + "PC_ARM") + self.disarm_set = epics_signal_rw(float, prefix + "PC_DISARM") + self.armed = epics_signal_rw(float, prefix + "PC_ARM_OUT") super().__init__(name) async def _set_armed(self, demand: ArmDemand): - await self.armed.set(demand) + await self.armed.set(demand.value) signal_to_set = self.arm_set if demand == ArmDemand.ARM else self.disarm_set await signal_to_set.set(1) @@ -110,22 +111,22 @@ def set(self, demand: ArmDemand) -> AsyncStatus: class PositionCompare(StandardReadable): def __init__(self, prefix: str, name: str = "") -> None: - self.num_gates = epics_signal_rw(int, prefix + "PC_GATE_NGATE") - self.gate_trigger = epics_signal_rw(str, prefix + "PC_ENC") + self.num_gates = epics_signal_rw(float, prefix + "PC_GATE_NGATE") + self.gate_trigger = epics_signal_rw(EncEnum, prefix + "PC_ENC") self.gate_source = epics_signal_rw(TrigSource, prefix + "PC_GATE_SEL") - self.gate_input = epics_signal_rw(int, prefix + "PC_GATE_INP") + self.gate_input = epics_signal_rw(float, prefix + "PC_GATE_INP") self.gate_width = epics_signal_rw(float, prefix + "PC_GATE_WID") self.gate_start = epics_signal_rw(float, prefix + "PC_GATE_START") self.gate_step = epics_signal_rw(float, prefix + "PC_GATE_STEP") self.pulse_source = epics_signal_rw(TrigSource, prefix + "PC_PULSE_SEL") - self.pulse_input = epics_signal_rw(int, prefix + "PC_PULSE_INP") + self.pulse_input = epics_signal_rw(float, prefix + "PC_PULSE_INP") self.pulse_start = epics_signal_rw(float, prefix + "PC_PULSE_START") self.pulse_width = epics_signal_rw(float, prefix + "PC_PULSE_WID") self.pulse_step = epics_signal_rw(float, prefix + "PC_PULSE_STEP") - self.pulse_max = epics_signal_rw(int, prefix + "PC_PULSE_MAX") + self.pulse_max = epics_signal_rw(float, prefix + "PC_PULSE_MAX") - self.dir = epics_signal_rw(int, prefix + "PC_DIR") + self.dir = epics_signal_rw(RotationDirection, prefix + "PC_DIR") self.arm_source = epics_signal_rw(ArmSource, prefix + "PC_ARM_SEL") self.reset = epics_signal_rw(int, prefix + "SYS_RESET.PROC") @@ -141,7 +142,7 @@ class PulseOutput(StandardReadable): """Zebra pulse output panel.""" def __init__(self, prefix: str, name: str = "") -> None: - self.input = epics_signal_rw(int, prefix + "_INP") + self.input = epics_signal_rw(float, prefix + "_INP") self.delay = epics_signal_rw(float, prefix + "_DLY") self.width = epics_signal_rw(float, prefix + "_WID") super().__init__(name) @@ -153,7 +154,7 @@ def __init__(self, prefix: str, name: str = "") -> None: self.pulse2 = PulseOutput(prefix + "PULSE2") self.out_pvs: DeviceVector[SignalRW] = DeviceVector( - {i: epics_signal_rw(int, prefix + f"OUT{i}_TTL") for i in range(1, 5)} + {i: epics_signal_rw(float, prefix + f"OUT{i}_TTL") for i in range(1, 5)} ) super().__init__(name) @@ -175,7 +176,7 @@ class GateControl(StandardReadable): def __init__(self, prefix: str, name: str = "") -> None: self.enable = epics_signal_rw(int, prefix + "_ENA") self.sources = DeviceVector( - {i: epics_signal_rw(int, prefix + f"_INP{i}") for i in range(1, 5)} + {i: epics_signal_rw(float, prefix + f"_INP{i}") for i in range(1, 5)} ) self.invert = epics_signal_rw(int, prefix + "_INV") super().__init__(name) diff --git a/tests/devices/unit_tests/test_zebra.py b/tests/devices/unit_tests/test_zebra.py index e5ed9663fc..11df062527 100644 --- a/tests/devices/unit_tests/test_zebra.py +++ b/tests/devices/unit_tests/test_zebra.py @@ -32,7 +32,7 @@ async def test_position_compare_sets_signals(): await fake_pc.connect(sim=True) fake_pc.gate_source.set(TrigSource.EXTERNAL) - fake_pc.gate_trigger.set(I03Axes.OMEGA.value) + fake_pc.gate_trigger.set(I03Axes.OMEGA) fake_pc.num_gates.set(10) assert await fake_pc.gate_source.get_value() == "External" From 9c23530a47ad6c69b37f1480cb053fbf27b528fe Mon Sep 17 00:00:00 2001 From: Noemi Frisina Date: Mon, 18 Mar 2024 17:00:28 +0000 Subject: [PATCH 114/134] Remove unused enum --- src/dodal/devices/zebra.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/dodal/devices/zebra.py b/src/dodal/devices/zebra.py index e5859f4399..895521bab7 100644 --- a/src/dodal/devices/zebra.py +++ b/src/dodal/devices/zebra.py @@ -1,7 +1,7 @@ from __future__ import annotations import asyncio -from enum import Enum, IntEnum +from enum import Enum from functools import partialmethod from typing import List @@ -76,11 +76,6 @@ class ArmDemand(Enum): DISARM = 0 -class FastShutterAction(IntEnum): - OPEN = 1 - CLOSE = 0 - - class SoftInState(str, Enum): YES = "Yes" NO = "No" From 11762077dfc804cedea45ca0c1b6e471773225c2 Mon Sep 17 00:00:00 2001 From: Noemi Frisina Date: Tue, 19 Mar 2024 11:19:49 +0000 Subject: [PATCH 115/134] Fix typo --- src/dodal/devices/zebra.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/dodal/devices/zebra.py b/src/dodal/devices/zebra.py index 895521bab7..4b171ba15c 100644 --- a/src/dodal/devices/zebra.py +++ b/src/dodal/devices/zebra.py @@ -145,8 +145,8 @@ def __init__(self, prefix: str, name: str = "") -> None: class ZebraOutputPanel(StandardReadable): def __init__(self, prefix: str, name: str = "") -> None: - self.pulse1 = PulseOutput(prefix + "PULSE1") - self.pulse2 = PulseOutput(prefix + "PULSE2") + self.pulse_1 = PulseOutput(prefix + "PULSE1") + self.pulse_2 = PulseOutput(prefix + "PULSE2") self.out_pvs: DeviceVector[SignalRW] = DeviceVector( {i: epics_signal_rw(float, prefix + f"OUT{i}_TTL") for i in range(1, 5)} From 00b2c29853d90980d49828f9189174b1cd0dfea8 Mon Sep 17 00:00:00 2001 From: Kate Smith Date: Tue, 19 Mar 2024 16:51:19 +0000 Subject: [PATCH 116/134] Add aperture position readbacks #385 --- src/dodal/devices/aperture.py | 5 +- src/dodal/devices/aperturescatterguard.py | 29 ++++++------ .../unit_tests/test_aperture_scatterguard.py | 47 ++++++++++++------- 3 files changed, 50 insertions(+), 31 deletions(-) diff --git a/src/dodal/devices/aperture.py b/src/dodal/devices/aperture.py index d66173f5cd..fa2b0d1d8d 100644 --- a/src/dodal/devices/aperture.py +++ b/src/dodal/devices/aperture.py @@ -1,4 +1,4 @@ -from ophyd import Component, Device +from ophyd import Component, Device, EpicsSignalRO from dodal.devices.util.motor_utils import ExtendedEpicsMotor @@ -7,3 +7,6 @@ class Aperture(Device): x = Component(ExtendedEpicsMotor, "X") y = Component(ExtendedEpicsMotor, "Y") z = Component(ExtendedEpicsMotor, "Z") + small = Component(EpicsSignalRO, "Y:SMALL_CALC") + medium = Component(EpicsSignalRO, "Y:MEDIUM_CALC") + large = Component(EpicsSignalRO, "Y:LARGE_CALC") diff --git a/src/dodal/devices/aperturescatterguard.py b/src/dodal/devices/aperturescatterguard.py index bd41dc4393..9246f9c74f 100644 --- a/src/dodal/devices/aperturescatterguard.py +++ b/src/dodal/devices/aperturescatterguard.py @@ -4,7 +4,6 @@ from functools import reduce from typing import List, Optional, Sequence -import numpy as np from ophyd import Component as Cpt from ophyd import SignalRO from ophyd.epics_motor import EpicsMotor @@ -89,11 +88,12 @@ class ApertureScatterguard(InfoLoggingDevice): scatterguard = Cpt(Scatterguard, "-MO-SCAT-01:") aperture_positions: Optional[AperturePositions] = None TOLERANCE_STEPS = 3 # Number of MRES steps + ROBOT_LOAD_Y = 35.0 # Below this in Y we assume to robot load class SelectedAperture(SignalRO): def get(self): assert isinstance(self.parent, ApertureScatterguard) - return self.parent._get_closest_position_to_current() + return self.parent._get_current_aperture_position() selected_aperture = Cpt(SelectedAperture) @@ -123,22 +123,23 @@ def _set_raw_unsafe(self, positions: ApertureFiveDimensionalLocation) -> AndStat operator.and_, [motor.set(pos) for motor, pos in zip(motors, positions)] ) - def _get_closest_position_to_current(self) -> SingleAperturePosition: + def _get_current_aperture_position(self) -> SingleAperturePosition: """ - Returns the closest valid position to current position within {TOLERANCE_STEPS}. + Returns the closest valid position to current position using readback values + for SMALL, MEDIUM, LARGE. ROBOT_LOAD position defined when mini aperture y <= ROBOT_LOAD_Y. If no position is found then raises InvalidApertureMove. """ assert isinstance(self.aperture_positions, AperturePositions) - for aperture in self.aperture_positions.as_list(): - aperture_in_tolerence = [] - motors = self._get_motor_list() - for motor, test_position in zip(motors, list(aperture.location)): - current_position = motor.user_readback.get() - tolerance = self.TOLERANCE_STEPS * motor.motor_resolution.get() - diff = abs(current_position - test_position) - aperture_in_tolerence.append(diff <= tolerance) - if np.all(aperture_in_tolerence): - return aperture + current_ap_y = float(self.aperture.y.user_readback.get()) + + if int(self.aperture.large.get()) == 1: + return self.aperture_positions.LARGE + elif int(self.aperture.medium.get()) == 1: + return self.aperture_positions.MEDIUM + elif int(self.aperture.small.get()) == 1: + return self.aperture_positions.SMALL + elif current_ap_y <= self.ROBOT_LOAD_Y: + return self.aperture_positions.ROBOT_LOAD raise InvalidApertureMove("Current aperture/scatterguard state unrecognised") diff --git a/tests/devices/unit_tests/test_aperture_scatterguard.py b/tests/devices/unit_tests/test_aperture_scatterguard.py index 76f1010b30..bee8cdb7be 100644 --- a/tests/devices/unit_tests/test_aperture_scatterguard.py +++ b/tests/devices/unit_tests/test_aperture_scatterguard.py @@ -41,6 +41,7 @@ def aperture_in_medium_pos( ap_sg.aperture.z.set(medium.aperture_z) ap_sg.scatterguard.x.set(medium.scatterguard_x) ap_sg.scatterguard.y.set(medium.scatterguard_y) + ap_sg.aperture.medium.sim_put(1) # type: ignore yield ap_sg @@ -175,32 +176,46 @@ def set_underlying_motors( ap_sg.scatterguard.y.set(position.scatterguard_y) -def test_aperture_positions_get_close_position_truthy_exact( +def test_aperture_positions_large( ap_sg: ApertureScatterguard, aperture_positions: AperturePositions ): - should_be_large = ApertureFiveDimensionalLocation(2.389, 40.986, 15.8, 5.25, 4.43) - set_underlying_motors(ap_sg, should_be_large) + ap_sg.aperture.large.sim_put(1) # type: ignore + assert ap_sg._get_current_aperture_position() == aperture_positions.LARGE - assert ap_sg._get_closest_position_to_current() == aperture_positions.LARGE + +def test_aperture_positions_medium( + ap_sg: ApertureScatterguard, aperture_positions: AperturePositions +): + ap_sg.aperture.medium.sim_put(1) # type: ignore + assert ap_sg._get_current_aperture_position() == aperture_positions.MEDIUM -def test_aperture_positions_get_close_position_truthy_inside_tolerance( +def test_aperture_positions_small( ap_sg: ApertureScatterguard, aperture_positions: AperturePositions ): - should_be_large = ApertureFiveDimensionalLocation(2.389, 40.9865, 15.8, 5.25, 4.43) - set_underlying_motors(ap_sg, should_be_large) - assert ap_sg._get_closest_position_to_current() == aperture_positions.LARGE + ap_sg.aperture.small.sim_put(1) # type: ignore + assert ap_sg._get_current_aperture_position() == aperture_positions.SMALL -def test_aperture_positions_get_close_position_falsy( +def test_aperture_positions_robot_load( ap_sg: ApertureScatterguard, aperture_positions: AperturePositions ): - large_missed_by_2_at_y = ApertureFiveDimensionalLocation( - 2.389, 42, 15.8, 5.25, 4.43 - ) - set_underlying_motors(ap_sg, large_missed_by_2_at_y) + ap_sg.aperture.large.sim_put(0) # type: ignore + ap_sg.aperture.medium.sim_put(0) # type: ignore + ap_sg.aperture.small.sim_put(0) # type: ignore + ap_sg.aperture.y.set(34.0) # type: ignore + assert ap_sg._get_current_aperture_position() == aperture_positions.ROBOT_LOAD + + +def test_aperture_positions_robot_load_unsafe( + ap_sg: ApertureScatterguard, aperture_positions: AperturePositions +): + ap_sg.aperture.large.sim_put(0) # type: ignore + ap_sg.aperture.medium.sim_put(0) # type: ignore + ap_sg.aperture.small.sim_put(0) # type: ignore + ap_sg.aperture.y.set(50.0) # type: ignore with pytest.raises(InvalidApertureMove): - ap_sg._get_closest_position_to_current() + ap_sg._get_current_aperture_position() def test_given_aperture_not_set_through_device_but_motors_in_position_when_device_read_then_position_returned( @@ -216,12 +231,12 @@ def test_given_aperture_not_set_through_device_but_motors_in_position_when_devic def test_when_aperture_set_and_device_read_then_position_returned( aperture_in_medium_pos: ApertureScatterguard, aperture_positions: AperturePositions ): - set_status = aperture_in_medium_pos.set(aperture_positions.SMALL) + set_status = aperture_in_medium_pos.set(aperture_positions.MEDIUM) set_status.wait() selected_aperture = aperture_in_medium_pos.read() assert ( selected_aperture["test_ap_sg_selected_aperture"]["value"] - == aperture_positions.SMALL + == aperture_positions.MEDIUM ) From a45a6f21d292cfa91ac9e82f7a93c58d7d1bffec Mon Sep 17 00:00:00 2001 From: Kate Smith Date: Wed, 20 Mar 2024 12:01:17 +0000 Subject: [PATCH 117/134] 385 add robot load position and tolerance --- src/dodal/devices/aperturescatterguard.py | 10 ++++++---- .../devices/unit_tests/test_aperture_scatterguard.py | 12 +++++++++++- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/dodal/devices/aperturescatterguard.py b/src/dodal/devices/aperturescatterguard.py index 9246f9c74f..54be18f08d 100644 --- a/src/dodal/devices/aperturescatterguard.py +++ b/src/dodal/devices/aperturescatterguard.py @@ -88,7 +88,8 @@ class ApertureScatterguard(InfoLoggingDevice): scatterguard = Cpt(Scatterguard, "-MO-SCAT-01:") aperture_positions: Optional[AperturePositions] = None TOLERANCE_STEPS = 3 # Number of MRES steps - ROBOT_LOAD_Y = 35.0 # Below this in Y we assume to robot load + ROBOT_LOAD_TOLERANCE = 5 # Number of mm from ROBOT_LOAD_Y + ROBOT_LOAD_Y = 31.4 # Robot load position in mm class SelectedAperture(SignalRO): def get(self): @@ -125,8 +126,9 @@ def _set_raw_unsafe(self, positions: ApertureFiveDimensionalLocation) -> AndStat def _get_current_aperture_position(self) -> SingleAperturePosition: """ - Returns the closest valid position to current position using readback values - for SMALL, MEDIUM, LARGE. ROBOT_LOAD position defined when mini aperture y <= ROBOT_LOAD_Y. + Returns the current aperture position using readback values + for SMALL, MEDIUM, LARGE. ROBOT_LOAD position defined when + mini aperture y <= ROBOT_LOAD_Y + ROBOT_LOAD_TOLERANCE. If no position is found then raises InvalidApertureMove. """ assert isinstance(self.aperture_positions, AperturePositions) @@ -138,7 +140,7 @@ def _get_current_aperture_position(self) -> SingleAperturePosition: return self.aperture_positions.MEDIUM elif int(self.aperture.small.get()) == 1: return self.aperture_positions.SMALL - elif current_ap_y <= self.ROBOT_LOAD_Y: + elif current_ap_y <= self.ROBOT_LOAD_Y + self.ROBOT_LOAD_TOLERANCE: return self.aperture_positions.ROBOT_LOAD raise InvalidApertureMove("Current aperture/scatterguard state unrecognised") diff --git a/tests/devices/unit_tests/test_aperture_scatterguard.py b/tests/devices/unit_tests/test_aperture_scatterguard.py index bee8cdb7be..a2b2863176 100644 --- a/tests/devices/unit_tests/test_aperture_scatterguard.py +++ b/tests/devices/unit_tests/test_aperture_scatterguard.py @@ -203,7 +203,17 @@ def test_aperture_positions_robot_load( ap_sg.aperture.large.sim_put(0) # type: ignore ap_sg.aperture.medium.sim_put(0) # type: ignore ap_sg.aperture.small.sim_put(0) # type: ignore - ap_sg.aperture.y.set(34.0) # type: ignore + ap_sg.aperture.y.set(31.4) # type: ignore + assert ap_sg._get_current_aperture_position() == aperture_positions.ROBOT_LOAD + + +def test_aperture_positions_robot_load_within_tolerance( + ap_sg: ApertureScatterguard, aperture_positions: AperturePositions +): + ap_sg.aperture.large.sim_put(0) # type: ignore + ap_sg.aperture.medium.sim_put(0) # type: ignore + ap_sg.aperture.small.sim_put(0) # type: ignore + ap_sg.aperture.y.set(33.4) # type: ignore assert ap_sg._get_current_aperture_position() == aperture_positions.ROBOT_LOAD From 6a9ed0bb89c196f20b3c2a1bcf9672bdaca2afee Mon Sep 17 00:00:00 2001 From: Kate Smith Date: Wed, 20 Mar 2024 13:35:11 +0000 Subject: [PATCH 118/134] #385 abstracted robot load to config and added tolerance test --- src/dodal/devices/aperturescatterguard.py | 9 ++++----- .../unit_tests/test_aperture_scatterguard.py | 13 ++++++++++++- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/dodal/devices/aperturescatterguard.py b/src/dodal/devices/aperturescatterguard.py index 54be18f08d..5f9f97f2c0 100644 --- a/src/dodal/devices/aperturescatterguard.py +++ b/src/dodal/devices/aperturescatterguard.py @@ -88,8 +88,6 @@ class ApertureScatterguard(InfoLoggingDevice): scatterguard = Cpt(Scatterguard, "-MO-SCAT-01:") aperture_positions: Optional[AperturePositions] = None TOLERANCE_STEPS = 3 # Number of MRES steps - ROBOT_LOAD_TOLERANCE = 5 # Number of mm from ROBOT_LOAD_Y - ROBOT_LOAD_Y = 31.4 # Robot load position in mm class SelectedAperture(SignalRO): def get(self): @@ -128,19 +126,20 @@ def _get_current_aperture_position(self) -> SingleAperturePosition: """ Returns the current aperture position using readback values for SMALL, MEDIUM, LARGE. ROBOT_LOAD position defined when - mini aperture y <= ROBOT_LOAD_Y + ROBOT_LOAD_TOLERANCE. + mini aperture y <= ROBOT_LOAD.location.aperture_y + tolerance. If no position is found then raises InvalidApertureMove. """ assert isinstance(self.aperture_positions, AperturePositions) current_ap_y = float(self.aperture.y.user_readback.get()) - + robot_load_ap_y = self.aperture_positions.ROBOT_LOAD.location.aperture_y + tolerance = self.TOLERANCE_STEPS * self.aperture.y.motor_resolution.get() if int(self.aperture.large.get()) == 1: return self.aperture_positions.LARGE elif int(self.aperture.medium.get()) == 1: return self.aperture_positions.MEDIUM elif int(self.aperture.small.get()) == 1: return self.aperture_positions.SMALL - elif current_ap_y <= self.ROBOT_LOAD_Y + self.ROBOT_LOAD_TOLERANCE: + elif current_ap_y <= +robot_load_ap_y + tolerance: return self.aperture_positions.ROBOT_LOAD raise InvalidApertureMove("Current aperture/scatterguard state unrecognised") diff --git a/tests/devices/unit_tests/test_aperture_scatterguard.py b/tests/devices/unit_tests/test_aperture_scatterguard.py index a2b2863176..d00d85b3c5 100644 --- a/tests/devices/unit_tests/test_aperture_scatterguard.py +++ b/tests/devices/unit_tests/test_aperture_scatterguard.py @@ -213,10 +213,21 @@ def test_aperture_positions_robot_load_within_tolerance( ap_sg.aperture.large.sim_put(0) # type: ignore ap_sg.aperture.medium.sim_put(0) # type: ignore ap_sg.aperture.small.sim_put(0) # type: ignore - ap_sg.aperture.y.set(33.4) # type: ignore + ap_sg.aperture.y.set(31.403) # type: ignore assert ap_sg._get_current_aperture_position() == aperture_positions.ROBOT_LOAD +def test_aperture_positions_robot_load_outside_tolerance( + ap_sg: ApertureScatterguard, aperture_positions: AperturePositions +): + ap_sg.aperture.large.sim_put(0) # type: ignore + ap_sg.aperture.medium.sim_put(0) # type: ignore + ap_sg.aperture.small.sim_put(0) # type: ignore + ap_sg.aperture.y.set(31.404) # type: ignore + with pytest.raises(InvalidApertureMove): + ap_sg._get_current_aperture_position() + + def test_aperture_positions_robot_load_unsafe( ap_sg: ApertureScatterguard, aperture_positions: AperturePositions ): From 39357f2cf2301a096f6915f4447e37ceb578d80f Mon Sep 17 00:00:00 2001 From: Kate Smith Date: Wed, 20 Mar 2024 13:49:17 +0000 Subject: [PATCH 119/134] Use abstraction instead of hard coded 31.4 for robot load tests --- tests/devices/unit_tests/test_aperture_scatterguard.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/devices/unit_tests/test_aperture_scatterguard.py b/tests/devices/unit_tests/test_aperture_scatterguard.py index d00d85b3c5..7c4a2d2407 100644 --- a/tests/devices/unit_tests/test_aperture_scatterguard.py +++ b/tests/devices/unit_tests/test_aperture_scatterguard.py @@ -203,7 +203,7 @@ def test_aperture_positions_robot_load( ap_sg.aperture.large.sim_put(0) # type: ignore ap_sg.aperture.medium.sim_put(0) # type: ignore ap_sg.aperture.small.sim_put(0) # type: ignore - ap_sg.aperture.y.set(31.4) # type: ignore + ap_sg.aperture.y.set(aperture_positions.ROBOT_LOAD.location.aperture_y) # type: ignore assert ap_sg._get_current_aperture_position() == aperture_positions.ROBOT_LOAD @@ -213,7 +213,7 @@ def test_aperture_positions_robot_load_within_tolerance( ap_sg.aperture.large.sim_put(0) # type: ignore ap_sg.aperture.medium.sim_put(0) # type: ignore ap_sg.aperture.small.sim_put(0) # type: ignore - ap_sg.aperture.y.set(31.403) # type: ignore + ap_sg.aperture.y.set(aperture_positions.ROBOT_LOAD.location.aperture_y + 0.003) # type: ignore assert ap_sg._get_current_aperture_position() == aperture_positions.ROBOT_LOAD @@ -223,7 +223,7 @@ def test_aperture_positions_robot_load_outside_tolerance( ap_sg.aperture.large.sim_put(0) # type: ignore ap_sg.aperture.medium.sim_put(0) # type: ignore ap_sg.aperture.small.sim_put(0) # type: ignore - ap_sg.aperture.y.set(31.404) # type: ignore + ap_sg.aperture.y.set(aperture_positions.ROBOT_LOAD.location.aperture_y + 0.004) # type: ignore with pytest.raises(InvalidApertureMove): ap_sg._get_current_aperture_position() From 67b7152145c31de4f82d700062acb531ddc3b58a Mon Sep 17 00:00:00 2001 From: Kate Smith Date: Wed, 20 Mar 2024 13:57:00 +0000 Subject: [PATCH 120/134] Remove hard coded values from robot load tests --- tests/devices/unit_tests/test_aperture_scatterguard.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/devices/unit_tests/test_aperture_scatterguard.py b/tests/devices/unit_tests/test_aperture_scatterguard.py index 7c4a2d2407..b119571062 100644 --- a/tests/devices/unit_tests/test_aperture_scatterguard.py +++ b/tests/devices/unit_tests/test_aperture_scatterguard.py @@ -210,20 +210,24 @@ def test_aperture_positions_robot_load( def test_aperture_positions_robot_load_within_tolerance( ap_sg: ApertureScatterguard, aperture_positions: AperturePositions ): + robot_load_ap_y = aperture_positions.ROBOT_LOAD.location.aperture_y + tolerance = ap_sg.TOLERANCE_STEPS * ap_sg.aperture.y.motor_resolution.get() ap_sg.aperture.large.sim_put(0) # type: ignore ap_sg.aperture.medium.sim_put(0) # type: ignore ap_sg.aperture.small.sim_put(0) # type: ignore - ap_sg.aperture.y.set(aperture_positions.ROBOT_LOAD.location.aperture_y + 0.003) # type: ignore + ap_sg.aperture.y.set(robot_load_ap_y + tolerance) # type: ignore assert ap_sg._get_current_aperture_position() == aperture_positions.ROBOT_LOAD def test_aperture_positions_robot_load_outside_tolerance( ap_sg: ApertureScatterguard, aperture_positions: AperturePositions ): + robot_load_ap_y = aperture_positions.ROBOT_LOAD.location.aperture_y + tolerance = (ap_sg.TOLERANCE_STEPS + 1) * ap_sg.aperture.y.motor_resolution.get() ap_sg.aperture.large.sim_put(0) # type: ignore ap_sg.aperture.medium.sim_put(0) # type: ignore ap_sg.aperture.small.sim_put(0) # type: ignore - ap_sg.aperture.y.set(aperture_positions.ROBOT_LOAD.location.aperture_y + 0.004) # type: ignore + ap_sg.aperture.y.set(robot_load_ap_y + tolerance) # type: ignore with pytest.raises(InvalidApertureMove): ap_sg._get_current_aperture_position() From 2b45300af42689d9a00ee7c15ea81d1cecd6a59f Mon Sep 17 00:00:00 2001 From: Kate Smith <51903787+katesmith280@users.noreply.github.com> Date: Wed, 20 Mar 2024 14:12:54 +0000 Subject: [PATCH 121/134] Update src/dodal/devices/aperturescatterguard.py Co-authored-by: David Perl <115003895+dperl-dls@users.noreply.github.com> --- src/dodal/devices/aperturescatterguard.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dodal/devices/aperturescatterguard.py b/src/dodal/devices/aperturescatterguard.py index 5f9f97f2c0..66cf56e459 100644 --- a/src/dodal/devices/aperturescatterguard.py +++ b/src/dodal/devices/aperturescatterguard.py @@ -139,7 +139,7 @@ def _get_current_aperture_position(self) -> SingleAperturePosition: return self.aperture_positions.MEDIUM elif int(self.aperture.small.get()) == 1: return self.aperture_positions.SMALL - elif current_ap_y <= +robot_load_ap_y + tolerance: + elif current_ap_y <= robot_load_ap_y + tolerance: return self.aperture_positions.ROBOT_LOAD raise InvalidApertureMove("Current aperture/scatterguard state unrecognised") From 8356ccf1bc59f7c3682cf1aea22a1840dbed7a68 Mon Sep 17 00:00:00 2001 From: Dominic Oram Date: Mon, 25 Mar 2024 13:30:20 +0000 Subject: [PATCH 122/134] (DiamondLightSource/hyperion#1251) Remove old Synchrotron --- src/dodal/devices/synchrotron.py | 20 +------------------ .../system_tests/test_synchrotron_system.py | 4 ++-- tests/devices/unit_tests/test_synchrotron.py | 16 +++++++-------- 3 files changed, 11 insertions(+), 29 deletions(-) diff --git a/src/dodal/devices/synchrotron.py b/src/dodal/devices/synchrotron.py index 19ead2c5ab..ae9eabe8ca 100644 --- a/src/dodal/devices/synchrotron.py +++ b/src/dodal/devices/synchrotron.py @@ -1,6 +1,5 @@ from enum import Enum -from ophyd import Component, Device, EpicsSignal from ophyd_async.core import StandardReadable from ophyd_async.epics.signal import epics_signal_r @@ -31,24 +30,7 @@ class SynchrotronMode(str, Enum): UNKNOWN = "Unknown" -class SynchrotronMachineStatus(Device): - synchrotron_mode = Component(EpicsSignal, Suffix.MODE, string=True) - user_countdown = Component(EpicsSignal, Suffix.USER_COUNTDOWN) - beam_energy = Component(EpicsSignal, Suffix.BEAM_ENERGY) - - -class SynchrotronTopUp(Device): - start_countdown = Component(EpicsSignal, Suffix.COUNTDOWN) - end_countdown = Component(EpicsSignal, Suffix.END_COUNTDOWN) - - -class Synchrotron(Device): - machine_status = Component(SynchrotronMachineStatus, Prefix.STATUS) - top_up = Component(SynchrotronTopUp, Prefix.TOP_UP) - ring_current = Component(EpicsSignal, Prefix.SIGNAL + Suffix.SIGNAL) - - -class OASynchrotron(StandardReadable): +class Synchrotron(StandardReadable): def __init__( self, prefix: str = "", diff --git a/tests/devices/system_tests/test_synchrotron_system.py b/tests/devices/system_tests/test_synchrotron_system.py index 655094b1d4..c39edf869d 100644 --- a/tests/devices/system_tests/test_synchrotron_system.py +++ b/tests/devices/system_tests/test_synchrotron_system.py @@ -10,5 +10,5 @@ def synchrotron(): @pytest.mark.s03 -def test_synchrotron_connects(synchrotron: Synchrotron): - synchrotron.wait_for_connection() +async def test_synchrotron_connects(synchrotron: Synchrotron): + await synchrotron.connect() diff --git a/tests/devices/unit_tests/test_synchrotron.py b/tests/devices/unit_tests/test_synchrotron.py index ab65392aba..b935df7e48 100644 --- a/tests/devices/unit_tests/test_synchrotron.py +++ b/tests/devices/unit_tests/test_synchrotron.py @@ -7,9 +7,9 @@ from ophyd_async.core import DeviceCollector, StandardReadable, set_sim_value from dodal.devices.synchrotron import ( - OASynchrotron, Prefix, Suffix, + Synchrotron, SynchrotronMode, ) @@ -120,9 +120,9 @@ @pytest.fixture -async def sim_synchrotron() -> OASynchrotron: +async def sim_synchrotron() -> Synchrotron: async with DeviceCollector(sim=True): - sim_synchrotron = OASynchrotron() + sim_synchrotron = Synchrotron() set_sim_value(sim_synchrotron.ring_current, RING_CURRENT) set_sim_value(sim_synchrotron.machine_user_countdown, USER_COUNTDOWN) set_sim_value(sim_synchrotron.topup_start_countdown, START_COUNTDOWN) @@ -132,7 +132,7 @@ async def sim_synchrotron() -> OASynchrotron: return sim_synchrotron -async def test_oasynchrotron_read(sim_synchrotron: OASynchrotron): +async def test_synchrotron_read(sim_synchrotron: Synchrotron): await verify( sim_synchrotron.read, READ_SIGNALS, @@ -141,7 +141,7 @@ async def test_oasynchrotron_read(sim_synchrotron: OASynchrotron): ) -async def test_oasynchrotron_read_configuration(sim_synchrotron: OASynchrotron): +async def test_synchrotron_read_configuration(sim_synchrotron: Synchrotron): await verify( sim_synchrotron.read_configuration, CONFIG_SIGNALS, @@ -150,7 +150,7 @@ async def test_oasynchrotron_read_configuration(sim_synchrotron: OASynchrotron): ) -async def test_oasynchrotron_describe(sim_synchrotron: OASynchrotron): +async def test_synchrotron_describe(sim_synchrotron: Synchrotron): await verify( sim_synchrotron.describe, READ_SIGNALS, @@ -159,7 +159,7 @@ async def test_oasynchrotron_describe(sim_synchrotron: OASynchrotron): ) -async def test_oasynchrotron_describe_configuration(sim_synchrotron: OASynchrotron): +async def test_synchrotron_describe_configuration(sim_synchrotron: Synchrotron): await verify( sim_synchrotron.describe_configuration, CONFIG_SIGNALS, @@ -168,7 +168,7 @@ async def test_oasynchrotron_describe_configuration(sim_synchrotron: OASynchrotr ) -async def test_oasynchrotron_count(RE: RunEngine, sim_synchrotron: OASynchrotron): +async def test_synchrotron_count(RE: RunEngine, sim_synchrotron: Synchrotron): docs = [] RE(count_sim(sim_synchrotron), lambda x, y: docs.append(y)) From bc22d2affc682fe300424203cfcc66e6f7ea22e6 Mon Sep 17 00:00:00 2001 From: Dominic Oram Date: Tue, 12 Mar 2024 15:56:40 +0000 Subject: [PATCH 123/134] (DiamondLightSource/hyperion#360) Added check for topup plan and associated tests, originally from Hyperion --- src/dodal/plans/check_topup.py | 82 +++++++++++++++++++++++++++++ tests/plans/test_topup_plan.py | 94 ++++++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 src/dodal/plans/check_topup.py create mode 100644 tests/plans/test_topup_plan.py diff --git a/src/dodal/plans/check_topup.py b/src/dodal/plans/check_topup.py new file mode 100644 index 0000000000..78110d70c5 --- /dev/null +++ b/src/dodal/plans/check_topup.py @@ -0,0 +1,82 @@ +import bluesky.plan_stubs as bps + +from dodal.devices.synchrotron import Synchrotron, SynchrotronMode +from dodal.log import LOGGER + +ALLOWED_MODES = [SynchrotronMode.USER, SynchrotronMode.SPECIAL] +DECAY_MODE_COUNTDOWN = -1 # Value of the start_countdown PV when in decay mode +COUNTDOWN_DURING_TOPUP = 0 + + +def _in_decay_mode(time_to_topup): + if time_to_topup == DECAY_MODE_COUNTDOWN: + LOGGER.info("Machine in decay mode, gating disabled") + return True + return False + + +def _gating_permitted(machine_mode: SynchrotronMode): + if machine_mode in ALLOWED_MODES: + LOGGER.info("Machine in allowed mode, gating top up enabled.") + return True + LOGGER.info("Machine not in allowed mode, gating disabled") + return False + + +def _delay_to_avoid_topup(total_run_time, time_to_topup): + if total_run_time > time_to_topup: + LOGGER.info( + """ + Total run time for this collection exceeds time to next top up. + Collection delayed until top up done. + """ + ) + return True + LOGGER.info( + """ + Total run time less than time to next topup. Proceeding with collection. + """ + ) + return False + + +def wait_for_topup_complete(synchrotron: Synchrotron): + LOGGER.info("Waiting for topup to complete") + start = yield from bps.rd(synchrotron.topup_start_countdown) + while start == COUNTDOWN_DURING_TOPUP: + yield from bps.sleep(0.1) + start = yield from bps.rd(synchrotron.topup_start_countdown) + + +def check_topup_and_wait_if_necessary( + synchrotron: Synchrotron, + total_exposure_time: float, + ops_time: float, # Account for xray centering, rotation speed, etc +): # See https://github.com/DiamondLightSource/hyperion/issues/932 + """A small plan to check if topup gating is permitted and sleep until the topup\ + is over if it starts before the end of collection. + + Args: + synchrotron (Synchrotron): Synchrotron device. + total_exposure_time (float): Expected total exposure time for \ + collection, in seconds. + ops_time (float): Additional time to account for various operations,\ + eg. x-ray centering, in seconds. Defaults to 30.0. + """ + machine_mode = yield from bps.rd(synchrotron.synchrotron_mode) + assert isinstance(machine_mode, SynchrotronMode) + time_to_topup = yield from bps.rd(synchrotron.topup_start_countdown) + if _in_decay_mode(time_to_topup) or not _gating_permitted(machine_mode): + yield from bps.null() + return + tot_run_time = total_exposure_time + ops_time + end_topup = yield from bps.rd(synchrotron.top_up_end_countdown) + time_to_wait = ( + end_topup if _delay_to_avoid_topup(tot_run_time, time_to_topup) else 0.0 + ) + + yield from bps.sleep(time_to_wait) + + check_start = yield from bps.rd(synchrotron.topup_start_countdown) + if check_start == COUNTDOWN_DURING_TOPUP: + yield from wait_for_topup_complete(synchrotron) diff --git a/tests/plans/test_topup_plan.py b/tests/plans/test_topup_plan.py new file mode 100644 index 0000000000..287ec7da8f --- /dev/null +++ b/tests/plans/test_topup_plan.py @@ -0,0 +1,94 @@ +from unittest.mock import patch + +import bluesky.plan_stubs as bps +import pytest +from bluesky.run_engine import RunEngine +from ophyd_async.core import set_sim_value + +from dodal.beamlines import i03 +from dodal.devices.synchrotron import Synchrotron, SynchrotronMode +from dodal.plans.check_topup import ( + check_topup_and_wait_if_necessary, + wait_for_topup_complete, +) + + +@pytest.fixture +def synchrotron() -> Synchrotron: + return i03.synchrotron(fake_with_ophyd_sim=True) + + +@patch("hyperion.device_setup_plans.check_topup.wait_for_topup_complete") +@patch("hyperion.device_setup_plans.check_topup.bps.sleep") +def test_when_topup_before_end_of_collection_wait( + fake_sleep, fake_wait, synchrotron: Synchrotron +): + set_sim_value(synchrotron.synchrotron_mode, SynchrotronMode.USER) + set_sim_value(synchrotron.topup_start_countdown, 20.0) + set_sim_value(synchrotron.top_up_end_countdown, 60.0) + + RE = RunEngine() + RE( + check_topup_and_wait_if_necessary( + synchrotron=synchrotron, + total_exposure_time=40.0, + ops_time=30.0, + ) + ) + fake_sleep.assert_called_once_with(60.0) + + +@patch("hyperion.device_setup_plans.check_topup.bps.rd") +@patch("hyperion.device_setup_plans.check_topup.bps.sleep") +def test_wait_for_topup_complete(fake_sleep, fake_rd, synchrotron): + def fake_generator(value): + yield from bps.null() + return value + + fake_rd.side_effect = [ + fake_generator(0.0), + fake_generator(0.0), + fake_generator(0.0), + fake_generator(10.0), + ] + + RE = RunEngine() + RE(wait_for_topup_complete(synchrotron)) + + assert fake_sleep.call_count == 3 + fake_sleep.assert_called_with(0.1) + + +@patch("hyperion.device_setup_plans.check_topup.bps.sleep") +@patch("hyperion.device_setup_plans.check_topup.bps.null") +def test_no_waiting_if_decay_mode(fake_null, fake_sleep, synchrotron: Synchrotron): + set_sim_value(synchrotron.topup_start_countdown, -1) + + RE = RunEngine() + RE( + check_topup_and_wait_if_necessary( + synchrotron=synchrotron, + total_exposure_time=10.0, + ops_time=1.0, + ) + ) + fake_null.assert_called_once() + assert fake_sleep.call_count == 0 + + +@patch("hyperion.device_setup_plans.check_topup.bps.null") +def test_no_waiting_when_mode_does_not_allow_gating( + fake_null, synchrotron: Synchrotron +): + set_sim_value(synchrotron.topup_start_countdown, 1.0) + set_sim_value(synchrotron.synchrotron_mode, SynchrotronMode.SHUTDOWN) + + RE = RunEngine() + RE( + check_topup_and_wait_if_necessary( + synchrotron=synchrotron, + total_exposure_time=10.0, + ops_time=1.0, + ) + ) + fake_null.assert_called_once() From 32b5cbdb635c270491e8cad2274f238f67702ce5 Mon Sep 17 00:00:00 2001 From: Dominic Oram Date: Tue, 12 Mar 2024 16:12:36 +0000 Subject: [PATCH 124/134] (DiamondLightSource/hyperion#360) Fix typo in moving check topup from Hyperion --- tests/plans/test_topup_plan.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/plans/test_topup_plan.py b/tests/plans/test_topup_plan.py index 287ec7da8f..4cb079af1c 100644 --- a/tests/plans/test_topup_plan.py +++ b/tests/plans/test_topup_plan.py @@ -18,8 +18,8 @@ def synchrotron() -> Synchrotron: return i03.synchrotron(fake_with_ophyd_sim=True) -@patch("hyperion.device_setup_plans.check_topup.wait_for_topup_complete") -@patch("hyperion.device_setup_plans.check_topup.bps.sleep") +@patch("dodal.plans.check_topup.wait_for_topup_complete") +@patch("dodal.plans.check_topup.bps.sleep") def test_when_topup_before_end_of_collection_wait( fake_sleep, fake_wait, synchrotron: Synchrotron ): @@ -38,8 +38,8 @@ def test_when_topup_before_end_of_collection_wait( fake_sleep.assert_called_once_with(60.0) -@patch("hyperion.device_setup_plans.check_topup.bps.rd") -@patch("hyperion.device_setup_plans.check_topup.bps.sleep") +@patch("dodal.plans.check_topup.bps.rd") +@patch("dodal.plans.check_topup.bps.sleep") def test_wait_for_topup_complete(fake_sleep, fake_rd, synchrotron): def fake_generator(value): yield from bps.null() @@ -59,8 +59,8 @@ def fake_generator(value): fake_sleep.assert_called_with(0.1) -@patch("hyperion.device_setup_plans.check_topup.bps.sleep") -@patch("hyperion.device_setup_plans.check_topup.bps.null") +@patch("dodal.plans.check_topup.bps.sleep") +@patch("dodal.plans.check_topup.bps.null") def test_no_waiting_if_decay_mode(fake_null, fake_sleep, synchrotron: Synchrotron): set_sim_value(synchrotron.topup_start_countdown, -1) @@ -76,7 +76,7 @@ def test_no_waiting_if_decay_mode(fake_null, fake_sleep, synchrotron: Synchrotro assert fake_sleep.call_count == 0 -@patch("hyperion.device_setup_plans.check_topup.bps.null") +@patch("dodal.plans.check_topup.bps.null") def test_no_waiting_when_mode_does_not_allow_gating( fake_null, synchrotron: Synchrotron ): From 99553b2ccdd578bcb749c9fa16ecb7043a2d2aa5 Mon Sep 17 00:00:00 2001 From: DiamondJoseph <53935796+DiamondJoseph@users.noreply.github.com> Date: Wed, 27 Mar 2024 07:52:04 +0000 Subject: [PATCH 125/134] Allow ophyd_async timeout exception to propagate correctly (#393) The exception was previously being swallowed due to timeout not being passed in the correct place. Now that the new exception propagates to the user it provides a better error message (including PV name). Also update the tests. --------- Co-authored-by: Callum Forrester --- src/dodal/beamlines/beamline_utils.py | 8 ++++++-- .../devices/oav/pin_image_recognition/__init__.py | 11 ++++++++--- tests/beamlines/unit_tests/test_beamline_utils.py | 6 +++++- tests/conftest.py | 5 +++-- 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/dodal/beamlines/beamline_utils.py b/src/dodal/beamlines/beamline_utils.py index 7553709aa6..1647eba4ef 100644 --- a/src/dodal/beamlines/beamline_utils.py +++ b/src/dodal/beamlines/beamline_utils.py @@ -52,8 +52,12 @@ def _wait_for_connection( device.wait_for_connection(timeout=timeout) elif isinstance(device, OphydV2Device): call_in_bluesky_event_loop( - v2_device_wait_for_connection(coros=device.connect(sim=sim)), - timeout=timeout, + v2_device_wait_for_connection( + coros=device.connect( + sim=sim, + timeout=timeout, + ) + ), ) else: raise TypeError( diff --git a/src/dodal/devices/oav/pin_image_recognition/__init__.py b/src/dodal/devices/oav/pin_image_recognition/__init__.py index 6a78772585..191b133792 100644 --- a/src/dodal/devices/oav/pin_image_recognition/__init__.py +++ b/src/dodal/devices/oav/pin_image_recognition/__init__.py @@ -4,7 +4,12 @@ import numpy as np from numpy.typing import NDArray -from ophyd_async.core import AsyncStatus, StandardReadable, observe_value +from ophyd_async.core import ( + DEFAULT_TIMEOUT, + AsyncStatus, + StandardReadable, + observe_value, +) from ophyd_async.epics.signal import epics_signal_r from dodal.devices.oav.pin_image_recognition.utils import ( @@ -142,8 +147,8 @@ async def _get_tip_and_edge_data( ) return location - async def connect(self, sim: bool = False): - await super().connect(sim) + async def connect(self, sim: bool = False, timeout: float = DEFAULT_TIMEOUT): + await super().connect(sim, timeout) # Set defaults for soft parameters await self.validity_timeout.set(5.0) diff --git a/tests/beamlines/unit_tests/test_beamline_utils.py b/tests/beamlines/unit_tests/test_beamline_utils.py index 20f182723d..6bc13e21a6 100644 --- a/tests/beamlines/unit_tests/test_beamline_utils.py +++ b/tests/beamlines/unit_tests/test_beamline_utils.py @@ -100,10 +100,14 @@ def test_wait_for_v2_device_connection_passes_through_timeout( ): RE() device = OphydV2Device() + device.connect = MagicMock() beamline_utils._wait_for_connection(device, **kwargs) - call_in_bluesky_el.assert_called_once_with(ANY, timeout=expected_timeout) + device.connect.assert_called_once_with( + sim=ANY, + timeout=expected_timeout, + ) def test_default_directory_provider_is_singleton(): diff --git a/tests/conftest.py b/tests/conftest.py index 8d0236987a..54ccd42836 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -42,8 +42,9 @@ def module_and_devices_for_beamline(request): bl_mod = importlib.import_module("dodal.beamlines." + beamline) importlib.reload(bl_mod) mock_beamline_module_filepaths(beamline, bl_mod) - yield bl_mod, make_all_devices( - bl_mod, include_skipped=True, fake_with_ophyd_sim=True + yield ( + bl_mod, + make_all_devices(bl_mod, include_skipped=True, fake_with_ophyd_sim=True), ) beamline_utils.clear_devices() del bl_mod From f7ddddeaa681a929a14c8d4e72987ac756340a69 Mon Sep 17 00:00:00 2001 From: David Perl Date: Wed, 27 Mar 2024 12:05:41 +0000 Subject: [PATCH 126/134] hyperion 1219 remove unused calculations --- src/dodal/devices/oav/oav_calculations.py | 300 ---------------------- 1 file changed, 300 deletions(-) diff --git a/src/dodal/devices/oav/oav_calculations.py b/src/dodal/devices/oav/oav_calculations.py index cc3ca79b75..6796e3a411 100644 --- a/src/dodal/devices/oav/oav_calculations.py +++ b/src/dodal/devices/oav/oav_calculations.py @@ -1,228 +1,5 @@ -from typing import Tuple - import numpy as np -from dodal.devices.oav.oav_errors import ( - OAVError_MissingRotations, - OAVError_NoRotationsPassValidityTest, -) -from dodal.log import LOGGER - - -def smooth(array): - """ - Remove noise from waveform using a convolution. - - Args: - array (np.ndarray): waveform to be smoothed. - Returns: - array_smooth (np.ndarray): array with noise removed. - """ - - # the smoothing window is set to 50 on i03 - smoothing_window = 50 - box = np.ones(smoothing_window) / smoothing_window - array_smooth = np.convolve(array, box, mode="same") - return array_smooth - - -def find_midpoint(top, bottom): - """ - Finds the midpoint from MXSC edge PVs. The midpoint is considered the centre of the first - bulge in the waveforms. This will correspond to the pin where the sample is located. - - Args: - top (np.ndarray): The waveform corresponding to the top of the pin. - bottom (np.ndarray): The waveform corresponding to the bottom of the pin. - Returns: - i_pixel (int): The i position of the located centre (in pixels). - j_pixel (int): The j position of the located centre (in pixels). - width (int): The width of the pin at the midpoint (in pixels). - """ - - # Widths between top and bottom. - widths = bottom - top - - # The line going down the middle of the waveform. - middle_line = (bottom + top) * 0.5 - - smoothed_width = smooth(widths) - first_derivative = np.gradient(smoothed_width) - - # The derivative introduces more noise, so another application of smooth is neccessary. - # The gradient is reversed prior since a new index has been introduced in smoothing, that is - # negated by smoothing in the reversed array. - reversed_derivative = first_derivative[::-1] - reversed_grad = smooth(reversed_derivative) - grad = reversed_grad[::-1] - - # np.sign gives us the positions where the gradient is positive and negative. - # Taking the diff of th/at gives us an array with all 0's apart from the places - # sign of the gradient went from -1 -> 1 or 1 -> -1. - # Indices are -1 for decreasing width, +1 for increasing width. - increasing_or_decreasing = np.sign(grad) - - # Taking the difference will give us an array with -2/2 for the places the gradient where the gradient - # went from negative->positive/postitive->negative, 0 where it didn't change, and -1 where the gradient goes from 0->1 - # at the pin tip. - gradient_changed = np.diff(increasing_or_decreasing) - - # np.where will give all non-zero indices: the indices where the gradient changed. - # We take the 0th element as the x pos since it's the first place where the gradient changed, indicating a bulge. - stationary_points = np.where(gradient_changed)[0] - - # We'll have one stationary point before the midpoint. - i_pixel = stationary_points[1] - - j_pixel = middle_line[int(i_pixel)] - width = widths[int(i_pixel)] - return (i_pixel, j_pixel, width) - - -def get_rotation_increment(rotations: int, omega: int, high_limit: int) -> float: - """ - By default we'll rotate clockwise (viewing the goniometer from the front), but if we - can't rotate 180 degrees clockwise without exceeding the high_limit threshold then - the goniometer rotates in the anticlockwise direction. - - Args: - rotations (int): The number of rotations we want to add up to 180/-180 - omega (int): The current omega angle of the smargon. - high_limit (int): The maximum allowed angle we want the smargon omega to have. - Returns: - The inrement we should rotate omega by. - """ - - # Number of degrees to rotate to. - increment = 180.0 / rotations - - # If the rotation threshhold would be exceeded flip the rotation direction. - if omega + 180 > high_limit: - increment = -increment - - return increment - - -def filter_rotation_data( - i_positions: np.ndarray, - j_positions: np.ndarray, - widths: np.ndarray, - omega_angles: np.ndarray, - acceptable_i_difference=100, -) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: - """ - Filters out outlier positions - those for which the i value of the midpoint unreasonably differs from the median of the i values at other rotations. - - Args: - i_positions (numpy.ndarray): Array where the n-th element corresponds to the i value (in pixels) of the midpoint at rotation n. - j_positions (numpy.ndarray): Array where the n-th element corresponds to the j value (in pixels) of the midpoint at rotation n. - widths (numpy.ndarray): Array where the n-th element corresponds to the pin width (in pixels) of the midpoint at rotation n. - acceptable_i_difference: the acceptable difference between the average value of i and - any individual value of i. We don't want to use exceptional positions for calculation. - Returns: - i_positions_filtered: the i_positions with outliers filtered out - j_positions_filtered: the j_positions with outliers filtered out - widths_filtered: the widths with outliers filtered out - omega_angles_filtered: the omega_angles with outliers filtered out - """ - # Find the average of the non zero elements of the array. - i_median = np.median(i_positions) - - # Filter out outliers. - outlier_i_positions = np.where( - abs(i_positions - i_median) > acceptable_i_difference - )[0] - i_positions_filtered = np.delete(i_positions, outlier_i_positions) - j_positions_filtered = np.delete(j_positions, outlier_i_positions) - widths_filtered = np.delete(widths, outlier_i_positions) - omega_angles_filtered = np.delete(omega_angles, outlier_i_positions) - - if not widths_filtered.size: - raise OAVError_NoRotationsPassValidityTest( - "No rotations pass the validity test." - ) - - return ( - i_positions_filtered, - j_positions_filtered, - widths_filtered, - omega_angles_filtered, - ) - - -def check_i_within_bounds( - max_tip_distance_pixels: int, tip_i: int, i_pixels: int -) -> int: - """ - Checks if i_pixels exceeds max tip distance (in pixels), if so returns max_tip_distance, else i_pixels. - This is necessary as some users send in wierd loops for which the differential method isn't functional. - OAV centring only needs to get in the right ballpark so Xray centring can do its thing. - """ - - tip_distance_pixels = i_pixels - tip_i - if tip_distance_pixels > max_tip_distance_pixels: - LOGGER.warning( - f"x_pixels={i_pixels} exceeds maximum tip distance {max_tip_distance_pixels}, using setting x_pixels within the max tip distance" - ) - i_pixels = max_tip_distance_pixels + tip_i - return i_pixels - - -def extract_pixel_centre_values_from_rotation_data( - i_positions: np.ndarray, - j_positions: np.ndarray, - widths: np.ndarray, - omega_angles: np.ndarray, -) -> Tuple[int, int, int, float, float]: - """ - Takes the obtained midpoints x_positions, y_positions, the pin widths, omega_angles from the rotations - and returns i, j, k, the angle the pin is widest, and the angle orthogonal to it. - - Args: - i_positions (numpy.ndarray): Array where the n-th element corresponds to the x value (in pixels) of the midpoint at rotation n. - j_positions (numpy.ndarray): Array where the n-th element corresponds to the y value (in pixels) of the midpoint at rotation n. - widths (numpy.ndarray): Array where the n-th element corresponds to the pin width (in pixels) of the midpoint at rotation n. - omega_angles (numpy.ndarray): Array where the n-th element corresponds to the omega angle at rotation n. - Returns: - i_pixels (int): The i value (x in pixels) of the midpoint when omega is equal to widest_omega_angle - j_pixels (int): The j value (y in pixels) of the midpoint when omega is equal to widest_omega_angle - k_pixels (int): The k value - the distance in pixels between the the midpoint and the top/bottom of the pin, - when omega is equal to `widest_omega_angle_orthogonal` - widest_omega_angle (float): The value of omega where the pin is widest in the image. - widest_omega_angle_orthogonal (float): The value of omega orthogonal to the angle where the pin is widest in the image. - """ - - ( - i_positions, - j_positions, - widths, - omega_angles, - ) = filter_rotation_data(i_positions, j_positions, widths, omega_angles) - - ( - index_of_largest_width, - index_orthogonal_to_largest_width, - ) = find_widest_point_and_orthogonal_point(widths, omega_angles) - - i_pixels = int(i_positions[index_of_largest_width]) - j_pixels = int(j_positions[index_of_largest_width]) - widest_omega_angle = float(omega_angles[index_of_largest_width]) - - widest_omega_angle_orthogonal = float( - omega_angles[index_orthogonal_to_largest_width] - ) - - # Store the y value which will be the magnitude in the z axis on 90 degree rotation - k_pixels = int(j_positions[index_orthogonal_to_largest_width]) - - return ( - i_pixels, - j_pixels, - k_pixels, - widest_omega_angle, - widest_omega_angle_orthogonal, - ) - def camera_coordinates_to_xyz( horizontal: float, @@ -260,80 +37,3 @@ def camera_coordinates_to_xyz( z = vertical * sine return np.array([x, y, z], dtype=np.float64) - - -def keep_inside_bounds(value: float, lower_bound: float, upper_bound: float) -> float: - """ - If value is above an upper bound then the upper bound is returned. - If value is below a lower bound then the lower bound is returned. - If value is within bounds then the value is returned. - - Args: - value (float): The value being checked against bounds. - lower_bound (float): The lower bound. - lower_bound (float): The upper bound. - """ - if value < lower_bound: - return lower_bound - if value > upper_bound: - return upper_bound - return value - - -def find_widest_point_and_orthogonal_point( - widths: np.ndarray, - omega_angles: np.ndarray, -) -> Tuple[int, int]: - """ - Find the index of the rotation where the pin was widest in the camera, and the indices of rotations orthogonal to it. - - Args: Lists of values taken, the i-th value of the list is the i-th point sampled: - widths (numpy.ndarray): Array where the i-th element corresponds to the pin width (in pixels) of the midpoint at rotation i. - omega_angles (numpy.ndarray): Array where the i-th element corresponds to the omega angle at rotation i. - Returns: The index of the sample which had the widest pin as an int, and the index orthogonal to that - """ - - # Find omega for face-on position: where bulge was widest. - index_of_largest_width = widths.argmax() - widest_omega_angle = omega_angles[index_of_largest_width] - - # Find the best angles orthogonal to the best_omega_angle. - index_orthogonal_to_largest_width = get_orthogonal_index( - omega_angles, widest_omega_angle - ) - - return int(index_of_largest_width), index_orthogonal_to_largest_width - - -def get_orthogonal_index( - angle_array: np.ndarray, angle: float, error_bounds: float = 5 -) -> int: - """ - Takes a numpy array of angles that encompasses 180 deg, and an angle from within - that 180 deg and returns the index of the element most orthogonal to that angle. - - Args: - angle_array (np.ndarray): Numpy array of angles. - angle (float): The angle we want to be orthogonal to - error_bounds (float): The absolute error allowed on the angle - - Returns: - The index of the orthogonal angle - """ - smallest_angle = angle_array.min() - - # Normalise values to be positive - normalised_array = angle_array - smallest_angle - normalised_angle = angle - smallest_angle - - orthogonal_angle = (normalised_angle + 90) % 180 - - angle_distance_to_orthogonal: np.ndarray = abs(normalised_array - orthogonal_angle) - index_of_orthogonal = int(angle_distance_to_orthogonal.argmin()) - - if not (abs((angle_distance_to_orthogonal[index_of_orthogonal])) <= error_bounds): - raise OAVError_MissingRotations( - f"Orthogonal angle found {angle_array[index_of_orthogonal]} not sufficiently orthogonal to angle {angle}" - ) - - return index_of_orthogonal From 2c82aa47d1a11292e344a72eec3d5ec85833ab6e Mon Sep 17 00:00:00 2001 From: David Perl Date: Thu, 28 Mar 2024 16:12:54 +0000 Subject: [PATCH 127/134] (#356) make setting arm use observe_value (and test) --- src/dodal/devices/zebra.py | 12 ++++++++++-- tests/devices/unit_tests/test_zebra.py | 10 ++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/dodal/devices/zebra.py b/src/dodal/devices/zebra.py index 4b171ba15c..12c582d0c1 100644 --- a/src/dodal/devices/zebra.py +++ b/src/dodal/devices/zebra.py @@ -5,7 +5,13 @@ from functools import partialmethod from typing import List -from ophyd_async.core import AsyncStatus, DeviceVector, SignalRW, StandardReadable +from ophyd_async.core import ( + AsyncStatus, + DeviceVector, + SignalRW, + StandardReadable, + observe_value, +) from ophyd_async.epics.signal import epics_signal_rw # Sources @@ -94,9 +100,11 @@ def __init__(self, prefix: str, name: str = "") -> None: super().__init__(name) async def _set_armed(self, demand: ArmDemand): - await self.armed.set(demand.value) signal_to_set = self.arm_set if demand == ArmDemand.ARM else self.disarm_set await signal_to_set.set(1) + async for reading in observe_value(self.armed): + if reading == demand.value: + return def set(self, demand: ArmDemand) -> AsyncStatus: return AsyncStatus( diff --git a/tests/devices/unit_tests/test_zebra.py b/tests/devices/unit_tests/test_zebra.py index 11df062527..73a3d8686e 100644 --- a/tests/devices/unit_tests/test_zebra.py +++ b/tests/devices/unit_tests/test_zebra.py @@ -1,3 +1,5 @@ +from unittest.mock import AsyncMock + import pytest from bluesky.run_engine import RunEngine from mockito import mock, verify @@ -31,6 +33,14 @@ async def test_position_compare_sets_signals(): fake_pc = PositionCompare("", name="fake position compare") await fake_pc.connect(sim=True) + async def mock_arm(demand): + fake_pc.arm.disarm_set._backend._set_value(not demand) # type: ignore + fake_pc.arm.arm_set._backend._set_value(demand) # type: ignore + await fake_pc.arm.armed.set(demand) + + fake_pc.arm.arm_set.set = AsyncMock(side_effect=mock_arm) + fake_pc.arm.disarm_set.set = AsyncMock(side_effect=mock_arm) + fake_pc.gate_source.set(TrigSource.EXTERNAL) fake_pc.gate_trigger.set(I03Axes.OMEGA) fake_pc.num_gates.set(10) From 747430b052c065cb3b47ced7acfac04039e42237 Mon Sep 17 00:00:00 2001 From: Callum Forrester Date: Wed, 3 Apr 2024 10:24:59 +0100 Subject: [PATCH 128/134] Fix Mypy Errors Caused by Pillow (#408) * Fix Mypy Errors Caused by Pillow The new version of pillow includes type hints, which seems to have caused mypy to pay more attention to it and thrown up a couple of issues. Nothing significant but blocks CI on other PRs. * Patch pillow mock properly rather than type ignore --- src/dodal/devices/oav/grid_overlay.py | 2 +- tests/devices/unit_tests/test_oav.py | 19 +++++++++---------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/dodal/devices/oav/grid_overlay.py b/src/dodal/devices/oav/grid_overlay.py index 062ba60ae6..57e74908fc 100644 --- a/src/dodal/devices/oav/grid_overlay.py +++ b/src/dodal/devices/oav/grid_overlay.py @@ -15,7 +15,7 @@ class Orientation(Enum): def _add_parallel_lines_to_image( - image: Image, + image: Image.Image, start_x: int, start_y: int, line_length: int, diff --git a/tests/devices/unit_tests/test_oav.py b/tests/devices/unit_tests/test_oav.py index f32c1d2a45..b7d01e50c3 100644 --- a/tests/devices/unit_tests/test_oav.py +++ b/tests/devices/unit_tests/test_oav.py @@ -67,17 +67,16 @@ def test_snapshot_trigger_saves_to_correct_file( mock_open: MagicMock, mock_get, fake_oav ): image = PIL.Image.open("test") - mock_save = MagicMock() - image.save = mock_save mock_open.return_value.__enter__.return_value = image - st = fake_oav.snapshot.trigger() - st.wait() - expected_calls_to_save = [ - call(f"test directory/test filename{addition}.png") - for addition in ["", "_outer_overlay", "_grid_overlay"] - ] - calls_to_save = mock_save.mock_calls - assert calls_to_save == expected_calls_to_save + with patch.object(image, "save") as mock_save: + st = fake_oav.snapshot.trigger() + st.wait() + expected_calls_to_save = [ + call(f"test directory/test filename{addition}.png") + for addition in ["", "_outer_overlay", "_grid_overlay"] + ] + calls_to_save = mock_save.mock_calls + assert calls_to_save == expected_calls_to_save @patch("requests.get") From b20990c5cbcf96fa17d43c7394473233cbc3c713 Mon Sep 17 00:00:00 2001 From: Stanislaw Malinowski Date: Fri, 1 Mar 2024 15:22:51 +0000 Subject: [PATCH 129/134] add turbo slit device --- src/dodal/beamlines/i20_1.py | 34 +++++++++++++++++++ src/dodal/devices/i20_1/__init__.py | 0 src/dodal/devices/i20_1/turbo_slit.py | 28 +++++++++++++++ .../unit_tests/test_device_instantiation.py | 2 +- 4 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 src/dodal/beamlines/i20_1.py create mode 100644 src/dodal/devices/i20_1/__init__.py create mode 100644 src/dodal/devices/i20_1/turbo_slit.py diff --git a/src/dodal/beamlines/i20_1.py b/src/dodal/beamlines/i20_1.py new file mode 100644 index 0000000000..902a351c8a --- /dev/null +++ b/src/dodal/beamlines/i20_1.py @@ -0,0 +1,34 @@ + +from dodal.beamlines.beamline_utils import device_instantiation +from dodal.beamlines.beamline_utils import set_beamline as set_utils_beamline +from dodal.devices.motors import EpicsMotor +from dodal.log import set_beamline as set_log_beamline +from dodal.utils import get_beamline_name, get_hostname, skip_device + +BL = get_beamline_name("i20_1") +set_log_beamline(BL) +set_utils_beamline(BL) + + +def _is_i20_1_machine(): + """ + Devices using PVA can only connect from i23 machines, due to the absence of + PVA gateways at present. + """ + hostname = get_hostname() + return hostname.startswith("i20_1") + + +@skip_device(lambda: not _is_i20_1_machine()) +def turbo_slit_motor( + wait_for_connection: bool = True, fake_with_ophyd_sim: bool = False +) -> EpicsMotor: + """Get the i20-1 motor""" + + return device_instantiation( + EpicsMotor, + prefix="BL20J-OP-PCHRO-01:TS:XFINE", + name="turbo_slit_motor_x", + wait=wait_for_connection, + fake=fake_with_ophyd_sim, + ) \ No newline at end of file diff --git a/src/dodal/devices/i20_1/__init__.py b/src/dodal/devices/i20_1/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/dodal/devices/i20_1/turbo_slit.py b/src/dodal/devices/i20_1/turbo_slit.py new file mode 100644 index 0000000000..2dd86ae9df --- /dev/null +++ b/src/dodal/devices/i20_1/turbo_slit.py @@ -0,0 +1,28 @@ + +from ophyd import Component, Device, EpicsSignal, StatusBase +from ophyd_async.core import Device as OphydV2Device + + +class TurboSlit(OphydV2Device): + """ + todo for now only the x motor + add soft limits + check min speed + set speed back to before movement + """ + + motor_x = Component( + EpicsSignal, + "BL20J-OP-PCHRO-01:TS:XFINE", + ) + + def set(self, position: str) -> StatusBase: + status = self.motor_x.set(position) + return status + + +class AsyncTurboSlit(Device): + # .val - set value channel + # .rbv - readback value - read channel + pass + # motor_x = Motor() \ No newline at end of file diff --git a/tests/beamlines/unit_tests/test_device_instantiation.py b/tests/beamlines/unit_tests/test_device_instantiation.py index 65fb5fde26..fff57c9187 100644 --- a/tests/beamlines/unit_tests/test_device_instantiation.py +++ b/tests/beamlines/unit_tests/test_device_instantiation.py @@ -5,7 +5,7 @@ from dodal.beamlines import beamline_utils from dodal.utils import BLUESKY_PROTOCOLS, make_all_devices -ALL_BEAMLINES = {"i03", "i04", "i04_1", "i23", "i24", "p38", "p45"} +ALL_BEAMLINES = {"i03", "i04", "i04_1", "i23", "i24", "p38", "p45", "i20_1"} def follows_bluesky_protocols(obj: Any) -> bool: From ac397e04c505b0e79ea52bff0c875ca4bb6d6b06 Mon Sep 17 00:00:00 2001 From: Stanislaw Malinowski Date: Fri, 1 Mar 2024 15:23:42 +0000 Subject: [PATCH 130/134] add StandardMovable --- src/dodal/devices/i20_1/turbo_slit.py | 34 ++++++++++++++++++++------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/src/dodal/devices/i20_1/turbo_slit.py b/src/dodal/devices/i20_1/turbo_slit.py index 2dd86ae9df..c1486ce832 100644 --- a/src/dodal/devices/i20_1/turbo_slit.py +++ b/src/dodal/devices/i20_1/turbo_slit.py @@ -1,9 +1,24 @@ -from ophyd import Component, Device, EpicsSignal, StatusBase -from ophyd_async.core import Device as OphydV2Device +from enum import Enum +from bluesky.protocols import Configurable, Movable, Stageable +from ophyd_async.core import AsyncStatus, Device, Signal, StandardReadable -class TurboSlit(OphydV2Device): + +class StandardMovable(Device, Movable, Configurable, Stageable): + pass + + +class TestDetector(StandardReadable): + pass + + +class ControlEnum(str, Enum): + value1 = "close" + value2 = "open" + + +class TurboSlit(StandardMovable): """ todo for now only the x motor add soft limits @@ -11,14 +26,15 @@ class TurboSlit(OphydV2Device): set speed back to before movement """ - motor_x = Component( - EpicsSignal, - "BL20J-OP-PCHRO-01:TS:XFINE", + motor_x = Signal( + "BL20J-OP-PCHRO-01:TS:XFINE", # type: ignore + 0.01 ) - def set(self, position: str) -> StatusBase: - status = self.motor_x.set(position) - return status + def set(self, position: str) -> AsnycStatus: + pass + # status = self.motor_x.set(position) + # return status class AsyncTurboSlit(Device): From 54f913215951453236d607799e1033a573fe5df1 Mon Sep 17 00:00:00 2001 From: Stanislaw Malinowski Date: Fri, 1 Mar 2024 17:15:15 +0000 Subject: [PATCH 131/134] half fixed somewhat --- src/dodal/beamlines/i20_1.py | 14 ++-- src/dodal/devices/i20_1/turbo_slit.py | 100 +++++++++++++++++++++----- 2 files changed, 91 insertions(+), 23 deletions(-) diff --git a/src/dodal/beamlines/i20_1.py b/src/dodal/beamlines/i20_1.py index 902a351c8a..e1259f7d3d 100644 --- a/src/dodal/beamlines/i20_1.py +++ b/src/dodal/beamlines/i20_1.py @@ -1,7 +1,7 @@ - from dodal.beamlines.beamline_utils import device_instantiation from dodal.beamlines.beamline_utils import set_beamline as set_utils_beamline from dodal.devices.motors import EpicsMotor +from dodal.devices.i20_1.turbo_slit import TurboSlit from dodal.log import set_beamline as set_log_beamline from dodal.utils import get_beamline_name, get_hostname, skip_device @@ -12,7 +12,7 @@ def _is_i20_1_machine(): """ - Devices using PVA can only connect from i23 machines, due to the absence of + Devices using PVA can only connect from i20_1 machines, due to the absence of PVA gateways at present. """ hostname = get_hostname() @@ -22,13 +22,13 @@ def _is_i20_1_machine(): @skip_device(lambda: not _is_i20_1_machine()) def turbo_slit_motor( wait_for_connection: bool = True, fake_with_ophyd_sim: bool = False -) -> EpicsMotor: +) -> TurboSlit: """Get the i20-1 motor""" return device_instantiation( - EpicsMotor, - prefix="BL20J-OP-PCHRO-01:TS:XFINE", - name="turbo_slit_motor_x", + TurboSlit, + prefix="-OP-PCHRO-01", + name="turbo_slit", wait=wait_for_connection, fake=fake_with_ophyd_sim, - ) \ No newline at end of file + ) diff --git a/src/dodal/devices/i20_1/turbo_slit.py b/src/dodal/devices/i20_1/turbo_slit.py index c1486ce832..fe77318cbd 100644 --- a/src/dodal/devices/i20_1/turbo_slit.py +++ b/src/dodal/devices/i20_1/turbo_slit.py @@ -1,8 +1,81 @@ - +import asyncio +import time from enum import Enum +from typing import Callable, List, Optional + +from bluesky.protocols import Configurable, Movable, Stageable, Stoppable +from ophyd_async.core import AsyncStatus, Device, StandardReadable +from ophyd_async.epics.motion.motor import Motor + +from dodal.devices.util.epics_util import epics_signal_put_wait + + +class ControlEnumMotor(StandardReadable, Movable, Stoppable): + """Device that moves a motor record""" + + def __init__(self, prefix: str, name="") -> None: + # Define some signals + self.setpoint = epics_signal_put_wait(ControlEnum, prefix + ".VAL") + self.readback = epics_signal_r(ControlEnum, prefix + ".RBV") + self.stop_ = epics_signal_x(prefix + ".STOP") + self.set_readable_signals( + read=[self.readback], + config=[], + ) + super().__init__(name=name) + + def set_name(self, name: str): + super().set_name(name) + # Readback should be named the same as its parent in read() + self.readback.set_name(name) + + async def _move(self, new_position: float, watchers: List[Callable] = []): + self._set_success = True + start = time.monotonic() + old_position, units, precision = await asyncio.gather( + self.setpoint.get_value(), + ) + + def update_watchers(current_position: float): + for watcher in watchers: + watcher( + name=self.name, + current=current_position, + initial=old_position, + target=new_position, + time_elapsed=time.monotonic() - start, + ) -from bluesky.protocols import Configurable, Movable, Stageable -from ophyd_async.core import AsyncStatus, Device, Signal, StandardReadable + self.readback.subscribe_value(update_watchers) + try: + await self.setpoint.set(new_position) + finally: + self.readback.clear_sub(update_watchers) + if not self._set_success: + raise RuntimeError("Motor was stopped") + + def move(self, new_position: ControlEnum, timeout: Optional[float] = None): + """Commandline only synchronous move of a Motor""" + from bluesky.run_engine import call_in_bluesky_event_loop, in_bluesky_event_loop + + if in_bluesky_event_loop(): + raise RuntimeError("Will deadlock run engine if run in a plan") + call_in_bluesky_event_loop(self._move(new_position), timeout) # type: ignore + + def set( + self, new_position: ControlEnum, timeout: Optional[float] = None + ) -> AsyncStatus: + watchers: List[Callable] = [] + # todo here string or float? + coro = asyncio.wait_for(self._move(new_position, watchers), timeout=timeout) + return AsyncStatus(coro, watchers) + + async def stop(self, success=False): + self._set_success = success + # Put with completion will never complete as we are waiting for completion on + # the move above, so need to pass wait=False + status = self.stop_.trigger(wait=False) + await status class StandardMovable(Device, Movable, Configurable, Stageable): @@ -26,19 +99,14 @@ class TurboSlit(StandardMovable): set speed back to before movement """ - motor_x = Signal( - "BL20J-OP-PCHRO-01:TS:XFINE", # type: ignore - 0.01 - ) + # motor_x = Motor(prefix="BL20J-OP-PCHRO-01:TS:XFINE", name="motorX") + motor_x: ControlEnum = Cpt(ControlEnum, "TS:XFINE") - def set(self, position: str) -> AsnycStatus: - pass + motor_y = Motor(prefix="BL20J-OP-PCHRO-01:TS:YFINE", name="motorY") + + def set(self, position: str) -> AsyncStatus: + task: asyncio.Task = yield {} + c: AsyncStatus = AsyncStatus(awaitable=task) + return c # status = self.motor_x.set(position) # return status - - -class AsyncTurboSlit(Device): - # .val - set value channel - # .rbv - readback value - read channel - pass - # motor_x = Motor() \ No newline at end of file From 85fb5ca35dba6c235f87da694e5ce6c105542982 Mon Sep 17 00:00:00 2001 From: Stanislaw Malinowski Date: Thu, 4 Apr 2024 10:58:01 +0100 Subject: [PATCH 132/134] add turboslit device --- src/dodal/beamlines/i20_1.py | 7 +- src/dodal/devices/i20_1/__init__.py | 0 src/dodal/devices/i20_1/turbo_slit.py | 112 -------------------------- src/dodal/devices/turbo_slit.py | 17 ++++ 4 files changed, 20 insertions(+), 116 deletions(-) delete mode 100644 src/dodal/devices/i20_1/__init__.py delete mode 100644 src/dodal/devices/i20_1/turbo_slit.py create mode 100644 src/dodal/devices/turbo_slit.py diff --git a/src/dodal/beamlines/i20_1.py b/src/dodal/beamlines/i20_1.py index e1259f7d3d..6fba97d298 100644 --- a/src/dodal/beamlines/i20_1.py +++ b/src/dodal/beamlines/i20_1.py @@ -1,7 +1,6 @@ from dodal.beamlines.beamline_utils import device_instantiation from dodal.beamlines.beamline_utils import set_beamline as set_utils_beamline -from dodal.devices.motors import EpicsMotor -from dodal.devices.i20_1.turbo_slit import TurboSlit +from dodal.devices.turbo_slit import TurboSlit from dodal.log import set_beamline as set_log_beamline from dodal.utils import get_beamline_name, get_hostname, skip_device @@ -20,14 +19,14 @@ def _is_i20_1_machine(): @skip_device(lambda: not _is_i20_1_machine()) -def turbo_slit_motor( +def turbo_slit( wait_for_connection: bool = True, fake_with_ophyd_sim: bool = False ) -> TurboSlit: """Get the i20-1 motor""" return device_instantiation( TurboSlit, - prefix="-OP-PCHRO-01", + prefix="-OP-PCHRO-01:TS:", name="turbo_slit", wait=wait_for_connection, fake=fake_with_ophyd_sim, diff --git a/src/dodal/devices/i20_1/__init__.py b/src/dodal/devices/i20_1/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/dodal/devices/i20_1/turbo_slit.py b/src/dodal/devices/i20_1/turbo_slit.py deleted file mode 100644 index fe77318cbd..0000000000 --- a/src/dodal/devices/i20_1/turbo_slit.py +++ /dev/null @@ -1,112 +0,0 @@ -import asyncio -import time -from enum import Enum -from typing import Callable, List, Optional - -from bluesky.protocols import Configurable, Movable, Stageable, Stoppable -from ophyd_async.core import AsyncStatus, Device, StandardReadable -from ophyd_async.epics.motion.motor import Motor - -from dodal.devices.util.epics_util import epics_signal_put_wait - - -class ControlEnumMotor(StandardReadable, Movable, Stoppable): - """Device that moves a motor record""" - - def __init__(self, prefix: str, name="") -> None: - # Define some signals - self.setpoint = epics_signal_put_wait(ControlEnum, prefix + ".VAL") - self.readback = epics_signal_r(ControlEnum, prefix + ".RBV") - self.stop_ = epics_signal_x(prefix + ".STOP") - self.set_readable_signals( - read=[self.readback], - config=[], - ) - super().__init__(name=name) - - def set_name(self, name: str): - super().set_name(name) - # Readback should be named the same as its parent in read() - self.readback.set_name(name) - - async def _move(self, new_position: float, watchers: List[Callable] = []): - self._set_success = True - start = time.monotonic() - old_position, units, precision = await asyncio.gather( - self.setpoint.get_value(), - ) - - def update_watchers(current_position: float): - for watcher in watchers: - watcher( - name=self.name, - current=current_position, - initial=old_position, - target=new_position, - time_elapsed=time.monotonic() - start, - ) - - self.readback.subscribe_value(update_watchers) - try: - await self.setpoint.set(new_position) - finally: - self.readback.clear_sub(update_watchers) - if not self._set_success: - raise RuntimeError("Motor was stopped") - - def move(self, new_position: ControlEnum, timeout: Optional[float] = None): - """Commandline only synchronous move of a Motor""" - from bluesky.run_engine import call_in_bluesky_event_loop, in_bluesky_event_loop - - if in_bluesky_event_loop(): - raise RuntimeError("Will deadlock run engine if run in a plan") - call_in_bluesky_event_loop(self._move(new_position), timeout) # type: ignore - - def set( - self, new_position: ControlEnum, timeout: Optional[float] = None - ) -> AsyncStatus: - watchers: List[Callable] = [] - # todo here string or float? - coro = asyncio.wait_for(self._move(new_position, watchers), timeout=timeout) - return AsyncStatus(coro, watchers) - - async def stop(self, success=False): - self._set_success = success - # Put with completion will never complete as we are waiting for completion on - # the move above, so need to pass wait=False - status = self.stop_.trigger(wait=False) - await status - - -class StandardMovable(Device, Movable, Configurable, Stageable): - pass - - -class TestDetector(StandardReadable): - pass - - -class ControlEnum(str, Enum): - value1 = "close" - value2 = "open" - - -class TurboSlit(StandardMovable): - """ - todo for now only the x motor - add soft limits - check min speed - set speed back to before movement - """ - - # motor_x = Motor(prefix="BL20J-OP-PCHRO-01:TS:XFINE", name="motorX") - motor_x: ControlEnum = Cpt(ControlEnum, "TS:XFINE") - - motor_y = Motor(prefix="BL20J-OP-PCHRO-01:TS:YFINE", name="motorY") - - def set(self, position: str) -> AsyncStatus: - task: asyncio.Task = yield {} - c: AsyncStatus = AsyncStatus(awaitable=task) - return c - # status = self.motor_x.set(position) - # return status diff --git a/src/dodal/devices/turbo_slit.py b/src/dodal/devices/turbo_slit.py new file mode 100644 index 0000000000..7976301d56 --- /dev/null +++ b/src/dodal/devices/turbo_slit.py @@ -0,0 +1,17 @@ +from ophyd_async.core import Device +from ophyd_async.epics.motion.motor import Motor + + +class TurboSlit(Device): + """ + todo for now only the x motor + add soft limits + check min speed + set speed back to before movement + """ + + def __init__(self, prefix: str, name: str): + self.gap = Motor(prefix=prefix + "GAP") + self.arc = Motor(prefix=prefix + "ARC") + self.xfine = Motor(prefix=prefix + "XFINE") + super().__init__(name=name) From f2e40d4fc15a668d43ec008ca38ac5534d3585bf Mon Sep 17 00:00:00 2001 From: Stanislaw Malinowski Date: Thu, 4 Apr 2024 11:12:30 +0100 Subject: [PATCH 133/134] add docs --- src/dodal/devices/turbo_slit.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/dodal/devices/turbo_slit.py b/src/dodal/devices/turbo_slit.py index 7976301d56..d9db5491c2 100644 --- a/src/dodal/devices/turbo_slit.py +++ b/src/dodal/devices/turbo_slit.py @@ -4,10 +4,13 @@ class TurboSlit(Device): """ - todo for now only the x motor - add soft limits - check min speed - set speed back to before movement + This collection of motors coordinates time resolved XAS experiments. + It selects a beam out of the polychromatic fan. + These slits can be scanned continously or in step mode. + The relationship between the three motors is as follows: + - gap provides energy resolution + - xfine selects the energy + - arc - ??? """ def __init__(self, prefix: str, name: str): From 22f8f1966c3cf31904dec3d445e47f42615792b4 Mon Sep 17 00:00:00 2001 From: Stanislaw Malinowski Date: Thu, 4 Apr 2024 11:16:05 +0100 Subject: [PATCH 134/134] add docs to the beamline --- src/dodal/beamlines/i20_1.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/src/dodal/beamlines/i20_1.py b/src/dodal/beamlines/i20_1.py index 6fba97d298..fe5f1be071 100644 --- a/src/dodal/beamlines/i20_1.py +++ b/src/dodal/beamlines/i20_1.py @@ -2,27 +2,19 @@ from dodal.beamlines.beamline_utils import set_beamline as set_utils_beamline from dodal.devices.turbo_slit import TurboSlit from dodal.log import set_beamline as set_log_beamline -from dodal.utils import get_beamline_name, get_hostname, skip_device +from dodal.utils import get_beamline_name BL = get_beamline_name("i20_1") set_log_beamline(BL) set_utils_beamline(BL) -def _is_i20_1_machine(): - """ - Devices using PVA can only connect from i20_1 machines, due to the absence of - PVA gateways at present. - """ - hostname = get_hostname() - return hostname.startswith("i20_1") - - -@skip_device(lambda: not _is_i20_1_machine()) def turbo_slit( wait_for_connection: bool = True, fake_with_ophyd_sim: bool = False ) -> TurboSlit: - """Get the i20-1 motor""" + """ + turboslit for selecting energy from the polychromator + """ return device_instantiation( TurboSlit,