ophyd-async 0.9.0a2__py3-none-any.whl → 0.10.0a2__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 (151) hide show
  1. ophyd_async/__init__.py +5 -8
  2. ophyd_async/_docs_parser.py +12 -0
  3. ophyd_async/_version.py +9 -4
  4. ophyd_async/core/__init__.py +97 -62
  5. ophyd_async/core/_derived_signal.py +271 -0
  6. ophyd_async/core/_derived_signal_backend.py +300 -0
  7. ophyd_async/core/_detector.py +106 -125
  8. ophyd_async/core/_device.py +69 -63
  9. ophyd_async/core/_device_filler.py +65 -1
  10. ophyd_async/core/_flyer.py +14 -5
  11. ophyd_async/core/_hdf_dataset.py +29 -22
  12. ophyd_async/core/_log.py +14 -23
  13. ophyd_async/core/_mock_signal_backend.py +11 -3
  14. ophyd_async/core/_protocol.py +65 -45
  15. ophyd_async/core/_providers.py +28 -9
  16. ophyd_async/core/_readable.py +44 -35
  17. ophyd_async/core/_settings.py +36 -27
  18. ophyd_async/core/_signal.py +262 -170
  19. ophyd_async/core/_signal_backend.py +56 -13
  20. ophyd_async/core/_soft_signal_backend.py +16 -11
  21. ophyd_async/core/_status.py +72 -24
  22. ophyd_async/core/_table.py +41 -11
  23. ophyd_async/core/_utils.py +96 -49
  24. ophyd_async/core/_yaml_settings.py +2 -0
  25. ophyd_async/epics/__init__.py +1 -0
  26. ophyd_async/epics/adandor/_andor.py +2 -2
  27. ophyd_async/epics/adandor/_andor_controller.py +4 -2
  28. ophyd_async/epics/adandor/_andor_io.py +2 -4
  29. ophyd_async/epics/adaravis/__init__.py +5 -0
  30. ophyd_async/epics/adaravis/_aravis.py +4 -8
  31. ophyd_async/epics/adaravis/_aravis_controller.py +20 -43
  32. ophyd_async/epics/adaravis/_aravis_io.py +13 -28
  33. ophyd_async/epics/adcore/__init__.py +23 -8
  34. ophyd_async/epics/adcore/_core_detector.py +42 -2
  35. ophyd_async/epics/adcore/_core_io.py +124 -99
  36. ophyd_async/epics/adcore/_core_logic.py +106 -27
  37. ophyd_async/epics/adcore/_core_writer.py +12 -8
  38. ophyd_async/epics/adcore/_hdf_writer.py +21 -38
  39. ophyd_async/epics/adcore/_single_trigger.py +2 -2
  40. ophyd_async/epics/adcore/_utils.py +2 -2
  41. ophyd_async/epics/adkinetix/__init__.py +2 -1
  42. ophyd_async/epics/adkinetix/_kinetix.py +3 -3
  43. ophyd_async/epics/adkinetix/_kinetix_controller.py +4 -2
  44. ophyd_async/epics/adkinetix/_kinetix_io.py +12 -13
  45. ophyd_async/epics/adpilatus/__init__.py +5 -0
  46. ophyd_async/epics/adpilatus/_pilatus.py +1 -1
  47. ophyd_async/epics/adpilatus/_pilatus_controller.py +5 -24
  48. ophyd_async/epics/adpilatus/_pilatus_io.py +11 -9
  49. ophyd_async/epics/adsimdetector/__init__.py +8 -1
  50. ophyd_async/epics/adsimdetector/_sim.py +4 -14
  51. ophyd_async/epics/adsimdetector/_sim_controller.py +17 -0
  52. ophyd_async/epics/adsimdetector/_sim_io.py +10 -0
  53. ophyd_async/epics/advimba/__init__.py +10 -1
  54. ophyd_async/epics/advimba/_vimba.py +3 -2
  55. ophyd_async/epics/advimba/_vimba_controller.py +4 -2
  56. ophyd_async/epics/advimba/_vimba_io.py +23 -28
  57. ophyd_async/epics/core/_aioca.py +35 -16
  58. ophyd_async/epics/core/_epics_connector.py +4 -0
  59. ophyd_async/epics/core/_epics_device.py +2 -0
  60. ophyd_async/epics/core/_p4p.py +10 -2
  61. ophyd_async/epics/core/_pvi_connector.py +65 -8
  62. ophyd_async/epics/core/_signal.py +51 -51
  63. ophyd_async/epics/core/_util.py +4 -4
  64. ophyd_async/epics/demo/__init__.py +16 -0
  65. ophyd_async/epics/demo/__main__.py +31 -0
  66. ophyd_async/epics/demo/_ioc.py +32 -0
  67. ophyd_async/epics/demo/_motor.py +82 -0
  68. ophyd_async/epics/demo/_point_detector.py +42 -0
  69. ophyd_async/epics/demo/_point_detector_channel.py +22 -0
  70. ophyd_async/epics/demo/_stage.py +15 -0
  71. ophyd_async/epics/{sim/mover.db → demo/motor.db} +2 -1
  72. ophyd_async/epics/demo/point_detector.db +59 -0
  73. ophyd_async/epics/demo/point_detector_channel.db +21 -0
  74. ophyd_async/epics/eiger/_eiger.py +1 -3
  75. ophyd_async/epics/eiger/_eiger_controller.py +11 -4
  76. ophyd_async/epics/eiger/_eiger_io.py +2 -0
  77. ophyd_async/epics/eiger/_odin_io.py +1 -2
  78. ophyd_async/epics/motor.py +65 -28
  79. ophyd_async/epics/signal.py +4 -1
  80. ophyd_async/epics/testing/_example_ioc.py +21 -9
  81. ophyd_async/epics/testing/_utils.py +3 -0
  82. ophyd_async/epics/testing/test_records.db +8 -0
  83. ophyd_async/epics/testing/test_records_pva.db +17 -16
  84. ophyd_async/fastcs/__init__.py +1 -0
  85. ophyd_async/fastcs/core.py +6 -0
  86. ophyd_async/fastcs/odin/__init__.py +1 -0
  87. ophyd_async/fastcs/panda/__init__.py +8 -6
  88. ophyd_async/fastcs/panda/_block.py +29 -9
  89. ophyd_async/fastcs/panda/_control.py +5 -0
  90. ophyd_async/fastcs/panda/_hdf_panda.py +2 -0
  91. ophyd_async/fastcs/panda/_table.py +9 -6
  92. ophyd_async/fastcs/panda/_trigger.py +23 -9
  93. ophyd_async/fastcs/panda/_writer.py +27 -30
  94. ophyd_async/plan_stubs/__init__.py +2 -0
  95. ophyd_async/plan_stubs/_ensure_connected.py +1 -0
  96. ophyd_async/plan_stubs/_fly.py +2 -4
  97. ophyd_async/plan_stubs/_nd_attributes.py +2 -0
  98. ophyd_async/plan_stubs/_panda.py +1 -0
  99. ophyd_async/plan_stubs/_settings.py +43 -16
  100. ophyd_async/plan_stubs/_utils.py +3 -0
  101. ophyd_async/plan_stubs/_wait_for_awaitable.py +1 -1
  102. ophyd_async/sim/__init__.py +24 -14
  103. ophyd_async/sim/__main__.py +43 -0
  104. ophyd_async/sim/_blob_detector.py +33 -0
  105. ophyd_async/sim/_blob_detector_controller.py +48 -0
  106. ophyd_async/sim/_blob_detector_writer.py +105 -0
  107. ophyd_async/sim/_mirror_horizontal.py +46 -0
  108. ophyd_async/sim/_mirror_vertical.py +74 -0
  109. ophyd_async/sim/_motor.py +233 -0
  110. ophyd_async/sim/_pattern_generator.py +124 -0
  111. ophyd_async/sim/_point_detector.py +86 -0
  112. ophyd_async/sim/_stage.py +19 -0
  113. ophyd_async/tango/__init__.py +1 -0
  114. ophyd_async/tango/core/__init__.py +6 -1
  115. ophyd_async/tango/core/_base_device.py +41 -33
  116. ophyd_async/tango/core/_converters.py +81 -0
  117. ophyd_async/tango/core/_signal.py +18 -32
  118. ophyd_async/tango/core/_tango_readable.py +2 -19
  119. ophyd_async/tango/core/_tango_transport.py +136 -60
  120. ophyd_async/tango/core/_utils.py +47 -0
  121. ophyd_async/tango/{sim → demo}/_counter.py +2 -0
  122. ophyd_async/tango/{sim → demo}/_detector.py +2 -0
  123. ophyd_async/tango/{sim → demo}/_mover.py +5 -4
  124. ophyd_async/tango/{sim → demo}/_tango/_servers.py +4 -0
  125. ophyd_async/tango/testing/__init__.py +6 -0
  126. ophyd_async/tango/testing/_one_of_everything.py +200 -0
  127. ophyd_async/testing/__init__.py +29 -7
  128. ophyd_async/testing/_assert.py +145 -83
  129. ophyd_async/testing/_mock_signal_utils.py +56 -70
  130. ophyd_async/testing/_one_of_everything.py +41 -21
  131. ophyd_async/testing/_single_derived.py +89 -0
  132. ophyd_async/testing/_utils.py +3 -0
  133. {ophyd_async-0.9.0a2.dist-info → ophyd_async-0.10.0a2.dist-info}/METADATA +25 -26
  134. ophyd_async-0.10.0a2.dist-info/RECORD +149 -0
  135. {ophyd_async-0.9.0a2.dist-info → ophyd_async-0.10.0a2.dist-info}/WHEEL +1 -1
  136. ophyd_async/epics/sim/__init__.py +0 -54
  137. ophyd_async/epics/sim/_ioc.py +0 -29
  138. ophyd_async/epics/sim/_mover.py +0 -101
  139. ophyd_async/epics/sim/_sensor.py +0 -37
  140. ophyd_async/epics/sim/sensor.db +0 -19
  141. ophyd_async/sim/_pattern_detector/__init__.py +0 -13
  142. ophyd_async/sim/_pattern_detector/_pattern_detector.py +0 -42
  143. ophyd_async/sim/_pattern_detector/_pattern_detector_controller.py +0 -69
  144. ophyd_async/sim/_pattern_detector/_pattern_detector_writer.py +0 -41
  145. ophyd_async/sim/_pattern_detector/_pattern_generator.py +0 -214
  146. ophyd_async/sim/_sim_motor.py +0 -107
  147. ophyd_async-0.9.0a2.dist-info/RECORD +0 -129
  148. /ophyd_async/tango/{sim → demo}/__init__.py +0 -0
  149. /ophyd_async/tango/{sim → demo}/_tango/__init__.py +0 -0
  150. {ophyd_async-0.9.0a2.dist-info → ophyd_async-0.10.0a2.dist-info/licenses}/LICENSE +0 -0
  151. {ophyd_async-0.9.0a2.dist-info → ophyd_async-0.10.0a2.dist-info}/top_level.txt +0 -0
