ophyd-async 0.5.2__py3-none-any.whl → 0.6.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 (66) hide show
  1. ophyd_async/__init__.py +10 -1
  2. ophyd_async/__main__.py +12 -4
  3. ophyd_async/_version.py +2 -2
  4. ophyd_async/core/__init__.py +11 -3
  5. ophyd_async/core/_detector.py +72 -63
  6. ophyd_async/core/_device.py +13 -15
  7. ophyd_async/core/_device_save_loader.py +30 -19
  8. ophyd_async/core/_flyer.py +6 -4
  9. ophyd_async/core/_hdf_dataset.py +8 -9
  10. ophyd_async/core/_log.py +3 -1
  11. ophyd_async/core/_mock_signal_backend.py +11 -9
  12. ophyd_async/core/_mock_signal_utils.py +8 -5
  13. ophyd_async/core/_protocol.py +7 -7
  14. ophyd_async/core/_providers.py +11 -11
  15. ophyd_async/core/_readable.py +30 -22
  16. ophyd_async/core/_signal.py +52 -51
  17. ophyd_async/core/_signal_backend.py +20 -7
  18. ophyd_async/core/_soft_signal_backend.py +62 -32
  19. ophyd_async/core/_status.py +7 -9
  20. ophyd_async/core/_table.py +63 -0
  21. ophyd_async/core/_utils.py +24 -28
  22. ophyd_async/epics/adaravis/_aravis_controller.py +17 -16
  23. ophyd_async/epics/adaravis/_aravis_io.py +2 -1
  24. ophyd_async/epics/adcore/_core_io.py +2 -0
  25. ophyd_async/epics/adcore/_core_logic.py +2 -3
  26. ophyd_async/epics/adcore/_hdf_writer.py +19 -8
  27. ophyd_async/epics/adcore/_single_trigger.py +1 -1
  28. ophyd_async/epics/adcore/_utils.py +5 -6
  29. ophyd_async/epics/adkinetix/_kinetix_controller.py +19 -14
  30. ophyd_async/epics/adpilatus/_pilatus_controller.py +18 -16
  31. ophyd_async/epics/adsimdetector/_sim.py +6 -5
  32. ophyd_async/epics/adsimdetector/_sim_controller.py +20 -15
  33. ophyd_async/epics/advimba/_vimba_controller.py +21 -16
  34. ophyd_async/epics/demo/_mover.py +4 -5
  35. ophyd_async/epics/demo/sensor.db +0 -1
  36. ophyd_async/epics/eiger/_eiger.py +1 -1
  37. ophyd_async/epics/eiger/_eiger_controller.py +16 -16
  38. ophyd_async/epics/eiger/_odin_io.py +6 -5
  39. ophyd_async/epics/motor.py +8 -10
  40. ophyd_async/epics/pvi/_pvi.py +30 -33
  41. ophyd_async/epics/signal/_aioca.py +55 -25
  42. ophyd_async/epics/signal/_common.py +3 -10
  43. ophyd_async/epics/signal/_epics_transport.py +11 -8
  44. ophyd_async/epics/signal/_p4p.py +79 -30
  45. ophyd_async/epics/signal/_signal.py +6 -8
  46. ophyd_async/fastcs/panda/__init__.py +0 -6
  47. ophyd_async/fastcs/panda/_control.py +14 -15
  48. ophyd_async/fastcs/panda/_hdf_panda.py +11 -4
  49. ophyd_async/fastcs/panda/_table.py +111 -138
  50. ophyd_async/fastcs/panda/_trigger.py +1 -2
  51. ophyd_async/fastcs/panda/_utils.py +3 -2
  52. ophyd_async/fastcs/panda/_writer.py +28 -13
  53. ophyd_async/plan_stubs/_fly.py +16 -16
  54. ophyd_async/plan_stubs/_nd_attributes.py +12 -6
  55. ophyd_async/sim/demo/_pattern_detector/_pattern_detector.py +3 -3
  56. ophyd_async/sim/demo/_pattern_detector/_pattern_detector_controller.py +24 -20
  57. ophyd_async/sim/demo/_pattern_detector/_pattern_detector_writer.py +9 -6
  58. ophyd_async/sim/demo/_pattern_detector/_pattern_generator.py +21 -23
  59. ophyd_async/sim/demo/_sim_motor.py +2 -1
  60. {ophyd_async-0.5.2.dist-info → ophyd_async-0.6.0.dist-info}/METADATA +46 -45
  61. ophyd_async-0.6.0.dist-info/RECORD +96 -0
  62. {ophyd_async-0.5.2.dist-info → ophyd_async-0.6.0.dist-info}/WHEEL +1 -1
  63. ophyd_async-0.5.2.dist-info/RECORD +0 -95
  64. {ophyd_async-0.5.2.dist-info → ophyd_async-0.6.0.dist-info}/LICENSE +0 -0
  65. {ophyd_async-0.5.2.dist-info → ophyd_async-0.6.0.dist-info}/entry_points.txt +0 -0
  66. {ophyd_async-0.5.2.dist-info → ophyd_async-0.6.0.dist-info}/top_level.txt +0 -0
