ophyd-async 0.14.2__py3-none-any.whl → 0.15__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 (38) hide show
  1. ophyd_async/_version.py +2 -2
  2. ophyd_async/core/__init__.py +17 -5
  3. ophyd_async/core/{_table.py → _datatypes.py} +18 -9
  4. ophyd_async/core/_derived_signal.py +2 -2
  5. ophyd_async/core/_derived_signal_backend.py +1 -5
  6. ophyd_async/core/_device_filler.py +4 -6
  7. ophyd_async/core/_mock_signal_backend.py +25 -7
  8. ophyd_async/core/_mock_signal_utils.py +7 -11
  9. ophyd_async/core/_signal.py +11 -11
  10. ophyd_async/core/_signal_backend.py +7 -19
  11. ophyd_async/core/_soft_signal_backend.py +6 -6
  12. ophyd_async/core/_status.py +81 -4
  13. ophyd_async/core/_typing.py +0 -0
  14. ophyd_async/core/_utils.py +57 -7
  15. ophyd_async/epics/adcore/_core_io.py +12 -5
  16. ophyd_async/epics/adcore/_core_logic.py +1 -1
  17. ophyd_async/epics/core/__init__.py +2 -1
  18. ophyd_async/epics/core/_aioca.py +13 -3
  19. ophyd_async/epics/core/_epics_connector.py +4 -1
  20. ophyd_async/epics/core/_p4p.py +13 -3
  21. ophyd_async/epics/core/_signal.py +18 -6
  22. ophyd_async/epics/core/_util.py +23 -3
  23. ophyd_async/epics/demo/_motor.py +2 -2
  24. ophyd_async/epics/motor.py +15 -17
  25. ophyd_async/epics/odin/_odin_io.py +1 -1
  26. ophyd_async/epics/pmac/_pmac_io.py +23 -4
  27. ophyd_async/epics/pmac/_pmac_trajectory.py +47 -10
  28. ophyd_async/fastcs/eiger/_eiger_io.py +20 -1
  29. ophyd_async/fastcs/jungfrau/_signals.py +4 -1
  30. ophyd_async/fastcs/panda/_block.py +28 -6
  31. ophyd_async/fastcs/panda/_writer.py +1 -3
  32. ophyd_async/tango/core/_tango_transport.py +7 -17
  33. ophyd_async/tango/demo/_counter.py +2 -2
  34. {ophyd_async-0.14.2.dist-info → ophyd_async-0.15.dist-info}/METADATA +1 -1
  35. {ophyd_async-0.14.2.dist-info → ophyd_async-0.15.dist-info}/RECORD +38 -37
  36. {ophyd_async-0.14.2.dist-info → ophyd_async-0.15.dist-info}/WHEEL +1 -1
  37. {ophyd_async-0.14.2.dist-info → ophyd_async-0.15.dist-info}/licenses/LICENSE +0 -0
  38. {ophyd_async-0.14.2.dist-info → ophyd_async-0.15.dist-info}/top_level.txt +0 -0
@@ -5,6 +5,8 @@ import logging
5
5
  from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence
6
6
  from dataclasses import dataclass
7
7
  from enum import Enum, EnumMeta, StrEnum
