ophyd-async 0.10.1__py3-none-any.whl → 0.12__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. ophyd_async/_version.py +2 -2
  2. ophyd_async/core/__init__.py +12 -1
  3. ophyd_async/core/_derived_signal.py +68 -22
  4. ophyd_async/core/_derived_signal_backend.py +46 -24
  5. ophyd_async/core/_detector.py +3 -3
  6. ophyd_async/core/_device.py +24 -16
  7. ophyd_async/core/_flyer.py +39 -5
  8. ophyd_async/core/_hdf_dataset.py +11 -10
  9. ophyd_async/core/_signal.py +58 -30
  10. ophyd_async/core/_table.py +3 -3
  11. ophyd_async/core/_utils.py +25 -0
  12. ophyd_async/core/_yaml_settings.py +3 -3
  13. ophyd_async/epics/adandor/__init__.py +7 -1
  14. ophyd_async/epics/adandor/_andor_controller.py +5 -8
  15. ophyd_async/epics/adandor/_andor_io.py +12 -19
  16. ophyd_async/epics/adcore/_hdf_writer.py +12 -19
  17. ophyd_async/epics/core/_signal.py +8 -3
  18. ophyd_async/epics/eiger/_odin_io.py +4 -2
  19. ophyd_async/epics/motor.py +47 -97
  20. ophyd_async/epics/pmac/__init__.py +3 -0
  21. ophyd_async/epics/pmac/_pmac_io.py +100 -0
  22. ophyd_async/fastcs/eiger/__init__.py +1 -2
  23. ophyd_async/fastcs/eiger/_eiger.py +3 -9
  24. ophyd_async/fastcs/panda/_trigger.py +8 -8
  25. ophyd_async/fastcs/panda/_writer.py +15 -13
  26. ophyd_async/plan_stubs/__init__.py +0 -8
  27. ophyd_async/plan_stubs/_fly.py +0 -204
  28. ophyd_async/sim/__init__.py +1 -2
  29. ophyd_async/sim/_blob_detector_writer.py +6 -12
  30. ophyd_async/sim/_mirror_horizontal.py +3 -2
  31. ophyd_async/sim/_mirror_vertical.py +1 -0
  32. ophyd_async/sim/_motor.py +13 -43
  33. ophyd_async/testing/__init__.py +2 -0
  34. ophyd_async/testing/_assert.py +34 -6
  35. {ophyd_async-0.10.1.dist-info → ophyd_async-0.12.dist-info}/METADATA +4 -3
  36. {ophyd_async-0.10.1.dist-info → ophyd_async-0.12.dist-info}/RECORD +39 -37
  37. {ophyd_async-0.10.1.dist-info → ophyd_async-0.12.dist-info}/WHEEL +0 -0
  38. {ophyd_async-0.10.1.dist-info → ophyd_async-0.12.dist-info}/licenses/LICENSE +0 -0
  39. {ophyd_async-0.10.1.dist-info → ophyd_async-0.12.dist-info}/top_level.txt +0 -0