ophyd_async/__init__.py CHANGED
@@ -1,3 +1,12 @@
1
+ """Top level API.
2
+
3
+ .. data:: __version__
4
+ :type: str
5
+
6
+ Version number as calculated by https://github.com/pypa/setuptools_scm
7
+ """
8
+
9
+ from . import core
1
10
  from ._version import __version__
2
11
 
3
- __all__ = ["__version__"]
12
+ __all__ = ["__version__", "core"]
ophyd_async/__main__.py CHANGED
@@ -1,16 +1,24 @@
1
+ """Interface for ``python -m ophyd_async``."""
2
+
1
3
  from argparse import ArgumentParser
4
+ from collections.abc import Sequence
2
5
 
3
6
  from . import __version__
4
7
 
5
8
  __all__ = ["main"]
6
9
 
7
10
 
8
- def main(args=None):
11
+ def main(args: Sequence[str] | None = None) -> None:
12
+ """Argument parser for the CLI."""
9
13
  parser = ArgumentParser()
10
- parser.add_argument("-v", "--version", action="version", version=__version__)
11
- args = parser.parse_args(args)
14
+ parser.add_argument(
15
+ "-v",
16
+ "--version",
17
+ action="version",
18
+ version=__version__,
19
+ )
20
+ parser.parse_args(args)
12
21
 
13
22
 
14
- # test with: python -m ophyd_async
15
23
  if __name__ == "__main__":
16
24
  main()
ophyd_async/_version.py CHANGED
@@ -12,5 +12,5 @@ __version__: str
12
12
  __version_tuple__: VERSION_TUPLE
13
13
  version_tuple: VERSION_TUPLE
14
14
 
15
- __version__ = version = '0.5.2'
16
- __version_tuple__ = version_tuple = (0, 5, 2)
15
+ __version__ = version = '0.6.0'
16
+ __version_tuple__ = version_tuple = (0, 6, 0)
@@ -61,13 +61,18 @@ from ._signal import (
61
61
  soft_signal_rw,
62
62
  wait_for_value,
63
63
  )
64
- from ._signal_backend import RuntimeSubsetEnum, SignalBackend, SubsetEnum
64
+ from ._signal_backend import (
65
+ RuntimeSubsetEnum,
66
+ SignalBackend,
67
+ SubsetEnum,
68
+ )
65
69
  from ._soft_signal_backend import SignalMetadata, SoftSignalBackend
66
70
  from ._status import AsyncStatus, WatchableAsyncStatus, completed_status