8
+ from functools import lru_cache
9
+ from inspect import isawaitable
8
10
  from typing import (
9
11
  Any,
10
12
  Generic,
@@ -13,6 +15,7 @@ from typing import (
13
15
  TypeVar,
14
16
  get_args,
15
17
  get_origin,
18
+ get_type_hints,
16
19
  )
17
20
 
18
21
  import numpy as np
@@ -204,6 +207,20 @@ async def wait_for_connection(**coros: Awaitable[None]):
204
207
  raise NotConnectedError.with_other_exceptions_logged(exceptions)
205
208
 
206
209
 
210
+ # Cache get_type_hints calls to avoid expensive introspection across the codebase
211
+ @lru_cache(maxsize=512)
212
+ def cached_get_type_hints(cls: type, include_extras: bool = False) -> dict[str, Any]:
213
+ """Get type hints with caching to avoid expensive introspection."""
214
+ return get_type_hints(cls, include_extras=include_extras)
215
+
216
+
217
+ # Cache get_origin calls to avoid expensive type introspection
218
+ @lru_cache(maxsize=512)
219
+ def cached_get_origin(tp: Any) -> Any:
220
+ """Get the origin of a type with caching."""
221
+ return get_origin(tp)
222
+
223
+
207
224
  def get_dtype(datatype: type) -> np.dtype:
208
225
  """Get the runtime dtype from a numpy ndarray type annotation.
209
226
 
@@ -215,7 +232,7 @@ def get_dtype(datatype: type) -> np.dtype:
215
232
 
216
233
  ```
217
234
  """
218
- if not get_origin(datatype) == np.ndarray:
235
+ if not cached_get_origin(datatype) == np.ndarray:
219
236
  raise TypeError(f"Expected Array1D[dtype], got {datatype}")
220
237
  # datatype = numpy.ndarray[typing.Any, numpy.dtype[numpy.float64]]
221
238
  # so extract numpy.float64 from it
@@ -240,7 +257,7 @@ def get_enum_cls(datatype: type | None) -> type[EnumTypes] | None:
240
257
 
241
258
  ```
242
259
  """
243
- if get_origin(datatype) is Sequence:
260
+ if cached_get_origin(datatype) is Sequence:
244
261
  datatype = get_args(datatype)[0]
245
262
  datatype = get_origin_class(datatype)
246
263
  if datatype and issubclass(datatype, Enum):
@@ -292,10 +309,36 @@ async def merge_gathered_dicts(
292
309
  return ret
293
310
 
294
311
 
295
- async def gather_dict(coros: Mapping[T, Awaitable[V]]) -> dict[T, V]:
296
- """Take named coros and return a dict of their name to their return value."""
297
- values = await asyncio.gather(*coros.values())
298
- return dict(zip(coros, values, strict=True))
312
+ def _partition_awaitable(
313
+ maybe_awaitables: Iterable[T | Awaitable[T]],
314
+ ) -> tuple[dict[int, Awaitable[T]], dict[int, T]]:
315
+ awaitable: dict[int, Awaitable[T]] = {}
316
+ not_awaitable: dict[int, T] = {}
317
+ for i, x in enumerate(maybe_awaitables):
318
+ if isawaitable(x):
319
+ awaitable[i] = x
320
+ else:
321
+ not_awaitable[i] = x
322
+ return awaitable, not_awaitable
323
+
324
+
325
+ async def gather_dict(coros: dict[T | Awaitable[T], V | Awaitable[V]]) -> dict[T, V]:
326
+ """Await any coros in the keys or values of a dictionary."""
327
+ k_awaitable, k_not_awaitable = _partition_awaitable(coros.keys())
328
+ v_awaitable, v_not_awaitable = _partition_awaitable(coros.values())
329
+
330
+ # Await all awaitables in parallel
331
+ k_results, v_results = await asyncio.gather(
332
+ asyncio.gather(*k_awaitable.values()),
333
+ asyncio.gather(*v_awaitable.values()),
334
+ )
335
+
336
+ # Combine awaited and non-awaited values by index
337
+ k_map = k_not_awaitable | dict(zip(k_awaitable, k_results, strict=True))
338
+ v_map = v_not_awaitable | dict(zip(v_awaitable, v_results, strict=True))
339
+
340
+ # Reconstruct dict in original index order
341
+ return {k_map[i]: v_map[i] for i in range(len(coros))}
299
342
 
300
343
 
301
344
  def in_micros(t: float) -> int:
@@ -310,8 +353,10 @@ def in_micros(t: float) -> int:
310
353
  return int(np.ceil(t * 1e6))
311
354
 
312
355
 
356
+ @lru_cache(maxsize=512)
313
357
  def get_origin_class(annotatation: Any) -> type | None:
314
- origin = get_origin(annotatation) or annotatation
358
+ """Get the origin class of a type annotation with caching."""
359
+ origin = cached_get_origin(annotatation) or annotatation
315
360
  if isinstance(origin, type):
316
361
  return origin
317
362
  return None
@@ -363,3 +408,8 @@ def error_if_none(value: T | None, msg: str) -> T:
363
408
  if value is None:
364
409
  raise RuntimeError(msg)
365
410
  return value
411
+
412
+
413
+ def non_zero(value):
414
+ """Return True if the value cast to an int is not zero."""
415
+ return int(value) != 0
@@ -8,8 +8,9 @@ from ophyd_async.core import (
8
8
  SignalR,
9
9
  SignalRW,
10
10
  StrictEnum,
11
+ non_zero,
11
12
  )
12
- from ophyd_async.epics.core import EpicsDevice, PvSuffix
13
+ from ophyd_async.epics.core import EpicsDevice, EpicsOptions, PvSuffix
13
14
 
14
15
  from ._utils import ADBaseDataType, ADFileWriteMode, ADImageMode, convert_ad_dtype_to_np
15
16
 
@@ -23,7 +24,7 @@ class NDArrayBaseIO(EpicsDevice):
23
24
 
24
25
  unique_id: A[SignalR[int], PvSuffix("UniqueId_RBV")]
25
26
  nd_attributes_file: A[SignalRW[str], PvSuffix("NDAttributesFile")]
26
- acquire: A[SignalRW[bool], PvSuffix.rbv("Acquire")]
27
+ acquire: A[SignalRW[bool], PvSuffix.rbv("Acquire"), EpicsOptions(wait=non_zero)]
27
28
  array_size_x: A[SignalR[int], PvSuffix("ArraySizeX_RBV")]
28
29
  array_size_y: A[SignalR[int], PvSuffix("ArraySizeY_RBV")]
29
30
  data_type: A[SignalR[ADBaseDataType], PvSuffix("DataType_RBV")]
@@ -200,7 +201,7 @@ class NDFileIO(NDArrayBaseIO):
200
201
  file_write_mode: A[SignalRW[ADFileWriteMode], PvSuffix.rbv("FileWriteMode")]
201
202
  num_capture: A[SignalRW[int], PvSuffix.rbv("NumCapture")]
202
203
  num_captured: A[SignalR[int], PvSuffix("NumCaptured_RBV")]
203
- capture: A[SignalRW[bool], PvSuffix.rbv("Capture")]
204
+ capture: A[SignalRW[bool], PvSuffix.rbv("Capture"), EpicsOptions(wait=non_zero)]
204
205
  array_size0: A[SignalR[int], PvSuffix("ArraySize0")]
205
206
  array_size1: A[SignalR[int], PvSuffix("ArraySize1")]
206
207
  create_directory: A[SignalRW[int], PvSuffix("CreateDirectory")]
@@ -240,11 +241,17 @@ class NDCBFlushOnSoftTrgMode(StrictEnum):
240
241
 
241
242
 
242
243
  class NDPluginCBIO(NDPluginBaseIO):
244
+ """Plugin that outputs pre/post-trigger NDArrays based on defined conditions.
245
+
246
+ This mirrors the interface provided by ADCore//Db/NDCircularBuff.template
247
+ See HTML docs at https://areadetector.github.io/areaDetector/ADCore/NDPluginCircularBuff.html
248
+ """
249
+
243
250
  pre_count: A[SignalRW[int], PvSuffix.rbv("PreCount")]
244
251
  post_count: A[SignalRW[int], PvSuffix.rbv("PostCount")]
245
252
  preset_trigger_count: A[SignalRW[int], PvSuffix.rbv("PresetTriggerCount")]
246
- trigger: A[SignalRW[bool], PvSuffix.rbv("Trigger")]
247
- capture: A[SignalRW[bool], PvSuffix.rbv("Capture")]
253
+ trigger: A[SignalRW[bool], PvSuffix.rbv("Trigger"), EpicsOptions(wait=non_zero)]
254
+ capture: A[SignalRW[bool], PvSuffix.rbv("Capture"), EpicsOptions(wait=non_zero)]
248
255
  flush_on_soft_trg: A[
249
256
  SignalRW[NDCBFlushOnSoftTrgMode], PvSuffix.rbv("FlushOnSoftTrg")
250
257
  ]
@@ -218,7 +218,7 @@ class ADBaseContAcqController(ADBaseController[ADBaseIO]):
218
218
  )
219
219
 
220
220
  # Send the trigger to begin acquisition
221
- await self.cb_plugin.trigger.set(True, wait=False)
221
+ await self.cb_plugin.trigger.set(True)
222
222
 
223
223
  async def disarm(self) -> None:
224
224
  await stop_busy_record(self.cb_plugin.capture, False)
@@ -10,7 +10,7 @@ from ._signal import (
10
10
  epics_signal_w,
11
11
  epics_signal_x,
12
12
  )
13
- from ._util import stop_busy_record
13
+ from ._util import EpicsOptions, stop_busy_record
14
14
 
15
15
  __all__ = [
16
16
  "PviDeviceConnector",
@@ -25,4 +25,5 @@ __all__ = [
25
25
  "epics_signal_w",
26
26
  "epics_signal_x",
27
27
  "stop_busy_record",
28
+ "EpicsOptions",
28
29
  ]
@@ -36,7 +36,12 @@ from ophyd_async.core import (
36
36
  wait_for_connection,
37
37
  )
38
38
 
39
- from ._util import EpicsSignalBackend, format_datatype, get_supported_values
39
+ from ._util import (
40
+ EpicsOptions,
41
+ EpicsSignalBackend,
42
+ format_datatype,
43
+ get_supported_values,
44
+ )
40
45
 
41
46
  logger = logging.getLogger("ophyd_async")
42
47
 
@@ -255,12 +260,13 @@ class CaSignalBackend(EpicsSignalBackend[SignalDatatypeT]):
255
260
  datatype: type[SignalDatatypeT] | None,
256
261
  read_pv: str = "",
257
262
  write_pv: str = "",
263
+ options: EpicsOptions | None = None,
258
264
  ):
259
265
  self.converter: CaConverter = DisconnectedCaConverter(float, dbr.DBR_DOUBLE)
260
266
  self.initial_values: dict[str, AugmentedValue] = {}
261
267
  self.subscription: Subscription | None = None
262
268
  self._all_updates = _all_updates()
263
- super().__init__(datatype, read_pv, write_pv)
269
+ super().__init__(datatype, read_pv, write_pv, options)
264
270
 
265
271
  def source(self, name: str, read: bool):
266
272
  return f"ca://{self.read_pv if read else self.write_pv}"
@@ -299,11 +305,15 @@ class CaSignalBackend(EpicsSignalBackend[SignalDatatypeT]):
299
305
  "alarm_severity": -1 if value.severity > 2 else value.severity,
300
306
  }
301
307
 
302
- async def put(self, value: SignalDatatypeT | None, wait: bool):
308
+ async def put(self, value: SignalDatatypeT | None):
303
309
  if value is None:
304
310
  write_value = self.initial_values[self.write_pv]
305
311
  else:
306
312
  write_value = self.converter.write_value(value)
313
+ if callable(self.options.wait):
314
+ wait = self.options.wait(value)
315
+ else:
316
+ wait = self.options.wait
307
317
  try:
308
318
  await caput(
309
319
  self.write_pv,
@@ -5,7 +5,8 @@ from typing import Any
5
5
 
6
6
  from ophyd_async.core import Device, DeviceConnector, DeviceFiller
7
7
 
8
- from ._signal import EpicsSignalBackend, get_signal_backend_type, split_protocol_from_pv
8
+ from ._signal import get_signal_backend_type, split_protocol_from_pv
9
+ from ._util import EpicsOptions, EpicsSignalBackend
9
10
 
10
11
 
11
12
  @dataclass
@@ -44,6 +45,8 @@ def fill_backend_with_prefix(
44
45
  backend.write_pv = prefix + (
45
46
  annotation.write_suffix or annotation.read_suffix
46
47
  )
48
+ elif isinstance(annotation, EpicsOptions):
49
+ backend.options = annotation
47
50
  else:
48
51
  unhandled.append(annotation)
49
52
  annotations.extend(unhandled)
@@ -29,7 +29,12 @@ from ophyd_async.core import (
29
29
  wait_for_connection,
30
30
  )
31
31
 
32
- from ._util import EpicsSignalBackend, format_datatype, get_supported_values
32
+ from ._util import (
33
+ EpicsOptions,
34
+ EpicsSignalBackend,
35
+ format_datatype,
36
+ get_supported_values,
37
+ )
33
38
 
34
39
  logger = logging.getLogger("ophyd_async")
35
40
 
@@ -347,11 +352,12 @@ class PvaSignalBackend(EpicsSignalBackend[SignalDatatypeT]):
347
352
  datatype: type[SignalDatatypeT] | None,
348
353
  read_pv: str = "",
349
354
  write_pv: str = "",
355
+ options: EpicsOptions | None = None,
350
356
  ):
351
357
  self.converter: PvaConverter = DisconnectedPvaConverter(float)
352
358
  self.initial_values: dict[str, Any] = {}
353
359
  self.subscription: Subscription | None = None
354
- super().__init__(datatype, read_pv, write_pv)
360
+ super().__init__(datatype, read_pv, write_pv, options)
355
361
 
356
362
  def source(self, name: str, read: bool):
357
363
  return f"pva://{self.read_pv if read else self.write_pv}"
@@ -380,11 +386,15 @@ class PvaSignalBackend(EpicsSignalBackend[SignalDatatypeT]):
380
386
  "alarm_severity": -1 if sv > 2 else sv,
381
387
  }
382
388
 
383
- async def put(self, value: SignalDatatypeT | None, wait: bool):
389
+ async def put(self, value: SignalDatatypeT | None):
384
390
  if value is None:
385
391
  write_value = self.initial_values[self.write_pv]["value"]
386
392
  else:
387
393
  write_value = self.converter.write_value(value)
394
+ if callable(self.options.wait):
395
+ wait = self.options.wait(value)
396
+ else:
397
+ wait = self.options.wait
388
398
  await context().put(self.write_pv, {"value": write_value}, wait=wait)
389
399
 
390
400
  async def get_datakey(self, source: str) -> DataKey:
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from collections.abc import Callable
5
6
  from enum import Enum
6
7
 
7
8
  from ophyd_async.core import (
@@ -15,7 +16,7 @@ from ophyd_async.core import (
15
16
  get_unique,
16
17
  )
17
18
 
18
- from ._util import EpicsSignalBackend, get_pv_basename_and_field
19
+ from ._util import EpicsOptions, EpicsSignalBackend, get_pv_basename_and_field
19
20
 
20
21
 
21
22
  class EpicsProtocol(Enum):
@@ -78,14 +79,18 @@ def get_signal_backend_type(protocol: EpicsProtocol) -> type[EpicsSignalBackend]
78
79
 
79
80
 
80
81
  def _epics_signal_backend(
81
- datatype: type[SignalDatatypeT] | None, read_pv: str, write_pv: str
82
+ datatype: type[SignalDatatypeT] | None,
83
+ read_pv: str,
84
+ write_pv: str,
85
+ options: EpicsOptions | None = None,
82
86
  ) -> SignalBackend[SignalDatatypeT]:
83
87
  """Create an epics signal backend."""
84
88
  r_protocol, r_pv = split_protocol_from_pv(read_pv)
85
89
  w_protocol, w_pv = split_protocol_from_pv(write_pv)
86
90
  protocol = get_unique({read_pv: r_protocol, write_pv: w_protocol}, "protocols")
91
+
87
92
  signal_backend_type = get_signal_backend_type(protocol)
88
- return signal_backend_type(datatype, r_pv, w_pv)
93
+ return signal_backend_type(datatype, r_pv, w_pv, options)
89
94
 
90
95
 
91
96
  def epics_signal_rw(
@@ -95,6 +100,7 @@ def epics_signal_rw(
95
100
  name: str = "",
96
101
  timeout: float = DEFAULT_TIMEOUT,
97
102
  attempts: int = 1,
103
+ wait: bool | Callable[[SignalDatatypeT], bool] = True,
98
104
  ) -> SignalRW[SignalDatatypeT]:
99
105
  """Create a `SignalRW` backed by 1 or 2 EPICS PVs.
100
106
 
@@ -104,7 +110,9 @@ def epics_signal_rw(
104
110
  :param name: The name of the signal (defaults to empty string)
105
111
  :param timeout: A timeout to be used when reading (not connecting) this signal
106
112
  """
107
- backend = _epics_signal_backend(datatype, read_pv, write_pv or read_pv)
113
+ backend = _epics_signal_backend(
114
+ datatype, read_pv, write_pv or read_pv, EpicsOptions(wait=wait)
115
+ )
108
116
  return SignalRW(backend, name=name, timeout=timeout, attempts=attempts)
109
117
 
110
118
 
@@ -115,6 +123,7 @@ def epics_signal_rw_rbv(
115
123
  name: str = "",
116
124
  timeout: float = DEFAULT_TIMEOUT,
117
125
  attempts: int = 1,
126
+ wait: bool | Callable[[SignalDatatypeT], bool] = True,
118
127
  ) -> SignalRW[SignalDatatypeT]:
119
128
  """Create a `SignalRW` backed by 1 or 2 EPICS PVs, with a suffix on the readback pv.
120
129
 
@@ -131,7 +140,7 @@ def epics_signal_rw_rbv(
131
140
  read_pv = f"{write_pv}{read_suffix}"
132
141
 
133
142
  return epics_signal_rw(
134
- datatype, read_pv, write_pv, name, timeout=timeout, attempts=attempts
143
+ datatype, read_pv, write_pv, name, timeout=timeout, attempts=attempts, wait=wait
135
144
  )
136
145
 
137
146
 
@@ -158,6 +167,7 @@ def epics_signal_w(
158
167
  name: str = "",
159
168
  timeout: float = DEFAULT_TIMEOUT,
160
169
  attempts: int = 1,
170
+ wait: bool | Callable[[SignalDatatypeT], bool] = True,
161
171
  ) -> SignalW[SignalDatatypeT]:
162
172
  """Create a `SignalW` backed by 1 EPICS PVs.
163
173
 
@@ -166,7 +176,9 @@ def epics_signal_w(
166
176
  :param name: The name of the signal (defaults to empty string)
167
177
  :param timeout: A timeout to be used when reading (not connecting) this signal
168
178
  """
169
- backend = _epics_signal_backend(datatype, write_pv, write_pv)
179
+ backend = _epics_signal_backend(
180
+ datatype, write_pv, write_pv, EpicsOptions(wait=wait)
181
+ )
170
182
  return SignalW(backend, name=name, timeout=timeout, attempts=attempts)
171
183
 
172
184
 
@@ -1,5 +1,6 @@
1
- from collections.abc import Mapping, Sequence
2
- from typing import Any, TypeVar, get_args, get_origin
1
+ from collections.abc import Callable, Mapping, Sequence
2
+ from dataclasses import dataclass
3
+ from typing import Any, Generic, TypeVar, get_args, get_origin
3
4
 
4
5
  import numpy as np
5
6
 
@@ -19,6 +20,23 @@ from ophyd_async.core import (
19
20
  T = TypeVar("T")
20
21
 
21
22
 
23
+ @dataclass
24
+ class EpicsOptions(Generic[SignalDatatypeT]):
25
+ """Options for EPICS Signals."""
26
+
27
+ wait: bool | Callable[[SignalDatatypeT], bool] = True
28
+ """Whether to wait for server-side completion of the operation:
29
+
30
+ - `True`: Return when server-side operation has completed
31
+ - `False`: Return when server-side operation has started
32
+ - `callable`: Call with the value being put to decide whether to wait
33
+
34
+ For example, use `EpicsOption(wait=non_zero)` for busy records like
35
+ areaDetector acquire PVs that should not wait when being set to zero
36
+ as it causes a deadlock.
37
+ """
38
+
39
+
22
40
  def get_pv_basename_and_field(pv: str) -> tuple[str, str | None]:
23
41
  """Split PV into record name and field."""
24
42
  if "." in pv:
@@ -75,9 +93,11 @@ class EpicsSignalBackend(SignalBackend[SignalDatatypeT]):
75
93
  datatype: type[SignalDatatypeT] | None,
76
94
  read_pv: str = "",
77
95
  write_pv: str = "",
96
+ options: EpicsOptions | None = None,
78
97
  ):
79
98
  self.read_pv = read_pv
80
99
  self.write_pv = write_pv
100
+ self.options = options or EpicsOptions()
81
101
  super().__init__(datatype)
82
102
 
83
103
 
@@ -86,5 +106,5 @@ async def stop_busy_record(
86
106
  value: SignalDatatypeT,
87
107
  timeout: float = DEFAULT_TIMEOUT,
88
108
  ) -> None:
89
- await signal.set(value, wait=False)
109
+ await signal.set(value)
90
110
  await wait_for_value(signal, value, timeout=timeout)
@@ -55,8 +55,8 @@ class DemoMotor(EpicsDevice, StandardReadable, Movable, Stoppable):
55
55
  # If not supplied, calculate a suitable timeout for the move
56
56
  if timeout == CALCULATE_TIMEOUT:
57
57
  timeout = abs(new_position - old_position) / velocity + DEFAULT_TIMEOUT
58
- # Wait for the value to set, but don't wait for put completion callback
59
- await self.setpoint.set(new_position, wait=False)
58
+ # Setting the setpoint starts the motion
59
+ await self.setpoint.set(new_position)
60
60
  # Observe the readback Signal, and on each new position...
61
61
  async for current_position in observe_value(
62
62
  self.readback, done_timeout=timeout
@@ -97,7 +97,7 @@ class InstantMotorMock(DeviceMock["Motor"]):
97
97
  set_mock_value(device.motor_done_move, 1)
98
98
 
99
99
  # When setpoint is written to, immediately update readback and done flag
100
- def _instant_move(value, wait):
100
+ def _instant_move(value):
101
101
  set_mock_value(device.motor_done_move, 0) # Moving
102
102
  set_mock_value(device.user_readback, value) # Arrive instantly
103
103
  set_mock_value(device.motor_done_move, 1) # Done
@@ -145,7 +145,9 @@ class Motor(
145
145
  # Note:cannot use epics_signal_x here, as the motor record specifies that
146
146
  # we must write 1 to stop the motor. Simply processing the record is not
147
147
  # sufficient.
148
- self.motor_stop = epics_signal_w(int, prefix + ".STOP")
148
+ # Put with completion will never complete as we are waiting for completion on
149
+ # the move in set, so need to pass wait=False
150
+ self.motor_stop = epics_signal_w(int, prefix + ".STOP", wait=False)
149
151
 
150
152
  # Whether set() should complete successfully or not
151
153
  self._set_success = True
@@ -283,27 +285,23 @@ class Motor(
283
285
 
284
286
  await self.check_motor_limit(old_position, new_position)
285
287
 
286
- move_status = self.user_setpoint.set(new_position, wait=True, timeout=timeout)
287
- async for current_position in observe_value(
288
- self.user_readback, done_status=move_status
289
- ):
290
- yield WatcherUpdate(
291
- current=current_position,
292
- initial=old_position,
293
- target=new_position,
294
- name=self.name,
295
- unit=units,
296
- precision=precision,
297
- )
288
+ async with self.user_setpoint.set(new_position, timeout=timeout):
289
+ async for current_position in observe_value(self.user_readback):
290
+ yield WatcherUpdate(
291
+ current=current_position,
292
+ initial=old_position,
293
+ target=new_position,
294
+ name=self.name,
295
+ unit=units,
296
+ precision=precision,
297
+ )
298
298
  if not self._set_success:
299
299
  raise RuntimeError("Motor was stopped")
300
300
 
301
301
  async def stop(self, success=False):
302
302
  """Request to stop moving and return immediately."""
303
303
  self._set_success = success
304
- # Put with completion will never complete as we are waiting for completion on
305
- # the move above, so need to pass wait=False
306
- await self.motor_stop.set(1, wait=False)
304
+ await self.motor_stop.set(1)
307
305
 
308
306
  async def locate(self) -> Location[float]:
309
307
  """Return the current setpoint and readback of the motor."""
@@ -163,7 +163,7 @@ class OdinWriter(DetectorWriter):
163
163
 
164
164
  async def close(self) -> None:
165
165
  await stop_busy_record(self._drv.capture, Writing.DONE, timeout=DEFAULT_TIMEOUT)
166
- await self._drv.meta_stop.set(True, wait=True)
166
+ await self._drv.meta_stop.set(True)
167
167
  if self._capture_status and not self._capture_status.done:
168
168
  await self._capture_status
169
169
  self._capture_status = None
@@ -2,7 +2,7 @@ from collections.abc import Sequence
2
2
 
3
3
  import numpy as np
4
4
 
5
- from ophyd_async.core import Array1D, Device, DeviceVector, StandardReadable
5
+ from ophyd_async.core import Array1D, Device, DeviceVector, StandardReadable, SubsetEnum
6
6
  from ophyd_async.epics import motor
7
7
  from ophyd_async.epics.core import epics_signal_r, epics_signal_rw, epics_signal_x
8
8
 
@@ -10,8 +10,16 @@ from ophyd_async.epics.core import epics_signal_r, epics_signal_rw, epics_signal
10
10
  CS_INDEX = {letter: index + 1 for index, letter in enumerate("ABCUVWXYZ")}
11
11
 
12
12
 
13
+ class PmacExecuteState(SubsetEnum):
14
+ DONE = "Done"
15
+ EXECUTING = "Executing"
16
+
17
+
13
18
  class PmacTrajectoryIO(StandardReadable):
14
- """Device that moves a PMAC Motor record."""
19
+ """Device that moves a PMAC Motor record.
20
+
21
+ This mirrors the interface provided by pmac/Db/pmacControllerTrajectory.template
22
+ """
15
23
 
16
24
  def __init__(self, prefix: str, name: str = "") -> None:
17
25
  self.time_array = epics_signal_rw(
@@ -45,6 +53,9 @@ class PmacTrajectoryIO(StandardReadable):
45
53
  # be a SignalRW to be waited on in PmacTrajectoryTriggerLogic.
46
54
  # TODO: Change record type to bo from busy (https://github.com/DiamondLightSource/pmac/issues/154)
47
55
  self.execute_profile = epics_signal_rw(bool, prefix + "ProfileExecute")
56
+ self.execute_state = epics_signal_r(
57
+ PmacExecuteState, prefix + "ProfileExecuteState_RBV"
58
+ )
48
59
  self.abort_profile = epics_signal_x(prefix + "ProfileAbort")
49
60
  self.profile_cs_name = epics_signal_rw(str, prefix + "ProfileCsName")
50
61
  self.calculate_velocities = epics_signal_rw(bool, prefix + "ProfileCalcVel")
@@ -56,6 +67,7 @@ class PmacAxisAssignmentIO(Device):
56
67
  """A Device that (direct) moves a PMAC Coordinate System Motor.
57
68
 
58
69
  Note that this does not go through a motor record.
70
+ This mirrors the interface provided by pmac/Db/motor_in_cs.template
59
71
  """
60
72
 
61
73
  def __init__(self, prefix: str, name: str = "") -> None:
@@ -66,7 +78,11 @@ class PmacAxisAssignmentIO(Device):
66
78
 
67
79
 
68
80
  class PmacCoordIO(Device):
69
- """A Device that represents a Pmac Coordinate System."""
81
+ """A Device that represents a Pmac Coordinate System.
82
+
83
+ This mirrors the interfaces provided by pmac/Db/pmacCsController.template,
84
+ and pmac/Db/pmacDirectMotor.template
85
+ """
70
86
 
71
87
  def __init__(self, prefix: str, name: str = "") -> None:
72
88
  self.defer_moves = epics_signal_rw(bool, f"{prefix}DeferMoves")
@@ -81,7 +97,10 @@ class PmacCoordIO(Device):
81
97
 
82
98
 
83
99
  class PmacIO(Device):
84
- """Device that represents a pmac controller."""
100
+ """Device that represents a pmac controller.
101
+
102
+ This mirrors the interface provided by pmac/Db/pmacController.template
103
+ """
85
104
 
86
105
  def __init__(
87
106
  self,