ophyd-async 0.8.0a6__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 (110) hide show
  1. ophyd_async/_version.py +2 -2
  2. ophyd_async/core/__init__.py +15 -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 +91 -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 +18 -10
  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 -86
  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 +4 -4
  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 +14 -14
  58. ophyd_async/epics/testing/_example_ioc.py +53 -67
  59. ophyd_async/epics/testing/_utils.py +17 -45
  60. ophyd_async/epics/testing/test_records.db +22 -0
  61. ophyd_async/fastcs/core.py +2 -2
  62. ophyd_async/fastcs/panda/__init__.py +0 -2
  63. ophyd_async/fastcs/panda/_block.py +9 -9
  64. ophyd_async/fastcs/panda/_control.py +9 -4
  65. ophyd_async/fastcs/panda/_hdf_panda.py +7 -2
  66. ophyd_async/fastcs/panda/_table.py +4 -1
  67. ophyd_async/fastcs/panda/_trigger.py +7 -7
  68. ophyd_async/plan_stubs/__init__.py +14 -0
  69. ophyd_async/plan_stubs/_ensure_connected.py +11 -17
  70. ophyd_async/plan_stubs/_fly.py +2 -2
  71. ophyd_async/plan_stubs/_nd_attributes.py +7 -5
  72. ophyd_async/plan_stubs/_panda.py +13 -0
  73. ophyd_async/plan_stubs/_settings.py +125 -0
  74. ophyd_async/plan_stubs/_wait_for_awaitable.py +13 -0
  75. ophyd_async/sim/__init__.py +19 -0
  76. ophyd_async/sim/{demo/_pattern_detector → _pattern_detector}/_pattern_detector_controller.py +9 -2
  77. ophyd_async/sim/{demo/_pattern_detector → _pattern_detector}/_pattern_generator.py +13 -6
  78. ophyd_async/sim/{demo/_sim_motor.py → _sim_motor.py} +34 -32
  79. ophyd_async/tango/core/_signal.py +3 -1
  80. ophyd_async/tango/core/_tango_transport.py +13 -15
  81. ophyd_async/tango/{demo → sim}/_mover.py +5 -2
  82. ophyd_async/testing/__init__.py +52 -0
  83. ophyd_async/testing/__pytest_assert_rewrite.py +4 -0
  84. ophyd_async/testing/_assert.py +176 -0
  85. ophyd_async/{core → testing}/_mock_signal_utils.py +15 -11
  86. ophyd_async/testing/_one_of_everything.py +126 -0
  87. ophyd_async/testing/_wait_for_pending.py +22 -0
  88. {ophyd_async-0.8.0a6.dist-info → ophyd_async-0.9.0.dist-info}/METADATA +4 -2
  89. ophyd_async-0.9.0.dist-info/RECORD +129 -0
  90. {ophyd_async-0.8.0a6.dist-info → ophyd_async-0.9.0.dist-info}/WHEEL +1 -1
  91. ophyd_async/core/_device_save_loader.py +0 -274
  92. ophyd_async/epics/adsimdetector/_sim_controller.py +0 -51
  93. ophyd_async/fastcs/panda/_utils.py +0 -16
  94. ophyd_async/sim/demo/__init__.py +0 -19
  95. ophyd_async/sim/testing/__init__.py +0 -0
  96. ophyd_async-0.8.0a6.dist-info/RECORD +0 -116
  97. ophyd_async-0.8.0a6.dist-info/entry_points.txt +0 -2
  98. /ophyd_async/epics/{demo → sim}/__init__.py +0 -0
  99. /ophyd_async/epics/{demo → sim}/mover.db +0 -0
  100. /ophyd_async/epics/{demo → sim}/sensor.db +0 -0
  101. /ophyd_async/sim/{demo/_pattern_detector → _pattern_detector}/__init__.py +0 -0
  102. /ophyd_async/sim/{demo/_pattern_detector → _pattern_detector}/_pattern_detector.py +0 -0
  103. /ophyd_async/sim/{demo/_pattern_detector → _pattern_detector}/_pattern_detector_writer.py +0 -0
  104. /ophyd_async/tango/{demo → sim}/__init__.py +0 -0
  105. /ophyd_async/tango/{demo → sim}/_counter.py +0 -0
  106. /ophyd_async/tango/{demo → sim}/_detector.py +0 -0
  107. /ophyd_async/tango/{demo → sim}/_tango/__init__.py +0 -0
  108. /ophyd_async/tango/{demo → sim}/_tango/_servers.py +0 -0
  109. {ophyd_async-0.8.0a6.dist-info → ophyd_async-0.9.0.dist-info}/LICENSE +0 -0
  110. {ophyd_async-0.8.0a6.dist-info → ophyd_async-0.9.0.dist-info}/top_level.txt +0 -0
