ophyd-async 0.8.0a5__py3-none-any.whl → 0.9.0__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 (116) hide show
  1. ophyd_async/_version.py +2 -2
  2. ophyd_async/core/__init__.py +17 -46
  3. ophyd_async/core/_detector.py +68 -44
  4. ophyd_async/core/_device.py +120 -79
  5. ophyd_async/core/_device_filler.py +17 -8
  6. ophyd_async/core/_flyer.py +2 -2
  7. ophyd_async/core/_protocol.py +0 -28
  8. ophyd_async/core/_readable.py +30 -23
  9. ophyd_async/core/_settings.py +104 -0
  10. ophyd_async/core/_signal.py +164 -151
  11. ophyd_async/core/_signal_backend.py +4 -1
  12. ophyd_async/core/_soft_signal_backend.py +2 -1
  13. ophyd_async/core/_table.py +27 -14
  14. ophyd_async/core/_utils.py +30 -5
  15. ophyd_async/core/_yaml_settings.py +64 -0
  16. ophyd_async/epics/adandor/__init__.py +9 -0
  17. ophyd_async/epics/adandor/_andor.py +45 -0
  18. ophyd_async/epics/adandor/_andor_controller.py +49 -0
  19. ophyd_async/epics/adandor/_andor_io.py +36 -0
  20. ophyd_async/epics/adaravis/__init__.py +3 -1
  21. ophyd_async/epics/adaravis/_aravis.py +23 -37
  22. ophyd_async/epics/adaravis/_aravis_controller.py +21 -30
  23. ophyd_async/epics/adaravis/_aravis_io.py +4 -4
  24. ophyd_async/epics/adcore/__init__.py +15 -8
  25. ophyd_async/epics/adcore/_core_detector.py +41 -0
  26. ophyd_async/epics/adcore/_core_io.py +56 -31
  27. ophyd_async/epics/adcore/_core_logic.py +99 -84
  28. ophyd_async/epics/adcore/_core_writer.py +219 -0
  29. ophyd_async/epics/adcore/_hdf_writer.py +33 -59
  30. ophyd_async/epics/adcore/_jpeg_writer.py +26 -0
  31. ophyd_async/epics/adcore/_single_trigger.py +5 -4
  32. ophyd_async/epics/adcore/_tiff_writer.py +26 -0
  33. ophyd_async/epics/adcore/_utils.py +37 -36
  34. ophyd_async/epics/adkinetix/_kinetix.py +29 -24
  35. ophyd_async/epics/adkinetix/_kinetix_controller.py +15 -27
  36. ophyd_async/epics/adkinetix/_kinetix_io.py +7 -7
  37. ophyd_async/epics/adpilatus/__init__.py +2 -2
  38. ophyd_async/epics/adpilatus/_pilatus.py +28 -40
  39. ophyd_async/epics/adpilatus/_pilatus_controller.py +47 -25
  40. ophyd_async/epics/adpilatus/_pilatus_io.py +5 -5
  41. ophyd_async/epics/adsimdetector/__init__.py +3 -3
  42. ophyd_async/epics/adsimdetector/_sim.py +33 -17
  43. ophyd_async/epics/advimba/_vimba.py +23 -23
  44. ophyd_async/epics/advimba/_vimba_controller.py +21 -35
  45. ophyd_async/epics/advimba/_vimba_io.py +23 -23
  46. ophyd_async/epics/core/_aioca.py +52 -21
  47. ophyd_async/epics/core/_p4p.py +59 -16
  48. ophyd_async/epics/core/_pvi_connector.py +4 -2
  49. ophyd_async/epics/core/_signal.py +9 -2
  50. ophyd_async/epics/core/_util.py +10 -1
  51. ophyd_async/epics/eiger/_eiger_controller.py +10 -5
  52. ophyd_async/epics/eiger/_eiger_io.py +3 -3
  53. ophyd_async/epics/motor.py +26 -15
  54. ophyd_async/epics/sim/_ioc.py +29 -0
  55. ophyd_async/epics/{demo → sim}/_mover.py +12 -6
  56. ophyd_async/epics/{demo → sim}/_sensor.py +2 -2
  57. ophyd_async/epics/testing/__init__.py +24 -0
  58. ophyd_async/epics/testing/_example_ioc.py +91 -0
  59. ophyd_async/epics/testing/_utils.py +50 -0
  60. ophyd_async/epics/testing/test_records.db +174 -0
  61. ophyd_async/epics/testing/test_records_pva.db +177 -0
  62. ophyd_async/fastcs/core.py +2 -2
  63. ophyd_async/fastcs/panda/__init__.py +0 -2
  64. ophyd_async/fastcs/panda/_block.py +9 -9
  65. ophyd_async/fastcs/panda/_control.py +9 -4
  66. ophyd_async/fastcs/panda/_hdf_panda.py +7 -2
  67. ophyd_async/fastcs/panda/_table.py +4 -1
  68. ophyd_async/fastcs/panda/_trigger.py +7 -7
  69. ophyd_async/plan_stubs/__init__.py +14 -0
  70. ophyd_async/plan_stubs/_ensure_connected.py +11 -17
  71. ophyd_async/plan_stubs/_fly.py +2 -2
  72. ophyd_async/plan_stubs/_nd_attributes.py +7 -5
  73. ophyd_async/plan_stubs/_panda.py +13 -0
  74. ophyd_async/plan_stubs/_settings.py +125 -0
  75. ophyd_async/plan_stubs/_wait_for_awaitable.py +13 -0
  76. ophyd_async/sim/__init__.py +19 -0
  77. ophyd_async/sim/{demo/_pattern_detector → _pattern_detector}/_pattern_detector_controller.py +9 -2
  78. ophyd_async/sim/{demo/_pattern_detector → _pattern_detector}/_pattern_generator.py +13 -6
  79. ophyd_async/sim/{demo/_sim_motor.py → _sim_motor.py} +34 -32
  80. ophyd_async/tango/__init__.py +0 -43
  81. ophyd_async/tango/{signal → core}/__init__.py +7 -2
  82. ophyd_async/tango/{base_devices → core}/_base_device.py +38 -64
  83. ophyd_async/tango/{signal → core}/_signal.py +16 -4
  84. ophyd_async/tango/{base_devices → core}/_tango_readable.py +3 -4
  85. ophyd_async/tango/{signal → core}/_tango_transport.py +13 -15
  86. ophyd_async/tango/{demo → sim}/_counter.py +6 -7
  87. ophyd_async/tango/{demo → sim}/_mover.py +13 -9
  88. ophyd_async/testing/__init__.py +52 -0
  89. ophyd_async/testing/__pytest_assert_rewrite.py +4 -0
  90. ophyd_async/testing/_assert.py +176 -0
  91. ophyd_async/{core → testing}/_mock_signal_utils.py +15 -11
  92. ophyd_async/testing/_one_of_everything.py +126 -0
  93. ophyd_async/testing/_wait_for_pending.py +22 -0
  94. {ophyd_async-0.8.0a5.dist-info → ophyd_async-0.9.0.dist-info}/METADATA +50 -48
  95. ophyd_async-0.9.0.dist-info/RECORD +129 -0
  96. {ophyd_async-0.8.0a5.dist-info → ophyd_async-0.9.0.dist-info}/WHEEL +1 -1
  97. ophyd_async/core/_device_save_loader.py +0 -274
  98. ophyd_async/epics/adsimdetector/_sim_controller.py +0 -51
  99. ophyd_async/fastcs/panda/_utils.py +0 -16
  100. ophyd_async/sim/demo/__init__.py +0 -19
  101. ophyd_async/sim/testing/__init__.py +0 -0
  102. ophyd_async/tango/base_devices/__init__.py +0 -4
  103. ophyd_async-0.8.0a5.dist-info/RECORD +0 -112
  104. ophyd_async-0.8.0a5.dist-info/entry_points.txt +0 -2
  105. /ophyd_async/epics/{demo → sim}/__init__.py +0 -0
  106. /ophyd_async/epics/{demo → sim}/mover.db +0 -0
  107. /ophyd_async/epics/{demo → sim}/sensor.db +0 -0
  108. /ophyd_async/sim/{demo/_pattern_detector → _pattern_detector}/__init__.py +0 -0
  109. /ophyd_async/sim/{demo/_pattern_detector → _pattern_detector}/_pattern_detector.py +0 -0
  110. /ophyd_async/sim/{demo/_pattern_detector → _pattern_detector}/_pattern_detector_writer.py +0 -0
  111. /ophyd_async/tango/{demo → sim}/__init__.py +0 -0
  112. /ophyd_async/tango/{demo → sim}/_detector.py +0 -0
  113. /ophyd_async/tango/{demo → sim}/_tango/__init__.py +0 -0
  114. /ophyd_async/tango/{demo → sim}/_tango/_servers.py +0 -0
  115. {ophyd_async-0.8.0a5.dist-info → ophyd_async-0.9.0.dist-info}/LICENSE +0 -0
  116. {ophyd_async-0.8.0a5.dist-info → ophyd_async-0.9.0.dist-info}/top_level.txt +0 -0
