Skip to content

Commit

Permalink
Create mock signal backend (#251)
Browse files Browse the repository at this point in the history
* added `SoftSignalBackend` and `MockSignalBackend`

also added utility functions for `MockSignalBackend` in tests

* changed to give `MockSignalBackend` a reference to a `SoftSignalBackend`

* fixed flakey ioc test fixture

* use real panda controller in HDFPanda test
  • Loading branch information
evalott100 authored May 10, 2024
1 parent 5fd7928 commit 1e84233
Show file tree
Hide file tree
Showing 50 changed files with 1,265 additions and 742 deletions.
18 changes: 10 additions & 8 deletions docs/how-to/write-tests-for-devices.rst
Original file line number Diff line number Diff line change
Expand Up @@ -21,27 +21,29 @@ Async Tests
...
asyncio_mode = "auto"
Sim Backend
-----------
Mock Backend
------------

Ophyd devices initialized with a sim backend behave in a similar way to mocks, without requiring you to mock out all the dependencies and internals. The `DeviceCollector` can initialize any number of devices, and their signals and sub-devices (recursively), with a sim backend.
Ophyd devices initialized with a mock backend behave in a similar way to mocks, without requiring you to mock out all the dependencies and internals. The `DeviceCollector` can initialize any number of devices, and their signals and sub-devices (recursively), with a mock backend.

.. literalinclude:: ../../tests/epics/demo/test_demo.py
:pyobject: sim_sensor
:pyobject: mock_sensor


Sim Utility Functions
---------------------
Mock Utility Functions
----------------------

Sim signals behave as simply as possible, holding a sensible default value when initialized and retaining any value (in memory) to which they are set. This model breaks down in the case of read-only signals, which cannot be set because there is an expectation of some external device setting them in the real world. There is a utility function, ``set_sim_value``, to mock-set values for sim signals, including read-only ones.
Mock signals behave as simply as possible, holding a sensible default value when initialized and retaining any value (in memory) to which they are set. This model breaks down in the case of read-only signals, which cannot be set because there is an expectation of some external device setting them in the real world. There is a utility function, ``set_mock_value``, to mock-set values for mock signals, including read-only ones.

In addition this example also utilizes helper functions like ``assert_reading`` and ``assert_value`` to ensure the validity of device readings and values. For more information see: :doc:`API.core<../generated/ophyd_async.core>`

.. literalinclude:: ../../tests/epics/demo/test_demo.py
:pyobject: test_sensor_reading_shows_value


There is another utility function, ``set_sim_callback``, for hooking in logic when a sim value changes (e.g. because someone puts to it).
There are several other test utility functions:

Use ``callback_on_mock_put``, for hooking in logic when a mock value changes (e.g. because someone puts to it). This can be called directly, or used as a context, with the callbacks ending after exit.

.. literalinclude:: ../../tests/epics/demo/test_demo.py
:pyobject: test_mover_stopped
Expand Down
2 changes: 1 addition & 1 deletion docs/tutorials/using-existing-devices.rst
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ and run the following:
- If ``connect=True`` (the default), then call `Device.connect` in parallel for
all top level Devices, waiting for up to ``timeout`` seconds. For example,
here we call ``asyncio.wait([det.connect(), samp.connect()])``
- If ``sim=True`` is passed, then don't connect to PVs, but set Devices into
- If ``mock=True`` is passed, then don't connect to PVs, but set Devices into
simulation mode

The Devices we create in this example are a "sample stage" with a couple of
Expand Down
34 changes: 24 additions & 10 deletions src/ophyd_async/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,18 @@
walk_rw_signals,
)
from .flyer import HardwareTriggeredFlyable, TriggerLogic
from .mock_signal_backend import (
MockSignalBackend,
)
from .mock_signal_utils import (
assert_mock_put_called_with,
callback_on_mock_put,
mock_puts_blocked,
reset_mock_put_calls,
set_mock_put_proceeds,
set_mock_value,
set_mock_values,
)
from .signal import (
Signal,
SignalR,
Expand All @@ -36,15 +48,12 @@
assert_value,
observe_value,
set_and_wait_for_value,
set_sim_callback,
set_sim_put_proceeds,
set_sim_value,
soft_signal_r_and_backend,
soft_signal_r_and_setter,
soft_signal_rw,
wait_for_value,
)
from .signal_backend import SignalBackend
from .sim_signal_backend import SimSignalBackend
from .soft_signal_backend import SoftSignalBackend
from .standard_readable import ConfigSignal, HintedSignal, StandardReadable
from .utils import (
DEFAULT_TIMEOUT,
Expand All @@ -59,9 +68,15 @@
)

