diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 695d35ea5e..9c85ba4054 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" } @@ -38,7 +39,7 @@ }, "features": { // add in eternal history and other bash features - "ghcr.io/diamondlightsource/devcontainer-features/bash-config:1.0.0": {} + "ghcr.io/diamondlightsource/devcontainer-features/bash-config:1": {} }, // Create the config folder for the bash-config feature "initializeCommand": "mkdir -p ${localEnv:HOME}/.config/bash-config", diff --git a/docs/_static/custom.css b/docs/_static/custom.css new file mode 100644 index 0000000000..feabac7ca4 --- /dev/null +++ b/docs/_static/custom.css @@ -0,0 +1,5 @@ +/* allow a wider screen so we can fit 88 chars of source code on it */ +.bd-page-width { + max-width: 100rem; + /* default is 88rem */ +} 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..28e0e7009a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -72,6 +72,7 @@ # If true, Sphinx will warn about all references where the target cannot # be found. +# TODO: turn this back on # nitpicky = True # A list of (type, target) tuples (by default empty) that should be ignored when @@ -98,7 +99,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 @@ -137,6 +138,7 @@ "numpy": ("https://numpy.org/devdocs/", None), "databroker": ("https://blueskyproject.io/databroker/", None), "event-model": ("https://blueskyproject.io/event-model/main", None), + "pytest": ("https://docs.pytest.org/en/stable/", None), } # A dictionary of graphviz graph attributes for inheritance diagrams. @@ -230,6 +232,10 @@ html_logo = "images/ophyd-async-logo.svg" html_favicon = "images/ophyd-favicon.svg" +# Custom CSS +html_static_path = ["_static"] +html_css_files = ["custom.css"] + # If False and a module has the __all__ attribute set, autosummary documents # every member listed in __all__ and no others. Default is True autosummary_ignore_module_all = False diff --git a/docs/examples/epics_demo.py b/docs/examples/epics_demo.py deleted file mode 100644 index 8e446ba2e5..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 demo - -# 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 = demo.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 = demo.Sensor(pv_prefix) - det_group = demo.SensorGroup(pv_prefix) - samp = demo.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..2b46f27b47 --- /dev/null +++ b/docs/explanations/declarative-vs-procedural.md @@ -0,0 +1,36 @@ +# 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/_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_children_as_readables) 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/_block.py +:pyobject: PulseBlock +``` +For a traditional EPICS IOC there is no such introspection mechanism, so we require a PV Suffix to be supplied via an [annotation](#typing.Annotated). For example, in `ophyd_async.epics.demo.DemoPointDetectorChannel` we describe the PV Suffix and whether the signal appears in `read()` or `read_configuration()` using [](#typing.Annotated): +```{literalinclude} ../../src/ophyd_async/epics/demo/_point_detector_channel.py +:pyobject: DemoPointDetectorChannel +``` +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.demo.DemoMotor`. This could be written in either style with roughly the same legibility, so is a matter of taste: +```{literalinclude} ../../src/ophyd_async/epics/demo/_motor.py +:start-after: class DemoMotor +:end-before: def set_name +``` + +## 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/explanations/design-goals.rst b/docs/explanations/design-goals.md similarity index 100% rename from docs/explanations/design-goals.rst rename to docs/explanations/design-goals.md diff --git a/docs/explanations/event-loop-choice.rst b/docs/explanations/device-connection-strategies.md similarity index 83% rename from docs/explanations/event-loop-choice.rst rename to docs/explanations/device-connection-strategies.md index 4c33fb4e4d..54d7db7dc6 100644 --- a/docs/explanations/event-loop-choice.rst +++ b/docs/explanations/device-connection-strategies.md @@ -1,11 +1,13 @@ -Device Collector Event-Loop Choice ----------------------------------- +# Device connection strategies + +There are various ways you can connect an ophyd-async Device, depending on whether you are running under a RunEngine or not. This article details each of those modes and why you might want to connect in that mode + +## Up front connection In a sync context, the ophyd-async :python:`init_devices` requires the bluesky event-loop to connect to devices. In an async context, it does not. -Sync Context -============ +## Sync Context In a sync context the run-engine must be initialized prior to connecting to devices. We enfore usage of the bluesky event-loop in this context. @@ -22,8 +24,7 @@ The following will fail if :python:`RE = RunEngine()` has not been called alread The :python:`init_devices` connects to devices in the event-loop created in the run-engine. -Async Context -============= +## Async Context In an async context device connection is decoupled from the run-engine. The following attempts connection to all the devices in the :python:`init_devices` diff --git a/docs/how-to/choose-interfaces-for-devices.md b/docs/how-to/choose-right-baseclass.md similarity index 100% rename from docs/how-to/choose-interfaces-for-devices.md rename to docs/how-to/choose-right-baseclass.md diff --git a/docs/how-to/compound-devices.rst b/docs/how-to/compound-devices.rst deleted file mode 100644 index ee95746b05..0000000000 --- a/docs/how-to/compound-devices.rst +++ /dev/null @@ -1,44 +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 - -Compound Devices Together -========================= - -Assembly --------- - -Compound assemblies can be used to group Devices into larger logical Devices: - -.. literalinclude:: ../../src/ophyd_async/epics/sim/_mover.py - :pyobject: SampleStage - -This applies prefixes on construction: - -- SampleStage is passed a prefix like ``DEVICE:`` -- SampleStage.x will append its prefix ``X:`` to get ``DEVICE:X:`` -- SampleStage.x.velocity will append its suffix ``Velocity`` to get - ``DEVICE:X:Velocity`` - -If SampleStage is further nested in another Device another layer of prefix nesting would occur - -.. note:: - - SampleStage does not pass any signals into its superclass init. This means - that its ``read()`` method will return an empty dictionary. This means you - can ``rd sample_stage.x``, but not ``rd sample_stage``. - - -Grouping by Index ------------------ - -Sometimes, it makes sense to group devices by number, say an array of sensors: - -.. literalinclude:: ../../src/ophyd_async/epics/sim/_sensor.py - :pyobject: SensorGroup - -:class:`~ophyd-async.core.DeviceVector` allows writing maintainable, arbitrary-length device groups instead of fixed classes for each possible grouping. A :class:`~ophyd-async.core.DeviceVector` can be accessed via indices, for example: ``my_sensor_group.sensors[2]``. Here ``sensors`` is a dictionary with integer indices rather than a list so that the most semantically sensible indices may be used, the sensor group above may be 1-indexed, for example, because the sensors' datasheet calls them "sensor 1", "sensor 2" etc. - -.. note:: - The :class:`~ophyd-async.core.DeviceVector` adds an extra level of nesting to the device tree compared to static components like ``sensor_1``, ``sensor_2`` etc. so the behavior is not completely equivalent. diff --git a/docs/how-to/make-a-standard-detector.rst b/docs/how-to/implement-ad-detector.md similarity index 100% rename from docs/how-to/make-a-standard-detector.rst rename to docs/how-to/implement-ad-detector.md diff --git a/docs/how-to/use_set_and_wait_for_other_value.md b/docs/how-to/interact-with-signals.md similarity index 100% rename from docs/how-to/use_set_and_wait_for_other_value.md rename to docs/how-to/interact-with-signals.md diff --git a/docs/how-to/make-a-simple-device.rst b/docs/how-to/make-a-simple-device.rst deleted file mode 100644 index 5d2170c9c0..0000000000 --- a/docs/how-to/make-a-simple-device.rst +++ /dev/null @@ -1,91 +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 - -Make a Simple Device -==================== - -.. 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 -:external+bluesky:py:class:`bluesky.protocols.Movable`. - -The rest of this guide will show examples from ``src/ophyd_async/epics/sim/__init__.py`` - -Readable --------- - -For a simple :external+bluesky:py: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()``: - -.. literalinclude:: ../../src/ophyd_async/epics/sim/_sensor.py - :pyobject: Sensor - -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` ie. ``numpy.typing.NDArray[numpy.uint16]`` or ``Sequence[str]``) -- An enum (`enum.Enum`) which **must** also extend `str` - - `str` and ``EnumClass(StrictEnum)`` are the only valid ``datatype`` for an enumerated signal. - -The rest of the arguments are PV connection information, in this case the PV suffix. - -Finally `super().__init__() ` is called with: - -- Possibly empty Device ``name``: will also dash-prefix its child Device names is set -- Optional ``primary`` signal: a Signal that should be renamed to take the name - of the Device and output at ``read()`` -- ``read`` signals: Signals that should be output to ``read()`` without renaming -- ``config`` signals: Signals that should be output to ``read_configuration()`` - without renaming - -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. - -Movable -------- - -For a more complicated device like a `Mover`, you can still use `StandardReadable` -and implement some addition protocols: - -.. literalinclude:: ../../src/ophyd_async/epics/sim/_mover.py - :pyobject: Mover - -The ``set()`` method implements :external+bluesky:py:class:`bluesky.protocols.Movable`. This -creates a `coroutine` ``do_set()`` which gets the old position, units and -precision in parallel, sets the setpoint, then observes the readback value, -informing watchers of the progress. When it gets to the requested value it -completes. This co-routine is wrapped in a timeout handler, and passed to an -`AsyncStatus` which will start executing it as soon as the Run Engine adds a -callback to it. The ``stop()`` method then pokes a PV if the move needs to be -interrupted. - -Assembly --------- - -Compound assemblies can be used to group Devices into larger logical Devices: - -.. literalinclude:: ../../src/ophyd_async/epics/sim/_mover.py - :pyobject: SampleStage - -This applies prefixes on construction: - -- SampleStage is passed a prefix like ``DEVICE:`` -- SampleStage.x will append its prefix ``X:`` to get ``DEVICE:X:`` -- SampleStage.x.velocity will append its suffix ``Velocity`` to get - ``DEVICE:X:Velocity`` - -If SampleStage is further nested in another Device another layer of prefix -nesting would occur - -.. note:: - - SampleStage does not pass any signals into its superclass init. This means - that its ``read()`` method will return an empty dictionary. This means you - can ``rd sample_stage.x``, but not ``rd sample_stage``. diff --git a/docs/how-to/write-tests-for-devices.rst b/docs/how-to/write-tests-for-devices.rst deleted file mode 100644 index 596d141d71..0000000000 --- a/docs/how-to/write-tests-for-devices.rst +++ /dev/null @@ -1,63 +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 - -Write Tests for Devices -======================= - -Testing ophyd-async devices using tools like mocking, patching, and fixtures can become complicated very quickly. The library provides several utilities to make it easier. - -Async Tests ------------ - -`pytest-asyncio `_ is required for async tests. It is should be included as a dev dependency of your project. Tests can either be decorated with ``@pytest.mark.asyncio`` or the project can be automatically configured to detect async tests. - -.. code:: toml - - # pyproject.toml - - [tool.pytest.ini_options] - ... - asyncio_mode = "auto" - -Mock Backend ------------- - -Ophyd devices initialized with a mock backend behave in a similar way to mocks, without requiring you to mock out all the dependencies and internals. The `init_devices` can initialize any number of devices, and their signals and sub-devices (recursively), with a mock backend. - -.. literalinclude:: ../../tests/epics/sim/test_epics_sim.py - :pyobject: mock_sensor - - -Mock Utility Functions ----------------------- - -Mock signals behave as simply as possible, holding a sensible default value when initialized and retaining any value (in memory) to which they are set. This model breaks down in the case of read-only signals, which cannot be set because there is an expectation of some external device setting them in the real world. There is a utility function, ``set_mock_value``, to mock-set values for mock signals, including read-only ones. - -In addition this example also utilizes helper functions like ``assert_reading`` and ``assert_value`` to ensure the validity of device readings and values. For more information see: :doc:`API.core<../_api/ophyd_async.core>` - -.. literalinclude:: ../../tests/epics/sim/test_epics_sim.py - :pyobject: test_sensor_reading_shows_value - - -Given that the mock signal holds a ``unittest.mock.Mock`` object you can retrieve this object and assert that the device has been set correctly using ``get_mock_put``. You are also free to use any other behaviour that ``unittest.mock.Mock`` provides, such as in this example which sets the parent of the mock to allow ordering across signals to be asserted: - -.. literalinclude:: ../../tests/epics/sim/test_epics_sim.py - :pyobject: test_retrieve_mock_and_assert - -There are several other test utility functions: - -Use ``callback_on_mock_put``, for hooking in logic when a mock value changes (e.g. because someone puts to it). This can be called directly, or used as a context, with the callbacks ending after exit. - -.. literalinclude:: ../../tests/epics/sim/test_epics_sim.py - :pyobject: test_mover_stopped - - -Testing a Device in a Plan with the RunEngine ---------------------------------------------- -.. literalinclude:: ../../tests/epics/sim/test_epics_sim.py - :pyobject: test_sensor_in_plan - - -This test verifies that the sim_sensor behaves as expected within a plan. The plan we use here is a ``count``, which takes a specified number of readings from the ``sim_sensor``. Since we set the ``repeat`` to two in this test, the sensor should emit two "event" documents along with "start", "stop" and "descriptor" documents. Finally, we use the helper function ``assert_emitted`` to confirm that the emitted documents match our expectations. diff --git a/docs/tutorials.md b/docs/tutorials.md index 1fe66c541d..674246af6f 100644 --- a/docs/tutorials.md +++ b/docs/tutorials.md @@ -6,5 +6,8 @@ Tutorials for installation and typical usage. New users start here. :maxdepth: 1 :glob: -tutorials/* +tutorials/installation +tutorials/using-devices +tutorials/implementing-devices +tutorials/writing-tests-for-devices ``` diff --git a/docs/tutorials/implementing-devices.md b/docs/tutorials/implementing-devices.md new file mode 100644 index 0000000000..646b778ffe --- /dev/null +++ b/docs/tutorials/implementing-devices.md @@ -0,0 +1,335 @@ +# Implementing Devices + +In [](./using-devices.md) we learned how to instantiate some existing ophyd-async Devices. These Devices were ophyd level simulations, so did not talk to any underlying control system. In this tutorial we will instantiate some demo Devices that talk to underlying control system implementations, then explore how the Devices themselves are implemented. + +## Pick your control system + +Most bluesky users will be interfacing to an underlying control system like EPICS or Tango. The underlying control system will provide functionality like Engineering display screens and historical archiving of control system data. It is possibly to use ophyd-async with multiple control systems, so this tutorial is written with tabbed sections to allow us to only show the information relevant to one particular control system. + +To summarize what each control system does: + +::::{tab-set} +:sync-group: cs + +:::{tab-item} EPICS +:sync: epics + +[EPICS](https://epics-controls.org) is a set of software tools and applications which provide a software infrastructure for use in building distributed control systems to operate devices such as Particle Accelerators, Large Experiments and major Telescopes. Such distributed control systems typically comprise tens or even hundreds of computers, networked together to allow communication between them and to provide control and feedback of the various parts of the device from a central control room. + +EPICS uses Client/Server and Publish/Subscribe techniques to communicate between the various computers. Most servers (called Input/Output Controllers or IOCs) perform real-world I/O and local control tasks, and publish this information to clients using robust, EPICS specific network protocols Channel Access and pvAccess. Clients use a process variable (PV) as an identifier to get, put or monitor the value and metadata of a particular control system variable without knowing which server hosts that variable. + +EPICS has a flat architecture where any client can request the PV of any server. Sites typically introduce hierarchy by imposing a naming convention on these PVs. +::: + +:::{tab-item} Tango +:sync: tango + +[Tango](https://www.tango-controls.org/) is an Open Source solution for SCADA and DCS. Open Source means you get all the source code under an Open Source free licence (LGPL and GPL). Supervisory Control and Data Acquisition (SCADA) systems are typically industrial type systems using standard hardware. Distributed Control Systems (DCS) are more flexible control systems used in more complex environments. Sardana is a good example of a Tango based Beamline SCADA. + +Tango is typically deployed with a central database, which provides a nameserver to lookup which distributed server provides access to the Device. Once located, the Device server can be introspected to see what Attributes of the Device exist. + +::: + +:::{tab-item} FastCS +:sync: fastcs + +[FastCS](https://diamondlightsource.github.io/FastCS) is a control system agnostic framework for building Device support in Python that will work for both EPICS and Tango without depending on either. It allows Device support to be written once in a declarative way, then at runtime the control system can be selected. It also adds support for multi-device introspection to both EPICS and Tango, which allows the same ophyd-async Device and the same FastCS Device to use either EPICS or Tango as a transport layer. + +FastCS is currently in an early phase of development, being used for [PandA](https://github.com/PandABlocks/fastcs-PandABlocks) and [Odin](https://github.com/DiamondLightSource/fastcs-odin) Devices within ophyd-async. +::: + +:::: + +## Run the demo + +Ophyd-async ships with some demo devices that do the same thing for each control system, and a script that will create them along with a RunEngine. Let's run it in an interactive [ipython](https://ipython.org) shell: + +::::{tab-set} +:sync-group: cs + +:::{tab-item} EPICS +:sync: epics + +``` +$ ipython -i -m ophyd_async.epics.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]: +``` +::: + +:::{tab-item} Tango +:sync: tango + +TODO + +::: + +:::{tab-item} FastCS +:sync: fastcs + +TODO +::: + +:::: + +We can now go ahead and run the same grid scan [we did in the previous tutorial](#demo-grid-scan): + +```python +In [1]: RE(bp.grid_scan([pdet], stage.x, 1, 2, 3, stage.y, 2, 3, 3)) + +Transient Scan ID: 1 Time: 2025-01-14 11:29:05 +Persistent Unique Scan ID: '2e0e75d8-33dd-430f-8cd8-6d6ae053c429' +New stream: 'primary' ++-----------+------------+------------+------------+----------------------+----------------------+----------------------+ +| seq_num | time | stage-x | stage-y | pdet-channel-1-value | pdet-channel-2-value | pdet-channel-3-value | ++-----------+------------+------------+------------+----------------------+----------------------+----------------------+ +| 1 | 11:29:05.7 | 1.000 | 2.000 | 921 | 887 | 859 | +| 2 | 11:29:06.6 | 1.000 | 2.500 | 959 | 926 | 898 | +| 3 | 11:29:07.5 | 1.000 | 3.000 | 937 | 903 | 875 | +| 4 | 11:29:08.3 | 1.500 | 2.000 | 976 | 975 | 974 | +| 5 | 11:29:09.1 | 1.500 | 2.500 | 843 | 843 | 842 | +| 6 | 11:29:09.8 | 1.500 | 3.000 | 660 | 660 | 659 | +| 7 | 11:29:10.6 | 2.000 | 2.000 | 761 | 740 | 722 | +| 8 | 11:29:11.4 | 2.000 | 2.500 | 537 | 516 | 498 | +| 9 | 11:29:12.2 | 2.000 | 3.000 | 487 | 467 | 448 | ++-----------+------------+------------+------------+----------------------+----------------------+----------------------+ +generator grid_scan ['2e0e75d8'] (scan num: 1) + +Out[1]: RunEngineResult(run_start_uids=('2e0e75d8-33dd-430f-8cd8-6d6ae053c429',), plan_result='2e0e75d8-33dd-430f-8cd8-6d6ae053c429', exit_status='success', interrupted=False, reason='', exception=None) +``` + +## See how Devices are instantiated + +Now we will take a look at the demo script and see how it is instantiated. The beginning section with imports is the same as in the first tutorial, but then the control system specific differences appear. + +::::{tab-set} +:sync-group: cs + +:::{tab-item} EPICS +:sync: epics + +```{literalinclude} ../../src/ophyd_async/epics/demo/__main__.py +:language: python +:emphasize-lines: 18- +``` + +EPICS PVs are normally broadcast to your entire network subnet. To avoid PV name clashes, we pick a random prefix, then start the demo IOC using this PV prefix. Starting an IOC here is done just for the demo, in production the IOC would already be running before you started bluesky. + +We then pass the PV prefix for each Device down using prior knowledge about the PVs that this particular IOC creates. For example, we know that there will be a `DemoStage`, and all its PVs will start with `prefix + "STAGE:"`. + +```{note} +There is no introspection of PVs in a device in EPICS, if we tell the IOC to make 3 channels on the point detector, we must also tell the ophyd-async device that the point detector has 3 channels. +``` + +::: + +:::{tab-item} Tango +:sync: tango + +TODO + +::: + +:::{tab-item} FastCS +:sync: fastcs + +TODO +::: + +:::: + +## Look at the Device implementations + +The demo creates the following structure of Devices: +```{mermaid} +flowchart LR + DemoPointDetector-- channel ---DeviceVector + DeviceVector-- 1 ---pdet.1(DemoPointDetectorChannel) + DeviceVector-- 2 ---pdet.2(DemoPointDetectorChannel) + DeviceVector-- 3 ---pdet.3(DemoPointDetectorChannel) + DemoStage-- x ---stage.x(DemoMotor) + DemoStage-- y ---stage.y(DemoMotor) +``` +The `DemoStage` contains two `DemoMotor`s, called `x` and `y`. The `DemoPointDetector` contains a `DeviceVector` called `channel` that contains 3 `DemoPointDetectorChannel`s, called `1`, `2` and `3`. + +We will now inspect the `Demo` classes in the diagram to see how they talk to the underlying control system. + +### `DemoPointDetectorChannel` + +Let's start with the lowest level sort of Device, a single channel of our point detector. It contains Signals, which are the smallest sort of Device in ophyd-async, with a current value of a given datatype. In this case, there are two: +- `value`: the current value of the channel in integer counts +- `mode`: a configuration enum which varies the output of the channel + +We specify to the Device baseclass that we would like a Signal of a given type (e.g. `SignalR[int]`) via a type hint, and it will create that signal for us in a control system specific way. The type of `value` is the python builtin `int`, and the type of `mode` is an [enum](#StrictEnum) we have declared ourselves, where the string values must exactly match what the control system produces. + +```{seealso} +[](#SignalDatatype) defines the list of all possible datatypes you can use for Signals +``` + +We also [annotate](#typing.Annotated) this type hint with some additional information, like [`Format`](#StandardReadableFormat). This will tell the [](#StandardReadable) baseclass which Signals are important in a plan like `bp.grid_scan`. In this case we specify that `mode` should be reported as a [configuration parameter](#StandardReadableFormat.CONFIG_SIGNAL) once at the start of the scan, and `value` should be [fetched without caching and plotted](#StandardReadableFormat.HINTED_UNCACHED_SIGNAL) at each point of the scan. + +::::{tab-set} +:sync-group: cs + +:::{tab-item} EPICS +:sync: epics + +```{literalinclude} ../../src/ophyd_async/epics/demo/_point_detector_channel.py +:language: python +``` + +When the Device is instantiated, the [](#EpicsDevice) baseclass will look at all the type hints for annotations with a [](#PvSuffix). It will append that to the PV prefix that is passed into the device. In this case if we made a `DemoPointDetectorChannel(prefix="PREFIX:")`, then `value` would have PV `PREFIX:Value`. [](#PvSuffix) also allows you to specify different suffixes for the read and write PVs if they are different. + +::: + +:::{tab-item} Tango +:sync: tango + +TODO + +::: + +:::{tab-item} FastCS +:sync: fastcs + +TODO +::: + +:::: + +### `DemoPointDetector` + +Moving up a level, we have the point detector itself. This also has some Signals to control acquisition which are created in the same way as above: +- `acquire_time`: a configuration float saying how long each point should be acquired for +- `start`: an executable to start a single acquisition +- `acquiring`: a boolean that is True when acquiring +- `reset`: an executable to reset the counts on all channels + +We also have a [](#DeviceVector) called `channel` with `DemoPointDetectorChannel` instances within it. These will all contribute their configuration values at the start of scan, and their values at every point in the scan. + +Finally, we need to communicate to bluesky that it has to `trigger()` and acquisition before it can `read()` from the underlying channels. We do this by implementing the [`Triggerable`](#bluesky.protocols.Triggerable) protocol. This involves writing a `trigger()` method with the logic that must be run, calling [](#SignalX.trigger), [](#SignalW.set) and [](#SignalR.get_value) to manipulate the values of the underlying Signals, returning when complete. This is wrapped in an [](#AsyncStatus), which is used by bluesky to run this operation in the background and know when it is complete. + +```{seealso} +[](#../how-to/interact-with-signals) +``` + +::::{tab-set} +:sync-group: cs + +:::{tab-item} EPICS +:sync: epics + +```{literalinclude} ../../src/ophyd_async/epics/demo/_point_detector.py +:language: python +``` + +Although the Signals are declared via type hints, the DeviceVector requires explicit instantiation in an `__init__` method. This is because it requires the `num_channels` to be passed in to the constructor to know how many channels require creation. This means that we also need to do the PV concatenation ourselves, so if the PV prefix for the device as `PREFIX:` then the first channel would have prefix `PREFIX:CHAN1:`. We also register them with `StandardReadable` in a different way, adding them within a [](#StandardReadable.add_children_as_readables) context manager which adds all the children created within its body. + +::: + +:::{tab-item} Tango +:sync: tango + +TODO + +::: + +:::{tab-item} FastCS +:sync: fastcs + +TODO +::: + +:::: + +```{seealso} +For more information on when to construct Devices declaratively using type hints, and when to construct them procedurally with an `__init__` method, see [](#../explanations/declarative-vs-procedural) +``` + +### `DemoMotor` + +Moving onto the motion side, we have `DemoMotor`. This has a few more signals: +- `readback`: the current position of the motor as a float +- `velocity`: a configuration parameter for the velocity in units/s +- `units`: the string units of the position +- `setpoint`: the position the motor has been requested to move to as a float, it returns as soon as it's been set +- `precision`: the number of points after the decimal place of the position that are relevant +- `stop_`: an executable to stop the move immediately + +At each point in the scan it will report the `readback`, but we override the `set_name()` method so that it reports its position as `stage.x` rather than `stage.x.readback`. + +If we consider how we would use this in a scan, we could `bp.scan(stage.x.setpoint, ...)` directly, but that would only start the motor moving, not wait for it to complete the move. To do this, we need to implement another protocol: [`Movable`](#bluesky.protocols.Movable). This requires implementing a `set()` method (again wrapped in an [](#AsyncStatus)) that does the following: +- Work out where to move to +- Start the motor moving +- Optionally report back updates on how far the motor has moved so bluesky can provide a progress bar +- Wait until the motor is at the target position + +Finally, we implement [`Stoppable`](#bluesky.protocols.Stoppable) which tells bluesky what to do if the user aborts a plan. This requires implementing `stop()` to execute the `stop_` signal and tell `set()` whether the move should be reported as successful completion, or if it should raise an error. + +::::{tab-set} +:sync-group: cs + +:::{tab-item} EPICS +:sync: epics + +```{literalinclude} ../../src/ophyd_async/epics/demo/_motor.py +:language: python +``` + + +::: + +:::{tab-item} Tango +:sync: tango + +TODO + +::: + +:::{tab-item} FastCS +:sync: fastcs + +TODO +::: + +:::: + + +### `DemoStage` + +Finally we get to the `DemoStage`, which is responsible for instantiating two `DemoMotor`s. It also inherits from [](#StandardReadable), which allows it to be used in plans that `read()` devices. It ensures that the output of `read()` is the same as if you were to `read()` both the `DemoMotor`s, and merge the result: + +::::{tab-set} +:sync-group: cs + +:::{tab-item} EPICS +:sync: epics + +```{literalinclude} ../../src/ophyd_async/epics/demo/_stage.py +:language: python +``` +Like `DemoPointDetector`, the PV concatenation is done explicitly in code, and the children are added within a [](#StandardReadable.add_children_as_readables) context manager. + +::: + +:::{tab-item} Tango +:sync: tango + +TODO + +::: + +:::{tab-item} FastCS +:sync: fastcs + +TODO +::: + +:::: + +## Conclusion + +In this tutorial we have seen how to create some Devices that are backed by a Control system implementation. Read on to see how we would write some tests to ensure that they behave correctly. 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-devices.md b/docs/tutorials/using-devices.md new file mode 100644 index 0000000000..c141bae6e1 --- /dev/null +++ b/docs/tutorials/using-devices.md @@ -0,0 +1,156 @@ +# Using 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 [](inv:bluesky#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/__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 +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`, then return to an interactive prompt. + +## Investigate the Devices + +We will look at the `stage.x` and `y` motors first. If we examine them we can see that they have a name: +```python +In [1]: stage.x.name +Out[1]: 'stage-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]: stage.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 stage.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(stage.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(stage.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(stage.x)) +Out[6]: RunEngineResult(run_start_uids=(), plan_result=1.5, exit_status='success', interrupted=False, reason='', exception=None) +``` + +(demo-grid-scan)= +## Run a grid scan + +There is also a point detector that changes its 3 channels of output based on the positions of the `stage.x` and `stage.y` motors, so we can use it in a [`bp.grid_scan`](#bluesky.plans.grid_scan): + +```{eval-rst} +.. ipython:: python + :suppress: + + from ophyd_async.sim.__main__ import * + # Make the moves faster so docs build don't take too long + RE(bps.mv(stage.x.velocity, 1000, stage.y.velocity, 1000)) + +.. ipython:: python + + @savefig sim_grid_scan.png width=4in + RE(bp.grid_scan([pdet], stage.x, 1, 2, 3, stage.y, 2, 3, 3)) +``` + +This detector produces a single point of information for each channel at each motor value. This means that the [](inv:bluesky#best_effort_callback) is able to print a tabular form of the scan. + +There is also a blob detector that produces a gaussian blob with intensity based on the positions of the `stage.x` and `stage.y` motors, writing the data to an HDF file. You can also use this in a grid scan, but there will be no data displayed as the `BestEffortCallback` doesn't know how to read data from file: + +```{eval-rst} +.. ipython:: python + :okwarning: + + RE(bp.grid_scan([bdet], stage.x, 1, 2, 3, stage.y, 2, 3, 3)) +``` + +:::{seealso} +A more interactive scanning tutorial including live plotting of the data from file 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/__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/__main__.py +:language: python +:start-after: Create a run engine +:end-before: Add a callback +``` +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 behavior that occurs when devices are accessed from multiple event loops. + +We then setup plotting of the resulting scans: +```{literalinclude} ../../src/ophyd_async/sim/__main__.py +:language: python +:start-after: Add a callback +:end-before: Make a pattern generator +``` +This subscribes to the emitted bluesky [](inv:bluesky#documents), and interprets them for plotting. In this case it made a table of points for the motors and each channel of the point detector, and plots of the point detector channels in a gridded pattern. + +Just for the simulation we need something to produce the test data: +```{literalinclude} ../../src/ophyd_async/sim/__main__.py +:language: python +:start-after: X-ray scattering +:end-before: path provider +``` +This is passed to all the Devices so they can tell it the X and Y positions of the motors and get simulated point and gaussian blob data from it. In production you would pass around references to the control system (EPICS PV prefixes or Tango Resource Locations) instead of creating an object here. This is explored in more detail in [](./implementing-devices.md). + +Next up is the path provider: +```{literalinclude} ../../src/ophyd_async/sim/__main__.py +:language: python +:start-after: temporary directory +: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 temporary directory 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. In production we would chose a location on a filesystem that would be accessible by downstream consumers of the scan documents. + +Finally we create and connect the Devices: +```{literalinclude} ../../src/ophyd_async/sim/__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 + ``pdet.set_name("pdet")`` +- 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 `stage`, `pdet` and `bdet` 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 `stage` with `x` and `y` motors, and the two detectors. + +## 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``: ``=1.13", + "bluesky>=1.13.1rc2", "event-model>=1.22.1", "p4p>=4.2.0a3", "pyyaml", diff --git a/src/ophyd_async/core/__init__.py b/src/ophyd_async/core/__init__.py index 9bab111778..be7bc0be98 100644 --- a/src/ophyd_async/core/__init__.py +++ b/src/ophyd_async/core/__init__.py @@ -1,3 +1,18 @@ +"""The building blocks for making devices. + +.. data:: SignalDatatype + + The supported `Signal` datatypes + + - A python primitive `bool`, `int`, `float`, `str` + - A `StrictEnum` or `SubsetEnum` subclass + - A fixed datatype ``Array1D`` of numpy bool, signed and unsigned integers or float + - A numpy array which can change dimensions and datatype at runtime + - A `Sequence` of `str` + - A `Sequence` of `StrictEnum` or `SubsetEnum` subclass + - A `Table` subclass +""" + from ._detector import ( DetectorController, DetectorControllerT, @@ -12,7 +27,7 @@ from ._hdf_dataset import HDFDataset, HDFFile from ._log import config_ophyd_async_logging from ._mock_signal_backend import MockSignalBackend -from ._protocol import AsyncConfigurable, AsyncReadable, AsyncStageable +from ._protocol import AsyncConfigurable, AsyncReadable, AsyncStageable, Watcher from ._providers import ( AutoIncrementFilenameProvider, AutoIncrementingPathProvider, @@ -72,6 +87,7 @@ SubsetEnum, T, WatcherUpdate, + gather_dict, get_dtype, get_enum_cls, get_unique, @@ -154,6 +170,7 @@ "Table", "T", "WatcherUpdate", + "gather_dict", "get_dtype", "get_enum_cls", "get_unique", @@ -161,4 +178,5 @@ "wait_for_connection", "completed_status", "YamlSettingsProvider", + "Watcher", ] diff --git a/src/ophyd_async/core/_protocol.py b/src/ophyd_async/core/_protocol.py index 11334b7d3d..c0d4926cd7 100644 --- a/src/ophyd_async/core/_protocol.py +++ b/src/ophyd_async/core/_protocol.py @@ -103,16 +103,15 @@ def unstage(self) -> AsyncStatus: class Watcher(Protocol, Generic[C]): - @staticmethod def __call__( - *, - current: C, - initial: C, - target: C, - name: str | None, - unit: str | None, - precision: float | None, - fraction: float | None, - time_elapsed: float | None, - time_remaining: float | None, + self, + current: C | None = None, + initial: C | None = None, + target: C | None = None, + name: str | None = None, + unit: str | None = None, + precision: int | None = None, + fraction: float | None = None, + time_elapsed: float | None = None, + time_remaining: float | None = None, ) -> Any: ... diff --git a/src/ophyd_async/core/_providers.py b/src/ophyd_async/core/_providers.py index d86925f08e..c25e3ed57e 100644 --- a/src/ophyd_async/core/_providers.py +++ b/src/ophyd_async/core/_providers.py @@ -31,6 +31,8 @@ def __call__(self, device_name: str | None = None) -> str: class PathProvider(Protocol): + """Abstract class that tells a detector where to write its data.""" + @abstractmethod def __call__(self, device_name: str | None = None) -> PathInfo: """Get the current directory to write files into""" @@ -45,6 +47,8 @@ def __call__(self, device_name: str | None = None) -> str: class UUIDFilenameProvider(FilenameProvider): + """Files will be have a UUID as a filename.""" + def __init__( self, uuid_call_func: Callable = uuid.uuid4, @@ -98,6 +102,8 @@ def __call__(self, device_name: str | None = None) -> str: class StaticPathProvider(PathProvider): + """All files will be within a static directory.""" + def __init__( self, filename_provider: FilenameProvider, diff --git a/src/ophyd_async/core/_signal_backend.py b/src/ophyd_async/core/_signal_backend.py index a0ad517b7c..100591240e 100644 --- a/src/ophyd_async/core/_signal_backend.py +++ b/src/ophyd_async/core/_signal_backend.py @@ -15,9 +15,17 @@ # https://github.com/numpy/numpy/issues/28077#issuecomment-2566485178 Array1D = np.ndarray[tuple[int, ...], np.dtype[DTypeScalar_co]] Primitive = bool | int | float | str -# NOTE: if you change this union then update the docs to match +#: The supported `Signal` datatypes: +#: - A python primitive `bool`, `int`, `float`, `str` +#: - A `StrictEnum` or `SubsetEnum` subclass +#: - A fixed datatype `Array1D` of numpy bool, signed and unsigned integers or float +#: - A `np.ndarray` which can change dimensions and datatype at runtime +#: - A `Sequence` of `str` +#: - A `Sequence` of `StrictEnum` or `SubsetEnum` subclass +#: - A `Table` subclass SignalDatatype = ( Primitive + | StrictEnum | Array1D[np.bool_] | Array1D[np.int8] | Array1D[np.uint8] @@ -30,7 +38,6 @@ | Array1D[np.float32] | Array1D[np.float64] | np.ndarray - | StrictEnum | Sequence[str] | Sequence[StrictEnum] | Table 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/_epics_connector.py b/src/ophyd_async/epics/core/_epics_connector.py index 16cb103331..8649469626 100644 --- a/src/ophyd_async/epics/core/_epics_connector.py +++ b/src/ophyd_async/epics/core/_epics_connector.py @@ -10,6 +10,8 @@ @dataclass class PvSuffix: + """Define the PV suffix to be appended to the device prefix.""" + read_suffix: str write_suffix: str | None = None diff --git a/src/ophyd_async/epics/core/_epics_device.py b/src/ophyd_async/epics/core/_epics_device.py index d72c88f8b8..38ab6f238e 100644 --- a/src/ophyd_async/epics/core/_epics_device.py +++ b/src/ophyd_async/epics/core/_epics_device.py @@ -5,6 +5,8 @@ class EpicsDevice(Device): + """Baseclass to allow child signals to be created declaratively.""" + def __init__(self, prefix: str, with_pvi: bool = False, name: str = ""): if with_pvi: connector = PviDeviceConnector(prefix) diff --git a/src/ophyd_async/epics/demo/__main__.py b/src/ophyd_async/epics/demo/__main__.py index 22f6e816ae..2704ae3c63 100644 --- a/src/ophyd_async/epics/demo/__main__.py +++ b/src/ophyd_async/epics/demo/__main__.py @@ -26,4 +26,4 @@ stage = demo.DemoStage(f"{prefix}STAGE:") # Create a multi channel counter with the same number # of counters as the IOC - det1 = demo.DemoPointDetector(f"{prefix}DET:") + pdet = demo.DemoPointDetector(f"{prefix}DET:", num_channels=3) diff --git a/src/ophyd_async/epics/demo/_motor.py b/src/ophyd_async/epics/demo/_motor.py index 58cb4f358f..3f3db53e29 100644 --- a/src/ophyd_async/epics/demo/_motor.py +++ b/src/ophyd_async/epics/demo/_motor.py @@ -40,22 +40,28 @@ def set_name(self, name: str, *, child_name_separator: str | None = None) -> Non self.readback.set_name(name) @WatchableAsyncStatus.wrap - async def set(self, value: float, timeout: CalculatableTimeout = CALCULATE_TIMEOUT): - new_position = value + async def set( + self, new_position: float, timeout: CalculatableTimeout = CALCULATE_TIMEOUT + ): + # The move should complete successfully unless stop(success=False) is called self._set_success = True + # Get some variables for the progress bar reporting old_position, units, precision, velocity = await asyncio.gather( self.setpoint.get_value(), self.units.get_value(), self.precision.get_value(), self.velocity.get_value(), ) + # If not supplied, calculate a suitable timeout for the move if timeout == CALCULATE_TIMEOUT: timeout = abs(new_position - old_position) / velocity + DEFAULT_TIMEOUT # Wait for the value to set, but don't wait for put completion callback await self.setpoint.set(new_position, wait=False) + # Observe the readback Signal, and on each new position... async for current_position in observe_value( self.readback, done_timeout=timeout ): + # Emit a progress bar update yield WatcherUpdate( current=current_position, initial=old_position, @@ -64,12 +70,13 @@ async def set(self, value: float, timeout: CalculatableTimeout = CALCULATE_TIMEO unit=units, precision=precision, ) + # If we are at the desired position the break if np.isclose(current_position, new_position): break + # If we were told to stop and report an error then do so if not self._set_success: raise RuntimeError("Motor was stopped") async def stop(self, success=True): self._set_success = success - status = self.stop_.trigger() - await status + await self.stop_.trigger() diff --git a/src/ophyd_async/epics/demo/_point_detector.py b/src/ophyd_async/epics/demo/_point_detector.py index 63b62191be..c1a7c95d0a 100644 --- a/src/ophyd_async/epics/demo/_point_detector.py +++ b/src/ophyd_async/epics/demo/_point_detector.py @@ -1,5 +1,7 @@ from typing import Annotated as A +from bluesky.protocols import Triggerable + from ophyd_async.core import ( DEFAULT_TIMEOUT, AsyncStatus, @@ -15,7 +17,9 @@ from ._point_detector_channel import DemoPointDetectorChannel -class DemoPointDetector(StandardReadable, EpicsDevice): +class DemoPointDetector(StandardReadable, EpicsDevice, Triggerable): + """A demo detector that produces a point values based on X and Y motors.""" + acquire_time: A[SignalRW[float], PvSuffix("AcquireTime"), Format.CONFIG_SIGNAL] start: A[SignalX, PvSuffix("Start.PROC")] acquiring: A[SignalR[bool], PvSuffix("Acquiring")] diff --git a/src/ophyd_async/epics/demo/_point_detector_channel.py b/src/ophyd_async/epics/demo/_point_detector_channel.py index 853abf97c0..aa2196ec4e 100644 --- a/src/ophyd_async/epics/demo/_point_detector_channel.py +++ b/src/ophyd_async/epics/demo/_point_detector_channel.py @@ -6,7 +6,7 @@ class EnergyMode(StrictEnum): - """Energy mode for `Sensor`""" + """Energy mode for `DemoPointDetectorChannel`""" #: Low energy mode LOW = "Low Energy" @@ -15,7 +15,7 @@ class EnergyMode(StrictEnum): class DemoPointDetectorChannel(StandardReadable, EpicsDevice): - """A demo sensor that produces a scalar value based on X and Y Movers""" + """A channel for `DemoPointDetector` with int value based on X and Y Motors""" 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/demo/motor.db b/src/ophyd_async/epics/demo/motor.db index 09c95b16e8..beb99f3b19 100644 --- a/src/ophyd_async/epics/demo/motor.db +++ b/src/ophyd_async/epics/demo/motor.db @@ -11,6 +11,7 @@ record(ao, "$(P)Velocity") { field(PINI, "YES") field(EGU, "$(EGU=mm)/s") field(VAL, "$(VELO=1)") + field(DRVL, "0") } record(calc, "$(P)VelocityDiv") { diff --git a/src/ophyd_async/epics/demo/plot.py b/src/ophyd_async/epics/demo/plot.py new file mode 100644 index 0000000000..4a7938dc0b --- /dev/null +++ b/src/ophyd_async/epics/demo/plot.py @@ -0,0 +1,23 @@ +import matplotlib.pyplot as plt +import numpy as np + +delta = 0.025 +x = y = np.arange(-5.0, 5.0, delta) +X, Y = np.meshgrid(x, y) +fig, ax = plt.subplots(nrows=3, ncols=2) + +for channel, row in zip([1, 2, 3], ax, strict=False): + for offset, col in zip([10, 100], row, strict=False): + Z = np.sin(X) ** channel + np.cos(X * Y + offset) + 2 + print(Z.min(), Z.max()) + im = col.imshow( + Z, + interpolation="bilinear", + origin="lower", + extent=(-10, 10, -10, 10), + vmax=4, + vmin=0, + ) + +if __name__ == "__main__": + plt.show() 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/__main__.py b/src/ophyd_async/sim/__main__.py index dc5232b294..c3c10f11b4 100644 --- a/src/ophyd_async/sim/__main__.py +++ b/src/ophyd_async/sim/__main__.py @@ -34,7 +34,7 @@ stage = sim.SimStage(pattern_generator) # Make a detector device that gives the point value of the pattern generator # when triggered - det1 = sim.SimPointDetector(pattern_generator) + pdet = sim.SimPointDetector(pattern_generator) # Make a detector device that gives a gaussian blob with intensity based # on the point value of the pattern generator when triggered - det2 = sim.SimBlobDetector(path_provider, pattern_generator) + bdet = sim.SimBlobDetector(path_provider, pattern_generator) diff --git a/src/ophyd_async/sim/_motor.py b/src/ophyd_async/sim/_motor.py index 309a852a76..1c07b2b613 100644 --- a/src/ophyd_async/sim/_motor.py +++ b/src/ophyd_async/sim/_motor.py @@ -19,13 +19,11 @@ class SimMotor(StandardReadable, Movable, Stoppable): def __init__(self, name="", instant=True) -> None: - """ - Simulated motor device + """Simulation of a motor, with optional velocity - args: - - prefix: str: Signal names prefix - - name: str: name of device - - instant: bool: whether to move instantly, or with a delay + Args: + - name: name of device + - instant: whether to move instantly or calculate move time using velocity """ # Define some signals with self.add_children_as_readables(Format.HINTED_SIGNAL): @@ -43,6 +41,11 @@ def __init__(self, name="", instant=True) -> None: super().__init__(name=name) + def set_name(self, name: str, *, child_name_separator: str | None = None) -> None: + super().set_name(name, child_name_separator=child_name_separator) + # Readback should be named the same as its parent in read() + self.user_readback.set_name(name) + async def _move(self, old_position: float, new_position: float, move_time: float): start = time.monotonic() # Make an array of relative update times at 10Hz intervals 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/testing/__init__.py b/src/ophyd_async/testing/__init__.py index ab8c5d4a59..ebb69b8552 100644 --- a/src/ophyd_async/testing/__init__.py +++ b/src/ophyd_async/testing/__init__.py @@ -1,7 +1,10 @@ +"""Utilities for testing devices.""" + from . import __pytest_assert_rewrite # noqa: F401 from ._assert import ( ApproxTable, MonitorQueue, + StatusWatcher, approx_value, assert_configuration, assert_describe_signal, @@ -14,7 +17,6 @@ get_mock, get_mock_put, mock_puts_blocked, - reset_mock_put_calls, set_mock_put_proceeds, set_mock_value, set_mock_values, @@ -27,21 +29,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 imports __all__ = [ "approx_value", + # 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", @@ -49,4 +55,5 @@ "ParentOfEverythingDevice", "MonitorQueue", "ApproxTable", + "StatusWatcher", ] diff --git a/src/ophyd_async/testing/_assert.py b/src/ophyd_async/testing/_assert.py index fcb266862e..b218c56629 100644 --- a/src/ophyd_async/testing/_assert.py +++ b/src/ophyd_async/testing/_assert.py @@ -2,6 +2,7 @@ import time from contextlib import AbstractContextManager from typing import Any +from unittest.mock import Mock, call import pytest from bluesky.protocols import Reading @@ -12,8 +13,11 @@ AsyncReadable, SignalDatatypeT, SignalR, + T, Table, + Watcher, ) +from ophyd_async.core._status import WatchableAsyncStatus def approx_value(value: Any): @@ -21,20 +25,14 @@ def approx_value(value: Any): 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 approx_value(value) == actual_value @@ -43,21 +41,14 @@ async def assert_value(signal: SignalR[SignalDatatypeT], value: Any) -> None: async def assert_reading( readable: AsyncReadable, expected_reading: dict[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() approx_expected_reading = { @@ -71,21 +62,15 @@ async def assert_configuration( configurable: AsyncConfigurable, configuration: dict[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_configuration = await configurable.read_configuration() approx_expected_configuration = { @@ -108,15 +93,15 @@ def assert_emitted(docs: dict[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()) @@ -174,3 +159,44 @@ def __enter__(self): def __exit__(self, exc_type, exc_value, traceback): self.signal.clear_sub(self.updates.put_nowait) + + +class StatusWatcher(Watcher[T]): + """Watches an `AsyncStatus`, storing the calls within.""" + + def __init__(self, status: WatchableAsyncStatus) -> None: + self._event = asyncio.Event() + self._mock = Mock() + status.watch(self._mock) + + def __call__( + self, + current: T | None = None, + initial: T | None = None, + target: T | None = None, + name: str | None = None, + unit: str | None = None, + precision: int | None = None, + fraction: float | None = None, + time_elapsed: float | None = None, + time_remaining: float | None = None, + ) -> Any: + self._mock( + current=current, + initial=initial, + target=target, + name=name, + unit=unit, + precision=precision, + fraction=fraction, + time_elapsed=time_elapsed, + time_remaining=time_remaining, + ) + self._event.set() + + async def wait_for_call(self, *args, **kwargs): + await asyncio.wait_for(self._event.wait(), timeout=1) + assert self._mock.call_count == 1 + assert self._mock.call_args == call(*args, **kwargs) + self._mock.reset_mock() + self._event.clear() diff --git a/src/ophyd_async/testing/_mock_signal_utils.py b/src/ophyd_async/testing/_mock_signal_utils.py index b5077b637c..9e879c97c2 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:: + Examples + -------- + .. code:: - for value_set in set_mock_values(signal, [1, 2, 3]): - # do something - - 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,28 @@ 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): + """Context manager to block puts at the start and unblock at the end.""" + 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 diff --git a/tests/core/test_mock_signal_backend.py b/tests/core/test_mock_signal_backend.py index 482cd6d769..ecbf0fa729 100644 --- a/tests/core/test_mock_signal_backend.py +++ b/tests/core/test_mock_signal_backend.py @@ -19,7 +19,6 @@ callback_on_mock_put, get_mock_put, mock_puts_blocked, - reset_mock_put_calls, set_mock_put_proceeds, set_mock_value, set_mock_values, @@ -314,7 +313,7 @@ async def test_reset_mock_put_calls(mock_signals): signal1, _ = mock_signals await signal1.set("test_value", wait=True, timeout=1) get_mock_put(signal1).assert_called_with("test_value", wait=ANY) - reset_mock_put_calls(signal1) + get_mock_put(signal1).reset_mock() with pytest.raises(AssertionError) as exc: get_mock_put(signal1).assert_called_with("test_value", wait=ANY) # Replacing spaces because they change between runners diff --git a/tests/epics/demo/test_epics_sim.py b/tests/epics/demo/test_epics_demo.py similarity index 77% rename from tests/epics/demo/test_epics_sim.py rename to tests/epics/demo/test_epics_demo.py index e0e8808c5b..15fc073512 100644 --- a/tests/epics/demo/test_epics_sim.py +++ b/tests/epics/demo/test_epics_demo.py @@ -14,10 +14,11 @@ ) from ophyd_async.epics import demo from ophyd_async.testing import ( + StatusWatcher, + assert_configuration, assert_emitted, assert_reading, assert_value, - callback_on_mock_put, get_mock, get_mock_put, set_mock_value, @@ -26,84 +27,43 @@ @pytest.fixture -async def mock_motor() -> demo.DemoMotor: +async def mock_motor(): async with init_devices(mock=True): mock_motor = demo.DemoMotor("BLxxI-MO-TABLE-01:X:") - # Signals connected here - - assert mock_motor.name == "mock_motor" set_mock_value(mock_motor.units, "mm") set_mock_value(mock_motor.precision, 3) set_mock_value(mock_motor.velocity, 1) - return mock_motor + yield mock_motor @pytest.fixture -async def mock_point_detector() -> demo.DemoPointDetector: +async def mock_point_detector(): async with init_devices(mock=True): mock_point_detector = demo.DemoPointDetector("MOCK:DET:") - # Signals connected here - - assert mock_point_detector.name == "mock_point_detector" - return mock_point_detector + yield mock_point_detector async def test_motor_stopped(mock_motor: demo.DemoMotor): - callbacks = [] - callback_on_mock_put( - mock_motor.stop_, lambda v, *args, **kwargs: callbacks.append(v) - ) - + # Check it hasn't already been called + stop_mock = get_mock_put(mock_motor.stop_) + stop_mock.assert_not_called() + # Call stop and check it's called with the default value await mock_motor.stop() - assert callbacks == [None] - - -class DemoWatcher: - def __init__(self) -> None: - self._event = asyncio.Event() - self._mock = Mock() - - def __call__( - self, - *args, - current: float, - initial: float, - target: float, - name: str | None = None, - unit: str | None = None, - precision: float | None = None, - fraction: float | None = None, - time_elapsed: float | None = None, - time_remaining: float | None = None, - **kwargs, - ): - self._mock( - *args, - current=current, - initial=initial, - target=target, - name=name, - unit=unit, - precision=precision, - time_elapsed=time_elapsed, - **kwargs, - ) - self._event.set() - - async def wait_for_call(self, *args, **kwargs): - await asyncio.wait_for(self._event.wait(), timeout=1) - assert self._mock.call_count == 1 - assert self._mock.call_args == call(*args, **kwargs) - self._mock.reset_mock() - self._event.clear() + stop_mock.assert_called_once_with(None, wait=True) + # We can also track all the mock puts that have happened on the device + parent_mock = get_mock(mock_motor) + await mock_motor.velocity.set(15) + assert parent_mock.mock_calls == [ + call.stop_.put(None, wait=True), + call.velocity.put(15, wait=True), + ] async def test_motor_moving_well(mock_motor: demo.DemoMotor) -> None: + # Start it moving s = mock_motor.set(0.55) - watcher = DemoWatcher() - s.watch(watcher) - done = Mock() - s.add_callback(done) + # Watch for updates, and make sure the first update is the current position + watcher = StatusWatcher(s) await watcher.wait_for_call( name="mock_motor", current=0.0, @@ -113,10 +73,9 @@ async def test_motor_moving_well(mock_motor: demo.DemoMotor) -> None: precision=3, time_elapsed=pytest.approx(0.0, abs=0.08), ) - await assert_value(mock_motor.setpoint, 0.55) assert not s.done - done.assert_not_called() + # Wait a bit and give it an update, checking that the watcher is called with it await asyncio.sleep(0.1) set_mock_value(mock_motor.readback, 0.1) await watcher.wait_for_call( @@ -128,14 +87,11 @@ async def test_motor_moving_well(mock_motor: demo.DemoMotor) -> None: precision=3, time_elapsed=pytest.approx(0.1, abs=0.08), ) + # Make it almost get there and check that it completes set_mock_value(mock_motor.readback, 0.5499999) await wait_for_pending_wakeups() assert s.done assert s.success - done.assert_called_once_with(s) - done2 = Mock() - s.add_callback(done2) - done2.assert_called_once_with(s) async def test_retrieve_mock_and_assert(mock_motor: demo.DemoMotor): @@ -184,18 +140,39 @@ async def test_mocks_in_device_share_parent(): async def test_read_motor(mock_motor: demo.DemoMotor): await mock_motor.stage() - assert (await mock_motor.read())["mock_motor"]["value"] == 0.0 - assert (await mock_motor.read_configuration())["mock_motor-velocity"]["value"] == 1 - assert (await mock_motor.describe_configuration())["mock_motor-units"][ - "shape" - ] == [] + await assert_reading( + mock_motor, + {"mock_motor": {"value": 0.0, "timestamp": ANY, "alarm_severity": 0}}, + ) + await assert_configuration( + mock_motor, + { + "mock_motor-units": { + "value": "mm", + "timestamp": ANY, + "alarm_severity": 0, + }, + "mock_motor-velocity": { + "value": 1.0, + "timestamp": ANY, + "alarm_severity": 0, + }, + }, + ) + # Check that changing the readback value changes the reading set_mock_value(mock_motor.readback, 0.5) - assert (await mock_motor.read())["mock_motor"]["value"] == 0.5 + await assert_value(mock_motor.readback, 0.5) + await assert_reading( + mock_motor, + {"mock_motor": {"value": 0.5, "timestamp": ANY, "alarm_severity": 0}}, + ) + # Check we can still read when not staged await mock_motor.unstage() - # Check we can still read and describe when not staged set_mock_value(mock_motor.readback, 0.1) - assert (await mock_motor.read())["mock_motor"]["value"] == 0.1 - assert await mock_motor.describe() + await assert_reading( + mock_motor, + {"mock_motor": {"value": 0.1, "timestamp": ANY, "alarm_severity": 0}}, + ) async def test_set_velocity(mock_motor: demo.DemoMotor) -> None: @@ -245,16 +222,20 @@ async def test_read_point_detector(mock_point_detector: demo.DemoPointDetector): async def test_point_detector_in_plan( RE: RunEngine, mock_point_detector: demo.DemoPointDetector ): - """Tests mock point_detector behavior within a RunEngine plan. - - This test verifies that the point_detector emits the expected documents - when used in plan(count). - """ + # Subscribe to new documents produce, putting them in a dict by type docs = defaultdict(list) RE.subscribe(lambda name, doc: docs[name].append(doc)) - + # Set the channel values to a known value + for i, channel in mock_point_detector.channel.items(): + set_mock_value(channel.value, 100 + i) + # Run the plan and assert the right docs are produced RE(bp.count([mock_point_detector], num=2)) assert_emitted(docs, start=1, descriptor=1, event=2, stop=1) + assert docs["event"][1]["data"] == { + "mock_point_detector-channel-1-value": 101, + "mock_point_detector-channel-2-value": 102, + "mock_point_detector-channel-3-value": 103, + } async def test_assembly_renaming() -> None: diff --git a/tests/test_tutorials.py b/tests/test_tutorials.py index ca6eaf6e3b..1bfbbd5212 100644 --- a/tests/test_tutorials.py +++ b/tests/test_tutorials.py @@ -1,28 +1,12 @@ import importlib import re import time +from pathlib import Path from unittest.mock import patch import pytest from bluesky.run_engine import RunEngine -EXPECTED = """ -+-----------+------------+-----------------------+-----------------------+----------------------+----------------------+----------------------+ -| seq_num | time | stage-x-user_readback | stage-y-user_readback | det1-channel-1-value | det1-channel-2-value | det1-channel-3-value | -+-----------+------------+-----------------------+-----------------------+----------------------+----------------------+----------------------+ -| 1 | 10:41:18.8 | 1.000 | 1.000 | 711 | 678 | 650 | -| 2 | 10:41:18.9 | 1.000 | 1.500 | 831 | 797 | 769 | -| 3 | 10:41:19.1 | 1.000 | 2.000 | 921 | 887 | 859 | -| 4 | 10:41:19.2 | 1.500 | 1.000 | 870 | 869 | 868 | -| 5 | 10:41:19.3 | 1.500 | 1.500 | 986 | 986 | 985 | -| 6 | 10:41:19.4 | 1.500 | 2.000 | 976 | 975 | 974 | -| 7 | 10:41:19.6 | 2.000 | 1.000 | 938 | 917 | 898 | -| 8 | 10:41:19.7 | 2.000 | 1.500 | 954 | 933 | 914 | -| 9 | 10:41:19.8 | 2.000 | 2.000 | 761 | 740 | 722 | -+-----------+------------+-----------------------+-----------------------+----------------------+----------------------+----------------------+ -""" # noqa: E501 - - # https://regex101.com/r/KvLj7t/1 SCAN_LINE = re.compile( r"^\| *(\d+) \|[^\|]*\| *(\d*.\d*) \| *(\d*.\d*) \| *(\d*) \| *(\d*) \| *(\d*) \|$", @@ -32,8 +16,13 @@ @pytest.fixture def expected_scan_output(): - # TODO: get this from md file - matches = SCAN_LINE.findall(EXPECTED) + tutorial_text = ( + Path(__file__).absolute().parent.parent + / "docs" + / "tutorials" + / "implementing-devices.md" + ).read_text() + matches = SCAN_LINE.findall(tutorial_text) assert len(matches) == 9 yield matches @@ -47,7 +36,7 @@ def test_implementing_devices(module, capsys, expected_scan_output): for motor in [main.stage.x, main.stage.y]: RE(main.bps.mv(motor.velocity, 1000)) start = time.monotonic() - RE(main.bp.grid_scan([main.det1], main.stage.x, 1, 2, 3, main.stage.y, 1, 2, 3)) + RE(main.bp.grid_scan([main.pdet], main.stage.x, 1, 2, 3, main.stage.y, 1, 2, 3)) assert time.monotonic() - start == pytest.approx(2.0, abs=1.0) captured = capsys.readouterr() assert captured.err == ""