@@ -1,8 +1,6 @@
1
1
  import asyncio
2
2
 
3
3
  from ophyd_async.core import (
4
- AsyncStatus,
5
- DetectorController,
6
4
  DetectorTrigger,
7
5
  TriggerInfo,
8
6
  )
@@ -11,56 +9,44 @@ from ophyd_async.epics import adcore
11
9
  from ._vimba_io import VimbaDriverIO, VimbaExposeOutMode, VimbaOnOff, VimbaTriggerSource
12
10
 
13
11
  TRIGGER_MODE = {
14
- DetectorTrigger.internal: VimbaOnOff.off,
15
- DetectorTrigger.constant_gate: VimbaOnOff.on,
16
- DetectorTrigger.variable_gate: VimbaOnOff.on,
17
- DetectorTrigger.edge_trigger: VimbaOnOff.on,
12
+ DetectorTrigger.INTERNAL: VimbaOnOff.OFF,
13
+ DetectorTrigger.CONSTANT_GATE: VimbaOnOff.ON,
14
+ DetectorTrigger.VARIABLE_GATE: VimbaOnOff.ON,
15
+ DetectorTrigger.EDGE_TRIGGER: VimbaOnOff.ON,
18
16
  }
19
17
 
20
18
  EXPOSE_OUT_MODE = {
21
- DetectorTrigger.internal: VimbaExposeOutMode.timed,
22
- DetectorTrigger.constant_gate: VimbaExposeOutMode.trigger_width,
23
- DetectorTrigger.variable_gate: VimbaExposeOutMode.trigger_width,
24
- DetectorTrigger.edge_trigger: VimbaExposeOutMode.timed,
19
+ DetectorTrigger.INTERNAL: VimbaExposeOutMode.TIMED,
20
+ DetectorTrigger.CONSTANT_GATE: VimbaExposeOutMode.TRIGGER_WIDTH,
21
+ DetectorTrigger.VARIABLE_GATE: VimbaExposeOutMode.TRIGGER_WIDTH,
22
+ DetectorTrigger.EDGE_TRIGGER: VimbaExposeOutMode.TIMED,
25
23
  }
26
24
 
27
25
 
28
- class VimbaController(DetectorController):
26
+ class VimbaController(adcore.ADBaseController[VimbaDriverIO]):
29
27
  def __init__(
30
28
  self,
31
29
  driver: VimbaDriverIO,
30
+ good_states: frozenset[adcore.DetectorState] = adcore.DEFAULT_GOOD_STATES,
32
31
  ) -> None:
33
- self._drv = driver
34
- self._arm_status: AsyncStatus | None = None
32
+ super().__init__(driver, good_states=good_states)
35
33
 
36
34
  def get_deadtime(self, exposure: float | None) -> float:
37
35
  return 0.001
38
36
 
39
37
  async def prepare(self, trigger_info: TriggerInfo):
40
38
  await asyncio.gather(
41
- self._drv.trigger_mode.set(TRIGGER_MODE[trigger_info.trigger]),
42
- self._drv.exposure_mode.set(EXPOSE_OUT_MODE[trigger_info.trigger]),
43
- self._drv.num_images.set(trigger_info.total_number_of_triggers),
44
- self._drv.image_mode.set(adcore.ImageMode.multiple),
39
+ self.driver.trigger_mode.set(TRIGGER_MODE[trigger_info.trigger]),
40
+ self.driver.exposure_mode.set(EXPOSE_OUT_MODE[trigger_info.trigger]),
41
+ self.driver.num_images.set(trigger_info.total_number_of_triggers),
42
+ self.driver.image_mode.set(adcore.ImageMode.MULTIPLE),
45
43
  )
46
44
  if trigger_info.livetime is not None and trigger_info.trigger not in [
47
- DetectorTrigger.variable_gate,
48
- DetectorTrigger.constant_gate,
45
+ DetectorTrigger.VARIABLE_GATE,
46
+ DetectorTrigger.CONSTANT_GATE,
49
47
  ]:
50
- await self._drv.acquire_time.set(trigger_info.livetime)
51
- if trigger_info.trigger != DetectorTrigger.internal:
52
- self._drv.trigger_source.set(VimbaTriggerSource.line1)
48
+ await self.driver.acquire_time.set(trigger_info.livetime)
49
+ if trigger_info.trigger != DetectorTrigger.INTERNAL:
50
+ self.driver.trigger_source.set(VimbaTriggerSource.LINE1)
53
51
  else:
54
- self._drv.trigger_source.set(VimbaTriggerSource.freerun)
55
-
56
- async def arm(self):
57
- self._arm_status = await adcore.start_acquiring_driver_and_ensure_status(
58
- self._drv
59
- )
60
-
61
- async def wait_for_idle(self):
62
- if self._arm_status:
63
- await self._arm_status
64
-
65
- async def disarm(self):
66
- await adcore.stop_busy_record(self._drv.acquire, False, timeout=1)
52
+ self.driver.trigger_source.set(VimbaTriggerSource.FREERUN)
@@ -4,44 +4,44 @@ from ophyd_async.epics.core import epics_signal_rw_rbv
4
4
 
5
5
 
6
6
  class VimbaPixelFormat(StrictEnum):
7
- internal = "Mono8"
8
- ext_enable = "Mono12"
9
- ext_trigger = "Ext. Trigger"
10
- mult_trigger = "Mult. Trigger"
11
- alignment = "Alignment"
7
+ INTERNAL = "Mono8"
8
+ EXT_ENABLE = "Mono12"
9
+ EXT_TRIGGER = "Ext. Trigger"
10
+ MULT_TRIGGER = "Mult. Trigger"
11
+ ALIGNMENT = "Alignment"
12
12
 
13
13
 
14
14
  class VimbaConvertFormat(StrictEnum):
15
- none = "None"
16
- mono8 = "Mono8"
17
- mono16 = "Mono16"
18
- rgb8 = "RGB8"
19
- rgb16 = "RGB16"
15
+ NONE = "None"
16
+ MONO8 = "Mono8"
17
+ MONO16 = "Mono16"
18
+ RGB8 = "RGB8"
19
+ RGB16 = "RGB16"
20
20
 
21
21
 
22
22
  class VimbaTriggerSource(StrictEnum):
23
- freerun = "Freerun"
24
- line1 = "Line1"
25
- line2 = "Line2"
26
- fixed_rate = "FixedRate"
27
- software = "Software"
28
- action0 = "Action0"
29
- action1 = "Action1"
23
+ FREERUN = "Freerun"
24
+ LINE1 = "Line1"
25
+ LINE2 = "Line2"
26
+ FIXED_RATE = "FixedRate"
27
+ SOFTWARE = "Software"
28
+ ACTION0 = "Action0"
29
+ ACTION1 = "Action1"
30
30
 
31
31
 
32
32
  class VimbaOverlap(StrictEnum):
33
- off = "Off"
34
- prev_frame = "PreviousFrame"
33
+ OFF = "Off"
34
+ PREV_FRAME = "PreviousFrame"
35
35
 
36
36
 
37
37
  class VimbaOnOff(StrictEnum):
38
- on = "On"
39
- off = "Off"
38
+ ON = "On"
39
+ OFF = "Off"
40
40
 
41
41
 
42
42
  class VimbaExposeOutMode(StrictEnum):
43
- timed = "Timed" # Use ExposureTime PV
44
- trigger_width = "TriggerWidth" # Expose for length of high signal
43
+ TIMED = "Timed" # Use ExposureTime PV
44
+ TRIGGER_WIDTH = "TriggerWidth" # Expose for length of high signal
45
45
 
46
46
 
47
47
  class VimbaDriverIO(adcore.ADBaseIO):
