Skip to content

Commit

Permalink
Read hardware on every read of the aperture scatterguard (#789)
Browse files Browse the repository at this point in the history
* Make sure that the aperture scatterguard position is set correctly and add tests

* Add helper signal that is backed by some function

* Use a hardware backed signal for the aperturescatterguard

* Apply suggestions from code review

Co-authored-by: DiamondJoseph <[email protected]>

* Make get_current_aperture_position private

* Fix linting

* Add some documentation pointing to the future cleaner derived signals

---------

Co-authored-by: DiamondJoseph <[email protected]>
  • Loading branch information
DominicOram and DiamondJoseph committed Sep 27, 2024
1 parent 936dfaa commit 228d9dc
Show file tree
Hide file tree
Showing 5 changed files with 189 additions and 41 deletions.
83 changes: 78 additions & 5 deletions docs/reference/device-standards.rst
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,17 @@ should think about where to place them in the following order:

This is in an effort to avoid duplication across facilities/beamlines.

Device Best Practices
----------------------------

Ophyd-async directory contains a flowchart_ for a simplified decision tree about what interfaces
should a given device implement. In addition to this the following guidelines are strongly recommended:

#. Devices should contain only the PV suffixes that are generic for any instance of the device. See `PV Suffixes`_
#. Anything in a device that is expected to be set externally should be a signal. See `Use of signals`_
#. Devices should not hold state, when they are read they should read the hardware. See `Holding State`_


PV Suffixes
-----------

Expand Down Expand Up @@ -86,16 +97,78 @@ Instead you should make a soft signal:
class MyDevice(Device):
def __init__(self):
self.param = create_soft_signal(str, "", self.name)
self.param = soft_signal_rw(str)
my_device = MyDevice()
def my_plan():
yield from bps.mv(my_device.param, "new_value")
Ophyd Devices best practices
----------------------------
Ophyd-async directory contains a flowchart_ for a simplified decision tree about what interfaces
should a given device implement.
Holding State
-------------

Devices should avoid holding state as much as possible. Ophyd devices are mostly trying to reflect the state of hardware and so when the device is read that hardware should be read.

If the device holds the state itself it is likely to not reflect the real hardware if:
* The device has just been initialised
* The hardware has changed independently e.g. via EPICS directly
* The hardware has failed to do what the device expected

For example, if I have a device that I would like to treat as moving in/out based on an underlying axis then it would be incorrect to implement it like this:

.. code-block:: python
class InOut(Enum):
IN = 0
OUT = 0
class MyDevice(Device):
def __init__(self):
self.underlying_motor = Motor("MOTOR")
with self.add_children_as_readables():
self.in_out, self._in_out_setter = soft_signal_r_and_setter(InOut)
@AsyncStatus.wrap
async def set(self, value: InOut):
if value == InOut.IN:
await self.underlying_motor.set(100)
else:
await self.underlying_motor.set(0)
self._in_out_setter(value)
While this may appear to work fine during normal operation the state of in_out is only ever updated if the ophyd device is set. It is incorrect to assume that underlying_motor only changes
based on this and so this has the issues listed above. Instead you should make sure to update in_out whenever the device is read e.g.

.. code-block:: python
class InOut(Enum):
IN = 0
OUT = 0
class MyDevice(Device):
def __init__(self):
self.underlying_motor = Motor("MOTOR")
with self.add_children_as_readables():
self.in_out = create_hardware_backed_soft_signal(InOut, self._get_in_out_from_hardware)
async def _get_in_out_from_hardware(self):
current_position = await self.underlying_motor.get_value()
if isclose(current_position, 0):
return InOut.IN
elif isclose(current_position, 100):
return InOut.OUT
else:
raise ValueError()
@AsyncStatus.wrap
async def set(self, value: InOut):
if value == InOut.IN:
await self.underlying_motor.set(100)
else:
await self.underlying_motor.set(0)
This will be simplified by https://github.com/bluesky/ophyd-async/issues/525

.. _flowchart: https://blueskyproject.io/ophyd-async/main/how-to/choose-interfaces-for-devices.html
2 changes: 1 addition & 1 deletion pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@ Fixes #ISSUE

### Checks for reviewer
- [ ] Would the PR title make sense to a scientist on a set of release notes
- [ ] If a new device has been added does it follow the [standards](https://github.com/DiamondLightSource/dodal/wiki/Device-Standards)
- [ ] If a new device has been added does it follow the [standards](https://diamondlightsource.github.io/dodal/main/reference/device-standards.html)
- [ ] If changing the API for a pre-existing device, ensure that any beamlines using this device have updated their Bluesky plans accordingly
- [ ] Have the connection tests for the relevant beamline(s) been run via `dodal connect ${BEAMLINE}`
53 changes: 53 additions & 0 deletions src/dodal/common/signal_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from collections.abc import Callable, Coroutine
from typing import Any, TypeVar

from bluesky.protocols import Reading
from ophyd_async.core import SignalR, SoftSignalBackend
from ophyd_async.core._soft_signal_backend import SignalMetadata

T = TypeVar("T")


class HarwareBackedSoftSignalBackend(SoftSignalBackend[T]):
def __init__(
self,
get_from_hardware_func: Callable[[], Coroutine[Any, Any, T]],
*args,
**kwargs,
) -> None:
self.get_from_hardware_func = get_from_hardware_func
super().__init__(*args, **kwargs)

async def _update_value(self):
new_value = await self.get_from_hardware_func()
await self.put(new_value)

async def get_reading(self) -> Reading:
await self._update_value()
return await super().get_reading()

async def get_value(self) -> T:
await self._update_value()
return await super().get_value()


def create_hardware_backed_soft_signal(
datatype: type[T],
get_from_hardware_func: Callable[[], Coroutine[Any, Any, T]],
units: str | None = None,
precision: int | None = None,
):
"""Creates a soft signal that, when read will call the function passed into
`get_from_hardware_func` and return this.
This will allow you to make soft signals derived from arbitrary hardware signals.
However, calling subscribe on this signal does not give you a sensible value and
the signal is currently read only. See https://github.com/bluesky/ophyd-async/issues/525
for a more full solution.
"""
metadata = SignalMetadata(units=units, precision=precision)
return SignalR(
backend=HarwareBackedSoftSignalBackend(
get_from_hardware_func, datatype, metadata=metadata
)
)
27 changes: 12 additions & 15 deletions src/dodal/devices/aperturescatterguard.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@
AsyncStatus,
HintedSignal,
StandardReadable,
soft_signal_rw,
)
from pydantic import BaseModel, Field

from dodal.common.beamlines.beamline_parameters import GDABeamlineParameters
from dodal.common.signal_utils import create_hardware_backed_soft_signal
from dodal.devices.aperture import Aperture
from dodal.devices.scatterguard import Scatterguard

Expand Down Expand Up @@ -105,7 +105,9 @@ def __init__(
) -> None:
self.aperture = Aperture(prefix + "-MO-MAPT-01:")
self.scatterguard = Scatterguard(prefix + "-MO-SCAT-01:")
self.radius = soft_signal_rw(float, units="µm")
self.radius = create_hardware_backed_soft_signal(
float, self._get_current_radius, units="µm"
)
self._loaded_positions = loaded_positions
self._tolerances = tolerances
self.add_readables(
Expand All @@ -119,7 +121,9 @@ def __init__(
],
)
with self.add_children_as_readables(HintedSignal):
self.selected_aperture = soft_signal_rw(ApertureValue)
self.selected_aperture = create_hardware_backed_soft_signal(
ApertureValue, self._get_current_aperture_position
)

super().__init__(name)

Expand All @@ -136,9 +140,6 @@ async def set(self, value: ApertureValue):
@AsyncStatus.wrap
async def _set_raw_unsafe(self, position: AperturePosition):
"""Accept the risks and move in an unsafe way. Collisions possible."""
if position.radius is not None:
await self.radius.set(position.radius)

aperture_x, aperture_y, aperture_z, scatterguard_x, scatterguard_y = (
position.values
)
Expand All @@ -150,13 +151,8 @@ async def _set_raw_unsafe(self, position: AperturePosition):
self.scatterguard.x.set(scatterguard_x),
self.scatterguard.y.set(scatterguard_y),
)
try:
value = await self.get_current_aperture_position()
self.selected_aperture.set(value)
except InvalidApertureMove:
self.selected_aperture.set(None) # type: ignore

async def get_current_aperture_position(self) -> ApertureValue:
async def _get_current_aperture_position(self) -> ApertureValue:
"""
Returns the current aperture position using readback values
for SMALL, MEDIUM, LARGE. ROBOT_LOAD position defined when
Expand All @@ -176,6 +172,10 @@ async def get_current_aperture_position(self) -> ApertureValue:

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

async def _get_current_radius(self) -> float | None:
current_value = await self._get_current_aperture_position()
return self._loaded_positions[current_value].radius

async def _safe_move_within_datacollection_range(
self, position: AperturePosition, value: ApertureValue
):
Expand Down Expand Up @@ -203,8 +203,6 @@ async def _safe_move_within_datacollection_range(
)

current_ap_y = await self.aperture.y.user_readback.get_value()
if position.radius is not None:
await self.radius.set(position.radius)

aperture_x, aperture_y, aperture_z, scatterguard_x, scatterguard_y = (
position.values
Expand All @@ -231,4 +229,3 @@ async def _safe_move_within_datacollection_range(
self.scatterguard.x.set(scatterguard_x),
self.scatterguard.y.set(scatterguard_y),
)
await self.selected_aperture.set(value)
65 changes: 45 additions & 20 deletions tests/devices/unit_tests/test_aperture_scatterguard.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from bluesky.run_engine import RunEngine
from ophyd_async.core import (
DeviceCollector,
callback_on_mock_put,
get_mock_put,
set_mock_value,
)
Expand Down Expand Up @@ -115,6 +116,7 @@ async def aperture_in_medium_pos_w_call_log(
await ap_sg._set_raw_unsafe(aperture_positions[ApertureValue.MEDIUM])

set_mock_value(ap_sg.aperture.medium, 1)

yield ap_sg, call_log


Expand Down Expand Up @@ -144,9 +146,6 @@ def _assert_position_in_reading(
position: AperturePosition,
device_name: str,
):
if position.radius is not None:
assert reading[f"{device_name}-radius"]["value"] == position.radius
assert reading[f"{device_name}-selected_aperture"]["value"] == aperture
assert reading[f"{device_name}-aperture-x"]["value"] == position.aperture_x
assert reading[f"{device_name}-aperture-y"]["value"] == position.aperture_y
assert reading[f"{device_name}-aperture-z"]["value"] == position.aperture_z
Expand Down Expand Up @@ -244,19 +243,27 @@ def set_underlying_motors(ap_sg: ApertureScatterguard, position: AperturePositio
motor.set(pos)


async def test_aperture_positions_large(ap_sg: ApertureScatterguard):
set_mock_value(ap_sg.aperture.large, 1)
assert await ap_sg.get_current_aperture_position() == ApertureValue.LARGE


async def test_aperture_positions_medium(ap_sg: ApertureScatterguard):
set_mock_value(ap_sg.aperture.medium, 1)
assert await ap_sg.get_current_aperture_position() == ApertureValue.MEDIUM


async def test_aperture_positions_small(ap_sg: ApertureScatterguard):
set_mock_value(ap_sg.aperture.small, 1)
assert await ap_sg.get_current_aperture_position() == ApertureValue.SMALL
@pytest.mark.parametrize(
"read_pv, aperture",
[
("large", ApertureValue.LARGE),
("medium", ApertureValue.MEDIUM),
("small", ApertureValue.SMALL),
],
)
async def test_aperture_positions(
ap_sg: ApertureScatterguard,
aperture_positions: dict[ApertureValue, AperturePosition],
read_pv: str,
aperture: ApertureValue,
):
set_mock_value(getattr(ap_sg.aperture, read_pv), 1)
reading = await ap_sg.read()
assert isinstance(reading, dict)
assert (
reading[f"{ap_sg.name}-radius"]["value"] == aperture_positions[aperture].radius
)
assert reading[f"{ap_sg.name}-selected_aperture"]["value"] == aperture


async def test_aperture_positions_robot_load(
Expand All @@ -268,7 +275,12 @@ async def test_aperture_positions_robot_load(
set_mock_value(ap_sg.aperture.small, 0)
robot_load = aperture_positions[ApertureValue.ROBOT_LOAD]
await ap_sg.aperture.y.set(robot_load.aperture_y)
assert await ap_sg.get_current_aperture_position() == ApertureValue.ROBOT_LOAD
reading = await ap_sg.read()
assert isinstance(reading, dict)
assert reading[f"{ap_sg.name}-radius"]["value"] == 0.0
assert (
reading[f"{ap_sg.name}-selected_aperture"]["value"] == ApertureValue.ROBOT_LOAD
)


async def test_aperture_positions_robot_load_within_tolerance(
Expand All @@ -282,7 +294,12 @@ async def test_aperture_positions_robot_load_within_tolerance(
set_mock_value(ap_sg.aperture.medium, 0)
set_mock_value(ap_sg.aperture.small, 0)
await ap_sg.aperture.y.set(robot_load_ap_y + tolerance)
assert await ap_sg.get_current_aperture_position() == ApertureValue.ROBOT_LOAD
reading = await ap_sg.read()
assert isinstance(reading, dict)
assert reading[f"{ap_sg.name}-radius"]["value"] == 0.0
assert (
reading[f"{ap_sg.name}-selected_aperture"]["value"] == ApertureValue.ROBOT_LOAD
)


async def test_aperture_positions_robot_load_outside_tolerance(
Expand All @@ -297,7 +314,7 @@ async def test_aperture_positions_robot_load_outside_tolerance(
set_mock_value(ap_sg.aperture.small, 0)
await ap_sg.aperture.y.set(robot_load_ap_y + tolerance)
with pytest.raises(InvalidApertureMove):
await ap_sg.get_current_aperture_position()
await ap_sg.read()


async def test_aperture_positions_robot_load_unsafe(
Expand All @@ -308,7 +325,7 @@ async def test_aperture_positions_robot_load_unsafe(
set_mock_value(ap_sg.aperture.small, 0)
await ap_sg.aperture.y.set(50.0)
with pytest.raises(InvalidApertureMove):
await ap_sg.get_current_aperture_position()
await ap_sg.read()


@pytest.mark.skip(
Expand Down Expand Up @@ -360,6 +377,14 @@ async def test_ap_sg_in_runengine(
ap = aperture_in_medium_pos.aperture
sg = aperture_in_medium_pos.scatterguard
test_loc = aperture_positions[ApertureValue.SMALL]

def set_small_readback_pv(value, *args, **kwargs):
set_mock_value(ap.small, 1)
set_mock_value(ap.medium, 0)
set_mock_value(ap.y.user_readback, value)

callback_on_mock_put(ap.y.user_setpoint, set_small_readback_pv)

RE(bps.abs_set(aperture_in_medium_pos, ApertureValue.SMALL, wait=True))
assert await ap.x.user_readback.get_value() == test_loc.aperture_x
assert await ap.y.user_readback.get_value() == test_loc.aperture_y
Expand Down

0 comments on commit 228d9dc

Please sign in to comment.