@@ -1,6 +1,8 @@
1
1
  import logging
2
2
  import sys
3
+ import typing
3
4
  from collections.abc import Sequence
5
+ from functools import cache
4
6
  from math import isnan, nan
5
7
  from typing import Any, Generic, cast
6
8
 
@@ -47,6 +49,7 @@ def _limits_from_augmented_value(value: AugmentedValue) -> Limits:
47
49
  low=None if isnan(low) else low,
48
50
  high=None if isnan(high) else high,
49
51
  )
52
+ return None
50
53
 
51
54
  limits = Limits()
52
55
  if limits_range := get_limits("alarm"):
@@ -181,6 +184,9 @@ def make_converter(
181
184
  Dbr, get_unique({k: v.datatype for k, v in values.items()}, "datatypes")
182
185
  )
183
186
  is_array = bool([v for v in values.values() if v.element_count > 1])
187
+ # Make the datatype canonical for the inference below
188
+ if datatype == typing.Sequence[str]:
189
+ datatype = Sequence[str]
184
190
  # Infer a datatype and converter from the dbr
185
191
  inferred_datatype, converter_cls = _datatype_converter_from_dbr[(pv_dbr, is_array)]
186
192
  # Some override cases
@@ -227,19 +233,18 @@ def make_converter(
227
233
  )