@@ -35,12 +35,14 @@ from ophyd_async.core import (
35
35
 
36
36
  from ._util import EpicsSignalBackend, format_datatype, get_supported_values
37
37
 
38
+ logger = logging.getLogger("ophyd_async")
39
+
38
40
 
39
41
  def _limits_from_augmented_value(value: AugmentedValue) -> Limits:
40
42
  def get_limits(limit: str) -> LimitsRange | None:
41
43
  low = getattr(value, f"lower_{limit}_limit", nan)
42
44
  high = getattr(value, f"upper_{limit}_limit", nan)
43
- if not (isnan(low) and isnan(high)):
45
+ if not (isnan(low) and isnan(high)) and not high == low == 0:
44
46
  return LimitsRange(
45
47
  low=None if isnan(low) else low,
46
48
  high=None if isnan(high) else high,
@@ -59,14 +61,20 @@ def _limits_from_augmented_value(value: AugmentedValue) -> Limits:
59
61
 
60
62
 
61
63
  def _metadata_from_augmented_value(
62
- value: AugmentedValue, metadata: SignalMetadata
64
+ datatype: type[SignalDatatypeT] | None,
65
+ value: AugmentedValue,
66
+ metadata: SignalMetadata,
63
67
  ) -> SignalMetadata:
64
68
  metadata = metadata.copy()
65
- if hasattr(value, "units"):
69
+ if hasattr(value, "units") and datatype not in (str, bool):
66
70
  metadata["units"] = value.units
67
- if hasattr(value, "precision") and not isnan(value.precision):
71
+ if (
72
+ hasattr(value, "precision")
73
+ and not isnan(value.precision)
74
+ and datatype is not int
75
+ ):
68
76
  metadata["precision"] = value.precision
69
- if limits := _limits_from_augmented_value(value):
77
+ if (limits := _limits_from_augmented_value(value)) and datatype is not bool:
70
78
  metadata["limits"] = limits
71
79
  return metadata
72
80
 
@@ -100,6 +108,11 @@ class DisconnectedCaConverter(CaConverter):
100
108
  raise NotImplementedError("No PV has been set as connect() has not been called")
101
109
 
102
110
 
111
+ class CaIntConverter(CaConverter[int]):
112
+ def value(self, value: AugmentedValue) -> int:
113
+ return int(value) # type: ignore
114
+
115
+
103
116
  class CaArrayConverter(CaConverter[np.ndarray]):
104
117
  def value(self, value: AugmentedValue) -> np.ndarray:
105
118
  # A less expensive conversion
@@ -202,7 +215,7 @@ def make_converter(
202
215
  and get_unique({k: v.precision for k, v in values.items()}, "precision") == 0
203
216
  ):
204
217
  # Allow int signals to represent float records when prec is 0
205
- return CaConverter(int, pv_dbr)
218
+ return CaIntConverter(int, pv_dbr)
206
219
  elif datatype in (None, inferred_datatype):
207
220
  # If datatype matches what we are given then allow it and use inferred converter
208
221
  return converter_cls(inferred_datatype, pv_dbr)
@@ -247,7 +260,7 @@ class CaSignalBackend(EpicsSignalBackend[SignalDatatypeT]):
247
260
  pv, format=FORMAT_CTRL, timeout=timeout
248
261
  )
249
262
  except CANothing as exc:
250
- logging.debug(f"signal ca://{pv} timed out")
263
+ logger.debug(f"signal ca://{pv} timed out")
251
264
  raise NotConnected(f"ca://{pv}") from exc
252
265
 
253
266
  async def connect(self, timeout: float):
@@ -280,17 +293,33 @@ class CaSignalBackend(EpicsSignalBackend[SignalDatatypeT]):
280
293
  write_value = self.initial_values[self.write_pv]
281
294
  else:
282
295
  write_value = self.converter.write_value(value)
283
- await caput(
284
- self.write_pv,
285
- write_value,
286
- datatype=self.converter.write_dbr,
287
- wait=wait,
288
- timeout=None,
289
- )
296
+ try:
297
+ await caput(
298
+ self.write_pv,
299
+ write_value,
300
+ datatype=self.converter.write_dbr,
301
+ wait=wait,
302
+ timeout=None,
303
+ )
304
+ except CANothing as exc:
305
+ # If we ran into a write error, check to see if there is a list
306
+ # of valid choices, and if the value we tried to write is in that list.
307
+ valid_choices = self.converter.metadata.get("choices")
308
+ if valid_choices:
309
+ if value not in valid_choices:
310
+ msg = (
311
+ f"{value} is not a valid choice for {self.write_pv}, "
312
+ f"valid choices: {self.converter.metadata.get('choices')}"
313
+ )
314
+ raise ValueError(msg) from exc
315
+ raise
316
+ raise
290
317
 
291
318
  async def get_datakey(self, source: str) -> DataKey:
292
319
  value = await self._caget(self.read_pv, FORMAT_CTRL)
293
- metadata = _metadata_from_augmented_value(value, self.converter.metadata)
320
+ metadata = _metadata_from_augmented_value(
321
+ self.datatype, value, self.converter.metadata
322
+ )
294
323
  return make_datakey(
295
324
  self.converter.datatype, self.converter.value(value), source, metadata
296
325
  )
@@ -308,16 +337,18 @@ class CaSignalBackend(EpicsSignalBackend[SignalDatatypeT]):
308
337
  return self.converter.value(value)
309
338
 
310
339
  def set_callback(self, callback: Callback[Reading[SignalDatatypeT]] | None) -> None:
340
+ if callback and self.subscription:
341
+ msg = "Cannot set a callback when one is already set"
342
+ raise RuntimeError(msg)
343
+
344
+ if self.subscription:
345
+ self.subscription.close()
346
+ self.subscription = None
347
+
311
348
  if callback:
312
- assert (
313
- not self.subscription
314
- ), "Cannot set a callback when one is already set"
315
349
  self.subscription = camonitor(
316
350
  self.read_pv,
317
351
  lambda v: callback(self._make_reading(v)),
318
352
  datatype=self.converter.read_dbr,
319
353
  format=FORMAT_TIME,
320
354
  )
321
- elif self.subscription:
322
- self.subscription.close()
323
- self.subscription = None
@@ -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
 
@@ -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