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/_templates/custom-module-template.rst b/docs/_templates/custom-module-template.rst index 726bf49435..9aeca54015 100644 --- a/docs/_templates/custom-module-template.rst +++ b/docs/_templates/custom-module-template.rst @@ -1,8 +1,3 @@ -.. note:: - - Ophyd async is considered experimental until the v1.0 release and - may change API on minor release numbers before then - {{ ('``' + fullname + '``') | underline }} {%- set filtered_members = [] %} diff --git a/docs/conf.py b/docs/conf.py index 7a7d1db7bc..324a4496be 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -98,7 +98,7 @@ autodoc_inherit_docstrings = False # Add some more modules to the top level autosummary -ophyd_async.__all__ += ["sim", "epics", "tango", "fastcs", "plan_stubs"] +ophyd_async.__all__ += ["sim", "epics", "tango", "fastcs", "plan_stubs", "testing"] # Document only what is in __all__ autosummary_ignore_module_all = False diff --git a/docs/examples/epics_demo.py b/docs/examples/epics_demo.py deleted file mode 100644 index 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..f6612d5798 --- /dev/null +++ b/docs/explanations/declarative-vs-procedural.md @@ -0,0 +1,38 @@ +# Declarative vs Procedural Devices + +Ophyd async has two styles of creating Devices, Declarative and Procedural. This article describes why there are two mechanisms for building Devices, and looks at the pros and cons of each style. + +## Procedural style + +The procedural style mirrors how you would create a traditional python class, you define an `__init__` method, add some class members, then call the superclass `__init__` method. In the case of ophyd async those class members are likely to be Signals and other Devices. For example, in the `ophyd_async.sim.SimMotor` we create its soft signal children in an `__init__` method: +```{literalinclude} ../../src/ophyd_async/sim/_sim_motor.py +:start-after: class SimMotor +:end-before: def set_name +``` +It is explicit and obvious, but verbose. It also allows you to embed arbitrary python logic in the creation of signals, so is required for making soft signals and DeviceVectors with contents based on an argument passed to `__init__`. It also allows you to use the [](#StandardReadable.add_readable_children) context manager which can save some typing. + +## Declarative style + +The declarative style mirrors how you would create a pydantic `BaseModel`. You create type hints to tell the base class what type of object you create, add annotations to tell it some parameters on how to create it, then the base class `__init__` will introspect and create them. For example, in the `ophyd_async.fastcs.panda.PulseBlock` we define the members we expect, and the baseclass will introspect the selected FastCS transport (EPICS IOC or Tango Device Server) and connect them, adding any extras that are published: +```{literalinclude} ../../src/ophyd_async/fastcs/panda/_panda.py +:start-after: for docs: start PulseBlock +:end-before: for docs: end PulseBlock +``` +For a traditional EPICS IOC there is no such introspection mechanism, so we require a PV Suffix to be supplied via an annotation. For example, in `ophyd_async.epics.sim.Counter` we describe the PV Suffix and whether the signal appears in `read()` or `read_configuration()` using [](#typing.Annotated): +```{literalinclude} ../../src/ophyd_async/epics/sim/_counter_.py +:start-after: class Counter +:end-before: class MultiChannelCounter +``` +It is compact and has the minimum amount of boilerplate, but is limited in its scope to what sorts of Signals and Devices the base class can create. It also requires the usage of a [](#StandardReadableFormat) for each Signal if using [](#StandardReadable) which may be more verbose than the procedural approach. It is best suited for introspectable FastCS and Tango devices, and repetitive EPICS Devices that are wrapped into larger Devices like areaDetectors. + +## Grey area + +There is quite a large segment of Devices that could be written both ways, for instance `ophyd_async.epics.sim.Mover`. This could be written in either style with roughly the same legibility, so is a matter of taste: +```{literalinclude} ../../src/ophyd_async/epics/sim/_mover.py +:start-after: class Mover +:end-before: baa +``` + +## Conclusion + +Ophyd async supports both the declarative and procedural style, and is not prescriptive about which is used. In the end the decision is likely to come down to personal taste, and the style of the surrounding code. diff --git a/docs/tutorials/implementing-epics-devices.md b/docs/tutorials/implementing-epics-devices.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/tutorials/installation.md b/docs/tutorials/installation.md index a55b96b935..1d8654be90 100644 --- a/docs/tutorials/installation.md +++ b/docs/tutorials/installation.md @@ -27,6 +27,19 @@ You can now use `pip` to install the library and its dependencies: $ python3 -m pip install ophyd-async ``` +If you need to talk to a given control system, you will need to install +the specific extra: +- `ca` for EPICS Channel Access +- `pva` for EPICS PVAccess +- `tango` for Tango +- `demo` for tutorial requirements like h5py and ipython +- `testing` for testing requirements like pytest + +E.g.: +``` +$ python3 -m pip install ophyd-async[ca,demo] +``` + If you require a feature that is not currently released you can also install from github: @@ -38,5 +51,5 @@ The library should now be installed and the commandline interface on your path. You can check the version that has been installed by typing: ``` -$ ophyd-async --version +$ python -m ophyd_async --version ``` diff --git a/docs/tutorials/using-existing-devices.md b/docs/tutorials/using-existing-devices.md new file mode 100644 index 0000000000..ab2e78b817 --- /dev/null +++ b/docs/tutorials/using-existing-devices.md @@ -0,0 +1,116 @@ +# Using existing Devices + +In this tutorial we will create a bluesky RunEngine, instantiate some existing ophyd-async Devices, and use them in some bluesky plans. It assumes you have already run through the Bluesky tutorial on `tutorial_run_engine_setup`. + +## Run the demo + +Ophyd-async ships with some simulated devices and a demo script that will create them along with a RunEngine. Let's take a look at it now: +```{literalinclude} ../../src/ophyd_async/sim/demo/__main__.py +:language: python +``` + +We will explain the contents in more detail later on, but for now let's run it in an interactive [ipython](https://ipython.org) shell: +``` +$ ipython -i -m ophyd_async.sim.demo +Python 3.11.11 (main, Dec 4 2024, 20:38:25) [GCC 12.2.0] +Type 'copyright', 'credits' or 'license' for more information +IPython 8.30.0 -- An enhanced Interactive Python. Type '?' for help. + +In [1]: +``` + +This has launched an ipython shell, told it to import and run the demo script packaged inside `ophyd_async.sim.demo`, then return to an interactive prompt. + +## Investigate the Devices + +We will look at the `x` and `y` motors first. If we examine them we can see that they have a name: +```python +In [1]: x.name +Out[1]: 'x' +``` + +But if we try to call any of the other methods like `read()` we will see that it doesn't return the value, but a [coroutines](inv:python:std:label#coroutine): + +```python +In [2]: x.read() +Out[2]: +``` + +This is because ophyd-async devices implement async versions of the bluesky [verbs](inv:bluesky#hardware). To get the value we can `await` it: + ```python +In [3]: await x.read() +Out[3]: +{'x-user_readback': {'value': 0.0, + 'timestamp': 367727.615860209, + 'alarm_severity': 0}} +``` + +## Run some plans + +Although it is useful to run the verbs using the `await` syntax for debugging, most of the time we will run them via plans executed by the [](#bluesky.run_engine.RunEngine). For instance we can read it using the [`bps.rd`](#bluesky.plan_stubs.rd) plan stub: + ```python +In [4]: RE(bps.rd(x)) +Out[4]: RunEngineResult(run_start_uids=(), plan_result=0.0, exit_status='success', interrupted=False, reason='', exception=None) +``` + +and move it using the [`bps.mv`](#bluesky.plan_stubs.mv) plan sub: + ```python +In [5]: RE(bps.mv(x, 1.5)) +Out[5]: RunEngineResult(run_start_uids=(), plan_result=(, done>,), exit_status='success', interrupted=False, reason='', exception=None) + +In [6]: RE(bps.rd(x)) +Out[6]: RunEngineResult(run_start_uids=(), plan_result=1.5, exit_status='success', interrupted=False, reason='', exception=None) +``` + +There is also a detector that changes its output based on the positions of the `x` and `y` motor, so we can use it in a [`bp.grid_scan`](#bluesky.plans.grid_scan): +```python +In [7]: RE(bp.grid_scan([det], x, -10, 10, 10, y, -8, 8, 9)) +Out[7]: RunEngineResult(run_start_uids=('63dc35b7-e4b9-46a3-9bcb-c64d8106cbf3',), plan_result='63dc35b7-e4b9-46a3-9bcb-c64d8106cbf3', exit_status='success', interrupted=False, reason='', exception=None) +``` + +:::{seealso} +A more interactive scanning tutorial including live plotting of the data is in the process of being written in [the bluesky cookbook](https://github.com/bluesky/bluesky-cookbook/pull/22) +::: + +## Examine the script + +We will now walk through the script section by section and examine what each part does. First of all we import the bluesky and ophyd libraries: +```{literalinclude} ../../src/ophyd_async/sim/demo/__main__.py +:language: python +:start-after: Import bluesky and ophyd +:end-before: Create a run engine +``` + +After this we create a RunEngine: +```{literalinclude} ../../src/ophyd_async/sim/demo/__main__.py +:language: python +:start-after: Create a run engine +:end-before: Define where test data should be written +``` +We pass `call_returns_result=True` to the RunEngine so that we can see the result of `bps.rd` above. We call `autoawait_in_bluesky_event_loop()` so that when we `await bps.rd(x)` it will happen in the same event loop that the RunEngine uses rather than an IPython specific one. This avoids some surprising behaviour that occurs when devices are accessed from multiple event loops. + +Next up is the path provider: +```{literalinclude} ../../src/ophyd_async/sim/demo/__main__.py +:language: python +:start-after: Define where test data should be written +:end-before: All Devices created within this block +``` +This is how we specify in which location file-writing detectors store their data. In this example we choose to write to a static directory `/tmp` using the [](#StaticPathProvider), and to name each file within it with a unique UUID using the [](#UUIDFilenameProvider). [Other PathProviders](#PathProvider) allow this to be customized. + +Finally we create and connect the Devices: +```{literalinclude} ../../src/ophyd_async/sim/demo/__main__.py +:language: python +:start-after: connected and named at the end of the with block +``` +The first thing to note is the `with` statement. This uses a [](#init_devices) as a context manager to collect up the top level `Device` instances created in the context, and run the following: + +- If `set_name=True` (the default), then call [](#Device.set_name) passing the name of the variable within the context. For example, here we call + ``det.set_name("det")`` +- If ``connect=True`` (the default), then call [](#Device.connect) in parallel for all top level Devices, waiting for up to ``timeout`` seconds. For example, here we will connect `x`, `y` and `det` at the same time. This parallel connect speeds up connection to the underlying control system. +- If ``mock=True`` is passed, then don't connect to the control system, but set Devices into mock mode for testing. + +Within it the device creation happens, in this case the `x` and `y` motors and a `det` detector that gives different data depending on the position of the motors. + +## Conclusion + +In this tutorial we have instantiated some existing ophyd-async devices, seen how they can be connected and named, and used them in some basic plans. Read on to see how to implement support for devices via a control system like EPICS or Tango. diff --git a/docs/tutorials/using-existing-devices.rst b/docs/tutorials/using-existing-devices.rst deleted file mode 100644 index 77a031b39b..0000000000 --- a/docs/tutorials/using-existing-devices.rst +++ /dev/null @@ -1,184 +0,0 @@ -.. note:: - - Ophyd async is included on a provisional basis until the v1.0 release and - may change API on minor release numbers before then - -Using existing Devices -====================== - -To use an Ophyd Device that has already been written, you need to make a -RunEngine, then instantiate the Device in that process. This tutorial will take -you through this process. It assumes you have already run through the Bluesky -tutorial on `tutorial_run_engine_setup`. - -Create Startup file -------------------- - -For this tutorial we will use IPython. We will instantiate the RunEngine and -Devices in a startup file. This is just a regular Python file that IPython -will execute before giving us a prompt to execute scans. Copy the text -below and place it in an ``epics_demo.py`` file: - -.. literalinclude:: ../examples/epics_demo.py - :language: python - -The top section of the file is explained in the Bluesky tutorial, but the bottom -section is Ophyd specific. - -First of all we start up a specific EPICS IOC for the demo devices. This is only -used in this tutorial: - -.. literalinclude:: ../examples/epics_demo.py - :language: python - :start-after: # Start IOC - :end-before: # Create ophyd devices - -Next we create an example Ophyd device for comparison purposes. It is here to show -that you can mix Ophyd and Ophyd Async devices in the same RunEngine: - -.. literalinclude:: ../examples/epics_demo.py - :language: python - :start-after: # Create ophyd devices - :end-before: # Create ophyd-async devices - -Finally we create the Ophyd Async devices imported from the `epics.sim` module: - -.. literalinclude:: ../examples/epics_demo.py - :language: python - :start-after: # Create ophyd-async devices - -The first thing to note is `with`. This uses `init_devices` as a context -manager to collect up the top level `Device` instances created in the context, -and run the following: - -- If ``set_name=True`` (the default), then call `Device.set_name` passing the - name of the variable within the context. For example, here we call - ``det.set_name("det")`` -- If ``connect=True`` (the default), then call `Device.connect` in parallel for - all top level Devices, waiting for up to ``timeout`` seconds. For example, - here we call ``asyncio.wait([det.connect(), samp.connect()])`` -- If ``mock=True`` is passed, then don't connect to PVs, but set Devices into - simulation mode - -The Devices we create in this example are a "sample stage" with a couple of -"movers" called ``x`` and ``y`` and a "sensor" called ``det`` that gives a -different reading depending on the position of the "movers". - -.. note:: - - There are very few devices implemented using ophyd async, see ophyd_async.epics.devices - and ophyd-tango-devices for some common ones associated with each control - system - -Run IPython ------------ - -You can now run ipython with this startup file:: - - $ ipython -i epics_demo.py - IPython 8.5.0 -- An enhanced Interactive Python. Type '?' for help. - - In [1]: - -.. ipython:: python - :suppress: - :okexcept: - - import sys - from pathlib import Path - sys.path.append(str(Path(".").absolute()/"docs/examples")) - from epics_demo import * - # Turn off progressbar and table - RE.waiting_hook = None - bec.disable_table() - -This is like a regular python console with the contents of that file executed. -IPython adds some extra features like tab completion and magics (shortcut -commands). - -Run some plans --------------- - -Ophyd Devices give an interface to the `bluesky.run_engine.RunEngine` so they -can be used in plans. We can move the ``samp.x`` mover to 100mm using -`bluesky.plan_stubs.mv`: - -.. ipython:: - :okexcept: - - In [1]: RE(mov(samp.x, 100)) - -If this is too verbose to write, we registered a shorthand with -``bluesky.utils.register_transform``: `` int: @abstractmethod def collect_stream_docs(self, indices_written: int) -> AsyncIterator[StreamAsset]: """Create Stream docs up to given number written""" + ... @abstractmethod async def close(self) -> None: @@ -274,9 +275,6 @@ async def trigger(self) -> None: TriggerInfo( number_of_triggers=1, trigger=DetectorTrigger.INTERNAL, - deadtime=None, - livetime=None, - frame_timeout=None, ) ) assert self._trigger_info @@ -290,7 +288,7 @@ async def trigger(self) -> None: async for index in self._writer.observe_indices_written( DEFAULT_TIMEOUT + (self._trigger_info.livetime or 0) - + (self._trigger_info.deadtime or 0) + + self._trigger_info.deadtime ): if index >= end_observation: break @@ -312,25 +310,27 @@ async def prepare(self, value: TriggerInfo) -> None: value: TriggerInfo describing how to trigger the detector """ if value.trigger != DetectorTrigger.INTERNAL: - assert ( - value.deadtime - ), "Deadtime must be supplied when in externally triggered mode" + assert value.deadtime, ( + "Deadtime must be supplied when in externally triggered mode" + ) if value.deadtime: required = self._controller.get_deadtime(value.livetime) assert required <= value.deadtime, ( f"Detector {self._controller} needs at least {required}s deadtime, " f"but trigger logic provides only {value.deadtime}s" ) + else: + value.deadtime = self._controller.get_deadtime(value.livetime) self._trigger_info = value self._number_of_triggers_iter = iter( self._trigger_info.number_of_triggers if isinstance(self._trigger_info.number_of_triggers, list) else [self._trigger_info.number_of_triggers] ) - self._initial_frame = await self._writer.get_indices_written() self._describe, _ = await asyncio.gather( self._writer.open(value.multiplier), self._controller.prepare(value) ) + self._initial_frame = await self._writer.get_indices_written() if value.trigger != DetectorTrigger.INTERNAL: await self._controller.arm() self._fly_start = time.monotonic() diff --git a/src/ophyd_async/core/_providers.py b/src/ophyd_async/core/_providers.py index bb42cb7ff8..d86925f08e 100644 --- a/src/ophyd_async/core/_providers.py +++ b/src/ophyd_async/core/_providers.py @@ -101,11 +101,11 @@ class StaticPathProvider(PathProvider): def __init__( self, filename_provider: FilenameProvider, - directory_path: Path, + directory_path: Path | str, create_dir_depth: int = 0, ) -> None: self._filename_provider = filename_provider - self._directory_path = directory_path + self._directory_path = Path(directory_path) self._create_dir_depth = create_dir_depth def __call__(self, device_name: str | None = None) -> PathInfo: diff --git a/src/ophyd_async/core/_utils.py b/src/ophyd_async/core/_utils.py index e89131ea75..3ca234e635 100644 --- a/src/ophyd_async/core/_utils.py +++ b/src/ophyd_async/core/_utils.py @@ -11,6 +11,7 @@ import numpy as np T = TypeVar("T") +V = TypeVar("V") P = ParamSpec("P") Callback = Callable[[T], None] DEFAULT_TIMEOUT = 10.0 @@ -94,7 +95,7 @@ def _format_sub_errors(self, name: str, error: Exception, indent="") -> str: def format_error_string(self, indent="") -> str: if not isinstance(self._errors, dict) and not isinstance(self._errors, str): raise RuntimeError( - f"Unexpected type `{type(self._errors)}` " "expected `str` or `dict`" + f"Unexpected type `{type(self._errors)}` expected `str` or `dict`" ) if isinstance(self._errors, str): @@ -227,8 +228,9 @@ async def merge_gathered_dicts( return ret -async def gather_list(coros: Iterable[Awaitable[T]]) -> list[T]: - return await asyncio.gather(*coros) +async def gather_dict(coros: dict[T, Awaitable[V]]) -> dict[T, V]: + values = await asyncio.gather(*coros.values()) + return dict(zip(coros, values, strict=True)) def in_micros(t: float) -> int: 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/demo/__init__.py b/src/ophyd_async/epics/demo/__init__.py index 3e3d602f91..ead1404ff7 100644 --- a/src/ophyd_async/epics/demo/__init__.py +++ b/src/ophyd_async/epics/demo/__init__.py @@ -1,54 +1,16 @@ """Demo EPICS Devices for the tutorial""" -import atexit -import random -import string -import subprocess -import sys -from pathlib import Path - -from ._mover import Mover, SampleStage -from ._sensor import EnergyMode, Sensor, SensorGroup +from ._ioc import start_ioc_subprocess +from ._motor import DemoMotor +from ._point_detector import DemoPointDetector +from ._point_detector_channel import DemoPointDetectorChannel, EnergyMode +from ._stage import DemoStage __all__ = [ - "Mover", - "SampleStage", + "DemoMotor", + "DemoStage", "EnergyMode", - "Sensor", - "SensorGroup", + "DemoPointDetectorChannel", + "DemoPointDetector", + "start_ioc_subprocess", ] - - -def start_ioc_subprocess() -> str: - """Start an IOC subprocess with EPICS database for sample stage and sensor - with the same pv prefix - """ - - pv_prefix = "".join(random.choice(string.ascii_uppercase) for _ in range(12)) + ":" - here = Path(__file__).absolute().parent - args = [sys.executable, "-m", "epicscorelibs.ioc"] - - # Create standalone sensor - args += ["-m", f"P={pv_prefix}"] - args += ["-d", str(here / "sensor.db")] - - # Create sensor group - for suffix in ["1", "2", "3"]: - args += ["-m", f"P={pv_prefix}{suffix}:"] - args += ["-d", str(here / "sensor.db")] - - # Create X and Y motors - for suffix in ["X", "Y"]: - args += ["-m", f"P={pv_prefix}{suffix}:"] - args += ["-d", str(here / "mover.db")] - - # Start IOC - process = subprocess.Popen( - args, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - universal_newlines=True, - ) - atexit.register(process.communicate, "exit") - return pv_prefix diff --git a/src/ophyd_async/epics/demo/__main__.py b/src/ophyd_async/epics/demo/__main__.py index b3634d0a67..22f6e816ae 100644 --- a/src/ophyd_async/epics/demo/__main__.py +++ b/src/ophyd_async/epics/demo/__main__.py @@ -17,14 +17,13 @@ # Start IOC with demo pvs in subprocess prefix = testing.generate_random_pv_prefix() -prefix = "foo:" -demo.start_ioc_subprocess(prefix, num_counters=3) +demo.start_ioc_subprocess(prefix, num_channels=3) # All Devices created within this block will be # connected and named at the end of the with block with init_devices(): # Create a sample stage with X and Y motors - stage = demo.Stage(f"{prefix}STAGE:") + stage = demo.DemoStage(f"{prefix}STAGE:") # Create a multi channel counter with the same number # of counters as the IOC - mcc = demo.MultiChannelCounter(f"{prefix}MCC:") + det1 = demo.DemoPointDetector(f"{prefix}DET:") diff --git a/src/ophyd_async/epics/demo/_ioc.py b/src/ophyd_async/epics/demo/_ioc.py index eaefd1f1af..f2ee421499 100644 --- a/src/ophyd_async/epics/demo/_ioc.py +++ b/src/ophyd_async/epics/demo/_ioc.py @@ -6,20 +6,20 @@ HERE = Path(__file__).absolute().parent -def start_ioc_subprocess(prefix: str, num_counters: int): +def start_ioc_subprocess(prefix: str, num_channels: int): """Start an IOC subprocess with EPICS database for sample stage and sensor with the same pv prefix """ ioc = TestingIOC() # Create X and Y motors for suffix in ["X", "Y"]: - ioc.add_database(HERE / "mover.db", P=f"{prefix}STAGE:{suffix}:") + ioc.add_database(HERE / "motor.db", P=f"{prefix}STAGE:{suffix}:") # Create a multichannel counter with num_counters - ioc.add_database(HERE / "multichannelcounter.db", P=f"{prefix}MCC:") - for i in range(1, num_counters + 1): + ioc.add_database(HERE / "point_detector.db", P=f"{prefix}DET:") + for i in range(1, num_channels + 1): ioc.add_database( - HERE / "counter.db", - P=f"{prefix}MCC:", + HERE / "point_detector_channel.db", + P=f"{prefix}DET:", CHANNEL=str(i), X=f"{prefix}STAGE:X:", Y=f"{prefix}STAGE:Y:", diff --git a/src/ophyd_async/epics/demo/_mover.py b/src/ophyd_async/epics/demo/_motor.py similarity index 55% rename from src/ophyd_async/epics/demo/_mover.py rename to src/ophyd_async/epics/demo/_motor.py index 88bd3fd655..9d17e2c699 100644 --- a/src/ophyd_async/epics/demo/_mover.py +++ b/src/ophyd_async/epics/demo/_motor.py @@ -1,4 +1,5 @@ import asyncio +from typing import Annotated as A import numpy as np from bluesky.protocols import Movable, Stoppable @@ -6,36 +7,32 @@ from ophyd_async.core import ( CALCULATE_TIMEOUT, DEFAULT_TIMEOUT, - AsyncStatus, CalculatableTimeout, - Device, + SignalR, + SignalRW, + SignalX, StandardReadable, WatchableAsyncStatus, WatcherUpdate, observe_value, ) from ophyd_async.core import StandardReadableFormat as Format -from ophyd_async.epics.core import epics_signal_r, epics_signal_rw, epics_signal_x +from ophyd_async.epics.core import EpicsDevice, PvSuffix -class Mover(StandardReadable, Movable, Stoppable): +class DemoMotor(EpicsDevice, StandardReadable, Movable, Stoppable): """A demo movable that moves based on velocity""" - def __init__(self, prefix: str, name="") -> None: - # Define some signals - with self.add_children_as_readables(Format.HINTED_SIGNAL): - self.readback = epics_signal_r(float, prefix + "Readback") - with self.add_children_as_readables(Format.CONFIG_SIGNAL): - self.velocity = epics_signal_rw(float, prefix + "Velocity") - self.units = epics_signal_r(str, prefix + "Readback.EGU") - self.setpoint = epics_signal_rw(float, prefix + "Setpoint") - self.precision = epics_signal_r(int, prefix + "Readback.PREC") - # Signals that collide with standard methods should have a trailing underscore - self.stop_ = epics_signal_x(prefix + "Stop.PROC") - # Whether set() should complete successfully or not - self._set_success = True - - super().__init__(name=name) + # Whether set() should complete successfully or not + _set_success = True + # Define some signals + readback: A[SignalR[float], PvSuffix("Readback"), Format.HINTED_SIGNAL] + velocity: A[SignalRW[float], PvSuffix("Velocity"), Format.CONFIG_SIGNAL] + units: A[SignalR[str], PvSuffix("Readback.EGU"), Format.CONFIG_SIGNAL] + setpoint: A[SignalRW[float], PvSuffix("Setpoint")] + precision: A[SignalR[int], PvSuffix("Readback.PREC")] + # If a signal name clashes with a bluesky verb add _ to the attribute name + stop_: A[SignalX, PvSuffix("Stop.PROC")] def set_name(self, name: str, *, child_name_separator: str | None = None) -> None: super().set_name(name, child_name_separator=child_name_separator) @@ -55,14 +52,10 @@ async def set(self, value: float, timeout: CalculatableTimeout = CALCULATE_TIMEO if timeout == CALCULATE_TIMEOUT: assert velocity > 0, "Mover has zero velocity" timeout = abs(new_position - old_position) / velocity + DEFAULT_TIMEOUT - # Make an Event that will be set on completion, and a Status that will - # error if not done in time - done = asyncio.Event() - done_status = AsyncStatus(asyncio.wait_for(done.wait(), timeout)) # Wait for the value to set, but don't wait for put completion callback await self.setpoint.set(new_position, wait=False) async for current_position in observe_value( - self.readback, done_status=done_status + self.readback, done_timeout=timeout ): yield WatcherUpdate( current=current_position, @@ -73,7 +66,6 @@ async def set(self, value: float, timeout: CalculatableTimeout = CALCULATE_TIMEO precision=precision, ) if np.isclose(current_position, new_position): - done.set() break if not self._set_success: raise RuntimeError("Motor was stopped") @@ -82,14 +74,3 @@ async def stop(self, success=True): self._set_success = success status = self.stop_.trigger() await status - - -class SampleStage(Device): - """A demo sample stage with X and Y movables""" - - def __init__(self, prefix: str, name="") -> None: - # Define some child Devices - self.x = Mover(prefix + "X:") - self.y = Mover(prefix + "Y:") - # Set name of device and child devices - super().__init__(name=name) diff --git a/src/ophyd_async/epics/demo/_point_detector.py b/src/ophyd_async/epics/demo/_point_detector.py new file mode 100644 index 0000000000..63b62191be --- /dev/null +++ b/src/ophyd_async/epics/demo/_point_detector.py @@ -0,0 +1,38 @@ +from typing import Annotated as A + +from ophyd_async.core import ( + DEFAULT_TIMEOUT, + AsyncStatus, + DeviceVector, + SignalR, + SignalRW, + SignalX, + StandardReadable, +) +from ophyd_async.core import StandardReadableFormat as Format +from ophyd_async.epics.core import EpicsDevice, PvSuffix + +from ._point_detector_channel import DemoPointDetectorChannel + + +class DemoPointDetector(StandardReadable, EpicsDevice): + acquire_time: A[SignalRW[float], PvSuffix("AcquireTime"), Format.CONFIG_SIGNAL] + start: A[SignalX, PvSuffix("Start.PROC")] + acquiring: A[SignalR[bool], PvSuffix("Acquiring")] + reset: A[SignalX, PvSuffix("Reset.PROC")] + + def __init__(self, prefix: str, num_channels: int = 3, name: str = "") -> None: + with self.add_children_as_readables(): + self.channel = DeviceVector( + { + i: DemoPointDetectorChannel(f"{prefix}{i}:") + for i in range(1, num_channels + 1) + } + ) + super().__init__(prefix=prefix, name=name) + + @AsyncStatus.wrap + async def trigger(self): + await self.reset.trigger() + timeout = await self.acquire_time.get_value() + DEFAULT_TIMEOUT + await self.start.trigger(timeout=timeout) diff --git a/src/ophyd_async/epics/demo/_point_detector_channel.py b/src/ophyd_async/epics/demo/_point_detector_channel.py new file mode 100644 index 0000000000..853abf97c0 --- /dev/null +++ b/src/ophyd_async/epics/demo/_point_detector_channel.py @@ -0,0 +1,21 @@ +from typing import Annotated as A + +from ophyd_async.core import SignalR, SignalRW, StandardReadable, StrictEnum +from ophyd_async.core import StandardReadableFormat as Format +from ophyd_async.epics.core import EpicsDevice, PvSuffix + + +class EnergyMode(StrictEnum): + """Energy mode for `Sensor`""" + + #: Low energy mode + LOW = "Low Energy" + #: High energy mode + HIGH = "High Energy" + + +class DemoPointDetectorChannel(StandardReadable, EpicsDevice): + """A demo sensor that produces a scalar value based on X and Y Movers""" + + value: A[SignalR[int], PvSuffix("Value"), Format.HINTED_UNCACHED_SIGNAL] + mode: A[SignalRW[EnergyMode], PvSuffix("Mode"), Format.CONFIG_SIGNAL] diff --git a/src/ophyd_async/epics/demo/_sensor.py b/src/ophyd_async/epics/demo/_sensor.py deleted file mode 100644 index 0cc99d090a..0000000000 --- a/src/ophyd_async/epics/demo/_sensor.py +++ /dev/null @@ -1,37 +0,0 @@ -from typing import Annotated as A - -from ophyd_async.core import ( - DeviceVector, - SignalR, - SignalRW, - StandardReadable, - StrictEnum, -) -from ophyd_async.core import StandardReadableFormat as Format -from ophyd_async.epics.core import EpicsDevice, PvSuffix - - -class EnergyMode(StrictEnum): - """Energy mode for `Sensor`""" - - #: Low energy mode - LOW = "Low Energy" - #: High energy mode - HIGH = "High Energy" - - -class Sensor(StandardReadable, EpicsDevice): - """A demo sensor that produces a scalar value based on X and Y Movers""" - - value: A[SignalR[float], PvSuffix("Value"), Format.HINTED_SIGNAL] - mode: A[SignalRW[EnergyMode], PvSuffix("Mode"), Format.CONFIG_SIGNAL] - - -class SensorGroup(StandardReadable): - def __init__(self, prefix: str, name: str = "", sensor_count: int = 3) -> None: - with self.add_children_as_readables(): - self.sensors = DeviceVector( - {i: Sensor(f"{prefix}{i}:") for i in range(1, sensor_count + 1)} - ) - - super().__init__(name) diff --git a/src/ophyd_async/epics/demo/_stage.py b/src/ophyd_async/epics/demo/_stage.py new file mode 100644 index 0000000000..3f957dbf0f --- /dev/null +++ b/src/ophyd_async/epics/demo/_stage.py @@ -0,0 +1,15 @@ +from ophyd_async.core import StandardReadable + +from ._motor import DemoMotor + + +class DemoStage(StandardReadable): + """A simulated sample stage with X and Y movables""" + + def __init__(self, prefix: str, name="") -> None: + # Define some child Devices + with self.add_children_as_readables(): + self.x = DemoMotor(prefix + "X:") + self.y = DemoMotor(prefix + "Y:") + # Set name of device and child devices + super().__init__(name=name) diff --git a/src/ophyd_async/epics/demo/mover.db b/src/ophyd_async/epics/demo/motor.db similarity index 96% rename from src/ophyd_async/epics/demo/mover.db rename to src/ophyd_async/epics/demo/motor.db index 4707cbba8b..09c95b16e8 100644 --- a/src/ophyd_async/epics/demo/mover.db +++ b/src/ophyd_async/epics/demo/motor.db @@ -10,7 +10,7 @@ record(ao, "$(P)Velocity") { field(PREC, "$(PREC=3)") field(PINI, "YES") field(EGU, "$(EGU=mm)/s") - field(VAL, "$(VELO=100)") + field(VAL, "$(VELO=1)") } 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/epics/demo/point_detector.db b/src/ophyd_async/epics/demo/point_detector.db new file mode 100644 index 0000000000..d36911614b --- /dev/null +++ b/src/ophyd_async/epics/demo/point_detector.db @@ -0,0 +1,59 @@ +record(ao, "$(P)AcquireTime") { + field(DESC, "Time to acquire for") + field(VAL, "0.1") + field(OUT, "$(P)Start.DLY2") + field(PINI, "YES") +} + +record(seq, "$(P)Start") { + field(DESC, "Start sequence") + # Grab the start time + field(LNK0, "$(P)StartTime.PROC") + # Set it to be acquiring + field(LNK1, "$(P)Acquiring PP") + field(DO1, "1") + # Set it back to idle + field(LNK2, "$(P)Acquiring PP") + field(DO2, "0") + # Set the elapsed time to the full acquire time + field(LNK3, "$(P)Elapsed PP") + field(DOL3, "$(P)AcquireTime") +} + +record(ai, "$(P)StartTime") { + field(DTYP, "Soft Timestamp") +} + +record(bi, "$(P)Acquiring") { + field(DESC, "Currently acquiring") + field(ZNAM, "Idle") + field(ONAM, "Acquiring") + field(PINI, "YES") +} + +record(ai, "$(P)CurrentTime") { + field(DTYP, "Soft Timestamp") +} + +record(calcout, "$(P)Process") { + field(DESC, "Process elapsed time if acquiring") + field(INPA, "$(P)StartTime") + field(INPB, "$(P)CurrentTime PP") + field(SCAN, ".1 second") + field(CALC, "B-A") + field(OUT, "$(P)Elapsed PP") + field(SDIS, "$(P)Acquiring") + field(DISV, "0") +} + +record(ai, "$(P)Elapsed") { + field(DESC, "Elapsed time") + field(EGU, "s") + field(PREC, "1") + field(PINI, "YES") +} + +record(calcout, "$(P)Reset") { + field(OUT, "$(P)Elapsed PP") + field(CALC, "0") +} diff --git a/src/ophyd_async/epics/demo/point_detector_channel.db b/src/ophyd_async/epics/demo/point_detector_channel.db new file mode 100644 index 0000000000..8496d903bf --- /dev/null +++ b/src/ophyd_async/epics/demo/point_detector_channel.db @@ -0,0 +1,21 @@ +record(mbbo, "$(P)$(CHANNEL):Mode") { + field(DESC, "Energy sensitivity of the image") + field(DTYP, "Raw Soft Channel") + field(PINI, "YES") + field(ZRVL, "10") + field(ZRST, "Low Energy") + field(ONVL, "100") + field(ONST, "High Energy") +} + +record(calc, "$(P)$(CHANNEL):Value") { + field(DESC, "Sensor value simulated from X and Y") + field(INPA, "$(X)Readback") + field(INPB, "$(Y)Readback") + field(INPC, "$(CHANNEL)") + field(INPD, "$(P)$(CHANNEL):Mode.RVAL") + field(INPE, "$(P)Elapsed CP") + field(CALC, "FLOOR((SIN(A)**C+COS(A*B+D)+2)*2500*E)") + field(EGU, "cts") + field(PREC, "0") +} diff --git a/src/ophyd_async/epics/demo/sensor.db b/src/ophyd_async/epics/demo/sensor.db deleted file mode 100644 index 95cba4b872..0000000000 --- a/src/ophyd_async/epics/demo/sensor.db +++ /dev/null @@ -1,19 +0,0 @@ -record(mbbo, "$(P)Mode") { - field(DESC, "Energy sensitivity of the image") - field(DTYP, "Raw Soft Channel") - field(PINI, "YES") - field(ZRVL, "10") - field(ZRST, "Low Energy") - field(ONVL, "100") - field(ONST, "High Energy") -} - -record(calc, "$(P)Value") { - field(DESC, "Sensor value simulated from X and Y") - field(INPA, "$(P)X:Readback CP") - field(INPB, "$(P)Y:Readback CP") - field(INPC, "$(P)Mode.RVAL CP") - field(CALC, "SIN(A)**10+COS(C+B*A)*COS(A)") - field(EGU, "$(EGU=cts/s)") - field(PREC, "$(PREC=3)") -} diff --git a/src/ophyd_async/fastcs/__init__.py b/src/ophyd_async/fastcs/__init__.py index e69de29bb2..c0b650fa33 100644 --- a/src/ophyd_async/fastcs/__init__.py +++ b/src/ophyd_async/fastcs/__init__.py @@ -0,0 +1 @@ +"""FastCS support for Signals via EPICS or Tango, and Devices that use them.""" diff --git a/src/ophyd_async/plan_stubs/__init__.py b/src/ophyd_async/plan_stubs/__init__.py index f549dd27f9..a127b64de8 100644 --- a/src/ophyd_async/plan_stubs/__init__.py +++ b/src/ophyd_async/plan_stubs/__init__.py @@ -1,3 +1,5 @@ +"""Plan stubs for connecting, setting up and flying devices.""" + from ._ensure_connected import ensure_connected from ._fly import ( fly_and_collect, diff --git a/src/ophyd_async/sim/__init__.py b/src/ophyd_async/sim/__init__.py index fa19366c11..ddd0d5038a 100644 --- a/src/ophyd_async/sim/__init__.py +++ b/src/ophyd_async/sim/__init__.py @@ -1,19 +1,15 @@ -from ._pattern_detector import ( - DATA_PATH, - SUM_PATH, - PatternDetector, - PatternDetectorController, - PatternDetectorWriter, - PatternGenerator, -) -from ._sim_motor import SimMotor +"""Some simulated devices to be used in tutorials and testing.""" + +from ._blob_detector import SimBlobDetector +from ._motor import SimMotor +from ._pattern_generator import PatternGenerator +from ._point_detector import SimPointDetector +from ._stage import SimStage __all__ = [ - "DATA_PATH", - "SUM_PATH", - "PatternGenerator", - "PatternDetector", - "PatternDetectorController", - "PatternDetectorWriter", "SimMotor", + "SimStage", + "PatternGenerator", + "SimPointDetector", + "SimBlobDetector", ] diff --git a/src/ophyd_async/sim/__main__.py b/src/ophyd_async/sim/__main__.py new file mode 100644 index 0000000000..dc5232b294 --- /dev/null +++ b/src/ophyd_async/sim/__main__.py @@ -0,0 +1,40 @@ +# Import bluesky and ophyd +from tempfile import mkdtemp + +import bluesky.plan_stubs as bps # noqa: F401 +import bluesky.plans as bp # noqa: F401 +from bluesky.callbacks.best_effort import BestEffortCallback +from bluesky.run_engine import RunEngine, autoawait_in_bluesky_event_loop + +from ophyd_async import sim +from ophyd_async.core import StaticPathProvider, UUIDFilenameProvider, init_devices + +# Create a run engine and make ipython use it for `await` commands +RE = RunEngine(call_returns_result=True) +autoawait_in_bluesky_event_loop() + +# Add a callback for plotting +bec = BestEffortCallback() +RE.subscribe(bec) + +# Make a pattern generator that uses the motor positions +# to make a test pattern. This simulates the real life process +# of X-ray scattering off a sample +pattern_generator = sim.PatternGenerator() + +# Make a path provider that makes UUID filenames within a static +# temporary directory +path_provider = StaticPathProvider(UUIDFilenameProvider(), mkdtemp()) + +# All Devices created within this block will be +# connected and named at the end of the with block +with init_devices(): + # Create a sample stage with X and Y motors that report their positions + # to the pattern generator + 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) + # 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) diff --git a/src/ophyd_async/sim/_blob_detector.py b/src/ophyd_async/sim/_blob_detector.py new file mode 100644 index 0000000000..f8574f0158 --- /dev/null +++ b/src/ophyd_async/sim/_blob_detector.py @@ -0,0 +1,31 @@ +from collections.abc import Sequence + +from ophyd_async.core import PathProvider, SignalR, StandardDetector + +from ._blob_detector_controller import BlobDetectorController +from ._blob_detector_writer import BlobDetectorWriter +from ._pattern_generator import PatternGenerator + + +class SimBlobDetector(StandardDetector): + def __init__( + self, + path_provider: PathProvider, + pattern_generator: PatternGenerator | None = None, + config_sigs: Sequence[SignalR] = (), + name: str = "", + ) -> None: + self.pattern_generator = pattern_generator or PatternGenerator() + + super().__init__( + controller=BlobDetectorController( + pattern_generator=self.pattern_generator, + ), + writer=BlobDetectorWriter( + pattern_generator=self.pattern_generator, + path_provider=path_provider, + name_provider=lambda: self.name, + ), + config_sigs=config_sigs, + name=name, + ) diff --git a/src/ophyd_async/sim/_blob_detector_controller.py b/src/ophyd_async/sim/_blob_detector_controller.py new file mode 100644 index 0000000000..a9e34e548e --- /dev/null +++ b/src/ophyd_async/sim/_blob_detector_controller.py @@ -0,0 +1,54 @@ +import asyncio +import time +from contextlib import suppress + +from ophyd_async.core import DetectorController +from ophyd_async.core._detector import TriggerInfo + +from ._pattern_generator import PatternGenerator + + +class BlobDetectorController(DetectorController): + def __init__(self, pattern_generator: PatternGenerator): + self.pattern_generator = pattern_generator + self.trigger_info: TriggerInfo | None = None + self.task: asyncio.Task | None = None + + def get_deadtime(self, exposure): + return 0.001 + + async def prepare(self, trigger_info): + # Just hold onto the trigger info until we need it + self.trigger_info = trigger_info + + async def _write_images( + self, exposure: float, period: float, number_of_frames: int + ): + start = time.monotonic() + for i in range(1, number_of_frames + 1): + deadline = start + i * period + timeout = deadline - time.monotonic() + await asyncio.sleep(timeout) + self.pattern_generator.write_image_to_file(exposure) + + async def arm(self): + if self.trigger_info is None: + raise RuntimeError(f"prepare() not called on {self}") + livetime = self.trigger_info.livetime or 0.1 + coro = self._write_images( + exposure=livetime, + period=livetime + self.trigger_info.deadtime, + number_of_frames=self.trigger_info.total_number_of_triggers, + ) + self.task = asyncio.create_task(coro) + + async def wait_for_idle(self): + if self.task: + await self.task + + async def disarm(self): + if self.task: + self.task.cancel() + with suppress(asyncio.CancelledError): + await self.task + self.task = None diff --git a/src/ophyd_async/sim/_blob_detector_writer.py b/src/ophyd_async/sim/_blob_detector_writer.py new file mode 100644 index 0000000000..cd047032c1 --- /dev/null +++ b/src/ophyd_async/sim/_blob_detector_writer.py @@ -0,0 +1,89 @@ +from collections.abc import AsyncGenerator, AsyncIterator +from pathlib import Path + +from bluesky.protocols import StreamAsset +from event_model import DataKey + +from ophyd_async.core import DEFAULT_TIMEOUT, DetectorWriter, NameProvider, PathProvider +from ophyd_async.core._hdf_dataset import HDFDataset, HDFFile + +from ._pattern_generator import DATA_PATH, SUM_PATH, PatternGenerator + +WIDTH = 320 +HEIGHT = 240 + + +class BlobDetectorWriter(DetectorWriter): + def __init__( + self, + pattern_generator: PatternGenerator, + path_provider: PathProvider, + name_provider: NameProvider, + ) -> None: + self.pattern_generator = pattern_generator + self.path_provider = path_provider + self.name_provider = name_provider + self.path: Path | None = None + self.composer: HDFFile | None = None + self.datasets: list[HDFDataset] = [] + + async def open(self, multiplier: int = 1) -> dict[str, DataKey]: + name = self.name_provider() + path_info = self.path_provider(name) + self.path = path_info.directory_path / f"{path_info.filename}.h5" + self.pattern_generator.open_file(self.path, WIDTH, HEIGHT) + # We know it will write data and sum, so emit those + self.datasets = [ + HDFDataset( + data_key=name, + dataset=DATA_PATH, + shape=(HEIGHT, WIDTH), + multiplier=multiplier, + ), + HDFDataset( + f"{name}-sum", + dataset=SUM_PATH, + shape=(), + multiplier=multiplier, + ), + ] + self.composer = None + outer_shape = (multiplier,) if multiplier > 1 else () + describe = { + ds.data_key: DataKey( + source="sim://pattern-generator-hdf-file", + shape=list(outer_shape) + list(ds.shape), + dtype="array" if ds.shape else "number", + external="STREAM:", + ) + for ds in self.datasets + } + return describe + + async def close(self) -> None: + self.pattern_generator.close_file() + + async def collect_stream_docs( + self, indices_written: int + ) -> AsyncIterator[StreamAsset]: + # When we have written something to the file + if indices_written: + # Only emit stream resource the first time we see frames in + # the file + if not self.composer: + if not self.path: + raise RuntimeError(f"open() not called on {self}") + self.composer = HDFFile(self.path, self.datasets) + for doc in self.composer.stream_resources(): + yield "stream_resource", doc + for doc in self.composer.stream_data(indices_written): + yield "stream_datum", doc + + async def observe_indices_written( + self, timeout=DEFAULT_TIMEOUT + ) -> AsyncGenerator[int, None]: + async for index in self.pattern_generator.observe_indices_written(timeout): + yield index + + async def get_indices_written(self) -> int: + return await self.pattern_generator.get_last_index() diff --git a/src/ophyd_async/sim/_sim_motor.py b/src/ophyd_async/sim/_motor.py similarity index 100% rename from src/ophyd_async/sim/_sim_motor.py rename to src/ophyd_async/sim/_motor.py diff --git a/src/ophyd_async/sim/_pattern_detector/__init__.py b/src/ophyd_async/sim/_pattern_detector/__init__.py deleted file mode 100644 index 8bce03bd3f..0000000000 --- a/src/ophyd_async/sim/_pattern_detector/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -from ._pattern_detector import PatternDetector -from ._pattern_detector_controller import PatternDetectorController -from ._pattern_detector_writer import PatternDetectorWriter -from ._pattern_generator import DATA_PATH, SUM_PATH, PatternGenerator - -__all__ = [ - "PatternDetector", - "PatternDetectorController", - "PatternDetectorWriter", - "DATA_PATH", - "SUM_PATH", - "PatternGenerator", -] diff --git a/src/ophyd_async/sim/_pattern_detector/_pattern_detector.py b/src/ophyd_async/sim/_pattern_detector/_pattern_detector.py deleted file mode 100644 index baa97cbd58..0000000000 --- a/src/ophyd_async/sim/_pattern_detector/_pattern_detector.py +++ /dev/null @@ -1,42 +0,0 @@ -from collections.abc import Sequence -from pathlib import Path - -from ophyd_async.core import ( - FilenameProvider, - PathProvider, - SignalR, - StandardDetector, - StaticFilenameProvider, - StaticPathProvider, -) - -from ._pattern_detector_controller import PatternDetectorController -from ._pattern_detector_writer import PatternDetectorWriter -from ._pattern_generator import PatternGenerator - - -class PatternDetector(StandardDetector): - def __init__( - self, - path: Path, - config_sigs: Sequence[SignalR] = (), - name: str = "", - ) -> None: - fp: FilenameProvider = StaticFilenameProvider(name) - self.path_provider: PathProvider = StaticPathProvider(fp, path) - self.pattern_generator = PatternGenerator() - writer = PatternDetectorWriter( - pattern_generator=self.pattern_generator, - path_provider=self.path_provider, - name_provider=lambda: self.name, - ) - controller = PatternDetectorController( - pattern_generator=self.pattern_generator, - path_provider=self.path_provider, - ) - super().__init__( - controller=controller, - writer=writer, - config_sigs=config_sigs, - name=name, - ) diff --git a/src/ophyd_async/sim/_pattern_detector/_pattern_detector_controller.py b/src/ophyd_async/sim/_pattern_detector/_pattern_detector_controller.py deleted file mode 100644 index 05b56dfe96..0000000000 --- a/src/ophyd_async/sim/_pattern_detector/_pattern_detector_controller.py +++ /dev/null @@ -1,62 +0,0 @@ -import asyncio - -from ophyd_async.core import DetectorController, PathProvider, TriggerInfo - -from ._pattern_generator import PatternGenerator - - -class PatternDetectorController(DetectorController): - def __init__( - self, - pattern_generator: PatternGenerator, - path_provider: PathProvider, - exposure: float = 0.1, - ) -> None: - self.pattern_generator: PatternGenerator = pattern_generator - self.pattern_generator.set_exposure(exposure) - self.path_provider: PathProvider = path_provider - self.task: asyncio.Task | None = None - super().__init__() - - async def prepare(self, trigger_info: TriggerInfo): - self._trigger_info = trigger_info - if self._trigger_info.livetime is None: - self._trigger_info.livetime = 0.01 - self.period: float = self._trigger_info.livetime + self.get_deadtime( - trigger_info.livetime - ) - - async def arm(self): - assert self._trigger_info.livetime - assert self.period - self.task = asyncio.create_task( - self._coroutine_for_image_writing( - self._trigger_info.livetime, - self.period, - self._trigger_info.total_number_of_triggers, - ) - ) - - async def wait_for_idle(self): - if self.task: - await self.task - - async def disarm(self): - if self.task and not self.task.done(): - self.task.cancel() - try: - await self.task - except asyncio.CancelledError: - pass - self.task = None - - def get_deadtime(self, exposure: float | None) -> float: - return 0.001 - - async def _coroutine_for_image_writing( - self, exposure: float, period: float, frames_number: int - ): - for _ in range(frames_number): - self.pattern_generator.set_exposure(exposure) - await asyncio.sleep(period) - await self.pattern_generator.write_image_to_file() diff --git a/src/ophyd_async/sim/_pattern_detector/_pattern_detector_writer.py b/src/ophyd_async/sim/_pattern_detector/_pattern_detector_writer.py deleted file mode 100644 index 16dda6f69f..0000000000 --- a/src/ophyd_async/sim/_pattern_detector/_pattern_detector_writer.py +++ /dev/null @@ -1,41 +0,0 @@ -from collections.abc import AsyncGenerator, AsyncIterator - -from event_model import DataKey - -from ophyd_async.core import DEFAULT_TIMEOUT, DetectorWriter, NameProvider, PathProvider - -from ._pattern_generator import PatternGenerator - - -class PatternDetectorWriter(DetectorWriter): - pattern_generator: PatternGenerator - - def __init__( - self, - pattern_generator: PatternGenerator, - path_provider: PathProvider, - name_provider: NameProvider, - ) -> None: - self.pattern_generator = pattern_generator - self.path_provider = path_provider - self.name_provider = name_provider - - async def open(self, multiplier: int = 1) -> dict[str, DataKey]: - return await self.pattern_generator.open_file( - self.path_provider, self.name_provider(), multiplier - ) - - async def close(self) -> None: - self.pattern_generator.close() - - def collect_stream_docs(self, indices_written: int) -> AsyncIterator: - return self.pattern_generator.collect_stream_docs(indices_written) - - async def observe_indices_written( - self, timeout=DEFAULT_TIMEOUT - ) -> AsyncGenerator[int, None]: - async for index in self.pattern_generator.observe_indices_written(timeout): - yield index - - async def get_indices_written(self) -> int: - return self.pattern_generator.image_counter diff --git a/src/ophyd_async/sim/_pattern_detector/_pattern_generator.py b/src/ophyd_async/sim/_pattern_detector/_pattern_generator.py deleted file mode 100644 index 01c54c8245..0000000000 --- a/src/ophyd_async/sim/_pattern_detector/_pattern_generator.py +++ /dev/null @@ -1,207 +0,0 @@ -from collections.abc import AsyncGenerator, AsyncIterator -from pathlib import Path - -import h5py -import numpy as np -from bluesky.protocols import StreamAsset -from event_model import DataKey - -from ophyd_async.core import ( - DEFAULT_TIMEOUT, - HDFDataset, - HDFFile, - PathProvider, - observe_value, - soft_signal_r_and_setter, -) - -# raw data path -DATA_PATH = "/entry/data/data" - -# pixel sum path -SUM_PATH = "/entry/sum" - -MAX_UINT8_VALUE = np.iinfo(np.uint8).max - - -def generate_gaussian_blob(height: int, width: int) -> np.ndarray: - """Make a Gaussian Blob with float values in range 0..1""" - x, y = np.meshgrid(np.linspace(-1, 1, width), np.linspace(-1, 1, height)) - d = np.sqrt(x * x + y * y) - blob = np.exp(-(d**2)) - return blob - - -def generate_interesting_pattern(x: float, y: float) -> float: - """This function is interesting in x and y in range -10..10, returning - a float value in range 0..1 - """ - z = 0.5 + (np.sin(x) ** 10 + np.cos(10 + y * x) * np.cos(x)) / 2 - return z - - -class PatternGenerator: - def __init__( - self, - saturation_exposure_time: float = 0.1, - detector_width: int = 320, - detector_height: int = 240, - ) -> None: - self.saturation_exposure_time = saturation_exposure_time - self.exposure = saturation_exposure_time - self.x = 0.0 - self.y = 0.0 - self.height = detector_height - self.width = detector_width - self.image_counter: int = 0 - - # it automatically initializes to 0 - self.counter_signal, self._set_counter_signal = soft_signal_r_and_setter(int) - self._full_intensity_blob = ( - generate_gaussian_blob(width=detector_width, height=detector_height) - * MAX_UINT8_VALUE - ) - self._hdf_stream_provider: HDFFile | None = None - self._handle_for_h5_file: h5py.File | None = None - self.target_path: Path | None = None - - def write_data_to_dataset(self, path: str, data_shape: tuple[int, ...], data): - """Write data to named dataset, resizing to fit and flushing after.""" - assert self._handle_for_h5_file, "no file has been opened!" - dset = self._handle_for_h5_file[path] - assert isinstance( - dset, h5py.Dataset - ), f"Expected {path} to be dataset, got {dset}" - dset.resize((self.image_counter + 1,) + data_shape) - dset[self.image_counter] = data - dset.flush() - - async def write_image_to_file(self) -> None: - # generate the simulated data - intensity: float = generate_interesting_pattern(self.x, self.y) - detector_data = ( - self._full_intensity_blob - * intensity - * self.exposure - / self.saturation_exposure_time - ).astype(np.uint8) - - # Write the data and sum - self.write_data_to_dataset(DATA_PATH, (self.height, self.width), detector_data) - self.write_data_to_dataset(SUM_PATH, (), np.sum(detector_data)) - - # counter increment is last - # as only at this point the new data is visible from the outside - self.image_counter += 1 - self._set_counter_signal(self.image_counter) - - def set_exposure(self, value: float) -> None: - self.exposure = value - - def set_x(self, value: float) -> None: - self.x = value - - def set_y(self, value: float) -> None: - self.y = value - - async def open_file( - self, path_provider: PathProvider, name: str, multiplier: int = 1 - ) -> dict[str, DataKey]: - await self.counter_signal.connect() - - self.target_path = self._get_new_path(path_provider) - self._path_provider = path_provider - - self._handle_for_h5_file = h5py.File(self.target_path, "w", libver="latest") - - assert self._handle_for_h5_file, "not loaded the file right" - - self._handle_for_h5_file.create_dataset( - name=DATA_PATH, - shape=(0, self.height, self.width), - dtype=np.uint8, - maxshape=(None, self.height, self.width), - ) - self._handle_for_h5_file.create_dataset( - name=SUM_PATH, - shape=(0,), - dtype=np.float64, - maxshape=(None,), - ) - - # once datasets written, can switch the model to single writer multiple reader - self._handle_for_h5_file.swmr_mode = True - self.multiplier = multiplier - - outer_shape = (multiplier,) if multiplier > 1 else () - - # cache state to self - # Add the main data - self._datasets = [ - HDFDataset( - data_key=name, - dataset=DATA_PATH, - shape=(self.height, self.width), - multiplier=multiplier, - ), - HDFDataset( - f"{name}-sum", - dataset=SUM_PATH, - shape=(), - multiplier=multiplier, - ), - ] - - describe = { - ds.data_key: DataKey( - source="sim://pattern-generator-hdf-file", - shape=list(outer_shape) + list(ds.shape), - dtype="array" if ds.shape else "number", - external="STREAM:", - ) - for ds in self._datasets - } - return describe - - def _get_new_path(self, path_provider: PathProvider) -> Path: - info = path_provider(device_name="pattern") - new_path: Path = info.directory_path / info.filename - return new_path - - async def collect_stream_docs( - self, indices_written: int - ) -> AsyncIterator[StreamAsset]: - """ - stream resource says "here is a dataset", - stream datum says "here are N frames in that stream resource", - you get one stream resource and many stream datums per scan - """ - if self._handle_for_h5_file: - self._handle_for_h5_file.flush() - # when already something was written to the file - if indices_written: - # if no frames arrived yet, there's no file to speak of - # cannot get the full filename the HDF writer will write - # until the first frame comes in - if not self._hdf_stream_provider: - assert self.target_path, "open file has not been called" - self._hdf_stream_provider = HDFFile( - self.target_path, - self._datasets, - ) - for doc in self._hdf_stream_provider.stream_resources(): - yield "stream_resource", doc - if self._hdf_stream_provider: - for doc in self._hdf_stream_provider.stream_data(indices_written): - yield "stream_datum", doc - - def close(self) -> None: - if self._handle_for_h5_file: - self._handle_for_h5_file.close() - self._handle_for_h5_file = None - - async def observe_indices_written( - self, timeout=DEFAULT_TIMEOUT - ) -> AsyncGenerator[int, None]: - async for num_captured in observe_value(self.counter_signal, timeout=timeout): - yield num_captured // self.multiplier diff --git a/src/ophyd_async/sim/_pattern_generator.py b/src/ophyd_async/sim/_pattern_generator.py new file mode 100644 index 0000000000..ca5abb9e27 --- /dev/null +++ b/src/ophyd_async/sim/_pattern_generator.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import asyncio +from collections.abc import AsyncGenerator +from pathlib import Path + +import h5py +import numpy as np + +# raw data path +DATA_PATH = "/entry/data/data" + +# pixel sum path +SUM_PATH = "/entry/sum" + + +def generate_gaussian_blob(height: int, width: int) -> np.ndarray: + """Make a Gaussian Blob with float values in range 0..1""" + x, y = np.meshgrid(np.linspace(-1, 1, width), np.linspace(-1, 1, height)) + d = np.sqrt(x * x + y * y) + blob = np.exp(-(d**2)) + return blob + + +def generate_interesting_pattern( + x: float, y: float, channel: int, offset: float +) -> float: + """This function is interesting in x and y in range -10..10, returning + a float value in range 0..1 + """ + return (np.sin(x) ** channel + np.cos(x * y + offset) + 2) / 4 + + +class PatternFile: + def __init__( + self, + path: Path, + width: int = 320, + height: int = 240, + ): + self.file = h5py.File(path, "w", libver="latest") + self.data = self.file.create_dataset( + name=DATA_PATH, + shape=(0, height, width), + dtype=np.uint8, + maxshape=(None, height, width), + ) + self.sum = self.file.create_dataset( + name=SUM_PATH, + shape=(0,), + dtype=np.int64, + maxshape=(None,), + ) + # Once datasets written, can switch the model to single writer multiple reader + self.file.swmr_mode = True + self.blob = generate_gaussian_blob(height, width) * np.iinfo(np.uint8).max + self.image_counter = 0 + self.q = asyncio.Queue() + + def write_image_to_file(self, intensity: float): + data = np.floor(self.blob * intensity) + for dset, value in ((self.data, data), (self.sum, np.sum(data))): + dset.resize(self.image_counter + 1, axis=0) + dset[self.image_counter] = value + dset.flush() + self.q.put_nowait(self.image_counter) + self.image_counter += 1 + + def close(self): + self.file.close() + + +class PatternGenerator: + def __init__(self): + self._x = 0.0 + self._y = 0.0 + self._file: PatternFile | None = None + + def set_x(self, x: float): + self._x = x + + def set_y(self, y: float): + self._y = y + + def generate_point(self, channel: int = 1, high_energy: bool = False) -> float: + """Make a point between 0 and 1 based on x and y""" + offset = 100 if high_energy else 10 + return generate_interesting_pattern(self._x, self._y, channel, offset) + + def open_file(self, path: Path, width: int, height: int): + self._file = PatternFile(path, width, height) + + def _get_file(self) -> PatternFile: + if not self._file: + raise RuntimeError("open_file not run") + return self._file + + def write_image_to_file(self, exposure: float): + self._get_file().write_image_to_file(self.generate_point() * exposure) + + async def observe_indices_written(self, timeout: float) -> AsyncGenerator[int]: + file = self._get_file() + if file.image_counter: + yield file.image_counter + while True: + yield await asyncio.wait_for(file.q.get(), timeout) + + async def get_last_index(self) -> int: + return self._get_file().image_counter + + def close_file(self): + if self._file: + self._file.close() + self._file = None diff --git a/src/ophyd_async/sim/_point_detector.py b/src/ophyd_async/sim/_point_detector.py new file mode 100644 index 0000000000..68c5bca3c8 --- /dev/null +++ b/src/ophyd_async/sim/_point_detector.py @@ -0,0 +1,83 @@ +import asyncio +import time + +import numpy as np + +from ophyd_async.core import ( + AsyncStatus, + DeviceVector, + SignalR, + StandardReadable, + StrictEnum, + gather_dict, + soft_signal_r_and_setter, + soft_signal_rw, +) +from ophyd_async.core import StandardReadableFormat as Format + +from ._pattern_generator import PatternGenerator + + +class EnergyMode(StrictEnum): + """Energy mode for `SimPointDetector`""" + + #: Low energy mode + LOW = "Low Energy" + #: High energy mode + HIGH = "High Energy" + + +class SimPointDetectorChannel(StandardReadable): + def __init__(self, value_signal: SignalR[int], name=""): + with self.add_children_as_readables(Format.HINTED_SIGNAL): + self.value = value_signal + with self.add_children_as_readables(Format.CONFIG_SIGNAL): + self.mode = soft_signal_rw(EnergyMode) + super().__init__(name) + + +class SimPointDetector(StandardReadable): + def __init__( + self, generator: PatternGenerator, num_channels: int = 3, name: str = "" + ) -> None: + self._generator = generator + self.acquire_time = soft_signal_rw(float, 0.1) + self.acquiring, self._set_acquiring = soft_signal_r_and_setter(bool) + self._value_signals = dict( + soft_signal_r_and_setter(int) for _ in range(num_channels) + ) + with self.add_children_as_readables(): + self.channel = DeviceVector( + { + i + 1: SimPointDetectorChannel(value_signal) + for i, value_signal in enumerate(self._value_signals) + } + ) + super().__init__(name=name) + + async def _update_values(self, acquire_time: float): + # Get the modes + modes = await gather_dict( + {channel: channel.mode.get_value() for channel in self.channel.values()} + ) + start = time.monotonic() + # Make an array of relative update times at 10Hz intervals + update_times = np.arange(0.1, acquire_time, 0.1) + # With the end position appended + update_times = np.concatenate((update_times, [acquire_time])) + for update_time in update_times: + # Calculate how long to wait to get there + relative_time = time.monotonic() - start + await asyncio.sleep(update_time - relative_time) + # Update the channel value + for i, channel in self.channel.items(): + high_energy = modes[channel] == EnergyMode.HIGH + point = self._generator.generate_point(i, high_energy) + setter = self._value_signals[channel.value] + setter(int(point * 10000 * update_time)) + + @AsyncStatus.wrap + async def trigger(self): + for setter in self._value_signals.values(): + setter(0) + await self._update_values(await self.acquire_time.get_value()) diff --git a/src/ophyd_async/sim/_stage.py b/src/ophyd_async/sim/_stage.py new file mode 100644 index 0000000000..e1fde13a2d --- /dev/null +++ b/src/ophyd_async/sim/_stage.py @@ -0,0 +1,19 @@ +from ophyd_async.core import StandardReadable +from ophyd_async.sim._pattern_generator import PatternGenerator + +from ._motor import SimMotor + + +class SimStage(StandardReadable): + """A simulated sample stage with X and Y movables""" + + def __init__(self, pattern_generator: PatternGenerator, name="") -> None: + # Define some child Devices + with self.add_children_as_readables(): + self.x = SimMotor(instant=False) + self.y = SimMotor(instant=False) + # Tell the pattern generator about the motor positions + self.x.user_readback.subscribe_value(pattern_generator.set_x) + self.y.user_readback.subscribe_value(pattern_generator.set_y) + # Set name of device and child devices + super().__init__(name=name) 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 2e65cba983..93ed614996 100644 --- a/src/ophyd_async/testing/__init__.py +++ b/src/ophyd_async/testing/__init__.py @@ -1,3 +1,5 @@ +"""Utilities for testing devices.""" + from . import __pytest_assert_rewrite # noqa: F401 from ._assert import ( ApproxTable, @@ -26,20 +28,25 @@ ) from ._wait_for_pending import wait_for_pending_wakeups +# The order of this list determines the order of the documentation, +# so does not match the alphabetical order of the impors __all__ = [ + # Assert functions + "assert_value", + "assert_reading", "assert_configuration", "assert_describe_signal", "assert_emitted", - "assert_reading", - "assert_value", - "callback_on_mock_put", + # Mocking utilities "get_mock", + "set_mock_value", + "set_mock_values", "get_mock_put", + "callback_on_mock_put", "mock_puts_blocked", "reset_mock_put_calls", "set_mock_put_proceeds", - "set_mock_value", - "set_mock_values", + # Wait for pending wakeups "wait_for_pending_wakeups", "ExampleEnum", "ExampleTable", diff --git a/src/ophyd_async/testing/_assert.py b/src/ophyd_async/testing/_assert.py index 48d8a35538..121b5fbade 100644 --- a/src/ophyd_async/testing/_assert.py +++ b/src/ophyd_async/testing/_assert.py @@ -29,20 +29,14 @@ def _generate_assert_error_msg(name: str, expected_result, actual_result) -> str async def assert_value(signal: SignalR[SignalDatatypeT], value: Any) -> None: - """Assert a signal's value and compare it an expected signal. + """Assert that a Signal has the given value. Parameters ---------- signal: - signal with get_value. + Signal with get_value. value: The expected value from the signal. - - Notes - ----- - Example usage:: - await assert_value(signal, value) - """ actual_value = await signal.get_value() assert actual_value == value, _generate_assert_error_msg( @@ -53,38 +47,33 @@ async def assert_value(signal: SignalR[SignalDatatypeT], value: Any) -> None: def _approx_readable_value(reading: Mapping[str, Reading]) -> Mapping[str, Reading]: - """Change Reading value to pytest.approx(value)""" for i in reading: + # np_array1 == np_array2 gives an array of booleans rather than a single bool + # Use pytest.approx(np_array1) so that we get a bool instead reading[i]["value"] = pytest.approx(reading[i]["value"]) return reading async def assert_reading( - readable: AsyncReadable, expected_reading: Mapping[str, Reading] + readable: AsyncReadable, + reading: Mapping[str, Reading], ) -> None: - """Assert readings from readable. + """Assert that a readable Device has the given reading. Parameters ---------- readable: - Callable with readable.read function that generate readings. - + Device with an async ``read()`` method to get the reading from. reading: - The expected readings from the readable. - - Notes - ----- - Example usage:: - await assert_reading(readable, reading) - + The expected reading from the readable. """ actual_reading = await readable.read() - assert ( - _approx_readable_value(expected_reading) == actual_reading - ), _generate_assert_error_msg( - name=readable.name, - expected_result=expected_reading, - actual_result=actual_reading, + assert _approx_readable_value(reading) == actual_reading, ( + _generate_assert_error_msg( + name=readable.name, + expected_result=reading, + actual_result=actual_reading, + ) ) @@ -92,29 +81,23 @@ async def assert_configuration( configurable: AsyncConfigurable, configuration: Mapping[str, Reading], ) -> None: - """Assert readings from Configurable. + """Assert that a configurable Device has the given configuration. Parameters ---------- configurable: - Configurable with Configurable.read function that generate readings. - + Device with an async ``read_configuration()`` method to get the configuration + from. configuration: - The expected readings from configurable. - - Notes - ----- - Example usage:: - await assert_configuration(configurable configuration) - + The expected configuration from the configurable. """ actual_configurable = await configurable.read_configuration() - assert ( - _approx_readable_value(configuration) == actual_configurable - ), _generate_assert_error_msg( - name=configurable.name, - expected_result=configuration, - actual_result=actual_configurable, + assert _approx_readable_value(configuration) == actual_configurable, ( + _generate_assert_error_msg( + name=configurable.name, + expected_result=configuration, + actual_result=actual_configurable, + ) ) @@ -131,15 +114,15 @@ def assert_emitted(docs: Mapping[str, list[dict]], **numbers: int): Parameters ---------- - Doc: - A dictionary - + docs: + A mapping of document type -> list of documents that have been emitted. numbers: - expected emission in kwarg from + The number of each document type expected. + + Examples + -------- + .. code:: - Notes - ----- - Example usage:: docs = defaultdict(list) RE.subscribe(lambda name, doc: docs[name].append(doc)) RE(my_plan()) diff --git a/src/ophyd_async/testing/_mock_signal_utils.py b/src/ophyd_async/testing/_mock_signal_utils.py index 683666e6b4..742488551d 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() @@ -22,9 +26,9 @@ def get_mock(device: Device | Signal) -> Mock: def _get_mock_signal_backend(signal: Signal) -> MockSignalBackend: connector = signal._connector # noqa: SLF001 assert isinstance(connector, SignalConnector), f"Expected Signal, got {signal}" - assert isinstance( - connector.backend, MockSignalBackend - ), f"Signal {signal} not connected in mock mode" + assert isinstance(connector.backend, MockSignalBackend), ( + f"Signal {signal} not connected in mock mode" + ) return connector.backend @@ -34,36 +38,7 @@ def set_mock_value(signal: Signal[SignalDatatypeT], value: SignalDatatypeT): backend.set_value(value) -def set_mock_put_proceeds(signal: Signal, proceeds: bool): - """Allow or block a put with wait=True from proceeding""" - backend = _get_mock_signal_backend(signal) - - if proceeds: - backend.put_proceeds.set() - else: - backend.put_proceeds.clear() - - -@contextmanager -def mock_puts_blocked(*signals: Signal): - for signal in signals: - set_mock_put_proceeds(signal, False) - yield - for signal in signals: - set_mock_put_proceeds(signal, True) - - -def get_mock_put(signal: Signal) -> AsyncMock: - """Get the mock associated with the put call on the signal.""" - return _get_mock_signal_backend(signal).put_mock - - -def reset_mock_put_calls(signal: Signal): - backend = _get_mock_signal_backend(signal) - backend.put_mock.reset_mock() - - -class _SetValuesIterator: +class _SetValuesIterator(Iterator[SignalDatatypeT]): # Garbage collected by the time __del__ is called unless we put it as a # global attrbute here. require_all_consumed: bool = False @@ -78,13 +53,9 @@ def __init__( self.values = values self.require_all_consumed = require_all_consumed self.index = 0 - self.iterator = enumerate(values, start=1) - def __iter__(self): - return self - - def __next__(self): + def __next__(self) -> SignalDatatypeT: # Will propogate StopIteration self.index, next_value = next(self.iterator) set_mock_value(self.signal, next_value) @@ -113,33 +84,32 @@ def set_mock_values( signal: SignalR[SignalDatatypeT], values: Iterable[SignalDatatypeT], require_all_consumed: bool = False, -) -> _SetValuesIterator: +) -> Iterator[SignalDatatypeT]: """Iterator to set a signal to a sequence of values, optionally repeating the sequence. Parameters ---------- signal: - A signal with a `MockSignalBackend` backend. + A signal connected in mock mode. values: An iterable of the values to set the signal to, on each iteration - the value will be set. + the next value will be set. require_all_consumed: If True, an AssertionError will be raised if the iterator is deleted before all values have been consumed. - Notes - ----- - Example usage:: - - for value_set in set_mock_values(signal, [1, 2, 3]): - # do something + Examples + -------- + .. code:: - cm = set_mock_values(signal, 1, 2, 3, require_all_consumed=True): - next(cm) + for value_set in set_mock_values(signal, range(3)): # do something - """ + cm = set_mock_values(signal, [1, 3, 8], require_all_consumed=True): + next(cm) + # do something + """ return _SetValuesIterator( signal, values, @@ -173,3 +143,32 @@ def callback_on_mock_put( backend = _get_mock_signal_backend(signal) backend.put_mock.side_effect = callback return _unset_side_effect_cm(backend.put_mock) + + +def set_mock_put_proceeds(signal: Signal, proceeds: bool): + """Allow or block a put with wait=True from proceeding""" + backend = _get_mock_signal_backend(signal) + + if proceeds: + backend.put_proceeds.set() + else: + backend.put_proceeds.clear() + + +@contextmanager +def mock_puts_blocked(*signals: Signal): + for signal in signals: + set_mock_put_proceeds(signal, False) + yield + for signal in signals: + set_mock_put_proceeds(signal, True) + + +def get_mock_put(signal: Signal) -> AsyncMock: + """Get the mock associated with the put call on the signal.""" + return _get_mock_signal_backend(signal).put_mock + + +def reset_mock_put_calls(signal: Signal): + backend = _get_mock_signal_backend(signal) + backend.put_mock.reset_mock() diff --git a/src/ophyd_async/testing/conftest.py b/src/ophyd_async/testing/conftest.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/conftest.py b/tests/conftest.py index 69d4cfdf4f..e707494d15 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -241,11 +241,8 @@ def static_path_provider( @pytest.fixture def one_shot_trigger_info() -> TriggerInfo: return TriggerInfo( - frame_timeout=None, number_of_triggers=1, trigger=DetectorTrigger.INTERNAL, - deadtime=None, - livetime=None, ) diff --git a/tests/epics/adaravis/test_aravis.py b/tests/epics/adaravis/test_aravis.py index 6382306d23..d56a691670 100644 --- a/tests/epics/adaravis/test_aravis.py +++ b/tests/epics/adaravis/test_aravis.py @@ -33,9 +33,6 @@ async def trigger_and_complete(): TriggerInfo( number_of_triggers=1, trigger=DetectorTrigger.EDGE_TRIGGER, - livetime=None, - deadtime=None, - frame_timeout=None, ) ) # Prevent timeouts diff --git a/tests/epics/demo/test_epics_sim.py b/tests/epics/demo/test_epics_sim.py index 5911fe524c..e441f4e250 100644 --- a/tests/epics/demo/test_epics_sim.py +++ b/tests/epics/demo/test_epics_sim.py @@ -1,7 +1,6 @@ import asyncio -import subprocess from collections import defaultdict -from unittest.mock import ANY, Mock, call, patch +from unittest.mock import ANY, Mock, call import pytest from bluesky import plans as bp @@ -27,45 +26,35 @@ @pytest.fixture -async def mock_mover() -> demo.Mover: +async def mock_motor() -> demo.DemoMotor: async with init_devices(mock=True): - mock_mover = demo.Mover("BLxxI-MO-TABLE-01:X:") + mock_motor = demo.DemoMotor("BLxxI-MO-TABLE-01:X:") # Signals connected here - assert mock_mover.name == "mock_mover" - set_mock_value(mock_mover.units, "mm") - set_mock_value(mock_mover.precision, 3) - set_mock_value(mock_mover.velocity, 1) - return mock_mover + 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 @pytest.fixture -async def mock_sensor() -> demo.Sensor: +async def mock_point_detector() -> demo.DemoPointDetector: async with init_devices(mock=True): - mock_sensor = demo.Sensor("MOCK:SENSOR:") + mock_point_detector = demo.DemoPointDetector("MOCK:DET:") # Signals connected here - assert mock_sensor.name == "mock_sensor" - return mock_sensor + assert mock_point_detector.name == "mock_point_detector" + return mock_point_detector -@pytest.fixture -async def mock_sensor_group() -> demo.SensorGroup: - async with init_devices(mock=True): - mock_sensor_group = demo.SensorGroup("MOCK:SENSOR:") - # Signals connected here - - assert mock_sensor_group.name == "mock_sensor_group" - return mock_sensor_group - - -async def test_mover_stopped(mock_mover: demo.Mover): +async def test_motor_stopped(mock_motor: demo.DemoMotor): callbacks = [] callback_on_mock_put( - mock_mover.stop_, lambda v, *args, **kwargs: callbacks.append(v) + mock_motor.stop_, lambda v, *args, **kwargs: callbacks.append(v) ) - await mock_mover.stop() + await mock_motor.stop() assert callbacks == [None] @@ -109,14 +98,14 @@ async def wait_for_call(self, *args, **kwargs): self._event.clear() -async def test_mover_moving_well(mock_mover: demo.Mover) -> None: - s = mock_mover.set(0.55) +async def test_motor_moving_well(mock_motor: demo.DemoMotor) -> None: + s = mock_motor.set(0.55) watcher = DemoWatcher() s.watch(watcher) done = Mock() s.add_callback(done) await watcher.wait_for_call( - name="mock_mover", + name="mock_motor", current=0.0, initial=0.0, target=0.55, @@ -125,13 +114,13 @@ async def test_mover_moving_well(mock_mover: demo.Mover) -> None: time_elapsed=pytest.approx(0.0, abs=0.05), ) - await assert_value(mock_mover.setpoint, 0.55) + await assert_value(mock_motor.setpoint, 0.55) assert not s.done done.assert_not_called() await asyncio.sleep(0.1) - set_mock_value(mock_mover.readback, 0.1) + set_mock_value(mock_motor.readback, 0.1) await watcher.wait_for_call( - name="mock_mover", + name="mock_motor", current=0.1, initial=0.0, target=0.55, @@ -139,7 +128,7 @@ async def test_mover_moving_well(mock_mover: demo.Mover) -> None: precision=3, time_elapsed=pytest.approx(0.1, abs=0.05), ) - set_mock_value(mock_mover.readback, 0.5499999) + set_mock_value(mock_motor.readback, 0.5499999) await wait_for_pending_wakeups() assert s.done assert s.success @@ -149,48 +138,20 @@ async def test_mover_moving_well(mock_mover: demo.Mover) -> None: done2.assert_called_once_with(s) -async def test_sensor_reading_shows_value(mock_sensor: demo.Sensor): - # Check default value - await assert_value(mock_sensor.value, pytest.approx(0.0)) - assert (await mock_sensor.value.get_value()) == pytest.approx(0.0) - await assert_reading( - mock_sensor, - { - "mock_sensor-value": { - "value": 0.0, - "alarm_severity": 0, - "timestamp": ANY, - } - }, - ) - # Check different value - set_mock_value(mock_sensor.value, 5.0) - await assert_reading( - mock_sensor, - { - "mock_sensor-value": { - "value": 5.0, - "timestamp": ANY, - "alarm_severity": 0, - } - }, - ) - - -async def test_retrieve_mock_and_assert(mock_mover: demo.Mover): - mover_setpoint_mock = get_mock_put(mock_mover.setpoint) - await mock_mover.setpoint.set(10) - mover_setpoint_mock.assert_called_once_with(10, wait=ANY) +async def test_retrieve_mock_and_assert(mock_motor: demo.DemoMotor): + motor_setpoint_mock = get_mock_put(mock_motor.setpoint) + await mock_motor.setpoint.set(10) + motor_setpoint_mock.assert_called_once_with(10, wait=ANY) # Assert that velocity is set before move - mover_velocity_mock = get_mock_put(mock_mover.velocity) + motor_velocity_mock = get_mock_put(mock_motor.velocity) parent_mock = Mock() - parent_mock.attach_mock(mover_setpoint_mock, "setpoint") - parent_mock.attach_mock(mover_velocity_mock, "velocity") + parent_mock.attach_mock(motor_setpoint_mock, "setpoint") + parent_mock.attach_mock(motor_velocity_mock, "velocity") - await mock_mover.velocity.set(100) - await mock_mover.setpoint.set(67) + await mock_motor.velocity.set(100) + await mock_motor.setpoint.set(67) assert parent_mock.mock_calls == [ call.velocity(100, wait=True), call.setpoint(67, wait=True), @@ -199,110 +160,98 @@ async def test_retrieve_mock_and_assert(mock_mover: demo.Mover): async def test_mocks_in_device_share_parent(): lm = LazyMock() - mock_mover = demo.Mover("BLxxI-MO-TABLE-01:Y:") - await mock_mover.connect(mock=lm) + mock_motor = demo.DemoMotor("BLxxI-MO-TABLE-01:Y:") + await mock_motor.connect(mock=lm) mock = lm() - assert get_mock(mock_mover) is mock - assert get_mock(mock_mover.setpoint) is mock.setpoint - assert get_mock_put(mock_mover.setpoint) is mock.setpoint.put - await mock_mover.setpoint.set(10) - get_mock_put(mock_mover.setpoint).assert_called_once_with(10, wait=ANY) + assert get_mock(mock_motor) is mock + assert get_mock(mock_motor.setpoint) is mock.setpoint + assert get_mock_put(mock_motor.setpoint) is mock.setpoint.put + await mock_motor.setpoint.set(10) + get_mock_put(mock_motor.setpoint).assert_called_once_with(10, wait=ANY) - await mock_mover.velocity.set(100) - await mock_mover.setpoint.set(67) + await mock_motor.velocity.set(100) + await mock_motor.setpoint.set(67) mock.reset_mock() - await mock_mover.velocity.set(100) - await mock_mover.setpoint.set(67) + await mock_motor.velocity.set(100) + await mock_motor.setpoint.set(67) assert mock.mock_calls == [ call.velocity.put(100, wait=True), call.setpoint.put(67, wait=True), ] -async def test_read_mover(mock_mover: demo.Mover): - await mock_mover.stage() - assert (await mock_mover.read())["mock_mover"]["value"] == 0.0 - assert (await mock_mover.read_configuration())["mock_mover-velocity"]["value"] == 1 - assert (await mock_mover.describe_configuration())["mock_mover-units"][ +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" ] == [] - set_mock_value(mock_mover.readback, 0.5) - assert (await mock_mover.read())["mock_mover"]["value"] == 0.5 - await mock_mover.unstage() + set_mock_value(mock_motor.readback, 0.5) + assert (await mock_motor.read())["mock_motor"]["value"] == 0.5 + await mock_motor.unstage() # Check we can still read and describe when not staged - set_mock_value(mock_mover.readback, 0.1) - assert (await mock_mover.read())["mock_mover"]["value"] == 0.1 - assert await mock_mover.describe() + set_mock_value(mock_motor.readback, 0.1) + assert (await mock_motor.read())["mock_motor"]["value"] == 0.1 + assert await mock_motor.describe() -async def test_set_velocity(mock_mover: demo.Mover) -> None: - v = mock_mover.velocity +async def test_set_velocity(mock_motor: demo.DemoMotor) -> None: + v = mock_motor.velocity q: asyncio.Queue[dict[str, Reading]] = asyncio.Queue() v.subscribe(q.put_nowait) - assert (await q.get())["mock_mover-velocity"]["value"] == 1.0 + assert (await q.get())["mock_motor-velocity"]["value"] == 1.0 await v.set(2.0) - assert (await q.get())["mock_mover-velocity"]["value"] == 2.0 + assert (await q.get())["mock_motor-velocity"]["value"] == 2.0 v.clear_sub(q.put_nowait) await v.set(3.0) - assert (await v.read())["mock_mover-velocity"]["value"] == 3.0 + assert (await v.read())["mock_motor-velocity"]["value"] == 3.0 assert q.empty() -async def test_mover_disconnected(): +async def test_motor_disconnected(): with pytest.raises(NotConnected): async with init_devices(timeout=0.1): - m = demo.Mover("ca://PRE:", name="mover") - assert m.name == "mover" + m = demo.DemoMotor("ca://PRE:", name="motor") + assert m.name == "motor" -async def test_sensor_disconnected(caplog): - caplog.set_level(10) - with pytest.raises(NotConnected): - async with init_devices(timeout=0.1): - s = demo.Sensor("ca://PRE:", name="sensor") - logs = caplog.get_records("call") - logs = [log for log in logs if "_signal" not in log.pathname] - assert len(logs) == 2 - messages = {log.message for log in logs} - - assert messages == { - "signal ca://PRE:Value timed out", - "signal ca://PRE:Mode timed out", - } - assert s.name == "sensor" - - -async def test_read_sensor(mock_sensor: demo.Sensor): - assert (await mock_sensor.read())["mock_sensor-value"]["value"] == 0 - assert (await mock_sensor.read_configuration())["mock_sensor-mode"][ +async def test_read_point_detector(mock_point_detector: demo.DemoPointDetector): + channel = mock_point_detector.channel[1] + assert (await channel.read())["mock_point_detector-channel-1-value"]["value"] == 0 + assert (await channel.read_configuration())["mock_point_detector-channel-1-mode"][ "value" ] == demo.EnergyMode.LOW - desc = (await mock_sensor.describe_configuration())["mock_sensor-mode"] + desc = (await channel.describe_configuration())[ + "mock_point_detector-channel-1-mode" + ] assert desc["dtype"] == "string" assert desc["choices"] == ["Low Energy", "High Energy"] - set_mock_value(mock_sensor.mode, demo.EnergyMode.HIGH) - assert (await mock_sensor.read_configuration())["mock_sensor-mode"][ + set_mock_value(channel.mode, demo.EnergyMode.HIGH) + assert (await channel.read_configuration())["mock_point_detector-channel-1-mode"][ "value" ] == demo.EnergyMode.HIGH -async def test_sensor_in_plan(RE: RunEngine, mock_sensor: demo.Sensor): - """Tests mock sensor behavior within a RunEngine plan. +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 sensor emits the expected documents + This test verifies that the point_detector emits the expected documents when used in plan(count). """ docs = defaultdict(list) RE.subscribe(lambda name, doc: docs[name].append(doc)) - RE(bp.count([mock_sensor], num=2)) + RE(bp.count([mock_point_detector], num=2)) assert_emitted(docs, start=1, descriptor=1, event=2, stop=1) async def test_assembly_renaming() -> None: - thing = demo.SampleStage("PRE") + thing = demo.DemoStage("PRE") await thing.connect(mock=True) assert thing.x.name == "" assert thing.x.velocity.name == "" @@ -315,88 +264,80 @@ async def test_assembly_renaming() -> None: assert thing.x.stop_.name == "foo-x-stop_" -async def test_dynamic_sensor_group_disconnected(): +async def test_point_detector_disconnected(): with pytest.raises(NotConnected) as e: async with init_devices(timeout=0.1): - mock_sensor_group_dynamic = demo.SensorGroup("MOCK:SENSOR:") + det = demo.DemoPointDetector("MOCK:DET:") expected = """ -mock_sensor_group_dynamic: NotConnected: - sensors: NotConnected: +det: NotConnected: + channel: NotConnected: 1: NotConnected: - value: NotConnected: ca://MOCK:SENSOR:1:Value - mode: NotConnected: ca://MOCK:SENSOR:1:Mode + value: NotConnected: ca://MOCK:DET:1:Value + mode: NotConnected: ca://MOCK:DET:1:Mode 2: NotConnected: - value: NotConnected: ca://MOCK:SENSOR:2:Value - mode: NotConnected: ca://MOCK:SENSOR:2:Mode + value: NotConnected: ca://MOCK:DET:2:Value + mode: NotConnected: ca://MOCK:DET:2:Mode 3: NotConnected: - value: NotConnected: ca://MOCK:SENSOR:3:Value - mode: NotConnected: ca://MOCK:SENSOR:3:Mode + value: NotConnected: ca://MOCK:DET:3:Value + mode: NotConnected: ca://MOCK:DET:3:Mode + acquire_time: NotConnected: ca://MOCK:DET:AcquireTime + start: NotConnected: ca://MOCK:DET:Start.PROC + acquiring: NotConnected: ca://MOCK:DET:Acquiring + reset: NotConnected: ca://MOCK:DET:Reset.PROC """ assert str(e.value) == expected - assert mock_sensor_group_dynamic.name == "mock_sensor_group_dynamic" + assert det.name == "det" -async def test_dynamic_sensor_group_read_and_describe( - mock_sensor_group: demo.SensorGroup, +async def test_point_detector_read_and_describe( + mock_point_detector: demo.DemoPointDetector, ): - set_mock_value(mock_sensor_group.sensors[1].value, 0.0) - set_mock_value(mock_sensor_group.sensors[2].value, 0.5) - set_mock_value(mock_sensor_group.sensors[3].value, 1.0) + set_mock_value(mock_point_detector.channel[1].value, 1) + set_mock_value(mock_point_detector.channel[2].value, 5) + set_mock_value(mock_point_detector.channel[3].value, 10) - await mock_sensor_group.stage() - description = await mock_sensor_group.describe() + await mock_point_detector.stage() + description = await mock_point_detector.describe() - await mock_sensor_group.unstage() + await mock_point_detector.unstage() await assert_reading( - mock_sensor_group, + mock_point_detector, { - "mock_sensor_group-sensors-1-value": { - "value": 0.0, + "mock_point_detector-channel-1-value": { + "value": 1, "timestamp": ANY, "alarm_severity": 0, }, - "mock_sensor_group-sensors-2-value": { - "value": 0.5, + "mock_point_detector-channel-2-value": { + "value": 5, "timestamp": ANY, "alarm_severity": 0, }, - "mock_sensor_group-sensors-3-value": { - "value": 1.0, + "mock_point_detector-channel-3-value": { + "value": 10, "timestamp": ANY, "alarm_severity": 0, }, }, ) assert description == { - "mock_sensor_group-sensors-1-value": { - "dtype": "number", - "dtype_numpy": " PatternDetector: - path: Path = tmp_path_factory.mktemp("tmp") - return PatternDetector(name="PATTERN1", path=path) diff --git a/tests/sim/test_pattern_generator.py b/tests/sim/test_pattern_generator.py deleted file mode 100644 index 745e64cfc7..0000000000 --- a/tests/sim/test_pattern_generator.py +++ /dev/null @@ -1,34 +0,0 @@ -import pytest - -from ophyd_async.sim import PatternGenerator - - -@pytest.fixture -async def pattern_generator(): - # path: Path = tmp_path_factory.mktemp("tmp") - pattern_generator = PatternGenerator() - yield pattern_generator - - -async def test_init(pattern_generator: PatternGenerator): - assert pattern_generator.exposure == 0.1 - assert pattern_generator.height == 240 - assert pattern_generator.width == 320 - assert pattern_generator.image_counter == 0 - assert pattern_generator._handle_for_h5_file is None - assert pattern_generator._full_intensity_blob.shape == (240, 320) - - -def test_set_exposure(pattern_generator: PatternGenerator): - pattern_generator.set_exposure(0.5) - assert pattern_generator.exposure == 0.5 - - -def test_set_x(pattern_generator: PatternGenerator): - pattern_generator.set_x(5.0) - assert pattern_generator.x == 5.0 - - -def test_set_y(pattern_generator: PatternGenerator): - pattern_generator.set_y(-3.0) - assert pattern_generator.y == -3.0 diff --git a/tests/sim/test_sim_blob_detector.py b/tests/sim/test_sim_blob_detector.py new file mode 100644 index 0000000000..be62942ee5 --- /dev/null +++ b/tests/sim/test_sim_blob_detector.py @@ -0,0 +1,57 @@ +import os +from collections import defaultdict + +import bluesky.plans as bp +import h5py +import numpy as np +import pytest +from bluesky.run_engine import RunEngine + +from ophyd_async.core import StaticFilenameProvider, StaticPathProvider +from ophyd_async.sim import SimBlobDetector +from ophyd_async.testing import assert_emitted + + +@pytest.mark.parametrize("x_position,det_sum", [(0.0, 277544), (1.0, 506344)]) +async def test_sim_blob_detector( + RE: RunEngine, tmp_path, x_position: float, det_sum: int +): + docs = defaultdict(list) + RE.subscribe(lambda name, doc: docs[name].append(doc)) + path_provider = StaticPathProvider(StaticFilenameProvider("file"), tmp_path) + det = SimBlobDetector(path_provider, name="det") + det.pattern_generator.set_x(x_position) + + def plan(): + yield from bp.count([det], num=2) + + RE(plan()) + assert_emitted( + docs, start=1, descriptor=1, stream_resource=2, stream_datum=4, event=2, stop=1 + ) + path = docs["stream_resource"][0]["uri"].split("://localhost")[-1] + if os.name == "nt": + path = path.lstrip("/") + # Check data looks right + assert path == str(tmp_path / "file.h5") + h5file = h5py.File(path) + assert list(h5file["/entry"]) == ["data", "sum"] + assert list(h5file["/entry/sum"]) == [det_sum, det_sum] + assert np.sum(h5file["/entry/data/data"][0]) == det_sum + # Check descriptor looks right + assert docs["descriptor"][0]["data_keys"] == { + "det": { + "source": "sim://pattern-generator-hdf-file", + "shape": [240, 320], + "dtype": "array", + "object_name": "det", + "external": "STREAM:", + }, + "det-sum": { + "source": "sim://pattern-generator-hdf-file", + "shape": [], + "dtype": "number", + "object_name": "det", + "external": "STREAM:", + }, + } diff --git a/tests/sim/test_sim_detector.py b/tests/sim/test_sim_detector.py deleted file mode 100644 index 3f2e0f3557..0000000000 --- a/tests/sim/test_sim_detector.py +++ /dev/null @@ -1,51 +0,0 @@ -import os -from collections import defaultdict - -import bluesky.plans as bp -import h5py -import numpy as np -from bluesky.run_engine import RunEngine - -from ophyd_async.plan_stubs import ensure_connected -from ophyd_async.sim import PatternDetector -from ophyd_async.testing import assert_emitted - - -async def test_sim_pattern_detector_initialization( - sim_pattern_detector: PatternDetector, -): - assert ( - sim_pattern_detector.pattern_generator - ), "PatternGenerator was not initialized correctly." - - -async def test_detector_creates_controller_and_writer( - sim_pattern_detector: PatternDetector, -): - assert sim_pattern_detector._writer - assert sim_pattern_detector._controller - - -def test_writes_pattern_to_file( - sim_pattern_detector: PatternDetector, - RE: RunEngine, -): - # assert that the file contains data in expected dimensions - docs = defaultdict(list) - RE.subscribe(lambda name, doc: docs[name].append(doc)) - - def plan(): - yield from ensure_connected(sim_pattern_detector, mock=True) - yield from bp.count([sim_pattern_detector]) - - RE(plan()) - assert_emitted( - docs, start=1, descriptor=1, stream_resource=2, stream_datum=2, event=1, stop=1 - ) - path = docs["stream_resource"][0]["uri"].split("://localhost")[-1] - if os.name == "nt": - path = path.lstrip("/") - h5file = h5py.File(path) - assert list(h5file["/entry"]) == ["data", "sum"] - assert list(h5file["/entry/sum"]) == [44540.0] - assert np.sum(h5file["/entry/data/data"]) == 44540.0 diff --git a/tests/sim/test_sim_writer.py b/tests/sim/test_sim_writer.py deleted file mode 100644 index 9422eca0ef..0000000000 --- a/tests/sim/test_sim_writer.py +++ /dev/null @@ -1,42 +0,0 @@ -from unittest.mock import patch - -import pytest - -from ophyd_async.core import init_devices -from ophyd_async.sim import PatternDetectorWriter, PatternGenerator - - -@pytest.fixture -async def writer(static_path_provider) -> PatternDetectorWriter: - async with init_devices(mock=True): - driver = PatternGenerator() - - return PatternDetectorWriter(driver, static_path_provider, lambda: "NAME") - - -async def test_correct_descriptor_doc_after_open(writer: PatternDetectorWriter): - with patch("ophyd_async.core._signal.wait_for_value", return_value=None): - descriptor = await writer.open() - - assert descriptor == { - "NAME": { - "source": "sim://pattern-generator-hdf-file", - "shape": [240, 320], - "dtype": "array", - "external": "STREAM:", - }, - "NAME-sum": { - "source": "sim://pattern-generator-hdf-file", - "shape": [], - "dtype": "number", - "external": "STREAM:", - }, - } - - await writer.close() - - -async def test_collect_stream_docs(writer: PatternDetectorWriter): - await writer.open() - [item async for item in writer.collect_stream_docs(1)] - assert writer.pattern_generator._handle_for_h5_file diff --git a/tests/sim/test_streaming_plan.py b/tests/sim/test_streaming_plan.py deleted file mode 100644 index bfb7de78dc..0000000000 --- a/tests/sim/test_streaming_plan.py +++ /dev/null @@ -1,56 +0,0 @@ -from collections import defaultdict - -from bluesky import plans as bp -from bluesky.run_engine import RunEngine - -from ophyd_async.plan_stubs import ensure_connected -from ophyd_async.sim import PatternDetector -from ophyd_async.testing import assert_emitted - - -# NOTE the async operations with h5py are non-trival -# because of lack of native support for async operations -# see https://github.com/h5py/h5py/issues/837 -async def test_streaming_plan(RE: RunEngine, sim_pattern_detector: PatternDetector): - names = [] - docs = [] - - def append_and_print(name, doc): - names.append(name) - docs.append(doc) - - RE.subscribe(append_and_print) - - def plan(): - yield from ensure_connected(sim_pattern_detector, mock=True) - yield from bp.count([sim_pattern_detector], num=1) - - RE(plan()) - - # NOTE - double resource because double stream - assert names == [ - "start", - "descriptor", - "stream_resource", - "stream_resource", - "stream_datum", - "stream_datum", - "event", - "stop", - ] - await sim_pattern_detector._writer.close() - - -async def test_plan(RE: RunEngine, sim_pattern_detector: PatternDetector): - docs = defaultdict(list) - RE.subscribe(lambda name, doc: docs[name].append(doc)) - - def plan(): - yield from ensure_connected(sim_pattern_detector, mock=True) - yield from bp.count([sim_pattern_detector]) - - RE(plan()) - - assert_emitted( - docs, start=1, descriptor=1, stream_resource=2, stream_datum=2, event=1, stop=1 - ) diff --git a/tests/test_tutorials.py b/tests/test_tutorials.py new file mode 100644 index 0000000000..ca6eaf6e3b --- /dev/null +++ b/tests/test_tutorials.py @@ -0,0 +1,54 @@ +import importlib +import re +import time +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*) \|$", + re.M, +) + + +@pytest.fixture +def expected_scan_output(): + # TODO: get this from md file + matches = SCAN_LINE.findall(EXPECTED) + assert len(matches) == 9 + yield matches + + +@pytest.mark.parametrize("module", ["ophyd_async.sim", "ophyd_async.epics.demo"]) +def test_implementing_devices(module, capsys, expected_scan_output): + with patch("bluesky.run_engine.autoawait_in_bluesky_event_loop") as autoawait: + main = importlib.import_module(f"{module}.__main__") + autoawait.assert_called_once_with() + RE: RunEngine = main.RE + 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)) + assert time.monotonic() - start == pytest.approx(2.0, abs=1.0) + captured = capsys.readouterr() + assert captured.err == "" + assert SCAN_LINE.findall(captured.out) == expected_scan_output