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

Add user information on the DeviceCollector event-loop #114

Merged
merged 2 commits into from
Feb 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
52 changes: 52 additions & 0 deletions docs/user/explanations/event-loop-choice.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
Device Collector Event-Loop Choice
----------------------------------

In a sync context, the ophyd-async :python:`DeviceCollector` requires the bluesky event-loop
to connect to devices. In an async context, it does not.

Sync Context
============

In a sync context the run-engine must be initialized prior to connecting to devices.
We enfore usage of the bluesky event-loop in this context.

The following will fail if :python:`RE = RunEngine()` has not been called already:

.. code:: python

with DeviceCollector():
device1 = Device1(prefix)
device2 = Device2(prefix)
device3 = Device3(prefix)

The :python:`DeviceCollector` connects to devices in the event-loop created in the run-engine.


Async Context
=============

In an async context device connection is decoupled from the run-engine.
The following attempts connection to all the devices in the :python:`DeviceCollector`
before or after run-engine initialization.

.. code:: python

async def connection_function() :
async with DeviceCollector():
device1 = Device1(prefix)
device2 = Device2(prefix)
device3 = Device3(prefix)

asyncio.run(connection_function())

The devices will be unable to be used in the run-engine unless they share the same event-loop.
When the run-engine is initialised it will create a new background event-loop to use if one
is not passed in with :python:`RunEngine(loop=loop)`.

If the user wants to use devices in the async :python:`DeviceCollector` within the run-engine
they can either:

* Run the :python:`DeviceCollector` first and pass the event-loop into the run-engine.
* Initialize the run-engine first and run the :python:`DeviceCollector` using the bluesky event-loop.


1 change: 1 addition & 0 deletions docs/user/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ side-bar.
:maxdepth: 1

explanations/docs-structure
explanations/event-loop-choice

+++

Expand Down
12 changes: 10 additions & 2 deletions src/ophyd_async/core/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from bluesky.protocols import HasName
from bluesky.run_engine import call_in_bluesky_event_loop

from .utils import DEFAULT_TIMEOUT, wait_for_connection
from .utils import DEFAULT_TIMEOUT, NotConnected, wait_for_connection


class Device(HasName):
Expand Down Expand Up @@ -173,4 +173,12 @@ async def __aexit__(self, type, value, traceback):

def __exit__(self, type_, value, traceback):
self._objects_on_exit = self._caller_locals()
return call_in_bluesky_event_loop(self._on_exit())
try:
fut = call_in_bluesky_event_loop(self._on_exit())
except RuntimeError:
raise NotConnected(
"Could not connect devices. Is the bluesky event loop running? See "
"https://blueskyproject.io/ophyd-async/main/"
"user/explanations/event-loop-choice.html for more info."
)
return fut
55 changes: 51 additions & 4 deletions tests/core/test_device_collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,26 @@
from ophyd_async.core import DEFAULT_TIMEOUT, Device, DeviceCollector, NotConnected


class Dummy(Device):
class FailingDevice(Device):
async def connect(self, sim: bool = False, timeout=DEFAULT_TIMEOUT):
raise AttributeError()


def test_device_collector_handles_top_level_errors(RE, caplog):
class WorkingDevice(Device):
connected = False

async def connect(self, sim: bool = True, timeout=DEFAULT_TIMEOUT):
self.connected = True
return await super().connect(sim=True)

async def set(self, new_position: float): ...


async def test_device_collector_handles_top_level_errors(caplog):
caplog.set_level(10)
with pytest.raises(NotConnected) as exc:
with DeviceCollector():
_ = Dummy("somename")
async with DeviceCollector():
_ = FailingDevice("somename")

assert not exc.value.__cause__

Expand All @@ -25,3 +35,40 @@ def test_device_collector_handles_top_level_errors(RE, caplog):

assert len(device_log) == 1
device_log[0].levelname == "ERROR"


def test_device_connector_sync_no_run_engine_raises_error():
with pytest.raises(NotConnected) as e:
with DeviceCollector():
working_device = WorkingDevice("somename")
assert e.value._errors == (
"Could not connect devices. Is the bluesky event loop running? See "
"https://blueskyproject.io/ophyd-async/main/"
"user/explanations/event-loop-choice.html for more info."
)
assert not working_device.connected


def test_device_connector_sync_run_engine_created_connects(RE):
with DeviceCollector():
working_device = WorkingDevice("somename")

assert working_device.connected


"""
# TODO: Once passing a loop into the run-engine selector works, this should pass
async def test_device_connector_async_run_engine_same_event_loop():
async with DeviceCollector(sim=True):
sim_motor = motor.Motor("BLxxI-MO-TABLE-01:X")

RE = RunEngine(loop=asyncio.get_running_loop())

def my_plan():
sim_motor.move(3.14)
return

RE(my_plan())

assert await sim_motor.readback.get_value() == 3.14
"""
Loading