71
+ from ._table import Table
67
72
  from ._utils import (
73
+ CALCULATE_TIMEOUT,
68
74
  DEFAULT_TIMEOUT,
69
75
  CalculatableTimeout,
70
- CalculateTimeout,
71
76
  NotConnected,
72
77
  ReadingValueCallback,
73
78
  T,
@@ -75,6 +80,7 @@ from ._utils import (
75
80
  get_dtype,
76
81
  get_unique,
77
82
  in_micros,
83
+ is_pydantic_model,
78
84
  wait_for_connection,
79
85
  )
80
86
 
@@ -149,14 +155,16 @@ __all__ = [
149
155
  "WatchableAsyncStatus",
150
156
  "DEFAULT_TIMEOUT",
151
157
  "CalculatableTimeout",
152
- "CalculateTimeout",
158
+ "CALCULATE_TIMEOUT",
153
159
  "NotConnected",
154
160
  "ReadingValueCallback",
161
+ "Table",
155
162
  "T",
156
163
  "WatcherUpdate",
157
164
  "get_dtype",
158
165
  "get_unique",
159
166
  "in_micros",
167
+ "is_pydantic_model",
160
168
  "wait_for_connection",
161
169
  "completed_status",
162
170
  ]
@@ -3,21 +3,14 @@
3
3
  import asyncio
4
4
  import time
5
5
  from abc import ABC, abstractmethod
6
+ from collections.abc import AsyncGenerator, AsyncIterator, Callable, Sequence
6
7
  from enum import Enum
7
8
  from typing import (
8
- AsyncGenerator,
9
- AsyncIterator,
10
- Callable,
11
- Dict,
12
9
  Generic,
13
- List,
14
- Optional,
15
- Sequence,
16
10
  )
17
11
 
18
12
  from bluesky.protocols import (
19
13
  Collectable,
20
- DataKey,
21
14
  Flyable,
22
15
  Preparable,
23
16
  Reading,
@@ -26,10 +19,12 @@ from bluesky.protocols import (
26
19
  Triggerable,
27
20
  WritesStreamAssets,
28
21
  )
22
+ from event_model import DataKey
29
23
  from pydantic import BaseModel, Field
30
24
 
31
25
  from ._device import Device
32
26
  from ._protocol import AsyncConfigurable, AsyncReadable
27
+ from ._signal import SignalR
33
28
  from ._status import AsyncStatus, WatchableAsyncStatus
34
29
  from ._utils import DEFAULT_TIMEOUT, T, WatcherUpdate, merge_gathered_dicts
35
30
 
@@ -51,20 +46,24 @@ class TriggerInfo(BaseModel):
51
46
  """Minimal set of information required to setup triggering on a detector"""
52
47
 
53
48
  #: Number of triggers that will be sent, 0 means infinite
54
- number: int = Field(gt=0)
49
+ number: int = Field(ge=0)
55
50
  #: Sort of triggers that will be sent
56
- trigger: DetectorTrigger = Field()
51
+ trigger: DetectorTrigger = Field(default=DetectorTrigger.internal)
57
52
  #: What is the minimum deadtime between triggers
58
- deadtime: float | None = Field(ge=0)
53
+ deadtime: float | None = Field(default=None, ge=0)
59
54
  #: What is the maximum high time of the triggers
60
- livetime: float | None = Field(ge=0)
55
+ livetime: float | None = Field(default=None, ge=0)
61
56
  #: What is the maximum timeout on waiting for a frame
62
- frame_timeout: float | None = Field(None, gt=0)
57
+ frame_timeout: float | None = Field(default=None, gt=0)
63
58
  #: How many triggers make up a single StreamDatum index, to allow multiple frames
64
59
  #: from a faster detector to be zipped with a single frame from a slow detector
65
60
  #: e.g. if num=10 and multiplier=5 then the detector will take 10 frames,
66
61
  #: but publish 2 indices, and describe() will show a shape of (5, h, w)
67
62
  multiplier: int = 1
63
+ #: The number of times the detector can go through a complete cycle of kickoff and
64
+ #: complete without needing to re-arm. This is important for detectors where the
65
+ #: process of arming is expensive in terms of time
66
+ iteration: int = 1
68
67
 
69
68
 
70
69
  class DetectorControl(ABC):
@@ -78,27 +77,35 @@ class DetectorControl(ABC):
78
77
  """For a given exposure, how long should the time between exposures be"""
79
78
 
80
79
  @abstractmethod
81
- async def arm(
82
- self,
83
- num: int,
84
- trigger: DetectorTrigger = DetectorTrigger.internal,
85
- exposure: Optional[float] = None,
86
- ) -> AsyncStatus:
80
+ async def prepare(self, trigger_info: TriggerInfo):
87
81
  """
88
- Arm detector, do all necessary steps to prepare detector for triggers.
82
+ Do all necessary steps to prepare the detector for triggers.
89
83
 
90
84
  Args:
91
- num: Expected number of frames
92
- trigger: Type of trigger for which to prepare the detector. Defaults to
93
- DetectorTrigger.internal.
94
- exposure: Exposure time with which to set up the detector. Defaults to None
95
- if not applicable or the detector is expected to use its previously-set
96
- exposure time.
85
+ trigger_info: This is a Pydantic model which contains
86
+ number Expected number of frames.
87
+ trigger Type of trigger for which to prepare the detector. Defaults
88
+ to DetectorTrigger.internal.
89
+ livetime Livetime / Exposure time with which to set up the detector.
90
+ Defaults to None
91
+ if not applicable or the detector is expected to use its previously-set
92
+ exposure time.
93
+ deadtime Defaults to None. This is the minimum deadtime between
94
+ triggers.
95
+ multiplier The number of triggers grouped into a single StreamDatum
96
+ index.
97
+ """
97
98
 
98
- Returns:
99
- AsyncStatus: Status representing the arm operation. This function returning
100
- represents the start of the arm. The returned status completing means
101
- the detector is now armed.
99
+ @abstractmethod
100
+ async def arm(self) -> None:
101
+ """
102
+ Arm the detector
103
+ """
104
+
105
+ @abstractmethod
106
+ async def wait_for_idle(self):
107
+ """
108
+ This will wait on the internal _arm_status and wait for it to get disarmed/idle
102
109
  """
103
110
 
104
111
  @abstractmethod
@@ -111,7 +118,7 @@ class DetectorWriter(ABC):
111
118
  (e.g. an HDF5 file)"""
112
119
 
113
120
  @abstractmethod
114
- async def open(self, multiplier: int = 1) -> Dict[str, DataKey]:
121
+ async def open(self, multiplier: int = 1) -> dict[str, DataKey]:
115
122
  """Open writer and wait for it to be ready for data.
116
123
 
117
124
  Args:
@@ -162,7 +169,7 @@ class StandardDetector(
162
169
  self,
163
170
  controller: DetectorControl,
164
171
  writer: DetectorWriter,
165
- config_sigs: Sequence[AsyncReadable] = (),
172
+ config_sigs: Sequence[SignalR] = (),
166
173
  name: str = "",
167
174
  ) -> None:
168
175
  """
@@ -177,16 +184,16 @@ class StandardDetector(
177
184
  """
178
185
  self._controller = controller
179
186
  self._writer = writer
180
- self._describe: Dict[str, DataKey] = {}
187
+ self._describe: dict[str, DataKey] = {}
181
188
  self._config_sigs = list(config_sigs)
182
189
  # For prepare
183
- self._arm_status: Optional[AsyncStatus] = None
184
- self._trigger_info: Optional[TriggerInfo] = None
190
+ self._arm_status: AsyncStatus | None = None
191
+ self._trigger_info: TriggerInfo | None = None
185
192
  # For kickoff
186
- self._watchers: List[Callable] = []
187
- self._fly_status: Optional[WatchableAsyncStatus] = None
193
+ self._watchers: list[Callable] = []
194
+ self._fly_status: WatchableAsyncStatus | None = None
188
195
  self._fly_start: float
189
-
196
+ self._iterations_completed: int = 0
190
197
  self._intial_frame: int
191
198
  self._last_frame: int
192
199
  super().__init__(name)
@@ -215,28 +222,28 @@ class StandardDetector(
215
222
  )
216
223
  try:
217
224
  await signal.get_value()
218
- except NotImplementedError:
225
+ except NotImplementedError as e:
219
226
  raise Exception(
220
227
  f"config signal {signal.name} must be connected before it is "
221
228
  + "passed to the detector"
222
- )
229
+ ) from e
223
230
 
224
231
  @AsyncStatus.wrap
225
232
  async def unstage(self) -> None:
226
233
  # Stop data writing.
227
- await self.writer.close()
234
+ await asyncio.gather(self.writer.close(), self.controller.disarm())
228
235
 
229
- async def read_configuration(self) -> Dict[str, Reading]:
236
+ async def read_configuration(self) -> dict[str, Reading]:
230
237
  return await merge_gathered_dicts(sig.read() for sig in self._config_sigs)
231
238
 
232
- async def describe_configuration(self) -> Dict[str, DataKey]:
239
+ async def describe_configuration(self) -> dict[str, DataKey]:
233
240
  return await merge_gathered_dicts(sig.describe() for sig in self._config_sigs)
234
241
 
235
- async def read(self) -> Dict[str, Reading]:
242
+ async def read(self) -> dict[str, Reading]:
236
243
  # All data is in StreamResources, not Events, so nothing to output here
237
244
  return {}
238
245
 
239
- async def describe(self) -> Dict[str, DataKey]:
246
+ async def describe(self) -> dict[str, DataKey]:
240
247
  return self._describe
241
248
 
242
249
  @AsyncStatus.wrap
@@ -248,15 +255,15 @@ class StandardDetector(
248
255
  trigger=DetectorTrigger.internal,
249
256
  deadtime=None,
250
257
  livetime=None,
258
+ frame_timeout=None,
251
259
  )
252
260
  )