@@ -1,204 +0,0 @@
1
- import bluesky.plan_stubs as bps
2
- from bluesky.utils import short_uid
3
-
4
- from ophyd_async.core import (
5
- DetectorTrigger,
6
- StandardDetector,
7
- StandardFlyer,
8
- TriggerInfo,
9
- in_micros,
10
- )
11
- from ophyd_async.fastcs.panda import (
12
- PandaPcompDirection,
13
- PcompInfo,
14
- SeqTable,
15
- SeqTableInfo,
16
- )
17
-
18
-
19
- def prepare_static_pcomp_flyer_and_detectors(
20
- flyer: StandardFlyer[PcompInfo],
21
- detectors: list[StandardDetector],
22
- pcomp_info: PcompInfo,
23
- trigger_info: TriggerInfo,
24
- ):
25
- """Prepare a hardware triggered flyable and one or more detectors.
26
-
27
- Prepare a hardware triggered flyable and one or more detectors with the
28
- same trigger.
29
-
30
- """
31
- for det in detectors:
32
- yield from bps.prepare(det, trigger_info, wait=False, group="prep")
33
- yield from bps.prepare(flyer, pcomp_info, wait=False, group="prep")
34
- yield from bps.wait(group="prep")
35
-
36
-
37
- def prepare_static_seq_table_flyer_and_detectors_with_same_trigger(
38
- flyer: StandardFlyer[SeqTableInfo],
39
- detectors: list[StandardDetector],
40
- number_of_frames: int,
41
- exposure: float,
42
- shutter_time: float,
43
- repeats: int = 1,
44
- period: float = 0.0,
45
- frame_timeout: float | None = None,
46
- ):
47
- """Prepare a hardware triggered flyable and one or more detectors.
48
-
49
- Prepare a hardware triggered flyable and one or more detectors with the
50
- same trigger. This method constructs TriggerInfo and a static sequence
51
- table from required parameters. The table is required to prepare the flyer,
52
- and the TriggerInfo is required to prepare the detector(s).
53
-
54
- This prepares all supplied detectors with the same trigger.
55
-
56
- """
57
- if not detectors:
58
- raise ValueError("No detectors provided. There must be at least one.")
59
-
60
- deadtime = max(det._controller.get_deadtime(exposure) for det in detectors) # noqa: SLF001
61
-
62
- trigger_info = TriggerInfo(
63
- number_of_events=number_of_frames * repeats,
64
- trigger=DetectorTrigger.CONSTANT_GATE,
65
- deadtime=deadtime,
66
- livetime=exposure,
67
- exposure_timeout=frame_timeout,
68
- )
69
- trigger_time = number_of_frames * (exposure + deadtime)
70
- pre_delay = max(period - 2 * shutter_time - trigger_time, 0)
71
-
72
- table = (
73
- # Wait for pre-delay then open shutter
74
- SeqTable.row(
75
- time1=in_micros(pre_delay),
76
- time2=in_micros(shutter_time),
77
- outa2=True,
78
- )
79
- +
80
- # Keeping shutter open, do N triggers
81
- SeqTable.row(
82
- repeats=number_of_frames,
83
- time1=in_micros(exposure),
84
- outa1=True,
85
- outb1=True,
86
- time2=in_micros(deadtime),
87
- outa2=True,
88
- )
89
- +
90
- # Add the shutter close
91
- SeqTable.row(time2=in_micros(shutter_time))
92
- )
93
-
94
- table_info = SeqTableInfo(sequence_table=table, repeats=repeats)
95
-
96
- for det in detectors:
97
- yield from bps.prepare(det, trigger_info, wait=False, group="prep")
98
- yield from bps.prepare(flyer, table_info, wait=False, group="prep")
99
- yield from bps.wait(group="prep")
100
-
101
-
102
- def fly_and_collect(
103
- stream_name: str,
104
- flyer: StandardFlyer[SeqTableInfo] | StandardFlyer[PcompInfo],
105
- detectors: list[StandardDetector],
106
- ):
107
- """Kickoff, complete and collect with a flyer and multiple detectors.
108
-
109
- This stub takes a flyer and one or more detectors that have been prepared. It
110
- declares a stream for the detectors, then kicks off the detectors and the flyer.
111
- The detectors are collected until the flyer and detectors have completed.
112
-
113
- """
114
- yield from bps.declare_stream(*detectors, name=stream_name, collect=True)
115
- yield from bps.kickoff(flyer, wait=True)
116
- for detector in detectors:
117
- yield from bps.kickoff(detector)
118
-
119
- # collect_while_completing
120
- group = short_uid(label="complete")
121
-
122
- yield from bps.complete(flyer, wait=False, group=group)
123
- for detector in detectors:
124
- yield from bps.complete(detector, wait=False, group=group)
125
-
126
- done = False
127
- while not done:
128
- try:
129
- yield from bps.wait(group=group, timeout=0.5)
130
- except TimeoutError:
131
- pass
132
- else:
133
- done = True
134
- yield from bps.collect(
135
- *detectors,
136
- return_payload=False,
137
- name=stream_name,
138
- )
139
- yield from bps.wait(group=group)
140
-
141
-
142
- def fly_and_collect_with_static_pcomp(
143
- stream_name: str,
144
- flyer: StandardFlyer[PcompInfo],
145
- detectors: list[StandardDetector],
146
- number_of_pulses: int,
147
- pulse_width: int,
148
- rising_edge_step: int,
149
- direction: PandaPcompDirection,
150
- trigger_info: TriggerInfo,
151
- ):
152
- # Set up scan and prepare trigger
153
- pcomp_info = PcompInfo(
154
- start_postion=0,
155
- pulse_width=pulse_width,
156
- rising_edge_step=rising_edge_step,
157
- number_of_pulses=number_of_pulses,
158
- direction=direction,
159
- )
160
- yield from prepare_static_pcomp_flyer_and_detectors(
161
- flyer, detectors, pcomp_info, trigger_info
162
- )
163
-
164
- # Run the fly scan
165
- yield from fly_and_collect(stream_name, flyer, detectors)
166
-
167
-
168
- def time_resolved_fly_and_collect_with_static_seq_table(
169
- stream_name: str,
170
- flyer: StandardFlyer[SeqTableInfo],
171
- detectors: list[StandardDetector],
172
- number_of_frames: int,
173
- exposure: float,
174
- shutter_time: float,
175
- repeats: int = 1,
176
- period: float = 0.0,
177
- frame_timeout: float | None = None,
178
- ):
179
- """Run a scan wth a flyer and multiple detectors.
180
-
181
- The stub demonstrates the standard basic flow for a flyscan:
182
-
183
- - Prepare the flyer and detectors with a trigger
184
- - Fly and collect:
185
- - Declare the stream and kickoff the scan
186
- - Collect while completing
187
-
188
- This needs to be used in a plan that instantates detectors and a flyer,
189
- stages/unstages the devices, and opens and closes the run.
190
-
191
- """
192
- # Set up scan and prepare trigger
193
- yield from prepare_static_seq_table_flyer_and_detectors_with_same_trigger(
194
- flyer,
195
- detectors,
196
- number_of_frames=number_of_frames,
197
- exposure=exposure,
198
- shutter_time=shutter_time,
199
- repeats=repeats,
200
- period=period,
201
- frame_timeout=frame_timeout,
202
- )
203
- # Run the fly scan
204
- yield from fly_and_collect(stream_name, flyer, detectors)
@@ -8,14 +8,13 @@ from ._mirror_vertical import (
8
8
  TwoJackTransform,
9
9
  VerticalMirror,
10
10
  )
