Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

(#391) Create an ophyd-async ApertureScatterguard device #419

Merged
merged 33 commits into from
May 7, 2024
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
e4e5122
ophyd-async aperture and scatterguard first attempt
katesmith280 Mar 20, 2024
adaaf77
WIP help..
katesmith280 Mar 26, 2024
7b376be
...
dperl-dls Mar 26, 2024
241414b
work in progress, scatterguard extended motor, async/await
katesmith280 Mar 26, 2024
2309790
Work in progress async
katesmith280 Mar 26, 2024
661ec59
WIP ophyd-asyncio .connect(sim=True) for unit-tests
katesmith280 Mar 27, 2024
7d0f304
WIP more tests passing now we are updating position values - thanks D…
katesmith280 Mar 27, 2024
ed2b34b
WIP working on tests
katesmith280 Apr 4, 2024
9650bea
(#391) mock _move instead of set in ap_sg
dperl-dls Apr 4, 2024
41ca74f
(#391) remove stray await
dperl-dls Apr 4, 2024
41436a3
_set_raw_unsafe to asyncio.gather
katesmith280 Apr 4, 2024
32be1b5
tidy up unused imports and old commented code block
katesmith280 Apr 4, 2024
1b94847
Merge branch 'main' into 391_aperture_scatterguard_ophyd_async
dperl-dls Apr 5, 2024
19a03bd
placate linter
dperl-dls Apr 5, 2024
ad5ba13
Merge pull request #394 from DiamondLightSource/hyperion_1033_clarify…
dperl-dls Apr 4, 2024
2de7fa1
(#391) fix logging and LUT tests
dperl-dls Apr 5, 2024
b6f7373
Merge branch 'main' of github.com:DiamondLightSource/dodal
dperl-dls Apr 5, 2024
418de3d
Merge branch 'main' into 391_aperture_scatterguard_ophyd_async
dperl-dls Apr 5, 2024
49fa716
WIP AsyncStatus bluseky tests
katesmith280 Apr 10, 2024
7325b3b
(#391) fix test
dperl-dls Apr 10, 2024
bf1de01
Merge branch 'main' into 391_aperture_scatterguard_ophyd_async
katesmith280 Apr 11, 2024
cedcfd2
(#391) update i03 test
dperl-dls Apr 12, 2024
5c5c97e
(#391) tidy up tests a little
dperl-dls Apr 12, 2024
9652718
Remove print from tests and obseleted comment
katesmith280 Apr 15, 2024
211703f
update for compatability with ophyd-async breaking change 0e7e45b
katesmith280 Apr 16, 2024
852adb6
Merge branch 'main' into 391_aperture_scatterguard_ophyd_async
katesmith280 Apr 17, 2024
991aaf0
Removed ExtendedMotor for Motor and changed motor_resolution to deadband
katesmith280 Apr 17, 2024
73c1361
remove old comment about deadband not in epics motor
katesmith280 Apr 18, 2024
a991d2f
remove unnecessary if statement (no more ExtendedMotor)
katesmith280 Apr 19, 2024
3848acb
Merge branch 'main' into 391_aperture_scatterguard_ophyd_async
DominicOram Apr 29, 2024
e173bbe
Remove unused imports
DominicOram Apr 29, 2024
1142f8b
Tidy up tests a bit
DominicOram Apr 29, 2024
b2ef997
Merge branch 'main' into 391_aperture_scatterguard_ophyd_async
katesmith280 May 2, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ repos:
name: Run ruff
stages: [commit]
language: system
entry: ruff
entry: ruff check
types: [python]

# Re-enable after https://github.com/DiamondLightSource/dodal/issues/311
Expand Down
21 changes: 12 additions & 9 deletions src/dodal/devices/aperture.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
from ophyd import Component, Device, EpicsSignalRO
from ophyd_async.core import StandardReadable
from ophyd_async.epics.signal import epics_signal_r

from dodal.devices.util.motor_utils import ExtendedEpicsMotor
from dodal.devices.util.motor_utils import ExtendedMotor


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")
class Aperture(StandardReadable):
def __init__(self, prefix: str, name: str = "") -> None:
self.x = ExtendedMotor(prefix + "X")
self.y = ExtendedMotor(prefix + "Y")
self.z = ExtendedMotor(prefix + "Z")
self.small = epics_signal_r(int, prefix + "Y:SMALL_CALC")
self.medium = epics_signal_r(int, prefix + "Y:MEDIUM_CALC")
self.large = epics_signal_r(int, prefix + "Y:LARGE_CALC")
super().__init__(name)
151 changes: 83 additions & 68 deletions src/dodal/devices/aperturescatterguard.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,12 @@
import operator
import asyncio
from collections import namedtuple
from dataclasses import dataclass
from functools import reduce
from typing import List, Sequence

from ophyd import Component as Cpt
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you will want to from bluesky.protocols import Movable

from ophyd import SignalRO
from ophyd.epics_motor import EpicsMotor
from ophyd.status import AndStatus, Status, StatusBase
from bluesky.protocols import Movable
from ophyd_async.core import AsyncStatus, SignalR, StandardReadable
from ophyd_async.core.sim_signal_backend import SimSignalBackend

from dodal.devices.aperture import Aperture
from dodal.devices.logging_ophyd_device import InfoLoggingDevice
from dodal.devices.scatterguard import Scatterguard
from dodal.log import LOGGER

Expand All @@ -33,10 +29,12 @@ class InvalidApertureMove(Exception):

@dataclass
class SingleAperturePosition:
name: str
GDA_name: str
radius_microns: float | None
location: ApertureFiveDimensionalLocation
name: str = ""
GDA_name: str = ""
radius_microns: float | None = 0
location: ApertureFiveDimensionalLocation = ApertureFiveDimensionalLocation(
0, 0, 0, 0, 0
)


def position_from_params(
Expand Down Expand Up @@ -74,7 +72,7 @@ def from_gda_beamline_params(cls, params):
ROBOT_LOAD=position_from_params("Robot load", "ROBOT_LOAD", None, params),
)

def as_list(self) -> List[SingleAperturePosition]:
def as_list(self) -> list[SingleAperturePosition]:
return [
self.LARGE,
self.MEDIUM,
Expand All @@ -83,29 +81,38 @@ def as_list(self) -> List[SingleAperturePosition]:
]


class ApertureScatterguard(InfoLoggingDevice):
aperture = Cpt(Aperture, "-MO-MAPT-01:")
scatterguard = Cpt(Scatterguard, "-MO-SCAT-01:")
aperture_positions: AperturePositions | None = None
TOLERANCE_STEPS = 3 # Number of MRES steps
class ApertureScatterguard(StandardReadable, Movable):
def __init__(self, prefix: str = "", name: str = "") -> None:
self.aperture = Aperture(prefix="-MO-MAPT-01:")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ApertureScatterguard should inherit from both StandardReadable but also Moveable, which defines what the signature for set() should look like

self.scatterguard = Scatterguard(prefix="-MO-SCAT-01:")
self.aperture_positions: AperturePositions | None = None
self.TOLERANCE_STEPS = 3 # Number of MRES steps
self.selected_aperture = self.SelectedAperture(
backend=SimSignalBackend(datatype=SingleAperturePosition, source="")
)
self.set_readable_signals(
read=[
self.selected_aperture,
]
)
super().__init__(name)

class SelectedAperture(SignalRO):
def get(self):
class SelectedAperture(SignalR):
async def read(self, *args, **kwargs):
assert isinstance(self.parent, ApertureScatterguard)
return self.parent._get_current_aperture_position()

selected_aperture = Cpt(SelectedAperture)
await self._backend.put(await self.parent._get_current_aperture_position())
return {self.name: await self._backend.get_reading()}

def load_aperture_positions(self, positions: AperturePositions):
LOGGER.info(f"{self.name} loaded in {positions}")
self.aperture_positions = positions

def set(self, pos: SingleAperturePosition) -> StatusBase:
def set(self, pos: SingleAperturePosition) -> AsyncStatus:
assert isinstance(self.aperture_positions, AperturePositions)
if pos not in self.aperture_positions.as_list():
raise InvalidApertureMove(f"Unknown aperture: {pos}")

return self._safe_move_within_datacollection_range(pos.location)
return AsyncStatus(self._safe_move_within_datacollection_range(pos.location))

def _get_motor_list(self):
return [
Expand All @@ -116,37 +123,49 @@ def _get_motor_list(self):
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)]
async def _set_raw_unsafe(self, positions: ApertureFiveDimensionalLocation):
"""Accept the risks and move in an unsafe way. Collisions possible."""

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

love this docstring

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed!

# unpacking the position
aperture_x, aperture_y, aperture_z, scatterguard_x, scatterguard_y = positions

await asyncio.gather(
self.aperture.x._move(aperture_x),
self.aperture.y._move(aperture_y),
self.aperture.z._move(aperture_z),
self.scatterguard.x._move(scatterguard_x),
self.scatterguard.y._move(scatterguard_y),
)

def _get_current_aperture_position(self) -> SingleAperturePosition:
async 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.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())
current_ap_y = await self.aperture.y.readback.get_value(cached=False)
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:
tolerance = (
self.TOLERANCE_STEPS * await self.aperture.y.motor_resolution.get_value()
)
# extendedepicsmotor class has tolerance fields added
# ophyd-async epics motor may need to do the same thing - epics motor
if await self.aperture.large.get_value(cached=False) == 1:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should: I think we now have these fields on the ophyd-async equivalent, so this comment is redundant?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment is now removed

return self.aperture_positions.LARGE
elif int(self.aperture.medium.get()) == 1:
elif await self.aperture.medium.get_value(cached=False) == 1:
return self.aperture_positions.MEDIUM
elif int(self.aperture.small.get()) == 1:
elif await self.aperture.small.get_value(cached=False) == 1:
return self.aperture_positions.SMALL
elif current_ap_y <= robot_load_ap_y + tolerance:
return self.aperture_positions.ROBOT_LOAD

raise InvalidApertureMove("Current aperture/scatterguard state unrecognised")

def _safe_move_within_datacollection_range(
async def _safe_move_within_datacollection_range(
self, pos: ApertureFiveDimensionalLocation
) -> StatusBase:
):
"""
Move the aperture and scatterguard combo safely to a new position.
See https://github.com/DiamondLightSource/hyperion/wiki/Aperture-Scatterguard-Collisions
Expand All @@ -159,19 +178,17 @@ 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()
ap_z_in_position = await self.aperture.z.motor_done_move.get_value()
if not ap_z_in_position:
status: Status = Status(obj=self)
status.set_exception(
InvalidApertureMove(
"ApertureScatterguard z is still moving. Wait for it to finish "
"before triggering another move."
)
raise InvalidApertureMove(
"ApertureScatterguard z is still moving. Wait for it to finish "
"before triggering another move."
)
return status

current_ap_z = self.aperture.z.user_setpoint.get()
tolerance = self.TOLERANCE_STEPS * self.aperture.z.motor_resolution.get()
current_ap_z = await self.aperture.z.readback.get_value()
tolerance = (
self.TOLERANCE_STEPS * await self.aperture.z.motor_resolution.get_value()
)
diff_on_z = abs(current_ap_z - aperture_z)
if diff_on_z > tolerance:
raise InvalidApertureMove(
Expand All @@ -180,28 +197,26 @@ def _safe_move_within_datacollection_range(
f"Current aperture z ({current_ap_z}), outside of tolerance ({tolerance}) from target ({aperture_z})."
)

current_ap_y = self.aperture.y.user_readback.get()
current_ap_y = await self.aperture.y.readback.get_value()

if aperture_y > current_ap_y:
sg_status: AndStatus = self.scatterguard.x.set(
scatterguard_x
) & self.scatterguard.y.set(scatterguard_y)
sg_status.wait()
return (
sg_status
& self.aperture.x.set(aperture_x)
& self.aperture.y.set(aperture_y)
& self.aperture.z.set(aperture_z)
await asyncio.gather(
self.scatterguard.x._move(scatterguard_x),
self.scatterguard.y._move(scatterguard_y),
)

ap_status: AndStatus = (
self.aperture.x.set(aperture_x)
& self.aperture.y.set(aperture_y)
& self.aperture.z.set(aperture_z)
await asyncio.gather(
self.aperture.x._move(aperture_x),
self.aperture.y._move(aperture_y),
self.aperture.z._move(aperture_z),
)
return
await asyncio.gather(
self.aperture.x._move(aperture_x),
self.aperture.y._move(aperture_y),
self.aperture.z._move(aperture_z),
)
ap_status.wait()
final_status: AndStatus = (
ap_status
& self.scatterguard.x.set(scatterguard_x)
& self.scatterguard.y.set(scatterguard_y)

await asyncio.gather(
self.scatterguard.x._move(scatterguard_x),
self.scatterguard.y._move(scatterguard_y),
)
return final_status
15 changes: 9 additions & 6 deletions src/dodal/devices/scatterguard.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
from ophyd import Component as Cpt
from ophyd import Device
from ophyd_async.core import StandardReadable

from dodal.devices.util.motor_utils import ExtendedEpicsMotor
from dodal.devices.util.motor_utils import ExtendedMotor


class Scatterguard(Device):
x = Cpt(ExtendedEpicsMotor, "X")
y = Cpt(ExtendedEpicsMotor, "Y")
class Scatterguard(StandardReadable):
"""The scatterguard device."""

def __init__(self, prefix: str, name: str = "") -> None:
self.x = ExtendedMotor(prefix + "X")
self.y = ExtendedMotor(prefix + "Y")
super().__init__(name)
10 changes: 10 additions & 0 deletions src/dodal/devices/util/motor_utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
from ophyd import Component, EpicsMotor, EpicsSignalRO
from ophyd_async.epics.motion import Motor
from ophyd_async.epics.signal import epics_signal_r


class ExtendedEpicsMotor(EpicsMotor):
motor_resolution: Component[EpicsSignalRO] = Component(EpicsSignalRO, ".MRES")
max_velocity: Component[EpicsSignalRO] = Component(EpicsSignalRO, ".VMAX")


class ExtendedMotor(Motor):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I forgot that I had done some work to get these into ophy-async ages ago. I've revived it at bluesky/ophyd-async#174. Let's not wait for that to be merged though, we can merge this in here and remove later

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still trying to get this into ophyd-async

def __init__(self, prefix: str, name: str = ""):
self.motor_resolution = epics_signal_r(float, prefix + ".MRES")
self.max_velocity = epics_signal_r(float, prefix + ".VMAX")
self.motor_done_move = epics_signal_r(float, prefix + ".DMOV")
super().__init__(name)
37 changes: 21 additions & 16 deletions tests/devices/unit_tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,34 @@
import asyncio
from functools import partial
from pathlib import Path
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, patch

import pytest
from ophyd.epics_motor import EpicsMotor
from ophyd.status import Status
from ophyd_async.core import DirectoryInfo, DirectoryProvider, StaticDirectoryProvider
from ophyd_async.epics.motion import Motor

from dodal.devices.util.motor_utils import ExtendedEpicsMotor
from dodal.devices.util.motor_utils import ExtendedMotor


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)
async def mock_good_coroutine():
return asyncio.sleep(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, ExtendedEpicsMotor):
motor.motor_resolution.sim_put(0.001) # type: ignore
return patch.object(motor, "set", MagicMock(side_effect=partial(mock_set, motor)))
def mock_move(motor: ExtendedMotor | Motor, val, *args, **kwargs):
motor.setpoint._backend._set_value(val) # type: ignore
motor.readback._backend._set_value(val) # type: ignore
return mock_good_coroutine() # type: ignore


def patch_motor(motor: ExtendedMotor | Motor, initial_position=0):
motor.setpoint._backend._set_value(initial_position) # type: ignore
motor.readback._backend._set_value(initial_position) # type: ignore
if isinstance(motor, ExtendedMotor):
motor.motor_resolution._backend._set_value(0.001) # type: ignore
motor.motor_done_move._backend._set_value(1) # type: ignore
return patch.object(
motor, "_move", AsyncMock(side_effect=partial(mock_move, motor))
)


DIRECTORY_INFO_FOR_TESTING: DirectoryInfo = DirectoryInfo(
Expand Down
7 changes: 4 additions & 3 deletions tests/devices/unit_tests/test_aperture.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@


@pytest.fixture
def fake_aperture():
async def fake_aperture():
FakeAperture = make_fake_device(Aperture)
fake_aperture: Aperture = FakeAperture(name="aperture")
fake_aperture: Aperture = FakeAperture(prefix="test_ap", name="aperture")
await fake_aperture.connect(sim=True)
return fake_aperture


def test_aperture_can_be_created(fake_aperture: Aperture):
fake_aperture.wait_for_connection()
assert isinstance(fake_aperture, Aperture)
Loading
Loading