__all__ = [
"assert_mock_put_called_with",
"callback_on_mock_put",
"mock_puts_blocked",
"set_mock_values",
"reset_mock_put_calls",
"SignalBackend",
"SimSignalBackend",
"SoftSignalBackend",
"DetectorControl",
"MockSignalBackend",
"DetectorTrigger",
"DetectorWriter",
"StandardDetector",
Expand All @@ -73,13 +88,12 @@
"SignalW",
"SignalRW",
"SignalX",
"soft_signal_r_and_backend",
"soft_signal_r_and_setter",
"soft_signal_rw",
"observe_value",
"set_and_wait_for_value",
"set_sim_callback",
"set_sim_put_proceeds",
"set_sim_value",
"set_mock_put_proceeds",
"set_mock_value",
"wait_for_value",
"AsyncStatus",
"DirectoryInfo",
Expand Down
2 changes: 2 additions & 0 deletions src/ophyd_async/core/async_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ def __init__(
self.task = awaitable
else:
self.task = asyncio.create_task(awaitable) # type: ignore

self.task.add_done_callback(self._run_callbacks)

self._callbacks = cast(List[Callback[Status]], [])
self._watchers = watchers

Expand Down
18 changes: 9 additions & 9 deletions src/ophyd_async/core/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,20 +71,20 @@ def set_name(self, name: str):
child.set_name(child_name)
child.parent = self

async def connect(self, sim: bool = False, timeout: float = DEFAULT_TIMEOUT):
async def connect(self, mock: bool = False, timeout: float = DEFAULT_TIMEOUT):
"""Connect self and all child Devices.
Contains a timeout that gets propagated to child.connect methods.
Parameters
----------
sim:
If True then connect in simulation mode.
mock:
If True then use ``MockSignalBackend`` for all Signals
timeout:
Time to wait before failing with a TimeoutError.
"""
coros = {
name: child_device.connect(sim, timeout=timeout)
name: child_device.connect(mock=mock, timeout=timeout)
for name, child_device in self.children()
}
if coros:
Expand Down Expand Up @@ -118,9 +118,9 @@ class DeviceCollector:
If True, call ``device.set_name(variable_name)`` on all collected
Devices
connect:
If True, call ``device.connect(sim)`` in parallel on all
If True, call ``device.connect(mock)`` in parallel on all
collected Devices
sim:
mock:
If True, connect Signals in simulation mode
timeout:
How long to wait for connect before logging an exception
Expand All @@ -142,12 +142,12 @@ def __init__(
self,
set_name=True,
connect=True,
sim=False,
mock=False,
timeout: float = 10.0,
):
self._set_name = set_name
self._connect = connect
self._sim = sim
self._mock = mock
self._timeout = timeout
self._names_on_enter: Set[str] = set()
self._objects_on_exit: Dict[str, Any] = {}
Expand Down Expand Up @@ -181,7 +181,7 @@ async def _on_exit(self) -> None:
obj.set_name(name)
if self._connect:
connect_coroutines[name] = obj.connect(
self._sim, timeout=self._timeout
self._mock, timeout=self._timeout
)

# Connect to all the devices
Expand Down
86 changes: 86 additions & 0 deletions src/ophyd_async/core/mock_signal_backend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import asyncio
from typing import Optional, Type
from unittest.mock import MagicMock

from bluesky.protocols import Descriptor, Reading

from ophyd_async.core.signal_backend import SignalBackend
from ophyd_async.core.soft_signal_backend import SoftSignalBackend
from ophyd_async.core.utils import DEFAULT_TIMEOUT, ReadingValueCallback, T


class MockSignalBackend(SignalBackend):
def __init__(
self,
datatype: Optional[Type[T]] = None,
initial_backend: Optional[SignalBackend[T]] = None,
) -> None:
if isinstance(initial_backend, MockSignalBackend):
raise ValueError("Cannot make a MockSignalBackend for a MockSignalBackends")

self.initial_backend = initial_backend

if datatype is None:
assert (
self.initial_backend
), "Must supply either initial_backend or datatype"
datatype = self.initial_backend.datatype

self.datatype = datatype

if not isinstance(self.initial_backend, SoftSignalBackend):
# If the backend is a hard signal backend, or not provided,
# then we create a soft signal to mimick it

self.soft_backend = SoftSignalBackend(datatype=datatype)
else:
self.soft_backend = initial_backend

self.mock = MagicMock()

self.put_proceeds = asyncio.Event()
self.put_proceeds.set()

def source(self, name: str) -> str:
self.mock.source(name)
if self.initial_backend:
return f"mock+{self.initial_backend.source(name)}"
return f"mock+{name}"

async def connect(self, timeout: float = DEFAULT_TIMEOUT) -> None:
self.mock.connect(timeout=timeout)

async def put(self, value: Optional[T], wait=True, timeout=None):
self.mock.put(value, wait=wait, timeout=timeout)
await self.soft_backend.put(value, wait=wait, timeout=timeout)

if wait:
await asyncio.wait_for(self.put_proceeds.wait(), timeout=timeout)

def set_value(self, value: T):
self.mock.set_value(value)
self.soft_backend.set_value(value)

async def get_descriptor(self, source: str) -> Descriptor:
self.mock.get_descriptor(source)
return await self.soft_backend.get_descriptor(source)

async def get_reading(self) -> Reading:
self.mock.get_reading()
return await self.soft_backend.get_reading()

async def get_value(self) -> T:
self.mock.get_value()
return await self.soft_backend.get_value()

async def get_setpoint(self) -> T:
"""For a soft signal, the setpoint and readback values are the same."""
self.mock.get_setpoint()
return await self.soft_backend.get_setpoint()

async def get_datakey(self, source: str) -> Descriptor:
return await self.soft_backend.get_datakey(source)

def set_callback(self, callback: Optional[ReadingValueCallback[T]]) -> None:
self.mock.set_callback(callback)
self.soft_backend.set_callback(callback)
Loading

0 comments on commit 1e84233

Please sign in to comment.