228
234
 
229
235
 
230
- _tried_pyepics = False
231
-
232
-
236
+ # Cached call to avoid repeated initialization attempts
237
+ @cache
233
238
  def _use_pyepics_context_if_imported():
234
- global _tried_pyepics
235
- if not _tried_pyepics:
236
- ca = sys.modules.get("epics.ca", None)
237
- if ca:
238
- ca.use_initial_context()
239
- _tried_pyepics = True
239
+ """Sets up the pyepics context if the module is imported."""
240
+ ca = sys.modules.get("epics.ca", None)
241
+ if ca:
242
+ ca.use_initial_context()
240
243
 
241
244
 
242
245
  class CaSignalBackend(EpicsSignalBackend[SignalDatatypeT]):
246
+ """Backend for a signal to interact with PVs over channel access."""
247
+
243
248
  def __init__(
244
249
  self,
245
250
  datatype: type[SignalDatatypeT] | None,
@@ -293,13 +298,27 @@ class CaSignalBackend(EpicsSignalBackend[SignalDatatypeT]):
293
298
  write_value = self.initial_values[self.write_pv]
294
299
  else:
295
300
  write_value = self.converter.write_value(value)
296
- await caput(
297
- self.write_pv,
298
- write_value,
299
- datatype=self.converter.write_dbr,
300
- wait=wait,
301
- timeout=None,
302
- )
301
+ try:
302
+ await caput(
303
+ self.write_pv,
304
+ write_value,
305
+ datatype=self.converter.write_dbr,
306
+ wait=wait,
307
+ timeout=None,
308
+ )
309
+ except CANothing as exc:
310
+ # If we ran into a write error, check to see if there is a list
311
+ # of valid choices, and if the value we tried to write is in that list.
312
+ valid_choices = self.converter.metadata.get("choices")
313
+ if valid_choices:
314
+ if value not in valid_choices:
315
+ msg = (
316
+ f"{value} is not a valid choice for {self.write_pv}, "
317
+ f"valid choices: {self.converter.metadata.get('choices')}"
318
+ )
319
+ raise ValueError(msg) from exc
320
+ raise
321
+ raise
303
322
 
304
323
  async def get_datakey(self, source: str) -> DataKey:
305
324
  value = await self._caget(self.read_pv, FORMAT_CTRL)
@@ -10,6 +10,8 @@ from ._signal import EpicsSignalBackend, get_signal_backend_type, split_protocol
10
10
 
11
11
  @dataclass
12
12
  class PvSuffix:
13
+ """Define the PV suffix to be appended to the device prefix."""
14
+
13
15
  read_suffix: str
14
16
  write_suffix: str | None = None
15
17
 
@@ -36,6 +38,8 @@ def fill_backend_with_prefix(
36
38
 
37
39
 
38
40
  class EpicsDeviceConnector(DeviceConnector):
41
+ """Used for connecting signals to static EPICS pvs."""
42
+
39
43
  def __init__(self, prefix: str) -> None:
40
44
  self.prefix = prefix
41
45
 
@@ -5,6 +5,8 @@ from ._pvi_connector import PviDeviceConnector
5
5
 
6
6
 
7
7
  class EpicsDevice(Device):
8
+ """Baseclass to allow child signals to be created declaratively."""
9
+
8
10
  def __init__(self, prefix: str, with_pvi: bool = False, name: str = ""):
9
11
  if with_pvi:
10
12
  connector = PviDeviceConnector(prefix)
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import asyncio
4
4
  import atexit
5
5
  import logging
6
+ import typing
6
7
  from collections.abc import Mapping, Sequence
7
8
  from math import isnan, nan
8
9
  from typing import Any, Generic
@@ -46,6 +47,7 @@ def _limits_from_value(value: Any) -> Limits:
46
47
  low=None if isnan(low) else low,
47
48
  high=None if isnan(high) else high,
48
49
  )
50
+ return None
49
51
 
50
52
  limits = Limits()
51
53
  if limits_range := get_limits("valueAlarm", "lowAlarmLimit", "highAlarmLimit"):
@@ -245,6 +247,9 @@ def make_converter(datatype: type | None, values: dict[str, Any]) -> PvaConverte
245
247
  {k: _get_specifier(v) for k, v in values.items()},
246
248
  "value type specifiers",
247
249
  )
