From 438855df6322cf6280b924b77e9fe6655c85069e Mon Sep 17 00:00:00 2001 From: Rose Syrett Date: Fri, 26 Jan 2024 16:40:34 +0000 Subject: [PATCH] Added more documentation --- .../demo-devices.rst} | 45 +- docs/user/how-to/create-detectors.rst | 216 +++++++ docs/user/how-to/create-devices.rst | 238 +++++++ docs/user/index.rst | 5 +- ...ing-your-own-devices-to-run-a-gridscan.rst | 581 ++++++++++++++++++ .../user/tutorials/using-existing-devices.rst | 4 +- src/ophyd_async/core/device.py | 1 + src/ophyd_async/epics/demo/__init__.py | 10 +- 8 files changed, 1076 insertions(+), 24 deletions(-) rename docs/user/{how-to/make-a-simple-device.rst => explanations/demo-devices.rst} (66%) create mode 100644 docs/user/how-to/create-detectors.rst create mode 100644 docs/user/how-to/create-devices.rst create mode 100644 docs/user/tutorials/making-your-own-devices-to-run-a-gridscan.rst diff --git a/docs/user/how-to/make-a-simple-device.rst b/docs/user/explanations/demo-devices.rst similarity index 66% rename from docs/user/how-to/make-a-simple-device.rst rename to docs/user/explanations/demo-devices.rst index 86b112d307..f13ad664e5 100644 --- a/docs/user/how-to/make-a-simple-device.rst +++ b/docs/user/explanations/demo-devices.rst @@ -1,36 +1,43 @@ -.. note:: +Demo devices +============ - Ophyd async is included on a provisional basis until the v1.0 release and - may change API on minor release numbers before then +ophyd-async comes with a demo module for epics, `ophyd_async.epics.demo`. +This :doc:`tutorial <../tutorials/making-your-own-devices-to-run-a-gridscan>` +makes reference, towards the end, of the optimal way of constructing the basic +devices contained therein. The purpose of this document is to explain why this +is an optimal configuration. -Make a Simple Device -==================== +Readable +-------- .. currentmodule:: ophyd_async.core -To make a simple device, you need to subclass from the -`StandardReadable` class, create some `Signal` instances, and optionally implement -other suitable Bluesky `Protocols ` like -:class:`~bluesky.protocols.Movable`. - -The rest of this guide will show examples from ``src/ophyd_async/epics/demo/__init__.py`` +For a simple :class:`~bluesky.protocols.Readable` object like a `Sensor`, it is +`StandardReadable` should be subclassed as it comes with useful default +behaviour, such as providing ``stage`` and ``unstage`` methods, and other +methods to adhere to :class:`~bluesky.protocols.Readable` and :class:`~bluesky +.protocols.Configurable`. These allow the construction of both readable signals +(i.e. ones which change with each scan point) and configurable ones, which are +more meant to describe slow-changing signals, or signals to define the state of +the device. -Readable --------- - -For a simple :class:`~bluesky.protocols.Readable` object like a `Sensor`, you need to -define some signals, then tell the superclass which signals should contribute to -``read()`` and ``read_configuration()``: +Here is an example, from the tutorials: .. literalinclude:: ../../../src/ophyd_async/epics/demo/__init__.py :pyobject: Sensor +In this case, ``self.value`` changes very often, however ``self.mode`` is an +Enum which is set once during a scan. Therefore, the latter is a configuration +signal, but the former is a readable signal. They are passed as such to the +constructor of `StandardReadable`, at the end of the constructor of the +``Sensor`` object itself. + First some Signals are constructed and stored on the Device. Each one is passed its Python type, which could be: - A primitive (`str`, `int`, `float`) - An array (`numpy.typing.NDArray` or ``Sequence[str]``) -- An enum (`enum.Enum`). +- An enum (`enum.Enum`), which must also subclass `str`. The rest of the arguments are PV connection information, in this case the PV suffix. @@ -45,7 +52,7 @@ Finally `super().__init__() ` is called with: All signals passed into this init method will be monitored between ``stage()`` and ``unstage()`` and their cached values returned on ``read()`` and -``read_configuration()`` for perfomance. +``read_configuration()`` for performance. Movable ------- diff --git a/docs/user/how-to/create-detectors.rst b/docs/user/how-to/create-detectors.rst new file mode 100644 index 0000000000..0cc57bf284 --- /dev/null +++ b/docs/user/how-to/create-detectors.rst @@ -0,0 +1,216 @@ +Create Detectors +================ + +.. currentmodule:: ophyd_async.core + +Detectors often require standard bits of functionality to work with bluesky, +for this reason ophyd-async comes with a `StandardDetector` that can be +used or expanded upon +A StandardDetector needs two crucial components; a `DetectorControl` object and +a `DetectorWriter`. + +The former is responsible for arming and disarming the detector, whereas the +latter is responsible for handling any data writing, for example a HDF writer. + +The `ophyd_async.epics.areadetector` module contains examples of common +detector controllers and writers. + +Writing a detector controller +----------------------------- +The `DetectorControl` protocol contains three methods that must be defined for +any implementation of it: + +.. literalinclude:: ../../../src/ophyd_async/core/detector.py + :pyobject: DetectorControl + +`DetectorControl.get_deadtime` should return a float, in seconds, of the +detector deadtime. This will usually be restricted by the detector hardware you +are using. + +`DetectorControl.arm` takes one argument, and two keyword arguments: + +- ``num`` indicates the number of images that will be taken, +- ``trigger`` indicates the type of trigger which the detector will receive, +- ``exposure`` is the exposure time, i.e. time between frames. + +.. literalinclude:: ../../../src/ophyd_async/core/detector.py + :pyobject: DetectorTrigger + +`DetectorTrigger.internal` is the default trigger mode, which aligns with +step-scanning methods (i.e. something pokes the PV from the software side, to +tell it to take pictures). + +`DetectorControl.disarm` takes no arguments, and simply re-sets the state of +the detector. + + +:mod:`ophyd_async.epics.areadetector.controllers` contains some +examples of how this class is implemented. Because a controller needs to be +able to start and stop detector frame collection (although it is not +responsible for how and where these frames are stored; that is the +responsibility of the `detector writer <#writing-a-detector-writer>`_), in +practice it should be passed a driver. + +Below is an example of an implementation of a controller for an area detector. + +.. literalinclude:: ../../../src/ophyd_async/epics/areadetector/controllers/ad_sim_controller.py + :pyobject: ADSimController + + +Note: + +- The use of `asyncio.gather`: this ensures some operations happen in parallel, + or as close to parallel as python's asyncio logic allows. +- The driver is passed into the constructor. The next subsection contains + details on how to write your own drivers. +- You should place assertions of `DetectorTrigger` in `DetectorControl.arm`, + especially if you only intend for your detector to be used in step or fly + scans. If you can use them for both, ensure to write the logic as such. +- :mod:`ophyd_async.epics.areadetector.drivers.ad_base.start_acquiring_driver_and_ensure_status` + starts scquiring the driver, and checks that the detector state is valid + before completing (when it is awaited on). +- The disarm method uses :mod:`ophyd_async.epics.areadetector.utils.stop_busy_record` to stop the + aquisition (without a caput callback) and wait for it to have stopped with a + timeout. + +When writing your own driver, make sure you start acquiring the driver and stop +it in exactly the same way as done in the above example; this will ensure the +RunEngine does not deadlock. + +Writing a driver +^^^^^^^^^^^^^^^^ + +drivers are just ophyd-async `Device` instances that interface with detector +acquisition. In the above example for the areadetector, the driver used closely +follows the `areaDetector simulator`_ specification, which is why its +definition has a non trivial subclassing hierarchy. You are free to not do this +for your own devices: this is only included for extensibility of drivers in +future and compatibility with Malcolm (Diamonds current internal fly-scanning +system). + +Your driver just needs enough PVs to allow the controller to do it's job, that +is to start and stop acquiring frames. Create it like any regular device. + + +Writing a detector writer +------------------------- + +Detector writers define how data is stored, that is, how files are opened and +closed, and how they keep track of the number of frames written. This becomes +especially important for fly scanning. + +`DetectorWriter` implementations must have the following methods: + +- `DetectorWriter.open`, to open a file for writing, +- `DetectorWriter.close` to open the file after writing has finished, +- `DetectorWriter.get_indices_written` to get the number of frames that have + been written already by whichever plugin is being used (e.g. a hdf plugin) +- `DetectorWriter.wait_for_index` to wait for the number of frames to reach a + certain value, and +- `DetectorWriter.collect_stream_docs` which should yield stream resource or + stream datum documents, aggregating a certain number of frames together. + +As for the `detector controller <#writing-a-detector-controller>`_, the +detector writer should not directly poke PVs in these methods but instead +delegate this role to a `hdf plugin <#writing-a-hdf-plugin-or-equivalent>`_. + +Here is an example of a detector writer for creating HDF files: + +.. literalinclude:: ../../../src/ophyd_async/epics/areadetector/writers/hdf_writer.py + :pyobject: HDFWriter + +Note: + +- Just as with the `driver <#writing-a-driver>`_ for a `DetectorControl` + instance, writers should delegate all PV poking logic to a plugin which does + the file writing on the EPICS side. That is, the `DetectorWriter` itself + should not perform any file I/O but instead understand how the underlying + EPICS layer does it, and delegate to this instead. In the above case, this is + a hdf plugin since we are writing a HDF file. +- A directory provider is passed into the constructor, which is used to + configure the plugin with the correct path to write data to. This is an + optional step, but recommended. +- A name provider is passed into the constructor, which is used to generate a + unique name for each dataset in the descriptor document, +- A shape provider is passed into the constructor, which is used to determine + the ``dtype`` for each entry in the generated descriptor document. + +Writing a hdf plugin or equivalent +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +To write a plugin, simply make an ophyd-async device which contains all the +PVs necessary for the `DetectorWriter` to handle opening and closing files, +as well as keeping track of the number of frames written. + +:mod:`ophyd_async.epics.areadetector.writers.hdf_writer.HDFWriter`, uses the +:mod:`ophyd_async.epics.areadetector.writers.nd_file_hdf.NDFileHDF` plugin: + + +.. literalinclude:: ../../../src/ophyd_async/epics/areadetector/writers/nd_file_hdf.py + :pyobject: NDFileHDF + + +Instantiating a detector +------------------------ + +An example of a simple detector looks like the following: + +.. literalinclude:: ../../../src/ophyd_async/epics/demo/demo_ad_sim_detector.py + :pyobject: DemoADSimDetector + +Note: + +- a driver and plugin are passed into the constructor, which only creates the + `DetectorWriter` and `DetectorControl` instances as it's passing them to the + superclass. +- directory provider, name provider and shape provider are optional, that is + they don't have to be passed through the constructor. As an example, the + shape provider in this instance is always + :mod:`ophyd_async.epics.areadetector.drivers.ADBaseShapeProvider`. + +`DetectorWriter` and `DetectorControl` are just bits of logic that should +exist in a `StandardDetector`, and are not themselves ophyd-devices. Because +connecting (and naming) a top level device means all the children of the device +get named and connected also, it is preferred to only create these objects when +calling ``super().__init__`` as done above, and make the driver and plugin +attributes of the `StandardDetector`. This way, when the instance of +``DemoADSimDetector`` gets connected and named, all underlying child devices in +the driver and plugin are correctly connected and named also. If we missed this +step we would have to individually name and connect them, which is a faff. + +That is, to instantiate this detector: + +.. code-block:: python + + from bluesky.run_engine import RunEngine, call_in_bluesky_event_loop + + from ophyd_async.epics.areadetector.drivers import ADBase + from ophyd_async.epics.areadetector.writers import NDFileHDF + from ophyd_async.epics.demo.demo_ad_sim_detector import DemoADSimDetector + + from ophyd_async.core import StaticDirectoryProvider + + RE = RunEngine() + + driver = ADBase("PREFIX:Driver", name="driver") + plugin = NDFileHDF("PREFIX:Plugin", name="plugin") + + dp = StaticDirectoryProvider("/some/path", "some_filename") + + detector = DemoADSimDetector(driver, plugin, dp, name="detector") + call_in_bluesky_event_loop(detector.connect(sim=True)) + +Note that in the above, the directory provider used is a +`StaticDirectoryProvider`, which requires a path and filename to be used +for storing data from the hdf plugin. Recall that the `DetectorWriter` itself +does nothing with this information; it instead passes this to the plugin, which +updates epics PVs. This means the validation happens at an EPICS level - it is +good practise to ensure your `DetectorWriter` has some way of checking that the +file you passed to it is valid, perhaps by watching another PV as is done in +the ``HDFWriter``. + +It also means if you run the above code, nothing will actually get written, as +we have specified ``sim=True`` which means no connections to EPICS PVs will +be established. + +.. _areaDetector simulator: https://millenia.cars.aps.anl.gov/software/epics/simDetectorDoc.html diff --git a/docs/user/how-to/create-devices.rst b/docs/user/how-to/create-devices.rst new file mode 100644 index 0000000000..20e21437f7 --- /dev/null +++ b/docs/user/how-to/create-devices.rst @@ -0,0 +1,238 @@ +.. note:: + + Ophyd async is included on a provisional basis until the v1.0 release and + may change API on minor release numbers before then + +Create devices +============== + +.. currentmodule:: ophyd_async.core + +There are lots of ways to create ophyd-async devices, since an ophyd-async +device simply needs to subclass `Device` and obey certain protocols to work +with bluesky plans or plan stubs. By default, some utility classes and +functions exist to simplify this process for you. + +Make a simple device +-------------------- + +To make a simple device, you need to subclass from the `Device` class and +optionally create some `Signal` instances connecting to EPICS PVs in the +constructor. + +Because `Signal` instances require a backend to understand how to interface +with hardware over protocols like EPICS, you will need to either instantiate +such a backend, or use factory functions within `ophyd_async.epics.signal` such +that these are automatically generated for you. + +This can be as simple as: + +.. code-block:: python + + from ophyd_async.core import Device + from ophyd_async.epics.signal import epics_signal_rw + + class MyDevice(Device): + def __init__(self, prefix, name = ""): + self.my_pv = epics_signal_rw(float, prefix + ":Mode") + super().__init__(name=name) + +``self.my_pv`` is now an instance of ``SignalRW``. There are four variants of +these factory functions: + +1. `ophyd_async.epics.signal.epics_signal_r` which produces a `SignalR`, +2. `ophyd_async.epics.signal.epics_signal_w` which produces a `SignalW`, +3. `ophyd_async.epics.signal.epics_signal_rw` which produces a `SignalRW`, +4. `ophyd_async.epics.signal.epics_signal_x` which produces a `SignalX`. + +These variants of `Signal` will provide useful default methods, for example +`SignalW` implements ``.set`` which means it obeys the +:class:`~bluesky.protocols.Movable` protocol, `SignalR` implements +``.get_value``, `SignalRW` subclasses both of these and `SignalX` implements +``.trigger`` to execute a PV by setting it to ``None``. + + +Signals created in this way need to be passed a Python type that their value +can be converted to, and a string to connect to (i.e. fully qualified name to +reach the PV). The python type of all epics signals can be one of: + +- A primitive (`str`, `int`, `float`) +- An array (`numpy.typing.NDArray` or ``Sequence[str]``) +- An enum (`enum.Enum`), which must also subclass `str`. + +Some enum PV's can be coerced into a bool type, provided they have only two +options. + +Going back to the above example, ``super().__init__(name=name)`` ensures any +child devices are correctly named. `Signal` objects subclass `Device`, which +means ``self.my_pv`` is itself a child device. Such devices can be accessed +through `Device.children`. + +``MyDevice`` is not itself ``Movable`` although it has a signal that is, +because it does not have a ``.set`` method. You can make it ``Movable`` by +adding it: + +.. code-block:: python + + from ophyd_async.core import Device, AsyncStatus + from ophyd_async.epics.signal import epics_signal_rw + from bluesky.protocols import Movable + from typing import Optional + import asyncio + + class MyDevice(Device, Movable): + def __init__(self, prefix, name = ""): + self.my_pv = epics_signal_rw(float, prefix + ":Mode") + super().__init__(name=name) + + async def _set(self, value: float) -> None: + await self.my_pv.set(value) + + def set(self, value: float, timeout: Optional[float] = None) -> AsyncStatus: + coro = asyncio.wait_for(self._set(value), timeout=timeout) + return AsyncStatus(coro, []) + +There is some added complexity here, because the ``Movable`` protocol has to be +able to apply to both ophyd and ophyd-async, and only one of these leverages +asynchronous logic. Therefore the signature of ``set`` itself must be +synchronous. In this case, we return an `AsyncStatus` which can be awaited on +to complete the coroutine it is wrapping. + +You can follow this pattern with any protocol. + +Make a simple device to be used in scans +---------------------------------------- + +Bluesky plans require devices to be stageable. In many cases, you will probably +be dealing with devices that are readable, that is, that they have some PV +whose value(s) are interesting to observe in a scan. For such a use case, you +can use `StandardReadable`, which provides useful default behaviour: + +.. code-block:: python + + from ophyd_async.core import Device, AsyncStatus + from ophyd_async.epics.signal import epics_signal_rw + from bluesky.protocols import Movable + from typing import Optional + import asyncio + + class MyDevice(StandardReadable, Movable): + def __init__(self, prefix, name = ""): + self.my_pv = epics_signal_rw(float, prefix + ":Mode") + self.my_interesting_pv = epics_signal_rw(float, prefix + ":Interesting") + self.my_changing_pv = epics_signal_rw(float, prefix + ":ChangesOften") + + self.set_readable_signals( + read=[self.my_interesting_pv], + config=[self.my_pv], + read_uncached=[self.my_changing_pv] + ) + + super().__init__(name=name) + + async def _set(self, value: float) -> None: + await self.my_pv.set(value) + + def set(self, value: float, timeout: Optional[float] = None) -> AsyncStatus: + coro = asyncio.wait_for(self._set(value), timeout=timeout) + return AsyncStatus(coro, []) + + +Above, `StandardReadable.set_readable_signals` is called with: + +- ``read`` signals: Signals that should be output to ``read()`` +- ``config`` signals: Signals that should be output to ``read_configuration()`` +- ``read_uncached`` signals: Signals that should be output to ``read()`` but + whose values should not be cached, for example if they change so frequently + that caching their values will not be useful. + +All signals passed into this init method will be monitored between ``stage()`` +and ``unstage()`` and their cached values returned on ``read()`` and +``read_configuration()`` for perfomance, unless they are specified as uncached. + +Make a compound device +---------------------- + +`Signal` instances subclass `Device`, so you can make your own `Device` classes +and instantiate them in a `Device` constructor to nest devices: + + +.. code-block:: python + + from ophyd_async import Device + from ophyd_async.epics.signal import epics_signal_rw + + class Motor(Device): + def __init__(self, prefix, name = ""): + self.motion = epics_signal_rw(float, prefix + ":Motion") + super().__init__(name=name) + + class SampleTable(Device): + def __init__(self, prefix, name=""): + self.x_motor = Motor(prefix + ":X") + self.y_motor = Motor(prefix + ":Y") + super().__init__(name=name) + +Make a device vector +-------------------- + +Sometimes signals logically belong in a dictionary, or a vector: + +.. code-block:: python + + from ophyd_async.core import Device + from ophyd_async.epics.signal import epics_signal_rw + + class Motor(Device): + def __init__(self, prefix, name = ""): + self.motion = epics_signal_rw(float, prefix + ":Motion") + super().__init__(name=name) + + motors = DeviceVector({1: Motor("Motor1", name="motor-1"), 2: Motor("Motor2", name="motor-2")}) + +Alternatively, you can create the devices that will go into your device vector +before passing them through to a `DeviceVector`: + +.. code-block:: python + + from ophyd_async.core import DeviceVector + + motor1 = Motor("Motor1", name="motor-1") + motor2 = Motor("Motor2", name="motor-2") + motors = DeviceVector({1:motor1, 2:motor2}) + +Instantiate a device +-------------------- +The process of instantiating a device, regardless of exactly how it subclasses +a `Device`, is the same: + +1. Start the RunEngine, +2. Create an instance of the desired device(s) +3. Connect them in the RunEngine event loop + +.. code-block:: python + + from bluesky.run_engine import RunEngine, call_in_bluesky_event_loop + + RE = RunEngine() + my_device = MyDevice("SOME:PREFIX:", name="my_device") + call_in_bluesky_event_loop(my_device.connect()) + +Connecting the device is optional, however if it is not done any signals will +be unusable, meaning you will not be able to run bluesky scans with it. + +Devices must **always** be defined to at least call ``super().__init__(name= +name)`` in their constructors, preferably by the end. This means that if a name +is passed to the device upon instantiation, `Device.set_name` gets called +which will name all of the child devices using the python variable names. + +Devices must **always** be named at the top level, as in the above example, +so that each `Device` instance (including `Signal`) has a unique name. If this +step is omitted, you may see unexpected RunEngine errors when it tries to +collect event documents, as it uses the `Device.name` property to collect this +data into a dictionary. Python dictionaries cannot support multiple keys with +the same value, so **each device name must be unique**. + +`Device.connect` can accept a ``sim`` keyword argument, to indicate if +the Device should be started in simulation mode. In this mode, no connection to +any underlying hardware is established. diff --git a/docs/user/index.rst b/docs/user/index.rst index 6e612e82bc..52b3a6d8fb 100644 --- a/docs/user/index.rst +++ b/docs/user/index.rst @@ -19,6 +19,7 @@ side-bar. :maxdepth: 1 tutorials/installation + tutorials/making-your-own-devices-to-run-a-gridscan tutorials/using-existing-devices +++ @@ -31,7 +32,8 @@ side-bar. :caption: How-to Guides :maxdepth: 1 - how-to/make-a-simple-device + how-to/create-devices + how-to/create-detectors how-to/run-container +++ @@ -45,6 +47,7 @@ side-bar. :maxdepth: 1 explanations/docs-structure + explanations/demo-devices +++ diff --git a/docs/user/tutorials/making-your-own-devices-to-run-a-gridscan.rst b/docs/user/tutorials/making-your-own-devices-to-run-a-gridscan.rst new file mode 100644 index 0000000000..18b2a7d587 --- /dev/null +++ b/docs/user/tutorials/making-your-own-devices-to-run-a-gridscan.rst @@ -0,0 +1,581 @@ +.. note:: + + Ophyd async is included on a provisional basis until the v1.0 release and + may change API on minor release numbers before then + +Making your own devices to run a gridscan +========================================= + +This tutorial will guide you through the process of making ophyd-async devices +to run bluesky plans on. It assumes you have some familiarity with Bluesky, and +that you have already run through the tutorial on `tutorial_run_engine_setup`. + +We will be running EPICS IOCs, and writing devices to run *step scans* on them. +This means the triggering of PVs will be handled entirely by our software. + +Please see the :doc:`this tutorial` if you don't +really care about how to make your own devices and just want to get started +with existing ones. The devices you will write by the end of this tutorial are +the same devices this tutorial manipulates. + +Setting up the EPICS layer +-------------------------- +First, lets set up some PVs that we can communicate with. We are going to +accomplish this by using the epicscorelibs_ python library, which is a +dependency of ophyd-async. Make sure to follow the :doc:`installation tutorial +` first to ensure you have a virtual environment set up. +This tutorial will also make use of IPython, which should also be packaged in +your virtual environment provided you have installed the dependencies. + +Once you have activated the python virtual environment, open up an IPython REPL +and type: + +.. code-block:: python + + import ophyd_async.epics.demo + demo.start_ioc_subprocess("TEST") + + +This will start two IOCs - a sensor, and a motor with X and Y axes - and expose +their PVs via the EPICS protocol. If you have EPICS_ installed on your machine +you should now be able to type the following into a regular terminal: + +.. code-block:: bash + + caget TEST:Mode + caget TEST:X:Velocity + caget TEST:Y:Readback + +If this command doesn't work for you, it's probably because you don't have +EPICS installed. For this tutorial, you don't have to have EPICS installed; you +can use ``aioca`` within an IPython terminal instead: + +.. code-block:: python + + import aioca + await aioca.caget("TEST:Mode") + await aioca.caget("TEST:X:Velocity") + await aioca.caget("TEST:Y:Readback") + +The ``demo.start_ioc_subprocess`` function actually runs two .db files in the +background. These can be found in ``src/ophyd_async/epics/demo/`` in the +``mover.db`` and ``sensor.db`` files. Here you can inspect all the PVs that are +exposed by each file, e.g. the sensor (prefixed by ``TEST:``) only has a +``Mode`` and ``Value`` PV. + +Notice the use of ``await``. Ophyd-async is an *asynchronous* hardware +abstraction layer, which means it delegates control to python's asynchronous +single-threaded event loop to run tasks concurrently. If any of these terms are +unfamiliar to you, you should consult the `official asyncio documentation`_. BBC +has a brilliant introduction into `Python's asynchronous logic`_, which is +worth a read and accessible for beginners and more experienced programmers. +Give it a look, and come back here when you are ready :) + +What the demo EPICS IOCs actually do +------------------------------------ +If you look at the ``mover.db`` and ``sensor.db`` files mentioned in the +previous section, you can work out what each PV is actually doing. The sensor +has a ``Value`` PV which calculates some combinations of sine and cosine +functions of ``X:Readback`` and ``Y:Readback`` PVs. Specifically, it outputs: + +.. math:: + sin(x)^{10} + cos(E + xy)cos(x) + +... where x and y represent the ``X:Readback`` and ``Y:Readback`` PVs +respectively, and E is a value taken from the ``Mode`` PV. If the mode is set +to ``Low Energy`` (or 0), this is 10, and if it is set to ``High Energy`` (or +1), it is 100. + +Therefore, what we probably want to do with these PVs is move the X and Y +positions of the motor around in a grid scan, and get a heatmap of the +``Value`` PV to see the maxima and minima of this function. + +Writing Devices to access PVs +----------------------------- +At this point, we have some PVs running in the background. By the end of this +section, we will encapsulate them in ophyd-async devices, and be able to +control them without directly interacting with ``aioca`` as we have done above. + +An ophyd-async ``Device`` is just a collection of PVs that can be coordinated +to run plans. To connect an EPICS PV with such a device, we can use factory +functions that generate an ``ophyd_async.core.Signal`` which can have 4 +flavours: + +- ``SignalR`` which has the method ``.get_value()`` to get its value, +- ``SignalW`` which has the method ``.set(value)`` to set a value, +- ``SignalRW`` which subclasses both ``SignalR`` and ``SignalW`` and, +- ``SignalX`` which has a ``.trigger()`` method on to set the PV to None, which + will execute it. + +Each of these has a corresponding factory function to generate signals that +use the EPICS protocol, which can be imported from `ophyd_async.epics.signal` +and includes ``epics_signal_$mode`` (where ``$mode`` is one of ``r``, ``w``, +``rw`` or ``x``). + +.. code-block:: python + + from ophyd_async.epics.signal import epics_signal_r + signal = epics_signal_r(float, "TEST:Value") + +Above, we call ``epics_signal_r`` with two arguments; the first describes the +type of value we are manipulating (in this case, a float). The second is the +PV name itself. + +Notice the use of ``epics_signal_r`` instead of, e.g. ``epics_signal_rw``. This +is because the ``sensor.db`` file shows us the ``Value`` PV of our IOC is a +CALC record; we can ``caput`` to it, but it won't mean anything. So we should +only use this as a readable signal. + +Try to read the value of this signal now: + +.. code-block:: python + + await signal.get_value() + +You should encounter an error telling you that you haven't connected it yet. +To connect it, you should call ``signal.connect()`` in the bluesky event loop, +which means the bluesky event loop should already be running. The easiest way +to ensure this is to set up a RunEngine, ``RE = RunEngine()``. + +.. code-block:: python + + from bluesky.run_engine import RunEngine, call_in_bluesky_event_loop + + RE = RunEngine() + call_in_bluesky_event_loop(signal.connect()) + await signal.get_value() + +So far, we have defined and connected a signal. Now try to write a device that +contains both ``TEST:Value`` and ``TEST:Mode`` PVs. To do this, you should make +a class that subclasses ``ophyd_async.core.Device``, and creates signals in its +constructor in a similar way to what has been demonstrated above. + +To create a signal representing the ``TEST:Mode`` PV, the datatype will need to +be a defined Enum, containing both the ``Low Energy`` and ``High Energy`` +values that this PV can accept. This Enum should subclass ``str`` as well. + +.. code-block:: python + + from ophyd_async.core import Device + from ophyd_async.epics.signal import epics_signal_rw + from enum import Enum + + + class EnergyMode(str, Enum): + low = "Low Energy" + high = "High Energy" + + + class Sensor(Device): + def __init__(self, prefix: str, name: str = "") -> None: + self.mode = epics_signal_rw(EnergyMode, prefix + "Mode") + self.value = epics_signal_r(float, prefix + "Value") + super().__init__(name=name) + + +Note the call to ``super().__init__(self, name=name)`` in the ``Sensor``. This +is useful because a ``Device`` names itself and all of its children in its +constructor, and all devices (including ``Signal``-s) should be named to work +with Bluesky plans, as they will generate bluesky documents describing them. + +As an exercise, try to write an ophyd device to describe the PVs in the +``mover.db`` file. You should include the ``Setpoint``, ``Velocity``, +``Readback`` and ``Stop.PROC`` PVs, ensuring the last of these becomes a +``SignalX``. + +.. code-block:: python + + from ophyd_async.epics.signal import epics_signal_x + + class Mover(Device): + def __init__(self, prefix: str, name: str = "") -> None: + self.setpoint = epics_signal_rw(float, prefix + "Setpoint") + self.readback = epics_signal_r(float, prefix + "Readback") + self.velocity = epics_signal_rw(float, prefix + "Velocity") + self.stop = epics_signal_x(prefix + "Stop.PROC") + super().__init__(name=name) + +As above, we can instantiate a ``Mover`` like so: + +.. code-block:: python + + RE = RunEngine() + mover_x = Mover("TEST:X:", "moverx") + mover_y = Mover("TEST:Y:", "movery") + call_in_bluesky_event_loop(mover_x.connect()) + call_in_bluesky_event_loop(mover_y.connect()) + await mover_x.velocity.get_value() + await mover_y.readback.get_value() + +It seems like a lot of effort to have to create the X and Y movers separately. +So, we can make a device that creates them together: + +.. code-block:: python + + class SampleStage(Device): + def __init__(self, prefix: str, name: str = "") -> None: + self.x = Mover(prefix + "X:") + self.y = Mover(prefix + "Y:") + super().__init__(name=name) + +... And use it: + +.. code-block:: python + + + from bluesky.run_engine import RunEngine, call_in_bluesky_event_loop + + RE = RunEngine() + stage = SampleStage("TEST:", "stage") + call_in_bluesky_event_loop(stage.connect()) + await stage.x.velocity.get_value() + await stage.y.readback.get_value() + +Experiment with setting the X and Y values, and see how it changes the sensor. +For example, you can set both X and Y setpoints to 10: + +.. code-block:: python + + await stage.x.setpoint.set(10) + await stage.y.setpoint.set(10) + +... and check that the sensor reads the correct value: + +.. code-block:: python + + E = 10 if await sensor.mode.get_value() == EnergyMode.low else 100 + X = await stage.x.readback.get_value() + Y = await stage.y.readback.get_value() + + sensor_value = await sensor.value.get_value() + assert np.sin(X)**10 + np.cos(E + Y*X) * np.cos(X) + + +Using plans and plan stubs +-------------------------- +So far, all manipulation of PVs has been done through the ophyd layer. However, +we can do a similar thing using bluesky, without interacting with the +``.get_value`` and ``.set`` methods on our signals: + +.. code-block:: python + + import bluesky.plan_stubs as bps + + RE(bps.mv(stage.x.setpoint, 12, stage.y.setpoint, 8)) + +``bluesky.plans`` and ``bluesky.plan_stubs`` introduce more complex scanning +logic to manipulate devices, using either `partial or complete recipes`_ of +generated messages that get sent to the RunEngine. For example, ``bps.mv`` in +the above example generates a ``Msg("set", ...)`` for each pair of values it +receives, where the first of these is a device and the second is a value to set +this to. + +Upon receiving this message, the RunEngine tries to call ``.set`` on the object, +which we get for free because ``stage.x`` is not just a ``ophyd_async.core. +Device`` but also a ``ophyd_async.core.signal.SignalW``, meaning it has a +``.set`` already. + +You can see how this works if we pass in a signal that doesn't have a ``.set`` +method, like for example ``sensor.value``, which is readonly: + +.. code-block:: python + + RE(bps.mv(sensor.value, 12)) + + +You will see an assertion error, `` does not implement all Movable +methods``. ``Movable`` refers to a `bluesky protocol`_ that needs to be obeyed +by a device in order for ``bps.mv`` to be run on it - protocols_ are similar to +abstract classes as they only define signatures of methods, rather than +implementations. + +You can usually inspect plans and plan stubs, as well as their logic in the +RunEngine, to figure out what protocols need to be obeyed by a device. You may +also be able to try running a plan or plan stub with a device, and read the +error message to diagnose which protocol it needs, such as in the example +above. + +Running a gridscan +------------------ +A bluesky plan already exists to allow running a grid scan with our devices. +This is ``bluesky.plans.grid_scan``, and it's call signature looks something +like: + +.. code-block:: python + + RE(grid_scan([det1, det2, ...], motor1, start1, stop1, num1, ...)) + +... where ``det1`` and ``det2`` are detectors similar to our ``sensor`` object, +and ``motor1`` is a motor similar to our ``mover`` object. ``start1``, +``stop1`` and ``num1`` indicate the range of values this motor should be +driven to, so values of 0, 1 and 2 respectively means the motor will be +driven to 0, 0.5 and 1. + +At each stage of motor motion, the detectors ``det1`` and ``det2`` will be +triggered. + +For this plan to work, the ophyd devices above, ``Sensor`` and ``Mover``, will +have to change to allow for the following protocols for the ``Sensor``: + +1. Readable, so that we can get event documents each time we collect data, +2. Stageable, so that we can plan how to setup and teardown the device for the + scan. + +Note that all plans require their corresponding devices to be ``Stageable``, +because all plans will try to stage and unstage devices around the actual plan +logic. + +Similarly, the ``Mover`` should be: + +1. Both of the above; we also want event documents describing what the mover is + doing. +2. Movable, so we can drive it to move to specific motor values, + +As an exercise, have a go at trying to expand your existing definitions of +``Sensor`` and ``Mover`` so that they have these protocols, too. Both of these +objects should delegate to the existing methods that their signals have. +You will want to make use of ``ophyd_async.core.AsyncStatus`` for the ``stage`` +and ``unstage`` methods, which can be used to wrap around the methods to return +a status that can be awaited on. See ``ophyd_async.core.SignalR`` for an +example of this. + +.. code-block:: python + + from bluesky.protocols import Reading, Descriptor, Readable, Stageable + from typing import Dict + from ophyd_async.core import AsyncStatus + + class Sensor(Device, Readable, Stageable): + def __init__(self, prefix: str, name: str = "") -> None: + self.mode = epics_signal_rw(EnergyMode, prefix + "Mode") + self.value = epics_signal_r(float, prefix + "Value") + super().__init__(name=name) + + async def read(self) -> Dict[str, Reading]: + return {**(await self.mode.read()), **(await self.value.read())} + + async def describe(self) -> Dict[str, Descriptor]: + return {**(await self.mode.describe()), **(await self.value.describe())} + + @AsyncStatus.wrap + async def stage(self) -> None: + await self.mode.stage() + await self.value.stage() + + @AsyncStatus.wrap + async def unstage(self) -> None: + await self.mode.unstage() + await self.value.unstage() + + +In the above, ``Sensor.stage`` and ``Sensor.unstage`` simply delegate to each +``SignalR``-s ``.stage`` and ``.unstage`` methods. In this case, both +``self.mode`` and ``self.value`` are instances of this class (recall that +``SignalRW`` subclasses ``SignalR``). + +Our ``Mover`` doesn't just have ``SignalR`` devices, however. It has a +``SignalX``, too. This doesn't have a ``.stage`` or a ``.unstage`` method, nor +does it have ``.read`` or ``.describe``. With this in mind, the ``Mover`` can +be written as, + +.. code-block:: python + + from bluesky.protocols import Movable + from typing import Optional + import asyncio + import numpy as np + from ophyd_async.core import observe_value + + class Mover(Device, Readable, Stageable, Movable): + def __init__(self, prefix: str, name: str = "") -> None: + self.setpoint = epics_signal_rw(float, prefix + "Setpoint") + self.readback = epics_signal_r(float, prefix + "Readback") + self.velocity = epics_signal_rw(float, prefix + "Velocity") + self._stop = epics_signal_x(prefix + "Stop.PROC") + super().__init__(name=name) + + async def read(self) -> Dict[str, Reading]: + return { + **(await self.readback.read()), + **(await self.velocity.read()) + } + + async def describe(self) -> Dict[str, Descriptor]: + return { + **(await self.readback.describe()), + **(await self.velocity.describe()) + } + + @AsyncStatus.wrap + async def stage(self) -> None: + await self.readback.stage() + await self.velocity.stage() + + @AsyncStatus.wrap + async def unstage(self) -> None: + await self.readback.unstage() + await self.velocity.unstage() + + async def _set(self, value: float): + await self.setpoint.set(value, wait=False) + + async for current_position in observe_value(self.readback): + if np.isclose(current_position, value): + break + + def set(self, value: float, timeout: Optional[float] = None) -> AsyncStatus: + coro = asyncio.wait_for(self._set(value), timeout=timeout) + return AsyncStatus(coro, []) + +Notice in the above, that I have renamed ``self.stop`` to ``self._stop``. This +is because ``self.stop`` is actually reserved by the ``Stoppable`` protocol; +during the grid scan the RunEngine will check if it can call ``self.stop``. +However, because of the limitations of python runtime typing systems, the +RunEngine doesn't recognise the difference between a property and a method on +a class attribute; it will think ``self.stop`` is a method, not a ``SignalX``. + +Also notice that ``.read`` and ``.describe`` do not include ``self.setpoint``. +This is deliberate; we want to capture the readback value, not the setpoint, +to ensure the motor is doing what we expect it to be doing. + +Finally, notice the use of ``observe_value`` in ``self._set``. This returns an +async iterator, which yields values whenever the signal changes. Therefore, +whenever ``self.readback`` has a change in its value, it is yielded in this for +loop. This bit of logic ensures we only return from ``self._set`` when the +motor's readback value is close enough to what we asked it to be. + +We can now try running our grid scan. First, let's look at the messages that +``bluesky.plans.grid_scan`` will emit with our system, after creating and +connecting these devices: + +.. code-block:: python + + import bluesky.plans as bp + + sensor = Sensor("TEST:", "sensor") + stage = SampleStage("TEST:", "stage") + + call_in_bluesky_event_loop(stage.connect()) + call_in_bluesky_event_loop(sensor.connect()) + + list(bp.grid_scan([sensor], stage.x, 0, 2, 4, stage.y, 0, 2, 4)) + +It should be noted here that each device you use in a plan must have a unique +name, which includes giving it a name in the first place. Bluesky documents use +device names to configure python dictionaries, and overlapping keys are +forbidden. If no name is given for both of these devices (the default), the +RunEngine will recognize there are two devices with the same name (i.e. "") and +complain. + +The above code snippet will generate all the messages ``bp.grid_scan`` would +yield to the RunEngine, which can be useful to identify any errors in the plan +itself. + +For example, try doing: + +.. code-block:: python + + list(bp.grid_scan([sensor], stage.x, 0, 2, 0.5, stage.y, 0, 2, 0.5)) + +... and you will see an error complaining because we have asked for a non- +integer number of images to be taken. Now, we are ready to run the grid scan! + +.. code-block:: python + + RE(bp.grid_scan([sensor], stage.x, 0, 2, 4, stage.y, 0, 2, 4)) + +... did you see that? The result of this command should be a single output, +of the uid of the scan. We ran a grid scan! But how do we get information out +of it? + +Subscriptions and documents +--------------------------- +We can add subscriptions to the RunEngine which will give us more information +about the scan that is being run. For example, we can make a class that stores +all the documents produced, and subscribe to a bluesky utility, +``BestEffortCallback``. + +.. code-block:: python + + from bluesky.callbacks.best_effort import BestEffortCallback + + bec = BestEffortCallback() + + class DocHolder: + def __init__(self): + self.docs = [] + + def __call__(self, name, doc): + self.docs.append({"name": name, "doc": doc}) + + holder = DocHolder() + bec_subscription = RE.subscribe(bec) + holder_subscription = RE.subscribe(holder) + + RE(bp.grid_scan([sensor], stage.x, 0, 2, 4, stage.y, 0, 2, 4)) + + +You can now inspect the documents produced, by inspecting ``holder.docs``, and +should see a table of the results along each point in the grid scan. You can +write whatever subscriptions you want, including ones that make a live plot as +the scan progresses. + + +Using the StandardReadable +-------------------------- + +Congratulations! You have successfully made some ophyd devices to abstract +EPICS IOCs, and run bluesky plans with them. However, I deliberately left out +some finer details that you may now care about. The attentive reader may have +noticed that any motor or detector they write will want to be ``Readable`` and +``Stageable``, and may think it's a bit of a chore to have to re-write this +every time they make a new device. Any readers more familiar with bluesky will +notice we haven't made any use of the concept of configuration signals, or the +``Configurable`` protocol. To help with this, ophyd-async comes with a class +called ``StandardReadable`` which subclasses these, and makes it easy to +produce the correct documents from each scan by grouping signals into readable +or configurable ones. Using this class, the Sensor can be re-written as: + +.. code-block:: python + + class Sensor(StandardReadable): + """A demo sensor that produces a scalar value based on X and Y Movers""" + + def __init__(self, prefix: str, name="") -> None: + # Define some signals + self.value = epics_signal_r(float, prefix + "Value") + self.mode = epics_signal_rw(EnergyMode, prefix + "Mode") + # Set name and signals for read() and read_configuration() + self.set_readable_signals( + read=[self.value], + config=[self.mode], + ) + super().__init__(name=name) + +The only difference between this and the Sensor as we defined previously, is +now we have the ``Mode`` PV attached to a a configurational signal, instead +of a readable one. This just changes how it appears in documents - try this +definition out instead and notice the difference. + +There are a few more enhancements that could be made ontop of the existing +``Mover`` device. First of all, we can also make this subclass +``StandardReadable`` to get the same benefits of staging/unstaging behaviour, +and read/configuration signals. Secondly, we could include more PVs, for +example to describe the units we're working with - at the moment, we just have +a 'velocity' PV without knowing its units. We could also make our device +``Stoppable``, so that we could technically kill the motor motion if anything +goes wrong (and finally make use of the ``self.stop_`` PV...). + +All of that and more is done in the ``ophyd_async.epics.demo`` module, in the +``__init__.py`` file, so have a look through that to observe the differences +and experiment with what each of them do. + + +.. _epicscorelibs: https://github.com/mdavidsaver/epicscorelibs +.. _bluesky framework: https://blueskyproject.io/ +.. _EPICS: https://epics-controls.org/resources-and-support/documents/getting-started/ +.. _Python's asynchronous logic: https://bbc.github.io/cloudfit-public-docs/asyncio/asyncio-part-1.html +.. _protocols: https://mypy.readthedocs.io/en/stable/protocols.html +.. _bluesky protocol: https://github.com/bluesky/bluesky/blob/master/bluesky/protocols.py +.. _partial or complete recipes: https://blueskyproject.io/bluesky/plans.html +.. _official asyncio documentation: https://docs.python.org/3/library/asyncio.html diff --git a/docs/user/tutorials/using-existing-devices.rst b/docs/user/tutorials/using-existing-devices.rst index 067d01ca99..7e504ad1db 100644 --- a/docs/user/tutorials/using-existing-devices.rst +++ b/docs/user/tutorials/using-existing-devices.rst @@ -3,7 +3,7 @@ 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 +Using existing devices ====================== To use an Ophyd Device that has already been written, you need to make a @@ -171,4 +171,4 @@ device we can see it gives the same result: .. seealso:: - How-to `../how-to/make-a-simple-device` to make your own Ophyd Async devices. + How-to `../how-to/create-devices` to learn how to make your own devices. diff --git a/src/ophyd_async/core/device.py b/src/ophyd_async/core/device.py index 75aef56ce6..d8a40964b0 100644 --- a/src/ophyd_async/core/device.py +++ b/src/ophyd_async/core/device.py @@ -33,6 +33,7 @@ def name(self) -> str: return self._name def children(self) -> Iterator[Tuple[str, Device]]: + """Get all attributes of the class which also subclass Device""" for attr_name, attr in self.__dict__.items(): if attr_name != "parent" and isinstance(attr, Device): yield attr_name, attr diff --git a/src/ophyd_async/epics/demo/__init__.py b/src/ophyd_async/epics/demo/__init__.py index 73833c1731..6515771a81 100644 --- a/src/ophyd_async/epics/demo/__init__.py +++ b/src/ophyd_async/epics/demo/__init__.py @@ -127,12 +127,18 @@ def __init__(self, prefix: str, name="") -> None: super().__init__(name=name) -def start_ioc_subprocess() -> str: +def start_ioc_subprocess(pv_prefix: Optional[str] = None) -> 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)) + ":" + if not pv_prefix: + pv_prefix = ( + "".join(random.choice(string.ascii_uppercase) for _ in range(12)) + ":" + ) + elif not pv_prefix.endswith(":"): + pv_prefix += ":" + here = Path(__file__).absolute().parent args = [sys.executable, "-m", "epicscorelibs.ioc"] args += ["-m", f"P={pv_prefix}"]