261
+ assert self._trigger_info
262
+ assert self._trigger_info.trigger is DetectorTrigger.internal
253
263
  # Arm the detector and wait for it to finish.
254
264
  indices_written = await self.writer.get_indices_written()
255
- written_status = await self.controller.arm(
256
- num=self._trigger_info.number,
257
- trigger=self._trigger_info.trigger,
258
- )
259
- await written_status
265
+ await self.controller.arm()
266
+ await self.controller.wait_for_idle()
260
267
  end_observation = indices_written + 1
261
268
 
262
269
  async for index in self.writer.observe_indices_written(
@@ -283,35 +290,35 @@ class StandardDetector(
283
290
  Args:
284
291
  value: TriggerInfo describing how to trigger the detector
285
292
  """
286
- self._trigger_info = value
287
293
  if value.trigger != DetectorTrigger.internal:
288
294
  assert (
289
295
  value.deadtime
290
296
  ), "Deadtime must be supplied when in externally triggered mode"
291
297
  if value.deadtime:
292
- required = self.controller.get_deadtime(self._trigger_info.livetime)
298
+ required = self.controller.get_deadtime(value.livetime)
293
299
  assert required <= value.deadtime, (
294
300
  f"Detector {self.controller} needs at least {required}s deadtime, "
295
301
  f"but trigger logic provides only {value.deadtime}s"
296
302
  )
303
+ self._trigger_info = value
297
304
  self._initial_frame = await self.writer.get_indices_written()
298
305
  self._last_frame = self._initial_frame + self._trigger_info.number
299
- self._arm_status = await self.controller.arm(
300
- num=self._trigger_info.number,
301
- trigger=self._trigger_info.trigger,
302
- exposure=self._trigger_info.livetime,
306
+ self._describe, _ = await asyncio.gather(
307
+ self.writer.open(value.multiplier), self.controller.prepare(value)
303
308
  )
304
- self._fly_start = time.monotonic()
305
- self._describe = await self.writer.open(value.multiplier)
309
+ if value.trigger != DetectorTrigger.internal:
310
+ await self.controller.arm()
311
+ self._fly_start = time.monotonic()
306
312
 
307
313
  @AsyncStatus.wrap
308
314
  async def kickoff(self):
309
- if not self._arm_status:
310
- raise Exception("Detector not armed!")
315
+ assert self._trigger_info, "Prepare must be called before kickoff!"
316
+ if self._iterations_completed >= self._trigger_info.iteration:
317
+ raise Exception(f"Kickoff called more than {self._trigger_info.iteration}")
318
+ self._iterations_completed += 1
311
319
 
312
320
  @WatchableAsyncStatus.wrap
313
321
  async def complete(self):
314
- assert self._arm_status, "Prepare not run"
315
322
  assert self._trigger_info
316
323
  async for index in self.writer.observe_indices_written(
317
324
  self._trigger_info.frame_timeout
@@ -332,12 +339,14 @@ class StandardDetector(
332
339
  )
333
340
  if index >= self._trigger_info.number:
334
341
  break
342
+ if self._iterations_completed == self._trigger_info.iteration:
343
+ await self.controller.wait_for_idle()
335
344
 
336
- async def describe_collect(self) -> Dict[str, DataKey]:
345
+ async def describe_collect(self) -> dict[str, DataKey]:
337
346
  return self._describe
338
347
 
339
348
  async def collect_asset_docs(
340
- self, index: Optional[int] = None
349
+ self, index: int | None = None
341
350
  ) -> AsyncIterator[StreamAsset]:
342
351
  # Collect stream datum documents for all indices written.
343
352
  # The index is optional, and provided for fly scans, however this needs to be
@@ -2,17 +2,12 @@
2
2
 
3
3
  import asyncio
4
4
  import sys
5
+ from collections.abc import Coroutine, Generator, Iterator
5
6
  from functools import cached_property
6
7
  from logging import LoggerAdapter, getLogger
7
8
  from typing import (
8
9
  Any,
9
- Coroutine,
10
- Dict,
11
- Generator,
12
- Iterator,
13
10
  Optional,
14
- Set,
15
- Tuple,
16
11
  TypeVar,
17
12
  )
18
13
 
@@ -32,7 +27,7 @@ class Device(HasName):
32
27
  #: The parent Device if it exists
33
28
  parent: Optional["Device"] = None
34
29
  # None if connect hasn't started, a Task if it has
35
- _connect_task: Optional[asyncio.Task] = None
30
+ _connect_task: asyncio.Task | None = None
36
31
 
37
32
  # Used to check if the previous connect was mocked,
38
33
  # if the next mock value differs then we fail
@@ -52,7 +47,7 @@ class Device(HasName):
52
47
  getLogger("ophyd_async.devices"), {"ophyd_async_device_name": self.name}
53
48
  )
54
49
 
55
- def children(self) -> Iterator[Tuple[str, "Device"]]:
50
+ def children(self) -> Iterator[tuple[str, "Device"]]:
56
51
  for attr_name, attr in self.__dict__.items():
57
52
  if attr_name != "parent" and isinstance(attr, Device):
58
53
  yield attr_name, attr
@@ -127,7 +122,7 @@ class Device(HasName):
127
122
  VT = TypeVar("VT", bound=Device)
128
123
 
129
124
 
130
- class DeviceVector(Dict[int, VT], Device):
125
+ class DeviceVector(dict[int, VT], Device):
131
126
  """
132
127
  Defines device components with indices.
133
128
 
@@ -136,7 +131,7 @@ class DeviceVector(Dict[int, VT], Device):
136
131
  :class:`~ophyd_async.epics.demo.DynamicSensorGroup`
137
132
  """
138
133
 
139
- def children(self) -> Generator[Tuple[str, Device], None, None]:
134
+ def children(self) -> Generator[tuple[str, Device], None, None]:
140
135
  for attr_name, attr in self.items():
141
136
  if isinstance(attr, Device):
142
137
  yield str(attr_name), attr
@@ -182,8 +177,8 @@ class DeviceCollector:
182
177
  self._connect = connect
183
178
  self._mock = mock
184
179
  self._timeout = timeout
185
- self._names_on_enter: Set[str] = set()
186
- self._objects_on_exit: Dict[str, Any] = {}
180
+ self._names_on_enter: set[str] = set()
181
+ self._objects_on_exit: dict[str, Any] = {}
187
182
 
188
183
  def _caller_locals(self):
189
184
  """Walk up until we find a stack frame that doesn't have us as self"""
@@ -195,6 +190,9 @@ class DeviceCollector:
195
190
  caller_frame = tb.tb_frame
196
191
  while caller_frame.f_locals.get("self", None) is self:
197
192
  caller_frame = caller_frame.f_back
193
+ assert (
194
+ caller_frame
195
+ ), "No previous frame to the one with self in it, this shouldn't happen"
198
196
  return caller_frame.f_locals
199
197
 
200
198
  def __enter__(self) -> "DeviceCollector":
@@ -207,7 +205,7 @@ class DeviceCollector:
207
205
 
208
206
  async def _on_exit(self) -> None:
209
207
  # Name and kick off connect for devices
210
- connect_coroutines: Dict[str, Coroutine] = {}
208
+ connect_coroutines: dict[str, Coroutine] = {}
211
209
  for name, obj in self._objects_on_exit.items():
212
210
  if name not in self._names_on_enter and isinstance(obj, Device):
213
211
  if self._set_name and not obj.name:
@@ -229,10 +227,10 @@ class DeviceCollector:
229
227
  self._objects_on_exit = self._caller_locals()
230
228
  try:
231
229
  fut = call_in_bluesky_event_loop(self._on_exit())
232
- except RuntimeError:
230
+ except RuntimeError as e:
233
231
  raise NotConnected(
234
232
  "Could not connect devices. Is the bluesky event loop running? See "
235
233
  "https://blueskyproject.io/ophyd-async/main/"
236
234
  "user/explanations/event-loop-choice.html for more info."
237
- )
235
+ ) from e
238
236
  return fut
@@ -1,5 +1,7 @@
1
+ from collections.abc import Callable, Generator, Sequence
1
2
  from enum import Enum
2
- from typing import Any, Callable, Dict, Generator, List, Optional, Sequence
3
+ from pathlib import Path
4
+ from typing import Any
3
5
 
4
6
  import numpy as np
5
7
  import numpy.typing as npt
@@ -7,6 +9,7 @@ import yaml
7
9
  from bluesky.plan_stubs import abs_set, wait
8
10
  from bluesky.protocols import Location
9
11
  from bluesky.utils import Msg
12
+ from pydantic import BaseModel
10
13
 
11
14
  from ._device import Device
12
15
  from ._signal import SignalRW
@@ -18,16 +21,22 @@ def ndarray_representer(dumper: yaml.Dumper, array: npt.NDArray[Any]) -> yaml.No
18
21
  )
