diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 695d35ea5e..87019ea0c9 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -20,6 +20,7 @@ "editor.codeActionsOnSave": { "source.organizeImports": "explicit" }, + "remote.autoForwardPorts": false, "[python]": { "editor.defaultFormatter": "charliermarsh.ruff" } diff --git a/docs/_templates/custom-module-template.rst b/docs/_templates/custom-module-template.rst index 726bf49435..9aeca54015 100644 --- a/docs/_templates/custom-module-template.rst +++ b/docs/_templates/custom-module-template.rst @@ -1,8 +1,3 @@ -.. note:: - - Ophyd async is considered experimental until the v1.0 release and - may change API on minor release numbers before then - {{ ('``' + fullname + '``') | underline }} {%- set filtered_members = [] %} diff --git a/docs/conf.py b/docs/conf.py index 7a7d1db7bc..324a4496be 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -98,7 +98,7 @@ autodoc_inherit_docstrings = False # Add some more modules to the top level autosummary -ophyd_async.__all__ += ["sim", "epics", "tango", "fastcs", "plan_stubs"] +ophyd_async.__all__ += ["sim", "epics", "tango", "fastcs", "plan_stubs", "testing"] # Document only what is in __all__ autosummary_ignore_module_all = False diff --git a/docs/examples/epics_demo.py b/docs/examples/epics_demo.py deleted file mode 100644 index 20b7ba18a4..0000000000 --- a/docs/examples/epics_demo.py +++ /dev/null @@ -1,37 +0,0 @@ -# Import bluesky and ophyd -import matplotlib.pyplot as plt -from bluesky import RunEngine -from bluesky.callbacks.best_effort import BestEffortCallback -from bluesky.plan_stubs import mov, movr, rd # noqa -from bluesky.plans import grid_scan # noqa -from bluesky.utils import ProgressBarManager, register_transform -from ophyd import Component, Device, EpicsSignal, EpicsSignalRO - -from ophyd_async.core import init_devices -from ophyd_async.epics import sim - -# Create a run engine, with plotting, progressbar and transform -RE = RunEngine({}, call_returns_result=True) -bec = BestEffortCallback() -RE.subscribe(bec) -RE.waiting_hook = ProgressBarManager() -plt.ion() -register_transform("RE", prefix="<") - -# Start IOC with demo pvs in subprocess -pv_prefix = sim.start_ioc_subprocess() - - -# Create ophyd devices -class OldSensor(Device): - mode = Component(EpicsSignal, "Mode", kind="config") - value = Component(EpicsSignalRO, "Value", kind="hinted") - - -det_old = OldSensor(pv_prefix, name="det_old") - -# Create ophyd-async devices -with init_devices(): - det = sim.Sensor(pv_prefix) - det_group = sim.SensorGroup(pv_prefix) - samp = sim.SampleStage(pv_prefix) diff --git a/docs/explanations/declarative-vs-procedural.md b/docs/explanations/declarative-vs-procedural.md new file mode 100644 index 0000000000..f6612d5798 --- /dev/null +++ b/docs/explanations/declarative-vs-procedural.md @@ -0,0 +1,38 @@ +# Declarative vs Procedural Devices + +Ophyd async has two styles of creating Devices, Declarative and Procedural. This article describes why there are two mechanisms for building Devices, and looks at the pros and cons of each style. + +## Procedural style + +The procedural style mirrors how you would create a traditional python class, you define an `__init__` method, add some class members, then call the superclass `__init__` method. In the case of ophyd async those class members are likely to be Signals and other Devices. For example, in the `ophyd_async.sim.SimMotor` we create its soft signal children in an `__init__` method: +```{literalinclude} ../../src/ophyd_async/sim/_sim_motor.py +:start-after: class SimMotor +:end-before: def set_name +``` +It is explicit and obvious, but verbose. It also allows you to embed arbitrary python logic in the creation of signals, so is required for making soft signals and DeviceVectors with contents based on an argument passed to `__init__`. It also allows you to use the [](#StandardReadable.add_readable_children) context manager which can save some typing. + +## Declarative style + +The declarative style mirrors how you would create a pydantic `BaseModel`. You create type hints to tell the base class what type of object you create, add annotations to tell it some parameters on how to create it, then the base class `__init__` will introspect and create them. For example, in the `ophyd_async.fastcs.panda.PulseBlock` we define the members we expect, and the baseclass will introspect the selected FastCS transport (EPICS IOC or Tango Device Server) and connect them, adding any extras that are published: +```{literalinclude} ../../src/ophyd_async/fastcs/panda/_panda.py +:start-after: for docs: start PulseBlock +:end-before: for docs: end PulseBlock +``` +For a traditional EPICS IOC there is no such introspection mechanism, so we require a PV Suffix to be supplied via an annotation. For example, in `ophyd_async.epics.sim.Counter` we describe the PV Suffix and whether the signal appears in `read()` or `read_configuration()` using [](#typing.Annotated): +```{literalinclude} ../../src/ophyd_async/epics/sim/_counter_.py +:start-after: class Counter +:end-before: class MultiChannelCounter +``` +It is compact and has the minimum amount of boilerplate, but is limited in its scope to what sorts of Signals and Devices the base class can create. It also requires the usage of a [](#StandardReadableFormat) for each Signal if using [](#StandardReadable) which may be more verbose than the procedural approach. It is best suited for introspectable FastCS and Tango devices, and repetitive EPICS Devices that are wrapped into larger Devices like areaDetectors. + +## Grey area + +There is quite a large segment of Devices that could be written both ways, for instance `ophyd_async.epics.sim.Mover`. This could be written in either style with roughly the same legibility, so is a matter of taste: +```{literalinclude} ../../src/ophyd_async/epics/sim/_mover.py +:start-after: class Mover +:end-before: baa +``` + +## Conclusion + +Ophyd async supports both the declarative and procedural style, and is not prescriptive about which is used. In the end the decision is likely to come down to personal taste, and the style of the surrounding code. diff --git a/docs/tutorials/implementing-epics-devices.md b/docs/tutorials/implementing-epics-devices.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/tutorials/installation.md b/docs/tutorials/installation.md index a55b96b935..1d8654be90 100644 --- a/docs/tutorials/installation.md +++ b/docs/tutorials/installation.md @@ -27,6 +27,19 @@ You can now use `pip` to install the library and its dependencies: $ python3 -m pip install ophyd-async ``` +If you need to talk to a given control system, you will need to install +the specific extra: +- `ca` for EPICS Channel Access +- `pva` for EPICS PVAccess +- `tango` for Tango +- `demo` for tutorial requirements like h5py and ipython +- `testing` for testing requirements like pytest + +E.g.: +``` +$ python3 -m pip install ophyd-async[ca,demo] +``` + If you require a feature that is not currently released you can also install from github: @@ -38,5 +51,5 @@ The library should now be installed and the commandline interface on your path. You can check the version that has been installed by typing: ``` -$ ophyd-async --version +$ python -m ophyd_async --version ``` diff --git a/docs/tutorials/using-existing-devices.md b/docs/tutorials/using-existing-devices.md new file mode 100644 index 0000000000..ab2e78b817 --- /dev/null +++ b/docs/tutorials/using-existing-devices.md @@ -0,0 +1,116 @@ +# Using existing Devices + +In this tutorial we will create a bluesky RunEngine, instantiate some existing ophyd-async Devices, and use them in some bluesky plans. It assumes you have already run through the Bluesky tutorial on `tutorial_run_engine_setup`. + +## Run the demo + +Ophyd-async ships with some simulated devices and a demo script that will create them along with a RunEngine. Let's take a look at it now: +```{literalinclude} ../../src/ophyd_async/sim/demo/__main__.py +:language: python +``` + +We will explain the contents in more detail later on, but for now let's run it in an interactive [ipython](https://ipython.org) shell: +``` +$ ipython -i -m ophyd_async.sim.demo +Python 3.11.11 (main, Dec 4 2024, 20:38:25) [GCC 12.2.0] +Type 'copyright', 'credits' or 'license' for more information +IPython 8.30.0 -- An enhanced Interactive Python. Type '?' for help. + +In [1]: +``` + +This has launched an ipython shell, told it to import and run the demo script packaged inside `ophyd_async.sim.demo`, then return to an interactive prompt. + +## Investigate the Devices + +We will look at the `x` and `y` motors first. If we examine them we can see that they have a name: +```python +In [1]: x.name +Out[1]: 'x' +``` + +But if we try to call any of the other methods like `read()` we will see that it doesn't return the value, but a [coroutines](inv:python:std:label#coroutine): + +```python +In [2]: x.read() +Out[2]: +``` + +This is because ophyd-async devices implement async versions of the bluesky [verbs](inv:bluesky#hardware). To get the value we can `await` it: + ```python +In [3]: await x.read() +Out[3]: +{'x-user_readback': {'value': 0.0, + 'timestamp': 367727.615860209, + 'alarm_severity': 0}} +``` + +## Run some plans + +Although it is useful to run the verbs using the `await` syntax for debugging, most of the time we will run them via plans executed by the [](#bluesky.run_engine.RunEngine). For instance we can read it using the [`bps.rd`](#bluesky.plan_stubs.rd) plan stub: + ```python +In [4]: RE(bps.rd(x)) +Out[4]: RunEngineResult(run_start_uids=(), plan_result=0.0, exit_status='success', interrupted=False, reason='', exception=None) +``` + +and move it using the [`bps.mv`](#bluesky.plan_stubs.mv) plan sub: + ```python +In [5]: RE(bps.mv(x, 1.5)) +Out[5]: RunEngineResult(run_start_uids=(), plan_result=(, done>,), exit_status='success', interrupted=False, reason='', exception=None) + +In [6]: RE(bps.rd(x)) +Out[6]: RunEngineResult(run_start_uids=(), plan_result=1.5, exit_status='success', interrupted=False, reason='', exception=None) +``` + +There is also a detector that changes its output based on the positions of the `x` and `y` motor, so we can use it in a [`bp.grid_scan`](#bluesky.plans.grid_scan): +```python +In [7]: RE(bp.grid_scan([det], x, -10, 10, 10, y, -8, 8, 9)) +Out[7]: RunEngineResult(run_start_uids=('63dc35b7-e4b9-46a3-9bcb-c64d8106cbf3',), plan_result='63dc35b7-e4b9-46a3-9bcb-c64d8106cbf3', exit_status='success', interrupted=False, reason='', exception=None) +``` + +:::{seealso} +A more interactive scanning tutorial including live plotting of the data is in the process of being written in [the bluesky cookbook](https://github.com/bluesky/bluesky-cookbook/pull/22) +::: + +## Examine the script + +We will now walk through the script section by section and examine what each part does. First of all we import the bluesky and ophyd libraries: +```{literalinclude} ../../src/ophyd_async/sim/demo/__main__.py +:language: python +:start-after: Import bluesky and ophyd +:end-before: Create a run engine +``` + +After this we create a RunEngine: +```{literalinclude} ../../src/ophyd_async/sim/demo/__main__.py +:language: python +:start-after: Create a run engine +:end-before: Define where test data should be written +``` +We pass `call_returns_result=True` to the RunEngine so that we can see the result of `bps.rd` above. We call `autoawait_in_bluesky_event_loop()` so that when we `await bps.rd(x)` it will happen in the same event loop that the RunEngine uses rather than an IPython specific one. This avoids some surprising behaviour that occurs when devices are accessed from multiple event loops. + +Next up is the path provider: +```{literalinclude} ../../src/ophyd_async/sim/demo/__main__.py +:language: python +:start-after: Define where test data should be written +:end-before: All Devices created within this block +``` +This is how we specify in which location file-writing detectors store their data. In this example we choose to write to a static directory `/tmp` using the [](#StaticPathProvider), and to name each file within it with a unique UUID using the [](#UUIDFilenameProvider). [Other PathProviders](#PathProvider) allow this to be customized. + +Finally we create and connect the Devices: +```{literalinclude} ../../src/ophyd_async/sim/demo/__main__.py +:language: python +:start-after: connected and named at the end of the with block +``` +The first thing to note is the `with` statement. This uses a [](#init_devices) as a context manager to collect up the top level `Device` instances created in the context, and run the following: + +- If `set_name=True` (the default), then call [](#Device.set_name) passing the name of the variable within the context. For example, here we call + ``det.set_name("det")`` +- 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 will connect `x`, `y` and `det` at the same time. This parallel connect speeds up connection to the underlying control system. +- If ``mock=True`` is passed, then don't connect to the control system, but set Devices into mock mode for testing. + +Within it the device creation happens, in this case the `x` and `y` motors and a `det` detector that gives different data depending on the position of the motors. + +## Conclusion + +In this tutorial we have instantiated some existing ophyd-async devices, seen how they can be connected and named, and used them in some basic plans. Read on to see how to implement support for devices via a control system like EPICS or Tango. diff --git a/docs/tutorials/using-existing-devices.rst b/docs/tutorials/using-existing-devices.rst deleted file mode 100644 index 77a031b39b..0000000000 --- a/docs/tutorials/using-existing-devices.rst +++ /dev/null @@ -1,184 +0,0 @@ -.. note:: - - Ophyd async is included on a provisional basis until the v1.0 release and - may change API on minor release numbers before then - -Using existing Devices -====================== - -To use an Ophyd Device that has already been written, you need to make a -RunEngine, then instantiate the Device in that process. This tutorial will take -you through this process. It assumes you have already run through the Bluesky -tutorial on `tutorial_run_engine_setup`. - -Create Startup file -------------------- - -For this tutorial we will use IPython. We will instantiate the RunEngine and -Devices in a startup file. This is just a regular Python file that IPython -will execute before giving us a prompt to execute scans. Copy the text -below and place it in an ``epics_demo.py`` file: - -.. literalinclude:: ../examples/epics_demo.py - :language: python - -The top section of the file is explained in the Bluesky tutorial, but the bottom -section is Ophyd specific. - -First of all we start up a specific EPICS IOC for the demo devices. This is only -used in this tutorial: - -.. literalinclude:: ../examples/epics_demo.py - :language: python - :start-after: # Start IOC - :end-before: # Create ophyd devices - -Next we create an example Ophyd device for comparison purposes. It is here to show -that you can mix Ophyd and Ophyd Async devices in the same RunEngine: - -.. literalinclude:: ../examples/epics_demo.py - :language: python - :start-after: # Create ophyd devices - :end-before: # Create ophyd-async devices - -Finally we create the Ophyd Async devices imported from the `epics.sim` module: - -.. literalinclude:: ../examples/epics_demo.py - :language: python - :start-after: # Create ophyd-async devices - -The first thing to note is `with`. This uses `init_devices` as a context -manager to collect up the top level `Device` instances created in the context, -and run the following: - -- If ``set_name=True`` (the default), then call `Device.set_name` passing the - name of the variable within the context. For example, here we call - ``det.set_name("det")`` -- 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 ``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 -"movers" called ``x`` and ``y`` and a "sensor" called ``det`` that gives a -different reading depending on the position of the "movers". - -.. note:: - - There are very few devices implemented using ophyd async, see ophyd_async.epics.devices - and ophyd-tango-devices for some common ones associated with each control - system - -Run IPython ------------ - -You can now run ipython with this startup file:: - - $ ipython -i epics_demo.py - IPython 8.5.0 -- An enhanced Interactive Python. Type '?' for help. - - In [1]: - -.. ipython:: python - :suppress: - :okexcept: - - import sys - from pathlib import Path - sys.path.append(str(Path(".").absolute()/"docs/examples")) - from epics_demo import * - # Turn off progressbar and table - RE.waiting_hook = None - bec.disable_table() - -This is like a regular python console with the contents of that file executed. -IPython adds some extra features like tab completion and magics (shortcut -commands). - -Run some plans --------------- - -Ophyd Devices give an interface to the `bluesky.run_engine.RunEngine` so they -can be used in plans. We can move the ``samp.x`` mover to 100mm using -`bluesky.plan_stubs.mv`: - -.. ipython:: - :okexcept: - - In [1]: RE(mov(samp.x, 100)) - -If this is too verbose to write, we registered a shorthand with -``bluesky.utils.register_transform``: `` None: self._filename_provider = filename_provider - self._directory_path = directory_path + self._directory_path = Path(directory_path) self._create_dir_depth = create_dir_depth def __call__(self, device_name: str | None = None) -> PathInfo: diff --git a/src/ophyd_async/core/_utils.py b/src/ophyd_async/core/_utils.py index e89131ea75..8968aa8299 100644 --- a/src/ophyd_async/core/_utils.py +++ b/src/ophyd_async/core/_utils.py @@ -227,10 +227,6 @@ async def merge_gathered_dicts( return ret -async def gather_list(coros: Iterable[Awaitable[T]]) -> list[T]: - return await asyncio.gather(*coros) - - def in_micros(t: float) -> int: """ Converts between a positive number of seconds and an equivalent diff --git a/src/ophyd_async/epics/__init__.py b/src/ophyd_async/epics/__init__.py index e69de29bb2..f6a47dace6 100644 --- a/src/ophyd_async/epics/__init__.py +++ b/src/ophyd_async/epics/__init__.py @@ -0,0 +1 @@ +"""EPICS support for Signals, and Devices that use them.""" diff --git a/src/ophyd_async/epics/core/_aioca.py b/src/ophyd_async/epics/core/_aioca.py index bca8892371..ff2fff15b5 100644 --- a/src/ophyd_async/epics/core/_aioca.py +++ b/src/ophyd_async/epics/core/_aioca.py @@ -66,7 +66,11 @@ def _metadata_from_augmented_value( metadata = metadata.copy() if hasattr(value, "units") and datatype not in (str, bool): metadata["units"] = value.units - if hasattr(value, "precision") and not isnan(value.precision): + if ( + hasattr(value, "precision") + and not isnan(value.precision) + and datatype is not int + ): metadata["precision"] = value.precision if (limits := _limits_from_augmented_value(value)) and datatype is not bool: metadata["limits"] = limits @@ -102,6 +106,11 @@ def __getattribute__(self, __name: str) -> Any: raise NotImplementedError("No PV has been set as connect() has not been called") +class CaIntConverter(CaConverter[int]): + def value(self, value: AugmentedValue) -> int: + return int(value) # type: ignore + + class CaArrayConverter(CaConverter[np.ndarray]): def value(self, value: AugmentedValue) -> np.ndarray: # A less expensive conversion @@ -204,7 +213,7 @@ def make_converter( and get_unique({k: v.precision for k, v in values.items()}, "precision") == 0 ): # Allow int signals to represent float records when prec is 0 - return CaConverter(int, pv_dbr) + return CaIntConverter(int, pv_dbr) elif datatype in (None, inferred_datatype): # If datatype matches what we are given then allow it and use inferred converter return converter_cls(inferred_datatype, pv_dbr) diff --git a/src/ophyd_async/epics/core/_p4p.py b/src/ophyd_async/epics/core/_p4p.py index e0e2713b0f..1f5c3acbe6 100644 --- a/src/ophyd_async/epics/core/_p4p.py +++ b/src/ophyd_async/epics/core/_p4p.py @@ -72,6 +72,7 @@ def _metadata_from_value(datatype: type[SignalDatatype], value: Any) -> SignalMe hasattr(display_data, "precision") and not isnan(display_data.precision) and specifier[-1] in _float_specifiers + and datatype is not int ): metadata["precision"] = display_data.precision if (limits := _limits_from_value(value)) and specifier[-1] in _number_specifiers: @@ -93,9 +94,7 @@ def __init__(self, datatype: type[SignalDatatypeT]): self.datatype = datatype def value(self, value: Any) -> SignalDatatypeT: - # for channel access ca_xxx classes, this - # invokes __pos__ operator to return an instance of - # the builtin base class + # Normally the value will be of the correct python type return value["value"] def write_value(self, value: Any) -> Any: @@ -103,6 +102,15 @@ def write_value(self, value: Any) -> Any: return value +class PvaIntConverter(PvaConverter[int]): + def __init__(self): + super().__init__(int) + + def value(self, value: Any) -> int: + # Convert to an int + return int(value["value"]) + + class PvaLongStringConverter(PvaConverter[str]): def __init__(self): super().__init__(str) @@ -270,7 +278,7 @@ def make_converter(datatype: type | None, values: dict[str, Any]) -> PvaConverte == 0 ): # Allow int signals to represent float records when prec is 0 - return PvaConverter(int) + return PvaIntConverter() elif inferred_datatype is str and (enum_cls := get_enum_cls(datatype)): # Allow strings to be used as enums until QSRV supports this return PvaConverter(str) diff --git a/src/ophyd_async/epics/sim/__init__.py b/src/ophyd_async/epics/sim/__init__.py index 3e3d602f91..24bed36172 100644 --- a/src/ophyd_async/epics/sim/__init__.py +++ b/src/ophyd_async/epics/sim/__init__.py @@ -1,54 +1,16 @@ """Demo EPICS Devices for the tutorial""" -import atexit -import random -import string -import subprocess -import sys -from pathlib import Path - -from ._mover import Mover, SampleStage -from ._sensor import EnergyMode, Sensor, SensorGroup +from ._counter import Counter, EnergyMode +from ._ioc import start_ioc_subprocess +from ._mover import Mover +from ._multichannelcounter import MultiChannelCounter +from ._stage import Stage __all__ = [ "Mover", - "SampleStage", + "Stage", "EnergyMode", - "Sensor", - "SensorGroup", + "Counter", + "MultiChannelCounter", + "start_ioc_subprocess", ] - - -def start_ioc_subprocess() -> str: - """Start an IOC subprocess with EPICS database for sample stage and sensor - with the same pv prefix - """ - - pv_prefix = "".join(random.choice(string.ascii_uppercase) for _ in range(12)) + ":" - here = Path(__file__).absolute().parent - args = [sys.executable, "-m", "epicscorelibs.ioc"] - - # Create standalone sensor - args += ["-m", f"P={pv_prefix}"] - args += ["-d", str(here / "sensor.db")] - - # Create sensor group - for suffix in ["1", "2", "3"]: - args += ["-m", f"P={pv_prefix}{suffix}:"] - args += ["-d", str(here / "sensor.db")] - - # Create X and Y motors - for suffix in ["X", "Y"]: - args += ["-m", f"P={pv_prefix}{suffix}:"] - args += ["-d", str(here / "mover.db")] - - # Start IOC - process = subprocess.Popen( - args, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - universal_newlines=True, - ) - atexit.register(process.communicate, "exit") - return pv_prefix diff --git a/src/ophyd_async/epics/sim/__main__.py b/src/ophyd_async/epics/sim/__main__.py new file mode 100644 index 0000000000..4ff54b0a81 --- /dev/null +++ b/src/ophyd_async/epics/sim/__main__.py @@ -0,0 +1,30 @@ +# Import bluesky and ophyd +import bluesky.plan_stubs as bps # noqa: F401 +import bluesky.plans as bp # noqa: F401 +from bluesky.callbacks.best_effort import BestEffortCallback +from bluesky.run_engine import RunEngine, autoawait_in_bluesky_event_loop + +from ophyd_async.core import init_devices +from ophyd_async.epics import sim, testing + +# Create a run engine and make ipython use it for `await` commands +RE = RunEngine(call_returns_result=True) +autoawait_in_bluesky_event_loop() + +# Add a callback for plotting +bec = BestEffortCallback() +RE.subscribe(bec) + +# Start IOC with demo pvs in subprocess +prefix = testing.generate_random_pv_prefix() +prefix = "foo:" +sim.start_ioc_subprocess(prefix, num_counters=3) + +# All Devices created within this block will be +# connected and named at the end of the with block +with init_devices(): + # Create a sample stage with X and Y motors + stage = sim.Stage(f"{prefix}STAGE:") + # Create a multi channel counter with the same number + # of counters as the IOC + mcc = sim.MultiChannelCounter(f"{prefix}MCC:") diff --git a/src/ophyd_async/epics/sim/_counter.py b/src/ophyd_async/epics/sim/_counter.py new file mode 100644 index 0000000000..b9c6a6c2f7 --- /dev/null +++ b/src/ophyd_async/epics/sim/_counter.py @@ -0,0 +1,21 @@ +from typing import Annotated as A + +from ophyd_async.core import SignalR, SignalRW, StandardReadable, StrictEnum +from ophyd_async.core import StandardReadableFormat as Format +from ophyd_async.epics.core import EpicsDevice, PvSuffix + + +class EnergyMode(StrictEnum): + """Energy mode for `Sensor`""" + + #: Low energy mode + LOW = "Low Energy" + #: High energy mode + HIGH = "High Energy" + + +class Counter(StandardReadable, EpicsDevice): + """A demo sensor that produces a scalar value based on X and Y Movers""" + + value: A[SignalR[int], PvSuffix("Value"), Format.HINTED_UNCACHED_SIGNAL] + mode: A[SignalRW[EnergyMode], PvSuffix("Mode"), Format.CONFIG_SIGNAL] diff --git a/src/ophyd_async/epics/sim/_mover.py b/src/ophyd_async/epics/sim/_mover.py index 88bd3fd655..2296fba27f 100644 --- a/src/ophyd_async/epics/sim/_mover.py +++ b/src/ophyd_async/epics/sim/_mover.py @@ -1,4 +1,5 @@ import asyncio +from typing import Annotated as A import numpy as np from bluesky.protocols import Movable, Stoppable @@ -6,36 +7,32 @@ from ophyd_async.core import ( CALCULATE_TIMEOUT, DEFAULT_TIMEOUT, - AsyncStatus, CalculatableTimeout, - Device, + SignalR, + SignalRW, + SignalX, StandardReadable, WatchableAsyncStatus, WatcherUpdate, observe_value, ) from ophyd_async.core import StandardReadableFormat as Format -from ophyd_async.epics.core import epics_signal_r, epics_signal_rw, epics_signal_x +from ophyd_async.epics.core import EpicsDevice, PvSuffix -class Mover(StandardReadable, Movable, Stoppable): +class Mover(EpicsDevice, StandardReadable, Movable, Stoppable): """A demo movable that moves based on velocity""" - def __init__(self, prefix: str, name="") -> None: - # Define some signals - with self.add_children_as_readables(Format.HINTED_SIGNAL): - self.readback = epics_signal_r(float, prefix + "Readback") - with self.add_children_as_readables(Format.CONFIG_SIGNAL): - self.velocity = epics_signal_rw(float, prefix + "Velocity") - self.units = epics_signal_r(str, prefix + "Readback.EGU") - self.setpoint = epics_signal_rw(float, prefix + "Setpoint") - self.precision = epics_signal_r(int, prefix + "Readback.PREC") - # Signals that collide with standard methods should have a trailing underscore - self.stop_ = epics_signal_x(prefix + "Stop.PROC") - # Whether set() should complete successfully or not - self._set_success = True - - super().__init__(name=name) + # Whether set() should complete successfully or not + _set_success = True + # Define some signals + readback: A[SignalR[float], PvSuffix("Readback"), Format.HINTED_SIGNAL] + velocity: A[SignalRW[float], PvSuffix("Velocity"), Format.CONFIG_SIGNAL] + units: A[SignalR[str], PvSuffix("Readback.EGU"), Format.CONFIG_SIGNAL] + setpoint: A[SignalRW[float], PvSuffix("Setpoint")] + precision: A[SignalR[int], PvSuffix("Readback.PREC")] + # If a signal name clashes with a bluesky verb add _ to the attribute name + stop_: A[SignalX, PvSuffix("Stop.PROC")] def set_name(self, name: str, *, child_name_separator: str | None = None) -> None: super().set_name(name, child_name_separator=child_name_separator) @@ -55,14 +52,10 @@ async def set(self, value: float, timeout: CalculatableTimeout = CALCULATE_TIMEO if timeout == CALCULATE_TIMEOUT: assert velocity > 0, "Mover has zero velocity" timeout = abs(new_position - old_position) / velocity + DEFAULT_TIMEOUT - # Make an Event that will be set on completion, and a Status that will - # error if not done in time - done = asyncio.Event() - done_status = AsyncStatus(asyncio.wait_for(done.wait(), timeout)) # Wait for the value to set, but don't wait for put completion callback await self.setpoint.set(new_position, wait=False) async for current_position in observe_value( - self.readback, done_status=done_status + self.readback, done_timeout=timeout ): yield WatcherUpdate( current=current_position, @@ -73,7 +66,6 @@ async def set(self, value: float, timeout: CalculatableTimeout = CALCULATE_TIMEO precision=precision, ) if np.isclose(current_position, new_position): - done.set() break if not self._set_success: raise RuntimeError("Motor was stopped") @@ -82,14 +74,3 @@ async def stop(self, success=True): self._set_success = success status = self.stop_.trigger() await status - - -class SampleStage(Device): - """A demo sample stage with X and Y movables""" - - def __init__(self, prefix: str, name="") -> None: - # Define some child Devices - self.x = Mover(prefix + "X:") - self.y = Mover(prefix + "Y:") - # Set name of device and child devices - super().__init__(name=name) diff --git a/src/ophyd_async/epics/sim/_multichannelcounter.py b/src/ophyd_async/epics/sim/_multichannelcounter.py new file mode 100644 index 0000000000..446bf1ec28 --- /dev/null +++ b/src/ophyd_async/epics/sim/_multichannelcounter.py @@ -0,0 +1,35 @@ +from typing import Annotated as A + +from ophyd_async.core import ( + DEFAULT_TIMEOUT, + AsyncStatus, + DeviceVector, + SignalR, + SignalRW, + SignalX, + StandardReadable, +) +from ophyd_async.core import StandardReadableFormat as Format +from ophyd_async.epics.core import EpicsDevice, PvSuffix + +from ._counter import Counter + + +class MultiChannelCounter(StandardReadable, EpicsDevice): + acquire_time: A[SignalRW[float], PvSuffix("AcquireTime"), Format.CONFIG_SIGNAL] + start: A[SignalX, PvSuffix("Start.PROC")] + acquiring: A[SignalR[bool], PvSuffix("Acquiring")] + reset: A[SignalX, PvSuffix("Reset.PROC")] + + def __init__(self, prefix: str, name: str = "", num_counters: int = 3) -> None: + with self.add_children_as_readables(): + self.counters = DeviceVector( + {i: Counter(f"{prefix}{i}:") for i in range(1, num_counters + 1)} + ) + super().__init__(prefix=prefix, name=name) + + @AsyncStatus.wrap + async def trigger(self): + await self.reset.trigger() + timeout = await self.acquire_time.get_value() + DEFAULT_TIMEOUT + await self.start.trigger(timeout=timeout) diff --git a/src/ophyd_async/epics/sim/_sensor.py b/src/ophyd_async/epics/sim/_sensor.py deleted file mode 100644 index 0cc99d090a..0000000000 --- a/src/ophyd_async/epics/sim/_sensor.py +++ /dev/null @@ -1,37 +0,0 @@ -from typing import Annotated as A - -from ophyd_async.core import ( - DeviceVector, - SignalR, - SignalRW, - StandardReadable, - StrictEnum, -) -from ophyd_async.core import StandardReadableFormat as Format -from ophyd_async.epics.core import EpicsDevice, PvSuffix - - -class EnergyMode(StrictEnum): - """Energy mode for `Sensor`""" - - #: Low energy mode - LOW = "Low Energy" - #: High energy mode - HIGH = "High Energy" - - -class Sensor(StandardReadable, EpicsDevice): - """A demo sensor that produces a scalar value based on X and Y Movers""" - - value: A[SignalR[float], PvSuffix("Value"), Format.HINTED_SIGNAL] - mode: A[SignalRW[EnergyMode], PvSuffix("Mode"), Format.CONFIG_SIGNAL] - - -class SensorGroup(StandardReadable): - def __init__(self, prefix: str, name: str = "", sensor_count: int = 3) -> None: - with self.add_children_as_readables(): - self.sensors = DeviceVector( - {i: Sensor(f"{prefix}{i}:") for i in range(1, sensor_count + 1)} - ) - - super().__init__(name) diff --git a/src/ophyd_async/epics/sim/_stage.py b/src/ophyd_async/epics/sim/_stage.py new file mode 100644 index 0000000000..3db6da87f6 --- /dev/null +++ b/src/ophyd_async/epics/sim/_stage.py @@ -0,0 +1,15 @@ +from ophyd_async.core import StandardReadable + +from ._mover import Mover + + +class Stage(StandardReadable): + """A simulated sample stage with X and Y movables""" + + def __init__(self, prefix: str, name="") -> None: + # Define some child Devices + with self.add_children_as_readables(): + self.x = Mover(prefix + "X:") + self.y = Mover(prefix + "Y:") + # Set name of device and child devices + super().__init__(name=name) diff --git a/src/ophyd_async/epics/sim/counter.db b/src/ophyd_async/epics/sim/counter.db new file mode 100644 index 0000000000..a46336edd2 --- /dev/null +++ b/src/ophyd_async/epics/sim/counter.db @@ -0,0 +1,21 @@ +record(mbbo, "$(P)$(CHANNEL):Mode") { + field(DESC, "Energy sensitivity of the image") + field(DTYP, "Raw Soft Channel") + field(PINI, "YES") + field(ZRVL, "10") + field(ZRST, "Low Energy") + field(ONVL, "100") + field(ONST, "High Energy") +} + +record(calc, "$(P)$(CHANNEL):Value") { + field(DESC, "Sensor value simulated from X and Y") + field(INPA, "$(X)Readback") + field(INPB, "$(Y)Readback") + field(INPC, "$(CHANNEL)") + field(INPD, "$(P)$(CHANNEL):Mode.RVAL") + field(INPE, "$(P)Elapsed CP") + field(CALC, "CEIL((SIN(A)**C+COS(D+B*A)+2)*10000*E)") + field(EGU, "cts") + field(PREC, "0") +} diff --git a/src/ophyd_async/epics/sim/multichannelcounter.db b/src/ophyd_async/epics/sim/multichannelcounter.db new file mode 100644 index 0000000000..d36911614b --- /dev/null +++ b/src/ophyd_async/epics/sim/multichannelcounter.db @@ -0,0 +1,59 @@ +record(ao, "$(P)AcquireTime") { + field(DESC, "Time to acquire for") + field(VAL, "0.1") + field(OUT, "$(P)Start.DLY2") + field(PINI, "YES") +} + +record(seq, "$(P)Start") { + field(DESC, "Start sequence") + # Grab the start time + field(LNK0, "$(P)StartTime.PROC") + # Set it to be acquiring + field(LNK1, "$(P)Acquiring PP") + field(DO1, "1") + # Set it back to idle + field(LNK2, "$(P)Acquiring PP") + field(DO2, "0") + # Set the elapsed time to the full acquire time + field(LNK3, "$(P)Elapsed PP") + field(DOL3, "$(P)AcquireTime") +} + +record(ai, "$(P)StartTime") { + field(DTYP, "Soft Timestamp") +} + +record(bi, "$(P)Acquiring") { + field(DESC, "Currently acquiring") + field(ZNAM, "Idle") + field(ONAM, "Acquiring") + field(PINI, "YES") +} + +record(ai, "$(P)CurrentTime") { + field(DTYP, "Soft Timestamp") +} + +record(calcout, "$(P)Process") { + field(DESC, "Process elapsed time if acquiring") + field(INPA, "$(P)StartTime") + field(INPB, "$(P)CurrentTime PP") + field(SCAN, ".1 second") + field(CALC, "B-A") + field(OUT, "$(P)Elapsed PP") + field(SDIS, "$(P)Acquiring") + field(DISV, "0") +} + +record(ai, "$(P)Elapsed") { + field(DESC, "Elapsed time") + field(EGU, "s") + field(PREC, "1") + field(PINI, "YES") +} + +record(calcout, "$(P)Reset") { + field(OUT, "$(P)Elapsed PP") + field(CALC, "0") +} diff --git a/src/ophyd_async/epics/sim/plot.py b/src/ophyd_async/epics/sim/plot.py new file mode 100644 index 0000000000..b0a0e5c8fd --- /dev/null +++ b/src/ophyd_async/epics/sim/plot.py @@ -0,0 +1,22 @@ +import matplotlib.cm as cm +import matplotlib.pyplot as plt +import numpy as np + +delta = 0.025 +x = y = np.arange(-10.0, 10.0, delta) +X, Y = np.meshgrid(x, y) +Z = (2 + np.sin(X) ** 10 + np.cos(100 + Y * X) * np.cos(X)) * 999 +print(Z.max(), Z.min()) +fig, ax = plt.subplots() +im = ax.imshow( + Z, + interpolation="bilinear", + cmap=cm.RdYlGn, + origin="lower", + extent=(-10, 10, -10, 10), + vmax=abs(Z).max(), + vmin=-abs(Z).max(), +) + +if __name__ == "__main__": + plt.show() diff --git a/src/ophyd_async/epics/sim/sensor.db b/src/ophyd_async/epics/sim/sensor.db deleted file mode 100644 index 95cba4b872..0000000000 --- a/src/ophyd_async/epics/sim/sensor.db +++ /dev/null @@ -1,19 +0,0 @@ -record(mbbo, "$(P)Mode") { - field(DESC, "Energy sensitivity of the image") - field(DTYP, "Raw Soft Channel") - field(PINI, "YES") - field(ZRVL, "10") - field(ZRST, "Low Energy") - field(ONVL, "100") - field(ONST, "High Energy") -} - -record(calc, "$(P)Value") { - field(DESC, "Sensor value simulated from X and Y") - field(INPA, "$(P)X:Readback CP") - field(INPB, "$(P)Y:Readback CP") - field(INPC, "$(P)Mode.RVAL CP") - field(CALC, "SIN(A)**10+COS(C+B*A)*COS(A)") - field(EGU, "$(EGU=cts/s)") - field(PREC, "$(PREC=3)") -} diff --git a/src/ophyd_async/epics/testing/_example_ioc.py b/src/ophyd_async/epics/testing/_example_ioc.py index 6ae7a313fa..f4a2de7947 100644 --- a/src/ophyd_async/epics/testing/_example_ioc.py +++ b/src/ophyd_async/epics/testing/_example_ioc.py @@ -36,6 +36,7 @@ class EpicsTestTable(Table): class EpicsTestCaDevice(EpicsDevice): my_int: A[SignalRW[int], PvSuffix("int")] my_float: A[SignalRW[float], PvSuffix("float")] + float_prec_0: A[SignalRW[int], PvSuffix("float_prec_0")] my_str: A[SignalRW[str], PvSuffix("str")] longstr: A[SignalRW[str], PvSuffix("longstr")] longstr2: A[SignalRW[str], PvSuffix("longstr2.VAL$")] diff --git a/src/ophyd_async/fastcs/__init__.py b/src/ophyd_async/fastcs/__init__.py index e69de29bb2..c0b650fa33 100644 --- a/src/ophyd_async/fastcs/__init__.py +++ b/src/ophyd_async/fastcs/__init__.py @@ -0,0 +1 @@ +"""FastCS support for Signals via EPICS or Tango, and Devices that use them.""" diff --git a/src/ophyd_async/plan_stubs/__init__.py b/src/ophyd_async/plan_stubs/__init__.py index f549dd27f9..a127b64de8 100644 --- a/src/ophyd_async/plan_stubs/__init__.py +++ b/src/ophyd_async/plan_stubs/__init__.py @@ -1,3 +1,5 @@ +"""Plan stubs for connecting, setting up and flying devices.""" + from ._ensure_connected import ensure_connected from ._fly import ( fly_and_collect, diff --git a/src/ophyd_async/sim/__init__.py b/src/ophyd_async/sim/__init__.py index fa19366c11..4e7c61e8d7 100644 --- a/src/ophyd_async/sim/__init__.py +++ b/src/ophyd_async/sim/__init__.py @@ -1,3 +1,5 @@ +"""Some simulated devices to be used in tutorials and testing.""" + from ._pattern_detector import ( DATA_PATH, SUM_PATH, diff --git a/src/ophyd_async/sim/__main__.py b/src/ophyd_async/sim/__main__.py new file mode 100644 index 0000000000..41448c38f0 --- /dev/null +++ b/src/ophyd_async/sim/__main__.py @@ -0,0 +1,30 @@ +# Import bluesky and ophyd +import bluesky.plan_stubs as bps # noqa: F401 +import bluesky.plans as bp # noqa: F401 +from bluesky.run_engine import RunEngine, autoawait_in_bluesky_event_loop + +from ophyd_async import sim +from ophyd_async.core import StaticPathProvider, UUIDFilenameProvider, init_devices + +# Create a run engine and make ipython use it for `await` commands +RE = RunEngine(call_returns_result=True) +autoawait_in_bluesky_event_loop() + +# Define where test data should be written +path_provider = StaticPathProvider(UUIDFilenameProvider(), "/tmp") + +# All Devices created within this block will be +# connected and named at the end of the with block +with init_devices(): + # Create a couple of simulated motors + x = sim.SimMotor() + y = sim.SimMotor() + # Make a pattern generator that uses the motor positions + # to make a test pattern. This simulates the real life process + # of X-ray scattering off a sample + generator = sim.PatternGenerator( + x_signal=x.user_readback, + y_signal=y.user_readback, + ) + # Make a detector device that wraps the generator + det = sim.PatternDetector(path_provider, generator) diff --git a/src/ophyd_async/sim/_pattern_detector/_pattern_detector.py b/src/ophyd_async/sim/_pattern_detector/_pattern_detector.py index baa97cbd58..f9f8171203 100644 --- a/src/ophyd_async/sim/_pattern_detector/_pattern_detector.py +++ b/src/ophyd_async/sim/_pattern_detector/_pattern_detector.py @@ -1,13 +1,9 @@ from collections.abc import Sequence -from pathlib import Path from ophyd_async.core import ( - FilenameProvider, PathProvider, SignalR, StandardDetector, - StaticFilenameProvider, - StaticPathProvider, ) from ._pattern_detector_controller import PatternDetectorController @@ -18,21 +14,20 @@ class PatternDetector(StandardDetector): def __init__( self, - path: Path, + path_provider: PathProvider, + pattern_generator: PatternGenerator | None = None, config_sigs: Sequence[SignalR] = (), name: str = "", ) -> None: - fp: FilenameProvider = StaticFilenameProvider(name) - self.path_provider: PathProvider = StaticPathProvider(fp, path) - self.pattern_generator = PatternGenerator() + self.pattern_generator = pattern_generator or PatternGenerator() writer = PatternDetectorWriter( pattern_generator=self.pattern_generator, - path_provider=self.path_provider, + path_provider=path_provider, name_provider=lambda: self.name, ) controller = PatternDetectorController( pattern_generator=self.pattern_generator, - path_provider=self.path_provider, + path_provider=path_provider, ) super().__init__( controller=controller, diff --git a/src/ophyd_async/sim/_pattern_detector/_pattern_generator.py b/src/ophyd_async/sim/_pattern_detector/_pattern_generator.py index 01c54c8245..2d13801dd7 100644 --- a/src/ophyd_async/sim/_pattern_detector/_pattern_generator.py +++ b/src/ophyd_async/sim/_pattern_detector/_pattern_generator.py @@ -11,6 +11,7 @@ HDFDataset, HDFFile, PathProvider, + SignalR, observe_value, soft_signal_r_and_setter, ) @@ -46,11 +47,13 @@ def __init__( saturation_exposure_time: float = 0.1, detector_width: int = 320, detector_height: int = 240, + x_signal: SignalR[float] | None = None, + y_signal: SignalR[float] | None = None, ) -> None: self.saturation_exposure_time = saturation_exposure_time self.exposure = saturation_exposure_time - self.x = 0.0 - self.y = 0.0 + self.x_signal = x_signal + self.y_signal = y_signal self.height = detector_height self.width = detector_width self.image_counter: int = 0 @@ -78,7 +81,9 @@ def write_data_to_dataset(self, path: str, data_shape: tuple[int, ...], data): async def write_image_to_file(self) -> None: # generate the simulated data - intensity: float = generate_interesting_pattern(self.x, self.y) + x = await self.x_signal.get_value() if self.x_signal else 0.0 + y = await self.y_signal.get_value() if self.y_signal else 0.0 + intensity = generate_interesting_pattern(x, y) detector_data = ( self._full_intensity_blob * intensity @@ -98,12 +103,6 @@ async def write_image_to_file(self) -> None: def set_exposure(self, value: float) -> None: self.exposure = value - def set_x(self, value: float) -> None: - self.x = value - - def set_y(self, value: float) -> None: - self.y = value - async def open_file( self, path_provider: PathProvider, name: str, multiplier: int = 1 ) -> dict[str, DataKey]: @@ -113,6 +112,7 @@ async def open_file( self._path_provider = path_provider self._handle_for_h5_file = h5py.File(self.target_path, "w", libver="latest") + print(self, self.target_path) assert self._handle_for_h5_file, "not loaded the file right" diff --git a/src/ophyd_async/tango/__init__.py b/src/ophyd_async/tango/__init__.py index e69de29bb2..a39e65f0cf 100644 --- a/src/ophyd_async/tango/__init__.py +++ b/src/ophyd_async/tango/__init__.py @@ -0,0 +1 @@ +"""Tango support for Signals, and Devices that use them.""" diff --git a/src/ophyd_async/tango/sim/__main__.py b/src/ophyd_async/tango/sim/__main__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/ophyd_async/testing/__init__.py b/src/ophyd_async/testing/__init__.py index 2e65cba983..93ed614996 100644 --- a/src/ophyd_async/testing/__init__.py +++ b/src/ophyd_async/testing/__init__.py @@ -1,3 +1,5 @@ +"""Utilities for testing devices.""" + from . import __pytest_assert_rewrite # noqa: F401 from ._assert import ( ApproxTable, @@ -26,20 +28,25 @@ ) from ._wait_for_pending import wait_for_pending_wakeups +# The order of this list determines the order of the documentation, +# so does not match the alphabetical order of the impors __all__ = [ + # Assert functions + "assert_value", + "assert_reading", "assert_configuration", "assert_describe_signal", "assert_emitted", - "assert_reading", - "assert_value", - "callback_on_mock_put", + # Mocking utilities "get_mock", + "set_mock_value", + "set_mock_values", "get_mock_put", + "callback_on_mock_put", "mock_puts_blocked", "reset_mock_put_calls", "set_mock_put_proceeds", - "set_mock_value", - "set_mock_values", + # Wait for pending wakeups "wait_for_pending_wakeups", "ExampleEnum", "ExampleTable", diff --git a/src/ophyd_async/testing/_assert.py b/src/ophyd_async/testing/_assert.py index da2281c1d8..55cba6f461 100644 --- a/src/ophyd_async/testing/_assert.py +++ b/src/ophyd_async/testing/_assert.py @@ -29,20 +29,14 @@ def _generate_assert_error_msg(name: str, expected_result, actual_result) -> str async def assert_value(signal: SignalR[SignalDatatypeT], value: Any) -> None: - """Assert a signal's value and compare it an expected signal. + """Assert that a Signal has the given value. Parameters ---------- signal: - signal with get_value. + Signal with get_value. value: The expected value from the signal. - - Notes - ----- - Example usage:: - await assert_value(signal, value) - """ actual_value = await signal.get_value() assert actual_value == value, _generate_assert_error_msg( @@ -53,37 +47,32 @@ async def assert_value(signal: SignalR[SignalDatatypeT], value: Any) -> None: def _approx_readable_value(reading: Mapping[str, Reading]) -> Mapping[str, Reading]: - """Change Reading value to pytest.approx(value)""" for i in reading: + # np_array1 == np_array2 gives an array of booleans rather than a single bool + # Use pytest.approx(np_array1) so that we get a bool instead reading[i]["value"] = pytest.approx(reading[i]["value"]) return reading async def assert_reading( - readable: AsyncReadable, expected_reading: Mapping[str, Reading] + readable: AsyncReadable, + reading: Mapping[str, Reading], ) -> None: - """Assert readings from readable. + """Assert that a readable Device has the given reading. Parameters ---------- readable: - Callable with readable.read function that generate readings. - + Device with an async ``read()`` method to get the reading from. reading: - The expected readings from the readable. - - Notes - ----- - Example usage:: - await assert_reading(readable, reading) - + The expected reading from the readable. """ actual_reading = await readable.read() assert ( - _approx_readable_value(expected_reading) == actual_reading + _approx_readable_value(reading) == actual_reading ), _generate_assert_error_msg( name=readable.name, - expected_result=expected_reading, + expected_result=reading, actual_result=actual_reading, ) @@ -92,21 +81,15 @@ async def assert_configuration( configurable: AsyncConfigurable, configuration: Mapping[str, Reading], ) -> None: - """Assert readings from Configurable. + """Assert that a configurable Device has the given configuration. Parameters ---------- configurable: - Configurable with Configurable.read function that generate readings. - + Device with an async ``read_configuration()`` method to get the configuration + from. configuration: - The expected readings from configurable. - - Notes - ----- - Example usage:: - await assert_configuration(configurable configuration) - + The expected configuration from the configurable. """ actual_configurable = await configurable.read_configuration() assert ( @@ -131,15 +114,15 @@ def assert_emitted(docs: Mapping[str, list[dict]], **numbers: int): Parameters ---------- - Doc: - A dictionary - + docs: + A mapping of document type -> list of documents that have been emitted. numbers: - expected emission in kwarg from + The number of each document type expected. + + Examples + -------- + .. code:: - Notes - ----- - Example usage:: docs = defaultdict(list) RE.subscribe(lambda name, doc: docs[name].append(doc)) RE(my_plan()) @@ -182,6 +165,7 @@ def __init__(self, signal: SignalR): async def assert_updates(self, expected_value): # Get an update, value and reading + expected_type = type(expected_value) if isinstance(expected_value, Table): expected_value = ApproxTable(expected_value) else: @@ -191,6 +175,7 @@ async def assert_updates(self, expected_value): reading = await self.signal.read() # Check they match what we expected assert value == expected_value + assert type(value) is expected_type expected_reading = { self.signal.name: { "value": expected_value, diff --git a/src/ophyd_async/testing/_mock_signal_utils.py b/src/ophyd_async/testing/_mock_signal_utils.py index 683666e6b4..e71fce7858 100644 --- a/src/ophyd_async/testing/_mock_signal_utils.py +++ b/src/ophyd_async/testing/_mock_signal_utils.py @@ -1,4 +1,4 @@ -from collections.abc import Awaitable, Callable, Iterable +from collections.abc import Awaitable, Callable, Iterable, Iterator from contextlib import contextmanager from unittest.mock import AsyncMock, Mock @@ -14,6 +14,10 @@ def get_mock(device: Device | Signal) -> Mock: + """Return the mock (which may have child mocks attached) for a Device. + + The device must have been connected in mock mode. + """ mock = device._mock # noqa: SLF001 assert isinstance(mock, LazyMock), f"Device {device} not connected in mock mode" return mock() @@ -34,36 +38,7 @@ def set_mock_value(signal: Signal[SignalDatatypeT], value: SignalDatatypeT): backend.set_value(value) -def set_mock_put_proceeds(signal: Signal, proceeds: bool): - """Allow or block a put with wait=True from proceeding""" - backend = _get_mock_signal_backend(signal) - - if proceeds: - backend.put_proceeds.set() - else: - backend.put_proceeds.clear() - - -@contextmanager -def mock_puts_blocked(*signals: Signal): - for signal in signals: - set_mock_put_proceeds(signal, False) - yield - for signal in signals: - set_mock_put_proceeds(signal, True) - - -def get_mock_put(signal: Signal) -> AsyncMock: - """Get the mock associated with the put call on the signal.""" - return _get_mock_signal_backend(signal).put_mock - - -def reset_mock_put_calls(signal: Signal): - backend = _get_mock_signal_backend(signal) - backend.put_mock.reset_mock() - - -class _SetValuesIterator: +class _SetValuesIterator(Iterator[SignalDatatypeT]): # Garbage collected by the time __del__ is called unless we put it as a # global attrbute here. require_all_consumed: bool = False @@ -78,13 +53,9 @@ def __init__( self.values = values self.require_all_consumed = require_all_consumed self.index = 0 - self.iterator = enumerate(values, start=1) - def __iter__(self): - return self - - def __next__(self): + def __next__(self) -> SignalDatatypeT: # Will propogate StopIteration self.index, next_value = next(self.iterator) set_mock_value(self.signal, next_value) @@ -113,33 +84,32 @@ def set_mock_values( signal: SignalR[SignalDatatypeT], values: Iterable[SignalDatatypeT], require_all_consumed: bool = False, -) -> _SetValuesIterator: +) -> Iterator[SignalDatatypeT]: """Iterator to set a signal to a sequence of values, optionally repeating the sequence. Parameters ---------- signal: - A signal with a `MockSignalBackend` backend. + A signal connected in mock mode. values: An iterable of the values to set the signal to, on each iteration - the value will be set. + the next value will be set. require_all_consumed: If True, an AssertionError will be raised if the iterator is deleted before all values have been consumed. - Notes - ----- - Example usage:: - - for value_set in set_mock_values(signal, [1, 2, 3]): - # do something + Examples + -------- + .. code:: - cm = set_mock_values(signal, 1, 2, 3, require_all_consumed=True): - next(cm) + for value_set in set_mock_values(signal, range(3)): # do something - """ + cm = set_mock_values(signal, [1, 3, 8], require_all_consumed=True): + next(cm) + # do something + """ return _SetValuesIterator( signal, values, @@ -173,3 +143,32 @@ def callback_on_mock_put( backend = _get_mock_signal_backend(signal) backend.put_mock.side_effect = callback return _unset_side_effect_cm(backend.put_mock) + + +def set_mock_put_proceeds(signal: Signal, proceeds: bool): + """Allow or block a put with wait=True from proceeding""" + backend = _get_mock_signal_backend(signal) + + if proceeds: + backend.put_proceeds.set() + else: + backend.put_proceeds.clear() + + +@contextmanager +def mock_puts_blocked(*signals: Signal): + for signal in signals: + set_mock_put_proceeds(signal, False) + yield + for signal in signals: + set_mock_put_proceeds(signal, True) + + +def get_mock_put(signal: Signal) -> AsyncMock: + """Get the mock associated with the put call on the signal.""" + return _get_mock_signal_backend(signal).put_mock + + +def reset_mock_put_calls(signal: Signal): + backend = _get_mock_signal_backend(signal) + backend.put_mock.reset_mock() diff --git a/src/ophyd_async/testing/conftest.py b/src/ophyd_async/testing/conftest.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/epics/signal/test_signals.py b/tests/epics/signal/test_signals.py index 7947b88ed3..e7fa6c3cbf 100644 --- a/tests/epics/signal/test_signals.py +++ b/tests/epics/signal/test_signals.py @@ -152,37 +152,81 @@ async def assert_monitor_then_put( ), "my_float": ExpectedData(3.141, 43.5, "number", "