@@ -31,6 +31,8 @@ from ophyd_async.core import (
31
31
 
32
32
  from ._util import EpicsSignalBackend, format_datatype, get_supported_values
33
33
 
34
+ logger = logging.getLogger("ophyd_async")
35
+
34
36
 
35
37
  def _limits_from_value(value: Any) -> Limits:
36
38
  def get_limits(
@@ -39,7 +41,7 @@ def _limits_from_value(value: Any) -> Limits:
39
41
  substructure = getattr(value, substucture_name, None)
40
42
  low = getattr(substructure, low_name, nan)
41
43
  high = getattr(substructure, high_name, nan)
42
- if not (isnan(low) and isnan(high)):
44
+ if not (isnan(low) and isnan(high)) and not low == high == 0:
43
45
  return LimitsRange(
44
46
  low=None if isnan(low) else low,
45
47
  high=None if isnan(high) else high,
@@ -60,12 +62,22 @@ def _limits_from_value(value: Any) -> Limits:
60
62
  def _metadata_from_value(datatype: type[SignalDatatype], value: Any) -> SignalMetadata:
61
63
  metadata = SignalMetadata()
62
64
  value_data: Any = getattr(value, "value", None)
65
+ specifier = _get_specifier(value)
63
66
  display_data: Any = getattr(value, "display", None)
64
- if hasattr(display_data, "units"):
67
+ if (
68
+ hasattr(display_data, "units")
69
+ and specifier[-1] in _number_specifiers
70
+ and datatype is not str
71
+ ):
65
72
  metadata["units"] = display_data.units
66
- if hasattr(display_data, "precision") and not isnan(display_data.precision):
73
+ if (
74
+ hasattr(display_data, "precision")
75
+ and not isnan(display_data.precision)
76
+ and specifier[-1] in _float_specifiers
77
+ and datatype is not int
78
+ ):
67
79
  metadata["precision"] = display_data.precision
68
- if limits := _limits_from_value(value):
80
+ if (limits := _limits_from_value(value)) and specifier[-1] in _number_specifiers:
69
81
  metadata["limits"] = limits
70
82
  # Get choices from display or value
71
83
  if datatype is str or issubclass(datatype, StrictEnum):
@@ -84,9 +96,7 @@ class PvaConverter(Generic[SignalDatatypeT]):
84
96
  self.datatype = datatype
85
97
 
86
98
  def value(self, value: Any) -> SignalDatatypeT:
87
- # for channel access ca_xxx classes, this
88
- # invokes __pos__ operator to return an instance of
89
- # the builtin base class
99
+ # Normally the value will be of the correct python type
90
100
  return value["value"]
91
101
 
92
102
  def write_value(self, value: Any) -> Any:
@@ -94,6 +104,31 @@ class PvaConverter(Generic[SignalDatatypeT]):
94
104
  return value
95
105
 
96
106
 
107
+ class PvaIntConverter(PvaConverter[int]):
108
+ def __init__(self):
109
+ super().__init__(int)
110
+
111
+ def value(self, value: Any) -> int:
112
+ # Convert to an int
113
+ return int(value["value"])
114
+
115
+
116
+ class PvaLongStringConverter(PvaConverter[str]):
117
+ def __init__(self):
118
+ super().__init__(str)
119
+
120
+ def value(self, value: Any) -> Any:
121
+ # Value here is a null terminated array of ascii codes.
122
+ # We strip out the null terminator, and convert each code
123
+ # to the corresponding char, joining into a string
124
+ return value["value"].tobytes().rstrip(b"\0").decode()
125
+
126
+ def write_value(self, value: Any) -> Any:
127
+ # Inverse of reading - convert each character into it's ascii code,
128
+ # put into a list, and add null terminator.
129
+ return np.frombuffer(str(value).encode() + b"\0", dtype=np.int8)
130
+
131
+
97
132
  class DisconnectedPvaConverter(PvaConverter):
98
133
  def __getattribute__(self, __name: str) -> Any:
99
134
  raise NotImplementedError("No PV has been set as connect() has not been called")
@@ -158,6 +193,9 @@ class PvaTableConverter(PvaConverter[Table]):
158
193
 
159
194
 
160
195
  # https://mdavidsaver.github.io/p4p/values.html
196
+ _float_specifiers = {"f", "d"}
197
+ _int_specifiers = {"b", "B", "h", "H", "i", "I", "l", "L"}
198
+ _number_specifiers = _float_specifiers.union(_int_specifiers)
161
199
  _datatype_converter_from_typeid: dict[
162
200
  tuple[str, str], tuple[type[SignalDatatype], type[PvaConverter]]
163
201
  ] = {
@@ -192,7 +230,7 @@ _datatype_converter_from_typeid: dict[
192
230
  }
193
231
 
194
232
 
195
- def _get_specifier(value: Value):
233
+ def _get_specifier(value: Value) -> str:
196
234
  typ = value.type("value").aspy()
197
235
  if isinstance(typ, tuple):
198
236
  return typ[0]
@@ -242,7 +280,7 @@ def make_converter(datatype: type | None, values: dict[str, Any]) -> PvaConverte
242
280
  == 0
243
281
  ):
244
282
  # Allow int signals to represent float records when prec is 0
245
- return PvaConverter(int)
283
+ return PvaIntConverter()
246
284
  elif inferred_datatype is str and (enum_cls := get_enum_cls(datatype)):
247
285
  # Allow strings to be used as enums until QSRV supports this
248
286
  return PvaConverter(str)
@@ -252,6 +290,9 @@ def make_converter(datatype: type | None, values: dict[str, Any]) -> PvaConverte
252
290
  elif datatype in (None, inferred_datatype):
253
291
  # If datatype matches what we are given then allow it and use inferred converter
254
292
  return converter_cls(inferred_datatype)
293
+ # Allow waveforms with FTVL=CHAR to be treated as str when requested
294
+ elif datatype is str and inferred_datatype == Array1D[np.int8]:
295
+ return PvaLongStringConverter()
255
296
  raise TypeError(
256
297
  f"{pv} with inferred datatype {format_datatype(inferred_datatype)}"
257
298
  f" from {typeid=} {specifier=}"
@@ -282,7 +323,7 @@ async def pvget_with_timeout(pv: str, timeout: float) -> Any:
282
323
  try:
283
324
  return await asyncio.wait_for(context().get(pv), timeout=timeout)
284
325
  except asyncio.TimeoutError as exc:
285
- logging.debug(f"signal pva://{pv} timed out", exc_info=True)
326
+ logger.debug(f"signal pva://{pv} timed out", exc_info=True)
286
327
  raise NotConnected(f"pva://{pv}") from exc
287
328
 
288
329
 
@@ -364,10 +405,15 @@ class PvaSignalBackend(EpicsSignalBackend[SignalDatatypeT]):
364
405
  return self.converter.value(value)
365
406
 
366
407
  def set_callback(self, callback: Callback[Reading[SignalDatatypeT]] | None) -> None:
408
+ if callback and self.subscription:
409
+ msg = "Cannot set a callback when one is already set"
410
+ raise RuntimeError(msg)
411
+
412
+ if self.subscription:
413
+ self.subscription.close()
414
+ self.subscription = None
415
+
367
416
  if callback:
368
- assert (
369
- not self.subscription
370
- ), "Cannot set a callback when one is already set"
371
417
 
372
418
  async def async_callback(v):
373
419
  callback(self._make_reading(v))
@@ -378,6 +424,3 @@ class PvaSignalBackend(EpicsSignalBackend[SignalDatatypeT]):
378
424
  self.subscription = context().monitor(
379
425
  self.read_pv, async_callback, request=request
380
426
  )
381
- elif self.subscription:
382
- self.subscription.close()
383
- self.subscription = None
@@ -32,10 +32,11 @@ def _get_signal_details(entry: Entry) -> tuple[type[Signal], str, str]:
32
32
 
33
33
 
34
34
  class PviDeviceConnector(DeviceConnector):
35
- def __init__(self, prefix: str = "") -> None:
35
+ def __init__(self, prefix: str = "", error_hint: str = "") -> None:
36
36
  # TODO: what happens if we get a leading "pva://" here?
37
37
  self.prefix = prefix
38
38
  self.pvi_pv = prefix + "PVI"
39
+ self.error_hint = error_hint
39
40
 
40
41
  def create_children_from_annotations(self, device: Device):
41
42
  if not hasattr(self, "filler"):
@@ -85,7 +86,8 @@ class PviDeviceConnector(DeviceConnector):
85
86
  if e:
86
87
  self._fill_child(name, e, i)
87
88
  # Check that all the requested children have been filled
88
- self.filler.check_filled(f"{self.pvi_pv}: {entries}")
89
+ suffix = f"\n{self.error_hint}" if self.error_hint else ""
90
+ self.filler.check_filled(f"{self.pvi_pv}: {entries}{suffix}")
89
91
  # Set the name of the device to name all children
90
92
  device.set_name(device.name)
91
93
  return await super().connect_real(device, timeout, force_reconnect)
@@ -14,7 +14,7 @@ from ophyd_async.core import (
14
14
  get_unique,
15
15
  )
16
16
 
17
- from ._util import EpicsSignalBackend
17
+ from ._util import EpicsSignalBackend, get_pv_basename_and_field
18
18
 
19
19
 
20
20
  class EpicsProtocol(Enum):
@@ -124,7 +124,14 @@ def epics_signal_rw_rbv(
124
124
  read_suffix:
125
125
  Append this suffix to the write pv to create the readback pv
126
126
  """
127
- return epics_signal_rw(datatype, f"{write_pv}{read_suffix}", write_pv, name)
127
+
128
+ base_pv, field = get_pv_basename_and_field(write_pv)
129
+ if field is not None:
130
+ read_pv = f"{base_pv}{read_suffix}.{field}"
131
+ else:
132
+ read_pv = f"{write_pv}{read_suffix}"
133
+
134
+ return epics_signal_rw(datatype, read_pv, write_pv, name)
128
135
 
129
136
 
130
137
  def epics_signal_r(
@@ -12,6 +12,15 @@ from ophyd_async.core import (
12
12
  )
13
13
 
14
14
 
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
+
18
+ if "." in pv:
19
+ return (pv.split(".", -1)[0], pv.split(".", -1)[1])
20
+ else:
21
+ return (pv, None)
22
+
23
+
15
24
  def get_supported_values(
16
25
  pv: str,
17
26
  datatype: type,
@@ -38,7 +47,7 @@ def get_supported_values(
38
47
 
39
48
 
40
49
  def format_datatype(datatype: Any) -> str:
41
- if get_origin(datatype) is np.ndarray and get_args(datatype)[0] == tuple[int]:
50
+ if get_origin(datatype) is np.ndarray and get_args(datatype):
42
51
  dtype = get_dtype(datatype)
43
52
  return f"Array1D[np.{dtype.name}]"
44
53
  elif get_origin(datatype) is Sequence:
@@ -11,10 +11,10 @@ from ophyd_async.core import (
11
11
  from ._eiger_io import EigerDriverIO, EigerTriggerMode
12
12
 
13
13
  EIGER_TRIGGER_MODE_MAP = {
14
- DetectorTrigger.internal: EigerTriggerMode.internal,
15
- DetectorTrigger.constant_gate: EigerTriggerMode.gate,
16
- DetectorTrigger.variable_gate: EigerTriggerMode.gate,
17
- DetectorTrigger.edge_trigger: EigerTriggerMode.edge,
14
+ DetectorTrigger.INTERNAL: EigerTriggerMode.INTERNAL,
15
+ DetectorTrigger.CONSTANT_GATE: EigerTriggerMode.GATE,
16
+ DetectorTrigger.VARIABLE_GATE: EigerTriggerMode.GATE,
17
+ DetectorTrigger.EDGE_TRIGGER: EigerTriggerMode.EDGE,
18
18
  }
19
19
 
20
20
 
@@ -55,7 +55,12 @@ class EigerController(DetectorController):
55
55
  async def arm(self):
56
56
  # TODO: Detector state should be an enum see https://github.com/DiamondLightSource/eiger-fastcs/issues/43
57
57
  self._arm_status = set_and_wait_for_other_value(
58
- self._drv.arm, 1, self._drv.state, "ready", timeout=DEFAULT_TIMEOUT
58
+ self._drv.arm,
59
+ 1,
60
+ self._drv.state,
61
+ "ready",
62
+ timeout=DEFAULT_TIMEOUT,
63
+ wait_for_set_completion=False,
59
64
  )
60
65
 
61
66
  async def wait_for_idle(self):
@@ -3,9 +3,9 @@ from ophyd_async.epics.core import epics_signal_r, epics_signal_rw_rbv, epics_si
3
3
 
4
4
 
5
5
  class EigerTriggerMode(StrictEnum):
6
- internal = "ints"
7
- edge = "exts"
8
- gate = "exte"
6
+ INTERNAL = "ints"
7
+ EDGE = "exts"
8
+ GATE = "exte"
9
9
 
10
10
 
11
11
  class EigerDriverIO(Device):
@@ -20,7 +20,7 @@ from ophyd_async.core import (
20
20
  observe_value,
21
21
  )
22
22
  from ophyd_async.core import StandardReadableFormat as Format
23
- from ophyd_async.epics.core import epics_signal_r, epics_signal_rw, epics_signal_x
23
+ from ophyd_async.epics.core import epics_signal_r, epics_signal_rw, epics_signal_w
24
24
 
25
25
 
26
26
  class MotorLimitsException(Exception):
@@ -63,6 +63,7 @@ class Motor(StandardReadable, Locatable, Stoppable, Flyable, Preparable):
63
63
  with self.add_children_as_readables(Format.CONFIG_SIGNAL):
64
64
  self.motor_egu = epics_signal_r(str, prefix + ".EGU")
65
65
  self.velocity = epics_signal_rw(float, prefix + ".VELO")
66
+ self.offset = epics_signal_rw(float, prefix + ".OFF")
66
67
 
67
68
  with self.add_children_as_readables(Format.HINTED_SIGNAL):
68
69
  self.user_readback = epics_signal_r(float, prefix + ".RBV")
@@ -76,7 +77,10 @@ class Motor(StandardReadable, Locatable, Stoppable, Flyable, Preparable):
76
77
  self.low_limit_travel = epics_signal_rw(float, prefix + ".LLM")
77
78
  self.high_limit_travel = epics_signal_rw(float, prefix + ".HLM")
78
79
 
79
- self.motor_stop = epics_signal_x(prefix + ".STOP")
80
+ # Note:cannot use epics_signal_x here, as the motor record specifies that
81
+ # we must write 1 to stop the motor. Simply processing the record is not
82
+ # sufficient.
83
+ self.motor_stop = epics_signal_w(int, prefix + ".STOP")
80
84
  # Whether set() should complete successfully or not
81
85
  self._set_success = True
82
86
 
@@ -91,8 +95,8 @@ class Motor(StandardReadable, Locatable, Stoppable, Flyable, Preparable):
91
95
 
92
96
  super().__init__(name=name)
93
97
 
94
- def set_name(self, name: str):
95
- super().set_name(name)
98
+ def set_name(self, name: str, *, child_name_separator: str | None = None) -> None:
99
+ super().set_name(name, child_name_separator=child_name_separator)
96
100
  # Readback should be named the same as its parent in read()
97
101
  self.user_readback.set_name(name)
98
102
 
@@ -122,9 +126,9 @@ class Motor(StandardReadable, Locatable, Stoppable, Flyable, Preparable):
122
126
  @AsyncStatus.wrap
123
127
  async def kickoff(self):
124
128
  """Begin moving motor from prepared position to final position."""
125
- assert (
126
- self._fly_completed_position
127
- ), "Motor must be prepared before attempting to kickoff"
129
+ if not self._fly_completed_position:
130
+ msg = "Motor must be prepared before attempting to kickoff"
131
+ raise RuntimeError(msg)
128
132
 
129
133
  self._fly_status = self.set(
130
134
  self._fly_completed_position, timeout=self._fly_timeout
@@ -132,7 +136,9 @@ class Motor(StandardReadable, Locatable, Stoppable, Flyable, Preparable):
132
136
 
133
137
  def complete(self) -> WatchableAsyncStatus:
134
138
  """Mark as complete once motor reaches completed position."""
135
- assert self._fly_status, "kickoff not called"
139
+ if not self._fly_status:
140
+ msg = "kickoff not called"
141
+ raise RuntimeError(msg)
136
142
  return self._fly_status
137
143
 
138
144
  @WatchableAsyncStatus.wrap
@@ -152,13 +158,18 @@ class Motor(StandardReadable, Locatable, Stoppable, Flyable, Preparable):
152
158
  self.velocity.get_value(),
153
159
  self.acceleration_time.get_value(),
154
160
  )
161
+
155
162
  if timeout is CALCULATE_TIMEOUT:
156
- assert velocity > 0, "Motor has zero velocity"
157
- timeout = (
158
- abs(new_position - old_position) / velocity
159
- + 2 * acceleration_time
160
- + DEFAULT_TIMEOUT
161
- )
163
+ try:
164
+ timeout = (
165
+ abs((new_position - old_position) / velocity)
166
+ + 2 * acceleration_time
167
+ + DEFAULT_TIMEOUT
168
+ )
169
+ except ZeroDivisionError as error:
170
+ msg = "Mover has zero velocity"
171
+ raise ValueError(msg) from error
172
+
162
173
  move_status = self.user_setpoint.set(new_position, wait=True, timeout=timeout)
163
174
  async for current_position in observe_value(
164
175
  self.user_readback, done_status=move_status
@@ -178,7 +189,7 @@ class Motor(StandardReadable, Locatable, Stoppable, Flyable, Preparable):
178
189
  self._set_success = success
179
190
  # Put with completion will never complete as we are waiting for completion on
180
191
  # the move above, so need to pass wait=False
181
- await self.motor_stop.trigger(wait=False)
192
+ await self.motor_stop.set(1, wait=False)
182
193
 
183
194
  async def _prepare_velocity(
184
195
  self, start_position: float, end_position: float, time_for_move: float
@@ -0,0 +1,29 @@
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_counters: int):
10
+ """Start an IOC subprocess with EPICS database for sample stage and sensor
11
+ with the same pv prefix
12
+ """
13
+ ioc = TestingIOC()
14
+ # Create X and Y motors
15
+ for suffix in ["X", "Y"]:
16
+ ioc.add_database(HERE / "mover.db", P=f"{prefix}STAGE:{suffix}:")
17
+ # Create a multichannel counter with num_counters
18
+ ioc.add_database(HERE / "multichannelcounter.db", P=f"{prefix}MCC:")
19
+ for i in range(1, num_counters + 1):
20
+ ioc.add_database(
21
+ HERE / "counter.db",
22
+ P=f"{prefix}MCC:",
23
+ CHANNEL=str(i),
24
+ X=f"{prefix}STAGE:X:",
25
+ Y=f"{prefix}STAGE:Y:",
26
+ )
27
+ # Start IOC and register it to be stopped at exit
28
+ ioc.start()
29
+ atexit.register(ioc.stop)
@@ -37,8 +37,8 @@ class Mover(StandardReadable, Movable, Stoppable):
37
37
 
38
38
  super().__init__(name=name)
39
39
 
40
- def set_name(self, name: str):
41
- super().set_name(name)
40
+ def set_name(self, name: str, *, child_name_separator: str | None = None) -> None:
41
+ super().set_name(name, child_name_separator=child_name_separator)
42
42
  # Readback should be named the same as its parent in read()
43
43
  self.readback.set_name(name)
44
44
 
@@ -52,13 +52,19 @@ class Mover(StandardReadable, Movable, Stoppable):
52
52
  self.precision.get_value(),
53
53
  self.velocity.get_value(),
54
54
  )
55
- if timeout == CALCULATE_TIMEOUT:
56
- assert velocity > 0, "Mover has zero velocity"
57
- timeout = abs(new_position - old_position) / velocity + DEFAULT_TIMEOUT
55
+ if timeout is CALCULATE_TIMEOUT:
56
+ try:
57
+ timeout = (
58
+ abs((new_position - old_position) / velocity) + DEFAULT_TIMEOUT
59
+ )
60
+ except ZeroDivisionError as error:
61
+ msg = "Mover has zero velocity"
62
+ raise ValueError(msg) from error
63
+
58
64
  # Make an Event that will be set on completion, and a Status that will
59
65
  # error if not done in time
60
66
  done = asyncio.Event()
61
- done_status = AsyncStatus(asyncio.wait_for(done.wait(), timeout))
67
+ done_status = AsyncStatus(asyncio.wait_for(done.wait(), timeout)) # type: ignore
62
68
  # Wait for the value to set, but don't wait for put completion callback
63
69
  await self.setpoint.set(new_position, wait=False)
64
70
  async for current_position in observe_value(
@@ -15,9 +15,9 @@ class EnergyMode(StrictEnum):
15
15
  """Energy mode for `Sensor`"""
16
16
 
17
17
  #: Low energy mode
18
- low = "Low Energy"
18
+ LOW = "Low Energy"
19
19
  #: High energy mode
20
- high = "High Energy"
20
+ HIGH = "High Energy"
21
21
 
22
22
 
23
23
  class Sensor(StandardReadable, EpicsDevice):
@@ -0,0 +1,24 @@
1
+ from ._example_ioc import (
2
+ CA_PVA_RECORDS,
3
+ PVA_RECORDS,
4
+ EpicsTestCaDevice,
5
+ EpicsTestEnum,
6
+ EpicsTestIocAndDevices,
7
+ EpicsTestPvaDevice,
8
+ EpicsTestSubsetEnum,
9
+ EpicsTestTable,
10
+ )
11
+ from ._utils import TestingIOC, generate_random_pv_prefix
12
+
13
+ __all__ = [
14
+ "CA_PVA_RECORDS",
15
+ "PVA_RECORDS",
16
+ "EpicsTestCaDevice",
17
+ "EpicsTestEnum",
18
+ "EpicsTestSubsetEnum",
19
+ "EpicsTestPvaDevice",
20
+ "EpicsTestTable",
21
+ "EpicsTestIocAndDevices",
22
+ "TestingIOC",
23
+ "generate_random_pv_prefix",
24
+ ]
@@ -0,0 +1,91 @@
1
+ from collections.abc import Sequence
2
+ from pathlib import Path
3
+ from typing import Annotated as A
4
+
5
+ import numpy as np
6
+
7
+ from ophyd_async.core import Array1D, SignalR, SignalRW, StrictEnum, Table
8
+ from ophyd_async.core._utils import SubsetEnum
9
+ from ophyd_async.epics.core import EpicsDevice, PvSuffix
10
+
11
+ from ._utils import TestingIOC, generate_random_pv_prefix
12
+
13
+ CA_PVA_RECORDS = Path(__file__).parent / "test_records.db"
14
+ PVA_RECORDS = Path(__file__).parent / "test_records_pva.db"
15
+
16
+
17
+ class EpicsTestEnum(StrictEnum):
18
+ A = "Aaa"
19
+ B = "Bbb"
20
+ C = "Ccc"
21
+
22
+
23
+ class EpicsTestSubsetEnum(SubsetEnum):
24
+ A = "Aaa"
25
+ B = "Bbb"
26
+
27
+
28
+ class EpicsTestTable(Table):
29
+ bool: Array1D[np.bool_]
30
+ int: Array1D[np.int32]
31
+ float: Array1D[np.float64]
32
+ str: Sequence[str]
33
+ enum: Sequence[EpicsTestEnum]
34
+
35
+
36
+ class EpicsTestCaDevice(EpicsDevice):
37
+ my_int: A[SignalRW[int], PvSuffix("int")]
38
+ my_float: A[SignalRW[float], PvSuffix("float")]
39
+ float_prec_0: A[SignalRW[int], PvSuffix("float_prec_0")]
40
+ my_str: A[SignalRW[str], PvSuffix("str")]
41
+ longstr: A[SignalRW[str], PvSuffix("longstr")]
42
+ longstr2: A[SignalRW[str], PvSuffix("longstr2.VAL$")]
43
+ my_bool: A[SignalRW[bool], PvSuffix("bool")]
44
+ enum: A[SignalRW[EpicsTestEnum], PvSuffix("enum")]
45
+ enum2: A[SignalRW[EpicsTestEnum], PvSuffix("enum2")]
46
+ subset_enum: A[SignalRW[EpicsTestSubsetEnum], PvSuffix("subset_enum")]
47
+ enum_str_fallback: A[SignalRW[str], PvSuffix("enum_str_fallback")]
48
+ bool_unnamed: A[SignalRW[bool], PvSuffix("bool_unnamed")]
49
+ partialint: A[SignalRW[int], PvSuffix("partialint")]
50
+ lessint: A[SignalRW[int], PvSuffix("lessint")]
51
+ uint8a: A[SignalRW[Array1D[np.uint8]], PvSuffix("uint8a")]
52
+ int16a: A[SignalRW[Array1D[np.int16]], PvSuffix("int16a")]
53
+ int32a: A[SignalRW[Array1D[np.int32]], PvSuffix("int32a")]
54
+ float32a: A[SignalRW[Array1D[np.float32]], PvSuffix("float32a")]
55
+ float64a: A[SignalRW[Array1D[np.float64]], PvSuffix("float64a")]
56
+ stra: A[SignalRW[Sequence[str]], PvSuffix("stra")]
57
+
58
+
59
+ class EpicsTestPvaDevice(EpicsTestCaDevice):
60
+ # pva can support all signal types that ca can
61
+ int8a: A[SignalRW[Array1D[np.int8]], PvSuffix("int8a")]
62
+ uint16a: A[SignalRW[Array1D[np.uint16]], PvSuffix("uint16a")]
63
+ uint32a: A[SignalRW[Array1D[np.uint32]], PvSuffix("uint32a")]
64
+ int64a: A[SignalRW[Array1D[np.int64]], PvSuffix("int64a")]
65
+ uint64a: A[SignalRW[Array1D[np.uint64]], PvSuffix("uint64a")]
66
+ table: A[SignalRW[EpicsTestTable], PvSuffix("table")]
67
+ ntndarray: A[SignalR[np.ndarray], PvSuffix("ntndarray")]
68
+
69
+
70
+ class EpicsTestIocAndDevices:
71
+ def __init__(self):
72
+ self.prefix = generate_random_pv_prefix()
73
+ self.ioc = TestingIOC()
74
+ # Create supporting records and ExampleCaDevice
75
+ ca_prefix = f"{self.prefix}ca:"
76
+ self.ioc.add_database(CA_PVA_RECORDS, device=ca_prefix)
77
+ self.ca_device = EpicsTestCaDevice(f"ca://{ca_prefix}")
78
+ # Create supporting records and ExamplePvaDevice
79
+ pva_prefix = f"{self.prefix}pva:"
80
+ self.ioc.add_database(CA_PVA_RECORDS, device=pva_prefix)
81
+ self.ioc.add_database(PVA_RECORDS, device=pva_prefix)
82
+ self.pva_device = EpicsTestPvaDevice(f"pva://{pva_prefix}")
83
+
84
+ def get_device(self, protocol: str) -> EpicsTestCaDevice | EpicsTestPvaDevice:
85
+ return getattr(self, f"{protocol}_device")
86
+
87
+ def get_signal(self, protocol: str, name: str) -> SignalRW:
88
+ return getattr(self.get_device(protocol), name)
89
+
90
+ def get_pv(self, protocol: str, name: str) -> str:
91
+ return f"{protocol}://{self.prefix}{protocol}:{name}"
@@ -0,0 +1,50 @@
1
+ import random
2
+ import string
3
+ import subprocess
4
+ import sys
5
+ import time
6
+ from pathlib import Path
7
+
8
+
9
+ def generate_random_pv_prefix() -> str:
10
+ return "".join(random.choice(string.ascii_lowercase) for _ in range(12)) + ":"
11
+
12
+
13
+ class TestingIOC:
14
+ def __init__(self):
15
+ self._db_macros: list[tuple[Path, dict[str, str]]] = []
16
+ self.output = ""
17
+
18
+ def add_database(self, db: Path | str, /, **macros: str):
19
+ self._db_macros.append((Path(db), macros))
20
+
21
+ def start(self):
22
+ ioc_args = [
23
+ sys.executable,
24
+ "-m",
25
+ "epicscorelibs.ioc",
26
+ ]
27
+ for db, macros in self._db_macros:
28
+ macro_str = ",".join(f"{k}={v}" for k, v in macros.items())
29
+ ioc_args += ["-m", macro_str, "-d", str(db)]
30
+ self._process = subprocess.Popen(
31
+ ioc_args,
32
+ stdin=subprocess.PIPE,
33
+ stdout=subprocess.PIPE,
34
+ stderr=subprocess.STDOUT,
35
+ universal_newlines=True,
36
+ )
37
+ assert self._process.stdout # noqa: S101 # this is to make Pylance happy
38
+ start_time = time.monotonic()
39
+ while "iocRun: All initialization complete" not in self.output:
40
+ if time.monotonic() - start_time > 10:
41
+ self.stop()
42
+ raise TimeoutError(f"IOC did not start in time:\n{self.output}")
43
+ self.output += self._process.stdout.readline()
44
+
45
+ def stop(self):
46
+ try:
47
+ self.output += self._process.communicate("exit()")[0]
48
+ except ValueError:
49
+ # Someone else already called communicate
50
+ pass