19
22
 
20
23
 
24
+ def pydantic_model_abstraction_representer(
25
+ dumper: yaml.Dumper, model: BaseModel
26
+ ) -> yaml.Node:
27
+ return dumper.represent_data(model.model_dump(mode="python"))
28
+
29
+
21
30
  class OphydDumper(yaml.Dumper):
22
31
  def represent_data(self, data: Any) -> Any:
23
32
  if isinstance(data, Enum):
24
33
  return self.represent_data(data.value)
25
- return super(OphydDumper, self).represent_data(data)
34
+ return super().represent_data(data)
26
35
 
27
36
 
28
37
  def get_signal_values(
29
- signals: Dict[str, SignalRW[Any]], ignore: Optional[List[str]] = None
30
- ) -> Generator[Msg, Sequence[Location[Any]], Dict[str, Any]]:
38
+ signals: dict[str, SignalRW[Any]], ignore: list[str] | None = None
39
+ ) -> Generator[Msg, Sequence[Location[Any]], dict[str, Any]]:
31
40
  """Get signal values in bulk.
32
41
 
33
42
  Used as part of saving the signals of a device to a yaml file.
@@ -59,13 +68,10 @@ def get_signal_values(
59
68
  }
60
69
  selected_values = yield Msg("locate", *selected_signals.values())
61
70
 
62
- # TODO: investigate wrong type hints
63
- if isinstance(selected_values, dict):
64
- selected_values = [selected_values] # type: ignore
65
-
66
71
  assert selected_values is not None, "No signalRW's were able to be located"
67
72
  named_values = {
68
- key: value["setpoint"] for key, value in zip(selected_signals, selected_values)
73
+ key: value["setpoint"]
74
+ for key, value in zip(selected_signals, selected_values, strict=False)
69
75
  }
70
76
  # Ignored values place in with value None so we know which ones were ignored
71
77
  named_values.update({key: None for key in ignore})
@@ -73,8 +79,8 @@ def get_signal_values(
73
79
 
74
80
 
75
81
  def walk_rw_signals(
76
- device: Device, path_prefix: Optional[str] = ""
77
- ) -> Dict[str, SignalRW[Any]]:
82
+ device: Device, path_prefix: str | None = ""
83
+ ) -> dict[str, SignalRW[Any]]:
78
84
  """Retrieve all SignalRWs from a device.