250
+ # Make the datatype canonical for the inference below
251
+ if datatype == typing.Sequence[str]:
252
+ datatype = Sequence[str]
248
253
  # Infer a datatype and converter from the typeid and specifier
249
254
  inferred_datatype, converter_cls = _datatype_converter_from_typeid[
250
255
  (typeid, specifier)
@@ -328,13 +333,16 @@ async def pvget_with_timeout(pv: str, timeout: float) -> Any:
328
333
 
329
334
 
330
335
  def _pva_request_string(fields: Sequence[str]) -> str:
331
- """Converts a list of requested fields into a PVA request string which can be
332
- passed to p4p.
336
+ """Convert a list of requested fields into a PVA request string.
337
+
338
+ This can be passed to p4p.
333
339
  """
334
340
  return f"field({','.join(fields)})"
335
341
 
336
342
 
337
343
  class PvaSignalBackend(EpicsSignalBackend[SignalDatatypeT]):
344
+ """Backend for a signal to interact with PVs over pva."""
345
+
338
346
  def __init__(
339
347
  self,
340
348
  datatype: type[SignalDatatypeT] | None,
@@ -1,5 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from typing import Literal, cast
4
+
3
5
  from ophyd_async.core import (
4
6
  Device,
5
7
  DeviceConnector,
@@ -7,6 +9,7 @@ from ophyd_async.core import (
7
9
  Signal,
8
10
  SignalR,
9
11
  SignalRW,
12
+ SignalW,
10
13
  SignalX,
11
14
  )
12
15
  from ophyd_async.core._utils import LazyMock
@@ -16,6 +19,27 @@ from ._signal import PvaSignalBackend, pvget_with_timeout
16
19
 
17
20
  Entry = dict[str, str]
18
21
 
22
+ OldPVIVector = list[Entry | None]
23
+ # The older PVI structure has vectors of the form
24
+ # structure[] ttlout
25
+ # (none)
26
+ # structure
27
+ # string d PANDABLOCKS_IOC:TTLOUT1:PVI
28
+ # structure
29
+ # string d PANDABLOCKS_IOC:TTLOUT2:PVI
30
+ # structure
31
+ # string d PANDABLOCKS_IOC:TTLOUT3:PVI
32
+
33
+
34
+ FastCSPVIVector = dict[Literal["d"], Entry]
35
+ # The newer pva FastCS PVI structure has vectors of the form
36
+ # structure ttlout
37
+ # structure d
38
+ # string v1 FASTCS_PANDA:Ttlout1:PVI
39
+ # string v2 FASTCS_PANDA:Ttlout2:PVI
40
+ # string v3 FASTCS_PANDA:Ttlout3:PVI
41
+ # string v4 FASTCS_PANDA:Ttlout4:PVI
42
+
19
43
 
20
44
  def _get_signal_details(entry: Entry) -> tuple[type[Signal], str, str]:
21
45
  match entry:
@@ -23,6 +47,8 @@ def _get_signal_details(entry: Entry) -> tuple[type[Signal], str, str]:
23
47
  return SignalR, read_pv, read_pv
24
48
  case {"r": read_pv, "w": write_pv}:
25
49
  return SignalRW, read_pv, write_pv
50
+ case {"w": write_pv}:
51
+ return SignalW, write_pv, write_pv
26
52
  case {"rw": read_write_pv}:
27
53
  return SignalRW, read_write_pv, read_write_pv
28
54
  case {"x": execute_pv}:
@@ -31,7 +57,26 @@ def _get_signal_details(entry: Entry) -> tuple[type[Signal], str, str]:
31
57
  raise TypeError(f"Can't process entry {entry}")
32
58
 
33
59
 
60
+ def _is_device_vector_entry(entry: Entry | OldPVIVector | FastCSPVIVector) -> bool:
61
+ return isinstance(entry, list) or (
62
+ entry.keys() == {"d"} and isinstance(entry["d"], dict)
63
+ )
64
+
65
+
34
66
  class PviDeviceConnector(DeviceConnector):
67
+ """Connect to PVI structure served over PVA.
68
+
69
+ At init, fill in all the type hinted signals. At connection check their
70
+ types and fill in any extra signals.
71
+
72
+ :param prefix:
73
+ The PV prefix of the device, "PVI" will be appended to it to get the PVI
74
+ PV.
75
+ :param error_hint:
76
+ If given, this will be appended to the error message if any of they type
77
+ hinted Signals are not present.
78
+ """
79
+
35
80
  def __init__(self, prefix: str = "", error_hint: str = "") -> None:
36
81
  # TODO: what happens if we get a leading "pva://" here?
37
82
  self.prefix = prefix
@@ -70,21 +115,33 @@ class PviDeviceConnector(DeviceConnector):
70
115
  device.set_name(device.name)
71
116
  return await super().connect_mock(device, mock)
72
117
 
118
+ def _fill_vector_child(self, name: str, entry: OldPVIVector | FastCSPVIVector):
119
+ if isinstance(entry, list):
120
+ for i, e in enumerate(entry):
121
+ if e:
122
+ self._fill_child(name, e, i)
123
+ else:
124
+ for i_string, e in entry["d"].items():
125
+ self._fill_child(name, {"d": e}, int(i_string.lstrip("v")))
126
+
73
127
  async def connect_real(
74
128
  self, device: Device, timeout: float, force_reconnect: bool
75
129
  ) -> None:
76
130
  pvi_structure = await pvget_with_timeout(self.pvi_pv, timeout)
77
- entries: dict[str, Entry | list[Entry | None]] = pvi_structure["value"].todict()
131
+
132
+ entries: dict[str, Entry | OldPVIVector | FastCSPVIVector] = pvi_structure[
133
+ "value"
134
+ ].todict()
78
135
  # Fill based on what PVI gives us
79
136
  for name, entry in entries.items():
80
- if isinstance(entry, dict):
81
- # This is a child
82
- self._fill_child(name, entry)
137
+ if _is_device_vector_entry(entry):
138
+ self._fill_vector_child(
139
+ name, cast(OldPVIVector | FastCSPVIVector, entry)
140
+ )
83
141
  else:
84
- # This is a DeviceVector of children
85
- for i, e in enumerate(entry):
86
- if e:
87
- self._fill_child(name, e, i)
142
+ # This is a child
143
+ self._fill_child(name, cast(Entry, entry))
144
+
88
145
  # Check that all the requested children have been filled
89
146
  suffix = f"\n{self.error_hint}" if self.error_hint else ""
90
147
  self.filler.check_filled(f"{self.pvi_pv}: {entries}{suffix}")
@@ -1,10 +1,11 @@
1
- """EPICS Signals over CA or PVA"""
1
+ """EPICS Signals over CA or PVA."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
5
  from enum import Enum
6
6
 
7
7
  from ophyd_async.core import (
8
+ DEFAULT_TIMEOUT,
8
9
  SignalBackend,
9
10
  SignalDatatypeT,
10
11
  SignalR,
@@ -73,6 +74,7 @@ def get_signal_backend_type(protocol: EpicsProtocol) -> type[EpicsSignalBackend]
73
74
  return CaSignalBackend
74
75
  case EpicsProtocol.PVA:
75
76
  return PvaSignalBackend
77
+ raise TypeError(f"Unsupported protocol: {protocol}")
76
78
 
77
79
 
78
80
  def _epics_signal_backend(
@@ -91,20 +93,18 @@ def epics_signal_rw(
91
93
  read_pv: str,
92
94
  write_pv: str | None = None,
93
95
  name: str = "",
96
+ timeout: float = DEFAULT_TIMEOUT,
94
97
  ) -> SignalRW[SignalDatatypeT]:
95
- """Create a `SignalRW` backed by 1 or 2 EPICS PVs
96
-
97
- Parameters
98
- ----------
99
- datatype:
100
- Check that the PV is of this type
101
- read_pv:
102
- The PV to read and monitor
103
- write_pv:
104
- If given, use this PV to write to, otherwise use read_pv
98
+ """Create a `SignalRW` backed by 1 or 2 EPICS PVs.
99
+
100
+ :param datatype: Check that the PV is of this type
101
+ :param read_pv: The PV to read and monitor
102
+ :param write_pv: If given, use this PV to write to, otherwise use read_pv
103
+ :param name: The name of the signal (defaults to empty string)
104
+ :param timeout: A timeout to be used when reading (not connecting) this signal
105
105
  """
106
106
  backend = _epics_signal_backend(datatype, read_pv, write_pv or read_pv)
107
- return SignalRW(backend, name=name)
107
+ return SignalRW(backend, name=name, timeout=timeout)
108
108
 
109
109
 
110
110
  def epics_signal_rw_rbv(
@@ -112,67 +112,67 @@ def epics_signal_rw_rbv(
112
112
  write_pv: str,
113
113
  read_suffix: str = "_RBV",
114
114
  name: str = "",
115
+ timeout: float = DEFAULT_TIMEOUT,
115
116
  ) -> SignalRW[SignalDatatypeT]:
116
- """Create a `SignalRW` backed by 1 or 2 EPICS PVs, with a suffix on the readback pv
117
-
118
- Parameters
119
- ----------
120
- datatype:
121
- Check that the PV is of this type
122
- write_pv:
123
- The PV to write to
124
- read_suffix:
125
- Append this suffix to the write pv to create the readback pv
126
- """
117
+ """Create a `SignalRW` backed by 1 or 2 EPICS PVs, with a suffix on the readback pv.
127
118
 
119
+ :param datatype: Check that the PV is of this type
120
+ :param write_pv: The PV to write to
121
+ :param read_suffix: Append this suffix to the write pv to create the readback pv
122
+ :param name: The name of the signal (defaults to empty string)
123
+ :param timeout: A timeout to be used when reading (not connecting) this signal
124
+ """
128
125
  base_pv, field = get_pv_basename_and_field(write_pv)
129
126
  if field is not None:
130
127
  read_pv = f"{base_pv}{read_suffix}.{field}"
131
128
  else:
132
129
  read_pv = f"{write_pv}{read_suffix}"
133
130
 
134
- return epics_signal_rw(datatype, read_pv, write_pv, name)
131
+ return epics_signal_rw(datatype, read_pv, write_pv, name, timeout=timeout)
135
132
 
136
133
 
137
134
  def epics_signal_r(
138
- datatype: type[SignalDatatypeT], read_pv: str, name: str = ""
135
+ datatype: type[SignalDatatypeT],
136
+ read_pv: str,
137
+ name: str = "",
138
+ timeout: float = DEFAULT_TIMEOUT,
139
139
  ) -> SignalR[SignalDatatypeT]:
140
- """Create a `SignalR` backed by 1 EPICS PV
141
-
142
- Parameters
143
- ----------
144
- datatype
145
- Check that the PV is of this type
146
- read_pv:
147
- The PV to read and monitor
140
+ """Create a `SignalR` backed by 1 EPICS PV.
141
+
142
+ :param datatype: Check that the PV is of this type
143
+ :param read_pv: The PV to read from
144
+ :param name: The name of the signal (defaults to empty string)
145
+ :param timeout: A timeout to be used when reading (not connecting) this signal
148
146
  """
149
147
  backend = _epics_signal_backend(datatype, read_pv, read_pv)
150
- return SignalR(backend, name=name)
148
+ return SignalR(backend, name=name, timeout=timeout)
151
149
 
152
150
 
153
151
  def epics_signal_w(
154
- datatype: type[SignalDatatypeT], write_pv: str, name: str = ""
152
+ datatype: type[SignalDatatypeT],
153
+ write_pv: str,
154
+ name: str = "",
155
+ timeout: float = DEFAULT_TIMEOUT,
155
156
  ) -> SignalW[SignalDatatypeT]:
156
- """Create a `SignalW` backed by 1 EPICS PVs
157
-
158
- Parameters
159
- ----------
160
- datatype:
161
- Check that the PV is of this type
162
- write_pv:
163
- The PV to write to
157
+ """Create a `SignalW` backed by 1 EPICS PVs.
158
+
159
+ :param datatype: Check that the PV is of this type
160
+ :param write_pv: The PV to write to
161
+ :param name: The name of the signal (defaults to empty string)
162
+ :param timeout: A timeout to be used when reading (not connecting) this signal
164
163
  """
165
164
  backend = _epics_signal_backend(datatype, write_pv, write_pv)
166
- return SignalW(backend, name=name)
165
+ return SignalW(backend, name=name, timeout=timeout)
167
166
 
168
167
 
169
- def epics_signal_x(write_pv: str, name: str = "") -> SignalX:
170
- """Create a `SignalX` backed by 1 EPICS PVs
168
+ def epics_signal_x(
169
+ write_pv: str, name: str = "", timeout: float = DEFAULT_TIMEOUT
170
+ ) -> SignalX:
171
+ """Create a `SignalX` backed by 1 EPICS PVs.
171
172
 
172
- Parameters
173
- ----------
174
- write_pv:
175
- The PV to write its initial value to on trigger
173
+ :param write_pv: The PV to write its initial value to on trigger
174
+ :param name: The name of the signal
175
+ :param timeout: A timeout to be used when reading (not connecting) this signal
176
176
  """
177
177
  backend = _epics_signal_backend(None, write_pv, write_pv)
178
- return SignalX(backend, name=name)
178
+ return SignalX(backend, name=name, timeout=timeout)
@@ -13,12 +13,12 @@ from ophyd_async.core import (
13
13
 
14
14
 
15
15
  def get_pv_basename_and_field(pv: str) -> tuple[str, str | None]:
16
- """Simple utility function for extracting base pv name without field"""
17
-
16
+ """Split PV into record name and field."""
18
17
  if "." in pv:
19
- return (pv.split(".", -1)[0], pv.split(".", -1)[1])
18
+ record, field = pv.split(".", maxsplit=1)
20
19
  else:
21
- return (pv, None)
20
+ record, field = pv, None
21
+ return (record, field)
22
22
 
23
23
 
24
24
  def get_supported_values(
@@ -0,0 +1,16 @@
1
+ """Demo EPICS Devices for the tutorial."""
2
+
3
+ from ._ioc import start_ioc_subprocess
4
+ from ._motor import DemoMotor
5
+ from ._point_detector import DemoPointDetector
6
+ from ._point_detector_channel import DemoPointDetectorChannel, EnergyMode
7
+ from ._stage import DemoStage
8
+
9
+ __all__ = [
10
+ "DemoMotor",
11
+ "DemoStage",
12
+ "EnergyMode",
13
+ "DemoPointDetectorChannel",
14
+ "DemoPointDetector",
15
+ "start_ioc_subprocess",
16
+ ]
@@ -0,0 +1,31 @@
1
+ """Used for tutorial `Implementing Devices`."""
2
+
3
+ # Import bluesky and ophyd
4
+ import bluesky.plan_stubs as bps # noqa: F401
5
+ import bluesky.plans as bp # noqa: F401
6
+ from bluesky.callbacks.best_effort import BestEffortCallback
7
+ from bluesky.run_engine import RunEngine, autoawait_in_bluesky_event_loop
8
+
9
+ from ophyd_async.core import init_devices
10
+ from ophyd_async.epics import demo, testing
11
+
12
+ # Create a run engine and make ipython use it for `await` commands
13
+ RE = RunEngine(call_returns_result=True)
14
+ autoawait_in_bluesky_event_loop()
15
+
16
+ # Add a callback for plotting
17
+ bec = BestEffortCallback()
18
+ RE.subscribe(bec)
19
+
20
+ # Start IOC with demo pvs in subprocess
21
+ prefix = testing.generate_random_pv_prefix()
22
+ ioc = demo.start_ioc_subprocess(prefix, num_channels=3)
23
+
24
+ # All Devices created within this block will be
25
+ # connected and named at the end of the with block
26
+ with init_devices():
27
+ # Create a sample stage with X and Y motors
28
+ stage = demo.DemoStage(f"{prefix}STAGE:")
29
+ # Create a multi channel counter with the same number
30
+ # of counters as the IOC
31
+ pdet = demo.DemoPointDetector(f"{prefix}DET:", num_channels=3)
@@ -0,0 +1,32 @@
1
+ import atexit
2
+ from pathlib import Path
3
+
4
+ from ophyd_async.epics.testing import TestingIOC
5
+
6
+ HERE = Path(__file__).absolute().parent
7
+
8
+
9
+ def start_ioc_subprocess(prefix: str, num_channels: int) -> TestingIOC:
10
+ """Start an IOC subprocess for sample stage and sensor.
11
+
12
+ :param prefix: The prefix for the IOC PVs.
13
+ :param num_channels: The number of point detector channels to create.
14
+ """
15
+ ioc = TestingIOC()
16
+ # Create X and Y motors
17
+ for suffix in ["X", "Y"]:
18
+ ioc.add_database(HERE / "motor.db", P=f"{prefix}STAGE:{suffix}:")
19
+ # Create a multichannel counter with num_counters
20
+ ioc.add_database(HERE / "point_detector.db", P=f"{prefix}DET:")
21
+ for i in range(1, num_channels + 1):
22
+ ioc.add_database(
23
+ HERE / "point_detector_channel.db",
24
+ P=f"{prefix}DET:",
25
+ CHANNEL=str(i),
26
+ X=f"{prefix}STAGE:X:",
27
+ Y=f"{prefix}STAGE:Y:",
28
+ )
29
+ # Start IOC and register it to be stopped at exit
30
+ ioc.start()
31
+ atexit.register(ioc.stop)
32
+ return ioc
@@ -0,0 +1,82 @@
1
+ import asyncio
2
+ from typing import Annotated as A
3
+
4
+ import numpy as np
5
+ from bluesky.protocols import Movable, Stoppable
6
+
7
+ from ophyd_async.core import (
8
+ CALCULATE_TIMEOUT,
9
+ DEFAULT_TIMEOUT,
10
+ CalculatableTimeout,
11
+ SignalR,
12
+ SignalRW,
13
+ SignalX,
14
+ StandardReadable,
15
+ WatchableAsyncStatus,
16
+ WatcherUpdate,
17
+ observe_value,
18
+ )
19
+ from ophyd_async.core import StandardReadableFormat as Format
20
+ from ophyd_async.epics.core import EpicsDevice, PvSuffix
21
+
22
+
23
+ class DemoMotor(EpicsDevice, StandardReadable, Movable, Stoppable):
24
+ """A demo movable that moves based on velocity."""
25
+
26
+ # Whether set() should complete successfully or not
27
+ _set_success = True
28
+ # Define some signals
29
+ readback: A[SignalR[float], PvSuffix("Readback"), Format.HINTED_SIGNAL]
30
+ velocity: A[SignalRW[float], PvSuffix("Velocity"), Format.CONFIG_SIGNAL]
31
+ units: A[SignalR[str], PvSuffix("Readback.EGU"), Format.CONFIG_SIGNAL]
32
+ setpoint: A[SignalRW[float], PvSuffix("Setpoint")]
33
+ precision: A[SignalR[int], PvSuffix("Readback.PREC")]
34
+ # If a signal name clashes with a bluesky verb add _ to the attribute name
35
+ stop_: A[SignalX, PvSuffix("Stop.PROC")]
36
+
37
+ def set_name(self, name: str, *, child_name_separator: str | None = None) -> None:
38
+ super().set_name(name, child_name_separator=child_name_separator)
39
+ # Readback should be named the same as its parent in read()
40
+ self.readback.set_name(name)
41
+
42
+ @WatchableAsyncStatus.wrap
43
+ async def set( # type: ignore
44
+ self, new_position: float, timeout: CalculatableTimeout = CALCULATE_TIMEOUT
45
+ ):
46
+ # The move should complete successfully unless stop(success=False) is called
47
+ self._set_success = True
48
+ # Get some variables for the progress bar reporting
49
+ old_position, units, precision, velocity = await asyncio.gather(
50
+ self.setpoint.get_value(),
51
+ self.units.get_value(),
52
+ self.precision.get_value(),
53
+ self.velocity.get_value(),
54
+ )
55
+ # If not supplied, calculate a suitable timeout for the move
56
+ if timeout == CALCULATE_TIMEOUT:
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)
60
+ # Observe the readback Signal, and on each new position...
61
+ async for current_position in observe_value(
62
+ self.readback, done_timeout=timeout
63
+ ):
64
+ # Emit a progress bar update
65
+ yield WatcherUpdate(
66
+ current=current_position,
67
+ initial=old_position,
68
+ target=new_position,
69
+ name=self.name,
70
+ unit=units,
71
+ precision=precision,
72
+ )
73
+ # If we are at the desired position the break
74
+ if np.isclose(current_position, new_position):
75
+ break
76
+ # If we were told to stop and report an error then do so
77
+ if not self._set_success:
78
+ raise RuntimeError("Motor was stopped")
79
+
80
+ async def stop(self, success=True):
81
+ self._set_success = success
82
+ await self.stop_.trigger()
@@ -0,0 +1,42 @@
1
+ from typing import Annotated as A
2
+
3
+ from bluesky.protocols import Triggerable
4
+
5
+ from ophyd_async.core import (
6
+ DEFAULT_TIMEOUT,
7
+ AsyncStatus,
8
+ DeviceVector,
9
+ SignalR,
10
+ SignalRW,
11
+ SignalX,
12
+ StandardReadable,
13
+ )
14
+ from ophyd_async.core import StandardReadableFormat as Format
15
+ from ophyd_async.epics.core import EpicsDevice, PvSuffix
16
+
17
+ from ._point_detector_channel import DemoPointDetectorChannel
18
+
19
+
20
+ class DemoPointDetector(StandardReadable, EpicsDevice, Triggerable):
21
+ """A demo detector that produces a point values based on X and Y motors."""
22
+
23
+ acquire_time: A[SignalRW[float], PvSuffix("AcquireTime"), Format.CONFIG_SIGNAL]
24
+ start: A[SignalX, PvSuffix("Start.PROC")]
25
+ acquiring: A[SignalR[bool], PvSuffix("Acquiring")]
26
+ reset: A[SignalX, PvSuffix("Reset.PROC")]
27
+
28
+ def __init__(self, prefix: str, num_channels: int = 3, name: str = "") -> None:
29
+ with self.add_children_as_readables():
30
+ self.channel = DeviceVector(
31
+ {
32
+ i: DemoPointDetectorChannel(f"{prefix}{i}:")
33
+ for i in range(1, num_channels + 1)
34
+ }
35
+ )
36
+ super().__init__(prefix=prefix, name=name)
37
+
38
+ @AsyncStatus.wrap
39
+ async def trigger(self):
40
+ await self.reset.trigger()
41
+ timeout = await self.acquire_time.get_value() + DEFAULT_TIMEOUT
42
+ await self.start.trigger(timeout=timeout)