11
- from ._motor import FlySimMotorInfo, SimMotor
11
+ from ._motor import SimMotor
12
12
  from ._pattern_generator import PatternGenerator
13
13
  from ._point_detector import SimPointDetector
14
14
  from ._stage import SimStage
15
15
 
16
16
  __all__ = [
17
17
  "SimMotor",
18
- "FlySimMotorInfo",
19
18
  "SimStage",
20
19
  "PatternGenerator",
21
20
  "SimPointDetector",
@@ -52,7 +52,7 @@ class BlobDetectorWriter(DetectorWriter):
52
52
  chunk_shape=(1024,),
53
53
  ),
54
54
  ]
55
- self.composer = None
55
+ self.composer = HDFDocumentComposer(self.path, self.datasets)
56
56
  describe = {
57
57
  ds.data_key: DataKey(
58
58
  source="sim://pattern-generator-hdf-file",
@@ -85,17 +85,11 @@ class BlobDetectorWriter(DetectorWriter):
85
85
  self, name: str, indices_written: int
86
86
  ) -> AsyncIterator[StreamAsset]:
87
87
  # When we have written something to the file
88
- if indices_written:
89
- # Only emit stream resource the first time we see frames in
90
- # the file
91
- if not self.composer:
92
- if not self.path:
93
- raise RuntimeError(f"open() not called on {self}")
94
- self.composer = HDFDocumentComposer(self.path, self.datasets)
95
- for doc in self.composer.stream_resources():
96
- yield "stream_resource", doc
97
- for doc in self.composer.stream_data(indices_written):
98
- yield "stream_datum", doc
88
+ if self.composer is None:
89
+ msg = f"open() not called on {self}"
90
+ raise RuntimeError(msg)
91
+ for doc in self.composer.make_stream_docs(indices_written):
92
+ yield doc
99
93
 
100
94
  async def close(self) -> None:
101
95
  self.pattern_generator.close_file()
@@ -3,7 +3,7 @@ from typing import TypedDict
3
3
 
4
4
  from bluesky.protocols import Movable
5
5
 
6
- from ophyd_async.core import AsyncStatus, DerivedSignalFactory, Device, soft_signal_rw
6
+ from ophyd_async.core import AsyncStatus, DerivedSignalFactory, Device
7
7
 
8
8
  from ._mirror_vertical import TwoJackDerived, TwoJackTransform
9
9
  from ._motor import SimMotor
@@ -20,7 +20,8 @@ class HorizontalMirror(Device, Movable):
20
20
  self.x1 = SimMotor()
21
21
  self.x2 = SimMotor()
22
22
  # Parameter
23
- self.x1_x2_distance = soft_signal_rw(float, initial_value=1)
23
+ # This could also be set as 'soft_signal_rw(float, initial_value=1)'
24
+ self.x1_x2_distance = 1.0
24
25
  # Derived signals
25
26
  self._factory = DerivedSignalFactory(
26
27
  TwoJackTransform,
@@ -51,6 +51,7 @@ class VerticalMirror(Device, Movable[TwoJackDerived]):
51
51
  self.y1 = SimMotor()
52
52
  self.y2 = SimMotor()
53
53
  # Parameter
54
+ # This could also be set as '1.0', if constant.
54
55
  self.y1_y2_distance = soft_signal_rw(float, initial_value=1)
55
56
  # Derived signals
56
57
  self._factory = DerivedSignalFactory(
ophyd_async/sim/_motor.py CHANGED
@@ -4,14 +4,15 @@ import time
4
4
 
5
5
  import numpy as np
6
6
  from bluesky.protocols import Locatable, Location, Reading, Stoppable, Subscribable
7
- from pydantic import BaseModel, ConfigDict, Field
8
7
 
9
8
  from ophyd_async.core import (
10
9
  AsyncStatus,
11
10
  Callback,
11
+ FlyMotorInfo,
12
12
  StandardReadable,
13
13
  WatchableAsyncStatus,
14
14
  WatcherUpdate,
15
+ error_if_none,
15
16
  observe_value,
16
17
  soft_signal_r_and_setter,
17
18
  soft_signal_rw,
@@ -19,37 +20,6 @@ from ophyd_async.core import (
19
20
  from ophyd_async.core import StandardReadableFormat as Format
20
21
 
21
22
 
22
- class FlySimMotorInfo(BaseModel):
23
- """Minimal set of information required to fly a [](#SimMotor)."""
24
-
25
- model_config = ConfigDict(frozen=True)
26
-
27
- cv_start: float
28
- """Absolute position of the motor once it finishes accelerating to desired
29
- velocity, in motor EGUs"""
30
-
31
- cv_end: float
32
- """Absolute position of the motor once it begins decelerating from desired
33
- velocity, in EGUs"""
34
-
35
- cv_time: float = Field(gt=0)
36
- """Time taken for the motor to get from start_position to end_position, excluding
37
- run-up and run-down, in seconds."""
38
-
39
- @property
40
- def velocity(self) -> float:
41
- """Calculate the velocity of the constant velocity phase."""
42
- return (self.cv_end - self.cv_start) / self.cv_time
43
-
44
- def start_position(self, acceleration_time: float) -> float:
45
- """Calculate the start position with run-up distance added on."""
46
- return self.cv_start - acceleration_time * self.velocity / 2
47
-
48
- def end_position(self, acceleration_time: float) -> float:
49
- """Calculate the end position with run-down distance added on."""
50
- return self.cv_end + acceleration_time * self.velocity / 2
51
-
52
-
53
23
  class SimMotor(StandardReadable, Stoppable, Subscribable[float], Locatable[float]):
54
24
  """For usage when simulating a motor."""
55
25
 
@@ -74,7 +44,7 @@ class SimMotor(StandardReadable, Stoppable, Subscribable[float], Locatable[float
74
44
  self._set_success = True
75
45
  self._move_status: AsyncStatus | None = None
76
46
  # Stored in prepare
77
- self._fly_info: FlySimMotorInfo | None = None
47
+ self._fly_info: FlyMotorInfo | None = None
78
48
  # Set on kickoff(), complete when motor reaches end position
79
49
  self._fly_status: WatchableAsyncStatus | None = None
80
50
 
@@ -86,12 +56,14 @@ class SimMotor(StandardReadable, Stoppable, Subscribable[float], Locatable[float
86
56
  self.user_readback.set_name(name)
87
57
 
88
58
  @AsyncStatus.wrap
89
- async def prepare(self, value: FlySimMotorInfo):
59
+ async def prepare(self, value: FlyMotorInfo):
90
60
  """Calculate run-up and move there, setting fly velocity when there."""
91
61
  self._fly_info = value
92
62
  # Move to start as fast as we can
93
63
  await self.velocity.set(0)
94
- await self.set(value.start_position(await self.acceleration_time.get_value()))
64
+ await self.set(
65
+ value.ramp_up_start_pos(await self.acceleration_time.get_value())
66
+ )
95
67
  # Set the velocity for the actual move
96
68
  await self.velocity.set(value.velocity)
97
69
 
@@ -111,20 +83,18 @@ class SimMotor(StandardReadable, Stoppable, Subscribable[float], Locatable[float
111
83
  @AsyncStatus.wrap
112
84
  async def kickoff(self):
113
85
  """Begin moving motor from prepared position to final position."""
114
- if not self._fly_info:
115
- msg = "Motor must be prepared before attempting to kickoff"
116
- raise RuntimeError(msg)
86
+ fly_info = error_if_none(
87
+ self._fly_info, "Motor must be prepared before attempting to kickoff"
88
+ )
117
89
  acceleration_time = await self.acceleration_time.get_value()
118
- self._fly_status = self.set(self._fly_info.end_position(acceleration_time))
90
+ self._fly_status = self.set(fly_info.ramp_down_end_pos(acceleration_time))
119
91
  # Wait for the acceleration time to ensure we are at velocity
120
92
  await asyncio.sleep(acceleration_time)
121
93
 
122
94
  def complete(self) -> WatchableAsyncStatus:
123
95
  """Mark as complete once motor reaches completed position."""
124
- if not self._fly_status:
125
- msg = "kickoff not called"
126
- raise RuntimeError(msg)
127
- return self._fly_status
96
+ fly_status = error_if_none(self._fly_status, "kickoff not called")
97
+ return fly_status
128
98
 
129
99
  async def _move(self, old_position: float, new_position: float, velocity: float):
130
100
  if old_position == new_position:
@@ -11,6 +11,7 @@ from ._assert import (
11
11
  assert_emitted,
12
12
  assert_reading,
13
13
  assert_value,
14
+ partial_reading,
14
15
  )
15
16
  from ._mock_signal_utils import (
16
17
  callback_on_mock_put,
@@ -47,6 +48,7 @@ __all__ = [
47
48
  "assert_configuration",
48
49
  "assert_describe_signal",
49
50
  "assert_emitted",
51
+ "partial_reading",
50
52
  # Mocking utilities
51
53
  "get_mock",
52
54
  "set_mock_value",
@@ -21,6 +21,15 @@ from ophyd_async.core import (
21
21
  from ._utils import T
22
22
 
23
23
 
24
+ def partial_reading(val: Any) -> dict[str, Any]:
25
+ """Helper function for building expected reading or configuration dicts.
26
+
27
+ :param val: Value to be wrapped in dict with "value" as the key.
28
+ :return: The dict that has wrapped the val with key "value".
29
+ """
30
+ return {"value": val}
31
+
32
+
24
33
  def approx_value(value: Any):
25
34
  """Allow any value to be compared to another in tests.
26
35
 
@@ -45,14 +54,18 @@ async def assert_value(signal: SignalR[SignalDatatypeT], value: Any) -> None:
45
54
  async def assert_reading(
46
55
  readable: AsyncReadable,
47
56
  expected_reading: Mapping[str, Mapping[str, Any]],
57
+ full_match: bool = True,
48
58
  ) -> None:
49
59
  """Assert that a readable Device has the given reading.
50
60
 
51
61
  :param readable: Device with an async ``read()`` method to get the reading from.
52
62
  :param expected_reading: The expected reading from the readable.
63
+ :param full_match: if expected_reading keys set is same as actual keys set.
64
+ true: exact match
65
+ false: expected_reading keys is subset of actual reading keys
53
66
  """
54
67
  actual_reading = await readable.read()
55
- _assert_readings_approx_equal(expected_reading, actual_reading)
68
+ _assert_readings_approx_equal(expected_reading, actual_reading, full_match)
56
69
 
57
70
 
58
71
  def _approx_reading(expected: Mapping[str, Any], actual: Reading) -> Reading:
@@ -69,16 +82,26 @@ def _approx_reading(expected: Mapping[str, Any], actual: Reading) -> Reading:
69
82
 
70
83
 
71
84
  def _assert_readings_approx_equal(
72
- expected: Mapping[str, Mapping[str, Any]], actual: Mapping[str, Reading]
85
+ expected: Mapping[str, Mapping[str, Any]],
86
+ actual: Mapping[str, Reading],
87
+ full_match: bool = True,
73
88
  ):
74
- assert actual == {
75
- k: _approx_reading(v, actual[k]) for k, v in expected.items() if k in actual
89
+ # expand the expected keys to include actual if we allow partial matches
90
+ if not full_match:
91
+ expected = dict(actual, **expected)
92
+ # now make them approximate if they are in actual so we get a nicer diff
93
+ approx_expected = {
94
+ k: _approx_reading(v, actual[k]) if k in actual else v
95
+ for k, v in expected.items()
76
96
  }
97
+ # now we can compare them
98
+ assert actual == approx_expected
77
99
 
78
100
 
79
101
  async def assert_configuration(
80
102
  configurable: AsyncConfigurable,
81
- configuration: dict[str, dict[str, Any]],
103
+ expected_configuration: dict[str, dict[str, Any]],
104
+ full_match: bool = True,
82
105
  ) -> None:
83
106
  """Assert that a configurable Device has the given configuration.
84
107
 
@@ -86,9 +109,14 @@ async def assert_configuration(
86
109
  Device with an async ``read_configuration()`` method to get the
87
110
  configuration from.
88
111
  :param configuration: The expected configuration from the configurable.
112
+ :param full_match: if expected_reading keys set is same as actual keys set.
113
+ true: exact match
114
+ false: expected_reading keys is subset of actual reading keys
89
115
  """
90
116
  actual_configuration = await configurable.read_configuration()
91
- _assert_readings_approx_equal(configuration, actual_configuration)
117
+ _assert_readings_approx_equal(
118
+ expected_configuration, actual_configuration, full_match
119
+ )
92
120
 
93
121
 
94
122
  async def assert_describe_signal(signal: SignalR, /, **metadata):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ophyd-async
3
- Version: 0.10.1
3
+ Version: 0.12
4
4
  Summary: Asynchronous Bluesky hardware abstraction code, compatible with control systems like EPICS and Tango
5
5
  Author-email: Tom Cobb <tom.cobb@diamond.ac.uk>
6
6
  License: BSD 3-Clause License
@@ -48,6 +48,7 @@ Requires-Dist: pyyaml
48
48
  Requires-Dist: colorlog
49
49
  Requires-Dist: pydantic>=2.0
50
50
  Requires-Dist: pydantic-numpy
51
+ Requires-Dist: stamina>=23.1.0
51
52
  Provides-Extra: sim
52
53
  Requires-Dist: h5py; extra == "sim"
53
54
  Provides-Extra: ca
@@ -70,7 +71,7 @@ Requires-Dist: inflection; extra == "dev"
70
71
  Requires-Dist: import-linter; extra == "dev"
71
72
  Requires-Dist: myst-parser; extra == "dev"
72
73
  Requires-Dist: numpydoc; extra == "dev"
73
- Requires-Dist: ophyd; extra == "dev"
74
+ Requires-Dist: ophyd>=1.10.7; extra == "dev"
74
75
  Requires-Dist: pickleshare; extra == "dev"
75
76
  Requires-Dist: pipdeptree; extra == "dev"
76
77
  Requires-Dist: pre-commit; extra == "dev"
@@ -120,7 +121,7 @@ The main differences from ophyd sync are:
120
121
  - Support for [EPICS][] PVA and [Tango][] as well as the traditional EPICS CA
121
122
  - Better library support for splitting the logic from the hardware interface to avoid complex class heirarchies
122
123
 
123
- It was written with the aim of implementing flyscanning in a generic and extensible way with highly customizable devices like PandABox and the Delta Tau PMAC products. Using async code makes it possible to do the "put 3 PVs in parallel, then get from another PV" logic that is common in flyscanning without the performance and complexity overhead of multiple threads.
124
+ It was written with the aim of implementing fly scanning in a generic and extensible way with highly customizable devices like PandABox and the Delta Tau PMAC products. Using async code makes it possible to do the "put 3 PVs in parallel, then get from another PV" logic that is common in fly scanning without the performance and complexity overhead of multiple threads.
124
125
 
125
126
  Devices from both ophyd sync and ophyd-async can be used in the same RunEngine and even in the same scan. This allows a per-device migration where devices are reimplemented in ophyd-async one by one.
126
127