79
85
 
80
86
  Stores retrieved signals with their dotted attribute paths in a dictionary. Used as
@@ -104,7 +110,7 @@ def walk_rw_signals(
104
110
  if not path_prefix:
105
111
  path_prefix = ""
106
112
 
107
- signals: Dict[str, SignalRW[Any]] = {}
113
+ signals: dict[str, SignalRW[Any]] = {}
108
114
  for attr_name, attr in device.children():
109
115
  dot_path = f"{path_prefix}{attr_name}"
110
116
  if type(attr) is SignalRW:
@@ -114,7 +120,7 @@ def walk_rw_signals(
114
120
  return signals
115
121
 
116
122
 
117
- def save_to_yaml(phases: Sequence[Dict[str, Any]], save_path: str) -> None:
123
+ def save_to_yaml(phases: Sequence[dict[str, Any]], save_path: str | Path) -> None:
118
124
  """Plan which serialises a phase or set of phases of SignalRWs to a yaml file.
119
125
 
120
126
  Parameters
@@ -134,12 +140,17 @@ def save_to_yaml(phases: Sequence[Dict[str, Any]], save_path: str) -> None:
134
140
  """
135
141
 
136
142
  yaml.add_representer(np.ndarray, ndarray_representer, Dumper=yaml.Dumper)
143
+ yaml.add_multi_representer(
144
+ BaseModel,
145
+ pydantic_model_abstraction_representer,
146
+ Dumper=yaml.Dumper,
147
+ )
137
148
 
138
149
  with open(save_path, "w") as file:
139
150
  yaml.dump(phases, file, Dumper=OphydDumper, default_flow_style=False)
140
151
 
141
152
 
142
- def load_from_yaml(save_path: str) -> Sequence[Dict[str, Any]]:
153
+ def load_from_yaml(save_path: str) -> Sequence[dict[str, Any]]:
143
154
  """Plan that returns a list of dicts with saved signal values from a yaml file.
144
155
 
145
156
  Parameters
@@ -152,12 +163,12 @@ def load_from_yaml(save_path: str) -> Sequence[Dict[str, Any]]:
152
163
  :func:`ophyd_async.core.save_to_yaml`
153
164
  :func:`ophyd_async.core.set_signal_values`
154
165
  """
155
- with open(save_path, "r") as file:
166
+ with open(save_path) as file:
156
167
  return yaml.full_load(file)
157
168
 
158
169
 
159
170
  def set_signal_values(
160
- signals: Dict[str, SignalRW[Any]], values: Sequence[Dict[str, Any]]
171
+ signals: dict[str, SignalRW[Any]], values: Sequence[dict[str, Any]]
161
172
  ) -> Generator[Msg, None, None]:
162
173
  """Maps signals from a yaml file into device signals.
163
174
 
@@ -217,7 +228,7 @@ def load_device(device: Device, path: str):
217
228
  yield from set_signal_values(signals_to_set, values)
218
229
 
219
230
 
220
- def all_at_once(values: Dict[str, Any]) -> Sequence[Dict[str, Any]]:
231
+ def all_at_once(values: dict[str, Any]) -> Sequence[dict[str, Any]]:
221
232
  """Sort all the values into a single phase so they are set all at once"""
222
233
  return [values]
223
234
 
@@ -225,8 +236,8 @@ def all_at_once(values: Dict[str, Any]) -> Sequence[Dict[str, Any]]:
225
236
  def save_device(
226
237
  device: Device,
227
238
  path: str,
228
- sorter: Callable[[Dict[str, Any]], Sequence[Dict[str, Any]]] = all_at_once,
229
- ignore: Optional[List[str]] = None,
239
+ sorter: Callable[[dict[str, Any]], Sequence[dict[str, Any]]] = all_at_once,
240
+ ignore: list[str] | None = None,
230
241
  ):
231
242
  """Plan that saves the state of all PV's on a device using a sorter.
232
243
 
@@ -1,7 +1,9 @@
1
1
  from abc import ABC, abstractmethod
2
- from typing import Dict, Generic, Sequence
2
+ from collections.abc import Sequence
3
+ from typing import Generic
3
4
 
4
- from bluesky.protocols import DataKey, Flyable, Preparable, Reading, Stageable
5
+ from bluesky.protocols import Flyable, Preparable, Reading, Stageable
6
+ from event_model import DataKey
5
7
 
6
8
  from ._device import Device
7
9
  from ._signal import SignalR
@@ -72,12 +74,12 @@ class StandardFlyer(
72
74
  async def complete(self) -> None:
73
75
  await self._trigger_logic.complete()
74
76
 
75
- async def describe_configuration(self) -> Dict[str, DataKey]:
77
+ async def describe_configuration(self) -> dict[str, DataKey]:
76
78
  return await merge_gathered_dicts(
77
79
  [sig.describe() for sig in self._configuration_signals]
78
80
  )
79
81
 
80
- async def read_configuration(self) -> Dict[str, Reading]:
82
+ async def read_configuration(self) -> dict[str, Reading]:
81
83
  return await merge_gathered_dicts(
82
84
  [sig.read() for sig in self._configuration_signals]
83
85
  )